Command activity and evidence-backed Aikya workflow states in a native pane.

A native Claude Code mod that follows command activity and displays evidence-backed Aikya workflow states. The interface is in English and has no on-screen controls.
From your terminal, install directly from the hosted marketplace:
claude plugin install aikya-flow --marketplace jlema0590/aikya-mods
This installs only aikya-flow. To install the other mods, run their matching commands in the installation guide.
From the repository root:
bash mods/aikya-flow/install.sh
Or in Claude Code, from the public repository:
/plugin install aikya-flow --marketplace jlema0590/aikya-mods
Restart Claude Code. Flow opens its native pane automatically in interactive sessions without requesting keyboard focus. In a sufficiently wide fullscreen terminal, Claude places the pane beside its transcript. A narrow or non-fullscreen terminal may place it above the prompt or defer opening it; use a wider fullscreen terminal for the agreed side-by-side layout.
With Flow 0.1.1 and BoxBot 0.3.1 or later enabled, Flow stays at the top of the docked pane and the conveyor stays at the bottom. The shared frame uses the measured pane body height and adjusts on resize, in either hook order. Inline panes remain compact; if content exceeds the available height, Claude scrolls the whole pane. Flow and Usage have no dependency on each other. Flow also runs without BoxBot.
When started with claude in an IDE's integrated terminal, Flow uses Claude's terminal panel and events. See the integrated-terminal validation matrix for installation across projects, fullscreen requirements and per-IDE checks.
/aikya-flow
/aikya-flow show
/aikya-flow hide
/aikya-flow status
/aikya-flow still
/aikya-flow motion
/aikya-flow connect /absolute/path/to/execution-cache
/aikya-flow disconnect
status prints details locally without calling a model. hide closes the shared Flow pane; /aikya-boxbot hide hides only the conveyor section.
Flow 0.1.2 shows Explore, Plan, Implement, Validate, Security and Review as rounded cards with arrows. A card contains the phase name and its state. Panels with at least 32 usable columns and 45 usable rows use cards; smaller, unmeasured or inline panels use a compact diagram. The layout keeps the existing shared frame and bottom BoxBot section. Very small panes still use Claude's native scrolling.
State colors are gray for No evidence/Pending/Not applicable, cyan for Running, green for Completed, amber for Waiting/Blocked/Interrupted, and red for Failed. Native theme colors and visible state labels keep the diagram readable without color. The running symbol rotates every 250 ms. A confirmed completion/failure/block or handoff receives one 250 ms emphasis; first loads and duplicate reads do not replay confirmations. Cards stay fixed.
still disables visual movement while readings continue updating; motion resumes current movement without replaying past changes. Both commands are local and add no on-screen controls. Animation and the existing two-second periodic cache reads share one coordinated timer. Closing or hiding the pane stops its scheduled work.
The idle task placeholder and normal turn-ended activity banner are omitted. The header still shows current commands and relevant native activity, plus the evidence source. One applicable reason is shown compactly; /aikya-flow status includes every phase reason.
To update an existing installation after these changes are available, use /plugin → Marketplaces → aikya-mods → Update marketplace, or claude plugin marketplace update aikya-mods followed by claude plugin update aikya-flow@aikya-mods in your terminal. Restart after shell updates. See the complete update guide.
Commands and skills select a recipe. Bug fixes use Reproduce, Diagnose, Fix and Regression labels; read-only verification marks implementation as not applicable to that recipe. Native events show command activity, tools, permission waits and interruption. Tool results, prompts and permissions pass through unchanged. Prompt text, command arguments and tool outputs are not retained by the mod.
A finished tool or model turn never completes a semantic phase. Without an Aikya snapshot, applicable phases show No evidence. Flow does not calculate a percentage of work completed from phase counts.
To read semantic phase states, connect an existing Aikya execution cache. This is the cache root used by the compatible harness's AIKYA_EXECUTION_CACHE setting, not a project directory. The expected structure is:
<cache-root>/<sha256(canonical-project-root + NUL + session-id)>/
active.json # {"run_id":"..."}
<run-id>.json # schema=1 execution snapshot
The adapter reads the contract in Aikya's src/aikya/core/state.py. It validates project/session identity, the active execution, command, revision, and candidate generation. Completed validation, security and review phases require successful evidence and actors bound to the current candidate. Missing, invalid, superseded or mismatched records show no verified phase evidence. A snapshot does not authorize any action.
The compatible harness must already produce the cache. This mod does not instrument older harness versions or write execution state. A cache is refreshed after relevant native events and at most every two seconds while the pane is visible. Closing the pane cancels polling. The connection remains in memory only and is rebound after a session change; restart Claude Code to discard it.
claude plugin validate mods/aikya-flow --strict
claude plugin test mods/aikya-flow
Native tests validate event forwarding, startup behavior, platform element trees, cache identity, candidate evidence, timers and session isolation. They do not replace inspection of a particular terminal's final layout.
GENERATED BY AIKYA HARNESS
hooks/register.js 225 lines1import { createFlow, normalizeCommand, beginRun, observeSkill, endTurn, phaseRows, LABELS, SYMBOLS, clean } from '../lib/flow.js';
2import { acceptSnapshot, executionIdentity } from '../lib/snapshot.js';
3import { phaseView } from '../lib/view.js';
4import { FRAME_MS, motionFrame, transitionEmphasis } from '../lib/motion.js';
5
6const PANE = 'aikya-flow';
7function stop(state) { if (state.timer) state.timer.cancel(); state.timer = null; state.timerMs = null; }
8function nativeActive(state) { return state.flow.running && state.flow.status === 'Running' && !state.flow.activity.startsWith('Waiting for permission'); }
9function moving(state) { return !state.still && !state.motionBlocked && (nativeActive(state) || phaseRows(state.flow).some(row => row.status === 'running') || !!state.emphasis); }
10async function readJson($, path) {
11 const info = await $.fs.stat(path);
12 if (info.kind !== 'file' || info.isLink || info.size > 2000000) throw new Error('Invalid execution file');
13 const text = await $.fs.read(path);
14 if (typeof text !== 'string' || text.length > 2000000) throw new Error('Invalid execution file');
15 return JSON.parse(text);
16}
17async function refresh($, state) {
18 if (!state.cacheRoot || state.busy) return;
19 state.busy = true;
20 const flow = state.flow, epoch = flow.epoch, sourceEpoch = state.sourceEpoch, cacheRoot = state.cacheRoot;
21 const currentRead = () => flow === state.flow && epoch === flow.epoch && sourceEpoch === state.sourceEpoch;
22 try {
23 let context = state.context;
24 if (!context) {
25 const cwd = await $.session.cwd();
26 const info = await $.fs.stat(cwd, { resolve: true });
27 if (typeof info.realPath !== 'string') throw new Error('Project path unavailable');
28 context = { identity: await executionIdentity(info.realPath, await $.session.id()) };
29 if (!currentRead()) return;
30 state.context = context;
31 }
32 const folder = cacheRoot + '/' + context.identity;
33 const active = await readJson($, folder + '/active.json');
34 if (typeof active?.run_id !== 'string' || !/^[a-zA-Z0-9-]{1,80}$/.test(active.run_id)) throw new Error('Invalid execution id');
35 const raw = await readJson($, folder + '/' + active.run_id + '.json');
36 const current = await readJson($, folder + '/active.json');
37 if (!currentRead()) return;
38 const snapshot = current?.run_id === active.run_id ? acceptSnapshot(raw, { ...context, runId: active.run_id, command: flow.command,
39 minStartedAt: flow.startedAt, blockedRunId: flow.blockedRunId, previous: flow.snapshot }) : null;
40 const now = await $.clock.now();
41 if (!currentRead()) return;
42 const previous = flow.snapshot;
43 const changed = previous && snapshot && previous.phases.some(row => row.status !== snapshot.phases.find(item => item.id === row.id)?.status);
44 if (!snapshot || previous?.runId !== snapshot.runId || previous?.generation !== snapshot.generation) state.emphasis = null;
45 else if (changed) state.emphasis = state.still || state.motionBlocked ? null : { previous, current: snapshot, at: now, epoch };
46 flow.snapshot = snapshot;
47 if (snapshot && !flow.command) flow.command = snapshot.command;
48 } catch {
49 if (currentRead()) { flow.snapshot = null; state.emphasis = null; }
50 } finally {
51 state.busy = false;
52 sync($, state);
53 $.ui.invalidate('ui.render');
54 }
55}
56function sync($, state) {
57 if (state.emphasis?.epoch !== state.flow.epoch) state.emphasis = null;
58 const period = state.visible ? moving(state) ? FRAME_MS : state.cacheRoot ? 2000 : null : null;
59 if (state.timer && period === state.timerMs) return;
60 stop(state);
61 if (!period) return;
62 state.timerMs = period;
63 state.timer = $.clock.every(period, async () => {
64 const now = await $.clock.now();
65 if (!state.visible) return;
66 const animated = moving(state);
67 if (state.emphasis && now - state.emphasis.at >= FRAME_MS) state.emphasis = null;
68 if (animated) { state.frame = (state.frame + 1) % 4; $.ui.invalidate('ui.render'); }
69 state.pollElapsed += period;
70 if (state.cacheRoot && state.pollElapsed >= 2000) { state.pollElapsed = 0; await refresh($, state); }
71 sync($, state);
72 });
73}
74async function open($, state) {
75 const placement = await $.ui.open({ id: PANE, title: 'Aikya Mods', columns: 44, rows: 20, closeOnEscape: true });
76 state.visible = placement?.isPlaced !== false;
77 state.hidden = false;
78 sync($, state);
79}
80function status(flow) {
81 const rows = phaseRows(flow);
82 return ['AIKYA FLOW', ...(flow.command ? ['Command: /' + flow.command] : []),
83 'Native activity: ' + flow.status + (flow.activity ? ' · ' + flow.activity : ''),
84 'Source: ' + (flow.snapshot ? 'Aikya execution snapshot' : 'Native activity; phase evidence unavailable'),
85 ...rows.map(row => `${SYMBOLS[row.status]} ${row.label}: ${LABELS[row.status]}${row.reason ? ' — ' + row.reason : ''}`)].join('\n');
86}
87export function register(on) {
88 const state = { flow: createFlow(), visible: false, hidden: false, timer: null, cacheRoot: null, context: null, busy: false, sourceEpoch: 0, frame: 0, still: false, emphasis: null, motionBlocked: false, timerMs: null, pollElapsed: 0 };
89 on('session.start', async ($, e, next) => {
90 await $.command.register({ name: 'aikya-flow', description: 'Show command activity and evidence-backed workflow states', argumentHint: '[show|hide|status|still|motion|connect <cache-root>|disconnect]', immediate: true });
91 if (e.isInteractive && e.surface) await open($, state);
92 return next(e);
93 });
94 on('command.run', { command: 'aikya-flow' }, async ($, e) => {
95 const args = e.args.trim();
96 if (args === 'hide') { state.hidden = true; state.visible = false; stop(state); await $.ui.close({ id: PANE }); return {}; }
97 if (args === 'still' || args === 'motion') { state.still = args === 'still'; state.emphasis = null; sync($, state); $.ui.invalidate('ui.render'); return {}; }
98 if (args === 'disconnect') { state.sourceEpoch += 1; state.cacheRoot = null; state.context = null; state.flow.snapshot = null; state.emphasis = null; sync($, state); $.ui.invalidate('ui.render'); return {}; }
99 if (args.startsWith('connect ')) {
100 const path = args.slice(8).trim();
101 try {
102 const info = await $.fs.stat(path, { resolve: true });
103 if (info.kind !== 'directory' || typeof info.realPath !== 'string') return { text: 'The execution cache must be an existing local directory.' };
104 state.cacheRoot = info.realPath.replace(/\/$/, '');
105 state.sourceEpoch += 1;
106 state.context = null;
107 state.pollElapsed = 0; state.emphasis = null;
108 await refresh($, state);
109 sync($, state);
110 return { text: state.flow.snapshot ? 'Connected to the current Aikya execution snapshot.' : 'Cache connected. No matching execution snapshot is available yet.' };
111 } catch { return { text: 'Unable to read that execution cache directory.' }; }
112 }
113 if (args === 'status') { await refresh($, state); return { text: status(state.flow) }; }
114 if (args && args !== 'show') return { text: 'Usage: /aikya-flow [show|hide|status|still|motion|connect <cache-root>|disconnect]' };
115 await refresh($, state);
116 await open($, state);
117 return {};
118 });
119 on('command.run', async ($, e, next) => {
120 const command = normalizeCommand(e.command);
121 if (!command) return next(e);
122 beginRun(state.flow, command, await $.clock.now());
123 state.motionBlocked = false; state.emphasis = null;
124 $.ui.invalidate('ui.render');
125 const epoch = state.flow.epoch;
126 const result = await next(e);
127 if (epoch === state.flow.epoch) {
128 if (state.flow.pendingTurn) { state.flow.status = 'Command returned'; state.flow.activity = 'Waiting for native turn or execution evidence'; }
129 await refresh($, state);
130 $.ui.invalidate('ui.render');
131 }
132 return result;
133 });
134 on('skill.prompt', async ($, e, next) => {
135 const command = normalizeCommand(e.skill);
136 if (command) { observeSkill(state.flow, command, await $.clock.now()); $.ui.invalidate('ui.render'); }
137 return next(e);
138 });
139 on('turn.start', async ($, e, next) => {
140 if (!state.flow.pendingTurn) beginRun(state.flow, normalizeCommand(e.text.startsWith('/') ? e.text : ''), await $.clock.now());
141 state.motionBlocked = false; state.emphasis = null;
142 state.flow.pendingTurn = false;
143 state.flow.status = 'Running';
144 state.flow.running = true;
145 state.flow.turnId = e.turnId;
146 state.flow.activity = 'Thinking';
147 if (!state.hidden && !state.visible) await open($, state);
148 $.ui.invalidate('ui.render');
149 return next(e);
150 });
151 on('tool.call', async ($, e, next) => {
152 const epoch = state.flow.epoch;
153 const name = clean(e.tool);
154 if (state.flow.running) { state.flow.activity = 'Tool running: ' + name; state.flow.toolCount += 1; $.ui.invalidate('ui.render'); }
155 const result = await next(e);
156 if (epoch === state.flow.epoch && state.flow.running) {
157 state.flow.activity = (result.deny ? 'Denied: ' : result.isError ? 'Tool failed: ' : 'Tool finished: ') + name;
158 await refresh($, state);
159 $.ui.invalidate('ui.render');
160 }
161 return result;
162 });
163 on('tool.check', async ($, e, next) => {
164 const result = await next(e);
165 if (state.flow.running && result.decision === 'ask') { state.flow.activity = 'Waiting for permission: ' + clean(e.tool); $.ui.invalidate('ui.render'); }
166 return result;
167 });
168 on('turn.complete', async ($, e, next) => {
169 endTurn(state.flow, e);
170 if (!e.agentId && state.flow.turnId === e.turnId && ['Interrupted','Error','Refused'].includes(state.flow.status)) { state.motionBlocked = true; state.emphasis = null; }
171 if (!e.agentId) await refresh($, state);
172 $.ui.invalidate('ui.render');
173 return next(e);
174 });
175 on('ui.close', { id: PANE }, async ($, e, next) => { state.visible = false; state.hidden = true; stop(state); return next(e); });
176 on('session.end', async ($, e, next) => {
177 stop(state); state.visible = false; state.hidden = false; state.sourceEpoch += 1; state.flow = createFlow(); state.context = null; state.emphasis = null; state.motionBlocked = false; state.pollElapsed = 0;
178 return next(e);
179 });
180 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
181 if (e.requestId !== PANE) { state.visible = false; stop(state); return next(e); }
182 if (!state.hidden && !state.visible) { state.visible = true; sync($, state); }
183 sync($, state);
184 const now = await $.clock.now();
185 const { Box, Text } = $.ui.resolve(e);
186 const rows = phaseRows(state.flow);
187 const view = phaseView(rows, { bodyColumns: e.props.bodyColumns, bodyRows: e.props.scroll?.bodyRows, placement: e.props.placement });
188 const highlight = !state.still && !state.motionBlocked && state.emphasis
189 ? transitionEmphasis(state.emphasis.previous, state.emphasis.current, state.emphasis.at, now) : { phaseIds: [], connectorIds: [] };
190 const spinner = motionFrame({ nativeRunning: nativeActive(state), still: state.still || state.motionBlocked, frame: state.frame });
191 const content = [Text({ bold: true, children: ['AIKYA FLOW'] })];
192 if (state.flow.command) content.push(Text({ wrap: 'truncate-end', children: ['/' + state.flow.command] }));
193 if (state.flow.status !== 'Turn ended') content.push(Text({ color: 'cyan', wrap: 'truncate-end',
194 children: [(nativeActive(state) ? spinner.symbol + ' ' : '') + state.flow.status + (state.flow.activity ? ' · ' + state.flow.activity : '')] }));
195 content.push(Text({ dimColor: true, wrap: 'truncate-end', children: [state.flow.snapshot ? 'Source: Aikya execution snapshot' : 'Native activity · No phase evidence'] }));
196 const reason = rows.find(row => row.reason);
197 if (reason) content.push(Text({ dimColor: true, wrap: 'truncate-end', children: [reason.label + ': ' + reason.reason] }));
198 const diagram = [];
199 view.nodes.forEach((node, index) => {
200 const symbol = motionFrame({ phaseStatus: node.status, still: state.still || state.motionBlocked, frame: state.frame }).symbol;
201 const label = Text({ wrap: 'truncate-end', children: [node.label + (view.mode === 'compact' ? ' ' : '')] });
202 const statusText = Text({ color: node.color, bold: highlight.phaseIds.includes(node.id), wrap: 'truncate-end', children: [symbol + ' ' + node.stateLabel] });
203 diagram.push(Box({ key: 'phase-' + node.id, width: view.width, flexDirection: view.mode === 'cards' ? 'column' : 'row',
204 alignItems: view.mode === 'cards' ? 'center' : undefined, borderStyle: view.mode === 'cards' ? 'round' : undefined,
205 borderColor: node.color, flexShrink: 0, children: [label, statusText] }));
206 const nextNode = view.nodes[index + 1];
207 if (nextNode) diagram.push(Box({ key: 'connector-' + node.id, width: view.width, justifyContent: 'center', flexShrink: 0, children: [
208 Text({ color: node.status === 'completed' && nextNode.status === 'completed' ? 'success' : nextNode.status === 'running' ? 'cyan' : 'inactive',
209 bold: highlight.connectorIds.includes(node.id), children: ['↓'] }),
210 ] }));
211 });
212 content.push(Box({ key: 'phase-diagram', flexDirection: 'column', alignItems: 'center', children: diagram }));
213 if (e.surface === 'terminal' && e.props.placement === 'inline') content.push(Text({ dimColor: true, children: ['Use a wider fullscreen terminal for side-by-side panes.'] }));
214 const theirs = await next(e);
215 const bodyRows = e.props.scroll?.bodyRows;
216 const minHeight = e.props.placement === 'dock' && Number.isFinite(bodyRows) && bodyRows > 0 ? bodyRows : undefined;
217 return Box({ key: 'aikya-mods-pane', flexDirection: 'column', rowGap: 0, minHeight, justifyContent: 'space-between', children: [
218 Box({ key: 'aikya-flow-content', flexDirection: 'column', flexShrink: 0, children: content }),
219 // The original Pane body is empty. Its opaque host drawing cannot be
220 // nested under a sized Box; only other mods' own trees belong here.
221 Box({ key: 'aikya-mods-footer', flexDirection: 'column', flexShrink: 0, children: theirs?.type === 'engine' ? [] : [theirs] }),
222 ] });
223 });
224}
225lib/flow.js 57 lines1export const PHASES = ['explore', 'plan', 'implement', 'validate', 'security', 'review'];
2const READ_ONLY = new Set(['aikya-verify', 'aikya-tests', 'aikya-quality', 'aikya-tokens', 'aikya-skills-status', 'aikya-skills-plan', 'aikya-sdd-status', 'aikya-doctor', 'aikya-arc-review']);
3const SHORT = new Set(['feature', 'mobile-feature', 'bugfix', 'analyze', 'verify', 'tests', 'quality', 'tokens']);
4export const clean = value => typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 160) : '';
5export function normalizeCommand(value) {
6 if (typeof value !== 'string') return null;
7 const token = value.replace(/^\//, '').trim().split(/\s/)[0];
8 let name = token.split(':').pop();
9 if (SHORT.has(name) && (token.includes('aikya:') || !token.includes(':'))) name = 'aikya-' + name;
10 if (!/^aikya-[a-z0-9-]+$/.test(name) || ['aikya-flow', 'aikya-usage', 'aikya-boxbot'].includes(name)) return null;
11 return name;
12}
13export function recipe(command) {
14 const fix = command === 'aikya-bugfix';
15 const fixLabels = { explore: 'Reproduce', plan: 'Diagnose', implement: 'Fix', validate: 'Regression' };
16 return PHASES.map(id => ({ id, label: fix && fixLabels[id] ? fixLabels[id] : id[0].toUpperCase() + id.slice(1),
17 status: READ_ONLY.has(command) && id === 'implement' ? 'skipped' : 'unknown',
18 reason: READ_ONLY.has(command) && id === 'implement' ? 'Not applicable to this read-only recipe' : '' }));
19}
20export function createFlow() {
21 return { epoch: 0, command: null, status: 'Idle', activity: 'Waiting for a command', turnId: null, pendingTurn: false, running: false, startedAt: 0, snapshot: null, blockedRunId: null, toolCount: 0 };
22}
23export function beginRun(flow, command, now) {
24 flow.blockedRunId = flow.snapshot?.runId || flow.blockedRunId;
25 flow.epoch += 1;
26 flow.command = command;
27 flow.status = 'Running';
28 flow.activity = 'Command observed';
29 flow.turnId = null;
30 flow.pendingTurn = true;
31 flow.running = true;
32 flow.startedAt = now;
33 flow.snapshot = null;
34 flow.toolCount = 0;
35}
36export function observeSkill(flow, command, now) {
37 if (flow.running) {
38 if (flow.command !== command) flow.activity = 'Nested skill: /' + command;
39 return;
40 }
41 beginRun(flow, command, now);
42}
43export function endTurn(flow, event) {
44 if (event.agentId || flow.turnId !== event.turnId) return;
45 flow.running = false;
46 flow.pendingTurn = false;
47 flow.status = event.isAborted || event.reason === 'aborted' ? 'Interrupted' : event.reason === 'error' ? 'Error' : event.reason === 'refusal' ? 'Refused' : 'Turn ended';
48 flow.activity = flow.status === 'Turn ended' ? '' : 'Native turn ' + flow.status.toLowerCase();
49}
50export function phaseRows(flow) {
51 const rows = recipe(flow.command);
52 if (!flow.snapshot) return rows;
53 return rows.map(row => ({ ...row, ...flow.snapshot.phases.find(item => item.id === row.id), label: row.label }));
54}
55export const LABELS = { unknown: 'No evidence', pending: 'Pending', running: 'Running', waiting: 'Waiting', completed: 'Completed', failed: 'Failed', skipped: 'Not applicable', blocked: 'Blocked', interrupted: 'Interrupted' };
56export const SYMBOLS = { unknown: '?', pending: '○', running: '●', waiting: '◷', completed: '✓', failed: '×', skipped: '—', blocked: '!', interrupted: '■' };
57lib/snapshot.js 44 lines1import { PHASES, clean, normalizeCommand } from './flow.js';
2const STATES = new Set(['pending', 'running', 'waiting', 'completed', 'failed', 'skipped', 'blocked', 'interrupted']);
3const integer = value => Number.isSafeInteger(value) && value >= 0;
4export async function executionIdentity(root, sessionId) {
5 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(root + '\0' + sessionId));
6 return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
7}
8function hasEvidence(phase, items) {
9 return Array.isArray(items) && items.length > 0 && items.length <= 40 && items.every(item => {
10 if (!item || typeof item.check !== 'string' || !item.check.trim()) return false;
11 if (phase === 'review') return item.kind === 'review' && item.result === 'passed' && typeof item.reviewer === 'string' && item.reviewer.trim() && /^[a-f0-9]{64}$/.test(item.candidate);
12 if (phase === 'validate' || phase === 'security') return item.kind === 'process' ? item.exit_code === 0 : item.kind === 'inspection' && item.result === 'passed';
13 return item.result === 'recorded' || item.result === 'passed';
14 });
15}
16export function acceptSnapshot(raw, context) {
17 if (!raw || raw.schema !== 1 || raw.identity !== context.identity || typeof raw.run_id !== 'string' || !/^[a-zA-Z0-9-]{1,80}$/.test(raw.run_id)) return null;
18 if ((context.runId && raw.run_id !== context.runId) || raw.run_id === context.blockedRunId) return null;
19 if (!integer(raw.revision) || !integer(raw.candidate_generation) || !STATES.has(raw.status)) return null;
20 if (!Number.isFinite(raw.started_at) || !Number.isFinite(raw.updated_at) || raw.updated_at < raw.started_at) return null;
21 if (context.minStartedAt && raw.started_at * 1000 < context.minStartedAt) return null;
22 const command = normalizeCommand(raw.command);
23 if (!command || (context.command && command !== context.command)) return null;
24 const previous = context.previous;
25 if (previous?.runId === raw.run_id && (raw.revision < previous.revision || raw.candidate_generation < previous.generation)) return null;
26 if (!raw.phases || PHASES.some(name => !raw.phases[name])) return null;
27 const phases = [];
28 for (const id of PHASES) {
29 const phase = raw.phases[id];
30 if (!phase || !STATES.has(phase.status) || !Array.isArray(phase.evidence)) return null;
31 if (['skipped', 'blocked', 'failed', 'waiting', 'interrupted'].includes(phase.status) && !clean(phase.reason)) return null;
32 if (phase.status === 'completed') {
33 if (!hasEvidence(id, phase.evidence)) return null;
34 if (['validate', 'security', 'review'].includes(id)) {
35 const actors = Object.values(phase.actors || {}).filter(actor => actor?.candidate_generation === raw.candidate_generation);
36 if (!actors.length || actors.some(actor => !['completed', 'skipped'].includes(actor.status))) return null;
37 }
38 }
39 phases.push({ id, status: phase.status, reason: clean(phase.reason) });
40 }
41 if (raw.status === 'completed' && phases.some(phase => !['completed', 'skipped'].includes(phase.status))) return null;
42 return { runId: raw.run_id, command, revision: raw.revision, generation: raw.candidate_generation, status: raw.status, activity: clean(raw.activity), phases };
43}
44lib/view.js 9 lines1import { LABELS, SYMBOLS } from './flow.js';
2
3const COLORS = { unknown: 'inactive', pending: 'inactive', running: 'cyan', waiting: 'warning', completed: 'success', failed: 'error', skipped: 'inactive', blocked: 'warning', interrupted: 'warning' };
4export function phaseView(rows, { bodyColumns, bodyRows, placement } = {}) {
5 const columns = Number.isFinite(bodyColumns) && bodyColumns > 0 ? bodyColumns : 38;
6 return { mode: placement === 'dock' && columns >= 32 && bodyRows >= 45 ? 'cards' : 'compact',
7 width: Math.min(40, columns), nodes: rows.map(row => ({ ...row, symbol: SYMBOLS[row.status], stateLabel: LABELS[row.status], color: COLORS[row.status] })) };
8}
9lib/motion.js 22 lines1import { SYMBOLS } from './flow.js';
2
3export const FRAME_MS = 250;
4const FRAMES = ['◐', '◓', '◑', '◒'];
5export function motionFrame({ phaseStatus, nativeRunning = false, still = false, frame = 0 }) {
6 const running = phaseStatus === undefined ? nativeRunning : phaseStatus === 'running';
7 const animate = running && !still;
8 return { symbol: animate ? FRAMES[frame % FRAMES.length] : SYMBOLS[phaseStatus] || '●', animate };
9}
10export function transitionEmphasis(previous, current, changedAt, now) {
11 const result = { phaseIds: [], connectorIds: [] };
12 if (!previous || !current || previous.runId !== current.runId || previous.generation !== current.generation || now < changedAt || now - changedAt >= FRAME_MS) return result;
13 current.phases.forEach((phase, index) => {
14 const old = previous.phases.find(row => row.id === phase.id);
15 if (old && old.status !== phase.status && ['completed','failed','blocked'].includes(phase.status)) result.phaseIds.push(phase.id);
16 const next = current.phases[index + 1];
17 const oldNext = next && previous.phases.find(row => row.id === next.id);
18 if (phase.status === 'completed' && next?.status === 'running' && oldNext && oldNext.status !== 'running') result.connectorIds.push(phase.id);
19 });
20 return result;
21}
22