@skyf0xx/hedgehog 6.1.6 → 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 +444 -5
- package/package.json +1 -1
- package/src/agents/bootstrap.md +4 -1
- package/src/db/debt.mjs +24 -14
- package/src/db/decision.mjs +20 -11
- package/src/db/init.mjs +16 -0
- package/src/db/notes.mjs +121 -0
- package/src/db/plan.mjs +55 -2
- package/src/db/rebuild.mjs +166 -69
- package/src/db/status.mjs +44 -0
- package/src/db/verify.mjs +4 -3
- package/src/db/worktree.mjs +562 -0
- package/src/hosts/gemini/gemini-extension.json +1 -1
- package/src/skills/hedgehog-contributing/SKILL.md +11 -18
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 {
|
|
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
|
|
@@ -3293,7 +3722,7 @@ async function debtCommand(args) {
|
|
|
3293
3722
|
const db = openDb();
|
|
3294
3723
|
let entry;
|
|
3295
3724
|
try {
|
|
3296
|
-
entry = addDebt(db, { taskId, note });
|
|
3725
|
+
entry = await addDebt(db, { taskId, note });
|
|
3297
3726
|
} catch (err) {
|
|
3298
3727
|
console.error(
|
|
3299
3728
|
`${red('Failed to declare debt:')} ${err.message}\n\nRun ${bold('hedgehog status')} to see valid task ids.\n`,
|
|
@@ -3364,7 +3793,7 @@ async function decisionCommand(args) {
|
|
|
3364
3793
|
const db = openDb();
|
|
3365
3794
|
let entry;
|
|
3366
3795
|
try {
|
|
3367
|
-
entry = addDecision(db, { taskId, note });
|
|
3796
|
+
entry = await addDecision(db, { taskId, note });
|
|
3368
3797
|
} catch (err) {
|
|
3369
3798
|
console.error(
|
|
3370
3799
|
`${red('Failed to declare decision:')} ${err.message}\n\nRun ${bold('hedgehog status')} to see valid task ids.\n`,
|
|
@@ -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
package/src/agents/bootstrap.md
CHANGED
|
@@ -16,7 +16,10 @@ read it.
|
|
|
16
16
|
|
|
17
17
|
You touch no build content on any core. That's the first build step,
|
|
18
18
|
started after Bootstrap closes, run by the core's own loop skill and its
|
|
19
|
-
agents.
|
|
19
|
+
agents. This is a scope discipline, not a tool restriction — your tool
|
|
20
|
+
grant includes `Edit`/`Write`, so nothing stops you mechanically from
|
|
21
|
+
writing domain code. Follow the chosen core's bootstrap skill exactly and
|
|
22
|
+
stop where it stops.
|
|
20
23
|
|
|
21
24
|
## Finding your step
|
|
22
25
|
|
package/src/db/debt.mjs
CHANGED
|
@@ -10,30 +10,37 @@
|
|
|
10
10
|
// arrives. `debt add` records the note against the declaring task, and
|
|
11
11
|
// next.mjs renders it into the packet of every task that depends on it.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// `.hedgehog
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
13
|
+
// A note recorded here has a committed source behind it —
|
|
14
|
+
// `.hedgehog/notes/<task-id>.json` (notes.mjs) — the same way
|
|
15
|
+
// `.hedgehog/reconciled/*.json` backs a reconciliation. Debt is in-build
|
|
16
|
+
// traffic between two tasks, and a file under `.hedgehog/` written
|
|
17
|
+
// mid-task sits outside every task's scope globs — it would trip verify's
|
|
18
|
+
// scope gate on the very task that declared it — so `hedgehog debt add`
|
|
19
|
+
// writes it directly rather than through the declaring task's own commit.
|
|
20
|
+
// `hedgehog db rebuild` replays it from there (rebuild.mjs), the same way
|
|
21
|
+
// it replays overrides and reconciliations.
|
|
20
22
|
|
|
21
23
|
import { applySchema } from './schema.mjs';
|
|
24
|
+
import { appendNote } from './notes.mjs';
|
|
22
25
|
|
|
23
26
|
const insertDebt = (db) =>
|
|
24
27
|
db.prepare(`
|
|
25
|
-
INSERT INTO debt (task_id, note)
|
|
26
|
-
VALUES (?, ?)
|
|
28
|
+
INSERT INTO debt (task_id, note, logged_at)
|
|
29
|
+
VALUES (?, ?, ?)
|
|
27
30
|
`);
|
|
28
31
|
|
|
29
32
|
function taskExists(db, taskId) {
|
|
30
33
|
return db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(taskId) !== undefined;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
// Writes one debt row against `taskId
|
|
34
|
-
// addressed to nobody reaches nobody, and the
|
|
35
|
-
// reject it anyway, less legibly.
|
|
36
|
-
|
|
36
|
+
// Writes one debt row against `taskId`, and its committed record. The
|
|
37
|
+
// task must exist — debt addressed to nobody reaches nobody, and the
|
|
38
|
+
// schema's foreign key would reject it anyway, less legibly.
|
|
39
|
+
//
|
|
40
|
+
// Committed file before DB row, deliberately — reconcile.mjs's same
|
|
41
|
+
// ordering, for the same reason: if the write fails, nothing has been
|
|
42
|
+
// recorded on a fact that would not survive the next rebuild.
|
|
43
|
+
export async function addDebt(db, { taskId, note }, notesDir = undefined) {
|
|
37
44
|
// Idempotent, and the migration path for a build graph created before
|
|
38
45
|
// the `debt` table existed: dbInit only applies the schema to a DB it
|
|
39
46
|
// just created, so an in-flight project's DB would otherwise have no
|
|
@@ -44,7 +51,10 @@ export function addDebt(db, { taskId, note }) {
|
|
|
44
51
|
if (!note) throw new Error('debt requires a note');
|
|
45
52
|
if (!taskExists(db, taskId)) throw new Error(`no such task: ${taskId}`);
|
|
46
53
|
|
|
47
|
-
const
|
|
54
|
+
const loggedAt = new Date().toISOString();
|
|
55
|
+
await appendNote(taskId, { kind: 'debt', note, loggedAt }, notesDir);
|
|
56
|
+
|
|
57
|
+
const result = insertDebt(db).run(taskId, note, loggedAt);
|
|
48
58
|
return { id: Number(result.lastInsertRowid), taskId, note };
|
|
49
59
|
}
|
|
50
60
|
|