@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 +24 -6
- package/package.json +1 -1
- package/src/db/rebuild.mjs +123 -51
- package/src/db/worktree.mjs +27 -0
- package/src/hosts/gemini/gemini-extension.json +1 -1
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(
|
|
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\
|
|
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
package/src/db/rebuild.mjs
CHANGED
|
@@ -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
|
|
222
|
-
// one `git log` call rather than one per task. Membership
|
|
223
|
-
// "did this task ever run"; position also answers "did it
|
|
224
|
-
// the thing it depends on", which is what a `once: true`
|
|
225
|
-
// since its commit subject is a constant that one historical
|
|
226
|
-
// would otherwise satisfy forever.
|
|
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
|
|
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
|
|
237
|
-
|
|
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
|
|
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
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
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
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
333
|
+
const ambiguousTasks = [];
|
|
283
334
|
for (const task of tasks) {
|
|
284
|
-
if (task.module === CORE_MODULE) {
|
|
285
|
-
|
|
335
|
+
if (task.module === CORE_MODULE || isAmbiguous(task)) {
|
|
336
|
+
ambiguousTasks.push(task);
|
|
286
337
|
continue;
|
|
287
338
|
}
|
|
288
|
-
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
const ranAfterPrerequisites = (task) =>
|
|
294
|
-
|
|
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 ||
|
|
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
|
|
305
|
-
if (complete.has(task.id) || positionOf.
|
|
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
|
-
|
|
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
|
|
317
|
-
//
|
|
318
|
-
// Reconcile it back rather than leaving the stale status
|
|
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
|
|
394
|
+
for (const task of ambiguousTasks) {
|
|
323
395
|
if (!complete.has(task.id)) reopen.run(task.id);
|
|
324
396
|
}
|
|
325
397
|
|
package/src/db/worktree.mjs
CHANGED
|
@@ -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 };
|