wavework 0.0.0-stage → 0.1.1

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 (55) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -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/diff-stat.mjs +41 -0
  10. package/src/cli/hooks.mjs +109 -0
  11. package/src/cli/init.mjs +104 -0
  12. package/src/cli/safe-path.mjs +63 -0
  13. package/src/cli/sync.mjs +302 -0
  14. package/src/cli/update.mjs +24 -0
  15. package/src/cli.mjs +78 -0
  16. package/src/config.mjs +313 -0
  17. package/src/context.mjs +133 -0
  18. package/src/render.mjs +148 -0
  19. package/src/targets.mjs +100 -0
  20. package/templates/agents/implementer.md +16 -0
  21. package/templates/dashboard/README.md +96 -0
  22. package/templates/dashboard/hooks/session-start.mjs +305 -0
  23. package/templates/dashboard/hooks/subagent-stop.mjs +129 -0
  24. package/templates/dashboard/lib/ci.mjs +52 -0
  25. package/templates/dashboard/lib/derive.mjs +105 -0
  26. package/templates/dashboard/lib/settings.mjs +34 -0
  27. package/templates/dashboard/lib/tokens.mjs +21 -0
  28. package/templates/dashboard/pricing.json +11 -0
  29. package/templates/dashboard/public/app.css +134 -0
  30. package/templates/dashboard/public/app.js +260 -0
  31. package/templates/dashboard/public/default-theme.css +86 -0
  32. package/templates/dashboard/public/format.mjs +138 -0
  33. package/templates/dashboard/public/index.html +20 -0
  34. package/templates/dashboard/record.mjs +144 -0
  35. package/templates/dashboard/server.mjs +254 -0
  36. package/templates/dashboard-config.json +1 -0
  37. package/templates/scripts/red-green-check.sh +287 -0
  38. package/templates/scripts/red-green-check.test.sh +151 -0
  39. package/templates/settings/hooks.json +31 -0
  40. package/templates/skills/next-task/SKILL.md +77 -0
  41. package/templates/skills/next-task-batch/SKILL.md +27 -0
  42. package/templates/skills/next-task-beads/SKILL.md +29 -0
  43. package/templates/skills/next-task-branch/SKILL.md +19 -0
  44. package/templates/skills/next-task-code/SKILL.md +22 -0
  45. package/templates/skills/next-task-deps/SKILL.md +31 -0
  46. package/templates/skills/next-task-e2e/SKILL.md +35 -0
  47. package/templates/skills/next-task-findings/SKILL.md +65 -0
  48. package/templates/skills/next-task-finish/SKILL.md +43 -0
  49. package/templates/skills/next-task-implement/SKILL.md +34 -0
  50. package/templates/skills/next-task-plan/SKILL.md +52 -0
  51. package/templates/skills/next-task-review/SKILL.md +61 -0
  52. package/templates/skills/next-task-test/SKILL.md +30 -0
  53. package/templates/skills/next-task-write-tests/SKILL.md +34 -0
  54. package/templates/skills/text-to-task/SKILL.md +78 -0
  55. package/templates/skills/watch-ci/SKILL.md +42 -0
@@ -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>
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // Pipeline run recorder: appends events of /next-task* runs to a store shared by all worktrees.
3
+ // CLI: record.mjs <event> [--run <id>] [--key value ...]. Never fails loudly (exit 0 always).
4
+ // Importable: resolveStoreDir, findRunByCwd, recordEvent, readRun, listRuns, EVENT_TYPES.
5
+ import { execFileSync } from 'node:child_process';
6
+ import {
7
+ appendFileSync, closeSync, mkdirSync, openSync, readdirSync, readFileSync,
8
+ realpathSync, renameSync, writeFileSync, constants,
9
+ } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ export const STORE_ENV = 'WAVEWORK_PIPELINE_STORE';
14
+ export const EVENT_TYPES = [
15
+ 'run-start', 'run-end', 'step-start', 'step-end', 'child-start', 'child-end',
16
+ 'review', 'tests', 'finding', 'agent',
17
+ ];
18
+ const RUN_KINDS = ['next-task', 'beads', 'batch'];
19
+ const RUN_STATUSES = ['running', 'done', 'blocked', 'failed'];
20
+ const NUMERIC = new Set([
21
+ 'wave', 'round', 'mustFix', 'passed', 'failed', 'duration', 'durationMs',
22
+ 'tokensIn', 'tokensOut', 'tokensCacheRead', 'tokensCacheWrite', 'cost',
23
+ ]);
24
+ const LIST = new Set(['units']);
25
+ export const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
26
+
27
+ /** Store directory: $WAVEWORK_PIPELINE_STORE, else <git common dir>/pipeline-runs. Throws if neither works. */
28
+ export function resolveStoreDir({ cwd = process.cwd(), env = process.env } = {}) {
29
+ if (env[STORE_ENV]) return path.resolve(env[STORE_ENV]);
30
+ const out = execFileSync('git', ['rev-parse', '--git-common-dir'], {
31
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
32
+ }).trim();
33
+ return path.join(path.resolve(cwd, out), 'pipeline-runs');
34
+ }
35
+
36
+ function real(p) {
37
+ try { return realpathSync(p); } catch { return path.resolve(p); }
38
+ }
39
+
40
+ function within(dir, cwd) {
41
+ const rel = path.relative(dir, cwd);
42
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
43
+ }
44
+
45
+ export function readRun(storeDir, id) {
46
+ if (typeof id !== 'string' || !RUN_ID.test(id)) return null;
47
+ try { return JSON.parse(readFileSync(path.join(storeDir, id, 'run.json'), 'utf8')); } catch { return null; }
48
+ }
49
+
50
+ export function listRuns(storeDir) {
51
+ let names;
52
+ try { names = readdirSync(storeDir); } catch { return []; }
53
+ return names.map((n) => readRun(storeDir, n)).filter(Boolean);
54
+ }
55
+
56
+ /** The running run whose worktree contains cwd (deepest worktree wins), or null. */
57
+ export function findRunByCwd(cwd, { storeDir = resolveStoreDir({ cwd }) } = {}) {
58
+ const target = real(cwd);
59
+ let best = null;
60
+ let bestLen = -1;
61
+ for (const run of listRuns(storeDir)) {
62
+ if (run.status !== 'running' || !run.worktree) continue;
63
+ const wt = real(run.worktree);
64
+ if (within(wt, target) && wt.length > bestLen) { best = run; bestLen = wt.length; }
65
+ }
66
+ return best;
67
+ }
68
+
69
+ function writeRunJson(storeDir, id, run) {
70
+ const dir = path.join(storeDir, id);
71
+ const tmp = path.join(dir, `.run.json.${process.pid}.${Date.now()}.tmp`);
72
+ writeFileSync(tmp, JSON.stringify(run, null, 2) + '\n');
73
+ renameSync(tmp, path.join(dir, 'run.json'));
74
+ }
75
+
76
+ function appendLine(file, obj) {
77
+ // One write call with O_APPEND: lines from parallel processes do not interleave.
78
+ const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT, 0o644);
79
+ try { appendFileSync(fd, JSON.stringify(obj) + '\n'); } finally { closeSync(fd); }
80
+ }
81
+
82
+ /** Records one event. `fields` are the event's data (without ts/type). Returns the written event. */
83
+ export function recordEvent(storeDir, runId, type, fields = {}, now = new Date()) {
84
+ if (!EVENT_TYPES.includes(type)) throw new Error(`unknown event type: ${type}`);
85
+ if (!RUN_ID.test(runId || '')) throw new Error(`invalid run id: ${runId}`);
86
+ const ts = now.toISOString();
87
+ mkdirSync(path.join(storeDir, runId), { recursive: true });
88
+ const event = { ...fields, ts, type, run: runId };
89
+ appendLine(path.join(storeDir, runId, 'events.jsonl'), event);
90
+
91
+ if (type === 'run-start') {
92
+ const kind = RUN_KINDS.includes(fields.kind) ? fields.kind : 'next-task';
93
+ writeRunJson(storeDir, runId, {
94
+ id: runId, kind, units: fields.units ?? [], parent: fields.parent ?? null,
95
+ branch: fields.branch ?? null, worktree: fields.worktree ?? null,
96
+ status: 'running', started: ts, ended: null,
97
+ });
98
+ } else if (type === 'run-end') {
99
+ const run = readRun(storeDir, runId) ?? { id: runId, started: null };
100
+ const status = RUN_STATUSES.includes(fields.status) && fields.status !== 'running' ? fields.status : 'done';
101
+ writeRunJson(storeDir, runId, { ...run, status, ended: ts });
102
+ }
103
+ return event;
104
+ }
105
+
106
+ /** Parses `--key value` pairs; numeric and list fields are coerced, bare flags become true. */
107
+ export function parseArgs(args) {
108
+ const out = {};
109
+ for (let i = 0; i < args.length; i++) {
110
+ const a = args[i];
111
+ if (!a.startsWith('--')) continue;
112
+ const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
113
+ const next = args[i + 1];
114
+ let value = true;
115
+ if (next !== undefined && !next.startsWith('--')) { value = next; i++; }
116
+ if (typeof value === 'string') {
117
+ if (LIST.has(key)) value = value.split(',').map((s) => s.trim()).filter(Boolean);
118
+ else if (NUMERIC.has(key) && value.trim() !== '' && Number.isFinite(Number(value))) value = Number(value);
119
+ }
120
+ out[key] = value;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** CLI entry. Returns the exit code, which is always 0. */
126
+ export function main(argv, { cwd = process.cwd(), env = process.env, stderr = console.error } = {}) {
127
+ try {
128
+ const [type, ...rest] = argv;
129
+ if (!type) throw new Error(`usage: record.mjs <${EVENT_TYPES.join('|')}> --run <id> [--key value ...]`);
130
+ const { run, ...fields } = parseArgs(rest);
131
+ const storeDir = resolveStoreDir({ cwd, env });
132
+ let runId = typeof run === 'string' ? run : undefined;
133
+ if (!runId) runId = findRunByCwd(cwd, { storeDir })?.id;
134
+ if (!runId) throw new Error('no --run given and no running run for this directory');
135
+ recordEvent(storeDir, runId, type, fields);
136
+ } catch (e) {
137
+ stderr(`pipeline-record: ${e?.message ?? String(e)}`);
138
+ }
139
+ return 0;
140
+ }
141
+
142
+ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
143
+ process.exitCode = main(process.argv.slice(2));
144
+ }
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ // Pipeline dashboard server: JSON API over the run store, SSE live updates, CI status, static UI.
3
+ // Node built-ins only. Binds 127.0.0.1. See README "Server/API".
4
+ import { readFileSync, readdirSync, realpathSync, statSync, watch as fsWatch } from 'node:fs';
5
+ import http from 'node:http';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { createCiLookup } from './lib/ci.mjs';
9
+ import { deriveTimeline } from './lib/derive.mjs';
10
+ import { projectName, readSettings, resolvePort } from './lib/settings.mjs';
11
+ import { resolveTokensFile } from './lib/tokens.mjs';
12
+ import { RUN_ID, listRuns, readRun, resolveStoreDir } from './record.mjs';
13
+
14
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
15
+ // This folder is <repo>/.claude/wavework/dashboard, so the checkout root is three levels up.
16
+ const REPO_ROOT = path.resolve(HERE, '..', '..', '..');
17
+ const MIME = {
18
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
19
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml',
20
+ '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.map': 'application/json; charset=utf-8',
21
+ };
22
+
23
+ function readEvents(storeDir, id) {
24
+ if (!RUN_ID.test(id)) return [];
25
+ let text;
26
+ try { text = readFileSync(path.join(storeDir, id, 'events.jsonl'), 'utf8'); } catch { return []; }
27
+ const out = [];
28
+ for (const line of text.split('\n')) {
29
+ if (!line.trim()) continue;
30
+ try { out.push(JSON.parse(line)); } catch { /* partial or corrupt line: skip */ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function sendJson(res, status, body) {
36
+ const data = JSON.stringify(body);
37
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
38
+ res.end(data);
39
+ }
40
+
41
+ function sendText(res, status, type, text) {
42
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-cache' });
43
+ res.end(text);
44
+ }
45
+
46
+ function sendFile(res, file) {
47
+ let data;
48
+ try { data = readFileSync(file); } catch { return sendJson(res, 404, { error: 'not found' }); }
49
+ res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream', 'cache-control': 'no-cache' });
50
+ res.end(data);
51
+ }
52
+
53
+ /** Maps a URL path to a file inside publicDir, or null. Rejects traversal, NULs and symlinks leaving publicDir. */
54
+ function resolvePublic(publicDir, rawPath) {
55
+ let decoded;
56
+ try { decoded = decodeURIComponent(rawPath); } catch { return { status: 400 }; }
57
+ if (decoded.includes('\0') || decoded.includes('\\')) return { status: 400 };
58
+ const rel = decoded === '/' ? 'index.html' : decoded.replace(/^\/+/, '');
59
+ if (rel.split('/').includes('..')) return { status: 400 };
60
+ const base = path.resolve(publicDir);
61
+ const candidate = path.resolve(base, rel);
62
+ if (!candidate.startsWith(base + path.sep)) return { status: 400 };
63
+ try {
64
+ const realBase = realpathSync(base);
65
+ const real = realpathSync(candidate);
66
+ if (!real.startsWith(realBase + path.sep) || !statSync(real).isFile()) return { status: 404 };
67
+ return { file: real };
68
+ } catch { return { status: 404 }; }
69
+ }
70
+
71
+ function snapshot(storeDir) {
72
+ const snap = new Map();
73
+ let names;
74
+ try { names = readdirSync(storeDir); } catch { return snap; }
75
+ for (const n of names) {
76
+ if (!RUN_ID.test(n)) continue;
77
+ const sig = ['run.json', 'events.jsonl'].map((f) => {
78
+ try { const s = statSync(path.join(storeDir, n, f)); return `${s.mtimeMs}:${s.size}`; } catch { return '-'; }
79
+ }).join('|');
80
+ snap.set(n, sig);
81
+ }
82
+ return snap;
83
+ }
84
+
85
+ /**
86
+ * Calls onChange(runId) when a store file changes. fs.watch (recursive) is only the trigger; the run id comes
87
+ * from diffing store snapshots, because filename reporting differs per platform. Falls back to polling.
88
+ */
89
+ function watchStore(storeDir, onChange, { mode = 'auto', pollMs = 1000 } = {}) {
90
+ let watcher = null;
91
+ let timer = null;
92
+ let debounce = null;
93
+ let closed = false;
94
+ let prev = snapshot(storeDir);
95
+ const diff = () => {
96
+ const next = snapshot(storeDir);
97
+ for (const [id, sig] of next) if (prev.get(id) !== sig) onChange(id);
98
+ prev = next;
99
+ };
100
+ if (mode !== 'poll') {
101
+ try {
102
+ watcher = fsWatch(storeDir, { recursive: true }, () => {
103
+ clearTimeout(debounce);
104
+ debounce = setTimeout(diff, 20);
105
+ });
106
+ watcher.on('error', () => { watcher?.close(); watcher = null; if (!closed) startPolling(); });
107
+ } catch { watcher = null; }
108
+ }
109
+ function startPolling() {
110
+ if (timer || closed) return;
111
+ timer = setInterval(diff, pollMs);
112
+ timer.unref?.();
113
+ }
114
+ if (!watcher) startPolling();
115
+ return () => {
116
+ closed = true;
117
+ watcher?.close();
118
+ clearTimeout(debounce);
119
+ if (timer) clearInterval(timer);
120
+ };
121
+ }
122
+
123
+ export function createServer({
124
+ storeDir = resolveStoreDir(), port = resolvePort(HERE),
125
+ publicDir = path.join(HERE, 'public'),
126
+ // The project's own tokens CSS: a fixed absolute path, or a function returning it (or null) per request.
127
+ tokensPath = () => resolveTokensFile(REPO_ROOT, readSettings(HERE).tokensFile),
128
+ project = projectName(REPO_ROOT),
129
+ ghRunner, ciRefreshMs = 15_000, watch = 'auto', pollMs = 1000,
130
+ } = {}) {
131
+ const ci = createCiLookup({ ghRunner, refreshMs: ciRefreshMs });
132
+ const sse = new Set();
133
+
134
+ /** A batch still `running` whose members have all ended is reported as ended (blocked if any member is blocked/failed). */
135
+ const withBatchStatus = (run, all) => {
136
+ if (run.status !== 'running') return run;
137
+ const members = all.filter((r) => r.parent === run.id);
138
+ if (members.length === 0 || members.some((m) => m.status === 'running')) return run;
139
+ const bad = members.some((m) => m.status === 'blocked' || m.status === 'failed');
140
+ const ended = members.map((m) => m.ended).filter(Boolean).sort().pop() ?? null;
141
+ return { ...run, status: bad ? 'blocked' : 'done', ended };
142
+ };
143
+
144
+ const summary = (run) => {
145
+ const timeline = deriveTimeline(run, readEvents(storeDir, run.id));
146
+ return { ...run, totals: timeline.totals };
147
+ };
148
+
149
+ function listSummaries() {
150
+ const raw = listRuns(storeDir);
151
+ const all = raw.map((r) => summary(withBatchStatus(r, raw)));
152
+ const newest = (a, b) => (b.started ?? '').localeCompare(a.started ?? '');
153
+ const ids = new Set(all.map((r) => r.id));
154
+ const kids = new Map();
155
+ const top = [];
156
+ for (const r of all) {
157
+ if (r.parent && ids.has(r.parent)) {
158
+ if (!kids.has(r.parent)) kids.set(r.parent, []);
159
+ kids.get(r.parent).push(r);
160
+ } else top.push(r);
161
+ }
162
+ return top.sort(newest).map((r) => ({ ...r, children: (kids.get(r.id) ?? []).sort(newest) }));
163
+ }
164
+
165
+ async function runDetail(id) {
166
+ const stored = readRun(storeDir, id);
167
+ if (!stored) return null;
168
+ const run = withBatchStatus(stored, listRuns(storeDir));
169
+ const events = readEvents(storeDir, id);
170
+ const timeline = deriveTimeline(run, events);
171
+ return { run, events, timeline, ci: timeline.sha ? await ci.lookup(timeline.sha) : null };
172
+ }
173
+
174
+ async function handle(req, res) {
175
+ const host = req.headers.host ?? '';
176
+ const p = server.address()?.port;
177
+ if (host !== `127.0.0.1:${p}` && host !== `localhost:${p}`) return sendJson(res, 403, { error: 'forbidden host' });
178
+ if (req.method !== 'GET' && req.method !== 'HEAD') return sendJson(res, 405, { error: 'method not allowed' });
179
+ // Raw path on purpose: new URL() would normalize %2e%2e/.. segments before we can reject them.
180
+ const rawPath = (req.url ?? '/').split('?')[0].split('#')[0];
181
+
182
+ // root: the checkout this server runs from, so the SessionStart hook can replace a server whose worktree was deleted.
183
+ if (rawPath === '/api/health') return sendJson(res, 200, { ok: true, root: HERE });
184
+ // project: the repo directory name, shown in the UI title.
185
+ if (rawPath === '/api/info') return sendJson(res, 200, { project });
186
+ if (rawPath === '/api/runs') return sendJson(res, 200, listSummaries());
187
+ if (rawPath === '/api/events') {
188
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
189
+ res.write(': connected\n\n');
190
+ sse.add(res);
191
+ res.on('close', () => sse.delete(res));
192
+ return undefined;
193
+ }
194
+ if (rawPath.startsWith('/api/runs/')) {
195
+ let id;
196
+ try { id = decodeURIComponent(rawPath.slice('/api/runs/'.length)); } catch { return sendJson(res, 400, { error: 'invalid run id' }); }
197
+ if (!RUN_ID.test(id)) return sendJson(res, 400, { error: 'invalid run id' });
198
+ const detail = await runDetail(id);
199
+ return detail ? sendJson(res, 200, detail) : sendJson(res, 404, { error: 'run not found' });
200
+ }
201
+ if (rawPath.startsWith('/api/')) return sendJson(res, 404, { error: 'not found' });
202
+ if (rawPath === '/tokens.css') {
203
+ // Optional project theme, loaded after the default theme. Nothing configured: an empty stylesheet.
204
+ const file = typeof tokensPath === 'function' ? tokensPath() : tokensPath;
205
+ if (!file) return sendText(res, 200, 'text/css; charset=utf-8', '/* no project tokens configured */\n');
206
+ return sendFile(res, file);
207
+ }
208
+ const r = resolvePublic(publicDir, rawPath);
209
+ if (r.file) return sendFile(res, r.file);
210
+ return sendJson(res, r.status, { error: r.status === 400 ? 'bad request' : 'not found' });
211
+ }
212
+
213
+ const server = http.createServer((req, res) => {
214
+ handle(req, res).catch(() => { if (!res.headersSent) sendJson(res, 500, { error: 'internal error' }); else res.end(); });
215
+ });
216
+ let stopWatch = null;
217
+ let keepAlive = null;
218
+
219
+ return {
220
+ get port() { return server.address()?.port; },
221
+ address: () => server.address(),
222
+ listen() {
223
+ return new Promise((resolve, reject) => {
224
+ server.once('error', reject);
225
+ server.listen(port, '127.0.0.1', () => {
226
+ server.off('error', reject);
227
+ stopWatch = watchStore(storeDir, (id) => {
228
+ for (const res of sse) res.write(`event: run-changed\ndata: ${JSON.stringify({ id })}\n\n`);
229
+ }, { mode: watch, pollMs });
230
+ keepAlive = setInterval(() => { for (const res of sse) res.write(': ping\n\n'); }, 25_000);
231
+ keepAlive.unref();
232
+ resolve(server.address());
233
+ });
234
+ });
235
+ },
236
+ close() {
237
+ return new Promise((resolve) => {
238
+ stopWatch?.();
239
+ clearInterval(keepAlive);
240
+ for (const res of sse) res.end();
241
+ sse.clear();
242
+ server.close(() => resolve());
243
+ server.closeAllConnections?.();
244
+ });
245
+ },
246
+ };
247
+ }
248
+
249
+ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
250
+ const s = createServer();
251
+ s.listen().then(() => {
252
+ console.log(`pipeline dashboard: http://127.0.0.1:${s.port}`);
253
+ }).catch((e) => { console.error(`pipeline dashboard: ${e.message}`); process.exitCode = 1; });
254
+ }
@@ -0,0 +1 @@
1
+ {{dashboardConfigJson}}