@skyf0xx/hedgehog 6.2.17 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -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';
@@ -1557,11 +1558,21 @@ async function planCommand(args = []) {
1557
1558
  // by the same rule that got it a worktree in the first place. Without
1558
1559
  // this guard, that recursive call would try to create a second,
1559
1560
  // colliding worktree for itself instead of just compiling normally.
1561
+ // A committed `.hedgehog/abandoned/<id>.json` record outlives the
1562
+ // `intent_dependencies` reset that comes with it (issue #431) — see
1563
+ // worktree.mjs#eligibleIntents for why the abandonment record, not the
1564
+ // (re-clearable) dependency table, has to be the thing checked there.
1565
+ // Loaded unconditionally (not just under the onHedgehogBranch guard
1566
+ // below) because it also feeds excludeIntentIds just below: an abandoned
1567
+ // intent must never fall through to a plain trunk compile just because
1568
+ // eligibleIntents excluded it from the worktree path.
1569
+ const abandonedIntentIds = new Set((await loadAbandoned()).keys());
1570
+
1560
1571
  let eligible = [];
1561
1572
  if (!onHedgehogBranch()) {
1562
1573
  const eligibilityDb = openDb({ readOnly: true });
1563
1574
  try {
1564
- eligible = eligibleIntents(eligibilityDb);
1575
+ eligible = eligibleIntents(eligibilityDb, abandonedIntentIds);
1565
1576
  } finally {
1566
1577
  eligibilityDb.close();
1567
1578
  }
@@ -1572,8 +1583,12 @@ async function planCommand(args = []) {
1572
1583
  // it a worktree this run (hasWorktree true already, or the commit check
1573
1584
  // below defers it) — an eligible intent must never fall through to
1574
1585
  // 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));
1586
+ // intent and this feature would have done nothing for it. Abandoned
1587
+ // intents are unioned in for the same reason (issue #431): excluded from
1588
+ // `eligible` itself now, they'd otherwise read as "not eligible, so
1589
+ // compile normally" and get recompiled straight onto trunk instead of
1590
+ // being left alone — the abandonment record makes that impossible.
1591
+ const excludeIntentIds = new Set([...eligible.map((i) => i.id), ...abandonedIntentIds]);
1577
1592
 
1578
1593
  const worktreesCreated = [];
1579
1594
  for (const intent of eligible) {
@@ -2240,7 +2255,8 @@ async function verifyCommand(args) {
2240
2255
  process.exitCode = 1;
2241
2256
  return;
2242
2257
  }
2243
- result = verifyTask(db, taskId, owner);
2258
+ const overrides = await loadOverrides();
2259
+ result = verifyTask(db, taskId, owner, overrides);
2244
2260
  } catch (err) {
2245
2261
  console.error(`${red('Verify failed:')} ${err.message}\n`);
2246
2262
  process.exitCode = 1;
@@ -3332,7 +3348,7 @@ async function overrideCommand(args) {
3332
3348
  console.log(` ${green('added')} ${OVERRIDES_DIR}/${record.task.toLowerCase()}.json`);
3333
3349
  for (const glob of record.scope_add) console.log(` + ${glob}`);
3334
3350
  console.log(
3335
- ` ${dim(`run \`hedgehog plan --recompile\` to widen ${record.task} now, if it's already compiled`)}\n`,
3351
+ ` ${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
3352
  );
3337
3353
  return;
3338
3354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.2.17",
3
+ "version": "6.3.0",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
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
+ }
@@ -311,7 +311,7 @@ function loadCommitSubjects() {
311
311
  // and applying either check there would cascade that gap through the
312
312
  // whole chain and reset already-built modules that have no ambiguity to
313
313
  // resolve in the first place.
314
- function markCompletedTasks(db, commitSubjects) {
314
+ function markCompletedTasks(db, commitSubjects, reconciledTaskIds = new Set()) {
315
315
  const tasks = db.prepare('SELECT id, module, commit_message FROM tasks').all();
316
316
  const prerequisites = new Map(tasks.map((t) => [t.id, []]));
317
317
  for (const d of db.prepare('SELECT task_id, depends_on_task_id FROM dependencies').all()) {
@@ -329,7 +329,16 @@ function markCompletedTasks(db, commitSubjects) {
329
329
  // resolved separately below, since "the" matching commit for its
330
330
  // subject isn't decided until a claim succeeds.
331
331
  const positionOf = new Map();
332
- const complete = new Set();
332
+ // Seeded with every reconciled task up front, before the ambiguous-task
333
+ // fixpoint walk below ever runs: a reconciled task was closed precisely
334
+ // because it has no commit of its own to match in commitSubjects (a
335
+ // hand-written commit predating the reconciliation, or none at all), so
336
+ // without this seed it can never enter `complete` from inside this
337
+ // function — leaving every task that depends on it, directly or
338
+ // transitively, stuck `planned` forever, since replayReconciliations
339
+ // (the only other path that would mark it complete) runs after this
340
+ // function returns.
341
+ const complete = new Set(reconciledTaskIds);
333
342
  const ambiguousTasks = [];
334
343
  for (const task of tasks) {
335
344
  if (task.module === CORE_MODULE || isAmbiguous(task)) {
@@ -552,7 +561,18 @@ export async function rebuildDb(
552
561
  planTasks(db, core, overrides, { excludeIntentIds: openWorktreeIntentIds });
553
562
 
554
563
  const commitSubjects = loadCommitSubjects();
555
- const tasksMarkedComplete = markCompletedTasks(db, commitSubjects);
564
+ // Reconciled task ids are seeded into markCompletedTasks's own `complete`
565
+ // set before its ambiguous-task fixpoint walk runs, not after: a
566
+ // reconciled task's whole reason for being in .hedgehog/reconciled/ is
567
+ // that it has no commit of its own in commitSubjects to match, so
568
+ // without this it can never satisfy `every prerequisite is complete` for
569
+ // whatever depends on it — see markCompletedTasks's own comment on
570
+ // `reconciledTaskIds` for the full mechanics. replayReconciliations
571
+ // (below) still runs afterward to write the provenance note and cover
572
+ // any reconciled task markCompletedTasks doesn't touch (module = CORE_MODULE
573
+ // edge cases aside, every reconciled id ends up here either way).
574
+ const reconciledTaskIds = new Set(reconciliations.keys());
575
+ const tasksMarkedComplete = markCompletedTasks(db, commitSubjects, reconciledTaskIds);
556
576
 
557
577
  const tasksReconciled = replayReconciliations(db, reconciliations);
558
578
  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/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.0",
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.