@skyf0xx/hedgehog 6.3.0 → 6.3.1

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,
@@ -663,7 +663,8 @@ ${bold('Usage')}
663
663
  npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
664
664
  npx @skyf0xx/hedgehog friction list list logged friction, oldest first
665
665
  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
666
+ npx @skyf0xx/hedgehog debt list [<task-id>] [--all] list open debt, oldest first (--all includes resolved)
667
+ npx @skyf0xx/hedgehog debt resolve <debt-id> --reason "<why>" mark a debt note resolved
667
668
  npx @skyf0xx/hedgehog decision add <task-id> "<note>" declare a decision that lands in dependent tasks' packets
668
669
  npx @skyf0xx/hedgehog decision list [<task-id>] list declared decisions, oldest first
669
670
  npx @skyf0xx/hedgehog db migrate bring the graph's schema up to the latest version
@@ -3844,28 +3845,61 @@ async function debtCommand(args) {
3844
3845
  }
3845
3846
 
3846
3847
  if (sub === 'list') {
3847
- const taskId = args[1];
3848
+ const includeResolved = args.includes('--all') || args.includes('--resolved');
3849
+ const taskId = args.slice(1).find((a) => !a.startsWith('--'));
3848
3850
  const db = openDb();
3849
3851
  let entries;
3850
3852
  try {
3851
- entries = listDebt(db, taskId);
3853
+ entries = listDebt(db, taskId, { includeResolved });
3852
3854
  } finally {
3853
3855
  db.close();
3854
3856
  }
3855
3857
 
3856
3858
  if (entries.length === 0) {
3857
- console.log(`${dim('No debt declared.')}\n`);
3859
+ console.log(`${dim(includeResolved ? 'No debt declared.' : 'No open debt.')}\n`);
3858
3860
  return;
3859
3861
  }
3860
3862
  for (const entry of entries) {
3861
3863
  console.log(`#${entry.id} ${dim(entry.loggedAt)} ${bold(entry.taskId)}`);
3862
- console.log(` ${entry.note}\n`);
3864
+ console.log(` ${entry.note}`);
3865
+ if (entry.resolvedAt) {
3866
+ console.log(` ${green('resolved')} ${dim(entry.resolvedAt)} — ${entry.resolvedReason}`);
3867
+ }
3868
+ console.log('');
3869
+ }
3870
+ return;
3871
+ }
3872
+
3873
+ if (sub === 'resolve') {
3874
+ const debtId = args[1];
3875
+ const reasonIdx = args.indexOf('--reason');
3876
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
3877
+
3878
+ if (!debtId || debtId.startsWith('--') || !reason) {
3879
+ console.error(`${red('Usage:')} hedgehog debt resolve <debt-id> --reason "<why>"\n`);
3880
+ process.exitCode = 1;
3881
+ return;
3863
3882
  }
3883
+
3884
+ const db = openDb();
3885
+ let result;
3886
+ try {
3887
+ result = await resolveDebt(db, { debtId: Number(debtId), reason });
3888
+ } catch (err) {
3889
+ console.error(`${red('Failed to resolve debt:')} ${err.message}\n`);
3890
+ process.exitCode = 1;
3891
+ return;
3892
+ } finally {
3893
+ db.close();
3894
+ }
3895
+
3896
+ console.log(` ${green('resolved')} #${result.id} ${bold(result.taskId)}`);
3897
+ console.log(` ${dim(result.note)}`);
3864
3898
  return;
3865
3899
  }
3866
3900
 
3867
3901
  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`,
3902
+ `${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
3903
  );
3870
3904
  process.exitCode = 1;
3871
3905
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.3.0",
3
+ "version": "6.3.1",
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
+ }
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) {
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 });
@@ -110,23 +110,40 @@ function replayNotes(db, notesByTask) {
110
110
  const insertDecision = db.prepare(
111
111
  'INSERT INTO decisions (task_id, note, logged_at) VALUES (?, ?, ?)',
112
112
  );
113
+ const resolveDebtRow = db.prepare(
114
+ 'UPDATE debt SET resolved_at = ?, resolved_reason = ? WHERE task_id = ? AND logged_at = ? AND resolved_at IS NULL',
115
+ );
113
116
 
114
117
  const orphaned = [];
118
+ // Two passes: every `debt`/`decision` entry inserted first, then every
119
+ // `debt-resolve` entry applied — a resolve entry can appear anywhere
120
+ // after its debt entry in the same file, but the row it references must
121
+ // already exist for the UPDATE to find it.
115
122
  for (const [taskId, notes] of notesByTask) {
116
123
  if (taskExists.get(taskId) === undefined) {
117
124
  for (const entry of notes) {
118
- orphaned.push({ kind: entry.kind, taskId, note: entry.note });
125
+ if (entry.kind !== 'debt-resolve') orphaned.push({ kind: entry.kind, taskId, note: entry.note });
119
126
  }
120
127
  continue;
121
128
  }
122
129
  for (const entry of notes) {
123
130
  if (entry.kind === 'debt') {
124
131
  insertDebt.run(taskId, entry.note, entry.logged_at);
125
- } else {
132
+ } else if (entry.kind === 'decision') {
126
133
  insertDecision.run(taskId, entry.note, entry.logged_at);
127
134
  }
128
135
  }
129
136
  }
137
+ for (const [taskId, notes] of notesByTask) {
138
+ if (taskExists.get(taskId) === undefined) continue;
139
+ for (const entry of notes) {
140
+ if (entry.kind !== 'debt-resolve') continue;
141
+ const result = resolveDebtRow.run(entry.logged_at, entry.reason, taskId, entry.resolves);
142
+ if (result.changes === 0) {
143
+ orphaned.push({ kind: entry.kind, taskId, note: `resolve for ${entry.resolves}` });
144
+ }
145
+ }
146
+ }
130
147
  return orphaned;
131
148
  }
132
149
 
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,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.3.0",
3
+ "version": "6.3.1",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }