mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user