@skyf0xx/hedgehog 5.3.2 → 5.3.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/bin/cli.mjs CHANGED
@@ -585,9 +585,33 @@ the @latest tag matters, since a bare npx may reuse a cached older CLI.
585
585
  `);
586
586
  }
587
587
 
588
+ // The entire Hedgehog discipline is commit-per-layer/step — every loop
589
+ // skill's own enforcement mechanism assumes `git commit` works in this
590
+ // directory. Checked here rather than left to surface as a failed commit
591
+ // several planning-intake steps later, after work with nowhere to land
592
+ // as history has already happened. Run unattended, the same as
593
+ // ensureGlobalInstall: this binary has no stdin channel to prompt on in
594
+ // the general case, and `init` already writes every other file without
595
+ // confirmation.
596
+ function ensureGitRepo() {
597
+ try {
598
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
599
+ stdio: 'ignore',
600
+ timeout: 5_000,
601
+ });
602
+ return;
603
+ } catch {
604
+ // Not inside a work tree — fall through to init one.
605
+ }
606
+ execFileSync('git', ['init'], { stdio: 'ignore', timeout: 5_000 });
607
+ console.log(dim(' (no git repository found here — ran `git init`, since every later step commits)'));
608
+ }
609
+
588
610
  // `core` is the fetched core — `{ manifest, root, version }` — or null on
589
611
  // a deferred install, where planner picks one later.
590
612
  async function init({ force, core, host = DEFAULT_HOST, hostOnly = false }) {
613
+ ensureGitRepo();
614
+
591
615
  // Resolve the full list of writes up front so we can detect conflicts
592
616
  // before touching anything. A deferred install plans against `null` —
593
617
  // the shared agents/skills/build-graph payload only.
@@ -650,6 +674,16 @@ async function init({ force, core, host = DEFAULT_HOST, hostOnly = false }) {
650
674
  )}\n`,
651
675
  );
652
676
  console.log('Next steps:');
677
+ // This install itself just fetched globalInstall.latest to run, so the
678
+ // global binary is already ahead of the version this run scaffolded
679
+ // with. Printed as step 0 here rather than left for `status`'s passive
680
+ // nudge to surface later, mixed in with routine output well into the
681
+ // build.
682
+ if (globalInstall?.updated) {
683
+ console.log(
684
+ ` 0. ${bold(`npx @skyf0xx/hedgehog@latest update`)} ${dim(`(picks up ${globalInstall.latest} before intake begins)`)}`,
685
+ );
686
+ }
653
687
  // A core that landed a workspace landed its root package.json with it,
654
688
  // so there are dependencies to install before the first commit.
655
689
  if (core?.manifest.workspace) {
@@ -1262,11 +1296,15 @@ async function intentCommand(args) {
1262
1296
  }
1263
1297
 
1264
1298
  // On a module-axis core the intent id becomes {module} in every layer's
1265
- // scope glob and verify command, and the generators the packet points at
1266
- // take the module name plural. A singular id compiles a whole graph
1267
- // scoped to a directory the generator will never write, and nothing
1268
- // downstream catches it: plan compiles it, claim hands out a packet whose
1269
- // own scaffold command contradicts its ALLOWED SCOPE.
1299
+ // scope glob and verify command, and on a core whose generator pluralizes
1300
+ // it (core.yaml's `pluralizes`, default true), a singular id compiles a
1301
+ // whole graph scoped to a directory the generator will never write, and
1302
+ // nothing downstream catches it: plan compiles it, claim hands out a
1303
+ // packet whose own scaffold command contradicts its ALLOWED SCOPE. A core
1304
+ // that declares `pluralizes: false` never has this failure mode, so
1305
+ // warnSingularModuleId and warnSingularModuleIdsAtPlan both skip it —
1306
+ // see core.pluralizes in src/db/core.mjs for why that lives on the core
1307
+ // rather than as a hardcoded exception here.
1270
1308
  //
1271
1309
  // A warning rather than a refusal: plenty of real modules are singular
1272
1310
  // (`billing`, `search`), so the convention cannot be enforced without
@@ -1298,6 +1336,7 @@ async function warnSingularModuleId(intent) {
1298
1336
  return;
1299
1337
  }
1300
1338
  if (!isModuleAxis(core)) return;
1339
+ if (core.pluralizes === false) return;
1301
1340
  if (!looksSingular(intent.id)) return;
1302
1341
 
1303
1342
  console.log(
@@ -1326,6 +1365,7 @@ async function warnSingularModuleId(intent) {
1326
1365
  // recovery is still per-id, so those lines are listed one per name.
1327
1366
  function warnSingularModuleIdsAtPlan(core, db) {
1328
1367
  if (!isModuleAxis(core)) return;
1368
+ if (core.pluralizes === false) return;
1329
1369
 
1330
1370
  // Every intent whose tasks have all yet to start, not just the ones
1331
1371
  // this run compiled. `db rebuild` replays the committed intent files
@@ -1426,8 +1466,15 @@ async function noteAvailableUpdate() {
1426
1466
  // The install/update itself always runs regardless of `quiet` — silence
1427
1467
  // governs only whether this prints, matching `boundary --quiet`'s "exit
1428
1468
  // code only, nothing on either stream" contract for shell hooks.
1469
+ // Returns `{ updated, latest }` — `updated` true only when this call
1470
+ // actually installed a newer global version this run is not yet running
1471
+ // on. `init` uses that to put the update command explicitly in its own
1472
+ // "Next steps" rather than leaving it to be discovered later via
1473
+ // `status`'s passive nudge, since by the time `init` prints its own
1474
+ // output the global binary is already ahead of the version this run used
1475
+ // to scaffold the project.
1429
1476
  async function ensureGlobalInstall({ quiet = false } = {}) {
1430
- if (process.env.HEDGEHOG_NO_UPDATE_CHECK) return;
1477
+ if (process.env.HEDGEHOG_NO_UPDATE_CHECK) return { updated: false };
1431
1478
  try {
1432
1479
  const globalRoot = execFileSync('npm', ['root', '-g'], {
1433
1480
  encoding: 'utf8',
@@ -1435,8 +1482,8 @@ async function ensureGlobalInstall({ quiet = false } = {}) {
1435
1482
  }).trim();
1436
1483
  const runningFromGlobal = PKG_ROOT === join(globalRoot, '@skyf0xx/hedgehog');
1437
1484
  const { latest, stale } = await checkBinaryStaleness(DEST_ROOT, PKG_VERSION);
1438
- if (runningFromGlobal && !stale) return;
1439
- if (!latest) return;
1485
+ if (runningFromGlobal && !stale) return { updated: false };
1486
+ if (!latest) return { updated: false };
1440
1487
 
1441
1488
  execFileSync('npm', ['install', '-g', `@skyf0xx/hedgehog@${latest}`], {
1442
1489
  stdio: 'ignore',
@@ -1451,9 +1498,11 @@ async function ensureGlobalInstall({ quiet = false } = {}) {
1451
1498
  )}`,
1452
1499
  );
1453
1500
  }
1501
+ return { updated: runningFromGlobal, latest };
1454
1502
  } catch {
1455
1503
  // Advisory only — a failed check or install is never worth failing a
1456
1504
  // command over.
1505
+ return { updated: false };
1457
1506
  }
1458
1507
  }
1459
1508
 
@@ -2877,7 +2926,7 @@ async function main() {
2877
2926
  console.log(PKG_VERSION);
2878
2927
  return;
2879
2928
  }
2880
- await ensureGlobalInstall({ quiet: args.includes('--quiet') });
2929
+ const globalInstall = await ensureGlobalInstall({ quiet: args.includes('--quiet') });
2881
2930
  const cmd = args[0];
2882
2931
  const force = args.includes('--force') || args.includes('-f');
2883
2932
 
@@ -2948,7 +2997,7 @@ async function main() {
2948
2997
  // the first host; the rest add only what differs per host.
2949
2998
  const targets = hosts.length ? hosts : [DEFAULT_HOST];
2950
2999
  for (const [i, host] of targets.entries()) {
2951
- await init({ force, core, host, hostOnly: i > 0 });
3000
+ await init({ force, core, host, hostOnly: i > 0, globalInstall });
2952
3001
  }
2953
3002
  return;
2954
3003
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "5.3.2",
3
+ "version": "5.3.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": {
package/src/db/core.mjs CHANGED
@@ -182,6 +182,7 @@ function indentOf(line) {
182
182
 
183
183
  // Parses the narrow subset of YAML a core definition needs:
184
184
  // id: <scalar>
185
+ // pluralizes: <bool> # optional, default true
185
186
  // layers:
186
187
  // - id: <scalar>
187
188
  // depends_on: <scalar> # optional
@@ -201,7 +202,7 @@ export function parseCoreYaml(text) {
201
202
  lines.push({ indent: indentOf(noComment), text: noComment.trim() });
202
203
  }
203
204
 
204
- const core = { id: undefined, layers: [] };
205
+ const core = { id: undefined, pluralizes: true, layers: [] };
205
206
  let i = 0;
206
207
 
207
208
  while (i < lines.length && lines[i].indent === 0) {
@@ -214,6 +215,15 @@ export function parseCoreYaml(text) {
214
215
  if (!match) throw new Error(`unparseable line: ${line.text}`);
215
216
  const [, key, value] = match;
216
217
  if (key === 'id') core.id = parseScalar(value);
218
+ // Whether this core's own generator takes a module id plural — a
219
+ // fixed, known fact about that generator, not a per-project unknown.
220
+ // Absent means true, so every core written before this field existed
221
+ // keeps warning exactly as it always has; a core whose generator
222
+ // never pluralizes (deepseek-harness's tool generator uses the id
223
+ // verbatim) declares `pluralizes: false` once and the singular-id
224
+ // advisory stops firing on it for good, rather than every user of
225
+ // that core re-discovering the same false positive.
226
+ if (key === 'pluralizes') core.pluralizes = parseScalar(value) === 'true';
217
227
  i++;
218
228
  }
219
229
 
package/src/db/next.mjs CHANGED
@@ -374,7 +374,21 @@ const HONESTY = [
374
374
  export function scopePackageRoot(glob, module) {
375
375
  const segments = glob.replace('{module}', module).split('/');
376
376
  const wildcard = segments.findIndex((s) => s.includes('*'));
377
- const literal = wildcard === -1 ? segments.slice(0, -1) : segments.slice(0, wildcard);
377
+ // No wildcard means the glob names one literal file, not a directory
378
+ // tree. Most such files are a single prompt/config file (e.g.
379
+ // `.hedgehog/dsh-smoke/{module}.md`) whose parent directory never holds
380
+ // a `package.json` of its own, so it is never a package root a
381
+ // generator could be first into — except when the literal file IS
382
+ // `package.json`: a layer whose own scope names that file directly
383
+ // (deepseek-harness's `bundle`: `plugins/{module}/package.json`) is
384
+ // naming its package root exactly as surely as a wildcard glob under
385
+ // that root would, and its parent directory is that root.
386
+ if (wildcard === -1) {
387
+ if (segments.at(-1) !== 'package.json') return null;
388
+ const literal = segments.slice(0, -1);
389
+ return literal.length >= 2 ? literal.join('/') : null;
390
+ }
391
+ const literal = segments.slice(0, wildcard);
378
392
  const src = literal.indexOf('src');
379
393
  const root = src === -1 ? literal : literal.slice(0, src);
380
394
  // A package root is at least `<area>/<name>`; anything shallower is the
@@ -384,12 +398,13 @@ export function scopePackageRoot(glob, module) {
384
398
 
385
399
  // A task is the first arrival in its package when the package the scope
386
400
  // points into has no package.json on disk yet: the generator that
387
- // scaffolds this layer will create the package shell (package.json,
388
- // tsconfig*.json, vitest.config.mts, src/index.ts) alongside the module's
389
- // own files, and every one of those lands outside a {module}-bearing
390
- // glob. Detected here rather than left to be discovered from a failed
391
- // verify, which is where the override stops being available and the
392
- // recovery becomes a five-command round trip.
401
+ // scaffolds this layer will create the package shell (package.json plus
402
+ // whatever else this core scaffolds alongside it — tsconfig, test
403
+ // config, src/index.ts) alongside the module's own files, and every one
404
+ // of those lands outside a {module}-bearing glob. Detected here rather
405
+ // than left to be discovered from a failed verify, which is where the
406
+ // override stops being available and the recovery becomes a
407
+ // five-command round trip.
393
408
  //
394
409
  // `exists` is injected so this module keeps no filesystem dependency of
395
410
  // its own; the CLI passes a real one and the packet degrades to no
@@ -424,11 +439,11 @@ function firstArrivalLines(task, roots) {
424
439
  return [
425
440
  'FIRST ARRIVAL',
426
441
  ` ${roots.join(', ')} ${roots.length > 1 ? 'do' : 'does'} not exist yet, so this task's`,
427
- " generator also creates the package shell (package.json, tsconfig*.json,",
428
- ' vitest.config.mts, src/index.ts) plus any package-wide source it writes',
429
- ' at the src/ root. All of that lands OUTSIDE the scope above. Widen this',
430
- ' one task before building it, or verify will reject those paths and',
431
- ' block the task:',
442
+ ' generator also creates the package shell (package.json plus whatever else',
443
+ ' this core scaffolds alongside it — tsconfig, test config, src/index.ts)',
444
+ ' plus any package-wide source it writes at the src/ root. All of that',
445
+ ' lands OUTSIDE the scope above. Widen this one task before building it,',
446
+ ' or verify will reject those paths and block the task:',
432
447
  '',
433
448
  ` hedgehog override add ${task.id} \\`,
434
449
  ...scopes.map((s) => ` --scope '${s}' \\`),
package/src/db/verify.mjs CHANGED
@@ -61,6 +61,24 @@ import { DB_PATH } from './init.mjs';
61
61
  import { withCommitLock, LOCK_PATH } from './commitLock.mjs';
62
62
  import { reapExpiredLeases, pathFingerprint } from './claim.mjs';
63
63
  import { ensureTaskColumns } from './schema.mjs';
64
+ import { FRICTION_DIR } from './friction.mjs';
65
+ import { OVERRIDES_DIR } from './overrides.mjs';
66
+ import { INTENTS_DIR } from './intent.mjs';
67
+
68
+ // Build-graph state directories: written by their own command
69
+ // (`friction add`, `override add`, `intent add`/`db rebuild`), committed
70
+ // by that command's own next step, never by a layer's verify_command. A
71
+ // layer's own work never lands here, so a path under one of these is
72
+ // never this task's doing regardless of when it changed relative to
73
+ // claim time — unlike attributedToTask's fingerprint check, which only
74
+ // excludes a path unchanged since claim and so still attributes a
75
+ // friction note logged mid-layer (exactly what the loop skill instructs)
76
+ // to whichever task happened to be building when it was logged.
77
+ const BUILD_GRAPH_STATE_DIRS = [FRICTION_DIR, OVERRIDES_DIR, INTENTS_DIR];
78
+
79
+ function isBuildGraphStatePath(path) {
80
+ return BUILD_GRAPH_STATE_DIRS.some((dir) => path === dir || path.startsWith(`${dir}/`));
81
+ }
64
82
 
65
83
  // The build graph file and the commit lock are engine state, written
66
84
  // only by this CLI, never by an agent — both are excluded from every
@@ -69,7 +87,12 @@ import { ensureTaskColumns } from './schema.mjs';
69
87
  // check they're performing. Covers SQLite's journal/WAL/SHM sidecar
70
88
  // files too.
71
89
  function isEngineStatePath(path) {
72
- return path === DB_PATH || path.startsWith(`${DB_PATH}-`) || path === LOCK_PATH;
90
+ return (
91
+ path === DB_PATH ||
92
+ path.startsWith(`${DB_PATH}-`) ||
93
+ path === LOCK_PATH ||
94
+ isBuildGraphStatePath(path)
95
+ );
73
96
  }
74
97
 
75
98
  // Runs git with an argv array and no shell, so every element of `args`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "5.3.2",
3
+ "version": "5.3.4",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }