@skyf0xx/hedgehog 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  releaseTask,
38
38
  renewLease,
39
39
  retryTask,
40
+ reopenTask,
40
41
  reapExpiredLeases,
41
42
  } from '../src/db/claim.mjs';
42
43
  import { readyTasks, formatReady } from '../src/db/ready.mjs';
@@ -499,7 +500,9 @@ ${bold('Usage')}
499
500
  npx @skyf0xx/hedgehog update refresh the installed agents + skills
500
501
  npx @skyf0xx/hedgehog update --check report whether a newer release is published
501
502
  npx @skyf0xx/hedgehog db init create .hedgehog/hedgehog.db if absent
502
- npx @skyf0xx/hedgehog db rebuild re-derive the build graph from committed intents + git history
503
+ npx @skyf0xx/hedgehog db rebuild re-derive the build graph from committed intents + git history;
504
+ also reconciles graph state after a hand-committed fix landed
505
+ outside 'hedgehog verify' — run it to mark that work complete
503
506
  npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
504
507
  (starts no graph server; --no-open says so explicitly)
505
508
  npx @skyf0xx/hedgehog plan --open also start the graph server and open it, if anything compiled
@@ -515,6 +518,8 @@ ${bold('Usage')}
515
518
  npx @skyf0xx/hedgehog claim --owner <owner> [--count <n>] atomically claim up to n ready tasks
516
519
  npx @skyf0xx/hedgehog claim <task-id> --owner <owner> claim one specific task (breaks a starvation tie)
517
520
  npx @skyf0xx/hedgehog retry <task-id> return a blocked task to planned, so it can be rebuilt
521
+ npx @skyf0xx/hedgehog reopen <task-id> --confirm return a complete task (and its complete dependents) to
522
+ planned, for a Correction Protocol fix to an already-shipped layer
518
523
  npx @skyf0xx/hedgehog release <task-id> --owner <owner> hand a claimed task back to ready
519
524
  npx @skyf0xx/hedgehog renew <task-id> --owner <owner> [--minutes <n>] extend a held lease
520
525
  npx @skyf0xx/hedgehog verify <task-id> --owner <owner> run scope + verify checks, commit on pass
@@ -1297,9 +1302,10 @@ async function warnSingularModuleId(intent) {
1297
1302
 
1298
1303
  console.log(
1299
1304
  `\n${yellow(bold('Module id looks singular.'))} On this core the intent id is ${bold('{module}')} in\n` +
1300
- `every layer's scope, and the generators take it plural — so ${bold(intent.id)} compiles\n` +
1301
- `scope for ${bold(`${intent.id}/`)} while the scaffold command writes ${bold(`${intent.id}s/`)}.\n` +
1302
- `If ${bold(`${intent.id}s`)} is the name you meant, fix it now, before ${bold('hedgehog plan')}:\n` +
1305
+ `every layer's scope. On some cores the generator pluralizes it, so ${bold(intent.id)} would\n` +
1306
+ `compile scope for ${bold(`${intent.id}/`)} while the scaffold command writes ${bold(`${intent.id}s/`)} — worth\n` +
1307
+ `checking this core's own generator before ${bold('hedgehog plan')}. If ${bold(`${intent.id}s`)} is the name you\n` +
1308
+ `meant, it's still cheap to fix now:\n` +
1303
1309
  ` ${dim(`git mv ${INTENTS_DIR}/${intent.id}.json ${INTENTS_DIR}/${intent.id}s.json`)}\n` +
1304
1310
  ` ${dim(`# edit "id" and every ${intent.id.toUpperCase()}-* requirement id, then:`)}\n` +
1305
1311
  ` ${dim('hedgehog db rebuild')}\n`,
@@ -1347,11 +1353,12 @@ function warnSingularModuleIdsAtPlan(core, db) {
1347
1353
  console.log(
1348
1354
  `${yellow(bold(many ? `${singular.length} module ids look singular.` : 'Module id looks singular.'))} ` +
1349
1355
  `On this core an intent id is ${bold('{module}')} in\n` +
1350
- `every layer's scope, and the generators take it plural — so ${many ? 'each of these compiles' : 'this compiles'}\n` +
1351
- `scope for a directory the scaffold command will never write:\n` +
1352
- singular.map((id) => ` ${bold(id)} ${dim(`— scope ${id}/, scaffold writes ${id}s/`)}`).join('\n') +
1356
+ `every layer's scope. On some cores the generator pluralizes it, so ${many ? 'each of these would compile' : 'this would compile'}\n` +
1357
+ `scope for a directory the scaffold command might never write:\n` +
1358
+ singular.map((id) => ` ${bold(id)} ${dim(`— scope ${id}/, generator may instead write ${id}s/`)}`).join('\n') +
1353
1359
  `\n\nThis is a naming convention, not a rule — ${bold('billing')} and ${bold('search')} are ` +
1354
- `legitimately\nsingular. If the plural is what you meant, it is still cheap to fix:\n` +
1360
+ `legitimately\nsingular, and not every core's generator pluralizes at all check this core's own\n` +
1361
+ `generator before assuming it does. If the plural is what you meant, it is still cheap to fix:\n` +
1355
1362
  singular
1356
1363
  .map(
1357
1364
  (id) =>
@@ -1927,6 +1934,84 @@ async function retryCommand(args) {
1927
1934
  console.log(` ${dim('claim it with')} hedgehog claim ${taskId} --owner <owner>`);
1928
1935
  }
1929
1936
 
1937
+ // `hedgehog reopen <task-id> --confirm` — the transition out of
1938
+ // `complete`, for a Correction Protocol fix: a downstream layer reveals
1939
+ // an upstream one (already verified, committed, and possibly built upon)
1940
+ // was wrong. `retry` only returns a `blocked` task to `planned`; nothing
1941
+ // else in the CLI moves a `complete` task backward, which left a
1942
+ // Correction Protocol fix with no path but a hand-authored commit
1943
+ // outside `hedgehog verify` entirely (see the issue this command closes).
1944
+ //
1945
+ // `--confirm` is required and is the whole difference from `retry`'s
1946
+ // UX: retrying a blocked task undoes nothing that shipped, but reopening
1947
+ // a complete task does, transitively, for everything built on top of it
1948
+ // — that's consequential enough to need the caller to say so explicitly
1949
+ // rather than default to it.
1950
+ async function reopenCommand(args) {
1951
+ await ensureDb();
1952
+
1953
+ const taskId = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
1954
+ const confirmed = args.includes('--confirm');
1955
+ if (!taskId) {
1956
+ console.error(`${red('Usage:')} hedgehog reopen <task-id> --confirm\n`);
1957
+ process.exitCode = 1;
1958
+ return;
1959
+ }
1960
+ if (!confirmed) {
1961
+ console.error(
1962
+ `${red('Refused.')} Reopening ${bold(taskId)} undoes a completed, committed layer and\n` +
1963
+ `every completed layer built on top of it. Re-run with ${bold('--confirm')} to proceed:\n\n` +
1964
+ ` hedgehog reopen ${taskId} --confirm\n`,
1965
+ );
1966
+ process.exitCode = 1;
1967
+ return;
1968
+ }
1969
+
1970
+ if (!(await exists(DB_PATH))) {
1971
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
1972
+ process.exitCode = 1;
1973
+ return;
1974
+ }
1975
+
1976
+ printDbTarget();
1977
+
1978
+ const db = openDb();
1979
+ let result;
1980
+ try {
1981
+ result = reopenTask(db, taskId);
1982
+ } finally {
1983
+ db.close();
1984
+ }
1985
+
1986
+ if (!result.reopened) {
1987
+ process.exitCode = 1;
1988
+ if (result.reason === 'no_such_task') {
1989
+ console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
1990
+ return;
1991
+ }
1992
+ console.error(
1993
+ `${red('Not reopened.')} Task ${bold(taskId)} is ${bold(result.task.status)}, not ${bold('complete')}.\n`,
1994
+ );
1995
+ return;
1996
+ }
1997
+
1998
+ console.log(
1999
+ `${green(bold('Reopened.'))} ${bold(result.reopenedIds.length)} task(s) back to ${bold('planned')}: ${result.reopenedIds.join(', ')}`,
2000
+ );
2001
+ console.log(` ${dim('claim the fix with')} hedgehog claim ${taskId} --owner <owner>`);
2002
+ if (result.stillInFlight.length > 0) {
2003
+ console.log(
2004
+ `\n${yellow(bold('Downstream, not reopened'))} (not complete — nothing shipped yet to invalidate):`,
2005
+ );
2006
+ for (const t of result.stillInFlight) {
2007
+ console.log(` ${bold(t.id)} ${t.status}`);
2008
+ }
2009
+ }
2010
+ console.log(
2011
+ `\n${dim('Hand-committing the fix instead of running it through')} ${bold('hedgehog verify')}${dim('? Reconcile graph state afterward with')} ${bold('hedgehog db rebuild')}${dim('.')}`,
2012
+ );
2013
+ }
2014
+
1930
2015
  // `hedgehog show <task-id>` — the same packet `next` prints, for a task
1931
2016
  // named by id whatever its status. Read-only: claims nothing, changes
1932
2017
  // nothing.
@@ -2922,6 +3007,11 @@ async function main() {
2922
3007
  return;
2923
3008
  }
2924
3009
 
3010
+ if (cmd === 'reopen') {
3011
+ await reopenCommand(args.slice(1));
3012
+ return;
3013
+ }
3014
+
2925
3015
  if (cmd === 'release') {
2926
3016
  await releaseCommand(args.slice(1));
2927
3017
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "5.2.0",
3
+ "version": "5.3.1",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -256,11 +256,20 @@ the first-run shape; on re-entry, run `hedgehog-planning-intake`'s
256
256
  owns `.hedgehog/chain/00-brief.md` and this core's own Confirm & Lock
257
257
  stage; `.hedgehog/BMAD/` is written by the shared Phase 0 in
258
258
  `hedgehog-planning-intake`.
259
+ - **`deepseek-harness`** → no BMAD shelf runs on this core, and none of
260
+ `hedgehog-planning-intake` applies. Intake is mechanical, owned
261
+ entirely by `hedgehog-dsh-loop`'s own Planning intake section: confirm
262
+ the plugin name and goal with the user, one intent per plugin named
263
+ directly, `hedgehog intent add`, `hedgehog plan`, commit, hand off to
264
+ `bootstrap`. There is no subject/audience/job to mine and no brief to
265
+ lock — open that skill's section rather than looking for the shape
266
+ above here.
259
267
 
260
268
  Either way, this is the mechanical procedure; the judgment — what's
261
269
  actually in scope, where a table becomes a module (full-stack-app,
262
270
  pwa-app) or what the page's single job actually is (landing-page) —
263
- stays yours throughout.
271
+ stays yours throughout, except on deepseek-harness, where the procedure
272
+ itself is the judgment call: which plugin, named directly with the user.
264
273
 
265
274
  ## The Add-ons decision (full-stack-app only)
266
275
 
package/src/db/claim.mjs CHANGED
@@ -401,6 +401,94 @@ export function retryTask(db, taskId) {
401
401
  });
402
402
  }
403
403
 
404
+ // The full transitive closure of tasks that depend on `taskId`, directly
405
+ // or through another dependent — the same walk verify.mjs's
406
+ // loadDirectDependents feeds one layer at a time, extended to every
407
+ // layer downstream. A Correction Protocol reopen has to see this whole
408
+ // chain: an upstream task built on wrong output can have several
409
+ // already-complete layers stacked on it, and every one of them was
410
+ // built against the thing that's about to change.
411
+ function loadTransitiveDependents(db, taskId) {
412
+ const directDependents = db.prepare(
413
+ 'SELECT task_id AS id FROM dependencies WHERE depends_on_task_id = ?',
414
+ );
415
+ const seen = new Set([taskId]);
416
+ const downstream = [];
417
+ let frontier = [taskId];
418
+ while (frontier.length > 0) {
419
+ const next = [];
420
+ for (const id of frontier) {
421
+ for (const row of directDependents.all(id)) {
422
+ if (seen.has(row.id)) continue;
423
+ seen.add(row.id);
424
+ downstream.push(row.id);
425
+ next.push(row.id);
426
+ }
427
+ }
428
+ frontier = next;
429
+ }
430
+ return downstream;
431
+ }
432
+
433
+ // Reopens `taskId` for a Correction Protocol fix: moves a `complete` task
434
+ // (and every `complete` task downstream of it, transitively) back to
435
+ // `planned`, so the fix and every layer built on top of it are rebuilt
436
+ // and re-verified in dependency order, the same as any other `planned`
437
+ // task.
438
+ //
439
+ // This is deliberately not folded into `retryTask` as an automatic extra
440
+ // case: `retry` returns a task the loop itself put into `blocked` —
441
+ // expected, low-stakes, no confirmation needed. Reopening a `complete`
442
+ // task undoes a task the loop already verified and committed, and can
443
+ // invalidate everything built against it since, which is why the CLI
444
+ // requires an explicit `--confirm` before calling this — see
445
+ // `reopenCommand`.
446
+ //
447
+ // A downstream task not yet `complete` (still `planned`, `ready`,
448
+ // `building`, `blocked`) is left exactly where it is: it hasn't shipped
449
+ // anything for the fix to invalidate, and its own lease (if any) is not
450
+ // this command's to touch. Only `complete` tasks — upstream's own status
451
+ // plus every `complete` descendant — move; anything else downstream is
452
+ // reported back so the caller can decide what to do about in-flight
453
+ // work sitting on top of a reopened dependency.
454
+ export function reopenTask(db, taskId) {
455
+ return inTransaction(db, () => {
456
+ reapExpiredLeases(db);
457
+ const task = loadTask(db, taskId);
458
+ if (task === undefined) return { reopened: false, reason: 'no_such_task' };
459
+ if (task.status !== 'complete') {
460
+ return { reopened: false, reason: 'not_complete', task };
461
+ }
462
+
463
+ const downstreamIds = loadTransitiveDependents(db, taskId);
464
+ const downstreamTasks = downstreamIds.map((id) => loadTask(db, id));
465
+ const toReopen = [task, ...downstreamTasks.filter((t) => t.status === 'complete')];
466
+ const stillInFlight = downstreamTasks.filter((t) => t.status !== 'complete');
467
+
468
+ const reopenOne = db.prepare(
469
+ `
470
+ UPDATE tasks SET status = 'planned', blocked_reason = NULL,
471
+ lease_owner = NULL, lease_expires_at = NULL, leased_at = NULL,
472
+ claim_snapshot = NULL
473
+ WHERE id = ? AND status = 'complete'
474
+ RETURNING id
475
+ `,
476
+ );
477
+ const reopenedIds = [];
478
+ for (const t of toReopen) {
479
+ const result = reopenOne.get(t.id);
480
+ if (result !== undefined) reopenedIds.push(result.id);
481
+ }
482
+
483
+ return {
484
+ reopened: true,
485
+ reopenedIds,
486
+ stillInFlight,
487
+ task: loadTask(db, taskId),
488
+ };
489
+ });
490
+ }
491
+
404
492
  // Releases `taskId` back to `ready` if `owner` currently holds its lease.
405
493
  // Scoped to `status = 'building'` — `verifying` is the engine's own
406
494
  // transient lease during verifyTask, not something an external release
package/src/db/next.mjs CHANGED
@@ -394,14 +394,29 @@ export function scopePackageRoot(glob, module) {
394
394
  // `exists` is injected so this module keeps no filesystem dependency of
395
395
  // its own; the CLI passes a real one and the packet degrades to no
396
396
  // section when a caller supplies none.
397
+ //
398
+ // A layer can carry more than one glob into the same real package — e.g.
399
+ // `plugins/{module}/package.json` and `plugins/{module}/lib/**` both
400
+ // belong to one package, but the second glob's own literal prefix ends
401
+ // at `lib`, a build-output directory that never holds a `package.json`
402
+ // of its own. Naively treating every such root as first-arrival would
403
+ // flag `lib/` as a second package needing its own scaffold. A root
404
+ // nested under another of this task's own roots is never a package
405
+ // boundary — only the shallowest root in each chain is.
397
406
  export function firstArrivalPackages(task, exists) {
398
407
  if (!exists) return [];
399
- const roots = new Set();
408
+ const allRoots = new Set();
409
+ const missing = new Set();
400
410
  for (const glob of JSON.parse(task.scope_globs)) {
401
411
  const root = scopePackageRoot(glob, task.module);
402
- if (root && !exists(`${root}/package.json`)) roots.add(root);
412
+ if (!root) continue;
413
+ allRoots.add(root);
414
+ if (!exists(`${root}/package.json`)) missing.add(root);
403
415
  }
404
- return [...roots].sort();
416
+ const outermost = [...missing].filter(
417
+ (root) => ![...allRoots].some((other) => other !== root && root.startsWith(`${other}/`)),
418
+ );
419
+ return outermost.sort();
405
420
  }
406
421
 
407
422
  function firstArrivalLines(task, roots) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "5.2.0",
3
+ "version": "5.3.1",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }