@skyf0xx/hedgehog 3.0.4 → 3.0.5

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.
package/README.md CHANGED
@@ -88,15 +88,14 @@ Artifact
88
88
 
89
89
  ### Anything else
90
90
 
91
- A CLI, a library, a browser extension, a data pipeline, a compiler a
92
- project fitting neither shape gets its own build order, designed from
93
- your planning documents at intake rather than chosen from a menu. Run
94
- `init` with no core flag: planning intake names the system shape, picks
91
+ A CLI, a library, a browser extension, a data pipeline, etc. fitting neither shape gets its own build order, designed at intake rather than chosen from a menu.
92
+
93
+ Run `init` with no core flag: planning intake names the system shape, picks
95
94
  the stack, derives the layers, and locks them to `.hedgehog/core.yaml`,
96
95
  then generates that workspace and builds it one verified layer at a time.
97
96
 
98
- The layers are bespoke, the enforcement is the same ordered steps,
99
- scoped file access, a verification command per layer, one commit each.
97
+ The enforcement remains the same: ordered steps,
98
+ scoped file access and a verification command per layer.
100
99
 
101
100
  ![Why Hedgehog works: a different way to build with AI, comparing traditional AI workflow to Hedgehog](https://raw.githubusercontent.com/skyf0xx/hedgehog/master/docs/images/why.png)
102
101
 
@@ -133,6 +132,24 @@ This refreshes `.claude/agents/` and `.claude/skills/` only. It never
133
132
  touches `CLAUDE.md`, the build graph, the core workspace, or
134
133
  `skills/BMAD`, since those carry project-specific or write-once content.
135
134
 
135
+ To see the build graph:
136
+
137
+ ``` bash
138
+ npx @skyf0xx/hedgehog graph
139
+ ```
140
+
141
+ Starts a small local server and opens a live, read-only diagram of every
142
+ task and its dependencies — one node per task, coloured by lifecycle
143
+ status, laid out top-to-bottom by dependency order. Click a task to see
144
+ its objective, verify command, and commit message; click empty canvas to
145
+ close it. The page polls for changes, so it keeps updating on its own as
146
+ `hedgehog verify` moves tasks through their lifecycle — no re-running the
147
+ command or reloading the page. Running `graph` again while a server is
148
+ already up reuses it instead of starting a second one. Pass `--no-open`
149
+ to start (or reuse) the server and print its URL instead of launching a
150
+ browser. `hedgehog plan` opens the same live view automatically whenever
151
+ it compiles new tasks.
152
+
136
153
  ## Why Hedgehog
137
154
 
138
155
  Most AI coding tools improve prompting.
package/bin/cli.mjs CHANGED
@@ -15,6 +15,7 @@ import { constants } from 'node:fs';
15
15
  import { DatabaseSync } from 'node:sqlite';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { dirname, join, relative, resolve } from 'node:path';
18
+ import { spawn } from 'node:child_process';
18
19
  import { dbInit, DB_PATH } from '../src/db/init.mjs';
19
20
  import { loadCore } from '../src/db/core.mjs';
20
21
  import { planTasks } from '../src/db/plan.mjs';
@@ -197,12 +198,15 @@ ${bold('Usage')}
197
198
  npx @skyf0xx/hedgehog init --force overwrite existing files
198
199
  npx @skyf0xx/hedgehog update refresh .claude/agents + .claude/skills
199
200
  npx @skyf0xx/hedgehog db init create .hedgehog/hedgehog.db if absent
200
- npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
201
+ npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies,
202
+ then open the build graph if anything compiled
201
203
  npx @skyf0xx/hedgehog intent add [flags] add an intent (rules/requirements/dependencies)
202
204
  npx @skyf0xx/hedgehog intent add --file <path> add an intent from a JSON file
203
205
  npx @skyf0xx/hedgehog next print the task packet for one ready task
204
206
  npx @skyf0xx/hedgehog verify <task-id> run scope + verify checks, commit on pass
205
207
  npx @skyf0xx/hedgehog status graph overview: counts by status, ready list
208
+ npx @skyf0xx/hedgehog graph start (or reuse) the live graph server and open it
209
+ npx @skyf0xx/hedgehog graph --no-open start (or reuse) the server; print the URL instead
206
210
  npx @skyf0xx/hedgehog why <path> provenance chain for a file
207
211
  npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
208
212
  npx @skyf0xx/hedgehog friction list list logged friction, oldest first
@@ -424,6 +428,18 @@ async function planCommand() {
424
428
  console.log(
425
429
  `\n${green(bold('Plan complete.'))} ${dim(`${result.compiled.length} intent(s) compiled, ${result.skipped.length} skipped`)}\n`,
426
430
  );
431
+
432
+ // Only worth opening when this run actually changed the graph's shape
433
+ // — a plan run that compiled nothing (every intent already had tasks)
434
+ // would just re-open what's already open. planTasks's own db handle is
435
+ // closed by this point: the graph server opens its own connection in a
436
+ // separate process, and holding two write-capable handles on the same
437
+ // sqlite file across that handoff invites lock contention for no
438
+ // benefit.
439
+ if (result.compiled.length > 0) {
440
+ const { port } = await startOrReuseGraphServer();
441
+ openInBrowser(`http://localhost:${port}`);
442
+ }
427
443
  }
428
444
 
429
445
  // Parses `hedgehog intent add` args into the same record shape
@@ -648,6 +664,133 @@ async function statusCommand() {
648
664
  console.log(formatStatus(result));
649
665
  }
650
666
 
667
+ const GRAPH_PIDFILE_PATH = '.hedgehog/graph-server.json';
668
+ const GRAPH_SERVER_MODULE = join(PKG_ROOT, 'src/db/graph-server.mjs');
669
+ const GRAPH_TEMPLATE_PATH = join(PKG_ROOT, 'src/templates/graph.html');
670
+
671
+ // Opens a URL/file with the OS default handler — the same mechanism
672
+ // `open` (macOS), `xdg-open` (Linux), and `start` (Windows) provide,
673
+ // chosen per-platform so this stays a zero-dependency CLI rather than
674
+ // reaching for an npm package to do what the OS already does.
675
+ function openInBrowser(url) {
676
+ const platform = process.platform;
677
+ const cmd =
678
+ platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
679
+ const args = platform === 'win32' ? ['', url] : [url];
680
+ spawn(cmd, args, { detached: true, stdio: 'ignore', shell: platform === 'win32' }).unref();
681
+ }
682
+
683
+ // True if `pid` names a live process. Sending signal 0 performs the
684
+ // existence/permission check without actually signalling anything — the
685
+ // standard POSIX idiom `kill -0` follows, and Node exposes it the same
686
+ // way via process.kill.
687
+ function isProcessAlive(pid) {
688
+ try {
689
+ process.kill(pid, 0);
690
+ return true;
691
+ } catch {
692
+ return false;
693
+ }
694
+ }
695
+
696
+ // Returns the port of a running graph server for this project, starting
697
+ // one if none is live. Both `plan` (auto-open after scoping) and `graph`
698
+ // (explicit request) call this rather than each managing their own
699
+ // server, so a project only ever has one live server no matter which
700
+ // command a person or agent happens to run — re-running `plan` after
701
+ // `graph` is already open reuses the same tab's server instead of
702
+ // spawning a second one bound to a different port.
703
+ async function startOrReuseGraphServer() {
704
+ const pidfilePath = join(DEST_ROOT, GRAPH_PIDFILE_PATH);
705
+
706
+ if (await exists(pidfilePath)) {
707
+ try {
708
+ const { pid, port } = JSON.parse(await readFile(pidfilePath, 'utf8'));
709
+ if (isProcessAlive(pid)) return { port, reused: true };
710
+ } catch {
711
+ // Corrupt or half-written pidfile from a killed server — fall
712
+ // through and start a fresh one rather than failing the command.
713
+ }
714
+ await rm(pidfilePath, { force: true });
715
+ }
716
+
717
+ const child = spawn(
718
+ process.execPath,
719
+ [GRAPH_SERVER_MODULE, join(DEST_ROOT, DB_PATH), GRAPH_TEMPLATE_PATH, pidfilePath],
720
+ { detached: true, stdio: ['ignore', 'pipe', 'ignore'] },
721
+ );
722
+ child.unref();
723
+
724
+ // Waits for graph-server.mjs's own "LISTENING <port>" line rather than
725
+ // polling the pidfile, so the caller can't race a pidfile that exists
726
+ // but was written a moment before the port was actually bound.
727
+ //
728
+ // child.unref() alone only unrefs the child process handle — the
729
+ // stdout pipe is a separate stream the parent still holds open, and
730
+ // leaving a 'data' listener on it keeps the event loop alive even
731
+ // after this promise resolves (the earlier version of this function
732
+ // hung the parent CLI process for exactly that reason). Explicitly
733
+ // removing every listener and unref()-ing the stream once the port is
734
+ // known lets the parent exit as soon as its own work is done, leaving
735
+ // the detached child running independently.
736
+ const port = await new Promise((resolvePort, rejectPort) => {
737
+ let buf = '';
738
+ function cleanup() {
739
+ child.stdout.off('data', onData);
740
+ child.off('error', onError);
741
+ child.off('exit', onExit);
742
+ child.stdout.unref();
743
+ }
744
+ function onData(chunk) {
745
+ buf += chunk;
746
+ const match = buf.match(/LISTENING (\d+)/);
747
+ if (match) {
748
+ cleanup();
749
+ resolvePort(Number(match[1]));
750
+ }
751
+ }
752
+ function onError(err) {
753
+ cleanup();
754
+ rejectPort(err);
755
+ }
756
+ function onExit(code) {
757
+ if (code !== 0) {
758
+ cleanup();
759
+ rejectPort(new Error(`graph server exited early (code ${code})`));
760
+ }
761
+ }
762
+ child.stdout.on('data', onData);
763
+ child.once('error', onError);
764
+ child.once('exit', onExit);
765
+ });
766
+
767
+ return { port, reused: false };
768
+ }
769
+
770
+ async function graphCommand(args) {
771
+ if (!(await exists(DB_PATH))) {
772
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
773
+ process.exitCode = 1;
774
+ return;
775
+ }
776
+
777
+ const { port, reused } = await startOrReuseGraphServer();
778
+ const url = `http://localhost:${port}`;
779
+ console.log(
780
+ ` ${reused ? dim('reusing') : green('started')} graph server ${dim(`(${url})`)}`,
781
+ );
782
+
783
+ // --no-open covers headless/SSH sessions where there's no local
784
+ // browser to hand a URL to — the server itself is still started (or
785
+ // reused) either way, since a remote person may open the URL manually
786
+ // via port-forwarding.
787
+ if (args.includes('--no-open')) {
788
+ console.log(`\nOpen ${bold(url)} in a browser to view it.`);
789
+ } else {
790
+ openInBrowser(url);
791
+ }
792
+ }
793
+
651
794
  async function whyCommand(args) {
652
795
  const path = args[0];
653
796
  if (!path) {
@@ -809,6 +952,11 @@ async function main() {
809
952
  return;
810
953
  }
811
954
 
955
+ if (cmd === 'graph') {
956
+ await graphCommand(args.slice(1));
957
+ return;
958
+ }
959
+
812
960
  if (cmd === 'why') {
813
961
  await whyCommand(args.slice(1));
814
962
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "3.0.4",
3
+ "version": "3.0.5",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,91 @@
1
+ // The `hedgehog graph` live server: a standalone, detached process (see
2
+ // bin/cli.mjs's startOrReuseGraphServer) that serves the graph viewer
3
+ // page and a fresh /graph.json on every request, so an open browser tab
4
+ // polling /graph.json (src/templates/graph.html) sees task status
5
+ // changes as `hedgehog verify` makes them, without anyone re-running a
6
+ // CLI command or reloading the page.
7
+ //
8
+ // Runs as its own process rather than inline in a CLI command because
9
+ // `hedgehog plan` and `hedgehog graph` both need to exit promptly (the
10
+ // former is typically invoked by an agent, not a human waiting at a
11
+ // terminal) while the graph a human opened stays live. One process
12
+ // serves whichever project invoked it; a second invocation against the
13
+ // same project reuses it (see startOrReuseGraphServer's pidfile check)
14
+ // rather than starting a second server on the same database.
15
+
16
+ import { createServer } from 'node:http';
17
+ import { readFile, writeFile, rm } from 'node:fs/promises';
18
+ import { DatabaseSync } from 'node:sqlite';
19
+ import { buildGraph } from './graph.mjs';
20
+
21
+ // Args: <db-path> <template-path> <pidfile-path>. Plain positional argv
22
+ // rather than a flags parser — this process is only ever spawned by
23
+ // bin/cli.mjs, never invoked directly by a person, so there's no usage
24
+ // text or flag surface to design for.
25
+ const [, , dbPath, templatePath, pidfilePath] = process.argv;
26
+
27
+ if (!dbPath || !templatePath || !pidfilePath) {
28
+ console.error('graph-server.mjs requires <db-path> <template-path> <pidfile-path>');
29
+ process.exit(1);
30
+ }
31
+
32
+ const template = await readFile(templatePath, 'utf8');
33
+
34
+ function loadGraphJson() {
35
+ // Opened and closed per request rather than held open: DatabaseSync is
36
+ // synchronous and local, so the cost is negligible, and it avoids ever
37
+ // serving a stale read against a handle opened before the last
38
+ // `hedgehog verify` committed its transaction.
39
+ const db = new DatabaseSync(dbPath, { readOnly: true });
40
+ try {
41
+ return buildGraph(db);
42
+ } finally {
43
+ db.close();
44
+ }
45
+ }
46
+
47
+ const server = createServer((req, res) => {
48
+ if (req.url === '/graph.json') {
49
+ let body;
50
+ try {
51
+ body = JSON.stringify(loadGraphJson());
52
+ } catch (err) {
53
+ res.writeHead(500, { 'content-type': 'application/json' });
54
+ res.end(JSON.stringify({ error: err.message }));
55
+ return;
56
+ }
57
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
58
+ res.end(body);
59
+ return;
60
+ }
61
+
62
+ if (req.url === '/' || req.url === '/index.html') {
63
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
64
+ res.end(template);
65
+ return;
66
+ }
67
+
68
+ res.writeHead(404);
69
+ res.end('Not found');
70
+ });
71
+
72
+ // Port 0: ask the OS for any free port, so multiple hedgehog projects on
73
+ // one machine never collide. The chosen port is written to the pidfile
74
+ // once bound, which is the only way a caller (or a later invocation
75
+ // checking whether to reuse this server) learns what it is.
76
+ server.listen(0, '127.0.0.1', async () => {
77
+ const { port } = server.address();
78
+ await writeFile(pidfilePath, JSON.stringify({ pid: process.pid, port }));
79
+ // Signals bin/cli.mjs's spawn-and-wait handshake that the server is
80
+ // ready to accept requests, not just that the process has started.
81
+ console.log(`LISTENING ${port}`);
82
+ });
83
+
84
+ async function shutdown() {
85
+ server.close();
86
+ await rm(pidfilePath, { force: true });
87
+ process.exit(0);
88
+ }
89
+
90
+ process.on('SIGINT', shutdown);
91
+ process.on('SIGTERM', shutdown);
@@ -0,0 +1,59 @@
1
+ // `hedgehog graph` — read-only dependency-graph export for the build
2
+ // graph: every task as a node, every `dependencies` row as an edge. See
3
+ // hedgehog-persistent-build-graph.md, "Schema", for the `tasks` and
4
+ // `dependencies` tables this mirrors.
5
+ //
6
+ // This module only shapes rows into { nodes, edges }; layout and
7
+ // rendering live in graph-viewer.mjs's HTML template, not here — the
8
+ // same separation status.mjs keeps between graphStatus() (data) and
9
+ // formatStatus() (text rendering).
10
+
11
+ const ALL_TASKS_SQL = `
12
+ SELECT t.*, i.goal AS intent_goal FROM tasks t
13
+ JOIN intents i ON i.id = t.intent_id
14
+ ORDER BY t.priority, t.id;
15
+ `;
16
+
17
+ const ALL_DEPENDENCIES_SQL = `
18
+ SELECT task_id, depends_on_task_id FROM dependencies;
19
+ `;
20
+
21
+ function loadAllTasks(db) {
22
+ return db.prepare(ALL_TASKS_SQL).all();
23
+ }
24
+
25
+ function loadAllDependencies(db) {
26
+ return db.prepare(ALL_DEPENDENCIES_SQL).all();
27
+ }
28
+
29
+ // Shapes the full build graph into { nodes, edges } for the viewer.
30
+ // Each node carries exactly the fields the viewer displays on click
31
+ // (objective, verify_command, commit_message) plus the fields that drive
32
+ // layout and status colour — nothing the viewer doesn't render.
33
+ export function buildGraph(db) {
34
+ const tasks = loadAllTasks(db);
35
+ const dependencies = loadAllDependencies(db);
36
+
37
+ const nodes = tasks.map((t) => ({
38
+ id: t.id,
39
+ module: t.module,
40
+ layer: t.layer,
41
+ status: t.status,
42
+ objective: t.objective,
43
+ verifyCommand: t.verify_command,
44
+ commitMessage: t.commit_message,
45
+ intentGoal: t.intent_goal,
46
+ }));
47
+
48
+ // Edge direction follows the dependency, not the SQL column order:
49
+ // `depends_on_task_id` must finish before `task_id` can start, so the
50
+ // arrow is drawn from the prerequisite to the dependent — the same
51
+ // direction hedgehog next's BLOCKED DOWNSTREAM walk assumes.
52
+ const edges = dependencies.map((d) => ({
53
+ id: `${d.depends_on_task_id}->${d.task_id}`,
54
+ source: d.depends_on_task_id,
55
+ target: d.task_id,
56
+ }));
57
+
58
+ return { nodes, edges };
59
+ }
@@ -6,6 +6,7 @@ out-tsc
6
6
  .env.local
7
7
  *.log
8
8
  .DS_Store
9
+ .hedgehog/graph-server.json
9
10
 
10
11
  .nx/cache
11
12
  .nx/workspace-data
@@ -7,3 +7,4 @@ dist
7
7
  *.log
8
8
  .DS_Store
9
9
  .idea
10
+ .hedgehog/graph-server.json
@@ -0,0 +1,316 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Hedgehog build graph</title>
6
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
7
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
8
+ <script crossorigin src="https://unpkg.com/reactflow@11/dist/umd/index.js"></script>
9
+ <script crossorigin src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
10
+ <link rel="stylesheet" href="https://unpkg.com/reactflow@11/dist/style.css" />
11
+ <style>
12
+ html, body, #root { height: 100%; margin: 0; }
13
+ body {
14
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
+ background: #0d1117;
16
+ color: #e6edf3;
17
+ }
18
+ .rf-task-node {
19
+ padding: 12px 14px 10px;
20
+ border-radius: 8px;
21
+ border: 1px solid var(--node-border);
22
+ border-top: 5px solid var(--node-border);
23
+ background: var(--node-bg);
24
+ font-size: 13px;
25
+ min-width: 160px;
26
+ cursor: pointer;
27
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
28
+ transition: box-shadow 0.1s ease-out, transform 0.1s ease-out;
29
+ }
30
+ .rf-task-node:hover {
31
+ box-shadow: 0 0 0 1px var(--node-border), 0 4px 10px rgba(0, 0, 0, 0.5);
32
+ transform: translateY(-1px);
33
+ }
34
+ .rf-task-node .id { font-weight: 700; color: #f0f6fc; }
35
+ .rf-task-node .status {
36
+ display: inline-block;
37
+ margin-top: 4px;
38
+ padding: 1px 7px;
39
+ border-radius: 999px;
40
+ font-size: 10px;
41
+ font-weight: 600;
42
+ text-transform: uppercase;
43
+ letter-spacing: 0.04em;
44
+ color: var(--node-status-fg);
45
+ background: var(--node-status-bg);
46
+ }
47
+ #detail-panel {
48
+ position: fixed;
49
+ top: 0;
50
+ right: 0;
51
+ width: 360px;
52
+ height: 100%;
53
+ background: #161b22;
54
+ border-left: 1px solid #30363d;
55
+ padding: 20px;
56
+ box-sizing: border-box;
57
+ overflow-y: auto;
58
+ transform: translateX(100%);
59
+ transition: transform 0.15s ease-out;
60
+ }
61
+ #detail-panel.open { transform: translateX(0); }
62
+ #detail-panel h2 { margin-top: 0; font-size: 16px; }
63
+ #detail-panel .field-label {
64
+ font-size: 11px;
65
+ text-transform: uppercase;
66
+ letter-spacing: 0.04em;
67
+ color: #8b949e;
68
+ margin-top: 16px;
69
+ }
70
+ #detail-panel pre {
71
+ white-space: pre-wrap;
72
+ word-break: break-word;
73
+ background: #0d1117;
74
+ padding: 8px;
75
+ border-radius: 6px;
76
+ font-size: 12px;
77
+ }
78
+ #legend {
79
+ position: fixed;
80
+ bottom: 16px;
81
+ left: 16px;
82
+ background: #161b22;
83
+ border: 1px solid #30363d;
84
+ border-radius: 8px;
85
+ padding: 10px 14px;
86
+ font-size: 12px;
87
+ }
88
+ #legend .row { display: flex; align-items: center; gap: 8px; margin: 3px 0; }
89
+ #legend .swatch { width: 4px; height: 14px; border-radius: 2px; }
90
+ #loading {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ height: 100%;
95
+ color: #8b949e;
96
+ font-size: 14px;
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <div id="root"></div>
102
+ <script>
103
+ (function () {
104
+ const { useState, useEffect, useMemo, useRef, createElement: h } = React;
105
+ const {
106
+ default: ReactFlowCanvas,
107
+ Background,
108
+ Controls,
109
+ MarkerType,
110
+ Handle,
111
+ Position,
112
+ } = window.ReactFlow;
113
+
114
+ // Mirrors status.mjs's TASK_STATUSES lifecycle order and colour intent:
115
+ // grey (not started) -> blue (in progress) -> amber (needs attention) ->
116
+ // green (done) -> red (blocked). Each status gets a distinct hue rather
117
+ // than reusing green for both `ready` and `verified` (the two are
118
+ // different points in the lifecycle and looked identical before).
119
+ const STATUS_STYLE = {
120
+ proposed: { bg: '#282e37', border: '#6e7681', badgeBg: '#6e768133', badgeFg: '#c9d1d9' },
121
+ planned: { bg: '#122a4d', border: '#58a6ff', badgeBg: '#58a6ff33', badgeFg: '#79c0ff' },
122
+ ready: { bg: '#1c3a29', border: '#3fb950', badgeBg: '#3fb95033', badgeFg: '#56d364' },
123
+ implemented: { bg: '#3d2f0a', border: '#d29922', badgeBg: '#d2992233', badgeFg: '#e3b341' },
124
+ verified: { bg: '#123a3c', border: '#39c5cf', badgeBg: '#39c5cf33', badgeFg: '#56d4dd' },
125
+ complete: { bg: '#0f2818', border: '#238636', badgeBg: '#23863633', badgeFg: '#3fb950' },
126
+ failed: { bg: '#3d1418', border: '#f85149', badgeBg: '#f8514933', badgeFg: '#ff7b72' },
127
+ };
128
+
129
+ function layout(nodes, edges) {
130
+ const g = new dagre.graphlib.Graph();
131
+ g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 70 });
132
+ g.setDefaultEdgeLabel(() => ({}));
133
+ const width = 180;
134
+ const height = 56;
135
+ for (const n of nodes) g.setNode(n.id, { width, height });
136
+ for (const e of edges) g.setEdge(e.source, e.target);
137
+ dagre.layout(g);
138
+ return nodes.map((n) => {
139
+ const pos = g.node(n.id);
140
+ return { ...n, position: { x: pos.x - width / 2, y: pos.y - height / 2 } };
141
+ });
142
+ }
143
+
144
+ function TaskNode({ data }) {
145
+ const style = STATUS_STYLE[data.task.status] || STATUS_STYLE.proposed;
146
+ return h(
147
+ 'div',
148
+ {
149
+ className: 'rf-task-node',
150
+ style: {
151
+ '--node-bg': style.bg,
152
+ '--node-border': style.border,
153
+ '--node-status-bg': style.badgeBg,
154
+ '--node-status-fg': style.badgeFg,
155
+ },
156
+ // Selecting a node opens the detail panel; React Flow's own pane
157
+ // click (see App's onPaneClick) closes it again on lose-of-focus,
158
+ // so this only ever needs to open.
159
+ onClick: (e) => {
160
+ e.stopPropagation();
161
+ data.onSelect(data.task.id);
162
+ },
163
+ },
164
+ h(Handle, { type: 'target', position: Position.Top }),
165
+ h('div', { className: 'id' }, data.task.id),
166
+ h('div', null, data.task.layer),
167
+ h('div', { className: 'status' }, data.task.status),
168
+ h(Handle, { type: 'source', position: Position.Bottom }),
169
+ );
170
+ }
171
+
172
+ const nodeTypes = { task: TaskNode };
173
+
174
+ function DetailPanel({ task }) {
175
+ if (!task) return h('div', { id: 'detail-panel' });
176
+ return h(
177
+ 'div',
178
+ { id: 'detail-panel', className: 'open' },
179
+ h('h2', null, task.id),
180
+ h('div', { className: 'field-label' }, 'Intent'),
181
+ h('div', null, task.intentGoal),
182
+ h('div', { className: 'field-label' }, 'Objective'),
183
+ h('div', null, task.objective),
184
+ h('div', { className: 'field-label' }, 'Verify command'),
185
+ h('pre', null, task.verifyCommand),
186
+ h('div', { className: 'field-label' }, 'Commit message'),
187
+ h('pre', null, task.commitMessage),
188
+ );
189
+ }
190
+
191
+ function Legend() {
192
+ return h(
193
+ 'div',
194
+ { id: 'legend' },
195
+ Object.entries(STATUS_STYLE).map(([status, style]) =>
196
+ h(
197
+ 'div',
198
+ { className: 'row', key: status },
199
+ h('span', {
200
+ className: 'swatch',
201
+ style: { background: style.border, boxShadow: `0 0 6px ${style.border}` },
202
+ }),
203
+ h('span', null, status),
204
+ ),
205
+ ),
206
+ );
207
+ }
208
+
209
+ const POLL_INTERVAL_MS = 2000;
210
+
211
+ function App() {
212
+ const [raw, setRaw] = useState(null);
213
+ // Holds the selected task's id, not the task object itself, so the
214
+ // open detail panel always reflects the latest poll (e.g. a status
215
+ // flip from `ready` to `complete` while the panel is open) instead
216
+ // of freezing on whatever the task looked like at click time.
217
+ const [selectedId, setSelectedId] = useState(null);
218
+ // Tracks the last-rendered graph's JSON so a poll that returns
219
+ // identical data (the common case — nothing changed since last
220
+ // tick) skips relayout entirely instead of jittering node positions
221
+ // or resetting the user's pan/zoom on every 2s tick.
222
+ const lastJsonRef = useRef(null);
223
+ const firstLoadRef = useRef(true);
224
+
225
+ useEffect(() => {
226
+ let cancelled = false;
227
+ async function poll() {
228
+ try {
229
+ const res = await fetch('/graph.json', { cache: 'no-store' });
230
+ const data = await res.json();
231
+ if (cancelled) return;
232
+ const json = JSON.stringify(data);
233
+ if (json !== lastJsonRef.current) {
234
+ lastJsonRef.current = json;
235
+ setRaw(data);
236
+ }
237
+ } catch {
238
+ // Server briefly unreachable (e.g. mid-restart) — next tick retries.
239
+ }
240
+ }
241
+ poll();
242
+ const id = setInterval(poll, POLL_INTERVAL_MS);
243
+ return () => {
244
+ cancelled = true;
245
+ clearInterval(id);
246
+ };
247
+ }, []);
248
+
249
+ const nodes = useMemo(() => {
250
+ if (!raw) return [];
251
+ const positioned = layout(raw.nodes, raw.edges);
252
+ return positioned.map((n) => ({
253
+ id: n.id,
254
+ type: 'task',
255
+ position: n.position,
256
+ data: { task: n, onSelect: setSelectedId },
257
+ }));
258
+ }, [raw]);
259
+
260
+ const edges = useMemo(() => {
261
+ if (!raw) return [];
262
+ return raw.edges.map((e) => ({
263
+ ...e,
264
+ markerEnd: { type: MarkerType.ArrowClosed },
265
+ style: { stroke: '#484f58' },
266
+ }));
267
+ }, [raw]);
268
+
269
+ // fitView only on the graph's first paint — a later poll that adds
270
+ // or moves nodes re-lays-out in place without recentering the view
271
+ // the user has already panned or zoomed.
272
+ const shouldFitView = firstLoadRef.current && nodes.length > 0;
273
+ if (shouldFitView) firstLoadRef.current = false;
274
+
275
+ if (!raw) {
276
+ return h('div', { id: 'loading' }, 'Loading build graph…');
277
+ }
278
+
279
+ // Derived fresh from the latest poll every render, rather than the
280
+ // task object captured at click time, so the open panel tracks a
281
+ // status change (e.g. ready -> complete) live instead of freezing.
282
+ // A task removed since selection (id no longer present) closes the
283
+ // panel implicitly, since selectedTask is then undefined/falsy.
284
+ const selectedTask = selectedId
285
+ ? raw.nodes.find((n) => n.id === selectedId)
286
+ : null;
287
+
288
+ return h(
289
+ React.Fragment,
290
+ null,
291
+ h(
292
+ ReactFlowCanvas,
293
+ {
294
+ nodes,
295
+ edges,
296
+ nodeTypes,
297
+ fitView: shouldFitView,
298
+ // Clicking empty canvas is the graph losing focus, same as
299
+ // clicking away from any open panel — close the detail panel.
300
+ // Node clicks stop propagation (see TaskNode) so this never
301
+ // fires for a click that's opening a different task instead.
302
+ onPaneClick: () => setSelectedId(null),
303
+ },
304
+ h(Background, { color: '#21262d' }),
305
+ h(Controls, null),
306
+ ),
307
+ h(Legend),
308
+ h(DetailPanel, { task: selectedTask }),
309
+ );
310
+ }
311
+
312
+ ReactDOM.createRoot(document.getElementById('root')).render(h(App));
313
+ })();
314
+ </script>
315
+ </body>
316
+ </html>