@skyf0xx/hedgehog 6.2.3 → 6.2.5

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
@@ -21,7 +21,7 @@ import { spawn, execFileSync } from 'node:child_process';
21
21
  import { dbInit, DB_PATH, dbAbsPath, openDb, openDbAt } from '../src/db/init.mjs';
22
22
  import { loadCore, lintCore, isModuleAxis } from '../src/db/core.mjs';
23
23
  import { planTasks, CORE_INTENT_ID } from '../src/db/plan.mjs';
24
- import { addIntent, INTENTS_DIR } from '../src/db/intent.mjs';
24
+ import { addIntent, INTENTS_DIR, intentFilePath } from '../src/db/intent.mjs';
25
25
  import {
26
26
  nextTask,
27
27
  formatNext,
@@ -595,9 +595,13 @@ ${bold('Usage')}
595
595
  npx @skyf0xx/hedgehog abandon <intent-id> --reason "<why>"
596
596
  drop an intent that will never finish: records why,
597
597
  resets its tasks to planned on trunk, removes its
598
- worktree and branch
598
+ worktree and branch — the id stays taken; to change
599
+ an abandoned intent, edit its .hedgehog/intents/<id>.json
600
+ by hand and run 'hedgehog plan', not 'intent add' again
599
601
  npx @skyf0xx/hedgehog intent add [flags] add an intent (rules/requirements/dependencies)
600
- npx @skyf0xx/hedgehog intent add --file <path> add an intent from a JSON file
602
+ npx @skyf0xx/hedgehog intent add --file <path> add an intent from a JSON file — fails on an id already
603
+ in the build graph, including one reset by 'hedgehog
604
+ abandon' (still 'planned', not removed); see 'abandon' above
601
605
  npx @skyf0xx/hedgehog next print the task packet for one ready task
602
606
  npx @skyf0xx/hedgehog show <task-id> print the task packet for any task, at any status
603
607
  npx @skyf0xx/hedgehog claim --owner <owner> [--count <n>] atomically claim up to n ready tasks
@@ -1555,7 +1559,11 @@ async function planCommand(args = []) {
1555
1559
  }
1556
1560
 
1557
1561
  for (const { intentId, branch, path } of worktreesCreated) {
1558
- console.log(` ${green('worktree')} ${bold(intentId)} ${dim(`${branch} → ${path}`)}`);
1562
+ console.log(
1563
+ ` ${green('worktree')} ${bold(intentId)} ${dim(`${branch} → ${path}`)}\n` +
1564
+ ` ${dim(`compiling here, not onto trunk, because every intent ${intentId} depends_on is already complete —`)}\n` +
1565
+ ` ${dim(`see \`hedgehog merge ${intentId}\`/\`hedgehog abandon ${intentId}\` to close it out later`)}`,
1566
+ );
1559
1567
  }
1560
1568
 
1561
1569
  // Compiles the new worktree's own graph from inside it — a plain
@@ -1728,8 +1736,15 @@ async function intentCommand(args) {
1728
1736
  try {
1729
1737
  intent = await addIntent(db, record);
1730
1738
  } catch (err) {
1739
+ const idTaken = /UNIQUE constraint failed: intents\.id/.test(err.message);
1731
1740
  console.error(
1732
- `${red('Failed to add intent:')} ${err.message}\n\nUsage: hedgehog intent add --id <id> --goal <goal> --outcome <outcome> [--rule <r>]... [--depends-on <id>]...\n or: hedgehog intent add --file <path.json>\n`,
1741
+ `${red('Failed to add intent:')} ${err.message}\n\n` +
1742
+ (idTaken
1743
+ ? `${bold(record.id)} already exists in the build graph — this includes an intent \`hedgehog abandon\`\n` +
1744
+ `reset to 'planned' rather than removed. To change it, edit ${intentFilePath(record.id)}\n` +
1745
+ `by hand and run \`hedgehog plan\` to recompile; don't run \`intent add\` again for this id.\n\n`
1746
+ : '') +
1747
+ `Usage: hedgehog intent add --id <id> --goal <goal> --outcome <outcome> [--rule <r>]... [--depends-on <id>]...\n or: hedgehog intent add --file <path.json>\n`,
1733
1748
  );
1734
1749
  process.exitCode = 1;
1735
1750
  return;
@@ -3705,7 +3720,10 @@ async function abandonCommand(args) {
3705
3720
  console.log(
3706
3721
  `\n${bold('Abandoned.')} ${dim(`${id} is reset to planned on trunk. Commit ${ABANDONED_DIR}/${id.toLowerCase()}.json —`)}\n` +
3707
3722
  `${dim('the build graph is derived and gitignored, and an uncommitted abandonment')}\n` +
3708
- `${dim('is reverted by the next `hedgehog db rebuild`.')}\n`,
3723
+ `${dim('is reverted by the next `hedgehog db rebuild`.')}\n\n` +
3724
+ `${dim(`${bold(id)} still exists in the build graph at 'planned' — do not run \`hedgehog intent add\` for`)}\n` +
3725
+ `${dim(`this id again (it will fail: the id is already taken). To change the intent, edit`)}\n` +
3726
+ `${dim(`${intentFilePath(id)} by hand and run \`hedgehog plan\` to recompile it.`)}\n`,
3709
3727
  );
3710
3728
  }
3711
3729
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.2.3",
3
+ "version": "6.2.5",
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": {
@@ -218,26 +218,32 @@ async function replayIntents(db, intentsDir) {
218
218
  return count;
219
219
  }
220
220
 
221
- // Every commit subject in history mapped to its position, newest first
222
- // one `git log` call rather than one per task. Membership alone answers
223
- // "did this task ever run"; position also answers "did it run *after*
224
- // the thing it depends on", which is what a `once: true` task needs,
225
- // since its commit subject is a constant that one historical occurrence
226
- // would otherwise satisfy forever. `--topo-order` so the position is a
221
+ // Every commit subject in history mapped to EVERY position it occurs at,
222
+ // newest first — one `git log` call rather than one per task. Membership
223
+ // alone answers "did this task ever run"; position also answers "did it
224
+ // run *after* the thing it depends on", which is what a `once: true`
225
+ // task needs, since its commit subject is a constant that one historical
226
+ // occurrence would otherwise satisfy forever. Keeping every occurrence
227
+ // (not just the newest) is what lets markCompletedTasks tell two tasks
228
+ // that share a constant commit_message apart on a linear-chain core: one
229
+ // real commit per intent that actually ran the layer, each position
230
+ // claimable by at most one task. `--topo-order` so position is a
227
231
  // property of the history's shape rather than of commit timestamps.
228
232
  function loadCommitSubjects() {
229
233
  const output = execSync('git log --topo-order --format=%H%x00%s', { encoding: 'utf8' });
230
- const newestPosition = new Map();
234
+ const positions = new Map();
231
235
  let position = 0;
232
236
  for (const line of output.split('\n')) {
233
237
  if (!line) continue;
234
238
  const [, subject] = line.split('\0');
235
239
  if (subject === undefined) continue;
236
- // Newest first, so the first occurrence seen is the most recent one.
237
- if (!newestPosition.has(subject)) newestPosition.set(subject, position);
240
+ // Newest first, so pushing in read order keeps each subject's array
241
+ // newest-to-oldest.
242
+ if (!positions.has(subject)) positions.set(subject, []);
243
+ positions.get(subject).push(position);
238
244
  position++;
239
245
  }
240
- return newestPosition;
246
+ return positions;
241
247
  }
242
248
 
243
249
  // A task is complete iff some commit's subject exactly matches its
@@ -246,25 +252,65 @@ function loadCommitSubjects() {
246
252
  // there's no verify_command to re-run and no working tree diff to check,
247
253
  // only the historical fact that the commit already happened.
248
254
  //
249
- // A `once: true` task carries two extra conditions: every prerequisite
250
- // must be complete, and its own commit must be *newer* than all of them.
251
- // Its prerequisite set is the only one that grows after the task has
252
- // already run — `planner`'s Re-entry pass adds a new intent whose work a
253
- // tail once-layer then depends on (plan.mjs reopens it for exactly that
254
- // reason). Its commit subject carries no {module}, so it is a constant:
255
- // the single `chore(infra): deploy` from the first run would otherwise
256
- // make the layer look done forever, re-closing it here on the next fresh
257
- // clone and quietly undoing the reopen. Requiring it to sit above its
258
- // prerequisites in history is what encodes "the deploy ran *after* that
259
- // module landed". Walked to a fixpoint, since a once-layer may sit
260
- // behind another one.
255
+ // A task's `commit_message` is unique to it exactly when the layer's
256
+ // `commit` template interpolates `{module}` the module-axis case,
257
+ // where each intent's copy of the layer produces a distinct string. A
258
+ // `once: true` layer has no module to substitute (core.mjs's
259
+ // validateCore rejects one that names {module}), so its single task's
260
+ // commit subject is a constant by construction and, since a core
261
+ // compiles at most one once-task per once-layer, that task is the only
262
+ // one carrying its subject. A linear-chain core (authored, adopted) has
263
+ // no module axis at all: every layer's `commit` is a fixed string in
264
+ // core.yaml with no `{module}` token, so every intent that walks the
265
+ // chain compiles a per-layer task carrying that same constant one task
266
+ // per intent, all sharing one subject.
261
267
  //
262
- // This condition is deliberately not applied to per-module tasks. A task
263
- // that completed without touching any file leaves no commit at all
264
- // (verifyTask writes none), and cascading that gap through the whole
265
- // chain would reset already-built modules. Scoping it to once-layers
266
- // keeps the change to cores that use the feature, and errs toward
267
- // re-running an idempotent infrastructure step rather than skipping it.
268
+ // A commit_message shared by more than one task (grouped below into
269
+ // `ambiguousTasks`) means membership in commitSubjects can't tell those
270
+ // tasks apart: every commit ever made with that subject is a candidate
271
+ // match for every one of them. Two conditions resolve it:
272
+ //
273
+ // 1. Ordering a task's own matching commit must sit *above* (be
274
+ // newer than) every one of its own prerequisites' matching
275
+ // commits, the same way a once-task has always required its commit
276
+ // to postdate the module it deploys. Without this, the first
277
+ // intent's commit for a layer would satisfy every later intent's
278
+ // task of that same layer forever, re-closing the layer on a fresh
279
+ // clone regardless of what that later intent's own chain has
280
+ // actually done.
281
+ // 2. Consumption — a group of N tasks sharing a subject can credit at
282
+ // most as many of them complete as there are actual commits with
283
+ // that subject in history, each commit backing at most one task.
284
+ // Ordering alone doesn't catch a head-of-chain task: with no
285
+ // prerequisite of its own, "ran after its prerequisites" is
286
+ // vacuously true regardless of which commit it points at, which is
287
+ // exactly the shape of the bug — a fresh intent's first layer,
288
+ // sharing a constant commit_message with an already-built intent's
289
+ // completed first layer, has nothing to check position against.
290
+ // Consumption is what a head-of-chain task actually needs: once
291
+ // every real commit for that subject is claimed by other tasks,
292
+ // none is left for it to point at.
293
+ //
294
+ // Both are walked together, per group, in a single deterministic pass
295
+ // ordered by task id: earlier-sorted tasks get first claim on the
296
+ // oldest still-unclaimed matching commit that satisfies the ordering
297
+ // condition against whatever their own prerequisites already claimed.
298
+ // A task that finds no claimable commit is left incomplete — the safe
299
+ // direction, matching every other cross-cutting-layer default in this
300
+ // engine (re-running an idempotent step beats silently skipping one).
301
+ // The whole thing is walked to a fixpoint, since one ambiguous task's
302
+ // claim can be the prerequisite another ambiguous task needs before it
303
+ // can claim its own (a once-layer behind another once-layer, or one
304
+ // linear-chain layer behind the one before it in the same intent).
305
+ //
306
+ // A task with a commit_message unique to it (the ordinary module-axis
307
+ // case) skips all of this: it is marked complete directly from
308
+ // commitSubjects membership, with no ordering or consumption check.
309
+ // That's necessary, not just cheaper — a task that completed without
310
+ // touching any file leaves no commit at all (verifyTask writes none),
311
+ // and applying either check there would cascade that gap through the
312
+ // whole chain and reset already-built modules that have no ambiguity to
313
+ // resolve in the first place.
268
314
  function markCompletedTasks(db, commitSubjects) {
269
315
  const tasks = db.prepare('SELECT id, module, commit_message FROM tasks').all();
270
316
  const prerequisites = new Map(tasks.map((t) => [t.id, []]));
@@ -272,39 +318,64 @@ function markCompletedTasks(db, commitSubjects) {
272
318
  prerequisites.get(d.task_id)?.push(d.depends_on_task_id);
273
319
  }
274
320
 
275
- // Position of each task's most recent matching commit; undefined means
276
- // the task never committed.
277
- const positionOf = new Map(
278
- tasks.map((t) => [t.id, commitSubjects.get(t.commit_message)]),
279
- );
321
+ const messageCounts = new Map();
322
+ for (const task of tasks) {
323
+ messageCounts.set(task.commit_message, (messageCounts.get(task.commit_message) ?? 0) + 1);
324
+ }
325
+ const isAmbiguous = (task) => messageCounts.get(task.commit_message) > 1;
280
326
 
327
+ // Position of each unambiguous task's one matching commit; undefined
328
+ // means the task never committed. An ambiguous task's position is
329
+ // resolved separately below, since "the" matching commit for its
330
+ // subject isn't decided until a claim succeeds.
331
+ const positionOf = new Map();
281
332
  const complete = new Set();
282
- const onceTasks = [];
333
+ const ambiguousTasks = [];
283
334
  for (const task of tasks) {
284
- if (task.module === CORE_MODULE) {
285
- onceTasks.push(task);
335
+ if (task.module === CORE_MODULE || isAmbiguous(task)) {
336
+ ambiguousTasks.push(task);
286
337
  continue;
287
338
  }
288
- if (positionOf.get(task.id) !== undefined) complete.add(task.id);
339
+ const position = (commitSubjects.get(task.commit_message) ?? [])[0];
340
+ positionOf.set(task.id, position);
341
+ if (position !== undefined) complete.add(task.id);
289
342
  }
343
+ // Deterministic claim order within a shared subject: sorted by task id.
344
+ ambiguousTasks.sort((a, b) => a.id.localeCompare(b.id));
345
+
346
+ // Every commit position still unclaimed, per subject — shrinks as
347
+ // ambiguous tasks below claim one each.
348
+ const available = new Map(
349
+ [...commitSubjects].map(([subject, positions]) => [subject, [...positions]]),
350
+ );
290
351
 
291
- // Lower position is newer, so a once-task ran after a prerequisite when
292
- // its own position is strictly smaller.
293
- const ranAfterPrerequisites = (task) => {
294
- const own = positionOf.get(task.id);
295
- return prerequisites.get(task.id).every((id) => {
352
+ // A task ran after a prerequisite when its own claimed position is
353
+ // strictly smaller (newer) than the prerequisite's.
354
+ const ranAfterPrerequisites = (task, position) =>
355
+ prerequisites.get(task.id).every((id) => {
296
356
  const prereq = positionOf.get(id);
297
- return prereq === undefined || own < prereq;
357
+ return prereq === undefined || position < prereq;
298
358
  });
299
- };
300
359
 
301
360
  let changed = true;
302
361
  while (changed) {
303
362
  changed = false;
304
- for (const task of onceTasks) {
305
- if (complete.has(task.id) || positionOf.get(task.id) === undefined) continue;
363
+ for (const task of ambiguousTasks) {
364
+ if (complete.has(task.id) || positionOf.has(task.id)) continue;
306
365
  if (!prerequisites.get(task.id).every((id) => complete.has(id))) continue;
307
- if (!ranAfterPrerequisites(task)) continue;
366
+
367
+ const slots = available.get(task.commit_message) ?? [];
368
+ // Oldest-first, so a task claims the least-recent commit that
369
+ // still satisfies its ordering condition — leaving newer slots
370
+ // free for whichever task in the group depends on this one.
371
+ const slotIndex = [...slots]
372
+ .map((position, i) => [position, i])
373
+ .sort((a, b) => b[0] - a[0])
374
+ .find(([position]) => ranAfterPrerequisites(task, position))?.[1];
375
+ if (slotIndex === undefined) continue;
376
+
377
+ const [position] = slots.splice(slotIndex, 1);
378
+ positionOf.set(task.id, position);
308
379
  complete.add(task.id);
309
380
  changed = true;
310
381
  }
@@ -313,13 +384,14 @@ function markCompletedTasks(db, commitSubjects) {
313
384
  const setComplete = db.prepare("UPDATE tasks SET status = 'complete' WHERE id = ?");
314
385
  for (const id of complete) setComplete.run(id);
315
386
 
316
- // A once-task can be marked complete by the loop above and then fail
317
- // one of the extra conditions on a later pass of the fixpoint walk.
318
- // Reconcile it back rather than leaving the stale status untouched.
387
+ // A task on the ambiguous path can be marked complete by the loop
388
+ // above and then fail to claim a slot on a later pass of the fixpoint
389
+ // walk. Reconcile it back rather than leaving the stale status
390
+ // untouched.
319
391
  const reopen = db.prepare(
320
392
  "UPDATE tasks SET status = 'planned' WHERE id = ? AND status = 'complete'",
321
393
  );
322
- for (const task of onceTasks) {
394
+ for (const task of ambiguousTasks) {
323
395
  if (!complete.has(task.id)) reopen.run(task.id);
324
396
  }
325
397
 
@@ -431,6 +431,26 @@ function resetIntentTasksToPlanned(db, intentId) {
431
431
  .all(intentId);
432
432
  }
433
433
 
434
+ // Deletes every `intent_dependencies` row naming `intentId` on either
435
+ // side. An abandoned intent is reset to `planned` with no compiled tasks
436
+ // on trunk, so a `depends_on` edge pointing at it (something else declared
437
+ // as depending on it) or away from it (its own declared dependency) is
438
+ // both stale the moment abandonment lands: `eligibleIntents` (this file)
439
+ // reads that table directly to decide worktree eligibility, and a row
440
+ // surviving abandonment lets an unrelated intent read as "its dependency
441
+ // is satisfied" against an intent that no longer has any real status to
442
+ // satisfy anything with, or lets this intent re-read as eligible against a
443
+ // dependency it no longer declares. Re-adding the same id later
444
+ // (`hedgehog intent add --file` with a JSON that still declares the
445
+ // dependency) recreates the row from that file, same as any first-time
446
+ // add — this only clears what abandonment made stale, not what a future
447
+ // add might legitimately restate.
448
+ function clearIntentDependencies(db, intentId) {
449
+ db.prepare(
450
+ 'DELETE FROM intent_dependencies WHERE intent_id = ? OR depends_on_intent_id = ?',
451
+ ).run(intentId, intentId);
452
+ }
453
+
434
454
  // Resets every task of `intentId` to `planned` on whichever DB `db` is
435
455
  // open against (trunk, by the CLI's own contract — see abandonCommand),
436
456
  // clears any lease, and reopens the intent itself to `planned` so a later
@@ -440,6 +460,11 @@ function resetIntentTasksToPlanned(db, intentId) {
440
460
  // tasks that don't exist yet). Mirrors reconcile.mjs#applyReconciliation's
441
461
  // shape: the committed file is written first, this is applied second.
442
462
  //
463
+ // Also clears every `intent_dependencies` row naming this intent, on
464
+ // either side (clearIntentDependencies, above) — an abandoned intent's own
465
+ // declared dependency and any other intent's dependency on it are both
466
+ // stale the instant it resets to `planned` with no shipped work.
467
+ //
443
468
  // Refuses an intent already `complete` (merged, real shipped work) —
444
469
  // same reasoning as reconcile.mjs#confirmReconciliation refusing an
445
470
  // already-complete task: applyAbandonment resets tasks to 'planned'
@@ -458,6 +483,7 @@ export function applyAbandonment(db, intentId) {
458
483
  );
459
484
  }
460
485
  const resetTasks = resetIntentTasksToPlanned(db, intentId);
486
+ clearIntentDependencies(db, intentId);
461
487
 
462
488
  if (intent) {
463
489
  db.prepare("UPDATE intents SET status = 'planned' WHERE id = ?").run(intentId);
@@ -501,6 +527,7 @@ export function replayAbandonments(db, abandonments) {
501
527
  }
502
528
  setPlanned.run(intentId);
503
529
  resetIntentTasksToPlanned(db, intentId);
530
+ clearIntentDependencies(db, intentId);
504
531
  replayed.push({ intentId, reason: record.reason });
505
532
  }
506
533
  return { replayed, orphaned };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.2.3",
3
+ "version": "6.2.5",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }