SLOPSHOPPER

aikya-flow

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

newpaneguardcommandprompttimer
v0.1.2MITupdated 2026-10-26jlema0590/aikya-mods/mods/aikya-flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aikya-flow
│ ┃ Aikya Mods ✕ › fix the failing auth test and add an audit log call │ ┃ AIKYA FLOW │ ┃ Idle · Waiting for a command ⏺ Read(src/auth.ts) │ ┃ Native activity · No phase evidence ⎿ Read 6 lines │ ┃ Explore ? No evidence ⏺ Update(src/auth.ts) │ ┃ ↓ ⎿ Added 2 lines, removed 1 line │ ┃ Plan ? No evidence ⏺ Bash(bun test) │ ┃ ↓ ⎿ 3 pass, 1 fail │ ┃ Implement ? No evidence │ ┃ ↓ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Validate ? No evidence │ ┃ ↓ ✻ Worked for 42s · done 4:20 PM │ ┃ Security ? No evidence │ ┃ ↓ › /aikya-flow │ ┃ Review ? No evidence │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Aikya Mods
AIKYA FLOW Idle · Waiting for a command Native activity · No phase evidence Explore ? No evidence ↓ Plan ? No evidence ↓ Implement ? No evidence ↓ Validate ? No evidence ↓ Security ? No evidence ↓ Review ? No evidence
README

Aikya Flow

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.

Install

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.

Commands

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.

Phase diagram and motion

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.

Activity and evidence

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.

Verify

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

Source 5 files
hooks/register.js 225 lines
1import { 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}
225
lib/flow.js 57 lines
1export 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: '■' };
57
lib/snapshot.js 44 lines
1import { 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}
44
lib/view.js 9 lines
1import { 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}
9
lib/motion.js 22 lines
1import { 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