diff --git a/apps/dashboard/.gitignore b/apps/dashboard/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/apps/dashboard/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md new file mode 100644 index 00000000..08b2fb49 --- /dev/null +++ b/apps/dashboard/README.md @@ -0,0 +1,252 @@ +# Consort · Agent Mission Control + +A local dashboard that turns a Consort session into a watchable process. It runs in two modes +over one UI: + +- **Live** — watch a **running** Consort build: one bubble per agent persona (working / on-deck / + flagging / waiting-on-you / idle), a one-line "what it's doing right now", handback routing + (issue → the agent that must fix it), % complete, and an optional per-agent cost panel. +- **Replay** — scrub a **finished, recorded** run: play/pause/step over the whole event log, with + the lifecycle topology graph, per-lane sub-workflows, per-turn transcripts, and the code each + turn produced. + +It is a **pure read-only observer**. It watches a project's `.consort/` state (live) or a recorded +corpus (replay); it never writes to a project and cannot affect a run. + +## Requirements + +- Node 20+ (the Consort toolchain already needs this) +- **Live mode:** a scaffolded Consort project on the **same machine** (this reads local files) +- **Replay mode:** a recorded corpus directory (see [Replay mode](#replay-mode)) + +## Install + +```bash +cd consort-dashboard +npm install +``` + +## Live mode + +Point the dashboard at a scaffolded Consort project, then start it. Run it in a second terminal +beside your `/design` or `/build` session. + +```bash +CONSORT_PROJECT_DIR=/absolute/path/to/your/stockflow npm run dev +# open http://localhost:3000 +``` + +Set a custom port with `PORT`: + +```bash +CONSORT_PROJECT_DIR=/absolute/path/to/your/stockflow PORT=3737 npm run dev +``` + +Or use the helper (it warns if the target has no `.consort/`): + +```bash +./run.sh /absolute/path/to/your/stockflow # defaults to port 3000 +PORT=3737 ./run.sh /absolute/path/to/your/stockflow # custom port +THEME=dark ./run.sh /absolute/path/to/your/stockflow # boot in dark mode +``` + +If `CONSORT_PROJECT_DIR` is unset it falls back to the current working directory, so you can `cd` +into a scaffolded Consort project and run the dashboard from there. + +### Dark mode + +The board ships light by default and has a dark theme built from the palette of Kevin's original +dashboard (warm near-black surfaces, tan text, the c1–c10 role hues). Two ways to choose: + +- **In-app toggle** — the ☀️/🌙 button in the header. The choice is remembered per browser + (`localStorage`), so it survives reloads and restarts. +- **Launch default** — `THEME=dark ./run.sh …` (or `?theme=dark` on the URL) boots the board dark, + useful for a pinned demo/projector. A stored toggle choice overrides the launch default. + +Theming is a single `data-theme` flip on `` over CSS custom properties (generated from +`lib/theme.ts`), so switching is instant and there is no flash of the wrong theme on load. We do +**not** follow the OS `prefers-color-scheme` — dark is always an explicit choice. + +### What live mode reads (the integration surface) + +Consort already emits everything this needs — **no hook or kit change required**: + +| Source | Used for | +|---|---| +| `.consort/agent-log.jsonl` (append-only JSONL event bus) | live per-agent state, "what it's doing", per-turn cost, the event ticker, the topology graph | +| `./scripts/lk lakebase-feature-status --json` (the project's `lk` wrapper) | test counts, richer story statuses, gate detail | +| `.consort/next.json` | blockers, waiting-on-human (open gates), resolver hints | + +Consort v0.3.7 renamed the artifact root `.sftdd/` → `.consort/`. The dashboard resolves +`.consort/` first and falls back to the legacy `.sftdd/`/`.tdd/` roots, so it watches both +pre- and post-rename projects unchanged. + +**The log is the source of truth.** It is append-only and cannot lie about the past; `next.json` +and the status CLI describe *now* and go stale — even at the live edge. The dashboard reconciles +their signals against the log and only ever lets a snapshot *advance* a story, never rewind one. +After that reconciliation the snapshot sources are trusted for exactly three things: **test +counts, richer story statuses, and gate detail.** + +## Replay mode + +Point the dashboard at a recorded corpus directory. A corpus is a finished run's +`turns/index.json` + `turns/-/{turn.json,transcript.md,files/**}` + +`recorded-artifacts/**` + `agent-log.jsonl` (+ `provenance.json`). + +```bash +CONSORT_CORPUS_DIR=/absolute/path/to/corpus npm run dev +# open http://localhost:3000 +``` + +The reference corpus ships with the Consort plugin. If you have the marketplace checkout, it is at +(v0.3.7 relocated it here from the old `examples/sftdd-scenarios/`): + +``` +~/.claude/plugins/marketplaces/databricks-solutions/examples/replay/corpora/stockflow-rerecord/ +``` + +(This is a two-sprint run — F1-stock-visibility then F6-split-tracking-code — which is why the +header shows a **feature switcher**.) + +For the **correspondence** view (the HIL↔orchestrator conversation folded into the event stream), +point at a corpus that ships `correspondence.jsonl` alongside its `agent-log.jsonl` — the +`stockflow-full` corpus carries both: + +``` +~/.claude/plugins/marketplaces/databricks-solutions/examples/replay/corpora/stockflow-full/ +``` + +A corpus with no `correspondence.jsonl` (like `stockflow-rerecord`) simply shows no correspondence +rows; everything else works unchanged. + +### Mode selection + +Mode is chosen from which env var is set: + +| Env | Result | +|---|---| +| `CONSORT_PROJECT_DIR` only | **live** | +| `CONSORT_CORPUS_DIR` only | **replay** | +| both set | header shows a **live / replay switch**; **live is the default** | +| neither, but a corpus is present | falls back to the corpus | + +You can also deep-link a mode with `?mode=replay` (or `?mode=live`) on the page URL, so a replay +board is linkable and screenshottable. + +A configured-but-unreadable corpus is reported with the specific defect (missing dir / missing +log / missing index) rather than silently dropping the mode switch. + +## Capability matrix + +The two modes are not a hard fork — one UI drives off declared **capabilities**, so a panel +degrades instead of disappearing. + +| Capability | Live | Replay | +|---|---|---| +| Timeline (fold events) | ✅ tail | ✅ full + scrub | +| Transport (scrub / play) | ⚠️ over history-so-far; no seek past now | ✅ | +| Liveness banners (waiting / escalation) | ✅ | ❌ (meaningless) | +| Feature % / story / gate detail | ✅ (`lk` CLI + log) | ✅ (recorded snapshots + log) | +| Artifact **paths** | ✅ | ✅ | +| Artifact **content** | ⚠️ at HEAD only | ✅ per-turn snapshot | +| Transcripts | ❌ | ✅ | +| Planning / backlog | ✅ (live `.consort/`) | ✅ (recorded) | +| Step outputs (per-lifecycle-step deliverables) | ⚠️ (planned; HEAD reads) | ✅ (`recorded-artifacts/`) | +| Correspondence (HIL↔orchestrator, folded into the timeline) | ❌ | ✅ (when the corpus ships `correspondence.jsonl`) | +| Test-count time-travel | ❌ (log carries too few `test_id`s; the bar hides when scrubbed) | ✅ (per-turn `test-list.json` snapshots) | + +**Drill-down differs by mode.** Clicking a recorded turn (replay) opens the transcript, produced +files and per-turn file content. In live mode there are no transcripts, so the panel shows an +`artifact.written` path read at the project's current HEAD, labelled "content at HEAD · +transcripts are replay-only". + +## Step outputs + +Every lifecycle node on the graph is clickable when it maps to deliverables (`STEP_OUTPUTS` in +`lib/topology.ts`); clicking opens a drill-down of what that step produced — plan's +proposals/estimates, design's guide + per-feature spec/db-design/architecture, the build cycle +files, deploy evidence, gate records. Read from `recorded-artifacts/` in replay and served over +`/api/step-outputs`, scoped to the board's current feature. Nodes with no durable output (e.g. +`shipped`) are not clickable. + +## Correspondence + +Consort records `correspondence.jsonl` — the HIL↔orchestrator conversation (kickoff, intake, +per-action progress, gate approvals), each exchange carrying pre-rendered markdown and an +`outcome`. Where `agent-log.jsonl` is the machine event bus, this is the *conversation*. The event +ticker **folds the two streams into one chronological timeline**: correspondence rows render with a +purple accent, a direction glyph (`→you` / `you→`), the exchange kind, and a completion badge +(**✓ done** for a validated action, **✓ approved** for a gate). Because a `progress` row fires at +action completion — and its `ordinal` is 1:1 with the log's turn ordinals — it is the +authoritative "this action finished" signal the agent-log's turn-boundary logging can lag on; +clicking a correspondence row that names a turn opens that turn's drill-down. The stream is +filtered to the playhead, so scrubbing rewinds the conversation too. + +## How agent state is derived (live) + +| Bubble | Signal | +|---|---| +| **working** | role has an open turn (`phase.start` not yet closed) | +| **on-deck** | role is the target of the last `handoff` but hasn't started | +| **issue** | role emitted `smell.flagged`/`concern.flagged`/… or a `next.json` blocker routes to it | +| **waiting** | a real HITL gate is open (unresolved `gate.surfaced` in the log / `next.json.open_gates`) | +| **idle** | none of the above | + +The dashboard surfaces two live "the run is paused on you" banners, both sourced from Consort's +own `.consort` files: a **gate** banner (a HITL design gate) and an **escalation** banner (a role +kicked a problem up to you — e.g. a GREEN verify failed). A third pause kind, Claude Code +**permission** prompts, is implemented but currently disabled +(`ENABLE_PERMISSION_BANNER = false` in `lib/consort.ts`) — transcript-based detection proved too +flaky to ship. The gate and escalation banners stay on and reliable. + +## Event stream + +The ticker under the board renders the event log **oldest-first, newest at the bottom** — it grows +downward like a terminal and auto-scrolls to follow the live edge. Scroll up to read history and it +holds position; return to the bottom and it resumes following. + +The header carries a **connection indicator**: `live` when polling is healthy, `reconnecting` when a +request fails, and `no update · Ns` once the poll has been quiet for more than a few seconds. The +last state matters on long live runs — a slow or hung `/api/state` (e.g. the `lk` status shell-out) +can no longer silently wedge the poll loop; requests time out, back off, and the badge shows the +board has gone stale instead of looking frozen-but-live. + +## Cost panel + +Toggle in the header: **show / hidden**. Hiding it drops both the per-bubble costs and the overall +cost bar. Costs come from each `turn.usage` event's `cost_usd`, summed per agent. + +## Time travel + +Scrubbing the transport re-folds the event log up to that point. Everything the log can support +is reconstructed at the scrubbed position — agents, costs, gates, stories, blockers, feature, +lane. The **one** thing that can't rewind in live mode is test *counts* (the log records only the +`test_id`s that had a `cycle.*` event), so the test bar hides rather than showing a misleading +current number. Replay rewinds counts too, from the corpus's per-turn `test-list.json` snapshots. + +The transport's "follow live" (`at === null`) is deliberately distinct from being pinned at the +last event: stepping off the end resumes following instead of freezing one event back. + +## Development + +```bash +npm run test # vitest (single run) +npm run test:watch # vitest (watch) +npm run build # next build — catches route-type errors tsc and vitest don't +``` + +Bugs on this project have consistently been found by **running the app**, not by the test suite — +drive `/api/state?at=` at several playheads and look at the real output. + +## Stack + +Next.js 15 (App Router) + React 19 + TypeScript, plain inline-CSS styling with a token layer in +`lib/theme.ts`. The event-log reducer (`lib/reducer.ts` + `lib/derive.ts`) is a pure fold shared +by both modes; sources live behind a `DashboardSource` interface (`lib/source.ts`, +`lib/sources/{live,replay}.ts`). + +## Roadmap + +Not yet shipped: a **static single-file export** (`npm run export`) reproducing a shareable, +offline replay artifact from the merged UI. See `../.tmp/dashboard-merge-plan.md` for the full +plan and history. diff --git a/apps/dashboard/app/AgentBubble.tsx b/apps/dashboard/app/AgentBubble.tsx new file mode 100644 index 00000000..b082852d --- /dev/null +++ b/apps/dashboard/app/AgentBubble.tsx @@ -0,0 +1,118 @@ +"use client"; + +import type { AgentState, AgentStatus } from "@/lib/types"; +import { radius } from "@/lib/theme"; + +// Status → color/icon, adapted from pipeline-app's StepNode STATUS_CONFIG. +const CFG: Record = { + working: { bg: "var(--status-accent-tint-soft)", border: "var(--status-accent)", text: "var(--status-accent)", label: "working" }, + "on-deck": { bg: "var(--status-on-deck-tint)", border: "var(--status-on-deck)", text: "var(--status-on-deck)", label: "on deck" }, + issue: { bg: "var(--status-critical-tint-soft)", border: "var(--status-critical)", text: "var(--status-critical)", label: "issue" }, + waiting: { bg: "var(--status-gate-tint-soft)", border: "var(--status-gate)", text: "var(--status-gate)", label: "waiting on you" }, + idle: { bg: "var(--surface-muted)", border: "var(--border-default)", text: "var(--text-faint)", label: "idle" }, +}; + +function Spinner({ color }: { color: string }) { + return ( + + + + + ); +} + +const ICON: Record = { + working: "", + "on-deck": "→", + issue: "⚠", + waiting: "⏸", + idle: "·", +}; + +// "3m", "45s" — how long the current open turn has been running. +function fmtElapsed(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + return s % 60 >= 30 && m < 10 ? `${m}m${s % 60}s` : `${m}m`; +} + +export function AgentBubble({ agent, showCost }: { agent: AgentState; showCost: boolean }) { + const cfg = CFG[agent.status]; + const active = agent.status === "working"; + const pulse = agent.status === "waiting" || agent.status === "issue"; + + // Consort only logs at turn boundaries, so a long open turn looks frozen. Show how long the + // turn's been running + a liveness dot: green "live" = a session is writing (working, just a + // long turn); amber "quiet" = no recent transcript write (may be stalled — worth a look). + const startMs = agent.turnStartTs ? Date.parse(agent.turnStartTs) : NaN; + const elapsed = active && !Number.isNaN(startMs) ? fmtElapsed(Math.max(0, Date.now() - startMs)) : null; + const live = agent.sessionActive; + + return ( +
+
+
+ {active ? : ICON[agent.status]} +
+
+
+ {agent.role} +
+
+ {cfg.label} + {agent.model ? · {agent.model} : null} +
+
+
+ +
+ {agent.status !== "idle" && agent.work ? agent.work : agent.status === "idle" ? : null} + {agent.story ?
{agent.story}
: null} +
+ + {active && elapsed ? ( +
+ + + working {elapsed} + {live === false ? " · quiet" : live === true ? " · live" : ""} + +
+ ) : null} + +
+ {agent.turns} turn{agent.turns === 1 ? "" : "s"} + {showCost && agent.cost > 0 ? ${agent.cost.toFixed(2)} : null} +
+
+ ); +} diff --git a/apps/dashboard/app/BacklogPanel.tsx b/apps/dashboard/app/BacklogPanel.tsx new file mode 100644 index 00000000..287a7af0 --- /dev/null +++ b/apps/dashboard/app/BacklogPanel.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { font, radius } from "@/lib/theme"; +import type { Planning } from "@/lib/types"; + +// The planning / backlog view: what was proposed, how it was sized, what got committed to each +// sprint, and whether the plan gate was approved. Ported from Kevin's `load_planning` output. +// +// Fetched once from /api/planning (mode-aware), not folded into the board: planning is a static +// snapshot of the run's start, not timeline state, so it does not rewind with the transport. +// Gated by the caller on the `planningBacklog` capability — a source without planning artifacts +// answers 409 and the panel renders nothing. + +/** Build /api/planning?mode=… — mode omitted when null so the server keeps its default. */ +export function planningUrl(mode: "live" | "replay" | null): string { + return mode === null ? "/api/planning" : `/api/planning?mode=${mode}`; +} + +const SIZE_HELP: Record = { + S: "small", + M: "medium", + L: "large", + XL: "extra large", +}; + +function SizeChip({ size }: { size: string | null }) { + if (!size) return null; + return ( + + {size} + + ); +} + +function CommittedChip() { + return ( + + committed + + ); +} + +export function BacklogPanel({ mode }: { mode: "live" | "replay" | null }) { + const [planning, setPlanning] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + setPlanning(null); + setError(null); + (async () => { + try { + const r = await fetch(planningUrl(mode), { cache: "no-store" }); + // Tolerate a non-JSON / empty / non-object body (a proxy 502, a bare `null`): parse + // defensively and only read `.error` off an actual object, else fall back to the HTTP + // status. The old `body.error` threw "Cannot read properties of null" and masked the + // real status when the server returned a JSON literal `null` on a non-ok response. + const body = await r.json().catch(() => null); + if (!live) return; + const bodyErr = body && typeof body === "object" && !Array.isArray(body) ? (body as { error?: unknown }).error : null; + if (!r.ok) { + setError(typeof bodyErr === "string" ? bodyErr : `HTTP ${r.status}`); + return; + } + setPlanning(body as Planning); + } catch (e) { + if (live) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + live = false; + }; + }, [mode]); + + if (error) { + return
Backlog unavailable — {error}
; + } + if (!planning) { + return
Loading backlog…
; + } + + // A run with no planning artifacts at all (neither proposals nor sprints) has nothing to show. + if (planning.candidates.length === 0 && planning.sprints.length === 0) { + return
No planning artifacts recorded for this run.
; + } + + return ( +
+ {/* --- Sprints: what was committed, and the plan gate. --- */} + {planning.sprints.length > 0 ? ( +
+
+ Sprints +
+ {planning.sprints.map((sp) => ( +
+
+ {sp.sprint} + {sp.isReplan ? ( + + re-plan + + ) : null} + + plan gate + + +
+ {sp.features.map((f) => ( +
+ + {f.id} + {f.title ? {f.title} : null} +
+ ))} +
+ ))} +
+ ) : null} + + {/* --- Candidates: the proposal pool, in proposal order, with sizes and commit state. --- */} + {planning.candidates.length > 0 ? ( +
+
+ {/* Count the committed CANDIDATES actually shown here, not planning.committed (which + counts sprint ids). A committed feature that isn't in estimates.json has no + candidate row and no chip, so using planning.committed.length would read + "2 of 7 committed" while only one chip appears. This number always matches the + chips below it. */} + Proposed features · {planning.candidates.filter((c) => c.committed).length} of {planning.candidates.length} committed +
+ {planning.candidates.map((c) => ( +
+
+ + {c.id} + {c.title ? {c.title} : null} + {c.committed ? : null} +
+ {c.ask ?
{c.ask}
: null} + {c.rationale ?
{c.rationale}
: null} +
+ ))} +
+ ) : null} +
+ ); +} + +function PlanGate({ status: s, approver, approvedAt }: { status: string | null; approver: string | null; approvedAt: string | null }) { + if (!s) { + return ; + } + const approved = s === "approved"; + const title = [s, approver ? `by ${approver}` : null, approvedAt ? `at ${approvedAt}` : null].filter(Boolean).join(" · "); + return ( + + {s} + + ); +} diff --git a/apps/dashboard/app/DrilldownPanel.tsx b/apps/dashboard/app/DrilldownPanel.tsx new file mode 100644 index 00000000..daf830bb --- /dev/null +++ b/apps/dashboard/app/DrilldownPanel.tsx @@ -0,0 +1,626 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { nodeById } from "@/lib/topology"; +import { colorForRole, font, radius } from "@/lib/theme"; +import type { ArtifactContent, StepOutputAsset, StepOutputs } from "@/lib/types"; + +// The ONE drill-down surface. Everything the board lets you click — an event-stream row that begins +// a recorded turn, an event row that names a produced artifact, or a lifecycle node on the graph — +// opens THIS panel. Before, three separate panels (TurnPanel / ArtifactPanel / StepOutputsPanel) +// answered three phrasings of the same question ("what did this produce, and what went into it?") +// with three shapes and three open-states; a viewer couldn't tell they were the same idea. This +// collapses them into one component behind a tagged-union target, sharing one shell, one file list, +// and one content view — the "click anything → one panel" the merge was missing. +// +// It stays capability-honest: a turn target shows the transcript+files a recorded corpus has; a +// live artifact target shows one file at HEAD and SAYS it's HEAD (no transcript live); a step +// target shows a lifecycle step's recorded deliverables. The page gates which targets are offered +// (by capability), so this never renders an affordance the source can't satisfy. + +export type DrilldownTarget = + | { kind: "turn"; ord: number } + // Live's shallower drill-down: a produced file, read at the project's current HEAD. + | { kind: "artifact"; path: string } + // A lifecycle step's deliverables. Timeline-independent (a recorded artifact is the same at every + // playhead), which is why the page keeps it open across a scrub. NOTE: the feature it's scoped to + // is deliberately NOT part of the target — it's passed to the panel LIVE (see `feature` below), so + // switching the FeatureSwitcher (which does not close a step target) re-scopes the deliverables + // instead of leaving them frozen at the feature that was current when the node was clicked. + | { kind: "step"; node: string }; + +/** + * `/api/turn/` with an optional mode and file. Built through URLSearchParams so `mode` is + * simply omitted when null rather than sent as the string "null". Exported for tests + reuse. + */ +export function turnUrl(ord: number, mode: "live" | "replay" | null, file?: string): string { + const q = new URLSearchParams(); + if (mode !== null) q.set("mode", mode); + if (file !== undefined) q.set("file", file); + return q.size > 0 ? `/api/turn/${ord}?${q}` : `/api/turn/${ord}`; +} + +/** `/api/artifact?path=…&mode=…` — mode omitted when null so the server keeps its default. */ +export function artifactUrl(path: string, mode: "live" | "replay" | null): string { + const q = new URLSearchParams({ path }); + if (mode !== null) q.set("mode", mode); + return `/api/artifact?${q}`; +} + +/** `/api/step-outputs` list URL for a node, scoped to a feature, with an optional mode. */ +function stepListUrl(node: string, feature: string | null, mode: "live" | "replay" | null): string { + const q = new URLSearchParams({ node }); + if (feature) q.set("feature", feature); + if (mode !== null) q.set("mode", mode); + return `/api/step-outputs?${q}`; +} + +/** `/api/step-outputs` content URL for one asset path, with an optional mode. */ +function stepContentUrl(path: string, mode: "live" | "replay" | null): string { + const q = new URLSearchParams({ path }); + if (mode !== null) q.set("mode", mode); + return `/api/step-outputs?${q}`; +} + +// The one entry point. Dispatches to the body for the target kind; each body owns its own fetches +// (a turn, a file, a step-output list are genuinely different requests), but they all render inside +// the same shell with the same file-row and content primitives, so the surface reads as one panel. +export function DrilldownPanel({ + target, + mode, + feature, + onClose, +}: { + target: DrilldownTarget; + mode: "live" | "replay" | null; + // The board's CURRENT feature (the FeatureSwitcher pin, or the playhead's feature). Passed live + // rather than baked into a step target, so switching the pin re-scopes an open step drill-down. + // Only step targets read it. + feature: string | null; + onClose: () => void; +}) { + switch (target.kind) { + case "turn": + return ; + case "artifact": + return ; + case "step": + return ; + } +} + +// --- shared shell + primitives --------------------------------------------------------------- + +// The card + left accent rail + a header row (caller-supplied content, left of an always-present +// close button) + a padded body. One shell for every kind, so the surface is visually one thing. +function PanelShell({ + accent, + header, + onClose, + children, +}: { + accent: string; + header: React.ReactNode; + onClose: () => void; + children: React.ReactNode; +}) { + return ( +
+
+ {header} + +
+
{children}
+
+ ); +} + +const HEAD_LABEL: React.CSSProperties = { fontSize: "0.68rem", fontWeight: 700, color: "var(--text-faint)", letterSpacing: "0.05em" }; +const CHIP: React.CSSProperties = { fontSize: "0.68rem", color: "var(--text-muted)", background: "var(--surface-inset)", border: `1px solid var(--border-default)`, borderRadius: radius.chip, padding: "1px 6px" }; + +// A file/asset row: a code/artifact (or deleted) badge + a path, optionally with a trailing muted +// sub-path. Clickable when `onSelect` is given (a deleted file has no content to open, so it +// renders as a static row). Shared by the turn Files tab and the step-outputs list, which were +// near-identical before. +function FileRow({ + badge, + badgeColor, + label, + sub, + strike, + selected, + onSelect, +}: { + badge: string; + badgeColor: string; + label: string; + sub?: string; + strike?: boolean; + selected?: boolean; + onSelect?: () => void; +}) { + const labelSpan = ( + + {label} + {sub ? · {sub} : null} + + ); + const badgeSpan = {badge}; + if (!onSelect) { + return ( +
+ {badgeSpan} + {labelSpan} +
+ ); + } + return ( + + ); +} + +// The content pane for a selected file: its text, or the reason it can't be shown (gone at HEAD, +// too large, binary, not captured), or a loading / nothing-selected line. The reason IS +// information — a live artifact can legitimately no longer exist — so it's named, never blanked. +function ContentView({ + file, + idle, + loadingName, +}: { + // undefined = nothing selected; null = selected but still loading; else the fetched content. + file: { content: string | null; reason?: string | null } | null | undefined; + idle: string; + loadingName: string | null; +}) { + if (file === undefined) return
{idle}
; + if (file === null) return
Loading {loadingName}…
; + if (file.content === null) return
{file.reason ?? "(no content)"}
; + return
{file.content}
; +} + +function Pre({ children }: { children: React.ReactNode }) { + return ( +
+      {children}
+    
+ ); +} + +function Section({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ); +} + +// --- turn body (replay: transcript + per-turn produced/deleted files) -------------------------- + +type TurnKind = "code" | "artifact"; + +// Most fields optional because the corpus's turn.json genuinely omits them (mode on 36/126, etc.). +interface TurnPayload { + ordinal: number; + step: number; + label: string; + kind: string; + role?: string | null; + mode?: string | null; + story?: string | null; + ac?: string | null; + produced: { path: string; kind: TurnKind }[]; + deleted: string[]; + transcript: { prompt: string; tools: string[]; reasoning: string } | null; + transcriptSummary: { role?: string; model?: string; toolCount?: number; finalTextChars?: number } | null; +} + +interface FilePayload { + path: string; + kind: TurnKind; + content: string | null; + reason: string | null; +} + +type Tab = "transcript" | "files"; + +function fileCount(t: TurnPayload): number { + return t.produced.length + t.deleted.length; +} + +function TurnBody({ ord, mode, onClose }: { ord: number; mode: "live" | "replay" | null; onClose: () => void }) { + const [turn, setTurn] = useState(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState("transcript"); + const [selected, setSelected] = useState(null); + const [file, setFile] = useState(null); + + // Reset on ordinal change, so opening a second turn never shows the first while the new fetch is + // in flight. + useEffect(() => { + let live = true; + setTurn(null); + setError(null); + setSelected(null); + setFile(null); + (async () => { + try { + const r = await fetch(turnUrl(ord, mode), { cache: "no-store" }); + const body = await r.json(); + if (!live) return; + if (!r.ok) { + setError(body.error ?? `HTTP ${r.status}`); + return; + } + const t = body as TurnPayload; + setTurn(t); + // Land on whichever tab has something: a gate turn has no transcript, several produce + // nothing. Opening on an empty pane reads as broken. + setTab(t.transcript ? "transcript" : fileCount(t) > 0 ? "files" : "transcript"); + } catch (e) { + if (live) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + live = false; + }; + }, [ord, mode]); + + // Selected file's content — separate effect so switching files doesn't refetch the turn. + useEffect(() => { + if (selected === null) { + setFile(null); + return; + } + let live = true; + setFile(null); + (async () => { + try { + const r = await fetch(turnUrl(ord, mode, selected), { cache: "no-store" }); + const body = await r.json(); + if (live) setFile(r.ok ? (body as FilePayload) : { path: selected, kind: "artifact", content: null, reason: body.error ?? `HTTP ${r.status}` }); + } catch (e) { + if (live) setFile({ path: selected, kind: "artifact", content: null, reason: e instanceof Error ? e.message : String(e) }); + } + })(); + return () => { + live = false; + }; + }, [ord, mode, selected]); + + const roleColor = turn?.role ? colorForRole(turn.role) : "var(--border-strong)"; + + const header = ( + <> + TURN {ord} + {turn?.role ?? turn?.label ?? (error ? "—" : "loading…")} + {/* mode/story are alternatives (most turns carry one), ac narrows a story turn further. */} + {[turn?.mode, turn?.story, turn?.ac].filter(Boolean).map((chip) => ( + + {chip} + + ))} + {turn && turn.kind !== "invoke-role" ? ( + + {turn.kind} + + ) : null} + {turn?.transcriptSummary?.model ? · {turn.transcriptSummary.model} : null} + + ); + + return ( + + {error ? ( +
{error}
+ ) : !turn ? ( +
Loading turn {ord}…
+ ) : ( + <> +
+ setTab("transcript")} disabled={!turn.transcript}> + Transcript + + setTab("files")} disabled={fileCount(turn) === 0}> + Files {fileCount(turn) > 0 ? `(${fileCount(turn)})` : ""} + +
+ + {tab === "transcript" ? ( + + ) : ( +
+
+ {turn.produced.map((p) => ( + setSelected(p.path === selected ? null : p.path)} + /> + ))} + {turn.deleted.map((d) => ( + + ))} +
+ +
+ )} + + )} +
+ ); +} + +function TabButton({ active, onClick, disabled, children }: { active: boolean; onClick: () => void; disabled?: boolean; children: React.ReactNode }) { + return ( + + ); +} + +function TranscriptView({ turn }: { turn: TurnPayload }) { + if (!turn.transcript) { + return ( +
+ {turn.kind === "invoke-role" ? "This turn recorded no transcript." : `A ${turn.kind} step — the orchestrator's own action, so there is no agent transcript.`} +
+ ); + } + const { prompt, tools, reasoning } = turn.transcript; + return ( +
+
+
{prompt || "(empty)"}
+
+ {tools.length > 0 ? ( +
+
+ {tools.map((t, i) => ( +
+ {t} +
+ ))} +
+
+ ) : null} + {reasoning ? ( +
+
{reasoning}
+
+ ) : null} +
+ ); +} + +// --- artifact body (live: one produced file, read at HEAD) ------------------------------------- + +function ArtifactBody({ path, mode, onClose }: { path: string; mode: "live" | "replay" | null; onClose: () => void }) { + const [artifact, setArtifact] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + setArtifact(null); + setError(null); + (async () => { + try { + const r = await fetch(artifactUrl(path, mode), { cache: "no-store" }); + const body = await r.json(); + if (!live) return; + if (!r.ok) { + setError(body?.error ?? `HTTP ${r.status}`); + return; + } + setArtifact(body as ArtifactContent); + } catch (e) { + if (live) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + live = false; + }; + }, [path, mode]); + + const header = ( + <> + ARTIFACT + + {path} + + {artifact ? {artifact.kind} : null} + {/* The honesty label: this is HEAD, and there is no transcript here. Said up front, so a + viewer never mistakes a live artifact view for replay's per-turn snapshot. */} + + content at HEAD · transcripts are replay-only + + + ); + + return ( + + {error ? ( +
{error}
+ ) : !artifact ? ( +
Loading {path}…
+ ) : ( + + )} +
+ ); +} + +// --- step body (a lifecycle step's recorded deliverables) -------------------------------------- + +function StepBody({ node, feature, mode, onClose }: { node: string; feature: string | null; mode: "live" | "replay" | null; onClose: () => void }) { + const [outputs, setOutputs] = useState(null); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); + const [file, setFile] = useState(null); + + const label = nodeById(node)?.label ?? node; + + // Re-fetch the list when node OR feature changes: switching the pinned feature must re-scope the + // per-feature deliverables. + useEffect(() => { + let live = true; + setOutputs(null); + setError(null); + setSelected(null); + setFile(null); + (async () => { + try { + const r = await fetch(stepListUrl(node, feature, mode), { cache: "no-store" }); + const body = await r.json(); + if (!live) return; + if (!r.ok) { + setError(body.error ?? `HTTP ${r.status}`); + return; + } + setOutputs(body as StepOutputs); + } catch (e) { + if (live) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + live = false; + }; + }, [node, feature, mode]); + + useEffect(() => { + if (selected === null) { + setFile(null); + return; + } + let live = true; + setFile(null); + (async () => { + try { + const r = await fetch(stepContentUrl(selected, mode), { cache: "no-store" }); + const body = await r.json(); + if (live) setFile(r.ok ? (body as ArtifactContent) : { path: selected, kind: "artifact", content: null, reason: body.error ?? `HTTP ${r.status}` }); + } catch (e) { + if (live) setFile({ path: selected, kind: "artifact", content: null, reason: e instanceof Error ? e.message : String(e) }); + } + })(); + return () => { + live = false; + }; + // `node`/`feature` are in the deps for explicitness: when either changes the list effect above + // already resets `selected` to null (which re-runs this and clears the file), but naming them + // here makes the re-scope correctness self-evident instead of an implicit cross-effect ordering, + // and matches TurnBody's content effect (which keys on its `ord`). + }, [selected, node, feature, mode]); + + const assets: StepOutputAsset[] = outputs?.assets ?? []; + + const header = ( + <> + STEP OUTPUTS + {label} + {outputs?.feature ? {outputs.feature} : null} + + ); + + return ( + + {error ? ( +
{error}
+ ) : !outputs ? ( +
Loading {label} outputs…
+ ) : assets.length === 0 ? ( +
No recorded deliverables for this step{feature ? ` in ${feature}` : ""}.
+ ) : ( +
+
+ {assets.map((a) => ( + setSelected(a.path === selected ? null : a.path)} + /> + ))} +
+ +
+ )} +
+ ); +} diff --git a/apps/dashboard/app/LaneGraph.tsx b/apps/dashboard/app/LaneGraph.tsx new file mode 100644 index 00000000..1d311834 --- /dev/null +++ b/apps/dashboard/app/LaneGraph.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { useState } from "react"; +import { + LANE_IDS, + WORKFLOW, + type BackEdge, + type Lane, + type LaneId, + type LaneStep, +} from "@/lib/topology"; +import { colorForRole, font, radius } from "@/lib/theme"; +import type { DashboardState } from "@/lib/types"; + +// The per-lane inter-agent sub-workflows (Kevin's Figure 2) — what happens *inside* each +// lifecycle node the top-level WorkflowGraph shows as one box. +// +// Layout: the lane the playhead is in renders as a full graph; the other two collapse to a +// one-line summary you can click to expand. Three full graphs would cost ~3x WorkflowGraph's +// vertical space for two lanes you are usually not looking at. +// +// Everything derives from the folded state (`topology.laneSteps` / `laneCurrent`), so this +// works identically live and scrubbed back. Two facts about that data shape this component: +// +// 1. Gate steps NEVER light from events (`match: null` — human-decided, out of band). So a +// gate's state comes from the run's gate list, not from laneSteps. Rendering them off the +// step data alone would draw every gate permanently pending. +// 2. 52% of playhead positions light no lane step at all (measured on the 421-event corpus), +// so `laneCurrent` is frequently null. "Nothing lit" is the common case, not an error — +// the lane still shows its reached steps, just with no pulsing one. + +const STEP_W = 104; +const STEP_H = 46; +const GAP = 30; +const PAD = 14; +const BACK_LANE_H = 34; // vertical room under the row for back-edges + +// Which lifecycle node each lane sits inside, and which node's arrival proves the lane is +// finished. The lane's own step predicates cannot answer either question: `b-perm` only lights +// on a supersession (so a clean run never reaches every build step), and no plan step matches +// `breakdown` (so a feature's planning can complete without lighting one). The lifecycle nodes +// are the honest signal, and this is the single place that mapping lives. +const LANE_NODE: Record = { + plan: { own: "plan", after: ["design", "build"] }, + design: { own: "design", after: ["build", "deploy"] }, + build: { own: "build", after: ["deploy"] }, +}; + +// The gate each lane's terminal gate step reflects. Lane gates are human-decided and never +// appear in laneSteps, so their status comes from state.gates. +const LANE_STEP_GATE: Record = { + "p-gate": "plan", + "d-gate": "spec", +}; + +type StepState = "done" | "current" | "pending" | "gate-open" | "gate-approved"; + +export function LaneGraph({ state }: { state: DashboardState }) { + const current = state.topology.laneCurrent; + // The lane to expand by default: where the playhead is, else the furthest lane the run has + // entered, so a paused or finished run still shows something substantive rather than Plan. + const reached = LANE_IDS.filter((l) => (state.topology.laneSteps[l] ?? []).length > 0); + // `laneCurrent.lane` is typed `string` (DashboardState is the wire format, and a replay + // source in Phase 2 may not share this vocabulary), so validate rather than cast: an + // unrecognised name used to match no panel and silently collapse all three lanes. + const currentLane = LANE_IDS.find((l) => l === current?.lane) ?? null; + const defaultOpen = currentLane ?? reached[reached.length - 1] ?? "plan"; + const [open, setOpen] = useState(null); + // `open` is an explicit user choice; until they make one, follow the playhead. This means + // the expanded lane tracks the run while it moves, but stops fighting the user once they + // have clicked — a controlled-with-a-default pattern, not a stale copy of derived state. + const expanded = open ?? defaultOpen; + + return ( +
+ {LANE_IDS.map((laneId) => { + const lane = WORKFLOW.lanes[laneId]; + const done = new Set(state.topology.laneSteps[laneId] ?? []); + const isExpanded = laneId === expanded; + return ( + setOpen(isExpanded ? null : laneId)} + state={state} + /> + ); + })} +
+ ); +} + +function LanePanel({ + laneId, + lane, + done, + currentStep, + expanded, + onToggle, + state, +}: { + laneId: LaneId; + lane: Lane; + done: Set; + currentStep: string | null; + expanded: boolean; + onToggle: () => void; + state: DashboardState; +}) { + // Gates are excluded from the ratio: they never light from events, so counting them would + // cap every lane below 100% forever. + const lightable = lane.steps.filter((s) => s.match !== null); + const reached = lightable.filter((s) => done.has(s.id)).length; + const active = currentStep !== null; + + // Lane status takes the LIFECYCLE as its sole authority. The lane's own lit-step count is + // NOT evidence about completion in either direction, and both directions were shipped bugs: + // + // - A lane can finish without lighting every step (`b-perm` only lights on a supersession) + // or even ANY step (no plan predicate matches `breakdown`, the sole plan phase attributed + // to a named feature). Judging by steps alone printed "0/3 steps · not started" directly + // beneath a green Plan node in the lifecycle graph. + // - Conversely, a later node being reached does NOT mean this lane is done with its own + // work — a back-edge can send the run around again. + // + // The tempting middle rule — "complete only once every step is lit" — was measured across + // every prefix fold of both real logs and REFUTED: on the shipped end of the corpus the plan + // lane sits at 1/3 (0/3 on the live log, where `p-req` never lights at all), so that rule + // labels a shipped feature's planning "in progress". Lit-step counts cannot tell "finished, + // some steps never applicable" apart from "still going" — only the lifecycle can. + // + // So mid-flight is "the lifecycle is still inside this lane's own node", which is exactly + // what `activeNode` means. Measured over all 421-event corpus and 380-event live playheads, + // this never once called a lane complete whose own node had not been passed. + const passed = new Set(state.topology.passedNodes); + const nodes = LANE_NODE[laneId]; + const entered = done.size > 0 || passed.has(nodes.own) || nodes.after.some((n) => passed.has(n)); + const movedOn = nodes.after.some((n) => passed.has(n)) || (laneId === "build" && state.lane === "complete"); + const inOwnNode = state.topology.activeNode === nodes.own; + const complete = !active && !inOwnNode && movedOn; + + const statusLabel = active + ? "active" + : complete + ? "complete" + : entered + ? "in progress" + : "not started"; + const statusColor = active ? "var(--status-accent-text)" : complete ? "var(--status-good-text)" : "var(--text-faint)"; + + return ( +
+ + + {expanded ? ( +
+
{lane.title}
+ +
+ ) : null} +
+ ); +} + +function gateTintFor(step: LaneStep, state: DashboardState): string { + const gateName = LANE_STEP_GATE[step.id]; + if (!gateName) return "var(--border-strong)"; + const g = state.gates.find((x) => x.name === gateName); + if (!g) return "var(--border-strong)"; + return g.status === "approved" ? "var(--status-good)" : "var(--status-gate)"; +} + +// --------------------------------------------------------------------------- the graph + +function LaneSvg({ + lane, + done, + currentStep, + state, +}: { + lane: Lane; + done: Set; + currentStep: string | null; + state: DashboardState; +}) { + // One row, left to right, in declared order. Back-edges arc underneath. + const xs = new Map(); + lane.steps.forEach((s, i) => xs.set(s.id, PAD + i * (STEP_W + GAP))); + const width = PAD * 2 + lane.steps.length * STEP_W + (lane.steps.length - 1) * GAP; + const hasBack = lane.backEdges.length > 0; + const height = PAD * 2 + STEP_H + (hasBack ? BACK_LANE_H + lane.backEdges.length * 9 : 0); + const rowY = PAD; + const midY = rowY + STEP_H / 2; + + return ( +
+ + + + + + + + + + + + + + {lane.edges.map(([from, to]) => { + const a = xs.get(from); + const b = xs.get(to); + if (a === undefined || b === undefined) return null; + const isDone = done.has(from) && (done.has(to) || to === currentStep); + // A backward happy-path edge (build's review → red closes the cycle) arcs under. + if (b < a) { + const y = midY + STEP_H / 2 + 12; + return ( + ${to}`} + d={`M ${a + STEP_W / 2} ${midY + STEP_H / 2} V ${y} H ${b + STEP_W / 2} V ${midY + STEP_H / 2}`} + fill="none" + style={{ stroke: isDone ? "var(--status-good)" : "var(--border-strong)" }} + strokeWidth={1.4} + strokeDasharray="4 3" + markerEnd={isDone ? "url(#lg-arrow-done)" : "url(#lg-arrow)"} + opacity={0.8} + /> + ); + } + return ( + ${to}`} + x1={a + STEP_W} + y1={midY} + x2={b - 4} + y2={midY} + style={{ stroke: isDone ? "var(--status-good)" : "var(--border-strong)" }} + strokeWidth={isDone ? 2 : 1.4} + markerEnd={isDone ? "url(#lg-arrow-done)" : "url(#lg-arrow)"} + /> + ); + })} + + {lane.backEdges.map((be, i) => ( + ")} be={be} xs={xs} midY={midY} depth={i} /> + ))} + + {lane.steps.map((s) => ( + + ))} + +
+ ); +} + +function stepState( + s: LaneStep, + done: Set, + currentStep: string | null, + state: DashboardState, +): StepState { + if (s.id === currentStep) return "current"; + if (done.has(s.id)) return "done"; + // Gates never light from events; take their state from the run's gate list so a cleared + // gate reads as cleared instead of pending forever. + if (s.gate && s.match === null) { + const gateName = LANE_STEP_GATE[s.id]; + const g = gateName ? state.gates.find((x) => x.name === gateName) : undefined; + if (g?.status === "approved") return "gate-approved"; + if (g) return "gate-open"; + } + return "pending"; +} + +// A fail/side path: arcs below the row, labelled, in warning amber so it never reads as the +// happy path. `depth` staggers concentric arcs so multiple back-edges don't overlap. +function BackEdgeArc({ + be, + xs, + midY, + depth, +}: { + be: BackEdge; + xs: Map; + midY: number; + depth: number; +}) { + const [from, to, label] = be; + const a = xs.get(from); + const b = xs.get(to); + if (a === undefined || b === undefined) return null; + const y = midY + STEP_H / 2 + 16 + depth * 9; + const ax = a + STEP_W / 2; + const bx = b + STEP_W / 2; + return ( + + + + {label} + + + ); +} + +function StepBox({ step, x, y, state }: { step: LaneStep; x: number; y: number; state: StepState }) { + const isGate = step.gate === true; + + const stroke = + state === "current" + ? step.role + ? colorForRole(step.role) + : "var(--status-accent)" + : state === "done" || state === "gate-approved" + ? "var(--status-good)" + : state === "gate-open" + ? "var(--status-gate)" + : step.branch + ? "var(--status-warning-soft)" + : "var(--border-default)"; + + const fill = + state === "current" + ? "var(--status-accent-tint)" + : state === "done" || state === "gate-approved" + ? "var(--status-good-tint)" + : state === "gate-open" + ? "var(--status-gate-tint)" + : "var(--surface-inset)"; + + const labelColor = + state === "current" + ? "var(--status-accent-text)" + : state === "done" || state === "gate-approved" + ? "var(--status-good-text)" + : "var(--text-faint)"; + + const title = `${step.label} — ${step.sub}${isGate ? " (human gate)" : ""}${ + step.branch ? " (branch: only on failure)" : "" + } · ${state.replace("gate-", "gate ")}`; + + return ( + + {title} + + {/* Role stripe: ties a step to its agent bubble by colour. Gates have no owner. */} + {step.role ? ( + + ) : null} + + {step.label} + + + {truncate(step.sub, 22)} + + + ); +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : `${s.slice(0, n - 1)}…`; +} diff --git a/apps/dashboard/app/Transport.tsx b/apps/dashboard/app/Transport.tsx new file mode 100644 index 00000000..b8a4952f --- /dev/null +++ b/apps/dashboard/app/Transport.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { font, radius } from "@/lib/theme"; + +// Transport bar: play/pause, step, speed, scrub over the event log — Kevin's most valuable +// interaction, re-skinned to Cathy's light theme. +// +// The scrub position is an event COUNT (how many events are folded), matching the fold's +// `upTo` semantics: 0 = nothing folded, totalEventCount = the live edge. `at === null` +// means "follow the live edge", which is distinct from being pinned at the last index — +// pinned stops following, and the board must say so, because snapshot-derived panels keep +// describing now (see lib/reducer.ts and the plan's §3a). + +export interface TransportProps { + at: number | null; // null = following live + total: number; + onChange: (at: number | null) => void; + playing: boolean; + onPlayingChange: (playing: boolean) => void; + speed: number; // events per second + onSpeedChange: (speed: number) => void; + // Timestamp of the event at the playhead, for the clock readout. + atTimestamp?: string | null; +} + +const SPEEDS = [1, 2, 5, 20] as const; + +export function Transport({ + at, + total, + onChange, + playing, + onPlayingChange, + speed, + onSpeedChange, + atTimestamp, +}: TransportProps) { + const live = at === null; + const pos = live ? total : Math.max(0, Math.min(at, total)); + const atEnd = pos >= total; + + // Playback advances the playhead on a timer. Reaching the end returns to following live, + // so pressing play on a live run leaves you watching it rather than pinned one event back. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const onPlayingChangeRef = useRef(onPlayingChange); + onPlayingChangeRef.current = onPlayingChange; + + useEffect(() => { + if (!playing) return; + const id = setInterval(() => { + const next = pos + 1; + if (next >= total) { + onChangeRef.current(null); // caught up — resume following + onPlayingChangeRef.current(false); + } else { + onChangeRef.current(next); + } + }, 1000 / speed); + return () => clearInterval(id); + }, [playing, pos, total, speed]); + + const step = (delta: number) => { + onPlayingChange(false); + const next = pos + delta; + if (next >= total) onChange(null); + else onChange(Math.max(0, next)); + }; + + return ( +
+
+ { onPlayingChange(false); onChange(0); }} /> + step(-1)} disabled={pos <= 0} /> + { + // Playing from the live edge would have nowhere to go; restart from the top. + if (!playing && atEnd) onChange(0); + onPlayingChange(!playing); + }} + /> + step(1)} disabled={atEnd} /> + { onPlayingChange(false); onChange(null); }} + /> +
+ + { + onPlayingChange(false); + const v = Number(e.target.value); + // Dragging to the far right means "follow live" rather than "pin at the end". + onChange(v >= total ? null : v); + }} + aria-label="scrub through the event log" + style={{ flex: 1, minWidth: 160, accentColor: "var(--status-accent)", cursor: "pointer" }} + /> + +
+ + {pos} / {total} + + + {atTimestamp ? atTimestamp.slice(11, 19) : "--:--:--"} + + {live ? ( + + + LIVE + + ) : ( + PINNED + )} +
+ +
+ speed: + {SPEEDS.map((s) => ( + + ))} +
+
+ ); +} + +function TransportButton({ + label, + title, + onClick, + disabled, + primary, + active, +}: { + label: string; + title: string; + onClick: () => void; + disabled?: boolean; + primary?: boolean; + active?: boolean; +}) { + return ( + + ); +} diff --git a/apps/dashboard/app/WorkflowGraph.tsx b/apps/dashboard/app/WorkflowGraph.tsx new file mode 100644 index 00000000..0231d294 --- /dev/null +++ b/apps/dashboard/app/WorkflowGraph.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { STEP_OUTPUTS, WORKFLOW, gateForNode, type WorkflowNode } from "@/lib/topology"; +import { colorForRole, font, radius } from "@/lib/theme"; +import type { DashboardState, GateInfo } from "@/lib/types"; + +// The Consort lifecycle graph (Figure 1), re-skinned from Kevin's dark SVG to the light +// theme. Layout is a single horizontal spine — intake → plan → design → build → deploy → +// promote → shipped — with gates as narrow diamonds between phases. +// +// Node lighting comes straight from lib/topology.ts: +// passed → a phase this run has reached (green) +// active → the phase the playhead is in (accent + glow, matching DesignLane's treatment) +// dim → not yet reached +// Gate nodes additionally show approved/surfaced from the run's gate state. +// +// Everything here derives from the folded state, so it works identically at the live edge +// and scrubbed back — the graph is timeline-only data, which does rewind honestly. + +const NODE_W = 96; +const NODE_H = 40; +const GATE_W = 26; +const GAP = 26; +const PAD = 16; +const LABEL_H = 30; // room under the spine for role chips + +interface Placed { + node: WorkflowNode; + x: number; + w: number; +} + +// Lay the spine out left to right, sizing gates narrower than phases. +function layout(): { placed: Placed[]; width: number; height: number } { + let x = PAD; + const placed: Placed[] = []; + for (const node of WORKFLOW.nodes) { + const w = node.type === "gate" ? GATE_W : NODE_W; + placed.push({ node, x, w }); + x += w + GAP; + } + return { placed, width: x - GAP + PAD, height: PAD * 2 + NODE_H + LABEL_H }; +} + +const { placed: PLACED, width: SVG_W, height: SVG_H } = layout(); +const POS = new Map(PLACED.map((p) => [p.node.id, p])); +const CENTER_Y = PAD + NODE_H / 2; + +export function WorkflowGraph({ + state, + onSelectNode, + selectedNode, +}: { + state: DashboardState; + // Clicking a node opens its step-output deliverables. Optional: omitted when the source has no + // stepOutputs capability, in which case nodes render exactly as before (no pointer, no click). + onSelectNode?: (nodeId: string) => void; + selectedNode?: string | null; +}) { + // Both folded server-side (see deriveTopology in lib/reducer.ts): the client only gets a + // 40-event tail, but this needs the whole prefix. + const passed = new Set(state.topology.passedNodes); + const activeNode = state.topology.activeNode; + // Whichever agent is working drives the active node's stroke color, as in Kevin's version. + const activeRole = state.agents.find((a) => a.status === "working")?.role ?? null; + const gateState = new Map(state.gates.map((g: GateInfo) => [g.name, g.status])); + + return ( +
+ + + + + + + + + + + {WORKFLOW.edges.map(([from, to]) => ( + ${to}`} + from={from} + to={to} + done={passed.has(from) && (passed.has(to) || to === activeNode)} + /> + ))} + + {PLACED.map(({ node, x, w }) => ( + 0 ? onSelectNode : undefined} + selected={node.id === selectedNode} + /> + ))} + +
+ ); +} + +function gateStatusFor(node: WorkflowNode, gateState: Map): string | null { + if (node.type !== "gate") return null; + const name = gateForNode(node.id); + return name ? gateState.get(name) ?? null : null; +} + +// `shipped → plan` closes the sprint loop, so it runs back under the spine rather than +// through every intervening node. +function Edge({ from, to, done }: { from: string; to: string; done: boolean }) { + const a = POS.get(from); + const b = POS.get(to); + if (!a || !b) return null; + + const stroke = done ? "var(--status-good)" : "var(--border-strong)"; + const marker = done ? "url(#wf-arrow-done)" : "url(#wf-arrow)"; + + if (b.x < a.x) { + const y = CENTER_Y + NODE_H / 2 + 14; + const d = `M ${a.x + a.w / 2} ${CENTER_Y + NODE_H / 2} V ${y} H ${b.x + b.w / 2} V ${CENTER_Y + NODE_H / 2}`; + return ( + + ); + } + + return ( + + ); +} + +function Node({ + node, + x, + w, + active, + passed, + activeRole, + gateStatus, + onSelect, + selected, +}: { + node: WorkflowNode; + x: number; + w: number; + active: boolean; + passed: boolean; + activeRole: string | null; + gateStatus: string | null; + onSelect?: (nodeId: string) => void; + selected?: boolean; +}) { + const isGate = node.type === "gate"; + + // Active beats passed: the accent means "here, now", matching DesignLane's current phase. + const stroke = active + ? activeRole + ? colorForRole(activeRole) + : "var(--status-accent)" + : gateStatus === "approved" + ? "var(--status-good)" + : gateStatus === "surfaced" + ? "var(--status-gate)" + : passed + ? "var(--status-good)" + : "var(--border-default)"; + + const fill = active + ? "var(--status-accent-tint)" + : gateStatus === "approved" + ? "var(--status-good-tint)" + : gateStatus === "surfaced" + ? "var(--status-gate-tint)" + : passed + ? "var(--status-good-tint)" + : "var(--surface-inset)"; + + const label = active ? "var(--status-accent-text)" : passed || gateStatus === "approved" ? "var(--status-good-text)" : "var(--text-faint)"; + + const title = `${node.label}${isGate ? ` · gate${gateStatus ? `: ${gateStatus}` : ""}` : ""}${ + active ? " · active now" : passed ? " · reached" : " · not reached" + }${node.roles.length ? ` · ${node.roles.join(", ")}` : ""}`; + + const clickable = !!onSelect; + return ( + onSelect!(node.id) : undefined} + style={{ + cursor: clickable ? "pointer" : undefined, + ...(active ? { animation: "softpulse 2s ease-in-out infinite", color: stroke } : {}), + }} + > + {clickable ? `${title} · click for step outputs` : title} + {/* Selection ring: a dashed accent outline, distinct from the active-now glow/pulse, so a + node can read as "selected for its outputs" and "active now" at the same time. */} + {selected ? ( + isGate ? ( + + ) : ( + + ) + ) : null} + {isGate ? ( + // A diamond, so a human decision point never reads as just another phase. + + ) : ( + + )} + + {!isGate ? ( + + {node.label.replace(/ lane$/, "")} + + ) : null} + + {/* Gate labels sit below the diamond; there's no room inside it. */} + {isGate ? ( + + gate + + ) : null} + + {/* Role chips under each phase, tinted by role — the graph doubles as a legend. */} + {!isGate && node.roles.length > 0 ? ( + + {node.roles.slice(0, 5).map((role, i) => ( + + {role} + + ))} + + ) : null} + + ); +} diff --git a/apps/dashboard/app/__snapshots__/render.test.tsx.snap b/apps/dashboard/app/__snapshots__/render.test.tsx.snap new file mode 100644 index 00000000..2ab8a483 --- /dev/null +++ b/apps/dashboard/app/__snapshots__/render.test.tsx.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`render — LaneGraph > expands the furthest lane reached when nothing is active 1`] = `"
Build lane · honest-GREEN cycle (Branched-Database TDD)
verify failsregressionsupersessionre-verifyre-verifyNavigator — write failing test (RED) · doneNavigatorwrite failing test (R…Driver — minimal honest code (GREEN) · doneDriverminimal honest code (…Verify — run vs real branch (human gate) · doneVerifyrun vs real branchNavigator — review / refactor · doneNavigatorreview / refactorNavigator — assess: regression or supersession? (branch: only on failure) · doneNavigatorassess: regression or…Driver — repair code, never tests (branch: only on failure) · doneDriverrepair code, never te…Driver — permissive-green (superseded only) (branch: only on failure) · doneDriverpermissive-green (sup…
"`; + +exports[`render — LaneGraph > renders all three lanes, with only the playhead's lane expanded 1`] = `"
Design lane · spec-first (per story)
revise on findingsUX designer — design guide (once) · doneUX designerdesign guide (once)Spec author — acceptance criteria · currentSpec authoracceptance criteriaArchitect — annotate layers · doneArchitectannotate layersDBA — realize schema · pendingDBArealize schemaTest strategist — test list · pendingTest strategisttest listNavigator — reflect / critique · pendingNavigatorreflect / critiqueSpec gate — human approves (human gate) · gate openSpec gatehuman approves
"`; + +exports[`render — Transport > renders following the live edge 1`] = `"
380 / 38015:09:36LIVE
speed:
"`; + +exports[`render — Transport > renders pinned at an event, and says so 1`] = `"
40 / 38019:39:11PINNED
speed:
"`; + +exports[`render — WorkflowGraph > renders the finished run: nodes reached, nothing active 1`] = `"
Intake · not reachedIntakePlan · reached · spec-author, architect-reviewer, product-ownerPlanspec-authorarchitect-reviewerproduct-ownerplan gate · gate: open · not reachedgateDesign lane · reached · spec-author, architect-reviewer, dba, test-strategist, ux-designerDesignspec-authorarchitect-reviewerdbatest-strategistux-designerspec + test-list gates · gate: open · not reachedgateBuild lane · reached · navigator, driverBuildnavigatordriverDeploy · reached · release-engineerDeployrelease-engineerdeploy gate · gate: approved · not reachedgatePromote · reached · release-engineerPromoterelease-engineerpromote gate · gate: approved · not reachedgateShipped · not reachedShipped
"`; + +exports[`render — WorkflowGraph > renders the same run scrubbed back to event 40, with design active 1`] = `"
Intake · not reachedIntakePlan · reached · spec-author, architect-reviewer, product-ownerPlanspec-authorarchitect-reviewerproduct-ownerplan gate · gate: open · not reachedgateDesign lane · active now · spec-author, architect-reviewer, dba, test-strategist, ux-designerDesignspec-authorarchitect-reviewerdbatest-strategistux-designerspec + test-list gates · gate: open · not reachedgateBuild lane · not reached · navigator, driverBuildnavigatordriverDeploy · not reached · release-engineerDeployrelease-engineerdeploy gate · gate: approved · not reachedgatePromote · not reached · release-engineerPromoterelease-engineerpromote gate · gate: approved · not reachedgateShipped · not reachedShipped
"`; + +exports[`render — appearance is unchanged by the token refactor > covers every AgentStatus, not just the ones this run happened to produce 1`] = ` +"
orchestrator
working
synthetic working
S1-record-stock
working 4m
0 turns
+
orchestrator
on deck
synthetic on-deck
S1-record-stock
0 turns
+
orchestrator
issue
synthetic issue
S1-record-stock
0 turns
+
orchestrator
waiting on you
synthetic waiting
S1-record-stock
0 turns
+
·
orchestrator
idle
S1-record-stock
0 turns
" +`; + +exports[`render — appearance is unchanged by the token refactor > renders a working bubble in both liveness states 1`] = ` +"
orchestrator
working
orchestrator START build
S1-record-stock
working 4m · live
0 turns
+
orchestrator
working
orchestrator START build
S1-record-stock
working 4m · quiet
0 turns
+
orchestrator
working
orchestrator START build
S1-record-stock
working 4m
0 turns
" +`; + +exports[`render — appearance is unchanged by the token refactor > renders bubbles the same with cost hidden 1`] = ` +"
·
orchestrator
idle
S1-record-stock
0 turns
+
·
spec-author
idle · sonnet
S3-sku-detail-view
9 turns
+
·
ux-designer
idle · sonnet
3 turns
+
·
architect-reviewer
idle · sonnet
S3-sku-detail-view
10 turns
+
·
dba
idle · sonnet
S1-record-stock
27 turns
+
·
test-strategist
idle · sonnet
S3-sku-detail-view
6 turns
+
·
navigator
idle · sonnet
S1-record-stock
20 turns
+
·
driver
idle · sonnet
S1-record-stock
10 turns
+
·
product-owner
idle
0 turns
+
·
release-engineer
idle
0 turns
" +`; + +exports[`render — appearance is unchanged by the token refactor > renders every agent bubble identically 1`] = ` +"
·
orchestrator
idle
S1-record-stock
0 turns
+
·
spec-author
idle · sonnet
S3-sku-detail-view
9 turns$1.32
+
·
ux-designer
idle · sonnet
3 turns$0.59
+
·
architect-reviewer
idle · sonnet
S3-sku-detail-view
10 turns$2.43
+
·
dba
idle · sonnet
S1-record-stock
27 turns$2.87
+
·
test-strategist
idle · sonnet
S3-sku-detail-view
6 turns$1.73
+
·
navigator
idle · sonnet
S1-record-stock
20 turns$19.18
+
·
driver
idle · sonnet
S1-record-stock
10 turns$9.70
+
·
product-owner
idle
0 turns
+
·
release-engineer
idle
0 turns
" +`; diff --git a/apps/dashboard/app/api/artifact/route.ts b/apps/dashboard/app/api/artifact/route.ts new file mode 100644 index 00000000..21709b1d --- /dev/null +++ b/apps/dashboard/app/api/artifact/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveSource } from "@/lib/sources"; +import type { SourceMode } from "@/lib/source"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +// GET /api/artifact?path= → an artifact the log named, read at the project's HEAD +// GET /api/artifact?path=&mode=live → explicit mode (defaults as /api/state does) +// +// The live half of the turn drill-down. A live project has no per-turn corpus, so the deepest it +// can show for an `artifact.written` row is the file as it is NOW — HEAD content. Replay shows +// richer per-turn snapshots through /api/turn instead, so it does NOT implement `artifactAtHead`; +// this route 409s there rather than pretending, the same shape /api/turn uses for a live project +// with no turns corpus. +// +// SECURITY: `path` is attacker-controlled and becomes a filesystem path. `readArtifactAtHead` +// (via lib/safepath.ts) realpath-resolves it and requires containment under `.sftdd/` before any +// read — the same audited guard the replay file reader uses. Do not bypass it. +export async function GET(req: NextRequest) { + try { + const rel = req.nextUrl.searchParams.get("path"); + if (rel === null || rel === "") { + return NextResponse.json({ error: "Missing ?path=" }, { status: 400 }); + } + + const modeParam = req.nextUrl.searchParams.get("mode"); + const requested: SourceMode | undefined = + modeParam === "live" || modeParam === "replay" ? modeParam : undefined; + + const { source } = resolveSource(requested); + + // Gated on the capability + method presence, not on `mode === "live"`, so a future source + // that can read HEAD gets this for free and one that can't isn't asked. Replay lands here: + // it has artifactContent but as per-turn snapshots, reached through /api/turn. + if (!source.capabilities.has("artifactContent") || !source.artifactAtHead) { + return NextResponse.json( + { + error: "This source has no HEAD artifact content. In replay, open the turn that produced the file instead.", + mode: source.mode, + }, + { status: 409 }, + ); + } + + return NextResponse.json(source.artifactAtHead(rel)); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} diff --git a/apps/dashboard/app/api/planning/route.ts b/apps/dashboard/app/api/planning/route.ts new file mode 100644 index 00000000..a8e06a70 --- /dev/null +++ b/apps/dashboard/app/api/planning/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveSource } from "@/lib/sources"; +import type { SourceMode } from "@/lib/source"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +// GET /api/planning → the run's planning artifacts for the current source +// GET /api/planning?mode=replay → explicit mode (defaults as /api/state does) +// +// Planning is a STATIC snapshot of the run's start — proposals, t-shirt sizes, sprint backlog, +// plan gate — not timeline state, so it takes no `at`: there is no honest per-playhead version +// (the plan gate was approved once). Served on its own route rather than folded into +// /api/state so the board doesn't carry it on every 1 Hz poll; the BacklogPanel fetches it once. +// +// Source-agnostic: the route asks the resolved source for `planning()` and 409s if the source +// doesn't offer it — gated on the `planningBacklog` capability, the same pattern /api/turn uses +// for the replay-only `transcripts` capability. +export async function GET(req: NextRequest) { + try { + const modeParam = req.nextUrl.searchParams.get("mode"); + const requested: SourceMode | undefined = + modeParam === "live" || modeParam === "replay" ? modeParam : undefined; + + const { source } = resolveSource(requested); + + // A source that doesn't declare planningBacklog (or doesn't implement planning()) has no + // backlog to show. 409, not 404: the panel isn't missing a resource, this source can't have + // one — the same distinction /api/turn draws for a live project with no turns corpus. + if (!source.capabilities.has("planningBacklog") || !source.planning) { + return NextResponse.json( + { + error: "This source has no planning artifacts.", + mode: source.mode, + }, + { status: 409 }, + ); + } + + return NextResponse.json(source.planning()); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} diff --git a/apps/dashboard/app/api/state/route.ts b/apps/dashboard/app/api/state/route.ts new file mode 100644 index 00000000..6bfae106 --- /dev/null +++ b/apps/dashboard/app/api/state/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveSource } from "@/lib/sources"; +import type { SourceMode } from "@/lib/source"; + +// Always re-read the source on each request; never cache. +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +// GET /api/state → the live edge (whole log folded) +// GET /api/state?at= → time travel: fold only the first n events +// GET /api/state?mode=replay → replay a corpus from CONSORT_CORPUS_DIR +// +// `at` is clamped inside the fold, so a garbage or out-of-range value degrades to the live +// edge rather than erroring. The response carries atEventIndex / totalEventCount / atLive. +// When scrubbed back the fold reconstructs gates, stories, blockers, feature and lane from +// the log prefix. In LIVE mode test COUNTS are the one thing that can't rewind, flagged via +// progress.testsHistorical; in replay they can, from the corpus's per-turn test-list snapshots. +// +// The route does not know where the data comes from: it asks for a source and folds it. That +// is what let sources/replay.ts drop in without touching this file — the only edit it needed +// was this comment. +export async function GET(req: NextRequest) { + try { + const raw = req.nextUrl.searchParams.get("at"); + const parsed = raw === null ? undefined : Number(raw); + const upTo = parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined; + + const modeParam = req.nextUrl.searchParams.get("mode"); + const requested: SourceMode | undefined = + modeParam === "live" || modeParam === "replay" ? modeParam : undefined; + + // ?feature= pins the board to one feature (FeatureSwitcher) — a filter over the same + // playhead, not a seek. An id the folded window hasn't seen is dropped inside the fold, so a + // stale pin degrades to the playhead's feature rather than emptying the board. + const featureParam = req.nextUrl.searchParams.get("feature"); + const pinnedFeature = featureParam && featureParam.length > 0 ? featureParam : null; + + const { source, available, note } = resolveSource(requested); + const state = source.getState(upTo, pinnedFeature); + + // Mode metadata travels alongside the state so the header can show the mode switch + // without the client re-deriving what the server just decided. + // + // `correlationSummary` is optional on the interface (live has no corpus to disagree with), + // so this stays source-agnostic: any source that can report pairing drift gets it rendered. + // Scoped to the same playhead as the fold, so the banner describes what is on screen. + return NextResponse.json({ + ...state, + source: { + mode: source.mode, + describe: source.describe(), + capabilities: [...source.capabilities], + availableModes: available, + note, + correlation: source.correlationSummary?.(upTo) ?? null, + correspondence: source.correspondenceSummary?.(upTo) ?? null, + // Live only: is the record lane capturing a full corpus, or just the agent-log? Drives the + // FidelityBanner. Null (replay, or any source that doesn't implement it) → no banner. + fidelity: source.fidelity?.() ?? null, + }, + }); + } catch (err) { + return NextResponse.json( + { ok: false, error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} diff --git a/apps/dashboard/app/api/step-outputs/route.ts b/apps/dashboard/app/api/step-outputs/route.ts new file mode 100644 index 00000000..916da3e5 --- /dev/null +++ b/apps/dashboard/app/api/step-outputs/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveSource } from "@/lib/sources"; +import type { SourceMode } from "@/lib/source"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +// GET /api/step-outputs?node=&feature= → the deliverables that lifecycle step produced +// GET /api/step-outputs?path= → one deliverable's content +// GET /api/step-outputs?...&mode=replay → explicit mode (defaults as /api/state does) +// +// The WorkflowGraph drill-down. Two shapes over one route, mirroring /api/turn (list vs ?file=): +// `?node=` lists a step's assets, `?path=` reads one. `path` takes precedence when both are given. +// +// SECURITY: `path` is attacker-controlled and becomes a filesystem path. The source's +// `stepOutputContent` routes it through the same audited containment guard (lib/safepath.ts) the +// replay turn-file and live HEAD readers use, rooted at `recorded-artifacts/`. Do not bypass it. +export async function GET(req: NextRequest) { + try { + const params = req.nextUrl.searchParams; + + const modeParam = params.get("mode"); + const requested: SourceMode | undefined = + modeParam === "live" || modeParam === "replay" ? modeParam : undefined; + const { source } = resolveSource(requested); + + // Gated on the capability + method presence, not on `mode === "replay"`, so a future live + // source that can surface step outputs gets this for free and one that can't isn't asked. + if (!source.capabilities.has("stepOutputs") || !source.stepOutputs || !source.stepOutputContent) { + return NextResponse.json( + { + error: "This source has no step outputs. In replay, point CONSORT_CORPUS_DIR at a recorded corpus.", + mode: source.mode, + }, + { status: 409 }, + ); + } + + // Content branch takes precedence: a `?path=` request is asking to read a specific asset. + const path = params.get("path"); + if (path !== null && path !== "") { + return NextResponse.json(source.stepOutputContent(path)); + } + + const node = params.get("node"); + if (node === null || node === "") { + return NextResponse.json({ error: "Missing ?node= (or ?path=)" }, { status: 400 }); + } + const feature = params.get("feature"); + return NextResponse.json(source.stepOutputs(node, feature)); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} diff --git a/apps/dashboard/app/api/turn/[ord]/route.ts b/apps/dashboard/app/api/turn/[ord]/route.ts new file mode 100644 index 00000000..e133b30f --- /dev/null +++ b/apps/dashboard/app/api/turn/[ord]/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveSource } from "@/lib/sources"; +import { classify } from "@/lib/sources/replay"; +import type { SourceMode } from "@/lib/source"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +// GET /api/turn/ → a recorded turn: metadata, transcript, produced files +// GET /api/turn/?file= → one produced file's snapshot content +// GET /api/turn/?mode=replay → explicit mode (defaults as /api/state does) +// +// Served lazily, per the plan's decision 1: Kevin's build embeds all 126 turns into a 1.5 MB +// single-file HTML, which is a virtue for offline sharing but the wrong runtime model. Fetching +// one turn on demand keeps the first paint fast and lets the same UI serve live and replay. +// +// File contents are NOT included in the turn payload for the same reason — a turn can produce +// a dozen files and the panel shows one at a time. `?file=` fetches the selected one. +// +// SECURITY: `file` is attacker-controlled and becomes a filesystem path. `readFileContent` +// resolves it and requires containment under /files/, which is why this route can pass it +// through — review found that check missing, and traversal read /etc/passwd. Do not bypass it. +export async function GET(req: NextRequest, ctx: { params: Promise<{ ord: string }> }) { + try { + const { ord: ordRaw } = await ctx.params; + // Require plain digits, then parse. `Number()` alone is too permissive for a path segment: + // `Number("")` is 0 (so an empty ordinal would serve turn 0), `Number(" 1 ")` is 1, and + // `Number("1e3")` is 1000 — each silently resolving to a turn the caller didn't ask for. + const ord = /^\d+$/.test(ordRaw) ? Number(ordRaw) : NaN; + if (!Number.isSafeInteger(ord)) { + return NextResponse.json({ error: `Not a turn ordinal: ${JSON.stringify(ordRaw)}` }, { status: 400 }); + } + + const modeParam = req.nextUrl.searchParams.get("mode"); + const requested: SourceMode | undefined = + modeParam === "live" || modeParam === "replay" ? modeParam : undefined; + const { source } = resolveSource(requested); + + // Turns come from a recorded `turns/` corpus, which the `transcripts` capability encodes. + // Replay always has one; a LIVE board has one only when a companion record dir is configured + // and producing (Phase B). Gate on the capability + method presence, NOT `instanceof + // ReplaySource`, so a recording live source serves turns too. Answer 409 rather than 404 — the + // turn isn't missing, this source fundamentally has no turns corpus (yet). + if (!source.capabilities.has("transcripts") || !source.turn || !source.file || !source.transcript) { + return NextResponse.json( + { + error: + "Turns are recorded per-run. They exist in replay mode, or in a live build recording to a companion record dir (CONSORT_RECORD_DIR); this source has none.", + mode: source.mode, + }, + { status: 409 }, + ); + } + + const turn = source.turn(ord); + if (!turn) return NextResponse.json({ error: `No turn ${ord} in this corpus` }, { status: 404 }); + + const rel = req.nextUrl.searchParams.get("file"); + if (rel !== null) { + // A file request answers only about that file, so the panel's tab switch is one small + // response rather than the whole turn again. + const f = source.file(ord, rel); + return NextResponse.json({ ord, path: rel, ...f }); + } + + return NextResponse.json({ + ...turn, + // Classified here rather than in the client so "is this code or an artifact?" has exactly + // one definition, shared with the file response above. + // + // `classify` directly, NOT `source.file()`: the latter also reads the file, so this + // discarded every produced file's contents just to keep `.kind` — 82 KB across 21 files + // for turn 81, 187 KB for turn 15. That is precisely the per-request cost this route + // exists to avoid. `classify` is a pure string function on the path. + // + // `?? []` on both lists because turn.json genuinely omits keys (this PR relaxed most of + // TurnDetail to optional for that reason, and a turn without `produced` 500'd here). + produced: (turn.produced ?? []).map((p) => ({ path: p, kind: classify(p) })), + deleted: turn.deleted ?? [], + transcript: source.transcript(ord), + // The transcript SUMMARY (model, tool count) lives on turn.json under the same key, so + // it would be shadowed by the parsed body above. Keep both, named distinctly. + transcriptSummary: turn.transcript ?? null, + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} diff --git a/apps/dashboard/app/api/turn/route.test.ts b/apps/dashboard/app/api/turn/route.test.ts new file mode 100644 index 00000000..fa1f3056 --- /dev/null +++ b/apps/dashboard/app/api/turn/route.test.ts @@ -0,0 +1,214 @@ +/** + * `/api/turn/` route tests. + * + * Exercised by calling the handler directly with a NextRequest, which is enough: the route is + * pure request→response over `ReplaySource`, and this avoids standing up a server in the suite. + * + * The security case is the reason this file exists at all. `?file=` is attacker-controlled and + * becomes a filesystem path — review found the containment check missing in + * `readFileContent`, where traversal read the real /etc/passwd. That is fixed at the source, + * but this route is the thing that would expose it, so it gets its own regression test. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NextRequest } from "next/server"; +import { GET } from "./[ord]/route"; + +// v0.3.7 relocated the corpus examples/sftdd-scenarios/ → examples/replay/corpora/; try new then legacy. +const MARKETPLACE = join(process.env.HOME ?? "", ".claude/plugins/marketplaces/databricks-solutions"); +const CORPUS = + [ + process.env.CONSORT_TEST_CORPUS_DIR, + join(MARKETPLACE, "examples/replay/corpora/stockflow-rerecord"), + join(MARKETPLACE, "examples/sftdd-scenarios/stockflow-rerecord"), + ] + .filter((p): p is string => !!p) + .find((p) => existsSync(join(p, "agent-log.jsonl"))) ?? + join(MARKETPLACE, "examples/replay/corpora/stockflow-rerecord"); +const HAVE_CORPUS = existsSync(join(CORPUS, "agent-log.jsonl")); + +// The handler reads the mode from the query and the corpus from the env, so both are set here. +const call = async (ord: string, query = "") => { + const url = `http://localhost/api/turn/${ord}${query}`; + const res = await GET(new NextRequest(url), { params: Promise.resolve({ ord }) }); + return { status: res.status, body: await res.json() }; +}; + +describe.skipIf(!HAVE_CORPUS)("/api/turn/", () => { + const saved = { corpus: process.env.CONSORT_CORPUS_DIR, project: process.env.CONSORT_PROJECT_DIR }; + beforeEach(() => { + process.env.CONSORT_CORPUS_DIR = CORPUS; + }); + afterEach(() => { + if (saved.corpus === undefined) delete process.env.CONSORT_CORPUS_DIR; + else process.env.CONSORT_CORPUS_DIR = saved.corpus; + if (saved.project === undefined) delete process.env.CONSORT_PROJECT_DIR; + else process.env.CONSORT_PROJECT_DIR = saved.project; + }); + + it("serves a turn with its transcript and classified produced files", async () => { + const { status, body } = await call("0", "?mode=replay"); + expect(status).toBe(200); + expect(body.role).toBe("spec-author"); + expect(body.kind).toBe("invoke-role"); + expect(body.produced).toEqual([ + { path: ".sftdd/planning/feature-proposals.md", kind: "artifact" }, + ]); + expect(body.transcript.prompt.length).toBeGreaterThan(0); + expect(Array.isArray(body.transcript.tools)).toBe(true); + }); + + it("keeps the parsed transcript and turn.json's summary distinct", () => { + // turn.json has its own `transcript` key holding {model, toolCount, …}. The parsed body is + // served under the same name, so the summary would be shadowed if it weren't renamed — + // and the model/tool counts would vanish with no error. + return call("0", "?mode=replay").then(({ body }) => { + expect(body.transcriptSummary.model).toBe("opus"); + expect(body.transcriptSummary.toolCount).toBe(5); + expect(body.transcript.prompt).toBeTypeOf("string"); // the parsed one, not the summary + }); + }); + + it("does NOT embed file contents in the turn payload", async () => { + // Decision 1: content is a second fetch. A turn can produce a dozen files and only one is + // ever on screen; embedding them recreates the 1.5 MB payload the merge set out to avoid. + const { body } = await call("0", "?mode=replay"); + for (const p of body.produced) expect(p).not.toHaveProperty("content"); + }); + + it("serves one file's snapshot content on ?file=", async () => { + const { status, body } = await call("0", "?mode=replay&file=.sftdd/planning/feature-proposals.md"); + expect(status).toBe(200); + expect(body.kind).toBe("artifact"); + expect(body.reason).toBeNull(); + expect(body.content).toContain("##"); + }); + + it("refuses to read outside the turn's snapshot directory", async () => { + for (const evil of [ + "../".repeat(30) + "etc/passwd", + "../../../provenance.json", + "../turn.json", + "/etc/passwd", + ]) { + const { status, body } = await call("0", `?mode=replay&file=${encodeURIComponent(evil)}`); + // 200 with a stated reason, not an error: the route answers "here is why you get nothing" + // exactly as it does for a too-large or binary file. What matters is content === null. + // The reason varies — a path that exists outside is "(outside…)", one that doesn't exist + // fails realpath first and is "(not captured…)", which also avoids disclosing existence. + expect(status).toBe(200); + expect(body.content, `leaked via ${evil}`).toBeNull(); + expect(body.reason).toMatch(/outside this turn's snapshot|not captured/); + } + }); + + it("names a code file as code, so the drill-down can separate it from bookkeeping", async () => { + // Turn 16 produces an alembic migration; turn 31 a repository module. Both are real code + // this run wrote, and must not read as `.sftdd/` bookkeeping. + const mig = await call("16", "?mode=replay"); + const code = (mig.body.produced as { path: string; kind: string }[]).filter((p) => p.kind === "code"); + expect(code.length).toBeGreaterThan(0); + expect(code.some((p) => p.path.startsWith("alembic/"))).toBe(true); + for (const p of code) expect(p.path).not.toMatch(/^\.sftdd\//); + + // ...and its content is actually readable, which is the whole point of the corpus. + const f = await call("16", `?mode=replay&file=${encodeURIComponent(code[0].path)}`); + expect(f.body.kind).toBe("code"); + expect(f.body.content).toBeTypeOf("string"); + }); + + it("classifies without reading file contents", async () => { + // Found in review: classification went through `source.file()`, which also READS the file, + // so the turn payload discarded every produced file's contents just to keep `.kind` — + // measured 82,866 bytes across 21 files for turn 81, and 187,568 for turn 15. That is + // exactly the per-request cost this lazy route exists to avoid. `classify` is a pure string + // function, so the kinds must still be right while the reads are gone. + // + // Asserted by cost rather than by output: a turn whose produced files are large enough that + // reading them would show up. Timing is too flaky to assert, so instead confirm the payload + // never carries content — the only observable of the old path — and that kinds are correct. + const { body } = await call("15", "?mode=replay"); + expect(body.produced.length).toBeGreaterThan(0); + for (const p of body.produced) { + expect(Object.keys(p).sort()).toEqual(["kind", "path"]); // no `content`, no `reason` + expect(["code", "artifact"]).toContain(p.kind); + } + }); + + it("survives a turn.json that omits produced/deleted", async () => { + // Found in review: this PR relaxed most of TurnDetail to optional because the corpus really + // does omit fields, but left `produced` required and then called `.map` on it — a turn + // without it returned HTTP 500 "Cannot read properties of undefined". Normalised in + // ReplaySource.turn now, so every consumer can iterate without a guard. + const dir = mkdtempSync(join(tmpdir(), "turn-noprod-")); + mkdirSync(join(dir, "turns", "0000-x"), { recursive: true }); + writeFileSync( + join(dir, "turns", "index.json"), + JSON.stringify({ turns: [{ ordinal: 0, step: 0, label: "x", kind: "invoke-role", role: "driver", dir: "0000-x", producedCount: 0, deletedCount: 0 }] }), + ); + // No `produced`, no `deleted` — the shape that 500'd. + writeFileSync(join(dir, "turns", "0000-x", "turn.json"), JSON.stringify({ ordinal: 0, step: 0, label: "x", kind: "invoke-role", role: "driver" })); + writeFileSync(join(dir, "agent-log.jsonl"), ""); + process.env.CONSORT_CORPUS_DIR = dir; + + const { status, body } = await call("0", "?mode=replay"); + expect(status).toBe(200); + expect(body.produced).toEqual([]); + expect(body.deleted).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }); + + it("refuses to follow a symlink out of the snapshot, over HTTP", async () => { + // The route-level regression for the review's highest finding: `resolve()` is lexical, so a + // corpus with `files/leak.md -> /etc/passwd` served the real file with HTTP 200 and + // `content` populated, despite this route's own comment claiming containment. + const dir = mkdtempSync(join(tmpdir(), "turn-symlink-")); + mkdirSync(join(dir, "turns", "0000-x", "files"), { recursive: true }); + writeFileSync( + join(dir, "turns", "index.json"), + JSON.stringify({ turns: [{ ordinal: 0, step: 0, label: "x", kind: "invoke-role", role: "driver", dir: "0000-x", producedCount: 1, deletedCount: 0 }] }), + ); + writeFileSync(join(dir, "turns", "0000-x", "turn.json"), JSON.stringify({ ordinal: 0, step: 0, label: "x", kind: "invoke-role", role: "driver", produced: ["leak.md"], deleted: [] })); + writeFileSync(join(dir, "agent-log.jsonl"), ""); + symlinkSync("/etc/passwd", join(dir, "turns", "0000-x", "files", "leak.md")); + process.env.CONSORT_CORPUS_DIR = dir; + + const { status, body } = await call("0", "?mode=replay&file=leak.md"); + expect(status).toBe(200); + expect(body.content).toBeNull(); + // Shared containment guard (lib/safepath.ts) folds escaped and non-existent into one reason, + // so the response can't be used to probe whether an out-of-tree path exists. + expect(body.reason).toBe("(not captured in this turn's snapshot)"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("404s an ordinal the corpus doesn't have", async () => { + const { status, body } = await call("99999", "?mode=replay"); + expect(status).toBe(404); + expect(body.error).toContain("No turn 99999"); + }); + + it("400s anything that isn't plain digits, rather than coercing it", async () => { + // Found by this test: `Number("")` is 0, so an empty ordinal served turn 0. `Number(" 1 ")` + // is 1 and `Number("1e3")` is 1000 — each silently resolving to an unasked-for turn. The + // route now requires /^\d+$/ before parsing. + for (const bad of ["abc", "-1", "1.5", "", " 1 ", "1e3", "0x2", "+1"]) { + const { status } = await call(bad, "?mode=replay"); + expect(status, `accepted ordinal ${JSON.stringify(bad)}`).toBe(400); + } + // Leading zeros are unambiguous digits, so they resolve normally. + expect((await call("000", "?mode=replay")).status).toBe(200); + }); + + it("409s in live mode, because turns are a replay-only asset", async () => { + // Not 404: the turn isn't missing, the source fundamentally cannot have one. A live + // project has no turns/ directory at all. + process.env.CONSORT_PROJECT_DIR = join(process.env.HOME ?? "", "Code/consort-lab/stockflow"); + const { status, body } = await call("0", "?mode=live"); + expect(status).toBe(409); + expect(body.error).toContain("replay mode"); + expect(body.mode).toBe("live"); + }); +}); diff --git a/apps/dashboard/app/board-parts.tsx b/apps/dashboard/app/board-parts.tsx new file mode 100644 index 00000000..01b5d90f --- /dev/null +++ b/apps/dashboard/app/board-parts.tsx @@ -0,0 +1,314 @@ +"use client"; + +// Pieces shared between the board and its tests. +// +// They live here rather than in page.tsx because a Next.js page module may only export a +// default — exporting a named component alongside it fails the build's route type check +// (`Property 'x' is incompatible with index signature`). `npm run build` catches that; `tsc` +// and vitest do not, which is how it slipped through once. + +import { useEffect, useRef } from "react"; +import type { DashboardState } from "@/lib/types"; +import { font, radius } from "@/lib/theme"; + +/** + * `?mode=live|replay` from the page URL, so a mode is linkable. + * + * Validated against the two known modes: a typo falls back to the server's choice rather than + * requesting a mode that cannot exist. + */ +export function modeFromUrl(search: string): "live" | "replay" | null { + const m = new URLSearchParams(search).get("mode"); + return m === "live" || m === "replay" ? m : null; +} + +export function DriftBanner({ correlation }: { correlation: NonNullable["correlation"] }) { + if (!correlation || correlation.severity === "ok") return null; + // This banner is ALWAYS about the dashboard's corpus pairing, never the run's health — it never + // means the orchestrator/build/deploy is failing. Two weights: + // warning — a role the corpus never recorded, i.e. the RECORD_DIR likely points at a DIFFERENT + // run. Amber (not critical-red): worth a look, still not a failure. + // info — a benign caveat (kit-version drift, or the live edge running ahead of the corpus). + // A quiet note; drill-downs may just be approximate. + const warn = correlation.severity === "warning"; + return ( +
+ {warn ? "⚠" : "ℹ"} +
+
+ {warn ? "Corpus pairing unreliable" : "Live view pairing note"} +
+
+ {correlation.message} Turn drill-downs may be approximate; the run itself is unaffected. +
+ {/* The counts say how far to trust it: `paired` turns did match, and `structural` + non-pairings are expected rather than evidence of a problem. */} +
+ {correlation.paired} paired · {correlation.unpairedEvents} unpaired + {correlation.structural > 0 ? ` · ${correlation.structural} structural (expected)` : ""} + {correlation.kitVersionMatch === false ? " · kit version mismatch" : ""} +
+
+
+ ); +} + +/** The `.sftdd/`-relative path an `artifact.written` event names, or null for any other event. */ +function artifactPathOf(e: DashboardState["recentEvents"][number]): string | null { + if (e.event !== "artifact.written") return null; + const p = (e.metadata as Record | undefined)?.path; + return typeof p === "string" && p ? p : null; +} + +export function EventTicker({ + state, + onOpenTurn, + onOpenArtifact, +}: { + state: DashboardState; + // Replay: open a recorded turn (transcript + per-turn snapshot) by ordinal. + onOpenTurn?: (ord: number) => void; + // Live: open an artifact.written path, read at HEAD. The two are mutually exclusive by mode — + // replay rows carry a turn ordinal, live rows carry an artifact path — so a row offers at most + // one affordance and there is no ambiguity about what a click does. + onOpenArtifact?: (path: string) => void; +}) { + const levelColor: Record = { debug: "var(--text-faint)", info: "var(--text-muted)", warn: "var(--status-warning-ticker)", error: "var(--status-critical-text)" }; + // Turn ordinals arrive positionally aligned to `recentEvents` (server-side, so the client + // never computes absolute event indices — a wrong ordinal means the wrong transcript). + const turns = state.source?.correlation?.recentTurns ?? []; + + // Fold the two streams into ONE chronological list: the machine event bus (recentEvents) and + // the HIL↔orchestrator conversation (source.correspondence). Both are tails taken up to the + // same playhead, so their newest ends align; merging by timestamp and keeping the tail of the + // merge gives a single time-consistent window. Rows render oldest→newest, NEWEST AT THE BOTTOM + // — a terminal log that grows downward, so the live edge is the last line. + const corr = state.source?.correspondence?.recent ?? []; + const merged: MergedRow[] = [ + ...state.recentEvents.map((e, i) => ({ kind: "event" as const, ts: e.timestamp, e, turn: turns[i] ?? null })), + ...corr.map((c) => ({ kind: "corr" as const, ts: c.at, c })), + ] + .sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0)) + .slice(-MERGED_TAIL); + + // Follow the live edge: keep the newest (bottom) row in view as rows arrive. Polite — only + // auto-scrolls when the viewer is already parked at the bottom, so scrolling up to read history + // isn't yanked back down. `stick` starts true (a fresh ticker follows) and flips on manual + // scroll away from the bottom. The effect re-runs when the newest row changes. + const scrollRef = useRef(null); + const stickRef = useRef(true); + const newestTs = merged[merged.length - 1]?.ts; + useEffect(() => { + const el = scrollRef.current; + if (el && stickRef.current) el.scrollTop = el.scrollHeight; + }, [newestTs, state.eventCount]); + const onScroll = () => { + const el = scrollRef.current; + if (el) stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; + }; + return ( +
+ {/* A drill-down row must LOOK clickable at rest, not only reward a hover — Kevin lost the + `»` affordance in the merge because it was a faint tail span the auto-scroll buried. The + `.consort-open` class carries a left accent rail + a hover lift so an openable row reads + as a button in a log of inert lines. Interpolating theme tokens keeps it themed. */} + +
+ {merged.map((row, i) => { + if (row.kind === "corr") return ; + const { e, turn } = row; + const openTurn = turn !== null && !!onOpenTurn; + // Only offer the artifact affordance when NOT already offering a turn (a recorded turn + // is the richer drill-down), and only for a row that actually names a path. + const artifactPath = openTurn ? null : onOpenArtifact ? artifactPathOf(e) : null; + const clickable = openTurn || artifactPath !== null; + const onClick = openTurn ? () => onOpenTurn!(turn!) : artifactPath !== null ? () => onOpenArtifact!(artifactPath) : undefined; + return ( +
+ {/* A fixed-width left gutter carrying a bold `»` on openable rows (blank otherwise, so + columns stay aligned). This is the marker Kevin remembers — leading the row, not a + faint trailing span, so it survives the auto-scroll and reads at a glance. */} + {/* A clickable row carries the `surface.inset` highlight (via .consort-open), so its + text must switch to the SEMANTIC tokens — the always-light onDark* colors used on + the dark-terminal rows are unreadable on that highlight in light mode (light-on- + white). Using strong/body/muted (not hardcoded black) makes it correct in both + palettes: light row + dark text in light mode, dark row + light text in dark. */} + {clickable ? "»" : ""} + {e.timestamp.slice(11, 19)} + {e.event.split(".")[0]} + {e.role} + {e.message} + {/* The trailing label names the action (open …), accent-coloured so it reads as the + button it is rather than metadata. Only rows that begin a recorded turn / name an + artifact are openable, so the affordance marks exactly where the drill-down is. */} + {openTurn ? open turn {turn} › : null} + {artifactPath !== null ? open file › : null} +
+ ); + })} +
+
+ ); +} + +// A LIVE build can open produced files at HEAD and rewind its timeline, but the prompts, inputs, +// the HIL↔orchestrator conversation, and point-in-time per-step snapshots live only in the +// record-lane corpus. Rather than let those panels silently not exist (the "where did the +// drill-down go?" report), this states plainly what IS and ISN'T available, and how to get the rest. +// +// VISIBILITY IS DRIVEN BY THE MISSING CAPABILITIES, NOT by the `recording` flag. Conflating "the +// recorder is writing" with "the dashboard can show the richer data" is how a banner meant to +// explain a gap ends up hiding DURING the gap: a build can be recording to disk while this source +// still lacks the transcripts/correspondence/stepOutputs capabilities that surface it (that wiring +// is Phase B). So the banner shows whenever a live board is missing any of those, and hides itself +// only once they are ALL present — at which point there is genuinely nothing to warn about. The +// `recording` flag is used solely to tailor the remediation line (turn capture on vs. it's already +// on, use replay). Renders nothing for replay (fidelity null — a corpus is full-fidelity by +// definition, and a corpus that simply lacks correspondence is not something a re-run would fix). +export function FidelityBanner({ source }: { source: DashboardState["source"] }) { + const fidelity = source?.fidelity; + // `fidelity` is present only on sources that reason about capture — live today. Null → replay. + if (!source || !fidelity) return null; + const caps = new Set(source.capabilities); + // The record-lane corpus would add these; each line is keyed off the capability still MISSING, + // so as a future recording-aware live source gains one (Phase B) it drops out — and when none + // remain, `missing` is empty and the banner hides itself. Point-in-time snapshots are keyed on + // `stepOutputs` (replay's per-turn recorded-artifacts); live's artifactContent is HEAD-only. + const missing: string[] = []; + if (!caps.has("transcripts")) missing.push("prompts & inputs"); + if (!caps.has("correspondence")) missing.push("the HIL↔orchestrator conversation"); + if (!caps.has("stepOutputs")) missing.push("point-in-time per-step snapshots"); + if (missing.length === 0) return null; // full-fidelity live — nothing to warn about + const available: string[] = []; + if (caps.has("artifactContent")) available.push("current outputs (at HEAD)"); + if (caps.has("timeline")) available.push("the full event timeline"); + if (caps.has("featureStatus")) available.push("live status & progress"); + return ( +
+ +
+
+ {fidelity.recording ? "Live build · limited live view" : "Live build · not recording"} +
+
+ {/* Guard the empty case: a source declaring none of the "available" caps must not render + "Available: ." — say what it can do, or nothing rather than a broken sentence. */} + {available.length > 0 ? `Available: ${available.join(" · ")}. ` : ""}Not captured: {missing.join(", ")}. +
+
+ {fidelity.recording ? ( + // The recorder is writing, but this dashboard doesn't yet surface those streams in a + // live board (Phase B) — so point at replay rather than telling them to turn on what + // is already on. + <>Open the recorded corpus in replay mode to see these. + ) : ( + <> + For complete replay fidelity, re-run the build with{" "} + LAKEBASE_CONSORT_RECORD_DIR{" "} + set. + + )} +
+
+
+ ); +} + +/** How many merged rows the ticker keeps in view. Larger than the 40-event tail because two + * interleaved streams share the window. */ +const MERGED_TAIL = 60; + +type CorrItem = NonNullable["correspondence"]>["recent"][number]; +type MergedRow = + | { kind: "event"; ts: string; e: DashboardState["recentEvents"][number]; turn: number | null } + | { kind: "corr"; ts: string; c: CorrItem }; + +// A correspondence row: the conversation, rendered distinctly from the machine event bus. +// +// Reads left→right like an event row (timestamp · lane · who · message) so the columns line up, +// but a purple accent + direction glyph mark it as a HIL↔orchestrator exchange, and the outcome +// badge (✓ approved / ✓ done) surfaces the per-action completion signal — the thing the agent +// log's turn-boundary logging lags on. Clicking a row that names a turn opens that turn (ordinals +// are 1:1 with the log's turns), the same drill-down an event row offers. +function CorrRow({ c, onOpenTurn }: { c: CorrItem; onOpenTurn?: (ord: number) => void }) { + const openTurn = c.ordinal !== null && !!onOpenTurn; + // "you" = the human-in-the-loop. "→you" is the orchestrator surfacing something to you; "you→" + // is you answering (a kickoff or a gate approval). Kept short to fit the lane column and stay + // aligned with the event rows; the full direction is in the row title. + const arrow = c.direction === "hil-to-orch" ? "you→" : c.direction === "orch-to-hil" ? "→you" : c.direction; + const badge = c.outcome === "approved" ? "✓ approved" : c.outcome === "validated" ? "✓ done" : null; + const badgeColor = c.outcome === "approved" ? "var(--status-good)" : "var(--text-on-dark-muted)"; + return ( +
onOpenTurn!(c.ordinal!) : undefined} + title={openTurn ? `Open turn ${c.ordinal} — transcript and produced files` : `${arrow}${c.kind ? ` · ${c.kind}` : ""}`} + style={{ + display: "flex", + gap: 10, + padding: "2px 0", + paddingLeft: 6, + marginLeft: -6, + borderLeft: `2px solid var(--status-gate)`, + background: "var(--status-gate-tint)", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + cursor: openTurn ? "pointer" : undefined, + }} + > + {c.at.slice(11, 19)} + {/* the "lane" column slot, reused to carry the direction glyph so columns align with events */} + {arrow} + {c.kind ?? "message"} + {c.text} + {badge ? {badge} : null} + {openTurn && !badge ? turn {c.ordinal} › : null} +
+ ); +} diff --git a/apps/dashboard/app/favicon.ico b/apps/dashboard/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/apps/dashboard/app/favicon.ico differ diff --git a/apps/dashboard/app/globals.css b/apps/dashboard/app/globals.css new file mode 100644 index 00000000..782b6c45 --- /dev/null +++ b/apps/dashboard/app/globals.css @@ -0,0 +1,29 @@ +/* Design tokens (--surface-*, --text-*, --status-*, --role-*) are injected by layout.tsx as + two blocks generated from lib/theme.ts: a light :root and a :root[data-theme="dark"] (each + also sets color-scheme). We deliberately do NOT invert on prefers-color-scheme — dark is an + explicit choice (the in-app toggle or THEME=dark), so the board looks identical on any + machine unless someone asks for dark. */ +html { + height: 100%; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + min-height: 100%; + color: var(--text-strong); + background: var(--surface-page); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} diff --git a/apps/dashboard/app/layout.tsx b/apps/dashboard/app/layout.tsx new file mode 100644 index 00000000..cc18e745 --- /dev/null +++ b/apps/dashboard/app/layout.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { cssVariables } from "@/lib/theme"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Consort · Agent Mission Control", + description: "Live observability for a Consort run", +}; + +// The launch/env default: `THEME=dark ./run.sh` boots the board dark (deterministic for a +// pinned demo). A per-viewer choice from the in-app toggle lives in localStorage and overrides +// this — see the no-flash script below and app/useTheme.ts. Default is light, matching the +// board's "looks identical on any machine unless you ask otherwise" stance. +const envTheme = process.env.THEME === "dark" ? "dark" : undefined; + +// Runs before first paint so a stored/queried dark choice doesn't flash the light default. +// Precedence: ?theme= query (transient) > localStorage (persisted) > env SSR default. +const noFlashTheme = `(function(){try{ + var q=new URLSearchParams(location.search).get('theme'); + var s=localStorage.getItem('theme'); + // A valid query wins; an invalid one (typo/stale link) must NOT shadow a stored choice. + var t=(q==='dark'||q==='light')?q:s; + if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t); +}catch(e){}})();`; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {/* Design tokens as CSS custom properties, generated from lib/theme.ts: a light :root + block and a :root[data-theme="dark"] block, so flipping data-theme re-themes the + whole board (inline styles, CSS, and SVG) with no re-render. */} + + +
{ + // Switching source resets the playhead: an event index means nothing across two + // different runs, and carrying it over would silently show a corpus at a live run's + // position. Stop playback too, so the board doesn't start scrubbing a new source. + setAt(null); + setPlaying(false); + // ...and close any open drill-down: a turn ordinal / artifact path / recorded step + // deliverable is all as source-specific as an event index. + setDrilldown(null); + // ...and drop any feature pin: a feature id is as run-specific as an event index, so + // carrying F1 from a live run onto a replay corpus would pin nothing (or the wrong + // thing). The fold would drop a stale id anyway; clearing it keeps the control honest. + setPinned(null); + setMode(m); + }} + /> + + {!state ? ( + + ) : !state.ok ? ( + + ) : ( + <> + {state.waiting ? : null} + + {/* A live build not capturing the full record-lane corpus says so, and how to fix it — + turning a silently-missing drill-down into an actionable message. Null in replay. */} + + + Lifecycle + + setDrilldown((cur) => (cur && cur.kind === "step" && cur.node === id ? null : { kind: "step", node: id })) + : undefined + } + // The node's selection ring reflects the open step target (and nothing when a turn or + // artifact is open instead). + selectedNode={drilldown?.kind === "step" ? drilldown.node : null} + /> + +
+ +
+ + Lanes · inter-agent sub-workflows + + + Status + + + {/* Planning / backlog — proposals, t-shirt sizes, sprint commit, plan gate. Fetched on + its own route (not folded), so it does not rewind with the transport. */} + {canShowBacklog ? ( + <> + Planning · backlog + + + ) : null} + + Current State +
+ {state.agents.map((a) => ( + + ))} +
+ + {state.blockers.length > 0 ? : null} + + Event Stream · {state.eventCount} events + setDrilldown({ kind: "turn", ord }) : undefined} + onOpenArtifact={canOpenArtifact ? (path) => setDrilldown({ kind: "artifact", path }) : undefined} + /> + {/* The ONE drill-down surface: whatever you clicked — a ticker row (turn or artifact) or + a WorkflowGraph node (step) — opens here. It sits under the stream and scrolls itself + into view (see the effect above) so a graph click up top still lands somewhere visible. + Pass the board's ACTUAL mode rather than assuming replay: the openers are gated on the + right capability precisely so a future non-replay source with the data works, and a + hardcoded mode would silently serve it the wrong source. `feature` is passed LIVE (not + baked into a step target) so switching the FeatureSwitcher re-scopes an open step panel. + + No capability re-check here even though the render isn't gated on one: `drilldown` is + only ever SET through the capability-gated openers above, and the sole capability- + changing action — a mode switch — clears it (see onMode). So an open target's source + can always still satisfy it. */} + {drilldown ? ( + // A FIXED right-side drawer: it floats over the right of the page and stays in view as + // you scroll, so whatever you clicked (a lifecycle node at the top, a ticker row at the + // bottom) is answered right where you are — no jump to a panel docked below the fold. + // Caps its own height to the viewport and scrolls internally for a long transcript; on a + // narrow screen it becomes near-full-width. z-index over the board; the shadow lifts it + // off the content it overlays. The ✕ (and any scrub, per scrubTo) closes it. +
+ setDrilldown(null)} + /> +
+ ) : null} + + )} + + ); +} + +function Header({ state, connected, lastUpdatedAt, costMode, setCostMode, onMode, pinned, onPin }: { state: DashboardState | null; connected: boolean; lastUpdatedAt: number | null; costMode: CostMode; setCostMode: (m: CostMode) => void; onMode: (m: "live" | "replay") => void; pinned: string | null; onPin: (f: string | null) => void }) { + return ( +
+
+

Consort · Agent Mission Control

+
+ {state?.feature ? ( + <> + {state.feature} · phase: {state.phase ?? "—"} + {state.atLive ? null : ` · viewing event ${state.atEventIndex} of ${state.totalEventCount}`} + {/* A divergent pin means the board is FILTERED to a feature the run has moved past. + Say so, in the run's own terms, so it can't be mistaken for a rewind — the + playhead is still where the transport shows it. */} + {state.pinnedFeature && state.features.find((f) => f.active) ? ( + + {" "} + · run is on {state.features.find((f) => f.active)!.id} + + ) : null} + + ) : "waiting for a run…"} +
+
+
+ + {/* Source mode. A switch when the environment offers both live and a readable corpus, + otherwise a plain badge — a control that can only be pressed one way is noise. The + warning tint carries `note`, which is how a misconfigured CONSORT_CORPUS_DIR + becomes visible instead of silently removing the replay option. */} + {state?.source ? ( + + {(state.source.availableModes.length > 1 ? state.source.availableModes : [state.source.mode]).map((m) => { + const on = m === state.source!.mode; + return ( + + ); + })} + {state.source.note ? : null} + + ) : null} + +
+ cost: + {(["show", "hidden"] as CostMode[]).map((m) => ( + + ))} +
+ +
+
+ ); +} + +// ☀️/🌙 toggle. Flipping data-theme on re-themes the board via CSS (see app/useTheme.ts); +// the choice persists to localStorage. Shows a neutral glyph until mounted so it doesn't +// hydrate-mismatch the viewer's stored preference. +function ThemeToggle() { + const { theme, toggle } = useTheme(); + const dark = theme === "dark"; + const label = theme === null ? "Toggle theme" : dark ? "Switch to light mode" : "Switch to dark mode"; + return ( + + ); +} + +// Connection health, with a staleness clock. `connected` flips false only when a poll actively +// FAILS — but a wedged poll chain (a request that never settles) leaves it stuck true while the +// board silently stops updating, which is the "event stream isn't refreshing" report. So this +// runs its OWN 1s tick and measures the age of the last successful update: even with zero poll +// re-renders, the age keeps climbing and the badge turns amber, making a frozen board obvious. +function ConnectionStatus({ connected, lastUpdatedAt }: { connected: boolean; lastUpdatedAt: number | null }) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + const ageSec = lastUpdatedAt === null ? null : Math.max(0, Math.round((now - lastUpdatedAt) / 1000)); + // A live board polls every ~1s; anything past a few seconds means updates have stopped, even + // if the last poll technically succeeded. 5s is comfortably past normal jitter. + const stale = ageSec !== null && ageSec > 5; + const color = !connected ? "var(--status-critical)" : stale ? "var(--status-warning)" : "var(--status-good)"; + const label = !connected ? "reconnecting" : stale ? `no update · ${ageSec}s` : "live"; + return ( +
+ + {label} +
+ ); +} + +// The sprint/feature selector. A multi-feature run scopes the board to one feature at a time +// (correctly — see the reducer), which leaves earlier features unreachable except by scrubbing +// the transport back past the sprint boundary, which no viewer discovers. This names every +// feature the run has touched and lets one be pinned. +// +// Two deliberate rules: +// - Shown only when the run has MORE than one feature. On a single-feature run it is a control +// that can only be pressed one way — noise — matching the mode switch's own rule. +// - Pinning is a FILTER, not a seek: it sets `pinned` and never touches the playhead. "Live" +// (null pin) follows the playhead's own feature, distinct from pinning that same feature, +// so a viewer who wants "just track whatever's active" isn't stuck on a stale pin. +function FeatureSwitcher({ features, pinned, onPin }: { features: DashboardState["features"]; pinned: string | null; onPin: (f: string | null) => void }) { + if (features.length < 2) return null; + const dot = (f: DashboardState["features"][number]) => + f.done ? "var(--status-good)" : f.active ? "var(--status-warning-amber)" : "var(--border-default)"; + const chipStyle = (on: boolean): CSSProperties => ({ + fontSize: "0.66rem", + fontWeight: 700, + letterSpacing: "0.04em", + color: on ? "var(--text-strong)" : "var(--text-faint)", + background: on ? "var(--surface-card)" : "transparent", + border: "none", + borderRadius: radius.chip, + padding: "2px 8px", + cursor: "pointer", + font: "inherit", + display: "flex", + alignItems: "center", + gap: 5, + }); + return ( + + {/* "Live" = follow the playhead's own feature (no pin). It is also highlighted when the + pin is STALE — the client still holds a feature id, but it names nothing in this + window (scrubbed back before it appears), so the fold dropped it and the board is in + fact following the playhead. Highlighting the held-but-inert chip would misrepresent + the filter's real state, so a pin that matches no chip reads as "follow". */} + + {features.map((f) => ( + + ))} + + ); +} + +function StatusBar({ state, showCost }: { state: DashboardState; showCost: boolean }) { + const gateColor = (s: string) => (s === "approved" ? "var(--status-good)" : s === "open" ? "var(--border-default)" : "var(--status-warning-amber)"); + const designActive = state.lane === "design"; + // A complete run has no active lane — neither bar should claim "in progress". + const buildActive = state.lane === "build"; + return ( +
+ {/* DESIGN lane → BUILD lane → per-story rows → Stories/gates → (optional) COST at bottom. */} + + + + {state.stories.length > 0 ? : null} + +
+ +
+
Sprint gates
+
+ {state.gates.length === 0 ? : state.gates.map((g) => ( + + {g.name} + + ))} +
+
+
+ + {showCost ? : null} +
+ ); +} + +function CostBar({ state }: { state: DashboardState }) { + const total = state.totalCost; + const contributors = state.agents.filter((a) => a.cost > 0).sort((a, b) => b.cost - a.cost); + return ( +
+
+ + Cost · ${total.toFixed(2)} + + relative contribution by agent +
+
+ {total === 0 + ? null + : contributors.map((a) => ( +
+ ))} +
+ {/* compact legend for the top contributors */} +
+ {contributors.map((a) => ( + + + {a.role} ${a.cost.toFixed(2)} + + ))} +
+
+ ); +} + +const PHASE_CFG = { + "not-started": { bg: "var(--surface-inset)", border: "var(--border-default)", text: "var(--text-faint)" }, + "in-progress": { bg: "var(--status-accent-tint)", border: "var(--status-accent)", text: "var(--status-accent-text)" }, + complete: { bg: "var(--status-good-tint)", border: "var(--status-good)", text: "var(--status-good-text)" }, +} as const; + +function DesignLane({ phases, active }: { phases: DashboardState["designPhases"]; active: boolean }) { + return ( +
+
+ Design {active ? "· in progress" : "· complete"} +
+
+ {phases.map((p) => { + const cfg = PHASE_CFG[p.status]; + return ( +
+ + {p.name} + + {p.looping && p.name === "reflect" ? ( + + ) : null} +
+ ); + })} +
+
+ ); +} + +function BuildLane({ state, active, complete }: { state: DashboardState; active: boolean; complete?: boolean }) { + const { testTotal, testByStatus: t, testPct, testsHistorical } = state.progress; + const seg = (n: number, color: string, label: string) => + n > 0 ?
: null; + + // No honest count for this position. Rather than show current counts under a past playhead + // — or a zeroed bar implying no tests existed — say plainly that the number isn't knowable. + // Everything else on the board does rewind. + // + // Two different situations reach this, so the wording can't name just one: in LIVE mode the + // counts come from the feature-status CLI and never rewind at all; in REPLAY they do rewind + // (from the corpus's per-turn test-list snapshots) but only from the first snapshot onward, + // so an early playhead genuinely predates any test list. + if (!testsHistorical) { + const replay = state.source?.mode === "replay"; + return ( +
+
+ + Build + + + {replay ? "no test list recorded yet at this point" : "test counts unavailable when scrubbed back"} + +
+
+
+ ); + } + + return ( +
+
+ + Build {complete ? "· run complete" : active ? "· in progress" : testTotal === 0 ? "· not started" : "· pending"} + + + {testTotal > 0 ? ( + <> + {t.red} red · {t.green + t.refactored} green · {testTotal} tests · {testPct}% + {/* A finished run with tests still pending never wrote them — say so, rather + than leaving a half-full bar looking like work in flight. */} + {complete && t.pending > 0 ? · {t.pending} never written : null} + + ) : "—"} + +
+
+ {/* order: green (done) → red (test written, failing) → pending (grey remainder) */} + {seg(t.green + t.refactored, `linear-gradient(90deg,var(--status-good),var(--status-good-light))`, "green (code written)")} + {seg(t.red, "var(--status-critical)", "red (test written, failing)")} + {seg(t.skipped, "var(--text-faint)", "skipped")} +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +// Per-story lifecycle: each row is a Design → Build → Done mini-track. Design & build +// iterate per story, so this shows e.g. "S1 done · S2 building · S3 still in design". +function StoryTracks({ stories }: { stories: StoryProgress[] }) { + return ( +
+
+ Stories +
+
+ {stories.map((s) => ( + + ))} +
+
+ ); +} + +function StoryRow({ s }: { s: StoryProgress }) { + // three steps; each is done / current / pending / discarded + const discarded = s.status === "discarded"; + const designState = s.stage === "design" && !s.designComplete ? "current" : "done"; // design always reached + const buildState = s.stage === "build" ? "current" : s.stage === "done" ? "done" : s.designComplete ? "pending" : "pending"; + // A story in the done stage is FINISHED — the done step settles to static green (or gold + // for a discard), never "current" (which pulses, misreading as still-working). + const doneState = discarded ? "discarded" : s.stage === "done" ? "done" : "pending"; + const steps: { label: string; state: "done" | "current" | "pending" | "discarded"; note?: string | null }[] = [ + { label: "design", state: designState, note: s.designPhase ? `→ ${s.designPhase}` : null }, + { label: "build", state: buildState }, + { label: discarded ? "discarded" : "done", state: doneState }, + ]; + const stepColor = (st: string) => + st === "done" ? { bg: "var(--status-good-tint)", border: "var(--status-good)", text: "var(--status-good-text)" } + : st === "discarded" ? { bg: "var(--status-discarded-tint)", border: "var(--status-discarded)", text: "var(--status-discarded-text)" } + : st === "current" ? { bg: "var(--status-accent-tint)", border: "var(--status-accent)", text: "var(--status-accent-text)" } + : { bg: "var(--surface-inset)", border: "var(--border-default)", text: "var(--text-faint)" }; + return ( +
+ + {s.active ? "▸ " : ""}{s.id} + +
+ {steps.map((step) => { + const c = stepColor(step.state); + return ( +
+ + {step.label}{step.state === "current" && step.note ? ` ${step.note}` : ""} + +
+ ); + })} +
+
+ ); +} + +// Log↔corpus pairing drift. The §6 risk table promises this is surfaced rather than silently +// mis-mapped: correlation is positional and cannot detect its own failure, so an off-by-one +// shows the wrong transcript and the wrong code for every later turn of a role, with no error. +// +// Renders NOTHING when healthy — including in live mode, where `correlation` is null because +// there is no corpus to disagree with. A permanent "pairing OK" chip would train the eye to +// ignore the one place it must not. + +function WaitingBanner({ waiting }: { waiting: NonNullable }) { + const isPerm = waiting.kind === "permission"; + const isEsc = waiting.kind === "escalation"; + // amber = Claude Code permission prompt · red = Consort escalation (failure) · purple = HITL gate + const c = isPerm + ? { border: "var(--status-warning)", bgA: "var(--status-warning-tint)", bgB: "var(--status-warning-tint-faint)", head: "var(--status-warning-text)", chipBorder: "var(--status-warning-soft)", chipText: "var(--status-warning-text-deep)" } + : isEsc + ? { border: "var(--status-critical)", bgA: "var(--status-critical-tint)", bgB: "var(--status-critical-tint-faint)", head: "var(--status-critical-text-deep)", chipBorder: "var(--status-critical-soft)", chipText: "var(--status-critical-text-deep)" } + : { border: "var(--status-gate)", bgA: "var(--status-gate-tint)", bgB: "var(--status-gate-tint-faint)", head: "var(--status-gate-text)", chipBorder: "var(--status-gate-soft)", chipText: "var(--status-gate-text-deep)" }; + // A session (a Consort role, or a human/proxy auto-resolving the escalation) is writing + // its transcript right now → an agent is actively working this, not idle-waiting on you. + const active = !isPerm && waiting.sessionActive === true; + const headline = isPerm + ? "⚠ Permission required in the Consort terminal" + : isEsc + ? active + ? `⚠ Escalation · being worked on${waiting.role ? ` · raised by ${waiting.role}` : ""}` + : `⚠ Consort escalated to you${waiting.role ? ` · raised by ${waiting.role}` : ""}` + : active + ? `⏸ Paused · being worked on${waiting.gate ? ` · ${waiting.gate} gate` : ""}${waiting.role ? ` · surfaced by ${waiting.role}` : ""}` + : `⏸ Consort is waiting on you${waiting.gate ? ` · ${waiting.gate} gate` : ""}${waiting.role ? ` · surfaced by ${waiting.role}` : ""}`; + return ( +
+
+ {headline} +
+
{waiting.prompt}
+ {!isPerm && waiting.sessionActive !== undefined ? ( +
+ + {active + ? "A session is actively working on this now — no action needed unless it stalls." + : `Idle — waiting on you${waiting.sessionActiveAgeSec != null ? ` · no session activity for ${waiting.sessionActiveAgeSec}s` : ""}.`} +
+ ) : null} + {isPerm && waiting.permission ? ( +
+ {waiting.permission.description ? ( +
{waiting.permission.description}
+ ) : null} + {waiting.permission.command ? ( + + $ {waiting.permission.command} + + ) : null} +
+ ) : null} + {waiting.options.length > 0 ? ( +
+ {waiting.options.map((o) => ( + {o.title} + ))} +
+ ) : null} +
+ ); +} + +function Blockers({ state }: { state: DashboardState }) { + return ( +
+ Open issues → resolver +
+ {state.blockers.map((b, i) => ( +
+
+ {b.source} + {b.story ? {b.story} : null} + {b.resolverRole ? ( + + → fix by {b.resolverRole} + + ) : null} +
+
{truncate(b.reason, 320)}
+
+ ))} +
+
+ ); +} + + + +function SectionTitle({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +// Top-level section label: Status / Current State / Event Stream. +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// `message`, not `text` — the latter would shadow the imported theme token. +function Placeholder({ message, error }: { message: string; error?: boolean }) { + return ( +
+ {message} +
+ ); +} + +function truncate(s: string, n: number) { + return s.length > n ? s.slice(0, n) + "…" : s; +} diff --git a/apps/dashboard/app/render.test.tsx b/apps/dashboard/app/render.test.tsx new file mode 100644 index 00000000..001337f7 --- /dev/null +++ b/apps/dashboard/app/render.test.tsx @@ -0,0 +1,557 @@ +/** + * Appearance-equivalence harness for the token refactor (Phase 1 item 6). + * + * The plan calls that refactor "a refactor of plumbing, not of appearance". lib/theme.test.ts + * pins each token to the literal it replaced, but that alone can't prove the right token + * reached the right element — mapping `#111827` to `text.strong` is correct for a heading and + * wrong for the ticker's background, and both compile. + * + * So render the real components against a real captured DashboardState and snapshot the + * resulting markup, inline styles included. Regenerated from `main` before the refactor, the + * snapshot is byte-identical after it — which is the actual claim being made. + * + * page.tsx is a client component whose board never renders server-side (the SSR output is + * just a loading shell), so this bypasses the polling hook and renders the exported pieces + * directly against fixed data. Date.now() is pinned because AgentBubble shows elapsed time. + */ +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { AgentBubble } from "./AgentBubble"; +import { WorkflowGraph } from "./WorkflowGraph"; +import { Transport } from "./Transport"; +import { LaneGraph } from "./LaneGraph"; +import { DrilldownPanel, turnUrl } from "./DrilldownPanel"; +import { DriftBanner, EventTicker, FidelityBanner, modeFromUrl } from "./board-parts"; +import type { DashboardState } from "@/lib/types"; + +const fixture = (name: string): DashboardState => + JSON.parse(readFileSync(join(__dirname, "..", "lib", "__fixtures__", name), "utf8")); + +const state = fixture("render-state.json"); +// The same run pinned at event 40 — atLive false, so snapshot-fenced panels must differ. +const scrubbed = fixture("render-state-scrubbed.json"); + +// AgentBubble renders "working 4m" from Date.now() - turnStartTs; pin it so the markup is +// deterministic. Chosen well after the fixture's timestamps so elapsed values are stable. +const FIXED_NOW = Date.parse("2026-08-05T00:00:00.000Z"); + +beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); +}); +afterAll(() => { + vi.useRealTimers(); +}); + +describe("render — appearance is unchanged by the token refactor", () => { + it("renders every agent bubble identically", () => { + // All ten roles, and with real data that means several distinct statuses. + const markup = state.agents + .map((a) => renderToStaticMarkup()) + .join("\n"); + expect(markup).toMatchSnapshot(); + }); + + it("renders bubbles the same with cost hidden", () => { + const markup = state.agents + .map((a) => renderToStaticMarkup()) + .join("\n"); + expect(markup).toMatchSnapshot(); + }); + + it("covers every AgentStatus, not just the ones this run happened to produce", () => { + // The fixture is one moment of one run, so it won't contain all five statuses. Synthesize + // the rest from a real agent so the tinted/pulsing variants are snapshotted too. + const base = state.agents[0]; + const statuses = ["working", "on-deck", "issue", "waiting", "idle"] as const; + const markup = statuses + .map((status) => + renderToStaticMarkup( + , + ), + ) + .join("\n"); + expect(markup).toMatchSnapshot(); + }); + + it("renders a working bubble in both liveness states", () => { + const base = { ...state.agents[0], status: "working" as const, turnStartTs: "2026-08-04T23:56:00.000Z" }; + const markup = [true, false, null] + .map((sessionActive) => renderToStaticMarkup()) + .join("\n"); + expect(markup).toMatchSnapshot(); + }); +}); + +// --------------------------------------------------------------------------- +// Item 5: the topology graph and transport. These have no pre-refactor baseline — they're +// new — so the snapshots pin them going forward rather than proving equivalence. + +describe("render — WorkflowGraph", () => { + it("renders the finished run: nodes reached, nothing active", () => { + // The real log ends with phase.end, so a completed run must show no active node. + expect(state.topology.activeNode).toBeNull(); + expect(renderToStaticMarkup()).toMatchSnapshot(); + }); + + it("renders the same run scrubbed back to event 40, with design active", () => { + expect(scrubbed.topology.activeNode).toBe("design"); + expect(renderToStaticMarkup()).toMatchSnapshot(); + }); + + it("lights the active node differently from a merely-reached one", () => { + const markup = renderToStaticMarkup(); + // the active node gets the accent + a 3px stroke; reached nodes get green at 1.5px + expect(markup).toContain("active now"); + expect(markup).toContain("stroke-width=\"3\""); + expect(markup).toContain("· reached"); + expect(markup).toContain("· not reached"); + }); + + it("marks gates as diamonds, not phases", () => { + // A human decision point must never read as just another phase node. + const markup = renderToStaticMarkup(); + expect((markup.match(/${laneId}<`); + if (start === -1) throw new Error(`lane ${laneId} not rendered`); + const end = markup.indexOf("margin-left:auto", start); + return markup.slice(start, end === -1 ? start + 400 : end); +} + +const renderLane = (s: DashboardState) => ; + +describe("render — LaneGraph", () => { + // Corpus-shaped states, which is where the single-feature fixtures above can't reach. All + // three of these are real playhead positions in stockflow-rerecord (see reducer.test.ts). + const withTopology = (over: Partial, rest: Partial = {}) => + ({ ...state, ...rest, topology: { ...state.topology, ...over } }) as DashboardState; + + it("does not claim a lane is not-started when the lifecycle has passed it", () => { + // Reported: at corpus events 20/90/230/260, passedNodes contains "plan" — the lifecycle + // graph directly above lights Plan green — while laneSteps.plan is empty, because no plan + // step predicate matches `breakdown` (the only plan phase attributed to a named feature + // after the PR #13 scoping). The header read "0/3 steps · not started" under a green Plan + // node. Two panels must not assert opposite things about the same phase. + const markup = renderToStaticMarkup( + renderLane(withTopology({ laneCurrent: null, passedNodes: ["intake", "plan", "design"], laneSteps: { plan: [], design: ["d-spec"], build: [] } })), + ); + const plan = laneHeader(markup, "plan"); + expect(plan).not.toContain("not started"); + // It ran, we just can't see which steps — say so rather than denying it happened. + expect(plan).toContain("complete"); + }); + + it("never pairs a step ratio with 'complete'", () => { + // Reported: passedNodes reaching deploy made the build lane read "1/7 steps · complete" — + // self-contradicting. The ratio is the misleading half, not the status: steps that never + // light (`b-perm` only on a supersession) are invisible to it, so it under-reports a lane + // that really did finish. Sweeping "complete only when every step is lit" across every + // prefix fold of both real logs refuted it — the corpus's SHIPPED run sits at plan 1/3, + // live at 0/3. So the lane stays complete and the ratio goes away. + const markup = renderToStaticMarkup( + renderLane( + withTopology( + { laneCurrent: null, activeNode: null, passedNodes: ["intake", "plan", "design", "build", "deploy"], laneSteps: { plan: [], design: ["d-spec"], build: ["b-red"] } }, + { lane: "build" }, + ), + ), + ); + const build = laneHeader(markup, "build"); + expect(build).toContain("complete"); + expect(build).not.toContain("1/7"); + expect(build).not.toContain("steps"); + }); + + it("keeps a lane in progress while the lifecycle is still inside its own node", () => { + // The mid-flight signal is `activeNode`, not step counts: a back-edge can send the run + // around a lane again after a later node was already reached, and only the lifecycle + // knows. Here build has been reached and deploy passed, but the playhead is back in + // `build` — so build must not read complete. + const markup = renderToStaticMarkup( + renderLane( + withTopology( + { laneCurrent: null, activeNode: "build", passedNodes: ["intake", "plan", "design", "build", "deploy"], laneSteps: { plan: [], design: ["d-spec"], build: ["b-red"] } }, + { lane: "build" }, + ), + ), + ); + const build = laneHeader(markup, "build"); + expect(build).toContain("in progress"); + expect(build).not.toContain("complete"); + // ...and while it's in progress the ratio is actionable, so it stays. + expect(build).toContain("1/7 steps"); + }); + + it("falls back to a real lane when laneCurrent names an unknown one", () => { + // topology.laneCurrent.lane is typed `string`, not LaneId, so an unrecognised value used + // to become the expanded lane, match no panel, and collapse all three — zero graphs, no + // error. A Phase 2 replay source emitting a different vocabulary is the realistic trigger. + const markup = renderToStaticMarkup( + renderLane(withTopology({ laneCurrent: { lane: "nonexistent", step: "x" } })), + ); + expect((markup.match(/aria-expanded/g) ?? []).length).toBe(3); + expect((markup.match(/ { + // The scrubbed fixture has laneCurrent = design/d-spec, so design expands and the other + // two collapse to summary rows. + expect(scrubbed.topology.laneCurrent).toEqual({ lane: "design", step: "d-spec" }); + const markup = renderToStaticMarkup(); + // three headers, one + expect((markup.match(/aria-expanded/g) ?? []).length).toBe(3); + expect((markup.match(/ { + // The finished-run fixture has laneCurrent = null (the log ends on phase.end). Falling + // back to "plan" would show the least interesting lane on a completed run; the furthest + // lane entered is the useful default. + expect(state.topology.laneCurrent).toBeNull(); + const markup = renderToStaticMarkup(); + expect((markup.match(/ { + // Gates never light from events (match: null), so they are excluded from the ratio — + // counting them would cap design at 6/7 forever, reading as permanently unfinished. + // + // Held at a playhead that has passed no lifecycle node, so no lane counts as complete and + // every ratio is on screen: a complete lane suppresses its ratio (see the test above), and + // this fixture is a shipped run where all three would otherwise be hidden. The lit-step + // sets are the shipped run's, which is what makes the denominators worth asserting. + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("6/6 steps"); // design: 6 lightable, all lit (d-gate excluded) + expect(markup).toContain("7/7 steps"); // build: all 7 lit on this run + // Plan reads 2/3, not 3/3: `p-req` never lights on the live stockflow log, because its + // product-owner emits only gate.approved and never the author-requests phase. That is a + // property of this run, not a bug — the corpus log does light it. + expect(markup).toContain("2/3 steps"); + }); + + it("takes gate state from the run's gates, not from the step data", () => { + // A lane gate can never light from laneSteps, so its only honest source is state.gates. + // Assert on a COLLAPSED lane's gate dot: the expanded lane in this fixture is build, + // whose only gate (b-verify) has a real match predicate and is already lit. + const open = renderToStaticMarkup(); + // the design lane's gate dot picks up the HITL purple for a surfaced-but-unapproved gate. + // The trailing ")" keeps this from matching var(--status-gate-tint). + expect(open).toContain("var(--status-gate)"); + + const approved = renderToStaticMarkup( + , + ); + // ...and green once cleared, without any step data changing + expect(approved).not.toContain("var(--status-gate)"); + }); + + it("draws back-edges as labelled branches, not happy path", () => { + // The build lane's five back-edges are the honest-GREEN recovery paths; they must be + // visually distinct (dashed + amber + labelled) or the cycle reads as linear. + const markup = renderToStaticMarkup(); + expect(markup).toContain("verify fails"); + expect(markup).toContain("regression"); + expect(markup).toContain("supersession"); + expect(markup).toContain("re-verify"); + }); + + it("survives an empty board without throwing", () => { + // A run with no events: every lane not started, nothing current, no gates. + const empty = { + ...state, + gates: [], + topology: { ...state.topology, passedNodes: [], laneSteps: { plan: [], design: [], build: [] }, laneCurrent: null }, + }; + const markup = renderToStaticMarkup(); + expect((markup.match(/aria-expanded/g) ?? []).length).toBe(3); + expect(markup).toContain("not started"); + expect(markup).toContain("0/3 steps"); + }); +}); + +describe("render — Transport", () => { + const noop = () => {}; + + it("renders following the live edge", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("LIVE"); + expect(markup).not.toContain("PINNED"); + expect(markup).toMatchSnapshot(); + }); + + it("renders pinned at an event, and says so", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("PINNED"); + expect(markup).not.toContain(">LIVE<"); + expect(markup).toMatchSnapshot(); + }); + + it("disables step-back at the start and step-forward at the end", () => { + const atStart = renderToStaticMarkup( + , + ); + // two disabled buttons would mean both ends; at the start only step-back is disabled + expect((atStart.match(/disabled=""/g) ?? []).length).toBe(1); + const atEnd = renderToStaticMarkup( + , + ); + expect((atEnd.match(/disabled=""/g) ?? []).length).toBe(1); + }); + + it("handles an empty log without producing a broken range input", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('max="0"'); + expect(markup).toContain("0 / 0"); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 3: the drill-down surfaces. TurnPanel fetches on mount, so SSR markup only shows its +// loading shell — which is exactly what should be asserted here (the fetch paths are covered by +// app/api/turn/route.test.ts against the real corpus). The interesting logic that IS testable +// server-side is the ticker's zip of recentEvents against recentTurns, where an off-by-one +// would open the wrong turn. + +const withSource = (over: Partial>): DashboardState => + ({ + ...state, + source: { + mode: "replay", + describe: "stockflow-rerecord (replay)", + capabilities: ["timeline", "transport", "transcripts", "artifactContent"], + availableModes: ["live", "replay"], + note: null, + correlation: null, + ...over, + }, + }) as DashboardState; + +const health = (over: Partial["correlation"]>> = {}) => ({ + healthy: true, + severity: "ok" as "ok" | "info" | "warning", + message: null, + paired: 71, + structural: 10, + unpairedEvents: 0, + kitVersionMatch: true as boolean | null, + recentTurns: [], + ...over, +}); + +describe("render — EventTicker turn affordance", () => { + it("marks only the rows that begin a recorded turn", () => { + // One openable row among several, positioned to catch a shift: recentTurns is aligned to + // recentEvents by index, so marking row 1 must mark the SECOND event, not the first. + const s = withSource({ + correlation: health({ recentTurns: state.recentEvents.map((_, i) => (i === 1 ? 7 : null)) }), + }); + const markup = renderToStaticMarkup( {}} />); + expect((markup.match(/turn 7 ›/g) ?? []).length).toBe(1); + // ...and no other row claims a turn. + expect((markup.match(/turn \d+ ›/g) ?? []).length).toBe(1); + }); + + it("shows no affordance when the source cannot drill down", () => { + // Live mode: correlation is null, so no row is clickable and the ticker looks as it always + // has. A dead "turn N" chip would invite clicks that 409. + const markup = renderToStaticMarkup(); + expect(markup).not.toContain("›"); + }); + + it("does not offer rows when onOpenTurn is absent even if turns are known", () => { + // Belt and braces: the capability gate lives in page.tsx, so the ticker must not render an + // affordance it cannot honour. + const s = withSource({ correlation: health({ recentTurns: state.recentEvents.map(() => 3) }) }); + expect(renderToStaticMarkup()).not.toContain("turn 3 ›"); + }); +}); + +describe("render — DriftBanner", () => { + it("renders nothing when pairing is healthy", () => { + // A permanent "pairing OK" chip would train the eye to ignore the one place it must not. + expect(renderToStaticMarkup()).toBe(""); + // ...and nothing in live mode, where there is no corpus to disagree with. + expect(renderToStaticMarkup()).toBe(""); + }); + + it("treats a kit-version mismatch as a quiet pairing NOTE, not a critical alert (info)", () => { + // Kevin's ask: a kit-version drift is an expected observability caveat, not a run failure, so + // it must not wear the critical-red alert weight that reads as "the orchestrator is broken". + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Live view pairing note"); + expect(markup).toContain("different kit versions"); + expect(markup).toContain("Turn drill-downs may be approximate"); + expect(markup).toContain("kit version mismatch"); + // The structural count is labelled as expected, so it never reads as part of the problem. + expect(markup).toContain("10 structural (expected)"); + // Quiet: a polite note, never the assertive critical alert. + expect(markup).toContain('role="note"'); + expect(markup).not.toContain('role="alert"'); + }); + + it("flags a role the corpus never recorded as a prominent WARNING — a likely different run", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Corpus pairing unreliable"); + expect(markup).toContain("may be a different run"); + // The paired count is kept, so partial trust stays legible. + expect(markup).toContain("71 paired · 4 unpaired"); + expect(markup).toContain('role="alert"'); + expect(markup).not.toContain("kit version mismatch"); // that isn't this failure + }); +}); + +describe("render — FidelityBanner", () => { + // A minimal SourceMeta; `caps` and `fidelity` are the only things this banner reasons about. + type Src = NonNullable; + const src = (caps: Src["capabilities"], fidelity: Src["fidelity"]): DashboardState["source"] => + ({ mode: "live", describe: "proj", capabilities: caps, availableModes: ["live"], note: null, correlation: null, fidelity }) as DashboardState["source"]; + + it("renders nothing for replay (no fidelity — a corpus is full-fidelity by definition)", () => { + // Even a replay corpus that happens to lack correspondence must not nag: re-running won't fix + // a recorded corpus, and fidelity is null for replay. + expect(renderToStaticMarkup()).toBe(""); + }); + + it("shows on a NOT-recording live build and points at LAKEBASE_CONSORT_RECORD_DIR", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("not recording"); + expect(markup).toContain("prompts & inputs"); + expect(markup).toContain("the HIL↔orchestrator conversation"); + expect(markup).toContain("point-in-time per-step snapshots"); + expect(markup).toContain("LAKEBASE_CONSORT_RECORD_DIR"); + expect(markup).toContain("Available: current outputs (at HEAD)"); + }); + + it("STILL shows on a recording live build that can't yet surface the streams (points at replay, not re-run)", () => { + // The core review finding: `recording:true` must not silently hide the banner while the + // capabilities that surface those streams are absent — that leaves a recording build with no + // drill-down AND no explanation. It shows, with replay guidance instead of the re-run advice. + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("limited live view"); + expect(markup).toContain("Open the recorded corpus in replay"); + expect(markup).not.toContain("LAKEBASE_CONSORT_RECORD_DIR"); + }); + + it("hides once the live board has every richer capability (nothing to warn about)", () => { + // Capability-driven visibility: when transcripts + correspondence + stepOutputs are all + // present (the Phase B end state), `missing` is empty and the banner removes itself. + const full = src( + ["timeline", "featureStatus", "artifactContent", "transcripts", "correspondence", "stepOutputs"], + { recording: true }, + ); + expect(renderToStaticMarkup()).toBe(""); + }); + + it("never renders a broken 'Available: .' sentence when no available caps are present", () => { + const markup = renderToStaticMarkup(); + expect(markup).not.toContain("Available: ."); + expect(markup).toContain("Not captured:"); + }); +}); + +describe("render — DrilldownPanel", () => { + it("renders a loading shell for a turn target without fetching server-side", () => { + // The panel fetches in an effect, which never runs under renderToStaticMarkup — so this + // pins the shell a viewer sees for one frame, and proves the component doesn't throw + // when its data is absent. + const markup = renderToStaticMarkup( {}} />); + expect(markup).toContain("TURN 16"); + expect(markup).toContain("Loading turn 16…"); + expect(markup).toContain("Close drill-down panel"); // always escapable + }); + + it("renders a loading shell for an artifact target with the HEAD honesty label", () => { + // The live half: one file at HEAD, labelled as such so it's never mistaken for a snapshot. + const markup = renderToStaticMarkup( {}} />); + expect(markup).toContain("ARTIFACT"); + expect(markup).toContain("design/ia.md"); + expect(markup).toContain("content at HEAD"); + expect(markup).toContain("Close drill-down panel"); + }); + + it("renders a loading shell for a step target", () => { + const markup = renderToStaticMarkup( {}} />); + expect(markup).toContain("STEP OUTPUTS"); + expect(markup).toContain("Close drill-down panel"); + }); + + it("builds turn URLs without asserting a mode it wasn't given", () => { + // The panel is gated on the `transcripts` capability rather than on mode === "replay", so a + // future non-replay source with a turns corpus must not be silently handed the replay one. + // Null means "server's choice", matching /api/state. + expect(turnUrl(16, "replay")).toBe("/api/turn/16?mode=replay"); + expect(turnUrl(16, null)).toBe("/api/turn/16"); + // File paths are encoded, and the ?/& is never hand-assembled. + expect(turnUrl(16, "replay", "app/a.ts")).toBe("/api/turn/16?mode=replay&file=app%2Fa.ts"); + expect(turnUrl(16, null, "app/a.ts")).toBe("/api/turn/16?file=app%2Fa.ts"); + // A path with a literal `&` must not be able to inject another parameter. + expect(turnUrl(16, null, "a&mode=live.ts")).toBe("/api/turn/16?file=a%26mode%3Dlive.ts"); + }); +}); + +describe("modeFromUrl", () => { + it("reads a valid mode and ignores anything else", () => { + // `?mode=` makes a replay board linkable. Found by driving the page with ?mode=replay and + // getting live: the param reached /api/state but nothing read it on the client. + expect(modeFromUrl("?mode=replay")).toBe("replay"); + expect(modeFromUrl("?at=40&mode=live")).toBe("live"); + // An unknown value must fall back to the server's choice, not request a mode that can't + // exist — validated against the union so a typo can't disable the board. + expect(modeFromUrl("?mode=REPLAY")).toBeNull(); + expect(modeFromUrl("?mode=corpus")).toBeNull(); + expect(modeFromUrl("?mode=")).toBeNull(); + expect(modeFromUrl("")).toBeNull(); + expect(modeFromUrl("?at=40")).toBeNull(); + }); +}); diff --git a/apps/dashboard/app/usePolledState.test.ts b/apps/dashboard/app/usePolledState.test.ts new file mode 100644 index 00000000..fd122984 --- /dev/null +++ b/apps/dashboard/app/usePolledState.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { pollBackoffMs } from "./usePolledState"; + +// The hook itself can't run under this project's node (no-DOM) test environment, so the +// backoff curve is extracted as a pure function and tested here. These lock in the two +// properties that matter for the "board stopped refreshing" class of bug: a healthy poll +// stays at the base interval, and a failing one recovers to a bounded ceiling rather than +// the old 30s that read as frozen. +describe("pollBackoffMs", () => { + const BASE = 1000; + + it("polls at the base interval when the last request succeeded", () => { + expect(pollBackoffMs(0, BASE)).toBe(BASE); + }); + + it("backs off exponentially on consecutive failures", () => { + expect(pollBackoffMs(1, BASE)).toBe(2000); + expect(pollBackoffMs(2, BASE)).toBe(4000); + expect(pollBackoffMs(3, BASE)).toBe(8000); + }); + + it("caps the backoff so a recovered server is picked up promptly", () => { + // 2^4 * 1000 = 16000 would exceed the ceiling; it must clamp. + expect(pollBackoffMs(4, BASE)).toBe(8000); + expect(pollBackoffMs(50, BASE)).toBe(8000); + }); + + it("never returns a delay above the ceiling regardless of interval", () => { + for (let f = 0; f <= 20; f++) { + expect(pollBackoffMs(f, 3000)).toBeLessThanOrEqual(8000); + } + }); +}); diff --git a/apps/dashboard/app/usePolledState.ts b/apps/dashboard/app/usePolledState.ts new file mode 100644 index 00000000..fd3f7616 --- /dev/null +++ b/apps/dashboard/app/usePolledState.ts @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { DashboardState } from "@/lib/types"; + +// Client-side polling with exponential backoff on failure, mirroring the pipeline-app pattern. +// +// `at` drives time travel. When null the board follows the live edge; when set to an event +// index the fold is evaluated there instead. Polling continues either way, deliberately: +// a pinned board still wants a fresh `totalEventCount` so the transport's right edge keeps +// growing as the run progresses. The fold clamps `at`, so a stale index degrades to the +// live edge rather than erroring. + +// Per-request timeout. Without one, a single hung/slow `/api/state` freezes the whole poll +// chain: the next tick cannot fire until the current fetch settles, so the board silently stops +// refreshing with no reconnecting signal — exactly the "event stream isn't refreshing" report. +// MUST sit ABOVE the server's own worst case: `readFeatureStatus` shells out to the lk CLI with +// a 15s timeout (lib/consort.ts), so a legitimate cold-cache request can take that long. A +// client timeout below 15s would abort healthy-but-slow requests and force needless backoff. +const REQUEST_TIMEOUT_MS = 20_000; + +// Backoff ceiling. The old 30s cap meant a live board could sit 30s stale after a transient +// blip; for a monitoring view that reads as "frozen". Cap lower so a recovered server is picked +// back up quickly — hammering a truly-down local dev server a little harder is a fine trade. +const MAX_BACKOFF_MS = 8_000; + +/** + * Delay before the next poll given how many consecutive failures have occurred. + * Exported (and pure) so the backoff curve is unit-testable without a DOM/timer harness — + * the hook itself can't run under the node test environment this project uses. + * failCount 0 (last poll ok) → the base interval; each further failure doubles, capped. + */ +export function pollBackoffMs(failCount: number, intervalMs: number): number { + return Math.min(intervalMs * Math.pow(2, failCount), MAX_BACKOFF_MS); +} + +export function usePolledState( + intervalMs = 2000, + at: number | null = null, + mode: "live" | "replay" | null = null, + // A pinned feature (FeatureSwitcher). Null follows the playhead's own feature. Like `at`, it + // is a display filter — the poll continues either way, and the fold drops a stale id. + feature: string | null = null, +) { + const [state, setState] = useState(null); + const [connected, setConnected] = useState(false); + // When the last successful poll landed (epoch ms), so the UI can surface staleness — a board + // that stopped updating should say so rather than looking like a slow-but-live run. + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const failRef = useRef(0); + const timerRef = useRef | null>(null); + // Generation guard. poll() reschedules itself, so a chain outlives the effect that began + // it: without this, a second effect run (a scrub, or React's mount+remount in dev Strict + // Mode) starts a SECOND self-perpetuating chain while `timerRef` tracks only the newest — + // doubling request volume and orphaning a loop that cleanup can no longer stop. Each chain + // checks it still owns the current generation before fetching and before rescheduling. + const genRef = useRef(0); + + useEffect(() => { + const myGen = ++genRef.current; + + const poll = async () => { + if (myGen !== genRef.current) return; // superseded + // Abort a request that outruns the timeout so it can't wedge the chain. A slow response + // that arrives after the abort is discarded by the generation/abort guards. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const q = new URLSearchParams(); + if (at !== null) q.set("at", String(at)); + // Omitted when null so the server keeps its own default (live), rather than the + // client asserting a mode before it knows which are available. + if (mode !== null) q.set("mode", mode); + if (feature !== null) q.set("feature", feature); + const url = q.size > 0 ? `/api/state?${q}` : "/api/state"; + const r = await fetch(url, { cache: "no-store", signal: controller.signal }); + const data = (await r.json()) as DashboardState; + if (myGen !== genRef.current) return; // don't clobber a newer chain's state + setState(data); + setConnected(true); + setLastUpdatedAt(Date.now()); + failRef.current = 0; + } catch { + // Covers network errors, non-JSON bodies (a dev error overlay), AND the abort above. + if (myGen !== genRef.current) return; + failRef.current += 1; + setConnected(false); + } finally { + clearTimeout(timeout); + if (myGen === genRef.current) { + timerRef.current = setTimeout(poll, pollBackoffMs(failRef.current, intervalMs)); + } + } + }; + + // Runs on mount AND whenever `at`, `mode` or `feature` changes, so scrubbing or pinning a + // feature repaints immediately instead of waiting for the next tick. + poll(); + + return () => { + genRef.current++; // retire this chain + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [intervalMs, at, mode, feature]); + + return { state, connected, lastUpdatedAt }; +} diff --git a/apps/dashboard/app/useTheme.ts b/apps/dashboard/app/useTheme.ts new file mode 100644 index 00000000..15d4a0aa --- /dev/null +++ b/apps/dashboard/app/useTheme.ts @@ -0,0 +1,38 @@ +"use client"; + +// Theme toggle state for the in-app ☀️/🌙 button. Color switching itself is pure CSS — the +// two token blocks in lib/theme.ts hang off `data-theme` on — so this hook only flips +// that attribute, persists the choice to localStorage, and tracks the value for the glyph. +// +// The actual initial theme is applied before paint by the no-flash script in layout.tsx +// (query > localStorage > env default). Here we read it back on mount, so the button matches +// whatever the page booted with. `theme` is null until mounted to avoid a hydration mismatch +// on the glyph (the server can't know the viewer's localStorage). + +import { useEffect, useState } from "react"; + +export type Theme = "light" | "dark"; + +export function useTheme() { + const [theme, setTheme] = useState(null); + + useEffect(() => { + const current = document.documentElement.getAttribute("data-theme"); + setTheme(current === "dark" ? "dark" : "light"); + }, []); + + const toggle = () => { + setTheme((prev) => { + const next: Theme = (prev ?? "light") === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + try { + localStorage.setItem("theme", next); + } catch { + // private mode / storage disabled — the flip still applies for this session. + } + return next; + }); + }; + + return { theme, toggle }; +} diff --git a/apps/dashboard/lib/__fixtures__/kevin-workflow.json b/apps/dashboard/lib/__fixtures__/kevin-workflow.json new file mode 100644 index 00000000..7da9acc0 --- /dev/null +++ b/apps/dashboard/lib/__fixtures__/kevin-workflow.json @@ -0,0 +1,486 @@ +{ + "_comment": "Verbatim extraction of WORKFLOW from Kevin Hartman's build_dashboard.py, via ast.literal_eval of the source literal. Do not hand-edit. Regenerate with scripts/extract-kevin-workflow.py. Guards lib/topology.ts against transcription drift; intentional deviations are declared in topology.test.ts.", + "_source": { + "file": "build_dashboard.py", + "symbol": "WORKFLOW", + "line": 384, + "literal_sha256": "5796743238375a2c3781294fd74cb71a2ac7bad88060aa6370bf0624b725d89e", + "kit_describe": "v0.3.6", + "kit_commit": "cad5f5fb" + }, + "nodes": [ + { + "id": "intake", + "label": "Intake", + "roles": [], + "type": "phase" + }, + { + "id": "plan", + "label": "Plan", + "roles": [ + "spec-author", + "architect-reviewer", + "product-owner" + ], + "type": "phase" + }, + { + "id": "plangate", + "label": "plan gate", + "roles": [], + "type": "gate" + }, + { + "id": "design", + "label": "Design lane", + "roles": [ + "spec-author", + "architect-reviewer", + "dba", + "test-strategist", + "ux-designer" + ], + "type": "phase" + }, + { + "id": "specgate", + "label": "spec + test-list gates", + "roles": [], + "type": "gate" + }, + { + "id": "build", + "label": "Build lane", + "roles": [ + "navigator", + "driver" + ], + "type": "phase" + }, + { + "id": "deploy", + "label": "Deploy", + "roles": [ + "release-engineer" + ], + "type": "phase" + }, + { + "id": "deploygate", + "label": "deploy gate", + "roles": [], + "type": "gate" + }, + { + "id": "promote", + "label": "Promote", + "roles": [ + "release-engineer" + ], + "type": "phase" + }, + { + "id": "promgate", + "label": "promote gate", + "roles": [], + "type": "gate" + }, + { + "id": "shipped", + "label": "Shipped", + "roles": [], + "type": "phase" + } + ], + "edges": [ + [ + "intake", + "plan" + ], + [ + "plan", + "plangate" + ], + [ + "plangate", + "design" + ], + [ + "design", + "specgate" + ], + [ + "specgate", + "build" + ], + [ + "build", + "deploy" + ], + [ + "deploy", + "deploygate" + ], + [ + "deploygate", + "promote" + ], + [ + "promote", + "promgate" + ], + [ + "promgate", + "shipped" + ], + [ + "shipped", + "plan" + ] + ], + "phaseToNode": { + "propose": "plan", + "estimate": "plan", + "estimate-committed": "plan", + "author-requests": "plan", + "breakdown": "plan", + "feature": "plan", + "assess": "plan", + "workflow": "plan", + "design": "design", + "build": "build", + "red": "build", + "green": "build", + "refactor": "build", + "review": "build", + "reflect": "build", + "repair": "build", + "RED": "build", + "deploy": "deploy", + "promote": "promote", + "estimate ": "plan" + }, + "lanes": { + "plan": { + "title": "Plan \u00b7 sprint planning", + "steps": [ + { + "id": "p-propose", + "role": "spec-author", + "label": "Spec author", + "sub": "propose features", + "match": { + "role": "spec-author", + "phase": "propose" + } + }, + { + "id": "p-size", + "role": "architect-reviewer", + "label": "Architect", + "sub": "t-shirt sizing (estimate)", + "match": { + "role": "architect-reviewer", + "phaseAny": [ + "estimate", + "estimate-committed" + ] + } + }, + { + "id": "p-req", + "role": "product-owner", + "label": "Product owner", + "sub": "author requests", + "match": { + "role": "product-owner", + "phaseAny": [ + "author-requests", + "feature" + ] + } + }, + { + "id": "p-gate", + "role": null, + "label": "Plan gate", + "sub": "human approves backlog", + "gate": true, + "match": null + } + ], + "edges": [ + [ + "p-propose", + "p-size" + ], + [ + "p-size", + "p-req" + ], + [ + "p-req", + "p-gate" + ] + ], + "backEdges": [] + }, + "design": { + "title": "Design lane \u00b7 spec-first (per story)", + "steps": [ + { + "id": "d-ux", + "role": "ux-designer", + "label": "UX designer", + "sub": "design guide (once)", + "match": { + "role": "ux-designer" + } + }, + { + "id": "d-spec", + "role": "spec-author", + "label": "Spec author", + "sub": "acceptance criteria", + "match": { + "role": "spec-author", + "phaseNot": [ + "propose", + "estimate", + "author-requests", + "breakdown" + ] + } + }, + { + "id": "d-arch", + "role": "architect-reviewer", + "label": "Architect", + "sub": "annotate layers", + "match": { + "role": "architect-reviewer", + "phaseNot": [ + "estimate" + ] + } + }, + { + "id": "d-dba", + "role": "dba", + "label": "DBA", + "sub": "realize schema", + "match": { + "role": "dba" + } + }, + { + "id": "d-ts", + "role": "test-strategist", + "label": "Test strategist", + "sub": "test list", + "match": { + "role": "test-strategist" + } + }, + { + "id": "d-nav", + "role": "navigator", + "label": "Navigator", + "sub": "reflect / critique", + "match": { + "role": "navigator", + "buildMode": "reflect", + "phase": "reflect" + } + }, + { + "id": "d-gate", + "role": null, + "label": "Spec gate", + "sub": "human approves", + "gate": true, + "match": null + } + ], + "edges": [ + [ + "d-ux", + "d-spec" + ], + [ + "d-spec", + "d-arch" + ], + [ + "d-arch", + "d-dba" + ], + [ + "d-dba", + "d-ts" + ], + [ + "d-ts", + "d-nav" + ], + [ + "d-nav", + "d-gate" + ] + ], + "backEdges": [ + [ + "d-nav", + "d-spec", + "revise on findings" + ] + ] + }, + "build": { + "title": "Build lane \u00b7 honest-GREEN cycle (Branched-Database TDD)", + "steps": [ + { + "id": "b-red", + "role": "navigator", + "label": "Navigator", + "sub": "write failing test (RED)", + "match": { + "role": "navigator", + "phase": "red", + "buildModeNot": [ + "reflect", + "review", + "assess", + "assess-refactor", + "assess-deploy" + ] + } + }, + { + "id": "b-green", + "role": "driver", + "label": "Driver", + "sub": "minimal honest code (GREEN)", + "match": { + "role": "driver", + "buildModeNot": [ + "refactor", + "repair", + "refactor-superseded", + "refactor-deploy" + ] + } + }, + { + "id": "b-verify", + "role": null, + "label": "Verify", + "sub": "run vs real branch", + "gate": true, + "match": { + "eventPrefix": "verify" + } + }, + { + "id": "b-review", + "role": "navigator", + "label": "Navigator", + "sub": "review / refactor", + "match": { + "role": "navigator", + "buildMode": "review" + } + }, + { + "id": "b-assess", + "role": "navigator", + "label": "Navigator", + "sub": "assess: regression or supersession?", + "branch": true, + "match": { + "role": "navigator", + "buildModeAny": [ + "assess", + "assess-refactor", + "assess-deploy" + ] + } + }, + { + "id": "b-repair", + "role": "driver", + "label": "Driver", + "sub": "repair code, never tests", + "branch": true, + "match": { + "role": "driver", + "buildModeAny": [ + "repair" + ] + } + }, + { + "id": "b-perm", + "role": "driver", + "label": "Driver", + "sub": "permissive-green (superseded only)", + "branch": true, + "match": { + "role": "driver", + "buildModeAny": [ + "refactor-superseded", + "refactor", + "refactor-deploy" + ] + } + } + ], + "edges": [ + [ + "b-red", + "b-green" + ], + [ + "b-green", + "b-verify" + ], + [ + "b-verify", + "b-review" + ], + [ + "b-review", + "b-red" + ] + ], + "backEdges": [ + [ + "b-verify", + "b-assess", + "verify fails" + ], + [ + "b-assess", + "b-repair", + "regression" + ], + [ + "b-assess", + "b-perm", + "supersession" + ], + [ + "b-repair", + "b-green", + "re-verify" + ], + [ + "b-perm", + "b-green", + "re-verify" + ] + ] + } + } +} diff --git a/apps/dashboard/lib/__fixtures__/render-state-scrubbed.json b/apps/dashboard/lib/__fixtures__/render-state-scrubbed.json new file mode 100644 index 00000000..4289cb11 --- /dev/null +++ b/apps/dashboard/lib/__fixtures__/render-state-scrubbed.json @@ -0,0 +1 @@ +{"ok": true, "error": null, "projectDir": "/Users/cathy.snell/Code/consort-lab/stockflow", "feature": "F1-stock-visibility", "phase": "build", "agents": [{"role": "orchestrator", "status": "idle", "work": null, "phase": null, "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": "2026-07-30T19:39:04.830Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "spec-author", "status": "idle", "work": "spec-author START design", "phase": "design", "story": "S1-record-stock", "model": "haiku", "cost": 0.26479660000000005, "turns": 4, "lastTs": "2026-07-30T19:39:11.081Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "ux-designer", "status": "idle", "work": "ux-designer START design", "phase": "design", "story": null, "model": "haiku", "cost": 0.30781310000000006, "turns": 2, "lastTs": "2026-07-30T19:38:44.601Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "architect-reviewer", "status": "working", "work": "architect-reviewer START design", "phase": "design", "story": "S1-record-stock", "model": "haiku", "cost": 0.043916800000000006, "turns": 1, "lastTs": "2026-07-30T19:39:04.830Z", "issues": [], "turnStartTs": "2026-07-30T19:39:04.830Z", "sessionActive": null}, {"role": "dba", "status": "idle", "work": null, "phase": null, "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": null, "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "test-strategist", "status": "idle", "work": null, "phase": null, "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": null, "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "navigator", "status": "idle", "work": null, "phase": null, "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": null, "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "driver", "status": "idle", "work": null, "phase": null, "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": null, "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "product-owner", "status": "idle", "work": null, "phase": null, "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": null, "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "release-engineer", "status": "idle", "work": null, "phase": null, "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": null, "issues": [], "turnStartTs": null, "sessionActive": null}], "gates": [{"name": "spec", "status": "open"}, {"name": "plan", "status": "open"}, {"name": "test_list", "status": "open"}, {"name": "promote", "status": "approved"}, {"name": "deploy", "status": "approved"}], "blockers": [], "waiting": {"kind": "gate", "gate": "spec", "role": "spec-author", "prompt": "Approve the spec for S2?", "options": [{"id": "a", "title": "Approve"}], "sessionActive": true, "sessionActiveAgeSec": 3}, "progress": {"testTotal": 29, "testDone": 13, "testPct": 45, "storiesTotal": 3, "storiesDone": 2, "testByStatus": {"pending": 16, "red": 0, "green": 13, "refactored": 0, "skipped": 0}}, "designPhases": [{"name": "propose", "status": "complete", "current": false, "looping": false}, {"name": "estimate", "status": "complete", "current": false, "looping": false}, {"name": "breakdown", "status": "complete", "current": false, "looping": false}, {"name": "design", "status": "complete", "current": false, "looping": false}, {"name": "reflect", "status": "complete", "current": false, "looping": false}], "stories": [{"id": "S1-record-stock", "status": "ready", "stage": "design", "designComplete": true, "designPhase": null, "gateApproved": true, "active": true}, {"id": "S2-stock-home-screen", "status": "done", "stage": "done", "designComplete": true, "designPhase": null, "gateApproved": true, "active": false}, {"id": "S3-sku-detail-view", "status": "done", "stage": "done", "designComplete": true, "designPhase": null, "gateApproved": true, "active": false}], "lane": "build", "totalCost": 0.6165265000000001, "eventCount": 40, "recentEvents": [{"timestamp": "2026-07-30T17:36:01.627Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for propose", "metadata": {"feature_id": "", "to_role": "spec-author", "phase": "propose", "mode": "propose"}}, {"timestamp": "2026-07-30T17:36:01.638Z", "level": "info", "role": "spec-author", "model": "haiku", "event": "phase.start", "message": "spec-author START propose", "metadata": {"feature_id": "", "phase": "propose", "mode": "propose"}}, {"timestamp": "2026-07-30T17:36:27.383Z", "level": "info", "role": "spec-author", "model": "haiku", "event": "turn.usage", "message": "spec-author turn used 29 input + 1589 output tokens", "metadata": {"feature_id": "", "duration_ms": 25744, "input_tokens": 29, "output_tokens": 1589, "cache_read_tokens": 28291, "cache_creation_tokens": 24755, "cost_usd": 0.0603131, "phase": "propose"}}, {"timestamp": "2026-07-30T17:36:27.384Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for estimate", "metadata": {"feature_id": "", "to_role": "architect-reviewer", "phase": "estimate", "mode": "estimate"}}, {"timestamp": "2026-07-30T17:36:27.384Z", "level": "info", "role": "architect-reviewer", "model": "haiku", "event": "phase.start", "message": "architect-reviewer START estimate", "metadata": {"feature_id": "", "phase": "estimate", "mode": "estimate"}}, {"timestamp": "2026-07-30T17:36:56.394Z", "level": "info", "role": "architect-reviewer", "model": "haiku", "event": "turn.usage", "message": "architect-reviewer turn used 48 input + 1534 output tokens", "metadata": {"feature_id": "", "duration_ms": 29009, "input_tokens": 48, "output_tokens": 1534, "cache_read_tokens": 76868, "cache_creation_tokens": 14256, "cost_usd": 0.043916800000000006, "phase": "estimate"}}, {"timestamp": "2026-07-30T19:35:15.198Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for breakdown", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "spec-author", "phase": "breakdown", "mode": "breakdown"}}, {"timestamp": "2026-07-30T19:35:15.209Z", "level": "info", "role": "spec-author", "model": "haiku", "event": "phase.start", "message": "spec-author START breakdown", "metadata": {"feature_id": "F1-stock-visibility", "phase": "breakdown", "mode": "breakdown"}}, {"timestamp": "2026-07-30T19:35:34.443Z", "level": "info", "role": "spec-author", "model": "haiku", "event": "turn.usage", "message": "spec-author turn used 24 input + 1674 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 18987, "input_tokens": 24, "output_tokens": 1674, "cache_read_tokens": 21495, "cache_creation_tokens": 16681, "cost_usd": 0.0439055, "phase": "breakdown"}}, {"timestamp": "2026-07-30T19:35:34.913Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote feature-spec.json , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "feature-spec.json", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/feature-spec.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:35:34.924Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "story stub S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/story.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:35:34.924Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S2-stock-home-table , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "story stub S2-stock-home-table", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S2-stock-home-table/story.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:35:34.924Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "story stub S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/story.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:35:34.930Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch ux-designer for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "ux-designer", "phase": "design"}}, {"timestamp": "2026-07-30T19:35:34.931Z", "level": "info", "role": "ux-designer", "model": "haiku", "event": "phase.start", "message": "ux-designer START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design"}}, {"timestamp": "2026-07-30T19:36:14.763Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch ux-designer for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "ux-designer", "phase": "design"}}, {"timestamp": "2026-07-30T19:36:14.773Z", "level": "info", "role": "ux-designer", "model": "haiku", "event": "phase.start", "message": "ux-designer START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design"}}, {"timestamp": "2026-07-30T19:37:08.556Z", "level": "info", "role": "ux-designer", "model": "haiku", "event": "turn.usage", "message": "ux-designer turn used 84 input + 7710 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 93625, "input_tokens": 84, "output_tokens": 7710, "cache_read_tokens": 204980, "cache_creation_tokens": 33439, "cost_usd": 0.12601}}, {"timestamp": "2026-07-30T19:37:08.844Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote design-guide.json , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "design-guide.json", "summary": "present on disk (reconciled)", "path": "design/design-guide.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:37:08.855Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote design-guide.md , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "design-guide.md", "summary": "present on disk (reconciled)", "path": "design/design-guide.md", "reconciled": true}}, {"timestamp": "2026-07-30T19:37:08.855Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote ia.md , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "ia.md", "summary": "present on disk (reconciled)", "path": "design/ia.md", "reconciled": true}}, {"timestamp": "2026-07-30T19:37:08.863Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "spec-author", "phase": "design", "story": "S1-record-stock"}}, {"timestamp": "2026-07-30T19:37:08.864Z", "level": "info", "role": "spec-author", "model": "haiku", "event": "phase.start", "message": "spec-author START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S1-record-stock"}}, {"timestamp": "2026-07-30T19:38:44.601Z", "level": "info", "role": "ux-designer", "model": "haiku", "event": "turn.usage", "message": "ux-designer turn used 109 input + 14052 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 149828, "input_tokens": 109, "output_tokens": 14052, "cache_read_tokens": 464621, "cache_creation_tokens": 32486, "cost_usd": 0.18180310000000002}}, {"timestamp": "2026-07-30T19:38:44.823Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "spec-author", "phase": "design", "story": "S1-record-stock"}}, {"timestamp": "2026-07-30T19:38:44.823Z", "level": "info", "role": "spec-author", "model": "haiku", "event": "phase.start", "message": "spec-author START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S1-record-stock"}}, {"timestamp": "2026-07-30T19:39:04.604Z", "level": "info", "role": "spec-author", "model": "haiku", "event": "turn.usage", "message": "spec-author turn used 24 input + 1929 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 19781, "input_tokens": 24, "output_tokens": 1929, "cache_read_tokens": 21983, "cache_creation_tokens": 17078, "cost_usd": 0.04602330000000001, "story": "S1-record-stock"}}, {"timestamp": "2026-07-30T19:39:04.812Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC1-create-stock-record for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC1-create-stock-record for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC1-create-stock-record.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC1-form-displays for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC1-form-displays for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC1-form-displays.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC2-form-accepts-valid-input for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC2-form-accepts-valid-input for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC2-form-accepts-valid-input.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC2-store-inventory-code for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC2-store-inventory-code for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC2-store-inventory-code.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC3-collision-resolution for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC3-collision-resolution for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC3-collision-resolution.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC3-duplicate-sku-location-pair-resolves-on-write for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC3-duplicate-sku-location-pair-resolves-on-write for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC3-duplicate-sku-location-pair-resolves-on-write.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC4-confirmation-feedback for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC4-confirmation-feedback for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC4-confirmation-feedback.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC4-same-sku-multiple-locations for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC4-same-sku-multiple-locations for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC4-same-sku-multiple-locations.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC5-tracking-code-stored for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC5-tracking-code-stored for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC5-tracking-code-stored.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.824Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC6-data-survives-schema-change for story S1-record-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC6-data-survives-schema-change for story S1-record-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-record-stock/acs/AC6-data-survives-schema-change.json", "reconciled": true}}, {"timestamp": "2026-07-30T19:39:04.830Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "architect-reviewer", "phase": "design", "story": "S1-record-stock"}}, {"timestamp": "2026-07-30T19:39:04.830Z", "level": "info", "role": "architect-reviewer", "model": "haiku", "event": "phase.start", "message": "architect-reviewer START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S1-record-stock"}}, {"timestamp": "2026-07-30T19:39:11.081Z", "level": "info", "role": "spec-author", "model": "haiku", "event": "turn.usage", "message": "spec-author turn used 30 input + 13410 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 122217, "input_tokens": 30, "output_tokens": 13410, "cache_read_tokens": 68927, "cache_creation_tokens": 20291, "cost_usd": 0.11455470000000001, "story": "S1-record-stock"}}], "generatedAt": "2026-08-05T15:42:04.634Z", "atEventIndex": 40, "totalEventCount": 380, "atLive": false, "snapshotAsOf": "2026-08-01T04:40:27.636Z", "topology": {"passedNodes": ["plan", "design"], "activeNode": "design", "laneSteps": {"plan": ["p-propose", "p-size"], "design": ["d-spec", "d-ux", "d-arch"], "build": []}, "laneCurrent": {"lane": "design", "step": "d-spec"}, "atTimestamp": "2026-07-30T19:39:11.081Z"}} \ No newline at end of file diff --git a/apps/dashboard/lib/__fixtures__/render-state.json b/apps/dashboard/lib/__fixtures__/render-state.json new file mode 100644 index 00000000..b388c0a2 --- /dev/null +++ b/apps/dashboard/lib/__fixtures__/render-state.json @@ -0,0 +1 @@ +{"ok": true, "error": null, "projectDir": "/Users/cathy.snell/Code/consort-lab/stockflow", "feature": "F1-stock-visibility", "phase": "build", "agents": [{"role": "orchestrator", "status": "idle", "work": "orchestrator START build", "phase": "build", "story": "S1-record-stock", "model": null, "cost": 0, "turns": 0, "lastTs": "2026-08-03T15:09:36.905Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "spec-author", "status": "idle", "work": "spec-author START design", "phase": "design", "story": "S3-sku-detail-view", "model": "sonnet", "cost": 1.3215343, "turns": 9, "lastTs": "2026-07-31T17:20:04.887Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "ux-designer", "status": "idle", "work": "ux-designer START design", "phase": "design", "story": null, "model": "sonnet", "cost": 0.5929925, "turns": 3, "lastTs": "2026-07-30T20:09:59.793Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "architect-reviewer", "status": "idle", "work": "architect-reviewer START design", "phase": "design", "story": "S3-sku-detail-view", "model": "sonnet", "cost": 2.4252673, "turns": 10, "lastTs": "2026-07-31T17:21:20.214Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "dba", "status": "idle", "work": "dba START design", "phase": "design", "story": "S1-record-stock", "model": "sonnet", "cost": 2.8725802000000003, "turns": 27, "lastTs": "2026-07-31T15:27:17.864Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "test-strategist", "status": "idle", "work": "test-strategist START design", "phase": "design", "story": "S3-sku-detail-view", "model": "sonnet", "cost": 1.7282265, "turns": 6, "lastTs": "2026-07-31T17:24:26.378Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "navigator", "status": "idle", "work": "navigator START review", "phase": "review", "story": "S1-record-stock", "model": "sonnet", "cost": 19.181238899999997, "turns": 20, "lastTs": "2026-08-01T04:37:52.796Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "driver", "status": "idle", "work": "driver START refactor", "phase": "refactor", "story": "S1-record-stock", "model": "sonnet", "cost": 9.702175199999997, "turns": 10, "lastTs": "2026-08-01T04:39:43.670Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "product-owner", "status": "idle", "work": null, "phase": null, "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": "2026-08-03T15:07:20.281Z", "issues": [], "turnStartTs": null, "sessionActive": null}, {"role": "release-engineer", "status": "idle", "work": "release-engineer START promote", "phase": "promote", "story": null, "model": null, "cost": 0, "turns": 0, "lastTs": "2026-08-03T14:40:35.920Z", "issues": [], "turnStartTs": null, "sessionActive": null}], "gates": [{"name": "spec", "status": "open"}, {"name": "plan", "status": "open"}, {"name": "test_list", "status": "open"}, {"name": "promote", "status": "approved"}, {"name": "deploy", "status": "approved"}], "blockers": [{"source": "verify.failed", "reason": "GREEN verify failed on S2", "story": "S2", "resolverRole": "driver", "resolverHint": "repair code"}], "waiting": {"kind": "gate", "gate": "spec", "role": "spec-author", "prompt": "Approve the spec for S2?", "options": [{"id": "a", "title": "Approve"}, {"id": "b", "title": "Revise"}], "sessionActive": true, "sessionActiveAgeSec": 3}, "progress": {"testTotal": 29, "testDone": 13, "testPct": 45, "storiesTotal": 3, "storiesDone": 2, "testByStatus": {"pending": 16, "red": 0, "green": 13, "refactored": 0, "skipped": 0}}, "designPhases": [{"name": "propose", "status": "complete", "current": false, "looping": false}, {"name": "estimate", "status": "complete", "current": false, "looping": false}, {"name": "breakdown", "status": "complete", "current": false, "looping": false}, {"name": "design", "status": "complete", "current": false, "looping": false}, {"name": "reflect", "status": "complete", "current": false, "looping": false}], "stories": [{"id": "S1-record-stock", "status": "ready", "stage": "design", "designComplete": true, "designPhase": null, "gateApproved": true, "active": true}, {"id": "S2-stock-home-screen", "status": "done", "stage": "done", "designComplete": true, "designPhase": null, "gateApproved": true, "active": false}, {"id": "S3-sku-detail-view", "status": "done", "stage": "done", "designComplete": true, "designPhase": null, "gateApproved": true, "active": false}], "lane": "build", "totalCost": 37.824014899999995, "eventCount": 380, "recentEvents": [{"timestamp": "2026-08-01T04:29:42.459Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 36 input + 20129 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 412644, "input_tokens": 36, "output_tokens": 20129, "cache_read_tokens": 1423814, "cache_creation_tokens": 76842, "cost_usd": 1.1902392, "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:29:42.701Z", "level": "info", "role": "navigator", "event": "cycle.red", "message": "RED 13 test(s) in cycle-001 [API], lead T3 (AC2-submit-creates-retrievable-record): POST /stock with valid sku, location, quantity, and inventory_code creates a new record; a subsequent GET for that (sku, location) pair returns the same field values", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "cycle-001", "test_id": "T3", "ac": "AC2-submit-creates-retrievable-record", "asserts": "POST /stock with valid sku, location, quantity, and inventory_code creates a new record; a subsequent GET for that (sku, location) pair returns the same field values", "layer": "API", "batch": 13}}, {"timestamp": "2026-08-01T04:29:42.969Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "green", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:29:42.969Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F1-stock-visibility", "phase": "green", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:35:36.276Z", "level": "info", "role": "driver", "event": "reasoning", "message": "S1-record-stock: all layers exist (route/service/repository/models/migrations). POST /api/stock and GET /api/skus/{sku}/stock wired. Layering contract satisfied (route has no DB session import). DB constraints present in migrations. Running tests to confirm GREEN.", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "S1-record-stock", "note": "S1-record-stock: all layers exist (route/service/repository/models/migrations). POST /api/stock and GET /api/skus/{sku}/stock wired. Layering contract satisfied (route has no DB session import). DB constraints present in migrations. Running tests to confirm GREEN."}}, {"timestamp": "2026-08-01T04:36:23.490Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 52 input + 20738 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 400519, "input_tokens": 52, "output_tokens": 20738, "cache_read_tokens": 2041513, "cache_creation_tokens": 67345, "cost_usd": 1.3277498999999997, "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:37:14.418Z", "level": "info", "role": "driver", "event": "cycle.green", "message": "GREEN T3 [AC2-submit-creates-retrievable-record]: minimal honest code", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "cycle-001", "test_id": "T3", "ac": "AC2-submit-creates-retrievable-record", "change": "minimal honest code"}}, {"timestamp": "2026-08-01T04:37:18.375Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for review", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "review", "story": "S1-record-stock", "buildMode": "review"}}, {"timestamp": "2026-08-01T04:37:18.375Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "phase.start", "message": "navigator START review", "metadata": {"feature_id": "F1-stock-visibility", "phase": "review", "story": "S1-record-stock", "buildMode": "review"}}, {"timestamp": "2026-08-01T04:37:52.551Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "turn.usage", "message": "navigator turn used 7 input + 1233 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 34175, "input_tokens": 7, "output_tokens": 1233, "cache_read_tokens": 80755, "cache_creation_tokens": 52903, "cost_usd": 0.36016050000000005, "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:37:52.796Z", "level": "info", "role": "navigator", "event": "cycle.review", "message": "REVIEW [S1-record-stock] refactor=true: Submit button uses hardcoded `borderRadius: '0'` (RecordStockPage.tsx line 255) instead of a design token (e.g. `var(--radius-sm)`), violating the radius token group contract. All other layer boundaries, NFR coverage (R2 CheckConstraint, R3 UniqueConstraint, R4 real-branch tests, R5 React SPA, validation messages), and design-token usage are correct. Fix: replace `borderRadius: \"0\"` with `borderRadius: \"var(--radius-sm)\"`.", "metadata": {"feature_id": "F1-stock-visibility", "ac": "S1-record-stock", "refactor": true, "rationale": "Submit button uses hardcoded `borderRadius: '0'` (RecordStockPage.tsx line 255) instead of a design token (e.g. `var(--radius-sm)`), violating the radius token group contract. All other layer boundaries, NFR coverage (R2 CheckConstraint, R3 UniqueConstraint, R4 real-branch tests, R5 React SPA, validation messages), and design-token usage are correct. Fix: replace `borderRadius: \"0\"` with `borderRadius: \"var(--radius-sm)\"`.", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:37:53.068Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for refactor", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "refactor", "story": "S1-record-stock", "buildMode": "refactor"}}, {"timestamp": "2026-08-01T04:37:53.068Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START refactor", "metadata": {"feature_id": "F1-stock-visibility", "phase": "refactor", "story": "S1-record-stock", "buildMode": "refactor"}}, {"timestamp": "2026-08-01T04:38:42.885Z", "level": "info", "role": "driver", "event": "reasoning", "message": "Replaced hardcoded borderRadius:0 with var(--radius-sm) token on RecordStockPage.tsx submit button; client tests green", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "S1-record-stock", "note": "Replaced hardcoded borderRadius:0 with var(--radius-sm) token on RecordStockPage.tsx submit button; client tests green"}}, {"timestamp": "2026-08-01T04:38:46.881Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 15 input + 1730 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 53811, "input_tokens": 15, "output_tokens": 1730, "cache_read_tokens": 258124, "cache_creation_tokens": 35973, "cost_usd": 0.3192702, "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:39:43.670Z", "level": "info", "role": "driver", "event": "cycle.refactored", "message": "REFACTOR [S1-record-stock]: addressed: Submit button uses hardcoded `borderRadius: '0'` (RecordStockPage.tsx line 255) instead of a design token (e.g. `var(--radius-sm)`), violating the radius token group contract. All other layer boundaries, NFR coverage (R2 CheckConstraint, R3 UniqueConstraint, R4 real-branch tests, R5 React SPA, validation messages), and design-token usage are correct. Fix: replace `borderRadius: \"0\"` with `borderRadius: \"var(--radius-sm)\"`.", "metadata": {"feature_id": "F1-stock-visibility", "ac": "S1-record-stock", "change": "addressed: Submit button uses hardcoded `borderRadius: '0'` (RecordStockPage.tsx line 255) instead of a design token (e.g. `var(--radius-sm)`), violating the radius token group contract. All other layer boundaries, NFR coverage (R2 CheckConstraint, R3 UniqueConstraint, R4 real-branch tests, R5 React SPA, validation messages), and design-token usage are correct. Fix: replace `borderRadius: \"0\"` with `borderRadius: \"var(--radius-sm)\"`.", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:39:47.608Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:39:47.608Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE acceptance awaiting decision , story S1-record-stock", "metadata": {"feature_id": "F1-stock-visibility", "gate": "acceptance", "subject": "story S1-record-stock", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:39:48.085Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start story S1-record-stock -> local", "metadata": {"feature_id": "F1-stock-visibility", "scope": "story S1-record-stock", "target": "local", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:40:27.356Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 37144)", "metadata": {"url": "http://localhost:8000/", "pid": 37144, "feature_id": "F1-stock-visibility", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:40:27.357Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed story S1-record-stock (./scripts/run-tests.sh)", "metadata": {"scope": "story S1-record-stock", "command": "./scripts/run-tests.sh", "feature_id": "F1-stock-visibility", "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:40:27.357Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified story S1-record-stock @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F1-stock-visibility", "scope": "story S1-record-stock", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true, "story": "S1-record-stock"}}, {"timestamp": "2026-08-01T04:40:27.357Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "outcome": "verified", "ok": true, "story": "S1-record-stock"}}, {"timestamp": "2026-08-03T14:00:12.196Z", "level": "info", "role": "orchestrator", "event": "phase.end", "message": "orchestrator END feature (complete)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "feature", "outcome": "complete"}}, {"timestamp": "2026-08-03T14:33:49.543Z", "level": "info", "role": "orchestrator", "event": "phase.end", "message": "orchestrator END feature (complete)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "feature", "outcome": "complete"}}, {"timestamp": "2026-08-03T14:33:49.690Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy"}}, {"timestamp": "2026-08-03T14:33:50.116Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start feature F1-stock-visibility -> local", "metadata": {"feature_id": "F1-stock-visibility", "scope": "feature F1-stock-visibility", "target": "local"}}, {"timestamp": "2026-08-03T14:34:20.096Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 44904)", "metadata": {"url": "http://localhost:8000/", "pid": 44904, "feature_id": "F1-stock-visibility"}}, {"timestamp": "2026-08-03T14:34:20.096Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed feature F1-stock-visibility (./scripts/run-tests.sh)", "metadata": {"scope": "feature F1-stock-visibility", "command": "./scripts/run-tests.sh", "feature_id": "F1-stock-visibility"}}, {"timestamp": "2026-08-03T14:34:20.096Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified feature F1-stock-visibility @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F1-stock-visibility", "scope": "feature F1-stock-visibility", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true}}, {"timestamp": "2026-08-03T14:34:20.096Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "outcome": "verified", "ok": true}}, {"timestamp": "2026-08-03T14:40:16.420Z", "level": "info", "role": "product-owner", "event": "gate.approved", "message": "GATE deploy APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "deploy", "artifacts": ["deploy-evidence.json"], "approver": "Cathy Snell", "validated": true}}, {"timestamp": "2026-08-03T14:40:35.920Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START promote", "metadata": {"feature_id": "F1-stock-visibility", "phase": "promote"}}, {"timestamp": "2026-08-03T14:40:35.937Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: prepare-pr", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: prepare-pr"}}, {"timestamp": "2026-08-03T14:40:41.829Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: wait-ci", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: wait-ci"}}, {"timestamp": "2026-08-03T14:44:55.929Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: wait-ci", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: wait-ci"}}, {"timestamp": "2026-08-03T15:03:52.520Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: wait-ci", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: wait-ci"}}, {"timestamp": "2026-08-03T15:07:20.281Z", "level": "info", "role": "product-owner", "event": "gate.approved", "message": "GATE promote APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "promote", "artifacts": ["promote_ref"], "approver": "Cathy Snell", "validated": true}}, {"timestamp": "2026-08-03T15:08:28.689Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: merge", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: merge"}}, {"timestamp": "2026-08-03T15:09:36.905Z", "level": "info", "role": "orchestrator", "event": "phase.end", "message": "orchestrator END workflow (complete)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "workflow", "outcome": "complete"}}], "generatedAt": "2026-08-05T15:45:58.189Z", "atEventIndex": 380, "totalEventCount": 380, "atLive": true, "snapshotAsOf": "2026-08-01T04:40:27.636Z", "topology": {"passedNodes": ["plan", "design", "build", "deploy", "promote"], "activeNode": null, "laneSteps": {"plan": ["p-propose", "p-size"], "design": ["d-spec", "d-ux", "d-arch", "d-dba", "d-ts", "d-nav"], "build": ["b-red", "b-green", "b-assess", "b-review", "b-perm", "b-verify", "b-repair"]}, "laneCurrent": null, "atTimestamp": "2026-08-03T15:09:36.905Z"}} \ No newline at end of file diff --git a/apps/dashboard/lib/__fixtures__/stockflow-f1-replay-agent-log.jsonl b/apps/dashboard/lib/__fixtures__/stockflow-f1-replay-agent-log.jsonl new file mode 100644 index 00000000..d348dbd2 --- /dev/null +++ b/apps/dashboard/lib/__fixtures__/stockflow-f1-replay-agent-log.jsonl @@ -0,0 +1,136 @@ +{"timestamp":"2026-08-12T15:01:47.072Z","level":"info","role":"product-owner","event":"intake.supplied","message":"INTAKE supplied product-overview.md","metadata":{"artifact":"product-overview.md","from":"/Users/cathy.snell/.claude/plugins/marketplaces/databricks-solutions/examples/sftdd-scenarios/stockflow-f1-only/intake/product-overview.md","to":"/Users/cathy.snell/consort-coldtest/stockflow-f1-run2/.sftdd/product-overview.md","approver":"human-proxy","validated":true}} +{"timestamp":"2026-08-12T15:01:47.406Z","level":"info","role":"product-owner","event":"intake.supplied","message":"INTAKE supplied nfrs.md","metadata":{"artifact":"nfrs.md","from":"/Users/cathy.snell/.claude/plugins/marketplaces/databricks-solutions/examples/sftdd-scenarios/stockflow-f1-only/intake/nfrs.md","to":"/Users/cathy.snell/consort-coldtest/stockflow-f1-run2/.sftdd/nfrs.md","approver":"human-proxy","validated":true}} +{"timestamp":"2026-08-12T15:01:47.754Z","level":"info","role":"product-owner","event":"intake.supplied","message":"INTAKE supplied design-brief.md","metadata":{"artifact":"design-brief.md","from":"/Users/cathy.snell/.claude/plugins/marketplaces/databricks-solutions/examples/sftdd-scenarios/stockflow-f1-only/intake/design-brief.md","to":"/Users/cathy.snell/consort-coldtest/stockflow-f1-run2/.sftdd/design/design-brief.md","approver":"human-proxy","validated":true}} +{"timestamp":"2026-08-12T15:02:00.491Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch spec-author for breakdown","metadata":{"feature_id":"F1-stock-visibility","to_role":"spec-author","phase":"breakdown","mode":"breakdown"}} +{"timestamp":"2026-08-12T15:02:00.500Z","level":"info","role":"spec-author","model":"opus","event":"phase.start","message":"spec-author START breakdown","metadata":{"feature_id":"F1-stock-visibility","phase":"breakdown","mode":"breakdown"}} +{"timestamp":"2026-08-12T15:02:01.118Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote feature-spec.json , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"feature-spec.json","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/feature-spec.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.130Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote story stub S1-file-stock , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"story stub S1-file-stock","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S1-file-stock/story.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.130Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote story stub S2-stock-by-location-table , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"story stub S2-stock-by-location-table","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S2-stock-by-location-table/story.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.130Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote story stub S3-sku-detail-view , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"story stub S3-sku-detail-view","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S3-sku-detail-view/story.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.139Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch ux-designer for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"ux-designer","phase":"design"}} +{"timestamp":"2026-08-12T15:02:01.139Z","level":"info","role":"ux-designer","model":"sonnet","event":"phase.start","message":"ux-designer START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design"}} +{"timestamp":"2026-08-12T15:02:01.349Z","level":"info","role":"ux-designer","event":"artifact.written","message":"ux-designer wrote design-guide.json , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"design-guide.json","summary":"present on disk (reconciled)","path":"design/design-guide.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.362Z","level":"info","role":"ux-designer","event":"artifact.written","message":"ux-designer wrote design-guide.md , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"design-guide.md","summary":"present on disk (reconciled)","path":"design/design-guide.md","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.362Z","level":"info","role":"ux-designer","event":"artifact.written","message":"ux-designer wrote ia.md , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"ia.md","summary":"present on disk (reconciled)","path":"design/ia.md","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.371Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch spec-author for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"spec-author","phase":"design","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:01.371Z","level":"info","role":"spec-author","model":"opus","event":"phase.start","message":"spec-author START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:01.582Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC1-file-stock-record for story S1-file-stock , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC1-file-stock-record for story S1-file-stock","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S1-file-stock/acs/AC1-file-stock-record.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.594Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC2-retrieve-stock-record for story S1-file-stock , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC2-retrieve-stock-record for story S1-file-stock","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S1-file-stock/acs/AC2-retrieve-stock-record.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.594Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC3-collision-resolved-at-write for story S1-file-stock , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC3-collision-resolved-at-write for story S1-file-stock","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S1-file-stock/acs/AC3-collision-resolved-at-write.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.601Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch architect-reviewer for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"architect-reviewer","phase":"design","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:01.602Z","level":"info","role":"architect-reviewer","model":"opus","event":"phase.start","message":"architect-reviewer START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:01.819Z","level":"info","role":"architect-reviewer","event":"reasoning","message":"established project architecture conventions: boundary=app/routes, service=app/services, repository=app/repositories, models=app/models","metadata":{"feature_id":"F1-stock-visibility","note":"established project architecture conventions: boundary=app/routes, service=app/services, repository=app/repositories, models=app/models"}} +{"timestamp":"2026-08-12T15:02:01.831Z","level":"info","role":"architect-reviewer","event":"reasoning","message":"established project architecture canon: layers=[E2E] nfrs=[] invariants=[unique, not_null, check, transactional, migration_reversible]","metadata":{"feature_id":"F1-stock-visibility","note":"established project architecture canon: layers=[E2E] nfrs=[] invariants=[unique, not_null, check, transactional, migration_reversible]"}} +{"timestamp":"2026-08-12T15:02:01.832Z","level":"info","role":"architect-reviewer","event":"artifact.written","message":"architect-reviewer wrote architecture.json , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"architecture.json","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/architecture.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.832Z","level":"info","role":"architect-reviewer","event":"artifact.written","message":"architect-reviewer wrote architecture conventions (project) , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"architecture conventions (project)","summary":"present on disk (reconciled)","path":"architecture/conventions.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:01.840Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch dba for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"dba","phase":"design","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:01.840Z","level":"info","role":"dba","model":"opus","event":"phase.start","message":"dba START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:02.067Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch test-strategist for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"test-strategist","phase":"design","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:02.068Z","level":"info","role":"test-strategist","model":"sonnet","event":"phase.start","message":"test-strategist START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:02.346Z","level":"info","role":"test-strategist","event":"artifact.written","message":"test-strategist wrote test-list.json , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"test-list.json","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/test-list.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:02.358Z","level":"info","role":"test-strategist","event":"artifact.written","message":"test-strategist wrote per-story test list for S1-file-stock , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"per-story test list for S1-file-stock","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S1-file-stock/test-list-per-story.json","reconciled":true}} +{"timestamp":"2026-08-12T15:02:02.368Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for reflect","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"reflect","story":"S1-file-stock","buildMode":"reflect"}} +{"timestamp":"2026-08-12T15:02:02.368Z","level":"info","role":"navigator","model":"sonnet","event":"phase.start","message":"navigator START reflect","metadata":{"feature_id":"F1-stock-visibility","phase":"reflect","story":"S1-file-stock","buildMode":"reflect"}} +{"timestamp":"2026-08-12T15:02:02.825Z","level":"info","role":"orchestrator","event":"gate.surfaced","message":"GATE spec awaiting decision , story S1-file-stock","metadata":{"feature_id":"F1-stock-visibility","gate":"spec","subject":"story S1-file-stock","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:03.047Z","level":"info","role":"orchestrator","event":"gate.approved","message":"GATE spec APPROVED","metadata":{"feature_id":"F1-stock-visibility","gate":"spec","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:03.285Z","level":"info","role":"orchestrator","event":"phase.start","message":"orchestrator START build","metadata":{"feature_id":"F1-stock-visibility","phase":"build","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:03.514Z","level":"info","role":"orchestrator","event":"experiment.cut","message":"EXPERIMENT cut for S1-file-stock","metadata":{"feature_id":"F1-stock-visibility","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:22.460Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for red","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"red","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:22.460Z","level":"info","role":"navigator","model":"sonnet","event":"phase.start","message":"navigator START red","metadata":{"feature_id":"F1-stock-visibility","phase":"red","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:22.731Z","level":"info","role":"navigator","event":"cycle.red","message":"RED 17 test(s) in cycle-001 [E2E], lead T1 (AC1-file-stock-record): filing a stock level (unique uuid-suffixed sku/location, cleaned up after) for a SKU at a location durably persists a stock_records row on the branch DB capturing sku, location, quantity, and combined inventory_code","metadata":{"feature_id":"F1-stock-visibility","cycle_id":"cycle-001","test_id":"T1","ac":"AC1-file-stock-record","asserts":"filing a stock level (unique uuid-suffixed sku/location, cleaned up after) for a SKU at a location durably persists a stock_records row on the branch DB capturing sku, location, quantity, and combined inventory_code","layer":"E2E","batch":17}} +{"timestamp":"2026-08-12T15:02:22.991Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch driver for green","metadata":{"feature_id":"F1-stock-visibility","to_role":"driver","phase":"green","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:22.991Z","level":"info","role":"driver","model":"sonnet","event":"phase.start","message":"driver START green","metadata":{"feature_id":"F1-stock-visibility","phase":"green","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:23.281Z","level":"info","role":"driver","event":"cycle.green","message":"GREEN T1 [AC1-file-stock-record]: minimal honest code","metadata":{"feature_id":"F1-stock-visibility","cycle_id":"cycle-001","test_id":"T1","ac":"AC1-file-stock-record","change":"minimal honest code"}} +{"timestamp":"2026-08-12T15:02:27.917Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for review","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"review","story":"S1-file-stock","buildMode":"review"}} +{"timestamp":"2026-08-12T15:02:27.917Z","level":"info","role":"navigator","model":"sonnet","effort":"low","event":"phase.start","message":"navigator START review","metadata":{"feature_id":"F1-stock-visibility","phase":"review","story":"S1-file-stock","buildMode":"review"}} +{"timestamp":"2026-08-12T15:02:28.162Z","level":"info","role":"navigator","event":"cycle.review","message":"REVIEW [S1-file-stock] refactor=false: Layer boundaries are respected: route validates + delegates to service, service delegates to repository, no Session usage in routes or services. StaticFiles mount is guarded by os.path.isdir so no import-time build coupling. Design tokens delivered via CSS custom properties consumed by components via var(--token). NFR-F1-1 (immutable created_at via ON CONFLICT DO UPDATE), F1-2 (CHECK constraint + Pydantic validator + service guard), F1-3 (UNIQUE constraint + upsert), F1-4 (real branch DB in tests), F1-5 (SPA + JSON boundary), F1-6 (field-named validation messages), and F1-7 (DATABASE_URL from env) are all satisfied. No concrete improvement warranted.","metadata":{"feature_id":"F1-stock-visibility","ac":"S1-file-stock","refactor":false,"rationale":"Layer boundaries are respected: route validates + delegates to service, service delegates to repository, no Session usage in routes or services. StaticFiles mount is guarded by os.path.isdir so no import-time build coupling. Design tokens delivered via CSS custom properties consumed by components via var(--token). NFR-F1-1 (immutable created_at via ON CONFLICT DO UPDATE), F1-2 (CHECK constraint + Pydantic validator + service guard), F1-3 (UNIQUE constraint + upsert), F1-4 (real branch DB in tests), F1-5 (SPA + JSON boundary), F1-6 (field-named validation messages), and F1-7 (DATABASE_URL from env) are all satisfied. No concrete improvement warranted.","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:28.378Z","level":"info","role":"release-engineer","event":"phase.start","message":"release-engineer START deploy","metadata":{"feature_id":"F1-stock-visibility","phase":"deploy","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:28.378Z","level":"info","role":"orchestrator","event":"gate.surfaced","message":"GATE acceptance awaiting decision , story S1-file-stock","metadata":{"feature_id":"F1-stock-visibility","gate":"acceptance","subject":"story S1-file-stock","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:02:28.765Z","level":"info","role":"release-engineer","event":"deploy.start","message":"DEPLOY start story S1-file-stock -> local","metadata":{"feature_id":"F1-stock-visibility","scope":"story S1-file-stock","target":"local","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:03:22.761Z","level":"info","role":"release-engineer","event":"deploy.reachable","message":"DEPLOY reachable http://localhost:8000/ (pid 29365)","metadata":{"url":"http://localhost:8000/","pid":29365,"feature_id":"F1-stock-visibility","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:03:22.761Z","level":"info","role":"release-engineer","event":"verify.passed","message":"VERIFY passed story S1-file-stock (./scripts/run-tests.sh)","metadata":{"scope":"story S1-file-stock","command":"./scripts/run-tests.sh","feature_id":"F1-stock-visibility","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:03:22.763Z","level":"info","role":"release-engineer","event":"deploy.verified","message":"DEPLOY verified story S1-file-stock @ http://localhost:8000/ , verify passed","metadata":{"feature_id":"F1-stock-visibility","scope":"story S1-file-stock","url":"http://localhost:8000/","verify_status":"passed","target":"local","reachable":true,"verify_passed":true,"story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:03:22.764Z","level":"info","role":"release-engineer","event":"phase.end","message":"release-engineer END deploy (verified)","metadata":{"feature_id":"F1-stock-visibility","phase":"deploy","outcome":"verified","ok":true,"story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:03:23.025Z","level":"info","role":"orchestrator","event":"experiment.accepted","message":"EXPERIMENT accepted (merged) for S1-file-stock","metadata":{"feature_id":"F1-stock-visibility","story":"S1-file-stock"}} +{"timestamp":"2026-08-12T15:03:38.671Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch spec-author for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"spec-author","phase":"design","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:38.671Z","level":"info","role":"spec-author","model":"opus","event":"phase.start","message":"spec-author START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:38.907Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC1-table-lists-stock-by-location for story S2-stock-by-location-table , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC1-table-lists-stock-by-location for story S2-stock-by-location-table","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S2-stock-by-location-table/acs/AC1-table-lists-stock-by-location.json","reconciled":true}} +{"timestamp":"2026-08-12T15:03:38.920Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC2-quantity-right-aligned for story S2-stock-by-location-table , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC2-quantity-right-aligned for story S2-stock-by-location-table","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S2-stock-by-location-table/acs/AC2-quantity-right-aligned.json","reconciled":true}} +{"timestamp":"2026-08-12T15:03:38.920Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC3-empty-location-state for story S2-stock-by-location-table , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC3-empty-location-state for story S2-stock-by-location-table","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S2-stock-by-location-table/acs/AC3-empty-location-state.json","reconciled":true}} +{"timestamp":"2026-08-12T15:03:38.931Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch test-strategist for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"test-strategist","phase":"design","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:38.931Z","level":"info","role":"test-strategist","model":"sonnet","event":"phase.start","message":"test-strategist START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:39.216Z","level":"info","role":"test-strategist","event":"artifact.written","message":"test-strategist wrote per-story test list for S2-stock-by-location-table , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"per-story test list for S2-stock-by-location-table","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S2-stock-by-location-table/test-list-per-story.json","reconciled":true}} +{"timestamp":"2026-08-12T15:03:39.241Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for reflect","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"reflect","story":"S2-stock-by-location-table","buildMode":"reflect"}} +{"timestamp":"2026-08-12T15:03:39.241Z","level":"info","role":"navigator","model":"sonnet","event":"phase.start","message":"navigator START reflect","metadata":{"feature_id":"F1-stock-visibility","phase":"reflect","story":"S2-stock-by-location-table","buildMode":"reflect"}} +{"timestamp":"2026-08-12T15:03:39.699Z","level":"info","role":"orchestrator","event":"gate.surfaced","message":"GATE spec awaiting decision , story S2-stock-by-location-table","metadata":{"feature_id":"F1-stock-visibility","gate":"spec","subject":"story S2-stock-by-location-table","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:39.920Z","level":"info","role":"orchestrator","event":"gate.approved","message":"GATE spec APPROVED","metadata":{"feature_id":"F1-stock-visibility","gate":"spec","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:40.152Z","level":"info","role":"orchestrator","event":"phase.start","message":"orchestrator START build","metadata":{"feature_id":"F1-stock-visibility","phase":"build","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:40.359Z","level":"info","role":"orchestrator","event":"experiment.cut","message":"EXPERIMENT cut for S2-stock-by-location-table","metadata":{"feature_id":"F1-stock-visibility","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:57.662Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for red","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"red","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:57.662Z","level":"info","role":"navigator","model":"sonnet","event":"phase.start","message":"navigator START red","metadata":{"feature_id":"F1-stock-visibility","phase":"red","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:57.920Z","level":"info","role":"navigator","event":"cycle.red","message":"RED 6 test(s) in cycle-001 [E2E], lead T18 (AC1-table-lists-stock-by-location): reading the stock-by-location list through the boundary returns a JSON collection with one entry per seeded stock_records row (unique uuid-suffixed sku/location, cleaned up after) carrying that record's sku, location, and quantity, read through service and repository against the branch DB","metadata":{"feature_id":"F1-stock-visibility","cycle_id":"cycle-001","test_id":"T18","ac":"AC1-table-lists-stock-by-location","asserts":"reading the stock-by-location list through the boundary returns a JSON collection with one entry per seeded stock_records row (unique uuid-suffixed sku/location, cleaned up after) carrying that record's sku, location, and quantity, read through service and repository against the branch DB","layer":"E2E","batch":6}} +{"timestamp":"2026-08-12T15:03:58.152Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch driver for green","metadata":{"feature_id":"F1-stock-visibility","to_role":"driver","phase":"green","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:58.152Z","level":"info","role":"driver","model":"sonnet","event":"phase.start","message":"driver START green","metadata":{"feature_id":"F1-stock-visibility","phase":"green","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:03:58.387Z","level":"info","role":"driver","event":"cycle.green","message":"GREEN T18 [AC1-table-lists-stock-by-location]: minimal honest code","metadata":{"feature_id":"F1-stock-visibility","cycle_id":"cycle-001","test_id":"T18","ac":"AC1-table-lists-stock-by-location","change":"minimal honest code"}} +{"timestamp":"2026-08-12T15:04:02.259Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for review","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"review","story":"S2-stock-by-location-table","buildMode":"review"}} +{"timestamp":"2026-08-12T15:04:02.259Z","level":"info","role":"navigator","model":"sonnet","effort":"low","event":"phase.start","message":"navigator START review","metadata":{"feature_id":"F1-stock-visibility","phase":"review","story":"S2-stock-by-location-table","buildMode":"review"}} +{"timestamp":"2026-08-12T15:04:02.574Z","level":"info","role":"navigator","event":"cycle.review","message":"REVIEW [S2-stock-by-location-table] refactor=false: looks good","metadata":{"feature_id":"F1-stock-visibility","ac":"S2-stock-by-location-table","refactor":false,"rationale":"looks good","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:04:02.844Z","level":"info","role":"release-engineer","event":"phase.start","message":"release-engineer START deploy","metadata":{"feature_id":"F1-stock-visibility","phase":"deploy","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:04:02.845Z","level":"info","role":"orchestrator","event":"gate.surfaced","message":"GATE acceptance awaiting decision , story S2-stock-by-location-table","metadata":{"feature_id":"F1-stock-visibility","gate":"acceptance","subject":"story S2-stock-by-location-table","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:04:03.308Z","level":"info","role":"release-engineer","event":"deploy.start","message":"DEPLOY start story S2-stock-by-location-table -> local","metadata":{"feature_id":"F1-stock-visibility","scope":"story S2-stock-by-location-table","target":"local","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:04:46.313Z","level":"info","role":"release-engineer","event":"deploy.reachable","message":"DEPLOY reachable http://localhost:8000/ (pid 31015)","metadata":{"url":"http://localhost:8000/","pid":31015,"feature_id":"F1-stock-visibility","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:04:46.314Z","level":"info","role":"release-engineer","event":"verify.passed","message":"VERIFY passed story S2-stock-by-location-table (./scripts/run-tests.sh)","metadata":{"scope":"story S2-stock-by-location-table","command":"./scripts/run-tests.sh","feature_id":"F1-stock-visibility","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:04:46.314Z","level":"info","role":"release-engineer","event":"deploy.verified","message":"DEPLOY verified story S2-stock-by-location-table @ http://localhost:8000/ , verify passed","metadata":{"feature_id":"F1-stock-visibility","scope":"story S2-stock-by-location-table","url":"http://localhost:8000/","verify_status":"passed","target":"local","reachable":true,"verify_passed":true,"story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:04:46.315Z","level":"info","role":"release-engineer","event":"phase.end","message":"release-engineer END deploy (verified)","metadata":{"feature_id":"F1-stock-visibility","phase":"deploy","outcome":"verified","ok":true,"story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:04:46.580Z","level":"info","role":"orchestrator","event":"experiment.accepted","message":"EXPERIMENT accepted (merged) for S2-stock-by-location-table","metadata":{"feature_id":"F1-stock-visibility","story":"S2-stock-by-location-table"}} +{"timestamp":"2026-08-12T15:05:00.814Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch spec-author for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"spec-author","phase":"design","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:00.814Z","level":"info","role":"spec-author","model":"opus","event":"phase.start","message":"spec-author START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:01.042Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC1-lists-stock-across-locations for story S3-sku-detail-view , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC1-lists-stock-across-locations for story S3-sku-detail-view","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC1-lists-stock-across-locations.json","reconciled":true}} +{"timestamp":"2026-08-12T15:05:01.054Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC2-shows-tracking-code for story S3-sku-detail-view , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC2-shows-tracking-code for story S3-sku-detail-view","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC2-shows-tracking-code.json","reconciled":true}} +{"timestamp":"2026-08-12T15:05:01.054Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC3-par-level-not-tracked for story S3-sku-detail-view , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC3-par-level-not-tracked for story S3-sku-detail-view","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC3-par-level-not-tracked.json","reconciled":true}} +{"timestamp":"2026-08-12T15:05:01.054Z","level":"info","role":"spec-author","event":"artifact.written","message":"spec-author wrote AC AC4-sku-with-no-stock-empty-state for story S3-sku-detail-view , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"AC AC4-sku-with-no-stock-empty-state for story S3-sku-detail-view","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC4-sku-with-no-stock-empty-state.json","reconciled":true}} +{"timestamp":"2026-08-12T15:05:01.069Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch test-strategist for design","metadata":{"feature_id":"F1-stock-visibility","to_role":"test-strategist","phase":"design","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:01.069Z","level":"info","role":"test-strategist","model":"sonnet","event":"phase.start","message":"test-strategist START design","metadata":{"feature_id":"F1-stock-visibility","phase":"design","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:01.364Z","level":"info","role":"test-strategist","event":"artifact.written","message":"test-strategist wrote per-story test list for S3-sku-detail-view , present on disk (reconciled)","metadata":{"feature_id":"F1-stock-visibility","artifact":"per-story test list for S3-sku-detail-view","summary":"present on disk (reconciled)","path":"features/F1-stock-visibility/stories/S3-sku-detail-view/test-list-per-story.json","reconciled":true}} +{"timestamp":"2026-08-12T15:05:01.392Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for reflect","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"reflect","story":"S3-sku-detail-view","buildMode":"reflect"}} +{"timestamp":"2026-08-12T15:05:01.392Z","level":"info","role":"navigator","model":"sonnet","event":"phase.start","message":"navigator START reflect","metadata":{"feature_id":"F1-stock-visibility","phase":"reflect","story":"S3-sku-detail-view","buildMode":"reflect"}} +{"timestamp":"2026-08-12T15:05:01.870Z","level":"info","role":"orchestrator","event":"gate.surfaced","message":"GATE spec awaiting decision , story S3-sku-detail-view","metadata":{"feature_id":"F1-stock-visibility","gate":"spec","subject":"story S3-sku-detail-view","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:02.093Z","level":"info","role":"orchestrator","event":"gate.approved","message":"GATE spec APPROVED","metadata":{"feature_id":"F1-stock-visibility","gate":"spec","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:02.344Z","level":"info","role":"orchestrator","event":"phase.start","message":"orchestrator START build","metadata":{"feature_id":"F1-stock-visibility","phase":"build","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:02.579Z","level":"info","role":"orchestrator","event":"experiment.cut","message":"EXPERIMENT cut for S3-sku-detail-view","metadata":{"feature_id":"F1-stock-visibility","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:14.939Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for red","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"red","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:14.939Z","level":"info","role":"navigator","model":"sonnet","event":"phase.start","message":"navigator START red","metadata":{"feature_id":"F1-stock-visibility","phase":"red","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:15.209Z","level":"info","role":"navigator","event":"cycle.red","message":"RED 9 test(s) in cycle-001 [E2E], lead T24 (AC1-lists-stock-across-locations): reading a SKU's detail through the boundary returns a JSON collection with one entry per location where that sku holds stock (seeded across multiple locations under a unique uuid-suffixed sku, cleaned up after), each entry carrying its location and quantity, read through service and repository against the branch DB","metadata":{"feature_id":"F1-stock-visibility","cycle_id":"cycle-001","test_id":"T24","ac":"AC1-lists-stock-across-locations","asserts":"reading a SKU's detail through the boundary returns a JSON collection with one entry per location where that sku holds stock (seeded across multiple locations under a unique uuid-suffixed sku, cleaned up after), each entry carrying its location and quantity, read through service and repository against the branch DB","layer":"E2E","batch":9}} +{"timestamp":"2026-08-12T15:05:15.483Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch driver for green","metadata":{"feature_id":"F1-stock-visibility","to_role":"driver","phase":"green","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:15.484Z","level":"info","role":"driver","model":"sonnet","event":"phase.start","message":"driver START green","metadata":{"feature_id":"F1-stock-visibility","phase":"green","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:15.750Z","level":"info","role":"driver","event":"cycle.green","message":"GREEN T24 [AC1-lists-stock-across-locations]: minimal honest code","metadata":{"feature_id":"F1-stock-visibility","cycle_id":"cycle-001","test_id":"T24","ac":"AC1-lists-stock-across-locations","change":"minimal honest code"}} +{"timestamp":"2026-08-12T15:05:19.842Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch navigator for review","metadata":{"feature_id":"F1-stock-visibility","to_role":"navigator","phase":"review","story":"S3-sku-detail-view","buildMode":"review"}} +{"timestamp":"2026-08-12T15:05:19.842Z","level":"info","role":"navigator","model":"sonnet","effort":"low","event":"phase.start","message":"navigator START review","metadata":{"feature_id":"F1-stock-visibility","phase":"review","story":"S3-sku-detail-view","buildMode":"review"}} +{"timestamp":"2026-08-12T15:05:20.103Z","level":"info","role":"navigator","event":"cycle.review","message":"REVIEW [S3-sku-detail-view] refactor=true: SkuDetailPage.tsx renders a bare with no CSS classes consuming design tokens (typography via --font-sans, spacing via --space-*, color via --color-text/--color-card). The design-guide requires all surfaces to consume tokens via var(--token) rather than leave them absent. Add a .sku-detail-table / .page wrapper in theme.css and apply the class to the
and container div so the card surface, font, and spacing are token-driven. Layer boundaries, service/repository split, JSON-only API boundary (NFR-F1-5), config-in-env (NFR-F1-7), and no-mock DB (NFR-F1-4) are all satisfied; no production code concerns beyond the missing token classes.","metadata":{"feature_id":"F1-stock-visibility","ac":"S3-sku-detail-view","refactor":true,"rationale":"SkuDetailPage.tsx renders a bare
with no CSS classes consuming design tokens (typography via --font-sans, spacing via --space-*, color via --color-text/--color-card). The design-guide requires all surfaces to consume tokens via var(--token) rather than leave them absent. Add a .sku-detail-table / .page wrapper in theme.css and apply the class to the
and container div so the card surface, font, and spacing are token-driven. Layer boundaries, service/repository split, JSON-only API boundary (NFR-F1-5), config-in-env (NFR-F1-7), and no-mock DB (NFR-F1-4) are all satisfied; no production code concerns beyond the missing token classes.","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:20.372Z","level":"info","role":"orchestrator","event":"handoff","message":"dispatch driver for refactor","metadata":{"feature_id":"F1-stock-visibility","to_role":"driver","phase":"refactor","story":"S3-sku-detail-view","buildMode":"refactor"}} +{"timestamp":"2026-08-12T15:05:20.372Z","level":"info","role":"driver","model":"sonnet","event":"phase.start","message":"driver START refactor","metadata":{"feature_id":"F1-stock-visibility","phase":"refactor","story":"S3-sku-detail-view","buildMode":"refactor"}} +{"timestamp":"2026-08-12T15:05:20.635Z","level":"info","role":"driver","event":"cycle.refactored","message":"REFACTOR [S3-sku-detail-view]: addressed: SkuDetailPage.tsx renders a bare
with no CSS classes consuming design tokens (typography via --font-sans, spacing via --space-*, color via --color-text/--color-card). The design-guide requires all surfaces to consume tokens via var(--token) rather than leave them absent. Add a .sku-detail-table / .page wrapper in theme.css and apply the class to the
and container div so the card surface, font, and spacing are token-driven. Layer boundaries, service/repository split, JSON-only API boundary (NFR-F1-5), config-in-env (NFR-F1-7), and no-mock DB (NFR-F1-4) are all satisfied; no production code concerns beyond the missing token classes.","metadata":{"feature_id":"F1-stock-visibility","ac":"S3-sku-detail-view","change":"addressed: SkuDetailPage.tsx renders a bare
with no CSS classes consuming design tokens (typography via --font-sans, spacing via --space-*, color via --color-text/--color-card). The design-guide requires all surfaces to consume tokens via var(--token) rather than leave them absent. Add a .sku-detail-table / .page wrapper in theme.css and apply the class to the
and container div so the card surface, font, and spacing are token-driven. Layer boundaries, service/repository split, JSON-only API boundary (NFR-F1-5), config-in-env (NFR-F1-7), and no-mock DB (NFR-F1-4) are all satisfied; no production code concerns beyond the missing token classes.","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:24.310Z","level":"info","role":"release-engineer","event":"phase.start","message":"release-engineer START deploy","metadata":{"feature_id":"F1-stock-visibility","phase":"deploy","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:24.311Z","level":"info","role":"orchestrator","event":"gate.surfaced","message":"GATE acceptance awaiting decision , story S3-sku-detail-view","metadata":{"feature_id":"F1-stock-visibility","gate":"acceptance","subject":"story S3-sku-detail-view","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:05:24.771Z","level":"info","role":"release-engineer","event":"deploy.start","message":"DEPLOY start story S3-sku-detail-view -> local","metadata":{"feature_id":"F1-stock-visibility","scope":"story S3-sku-detail-view","target":"local","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:06:18.927Z","level":"info","role":"release-engineer","event":"deploy.reachable","message":"DEPLOY reachable http://localhost:8000/ (pid 32585)","metadata":{"url":"http://localhost:8000/","pid":32585,"feature_id":"F1-stock-visibility","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:06:18.928Z","level":"info","role":"release-engineer","event":"verify.passed","message":"VERIFY passed story S3-sku-detail-view (./scripts/run-tests.sh)","metadata":{"scope":"story S3-sku-detail-view","command":"./scripts/run-tests.sh","feature_id":"F1-stock-visibility","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:06:18.929Z","level":"info","role":"release-engineer","event":"deploy.verified","message":"DEPLOY verified story S3-sku-detail-view @ http://localhost:8000/ , verify passed","metadata":{"feature_id":"F1-stock-visibility","scope":"story S3-sku-detail-view","url":"http://localhost:8000/","verify_status":"passed","target":"local","reachable":true,"verify_passed":true,"story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:06:18.929Z","level":"info","role":"release-engineer","event":"phase.end","message":"release-engineer END deploy (verified)","metadata":{"feature_id":"F1-stock-visibility","phase":"deploy","outcome":"verified","ok":true,"story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:06:19.193Z","level":"info","role":"orchestrator","event":"experiment.accepted","message":"EXPERIMENT accepted (merged) for S3-sku-detail-view","metadata":{"feature_id":"F1-stock-visibility","story":"S3-sku-detail-view"}} +{"timestamp":"2026-08-12T15:06:33.773Z","level":"info","role":"orchestrator","event":"phase.end","message":"orchestrator END feature (complete)","metadata":{"feature_id":"F1-stock-visibility","phase":"feature","outcome":"complete"}} +{"timestamp":"2026-08-12T15:06:33.936Z","level":"info","role":"release-engineer","event":"phase.start","message":"release-engineer START deploy","metadata":{"feature_id":"F1-stock-visibility","phase":"deploy"}} +{"timestamp":"2026-08-12T15:06:34.374Z","level":"info","role":"release-engineer","event":"deploy.start","message":"DEPLOY start feature F1-stock-visibility -> local","metadata":{"feature_id":"F1-stock-visibility","scope":"feature F1-stock-visibility","target":"local"}} +{"timestamp":"2026-08-12T15:07:17.890Z","level":"info","role":"release-engineer","event":"deploy.reachable","message":"DEPLOY reachable http://localhost:8000/ (pid 33480)","metadata":{"url":"http://localhost:8000/","pid":33480,"feature_id":"F1-stock-visibility"}} +{"timestamp":"2026-08-12T15:07:17.890Z","level":"info","role":"release-engineer","event":"verify.passed","message":"VERIFY passed feature F1-stock-visibility (./scripts/run-tests.sh)","metadata":{"scope":"feature F1-stock-visibility","command":"./scripts/run-tests.sh","feature_id":"F1-stock-visibility"}} +{"timestamp":"2026-08-12T15:07:17.891Z","level":"info","role":"release-engineer","event":"deploy.verified","message":"DEPLOY verified feature F1-stock-visibility @ http://localhost:8000/ , verify passed","metadata":{"feature_id":"F1-stock-visibility","scope":"feature F1-stock-visibility","url":"http://localhost:8000/","verify_status":"passed","target":"local","reachable":true,"verify_passed":true}} +{"timestamp":"2026-08-12T15:07:17.891Z","level":"info","role":"release-engineer","event":"phase.end","message":"release-engineer END deploy (verified)","metadata":{"feature_id":"F1-stock-visibility","phase":"deploy","outcome":"verified","ok":true}} +{"timestamp":"2026-08-12T15:07:17.927Z","level":"info","role":"orchestrator","event":"gate.approved","message":"GATE deploy APPROVED","metadata":{"feature_id":"F1-stock-visibility","gate":"deploy"}} +{"timestamp":"2026-08-12T15:07:18.168Z","level":"info","role":"product-owner","event":"gate.approved","message":"GATE deploy APPROVED","metadata":{"feature_id":"F1-stock-visibility","gate":"deploy","artifacts":["deploy-evidence.json"],"approver":"human-proxy","validated":true}} +{"timestamp":"2026-08-12T15:07:18.190Z","level":"info","role":"release-engineer","event":"phase.start","message":"release-engineer START promote","metadata":{"feature_id":"F1-stock-visibility","phase":"promote"}} +{"timestamp":"2026-08-12T15:07:18.204Z","level":"info","role":"orchestrator","event":"reasoning","message":"orchestrator: prepare-pr","metadata":{"feature_id":"F1-stock-visibility","note":"orchestrator: prepare-pr"}} +{"timestamp":"2026-08-12T15:07:24.092Z","level":"info","role":"orchestrator","event":"reasoning","message":"orchestrator: wait-ci","metadata":{"feature_id":"F1-stock-visibility","note":"orchestrator: wait-ci"}} +{"timestamp":"2026-08-12T15:10:01.599Z","level":"info","role":"orchestrator","event":"gate.approved","message":"GATE promote APPROVED","metadata":{"feature_id":"F1-stock-visibility","gate":"promote"}} +{"timestamp":"2026-08-12T15:10:01.828Z","level":"info","role":"product-owner","event":"gate.approved","message":"GATE promote APPROVED","metadata":{"feature_id":"F1-stock-visibility","gate":"promote","artifacts":["promote_ref"],"approver":"human-proxy","validated":true}} +{"timestamp":"2026-08-12T15:10:01.859Z","level":"info","role":"orchestrator","event":"reasoning","message":"orchestrator: merge","metadata":{"feature_id":"F1-stock-visibility","note":"orchestrator: merge"}} +{"timestamp":"2026-08-12T15:11:12.653Z","level":"info","role":"orchestrator","event":"phase.end","message":"orchestrator END workflow (complete)","metadata":{"feature_id":"F1-stock-visibility","phase":"workflow","outcome":"complete"}} diff --git a/apps/dashboard/lib/__fixtures__/stockflow-rerecord-agent-log.jsonl b/apps/dashboard/lib/__fixtures__/stockflow-rerecord-agent-log.jsonl new file mode 100644 index 00000000..1dcfcd5c --- /dev/null +++ b/apps/dashboard/lib/__fixtures__/stockflow-rerecord-agent-log.jsonl @@ -0,0 +1,421 @@ +{"timestamp":"2026-08-01T02:50:33.000Z","level":"info","role":"product-owner","event":"intake.supplied","message":"INTAKE supplied product-overview.md","metadata":{"artifact":"product-overview.md","from":"/Users/kevin.hartman/code/databricks-solutions/consort/examples/sftdd-scenarios/stockflow/intake/product-overview.md","to":"/Users/kevin.hartman/code/tdd-workflow-smoke/stockflow-rerecord-cap-20260801-044928/.sftdd/product-overview.md","approver":"human-proxy","validated":true,"kit_ref":"sftdd-capture-local","kit_commit":"cad5f5fb5eb7e59a703722284b6a5858ddf3fff0","kit_describe":"v0.3.6"}} +{"timestamp": "2026-08-01T02:50:33.259Z", "level": "info", "role": "product-owner", "event": "intake.supplied", "message": "INTAKE supplied nfrs.md", "metadata": {"artifact": "nfrs.md", "from": "/Users/kevin.hartman/code/databricks-solutions/consort/examples/sftdd-scenarios/stockflow/intake/nfrs.md", "to": "/Users/kevin.hartman/code/tdd-workflow-smoke/stockflow-rerecord-cap-20260801-044928/.sftdd/nfrs.md", "approver": "human-proxy", "validated": true}} +{"timestamp": "2026-08-01T02:50:33.489Z", "level": "info", "role": "product-owner", "event": "intake.supplied", "message": "INTAKE supplied design-brief.md", "metadata": {"artifact": "design-brief.md", "from": "/Users/kevin.hartman/code/databricks-solutions/consort/examples/sftdd-scenarios/stockflow/intake/design-brief.md", "to": "/Users/kevin.hartman/code/tdd-workflow-smoke/stockflow-rerecord-cap-20260801-044928/.sftdd/design/design-brief.md", "approver": "human-proxy", "validated": true}} +{"timestamp": "2026-08-01T02:50:45.130Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for propose", "metadata": {"feature_id": "", "to_role": "spec-author", "phase": "propose", "mode": "propose"}} +{"timestamp": "2026-08-01T02:50:45.139Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START propose", "metadata": {"feature_id": "", "phase": "propose", "mode": "propose"}} +{"timestamp": "2026-08-01T02:51:57.449Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 10 input + 4736 output tokens", "metadata": {"feature_id": "", "duration_ms": 72310, "input_tokens": 10, "output_tokens": 4736, "cache_read_tokens": 58364, "cache_creation_tokens": 18726, "cost_usd": 0.334892, "phase": "propose"}} +{"timestamp": "2026-08-01T02:51:57.462Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for estimate", "metadata": {"feature_id": "", "to_role": "architect-reviewer", "phase": "estimate", "mode": "estimate"}} +{"timestamp": "2026-08-01T02:51:57.462Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START estimate", "metadata": {"feature_id": "", "phase": "estimate", "mode": "estimate"}} +{"timestamp": "2026-08-01T02:52:12.125Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 6 input + 611 output tokens", "metadata": {"feature_id": "", "duration_ms": 14663, "input_tokens": 6, "output_tokens": 611, "cache_read_tokens": 23598, "cache_creation_tokens": 9592, "cost_usd": 0.123024, "phase": "estimate"}} +{"timestamp": "2026-08-01T02:52:12.134Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch product-owner for author-requests", "metadata": {"feature_id": "", "to_role": "product-owner", "phase": "author-requests", "mode": "author-requests"}} +{"timestamp": "2026-08-01T02:52:12.135Z", "level": "info", "role": "product-owner", "model": "opus", "event": "phase.start", "message": "product-owner START author-requests", "metadata": {"feature_id": "", "phase": "author-requests", "mode": "author-requests"}} +{"timestamp": "2026-08-01T02:52:12.388Z", "level": "info", "role": "product-owner", "event": "intake.supplied", "message": "INTAKE supplied feature-request.md", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "feature-request.md", "from": "/Users/kevin.hartman/code/databricks-solutions/consort/examples/sftdd-scenarios/stockflow/recorded-artifacts/features/F1-stock-visibility/feature-request.md", "to": "/Users/kevin.hartman/code/tdd-workflow-smoke/stockflow-rerecord-cap-20260801-044928/.sftdd/features/F1-stock-visibility/feature-request.md", "approver": "human-proxy", "validated": true}} +{"timestamp": "2026-08-01T02:52:12.415Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE plan APPROVED", "metadata": {"feature_id": "", "gate": "plan"}} +{"timestamp": "2026-08-01T02:52:36.298Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for breakdown", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "spec-author", "phase": "breakdown", "mode": "breakdown"}} +{"timestamp": "2026-08-01T02:52:36.298Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START breakdown", "metadata": {"feature_id": "F1-stock-visibility", "phase": "breakdown", "mode": "breakdown"}} +{"timestamp": "2026-08-01T02:54:06.346Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 16 input + 6345 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 89822, "input_tokens": 16, "output_tokens": 6345, "cache_read_tokens": 116524, "cache_creation_tokens": 13271, "cost_usd": 0.3496769999999999, "phase": "breakdown"}} +{"timestamp": "2026-08-01T02:54:06.731Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote feature-spec.json , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "feature-spec.json", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/feature-spec.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:54:06.743Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S1-file-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "story stub S1-file-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-file-stock/story.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:54:06.743Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S2-stock-by-location-table , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "story stub S2-stock-by-location-table", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S2-stock-by-location-table/story.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:54:06.743Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "story stub S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/story.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:54:06.771Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch ux-designer for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "ux-designer", "phase": "design"}} +{"timestamp": "2026-08-01T02:54:06.771Z", "level": "info", "role": "ux-designer", "model": "sonnet", "event": "phase.start", "message": "ux-designer START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design"}} +{"timestamp": "2026-08-01T02:56:25.935Z", "level": "info", "role": "ux-designer", "model": "sonnet", "event": "turn.usage", "message": "ux-designer turn used 10 input + 8218 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 139163, "input_tokens": 10, "output_tokens": 8218, "cache_read_tokens": 108341, "cache_creation_tokens": 32512, "cost_usd": 0.35087430000000003}} +{"timestamp": "2026-08-01T02:56:26.106Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote design-guide.json , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "design-guide.json", "summary": "present on disk (reconciled)", "path": "design/design-guide.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:56:26.117Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote design-guide.md , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "design-guide.md", "summary": "present on disk (reconciled)", "path": "design/design-guide.md", "reconciled": true}} +{"timestamp": "2026-08-01T02:56:26.117Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote ia.md , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "ia.md", "summary": "present on disk (reconciled)", "path": "design/ia.md", "reconciled": true}} +{"timestamp": "2026-08-01T02:56:26.139Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "spec-author", "phase": "design", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T02:56:26.139Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T02:57:47.498Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 22 input + 5203 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 81359, "input_tokens": 22, "output_tokens": 5203, "cache_read_tokens": 136042, "cache_creation_tokens": 8152, "cost_usd": 0.27972600000000003, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T02:57:47.669Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC1-file-stock-record for story S1-file-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC1-file-stock-record for story S1-file-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-file-stock/acs/AC1-file-stock-record.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:57:47.679Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC2-retrieve-stock-record for story S1-file-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC2-retrieve-stock-record for story S1-file-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-file-stock/acs/AC2-retrieve-stock-record.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:57:47.680Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC3-collision-resolved-at-write for story S1-file-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC3-collision-resolved-at-write for story S1-file-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-file-stock/acs/AC3-collision-resolved-at-write.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:57:47.697Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "architect-reviewer", "phase": "design", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T02:57:47.698Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T02:59:42.483Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 22 input + 7818 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 114785, "input_tokens": 22, "output_tokens": 7818, "cache_read_tokens": 158679, "cache_creation_tokens": 12031, "cost_usd": 0.3952095, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T02:59:42.657Z", "level": "info", "role": "architect-reviewer", "event": "reasoning", "message": "established project architecture conventions: boundary=app/routes, service=app/services, repository=app/repositories, models=app/models", "metadata": {"feature_id": "F1-stock-visibility", "note": "established project architecture conventions: boundary=app/routes, service=app/services, repository=app/repositories, models=app/models"}} +{"timestamp": "2026-08-01T02:59:42.667Z", "level": "info", "role": "architect-reviewer", "event": "reasoning", "message": "established project architecture canon: layers=[E2E] nfrs=[] invariants=[unique, not_null, check, transactional, migration_reversible]", "metadata": {"feature_id": "F1-stock-visibility", "note": "established project architecture canon: layers=[E2E] nfrs=[] invariants=[unique, not_null, check, transactional, migration_reversible]"}} +{"timestamp": "2026-08-01T02:59:42.668Z", "level": "info", "role": "architect-reviewer", "event": "artifact.written", "message": "architect-reviewer wrote architecture.json , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "architecture.json", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/architecture.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:59:42.668Z", "level": "info", "role": "architect-reviewer", "event": "artifact.written", "message": "architect-reviewer wrote architecture conventions (project) , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "architecture conventions (project)", "summary": "present on disk (reconciled)", "path": "architecture/conventions.json", "reconciled": true}} +{"timestamp": "2026-08-01T02:59:42.692Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch dba for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "dba", "phase": "design", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T02:59:42.692Z", "level": "info", "role": "dba", "model": "opus", "event": "phase.start", "message": "dba START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:00:26.767Z", "level": "info", "role": "dba", "model": "opus", "event": "turn.usage", "message": "dba turn used 12 input + 3108 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 44074, "input_tokens": 12, "output_tokens": 3108, "cache_read_tokens": 59956, "cache_creation_tokens": 12741, "cost_usd": 0.23514800000000005, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:00:26.959Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch test-strategist for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "test-strategist", "phase": "design", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:00:26.959Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "phase.start", "message": "test-strategist START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:03:00.386Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "turn.usage", "message": "test-strategist turn used 18 input + 10593 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 153426, "input_tokens": 18, "output_tokens": 10593, "cache_read_tokens": 170791, "cache_creation_tokens": 21655, "cost_usd": 0.5668605000000001, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:03:00.721Z", "level": "info", "role": "test-strategist", "event": "artifact.written", "message": "test-strategist wrote test-list.json , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "test-list.json", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/test-list.json", "reconciled": true}} +{"timestamp": "2026-08-01T03:03:00.731Z", "level": "info", "role": "test-strategist", "event": "artifact.written", "message": "test-strategist wrote per-story test list for S1-file-stock , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "per-story test list for S1-file-stock", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S1-file-stock/test-list-per-story.json", "reconciled": true}} +{"timestamp": "2026-08-01T03:03:00.756Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for reflect", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "reflect", "story": "S1-file-stock", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T03:03:00.756Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START reflect", "metadata": {"feature_id": "F1-stock-visibility", "phase": "reflect", "story": "S1-file-stock", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T03:07:29.316Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 9 input + 14499 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 268559, "input_tokens": 9, "output_tokens": 14499, "cache_read_tokens": 144358, "cache_creation_tokens": 54317, "cost_usd": 0.5867214000000002, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:07:29.752Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE spec awaiting decision , story S1-file-stock", "metadata": {"feature_id": "F1-stock-visibility", "gate": "spec", "subject": "story S1-file-stock", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:07:29.939Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE spec APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "spec", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:07:30.109Z", "level": "info", "role": "orchestrator", "event": "phase.start", "message": "orchestrator START build", "metadata": {"feature_id": "F1-stock-visibility", "phase": "build", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:07:30.261Z", "level": "info", "role": "orchestrator", "event": "experiment.cut", "message": "EXPERIMENT cut for S1-file-stock", "metadata": {"feature_id": "F1-stock-visibility", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:07:50.879Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for red", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "red", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:07:50.879Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START red", "metadata": {"feature_id": "F1-stock-visibility", "phase": "red", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:31:22.859Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 34 input + 95343 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 1411974, "input_tokens": 34, "output_tokens": 95343, "cache_read_tokens": 1394143, "cache_creation_tokens": 61761, "cost_usd": 2.2190559, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:31:23.137Z", "level": "info", "role": "navigator", "event": "cycle.red", "message": "RED 17 test(s) in cycle-001 [E2E], lead T1 (AC1-file-stock-record): filing a stock level (unique uuid-suffixed sku/location, cleaned up after) for a SKU at a location durably persists a stock_records row on the branch DB capturing sku, location, quantity, and combined inventory_code", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "cycle-001", "test_id": "T1", "ac": "AC1-file-stock-record", "asserts": "filing a stock level (unique uuid-suffixed sku/location, cleaned up after) for a SKU at a location durably persists a stock_records row on the branch DB capturing sku, location, quantity, and combined inventory_code", "layer": "E2E", "batch": 17}} +{"timestamp": "2026-08-01T03:31:23.363Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "green", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:31:23.363Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F1-stock-visibility", "phase": "green", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:42:49.675Z", "level": "info", "role": "driver", "event": "reasoning", "message": "GREEN: created stock_records migration, model, repository (upsert+get), service (validation), route (JSON-only via app.dependencies.open_session satisfying T4/T6). All 15 story tests pass.", "metadata": {"feature_id": "F1-stock-visibility", "note": "GREEN: created stock_records migration, model, repository (upsert+get), service (validation), route (JSON-only via app.dependencies.open_session satisfying T4/T6). All 15 story tests pass."}} +{"timestamp": "2026-08-01T03:42:52.464Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 49 input + 29974 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 689100, "input_tokens": 49, "output_tokens": 29974, "cache_read_tokens": 1922235, "cache_creation_tokens": 59505, "cost_usd": 1.3834575000000002, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:44:48.175Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for assess", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "assess", "story": "S1-file-stock", "buildMode": "assess", "ac": "AC1-file-stock-record"}} +{"timestamp": "2026-08-01T03:44:48.176Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START assess", "metadata": {"feature_id": "F1-stock-visibility", "phase": "assess", "story": "S1-file-stock", "buildMode": "assess", "ac": "AC1-file-stock-record"}} +{"timestamp": "2026-08-01T03:46:12.359Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 12 input + 4262 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 84183, "input_tokens": 12, "output_tokens": 4262, "cache_read_tokens": 255355, "cache_creation_tokens": 53121, "cost_usd": 0.4592985, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T03:46:12.746Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for repair", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "repair", "story": "S1-file-stock", "buildMode": "repair", "ac": "AC1-file-stock-record"}} +{"timestamp": "2026-08-01T03:46:12.746Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START repair", "metadata": {"feature_id": "F1-stock-visibility", "phase": "repair", "story": "S1-file-stock", "buildMode": "repair", "ac": "AC1-file-stock-record"}} +{"timestamp": "2026-08-01T04:08:48.385Z", "level": "info", "role": "driver", "event": "reasoning", "message": "REPAIR: created stock.ts api, FileStockPage.tsx, RetrieveStockPage.tsx; fixed tests/setup.ts restoreAllMocks->clearAllMocks (wiped vi.mock factory impls between tests; no spyOn in project so restore had no benefit). All 15 client tests green.", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "repair-AC1-file-stock-record", "note": "REPAIR: created stock.ts api, FileStockPage.tsx, RetrieveStockPage.tsx; fixed tests/setup.ts restoreAllMocks->clearAllMocks (wiped vi.mock factory impls between tests; no spyOn in project so restore had no benefit). All 15 client tests green."}} +{"timestamp": "2026-08-01T04:08:51.675Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 289 input + 82015 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 1358928, "input_tokens": 289, "output_tokens": 82015, "cache_read_tokens": 5041482, "cache_creation_tokens": 93982, "cost_usd": 3.3074286000000006, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:10:36.731Z", "level": "info", "role": "driver", "event": "cycle.green", "message": "GREEN T1 [AC1-file-stock-record]: minimal honest code", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "cycle-001", "test_id": "T1", "ac": "AC1-file-stock-record", "change": "minimal honest code"}} +{"timestamp": "2026-08-01T04:10:44.830Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for review", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "review", "story": "S1-file-stock", "buildMode": "review"}} +{"timestamp": "2026-08-01T04:10:44.830Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "phase.start", "message": "navigator START review", "metadata": {"feature_id": "F1-stock-visibility", "phase": "review", "story": "S1-file-stock", "buildMode": "review"}} +{"timestamp": "2026-08-01T04:11:13.844Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "turn.usage", "message": "navigator turn used 9 input + 1367 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 29013, "input_tokens": 9, "output_tokens": 1367, "cache_read_tokens": 154275, "cache_creation_tokens": 57969, "cost_usd": 0.4146285, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:11:14.017Z", "level": "info", "role": "navigator", "event": "cycle.review", "message": "REVIEW [S1-file-stock] refactor=false: Layer boundaries are respected: route validates + delegates to service, service delegates to repository, no Session usage in routes or services. StaticFiles mount is guarded by os.path.isdir so no import-time build coupling. Design tokens delivered via CSS custom properties consumed by components via var(--token). NFR-F1-1 (immutable created_at via ON CONFLICT DO UPDATE), F1-2 (CHECK constraint + Pydantic validator + service guard), F1-3 (UNIQUE constraint + upsert), F1-4 (real branch DB in tests), F1-5 (SPA + JSON boundary), F1-6 (field-named validation messages), and F1-7 (DATABASE_URL from env) are all satisfied. No concrete improvement warranted.", "metadata": {"feature_id": "F1-stock-visibility", "ac": "S1-file-stock", "refactor": false, "rationale": "Layer boundaries are respected: route validates + delegates to service, service delegates to repository, no Session usage in routes or services. StaticFiles mount is guarded by os.path.isdir so no import-time build coupling. Design tokens delivered via CSS custom properties consumed by components via var(--token). NFR-F1-1 (immutable created_at via ON CONFLICT DO UPDATE), F1-2 (CHECK constraint + Pydantic validator + service guard), F1-3 (UNIQUE constraint + upsert), F1-4 (real branch DB in tests), F1-5 (SPA + JSON boundary), F1-6 (field-named validation messages), and F1-7 (DATABASE_URL from env) are all satisfied. No concrete improvement warranted.", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:11:14.247Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:11:14.247Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE acceptance awaiting decision , story S1-file-stock", "metadata": {"feature_id": "F1-stock-visibility", "gate": "acceptance", "subject": "story S1-file-stock", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:11:14.550Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start story S1-file-stock -> local", "metadata": {"feature_id": "F1-stock-visibility", "scope": "story S1-file-stock", "target": "local", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:12:38.639Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 58981)", "metadata": {"url": "http://localhost:8000/", "pid": 58981, "feature_id": "F1-stock-visibility", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:12:38.639Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed story S1-file-stock (./scripts/run-tests.sh)", "metadata": {"scope": "story S1-file-stock", "command": "./scripts/run-tests.sh", "feature_id": "F1-stock-visibility", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:12:38.639Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified story S1-file-stock @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F1-stock-visibility", "scope": "story S1-file-stock", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:12:38.639Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "outcome": "verified", "ok": true, "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:12:38.907Z", "level": "info", "role": "orchestrator", "event": "experiment.accepted", "message": "EXPERIMENT accepted (merged) for S1-file-stock", "metadata": {"feature_id": "F1-stock-visibility", "story": "S1-file-stock"}} +{"timestamp": "2026-08-01T04:13:01.727Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "spec-author", "phase": "design", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:13:01.727Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:13:58.807Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 16 input + 4023 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 57079, "input_tokens": 16, "output_tokens": 4023, "cache_read_tokens": 90877, "cache_creation_tokens": 16277, "cost_usd": 0.30886350000000007, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:13:59.048Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC1-table-lists-stock-by-location for story S2-stock-by-location-table , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC1-table-lists-stock-by-location for story S2-stock-by-location-table", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S2-stock-by-location-table/acs/AC1-table-lists-stock-by-location.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:13:59.059Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC2-quantity-right-aligned for story S2-stock-by-location-table , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC2-quantity-right-aligned for story S2-stock-by-location-table", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S2-stock-by-location-table/acs/AC2-quantity-right-aligned.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:13:59.059Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC3-empty-location-state for story S2-stock-by-location-table , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC3-empty-location-state for story S2-stock-by-location-table", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S2-stock-by-location-table/acs/AC3-empty-location-state.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:13:59.096Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "architect-reviewer", "phase": "design", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:13:59.096Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:14:40.013Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 8 input + 2622 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 40916, "input_tokens": 8, "output_tokens": 2622, "cache_read_tokens": 42441, "cache_creation_tokens": 13653, "cost_usd": 0.22334050000000003, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:14:40.319Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch test-strategist for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "test-strategist", "phase": "design", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:14:40.319Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "phase.start", "message": "test-strategist START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:16:23.349Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "turn.usage", "message": "test-strategist turn used 20 input + 6464 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 103029, "input_tokens": 20, "output_tokens": 6464, "cache_read_tokens": 178805, "cache_creation_tokens": 19515, "cost_usd": 0.44625250000000005, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:16:23.619Z", "level": "info", "role": "test-strategist", "event": "artifact.written", "message": "test-strategist wrote per-story test list for S2-stock-by-location-table , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "per-story test list for S2-stock-by-location-table", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S2-stock-by-location-table/test-list-per-story.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:16:23.659Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for reflect", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "reflect", "story": "S2-stock-by-location-table", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T04:16:23.659Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START reflect", "metadata": {"feature_id": "F1-stock-visibility", "phase": "reflect", "story": "S2-stock-by-location-table", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T04:19:47.549Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 8 input + 10954 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 203889, "input_tokens": 8, "output_tokens": 10954, "cache_read_tokens": 142371, "cache_creation_tokens": 19634, "cost_usd": 0.3248493, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:19:48.001Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE spec awaiting decision , story S2-stock-by-location-table", "metadata": {"feature_id": "F1-stock-visibility", "gate": "spec", "subject": "story S2-stock-by-location-table", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:19:48.184Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE spec APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "spec", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:19:48.384Z", "level": "info", "role": "orchestrator", "event": "phase.start", "message": "orchestrator START build", "metadata": {"feature_id": "F1-stock-visibility", "phase": "build", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:19:48.539Z", "level": "info", "role": "orchestrator", "event": "experiment.cut", "message": "EXPERIMENT cut for S2-stock-by-location-table", "metadata": {"feature_id": "F1-stock-visibility", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:20:03.513Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for red", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "red", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:20:03.513Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START red", "metadata": {"feature_id": "F1-stock-visibility", "phase": "red", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:34:46.866Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 28 input + 56525 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 883351, "input_tokens": 28, "output_tokens": 56525, "cache_read_tokens": 1036424, "cache_creation_tokens": 69749, "cost_usd": 1.5773802000000001, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:34:47.056Z", "level": "info", "role": "navigator", "event": "cycle.red", "message": "RED 6 test(s) in cycle-001 [E2E], lead T18 (AC1-table-lists-stock-by-location): reading the stock-by-location list through the boundary returns a JSON collection with one entry per seeded stock_records row (unique uuid-suffixed sku/location, cleaned up after) carrying that record's sku, location, and quantity, read through service and repository against the branch DB", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "cycle-001", "test_id": "T18", "ac": "AC1-table-lists-stock-by-location", "asserts": "reading the stock-by-location list through the boundary returns a JSON collection with one entry per seeded stock_records row (unique uuid-suffixed sku/location, cleaned up after) carrying that record's sku, location, and quantity, read through service and repository against the branch DB", "layer": "E2E", "batch": 6}} +{"timestamp": "2026-08-01T04:34:47.283Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "green", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:34:47.283Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F1-stock-visibility", "phase": "green", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:36:27.161Z", "level": "info", "role": "driver", "event": "reasoning", "message": "S2 GREEN: added GET /api/stock/location/{location} route + service + repository query; all 6 tests pass.", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "unknown", "note": "S2 GREEN: added GET /api/stock/location/{location} route + service + repository query; all 6 tests pass."}} +{"timestamp": "2026-08-01T04:36:29.735Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 23 input + 4101 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 102451, "input_tokens": 23, "output_tokens": 4101, "cache_read_tokens": 581859, "cache_creation_tokens": 48790, "cost_usd": 0.5288817, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:38:22.348Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for assess", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "assess", "story": "S2-stock-by-location-table", "buildMode": "assess", "ac": "AC1-table-lists-stock-by-location"}} +{"timestamp": "2026-08-01T04:38:22.348Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START assess", "metadata": {"feature_id": "F1-stock-visibility", "phase": "assess", "story": "S2-stock-by-location-table", "buildMode": "assess", "ac": "AC1-table-lists-stock-by-location"}} +{"timestamp": "2026-08-01T04:45:26.187Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 30 input + 25043 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 423838, "input_tokens": 30, "output_tokens": 25043, "cache_read_tokens": 1153278, "cache_creation_tokens": 43302, "cost_usd": 0.9815304, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:45:26.596Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for repair", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "repair", "story": "S2-stock-by-location-table", "buildMode": "repair", "ac": "AC1-table-lists-stock-by-location"}} +{"timestamp": "2026-08-01T04:45:26.596Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START repair", "metadata": {"feature_id": "F1-stock-visibility", "phase": "repair", "story": "S2-stock-by-location-table", "buildMode": "repair", "ac": "AC1-table-lists-stock-by-location"}} +{"timestamp": "2026-08-01T04:47:13.561Z", "level": "info", "role": "driver", "event": "reasoning", "message": "REPAIR: added fetchStockByLocation to api/stock.ts and created StockByLocationPage.tsx using useLocation; all 25 client tests pass", "metadata": {"feature_id": "F1", "cycle_id": "AC1-table-lists-stock-by-location", "note": "REPAIR: added fetchStockByLocation to api/stock.ts and created StockByLocationPage.tsx using useLocation; all 25 client tests pass"}} +{"timestamp": "2026-08-01T04:47:16.393Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 20 input + 4994 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 109796, "input_tokens": 20, "output_tokens": 4994, "cache_read_tokens": 475906, "cache_creation_tokens": 23536, "cost_usd": 0.3589577999999999, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:49:10.583Z", "level": "info", "role": "driver", "event": "cycle.green", "message": "GREEN T18 [AC1-table-lists-stock-by-location]: minimal honest code", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "cycle-001", "test_id": "T18", "ac": "AC1-table-lists-stock-by-location", "change": "minimal honest code"}} +{"timestamp": "2026-08-01T04:49:18.043Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for review", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "review", "story": "S2-stock-by-location-table", "buildMode": "review"}} +{"timestamp": "2026-08-01T04:49:18.043Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "phase.start", "message": "navigator START review", "metadata": {"feature_id": "F1-stock-visibility", "phase": "review", "story": "S2-stock-by-location-table", "buildMode": "review"}} +{"timestamp": "2026-08-01T04:50:00.443Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "turn.usage", "message": "navigator turn used 12 input + 1835 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 42399, "input_tokens": 12, "output_tokens": 1835, "cache_read_tokens": 228611, "cache_creation_tokens": 28984, "cost_usd": 0.2700483, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:50:00.618Z", "level": "info", "role": "navigator", "event": "cycle.review", "message": "REVIEW [S2-stock-by-location-table] refactor=false: looks good", "metadata": {"feature_id": "F1-stock-visibility", "ac": "S2-stock-by-location-table", "refactor": false, "rationale": "looks good", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:50:00.868Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:50:00.868Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE acceptance awaiting decision , story S2-stock-by-location-table", "metadata": {"feature_id": "F1-stock-visibility", "gate": "acceptance", "subject": "story S2-stock-by-location-table", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:50:01.202Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start story S2-stock-by-location-table -> local", "metadata": {"feature_id": "F1-stock-visibility", "scope": "story S2-stock-by-location-table", "target": "local", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:51:34.464Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 72913)", "metadata": {"url": "http://localhost:8000/", "pid": 72913, "feature_id": "F1-stock-visibility", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:51:34.464Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed story S2-stock-by-location-table (./scripts/run-tests.sh)", "metadata": {"scope": "story S2-stock-by-location-table", "command": "./scripts/run-tests.sh", "feature_id": "F1-stock-visibility", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:51:34.465Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified story S2-stock-by-location-table @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F1-stock-visibility", "scope": "story S2-stock-by-location-table", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:51:34.465Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "outcome": "verified", "ok": true, "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:51:34.682Z", "level": "info", "role": "orchestrator", "event": "experiment.accepted", "message": "EXPERIMENT accepted (merged) for S2-stock-by-location-table", "metadata": {"feature_id": "F1-stock-visibility", "story": "S2-stock-by-location-table"}} +{"timestamp": "2026-08-01T04:51:56.461Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "spec-author", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:51:56.461Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:52:31.288Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 8 input + 2524 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 34826, "input_tokens": 8, "output_tokens": 2524, "cache_read_tokens": 72057, "cache_creation_tokens": 4422, "cost_usd": 0.1433885, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:52:31.465Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC1-lists-stock-across-locations for story S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC1-lists-stock-across-locations for story S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC1-lists-stock-across-locations.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:52:31.475Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC2-shows-tracking-code for story S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC2-shows-tracking-code for story S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC2-shows-tracking-code.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:52:31.475Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC3-par-level-not-tracked for story S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC3-par-level-not-tracked for story S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC3-par-level-not-tracked.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:52:31.476Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC4-known-sku-no-stock for story S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC4-known-sku-no-stock for story S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC4-known-sku-no-stock.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:52:31.476Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC5-unknown-sku-not-found for story S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC5-unknown-sku-not-found for story S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC5-unknown-sku-not-found.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:52:31.508Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "architect-reviewer", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:52:31.508Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:53:17.727Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 8 input + 3610 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 46218, "input_tokens": 8, "output_tokens": 3610, "cache_read_tokens": 75486, "cache_creation_tokens": 6951, "cost_usd": 0.19754300000000002, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:53:17.931Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch test-strategist for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "test-strategist", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:53:17.931Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "phase.start", "message": "test-strategist START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:55:26.971Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "turn.usage", "message": "test-strategist turn used 20 input + 9223 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 129039, "input_tokens": 20, "output_tokens": 9223, "cache_read_tokens": 223686, "cache_creation_tokens": 16833, "cost_usd": 0.5108480000000001, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:55:27.186Z", "level": "info", "role": "test-strategist", "event": "artifact.written", "message": "test-strategist wrote per-story test list for S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "per-story test list for S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/test-list-per-story.json", "reconciled": true}} +{"timestamp": "2026-08-01T04:55:27.230Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for reflect", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "reflect", "story": "S3-sku-detail-view", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T04:55:27.230Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START reflect", "metadata": {"feature_id": "F1-stock-visibility", "phase": "reflect", "story": "S3-sku-detail-view", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T04:59:15.523Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 9 input + 12941 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 228291, "input_tokens": 9, "output_tokens": 12941, "cache_read_tokens": 167427, "cache_creation_tokens": 34686, "cost_usd": 0.4524861, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:59:15.993Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: revise-route", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: revise-route"}} +{"timestamp": "2026-08-01T04:59:16.199Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "spec-author", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T04:59:16.199Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:00:26.638Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 12 input + 4765 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 70438, "input_tokens": 12, "output_tokens": 4765, "cache_read_tokens": 142113, "cache_creation_tokens": 7695, "cost_usd": 0.26719149999999997, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:00:26.816Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC4-sku-with-no-stock-empty-state for story S3-sku-detail-view , present on disk (reconciled)", "metadata": {"feature_id": "F1-stock-visibility", "artifact": "AC AC4-sku-with-no-stock-empty-state for story S3-sku-detail-view", "summary": "present on disk (reconciled)", "path": "features/F1-stock-visibility/stories/S3-sku-detail-view/acs/AC4-sku-with-no-stock-empty-state.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:00:26.859Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "architect-reviewer", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:00:26.859Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:01:39.306Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 14 input + 4723 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 72446, "input_tokens": 14, "output_tokens": 4723, "cache_read_tokens": 185507, "cache_creation_tokens": 7417, "cost_usd": 0.28506850000000006, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:01:39.512Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch test-strategist for design", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "test-strategist", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:01:39.512Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "phase.start", "message": "test-strategist START design", "metadata": {"feature_id": "F1-stock-visibility", "phase": "design", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:04:44.861Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "turn.usage", "message": "test-strategist turn used 32 input + 12136 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 185349, "input_tokens": 32, "output_tokens": 12136, "cache_read_tokens": 348160, "cache_creation_tokens": 21588, "cost_usd": 0.6935199999999999, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:04:45.128Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for reflect", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "reflect", "story": "S3-sku-detail-view", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T05:04:45.128Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START reflect", "metadata": {"feature_id": "F1-stock-visibility", "phase": "reflect", "story": "S3-sku-detail-view", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T05:06:02.004Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 8 input + 4350 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 76875, "input_tokens": 8, "output_tokens": 4350, "cache_read_tokens": 144215, "cache_creation_tokens": 20847, "cost_usd": 0.23362049999999998, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:06:02.437Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE spec awaiting decision , story S3-sku-detail-view", "metadata": {"feature_id": "F1-stock-visibility", "gate": "spec", "subject": "story S3-sku-detail-view", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:06:02.635Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE spec APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "spec", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:06:02.823Z", "level": "info", "role": "orchestrator", "event": "phase.start", "message": "orchestrator START build", "metadata": {"feature_id": "F1-stock-visibility", "phase": "build", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:06:02.979Z", "level": "info", "role": "orchestrator", "event": "experiment.cut", "message": "EXPERIMENT cut for S3-sku-detail-view", "metadata": {"feature_id": "F1-stock-visibility", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:06:17.977Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for red", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "red", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:06:17.977Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START red", "metadata": {"feature_id": "F1-stock-visibility", "phase": "red", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:17:45.342Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 21 input + 46179 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 687362, "input_tokens": 21, "output_tokens": 46179, "cache_read_tokens": 754916, "cache_creation_tokens": 178799, "cost_usd": 1.9920168, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:17:45.605Z", "level": "info", "role": "navigator", "event": "cycle.red", "message": "RED 9 test(s) in cycle-001 [E2E], lead T24 (AC1-lists-stock-across-locations): reading a SKU's detail through the boundary returns a JSON collection with one entry per location where that sku holds stock (seeded across multiple locations under a unique uuid-suffixed sku, cleaned up after), each entry carrying its location and quantity, read through service and repository against the branch DB", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "cycle-001", "test_id": "T24", "ac": "AC1-lists-stock-across-locations", "asserts": "reading a SKU's detail through the boundary returns a JSON collection with one entry per location where that sku holds stock (seeded across multiple locations under a unique uuid-suffixed sku, cleaned up after), each entry carrying its location and quantity, read through service and repository against the branch DB", "layer": "E2E", "batch": 9}} +{"timestamp": "2026-08-01T05:17:45.860Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "green", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:17:45.860Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F1-stock-visibility", "phase": "green", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:19:59.695Z", "level": "info", "role": "driver", "event": "reasoning", "message": "Added GET /api/stock/sku/{sku} endpoint across repo/service/route layers with par_level:None; all 4 S3 BDD scenarios and 4 architecture fitness tests pass.", "metadata": {"feature_id": "F1-stock-visibility", "note": "Added GET /api/stock/sku/{sku} endpoint across repo/service/route layers with par_level:None; all 4 S3 BDD scenarios and 4 architecture fitness tests pass."}} +{"timestamp": "2026-08-01T05:20:02.527Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 23 input + 5296 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 136665, "input_tokens": 23, "output_tokens": 5296, "cache_read_tokens": 627369, "cache_creation_tokens": 35561, "cost_usd": 0.4810857, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:21:34.838Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for assess", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "assess", "story": "S3-sku-detail-view", "buildMode": "assess", "ac": "AC1-lists-stock-across-locations"}} +{"timestamp": "2026-08-01T05:21:34.838Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START assess", "metadata": {"feature_id": "F1-stock-visibility", "phase": "assess", "story": "S3-sku-detail-view", "buildMode": "assess", "ac": "AC1-lists-stock-across-locations"}} +{"timestamp": "2026-08-01T05:32:59.003Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 31 input + 41494 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 684165, "input_tokens": 31, "output_tokens": 41494, "cache_read_tokens": 1576353, "cache_creation_tokens": 128433, "cost_usd": 1.8660068999999995, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:32:59.448Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for repair", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "repair", "story": "S3-sku-detail-view", "buildMode": "repair", "ac": "AC1-lists-stock-across-locations"}} +{"timestamp": "2026-08-01T05:32:59.449Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START repair", "metadata": {"feature_id": "F1-stock-visibility", "phase": "repair", "story": "S3-sku-detail-view", "buildMode": "repair", "ac": "AC1-lists-stock-across-locations"}} +{"timestamp": "2026-08-01T05:35:12.799Z", "level": "info", "role": "driver", "event": "reasoning", "message": "REPAIR: renamed list_stock_by_sku to get_sku_detail; created SkuDetailPage.tsx + fetchSkuDetail export; all S3 and client tests green", "metadata": {"feature_id": "S3-sku-detail-view", "cycle_id": "AC1-lists-stock-across-locations", "note": "REPAIR: renamed list_stock_by_sku to get_sku_detail; created SkuDetailPage.tsx + fetchSkuDetail export; all S3 and client tests green"}} +{"timestamp": "2026-08-01T05:35:16.606Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 27 input + 5231 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 137157, "input_tokens": 27, "output_tokens": 5231, "cache_read_tokens": 804580, "cache_creation_tokens": 32392, "cost_usd": 0.514272, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:35:46.606Z", "level": "info", "role": "driver", "event": "cycle.green", "message": "GREEN T24 [AC1-lists-stock-across-locations]: minimal honest code", "metadata": {"feature_id": "F1-stock-visibility", "cycle_id": "cycle-001", "test_id": "T24", "ac": "AC1-lists-stock-across-locations", "change": "minimal honest code"}} +{"timestamp": "2026-08-01T05:35:54.240Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for review", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "navigator", "phase": "review", "story": "S3-sku-detail-view", "buildMode": "review"}} +{"timestamp": "2026-08-01T05:35:54.240Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "phase.start", "message": "navigator START review", "metadata": {"feature_id": "F1-stock-visibility", "phase": "review", "story": "S3-sku-detail-view", "buildMode": "review"}} +{"timestamp": "2026-08-01T05:36:41.675Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "turn.usage", "message": "navigator turn used 12 input + 1915 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 47433, "input_tokens": 12, "output_tokens": 1915, "cache_read_tokens": 252864, "cache_creation_tokens": 58253, "cost_usd": 0.45413820000000005, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:36:41.847Z", "level": "info", "role": "navigator", "event": "cycle.review", "message": "REVIEW [S3-sku-detail-view] refactor=true: SkuDetailPage.tsx renders a bare
with no CSS classes consuming design tokens (typography via --font-sans, spacing via --space-*, color via --color-text/--color-card). The design-guide requires all surfaces to consume tokens via var(--token) rather than leave them absent. Add a .sku-detail-table / .page wrapper in theme.css and apply the class to the
and container div so the card surface, font, and spacing are token-driven. Layer boundaries, service/repository split, JSON-only API boundary (NFR-F1-5), config-in-env (NFR-F1-7), and no-mock DB (NFR-F1-4) are all satisfied; no production code concerns beyond the missing token classes.", "metadata": {"feature_id": "F1-stock-visibility", "ac": "S3-sku-detail-view", "refactor": true, "rationale": "SkuDetailPage.tsx renders a bare
with no CSS classes consuming design tokens (typography via --font-sans, spacing via --space-*, color via --color-text/--color-card). The design-guide requires all surfaces to consume tokens via var(--token) rather than leave them absent. Add a .sku-detail-table / .page wrapper in theme.css and apply the class to the
and container div so the card surface, font, and spacing are token-driven. Layer boundaries, service/repository split, JSON-only API boundary (NFR-F1-5), config-in-env (NFR-F1-7), and no-mock DB (NFR-F1-4) are all satisfied; no production code concerns beyond the missing token classes.", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:36:42.094Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for refactor", "metadata": {"feature_id": "F1-stock-visibility", "to_role": "driver", "phase": "refactor", "story": "S3-sku-detail-view", "buildMode": "refactor"}} +{"timestamp": "2026-08-01T05:36:42.094Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START refactor", "metadata": {"feature_id": "F1-stock-visibility", "phase": "refactor", "story": "S3-sku-detail-view", "buildMode": "refactor"}} +{"timestamp": "2026-08-01T05:38:05.361Z", "level": "info", "role": "driver", "model": "haiku", "event": "turn.usage", "message": "driver turn used 90 input + 3265 output tokens", "metadata": {"feature_id": "F1-stock-visibility", "duration_ms": 83265, "input_tokens": 90, "output_tokens": 3265, "cache_read_tokens": 349634, "cache_creation_tokens": 38813, "cost_usd": 0.12900440000000002, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:38:35.361Z", "level": "info", "role": "driver", "event": "cycle.refactored", "message": "REFACTOR [S3-sku-detail-view]: addressed: SkuDetailPage.tsx renders a bare
with no CSS classes consuming design tokens (typography via --font-sans, spacing via --space-*, color via --color-text/--color-card). The design-guide requires all surfaces to consume tokens via var(--token) rather than leave them absent. Add a .sku-detail-table / .page wrapper in theme.css and apply the class to the
and container div so the card surface, font, and spacing are token-driven. Layer boundaries, service/repository split, JSON-only API boundary (NFR-F1-5), config-in-env (NFR-F1-7), and no-mock DB (NFR-F1-4) are all satisfied; no production code concerns beyond the missing token classes.", "metadata": {"feature_id": "F1-stock-visibility", "ac": "S3-sku-detail-view", "change": "addressed: SkuDetailPage.tsx renders a bare
with no CSS classes consuming design tokens (typography via --font-sans, spacing via --space-*, color via --color-text/--color-card). The design-guide requires all surfaces to consume tokens via var(--token) rather than leave them absent. Add a .sku-detail-table / .page wrapper in theme.css and apply the class to the
and container div so the card surface, font, and spacing are token-driven. Layer boundaries, service/repository split, JSON-only API boundary (NFR-F1-5), config-in-env (NFR-F1-7), and no-mock DB (NFR-F1-4) are all satisfied; no production code concerns beyond the missing token classes.", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:38:42.800Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:38:42.800Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE acceptance awaiting decision , story S3-sku-detail-view", "metadata": {"feature_id": "F1-stock-visibility", "gate": "acceptance", "subject": "story S3-sku-detail-view", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:38:43.114Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start story S3-sku-detail-view -> local", "metadata": {"feature_id": "F1-stock-visibility", "scope": "story S3-sku-detail-view", "target": "local", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:40:34.893Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 93385)", "metadata": {"url": "http://localhost:8000/", "pid": 93385, "feature_id": "F1-stock-visibility", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:40:34.895Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed story S3-sku-detail-view (./scripts/run-tests.sh)", "metadata": {"scope": "story S3-sku-detail-view", "command": "./scripts/run-tests.sh", "feature_id": "F1-stock-visibility", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:40:34.896Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified story S3-sku-detail-view @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F1-stock-visibility", "scope": "story S3-sku-detail-view", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:40:34.896Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "outcome": "verified", "ok": true, "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:40:35.178Z", "level": "info", "role": "orchestrator", "event": "experiment.accepted", "message": "EXPERIMENT accepted (merged) for S3-sku-detail-view", "metadata": {"feature_id": "F1-stock-visibility", "story": "S3-sku-detail-view"}} +{"timestamp": "2026-08-01T05:40:57.215Z", "level": "info", "role": "orchestrator", "event": "phase.end", "message": "orchestrator END feature (complete)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "feature", "outcome": "complete"}} +{"timestamp": "2026-08-01T05:40:57.335Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy"}} +{"timestamp": "2026-08-01T05:40:57.659Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start feature F1-stock-visibility -> local", "metadata": {"feature_id": "F1-stock-visibility", "scope": "feature F1-stock-visibility", "target": "local"}} +{"timestamp": "2026-08-01T05:42:48.627Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 93986)", "metadata": {"url": "http://localhost:8000/", "pid": 93986, "feature_id": "F1-stock-visibility"}} +{"timestamp": "2026-08-01T05:42:48.628Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed feature F1-stock-visibility (./scripts/run-tests.sh)", "metadata": {"scope": "feature F1-stock-visibility", "command": "./scripts/run-tests.sh", "feature_id": "F1-stock-visibility"}} +{"timestamp": "2026-08-01T05:42:48.629Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified feature F1-stock-visibility @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F1-stock-visibility", "scope": "feature F1-stock-visibility", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true}} +{"timestamp": "2026-08-01T05:42:48.629Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "deploy", "outcome": "verified", "ok": true}} +{"timestamp": "2026-08-01T05:42:48.683Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE deploy APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "deploy"}} +{"timestamp": "2026-08-01T05:42:48.910Z", "level": "info", "role": "product-owner", "event": "gate.approved", "message": "GATE deploy APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "deploy", "artifacts": ["deploy-evidence.json"], "approver": "human-proxy", "validated": true}} +{"timestamp": "2026-08-01T05:42:48.945Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START promote", "metadata": {"feature_id": "F1-stock-visibility", "phase": "promote"}} +{"timestamp": "2026-08-01T05:42:48.967Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: prepare-pr", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: prepare-pr"}} +{"timestamp": "2026-08-01T05:42:55.094Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: wait-ci", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: wait-ci"}} +{"timestamp": "2026-08-01T05:43:25.094Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE promote APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "promote"}} +{"timestamp": "2026-08-01T05:43:25.313Z", "level": "info", "role": "product-owner", "event": "gate.approved", "message": "GATE promote APPROVED", "metadata": {"feature_id": "F1-stock-visibility", "gate": "promote", "artifacts": ["promote_ref"], "approver": "human-proxy", "validated": true}} +{"timestamp": "2026-08-01T05:43:25.354Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: merge", "metadata": {"feature_id": "F1-stock-visibility", "note": "orchestrator: merge"}} +{"timestamp": "2026-08-01T05:45:08.354Z", "level": "info", "role": "orchestrator", "event": "phase.end", "message": "orchestrator END workflow (complete)", "metadata": {"feature_id": "F1-stock-visibility", "phase": "workflow", "outcome": "complete"}} +{"timestamp": "2026-08-01T05:45:11.719Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch product-owner for author-requests", "metadata": {"feature_id": "", "to_role": "product-owner", "phase": "author-requests", "mode": "author-requests"}} +{"timestamp": "2026-08-01T05:45:11.730Z", "level": "info", "role": "product-owner", "model": "opus", "event": "phase.start", "message": "product-owner START author-requests", "metadata": {"feature_id": "", "phase": "author-requests", "mode": "author-requests"}} +{"timestamp": "2026-08-01T05:45:11.881Z", "level": "info", "role": "product-owner", "event": "intake.supplied", "message": "INTAKE supplied feature-request.md", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "feature-request.md", "from": "/Users/kevin.hartman/code/databricks-solutions/consort/examples/sftdd-scenarios/stockflow/recorded-artifacts/features/F6-split-tracking-code/feature-request.md", "to": "/Users/kevin.hartman/code/tdd-workflow-smoke/stockflow-rerecord-cap-20260801-044928/.sftdd/features/F6-split-tracking-code/feature-request.md", "approver": "human-proxy", "validated": true}} +{"timestamp": "2026-08-01T05:45:11.922Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE plan APPROVED", "metadata": {"feature_id": "", "gate": "plan"}} +{"timestamp": "2026-08-01T05:45:43.261Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for breakdown", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "spec-author", "phase": "breakdown", "mode": "breakdown"}} +{"timestamp": "2026-08-01T05:45:43.261Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START breakdown", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "breakdown", "mode": "breakdown"}} +{"timestamp": "2026-08-01T05:48:22.269Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 26 input + 10871 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 158857, "input_tokens": 26, "output_tokens": 10871, "cache_read_tokens": 212391, "cache_creation_tokens": 13048, "cost_usd": 0.5085805, "phase": "breakdown"}} +{"timestamp": "2026-08-01T05:48:22.599Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote feature-spec.json , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "feature-spec.json", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/feature-spec.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:48:22.609Z", "level": "info", "role": "architect-reviewer", "event": "artifact.written", "message": "architect-reviewer wrote architecture conventions (project) , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "architecture conventions (project)", "summary": "present on disk (reconciled)", "path": "architecture/conventions.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:48:22.609Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote design-guide.json , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "design-guide.json", "summary": "present on disk (reconciled)", "path": "design/design-guide.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:48:22.609Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote design-guide.md , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "design-guide.md", "summary": "present on disk (reconciled)", "path": "design/design-guide.md", "reconciled": true}} +{"timestamp": "2026-08-01T05:48:22.609Z", "level": "info", "role": "ux-designer", "event": "artifact.written", "message": "ux-designer wrote ia.md , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "ia.md", "summary": "present on disk (reconciled)", "path": "design/ia.md", "reconciled": true}} +{"timestamp": "2026-08-01T05:48:22.609Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "story stub S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/story.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:48:22.609Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S2-reversible-down-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "story stub S2-reversible-down-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S2-reversible-down-migration/story.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:48:22.609Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote story stub S3-stock-shows-split-fields , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "story stub S3-stock-shows-split-fields", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S3-stock-shows-split-fields/story.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:48:22.646Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "spec-author", "phase": "design", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:48:22.646Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:52:28.463Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 36 input + 11161 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 245817, "input_tokens": 36, "output_tokens": 11161, "cache_read_tokens": 346722, "cache_creation_tokens": 19103, "cost_usd": 0.6435960000000001, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:52:28.751Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC1-batch-serial-columns-added for story S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC1-batch-serial-columns-added for story S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/acs/AC1-batch-serial-columns-added.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:52:28.762Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC2-conforming-code-split for story S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC2-conforming-code-split for story S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/acs/AC2-conforming-code-split.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:52:28.762Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC3-combined-code-dropped for story S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC3-combined-code-dropped for story S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/acs/AC3-combined-code-dropped.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:52:28.762Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC4-nonconforming-code-left-null for story S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC4-nonconforming-code-left-null for story S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/acs/AC4-nonconforming-code-left-null.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:52:28.762Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC5-all-rows-preserved for story S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC5-all-rows-preserved for story S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/acs/AC5-all-rows-preserved.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:52:28.762Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC6-nonconforming-count-surfaced for story S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC6-nonconforming-count-surfaced for story S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/acs/AC6-nonconforming-count-surfaced.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:52:28.762Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC7-migration-reversible for story S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC7-migration-reversible for story S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/acs/AC7-migration-reversible.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:52:28.762Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC8-location-unchanged for story S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC8-location-unchanged for story S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/acs/AC8-location-unchanged.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:52:28.809Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "architect-reviewer", "phase": "design", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:52:28.809Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:56:03.866Z", "level": "info", "role": "architect-reviewer", "event": "gate.surfaced", "message": "GATE plan awaiting decision , S1 layer assignment + cross-cutting mapping + 9 proposed NFRs for PO adjudication", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "plan", "subject": "S1 layer assignment + cross-cutting mapping + 9 proposed NFRs for PO adjudication"}} +{"timestamp": "2026-08-01T05:56:03.876Z", "level": "info", "role": "product-owner", "event": "gate.approved", "message": "GATE plan APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "plan"}} +{"timestamp": "2026-08-01T05:56:08.374Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 46 input + 14736 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 219565, "input_tokens": 46, "output_tokens": 14736, "cache_read_tokens": 490786, "cache_creation_tokens": 24911, "cost_usd": 0.8631329999999998, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:56:08.547Z", "level": "info", "role": "architect-reviewer", "event": "artifact.written", "message": "architect-reviewer wrote architecture.json , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "architecture.json", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/architecture.json", "reconciled": true}} +{"timestamp": "2026-08-01T05:56:08.593Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch dba for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "dba", "phase": "design", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:56:08.593Z", "level": "info", "role": "dba", "model": "opus", "event": "phase.start", "message": "dba START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:57:04.379Z", "level": "info", "role": "dba", "model": "opus", "event": "turn.usage", "message": "dba turn used 14 input + 3913 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 55786, "input_tokens": 14, "output_tokens": 3913, "cache_read_tokens": 76944, "cache_creation_tokens": 13725, "cost_usd": 0.273617, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:57:04.587Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch test-strategist for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "test-strategist", "phase": "design", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T05:57:04.587Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "phase.start", "message": "test-strategist START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:01:26.589Z", "level": "info", "role": "test-strategist", "event": "gate.surfaced", "message": "GATE test_list awaiting decision , S1-split-columns-migration test list", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "test_list", "subject": "S1-split-columns-migration test list", "detail": "14 items ordered design-momentum: columns-exist first, then backfill split, drop, preservation, key-integrity, aggregate probe, reversible round-trip; every AC AC1-AC8 covered, PI1/PI2/PI3 each have a real-branch fitness test"}} +{"timestamp": "2026-08-01T06:01:26.598Z", "level": "info", "role": "test-strategist", "event": "reasoning", "message": "design-momentum: earliest tests force schema/interface (columns addressable, layering, config-in-env, real-branch binding) before backfill correctness, then destructive drop, whole-migration preservation, scoped aggregate probe, downgrade round-trip last", "metadata": {"feature_id": "F6-split-tracking-code", "subject": "ordered_for rationale", "note": "design-momentum: earliest tests force schema/interface (columns addressable, layering, config-in-env, real-branch binding) before backfill correctness, then destructive drop, whole-migration preservation, scoped aggregate probe, downgrade round-trip last"}} +{"timestamp": "2026-08-01T06:01:26.598Z", "level": "info", "role": "product-owner", "event": "gate.approved", "message": "GATE test_list APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "test_list", "subject": "S1-split-columns-migration test list", "detail": "Human Proxy: rendered test-list.md ordered, every S1 AC covered, PI1/PI2/PI3 real-branch, no deferrals; approved"}} +{"timestamp": "2026-08-01T06:01:30.698Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "turn.usage", "message": "test-strategist turn used 36 input + 18398 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 266111, "input_tokens": 36, "output_tokens": 18398, "cache_read_tokens": 486821, "cache_creation_tokens": 26036, "cost_usd": 0.9639005, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:01:30.914Z", "level": "info", "role": "test-strategist", "event": "artifact.written", "message": "test-strategist wrote test-list.json , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "test-list.json", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/test-list.json", "reconciled": true}} +{"timestamp": "2026-08-01T06:01:30.924Z", "level": "info", "role": "test-strategist", "event": "artifact.written", "message": "test-strategist wrote per-story test list for S1-split-columns-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "per-story test list for S1-split-columns-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S1-split-columns-migration/test-list-per-story.json", "reconciled": true}} +{"timestamp": "2026-08-01T06:01:30.969Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for reflect", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "reflect", "story": "S1-split-columns-migration", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T06:01:30.969Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START reflect", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "reflect", "story": "S1-split-columns-migration", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T06:02:42.195Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 7 input + 3727 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 71224, "input_tokens": 7, "output_tokens": 3727, "cache_read_tokens": 96296, "cache_creation_tokens": 45627, "cost_usd": 0.3585768, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:02:42.610Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE spec awaiting decision , story S1-split-columns-migration", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "spec", "subject": "story S1-split-columns-migration", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:02:42.807Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE spec APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "spec", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:02:42.998Z", "level": "info", "role": "orchestrator", "event": "phase.start", "message": "orchestrator START build", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "build", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:02:43.168Z", "level": "info", "role": "orchestrator", "event": "experiment.cut", "message": "EXPERIMENT cut for S1-split-columns-migration", "metadata": {"feature_id": "F6-split-tracking-code", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:03:04.700Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for red", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "red", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:03:04.700Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START red", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "red", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:28:09.869Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 35 input + 96742 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 1505166, "input_tokens": 35, "output_tokens": 96742, "cache_read_tokens": 1180002, "cache_creation_tokens": 57187, "cost_usd": 2.1483575999999998, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:28:10.164Z", "level": "info", "role": "navigator", "event": "cycle.red", "message": "RED 14 test(s) in cycle-001 [Infra], lead T1 (AC1-batch-serial-columns-added): after the up-migration runs against the branch DB, stock_records exposes batch_number and serial_number as its own separately addressable columns", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "test_id": "T1", "ac": "AC1-batch-serial-columns-added", "asserts": "after the up-migration runs against the branch DB, stock_records exposes batch_number and serial_number as its own separately addressable columns", "layer": "Infra", "batch": 14}} +{"timestamp": "2026-08-01T06:28:10.455Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "driver", "phase": "green", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:28:10.455Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "green", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:50:31.701Z", "level": "info", "role": "driver", "event": "reasoning", "message": "Created migration 20260801065232: expand/contract split of inventory_code into batch_number+serial_number; model+repo+service+route updated in lockstep; all 5 story tests GREEN", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "note": "Created migration 20260801065232: expand/contract split of inventory_code into batch_number+serial_number; model+repo+service+route updated in lockstep; all 5 story tests GREEN"}} +{"timestamp": "2026-08-01T06:50:34.831Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 37 input + 80498 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 1344375, "input_tokens": 37, "output_tokens": 80498, "cache_read_tokens": 1449950, "cache_creation_tokens": 67717, "cost_usd": 2.0488679999999997, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:51:04.831Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for assess", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "assess", "story": "S1-split-columns-migration", "buildMode": "assess", "ac": "AC1-batch-serial-columns-added"}} +{"timestamp": "2026-08-01T06:51:04.832Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START assess", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "assess", "story": "S1-split-columns-migration", "buildMode": "assess", "ac": "AC1-batch-serial-columns-added"}} +{"timestamp": "2026-08-01T06:53:51.848Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 7 input + 10342 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 167016, "input_tokens": 7, "output_tokens": 10342, "cache_read_tokens": 128726, "cache_creation_tokens": 20321, "cost_usd": 0.3156948, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:53:52.263Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "driver", "phase": "green", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T06:53:52.263Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "green", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:15:25.457Z", "level": "info", "role": "driver", "event": "reasoning", "message": "GREEN: superseded tests refactored to batch_number/serial_number schema; T4 fixed to skip non-utf8 cache files; T13 test data corrected to use truly nonconforming codes", "metadata": {"feature_id": "F6-split-tracking-code", "note": "GREEN: superseded tests refactored to batch_number/serial_number schema; T4 fixed to skip non-utf8 cache files; T13 test data corrected to use truly nonconforming codes"}} +{"timestamp": "2026-08-01T07:15:34.179Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 54 input + 56202 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 1301914, "input_tokens": 54, "output_tokens": 56202, "cache_read_tokens": 3173441, "cache_creation_tokens": 85380, "cost_usd": 2.3075043000000006, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:16:04.179Z", "level": "info", "role": "driver", "event": "cycle.green", "message": "GREEN T1 [AC1-batch-serial-columns-added]: minimal honest code", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "test_id": "T1", "ac": "AC1-batch-serial-columns-added", "change": "minimal honest code"}} +{"timestamp": "2026-08-01T07:16:12.122Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for review", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "review", "story": "S1-split-columns-migration", "buildMode": "review"}} +{"timestamp": "2026-08-01T07:16:12.122Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "phase.start", "message": "navigator START review", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "review", "story": "S1-split-columns-migration", "buildMode": "review"}} +{"timestamp": "2026-08-01T07:16:33.032Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "turn.usage", "message": "navigator turn used 7 input + 794 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 20908, "input_tokens": 7, "output_tokens": 794, "cache_read_tokens": 77422, "cache_creation_tokens": 50300, "cost_usd": 0.33695759999999997, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:16:33.202Z", "level": "info", "role": "navigator", "event": "cycle.review", "message": "REVIEW [S1-split-columns-migration] refactor=false: looks good", "metadata": {"feature_id": "F6-split-tracking-code", "ac": "S1-split-columns-migration", "refactor": false, "rationale": "looks good", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:16:33.434Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "deploy", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:16:33.434Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE acceptance awaiting decision , story S1-split-columns-migration", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "acceptance", "subject": "story S1-split-columns-migration", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:16:33.713Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start story S1-split-columns-migration -> local", "metadata": {"feature_id": "F6-split-tracking-code", "scope": "story S1-split-columns-migration", "target": "local", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:17:03.713Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 25939)", "metadata": {"url": "http://localhost:8000/", "pid": 25939, "feature_id": "F6-split-tracking-code", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:17:03.715Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed story S1-split-columns-migration (./scripts/run-tests.sh)", "metadata": {"scope": "story S1-split-columns-migration", "command": "./scripts/run-tests.sh", "feature_id": "F6-split-tracking-code", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:17:03.715Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified story S1-split-columns-migration @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F6-split-tracking-code", "scope": "story S1-split-columns-migration", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:17:03.715Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "deploy", "outcome": "verified", "ok": true, "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:17:03.999Z", "level": "info", "role": "orchestrator", "event": "experiment.accepted", "message": "EXPERIMENT accepted (merged) for S1-split-columns-migration", "metadata": {"feature_id": "F6-split-tracking-code", "story": "S1-split-columns-migration"}} +{"timestamp": "2026-08-01T07:17:27.007Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch spec-author for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "spec-author", "phase": "design", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:17:27.007Z", "level": "info", "role": "spec-author", "model": "opus", "event": "phase.start", "message": "spec-author START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:19:06.005Z", "level": "info", "role": "spec-author", "model": "opus", "event": "turn.usage", "message": "spec-author turn used 18 input + 6423 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 98998, "input_tokens": 18, "output_tokens": 6423, "cache_read_tokens": 112542, "cache_creation_tokens": 19219, "cost_usd": 0.40912600000000005, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:19:06.187Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC1-combined-column-restored for story S2-reversible-down-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC1-combined-column-restored for story S2-reversible-down-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S2-reversible-down-migration/acs/AC1-combined-column-restored.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:19:06.199Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC2-code-recombined-from-parts for story S2-reversible-down-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC2-code-recombined-from-parts for story S2-reversible-down-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S2-reversible-down-migration/acs/AC2-code-recombined-from-parts.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:19:06.199Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC3-nonconforming-row-recombined-safely for story S2-reversible-down-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC3-nonconforming-row-recombined-safely for story S2-reversible-down-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S2-reversible-down-migration/acs/AC3-nonconforming-row-recombined-safely.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:19:06.200Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC4-split-columns-removed for story S2-reversible-down-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC4-split-columns-removed for story S2-reversible-down-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S2-reversible-down-migration/acs/AC4-split-columns-removed.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:19:06.200Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC5-all-rows-survive-rollback for story S2-reversible-down-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC5-all-rows-survive-rollback for story S2-reversible-down-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S2-reversible-down-migration/acs/AC5-all-rows-survive-rollback.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:19:06.242Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "architect-reviewer", "phase": "design", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:19:06.242Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:20:29.379Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 18 input + 5614 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 83135, "input_tokens": 18, "output_tokens": 5614, "cache_read_tokens": 144139, "cache_creation_tokens": 18847, "cost_usd": 0.40097949999999993, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:20:29.568Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch test-strategist for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "test-strategist", "phase": "design", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:20:29.568Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "phase.start", "message": "test-strategist START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:25:01.835Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "turn.usage", "message": "test-strategist turn used 38 input + 17239 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 272267, "input_tokens": 38, "output_tokens": 17239, "cache_read_tokens": 461129, "cache_creation_tokens": 27998, "cost_usd": 0.9417095, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:25:02.146Z", "level": "info", "role": "test-strategist", "event": "artifact.written", "message": "test-strategist wrote per-story test list for S2-reversible-down-migration , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "per-story test list for S2-reversible-down-migration", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S2-reversible-down-migration/test-list-per-story.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:25:02.215Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for reflect", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "reflect", "story": "S2-reversible-down-migration", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T07:25:02.215Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START reflect", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "reflect", "story": "S2-reversible-down-migration", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T07:26:26.491Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 7 input + 4841 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 84275, "input_tokens": 7, "output_tokens": 4841, "cache_read_tokens": 92874, "cache_creation_tokens": 35610, "cost_usd": 0.3141582, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:26:26.886Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE spec awaiting decision , story S2-reversible-down-migration", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "spec", "subject": "story S2-reversible-down-migration", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:26:27.066Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE spec APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "spec", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:26:27.268Z", "level": "info", "role": "orchestrator", "event": "phase.start", "message": "orchestrator START build", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "build", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:26:27.426Z", "level": "info", "role": "orchestrator", "event": "experiment.cut", "message": "EXPERIMENT cut for S2-reversible-down-migration", "metadata": {"feature_id": "F6-split-tracking-code", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:26:44.283Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for red", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "red", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:26:44.283Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START red", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "red", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:31:25.760Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 14 input + 14970 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 281474, "input_tokens": 14, "output_tokens": 14970, "cache_read_tokens": 393932, "cache_creation_tokens": 30627, "cost_usd": 0.5265335999999999, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:31:26.039Z", "level": "info", "role": "navigator", "event": "cycle.red", "message": "RED 5 test(s) in cycle-001 [Infra], lead T15 (AC1-combined-column-restored): after the down path runs (@pytest.mark.migration, single-step downgrade -1 on the branch DB), stock_records again exposes a combined inventory_code column", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "test_id": "T15", "ac": "AC1-combined-column-restored", "asserts": "after the down path runs (@pytest.mark.migration, single-step downgrade -1 on the branch DB), stock_records again exposes a combined inventory_code column", "layer": "Infra", "batch": 5}} +{"timestamp": "2026-08-01T07:31:26.304Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "driver", "phase": "green", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:31:26.304Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "green", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:33:29.121Z", "level": "info", "role": "driver", "event": "reasoning", "message": "All 8 story tests already GREEN. The downgrade() in 20260801065232_split_inventory_code_columns.py correctly restores inventory_code (backfills via CASE WHEN for NULL batch/serial), drops batch_number/serial_number, and preserves row cardinality. No code changes required.", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "S2-reversible-down-migration", "note": "All 8 story tests already GREEN. The downgrade() in 20260801065232_split_inventory_code_columns.py correctly restores inventory_code (backfills via CASE WHEN for NULL batch/serial), drops batch_number/serial_number, and preserves row cardinality. No code changes required."}} +{"timestamp": "2026-08-01T07:33:31.625Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 15 input + 2150 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 125320, "input_tokens": 15, "output_tokens": 2150, "cache_read_tokens": 369777, "cache_creation_tokens": 26873, "cost_usd": 0.3044661, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:34:01.625Z", "level": "info", "role": "driver", "event": "cycle.green", "message": "GREEN T15 [AC1-combined-column-restored]: minimal honest code", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "test_id": "T15", "ac": "AC1-combined-column-restored", "change": "minimal honest code"}} +{"timestamp": "2026-08-01T07:34:10.074Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for review", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "review", "story": "S2-reversible-down-migration", "buildMode": "review"}} +{"timestamp": "2026-08-01T07:34:10.074Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "phase.start", "message": "navigator START review", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "review", "story": "S2-reversible-down-migration", "buildMode": "review"}} +{"timestamp": "2026-08-01T07:34:36.456Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "turn.usage", "message": "navigator turn used 10 input + 1018 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 26381, "input_tokens": 10, "output_tokens": 1018, "cache_read_tokens": 180237, "cache_creation_tokens": 14891, "cost_usd": 0.15871710000000003, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:34:36.620Z", "level": "info", "role": "navigator", "event": "cycle.review", "message": "REVIEW [S2-reversible-down-migration] refactor=false: looks good", "metadata": {"feature_id": "F6-split-tracking-code", "ac": "S2-reversible-down-migration", "refactor": false, "rationale": "looks good", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:34:36.858Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "deploy", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:34:36.858Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE acceptance awaiting decision , story S2-reversible-down-migration", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "acceptance", "subject": "story S2-reversible-down-migration", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:34:37.148Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start story S2-reversible-down-migration -> local", "metadata": {"feature_id": "F6-split-tracking-code", "scope": "story S2-reversible-down-migration", "target": "local", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:35:07.148Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 34435)", "metadata": {"url": "http://localhost:8000/", "pid": 34435, "feature_id": "F6-split-tracking-code", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:35:07.148Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed story S2-reversible-down-migration (./scripts/run-tests.sh)", "metadata": {"scope": "story S2-reversible-down-migration", "command": "./scripts/run-tests.sh", "feature_id": "F6-split-tracking-code", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:35:07.149Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified story S2-reversible-down-migration @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F6-split-tracking-code", "scope": "story S2-reversible-down-migration", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:35:07.149Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "deploy", "outcome": "verified", "ok": true, "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:35:07.369Z", "level": "info", "role": "orchestrator", "event": "experiment.accepted", "message": "EXPERIMENT accepted (merged) for S2-reversible-down-migration", "metadata": {"feature_id": "F6-split-tracking-code", "story": "S2-reversible-down-migration"}} +{"timestamp": "2026-08-01T07:35:37.369Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "architect-reviewer", "phase": "design", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:35:37.371Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:36:26.138Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 8 input + 3457 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 48766, "input_tokens": 8, "output_tokens": 3457, "cache_read_tokens": 45348, "cache_creation_tokens": 15442, "cost_usd": 0.263559, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:36:26.369Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC1-split-fields-shown for story S3-stock-shows-split-fields , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC1-split-fields-shown for story S3-stock-shows-split-fields", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S3-stock-shows-split-fields/acs/AC1-split-fields-shown.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:36:26.379Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC2-combined-code-not-shown for story S3-stock-shows-split-fields , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC2-combined-code-not-shown for story S3-stock-shows-split-fields", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S3-stock-shows-split-fields/acs/AC2-combined-code-not-shown.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:36:26.379Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC3-batch-value-shown for story S3-stock-shows-split-fields , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC3-batch-value-shown for story S3-stock-shows-split-fields", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S3-stock-shows-split-fields/acs/AC3-batch-value-shown.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:36:26.379Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC4-serial-value-shown for story S3-stock-shows-split-fields , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC4-serial-value-shown for story S3-stock-shows-split-fields", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S3-stock-shows-split-fields/acs/AC4-serial-value-shown.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:36:26.379Z", "level": "info", "role": "spec-author", "event": "artifact.written", "message": "spec-author wrote AC AC5-null-field-shows-none-yet for story S3-stock-shows-split-fields , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "AC AC5-null-field-shows-none-yet for story S3-stock-shows-split-fields", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S3-stock-shows-split-fields/acs/AC5-null-field-shows-none-yet.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:36:26.447Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch test-strategist for design", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "test-strategist", "phase": "design", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:36:26.447Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "phase.start", "message": "test-strategist START design", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "design", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:40:08.778Z", "level": "info", "role": "test-strategist", "model": "opus", "event": "turn.usage", "message": "test-strategist turn used 42 input + 13249 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 222330, "input_tokens": 42, "output_tokens": 13249, "cache_read_tokens": 561190, "cache_creation_tokens": 24673, "cost_usd": 0.8587600000000002, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:40:09.105Z", "level": "info", "role": "test-strategist", "event": "artifact.written", "message": "test-strategist wrote per-story test list for S3-stock-shows-split-fields , present on disk (reconciled)", "metadata": {"feature_id": "F6-split-tracking-code", "artifact": "per-story test list for S3-stock-shows-split-fields", "summary": "present on disk (reconciled)", "path": "features/F6-split-tracking-code/stories/S3-stock-shows-split-fields/test-list-per-story.json", "reconciled": true}} +{"timestamp": "2026-08-01T07:40:09.179Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for reflect", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "reflect", "story": "S3-stock-shows-split-fields", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T07:40:09.179Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START reflect", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "reflect", "story": "S3-stock-shows-split-fields", "buildMode": "reflect"}} +{"timestamp": "2026-08-01T07:41:29.464Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 7 input + 4575 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 80284, "input_tokens": 7, "output_tokens": 4575, "cache_read_tokens": 90463, "cache_creation_tokens": 42007, "cost_usd": 0.34782689999999994, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:41:29.954Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE spec awaiting decision , story S3-stock-shows-split-fields", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "spec", "subject": "story S3-stock-shows-split-fields", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:41:30.142Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE spec APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "spec", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:41:30.341Z", "level": "info", "role": "orchestrator", "event": "phase.start", "message": "orchestrator START build", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "build", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:41:30.509Z", "level": "info", "role": "orchestrator", "event": "experiment.cut", "message": "EXPERIMENT cut for S3-stock-shows-split-fields", "metadata": {"feature_id": "F6-split-tracking-code", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:41:45.544Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for red", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "red", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:41:45.544Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START red", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "red", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:49:52.649Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 20 input + 31080 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 487103, "input_tokens": 20, "output_tokens": 31080, "cache_read_tokens": 716668, "cache_creation_tokens": 54910, "cost_usd": 1.0107203999999999, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:49:52.857Z", "level": "info", "role": "navigator", "event": "cycle.red", "message": "RED 6 test(s) in cycle-001 [E2E], lead T20 (AC1-split-fields-shown): the stock view renders batch and serial as two distinct, separately labelled fields (each with its own data-testid seam) where the combined tracking code used to be shown", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "test_id": "T20", "ac": "AC1-split-fields-shown", "asserts": "the stock view renders batch and serial as two distinct, separately labelled fields (each with its own data-testid seam) where the combined tracking code used to be shown", "layer": "E2E", "batch": 6}} +{"timestamp": "2026-08-01T07:49:53.129Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "driver", "phase": "green", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:49:53.129Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "green", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:51:56.584Z", "level": "info", "role": "driver", "event": "reasoning", "message": "All 8 S3 tests GREEN against experiment branch DB; batch_number/serial_number columns exist post-S1 migration, get_stock_records_by_sku returns par_level:null, and /api/stock/sku/{sku} route is wired. No production code changes needed.", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "S3-stock-shows-split-fields", "note": "All 8 S3 tests GREEN against experiment branch DB; batch_number/serial_number columns exist post-S1 migration, get_stock_records_by_sku returns par_level:null, and /api/stock/sku/{sku} route is wired. No production code changes needed."}} +{"timestamp": "2026-08-01T07:51:59.589Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 27 input + 3712 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 126459, "input_tokens": 27, "output_tokens": 3712, "cache_read_tokens": 690940, "cache_creation_tokens": 50286, "cost_usd": 0.5647590000000001, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T07:52:29.589Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for assess", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "assess", "story": "S3-stock-shows-split-fields", "buildMode": "assess", "ac": "AC1-split-fields-shown"}} +{"timestamp": "2026-08-01T07:52:29.589Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START assess", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "assess", "story": "S3-stock-shows-split-fields", "buildMode": "assess", "ac": "AC1-split-fields-shown"}} +{"timestamp": "2026-08-01T08:03:28.763Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 10 input + 41086 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 659173, "input_tokens": 10, "output_tokens": 41086, "cache_read_tokens": 237327, "cache_creation_tokens": 28421, "cost_usd": 0.8580441, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:03:29.207Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "driver", "phase": "green", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:03:29.207Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "green", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:10:48.850Z", "level": "info", "role": "driver", "event": "reasoning", "message": "S3 tests already green: routes/stock.py exposes batch_number+serial_number (no inventory_code in Pydantic models), repository returns split fields with par_level:null, fitness guards pass. All 6 S3 tests pass; no code changes needed.", "metadata": {"feature_id": "F6-split-tracking-code", "note": "S3 tests already green: routes/stock.py exposes batch_number+serial_number (no inventory_code in Pydantic models), repository returns split fields with par_level:null, fitness guards pass. All 6 S3 tests pass; no code changes needed."}} +{"timestamp": "2026-08-01T08:10:53.984Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 27 input + 10459 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 444776, "input_tokens": 27, "output_tokens": 10459, "cache_read_tokens": 1111104, "cache_creation_tokens": 127646, "cost_usd": 1.2561732000000003, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:11:23.984Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch architect-reviewer for estimate-committed", "metadata": {"feature_id": "", "to_role": "architect-reviewer", "phase": "estimate-committed", "mode": "estimate-committed"}} +{"timestamp": "2026-08-01T08:11:23.998Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "phase.start", "message": "architect-reviewer START estimate-committed", "metadata": {"feature_id": "", "phase": "estimate-committed", "mode": "estimate-committed"}} +{"timestamp": "2026-08-01T08:12:32.797Z", "level": "info", "role": "architect-reviewer", "model": "opus", "event": "turn.usage", "message": "architect-reviewer turn used 8 input + 1223 output tokens", "metadata": {"feature_id": "", "duration_ms": 68798, "input_tokens": 8, "output_tokens": 1223, "cache_read_tokens": 32452, "cache_creation_tokens": 12669, "cost_usd": 0.173531, "phase": "estimate-committed"}} +{"timestamp": "2026-08-01T08:13:21.332Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for assess", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "assess", "story": "S3-stock-shows-split-fields", "buildMode": "assess", "ac": "AC1-split-fields-shown"}} +{"timestamp": "2026-08-01T08:13:21.332Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START assess", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "assess", "story": "S3-stock-shows-split-fields", "buildMode": "assess", "ac": "AC1-split-fields-shown"}} +{"timestamp": "2026-08-01T08:21:46.596Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 20 input + 15742 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 505263, "input_tokens": 20, "output_tokens": 15742, "cache_read_tokens": 681835, "cache_creation_tokens": 79508, "cost_usd": 0.9177885000000001, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:21:47.298Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for repair", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "driver", "phase": "repair", "story": "S3-stock-shows-split-fields", "buildMode": "repair", "ac": "AC1-split-fields-shown"}} +{"timestamp": "2026-08-01T08:21:47.298Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START repair", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "repair", "story": "S3-stock-shows-split-fields", "buildMode": "repair", "ac": "AC1-split-fields-shown"}} +{"timestamp": "2026-08-01T08:26:29.499Z", "level": "info", "role": "driver", "event": "reasoning", "message": "REPAIR: created StockViewPage.tsx with stock-batch-number and stock-serial-number seams; 'none yet' fallback for null fields; no inventory_code element; T20-T25 and T21 fitness all pass", "metadata": {"feature_id": "S3-stock-shows-split-fields", "cycle_id": "AC1-split-fields-shown", "note": "REPAIR: created StockViewPage.tsx with stock-batch-number and stock-serial-number seams; 'none yet' fallback for null fields; no inventory_code element; T20-T25 and T21 fitness all pass"}} +{"timestamp": "2026-08-01T08:26:39.992Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 17 input + 2345 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 292693, "input_tokens": 17, "output_tokens": 2345, "cache_read_tokens": 355874, "cache_creation_tokens": 46337, "cost_usd": 0.4200102, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:27:09.992Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for assess", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "assess", "story": "S3-stock-shows-split-fields", "buildMode": "assess", "ac": "AC1-split-fields-shown"}} +{"timestamp": "2026-08-01T08:27:09.996Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "phase.start", "message": "navigator START assess", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "assess", "story": "S3-stock-shows-split-fields", "buildMode": "assess", "ac": "AC1-split-fields-shown"}} +{"timestamp": "2026-08-01T08:39:54.464Z", "level": "info", "role": "navigator", "model": "sonnet", "event": "turn.usage", "message": "navigator turn used 35 input + 46535 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 764467, "input_tokens": 35, "output_tokens": 46535, "cache_read_tokens": 1581163, "cache_creation_tokens": 88213, "cost_usd": 1.7017569, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:39:55.074Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for green", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "driver", "phase": "green", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:39:55.074Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START green", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "green", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:42:01.206Z", "level": "info", "role": "driver", "event": "reasoning", "message": "S3 all tests GREEN: StockViewPage renders batch_number/serial_number with stock-batch-number/stock-serial-number data-testids; 8 StockView Vitest + 4 S3 BDD + 4 architecture fitness tests all pass; no code changes required.", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "note": "S3 all tests GREEN: StockViewPage renders batch_number/serial_number with stock-batch-number/stock-serial-number data-testids; 8 StockView Vitest + 4 S3 BDD + 4 architecture fitness tests all pass; no code changes required."}} +{"timestamp": "2026-08-01T08:42:04.701Z", "level": "info", "role": "driver", "model": "sonnet", "event": "turn.usage", "message": "driver turn used 34 input + 5270 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 129625, "input_tokens": 34, "output_tokens": 5270, "cache_read_tokens": 1165968, "cache_creation_tokens": 48746, "cost_usd": 0.7214183999999999, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:43:46.924Z", "level": "info", "role": "driver", "event": "cycle.green", "message": "GREEN T20 [AC1-split-fields-shown]: minimal honest code", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "test_id": "T20", "ac": "AC1-split-fields-shown", "change": "minimal honest code"}} +{"timestamp": "2026-08-01T08:43:51.004Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch navigator for review", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "navigator", "phase": "review", "story": "S3-stock-shows-split-fields", "buildMode": "review"}} +{"timestamp": "2026-08-01T08:43:51.004Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "phase.start", "message": "navigator START review", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "review", "story": "S3-stock-shows-split-fields", "buildMode": "review"}} +{"timestamp": "2026-08-01T08:44:13.060Z", "level": "info", "role": "navigator", "model": "sonnet", "effort": "low", "event": "turn.usage", "message": "navigator turn used 6 input + 943 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 22055, "input_tokens": 6, "output_tokens": 943, "cache_read_tokens": 55690, "cache_creation_tokens": 52862, "cost_usd": 0.3480420000000001, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:44:13.228Z", "level": "info", "role": "navigator", "event": "cycle.review", "message": "REVIEW [S3-stock-shows-split-fields] refactor=true: Client types and UI components still use the old `inventory_code` field instead of the split `batch_number` / `serial_number` fields that are the entire purpose of this story (NFR-F6-9 + the AC). `StockRecord` in client/src/api/stock.ts declares `inventory_code: string`, `SkuDetailEntry` likewise, `FileStockPage` posts `inventory_code`, and `RetrieveStockPage` renders `data-testid='stock-inventory-code'`. The backend correctly returns `batch_number` and `serial_number`; the client API contract and pages must be updated to match: replace `inventory_code` with `batch_number: string | null` and `serial_number: string | null` in all client interfaces, form fields (data-testid='field-batch-number', 'field-serial-number'), and display slots (data-testid='stock-batch-number', 'stock-serial-number'). No outer-boundary test assertions change; the E2E tests already assert on those testids.", "metadata": {"feature_id": "F6-split-tracking-code", "ac": "S3-stock-shows-split-fields", "refactor": true, "rationale": "Client types and UI components still use the old `inventory_code` field instead of the split `batch_number` / `serial_number` fields that are the entire purpose of this story (NFR-F6-9 + the AC). `StockRecord` in client/src/api/stock.ts declares `inventory_code: string`, `SkuDetailEntry` likewise, `FileStockPage` posts `inventory_code`, and `RetrieveStockPage` renders `data-testid='stock-inventory-code'`. The backend correctly returns `batch_number` and `serial_number`; the client API contract and pages must be updated to match: replace `inventory_code` with `batch_number: string | null` and `serial_number: string | null` in all client interfaces, form fields (data-testid='field-batch-number', 'field-serial-number'), and display slots (data-testid='stock-batch-number', 'stock-serial-number'). No outer-boundary test assertions change; the E2E tests already assert on those testids.", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:44:13.468Z", "level": "info", "role": "orchestrator", "event": "handoff", "message": "dispatch driver for refactor", "metadata": {"feature_id": "F6-split-tracking-code", "to_role": "driver", "phase": "refactor", "story": "S3-stock-shows-split-fields", "buildMode": "refactor"}} +{"timestamp": "2026-08-01T08:44:13.468Z", "level": "info", "role": "driver", "model": "sonnet", "event": "phase.start", "message": "driver START refactor", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "refactor", "story": "S3-stock-shows-split-fields", "buildMode": "refactor"}} +{"timestamp": "2026-08-01T08:46:24.665Z", "level": "info", "role": "driver", "event": "reasoning", "message": "REFACTOR: Updated client API types (StockRecord, SkuDetailEntry) to use batch_number + serial_number instead of inventory_code; FileStockPage, RetrieveStockPage, SkuDetailPage now render split fields with correct data-testids; updated all client component tests (T16, T17, T26, T28, T30, T32) to assert on new split-field seams. All 49 client tests + 2 architecture fitness tests pass.", "metadata": {"feature_id": "F6-split-tracking-code", "cycle_id": "cycle-001", "note": "REFACTOR: Updated client API types (StockRecord, SkuDetailEntry) to use batch_number + serial_number instead of inventory_code; FileStockPage, RetrieveStockPage, SkuDetailPage now render split fields with correct data-testids; updated all client component tests (T16, T17, T26, T28, T30, T32) to assert on new split-field seams. All 49 client tests + 2 architecture fitness tests pass."}} +{"timestamp": "2026-08-01T08:46:36.242Z", "level": "info", "role": "driver", "model": "haiku", "event": "turn.usage", "message": "driver turn used 119 input + 12701 output tokens", "metadata": {"feature_id": "F6-split-tracking-code", "duration_ms": 142773, "input_tokens": 119, "output_tokens": 12701, "cache_read_tokens": 1217478, "cache_creation_tokens": 65286, "cost_usd": 0.31594380000000005, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:48:15.404Z", "level": "info", "role": "driver", "event": "cycle.refactored", "message": "REFACTOR [S3-stock-shows-split-fields]: addressed: Client types and UI components still use the old `inventory_code` field instead of the split `batch_number` / `serial_number` fields that are the entire purpose of this story (NFR-F6-9 + the AC). `StockRecord` in client/src/api/stock.ts declares `inventory_code: string`, `SkuDetailEntry` likewise, `FileStockPage` posts `inventory_code`, and `RetrieveStockPage` renders `data-testid='stock-inventory-code'`. The backend correctly returns `batch_number` and `serial_number`; the client API contract and pages must be updated to match: replace `inventory_code` with `batch_number: string | null` and `serial_number: string | null` in all client interfaces, form fields (data-testid='field-batch-number', 'field-serial-number'), and display slots (data-testid='stock-batch-number', 'stock-serial-number'). No outer-boundary test assertions change; the E2E tests already assert on those testids.", "metadata": {"feature_id": "F6-split-tracking-code", "ac": "S3-stock-shows-split-fields", "change": "addressed: Client types and UI components still use the old `inventory_code` field instead of the split `batch_number` / `serial_number` fields that are the entire purpose of this story (NFR-F6-9 + the AC). `StockRecord` in client/src/api/stock.ts declares `inventory_code: string`, `SkuDetailEntry` likewise, `FileStockPage` posts `inventory_code`, and `RetrieveStockPage` renders `data-testid='stock-inventory-code'`. The backend correctly returns `batch_number` and `serial_number`; the client API contract and pages must be updated to match: replace `inventory_code` with `batch_number: string | null` and `serial_number: string | null` in all client interfaces, form fields (data-testid='field-batch-number', 'field-serial-number'), and display slots (data-testid='stock-batch-number', 'stock-serial-number'). No outer-boundary test assertions change; the E2E tests already assert on those testids.", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:48:19.208Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "deploy", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:48:19.208Z", "level": "info", "role": "orchestrator", "event": "gate.surfaced", "message": "GATE acceptance awaiting decision , story S3-stock-shows-split-fields", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "acceptance", "subject": "story S3-stock-shows-split-fields", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:48:19.508Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start story S3-stock-shows-split-fields -> local", "metadata": {"feature_id": "F6-split-tracking-code", "scope": "story S3-stock-shows-split-fields", "target": "local", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:49:46.215Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 99155)", "metadata": {"url": "http://localhost:8000/", "pid": 99155, "feature_id": "F6-split-tracking-code", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:49:46.217Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed story S3-stock-shows-split-fields (./scripts/run-tests.sh)", "metadata": {"scope": "story S3-stock-shows-split-fields", "command": "./scripts/run-tests.sh", "feature_id": "F6-split-tracking-code", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:49:46.219Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified story S3-stock-shows-split-fields @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F6-split-tracking-code", "scope": "story S3-stock-shows-split-fields", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:49:46.219Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "deploy", "outcome": "verified", "ok": true, "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:49:46.491Z", "level": "info", "role": "orchestrator", "event": "experiment.accepted", "message": "EXPERIMENT accepted (merged) for S3-stock-shows-split-fields", "metadata": {"feature_id": "F6-split-tracking-code", "story": "S3-stock-shows-split-fields"}} +{"timestamp": "2026-08-01T08:50:00.243Z", "level": "info", "role": "orchestrator", "event": "phase.end", "message": "orchestrator END feature (complete)", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "feature", "outcome": "complete"}} +{"timestamp": "2026-08-01T08:50:00.361Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START deploy", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "deploy"}} +{"timestamp": "2026-08-01T08:50:00.662Z", "level": "info", "role": "release-engineer", "event": "deploy.start", "message": "DEPLOY start feature F6-split-tracking-code -> local", "metadata": {"feature_id": "F6-split-tracking-code", "scope": "feature F6-split-tracking-code", "target": "local"}} +{"timestamp": "2026-08-01T08:51:29.794Z", "level": "info", "role": "release-engineer", "event": "deploy.reachable", "message": "DEPLOY reachable http://localhost:8000/ (pid 2264)", "metadata": {"url": "http://localhost:8000/", "pid": 2264, "feature_id": "F6-split-tracking-code"}} +{"timestamp": "2026-08-01T08:51:29.794Z", "level": "info", "role": "release-engineer", "event": "verify.passed", "message": "VERIFY passed feature F6-split-tracking-code (./scripts/run-tests.sh)", "metadata": {"scope": "feature F6-split-tracking-code", "command": "./scripts/run-tests.sh", "feature_id": "F6-split-tracking-code"}} +{"timestamp": "2026-08-01T08:51:29.795Z", "level": "info", "role": "release-engineer", "event": "deploy.verified", "message": "DEPLOY verified feature F6-split-tracking-code @ http://localhost:8000/ , verify passed", "metadata": {"feature_id": "F6-split-tracking-code", "scope": "feature F6-split-tracking-code", "url": "http://localhost:8000/", "verify_status": "passed", "target": "local", "reachable": true, "verify_passed": true}} +{"timestamp": "2026-08-01T08:51:29.795Z", "level": "info", "role": "release-engineer", "event": "phase.end", "message": "release-engineer END deploy (verified)", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "deploy", "outcome": "verified", "ok": true}} +{"timestamp": "2026-08-01T08:51:29.846Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE deploy APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "deploy"}} +{"timestamp": "2026-08-01T08:51:30.004Z", "level": "info", "role": "product-owner", "event": "gate.approved", "message": "GATE deploy APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "deploy", "artifacts": ["deploy-evidence.json"], "approver": "human-proxy", "validated": true}} +{"timestamp": "2026-08-01T08:51:30.042Z", "level": "info", "role": "release-engineer", "event": "phase.start", "message": "release-engineer START promote", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "promote"}} +{"timestamp": "2026-08-01T08:51:30.069Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: prepare-pr", "metadata": {"feature_id": "F6-split-tracking-code", "note": "orchestrator: prepare-pr"}} +{"timestamp": "2026-08-01T08:51:34.828Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: wait-ci", "metadata": {"feature_id": "F6-split-tracking-code", "note": "orchestrator: wait-ci"}} +{"timestamp": "2026-08-01T08:52:04.828Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: wait-ci", "metadata": {"feature_id": "F6-split-tracking-code", "note": "orchestrator: wait-ci"}} +{"timestamp": "2026-08-01T08:52:06.095Z", "level": "info", "role": "orchestrator", "event": "gate.approved", "message": "GATE promote APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "promote"}} +{"timestamp": "2026-08-01T08:52:06.240Z", "level": "info", "role": "product-owner", "event": "gate.approved", "message": "GATE promote APPROVED", "metadata": {"feature_id": "F6-split-tracking-code", "gate": "promote", "artifacts": ["promote_ref"], "approver": "human-proxy", "validated": true}} +{"timestamp": "2026-08-01T08:52:06.288Z", "level": "info", "role": "orchestrator", "event": "reasoning", "message": "orchestrator: merge", "metadata": {"feature_id": "F6-split-tracking-code", "note": "orchestrator: merge"}} +{"timestamp": "2026-08-01T08:53:14.838Z", "level": "info", "role": "orchestrator", "event": "phase.end", "message": "orchestrator END workflow (complete)", "metadata": {"feature_id": "F6-split-tracking-code", "phase": "workflow", "outcome": "complete"}} diff --git a/apps/dashboard/lib/__fixtures__/stockflow-rerecord-provenance.json b/apps/dashboard/lib/__fixtures__/stockflow-rerecord-provenance.json new file mode 100644 index 00000000..44975493 --- /dev/null +++ b/apps/dashboard/lib/__fixtures__/stockflow-rerecord-provenance.json @@ -0,0 +1,9 @@ +{ + "scenario": "stockflow-rerecord", + "captured_at": "2026-08-01T02:50:33.000Z", + "kit_ref": "sftdd-capture-local", + "kit_commit": "cad5f5fb5eb7e59a703722284b6a5858ddf3fff0", + "kit_describe": "v0.3.6", + "agent_log": "agent-log.jsonl", + "notes": "Provenance for the stockflow-rerecord corpus + full-run agent-log.jsonl. kit_ref is the LOCAL dev ref capture-scenario.sh pins (CAPTURE_KIT_REF, a cache symlink to the working tree at capture time), not a published version. kit_commit/kit_describe are the real version anchor: the kit commit the recorded artifacts + log reflect (the v0.3.6 corpus commit). The same kit_ref/kit_commit/kit_describe are stamped on metadata of the first event in agent-log.jsonl." +} diff --git a/apps/dashboard/lib/__fixtures__/stockflow-rerecord-turns-index.json b/apps/dashboard/lib/__fixtures__/stockflow-rerecord-turns-index.json new file mode 100644 index 00000000..06c99bc2 --- /dev/null +++ b/apps/dashboard/lib/__fixtures__/stockflow-rerecord-turns-index.json @@ -0,0 +1,1423 @@ +{ + "turns": [ + { + "ordinal": 0, + "step": 0, + "label": "spec-author-propose", + "kind": "invoke-role", + "role": "spec-author", + "mode": "propose", + "dir": "0000-spec-author-propose", + "producedCount": 1, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 1, + "step": 0, + "label": "architect-reviewer-estimate", + "kind": "invoke-role", + "role": "architect-reviewer", + "mode": "estimate", + "dir": "0001-architect-reviewer-estimate", + "producedCount": 1, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 2, + "step": 0, + "label": "product-owner-author-requests", + "kind": "invoke-role", + "role": "product-owner", + "mode": "author-requests", + "dir": "0002-product-owner-author-requests", + "producedCount": 3, + "deletedCount": 0 + }, + { + "ordinal": 3, + "step": 0, + "label": "gate-plan", + "kind": "approve-plan-gate", + "dir": "0003-gate-plan", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 4, + "step": 0, + "label": "spec-author-breakdown", + "kind": "invoke-role", + "role": "spec-author", + "mode": "breakdown", + "dir": "0004-spec-author-breakdown", + "producedCount": 11, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 5, + "step": 0, + "label": "ux-designer", + "kind": "invoke-role", + "role": "ux-designer", + "dir": "0005-ux-designer", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 6, + "step": 0, + "label": "spec-author", + "kind": "invoke-role", + "role": "spec-author", + "story": "S1-file-stock", + "dir": "0006-spec-author", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 7, + "step": 0, + "label": "architect-reviewer", + "kind": "invoke-role", + "role": "architect-reviewer", + "story": "S1-file-stock", + "dir": "0007-architect-reviewer", + "producedCount": 7, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 8, + "step": 0, + "label": "dba", + "kind": "invoke-role", + "role": "dba", + "story": "S1-file-stock", + "dir": "0008-dba", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 9, + "step": 0, + "label": "test-strategist", + "kind": "invoke-role", + "role": "test-strategist", + "story": "S1-file-stock", + "dir": "0009-test-strategist", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 10, + "step": 0, + "label": "navigator-reflect", + "kind": "invoke-role", + "role": "navigator", + "mode": "reflect", + "story": "S1-file-stock", + "dir": "0010-navigator-reflect", + "producedCount": 1, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 11, + "step": 0, + "label": "gate-surface", + "kind": "surface-gate", + "story": "S1-file-stock", + "dir": "0011-gate-surface", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 12, + "step": 0, + "label": "gate-spec", + "kind": "approve-gate", + "story": "S1-file-stock", + "dir": "0012-gate-spec", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 13, + "step": 0, + "label": "dispatch", + "kind": "dispatch", + "story": "S1-file-stock", + "dir": "0013-dispatch", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 14, + "step": 0, + "label": "cut-experiment", + "kind": "cut-experiment", + "story": "S1-file-stock", + "dir": "0014-cut-experiment", + "producedCount": 5, + "deletedCount": 0 + }, + { + "ordinal": 15, + "step": 0, + "label": "navigator", + "kind": "invoke-role", + "role": "navigator", + "story": "S1-file-stock", + "dir": "0015-navigator", + "producedCount": 9, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 16, + "step": 0, + "label": "driver", + "kind": "invoke-role", + "role": "driver", + "story": "S1-file-stock", + "dir": "0016-driver", + "producedCount": 12, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 17, + "step": 0, + "label": "navigator-assess", + "kind": "invoke-role", + "role": "navigator", + "mode": "assess", + "story": "S1-file-stock", + "ac": "AC1-file-stock-record", + "dir": "0017-navigator-assess", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 18, + "step": 0, + "label": "driver-repair", + "kind": "invoke-role", + "role": "driver", + "mode": "repair", + "story": "S1-file-stock", + "ac": "AC1-file-stock-record", + "dir": "0018-driver-repair", + "producedCount": 13, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 19, + "step": 0, + "label": "navigator-review", + "kind": "invoke-role", + "role": "navigator", + "mode": "review", + "story": "S1-file-stock", + "dir": "0019-navigator-review", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 20, + "step": 0, + "label": "await-acceptance", + "kind": "await-acceptance", + "story": "S1-file-stock", + "dir": "0020-await-acceptance", + "producedCount": 3, + "deletedCount": 0 + }, + { + "ordinal": 21, + "step": 0, + "label": "accept", + "kind": "accept", + "story": "S1-file-stock", + "dir": "0021-accept", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 22, + "step": 0, + "label": "spec-author", + "kind": "invoke-role", + "role": "spec-author", + "story": "S2-stock-by-location-table", + "dir": "0022-spec-author", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 23, + "step": 0, + "label": "architect-reviewer", + "kind": "invoke-role", + "role": "architect-reviewer", + "story": "S2-stock-by-location-table", + "dir": "0023-architect-reviewer", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 24, + "step": 0, + "label": "test-strategist", + "kind": "invoke-role", + "role": "test-strategist", + "story": "S2-stock-by-location-table", + "dir": "0024-test-strategist", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 25, + "step": 0, + "label": "navigator-reflect", + "kind": "invoke-role", + "role": "navigator", + "mode": "reflect", + "story": "S2-stock-by-location-table", + "dir": "0025-navigator-reflect", + "producedCount": 1, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 26, + "step": 0, + "label": "gate-surface", + "kind": "surface-gate", + "story": "S2-stock-by-location-table", + "dir": "0026-gate-surface", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 27, + "step": 0, + "label": "gate-spec", + "kind": "approve-gate", + "story": "S2-stock-by-location-table", + "dir": "0027-gate-spec", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 28, + "step": 0, + "label": "dispatch", + "kind": "dispatch", + "story": "S2-stock-by-location-table", + "dir": "0028-dispatch", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 29, + "step": 0, + "label": "cut-experiment", + "kind": "cut-experiment", + "story": "S2-stock-by-location-table", + "dir": "0029-cut-experiment", + "producedCount": 5, + "deletedCount": 0 + }, + { + "ordinal": 30, + "step": 0, + "label": "navigator", + "kind": "invoke-role", + "role": "navigator", + "story": "S2-stock-by-location-table", + "dir": "0030-navigator", + "producedCount": 5, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 31, + "step": 0, + "label": "driver", + "kind": "invoke-role", + "role": "driver", + "story": "S2-stock-by-location-table", + "dir": "0031-driver", + "producedCount": 5, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 32, + "step": 0, + "label": "navigator-assess", + "kind": "invoke-role", + "role": "navigator", + "mode": "assess", + "story": "S2-stock-by-location-table", + "ac": "AC1-table-lists-stock-by-location", + "dir": "0032-navigator-assess", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 33, + "step": 0, + "label": "driver-repair", + "kind": "invoke-role", + "role": "driver", + "mode": "repair", + "story": "S2-stock-by-location-table", + "ac": "AC1-table-lists-stock-by-location", + "dir": "0033-driver-repair", + "producedCount": 11, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 34, + "step": 0, + "label": "navigator-review", + "kind": "invoke-role", + "role": "navigator", + "mode": "review", + "story": "S2-stock-by-location-table", + "dir": "0034-navigator-review", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 35, + "step": 0, + "label": "await-acceptance", + "kind": "await-acceptance", + "story": "S2-stock-by-location-table", + "dir": "0035-await-acceptance", + "producedCount": 3, + "deletedCount": 0 + }, + { + "ordinal": 36, + "step": 0, + "label": "accept", + "kind": "accept", + "story": "S2-stock-by-location-table", + "dir": "0036-accept", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 37, + "step": 0, + "label": "spec-author", + "kind": "invoke-role", + "role": "spec-author", + "story": "S3-sku-detail-view", + "dir": "0037-spec-author", + "producedCount": 5, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 38, + "step": 0, + "label": "architect-reviewer", + "kind": "invoke-role", + "role": "architect-reviewer", + "story": "S3-sku-detail-view", + "dir": "0038-architect-reviewer", + "producedCount": 5, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 39, + "step": 0, + "label": "test-strategist", + "kind": "invoke-role", + "role": "test-strategist", + "story": "S3-sku-detail-view", + "dir": "0039-test-strategist", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 40, + "step": 0, + "label": "navigator-reflect", + "kind": "invoke-role", + "role": "navigator", + "mode": "reflect", + "story": "S3-sku-detail-view", + "dir": "0040-navigator-reflect", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 41, + "step": 0, + "label": "revise-route", + "kind": "revise-route", + "role": "spec-author", + "story": "S3-sku-detail-view", + "dir": "0041-revise-route", + "producedCount": 6, + "deletedCount": 7 + }, + { + "ordinal": 42, + "step": 0, + "label": "spec-author", + "kind": "invoke-role", + "role": "spec-author", + "story": "S3-sku-detail-view", + "dir": "0042-spec-author", + "producedCount": 4, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 43, + "step": 0, + "label": "architect-reviewer", + "kind": "invoke-role", + "role": "architect-reviewer", + "story": "S3-sku-detail-view", + "dir": "0043-architect-reviewer", + "producedCount": 4, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 44, + "step": 0, + "label": "test-strategist", + "kind": "invoke-role", + "role": "test-strategist", + "story": "S3-sku-detail-view", + "dir": "0044-test-strategist", + "producedCount": 2, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 45, + "step": 0, + "label": "navigator-reflect", + "kind": "invoke-role", + "role": "navigator", + "mode": "reflect", + "story": "S3-sku-detail-view", + "dir": "0045-navigator-reflect", + "producedCount": 1, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 46, + "step": 0, + "label": "gate-surface", + "kind": "surface-gate", + "story": "S3-sku-detail-view", + "dir": "0046-gate-surface", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 47, + "step": 0, + "label": "gate-spec", + "kind": "approve-gate", + "story": "S3-sku-detail-view", + "dir": "0047-gate-spec", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 48, + "step": 0, + "label": "dispatch", + "kind": "dispatch", + "story": "S3-sku-detail-view", + "dir": "0048-dispatch", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 49, + "step": 0, + "label": "cut-experiment", + "kind": "cut-experiment", + "story": "S3-sku-detail-view", + "dir": "0049-cut-experiment", + "producedCount": 5, + "deletedCount": 0 + }, + { + "ordinal": 50, + "step": 0, + "label": "navigator", + "kind": "invoke-role", + "role": "navigator", + "story": "S3-sku-detail-view", + "dir": "0050-navigator", + "producedCount": 5, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 51, + "step": 0, + "label": "driver", + "kind": "invoke-role", + "role": "driver", + "story": "S3-sku-detail-view", + "dir": "0051-driver", + "producedCount": 5, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 52, + "step": 0, + "label": "navigator-assess", + "kind": "invoke-role", + "role": "navigator", + "mode": "assess", + "story": "S3-sku-detail-view", + "ac": "AC1-lists-stock-across-locations", + "dir": "0052-navigator-assess", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 53, + "step": 0, + "label": "driver-repair", + "kind": "invoke-role", + "role": "driver", + "mode": "repair", + "story": "S3-sku-detail-view", + "ac": "AC1-lists-stock-across-locations", + "dir": "0053-driver-repair", + "producedCount": 15, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 54, + "step": 0, + "label": "navigator-review", + "kind": "invoke-role", + "role": "navigator", + "mode": "review", + "story": "S3-sku-detail-view", + "dir": "0054-navigator-review", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 55, + "step": 0, + "label": "driver-refactor", + "kind": "invoke-role", + "role": "driver", + "mode": "refactor", + "story": "S3-sku-detail-view", + "dir": "0055-driver-refactor", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 56, + "step": 0, + "label": "await-acceptance", + "kind": "await-acceptance", + "story": "S3-sku-detail-view", + "dir": "0056-await-acceptance", + "producedCount": 3, + "deletedCount": 0 + }, + { + "ordinal": 57, + "step": 0, + "label": "accept", + "kind": "accept", + "story": "S3-sku-detail-view", + "dir": "0057-accept", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 58, + "step": 0, + "label": "feature-complete", + "kind": "feature-complete", + "dir": "0058-feature-complete", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 59, + "step": 0, + "label": "deploy", + "kind": "deploy", + "dir": "0059-deploy", + "producedCount": 2, + "deletedCount": 0 + }, + { + "ordinal": 60, + "step": 0, + "label": "gate-deploy", + "kind": "approve-deploy-gate", + "dir": "0060-gate-deploy", + "producedCount": 2, + "deletedCount": 0 + }, + { + "ordinal": 61, + "step": 0, + "label": "deploy-complete", + "kind": "deploy-complete", + "dir": "0061-deploy-complete", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 62, + "step": 0, + "label": "prepare-pr", + "kind": "prepare-pr", + "dir": "0062-prepare-pr", + "producedCount": 0, + "deletedCount": 0 + }, + { + "ordinal": 63, + "step": 0, + "label": "wait-ci", + "kind": "wait-ci", + "dir": "0063-wait-ci", + "producedCount": 0, + "deletedCount": 0 + }, + { + "ordinal": 64, + "step": 0, + "label": "gate-promote", + "kind": "approve-promote-gate", + "dir": "0064-gate-promote", + "producedCount": 2, + "deletedCount": 0 + }, + { + "ordinal": 65, + "step": 0, + "label": "merge", + "kind": "merge", + "dir": "0065-merge", + "producedCount": 0, + "deletedCount": 0 + }, + { + "ordinal": 66, + "step": 0, + "label": "product-owner-author-requests", + "kind": "invoke-role", + "role": "product-owner", + "mode": "author-requests", + "dir": "0066-product-owner-author-requests", + "producedCount": 7, + "deletedCount": 0 + }, + { + "ordinal": 67, + "step": 0, + "label": "gate-plan", + "kind": "approve-plan-gate", + "dir": "0067-gate-plan", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 68, + "step": 0, + "label": "spec-author-breakdown", + "kind": "invoke-role", + "role": "spec-author", + "mode": "breakdown", + "dir": "0068-spec-author-breakdown", + "producedCount": 11, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 69, + "step": 0, + "label": "spec-author", + "kind": "invoke-role", + "role": "spec-author", + "story": "S1-split-columns-migration", + "dir": "0069-spec-author", + "producedCount": 8, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 70, + "step": 0, + "label": "architect-reviewer", + "kind": "invoke-role", + "role": "architect-reviewer", + "story": "S1-split-columns-migration", + "dir": "0070-architect-reviewer", + "producedCount": 10, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 71, + "step": 0, + "label": "dba", + "kind": "invoke-role", + "role": "dba", + "story": "S1-split-columns-migration", + "dir": "0071-dba", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 72, + "step": 0, + "label": "test-strategist", + "kind": "invoke-role", + "role": "test-strategist", + "story": "S1-split-columns-migration", + "dir": "0072-test-strategist", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 73, + "step": 0, + "label": "navigator-reflect", + "kind": "invoke-role", + "role": "navigator", + "mode": "reflect", + "story": "S1-split-columns-migration", + "dir": "0073-navigator-reflect", + "producedCount": 1, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 74, + "step": 0, + "label": "gate-surface", + "kind": "surface-gate", + "story": "S1-split-columns-migration", + "dir": "0074-gate-surface", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 75, + "step": 0, + "label": "gate-spec", + "kind": "approve-gate", + "story": "S1-split-columns-migration", + "dir": "0075-gate-spec", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 76, + "step": 0, + "label": "dispatch", + "kind": "dispatch", + "story": "S1-split-columns-migration", + "dir": "0076-dispatch", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 77, + "step": 0, + "label": "cut-experiment", + "kind": "cut-experiment", + "story": "S1-split-columns-migration", + "dir": "0077-cut-experiment", + "producedCount": 5, + "deletedCount": 0 + }, + { + "ordinal": 78, + "step": 0, + "label": "navigator", + "kind": "invoke-role", + "role": "navigator", + "story": "S1-split-columns-migration", + "dir": "0078-navigator", + "producedCount": 4, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 79, + "step": 0, + "label": "driver", + "kind": "invoke-role", + "role": "driver", + "story": "S1-split-columns-migration", + "dir": "0079-driver", + "producedCount": 7, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 80, + "step": 0, + "label": "navigator-assess", + "kind": "invoke-role", + "role": "navigator", + "mode": "assess", + "story": "S1-split-columns-migration", + "ac": "AC1-batch-serial-columns-added", + "dir": "0080-navigator-assess", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 81, + "step": 0, + "label": "driver-green-superseded", + "kind": "invoke-role", + "role": "driver", + "story": "S1-split-columns-migration", + "dir": "0081-driver-green-superseded", + "producedCount": 21, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 82, + "step": 0, + "label": "navigator-review", + "kind": "invoke-role", + "role": "navigator", + "mode": "review", + "story": "S1-split-columns-migration", + "dir": "0082-navigator-review", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 83, + "step": 0, + "label": "await-acceptance", + "kind": "await-acceptance", + "story": "S1-split-columns-migration", + "dir": "0083-await-acceptance", + "producedCount": 3, + "deletedCount": 0 + }, + { + "ordinal": 84, + "step": 0, + "label": "accept", + "kind": "accept", + "story": "S1-split-columns-migration", + "dir": "0084-accept", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 85, + "step": 0, + "label": "spec-author", + "kind": "invoke-role", + "role": "spec-author", + "story": "S2-reversible-down-migration", + "dir": "0085-spec-author", + "producedCount": 5, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 86, + "step": 0, + "label": "architect-reviewer", + "kind": "invoke-role", + "role": "architect-reviewer", + "story": "S2-reversible-down-migration", + "dir": "0086-architect-reviewer", + "producedCount": 6, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 87, + "step": 0, + "label": "test-strategist", + "kind": "invoke-role", + "role": "test-strategist", + "story": "S2-reversible-down-migration", + "dir": "0087-test-strategist", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 88, + "step": 0, + "label": "navigator-reflect", + "kind": "invoke-role", + "role": "navigator", + "mode": "reflect", + "story": "S2-reversible-down-migration", + "dir": "0088-navigator-reflect", + "producedCount": 1, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 89, + "step": 0, + "label": "gate-surface", + "kind": "surface-gate", + "story": "S2-reversible-down-migration", + "dir": "0089-gate-surface", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 90, + "step": 0, + "label": "gate-spec", + "kind": "approve-gate", + "story": "S2-reversible-down-migration", + "dir": "0090-gate-spec", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 91, + "step": 0, + "label": "dispatch", + "kind": "dispatch", + "story": "S2-reversible-down-migration", + "dir": "0091-dispatch", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 92, + "step": 0, + "label": "cut-experiment", + "kind": "cut-experiment", + "story": "S2-reversible-down-migration", + "dir": "0092-cut-experiment", + "producedCount": 5, + "deletedCount": 0 + }, + { + "ordinal": 93, + "step": 0, + "label": "navigator", + "kind": "invoke-role", + "role": "navigator", + "story": "S2-reversible-down-migration", + "dir": "0093-navigator", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 94, + "step": 0, + "label": "driver", + "kind": "invoke-role", + "role": "driver", + "story": "S2-reversible-down-migration", + "dir": "0094-driver", + "producedCount": 11, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 95, + "step": 0, + "label": "navigator-review", + "kind": "invoke-role", + "role": "navigator", + "mode": "review", + "story": "S2-reversible-down-migration", + "dir": "0095-navigator-review", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 96, + "step": 0, + "label": "await-acceptance", + "kind": "await-acceptance", + "story": "S2-reversible-down-migration", + "dir": "0096-await-acceptance", + "producedCount": 3, + "deletedCount": 0 + }, + { + "ordinal": 97, + "step": 0, + "label": "accept", + "kind": "accept", + "story": "S2-reversible-down-migration", + "dir": "0097-accept", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 98, + "step": 0, + "label": "architect-reviewer", + "kind": "invoke-role", + "role": "architect-reviewer", + "story": "S3-stock-shows-split-fields", + "dir": "0098-architect-reviewer", + "producedCount": 6, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 99, + "step": 0, + "label": "test-strategist", + "kind": "invoke-role", + "role": "test-strategist", + "story": "S3-stock-shows-split-fields", + "dir": "0099-test-strategist", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 100, + "step": 0, + "label": "navigator-reflect", + "kind": "invoke-role", + "role": "navigator", + "mode": "reflect", + "story": "S3-stock-shows-split-fields", + "dir": "0100-navigator-reflect", + "producedCount": 1, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 101, + "step": 0, + "label": "gate-surface", + "kind": "surface-gate", + "story": "S3-stock-shows-split-fields", + "dir": "0101-gate-surface", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 102, + "step": 0, + "label": "gate-spec", + "kind": "approve-gate", + "story": "S3-stock-shows-split-fields", + "dir": "0102-gate-spec", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 103, + "step": 0, + "label": "dispatch", + "kind": "dispatch", + "story": "S3-stock-shows-split-fields", + "dir": "0103-dispatch", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 104, + "step": 0, + "label": "cut-experiment", + "kind": "cut-experiment", + "story": "S3-stock-shows-split-fields", + "dir": "0104-cut-experiment", + "producedCount": 5, + "deletedCount": 0 + }, + { + "ordinal": 105, + "step": 0, + "label": "navigator", + "kind": "invoke-role", + "role": "navigator", + "story": "S3-stock-shows-split-fields", + "dir": "0105-navigator", + "producedCount": 4, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 106, + "step": 0, + "label": "driver", + "kind": "invoke-role", + "role": "driver", + "story": "S3-stock-shows-split-fields", + "dir": "0106-driver", + "producedCount": 2, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 107, + "step": 0, + "label": "navigator-assess", + "kind": "invoke-role", + "role": "navigator", + "mode": "assess", + "story": "S3-stock-shows-split-fields", + "ac": "AC1-split-fields-shown", + "dir": "0107-navigator-assess", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 108, + "step": 0, + "label": "driver-green-superseded", + "kind": "invoke-role", + "role": "driver", + "story": "S3-stock-shows-split-fields", + "dir": "0108-driver-green-superseded", + "producedCount": 2, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 109, + "step": 0, + "label": "architect-reviewer-estimate-committed", + "kind": "invoke-role", + "role": "architect-reviewer", + "mode": "estimate-committed", + "dir": "0109-architect-reviewer-estimate-committed", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 110, + "step": 0, + "label": "navigator-assess", + "kind": "invoke-role", + "role": "navigator", + "mode": "assess", + "story": "S3-stock-shows-split-fields", + "ac": "AC1-split-fields-shown", + "dir": "0110-navigator-assess", + "producedCount": 3, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 111, + "step": 0, + "label": "driver-repair", + "kind": "invoke-role", + "role": "driver", + "mode": "repair", + "story": "S3-stock-shows-split-fields", + "ac": "AC1-split-fields-shown", + "dir": "0111-driver-repair", + "producedCount": 3, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 112, + "step": 0, + "label": "navigator-assess", + "kind": "invoke-role", + "role": "navigator", + "mode": "assess", + "story": "S3-stock-shows-split-fields", + "ac": "AC1-split-fields-shown", + "dir": "0112-navigator-assess", + "producedCount": 5, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 113, + "step": 0, + "label": "driver-green-superseded", + "kind": "invoke-role", + "role": "driver", + "story": "S3-stock-shows-split-fields", + "dir": "0113-driver-green-superseded", + "producedCount": 12, + "deletedCount": 1, + "hasTranscript": true + }, + { + "ordinal": 114, + "step": 0, + "label": "navigator-review", + "kind": "invoke-role", + "role": "navigator", + "mode": "review", + "story": "S3-stock-shows-split-fields", + "dir": "0114-navigator-review", + "producedCount": 2, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 115, + "step": 0, + "label": "driver-refactor", + "kind": "invoke-role", + "role": "driver", + "mode": "refactor", + "story": "S3-stock-shows-split-fields", + "dir": "0115-driver-refactor", + "producedCount": 8, + "deletedCount": 0, + "hasTranscript": true + }, + { + "ordinal": 116, + "step": 0, + "label": "await-acceptance", + "kind": "await-acceptance", + "story": "S3-stock-shows-split-fields", + "dir": "0116-await-acceptance", + "producedCount": 3, + "deletedCount": 0 + }, + { + "ordinal": 117, + "step": 0, + "label": "accept", + "kind": "accept", + "story": "S3-stock-shows-split-fields", + "dir": "0117-accept", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 118, + "step": 0, + "label": "feature-complete", + "kind": "feature-complete", + "dir": "0118-feature-complete", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 119, + "step": 0, + "label": "deploy", + "kind": "deploy", + "dir": "0119-deploy", + "producedCount": 2, + "deletedCount": 0 + }, + { + "ordinal": 120, + "step": 0, + "label": "gate-deploy", + "kind": "approve-deploy-gate", + "dir": "0120-gate-deploy", + "producedCount": 2, + "deletedCount": 0 + }, + { + "ordinal": 121, + "step": 0, + "label": "deploy-complete", + "kind": "deploy-complete", + "dir": "0121-deploy-complete", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 122, + "step": 0, + "label": "prepare-pr", + "kind": "prepare-pr", + "dir": "0122-prepare-pr", + "producedCount": 0, + "deletedCount": 0 + }, + { + "ordinal": 123, + "step": 0, + "label": "wait-ci", + "kind": "wait-ci", + "dir": "0123-wait-ci", + "producedCount": 1, + "deletedCount": 0 + }, + { + "ordinal": 124, + "step": 0, + "label": "gate-promote", + "kind": "approve-promote-gate", + "dir": "0124-gate-promote", + "producedCount": 2, + "deletedCount": 0 + }, + { + "ordinal": 125, + "step": 0, + "label": "merge", + "kind": "merge", + "dir": "0125-merge", + "producedCount": 0, + "deletedCount": 0 + } + ] +} diff --git a/apps/dashboard/lib/artifact-head.test.ts b/apps/dashboard/lib/artifact-head.test.ts new file mode 100644 index 00000000..2a3be900 --- /dev/null +++ b/apps/dashboard/lib/artifact-head.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readArtifactAtHead } from "./consort"; + +// readArtifactAtHead is the live half of the turn drill-down: an artifact.written path, read at +// HEAD, through the shared containment + text/size guards. CONSORT_PROJECT_DIR points it at a +// temp project so the whole reader runs against real files on disk. + +describe("readArtifactAtHead", () => { + let proj: string; + let prevDir: string | undefined; + + beforeEach(() => { + proj = mkdtempSync(join(tmpdir(), "artifact-head-")); + mkdirSync(join(proj, ".sftdd", "features", "F1"), { recursive: true }); + writeFileSync(join(proj, ".sftdd", "features", "F1", "feature-spec.json"), '{\n "name": "F1"\n}\n'); + mkdirSync(join(proj, ".sftdd", "design"), { recursive: true }); + writeFileSync(join(proj, ".sftdd", "design", "ia.md"), "# IA\n"); + prevDir = process.env.CONSORT_PROJECT_DIR; + process.env.CONSORT_PROJECT_DIR = proj; + }); + + afterEach(() => { + if (prevDir === undefined) delete process.env.CONSORT_PROJECT_DIR; + else process.env.CONSORT_PROJECT_DIR = prevDir; + rmSync(proj, { recursive: true, force: true }); + }); + + it("reads a present artifact at HEAD, classified", () => { + const r = readArtifactAtHead("features/F1/feature-spec.json"); + expect(r.path).toBe("features/F1/feature-spec.json"); + expect(r.content).toBe('{\n "name": "F1"\n}\n'); + expect(r.reason).toBeNull(); + // Under .sftdd/ → an artifact, not code. + expect(r.kind).toBe("artifact"); + }); + + it("classifies as-resolved (under .sftdd/), agreeing with replay on interior code paths", () => { + // Review finding: an .sftdd/-interior path that hits a code dir/ext (scripts/, tests/, app/, + // *.py) classified "code" in live but "artifact" in replay, because live passed the bare rel + // and skipped classify's `.sftdd/` → artifact rule. readArtifactAtHead now classifies the + // resolved `.sftdd/`-prefixed path, so both agree — and everything under .sftdd/ is bookkeeping. + mkdirSync(join(proj, ".sftdd", "scripts"), { recursive: true }); + writeFileSync(join(proj, ".sftdd", "scripts", "run.py"), "print(1)\n"); + const r = readArtifactAtHead("scripts/run.py"); + expect(r.content).toBe("print(1)\n"); + expect(r.kind).toBe("artifact"); // not "code", despite scripts/ + .py + }); + + it("reports a HEAD-specific reason when the file no longer exists", () => { + const r = readArtifactAtHead("features/F1/deleted-since.json"); + expect(r.content).toBeNull(); + expect(r.reason).toBe("(no longer present at HEAD)"); + }); + + it("refuses to traverse or follow a symlink out of .sftdd", () => { + const trav = readArtifactAtHead("../".repeat(20) + "etc/passwd"); + expect(trav.content).toBeNull(); + expect(trav.reason).toBe("(no longer present at HEAD)"); // containment miss, HEAD wording + + symlinkSync("/etc/passwd", join(proj, ".sftdd", "leak.md")); + const link = readArtifactAtHead("leak.md"); + expect(link.content).toBeNull(); + expect(link.reason).toBe("(no longer present at HEAD)"); + }); + + it("names the binary/size guard reasons rather than blanking", () => { + // A non-text extension is refused with its own reason. + writeFileSync(join(proj, ".sftdd", "img.png"), "\x89PNG"); + const bin = readArtifactAtHead("img.png"); + expect(bin.content).toBeNull(); + expect(bin.reason).toContain("binary/non-text"); + }); +}); diff --git a/apps/dashboard/lib/consort.test.ts b/apps/dashboard/lib/consort.test.ts new file mode 100644 index 00000000..1ddf47a8 --- /dev/null +++ b/apps/dashboard/lib/consort.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, it, expect } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + isSafeSegment, + resolverFor, + storyStage, + designComplete, + findPendingGate, + computeDesignPhases, + reduceAgents, + consortDir, + sftddDir, +} from "./consort"; +import type { AgentLogEvent } from "./types"; + +// Minimal event builder — only the fields the pure functions read. +function ev(event: string, metadata: Record = {}, extra: Partial = {}): AgentLogEvent { + return { + timestamp: extra.timestamp ?? "2026-07-31T20:00:00.000Z", + level: "info", + role: extra.role ?? "orchestrator", + event, + message: extra.message ?? "", + metadata, + }; +} + +describe("isSafeSegment (path-traversal guard)", () => { + it("accepts real feature ids", () => { + expect(isSafeSegment("F1-stock-visibility")).toBe(true); + expect(isSafeSegment("F6-split-tracking-code")).toBe(true); + expect(isSafeSegment("feature_1.v2")).toBe(true); + }); + it("rejects traversal and separators", () => { + expect(isSafeSegment("../../etc")).toBe(false); + expect(isSafeSegment("a/b")).toBe(false); + expect(isSafeSegment("/abs")).toBe(false); + expect(isSafeSegment("..")).toBe(false); + expect(isSafeSegment(".hidden")).toBe(false); // leading dot + expect(isSafeSegment("")).toBe(false); + }); +}); + +describe("resolverFor (blocker source → resolver role)", () => { + it("routes build-lane escalations to the driver", () => { + expect(resolverFor("driver-refactor")).toBe("driver"); + expect(resolverFor("driver-green")).toBe("driver"); + }); + it("routes by keyword", () => { + expect(resolverFor("navigator-review")).toBe("navigator"); + expect(resolverFor("test-list-gap")).toBe("test-strategist"); + expect(resolverFor("schema-migration")).toBe("dba"); + expect(resolverFor("spec-mismatch")).toBe("spec-author"); + }); + it("returns null for an unknown source", () => { + expect(resolverFor("mystery-source")).toBeNull(); + }); +}); + +describe("storyStage / designComplete", () => { + it("buckets raw Consort statuses", () => { + expect(storyStage("designing")).toBe("design"); + expect(storyStage("awaiting-gate")).toBe("design"); + expect(storyStage("ready")).toBe("design"); + expect(storyStage("building")).toBe("build"); + // build-green-but-unaccepted stays in build, NOT done (the acceptance gate is the HITL beat) + expect(storyStage("awaiting-acceptance")).toBe("build"); + expect(storyStage("done")).toBe("done"); + expect(storyStage("discarded")).toBe("done"); + }); + it("treats ready-or-later as design-complete", () => { + expect(designComplete("designing")).toBe(false); + expect(designComplete("awaiting-gate")).toBe(false); + expect(designComplete("ready")).toBe(true); + expect(designComplete("building")).toBe(true); + expect(designComplete("done")).toBe(true); + }); +}); + +describe("findPendingGate", () => { + it("returns null when there is no gate/escalation", () => { + expect(findPendingGate([ev("phase.start", { phase: "design" })])).toBeNull(); + }); + + it("flags a trailing escalation as pending (the live case we hit this session)", () => { + const g = findPendingGate([ + ev("phase.start", { phase: "design" }), + ev("escalation.raised", { story: "S2-stock-home-screen" }, { message: "GREEN verify FAILED" }), + ]); + expect(g).not.toBeNull(); + expect(g!.variety).toBe("escalation"); + expect(g!.story).toBe("S2-stock-home-screen"); + }); + + it("treats a gate.surfaced with a following resume event as NOT pending", () => { + // ANY event after the surface means the run resumed — this is exactly what cleared the + // banner when the drive picked back up. + const g = findPendingGate([ + ev("gate.surfaced", { gate: "acceptance" }), + ev("phase.start", { phase: "design" }), + ]); + expect(g).toBeNull(); + }); + + it("distinguishes a design gate from an escalation", () => { + const gate = findPendingGate([ev("gate.surfaced", { gate: "spec" })]); + expect(gate!.variety).toBe("gate"); + expect(gate!.gate).toBe("spec"); + }); + + it("uses the LAST surface when several exist", () => { + const g = findPendingGate([ + ev("gate.surfaced", { gate: "spec" }), + ev("escalation.raised", { story: "S2" }, { message: "boom" }), + ]); + expect(g!.variety).toBe("escalation"); + }); +}); + +describe("computeDesignPhases", () => { + it("marks all phases complete once the lane is build", () => { + const phases = computeDesignPhases([ev("phase.start", { phase: "design" })], "build"); + expect(phases.every((p) => p.status === "complete")).toBe(true); + expect(phases.some((p) => p.current)).toBe(false); + }); + + it("marks earlier phases complete and the newest in-progress", () => { + const phases = computeDesignPhases( + [ + ev("phase.start", { phase: "propose" }), + ev("phase.start", { phase: "estimate" }), + ev("phase.start", { phase: "breakdown" }), + ], + "design", + ); + const byName = Object.fromEntries(phases.map((p) => [p.name, p])); + expect(byName.propose.status).toBe("complete"); + expect(byName.estimate.status).toBe("complete"); + expect(byName.breakdown.status).toBe("in-progress"); + expect(byName.breakdown.current).toBe(true); + expect(byName.design.status).toBe("not-started"); + }); + + it("flags the design⇄reflect loop once reflect has been seen", () => { + const phases = computeDesignPhases( + [ + ev("phase.start", { phase: "design" }), + ev("phase.start", { phase: "reflect" }), + ev("phase.start", { phase: "design" }), + ], + "design", + ); + const byName = Object.fromEntries(phases.map((p) => [p.name, p])); + expect(byName.design.looping).toBe(true); + expect(byName.reflect.looping).toBe(true); + expect(byName.propose.looping).toBe(false); + }); + + it("ignores unknown phase values in metadata", () => { + const phases = computeDesignPhases([ev("phase.start", { phase: "not-a-phase" })], "design"); + expect(phases.every((p) => p.status === "not-started")).toBe(true); + }); +}); + +describe("reduceAgents — issue state clears when the run moves on", () => { + const orch = (event: string, md: Record = {}, msg = "") => + ev(event, md, { role: "orchestrator", message: msg }); + const get = (events: AgentLogEvent[], role: string) => + reduceAgents(events).agents.find((a) => a.role === role)!; + + // NOTE: reduceAgents leaves status "idle" with issues[] populated; the idle→"issue" flip + // happens later in buildState. So these assert on issues[] — the thing this fix changed. + it("keeps a trailing escalation as an open issue (nothing cleared it)", () => { + const o = get( + [ + orch("phase.start", { phase: "build" }), + orch("phase.end", { phase: "build" }), + orch("escalation.raised", { story: "S2" }, "GREEN verify failed"), + ], + "orchestrator", + ); + expect(o.issues.length).toBe(1); + }); + + it("clears a resolved escalation once the role starts a later phase (the live bug)", () => { + // Exactly the stockflow case: escalations on S2, then the orchestrator resumes (starts + // build for the next story). The issues are resolved and must not pin it red. + const o = get( + [ + orch("phase.start", { phase: "build" }), + orch("escalation.raised", { story: "S2" }, "GREEN verify failed"), + orch("escalation.raised", { story: "S2" }, "REFACTOR verify failed"), + orch("phase.start", { phase: "build" }, "orchestrator START build"), // run moved on + ], + "orchestrator", + ); + expect(o.issues.length).toBe(0); + }); + + it("clears issues per role independently", () => { + // navigator flags a concern, then re-starts a phase → cleared; driver's later concern stays. + const { agents } = reduceAgents([ + ev("phase.start", { phase: "review" }, { role: "navigator" }), + ev("concern.flagged", { note: "n1" }, { role: "navigator" }), + ev("phase.start", { phase: "review" }, { role: "navigator" }), // clears navigator + ev("phase.start", { phase: "green" }, { role: "driver" }), + ev("concern.flagged", { note: "d1" }, { role: "driver" }), // driver still open (trailing) + ]); + expect(agents.find((a) => a.role === "navigator")!.issues.length).toBe(0); + expect(agents.find((a) => a.role === "driver")!.issues.length).toBe(1); + }); +}); + +describe("reduceAgents — the run ending calms every bubble", () => { + const get = (events: AgentLogEvent[], role: string) => + reduceAgents(events).agents.find((a) => a.role === role)!; + + // The live bug: at workflow end the last role to run has an open turn (its phase.start + // had no closing turn.usage/phase.end, and no later handoff/phase.start cleared it), so + // the finalize step pinned it "working" forever. The terminal workflow phase.end is the + // signal the whole run is over — after it, nothing is working. + it("idles a role left mid-turn once the workflow phase.end fires", () => { + const re = get( + [ + ev("phase.start", { phase: "deploy" }, { role: "release-engineer" }), + ev("phase.end", { phase: "workflow" }, { role: "orchestrator" }), // run complete + ], + "release-engineer", + ); + expect(re.status).toBe("idle"); + }); + + it("clears a dangling on-deck role when the run completes", () => { + // A handoff dispatched navigator but the run ended before it started — no lingering on-deck. + const { agents, onDeck } = reduceAgents([ + ev("handoff", { to_role: "navigator" }, { role: "orchestrator" }), + ev("phase.end", { phase: "workflow" }, { role: "orchestrator" }), + ]); + expect(onDeck).toBeNull(); + expect(agents.find((a) => a.role === "navigator")!.status).toBe("idle"); + }); + + it("a non-terminal phase.end (a normal turn boundary) does NOT calm other roles", () => { + // Only the workflow-level phase.end ends the run; a per-phase phase.end must not + // idle a different role that is genuinely mid-turn. + const drv = get( + [ + ev("phase.start", { phase: "green" }, { role: "driver" }), + ev("phase.end", { phase: "build" }, { role: "navigator" }), // navigator's turn ended, not the run + ], + "driver", + ); + expect(drv.status).toBe("working"); + }); +}); + +describe("consortDir — artifact-root resolution (v0.3.7 .sftdd → .consort rename)", () => { + const saved = process.env.CONSORT_PROJECT_DIR; + const made: string[] = []; + afterEach(() => { + if (saved === undefined) delete process.env.CONSORT_PROJECT_DIR; + else process.env.CONSORT_PROJECT_DIR = saved; + for (const d of made.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + function projectWith(roots: string[]): string { + const proj = mkdtempSync(join(tmpdir(), "consort-proj-")); + made.push(proj); + for (const r of roots) mkdirSync(join(proj, r)); + process.env.CONSORT_PROJECT_DIR = proj; + return proj; + } + + it("prefers .consort/ when present", () => { + const proj = projectWith([".consort", ".sftdd"]); + expect(consortDir()).toBe(join(proj, ".consort")); + }); + it("falls back to legacy .sftdd/ for a pre-rename project", () => { + const proj = projectWith([".sftdd"]); + expect(consortDir()).toBe(join(proj, ".sftdd")); + }); + it("honours the oldest .tdd/ root when it is the only one", () => { + const proj = projectWith([".tdd"]); + expect(consortDir()).toBe(join(proj, ".tdd")); + }); + it("defaults to .consort/ when no root exists yet (project not scaffolded)", () => { + const proj = projectWith([]); + expect(basename(consortDir())).toBe(".consort"); + }); + it("sftddDir alias resolves identically", () => { + projectWith([".consort"]); + expect(sftddDir()).toBe(consortDir()); + }); +}); diff --git a/apps/dashboard/lib/consort.ts b/apps/dashboard/lib/consort.ts new file mode 100644 index 00000000..e676c976 --- /dev/null +++ b/apps/dashboard/lib/consort.ts @@ -0,0 +1,420 @@ +// Server-side READER for a Consort project's .sftdd state. +// +// This module owns all I/O: reading files, shelling out to the feature-status CLI, and +// scanning Claude transcripts for liveness. The derivation itself lives in derive.ts +// (pure functions over events) and reducer.ts (the fold that composes them), so the +// board can be evaluated at any point in the log and tested without a project on disk. +// +// Pure read-only observer: it never writes to the watched project. +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; +import { + AgentLogEvent, + ArtifactContent, + DashboardState, + FeatureStatus, + NextJson, + PendingPermission, + Role, + SnapshotInputs, +} from "./types"; +import { ROLE_SET } from "./derive"; +import { classify, readTextFile } from "./filekind"; +import { resolveContained } from "./safepath"; +import { emptyState, fold, ENABLE_PERMISSION_BANNER } from "./reducer"; + +// Re-exported so existing importers (and tests) keep working after the split. +export { + resolverFor, + findPendingGate, + computeDesignPhases, + computeStories, + storyStage, + designComplete, + reduceAgents, +} from "./derive"; +export { fold } from "./reducer"; + +// Authoritative issue→resolver attribution: Consort writes .handback/[.].md +// naming EXACTLY the role that must fix a failed contract. Reading these beats guessing +// from a blocker's source string. Returns a map keyed by story (or "" for feature-scoped) +// → the routed-to role. Filename convention (kit drive.cli.js): `${role}${story?`.${story}`:""}.md`. +function readHandbacks(feature: string): { role: Role; story: string | null }[] { + if (!isSafeSegment(feature)) return []; // untrusted id; refuse to build a traversal path + const dir = join(sftddDir(), "features", feature, ".handback"); + if (!existsSync(dir)) return []; + const out: { role: Role; story: string | null }[] = []; + try { + for (const f of readdirSync(dir)) { + if (!f.endsWith(".md")) continue; + const base = f.slice(0, -3); // drop .md + // role is the first dotted segment that is a known role; the rest is the story id + const dot = base.indexOf("."); + const role = (dot === -1 ? base : base.slice(0, dot)) as Role; + if (!ROLE_SET.has(role)) continue; + const story = dot === -1 ? null : base.slice(dot + 1); + out.push({ role, story: story || null }); + } + } catch { + return []; + } + return out; +} + +// The Consort project to observe. Set CONSORT_PROJECT_DIR to point at any scaffolded +// project; otherwise fall back to the process working directory (so you can `cd` into a +// project and launch the dashboard from there). +export function projectDir(): string { + return process.env.CONSORT_PROJECT_DIR || process.cwd(); +} + +// A companion record-lane corpus for a LIVE build, when one is being captured. The Consort +// drive's record lane (LAKEBASE_CONSORT_RECORD_DIR set) writes a full ReplaySource-shaped corpus +// — turns/ + correspondence.jsonl + per-turn file snapshots + a MIRRORED agent-log.jsonl — into a +// SEPARATE directory, never the watched `.consort/` (setting RECORD_DIR to the project's own +// `.consort/` would double-append and corrupt its agent-log). Point the dashboard at that same +// directory and a live board gains replay-grade drill-down (prompts, inputs, point-in-time +// per-step snapshots) while liveness still comes from the agent-log mirrored under `.consort/`. +// +// Prefer a dashboard-native override, else read the kit's OWN var so launching the dashboard in +// the same shell as the build ("source ~/.consort-run.env") wires it up with no extra step. +// Null when unset (a plain live build with no companion recording) — the caller degrades to the +// agent-log-only live board and the FidelityBanner explains what isn't captured. +export function recordDir(): string | null { + const d = process.env.CONSORT_RECORD_DIR || process.env.LAKEBASE_CONSORT_RECORD_DIR; + return d && d.trim() ? d.trim() : null; +} + +// The artifact-root directory names Consort has used, in resolution priority. v0.3.7 renamed +// the root `.sftdd/` → `.consort/` but still READS the legacy names (and auto-migrates old +// projects on their next run), so the observer mirrors the kit's `resolveConsortDir()`: pick the +// first that exists, else default to the current name so a not-yet-created project still resolves +// to a coherent (if absent) path. This lets one dashboard watch both pre- and post-rename projects. +export const ARTIFACT_ROOT_NAMES = [".consort", ".sftdd", ".tdd"] as const; + +// The resolved artifact root under the watched project (`.consort/` by preference, legacy +// `.sftdd/`/`.tdd/` honoured in place). Named `consortDir`; `sftddDir` is kept as an alias so +// existing importers and tests keep working. +export function consortDir(): string { + const root = projectDir(); + for (const name of ARTIFACT_ROOT_NAMES) { + if (existsSync(join(root, name))) return join(root, name); + } + return join(root, ARTIFACT_ROOT_NAMES[0]); +} + +/** @deprecated Use {@link consortDir}. Retained so existing callers keep resolving. */ +export function sftddDir(): string { + return consortDir(); +} + +// Read an artifact the log named (`artifact.written.path`, relative to `.sftdd/`) at the +// project's CURRENT HEAD. This is the live half of the turn drill-down: a live project has no +// per-turn snapshot, so the honest thing it can offer is the file as it is NOW, which the panel +// labels as such. +// +// `rel` comes from a request parameter, so it goes through the same audited containment guard as +// the replay reader (lib/safepath.ts): realpath both sides, require containment under `.sftdd/`. +// Then filekind's shared read/size/text guards, so live and replay agree on what is text and how +// big is too big. `readArtifactAtHead` maps a containment miss onto a HEAD-specific reason, since +// "not captured in a turn" is the wrong wording for a file that simply no longer exists here. +export function readArtifactAtHead(rel: string): ArtifactContent { + // Classify the path AS RESOLVED — under `.sftdd/` — not the bare rel. `artifact.written.path` + // is `.sftdd/`-relative and drops the prefix (`design/ia.md`, not `.sftdd/design/ia.md`), but + // replay classifies the prefixed form (`turn.produced` keeps it), and classify's first rule is + // `.sftdd/` → artifact. Passing the bare rel skips that rule, so `.sftdd/scripts/x.py` or + // `.sftdd/tests/x.py` would read "code" here and "artifact" in replay — the exact live/replay + // disagreement the shared classify exists to prevent. Everything under `.sftdd/` is workflow + // bookkeeping, so "artifact" is also the honest answer. + // Resolve once per call. Deliberately NOT memoized across calls: the observer must notice a + // project (or its artifact root) appearing mid-run — the live board lights up when a replay/build + // first writes `.consort/`, and a legacy `.sftdd/` project can materialize after launch too. + const dir = consortDir(); + const kind = classify(basename(dir) + "/" + rel); + const abs = resolveContained(dir, rel); + if (abs === null) { + return { path: rel, kind, content: null, reason: "(no longer present at HEAD)" }; + } + const r = readTextFile(abs, rel); + // filekind's generic "(not a file)"/"(unreadable)" read, for a live project, as "gone from HEAD". + const reason = r.reason === "(not a file)" || r.reason === "(unreadable)" ? "(no longer present at HEAD)" : r.reason; + return { path: rel, kind, content: r.content, reason }; +} + +// The one definition of the "this isn't a Consort project" message. Both consort.buildState() +// and LiveSource.unavailableReason() render it, so it must not be written twice. +export function noSftddMessage(dir: string): string { + return `No .consort/ (or legacy .sftdd/) found in ${dir} — is CONSORT_PROJECT_DIR a scaffolded Consort project?`; +} + +// A feature id is used to build a path under .sftdd/features//. It comes from +// next.json / the agent-log (files the watched project writes), so treat it as untrusted: +// a value like "../../etc" would escape the project dir. A real feature id is a single path +// segment (letters/digits/dash/underscore/dot, no separators, no leading dot). Reject +// anything else so the read-only observer can never be walked outside .sftdd/features. +export function isSafeSegment(seg: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(seg) && !seg.includes(".."); +} + +function readJsonSafe(path: string): T | null { + try { + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, "utf8")) as T; + } catch { + return null; + } +} + +// A session is considered "actively working" if any Claude Code transcript for this +// project was written within this window. Consort role turns and a human/proxy session +// auto-resolving an escalation both write their transcript continuously; a genuinely idle +// "waiting on you" pause does not. 15s comfortably spans think-time between tool calls +// without latching on after the session actually stops. + +// Scanning the transcript dir (readdir + stat per file) is O(files) and the dir can hold +// dozens of sub-agent transcripts, so cache the newest-mtime scan briefly. The poll cadence +// is 2s; a 1s TTL means at most one scan per poll while staying fresh enough for a 15s +// liveness window. +const SESSION_SCAN_TTL_MS = 1000; +let _sessionScan: { at: number; ageBaseMs: number; scannedAt: number } | null = null; + +// Newest mtime across the project's Claude Code transcripts, as ms-since-write (Infinity if +// none). Deliberately mtime-only across ALL transcripts — we only need "is SOMETHING writing +// right now", not WHICH session, so this sidesteps the per-session driver-identification that +// made permission detection flaky (many sub-agent transcripts share the project dir). +// Callers gate this on "is there anything to check" (a working agent or a pending banner) so +// a fully-idle run pays nothing; the 1s cache covers repeat calls within a poll. +function sessionActivityAgeMs(): number { + // HOME must be set to locate ~/.claude; without it the derived path is bogus (leading "/"), + // so report "no activity" rather than silently scanning the wrong place. + const home = process.env.HOME; + if (!home) return Infinity; + + const now = Date.now(); + if (_sessionScan && now - _sessionScan.at < SESSION_SCAN_TTL_MS) { + // Age advances with wall-clock between scans (the file isn't getting newer on its own). + return _sessionScan.ageBaseMs + (now - _sessionScan.scannedAt); + } + + const projectSlug = projectDir().replace(/[/.]/g, "-").replace(/^-/, ""); + const txDir = join(home, ".claude", "projects", `-${projectSlug}`); + let age = Infinity; + if (existsSync(txDir)) { + let newest = -Infinity; + try { + for (const f of readdirSync(txDir)) { + if (!f.endsWith(".jsonl")) continue; + const m = statSync(join(txDir, f)).mtimeMs; + if (m > newest) newest = m; + } + } catch { + newest = -Infinity; + } + if (newest !== -Infinity) age = now - newest; + } + _sessionScan = { at: now, ageBaseMs: Number.isFinite(age) ? age : Infinity, scannedAt: now }; + return age; +} + + + +// A trailing tool_use resolves within a second or two when it's auto-approved and just +// executing; a real permission prompt sits unanswered until the human acts. So we only +// report a pending permission once the tool_use has gone unanswered for this long. This +// kills the "false flash" as a tool starts, at the cost of a ~few-second delay before a +// genuine prompt shows — an acceptable trade for not crying wolf. +// Permission-prompt detection (over Claude Code transcripts) is DISABLED: it was too +// flaky — false-positives from parked/sub-agent sessions, and "newest transcript by mtime" +// picks the wrong session when many sub-agent transcripts share the project dir. The gate +// and escalation banners (sourced from Consort's own .sftdd files) stay on and are reliable. +// Before re-enabling, identify the live driver session by correlating the transcript against +// .sftdd/agent-log.jsonl activity rather than guessing by mtime. Flip to re-enable. + +// Master switch for the top-of-dashboard "waiting on you" banner (gate + escalation + +// permission). Disabled: in practice the banners caused more confusion than they resolved — +// stale/flapping states, and "waiting" vs "being worked on" was hard to read at a glance. +// The underlying signals still drive per-agent bubble state (waiting/issue) and the Open +// issues → resolver list, which are the reliable surfaces. Flip to re-enable the banner. + +const PERMISSION_DWELL_MS = 4000; + +// Upper bound: a tool_use pending longer than this belongs to an abandoned/dead session, +// not a live prompt you're staring at. Consort's driver sessions turn over quickly, so a +// multi-minute-old trailing tool_use is stale, not waiting. (5 min.) +const PERMISSION_STALE_MS = 5 * 60 * 1000; + +// Detect a Claude Code permission prompt in the DRIVING session's transcript: the +// file ends with an assistant message whose final content block is a tool_use with +// no following tool_result, AND that tool_use has been pending > PERMISSION_DWELL_MS. +// A different layer from a Consort HITL gate. We read only the tail to stay cheap. +function findPendingPermission(): PendingPermission | null { + const projectSlug = projectDir().replace(/[/.]/g, "-").replace(/^-/, ""); + const txDir = join(process.env.HOME || "", ".claude", "projects", `-${projectSlug}`); + if (!existsSync(txDir)) return null; + + // newest transcript by mtime + let newest: { path: string; mtime: number } | null = null; + try { + for (const f of readdirSync(txDir)) { + if (!f.endsWith(".jsonl")) continue; + const p = join(txDir, f); + const m = statSync(p).mtimeMs; + if (!newest || m > newest.mtime) newest = { path: p, mtime: m }; + } + } catch { + return null; + } + if (!newest) return null; + + const raw = readFileSync(newest.path, "utf8"); + const lines = raw.split("\n").filter(Boolean); + // Walk backwards to the last message record with content. But bail early on trailing + // session-control records (last-prompt / mode / permission-mode) — those are written when + // a session is PARKED/idle at a prompt box, i.e. it's done executing, not mid-tool. Their + // presence after the last message means any tool_use above them already resolved. + for (let i = lines.length - 1; i >= 0; i--) { + let rec: Record; + try { + rec = JSON.parse(lines[i]); + } catch { + continue; + } + const type = rec.type as string; + if (type === "last-prompt" || type === "mode" || type === "permission-mode") { + return null; // session is parked at an idle prompt, not blocked on a tool + } + if (type === "user") { + // a user/tool_result already answered the newest turn — not paused + return null; + } + if (type === "assistant") { + const msg = (rec.message as Record) || {}; + const content = msg.content; + if (!Array.isArray(content) || content.length === 0) return null; + const last = content[content.length - 1] as Record; + if (last?.type !== "tool_use") return null; // ended on text = not awaiting a tool + + const toolName = (last.name as string) ?? "tool"; + // Agent/Task calls spawn a CHILD session; their tool_result flows through the + // sub-agent transcript, not this file, so a trailing Agent tool_use is NOT a pending + // permission — it's a normal, already-dispatched sub-agent. Ignore it. + if (toolName === "Agent" || toolName === "Task") return null; + + const ts = typeof rec.timestamp === "string" ? Date.parse(rec.timestamp) : NaN; + const age = Number.isNaN(ts) ? Infinity : Date.now() - ts; + // Dwell gate: too new = auto-approved tool just executing (result about to land). + if (age < PERMISSION_DWELL_MS) return null; + // Staleness cap: too old = an abandoned/dead session, not a live prompt you're facing. + if (age > PERMISSION_STALE_MS) return null; + + const input = (last.input as Record) || {}; + return { + tool: toolName, + command: typeof input.command === "string" ? input.command : null, + description: typeof input.description === "string" ? input.description : null, + }; + } + // skip attachment / file-history-snapshot / summary records + } + return null; +} + +export function readEvents(): AgentLogEvent[] { + const path = join(sftddDir(), "agent-log.jsonl"); + if (!existsSync(path)) return []; + const raw = readFileSync(path, "utf8"); + const out: AgentLogEvent[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + out.push(JSON.parse(line) as AgentLogEvent); + } catch { + // tolerate a torn final line while Consort is mid-append + } + } + return out; +} + + +// Shell out to the project's feature-status CLI for the authoritative % snapshot. +// feature-status shells out (~0.4s) and changes slowly (story/gate/test counts), so cache +// it briefly. The fast-moving signals (agent activity, waiting banner) come from files, not +// this — so a short TTL keeps the poll snappy without staling what matters. +let _fsCache: { feature: string; at: number; value: FeatureStatus | null } | null = null; + +const FEATURE_STATUS_TTL_MS = 4000; + +function readFeatureStatus(feature: string): FeatureStatus | null { + if (_fsCache && _fsCache.feature === feature && Date.now() - _fsCache.at < FEATURE_STATUS_TTL_MS) { + return _fsCache.value; + } + let value: FeatureStatus | null = null; + try { + const out = execFileSync("./scripts/lk", ["lakebase-feature-status", feature, "--json"], { + cwd: projectDir(), + encoding: "utf8", + timeout: 15000, + stdio: ["ignore", "pipe", "ignore"], + }); + value = JSON.parse(out) as FeatureStatus; + } catch { + value = _fsCache?.feature === feature ? _fsCache.value : null; // keep last-good on transient failure + } + _fsCache = { feature, at: Date.now(), value }; + return value; +} + + +// Gather every disk-sourced input the fold needs. Isolating the I/O here is what keeps +// `fold` pure — and what lets a future replay source supply the same struct from a corpus. +export function readSnapshot(events: AgentLogEvent[], generatedAt: string): SnapshotInputs { + const next = readJsonSafe(join(sftddDir(), "next.json")); + + // Active feature: prefer next.json, else the newest event carrying one. Needed here + // (ahead of the fold) because the status CLI and handbacks are keyed by feature. + let feature = next?.feature ?? null; + if (!feature) { + for (let i = events.length - 1; i >= 0; i--) { + const f = (events[i].metadata as Record | undefined)?.feature_id; + if (f) { + feature = String(f); + break; + } + } + } + + const status = feature ? readFeatureStatus(feature) : null; + const handbacks = feature ? readHandbacks(feature) : []; + + // Both liveness scans are gated on there being something to check, so a fully idle run + // pays nothing. `sessionAgeMs` is consumed by the fold only at the live edge. + const needsLiveness = + events.length > 0 || (next?.state?.open_gates ?? []).length > 0 || !!next?.primary_action; + const sessionAgeMs = needsLiveness ? sessionActivityAgeMs() : Infinity; + const pendingPermission = ENABLE_PERMISSION_BANNER ? findPendingPermission() : null; + + return { projectDir: projectDir(), next, status, handbacks, sessionAgeMs, pendingPermission, generatedAt }; +} + +/** + * Read the watched project and fold it into a dashboard state. + * + * @param upTo optional event index for time travel — omit for the live edge. Note the + * snapshot half (progress/gates/story status) always reflects NOW regardless; + * `atLive` and `snapshotAsOf` on the result tell the UI how to label it. + */ +export function buildState(upTo?: number): DashboardState { + const generatedAt = new Date().toISOString(); + const dir = projectDir(); + + if (!existsSync(sftddDir())) { + return { + ...emptyState(dir, generatedAt), + error: noSftddMessage(dir), + }; + } + + const events = readEvents(); + return fold(events, readSnapshot(events, generatedAt), upTo); +} diff --git a/apps/dashboard/lib/correlate.test.ts b/apps/dashboard/lib/correlate.test.ts new file mode 100644 index 00000000..418a7e4b --- /dev/null +++ b/apps/dashboard/lib/correlate.test.ts @@ -0,0 +1,324 @@ +/** + * Correlation tests. + * + * The pairing algorithm cannot detect its own failure (plan §6), so these tests do three + * distinct jobs, in increasing order of what they'd catch: + * + * 1. DIFFERENTIAL vs. Kevin's original JS, transcribed verbatim below and run over the real + * 421-event corpus. This is the guard that the port didn't change the semantics — the + * same technique that caught the `topology.ts` transcription risk in PR #9. + * 2. GROUND TRUTH on the real corpus: 71/71 invoke-role turns consumed, and the 10 + * release-engineer events classified as structural rather than as drift. + * 3. DRIFT DETECTION on synthetic mismatches, because the healthy corpus by definition + * exercises none of them. A report that only ever sees a good run is untested where it + * matters. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { correlate, driftMessage, driftSeverity, kitVersionOfLog, turnByEvent, type TurnIndexEntry } from "./correlate"; +import type { AgentLogEvent } from "./types"; + +const CORPUS_DIR = join(__dirname, "__fixtures__"); +const CORPUS_KIT_COMMIT = "cad5f5fb5eb7e59a703722284b6a5858ddf3fff0"; + +function readCorpusLog(): AgentLogEvent[] { + return readFileSync(join(CORPUS_DIR, "stockflow-rerecord-agent-log.jsonl"), "utf8") + .split("\n") + .filter((l) => l.trim()) + .flatMap((l) => { + try { + return [JSON.parse(l) as AgentLogEvent]; + } catch { + return []; + } + }); +} + +function readTurns(): TurnIndexEntry[] { + const raw = JSON.parse(readFileSync(join(CORPUS_DIR, "stockflow-rerecord-turns-index.json"), "utf8")); + return raw.turns as TurnIndexEntry[]; +} + +const LOG = readCorpusLog(); +const TURNS = readTurns(); + +// --------------------------------------------------------------------------- +// 1. Differential: Kevin's algorithm, transcribed verbatim from +// `_dashboard_template.html:312-325`. Deliberately NOT refactored — quirks included — so it +// stands as an independent oracle rather than a paraphrase of the port. + +function kevinEventTurn(log: AgentLogEvent[], turns: TurnIndexEntry[]): Record { + const turnByRole: Record = {}; + turns.forEach((t) => { + if (t.kind === "invoke-role") { + (turnByRole[t.role!] = turnByRole[t.role!] || []).push(t); + } + }); + const roleCursor: Record = {}; + const eventTurn: Record = {}; + log.forEach((e, i) => { + if (e.event === "phase.start" && e.role && e.role !== "orchestrator") { + const list = turnByRole[e.role] || []; + const k = roleCursor[e.role] || 0; + if (list[k]) { + eventTurn[i] = list[k].ordinal; + roleCursor[e.role] = k + 1; + } + } + }); + return eventTurn; +} + +describe("correlate — differential against Kevin's original", () => { + it("pairs event-for-event identically over the whole corpus", () => { + const mine = turnByEvent(correlate(LOG, TURNS, CORPUS_KIT_COMMIT)); + const theirs = kevinEventTurn(LOG, TURNS); + + // Same set of paired events, same turn for each. Asserted as sorted pairs so a + // difference names the event index rather than just failing a size check. + const asPairs = (m: Map | Record) => + Object.entries(m instanceof Map ? Object.fromEntries(m) : m) + .map(([k, v]) => [Number(k), v] as const) + .sort((a, b) => a[0] - b[0]); + + expect(asPairs(mine)).toEqual(asPairs(theirs)); + }); + + it("agrees at every prefix, not only on the whole log", () => { + // Scrubbing calls correlate() with prefixes; a cursor bug could agree at the end and + // disagree in the middle. Step by 7 to keep this cheap while still covering ~60 points. + for (let i = 0; i <= LOG.length; i += 7) { + const slice = LOG.slice(0, i); + const mine = Object.fromEntries(turnByEvent(correlate(slice, TURNS, CORPUS_KIT_COMMIT))); + expect(mine).toEqual(kevinEventTurn(slice, TURNS)); + } + }); + + it("differs from Kevin's ONLY by classifying structural roles, not by dropping them", () => { + // The one intentional deviation: he silently produces no pairing for release-engineer + // (its role list is empty, so `list[k]` is undefined); we record the same non-pairing but + // report it as `structural`. Assert the deviation is exactly that and nothing more. + const report = correlate(LOG, TURNS, CORPUS_KIT_COMMIT); + const theirs = kevinEventTurn(LOG, TURNS); + for (const s of report.structural) { + expect(theirs[s.eventIndex]).toBeUndefined(); + expect(s.role).toBe("release-engineer"); + } + expect(report.structural.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Ground truth on the real corpus. + +describe("correlate — the real stockflow-rerecord corpus", () => { + it("consumes every invoke-role turn exactly once, with nothing left over", () => { + const r = correlate(LOG, TURNS, CORPUS_KIT_COMMIT); + // Measured: 71 invoke-role turns, all consumed. This is the plan §8 claim, pinned. + expect(r.pairings.length).toBe(71); + expect(r.unpairedTurns).toEqual([]); + expect(r.cursors).toEqual({ + "architect-reviewer": { consumed: 9, available: 9 }, + dba: { consumed: 2, available: 2 }, + driver: { consumed: 15, available: 15 }, + navigator: { consumed: 26, available: 26 }, + "product-owner": { consumed: 2, available: 2 }, + "spec-author": { consumed: 9, available: 9 }, + "test-strategist": { consumed: 7, available: 7 }, + "ux-designer": { consumed: 1, available: 1 }, + }); + }); + + it("reports the 10 release-engineer events as structural, NOT as drift", () => { + // The whole point of STRUCTURAL_ROLES: a healthy corpus must read as healthy. Before + // this distinction existed the same run reported 10 phantom unpaired rows. + const r = correlate(LOG, TURNS, CORPUS_KIT_COMMIT); + expect(r.structural.length).toBe(10); + expect(new Set(r.structural.map((s) => s.phase))).toEqual(new Set(["deploy", "promote"])); + expect(r.structural.filter((s) => s.phase === "deploy").length).toBe(8); + expect(r.structural.filter((s) => s.phase === "promote").length).toBe(2); + expect(r.unpairedEvents).toEqual([]); + expect(r.healthy).toBe(true); + expect(driftMessage(r)).toBeNull(); + }); + + it("confirms the log's kit stamp against the corpus provenance", () => { + expect(kitVersionOfLog(LOG)).toBe(CORPUS_KIT_COMMIT); + const r = correlate(LOG, TURNS, CORPUS_KIT_COMMIT); + expect(r.kitVersionMatch).toBe(true); + expect(r.kitVersion).toEqual({ log: CORPUS_KIT_COMMIT, corpus: CORPUS_KIT_COMMIT }); + }); + + it("pairs every phase.start that is neither orchestrator nor structural", () => { + // Completeness from the log's side: nothing eligible is silently ignored. + const eligible = LOG.filter( + (e) => e.event === "phase.start" && e.role && e.role !== "orchestrator", + ).length; + const r = correlate(LOG, TURNS, CORPUS_KIT_COMMIT); + expect(r.pairings.length + r.structural.length + r.unpairedEvents.length).toBe(eligible); + }); + + it("stays healthy at every prefix of the corpus", () => { + // Scrubbing must never make a good corpus look drifted. Unreached turns are expected + // mid-log, which is exactly why `healthy` ignores unpairedTurns. + for (let i = 0; i <= LOG.length; i += 5) { + const r = correlate(LOG.slice(0, i), TURNS, CORPUS_KIT_COMMIT); + expect(r.healthy, `prefix ${i} reported drift: ${driftMessage(r)}`).toBe(true); + } + }); + + it("pairs turns in ascending ordinal order per role", () => { + // The cursor's core invariant. A regression here is the silent mis-mapping the plan + // warns about, so assert it directly rather than trusting the counts. + const r = correlate(LOG, TURNS, CORPUS_KIT_COMMIT); + const seen = new Map(); + for (const p of r.pairings) { + const last = seen.get(p.role); + if (last !== undefined) expect(p.turnOrdinal).toBeGreaterThan(last); + seen.set(p.role, p.turnOrdinal); + } + // ...and event order is ascending too, so a pairing never points backwards in the log. + const idx = r.pairings.map((p) => p.eventIndex); + expect(idx).toEqual([...idx].sort((a, b) => a - b)); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Drift detection. The healthy corpus exercises none of these paths. + +describe("correlate — drift is detected and named", () => { + const ev = (role: string, phase: string, metadata: Record = {}): AgentLogEvent => ({ + timestamp: "2026-08-01T00:00:00.000Z", + level: "info", + role, + event: "phase.start", + message: `${role} START ${phase}`, + metadata: { phase, ...metadata }, + }); + const turn = (ordinal: number, role: string): TurnIndexEntry => ({ + ordinal, + step: 0, + label: `${role}-x`, + kind: "invoke-role", + role, + mode: "x", + dir: `${String(ordinal).padStart(4, "0")}-${role}-x`, + producedCount: 0, + deletedCount: 0, + hasTranscript: true, + }); + + it("flags a log that outruns the corpus (role-exhausted)", () => { + const r = correlate([ev("driver", "green"), ev("driver", "green")], [turn(0, "driver")]); + expect(r.pairings.length).toBe(1); + expect(r.unpairedEvents).toEqual([ + { eventIndex: 1, role: "driver", phase: "green", reason: "role-exhausted" }, + ]); + expect(r.healthy).toBe(false); + expect(driftMessage(r)).toContain("log is ahead of the corpus"); + // A log-ahead tail is the normal live edge — a quiet caveat, not a prominent warning. + expect(driftSeverity(r)).toBe("info"); + }); + + it("is 'ok' severity (no banner) when the log pairs cleanly", () => { + const r = correlate([ev("driver", "green")], [turn(0, "driver")]); + expect(r.healthy).toBe(true); + expect(driftSeverity(r)).toBe("ok"); + }); + + it("distinguishes a role the corpus has never heard of (role-absent)", () => { + // Much stronger evidence of a different run than merely running out of turns, so it + // gets its own reason and its own message. + const r = correlate([ev("navigator", "red")], [turn(0, "driver")]); + expect(r.unpairedEvents[0].reason).toBe("role-absent"); + expect(driftMessage(r)).toContain("no turns for navigator"); + // A role the corpus never recorded = likely a different run: the one case worth flagging loudly. + expect(driftSeverity(r)).toBe("warning"); + }); + + it("flags an explicit kit-version mismatch as fatal", () => { + const log = [{ ...ev("driver", "green"), metadata: { phase: "green", kit_commit: "aaaa111" } }]; + const r = correlate(log, [turn(0, "driver")], "bbbb222"); + expect(r.kitVersionMatch).toBe(false); + expect(r.healthy).toBe(false); + expect(driftMessage(r)).toContain("different kit versions"); + // Both sides are named, so the UI can show what mismatched. + expect(driftMessage(r)).toContain("aaaa111"); + expect(driftMessage(r)).toContain("bbbb222"); + // Pairing IS unreliable (healthy=false), but a kit drift is a benign, expected caveat — the + // banner surfaces it quietly (info), not as the critical-red alert it used to. + expect(driftSeverity(r)).toBe("info"); + }); + + it("treats a missing stamp on either side as unknown, not as a mismatch", () => { + // An older corpus with no provenance must not read as drifted — that would cry wolf on + // every pre-6e73019 scenario. Unknown is a third state, deliberately. + const paired = [ev("driver", "green")]; + expect(correlate(paired, [turn(0, "driver")], null).kitVersionMatch).toBeNull(); + expect(correlate(paired, [turn(0, "driver")], null).healthy).toBe(true); + const stamped = [{ ...ev("driver", "green"), metadata: { phase: "green", kit_commit: "aaaa111" } }]; + expect(correlate(stamped, [turn(0, "driver")], null).kitVersionMatch).toBeNull(); + }); + + it("ignores kit_ref, which is a local capture symlink and not a version", () => { + // provenance.json says so explicitly; keying on it would compare "sftdd-capture-local" + // against a commit sha and mismatch on every healthy corpus. + const log = [{ ...ev("driver", "green"), metadata: { phase: "green", kit_ref: "sftdd-capture-local" } }]; + expect(kitVersionOfLog(log)).toBeNull(); + }); + + it("detects a version mismatch from an empty prefix, given the full log", () => { + // Found in review: `kitVersionOfLog` reads events[0], and correlate() is called with + // prefixes while scrubbing — so at upTo = 0 there was no stamp, no unpaired events, and + // `healthy` was true. Drift detection switched itself off at the transport's left edge, + // which is exactly where a viewer starts. The full log is now a separate argument. + const full = [{ ...ev("driver", "green"), metadata: { phase: "green", kit_commit: "aaaa111" } }]; + const r = correlate([], [turn(0, "driver")], "bbbb222", full); + expect(r.kitVersionMatch).toBe(false); + expect(r.healthy).toBe(false); + expect(driftMessage(r)).toContain("different kit versions"); + }); + + it("defaults the full log to the prefix, so existing callers are unchanged", () => { + const stamped = [{ ...ev("driver", "green"), metadata: { phase: "green", kit_commit: "aaaa111" } }]; + expect(correlate(stamped, [turn(0, "driver")], "aaaa111").kitVersionMatch).toBe(true); + }); + + it("reports turns the log never reached without calling them drift", () => { + const r = correlate([ev("driver", "green")], [turn(0, "driver"), turn(1, "driver")]); + expect(r.unpairedTurns).toEqual([{ ordinal: 1, role: "driver", label: "driver-x" }]); + expect(r.healthy).toBe(true); // a prefix, not a mismatch + }); + + it("ignores non-phase.start events and the orchestrator", () => { + const noise: AgentLogEvent[] = [ + { ...ev("driver", "green"), event: "turn.usage" }, + ev("orchestrator", "dispatch"), + { ...ev("driver", "green"), role: "" }, + ]; + const r = correlate(noise, [turn(0, "driver")]); + expect(r.pairings).toEqual([]); + expect(r.unpairedEvents).toEqual([]); + expect(r.cursors.driver).toEqual({ consumed: 0, available: 1 }); + }); + + it("handles an empty log and an empty corpus without inventing health problems", () => { + expect(correlate([], TURNS, CORPUS_KIT_COMMIT).healthy).toBe(true); + expect(correlate([], []).pairings).toEqual([]); + // A corpus with no turns at all, against a log that wants them, IS drift. + const r = correlate([ev("driver", "green")], []); + expect(r.healthy).toBe(false); + expect(r.unpairedEvents[0].reason).toBe("role-absent"); + }); + + it("only counts invoke-role turns as pairable", () => { + // The corpus's 55 non-invoke-role turns (gates, dispatch, deploy…) must never be paired + // to a phase.start, or a gate turn would show up as a role's work. + const gate: TurnIndexEntry = { ...turn(0, "driver"), kind: "approve-gate", role: null }; + const r = correlate([ev("driver", "green")], [gate, turn(1, "driver")]); + expect(r.pairings).toEqual([ + { eventIndex: 0, turnOrdinal: 1, role: "driver", phase: "green" }, + ]); + }); +}); diff --git a/apps/dashboard/lib/correlate.ts b/apps/dashboard/lib/correlate.ts new file mode 100644 index 00000000..7153b37a --- /dev/null +++ b/apps/dashboard/lib/correlate.ts @@ -0,0 +1,249 @@ +// Log ↔ corpus pairing: which recorded turn produced each log event. +// +// This is the fragile part of replay mode, and the plan's §6 names it the top risk. The +// algorithm is Kevin's, transcribed from `_dashboard_template.html:312-325`: walk the log in +// order, and for each `phase.start` carrying a non-orchestrator role, take that role's next +// unconsumed `invoke-role` turn. A per-role cursor, nothing more. +// +// It is correct when the log and the corpus are the same run at the same kit version, and it +// CANNOT DETECT that they aren't. An off-by-one early in a role mis-maps every later turn for +// that role — wrong transcript, wrong code, no error. That is the same failure class as the +// `REPLAY CORPUS MISS` on `dba S1-record-stock` (corpus captured v0.3.0-beta.14 against a +// v0.3.5 pipeline). So this module's real job is not the pairing — it is the REPORT. +// +// `correlate()` therefore returns a `CorrelationReport` the UI can surface, and callers are +// expected to show drift rather than silently render a mis-paired turn. +// +// One structural subtlety, measured rather than assumed (see `STRUCTURAL_ROLES`): a healthy +// corpus has legitimately unpaired events, so "unpaired > 0" is NOT a drift signal on its own. + +import type { AgentLogEvent } from "./types"; + +/** + * A turn as it appears in `turns/index.json`. + * + * Optional fields are optional in the data, not merely nullable — measured across the corpus's + * 126 entries: `ordinal`/`step`/`label`/`kind`/`dir`/`producedCount`/`deletedCount` are always + * present, but `role` on 72, `story` on 100, `hasTranscript` on 69, `mode` on 33, `ac` on 11. + * Only `role` and `kind` matter for pairing; the rest are here so the shape doesn't lie. + */ +export interface TurnIndexEntry { + ordinal: number; + step: number; + label: string; + kind: string; + role?: string | null; + mode?: string | null; + story?: string | null; + ac?: string | null; + dir: string; + producedCount: number; + deletedCount: number; + /** Absent (not false) on the 57 turns with no transcript — treat missing as "no". */ + hasTranscript?: boolean; +} + +/** + * Roles that emit `phase.start` but own NO `invoke-role` turns, by design. + * + * `release-engineer` drives deploy and promote, which the corpus models as distinct turn + * kinds (`deploy`, `deploy-complete`, `prepare-pr`, `wait-ci`, `merge`, `approve-*-gate`) + * rather than as a role invocation. Measured on stockflow-rerecord: it emits 10 + * `phase.start` events (8 `deploy`, 2 `promote`) and has 0 `invoke-role` turns. + * + * Without this, a perfectly healthy corpus reports 10 phantom unpaired rows and any + * threshold on `unpaired` fires on a good run. These are counted as `structural`, not as + * `unpairedEvents`. + */ +const STRUCTURAL_ROLES = new Set(["release-engineer"]); + +/** An event paired to the turn that produced it. */ +export interface Pairing { + /** Index into the event array passed to `correlate`. */ + eventIndex: number; + /** `ordinal` of the paired turn, matching `TurnIndexEntry.ordinal`. */ + turnOrdinal: number; + role: string; + phase: string | null; +} + +/** An event that should have paired but didn't. This is the drift signal. */ +export interface UnpairedEvent { + eventIndex: number; + role: string; + phase: string | null; + /** Why: the role ran out of turns, or the corpus knows no such role at all. */ + reason: "role-exhausted" | "role-absent"; +} + +export interface CorrelationReport { + pairings: Pairing[]; + /** Events that wanted a turn and found none — real drift. Empty on a healthy corpus. */ + unpairedEvents: UnpairedEvent[]; + /** `invoke-role` turns no event ever reached. A short log explains this; drift also can. */ + unpairedTurns: { ordinal: number; role: string; label: string }[]; + /** + * Events skipped on purpose because their role owns no `invoke-role` turns (see + * STRUCTURAL_ROLES). Reported separately so they never read as drift. + */ + structural: { eventIndex: number; role: string; phase: string | null }[]; + /** Per-role `consumed / available`, the quickest read on whether a cursor slipped. */ + cursors: Record; + /** + * Provenance agreement between the log's first event and the corpus's provenance.json. + * Null when either side carries no version stamp (an older corpus, say) — which is itself + * worth surfacing, and is why this is a tri-state rather than a boolean. + */ + kitVersionMatch: boolean | null; + /** What each side claimed, so the UI can name the mismatch instead of just flagging it. */ + kitVersion: { log: string | null; corpus: string | null }; + /** True when nothing suggests the log and corpus disagree. The single check for callers. */ + healthy: boolean; +} + +/** The version anchor a log carries on its first event's metadata. */ +export function kitVersionOfLog(events: AgentLogEvent[]): string | null { + const md = (events[0]?.metadata ?? {}) as Record; + // `kit_commit` is the real anchor; `kit_describe` (v0.3.6) is the human-readable form. + // `kit_ref` is deliberately NOT used: on this corpus it is `sftdd-capture-local`, a local + // capture symlink rather than a published version, and provenance.json says so explicitly. + const commit = md.kit_commit; + return typeof commit === "string" && commit ? commit : null; +} + +/** + * Pair log events to corpus turns, and report on how well they fit. + * + * @param events the run's log, oldest first. May be a PREFIX when scrubbing. + * @param turns `turns/index.json`, in ordinal order. + * @param corpusKitCommit `kit_commit` from provenance.json, if the corpus has one. + * @param fullLog the complete log, when `events` is a prefix. The kit stamp lives on the + * FIRST event, so an empty prefix (`upTo = 0`) carries no version and a genuine + * mismatch would report healthy at the left edge of the transport — drift detection + * that switches off exactly where a viewer starts. Defaults to `events`. + */ +export function correlate( + events: AgentLogEvent[], + turns: TurnIndexEntry[], + corpusKitCommit: string | null = null, + fullLog: AgentLogEvent[] = events, +): CorrelationReport { + // Only `invoke-role` turns participate: they are the ones that represent a role taking a + // turn, which is what a `phase.start` announces. + const byRole = new Map(); + for (const t of turns) { + if (t.kind !== "invoke-role" || !t.role) continue; + const list = byRole.get(t.role); + if (list) list.push(t); + else byRole.set(t.role, [t]); + } + + const cursor = new Map(); + const pairings: Pairing[] = []; + const unpairedEvents: UnpairedEvent[] = []; + const structural: CorrelationReport["structural"] = []; + + events.forEach((e, eventIndex) => { + // The orchestrator dispatches; it never takes a role turn of its own. + if (e.event !== "phase.start" || !e.role || e.role === "orchestrator") return; + const md = (e.metadata ?? {}) as Record; + const phase = typeof md.phase === "string" ? md.phase : null; + const role = e.role; + + if (STRUCTURAL_ROLES.has(role)) { + structural.push({ eventIndex, role, phase }); + return; + } + + const list = byRole.get(role) ?? []; + const k = cursor.get(role) ?? 0; + const turn = list[k]; + if (turn) { + pairings.push({ eventIndex, turnOrdinal: turn.ordinal, role, phase }); + cursor.set(role, k + 1); + } else { + // Distinguish "this role ran out" from "the corpus has never heard of this role" — + // the second is a much stronger signal that the log and corpus are different runs. + unpairedEvents.push({ + eventIndex, + role, + phase, + reason: list.length === 0 ? "role-absent" : "role-exhausted", + }); + } + }); + + const cursors: CorrelationReport["cursors"] = {}; + const unpairedTurns: CorrelationReport["unpairedTurns"] = []; + for (const [role, list] of byRole) { + const consumed = cursor.get(role) ?? 0; + cursors[role] = { consumed, available: list.length }; + for (const t of list.slice(consumed)) { + unpairedTurns.push({ ordinal: t.ordinal, role, label: t.label }); + } + } + unpairedTurns.sort((a, b) => a.ordinal - b.ordinal); + + // From the full log, so the version check holds at every playhead including 0. + const logKit = kitVersionOfLog(fullLog); + const kitVersionMatch = + logKit === null || corpusKitCommit === null ? null : logKit === corpusKitCommit; + + // "Healthy" deliberately ignores `unpairedTurns`: folding a PREFIX of the log legitimately + // leaves later turns unreached, and correlate() is called with prefixes while scrubbing. + // An explicit version mismatch is fatal; an absent stamp on either side is not. + const healthy = unpairedEvents.length === 0 && kitVersionMatch !== false; + + return { + pairings, + unpairedEvents, + unpairedTurns, + structural, + cursors, + kitVersionMatch, + kitVersion: { log: logKit, corpus: corpusKitCommit }, + healthy, + }; +} + +/** `eventIndex → turnOrdinal`, for a UI that wants to jump from a log row to its turn. */ +export function turnByEvent(report: CorrelationReport): Map { + return new Map(report.pairings.map((p) => [p.eventIndex, p.turnOrdinal])); +} + +/** One-line summary for the UI when a report is unhealthy. Null when healthy. */ +export function driftMessage(report: CorrelationReport): string | null { + if (report.healthy) return null; + if (report.kitVersionMatch === false) { + const { log, corpus } = report.kitVersion; + return `Log and corpus are different kit versions (log ${short(log)} vs corpus ${short(corpus)}) — turn pairing is unreliable.`; + } + const absent = report.unpairedEvents.filter((u) => u.reason === "role-absent"); + if (absent.length > 0) { + const roles = [...new Set(absent.map((u) => u.role))].join(", "); + return `The corpus has no turns for ${roles} (${absent.length} event${absent.length === 1 ? "" : "s"}) — it may be a different run.`; + } + const n = report.unpairedEvents.length; + return `${n} event${n === 1 ? "" : "s"} found no matching turn — the log is ahead of the corpus, so later turns may be mis-paired.`; +} + +/** + * How prominently the UI should surface an unhealthy report. This is a property of the + * dashboard's corpus PAIRING, not the run's health — the banner it drives never means the + * orchestrator, build, or deploy is failing. + * + * "ok" — healthy, no banner. + * "warning" — a role the corpus never recorded: the RECORD_DIR likely points at a DIFFERENT + * run. This is the one case worth flagging prominently. + * "info" — a benign observability caveat: a kit-version mismatch (an expected-with-caveat + * drift) or a plain log-ahead tail. Quiet, non-alarming. + */ +export function driftSeverity(report: CorrelationReport): "ok" | "info" | "warning" { + if (report.healthy) return "ok"; + if (report.unpairedEvents.some((u) => u.reason === "role-absent")) return "warning"; + return "info"; +} + +function short(commit: string | null): string { + return commit ? commit.slice(0, 7) : "unstamped"; +} diff --git a/apps/dashboard/lib/correspondence.test.ts b/apps/dashboard/lib/correspondence.test.ts new file mode 100644 index 00000000..01f99355 --- /dev/null +++ b/apps/dashboard/lib/correspondence.test.ts @@ -0,0 +1,151 @@ +/** + * Correspondence parser tests. + * + * The pure parse (`parseCorrespondence`, `completionByOrdinal`) is tested unconditionally on + * inline fixtures. The corpus-backed assertions run against the REAL stockflow-full + * correspondence.jsonl when it's on disk and skip otherwise — the corpus lives in the plugin + * marketplace, not this repo. `CONSORT_TEST_CORPUS_DIR` (a corpus dir carrying the file) overrides. + */ +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseCorrespondence, completionByOrdinal, type CorrespondenceEntry } from "./correspondence"; + +const MARKETPLACE = join(process.env.HOME ?? "", ".claude/plugins/marketplaces/databricks-solutions"); +const CANDIDATES = [ + process.env.CONSORT_TEST_CORPUS_DIR, + join(MARKETPLACE, "examples/replay/corpora/stockflow-full"), +].filter((p): p is string => !!p); +const CORPUS = CANDIDATES.find((p) => existsSync(join(p, "correspondence.jsonl"))); + +// --------------------------------------------------------------------------- +// Pure parse — no corpus. + +describe("correspondence — parseCorrespondence", () => { + it("flattens a kickoff exchange, preferring rendered markdown", () => { + const line = JSON.stringify({ + seq: 0, + at: "2026-08-09T15:52:59.268Z", + direction: "hil-to-orch", + phase: "planning", + ordinal: null, + request: { kind: "kickoff", prompt: "/sprint s1", presentation: { rendered: "`/sprint s1`" } }, + response: { by: "human-proxy", presentation: { rendered: "Starting sprint `s1`." } }, + outcome: { validated: true }, + }); + const [e] = parseCorrespondence(line); + expect(e.kind).toBe("kickoff"); + expect(e.direction).toBe("hil-to-orch"); + expect(e.promptMd).toBe("`/sprint s1`"); + expect(e.responseMd).toBe("Starting sprint `s1`."); + expect(e.by).toBe("human-proxy"); + expect(e.validated).toBe(true); + expect(e.approved).toBe(false); + }); + + it("falls back to the plain prompt when a progress row has no rendered markdown", () => { + const line = JSON.stringify({ + seq: 0, + at: "2026-08-09T15:53:00.000Z", + direction: "orch-to-hil", + ordinal: 0, + step: "0", + request: { kind: "progress", prompt: "spec-author propose, 1 file(s) produced" }, + response: { by: "orchestrator" }, + outcome: { validated: true }, + }); + const [e] = parseCorrespondence(line); + expect(e.promptMd).toBe("spec-author propose, 1 file(s) produced"); + expect(e.responseMd).toBeNull(); + expect(e.ordinal).toBe(0); + }); + + it("reads approved on a gate exchange", () => { + const line = JSON.stringify({ + at: "2026-08-09T15:59:00.000Z", + direction: "orch-to-hil", + ordinal: 4, + request: { kind: "gate", presentation: { rendered: "**HIL approval requested** , GATE plan APPROVED" } }, + response: { by: "orchestrator" }, + outcome: { approved: true, validated: true }, + }); + const [e] = parseCorrespondence(line); + expect(e.kind).toBe("gate"); + expect(e.approved).toBe(true); + expect(e.validated).toBe(true); + }); + + it("skips malformed lines and rows without a timestamp", () => { + const raw = ['not json', JSON.stringify({ direction: "orch-to-hil" }), '', JSON.stringify({ at: "2026-01-01T00:00:00Z", direction: "orch-to-hil" })].join("\n"); + const out = parseCorrespondence(raw); + expect(out.length).toBe(1); + expect(out[0].at).toBe("2026-01-01T00:00:00Z"); + }); +}); + +describe("correspondence — completionByOrdinal", () => { + const mk = (o: Partial): CorrespondenceEntry => ({ + seq: 0, at: "2026-01-01T00:00:00Z", direction: "orch-to-hil", phase: null, ordinal: null, + kind: "progress", by: "orchestrator", promptMd: null, responseMd: null, validated: false, approved: false, ...o, + }); + + it("maps validated progress rows by ordinal", () => { + const m = completionByOrdinal([ + mk({ ordinal: 0, validated: true, promptMd: "spec-author propose, 1 file(s) produced" }), + mk({ ordinal: 1, validated: true, promptMd: "architect estimate" }), + ]); + expect(m.size).toBe(2); + expect(m.get(0)?.label).toContain("propose"); + }); + + it("ignores non-progress rows and unvalidated rows", () => { + const m = completionByOrdinal([ + mk({ ordinal: 2, kind: "gate", approved: true }), // not progress + mk({ ordinal: 3, validated: false }), // not validated + ]); + expect(m.size).toBe(0); + }); + + it("keeps the latest completion when an ordinal repeats", () => { + const m = completionByOrdinal([ + mk({ ordinal: 5, validated: true, at: "2026-01-01T00:00:00Z", promptMd: "first" }), + mk({ ordinal: 5, validated: true, at: "2026-01-01T00:05:00Z", promptMd: "second" }), + ]); + expect(m.get(5)?.label).toBe("second"); + }); +}); + +// --------------------------------------------------------------------------- +// Real corpus. + +describe.skipIf(!CORPUS)("correspondence — the real stockflow-full log", () => { + const entries = () => parseCorrespondence(readFileSync(join(CORPUS!, "correspondence.jsonl"), "utf8")); + + it("parses every line (209 exchanges)", () => { + const all = entries(); + expect(all.length).toBe(209); + // Every entry has a timestamp and a direction — the two fields the timeline needs. + for (const e of all) { + expect(typeof e.at).toBe("string"); + expect(e.direction.length).toBeGreaterThan(0); + } + }); + + it("sees the expected exchange kinds", () => { + const kinds = new Set(entries().map((e) => e.kind)); + for (const k of ["kickoff", "intake", "progress", "author-requests", "gate"]) { + expect(kinds.has(k)).toBe(true); + } + }); + + it("derives completion markers from progress rows", () => { + const m = completionByOrdinal(entries()); + // The run has many completed actions; each maps to an ordinal with a "produced" label. + expect(m.size).toBeGreaterThan(10); + for (const c of m.values()) expect(c.label.length).toBeGreaterThan(0); + }); + + it("carries at least one approved gate", () => { + expect(entries().some((e) => e.kind === "gate" && e.approved)).toBe(true); + }); +}); diff --git a/apps/dashboard/lib/correspondence.ts b/apps/dashboard/lib/correspondence.ts new file mode 100644 index 00000000..f51a9e27 --- /dev/null +++ b/apps/dashboard/lib/correspondence.ts @@ -0,0 +1,144 @@ +// Correspondence: the HIL ↔ orchestrator message stream a Consort run records alongside the +// agent-log. Where `agent-log.jsonl` is the machine event bus (phase.start, cycle.*, turn.usage), +// `correspondence.jsonl` is the CONVERSATION — what the orchestrator asked the human, what the +// human answered, and the outcome of each exchange, each carrying pre-rendered markdown. +// +// Two things make it worth reading here: +// +// 1. It is the human-readable narrative of a run — kickoff, intake, per-action progress, gate +// approvals — which the event ticker can fold in beside the raw events. +// 2. Its `progress` entries fire at ACTION COMPLETION and carry `outcome.validated` keyed by +// `ordinal`. The agent-log only logs at turn boundaries, so a long-running turn can look +// frozen; a correspondence progress row is an authoritative "this action finished" marker +// the UI can use to settle a stale "still working" state. See `completionByOrdinal`. +// +// Shape verified against the stockflow-full corpus (209 lines). Everything here is a pure parse +// over strings — no I/O beyond the one `readFileSync` in `loadCorrespondence` — so it is unit +// tested directly against the real file. + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** A direction of travel for a correspondence exchange. Others may appear; not an exhaustive union. */ +export type CorrespondenceDirection = "hil-to-orch" | "orch-to-hil" | (string & {}); + +/** + * One request/response exchange, flattened to what the UI needs. + * + * The raw row nests `request.presentation.rendered` / `response.presentation.rendered` (markdown) + * plus `request.prompt` (plain). `promptMd` prefers the rendered form and falls back to the plain + * prompt, because the `progress` rows carry only `prompt` (rendered is null there). + */ +export interface CorrespondenceEntry { + /** Monotonic sequence in the file. Some rows record -1 (pre-sequence bookkeeping); kept as-is. */ + seq: number; + /** ISO timestamp, for interleaving with agent-log events. */ + at: string; + direction: CorrespondenceDirection; + /** Lifecycle phase (planning/feature/deploy/promote), or null on the per-action progress rows. */ + phase: string | null; + /** The turn ordinal this exchange concerns, when it names one — the key for completion mapping. */ + ordinal: number | null; + /** `request.kind`: kickoff | intake | progress | author-requests | gate | … */ + kind: string | null; + /** Who answered: human-proxy, orchestrator, … */ + by: string | null; + /** Rendered markdown of the request (falls back to the plain prompt). */ + promptMd: string | null; + /** Rendered markdown of the response, when there is one. */ + responseMd: string | null; + /** The exchange was validated (well-formed / accepted). */ + validated: boolean; + /** A gate/HIL decision was approved. Distinct from `validated`: an approval is also validated. */ + approved: boolean; +} + +function str(v: unknown): string | null { + return typeof v === "string" && v !== "" ? v : null; +} + +function num(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +/** Flatten one raw JSON row into a CorrespondenceEntry, or null when it isn't shaped like one. */ +function toEntry(o: Record): CorrespondenceEntry | null { + const at = str(o.at); + if (!at) return null; // a row with no timestamp can't be placed on the timeline — skip it. + + const request = (o.request ?? {}) as Record; + const response = (o.response ?? {}) as Record; + const outcome = (o.outcome ?? {}) as Record; + const reqPres = (request.presentation ?? {}) as Record; + const respPres = (response.presentation ?? {}) as Record; + + return { + seq: num(o.seq) ?? -1, + at, + direction: (str(o.direction) ?? "unknown") as CorrespondenceDirection, + phase: str(o.phase), + ordinal: num(o.ordinal), + kind: str(request.kind), + by: str(response.by), + // rendered markdown first, plain prompt as fallback (progress rows only have the latter). + promptMd: str(reqPres.rendered) ?? str(request.prompt), + responseMd: str(respPres.rendered), + validated: outcome.validated === true, + approved: outcome.approved === true, + }; +} + +/** Parse the JSONL text of a correspondence log. Malformed lines are skipped, as the log reader does. */ +export function parseCorrespondence(raw: string): CorrespondenceEntry[] { + return raw + .split("\n") + .filter((l) => l.trim()) + .flatMap((l) => { + try { + const e = toEntry(JSON.parse(l) as Record); + return e ? [e] : []; + } catch { + return []; + } + }); +} + +/** + * Read `correspondence.jsonl` from a corpus root, or `[]` when the corpus doesn't ship one. + * + * Only the older `stockflow-rerecord` carries an agent-log; the newer corpora + * (`stockflow-full`) ship correspondence instead — see the source's load-fallback. + */ +export function loadCorrespondence(root: string): CorrespondenceEntry[] { + const path = join(root, "correspondence.jsonl"); + if (!existsSync(path)) return []; + try { + return parseCorrespondence(readFileSync(path, "utf8")); + } catch { + return []; + } +} + +/** A completion marker: which action finished, when, and a one-line label from its progress row. */ +export interface Completion { + at: string; + label: string; +} + +/** + * Map each completed turn ordinal to its completion marker, from the `progress` rows. + * + * A `progress` row is emitted when an action finishes ("spec-author propose, 1 file(s) produced") + * with `outcome.validated`. Keyed by ordinal, this is the authoritative "turn N is done" signal + * the agent-log's turn-boundary logging can lag on. Latest row wins per ordinal (an ordinal can + * be revisited), so the map reflects the most recent completion. + */ +export function completionByOrdinal(entries: CorrespondenceEntry[]): Map { + const out = new Map(); + for (const e of entries) { + if (e.kind !== "progress" || e.ordinal === null) continue; + if (!e.validated && !e.approved) continue; + out.set(e.ordinal, { at: e.at, label: e.promptMd ?? `turn ${e.ordinal} complete` }); + } + return out; +} diff --git a/apps/dashboard/lib/derive.ts b/apps/dashboard/lib/derive.ts new file mode 100644 index 00000000..daf5a962 --- /dev/null +++ b/apps/dashboard/lib/derive.ts @@ -0,0 +1,618 @@ +// Pure derivations over an event log. No I/O, no module state. +// +// Split out of consort.ts so the reducer can be evaluated at any point in a run +// (see reducer.ts `fold`) and unit-tested without a project on disk. These functions +// were moved verbatim; behavior is unchanged. +import { + AgentLogEvent, + AgentState, + DesignPhase, + DESIGN_PHASE_NAMES, + DesignPhaseName, + FeatureSummary, + GateInfo, + Role, + ROLES, + StoryProgress, +} from "./types"; + +const ISSUE_EVENTS = new Set([ + "smell.flagged", + "concern.flagged", + "open.question", + "runner.missing", + "adherence.failed", + "verify.failed", + "escalation.raised", + "deploy.failed", + "deploy.unreachable", +]); +const TURN_END = new Set(["turn.usage", "phase.end"]); +export const ROLE_SET = new Set(ROLES); + +// Fallback: infer the fixer role from a blocker's source string by keyword. Used only +// when Consort hasn't written an explicit .handback file (see readHandbacks). +export function resolverFor(source: string): Role | null { + // build-lane escalations (e.g. "driver-green", "driver-refactor") → the driver + if (source.includes("driver")) return "driver"; + if (source.includes("navigator")) return "navigator"; + if (source.includes("testlist") || source.includes("test-list") || source.includes("test_list")) return "test-strategist"; + if (source.includes("db-design") || source.includes("schema") || source.includes("migration")) return "dba"; + if (source.includes("architecture") || source.includes("nfr") || source.includes("layer")) return "architect-reviewer"; + if (source.includes("adherence") || source.includes("design-system") || source.includes("ux")) return "ux-designer"; + if (source.includes("spec")) return "spec-author"; + return null; +} + +// Scan the log for a HITL stop that is still pending: the last gate.surfaced / +// escalation.raised with no matching resolution after it. Returns null if the run +// is actively proceeding (any turn started after the surface counts as "not waiting"). +// `variety` distinguishes a design GATE (gate.surfaced) from an ESCALATION +// (escalation.raised — e.g. a GREEN verify failed and the driver kicked it to you). +export function findPendingGate( + events: AgentLogEvent[], +): { variety: "gate" | "escalation"; gate: string | null; role: Role | null; story: string | null; message: string | null; ts: string } | null { + let lastSurface = -1; + for (let i = events.length - 1; i >= 0; i--) { + if (events[i].event === "gate.surfaced" || events[i].event === "escalation.raised") { + lastSurface = i; + break; + } + } + if (lastSurface < 0) return null; + + // A gate/escalation STOPS the driver — nothing legitimately logs after it until the + // human resolves it and the run resumes. So ANY event after the surface means the run is + // alive again and we're no longer waiting. (Earlier we whitelisted only gate.*/phase.start/ + // handoff, which missed resume signals like `reasoning`, leaving the banner stuck.) + if (lastSurface < events.length - 1) return null; + + const surface = events[lastSurface]; + const md = (surface.metadata || {}) as Record; + const role = surface.role; + return { + variety: surface.event === "escalation.raised" ? "escalation" : "gate", + gate: (md.gate as string) ?? null, + role: ROLE_SET.has(role) ? (role as Role) : null, + story: (md.story as string) ?? null, + message: surface.message ?? null, + ts: surface.timestamp, + }; +} + + +// Build the design-lane bar (propose→estimate→breakdown→design→reflect) from history. +// A phase is `complete` once a strictly-later phase has run; the newest design-lane +// phase.start is `in-progress`/`current`. reflect loops back into design, so when the +// active phase is design or reflect after at least one reflect has occurred, both are +// marked `looping`. Once the run is past design (lane=build/complete) all are complete. +export function computeDesignPhases(events: AgentLogEvent[], lane: string): DesignPhase[] { + const order: DesignPhaseName[] = [...DESIGN_PHASE_NAMES]; + const rank = new Map(order.map((p, i) => [p, i] as const)); + + let maxRank = -1; // furthest phase reached + let currentPhase: DesignPhaseName | null = null; + let reflectSeen = false; + for (const e of events) { + if (e.event !== "phase.start") continue; + const ph = (e.metadata as Record | undefined)?.phase as DesignPhaseName | undefined; + if (!ph || !rank.has(ph)) continue; + currentPhase = ph; // last design-lane phase to start + maxRank = Math.max(maxRank, rank.get(ph)!); + if (ph === "reflect") reflectSeen = true; + } + + const designDone = lane === "build" || lane === "complete"; + const activeLoop = !designDone && reflectSeen && (currentPhase === "design" || currentPhase === "reflect"); + + return order.map((name) => { + const r = rank.get(name)!; + let status: DesignPhase["status"]; + if (designDone) status = "complete"; + else if (currentPhase === name) status = "in-progress"; + else if (r < maxRank) status = "complete"; + else status = "not-started"; + return { + name, + status, + current: !designDone && currentPhase === name, + looping: activeLoop && (name === "design" || name === "reflect"), + }; + }); +} + +// Map a raw Consort story status to a coarse lifecycle bucket + whether it's mid-design. +// "design" bucket splits into actively-designing vs design-complete (ready = gate approved, +// queued to build) so the UI can show a filled design step even before build starts. +export function storyStage(status: string): "design" | "build" | "done" { + if (status === "done" || status === "discarded") return "done"; + if (status === "building" || status === "awaiting-acceptance") return "build"; + return "design"; // designing | awaiting-gate | ready +} +export function designComplete(status: string): boolean { + // "ready" means the story cleared its design/spec gate and is queued for build. + return status === "ready" || status === "building" || status === "awaiting-acceptance" || status === "done"; +} + +// Per-story lifecycle for the sub-progress row: authoritative status from feature-status, +// plus the story's current design-lane phase (last design/reflect phase.start seen for it). +export function computeStories( + events: AgentLogEvent[], + statusStories: { + story_id: string; + status: string; + gate_status?: string | null; + accepted?: boolean; + feature_id?: string | null; + }[], + /** + * The feature the CLI is reporting on (`FeatureStatus.feature_id`). It reports one feature + * at a time, so this is the authoritative owner of every story in `statusStories`; the + * log-derived feature is only the fallback when the caller doesn't supply it. + */ + statusFeature?: string | null, +): StoryProgress[] { + const rank = new Set(DESIGN_PHASE_NAMES); + const lastDesignPhase: Record = {}; + let activeStory: string | null = null; + // The feature in force at the live edge, so CLI-reported stories can be stamped with it. + // The CLI reports only the ACTIVE feature's stories, so one value is enough here — unlike + // storiesFromLog, which spans a whole multi-feature run. + let currentFeature: string | null = null; + for (const e of events) { + const f = featureIdOf(e); + if (f) currentFeature = f; + if (e.event === "phase.start") { + const md = (e.metadata || {}) as Record; + const st = md.story as string | undefined; + const ph = md.phase as string | undefined; + if (st) activeStory = st; + if (st && ph && rank.has(ph)) lastDesignPhase[st] = ph as DesignPhaseName; + } else if (e.event === "handoff") { + const md = (e.metadata || {}) as Record; + if (md.story) activeStory = md.story as string; + } + } + // The status CLI can lag the log: in the stockflow run it reports S1 as `ready` (a DESIGN + // bucket) long after the log recorded cycle.review, cycle.refactored and verify.passed for + // it — so the board drew a finished story as still designing. next.json disagrees too + // (`awaiting-acceptance`). Rather than trust one source blindly, take whichever evidence is + // furthest along: the log cannot un-happen, so a story the log has verified is done. + // Keyed by (feature, story) so a multi-feature run can't match sprint 1's finished S1 + // against sprint 2's fresh S1 and reconcile a brand-new story straight to "done". + const fromLog = new Map(storiesFromLog(events).map((s) => [storyKey(s.feature, s.id), s])); + + return statusStories.map((s) => { + // The CLI's own feature_id wins when it reports one (per-story, else the report's own + // feature); otherwise the story belongs to the feature the log is currently on. + const feature = s.feature_id ?? statusFeature ?? currentFeature; + const logged = fromLog.get(storyKey(feature, s.story_id)); + // Advance the status when the log proves the story got further than the CLI admits. + const status = + logged && stageRank(logged.stage) > stageRank(storyStage(s.status)) ? logged.status : s.status; + const stage = storyStage(status); + return { + id: s.story_id, + feature, + status, + stage, + designComplete: designComplete(status), + designPhase: stage === "design" && !designComplete(status) ? lastDesignPhase[s.story_id] ?? null : null, + gateApproved: s.gate_status === "approved" || (logged?.gateApproved ?? false), + active: s.story_id === activeStory && status !== "done", + }; + }); +} + +// design < build < done, for comparing how far two sources think a story has got. +function stageRank(stage: "design" | "build" | "done"): number { + return stage === "done" ? 2 : stage === "build" ? 1 : 0; +} + + +// --- feature + story identity ------------------------------------------------- +// +// A story is identified by (feature, id), never by id alone: ids are only unique within a +// feature. Both real logs confirm the hazard — the stockflow-rerecord corpus runs two +// sprints whose stories are both numbered S1/S2/S3. + +/** + * The composite key for a story. Exported so the reducer and tests agree on one spelling. + * + * The separator is escaped rather than merely chosen. Left unescaped, `("F1/x", "y")` and + * `("F1", "x/y")` collide into one key, which would silently merge two stories' progress — + * the same class of bug as the bare-story-id keying this replaced, just rarer. + * + * Measured: no feature or story id in either real log contains a `/` (0 of 9 across the + * 421-event corpus and the 380-event live log), so this is defensive, not a fix for observed + * data. It is 2 lines and cannot regress, which is a better trade than a comment promising to + * revisit — ids come from log metadata and nothing constrains their shape. + */ +export function storyKey(feature: string | null, story: string): string { + // `~` first, so the escapes it introduces aren't re-escaped. The unknown-feature sentinel is + // `~2` rather than a bare `?` for the same reason the separator is escaped at all: `?` is a + // legal feature id, so a bare sentinel would let a feature literally named "?" merge with + // the unknown bucket that `storiesFromLog`'s rekey path builds and looks up. + const esc = (s: string) => s.replace(/~/g, "~0").replace(/\//g, "~1"); + return `${feature === null ? "~2" : esc(feature)}/${esc(story)}`; +} + +/** + * The feature_id an event names, or null. + * + * `reasoning` events are excluded because their feature_id is unreliable: in the corpus + * three of them carry a STORY id ("S3-sku-detail-view") or a truncated "F1", and in the + * stockflow log one does too. Every other event type is clean in both logs (0 bogus of 400+), + * so the rule is structural — skip the one event type that lies — rather than a guess at + * whether a value looks feature-shaped. `reasoning` carries no state the fold needs, so + * ignoring its feature_id costs nothing. + */ +export function featureIdOf(e: AgentLogEvent): string | null { + if (e.event === "reasoning") return null; + const f = (e.metadata as Record | undefined)?.feature_id; + return typeof f === "string" && f ? f : null; +} + +// --- log-derived story + gate state (for scrubbed-back views) ---------------- +// +// computeStories/gates above take their authority from disk (feature-status + next.json), +// which describes NOW and cannot rewind. But stories and gates ARE knowable from the log: +// a story id first appears in event metadata, and gate.surfaced/gate.approved carry the +// gate name. So when the board is scrubbed back we derive them here instead of showing +// current values under a historical playhead. +// +// Test COUNTS are the genuine exception and are not derivable: the log carries only the +// handful of test_ids that had a cycle.* event (4 in the stockflow run) while the test list +// totals 29. There is no honest historical number, so the UI omits the bar rather than +// inventing one — see BuildLane's `unavailable` branch. + +// Story lifecycle reconstructed from the log prefix. A story exists only once the log has +// mentioned it, which is why a scrubbed-back board can legitimately show zero stories. +// Blockers reconstructed from the log prefix. +// +// next.json's `state.blockers` describes NOW, so it survived a scrub to event 0 — the board +// showed a GREEN-verify failure before the run had written a line of code. The log carries +// the same information: escalation.raised has `source` and `story`, the exact fields +// next.json exposes. +// +// An escalation STOPS the driver, so it is only outstanding while it is the last thing that +// happened; any later event means the human resolved it and work resumed. That is the same +// rule findPendingGate uses, applied per-escalation. +// @param feature scope to one feature (carried forward, since not every event stamps a +// feature_id); omit for the whole run. Only used to keep a divergent FeatureSwitcher pin +// honest — a blocker on the ACTIVE feature must not appear under a pinned PAST one. +export function blockersFromLog( + events: AgentLogEvent[], + feature?: string, +): { source: string; reason: string; story: string | null }[] { + const open: { source: string; reason: string; story: string | null }[] = []; + let currentFeature: string | null = null; + for (let i = 0; i < events.length; i++) { + const e = events[i]; + const f = featureIdOf(e); + if (f) currentFeature = f; + if (e.event !== "escalation.raised") continue; + const md = (e.metadata || {}) as Record; + // Resolved if anything at all follows it in the folded window. + if (i < events.length - 1) continue; + if (feature !== undefined && currentFeature !== feature) continue; // out of scope + open.push({ + source: typeof md.source === "string" ? md.source : e.role, + reason: e.message || "escalation raised", + story: typeof md.story === "string" ? md.story : null, + }); + } + return open; +} + +export function storiesFromLog(events: AgentLogEvent[]): StoryProgress[] { + // Keyed by `feature/story`, not by story id. Story ids repeat across features — the + // stockflow-rerecord corpus ships two sprints whose stories are both S1/S2/S3 — so a bare + // id collapsed six distinct stories into three and carried sprint 1's "done" onto sprint 2. + const order: string[] = []; // composite keys, in first-seen order + const featureOf = new Map(); + const idOf = new Map(); + const lastDesignPhase: Record = {}; + const designPhases = new Set(DESIGN_PHASE_NAMES); + const specApproved = new Set(); + const awaitingGate = new Set(); + const building = new Set(); + const accepted = new Set(); + let activeKey: string | null = null; + // The feature in force, carried forward: not every event that names a story also stamps a + // feature_id, so a story would otherwise land under a null feature mid-sprint. + let currentFeature: string | null = null; + + const evidence = [specApproved, awaitingGate, building, accepted]; + + /** + * Move everything recorded under `from` onto `to`. Used when a story was first seen before + * the log stamped a feature_id and the feature resolves later: the composite key embeds the + * feature, so without this the same story would occupy two keys — two UI rows with divergent + * stages, double-counted in storiesTotal. The unknown-feature key is the one that yields. + */ + const rekey = (from: string, to: string, story: string, feature: string | null) => { + order[order.indexOf(from)] = to; + featureOf.delete(from); + idOf.delete(from); + featureOf.set(to, feature); + idOf.set(to, story); + if (lastDesignPhase[from] !== undefined) { + lastDesignPhase[to] = lastDesignPhase[from]; + delete lastDesignPhase[from]; + } + for (const set of evidence) { + if (set.delete(from)) set.add(to); + } + if (activeKey === from) activeKey = to; + }; + + const note = (story: unknown, feature: string | null): string | null => { + if (typeof story !== "string" || !story) return null; + const key = storyKey(feature, story); + if (order.includes(key)) return key; + // The same story already seen while its feature was unknown: adopt the resolved feature + // rather than starting a second row, carrying the earlier evidence across. + const unknownKey = storyKey(null, story); + if (feature !== null && order.includes(unknownKey)) { + rekey(unknownKey, key, story, feature); + return key; + } + order.push(key); + featureOf.set(key, feature); + idOf.set(key, story); + return key; + }; + + for (const e of events) { + const md = (e.metadata || {}) as Record; + const feature = featureIdOf(e); + if (feature) currentFeature = feature; + const key = note(md.story, currentFeature); + if (key) activeKey = key; + + const phase = typeof md.phase === "string" ? md.phase : null; + if (e.event === "phase.start" && key && phase && designPhases.has(phase)) { + lastDesignPhase[key] = phase as DesignPhaseName; + } + // A story is building once a build-lane phase runs for it. + if (key && phase && ["red", "green", "refactor", "review", "repair", "assess"].includes(phase)) { + building.add(key); + } + if (e.event.startsWith("cycle.") && key) building.add(key); + // Its spec gate clearing moves it out of design. Both signals are honoured because the + // two real logs disagree: `stockflow` only ever surfaces the spec gate (approval happens + // out-of-band and is never logged), while the stockflow-rerecord corpus DOES log + // `gate.approved`/spec right after surfacing it. So an explicit approval is used when + // present, and build work starting remains the fallback evidence that the gate cleared. + if (e.event === "gate.approved" && key && md.gate === "spec") specApproved.add(key); + if (e.event === "gate.surfaced" && key && md.gate === "spec") awaitingGate.add(key); + // verify.passed is the story's completion signal (acceptance approval isn't logged). + if (e.event === "verify.passed" && key) accepted.add(key); + if (e.event === "gate.approved" && key && md.gate === "acceptance") accepted.add(key); + } + + return order.map((key) => { + // building implies the spec gate cleared, whether or not an approval was ever logged. + const status = accepted.has(key) + ? "done" + : building.has(key) + ? "building" + : specApproved.has(key) + ? "ready" + : awaitingGate.has(key) + ? "awaiting-gate" + : "designing"; + const stage = storyStage(status); + return { + id: idOf.get(key)!, + feature: featureOf.get(key) ?? null, + status, + stage, + designComplete: designComplete(status), + designPhase: stage === "design" && !designComplete(status) ? lastDesignPhase[key] ?? null : null, + gateApproved: specApproved.has(key), + active: key === activeKey && status !== "done", + }; + }); +} + +/** + * Every feature the folded window has touched, in first-seen order — the FeatureSwitcher's list. + * + * `done` is driven by the same signal `reduceAgents` uses for `runEnded`: `phase.end`/`workflow` + * fires once PER FEATURE (events 213 and 420 in the stockflow-rerecord corpus), carrying that + * feature's `feature_id`. `active` here is provisional (last feature seen); the reducer + * re-derives it against `playheadFeature` so it agrees with the board's `feature` even when + * next.json's feature differs from the last log-stamped one. `reasoning` events are skipped via + * `featureIdOf`, whose feature_id is unreliable (it would otherwise name a story as a feature). + * + * `done` is CLEARED when a feature's work resumes, mirroring `runEnded`'s reset — a feature is + * done only if its most recent feature-stamped event is the workflow-end, not merely if one ever + * fired. Both real logs never resume a feature after its end (0 F1 events after event 213), so + * this is defensive and byte-identical today; it keeps the flag from lying if a corpus ever + * re-opens a sprint, which would otherwise force lane='complete' on live work via `pinnedDone`. + */ +export function featuresFromLog(events: AgentLogEvent[]): FeatureSummary[] { + const order: string[] = []; + const done = new Set(); + let last: string | null = null; + for (const e of events) { + const f = featureIdOf(e); + if (f) { + if (!order.includes(f)) order.push(f); + last = f; + const md = (e.metadata || {}) as Record; + // Clear first, then set, so the workflow-end event (which carries this feature_id) leaves + // the feature done, while any LATER stamped event for it reopens the feature. + done.delete(f); + if (e.event === "phase.end" && md.phase === "workflow") done.add(f); + } + } + return order.map((id) => ({ id, done: done.has(id), active: id === last })); +} + +// Gate state reconstructed from the log prefix: surfaced → open, then approved. +// +// @param feature scope to one feature (carried forward); omit for the whole run. Only used for +// a divergent FeatureSwitcher pin, so the OTHER feature's open gate does not surface under the +// pinned one. Gate events carry feature_id in the corpus (30 of 32); a gate event that somehow +// lacks one inherits the feature in force, which is the right owner for an unstamped approval. +export function gatesFromLog(events: AgentLogEvent[], feature?: string): GateInfo[] { + const state = new Map(); + let currentFeature: string | null = null; + for (const e of events) { + const f = featureIdOf(e); + if (f) currentFeature = f; + const md = (e.metadata || {}) as Record; + const gate = typeof md.gate === "string" ? md.gate : null; + if (!gate) continue; + if (feature !== undefined && currentFeature !== feature) continue; // out of scope + if (e.event === "gate.surfaced") state.set(gate, state.get(gate) === "approved" ? "approved" : "open"); + else if (e.event === "gate.approved") state.set(gate, "approved"); + } + return [...state.entries()].map(([name, status]) => ({ name, status })); +} + +export function reduceAgents(events: AgentLogEvent[]): { agents: AgentState[]; onDeck: string | null; totalCost: number; runEnded: boolean } { + const agents: Record = {}; + for (const r of ROLES) { + agents[r] = { role: r, status: "idle", work: null, phase: null, story: null, model: null, cost: 0, turns: 0, lastTs: null, issues: [], turnStartTs: null, sessionActive: null }; + } + const openTurns: Record = {}; + let onDeck: string | null = null; + let runEnded = false; + + // Consort's orchestrator is SEQUENTIAL — it drives one role at a time. So when a role becomes + // active (dispatched via handoff, or its own phase.start), every OTHER role that still looks + // "working" has actually finished; its turn just never got a closing event. Close every open + // turn except the now-active role. + // + // For MOST roles in a LIVE run the closing event is turn.usage, so their turns are already + // shut and this only matters for replays — a REPLAY never spawns the model, emits NO + // turn.usage, and design/build roles emit no phase.end either, so without this they stay + // pinned "working" until the terminal phase.end/workflow, showing ghost concurrency (e.g. + // navigator "working" while release-engineer promotes). But note it is NOT a strict no-op on + // live runs: some roles emit no turn.usage even live (product-owner only ever emits + // intake.supplied / phase.start / gate.approved), so this is what closes their turn in both + // modes. That is correct — a product-owner whose gate the run has moved past IS idle. + const closeOtherTurns = (activeRole: string | null) => { + for (const r of Object.keys(openTurns)) { + if (r === activeRole) continue; + delete openTurns[r]; + if (agents[r] && agents[r].status === "working") { + agents[r].status = "idle"; + agents[r].turnStartTs = null; + } + } + }; + + for (const e of events) { + const md = (e.metadata || {}) as Record; + const a = ROLE_SET.has(e.role) ? agents[e.role] : null; + if (a) a.lastTs = e.timestamp; + + // The workflow-terminal event: the orchestrator emits phase.end with phase "workflow" + // as the run's very last event. The last roles to run never get a closing turn event + // and there's no later handoff/phase.start to clear them, so without this they stay in + // openTurns and the finalize step pins them "working" forever (a completed run must show + // calm bubbles, not 8 spinners). Clear every open turn + any dangling on-deck now. + // A new PHASE STARTING means the workflow is going again, so an earlier END is no longer + // the last word. This matters on multi-feature runs: `phase.end`/`workflow` fires once PER + // FEATURE (twice in the stockflow-rerecord corpus, at 213 and 420), and while the flag was + // sticky, sprint 1 finishing retired the board for the remaining 200 events — lane frozen + // at "complete" and every bubble calm while sprint 2 was still designing and building. + // + // A handoff counts only when it DISPATCHES INTO A PHASE. Every handoff in both real logs + // carries one (71/71 in the corpus, 89/89 in stockflow) — event 214 is sprint 2's genuine + // dispatch into `author-requests`, so ignoring handoffs outright would leave the board + // "complete" through the start of sprint 2. But treating a bare handoff as a resume let a + // single trailing wind-down handoff revive a shipped run, reinstating the exact + // "Build · in progress" / spinning-bubble bug that motivated `runEnded`. Requiring the + // phase keeps both: work being dispatched resumes the run, mere role-naming does not. + if (e.event === "phase.start" || (e.event === "handoff" && md.phase)) runEnded = false; + + if (e.event === "phase.end" && md.phase === "workflow") { + runEnded = true; + for (const r of Object.keys(openTurns)) { + delete openTurns[r]; + if (agents[r] && agents[r].status === "working") { + agents[r].status = "idle"; + agents[r].turnStartTs = null; + } + } + onDeck = null; + } + + if (e.event === "handoff") { + const toRole = (md.to_role as string) ?? null; + onDeck = toRole ?? onDeck; + // Dispatching the next role proves any other still-"working" role has finished (see + // closeOtherTurns) — including the orchestrator, which code-emits phase.start but never a + // closing turn.usage/phase.end and would otherwise look "working" forever. The incoming + // role isn't working yet (it's on-deck until its own phase.start), so exclude it. + // Guard on toRole: a handoff with no to_role must NOT pass null here, or closeOtherTurns + // would idle every open turn including the genuinely-active role. + if (toRole) closeOtherTurns(toRole); + } + + if (e.event === "phase.start" && a) { + // the dispatched role has started — it's no longer merely "on deck" + if (onDeck === e.role) onDeck = null; + // A role starting proves the previous one finished (sequential orchestrator). Not every + // handoff precedes a phase.start in a replay log (e.g. navigator's cycle.review flows + // straight into release-engineer's phase.start with no handoff between), so close other + // open turns here too. See closeOtherTurns for why this matters mostly, but not only, to + // replays. + closeOtherTurns(e.role); + openTurns[e.role] = true; + a.status = "working"; + a.phase = (md.phase as string) ?? null; + a.story = (md.story as string) ?? null; + a.model = e.model ?? a.model; + a.work = e.message ?? (md.phase as string) ?? "working"; + a.turnStartTs = e.timestamp; // when this still-open turn began (for "working for Nm") + // Starting a new phase means any issue this role previously flagged has been resolved + // (the run moved on). Issues are otherwise append-only, which would pin a role red on a + // long-since-resolved escalation. A genuinely-open issue is one with no later phase.start + // for its role — it survives because nothing clears it. Mirrors findPendingGate's + // "any later activity = resolved" rule, applied per role. + a.issues = []; + } + + if (TURN_END.has(e.event) && a) { + delete openTurns[e.role]; + if (a.status === "working") a.status = "idle"; + a.turnStartTs = null; // turn closed — no longer an open, in-progress turn + if (e.event === "turn.usage") { + a.cost += Number(md.cost_usd || 0); + a.turns += 1; + } + } + + if ((e.event.startsWith("cycle.") || e.event === "progress" || e.event === "artifact.written") && a && openTurns[e.role]) { + a.work = e.message ?? a.work; + } + + if (ISSUE_EVENTS.has(e.event) && a) { + a.issues.push({ + event: e.event, + detail: String(md.detail ?? md.note ?? md.reason ?? e.message ?? ""), + story: (md.story as string) ?? null, + }); + } + } + + // finalize: open turns win; a dispatched-but-not-started role is on-deck. Skip entirely + // once the run has ended — a completed workflow leaves nothing working or on-deck. + if (!runEnded) { + for (const r of Object.keys(openTurns)) if (agents[r]) agents[r].status = "working"; + if (onDeck && agents[onDeck] && agents[onDeck].status === "idle") agents[onDeck].status = "on-deck"; + } + + const totalCost = Object.values(agents).reduce((s, a) => s + a.cost, 0); + // runEnded is exported because it is the log's own statement that the workflow finished — + // more trustworthy than a `derived_phase` snapshot, which can sit at "build" indefinitely + // after the run is over (it does in the stockflow run). + return { agents: Object.values(agents), onDeck, totalCost, runEnded }; +} diff --git a/apps/dashboard/lib/filekind.ts b/apps/dashboard/lib/filekind.ts new file mode 100644 index 00000000..7c6beb11 --- /dev/null +++ b/apps/dashboard/lib/filekind.ts @@ -0,0 +1,71 @@ +// File-kind rules shared by both sources: what counts as code vs. a process artifact, which +// extensions are text, and the size cap for embedding a file in a response. Ported from Kevin's +// build_dashboard.py:47-56; lived in replay.ts until the live HEAD-artifact reader needed the +// same rules, at which point duplicating them (or importing replay into live) was the wrong +// trade. One definition here, so the two readers can never disagree about "is this text?". + +import { readFileSync, statSync } from "node:fs"; + +// The caps keep a 1.5 MB payload from becoming a 50 MB one, and avoid embedding lock files. +export const MAX_FILE_BYTES = 64 * 1024; +export const SKIP_FILE_NAMES = new Set(["uv.lock"]); +export const TEXT_EXTS = new Set([ + ".py", ".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt", ".yaml", + ".yml", ".toml", ".ini", ".cfg", ".html", ".css", ".sql", ".feature", + ".env", ".sh", ".gitignore", +]); +const CODE_EXTS = new Set([".py", ".ts", ".tsx", ".js", ".jsx", ".sql", ".css", ".html"]); +const CODE_DIR_PREFIXES = ["app/", "client/src", "client/tests", "alembic/", "tests/", "scripts/"]; + +/** `.ext` of a path's basename, or "" when it has none. Shared so the rules can't drift. */ +export function extOf(path: string): string { + const name = path.split("/").pop() ?? path; + const i = name.lastIndexOf("."); + return i === -1 ? "" : name.slice(i); +} + +/** + * Is a path code, or a process artifact? + * + * The artifact-root rule is not cosmetic: a `.consort/features/…/test-list.json` is the workflow's + * own bookkeeping, and showing it in a "code produced" view would drown the actual diff. All the + * root names Consort has used are matched — v0.3.7 renamed `.sftdd/` → `.consort/` but recorded + * corpora and legacy projects still carry the old prefixes, so a corpus must classify the same way + * whichever root its logged paths were captured under. + */ +const ARTIFACT_ROOT_PREFIXES = [".consort/", ".sftdd/", ".tdd/"]; + +export function classify(path: string): "code" | "artifact" { + if (ARTIFACT_ROOT_PREFIXES.some((p) => path.startsWith(p))) return "artifact"; + const isCode = CODE_DIR_PREFIXES.some((p) => path.startsWith(p)) || CODE_EXTS.has(extOf(path)); + return isCode ? "code" : "artifact"; +} + +/** + * Read an ALREADY-CONTAINED absolute path as text, applying the skip/size/binary guards, and + * report why when it can't. `rel` is passed only for its basename and extension (the skip-list + * and text-ext checks); `abs` is the resolveContained() output that the read actually uses, so + * the thing that was security-checked is the thing that gets read. + */ +export function readTextFile(abs: string, rel: string): { content: string | null; reason: string | null } { + const name = rel.split("/").pop() ?? rel; + if (SKIP_FILE_NAMES.has(name)) return { content: null, reason: "(skipped: lock file)" }; + + let size: number; + try { + const st = statSync(abs); + if (!st.isFile()) return { content: null, reason: "(not a file)" }; + size = st.size; + } catch { + return { content: null, reason: "(unreadable)" }; + } + if (size > MAX_FILE_BYTES) return { content: null, reason: `(too large to embed: ${size} bytes)` }; + + const ext = extOf(rel); + if (ext && !TEXT_EXTS.has(ext)) return { content: null, reason: `(binary/non-text: ${ext})` }; + try { + return { content: readFileSync(abs, "utf8"), reason: null }; + } catch { + return { content: null, reason: "(unreadable)" }; + } +} diff --git a/apps/dashboard/lib/planning.test.ts b/apps/dashboard/lib/planning.test.ts new file mode 100644 index 00000000..b22e6ce9 --- /dev/null +++ b/apps/dashboard/lib/planning.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadPlanning, parseProposals } from "./planning"; +import type { AgentLogEvent } from "./types"; + +// --------------------------------------------------------------------------- +// Pure parser — no corpus needed. The two real feature-proposals.md files disagree on body-label +// spelling (see planning.ts), so both are exercised here directly. + +describe("parseProposals — both real label spellings", () => { + // The REPLAY corpus format: `## FP1: title`, bulleted `- **Ask:**`, `- **E2E (UI) story:**`. + const replayFormat = `# Sprint candidates + +## FP1: File and view stock for a SKU at a location + +- **Ask:** As a warehouse worker, I can file a stock record. +- **Rationale:** The floor of everything. +- **E2E (UI) story:** YES. Empty state to a filed row. +- **Priority:** P0 (sprint-1 foundation). + +## FP2: Adjust a stock level in place + +- **Ask:** I can adjust the quantity. +- **Rationale:** Second most common action. + +## Open questions for the Product Owner + +- Should we support batch edits? +`; + + it("parses the replay format, skipping the prose section", () => { + const out = parseProposals(replayFormat); + expect(out.map((p) => p.id)).toEqual(["FP1", "FP2"]); // "Open questions" is not a feature + expect(out[0]).toEqual({ + id: "FP1", + title: "File and view stock for a SKU at a location", + ask: "As a warehouse worker, I can file a stock record.", + rationale: "The floor of everything.", + e2e: "YES. Empty state to a filed row.", + }); + expect(out[1].ask).toBe("I can adjust the quantity."); + expect(out[1].e2e).toBe(""); // FP2 recorded none + }); + + // The LIVE stockflow format: `**One-line ask:**`, `**E2E story:**`, no leading bullet. + const liveFormat = `## FP1: List current stock levels + +**One-line ask:** Display the current inventory in a table. +**Rationale:** Simple table read. +**E2E story:** YES. +**Priority:** P0. +`; + + it("parses the live format's alternate label spellings", () => { + const out = parseProposals(liveFormat); + expect(out).toHaveLength(1); + expect(out[0].ask).toBe("Display the current inventory in a table."); + expect(out[0].e2e).toBe("YES."); + expect(out[0].rationale).toBe("Simple table read."); + }); + + it("accepts the (candidate) tag and a committed F# header", () => { + const out = parseProposals("## PF1 (candidate) do a thing\n## F1-stock-visibility ships it\n"); + expect(out.map((p) => p.id)).toEqual(["PF1", "F1-stock-visibility"]); + expect(out[0].title).toBe("do a thing"); + expect(out[1].title).toBe("ships it"); + }); + + it("returns nothing for a doc with no feature headers", () => { + expect(parseProposals("# Title\n\nsome prose\n\n## Notes\n\nmore prose")).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Integration against the real recorded corpus. Skipped on a checkout without it. + +// v0.3.7 relocated the corpus from examples/sftdd-scenarios/ to examples/replay/corpora/; try new then legacy. +const CORPUS = [ + process.env.CONSORT_TEST_CORPUS_DIR, + join(process.env.HOME ?? "", ".claude/plugins/marketplaces/databricks-solutions/examples/replay/corpora/stockflow-rerecord"), + join(process.env.HOME ?? "", ".claude/plugins/marketplaces/databricks-solutions/examples/sftdd-scenarios/stockflow-rerecord"), +].filter((p): p is string => !!p).find((p) => existsSync(join(p, "recorded-artifacts", "planning", "estimates.json"))); + +function proposeEvent(): AgentLogEvent { + return { timestamp: "2026-08-01T00:00:00.000Z", level: "info", role: "spec-author", event: "phase.start", message: "", metadata: { phase: "propose" } }; +} + +describe.skipIf(!CORPUS)("loadPlanning — the real stockflow-rerecord corpus", () => { + const root = join(CORPUS!, "recorded-artifacts"); + + it("joins proposals with estimates, in proposal order, with committed flags", () => { + const p = loadPlanning([root], [proposeEvent()]); + // FP1..FP5 from the proposals doc, then the committed F1/F6 (estimate-only, no FP entry). + const fps = p.candidates.filter((c) => c.id.startsWith("FP")); + expect(fps.map((c) => c.id)).toEqual(["FP1", "FP2", "FP3", "FP4", "FP5"]); + // The both-format fix: asks/titles are non-empty on the replay corpus (a verbatim port left them blank). + expect(fps[0].title).toBeTruthy(); + expect(fps[0].ask).toBeTruthy(); + expect(fps[0].size).toBe("M"); // FP1 is sized M in estimates.json + // The committed features surface, flagged committed. + const committedIds = p.candidates.filter((c) => c.committed).map((c) => c.id).sort(); + expect(committedIds).toEqual(["F1-stock-visibility", "F6-split-tracking-code"]); + expect(p.committed).toEqual(["F1-stock-visibility", "F6-split-tracking-code"]); + }); + + it("reads both sprints, their plan gate, and resolves committed feature titles", () => { + const p = loadPlanning([root], [proposeEvent()]); + expect(p.sprints.map((s) => s.sprint)).toEqual(["stockflow-rerecord-s1", "stockflow-rerecord-s2"]); + const s1 = p.sprints[0]; + expect(s1.featureIds).toEqual(["F1-stock-visibility"]); + expect(s1.planGate).toBe("approved"); + expect(s1.approver).toBeTruthy(); + // feature_details resolves the F1 title from its feature-spec.json / feature-request.md. + expect(s1.features[0].id).toBe("F1-stock-visibility"); + expect(s1.features[0].title).toBeTruthy(); + expect(s1.features[0].size).toBe("M"); + }); + + it("flags the second sprint as a re-plan when only one propose round ran", () => { + // The corpus has exactly one spec-author propose phase feeding both sprints, so sprint 2 is + // a re-plan, not a fresh proposal. + const p = loadPlanning([root], [proposeEvent()]); + expect(p.proposeRounds).toBe(1); + expect(p.sprints[0].isReplan).toBe(false); + expect(p.sprints[1].isReplan).toBe(true); + }); + + it("does not flag a re-plan when multiple propose rounds ran", () => { + // Two genuine proposal rounds → neither sprint is a re-plan. + const p = loadPlanning([root], [proposeEvent(), proposeEvent()]); + expect(p.proposeRounds).toBe(2); + expect(p.sprints.every((s) => !s.isReplan)).toBe(true); + }); + + it("returns empty structures for a root with no planning artifacts", () => { + const p = loadPlanning(["/nonexistent-root-xyz"]); + expect(p.candidates).toEqual([]); + expect(p.sprints).toEqual([]); + expect(p.committed).toEqual([]); + expect(p.proposeRounds).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Review-finding regressions, on a synthetic root so they need no corpus. + +describe("loadPlanning — synthetic root (review-finding regressions)", () => { + let root: string; + const sprint = (name: string, features: { id: string; size?: string }[], gate?: string) => { + const dir = join(root, "sprints", name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "backlog.json"), JSON.stringify({ sprint: name, features })); + if (gate) writeFileSync(join(dir, "gates.json"), JSON.stringify({ gates: { plan: { status: gate } } })); + }; + const feature = (id: string, requestBody: string) => { + const dir = join(root, "features", id); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "feature-request.md"), requestBody); + }; + + const build = () => { + root = mkdtempSync(join(tmpdir(), "planning-syn-")); + }; + afterEach(() => root && rmSync(root, { recursive: true, force: true })); + + it("finding 1: does NOT flag a re-plan when the log shows zero propose rounds", () => { + build(); + sprint("run-s1", [{ id: "F1" }], "approved"); + sprint("run-s2", [{ id: "F2" }], "approved"); + // No propose events passed (truncated / omitted log). proposeRounds === 0 is UNKNOWN, not + // one, so a re-plan must not be asserted — the old `<= 1` wrongly stamped s2. + const p = loadPlanning([root]); + expect(p.proposeRounds).toBe(0); + expect(p.sprints.every((s) => !s.isReplan)).toBe(true); + // ...and exactly one propose round DOES flag the later sprint. + const withOne = loadPlanning([root], [proposeEvent()]); + expect(withOne.sprints.map((s) => s.isReplan)).toEqual([false, true]); + }); + + it("finding 3: orders sprints numerically, so s10 comes after s2", () => { + build(); + for (const n of [1, 2, 10, 11, 3]) sprint(`run-s${n}`, [{ id: `F${n}` }]); + const p = loadPlanning([root]); + expect(p.sprints.map((s) => s.sprint)).toEqual([ + "run-s1", "run-s2", "run-s3", "run-s10", "run-s11", + ]); + }); + + it("finding 5: picks the first PROSE line as a summary, skipping markup", () => { + build(); + // Title heading, then a bullet, a blockquote, a table row, and a rule — none are the summary. + feature("F1", [ + "# Feature One", + "", + "- a bullet, not prose", + "> a blockquote", + "| col | col |", + "---", + "The real one-line summary of the feature.", + "More detail after.", + ].join("\n")); + sprint("run-s1", [{ id: "F1" }], "approved"); + const p = loadPlanning([root]); + expect(p.sprints[0].features[0].title).toBe("Feature One"); + expect(p.sprints[0].features[0].summary).toBe("The real one-line summary of the feature."); + }); +}); diff --git a/apps/dashboard/lib/planning.ts b/apps/dashboard/lib/planning.ts new file mode 100644 index 00000000..d7b45104 --- /dev/null +++ b/apps/dashboard/lib/planning.ts @@ -0,0 +1,280 @@ +// Planning / backlog parsing — ported from Kevin's build_dashboard.py +// (`parse_proposals`, `feature_details`, `load_planning`), the one parser set the Phase 2 port +// left behind. Pure string/JSON work over a set of on-disk artifacts, so it lives here and is +// unit-tested against the real files; the source layer supplies the two roots to search. +// +// The plan calls this "port parse_proposals", but a verbatim port would MISPARSE the replay +// corpus. Kevin's regex was written against the LIVE stockflow format and never run against his +// own recorded corpus — the two `feature-proposals.md` files disagree: +// +// live stockflow/.sftdd/planning/feature-proposals.md +// `## FP1: File and view…` body: `**One-line ask:**` `**E2E story:**` +// replay recorded-artifacts/planning/feature-proposals.md +// `## FP1: File and view…` body: `- **Ask:**` `- **E2E (UI) story:**` +// +// Ported verbatim, the ask/rationale/e2e come out EMPTY on the replay demo — exactly the +// Kevin-parser-vs-real-data gap this project keeps hitting (see the memory). So the header regex +// and the body-label matcher below accept both spellings, verified against both real files. + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { + AgentLogEvent, + Planning, + PlanningCandidate, + PlanningSprint, + SprintFeature, +} from "./types"; + +/** A parsed proposal from feature-proposals.md, before joining with estimates. */ +export interface Proposal { + id: string; + title: string; + ask: string; + rationale: string; + e2e: string; +} + +/** + * Parse feature-proposals.md into an ordered list of candidate features. + * + * Headers look like `## FP1: File and view stock…` (both real files) — Kevin's docstring also + * allowed `## PF1 (candidate) …`, kept here. Non-feature sections (`## Open questions for the + * Product Owner`) are skipped: an id must start with letters followed by a digit. + * + * Body labels are matched in BOTH spellings the two real files use: + * ask — `**Ask:**` (replay) or `**One-line ask:**` (live) + * rationale — `**Rationale:**` + * e2e — `**E2E (UI) story:**` (replay) or `**E2E story:**` (live) + * A leading `- ` (the replay file bullets its labels) is tolerated before the `**`. + */ +export function parseProposals(md: string): Proposal[] { + const out: Proposal[] = []; + let cur: Proposal | null = null; + for (const line of md.split("\n")) { + if (line.startsWith("## ")) { + // Drop a trailing "FP1:" colon and an optional "(candidate)" tag, then split id / title. + const head = line.slice(3).trim(); + const m = head.match(/^([A-Za-z]+\d+[\w-]*)\s*:?\s*(?:\(candidate\))?\s*(.*)$/); + const fid = m ? m[1] : head; + const title = m ? m[2].trim() : ""; + // Skip prose sections like "Open questions…": a real id is letters then a digit. + if (!/^[A-Za-z]+\d/.test(fid)) { + cur = null; + continue; + } + cur = { id: fid, title, ask: "", rationale: "", e2e: "" }; + out.push(cur); + } else if (cur) { + // Strip a leading bullet so `- **Ask:** …` matches the same as `**Ask:** …`. + const s = line.trim().replace(/^-\s+/, ""); + const label = s.match(/^\*\*([^*]+):\*\*\s*(.*)$/); + if (!label) continue; + const key = label[1].trim().toLowerCase(); + const value = label[2].trim(); + if (key === "ask" || key === "one-line ask") cur.ask = value; + else if (key === "rationale") cur.rationale = value; + else if (key === "e2e story" || key === "e2e (ui) story") cur.e2e = value; + } + } + return out; +} + +/** JSON read that never throws — a malformed artifact yields null, not a 500. */ +function readJson(path: string): T | null { + try { + return JSON.parse(readFileSync(path, "utf8")) as T; + } catch { + return null; + } +} + +/** + * Title + summary for a committed feature, from its feature dir under one of `roots`. + * + * Committed features (F1, F6, …) are NOT in the FP proposal pool; their detail lives under + * `/features//`: the `name` from feature-spec.json and the first prose line of + * feature-request.md (its `# ` heading is the title fallback). First root that has either wins. + */ +export function featureDetails(roots: string[], fid: string): { title: string; summary: string } { + let title = ""; + let summary = ""; + for (const root of roots) { + const fdir = join(root, "features", fid); + const spec = readJson<{ name?: string; title?: string }>(join(fdir, "feature-spec.json")); + if (spec) title = spec.name || spec.title || title; + + const reqPath = join(fdir, "feature-request.md"); + if (existsSync(reqPath)) { + try { + const lines = readFileSync(reqPath, "utf8").split("\n").map((l) => l.replace(/\s+$/, "")); + if (!title) { + const h = lines.find((l) => l.startsWith("# ")); + if (h) title = h.slice(2).trim(); + } + // First real PROSE line, not merely the first non-heading. A feature-request.md can open + // with a bullet, a blockquote, a table row, or a `---` front-matter/rule — none of which + // read as a summary. Skip those markup-leading lines and take the first plain sentence. + const isProse = (l: string): boolean => { + const s = l.trim(); + if (!s || s.startsWith("#")) return false; // blank or heading + if (/^[-*+]\s/.test(s) || /^\d+[.)]\s/.test(s)) return false; // bullet / numbered list + if (s.startsWith(">")) return false; // blockquote + if (s.startsWith("|")) return false; // table row + if (/^[-=]{3,}$/.test(s) || s === "---") return false; // rule / front-matter fence + return true; + }; + const firstProse = lines.find(isProse); + if (firstProse) summary = firstProse.trim(); + } catch { + // leave title/summary as-is + } + } + if (title || summary) break; + } + return { title, summary }; +} + +/** + * Gather planning: t-shirt estimates, proposals, sprint backlog, and the plan gate. + * + * `roots` are searched in order (live `.sftdd` first, then recorded-artifacts) — the same + * freshest-wins fallback Kevin used, but taking the roots as an argument so the caller owns + * where the data is. This also sidesteps his dead `cap_dir = LOG_PATH.parent.parent` + * (build_dashboard.py:491), which resolved to a directory with no `.sftdd/` and only worked + * because `load_planning` fell back to recorded-artifacts anyway. + * + * `logEvents` is optional and used only to count spec-author `propose` rounds, which drives the + * re-plan flag. + */ +export function loadPlanning(roots: string[], logEvents?: AgentLogEvent[]): Planning { + const find = (rel: string): string | null => { + for (const r of roots) { + const p = join(r, rel); + if (existsSync(p)) return p; + } + return null; + }; + + // Estimates: feature_id → { size, rationale }. + const estimates = new Map(); + const ep = find("planning/estimates.json"); + if (ep) { + const j = readJson<{ estimates?: { feature_id: string; size?: string; rationale?: string }[] }>(ep); + for (const e of j?.estimates ?? []) { + estimates.set(e.feature_id, { size: e.size ?? null, rationale: e.rationale ?? "" }); + } + } + + // Proposals, in document order. + let proposals: Proposal[] = []; + const pp = find("planning/feature-proposals.md"); + if (pp) { + try { + proposals = parseProposals(readFileSync(pp, "utf8")); + } catch { + proposals = []; + } + } + + // Sprints: one dir per sprint under /sprints//. First root that HAS a sprints + // dir wins (live over recorded), matching Kevin's `break`. + const sprints: PlanningSprint[] = []; + const committed = new Set(); + for (const r of roots) { + const sdir = join(r, "sprints"); + if (!existsSync(sdir)) continue; + // Deterministic, chronological order. A plain `.sort()` is lexical, so `…-s10` and `…-s11` + // would sort BEFORE `…-s2` once a run reaches ten sprints — which also mis-derives the + // re-plan flag below (it keys on index order). `numeric` collation keeps s2 < s10. + let names: string[]; + try { + names = readdirSync(sdir).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + } catch { + continue; + } + for (const name of names) { + const sp = join(sdir, name); + const backlog = readJson<{ sprint?: string; features?: { id: string; size?: string }[] }>( + join(sp, "backlog.json"), + ); + if (!backlog) continue; // no backlog.json → not a sprint dir + const gatesJson = readJson<{ gates?: { plan?: { status?: string; approver?: string; approved_at?: string } } }>( + join(sp, "gates.json"), + ); + const gate = gatesJson?.gates?.plan ?? null; + + const ids = (backlog.features ?? []).map((f) => f.id); + for (const id of ids) committed.add(id); + + const features: SprintFeature[] = (backlog.features ?? []).map((f) => { + const det = featureDetails(roots, f.id); + const est = estimates.get(f.id); + return { + id: f.id, + title: det.title, + summary: det.summary, + // The backlog rarely carries a size (F# aren't in the FP estimate pool); prefer it, fall back to the estimate. + size: f.size ?? est?.size ?? null, + rationale: est?.rationale ?? "", + }; + }); + + sprints.push({ + sprint: backlog.sprint ?? name, + featureIds: ids, + features, + planGate: gate?.status ?? null, + approver: gate?.approver ?? null, + approvedAt: gate?.approved_at ?? null, + isReplan: false, // set below, once propose rounds are counted + }); + } + break; // first root with sprints wins + } + + // Count spec-author `propose` rounds. One round feeding multiple sprints means every sprint + // after the first is a re-plan, not a fresh proposal. + let proposeRounds = 0; + for (const e of logEvents ?? []) { + const md = (e.metadata || {}) as Record; + if (e.event === "phase.start" && e.role === "spec-author" && md.phase === "propose") proposeRounds++; + } + + // Candidate list = proposals joined with their estimate, in proposal order; then any + // estimate-only ids that weren't proposed (so a sized-but-undocumented feature still shows). + const candidates: PlanningCandidate[] = []; + const seen = new Set(); + for (const p of proposals) { + const est = estimates.get(p.id); + candidates.push({ + id: p.id, + title: p.title, + ask: p.ask, + size: est?.size ?? null, + rationale: est?.rationale || p.rationale, + committed: committed.has(p.id), + }); + seen.add(p.id); + } + for (const [fid, est] of estimates) { + if (seen.has(fid)) continue; + candidates.push({ id: fid, title: "", ask: "", size: est.size, rationale: est.rationale, committed: committed.has(fid) }); + } + + // A later sprint is a re-plan only when we can SEE that a single proposal round fed all of + // them. `=== 1`, not `<= 1`: zero propose rounds means the log was truncated, not passed, or + // predates logging — that is unknown, not "one round", and must not stamp a re-plan we can't + // support. (Kevin's `<= 1` never bit because his log always had the one round; a truncated or + // omitted log would have made every later sprint claim re-plan.) + for (let i = 0; i < sprints.length; i++) { + sprints[i].isReplan = i > 0 && proposeRounds === 1; + } + + return { + sprints, + candidates, + committed: [...committed].sort(), + proposeRounds, + }; +} diff --git a/apps/dashboard/lib/reducer.test.ts b/apps/dashboard/lib/reducer.test.ts new file mode 100644 index 00000000..aade0588 --- /dev/null +++ b/apps/dashboard/lib/reducer.test.ts @@ -0,0 +1,1157 @@ +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fold, emptyState } from "./reducer"; +import { blockersFromLog, storiesFromLog, storyKey } from "./derive"; +import type { AgentLogEvent, SnapshotInputs } from "./types"; + +function ev( + event: string, + metadata: Record = {}, + extra: Partial = {}, +): AgentLogEvent { + return { + timestamp: extra.timestamp ?? "2026-07-31T20:00:00.000Z", + level: "info", + role: extra.role ?? "orchestrator", + event, + message: extra.message ?? "", + metadata, + }; +} + +// No-op snapshot: the fold's disk half supplies nothing, so these tests exercise purely +// the event-derived half. `sessionAgeMs: Infinity` means "no session activity". +function snap(over: Partial = {}): SnapshotInputs { + return { + projectDir: "/tmp/proj", + next: null, + status: null, + handbacks: [], + sessionAgeMs: Infinity, + pendingPermission: null, + generatedAt: "2026-08-05T00:00:00.000Z", + ...over, + }; +} + +// A small synthetic run: two roles taking a turn each, with costs. +const RUN: AgentLogEvent[] = [ + ev("phase.start", { phase: "propose", feature_id: "F1" }, { role: "spec-author" }), + ev("turn.usage", { cost_usd: 1.5, phase: "propose" }, { role: "spec-author" }), + ev("handoff", { to_role: "architect-reviewer" }, { role: "spec-author" }), + ev("phase.start", { phase: "estimate" }, { role: "architect-reviewer" }), + ev("turn.usage", { cost_usd: 2.25, phase: "estimate" }, { role: "architect-reviewer" }), +]; + +// The graph-lighting half of the fold. Derived server-side because `recentEvents` is only a +// 40-event tail while the graph needs the whole prefix to know what a run has reached. +describe("fold — topology", () => { + it("reports reached nodes, the active node, and lane sub-steps", () => { + const t = fold(RUN, snap()).topology; + expect(t.passedNodes).toContain("plan"); + // last event carrying a phase is `estimate` → plan + expect(t.activeNode).toBe("plan"); + expect(t.laneSteps.plan).toEqual(["p-propose", "p-size"]); + expect(t.laneCurrent).toEqual({ lane: "plan", step: "p-size" }); + expect(t.atTimestamp).toBe(RUN[RUN.length - 1].timestamp); + }); + + it("rewinds with the scrub position — it is pure timeline data", () => { + const t = fold(RUN, snap(), 2).topology; + expect(t.laneSteps.plan).toEqual(["p-propose"]); + expect(t.laneSteps.design).toEqual([]); + expect(t.atTimestamp).toBe(RUN[1].timestamp); + }); + + it("is empty at the start of the log", () => { + const t = fold(RUN, snap(), 0).topology; + expect(t.passedNodes).toEqual([]); + expect(t.activeNode).toBeNull(); + expect(t.laneCurrent).toBeNull(); + expect(t.atTimestamp).toBeNull(); + expect(t.laneSteps).toEqual({ plan: [], design: [], build: [] }); + }); + + it("advances the active node as the run moves between lanes", () => { + const run = [ + ...RUN, + ev("phase.start", { phase: "design" }, { role: "dba" }), + ev("phase.start", { phase: "green" }, { role: "driver" }), + ]; + expect(fold(run, snap(), 6).topology.activeNode).toBe("design"); + const t = fold(run, snap()).topology; + expect(t.activeNode).toBe("build"); + expect(t.passedNodes).toEqual(expect.arrayContaining(["plan", "design", "build"])); + }); + + it("does not treat a closing phase.end as an active node", () => { + // Regression: the real stockflow log ends with phase.end/`workflow`, which maps to plan. + // Reading that as the active node made a FINISHED run render as "active in Plan". + const run = [ + ev("phase.start", { phase: "promote" }, { role: "release-engineer" }), + ev("phase.end", { phase: "workflow" }, { role: "orchestrator" }), + ]; + expect(fold(run, snap()).topology.activeNode).toBeNull(); + // mid-run, the open phase still lights + expect(fold(run, snap(), 1).topology.activeNode).toBe("promote"); + // and phase.end doesn't erase what the run reached + expect(fold(run, snap()).topology.passedNodes).toEqual(expect.arrayContaining(["promote"])); + }); + + it("looks past trailing events that carry no phase", () => { + const run = [ + ev("phase.start", { phase: "green" }, { role: "driver" }), + ev("reasoning", {}, { role: "orchestrator" }), + ev("turn.usage", { cost_usd: 1 }, { role: "driver" }), + ]; + expect(fold(run, snap()).topology.activeNode).toBe("build"); + }); + + it("falls back to intake.supplied, which has no phase", () => { + const t = fold([ev("intake.supplied", {}, { role: "product-owner" })], snap()).topology; + expect(t.activeNode).toBe("intake"); + expect(t.passedNodes).toEqual(["intake"]); + }); + + it("emptyState carries a well-formed empty topology", () => { + const t = emptyState("/tmp/p", "2026-08-05T00:00:00.000Z").topology; + expect(t.passedNodes).toEqual([]); + expect(t.activeNode).toBeNull(); + expect(t.laneCurrent).toBeNull(); + expect(t.laneSteps).toEqual({ plan: [], design: [], build: [] }); + }); + + it("serializes over JSON without losing shape (Sets would not)", () => { + // The fold converts Sets to arrays precisely so /api/state can carry this. + const t = fold(RUN, snap()).topology; + expect(JSON.parse(JSON.stringify(t))).toEqual(t); + for (const v of t.passedNodes) expect(typeof v).toBe("string"); + }); +}); + +describe("fold — time-travel window", () => { + it("folds the whole log when upTo is omitted, and reports the live edge", () => { + const s = fold(RUN, snap()); + expect(s.eventCount).toBe(5); + expect(s.atEventIndex).toBe(5); + expect(s.totalEventCount).toBe(5); + expect(s.atLive).toBe(true); + }); + + it("folds only the first n events when scrubbed back", () => { + const s = fold(RUN, snap(), 2); + expect(s.eventCount).toBe(2); + expect(s.atEventIndex).toBe(2); + expect(s.totalEventCount).toBe(5); // total still reports the full log + expect(s.atLive).toBe(false); + }); + + it("does not leak future state into a scrubbed-back board", () => { + // At index 2 the architect has not started; only the spec-author has spent. + const s = fold(RUN, snap(), 2); + const arch = s.agents.find((a) => a.role === "architect-reviewer")!; + expect(arch.status).toBe("idle"); + expect(arch.turns).toBe(0); + expect(s.totalCost).toBeCloseTo(1.5); + }); + + it("clamps out-of-range and fractional indices instead of throwing", () => { + expect(fold(RUN, snap(), -5).atEventIndex).toBe(0); + expect(fold(RUN, snap(), 999).atEventIndex).toBe(5); + expect(fold(RUN, snap(), 999).atLive).toBe(true); + expect(fold(RUN, snap(), 2.7).atEventIndex).toBe(2); + }); + + it("upTo === length is the live edge (identical to omitting it)", () => { + expect(fold(RUN, snap(), RUN.length)).toEqual(fold(RUN, snap())); + }); + + it("folds an empty log to a zero state that is still 'live'", () => { + const s = fold([], snap()); + expect(s.eventCount).toBe(0); + expect(s.atLive).toBe(true); + expect(s.totalCost).toBe(0); + }); +}); + +describe("fold — purity and monotonicity", () => { + it("is pure: same inputs yield deeply equal output", () => { + expect(fold(RUN, snap(), 3)).toEqual(fold(RUN, snap(), 3)); + }); + + it("does not mutate the events array it is given", () => { + const copy = JSON.parse(JSON.stringify(RUN)); + fold(RUN, snap(), 3); + expect(RUN).toEqual(copy); + }); + + it("cumulative measures never decrease as the window grows", () => { + let prevCost = -1; + let prevEvents = -1; + let prevTurns = -1; + for (let i = 0; i <= RUN.length; i++) { + const s = fold(RUN, snap(), i); + const turns = s.agents.reduce((n, a) => n + a.turns, 0); + expect(s.totalCost).toBeGreaterThanOrEqual(prevCost); + expect(s.eventCount).toBeGreaterThanOrEqual(prevEvents); + expect(turns).toBeGreaterThanOrEqual(prevTurns); + prevCost = s.totalCost; + prevEvents = s.eventCount; + prevTurns = turns; + } + }); +}); + +// The §3a rule, as corrected 2026-08-05. The ORIGINAL reading — "snapshot data always +// describes now, so show current values and label them" — turned out to be wrong in the +// only place it mattered: a viewer scrubbed to event 12 saw "design complete · 2 stories +// done" for a run that was still in `breakdown` with no stories yet, under a badge reading +// "not historical". Labelling a wrong number doesn't make it right. +// +// The corrected rule: derive from the log whatever the log CAN support (gates, stories, +// lane), and for the one thing it genuinely can't — test COUNTS — report +// testsHistorical:false so the UI omits the bar instead of showing a current or zeroed one. +describe("fold — scrubbed-back state is reconstructed from the log, not the snapshot", () => { + const withStatus = snap({ + status: { + feature_id: "F1", + derived_phase: "build", + stories: [{ story_id: "S1", status: "done", accepted: true }], + test_list: { total: 10, by_status: { green: 4, red: 1, pending: 5 }, completion_pct: 40 }, + gates: { spec: { status: "approved" } }, + }, + next: { feature: "F1", generated_at: "2026-08-05T12:00:00.000Z" }, + }); + + it("does not carry current stories/gates back to an early playhead", () => { + const live = fold(RUN, withStatus); + const back = fold(RUN, withStatus, 1); + // live still trusts the snapshot + expect(live.stories.map((s) => s.id)).toEqual(["S1"]); + expect(live.gates).toEqual([{ name: "spec", status: "approved" }]); + // RUN's first event mentions no story and no gate, so at event 1 neither exists yet + expect(back.stories).toEqual([]); + expect(back.gates).toEqual([]); + expect(back.progress.storiesTotal).toBe(0); + expect(back.progress.storiesDone).toBe(0); + }); + + it("reports test counts as unavailable rather than wrong when scrubbed", () => { + const live = fold(RUN, withStatus); + expect(live.progress.testsHistorical).toBe(true); + expect(live.progress.testTotal).toBe(10); + + const back = fold(RUN, withStatus, 1); + expect(back.progress.testsHistorical).toBe(false); + // zeroed, and flagged — the UI must omit the bar, not render 0/0 as if no tests existed + expect(back.progress.testTotal).toBe(0); + expect(back.progress.testPct).toBe(0); + expect(back.progress.testByStatus).toEqual({ pending: 0, red: 0, green: 0, refactored: 0, skipped: 0 }); + }); + + it("takes the lane from the playhead, not from derived_phase", () => { + // The snapshot says `build`, but event 1 is a `propose` phase.start — still planning. + expect(fold(RUN, withStatus).lane).toBe("build"); + expect(fold(RUN, withStatus, 1).lane).toBe("design"); + }); + + it("does not mark the design lane complete while the run is still designing", () => { + // Regression for the reported bug: at an early playhead every design phase showed + // `complete` because computeDesignPhases takes the snapshot-derived lane. + const back = fold(RUN, withStatus, 1); + const done = back.designPhases.filter((p) => p.status === "complete").map((p) => p.name); + expect(done).not.toContain("design"); + expect(done).not.toContain("reflect"); + }); + + it("still exposes snapshotAsOf, which now describes only the live edge", () => { + expect(fold(RUN, withStatus, 1).snapshotAsOf).toBe("2026-08-05T12:00:00.000Z"); + }); + + it("falls back to the log at the live edge when there is no snapshot on disk", () => { + // Regression: gating purely on atLive made the LIVE view show zero stories whenever the + // status CLI produced nothing, even though the log named them — leaving the live board + // worse informed than a scrubbed one. + const storyRun: AgentLogEvent[] = [ + ev("phase.start", { phase: "design", story: "S1" }, { role: "spec-author" }), + ev("gate.surfaced", { gate: "spec", story: "S1" }, { role: "orchestrator" }), + ]; + const s = fold(storyRun, snap()); // live, no status on disk + expect(s.atLive).toBe(true); + expect(s.stories.map((x) => x.id)).toEqual(["S1"]); + expect(s.gates).toEqual([{ name: "spec", status: "open" }]); + }); + + it("exposes snapshotAsOf + atLive so the UI can label stale panels", () => { + const back = fold(RUN, withStatus, 1); + expect(back.atLive).toBe(false); + // next.json's generated_at wins as the as-of stamp. + expect(back.snapshotAsOf).toBe("2026-08-05T12:00:00.000Z"); + }); + + it("has no snapshotAsOf when there is no snapshot at all", () => { + expect(fold(RUN, snap()).snapshotAsOf).toBeNull(); + }); +}); + +describe("fold — liveness only applies at the live edge", () => { + // An open turn (phase.start with no closing turn.usage) => the role is "working". + const open: AgentLogEvent[] = [ev("phase.start", { phase: "design" }, { role: "dba" })]; + + it("marks a working agent live when a session wrote recently", () => { + const s = fold(open, snap({ sessionAgeMs: 1000 })); + expect(s.agents.find((a) => a.role === "dba")!.sessionActive).toBe(true); + }); + + it("marks it not-live when the session has gone quiet", () => { + const s = fold(open, snap({ sessionAgeMs: 60_000 })); + expect(s.agents.find((a) => a.role === "dba")!.sessionActive).toBe(false); + }); + + it("leaves sessionActive null when scrubbed back — a past turn is not 'live now'", () => { + const s = fold([...open, ev("turn.usage", { cost_usd: 1 }, { role: "dba" })], snap({ sessionAgeMs: 1000 }), 1); + expect(s.atLive).toBe(false); + expect(s.agents.find((a) => a.role === "dba")!.sessionActive).toBeNull(); + }); +}); + +describe("fold — a role's turn closes on the next dispatch (replay/no-token runs)", () => { + // REPLAY runs never spawn the model, so they emit NO turn.usage — the event that + // otherwise closes a role's turn. Design/build roles also never emit phase.end (only + // orchestrator + release-engineer do). Before this fix, that left every design/build role + // pinned "working" for the whole mid-run window (only the terminal phase.end/workflow + // eventually cleared them), so a live viewer saw ghost concurrency — e.g. navigator+driver + // "working" while release-engineer promotes. The log always has a `handoff` between roles; + // the orchestrator dispatching the NEXT role proves the previous role's turn is over. + + it("closes an open turn when the orchestrator hands off to another role", () => { + const evs: AgentLogEvent[] = [ + ev("phase.start", { phase: "estimate" }, { role: "architect-reviewer" }), + // no turn.usage (replay) — then the orchestrator dispatches the next role: + ev("handoff", { to_role: "dba", phase: "db-design" }, { role: "orchestrator" }), + ev("phase.start", { phase: "db-design" }, { role: "dba" }), + ]; + const s = fold(evs, snap()); + // architect-reviewer finished before dba started — it must not still be "working". + expect(s.agents.find((a) => a.role === "architect-reviewer")!.status).toBe("idle"); + // dba is the one genuinely working now. + expect(s.agents.find((a) => a.role === "dba")!.status).toBe("working"); + }); + + it("does not close the incoming role's own turn on its dispatch handoff", () => { + // The handoff names to_role=dba and is immediately followed by dba's phase.start; + // dba must end up working, not cleared. + const evs: AgentLogEvent[] = [ + ev("handoff", { to_role: "dba", phase: "db-design" }, { role: "orchestrator" }), + ev("phase.start", { phase: "db-design" }, { role: "dba" }), + ]; + const s = fold(evs, snap()); + expect(s.agents.find((a) => a.role === "dba")!.status).toBe("working"); + }); + + it("mid-run: only the currently-dispatched role is working, not finished ones (replay fixture)", () => { + // The real cold-run replay log: at a mid-run slice, design/build roles that have handed + // off must be idle, not ghost-working. + const evs = readReplay(); + // Slice to just after release-engineer has started PROMOTE (nav/driver long done — including + // their cycle reruns). Must match phase === "promote", not the first RE phase.start, which is + // an earlier `deploy` at the end of TDD cycle 1 and would leave the nav/driver reruns unseen. + const promoteStart = evs.findIndex( + (e) => e.role === "release-engineer" && e.event === "phase.start" && (e.metadata as Record)?.phase === "promote", + ); + expect(promoteStart).toBeGreaterThan(0); + const s = fold(evs.slice(0, promoteStart + 1), snap()); + const status = (r: string) => s.agents.find((a) => a.role === r)!.status; + // The ghosts from the screenshot — all finished before promote: + for (const r of ["spec-author", "ux-designer", "architect-reviewer", "dba", "test-strategist", "navigator", "driver"]) { + expect(status(r), `${r} should be idle mid-promote`).not.toBe("working"); + } + // release-engineer is the one actually working. + expect(status("release-engineer")).toBe("working"); + }); + + it("the replay log genuinely emits no turn.usage (guards the premise of this fix)", () => { + const evs = readReplay(); + expect(evs.some((e) => e.event === "turn.usage")).toBe(false); + }); + + it("a handoff with no to_role does not idle the genuinely-active role", () => { + // Defensive: closeOtherTurns(null) would clear EVERY open turn. A to_role-less handoff must + // be a no-op for turn-closing, leaving the working role working. + const evs: AgentLogEvent[] = [ + ev("phase.start", { phase: "db-design" }, { role: "dba" }), + ev("handoff", {}, { role: "orchestrator" }), // no to_role + ]; + const s = fold(evs, snap()); + expect(s.agents.find((a) => a.role === "dba")!.status).toBe("working"); + }); + + it("closes a role that emits no turn.usage even in a LIVE run (product-owner)", () => { + // Not a replay-only concern: product-owner emits only intake.supplied/phase.start/ + // gate.approved — never turn.usage — so the next dispatch is what closes its turn, live too. + const evs: AgentLogEvent[] = [ + ev("phase.start", { phase: "author-requests" }, { role: "product-owner" }), + ev("gate.approved", { gate: "plan" }, { role: "product-owner" }), + ev("handoff", { to_role: "spec-author", phase: "propose" }, { role: "orchestrator" }), + ev("phase.start", { phase: "propose" }, { role: "spec-author" }), + ]; + const s = fold(evs, snap()); + expect(s.agents.find((a) => a.role === "product-owner")!.status).toBe("idle"); + expect(s.agents.find((a) => a.role === "spec-author")!.status).toBe("working"); + }); +}); + +describe("fold — blockers and resolver routing", () => { + it("routes a blocker to the handback role and flips that agent to issue", () => { + const s = fold( + RUN, + snap({ + next: { feature: "F1", state: { blockers: [{ source: "unknown-thing", reason: "boom", story: "S1" }] } }, + handbacks: [{ role: "dba", story: "S1" }], + }), + ); + expect(s.blockers[0].resolverRole).toBe("dba"); + expect(s.agents.find((a) => a.role === "dba")!.status).toBe("issue"); + }); + + it("falls back to a keyword guess when no handback matches", () => { + const s = fold( + RUN, + snap({ next: { feature: "F1", state: { blockers: [{ source: "driver-green", reason: "boom" }] } } }), + ); + expect(s.blockers[0].resolverRole).toBe("driver"); + }); +}); + +describe("storyKey — a story is (feature, id), unambiguously", () => { + // A story id is only unique WITHIN a feature (the corpus's two sprints both use S1/S2/S3), + // so the key is composite. These pin that no two distinct pairs can share a key — a + // collision silently merges two stories' progress, which is the bug class that keying by + // bare story id was. + it("keeps distinct (feature, story) pairs distinct even when they contain the separator", () => { + expect(storyKey("F1/x", "y")).not.toBe(storyKey("F1", "x/y")); + expect(storyKey("F1", "S1")).toBe(storyKey("F1", "S1")); // and is stable + }); + + it("does not let a feature named '?' collide with the unknown-feature bucket", () => { + // The sentinel shares a namespace with real feature ids, so it is escaped too. `?` is a + // legal id, and storiesFromLog's rekey path builds and looks up the unknown key directly. + expect(storyKey(null, "S1")).not.toBe(storyKey("?", "S1")); + }); + + it("escapes the escape character, so escaping can't be forged", () => { + // Without escaping `~` first, a literal "~1" in an id would decode as the separator. + expect(storyKey("F~1x", "y")).not.toBe(storyKey("F", "x/y")); + expect(storyKey("F~2", "S1")).not.toBe(storyKey(null, "S1")); + }); + + it("is unchanged for the ordinary ids both real logs actually contain", () => { + // Measured: no feature or story id in either log contains `/` or `~`, so the escaping is + // defensive and must not alter the keys in use. + expect(storyKey("F1-stock-visibility", "S1-file-stock")).toBe("F1-stock-visibility/S1-file-stock"); + }); +}); + +describe("emptyState", () => { + it("is a not-ok board with every role idle and zero cost", () => { + const s = emptyState("/tmp/proj", "2026-08-05T00:00:00.000Z"); + expect(s.ok).toBe(false); + expect(s.totalCost).toBe(0); + expect(s.agents.every((a) => a.status === "idle")).toBe(true); + expect(s.atLive).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Golden fold against the REAL log from the stockflow run in this lab. Guards the +// refactor against drift on production-shaped data (many roles, gates, escalations, +// deploys) rather than only hand-built fixtures. Skipped if the log isn't present, so +// the suite stays green on a clean checkout. +// v0.3.7 renames the artifact root .sftdd → .consort and auto-migrates old projects on next run, +// so resolve the current root first and fall back to the legacy one. +const STOCKFLOW = join(process.env.HOME || "", "Code/consort-lab/stockflow"); +const REAL_LOG = + [".consort", ".sftdd", ".tdd"] + .map((r) => join(STOCKFLOW, r, "agent-log.jsonl")) + .find((p) => existsSync(p)) ?? join(STOCKFLOW, ".consort", "agent-log.jsonl"); + +function readReal(): AgentLogEvent[] { + return readFileSync(REAL_LOG, "utf8") + .split("\n") + .filter((l) => l.trim()) + .flatMap((l) => { + try { + return [JSON.parse(l) as AgentLogEvent]; + } catch { + return []; + } + }); +} + +// The bug as actually reported, against the real log: "slider at 12/380 and status shows +// design complete and 2 stories complete". Event 12 is mid-`breakdown` — design hasn't +// started and no story has been mentioned yet. +describe.skipIf(!existsSync(REAL_LOG))("fold — the reported scrub-back bug, on the real log", () => { + it("at event 12: no stories, no gates, design not started", () => { + const s = fold(readReal(), snap(), 12); + expect(s.stories).toEqual([]); + expect(s.progress.storiesDone).toBe(0); + expect(s.gates).toEqual([]); + expect(s.lane).toBe("design"); + + const byName = new Map(s.designPhases.map((p) => [p.name, p.status])); + expect(byName.get("breakdown")).toBe("in-progress"); + expect(byName.get("design")).toBe("not-started"); + expect(byName.get("reflect")).toBe("not-started"); + expect(s.progress.testsHistorical).toBe(false); + }); + + it("stories appear only once the log first mentions them", () => { + const events = readReal(); + // S1 first appears at event 21, S2 at 205, S3 at 227. + expect(fold(events, snap(), 20).stories).toEqual([]); + expect(fold(events, snap(), 25).stories.map((s) => s.id)).toEqual(["S1-record-stock"]); + expect(fold(events, snap(), 210).stories.length).toBe(2); + expect(fold(events, snap(), 300).stories.length).toBe(3); + }); + + it("story count is monotonic and never exceeds the live count", () => { + const events = readReal(); + const liveCount = fold(events, snap()).stories.length; + let prev = 0; + for (let i = 0; i <= events.length; i += 20) { + const n = fold(events, snap(), i).stories.length; + expect(n).toBeGreaterThanOrEqual(prev); + expect(n).toBeLessThanOrEqual(Math.max(liveCount, n)); + prev = n; + } + }); + + it("keeps the lane in design while design is still running for a later story", () => { + // Event 210 is spec-author in `design` for S2 — an earlier reflect must not make this + // read as "build" (reflect maps to the build node in phaseToNode). + expect(fold(readReal(), snap(), 210).lane).toBe("design"); + }); + + it("does not carry current blockers back to a playhead before they existed", () => { + // next.json's blockers describe NOW, so a GREEN-verify failure showed at event 0 — + // before the run had written a line of code. + const events = readReal(); + const withBlocker = snap({ + next: { + feature: "F1", + state: { blockers: [{ source: "driver-green", reason: "GREEN verify failed", story: "S3-sku-detail-view" }] }, + }, + }); + expect(fold(events, withBlocker, 0).blockers).toEqual([]); + expect(fold(events, withBlocker, 12).blockers).toEqual([]); + // at the live edge next.json is still authoritative + expect(fold(events, withBlocker).blockers.length).toBe(1); + }); + + it("surfaces a log escalation only while it is unresolved", () => { + // #263 is escalation.raised for S2; work resumes at #266, which clears it. + const events = readReal(); + const atEscalation = fold(events, snap(), 264).blockers; + expect(atEscalation.length).toBe(1); + expect(atEscalation[0].source).toBe("driver-green"); + expect(atEscalation[0].story).toBe("S2-stock-home-screen"); + // resolver routing still works off log-derived blockers + expect(atEscalation[0].resolverRole).toBe("driver"); + // once the run moves on, it is no longer outstanding + expect(fold(events, snap(), 266).blockers).toEqual([]); + }); + + it("takes the feature from the log when scrubbed, not from next.json", () => { + // next.json names the feature being worked NOW; 200 events ago it may differ. + const events = readReal(); + const stale = snap({ next: { feature: "F9-some-later-feature" } }); + expect(fold(events, stale, 12).feature).toBe("F1-stock-visibility"); + expect(fold(events, stale).feature).toBe("F9-some-later-feature"); // live: snapshot wins + }); + + it("reports a finished run as complete, whatever derived_phase claims", () => { + // Reported: at 380/380 the Build bar still said "· in progress" with 13/29. The counts + // were right (16 tests genuinely never got written) but the run was over — promote was + // approved at #377 and phase.end/workflow landed at #379. next.json's derived_phase sits + // at "build" forever after, so trusting it kept the lane active on a shipped run. + const s = fold(readReal(), snap({ next: { feature: "F1", state: { derived_phase: "build" } } })); + expect(s.lane).toBe("complete"); + expect(s.phase).toBe("complete"); + // and nothing should be left working on a finished run + expect(s.agents.filter((a) => a.status === "working")).toEqual([]); + expect(s.agents.filter((a) => a.status === "on-deck")).toEqual([]); + }); + + it("tracks the lane forward through deploy and promote without falling back to design", () => { + // Regression: at event 372 the last folded event is phase.end/deploy, so nothing is + // active — and the lane fell through to "design" on a run that had just deployed. + const events = readReal(); + const laneAt = (n: number) => fold(events, snap(), n).lane; + expect(laneAt(370)).toBe("build"); // deploying + expect(laneAt(372)).toBe("build"); // deploy just closed; must NOT read as design + expect(laneAt(376)).toBe("complete"); // promote has started + expect(laneAt(380)).toBe("complete"); // run ended + }); + + it("at the live edge, a verified story is done even if the CLI says `ready`", () => { + // Reported: at 380/380 the board showed S1 still in design. The status CLI reports S1 as + // `ready` (a design-bucket status) although the log has cycle.review, cycle.refactored + // and verify.passed for it at #350/#355/#360, and next.json says awaiting-acceptance. + // The log can't un-happen, so the furthest-along evidence wins. + const events = readReal(); + const stale = snap({ + status: { + feature_id: "F1-stock-visibility", + derived_phase: "build", + stories: [ + { story_id: "S1-record-stock", status: "ready" }, + { story_id: "S2-stock-home-screen", status: "done", accepted: true }, + { story_id: "S3-sku-detail-view", status: "done", accepted: true }, + ], + }, + }); + const s1 = fold(events, stale).stories.find((x) => x.id === "S1-record-stock")!; + expect(s1.stage).toBe("done"); + expect(s1.active).toBe(false); + expect(fold(events, stale).progress.storiesDone).toBe(3); + }); + + it("never drags a story BACKWARDS from what the CLI reports", () => { + // The reconciliation is one-directional: it may only advance a story. If the CLI knows + // more than the log (acceptance approved out-of-band), that must stand. + const events = readReal(); + const ahead = snap({ + status: { + feature_id: "F1-stock-visibility", + derived_phase: "complete", + stories: [{ story_id: "S1-record-stock", status: "done", accepted: true }], + }, + }); + expect(fold(events, ahead).stories[0].status).toBe("done"); + }); + + it("a story only reaches done once verify.passed lands for it", () => { + const events = readReal(); + // S2's verify.passed is at event 289. + const before = fold(events, snap(), 289).stories.find((s) => s.id === "S2-stock-home-screen"); + const after = fold(events, snap(), 290).stories.find((s) => s.id === "S2-stock-home-screen"); + expect(before?.status).not.toBe("done"); + expect(after?.status).toBe("done"); + }); +}); + +// --------------------------------------------------------------------------- +// The MULTI-FEATURE corpus: stockflow-rerecord, 421 events, two sprints that each ship a +// feature end-to-end (F1-stock-visibility then F6-split-tracking-code). Vendored rather +// than read from the plugin cache because Kevin shipped it corpus-only in 6e73019 with no +// version bump, so the installed 0.3.6 cache does NOT contain it (same reasoning as the +// committed kevin-workflow.json: pin the upstream shape we derived against). +// +// Every earlier real-log test above uses `stockflow`, which is SINGLE-feature. That is +// exactly why flat story keying survived Phase 1 undetected: both features here number +// their stories S1/S2/S3, so a story id is only unique WITHIN a feature. +const CORPUS_LOG = join(__dirname, "__fixtures__/stockflow-rerecord-agent-log.jsonl"); + +function readCorpus(): AgentLogEvent[] { + return readFileSync(CORPUS_LOG, "utf8") + .split("\n") + .filter((l) => l.trim()) + .flatMap((l) => { + try { + return [JSON.parse(l) as AgentLogEvent]; + } catch { + return []; + } + }); +} + +// A real REPLAY run's log (captured from the cold-run F1 replay). Unlike the corpus fixture +// above — which is a recording of a real *live* run and so carries turn.usage — a replay never +// spawns the model, so it emits ZERO turn.usage. This is the fixture that exercises the +// no-token turn-closing path. +const REPLAY_LOG = join(__dirname, "__fixtures__/stockflow-f1-replay-agent-log.jsonl"); + +function readReplay(): AgentLogEvent[] { + return readFileSync(REPLAY_LOG, "utf8") + .split("\n") + .filter((l) => l.trim()) + .flatMap((l) => { + try { + return [JSON.parse(l) as AgentLogEvent]; + } catch { + return []; + } + }); +} + +describe("fold — multi-feature run (stockflow-rerecord corpus)", () => { + it("reads the vendored corpus and its provenance", () => { + const events = readCorpus(); + expect(events.length).toBe(421); + // The version anchor travels with the corpus, stamped on the first event's metadata. + const md = events[0].metadata as Record; + expect(md.kit_describe).toBe("v0.3.6"); + expect(md.kit_commit).toBe("cad5f5fb5eb7e59a703722284b6a5858ddf3fff0"); + }); + + it("keeps the two features' stories distinct", () => { + // THE BUG: story ids repeat across features (both sprints have S1/S2/S3), so keying by + // bare story id collapsed six real stories. Asserted on the raw derivation, which spans + // the whole run — the fold then scopes to one feature (see the next test). + const all = storiesFromLog(readCorpus()); + const keys = all.map((x) => `${x.feature}/${x.id}`); + expect(new Set(keys).size).toBe(6); + expect(keys).toEqual([ + "F1-stock-visibility/S1-file-stock", + "F1-stock-visibility/S2-stock-by-location-table", + "F1-stock-visibility/S3-sku-detail-view", + "F6-split-tracking-code/S1-split-columns-migration", + "F6-split-tracking-code/S2-reversible-down-migration", + "F6-split-tracking-code/S3-stock-shows-split-fields", + ]); + // Every one shipped, so a collapsed key would silently look "correct" at the live edge. + expect(all.every((s) => s.status === "done")).toBe(true); + }); + + it("shows only the active feature's stories, not every story ever run", () => { + // The user-visible bug at the sprint boundary: event 217 approves the F6 plan gate, so + // the board is now on sprint 2 — but the story list still showed three *completed* F1 + // stories with no sign a second feature had begun. Stories must be scoped to the + // feature the playhead is on. + const events = readCorpus(); + const at217 = fold(events, snap(), 217); + expect(at217.feature).toBe("F6-split-tracking-code"); + expect(at217.stories.map((s) => s.id)).toEqual([]); + expect(at217.progress.storiesTotal).toBe(0); + expect(at217.progress.storiesDone).toBe(0); + + // Mid-sprint-2: only F6's stories, and F6's S1 is its own story — not F1's S1 resurfacing. + const at300 = fold(events, snap(), 300); + expect(at300.feature).toBe("F6-split-tracking-code"); + expect(at300.stories.map((s) => s.id)).toEqual([ + "S1-split-columns-migration", + "S2-reversible-down-migration", + ]); + expect(at300.stories.every((s) => s.feature === "F6-split-tracking-code")).toBe(true); + }); + + it("scopes progress counts to the active feature", () => { + // At the end of sprint 1 the board should read 3/3 — not 3-of-6, and at the end of + // sprint 2 it should read 3/3 again rather than 6/6. + const events = readCorpus(); + const endOfSprint1 = fold(events, snap(), 212); + expect(endOfSprint1.feature).toBe("F1-stock-visibility"); + expect(endOfSprint1.progress.storiesTotal).toBe(3); + expect(endOfSprint1.progress.storiesDone).toBe(3); + + const live = fold(events, snap()); + expect(live.feature).toBe("F6-split-tracking-code"); + expect(live.progress.storiesTotal).toBe(3); + expect(live.progress.storiesDone).toBe(3); + }); + + it("ignores a feature_id that is really a story id", () => { + // Upstream data quality: three `reasoning` events from driver carry a story id (or a + // truncated "F1") in feature_id. Those must not invent a feature or re-scope the board. + const events = readCorpus(); + // Event 179 is one such event (feature_id="S3-sku-detail-view"); the run is on F1 there. + expect(fold(events, snap(), 180).feature).toBe("F1-stock-visibility"); + expect(fold(events, snap(), 116).feature).toBe("F1-stock-visibility"); + // And 378 sits just after feature_id="S3-stock-shows-split-fields" during sprint 2. + expect(fold(events, snap(), 378).feature).toBe("F6-split-tracking-code"); + }); + + it("does not treat the first sprint's end as the whole run ending", () => { + // Found by running the app, not by these tests: the board read "complete" from event 213 + // to the end of the log — through the whole of sprint 2. `phase.end`/`workflow` fires once + // PER FEATURE (213 ends F1, 420 ends F6), and runEnded was sticky, so sprint 1 finishing + // retired the entire board: every agent bubble went calm and the lane froze at complete + // while F6 was still designing and building. stockflow has exactly one such event, which + // is why this survived Phase 1. + const events = readCorpus(); + // Folding exactly TO sprint 1's end (213/214) legitimately reads complete: the slice ends + // on phase.end/workflow with nothing reopened yet. The bug was that it STAYED complete. + expect(fold(events, snap(), 215).lane).toBe("design"); // sprint 2's plan lane opens + expect(fold(events, snap(), 230).lane).toBe("design"); // F6 is designing + expect(fold(events, snap(), 260).lane).toBe("build"); // ...and building + expect(fold(events, snap(), 300).lane).not.toBe("complete"); + expect(fold(events, snap(), 380).lane).toBe("build"); + // The run really has ended at the log's end, and only there. + expect(fold(events, snap()).lane).toBe("complete"); + expect(fold(events, snap(), 421).lane).toBe("complete"); + }); + + it("stays complete when a trailing event follows the final workflow end", () => { + // The runEnded reset must not be so wide that one stray event revives a shipped run — + // that is the "Build · in progress" / spinning-bubbles bug PR #10 fixed. Both real logs + // end exactly on phase.end/workflow, but nothing guarantees a log has no trailing + // handoff, so assert the guard directly. A wind-down handoff names a role WITHOUT + // dispatching into a phase — unlike all 71 real handoffs in this corpus, which carry one. + const events = readCorpus(); + const trailingHandoff = [ + ...events, + ev("handoff", { to_role: "orchestrator", feature_id: "F6-split-tracking-code" }), + ]; + const s = fold(trailingHandoff, snap()); + expect(s.lane).toBe("complete"); + expect(s.agents.filter((a) => a.status === "working" || a.status === "on-deck")).toEqual([]); + + // ...but a real phase.start after the end DOES mean a new workflow began. + const trailingStart = [ + ...events, + ev("phase.start", { phase: "propose", feature_id: "F7-next" }, { role: "spec-author" }), + ]; + expect(fold(trailingStart, snap()).lane).toBe("design"); + }); + + it("puts agents back to work after the first sprint ends", () => { + // The same sticky flag also suppressed the working/on-deck finalize step, so a run in + // full flight showed eight idle bubbles. + const events = readCorpus(); + const mid = fold(events, snap(), 230); + expect(mid.agents.some((a) => a.status === "working" || a.status === "on-deck")).toBe(true); + // ...and a genuinely finished run still shows nothing working. + const end = fold(events, snap()); + expect(end.agents.filter((a) => a.status === "working" || a.status === "on-deck")).toEqual([]); + }); + + it("does not split a story that was named before its feature was stamped", () => { + // The composite key embeds the feature, so a story seen before any feature_id lands and + // again afterwards would be keyed twice (null/S1 and F1/S1) — one real story rendered as + // two rows with divergent stages, and double-counted in storiesTotal. Both real logs + // stamp a feature before the first story (event 11 vs 26 in the corpus), so nothing in + // them exercises this; it is a shape a design-lane-only log could easily have. + const events = [ + ev("phase.start", { phase: "design", story: "S1" }, { role: "spec-author" }), + ev("phase.start", { phase: "design", story: "S1", feature_id: "F1" }, { role: "spec-author" }), + ]; + const s = storiesFromLog(events); + expect(s.length).toBe(1); + expect(s[0].id).toBe("S1"); + expect(s[0].feature).toBe("F1"); // the resolved feature wins over the unknown one + + // Evidence recorded while the feature was unknown must survive the merge. + const withEvidence = [ + ev("phase.start", { phase: "design", story: "S2" }, { role: "spec-author" }), + ev("verify.passed", { story: "S2" }, { role: "driver" }), + ev("phase.start", { phase: "design", story: "S2", feature_id: "F1" }, { role: "spec-author" }), + ]; + const merged = storiesFromLog(withEvidence); + expect(merged.length).toBe(1); + expect(merged[0].feature).toBe("F1"); + expect(merged[0].status).toBe("done"); // verify.passed from the pre-feature window + + // And the fold reports one story, not two. + const folded = fold(events, snap()); + expect(folded.stories.length).toBe(1); + expect(folded.progress.storiesTotal).toBe(1); + }); + + it("does not inherit the previous sprint's graph progress", () => { + // The lifecycle graph and lane graphs light from topology.passedNodes / laneSteps, which + // described the whole RUN. At event 230 sprint 2 has only begun designing, yet the graph + // showed deploy+promote reached and all seven build sub-steps done — a shipped lifecycle + // for a feature that had not written a line of code. This is the bug LaneGraph would have + // rendered three times over, and it was already visible in WorkflowGraph. + const events = readCorpus(); + const at230 = fold(events, snap(), 230); + expect(at230.feature).toBe("F6-split-tracking-code"); + expect(at230.topology.passedNodes).not.toContain("deploy"); + expect(at230.topology.passedNodes).not.toContain("promote"); + expect(at230.topology.laneSteps.build).toEqual([]); + + // Sprint 1, at its end, legitimately HAS reached all of it. + const at212 = fold(events, snap(), 212); + expect(at212.feature).toBe("F1-stock-visibility"); + expect(at212.topology.passedNodes).toContain("promote"); + expect(at212.topology.laneSteps.build.length).toBeGreaterThan(0); + + // And by the end of sprint 2, F6 has genuinely built and shipped on its own evidence. + const live = fold(events, snap()); + expect(live.topology.passedNodes).toContain("promote"); + expect(live.topology.laneSteps.build.length).toBeGreaterThan(0); + }); + + it("attributes sprint-1 planning to no feature, because the log stamps none yet", () => { + // A real consequence of scoping, documented rather than papered over. The corpus opens + // with propose/estimate/author-requests carrying feature_id: "" (events 3-10) — the + // planning lane runs BEFORE a feature exists to attribute it to, which is honest: it is + // deciding *what* the feature will be. Only from `breakdown` (event 13) does F1 appear. + // + // So F1's scoped plan lane shows only what ran after it was named, and sprint 2's shows + // `estimate-committed` (event 369, carried forward from F6). The alternative — crediting + // pre-feature planning to whichever feature happens to come next — would be a guess. + const events = readCorpus(); + // Early: planning is running but no feature is named yet. + expect(fold(events, snap(), 11).feature).toBeNull(); + expect(fold(events, snap(), 11).topology.laneSteps.plan).toEqual(["p-propose", "p-size", "p-req"]); + // Once F1 is named, its own scoped view excludes the pre-naming planning steps. + const f1 = fold(events, snap(), 20); + expect(f1.feature).toBe("F1-stock-visibility"); + expect(f1.topology.laneSteps.plan).toEqual([]); + // F6 picks up the estimate-committed that ran under it late in the run. + expect(fold(events, snap()).topology.laneSteps.plan).toEqual(["p-size"]); + }); + + it("story counts stay monotonic WITHIN a feature across the whole corpus", () => { + // Across the run the count legitimately DROPS at the sprint boundary (a new feature + // starts with no stories), so the global monotonicity the stockflow suite asserts does + // not hold here. Per-feature it must. + const events = readCorpus(); + const seen = new Map(); + for (let i = 0; i <= events.length; i += 5) { + const s = fold(events, snap(), i); + if (!s.feature) continue; + const prev = seen.get(s.feature) ?? 0; + expect(s.stories.length).toBeGreaterThanOrEqual(prev); + seen.set(s.feature, s.stories.length); + } + expect(seen.get("F1-stock-visibility")).toBe(3); + expect(seen.get("F6-split-tracking-code")).toBe(3); + }); +}); + +describe("fold — features[] enumeration (FeatureSwitcher list)", () => { + it("lists both corpus features in log order with done/active flags", () => { + const events = readCorpus(); + + // At the live edge both sprints have shipped: both done, F6 (the last-seen) is active. + const live = fold(events, snap()); + expect(live.features).toEqual([ + { id: "F1-stock-visibility", done: true, active: false }, + { id: "F6-split-tracking-code", done: true, active: true }, + ]); + }); + + it("marks a feature done only once its phase.end/workflow has fired", () => { + const events = readCorpus(); + // F1's phase.end/workflow is event index 213, so it enters the fold at upTo=214. Before + // that F1 is active-not-done. + const before = fold(events, snap(), 213); + expect(before.features).toEqual([ + { id: "F1-stock-visibility", done: false, active: true }, + ]); + // Just past F1's end, before F6's id first appears (event index 216 stamps it): F1 is done + // and still the last feature seen, so it stays `active` until a second feature arrives. + const between = fold(events, snap(), 214); + expect(between.features).toEqual([ + { id: "F1-stock-visibility", done: true, active: true }, + ]); + // Mid sprint 2: F1 done and no longer active, F6 active but not yet done. + const mid = fold(events, snap(), 300); + expect(mid.features).toEqual([ + { id: "F1-stock-visibility", done: true, active: false }, + { id: "F6-split-tracking-code", done: false, active: true }, + ]); + }); + + it("is empty before any feature_id is stamped", () => { + // Events 0–10 precede F1's first feature_id (event 11), so the switcher has nothing to list. + expect(fold(readCorpus(), snap(), 5).features).toEqual([]); + }); +}); + +describe("fold — pinning a feature (FeatureSwitcher)", () => { + it("re-scopes the board to a past feature without moving the playhead", () => { + const events = readCorpus(); + const at = 300; // mid sprint 2; playhead's own feature is F6. + const unpinned = fold(events, snap(), at); + expect(unpinned.feature).toBe("F6-split-tracking-code"); + expect(unpinned.pinnedFeature).toBeNull(); + + const pinned = fold(events, snap(), at, "F1-stock-visibility"); + // The board now shows F1... + expect(pinned.feature).toBe("F1-stock-visibility"); + expect(pinned.pinnedFeature).toBe("F1-stock-visibility"); + expect(pinned.stories.every((s) => s.feature === "F1-stock-visibility")).toBe(true); + expect(pinned.stories.map((s) => s.id)).toEqual([ + "S1-file-stock", + "S2-stock-by-location-table", + "S3-sku-detail-view", + ]); + // ...and its lifecycle graph reflects the SHIPPED F1, not F6's in-progress state: it reached + // promote, and because F1's own workflow has ended, nothing reads as active and the lane is + // complete — it does NOT borrow F6's opening "design"/"plan". + expect(pinned.topology.passedNodes).toContain("promote"); + expect(pinned.topology.activeNode).toBeNull(); + expect(pinned.lane).toBe("complete"); + // ...but the playhead is unmoved: this is a filter, not a seek. + expect(pinned.atEventIndex).toBe(at); + expect(pinned.atEventIndex).toBe(unpinned.atEventIndex); + }); + + it("omits the test bar when the pin diverges from the playhead's feature", () => { + const events = readCorpus(); + // Historical test counts come from the snapshot at the playhead, which describes F6 here. + // Attributing them to a pinned F1 would be a wrong number under the right label — omit them. + const pinned = fold( + events, + snap({ status: { feature_id: "F6-split-tracking-code", test_list: { total: 25, by_status: { green: 5 }, completion_pct: 20 } }, statusIsHistorical: true }), + 300, + "F1-stock-visibility", + ); + expect(pinned.pinnedFeature).toBe("F1-stock-visibility"); + expect(pinned.progress.testsHistorical).toBe(false); + expect(pinned.progress.testTotal).toBe(0); + }); + + it("does not set pinnedFeature when the pin coincides with the playhead's feature", () => { + const events = readCorpus(); + // Pinning the feature you're already on is a no-op divergence: the bar stays honest. + const s = fold(events, snap({ status: { feature_id: "F6-split-tracking-code", test_list: { total: 25, by_status: { green: 5 }, completion_pct: 20 } }, statusIsHistorical: true }), 300, "F6-split-tracking-code"); + expect(s.feature).toBe("F6-split-tracking-code"); + expect(s.pinnedFeature).toBeNull(); + expect(s.progress.testsHistorical).toBe(true); + expect(s.progress.testTotal).toBe(25); + }); + + it("drops a stale pin the window has not seen, falling back to the playhead's feature", () => { + const events = readCorpus(); + // At event 200 only F1 exists; pinning F6 (not yet seen) must degrade, not empty the board. + const s = fold(events, snap(), 200, "F6-split-tracking-code"); + expect(s.feature).toBe("F1-stock-visibility"); + expect(s.pinnedFeature).toBeNull(); + expect(s.stories.length).toBeGreaterThan(0); + // A garbage id degrades the same way. + expect(fold(events, snap(), 200, "F999-nope").feature).toBe("F1-stock-visibility"); + }); + + it("scopes gates to the pinned feature, matching that feature's own live edge", () => { + // Review finding: gates were left unscoped under a divergent pin, so a pinned shipped F1 + // showed F6's open gates. A pinned view of F1 must equal what F1 showed at ITS OWN end + // (event 213) — the pin means "show me F1", not "show me now". + const events = readCorpus(); + const f1AtOwnEnd = fold(events, snap(), 213); // scrubbed to F1's workflow-end, no pin + const f1Pinned = fold(events, snap(), 421, "F1-stock-visibility"); // live edge, pinned to F1 + expect(f1Pinned.pinnedFeature).toBe("F1-stock-visibility"); + expect(f1Pinned.gates).toEqual(f1AtOwnEnd.gates); + + // And at event 260 the unpinned F6 board has a `test_list` gate F1 never had; the pin drops it. + const at260 = fold(events, snap(), 260); + const pinnedAt260 = fold(events, snap(), 260, "F1-stock-visibility"); + expect(at260.gates.some((g) => g.name === "test_list")).toBe(true); + expect(pinnedAt260.gates.some((g) => g.name === "test_list")).toBe(false); + }); + + it("scopes blockers to the pinned feature", () => { + // Same honesty rule as gates: an escalation on the ACTIVE feature must not surface under a + // pinned past one. Synthetic, since the corpus logs no escalations: F1 shipped, F6 has an + // open escalation at the live edge. + const run = [ + ev("phase.start", { phase: "propose", feature_id: "F1" }, { role: "spec-author" }), + ev("phase.end", { phase: "workflow", feature_id: "F1" }, { role: "orchestrator" }), + ev("phase.start", { phase: "green", feature_id: "F6", story: "S1" }, { role: "driver" }), + ev("escalation.raised", { feature_id: "F6", story: "S1", source: "verify" }, { role: "driver", message: "GREEN verify failed" }), + ]; + // The log-derived blocker set (what a divergent pin uses) is F6's alone: unscoped it has the + // escalation, scoped to F6 it keeps it, scoped to the shipped F1 it is empty. + expect(blockersFromLog(run).length).toBe(1); + expect(blockersFromLog(run, "F6").length).toBe(1); + expect(blockersFromLog(run, "F1").length).toBe(0); + // And through the fold: a divergent pin onto F1 derives from the scoped log, so no F6 + // escalation surfaces under F1. (The live-edge unpinned board reads blockers from next.json, + // a separate path; the pin path is the one this fix touches.) + const pinned = fold(run, snap(), undefined, "F1"); + expect(pinned.pinnedFeature).toBe("F1"); + expect(pinned.blockers.length).toBe(0); + }); + + it("clears a feature's done flag if its work resumes after a workflow-end", () => { + // Review finding: featuresFromLog kept `done` set forever, unlike runEnded which resets on a + // resume. A re-opened feature must not read as done (which would force lane='complete' via + // pinnedDone). Both real logs never resume a feature, so this is a synthetic guard. + const run = [ + ev("phase.start", { phase: "propose", feature_id: "F1" }, { role: "spec-author" }), + ev("phase.end", { phase: "workflow", feature_id: "F1" }, { role: "orchestrator" }), + ev("phase.start", { phase: "red", feature_id: "F1", story: "S1" }, { role: "driver" }), + ]; + // After the resume F1 is no longer done, and a pin onto it does NOT force complete. + expect(fold(run, snap()).features).toEqual([{ id: "F1", done: false, active: true }]); + // At the workflow-end (folding exactly 2 events) it IS done. + expect(fold(run, snap(), 2).features).toEqual([{ id: "F1", done: true, active: true }]); + }); + + it("marks the switcher's active feature from the playhead, not just the last log id", () => { + // Review finding: divergence was computed against playheadFeature but the header label read + // the last-log-seen `active` flag; at the live edge next.json's feature can differ. The fold + // now re-derives `active` against playheadFeature so the two agree. next.feature wins here. + const run = [ + ev("phase.start", { phase: "propose", feature_id: "F1" }, { role: "spec-author" }), + ev("phase.start", { phase: "design", feature_id: "F6" }, { role: "dba" }), + ]; + // next.json names F1 even though the last log event stamped F6: playhead follows next.json. + const s = fold(run, snap({ next: { feature: "F1" } })); + expect(s.feature).toBe("F1"); + expect(s.features.find((f) => f.active)?.id).toBe("F1"); + expect(s.features.filter((f) => f.active).length).toBe(1); + }); +}); + +describe("fold — story scoping is strict (PR #12 null-feature leak)", () => { + it("never leaks a null-feature story into a feature's view, across every corpus prefix", () => { + // The PR #12 finding: `s.feature === null` used to pass through to every feature. Fixed to + // strict scoping. Measured across every prefix of the real corpus: 0 produce a null-feature + // story, so this is byte-identical on real data — the sweep pins that it stays so, and that + // no scoped view ever contains a foreign-feature story. + const events = readCorpus(); + for (let i = 0; i <= events.length; i++) { + const s = fold(events, snap(), i); + if (!s.feature) continue; + for (const story of s.stories) { + expect(story.feature).toBe(s.feature); + } + } + }); + + it("shows every story when no feature is in force at all", () => { + // A log that never stamps a feature_id: the scoping guard must not empty the board. Here the + // fold's `feature` is null, so all stories show (unchanged behavior). + const noFeature = [ + ev("phase.start", { phase: "design", story: "S1" }, { role: "spec-author" }), + ev("phase.start", { phase: "red", story: "S1" }, { role: "driver" }), + ]; + const s = fold(noFeature, snap()); + expect(s.feature).toBeNull(); + expect(s.stories.map((x) => x.id)).toEqual(["S1"]); + }); +}); + +describe.skipIf(!existsSync(REAL_LOG))("fold — golden, real stockflow log", () => { + it("folds the full log without throwing and reports a sane live edge", () => { + const events = readReal(); + expect(events.length).toBeGreaterThan(100); + const s = fold(events, snap()); + expect(s.ok).toBe(true); + expect(s.atLive).toBe(true); + expect(s.eventCount).toBe(events.length); + expect(s.totalCost).toBeGreaterThan(0); + }); + + it("stays monotonic across the whole real run", () => { + const events = readReal(); + // Sample ~20 evenly spaced indices; folding all 379 individually is needless work. + const step = Math.max(1, Math.floor(events.length / 20)); + let prevCost = -1; + let prevEvents = -1; + for (let i = 0; i <= events.length; i += step) { + const s = fold(events, snap(), i); + expect(s.totalCost).toBeGreaterThanOrEqual(prevCost); + expect(s.eventCount).toBeGreaterThanOrEqual(prevEvents); + expect(s.atEventIndex).toBe(Math.min(i, events.length)); + prevCost = s.totalCost; + prevEvents = s.eventCount; + } + }); + + it("agrees with the live edge when folding at exactly the log length", () => { + const events = readReal(); + expect(fold(events, snap(), events.length)).toEqual(fold(events, snap())); + }); + + it("attributes cost to more than one role on a real run", () => { + const s = fold(readReal(), snap()); + expect(s.agents.filter((a) => a.cost > 0).length).toBeGreaterThan(1); + }); +}); diff --git a/apps/dashboard/lib/reducer.ts b/apps/dashboard/lib/reducer.ts new file mode 100644 index 00000000..2d184dd1 --- /dev/null +++ b/apps/dashboard/lib/reducer.ts @@ -0,0 +1,621 @@ +// The mode-independent fold: (events, snapshot inputs) -> DashboardState. +// +// This file does NO I/O. Everything it needs arrives as arguments, which is what makes +// the board evaluable at any point in a run's event log (`upTo`) and what makes the +// whole derivation unit-testable without a scaffolded project on disk. +// +// The same fold serves both dashboard modes: a live tail and a finished replay log use +// an identical event vocabulary (phase.start / handoff / turn.usage / artifact.written / +// gate.surfaced / ...), so nothing here is live-specific. +// +// Time-travel: at the live edge, disk snapshots (next.json + the feature-status CLI) are +// authoritative. Scrubbed back they would be a lie — they describe *now* — so gates, +// stories and the lane are reconstructed from the log prefix instead, which genuinely can +// rewind. Test COUNTS are the one exception: the log carries only the test_ids that had a +// cycle.* event, so there is no honest historical total. `progress.testsHistorical` goes +// false and the UI omits the bar rather than showing a current or invented number. +import { + AgentLogEvent, + DashboardState, + DESIGN_PHASE_NAMES, + GateInfo, + Role, + ROLES, + SnapshotInputs, + WaitingOnHuman, + Blocker, +} from "./types"; +import { + computeDesignPhases, + computeStories, + findPendingGate, + gatesFromLog, + reduceAgents, + resolverFor, + storiesFromLog, + blockersFromLog, + featureIdOf, + featuresFromLog, +} from "./derive"; +import { LANE_IDS, laneProgress, nodeForPhase, passedNodes } from "./topology"; + +// A Claude session that wrote its transcript within this window counts as "actively +// working" — Consort only logs at turn boundaries, so a long turn otherwise looks frozen. +export const SESSION_ACTIVE_MS = 15_000; + +/** + * How many trailing events the board ships as `recentEvents`. + * + * Exported because a source aligning per-event data to that tail (replay's `recentTurns`) must + * use the SAME length — a mismatch would shift every row's turn ordinal by the difference, and + * a wrong ordinal silently shows the wrong transcript and the wrong code. + */ +export const RECENT_EVENT_TAIL = 40; + +// Transcript-based permission detection proved too flaky to ship; the gate and escalation +// banners stay on and reliable. Flip to re-enable. +export const ENABLE_PERMISSION_BANNER = false; +export const ENABLE_WAITING_BANNER = false; + +// An empty board, used for the error paths and as the shape reference. +export function emptyState(projectDir: string, generatedAt: string): DashboardState { + return { + ok: false, + error: null, + projectDir, + feature: null, + features: [], + pinnedFeature: null, + phase: null, + agents: ROLES.map((role) => ({ + role, + status: "idle", + work: null, + phase: null, + story: null, + model: null, + cost: 0, + turns: 0, + lastTs: null, + issues: [], + turnStartTs: null, + sessionActive: null, + })), + gates: [], + blockers: [], + waiting: null, + progress: { + testTotal: 0, + testDone: 0, + testPct: 0, + storiesTotal: 0, + storiesDone: 0, + testByStatus: { pending: 0, red: 0, green: 0, refactored: 0, skipped: 0 }, + testsHistorical: true, + }, + designPhases: DESIGN_PHASE_NAMES.map((name) => ({ + name, + status: "not-started" as const, + current: false, + looping: false, + })), + stories: [], + lane: "design" as const, + totalCost: 0, + eventCount: 0, + recentEvents: [], + generatedAt, + atEventIndex: 0, + totalEventCount: 0, + atLive: true, + snapshotAsOf: null, + topology: { + passedNodes: [], + activeNode: null, + laneSteps: { plan: [], design: [], build: [] }, + laneCurrent: null, + atTimestamp: null, + }, + }; +} + +// Graph lighting for the folded window: which lifecycle nodes were reached, which node the +// playhead sits in, and the same for lane sub-steps. Derived here rather than on the client +// because the client only receives a 40-event tail, while this needs the whole prefix. +function deriveTopology( + slice: AgentLogEvent[], + /** + * The feature the playhead is on. Scopes graph lighting to it, so a multi-feature run does + * not inherit an earlier sprint's progress: at event 230 of the stockflow-rerecord corpus, + * sprint 2 has only begun designing, but unscoped `passedNodes` reached `promote` and all + * seven build sub-steps were lit — the lifecycle graph drew a shipped feature that had not + * written a line of code. Null (a log that never stamps a feature) means whole-run, which is + * the single-feature behavior this preserves exactly. + */ + feature: string | null, + /** + * True when the scoped feature has already shipped (`phase.end`/`workflow` fired for it). A + * done feature has nothing running, full stop. This is load-bearing only when a PAST feature + * is pinned: the feature_id carry-forward attributes the next sprint's dispatch events (which + * carry no id yet) to the feature before them, so a shipped F1 pinned mid-F6 would otherwise + * borrow F6's opening `plan` as its active node. At the natural playhead the walk's own + * `phase.end` handling already covers this, so it is a no-op there. + */ + featureDone: boolean, +): DashboardState["topology"] { + const scope = feature ?? undefined; + const progress = laneProgress(slice, undefined, scope); + const laneSteps: Record = {}; + for (const lane of LANE_IDS) laneSteps[lane] = [...progress.done[lane]]; + + // The active node is the most recent event carrying a mappable phase — but `phase.end` + // means that phase FINISHED, so it must not light anything. Without this the last event + // of a completed run (`phase.end` for `workflow`, which maps to plan) would leave a + // finished run showing "active in Plan". A trailing phase.end therefore ends the walk: + // nothing is running now. + // + // Scoped to `feature` the same way `passedNodes`/`laneProgress` are: a feature_id is carried + // forward (not every event stamps one), and events outside the scope are skipped. Without this + // a pin onto a FINISHED feature borrowed the active node of whatever the playhead's own feature + // was doing — a shipped F1 pinned at event 300 showed F6's "design" as active. Tag each index's + // feature in a forward pass, then walk back within scope. + // + // Built only when there is a feature to scope to AND the walk will run — a single-feature run + // (`feature === null`) or a pinned-done feature (`featureDone`, walk skipped) never reads it, + // so the common poll path pays nothing for this extra pass. + let featureAt: (string | null)[] = []; + if (feature !== null && !featureDone) { + featureAt = new Array(slice.length); + let f: string | null = null; + for (let i = 0; i < slice.length; i++) { + const id = featureIdOf(slice[i]); + if (id) f = id; + featureAt[i] = f; + } + } + let activeNode: string | null = null; + for (let i = slice.length - 1; !featureDone && i >= 0; i--) { + if (feature !== null && featureAt[i] !== feature) continue; // out of scope + const e = slice[i]; + const md = (e.metadata || {}) as Record; + const phase = typeof md.phase === "string" ? md.phase : null; + if (e.event === "phase.end") { + if (phase !== null) break; // this phase closed and nothing reopened after it + continue; // no phase to reason about; keep looking back + } + const node = nodeForPhase(phase); + if (node) { + activeNode = node; + break; + } + if (e.event === "intake.supplied") { + activeNode = "intake"; + break; + } + } + + return { + passedNodes: [...passedNodes(slice, undefined, scope)], + activeNode, + laneSteps, + laneCurrent: progress.current, + atTimestamp: slice.length > 0 ? slice[slice.length - 1].timestamp : null, + }; +} + +// Which lane a scrubbed-back playhead is in. +// +// Prefer the node the playhead sits in; when nothing is active — the last folded event was a +// `phase.end`, so a phase just closed and the next hasn't opened — fall back to the furthest +// node REACHED. Defaulting to "design" there was wrong: at event 372 the stockflow run has +// just finished deploying, and the board claimed it was back in design. +// +// Note this uses activeNode first precisely because `passedNodes` alone is too generous: +// `reflect` maps to the build node, so a design-lane reflect would otherwise read as "build". +function laneFromPlayhead(topology: DashboardState["topology"]): "design" | "build" | "complete" { + const laneOf = (node: string | null): "design" | "build" | "complete" | null => { + if (node === "shipped" || node === "promote") return "complete"; + if (node === "build" || node === "deploy") return "build"; + if (node === "design" || node === "plan" || node === "intake") return "design"; + return null; + }; + + const active = laneOf(topology.activeNode); + if (active) return active; + + // Nothing running: use the furthest point the run got to, most advanced first. + const passed = new Set(topology.passedNodes); + if (passed.has("shipped") || passed.has("promote")) return "complete"; + if (passed.has("deploy")) return "build"; + return "design"; +} + +/** + * Fold an event log into a dashboard state. + * + * @param events all events read from agent-log.jsonl, oldest first. + * @param snap disk-sourced inputs the log cannot supply (see SnapshotInputs). + * @param upTo how many events to fold. Omitted/undefined = the whole log ("live"). + * Clamped to [0, events.length], so callers may pass raw user input. + * @param pinnedFeature scope the board to this feature instead of the playhead's own. A FILTER, + * not a seek: `upTo` (the playhead) is untouched, only which feature the board + * shows changes. Ignored when it names a feature the window hasn't seen, so a + * stale pin degrades to the playhead's feature rather than emptying the board. + * + * Pure: same arguments always yield the same state. Monotonic in `upTo` for the + * cumulative measures (cost, turn counts, event count). + */ +export function fold( + events: AgentLogEvent[], + snap: SnapshotInputs, + upTo?: number, + pinnedFeature?: string | null, +): DashboardState { + const total = events.length; + const at = upTo === undefined ? total : Math.max(0, Math.min(Math.floor(upTo), total)); + const atLive = at === total; + // The window being folded. Everything below derives from `slice`, never `events`, + // so a scrubbed-back board cannot leak state from the future. + const slice = at === total ? events : events.slice(0, at); + + const base = emptyState(snap.projectDir, snap.generatedAt); + const { next, status } = snap; + + // Agents and totalCost are deliberately RUN-level, not feature-scoped — a FeatureSwitcher pin + // does not narrow them. The agent bubbles answer "who is on the run and what are they doing", + // which is a property of the run, not of whichever feature you are inspecting; and cost is a + // cumulative run total everywhere else in the UI (the cost bar sums the whole run), so scoping + // it to a pinned past feature would make one number silently mean something different from the + // same number unpinned. Gates, blockers, stories, topology and the test bar ARE scoped, because + // those describe a feature's state; agents/cost describe the run around it. + const { agents, totalCost, runEnded } = reduceAgents(slice); + + // Liveness for working agents. Only meaningful at the live edge: mid-run history is not + // "active now", so a scrubbed-back board leaves sessionActive null rather than claiming + // a past turn is live. + if (atLive && agents.some((a) => a.status === "working")) { + const live = snap.sessionAgeMs < SESSION_ACTIVE_MS; + for (const a of agents) if (a.status === "working") a.sessionActive = live; + } + + // Active feature. The newest folded event wins when scrubbed back: next.json names the + // feature being worked on NOW, which for a multi-feature run is not the one that was + // active 200 events ago. At the live edge next.json is preferred — it is authoritative + // and survives a log that hasn't stamped feature_id recently. + // featureIdOf skips `reasoning` events, whose feature_id is unreliable — in the corpus + // three of them hold a story id or a truncated "F1", which would otherwise make the newest + // -event-wins rule name a story as the active feature. + const featureFromLog = (): string | null => { + for (let i = slice.length - 1; i >= 0; i--) { + const f = featureIdOf(slice[i]); + if (f) return f; + } + return null; + }; + // The feature the playhead naturally sits on, before any pin. + const playheadFeature = + atLive ? next?.feature ?? featureFromLog() : featureFromLog() ?? next?.feature ?? null; + + // Every feature the window has touched — the switcher's list, and the validity check for a + // pin. A pin naming a feature not in this window is stale (scrubbed before it appears, or a + // different run) and is dropped, so the board falls back to the playhead's feature. + // + // `active` is re-derived against `playheadFeature`, NOT left at featuresFromLog's last-seen + // guess: at the live edge playheadFeature prefers next.json's feature, which can differ from + // the last feature_id stamped in the log. Divergence below is computed against playheadFeature + // too, so the header's "run is on " label and the "is this pin divergent" decision now + // read one source and can't contradict each other (was: label from the active flag, divergence + // from playheadFeature — a pin on the last-log feature could render "showing X · run is on X"). + const features = featuresFromLog(slice).map((f) => ({ ...f, active: f.id === playheadFeature })); + const pinValid = pinnedFeature != null && features.some((f) => f.id === pinnedFeature); + // The feature the board is SCOPED to. A valid pin wins; otherwise the playhead's own. + const feature = pinValid ? pinnedFeature! : playheadFeature; + // Surface the pin only when it actually diverges from the playhead — the UI's cue to say + // "showing X · run is on Y" and the fold's cue to omit the (playhead-scoped) test bar. + const pinnedDivergent = pinValid && pinnedFeature !== playheadFeature ? pinnedFeature! : null; + + // --- gates --- + // At the live edge the disk snapshot is authoritative. Scrubbed back it would be a lie — + // it describes now — and gates ARE reconstructable from the log (gate.surfaced → + // gate.approved), so derive them instead of showing current values under a past playhead. + const snapshotGates: GateInfo[] = status?.gates + ? Object.entries(status.gates).map(([name, g]) => ({ name, status: g.status })) + : []; + // A divergent pin cannot use the snapshot — it describes the ACTIVE feature, not the pinned + // past one — so derive from the log, scoped to the pinned feature. This is the same honesty + // rule the test bar obeys, extended to gates: the OTHER feature's open gate must not surface + // under this one. At the natural playhead behavior is unchanged. + const gates: GateInfo[] = + pinnedDivergent !== null + ? gatesFromLog(slice, feature ?? undefined) + : atLive && snapshotGates.length > 0 + ? snapshotGates + : gatesFromLog(slice); + + // Blockers + resolver routing. Prefer the AUTHORITATIVE .handback file (Consort names the + // exact role that must fix a failed contract); fall back to a keyword guess from the + // blocker source only when no handback matches. + const handbacks = snap.handbacks; + const handbackRoleFor = (story: string | null): Role | null => + handbacks.find((h) => h.story === story)?.role ?? // exact story match + handbacks.find((h) => h.story === null)?.role ?? // feature-scoped handback + (handbacks.length === 1 ? handbacks[0].role : null); // sole handback, story unknown + + // next.json's blockers describe NOW, so they leaked into every scrubbed view — a + // GREEN-verify failure showed at event 0, before any code existed. The log records the + // same escalations, so reconstruct them when scrubbed back. + // A divergent pin derives blockers from the log scoped to the pinned feature, for the same + // reason as gates: next.json's blockers describe the ACTIVE feature. Otherwise unchanged. + const rawBlockers = + pinnedDivergent !== null + ? blockersFromLog(slice, feature ?? undefined).map((b) => ({ ...b, resolver_hint: null })) + : atLive + ? (next?.state?.blockers ?? []).map((b) => ({ + source: b.source, + reason: b.reason, + story: b.story ?? null, + resolver_hint: b.resolver_hint ?? null, + })) + : blockersFromLog(slice).map((b) => ({ ...b, resolver_hint: null })); + + const blockers: Blocker[] = rawBlockers.map((b) => { + const resolverRole = handbackRoleFor(b.story ?? null) ?? resolverFor(b.source); + if (resolverRole) { + const a = agents.find((x) => x.role === resolverRole); + if (a && a.status === "idle") a.status = "issue"; + } + return { + source: b.source, + reason: b.reason, + story: b.story ?? null, + resolverRole, + resolverHint: b.resolver_hint, + }; + }); + + // Mark agents that flagged issues (and aren't currently working) as issue-state. + for (const a of agents) { + if (a.issues.length > 0 && a.status === "idle") a.status = "issue"; + } + + const waiting = deriveWaiting(slice, snap, agents, atLive); + + // --- stories --- + // Same reasoning as gates: story ids and their lifecycle are in the log, so a scrubbed + // board reconstructs them. A story the log hasn't mentioned yet simply does not exist at + // that point — which is why an early playhead legitimately shows none. + // Prefer the disk snapshot at the live edge — it is authoritative and richer (real Consort + // statuses, acceptance flags). Fall back to the log when scrubbed back, OR when there is no + // status on disk at all: the log knows the stories either way, and showing none while the + // log plainly names three would make the live view worse informed than a scrubbed one. + const snapshotStories = status?.stories ?? []; + const allStories = + atLive && snapshotStories.length > 0 + ? computeStories(slice, snapshotStories, status?.feature_id ?? null) + : storiesFromLog(slice); + + // Scope to the feature in force (the playhead's, or a pin). A multi-feature run (the + // stockflow-rerecord corpus ships two sprints) otherwise accumulates every story ever run: at + // the sprint boundary the board showed three COMPLETED sprint-1 stories with no sign a second + // feature had started, and the counts read 3/6 instead of 0/3. + // + // Scoping is STRICT: a null-feature story is no longer passed through to every feature. That + // was the PR #12 finding — it traded mis-scoped for silently-hidden, and once the switcher + // makes per-feature scoping user-visible, a story bleeding into the wrong feature's view is + // the worse failure. Measured across every prefix of both real logs (421-event corpus, + // 380-event live): 0 produce a null-feature story, so this is byte-identical on real data — + // it closes a latent leak rather than changing observed behavior. When NO feature is in force + // at all (a log that never stamps one), everything shows, unchanged. + const stories = feature ? allStories.filter((s) => s.feature === feature) : allStories; + + // --- test counts --- + // In LIVE mode this is the one genuinely unrewindable panel: the log carries only the + // test_ids that had a cycle.* event (4 in the stockflow run) while the list totals 29, so + // there is no honest historical count. Report testsHistorical=false when scrubbed and let + // the UI omit the bar rather than invent one. + // + // A source can override that by rewinding its own snapshot and setting + // `statusIsHistorical` — replay does, from the corpus's per-turn `test-list.json` + // snapshots. Then the counts describe the playhead and the bar is honest at any position. + // The distinction lives in the data, not in a mode check, so the fold stays source-agnostic. + // A divergent pin makes the test bar dishonest: `status` counts describe the playhead's + // feature (live) or the snapshot taken at the playhead (replay), never the pinned one. There + // is no per-feature historical count to substitute — the corpus snapshots test-list.json by + // playhead position, not by feature — so omit the bar rather than show the wrong feature's + // numbers under this feature's name. Same §3a honesty rule scrubbing already obeys: a wrong + // number labelled correctly is still a wrong number. + const statusHistorical = snap.statusIsHistorical === true; + const testsUsable = (atLive || statusHistorical) && pinnedDivergent === null; + const testsHistorical = testsUsable; + const testTotal = testsUsable ? status?.test_list?.total ?? 0 : 0; + const byStatus = testsUsable ? status?.test_list?.by_status ?? {} : {}; + const testByStatus = { + pending: byStatus.pending ?? 0, + red: byStatus.red ?? 0, + green: byStatus.green ?? 0, + refactored: byStatus.refactored ?? 0, + skipped: byStatus.skipped ?? 0, + }; + const testDone = testByStatus.green + testByStatus.refactored; + const testPct = !testsUsable + ? 0 + : status?.test_list?.completion_pct ?? (testTotal ? Math.round((testDone / testTotal) * 100) : 0); + const storiesDone = stories.filter((s) => s.status === "done").length; + + // --- lane / phase --- + // derived_phase is a snapshot fact. Scrubbed back, take the lane from where the topology + // says the playhead is, so the design/build emphasis matches the rest of the board. + const derivedSnapshot = status?.derived_phase ?? next?.state?.derived_phase ?? null; + // Is a PAST, shipped feature pinned while a later one is still running? Only then does the + // scoped feature's own completion override the playhead's lifecycle. At the natural playhead + // the existing runEnded / laneFromPlayhead logic already gives the right answer (and the + // sprint-boundary events, which carry no feature_id yet, must not be forced complete just + // because the carried-forward feature has ended — that is the F1→F6 handoff at event 214). + const pinnedDone = pinnedDivergent !== null && (features.find((f) => f.id === feature)?.done ?? false); + const topology = deriveTopology(slice, feature, pinnedDone); + // Use where the playhead IS, not what the run has ever touched. `passedNodes` is wrong + // here: `reflect` maps to the build node, so any design-lane reflect would make an + // early-design playhead claim "build". The lane the current phase belongs to is the + // honest answer, and it correctly flips back to design when design resumes for story 2. + // A finished run is `complete`, whatever the snapshot claims. The log's phase.end/workflow + // is definitive — in the stockflow run derived_phase sits at "build" forever after the + // workflow ended, which made the Build lane render "· in progress" on a run that had + // already promoted and shipped. A pinned-and-shipped past feature is complete for the same + // reason: its own workflow ended, even though the run at large has moved on. + const derived = + runEnded || pinnedDone ? "complete" : atLive ? derivedSnapshot : laneFromPlayhead(topology); + const lane: DashboardState["lane"] = + derived === "complete" ? "complete" : derived === "build" ? "build" : "design"; + + return { + ...base, + ok: true, + error: null, + feature, + features, + pinnedFeature: pinnedDivergent, + phase: derived, + agents, + gates, + blockers, + // Banner disabled (ENABLE_WAITING_BANNER): the derivation above still ran, so agent + // bubble states (waiting/issue) and the blockers list stay populated — we just don't + // surface the top banner. Flip the flag to bring it back. + waiting: ENABLE_WAITING_BANNER ? waiting : null, + progress: { + testTotal, + testDone, + testPct, + storiesTotal: stories.length, + storiesDone, + testsHistorical, + testByStatus, + }, + // `lane` is what tells computeDesignPhases to mark every phase complete. At the live + // edge that comes from the snapshot; scrubbed back it now comes from the playhead, so + // the design lane stops claiming "all complete" while the run is still designing. + designPhases: computeDesignPhases(slice, lane), + stories, + lane, + totalCost, + eventCount: slice.length, + recentEvents: slice.slice(-RECENT_EVENT_TAIL), + atEventIndex: at, + totalEventCount: total, + atLive, + snapshotAsOf: next?.generated_at ?? (status ? snap.generatedAt : null), + topology, + }; +} + +// The "Consort is waiting on you" derivation. Two distinct layers, in priority order: +// (1) a Claude Code PERMISSION prompt in the driving session (freshest, most immediate); +// (2) a Consort HITL GATE or ESCALATION (from the log, reconciled against next.json). +// Mutates `agents` to mark the surfacing role as waiting, matching prior behavior. +function deriveWaiting( + events: AgentLogEvent[], + snap: SnapshotInputs, + agents: DashboardState["agents"], + atLive: boolean, +): WaitingOnHuman | null { + const { next } = snap; + const nextGeneratedAt = next?.generated_at ?? null; + + let pendingGate = findPendingGate(events); + // Stale-gate reconcile: if next.json was regenerated AFTER a named GATE was surfaced and + // no longer lists it open, the human already answered (gate.approved lands in next.json, + // not the log). Escalations have no gate name and are cleared by a following handoff/ + // phase.start instead, so this applies only to real named gates. + // + // Only at the live edge. next.json describes now, so applying it to a past playhead would + // erase a gate that genuinely WAS pending then — the whole point of scrubbing to it. + if ( + atLive && + pendingGate && + pendingGate.variety === "gate" && + pendingGate.gate && + nextGeneratedAt && + pendingGate.ts < nextGeneratedAt + ) { + const stillOpen = (next?.state?.open_gates ?? []).includes(pendingGate.gate); + if (!stillOpen) pendingGate = null; + } + + const openGates = next?.state?.open_gates ?? []; + const gateOption = (next?.options ?? []).find((o) => o.kind === "gate"); + const pendingPermission = ENABLE_PERMISSION_BANNER ? snap.pendingPermission : null; + + if (pendingPermission) { + // Permission prompt wins — it's the live, immediate blocker in the driver session. + const cmd = pendingPermission.command?.split("\n")[0]?.slice(0, 100) ?? null; + const article = /^[aeiou]/i.test(pendingPermission.tool) ? "an" : "a"; + return { + kind: "permission", + gate: null, + role: null, + prompt: + `Claude Code is asking permission to run ${article} ${pendingPermission.tool} command in the Consort session. ` + + `Approve it in that terminal to continue.`, + options: [], + permission: { + tool: pendingPermission.tool, + command: cmd, + description: pendingPermission.description, + }, + }; + } + + if (pendingGate?.variety === "escalation") { + // A role kicked a problem up to you (e.g. a GREEN verify failed). Consort is parked + // (next.json primary_action = raise-to-hil) until you resolve it and resume. + const story = pendingGate.story ?? null; + if (pendingGate.role) { + const a = agents.find((x) => x.role === pendingGate!.role); + if (a && a.status === "idle") a.status = "waiting"; + } + // Prefer next.json's raise-to-hil description (fuller) over the log one-liner. + const raiseDesc = + next?.primary_action?.kind === "raise-to-hil" ? next?.primary_action?.describe ?? null : null; + const ageMs = snap.sessionAgeMs; + return { + kind: "escalation", + gate: null, + role: pendingGate.role, + prompt: + raiseDesc ?? + pendingGate.message ?? + `Consort escalated a problem${story ? ` on ${story}` : ""} and needs you to resolve it before it can proceed.`, + options: (next?.options ?? []).map((o) => ({ id: o.id, title: o.title })), + sessionActive: ageMs < SESSION_ACTIVE_MS, + sessionActiveAgeSec: Number.isFinite(ageMs) ? Math.round(ageMs / 1000) : null, + }; + } + + if (pendingGate || openGates.length > 0 || gateOption) { + const gateName = pendingGate?.gate ?? openGates[0] ?? null; + const surfacedRole = pendingGate?.role ?? null; + const story = pendingGate?.story ?? null; + if (surfacedRole) { + const a = agents.find((x) => x.role === surfacedRole); + if (a && a.status === "idle") a.status = "waiting"; + } + const gateLabel = gateName ? `${gateName} gate${story ? ` · ${story}` : ""}` : "a decision"; + const prompt = gateName + ? `Consort is paused at the ${gateLabel} and needs your review to proceed.` + : gateOption?.hil_prompt ?? + (next?.options ?? []).find((o) => o.hil_prompt)?.hil_prompt ?? + "Consort is paused and needs your input to proceed."; + const ageMs = snap.sessionAgeMs; + return { + kind: "gate", + gate: gateName, + role: surfacedRole, + prompt, + options: (next?.options ?? []).map((o) => ({ id: o.id, title: o.title })), + sessionActive: ageMs < SESSION_ACTIVE_MS, + sessionActiveAgeSec: Number.isFinite(ageMs) ? Math.round(ageMs / 1000) : null, + }; + } + + return null; +} diff --git a/apps/dashboard/lib/safepath.test.ts b/apps/dashboard/lib/safepath.test.ts new file mode 100644 index 00000000..2e42232e --- /dev/null +++ b/apps/dashboard/lib/safepath.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveContained } from "./safepath"; + +// resolveContained returns the REAL (symlink-resolved) path — on macOS tmpdir is /var → /private/var +// — so expected values are realpath'd too, not lexically resolved. +const real = (...p: string[]) => realpathSync(join(...p)); + +// The one audited path guard, shared by the replay turn reader and the live HEAD reader. Its +// whole job is to defeat traversal and symlink escapes, so those are what the tests hammer. + +describe("resolveContained", () => { + let root: string; + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "safepath-")); + mkdirSync(join(root, "sub"), { recursive: true }); + writeFileSync(join(root, "a.txt"), "a"); + writeFileSync(join(root, "sub", "b.txt"), "b"); + }); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + it("resolves a contained relative path to its real absolute path", () => { + expect(resolveContained(root, "a.txt")).toBe(real(root, "a.txt")); + expect(resolveContained(root, "sub/b.txt")).toBe(real(root, "sub", "b.txt")); + // A harmless inner `..` that stays contained is fine. + expect(resolveContained(root, "sub/../a.txt")).toBe(real(root, "a.txt")); + }); + + it("returns null for a lexical `../` or absolute escape", () => { + expect(resolveContained(root, "../".repeat(30) + "etc/passwd")).toBeNull(); + expect(resolveContained(root, "/etc/passwd")).toBeNull(); + }); + + it("returns null for a non-existent path (indistinguishable from escaped, on purpose)", () => { + expect(resolveContained(root, "nope.txt")).toBeNull(); + }); + + it("refuses a symlink that points OUT of the root", () => { + symlinkSync("/etc/passwd", join(root, "leak")); + expect(resolveContained(root, "leak")).toBeNull(); + // ...and a symlinked directory can't be used to resume traversal past it. + symlinkSync("/etc", join(root, "etcdir")); + expect(resolveContained(root, "etcdir/passwd")).toBeNull(); + }); + + it("allows a symlink that stays INSIDE the root — containment, not a ban on links", () => { + symlinkSync(join(root, "sub", "b.txt"), join(root, "link-b")); + expect(resolveContained(root, "link-b")).toBe(real(root, "sub", "b.txt")); + }); + + it("does not let `/root-evil` pass a bare prefix check on `/root`", () => { + // The sep-suffix guard: a sibling dir whose name starts with the root's must not pass. + const sibling = root + "-evil"; + mkdirSync(sibling, { recursive: true }); + writeFileSync(join(sibling, "x.txt"), "x"); + try { + // Reaching the sibling requires escaping root, so this must be null regardless. + expect(resolveContained(root, "../" + join(sibling, "x.txt").split("/").pop()!)).toBeNull(); + } finally { + rmSync(sibling, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/dashboard/lib/safepath.ts b/apps/dashboard/lib/safepath.ts new file mode 100644 index 00000000..8f663606 --- /dev/null +++ b/apps/dashboard/lib/safepath.ts @@ -0,0 +1,43 @@ +// One audited answer to "resolve this request-supplied relative path under this root, without +// letting it escape." Extracted from replay.ts's readFileContent, which grew this guard the hard +// way — a review found the first version served /etc/passwd through a symlink. Both the replay +// turn-file reader and the live HEAD-artifact reader now route through here, so the security- +// critical bytes live in exactly one place rather than two that can drift. + +import { realpathSync } from "node:fs"; +import { resolve, sep } from "node:path"; + +/** + * Resolve `rel` under `root`, returning the real (symlink-followed) path only when it is truly + * contained. Returns null in every unsafe or unresolvable case — the caller cannot tell "escaped" + * from "does not exist", which is deliberate: it must not leak whether a path outside the root is. + * + * `rel` is treated as ATTACKER-CONTROLLED (it reaches here from a request parameter). Two escapes + * have to be defeated, and were each observed in the wild on the replay reader: + * + * 1. Lexical — `"../".repeat(30) + "etc/passwd"`, or an absolute path. `resolve()` collapses + * those, so the resolved candidate can be compared against the resolved root. + * 2. Symlink — `resolve()` is purely lexical and does NOT follow links, so `files/leak -> /etc/passwd` + * passed a lexical check and served the real file. Only `realpathSync` catches it, so BOTH + * the root and the candidate are realpath'd and the prefix test runs on the real paths. + * + * The root is realpath'd too, so a root reached through a symlinked parent (a plausible layout) + * still passes its own containment check. The `sep` suffix stops `/files-evil` passing a bare + * `startsWith("/files")`. realpath throws for a non-existent path, which is folded into the null + * return rather than surfaced. + */ +export function resolveContained(root: string, rel: string): string | null { + const lexicalRoot = resolve(root); + const lexicalTarget = resolve(lexicalRoot, rel); + + let realRoot: string; + let realTarget: string; + try { + realRoot = realpathSync(lexicalRoot); + realTarget = realpathSync(lexicalTarget); + } catch { + return null; // non-existent (or unresolvable) — indistinguishable from escaped, on purpose + } + if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) return null; + return realTarget; +} diff --git a/apps/dashboard/lib/source.test.ts b/apps/dashboard/lib/source.test.ts new file mode 100644 index 00000000..62fef4a9 --- /dev/null +++ b/apps/dashboard/lib/source.test.ts @@ -0,0 +1,568 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CAPABILITIES, foldSource, hasCapability, readSource, type Capability, type DashboardSource } from "./source"; +import { buildState } from "./consort"; +import { LiveSource, resolveSource, currentSource } from "./sources"; +import { fold } from "./reducer"; +import { CAPABILITY_NAMES, type AgentLogEvent, type DashboardState, type SnapshotInputs, type SourceMeta } from "./types"; + +// Phase 1 item 3: today's reader moved behind an interface, with NO behavior change. These +// tests pin the contract, and — most importantly — check that going through the interface +// produces byte-identical state to calling the reader directly. + +const STOCKFLOW = join(process.env.HOME || "", "Code/consort-lab/stockflow"); +const HAS_PROJECT = existsSync(join(STOCKFLOW, ".sftdd")); + +describe("source — capability vocabulary", () => { + it("declares a stable, unique capability list", () => { + expect(new Set(CAPABILITIES).size).toBe(CAPABILITIES.length); + // These are the plan's §2 matrix rows; renaming one silently disables a panel. + expect(CAPABILITIES).toContain("timeline"); + expect(CAPABILITIES).toContain("transport"); + expect(CAPABILITIES).toContain("transcripts"); + expect(CAPABILITIES).toContain("featureStatus"); + }); + + it("keeps ONE vocabulary shared with the wire type", () => { + // source.ts re-exports types.ts's list rather than declaring a second one. Two lists + // would drift, and the wire type is what UI panels gate on. + expect(CAPABILITIES).toBe(CAPABILITY_NAMES); + }); + + it("types the wire capabilities as the union, not string[]", () => { + // Regression for a review finding: `capabilities: string[]` let a typo'd name compile + // on both sides, so a renamed capability would silently disable a panel forever with no + // compile error and no test failure. Verified with tsc: a bogus name now errors. + const meta: SourceMeta = { + mode: "live", + describe: "x", + capabilities: ["timeline", "transcripts"], + availableModes: ["live", "replay"], + note: null, + }; + for (const c of meta.capabilities) expect(CAPABILITY_NAMES).toContain(c); + // @ts-expect-error — a name outside the vocabulary must not type-check + const bad: SourceMeta["capabilities"] = ["timelinee"]; + expect(bad).toBeDefined(); + }); + + it("hasCapability reads the source's own set", () => { + const src = new LiveSource(); + expect(hasCapability(src, "timeline")).toBe(true); + expect(hasCapability(src, "transcripts")).toBe(false); + }); +}); + +describe("LiveSource — declared shape", () => { + const src = new LiveSource(); + + it("is live mode and describes its project dir", () => { + expect(src.mode).toBe("live"); + expect(typeof src.describe()).toBe("string"); + expect(src.describe().length).toBeGreaterThan(0); + }); + + it("claims only capabilities a live project can actually satisfy", () => { + expect(hasCapability(src, "timeline")).toBe(true); + expect(hasCapability(src, "transport")).toBe(true); + expect(hasCapability(src, "liveness")).toBe(true); + expect(hasCapability(src, "featureStatus")).toBe(true); + expect(hasCapability(src, "artifactPaths")).toBe(true); + // Claimed now that the artifact panel reads HEAD — live's artifactContent is HEAD-only + // (the file as it is NOW), which the panel labels, distinct from replay's per-turn snapshot. + expect(hasCapability(src, "artifactContent")).toBe(true); + + // A live project has no turns/ corpus, so this must NOT be claimed — the hard live/replay wall. + expect(hasCapability(src, "transcripts")).toBe(false); + }); + + it("declares only capabilities from the known vocabulary", () => { + for (const c of src.capabilities) expect(CAPABILITIES).toContain(c as Capability); + }); + + it("satisfies the DashboardSource interface structurally", () => { + const asInterface: DashboardSource = src; // compile-time check + for (const m of ["describe", "available", "unavailableReason", "events", "snapshot", "getState"]) { + expect(typeof (asInterface as unknown as Record)[m]).toBe("function"); + } + }); +}); + +describe("LiveSource.fidelity + capabilities — companion record dir (Phase B)", () => { + const saved = { + proj: process.env.CONSORT_PROJECT_DIR, + rec: process.env.CONSORT_RECORD_DIR, + recKit: process.env.LAKEBASE_CONSORT_RECORD_DIR, + }; + let proj: string; + let rec: string; + const RICH = ["transcripts", "correspondence", "stepOutputs"] as const; + + // A minimally READABLE ReplaySource corpus: available() requires the agent-log + turns/index.json. + const makeReadableCorpus = (dir: string) => { + mkdirSync(join(dir, "turns"), { recursive: true }); + writeFileSync(join(dir, "agent-log.jsonl"), ""); + writeFileSync(join(dir, "turns", "index.json"), JSON.stringify({ turns: [] })); + }; + + beforeEach(() => { + proj = mkdtempSync(join(tmpdir(), "consort-live-")); + rec = mkdtempSync(join(tmpdir(), "consort-rec-")); + mkdirSync(join(proj, ".consort")); + writeFileSync(join(proj, ".consort", "agent-log.jsonl"), ""); + process.env.CONSORT_PROJECT_DIR = proj; + delete process.env.CONSORT_RECORD_DIR; + delete process.env.LAKEBASE_CONSORT_RECORD_DIR; + }); + afterEach(() => { + rmSync(proj, { recursive: true, force: true }); + rmSync(rec, { recursive: true, force: true }); + for (const [k, v] of [ + ["CONSORT_PROJECT_DIR", saved.proj], + ["CONSORT_RECORD_DIR", saved.rec], + ["LAKEBASE_CONSORT_RECORD_DIR", saved.recKit], + ] as const) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("NOT recording, base capabilities only, for a plain live build (no record dir)", () => { + const src = new LiveSource(); + expect(src.fidelity()).toEqual({ recording: false }); + for (const c of RICH) expect(src.capabilities.has(c)).toBe(false); + }); + + it("does NOT key off the project's own .consort/turns (the shipped-A3 detection bug)", () => { + // The record lane writes ELSEWHERE, never the watched .consort/. A stray turns/ or + // correspondence.jsonl under .consort/ must NOT read as recording without a configured dir. + mkdirSync(join(proj, ".consort", "turns")); + writeFileSync(join(proj, ".consort", "correspondence.jsonl"), ""); + expect(new LiveSource().fidelity()).toEqual({ recording: false }); + }); + + it("recording + replay-grade capabilities once a companion record dir is readable", () => { + makeReadableCorpus(rec); + process.env.CONSORT_RECORD_DIR = rec; + const src = new LiveSource(); + expect(src.fidelity()).toEqual({ recording: true }); + for (const c of RICH) expect(src.capabilities.has(c)).toBe(true); + }); + + it("NOT recording while the configured record dir has no turns yet (early build)", () => { + process.env.CONSORT_RECORD_DIR = rec; // exists but empty: no log, no turns/index.json + const src = new LiveSource(); + expect(src.fidelity()).toEqual({ recording: false }); + expect(src.capabilities.has("transcripts")).toBe(false); + }); + + it("also reads the kit's own LAKEBASE_CONSORT_RECORD_DIR var", () => { + makeReadableCorpus(rec); + process.env.LAKEBASE_CONSORT_RECORD_DIR = rec; + expect(new LiveSource().fidelity()).toEqual({ recording: true }); + }); + + it("notices the companion corpus becoming readable mid-run (not memoized)", () => { + process.env.CONSORT_RECORD_DIR = rec; + const src = new LiveSource(); + expect(src.fidelity()).toEqual({ recording: false }); + makeReadableCorpus(rec); + expect(src.fidelity()).toEqual({ recording: true }); + expect(src.capabilities.has("transcripts")).toBe(true); + }); + + it("rewinds correspondence against the LIVE log's playhead, not the companion mirror", () => { + // The review's finding #1: `upTo` indexes the live agent-log; the companion mirror is a + // different length, so the horizon must come from the live events. Live log = 2 events; the + // companion carries 4 correspondence exchanges interleaved around them. + const ev = (ts: string) => + JSON.stringify({ timestamp: ts, level: "info", role: "orchestrator", event: "phase.start", message: "", metadata: {} }); + writeFileSync( + join(proj, ".consort", "agent-log.jsonl"), + [ev("2026-01-01T10:00:02Z"), ev("2026-01-01T10:00:06Z")].join("\n"), + ); + makeReadableCorpus(rec); + const corr = (at: string, seq: number) => + JSON.stringify({ seq, at, direction: "orch-to-hil", ordinal: null, request: { kind: "gate", presentation: { format: "markdown", rendered: "x" } } }); + writeFileSync( + join(rec, "correspondence.jsonl"), + [ + corr("2026-01-01T10:00:01Z", 0), + corr("2026-01-01T10:00:03Z", 1), + corr("2026-01-01T10:00:05Z", 2), + corr("2026-01-01T10:00:07Z", 3), + ].join("\n"), + ); + process.env.CONSORT_RECORD_DIR = rec; + const src = new LiveSource(); + + // upTo=1 → horizon = live event[0] = 10:00:02 → only the 10:00:01 exchange (NOT the mirror's). + const at1 = src.correspondenceSummary(1); + expect(at1.recent.length).toBe(1); + expect(at1.recent.every((r) => r.at <= "2026-01-01T10:00:02Z")).toBe(true); + + // upTo=2 (the live edge) → horizon = 10:00:06 → 01/03/05, NOT the 10:00:07 exchange. + expect(src.correspondenceSummary(2).recent.length).toBe(3); + expect(src.correspondenceSummary().recent.length).toBe(3); + }); + + it("correlates the recorded SUFFIX against companion turns, aligned to the live event tail", () => { + // Live log carries an earlier-feature navigator turn BEFORE the recording began, then the + // recorded one. The companion has a turn only for the recorded (later) navigator phase — so a + // naive whole-log correlation would let the F1 phase.start consume this run's turn and mis-pair. + const ev = (ts: string, event: string, role: string) => + JSON.stringify({ timestamp: ts, level: "info", role, event, message: "", metadata: { phase: "red" } }); + writeFileSync( + join(proj, ".consort", "agent-log.jsonl"), + [ + ev("2026-01-01T10:00:01Z", "phase.start", "navigator"), // F1 — before the recording + ev("2026-01-01T10:00:02Z", "turn.usage", "navigator"), // filler, not a phase.start + ev("2026-01-01T10:00:05Z", "phase.start", "navigator"), // the RECORDED navigator turn + ].join("\n"), + ); + mkdirSync(join(rec, "turns"), { recursive: true }); + writeFileSync( + join(rec, "turns", "index.json"), + JSON.stringify({ turns: [{ ordinal: 5, step: 0, label: "nav red", kind: "invoke-role", role: "navigator", dir: "0005-navigator", producedCount: 0, deletedCount: 0 }] }), + ); + // mirror begins at the recorded event's timestamp — that's how the suffix boundary is found. + writeFileSync(join(rec, "agent-log.jsonl"), ev("2026-01-01T10:00:05Z", "phase.start", "navigator")); + process.env.CONSORT_RECORD_DIR = rec; + + const c = new LiveSource().correlationSummary(); + // Positional to the 3 live events: F1 nav → null (excluded), filler → null, recorded nav → turn 5. + // A whole-log correlation would instead give [5, null, null] — the bug this guards against. + expect(c.recentTurns).toEqual([null, null, 5]); + expect(c.paired).toBe(1); + expect(c.healthy).toBe(true); + }); + + it("stays severity 'ok' on the normal live edge — an in-flight turn the companion hasn't recorded yet", () => { + // The live log runs one navigator phase.start AHEAD of the companion's recorded turns (the + // in-flight turn isn't captured yet): a role-EXHAUSTED tail. `report.healthy` trips on it, but the + // live rule keeps it healthy, so there must be NO DriftBanner. Guards the regression where severity + // was derived from report.healthy and lit an "info" banner (with a null message) on every live board. + const ev = (ts: string, event: string, role: string) => + JSON.stringify({ timestamp: ts, level: "info", role, event, message: "", metadata: { phase: "red" } }); + writeFileSync( + join(proj, ".consort", "agent-log.jsonl"), + [ + ev("2026-01-01T10:00:05Z", "phase.start", "navigator"), // pairs with the recorded turn + ev("2026-01-01T10:00:09Z", "phase.start", "navigator"), // in-flight: navigator turns are used up → role-exhausted + ].join("\n"), + ); + mkdirSync(join(rec, "turns"), { recursive: true }); + writeFileSync( + join(rec, "turns", "index.json"), + JSON.stringify({ turns: [{ ordinal: 5, step: 0, label: "nav red", kind: "invoke-role", role: "navigator", dir: "0005-navigator", producedCount: 0, deletedCount: 0 }] }), + ); + writeFileSync(join(rec, "agent-log.jsonl"), ev("2026-01-01T10:00:05Z", "phase.start", "navigator")); + process.env.CONSORT_RECORD_DIR = rec; + + const c = new LiveSource().correlationSummary(); + expect(c.paired).toBe(1); + expect(c.unpairedEvents).toBe(1); // the in-flight navigator turn + expect(c.healthy).toBe(true); + expect(c.severity).toBe("ok"); // → DriftBanner renders nothing + expect(c.message).toBeNull(); + }); +}); + +describe("resolveSource — mode selection", () => { + const saved = { project: process.env.CONSORT_PROJECT_DIR, corpus: process.env.CONSORT_CORPUS_DIR }; + + beforeEach(() => { + delete process.env.CONSORT_CORPUS_DIR; + }); + afterEach(() => { + if (saved.project === undefined) delete process.env.CONSORT_PROJECT_DIR; + else process.env.CONSORT_PROJECT_DIR = saved.project; + if (saved.corpus === undefined) delete process.env.CONSORT_CORPUS_DIR; + else process.env.CONSORT_CORPUS_DIR = saved.corpus; + }); + + it("defaults to live with no note", () => { + const r = resolveSource(); + expect(r.source.mode).toBe("live"); + expect(r.available).toEqual(["live"]); + expect(r.note).toBeNull(); + }); + + it("degrades a replay request loudly when no corpus is configured", () => { + // The request must be answered with live + a note, never with a blank board that looks + // like a run with no events. + delete process.env.CONSORT_CORPUS_DIR; + const r = resolveSource("replay"); + expect(r.source.mode).toBe("live"); + expect(r.note).toMatch(/CONSORT_CORPUS_DIR is not set/); + expect(r.available).toEqual(["live"]); + }); + + it("notes an unusable CONSORT_CORPUS_DIR instead of silently ignoring it", () => { + // A typo'd corpus path must not quietly remove the replay mode switch — the user set the + // variable, so they expect replay to be on offer, and silence would look like it worked. + process.env.CONSORT_CORPUS_DIR = "/tmp/definitely-not-a-corpus"; + const r = resolveSource(); + expect(r.source.mode).toBe("live"); + expect(r.note).toMatch(/unusable/i); + // ...and the mode is NOT offered, so the switch can't land on an error board. + expect(r.available).toEqual(["live"]); + }); + + it("names the specific defect when a replay request hits a broken corpus", () => { + process.env.CONSORT_CORPUS_DIR = "/tmp/definitely-not-a-corpus"; + const r = resolveSource("replay"); + expect(r.source.mode).toBe("live"); + expect(r.note).toMatch(/Replay unavailable/); + expect(r.note).toMatch(/not found/); // the actual reason, not just "unavailable" + }); + + it("currentSource returns the resolved source", () => { + expect(currentSource().mode).toBe("live"); + }); + + describe("with a usable corpus", () => { + // A minimal corpus is enough: resolveSource only asks `available()`, which checks for a + // log and a turns index. Building it here rather than depending on the real corpus keeps + // mode selection testable on any machine. + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "resolve-corpus-")); + mkdirSync(join(dir, "turns"), { recursive: true }); + writeFileSync(join(dir, "turns", "index.json"), JSON.stringify({ turns: [] })); + writeFileSync(join(dir, "agent-log.jsonl"), ""); + process.env.CONSORT_CORPUS_DIR = dir; + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("offers both modes and defaults to live when live is usable", () => { + // Watching a run in progress is this app's primary job; a corpus sitting on disk is not + // a reason to stop doing that. The switch appears, the default doesn't move. + // + // Requires a real scaffolded project, since "usable" is what decides the default — see + // the corpus-only case below. + const project = join(process.env.HOME ?? "", "Code/consort-lab/stockflow"); + if (!existsSync(join(project, ".sftdd"))) return; // no live project on this machine + process.env.CONSORT_PROJECT_DIR = project; + const r = resolveSource(); + expect(r.source.mode).toBe("live"); + expect(r.available).toEqual(["live", "replay"]); + expect(r.note).toBeNull(); + }); + + it("falls back to replay when there is no live project to watch", () => { + // Otherwise a corpus-only setup opens on an error page while a readable corpus sits + // right there. The note names why the board isn't live, so the switch is discoverable. + process.env.CONSORT_PROJECT_DIR = "/tmp/definitely-not-a-project"; + const r = resolveSource(); + expect(r.source.mode).toBe("replay"); + expect(r.available).toEqual(["live", "replay"]); + expect(r.note).toMatch(/No live Consort project found/); + // ...and the board is usable rather than an error state. + expect(r.source.getState().ok).toBe(true); + }); + + it("still honours an explicit live request even when live is unusable", () => { + // The fallback is only for "nothing was asked for". An explicit choice must not be + // second-guessed, or the switch would be unable to show live's own error. + process.env.CONSORT_PROJECT_DIR = "/tmp/definitely-not-a-project"; + const r = resolveSource("live"); + expect(r.source.mode).toBe("live"); + }); + + it("honours an explicit replay request", () => { + const r = resolveSource("replay"); + expect(r.source.mode).toBe("replay"); + expect(r.note).toBeNull(); + expect(r.available).toEqual(["live", "replay"]); + }); + + it("reads CONSORT_CORPUS_DIR per call, not once at import", () => { + // A module-level singleton would pin whatever the env said when the module loaded, + // which silently breaks both these tests and any future per-request corpus selection. + expect(resolveSource("replay").source.mode).toBe("replay"); + delete process.env.CONSORT_CORPUS_DIR; + expect(resolveSource("replay").source.mode).toBe("live"); + }); + }); +}); + +describe("LiveSource — unavailable project", () => { + const saved = process.env.CONSORT_PROJECT_DIR; + afterEach(() => { + if (saved === undefined) delete process.env.CONSORT_PROJECT_DIR; + else process.env.CONSORT_PROJECT_DIR = saved; + }); + + it("reports unavailable with an actionable reason when there is no .consort/", () => { + process.env.CONSORT_PROJECT_DIR = "/tmp/definitely-not-a-consort-project"; + const src = new LiveSource(); + expect(src.available()).toBe(false); + expect(src.unavailableReason()).toMatch(/No \.consort\//); + expect(src.unavailableReason()).toMatch(/CONSORT_PROJECT_DIR/); + }); + + it("still yields a well-formed error state rather than throwing", () => { + process.env.CONSORT_PROJECT_DIR = "/tmp/definitely-not-a-consort-project"; + const s = new LiveSource().getState(); + expect(s.ok).toBe(false); + expect(s.error).toMatch(/No \.consort\//); + expect(s.agents.length).toBeGreaterThan(0); // an empty board, not a broken one + }); + + it("reports no reason when the project IS available", () => { + if (!HAS_PROJECT) return; + process.env.CONSORT_PROJECT_DIR = STOCKFLOW; + const src = new LiveSource(); + expect(src.available()).toBe(true); + expect(src.unavailableReason()).toBeNull(); + }); +}); + +// The equivalence that makes this a safe refactor: the interface must be a pass-through. +describe.skipIf(!HAS_PROJECT)("LiveSource — equivalence with the direct reader", () => { + const saved = process.env.CONSORT_PROJECT_DIR; + beforeEach(() => { + process.env.CONSORT_PROJECT_DIR = STOCKFLOW; + }); + afterEach(() => { + if (saved === undefined) delete process.env.CONSORT_PROJECT_DIR; + else process.env.CONSORT_PROJECT_DIR = saved; + }); + + // Fields that legitimately differ between two reads a moment apart. + const stripVolatile = (s: DashboardState) => { + const { generatedAt: _g, snapshotAsOf: _s, ...rest } = s; + // sessionActive depends on transcript mtimes at read time. + return { ...rest, agents: rest.agents.map((a) => ({ ...a, sessionActive: null })) }; + }; + + it("getState() matches folding events+snapshot by hand", () => { + const src = new LiveSource(); + const events = src.events(); + expect(events.length).toBeGreaterThan(100); + + const generatedAt = new Date().toISOString(); + const byHand = fold(events, src.snapshot(events, generatedAt), undefined); + expect(stripVolatile(src.getState())).toEqual(stripVolatile(byHand)); + }); + + it("matches at a scrubbed position too", () => { + const src = new LiveSource(); + const events = src.events(); + const generatedAt = new Date().toISOString(); + for (const at of [0, 12, 210, 380]) { + const byHand = fold(events, src.snapshot(events, generatedAt), at); + expect(stripVolatile(src.getState(at)), `at=${at}`).toEqual(stripVolatile(byHand)); + } + }); + + it("events() returns the parsed log, oldest first", () => { + const events = new LiveSource().events(); + expect(events[0].timestamp <= events[events.length - 1].timestamp).toBe(true); + for (const e of events.slice(0, 20)) { + expect(typeof e.event).toBe("string"); + expect(typeof e.role).toBe("string"); + } + }); + + it("snapshot() supplies exactly the SnapshotInputs contract", () => { + const src = new LiveSource(); + const snap: SnapshotInputs = src.snapshot(src.events(), "2026-08-05T00:00:00.000Z"); + expect(snap.projectDir).toBe(STOCKFLOW); + expect(snap.generatedAt).toBe("2026-08-05T00:00:00.000Z"); + expect(Array.isArray(snap.handbacks)).toBe(true); + expect(typeof snap.sessionAgeMs).toBe("number"); + // The three things the audit showed are genuinely snapshot-only. + expect(snap.status === null || typeof snap.status === "object").toBe(true); + expect(snap.next === null || typeof snap.next === "object").toBe(true); + }); + + it("is a pure pass-through: repeated getState at a fixed index is stable", () => { + const src = new LiveSource(); + expect(stripVolatile(src.getState(210))).toEqual(stripVolatile(src.getState(210))); + }); + + // getState() now composes the interface (foldSource) instead of round-tripping + // consort.buildState(). That is a real change of path, so pin it against the old one. + it("foldSource matches the pre-refactor buildState path exactly", () => { + const src = new LiveSource(); + for (const at of [undefined, 0, 12, 210, 380] as (number | undefined)[]) { + expect(stripVolatile(src.getState(at)), `at=${at ?? "live"}`).toEqual(stripVolatile(buildState(at))); + } + }); + + it("readSource returns the same state as foldSource, plus the events, in one read", () => { + const src = new LiveSource(); + const both = readSource(src, 210); + expect(both.events.length).toBeGreaterThan(100); + expect(stripVolatile(both.state)).toEqual(stripVolatile(foldSource(src, 210))); + // the events are the source's own log, not a re-parse of something else + expect(both.events).toEqual(src.events()); + }); +}); + +// A source is meant to be substitutable — that is the whole point of the interface. This +// stand-in proves the fold needs nothing from the filesystem, which is what Phase 2's replay +// source will rely on. +describe("DashboardSource — a fake source satisfies the contract", () => { + const events: AgentLogEvent[] = [ + { timestamp: "2026-08-05T10:00:00.000Z", level: "info", role: "spec-author", event: "phase.start", message: "", metadata: { phase: "propose", feature_id: "F1" } }, + { timestamp: "2026-08-05T10:01:00.000Z", level: "info", role: "spec-author", event: "turn.usage", message: "", metadata: { cost_usd: 1.25, phase: "propose" } }, + ]; + + class FakeSource implements DashboardSource { + readonly mode = "replay" as const; + readonly capabilities: ReadonlySet = new Set(["timeline", "transport", "transcripts"]); + describe() { + return "fake-corpus"; + } + available() { + return true; + } + unavailableReason() { + return null; + } + events() { + return events; + } + snapshot(_e: AgentLogEvent[], generatedAt: string): SnapshotInputs { + return { + projectDir: "/fake", + next: null, + status: null, + handbacks: [], + sessionAgeMs: Infinity, + pendingPermission: null, + generatedAt, + }; + } + getState(upTo?: number) { + return fold(this.events(), this.snapshot(this.events(), "2026-08-05T10:02:00.000Z"), upTo); + } + } + + it("folds without any filesystem access", () => { + const s = new FakeSource().getState(); + expect(s.ok).toBe(true); + expect(s.feature).toBe("F1"); + expect(s.totalCost).toBeCloseTo(1.25); + expect(s.eventCount).toBe(2); + }); + + it("time-travels like the live source does", () => { + const src = new FakeSource(); + expect(src.getState(1).totalCost).toBe(0); + expect(src.getState(1).atLive).toBe(false); + expect(src.getState().atLive).toBe(true); + }); + + it("can claim replay-only capabilities the live source cannot", () => { + const src = new FakeSource(); + expect(hasCapability(src, "transcripts")).toBe(true); + expect(hasCapability(new LiveSource(), "transcripts")).toBe(false); + }); +}); diff --git a/apps/dashboard/lib/source.ts b/apps/dashboard/lib/source.ts new file mode 100644 index 00000000..9d843f57 --- /dev/null +++ b/apps/dashboard/lib/source.ts @@ -0,0 +1,241 @@ +// The dashboard source interface: where a run's data comes from. +// +// The merge plan's central finding is that the event-log reducer is mode-independent — a +// live tail and a finished replay log share an identical event vocabulary, so `fold()` +// works unchanged over either. What differs is only *acquisition*: live mode reads a +// watched `.sftdd/` directory and shells the feature-status CLI; replay mode reads a +// recorded corpus (`turns/index.json`, `recorded-artifacts/**`). +// +// So a source owes the fold exactly two things: +// +// events() — the run's event log, oldest first +// snapshot() — everything the log cannot supply (SnapshotInputs) +// +// After the 2026-08-05 snapshot audit, that second half is much smaller than it first +// looked. The fold reconstructs gates, stories, blockers, feature and lane from the log +// prefix whenever it can, and reconciles the disk snapshot against the log even at the +// live edge (snapshots go stale: `derived_phase` sits at "build" after a run ends). The +// snapshot is genuinely load-bearing for only three things: +// +// - test COUNTS (the log carries a handful of test_ids, not the list) +// - richer story statuses (real Consort statuses + acceptance flags) +// - gate detail (approval lands in next.json, not the log) +// +// That is the contract a replay source must satisfy from `workflow-state.json`. +// +// Capabilities describe what a mode can do, so panels degrade instead of disappearing — +// the plan's §2 capability matrix, in code. + +import { emptyState, fold } from "./reducer"; +// Type-only (erased at runtime), so importing from ./sources/replay here introduces no runtime +// import cycle even though replay.ts imports this module's DashboardSource. +import type { ParsedTranscript, TurnDetail } from "./sources/replay"; +import { + CAPABILITY_NAMES, + type AgentLogEvent, + type ArtifactContent, + type CapabilityName, + type DashboardState, + type Planning, + type SnapshotInputs, + type SourceMeta, + type SourceModeName, + type StepOutputs, +} from "./types"; + +// One vocabulary, declared in types.ts because DashboardState must reference it too. Both +// names are re-exported here so callers can keep importing them from the source module. +export const CAPABILITIES = CAPABILITY_NAMES; +export type Capability = CapabilityName; +export type SourceMode = SourceModeName; + +export interface DashboardSource { + readonly mode: SourceMode; + /** What this source can do. Drives capability-aware panels; see plan §2. */ + readonly capabilities: ReadonlySet; + + /** Human-readable identifier for the header — a project dir or a corpus name. */ + describe(): string; + + /** + * True when this source has something to read at all. Live mode returns false when the + * directory isn't a scaffolded Consort project; the caller turns that into an error + * state rather than an empty board that looks like a run with no events. + */ + available(): boolean; + /** Why `available()` is false, for the UI. Null when available. */ + unavailableReason(): string | null; + + /** The run's event log, oldest first. */ + events(): AgentLogEvent[]; + + /** + * Everything the fold needs that the log cannot supply. Takes the events because the + * active feature id (needed to key the status CLI and handbacks) is itself partly + * log-derived. + * + * `upTo` is the playhead, and it exists for sources whose snapshot half is genuinely + * historical. Live must ignore it — there is no way to know what the feature-status CLI + * would have said 200 events ago, which is the whole §3a constraint. Replay CAN honour it: + * the corpus snapshots `test-list.json` inside individual turns, so a scrubbed board can + * read the real test counts as of that point instead of hiding the bar. + */ + snapshot(events: AgentLogEvent[], generatedAt: string, upTo?: number): SnapshotInputs; + + /** + * The folded board. Implement with `foldSource(this, upTo, pinnedFeature)` unless the source + * can do something smarter (e.g. serving a precomputed state). + * + * @param upTo event index for time travel; omit for the live edge. + * @param pinnedFeature scope the board to one feature (FeatureSwitcher); a filter over the + * same playhead, not a seek. Source-agnostic — acquisition is unchanged, only which + * feature the fold shows — so it lives here rather than in a source's own reads. + */ + getState(upTo?: number, pinnedFeature?: string | null): DashboardState; + + /** + * Pairing health, for sources that correlate a log against a recorded corpus. + * + * Optional because it is meaningless in live mode — there is no corpus to disagree with. + * Declared here rather than having the API route reach for `ReplaySource` directly, so the + * route stays source-agnostic and a future third source can report drift the same way. + */ + correlationSummary?(upTo?: number, recentCount?: number): NonNullable; + + /** + * The correspondence tail as of the playhead, for folding into the event timeline. + * + * Optional and gated on the `correspondence` capability — a corpus without correspondence.jsonl + * omits it and no rows fold in. Declared here so /api/state stays source-agnostic rather than + * reaching for ReplaySource. Filtered to the same playhead as the fold so the conversation + * rewinds with the transport. + */ + correspondenceSummary?(upTo?: number, recentCount?: number): NonNullable; + + /** + * Recording fidelity, for sources where "is this capturing a full corpus?" is a real question. + * + * Optional and implemented only by the live source: a live build may run with the record lane + * on (mirroring `turns/` + `correspondence.jsonl` + per-turn snapshots as it goes) or off (only + * `agent-log.jsonl`). Replay omits it — a corpus is a finished recording, so it always has full + * fidelity and shows no banner. Declared here so /api/state stays source-agnostic rather than + * reaching for LiveSource directly. + */ + fidelity?(): NonNullable; + + /** + * The run's planning artifacts: proposals + t-shirt estimates, sprint backlog, plan gate. + * + * Optional and gated on the `planningBacklog` capability — a source without planning artifacts + * simply omits it, and the panel does not render. Both real sources implement it identically + * (live reads `.sftdd/{planning,sprints,features}`, replay reads the mirror under + * `recorded-artifacts/`), which is why the shape is one type and the route is source-agnostic. + * + * NOT part of the fold: planning is a static snapshot of the run's start, not timeline state, + * so it doesn't rewind with the transport (the plan gate was approved once). Served by + * /api/planning rather than carried in DashboardState. + */ + planning?(): Planning; + + /** + * An artifact named by the log (`artifact.written.path`), read at the project's current HEAD. + * + * The live half of the turn drill-down: a live project has no per-turn corpus, so the honest + * thing it can show for a log row is the file as it is NOW. Optional and gated on + * `artifactContent` — replay implements richer per-turn snapshots via `turns/` instead, so it + * does not provide this (its content is per-turn, reached through the turn route). Declared + * here so /api/artifact stays source-agnostic rather than reaching for LiveSource directly. + */ + artifactAtHead?(rel: string): ArtifactContent; + + /** + * The deliverables a lifecycle step produced, for the WorkflowGraph drill-down. + * + * Optional and gated on the `stepOutputs` capability. `node` is a `WorkflowNode.id`; `feature` + * scopes the per-feature entries (see topology's `STEP_OUTPUTS`) and is ignored by run-level + * ones. Only files that exist on disk are returned, so a node with nothing to show yields an + * empty `assets` list rather than dead links. Declared here so /api/step-outputs stays + * source-agnostic rather than reaching for a concrete source. + */ + stepOutputs?(node: string, feature?: string | null): StepOutputs; + + /** + * One step-output file's content, by the root-relative path `stepOutputs` handed out. + * + * The content half of the drill-down, paired with `stepOutputs` under the same capability. + * Replay resolves it under `recorded-artifacts/`; a live source would resolve it under the + * project's `.consort/`. Containment is the implementer's responsibility — the path is + * attacker-controlled over the API. + */ + stepOutputContent?(rel: string): ArtifactContent; + + /** + * A recorded turn's metadata, its transcript, and one produced file's per-turn snapshot — the + * turn drill-down, gated on the `transcripts` capability. + * + * Replay serves these from its own `turns/` corpus. A LIVE source serves them only when a + * companion record-lane corpus is configured (`CONSORT_RECORD_DIR`), by delegating to a + * ReplaySource over that dir — which is what upgrades a recording live board to replay-grade + * drill-down. Declared here (optional) so /api/turn stays source-agnostic and checks the + * capability + method presence rather than `source instanceof ReplaySource`. + */ + turn?(ordinal: number): TurnDetail | null; + transcript?(ordinal: number): ParsedTranscript | null; + file?(ordinal: number, rel: string): { kind: "code" | "artifact"; content: string | null; reason: string | null }; +} + +export function hasCapability(source: DashboardSource, cap: Capability): boolean { + return source.capabilities.has(cap); +} + +/** + * The default `getState`: read once, fold once. + * + * This is the whole interface in one line — `fold(events, snapshot)` — and it exists so + * every source shares one composition root instead of each reimplementing it. It also + * reads the log exactly once, which matters for a caller that wants both the events and + * the board (Phase 3's TurnPanel needs `artifact.written` paths alongside the state). + * + * Unavailability is handled here rather than in each source, so an unscaffolded project and + * a missing corpus produce the same shape: an empty board carrying `error`, never a + * zero-event board that reads as "a run that hasn't started". + */ +export function foldSource( + source: DashboardSource, + upTo?: number, + pinnedFeature?: string | null, +): DashboardState { + return readSource(source, upTo, pinnedFeature).state; +} + +/** + * Read a source once and return both halves, for callers that need the events themselves + * (artifact paths, the ticker) as well as the folded board — without paying for two reads. + * `foldSource` is this, minus the events. + */ +export function readSource( + source: DashboardSource, + upTo?: number, + pinnedFeature?: string | null, +): { events: AgentLogEvent[]; state: DashboardState } { + const generatedAt = new Date().toISOString(); + + if (!source.available()) { + return { + events: [], + state: { + ...emptyState(source.describe(), generatedAt), + error: source.unavailableReason() ?? "source unavailable", + }, + }; + } + + const events = source.events(); + // `upTo` reaches the snapshot as well as the fold, so a source with genuinely historical + // snapshot data can rewind it. Live ignores the argument by construction. `pinnedFeature` + // reaches only the fold — it re-scopes which feature is shown, not what is read from disk. + return { + events, + state: fold(events, source.snapshot(events, generatedAt, upTo), upTo, pinnedFeature), + }; +} diff --git a/apps/dashboard/lib/sources/index.ts b/apps/dashboard/lib/sources/index.ts new file mode 100644 index 00000000..8658eddb --- /dev/null +++ b/apps/dashboard/lib/sources/index.ts @@ -0,0 +1,74 @@ +// Source resolution: which mode the dashboard is running in. +// +// Per the plan's §Phase 2 mode selection: +// CONSORT_PROJECT_DIR → live +// CONSORT_CORPUS_DIR → replay +// both set → a mode switch in the header (default: live) +// +import { liveSource } from "./live"; +import { ReplaySource, corpusDir } from "./replay"; +import type { DashboardSource, SourceMode } from "../source"; + +export { LiveSource, liveSource } from "./live"; +// No `replaySource` counterpart on purpose — see the note at the foot of ./replay. +export { ReplaySource, clearCorpusCache, corpusDir } from "./replay"; + +export interface Resolution { + source: DashboardSource; + /** Modes the environment makes available; drives the header's mode switch. */ + available: SourceMode[]; + /** Set when a requested mode could not be honoured. */ + note: string | null; +} + +export function resolveSource(requested?: SourceMode): Resolution { + // Built per call rather than module-shared: CONSORT_CORPUS_DIR is read at construction, and + // a process-wide singleton would pin whatever the env said at import time — which breaks + // tests and any future per-request corpus selection. + const replay = corpusDir() ? new ReplaySource() : null; + + // A corpus counts as available only if it can actually be read. A configured-but-broken + // corpus must not offer a mode switch that lands on an error board. + const replayUsable = !!replay?.available(); + const available: SourceMode[] = replayUsable ? ["live", "replay"] : ["live"]; + + // Honour an explicit request when we can; otherwise say why not, and fall back to live. + // Live is the default even with a usable corpus: this app's primary job is watching a run + // in progress, and a corpus being on disk is not a reason to stop doing that. + if (requested === "replay") { + if (replayUsable) return { source: replay!, available, note: null }; + return { + source: liveSource, + available, + // Name the actual defect (missing dir / missing log / missing index), not just + // "unavailable" — a corpus that ships turns but no log is a real and specific case. + note: replay + ? `Replay unavailable — showing live. ${replay.unavailableReason()}` + : "CONSORT_CORPUS_DIR is not set, so there is no corpus to replay — showing live.", + }; + } + + // Live was requested (or nothing was). Flag a broken corpus config so a typo in + // CONSORT_CORPUS_DIR doesn't silently remove the mode switch. + const note = replay && !replayUsable ? `Corpus configured but unusable: ${replay.unavailableReason()}` : null; + + // "Live is the default" assumed live was usable. When it isn't — a corpus-only setup, with + // no scaffolded project — defaulting to live opens on an error page while a readable corpus + // sits right there. Prefer replay in that case, and say why, so the board is useful on first + // paint. An EXPLICIT live request is still honoured above... but only when nothing was asked + // for do we get to choose, which is what this branch is. + if (requested === undefined && replayUsable && !liveSource.available()) { + return { + source: replay!, + available, + note: `No live Consort project found (${liveSource.unavailableReason()}) — showing the recorded corpus instead.`, + }; + } + + return { source: liveSource, available, note }; +} + +/** The source for this request. Convenience for callers that don't offer a mode switch. */ +export function currentSource(requested?: SourceMode): DashboardSource { + return resolveSource(requested).source; +} diff --git a/apps/dashboard/lib/sources/live.ts b/apps/dashboard/lib/sources/live.ts new file mode 100644 index 00000000..a96eb901 --- /dev/null +++ b/apps/dashboard/lib/sources/live.ts @@ -0,0 +1,263 @@ +// The LIVE source: a Consort project being worked on right now. +// +// Reads a watched `.sftdd/` directory — `agent-log.jsonl` for the timeline, `next.json` and +// the feature-status CLI for the snapshot half, and Claude transcript mtimes for session +// liveness. All of that I/O already lived in lib/consort.ts and stays there; this file is +// the interface wrapper, so behavior is unchanged by construction rather than by careful +// re-transcription. (Moving ~340 lines of I/O wholesale is exactly how the derive.ts +// extraction broke two functions mid-move; the equivalence is the point, not the file +// layout.) +// +// Capabilities depend on whether a COMPANION record-lane corpus is configured (Phase B): +// +// Plain live build (no companion) — `agent-log.jsonl` + produced artifacts only. It claims: +// artifactContent — HEAD-only. It reads the file `artifact.written` named as it is NOW, not +// a per-turn snapshot (replay's kind), so it is strictly less and the panel +// says so. Claimed because the artifact panel reads HEAD. +// Deliberately NOT claimed here: transcripts / correspondence / stepOutputs — a plain live +// project has no `turns/` corpus, no correspondence.jsonl, no recorded-artifacts mirror. +// +// Live build WITH a companion record dir (CONSORT_RECORD_DIR / LAKEBASE_CONSORT_RECORD_DIR) — +// the drive's record lane writes a full ReplaySource-shaped corpus to a SEPARATE dir as it +// goes, while the agent-log is ALSO mirrored under `.consort/` so liveness is unaffected. So +// this source keeps events/snapshot/liveness from the live project and DELEGATES the rich +// drill-down (transcripts, correspondence, stepOutputs) to a ReplaySource over the record dir — +// the "rewind == replay while live" unlock. The FidelityBanner, whose visibility is keyed on +// the MISSING capabilities, then auto-hides. + +import { existsSync } from "node:fs"; +import { + noSftddMessage, + projectDir, + readArtifactAtHead, + readEvents, + readSnapshot, + recordDir, + sftddDir, +} from "../consort"; +import { loadPlanning } from "../planning"; +import { CAPABILITIES, foldSource, type Capability, type DashboardSource } from "../source"; +import { ReplaySource, classify, type ParsedTranscript, type TurnDetail } from "./replay"; +import { correlate, driftMessage, driftSeverity } from "../correlate"; +import { RECENT_EVENT_TAIL } from "../reducer"; +import type { AgentLogEvent, ArtifactContent, DashboardState, Planning, SnapshotInputs, SourceMeta, StepOutputs } from "../types"; + +const LIVE_CAPABILITIES: ReadonlySet = new Set([ + "timeline", + "transport", + "liveness", + "featureStatus", + "artifactPaths", + "artifactContent", // HEAD-only; see the header note + "planningBacklog", +]); + +// With a companion record-lane corpus, the live board additionally gains the three replay-grade +// drill-down capabilities, served from the record dir. Precomputed once; the getter picks between +// the two sets by whether a companion is present-and-readable right now. +const LIVE_CAPABILITIES_RECORDING: ReadonlySet = new Set([ + ...LIVE_CAPABILITIES, + "transcripts", + "correspondence", + "stepOutputs", +]); + +// Sanity: every capability named above must be a declared one. A typo would otherwise +// silently disable a panel forever. +for (const c of LIVE_CAPABILITIES_RECORDING) { + if (!CAPABILITIES.includes(c)) throw new Error(`live source declares unknown capability: ${c}`); +} + +export class LiveSource implements DashboardSource { + readonly mode = "live" as const; + + // Dynamic, not a fixed field: a companion record dir can appear (or its first turns can land) a + // few seconds into a build, and its capabilities must light up then — which is precisely how the + // FidelityBanner drops away mid-run. Cheap: one recordDir() env read + two existsSync (the + // companion's available()) per access, negligible next to the feature-status `lk` shell-out the + // same 1 Hz poll already pays for. + get capabilities(): ReadonlySet { + return this.companion() ? LIVE_CAPABILITIES_RECORDING : LIVE_CAPABILITIES; + } + + // The companion record-lane corpus, or null. `volatile` because the record dir is GROWING while + // we watch (a finished-corpus ReplaySource caches for the process lifetime; this must re-read). + // Only surfaced once it's actually readable (has a log + turns/index.json) — before the first + // turn lands, available() is false, so the rich capabilities stay off and the banner still shows + // "not captured yet" rather than the drill-down opening onto nothing. + private companion(): ReplaySource | null { + const dir = recordDir(); + if (!dir) return null; + const rs = new ReplaySource(dir, /* volatile */ true); + return rs.available() ? rs : null; + } + + describe(): string { + return projectDir(); + } + + available(): boolean { + return existsSync(sftddDir()); + } + + unavailableReason(): string | null { + // One definition, shared with consort.ts's own error path, so the two can't drift. + return this.available() ? null : noSftddMessage(projectDir()); + } + + events(): AgentLogEvent[] { + return readEvents(); + } + + snapshot(events: AgentLogEvent[], generatedAt: string): SnapshotInputs { + return readSnapshot(events, generatedAt); + } + + getState(upTo?: number, pinnedFeature?: string | null): DashboardState { + // The shared default: available() → events() → snapshot() → fold, reading the log once. + // Going through the interface rather than round-tripping consort.buildState() means + // there is exactly one path to a board, and a replay source inherits it unchanged. + return foldSource(this, upTo, pinnedFeature); + } + + // Planning reads straight from the live `.sftdd/`: planning/, sprints/ and features/ all sit + // there. The log is passed so the re-plan flag can count `propose` rounds. One root, since a + // live project has no recorded-artifacts mirror. (Unchanged by Phase B — the live project's own + // planning artifacts are authoritative; the companion mirror would say the same thing.) + planning(): Planning { + return loadPlanning([sftddDir()], readEvents()); + } + + // The live half of the turn drill-down: the artifact a log row named, read at HEAD. Path + // containment + text/size guards live in consort.readArtifactAtHead. Always available (it is + // what the `artifactContent` capability, which live always claims, promises), independent of any + // companion — the point-in-time per-turn snapshot is the companion's `file()` below. + artifactAtHead(rel: string): ArtifactContent { + return readArtifactAtHead(rel); + } + + // --- companion-backed drill-down (Phase B) --- + // + // Each delegates to a fresh volatile ReplaySource over the record dir. The `?? empty` fallbacks + // are defensive only: /api/turn, /api/step-outputs and /api/state all gate on the capability + + // method presence, and the capability is present only when the companion is readable — so in + // practice these are called only when companion() is non-null. The fallback keeps them + // type-total for the vanishing-companion race rather than throwing. + + correspondenceSummary(upTo?: number, recentCount?: number): NonNullable { + const rec = this.companion(); + if (!rec) return { recent: [] }; + // `upTo` indexes THIS source's live agent-log; the companion's mirror log is a different + // length (it starts at the recording, the live log carries prior features too). So resolve the + // playhead's horizon HERE, against the live events, and hand the companion the timestamp — it + // must not re-derive from its own mirror or scrubbed correspondence would misalign with the + // transport. Mirrors ReplaySource's own index→horizon math so the live edge and every scrub + // position agree with the event stream. + const events = this.events(); + const at = upTo === undefined ? events.length : Math.max(0, Math.min(Math.floor(upTo), events.length)); + const horizon = at > 0 ? events[at - 1]?.timestamp ?? null : null; + return rec.correspondenceSummary(undefined, recentCount, horizon); + } + + stepOutputs(node: string, feature?: string | null): StepOutputs { + return this.companion()?.stepOutputs(node, feature) ?? { node, feature: feature ?? null, assets: [] }; + } + + stepOutputContent(rel: string): ArtifactContent { + return this.companion()?.stepOutputContent(rel) ?? { path: rel, kind: classify(rel), content: null, reason: "(no companion recording)" }; + } + + turn(ordinal: number): TurnDetail | null { + return this.companion()?.turn(ordinal) ?? null; + } + + transcript(ordinal: number): ParsedTranscript | null { + return this.companion()?.transcript(ordinal) ?? null; + } + + file(ordinal: number, rel: string): { kind: "code" | "artifact"; content: string | null; reason: string | null } { + return this.companion()?.file(ordinal, rel) ?? { kind: classify(rel), content: null, reason: "(no companion recording)" }; + } + + // Whether the watched build is capturing the full record-lane corpus vs only the agent-log. + // + // FIXED in Phase B (the B1 spike's shipped-A3 bug): the record lane writes to a SEPARATE dir, + // NOT the watched `.consort/` (setting RECORD_DIR to the project's own `.consort/` would corrupt + // its agent-log via the mirror write). The old detection keyed on `.consort/turns` therefore + // always read "not recording" in the real setup. Now it keys off the CONFIGURED companion record + // dir being readable — the same condition that adds the transcripts/correspondence/stepOutputs + // capabilities, so `recording:true` and full-fidelity drill-down move together and the + // FidelityBanner (keyed on missing capabilities) hides exactly when recording is truly on. + fidelity(): NonNullable { + return { recording: this.companion() !== null }; + } + + // Pair the LIVE event stream against the companion's recorded turns, so a ticker row that begins + // a turn becomes an "open turn N" drill-down live — the same affordance replay has. Without this + // the live event rows are inert (the ticker keys clickability off `correlation.recentTurns`), so + // the only live entry points were WorkflowGraph nodes and correspondence rows. + // + // TWO things make this NOT a plain delegate to the companion's own correlationSummary: + // + // 1. INDEX SPACE. `recentTurns` is positional to the LIVE `recentEvents` the ticker renders. The + // companion mirror is a different-length log (it begins at the recording; the live log is + // prefixed with prior features' events), so its own correlation aligns to the wrong tail. + // 2. THE F1 PREFIX. correlate() is a per-role sequential cursor (correlate.ts): feeding it the + // whole live log against companion turns that only exist from the recording onward would let + // the earlier features' `phase.start`s consume THIS run's turns and mis-pair everything. So + // correlate only the RECORDED SUFFIX — the live events at/after the mirror's first timestamp — + // then shift the pairing indices back into live-log space. + // + // "Log ahead of the corpus" (a role-exhausted tail — the in-flight turn isn't recorded yet) is the + // NORMAL live edge, not drift, so it stays healthy (no DriftBanner). Only a role the companion + // never recorded, or a kit mismatch — i.e. a RECORD_DIR pointing at a different run — is surfaced. + correlationSummary(upTo?: number, recentCount = RECENT_EVENT_TAIL): NonNullable { + const liveEvents = this.events(); + const at = upTo === undefined ? liveEvents.length : Math.max(0, Math.min(Math.floor(upTo), liveEvents.length)); + const empty = { healthy: true, severity: "ok" as const, message: null, paired: 0, structural: 0, unpairedEvents: 0, kitVersionMatch: null as boolean | null, recentTurns: [] as (number | null)[] }; + + const rec = this.companion(); + if (!rec) return empty; + const firstTs = rec.events()[0]?.timestamp ?? null; + if (firstTs === null) return empty; + + // Where the recorded region begins in the live log (its events share timestamps with the mirror). + let base = liveEvents.findIndex((e) => e.timestamp >= firstTs); + if (base < 0) base = liveEvents.length; + + const suffix = liveEvents.slice(base, at); + const report = correlate(suffix, rec.turns(), rec.provenance()?.kit_commit ?? null, suffix); + + // pairing eventIndex is relative to `suffix`; shift into live-log space so recentTurns lines up + // with the LIVE recentEvents tail the ticker maps positionally. + const byLiveIndex = new Map(); + for (const p of report.pairings) byLiveIndex.set(base + p.eventIndex, p.turnOrdinal); + const start = Math.max(0, at - recentCount); + const recentTurns: (number | null)[] = []; + for (let i = start; i < at; i++) recentTurns.push(byLiveIndex.get(i) ?? null); + + const absent = report.unpairedEvents.filter((u) => u.reason === "role-absent"); + const healthy = absent.length === 0 && report.kitVersionMatch !== false; + // Severity must track THIS LOCAL `healthy`, not `report.healthy`. `report.healthy` also trips on + // a role-exhausted tail (the in-flight turn the companion hasn't recorded yet) — the NORMAL live + // edge this source deliberately treats as healthy. Keying `driftSeverity(report)` off it directly + // would return "info" and surface the banner (with a null message) on every recording live board. + // So short-circuit to "ok" whenever the local rule is healthy; only classify warning/info when it + // isn't (which, by construction, means role-absent or a kit mismatch — exactly what driftSeverity + // separates). + const severity = healthy ? "ok" : driftSeverity(report); + return { + healthy, + severity, + message: healthy ? null : driftMessage(report), + paired: report.pairings.length, + structural: report.structural.length, + unpairedEvents: report.unpairedEvents.length, + kitVersionMatch: report.kitVersionMatch, + recentTurns, + }; + } +} + +/** The process-wide live source. Stateless apart from the caches inside consort.ts. */ +export const liveSource = new LiveSource(); diff --git a/apps/dashboard/lib/sources/replay.test.ts b/apps/dashboard/lib/sources/replay.test.ts new file mode 100644 index 00000000..3614b7dc --- /dev/null +++ b/apps/dashboard/lib/sources/replay.test.ts @@ -0,0 +1,659 @@ +/** + * Replay source tests. + * + * These run against the REAL corpus when one is on disk, and skip otherwise — the corpus + * lives in the consort repo / plugin marketplace, not in this repo (only the log and turns + * index are vendored as fixtures). `CONSORT_TEST_CORPUS_DIR` overrides the search. + * + * The pure parsers (`parseTranscript`, `classify`, `readFileContent`) are tested + * unconditionally, since they take strings and paths rather than a corpus. + * + * What these deliberately assert, per the plan's warning that PR #10's log-derived inference + * was fitted to ONE log: the replay source must NOT hand the fold the corpus's end-state + * artifacts. Recorded `recorded-artifacts/` describes the run's finish, and feeding that in + * would resurrect the "finished story at event 12" bug. + */ +import { describe, it, expect } from "vitest"; +import { existsSync, mkdtempSync, mkdirSync, symlinkSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ReplaySource, classify, clearCorpusCache, parseTranscript, readFileContent } from "./replay"; +import { driftMessage } from "../correlate"; +import { RECENT_EVENT_TAIL } from "../reducer"; + +// The corpus ships in the consort repo; the marketplace checkout is where it lands locally. +// v0.3.7 relocated it from examples/sftdd-scenarios/ to examples/replay/corpora/ — try the new +// location first, then the legacy one, so the tests run against either kit version on disk. +const MARKETPLACE = join(process.env.HOME ?? "", ".claude/plugins/marketplaces/databricks-solutions"); +const CANDIDATES = [ + process.env.CONSORT_TEST_CORPUS_DIR, + join(MARKETPLACE, "examples/replay/corpora/stockflow-rerecord"), + join(MARKETPLACE, "examples/sftdd-scenarios/stockflow-rerecord"), +].filter((p): p is string => !!p); + +const CORPUS = CANDIDATES.find((p) => existsSync(join(p, "agent-log.jsonl"))); +const KIT_COMMIT = "cad5f5fb5eb7e59a703722284b6a5858ddf3fff0"; + +// --------------------------------------------------------------------------- +// Pure parsers — no corpus needed. + +describe("replay — classify", () => { + it("treats artifact-root bookkeeping as artifact even when it looks like code", () => { + // The rule that matters: a JSON under the artifact root is workflow state, not the code the + // run produced. Showing it in a code view would bury the actual diff. v0.3.7 renamed the root + // .sftdd/ → .consort/, so both (and legacy .tdd/) must classify the same way. + expect(classify(".consort/features/F1/test-list.json")).toBe("artifact"); + expect(classify(".consort/planning/feature-proposals.md")).toBe("artifact"); + expect(classify(".sftdd/features/F1/test-list.json")).toBe("artifact"); + expect(classify(".sftdd/planning/feature-proposals.md")).toBe("artifact"); + }); + + it("classifies by directory prefix and by extension", () => { + expect(classify("app/api/stock/route.ts")).toBe("code"); + expect(classify("tests/test_stock.py")).toBe("code"); + expect(classify("alembic/versions/001_init.py")).toBe("code"); + expect(classify("lib/thing.tsx")).toBe("code"); // extension alone is enough + expect(classify("README.md")).toBe("artifact"); + expect(classify("docs/design.md")).toBe("artifact"); + expect(classify("noext")).toBe("artifact"); + }); +}); + +describe("replay — parseTranscript", () => { + it("splits prompt / tools / reasoning and strips the prompt fence", () => { + const md = [ + "# Turn", + "## Prompt", + "```", + "do the thing", + "and the other thing", + "```", + "## Tools used", + "- Read(a.ts)", + "- Edit(b.ts)", + "ignored non-list line", + "## Final reasoning", + "I did the thing.", + ].join("\n"); + expect(parseTranscript(md)).toEqual({ + prompt: "do the thing\nand the other thing", + tools: ["Read(a.ts)", "Edit(b.ts)"], + reasoning: "I did the thing.", + }); + }); + + it("handles missing sections and an unfenced prompt", () => { + expect(parseTranscript("## Prompt\nbare prompt")).toEqual({ + prompt: "bare prompt", + tools: [], + reasoning: "", + }); + expect(parseTranscript("")).toEqual({ prompt: "", tools: [], reasoning: "" }); + // Headers are matched case-insensitively, as in the original. + expect(parseTranscript("## PROMPT\nx").prompt).toBe("x"); + }); +}); + +describe("replay — readFileContent guards", () => { + const dir = mkdtempSync(join(tmpdir(), "replay-file-")); + const put = (rel: string, body: string) => { + const p = join(dir, "files", rel); + mkdirSync(join(p, ".."), { recursive: true }); + writeFileSync(p, body); + }; + + it("refuses to read outside the turn's snapshot directory", () => { + // Found in review, and it worked: `file(0, "../".repeat(30) + "etc/passwd")` returned the + // real /etc/passwd, 9344 bytes, with reason null. `rel` reaches here from a request + // parameter once Phase 3's TurnPanel fetches by path, so this is an arbitrary-file-read + // primitive. The containment check runs before any stat, so nothing is even probed. + put("app/a.ts", "export const a = 1;\n"); + for (const evil of [ + "../".repeat(30) + "etc/passwd", + "../../../provenance.json", + "../turn.json", + "/etc/passwd", // absolute paths must not escape either + "app/../../turn.json", // traversal after a legitimate-looking prefix + "../files-evil/x.ts", // merely SHARING the prefix must not pass a naive startsWith + ]) { + // What matters is that nothing is read. The REASON differs by case and that is fine: + // a target that exists outside the root is "(outside…)", while one that doesn't exist at + // all fails realpath first and reports "(not captured…)" — which is also the honest + // answer, and deliberately doesn't disclose whether a path outside the corpus exists. + const r = readFileContent(dir, evil); + expect(r.content, `escaped with ${evil}`).toBeNull(); + expect(r.reason, `escaped with ${evil}`).toMatch(/outside this turn's snapshot|not captured/); + } + // ...and normal relative paths still work, including a harmless inner `..`. + expect(readFileContent(dir, "app/../app/a.ts").content).toBe("export const a = 1;\n"); + }); + + it("refuses to follow a symlink out of the snapshot directory", () => { + // The `../` fix was not enough, and review caught it: `resolve()` is purely LEXICAL and does + // not follow links, so a corpus containing `files/leak.md -> /etc/passwd` sailed through + // containment and served 9344 bytes of real /etc/passwd over HTTP 200. A corpus is + // third-party data — it arrives from a git checkout — so a malicious or careless one must + // not be able to read the host filesystem. Only realpath closes this. + symlinkSync("/etc/passwd", join(dir, "files", "leak.md")); + const r = readFileContent(dir, "leak.md"); + expect(r.content).toBeNull(); + // One reason for every "can't safely read this" case now that the guard is shared + // (lib/safepath.ts): escaped and non-existent are deliberately indistinguishable, so an + // attacker can't use the reason string to probe whether an out-of-tree path exists. + expect(r.reason).toBe("(not captured in this turn's snapshot)"); + + // A symlink INSIDE the snapshot is still fine — containment, not a ban on links. + put("real/inner.ts", "export const x = 1;\n"); + symlinkSync(join(dir, "files", "real", "inner.ts"), join(dir, "files", "link-inner.ts")); + expect(readFileContent(dir, "link-inner.ts").content).toBe("export const x = 1;\n"); + + // A link to a DIRECTORY outside is also blocked, so traversal can't resume past it. + symlinkSync("/etc", join(dir, "files", "etcdir")); + expect(readFileContent(dir, "etcdir/passwd").content).toBeNull(); + }); + + it("reads a text file, and reports why it can't read the others", () => { + put("app/a.ts", "export const a = 1;\n"); + put("uv.lock", "lock"); + put("img.png", "\x89PNG"); + put("big.ts", "x".repeat(64 * 1024 + 1)); + + expect(readFileContent(dir, "app/a.ts")).toEqual({ content: "export const a = 1;\n", reason: null }); + // Each guard names itself, so a UI can explain the gap instead of showing an empty pane. + expect(readFileContent(dir, "uv.lock").reason).toBe("(skipped: lock file)"); + expect(readFileContent(dir, "img.png").reason).toContain("binary/non-text"); + expect(readFileContent(dir, "big.ts").reason).toContain("too large to embed"); + expect(readFileContent(dir, "nope.ts").reason).toBe("(not captured in this turn's snapshot)"); + // A directory is not a file — must not throw or read as empty content. + expect(readFileContent(dir, "app").reason).toBe("(not captured in this turn's snapshot)"); + }); + + it("cleans up", () => { + rmSync(dir, { recursive: true, force: true }); + expect(existsSync(dir)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Unavailability — the loud-degradation path, testable without a real corpus. + +describe("replay — unavailability names the missing piece", () => { + it("distinguishes unset, missing, no-log and no-index", () => { + expect(new ReplaySource("").unavailableReason()).toContain("CONSORT_CORPUS_DIR is not set"); + expect(new ReplaySource("/nonexistent/corpus").unavailableReason()).toContain("not found"); + + const dir = mkdtempSync(join(tmpdir(), "replay-corpus-")); + // A corpus with turns but no log is exactly the pre-6e73019 situation the plan describes, + // so it gets its own message rather than a generic failure. + mkdirSync(join(dir, "turns"), { recursive: true }); + writeFileSync(join(dir, "turns", "index.json"), JSON.stringify({ turns: [] })); + expect(new ReplaySource(dir).unavailableReason()).toContain("no run log"); + + // ...and a log with no turns index cannot be correlated. + writeFileSync(join(dir, "agent-log.jsonl"), ""); + rmSync(join(dir, "turns"), { recursive: true, force: true }); + expect(new ReplaySource(dir).unavailableReason()).toContain("cannot correlate"); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("an unavailable source folds to an error board, not an empty run", () => { + // The whole point of `available()`: zero events must never render as "a run that hasn't + // started yet". Same contract live has. + const s = new ReplaySource("/nonexistent/corpus"); + const state = s.getState(); + expect(state.error).toContain("not found"); + expect(state.eventCount).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// The real corpus. + +describe.skipIf(!CORPUS)("replay — the real stockflow-rerecord corpus", () => { + const src = () => new ReplaySource(CORPUS!); + + it("is available, and describes itself by scenario name", () => { + const s = src(); + expect(s.available()).toBe(true); + expect(s.unavailableReason()).toBeNull(); + expect(s.describe()).toBe("stockflow-rerecord (replay)"); + expect(s.provenance()?.kit_commit).toBe(KIT_COMMIT); + }); + + it("reads the same 421 events as the vendored fixture", () => { + // The fixture is a byte copy of this file; if they ever diverge, every reducer test + // asserting corpus behavior is testing something the replay source doesn't serve. + expect(src().events().length).toBe(421); + }); + + it("declares replay-only capabilities and withholds live-only ones", () => { + const c = src().capabilities; + expect(c.has("transcripts")).toBe(true); + expect(c.has("artifactContent")).toBe(true); + expect(c.has("transport")).toBe(true); + // A finished corpus has no live session and no status CLI — claiming these would make + // panels render banners about liveness that can never be true. + expect(c.has("liveness")).toBe(false); + expect(c.has("featureStatus")).toBe(false); + }); + + it("correlates cleanly and reports no drift", () => { + const r = src().correlation(); + expect(r.healthy).toBe(true); + expect(r.pairings.length).toBe(71); + expect(r.structural.length).toBe(10); + expect(driftMessage(r)).toBeNull(); + }); + + it("folds to a shipped board at the live edge", () => { + const s = src().getState(); + expect(s.error).toBeNull(); + expect(s.eventCount).toBe(421); + expect(s.lane).toBe("complete"); + expect(s.feature).toBe("F6-split-tracking-code"); + expect(s.atLive).toBe(true); + // The log-derived halves still work through this source — it is the same fold. + expect(s.stories.length).toBe(3); + expect(s.stories.every((st) => st.status === "done")).toBe(true); + }); + + it("serves transcripts and per-turn file snapshots", () => { + const s = src(); + const t = s.turn(0); + expect(t?.role).toBe("spec-author"); + expect(t?.produced).toContain(".sftdd/planning/feature-proposals.md"); + + const tr = s.transcript(0); + expect(tr?.prompt.length).toBeGreaterThan(0); + + // The file this turn produced is readable AT that turn — the thing live cannot do. + const f = s.file(0, ".sftdd/planning/feature-proposals.md"); + expect(f.kind).toBe("artifact"); + expect(f.content).toContain("##"); + expect(f.reason).toBeNull(); + }); + + it("blocks traversal through the public file() accessor too", () => { + // readFileContent is guarded, but assert it through the method a route will actually call. + const r = src().file(0, "../".repeat(30) + "etc/passwd"); + expect(r.content).toBeNull(); + // Shared guard folds escaped and non-existent into one reason (see the symlink test). + expect(r.reason).toBe("(not captured in this turn's snapshot)"); + }); + + it("resolves every turn in the corpus, whatever fields it happens to carry", () => { + // Found by driving the route: turn.json's shape is far more optional than first typed. + // Measured across all 126 turns — `mode` on 36, `story` on 100, `role` on 72, `ac` on 11, + // 19 turns with neither mode nor story. Treating `mode` as always-present made the panel's + // header read a missing key. Assert every turn resolves and the invariant fields hold. + const s = src(); + const turns = s.turns(); + expect(turns.length).toBe(126); + for (const t of turns) { + const d = s.turn(t.ordinal); + expect(d, `turn ${t.ordinal} did not resolve`).not.toBeNull(); + expect(typeof d!.label).toBe("string"); + expect(typeof d!.kind).toBe("string"); + expect(Array.isArray(d!.produced)).toBe(true); + expect(Array.isArray(d!.deleted)).toBe(true); + } + // ...and the optionality is real, not a theory: some turns genuinely lack each field. + expect(turns.some((t) => t.mode == null)).toBe(true); + expect(turns.some((t) => t.role == null)).toBe(true); + expect(turns.some((t) => t.mode == null && t.story == null)).toBe(true); + }); + + it("declares a transcript exactly when one exists", () => { + // `hasTranscript` is ABSENT (not false) on the 57 turns without one, so the guard relies on + // undefined being falsy. Swept: 69 declared, 69 returned, 0 mismatches. + const s = src(); + let declared = 0; + for (const t of s.turns()) { + const has = t.hasTranscript === true; + if (has) declared++; + expect(!!s.transcript(t.ordinal), `turn ${t.ordinal}`).toBe(has); + } + expect(declared).toBe(69); + }); + + it("returns null for turns and files it doesn't have, rather than throwing", () => { + const s = src(); + expect(s.turn(99999)).toBeNull(); + expect(s.transcript(99999)).toBeNull(); + expect(s.file(99999, "a.ts").reason).toBe("(unknown turn)"); + // A gate turn has no transcript; asking must be safe. + const gate = s.turns().find((t) => t.kind === "approve-gate" && !t.hasTranscript); + if (gate) expect(s.transcript(gate.ordinal)).toBeNull(); + }); + + // --- the capability live cannot have --- + + it("rewinds test counts, so a scrubbed board shows real historical numbers", () => { + const s = src(); + // At the live edge, F6's final list: 25 items, all green. + const live = s.getState(); + expect(live.progress.testTotal).toBe(25); + expect(live.progress.testByStatus.green).toBe(25); + expect(live.progress.testsHistorical).toBe(true); + + // Scrubbed back into F1, the counts must be F1's AND from that moment — not F6's, and not + // F1's end state. Measured snapshots for F1 grow 17 → 23 → 32 as stories are broken down. + const mid = s.getState(120); + expect(mid.atLive).toBe(false); + expect(mid.feature).toBe("F1-stock-visibility"); + // This is the assertion that would fail if the source handed over end-state artifacts: + // F1 finishes at 32 tests, so a historical read must be strictly smaller here. + expect(mid.progress.testTotal).toBeGreaterThan(0); + expect(mid.progress.testTotal).toBeLessThan(32); + // ...and unlike live, the bar is honest rather than hidden. + expect(mid.progress.testsHistorical).toBe(true); + }); + + it("test totals track the recorded snapshots exactly, including a rework shrink", () => { + // Swept rather than spot-checked, per the LaneGraph lesson — and the sweep immediately + // refuted the obvious invariant. Test totals are NOT monotonic: turn 41 is a + // `revise-route` (spec-author rework) that DELETES five acceptance criteria for + // S3-sku-detail-view and rewrites test-list.json from 34 items back to 23. F1's recorded + // sequence is 17, 17, 23, 23, 34, 23, 32, 32 — a real rework, not a mis-attribution. + // + // So the honest assertion is that every value the board shows is one the corpus actually + // recorded, not that it only ever grows. A snapshot picked from the wrong turn would + // still be caught, because it would have to be a total the corpus never wrote for that + // feature at that point. + const RECORDED: Record = { + "F1-stock-visibility": [17, 23, 34, 32], + "F6-split-tracking-code": [14, 19, 25], + }; + const s = src(); + for (let at = 0; at <= 421; at += 7) { + const st = s.getState(at); + if (st.progress.testTotal === 0) continue; // before this feature had a list + const allowed = RECORDED[st.feature ?? ""] ?? []; + expect(allowed, `at=${at} feature=${st.feature}`).toContain(st.progress.testTotal); + } + }); + + it("reflects the rework shrink at the playhead where it happened", () => { + // Pin the shrink directly, since it is the most surprising thing replay's test bar does + // and a future "fix" to make totals monotonic would silently break it. + const s = src(); + const totals = new Set(); + for (let at = 0; at <= 200; at++) { + const st = s.getState(at); + if (st.feature === "F1-stock-visibility" && st.progress.testTotal > 0) { + totals.add(st.progress.testTotal); + } + } + // 34 appears (turn 39's list) and so does the smaller 23 that follows the rework. + expect(totals.has(34)).toBe(true); + expect(totals.has(23)).toBe(true); + }); + + it("does not show a turn's snapshot until that turn has finished", () => { + // Found in review: a pairing marks where a turn STARTS, but the file it snapshots is + // written during the turn. Attributing the snapshot to the start showed testTotal = 17 at + // event 44 while the log's `artifact.written` for that very file is event 45 — the future + // leaking into the past. Counts must not appear before the log says the file exists. + const s = src(); + const ev = s.events(); + const writeIdx = ev.findIndex((e) => { + const md = (e.metadata ?? {}) as Record; + return e.event === "artifact.written" && String(md.path ?? "").endsWith("F1-stock-visibility/test-list.json"); + }); + expect(writeIdx).toBeGreaterThan(0); // the corpus does record it + // At the event just before the file is written, no count may be shown. + expect(s.getState(writeIdx).progress.testTotal).toBe(0); + }); + + it("takes the highest paired ordinal, not the last pairing", () => { + // Found in review. Pairings are ordered by eventIndex while ordinals come from independent + // per-role cursors, so they aren't guaranteed monotonic; trusting the last one would + // discard every snapshot above it. This corpus is monotone (0 inversions across 71 + // pairings) — which is exactly why the sweep can't catch a regression here — so assert the + // property that makes the code correct rather than only its output. + const p = src().correlation().pairings; + const last = p[p.length - 1].turnOrdinal; + const max = Math.max(...p.map((x) => x.turnOrdinal)); + expect(last).toBe(max); // documents WHY the corpus can't catch it + // ...and the live edge still shows the final counts, which is what the max protects. + expect(src().getState().progress.testTotal).toBe(25); + }); + + it("caches corpus reads across separate instances", () => { + // The docstring promises a process-lifetime cache, but resolveSource() builds a fresh + // source per request, so a per-INSTANCE cache would never survive one. Measured cold ~18ms + // vs warm ~0ms against a 1 s poll — the optimization has to outlive the instance. + clearCorpusCache(); + const cold = Date.now(); + new ReplaySource(CORPUS!).getState(200); + const coldMs = Date.now() - cold; + + const warm = Date.now(); + for (let i = 0; i < 5; i++) new ReplaySource(CORPUS!).getState(200); // 5 fresh instances + const warmMs = (Date.now() - warm) / 5; + + // Generous bound: the point is that a fresh instance doesn't re-read the corpus, not a + // precise timing. Five cold reads would cost ~5x the first one. + expect(warmMs).toBeLessThan(Math.max(coldMs, 4)); + }); + + it("aligns recentTurns positionally with the fold's recentEvents", () => { + // The ticker zips these two arrays by index to decide which rows open a turn. A shift of + // one shows the WRONG transcript and the WRONG code — exactly the silent mis-mapping + // correlate.ts exists to prevent — so alignment is asserted against the real pairings + // rather than trusted. + const s = src(); + const full = s.correlation().pairings; + const byEvent = new Map(full.map((p) => [p.eventIndex, p.turnOrdinal])); + + for (const at of [0, 1, 39, 40, 41, 120, 260, 421]) { + const st = s.getState(at); + const sum = s.correlationSummary(at); + // Same length as the tail the board actually ships. + expect(sum.recentTurns.length, `at=${at}`).toBe(st.recentEvents.length); + // And every entry matches what the full-log correlation says for that absolute event. + const start = Math.max(0, at - RECENT_EVENT_TAIL); + sum.recentTurns.forEach((ord, i) => { + expect(ord, `at=${at} row ${i}`).toBe(byEvent.get(start + i) ?? null); + }); + } + }); + + it("points every openable ticker row at a turn whose role matches the event", () => { + // The strongest cheap check on alignment: a pairing claims THIS event began THAT turn, so + // the turn's role must be the event's role. A shift would mismatch almost immediately. + const s = src(); + const at = 200; + const st = s.getState(at); + const sum = s.correlationSummary(at); + let checked = 0; + sum.recentTurns.forEach((ord, i) => { + if (ord === null) return; + expect(s.turn(ord)?.role).toBe(st.recentEvents[i].role); + checked++; + }); + expect(checked).toBeGreaterThan(0); // the window really does contain openable rows + }); + + it("summarises correlation health for the wire without shipping every pairing", () => { + const sum = src().correlationSummary(); + expect(sum.healthy).toBe(true); + expect(sum.message).toBeNull(); // nothing to warn about on a matching corpus + expect(sum.paired).toBe(71); + expect(sum.structural).toBe(10); + expect(sum.unpairedEvents).toBe(0); + expect(sum.kitVersionMatch).toBe(true); + // The full report has 71 pairings; the summary must not carry them. + expect(sum).not.toHaveProperty("pairings"); + }); + + it("checks the kit version even at the very start of the log", () => { + // Found in review: the stamp lives on the first event, so an empty prefix carried no + // version and a genuine mismatch reported healthy at the transport's left edge. + const r = src().correlation(0); + expect(r.kitVersion.log).toBe(KIT_COMMIT); + expect(r.kitVersionMatch).toBe(true); + }); + + it("shows no test bar before the run has a test list", () => { + // Early on there is genuinely nothing to show. The honest answer is 0 + no bar, not the + // end state — this is the "finished story at event 12" bug, restated for test counts. + const early = src().getState(5); + expect(early.progress.testTotal).toBe(0); + expect(early.progress.testsHistorical).toBe(false); + }); + + it("does NOT import the corpus's end-state stories or gates", () => { + // The plan warns that PR #10's log-derived inference was fitted to one log. The guard is + // that replay supplies ONLY test counts; stories/gates/phase stay log-derived, so a + // scrubbed board cannot show a story as done before the log says so. + const s = src(); + const snap = s.snapshot(s.events(), "2026-08-06T00:00:00.000Z", 120); + expect(snap.status?.stories).toBeUndefined(); + expect(snap.status?.gates).toBeUndefined(); + expect(snap.status?.derived_phase).toBeUndefined(); + expect(snap.next).toBeNull(); + // No live-only inputs are faked. + expect(snap.sessionAgeMs).toBe(Infinity); + expect(snap.handbacks).toEqual([]); + expect(snap.pendingPermission).toBeNull(); + }); + + it("stays pure: the same playhead always folds to the same board", () => { + // `generatedAt`/`snapshotAsOf` are wall-clock stamps of when the read happened, so they + // are volatile by design — same convention as source.test.ts. + const stripVolatile = (s: ReturnType) => { + const { generatedAt: _g, snapshotAsOf: _s, ...rest } = s; + return rest; + }; + const s = src(); + expect(stripVolatile(s.getState(200))).toEqual(stripVolatile(s.getState(200))); + // ...and a fresh source agrees with a warm-cached one, so the caches can't skew a read. + expect(stripVolatile(new ReplaySource(CORPUS!).getState(200))).toEqual( + stripVolatile(s.getState(200)), + ); + }); + + it("clamps an out-of-range playhead like live does", () => { + const s = src(); + expect(s.getState(-5).atEventIndex).toBe(0); + expect(s.getState(99999).atEventIndex).toBe(421); + expect(s.getState(99999).atLive).toBe(true); + }); +}); + +describe.skipIf(!CORPUS)("replay — step outputs", () => { + const src = () => new ReplaySource(CORPUS!); + + it("lists run-level deliverables for the plan node (no feature needed)", () => { + const out = src().stepOutputs("plan"); + expect(out.node).toBe("plan"); + expect(out.feature).toBeNull(); + const names = out.assets.map((a) => a.name); + expect(names).toContain("feature-proposals.md"); + expect(names).toContain("estimates.json"); + // Every listed asset carries a root-relative path and a kind. + for (const a of out.assets) { + expect(a.path.startsWith("/")).toBe(false); + expect(["code", "artifact"]).toContain(a.kind); + } + }); + + it("scopes per-feature deliverables to the feature in force", () => { + const out = src().stepOutputs("design", "F1-stock-visibility"); + expect(out.feature).toBe("F1-stock-visibility"); + const paths = out.assets.map((a) => a.path); + // Run-level design docs AND the feature's own spec/db-design, all under this feature. + expect(paths).toContain("design/design-guide.md"); + expect(paths).toContain("features/F1-stock-visibility/feature-spec.md"); + expect(paths).toContain("features/F1-stock-visibility/db-design.md"); + expect(paths.every((p) => !p.includes(""))).toBe(true); + }); + + it("drops per-feature entries when no feature is in scope", () => { + const out = src().stepOutputs("design"); + // The run-level design docs still show; the per-feature ones are skipped, not broken links. + expect(out.assets.some((a) => a.path === "design/design-guide.md")).toBe(true); + expect(out.assets.some((a) => a.path.startsWith("features/"))).toBe(false); + }); + + it("expands a directory spec (build cycles) into its files", () => { + const out = src().stepOutputs("build", "F1-stock-visibility"); + const cyclePaths = out.assets.filter((a) => a.path.startsWith("cycles/F1-stock-visibility/")); + expect(cyclePaths.length).toBeGreaterThan(0); + }); + + it("returns empty assets for a node with no step-output mapping", () => { + expect(src().stepOutputs("shipped").assets).toEqual([]); + }); + + it("reads a listed deliverable's content", () => { + const out = src().stepOutputs("plan"); + const proposals = out.assets.find((a) => a.name === "feature-proposals.md")!; + const content = src().stepOutputContent(proposals.path); + expect(content.content).not.toBeNull(); + expect(content.reason).toBeNull(); + expect(content.path).toBe(proposals.path); + }); + + it("refuses to read outside recorded-artifacts (containment)", () => { + const escaped = src().stepOutputContent("../../../../etc/passwd"); + expect(escaped.content).toBeNull(); + expect(escaped.reason).toBe("(not found in recorded artifacts)"); + }); +}); + +// Correspondence lives on the newer corpora (stockflow-full ships both agent-log AND +// correspondence.jsonl); stockflow-rerecord ships none, which is a case worth pinning too. +const FULL = [ + process.env.CONSORT_TEST_FULL_CORPUS_DIR, + join(MARKETPLACE, "examples/replay/corpora/stockflow-full"), +].filter((p): p is string => !!p).find((p) => existsSync(join(p, "correspondence.jsonl")) && existsSync(join(p, "agent-log.jsonl"))); + +describe.skipIf(!CORPUS)("replay — correspondence absent", () => { + it("returns an empty tail when the corpus ships no correspondence.jsonl", () => { + // stockflow-rerecord has an agent-log but no correspondence — the summary is empty, not null. + expect(new ReplaySource(CORPUS!).correspondenceSummary().recent).toEqual([]); + }); +}); + +describe.skipIf(!FULL)("replay — correspondence (stockflow-full)", () => { + const src = () => new ReplaySource(FULL!); + + it("folds a recent correspondence tail aligned to the playhead", () => { + const s = src(); + const full = s.correspondenceSummary().recent; + expect(full.length).toBeGreaterThan(0); + // Each row is render-ready. + for (const r of full) { + expect(typeof r.at).toBe("string"); + expect(typeof r.text).toBe("string"); + expect([null, "approved", "validated"]).toContain(r.outcome); + } + // The final exchange carries the run's completion (a merge / promote). + expect(full[full.length - 1].outcome).not.toBeNull(); + }); + + it("rewinds with the transport: an early playhead shows less than the live edge", () => { + const s = src(); + const early = s.correspondenceSummary(20).recent; + const live = s.correspondenceSummary().recent; + // Everything shown early happened at or before the early playhead's newest event. + const events = s.events(); + const horizon = events[20 - 1]?.timestamp; + for (const r of early) expect(r.at <= horizon!).toBe(true); + // And the live edge has surfaced at least as much conversation. + expect(live.length).toBeGreaterThanOrEqual(early.length); + }); + + it("shows nothing before the first event", () => { + expect(src().correspondenceSummary(0).recent).toEqual([]); + }); + + it("carries an approved gate exchange in the tail somewhere", () => { + // Fold the whole run (large tail) and confirm an approval surfaced. + const all = src().correspondenceSummary(undefined, 500).recent; + expect(all.some((r) => r.outcome === "approved")).toBe(true); + }); +}); diff --git a/apps/dashboard/lib/sources/replay.ts b/apps/dashboard/lib/sources/replay.ts new file mode 100644 index 00000000..630de327 --- /dev/null +++ b/apps/dashboard/lib/sources/replay.ts @@ -0,0 +1,718 @@ +// The REPLAY source: a recorded corpus, read from disk. +// +// Layout (examples/sftdd-scenarios// in the consort repo): +// +// agent-log.jsonl the full run — same vocabulary as a live log +// provenance.json kit_commit / kit_describe: the version anchor +// turns/index.json 126 turn descriptors +// turns/-