@skyf0xx/hedgehog 6.1.7 → 6.2.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.
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';
@@ -58,9 +64,33 @@ import {
58
64
  shouldPromptForStar,
59
65
  recordStarAnswer,
60
66
  formatStarPrompt,
67
+ shouldPromptForShowcase,
68
+ recordShowcaseAnswer,
69
+ formatShowcasePrompt,
70
+ postShowcase,
61
71
  REPO_URL,
72
+ SHOWCASE_REPO_URL,
62
73
  } from '../src/db/community.mjs';
63
74
  import { rebuildDb } from '../src/db/rebuild.mjs';
75
+ import {
76
+ eligibleIntents,
77
+ hasWorktree,
78
+ createWorktree,
79
+ hedgehogWorktrees,
80
+ branchName,
81
+ worktreePath,
82
+ intentReadyToMerge,
83
+ intentTaskStatuses,
84
+ mergeBranch,
85
+ removeWorktree,
86
+ intentFileCommitted,
87
+ onHedgehogBranch,
88
+ loadAbandoned,
89
+ writeAbandonedFile,
90
+ applyAbandonment,
91
+ replayAbandonments,
92
+ ABANDONED_DIR,
93
+ } from '../src/db/worktree.mjs';
64
94
  import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
65
95
  import {
66
96
  gatherEvidence,
@@ -549,6 +579,9 @@ ${bold('Usage')}
549
579
  outside 'hedgehog verify' — run it to mark that work complete
550
580
  npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
551
581
  (starts no graph server; --no-open says so explicitly)
582
+ an intent whose declared intent_dependencies just
583
+ cleared gets its own git worktree + branch instead of
584
+ compiling here; see 'hedgehog merge'/'hedgehog abandon'
552
585
  npx @skyf0xx/hedgehog plan --open also start the graph server and open it, if anything compiled
553
586
  npx @skyf0xx/hedgehog plan --recompile rewrite core.yaml-derived fields on not-started tasks
554
587
  [--dry-run] [--include-blocked] [--strict]
@@ -561,6 +594,13 @@ ${bold('Usage')}
561
594
  close one task on your judgment — no scope gate and no
562
595
  verify command run; records it under .hedgehog/reconciled/
563
596
  npx @skyf0xx/hedgehog reconcile list list recorded reconciliations
597
+ npx @skyf0xx/hedgehog merge <intent-id> merge that intent's worktree branch into trunk,
598
+ rebuild trunk's graph, remove the worktree — fails
599
+ if the intent's tasks aren't all complete there
600
+ npx @skyf0xx/hedgehog abandon <intent-id> --reason "<why>"
601
+ drop an intent that will never finish: records why,
602
+ resets its tasks to planned on trunk, removes its
603
+ worktree and branch
564
604
  npx @skyf0xx/hedgehog intent add [flags] add an intent (rules/requirements/dependencies)
565
605
  npx @skyf0xx/hedgehog intent add --file <path> add an intent from a JSON file
566
606
  npx @skyf0xx/hedgehog next print the task packet for one ready task
@@ -593,6 +633,9 @@ ${bold('Usage')}
593
633
  npx @skyf0xx/hedgehog decision list [<task-id>] list declared decisions, oldest first
594
634
  npx @skyf0xx/hedgehog db migrate bring the graph's schema up to the latest version
595
635
  npx @skyf0xx/hedgehog community star --answer <a> record the star prompt's answer
636
+ npx @skyf0xx/hedgehog community showcase --repo <url> [--description <text>]
637
+ share what you built to the public showcase
638
+ npx @skyf0xx/hedgehog community showcase --answer later|dismissed defer or decline showcasing
596
639
  npx @skyf0xx/hedgehog --help
597
640
 
598
641
  Available cores: ${cores.join(', ')} (${bold('cores list')} for what each one is for)
@@ -1217,6 +1260,20 @@ async function dbRebuildCommand() {
1217
1260
  `no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
1218
1261
  );
1219
1262
  }
1263
+ if (result.abandonmentsReplayed?.length > 0) {
1264
+ console.log(
1265
+ `${dim(`${result.abandonmentsReplayed.length} intent(s) replayed from ${ABANDONED_DIR}/ — kept at planned, not active:`)}\n` +
1266
+ result.abandonmentsReplayed.map(({ intentId, reason }) => ` ${intentId} ${dim(reason)}`).join('\n') +
1267
+ '\n',
1268
+ );
1269
+ }
1270
+ if (result.orphanedAbandonments?.length > 0) {
1271
+ console.log(
1272
+ `${yellow(bold('Abandonments not replayed.'))} ${result.orphanedAbandonments.join(', ')} —\n` +
1273
+ `either no intent with this id exists in the rebuilt graph, or it is already\n` +
1274
+ `complete (merged since), so each does nothing.\n`,
1275
+ );
1276
+ }
1220
1277
  warnOrphanedNotes(result);
1221
1278
  warnRebuildDrift(result, corePath);
1222
1279
  }
@@ -1435,14 +1492,98 @@ async function planCommand(args = []) {
1435
1492
 
1436
1493
  const overrides = await loadOverrides();
1437
1494
 
1495
+ // Worktree trigger: an intent whose `intent_dependencies` just cleared
1496
+ // (eligibleIntents.mjs's rule — see worktree.mjs for why "declares at
1497
+ // least one dependency" is part of that rule, not just "all complete")
1498
+ // gets its own `git worktree` and branch instead of compiling onto this
1499
+ // checkout. Read before planTasks runs, against the same handle, so the
1500
+ // exclusion set reflects the graph plan is about to compile against.
1501
+ //
1502
+ // Skipped entirely when this checkout is itself already a `hedgehog/*`
1503
+ // worktree (onHedgehogBranch) — this is the recursive `hedgehog plan`
1504
+ // this same command runs inside a worktree it just created, to compile
1505
+ // that worktree's own intent, and that intent reads as "eligible" again
1506
+ // by the same rule that got it a worktree in the first place. Without
1507
+ // this guard, that recursive call would try to create a second,
1508
+ // colliding worktree for itself instead of just compiling normally.
1509
+ let eligible = [];
1510
+ if (!onHedgehogBranch()) {
1511
+ const eligibilityDb = openDb({ readOnly: true });
1512
+ try {
1513
+ eligible = eligibleIntents(eligibilityDb);
1514
+ } finally {
1515
+ eligibilityDb.close();
1516
+ }
1517
+ }
1518
+
1519
+ // Every eligible intent is excluded from planTasks's own compile-onto-
1520
+ // trunk path below, whether or not this loop actually manages to give
1521
+ // it a worktree this run (hasWorktree true already, or the commit check
1522
+ // below defers it) — an eligible intent must never fall through to
1523
+ // compiling on trunk, or it would sit there just like any pre-feature
1524
+ // intent and this feature would have done nothing for it.
1525
+ const excludeIntentIds = new Set(eligible.map((i) => i.id));
1526
+
1527
+ const worktreesCreated = [];
1528
+ for (const intent of eligible) {
1529
+ if (hasWorktree(intent.id)) continue; // already has one — idempotent re-run
1530
+ // A new worktree checks out its branch from HEAD's committed tree —
1531
+ // it cannot see an intent file still sitting uncommitted in trunk's
1532
+ // working directory (worktree.mjs#intentFileCommitted). Left pending
1533
+ // rather than either worktree'd-with-nothing-in-it or silently
1534
+ // compiled onto trunk: the next `hedgehog plan`, after the commit
1535
+ // lands, picks it up correctly.
1536
+ if (!intentFileCommitted(intent.id)) {
1537
+ console.log(
1538
+ ` ${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`)}`,
1539
+ );
1540
+ continue;
1541
+ }
1542
+ let created;
1543
+ try {
1544
+ created = createWorktree(intent.id);
1545
+ } catch (err) {
1546
+ console.error(
1547
+ `${yellow('Could not create a worktree for')} ${bold(intent.id)}${dim(':')} ${err.message}\n`,
1548
+ );
1549
+ continue;
1550
+ }
1551
+ worktreesCreated.push({ intentId: intent.id, ...created });
1552
+ }
1553
+
1438
1554
  const db = openDb();
1439
1555
  let result;
1440
1556
  try {
1441
- result = planTasks(db, core, overrides);
1557
+ result = planTasks(db, core, overrides, { excludeIntentIds });
1442
1558
  } finally {
1443
1559
  db.close();
1444
1560
  }
1445
1561
 
1562
+ for (const { intentId, branch, path } of worktreesCreated) {
1563
+ console.log(` ${green('worktree')} ${bold(intentId)} ${dim(`${branch} → ${path}`)}`);
1564
+ }
1565
+
1566
+ // Compiles the new worktree's own graph from inside it — a plain
1567
+ // `hedgehog plan` subprocess with `cwd` set to the worktree, so that
1568
+ // intent's tasks land only in its own `.hedgehog/hedgehog.db`, per this
1569
+ // feature's DB-shape rule (worktree.mjs's file header). `ensureDb`
1570
+ // (this file) creates that DB from the intent files the new branch
1571
+ // already carries — no different from a fresh clone.
1572
+ for (const { intentId, path } of worktreesCreated) {
1573
+ try {
1574
+ execFileSync(process.execPath, [fileURLToPath(import.meta.url), 'plan'], {
1575
+ cwd: path,
1576
+ stdio: 'inherit',
1577
+ env: { ...process.env, HEDGEHOG_NO_UPDATE_CHECK: '1' },
1578
+ });
1579
+ } catch (err) {
1580
+ console.error(
1581
+ `${yellow('Worktree created but its own `hedgehog plan` failed:')} ${bold(intentId)}\n` +
1582
+ ` ${dim(err.message)}\n cd ${path} && hedgehog plan\n`,
1583
+ );
1584
+ }
1585
+ }
1586
+
1446
1587
  for (const id of result.once) {
1447
1588
  console.log(` ${green('compiled')} ${id} ${dim('(once — one task for the whole build)')}`);
1448
1589
  }
@@ -2100,10 +2241,21 @@ async function verifyCommand(args) {
2100
2241
 
2101
2242
  // Fires once per project — see community.mjs. Deliberately last: after
2102
2243
  // the gate's own output, not before it.
2103
- if (await shouldPromptForStar(DEST_ROOT, { intentComplete: result.intentComplete })) {
2244
+ const starJustShown = await shouldPromptForStar(DEST_ROOT, { intentComplete: result.intentComplete });
2245
+ if (starJustShown) {
2104
2246
  console.log(formatStarPrompt());
2105
2247
  console.log('');
2106
2248
  }
2249
+
2250
+ // Independent second ask, gated on the star question already having a
2251
+ // pre-existing answer or deferral — never on the same verify call that
2252
+ // just showed the star prompt for the first time. See
2253
+ // shouldPromptForShowcase for why `starJustShown` has to come from
2254
+ // here rather than being re-derived from state.
2255
+ if (await shouldPromptForShowcase(DEST_ROOT, { intentComplete: result.intentComplete, starJustShown })) {
2256
+ console.log(formatShowcasePrompt());
2257
+ console.log('');
2258
+ }
2107
2259
  }
2108
2260
 
2109
2261
  // Prints the full task packet for each task in `tasks`, read back from
@@ -2644,6 +2796,7 @@ async function statusCommand(args = []) {
2644
2796
  // blocked" until some other command happens to reap it first.
2645
2797
  reapExpiredLeases(db);
2646
2798
  result = graphStatus(db, { core, overrides });
2799
+ result.worktrees = await graphWorktreeStatus(db);
2647
2800
  } finally {
2648
2801
  db.close();
2649
2802
  }
@@ -3266,6 +3419,301 @@ async function reconcileCommand(args) {
3266
3419
  console.log(`${formatEvidence(evidence)}\n`);
3267
3420
  }
3268
3421
 
3422
+ // `hedgehog merge <intent-id>` — merges `hedgehog/<intent-id>` into trunk
3423
+ // with `git merge --no-ff`, rebuilds trunk's graph from what merged, then
3424
+ // removes the worktree and its branch. See src/db/worktree.mjs for why a
3425
+ // rebuild is the whole merge step: no DB row ever crosses from the
3426
+ // worktree's own graph to trunk's.
3427
+ //
3428
+ // Refuses before touching git at all if the intent's tasks are not all
3429
+ // `complete` in the worktree's own graph — checked there, never here,
3430
+ // since trunk has no row for a task compiled only inside the worktree.
3431
+ async function mergeCommand(args) {
3432
+ const intentId = args[0];
3433
+ if (!intentId || intentId.startsWith('--')) {
3434
+ console.error(`${red('Usage:')} hedgehog merge <intent-id>\n`);
3435
+ process.exitCode = 1;
3436
+ return;
3437
+ }
3438
+ // Every git/DB call below defaults to `process.cwd()` and assumes it's
3439
+ // trunk: `rebuildDb` mutates whichever `.hedgehog/hedgehog.db` that cwd
3440
+ // resolves to, `mergeBranch` runs `git merge --no-ff` there, and
3441
+ // `removeWorktree`'s `git worktree remove` refuses to delete a worktree
3442
+ // you're standing in. Run from inside the intent's own worktree
3443
+ // checkout — a realistic mistake, since the worktree is exactly where a
3444
+ // user would `cd` in to inspect the work before merging it — every one
3445
+ // of those would operate on (or fail against) the wrong checkout. Same
3446
+ // guard `hedgehog plan` uses to detect it's inside a worktree
3447
+ // (worktree.mjs#onHedgehogBranch), applied here as a refusal rather than
3448
+ // a trigger-skip.
3449
+ if (onHedgehogBranch()) {
3450
+ console.error(
3451
+ `${red('Run `hedgehog merge` from trunk, not from inside a worktree.')}\n` +
3452
+ `This checkout is on a \`hedgehog/*\` branch — merging from here would run\n` +
3453
+ `\`git merge\` against the worktree itself instead of trunk. \`cd\` back to the\n` +
3454
+ `main checkout and run \`hedgehog merge ${intentId}\` there.\n`,
3455
+ );
3456
+ process.exitCode = 1;
3457
+ return;
3458
+ }
3459
+ // No case-folding: unlike a task id (plan.mjs#taskId always upper-cases
3460
+ // one), an intent id is stored exactly as given to `hedgehog intent add
3461
+ // --id` — intent.mjs never normalizes its case — so it must be typed
3462
+ // here exactly as it was there.
3463
+ const id = intentId;
3464
+
3465
+ const worktrees = hedgehogWorktrees();
3466
+ const worktree = worktrees.find((w) => w.intentId === id);
3467
+ if (!worktree) {
3468
+ console.error(
3469
+ `${red('No active worktree for')} ${bold(id)}${red('.')} ${dim(`Expected branch ${branchName(id)} from \`git worktree list\`.`)}\n`,
3470
+ );
3471
+ process.exitCode = 1;
3472
+ return;
3473
+ }
3474
+
3475
+ // Opened against the worktree's own DB (its cwd), never trunk's — the
3476
+ // completeness check this command exists to enforce has no meaning
3477
+ // against a graph that never compiled this intent's tasks at all.
3478
+ //
3479
+ // Checked for existence first: if the recursive `hedgehog plan`
3480
+ // subprocess planCommand runs for a freshly created worktree ever
3481
+ // failed (the case its own catch block already anticipates, telling the
3482
+ // user to `cd` in and re-run), `.hedgehog/hedgehog.db` was never created
3483
+ // inside that worktree, and openDbAt would otherwise throw a raw SQLite
3484
+ // "unable to open database file" error instead of the friendly
3485
+ // `no_tasks` message the branch below was written to produce for
3486
+ // exactly this situation.
3487
+ const worktreeDbPath = join(worktree.path, DB_PATH);
3488
+ if (!existsSync(worktreeDbPath)) {
3489
+ console.error(
3490
+ `${red('Nothing to merge.')} ${bold(id)} has no build graph in its own worktree yet\n` +
3491
+ `(${worktreeDbPath} does not exist). Run \`hedgehog plan\` inside the worktree first.\n`,
3492
+ );
3493
+ process.exitCode = 1;
3494
+ return;
3495
+ }
3496
+
3497
+ let readiness;
3498
+ const worktreeDb = openDbAt(worktreeDbPath, { readOnly: true });
3499
+ try {
3500
+ readiness = intentReadyToMerge(worktreeDb, id);
3501
+ } finally {
3502
+ worktreeDb.close();
3503
+ }
3504
+
3505
+ if (!readiness.ready) {
3506
+ if (readiness.reason === 'no_tasks') {
3507
+ console.error(
3508
+ `${red('Nothing to merge.')} ${bold(id)} has no compiled tasks in its own worktree graph\n` +
3509
+ `(${join(worktree.path, DB_PATH)}). Run \`hedgehog plan\` inside the worktree first.\n`,
3510
+ );
3511
+ } else {
3512
+ console.error(
3513
+ `${red('Not all tasks are complete.')} ${bold(id)}'s worktree graph still has:\n\n` +
3514
+ readiness.incomplete.map((t) => ` ${t.id} ${t.status}`).join('\n') +
3515
+ '\n\nFinish and verify every task in the worktree before merging.\n',
3516
+ );
3517
+ }
3518
+ process.exitCode = 1;
3519
+ return;
3520
+ }
3521
+
3522
+ console.log(` ${dim('merging')} ${worktree.branch} → trunk`);
3523
+ try {
3524
+ mergeBranch(id);
3525
+ } catch (err) {
3526
+ console.error(
3527
+ `${red('git merge failed:')} ${err.message}\n\n` +
3528
+ `Resolve the conflict by hand in this checkout, then finish with:\n` +
3529
+ ` git add -A && git commit\n` +
3530
+ ` hedgehog db rebuild\n` +
3531
+ ` git worktree remove ${worktree.path} && git branch -D ${worktree.branch}\n`,
3532
+ );
3533
+ process.exitCode = 1;
3534
+ return;
3535
+ }
3536
+
3537
+ printDbTarget();
3538
+ const corePath = await resolveCorePath();
3539
+ const db = openDb();
3540
+ let result;
3541
+ try {
3542
+ // `id`'s worktree is still active at this point (removed only after a
3543
+ // successful rebuild, below) — rebuildDb's own open-worktree exclusion
3544
+ // would otherwise treat the intent this command exists to bring onto
3545
+ // trunk as still worktree-only. mergingIntentId names the one
3546
+ // exception.
3547
+ result = await rebuildDb(db, { corePath, mergingIntentId: id });
3548
+ } finally {
3549
+ db.close();
3550
+ }
3551
+ console.log(
3552
+ ` ${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}`,
3553
+ );
3554
+ if (result.intentsMarkedComplete?.length > 0) {
3555
+ console.log(
3556
+ ` ${dim(`${result.intentsMarkedComplete.length} intent(s) closed: ${result.intentsMarkedComplete.join(', ')}`)}`,
3557
+ );
3558
+ }
3559
+ if (result.abandonmentsReplayed?.length > 0) {
3560
+ console.log(
3561
+ ` ${dim(`${result.abandonmentsReplayed.length} intent(s) replayed from ${ABANDONED_DIR}/ — kept at planned, not active:`)}\n` +
3562
+ result.abandonmentsReplayed.map(({ intentId, reason }) => ` ${intentId} ${dim(reason)}`).join('\n'),
3563
+ );
3564
+ }
3565
+ if (result.orphanedAbandonments?.length > 0) {
3566
+ console.log(
3567
+ ` ${yellow(bold('Abandonments not replayed.'))} ${result.orphanedAbandonments.join(', ')} —\n` +
3568
+ ` either no intent with this id exists in the rebuilt graph, or it is already\n` +
3569
+ ` complete (merged since), so each does nothing.`,
3570
+ );
3571
+ }
3572
+ warnOrphanedNotes(result);
3573
+ warnRebuildDrift(result, corePath);
3574
+
3575
+ try {
3576
+ removeWorktree(worktree.path, { branch: worktree.branch, removeBranch: true });
3577
+ console.log(` ${green('removed')} ${worktree.path} ${dim(`(and branch ${worktree.branch})`)}`);
3578
+ } catch (err) {
3579
+ console.error(
3580
+ `${yellow('Merged, but could not remove the worktree:')} ${err.message}\n` +
3581
+ ` git worktree remove ${worktree.path} && git branch -D ${worktree.branch}\n`,
3582
+ );
3583
+ }
3584
+
3585
+ console.log(`\n${green(bold('Merged.'))} ${bold(id)} is complete on trunk.\n`);
3586
+ }
3587
+
3588
+ // `hedgehog abandon <intent-id> --reason "<why>"` — drops an intent that
3589
+ // will never be finished: writes a committed abandonment record
3590
+ // (`.hedgehog/abandoned/<intent-id>.json`, worktree.mjs — the same
3591
+ // temp-file+rename, replayed-on-rebuild shape reconcile.mjs's confirmed
3592
+ // reconciliations use), resets the intent's tasks to `planned` on trunk,
3593
+ // and removes the worktree and branch.
3594
+ //
3595
+ // Never routed through claim.mjs's lease-expiry reaping — a worktree
3596
+ // legitimately sits idle for days between sessions, and treating that
3597
+ // idleness as a dead lease would garbage-collect real, unfinished work.
3598
+ // Abandonment is a deliberate act with a stated reason, not a timeout.
3599
+ async function abandonCommand(args) {
3600
+ const intentId = args[0];
3601
+ const reasonIdx = args.indexOf('--reason');
3602
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
3603
+
3604
+ if (!intentId || intentId.startsWith('--') || !reason) {
3605
+ console.error(`${red('Usage:')} hedgehog abandon <intent-id> --reason "<why>"\n`);
3606
+ process.exitCode = 1;
3607
+ return;
3608
+ }
3609
+ // Same reasoning and same guard as mergeCommand: applyAbandonment and
3610
+ // removeWorktree both default to `process.cwd()` and assume it's trunk.
3611
+ // Run from inside the intent's own worktree checkout, this would mutate
3612
+ // the worktree's own DB instead of trunk's and then fail to remove a
3613
+ // worktree the command is standing in.
3614
+ if (onHedgehogBranch()) {
3615
+ console.error(
3616
+ `${red('Run `hedgehog abandon` from trunk, not from inside a worktree.')}\n` +
3617
+ `This checkout is on a \`hedgehog/*\` branch. \`cd\` back to the main checkout\n` +
3618
+ `and run \`hedgehog abandon ${intentId} --reason "..."\` there.\n`,
3619
+ );
3620
+ process.exitCode = 1;
3621
+ return;
3622
+ }
3623
+ // No case-folding — same reasoning as mergeCommand: an intent id is
3624
+ // never case-normalized, so it must be typed exactly as declared.
3625
+ const id = intentId;
3626
+
3627
+ await ensureDb();
3628
+ if (!(await exists(DB_PATH))) {
3629
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
3630
+ process.exitCode = 1;
3631
+ return;
3632
+ }
3633
+
3634
+ // Refuse before writing anything — same "validate before the committed
3635
+ // file exists" shape as reconcile.mjs#confirmReconciliation refusing an
3636
+ // already-complete task. An intent already merged (`hedgehog merge`
3637
+ // flips it to 'complete' and removes its worktree) has real, shipped
3638
+ // work on trunk; applyAbandonment has no notion of "already done" and
3639
+ // would silently reset its tasks back to 'planned' — un-shipping merged
3640
+ // work by mistake (wrong id, stale memory) — and the abandonment record,
3641
+ // once committed, would keep re-applying that reset on every future
3642
+ // `hedgehog db rebuild` via replayAbandonments. Checked here rather than
3643
+ // only inside applyAbandonment so the abandonment file is never written
3644
+ // for an operation this refuses.
3645
+ const statusCheckDb = openDb({ readOnly: true });
3646
+ let existingStatus;
3647
+ try {
3648
+ existingStatus = statusCheckDb.prepare('SELECT status FROM intents WHERE id = ?').get(id)
3649
+ ?.status;
3650
+ } finally {
3651
+ statusCheckDb.close();
3652
+ }
3653
+ if (existingStatus === 'complete') {
3654
+ console.error(
3655
+ `${red('Cannot abandon')} ${bold(id)}${red(':')} it is already complete on trunk —\n` +
3656
+ `merged work, not something left to drop. If this was a mistake, use the\n` +
3657
+ `Correction Protocol to undo the specific change instead.\n`,
3658
+ );
3659
+ process.exitCode = 1;
3660
+ return;
3661
+ }
3662
+
3663
+ const record = {
3664
+ intent: id,
3665
+ reason,
3666
+ abandoned_at: new Date().toISOString(),
3667
+ };
3668
+
3669
+ try {
3670
+ await writeAbandonedFile(record);
3671
+ } catch (err) {
3672
+ console.error(`${red('Failed to record abandonment:')} ${err.message}\n`);
3673
+ process.exitCode = 1;
3674
+ return;
3675
+ }
3676
+ console.log(` ${green('recorded')} ${ABANDONED_DIR}/${id.toLowerCase()}.json`);
3677
+
3678
+ printDbTarget();
3679
+ const db = openDb();
3680
+ let applied;
3681
+ try {
3682
+ applied = applyAbandonment(db, id);
3683
+ } finally {
3684
+ db.close();
3685
+ }
3686
+ if (!applied.intentExisted) {
3687
+ console.error(
3688
+ `${yellow('No such intent in the build graph:')} ${bold(id)}. The abandonment record was\n` +
3689
+ `still written — it will apply if an intent with this id is compiled later — but\n` +
3690
+ `nothing on trunk changed just now. Check for a typo, or run \`hedgehog status\`.\n`,
3691
+ );
3692
+ }
3693
+ if (applied.resetTaskIds.length > 0) {
3694
+ console.log(` ${green('reset')} ${applied.resetTaskIds.join(', ')} ${dim('→ planned')}`);
3695
+ }
3696
+
3697
+ const worktree = hedgehogWorktrees().find((w) => w.intentId === id);
3698
+ if (worktree) {
3699
+ try {
3700
+ removeWorktree(worktree.path, { branch: worktree.branch, removeBranch: true });
3701
+ console.log(` ${green('removed')} ${worktree.path} ${dim(`(and branch ${worktree.branch})`)}`);
3702
+ } catch (err) {
3703
+ console.error(
3704
+ `${yellow('Could not remove the worktree:')} ${err.message}\n` +
3705
+ ` git worktree remove ${worktree.path} && git branch -D ${worktree.branch}\n`,
3706
+ );
3707
+ }
3708
+ }
3709
+
3710
+ console.log(
3711
+ `\n${bold('Abandoned.')} ${dim(`${id} is reset to planned on trunk. Commit ${ABANDONED_DIR}/${id.toLowerCase()}.json —`)}\n` +
3712
+ `${dim('the build graph is derived and gitignored, and an uncommitted abandonment')}\n` +
3713
+ `${dim('is reverted by the next `hedgehog db rebuild`.')}\n`,
3714
+ );
3715
+ }
3716
+
3269
3717
  // `hedgehog debt add <task-id> "<note>"` / `hedgehog debt list [<task-id>]`
3270
3718
  // — declared debt between tasks. A note recorded against a task is
3271
3719
  // rendered into the INHERITED DEBT section of the packet of every task
@@ -3407,20 +3855,34 @@ async function decisionCommand(args) {
3407
3855
  process.exitCode = 1;
3408
3856
  }
3409
3857
 
3410
- // `hedgehog community star --answer starred|later|dismissed` — records
3411
- // the star prompt's answer. No build graph or core needed: this is
3412
- // project state about a question asked, not about the build.
3858
+ const COMMUNITY_USAGE = [
3859
+ 'hedgehog community star --answer starred|later|dismissed',
3860
+ ' or: hedgehog community showcase --repo <url> [--description <text>]',
3861
+ ' or: hedgehog community showcase --answer later|dismissed',
3862
+ ].join('\n');
3863
+
3864
+ // `hedgehog community star --answer starred|later|dismissed` and
3865
+ // `hedgehog community showcase ...` — record each prompt's answer. No
3866
+ // build graph or core needed: this is project state about questions
3867
+ // asked, not about the build.
3413
3868
  async function communityCommand(args) {
3414
3869
  const sub = args[0];
3415
3870
 
3416
- if (sub !== 'star') {
3417
- console.error(
3418
- `${red('Unknown community subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog community star --answer starred|later|dismissed\n`,
3419
- );
3420
- process.exitCode = 1;
3871
+ if (sub === 'star') {
3872
+ await communityStarCommand(args.slice(1));
3873
+ return;
3874
+ }
3875
+
3876
+ if (sub === 'showcase') {
3877
+ await communityShowcaseCommand(args.slice(1));
3421
3878
  return;
3422
3879
  }
3423
3880
 
3881
+ console.error(`${red('Unknown community subcommand:')} ${sub ?? '(none)'}\n\nUsage: ${COMMUNITY_USAGE}\n`);
3882
+ process.exitCode = 1;
3883
+ }
3884
+
3885
+ async function communityStarCommand(args) {
3424
3886
  const answerIdx = args.indexOf('--answer');
3425
3887
  const answer = answerIdx !== -1 ? args[answerIdx + 1] : undefined;
3426
3888
  const ANSWERS = ['starred', 'later', 'dismissed'];
@@ -3443,6 +3905,61 @@ async function communityCommand(args) {
3443
3905
  }
3444
3906
  }
3445
3907
 
3908
+ // `hedgehog community showcase --repo <url> [--description <text>]`
3909
+ // records and submits a showcase entry; `hedgehog community showcase
3910
+ // --answer later|dismissed` records a deferral or decline with nothing
3911
+ // to submit. `--repo` and `--answer` are mutually exclusive ways of
3912
+ // answering the same prompt, mirroring the star command's single
3913
+ // `--answer` flag but split in two because only this branch has a
3914
+ // network call and a second, optional flag (`--description`).
3915
+ async function communityShowcaseCommand(args) {
3916
+ const repoIdx = args.indexOf('--repo');
3917
+ const repoUrl = repoIdx !== -1 ? args[repoIdx + 1] : undefined;
3918
+ const descIdx = args.indexOf('--description');
3919
+ const description = descIdx !== -1 ? args[descIdx + 1] : undefined;
3920
+ const answerIdx = args.indexOf('--answer');
3921
+ const answer = answerIdx !== -1 ? args[answerIdx + 1] : undefined;
3922
+
3923
+ if (repoUrl) {
3924
+ // Courtesy check only — well-formed and http(s). The relay (#365) is
3925
+ // the real validation authority; this just catches an obvious typo
3926
+ // before spending a network round trip on it.
3927
+ let parsed;
3928
+ try {
3929
+ parsed = new URL(repoUrl);
3930
+ } catch {
3931
+ parsed = null;
3932
+ }
3933
+ if (!parsed || !['http:', 'https:'].includes(parsed.protocol)) {
3934
+ console.error(`${red('Usage:')} hedgehog community showcase --repo <http(s) url> [--description <text>]\n`);
3935
+ process.exitCode = 1;
3936
+ return;
3937
+ }
3938
+
3939
+ const core = (await installedCore(DEST_ROOT))?.name;
3940
+ await postShowcase({ repoUrl, core, description });
3941
+ await recordShowcaseAnswer(DEST_ROOT, 'shared', { repoUrl, core, description });
3942
+
3943
+ console.log(` ${green('shared')} ${dim(SHOWCASE_REPO_URL)}`);
3944
+ return;
3945
+ }
3946
+
3947
+ const ANSWERS = ['later', 'dismissed'];
3948
+ if (!ANSWERS.includes(answer)) {
3949
+ console.error(`${red('Usage:')} ${COMMUNITY_USAGE}\n`);
3950
+ process.exitCode = 1;
3951
+ return;
3952
+ }
3953
+
3954
+ await recordShowcaseAnswer(DEST_ROOT, answer);
3955
+
3956
+ if (answer === 'later') {
3957
+ console.log(` ${dim('deferred')} ${dim('asked again after about a week of building')}`);
3958
+ } else {
3959
+ console.log(` ${dim('dismissed')} ${dim('not asked again in this project')}`);
3960
+ }
3961
+ }
3962
+
3446
3963
  // `hedgehog cores list` — every core this release can install, the
3447
3964
  // package that ships it, and which of its versions are already extracted
3448
3965
  // locally. The prose each entry carries is what planner reads in Phase 0
@@ -3732,6 +4249,16 @@ async function main() {
3732
4249
  return;
3733
4250
  }
3734
4251
 
4252
+ if (cmd === 'merge') {
4253
+ await mergeCommand(args.slice(1));
4254
+ return;
4255
+ }
4256
+
4257
+ if (cmd === 'abandon') {
4258
+ await abandonCommand(args.slice(1));
4259
+ return;
4260
+ }
4261
+
3735
4262
  console.error(`${red('Unknown command:')} ${cmd}\n`);
3736
4263
  await help();
3737
4264
  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.2.0",
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": {