wavework 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -2
  4. package/bin/wavework.mjs +7 -0
  5. package/examples/shipir.wavework.json +54 -0
  6. package/package.json +35 -4
  7. package/src/cli/args.mjs +28 -0
  8. package/src/cli/dashboard.mjs +41 -0
  9. package/src/cli/hooks.mjs +109 -0
  10. package/src/cli/init.mjs +95 -0
  11. package/src/cli/safe-path.mjs +63 -0
  12. package/src/cli/sync.mjs +269 -0
  13. package/src/cli/update.mjs +24 -0
  14. package/src/cli.mjs +78 -0
  15. package/src/config.mjs +313 -0
  16. package/src/context.mjs +133 -0
  17. package/src/render.mjs +148 -0
  18. package/src/targets.mjs +100 -0
  19. package/templates/agents/implementer.md +16 -0
  20. package/templates/dashboard/README.md +96 -0
  21. package/templates/dashboard/hooks/session-start.mjs +305 -0
  22. package/templates/dashboard/hooks/subagent-stop.mjs +129 -0
  23. package/templates/dashboard/lib/ci.mjs +52 -0
  24. package/templates/dashboard/lib/derive.mjs +105 -0
  25. package/templates/dashboard/lib/settings.mjs +34 -0
  26. package/templates/dashboard/lib/tokens.mjs +21 -0
  27. package/templates/dashboard/pricing.json +11 -0
  28. package/templates/dashboard/public/app.css +134 -0
  29. package/templates/dashboard/public/app.js +260 -0
  30. package/templates/dashboard/public/default-theme.css +86 -0
  31. package/templates/dashboard/public/format.mjs +138 -0
  32. package/templates/dashboard/public/index.html +20 -0
  33. package/templates/dashboard/record.mjs +144 -0
  34. package/templates/dashboard/server.mjs +254 -0
  35. package/templates/dashboard-config.json +1 -0
  36. package/templates/scripts/red-green-check.sh +287 -0
  37. package/templates/scripts/red-green-check.test.sh +151 -0
  38. package/templates/settings/hooks.json +31 -0
  39. package/templates/skills/next-task/SKILL.md +77 -0
  40. package/templates/skills/next-task-batch/SKILL.md +27 -0
  41. package/templates/skills/next-task-beads/SKILL.md +29 -0
  42. package/templates/skills/next-task-branch/SKILL.md +19 -0
  43. package/templates/skills/next-task-code/SKILL.md +22 -0
  44. package/templates/skills/next-task-deps/SKILL.md +31 -0
  45. package/templates/skills/next-task-e2e/SKILL.md +35 -0
  46. package/templates/skills/next-task-findings/SKILL.md +65 -0
  47. package/templates/skills/next-task-finish/SKILL.md +43 -0
  48. package/templates/skills/next-task-implement/SKILL.md +34 -0
  49. package/templates/skills/next-task-plan/SKILL.md +52 -0
  50. package/templates/skills/next-task-review/SKILL.md +61 -0
  51. package/templates/skills/next-task-test/SKILL.md +30 -0
  52. package/templates/skills/next-task-write-tests/SKILL.md +34 -0
  53. package/templates/skills/text-to-task/SKILL.md +78 -0
  54. package/templates/skills/watch-ci/SKILL.md +42 -0
@@ -0,0 +1,134 @@
1
+ /* Pipeline dashboard. Tokens only (/default-theme.css, optionally overridden by the project tokens at /tokens.css); no raw colors, sizes, radii or durations. */
2
+ *, *::before, *::after { box-sizing: border-box; }
3
+
4
+ html { background: var(--color-bg); }
5
+ body {
6
+ margin: 0;
7
+ background: var(--color-bg);
8
+ color: var(--color-text);
9
+ font-family: var(--font-sans);
10
+ font-size: var(--text-body-size);
11
+ line-height: var(--text-body-leading);
12
+ letter-spacing: var(--text-body-tracking);
13
+ -webkit-font-smoothing: antialiased;
14
+ padding-left: var(--safe-left);
15
+ padding-right: var(--safe-right);
16
+ padding-bottom: var(--safe-bottom);
17
+ }
18
+
19
+ a { color: var(--color-accent-text); text-decoration: none; }
20
+ a:hover { text-decoration: underline; }
21
+ :focus-visible { outline: var(--border-width-thick) solid var(--color-focus); outline-offset: var(--focus-offset); border-radius: var(--radius-sm); }
22
+
23
+ .topbar {
24
+ position: sticky; top: 0; z-index: var(--z-sticky);
25
+ display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
26
+ min-height: var(--shell-bar-height);
27
+ padding: 0 var(--space-5);
28
+ background: var(--color-material);
29
+ -webkit-backdrop-filter: var(--blur-material);
30
+ backdrop-filter: var(--blur-material);
31
+ border-bottom: var(--border-width) solid var(--color-border);
32
+ }
33
+ .brand { font-size: var(--text-title-size); font-weight: var(--weight-semibold); letter-spacing: var(--text-title-tracking); color: var(--color-text); }
34
+ .brand:hover { text-decoration: none; }
35
+
36
+ .live { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-small-size); color: var(--color-text-muted); }
37
+ .live::before { content: ''; width: var(--size-dot); height: var(--size-dot); border-radius: var(--radius-pill); background: var(--color-text-subtle); }
38
+ .live[data-state='live']::before { background: var(--color-ok-solid); }
39
+ .live[data-state='offline']::before { background: var(--color-warn-solid); }
40
+
41
+ .page { max-width: var(--page-width-wide); margin: 0 auto; padding: var(--space-6) var(--space-5) var(--space-8); display: grid; gap: var(--space-6); min-width: 0; }
42
+
43
+ h1, h2, h3, p { margin: 0; overflow-wrap: break-word; min-width: 0; }
44
+ h1 { font-size: var(--text-heading-size); line-height: var(--text-heading-leading); letter-spacing: var(--text-heading-tracking); font-weight: var(--weight-semibold); }
45
+ h2 { font-size: var(--text-title-size); line-height: var(--text-title-leading); letter-spacing: var(--text-title-tracking); font-weight: var(--weight-semibold); }
46
+ .muted { color: var(--color-text-muted); }
47
+ .subtle { color: var(--color-text-subtle); }
48
+ .small { font-size: var(--text-small-size); line-height: var(--text-small-leading); }
49
+ .mono { font-family: var(--font-mono); font-size: var(--text-small-size); overflow-wrap: anywhere; }
50
+ .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
51
+
52
+ .head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3) var(--space-5); }
53
+ .back { display: inline-flex; align-items: center; min-height: var(--control-height); padding: 0 var(--space-3); margin-left: calc(var(--space-3) * -1); border-radius: var(--radius-sm); transition: background-color var(--duration-fast) var(--ease-out), transform var(--duration-press) var(--ease-out); }
54
+ .back:hover { background: var(--color-surface-hover); text-decoration: none; }
55
+ .back:active { transform: scale(var(--press-scale)); }
56
+
57
+ .card { container-type: inline-size; background: var(--color-surface); border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: var(--space-5); display: grid; gap: var(--space-4); align-content: start; min-width: 0; }
58
+ .card > h2 { margin-bottom: calc(var(--space-1) * -1); }
59
+ .stack { display: grid; gap: var(--space-6); min-width: 0; }
60
+ .grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--content-narrow) / 2), 1fr)); gap: var(--space-5); }
61
+
62
+ /* Badges: soft tint + dot + text, never color only */
63
+ .badge { display: inline-flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-3); border-radius: var(--radius-pill); font-size: var(--text-caption-size); line-height: var(--text-caption-leading); font-weight: var(--weight-medium); white-space: nowrap; background: var(--color-surface-sunken); color: var(--color-text-muted); }
64
+ .badge::before { content: ''; width: var(--size-dot); height: var(--size-dot); border-radius: var(--radius-pill); background: var(--color-text-subtle); }
65
+ .badge[data-tone='ok'] { background: var(--color-ok-soft); color: var(--color-ok); }
66
+ .badge[data-tone='ok']::before { background: var(--color-ok-solid); }
67
+ .badge[data-tone='warn'] { background: var(--color-warn-soft); color: var(--color-warn); }
68
+ .badge[data-tone='warn']::before { background: var(--color-warn-solid); }
69
+ .badge[data-tone='bad'] { background: var(--color-bad-soft); color: var(--color-bad); }
70
+ .badge[data-tone='bad']::before { background: var(--color-bad-solid); }
71
+ .badge[data-tone='info'] { background: var(--color-info-soft); color: var(--color-info); }
72
+ .badge[data-tone='info']::before { background: var(--color-info-solid); }
73
+ .badge[data-tone='info'][data-pulse]::before { animation: pulse var(--duration-pulse) var(--ease-out) infinite; }
74
+ @keyframes pulse { 50% { opacity: var(--opacity-ghost); } }
75
+
76
+ /* Tables: fit the screen (wrap, never scroll sideways), hairline row separators */
77
+ table { width: 100%; border-collapse: collapse; }
78
+ th, td { padding: var(--space-3) var(--space-3); text-align: left; vertical-align: top; overflow-wrap: anywhere; border-bottom: var(--border-width) solid var(--color-border); }
79
+ th { font-size: var(--text-caption-size); letter-spacing: var(--text-caption-tracking); font-weight: var(--weight-medium); color: var(--color-text-muted); }
80
+ tbody tr:last-child td { border-bottom: 0; }
81
+ th.num { text-align: right; }
82
+ .rows tbody tr { transition: background-color var(--duration-fast) var(--ease-out); }
83
+ .rows tbody tr:hover { background: var(--color-surface-hover); }
84
+ .rows .link { display: block; color: inherit; }
85
+ .rows .link:hover { text-decoration: none; }
86
+ tr.nested td:first-child { padding-left: var(--space-6); }
87
+ tr[data-clickable] { cursor: pointer; }
88
+ tr[data-clickable]:active { background: var(--color-surface-sunken); }
89
+
90
+ /* Summary stats in the run header */
91
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--size-file-col-date)), 1fr)); gap: var(--space-5); }
92
+ .stat { display: grid; gap: var(--space-1); min-width: 0; }
93
+ .stat .label { font-size: var(--text-caption-size); color: var(--color-text-muted); letter-spacing: var(--text-caption-tracking); }
94
+ .stat .value { font-size: var(--text-title-size); font-weight: var(--weight-semibold); letter-spacing: var(--text-title-tracking); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
95
+
96
+ /* Step timeline */
97
+ .steps { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
98
+ .steps li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); }
99
+ .steps .dot { width: var(--size-dot); height: var(--size-dot); border-radius: var(--radius-pill); background: var(--color-ok-solid); }
100
+ .steps li[data-open] .dot { background: var(--color-info-solid); }
101
+
102
+ .list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
103
+ .list li { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3); align-items: center; }
104
+ .link-btn { display: inline-flex; align-items: center; min-height: var(--control-height); padding: 0 var(--space-4); border-radius: var(--radius-sm); background: var(--color-accent-soft); font-weight: var(--weight-medium); transition: background-color var(--duration-fast) var(--ease-out), transform var(--duration-press) var(--ease-out); }
105
+ .link-btn:hover { text-decoration: none; background: var(--color-surface-hover); }
106
+ .link-btn:active { transform: scale(var(--press-scale)); }
107
+
108
+ .state { display: grid; gap: var(--space-3); justify-items: start; padding: var(--space-6); background: var(--color-surface-sunken); border-radius: var(--radius-md); }
109
+ .state[data-tone='bad'] { background: var(--color-bad-soft); color: var(--color-bad); }
110
+ .btn { min-height: var(--control-height); padding: 0 var(--space-5); border: 0; border-radius: var(--radius-sm); background: var(--color-accent); color: var(--color-on-accent); font: inherit; font-weight: var(--weight-medium); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-out), transform var(--duration-press) var(--ease-out); }
111
+ .btn:hover { background: var(--color-accent-hover); }
112
+ .btn:active { transform: scale(var(--press-scale)); }
113
+
114
+ .skeleton { height: var(--space-7); border-radius: var(--radius-md); background: var(--color-surface-sunken); animation: pulse var(--duration-pulse) var(--ease-out) infinite; }
115
+
116
+ /* Narrow cards (phones): tables become stacked rows, header text moves into each cell.
117
+ Tokens have no breakpoint token, so this single documented threshold is the only raw size here. */
118
+ @container (max-width: 40rem) {
119
+ thead { display: none; }
120
+ table, tbody, tr, td { display: block; }
121
+ tr { padding: var(--space-3) 0; border-bottom: var(--border-width) solid var(--color-border); }
122
+ tbody tr:last-child { border-bottom: 0; }
123
+ td { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); border-bottom: 0; padding: var(--space-1) var(--space-3); text-align: right; }
124
+ td::before { content: attr(data-label); color: var(--color-text-muted); font-size: var(--text-caption-size); text-align: left; flex: none; }
125
+ td:first-child { justify-content: flex-start; text-align: left; }
126
+ td:first-child::before { content: none; }
127
+ tr.nested td:first-child { padding-left: var(--space-6); }
128
+ }
129
+
130
+ @media (prefers-reduced-motion: reduce) {
131
+ *, *::before, *::after { animation-duration: var(--duration-spin-calm) !important; transition-duration: var(--duration-press) !important; }
132
+ .badge[data-pulse]::before, .skeleton { animation: none !important; }
133
+ .back:active, .btn:active, .link-btn:active { transform: none; }
134
+ }
@@ -0,0 +1,260 @@
1
+ // Pipeline dashboard UI. Plain ES modules, no build. All data is rendered with textContent (never innerHTML).
2
+ import {
3
+ ciBadge, findingsOf, formatCost, formatDuration, formatTokens, parseRoute, relativeTime,
4
+ resultBadge, reviewRows, reviewsByBead, safeHref, sortRuns, statusBadge, stepLabel, tokensOf, formatStarted,
5
+ } from './format.mjs';
6
+
7
+ const app = document.getElementById('app');
8
+
9
+ /** Replaces the page content and puts keyboard focus back on the same run link (by data-run) if it had it. */
10
+ function show(...nodes) {
11
+ const run = document.activeElement?.closest?.('[data-run]')?.dataset.run;
12
+ const onBack = document.activeElement?.classList?.contains('back');
13
+ app.replaceChildren(...nodes);
14
+ if (run) app.querySelector(`[data-run="${CSS.escape(run)}"] a.link`)?.focus({ preventScroll: true });
15
+ else if (onBack) app.querySelector('.back')?.focus({ preventScroll: true });
16
+ }
17
+ const liveEl = document.getElementById('live');
18
+ let project = '';
19
+ const appTitle = () => (project ? `Pipeline dashboard · ${project}` : 'Pipeline dashboard');
20
+ // The project name (repo directory) comes from the server; the page works without it.
21
+ fetch('/api/info').then((r) => r.json()).then((info) => {
22
+ if (typeof info?.project !== 'string') return;
23
+ project = info.project;
24
+ const brand = document.querySelector('.brand');
25
+ if (brand) brand.textContent = `Pipeline · ${project}`;
26
+ document.title = appTitle();
27
+ }).catch(() => {});
28
+
29
+ /** Tiny element builder: h('td', {class: 'num'}, 'text', childNode). Strings become text nodes. */
30
+ function h(tag, attrs, ...kids) {
31
+ const el = document.createElement(tag);
32
+ for (const [k, v] of Object.entries(attrs ?? {})) if (v != null && v !== false) el.setAttribute(k, v === true ? '' : v);
33
+ for (const k of kids.flat()) if (k != null && k !== false) el.append(k instanceof Node ? k : document.createTextNode(String(k)));
34
+ return el;
35
+ }
36
+ /** Copies each header text onto its cells (data-label) so CSS can stack rows on narrow cards. */
37
+ function labelCells(tbl) {
38
+ const heads = [...tbl.querySelectorAll('thead th')].map((th) => th.textContent);
39
+ for (const tr of tbl.querySelectorAll('tbody tr')) [...tr.children].forEach((td, i) => td.setAttribute('data-label', heads[i] ?? ''));
40
+ return tbl;
41
+ }
42
+ const badge = (b, pulse) => h('span', { class: 'badge', 'data-tone': b.tone, 'data-pulse': pulse }, b.label);
43
+ const dash = '—';
44
+ const runTitle = (r) => r.branch ?? (r.units?.length ? r.units.join(', ') : r.id);
45
+
46
+ async function getJson(url) {
47
+ const res = await fetch(url, { headers: { accept: 'application/json' } });
48
+ if (!res.ok) throw new Error(res.status === 404 ? 'This run does not exist (any more).' : `Server answered ${res.status}.`);
49
+ return res.json();
50
+ }
51
+
52
+ const state = { runs: null, listError: null, details: new Map(), ci: new Map(), route: parseRoute(location.hash) };
53
+
54
+ /* ---------- Run list ---------- */
55
+ function liveDuration(run) {
56
+ if (run.status === 'running' && run.started) return Date.now() - Date.parse(run.started);
57
+ return run.totals?.durationMs ?? null;
58
+ }
59
+
60
+ function runRow(r, nested) {
61
+ const href = `#/runs/${encodeURIComponent(r.id)}`;
62
+ const link = (kids) => h('a', { class: 'link', href }, kids);
63
+ const ci = ciBadge(state.ci.get(r.id) ?? null);
64
+ const tr = h('tr', { 'data-testid': 'run-row', 'data-run': r.id, class: nested ? 'nested' : null, 'data-clickable': true },
65
+ h('td', null, badge(statusBadge(r.status), r.status === 'running')),
66
+ h('td', null, link([h('strong', null, runTitle(r)), h('div', { class: 'small muted' }, `${r.kind ?? dash} · ${(r.units ?? []).join(', ') || dash}`)])),
67
+ h('td', { class: 'small muted', title: r.started ? relativeTime(r.started) : null }, formatStarted(r.started)),
68
+ h('td', { class: 'num' }, formatDuration(liveDuration(r))),
69
+ h('td', { class: 'num', title: 'Estimated cost' }, formatCost(r.totals?.cost ?? null)),
70
+ h('td', null, ci ? badge(ci) : h('span', { class: 'subtle' }, dash)));
71
+ tr.addEventListener('click', (e) => { if (!e.target.closest('a')) location.hash = href; });
72
+ return tr;
73
+ }
74
+
75
+ function renderList() {
76
+ if (state.listError) return errorState(state.listError, loadList);
77
+ if (!state.runs) return show(h('h1', null, 'Pipeline runs'), h('div', { class: 'skeleton' }), h('div', { class: 'skeleton' }));
78
+ const runs = sortRuns(state.runs);
79
+ const body = h('tbody');
80
+ for (const r of runs) {
81
+ body.append(runRow(r, false));
82
+ for (const c of sortRuns(r.children ?? [])) body.append(runRow(c, true));
83
+ }
84
+ const content = runs.length === 0
85
+ ? h('div', { class: 'state' }, h('h2', null, 'No runs yet'), h('p', { class: 'muted' }, 'Runs appear here as soon as a /next-task pipeline records its first event.'))
86
+ : h('div', { class: 'card' }, labelCells(h('table', { class: 'rows' },
87
+ h('thead', null, h('tr', null, ['Status', 'Run', 'Started'].map((t) => h('th', null, t)),
88
+ h('th', { class: 'num' }, 'Duration'), h('th', { class: 'num' }, 'Est. cost'), h('th', null, 'CI'))),
89
+ body)));
90
+ show(
91
+ h('div', { class: 'head' }, h('h1', null, 'Pipeline runs'), h('p', { class: 'muted small' }, 'Costs are estimates from token usage.')),
92
+ content);
93
+ document.title = appTitle();
94
+ }
95
+
96
+ async function loadList() {
97
+ try {
98
+ state.runs = await getJson('/api/runs');
99
+ state.listError = null;
100
+ } catch (e) { state.listError = e.message; }
101
+ if (state.route.view === 'list') renderList();
102
+ loadCi();
103
+ }
104
+
105
+ /** The list endpoint has no CI info; fetch details of runs with a pushed SHA once, then rely on the server cache. */
106
+ async function loadCi() {
107
+ const flat = (state.runs ?? []).flatMap((r) => [r, ...(r.children ?? [])]);
108
+ await Promise.all(flat.map(async (r) => {
109
+ if (state.ci.has(r.id) && state.ci.get(r.id)?.state === 'success') return;
110
+ try {
111
+ const d = await getJson(`/api/runs/${encodeURIComponent(r.id)}`);
112
+ state.ci.set(r.id, d.ci ?? null);
113
+ } catch { /* leave the badge out */ }
114
+ }));
115
+ if (state.route.view === 'list') renderList();
116
+ }
117
+
118
+ /* ---------- Run detail ---------- */
119
+ function stat(label, value, title, live) {
120
+ return h('div', { class: 'stat' }, h('span', { class: 'label' }, label), h('span', { class: 'value', title, 'data-live': live }, value));
121
+ }
122
+
123
+ function section(title, ...kids) { return h('section', { class: 'card' }, h('h2', null, title), ...kids); }
124
+ const empty = (t) => h('p', { class: 'muted' }, t);
125
+
126
+ function table(heads, rows, testid) {
127
+ return labelCells(h('table', { class: 'rows', 'data-testid': testid },
128
+ h('thead', null, h('tr', null, heads.map(([t, num]) => h('th', { class: num ? 'num' : null }, t)))), h('tbody', null, rows)));
129
+ }
130
+
131
+ function renderDetail(id) {
132
+ const d = state.details.get(id);
133
+ if (d?.error) return errorState(d.error, () => loadDetail(id));
134
+ if (!d) return show(backLink(), h('div', { class: 'skeleton' }), h('div', { class: 'skeleton' }));
135
+ const { run, events, timeline: t, ci } = d;
136
+ const members = (state.runs ?? []).find((r) => r.id === run.id)?.children ?? [];
137
+ const reviews = reviewsByBead(events);
138
+ const findings = findingsOf(events);
139
+ const ciB = ciBadge(ci);
140
+ const ciRuns = (ci?.runs ?? []);
141
+
142
+ const children = t.children.map((c) => {
143
+ const rv = reviews.get(c.bead);
144
+ return h('tr', { 'data-testid': 'child-row' },
145
+ h('td', { class: 'mono' }, c.bead), h('td', null, c.model ?? dash), h('td', { class: 'num' }, c.wave ?? dash),
146
+ h('td', { class: 'num' }, formatDuration(c.durationMs)),
147
+ h('td', { class: 'num', 'data-col': 'tokens' }, formatTokens(tokensOf({ ...c.agents, agents: c.agents?.count }))),
148
+ h('td', { class: 'num', 'data-col': 'cost' }, formatCost(c.agents?.cost ?? null)),
149
+ h('td', null, c.result ? badge(resultBadge(c.result)) : (run.status === 'running' ? badge({ label: 'Working', tone: 'info' }, true) : dash)),
150
+ h('td', null, rv ? `${rv.verdict ?? dash}${rv.round ? ` · round ${rv.round}` : ''}` : dash),
151
+ h('td', { class: 'small' }, c.redBeforeGreen ?? dash));
152
+ });
153
+
154
+ const steps = t.steps.map((s) => h('li', { 'data-open': s.end ? null : true },
155
+ h('span', { class: 'dot' }),
156
+ h('span', null, stepLabel(s), s.sha ? h('span', { class: 'mono muted' }, ` ${s.sha.slice(0, 7)}`) : null),
157
+ h('span', { class: 'num muted' }, s.end ? formatDuration(s.durationMs) : 'running')));
158
+
159
+ const reviewTr = reviewRows(events).map((r) => h('tr', null, h('td', { class: 'mono' }, r.bead ?? dash), h('td', { class: 'num' }, r.round ?? dash), h('td', null, r.verdict ?? dash), h('td', { class: 'num' }, r.mustFix)));
160
+ const testRows = t.tests.map((x) => h('tr', null,
161
+ h('td', null, x.scope ?? dash), h('td', { class: 'num' }, x.passed),
162
+ h('td', { class: 'num' }, x.failed > 0 ? badge({ label: String(x.failed), tone: 'bad' }) : x.failed), h('td', { class: 'num' }, x.duration != null ? `${x.duration}s` : dash)));
163
+
164
+ const ciLinks = ciRuns.map((r) => {
165
+ const href = safeHref(r.url);
166
+ return h('li', null, href ? h('a', { class: 'link-btn', href, target: '_blank', rel: 'noopener noreferrer' }, r.name || 'Workflow run') : h('span', null, r.name || 'Workflow run'),
167
+ h('span', { class: 'small muted' }, r.conclusion ?? r.status));
168
+ });
169
+
170
+ document.title = `${runTitle(run)} · ${appTitle()}`;
171
+ show(
172
+ backLink(),
173
+ h('div', { 'data-testid': 'run-detail', class: 'stack' },
174
+ h('div', { class: 'head' }, h('h1', null, runTitle(run)), badge(statusBadge(run.status), run.status === 'running')),
175
+ h('section', { class: 'card' },
176
+ h('div', { class: 'stats' },
177
+ stat('Kind', run.kind ?? dash), stat('Units', (run.units ?? []).join(', ') || dash),
178
+ stat('Started', formatStarted(run.started), run.started ? relativeTime(run.started) : null),
179
+ stat('Duration', formatDuration(run.status === 'running' && run.started ? Date.now() - Date.parse(run.started) : t.totals.durationMs), null, run.status === 'running' ? run.started : null),
180
+ stat('Tokens', formatTokens(tokensOf(t.totals))), stat('Est. cost (estimate)', formatCost(t.totals.cost)),
181
+ stat('Agents', String(t.totals.agents))),
182
+ t.totals.cost === null && t.totals.agents > 0 ? h('p', { class: 'small muted' }, 'Cost is unknown because at least one agent used a model without a price.') : null),
183
+ members.length ? section('Runs in this batch', table([['Status'], ['Run'], ['Started'], ['Duration', true], ['Est. cost', true], ['CI']], members.map((m) => runRow(m, false)))) : null,
184
+ section('Steps', t.steps.length ? h('ol', { class: 'steps' }, steps) : empty('No steps recorded yet.')),
185
+ section('Children', t.children.length
186
+ ? table([['Bead'], ['Model'], ['Wave', true], ['Duration', true], ['Tokens', true], ['Est. cost', true], ['Result'], ['Review'], ['Red before green']], children)
187
+ : empty('No child tasks recorded yet.')),
188
+ h('div', { class: 'grid-2' },
189
+ section('Reviews', reviewTr.length ? table([['Bead'], ['Round', true], ['Verdict'], ['Must fix', true]], reviewTr) : empty('No reviews yet.')),
190
+ section('Tests', t.tests.length ? table([['Scope'], ['Passed', true], ['Failed', true], ['Time', true]], testRows) : empty('No test results yet.'))),
191
+ h('div', { class: 'grid-2' },
192
+ section('Findings filed', findings.length ? h('ul', { class: 'list' }, findings.map((f) => h('li', { class: 'mono' }, f))) : empty('No findings.')),
193
+ section('CI', ci ? [ciB ? badge(ciB) : null, h('p', { class: 'small muted mono' }, ci.sha.slice(0, 7)), ciRuns.length ? h('ul', { class: 'list' }, ciLinks) : empty('No workflow runs found for this commit.')] : empty('Nothing pushed yet.')))));
194
+ }
195
+
196
+ const backLink = () => h('a', { class: 'back', href: '#/' }, '← All runs');
197
+
198
+ async function loadDetail(id) {
199
+ try {
200
+ const d = await getJson(`/api/runs/${encodeURIComponent(id)}`);
201
+ state.details.set(id, d);
202
+ state.ci.set(id, d.ci ?? null);
203
+ } catch (e) { state.details.set(id, { error: e.message }); }
204
+ if (state.route.view === 'run' && state.route.id === id) renderDetail(id);
205
+ }
206
+
207
+ function errorState(message, retry) {
208
+ show(backLink(), h('div', { class: 'state', 'data-tone': 'bad', 'data-testid': 'error-state', role: 'alert' },
209
+ h('h2', null, 'Could not load'), h('p', null, message), h('button', { class: 'btn', type: 'button', onclick: null }, 'Retry')));
210
+ app.querySelector('button').addEventListener('click', retry);
211
+ }
212
+
213
+ /* ---------- Routing and live updates ---------- */
214
+ function route() {
215
+ state.route = parseRoute(location.hash);
216
+ if (state.route.view === 'run') {
217
+ state.details.delete(state.route.id);
218
+ renderDetail(state.route.id);
219
+ loadDetail(state.route.id);
220
+ if (!state.runs) loadList();
221
+ } else {
222
+ renderList();
223
+ loadList();
224
+ }
225
+ window.scrollTo(0, 0);
226
+ }
227
+
228
+ let refreshTimer = null;
229
+ function onRunChanged(id) {
230
+ clearTimeout(refreshTimer);
231
+ refreshTimer = setTimeout(() => {
232
+ state.ci.delete(id);
233
+ loadList();
234
+ if (state.route.view === 'run') loadDetail(state.route.id);
235
+ }, 150);
236
+ }
237
+
238
+ function connect() {
239
+ const es = new EventSource('/api/events');
240
+ let dropped = false;
241
+ es.addEventListener('open', () => {
242
+ liveEl.dataset.state = 'live'; liveEl.textContent = 'Live';
243
+ if (dropped) { dropped = false; onRunChanged(null); } // events missed while disconnected
244
+ });
245
+ es.addEventListener('error', () => { dropped = true; liveEl.dataset.state = 'offline'; liveEl.textContent = 'Reconnecting'; });
246
+ es.addEventListener('run-changed', (e) => {
247
+ try { onRunChanged(JSON.parse(e.data).id); } catch { onRunChanged(null); }
248
+ });
249
+ }
250
+
251
+ window.addEventListener('hashchange', route);
252
+ // Keep relative times and running durations fresh.
253
+ const refreshMs = Number(new URLSearchParams(location.search).get('refresh')) || 10_000;
254
+ setInterval(() => {
255
+ if (state.route.view === 'list' && state.runs) renderList();
256
+ // Running detail: only the duration text ticks, the rest of the page (and focus) stays untouched.
257
+ for (const el of app.querySelectorAll('[data-live]')) el.textContent = formatDuration(Date.now() - Date.parse(el.dataset.live));
258
+ }, refreshMs);
259
+ route();
260
+ connect();
@@ -0,0 +1,86 @@
1
+ /* Neutral default theme: the design tokens app.css uses, light and dark. A project can override any of them
2
+ with its own tokens CSS (config dashboard.tokensFile), which is loaded after this file as /tokens.css. */
3
+ :root {
4
+ color-scheme: light dark;
5
+ --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
6
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
7
+
8
+ --text-caption-size: 0.75rem; --text-caption-leading: 1.3; --text-caption-tracking: 0.01em;
9
+ --text-small-size: 0.8125rem; --text-small-leading: 1.4;
10
+ --text-body-size: 0.9375rem; --text-body-leading: 1.5; --text-body-tracking: 0;
11
+ --text-title-size: 1.0625rem; --text-title-leading: 1.3; --text-title-tracking: -0.01em;
12
+ --text-heading-size: 1.5rem; --text-heading-leading: 1.2; --text-heading-tracking: -0.02em;
13
+ --weight-medium: 500;
14
+ --weight-semibold: 600;
15
+
16
+ --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem;
17
+ --space-5: 1.25rem; --space-6: 1.5rem; --space-7: 2rem; --space-8: 3rem;
18
+ --radius-sm: 0.375rem; --radius-md: 0.625rem; --radius-pill: 999px;
19
+ --border-width: 1px; --border-width-thick: 2px;
20
+ --size-dot: 0.5rem;
21
+ --size-file-col-date: 9rem;
22
+ --control-height: 2.25rem;
23
+ --shell-bar-height: 3.25rem;
24
+ --page-width-wide: 72rem;
25
+ --content-narrow: 40rem;
26
+ --focus-offset: 2px;
27
+ --z-sticky: 10;
28
+ --safe-left: env(safe-area-inset-left, 0px);
29
+ --safe-right: env(safe-area-inset-right, 0px);
30
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
31
+
32
+ --duration-press: 80ms;
33
+ --duration-fast: 160ms;
34
+ --duration-pulse: 1.6s;
35
+ --duration-spin-calm: 1.2s;
36
+ --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
37
+ --press-scale: 0.97;
38
+ --opacity-ghost: 0.45;
39
+ --blur-material: blur(16px) saturate(1.4);
40
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.08);
41
+
42
+ --color-bg: #f7f7f8;
43
+ --color-surface: #ffffff;
44
+ --color-surface-sunken: #efeff1;
45
+ --color-surface-hover: #f1f1f3;
46
+ --color-material: rgb(247 247 248 / 0.8);
47
+ --color-border: #dcdce0;
48
+ --color-text: #1b1b1f;
49
+ --color-text-muted: #5a5a63;
50
+ --color-text-subtle: #8a8a94;
51
+ --color-accent: #3b5bdb;
52
+ --color-accent-hover: #3050c4;
53
+ --color-accent-soft: #e4e9fb;
54
+ --color-accent-text: #2f4ac2;
55
+ --color-on-accent: #ffffff;
56
+ --color-focus: #3b5bdb;
57
+ --color-ok: #1f7a3d; --color-ok-soft: #e1f3e6; --color-ok-solid: #2e9e55;
58
+ --color-warn: #8a5a00; --color-warn-soft: #fbefd5; --color-warn-solid: #d9930d;
59
+ --color-bad: #b3261e; --color-bad-soft: #fbe4e2; --color-bad-solid: #d64034;
60
+ --color-info: #1f5f99; --color-info-soft: #deedfa; --color-info-solid: #2f84cf;
61
+ }
62
+
63
+ @media (prefers-color-scheme: dark) {
64
+ :root {
65
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
66
+ --color-bg: #121214;
67
+ --color-surface: #1b1b1f;
68
+ --color-surface-sunken: #161619;
69
+ --color-surface-hover: #232328;
70
+ --color-material: rgb(18 18 20 / 0.8);
71
+ --color-border: #2f2f36;
72
+ --color-text: #ececf0;
73
+ --color-text-muted: #a4a4ae;
74
+ --color-text-subtle: #70707a;
75
+ --color-accent: #7b93f0;
76
+ --color-accent-hover: #93a7f5;
77
+ --color-accent-soft: #232a4a;
78
+ --color-accent-text: #9db0f6;
79
+ --color-on-accent: #101018;
80
+ --color-focus: #7b93f0;
81
+ --color-ok: #6fcf8f; --color-ok-soft: #16301f; --color-ok-solid: #3fb866;
82
+ --color-warn: #f0bd5a; --color-warn-soft: #3a2c0f; --color-warn-solid: #e0a52c;
83
+ --color-bad: #f2877f; --color-bad-soft: #3c1a18; --color-bad-solid: #e5574b;
84
+ --color-info: #7ab8ee; --color-info-soft: #162b3e; --color-info-solid: #4a9be0;
85
+ }
86
+ }
@@ -0,0 +1,138 @@
1
+ // Pure helpers for the dashboard UI (no DOM), unit-tested in test/ui.test.mjs.
2
+ const DASH = '—';
3
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
4
+
5
+ export function formatDuration(ms) {
6
+ if (!isNum(ms) || ms < 0) return DASH;
7
+ if (ms < 1000) return '<1s';
8
+ const s = Math.round(ms / 1000);
9
+ if (s < 60) return `${s}s`;
10
+ const m = Math.floor(s / 60);
11
+ if (m < 60) return `${m}m ${s % 60}s`;
12
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
13
+ }
14
+
15
+ /** Cost is an estimate (prefix "~"). Unknown (null) is a dash, never $0.00. */
16
+ export function formatCost(cost) {
17
+ if (!isNum(cost)) return DASH;
18
+ if (cost > 0 && cost < 0.005) return '<$0.01';
19
+ return `~$${cost.toFixed(2)}`;
20
+ }
21
+
22
+ export function formatTokens(n) {
23
+ if (!isNum(n)) return DASH;
24
+ if (n < 1000) return String(n);
25
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;
26
+ return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
27
+ }
28
+
29
+ /** Sum of all token kinds of an agents/totals object. */
30
+ export function totalTokens(t) {
31
+ if (!t) return null;
32
+ return ['tokensIn', 'tokensOut', 'tokensCacheRead', 'tokensCacheWrite'].reduce((s, k) => s + (isNum(t[k]) ? t[k] : 0), 0);
33
+ }
34
+
35
+ const STATUS = {
36
+ running: { label: 'Running', tone: 'info' },
37
+ done: { label: 'Done', tone: 'ok' },
38
+ blocked: { label: 'Blocked', tone: 'warn' },
39
+ failed: { label: 'Failed', tone: 'bad' },
40
+ };
41
+ export function statusBadge(status) {
42
+ return STATUS[status] ?? { label: String(status ?? DASH), tone: 'neutral' };
43
+ }
44
+
45
+ const CI = {
46
+ success: { label: 'CI passed', tone: 'ok' },
47
+ failure: { label: 'CI failed', tone: 'bad' },
48
+ pending: { label: 'CI running', tone: 'info' },
49
+ none: { label: 'No CI', tone: 'neutral' },
50
+ unknown: { label: 'CI unknown', tone: 'neutral' },
51
+ };
52
+ export function ciBadge(ci) {
53
+ if (!ci) return null;
54
+ return CI[ci.state] ?? CI.unknown;
55
+ }
56
+
57
+ /** Returns the url only when it is http(s); anything else (javascript:, data:, relative) is null. */
58
+ export function safeHref(url) {
59
+ if (typeof url !== 'string') return null;
60
+ try {
61
+ const u = new URL(url);
62
+ return u.protocol === 'http:' || u.protocol === 'https:' ? url : null;
63
+ } catch { return null; }
64
+ }
65
+
66
+ /** Running runs first, then newest started first. Returns a new array. */
67
+ export function sortRuns(runs) {
68
+ const rank = (r) => (r.status === 'running' ? 0 : 1);
69
+ return [...runs].sort((a, b) => rank(a) - rank(b) || (b.started ?? '').localeCompare(a.started ?? ''));
70
+ }
71
+
72
+ export function parseRoute(hash) {
73
+ const m = /^#\/runs\/(.+)$/.exec(hash ?? '');
74
+ if (!m) return { view: 'list' };
75
+ try { return { view: 'run', id: decodeURIComponent(m[1]) }; } catch { return { view: 'list' }; }
76
+ }
77
+
78
+ export function relativeTime(iso, now = Date.now()) {
79
+ const t = Date.parse(iso ?? '');
80
+ if (!Number.isFinite(t)) return DASH;
81
+ const s = Math.max(0, Math.round((now - t) / 1000));
82
+ if (s < 30) return 'just now';
83
+ if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`;
84
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
85
+ return `${Math.floor(s / 86400)}d ago`;
86
+ }
87
+
88
+ /** Latest verdict and highest round per bead, from raw review events that carry a bead (the skill may record only the final round). */
89
+ export function reviewsByBead(events) {
90
+ const map = new Map();
91
+ for (const e of events ?? []) {
92
+ if (e.type !== 'review' || !e.bead) continue;
93
+ const prev = map.get(e.bead);
94
+ const round = isNum(e.round) ? e.round : 0;
95
+ if (!prev || round >= prev.round) map.set(e.bead, { verdict: e.verdict ?? null, round });
96
+ }
97
+ return map;
98
+ }
99
+
100
+ /** Review events (newest first) with their bead, for the reviews table. */
101
+ export function reviewRows(events) {
102
+ return (events ?? []).filter((e) => e.type === 'review')
103
+ .map((e) => ({ ts: e.ts, bead: e.bead ?? null, round: e.round ?? null, verdict: e.verdict ?? null, mustFix: e.mustFix ?? 0 }))
104
+ .reverse();
105
+ }
106
+
107
+ const RESULT = { committed: 'ok', ok: 'ok', done: 'ok', blocked: 'warn', failed: 'bad', running: 'info' };
108
+ export function resultBadge(result) {
109
+ if (!result) return null;
110
+ return { label: String(result), tone: RESULT[result] ?? 'neutral' };
111
+ }
112
+
113
+ /** Total tokens, or null when no agent was recorded (unknown, not 0). */
114
+ export function tokensOf(totals) {
115
+ if (!totals || !totals.agents) return null;
116
+ return totalTokens(totals);
117
+ }
118
+
119
+ /** Compact start time like "13 Nov, 10:44". */
120
+ export function formatStarted(iso, locale = undefined, timeZone = undefined) {
121
+ const t = Date.parse(iso ?? '');
122
+ if (!Number.isFinite(t)) return DASH;
123
+ const d = new Date(t);
124
+ const opts = { timeZone };
125
+ const day = new Intl.DateTimeFormat(locale, { ...opts, day: 'numeric', month: 'short' }).format(d);
126
+ const time = new Intl.DateTimeFormat(locale, { ...opts, hour: '2-digit', minute: '2-digit', hour12: false }).format(d);
127
+ return `${day}, ${time}`;
128
+ }
129
+
130
+ export function stepLabel({ step, wave }) {
131
+ const name = String(step ?? DASH).replace(/^./, (c) => c.toUpperCase());
132
+ if (step === 'wave') return wave != null ? `${name} ${wave}` : name;
133
+ return wave != null ? `${name} · wave ${wave}` : name;
134
+ }
135
+
136
+ export function findingsOf(events) {
137
+ return (events ?? []).filter((e) => e.type === 'finding' && e.bead).map((e) => e.bead);
138
+ }
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>Pipeline dashboard</title>
8
+ <link rel="stylesheet" href="/default-theme.css">
9
+ <link rel="stylesheet" href="/tokens.css">
10
+ <link rel="stylesheet" href="/app.css">
11
+ </head>
12
+ <body>
13
+ <header class="topbar">
14
+ <a class="brand" href="#/">Pipeline</a>
15
+ <span class="live" id="live" data-state="connecting" role="status">Connecting</span>
16
+ </header>
17
+ <main id="app" class="page"></main>
18
+ <script type="module" src="/app.js"></script>
19
+ </body>
20
+ </html>