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,305 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook: makes sure the pipeline dashboard server runs and tells the user its URL.
3
+ // Health answers from a live checkout -> silent. Port free -> start server.mjs detached (log in <store>/server.log)
4
+ // and print the URL. Port held by something else, or by a server whose checkout was deleted and is not the
5
+ // recorded one -> one warning. Always exits 0.
6
+ // Time budget (hard kill is the 5 s `timeout` in settings.json), worst case = stale-server replace path:
7
+ // probe 300 ms + lsof 800 + ps 800 + stop wait 250 + start wait 2000 + probes 150 + node start-up ~100 = about 4.4 s.
8
+ // Keep the sum under 5 s when changing any of these.
9
+ // Output format: https://code.claude.com/docs/en/hooks.md (systemMessage on stdout JSON; exit 0).
10
+ import { execFile, execFileSync, spawn } from 'node:child_process';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
+ import http from 'node:http';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { resolveStoreDir } from '../record.mjs';
17
+ import { resolvePort } from '../lib/settings.mjs';
18
+
19
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
20
+ const OWN_SERVER = path.join(HERE, '..', 'server.mjs');
21
+ // Where the dashboard lives in a checkout (wavework init puts it there).
22
+ const DASHBOARD_REL = ['.claude', 'wavework', 'dashboard'];
23
+ const PROBE_MS = 300; // first health probe
24
+ const LOOP_PROBE_MS = 150; // probes while waiting for a started server
25
+ // How long a started server gets to answer. The env override is for tests (a near-zero wait forces "gave up waiting").
26
+ const WAIT_MS = Number(process.env.PIPELINE_DASHBOARD_START_WAIT_MS) > 0 ? Number(process.env.PIPELINE_DASHBOARD_START_WAIT_MS) : 2000;
27
+ const EXEC_MS = 800; // lsof / ps each (a loaded machine can be slow; see the budget above)
28
+ const STOP_WAIT_MS = 250; // how long a stopped stale server gets to release the port
29
+ const STALE_LOCK_MS = 5000; // a start lock this old belongs to a crashed hook
30
+
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+
33
+ function emit(systemMessage) {
34
+ process.stdout.write(JSON.stringify({ systemMessage }) + '\n');
35
+ }
36
+
37
+ const inUse = (port) => `Pipeline dashboard: port ${port} is in use by another process (set dashboard.port in .claude/wavework.json and run wavework update to change it)`;
38
+ const staleInUse = (port) => `Pipeline dashboard: port ${port} is in use by a server from a missing checkout that is not the recorded one; stop it to start the dashboard`;
39
+ const couldNotStart = (logFile) => `Pipeline dashboard: could not start, see ${logFile}`;
40
+
41
+ /**
42
+ * Health probe. state: 'ok' (our health answers {"ok":true}), 'free' (connection refused),
43
+ * 'foreign' (something else answers, or accepts the connection but does not answer in time).
44
+ * root: the checkout directory the server reports, when it reports one.
45
+ */
46
+ function probe(port, timeout) {
47
+ return new Promise((resolve) => {
48
+ const req = http.get({ host: '127.0.0.1', port, path: '/api/health', timeout, agent: false }, (res) => {
49
+ let body = '';
50
+ res.setEncoding('utf8');
51
+ res.on('data', (d) => { body += d; });
52
+ res.on('end', () => {
53
+ try {
54
+ const json = JSON.parse(body);
55
+ if (res.statusCode === 200 && json.ok === true) {
56
+ resolve({ state: 'ok', root: typeof json.root === 'string' ? json.root : null });
57
+ return;
58
+ }
59
+ } catch { /* not our health answer */ }
60
+ resolve({ state: 'foreign', root: null });
61
+ });
62
+ res.on('error', () => resolve({ state: 'foreign', root: null }));
63
+ });
64
+ req.on('timeout', () => { req.destroy(); resolve({ state: 'foreign', root: null }); });
65
+ req.on('error', (e) => resolve({ state: e.code === 'ECONNREFUSED' ? 'free' : 'foreign', root: null }));
66
+ });
67
+ }
68
+
69
+ const isMissingCheckout = (root) => typeof root === 'string' && !existsSync(root);
70
+
71
+
72
+ /** Runs a fixed program with an argument list (no shell). Resolves to stdout, or null when it cannot run or fails. */
73
+ function run(file, args) {
74
+ return new Promise((resolve) => {
75
+ execFile(file, args, { encoding: 'utf8', timeout: EXEC_MS }, (err, stdout) => resolve(err ? null : stdout));
76
+ });
77
+ }
78
+
79
+ /**
80
+ * True only when `pid` is the single listener on `port` and its command line is the dashboard server. A pid that is
81
+ * merely recorded (pids get reused) is not enough. Anything that cannot be checked (no lsof or ps, no listener) counts
82
+ * as not proven, so nothing is killed.
83
+ */
84
+ /** True when `command` names `serverFile` as a whole argument (not merely as the tail of a longer path). */
85
+ export function commandNamesServer(command, serverFile) {
86
+ const escaped = serverFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ return new RegExp(`(^|\\s)${escaped}(\\s|$)`).test(command.trim());
88
+ }
89
+
90
+ async function isDashboardListener(pid, port, serverFile) {
91
+ const listing = await run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
92
+ if (listing === null) return false;
93
+ const pids = listing.split('\n').filter(Boolean).map(Number);
94
+ if (pids.length !== 1 || pids[0] !== pid) return false;
95
+ const command = await run('ps', ['-p', String(pid), '-o', 'command=']);
96
+ return command !== null && commandNamesServer(command, serverFile);
97
+ }
98
+
99
+ /** The main checkout's directory (the common git dir's parent), or null outside git. */
100
+ function mainCheckout() {
101
+ try {
102
+ const common = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
103
+ cwd: process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
104
+ }).trim();
105
+ return path.dirname(common);
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Which server.mjs to run: the main checkout's when it has one, so the long-lived server does not depend on a
113
+ * worktree that a later /next-task-finish deletes; otherwise the copy this hook belongs to.
114
+ */
115
+ function serverLocation() {
116
+ const main = mainCheckout();
117
+ const mainServer = main ? path.join(main, ...DASHBOARD_REL, 'server.mjs') : null;
118
+ if (mainServer && existsSync(mainServer)) return { server: mainServer, cwd: main, root: path.dirname(mainServer) };
119
+ return { server: OWN_SERVER, cwd: process.cwd(), root: path.dirname(OWN_SERVER) };
120
+ }
121
+
122
+ /** Exclusive create of the start lock. A lock older than STALE_LOCK_MS (crashed hook) is taken over. */
123
+ function acquireLock(lockFile) {
124
+ for (let attempt = 0; attempt < 2; attempt++) {
125
+ try {
126
+ return openSync(lockFile, 'wx');
127
+ } catch (e) {
128
+ if (e.code !== 'EEXIST') throw e;
129
+ if (!takeOverStaleLock(lockFile)) return null;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Removes a stale lock without racing other hooks: rename is atomic, so the lock file is moved to a unique name
137
+ * and only the hook that moved it inspects it. A fresh lock that was moved by mistake is linked back (link does
138
+ * not overwrite, so a lock someone else created meanwhile is never replaced). Returns true if a stale lock was removed.
139
+ */
140
+ function takeOverStaleLock(lockFile) {
141
+ const parked = `${lockFile}.${process.pid}.${randomUUID()}`;
142
+ try {
143
+ renameSync(lockFile, parked);
144
+ } catch {
145
+ return false; // gone, or moved by another hook
146
+ }
147
+ let stale = false;
148
+ try {
149
+ stale = Date.now() - statSync(parked).mtimeMs >= STALE_LOCK_MS;
150
+ } catch { /* unreadable: treat as not ours */ }
151
+ if (!stale) {
152
+ try { linkSync(parked, lockFile); } catch { /* someone else created a lock meanwhile */ }
153
+ }
154
+ rmSync(parked, { force: true });
155
+ return stale;
156
+ }
157
+
158
+ const readRecord = (pidFile) => {
159
+ try {
160
+ return JSON.parse(readFileSync(pidFile, 'utf8'));
161
+ } catch {
162
+ return null;
163
+ }
164
+ };
165
+
166
+ const isAlive = (pid) => {
167
+ try {
168
+ process.kill(pid, 0);
169
+ return true;
170
+ } catch (e) {
171
+ return e.code === 'EPERM';
172
+ }
173
+ };
174
+
175
+ /**
176
+ * Records the pid of a freshly spawned server, unless <store>/server.pid already names a live server on the same
177
+ * port (then that server stays recorded). Returns true when the record was written.
178
+ */
179
+ export async function recordSpawnedPid(pidFile, record) {
180
+ const current = readRecord(pidFile);
181
+ if (current && current.port === record.port && Number.isInteger(current.pid) && isAlive(current.pid)
182
+ && (await probe(record.port, LOOP_PROBE_MS)).state === 'ok') {
183
+ return false;
184
+ }
185
+ writeFileSync(pidFile, JSON.stringify(record));
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * Spawns the detached server and records its pid at once, before any health wait: a server that is still starting
191
+ * when the hook stops waiting must stay stoppable (the next session or a test cleanup reads this record).
192
+ */
193
+ async function startServer({ server, cwd, root }, { logFile, pidFile, port }) {
194
+ const logFd = openSync(logFile, 'a');
195
+ let child;
196
+ try {
197
+ child = spawn(process.execPath, [server], { cwd, env: { ...process.env, PIPELINE_DASHBOARD_PORT: String(port) }, detached: true, stdio: ['ignore', logFd, logFd] });
198
+ } finally {
199
+ closeSync(logFd);
200
+ }
201
+ const spawned = { child, exited: false, answered: false };
202
+ if (child.pid) await recordSpawnedPid(pidFile, { pid: child.pid, port, root });
203
+ // A child that exits clears only its own record, and only if it never answered health: a process that served
204
+ // is a real server, and its record is not this hook's to drop.
205
+ const forget = () => {
206
+ spawned.exited = true;
207
+ if (spawned.answered) return;
208
+ if (readRecord(pidFile)?.pid === child.pid) rmSync(pidFile, { force: true });
209
+ };
210
+ child.once('exit', forget);
211
+ child.once('error', forget);
212
+ child.unref();
213
+ return spawned;
214
+ }
215
+
216
+ /** Probes until the server answers ok, the spawned child exits, or WAIT_MS passes. Returns the last probe. */
217
+ async function waitForServer(port, spawned) {
218
+ const deadline = Date.now() + WAIT_MS;
219
+ for (;;) {
220
+ const last = await probe(port, LOOP_PROBE_MS);
221
+ if (last.state === 'ok') {
222
+ if (spawned) spawned.answered = true;
223
+ return { state: 'ok', last };
224
+ }
225
+ if (spawned?.exited || Date.now() >= deadline) return { state: 'failed', last };
226
+ await sleep(100);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Stops the server recorded in <store>/server.pid, but only when the record matches the answering server exactly
232
+ * (same port and same checkout root). Returns true once the port is free.
233
+ */
234
+ async function stopRecordedServer(pidFile, port, root) {
235
+ const record = readRecord(pidFile);
236
+ if (!record || record.port !== port || record.root !== root || !Number.isInteger(record.pid) || record.pid < 1) return false;
237
+ // The command line must name this record's own server file (<root>/server.mjs): another repo's dashboard, or
238
+ // a developer's own, never matches, so one repo's hook can only ever stop the server it started itself.
239
+ if (!(await isDashboardListener(record.pid, port, path.join(String(record.root), 'server.mjs')))) return false;
240
+ try {
241
+ process.kill(record.pid, 'SIGTERM');
242
+ } catch {
243
+ return false;
244
+ }
245
+ const deadline = Date.now() + STOP_WAIT_MS;
246
+ while (Date.now() < deadline) {
247
+ if ((await probe(port, LOOP_PROBE_MS)).state === 'free') {
248
+ rmSync(pidFile, { force: true });
249
+ return true;
250
+ }
251
+ await sleep(50);
252
+ }
253
+ return false;
254
+ }
255
+
256
+ async function main() {
257
+ const port = resolvePort(path.join(HERE, '..'));
258
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return;
259
+ const url = `http://127.0.0.1:${port}`;
260
+
261
+ const first = await probe(port, PROBE_MS);
262
+ if (first.state === 'foreign') return emit(inUse(port));
263
+ if (first.state === 'ok' && !isMissingCheckout(first.root)) return;
264
+
265
+ const storeDir = resolveStoreDir({ cwd: process.cwd() });
266
+ mkdirSync(storeDir, { recursive: true });
267
+ const pidFile = path.join(storeDir, 'server.pid');
268
+ const logFile = path.join(storeDir, 'server.log');
269
+ if (first.state === 'ok') {
270
+ // Healthy server whose checkout was deleted: replace it, but only the process we recorded for this port and root.
271
+ if (!(await stopRecordedServer(pidFile, port, first.root))) return emit(staleInUse(port));
272
+ }
273
+
274
+ // Only the session holding the lock spawns; a concurrent session waits for that server instead.
275
+ const lockFile = path.join(storeDir, 'server.starting');
276
+ const lock = acquireLock(lockFile);
277
+ if (lock === null) {
278
+ const r = await waitForServer(port, null);
279
+ if (r.state === 'ok') return emit(`Pipeline dashboard: ${url}`);
280
+ return emit(r.last.state === 'foreign' ? inUse(port) : couldNotStart(logFile));
281
+ }
282
+ let r;
283
+ try {
284
+ const spawned = await startServer(serverLocation(), { logFile, pidFile, port });
285
+ r = await waitForServer(port, spawned);
286
+ if (r.state === 'ok') return emit(`Pipeline dashboard: ${url}`);
287
+ } finally {
288
+ closeSync(lock);
289
+ rmSync(lockFile, { force: true });
290
+ }
291
+ emit(r.last.state === 'foreign' ? inUse(port) : couldNotStart(logFile));
292
+ }
293
+
294
+ // Runs only as the CLI entry (tests import the helpers). Let the process end on its own (no process.exit, so stdout
295
+ // is never cut short); nothing here keeps the loop alive.
296
+ const isEntry = (() => {
297
+ try {
298
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
299
+ } catch {
300
+ return false;
301
+ }
302
+ })();
303
+ if (isEntry) {
304
+ main().catch(() => { /* never fail the session */ }).finally(() => { process.exitCode = 0; });
305
+ }
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // SubagentStop hook: appends one `agent` event (duration, tokens, estimated cost) to the active pipeline run.
3
+ // Input (stdin JSON, Claude Code hooks docs): cwd, agent_id, agent_type, agent_transcript_path.
4
+ // Never blocks or fails the session: exit 0 always, nothing on stdout.
5
+ import { readFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { findRunByCwd, recordEvent, resolveStoreDir } from '../record.mjs';
9
+
10
+ const PRICING_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'pricing.json');
11
+ const TOKEN_FIELDS = {
12
+ input: 'input_tokens', output: 'output_tokens',
13
+ cacheRead: 'cache_read_input_tokens', cacheWrite: 'cache_creation_input_tokens',
14
+ };
15
+
16
+ export function loadPricing(file = PRICING_FILE) {
17
+ try { return JSON.parse(readFileSync(file, 'utf8')).models ?? {}; } catch { return {}; }
18
+ }
19
+
20
+ const BEAD = /\bbead\b\s*:?\s*([a-z][a-z0-9]*-[a-z0-9]+(?:\.[0-9]+)*)\b/i;
21
+
22
+ function promptText(content) {
23
+ if (typeof content === 'string') return content;
24
+ if (Array.isArray(content)) return content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n');
25
+ return '';
26
+ }
27
+
28
+ const num = (v) => (Number.isFinite(v) && v >= 0 ? v : 0);
29
+
30
+ /** Parses a sub-agent transcript (JSONL). Bad lines are skipped. Returns the event fields and the transcript cwd. */
31
+ export function summarizeTranscript(text, pricing = loadPricing()) {
32
+ const messages = new Map(); // message id -> { model, usage } (streamed lines repeat the id: keep the max per field)
33
+ let first = null;
34
+ let last = null;
35
+ let cwd = null;
36
+ let anon = 0;
37
+ let bead = null;
38
+ let seenPrompt = false;
39
+ for (const line of text.split('\n')) {
40
+ if (!line.trim()) continue;
41
+ let o;
42
+ try { o = JSON.parse(line); } catch { continue; }
43
+ if (!o || typeof o !== 'object') continue;
44
+ const t = Date.parse(o.timestamp);
45
+ if (Number.isFinite(t)) {
46
+ if (first === null || t < first) first = t;
47
+ if (last === null || t > last) last = t;
48
+ }
49
+ if (!cwd && typeof o.cwd === 'string') cwd = o.cwd;
50
+ const m = o.message;
51
+ if (o.type === 'user' && !seenPrompt && m) {
52
+ seenPrompt = true; // only the first user prompt names the bead
53
+ bead = BEAD.exec(promptText(m.content))?.[1] ?? null;
54
+ }
55
+ if (o.type !== 'assistant' || !m || typeof m.usage !== 'object' || m.usage === null) continue;
56
+ const id = typeof m.id === 'string' ? m.id : `anon-${anon++}`;
57
+ const cur = messages.get(id) ?? { model: null, usage: {} };
58
+ if (typeof m.model === 'string' && m.model && m.model !== '<synthetic>') cur.model = m.model;
59
+ for (const f of Object.values(TOKEN_FIELDS)) cur.usage[f] = Math.max(num(cur.usage[f]), num(m.usage[f]));
60
+ messages.set(id, cur);
61
+ }
62
+
63
+ const fields = {};
64
+ if (first !== null) fields.durationMs = last - first;
65
+ if (bead) fields.bead = bead;
66
+ if (messages.size === 0) return { fields, cwd };
67
+
68
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
69
+ let cost = 0;
70
+ let priced = true;
71
+ let model = null;
72
+ for (const msg of messages.values()) {
73
+ for (const [k, f] of Object.entries(TOKEN_FIELDS)) totals[k] += msg.usage[f] ?? 0;
74
+ if (msg.model) model = msg.model;
75
+ if (Object.values(msg.usage).every((v) => v === 0)) continue; // zero usage costs nothing, whatever its model
76
+ const p = msg.model ? pricing[msg.model] : null;
77
+ if (!p) { priced = false; continue; }
78
+ for (const k of Object.keys(totals)) cost += ((msg.usage[TOKEN_FIELDS[k]] ?? 0) * num(p[k])) / 1e6;
79
+ }
80
+ if (model) fields.model = model;
81
+ Object.assign(fields, {
82
+ tokensIn: totals.input, tokensOut: totals.output,
83
+ tokensCacheRead: totals.cacheRead, tokensCacheWrite: totals.cacheWrite,
84
+ });
85
+ if (priced) fields.cost = Number(cost.toFixed(6)); // unknown model: no cost field, never 0
86
+ return { fields, cwd };
87
+ }
88
+
89
+ /** Handles one hook input. Returns the recorded event or null (no-op). May throw; main() swallows. */
90
+ export function handle(input, { env = process.env, pricing = loadPricing() } = {}) {
91
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
92
+ const file = input.agent_transcript_path;
93
+ if (typeof file !== 'string' || !file) return null;
94
+ const { fields, cwd: transcriptCwd } = summarizeTranscript(readFileSync(file, 'utf8'), pricing);
95
+
96
+ let run = null;
97
+ let storeDir = null;
98
+ for (const cwd of [input.cwd, transcriptCwd]) {
99
+ if (typeof cwd !== 'string' || !cwd) continue;
100
+ try {
101
+ storeDir = resolveStoreDir({ cwd, env });
102
+ run = findRunByCwd(cwd, { storeDir });
103
+ } catch { run = null; }
104
+ if (run) break;
105
+ }
106
+ if (!run) return null;
107
+
108
+ const event = {};
109
+ if (typeof input.agent_id === 'string' && input.agent_id) event.agentId = input.agent_id;
110
+ if (typeof input.agent_type === 'string' && input.agent_type) event.agentType = input.agent_type;
111
+ return recordEvent(storeDir, run.id, 'agent', { ...event, ...fields });
112
+ }
113
+
114
+ function readStdin() {
115
+ try { return readFileSync(0, 'utf8'); } catch { return ''; }
116
+ }
117
+
118
+ export function main() {
119
+ try {
120
+ handle(JSON.parse(readStdin()));
121
+ } catch (err) {
122
+ console.error(`pipeline-subagent-stop: ${err?.message ?? err}`);
123
+ }
124
+ }
125
+
126
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
127
+ main();
128
+ process.exitCode = 0;
129
+ }
@@ -0,0 +1,52 @@
1
+ // CI status for a pushed SHA via `gh run list --commit <sha>`, cached per SHA (only success is final: failed runs get re-run).
2
+ import { execFile } from 'node:child_process';
3
+
4
+ const SHA = /^[0-9a-f]{7,40}$/i;
5
+ const BAD = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'action_required', 'stale']);
6
+
7
+ export function isSha(v) { return typeof v === 'string' && SHA.test(v); }
8
+
9
+ /** Default gh runner: execFile with an argument list, no shell. Rejects when gh is missing or fails. */
10
+ export function defaultGhRunner(args) {
11
+ return new Promise((resolve, reject) => {
12
+ execFile('gh', args, { timeout: 10_000, maxBuffer: 1024 * 1024 }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
13
+ });
14
+ }
15
+
16
+ export function summarize(runs) {
17
+ if (runs.length === 0) return 'none';
18
+ if (runs.some((r) => r.status !== 'completed')) return 'pending';
19
+ if (runs.some((r) => BAD.has(r.conclusion))) return 'failure';
20
+ return 'success';
21
+ }
22
+
23
+ export function createCiLookup({ ghRunner = defaultGhRunner, refreshMs = 15_000, now = Date.now } = {}) {
24
+ const cache = new Map(); // sha -> { at, value, inflight }
25
+
26
+ async function fetchSha(sha) {
27
+ try {
28
+ const out = await ghRunner(['run', 'list', '--commit', sha, '--json', 'status,conclusion,url,name']);
29
+ const parsed = JSON.parse(out);
30
+ if (!Array.isArray(parsed)) throw new Error('unexpected gh output');
31
+ const runs = parsed.map((r) => ({
32
+ name: String(r.name ?? ''), status: String(r.status ?? ''), conclusion: r.conclusion ? String(r.conclusion) : null, url: String(r.url ?? ''),
33
+ }));
34
+ return { sha, state: summarize(runs), runs };
35
+ } catch {
36
+ return { sha, state: 'unknown', runs: [] };
37
+ }
38
+ }
39
+
40
+ /** Returns { sha, state, runs }; state is unknown when gh is missing/fails or the sha is not a plain hex string. */
41
+ async function lookup(sha) {
42
+ if (!isSha(sha)) return { sha: sha ?? null, state: 'unknown', runs: [] };
43
+ const hit = cache.get(sha);
44
+ const final = hit?.value && (hit.value.state === 'success');
45
+ if (hit?.inflight) return hit.inflight;
46
+ if (hit && (final || now() - hit.at < refreshMs)) return hit.value;
47
+ const inflight = fetchSha(sha).then((value) => { cache.set(sha, { at: now(), value }); return value; });
48
+ cache.set(sha, { ...(hit ?? { at: 0 }), inflight });
49
+ return inflight;
50
+ }
51
+ return { lookup };
52
+ }
@@ -0,0 +1,105 @@
1
+ // Derives the run timeline (steps, children, reviews, tests, totals) from a run's events.
2
+ const AGENT_SUMS = ['durationMs', 'tokensIn', 'tokensOut', 'tokensCacheRead', 'tokensCacheWrite', 'cost'];
3
+
4
+ const ms = (a, b) => {
5
+ const d = Date.parse(b) - Date.parse(a);
6
+ return Number.isFinite(d) ? d : null;
7
+ };
8
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
9
+
10
+ /**
11
+ * Sums agent stats. `cost` is null (never 0 or a partial sum) when there are no agents or any agent
12
+ * lacks a numeric cost (unknown model): an unknown cost must not look like a cheap one.
13
+ */
14
+ function sumAgents(agents) {
15
+ const out = { count: agents.length };
16
+ for (const k of AGENT_SUMS) out[k] = agents.reduce((s, a) => s + num(a[k]), 0);
17
+ const known = agents.length > 0 && agents.every((a) => typeof a.cost === 'number' && Number.isFinite(a.cost));
18
+ // Round away binary float noise (0.1 + 0.2).
19
+ out.cost = known ? Math.round(out.cost * 1e6) / 1e6 : null;
20
+ return out;
21
+ }
22
+
23
+ export function deriveTimeline(run, events, now = Date.now()) {
24
+ const steps = [];
25
+ const open = new Map(); // step + wave -> stack of open step entries
26
+ const key = (step, wave) => `${step}\0${wave ?? ''}`;
27
+ const sha = (e) => (e.sha ? { sha: e.sha } : {});
28
+ for (const e of events) {
29
+ if (e.type === 'step-start') {
30
+ const entry = { step: e.step, wave: e.wave ?? null, start: e.ts, end: null, durationMs: null };
31
+ steps.push(entry);
32
+ const k = key(e.step, e.wave);
33
+ if (!open.has(k)) open.set(k, []);
34
+ open.get(k).push(entry);
35
+ } else if (e.type === 'step-end') {
36
+ let entry = open.get(key(e.step, e.wave))?.pop();
37
+ if (!entry && e.wave == null) {
38
+ // End without a wave: close the latest open entry of that step.
39
+ const stacks = [...open.entries()].filter(([k, v]) => k.startsWith(`${e.step}\0`) && v.length);
40
+ entry = stacks.length ? stacks[stacks.length - 1][1].pop() : undefined;
41
+ }
42
+ if (entry) {
43
+ entry.end = e.ts;
44
+ entry.durationMs = ms(entry.start, e.ts);
45
+ Object.assign(entry, sha(e));
46
+ } else {
47
+ steps.push({ step: e.step, wave: e.wave ?? null, start: null, end: e.ts, durationMs: null, ...sha(e) });
48
+ }
49
+ }
50
+ }
51
+
52
+ const children = [];
53
+ const byBead = new Map();
54
+ for (const e of events) {
55
+ if (e.type === 'child-start') {
56
+ const c = {
57
+ bead: e.bead, model: e.model ?? null, wave: e.wave ?? null, start: e.ts, end: null,
58
+ durationMs: null, result: null, redBeforeGreen: null,
59
+ };
60
+ children.push(c);
61
+ byBead.set(e.bead, c);
62
+ } else if (e.type === 'child-end') {
63
+ let c = byBead.get(e.bead);
64
+ if (!c) {
65
+ c = { bead: e.bead, model: e.model ?? null, wave: e.wave ?? null, start: null, end: null, durationMs: null, result: null, redBeforeGreen: null };
66
+ children.push(c);
67
+ byBead.set(e.bead, c);
68
+ }
69
+ c.end = e.ts;
70
+ c.durationMs = c.start ? ms(c.start, e.ts) : null;
71
+ c.result = e.result ?? null;
72
+ c.redBeforeGreen = e.redBeforeGreen ?? null;
73
+ if (!c.model && e.model) c.model = e.model;
74
+ }
75
+ }
76
+
77
+ const agentEvents = events.filter((e) => e.type === 'agent');
78
+ const perChild = new Map(children.map((c) => [c, []]));
79
+ for (const a of agentEvents) {
80
+ let owner = a.bead ? byBead.get(a.bead) : undefined;
81
+ if (!owner) {
82
+ // No bead on the event: attribute to the child whose window contains it.
83
+ owner = children.find((c) => c.start && a.ts >= c.start && (!c.end || a.ts <= c.end));
84
+ }
85
+ if (owner) perChild.get(owner).push(a);
86
+ }
87
+ for (const c of children) c.agents = sumAgents(perChild.get(c));
88
+
89
+ const reviews = events.filter((e) => e.type === 'review')
90
+ .map((e) => ({ ts: e.ts, round: e.round ?? null, verdict: e.verdict ?? null, mustFix: e.mustFix ?? 0 }));
91
+ const tests = events.filter((e) => e.type === 'tests')
92
+ .map((e) => ({ ts: e.ts, scope: e.scope ?? null, passed: e.passed ?? 0, failed: e.failed ?? 0, duration: e.duration ?? null }));
93
+
94
+ const all = sumAgents(agentEvents);
95
+ const end = run?.ended ? Date.parse(run.ended) : now;
96
+ const start = run?.started ? Date.parse(run.started) : NaN;
97
+ const totals = {
98
+ agents: all.count, tokensIn: all.tokensIn, tokensOut: all.tokensOut,
99
+ tokensCacheRead: all.tokensCacheRead, tokensCacheWrite: all.tokensCacheWrite, cost: all.cost,
100
+ durationMs: Number.isFinite(start) && Number.isFinite(end) ? end - start : null,
101
+ };
102
+
103
+ const pushed = [...steps].reverse().find((s) => s.step === 'push' && s.sha)?.sha ?? null;
104
+ return { steps, children, reviews, tests, totals, sha: pushed };
105
+ }
@@ -0,0 +1,34 @@
1
+ // Dashboard settings written by `wavework init/update` into ../config.json: { port, tokensFile }.
2
+ // The runtime never reads .claude/wavework.json, so it works in any checkout that has this folder.
3
+ // Without config.json (or with an unreadable one) the defaults apply.
4
+ import { readFileSync } from 'node:fs';
5
+ import { execFileSync } from 'node:child_process';
6
+ import path from 'node:path';
7
+
8
+ export const DEFAULT_PORT = 4690;
9
+
10
+ /** `{ port, tokensFile }` from `<dashboardDir>/config.json`; defaults for anything missing or invalid. */
11
+ export function readSettings(dashboardDir) {
12
+ let raw = {};
13
+ try { raw = JSON.parse(readFileSync(path.join(dashboardDir, 'config.json'), 'utf8')); } catch { /* defaults */ }
14
+ const port = Number.isInteger(raw?.port) && raw.port >= 1024 && raw.port <= 65535 ? raw.port : DEFAULT_PORT;
15
+ const tokensFile = typeof raw?.tokensFile === 'string' && raw.tokensFile !== '' ? raw.tokensFile : null;
16
+ return { port, tokensFile };
17
+ }
18
+
19
+ /** PIPELINE_DASHBOARD_PORT wins over config.json. Port 0 (ephemeral) is only meant for tests. */
20
+ export function resolvePort(dashboardDir, env = process.env) {
21
+ if (env.PIPELINE_DASHBOARD_PORT !== undefined) return Number(env.PIPELINE_DASHBOARD_PORT);
22
+ return readSettings(dashboardDir).port;
23
+ }
24
+
25
+ /** Repo directory name for the UI title: the main checkout (parent of the git common dir), else the checkout root. */
26
+ export function projectName(root) {
27
+ try {
28
+ const common = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
29
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
30
+ }).trim();
31
+ if (common) return path.basename(path.dirname(common));
32
+ } catch { /* not a git checkout */ }
33
+ return path.basename(path.resolve(root));
34
+ }
@@ -0,0 +1,21 @@
1
+ // Safe lookup of the project's own tokens CSS (config `dashboard.tokensFile`).
2
+ import { realpathSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Absolute real path of `rel` inside `root`, or null. Refuses anything that is not a plain repo-relative
7
+ * path (absolute, `..`, `.` or empty segments, backslash, NUL) and anything whose real path (symlinks
8
+ * resolved, also in parent directories) leaves the real repo root or is not a regular file.
9
+ */
10
+ export function resolveTokensFile(root, rel) {
11
+ if (typeof rel !== 'string' || rel === '' || rel.includes('\0') || rel.includes('\\')) return null;
12
+ if (!rel.endsWith('.css')) return null; // config.json can be edited by hand: the suffix is enforced here too
13
+ if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) return null;
14
+ if (rel.split('/').some((s) => s === '' || s === '.' || s === '..')) return null;
15
+ try {
16
+ const realRoot = realpathSync(root);
17
+ const real = realpathSync(path.join(realRoot, ...rel.split('/')));
18
+ if (!real.startsWith(realRoot + path.sep) || !statSync(real).isFile()) return null;
19
+ return real;
20
+ } catch { return null; }
21
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "unit": "USD per million tokens",
3
+ "source": "claude-api skill model table (cached 2026-10-06) and prompt-caching rules: cache read as listed (Haiku 5.5: 0.1x input), 5-minute cache write 1.25x input",
4
+ "note": "claude-haiku-5-5 uses the rate card for prompts up to 100K tokens. A model missing here gets no cost.",
5
+ "models": {
6
+ "claude-opus-5-5": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 },
7
+ "claude-sonnet-5-5": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5 },
8
+ "claude-haiku-5-5": { "input": 0.1, "output": 0.5, "cacheRead": 0.01, "cacheWrite": 0.125 },
9
+ "claude-fable-5-1": { "input": 10, "output": 50, "cacheRead": 0.25, "cacheWrite": 12.5 }
10
+ }
11
+ }