@skyf0xx/hedgehog 6.1.7 → 6.1.8

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/bin/cli.mjs CHANGED
@@ -18,7 +18,7 @@ import { constants, existsSync, realpathSync } from 'node:fs';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { dirname, join, relative, resolve } from 'node:path';
20
20
  import { spawn, execFileSync } from 'node:child_process';
21
- import { dbInit, DB_PATH, dbAbsPath, openDb } from '../src/db/init.mjs';
21
+ import { dbInit, DB_PATH, dbAbsPath, openDb, openDbAt } from '../src/db/init.mjs';
22
22
  import { loadCore, lintCore, isModuleAxis } from '../src/db/core.mjs';
23
23
  import { planTasks, CORE_INTENT_ID } from '../src/db/plan.mjs';
24
24
  import { addIntent, INTENTS_DIR } from '../src/db/intent.mjs';
@@ -41,7 +41,13 @@ import {
41
41
  reapExpiredLeases,
42
42
  } from '../src/db/claim.mjs';
43
43
  import { readyTasks, formatReady } from '../src/db/ready.mjs';
44
- import { graphStatus, formatStatus, inFlightTasks, formatBrief } from '../src/db/status.mjs';
44
+ import {
45
+ graphStatus,
46
+ graphWorktreeStatus,
47
+ formatStatus,
48
+ inFlightTasks,
49
+ formatBrief,
50
+ } from '../src/db/status.mjs';
45
51
  import { boundaryState, formatBoundary, formatPosition, formatHandoff } from '../src/db/boundary.mjs';
46
52
  import { commitGateStatus, formatCommitGate } from '../src/db/gate.mjs';
47
53
  import { detectDrift, recompileTasks, formatRecompile } from '../src/db/drift.mjs';
@@ -61,6 +67,25 @@ import {
61
67
  REPO_URL,
62
68
  } from '../src/db/community.mjs';
63
69
  import { rebuildDb } from '../src/db/rebuild.mjs';
70
+ import {
71
+ eligibleIntents,
72
+ hasWorktree,
73
+ createWorktree,
74
+ hedgehogWorktrees,
75
+ branchName,
76
+ worktreePath,
77
+ intentReadyToMerge,
78
+ intentTaskStatuses,
79
+ mergeBranch,
80
+ removeWorktree,
81
+ intentFileCommitted,
82
+ onHedgehogBranch,
83
+ loadAbandoned,
84
+ writeAbandonedFile,
85
+ applyAbandonment,
86
+ replayAbandonments,
87
+ ABANDONED_DIR,
88
+ } from '../src/db/worktree.mjs';
64
89
  import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
65
90
  import {
66
91
  gatherEvidence,
@@ -549,6 +574,9 @@ ${bold('Usage')}
549
574
  outside 'hedgehog verify' — run it to mark that work complete
550
575
  npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
551
576
  (starts no graph server; --no-open says so explicitly)
577
+ an intent whose declared intent_dependencies just
578
+ cleared gets its own git worktree + branch instead of
579
+ compiling here; see 'hedgehog merge'/'hedgehog abandon'
552
580
  npx @skyf0xx/hedgehog plan --open also start the graph server and open it, if anything compiled
553
581
  npx @skyf0xx/hedgehog plan --recompile rewrite core.yaml-derived fields on not-started tasks
554
582
  [--dry-run] [--include-blocked] [--strict]
@@ -561,6 +589,13 @@ ${bold('Usage')}
561
589
  close one task on your judgment — no scope gate and no
562
590
  verify command run; records it under .hedgehog/reconciled/
563
591
  npx @skyf0xx/hedgehog reconcile list list recorded reconciliations
592
+ npx @skyf0xx/hedgehog merge <intent-id> merge that intent's worktree branch into trunk,
593
+ rebuild trunk's graph, remove the worktree — fails
594
+ if the intent's tasks aren't all complete there
595
+ npx @skyf0xx/hedgehog abandon <intent-id> --reason "<why>"
596
+ drop an intent that will never finish: records why,
597
+ resets its tasks to planned on trunk, removes its
598
+ worktree and branch
564
599
  npx @skyf0xx/hedgehog intent add [flags] add an intent (rules/requirements/dependencies)
565
600
  npx @skyf0xx/hedgehog intent add --file <path> add an intent from a JSON file
566
601
  npx @skyf0xx/hedgehog next print the task packet for one ready task
@@ -1217,6 +1252,20 @@ async function dbRebuildCommand() {
1217
1252
  `no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
1218
1253
  );
1219
1254
  }
1255
+ if (result.abandonmentsReplayed?.length > 0) {
1256
+ console.log(
1257
+ `${dim(`${result.abandonmentsReplayed.length} intent(s) replayed from ${ABANDONED_DIR}/ — kept at planned, not active:`)}\n` +
1258
+ result.abandonmentsReplayed.map(({ intentId, reason }) => ` ${intentId} ${dim(reason)}`).join('\n') +
1259
+ '\n',
1260
+ );
1261
+ }
1262
+ if (result.orphanedAbandonments?.length > 0) {
1263
+ console.log(
1264
+ `${yellow(bold('Abandonments not replayed.'))} ${result.orphanedAbandonments.join(', ')} —\n` +
1265
+ `either no intent with this id exists in the rebuilt graph, or it is already\n` +
1266
+ `complete (merged since), so each does nothing.\n`,
1267
+ );
1268
+ }
1220
1269
  warnOrphanedNotes(result);
1221
1270
  warnRebuildDrift(result, corePath);
1222
1271
  }
@@ -1435,14 +1484,98 @@ async function planCommand(args = []) {
1435
1484
 
1436
1485
  const overrides = await loadOverrides();
1437
1486
 
1487
+ // Worktree trigger: an intent whose `intent_dependencies` just cleared
1488
+ // (eligibleIntents.mjs's rule — see worktree.mjs for why "declares at
1489
+ // least one dependency" is part of that rule, not just "all complete")
1490
+ // gets its own `git worktree` and branch instead of compiling onto this
1491
+ // checkout. Read before planTasks runs, against the same handle, so the
1492
+ // exclusion set reflects the graph plan is about to compile against.
1493
+ //
1494
+ // Skipped entirely when this checkout is itself already a `hedgehog/*`
1495
+ // worktree (onHedgehogBranch) — this is the recursive `hedgehog plan`
1496
+ // this same command runs inside a worktree it just created, to compile
1497
+ // that worktree's own intent, and that intent reads as "eligible" again
1498
+ // by the same rule that got it a worktree in the first place. Without
1499
+ // this guard, that recursive call would try to create a second,
1500
+ // colliding worktree for itself instead of just compiling normally.
1501
+ let eligible = [];
1502
+ if (!onHedgehogBranch()) {
1503
+ const eligibilityDb = openDb({ readOnly: true });
1504
+ try {
1505
+ eligible = eligibleIntents(eligibilityDb);
1506
+ } finally {
1507
+ eligibilityDb.close();
1508
+ }
1509
+ }
1510
+
1511
+ // Every eligible intent is excluded from planTasks's own compile-onto-
1512
+ // trunk path below, whether or not this loop actually manages to give
1513
+ // it a worktree this run (hasWorktree true already, or the commit check
1514
+ // below defers it) — an eligible intent must never fall through to
1515
+ // compiling on trunk, or it would sit there just like any pre-feature
1516
+ // intent and this feature would have done nothing for it.
1517
+ const excludeIntentIds = new Set(eligible.map((i) => i.id));
1518
+
1519
+ const worktreesCreated = [];
1520
+ for (const intent of eligible) {
1521
+ if (hasWorktree(intent.id)) continue; // already has one — idempotent re-run
1522
+ // A new worktree checks out its branch from HEAD's committed tree —
1523
+ // it cannot see an intent file still sitting uncommitted in trunk's
1524
+ // working directory (worktree.mjs#intentFileCommitted). Left pending
1525
+ // rather than either worktree'd-with-nothing-in-it or silently
1526
+ // compiled onto trunk: the next `hedgehog plan`, after the commit
1527
+ // lands, picks it up correctly.
1528
+ if (!intentFileCommitted(intent.id)) {
1529
+ console.log(
1530
+ ` ${yellow('pending')} ${bold(intent.id)} ${dim(`is worktree-eligible but .hedgehog/intents/${intent.id}.json isn't committed yet — commit it, then run \`hedgehog plan\` again`)}`,
1531
+ );
1532
+ continue;
1533
+ }
1534
+ let created;
1535
+ try {
1536
+ created = createWorktree(intent.id);
1537
+ } catch (err) {
1538
+ console.error(
1539
+ `${yellow('Could not create a worktree for')} ${bold(intent.id)}${dim(':')} ${err.message}\n`,
1540
+ );
1541
+ continue;
1542
+ }
1543
+ worktreesCreated.push({ intentId: intent.id, ...created });
1544
+ }
1545
+
1438
1546
  const db = openDb();
1439
1547
  let result;
1440
1548
  try {
1441
- result = planTasks(db, core, overrides);
1549
+ result = planTasks(db, core, overrides, { excludeIntentIds });
1442
1550
  } finally {
1443
1551
  db.close();
1444
1552
  }
1445
1553
 
1554
+ for (const { intentId, branch, path } of worktreesCreated) {
1555
+ console.log(` ${green('worktree')} ${bold(intentId)} ${dim(`${branch} → ${path}`)}`);
1556
+ }
1557
+
1558
+ // Compiles the new worktree's own graph from inside it — a plain
1559
+ // `hedgehog plan` subprocess with `cwd` set to the worktree, so that
1560
+ // intent's tasks land only in its own `.hedgehog/hedgehog.db`, per this
1561
+ // feature's DB-shape rule (worktree.mjs's file header). `ensureDb`
1562
+ // (this file) creates that DB from the intent files the new branch
1563
+ // already carries — no different from a fresh clone.
1564
+ for (const { intentId, path } of worktreesCreated) {
1565
+ try {
1566
+ execFileSync(process.execPath, [fileURLToPath(import.meta.url), 'plan'], {
1567
+ cwd: path,
1568
+ stdio: 'inherit',
1569
+ env: { ...process.env, HEDGEHOG_NO_UPDATE_CHECK: '1' },
1570
+ });
1571
+ } catch (err) {
1572
+ console.error(
1573
+ `${yellow('Worktree created but its own `hedgehog plan` failed:')} ${bold(intentId)}\n` +
1574
+ ` ${dim(err.message)}\n cd ${path} && hedgehog plan\n`,
1575
+ );
1576
+ }
1577
+ }
1578
+
1446
1579
  for (const id of result.once) {
1447
1580
  console.log(` ${green('compiled')} ${id} ${dim('(once — one task for the whole build)')}`);
1448
1581
  }
@@ -2644,6 +2777,7 @@ async function statusCommand(args = []) {
2644
2777
  // blocked" until some other command happens to reap it first.
2645
2778
  reapExpiredLeases(db);
2646
2779
  result = graphStatus(db, { core, overrides });
2780
+ result.worktrees = await graphWorktreeStatus(db);
2647
2781
  } finally {
2648
2782
  db.close();
2649
2783
  }
@@ -3266,6 +3400,301 @@ async function reconcileCommand(args) {
3266
3400
  console.log(`${formatEvidence(evidence)}\n`);
3267
3401
  }
3268
3402
 
3403
+ // `hedgehog merge <intent-id>` — merges `hedgehog/<intent-id>` into trunk
3404
+ // with `git merge --no-ff`, rebuilds trunk's graph from what merged, then
3405
+ // removes the worktree and its branch. See src/db/worktree.mjs for why a
3406
+ // rebuild is the whole merge step: no DB row ever crosses from the
3407
+ // worktree's own graph to trunk's.
3408
+ //
3409
+ // Refuses before touching git at all if the intent's tasks are not all
3410
+ // `complete` in the worktree's own graph — checked there, never here,
3411
+ // since trunk has no row for a task compiled only inside the worktree.
3412
+ async function mergeCommand(args) {
3413
+ const intentId = args[0];
3414
+ if (!intentId || intentId.startsWith('--')) {
3415
+ console.error(`${red('Usage:')} hedgehog merge <intent-id>\n`);
3416
+ process.exitCode = 1;
3417
+ return;
3418
+ }
3419
+ // Every git/DB call below defaults to `process.cwd()` and assumes it's
3420
+ // trunk: `rebuildDb` mutates whichever `.hedgehog/hedgehog.db` that cwd
3421
+ // resolves to, `mergeBranch` runs `git merge --no-ff` there, and
3422
+ // `removeWorktree`'s `git worktree remove` refuses to delete a worktree
3423
+ // you're standing in. Run from inside the intent's own worktree
3424
+ // checkout — a realistic mistake, since the worktree is exactly where a
3425
+ // user would `cd` in to inspect the work before merging it — every one
3426
+ // of those would operate on (or fail against) the wrong checkout. Same
3427
+ // guard `hedgehog plan` uses to detect it's inside a worktree
3428
+ // (worktree.mjs#onHedgehogBranch), applied here as a refusal rather than
3429
+ // a trigger-skip.
3430
+ if (onHedgehogBranch()) {
3431
+ console.error(
3432
+ `${red('Run `hedgehog merge` from trunk, not from inside a worktree.')}\n` +
3433
+ `This checkout is on a \`hedgehog/*\` branch — merging from here would run\n` +
3434
+ `\`git merge\` against the worktree itself instead of trunk. \`cd\` back to the\n` +
3435
+ `main checkout and run \`hedgehog merge ${intentId}\` there.\n`,
3436
+ );
3437
+ process.exitCode = 1;
3438
+ return;
3439
+ }
3440
+ // No case-folding: unlike a task id (plan.mjs#taskId always upper-cases
3441
+ // one), an intent id is stored exactly as given to `hedgehog intent add
3442
+ // --id` — intent.mjs never normalizes its case — so it must be typed
3443
+ // here exactly as it was there.
3444
+ const id = intentId;
3445
+
3446
+ const worktrees = hedgehogWorktrees();
3447
+ const worktree = worktrees.find((w) => w.intentId === id);
3448
+ if (!worktree) {
3449
+ console.error(
3450
+ `${red('No active worktree for')} ${bold(id)}${red('.')} ${dim(`Expected branch ${branchName(id)} from \`git worktree list\`.`)}\n`,
3451
+ );
3452
+ process.exitCode = 1;
3453
+ return;
3454
+ }
3455
+
3456
+ // Opened against the worktree's own DB (its cwd), never trunk's — the
3457
+ // completeness check this command exists to enforce has no meaning
3458
+ // against a graph that never compiled this intent's tasks at all.
3459
+ //
3460
+ // Checked for existence first: if the recursive `hedgehog plan`
3461
+ // subprocess planCommand runs for a freshly created worktree ever
3462
+ // failed (the case its own catch block already anticipates, telling the
3463
+ // user to `cd` in and re-run), `.hedgehog/hedgehog.db` was never created
3464
+ // inside that worktree, and openDbAt would otherwise throw a raw SQLite
3465
+ // "unable to open database file" error instead of the friendly
3466
+ // `no_tasks` message the branch below was written to produce for
3467
+ // exactly this situation.
3468
+ const worktreeDbPath = join(worktree.path, DB_PATH);
3469
+ if (!existsSync(worktreeDbPath)) {
3470
+ console.error(
3471
+ `${red('Nothing to merge.')} ${bold(id)} has no build graph in its own worktree yet\n` +
3472
+ `(${worktreeDbPath} does not exist). Run \`hedgehog plan\` inside the worktree first.\n`,
3473
+ );
3474
+ process.exitCode = 1;
3475
+ return;
3476
+ }
3477
+
3478
+ let readiness;
3479
+ const worktreeDb = openDbAt(worktreeDbPath, { readOnly: true });
3480
+ try {
3481
+ readiness = intentReadyToMerge(worktreeDb, id);
3482
+ } finally {
3483
+ worktreeDb.close();
3484
+ }
3485
+
3486
+ if (!readiness.ready) {
3487
+ if (readiness.reason === 'no_tasks') {
3488
+ console.error(
3489
+ `${red('Nothing to merge.')} ${bold(id)} has no compiled tasks in its own worktree graph\n` +
3490
+ `(${join(worktree.path, DB_PATH)}). Run \`hedgehog plan\` inside the worktree first.\n`,
3491
+ );
3492
+ } else {
3493
+ console.error(
3494
+ `${red('Not all tasks are complete.')} ${bold(id)}'s worktree graph still has:\n\n` +
3495
+ readiness.incomplete.map((t) => ` ${t.id} ${t.status}`).join('\n') +
3496
+ '\n\nFinish and verify every task in the worktree before merging.\n',
3497
+ );
3498
+ }
3499
+ process.exitCode = 1;
3500
+ return;
3501
+ }
3502
+
3503
+ console.log(` ${dim('merging')} ${worktree.branch} → trunk`);
3504
+ try {
3505
+ mergeBranch(id);
3506
+ } catch (err) {
3507
+ console.error(
3508
+ `${red('git merge failed:')} ${err.message}\n\n` +
3509
+ `Resolve the conflict by hand in this checkout, then finish with:\n` +
3510
+ ` git add -A && git commit\n` +
3511
+ ` hedgehog db rebuild\n` +
3512
+ ` git worktree remove ${worktree.path} && git branch -D ${worktree.branch}\n`,
3513
+ );
3514
+ process.exitCode = 1;
3515
+ return;
3516
+ }
3517
+
3518
+ printDbTarget();
3519
+ const corePath = await resolveCorePath();
3520
+ const db = openDb();
3521
+ let result;
3522
+ try {
3523
+ // `id`'s worktree is still active at this point (removed only after a
3524
+ // successful rebuild, below) — rebuildDb's own open-worktree exclusion
3525
+ // would otherwise treat the intent this command exists to bring onto
3526
+ // trunk as still worktree-only. mergingIntentId names the one
3527
+ // exception.
3528
+ result = await rebuildDb(db, { corePath, mergingIntentId: id });
3529
+ } finally {
3530
+ db.close();
3531
+ }
3532
+ console.log(
3533
+ ` ${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}`,
3534
+ );
3535
+ if (result.intentsMarkedComplete?.length > 0) {
3536
+ console.log(
3537
+ ` ${dim(`${result.intentsMarkedComplete.length} intent(s) closed: ${result.intentsMarkedComplete.join(', ')}`)}`,
3538
+ );
3539
+ }
3540
+ if (result.abandonmentsReplayed?.length > 0) {
3541
+ console.log(
3542
+ ` ${dim(`${result.abandonmentsReplayed.length} intent(s) replayed from ${ABANDONED_DIR}/ — kept at planned, not active:`)}\n` +
3543
+ result.abandonmentsReplayed.map(({ intentId, reason }) => ` ${intentId} ${dim(reason)}`).join('\n'),
3544
+ );
3545
+ }
3546
+ if (result.orphanedAbandonments?.length > 0) {
3547
+ console.log(
3548
+ ` ${yellow(bold('Abandonments not replayed.'))} ${result.orphanedAbandonments.join(', ')} —\n` +
3549
+ ` either no intent with this id exists in the rebuilt graph, or it is already\n` +
3550
+ ` complete (merged since), so each does nothing.`,
3551
+ );
3552
+ }
3553
+ warnOrphanedNotes(result);
3554
+ warnRebuildDrift(result, corePath);
3555
+
3556
+ try {
3557
+ removeWorktree(worktree.path, { branch: worktree.branch, removeBranch: true });
3558
+ console.log(` ${green('removed')} ${worktree.path} ${dim(`(and branch ${worktree.branch})`)}`);
3559
+ } catch (err) {
3560
+ console.error(
3561
+ `${yellow('Merged, but could not remove the worktree:')} ${err.message}\n` +
3562
+ ` git worktree remove ${worktree.path} && git branch -D ${worktree.branch}\n`,
3563
+ );
3564
+ }
3565
+
3566
+ console.log(`\n${green(bold('Merged.'))} ${bold(id)} is complete on trunk.\n`);
3567
+ }
3568
+
3569
+ // `hedgehog abandon <intent-id> --reason "<why>"` — drops an intent that
3570
+ // will never be finished: writes a committed abandonment record
3571
+ // (`.hedgehog/abandoned/<intent-id>.json`, worktree.mjs — the same
3572
+ // temp-file+rename, replayed-on-rebuild shape reconcile.mjs's confirmed
3573
+ // reconciliations use), resets the intent's tasks to `planned` on trunk,
3574
+ // and removes the worktree and branch.
3575
+ //
3576
+ // Never routed through claim.mjs's lease-expiry reaping — a worktree
3577
+ // legitimately sits idle for days between sessions, and treating that
3578
+ // idleness as a dead lease would garbage-collect real, unfinished work.
3579
+ // Abandonment is a deliberate act with a stated reason, not a timeout.
3580
+ async function abandonCommand(args) {
3581
+ const intentId = args[0];
3582
+ const reasonIdx = args.indexOf('--reason');
3583
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
3584
+
3585
+ if (!intentId || intentId.startsWith('--') || !reason) {
3586
+ console.error(`${red('Usage:')} hedgehog abandon <intent-id> --reason "<why>"\n`);
3587
+ process.exitCode = 1;
3588
+ return;
3589
+ }
3590
+ // Same reasoning and same guard as mergeCommand: applyAbandonment and
3591
+ // removeWorktree both default to `process.cwd()` and assume it's trunk.
3592
+ // Run from inside the intent's own worktree checkout, this would mutate
3593
+ // the worktree's own DB instead of trunk's and then fail to remove a
3594
+ // worktree the command is standing in.
3595
+ if (onHedgehogBranch()) {
3596
+ console.error(
3597
+ `${red('Run `hedgehog abandon` from trunk, not from inside a worktree.')}\n` +
3598
+ `This checkout is on a \`hedgehog/*\` branch. \`cd\` back to the main checkout\n` +
3599
+ `and run \`hedgehog abandon ${intentId} --reason "..."\` there.\n`,
3600
+ );
3601
+ process.exitCode = 1;
3602
+ return;
3603
+ }
3604
+ // No case-folding — same reasoning as mergeCommand: an intent id is
3605
+ // never case-normalized, so it must be typed exactly as declared.
3606
+ const id = intentId;
3607
+
3608
+ await ensureDb();
3609
+ if (!(await exists(DB_PATH))) {
3610
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
3611
+ process.exitCode = 1;
3612
+ return;
3613
+ }
3614
+
3615
+ // Refuse before writing anything — same "validate before the committed
3616
+ // file exists" shape as reconcile.mjs#confirmReconciliation refusing an
3617
+ // already-complete task. An intent already merged (`hedgehog merge`
3618
+ // flips it to 'complete' and removes its worktree) has real, shipped
3619
+ // work on trunk; applyAbandonment has no notion of "already done" and
3620
+ // would silently reset its tasks back to 'planned' — un-shipping merged
3621
+ // work by mistake (wrong id, stale memory) — and the abandonment record,
3622
+ // once committed, would keep re-applying that reset on every future
3623
+ // `hedgehog db rebuild` via replayAbandonments. Checked here rather than
3624
+ // only inside applyAbandonment so the abandonment file is never written
3625
+ // for an operation this refuses.
3626
+ const statusCheckDb = openDb({ readOnly: true });
3627
+ let existingStatus;
3628
+ try {
3629
+ existingStatus = statusCheckDb.prepare('SELECT status FROM intents WHERE id = ?').get(id)
3630
+ ?.status;
3631
+ } finally {
3632
+ statusCheckDb.close();
3633
+ }
3634
+ if (existingStatus === 'complete') {
3635
+ console.error(
3636
+ `${red('Cannot abandon')} ${bold(id)}${red(':')} it is already complete on trunk —\n` +
3637
+ `merged work, not something left to drop. If this was a mistake, use the\n` +
3638
+ `Correction Protocol to undo the specific change instead.\n`,
3639
+ );
3640
+ process.exitCode = 1;
3641
+ return;
3642
+ }
3643
+
3644
+ const record = {
3645
+ intent: id,
3646
+ reason,
3647
+ abandoned_at: new Date().toISOString(),
3648
+ };
3649
+
3650
+ try {
3651
+ await writeAbandonedFile(record);
3652
+ } catch (err) {
3653
+ console.error(`${red('Failed to record abandonment:')} ${err.message}\n`);
3654
+ process.exitCode = 1;
3655
+ return;
3656
+ }
3657
+ console.log(` ${green('recorded')} ${ABANDONED_DIR}/${id.toLowerCase()}.json`);
3658
+
3659
+ printDbTarget();
3660
+ const db = openDb();
3661
+ let applied;
3662
+ try {
3663
+ applied = applyAbandonment(db, id);
3664
+ } finally {
3665
+ db.close();
3666
+ }
3667
+ if (!applied.intentExisted) {
3668
+ console.error(
3669
+ `${yellow('No such intent in the build graph:')} ${bold(id)}. The abandonment record was\n` +
3670
+ `still written — it will apply if an intent with this id is compiled later — but\n` +
3671
+ `nothing on trunk changed just now. Check for a typo, or run \`hedgehog status\`.\n`,
3672
+ );
3673
+ }
3674
+ if (applied.resetTaskIds.length > 0) {
3675
+ console.log(` ${green('reset')} ${applied.resetTaskIds.join(', ')} ${dim('→ planned')}`);
3676
+ }
3677
+
3678
+ const worktree = hedgehogWorktrees().find((w) => w.intentId === id);
3679
+ if (worktree) {
3680
+ try {
3681
+ removeWorktree(worktree.path, { branch: worktree.branch, removeBranch: true });
3682
+ console.log(` ${green('removed')} ${worktree.path} ${dim(`(and branch ${worktree.branch})`)}`);
3683
+ } catch (err) {
3684
+ console.error(
3685
+ `${yellow('Could not remove the worktree:')} ${err.message}\n` +
3686
+ ` git worktree remove ${worktree.path} && git branch -D ${worktree.branch}\n`,
3687
+ );
3688
+ }
3689
+ }
3690
+
3691
+ console.log(
3692
+ `\n${bold('Abandoned.')} ${dim(`${id} is reset to planned on trunk. Commit ${ABANDONED_DIR}/${id.toLowerCase()}.json —`)}\n` +
3693
+ `${dim('the build graph is derived and gitignored, and an uncommitted abandonment')}\n` +
3694
+ `${dim('is reverted by the next `hedgehog db rebuild`.')}\n`,
3695
+ );
3696
+ }
3697
+
3269
3698
  // `hedgehog debt add <task-id> "<note>"` / `hedgehog debt list [<task-id>]`
3270
3699
  // — declared debt between tasks. A note recorded against a task is
3271
3700
  // rendered into the INHERITED DEBT section of the packet of every task
@@ -3732,6 +4161,16 @@ async function main() {
3732
4161
  return;
3733
4162
  }
3734
4163
 
4164
+ if (cmd === 'merge') {
4165
+ await mergeCommand(args.slice(1));
4166
+ return;
4167
+ }
4168
+
4169
+ if (cmd === 'abandon') {
4170
+ await abandonCommand(args.slice(1));
4171
+ return;
4172
+ }
4173
+
3735
4174
  console.error(`${red('Unknown command:')} ${cmd}\n`);
3736
4175
  await help();
3737
4176
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.1.7",
3
+ "version": "6.1.8",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/db/init.mjs CHANGED
@@ -54,6 +54,22 @@ export function withDb(fn, opts) {
54
54
  }
55
55
  }
56
56
 
57
+ // openDb's cwd-relative counterpart for the one caller that has to open a
58
+ // build graph that is deliberately not the one `process.cwd()` names:
59
+ // `hedgehog merge` reads a worktree's own `.hedgehog/hedgehog.db` (its
60
+ // completeness check) while running from trunk's checkout. Same pragmas,
61
+ // same schema-apply-on-writable-open contract as openDb — the only
62
+ // difference is the path is given rather than resolved from DB_PATH.
63
+ export function openDbAt(absPath, { readOnly = false } = {}) {
64
+ const db = new DatabaseSync(absPath, { readOnly });
65
+ db.exec('PRAGMA foreign_keys = ON');
66
+ db.exec('PRAGMA busy_timeout = 10000');
67
+ if (!readOnly) db.exec('PRAGMA journal_mode = WAL');
68
+ db.exec('PRAGMA synchronous = NORMAL');
69
+ if (!readOnly) applySchema(db);
70
+ return db;
71
+ }
72
+
57
73
  // BEGIN IMMEDIATE rather than a bare BEGIN: acquires the write lock up
58
74
  // front instead of on the first write statement, so two concurrent
59
75
  // writers fail fast with SQLITE_BUSY (retried via busy_timeout) instead
package/src/db/plan.mjs CHANGED
@@ -451,9 +451,62 @@ const insertTaskRequirement = (db) =>
451
451
  // override written after the fact needs `hedgehog plan --recompile`
452
452
  // (drift.mjs composes the same overrides Map) the same as any other
453
453
  // core.yaml-derived field would.
454
- export function planTasks(db, core, overrides = new Map()) {
455
- const intents = loadPendingIntents(db);
454
+ //
455
+ // `excludeIntentIds` (worktree.mjs's eligibleIntents feeds this, from
456
+ // `hedgehog plan`) is the set of pending intents this call leaves
457
+ // untouched — neither compiled nor skipped-as-already-compiled, simply
458
+ // not considered. It exists for exactly one caller: an intent whose
459
+ // `intent_dependencies` just cleared is worktree-eligible, and its tasks
460
+ // belong in that worktree's own graph, never in the one `planTasks` is
461
+ // running against here (trunk). Defaulted to an empty Set, so every
462
+ // existing caller — including a project that never uses worktrees — sees
463
+ // the identical behavior this function always had.
464
+ //
465
+ // Expanded to its transitive closure before anything else reads it: an
466
+ // intent C that `--depends-on` an excluded intent B is not itself
467
+ // worktree-eligible (eligibleIntents requires B to be `complete`, and an
468
+ // excluded-but-not-yet-merged B never is), so left out of the exclusion
469
+ // set C would still compile onto trunk here — and the cross-intent edge
470
+ // loop below would then try to INSERT a `dependencies` row onto one of
471
+ // B's task ids, which was never inserted anywhere (B's tasks exist only
472
+ // in B's own worktree, if it has one yet, or nowhere at all if it's still
473
+ // waiting on its own commit — see the `pending` branch in
474
+ // bin/cli.mjs#planCommand). `dependencies.depends_on_task_id` is a
475
+ // FOREIGN KEY with no bypass (schema.mjs, `foreign_keys = ON`), so that
476
+ // INSERT throws and aborts the whole `hedgehog plan` transaction. Mirrors
477
+ // eligibleIntents' own rule — a dependency has to be actually finished
478
+ // before its dependent can proceed — rather than inventing a second rule
479
+ // here that could drift from it.
480
+ function expandExcludedIntentIds(excludeIntentIds, intentDependencies) {
481
+ if (excludeIntentIds.size === 0) return excludeIntentIds;
482
+
483
+ const dependsOn = new Map();
484
+ for (const { intent_id, depends_on_intent_id } of intentDependencies) {
485
+ if (!dependsOn.has(intent_id)) dependsOn.set(intent_id, []);
486
+ dependsOn.get(intent_id).push(depends_on_intent_id);
487
+ }
488
+
489
+ const expanded = new Set(excludeIntentIds);
490
+ let changed = true;
491
+ while (changed) {
492
+ changed = false;
493
+ for (const [intentId, deps] of dependsOn) {
494
+ if (expanded.has(intentId)) continue;
495
+ if (deps.some((depId) => expanded.has(depId))) {
496
+ expanded.add(intentId);
497
+ changed = true;
498
+ }
499
+ }
500
+ }
501
+ return expanded;
502
+ }
503
+
504
+ export function planTasks(db, core, overrides = new Map(), { excludeIntentIds = new Set() } = {}) {
456
505
  const intentDependencies = loadIntentDependencies(db);
506
+ const effectiveExcludeIntentIds = expandExcludedIntentIds(excludeIntentIds, intentDependencies);
507
+ const intents = loadPendingIntents(db).filter(
508
+ (intent) => !effectiveExcludeIntentIds.has(intent.id),
509
+ );
457
510
  const ordered = orderIntents(intents, intentDependencies);
458
511
 
459
512
  const dependsOnByIntent = new Map();