@skyf0xx/hedgehog 6.3.0 → 6.3.2

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
@@ -59,7 +59,7 @@ import {
59
59
  } from '../src/db/requires.mjs';
60
60
  import { whyPath, formatWhy } from '../src/db/why.mjs';
61
61
  import { addFriction, listFriction } from '../src/db/friction.mjs';
62
- import { addDebt, listDebt } from '../src/db/debt.mjs';
62
+ import { addDebt, listDebt, resolveDebt } from '../src/db/debt.mjs';
63
63
  import { addDecision, listDecisions } from '../src/db/decision.mjs';
64
64
  import {
65
65
  shouldPromptForStar,
@@ -100,6 +100,8 @@ import {
100
100
  formatReconciliations,
101
101
  RECONCILED_DIR,
102
102
  } from '../src/db/reconcile.mjs';
103
+ import { NOOP_DIR } from '../src/db/noop.mjs';
104
+ import { runFastpath, loadFastpaths, orphanedFastpathTasks, FASTPATH_DIR } from '../src/db/fastpath.mjs';
103
105
  import { HOSTS, HOST_FLAGS, DEFAULT_HOST, availableHosts } from '../src/hosts/index.mjs';
104
106
  import { recordHosts, installedHosts } from '../src/hosts/installed.mjs';
105
107
  import { wrapSection } from '../src/hosts/claude-md-merge.mjs';
@@ -625,6 +627,11 @@ ${bold('Usage')}
625
627
  close one task on your judgment — no scope gate and no
626
628
  verify command run; records it under .hedgehog/reconciled/
627
629
  npx @skyf0xx/hedgehog reconcile list list recorded reconciliations
630
+ npx @skyf0xx/hedgehog fast-path <intent-id> --reason "<why>" --verify "<command>"
631
+ close every remaining task of a small, already-committed
632
+ intent below the per-layer loop; still gated by scope
633
+ and by --verify; records it under .hedgehog/fastpath/
634
+ npx @skyf0xx/hedgehog fast-path list list recorded fast-paths
628
635
  npx @skyf0xx/hedgehog merge <intent-id> merge that intent's worktree branch into trunk,
629
636
  rebuild trunk's graph, remove the worktree — fails
630
637
  if the intent's tasks aren't all complete there
@@ -663,7 +670,8 @@ ${bold('Usage')}
663
670
  npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
664
671
  npx @skyf0xx/hedgehog friction list list logged friction, oldest first
665
672
  npx @skyf0xx/hedgehog debt add <task-id> "<note>" declare debt that lands in dependent tasks' packets
666
- npx @skyf0xx/hedgehog debt list [<task-id>] list declared debt, oldest first
673
+ npx @skyf0xx/hedgehog debt list [<task-id>] [--all] list open debt, oldest first (--all includes resolved)
674
+ npx @skyf0xx/hedgehog debt resolve <debt-id> --reason "<why>" mark a debt note resolved
667
675
  npx @skyf0xx/hedgehog decision add <task-id> "<note>" declare a decision that lands in dependent tasks' packets
668
676
  npx @skyf0xx/hedgehog decision list [<task-id>] list declared decisions, oldest first
669
677
  npx @skyf0xx/hedgehog db migrate bring the graph's schema up to the latest version
@@ -1312,6 +1320,33 @@ async function dbRebuildCommand() {
1312
1320
  `no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
1313
1321
  );
1314
1322
  }
1323
+ // Same reporting split as tasksReconciled above, for the same reason: a
1324
+ // no-op-completed task had no verify_command run and no commit made,
1325
+ // and folding it into the plain "marked complete" count would hide that.
1326
+ if (result.tasksNoop > 0) {
1327
+ console.log(
1328
+ `${dim(`${result.tasksNoop} task(s) replayed from ${NOOP_DIR}/ — closed as a no-op, no commit`)}\n`,
1329
+ );
1330
+ }
1331
+ if (result.orphanedNoop?.length > 0) {
1332
+ console.log(
1333
+ `${yellow(bold('No-op records without a task.'))} ${result.orphanedNoop.join(', ')} —\n` +
1334
+ `no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
1335
+ );
1336
+ }
1337
+ // Same split as tasksReconciled/tasksNoop above: a fast-pathed task had
1338
+ // no per-layer verify_command run against it individually.
1339
+ if (result.tasksFastpathed > 0) {
1340
+ console.log(
1341
+ `${dim(`${result.tasksFastpathed} task(s) replayed from ${FASTPATH_DIR}/ — closed by fast-path, not per-layer verification`)}\n`,
1342
+ );
1343
+ }
1344
+ if (result.orphanedFastpath?.length > 0) {
1345
+ console.log(
1346
+ `${yellow(bold('Fast-path records without a task.'))} ${result.orphanedFastpath.join(', ')} —\n` +
1347
+ `no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
1348
+ );
1349
+ }
1315
1350
  if (result.abandonmentsReplayed?.length > 0) {
1316
1351
  console.log(
1317
1352
  `${dim(`${result.abandonmentsReplayed.length} intent(s) replayed from ${ABANDONED_DIR}/ — kept at planned, not active:`)}\n` +
@@ -2256,7 +2291,7 @@ async function verifyCommand(args) {
2256
2291
  return;
2257
2292
  }
2258
2293
  const overrides = await loadOverrides();
2259
- result = verifyTask(db, taskId, owner, overrides);
2294
+ result = await verifyTask(db, taskId, owner, overrides);
2260
2295
  } catch (err) {
2261
2296
  console.error(`${red('Verify failed:')} ${err.message}\n`);
2262
2297
  process.exitCode = 1;
@@ -2286,7 +2321,11 @@ async function verifyCommand(args) {
2286
2321
  }
2287
2322
 
2288
2323
  console.log(`${green(bold('Verified.'))} Task ${bold(taskId)} is now ${bold('complete')}.`);
2289
- if (result.commitSha) console.log(` ${dim('commit')} ${result.commitSha}`);
2324
+ if (result.noop) {
2325
+ console.log(` ${dim('no-op — nothing in scope to verify or commit')}`);
2326
+ } else if (result.commitSha) {
2327
+ console.log(` ${dim('commit')} ${result.commitSha}`);
2328
+ }
2290
2329
  if (result.unlocked.length === 0) {
2291
2330
  console.log(` ${dim('no dependents unlocked')}`);
2292
2331
  } else {
@@ -3502,6 +3541,97 @@ async function reconcileCommand(args) {
3502
3541
  console.log(`${formatEvidence(evidence)}\n`);
3503
3542
  }
3504
3543
 
3544
+ // `hedgehog fast-path <intent-id> --reason "<why>" --verify "<command>"` —
3545
+ // closes every remaining task of an intent below the normal per-layer
3546
+ // claim/verify ceremony (see src/db/fastpath.mjs). Requires a clean
3547
+ // working tree, checks the fix against the union of the remaining tasks'
3548
+ // own scope, and runs `--verify` for real before completing anything.
3549
+ async function fastpathCommand(args) {
3550
+ await ensureDb();
3551
+
3552
+ if (!(await exists(DB_PATH))) {
3553
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
3554
+ process.exitCode = 1;
3555
+ return;
3556
+ }
3557
+
3558
+ const sub = args[0];
3559
+
3560
+ if (sub === 'list') {
3561
+ const fastpaths = await loadFastpaths();
3562
+ const db = openDb({ readOnly: true });
3563
+ let orphaned;
3564
+ try {
3565
+ orphaned = orphanedFastpathTasks(db, fastpaths);
3566
+ } finally {
3567
+ db.close();
3568
+ }
3569
+ if (fastpaths.size === 0) {
3570
+ console.log(`${dim('No intent has been fast-pathed.')}\n`);
3571
+ return;
3572
+ }
3573
+ for (const record of fastpaths.values()) {
3574
+ console.log(`${bold(record.intent)}`);
3575
+ console.log(` ${record.reason}`);
3576
+ console.log(` verified with: ${record.verify_command}`);
3577
+ console.log(` confirmed ${record.confirmed_at}`);
3578
+ console.log(` tasks: ${record.tasks.join(', ')}`);
3579
+ console.log();
3580
+ }
3581
+ if (orphaned.length > 0) {
3582
+ console.log(
3583
+ `${dim(`Orphaned: ${orphaned.join(', ')} — no task with this id exists in the build graph.`)}\n`,
3584
+ );
3585
+ }
3586
+ return;
3587
+ }
3588
+
3589
+ const intentId = args[0];
3590
+ const reasonIdx = args.indexOf('--reason');
3591
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
3592
+ const verifyIdx = args.indexOf('--verify');
3593
+ const verifyCommand = verifyIdx !== -1 ? args[verifyIdx + 1] : undefined;
3594
+
3595
+ if (!intentId || intentId.startsWith('--') || !reason || !verifyCommand) {
3596
+ console.error(
3597
+ `${red('Usage:')} hedgehog fast-path <intent-id> --reason "<why>" --verify "<command>"\n` +
3598
+ ` or: hedgehog fast-path list\n\n` +
3599
+ `${dim('A fast-path is a narrow exception, not a default: use it only for a change')}\n` +
3600
+ `${dim('small enough that the per-layer loop costs more than the risk it prevents —')}\n` +
3601
+ `${dim('already committed, already tested, low risk. --verify names one real command')}\n` +
3602
+ `${dim('that must pass; the working tree must already be clean; and the diff since')}\n` +
3603
+ `${dim("the graph's last credited commit is still checked against the union of the")}\n` +
3604
+ `${dim('remaining tasks\' own scope — a fast-path never bypasses scope gating.')}\n`,
3605
+ );
3606
+ process.exitCode = 1;
3607
+ return;
3608
+ }
3609
+
3610
+ printDbTarget();
3611
+ const db = openDb();
3612
+ let result;
3613
+ try {
3614
+ result = await runFastpath(db, { intentId, reason, verifyCommand });
3615
+ } catch (err) {
3616
+ console.error(`${red('Failed to fast-path:')} ${err.message}\n`);
3617
+ process.exitCode = 1;
3618
+ return;
3619
+ } finally {
3620
+ db.close();
3621
+ }
3622
+
3623
+ const file = `${FASTPATH_DIR}/${result.record.intent.toLowerCase()}.json`;
3624
+ console.log(
3625
+ ` ${green('complete')} ${bold(result.record.intent)} ${dim(`(${result.record.tasks.length} task(s), fast-pathed, not per-layer verified)`)}`,
3626
+ );
3627
+ for (const taskId of result.record.tasks) console.log(` ${dim('closed')} ${taskId}`);
3628
+ console.log(` ${green('recorded')} ${file}`);
3629
+ console.log(
3630
+ `\n ${bold('Commit that file.')} ${dim('The build graph is derived and gitignored — an')}\n` +
3631
+ ` ${dim('uncommitted fast-path is reverted by the next `hedgehog db rebuild`.')}\n`,
3632
+ );
3633
+ }
3634
+
3505
3635
  // `hedgehog merge <intent-id>` — merges `hedgehog/<intent-id>` into trunk
3506
3636
  // with `git merge --no-ff`, rebuilds trunk's graph from what merged, then
3507
3637
  // removes the worktree and its branch. See src/db/worktree.mjs for why a
@@ -3844,28 +3974,61 @@ async function debtCommand(args) {
3844
3974
  }
3845
3975
 
3846
3976
  if (sub === 'list') {
3847
- const taskId = args[1];
3977
+ const includeResolved = args.includes('--all') || args.includes('--resolved');
3978
+ const taskId = args.slice(1).find((a) => !a.startsWith('--'));
3848
3979
  const db = openDb();
3849
3980
  let entries;
3850
3981
  try {
3851
- entries = listDebt(db, taskId);
3982
+ entries = listDebt(db, taskId, { includeResolved });
3852
3983
  } finally {
3853
3984
  db.close();
3854
3985
  }
3855
3986
 
3856
3987
  if (entries.length === 0) {
3857
- console.log(`${dim('No debt declared.')}\n`);
3988
+ console.log(`${dim(includeResolved ? 'No debt declared.' : 'No open debt.')}\n`);
3858
3989
  return;
3859
3990
  }
3860
3991
  for (const entry of entries) {
3861
3992
  console.log(`#${entry.id} ${dim(entry.loggedAt)} ${bold(entry.taskId)}`);
3862
- console.log(` ${entry.note}\n`);
3993
+ console.log(` ${entry.note}`);
3994
+ if (entry.resolvedAt) {
3995
+ console.log(` ${green('resolved')} ${dim(entry.resolvedAt)} — ${entry.resolvedReason}`);
3996
+ }
3997
+ console.log('');
3998
+ }
3999
+ return;
4000
+ }
4001
+
4002
+ if (sub === 'resolve') {
4003
+ const debtId = args[1];
4004
+ const reasonIdx = args.indexOf('--reason');
4005
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
4006
+
4007
+ if (!debtId || debtId.startsWith('--') || !reason) {
4008
+ console.error(`${red('Usage:')} hedgehog debt resolve <debt-id> --reason "<why>"\n`);
4009
+ process.exitCode = 1;
4010
+ return;
4011
+ }
4012
+
4013
+ const db = openDb();
4014
+ let result;
4015
+ try {
4016
+ result = await resolveDebt(db, { debtId: Number(debtId), reason });
4017
+ } catch (err) {
4018
+ console.error(`${red('Failed to resolve debt:')} ${err.message}\n`);
4019
+ process.exitCode = 1;
4020
+ return;
4021
+ } finally {
4022
+ db.close();
3863
4023
  }
4024
+
4025
+ console.log(` ${green('resolved')} #${result.id} ${bold(result.taskId)}`);
4026
+ console.log(` ${dim(result.note)}`);
3864
4027
  return;
3865
4028
  }
3866
4029
 
3867
4030
  console.error(
3868
- `${red('Unknown debt subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog debt add <task-id> "<note>"\n or: hedgehog debt list [<task-id>]\n`,
4031
+ `${red('Unknown debt subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog debt add <task-id> "<note>"\n or: hedgehog debt list [<task-id>] [--all]\n or: hedgehog debt resolve <debt-id> --reason "<why>"\n`,
3869
4032
  );
3870
4033
  process.exitCode = 1;
3871
4034
  }
@@ -4335,6 +4498,11 @@ async function main() {
4335
4498
  return;
4336
4499
  }
4337
4500
 
4501
+ if (cmd === 'fast-path') {
4502
+ await fastpathCommand(args.slice(1));
4503
+ return;
4504
+ }
4505
+
4338
4506
  if (cmd === 'merge') {
4339
4507
  await mergeCommand(args.slice(1));
4340
4508
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.3.0",
3
+ "version": "6.3.2",
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/debt.mjs CHANGED
@@ -58,14 +58,25 @@ export async function addDebt(db, { taskId, note }, notesDir = undefined) {
58
58
  return { id: Number(result.lastInsertRowid), taskId, note };
59
59
  }
60
60
 
61
- // Every debt row, oldest first, optionally narrowed to one task.
62
- export function listDebt(db, taskId) {
63
- const where = taskId ? 'WHERE task_id = ?' : '';
64
- const params = taskId ? [taskId] : [];
61
+ // Every debt row, oldest first, optionally narrowed to one task. Resolved
62
+ // rows are excluded by default — `debt list` is meant to answer "what's
63
+ // still open", the same way `reconcile list` never re-surfaces a task once
64
+ // it has closed and included when `includeResolved` is set (`--all`).
65
+ export function listDebt(db, taskId, { includeResolved = false } = {}) {
66
+ const conditions = [];
67
+ const params = [];
68
+ if (taskId) {
69
+ conditions.push('task_id = ?');
70
+ params.push(taskId);
71
+ }
72
+ if (!includeResolved) conditions.push('resolved_at IS NULL');
73
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
65
74
  try {
66
75
  return db
67
76
  .prepare(
68
- `SELECT id, task_id AS taskId, note, logged_at AS loggedAt FROM debt ${where} ORDER BY id ASC`,
77
+ `SELECT id, task_id AS taskId, note, logged_at AS loggedAt,
78
+ resolved_at AS resolvedAt, resolved_reason AS resolvedReason
79
+ FROM debt ${where} ORDER BY id ASC`,
69
80
  )
70
81
  .all(...params);
71
82
  } catch {
@@ -73,3 +84,54 @@ export function listDebt(db, taskId) {
73
84
  return [];
74
85
  }
75
86
  }
87
+
88
+ // Count of open (unresolved) debt across the whole graph — the one-line
89
+ // figure `hedgehog next`/`hedgehog claim` surface so debt doesn't
90
+ // accumulate silently with nothing prompting a look at it.
91
+ export function openDebtCount(db) {
92
+ try {
93
+ const row = db.prepare('SELECT COUNT(*) AS n FROM debt WHERE resolved_at IS NULL').get();
94
+ return row.n;
95
+ } catch {
96
+ return 0;
97
+ }
98
+ }
99
+
100
+ // Resolves one debt row by id: marks it resolved in the DB and writes the
101
+ // committed record behind it — same ordering as addDebt (file before row)
102
+ // and the same reason: if the write fails, nothing has been resolved on a
103
+ // fact that would not survive the next rebuild.
104
+ //
105
+ // The committed note is keyed by the debt row's own (task_id, note,
106
+ // logged_at) rather than its DB `id`, because that id is a fresh
107
+ // autoincrement every time rebuild.mjs#replayNotes re-inserts debt rows —
108
+ // it is not stable across a rebuild, so a resolution referencing it would
109
+ // point at nothing once replayed. The triple already committed for the
110
+ // debt note itself is the one part of its identity that *is* stable.
111
+ export async function resolveDebt(db, { debtId, reason }, notesDir = undefined) {
112
+ applySchema(db);
113
+
114
+ if (!debtId) throw new Error('debt resolve requires a debt id');
115
+ if (!reason) throw new Error('debt resolve requires a --reason');
116
+
117
+ const row = db
118
+ .prepare('SELECT id, task_id AS taskId, note, logged_at AS loggedAt, resolved_at AS resolvedAt FROM debt WHERE id = ?')
119
+ .get(debtId);
120
+ if (!row) throw new Error(`no such debt: #${debtId}`);
121
+ if (row.resolvedAt) throw new Error(`debt #${debtId} is already resolved`);
122
+
123
+ const resolvedAt = new Date().toISOString();
124
+ await appendNote(
125
+ row.taskId,
126
+ { kind: 'debt-resolve', resolves: row.loggedAt, reason, loggedAt: resolvedAt },
127
+ notesDir,
128
+ );
129
+
130
+ db.prepare('UPDATE debt SET resolved_at = ?, resolved_reason = ? WHERE id = ?').run(
131
+ resolvedAt,
132
+ reason,
133
+ debtId,
134
+ );
135
+
136
+ return { id: row.id, taskId: row.taskId, note: row.note, resolvedAt, resolvedReason: reason };
137
+ }
@@ -0,0 +1,53 @@
1
+ // The set of paths every scope-facing check in this engine excludes
2
+ // before judging what an agent (or a fast-path) actually touched: the
3
+ // build graph file and its sidecars, the commit lock, the star-prompt
4
+ // state file, and every directory a build-graph command writes its own
5
+ // committed record into (`friction add`, `override add`, `intent add`/
6
+ // `db rebuild`, `reconcile confirm`, `debt add`/`decision add`, a no-op
7
+ // completion, a fast-path). None of these is ever a layer's own work, so
8
+ // a path under one of them is never attributable to whatever task or
9
+ // intent happened to be active when it changed.
10
+ //
11
+ // verify.mjs's scope gate and fastpath.mjs's scope check both need
12
+ // exactly this predicate; this module exists so it has one definition
13
+ // instead of two that could drift apart.
14
+
15
+ import { DB_PATH } from './init.mjs';
16
+ import { LOCK_PATH } from './commitLock.mjs';
17
+ import { FRICTION_DIR } from './friction.mjs';
18
+ import { OVERRIDES_DIR } from './overrides.mjs';
19
+ import { INTENTS_DIR } from './intent.mjs';
20
+ import { RECONCILED_DIR } from './reconcile.mjs';
21
+ import { NOTES_DIR } from './notes.mjs';
22
+ import { NOOP_DIR } from './noop.mjs';
23
+ import { COMMUNITY_PATH } from './community.mjs';
24
+
25
+ // `FASTPATH_DIR` is deliberately not imported here: fastpath.mjs needs
26
+ // this module's predicates for its own scope check, and importing
27
+ // FASTPATH_DIR from fastpath.mjs here would make that a cycle. Its caller
28
+ // there passes it in instead (see fastpath.mjs's own use of
29
+ // isBuildGraphStatePath).
30
+ export const BUILD_GRAPH_STATE_DIRS = [
31
+ FRICTION_DIR,
32
+ OVERRIDES_DIR,
33
+ INTENTS_DIR,
34
+ RECONCILED_DIR,
35
+ NOTES_DIR,
36
+ NOOP_DIR,
37
+ ];
38
+
39
+ export function isBuildGraphStatePath(path, extraDirs = []) {
40
+ return [...BUILD_GRAPH_STATE_DIRS, ...extraDirs].some(
41
+ (dir) => path === dir || path.startsWith(`${dir}/`),
42
+ );
43
+ }
44
+
45
+ export function isEngineStatePath(path, extraDirs = []) {
46
+ return (
47
+ path === DB_PATH ||
48
+ path.startsWith(`${DB_PATH}-`) ||
49
+ path === LOCK_PATH ||
50
+ path === COMMUNITY_PATH ||
51
+ isBuildGraphStatePath(path, extraDirs)
52
+ );
53
+ }
@@ -0,0 +1,287 @@
1
+ // `hedgehog fast-path <intent-id> --reason "<why>"` — a sanctioned way to
2
+ // close every remaining task of an intent below the normal claim/verify
3
+ // ceremony, for a change small enough that walking it through every
4
+ // remaining layer costs more than the risk the loop exists to catch.
5
+ //
6
+ // This is not a second way to skip verification. It is a narrower,
7
+ // explicit substitute for it:
8
+ //
9
+ // - The working tree must be clean (everything already committed) —
10
+ // fast-pathing an intent whose fix is still sitting uncommitted has
11
+ // nothing for the scope check below to check.
12
+ // - The union of every remaining task's own scope_globs still gates the
13
+ // diff, exactly the way `hedgehog verify`'s own scope gate does: a
14
+ // commit that touched anything outside that union blocks the
15
+ // fast-path the same way it would block a normal verify. Discretion
16
+ // over ceremony never extends to discretion over the one guarantee
17
+ // that matters — a layer only writes inside its own boundary.
18
+ // - `--verify` names one real command that must pass before anything
19
+ // completes; the caller states it explicitly (the union of the
20
+ // skipped layers' own verify_commands is the honest default, but a
21
+ // narrower substitute is allowed, per that call being the same kind
22
+ // of judgment call `hedgehog verify` doesn't need a human for).
23
+ // - The decision is a committed record under `.hedgehog/fastpath/`, the
24
+ // same file-before-row shape reconcile.mjs uses, and for the same
25
+ // reason: without it, `hedgehog db rebuild` has nothing to replay and
26
+ // silently reintroduces every task this closed.
27
+ //
28
+ // A fast-pathed task is functionally identical to a reconciled one from
29
+ // markCompletedTasks's point of view — no commit_message of its own to
30
+ // match, closed by a committed decision instead — so rebuild.mjs seeds it
31
+ // into the same `reconciledTaskIds`-shaped set reconciliation uses.
32
+
33
+ import { execFileSync, execSync } from 'node:child_process';
34
+ import { readdir, readFile, mkdir, writeFile, rename, rm } from 'node:fs/promises';
35
+ import { pathInScope, newestGraphCommit, commitsSince } from './reconcile.mjs';
36
+ import { isEngineStatePath } from './engineState.mjs';
37
+
38
+ export const FASTPATH_DIR = '.hedgehog/fastpath';
39
+
40
+ function fastpathFilePath(intentId, fastpathDir = FASTPATH_DIR) {
41
+ return `${fastpathDir}/${intentId.toLowerCase()}.json`;
42
+ }
43
+
44
+ function git(args, options = {}) {
45
+ return execFileSync('git', args, { encoding: 'utf8', ...options });
46
+ }
47
+
48
+ function validateFastpath(record, path) {
49
+ if (record === null || typeof record !== 'object') {
50
+ throw new Error(`${path}: fast-path record must be a JSON object`);
51
+ }
52
+ const { intent, reason, verify_command: verifyCommand, confirmed_at: confirmedAt, tasks } = record;
53
+
54
+ // Not upper-cased: an intent id, unlike a task id, is stored exactly as
55
+ // given at `intent add` time (intent.mjs#normalizeIntent never cases
56
+ // it) — casing this would make the record's own lookup key disagree
57
+ // with `intents.id`.
58
+ if (!intent || typeof intent !== 'string') {
59
+ throw new Error(`${path}: fast-path record requires an "intent" id (string)`);
60
+ }
61
+
62
+ if (!reason || typeof reason !== 'string') {
63
+ throw new Error(`${path}: fast-path "${intent}" requires a "reason" (string)`);
64
+ }
65
+ if (!verifyCommand || typeof verifyCommand !== 'string') {
66
+ throw new Error(`${path}: fast-path "${intent}" requires a "verify_command" (string)`);
67
+ }
68
+ if (!confirmedAt || typeof confirmedAt !== 'string') {
69
+ throw new Error(`${path}: fast-path "${intent}" requires a "confirmed_at" timestamp (string)`);
70
+ }
71
+ if (!Array.isArray(tasks) || tasks.length === 0) {
72
+ throw new Error(`${path}: fast-path "${intent}" requires a non-empty "tasks" array`);
73
+ }
74
+ for (const t of tasks) {
75
+ if (typeof t !== 'string' || t.trim() === '') {
76
+ throw new Error(`${path}: fast-path "${intent}" has a non-string or empty entry in tasks`);
77
+ }
78
+ }
79
+
80
+ return {
81
+ intent,
82
+ reason,
83
+ verify_command: verifyCommand,
84
+ confirmed_at: confirmedAt,
85
+ tasks: tasks.map((t) => t.toUpperCase()),
86
+ };
87
+ }
88
+
89
+ // Every *.json in `fastpathDir`, validated, as a Map from intent id to its
90
+ // record — same convention as reconcile.mjs#loadReconciliations.
91
+ export async function loadFastpaths(fastpathDir = FASTPATH_DIR) {
92
+ let entries;
93
+ try {
94
+ entries = await readdir(fastpathDir);
95
+ } catch {
96
+ return new Map();
97
+ }
98
+
99
+ const byIntent = new Map();
100
+ for (const name of entries.filter((n) => n.endsWith('.json')).sort()) {
101
+ const path = `${fastpathDir}/${name}`;
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(await readFile(path, 'utf8'));
105
+ } catch (err) {
106
+ throw new Error(`could not read fast-path record ${path}: ${err.message}`, { cause: err });
107
+ }
108
+ const record = validateFastpath(parsed, path);
109
+ byIntent.set(record.intent, record);
110
+ }
111
+ return byIntent;
112
+ }
113
+
114
+ // Fast-pathed task ids matching no row in `tasks` — same read-side hygiene
115
+ // as reconcile.mjs#orphanedReconciliations.
116
+ export function orphanedFastpathTasks(db, fastpaths) {
117
+ const known = new Set(db.prepare('SELECT id FROM tasks').all().map((r) => r.id));
118
+ const orphaned = [];
119
+ for (const record of fastpaths.values()) {
120
+ for (const taskId of record.tasks) {
121
+ if (!known.has(taskId)) orphaned.push(taskId);
122
+ }
123
+ }
124
+ return orphaned.sort();
125
+ }
126
+
127
+ export async function writeFastpathFile(record, fastpathDir = FASTPATH_DIR) {
128
+ const path = fastpathFilePath(record.intent, fastpathDir);
129
+ try {
130
+ await readFile(path, 'utf8');
131
+ throw new Error(`${path} already exists — ${record.intent} is already recorded as fast-pathed.`);
132
+ } catch (err) {
133
+ if (!err || err.code !== 'ENOENT') throw err;
134
+ }
135
+
136
+ await mkdir(fastpathDir, { recursive: true });
137
+ const tempPath = `${path}.tmp-${process.pid}`;
138
+ try {
139
+ await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`);
140
+ await rename(tempPath, path);
141
+ } catch (err) {
142
+ await rm(tempPath, { force: true }).catch(() => {});
143
+ throw err;
144
+ }
145
+ return record;
146
+ }
147
+
148
+ // Every task belonging to `intentId` that isn't already `complete` —
149
+ // exactly the set a fast-path is closing.
150
+ function loadRemainingTasks(db, intentId) {
151
+ return db
152
+ .prepare("SELECT id, scope_globs, verify_command FROM tasks WHERE intent_id = ? AND status <> 'complete'")
153
+ .all(intentId);
154
+ }
155
+
156
+ // The provenance note fast-pathing writes into `decisions`, rendered into
157
+ // every dependent task's packet the same way reconcile.mjs#reconciledNote
158
+ // is — a dependent must be told outright that its prerequisite closed by
159
+ // explicit fast-path, not by a normal verify run.
160
+ export function fastpathNote(record) {
161
+ return (
162
+ `Closed by fast-path, not per-layer verification: ${record.reason} ` +
163
+ `(verified with: ${record.verify_command}; ${record.tasks.length} task(s) closed together)`
164
+ );
165
+ }
166
+
167
+ // Runs `hedgehog fast-path <intent-id> --reason "<why>"`.
168
+ //
169
+ // Refuses when the working tree is dirty — a fast-pathed intent's fix must
170
+ // already be committed, or there is nothing for the scope check below to
171
+ // check against. Refuses when any commit since the graph's own newest
172
+ // credited commit touched a path outside the union of the remaining
173
+ // tasks' own scope_globs — a fast-path is not a way around the one
174
+ // guarantee `hedgehog verify` exists to hold. Runs `verifyCommand` for
175
+ // real and refuses on a nonzero exit, exactly like `hedgehog verify`
176
+ // refuses to complete a task whose verify_command fails.
177
+ export async function runFastpath(
178
+ db,
179
+ { intentId, reason, verifyCommand },
180
+ fastpathDir = FASTPATH_DIR,
181
+ ) {
182
+ if (!intentId) throw new Error('fast-path requires an intent id');
183
+ if (!reason) throw new Error('fast-path requires a --reason');
184
+ if (!verifyCommand) throw new Error('fast-path requires a --verify command');
185
+
186
+ // Unlike a task id (always upper-cased — plan.mjs#taskId), an intent id
187
+ // is stored exactly as given at `intent add` time (intent.mjs#normalizeIntent
188
+ // never cases it), so it is looked up verbatim here too.
189
+ const id = intentId;
190
+ const intent = db.prepare('SELECT id FROM intents WHERE id = ?').get(id);
191
+ if (!intent) throw new Error(`no such intent: ${id}`);
192
+
193
+ const remaining = loadRemainingTasks(db, id);
194
+ if (remaining.length === 0) {
195
+ throw new Error(`Intent ${id} has no remaining tasks — there is nothing to fast-path.`);
196
+ }
197
+
198
+ const dirty = git(['status', '--porcelain']).trim();
199
+ if (dirty !== '') {
200
+ throw new Error(
201
+ `The working tree has uncommitted changes. Fast-pathing requires the fix to already be ` +
202
+ `committed, so the scope check below has something real to check:\n\n${dirty}`,
203
+ );
204
+ }
205
+
206
+ const scopeGlobs = remaining.flatMap((t) => JSON.parse(t.scope_globs));
207
+ const since = newestGraphCommit(db);
208
+ const commits = commitsSince(since);
209
+ // Excludes the same build-graph/engine state every scope-facing check
210
+ // in this engine excludes (engineState.mjs) — the bootstrap commit that
211
+ // first added core.yaml, .gitignore, or a committed intent file sits
212
+ // inside this window on a project with nothing verified yet
213
+ // (newestGraphCommit returns null there), and none of that is this
214
+ // fast-path's own fix to be judged against.
215
+ const offending = [];
216
+ for (const commit of commits) {
217
+ for (const path of commit.paths) {
218
+ if (isEngineStatePath(path)) continue;
219
+ if (!pathInScope(path, scopeGlobs)) offending.push(path);
220
+ }
221
+ }
222
+ if (offending.length > 0) {
223
+ throw new Error(
224
+ `Scope violation. The following path(s) touched since the graph's last credited commit ` +
225
+ `fall outside the union of ${id}'s remaining tasks' scope:\n\n` +
226
+ [...new Set(offending)].map((p) => ` ${p}`).join('\n') +
227
+ `\n\nA fast-path cannot close tasks whose scope wasn't actually where the change landed.`,
228
+ );
229
+ }
230
+
231
+ let exitCode = 0;
232
+ let output;
233
+ try {
234
+ output = execSync(verifyCommand, { encoding: 'utf8', stdio: 'pipe' });
235
+ } catch (err) {
236
+ exitCode = err.status ?? 1;
237
+ output = `${err.stdout ?? ''}${err.stderr ?? ''}` || err.message;
238
+ }
239
+ if (exitCode !== 0) {
240
+ throw new Error(`Fast-path verification failed (exit ${exitCode}):\n\n${output}`);
241
+ }
242
+
243
+ const record = validateFastpath(
244
+ {
245
+ intent: id,
246
+ reason,
247
+ verify_command: verifyCommand,
248
+ confirmed_at: new Date().toISOString(),
249
+ tasks: remaining.map((t) => t.id),
250
+ },
251
+ '(new fast-path)',
252
+ );
253
+
254
+ await writeFastpathFile(record, fastpathDir);
255
+ applyFastpath(db, record);
256
+ return { record, output };
257
+ }
258
+
259
+ // Applies a confirmed fast-path record to the graph: every task it names
260
+ // goes `complete`, a provenance note lands on each, and the intent closes
261
+ // once nothing remains — the same bookkeeping reconcile.mjs#applyReconciliation
262
+ // does for a single task, run here over the whole set at once.
263
+ export function applyFastpath(db, record) {
264
+ const note = fastpathNote(record);
265
+ db.exec('BEGIN IMMEDIATE');
266
+ try {
267
+ const setComplete = db.prepare(
268
+ "UPDATE tasks SET status = 'complete', blocked_reason = NULL WHERE id = ?",
269
+ );
270
+ const insertNote = db.prepare('INSERT INTO decisions (task_id, note) VALUES (?, ?)');
271
+ for (const taskId of record.tasks) {
272
+ setComplete.run(taskId);
273
+ insertNote.run(taskId, note);
274
+ }
275
+ db.prepare(
276
+ "UPDATE intents SET status = 'complete' WHERE id = ? AND NOT EXISTS (SELECT 1 FROM tasks WHERE intent_id = ? AND status <> 'complete')",
277
+ ).run(record.intent, record.intent);
278
+ db.exec('COMMIT');
279
+ } catch (err) {
280
+ try {
281
+ db.exec('ROLLBACK');
282
+ } catch {
283
+ // Rollback failing must not mask the original error.
284
+ }
285
+ throw err;
286
+ }
287
+ }
package/src/db/next.mjs CHANGED
@@ -105,7 +105,9 @@ function loadInheritedDebt(db, taskId) {
105
105
  try {
106
106
  return db
107
107
  .prepare(
108
- `SELECT task_id AS taskId, note FROM debt WHERE task_id IN (${placeholders}) ORDER BY id ASC`,
108
+ `SELECT task_id AS taskId, note FROM debt
109
+ WHERE task_id IN (${placeholders}) AND resolved_at IS NULL
110
+ ORDER BY id ASC`,
109
111
  )
110
112
  .all(...upstream);
111
113
  } catch {
@@ -113,6 +115,20 @@ function loadInheritedDebt(db, taskId) {
113
115
  }
114
116
  }
115
117
 
118
+ // Total open (unresolved) debt across the whole graph — not just this
119
+ // task's ancestors — so a packet can say plainly that debt exists even
120
+ // where none of it happens to be inherited here. Tolerates a `debt` table
121
+ // or `resolved_at` column that doesn't exist yet, the same way
122
+ // loadInheritedDebt does.
123
+ function loadOpenDebtCount(db) {
124
+ try {
125
+ const row = db.prepare('SELECT COUNT(*) AS n FROM debt WHERE resolved_at IS NULL').get();
126
+ return row.n;
127
+ } catch {
128
+ return 0;
129
+ }
130
+ }
131
+
116
132
  // Decisions declared by anything this task inherits from (see
117
133
  // decision.mjs) — same upstream walk as loadInheritedDebt, same tolerance
118
134
  // for a `decisions` table that doesn't exist yet on an older graph.
@@ -181,6 +197,7 @@ function assemblePacket(db, task) {
181
197
  const incompleteDeps = incompleteDependencies(db, task.id);
182
198
  const inheritedDebt = loadInheritedDebt(db, task.id);
183
199
  const inheritedDecisions = loadInheritedDecisions(db, task.id);
200
+ const openDebtCount = loadOpenDebtCount(db);
184
201
 
185
202
  return {
186
203
  task,
@@ -190,6 +207,7 @@ function assemblePacket(db, task) {
190
207
  incompleteDeps,
191
208
  inheritedDebt,
192
209
  inheritedDecisions,
210
+ openDebtCount,
193
211
  };
194
212
  }
195
213
 
@@ -484,6 +502,7 @@ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
484
502
  incompleteDeps = [],
485
503
  inheritedDebt = [],
486
504
  inheritedDecisions = [],
505
+ openDebtCount = 0,
487
506
  } = packet;
488
507
  const scopeGlobs = JSON.parse(task.scope_globs);
489
508
  const firstArrival = firstArrivalPackages(task, exists);
@@ -521,6 +540,11 @@ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
521
540
  lines.push(` ! ${entry.taskId} ${entry.note}`);
522
541
  }
523
542
  }
543
+ if (openDebtCount > 0) {
544
+ lines.push(
545
+ ` ${openDebtCount} open debt note(s) across the whole graph — \`hedgehog debt list --all\` to see them all`,
546
+ );
547
+ }
524
548
  lines.push('');
525
549
  lines.push('INHERITED DECISIONS');
526
550
  if (inheritedDecisions.length === 0) {
@@ -0,0 +1,108 @@
1
+ // `.hedgehog/noop/<task-id>.json` — the committed record behind a task
2
+ // verify.mjs closes `complete` without a git commit, because its scope had
3
+ // nothing left to touch before verify_command even ran.
4
+ //
5
+ // A completed task is normally recoverable on `hedgehog db rebuild`
6
+ // because its own commit's subject matches its `commit_message`
7
+ // (rebuild.mjs#markCompletedTasks). A task closed with no commit at all
8
+ // has nothing there to match, so — the same gap reconcile.mjs closes for a
9
+ // hand-written commit that doesn't match — this file is the committed
10
+ // source rebuild.mjs replays instead.
11
+ //
12
+ // One file per task, written once: a task closes as a no-op at most once,
13
+ // the same reason reconcile.mjs's record refuses to overwrite rather than
14
+ // growing like notes.mjs's does.
15
+
16
+ import { readdir, readFile, mkdir, writeFile, rename, rm } from 'node:fs/promises';
17
+
18
+ export const NOOP_DIR = '.hedgehog/noop';
19
+
20
+ function noopFilePath(taskId, noopDir = NOOP_DIR) {
21
+ return `${noopDir}/${taskId.toLowerCase()}.json`;
22
+ }
23
+
24
+ function validateNoop(record, path) {
25
+ if (record === null || typeof record !== 'object') {
26
+ throw new Error(`${path}: no-op record must be a JSON object`);
27
+ }
28
+ let { task } = record;
29
+ const { commit_message: commitMessage, verified_at: verifiedAt } = record;
30
+
31
+ if (!task || typeof task !== 'string') {
32
+ throw new Error(`${path}: no-op record requires a "task" id (string)`);
33
+ }
34
+ task = task.toUpperCase();
35
+
36
+ if (!commitMessage || typeof commitMessage !== 'string') {
37
+ throw new Error(`${path}: no-op record "${task}" requires a "commit_message" (string)`);
38
+ }
39
+ if (!verifiedAt || typeof verifiedAt !== 'string') {
40
+ throw new Error(`${path}: no-op record "${task}" requires a "verified_at" timestamp (string)`);
41
+ }
42
+
43
+ return { task, commit_message: commitMessage, verified_at: verifiedAt };
44
+ }
45
+
46
+ // Every *.json in `noopDir`, validated, as a Map from task id to its
47
+ // record. Absent directory reads as "nothing closed no-op" — the same
48
+ // convention overrides.mjs#loadOverrides and reconcile.mjs#loadReconciliations
49
+ // use for their own missing directories.
50
+ export async function loadNoopRecords(noopDir = NOOP_DIR) {
51
+ let entries;
52
+ try {
53
+ entries = await readdir(noopDir);
54
+ } catch {
55
+ return new Map();
56
+ }
57
+
58
+ const byTask = new Map();
59
+ for (const name of entries.filter((n) => n.endsWith('.json')).sort()) {
60
+ const path = `${noopDir}/${name}`;
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(await readFile(path, 'utf8'));
64
+ } catch (err) {
65
+ throw new Error(`could not read no-op record ${path}: ${err.message}`, { cause: err });
66
+ }
67
+ const record = validateNoop(parsed, path);
68
+ byTask.set(record.task, record);
69
+ }
70
+ return byTask;
71
+ }
72
+
73
+ // Writes one no-op record via temp file + rename, so a crash mid-write
74
+ // never leaves a half-written file for loadNoopRecords to trip on —
75
+ // reconcile.mjs#writeReconciledFile's pattern, applied to the same
76
+ // one-shot-per-task shape.
77
+ //
78
+ // Refuses to overwrite silently: a task closes no-op once, and a second
79
+ // attempt for the same id is a wrong id or a re-run worth stopping for,
80
+ // not a second distinct fact.
81
+ export async function writeNoopFile(record, noopDir = NOOP_DIR) {
82
+ const path = noopFilePath(record.task, noopDir);
83
+ try {
84
+ await readFile(path, 'utf8');
85
+ throw new Error(`${path} already exists — ${record.task} is already recorded as a no-op completion.`);
86
+ } catch (err) {
87
+ if (!err || err.code !== 'ENOENT') throw err;
88
+ }
89
+
90
+ await mkdir(noopDir, { recursive: true });
91
+ const tempPath = `${path}.tmp-${process.pid}`;
92
+ try {
93
+ await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`);
94
+ await rename(tempPath, path);
95
+ } catch (err) {
96
+ await rm(tempPath, { force: true }).catch(() => {});
97
+ throw err;
98
+ }
99
+ return record;
100
+ }
101
+
102
+ // No-op-record task ids matching no row in `tasks` — same read-side
103
+ // hygiene as reconcile.mjs#orphanedReconciliations: a dead record must
104
+ // stay discoverable rather than silently completing nothing forever.
105
+ export function orphanedNoopRecords(db, records) {
106
+ const known = new Set(db.prepare('SELECT id FROM tasks').all().map((r) => r.id));
107
+ return [...records.keys()].filter((taskId) => !known.has(taskId)).sort();
108
+ }
package/src/db/notes.mjs CHANGED
@@ -45,12 +45,21 @@ function validateNotesFile(record, path) {
45
45
  if (entry === null || typeof entry !== 'object') {
46
46
  throw new Error(`${path}: notes file "${task}" has a non-object entry in notes`);
47
47
  }
48
- if (entry.kind !== 'debt' && entry.kind !== 'decision') {
48
+ if (entry.kind !== 'debt' && entry.kind !== 'decision' && entry.kind !== 'debt-resolve') {
49
49
  throw new Error(
50
- `${path}: notes file "${task}" has an entry with kind "${entry.kind}" — expected "debt" or "decision"`,
50
+ `${path}: notes file "${task}" has an entry with kind "${entry.kind}" — expected "debt", "decision", or "debt-resolve"`,
51
51
  );
52
52
  }
53
- if (!entry.note || typeof entry.note !== 'string') {
53
+ if (entry.kind === 'debt-resolve') {
54
+ if (!entry.resolves || typeof entry.resolves !== 'string') {
55
+ throw new Error(
56
+ `${path}: notes file "${task}" has a "debt-resolve" entry with no "resolves" (the logged_at of the debt note it closes)`,
57
+ );
58
+ }
59
+ if (!entry.reason || typeof entry.reason !== 'string') {
60
+ throw new Error(`${path}: notes file "${task}" has a "debt-resolve" entry with no "reason"`);
61
+ }
62
+ } else if (!entry.note || typeof entry.note !== 'string') {
54
63
  throw new Error(`${path}: notes file "${task}" has an entry with no "note" (string)`);
55
64
  }
56
65
  if (!entry.logged_at || typeof entry.logged_at !== 'string') {
@@ -92,7 +101,7 @@ export async function loadNotes(notesDir = NOTES_DIR) {
92
101
  // never leaves a half-written file for loadNotes to trip on —
93
102
  // reconcile.mjs#writeReconciledFile's pattern, applied to a file that
94
103
  // grows instead of one written once.
95
- export async function appendNote(taskId, { kind, note, loggedAt }, notesDir = NOTES_DIR) {
104
+ export async function appendNote(taskId, { kind, note, loggedAt, resolves, reason }, notesDir = NOTES_DIR) {
96
105
  const path = notesFilePath(taskId, notesDir);
97
106
 
98
107
  let existing = [];
@@ -103,9 +112,17 @@ export async function appendNote(taskId, { kind, note, loggedAt }, notesDir = NO
103
112
  if (!err || err.code !== 'ENOENT') throw err;
104
113
  }
105
114
 
115
+ // `debt-resolve` entries carry `resolves`/`reason` instead of `note` —
116
+ // omit `note` entirely rather than writing it as undefined/null, so a
117
+ // resolve entry's shape matches what validateNotesFile expects back.
118
+ const entry =
119
+ kind === 'debt-resolve'
120
+ ? { kind, resolves, reason, logged_at: loggedAt }
121
+ : { kind, note, logged_at: loggedAt };
122
+
106
123
  const record = {
107
124
  task: taskId.toUpperCase(),
108
- notes: [...existing, { kind, note, logged_at: loggedAt }],
125
+ notes: [...existing, entry],
109
126
  };
110
127
 
111
128
  await mkdir(notesDir, { recursive: true });
@@ -41,6 +41,8 @@ import {
41
41
  RECONCILED_DIR,
42
42
  } from './reconcile.mjs';
43
43
  import { loadNotes, NOTES_DIR } from './notes.mjs';
44
+ import { loadNoopRecords, orphanedNoopRecords, NOOP_DIR } from './noop.mjs';
45
+ import { loadFastpaths, orphanedFastpathTasks, fastpathNote, FASTPATH_DIR } from './fastpath.mjs';
44
46
  import {
45
47
  loadAbandoned,
46
48
  replayAbandonments,
@@ -110,23 +112,40 @@ function replayNotes(db, notesByTask) {
110
112
  const insertDecision = db.prepare(
111
113
  'INSERT INTO decisions (task_id, note, logged_at) VALUES (?, ?, ?)',
112
114
  );
115
+ const resolveDebtRow = db.prepare(
116
+ 'UPDATE debt SET resolved_at = ?, resolved_reason = ? WHERE task_id = ? AND logged_at = ? AND resolved_at IS NULL',
117
+ );
113
118
 
114
119
  const orphaned = [];
120
+ // Two passes: every `debt`/`decision` entry inserted first, then every
121
+ // `debt-resolve` entry applied — a resolve entry can appear anywhere
122
+ // after its debt entry in the same file, but the row it references must
123
+ // already exist for the UPDATE to find it.
115
124
  for (const [taskId, notes] of notesByTask) {
116
125
  if (taskExists.get(taskId) === undefined) {
117
126
  for (const entry of notes) {
118
- orphaned.push({ kind: entry.kind, taskId, note: entry.note });
127
+ if (entry.kind !== 'debt-resolve') orphaned.push({ kind: entry.kind, taskId, note: entry.note });
119
128
  }
120
129
  continue;
121
130
  }
122
131
  for (const entry of notes) {
123
132
  if (entry.kind === 'debt') {
124
133
  insertDebt.run(taskId, entry.note, entry.logged_at);
125
- } else {
134
+ } else if (entry.kind === 'decision') {
126
135
  insertDecision.run(taskId, entry.note, entry.logged_at);
127
136
  }
128
137
  }
129
138
  }
139
+ for (const [taskId, notes] of notesByTask) {
140
+ if (taskExists.get(taskId) === undefined) continue;
141
+ for (const entry of notes) {
142
+ if (entry.kind !== 'debt-resolve') continue;
143
+ const result = resolveDebtRow.run(entry.logged_at, entry.reason, taskId, entry.resolves);
144
+ if (result.changes === 0) {
145
+ orphaned.push({ kind: entry.kind, taskId, note: `resolve for ${entry.resolves}` });
146
+ }
147
+ }
148
+ }
130
149
  return orphaned;
131
150
  }
132
151
 
@@ -477,6 +496,57 @@ function replayReconciliations(db, reconciliations) {
477
496
  return replayed;
478
497
  }
479
498
 
499
+ // Replays `.hedgehog/fastpath/*.json` — the committed record of every
500
+ // intent a user closed below the normal per-layer ceremony (fastpath.mjs).
501
+ // Same shape and same reason as replayReconciliations: a fast-pathed task
502
+ // has no commit_message of its own to match in commitSubjects (the fix it
503
+ // covers landed as one real commit, not a per-layer one), so without this
504
+ // replay a rebuild would silently revert every fast-pathed task back to
505
+ // its pre-completion status.
506
+ function replayFastpaths(db, fastpaths) {
507
+ const setComplete = db.prepare(
508
+ "UPDATE tasks SET status = 'complete', blocked_reason = NULL WHERE id = ?",
509
+ );
510
+ const insertNote = db.prepare('INSERT INTO decisions (task_id, note) VALUES (?, ?)');
511
+ const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
512
+
513
+ let replayed = 0;
514
+ for (const record of fastpaths.values()) {
515
+ const note = fastpathNote(record);
516
+ for (const taskId of record.tasks) {
517
+ if (taskExists.get(taskId) === undefined) continue;
518
+ setComplete.run(taskId);
519
+ insertNote.run(taskId, note);
520
+ replayed++;
521
+ }
522
+ }
523
+ return replayed;
524
+ }
525
+
526
+ // Replays `.hedgehog/noop/*.json` — the committed record of every task
527
+ // verify.mjs closed `complete` with no commit, because its scope had
528
+ // nothing left to touch before verify_command even ran (noop.mjs).
529
+ //
530
+ // Same shape and same reason as replayReconciliations above: a task
531
+ // recorded here has no commit for markCompletedTasks to match by
532
+ // construction, so without this replay a rebuild would leave it (and
533
+ // everything depending on it) stuck `planned` forever, silently reverting
534
+ // a completion that already happened.
535
+ function replayNoopRecords(db, records) {
536
+ const setComplete = db.prepare(
537
+ "UPDATE tasks SET status = 'complete', blocked_reason = NULL WHERE id = ?",
538
+ );
539
+ const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
540
+
541
+ let replayed = 0;
542
+ for (const taskId of records.keys()) {
543
+ if (taskExists.get(taskId) === undefined) continue;
544
+ setComplete.run(taskId);
545
+ replayed++;
546
+ }
547
+ return replayed;
548
+ }
549
+
480
550
  // Rebuilds `db` from scratch: schema, then every committed intent
481
551
  // replayed in dependency order, then planTasks to re-derive tasks +
482
552
  // dependencies, then git history to reconcile which tasks already
@@ -503,6 +573,8 @@ export async function rebuildDb(
503
573
  overridesDir = OVERRIDES_DIR,
504
574
  reconciledDir = RECONCILED_DIR,
505
575
  notesDir = NOTES_DIR,
576
+ noopDir = NOOP_DIR,
577
+ fastpathDir = FASTPATH_DIR,
506
578
  abandonedDir = ABANDONED_DIR,
507
579
  // `hedgehog merge <id>`'s own rebuild call (bin/cli.mjs#mergeCommand):
508
580
  // at the point it calls rebuildDb, `git merge --no-ff` has already
@@ -527,6 +599,8 @@ export async function rebuildDb(
527
599
  const overrides = await loadOverrides(overridesDir);
528
600
  const reconciliations = await loadReconciliations(reconciledDir);
529
601
  const notesByTask = await loadNotes(notesDir);
602
+ const noopRecords = await loadNoopRecords(noopDir);
603
+ const fastpaths = await loadFastpaths(fastpathDir);
530
604
  const abandonments = await loadAbandoned(abandonedDir);
531
605
 
532
606
  // Mirrors bin/cli.mjs#planCommand's own exclusion set: an intent still
@@ -571,11 +645,25 @@ export async function rebuildDb(
571
645
  // (below) still runs afterward to write the provenance note and cover
572
646
  // any reconciled task markCompletedTasks doesn't touch (module = CORE_MODULE
573
647
  // edge cases aside, every reconciled id ends up here either way).
574
- const reconciledTaskIds = new Set(reconciliations.keys());
648
+ // A no-op-completed task and a fast-pathed task have no commit of their
649
+ // own either, for the same reason a reconciled task doesn't — all
650
+ // seeded into the same set, before the same fixpoint walk, so each
651
+ // satisfies its dependents' "every prerequisite complete" check exactly
652
+ // like a reconciled task does.
653
+ const fastpathTaskIds = [...fastpaths.values()].flatMap((record) => record.tasks);
654
+ const reconciledTaskIds = new Set([
655
+ ...reconciliations.keys(),
656
+ ...noopRecords.keys(),
657
+ ...fastpathTaskIds,
658
+ ]);
575
659
  const tasksMarkedComplete = markCompletedTasks(db, commitSubjects, reconciledTaskIds);
576
660
 
577
661
  const tasksReconciled = replayReconciliations(db, reconciliations);
578
662
  const orphanedReconciled = orphanedReconciliations(db, reconciliations);
663
+ const tasksNoop = replayNoopRecords(db, noopRecords);
664
+ const orphanedNoop = orphanedNoopRecords(db, noopRecords);
665
+ const tasksFastpathed = replayFastpaths(db, fastpaths);
666
+ const orphanedFastpath = orphanedFastpathTasks(db, fastpaths);
579
667
 
580
668
  // After every task-status recovery path above (history-matched commits,
581
669
  // then reconciliation) — either can close an intent's last open task,
@@ -608,6 +696,10 @@ export async function rebuildDb(
608
696
  intentsMarkedComplete,
609
697
  tasksReconciled,
610
698
  orphanedReconciled,
699
+ tasksNoop,
700
+ orphanedNoop,
701
+ tasksFastpathed,
702
+ orphanedFastpath,
611
703
  orphanedNotes,
612
704
  abandonmentsReplayed,
613
705
  orphanedAbandonments,
@@ -202,7 +202,7 @@ export function orphanedReconciliations(db, reconciliations) {
202
202
  // forward-scanning window (commitsSince) needs the wider net, to catch a
203
203
  // hand-written commit that landed elsewhere; the floor it scans from stays
204
204
  // anchored to what this branch itself has already credited.
205
- function newestGraphCommit(db) {
205
+ export function newestGraphCommit(db) {
206
206
  const messages = new Set(
207
207
  db.prepare('SELECT commit_message FROM tasks').all().map((r) => r.commit_message),
208
208
  );
@@ -236,7 +236,7 @@ function newestGraphCommit(db) {
236
236
  // `claim`/`ready`/`status`. A project that has never opened a worktree has
237
237
  // exactly one branch with any commits, so `--all` and `HEAD` name the
238
238
  // same set there and this is a no-op for it.
239
- function commitsSince(sinceSha) {
239
+ export function commitsSince(sinceSha) {
240
240
  const range = sinceSha ? [`${sinceSha}..`, '--all'] : ['--all'];
241
241
  let output;
242
242
  try {
package/src/db/schema.mjs CHANGED
@@ -103,10 +103,12 @@ CREATE TABLE IF NOT EXISTS verifications (
103
103
  -- every task that (transitively) depends on task_id, so the limitation
104
104
  -- travels down the chain the same way the dependency does.
105
105
  CREATE TABLE IF NOT EXISTS debt (
106
- id INTEGER PRIMARY KEY,
107
- task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
108
- note TEXT NOT NULL,
109
- logged_at TEXT NOT NULL DEFAULT (datetime('now'))
106
+ id INTEGER PRIMARY KEY,
107
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
108
+ note TEXT NOT NULL,
109
+ logged_at TEXT NOT NULL DEFAULT (datetime('now')),
110
+ resolved_at TEXT,
111
+ resolved_reason TEXT
110
112
  );
111
113
 
112
114
  -- Declared decision: a note one task leaves for the tasks that inherit
@@ -176,7 +178,7 @@ export function ensureTaskColumns(db) {
176
178
  // hand-set past what MIGRATIONS actually covers, since runMigrations
177
179
  // trusts this number to mean "every migration through this version has
178
180
  // run."
179
- export const CURRENT_SCHEMA_VERSION = 2;
181
+ export const CURRENT_SCHEMA_VERSION = 3;
180
182
 
181
183
  // Forward migrations, applied in order to bring a graph's user_version up
182
184
  // to CURRENT_SCHEMA_VERSION. Unlike the CREATE TABLE IF NOT EXISTS /
@@ -209,6 +211,18 @@ const MIGRATIONS = [
209
211
  `);
210
212
  },
211
213
  },
214
+ {
215
+ version: 3,
216
+ // A `debt` row with no `resolved_at` reads back as unresolved, so an
217
+ // existing graph's rows default to open the moment this column exists.
218
+ migrate: (db) => {
219
+ const existing = new Set(db.prepare('PRAGMA table_info(debt)').all().map((row) => row.name));
220
+ if (!existing.has('resolved_at')) db.exec('ALTER TABLE debt ADD COLUMN resolved_at TEXT');
221
+ if (!existing.has('resolved_reason')) {
222
+ db.exec('ALTER TABLE debt ADD COLUMN resolved_reason TEXT');
223
+ }
224
+ },
225
+ },
212
226
  ];
213
227
 
214
228
  // Brings a graph's `PRAGMA user_version` up to CURRENT_SCHEMA_VERSION,
package/src/db/verify.mjs CHANGED
@@ -23,12 +23,18 @@
23
23
  // verification at all — the task moves to `blocked` with
24
24
  // blocked_reason `scope_violation`, no `verifications` row written,
25
25
  // lease released. This is a scope violation, not a failing check.
26
- // 2. Only once every touched path matches scope does verify_command run.
27
- // Exit 0: verifications row (passed) artifacts recorded git
28
- // commit with commit_message complete, lease released direct
29
- // dependents re-evaluated (a dependent is ready once every
30
- // dependency is complete same check as the readiness SELECT in
31
- // next.mjs).
26
+ // 2. Once every touched path matches scope, gate 1's own scan already
27
+ // knows whether this task's scope has anything touched at all. If
28
+ // not a genuine no-op, the layer's work already satisfied upstream
29
+ // verify_command never runs and no commit is made: the task closes
30
+ // `complete` directly, and a committed record under `.hedgehog/noop/`
31
+ // is what `hedgehog db rebuild` replays to recover it (noop.mjs),
32
+ // since there is no commit for rebuild to match against.
33
+ // Otherwise verify_command runs. Exit 0: verifications row (passed)
34
+ // → artifacts recorded → git commit with commit_message → complete,
35
+ // lease released → direct dependents re-evaluated (a dependent is
36
+ // ready once every dependency is complete — same check as the
37
+ // readiness SELECT in next.mjs).
32
38
  // Nonzero: verifications row (failed, output retained) → blocked
33
39
  // with blocked_reason `verification_failed`, lease released,
34
40
  // dependents stay blocked.
@@ -57,46 +63,25 @@
57
63
  // verify_command that is a shell command by definition.
58
64
 
59
65
  import { execSync, execFileSync } from 'node:child_process';
60
- import { DB_PATH } from './init.mjs';
61
- import { withCommitLock, LOCK_PATH } from './commitLock.mjs';
66
+ import { withCommitLock } from './commitLock.mjs';
62
67
  import { reapExpiredLeases, pathFingerprint } from './claim.mjs';
63
68
  import { ensureTaskColumns } from './schema.mjs';
64
- import { FRICTION_DIR } from './friction.mjs';
65
- import { OVERRIDES_DIR, composeScope } from './overrides.mjs';
66
- import { RECONCILED_DIR } from './reconcile.mjs';
67
- import { INTENTS_DIR } from './intent.mjs';
68
- import { COMMUNITY_PATH } from './community.mjs';
69
- import { NOTES_DIR } from './notes.mjs';
70
-
71
- // Build-graph state directories: written by their own command
72
- // (`friction add`, `override add`, `intent add`/`db rebuild`, `reconcile
73
- // confirm`, `debt add`/`decision add`), committed by that command's own
74
- // next step, never by a layer's verify_command. A
75
- // layer's own work never lands here, so a path under one of these is
76
- // never this task's doing regardless of when it changed relative to
77
- // claim time unlike attributedToTask's fingerprint check, which only
78
- // excludes a path unchanged since claim and so still attributes a
79
- // friction note logged mid-layer (exactly what the loop skill instructs)
80
- // to whichever task happened to be building when it was logged.
81
- const BUILD_GRAPH_STATE_DIRS = [FRICTION_DIR, OVERRIDES_DIR, INTENTS_DIR, RECONCILED_DIR, NOTES_DIR];
82
-
83
- function isBuildGraphStatePath(path) {
84
- return BUILD_GRAPH_STATE_DIRS.some((dir) => path === dir || path.startsWith(`${dir}/`));
85
- }
86
-
87
- // The build graph file, the commit lock, and the star-prompt state file
88
- // are engine state, written only by this CLI, never by an agent — all
89
- // are excluded from every task's scope check (and from artifacts/
90
- // commits), or verify's own writes would trip the very check they're
91
- // performing. Covers SQLite's journal/WAL/SHM sidecars too.
69
+ import { composeScope } from './overrides.mjs';
70
+ import { FASTPATH_DIR } from './fastpath.mjs';
71
+ import { writeNoopFile } from './noop.mjs';
72
+ import { isEngineStatePath as isEngineStatePathShared } from './engineState.mjs';
73
+
74
+ // The build graph file, the commit lock, the star-prompt state file, and
75
+ // every build-graph-state directory (friction, overrides, intents,
76
+ // reconciled, notes, noop, fast-path) are engine state, written only by
77
+ // their own command, never by a layer's verify_command — all excluded
78
+ // from every task's scope check (and from artifacts/commits), or a
79
+ // build-graph command's own writes would trip the very check it's
80
+ // performing. `FASTPATH_DIR` isn't part of engineState.mjs's own list
81
+ // (importing it there from fastpath.mjs would cycle back here see that
82
+ // module's header), so it's passed in as this call's own extra dir.
92
83
  function isEngineStatePath(path) {
93
- return (
94
- path === DB_PATH ||
95
- path.startsWith(`${DB_PATH}-`) ||
96
- path === LOCK_PATH ||
97
- path === COMMUNITY_PATH ||
98
- isBuildGraphStatePath(path)
99
- );
84
+ return isEngineStatePathShared(path, [FASTPATH_DIR]);
100
85
  }
101
86
 
102
87
  // Runs git with an argv array and no shell, so every element of `args`
@@ -476,7 +461,7 @@ function claimForVerify(db, taskId, owner) {
476
461
  // planRecompileCommand, so it does the same here and passes the
477
462
  // resulting Map in — defaulting to an empty Map keeps every other/test
478
463
  // caller's behavior unchanged.
479
- export function verifyTask(db, taskId, owner, overrides = new Map()) {
464
+ export async function verifyTask(db, taskId, owner, overrides = new Map()) {
480
465
  const task = claimForVerify(db, taskId, owner);
481
466
 
482
467
  const scopeGlobs = JSON.parse(composeScope({ scope_globs: task.scope_globs }, taskId, overrides).scope_globs);
@@ -490,7 +475,7 @@ export function verifyTask(db, taskId, owner, overrides = new Map()) {
490
475
  // in between) can itself touch files inside this task's own scope
491
476
  // (generated lockfiles, formatted output), and committing a stale
492
477
  // pre-verify_command snapshot would silently drop those.
493
- const { offending } = withCommitLock(() => {
478
+ const { offending, inScope: inScopeBeforeVerify } = withCommitLock(() => {
494
479
  // Only what changed during this task's lease is this task's to answer
495
480
  // for; everything else in the shared working tree was already there
496
481
  // when the task was handed out. The commit set below is deliberately
@@ -517,6 +502,47 @@ export function verifyTask(db, taskId, owner, overrides = new Map()) {
517
502
  return { outcome: 'scope_violation', offending };
518
503
  }
519
504
 
505
+ // Genuine no-op: nothing this task's scope claims has changed even
506
+ // before verify_command runs — the layer's work was already satisfied
507
+ // by an earlier layer, or this intent never touches this module at all.
508
+ // verify_command has nothing to check here, so it never runs, and no
509
+ // commit is made. The completion still needs to survive `hedgehog db
510
+ // rebuild`, which recovers completion from a matching commit subject;
511
+ // with no commit to match, the committed record under NOOP_DIR is what
512
+ // rebuild replays instead (see noop.mjs, rebuild.mjs#replayNoopRecords).
513
+ if (inScopeBeforeVerify.length === 0) {
514
+ const verifiedAt = new Date().toISOString();
515
+ await writeNoopFile({ task: task.id, commit_message: task.commit_message, verified_at: verifiedAt });
516
+
517
+ let unlocked;
518
+ let completedIntent;
519
+ db.exec('BEGIN IMMEDIATE');
520
+ try {
521
+ setTaskStatus(db, task.id, 'complete');
522
+ unlocked = unlockReadyDependents(db, task.id);
523
+ completedIntent = completeIntentIfDone(db, task.intent_id);
524
+ db.exec('COMMIT');
525
+ } catch (err) {
526
+ try {
527
+ db.exec('ROLLBACK');
528
+ } catch {
529
+ // Rollback failing must not mask the original error.
530
+ }
531
+ throw err;
532
+ }
533
+
534
+ return {
535
+ outcome: 'complete',
536
+ exitCode: null,
537
+ output: null,
538
+ commitSha: null,
539
+ noop: true,
540
+ unlocked,
541
+ intentComplete: completedIntent !== null,
542
+ completedIntent: completedIntent ?? null,
543
+ };
544
+ }
545
+
520
546
  const { exitCode, output } = runVerifyCommand(task.verify_command);
521
547
 
522
548
  if (exitCode !== 0) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.3.0",
3
+ "version": "6.3.2",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }