diff --git a/src/observability/dashboard/state/index.js b/src/observability/dashboard/state/index.js
index 7d10fc83..d6989d4a 100644
--- a/src/observability/dashboard/state/index.js
+++ b/src/observability/dashboard/state/index.js
@@ -14,6 +14,11 @@ import { buildPeople, buildPresence } from './people.js';
import { buildBusinessFlexState } from './business-flex.js';
import { buildDecisionState } from './decisions.js';
import { validateProjectConfigs } from '../../../core/harness/config-validation.js';
+// [wave:command] imports — pipeline rollup enrichment (#94 / #156 / #215)
+import { readPipelineState, buildPipelineState } from '../../../core/harness/pipeline-state.js';
+import { recommendPipelineAction } from '../../../commands/pipeline.js';
+import { runDirectory } from '../../../core/harness/runs.js';
+import { readJson } from './files.js';
export { toClientState } from './client-state.js';
export { resolveApprovalAcrossRoots } from './approvals.js';
@@ -33,6 +38,9 @@ export async function buildFullState(projectRoot, options = {}) {
getAllApprovals(roots),
]);
+ // [wave:command] Compact pipeline-state rollup per run (#94/#156/#215).
+ await attachPipelineRollups(runs);
+
// run.totals already prefers persisted cumulative metrics (#83) and falls
// back to event recompute for legacy runs; the metrics.json chain here is
// the last resort for index entries without totals.
@@ -143,3 +151,123 @@ function buildBlockedGateAlerts(blockedGates) {
ts: gate.ts,
}));
}
+
+// ── [wave:command] Pipeline rollup enrichment (#94 / #156 / #215) ───────────
+// Attaches `run.pipelineRollup` — a compact summary of the run's
+// pipeline-state.json — so the Command Center shows the SAME next-action the
+// `rstack-agents pipeline status` CLI computes: recommendPipelineAction is
+// reused verbatim, never re-implemented client-side (no second brain).
+//
+// Read path only: a persisted pipeline-state.json is preferred (one small
+// read); fully-parsed runs without one are summarized in memory via
+// buildPipelineState — the dashboard never writes run state. Index-served
+// lite runs without a persisted rollup stay null, and the UI shows an honest
+// "no pipeline state recorded" instead of a guess. Best-effort by contract:
+// any per-run failure yields null, never a broken snapshot.
+
+const ROLLUP_FAILED_STATUSES = new Set(['FAIL', 'FAILED', 'ERROR', 'BLOCKED']);
+const ROLLUP_PASSED_STATUSES = new Set(['PASS', 'PASSED', 'SUCCESS', 'SUCCEEDED', 'DONE', 'COMPLETED']);
+
+async function attachPipelineRollups(runs) {
+ await Promise.all((runs ?? []).map(async (run) => {
+ try {
+ let state = await readPipelineState(run.projectRoot, run.runId);
+ if (!state && !run.fromIndex) state = await buildPipelineState(run.projectRoot, run.runId);
+ run.pipelineRollup = state ? compactPipelineRollup(state, run.events ?? []) : null;
+ } catch {
+ run.pipelineRollup = null;
+ }
+ // Index-served lite runs rebuild a synthetic manifest that drops
+ // schema_version (#82 stamps it, #156 renders it) — restore it with one
+ // tiny manifest read so migration state stays observable for every run.
+ if (run.fromIndex && run.manifest && run.manifest.schema_version === undefined) {
+ const manifest = await readJson(join(runDirectory(run.projectRoot, run.runId), 'manifest.json'), null);
+ if (manifest?.schema_version !== undefined) run.manifest.schema_version = manifest.schema_version;
+ }
+ }));
+}
+
+// Mirrors recommendPipelineAction's deterministic priority order
+// (approvals → failed → active → pending → complete) to CLASSIFY the action
+// for chip routing; the sentence itself always comes from
+// recommendPipelineAction so the two can never disagree on substance.
+function classifyNextAction(state) {
+ const none = { kind: 'unknown', stage_id: null, task_id: null, artifact: null };
+ if (!state || !Array.isArray(state.stages)) return none;
+ const blocker = (state.approval_blockers ?? [])[0];
+ if (blocker) return { kind: 'approval', stage_id: blocker.stage_id ?? null, task_id: null, artifact: blocker.artifact ?? null };
+ const failed = state.stages.find((stage) => ROLLUP_FAILED_STATUSES.has(stage.status));
+ if (failed) {
+ const kind = failed.retry_state === 'exhausted' ? 'guardrail_blocked'
+ : failed.retry_state === 'retryable' ? 'retry' : 'failed';
+ return { kind, stage_id: failed.id, task_id: (failed.task_ids ?? [])[0] ?? null, artifact: null };
+ }
+ if (state.current?.stage_id) {
+ return { kind: 'active', stage_id: state.current.stage_id, task_id: state.current.task_id ?? null, artifact: null };
+ }
+ const pending = state.stages.find((stage) => stage.status === 'PENDING');
+ if (pending) return { kind: 'pending', stage_id: pending.id, task_id: null, artifact: null };
+ if (state.stages.length > 0 && state.stages.every((stage) => ROLLUP_PASSED_STATUSES.has(stage.status))) {
+ return { kind: 'complete', stage_id: null, task_id: null, artifact: null };
+ }
+ return none;
+}
+
+function compactPipelineRollup(state, events) {
+ const next = classifyNextAction(state);
+ const loop = state.goal_loop ?? {};
+ // Last goal verdict: prefer the rollup's last_evaluation.status; the BLE-4
+ // goal evaluator emits `recommendation` on the pinned goal_evaluated event,
+ // so fall back to that before giving up.
+ const lastGoalEvent = [...(events ?? [])].reverse()
+ .find((event) => String(event?.type ?? event?.kind ?? '') === 'goal_evaluated') ?? null;
+ const lastVerdict = loop.last_evaluation?.status ?? lastGoalEvent?.status ?? lastGoalEvent?.recommendation ?? null;
+ // Freshness (#218 review): a persisted pipeline-state.json can lag the live
+ // event stream on an active run. `generated_at` stamps when the state was
+ // computed; any event newer than it means the next-action below is behind
+ // live data. Detected from data already in the snapshot — no extra read —
+ // so the hero card can say so rather than present a stale recommendation as
+ // live ("never let stale data look live").
+ const generatedAt = state.generated_at ?? null;
+ const eventsBehind = generatedAt
+ ? (events ?? []).filter((event) => String(event?.ts ?? event?.timestamp ?? '') > String(generatedAt)).length
+ : 0;
+ return {
+ schema_version: state.schema_version ?? null,
+ status: state.pipeline?.status ?? 'UNKNOWN',
+ stages_total: state.pipeline?.stages_total ?? 0,
+ stages_passed: state.pipeline?.stages_passed ?? 0,
+ stages_failed: state.pipeline?.stages_failed ?? 0,
+ generated_at: generatedAt,
+ stale: eventsBehind > 0,
+ events_behind: eventsBehind,
+ next_action: { ...next, text: recommendPipelineAction(state) },
+ approval_blockers: (state.approval_blockers ?? []).length,
+ retries: {
+ total: state.retries?.total ?? 0,
+ scheduled: state.retries?.scheduled ?? 0,
+ exhausted: state.retries?.exhausted ?? 0,
+ human_required: state.retries?.human_required ?? 0,
+ },
+ context_pressure: {
+ total: state.context_pressure?.total ?? 0,
+ by_source: state.context_pressure?.by_source ?? {},
+ },
+ checkpoints: {
+ total: state.checkpoints?.total ?? 0,
+ before_saved: state.checkpoints?.before_saved ?? 0,
+ after_saved: state.checkpoints?.after_saved ?? 0,
+ reverted: state.checkpoints?.reverted ?? 0,
+ },
+ goal_loop: {
+ total: loop.total ?? 0,
+ iterations: loop.iterations ?? 0,
+ active: (loop.total ?? 0) > 0 && !loop.stopped_on,
+ stopped_on: loop.stopped_on ?? null,
+ last_verdict: lastVerdict,
+ criteria_met: lastGoalEvent?.criteria_met ?? null,
+ criteria_total: lastGoalEvent?.criteria_total ?? null,
+ },
+ };
+}
+// ── end [wave:command] ──────────────────────────────────────────────────────
diff --git a/src/observability/dashboard/ui/pages/command-center.js b/src/observability/dashboard/ui/pages/command-center.js
index 8ef9b5c7..a6256fb6 100644
--- a/src/observability/dashboard/ui/pages/command-center.js
+++ b/src/observability/dashboard/ui/pages/command-center.js
@@ -23,6 +23,9 @@ function renderCommand(s) {
setText('command-status-chip', hasAttention ? 'Needs review' : activeRunCount ? 'Live work running' : 'All clear');
setClass('command-status-chip', 'command-status ' + (hasAttention ? 'warn' : activeRunCount ? 'active' : 'ok'));
renderExecutiveMissionBrief(s);
+ ensureCommandWavePanels();
+ renderCommandExecRollup(s);
+ renderCommandNextAction(s);
setText('kpi-projects', projects.length);
setText('kpi-projects-s', (s.sourceRoots || []).length + ' source roots tracked');
@@ -92,6 +95,182 @@ function renderExecutiveMissionBrief(s) {
}).join(''));
}
+// ── [wave:command] Executive rollup + pipeline next action (#94 / #156) ──
+// The page skeleton (ui/pages/index.js) is shared across parallel page work,
+// so this module owns its new DOM: panels are injected once, then re-rendered
+// on every snapshot like everything else.
+function ensureCommandWavePanels() {
+ if (document.getElementById('command-exec-rollup-panel')) return;
+ var page = document.getElementById('page-command');
+ if (!page) return;
+ var anchor = page.querySelector('.executive-grid');
+ if (!anchor) return;
+ anchor.insertAdjacentHTML('afterend',
+ '
' +
+ '
Pipeline Next Action
' +
+ '
' +
+ '
' +
+ '' +
+ '
Executive Rollup
' +
+ '
' +
+ '
');
+}
+
+function fmtCompactCount(value) {
+ value = Number(value) || 0;
+ if (value >= 1000000) return (Math.round(value / 100000) / 10) + 'M';
+ if (value >= 1000) return (Math.round(value / 100) / 10) + 'k';
+ return String(value);
+}
+
+function runSpend(run) {
+ return Number((run.totals && run.totals.cost_usd) || (run.metrics && run.metrics.cumulative_cost_usd) || 0);
+}
+
+function runTokens(run) {
+ if (run.totals && run.totals.tokens) return Number(run.totals.tokens) || 0;
+ var metricTokens = run.metrics && run.metrics.cumulative_tokens;
+ if (metricTokens && typeof metricTokens === 'object') return Number(metricTokens.total) || 0;
+ return Number(metricTokens) || 0;
+}
+
+// The 30-second global-exec read (#94): runs by status, spend, tokens,
+// task pass-rate, open decisions — computed from the SCOPED snapshot so the
+// strip follows the project/run selector. Plus schema-version visibility
+// (#156): rollup + manifest schema versions for the selected run.
+function renderCommandExecRollup(s) {
+ var runs = s.runs || [];
+ var statusCounts = { active: 0, done: 0, stalled: 0, ended: 0, idle: 0 };
+ var spend = 0;
+ var tokens = 0;
+ runs.forEach(function(run) {
+ var status = run.derivedStatus || 'idle';
+ if (statusCounts[status] === undefined) statusCounts[status] = 0;
+ statusCounts[status] += 1;
+ spend += runSpend(run);
+ tokens += runTokens(run);
+ });
+ var counts = taskStatusCounts(allTasks(s));
+ var finished = counts.PASS + counts.FAIL;
+ var passRate = finished ? Math.round(counts.PASS / finished * 100) + '%' : '—';
+ var openDecisions = (s.decisions && s.decisions.totals && s.decisions.totals.pending) || 0;
+
+ setText('command-exec-rollup-note', runs.length + ' run' + (runs.length === 1 ? '' : 's') + ' in scope');
+
+ var runsDetail = statusCounts.active + ' active · ' + statusCounts.done + ' done' +
+ (statusCounts.stalled ? ' · ' + statusCounts.stalled + ' stalled' : '');
+ var stats = [
+ { value: runs.length, label: 'Runs', detail: runsDetail },
+ { value: '$' + spend.toFixed(2), label: 'Spend so far', detail: 'AI cost across runs in scope' },
+ { value: tokens ? fmtCompactCount(tokens) : '—', label: 'Tokens', detail: tokens ? 'input + output, all runs' : 'no token telemetry recorded' },
+ { value: passRate, label: 'Task pass rate', detail: finished ? counts.PASS + ' of ' + finished + ' finished tasks passed' : 'no finished tasks yet' },
+ { value: openDecisions, label: 'Open decisions', detail: openDecisions ? 'waiting in the Decision Queue' : 'nothing waiting on a human call' }
+ ];
+ var schema = execSchemaBadge(runs);
+ var html = stats.map(function(stat) {
+ return '' + esc(stat.value) + '
' +
+ '
' + esc(stat.label) + '
' +
+ '
' + esc(stat.detail) + '
';
+ }).join('') +
+ '' + esc(schema.value) + '
' +
+ '
State schema
' +
+ '
' + esc(schema.detail) + '
';
+ setHTML('command-exec-rollup', html);
+}
+
+function execSchemaBadge(runs) {
+ if (runs.length !== 1) {
+ return { value: '—', detail: 'select a single run to see its schema versions' };
+ }
+ var run = runs[0];
+ var rollupVersion = (run.pipelineRollup && run.pipelineRollup.schema_version) || null;
+ var manifestVersion = (run.manifest && run.manifest.schema_version) || null;
+ return {
+ value: 'rollup v' + (rollupVersion || '?') + ' · manifest v' + (manifestVersion || '?'),
+ detail: (rollupVersion && manifestVersion) ? 'pipeline-state.json + manifest.json schema versions' : 'unstamped files show v?'
+ };
+}
+
+// Which run does the exec care about right now? The scoped run if one is
+// selected; otherwise the newest active run; otherwise the newest run that
+// has pipeline state at all. Runs arrive newest-first from the state layer.
+function commandFocusRun(s) {
+ var runs = s.runs || [];
+ if (!runs.length) return null;
+ if (runs.length === 1) return runs[0];
+ var withRollup = runs.filter(function(run) { return run.pipelineRollup; });
+ var active = withRollup.filter(function(run) { return run.derivedStatus === 'active'; });
+ return active[0] || withRollup[0] || runs[0];
+}
+
+function showPageFromChip(el) {
+ showPage(el.getAttribute('data-page'));
+}
+
+function nextActionChip(kind) {
+ if (kind === 'approval') return { page: 'approvals', label: 'Review in Approvals', cls: 'warn' };
+ if (kind === 'guardrail_blocked' || kind === 'failed') return { page: 'alerts-guardrails', label: 'Open Alerts & Guardrails', cls: 'danger' };
+ if (kind === 'retry') return { page: 'alerts-guardrails', label: 'Watch in Alerts & Guardrails', cls: 'warn' };
+ if (kind === 'complete') return { page: 'run-report', label: 'Open Run Report', cls: '' };
+ if (kind === 'active' || kind === 'pending') return { page: 'workflow', label: 'View Workflow Map', cls: '' };
+ return { page: 'diagnostics', label: 'Open Diagnostics', cls: '' };
+}
+
+// #156: the deterministic next-action from the pipeline-state rollup — the
+// exact sentence the "rstack-agents pipeline status" CLI prints, rendered for
+// the selected run scope with a chip that jumps to the tab holding the action.
+function renderCommandNextAction(s) {
+ var run = commandFocusRun(s);
+ if (!run) {
+ setText('command-next-action-run', '');
+ setHTML('command-next-action', emptyHtml('No runs loaded yet', 'Start an RStack run and the pipeline recommendation appears here.'));
+ return;
+ }
+ setText('command-next-action-run', (run.runId || '').slice(-24));
+ var rollup = run.pipelineRollup;
+ if (!rollup || !rollup.next_action) {
+ setHTML('command-next-action', emptyHtml('No pipeline state recorded for this run',
+ 'Run "rstack-agents pipeline status --regenerate" to build pipeline-state.json from the run artifacts.'));
+ return;
+ }
+ var next = rollup.next_action;
+ var chipInfo = nextActionChip(next.kind);
+ var tone = next.kind === 'guardrail_blocked' || next.kind === 'failed' ? 'danger'
+ : next.kind === 'approval' || next.kind === 'retry' ? 'warn'
+ : next.kind === 'complete' ? 'ok' : 'info';
+ var meta = [];
+ if (next.stage_id) meta.push('stage ' + next.stage_id);
+ if (next.task_id) meta.push('task ' + next.task_id);
+ if (next.artifact) meta.push(next.artifact);
+ meta.push('pipeline ' + (rollup.status || 'UNKNOWN'));
+ meta.push(rollup.stages_passed + '/' + rollup.stages_total + ' stages passed');
+ setHTML('command-next-action',
+ '' +
+ '
→
' +
+ '
' +
+ '
' + esc(next.text || 'No recommendation available.') + '
' +
+ '
' + meta.map(function(part) { return '' + esc(part) + ' '; }).join('') + '
' +
+ '
' +
+ '
' + esc(chipInfo.label) + ' ' +
+ '
' +
+ nextActionSourceHtml(rollup));
+}
+
+// The hero states ITS OWN freshness, not the page's global "updated" chip: a
+// persisted pipeline-state.json that lags the live event stream must not be
+// presented as the current recommendation (#218 review — never let stale data
+// look live). Fresh → CLI-parity line; stale → an honest regenerate hint.
+function nextActionSourceHtml(rollup) {
+ if (rollup && rollup.stale) {
+ var behind = rollup.events_behind || 0;
+ return '⚠ From the last saved pipeline-state.json — ' +
+ behind + ' newer event' + (behind === 1 ? '' : 's') + ' since it was computed. ' +
+ 'Run "rstack-agents pipeline status --regenerate" for the live recommendation.
';
+ }
+ return 'Same recommendation the rstack-agents pipeline status CLI computes.
';
+}
+// ── end [wave:command] ────────────────────────────────────────────
+
function commandSummaryTitle(s, attentionItems, counts) {
var activeRunCount = (s.activeRuns || []).length;
if (attentionItems.length) {
@@ -143,6 +322,39 @@ function commandAttentionItems(s, counts) {
if (counts.BLOCKED) {
items.push({ level: 'danger', value: counts.BLOCKED, title: 'Guardrail-blocked tasks', detail: 'Attempt budget exhausted. Approve the guardrail-override entry in Approvals to allow exactly one more attempt.', meta: 'Guardrails' });
}
+ // [wave:command] July harness signals (#215): context pressure + goal loop
+ // from the per-run pipeline-state rollup. Runs without a rollup contribute
+ // nothing — no fabricated zeros.
+ var pressureTotal = 0;
+ var goalLoops = [];
+ runs.forEach(function(run) {
+ var rollup = run.pipelineRollup;
+ if (!rollup) return;
+ pressureTotal += (rollup.context_pressure && rollup.context_pressure.total) || 0;
+ if (rollup.goal_loop && rollup.goal_loop.active) goalLoops.push({ runId: run.runId, loop: rollup.goal_loop });
+ });
+ if (pressureTotal) {
+ items.push({
+ level: 'warn',
+ value: pressureTotal,
+ title: 'Context pressure: ' + pressureTotal + ' warning' + (pressureTotal === 1 ? '' : 's') + ' — long-loop quality risk',
+ detail: 'Prompts or memory blocks crossed configured size thresholds. Detect-only signal: nothing was pruned or truncated.',
+ meta: 'Context'
+ });
+ }
+ goalLoops.slice(0, 3).forEach(function(entry) {
+ var loop = entry.loop;
+ var verdict = loop.last_verdict ? 'last verdict ' + loop.last_verdict : 'no verdict yet';
+ var criteria = loop.criteria_total ? ', ' + loop.criteria_met + '/' + loop.criteria_total + ' criteria met' : '';
+ items.push({
+ level: 'info',
+ value: loop.iterations,
+ title: 'Goal loop running — iteration ' + loop.iterations,
+ detail: verdict + criteria + ' (' + entry.runId.slice(-24) + ').',
+ meta: 'Goal loop'
+ });
+ });
+ // end [wave:command]
if (missingValidation) {
items.push({ level: 'warn', value: missingValidation, title: 'Missing validations', detail: 'Agent work that does not yet have validation.json proof attached.', meta: 'Proof' });
}
diff --git a/src/observability/dashboard/ui/pages/decisions.js b/src/observability/dashboard/ui/pages/decisions.js
index 66d854f8..5d3df29d 100644
--- a/src/observability/dashboard/ui/pages/decisions.js
+++ b/src/observability/dashboard/ui/pages/decisions.js
@@ -26,11 +26,75 @@ function renderDecisions(s) {
var r = run.readiness || {};
return '' + esc(run.goal || run.runId) + '
' + esc(r.message || 'Definition-of-Ready status') + '
' + esc(run.profile || '') + ' ' + esc(r.mode || '') + ' score ' + esc(r.score || 0) + '
' + pill(r.status || 'PASS') + '
';
}).join('') || emptyHtml('No readiness data', 'Run sdlc_dor_check or rstack-agents dor after starting an RStack run.'));
+ ensureDecisionLogPanel();
+ renderDecisionLog(decisions);
}
+// ── [wave:command] Decision Log (#94) ──────────────────────────────
+// Beyond the pending queue: the chronological record of decisions a human
+// already made — who resolved or waived it, when, what the call was, and
+// which run it governs. The panel is injected by this module (the page
+// skeleton in ui/pages/index.js is shared across parallel page work).
+function ensureDecisionLogPanel() {
+ if (document.getElementById('decisions-log-panel')) return;
+ var page = document.getElementById('page-decisions');
+ if (!page) return;
+ var grid = page.querySelector('.grid-2');
+ if (!grid) return;
+ grid.insertAdjacentHTML('afterend',
+ '' +
+ '
Decision Log
' +
+ '
' +
+ '
');
+}
+
+function decisionResolvedTs(decision) {
+ return decision.resolved_at || decision.updated_at || decision.created_at || '';
+}
+
+function renderDecisionLog(decisions) {
+ var resolved = decisions.filter(function(item) {
+ var status = item.decision.status;
+ return status === 'resolved' || status === 'waived';
+ }).sort(function(a, b) {
+ return decisionResolvedTs(b.decision).localeCompare(decisionResolvedTs(a.decision));
+ });
+ setText('decisions-log-count', resolved.length + ' resolved or waived');
+ if (!resolved.length) {
+ setHTML('decisions-log', emptyHtml('No resolved decisions yet',
+ 'When someone resolves or waives a Decision Queue item, the who / when / what lands here as the audit trail.'));
+ return;
+ }
+ setHTML('decisions-log', resolved.slice(0, 60).map(decisionLogRowHtml).join(''));
+}
+
+function decisionLogRowHtml(item) {
+ var d = item.decision;
+ var who = d.resolved_by || 'unrecorded decider';
+ var verb = d.status === 'waived' ? 'Waived' : 'Resolved';
+ var outcome = d.resolution
+ ? 'Decision: ' + d.resolution
+ : (d.status === 'waived' ? 'Waived without a recorded resolution.' : 'No resolution text recorded.');
+ return '' +
+ '
' + esc(fmtTime(decisionResolvedTs(d)) || '-') + '
' +
+ '
' +
+ '
' + esc(d.decision_id + ' — ' + d.question) + '
' +
+ '
' + esc(outcome) + '
' +
+ '
' +
+ '' + esc(verb + ' by ' + who) + ' ' +
+ 'impact: ' + esc(d.impact || 'scope') + ' ' +
+ 'gated stage ' + esc(d.required_before_stage || '-') + ' ' +
+ 'run ' + esc((item.run.runId || '').slice(-16)) + ' ' +
+ '
' +
+ '
' +
+ pill(d.status === 'waived' ? 'warn' : 'pass', d.status) +
+ '
';
+}
+// ── end [wave:command] ─────────────────────────────────────────────
+
registerPage('decisions', {
errLabel: 'decisions',
- sub: 'Decision Queue and Definition-of-Ready status from decisions.json, dor-report.json and readiness.json.',
+ sub: 'Decision Queue, Decision Log and Definition-of-Ready status from decisions.json, dor-report.json and readiness.json.',
render: renderDecisions
});
`;
diff --git a/src/observability/dashboard/ui/styles.js b/src/observability/dashboard/ui/styles.js
index 8fffa1a9..6bef94f0 100644
--- a/src/observability/dashboard/ui/styles.js
+++ b/src/observability/dashboard/ui/styles.js
@@ -1270,6 +1270,32 @@ tr.clickable:hover td { background: #f8fbff; }
@media (max-width: 900px) { .report-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+/* [wave:command] Command Center next-action + exec rollup, Decision Log (#94/#156/#215) */
+.next-action-panel, .exec-rollup-panel { margin-bottom: 16px; }
+.next-action { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 14px; }
+.next-action-icon { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center;
+ justify-content: center; font-size: 18px; font-weight: 800; flex-shrink: 0; }
+.next-action-icon.ok { background: #f0fdf4; color: var(--green); }
+.next-action-icon.warn { background: #fff7ed; color: var(--amber); }
+.next-action-icon.danger { background: #fff5f5; color: var(--red); }
+.next-action-icon.info { background: #eff6ff; color: var(--blue); }
+.next-action-text { font-weight: 700; line-height: 1.4; }
+.next-action-source { margin-top: 10px; font-size: 11px; color: var(--faint); font-style: italic; }
+.next-action-source.stale { color: var(--amber, #b7791f); font-style: normal; font-weight: 600; }
+.exec-rollup-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
+.exec-stat { border: 1px solid var(--line); background: #fff; border-radius: 10px; padding: 10px 12px; }
+.exec-stat-v { font-size: 22px; font-weight: 800; line-height: 1.2; }
+.exec-stat-l { margin-top: 2px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .07em; }
+.exec-stat-s { margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.35; }
+.exec-stat-v.schema-badge { font-size: 13px; padding-top: 5px; }
+.decision-log-row { display: grid; grid-template-columns: 110px 1fr auto; gap: 12px; align-items: start;
+ padding: 10px 0; border-bottom: 1px dashed var(--line); }
+.decision-log-row:last-child { border-bottom: none; }
+.decision-log-when { color: var(--muted); font-size: 11px; padding-top: 2px; }
+.decision-log-main .feed-meta { margin-top: 4px; }
+@media (max-width: 900px) { .next-action, .decision-log-row { grid-template-columns: 1fr; } }
+/* end [wave:command] */
+
/* [wave:ops] — ops panels: retry state, guardrail depth, context pressure, audit rejections */
.feed-icon.info { color: var(--blue); background: #eff6ff; }
.ops-meta { color: var(--amber); font-weight: 700; }
diff --git a/tests/dashboard-command-pages.test.js b/tests/dashboard-command-pages.test.js
new file mode 100644
index 00000000..642a1e9d
--- /dev/null
+++ b/tests/dashboard-command-pages.test.js
@@ -0,0 +1,243 @@
+/**
+ * Command Center + Decisions page wave (#94 / #156 / #215 slice).
+ *
+ * Pins three contracts:
+ * 1. State layer: every run in the snapshot carries `pipelineRollup` — a
+ * compact pipeline-state summary whose next-action TEXT comes from
+ * recommendPipelineAction (the `pipeline status` CLI brain), never a
+ * client-side re-implementation. Missing/unbuildable state yields null,
+ * not a broken snapshot.
+ * 2. Client modules: the Command Center renders the next-action card, the
+ * executive rollup strip (with schema-version badge) and the new
+ * context-pressure / goal-loop attention signals; the Decisions page
+ * renders the resolved/waived Decision Log. All inside the compiled
+ * bundle (no-build-step stance).
+ * 3. Honest empty states: no fabricated data when the rollup or decisions
+ * are absent.
+ *
+ * owner: RStack developed by Richardson Gunde
+ */
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { buildFullState, toClientState } from '../src/observability/dashboard/state/index.js';
+import { recommendPipelineAction } from '../src/commands/pipeline.js';
+import { readPipelineState, writePipelineState } from '../src/core/harness/pipeline-state.js';
+import { clientScript } from '../src/observability/dashboard/ui/client.js';
+import { commandCenterScript } from '../src/observability/dashboard/ui/pages/command-center.js';
+import { decisionsScript } from '../src/observability/dashboard/ui/pages/decisions.js';
+
+const RUN_ID = '2026-07-06T12-00-00-000Z-command-fixture';
+
+function jsonl(events) {
+ return events.map((event) => JSON.stringify(event)).join('\n') + '\n';
+}
+
+// Fixture-shaped run: goal loop iteration 1 with a RETRY verdict, one
+// context_pressure_warning, a BLOCKED testing task with guardrail_triggered,
+// checkpoints, and a schema_version 2 manifest — the July harness signals.
+function seedProject() {
+ const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-command-pages-'));
+ const runDir = join(projectRoot, '.rstack', 'runs', RUN_ID);
+ mkdirSync(runDir, { recursive: true });
+ writeFileSync(join(runDir, 'manifest.json'), JSON.stringify({
+ run_id: RUN_ID,
+ schema_version: 2,
+ goal: 'Command pages fixture',
+ status: 'IN_PROGRESS',
+ created_at: '2026-07-06T12:00:00.000Z',
+ }));
+ writeFileSync(join(runDir, 'tasks.json'), JSON.stringify({
+ tasks: [
+ { id: '003-architecture', status: 'PASS', stage_artifacts: [{ stage_id: '06-architecture' }] },
+ { id: '004-implementation', status: 'IN_PROGRESS', stage_artifacts: [{ stage_id: '07-code' }] },
+ { id: '005-testing', status: 'BLOCKED', stage_artifacts: [{ stage_id: '08-testing' }] },
+ ],
+ }));
+ writeFileSync(join(runDir, 'metrics.json'), JSON.stringify({
+ cumulative_cost_usd: 4.87,
+ cumulative_tokens: { input: 1420000, output: 312000, total: 1732000 },
+ stage_status: { '06-architecture': 'PASS', '07-code': 'IN_PROGRESS', '08-testing': 'BLOCKED' },
+ }));
+ writeFileSync(join(runDir, 'events.jsonl'), jsonl([
+ { ts: '2026-07-06T12:01:00.000Z', type: 'task_started', task_id: '003-architecture' },
+ { ts: '2026-07-06T12:01:05.000Z', type: 'stage_checkpoint_before_saved', stage_id: '06-architecture', task_id: '003-architecture' },
+ { ts: '2026-07-06T12:10:00.000Z', type: 'task_validated', task_id: '003-architecture', status: 'PASS' },
+ { ts: '2026-07-06T12:11:00.000Z', type: 'loop_iteration_started', iteration: 1, goal_id: 'fixture-goal' },
+ { ts: '2026-07-06T12:13:00.000Z', type: 'context_pressure_warning', source: 'memory_summary', metric: 'chars', size: 61000, threshold: 40000, task_id: '004-implementation' },
+ { ts: '2026-07-06T12:31:00.000Z', type: 'task_retry_scheduled', task_id: '005-testing', attempt: 1 },
+ { ts: '2026-07-06T12:35:01.000Z', type: 'guardrail_triggered', task_id: '005-testing', limit_name: 'maxTaskAttempts' },
+ { ts: '2026-07-06T12:40:00.000Z', type: 'goal_evaluated', iteration: 1, recommendation: 'RETRY', criteria_met: 1, criteria_total: 2 },
+ ]));
+ writeFileSync(join(runDir, 'decisions.json'), JSON.stringify({
+ run_id: RUN_ID,
+ decisions: [
+ { decision_id: 'DEC-001', question: 'Database choice?', impact: 'architecture', required_before_stage: '06-architecture', status: 'resolved', resolution: 'PostgreSQL 16', resolved_by: 'Richardson', resolved_at: '2026-07-06T12:04:00.000Z' },
+ { decision_id: 'DEC-002', question: 'Require SSO for pilot?', impact: 'security', required_before_stage: '09-deployment', status: 'waived', resolved_by: 'Richardson', resolved_at: '2026-07-06T12:15:00.000Z' },
+ { decision_id: 'DEC-003', question: 'Cloud region?', impact: 'budget', required_before_stage: '09-deployment', status: 'pending' },
+ ],
+ }));
+ return projectRoot;
+}
+
+test('runs carry a pipelineRollup whose next-action text IS the CLI recommendation', async () => {
+ const projectRoot = seedProject();
+ const state = await buildFullState(projectRoot, { includeRegistry: false });
+ const run = state.runs.find((entry) => entry.runId === RUN_ID);
+ assert.ok(run, 'fixture run present in snapshot');
+ const rollup = run.pipelineRollup;
+ assert.ok(rollup, 'fully-parsed run gets a pipeline rollup even without a persisted pipeline-state.json');
+
+ // The rollup schema version + status flow through for #156 visibility.
+ assert.equal(rollup.schema_version, 1);
+ assert.equal(run.manifest.schema_version, 2);
+ assert.equal(rollup.status, 'IN_PROGRESS');
+
+ // No second brain: the sentence must be exactly what `pipeline status`
+ // would print for the same pipeline-state document.
+ const pipelineState = await readPipelineState(projectRoot, RUN_ID, { regenerateIfMissing: true });
+ assert.equal(rollup.next_action.text, recommendPipelineAction(pipelineState));
+ // Priority classification mirrors the same order: the BLOCKED testing stage
+ // with a scheduled retry classifies as 'retry' (what the CLI recommends).
+ assert.equal(rollup.next_action.kind, 'retry');
+ assert.equal(rollup.next_action.stage_id, '08-testing');
+ assert.equal(rollup.next_action.task_id, '005-testing');
+
+ // July signals (#215): context pressure + active goal loop with the BLE-4
+ // recommendation as the verdict (goal_evaluated emits `recommendation`).
+ assert.equal(rollup.context_pressure.total, 1);
+ assert.deepEqual(rollup.context_pressure.by_source, { memory_summary: 1 });
+ assert.equal(rollup.goal_loop.active, true);
+ assert.equal(rollup.goal_loop.iterations, 1);
+ assert.equal(rollup.goal_loop.last_verdict, 'RETRY');
+ assert.equal(rollup.goal_loop.criteria_met, 1);
+ assert.equal(rollup.goal_loop.criteria_total, 2);
+ assert.equal(rollup.checkpoints.before_saved, 1);
+
+ // The rollup survives into the client payload (the page renders from it).
+ const client = toClientState(state);
+ const clientRun = client.runs.find((entry) => entry.runId === RUN_ID);
+ assert.ok(clientRun.pipelineRollup, 'pipelineRollup reaches the client snapshot');
+ assert.equal(clientRun.pipelineRollup.next_action.text, rollup.next_action.text);
+});
+
+test('the next-action rollup flags itself stale when live events outrun the saved pipeline-state.json', async () => {
+ const projectRoot = seedProject();
+ // Operator ran `pipeline status` at 12:20; the run has emitted events since
+ // (12:31, 12:35:01, 12:40). The saved recommendation is behind live data.
+ await writePipelineState(projectRoot, RUN_ID, { generatedAt: '2026-07-06T12:20:00.000Z' });
+ const stale = (await buildFullState(projectRoot, { includeRegistry: false }))
+ .runs.find((entry) => entry.runId === RUN_ID).pipelineRollup;
+ assert.equal(stale.generated_at, '2026-07-06T12:20:00.000Z');
+ assert.equal(stale.stale, true, 'three events postdate the saved state');
+ assert.equal(stale.events_behind, 3);
+
+ // Re-saving AFTER the newest event makes it fresh again — no false alarm.
+ // (Writing the file changes the run-dir signature, forcing a re-parse, so
+ // the second snapshot reads the new state rather than the cached lite run.)
+ await writePipelineState(projectRoot, RUN_ID, { generatedAt: '2026-07-06T13:00:00.000Z' });
+ const fresh = (await buildFullState(projectRoot, { includeRegistry: false }))
+ .runs.find((entry) => entry.runId === RUN_ID).pipelineRollup;
+ assert.equal(fresh.stale, false);
+ assert.equal(fresh.events_behind, 0);
+});
+
+test('a pending approval outranks everything in the next-action classification', async () => {
+ const projectRoot = seedProject();
+ const runDir = join(projectRoot, '.rstack', 'runs', RUN_ID);
+ writeFileSync(join(runDir, 'approvals.json'), JSON.stringify([
+ { id: 'app-1', artifact: 'deploy-approval.md', stage_id: '09-deployment', status: 'PENDING' },
+ ]));
+ const state = await buildFullState(projectRoot, { includeRegistry: false });
+ const rollup = state.runs.find((entry) => entry.runId === RUN_ID).pipelineRollup;
+ assert.equal(rollup.next_action.kind, 'approval');
+ assert.equal(rollup.next_action.artifact, 'deploy-approval.md');
+ assert.match(rollup.next_action.text, /Resolve the pending approval for deploy-approval\.md/);
+ assert.equal(rollup.approval_blockers, 1);
+});
+
+test('index-served lite runs keep the persisted rollup and manifest schema_version', async () => {
+ const projectRoot = seedProject();
+ // Persist the rollup first (the operator flow: pipeline status --regenerate),
+ // THEN let the rollup index cache the run — writing afterwards would change
+ // the run-dir signature and force a re-parse, hiding the lite-run path.
+ await readPipelineState(projectRoot, RUN_ID, { regenerateIfMissing: true });
+ await buildFullState(projectRoot, { includeRegistry: false }); // parses + writes .rstack/index.json
+ const second = await buildFullState(projectRoot, { includeRegistry: false });
+ const run = second.runs.find((entry) => entry.runId === RUN_ID);
+ assert.equal(run.fromIndex, true, 'stalled fixture run is index-served on the second snapshot');
+ assert.ok(run.pipelineRollup, 'lite run reads the persisted pipeline-state.json');
+ assert.equal(run.pipelineRollup.schema_version, 1);
+ assert.equal(run.pipelineRollup.next_action.kind, 'retry');
+ assert.equal(run.manifest.schema_version, 2, 'manifest schema_version restored for lite runs (#156)');
+});
+
+test('a run without usable pipeline state gets pipelineRollup null, never a fabricated one', async () => {
+ const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-command-empty-'));
+ const runDir = join(projectRoot, '.rstack', 'runs', 'run-bare');
+ mkdirSync(runDir, { recursive: true });
+ writeFileSync(join(runDir, 'manifest.json'), JSON.stringify({ run_id: 'run-bare', goal: 'Bare run', status: 'IN_PROGRESS' }));
+ // Corrupt persisted rollup: readPipelineState tolerates it as fallback null,
+ // and the in-memory build still summarizes from the run artifacts — the
+ // snapshot must never throw either way.
+ writeFileSync(join(runDir, 'pipeline-state.json'), '{ truncated');
+ const state = await buildFullState(projectRoot, { includeRegistry: false });
+ const run = state.runs.find((entry) => entry.runId === 'run-bare');
+ assert.ok(run, 'bare run still in snapshot');
+ // Either an honest in-memory rollup (all-pending stages) or null — but the
+ // corrupt file must not leak garbage into next_action.
+ if (run.pipelineRollup) {
+ assert.equal(typeof run.pipelineRollup.next_action.text, 'string');
+ assert.equal(run.pipelineRollup.context_pressure.total, 0);
+ assert.equal(run.pipelineRollup.goal_loop.active, false);
+ }
+});
+
+test('command-center module renders next-action card, exec rollup and new attention signals', () => {
+ // The page module owns its injected DOM — ids the renderer targets.
+ for (const id of ['command-next-action-panel', 'command-next-action', 'command-exec-rollup', 'command-schema-version']) {
+ assert.ok(commandCenterScript.includes(id), `command-center module carries #${id}`);
+ }
+ // Next-action chip routes to the tab that holds the action.
+ assert.match(commandCenterScript, /nextActionChip/);
+ assert.match(commandCenterScript, /'approvals'/);
+ assert.match(commandCenterScript, /'alerts-guardrails'/);
+ // #215 attention rows: context pressure with the long-loop wording; goal
+ // loop with iteration + last verdict.
+ assert.match(commandCenterScript, /long-loop quality risk/);
+ assert.match(commandCenterScript, /Goal loop running — iteration/);
+ assert.match(commandCenterScript, /last_verdict/);
+ // Schema-version badge (#156) and honest empty states.
+ assert.match(commandCenterScript, /rollup v/);
+ assert.match(commandCenterScript, /No pipeline state recorded for this run/);
+ assert.match(commandCenterScript, /pipeline status --regenerate/);
+ assert.match(commandCenterScript, /No runs loaded yet/);
+ // Freshness honesty (#218 review): the hero states its OWN staleness rather
+ // than inheriting the page's global "updated" chip.
+ assert.match(commandCenterScript, /nextActionSourceHtml/);
+ assert.match(commandCenterScript, /From the last saved pipeline-state\.json/);
+ assert.match(commandCenterScript, /newer event/);
+});
+
+test('decisions module renders the resolved/waived Decision Log with who/when/impact', () => {
+ assert.ok(decisionsScript.includes('decisions-log-panel'), 'log panel injected by the module');
+ assert.match(decisionsScript, /Decision Log/);
+ assert.match(decisionsScript, /resolved.*waived|waived.*resolved/s);
+ assert.match(decisionsScript, /resolved_by/);
+ assert.match(decisionsScript, /resolved_at/);
+ assert.match(decisionsScript, /impact: /);
+ assert.match(decisionsScript, /openDrawerRow\(this\)/);
+ assert.match(decisionsScript, /No resolved decisions yet/);
+});
+
+test('wave additions keep the assembled bundle compiling and inline-script safe', () => {
+ const bundle = clientScript(3008);
+ assert.doesNotThrow(() => new Function(bundle), 'bundle with wave:command additions must compile');
+ assert.ok(!bundle.includes('