@skyf0xx/hedgehog 6.2.17 → 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,
@@ -86,6 +86,7 @@ import {
86
86
  onHedgehogBranch,
87
87
  writeAbandonedFile,
88
88
  applyAbandonment,
89
+ loadAbandoned,
89
90
  ABANDONED_DIR,
90
91
  } from '../src/db/worktree.mjs';
91
92
  import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
@@ -662,7 +663,8 @@ ${bold('Usage')}
662
663
  npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
663
664
  npx @skyf0xx/hedgehog friction list list logged friction, oldest first
664
665
  npx @skyf0xx/hedgehog debt add <task-id> "<note>" declare debt that lands in dependent tasks' packets
665
- 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
666
668
  npx @skyf0xx/hedgehog decision add <task-id> "<note>" declare a decision that lands in dependent tasks' packets
667
669
  npx @skyf0xx/hedgehog decision list [<task-id>] list declared decisions, oldest first
668
670
  npx @skyf0xx/hedgehog db migrate bring the graph's schema up to the latest version
@@ -1557,11 +1559,21 @@ async function planCommand(args = []) {
1557
1559
  // by the same rule that got it a worktree in the first place. Without
1558
1560
  // this guard, that recursive call would try to create a second,
1559
1561
  // colliding worktree for itself instead of just compiling normally.
1562
+ // A committed `.hedgehog/abandoned/<id>.json` record outlives the
1563
+ // `intent_dependencies` reset that comes with it (issue #431) — see
1564
+ // worktree.mjs#eligibleIntents for why the abandonment record, not the
1565
+ // (re-clearable) dependency table, has to be the thing checked there.
1566
+ // Loaded unconditionally (not just under the onHedgehogBranch guard
1567
+ // below) because it also feeds excludeIntentIds just below: an abandoned
1568
+ // intent must never fall through to a plain trunk compile just because
1569
+ // eligibleIntents excluded it from the worktree path.
1570
+ const abandonedIntentIds = new Set((await loadAbandoned()).keys());
1571
+
1560
1572
  let eligible = [];
1561
1573
  if (!onHedgehogBranch()) {
1562
1574
  const eligibilityDb = openDb({ readOnly: true });
1563
1575
  try {
1564
- eligible = eligibleIntents(eligibilityDb);
1576
+ eligible = eligibleIntents(eligibilityDb, abandonedIntentIds);
1565
1577
  } finally {
1566
1578
  eligibilityDb.close();
1567
1579
  }
@@ -1572,8 +1584,12 @@ async function planCommand(args = []) {
1572
1584
  // it a worktree this run (hasWorktree true already, or the commit check
1573
1585
  // below defers it) — an eligible intent must never fall through to
1574
1586
  // compiling on trunk, or it would sit there just like any pre-feature
1575
- // intent and this feature would have done nothing for it.
1576
- const excludeIntentIds = new Set(eligible.map((i) => i.id));
1587
+ // intent and this feature would have done nothing for it. Abandoned
1588
+ // intents are unioned in for the same reason (issue #431): excluded from
1589
+ // `eligible` itself now, they'd otherwise read as "not eligible, so
1590
+ // compile normally" and get recompiled straight onto trunk instead of
1591
+ // being left alone — the abandonment record makes that impossible.
1592
+ const excludeIntentIds = new Set([...eligible.map((i) => i.id), ...abandonedIntentIds]);
1577
1593
 
1578
1594
  const worktreesCreated = [];
1579
1595
  for (const intent of eligible) {
@@ -2240,7 +2256,8 @@ async function verifyCommand(args) {
2240
2256
  process.exitCode = 1;
2241
2257
  return;
2242
2258
  }
2243
- result = verifyTask(db, taskId, owner);
2259
+ const overrides = await loadOverrides();
2260
+ result = verifyTask(db, taskId, owner, overrides);
2244
2261
  } catch (err) {
2245
2262
  console.error(`${red('Verify failed:')} ${err.message}\n`);
2246
2263
  process.exitCode = 1;
@@ -3332,7 +3349,7 @@ async function overrideCommand(args) {
3332
3349
  console.log(` ${green('added')} ${OVERRIDES_DIR}/${record.task.toLowerCase()}.json`);
3333
3350
  for (const glob of record.scope_add) console.log(` + ${glob}`);
3334
3351
  console.log(
3335
- ` ${dim(`run \`hedgehog plan --recompile\` to widen ${record.task} now, if it's already compiled`)}\n`,
3352
+ ` ${dim(`\`hedgehog verify\` picks this up immediately; run \`hedgehog plan --recompile\` to also widen ${record.task}'s stored scope_globs (task listing, drift detection) now, if it's already compiled`)}\n`,
3336
3353
  );
3337
3354
  return;
3338
3355
  }
@@ -3828,28 +3845,61 @@ async function debtCommand(args) {
3828
3845
  }
3829
3846
 
3830
3847
  if (sub === 'list') {
3831
- const taskId = args[1];
3848
+ const includeResolved = args.includes('--all') || args.includes('--resolved');
3849
+ const taskId = args.slice(1).find((a) => !a.startsWith('--'));
3832
3850
  const db = openDb();
3833
3851
  let entries;
3834
3852
  try {
3835
- entries = listDebt(db, taskId);
3853
+ entries = listDebt(db, taskId, { includeResolved });
3836
3854
  } finally {
3837
3855
  db.close();
3838
3856
  }
3839
3857
 
3840
3858
  if (entries.length === 0) {
3841
- console.log(`${dim('No debt declared.')}\n`);
3859
+ console.log(`${dim(includeResolved ? 'No debt declared.' : 'No open debt.')}\n`);
3842
3860
  return;
3843
3861
  }
3844
3862
  for (const entry of entries) {
3845
3863
  console.log(`#${entry.id} ${dim(entry.loggedAt)} ${bold(entry.taskId)}`);
3846
- 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('');
3847
3869
  }
3848
3870
  return;
3849
3871
  }
3850
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;
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)}`);
3898
+ return;
3899
+ }
3900
+
3851
3901
  console.error(
3852
- `${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`,
3853
3903
  );
3854
3904
  process.exitCode = 1;
3855
3905
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.2.17",
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/claim.mjs CHANGED
@@ -8,6 +8,14 @@
8
8
  // — by setting status and the three lease columns in the same UPDATE:
9
9
  // a task entering `building` gets an owner, and a task leaving it (to
10
10
  // ready, planned, blocked) has all three cleared.
11
+ //
12
+ // Both `findClaimableTasks` (the fan-out's candidate set) and `claimTask`
13
+ // (a targeted, named claim) additionally treat a dependency as satisfied
14
+ // when it is complete anywhere reachable from a local git ref, not only
15
+ // in this worktree's own DB — see crossBranch.mjs. This is a read-only
16
+ // widening of "is this dependency done", never a write across worktrees:
17
+ // no DB row in this file ever crosses from one worktree's
18
+ // `.hedgehog/hedgehog.db` to another's (worktree.mjs's file header).
11
19
 
12
20
  import { execFileSync } from 'node:child_process';
13
21
  import { readFileSync, readlinkSync, lstatSync } from 'node:fs';
@@ -17,6 +25,7 @@ import { inTransaction } from './init.mjs';
17
25
  import { conflicts } from './conflict.mjs';
18
26
  import { incompleteDependencies } from './next.mjs';
19
27
  import { ensureTaskColumns } from './schema.mjs';
28
+ import { buildCrossBranchIndex, commitMessageExistsAnywhere } from './crossBranch.mjs';
20
29
 
21
30
  // ── Claim-time working-tree snapshot ──────────────────────────────────
22
31
  //
@@ -154,10 +163,94 @@ const CLAIMABLE_TASKS_SQL = `
154
163
  ORDER BY t.priority, t.exclusive DESC, t.id
155
164
  `;
156
165
 
166
+ // The same shape as CLAIMABLE_TASKS_SQL's own NOT EXISTS clause, but
167
+ // naming which dependency is unsatisfied *in this DB* rather than merely
168
+ // excluding the candidate — this is the raw material
169
+ // findUnsatisfiedDependencies (below) needs to then ask crossBranch.mjs
170
+ // whether that specific dependency is satisfied elsewhere instead.
171
+ const UNSATISFIED_DEPENDENCIES_SQL = `
172
+ SELECT d.task_id AS taskId, dep.id AS dependsOnId, dep.commit_message AS commitMessage
173
+ FROM dependencies d
174
+ JOIN tasks dep ON dep.id = d.depends_on_task_id
175
+ WHERE dep.status <> 'complete'
176
+ `;
177
+
178
+ // Candidate tasks CLAIMABLE_TASKS_SQL's own NOT EXISTS would exclude
179
+ // (status planned/ready, unleased, but blocked on a dependency this DB
180
+ // still shows incomplete) that become claimable once that dependency's
181
+ // commit is credited from ANY local branch's history — see crossBranch.mjs
182
+ // for why this is exactly `git log --all` widened from the current
183
+ // branch's own `git log`, and for the documented ambiguous-commit-message
184
+ // gap this can't resolve.
185
+ //
186
+ // This is a live, read-only re-derivation: nothing here writes
187
+ // `tasks.status` in the current worktree's DB (that would be exactly the
188
+ // "no DB row crosses worktrees" invariant this feature deliberately does
189
+ // not touch — see worktree.mjs's file header). A future claim/verify pass
190
+ // in this same worktree still needs its own copy of that fact; this only
191
+ // answers "is it claimable right now".
192
+ //
193
+ // `index` is always caller-supplied (buildCrossBranchIndex shells out to
194
+ // `git log`, and this file's own `inTransaction` contract — see
195
+ // claimTasks/claimTask below — forbids running a subprocess while a sqlite
196
+ // transaction is open; findClaimableTasks builds the index before ever
197
+ // entering one).
198
+ function crossBranchClaimableCandidates(db, index) {
199
+ const candidates = db
200
+ .prepare(
201
+ `SELECT t.* FROM tasks t
202
+ WHERE t.status IN ('planned', 'ready')
203
+ AND t.lease_owner IS NULL
204
+ AND EXISTS (
205
+ SELECT 1 FROM dependencies d
206
+ JOIN tasks dep ON dep.id = d.depends_on_task_id
207
+ WHERE d.task_id = t.id AND dep.status <> 'complete'
208
+ )
209
+ ORDER BY t.priority, t.exclusive DESC, t.id`,
210
+ )
211
+ .all();
212
+ if (candidates.length === 0) return [];
213
+
214
+ const unsatisfied = db.prepare(UNSATISFIED_DEPENDENCIES_SQL).all();
215
+ const unsatisfiedByTask = new Map();
216
+ for (const row of unsatisfied) {
217
+ if (!unsatisfiedByTask.has(row.taskId)) unsatisfiedByTask.set(row.taskId, []);
218
+ unsatisfiedByTask.get(row.taskId).push(row);
219
+ }
220
+
221
+ return candidates.filter((candidate) =>
222
+ unsatisfiedByTask
223
+ .get(candidate.id)
224
+ .every((dep) => commitMessageExistsAnywhere({ commit_message: dep.commitMessage }, index)),
225
+ );
226
+ }
227
+
157
228
  // Exported for ready.mjs, which walks the identical candidate set to
158
229
  // simulate this same fan-out without claiming anything.
159
- export function findClaimableTasks(db) {
160
- return db.prepare(CLAIMABLE_TASKS_SQL).all();
230
+ //
231
+ // Merges CLAIMABLE_TASKS_SQL's own current-DB-only candidates with the
232
+ // cross-branch set above, then re-sorts to the same `priority, exclusive
233
+ // DESC, id` order the fan-out and every caller depend on — the two source
234
+ // queries are individually ordered but interleaving them requires a single
235
+ // re-sort over the union.
236
+ //
237
+ // `index` is optional: a caller outside any sqlite transaction (ready.mjs,
238
+ // status.mjs) can omit it and this builds one itself. A caller that runs
239
+ // inside `inTransaction` (claimTasks/claimTask, below) MUST build the
240
+ // index first and pass it in — building it here would shell out to `git
241
+ // log` with a `BEGIN IMMEDIATE` already open, which this codebase
242
+ // forbids (see init.mjs#inTransaction's own contract, restated in
243
+ // claimTasks's comment).
244
+ export function findClaimableTasks(db, index = buildCrossBranchIndex(db)) {
245
+ const local = db.prepare(CLAIMABLE_TASKS_SQL).all();
246
+ const crossBranch = crossBranchClaimableCandidates(db, index);
247
+ if (crossBranch.length === 0) return local;
248
+
249
+ return [...local, ...crossBranch].sort((a, b) => {
250
+ if (a.priority !== b.priority) return a.priority - b.priority;
251
+ if (a.exclusive !== b.exclusive) return b.exclusive - a.exclusive;
252
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
253
+ });
161
254
  }
162
255
 
163
256
  // Tasks another call already holds a lease on — the conflict check's other
@@ -266,6 +359,11 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
266
359
  // Taking it a moment early can only miss a path dirtied in between,
267
360
  // which leaves that path attributed — the strict direction.
268
361
  const claimSnapshot = snapshotWorkingTree();
362
+ // Same rule, same reason, for the cross-branch index: buildCrossBranchIndex
363
+ // shells out to `git log --all` (twice — see crossBranch.mjs), so it is
364
+ // built here, before BEGIN, and threaded into findClaimableTasks rather
365
+ // than left to build itself once inside the transaction below.
366
+ const crossBranchIndex = buildCrossBranchIndex(db);
269
367
 
270
368
  return inTransaction(db, () => {
271
369
  const justReaped = reapExpiredLeases(db);
@@ -275,7 +373,7 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
275
373
  return { claimed: [], blocked };
276
374
  }
277
375
 
278
- const candidates = findClaimableTasks(db);
376
+ const candidates = findClaimableTasks(db, crossBranchIndex);
279
377
  const inFlight = findInFlightTasks(db);
280
378
  const runClaim = claimOne(db);
281
379
  const claimed = [];
@@ -317,8 +415,13 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
317
415
  export function claimTask(db, taskId, { owner, leaseMinutes = 45 }) {
318
416
  ensureTaskColumns(db);
319
417
  // Same rule as claimTasks: read before BEGIN, no subprocess inside a
320
- // transaction.
418
+ // transaction. buildCrossBranchIndex shells out to `git log --all`, so
419
+ // it is built here unconditionally (cheap relative to a `git` process
420
+ // spawn either way) rather than only on the branch that turns out to
421
+ // need it, which would otherwise tempt building it lazily from inside
422
+ // the transaction below.
321
423
  const claimSnapshot = snapshotWorkingTree();
424
+ const crossBranchIndex = buildCrossBranchIndex(db);
322
425
 
323
426
  return inTransaction(db, () => {
324
427
  reapExpiredLeases(db);
@@ -330,7 +433,15 @@ export function claimTask(db, taskId, { owner, leaseMinutes = 45 }) {
330
433
  return { claimed: false, reason: 'not_claimable', task };
331
434
  }
332
435
 
333
- const incomplete = incompleteDependencies(db, taskId);
436
+ // Same cross-branch widening findClaimableTasks's fan-out applies: a
437
+ // dependency this DB still shows incomplete may already be complete on
438
+ // trunk or a sibling worktree's branch. Checked here too so a targeted
439
+ // `hedgehog claim <task-id>` doesn't refuse a task the fan-out would
440
+ // have happily claimed.
441
+ const stillIncomplete = incompleteDependencies(db, taskId);
442
+ const incomplete = stillIncomplete.filter(
443
+ (dep) => !commitMessageExistsAnywhere(dep, crossBranchIndex),
444
+ );
334
445
  if (incomplete.length > 0) {
335
446
  return { claimed: false, reason: 'incomplete_dependencies', task, incomplete };
336
447
  }
@@ -0,0 +1,186 @@
1
+ // Cross-branch commit-history visibility for `hedgehog ready`/`claim`/
2
+ // `status`/`reconcile`, run live inside a `git worktree` checkout — where
3
+ // `hedgehog db rebuild` (rebuild.mjs#loadCommitSubjects) intentionally
4
+ // only ever reads the CURRENT branch's own `git log`.
5
+ //
6
+ // The gap this closes: each worktree carries its own `.hedgehog/hedgehog.db`
7
+ // (worktree.mjs's file header), and no DB row is ever copied between them
8
+ // or from a worktree to trunk — that invariant is the reason the build
9
+ // graph is a pure function of committed files plus git history in the
10
+ // first place. But "current git history" was, until now, silently read as
11
+ // "the current branch's history" everywhere a live command checked whether
12
+ // a dependency was done: claim.mjs's CLAIMABLE_TASKS_SQL joins only the
13
+ // current DB's own `tasks.status`, and reconcile.mjs's `commitsSince`
14
+ // ranges over `HEAD` alone. A task completed and committed on trunk (or on
15
+ // a sibling worktree's branch) is therefore invisible to a different
16
+ // worktree's own DB even though the *commit* — the actual source of truth
17
+ // — is sitting right there in the same repository's object store, reachable
18
+ // from a local ref this checkout can already see.
19
+ //
20
+ // This module answers the same question rebuild.mjs#loadCommitSubjects
21
+ // answers for the current branch (did a commit with this exact subject
22
+ // ever happen), widened to `git log --all` — every local branch's
23
+ // reachable history, which covers trunk and every open worktree's branch,
24
+ // since a worktree's branch is still a local ref of the same checkout.
25
+ // `--all` is exactly the right and only widening: it costs nothing beyond
26
+ // one extra `git log` flag, needs no new git remotes or fetches, and a
27
+ // project that has never used worktrees has exactly one branch with any
28
+ // commits — `git log --all` degenerates to `git log` for it, so this
29
+ // function is a strict no-op there (acceptance criterion: zero behavior
30
+ // change for a project that never opened a worktree).
31
+ //
32
+ // ── the ambiguous-commit-message case: a documented, deliberate gap ────
33
+ //
34
+ // rebuild.mjs#markCompletedTasks resolves a commit_message shared by more
35
+ // than one task (the linear-chain core case — authored/adopted, no
36
+ // `{module}` axis) via an ordering+consumption fixpoint walk over
37
+ // `dependencies` rows, mutating `tasks.status` directly as it goes. That
38
+ // walk is inseparable from the DB write path it drives (UPDATE statements
39
+ // interleaved with the fixpoint, `reconciledTaskIds` seeded in up front,
40
+ // re-open-on-failed-claim at the end) and rebuild.mjs is explicitly out of
41
+ // scope for this change — re-deriving its `positionOf`/`available`/
42
+ // `ranAfterPrerequisites` machinery here as a read-only, no-mutation
43
+ // sibling would either (a) silently drift from the real algorithm the
44
+ // first time either copy changes, which is exactly the "three divergent
45
+ // implementations" this file exists to avoid, or (b) require touching
46
+ // rebuild.mjs to extract a shared core, which the task constraints forbid.
47
+ //
48
+ // So: a task whose `commit_message` is NOT unique across the whole graph
49
+ // (the ambiguous / linear-chain case) is left OUT of `subjectsAllBranches`
50
+ // entirely by this module — see `buildCrossBranchIndex`'s filtering below.
51
+ // Every caller (claim.mjs, ready.mjs indirectly, status.mjs, reconcile.mjs)
52
+ // then treats "not present in the index" as "can't determine — leave
53
+ // blocked", the safe direction: a cross-worktree dependency this module
54
+ // can't resolve stays exactly as blocked as it already was, never
55
+ // incorrectly unblocked. The module-axis case — the overwhelmingly common
56
+ // one, since it's what `full-stack-app`/`pwa-app`/`landing-page` compile —
57
+ // has a `commit_message` unique to each task by construction (the layer's
58
+ // `commit` template interpolates `{module}`) and is fully solved.
59
+ import { execSync } from 'node:child_process';
60
+
61
+ // Memoized per `git log --all` call: a Map from commit subject to the
62
+ // number of times it occurs, newest-history-order irrelevant here (unlike
63
+ // rebuild.mjs's positional Map, this module never needs to order two
64
+ // candidate commits against each other, since it only ever answers for the
65
+ // unambiguous case where any one occurrence is as good as any other).
66
+ // Re-running `git log --all` per call rather than caching across calls:
67
+ // every entry point here (claim, ready, status, reconcile) is invoked at
68
+ // most once per CLI process, so there is no repeated-call cost to amortize
69
+ // within a run, and caching across separate `hedgehog` invocations would
70
+ // risk serving a stale answer to a long-lived process (the graph server)
71
+ // after a sibling worktree commits.
72
+ function subjectCountsFrom(gitArgs) {
73
+ let output;
74
+ try {
75
+ output = execSync(`git log ${gitArgs} --topo-order --format=%H%x00%s`, { encoding: 'utf8' });
76
+ } catch {
77
+ // No commits reachable from the requested ref set yet (a brand-new
78
+ // repo, or — for the current-branch-only call — a worktree whose
79
+ // branch predates its very first commit). Every subject is absent,
80
+ // which is the correct, honest answer.
81
+ return new Map();
82
+ }
83
+ const counts = new Map();
84
+ for (const line of output.split('\n')) {
85
+ if (!line) continue;
86
+ const [, subject] = line.split('\0');
87
+ if (subject === undefined) continue;
88
+ counts.set(subject, (counts.get(subject) ?? 0) + 1);
89
+ }
90
+ return counts;
91
+ }
92
+
93
+ function loadAllBranchesSubjectCounts() {
94
+ return subjectCountsFrom('--all');
95
+ }
96
+
97
+ // The current branch's own subject counts — i.e. exactly what
98
+ // rebuild.mjs#loadCommitSubjects would see (membership only; this module
99
+ // never needs commit position, only "did this subject occur here at
100
+ // all"). Used by buildCrossBranchIndex below to detect the one case
101
+ // cross-branch crediting must refuse: a task reopened via the Correction
102
+ // Protocol (claim.mjs#reopenTask) after an EARLIER commit with the same
103
+ // subject already landed on this very branch. That earlier commit is
104
+ // exactly as visible via `--all` as any genuine cross-branch completion —
105
+ // nothing about the subject string distinguishes "this is a fresh
106
+ // completion on another branch" from "this is the stale completion this
107
+ // branch itself already reset". A task whose current DB status is not
108
+ // `complete` but whose commit_message already appears on THIS branch is
109
+ // therefore excluded from resolvableMessages entirely: crediting it would
110
+ // resurrect a completion this worktree's own history has already
111
+ // superseded, which is worse than the safe "leave it blocked, the
112
+ // dependency isn't obviously satisfied" default this whole module commits
113
+ // to elsewhere.
114
+ function loadCurrentBranchSubjectCounts() {
115
+ return subjectCountsFrom('');
116
+ }
117
+
118
+ // Builds the set of commit_messages this module can safely credit as
119
+ // "happened somewhere in this repository's visible history" — i.e. every
120
+ // subject that occurs in `git log --all` AND belongs, in `db`, to exactly
121
+ // one task. A subject occurring for two distinct reasons (two different
122
+ // tasks in this graph legitimately share a commit_message — the ambiguous
123
+ // linear-chain case) is deliberately excluded even though it does appear
124
+ // in history, per the module-header comment above: this function cannot
125
+ // tell which of the sharing tasks a given commit actually credits, and
126
+ // guessing is worse than leaving both blocked.
127
+ //
128
+ // `db` is read only for `tasks.commit_message` grouping — never written.
129
+ export function buildCrossBranchIndex(db) {
130
+ const tasks = db.prepare('SELECT id, commit_message, status FROM tasks').all();
131
+ const messageCounts = new Map();
132
+ const statusByMessage = new Map();
133
+ for (const task of tasks) {
134
+ messageCounts.set(task.commit_message, (messageCounts.get(task.commit_message) ?? 0) + 1);
135
+ statusByMessage.set(task.commit_message, task.status);
136
+ }
137
+
138
+ const subjectCounts = loadAllBranchesSubjectCounts();
139
+ const currentBranchCounts = loadCurrentBranchSubjectCounts();
140
+ const resolvableMessages = new Set();
141
+ for (const [message, taskCount] of messageCounts) {
142
+ if (taskCount !== 1) continue; // ambiguous in THIS graph — see header comment
143
+ const total = subjectCounts.get(message) ?? 0;
144
+ if (total === 0) continue; // not seen on any branch
145
+ const onThisBranch = currentBranchCounts.get(message) ?? 0;
146
+ // Reopen guard (see loadCurrentBranchSubjectCounts): if every
147
+ // occurrence of this subject anywhere (`total`) is already accounted
148
+ // for on THIS branch (`onThisBranch >= total`) and the task is still
149
+ // not `complete` here, there is no occurrence anywhere else to credit
150
+ // — the one this branch already has is exactly the completion a
151
+ // Correction Protocol reopen (claim.mjs#reopenTask) superseded, and
152
+ // `--all` has nothing further to offer. Strictly fewer occurrences on
153
+ // this branch than `total` means at least one occurrence exists on
154
+ // SOME other branch this worktree cannot otherwise see, which is
155
+ // exactly the genuine cross-branch completion this module exists to
156
+ // surface — trusted even when this branch also independently carries
157
+ // its own earlier, now-superseded copy of the same subject.
158
+ if (onThisBranch >= total && statusByMessage.get(message) !== 'complete') {
159
+ continue;
160
+ }
161
+ resolvableMessages.add(message);
162
+ }
163
+
164
+ return { resolvableMessages };
165
+ }
166
+
167
+ // Returns true if `task` (a `tasks` row carrying `commit_message`, and
168
+ // unique in `db` for that message) can be credited complete from
169
+ // cross-branch history, per the index built above. Callers pass the same
170
+ // `index` across many tasks in one call rather than rebuilding it per
171
+ // task — one `git log --all` per CLI invocation, mirroring
172
+ // rebuild.mjs#loadCommitSubjects's own one-call-per-run shape.
173
+ export function commitMessageExistsAnywhere(task, index) {
174
+ return index.resolvableMessages.has(task.commit_message);
175
+ }
176
+
177
+ // Convenience for a caller that only has a raw commit_message string (no
178
+ // task row) — reconcile.mjs's evidence path doesn't need the
179
+ // per-task-uniqueness guard, since it isn't resolving a *dependency*'s
180
+ // status, only widening the commit window it reads from. Kept separate
181
+ // from commitMessageExistsAnywhere (which is intentionally conservative
182
+ // about ambiguity) so this narrower use doesn't inherit a guard it has no
183
+ // use for.
184
+ export function anyCommitAnywhereWithSubject(subject) {
185
+ return (loadAllBranchesSubjectCounts().get(subject) ?? 0) > 0;
186
+ }
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
 
@@ -311,7 +328,7 @@ function loadCommitSubjects() {
311
328
  // and applying either check there would cascade that gap through the
312
329
  // whole chain and reset already-built modules that have no ambiguity to
313
330
  // resolve in the first place.
314
- function markCompletedTasks(db, commitSubjects) {
331
+ function markCompletedTasks(db, commitSubjects, reconciledTaskIds = new Set()) {
315
332
  const tasks = db.prepare('SELECT id, module, commit_message FROM tasks').all();
316
333
  const prerequisites = new Map(tasks.map((t) => [t.id, []]));
317
334
  for (const d of db.prepare('SELECT task_id, depends_on_task_id FROM dependencies').all()) {
@@ -329,7 +346,16 @@ function markCompletedTasks(db, commitSubjects) {
329
346
  // resolved separately below, since "the" matching commit for its
330
347
  // subject isn't decided until a claim succeeds.
331
348
  const positionOf = new Map();
332
- const complete = new Set();
349
+ // Seeded with every reconciled task up front, before the ambiguous-task
350
+ // fixpoint walk below ever runs: a reconciled task was closed precisely
351
+ // because it has no commit of its own to match in commitSubjects (a
352
+ // hand-written commit predating the reconciliation, or none at all), so
353
+ // without this seed it can never enter `complete` from inside this
354
+ // function — leaving every task that depends on it, directly or
355
+ // transitively, stuck `planned` forever, since replayReconciliations
356
+ // (the only other path that would mark it complete) runs after this
357
+ // function returns.
358
+ const complete = new Set(reconciledTaskIds);
333
359
  const ambiguousTasks = [];
334
360
  for (const task of tasks) {
335
361
  if (task.module === CORE_MODULE || isAmbiguous(task)) {
@@ -552,7 +578,18 @@ export async function rebuildDb(
552
578
  planTasks(db, core, overrides, { excludeIntentIds: openWorktreeIntentIds });
553
579
 
554
580
  const commitSubjects = loadCommitSubjects();
555
- const tasksMarkedComplete = markCompletedTasks(db, commitSubjects);
581
+ // Reconciled task ids are seeded into markCompletedTasks's own `complete`
582
+ // set before its ambiguous-task fixpoint walk runs, not after: a
583
+ // reconciled task's whole reason for being in .hedgehog/reconciled/ is
584
+ // that it has no commit of its own in commitSubjects to match, so
585
+ // without this it can never satisfy `every prerequisite is complete` for
586
+ // whatever depends on it — see markCompletedTasks's own comment on
587
+ // `reconciledTaskIds` for the full mechanics. replayReconciliations
588
+ // (below) still runs afterward to write the provenance note and cover
589
+ // any reconciled task markCompletedTasks doesn't touch (module = CORE_MODULE
590
+ // edge cases aside, every reconciled id ends up here either way).
591
+ const reconciledTaskIds = new Set(reconciliations.keys());
592
+ const tasksMarkedComplete = markCompletedTasks(db, commitSubjects, reconciledTaskIds);
556
593
 
557
594
  const tasksReconciled = replayReconciliations(db, reconciliations);
558
595
  const orphanedReconciled = orphanedReconciliations(db, reconciliations);
@@ -15,6 +15,16 @@
15
15
  // — which every loop skill forbids, because the graph is derived and
16
16
  // gitignored and the patch dies at the next rebuild.
17
17
  //
18
+ // `commitsSince`'s scan window is `git log --all`, not just the current
19
+ // branch's `HEAD` — a hand-written commit that satisfies an open task's
20
+ // scope can just as well sit on trunk or a sibling worktree's branch as on
21
+ // this checkout's own branch, and `gatherEvidence` has to be able to
22
+ // propose reconciling that task from whichever worktree happens to run
23
+ // `hedgehog reconcile`, not only from the one that made the commit. See
24
+ // `commitsSince` below for why the *start* of that window
25
+ // (`newestGraphCommit`) stays scoped to the current branch's own history
26
+ // rather than widening the same way.
27
+ //
18
28
  // Four properties, each load-bearing:
19
29
  //
20
30
  // - **It proposes; it never asserts.** `gatherEvidence` reports which
@@ -179,6 +189,19 @@ export function orphanedReconciliations(db, reconciliations) {
179
189
  // Returns null when no commit matches any task's message (nothing has
180
190
  // been verified yet) — the caller then reads the whole history, which is
181
191
  // the honest window for a project whose loop has not closed a task.
192
+ //
193
+ // Scoped to the current branch's own `git log` (not `--all`), deliberately
194
+ // unlike `commitsSince` below: this names the boundary of THIS worktree's
195
+ // own graph-written history — the newest commit that credits some task as
196
+ // far as this checkout's own branch has progressed — and that is a fact
197
+ // about this branch specifically, not about the repository as a whole. A
198
+ // sibling branch can be ahead or behind this one in ways that have nothing
199
+ // to do with where this worktree's own evidence window should start; using
200
+ // `--all` here would let a commit on an unrelated, unmerged branch move
201
+ // this worktree's own "since" boundary out from under it. Only the
202
+ // forward-scanning window (commitsSince) needs the wider net, to catch a
203
+ // hand-written commit that landed elsewhere; the floor it scans from stays
204
+ // anchored to what this branch itself has already credited.
182
205
  function newestGraphCommit(db) {
183
206
  const messages = new Set(
184
207
  db.prepare('SELECT commit_message FROM tasks').all().map((r) => r.commit_message),
@@ -196,13 +219,30 @@ function newestGraphCommit(db) {
196
219
 
197
220
  // Every commit after `sinceSha` (exclusive), newest first, with the paths
198
221
  // it touched. `sinceSha` null means the whole history.
222
+ //
223
+ // The window's floor (`sinceSha`, from `newestGraphCommit` above) is
224
+ // deliberately still scoped to whatever the caller resolved it against —
225
+ // it names a specific commit, and `<sha>..` is unambiguous regardless of
226
+ // which branch's `git log` produced that sha. It is only the window's
227
+ // *ceiling* that widens here: `--all` in place of the bare `HEAD` ref,
228
+ // so the scan reaches every commit on every local branch newer than
229
+ // `sinceSha`, not only the ones that happen to be reachable from this
230
+ // worktree's own checked-out branch. A hand-written commit sitting on
231
+ // trunk while this runs inside an intent's own worktree (or vice versa)
232
+ // is exactly the case `--all` exists to reach — without it, `hedgehog
233
+ // reconcile` run from worktree B can never see a commit that only landed
234
+ // on worktree A's branch or on trunk, which is the same blind spot
235
+ // `commitMessageExistsAnywhere` (crossBranch.mjs) exists to close for
236
+ // `claim`/`ready`/`status`. A project that has never opened a worktree has
237
+ // exactly one branch with any commits, so `--all` and `HEAD` name the
238
+ // same set there and this is a no-op for it.
199
239
  function commitsSince(sinceSha) {
200
- const range = sinceSha ? [`${sinceSha}..HEAD`] : ['HEAD'];
240
+ const range = sinceSha ? [`${sinceSha}..`, '--all'] : ['--all'];
201
241
  let output;
202
242
  try {
203
243
  output = git(['log', '--topo-order', '--name-only', '--format=%x01%H%x00%s', ...range]);
204
244
  } catch {
205
- // An empty repository has no HEAD to log.
245
+ // An empty repository has no ref to log at all.
206
246
  return [];
207
247
  }
208
248
 
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/status.mjs CHANGED
@@ -19,6 +19,7 @@ import { RECONCILED_DIR, RECONCILED_NOTE_PREFIX } from './reconcile.mjs';
19
19
  import { formatMissingRequirements } from './requires.mjs';
20
20
  import { readyTasks, heldBackReason } from './ready.mjs';
21
21
  import { worktreeStatus } from './worktree.mjs';
22
+ import { findClaimableTasks } from './claim.mjs';
22
23
 
23
24
  // The task lifecycle in order, matching the tasks CHECK constraint in
24
25
  // schema.mjs exactly — every status the engine can write, and no others.
@@ -35,17 +36,16 @@ const TASK_STATUSES = [
35
36
  // `exclusive DESC` matches claim.mjs's CLAIMABLE_TASKS_SQL and next.mjs's
36
37
  // READY_TASK_SQL, so this list is in the order those two would actually
37
38
  // take the work — see claim.mjs for why exclusive sorts first.
38
- const READY_TASKS_SQL = `
39
- SELECT t.* FROM tasks t
40
- WHERE t.status IN ('planned', 'ready')
41
- AND t.lease_owner IS NULL
42
- AND NOT EXISTS (
43
- SELECT 1 FROM dependencies d
44
- JOIN tasks dep ON dep.id = d.depends_on_task_id
45
- WHERE d.task_id = t.id AND dep.status <> 'complete'
46
- )
47
- ORDER BY t.priority, t.exclusive DESC, t.id;
48
- `;
39
+ //
40
+ // Reuses claim.mjs#findClaimableTasks rather than a duplicate SQL string —
41
+ // this used to run its own local-DB-only query, which meant `hedgehog
42
+ // status` and `hedgehog claim` could disagree about which tasks are ready
43
+ // the moment a dependency's completing commit exists only cross-branch
44
+ // (crossBranch.mjs): a task the fan-out would happily claim would still
45
+ // show as blocked here. findClaimableTasks already returns exactly the
46
+ // same candidate set `hedgehog claim`'s fan-out and `hedgehog ready` use,
47
+ // merged and re-sorted, so this file no longer needs its own copy of the
48
+ // query.
49
49
 
50
50
  const IN_FLIGHT_TASKS_SQL = `
51
51
  SELECT t.* FROM tasks t
@@ -88,7 +88,7 @@ function countTasksByStatus(db) {
88
88
  }
89
89
 
90
90
  function loadReadyTasks(db) {
91
- return db.prepare(READY_TASKS_SQL).all();
91
+ return findClaimableTasks(db);
92
92
  }
93
93
 
94
94
  // Tasks that need a human/agent decision before the graph can move again
package/src/db/verify.mjs CHANGED
@@ -62,7 +62,7 @@ import { withCommitLock, LOCK_PATH } from './commitLock.mjs';
62
62
  import { reapExpiredLeases, pathFingerprint } from './claim.mjs';
63
63
  import { ensureTaskColumns } from './schema.mjs';
64
64
  import { FRICTION_DIR } from './friction.mjs';
65
- import { OVERRIDES_DIR } from './overrides.mjs';
65
+ import { OVERRIDES_DIR, composeScope } from './overrides.mjs';
66
66
  import { RECONCILED_DIR } from './reconcile.mjs';
67
67
  import { INTENTS_DIR } from './intent.mjs';
68
68
  import { COMMUNITY_PATH } from './community.mjs';
@@ -463,10 +463,23 @@ function claimForVerify(db, taskId, owner) {
463
463
  // `completedIntent` is the intent row (id/goal/outcome) when this task
464
464
  // was the last one of its intent, else null — the CLI prints it back as
465
465
  // an INTENT CHECK.
466
- export function verifyTask(db, taskId, owner) {
466
+ //
467
+ // `overrides` (a Map from loadOverrides(), same shape recompileTasks and
468
+ // detectDrift already take from their own callers) composes live into
469
+ // this task's scope for both gates below, rather than trusting
470
+ // task.scope_globs alone — that DB column is only ever widened by a
471
+ // `hedgehog plan --recompile` the caller may not have run yet, so an
472
+ // override written after claim but before recompile would otherwise
473
+ // flag its own newly-allowed paths as scope violations. verifyTask
474
+ // itself stays synchronous (loadOverrides is async, reading a
475
+ // directory); the CLI already awaits loadOverrides() for
476
+ // planRecompileCommand, so it does the same here and passes the
477
+ // resulting Map in — defaulting to an empty Map keeps every other/test
478
+ // caller's behavior unchanged.
479
+ export function verifyTask(db, taskId, owner, overrides = new Map()) {
467
480
  const task = claimForVerify(db, taskId, owner);
468
481
 
469
- const scopeGlobs = JSON.parse(task.scope_globs);
482
+ const scopeGlobs = JSON.parse(composeScope({ scope_globs: task.scope_globs }, taskId, overrides).scope_globs);
470
483
  // Gate 1 runs inside the commit lock: the diff has to see a working
471
484
  // tree no other task's commit is landing into mid-read, and the
472
485
  // neighbor-scope split has to be computed against a snapshot, not a
@@ -11,6 +11,17 @@
11
11
  // re-derives trunk's graph from what merged. No DB row ever crosses from
12
12
  // one worktree's `.hedgehog/hedgehog.db` to another's, or to trunk's.
13
13
  //
14
+ // That invariant is about DB rows, not about what a live command may read
15
+ // from git itself: `hedgehog ready`/`claim`/`status` (claim.mjs) and
16
+ // `hedgehog reconcile` (reconcile.mjs) additionally check whether a
17
+ // dependency's commit exists anywhere reachable from a local ref
18
+ // (crossBranch.mjs's `git log --all`), not only on this worktree's own
19
+ // checked-out branch — so a task completed and committed on trunk or on a
20
+ // sibling worktree's branch is reflected as complete here without any DB
21
+ // row ever having moved. The two facts are independent: this widens what
22
+ // a query is willing to read from git history, never what a write copies
23
+ // between databases.
24
+ //
14
25
  // Trigger: task ids are `<intent>-<layer>` (plan.mjs's taskId), so the
15
26
  // intent is the natural partition — every task at a layer belongs to
16
27
  // exactly one intent, and a layer-boundary trigger would instead fan the
@@ -113,7 +124,21 @@ export function onHedgehogBranch({ repoRoot = process.cwd() } = {}) {
113
124
  // `--depends-on` on `hedgehog intent add` never trips this path, full
114
125
  // stop. Ordered by priority, id so a repeat `hedgehog plan` run always
115
126
  // considers the same intents in the same order.
116
- export function eligibleIntents(db) {
127
+ //
128
+ // `abandonedIntentIds` excludes an intent with a committed
129
+ // `.hedgehog/abandoned/<id>.json` record (issue #431): applyAbandonment
130
+ // resets an abandoned intent to `status = 'planned'` by design —
131
+ // abandonment is a separate committed fact, not a status value — but that
132
+ // reset also clears its `intent_dependencies` rows (clearIntentDependencies
133
+ // in this file), and a later `hedgehog intent add` re-declaring the same
134
+ // `--depends-on` (or a rebuild replaying intent files back onto trunk)
135
+ // repopulates that table. Once the now-again-declared dependency
136
+ // completes, this function's own readiness rule alone would read the
137
+ // abandoned intent as eligible again and hand it a fresh worktree — the
138
+ // committed abandonment record is the one place that fact survives, so
139
+ // checking it here is what stops the recompile-and-offer cycle from
140
+ // repeating on every subsequent `hedgehog plan`.
141
+ export function eligibleIntents(db, abandonedIntentIds = new Set()) {
117
142
  const intents = db
118
143
  .prepare(
119
144
  `SELECT * FROM intents WHERE status IN ('proposed','planned') AND id <> '_core'
@@ -131,6 +156,7 @@ export function eligibleIntents(db) {
131
156
  );
132
157
 
133
158
  return intents.filter((intent) => {
159
+ if (abandonedIntentIds.has(intent.id)) return false;
134
160
  const deps = dependsOnByIntent.get(intent.id);
135
161
  if (!deps || deps.length === 0) return false;
136
162
  return deps.every((depId) => statusById.get(depId) === 'complete');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.2.17",
3
+ "version": "6.3.1",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: code-comment-discipline
3
+ description: Apply whenever writing or editing source code in any core's build agent (backend-eng, ui-builder, or equivalent). Governs when a comment belongs at all and what it may say. Default is no comment; a comment is only for the non-obvious. Never restates what the code does, never narrates the task, fix, or conversation that produced it.
4
+ ---
5
+
6
+ # Code Comment Discipline
7
+
8
+ Well-named identifiers and clear structure explain what code does. A
9
+ comment earns its place only when it explains something the code cannot:
10
+ a hidden constraint, a non-obvious invariant, a workaround for a specific
11
+ external bug, or behavior that would surprise a reader.
12
+
13
+ ## Default: no comment
14
+
15
+ Most lines, functions, and blocks need zero comments. Before writing one,
16
+ check whether the same clarity is reachable by renaming a variable or
17
+ function instead — prefer that over a comment every time.
18
+
19
+ ## When a comment is allowed
20
+
21
+ Only for the non-obvious:
22
+
23
+ - A constraint imposed from outside the code (an API's undocumented
24
+ limit, a browser quirk, a platform requirement) that isn't visible at
25
+ the call site.
26
+ - An invariant the code relies on that isn't implied by types or names
27
+ (e.g. "callers must hold the lock before this runs").
28
+ - A deliberate workaround for a specific bug in a dependency, with enough
29
+ detail to know when it's safe to remove.
30
+ - Behavior that looks like a mistake but is intentional.
31
+
32
+ ## What a comment must never say
33
+
34
+ - What the code does — that's the identifier's job, not the comment's.
35
+ - Why *this* task needed the change, who asked for it, or which ticket,
36
+ issue, or conversation prompted it.
37
+ - History: "used to be X," "changed from Y," "removed Z," "previously,"
38
+ "now we," or any other before/after narration. A comment states the
39
+ current state and its non-obvious reason only, never how it got there.
40
+ - A restatement of the function/variable name in prose ("increments the
41
+ counter" above `counter++`).
42
+
43
+ ## Applying to existing comments
44
+
45
+ When editing a file that already has comments violating these rules,
46
+ remove or rewrite them as part of the same change rather than leaving
47
+ them in place — don't let a nearby edit normalize the pattern.