@skyf0xx/hedgehog 4.0.3 → 4.0.7
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 +906 -43
- package/package.json +1 -1
- package/src/agents/backend-eng.md +16 -1
- package/src/agents/front-end-eng.md +14 -1
- package/src/agents/layer-eng.md +33 -5
- package/src/agents/planner.md +10 -0
- package/src/agents/tweaker.md +12 -1
- package/src/db/boundary.mjs +396 -0
- package/src/db/claim.mjs +263 -9
- package/src/db/conflict.mjs +4 -1
- package/src/db/core.mjs +567 -9
- package/src/db/debt.mjs +65 -0
- package/src/db/drift.mjs +360 -0
- package/src/db/gate.mjs +149 -0
- package/src/db/graph-server.mjs +11 -6
- package/src/db/init.mjs +7 -1
- package/src/db/intent.mjs +247 -34
- package/src/db/next.mjs +195 -14
- package/src/db/overrides.mjs +216 -0
- package/src/db/plan.mjs +369 -19
- package/src/db/rebuild.mjs +219 -36
- package/src/db/requires.mjs +137 -0
- package/src/db/schema.mjs +42 -0
- package/src/db/status.mjs +63 -13
- package/src/db/verify.mjs +201 -44
- package/src/golden-cores/full-stack-app/apps/web/next-env.d.ts +2 -1
- package/src/golden-cores/full-stack-app/apps/web/package.json +2 -2
- package/src/golden-cores/full-stack-app/core.yaml +14 -19
- package/src/golden-cores/full-stack-app/lefthook.yml +38 -3
- package/src/golden-cores/full-stack-app/package.json +13 -3
- package/src/golden-cores/full-stack-app/pnpm-lock.yaml +3333 -8456
- package/src/golden-cores/full-stack-app/tools/phase-gate.cjs +8 -2
- package/src/golden-cores/landing-page/package.json +8 -1
- package/src/golden-cores/landing-page/pnpm-lock.yaml +18 -13
- package/src/hosts/claude/DISPATCH.md +3 -1
- package/src/hosts/cursor/DISPATCH.md +3 -1
- package/src/hosts/gemini/DISPATCH.md +3 -1
- package/src/hosts/routing.mjs +5 -2
- package/src/skills/hedgehog-authored-loop/SKILL.md +108 -17
- package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +22 -6
- package/src/skills/hedgehog-contributing/SKILL.md +101 -0
- package/src/skills/hedgehog-core-design/SKILL.md +196 -11
- package/src/skills/hedgehog-landing-loop/SKILL.md +17 -4
- package/src/skills/hedgehog-loop/SKILL.md +53 -14
- package/src/skills/hedgehog-planning-intake/SKILL.md +20 -0
- package/src/templates/CLAUDE.core.authored.md +28 -9
- package/src/templates/CLAUDE.md +57 -19
package/bin/cli.mjs
CHANGED
|
@@ -17,18 +17,37 @@ import { constants } from 'node:fs';
|
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
19
19
|
import { spawn } from 'node:child_process';
|
|
20
|
-
import { dbInit, DB_PATH, openDb } from '../src/db/init.mjs';
|
|
21
|
-
import { loadCore } from '../src/db/core.mjs';
|
|
20
|
+
import { dbInit, DB_PATH, dbAbsPath, openDb } from '../src/db/init.mjs';
|
|
21
|
+
import { loadCore, lintCore } from '../src/db/core.mjs';
|
|
22
22
|
import { planTasks } from '../src/db/plan.mjs';
|
|
23
23
|
import { addIntent, INTENTS_DIR } from '../src/db/intent.mjs';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
nextTask,
|
|
26
|
+
formatNext,
|
|
27
|
+
formatPacket,
|
|
28
|
+
stalledTasks,
|
|
29
|
+
taskPacket,
|
|
30
|
+
taskStatusLine,
|
|
31
|
+
} from '../src/db/next.mjs';
|
|
25
32
|
import { verifyTask } from '../src/db/verify.mjs';
|
|
26
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
claimTasks,
|
|
35
|
+
claimTask,
|
|
36
|
+
releaseTask,
|
|
37
|
+
renewLease,
|
|
38
|
+
retryTask,
|
|
39
|
+
} from '../src/db/claim.mjs';
|
|
27
40
|
import { readyTasks, formatReady } from '../src/db/ready.mjs';
|
|
28
41
|
import { graphStatus, formatStatus } from '../src/db/status.mjs';
|
|
42
|
+
import { boundaryState, formatBoundary, formatPosition, formatHandoff } from '../src/db/boundary.mjs';
|
|
43
|
+
import { commitGateStatus, formatCommitGate } from '../src/db/gate.mjs';
|
|
44
|
+
import { detectDrift, recompileTasks, formatRecompile } from '../src/db/drift.mjs';
|
|
45
|
+
import { coreMissingRequirements, missingBinaries } from '../src/db/requires.mjs';
|
|
29
46
|
import { whyPath, formatWhy } from '../src/db/why.mjs';
|
|
30
47
|
import { addFriction, listFriction } from '../src/db/friction.mjs';
|
|
48
|
+
import { addDebt, listDebt } from '../src/db/debt.mjs';
|
|
31
49
|
import { rebuildDb } from '../src/db/rebuild.mjs';
|
|
50
|
+
import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
|
|
32
51
|
import { HOSTS, HOST_FLAGS, DEFAULT_HOST, availableHosts } from '../src/hosts/index.mjs';
|
|
33
52
|
import { recordHosts, installedHosts } from '../src/hosts/installed.mjs';
|
|
34
53
|
|
|
@@ -187,6 +206,17 @@ const exists = (p) =>
|
|
|
187
206
|
() => false,
|
|
188
207
|
);
|
|
189
208
|
|
|
209
|
+
// Printed by every command that writes to the build graph, before it
|
|
210
|
+
// writes. DB_PATH is relative (`.hedgehog/hedgehog.db`), so which
|
|
211
|
+
// database a command actually opens depends entirely on the directory it
|
|
212
|
+
// was run from — a recovery command run one directory up silently opens
|
|
213
|
+
// (or creates) a different database, matches nothing, and reports
|
|
214
|
+
// success. Naming the absolute path on every write makes that visible in
|
|
215
|
+
// the output instead of only in the outcome.
|
|
216
|
+
function printDbTarget() {
|
|
217
|
+
console.log(` ${dim('db')} ${dbAbsPath()}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
190
220
|
// Runs once at the top of every command that needs the build graph. A
|
|
191
221
|
// fresh clone has no `.hedgehog/hedgehog.db` (it's a derived artifact,
|
|
192
222
|
// not committed) but does have `.hedgehog/intents/*.json` — the committed
|
|
@@ -196,7 +226,11 @@ const exists = (p) =>
|
|
|
196
226
|
// there's nothing to rebuild from either (a genuinely fresh project, no
|
|
197
227
|
// intents yet), this no-ops and leaves the DB missing — the caller's own
|
|
198
228
|
// existing "No build graph found" guard still fires for that case.
|
|
199
|
-
|
|
229
|
+
// `log` exists for the one caller whose stdout is a machine-consumable
|
|
230
|
+
// payload (`hedgehog boundary`): the rebuild notice is commentary, and
|
|
231
|
+
// commentary on stdout would corrupt a block meant to be captured or
|
|
232
|
+
// piped. Every other command leaves it at the default.
|
|
233
|
+
async function ensureDb({ log = console.log } = {}) {
|
|
200
234
|
if (await exists(DB_PATH)) return;
|
|
201
235
|
|
|
202
236
|
let intentFiles = [];
|
|
@@ -218,9 +252,25 @@ async function ensureDb() {
|
|
|
218
252
|
} finally {
|
|
219
253
|
db.close();
|
|
220
254
|
}
|
|
221
|
-
|
|
255
|
+
log(
|
|
222
256
|
`${dim('DB missing — rebuilt from')} ${bold(INTENTS_DIR)}${dim(':')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
|
|
223
257
|
);
|
|
258
|
+
warnRebuildDrift(result, corePath);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A rebuild re-derives every task from the current core.yaml and the
|
|
262
|
+
// committed intents. Nothing else is committed — a task row someone
|
|
263
|
+
// patched by hand has no source to replay from — so say plainly what a
|
|
264
|
+
// rebuild can and cannot carry, and surface any task that ends up
|
|
265
|
+
// disagreeing with core.yaml.
|
|
266
|
+
function warnRebuildDrift({ drift }, corePath) {
|
|
267
|
+
if (!drift || drift.length === 0) return;
|
|
268
|
+
console.log(
|
|
269
|
+
`${yellow(bold('Core drift after rebuild.'))} ${drift.length} task(s) do not match ${bold(corePath)}.\n` +
|
|
270
|
+
`A rebuild replays ${bold(INTENTS_DIR)} and re-derives tasks from core.yaml; task rows\n` +
|
|
271
|
+
'edited by hand have no committed source and are not replayed. Run\n' +
|
|
272
|
+
`${bold('hedgehog status')} for the divergence, ${bold('hedgehog plan --recompile')} to reconcile.\n`,
|
|
273
|
+
);
|
|
224
274
|
}
|
|
225
275
|
|
|
226
276
|
// Writes one planned file to disk — a straight copy, or for a `merge`
|
|
@@ -305,28 +355,49 @@ ${bold('Usage')}
|
|
|
305
355
|
npx @skyf0xx/hedgehog update refresh the installed agents + skills
|
|
306
356
|
npx @skyf0xx/hedgehog db init create .hedgehog/hedgehog.db if absent
|
|
307
357
|
npx @skyf0xx/hedgehog db rebuild re-derive the build graph from committed intents + git history
|
|
308
|
-
npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
|
|
309
|
-
|
|
358
|
+
npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
|
|
359
|
+
(starts no graph server; --no-open says so explicitly)
|
|
360
|
+
npx @skyf0xx/hedgehog plan --open also start the graph server and open it, if anything compiled
|
|
361
|
+
npx @skyf0xx/hedgehog plan --recompile rewrite core.yaml-derived fields on not-started tasks
|
|
362
|
+
[--dry-run] [--include-blocked] [--strict]
|
|
363
|
+
npx @skyf0xx/hedgehog override add <task-id> --scope <glob> [--scope <glob>...] --reason "<why>"
|
|
364
|
+
record a committed, additive-only scope exception for one task
|
|
365
|
+
npx @skyf0xx/hedgehog override list list recorded scope overrides
|
|
310
366
|
npx @skyf0xx/hedgehog intent add [flags] add an intent (rules/requirements/dependencies)
|
|
311
367
|
npx @skyf0xx/hedgehog intent add --file <path> add an intent from a JSON file
|
|
312
368
|
npx @skyf0xx/hedgehog next print the task packet for one ready task
|
|
369
|
+
npx @skyf0xx/hedgehog show <task-id> print the task packet for any task, at any status
|
|
313
370
|
npx @skyf0xx/hedgehog claim --owner <owner> [--count <n>] atomically claim up to n ready tasks
|
|
371
|
+
npx @skyf0xx/hedgehog claim <task-id> --owner <owner> claim one specific task (breaks a starvation tie)
|
|
372
|
+
npx @skyf0xx/hedgehog retry <task-id> return a blocked task to planned, so it can be rebuilt
|
|
314
373
|
npx @skyf0xx/hedgehog release <task-id> --owner <owner> hand a claimed task back to ready
|
|
315
374
|
npx @skyf0xx/hedgehog renew <task-id> --owner <owner> [--minutes <n>] extend a held lease
|
|
316
375
|
npx @skyf0xx/hedgehog verify <task-id> --owner <owner> run scope + verify checks, commit on pass
|
|
317
|
-
npx @skyf0xx/hedgehog status graph overview: counts by status, ready list, in flight
|
|
376
|
+
npx @skyf0xx/hedgehog status graph overview: counts by status, ready list, in flight,
|
|
377
|
+
and any drift from core.yaml
|
|
318
378
|
npx @skyf0xx/hedgehog ready preview which ready tasks are claimable now vs held back
|
|
319
379
|
npx @skyf0xx/hedgehog quiesce report whether anything is still in flight
|
|
380
|
+
npx @skyf0xx/hedgehog boundary is this a moment to clear context? exits 0 only if it is,
|
|
381
|
+
and prints what a fresh session picks up next
|
|
382
|
+
npx @skyf0xx/hedgehog boundary --handoff print the block a fresh session starts from
|
|
383
|
+
npx @skyf0xx/hedgehog boundary --quiet exit code only, for a shell hook
|
|
320
384
|
npx @skyf0xx/hedgehog graph start (or reuse) the live graph server and open it
|
|
321
385
|
npx @skyf0xx/hedgehog graph --no-open start (or reuse) the server; print the URL instead
|
|
322
386
|
npx @skyf0xx/hedgehog why <path> provenance chain for a file
|
|
323
387
|
npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
|
|
324
388
|
npx @skyf0xx/hedgehog friction list list logged friction, oldest first
|
|
389
|
+
npx @skyf0xx/hedgehog debt add <task-id> "<note>" declare debt that lands in dependent tasks' packets
|
|
390
|
+
npx @skyf0xx/hedgehog debt list [<task-id>] list declared debt, oldest first
|
|
325
391
|
npx @skyf0xx/hedgehog --help
|
|
326
392
|
|
|
327
393
|
Available cores: ${cores.join(', ')}
|
|
328
394
|
Available hosts: ${availableHosts().join(', ')} (default: ${DEFAULT_HOST})
|
|
329
395
|
|
|
396
|
+
Every command that writes to the build graph prints the absolute path of
|
|
397
|
+
the database it opened, and exits non-zero when it matched no task —
|
|
398
|
+
which database a relative path resolves to depends on the directory you
|
|
399
|
+
ran from.
|
|
400
|
+
|
|
330
401
|
After it runs, commit the payload, open your coding agent, and describe
|
|
331
402
|
what you want to build — the planner agent runs planning intake, then
|
|
332
403
|
hands off to bootstrap.
|
|
@@ -423,8 +494,17 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
|
|
|
423
494
|
);
|
|
424
495
|
console.log('Next steps:');
|
|
425
496
|
if (explicitCore) {
|
|
426
|
-
|
|
427
|
-
|
|
497
|
+
// `pnpm install` before the first commit, not after it. The core's
|
|
498
|
+
// commit gate is lefthook, and lefthook's hooks are written by its
|
|
499
|
+
// own postinstall — so a commit made before the install is a commit
|
|
500
|
+
// made with no gate at all, and the instruction that put it first
|
|
501
|
+
// was quietly teaching the project to skip its own discipline on
|
|
502
|
+
// the one commit that lands the entire workspace. Installing first
|
|
503
|
+
// means commit #1 is already gated; with no HEAD to diff against it
|
|
504
|
+
// runs the whole workspace (see lefthook.yml), so expect it to take
|
|
505
|
+
// as long as a full typecheck/lint/test — that is the gate working.
|
|
506
|
+
console.log(` 1. ${bold('pnpm install')}`);
|
|
507
|
+
console.log(` 2. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
|
|
428
508
|
console.log(` 3. Open ${HOSTS[host].label} and describe what you want to build.`);
|
|
429
509
|
} else {
|
|
430
510
|
console.log(` 1. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
|
|
@@ -520,6 +600,7 @@ async function dbRebuildCommand() {
|
|
|
520
600
|
return;
|
|
521
601
|
}
|
|
522
602
|
|
|
603
|
+
printDbTarget();
|
|
523
604
|
await dbInit(DB_PATH);
|
|
524
605
|
const db = openDb();
|
|
525
606
|
let result;
|
|
@@ -532,6 +613,7 @@ async function dbRebuildCommand() {
|
|
|
532
613
|
console.log(
|
|
533
614
|
`${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
|
|
534
615
|
);
|
|
616
|
+
warnRebuildDrift(result, corePath);
|
|
535
617
|
}
|
|
536
618
|
|
|
537
619
|
async function dbCommand(args) {
|
|
@@ -547,6 +629,7 @@ async function dbCommand(args) {
|
|
|
547
629
|
process.exitCode = 1;
|
|
548
630
|
return;
|
|
549
631
|
}
|
|
632
|
+
printDbTarget();
|
|
550
633
|
const { created, path } = await dbInit(DB_PATH);
|
|
551
634
|
console.log(
|
|
552
635
|
created
|
|
@@ -570,9 +653,84 @@ async function resolveCorePath() {
|
|
|
570
653
|
return null;
|
|
571
654
|
}
|
|
572
655
|
|
|
573
|
-
|
|
656
|
+
// `hedgehog plan --recompile` — the reconciliation path for a core.yaml
|
|
657
|
+
// edited after `plan` already compiled it.
|
|
658
|
+
//
|
|
659
|
+
// `plan` copies each layer's scope globs, verify command, commit message,
|
|
660
|
+
// exclusivity and verify radius onto every task row, and from then on the
|
|
661
|
+
// row is what `next`/`claim`/`verify` read. A plain re-run can't fix a
|
|
662
|
+
// later core.yaml correction — compiling an intent flips it to `active`,
|
|
663
|
+
// and `plan` only ever looks at `proposed`/`planned` ones — so before this
|
|
664
|
+
// existed the only remedy was an UPDATE in SQLite by hand.
|
|
665
|
+
//
|
|
666
|
+
// Deliberately not a fresh compile: it rewrites fields on tasks nothing
|
|
667
|
+
// has acted on yet and refuses the rest, so ids, dependencies, statuses
|
|
668
|
+
// and history are untouched.
|
|
669
|
+
async function planRecompileCommand(args, { core, corePath }) {
|
|
670
|
+
const includeBlocked = args.includes('--include-blocked');
|
|
671
|
+
const dryRun = args.includes('--dry-run');
|
|
672
|
+
const strict = args.includes('--strict');
|
|
673
|
+
const overrides = await loadOverrides();
|
|
674
|
+
|
|
675
|
+
const db = openDb();
|
|
676
|
+
let result;
|
|
677
|
+
try {
|
|
678
|
+
result = recompileTasks(db, core, { includeBlocked, dryRun, overrides });
|
|
679
|
+
} finally {
|
|
680
|
+
db.close();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (dryRun) console.log(dim(' (--dry-run — nothing was written)'));
|
|
684
|
+
console.log(formatRecompile(result));
|
|
685
|
+
|
|
686
|
+
if (result.updated.length === 0 && result.skipped.length === 0) {
|
|
687
|
+
console.log(`\n${green(bold('In sync.'))} Every task matches ${bold(corePath)}.\n`);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (result.skipped.length > 0) {
|
|
692
|
+
console.log(
|
|
693
|
+
`\n${yellow(bold('Some drift remains.'))} Each skipped task above names why it kept its compiled\n` +
|
|
694
|
+
'fields. A task already built, leased, or committed is a Correction Protocol\n' +
|
|
695
|
+
'case (fix the layer at its source, re-run that layer), not a field rewrite;\n' +
|
|
696
|
+
'a changed depends_on or a layer dropped from core.yaml is a graph-shape\n' +
|
|
697
|
+
'change, which --recompile deliberately does not make.\n',
|
|
698
|
+
);
|
|
699
|
+
} else {
|
|
700
|
+
console.log(`\n${green(bold('Recompiled.'))}\n`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (strict && result.skipped.length > 0) process.exitCode = 1;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// `hedgehog plan [--open|--no-open]` — compiles pending intents.
|
|
707
|
+
//
|
|
708
|
+
// Starting the live graph server is opt-in (`--open`), not automatic.
|
|
709
|
+
// Hedgehog's primary caller is an agent in a headless session: there is
|
|
710
|
+
// no browser for `openInBrowser` to hand the URL to, so the old
|
|
711
|
+
// unconditional `startOrReuseGraphServer()` bought nothing and cost a
|
|
712
|
+
// detached `node` process left listening on 127.0.0.1 with an open
|
|
713
|
+
// handle on the build graph, plus a `.hedgehog/graph-server.json`
|
|
714
|
+
// pidfile outliving the command that wrote it. `hedgehog graph` is the
|
|
715
|
+
// one command whose *purpose* is that server; `plan`'s purpose is
|
|
716
|
+
// compiling the graph, and it now exits having started nothing.
|
|
717
|
+
//
|
|
718
|
+
// `--no-open` is accepted for symmetry with `hedgehog graph` and names
|
|
719
|
+
// the default explicitly. It differs from `graph --no-open` in one way
|
|
720
|
+
// that follows from the same principle: on `graph` the server is the
|
|
721
|
+
// point and only the browser is suppressed, whereas on `plan` the
|
|
722
|
+
// server exists solely to be opened, so suppressing the open leaves
|
|
723
|
+
// nothing worth serving — no server, no pidfile, no orphan.
|
|
724
|
+
async function planCommand(args = []) {
|
|
574
725
|
await ensureDb();
|
|
575
726
|
|
|
727
|
+
const wantsOpen = args.includes('--open');
|
|
728
|
+
if (wantsOpen && args.includes('--no-open')) {
|
|
729
|
+
console.error(`${red('Usage:')} hedgehog plan takes --open or --no-open, not both\n`);
|
|
730
|
+
process.exitCode = 1;
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
576
734
|
const corePath = await resolveCorePath();
|
|
577
735
|
if (!corePath) {
|
|
578
736
|
console.error(
|
|
@@ -588,32 +746,72 @@ async function planCommand() {
|
|
|
588
746
|
return;
|
|
589
747
|
}
|
|
590
748
|
|
|
749
|
+
printDbTarget();
|
|
591
750
|
const core = await loadCore(corePath);
|
|
751
|
+
|
|
752
|
+
if (args.includes('--recompile')) {
|
|
753
|
+
await planRecompileCommand(args, { core, corePath });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const overrides = await loadOverrides();
|
|
592
758
|
const db = openDb();
|
|
593
759
|
let result;
|
|
594
760
|
try {
|
|
595
|
-
result = planTasks(db, core);
|
|
761
|
+
result = planTasks(db, core, overrides);
|
|
596
762
|
} finally {
|
|
597
763
|
db.close();
|
|
598
764
|
}
|
|
599
765
|
|
|
766
|
+
for (const id of result.once) {
|
|
767
|
+
console.log(` ${green('compiled')} ${id} ${dim('(once — one task for the whole build)')}`);
|
|
768
|
+
}
|
|
600
769
|
for (const id of result.compiled) console.log(` ${green('compiled')} ${id}`);
|
|
601
770
|
for (const id of result.skipped) console.log(` ${dim('skipped')} ${id} ${dim('(already compiled)')}`);
|
|
771
|
+
for (const id of result.reopened) {
|
|
772
|
+
console.log(
|
|
773
|
+
` ${bold('reopened')} ${id} ${dim('(once — new scope landed under it; it must run again)')}`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
602
776
|
console.log(
|
|
603
777
|
`\n${green(bold('Plan complete.'))} ${dim(`${result.compiled.length} intent(s) compiled, ${result.skipped.length} skipped`)}\n`,
|
|
604
778
|
);
|
|
605
779
|
|
|
780
|
+
// A plain `plan` is where someone lands after editing core.yaml,
|
|
781
|
+
// expecting the edit to take. It won't — already-compiled tasks carry
|
|
782
|
+
// their own copy of the layer's fields — so say so here rather than
|
|
783
|
+
// letting the run report "0 compiled" and look like a no-op.
|
|
784
|
+
const driftDb = openDb({ readOnly: true });
|
|
785
|
+
let drifted;
|
|
786
|
+
try {
|
|
787
|
+
drifted = detectDrift(driftDb, core, { overrides });
|
|
788
|
+
} finally {
|
|
789
|
+
driftDb.close();
|
|
790
|
+
}
|
|
791
|
+
if (drifted.length > 0) {
|
|
792
|
+
console.log(
|
|
793
|
+
`${yellow(bold('Core drift.'))} ${drifted.length} already-compiled task(s) no longer match ${bold(corePath)}.\n` +
|
|
794
|
+
`Compiling does not revisit them. Run ${bold('hedgehog status')} to see the divergence,\n` +
|
|
795
|
+
`or ${bold('hedgehog plan --recompile')} to rewrite the not-started ones.\n`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
606
799
|
// Only worth opening when this run actually changed the graph's shape
|
|
607
800
|
// — a plan run that compiled nothing (every intent already had tasks)
|
|
608
|
-
// would just re-open what's already open.
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const { port } = await startOrReuseGraphServer();
|
|
615
|
-
openInBrowser(`http://localhost:${port}`);
|
|
801
|
+
// would just re-open what's already open.
|
|
802
|
+
if (result.compiled.length === 0) return;
|
|
803
|
+
|
|
804
|
+
if (!wantsOpen) {
|
|
805
|
+
console.log(`${dim('Run')} ${bold('hedgehog graph')} ${dim('to view the build graph.')}\n`);
|
|
806
|
+
return;
|
|
616
807
|
}
|
|
808
|
+
|
|
809
|
+
// planTasks's own db handle is closed by this point: the graph server
|
|
810
|
+
// opens its own connection in a separate process, and holding two
|
|
811
|
+
// write-capable handles on the same sqlite file across that handoff
|
|
812
|
+
// invites lock contention for no benefit.
|
|
813
|
+
const { port } = await startOrReuseGraphServer();
|
|
814
|
+
presentGraphUrl(`http://localhost:${port}`);
|
|
617
815
|
}
|
|
618
816
|
|
|
619
817
|
// Parses `hedgehog intent add` args into the same record shape
|
|
@@ -707,6 +905,7 @@ async function intentCommand(args) {
|
|
|
707
905
|
return;
|
|
708
906
|
}
|
|
709
907
|
|
|
908
|
+
printDbTarget();
|
|
710
909
|
const db = openDb();
|
|
711
910
|
let intent;
|
|
712
911
|
try {
|
|
@@ -752,8 +951,11 @@ async function nextCommand() {
|
|
|
752
951
|
const reason = BLOCKED_REASON_LABELS[task.blocked_reason] ?? task.blocked_reason;
|
|
753
952
|
console.error(` ${red('✗')} ${bold(task.id)} ${task.layer} ${dim(reason)}`);
|
|
754
953
|
}
|
|
954
|
+
// `verify` refuses anything that isn't `building` and leased to
|
|
955
|
+
// the caller, so a blocked task has to go back through the queue
|
|
956
|
+
// — retry, claim, then verify — rather than straight to verify.
|
|
755
957
|
console.error(
|
|
756
|
-
`\nFix the work, then
|
|
958
|
+
`\nFix the work, then ${bold('hedgehog retry <task-id>')} and claim it again.\n`,
|
|
757
959
|
);
|
|
758
960
|
process.exitCode = 1;
|
|
759
961
|
return;
|
|
@@ -765,6 +967,66 @@ async function nextCommand() {
|
|
|
765
967
|
console.log(formatNext(packet));
|
|
766
968
|
}
|
|
767
969
|
|
|
970
|
+
// Mirrors, read-only, the condition verifyTask's own Phase 0
|
|
971
|
+
// (claimForVerify) enforces: after expired leases are reaped, the task
|
|
972
|
+
// must be `building` and leased to `owner`. The expiry predicate is
|
|
973
|
+
// claim.mjs#reapExpiredLeases's, kept identical so this can't disagree
|
|
974
|
+
// with the check it's standing in for. Writes nothing and reaps nothing
|
|
975
|
+
// — the authoritative check, with its state changes, stays in verify.mjs.
|
|
976
|
+
const VERIFIABLE_BY_SQL = `
|
|
977
|
+
SELECT 1 FROM tasks
|
|
978
|
+
WHERE id = ? AND status = 'building' AND lease_owner = ?
|
|
979
|
+
AND (lease_expires_at IS NULL OR lease_expires_at >= datetime('now'))
|
|
980
|
+
`;
|
|
981
|
+
|
|
982
|
+
function taskVerifiableBy(db, taskId, owner) {
|
|
983
|
+
return db.prepare(VERIFIABLE_BY_SQL).get(taskId, owner) !== undefined;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// The declared-binary gate for one task: resolves the task's layer in
|
|
987
|
+
// the core definition and returns { layer, missing } when that layer's
|
|
988
|
+
// `requires:` names binaries this environment can't find, or null when
|
|
989
|
+
// there's nothing to say (no core, unknown task/layer, nothing missing).
|
|
990
|
+
//
|
|
991
|
+
// Runs ahead of verifyTask so the verify command is never handed to a
|
|
992
|
+
// shell that will answer `exit 127` with no context. Deliberately makes
|
|
993
|
+
// no state change: the task keeps its lease and its `building` status,
|
|
994
|
+
// so installing the binary and re-running `hedgehog verify` is the whole
|
|
995
|
+
// fix — no unblocking step, and no failed verification recorded for
|
|
996
|
+
// something that never ran.
|
|
997
|
+
//
|
|
998
|
+
// It only speaks for a caller who could actually verify this task. A
|
|
999
|
+
// caller holding no lease, or a stale one, has a different problem, and
|
|
1000
|
+
// telling them to install a binary would send them off to fix something
|
|
1001
|
+
// that isn't in their way — so this stays quiet and lets verifyTask
|
|
1002
|
+
// report the real ownership/state error. Nothing is lost by deferring:
|
|
1003
|
+
// claimForVerify throws before any verify command runs, so falling
|
|
1004
|
+
// through still records no failed verification.
|
|
1005
|
+
async function missingBinariesForTask(db, taskId, owner) {
|
|
1006
|
+
if (!taskVerifiableBy(db, taskId, owner)) return null;
|
|
1007
|
+
|
|
1008
|
+
const row = db.prepare('SELECT layer FROM tasks WHERE id = ?').get(taskId);
|
|
1009
|
+
if (row === undefined) return null;
|
|
1010
|
+
|
|
1011
|
+
const corePath = await resolveCorePath();
|
|
1012
|
+
if (!corePath) return null;
|
|
1013
|
+
|
|
1014
|
+
let core;
|
|
1015
|
+
try {
|
|
1016
|
+
core = await loadCore(corePath);
|
|
1017
|
+
} catch {
|
|
1018
|
+
// An unloadable core.yaml is `plan`'s error to report; don't turn it
|
|
1019
|
+
// into a confusing failure of an unrelated verify.
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const layer = core.layers.find((l) => l.id === row.layer);
|
|
1024
|
+
if (!layer) return null;
|
|
1025
|
+
|
|
1026
|
+
const missing = missingBinaries(layer.requires);
|
|
1027
|
+
return missing.length === 0 ? null : { layer: layer.id, missing };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
768
1030
|
async function verifyCommand(args) {
|
|
769
1031
|
await ensureDb();
|
|
770
1032
|
|
|
@@ -783,9 +1045,26 @@ async function verifyCommand(args) {
|
|
|
783
1045
|
return;
|
|
784
1046
|
}
|
|
785
1047
|
|
|
1048
|
+
printDbTarget();
|
|
786
1049
|
const db = openDb();
|
|
787
1050
|
let result;
|
|
788
1051
|
try {
|
|
1052
|
+
const blockers = await missingBinariesForTask(db, taskId, owner);
|
|
1053
|
+
if (blockers) {
|
|
1054
|
+
const list = blockers.missing.map((b) => bold(b)).join(', ');
|
|
1055
|
+
console.error(
|
|
1056
|
+
`${red(bold('Missing required binaries.'))} Task ${bold(taskId)} (layer ${bold(blockers.layer)}) was not verified.\n`,
|
|
1057
|
+
);
|
|
1058
|
+
console.error(`Not found on PATH: ${list}`);
|
|
1059
|
+
console.error(
|
|
1060
|
+
`${dim(`Declared by layer "${blockers.layer}" in core.yaml (requires:). The verify command was not run.`)}`,
|
|
1061
|
+
);
|
|
1062
|
+
console.error(
|
|
1063
|
+
`${dim('Install them, or put them on the PATH of the shell running hedgehog — an interactive login shell may see binaries a non-interactive one does not — then re-run this command.')}\n`,
|
|
1064
|
+
);
|
|
1065
|
+
process.exitCode = 1;
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
789
1068
|
result = verifyTask(db, taskId, owner);
|
|
790
1069
|
} catch (err) {
|
|
791
1070
|
console.error(`${red('Verify failed:')} ${err.message}\n`);
|
|
@@ -821,21 +1100,73 @@ async function verifyCommand(args) {
|
|
|
821
1100
|
if (result.intentComplete) {
|
|
822
1101
|
console.log(` ${green('intent complete')} ${dim('every task for this intent is done')}`);
|
|
823
1102
|
}
|
|
1103
|
+
|
|
1104
|
+
// The one comparison in the circuit. Each layer's verify_command runs
|
|
1105
|
+
// the tests that layer itself wrote, so it measures internal
|
|
1106
|
+
// consistency and never coverage of what was asked — a layer that
|
|
1107
|
+
// builds half an intent and tests that half exhaustively is green.
|
|
1108
|
+
// Closing the last layer is the moment the intent is claimed done, so
|
|
1109
|
+
// that is where what was requested gets printed back to be read against
|
|
1110
|
+
// what was built. It is not a machine-checkable gate; it cannot be. Its
|
|
1111
|
+
// value is that the comparison happens at all, once.
|
|
1112
|
+
if (result.completedIntent) {
|
|
1113
|
+
const { id, goal, outcome } = result.completedIntent;
|
|
1114
|
+
console.log('');
|
|
1115
|
+
console.log(`${bold('INTENT CHECK')} ${bold(id)} ${dim('— this closed the last layer of this intent.')}`);
|
|
1116
|
+
console.log(` ${dim('GOAL')} ${goal}`);
|
|
1117
|
+
console.log(` ${dim('OUTCOME')} ${outcome}`);
|
|
1118
|
+
console.log('');
|
|
1119
|
+
console.log(dim(' Confirm the work built across this intent\'s layers covers the above.'));
|
|
1120
|
+
console.log(dim(' Anything asked for and not built is a Correction Protocol case now,'));
|
|
1121
|
+
console.log(dim(' not a later discovery. Nothing else in the build checks this.'));
|
|
1122
|
+
console.log('');
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Prints the full task packet for each task in `tasks`, read back from
|
|
1127
|
+
// the graph after the claim landed. Claiming moves a task to `building`,
|
|
1128
|
+
// which takes it out of `hedgehog next`'s candidate set — so if claim
|
|
1129
|
+
// doesn't print the packet here, the STATUS/ALLOWED SCOPE/VERIFICATION an
|
|
1130
|
+
// agent is supposed to be dispatched with is no longer reachable from any
|
|
1131
|
+
// command. (`hedgehog show <task-id>` reprints it later.)
|
|
1132
|
+
function printPackets(tasks) {
|
|
1133
|
+
const db = openDb();
|
|
1134
|
+
try {
|
|
1135
|
+
for (const task of tasks) {
|
|
1136
|
+
const packet = taskPacket(db, task.id);
|
|
1137
|
+
if (!packet) continue;
|
|
1138
|
+
console.log();
|
|
1139
|
+
console.log(formatPacket(packet, taskStatusLine(packet.task)));
|
|
1140
|
+
}
|
|
1141
|
+
} finally {
|
|
1142
|
+
db.close();
|
|
1143
|
+
}
|
|
1144
|
+
console.log();
|
|
824
1145
|
}
|
|
825
1146
|
|
|
826
|
-
// `hedgehog claim --owner <owner> [--count <n>]` — atomically
|
|
827
|
-
// `count` mutually non-conflicting ready tasks (claimTasks's
|
|
828
|
-
// 13)
|
|
829
|
-
// holds
|
|
1147
|
+
// `hedgehog claim [<task-id>] --owner <owner> [--count <n>]` — atomically
|
|
1148
|
+
// claims up to `count` mutually non-conflicting ready tasks (claimTasks's
|
|
1149
|
+
// fan-out, item 13), or the one named task, and prints each claimed
|
|
1150
|
+
// task's full packet plus which owner now holds it.
|
|
830
1151
|
async function claimCommand(args) {
|
|
831
1152
|
await ensureDb();
|
|
832
1153
|
|
|
1154
|
+
// A leading non-flag argument names one specific task; without it this
|
|
1155
|
+
// is the fan-out claim it has always been.
|
|
1156
|
+
const taskId = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
|
|
833
1157
|
const ownerIdx = args.indexOf('--owner');
|
|
834
1158
|
const owner = ownerIdx !== -1 ? args[ownerIdx + 1] : undefined;
|
|
835
1159
|
const countIdx = args.indexOf('--count');
|
|
836
1160
|
const count = countIdx !== -1 ? Number(args[countIdx + 1]) : 1;
|
|
837
1161
|
if (!owner) {
|
|
838
|
-
console.error(
|
|
1162
|
+
console.error(
|
|
1163
|
+
`${red('Usage:')} hedgehog claim --owner <owner> [--count <n>]\n or: hedgehog claim <task-id> --owner <owner>\n`,
|
|
1164
|
+
);
|
|
1165
|
+
process.exitCode = 1;
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (taskId && countIdx !== -1) {
|
|
1169
|
+
console.error(`${red('--count cannot be combined with a task id')} — a targeted claim takes exactly one task.\n`);
|
|
839
1170
|
process.exitCode = 1;
|
|
840
1171
|
return;
|
|
841
1172
|
}
|
|
@@ -846,6 +1177,13 @@ async function claimCommand(args) {
|
|
|
846
1177
|
return;
|
|
847
1178
|
}
|
|
848
1179
|
|
|
1180
|
+
printDbTarget();
|
|
1181
|
+
|
|
1182
|
+
if (taskId) {
|
|
1183
|
+
await claimOneCommand(taskId, owner);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
849
1187
|
const db = openDb();
|
|
850
1188
|
let claimed;
|
|
851
1189
|
try {
|
|
@@ -868,6 +1206,150 @@ async function claimCommand(args) {
|
|
|
868
1206
|
if (claimed.length > 1) console.log(` ${bold(task.id)}`);
|
|
869
1207
|
console.log(` ${dim('expires')} ${task.lease_expires_at}`);
|
|
870
1208
|
}
|
|
1209
|
+
printPackets(claimed);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// The targeted half of `claim`: one named task, or a non-zero exit
|
|
1213
|
+
// naming which precondition refused it. Never exits 0 without having
|
|
1214
|
+
// claimed something — an operator breaking a starvation tie has to be
|
|
1215
|
+
// able to tell "claimed" from "matched nothing" by exit code alone.
|
|
1216
|
+
async function claimOneCommand(taskId, owner) {
|
|
1217
|
+
const db = openDb();
|
|
1218
|
+
let result;
|
|
1219
|
+
try {
|
|
1220
|
+
result = claimTask(db, taskId, { owner });
|
|
1221
|
+
} finally {
|
|
1222
|
+
db.close();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (result.claimed) {
|
|
1226
|
+
console.log(`${green(bold('Claimed.'))} Task ${bold(taskId)} leased to ${bold(owner)}.`);
|
|
1227
|
+
console.log(` ${dim('expires')} ${result.task.lease_expires_at}`);
|
|
1228
|
+
printPackets([result.task]);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
process.exitCode = 1;
|
|
1233
|
+
|
|
1234
|
+
if (result.reason === 'no_such_task') {
|
|
1235
|
+
console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (result.reason === 'not_claimable') {
|
|
1239
|
+
const held = result.task.lease_owner ? `, leased to ${result.task.lease_owner}` : '';
|
|
1240
|
+
console.error(
|
|
1241
|
+
`${red('Not claimable.')} Task ${bold(taskId)} is ${bold(result.task.status)}${held}.\n` +
|
|
1242
|
+
(result.task.status === 'blocked'
|
|
1243
|
+
? `\nReturn it to the queue first: ${bold(`hedgehog retry ${taskId}`)}\n`
|
|
1244
|
+
: '\n'),
|
|
1245
|
+
);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
if (result.reason === 'incomplete_dependencies') {
|
|
1249
|
+
console.error(`${red('Not claimable.')} Task ${bold(taskId)} is waiting on:\n`);
|
|
1250
|
+
for (const dep of result.incomplete) {
|
|
1251
|
+
console.error(` ${red('✗')} ${bold(dep.id)} ${dep.layer} ${dep.status}`);
|
|
1252
|
+
}
|
|
1253
|
+
console.error();
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (result.reason === 'conflict') {
|
|
1257
|
+
console.error(`${red('Not claimable.')} Task ${bold(taskId)} conflicts with work in flight:\n`);
|
|
1258
|
+
for (const { task, kind } of result.conflicting) {
|
|
1259
|
+
console.error(` ${red('✗')} ${bold(task.id)} ${task.status} ${dim(`(${kind})`)}`);
|
|
1260
|
+
}
|
|
1261
|
+
console.error(
|
|
1262
|
+
`\nWait for those to finish, or ${bold('hedgehog release')} them, then claim again.\n`,
|
|
1263
|
+
);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
console.error(`${red('Not claimed.')} A concurrent claim took ${bold(taskId)} first.\n`);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// `hedgehog retry <task-id>` — the transition out of `blocked`, for any
|
|
1270
|
+
// blocked_reason. A failed verification and a scope violation are the
|
|
1271
|
+
// loop's normal failure cases, but `release` only accepts `building` and
|
|
1272
|
+
// `verify` only accepts a task leased to the caller, so before this
|
|
1273
|
+
// command a blocked task had no CLI path back into the queue at all.
|
|
1274
|
+
async function retryCommand(args) {
|
|
1275
|
+
await ensureDb();
|
|
1276
|
+
|
|
1277
|
+
const taskId = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
|
|
1278
|
+
if (!taskId) {
|
|
1279
|
+
console.error(`${red('Usage:')} hedgehog retry <task-id> [--owner <owner>]\n`);
|
|
1280
|
+
process.exitCode = 1;
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (!(await exists(DB_PATH))) {
|
|
1285
|
+
console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
|
|
1286
|
+
process.exitCode = 1;
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
printDbTarget();
|
|
1291
|
+
|
|
1292
|
+
const db = openDb();
|
|
1293
|
+
let result;
|
|
1294
|
+
try {
|
|
1295
|
+
result = retryTask(db, taskId);
|
|
1296
|
+
} finally {
|
|
1297
|
+
db.close();
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (!result.retried) {
|
|
1301
|
+
process.exitCode = 1;
|
|
1302
|
+
if (result.reason === 'no_such_task') {
|
|
1303
|
+
console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
console.error(
|
|
1307
|
+
`${red('Not retried.')} Task ${bold(taskId)} is ${bold(result.task.status)}, not ${bold('blocked')}.\n`,
|
|
1308
|
+
);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const reason = BLOCKED_REASON_LABELS[result.from] ?? result.from;
|
|
1313
|
+
console.log(
|
|
1314
|
+
`${green(bold('Retried.'))} Task ${bold(taskId)} is back to ${bold('planned')} ${dim(`(was blocked: ${reason})`)}`,
|
|
1315
|
+
);
|
|
1316
|
+
console.log(` ${dim('claim it with')} hedgehog claim ${taskId} --owner <owner>`);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// `hedgehog show <task-id>` — the same packet `next` prints, for a task
|
|
1320
|
+
// named by id whatever its status. Read-only: claims nothing, changes
|
|
1321
|
+
// nothing.
|
|
1322
|
+
async function showCommand(args) {
|
|
1323
|
+
await ensureDb();
|
|
1324
|
+
|
|
1325
|
+
const taskId = args[0];
|
|
1326
|
+
if (!taskId) {
|
|
1327
|
+
console.error(`${red('Usage:')} hedgehog show <task-id>\n`);
|
|
1328
|
+
process.exitCode = 1;
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (!(await exists(DB_PATH))) {
|
|
1333
|
+
console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
|
|
1334
|
+
process.exitCode = 1;
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const db = openDb();
|
|
1339
|
+
let packet;
|
|
1340
|
+
try {
|
|
1341
|
+
packet = taskPacket(db, taskId);
|
|
1342
|
+
} finally {
|
|
1343
|
+
db.close();
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
if (!packet) {
|
|
1347
|
+
console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
|
|
1348
|
+
process.exitCode = 1;
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
console.log(formatPacket(packet, taskStatusLine(packet.task)));
|
|
871
1353
|
}
|
|
872
1354
|
|
|
873
1355
|
// `hedgehog release <task-id> --owner <owner>` — hands a claimed task
|
|
@@ -891,6 +1373,8 @@ async function releaseCommand(args) {
|
|
|
891
1373
|
return;
|
|
892
1374
|
}
|
|
893
1375
|
|
|
1376
|
+
printDbTarget();
|
|
1377
|
+
|
|
894
1378
|
const db = openDb();
|
|
895
1379
|
let result;
|
|
896
1380
|
try {
|
|
@@ -900,8 +1384,19 @@ async function releaseCommand(args) {
|
|
|
900
1384
|
}
|
|
901
1385
|
|
|
902
1386
|
if (!result.released) {
|
|
903
|
-
console.error(`${red('Not released.')} Task ${bold(taskId)} is not leased to ${bold(owner)} as ${bold('building')}.\n`);
|
|
904
1387
|
process.exitCode = 1;
|
|
1388
|
+
if (result.reason === 'no_such_task') {
|
|
1389
|
+
console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
console.error(
|
|
1393
|
+
`${red('Not released.')} Task ${bold(taskId)} is ${bold(result.task.status)}${
|
|
1394
|
+
result.task.lease_owner ? `, leased to ${bold(result.task.lease_owner)}` : ''
|
|
1395
|
+
} — not ${bold('building')} under ${bold(owner)}.\n` +
|
|
1396
|
+
(result.task.status === 'blocked'
|
|
1397
|
+
? `\nReturn a blocked task to the queue with: ${bold(`hedgehog retry ${taskId}`)}\n`
|
|
1398
|
+
: ''),
|
|
1399
|
+
);
|
|
905
1400
|
return;
|
|
906
1401
|
}
|
|
907
1402
|
|
|
@@ -930,6 +1425,8 @@ async function renewCommand(args) {
|
|
|
930
1425
|
return;
|
|
931
1426
|
}
|
|
932
1427
|
|
|
1428
|
+
printDbTarget();
|
|
1429
|
+
|
|
933
1430
|
const db = openDb();
|
|
934
1431
|
let result;
|
|
935
1432
|
try {
|
|
@@ -939,14 +1436,50 @@ async function renewCommand(args) {
|
|
|
939
1436
|
}
|
|
940
1437
|
|
|
941
1438
|
if (!result.renewed) {
|
|
942
|
-
console.error(`${red('Not renewed.')} Task ${bold(taskId)} is not leased to ${bold(owner)}.\n`);
|
|
943
1439
|
process.exitCode = 1;
|
|
1440
|
+
if (result.reason === 'no_such_task') {
|
|
1441
|
+
console.error(`${red('No such task:')} ${bold(taskId)}${dim(` (in ${dbAbsPath()})`)}\n`);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
console.error(
|
|
1445
|
+
`${red('Not renewed.')} Task ${bold(taskId)} is ${bold(result.task.status)}${
|
|
1446
|
+
result.task.lease_owner ? `, leased to ${bold(result.task.lease_owner)}` : ''
|
|
1447
|
+
} — not leased to ${bold(owner)}.\n`,
|
|
1448
|
+
);
|
|
944
1449
|
return;
|
|
945
1450
|
}
|
|
946
1451
|
|
|
947
1452
|
console.log(`${green(bold('Renewed.'))} Task ${bold(taskId)}'s lease extended by ${minutes} minute(s).`);
|
|
948
1453
|
}
|
|
949
1454
|
|
|
1455
|
+
// Heuristic problems with the project's own core definition, rendered for
|
|
1456
|
+
// `hedgehog status`. Surfaced here because status is the command every
|
|
1457
|
+
// session starts with, and a core-definition smell is a build-wide fact
|
|
1458
|
+
// rather than one task's: a layer whose verify command doesn't reach the
|
|
1459
|
+
// verify_radius it declared lets a task break a neighbour and still
|
|
1460
|
+
// commit green, and nothing later in the loop will say so. Warnings only
|
|
1461
|
+
// — the certainties throw from validateCore, on load.
|
|
1462
|
+
async function coreWarningLines() {
|
|
1463
|
+
const corePath = await resolveCorePath();
|
|
1464
|
+
if (!corePath) return [];
|
|
1465
|
+
const label = relative(DEST_ROOT, corePath) || corePath;
|
|
1466
|
+
let core;
|
|
1467
|
+
try {
|
|
1468
|
+
core = await loadCore(corePath);
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
// Every other command that loads the core fails outright on this; say
|
|
1471
|
+
// so here rather than letting `hedgehog status` die with a stack.
|
|
1472
|
+
return ['', `${red('CORE')} ${bold(label)} is invalid: ${err.message}`];
|
|
1473
|
+
}
|
|
1474
|
+
const warnings = lintCore(core);
|
|
1475
|
+
if (warnings.length === 0) return [];
|
|
1476
|
+
return [
|
|
1477
|
+
'',
|
|
1478
|
+
`${yellow('CORE WARNINGS')} ${dim(label)}`,
|
|
1479
|
+
...warnings.map((warning) => ` ${yellow('!')} ${warning}`),
|
|
1480
|
+
];
|
|
1481
|
+
}
|
|
1482
|
+
|
|
950
1483
|
async function statusCommand() {
|
|
951
1484
|
await ensureDb();
|
|
952
1485
|
|
|
@@ -956,15 +1489,57 @@ async function statusCommand() {
|
|
|
956
1489
|
return;
|
|
957
1490
|
}
|
|
958
1491
|
|
|
1492
|
+
// Drift needs the core definition to compare against. A project
|
|
1493
|
+
// without one yet (deferred install, pre-bootstrap) simply gets the
|
|
1494
|
+
// status it always got; an unparseable one is reported but never
|
|
1495
|
+
// allowed to take `status` down — it's the command every session
|
|
1496
|
+
// starts with.
|
|
1497
|
+
let core = null;
|
|
1498
|
+
const corePath = await resolveCorePath();
|
|
1499
|
+
if (corePath) {
|
|
1500
|
+
try {
|
|
1501
|
+
core = await loadCore(corePath);
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
console.error(
|
|
1504
|
+
`${yellow('Core definition unreadable:')} ${corePath} — ${err.message}\n${dim('Drift against core.yaml cannot be checked.')}\n`,
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const overrides = core ? await loadOverrides() : new Map();
|
|
959
1510
|
const db = openDb();
|
|
960
1511
|
let result;
|
|
961
1512
|
try {
|
|
962
|
-
result = graphStatus(db);
|
|
1513
|
+
result = graphStatus(db, { core, overrides });
|
|
963
1514
|
} finally {
|
|
964
1515
|
db.close();
|
|
965
1516
|
}
|
|
966
1517
|
|
|
1518
|
+
// Declared-binary check (core.yaml's `requires:`) rides on `status`
|
|
1519
|
+
// because status is what a fresh session runs first — that's the point
|
|
1520
|
+
// at which "this core needs terraform" is a setup fact rather than an
|
|
1521
|
+
// opaque exit 127 twenty minutes into the build. Reuses the `core`
|
|
1522
|
+
// already loaded above for drift — a project with no core definition
|
|
1523
|
+
// yet, or one that failed to parse (reported above already), has
|
|
1524
|
+
// nothing to check and reports nothing here either.
|
|
1525
|
+
if (core) result.missingRequirements = coreMissingRequirements(core);
|
|
1526
|
+
|
|
967
1527
|
console.log(formatStatus(result));
|
|
1528
|
+
|
|
1529
|
+
// Whether the commit gate is actually enforcing. This is reported at
|
|
1530
|
+
// the start of every session because a gate that isn't running looks
|
|
1531
|
+
// exactly like one that is — `.git/hooks` exists, `lefthook.yml` is
|
|
1532
|
+
// committed, and a passing commit prints nothing to tell the two
|
|
1533
|
+
// apart. Nothing else in the discipline would notice.
|
|
1534
|
+
const gate = await commitGateStatus(DEST_ROOT);
|
|
1535
|
+
const gateText = formatCommitGate(gate);
|
|
1536
|
+
if (gateText) {
|
|
1537
|
+
console.log('');
|
|
1538
|
+
console.log(gate.state === 'active' ? dim(gateText) : yellow(gateText));
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const warningLines = await coreWarningLines();
|
|
1542
|
+
if (warningLines.length > 0) console.log(warningLines.join('\n'));
|
|
968
1543
|
}
|
|
969
1544
|
|
|
970
1545
|
// `hedgehog ready` — read-only preview of what a `hedgehog claim` call
|
|
@@ -1024,6 +1599,62 @@ async function quiesceCommand() {
|
|
|
1024
1599
|
process.exitCode = 1;
|
|
1025
1600
|
}
|
|
1026
1601
|
|
|
1602
|
+
// `hedgehog boundary [--quiet] [--handoff]` — is this a good moment to
|
|
1603
|
+
// clear the conversation, and where would a fresh one pick up?
|
|
1604
|
+
//
|
|
1605
|
+
// `quiesce` answers one third of that question (nothing in flight).
|
|
1606
|
+
// This answers all of it: nothing in flight, a clean working tree, and a
|
|
1607
|
+
// last closed task that completed its intent — see boundary.mjs for how
|
|
1608
|
+
// each is derived. Exits 0 only when all three hold, so a shell hook can
|
|
1609
|
+
// consume it without reading anything.
|
|
1610
|
+
//
|
|
1611
|
+
// Output is split by consumer, not by importance:
|
|
1612
|
+
// stdout — the payload worth capturing: the NEXT/WHY positioning block,
|
|
1613
|
+
// or with --handoff the full block a fresh session starts from.
|
|
1614
|
+
// stderr — the verdict and the per-condition results, so a non-zero
|
|
1615
|
+
// exit always names which condition failed without that
|
|
1616
|
+
// commentary landing in a captured payload.
|
|
1617
|
+
// --quiet drops the commentary and the default payload, leaving the
|
|
1618
|
+
// exit code (and, if asked for explicitly, --handoff's block).
|
|
1619
|
+
//
|
|
1620
|
+
// Exit codes: 0 boundary reached; 1 not a boundary; 2 the question can't
|
|
1621
|
+
// be answered here (no build graph, no git repository, or a last closed
|
|
1622
|
+
// task that isn't identifiable).
|
|
1623
|
+
async function boundaryCommand(args) {
|
|
1624
|
+
const quiet = args.includes('--quiet');
|
|
1625
|
+
const handoff = args.includes('--handoff');
|
|
1626
|
+
|
|
1627
|
+
await ensureDb({ log: (msg) => console.error(msg) });
|
|
1628
|
+
|
|
1629
|
+
if (!(await exists(DB_PATH))) {
|
|
1630
|
+
if (!quiet) {
|
|
1631
|
+
console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
|
|
1632
|
+
}
|
|
1633
|
+
process.exitCode = 2;
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const db = openDb();
|
|
1638
|
+
let state;
|
|
1639
|
+
try {
|
|
1640
|
+
state = boundaryState(db);
|
|
1641
|
+
} finally {
|
|
1642
|
+
db.close();
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
if (!quiet) console.error(formatBoundary(state));
|
|
1646
|
+
if (handoff) console.log(formatHandoff(state));
|
|
1647
|
+
else if (!quiet && state.reached) console.log(formatPosition(state));
|
|
1648
|
+
|
|
1649
|
+
if (state.undecidable) {
|
|
1650
|
+
process.exitCode = 2;
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (!state.reached) {
|
|
1654
|
+
process.exitCode = 1;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1027
1658
|
const GRAPH_PIDFILE_PATH = '.hedgehog/graph-server.json';
|
|
1028
1659
|
const GRAPH_SERVER_MODULE = join(PKG_ROOT, 'src/db/graph-server.mjs');
|
|
1029
1660
|
const GRAPH_TEMPLATE_PATH = join(PKG_ROOT, 'src/templates/graph.html');
|
|
@@ -1040,6 +1671,28 @@ function openInBrowser(url) {
|
|
|
1040
1671
|
spawn(cmd, args, { detached: true, stdio: 'ignore', shell: platform === 'win32' }).unref();
|
|
1041
1672
|
}
|
|
1042
1673
|
|
|
1674
|
+
// True when there is plausibly a local display for a browser to open on.
|
|
1675
|
+
// macOS and Windows always have one; on Linux/BSD a session with neither
|
|
1676
|
+
// DISPLAY nor WAYLAND_DISPLAY set is headless (an SSH session, a
|
|
1677
|
+
// container, an agent's non-interactive shell), and `xdg-open` there
|
|
1678
|
+
// either fails silently or blocks on a text browser. Printing the URL is
|
|
1679
|
+
// the useful behaviour in that case — a person on the other end of a
|
|
1680
|
+
// port-forward can still open it.
|
|
1681
|
+
function hasDisplay() {
|
|
1682
|
+
if (process.platform === 'darwin' || process.platform === 'win32') return true;
|
|
1683
|
+
return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Hands `url` to the OS browser when that can work, and otherwise prints
|
|
1687
|
+
// it. One place, so `plan --open` and `graph` behave identically.
|
|
1688
|
+
function presentGraphUrl(url, { noOpen = false } = {}) {
|
|
1689
|
+
if (noOpen || !hasDisplay()) {
|
|
1690
|
+
console.log(`\nOpen ${bold(url)} in a browser to view it.`);
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
openInBrowser(url);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1043
1696
|
// True if `pid` names a live process. Sending signal 0 performs the
|
|
1044
1697
|
// existence/permission check without actually signalling anything — the
|
|
1045
1698
|
// standard POSIX idiom `kill -0` follows, and Node exposes it the same
|
|
@@ -1054,12 +1707,14 @@ function isProcessAlive(pid) {
|
|
|
1054
1707
|
}
|
|
1055
1708
|
|
|
1056
1709
|
// Returns the port of a running graph server for this project, starting
|
|
1057
|
-
// one if none is live. Both `plan
|
|
1058
|
-
//
|
|
1059
|
-
// server
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1710
|
+
// one if none is live. Both `plan --open` and `graph` call this rather
|
|
1711
|
+
// than each managing their own server, so a project only ever has one
|
|
1712
|
+
// live server no matter which command a person or agent happens to run —
|
|
1713
|
+
// re-running `plan --open` after `graph` is already open reuses the same
|
|
1714
|
+
// tab's server instead of spawning a second one bound to a different
|
|
1715
|
+
// port. Nothing else calls it: a command that isn't asked to open the
|
|
1716
|
+
// graph starts no server, so it can leave neither a pidfile nor a
|
|
1717
|
+
// detached process behind.
|
|
1063
1718
|
async function startOrReuseGraphServer() {
|
|
1064
1719
|
const pidfilePath = join(DEST_ROOT, GRAPH_PIDFILE_PATH);
|
|
1065
1720
|
|
|
@@ -1143,12 +1798,9 @@ async function graphCommand(args) {
|
|
|
1143
1798
|
// --no-open covers headless/SSH sessions where there's no local
|
|
1144
1799
|
// browser to hand a URL to — the server itself is still started (or
|
|
1145
1800
|
// reused) either way, since a remote person may open the URL manually
|
|
1146
|
-
// via port-forwarding.
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
} else {
|
|
1150
|
-
openInBrowser(url);
|
|
1151
|
-
}
|
|
1801
|
+
// via port-forwarding. presentGraphUrl reaches the same outcome
|
|
1802
|
+
// unasked when the session has no display at all.
|
|
1803
|
+
presentGraphUrl(url, { noOpen: args.includes('--no-open') });
|
|
1152
1804
|
}
|
|
1153
1805
|
|
|
1154
1806
|
async function whyCommand(args) {
|
|
@@ -1211,6 +1863,8 @@ async function frictionCommand(args) {
|
|
|
1211
1863
|
return;
|
|
1212
1864
|
}
|
|
1213
1865
|
|
|
1866
|
+
printDbTarget();
|
|
1867
|
+
|
|
1214
1868
|
const db = openDb();
|
|
1215
1869
|
let entry;
|
|
1216
1870
|
try {
|
|
@@ -1253,6 +1907,190 @@ async function frictionCommand(args) {
|
|
|
1253
1907
|
process.exitCode = 1;
|
|
1254
1908
|
}
|
|
1255
1909
|
|
|
1910
|
+
// `hedgehog override add <task-id> --scope <glob> [--scope <glob>...]
|
|
1911
|
+
// --reason "<why>"` / `hedgehog override list` — per-task scope
|
|
1912
|
+
// exceptions (see src/db/overrides.mjs). Writes
|
|
1913
|
+
// .hedgehog/overrides/<task-id>.json; the next `hedgehog plan` (for a
|
|
1914
|
+
// not-yet-compiled task) or `hedgehog plan --recompile` (for one already
|
|
1915
|
+
// compiled) is what actually widens the task's scope_globs — this command
|
|
1916
|
+
// only records the committed intent to do so.
|
|
1917
|
+
async function overrideCommand(args) {
|
|
1918
|
+
await ensureDb();
|
|
1919
|
+
|
|
1920
|
+
const sub = args[0];
|
|
1921
|
+
|
|
1922
|
+
if (sub === 'add') {
|
|
1923
|
+
const taskId = args[1];
|
|
1924
|
+
const rest = args.slice(2);
|
|
1925
|
+
const scopeAdd = [];
|
|
1926
|
+
let reason;
|
|
1927
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1928
|
+
const flag = rest[i];
|
|
1929
|
+
const value = rest[i + 1];
|
|
1930
|
+
if (flag === '--scope') {
|
|
1931
|
+
if (!value) {
|
|
1932
|
+
console.error(`${red('--scope requires a glob')}\n`);
|
|
1933
|
+
process.exitCode = 1;
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
scopeAdd.push(value);
|
|
1937
|
+
i++;
|
|
1938
|
+
} else if (flag === '--reason') {
|
|
1939
|
+
if (!value) {
|
|
1940
|
+
console.error(`${red('--reason requires text')}\n`);
|
|
1941
|
+
process.exitCode = 1;
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
reason = value;
|
|
1945
|
+
i++;
|
|
1946
|
+
} else {
|
|
1947
|
+
console.error(`${red('Unknown override flag:')} ${flag}\n`);
|
|
1948
|
+
process.exitCode = 1;
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
if (!taskId || scopeAdd.length === 0 || !reason) {
|
|
1954
|
+
console.error(
|
|
1955
|
+
`${red('Usage:')} hedgehog override add <task-id> --scope <glob> [--scope <glob>...] --reason "<why>"\n`,
|
|
1956
|
+
);
|
|
1957
|
+
process.exitCode = 1;
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
let record;
|
|
1962
|
+
try {
|
|
1963
|
+
record = await addOverride({ task: taskId, scope_add: scopeAdd, reason });
|
|
1964
|
+
} catch (err) {
|
|
1965
|
+
console.error(`${red('Failed to add override:')} ${err.message}\n`);
|
|
1966
|
+
process.exitCode = 1;
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
console.log(` ${green('added')} ${OVERRIDES_DIR}/${record.task.toLowerCase()}.json`);
|
|
1971
|
+
for (const glob of record.scope_add) console.log(` + ${glob}`);
|
|
1972
|
+
console.log(
|
|
1973
|
+
` ${dim(`run \`hedgehog plan --recompile\` to widen ${record.task} now, if it's already compiled`)}\n`,
|
|
1974
|
+
);
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
if (sub === 'list') {
|
|
1979
|
+
const overrides = await loadOverrides();
|
|
1980
|
+
if (overrides.size === 0) {
|
|
1981
|
+
console.log(`${dim('No overrides recorded.')}\n`);
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Best-effort: an override file can legitimately exist before `db
|
|
1986
|
+
// init`/`plan` has ever run (e.g. written ahead of the intent it
|
|
1987
|
+
// targets), so a missing DB just skips the orphan check rather than
|
|
1988
|
+
// failing the whole listing.
|
|
1989
|
+
let orphaned = [];
|
|
1990
|
+
if (await exists(DB_PATH)) {
|
|
1991
|
+
const db = openDb({ readOnly: true });
|
|
1992
|
+
try {
|
|
1993
|
+
orphaned = orphanedOverrides(db, overrides);
|
|
1994
|
+
} finally {
|
|
1995
|
+
db.close();
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
for (const [taskId, records] of overrides) {
|
|
2000
|
+
for (const record of records) {
|
|
2001
|
+
console.log(`${bold(taskId)}`);
|
|
2002
|
+
console.log(` ${dim(record.reason)}`);
|
|
2003
|
+
for (const glob of record.scope_add) console.log(` + ${glob}`);
|
|
2004
|
+
console.log('');
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
if (orphaned.length > 0) {
|
|
2009
|
+
console.log(
|
|
2010
|
+
`${yellow(bold('Orphaned:'))} ${orphaned.join(', ')} — no task with this id exists in the\n` +
|
|
2011
|
+
`build graph yet. A typo'd id, a renamed layer/module, or an intent that hasn't\n` +
|
|
2012
|
+
`compiled yet all look like this; it silently widens nothing until the id matches.\n`,
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
console.error(
|
|
2019
|
+
`${red('Unknown override subcommand:')} ${sub ?? '(none)'}\n\n` +
|
|
2020
|
+
`Usage: hedgehog override add <task-id> --scope <glob> [--scope <glob>...] --reason "<why>"\n` +
|
|
2021
|
+
` or: hedgehog override list\n`,
|
|
2022
|
+
);
|
|
2023
|
+
process.exitCode = 1;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// `hedgehog debt add <task-id> "<note>"` / `hedgehog debt list [<task-id>]`
|
|
2027
|
+
// — declared debt between tasks. A note recorded against a task is
|
|
2028
|
+
// rendered into the INHERITED DEBT section of the packet of every task
|
|
2029
|
+
// that depends on it (see src/db/debt.mjs and src/db/next.mjs).
|
|
2030
|
+
async function debtCommand(args) {
|
|
2031
|
+
await ensureDb();
|
|
2032
|
+
|
|
2033
|
+
const sub = args[0];
|
|
2034
|
+
|
|
2035
|
+
if (!(await exists(DB_PATH))) {
|
|
2036
|
+
console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
|
|
2037
|
+
process.exitCode = 1;
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
if (sub === 'add') {
|
|
2042
|
+
const taskId = args[1];
|
|
2043
|
+
const note = args.slice(2).join(' ');
|
|
2044
|
+
if (!taskId || !note) {
|
|
2045
|
+
console.error(`${red('Usage:')} hedgehog debt add <task-id> "<note>"\n`);
|
|
2046
|
+
process.exitCode = 1;
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const db = openDb();
|
|
2051
|
+
let entry;
|
|
2052
|
+
try {
|
|
2053
|
+
entry = addDebt(db, { taskId, note });
|
|
2054
|
+
} catch (err) {
|
|
2055
|
+
console.error(`${red('Failed to declare debt:')} ${err.message}\n`);
|
|
2056
|
+
process.exitCode = 1;
|
|
2057
|
+
return;
|
|
2058
|
+
} finally {
|
|
2059
|
+
db.close();
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
console.log(` ${green('declared')} #${entry.id} ${bold(entry.taskId)}`);
|
|
2063
|
+
console.log(` ${dim('reaches the packet of every task depending on it')}`);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
if (sub === 'list') {
|
|
2068
|
+
const taskId = args[1];
|
|
2069
|
+
const db = openDb();
|
|
2070
|
+
let entries;
|
|
2071
|
+
try {
|
|
2072
|
+
entries = listDebt(db, taskId);
|
|
2073
|
+
} finally {
|
|
2074
|
+
db.close();
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
if (entries.length === 0) {
|
|
2078
|
+
console.log(`${dim('No debt declared.')}\n`);
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
for (const entry of entries) {
|
|
2082
|
+
console.log(`#${entry.id} ${dim(entry.loggedAt)} ${bold(entry.taskId)}`);
|
|
2083
|
+
console.log(` ${entry.note}\n`);
|
|
2084
|
+
}
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
console.error(
|
|
2089
|
+
`${red('Unknown debt subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog debt add <task-id> "<note>"\n or: hedgehog debt list [<task-id>]\n`,
|
|
2090
|
+
);
|
|
2091
|
+
process.exitCode = 1;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
1256
2094
|
async function main() {
|
|
1257
2095
|
const args = process.argv.slice(2);
|
|
1258
2096
|
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
|
@@ -1322,7 +2160,7 @@ async function main() {
|
|
|
1322
2160
|
}
|
|
1323
2161
|
|
|
1324
2162
|
if (cmd === 'plan') {
|
|
1325
|
-
await planCommand();
|
|
2163
|
+
await planCommand(args.slice(1));
|
|
1326
2164
|
return;
|
|
1327
2165
|
}
|
|
1328
2166
|
|
|
@@ -1346,6 +2184,16 @@ async function main() {
|
|
|
1346
2184
|
return;
|
|
1347
2185
|
}
|
|
1348
2186
|
|
|
2187
|
+
if (cmd === 'show') {
|
|
2188
|
+
await showCommand(args.slice(1));
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
if (cmd === 'retry') {
|
|
2193
|
+
await retryCommand(args.slice(1));
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
1349
2197
|
if (cmd === 'release') {
|
|
1350
2198
|
await releaseCommand(args.slice(1));
|
|
1351
2199
|
return;
|
|
@@ -1371,6 +2219,11 @@ async function main() {
|
|
|
1371
2219
|
return;
|
|
1372
2220
|
}
|
|
1373
2221
|
|
|
2222
|
+
if (cmd === 'boundary') {
|
|
2223
|
+
await boundaryCommand(args.slice(1));
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
1374
2227
|
if (cmd === 'graph') {
|
|
1375
2228
|
await graphCommand(args.slice(1));
|
|
1376
2229
|
return;
|
|
@@ -1386,6 +2239,16 @@ async function main() {
|
|
|
1386
2239
|
return;
|
|
1387
2240
|
}
|
|
1388
2241
|
|
|
2242
|
+
if (cmd === 'debt') {
|
|
2243
|
+
await debtCommand(args.slice(1));
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
if (cmd === 'override') {
|
|
2248
|
+
await overrideCommand(args.slice(1));
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
1389
2252
|
console.error(`${red('Unknown command:')} ${cmd}\n`);
|
|
1390
2253
|
await help();
|
|
1391
2254
|
process.exitCode = 1;
|