@skyf0xx/hedgehog 4.0.3 → 4.0.7

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 (47) hide show
  1. package/bin/cli.mjs +906 -43
  2. package/package.json +1 -1
  3. package/src/agents/backend-eng.md +16 -1
  4. package/src/agents/front-end-eng.md +14 -1
  5. package/src/agents/layer-eng.md +33 -5
  6. package/src/agents/planner.md +10 -0
  7. package/src/agents/tweaker.md +12 -1
  8. package/src/db/boundary.mjs +396 -0
  9. package/src/db/claim.mjs +263 -9
  10. package/src/db/conflict.mjs +4 -1
  11. package/src/db/core.mjs +567 -9
  12. package/src/db/debt.mjs +65 -0
  13. package/src/db/drift.mjs +360 -0
  14. package/src/db/gate.mjs +149 -0
  15. package/src/db/graph-server.mjs +11 -6
  16. package/src/db/init.mjs +7 -1
  17. package/src/db/intent.mjs +247 -34
  18. package/src/db/next.mjs +195 -14
  19. package/src/db/overrides.mjs +216 -0
  20. package/src/db/plan.mjs +369 -19
  21. package/src/db/rebuild.mjs +219 -36
  22. package/src/db/requires.mjs +137 -0
  23. package/src/db/schema.mjs +42 -0
  24. package/src/db/status.mjs +63 -13
  25. package/src/db/verify.mjs +201 -44
  26. package/src/golden-cores/full-stack-app/apps/web/next-env.d.ts +2 -1
  27. package/src/golden-cores/full-stack-app/apps/web/package.json +2 -2
  28. package/src/golden-cores/full-stack-app/core.yaml +14 -19
  29. package/src/golden-cores/full-stack-app/lefthook.yml +38 -3
  30. package/src/golden-cores/full-stack-app/package.json +13 -3
  31. package/src/golden-cores/full-stack-app/pnpm-lock.yaml +3333 -8456
  32. package/src/golden-cores/full-stack-app/tools/phase-gate.cjs +8 -2
  33. package/src/golden-cores/landing-page/package.json +8 -1
  34. package/src/golden-cores/landing-page/pnpm-lock.yaml +18 -13
  35. package/src/hosts/claude/DISPATCH.md +3 -1
  36. package/src/hosts/cursor/DISPATCH.md +3 -1
  37. package/src/hosts/gemini/DISPATCH.md +3 -1
  38. package/src/hosts/routing.mjs +5 -2
  39. package/src/skills/hedgehog-authored-loop/SKILL.md +108 -17
  40. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +22 -6
  41. package/src/skills/hedgehog-contributing/SKILL.md +101 -0
  42. package/src/skills/hedgehog-core-design/SKILL.md +196 -11
  43. package/src/skills/hedgehog-landing-loop/SKILL.md +17 -4
  44. package/src/skills/hedgehog-loop/SKILL.md +53 -14
  45. package/src/skills/hedgehog-planning-intake/SKILL.md +20 -0
  46. package/src/templates/CLAUDE.core.authored.md +28 -9
  47. package/src/templates/CLAUDE.md +57 -19
package/bin/cli.mjs CHANGED
@@ -17,18 +17,37 @@ import { constants } from 'node:fs';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { dirname, join, relative, resolve } from 'node:path';
19
19
  import { spawn } from 'node:child_process';
20
- import { dbInit, DB_PATH, openDb } from '../src/db/init.mjs';
21
- import { loadCore } from '../src/db/core.mjs';
20
+ import { dbInit, DB_PATH, dbAbsPath, openDb } from '../src/db/init.mjs';
21
+ import { loadCore, lintCore } from '../src/db/core.mjs';
22
22
  import { planTasks } from '../src/db/plan.mjs';
23
23
  import { addIntent, INTENTS_DIR } from '../src/db/intent.mjs';
24
- import { nextTask, formatNext, stalledTasks } from '../src/db/next.mjs';
24
+ import {
25
+ nextTask,
26
+ formatNext,
27
+ formatPacket,
28
+ stalledTasks,
29
+ taskPacket,
30
+ taskStatusLine,
31
+ } from '../src/db/next.mjs';
25
32
  import { verifyTask } from '../src/db/verify.mjs';
26
- import { claimTasks, releaseTask, renewLease } from '../src/db/claim.mjs';
33
+ import {
34
+ claimTasks,
35
+ claimTask,
36
+ releaseTask,
37
+ renewLease,
38
+ retryTask,
39
+ } from '../src/db/claim.mjs';
27
40
  import { readyTasks, formatReady } from '../src/db/ready.mjs';
28
41
  import { graphStatus, formatStatus } from '../src/db/status.mjs';
42
+ import { boundaryState, formatBoundary, formatPosition, formatHandoff } from '../src/db/boundary.mjs';
43
+ import { commitGateStatus, formatCommitGate } from '../src/db/gate.mjs';
44
+ import { detectDrift, recompileTasks, formatRecompile } from '../src/db/drift.mjs';
45
+ import { coreMissingRequirements, missingBinaries } from '../src/db/requires.mjs';
29
46
  import { whyPath, formatWhy } from '../src/db/why.mjs';
30
47
  import { addFriction, listFriction } from '../src/db/friction.mjs';
48
+ import { addDebt, listDebt } from '../src/db/debt.mjs';
31
49
  import { rebuildDb } from '../src/db/rebuild.mjs';
50
+ import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
32
51
  import { HOSTS, HOST_FLAGS, DEFAULT_HOST, availableHosts } from '../src/hosts/index.mjs';
33
52
  import { recordHosts, installedHosts } from '../src/hosts/installed.mjs';
34
53
 
@@ -187,6 +206,17 @@ const exists = (p) =>
187
206
  () => false,
188
207
  );
189
208
 
209
+ // Printed by every command that writes to the build graph, before it
210
+ // writes. DB_PATH is relative (`.hedgehog/hedgehog.db`), so which
211
+ // database a command actually opens depends entirely on the directory it
212
+ // was run from — a recovery command run one directory up silently opens
213
+ // (or creates) a different database, matches nothing, and reports
214
+ // success. Naming the absolute path on every write makes that visible in
215
+ // the output instead of only in the outcome.
216
+ function printDbTarget() {
217
+ console.log(` ${dim('db')} ${dbAbsPath()}`);
218
+ }
219
+
190
220
  // Runs once at the top of every command that needs the build graph. A
191
221
  // fresh clone has no `.hedgehog/hedgehog.db` (it's a derived artifact,
192
222
  // not committed) but does have `.hedgehog/intents/*.json` — the committed
@@ -196,7 +226,11 @@ const exists = (p) =>
196
226
  // there's nothing to rebuild from either (a genuinely fresh project, no
197
227
  // intents yet), this no-ops and leaves the DB missing — the caller's own
198
228
  // existing "No build graph found" guard still fires for that case.
199
- async function ensureDb() {
229
+ // `log` exists for the one caller whose stdout is a machine-consumable
230
+ // payload (`hedgehog boundary`): the rebuild notice is commentary, and
231
+ // commentary on stdout would corrupt a block meant to be captured or
232
+ // piped. Every other command leaves it at the default.
233
+ async function ensureDb({ log = console.log } = {}) {
200
234
  if (await exists(DB_PATH)) return;
201
235
 
202
236
  let intentFiles = [];
@@ -218,9 +252,25 @@ async function ensureDb() {
218
252
  } finally {
219
253
  db.close();
220
254
  }
221
- console.log(
255
+ log(
222
256
  `${dim('DB missing — rebuilt from')} ${bold(INTENTS_DIR)}${dim(':')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
223
257
  );
258
+ warnRebuildDrift(result, corePath);
259
+ }
260
+
261
+ // A rebuild re-derives every task from the current core.yaml and the
262
+ // committed intents. Nothing else is committed — a task row someone
263
+ // patched by hand has no source to replay from — so say plainly what a
264
+ // rebuild can and cannot carry, and surface any task that ends up
265
+ // disagreeing with core.yaml.
266
+ function warnRebuildDrift({ drift }, corePath) {
267
+ if (!drift || drift.length === 0) return;
268
+ console.log(
269
+ `${yellow(bold('Core drift after rebuild.'))} ${drift.length} task(s) do not match ${bold(corePath)}.\n` +
270
+ `A rebuild replays ${bold(INTENTS_DIR)} and re-derives tasks from core.yaml; task rows\n` +
271
+ 'edited by hand have no committed source and are not replayed. Run\n' +
272
+ `${bold('hedgehog status')} for the divergence, ${bold('hedgehog plan --recompile')} to reconcile.\n`,
273
+ );
224
274
  }
225
275
 
226
276
  // Writes one planned file to disk — a straight copy, or for a `merge`
@@ -305,28 +355,49 @@ ${bold('Usage')}
305
355
  npx @skyf0xx/hedgehog update refresh the installed agents + skills
306
356
  npx @skyf0xx/hedgehog db init create .hedgehog/hedgehog.db if absent
307
357
  npx @skyf0xx/hedgehog db rebuild re-derive the build graph from committed intents + git history
308
- npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies,
309
- then open the build graph if anything compiled
358
+ npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
359
+ (starts no graph server; --no-open says so explicitly)
360
+ npx @skyf0xx/hedgehog plan --open also start the graph server and open it, if anything compiled
361
+ npx @skyf0xx/hedgehog plan --recompile rewrite core.yaml-derived fields on not-started tasks
362
+ [--dry-run] [--include-blocked] [--strict]
363
+ npx @skyf0xx/hedgehog override add <task-id> --scope <glob> [--scope <glob>...] --reason "<why>"
364
+ record a committed, additive-only scope exception for one task
365
+ npx @skyf0xx/hedgehog override list list recorded scope overrides
310
366
  npx @skyf0xx/hedgehog intent add [flags] add an intent (rules/requirements/dependencies)
311
367
  npx @skyf0xx/hedgehog intent add --file <path> add an intent from a JSON file
312
368
  npx @skyf0xx/hedgehog next print the task packet for one ready task
369
+ npx @skyf0xx/hedgehog show <task-id> print the task packet for any task, at any status
313
370
  npx @skyf0xx/hedgehog claim --owner <owner> [--count <n>] atomically claim up to n ready tasks
371
+ npx @skyf0xx/hedgehog claim <task-id> --owner <owner> claim one specific task (breaks a starvation tie)
372
+ npx @skyf0xx/hedgehog retry <task-id> return a blocked task to planned, so it can be rebuilt
314
373
  npx @skyf0xx/hedgehog release <task-id> --owner <owner> hand a claimed task back to ready
315
374
  npx @skyf0xx/hedgehog renew <task-id> --owner <owner> [--minutes <n>] extend a held lease
316
375
  npx @skyf0xx/hedgehog verify <task-id> --owner <owner> run scope + verify checks, commit on pass
317
- npx @skyf0xx/hedgehog status graph overview: counts by status, ready list, in flight
376
+ npx @skyf0xx/hedgehog status graph overview: counts by status, ready list, in flight,
377
+ and any drift from core.yaml
318
378
  npx @skyf0xx/hedgehog ready preview which ready tasks are claimable now vs held back
319
379
  npx @skyf0xx/hedgehog quiesce report whether anything is still in flight
380
+ npx @skyf0xx/hedgehog boundary is this a moment to clear context? exits 0 only if it is,
381
+ and prints what a fresh session picks up next
382
+ npx @skyf0xx/hedgehog boundary --handoff print the block a fresh session starts from
383
+ npx @skyf0xx/hedgehog boundary --quiet exit code only, for a shell hook
320
384
  npx @skyf0xx/hedgehog graph start (or reuse) the live graph server and open it
321
385
  npx @skyf0xx/hedgehog graph --no-open start (or reuse) the server; print the URL instead
322
386
  npx @skyf0xx/hedgehog why <path> provenance chain for a file
323
387
  npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
324
388
  npx @skyf0xx/hedgehog friction list list logged friction, oldest first
389
+ npx @skyf0xx/hedgehog debt add <task-id> "<note>" declare debt that lands in dependent tasks' packets
390
+ npx @skyf0xx/hedgehog debt list [<task-id>] list declared debt, oldest first
325
391
  npx @skyf0xx/hedgehog --help
326
392
 
327
393
  Available cores: ${cores.join(', ')}
328
394
  Available hosts: ${availableHosts().join(', ')} (default: ${DEFAULT_HOST})
329
395
 
396
+ Every command that writes to the build graph prints the absolute path of
397
+ the database it opened, and exits non-zero when it matched no task —
398
+ which database a relative path resolves to depends on the directory you
399
+ ran from.
400
+
330
401
  After it runs, commit the payload, open your coding agent, and describe
331
402
  what you want to build — the planner agent runs planning intake, then
332
403
  hands off to bootstrap.
@@ -423,8 +494,17 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
423
494
  );
424
495
  console.log('Next steps:');
425
496
  if (explicitCore) {
426
- console.log(` 1. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
427
- console.log(` 2. ${bold('pnpm install')}`);
497
+ // `pnpm install` before the first commit, not after it. The core's
498
+ // commit gate is lefthook, and lefthook's hooks are written by its
499
+ // own postinstall — so a commit made before the install is a commit
500
+ // made with no gate at all, and the instruction that put it first
501
+ // was quietly teaching the project to skip its own discipline on
502
+ // the one commit that lands the entire workspace. Installing first
503
+ // means commit #1 is already gated; with no HEAD to diff against it
504
+ // runs the whole workspace (see lefthook.yml), so expect it to take
505
+ // as long as a full typecheck/lint/test — that is the gate working.
506
+ console.log(` 1. ${bold('pnpm install')}`);
507
+ console.log(` 2. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
428
508
  console.log(` 3. Open ${HOSTS[host].label} and describe what you want to build.`);
429
509
  } else {
430
510
  console.log(` 1. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
@@ -520,6 +600,7 @@ async function dbRebuildCommand() {
520
600
  return;
521
601
  }
522
602
 
603
+ printDbTarget();
523
604
  await dbInit(DB_PATH);
524
605
  const db = openDb();
525
606
  let result;
@@ -532,6 +613,7 @@ async function dbRebuildCommand() {
532
613
  console.log(
533
614
  `${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
534
615
  );
616
+ warnRebuildDrift(result, corePath);
535
617
  }
536
618
 
537
619
  async function dbCommand(args) {
@@ -547,6 +629,7 @@ async function dbCommand(args) {
547
629
  process.exitCode = 1;
548
630
  return;
549
631
  }
632
+ printDbTarget();
550
633
  const { created, path } = await dbInit(DB_PATH);
551
634
  console.log(
552
635
  created
@@ -570,9 +653,84 @@ async function resolveCorePath() {
570
653
  return null;
571
654
  }
572
655
 
573
- async function planCommand() {
656
+ // `hedgehog plan --recompile` — the reconciliation path for a core.yaml
657
+ // edited after `plan` already compiled it.
658
+ //
659
+ // `plan` copies each layer's scope globs, verify command, commit message,
660
+ // exclusivity and verify radius onto every task row, and from then on the
661
+ // row is what `next`/`claim`/`verify` read. A plain re-run can't fix a
662
+ // later core.yaml correction — compiling an intent flips it to `active`,
663
+ // and `plan` only ever looks at `proposed`/`planned` ones — so before this
664
+ // existed the only remedy was an UPDATE in SQLite by hand.
665
+ //
666
+ // Deliberately not a fresh compile: it rewrites fields on tasks nothing
667
+ // has acted on yet and refuses the rest, so ids, dependencies, statuses
668
+ // and history are untouched.
669
+ async function planRecompileCommand(args, { core, corePath }) {
670
+ const includeBlocked = args.includes('--include-blocked');
671
+ const dryRun = args.includes('--dry-run');
672
+ const strict = args.includes('--strict');
673
+ const overrides = await loadOverrides();
674
+
675
+ const db = openDb();
676
+ let result;
677
+ try {
678
+ result = recompileTasks(db, core, { includeBlocked, dryRun, overrides });
679
+ } finally {
680
+ db.close();
681
+ }
682
+
683
+ if (dryRun) console.log(dim(' (--dry-run — nothing was written)'));
684
+ console.log(formatRecompile(result));
685
+
686
+ if (result.updated.length === 0 && result.skipped.length === 0) {
687
+ console.log(`\n${green(bold('In sync.'))} Every task matches ${bold(corePath)}.\n`);
688
+ return;
689
+ }
690
+
691
+ if (result.skipped.length > 0) {
692
+ console.log(
693
+ `\n${yellow(bold('Some drift remains.'))} Each skipped task above names why it kept its compiled\n` +
694
+ 'fields. A task already built, leased, or committed is a Correction Protocol\n' +
695
+ 'case (fix the layer at its source, re-run that layer), not a field rewrite;\n' +
696
+ 'a changed depends_on or a layer dropped from core.yaml is a graph-shape\n' +
697
+ 'change, which --recompile deliberately does not make.\n',
698
+ );
699
+ } else {
700
+ console.log(`\n${green(bold('Recompiled.'))}\n`);
701
+ }
702
+
703
+ if (strict && result.skipped.length > 0) process.exitCode = 1;
704
+ }
705
+
706
+ // `hedgehog plan [--open|--no-open]` — compiles pending intents.
707
+ //
708
+ // Starting the live graph server is opt-in (`--open`), not automatic.
709
+ // Hedgehog's primary caller is an agent in a headless session: there is
710
+ // no browser for `openInBrowser` to hand the URL to, so the old
711
+ // unconditional `startOrReuseGraphServer()` bought nothing and cost a
712
+ // detached `node` process left listening on 127.0.0.1 with an open
713
+ // handle on the build graph, plus a `.hedgehog/graph-server.json`
714
+ // pidfile outliving the command that wrote it. `hedgehog graph` is the
715
+ // one command whose *purpose* is that server; `plan`'s purpose is
716
+ // compiling the graph, and it now exits having started nothing.
717
+ //
718
+ // `--no-open` is accepted for symmetry with `hedgehog graph` and names
719
+ // the default explicitly. It differs from `graph --no-open` in one way
720
+ // that follows from the same principle: on `graph` the server is the
721
+ // point and only the browser is suppressed, whereas on `plan` the
722
+ // server exists solely to be opened, so suppressing the open leaves
723
+ // nothing worth serving — no server, no pidfile, no orphan.
724
+ async function planCommand(args = []) {
574
725
  await ensureDb();
575
726
 
727
+ const wantsOpen = args.includes('--open');
728
+ if (wantsOpen && args.includes('--no-open')) {
729
+ console.error(`${red('Usage:')} hedgehog plan takes --open or --no-open, not both\n`);
730
+ process.exitCode = 1;
731
+ return;
732
+ }
733
+
576
734
  const corePath = await resolveCorePath();
577
735
  if (!corePath) {
578
736
  console.error(
@@ -588,32 +746,72 @@ async function planCommand() {
588
746
  return;
589
747
  }
590
748
 
749
+ printDbTarget();
591
750
  const core = await loadCore(corePath);
751
+
752
+ if (args.includes('--recompile')) {
753
+ await planRecompileCommand(args, { core, corePath });
754
+ return;
755
+ }
756
+
757
+ const overrides = await loadOverrides();
592
758
  const db = openDb();
593
759
  let result;
594
760
  try {
595
- result = planTasks(db, core);
761
+ result = planTasks(db, core, overrides);
596
762
  } finally {
597
763
  db.close();
598
764
  }
599
765
 
766
+ for (const id of result.once) {
767
+ console.log(` ${green('compiled')} ${id} ${dim('(once — one task for the whole build)')}`);
768
+ }
600
769
  for (const id of result.compiled) console.log(` ${green('compiled')} ${id}`);
601
770
  for (const id of result.skipped) console.log(` ${dim('skipped')} ${id} ${dim('(already compiled)')}`);
771
+ for (const id of result.reopened) {
772
+ console.log(
773
+ ` ${bold('reopened')} ${id} ${dim('(once — new scope landed under it; it must run again)')}`,
774
+ );
775
+ }
602
776
  console.log(
603
777
  `\n${green(bold('Plan complete.'))} ${dim(`${result.compiled.length} intent(s) compiled, ${result.skipped.length} skipped`)}\n`,
604
778
  );
605
779
 
780
+ // A plain `plan` is where someone lands after editing core.yaml,
781
+ // expecting the edit to take. It won't — already-compiled tasks carry
782
+ // their own copy of the layer's fields — so say so here rather than
783
+ // letting the run report "0 compiled" and look like a no-op.
784
+ const driftDb = openDb({ readOnly: true });
785
+ let drifted;
786
+ try {
787
+ drifted = detectDrift(driftDb, core, { overrides });
788
+ } finally {
789
+ driftDb.close();
790
+ }
791
+ if (drifted.length > 0) {
792
+ console.log(
793
+ `${yellow(bold('Core drift.'))} ${drifted.length} already-compiled task(s) no longer match ${bold(corePath)}.\n` +
794
+ `Compiling does not revisit them. Run ${bold('hedgehog status')} to see the divergence,\n` +
795
+ `or ${bold('hedgehog plan --recompile')} to rewrite the not-started ones.\n`,
796
+ );
797
+ }
798
+
606
799
  // Only worth opening when this run actually changed the graph's shape
607
800
  // — a plan run that compiled nothing (every intent already had tasks)
608
- // would just re-open what's already open. planTasks's own db handle is
609
- // closed by this point: the graph server opens its own connection in a
610
- // separate process, and holding two write-capable handles on the same
611
- // sqlite file across that handoff invites lock contention for no
612
- // benefit.
613
- if (result.compiled.length > 0) {
614
- const { port } = await startOrReuseGraphServer();
615
- openInBrowser(`http://localhost:${port}`);
801
+ // would just re-open what's already open.
802
+ if (result.compiled.length === 0) return;
803
+
804
+ if (!wantsOpen) {
805
+ console.log(`${dim('Run')} ${bold('hedgehog graph')} ${dim('to view the build graph.')}\n`);
806
+ return;
616
807
  }
808
+
809
+ // planTasks's own db handle is closed by this point: the graph server
810
+ // opens its own connection in a separate process, and holding two
811
+ // write-capable handles on the same sqlite file across that handoff
812
+ // invites lock contention for no benefit.
813
+ const { port } = await startOrReuseGraphServer();
814
+ presentGraphUrl(`http://localhost:${port}`);
617
815
  }
618
816
 
619
817
  // Parses `hedgehog intent add` args into the same record shape
@@ -707,6 +905,7 @@ async function intentCommand(args) {
707
905
  return;
708
906
  }
709
907
 
908
+ printDbTarget();
710
909
  const db = openDb();
711
910
  let intent;
712
911
  try {
@@ -752,8 +951,11 @@ async function nextCommand() {
752
951
  const reason = BLOCKED_REASON_LABELS[task.blocked_reason] ?? task.blocked_reason;
753
952
  console.error(` ${red('✗')} ${bold(task.id)} ${task.layer} ${dim(reason)}`);
754
953
  }
954
+ // `verify` refuses anything that isn't `building` and leased to
955
+ // the caller, so a blocked task has to go back through the queue
956
+ // — retry, claim, then verify — rather than straight to verify.
755
957
  console.error(
756
- `\nFix the work, then re-run ${bold('hedgehog verify <task-id>')}.\n`,
958
+ `\nFix the work, then ${bold('hedgehog retry <task-id>')} and claim it again.\n`,
757
959
  );
758
960
  process.exitCode = 1;
759
961
  return;
@@ -765,6 +967,66 @@ async function nextCommand() {
765
967
  console.log(formatNext(packet));
766
968
  }
767
969
 
970
+ // Mirrors, read-only, the condition verifyTask's own Phase 0
971
+ // (claimForVerify) enforces: after expired leases are reaped, the task
972
+ // must be `building` and leased to `owner`. The expiry predicate is
973
+ // claim.mjs#reapExpiredLeases's, kept identical so this can't disagree
974
+ // with the check it's standing in for. Writes nothing and reaps nothing
975
+ // — the authoritative check, with its state changes, stays in verify.mjs.
976
+ const VERIFIABLE_BY_SQL = `
977
+ SELECT 1 FROM tasks
978
+ WHERE id = ? AND status = 'building' AND lease_owner = ?
979
+ AND (lease_expires_at IS NULL OR lease_expires_at >= datetime('now'))
980
+ `;
981
+
982
+ function taskVerifiableBy(db, taskId, owner) {
983
+ return db.prepare(VERIFIABLE_BY_SQL).get(taskId, owner) !== undefined;
984
+ }
985
+
986
+ // The declared-binary gate for one task: resolves the task's layer in
987
+ // the core definition and returns { layer, missing } when that layer's
988
+ // `requires:` names binaries this environment can't find, or null when
989
+ // there's nothing to say (no core, unknown task/layer, nothing missing).
990
+ //
991
+ // Runs ahead of verifyTask so the verify command is never handed to a
992
+ // shell that will answer `exit 127` with no context. Deliberately makes
993
+ // no state change: the task keeps its lease and its `building` status,
994
+ // so installing the binary and re-running `hedgehog verify` is the whole
995
+ // fix — no unblocking step, and no failed verification recorded for
996
+ // something that never ran.
997
+ //
998
+ // It only speaks for a caller who could actually verify this task. A
999
+ // caller holding no lease, or a stale one, has a different problem, and
1000
+ // telling them to install a binary would send them off to fix something
1001
+ // that isn't in their way — so this stays quiet and lets verifyTask
1002
+ // report the real ownership/state error. Nothing is lost by deferring:
1003
+ // claimForVerify throws before any verify command runs, so falling
1004
+ // through still records no failed verification.
1005
+ async function missingBinariesForTask(db, taskId, owner) {
1006
+ if (!taskVerifiableBy(db, taskId, owner)) return null;
1007
+
1008
+ const row = db.prepare('SELECT layer FROM tasks WHERE id = ?').get(taskId);
1009
+ if (row === undefined) return null;
1010
+
1011
+ const corePath = await resolveCorePath();
1012
+ if (!corePath) return null;
1013
+
1014
+ let core;
1015
+ try {
1016
+ core = await loadCore(corePath);
1017
+ } catch {
1018
+ // An unloadable core.yaml is `plan`'s error to report; don't turn it
1019
+ // into a confusing failure of an unrelated verify.
1020
+ return null;
1021
+ }
1022
+
1023
+ const layer = core.layers.find((l) => l.id === row.layer);
1024
+ if (!layer) return null;
1025
+
1026
+ const missing = missingBinaries(layer.requires);
1027
+ return missing.length === 0 ? null : { layer: layer.id, missing };
1028
+ }
1029
+
768
1030
  async function verifyCommand(args) {
769
1031
  await ensureDb();
770
1032
 
@@ -783,9 +1045,26 @@ async function verifyCommand(args) {
783
1045
  return;
784
1046
  }
785
1047
 
1048
+ printDbTarget();
786
1049
  const db = openDb();
787
1050
  let result;
788
1051
  try {
1052
+ const blockers = await missingBinariesForTask(db, taskId, owner);
1053
+ if (blockers) {
1054
+ const list = blockers.missing.map((b) => bold(b)).join(', ');
1055
+ console.error(
1056
+ `${red(bold('Missing required binaries.'))} Task ${bold(taskId)} (layer ${bold(blockers.layer)}) was not verified.\n`,
1057
+ );
1058
+ console.error(`Not found on PATH: ${list}`);
1059
+ console.error(
1060
+ `${dim(`Declared by layer "${blockers.layer}" in core.yaml (requires:). The verify command was not run.`)}`,
1061
+ );
1062
+ console.error(
1063
+ `${dim('Install them, or put them on the PATH of the shell running hedgehog — an interactive login shell may see binaries a non-interactive one does not — then re-run this command.')}\n`,
1064
+ );
1065
+ process.exitCode = 1;
1066
+ return;
1067
+ }
789
1068
  result = verifyTask(db, taskId, owner);
790
1069
  } catch (err) {
791
1070
  console.error(`${red('Verify failed:')} ${err.message}\n`);
@@ -821,21 +1100,73 @@ async function verifyCommand(args) {
821
1100
  if (result.intentComplete) {
822
1101
  console.log(` ${green('intent complete')} ${dim('every task for this intent is done')}`);
823
1102
  }
1103
+
1104
+ // The one comparison in the circuit. Each layer's verify_command runs
1105
+ // the tests that layer itself wrote, so it measures internal
1106
+ // consistency and never coverage of what was asked — a layer that
1107
+ // builds half an intent and tests that half exhaustively is green.
1108
+ // Closing the last layer is the moment the intent is claimed done, so
1109
+ // that is where what was requested gets printed back to be read against
1110
+ // what was built. It is not a machine-checkable gate; it cannot be. Its
1111
+ // value is that the comparison happens at all, once.
1112
+ if (result.completedIntent) {
1113
+ const { id, goal, outcome } = result.completedIntent;
1114
+ console.log('');
1115
+ console.log(`${bold('INTENT CHECK')} ${bold(id)} ${dim('— this closed the last layer of this intent.')}`);
1116
+ console.log(` ${dim('GOAL')} ${goal}`);
1117
+ console.log(` ${dim('OUTCOME')} ${outcome}`);
1118
+ console.log('');
1119
+ console.log(dim(' Confirm the work built across this intent\'s layers covers the above.'));
1120
+ console.log(dim(' Anything asked for and not built is a Correction Protocol case now,'));
1121
+ console.log(dim(' not a later discovery. Nothing else in the build checks this.'));
1122
+ console.log('');
1123
+ }
1124
+ }
1125
+
1126
+ // Prints the full task packet for each task in `tasks`, read back from
1127
+ // the graph after the claim landed. Claiming moves a task to `building`,
1128
+ // which takes it out of `hedgehog next`'s candidate set — so if claim
1129
+ // doesn't print the packet here, the STATUS/ALLOWED SCOPE/VERIFICATION an
1130
+ // agent is supposed to be dispatched with is no longer reachable from any
1131
+ // command. (`hedgehog show <task-id>` reprints it later.)
1132
+ function printPackets(tasks) {
1133
+ const db = openDb();
1134
+ try {
1135
+ for (const task of tasks) {
1136
+ const packet = taskPacket(db, task.id);
1137
+ if (!packet) continue;
1138
+ console.log();
1139
+ console.log(formatPacket(packet, taskStatusLine(packet.task)));
1140
+ }
1141
+ } finally {
1142
+ db.close();
1143
+ }
1144
+ console.log();
824
1145
  }
825
1146
 
826
- // `hedgehog claim --owner <owner> [--count <n>]` — atomically claims up to
827
- // `count` mutually non-conflicting ready tasks (claimTasks's fan-out, item
828
- // 13) and prints each one's packet-level summary, plus which owner now
829
- // holds them.
1147
+ // `hedgehog claim [<task-id>] --owner <owner> [--count <n>]` — atomically
1148
+ // claims up to `count` mutually non-conflicting ready tasks (claimTasks's
1149
+ // fan-out, item 13), or the one named task, and prints each claimed
1150
+ // task's full packet plus which owner now holds it.
830
1151
  async function claimCommand(args) {
831
1152
  await ensureDb();
832
1153
 
1154
+ // A leading non-flag argument names one specific task; without it this
1155
+ // is the fan-out claim it has always been.
1156
+ const taskId = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
833
1157
  const ownerIdx = args.indexOf('--owner');
834
1158
  const owner = ownerIdx !== -1 ? args[ownerIdx + 1] : undefined;
835
1159
  const countIdx = args.indexOf('--count');
836
1160
  const count = countIdx !== -1 ? Number(args[countIdx + 1]) : 1;
837
1161
  if (!owner) {
838
- console.error(`${red('Usage:')} hedgehog claim --owner <owner> [--count <n>]\n`);
1162
+ console.error(
1163
+ `${red('Usage:')} hedgehog claim --owner <owner> [--count <n>]\n or: hedgehog claim <task-id> --owner <owner>\n`,
1164
+ );
1165
+ process.exitCode = 1;
1166
+ return;
1167
+ }
1168
+ if (taskId && countIdx !== -1) {
1169
+ console.error(`${red('--count cannot be combined with a task id')} — a targeted claim takes exactly one task.\n`);
839
1170
  process.exitCode = 1;
840
1171
  return;
841
1172
  }
@@ -846,6 +1177,13 @@ async function claimCommand(args) {
846
1177
  return;
847
1178
  }
848
1179
 
1180
+ printDbTarget();
1181
+
1182
+ if (taskId) {
1183
+ await claimOneCommand(taskId, owner);
1184
+ return;
1185
+ }
1186
+
849
1187
  const db = openDb();
850
1188
  let claimed;
851
1189
  try {
@@ -868,6 +1206,150 @@ async function claimCommand(args) {
868
1206
  if (claimed.length > 1) console.log(` ${bold(task.id)}`);
869
1207
  console.log(` ${dim('expires')} ${task.lease_expires_at}`);
870
1208
  }
1209
+ printPackets(claimed);
1210
+ }
1211
+
1212
+ // The targeted half of `claim`: one named task, or a non-zero exit
1213
+ // naming which precondition refused it. Never exits 0 without having
1214
+ // claimed something — an operator breaking a starvation tie has to be
1215
+ // able to tell "claimed" from "matched nothing" by exit code alone.
1216
+ async function claimOneCommand(taskId, owner) {
1217
+ const db = openDb();
1218
+ let result;
1219
+ try {
1220
+ result = claimTask(db, taskId, { owner });
1221
+ } finally {
1222
+ db.close();
1223
+ }
1224
+
1225
+ if (result.claimed) {
1226
+ console.log(`${green(bold('Claimed.'))} Task ${bold(taskId)} leased to ${bold(owner)}.`);
1227
+ console.log(` ${dim('expires')} ${result.task.lease_expires_at}`);
1228
+ printPackets([result.task]);
1229
+ return;
1230
+ }
1231
+
1232
+ process.exitCode = 1;
1233
+
1234
+ if (result.reason === 'no_such_task') {
1235
+ console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
1236
+ return;
1237
+ }
1238
+ if (result.reason === 'not_claimable') {
1239
+ const held = result.task.lease_owner ? `, leased to ${result.task.lease_owner}` : '';
1240
+ console.error(
1241
+ `${red('Not claimable.')} Task ${bold(taskId)} is ${bold(result.task.status)}${held}.\n` +
1242
+ (result.task.status === 'blocked'
1243
+ ? `\nReturn it to the queue first: ${bold(`hedgehog retry ${taskId}`)}\n`
1244
+ : '\n'),
1245
+ );
1246
+ return;
1247
+ }
1248
+ if (result.reason === 'incomplete_dependencies') {
1249
+ console.error(`${red('Not claimable.')} Task ${bold(taskId)} is waiting on:\n`);
1250
+ for (const dep of result.incomplete) {
1251
+ console.error(` ${red('✗')} ${bold(dep.id)} ${dep.layer} ${dep.status}`);
1252
+ }
1253
+ console.error();
1254
+ return;
1255
+ }
1256
+ if (result.reason === 'conflict') {
1257
+ console.error(`${red('Not claimable.')} Task ${bold(taskId)} conflicts with work in flight:\n`);
1258
+ for (const { task, kind } of result.conflicting) {
1259
+ console.error(` ${red('✗')} ${bold(task.id)} ${task.status} ${dim(`(${kind})`)}`);
1260
+ }
1261
+ console.error(
1262
+ `\nWait for those to finish, or ${bold('hedgehog release')} them, then claim again.\n`,
1263
+ );
1264
+ return;
1265
+ }
1266
+ console.error(`${red('Not claimed.')} A concurrent claim took ${bold(taskId)} first.\n`);
1267
+ }
1268
+
1269
+ // `hedgehog retry <task-id>` — the transition out of `blocked`, for any
1270
+ // blocked_reason. A failed verification and a scope violation are the
1271
+ // loop's normal failure cases, but `release` only accepts `building` and
1272
+ // `verify` only accepts a task leased to the caller, so before this
1273
+ // command a blocked task had no CLI path back into the queue at all.
1274
+ async function retryCommand(args) {
1275
+ await ensureDb();
1276
+
1277
+ const taskId = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
1278
+ if (!taskId) {
1279
+ console.error(`${red('Usage:')} hedgehog retry <task-id> [--owner <owner>]\n`);
1280
+ process.exitCode = 1;
1281
+ return;
1282
+ }
1283
+
1284
+ if (!(await exists(DB_PATH))) {
1285
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
1286
+ process.exitCode = 1;
1287
+ return;
1288
+ }
1289
+
1290
+ printDbTarget();
1291
+
1292
+ const db = openDb();
1293
+ let result;
1294
+ try {
1295
+ result = retryTask(db, taskId);
1296
+ } finally {
1297
+ db.close();
1298
+ }
1299
+
1300
+ if (!result.retried) {
1301
+ process.exitCode = 1;
1302
+ if (result.reason === 'no_such_task') {
1303
+ console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
1304
+ return;
1305
+ }
1306
+ console.error(
1307
+ `${red('Not retried.')} Task ${bold(taskId)} is ${bold(result.task.status)}, not ${bold('blocked')}.\n`,
1308
+ );
1309
+ return;
1310
+ }
1311
+
1312
+ const reason = BLOCKED_REASON_LABELS[result.from] ?? result.from;
1313
+ console.log(
1314
+ `${green(bold('Retried.'))} Task ${bold(taskId)} is back to ${bold('planned')} ${dim(`(was blocked: ${reason})`)}`,
1315
+ );
1316
+ console.log(` ${dim('claim it with')} hedgehog claim ${taskId} --owner <owner>`);
1317
+ }
1318
+
1319
+ // `hedgehog show <task-id>` — the same packet `next` prints, for a task
1320
+ // named by id whatever its status. Read-only: claims nothing, changes
1321
+ // nothing.
1322
+ async function showCommand(args) {
1323
+ await ensureDb();
1324
+
1325
+ const taskId = args[0];
1326
+ if (!taskId) {
1327
+ console.error(`${red('Usage:')} hedgehog show <task-id>\n`);
1328
+ process.exitCode = 1;
1329
+ return;
1330
+ }
1331
+
1332
+ if (!(await exists(DB_PATH))) {
1333
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
1334
+ process.exitCode = 1;
1335
+ return;
1336
+ }
1337
+
1338
+ const db = openDb();
1339
+ let packet;
1340
+ try {
1341
+ packet = taskPacket(db, taskId);
1342
+ } finally {
1343
+ db.close();
1344
+ }
1345
+
1346
+ if (!packet) {
1347
+ console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
1348
+ process.exitCode = 1;
1349
+ return;
1350
+ }
1351
+
1352
+ console.log(formatPacket(packet, taskStatusLine(packet.task)));
871
1353
  }
872
1354
 
873
1355
  // `hedgehog release <task-id> --owner <owner>` — hands a claimed task
@@ -891,6 +1373,8 @@ async function releaseCommand(args) {
891
1373
  return;
892
1374
  }
893
1375
 
1376
+ printDbTarget();
1377
+
894
1378
  const db = openDb();
895
1379
  let result;
896
1380
  try {
@@ -900,8 +1384,19 @@ async function releaseCommand(args) {
900
1384
  }
901
1385
 
902
1386
  if (!result.released) {
903
- console.error(`${red('Not released.')} Task ${bold(taskId)} is not leased to ${bold(owner)} as ${bold('building')}.\n`);
904
1387
  process.exitCode = 1;
1388
+ if (result.reason === 'no_such_task') {
1389
+ console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
1390
+ return;
1391
+ }
1392
+ console.error(
1393
+ `${red('Not released.')} Task ${bold(taskId)} is ${bold(result.task.status)}${
1394
+ result.task.lease_owner ? `, leased to ${bold(result.task.lease_owner)}` : ''
1395
+ } — not ${bold('building')} under ${bold(owner)}.\n` +
1396
+ (result.task.status === 'blocked'
1397
+ ? `\nReturn a blocked task to the queue with: ${bold(`hedgehog retry ${taskId}`)}\n`
1398
+ : ''),
1399
+ );
905
1400
  return;
906
1401
  }
907
1402
 
@@ -930,6 +1425,8 @@ async function renewCommand(args) {
930
1425
  return;
931
1426
  }
932
1427
 
1428
+ printDbTarget();
1429
+
933
1430
  const db = openDb();
934
1431
  let result;
935
1432
  try {
@@ -939,14 +1436,50 @@ async function renewCommand(args) {
939
1436
  }
940
1437
 
941
1438
  if (!result.renewed) {
942
- console.error(`${red('Not renewed.')} Task ${bold(taskId)} is not leased to ${bold(owner)}.\n`);
943
1439
  process.exitCode = 1;
1440
+ if (result.reason === 'no_such_task') {
1441
+ console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
1442
+ return;
1443
+ }
1444
+ console.error(
1445
+ `${red('Not renewed.')} Task ${bold(taskId)} is ${bold(result.task.status)}${
1446
+ result.task.lease_owner ? `, leased to ${bold(result.task.lease_owner)}` : ''
1447
+ } — not leased to ${bold(owner)}.\n`,
1448
+ );
944
1449
  return;
945
1450
  }
946
1451
 
947
1452
  console.log(`${green(bold('Renewed.'))} Task ${bold(taskId)}'s lease extended by ${minutes} minute(s).`);
948
1453
  }
949
1454
 
1455
+ // Heuristic problems with the project's own core definition, rendered for
1456
+ // `hedgehog status`. Surfaced here because status is the command every
1457
+ // session starts with, and a core-definition smell is a build-wide fact
1458
+ // rather than one task's: a layer whose verify command doesn't reach the
1459
+ // verify_radius it declared lets a task break a neighbour and still
1460
+ // commit green, and nothing later in the loop will say so. Warnings only
1461
+ // — the certainties throw from validateCore, on load.
1462
+ async function coreWarningLines() {
1463
+ const corePath = await resolveCorePath();
1464
+ if (!corePath) return [];
1465
+ const label = relative(DEST_ROOT, corePath) || corePath;
1466
+ let core;
1467
+ try {
1468
+ core = await loadCore(corePath);
1469
+ } catch (err) {
1470
+ // Every other command that loads the core fails outright on this; say
1471
+ // so here rather than letting `hedgehog status` die with a stack.
1472
+ return ['', `${red('CORE')} ${bold(label)} is invalid: ${err.message}`];
1473
+ }
1474
+ const warnings = lintCore(core);
1475
+ if (warnings.length === 0) return [];
1476
+ return [
1477
+ '',
1478
+ `${yellow('CORE WARNINGS')} ${dim(label)}`,
1479
+ ...warnings.map((warning) => ` ${yellow('!')} ${warning}`),
1480
+ ];
1481
+ }
1482
+
950
1483
  async function statusCommand() {
951
1484
  await ensureDb();
952
1485
 
@@ -956,15 +1489,57 @@ async function statusCommand() {
956
1489
  return;
957
1490
  }
958
1491
 
1492
+ // Drift needs the core definition to compare against. A project
1493
+ // without one yet (deferred install, pre-bootstrap) simply gets the
1494
+ // status it always got; an unparseable one is reported but never
1495
+ // allowed to take `status` down — it's the command every session
1496
+ // starts with.
1497
+ let core = null;
1498
+ const corePath = await resolveCorePath();
1499
+ if (corePath) {
1500
+ try {
1501
+ core = await loadCore(corePath);
1502
+ } catch (err) {
1503
+ console.error(
1504
+ `${yellow('Core definition unreadable:')} ${corePath} — ${err.message}\n${dim('Drift against core.yaml cannot be checked.')}\n`,
1505
+ );
1506
+ }
1507
+ }
1508
+
1509
+ const overrides = core ? await loadOverrides() : new Map();
959
1510
  const db = openDb();
960
1511
  let result;
961
1512
  try {
962
- result = graphStatus(db);
1513
+ result = graphStatus(db, { core, overrides });
963
1514
  } finally {
964
1515
  db.close();
965
1516
  }
966
1517
 
1518
+ // Declared-binary check (core.yaml's `requires:`) rides on `status`
1519
+ // because status is what a fresh session runs first — that's the point
1520
+ // at which "this core needs terraform" is a setup fact rather than an
1521
+ // opaque exit 127 twenty minutes into the build. Reuses the `core`
1522
+ // already loaded above for drift — a project with no core definition
1523
+ // yet, or one that failed to parse (reported above already), has
1524
+ // nothing to check and reports nothing here either.
1525
+ if (core) result.missingRequirements = coreMissingRequirements(core);
1526
+
967
1527
  console.log(formatStatus(result));
1528
+
1529
+ // Whether the commit gate is actually enforcing. This is reported at
1530
+ // the start of every session because a gate that isn't running looks
1531
+ // exactly like one that is — `.git/hooks` exists, `lefthook.yml` is
1532
+ // committed, and a passing commit prints nothing to tell the two
1533
+ // apart. Nothing else in the discipline would notice.
1534
+ const gate = await commitGateStatus(DEST_ROOT);
1535
+ const gateText = formatCommitGate(gate);
1536
+ if (gateText) {
1537
+ console.log('');
1538
+ console.log(gate.state === 'active' ? dim(gateText) : yellow(gateText));
1539
+ }
1540
+
1541
+ const warningLines = await coreWarningLines();
1542
+ if (warningLines.length > 0) console.log(warningLines.join('\n'));
968
1543
  }
969
1544
 
970
1545
  // `hedgehog ready` — read-only preview of what a `hedgehog claim` call
@@ -1024,6 +1599,62 @@ async function quiesceCommand() {
1024
1599
  process.exitCode = 1;
1025
1600
  }
1026
1601
 
1602
+ // `hedgehog boundary [--quiet] [--handoff]` — is this a good moment to
1603
+ // clear the conversation, and where would a fresh one pick up?
1604
+ //
1605
+ // `quiesce` answers one third of that question (nothing in flight).
1606
+ // This answers all of it: nothing in flight, a clean working tree, and a
1607
+ // last closed task that completed its intent — see boundary.mjs for how
1608
+ // each is derived. Exits 0 only when all three hold, so a shell hook can
1609
+ // consume it without reading anything.
1610
+ //
1611
+ // Output is split by consumer, not by importance:
1612
+ // stdout — the payload worth capturing: the NEXT/WHY positioning block,
1613
+ // or with --handoff the full block a fresh session starts from.
1614
+ // stderr — the verdict and the per-condition results, so a non-zero
1615
+ // exit always names which condition failed without that
1616
+ // commentary landing in a captured payload.
1617
+ // --quiet drops the commentary and the default payload, leaving the
1618
+ // exit code (and, if asked for explicitly, --handoff's block).
1619
+ //
1620
+ // Exit codes: 0 boundary reached; 1 not a boundary; 2 the question can't
1621
+ // be answered here (no build graph, no git repository, or a last closed
1622
+ // task that isn't identifiable).
1623
+ async function boundaryCommand(args) {
1624
+ const quiet = args.includes('--quiet');
1625
+ const handoff = args.includes('--handoff');
1626
+
1627
+ await ensureDb({ log: (msg) => console.error(msg) });
1628
+
1629
+ if (!(await exists(DB_PATH))) {
1630
+ if (!quiet) {
1631
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
1632
+ }
1633
+ process.exitCode = 2;
1634
+ return;
1635
+ }
1636
+
1637
+ const db = openDb();
1638
+ let state;
1639
+ try {
1640
+ state = boundaryState(db);
1641
+ } finally {
1642
+ db.close();
1643
+ }
1644
+
1645
+ if (!quiet) console.error(formatBoundary(state));
1646
+ if (handoff) console.log(formatHandoff(state));
1647
+ else if (!quiet && state.reached) console.log(formatPosition(state));
1648
+
1649
+ if (state.undecidable) {
1650
+ process.exitCode = 2;
1651
+ return;
1652
+ }
1653
+ if (!state.reached) {
1654
+ process.exitCode = 1;
1655
+ }
1656
+ }
1657
+
1027
1658
  const GRAPH_PIDFILE_PATH = '.hedgehog/graph-server.json';
1028
1659
  const GRAPH_SERVER_MODULE = join(PKG_ROOT, 'src/db/graph-server.mjs');
1029
1660
  const GRAPH_TEMPLATE_PATH = join(PKG_ROOT, 'src/templates/graph.html');
@@ -1040,6 +1671,28 @@ function openInBrowser(url) {
1040
1671
  spawn(cmd, args, { detached: true, stdio: 'ignore', shell: platform === 'win32' }).unref();
1041
1672
  }
1042
1673
 
1674
+ // True when there is plausibly a local display for a browser to open on.
1675
+ // macOS and Windows always have one; on Linux/BSD a session with neither
1676
+ // DISPLAY nor WAYLAND_DISPLAY set is headless (an SSH session, a
1677
+ // container, an agent's non-interactive shell), and `xdg-open` there
1678
+ // either fails silently or blocks on a text browser. Printing the URL is
1679
+ // the useful behaviour in that case — a person on the other end of a
1680
+ // port-forward can still open it.
1681
+ function hasDisplay() {
1682
+ if (process.platform === 'darwin' || process.platform === 'win32') return true;
1683
+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
1684
+ }
1685
+
1686
+ // Hands `url` to the OS browser when that can work, and otherwise prints
1687
+ // it. One place, so `plan --open` and `graph` behave identically.
1688
+ function presentGraphUrl(url, { noOpen = false } = {}) {
1689
+ if (noOpen || !hasDisplay()) {
1690
+ console.log(`\nOpen ${bold(url)} in a browser to view it.`);
1691
+ return;
1692
+ }
1693
+ openInBrowser(url);
1694
+ }
1695
+
1043
1696
  // True if `pid` names a live process. Sending signal 0 performs the
1044
1697
  // existence/permission check without actually signalling anything — the
1045
1698
  // standard POSIX idiom `kill -0` follows, and Node exposes it the same
@@ -1054,12 +1707,14 @@ function isProcessAlive(pid) {
1054
1707
  }
1055
1708
 
1056
1709
  // Returns the port of a running graph server for this project, starting
1057
- // one if none is live. Both `plan` (auto-open after scoping) and `graph`
1058
- // (explicit request) call this rather than each managing their own
1059
- // server, so a project only ever has one live server no matter which
1060
- // command a person or agent happens to run re-running `plan` after
1061
- // `graph` is already open reuses the same tab's server instead of
1062
- // spawning a second one bound to a different port.
1710
+ // one if none is live. Both `plan --open` and `graph` call this rather
1711
+ // than each managing their own server, so a project only ever has one
1712
+ // live server no matter which command a person or agent happens to run
1713
+ // re-running `plan --open` after `graph` is already open reuses the same
1714
+ // tab's server instead of spawning a second one bound to a different
1715
+ // port. Nothing else calls it: a command that isn't asked to open the
1716
+ // graph starts no server, so it can leave neither a pidfile nor a
1717
+ // detached process behind.
1063
1718
  async function startOrReuseGraphServer() {
1064
1719
  const pidfilePath = join(DEST_ROOT, GRAPH_PIDFILE_PATH);
1065
1720
 
@@ -1143,12 +1798,9 @@ async function graphCommand(args) {
1143
1798
  // --no-open covers headless/SSH sessions where there's no local
1144
1799
  // browser to hand a URL to — the server itself is still started (or
1145
1800
  // reused) either way, since a remote person may open the URL manually
1146
- // via port-forwarding.
1147
- if (args.includes('--no-open')) {
1148
- console.log(`\nOpen ${bold(url)} in a browser to view it.`);
1149
- } else {
1150
- openInBrowser(url);
1151
- }
1801
+ // via port-forwarding. presentGraphUrl reaches the same outcome
1802
+ // unasked when the session has no display at all.
1803
+ presentGraphUrl(url, { noOpen: args.includes('--no-open') });
1152
1804
  }
1153
1805
 
1154
1806
  async function whyCommand(args) {
@@ -1211,6 +1863,8 @@ async function frictionCommand(args) {
1211
1863
  return;
1212
1864
  }
1213
1865
 
1866
+ printDbTarget();
1867
+
1214
1868
  const db = openDb();
1215
1869
  let entry;
1216
1870
  try {
@@ -1253,6 +1907,190 @@ async function frictionCommand(args) {
1253
1907
  process.exitCode = 1;
1254
1908
  }
1255
1909
 
1910
+ // `hedgehog override add <task-id> --scope <glob> [--scope <glob>...]
1911
+ // --reason "<why>"` / `hedgehog override list` — per-task scope
1912
+ // exceptions (see src/db/overrides.mjs). Writes
1913
+ // .hedgehog/overrides/<task-id>.json; the next `hedgehog plan` (for a
1914
+ // not-yet-compiled task) or `hedgehog plan --recompile` (for one already
1915
+ // compiled) is what actually widens the task's scope_globs — this command
1916
+ // only records the committed intent to do so.
1917
+ async function overrideCommand(args) {
1918
+ await ensureDb();
1919
+
1920
+ const sub = args[0];
1921
+
1922
+ if (sub === 'add') {
1923
+ const taskId = args[1];
1924
+ const rest = args.slice(2);
1925
+ const scopeAdd = [];
1926
+ let reason;
1927
+ for (let i = 0; i < rest.length; i++) {
1928
+ const flag = rest[i];
1929
+ const value = rest[i + 1];
1930
+ if (flag === '--scope') {
1931
+ if (!value) {
1932
+ console.error(`${red('--scope requires a glob')}\n`);
1933
+ process.exitCode = 1;
1934
+ return;
1935
+ }
1936
+ scopeAdd.push(value);
1937
+ i++;
1938
+ } else if (flag === '--reason') {
1939
+ if (!value) {
1940
+ console.error(`${red('--reason requires text')}\n`);
1941
+ process.exitCode = 1;
1942
+ return;
1943
+ }
1944
+ reason = value;
1945
+ i++;
1946
+ } else {
1947
+ console.error(`${red('Unknown override flag:')} ${flag}\n`);
1948
+ process.exitCode = 1;
1949
+ return;
1950
+ }
1951
+ }
1952
+
1953
+ if (!taskId || scopeAdd.length === 0 || !reason) {
1954
+ console.error(
1955
+ `${red('Usage:')} hedgehog override add <task-id> --scope <glob> [--scope <glob>...] --reason "<why>"\n`,
1956
+ );
1957
+ process.exitCode = 1;
1958
+ return;
1959
+ }
1960
+
1961
+ let record;
1962
+ try {
1963
+ record = await addOverride({ task: taskId, scope_add: scopeAdd, reason });
1964
+ } catch (err) {
1965
+ console.error(`${red('Failed to add override:')} ${err.message}\n`);
1966
+ process.exitCode = 1;
1967
+ return;
1968
+ }
1969
+
1970
+ console.log(` ${green('added')} ${OVERRIDES_DIR}/${record.task.toLowerCase()}.json`);
1971
+ for (const glob of record.scope_add) console.log(` + ${glob}`);
1972
+ console.log(
1973
+ ` ${dim(`run \`hedgehog plan --recompile\` to widen ${record.task} now, if it's already compiled`)}\n`,
1974
+ );
1975
+ return;
1976
+ }
1977
+
1978
+ if (sub === 'list') {
1979
+ const overrides = await loadOverrides();
1980
+ if (overrides.size === 0) {
1981
+ console.log(`${dim('No overrides recorded.')}\n`);
1982
+ return;
1983
+ }
1984
+
1985
+ // Best-effort: an override file can legitimately exist before `db
1986
+ // init`/`plan` has ever run (e.g. written ahead of the intent it
1987
+ // targets), so a missing DB just skips the orphan check rather than
1988
+ // failing the whole listing.
1989
+ let orphaned = [];
1990
+ if (await exists(DB_PATH)) {
1991
+ const db = openDb({ readOnly: true });
1992
+ try {
1993
+ orphaned = orphanedOverrides(db, overrides);
1994
+ } finally {
1995
+ db.close();
1996
+ }
1997
+ }
1998
+
1999
+ for (const [taskId, records] of overrides) {
2000
+ for (const record of records) {
2001
+ console.log(`${bold(taskId)}`);
2002
+ console.log(` ${dim(record.reason)}`);
2003
+ for (const glob of record.scope_add) console.log(` + ${glob}`);
2004
+ console.log('');
2005
+ }
2006
+ }
2007
+
2008
+ if (orphaned.length > 0) {
2009
+ console.log(
2010
+ `${yellow(bold('Orphaned:'))} ${orphaned.join(', ')} — no task with this id exists in the\n` +
2011
+ `build graph yet. A typo'd id, a renamed layer/module, or an intent that hasn't\n` +
2012
+ `compiled yet all look like this; it silently widens nothing until the id matches.\n`,
2013
+ );
2014
+ }
2015
+ return;
2016
+ }
2017
+
2018
+ console.error(
2019
+ `${red('Unknown override subcommand:')} ${sub ?? '(none)'}\n\n` +
2020
+ `Usage: hedgehog override add <task-id> --scope <glob> [--scope <glob>...] --reason "<why>"\n` +
2021
+ ` or: hedgehog override list\n`,
2022
+ );
2023
+ process.exitCode = 1;
2024
+ }
2025
+
2026
+ // `hedgehog debt add <task-id> "<note>"` / `hedgehog debt list [<task-id>]`
2027
+ // — declared debt between tasks. A note recorded against a task is
2028
+ // rendered into the INHERITED DEBT section of the packet of every task
2029
+ // that depends on it (see src/db/debt.mjs and src/db/next.mjs).
2030
+ async function debtCommand(args) {
2031
+ await ensureDb();
2032
+
2033
+ const sub = args[0];
2034
+
2035
+ if (!(await exists(DB_PATH))) {
2036
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
2037
+ process.exitCode = 1;
2038
+ return;
2039
+ }
2040
+
2041
+ if (sub === 'add') {
2042
+ const taskId = args[1];
2043
+ const note = args.slice(2).join(' ');
2044
+ if (!taskId || !note) {
2045
+ console.error(`${red('Usage:')} hedgehog debt add <task-id> "<note>"\n`);
2046
+ process.exitCode = 1;
2047
+ return;
2048
+ }
2049
+
2050
+ const db = openDb();
2051
+ let entry;
2052
+ try {
2053
+ entry = addDebt(db, { taskId, note });
2054
+ } catch (err) {
2055
+ console.error(`${red('Failed to declare debt:')} ${err.message}\n`);
2056
+ process.exitCode = 1;
2057
+ return;
2058
+ } finally {
2059
+ db.close();
2060
+ }
2061
+
2062
+ console.log(` ${green('declared')} #${entry.id} ${bold(entry.taskId)}`);
2063
+ console.log(` ${dim('reaches the packet of every task depending on it')}`);
2064
+ return;
2065
+ }
2066
+
2067
+ if (sub === 'list') {
2068
+ const taskId = args[1];
2069
+ const db = openDb();
2070
+ let entries;
2071
+ try {
2072
+ entries = listDebt(db, taskId);
2073
+ } finally {
2074
+ db.close();
2075
+ }
2076
+
2077
+ if (entries.length === 0) {
2078
+ console.log(`${dim('No debt declared.')}\n`);
2079
+ return;
2080
+ }
2081
+ for (const entry of entries) {
2082
+ console.log(`#${entry.id} ${dim(entry.loggedAt)} ${bold(entry.taskId)}`);
2083
+ console.log(` ${entry.note}\n`);
2084
+ }
2085
+ return;
2086
+ }
2087
+
2088
+ console.error(
2089
+ `${red('Unknown debt subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog debt add <task-id> "<note>"\n or: hedgehog debt list [<task-id>]\n`,
2090
+ );
2091
+ process.exitCode = 1;
2092
+ }
2093
+
1256
2094
  async function main() {
1257
2095
  const args = process.argv.slice(2);
1258
2096
  if (args.includes('--help') || args.includes('-h') || args.length === 0) {
@@ -1322,7 +2160,7 @@ async function main() {
1322
2160
  }
1323
2161
 
1324
2162
  if (cmd === 'plan') {
1325
- await planCommand();
2163
+ await planCommand(args.slice(1));
1326
2164
  return;
1327
2165
  }
1328
2166
 
@@ -1346,6 +2184,16 @@ async function main() {
1346
2184
  return;
1347
2185
  }
1348
2186
 
2187
+ if (cmd === 'show') {
2188
+ await showCommand(args.slice(1));
2189
+ return;
2190
+ }
2191
+
2192
+ if (cmd === 'retry') {
2193
+ await retryCommand(args.slice(1));
2194
+ return;
2195
+ }
2196
+
1349
2197
  if (cmd === 'release') {
1350
2198
  await releaseCommand(args.slice(1));
1351
2199
  return;
@@ -1371,6 +2219,11 @@ async function main() {
1371
2219
  return;
1372
2220
  }
1373
2221
 
2222
+ if (cmd === 'boundary') {
2223
+ await boundaryCommand(args.slice(1));
2224
+ return;
2225
+ }
2226
+
1374
2227
  if (cmd === 'graph') {
1375
2228
  await graphCommand(args.slice(1));
1376
2229
  return;
@@ -1386,6 +2239,16 @@ async function main() {
1386
2239
  return;
1387
2240
  }
1388
2241
 
2242
+ if (cmd === 'debt') {
2243
+ await debtCommand(args.slice(1));
2244
+ return;
2245
+ }
2246
+
2247
+ if (cmd === 'override') {
2248
+ await overrideCommand(args.slice(1));
2249
+ return;
2250
+ }
2251
+
1389
2252
  console.error(`${red('Unknown command:')} ${cmd}\n`);
1390
2253
  await help();
1391
2254
  process.exitCode = 1;