@skyf0xx/hedgehog 2.0.13 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +9 -3
  2. package/bin/cli.mjs +463 -19
  3. package/package.json +3 -2
  4. package/src/agents/backend-eng.md +56 -45
  5. package/src/agents/bootstrap.md +67 -73
  6. package/src/agents/front-end-eng.md +31 -18
  7. package/src/agents/planner.md +163 -84
  8. package/src/agents/reviewer.md +4 -4
  9. package/src/agents/tweaker.md +138 -106
  10. package/src/db/core.mjs +141 -0
  11. package/src/db/friction.mjs +25 -0
  12. package/src/db/init.mjs +35 -0
  13. package/src/db/intent.mjs +101 -0
  14. package/src/db/next.mjs +179 -0
  15. package/src/db/plan.mjs +222 -0
  16. package/src/db/schema.mjs +95 -0
  17. package/src/db/status.mjs +113 -0
  18. package/src/db/verify.mjs +286 -0
  19. package/src/db/why.mjs +97 -0
  20. package/src/golden-cores/full-stack-app/core.yaml +41 -0
  21. package/src/golden-cores/landing-page/core.yaml +41 -0
  22. package/src/skills/conventional-commits/SKILL.md +1 -1
  23. package/src/skills/hedgehog-bootstrap/SKILL.md +38 -41
  24. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +8 -12
  25. package/src/skills/hedgehog-bootstrap-landing-page-core/SKILL.md +7 -9
  26. package/src/skills/hedgehog-core-design/SKILL.md +239 -0
  27. package/src/skills/hedgehog-landing-loop/SKILL.md +91 -57
  28. package/src/skills/hedgehog-loop/SKILL.md +109 -77
  29. package/src/skills/hedgehog-planning-intake/SKILL.md +72 -97
  30. package/src/templates/CLAUDE.core.full-stack-app.md +31 -24
  31. package/src/templates/CLAUDE.core.landing-page.md +11 -7
  32. package/src/templates/CLAUDE.md +46 -38
  33. package/src/templates/TODO.core.full-stack-app.md +0 -51
  34. package/src/templates/TODO.core.landing-page.md +0 -31
  35. package/src/templates/TODO.md +0 -12
@@ -0,0 +1,179 @@
1
+ // `hedgehog next` — readiness query + task-packet assembly for one task.
2
+ // See hedgehog-persistent-build-graph.md, the readiness `SELECT` under
3
+ // "Schema", and "The task packet" / the `hedgehog next` output example.
4
+ //
5
+ // Readiness: a task with no dependency whose status isn't `complete`,
6
+ // lowest `priority` then `id`. Pickable status is `planned` OR `ready` —
7
+ // `plan.mjs` inserts new tasks as `planned`, but `verify.mjs`'s
8
+ // unlockReadyDependents sets a dependent's status to `ready` directly
9
+ // once its dependencies complete (see verify.mjs), without ever passing
10
+ // back through `planned`. A task already marked `ready` still has to
11
+ // satisfy the same no-incomplete-dependency condition here — the OR
12
+ // widens which statuses are eligible, it doesn't relax the dependency
13
+ // check itself. Once found, the packet is assembled by querying tasks
14
+ // joined through intents/requirements/task_requirements — never
15
+ // hand-written, never the whole plan.
16
+
17
+ const READY_TASK_SQL = `
18
+ SELECT t.* FROM tasks t
19
+ WHERE t.status IN ('planned', 'ready')
20
+ AND NOT EXISTS (
21
+ SELECT 1 FROM dependencies d
22
+ JOIN tasks dep ON dep.id = d.depends_on_task_id
23
+ WHERE d.task_id = t.id AND dep.status <> 'complete'
24
+ )
25
+ ORDER BY t.priority, t.id
26
+ LIMIT 1;
27
+ `;
28
+
29
+ function findReadyTask(db) {
30
+ return db.prepare(READY_TASK_SQL).get();
31
+ }
32
+
33
+ function loadIntent(db, intentId) {
34
+ return db.prepare('SELECT * FROM intents WHERE id = ?').get(intentId);
35
+ }
36
+
37
+ function loadTaskRequirements(db, taskId) {
38
+ return db
39
+ .prepare(
40
+ `
41
+ SELECT r.* FROM requirements r
42
+ JOIN task_requirements tr ON tr.requirement_id = r.id
43
+ WHERE tr.task_id = ?
44
+ `,
45
+ )
46
+ .all(taskId);
47
+ }
48
+
49
+ function loadDirectDependents(db, taskId) {
50
+ return db
51
+ .prepare(
52
+ `
53
+ SELECT t.* FROM tasks t
54
+ JOIN dependencies d ON d.task_id = t.id
55
+ WHERE d.depends_on_task_id = ?
56
+ ORDER BY t.priority, t.id
57
+ `,
58
+ )
59
+ .all(taskId);
60
+ }
61
+
62
+ // The full transitive closure of tasks blocked on `taskId`, not just its
63
+ // direct dependents (spec example: BLOCKED DOWNSTREAM lists the ready
64
+ // task's entire remaining chain — contract, repository, service, screen —
65
+ // not only the one task directly depending on it). Walked breadth-first,
66
+ // de-duplicated, in dependency order.
67
+ function loadBlockedDownstream(db, taskId) {
68
+ const seen = new Set([taskId]);
69
+ const result = [];
70
+ let frontier = [taskId];
71
+ while (frontier.length > 0) {
72
+ const next = [];
73
+ for (const id of frontier) {
74
+ for (const dep of loadDirectDependents(db, id)) {
75
+ if (seen.has(dep.id)) continue;
76
+ seen.add(dep.id);
77
+ result.push(dep);
78
+ next.push(dep.id);
79
+ }
80
+ }
81
+ frontier = next;
82
+ }
83
+ return result;
84
+ }
85
+
86
+ // Assembles the packet for `task` (already known ready) by querying its
87
+ // intent, requirements, and blocked downstream chain. Returns null fields
88
+ // never — every field here is NOT NULL on tasks, or defaults to an empty
89
+ // list.
90
+ function assemblePacket(db, task) {
91
+ const intent = loadIntent(db, task.intent_id);
92
+ const requirements = loadTaskRequirements(db, task.id);
93
+ const dependents = loadBlockedDownstream(db, task.id);
94
+
95
+ return {
96
+ task,
97
+ intent,
98
+ requirements,
99
+ dependents,
100
+ };
101
+ }
102
+
103
+ // Returns the assembled packet for the one ready task (spec: readiness
104
+ // SELECT), or null if no task is ready.
105
+ export function nextTask(db) {
106
+ const task = findReadyTask(db);
107
+ if (!task) return null;
108
+ return assemblePacket(db, task);
109
+ }
110
+
111
+ // Tasks stalled awaiting a re-verify: `failed` (verification returned
112
+ // nonzero) or `implemented` (scope violation refused to run it). Neither
113
+ // is pickable by the readiness query, so when `nextTask` returns null the
114
+ // caller uses this to tell "the build is finished" apart from "the build
115
+ // is stuck on a task that needs fixing" — otherwise a failed task makes
116
+ // a blocked graph look complete.
117
+ export function stalledTasks(db) {
118
+ return db
119
+ .prepare(
120
+ `
121
+ SELECT t.* FROM tasks t
122
+ WHERE t.status IN ('failed', 'implemented')
123
+ ORDER BY t.priority, t.id
124
+ `,
125
+ )
126
+ .all();
127
+ }
128
+
129
+ // Renders a packet into the STATUS / INTENT / RELEVANT RULES / WHY NOW /
130
+ // BLOCKED DOWNSTREAM / ALLOWED SCOPE / VERIFICATION format. The spec
131
+ // splits this across two examples — the `hedgehog next` display and "The
132
+ // task packet" (which carries the intent and its rules) — but an agent
133
+ // receives one thing, so the packet is one thing: everything the worker
134
+ // needs to build the task without reading the plan.
135
+ export function formatNext(packet) {
136
+ const { task, intent, requirements, dependents } = packet;
137
+ const scopeGlobs = JSON.parse(task.scope_globs);
138
+
139
+ const lines = [];
140
+ lines.push(`TASK ${task.id}`);
141
+ lines.push(task.objective);
142
+ lines.push('');
143
+ lines.push('STATUS READY');
144
+ lines.push('');
145
+ lines.push('INTENT');
146
+ lines.push(` ${intent.goal}`);
147
+ lines.push(` ${intent.outcome}`);
148
+ lines.push('');
149
+ lines.push('RELEVANT RULES');
150
+ if (requirements.length === 0) {
151
+ lines.push(' (none recorded)');
152
+ } else {
153
+ for (const req of requirements) {
154
+ lines.push(` - ${req.statement}`);
155
+ }
156
+ }
157
+ lines.push('');
158
+ lines.push('WHY NOW');
159
+ lines.push(` ✓ Intent "${intent.id}" compiled into the graph`);
160
+ lines.push(` ✓ Domain module "${task.module}" resolved`);
161
+ lines.push(' ✓ No incomplete dependencies');
162
+ lines.push('');
163
+ lines.push('BLOCKED DOWNSTREAM');
164
+ if (dependents.length === 0) {
165
+ lines.push(' (none)');
166
+ } else {
167
+ for (const dep of dependents) {
168
+ lines.push(` ✗ ${dep.id} ${dep.layer}`);
169
+ }
170
+ }
171
+ lines.push('');
172
+ lines.push('ALLOWED SCOPE');
173
+ for (const glob of scopeGlobs) lines.push(` ${glob}`);
174
+ lines.push('');
175
+ lines.push('VERIFICATION');
176
+ lines.push(` ${task.verify_command}`);
177
+
178
+ return lines.join('\n');
179
+ }
@@ -0,0 +1,222 @@
1
+ // `hedgehog plan` — compiles pending intents against the project's core
2
+ // definition into `tasks` + `dependencies` rows. See
3
+ // hedgehog-persistent-build-graph.md, "The build graph" and "Task
4
+ // lifecycle", plus the readiness SELECT under "Schema".
5
+ //
6
+ // full-stack-app: one task per layer per intent (an intent is a domain
7
+ // module — see the core definition's `{module}` placeholder). landing-page:
8
+ // one task per phase, no module axis. Both are the same operation — walk
9
+ // a core definition's layer chain once per intent — because a linear
10
+ // chain is the degenerate case of the layer graph (spec: MVP scope
11
+ // item 5).
12
+
13
+ function fillModule(template, module) {
14
+ return template.replaceAll('{module}', module);
15
+ }
16
+
17
+ // Deterministic, human-legible task id: <INTENT>-<LAYER>, upper-cased.
18
+ // Stable across repeated `hedgehog plan` runs on the same intent/layer.
19
+ function taskId(intentId, layerId) {
20
+ return `${intentId}-${layerId}`.toUpperCase();
21
+ }
22
+
23
+ // Compiles one intent's tasks + intra-intent dependencies (mirroring the
24
+ // core definition's layer order) without touching the database.
25
+ function compileIntentTasks(intent, core) {
26
+ const module = intent.id;
27
+ const tasks = core.layers.map((layer) => ({
28
+ id: taskId(intent.id, layer.id),
29
+ intent_id: intent.id,
30
+ module,
31
+ layer: layer.id,
32
+ objective: `${layer.id} for ${module}`,
33
+ scope_globs: JSON.stringify(layer.scope.map((g) => fillModule(g, module))),
34
+ verify_command: fillModule(layer.verify, module),
35
+ commit_message: fillModule(layer.commit, module),
36
+ priority: intent.priority,
37
+ }));
38
+
39
+ const dependencies = [];
40
+ for (const layer of core.layers) {
41
+ if (!layer.depends_on) continue;
42
+ dependencies.push({
43
+ task_id: taskId(intent.id, layer.id),
44
+ depends_on_task_id: taskId(intent.id, layer.depends_on),
45
+ });
46
+ }
47
+
48
+ return { tasks, dependencies };
49
+ }
50
+
51
+ // Reads pending intents (status 'proposed' or 'planned' — not yet
52
+ // compiled into tasks) in dependency order: an intent is compiled only
53
+ // after every intent it depends_on. Intents with no ordering constraint
54
+ // between them compile in `priority, id` order.
55
+ function orderIntents(intents, intentDependencies) {
56
+ const byId = new Map(intents.map((i) => [i.id, i]));
57
+ const dependsOn = new Map(intents.map((i) => [i.id, []]));
58
+ for (const { intent_id, depends_on_intent_id } of intentDependencies) {
59
+ if (!dependsOn.has(intent_id)) continue;
60
+ if (!byId.has(depends_on_intent_id)) continue;
61
+ dependsOn.get(intent_id).push(depends_on_intent_id);
62
+ }
63
+
64
+ const ordered = [];
65
+ const visited = new Set();
66
+ const visiting = new Set();
67
+
68
+ function visit(id) {
69
+ if (visited.has(id)) return;
70
+ if (visiting.has(id)) {
71
+ throw new Error(`intent_dependencies cycle detected at "${id}"`);
72
+ }
73
+ visiting.add(id);
74
+ for (const depId of dependsOn.get(id) ?? []) visit(depId);
75
+ visiting.delete(id);
76
+ visited.add(id);
77
+ ordered.push(byId.get(id));
78
+ }
79
+
80
+ const remaining = [...intents].sort(
81
+ (a, b) => a.priority - b.priority || a.id.localeCompare(b.id),
82
+ );
83
+ for (const intent of remaining) visit(intent.id);
84
+
85
+ return ordered;
86
+ }
87
+
88
+ const PENDING_INTENT_STATUSES = ['proposed', 'planned'];
89
+
90
+ function loadPendingIntents(db) {
91
+ const placeholders = PENDING_INTENT_STATUSES.map(() => '?').join(',');
92
+ return db
93
+ .prepare(`SELECT * FROM intents WHERE status IN (${placeholders})`)
94
+ .all(...PENDING_INTENT_STATUSES);
95
+ }
96
+
97
+ function loadIntentDependencies(db) {
98
+ return db.prepare('SELECT * FROM intent_dependencies').all();
99
+ }
100
+
101
+ function loadIntentRequirements(db, intentId) {
102
+ return db
103
+ .prepare('SELECT id FROM requirements WHERE intent_id = ? ORDER BY id')
104
+ .all(intentId);
105
+ }
106
+
107
+ function taskExists(db, taskId) {
108
+ return db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(taskId) !== undefined;
109
+ }
110
+
111
+ const insertTask = (db) =>
112
+ db.prepare(`
113
+ INSERT INTO tasks
114
+ (id, intent_id, module, layer, objective, scope_globs, verify_command,
115
+ commit_message, priority, status)
116
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'planned')
117
+ `);
118
+
119
+ const insertDependency = (db) =>
120
+ db.prepare(`
121
+ INSERT OR IGNORE INTO dependencies (task_id, depends_on_task_id)
122
+ VALUES (?, ?)
123
+ `);
124
+
125
+ const insertTaskRequirement = (db) =>
126
+ db.prepare(`
127
+ INSERT OR IGNORE INTO task_requirements (task_id, requirement_id)
128
+ VALUES (?, ?)
129
+ `);
130
+
131
+ // Compiles every pending intent against `core` and writes tasks +
132
+ // dependencies to `db`. Idempotent per intent: an intent whose tasks
133
+ // already exist (by id) is skipped entirely, so re-running `hedgehog
134
+ // plan` after adding a new intent doesn't touch already-compiled ones.
135
+ //
136
+ // Cross-intent ordering: for each `intent_dependencies` edge (A depends
137
+ // on B), A's first task in layer order gets an extra `dependencies` row
138
+ // on B's last task in layer order — so A's chain cannot start until B's
139
+ // chain is entirely `complete`.
140
+ //
141
+ // Requirement linkage: every task an intent compiles to is linked to all
142
+ // of that intent's requirements via `task_requirements`. The compiler has
143
+ // no basis for splitting requirements across layers — a rule like
144
+ // "invitations expire after 7 days" constrains the schema, the service,
145
+ // and the screen alike — so each layer carries the whole set and the task
146
+ // packet shows the agent every rule its intent is bound by. Without this
147
+ // the traceability chain (spec: "Traceability") has no middle link:
148
+ // `hedgehog why` could reach the intent but never name the requirement a
149
+ // file satisfies.
150
+ export function planTasks(db, core) {
151
+ const intents = loadPendingIntents(db);
152
+ const intentDependencies = loadIntentDependencies(db);
153
+ const ordered = orderIntents(intents, intentDependencies);
154
+
155
+ const dependsOnByIntent = new Map();
156
+ for (const { intent_id, depends_on_intent_id } of intentDependencies) {
157
+ if (!dependsOnByIntent.has(intent_id)) dependsOnByIntent.set(intent_id, []);
158
+ dependsOnByIntent.get(intent_id).push(depends_on_intent_id);
159
+ }
160
+
161
+ const firstLayerId = core.layers[0].id;
162
+ const lastLayerId = core.layers[core.layers.length - 1].id;
163
+
164
+ const runInsert = insertTask(db);
165
+ const runInsertDep = insertDependency(db);
166
+ const runInsertTaskReq = insertTaskRequirement(db);
167
+
168
+ const compiledIntentIds = [];
169
+ const skippedIntentIds = [];
170
+
171
+ db.exec('BEGIN');
172
+ try {
173
+ for (const intent of ordered) {
174
+ const firstTaskId = taskId(intent.id, firstLayerId);
175
+ if (taskExists(db, firstTaskId)) {
176
+ skippedIntentIds.push(intent.id);
177
+ continue;
178
+ }
179
+
180
+ const { tasks, dependencies } = compileIntentTasks(intent, core);
181
+ const requirementIds = loadIntentRequirements(db, intent.id).map((r) => r.id);
182
+ for (const t of tasks) {
183
+ runInsert.run(
184
+ t.id,
185
+ t.intent_id,
186
+ t.module,
187
+ t.layer,
188
+ t.objective,
189
+ t.scope_globs,
190
+ t.verify_command,
191
+ t.commit_message,
192
+ t.priority,
193
+ );
194
+ for (const requirementId of requirementIds) {
195
+ runInsertTaskReq.run(t.id, requirementId);
196
+ }
197
+ }
198
+ for (const d of dependencies) {
199
+ runInsertDep.run(d.task_id, d.depends_on_task_id);
200
+ }
201
+
202
+ // Cross-intent edge: this intent's first task can't be ready until
203
+ // every intent it depends_on has its last task complete.
204
+ for (const depIntentId of dependsOnByIntent.get(intent.id) ?? []) {
205
+ const depLastTaskId = taskId(depIntentId, lastLayerId);
206
+ runInsertDep.run(firstTaskId, depLastTaskId);
207
+ }
208
+
209
+ // An intent whose tasks now exist is no longer `proposed` — it's
210
+ // been compiled into the graph and is being built.
211
+ db.prepare("UPDATE intents SET status = 'active' WHERE id = ?").run(intent.id);
212
+
213
+ compiledIntentIds.push(intent.id);
214
+ }
215
+ db.exec('COMMIT');
216
+ } catch (err) {
217
+ db.exec('ROLLBACK');
218
+ throw err;
219
+ }
220
+
221
+ return { compiled: compiledIntentIds, skipped: skippedIntentIds };
222
+ }
@@ -0,0 +1,95 @@
1
+ // The Hedgehog build graph schema — see hedgehog-persistent-build-graph.md,
2
+ // "SQLite as build state" → Schema, for the source of truth these table
3
+ // definitions mirror verbatim.
4
+
5
+ export const SCHEMA_SQL = `
6
+ PRAGMA foreign_keys = ON;
7
+
8
+ CREATE TABLE IF NOT EXISTS intents (
9
+ id TEXT PRIMARY KEY,
10
+ goal TEXT NOT NULL,
11
+ outcome TEXT NOT NULL,
12
+ priority INTEGER NOT NULL DEFAULT 100,
13
+ status TEXT NOT NULL DEFAULT 'proposed'
14
+ CHECK (status IN ('proposed','planned','active','complete')),
15
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS requirements (
19
+ id TEXT PRIMARY KEY,
20
+ intent_id TEXT NOT NULL REFERENCES intents(id) ON DELETE CASCADE,
21
+ kind TEXT NOT NULL CHECK (kind IN ('rule','constraint','acceptance')),
22
+ statement TEXT NOT NULL
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS intent_dependencies (
26
+ intent_id TEXT NOT NULL REFERENCES intents(id) ON DELETE CASCADE,
27
+ depends_on_intent_id TEXT NOT NULL REFERENCES intents(id) ON DELETE CASCADE,
28
+ PRIMARY KEY (intent_id, depends_on_intent_id),
29
+ CHECK (intent_id <> depends_on_intent_id)
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS tasks (
33
+ id TEXT PRIMARY KEY,
34
+ intent_id TEXT NOT NULL REFERENCES intents(id) ON DELETE CASCADE,
35
+ module TEXT NOT NULL,
36
+ layer TEXT NOT NULL,
37
+ objective TEXT NOT NULL,
38
+ scope_globs TEXT NOT NULL,
39
+ verify_command TEXT NOT NULL,
40
+ commit_message TEXT NOT NULL,
41
+ priority INTEGER NOT NULL DEFAULT 100,
42
+ -- Every value here is one the engine actually writes. A CHECK listing
43
+ -- states nothing can produce documents a lifecycle that doesn't exist
44
+ -- and invites writing one the engine can't handle.
45
+ status TEXT NOT NULL DEFAULT 'proposed'
46
+ CHECK (status IN ('proposed','planned','ready',
47
+ 'implemented','verified',
48
+ 'complete','failed')),
49
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS task_requirements (
53
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
54
+ requirement_id TEXT NOT NULL REFERENCES requirements(id) ON DELETE CASCADE,
55
+ PRIMARY KEY (task_id, requirement_id)
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS dependencies (
59
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
60
+ depends_on_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
61
+ PRIMARY KEY (task_id, depends_on_task_id),
62
+ CHECK (task_id <> depends_on_task_id)
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS artifacts (
66
+ id INTEGER PRIMARY KEY,
67
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
68
+ path TEXT NOT NULL,
69
+ kind TEXT NOT NULL CHECK (kind IN ('created','modified')),
70
+ commit_sha TEXT
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS verifications (
74
+ id INTEGER PRIMARY KEY,
75
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
76
+ command TEXT NOT NULL,
77
+ exit_code INTEGER,
78
+ output TEXT,
79
+ status TEXT NOT NULL CHECK (status IN ('passed','failed')),
80
+ ran_at TEXT NOT NULL DEFAULT (datetime('now'))
81
+ );
82
+
83
+ CREATE TABLE IF NOT EXISTS friction (
84
+ id INTEGER PRIMARY KEY,
85
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
86
+ note TEXT NOT NULL,
87
+ logged_at TEXT NOT NULL DEFAULT (datetime('now'))
88
+ );
89
+ `;
90
+
91
+ // Applies the schema to an already-open node:sqlite DatabaseSync instance.
92
+ // Idempotent: safe to call against a DB that already has these tables.
93
+ export function applySchema(db) {
94
+ db.exec(SCHEMA_SQL);
95
+ }
@@ -0,0 +1,113 @@
1
+ // `hedgehog status` — graph overview: task counts by status, and the
2
+ // current ready list. See hedgehog-persistent-build-graph.md, "Task
3
+ // lifecycle" for the status set and "The CLI is the only writer" for the
4
+ // `hedgehog status` line.
5
+ //
6
+ // The ready list reuses next.mjs's readiness query rather than
7
+ // reimplementing it: a task is pickable by `hedgehog next` when its
8
+ // status is `planned` or `ready` (plan.mjs inserts `planned`;
9
+ // verify.mjs's unlockReadyDependents sets `ready` directly) and it has
10
+ // no dependency whose status isn't `complete`. `hedgehog status` lists
11
+ // every task meeting that condition, not just the one `hedgehog next`
12
+ // would pick.
13
+
14
+ // The task lifecycle in order, matching the tasks CHECK constraint in
15
+ // schema.mjs exactly — every status the engine can write, and no others.
16
+ const TASK_STATUSES = [
17
+ 'proposed',
18
+ 'planned',
19
+ 'ready',
20
+ 'implemented',
21
+ 'verified',
22
+ 'complete',
23
+ 'failed',
24
+ ];
25
+
26
+ const READY_TASKS_SQL = `
27
+ SELECT t.* FROM tasks t
28
+ WHERE t.status IN ('planned', 'ready')
29
+ AND NOT EXISTS (
30
+ SELECT 1 FROM dependencies d
31
+ JOIN tasks dep ON dep.id = d.depends_on_task_id
32
+ WHERE d.task_id = t.id AND dep.status <> 'complete'
33
+ )
34
+ ORDER BY t.priority, t.id;
35
+ `;
36
+
37
+ function countTasksByStatus(db) {
38
+ const rows = db
39
+ .prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status')
40
+ .all();
41
+ const counts = Object.fromEntries(TASK_STATUSES.map((s) => [s, 0]));
42
+ for (const row of rows) counts[row.status] = row.n;
43
+ return counts;
44
+ }
45
+
46
+ function loadReadyTasks(db) {
47
+ return db.prepare(READY_TASKS_SQL).all();
48
+ }
49
+
50
+ // Tasks that need a human/agent decision before the graph can move again:
51
+ // `failed` (verification ran and returned nonzero) and `implemented` (a
52
+ // scope violation refused to run verification). Neither is pickable by
53
+ // `hedgehog next`, so without listing them here a build whose only
54
+ // remaining work is a failed task looks identical to a finished one —
55
+ // "READY (none)" with no indication anything is wrong. Listing them is
56
+ // what makes the fix-and-re-verify path in hedgehog-loop's Loop step 4
57
+ // discoverable from a fresh context.
58
+ const ATTENTION_TASKS_SQL = `
59
+ SELECT t.* FROM tasks t
60
+ WHERE t.status IN ('failed', 'implemented')
61
+ ORDER BY t.priority, t.id;
62
+ `;
63
+
64
+ function loadAttentionTasks(db) {
65
+ return db.prepare(ATTENTION_TASKS_SQL).all();
66
+ }
67
+
68
+ // Returns { counts, ready, attention, total } — counts keyed by every
69
+ // status in the tasks CHECK constraint (present even at zero), ready the
70
+ // full list of currently-pickable tasks, attention the stalled tasks
71
+ // needing a re-verify, total the sum across all statuses.
72
+ export function graphStatus(db) {
73
+ const counts = countTasksByStatus(db);
74
+ const ready = loadReadyTasks(db);
75
+ const attention = loadAttentionTasks(db);
76
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
77
+ return { counts, ready, attention, total };
78
+ }
79
+
80
+ // Renders a graphStatus() result into a plain-text overview: counts by
81
+ // status (only non-zero ones, in lifecycle order), then the ready list.
82
+ export function formatStatus({ counts, ready, attention, total }) {
83
+ const lines = [];
84
+ lines.push(`TASKS ${total}`);
85
+ lines.push('');
86
+ for (const status of TASK_STATUSES) {
87
+ if (counts[status] === 0) continue;
88
+ lines.push(` ${status.padEnd(12)} ${counts[status]}`);
89
+ }
90
+ lines.push('');
91
+ lines.push('READY');
92
+ if (ready.length === 0) {
93
+ lines.push(' (none)');
94
+ } else {
95
+ for (const task of ready) {
96
+ lines.push(` ${task.id} ${task.layer} ${task.objective}`);
97
+ }
98
+ }
99
+
100
+ if (attention && attention.length > 0) {
101
+ lines.push('');
102
+ lines.push('NEEDS ATTENTION');
103
+ for (const task of attention) {
104
+ const reason =
105
+ task.status === 'failed' ? 'verification failed' : 'scope violation';
106
+ lines.push(` ${task.id} ${task.layer} ${reason}`);
107
+ }
108
+ lines.push('');
109
+ lines.push(' Fix the work, then re-run: hedgehog verify <task-id>');
110
+ }
111
+
112
+ return lines.join('\n');
113
+ }