@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 +27 -11
- package/bin/cli.mjs +165 -18
- package/package.json +1 -1
- package/src/agents/bootstrap.md +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/skills/hedgehog-core-design/SKILL.md +7 -7
- 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
|
|
|
@@ -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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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):
|
|
81
|
-
// core
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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
|
|
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
|
|
221
|
-
The planner agent designs a core at planning intake
|
|
222
|
-
and bootstrap generates that workspace once it's
|
|
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` —
|
|
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
|
|
325
|
-
'
|
|
326
|
-
'planning intake, then bootstrap
|
|
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
package/src/agents/bootstrap.md
CHANGED
|
@@ -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
|
|
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);
|
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
|
+
}
|
|
@@ -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
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
`hedgehog-bootstrap-authored-core`
|
|
246
|
-
|
|
247
|
-
|
|
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>
|