@skyf0xx/hedgehog 6.2.3 → 6.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.2.3",
3
+ "version": "6.2.4",
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.2.3",
3
+ "version": "6.2.4",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }