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,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}}