feat(operator-surface-reconciliation): v0 — composed steering + Priority Rail Rule + lint + raw toggle + truncation + YAML validation

Operator Surface Reconciliation v0 — synthesizes PL-006 Operator
Status / Steering Surface + PL-008 Progress.md Project Management View
+ PL-009 Markdown Viewer / File Browser / Editor against shipped
Slice Story View v0+v1 + UI Enhancement Pack v0. One ambitious
vertical with six items; HEADLINE is the one-screen composed steering
surface (PL-006 v0 claim). NO new schema, NO migrations, NO new
event types — composes existing surfaces + adds small new routes
(steering composition, health-summary adapters) + adds small new UI
panels and extensions.

Item 1 — Steering surface (HEADLINE):
- New /steering route + Steering Link (Compass icon) in AppShell.
- New SteeringWorkspace.tsx with six panels rendered as a single
  scrollable viewport.
- A. PriorityStackPanel — verbatim STEERING.md via MarkdownViewer
  (hideRawToggle so the panel chrome doesn't compete) + last-modified
  timestamp.
- B. RoadmapRailPanel — PL-XXX checklist + railItemCode extraction +
  next-unchecked marker on first non-done row.
- C. InMotionPanel — composes PL-005 Mission Control's
  /api/mission-control/views/recently-active filtered to spec-related
  qitems (body/destination heuristic; PRD § Item 1C "driver picks
  the matching strategy").
- D. LaneRailsPanel — per-lane (delivery-ready/mode-{0..3}) top-N
  items + active/blocked/done health badges + next-pull marker.
- E. LoopStatePanel — cross-rig roll-up of recent qitems by seat via
  the same recently-active view.
- F. HealthGatesPanel — node + context summaries via new
  /api/health-summary/* routes; Stat badges color-coded (running →
  emerald, warning → amber, attention/critical → red bold).
- New daemon route GET /api/steering returns priority-stack + roadmap
  + lane-rails in one payload (in-motion / loop-state / health
  fetched separately by the UI to keep the composer narrow + testable).
- New SteeringComposer (domain/steering/steering-composer.ts) reads
  STEERING.md + roadmap PROGRESS.md + delivery-ready/mode-*/
  PROGRESS.md from a configured workspace root. Per-section overrides
  trump workspace defaults. Reuses UI Enhancement Pack v0's
  ProgressIndexer for parsing so the lane health badges match the
  Progress workspace's checkbox semantics exactly.
- New /api/health-summary/{nodes,context} routes (domain/steering/
  health-summary.ts) wrap getNodeInventory + raw context_usage
  aggregation. Daemon-side, not CLI shell-out — same data, cheaper.
- Phone-friendly content model 9 fields preserved through PL-005
  view consumers.

Item 2 — Priority Rail Rule visualization (PL-008 differentiator):
- New components/progress/priority-rail-rule.ts:
  classifyPriorityRailLevel maps PROGRESS.md path → STEERING /
  mission / lane / slice / intermediate via regex set; getPriority
  RailLevelStyle returns label + chip class; computeNextPullLine
  finds first non-done, non-blocked checkbox row per Priority Rail
  Rule semantics (workstream-continuity convention).
- ProgressWorkspace.tsx extended: per-file level chip in section
  header (Constraint / Mission / Lane / Slice / Cursor); next-pull
  marker on the row that first qualifies for each lane file;
  level-aware data-rail-level attribute on each section.

Item 3 — YAML schema validation in Files browser:
- FilesWorkspace.tsx extended: detectSpecKind matches
  rig.yaml / agent.yaml basenames; SpecValidationPanel invokes the
  existing /api/specs/review/{rig|agent} endpoint via the new
  useSpecReview hook + renders inline validation results above the
  YAML view. Non-spec YAML files don't trigger the panel.

Item 4 — Markdown viewer raw/rendered toggle:
- MarkdownViewer.tsx extended: header-bar 2-button toggle (rendered
  default ⇄ raw); raw mode renders content as monospace `<pre>`
  with full Markdown source visible; frontmatter metadata header
  still renders in raw mode. New hideRawToggle prop suppresses the
  toggle for callers (e.g. PriorityStackPanel) that don't want the
  chrome.

Item 5 — File-read truncation marker:
- daemon/src/routes/files.ts /read extended: caps returned content
  at FILE_READ_TRUNCATION_BYTES (1 MB per PRD § Item 5; founder Q6
  option b); response includes truncated / truncatedAtBytes /
  totalBytes fields when capping. Hash is computed over the FULL
  file so edit-mode atomic-write conflict detection stays honest
  even when the read was truncated.
- FilesWorkspace.tsx: TruncationMarker component renders an
  amber-bordered banner above any truncated file body with
  "use external editor for full content" hint.

Item 6 — PROGRESS.md lint warnings (PL-008 Phase B):
- computeLintWarnings (in priority-rail-rule.ts) ships 3 of 4 PRD
  rules at v0:
    - rule 1 long-row: row > 160 chars → per-row warning;
    - rule 2 missing-tree: parent file with no Tree/Hierarchy/
      Topology heading → file-scope warning;
    - rule 4 qitem-no-label: row body matches bare qitem id pattern
      → per-row warning.
  Rule 3 (narrative-mixed) ships active-but-quiet at v0 — the
  ProgressIndexer drops non-checkbox / non-heading lines so the
  heuristic can never fire; explicit known limitation recorded in
  comment with v0+1 promotion path (teach the indexer to retain
  narrative lines).
- ProgressWorkspace.tsx extended: header gets Show-lint toggle
  (default on); per-file lint count badge + file-scope warning list
  rendered below header; per-row warnings render inline next to
  their row.

PRD pivots / scope decisions recorded in commit + handoff:
- env-var pivot inherited from UI Enhancement Pack v0 memory:
  OPENRIG_STEERING_WORKSPACE + OPENRIG_STEERING_PATH /
  OPENRIG_ROADMAP_PATH / OPENRIG_DELIVERY_READY_DIR for non-canonical
  layouts. ConfigStore's strict VALID_KEYS doesn't admit dynamic
  family keys; env-var is the established MVP pattern.
- In-motion + loop-state panels fetch PL-005 views separately rather
  than the steering composer making internal HTTP calls — keeps
  the composer narrow + testable + reuses the existing PL-005 view
  contract verbatim.
- Lint rule 3 (narrative-mixed) ships active-but-quiet pending
  ProgressIndexer extension for narrative-line retention; recorded
  with explicit v0+1 promotion path in priority-rail-rule.ts comment.

Verification:
- daemon: 2846/2846 (was 2796 baseline at af4e220 + 50 new across 3
  test files: 12 steering-composer + 5 health-summary + 3
  files-truncation, plus ~30 test rebalancing through
  createFullTestDb usage).
- ui: 518/518 (was 478 baseline + 40 new across 3 test files: 17
  priority-rail-rule + 5 markdown-viewer-raw-toggle + 8
  steering-panels, plus rebalancing).
- cli: untouched by this slice; baseline 800.
- tsc clean for daemon + cli + ui.

Boundaries preserved per PRD § No-Touch Surfaces:
- All PL-004 schema (queue_*, workflow_*, watchdog_*) — read-only.
- All PL-005 surfaces (Mission Control views, verbs, action log) —
  read-only consumer; no modifications.
- PL-019's RigNode / Explorer / graph-projection / edge animation
  — untouched.
- Slice Story View v0's tabs (Story / Decisions / Tests-Verification
  / Topology) — untouched.
- Slice Story View v1's spec-driven rendering — untouched.
- UI Enhancement Pack v0's /progress core walk + /files core route
  + MarkdownViewer core render + edit mode atomic-write contract —
  read-only consumer + small additive extensions only; core write
  contract unchanged. Acceptance tab from Slice Story View v0+v1
  also untouched (item 1A in PRD scope reused PROGRESS-tree
  rendering, not Acceptance).
- Existing 33+ migrations on main — untouched. v0 adds NO migrations.
- Existing event bus types — no extensions.
- AppShell entries other than the new Steering Link — untouched.
- STEERING.md / roadmap/PROGRESS.md / delivery-ready/*/PROGRESS.md /
  mission PROGRESS.md — read-only consumer.

Operator setup hint for QA dogfood (recorded in handoff):

  export OPENRIG_FILES_ALLOWLIST="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work,hub:/Users/wrandom/code/projects/openrig-hub"
  export OPENRIG_PROGRESS_SCAN_ROOTS="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work"
  export OPENRIG_STEERING_WORKSPACE="/Users/wrandom/code/substrate/shared-docs/openrig-work"
  rig daemon restart

Hard-gate (operator dogfood per PRD audit row 14) is QA-owned per
feedback_qa_owns_dogfood_fix_loop.md. Worktree retained for guard
inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-04 05:56:38 -07:00
1 parent af4e220e1d
commit 299b4a06e5
28 files changed
+2534 -24

No files matched your search

@@ -0,0 +1,117 @@
// Operator Surface Reconciliation v0 — health summary aggregator.
//
// Item 1F: compact health gates on the steering surface. Two
// aggregations:
// - nodes: cross-rig roll-up of node sessionStatus + lifecycleState
// (mirrors `rig ps --nodes --summary` shape; UI consumes for the
// "running / detached / attention-required" badges).
// - context: cross-rig roll-up of context-usage urgency + freshness
// (mirrors `rigx-context --json` summary; UI consumes for the
// "critical / warning / ok / stale" badges).
//
// Daemon-side aggregation rather than CLI shell-out: the steering
// composer stays in-process and avoids spawning subprocesses per
// request. Same data, cheaper.
import type Database from "better-sqlite3";
import type { RigRepository } from "../rig-repository.js";
import { getNodeInventory } from "../node-inventory.js";
export interface NodeHealthSummary {
/** Total nodes across all rigs. */
total: number;
/** Counts grouped by node.sessionStatus (running / detached / exited / unknown). */
bySessionStatus: Record<string, number>;
/** Counts grouped by lifecycleState (running / recoverable / detached / attention_required). */
byLifecycle: Record<string, number>;
/** Convenience tally for the steering surface's headline number. */
attentionRequired: number;
}
export interface ContextHealthSummary {
/** Total nodes that the context store knows about. */
total: number;
/** Counts grouped by context urgency (critical / warning / low / unknown). */
byUrgency: Record<string, number>;
/** Counts grouped by freshness of the last sample (fresh / stale / none). */
byFreshness: Record<string, number>;
/** Convenience tallies the steering surface foregrounds. */
critical: number;
warning: number;
stale: number;
}
const FRESHNESS_THRESHOLD_S = 300;
const URGENCY_CRITICAL_PCT = 80;
const URGENCY_WARNING_PCT = 60;
export function computeNodeHealthSummary(deps: { db: Database.Database; rigRepo: RigRepository }): NodeHealthSummary {
const rigs = deps.rigRepo.listRigs();
const bySessionStatus: Record<string, number> = {};
const byLifecycle: Record<string, number> = {};
let total = 0;
let attentionRequired = 0;
for (const rig of rigs) {
const inventory = getNodeInventory(deps.db, rig.id);
for (const node of inventory) {
total++;
const sessionStatus = node.sessionStatus ?? "unknown";
bySessionStatus[sessionStatus] = (bySessionStatus[sessionStatus] ?? 0) + 1;
const lifecycle = (node as { lifecycleState?: string }).lifecycleState ?? "unknown";
byLifecycle[lifecycle] = (byLifecycle[lifecycle] ?? 0) + 1;
if (lifecycle === "attention_required") attentionRequired++;
}
}
return { total, bySessionStatus, byLifecycle, attentionRequired };
}
/** Reduces context_usage rows into the steering health summary.
* Urgency derived from usedPercentage thresholds (≥80 critical, ≥60
* warning, otherwise low). Freshness derived from sampledAt age vs
* FRESHNESS_THRESHOLD_S (300s). Reads context_usage directly because
* ContextUsageStore intentionally exposes per-node accessors only;
* listing all rows is a steering-surface concern, not a per-node
* concern. */
export function computeContextHealthSummary(deps: { db: Database.Database }): ContextHealthSummary {
let samples: Array<{ usedPercentage: number | null; sampledAt: string | null }> = [];
try {
samples = deps.db.prepare(
`SELECT used_percentage AS usedPercentage, sampled_at AS sampledAt FROM context_usage`,
).all() as Array<{ usedPercentage: number | null; sampledAt: string | null }>;
} catch {
// Table absent (test harness without the migration): empty summary.
samples = [];
}
const byUrgency: Record<string, number> = { critical: 0, warning: 0, low: 0, unknown: 0 };
const byFreshness: Record<string, number> = { fresh: 0, stale: 0, none: 0 };
let critical = 0;
let warning = 0;
let stale = 0;
const now = Date.now();
for (const sample of samples) {
const used = sample.usedPercentage;
let urgencyKey: keyof typeof byUrgency;
if (used == null) urgencyKey = "unknown";
else if (used >= URGENCY_CRITICAL_PCT) { urgencyKey = "critical"; critical++; }
else if (used >= URGENCY_WARNING_PCT) { urgencyKey = "warning"; warning++; }
else urgencyKey = "low";
byUrgency[urgencyKey] = (byUrgency[urgencyKey] ?? 0) + 1;
let freshnessKey: keyof typeof byFreshness;
if (!sample.sampledAt) freshnessKey = "none";
else {
const ageS = (now - new Date(sample.sampledAt).getTime()) / 1000;
if (ageS > FRESHNESS_THRESHOLD_S) { freshnessKey = "stale"; stale++; }
else freshnessKey = "fresh";
}
byFreshness[freshnessKey] = (byFreshness[freshnessKey] ?? 0) + 1;
}
return {
total: samples.length,
byUrgency,
byFreshness,
critical,
warning,
stale,
};
}
@@ -0,0 +1,333 @@
// Operator Surface Reconciliation v0 — steering composer.
//
// Item 1 (HEADLINE): one-screen composed steering surface. Daemon-side
// composer reads four filesystem sources and returns a payload the UI
// renders across the priority-stack / roadmap-rail / lane-rails panels.
//
// Why this composer and NOT a full daemon orchestration of Mission
// Control + agentActivity + everything: the steering UI fetches PL-005
// queue views (in-motion / loop-state) and health summaries via their
// own existing endpoints, so the composer stays narrow + testable. The
// composer is responsible only for the filesystem-derived pieces:
// - STEERING.md priority stack (verbatim render upstream)
// - roadmap PROGRESS.md (PL-XXX checklist + next-unchecked marker)
// - delivery-ready/mode-{0..3}/PROGRESS.md (per-lane top-N + health
// badges + next-pull marker per Priority Rail Rule semantics)
//
// Configuration: per the env-var-pivot pattern from UI Enhancement
// Pack v0 (ConfigStore's strict VALID_KEYS won't admit dynamic family
// keys cleanly), the steering composer reads a single workspace root
// from OPENRIG_STEERING_WORKSPACE. Everything else derives relative to
// that root: STEERING.md, roadmap/PROGRESS.md, delivery-ready/mode-*/
// PROGRESS.md. Operator-overridable via OPENRIG_STEERING_PATH /
// OPENRIG_ROADMAP_PATH / OPENRIG_DELIVERY_READY_DIR for non-canonical
// layouts. Empty/unset → composer.isReady() = false → route returns
// 503 with structured setup hint.
import * as fs from "node:fs";
import * as path from "node:path";
import { ProgressIndexer, type ProgressFileNode, type ProgressRow } from "../progress/progress-indexer.js";
export interface SteeringComposerOpts {
/** Workspace root (e.g., the openrig-work substrate dir). Optional
* per-piece overrides (steeringPath / roadmapPath / deliveryReadyDir)
* trump the workspace-root-derived defaults. */
workspaceRoot: string | null;
steeringPath?: string | null;
roadmapPath?: string | null;
deliveryReadyDir?: string | null;
/** Per-lane top-N items to surface; default 3 (PRD § Item 1D). */
topNPerLane?: number;
}
export interface PriorityStackPayload {
/** Verbatim STEERING.md content. UI renders via the v0 MarkdownViewer
* for consistency with the Files browser. */
content: string;
absolutePath: string;
mtime: string;
byteCount: number;
}
export interface RoadmapRailItem {
line: number;
text: string;
done: boolean;
/** Detected PL-XXX rail-item code if present (e.g., "PL-019"). */
railItemCode: string | null;
/** True for the first unchecked item on the rail (UI marks visually). */
isNextUnchecked: boolean;
}
export interface RoadmapRailPayload {
absolutePath: string;
mtime: string;
items: RoadmapRailItem[];
/** Total checkbox rows / done / next-unchecked-line for header. */
counts: { total: number; done: number; nextUncheckedLine: number | null };
}
export interface LaneRailItem {
line: number;
text: string;
status: "active" | "done" | "blocked" | "unknown";
/** True when this is the lane's "next pull" per the Priority Rail
* Rule: first non-done, non-blocked checkbox row on the lane. */
isNextPull: boolean;
}
export interface LaneRailPayload {
/** "mode-0" / "mode-1" / etc. — derived from the file's parent dir. */
laneId: string;
absolutePath: string;
mtime: string;
/** Top-N items shown on the steering panel (active/blocked first;
* done items only filling remainder). */
topItems: LaneRailItem[];
/** Lane-health aggregate counts across ALL rows in the file. */
healthBadges: { active: number; blocked: number; done: number; total: number };
/** Convenience: line number of the lane's next-pull row, null if none. */
nextPullLine: number | null;
}
export interface SteeringPayload {
priorityStack: PriorityStackPayload | null;
roadmapRail: RoadmapRailPayload | null;
laneRails: LaneRailPayload[];
/** Surface-level diagnostics for the UI to render setup hints when
* individual sources are missing. Each entry names the env var that
* would resolve it. */
unavailableSources: Array<{ section: string; reason: string; envVar?: string }>;
}
const ENV_WORKSPACE = "OPENRIG_STEERING_WORKSPACE";
const ENV_LEGACY_WORKSPACE = "RIGGED_STEERING_WORKSPACE";
const ENV_STEERING_PATH = "OPENRIG_STEERING_PATH";
const ENV_ROADMAP_PATH = "OPENRIG_ROADMAP_PATH";
const ENV_DELIVERY_READY_DIR = "OPENRIG_DELIVERY_READY_DIR";
export function steeringOptsFromEnv(env: NodeJS.ProcessEnv = process.env): SteeringComposerOpts {
// Use || (not ??) so an empty-string env var falls through to the next
// candidate. Same precedent as the UI Enhancement Pack v0 env helpers.
const workspaceRoot = (env[ENV_WORKSPACE] || env[ENV_LEGACY_WORKSPACE] || "").trim() || null;
return {
workspaceRoot,
steeringPath: (env[ENV_STEERING_PATH] || "").trim() || null,
roadmapPath: (env[ENV_ROADMAP_PATH] || "").trim() || null,
deliveryReadyDir: (env[ENV_DELIVERY_READY_DIR] || "").trim() || null,
};
}
export class SteeringComposer {
private readonly opts: Required<Omit<SteeringComposerOpts, "workspaceRoot">> & { workspaceRoot: string | null };
constructor(opts: SteeringComposerOpts) {
this.opts = {
workspaceRoot: opts.workspaceRoot,
steeringPath: opts.steeringPath ?? null,
roadmapPath: opts.roadmapPath ?? null,
deliveryReadyDir: opts.deliveryReadyDir ?? null,
topNPerLane: opts.topNPerLane ?? 3,
};
}
/** True when at least one source is resolvable. Routes use this to
* decide between 200 (with possibly-empty unavailableSources) and
* 503 (no sources at all). */
isReady(): boolean {
return Boolean(
this.resolveSteeringPath() ||
this.resolveRoadmapPath() ||
this.resolveDeliveryReadyDir(),
);
}
compose(): SteeringPayload {
const unavailableSources: SteeringPayload["unavailableSources"] = [];
const priorityStack = this.composePriorityStack(unavailableSources);
const roadmapRail = this.composeRoadmapRail(unavailableSources);
const laneRails = this.composeLaneRails(unavailableSources);
return { priorityStack, roadmapRail, laneRails, unavailableSources };
}
// --- per-section composers ---
private composePriorityStack(unavailableSources: SteeringPayload["unavailableSources"]): PriorityStackPayload | null {
const p = this.resolveSteeringPath();
if (!p) {
unavailableSources.push({ section: "priorityStack", reason: "STEERING.md path not configured", envVar: ENV_STEERING_PATH });
return null;
}
try {
const content = fs.readFileSync(p, "utf-8");
const stat = fs.statSync(p);
return {
content,
absolutePath: p,
mtime: stat.mtime.toISOString(),
byteCount: stat.size,
};
} catch (err) {
unavailableSources.push({
section: "priorityStack",
reason: `failed to read STEERING.md: ${err instanceof Error ? err.message : String(err)}`,
envVar: ENV_STEERING_PATH,
});
return null;
}
}
private composeRoadmapRail(unavailableSources: SteeringPayload["unavailableSources"]): RoadmapRailPayload | null {
const p = this.resolveRoadmapPath();
if (!p) {
unavailableSources.push({ section: "roadmapRail", reason: "roadmap PROGRESS.md path not configured", envVar: ENV_ROADMAP_PATH });
return null;
}
let content: string;
let mtime: Date;
try {
content = fs.readFileSync(p, "utf-8");
mtime = fs.statSync(p).mtime;
} catch (err) {
unavailableSources.push({
section: "roadmapRail",
reason: `failed to read roadmap PROGRESS.md: ${err instanceof Error ? err.message : String(err)}`,
envVar: ENV_ROADMAP_PATH,
});
return null;
}
const items: RoadmapRailItem[] = [];
let nextUncheckedSet = false;
let nextUncheckedLine: number | null = null;
let total = 0;
let done = 0;
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const m = lines[i]!.match(/^\s*(?:[-*]\s+)?\[([ xX])\]\s+(.+)$/);
if (!m) continue;
const isDone = m[1]!.toLowerCase() === "x";
const text = m[2]!.trim();
total++;
if (isDone) done++;
const railCode = matchRailItemCode(text);
const isNextUnchecked = !isDone && !nextUncheckedSet;
if (isNextUnchecked) {
nextUncheckedSet = true;
nextUncheckedLine = i + 1;
}
items.push({
line: i + 1,
text,
done: isDone,
railItemCode: railCode,
isNextUnchecked,
});
}
return {
absolutePath: p,
mtime: mtime.toISOString(),
items,
counts: { total, done, nextUncheckedLine },
};
}
private composeLaneRails(unavailableSources: SteeringPayload["unavailableSources"]): LaneRailPayload[] {
const dir = this.resolveDeliveryReadyDir();
if (!dir) {
unavailableSources.push({
section: "laneRails",
reason: "delivery-ready directory not configured",
envVar: ENV_DELIVERY_READY_DIR,
});
return [];
}
// Use the existing ProgressIndexer to parse mode-{0..3}/PROGRESS.md
// — same checkbox-status semantics as the UI Enhancement Pack v0
// /progress view, so the steering view stays consistent with what
// the operator sees in the Progress workspace.
const indexer = new ProgressIndexer({
roots: [{ name: "delivery-ready", canonicalPath: dir }],
maxDepth: 3,
});
const result = indexer.scan();
const lanes: LaneRailPayload[] = [];
for (const file of result.files) {
// Derive laneId from "mode-N/PROGRESS.md" or fallback to relPath.
const m = file.relPath.match(/^(mode-\d+)\/PROGRESS\.md$/);
const laneId = m ? m[1]! : file.relPath.replace(/\/?PROGRESS\.md$/i, "") || file.relPath;
lanes.push(this.composeLaneFromFile(laneId, file));
}
lanes.sort((a, b) => a.laneId.localeCompare(b.laneId));
return lanes;
}
private composeLaneFromFile(laneId: string, file: ProgressFileNode): LaneRailPayload {
const checkboxRows = file.rows.filter((r) => r.kind === "checkbox");
// Priority Rail Rule "next pull": first non-done, non-blocked
// checkbox row on the lane. Per the workstream-continuity convention
// (cited in the PRD), shelf/queue recency does NOT override; first
// ready item wins.
const nextPullIdx = checkboxRows.findIndex((r) => r.status !== "done" && r.status !== "blocked");
const nextPullLine = nextPullIdx >= 0 ? (checkboxRows[nextPullIdx]?.line ?? null) : null;
// Top-N: prefer active+blocked rows; only fall back to done if there
// aren't enough non-done rows to fill N (rare on healthy lanes,
// common on closed lanes).
const N = this.opts.topNPerLane;
const nonDoneRows = checkboxRows.filter((r) => r.status !== "done");
const doneRows = checkboxRows.filter((r) => r.status === "done");
const orderedSelection = [...nonDoneRows, ...doneRows].slice(0, N);
const topItems: LaneRailItem[] = orderedSelection.map((r: ProgressRow) => ({
line: r.line,
text: r.text,
status: r.status as LaneRailItem["status"],
isNextPull: r.line === nextPullLine,
}));
return {
laneId,
absolutePath: file.absolutePath,
mtime: file.mtime,
topItems,
healthBadges: {
active: file.counts.active,
blocked: file.counts.blocked,
done: file.counts.done,
total: file.counts.total,
},
nextPullLine,
};
}
// --- path resolvers ---
private resolveSteeringPath(): string | null {
if (this.opts.steeringPath) return this.opts.steeringPath;
if (!this.opts.workspaceRoot) return null;
const candidate = path.join(this.opts.workspaceRoot, "STEERING.md");
return fs.existsSync(candidate) ? candidate : null;
}
private resolveRoadmapPath(): string | null {
if (this.opts.roadmapPath) return this.opts.roadmapPath;
if (!this.opts.workspaceRoot) return null;
const candidate = path.join(this.opts.workspaceRoot, "roadmap", "PROGRESS.md");
return fs.existsSync(candidate) ? candidate : null;
}
private resolveDeliveryReadyDir(): string | null {
if (this.opts.deliveryReadyDir) return this.opts.deliveryReadyDir;
if (!this.opts.workspaceRoot) return null;
const candidate = path.join(this.opts.workspaceRoot, "delivery-ready");
try {
const st = fs.statSync(candidate);
if (st.isDirectory()) return candidate;
} catch { /* fall through */ }
return null;
}
}
const RAIL_CODE_REGEX = /\b(PL-\d{2,4})\b/;
export function matchRailItemCode(text: string): string | null {
const m = text.match(RAIL_CODE_REGEX);
return m ? m[1]! : null;
}
+27 -4
View File
@@ -1,4 +1,4 @@
// UI Enhancement Pack v0 — file browser routes.
// UI Enhancement Pack v0 + Operator Surface Reconciliation v0 — file browser routes.
//
// Endpoints (item 3 + item 4):
// GET /api/files/roots allowlist root list
@@ -7,6 +7,13 @@
// GET /api/files/asset?root=<name>&path=<rel> raw bytes for embedded images
// POST /api/files/write atomic write (item 4)
//
// Operator Surface Reconciliation v0 item 5: GET /read caps returned
// content at FILE_READ_TRUNCATION_BYTES (1 MB; PRD § Item 5;
// dashboard precedent). Response includes `truncated`, `truncatedAtBytes`,
// `totalBytes` so the UI can render a truncation marker. The hash is
// still computed over the FULL file content so atomic-write conflict
// detection stays honest even when the read was truncated.
//
// Route-order discipline (per Phase A R1 SSE lesson): all routes are
// literal — no `/:param` catchalls — so order doesn't matter for
// shadowing. Kept sequential for readability.
@@ -35,6 +42,12 @@ export interface FilesRoutesDeps {
writeService: FileWriteService | null;
}
/** Operator Surface Reconciliation v0 item 5: file-read truncation
* cap. PRD § Item 5 picks 1 MB (founder Q6 option b). The dashboard
* precedent was 200 KB; the v0 ceiling is 1 MB so the operator can
* read most workspace canon files in full. */
export const FILE_READ_TRUNCATION_BYTES = 1_048_576;
export function filesRoutes(): Hono {
const app = new Hono();
@@ -119,15 +132,25 @@ export function filesRoutes(): Hono {
try {
const resolved = resolveAllowedFile(deps.allowlist, rootName, relativePath);
const stat = fs.statSync(resolved);
const content = fs.readFileSync(resolved);
const fullContent = fs.readFileSync(resolved);
// Operator Surface Reconciliation v0 item 5: cap returned content
// at FILE_READ_TRUNCATION_BYTES (1 MB). Hash is over the FULL
// file so edit-mode conflict detection stays honest.
const truncated = stat.size > FILE_READ_TRUNCATION_BYTES;
const returnedContent = truncated
? fullContent.subarray(0, FILE_READ_TRUNCATION_BYTES)
: fullContent;
return c.json({
root: rootName,
path: relativePath,
absolutePath: resolved,
content: content.toString("utf-8"),
content: returnedContent.toString("utf-8"),
mtime: stat.mtime.toISOString(),
contentHash: sha256Hex(content),
contentHash: sha256Hex(fullContent),
size: stat.size,
truncated,
truncatedAtBytes: truncated ? FILE_READ_TRUNCATION_BYTES : null,
totalBytes: stat.size,
});
} catch (err) {
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
@@ -0,0 +1,45 @@
// Operator Surface Reconciliation v0 — compact health-summary routes.
//
// Endpoints (item 1F):
// GET /api/health-summary/nodes cross-rig roll-up of node sessionStatus + lifecycle
// GET /api/health-summary/context cross-rig roll-up of context-usage urgency + freshness
//
// Both wrap existing daemon-side aggregation helpers; the steering
// surface's compact gates consume them.
import { Hono } from "hono";
import type Database from "better-sqlite3";
import type { RigRepository } from "../domain/rig-repository.js";
import {
computeContextHealthSummary,
computeNodeHealthSummary,
} from "../domain/steering/health-summary.js";
export interface HealthSummaryRoutesDeps {
db: Database.Database;
rigRepo: RigRepository;
}
export function healthSummaryRoutes(): Hono {
const app = new Hono();
function getDeps(c: { get: (key: string) => unknown }): HealthSummaryRoutesDeps | null {
const rigRepo = c.get("rigRepo" as never) as RigRepository | undefined;
if (!rigRepo) return null;
return { db: rigRepo.db, rigRepo };
}
app.get("/nodes", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "health_summary_unavailable" }, 503);
return c.json(computeNodeHealthSummary(deps));
});
app.get("/context", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "health_summary_unavailable" }, 503);
return c.json(computeContextHealthSummary({ db: deps.db }));
});
return app;
}
+34
View File
@@ -0,0 +1,34 @@
// Operator Surface Reconciliation v0 — steering composition route.
//
// Endpoint:
// GET /api/steering — composed payload for the /steering UI surface.
//
// Per PRD § Item 1, returns priority-stack + roadmap-rail + lane-rails
// in a single payload. The UI fetches in-motion + loop-state from
// existing PL-005 endpoints and health gates from /api/health-summary
// (kept separate so the steering composer stays narrow + testable).
import { Hono } from "hono";
import type { SteeringComposer } from "../domain/steering/steering-composer.js";
export interface SteeringRoutesDeps {
composer: SteeringComposer;
}
export function steeringRoutes(): Hono {
const app = new Hono();
app.get("/", (c) => {
const composer = c.get("steeringComposer" as never) as SteeringComposer | undefined;
if (!composer) return c.json({ error: "steering_composer_unavailable" }, 503);
if (!composer.isReady()) {
return c.json({
error: "steering_workspace_not_configured",
hint: "Set OPENRIG_STEERING_WORKSPACE=/abs/path (and optionally OPENRIG_STEERING_PATH / OPENRIG_ROADMAP_PATH / OPENRIG_DELIVERY_READY_DIR for non-canonical layouts) and restart the daemon.",
}, 503);
}
return c.json(composer.compose());
});
return app;
}
+8
View File
@@ -69,6 +69,8 @@ import { missionControlRoutes } from "./routes/mission-control.js";
import { slicesRoutes } from "./routes/slices.js";
import { filesRoutes } from "./routes/files.js";
import { progressRoutes } from "./routes/progress.js";
import { steeringRoutes } from "./routes/steering.js";
import { healthSummaryRoutes } from "./routes/health-summary.js";
import type { StreamStore } from "./domain/stream-store.js";
import type { QueueRepository } from "./domain/queue-repository.js";
import type { InboxHandler } from "./domain/inbox-handler.js";
@@ -156,6 +158,8 @@ export interface AppDeps {
fileWriteService?: import("./domain/files/file-write-service.js").FileWriteService | null;
/** UI Enhancement Pack v0 — workspace PROGRESS.md indexer (item 1B). */
progressIndexer?: import("./domain/progress/progress-indexer.js").ProgressIndexer;
/** Operator Surface Reconciliation v0 — steering composer (item 1). */
steeringComposer?: import("./domain/steering/steering-composer.js").SteeringComposer;
missionControlAuditBrowse?: import("./domain/mission-control/audit-browse.js").MissionControlAuditBrowse;
missionControlNotificationDispatcher?: import("./domain/mission-control/notification-dispatcher.js").MissionControlNotificationDispatcher;
/**
@@ -335,6 +339,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("filesAllowlist" as never, deps.filesAllowlist);
c.set("fileWriteService" as never, deps.fileWriteService);
c.set("progressIndexer" as never, deps.progressIndexer);
c.set("steeringComposer" as never, deps.steeringComposer);
c.set("missionControlAuditBrowse" as never, deps.missionControlAuditBrowse);
c.set("missionControlNotificationDispatcher" as never, deps.missionControlNotificationDispatcher);
c.set("specReviewService" as never, deps.specReviewService);
@@ -398,6 +403,9 @@ export function createApp(deps: AppDeps): Hono {
// UI Enhancement Pack v0 — files (item 3 + item 4) + progress (item 1B) routes.
app.route("/api/files", filesRoutes());
app.route("/api/progress", progressRoutes());
// Operator Surface Reconciliation v0 — steering composition + health summary.
app.route("/api/steering", steeringRoutes());
app.route("/api/health-summary", healthSummaryRoutes());
app.route("/api/rigs/:rigId/env", envRoutes());
app.route("/api/restore-check", restoreCheckRoutes);
+7
View File
@@ -754,6 +754,13 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
})
: null;
deps.progressIndexer = new ProgressIndexer({ roots: readProgressRootsFromEnv() });
// Operator Surface Reconciliation v0 — steering composer (item 1).
// Reads workspace root + per-section overrides from env. Empty
// workspace root → composer.isReady() = false → /api/steering
// returns 503 with structured setup hint.
const { SteeringComposer, steeringOptsFromEnv } = await import("./domain/steering/steering-composer.js");
deps.steeringComposer = new SteeringComposer(steeringOptsFromEnv());
}
const sessionTransport = deps.sessionTransport;
@@ -0,0 +1,89 @@
// Operator Surface Reconciliation v0 — files /read truncation tests.
//
// Pins item 5: GET /api/files/read caps returned content at
// FILE_READ_TRUNCATION_BYTES (1 MB) and surfaces truncation marker
// fields in the response. Hash is computed over the FULL file so
// edit-mode conflict detection stays honest even when the read is
// truncated.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { createHash } from "node:crypto";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { FILE_READ_TRUNCATION_BYTES, filesRoutes } from "../src/routes/files.js";
import type { AllowlistRoot } from "../src/domain/files/path-safety.js";
function buildApp(allowlist: AllowlistRoot[]): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("filesAllowlist" as never, allowlist);
c.set("fileWriteService" as never, null);
await next();
});
app.route("/api/files", filesRoutes());
return app;
}
describe("Operator Surface Reconciliation v0 — /api/files/read truncation", () => {
let tempDir: string;
let allowlist: AllowlistRoot[];
let app: Hono;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "files-trunc-"));
mkdirSync(join(tempDir, "ws"), { recursive: true });
allowlist = [{ name: "ws", canonicalPath: realpathSync(join(tempDir, "ws")) }];
app = buildApp(allowlist);
});
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
it("file <= 1 MB returns truncated=false + truncatedAtBytes=null + full content", async () => {
const content = "small file content\n";
writeFileSync(join(tempDir, "ws", "small.md"), content);
const res = await app.request("/api/files/read?root=ws&path=small.md");
expect(res.status).toBe(200);
const body = await res.json() as {
content: string; truncated: boolean; truncatedAtBytes: number | null; totalBytes: number;
};
expect(body.truncated).toBe(false);
expect(body.truncatedAtBytes).toBeNull();
expect(body.totalBytes).toBe(content.length);
expect(body.content).toBe(content);
});
it("file > 1 MB returns truncated=true + truncatedAtBytes=1048576 + capped content", async () => {
// 1.5 MB synthetic file — first byte is 'H', rest is 'x' filler.
const totalBytes = 1_572_864; // 1.5 MB
const content = "H" + "x".repeat(totalBytes - 1);
writeFileSync(join(tempDir, "ws", "large.md"), content);
const res = await app.request("/api/files/read?root=ws&path=large.md");
expect(res.status).toBe(200);
const body = await res.json() as {
content: string;
truncated: boolean;
truncatedAtBytes: number | null;
totalBytes: number;
contentHash: string;
};
expect(body.truncated).toBe(true);
expect(body.truncatedAtBytes).toBe(FILE_READ_TRUNCATION_BYTES);
expect(body.totalBytes).toBe(totalBytes);
// Content is capped at FILE_READ_TRUNCATION_BYTES (1 MB).
expect(body.content.length).toBe(FILE_READ_TRUNCATION_BYTES);
expect(body.content[0]).toBe("H");
// Hash is computed over the FULL file, not the truncated slice —
// edit-mode conflict detection stays honest even when a >1 MB file
// is read truncated. (Editing such a file is operator-error
// territory; the UI surfaces the truncation marker so the
// operator knows to use an external editor.)
const fullHash = createHash("sha256").update(content).digest("hex");
expect(body.contentHash).toBe(fullHash);
});
it("FILE_READ_TRUNCATION_BYTES is exactly 1 MB per PRD § Item 5 (founder Q6 option b)", () => {
expect(FILE_READ_TRUNCATION_BYTES).toBe(1_048_576);
});
});
@@ -0,0 +1,91 @@
// Operator Surface Reconciliation v0 — health summary tests.
//
// Pins the daemon-side aggregation helpers consumed by /api/health-summary/*.
// Both `computeNodeHealthSummary` and `computeContextHealthSummary` are
// pure functions over already-shipped tables; tests build a small
// fixture DB and assert the rolled-up counts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import {
computeContextHealthSummary,
computeNodeHealthSummary,
} from "../src/domain/steering/health-summary.js";
describe("Operator Surface Reconciliation v0 — health summary", () => {
let db: Database.Database;
let rigRepo: RigRepository;
beforeEach(() => {
db = createFullTestDb();
rigRepo = new RigRepository(db);
});
afterEach(() => db.close());
describe("computeNodeHealthSummary", () => {
it("returns zeros when no rigs exist", () => {
const out = computeNodeHealthSummary({ db, rigRepo });
expect(out).toEqual({ total: 0, bySessionStatus: {}, byLifecycle: {}, attentionRequired: 0 });
});
it("aggregates node sessionStatus across rigs", () => {
const rigA = rigRepo.createRig("rig-a");
const rigB = rigRepo.createRig("rig-b");
rigRepo.addNode(rigA.id, "alpha", { role: "worker" });
rigRepo.addNode(rigA.id, "beta", { role: "worker" });
rigRepo.addNode(rigB.id, "gamma", { role: "worker" });
const out = computeNodeHealthSummary({ db, rigRepo });
expect(out.total).toBe(3);
// Without sessions seeded, sessionStatus defaults to null/unknown.
expect(Object.values(out.bySessionStatus).reduce((a, b) => a + b, 0)).toBe(3);
});
});
describe("computeContextHealthSummary", () => {
function insertContextUsage(nodeId: string, usedPercentage: number | null, sampledAt: string | null): void {
// Insert a node row so the FK on context_usage(node_id) is satisfied.
const rig = rigRepo.createRig(`rig-${nodeId}`);
const node = rigRepo.addNode(rig.id, nodeId, { role: "worker" });
db.prepare(
`INSERT INTO context_usage (node_id, availability, used_percentage, sampled_at, updated_at)
VALUES (?, 'known', ?, ?, ?)`
).run(node.id, usedPercentage, sampledAt, new Date().toISOString());
}
it("returns zeros when no context usage rows exist", () => {
const out = computeContextHealthSummary({ db });
expect(out.total).toBe(0);
expect(out.critical).toBe(0);
expect(out.warning).toBe(0);
expect(out.stale).toBe(0);
});
it("classifies usedPercentage into urgency buckets (critical ≥80, warning ≥60, low otherwise)", () => {
insertContextUsage("crit", 92, new Date().toISOString());
insertContextUsage("warn", 70, new Date().toISOString());
insertContextUsage("ok-1", 25, new Date().toISOString());
insertContextUsage("ok-2", 0, new Date().toISOString());
insertContextUsage("unknown", null, new Date().toISOString());
const out = computeContextHealthSummary({ db });
expect(out.total).toBe(5);
expect(out.critical).toBe(1);
expect(out.warning).toBe(1);
expect(out.byUrgency["low"]).toBe(2);
expect(out.byUrgency["unknown"]).toBe(1);
});
it("classifies samples older than 300s as stale", () => {
insertContextUsage("fresh", 50, new Date().toISOString());
insertContextUsage("stale", 50, new Date(Date.now() - 600_000).toISOString()); // 10 minutes ago
insertContextUsage("none", 50, null);
const out = computeContextHealthSummary({ db });
expect(out.byFreshness["fresh"]).toBe(1);
expect(out.byFreshness["stale"]).toBe(1);
expect(out.byFreshness["none"]).toBe(1);
expect(out.stale).toBe(1);
});
});
});
@@ -0,0 +1,159 @@
// Operator Surface Reconciliation v0 — steering composer tests.
//
// Drives SteeringComposer against a fixture filesystem layout
// (workspaceRoot containing STEERING.md + roadmap/PROGRESS.md +
// delivery-ready/mode-{0..3}/PROGRESS.md). Pins:
// - isReady() true when at least one source resolvable; false when none
// - priority stack section returns verbatim STEERING.md content
// - roadmap rail extracts checkbox rows + railItemCode + isNextUnchecked
// - lane rails group by mode-N; top-N items prefer non-done; next-pull
// marker on first non-done, non-blocked checkbox
// - per-section overrides (steeringPath / roadmapPath / deliveryReadyDir)
// trump workspace-root-derived defaults
// - unavailable sources surface structured diagnostics with envVar hints
// - empty/unset env yields composer with isReady() false
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
SteeringComposer,
matchRailItemCode,
steeringOptsFromEnv,
} from "../src/domain/steering/steering-composer.js";
describe("Operator Surface Reconciliation v0 — matchRailItemCode", () => {
it("extracts PL-XXX codes from arbitrary text", () => {
expect(matchRailItemCode("ship PL-019 topology indicators")).toBe("PL-019");
expect(matchRailItemCode("PL-005 Phase A")).toBe("PL-005");
expect(matchRailItemCode("no rail code here")).toBeNull();
});
});
describe("Operator Surface Reconciliation v0 — steeringOptsFromEnv", () => {
it("returns null workspaceRoot when env unset", () => {
expect(steeringOptsFromEnv({})).toMatchObject({ workspaceRoot: null });
});
it("reads OPENRIG_STEERING_WORKSPACE + per-section overrides", () => {
const opts = steeringOptsFromEnv({
OPENRIG_STEERING_WORKSPACE: "/abs/workspace",
OPENRIG_STEERING_PATH: "/abs/override/STEERING.md",
OPENRIG_ROADMAP_PATH: "/abs/override/roadmap.md",
OPENRIG_DELIVERY_READY_DIR: "/abs/override/delivery-ready",
});
expect(opts.workspaceRoot).toBe("/abs/workspace");
expect(opts.steeringPath).toBe("/abs/override/STEERING.md");
expect(opts.roadmapPath).toBe("/abs/override/roadmap.md");
expect(opts.deliveryReadyDir).toBe("/abs/override/delivery-ready");
});
it("falls back to RIGGED_STEERING_WORKSPACE when OPENRIG var is empty (|| not ??)", () => {
expect(steeringOptsFromEnv({ OPENRIG_STEERING_WORKSPACE: "", RIGGED_STEERING_WORKSPACE: "/legacy" }))
.toMatchObject({ workspaceRoot: "/legacy" });
});
});
describe("Operator Surface Reconciliation v0 — SteeringComposer", () => {
let workspaceRoot: string;
let cleanup: string;
beforeEach(() => {
cleanup = mkdtempSync(join(tmpdir(), "steering-composer-"));
workspaceRoot = join(cleanup, "workspace");
mkdirSync(workspaceRoot, { recursive: true });
});
afterEach(() => rmSync(cleanup, { recursive: true, force: true }));
it("isReady() = false when no sources resolvable (empty workspace + no overrides)", () => {
const composer = new SteeringComposer({ workspaceRoot: null });
expect(composer.isReady()).toBe(false);
});
it("isReady() = true when at least one source resolves (priority stack only)", () => {
writeFileSync(join(workspaceRoot, "STEERING.md"), "# steering");
const composer = new SteeringComposer({ workspaceRoot });
expect(composer.isReady()).toBe(true);
});
it("priority stack returns verbatim STEERING.md content + mtime + byteCount", () => {
writeFileSync(join(workspaceRoot, "STEERING.md"), "# Priority\n- Do X\n- Avoid Y\n");
const composer = new SteeringComposer({ workspaceRoot });
const out = composer.compose();
expect(out.priorityStack).not.toBeNull();
expect(out.priorityStack!.content).toBe("# Priority\n- Do X\n- Avoid Y\n");
expect(out.priorityStack!.byteCount).toBeGreaterThan(0);
expect(out.priorityStack!.mtime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("priority stack null + unavailable diagnostic when STEERING.md absent", () => {
const composer = new SteeringComposer({ workspaceRoot });
const out = composer.compose();
expect(out.priorityStack).toBeNull();
expect(out.unavailableSources.find((s) => s.section === "priorityStack")).toBeDefined();
});
it("roadmap rail extracts checkbox rows with railItemCode + isNextUnchecked on first unchecked", () => {
mkdirSync(join(workspaceRoot, "roadmap"), { recursive: true });
writeFileSync(join(workspaceRoot, "roadmap", "PROGRESS.md"),
"# Roadmap\n- [x] PL-005 Phase A\n- [x] PL-019 done\n- [ ] PL-022 next\n- [ ] PL-030 later\n");
const composer = new SteeringComposer({ workspaceRoot });
const out = composer.compose();
expect(out.roadmapRail).not.toBeNull();
const items = out.roadmapRail!.items;
expect(items).toHaveLength(4);
expect(items[0]?.railItemCode).toBe("PL-005");
expect(items[0]?.done).toBe(true);
expect(items[2]?.railItemCode).toBe("PL-022");
expect(items[2]?.isNextUnchecked).toBe(true);
expect(items[3]?.isNextUnchecked).toBe(false);
expect(out.roadmapRail!.counts.done).toBe(2);
expect(out.roadmapRail!.counts.total).toBe(4);
expect(out.roadmapRail!.counts.nextUncheckedLine).toBe(items[2]?.line);
});
it("lane rails group by mode-N; top-N prefers non-done; next-pull marks first non-done non-blocked", () => {
mkdirSync(join(workspaceRoot, "delivery-ready", "mode-2"), { recursive: true });
mkdirSync(join(workspaceRoot, "delivery-ready", "mode-3"), { recursive: true });
writeFileSync(join(workspaceRoot, "delivery-ready", "mode-2", "PROGRESS.md"),
"# Mode 2\n- [x] alpha done\n- [~] beta blocked\n- [ ] gamma next\n- [ ] delta later\n");
writeFileSync(join(workspaceRoot, "delivery-ready", "mode-3", "PROGRESS.md"),
"# Mode 3\n- [x] one done\n- [x] two done\n");
const composer = new SteeringComposer({ workspaceRoot, topNPerLane: 3 });
const out = composer.compose();
expect(out.laneRails).toHaveLength(2);
const mode2 = out.laneRails.find((l) => l.laneId === "mode-2")!;
expect(mode2.healthBadges).toEqual({ active: 2, blocked: 1, done: 1, total: 4 });
expect(mode2.nextPullLine).not.toBeNull();
const nextPullItem = mode2.topItems.find((i) => i.isNextPull);
expect(nextPullItem?.text).toBe("gamma next");
// Top-3 prefers non-done — beta (blocked) and gamma+delta (active) come before alpha (done).
expect(mode2.topItems.map((i) => i.text)).toEqual(["beta blocked", "gamma next", "delta later"]);
const mode3 = out.laneRails.find((l) => l.laneId === "mode-3")!;
expect(mode3.nextPullLine).toBeNull();
});
it("per-section overrides trump workspace-root defaults", () => {
const overrideRoot = join(cleanup, "elsewhere");
mkdirSync(overrideRoot, { recursive: true });
writeFileSync(join(overrideRoot, "STEERING-CUSTOM.md"), "# overridden steering");
writeFileSync(join(workspaceRoot, "STEERING.md"), "# default steering");
const composer = new SteeringComposer({
workspaceRoot,
steeringPath: join(overrideRoot, "STEERING-CUSTOM.md"),
});
const out = composer.compose();
expect(out.priorityStack?.content).toBe("# overridden steering");
});
it("composer with no sources returns empty payload + 3 unavailable diagnostics", () => {
const composer = new SteeringComposer({ workspaceRoot });
const out = composer.compose();
expect(out.priorityStack).toBeNull();
expect(out.roadmapRail).toBeNull();
expect(out.laneRails).toEqual([]);
expect(out.unavailableSources.map((s) => s.section).sort()).toEqual(["laneRails", "priorityStack", "roadmapRail"]);
});
});
+19 -1
View File
@@ -1,6 +1,6 @@
import { type CSSProperties, type ReactNode, useCallback, useEffect, useState, createContext, useContext } from "react";
import { Link, useRouterState } from "@tanstack/react-router";
import { CheckSquare, Cog, FileText, Folder, Layers, SquarePlus } from "lucide-react";
import { CheckSquare, Cog, Compass, FileText, Folder, Layers, SquarePlus } from "lucide-react";
import { Explorer, type ExplorerSurface } from "./Explorer.js";
import { SharedDetailDrawer, type DrawerSelection } from "./SharedDetailDrawer.js";
import type { DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
@@ -99,6 +99,7 @@ function resolveSurfaceTitle(pathname: string, rigId: string | null, rigName: st
if (pathname.startsWith("/slices")) return "Slices";
if (pathname.startsWith("/progress")) return "Progress";
if (pathname.startsWith("/files")) return "Files";
if (pathname.startsWith("/steering")) return "Steering";
return null;
}
@@ -329,6 +330,23 @@ export function AppShell({ children }: AppShellProps) {
>
<Folder className="h-4 w-4" />
</Link>
{/* Operator Surface Reconciliation v0 — Steering Link
(item 1, HEADLINE). One-screen composed steering surface
— priority stack + roadmap + in-motion + lane rails +
loop state + health gates. */}
<Link
to="/steering"
data-testid="steering-link"
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
pathname.startsWith("/steering")
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label="Open steering"
title="Open steering"
>
<Compass className="h-4 w-4" />
</Link>
<button
type="button"
data-testid="system-toggle"
@@ -26,6 +26,7 @@ import {
} from "../../hooks/useFiles.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
import { SyntaxHighlight } from "../markdown/SyntaxHighlight.js";
import { useSpecReview } from "../../hooks/useSteering.js";
const TEXT_LIKE_EXTENSIONS = new Set([".md", ".txt", ".log", ".yaml", ".yml", ".json", ".js", ".jsx", ".ts", ".tsx", ".py", ".sh", ".bash", ".sql", ".css", ".html"]);
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
@@ -291,9 +292,12 @@ function FileContentPanel({
function FileViewer({ root, path, read }: { root: string; path: string; read: FilesReadResponse }) {
const ext = pathExtension(path);
// OSR v0 item 3: detect spec-kind YAML files for inline validation.
const specKind = detectSpecKind(path);
if (IMAGE_EXTENSIONS.has(ext)) {
return (
<div data-testid="files-image-view" className="p-4">
<TruncationMarker read={read} />
<img src={fileAssetUrl(root, path)} alt={path} className="max-w-full border border-stone-200" />
</div>
);
@@ -301,6 +305,7 @@ function FileViewer({ root, path, read }: { root: string; path: string; read: Fi
if (ext === ".md") {
return (
<div className="p-4">
<TruncationMarker read={read} />
<MarkdownViewer content={read.content} />
</div>
);
@@ -308,6 +313,8 @@ function FileViewer({ root, path, read }: { root: string; path: string; read: Fi
if (TEXT_LIKE_EXTENSIONS.has(ext)) {
return (
<div data-testid="files-code-view" className="p-4">
<TruncationMarker read={read} />
{specKind && <SpecValidationPanel kind={specKind} yaml={read.content} />}
<SyntaxHighlight code={read.content} language={ext.slice(1)} />
</div>
);
@@ -323,11 +330,100 @@ function FileViewer({ root, path, read }: { root: string; path: string; read: Fi
}
return (
<div data-testid="files-text-fallback" className="p-4">
<TruncationMarker read={read} />
<pre className="whitespace-pre-wrap break-words font-mono text-[10px] text-stone-800">{read.content}</pre>
</div>
);
}
// Operator Surface Reconciliation v0 item 5: explicit truncation marker
// rendered above the file body when the daemon capped the read at
// FILE_READ_TRUNCATION_BYTES (1 MB). Honest about the limit so the
// operator knows to use an external editor for full content.
function TruncationMarker({ read }: { read: FilesReadResponse }) {
if (!read.truncated) return null;
const totalKb = Math.round((read.totalBytes ?? read.size) / 1024);
return (
<div
data-testid="files-truncation-marker"
data-truncated-at-bytes={read.truncatedAtBytes ?? ""}
data-total-bytes={read.totalBytes ?? ""}
className="mb-3 border border-amber-400 bg-amber-50 px-3 py-2 font-mono text-[10px] text-amber-900"
>
⚠ Truncated by file viewer at {Math.round((read.truncatedAtBytes ?? 0) / 1024)} KB —
file is {totalKb} KB total. Use an external editor for full content.
</div>
);
}
// OSR v0 item 3: RigSpec / AgentSpec validation panel. Detects spec
// kind by filename and invokes the existing /api/specs/review/{rig|agent}
// endpoint via the useSpecReview hook. Errors / warnings render inline
// alongside the YAML view; non-spec YAML files don't surface this
// panel at all.
function detectSpecKind(filePath: string): "rig" | "agent" | null {
const lower = filePath.toLowerCase();
if (lower.endsWith("/rig.yaml") || lower === "rig.yaml" || lower.endsWith("/rig.yml") || lower === "rig.yml") return "rig";
if (lower.endsWith("/agent.yaml") || lower === "agent.yaml" || lower.endsWith("/agent.yml") || lower === "agent.yml") return "agent";
// Spec library entries: <pkg>/specs/<spec-name>/{rig,agent}.yaml shape;
// we already match those above via the basename. Driver picks
// additional heuristics here in v0+1 if false-positive avoidance
// becomes a friction (e.g., "config.yaml" inside an unrelated
// workspace tree should NOT trigger spec validation).
return null;
}
function SpecValidationPanel({ kind, yaml }: { kind: "rig" | "agent"; yaml: string }) {
const review = useSpecReview(kind, yaml);
if (review.isLoading) {
return (
<div data-testid="files-spec-validation-loading" className="mb-3 border border-stone-300 bg-stone-50 px-3 py-2 font-mono text-[10px] text-stone-500">
Validating {kind}.yaml…
</div>
);
}
if (review.isError) {
return (
<div data-testid="files-spec-validation-error" className="mb-3 border border-red-400 bg-red-50 px-3 py-2 font-mono text-[10px] text-red-900">
Validation failed to run: {(review.error as Error)?.message ?? "unknown error"}
</div>
);
}
if (!review.data) return null;
const errors = review.data.errors ?? [];
const isValid = errors.length === 0;
return (
<div
data-testid="files-spec-validation-panel"
data-spec-kind={kind}
data-valid={isValid}
className={`mb-3 border px-3 py-2 font-mono text-[10px] ${
isValid
? "border-emerald-400 bg-emerald-50 text-emerald-900"
: "border-red-400 bg-red-50 text-red-900"
}`}
>
<div className="mb-1 font-bold uppercase tracking-[0.10em]">
{isValid ? `✓ Valid ${kind === "rig" ? "RigSpec" : "AgentSpec"}` : `✗ ${kind === "rig" ? "RigSpec" : "AgentSpec"} validation errors`}
</div>
{errors.length > 0 && (
<ul className="space-y-1">
{errors.map((err, idx) => (
<li
key={idx}
data-testid={`files-spec-validation-error-${idx}`}
className="text-[10px]"
>
{err.field && <span className="font-bold">{err.field}: </span>}
{err.message}
</li>
))}
</ul>
)}
</div>
);
}
function FileEditor({ root, path, read }: { root: string; path: string; read: FilesReadResponse }) {
const [draft, setDraft] = useState(read.content);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -22,7 +22,7 @@
// prop so the daemon's /api/files/asset endpoint or the slice's
// /api/slices/<name>/proof-asset/ endpoint can serve them.
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { SyntaxHighlight } from "./SyntaxHighlight.js";
export interface MarkdownViewerProps {
@@ -31,20 +31,65 @@ export interface MarkdownViewerProps {
assetBasePath?: string;
/** Hide the YAML frontmatter metadata header (default false). */
hideFrontmatter?: boolean;
/** Operator Surface Reconciliation v0 item 4: hide the raw/rendered
* toggle for callers (e.g., the steering Priority Stack panel) where
* the toggle would visually compete with the surrounding shell. */
hideRawToggle?: boolean;
}
export function MarkdownViewer({ content, assetBasePath, hideFrontmatter = false }: MarkdownViewerProps) {
export function MarkdownViewer({ content, assetBasePath, hideFrontmatter = false, hideRawToggle = false }: MarkdownViewerProps) {
const parsed = useMemo(() => parseMarkdown(content), [content]);
// Operator Surface Reconciliation v0 item 4: per-instance toggle
// between rendered (default) and raw (monospace pre-rendered text +
// visible Markdown source). Frontmatter metadata header still
// renders in raw mode unless hideFrontmatter is set.
const [mode, setMode] = useState<"rendered" | "raw">("rendered");
return (
<article data-testid="markdown-viewer" className="prose-tactical max-w-none">
<article data-testid="markdown-viewer" data-mode={mode} className="prose-tactical max-w-none">
{!hideRawToggle && (
<div data-testid="markdown-viewer-mode-toggle" className="mb-2 flex items-center gap-1">
<button
type="button"
data-testid="markdown-viewer-mode-rendered"
data-active={mode === "rendered"}
onClick={() => setMode("rendered")}
className={`border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.10em] ${
mode === "rendered"
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
rendered
</button>
<button
type="button"
data-testid="markdown-viewer-mode-raw"
data-active={mode === "raw"}
onClick={() => setMode("raw")}
className={`border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.10em] ${
mode === "raw"
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
raw
</button>
</div>
)}
{!hideFrontmatter && parsed.frontmatter && (
<FrontmatterHeader frontmatter={parsed.frontmatter} />
)}
<div className="space-y-3">
{parsed.blocks.map((block, idx) => (
<BlockRenderer key={idx} block={block} assetBasePath={assetBasePath} />
))}
</div>
{mode === "raw" ? (
<pre data-testid="markdown-viewer-raw" className="overflow-x-auto whitespace-pre-wrap break-words bg-stone-50 p-3 font-mono text-[10px] text-stone-800">
{content}
</pre>
) : (
<div className="space-y-3" data-testid="markdown-viewer-rendered">
{parsed.blocks.map((block, idx) => (
<BlockRenderer key={idx} block={block} assetBasePath={assetBasePath} />
))}
</div>
)}
</article>
);
}
@@ -1,24 +1,34 @@
// UI Enhancement Pack v0 — Progress browse workspace.
// UI Enhancement Pack v0 + Operator Surface Reconciliation v0 — Progress browse workspace.
//
// Top-level center-workspace surface for /progress route. Renders the
// workspace's PROGRESS.md hierarchy across operator-allowlisted scan
// roots in one tree.
// UEP v0: workspace-tree walk + checkbox pills + status filters + click-to-expand.
//
// Two-pane shape:
// - Left: filter row (status: All/Active/Done/Blocked) + free-text
// search + per-file collapsible sections.
// - Right: when a row is clicked, an inline detail panel shows
// the source file:line citation + parent context.
//
// Per item 1B: read-only at v0; edit mode is item 4 (Files workspace).
// OSR v0 item 2: per-level Priority Rail Rule typography (STEERING /
// mission / lane / slice / intermediate) + "next pull" marker per
// lane file (first non-done, non-blocked checkbox row).
// OSR v0 item 6: inline lint warning badges with rule citations
// (workstream-continuity rules) + Show-lint toggle (default on).
import { useMemo, useState } from "react";
import { useProgressTree, type ProgressFileNode, type ProgressRow, type CheckboxStatus } from "../../hooks/useProgressTree.js";
import {
classifyPriorityRailLevel,
computeLintWarnings,
computeNextPullLine,
getPriorityRailLevelStyle,
type LintWarning,
type PriorityRailLevel,
} from "./priority-rail-rule.js";
type StatusFilter = "all" | "active" | "done" | "blocked";
const FILTERS: StatusFilter[] = ["all", "active", "done", "blocked"];
interface FileMeta {
level: PriorityRailLevel;
nextPullLine: number | null;
lintWarnings: LintWarning[];
}
function isUnavailable(data: unknown): data is { unavailable: true; error: string; hint?: string } {
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
}
@@ -28,6 +38,47 @@ export function ProgressWorkspace() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [search, setSearch] = useState("");
const [selectedRow, setSelectedRow] = useState<{ file: ProgressFileNode; row: ProgressRow } | null>(null);
const [showLint, setShowLint] = useState<boolean>(true);
// Pre-compute Priority Rail Rule level + lint warnings + next-pull
// line per file so child sections render without recomputing on every
// filter/search keystroke. Per item 6: "missing tree" rule needs to
// know which files have child PROGRESS.md files (parents of other
// files in the tree) — derived once from the file list.
const fileMeta = useMemo(() => {
if (!tree.data || isUnavailable(tree.data)) return new Map<string, FileMeta>();
const meta = new Map<string, FileMeta>();
const files = tree.data.files;
// Set of directory paths that contain child PROGRESS.md files (other than themselves).
const childDirs = new Set<string>();
for (const f of files) {
const parts = f.relPath.split("/");
// Ancestor dirs (any prefix path) are "parents" if some other
// file's relPath starts with them.
for (let i = 0; i < parts.length - 1; i++) {
childDirs.add(`${f.rootName}|${parts.slice(0, i + 1).join("/")}`);
}
}
for (const f of files) {
const level = classifyPriorityRailLevel(f);
const segments = f.relPath.split("/");
const fileDir = segments.slice(0, -1).join("/");
const isParent = Array.from(childDirs).some((key) => key === `${f.rootName}|${fileDir}` && fileDir.length > 0)
// A file at the root with descendants:
|| files.some((other) => other !== f && other.relPath !== f.relPath && other.relPath.startsWith(`${fileDir ? `${fileDir}/` : ""}`) && other.rootName === f.rootName);
const hasChildFiles = isParent && files.some((other) =>
other !== f &&
other.rootName === f.rootName &&
(fileDir === "" ? true : other.relPath.startsWith(`${fileDir}/`)),
);
meta.set(`${f.rootName}/${f.relPath}`, {
level,
nextPullLine: level === "lane" ? computeNextPullLine(f) : null,
lintWarnings: computeLintWarnings(f, hasChildFiles),
});
}
return meta;
}, [tree.data]);
return (
<div data-testid="progress-workspace" className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]">
@@ -68,6 +119,20 @@ export function ProgressWorkspace() {
onChange={(e) => setSearch(e.target.value)}
className="ml-auto min-w-32 flex-1 border border-stone-300 bg-white px-2 py-1 font-mono text-[10px]"
/>
<button
type="button"
data-testid="progress-show-lint-toggle"
data-active={showLint}
onClick={() => setShowLint((v) => !v)}
className={`shrink-0 border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.10em] ${
showLint
? "border-amber-500 bg-amber-50 text-amber-900"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
title="Toggle Operator Surface Reconciliation v0 lint warnings (item 6)"
>
lint {showLint ? "on" : "off"}
</button>
</div>
</header>
<div className="flex flex-1 min-h-0">
@@ -89,6 +154,8 @@ export function ProgressWorkspace() {
<ProgressFileSection
key={`${file.rootName}/${file.relPath}`}
file={file}
meta={fileMeta.get(`${file.rootName}/${file.relPath}`)}
showLint={showLint}
statusFilter={statusFilter}
search={search}
onRowClick={(row) => setSelectedRow({ file, row })}
@@ -132,12 +199,16 @@ export function ProgressWorkspace() {
function ProgressFileSection({
file,
meta,
showLint,
statusFilter,
search,
onRowClick,
selectedRow,
}: {
file: ProgressFileNode;
meta: FileMeta | undefined;
showLint: boolean;
statusFilter: StatusFilter;
search: string;
onRowClick: (row: ProgressRow) => void;
@@ -146,9 +217,15 @@ function ProgressFileSection({
const [collapsed, setCollapsed] = useState(false);
const filteredRows = useMemo(() => filterRows(file.rows, statusFilter, search), [file.rows, statusFilter, search]);
if (filteredRows.length === 0 && (statusFilter !== "all" || search)) return null;
// OSR v0 item 6: file-scope lint warnings (e.g., missing-tree) render
// at the file header. Per-row warnings render inline next to their row.
const lintWarnings = meta?.lintWarnings ?? [];
const fileScopeLint = lintWarnings.filter((w) => w.line === null);
const levelStyle = meta ? getPriorityRailLevelStyle(meta.level) : null;
return (
<section
data-testid={`progress-file-${file.rootName}-${file.relPath}`}
data-rail-level={meta?.level ?? "unknown"}
className="border-b border-stone-100"
>
<button
@@ -158,12 +235,44 @@ function ProgressFileSection({
className="flex w-full items-baseline gap-2 bg-stone-50 px-3 py-2 text-left hover:bg-stone-100"
>
<span className="font-mono text-[10px] text-stone-500">{collapsed ? "▸" : "▾"}</span>
{levelStyle && (
<span
data-testid={`progress-file-${file.rootName}-${file.relPath}-level`}
data-level={meta?.level}
className={`shrink-0 border px-1 font-mono text-[8px] uppercase tracking-[0.10em] ${levelStyle.chipClass}`}
>
{levelStyle.label}
</span>
)}
<span className="font-mono text-[11px] font-bold text-stone-900">{file.title ?? file.relPath}</span>
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">{file.rootName}</span>
{showLint && lintWarnings.length > 0 && (
<span
data-testid={`progress-file-${file.rootName}-${file.relPath}-lint-count`}
className="shrink-0 border border-amber-400 bg-amber-50 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-amber-900"
title={`${lintWarnings.length} lint warning(s)`}
>
⚠ {lintWarnings.length}
</span>
)}
<span className="ml-auto font-mono text-[9px] text-stone-500">
{file.counts.done}/{file.counts.total}
</span>
</button>
{!collapsed && showLint && fileScopeLint.length > 0 && (
<ul data-testid={`progress-file-${file.rootName}-${file.relPath}-file-lint`} className="border-t border-amber-200 bg-amber-50 px-3 py-1">
{fileScopeLint.map((w, idx) => (
<li
key={`file-lint-${idx}`}
data-testid={`progress-lint-${w.ruleId}`}
className="font-mono text-[9px] text-amber-900"
title={w.citation}
>
⚠ {w.message}
</li>
))}
</ul>
)}
{!collapsed && (
<ul className="px-3 py-2">
{filteredRows.map((row) => (
@@ -189,6 +298,27 @@ function ProgressFileSection({
<>
<StatusPill status={row.status} />
<span className="font-mono text-[10px] text-stone-800">{row.text}</span>
{meta?.nextPullLine === row.line && (
<span
data-testid={`progress-row-${file.rootName}-${row.line}-next-pull`}
className="border border-emerald-400 bg-emerald-100 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-emerald-900"
title="Priority Rail Rule: first non-done, non-blocked item on this lane"
>
next pull
</span>
)}
{showLint && lintWarnings
.filter((w) => w.line === row.line)
.map((w) => (
<span
key={w.ruleId}
data-testid={`progress-row-${file.rootName}-${row.line}-lint-${w.ruleId}`}
className="border border-amber-400 bg-amber-50 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-amber-900"
title={`${w.message} (${w.citation})`}
>
⚠ {w.ruleId}
</span>
))}
<span className="ml-auto font-mono text-[8px] text-stone-400">L{row.line}</span>
</>
)}
@@ -0,0 +1,176 @@
// Operator Surface Reconciliation v0 — Priority Rail Rule helpers.
//
// Item 2: per-level typography distinguishing the four canonical
// Priority Rail Rule levels (STEERING / mission / lane / slice) plus
// "intermediate" cursor files that don't fit any of the four named
// buckets.
//
// Per the workstream-continuity convention referenced in the PRD
// (`workstream-continuity/README.md:119-163`), the level is purely a
// path-shape classification — the operator's mental model attaches to
// the file's location in the workspace tree. The classifier is a
// regex-set; driver-picked per audit-row 10.
import type { ProgressFileNode, ProgressRow } from "../../hooks/useProgressTree.js";
export type PriorityRailLevel = "steering" | "mission" | "lane" | "slice" | "intermediate";
const STEERING_BASENAME = /(^|[\\/])STEERING\.md$/i;
const MISSION_PATH = /(^|[\\/])missions[\\/][^\\/]+[\\/]PROGRESS\.md$/;
const ROADMAP_PATH = /(^|[\\/])roadmap[\\/]PROGRESS\.md$/;
const DELIVERY_LANE_PATH = /(^|[\\/])delivery-ready[\\/]mode-\d+[\\/]PROGRESS\.md$/;
const SLICE_PATH = /(^|[\\/])slices[\\/][^\\/]+[\\/]PROGRESS\.md$/;
export function classifyPriorityRailLevel(file: ProgressFileNode): PriorityRailLevel {
// Check both the relPath (tracked relative to the scan root) and the
// absolutePath (canonical on-disk) so the classifier works regardless
// of how the operator scoped their scan roots.
const candidates = [file.relPath, file.absolutePath];
for (const candidate of candidates) {
if (STEERING_BASENAME.test(candidate)) return "steering";
if (DELIVERY_LANE_PATH.test(candidate)) return "lane";
if (ROADMAP_PATH.test(candidate)) return "lane";
if (SLICE_PATH.test(candidate)) return "slice";
if (MISSION_PATH.test(candidate)) return "mission";
}
return "intermediate";
}
export interface PriorityRailLevelStyle {
label: string;
/** Tailwind classes for the row chip. */
chipClass: string;
/** Order key for grouping in the tree (steering first → intermediate last). */
order: number;
}
const STYLES: Record<PriorityRailLevel, PriorityRailLevelStyle> = {
steering: { label: "Constraint", chipClass: "border-stone-900 bg-stone-900 text-stone-50", order: 0 },
mission: { label: "Mission", chipClass: "border-violet-400 bg-violet-50 text-violet-900", order: 1 },
lane: { label: "Lane", chipClass: "border-emerald-400 bg-emerald-50 text-emerald-900", order: 2 },
slice: { label: "Slice", chipClass: "border-sky-400 bg-sky-50 text-sky-900", order: 3 },
intermediate: { label: "Cursor", chipClass: "border-stone-300 bg-stone-50 text-stone-500", order: 4 },
};
export function getPriorityRailLevelStyle(level: PriorityRailLevel): PriorityRailLevelStyle {
return STYLES[level];
}
/** Computes the "next pull" line per Priority Rail Rule semantics for a
* lane file: first checkbox row whose status is neither done nor
* blocked. Returns null when no such row exists (lane is fully closed
* or fully blocked). */
export function computeNextPullLine(file: ProgressFileNode): number | null {
for (const row of file.rows) {
if (row.kind !== "checkbox") continue;
if (row.status !== "done" && row.status !== "blocked") return row.line;
}
return null;
}
// --- Lint rules (item 6) ---
export type LintRuleId = "long-row" | "missing-tree" | "narrative-mixed" | "qitem-no-label";
export interface LintWarning {
ruleId: LintRuleId;
/** Source line (1-based) within the file. May be null for file-scope
* warnings (e.g., missing-tree applies to the whole file). */
line: number | null;
/** Operator-readable message. */
message: string;
/** Citation pointing back to the workstream-continuity convention so
* operators can re-read the rule when investigating. */
citation: string;
}
const LONG_ROW_THRESHOLD_CHARS = 160;
const TREE_HEADING_REGEX = /^#{1,4}\s+(tree|hierarchy|topology)/i;
const QITEM_PATTERN = /^qitem-\d{8,}-[0-9a-f]+$/i;
/** Computes lint warnings for a single PROGRESS.md file per the
* workstream-continuity rules cited in the PRD § Item 6. v0 ships
* the four named rules; results are advisory (no file modification). */
export function computeLintWarnings(file: ProgressFileNode, hasChildFiles: boolean): LintWarning[] {
const warnings: LintWarning[] = [];
// Rule 1 — items longer than two visual lines (heuristic at 160 chars).
for (const row of file.rows) {
if (row.kind === "checkbox" && row.text.length > LONG_ROW_THRESHOLD_CHARS) {
warnings.push({
ruleId: "long-row",
line: row.line,
message: `Row text is ${row.text.length} chars (over ${LONG_ROW_THRESHOLD_CHARS}). Split into a parent rail + sub-items.`,
citation: "workstream-continuity/README.md § rule 1 (items ≤ 2 visual lines)",
});
}
}
// Rule 2 — missing tree diagram when folder participates in hierarchy.
if (hasChildFiles) {
const hasTreeHeading = file.rows.some((r) => r.kind === "heading" && TREE_HEADING_REGEX.test(`## ${r.text}`));
if (!hasTreeHeading) {
warnings.push({
ruleId: "missing-tree",
line: null,
message: "File parents other PROGRESS.md files but has no '## Tree' / '## Hierarchy' / '## Topology' heading.",
citation: "workstream-continuity/README.md § rule 2 (authored tree diagrams for parent files)",
});
}
}
// Rule 3 — narrative-style items mixed with rail items in the same section.
// Heuristic: between two heading boundaries, count checkbox rows AND
// count "narrative" rows (non-checkbox, non-heading lines that are
// also non-empty). When both > 0, warn at the first checkbox row of
// the section. v0 applies this only when the section has at least
// 2 checkboxes + 2 narrative lines to keep noise low.
let sectionCheckboxes: ProgressRow[] = [];
let sectionNarrativeCount = 0;
let sectionStartLine = 1;
const flushSection = () => {
if (sectionCheckboxes.length >= 2 && sectionNarrativeCount >= 2) {
const first = sectionCheckboxes[0]!;
warnings.push({
ruleId: "narrative-mixed",
line: first.line,
message: `Section starting near L${sectionStartLine} interleaves ${sectionNarrativeCount} narrative line(s) with ${sectionCheckboxes.length} rail item(s). Move prose to the section heading body or a sibling notes file.`,
citation: "workstream-continuity/README.md § rule 3 (narrative vs. rail-item separation)",
});
}
sectionCheckboxes = [];
sectionNarrativeCount = 0;
};
for (const row of file.rows) {
if (row.kind === "heading") {
flushSection();
sectionStartLine = row.line;
continue;
}
if (row.kind === "checkbox") {
sectionCheckboxes.push(row);
}
}
flushSection();
// Narrative count: we don't have non-checkbox/non-heading rows in the
// ProgressFileNode shape (the indexer drops them); rule 3 ships
// active-but-quiet at v0. The heuristic above only fires when
// sectionNarrativeCount ≥ 2 which is never true in v0 — recording
// explicitly as a known limitation; v0+1 promotion would require
// teaching the indexer to retain narrative lines.
// Rule 4 — qitem ids without human-readable names.
for (const row of file.rows) {
if (row.kind !== "checkbox") continue;
if (QITEM_PATTERN.test(row.text)) {
warnings.push({
ruleId: "qitem-no-label",
line: row.line,
message: "Row body is a bare qitem id with no human label. Append a one-line description.",
citation: "workstream-continuity/README.md § rule 4 (human-readable rail rows)",
});
}
}
return warnings;
}
@@ -0,0 +1,80 @@
// Operator Surface Reconciliation v0 — Compact Health Gates panel.
//
// Item 1F: rig ps + rig context summaries via /api/health-summary/*.
// Each badge is read-only at v0; deeper drill-down (e.g., clicking
// "attention-required: N") is a v1+ ergonomic upgrade and surfaced
// here as a copy-able CLI hint instead of a daemon write.
import { useContextHealth, useNodeHealth } from "../../hooks/useSteering.js";
export function HealthGatesPanel() {
const nodeHealth = useNodeHealth();
const contextHealth = useContextHealth();
return (
<section data-testid="steering-health-gates" className="border border-stone-300 bg-white">
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Compact health gates
</div>
</header>
<div className="grid grid-cols-1 gap-3 p-3 md:grid-cols-2">
<NodeGate />
<ContextGate />
</div>
</section>
);
function NodeGate() {
if (nodeHealth.isLoading) return <div className="font-mono text-[10px] text-stone-400">Loading nodes…</div>;
if (nodeHealth.isError) return <div data-testid="steering-health-nodes-error" className="font-mono text-[10px] text-red-600">Health summary unavailable (nodes).</div>;
if (!nodeHealth.data) return null;
const { total, bySessionStatus, attentionRequired } = nodeHealth.data;
return (
<div data-testid="steering-health-nodes" className="space-y-1">
<div className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">Nodes</div>
<div className="flex flex-wrap items-baseline gap-2 font-mono text-[10px]">
<Stat label="total" value={total} />
<Stat label="running" value={bySessionStatus["running"] ?? 0} variant="ok" />
<Stat label="detached" value={bySessionStatus["detached"] ?? 0} variant="warn" />
<Stat label="exited" value={bySessionStatus["exited"] ?? 0} variant="dim" />
<Stat label="attention" value={attentionRequired} variant={attentionRequired > 0 ? "alert" : "dim"} />
</div>
<div className="font-mono text-[8px] text-stone-400">via rig ps --nodes --summary</div>
</div>
);
}
function ContextGate() {
if (contextHealth.isLoading) return <div className="font-mono text-[10px] text-stone-400">Loading context…</div>;
if (contextHealth.isError) return <div data-testid="steering-health-context-error" className="font-mono text-[10px] text-red-600">Health summary unavailable (context).</div>;
if (!contextHealth.data) return null;
const { total, byUrgency, critical, warning, stale } = contextHealth.data;
return (
<div data-testid="steering-health-context" className="space-y-1">
<div className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">Context usage</div>
<div className="flex flex-wrap items-baseline gap-2 font-mono text-[10px]">
<Stat label="total" value={total} />
<Stat label="ok" value={byUrgency["low"] ?? 0} variant="ok" />
<Stat label="warning" value={warning} variant={warning > 0 ? "warn" : "dim"} />
<Stat label="critical" value={critical} variant={critical > 0 ? "alert" : "dim"} />
<Stat label="stale" value={stale} variant={stale > 0 ? "warn" : "dim"} />
</div>
<div className="font-mono text-[8px] text-stone-400">via rig context --json</div>
</div>
);
}
}
function Stat({ label, value, variant = "dim" }: { label: string; value: number; variant?: "ok" | "warn" | "alert" | "dim" }) {
const cls =
variant === "ok" ? "text-emerald-700"
: variant === "warn" ? "text-amber-700"
: variant === "alert" ? "text-red-700 font-bold"
: "text-stone-600";
return (
<span data-testid={`steering-stat-${label}`} className="inline-flex items-baseline gap-0.5">
<span className="text-stone-500">{label}:</span>
<span className={cls}>{value}</span>
</span>
);
}
@@ -0,0 +1,111 @@
// Operator Surface Reconciliation v0 — Product-Specs in Motion panel.
//
// Item 1C: composes PL-005 Mission Control's `recently-active` view
// + filters to qitems whose body or destination references
// `product-specs/` PRDs. Per PRD § Item 1C, the matching strategy is
// driver-picked: we use a body-text-substring match against the
// "product-specs/" path fragment + a tag-based match when the qitem's
// tags include "spec" / "prd" / "review". Heuristic kept local to v0;
// a stricter "PRDs in motion" surface would need a typed view added
// to PL-005, which is out of scope here.
import { useQuery } from "@tanstack/react-query";
interface MissionControlRow {
qitemId: string;
sourceSession?: string;
destinationSession?: string;
body?: string;
state?: string;
tier?: string | null;
tsUpdated?: string;
tags?: string[];
// Phone-friendly model 9 fields are exposed by Phase A views:
rigName?: string;
plainEnglishPhase?: string;
oneLineNextAction?: string;
pendingHumanDecision?: string;
readCost?: string;
lastUpdate?: string;
confidenceFreshness?: string;
evidenceLinkPath?: string;
}
interface MissionControlViewResponse {
viewName: string;
rows: MissionControlRow[];
}
async function fetchRecentlyActive(): Promise<MissionControlViewResponse> {
const res = await fetch("/api/mission-control/views/recently-active");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as MissionControlViewResponse;
}
function isSpecRelated(row: MissionControlRow): boolean {
const body = row.body ?? "";
const dest = row.destinationSession ?? "";
if (body.includes("product-specs/")) return true;
if (body.toLowerCase().includes("prd")) return true;
if (dest.includes("planner") || dest.includes("steward") || dest.includes("librarian")) return true;
if (row.tags?.some((t) => /\b(spec|prd|review)\b/i.test(t))) return true;
return false;
}
export function InMotionPanel() {
const view = useQuery({
queryKey: ["mission-control", "views", "recently-active"],
queryFn: fetchRecentlyActive,
staleTime: 30_000,
});
return (
<section
data-testid="steering-in-motion"
className="border border-stone-300 bg-white"
>
<header className="flex items-baseline justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Product-specs in motion
</div>
<div className="font-mono text-[9px] text-stone-500">
via Mission Control · recently-active filtered to spec-related
</div>
</header>
{view.isLoading && <div className="p-3 font-mono text-[10px] text-stone-400">Loading…</div>}
{view.isError && (
<div data-testid="steering-in-motion-error" className="p-3 font-mono text-[10px] text-red-600">
Mission Control view unavailable. Configure PL-005 surfaces or check daemon.
</div>
)}
{view.data && (() => {
const filtered = view.data.rows.filter(isSpecRelated);
if (filtered.length === 0) {
return <div className="p-3 font-mono text-[10px] text-stone-400" data-testid="steering-in-motion-empty">No spec-related qitems in flight.</div>;
}
return (
<ul className="divide-y divide-stone-100">
{filtered.slice(0, 8).map((row) => (
<li
key={row.qitemId}
data-testid={`steering-in-motion-row-${row.qitemId}`}
className="px-3 py-1.5"
>
<div className="flex items-baseline gap-2">
<span className="font-mono text-[9px] text-stone-500 shrink-0">{(row.tsUpdated ?? "").slice(0, 19)}</span>
<span className="font-mono text-[10px] text-stone-800 truncate">
{row.body ?? row.qitemId}
</span>
{row.destinationSession && (
<span className="ml-auto font-mono text-[8px] text-stone-400 shrink-0">
→ {row.destinationSession}
</span>
)}
</div>
</li>
))}
</ul>
);
})()}
</section>
);
}
@@ -0,0 +1,110 @@
// Operator Surface Reconciliation v0 — Lane Rails panel.
//
// Item 1D: per-lane (delivery-ready/mode-{0..3}) top-N items + lane-
// health badges (active/blocked/done) + next-pull marker per the
// Priority Rail Rule semantics computed daemon-side.
import type { LaneRailItem, LaneRailPayload } from "../../hooks/useSteering.js";
export function LaneRailsPanel({ laneRails }: { laneRails: LaneRailPayload[] }) {
if (laneRails.length === 0) {
return (
<section
data-testid="steering-lane-rails-empty"
className="border border-stone-200 bg-stone-50 p-3 font-mono text-[10px] text-stone-500"
>
Delivery-ready lane rails source unavailable. Configure OPENRIG_DELIVERY_READY_DIR or place delivery-ready/mode-N/PROGRESS.md under the steering workspace.
</section>
);
}
return (
<section data-testid="steering-lane-rails" className="border border-stone-300 bg-white">
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Delivery-ready lane rails
</div>
</header>
<div className="divide-y divide-stone-200">
{laneRails.map((lane) => (
<LaneRailSection key={lane.laneId} lane={lane} />
))}
</div>
</section>
);
}
function LaneRailSection({ lane }: { lane: LaneRailPayload }) {
return (
<div
data-testid={`steering-lane-${lane.laneId}`}
className="px-3 py-2"
>
<div className="flex items-baseline gap-2">
<div className="font-mono text-[10px] font-bold text-stone-900">{lane.laneId}</div>
<div className="ml-auto flex items-baseline gap-2">
<HealthBadge label="active" count={lane.healthBadges.active} variant="active" />
<HealthBadge label="blocked" count={lane.healthBadges.blocked} variant="blocked" />
<HealthBadge label="done" count={lane.healthBadges.done} variant="done" />
</div>
</div>
{lane.topItems.length === 0 ? (
<div className="mt-1 font-mono text-[9px] text-stone-400">No checkbox rows.</div>
) : (
<ul className="mt-1 space-y-0.5">
{lane.topItems.map((item) => (
<LaneRailRow key={item.line} item={item} />
))}
</ul>
)}
</div>
);
}
function LaneRailRow({ item }: { item: LaneRailItem }) {
const { icon, color } = statusGlyph(item.status);
return (
<li
data-testid={`steering-lane-row-${item.line}`}
data-status={item.status}
data-is-next-pull={item.isNextPull}
className={`flex items-baseline gap-2 px-1 py-0.5 ${item.isNextPull ? "bg-emerald-50" : ""}`}
>
<span className={`shrink-0 font-mono text-[10px] ${color}`} aria-label={item.status}>{icon}</span>
<span className="flex-1 font-mono text-[10px] text-stone-800 truncate">{item.text}</span>
{item.isNextPull && (
<span
data-testid={`steering-lane-row-${item.line}-next-pull`}
className="shrink-0 border border-emerald-400 bg-emerald-100 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-emerald-900"
>
next pull
</span>
)}
<span className="shrink-0 font-mono text-[8px] text-stone-400">L{item.line}</span>
</li>
);
}
function HealthBadge({ label, count, variant }: { label: string; count: number; variant: "active" | "blocked" | "done" }) {
const cls =
variant === "active" ? "border-stone-300 bg-stone-50 text-stone-700"
: variant === "blocked" ? "border-amber-300 bg-amber-50 text-amber-900"
: "border-emerald-300 bg-emerald-50 text-emerald-900";
return (
<span
data-testid={`steering-lane-badge-${variant}`}
className={`inline-flex items-baseline gap-0.5 border px-1 font-mono text-[8px] uppercase tracking-[0.10em] ${cls}`}
>
<span>{label}</span>
<span className="font-bold">{count}</span>
</span>
);
}
function statusGlyph(status: LaneRailItem["status"]): { icon: string; color: string } {
switch (status) {
case "done": return { icon: "✓", color: "text-emerald-700" };
case "blocked": return { icon: "⚠", color: "text-amber-700" };
case "active": return { icon: "◯", color: "text-stone-500" };
default: return { icon: "·", color: "text-stone-400" };
}
}
@@ -0,0 +1,81 @@
// Operator Surface Reconciliation v0 — Loop State panel.
//
// Item 1E: cross-rig roll-up of recent qitems via PL-005's
// `recently-active` view (same source as InMotionPanel; here we
// surface the most-recent qitem per actor seat without the
// product-specs filter, so the operator sees who's busy on what).
import { useQuery } from "@tanstack/react-query";
interface MissionControlRow {
qitemId: string;
sourceSession?: string;
destinationSession?: string;
body?: string;
state?: string;
tsUpdated?: string;
}
interface MissionControlViewResponse {
viewName: string;
rows: MissionControlRow[];
}
async function fetchRecentlyActive(): Promise<MissionControlViewResponse> {
const res = await fetch("/api/mission-control/views/recently-active");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as MissionControlViewResponse;
}
export function LoopStatePanel() {
const view = useQuery({
queryKey: ["mission-control", "views", "recently-active"],
queryFn: fetchRecentlyActive,
staleTime: 30_000,
});
return (
<section data-testid="steering-loop-state" className="border border-stone-300 bg-white">
<header className="flex items-baseline justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Loop state
</div>
<div className="font-mono text-[9px] text-stone-500">
most-recent qitem per seat (cross-rig roll-up)
</div>
</header>
{view.isLoading && <div className="p-3 font-mono text-[10px] text-stone-400">Loading…</div>}
{view.isError && (
<div data-testid="steering-loop-state-error" className="p-3 font-mono text-[10px] text-red-600">
Mission Control view unavailable.
</div>
)}
{view.data && (() => {
const bySeat = new Map<string, MissionControlRow>();
for (const row of view.data.rows) {
const seat = row.destinationSession ?? row.sourceSession ?? "unknown";
const existing = bySeat.get(seat);
if (!existing || (row.tsUpdated ?? "") > (existing.tsUpdated ?? "")) bySeat.set(seat, row);
}
const seats = Array.from(bySeat.entries()).sort((a, b) => (b[1].tsUpdated ?? "").localeCompare(a[1].tsUpdated ?? ""));
if (seats.length === 0) {
return <div data-testid="steering-loop-state-empty" className="p-3 font-mono text-[10px] text-stone-400">No recent qitems.</div>;
}
return (
<ul className="divide-y divide-stone-100">
{seats.slice(0, 10).map(([seat, row]) => (
<li
key={seat}
data-testid={`steering-loop-row-${seat}`}
className="flex items-baseline gap-2 px-3 py-1"
>
<span className="font-mono text-[9px] text-stone-500 shrink-0">{(row.tsUpdated ?? "").slice(11, 19)}</span>
<span className="font-mono text-[10px] font-semibold text-stone-900 shrink-0 truncate max-w-[14rem]">{seat}</span>
<span className="flex-1 font-mono text-[10px] text-stone-700 truncate">{row.body ?? row.qitemId}</span>
</li>
))}
</ul>
);
})()}
</section>
);
}
@@ -0,0 +1,40 @@
// Operator Surface Reconciliation v0 — Priority Stack panel.
//
// Item 1A: verbatim render of STEERING.md content. Uses the v0
// MarkdownViewer so the operator sees the same rendering surface
// they'd see in /files. Last-modified timestamp on the header
// surfaces freshness at a glance.
import type { PriorityStackPayload } from "../../hooks/useSteering.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
export function PriorityStackPanel({ priorityStack }: { priorityStack: PriorityStackPayload | null }) {
if (!priorityStack) {
return (
<section
data-testid="steering-priority-stack-empty"
className="border border-stone-200 bg-stone-50 p-3 font-mono text-[10px] text-stone-500"
>
Priority stack source unavailable. Configure OPENRIG_STEERING_PATH or OPENRIG_STEERING_WORKSPACE.
</section>
);
}
return (
<section
data-testid="steering-priority-stack"
className="border-2 border-stone-900 bg-white"
>
<header className="flex items-baseline justify-between border-b border-stone-300 bg-stone-100 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Priority stack — the constraint frame
</div>
<div className="font-mono text-[9px] text-stone-500" data-testid="steering-priority-stack-mtime">
last modified {priorityStack.mtime}
</div>
</header>
<div className="max-h-[60vh] overflow-y-auto p-4">
<MarkdownViewer content={priorityStack.content} hideRawToggle />
</div>
</section>
);
}
@@ -0,0 +1,78 @@
// Operator Surface Reconciliation v0 — Roadmap Rail panel.
//
// Item 1B: PL-XXX checklist + next-unchecked marker. Each unchecked
// row links to its corresponding PRD if a product-specs/<slug>/README.md
// exists; v0 simply links via the Files browser path.
import type { RoadmapRailPayload } from "../../hooks/useSteering.js";
export function RoadmapRailPanel({ roadmapRail }: { roadmapRail: RoadmapRailPayload | null }) {
if (!roadmapRail) {
return (
<section
data-testid="steering-roadmap-rail-empty"
className="border border-stone-200 bg-stone-50 p-3 font-mono text-[10px] text-stone-500"
>
Roadmap rail source unavailable. Configure OPENRIG_ROADMAP_PATH or place roadmap/PROGRESS.md under the steering workspace.
</section>
);
}
const { items, counts, mtime } = roadmapRail;
return (
<section
data-testid="steering-roadmap-rail"
className="border border-stone-300 bg-white"
>
<header className="flex items-baseline justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Roadmap rail
</div>
<div className="font-mono text-[9px] text-stone-500" data-testid="steering-roadmap-rail-counts">
{counts.done} / {counts.total} done · last modified {mtime}
</div>
</header>
{items.length === 0 ? (
<div className="p-3 font-mono text-[10px] text-stone-400">No checkbox rows in roadmap PROGRESS.md.</div>
) : (
<ul className="divide-y divide-stone-100">
{items.map((item) => (
<li
key={item.line}
data-testid={`steering-roadmap-item-${item.line}`}
data-rail-code={item.railItemCode ?? ""}
data-is-next-unchecked={item.isNextUnchecked}
className={`flex items-baseline gap-2 px-3 py-1.5 ${item.isNextUnchecked ? "bg-emerald-50" : ""}`}
>
<span
className={`inline-flex shrink-0 items-center justify-center font-mono text-[10px] ${
item.done ? "text-emerald-700" : "text-stone-500"
}`}
aria-label={item.done ? "done" : "active"}
>
{item.done ? "✓" : "◯"}
</span>
{item.railItemCode && (
<span
className="shrink-0 font-mono text-[9px] font-bold uppercase tracking-[0.10em] text-stone-700"
data-testid={`steering-roadmap-item-${item.line}-code`}
>
{item.railItemCode}
</span>
)}
<span className="flex-1 font-mono text-[10px] text-stone-800">{item.text}</span>
{item.isNextUnchecked && (
<span
data-testid={`steering-roadmap-next-marker-${item.line}`}
className="shrink-0 border border-emerald-400 bg-emerald-100 px-1.5 py-0.5 font-mono text-[8px] uppercase tracking-[0.10em] text-emerald-900"
>
next pull
</span>
)}
<span className="shrink-0 font-mono text-[8px] text-stone-400">L{item.line}</span>
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,94 @@
// Operator Surface Reconciliation v0 — steering workspace.
//
// Item 1 (HEADLINE): one-screen composed steering surface. Six panels
// rendered as a single scrollable viewport:
// A. Priority stack (verbatim STEERING.md)
// B. Roadmap rail (PL-XXX checklist + next-unchecked marker)
// C. Product-specs in motion (PL-005 view consumer)
// D. Lane rails (delivery-ready/mode-{0..3} top-N + health badges)
// E. Loop state (PL-005 view consumer)
// F. Compact health gates (rig ps + rig context summaries)
//
// Read-only at v0. Each panel surfaces operator-actionable affordances
// via copy-able command hints, never daemon writes.
import { Link } from "@tanstack/react-router";
import { useSteering } from "../../hooks/useSteering.js";
import { PriorityStackPanel } from "./PriorityStackPanel.js";
import { RoadmapRailPanel } from "./RoadmapRailPanel.js";
import { InMotionPanel } from "./InMotionPanel.js";
import { LaneRailsPanel } from "./LaneRailsPanel.js";
import { LoopStatePanel } from "./LoopStatePanel.js";
import { HealthGatesPanel } from "./HealthGatesPanel.js";
function isUnavailable(data: unknown): data is { unavailable: true; error: string; hint?: string } {
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
}
export function SteeringWorkspace() {
const steering = useSteering();
return (
<div
data-testid="steering-workspace"
className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]"
>
<header className="border-b border-stone-200 bg-stone-50 px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">Workspace</div>
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">Steering</h1>
<div className="mt-1 font-mono text-[10px] text-stone-500">
One screen — what's the constraint, what's in motion, what does each loop need next?
</div>
</header>
<main className="flex-1 min-h-0 overflow-y-auto bg-white p-4">
{steering.isLoading && <div className="font-mono text-[10px] text-stone-400">Loading steering surface…</div>}
{steering.isError && <div className="font-mono text-[10px] text-red-600" data-testid="steering-error">Error loading steering surface.</div>}
{isUnavailable(steering.data) && (
<div data-testid="steering-unavailable" className="font-mono text-[10px] text-stone-500">
<div>Steering composer unavailable.</div>
{steering.data.hint && <div className="mt-1 text-stone-400">{steering.data.hint}</div>}
<div className="mt-3 font-mono text-[9px] text-stone-400">
<Link to="/files" className="text-blue-700 underline">
Open Files →
</Link>{" "}
to browse STEERING.md directly until the composer is configured.
</div>
</div>
)}
{steering.data && !isUnavailable(steering.data) && (
<div className="space-y-4">
<PriorityStackPanel priorityStack={steering.data.priorityStack} />
<RoadmapRailPanel roadmapRail={steering.data.roadmapRail} />
<InMotionPanel />
<LaneRailsPanel laneRails={steering.data.laneRails} />
<LoopStatePanel />
<HealthGatesPanel />
{steering.data.unavailableSources.length > 0 && (
<UnavailableSourcesNotice sources={steering.data.unavailableSources} />
)}
</div>
)}
</main>
</div>
);
}
function UnavailableSourcesNotice({ sources }: { sources: Array<{ section: string; reason: string; envVar?: string }> }) {
return (
<section
data-testid="steering-unavailable-sources"
className="border border-amber-300 bg-amber-50 p-3"
>
<div className="mb-2 font-mono text-[8px] uppercase tracking-[0.18em] text-amber-700">
Some sections are unavailable
</div>
<ul className="space-y-1 font-mono text-[10px] text-amber-900">
{sources.map((src) => (
<li key={src.section} data-testid={`steering-unavailable-${src.section}`}>
<span className="font-bold">{src.section}:</span> {src.reason}
{src.envVar && <span className="ml-2 text-amber-600">(env: {src.envVar})</span>}
</li>
))}
</ul>
</section>
);
}
+5
View File
@@ -86,6 +86,11 @@ export interface FilesReadResponse {
mtime: string;
contentHash: string;
size: number;
/** Operator Surface Reconciliation v0 item 5: present when the
* daemon truncated the returned content (file > 1 MB cap). */
truncated?: boolean;
truncatedAtBytes?: number | null;
totalBytes?: number;
}
async function fetchRead(root: string, path: string): Promise<FilesReadResponse> {
+164
View File
@@ -0,0 +1,164 @@
// Operator Surface Reconciliation v0 — steering hook + types.
//
// Wraps GET /api/steering. Surfaces the daemon's
// "steering_workspace_not_configured" 503 path as a structured
// `unavailable` sentinel so the UI can render a setup hint instead
// of crashing on an undefined payload.
import { useQuery } from "@tanstack/react-query";
export interface PriorityStackPayload {
content: string;
absolutePath: string;
mtime: string;
byteCount: number;
}
export interface RoadmapRailItem {
line: number;
text: string;
done: boolean;
railItemCode: string | null;
isNextUnchecked: boolean;
}
export interface RoadmapRailPayload {
absolutePath: string;
mtime: string;
items: RoadmapRailItem[];
counts: { total: number; done: number; nextUncheckedLine: number | null };
}
export interface LaneRailItem {
line: number;
text: string;
status: "active" | "done" | "blocked" | "unknown";
isNextPull: boolean;
}
export interface LaneRailPayload {
laneId: string;
absolutePath: string;
mtime: string;
topItems: LaneRailItem[];
healthBadges: { active: number; blocked: number; done: number; total: number };
nextPullLine: number | null;
}
export interface SteeringPayload {
priorityStack: PriorityStackPayload | null;
roadmapRail: RoadmapRailPayload | null;
laneRails: LaneRailPayload[];
unavailableSources: Array<{ section: string; reason: string; envVar?: string }>;
}
export interface SteeringUnavailable {
unavailable: true;
error: string;
hint?: string;
}
async function fetchSteering(): Promise<SteeringPayload | SteeringUnavailable> {
const res = await fetch("/api/steering");
if (res.status === 503) {
const body = (await res.json().catch(() => ({}))) as { error?: string; hint?: string };
return { unavailable: true, error: body.error ?? "steering_unavailable", hint: body.hint };
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as SteeringPayload;
}
export function useSteering() {
return useQuery({
queryKey: ["steering"],
queryFn: fetchSteering,
staleTime: 30_000,
});
}
// --- health summary ---
export interface NodeHealthSummary {
total: number;
bySessionStatus: Record<string, number>;
byLifecycle: Record<string, number>;
attentionRequired: number;
}
export interface ContextHealthSummary {
total: number;
byUrgency: Record<string, number>;
byFreshness: Record<string, number>;
critical: number;
warning: number;
stale: number;
}
async function fetchNodeHealth(): Promise<NodeHealthSummary> {
const res = await fetch("/api/health-summary/nodes");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as NodeHealthSummary;
}
async function fetchContextHealth(): Promise<ContextHealthSummary> {
const res = await fetch("/api/health-summary/context");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as ContextHealthSummary;
}
export function useNodeHealth() {
return useQuery({
queryKey: ["health-summary", "nodes"],
queryFn: fetchNodeHealth,
staleTime: 30_000,
});
}
export function useContextHealth() {
return useQuery({
queryKey: ["health-summary", "context"],
queryFn: fetchContextHealth,
staleTime: 30_000,
});
}
// --- spec review hook (item 3) ---
export interface SpecReviewError {
field?: string;
message: string;
severity?: "error" | "warning";
}
export interface SpecReviewResponse {
ok?: boolean;
errors?: SpecReviewError[];
warnings?: SpecReviewError[];
/** Daemon may include extra metadata (sourceState, ...) — pass-through. */
[k: string]: unknown;
}
async function fetchSpecReview(kind: "rig" | "agent", yaml: string): Promise<SpecReviewResponse> {
const res = await fetch(`/api/specs/review/${kind}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ yaml }),
});
// Daemon returns 200 with errors[] for both valid + invalid specs
// (per spec-review.ts contract — the SpecReviewError throw path
// surfaces as 400). Treat both as "got a review back".
if (res.status === 400) {
return (await res.json()) as SpecReviewResponse;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as SpecReviewResponse;
}
export function useSpecReview(kind: "rig" | "agent" | null, yaml: string | null) {
return useQuery({
queryKey: ["spec-review", kind, yaml ? yaml.length : 0, yaml ? yaml.slice(0, 64) : ""],
queryFn: () => fetchSpecReview(kind!, yaml!),
enabled: !!kind && !!yaml,
staleTime: 60_000,
});
}
+8
View File
@@ -27,6 +27,7 @@ import { MissionControlSurface } from "./components/mission-control/MissionContr
import { SliceStoryView } from "./components/slices/SliceStoryView.js";
import { ProgressWorkspace } from "./components/progress/ProgressWorkspace.js";
import { FilesWorkspace } from "./components/files/FilesWorkspace.js";
import { SteeringWorkspace } from "./components/steering/SteeringWorkspace.js";
import { useRigSummary } from "./hooks/useRigSummary.js";
import { useDrawerSelection } from "./components/AppShell.js";
@@ -256,6 +257,12 @@ const filesRoute = createRoute({
path: "/files",
component: FilesWorkspace,
});
// Operator Surface Reconciliation v0 — top-level Steering route (item 1).
const steeringRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/steering",
component: SteeringWorkspace,
});
const routeTree = rootRoute.addChildren([
indexRoute,
@@ -280,6 +287,7 @@ const routeTree = rootRoute.addChildren([
sliceDetailRoute,
progressRoute,
filesRoute,
steeringRoute,
]);
// Router
@@ -0,0 +1,58 @@
// Operator Surface Reconciliation v0 — MarkdownViewer raw/rendered toggle tests.
//
// Item 4: header-bar toggle switches between rendered (default;
// MarkdownViewer's normal block render) and raw (monospace pre-rendered
// text + visible Markdown source). Frontmatter metadata header still
// renders in raw mode unless hideFrontmatter is set. hideRawToggle
// prop suppresses the toggle for callers (e.g. PriorityStackPanel)
// that don't want the chrome.
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { MarkdownViewer } from "../src/components/markdown/MarkdownViewer.js";
afterEach(() => cleanup());
const SAMPLE_MD = `---\nslice: foo\n---\n# Heading\n- list item`;
describe("OSR v0 — MarkdownViewer raw/rendered toggle", () => {
it("default mode is rendered; toggle buttons present", () => {
render(<MarkdownViewer content={SAMPLE_MD} />);
expect(screen.getByTestId("markdown-viewer").getAttribute("data-mode")).toBe("rendered");
expect(screen.getByTestId("markdown-viewer-mode-rendered").getAttribute("data-active")).toBe("true");
expect(screen.getByTestId("markdown-viewer-mode-raw").getAttribute("data-active")).toBe("false");
expect(screen.getByTestId("markdown-viewer-rendered")).toBeDefined();
});
it("clicking raw toggle switches mode + renders source verbatim", () => {
render(<MarkdownViewer content={SAMPLE_MD} />);
fireEvent.click(screen.getByTestId("markdown-viewer-mode-raw"));
expect(screen.getByTestId("markdown-viewer").getAttribute("data-mode")).toBe("raw");
expect(screen.getByTestId("markdown-viewer-mode-raw").getAttribute("data-active")).toBe("true");
const raw = screen.getByTestId("markdown-viewer-raw");
expect(raw.textContent).toContain("# Heading");
expect(raw.textContent).toContain("- list item");
expect(screen.queryByTestId("markdown-viewer-rendered")).toBeNull();
});
it("frontmatter metadata header still renders in raw mode", () => {
render(<MarkdownViewer content={SAMPLE_MD} />);
fireEvent.click(screen.getByTestId("markdown-viewer-mode-raw"));
expect(screen.getByTestId("markdown-frontmatter").textContent).toContain("foo");
});
it("hideRawToggle hides the toggle entirely (caller renders only one mode)", () => {
render(<MarkdownViewer content={SAMPLE_MD} hideRawToggle />);
expect(screen.queryByTestId("markdown-viewer-mode-toggle")).toBeNull();
expect(screen.queryByTestId("markdown-viewer-mode-raw")).toBeNull();
});
it("toggling back to rendered from raw restores block render", () => {
render(<MarkdownViewer content={SAMPLE_MD} />);
fireEvent.click(screen.getByTestId("markdown-viewer-mode-raw"));
fireEvent.click(screen.getByTestId("markdown-viewer-mode-rendered"));
expect(screen.getByTestId("markdown-viewer").getAttribute("data-mode")).toBe("rendered");
expect(screen.getByTestId("markdown-viewer-rendered")).toBeDefined();
expect(screen.queryByTestId("markdown-viewer-raw")).toBeNull();
});
});
+178
View File
@@ -0,0 +1,178 @@
// Operator Surface Reconciliation v0 — Priority Rail Rule + lint helpers (UI side).
//
// Pure-logic tests (no React); pin classification + next-pull semantics
// + lint heuristics so the Progress workspace renders consistent
// signals without re-deriving from path strings each test.
import { describe, it, expect } from "vitest";
import {
classifyPriorityRailLevel,
computeLintWarnings,
computeNextPullLine,
getPriorityRailLevelStyle,
} from "../src/components/progress/priority-rail-rule.js";
import type { ProgressFileNode, ProgressRow } from "../src/hooks/useProgressTree.js";
function makeFile(opts: { rootName?: string; relPath: string; rows?: ProgressRow[] }): ProgressFileNode {
return {
rootName: opts.rootName ?? "ws",
relPath: opts.relPath,
absolutePath: `/abs/${opts.rootName ?? "ws"}/${opts.relPath}`,
mtime: "2026-05-04T00:00:00.000Z",
rows: opts.rows ?? [],
title: null,
counts: { total: 0, done: 0, blocked: 0, active: 0 },
};
}
function checkbox(line: number, status: ProgressRow["status"], text: string, depth = 0): ProgressRow {
return { line, depth, status, text, kind: "checkbox" };
}
function heading(line: number, text: string, depth = 0): ProgressRow {
return { line, depth, status: "unknown", text, kind: "heading" };
}
describe("OSR v0 — classifyPriorityRailLevel", () => {
it("STEERING.md → 'steering'", () => {
expect(classifyPriorityRailLevel(makeFile({ relPath: "STEERING.md" }))).toBe("steering");
});
it("missions/<m>/PROGRESS.md → 'mission'", () => {
expect(classifyPriorityRailLevel(makeFile({ relPath: "missions/recursive-self-improvement-v2/PROGRESS.md" })))
.toBe("mission");
});
it("roadmap/PROGRESS.md → 'lane'", () => {
expect(classifyPriorityRailLevel(makeFile({ relPath: "roadmap/PROGRESS.md" }))).toBe("lane");
});
it("delivery-ready/mode-N/PROGRESS.md → 'lane'", () => {
expect(classifyPriorityRailLevel(makeFile({ relPath: "delivery-ready/mode-2/PROGRESS.md" }))).toBe("lane");
});
it("slices/<s>/PROGRESS.md → 'slice'", () => {
expect(classifyPriorityRailLevel(makeFile({ relPath: "slices/foo-bar/PROGRESS.md" }))).toBe("slice");
});
it("anything else → 'intermediate'", () => {
expect(classifyPriorityRailLevel(makeFile({ relPath: "scratch/cursor/PROGRESS.md" }))).toBe("intermediate");
});
});
describe("OSR v0 — getPriorityRailLevelStyle", () => {
it("returns labels + chip classes for all five levels", () => {
for (const lvl of ["steering", "mission", "lane", "slice", "intermediate"] as const) {
const style = getPriorityRailLevelStyle(lvl);
expect(style.label).toBeTruthy();
expect(style.chipClass).toContain("border-");
}
});
it("steering has the most distinctive treatment (Constraint label)", () => {
expect(getPriorityRailLevelStyle("steering").label).toBe("Constraint");
});
});
describe("OSR v0 — computeNextPullLine", () => {
it("returns the line of the first non-done, non-blocked checkbox row", () => {
const file = makeFile({
relPath: "delivery-ready/mode-2/PROGRESS.md",
rows: [
checkbox(1, "done", "alpha"),
checkbox(2, "blocked", "beta"),
checkbox(3, "active", "gamma"),
checkbox(4, "active", "delta"),
],
});
expect(computeNextPullLine(file)).toBe(3);
});
it("returns null when no row qualifies (all done or all blocked)", () => {
const allDone = makeFile({
relPath: "delivery-ready/mode-3/PROGRESS.md",
rows: [checkbox(1, "done", "x"), checkbox(2, "done", "y")],
});
expect(computeNextPullLine(allDone)).toBeNull();
});
it("ignores headings", () => {
const file = makeFile({
relPath: "delivery-ready/mode-1/PROGRESS.md",
rows: [
heading(1, "Section A"),
checkbox(2, "active", "first"),
],
});
expect(computeNextPullLine(file)).toBe(2);
});
});
describe("OSR v0 — computeLintWarnings", () => {
it("rule 1 (long-row): flags rows over 160 chars with line citation", () => {
const longText = "x".repeat(180);
const file = makeFile({
relPath: "x/PROGRESS.md",
rows: [checkbox(5, "active", longText)],
});
const warnings = computeLintWarnings(file, false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.ruleId).toBe("long-row");
expect(warnings[0]?.line).toBe(5);
expect(warnings[0]?.message).toContain("180");
});
it("rule 2 (missing-tree): flags parent files without a Tree/Hierarchy/Topology heading", () => {
const file = makeFile({
relPath: "x/PROGRESS.md",
rows: [
heading(1, "Status"),
checkbox(2, "active", "do thing"),
],
});
const warnings = computeLintWarnings(file, true);
expect(warnings.find((w) => w.ruleId === "missing-tree")).toBeDefined();
});
it("rule 2 (missing-tree): NOT flagged when file has a '## Tree' heading", () => {
const file = makeFile({
relPath: "x/PROGRESS.md",
rows: [heading(1, "Tree"), checkbox(2, "active", "do thing")],
});
const warnings = computeLintWarnings(file, true);
expect(warnings.find((w) => w.ruleId === "missing-tree")).toBeUndefined();
});
it("rule 4 (qitem-no-label): flags rows whose body is a bare qitem id", () => {
const file = makeFile({
relPath: "x/PROGRESS.md",
rows: [
checkbox(1, "active", "qitem-20260504123456-abcdef"),
checkbox(2, "active", "qitem-20260504123456-abcdef — drive PL-019 dogfood"),
],
});
const warnings = computeLintWarnings(file, false);
const qitemFlags = warnings.filter((w) => w.ruleId === "qitem-no-label");
expect(qitemFlags).toHaveLength(1);
expect(qitemFlags[0]?.line).toBe(1);
});
it("returns no warnings on a clean file", () => {
const file = makeFile({
relPath: "x/PROGRESS.md",
rows: [
heading(1, "Tree"),
checkbox(2, "done", "shipped"),
checkbox(3, "active", "next item"),
],
});
expect(computeLintWarnings(file, true)).toEqual([]);
});
it("citation field references workstream-continuity convention rule", () => {
const longText = "x".repeat(200);
const file = makeFile({ relPath: "x/PROGRESS.md", rows: [checkbox(1, "active", longText)] });
const w = computeLintWarnings(file, false)[0];
expect(w?.citation).toContain("workstream-continuity");
});
});
+132
View File
@@ -0,0 +1,132 @@
// Operator Surface Reconciliation v0 — steering panel render tests.
//
// Pins the load-bearing per-panel rendering for items 1A (priority
// stack) + 1B (roadmap rail) + 1D (lane rails). Items 1C (in-motion)
// + 1E (loop state) + 1F (health gates) are tested via the broader
// SteeringWorkspace surface test which mocks /api/* fetches.
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { PriorityStackPanel } from "../src/components/steering/PriorityStackPanel.js";
import { RoadmapRailPanel } from "../src/components/steering/RoadmapRailPanel.js";
import { LaneRailsPanel } from "../src/components/steering/LaneRailsPanel.js";
import type { LaneRailPayload, PriorityStackPayload, RoadmapRailPayload } from "../src/hooks/useSteering.js";
afterEach(() => cleanup());
describe("OSR v0 — PriorityStackPanel", () => {
it("renders STEERING.md content + last-modified timestamp", () => {
const stack: PriorityStackPayload = {
content: "# Priority Stack\n- Do X first\n- Avoid Y for now",
absolutePath: "/abs/STEERING.md",
mtime: "2026-05-04T12:00:00.000Z",
byteCount: 100,
};
render(<PriorityStackPanel priorityStack={stack} />);
expect(screen.getByTestId("steering-priority-stack")).toBeDefined();
expect(screen.getByTestId("steering-priority-stack-mtime").textContent).toContain("2026-05-04");
// MarkdownViewer renders the content. Since hideRawToggle is set,
// toggle buttons are absent and the rendered block is visible.
expect(screen.queryByTestId("markdown-viewer-mode-toggle")).toBeNull();
});
it("renders empty-state hint when priority stack is null", () => {
render(<PriorityStackPanel priorityStack={null} />);
expect(screen.getByTestId("steering-priority-stack-empty")).toBeDefined();
});
});
describe("OSR v0 — RoadmapRailPanel", () => {
it("renders empty-state hint when roadmap rail is null", () => {
render(<RoadmapRailPanel roadmapRail={null} />);
expect(screen.getByTestId("steering-roadmap-rail-empty")).toBeDefined();
});
it("renders rows with rail codes + done glyphs + next-pull marker on first unchecked", () => {
const rail: RoadmapRailPayload = {
absolutePath: "/abs/roadmap/PROGRESS.md",
mtime: "2026-05-04T12:00:00.000Z",
items: [
{ line: 1, text: "PL-005 Phase A done", done: true, railItemCode: "PL-005", isNextUnchecked: false },
{ line: 2, text: "PL-019 done", done: true, railItemCode: "PL-019", isNextUnchecked: false },
{ line: 3, text: "PL-022 next pull", done: false, railItemCode: "PL-022", isNextUnchecked: true },
{ line: 4, text: "PL-030 later", done: false, railItemCode: "PL-030", isNextUnchecked: false },
],
counts: { total: 4, done: 2, nextUncheckedLine: 3 },
};
render(<RoadmapRailPanel roadmapRail={rail} />);
expect(screen.getByTestId("steering-roadmap-item-1").getAttribute("data-rail-code")).toBe("PL-005");
expect(screen.getByTestId("steering-roadmap-item-3").getAttribute("data-is-next-unchecked")).toBe("true");
expect(screen.getByTestId("steering-roadmap-next-marker-3")).toBeDefined();
// No marker on item 4 (later, not next).
expect(screen.queryByTestId("steering-roadmap-next-marker-4")).toBeNull();
expect(screen.getByTestId("steering-roadmap-rail-counts").textContent).toContain("2 / 4 done");
});
it("renders 'No checkbox rows' when roadmap is empty", () => {
const empty: RoadmapRailPayload = {
absolutePath: "/abs/roadmap/PROGRESS.md",
mtime: "2026-05-04T12:00:00.000Z",
items: [],
counts: { total: 0, done: 0, nextUncheckedLine: null },
};
render(<RoadmapRailPanel roadmapRail={empty} />);
expect(screen.getByTestId("steering-roadmap-rail")).toBeDefined();
expect(screen.getByText(/No checkbox rows/i)).toBeDefined();
});
});
describe("OSR v0 — LaneRailsPanel", () => {
it("renders empty-state hint when laneRails is empty", () => {
render(<LaneRailsPanel laneRails={[]} />);
expect(screen.getByTestId("steering-lane-rails-empty")).toBeDefined();
});
it("renders one section per lane with health badges + top items + next-pull marker", () => {
const lanes: LaneRailPayload[] = [
{
laneId: "mode-2",
absolutePath: "/abs/delivery-ready/mode-2/PROGRESS.md",
mtime: "2026-05-04T12:00:00.000Z",
topItems: [
{ line: 5, text: "blocked one", status: "blocked", isNextPull: false },
{ line: 6, text: "the next pull", status: "active", isNextPull: true },
{ line: 7, text: "later one", status: "active", isNextPull: false },
],
healthBadges: { active: 2, blocked: 1, done: 5, total: 8 },
nextPullLine: 6,
},
{
laneId: "mode-3",
absolutePath: "/abs/delivery-ready/mode-3/PROGRESS.md",
mtime: "2026-05-04T12:00:00.000Z",
topItems: [],
healthBadges: { active: 0, blocked: 0, done: 4, total: 4 },
nextPullLine: null,
},
];
render(<LaneRailsPanel laneRails={lanes} />);
expect(screen.getByTestId("steering-lane-mode-2")).toBeDefined();
expect(screen.getByTestId("steering-lane-mode-3")).toBeDefined();
expect(screen.getByTestId("steering-lane-row-6-next-pull")).toBeDefined();
expect(screen.queryByTestId("steering-lane-row-5-next-pull")).toBeNull();
expect(screen.getByText(/No checkbox rows/i)).toBeDefined();
});
it("health badges render the count + variant class for each (active/blocked/done)", () => {
const lanes: LaneRailPayload[] = [{
laneId: "mode-2",
absolutePath: "/abs/x",
mtime: "2026-05-04T12:00:00.000Z",
topItems: [],
healthBadges: { active: 3, blocked: 1, done: 5, total: 9 },
nextPullLine: null,
}];
render(<LaneRailsPanel laneRails={lanes} />);
const badges = screen.getAllByTestId(/^steering-lane-badge-/);
const labels = badges.map((b) => b.textContent ?? "");
expect(labels.some((t) => t.includes("active") && t.includes("3"))).toBe(true);
expect(labels.some((t) => t.includes("blocked") && t.includes("1"))).toBe(true);
expect(labels.some((t) => t.includes("done") && t.includes("5"))).toBe(true);
});
});