@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 +59 -10
- package/package.json +1 -1
- package/src/db/core.mjs +11 -1
- package/src/db/next.mjs +27 -12
- package/src/db/verify.mjs +24 -1
- package/src/hosts/gemini/gemini-extension.json +1 -1
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
|
|
1266
|
-
//
|
|
1267
|
-
// scoped to a directory the generator will never write, and
|
|
1268
|
-
// downstream catches it: plan compiles it, claim hands out a
|
|
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
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
|
-
|
|
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
|
-
//
|
|
389
|
-
// own files, and every one
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
// recovery becomes a
|
|
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
|
-
|
|
428
|
-
'
|
|
429
|
-
' at the src/ root. All of that
|
|
430
|
-
'
|
|
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
|
|
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`
|