@skyf0xx/hedgehog 3.0.3 → 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
 
@@ -118,11 +117,10 @@ npx @skyf0xx/hedgehog init
118
117
  Then open Claude Code and describe what you want to build.
119
118
 
120
119
  Plain `init` (no core flag) installs the agents, skills, and build graph
121
- only no workspace, no framework, nothing core-specific. Planning
122
- intake designs an opinionated build order and stack for what you
123
- actually describe, then bootstrap lands that workspace for the first
124
- time. Don't pick `--ts-full-stack-app` or `--landing-page` by elimination
125
- when neither actually fits.
120
+ that every core shares. Planning intake designs an opinionated build
121
+ order and stack for what you actually describe, then bootstrap generates
122
+ that workspace. Don't pick `--ts-full-stack-app` or `--landing-page` by
123
+ elimination when neither actually fits.
126
124
 
127
125
  To update:
128
126
 
@@ -134,6 +132,24 @@ This refreshes `.claude/agents/` and `.claude/skills/` only. It never
134
132
  touches `CLAUDE.md`, the build graph, the core workspace, or
135
133
  `skills/BMAD`, since those carry project-specific or write-once content.
136
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
+
137
153
  ## Why Hedgehog
138
154
 
139
155
  Most AI coding tools improve prompting.
package/bin/cli.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // into the current repo, so the discipline travels with the project.
4
4
  //
5
5
  // Usage:
6
- // npx @skyf0xx/hedgehog init no workspace yet — planner picks the core
6
+ // npx @skyf0xx/hedgehog init install; planner picks the core at intake
7
7
  // npx @skyf0xx/hedgehog init --ts-full-stack-app scaffold the full-stack-app core now
8
8
  // npx @skyf0xx/hedgehog init --landing-page scaffold the landing-page core now
9
9
  // npx @skyf0xx/hedgehog init --force overwrite files that already exist
@@ -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';
@@ -77,12 +78,12 @@ async function availableCores() {
77
78
  // and a project can only switch cores before it's bootstrapped anyway.
78
79
  //
79
80
  // `core` is `null` on a deferred install (plain `init`, no explicit
80
- // flag): which core applies hasn't been decided yet, so nothing
81
- // core-specific no golden-core workspace, no filled CLAUDE.md section
82
- // gets written speculatively. `bootstrap` lands the real workspace, for
83
- // whichever core `planner` picks, the first time either way. An explicit
84
- // flag (`--ts-full-stack-app`, `--landing-page`) is a confirmed choice,
85
- // not a guess, so it scaffolds immediately as before.
81
+ // flag): the payload is the shared agents/skills/build-graph only, with
82
+ // which core applies left for `planner` to decide. `bootstrap` lands the
83
+ // golden-core workspace and fills the CLAUDE.md section for whichever
84
+ // core `planner` picks the first time either way. An explicit flag
85
+ // (`--ts-full-stack-app`, `--landing-page`) is a confirmed choice, so it
86
+ // scaffolds that workspace immediately, at install time.
86
87
  function plan(core) {
87
88
  const base = [
88
89
  { type: 'dir', from: 'src/agents', to: '.claude/agents' },
@@ -191,18 +192,21 @@ CLAUDE.md template and an empty build graph (${bold('.hedgehog/hedgehog.db')})
191
192
  into the repo root, so the discipline is committed alongside your code.
192
193
 
193
194
  ${bold('Usage')}
194
- npx @skyf0xx/hedgehog init no workspace yet — planner picks the core
195
+ npx @skyf0xx/hedgehog init install; planner picks the core at intake
195
196
  npx @skyf0xx/hedgehog init --ts-full-stack-app scaffold the full-stack-app core now
196
197
  npx @skyf0xx/hedgehog init --landing-page scaffold the landing-page core now
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
@@ -217,10 +221,10 @@ off to bootstrap.
217
221
  Building something else (a CLI, library, browser extension, data
218
222
  pipeline, desktop app, etc.)? Run plain 'init' with no core flag rather
219
223
  than picking --ts-full-stack-app or --landing-page by elimination — it
220
- installs the agents, skills, and build graph only, nothing core-specific.
221
- The planner agent designs a core at planning intake (hedgehog-core-design)
222
- and bootstrap generates that workspace once it's confirmed. Describe the
223
- actual project and let Phase 0 route it.
224
+ installs the agents, skills, and build graph, the payload every core
225
+ shares. The planner agent designs a core at planning intake
226
+ (hedgehog-core-design) and bootstrap generates that workspace once it's
227
+ confirmed. Describe the actual project and let Phase 0 route it.
224
228
 
225
229
  ${bold('update')} re-copies only .claude/agents and .claude/skills from the
226
230
  installed Hedgehog version, so an already-bootstrapped project can pick up
@@ -245,7 +249,7 @@ async function init({ force, core, explicitCore }) {
245
249
 
246
250
  // Resolve the full list of writes up front so we can detect conflicts
247
251
  // before touching anything. A deferred install (no explicit core) plans
248
- // against `null` — no golden-core workspace, nothing core-specific.
252
+ // against `null` — the shared agents/skills/build-graph payload only.
249
253
  const groups = [];
250
254
  for (const entry of plan(explicitCore ? core : null)) {
251
255
  const files = await plannedFiles(entry);
@@ -321,10 +325,9 @@ async function init({ force, core, explicitCore }) {
321
325
  } else {
322
326
  console.log(
323
327
  dim(
324
- 'Core: not chosen yet. Nothing core-specific landed no workspace,\n' +
325
- 'no framework, no lockfile. planner decides which core applies at\n' +
326
- 'planning intake, then bootstrap lands that core\'s workspace for\n' +
327
- 'the first time.',
328
+ 'Core: not chosen yet this installed the agents, skills, and\n' +
329
+ 'build graph every core shares. planner decides which core applies\n' +
330
+ 'at planning intake, then bootstrap generates that core\'s workspace.',
328
331
  ),
329
332
  );
330
333
  }
@@ -425,6 +428,18 @@ async function planCommand() {
425
428
  console.log(
426
429
  `\n${green(bold('Plan complete.'))} ${dim(`${result.compiled.length} intent(s) compiled, ${result.skipped.length} skipped`)}\n`,
427
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
+ }
428
443
  }
429
444
 
430
445
  // Parses `hedgehog intent add` args into the same record shape
@@ -649,6 +664,133 @@ async function statusCommand() {
649
664
  console.log(formatStatus(result));
650
665
  }
651
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
+
652
794
  async function whyCommand(args) {
653
795
  const path = args[0];
654
796
  if (!path) {
@@ -810,6 +952,11 @@ async function main() {
810
952
  return;
811
953
  }
812
954
 
955
+ if (cmd === 'graph') {
956
+ await graphCommand(args.slice(1));
957
+ return;
958
+ }
959
+
813
960
  if (cmd === 'why') {
814
961
  await whyCommand(args.slice(1));
815
962
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "3.0.3",
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": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bootstrap
3
- description: Use once per invocation, at the start of a new Hedgehog project, to land the workspace for whichever core `planner` selected at Phase 0 — the first real workspace this project gets, since `init` with no explicit core flag lands only agents/skills/the build graph, nothing core-specific. On full-stack-app, that's core (via hedgehog-bootstrap-full-stack-app-core, one pass) then exactly ONE add-on step of the hedgehog-bootstrap skill (0-3 steps depending on planning intake scope), handing off to a fresh instance of itself for the next add-on step. On landing-page, that's a single pass of hedgehog-bootstrap-landing-page-core with no add-on steps — one invocation, done. On an authored core (`.hedgehog/core.yaml` present), that's a single pass of hedgehog-bootstrap-authored-core, generating the workspace live for the stack `hedgehog-core-design` chose. Not for per-phase/per-module work — that's this core's own loop skill and its agents. Skip entirely if the core's workspace already exists (nx.json for full-stack-app, astro.config.mjs for landing-page, or the matching `feat(<id>): workspace` commit for an authored core).
3
+ description: Use once per invocation, at the start of a new Hedgehog project, to land the workspace for whichever core `planner` selected at Phase 0 — the first real workspace this project gets, since `init` with no explicit core flag lands the shared agents/skills/build-graph payload and leaves the workspace to bootstrap. On full-stack-app, that's core (via hedgehog-bootstrap-full-stack-app-core, one pass) then exactly ONE add-on step of the hedgehog-bootstrap skill (0-3 steps depending on planning intake scope), handing off to a fresh instance of itself for the next add-on step. On landing-page, that's a single pass of hedgehog-bootstrap-landing-page-core with no add-on steps — one invocation, done. On an authored core (`.hedgehog/core.yaml` present), that's a single pass of hedgehog-bootstrap-authored-core, generating the workspace live for the stack `hedgehog-core-design` chose. Not for per-phase/per-module work — that's this core's own loop skill and its agents. Skip entirely if the core's workspace already exists (nx.json for full-stack-app, astro.config.mjs for landing-page, or the matching `feat(<id>): workspace` commit for an authored core).
4
4
  model: sonnet
5
5
  color: green
6
6
  tools: Read, Glob, Grep, Edit, Write, Bash
@@ -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
@@ -238,10 +238,10 @@ holds. Once confirmed and written, control returns to `planner`, which
238
238
  runs `hedgehog-planning-intake`'s Phase 1 mining against this core the
239
239
  same way it would against a shipped one, then hands off to `bootstrap`.
240
240
 
241
- This skill never touches the workspace itselfno `pnpm init`, no
242
- generator, no install. `hedgehog init` lands nothing core-specific until
243
- a core is chosen, so there's nothing on disk yet for this skill to
244
- conflict with; its job ends at the design artifacts. `bootstrap`'s
245
- `hedgehog-bootstrap-authored-core` is what later generates the real
246
- workspace for the stack chosen here a separate step, run only once
247
- Phase 1 mining and Confirm & Lock have both landed.
241
+ This skill's job ends at the design artifacts — `core.yaml` and
242
+ `core-design.md` are text, written by editing files. `hedgehog init`
243
+ lands the shared agents/skills/build-graph payload regardless of core, so
244
+ the workspace this design describes is still `bootstrap`'s to generate:
245
+ `hedgehog-bootstrap-authored-core` runs the stack's own generator and
246
+ installs it, a separate step, once Phase 1 mining and Confirm & Lock have
247
+ both landed.
@@ -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>