@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 +23 -6
- package/bin/cli.mjs +149 -1
- package/package.json +1 -1
- package/src/db/graph-server.mjs +91 -0
- package/src/db/graph.mjs +59 -0
- package/src/golden-cores/full-stack-app/gitignore.template +1 -0
- package/src/golden-cores/landing-page/gitignore.template +1 -0
- package/src/templates/graph.html +316 -0
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,
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
99
|
-
scoped file access
|
|
97
|
+
The enforcement remains the same: ordered steps,
|
|
98
|
+
scoped file access and a verification command per layer.
|
|
100
99
|
|
|
101
100
|

|
|
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
|
@@ -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);
|
package/src/db/graph.mjs
ADDED
|
@@ -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
|
+
}
|
|
@@ -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>
|