@skyf0xx/hedgehog 4.3.1 → 4.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 +164 -8
- package/package.json +1 -1
- package/src/agents/planner.md +38 -6
- package/src/agents/ux-planner.md +29 -13
- package/src/db/core.mjs +10 -8
- package/src/db/next.mjs +72 -3
- package/src/db/rebuild.mjs +72 -14
- package/src/golden-cores/full-stack-app/apps/web/package.json +12 -0
- package/src/golden-cores/full-stack-app/apps/web/src/app/module-routes.ts +13 -0
- package/src/golden-cores/full-stack-app/apps/web/src/app/page.tsx +21 -1
- package/src/golden-cores/full-stack-app/nx.json +7 -3
- package/src/golden-cores/full-stack-app/packages/db/src/lib/db.ts +15 -1
- package/src/golden-cores/full-stack-app/tools/generate-module-routes.cjs +89 -0
- package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +59 -6
- package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +5 -1
- package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +75 -8
- package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +5 -1
- package/src/golden-cores/full-stack-app/tools/generators/fields.ts +38 -4
- package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +29 -12
- package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +1 -1
- package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +1 -1
- package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +82 -6
- package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +4 -0
- package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +26 -0
- package/src/skills/hedgehog-core-design/SKILL.md +9 -1
- package/src/skills/hedgehog-loop/SKILL.md +39 -7
- package/src/skills/hedgehog-planning-intake/SKILL.md +103 -6
- package/src/templates/CLAUDE.core.full-stack-app.md +7 -2
- package/src/templates/CLAUDE.md +21 -6
package/bin/cli.mjs
CHANGED
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
// npx @skyf0xx/hedgehog --help
|
|
14
14
|
|
|
15
15
|
import { cp, mkdir, access, readdir, stat, rm, readFile, writeFile } from 'node:fs/promises';
|
|
16
|
-
import { constants } from 'node:fs';
|
|
16
|
+
import { constants, existsSync } 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
20
|
import { dbInit, DB_PATH, dbAbsPath, openDb } from '../src/db/init.mjs';
|
|
21
|
-
import { loadCore, lintCore } from '../src/db/core.mjs';
|
|
22
|
-
import { planTasks } from '../src/db/plan.mjs';
|
|
21
|
+
import { loadCore, lintCore, isModuleAxis } from '../src/db/core.mjs';
|
|
22
|
+
import { planTasks, CORE_INTENT_ID } from '../src/db/plan.mjs';
|
|
23
23
|
import { addIntent, INTENTS_DIR } from '../src/db/intent.mjs';
|
|
24
24
|
import {
|
|
25
25
|
nextTask,
|
|
@@ -281,6 +281,26 @@ function warnRebuildDrift({ drift }, corePath) {
|
|
|
281
281
|
);
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// Debt and friction notes are operator-recorded and have no committed
|
|
285
|
+
// source, so a rebuild carries them across by task id. A note whose task
|
|
286
|
+
// is no longer in the recompiled graph — its intent file was renamed,
|
|
287
|
+
// deleted, or its layer sequence changed — has nowhere to re-attach.
|
|
288
|
+
// Friction notes survive unattached (their task_id is nullable); debt
|
|
289
|
+
// notes are lost, so both are printed with their text rather than
|
|
290
|
+
// disappearing into a count.
|
|
291
|
+
function warnOrphanedNotes({ orphanedNotes }) {
|
|
292
|
+
if (!orphanedNotes || orphanedNotes.length === 0) return;
|
|
293
|
+
console.log(
|
|
294
|
+
`${yellow(bold('Notes without a task after rebuild.'))} ${orphanedNotes.length} note(s) referenced a\n` +
|
|
295
|
+
'task the recompiled graph no longer holds. Friction notes were kept unattached;\n' +
|
|
296
|
+
'debt notes could not be, so they are reproduced here:\n',
|
|
297
|
+
);
|
|
298
|
+
for (const note of orphanedNotes) {
|
|
299
|
+
console.log(` ${dim(note.kind)} ${bold(note.taskId)} ${note.note}`);
|
|
300
|
+
}
|
|
301
|
+
console.log('');
|
|
302
|
+
}
|
|
303
|
+
|
|
284
304
|
// Writes one planned file to disk — a straight copy, or for a `merge`
|
|
285
305
|
// entry, the shell template with {{CORE_SECTION}} replaced by the
|
|
286
306
|
// chosen core's include.
|
|
@@ -528,16 +548,33 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
|
|
|
528
548
|
);
|
|
529
549
|
console.log(dim(' instead of polling tightly or narrating the wait.'));
|
|
530
550
|
console.log(` 2. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
|
|
531
|
-
console.log(
|
|
551
|
+
console.log(
|
|
552
|
+
` 3. Start a ${bold('new')} ${HOSTS[host].label} session and describe what you want to build.`,
|
|
553
|
+
);
|
|
532
554
|
} else {
|
|
533
555
|
console.log(` 1. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
|
|
534
|
-
console.log(
|
|
556
|
+
console.log(
|
|
557
|
+
` 2. Start a ${bold('new')} ${HOSTS[host].label} session and describe what you want to build.`,
|
|
558
|
+
);
|
|
535
559
|
}
|
|
536
560
|
console.log(
|
|
537
561
|
dim(
|
|
538
562
|
` The ${bold('planner')} agent runs planning intake, then hands off to bootstrap.`,
|
|
539
563
|
),
|
|
540
564
|
);
|
|
565
|
+
// A host enumerates its agents once, at session start. This install just
|
|
566
|
+
// wrote them mid-session, so in THIS session they are not dispatchable —
|
|
567
|
+
// and the failure is worse than a plain error: names like `planner` and
|
|
568
|
+
// `reviewer` often resolve to the user's own unrelated global agents,
|
|
569
|
+
// silently running a different discipline instead of erroring.
|
|
570
|
+
console.log(
|
|
571
|
+
dim(
|
|
572
|
+
` A new session matters: ${HOSTS[host].label} lists its agents at startup, so the\n` +
|
|
573
|
+
' ones just installed are not dispatchable in the session that ran this.\n' +
|
|
574
|
+
' If a handoff must happen here anyway, read the agent file and follow it\n' +
|
|
575
|
+
' inline rather than dispatching to a name that may resolve elsewhere.',
|
|
576
|
+
),
|
|
577
|
+
);
|
|
541
578
|
console.log();
|
|
542
579
|
if (explicitCore) {
|
|
543
580
|
console.log(dim(`Core: ${bold(core)}.`));
|
|
@@ -636,6 +673,7 @@ async function dbRebuildCommand() {
|
|
|
636
673
|
console.log(
|
|
637
674
|
`${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
|
|
638
675
|
);
|
|
676
|
+
warnOrphanedNotes(result);
|
|
639
677
|
warnRebuildDrift(result, corePath);
|
|
640
678
|
}
|
|
641
679
|
|
|
@@ -667,6 +705,13 @@ async function dbCommand(args) {
|
|
|
667
705
|
// along with the rest of src/golden-cores/<core>). Neither exists yet on
|
|
668
706
|
// a deferred install (plain `init`, no explicit core flag) until
|
|
669
707
|
// `bootstrap` runs — this returns null until then.
|
|
708
|
+
// Synchronous on purpose: formatPacket renders in one pass and takes this
|
|
709
|
+
// as a predicate, so the FIRST ARRIVAL check cannot be an await. Paths are
|
|
710
|
+
// repo-relative, the same way scope globs are.
|
|
711
|
+
function packetExists(path) {
|
|
712
|
+
return existsSync(join(DEST_ROOT, path));
|
|
713
|
+
}
|
|
714
|
+
|
|
670
715
|
async function resolveCorePath() {
|
|
671
716
|
if (await exists(join(DEST_ROOT, AUTHORED_CORE_PATH))) {
|
|
672
717
|
return join(DEST_ROOT, AUTHORED_CORE_PATH);
|
|
@@ -821,6 +866,7 @@ async function planCommand(args = []) {
|
|
|
821
866
|
const driftDb = openDb({ readOnly: true });
|
|
822
867
|
let drifted;
|
|
823
868
|
try {
|
|
869
|
+
warnSingularModuleIdsAtPlan(core, driftDb);
|
|
824
870
|
drifted = detectDrift(driftDb, core, { overrides });
|
|
825
871
|
} finally {
|
|
826
872
|
driftDb.close();
|
|
@@ -957,6 +1003,114 @@ async function intentCommand(args) {
|
|
|
957
1003
|
|
|
958
1004
|
console.log(` ${green('added')} ${intent.id}`);
|
|
959
1005
|
console.log(` ${dim(`${intent.requirements.length} requirement(s), ${intent.depends_on.length} dependency(ies)`)}`);
|
|
1006
|
+
await warnSingularModuleId(intent);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// On a module-axis core the intent id becomes {module} in every layer's
|
|
1010
|
+
// scope glob and verify command, and the generators the packet points at
|
|
1011
|
+
// take the module name plural. A singular id compiles a whole graph
|
|
1012
|
+
// scoped to a directory the generator will never write, and nothing
|
|
1013
|
+
// downstream catches it: plan compiles it, claim hands out a packet whose
|
|
1014
|
+
// own scaffold command contradicts its ALLOWED SCOPE.
|
|
1015
|
+
//
|
|
1016
|
+
// A warning rather than a refusal: plenty of real modules are singular
|
|
1017
|
+
// (`billing`, `search`), so the convention cannot be enforced without
|
|
1018
|
+
// rejecting correct ids. Raised here because this is the last moment the
|
|
1019
|
+
// fix is one file rename — three layers later it is a Correction Protocol
|
|
1020
|
+
// case across every compiled task.
|
|
1021
|
+
//
|
|
1022
|
+
// The convention is a heuristic, not a checkable property of the graph:
|
|
1023
|
+
// `tasks` and `task` are both structurally valid, and nothing downstream
|
|
1024
|
+
// knows which one the generator wants. So this only ever reports.
|
|
1025
|
+
function looksSingular(id) {
|
|
1026
|
+
return !/(s|ae|ia|people|children)$/i.test(id);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// `intent add` is one route to the compiler among several — `--file`, a
|
|
1030
|
+
// hand-written intents/*.json, and `db rebuild` all reach it without
|
|
1031
|
+
// passing through here — so `plan` re-raises the same check where every
|
|
1032
|
+
// route converges. See planCommand.
|
|
1033
|
+
async function warnSingularModuleId(intent) {
|
|
1034
|
+
const corePath = await resolveCorePath();
|
|
1035
|
+
if (!corePath) return;
|
|
1036
|
+
|
|
1037
|
+
let core;
|
|
1038
|
+
try {
|
|
1039
|
+
core = await loadCore(corePath);
|
|
1040
|
+
} catch {
|
|
1041
|
+
// A core that will not load is `plan`'s error to report, not this
|
|
1042
|
+
// advisory's.
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (!isModuleAxis(core)) return;
|
|
1046
|
+
if (!looksSingular(intent.id)) return;
|
|
1047
|
+
|
|
1048
|
+
console.log(
|
|
1049
|
+
`\n${yellow(bold('Module id looks singular.'))} On this core the intent id is ${bold('{module}')} in\n` +
|
|
1050
|
+
`every layer's scope, and the generators take it plural — so ${bold(intent.id)} compiles\n` +
|
|
1051
|
+
`scope for ${bold(`${intent.id}/`)} while the scaffold command writes ${bold(`${intent.id}s/`)}.\n` +
|
|
1052
|
+
`If ${bold(`${intent.id}s`)} is the name you meant, fix it now, before ${bold('hedgehog plan')}:\n` +
|
|
1053
|
+
` ${dim(`git mv ${INTENTS_DIR}/${intent.id}.json ${INTENTS_DIR}/${intent.id}s.json`)}\n` +
|
|
1054
|
+
` ${dim(`# edit "id" and every ${intent.id.toUpperCase()}-* requirement id, then:`)}\n` +
|
|
1055
|
+
` ${dim('hedgehog db rebuild')}\n`,
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// The same advisory at `plan`, where `--file`, a hand-written intent
|
|
1060
|
+
// file, and `db rebuild` all converge — none of them passes through
|
|
1061
|
+
// `intent add`, so on those routes this is the first and last cheap
|
|
1062
|
+
// warning before a whole graph is compiled against the id. It reports
|
|
1063
|
+
// while every one of an intent's tasks is still unstarted; once work has
|
|
1064
|
+
// landed the fix is a Correction Protocol case rather than a rename, and
|
|
1065
|
+
// repeating the `git mv` advice would point at the wrong recovery.
|
|
1066
|
+
//
|
|
1067
|
+
// One block for however many ids trip it, rather than repeating the
|
|
1068
|
+
// per-intent explanation: the reasoning is identical every time, and
|
|
1069
|
+
// stacking it once per intent buries the list of names under it. The
|
|
1070
|
+
// recovery is still per-id, so those lines are listed one per name.
|
|
1071
|
+
function warnSingularModuleIdsAtPlan(core, db) {
|
|
1072
|
+
if (!isModuleAxis(core)) return;
|
|
1073
|
+
|
|
1074
|
+
// Every intent whose tasks have all yet to start, not just the ones
|
|
1075
|
+
// this run compiled. `db rebuild` replays the committed intent files
|
|
1076
|
+
// *and* compiles their tasks, so a `plan` after it compiles nothing —
|
|
1077
|
+
// and that is precisely the route where nothing else has ever looked at
|
|
1078
|
+
// the id. The cheap-fix window is "no work has landed yet", which
|
|
1079
|
+
// outlasts any single `plan` invocation.
|
|
1080
|
+
const singular = db
|
|
1081
|
+
.prepare(
|
|
1082
|
+
`SELECT i.id FROM intents i
|
|
1083
|
+
WHERE i.id <> ?
|
|
1084
|
+
AND NOT EXISTS (
|
|
1085
|
+
SELECT 1 FROM tasks t
|
|
1086
|
+
WHERE t.intent_id = i.id
|
|
1087
|
+
AND t.status NOT IN ('proposed','planned','ready')
|
|
1088
|
+
)
|
|
1089
|
+
ORDER BY i.id`,
|
|
1090
|
+
)
|
|
1091
|
+
.all(CORE_INTENT_ID)
|
|
1092
|
+
.map((row) => row.id)
|
|
1093
|
+
.filter(looksSingular);
|
|
1094
|
+
if (singular.length === 0) return;
|
|
1095
|
+
|
|
1096
|
+
const many = singular.length > 1;
|
|
1097
|
+
console.log(
|
|
1098
|
+
`${yellow(bold(many ? `${singular.length} module ids look singular.` : 'Module id looks singular.'))} ` +
|
|
1099
|
+
`On this core an intent id is ${bold('{module}')} in\n` +
|
|
1100
|
+
`every layer's scope, and the generators take it plural — so ${many ? 'each of these compiles' : 'this compiles'}\n` +
|
|
1101
|
+
`scope for a directory the scaffold command will never write:\n` +
|
|
1102
|
+
singular.map((id) => ` ${bold(id)} ${dim(`— scope ${id}/, scaffold writes ${id}s/`)}`).join('\n') +
|
|
1103
|
+
`\n\nThis is a naming convention, not a rule — ${bold('billing')} and ${bold('search')} are ` +
|
|
1104
|
+
`legitimately\nsingular. If the plural is what you meant, it is still cheap to fix:\n` +
|
|
1105
|
+
singular
|
|
1106
|
+
.map(
|
|
1107
|
+
(id) =>
|
|
1108
|
+
` ${dim(`git mv ${INTENTS_DIR}/${id}.json ${INTENTS_DIR}/${id}s.json`)}\n` +
|
|
1109
|
+
` ${dim(`# edit "id" and every ${id.toUpperCase()}-* requirement id`)}`,
|
|
1110
|
+
)
|
|
1111
|
+
.join('\n') +
|
|
1112
|
+
`\n ${dim('hedgehog db rebuild')}\n`,
|
|
1113
|
+
);
|
|
960
1114
|
}
|
|
961
1115
|
|
|
962
1116
|
async function nextCommand() {
|
|
@@ -1010,7 +1164,7 @@ async function nextCommand() {
|
|
|
1010
1164
|
console.error('');
|
|
1011
1165
|
}
|
|
1012
1166
|
|
|
1013
|
-
console.log(formatNext(packet, await resolveCoreId()));
|
|
1167
|
+
console.log(formatNext(packet, await resolveCoreId(), packetExists));
|
|
1014
1168
|
}
|
|
1015
1169
|
|
|
1016
1170
|
function printStalledTasks(stalled) {
|
|
@@ -1237,7 +1391,7 @@ async function printPackets(tasks) {
|
|
|
1237
1391
|
const packet = taskPacket(db, task.id);
|
|
1238
1392
|
if (!packet) continue;
|
|
1239
1393
|
console.log();
|
|
1240
|
-
console.log(formatPacket(packet, taskStatusLine(packet.task), coreId));
|
|
1394
|
+
console.log(formatPacket(packet, taskStatusLine(packet.task), coreId, packetExists));
|
|
1241
1395
|
}
|
|
1242
1396
|
} finally {
|
|
1243
1397
|
db.close();
|
|
@@ -1467,7 +1621,9 @@ async function showCommand(args) {
|
|
|
1467
1621
|
return;
|
|
1468
1622
|
}
|
|
1469
1623
|
|
|
1470
|
-
console.log(
|
|
1624
|
+
console.log(
|
|
1625
|
+
formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId(), packetExists),
|
|
1626
|
+
);
|
|
1471
1627
|
}
|
|
1472
1628
|
|
|
1473
1629
|
// `hedgehog release <task-id> --owner <owner>` — hands a claimed task
|
package/package.json
CHANGED
package/src/agents/planner.md
CHANGED
|
@@ -50,6 +50,28 @@ follows delegates normally. Re-entry stays a subagent dispatch: its
|
|
|
50
50
|
questions are short, scoped, and answer-shaped, not a facilitated
|
|
51
51
|
session.
|
|
52
52
|
|
|
53
|
+
A hard rule stated in this file or `hedgehog-planning-intake` (this one
|
|
54
|
+
included) is not one option to weigh against a user's earlier
|
|
55
|
+
instruction — it's a constraint to work within. If a user instruction
|
|
56
|
+
genuinely conflicts with one (e.g. "don't ask clarifying questions"
|
|
57
|
+
against Phase 0's live elicitation requirement), say plainly that the
|
|
58
|
+
two conflict and ask the user how to proceed. Never resolve the conflict
|
|
59
|
+
by defaulting to a recommendation that bypasses the rule, and never
|
|
60
|
+
present bypassing it as an equally-weighted option alongside following
|
|
61
|
+
it — that smuggles the bypass in as the path of least resistance instead
|
|
62
|
+
of surfacing the actual conflict.
|
|
63
|
+
|
|
64
|
+
"Don't ask clarifying questions" is the common case, and on full-stack-app
|
|
65
|
+
or an authored core it has a defined destination once the user has chosen
|
|
66
|
+
it: **compressed intake** (`hedgehog-planning-intake`'s Phase 0). Surface
|
|
67
|
+
the conflict first, exactly as above — compressed intake is what the
|
|
68
|
+
user's answer can select, never what you recommend to avoid the
|
|
69
|
+
conversation. Say what it costs when you name it: one batched round of
|
|
70
|
+
questions instead of the shelf, a thinner archive, and an architecture
|
|
71
|
+
(on an authored core) designed from a brief rather than elicited drivers.
|
|
72
|
+
Landing-page has no such destination — see that skill for why — so there
|
|
73
|
+
the conflict is surfaced and resolved with the user, not routed.
|
|
74
|
+
|
|
53
75
|
## Phase 0 — which core applies
|
|
54
76
|
|
|
55
77
|
Before invoking any planning-intake skill, on a first run only (Workflow
|
|
@@ -189,6 +211,11 @@ anything here a background job, or is it all instant reads and writes?",
|
|
|
189
211
|
default an add-on on or off without either a concrete trigger in the PRD
|
|
190
212
|
or a direct answer.
|
|
191
213
|
|
|
214
|
+
This gate holds identically on compressed intake — it is the reason that
|
|
215
|
+
mode has a batched round of questions at all. Whatever the brief doesn't
|
|
216
|
+
concretely trigger goes into that round; nothing here is inferred from
|
|
217
|
+
silence because the user asked not to be asked.
|
|
218
|
+
|
|
192
219
|
Write the decision to `.hedgehog/addons.yaml`, one entry per add-on with
|
|
193
220
|
its on/off state and the one-line reason it landed there:
|
|
194
221
|
|
|
@@ -219,7 +246,9 @@ accounts get added where there were none).
|
|
|
219
246
|
- Decide which core applies before running any planning-intake skill —
|
|
220
247
|
Phase 0 above.
|
|
221
248
|
- **full-stack-app**: owns `.hedgehog/BMAD/` (archival, written once,
|
|
222
|
-
never edited after
|
|
249
|
+
never edited after — including its `00-manifest.md`, which records
|
|
250
|
+
which intake mode produced it) and `.hedgehog/addons.yaml` as
|
|
251
|
+
artifacts; the
|
|
223
252
|
intent records Phase 1 writes via `hedgehog intent add` live in the
|
|
224
253
|
build graph, not a file this agent owns.
|
|
225
254
|
- **landing-page**: owns `.hedgehog/BMAD/` and
|
|
@@ -277,11 +306,14 @@ accounts get added where there were none).
|
|
|
277
306
|
that turns out to be wrong is a Correction Protocol case, not a quiet
|
|
278
307
|
rewrite here.
|
|
279
308
|
5. **Run planning intake**, in the shape this path calls for:
|
|
280
|
-
- **First run, full-stack-app**: run the vendored BMAD shelf,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
the
|
|
309
|
+
- **First run, full-stack-app**: run the vendored BMAD shelf — or, if
|
|
310
|
+
the user has explicitly chosen it after the conflict was surfaced,
|
|
311
|
+
compressed intake's batched round — then mine `04-prd.md` only into
|
|
312
|
+
intent records per the PRD→graph-row table (spec: "Mapping BMAD
|
|
313
|
+
output to intents") and the Add-ons decision (see above) — asking
|
|
314
|
+
the user directly only for whatever the PRD leaves unresolved. The
|
|
315
|
+
mining step is the same either way; only how the archive was
|
|
316
|
+
produced differs.
|
|
285
317
|
- **First run, landing-page**: run the same vendored BMAD shelf in
|
|
286
318
|
full, then mine `.hedgehog/BMAD/` into a draft subject statement
|
|
287
319
|
(subject, audience, single page job) — asking the user directly only
|
package/src/agents/ux-planner.md
CHANGED
|
@@ -35,15 +35,26 @@ material `planner` files per module at planning intake, for you to act on here.
|
|
|
35
35
|
If it's thin, or a specific detail you need (information architecture, a
|
|
36
36
|
named flow, visual identity) isn't in it, read the full source directly:
|
|
37
37
|
`.hedgehog/BMAD/05-ux-spec/DESIGN.md` and `EXPERIENCE.md`, the un-mined
|
|
38
|
-
UX spec `planner`'s notes were drawn from. Read
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
from
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
UX spec `planner`'s notes were drawn from. Read whichever of the two the
|
|
39
|
+
archive holds — a project planned through compressed intake
|
|
40
|
+
(`hedgehog-planning-intake`'s Phase 0) has `EXPERIENCE.md` only, and one
|
|
41
|
+
whose brief stated no flows may have neither. **An absent file is your
|
|
42
|
+
cue to ask, not to invent.** Visual identity is the first thing a
|
|
43
|
+
compressed brief omits, so where the archive is silent, say so and
|
|
44
|
+
propose from the contract and hook rather than inferring a direction the
|
|
45
|
+
user never gave — that inference is the improvisation this step exists to
|
|
46
|
+
replace.
|
|
47
|
+
|
|
48
|
+
Read the notes file if present, then say so plainly and ask for anything
|
|
49
|
+
further before producing the rationale: "Phase A is closed for
|
|
50
|
+
`<module>` — this is the UX planning step before the screen gets built.
|
|
51
|
+
[If notes exist: "I've got what was noted at planning intake for this
|
|
52
|
+
module — here's a quick recap: (one-line summary)."] [If the archive
|
|
53
|
+
holds no UX spec: "This project was planned through compressed intake, so
|
|
54
|
+
there's no visual direction on file."] If you have a mockup, screenshot,
|
|
55
|
+
an export from a tool like Google Stitch or Figma, or an existing screen
|
|
56
|
+
you want this to resemble, hand it over now; otherwise I'll propose the
|
|
57
|
+
layout from the contract, hook, and any notes on file." Treat whatever's
|
|
47
58
|
supplied or on file the same way — a source of screen inventory and
|
|
48
59
|
hierarchy, not something to transcribe pixel-for-pixel. No visual tool or
|
|
49
60
|
prior note is required; the rationale stands on its own when nothing is
|
|
@@ -70,7 +81,10 @@ mockup, not a design system, not code:
|
|
|
70
81
|
was (a screenshot, a Stitch/Figma export, a named reference app,
|
|
71
82
|
planning-intake notes from `docs/design/<module>-notes.md`, or the
|
|
72
83
|
raw UX spec at `.hedgehog/BMAD/05-ux-spec/`) and what was drawn from
|
|
73
|
-
it versus decided independently.
|
|
84
|
+
it versus decided independently. Where there was none — a compressed
|
|
85
|
+
archive with no UX spec and nothing supplied — say that plainly here,
|
|
86
|
+
so `front-end-eng` and `reviewer` read the rationale as reasoned from
|
|
87
|
+
the contract and hook rather than from a direction on file.
|
|
74
88
|
|
|
75
89
|
Keep it short — a few bullets per screen, not a document. This is a
|
|
76
90
|
rationale `front-end-eng` reads once before starting, and `reviewer` can
|
|
@@ -110,9 +124,11 @@ conclusion.
|
|
|
110
124
|
1. Confirm the module's hook step is committed (`feat(<module>): hooks`)
|
|
111
125
|
— if not, stop, this is being asked for too early.
|
|
112
126
|
2. Check for `docs/design/<module>-notes.md` and read it if present. If
|
|
113
|
-
it's thin or missing a detail you need, read
|
|
114
|
-
`.hedgehog/BMAD/05-ux-spec/DESIGN.md` and `EXPERIENCE.md`
|
|
115
|
-
the full material it was drawn from.
|
|
127
|
+
it's thin or missing a detail you need, read whichever of
|
|
128
|
+
`.hedgehog/BMAD/05-ux-spec/DESIGN.md` and `EXPERIENCE.md` the archive
|
|
129
|
+
holds, for the full material it was drawn from. Where neither the
|
|
130
|
+
notes nor the spec covers what you need, that gap goes into step 3's
|
|
131
|
+
ask — it is not something to fill in yourself.
|
|
116
132
|
3. Announce the Phase B transition and ask for visual input, per "When
|
|
117
133
|
you run," above.
|
|
118
134
|
4. Read the contract (`packages/contracts`) for the module: what
|
package/src/db/core.mjs
CHANGED
|
@@ -480,6 +480,14 @@ function tokenInsideGlob(token, glob) {
|
|
|
480
480
|
return false;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
+
// True when the core compiles one task per domain module rather than one
|
|
484
|
+
// per layer — i.e. some layer's scope varies by {module}. The single
|
|
485
|
+
// owner of that question: validateCore, lintCore, and `intent add`'s
|
|
486
|
+
// plural-id check all read it from here.
|
|
487
|
+
export function isModuleAxis(core) {
|
|
488
|
+
return core.layers.some((layer) => layer.scope.join('').includes('{module}'));
|
|
489
|
+
}
|
|
490
|
+
|
|
483
491
|
// Enforces the interview's rule (spec: "Authored cores") — a layer without
|
|
484
492
|
// scope or without a verify command is rejected. Applied uniformly to
|
|
485
493
|
// shipped and authored cores alike; the loader has no shipped-core-only
|
|
@@ -600,10 +608,7 @@ export function validateCore(core) {
|
|
|
600
608
|
// is nothing for a {module} in its scope to isolate — and requiring one
|
|
601
609
|
// would reject exactly the cross-cutting layers `once` exists to
|
|
602
610
|
// express.
|
|
603
|
-
|
|
604
|
-
layer.scope.join('').includes('{module}'),
|
|
605
|
-
);
|
|
606
|
-
if (isModuleAxis) {
|
|
611
|
+
if (isModuleAxis(core)) {
|
|
607
612
|
for (const layer of core.layers) {
|
|
608
613
|
if (layer.exclusive || layer.once) continue;
|
|
609
614
|
if (!layer.scope.join('').includes('{module}')) {
|
|
@@ -662,10 +667,7 @@ export function validateCore(core) {
|
|
|
662
667
|
// hedgehog-core-design carries the question as an authoring rule too.
|
|
663
668
|
export function lintCore(core) {
|
|
664
669
|
const warnings = [];
|
|
665
|
-
|
|
666
|
-
layer.scope.join('').includes('{module}'),
|
|
667
|
-
);
|
|
668
|
-
if (isModuleAxis) {
|
|
670
|
+
if (isModuleAxis(core)) {
|
|
669
671
|
for (const layer of core.layers) {
|
|
670
672
|
if (layer.exclusive || layer.once) continue;
|
|
671
673
|
if (!layer.scope.join('').includes('{module}')) continue; // validateCore already rejects this
|
package/src/db/next.mjs
CHANGED
|
@@ -360,9 +360,74 @@ const HONESTY = [
|
|
|
360
360
|
// HONESTY is last deliberately: it's the one section that qualifies the
|
|
361
361
|
// gate above it, so it reads as the answer to "and what if I can't clear
|
|
362
362
|
// VERIFICATION honestly" rather than as preamble.
|
|
363
|
-
|
|
363
|
+
// The package root a scope glob lives inside: the literal path above the
|
|
364
|
+
// glob's first wildcard, truncated at `src/` when one appears — a package
|
|
365
|
+
// root is where `package.json` sits, which is always above the `src/` a
|
|
366
|
+
// module directory hangs off.
|
|
367
|
+
//
|
|
368
|
+
// `{module}` is substituted first rather than treated as a wildcard,
|
|
369
|
+
// because on this core it is a real directory name in the compiled task
|
|
370
|
+
// and the package root can sit *below* it: `packages/contracts/src/tasks/**`
|
|
371
|
+
// has its root two segments up, while `libs/tasks/repository/**` has its
|
|
372
|
+
// root at the full literal path. Only `*` genuinely ends the literal part.
|
|
373
|
+
export function scopePackageRoot(glob, module) {
|
|
374
|
+
const segments = glob.replace('{module}', module).split('/');
|
|
375
|
+
const wildcard = segments.findIndex((s) => s.includes('*'));
|
|
376
|
+
const literal = wildcard === -1 ? segments.slice(0, -1) : segments.slice(0, wildcard);
|
|
377
|
+
const src = literal.indexOf('src');
|
|
378
|
+
const root = src === -1 ? literal : literal.slice(0, src);
|
|
379
|
+
// A package root is at least `<area>/<name>`; anything shallower is the
|
|
380
|
+
// repo itself, which is never a first arrival.
|
|
381
|
+
return root.length >= 2 ? root.join('/') : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// A task is the first arrival in its package when the package the scope
|
|
385
|
+
// points into has no package.json on disk yet: the generator that
|
|
386
|
+
// scaffolds this layer will create the package shell (package.json,
|
|
387
|
+
// tsconfig*.json, vitest.config.mts, src/index.ts) alongside the module's
|
|
388
|
+
// own files, and every one of those lands outside a {module}-bearing
|
|
389
|
+
// glob. Detected here rather than left to be discovered from a failed
|
|
390
|
+
// verify, which is where the override stops being available and the
|
|
391
|
+
// recovery becomes a five-command round trip.
|
|
392
|
+
//
|
|
393
|
+
// `exists` is injected so this module keeps no filesystem dependency of
|
|
394
|
+
// its own; the CLI passes a real one and the packet degrades to no
|
|
395
|
+
// section when a caller supplies none.
|
|
396
|
+
export function firstArrivalPackages(task, exists) {
|
|
397
|
+
if (!exists) return [];
|
|
398
|
+
const roots = new Set();
|
|
399
|
+
for (const glob of JSON.parse(task.scope_globs)) {
|
|
400
|
+
const root = scopePackageRoot(glob, task.module);
|
|
401
|
+
if (root && !exists(`${root}/package.json`)) roots.add(root);
|
|
402
|
+
}
|
|
403
|
+
return [...roots].sort();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function firstArrivalLines(task, roots) {
|
|
407
|
+
const scopes = roots.flatMap((root) => [`${root}/*`, `${root}/src/*`]);
|
|
408
|
+
return [
|
|
409
|
+
'FIRST ARRIVAL',
|
|
410
|
+
` ${roots.join(', ')} ${roots.length > 1 ? 'do' : 'does'} not exist yet, so this task's`,
|
|
411
|
+
" generator also creates the package shell (package.json, tsconfig*.json,",
|
|
412
|
+
' vitest.config.mts, src/index.ts) plus any package-wide source it writes',
|
|
413
|
+
' at the src/ root. All of that lands OUTSIDE the scope above. Widen this',
|
|
414
|
+
' one task before building it, or verify will reject those paths and',
|
|
415
|
+
' block the task:',
|
|
416
|
+
'',
|
|
417
|
+
` hedgehog override add ${task.id} \\`,
|
|
418
|
+
...scopes.map((s) => ` --scope '${s}' \\`),
|
|
419
|
+
` --reason 'first module through ${task.layer} also creates the package shell'`,
|
|
420
|
+
'',
|
|
421
|
+
' src/* is non-recursive on purpose: it covers src/index.ts and any',
|
|
422
|
+
" shared util beside it without re-granting the module directory the",
|
|
423
|
+
' scope above already covers.',
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function formatPacket(packet, statusLine, coreId = null, exists = null) {
|
|
364
428
|
const { task, intent, requirements, dependents, incompleteDeps = [], inheritedDebt = [] } = packet;
|
|
365
429
|
const scopeGlobs = JSON.parse(task.scope_globs);
|
|
430
|
+
const firstArrival = firstArrivalPackages(task, exists);
|
|
366
431
|
|
|
367
432
|
const lines = [];
|
|
368
433
|
lines.push(`TASK ${task.id}`);
|
|
@@ -428,6 +493,10 @@ export function formatPacket(packet, statusLine, coreId = null) {
|
|
|
428
493
|
lines.push('ALLOWED SCOPE');
|
|
429
494
|
for (const glob of scopeGlobs) lines.push(` ${glob}`);
|
|
430
495
|
lines.push('');
|
|
496
|
+
if (firstArrival.length > 0) {
|
|
497
|
+
lines.push(...firstArrivalLines(task, firstArrival));
|
|
498
|
+
lines.push('');
|
|
499
|
+
}
|
|
431
500
|
const shapeLines = layerShapeLines(task, coreId);
|
|
432
501
|
if (shapeLines) {
|
|
433
502
|
lines.push(...shapeLines);
|
|
@@ -443,6 +512,6 @@ export function formatPacket(packet, statusLine, coreId = null) {
|
|
|
443
512
|
|
|
444
513
|
// `hedgehog next`'s rendering: its task always came out of the readiness
|
|
445
514
|
// SELECT, so STATUS is READY by construction.
|
|
446
|
-
export function formatNext(packet, coreId = null) {
|
|
447
|
-
return formatPacket(packet, 'READY', coreId);
|
|
515
|
+
export function formatNext(packet, coreId = null, exists = null) {
|
|
516
|
+
return formatPacket(packet, 'READY', coreId, exists);
|
|
448
517
|
}
|
package/src/db/rebuild.mjs
CHANGED
|
@@ -43,6 +43,67 @@ function intentExists(db, id) {
|
|
|
43
43
|
return db.prepare('SELECT 1 FROM intents WHERE id = ?').get(id) !== undefined;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// A rebuild's result is a pure function of the committed sources
|
|
47
|
+
// (`.hedgehog/intents/`, core.yaml, overrides, git history), so the
|
|
48
|
+
// derived graph is cleared before replay rather than replayed on top of
|
|
49
|
+
// whatever the DB already held. Without this, an intent file that was
|
|
50
|
+
// renamed or deleted leaves its tasks behind: the scheduler then sees a
|
|
51
|
+
// ghost task whose scope overlaps the real one and holds the real one
|
|
52
|
+
// back, producing a graph no set of committed intents describes.
|
|
53
|
+
//
|
|
54
|
+
// Deleting `intents` cascades through requirements, tasks,
|
|
55
|
+
// task_requirements, dependencies, artifacts and verifications — every
|
|
56
|
+
// one of which this run re-derives. `debt` and `friction` are the
|
|
57
|
+
// exception: they are operator-recorded notes with no committed source,
|
|
58
|
+
// so they are carried across by task id (deterministic, so a note
|
|
59
|
+
// re-attaches to the same task the replay recompiles). A note whose task
|
|
60
|
+
// no longer exists in the new graph has nowhere to live and is reported
|
|
61
|
+
// rather than silently dropped.
|
|
62
|
+
function clearDerivedGraph(db) {
|
|
63
|
+
const debt = db.prepare('SELECT task_id, note, logged_at FROM debt').all();
|
|
64
|
+
const friction = db.prepare('SELECT task_id, note, logged_at FROM friction').all();
|
|
65
|
+
|
|
66
|
+
db.prepare('DELETE FROM intents').run();
|
|
67
|
+
// `friction.task_id` is ON DELETE SET NULL rather than CASCADE, so its
|
|
68
|
+
// rows outlive the delete above. Clear them too and let restoreNotes be
|
|
69
|
+
// the single writer, so a note is not duplicated against its own copy.
|
|
70
|
+
db.prepare('DELETE FROM friction').run();
|
|
71
|
+
|
|
72
|
+
return { debt, friction };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function restoreNotes(db, { debt, friction }) {
|
|
76
|
+
const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
|
|
77
|
+
const insertDebt = db.prepare(
|
|
78
|
+
'INSERT INTO debt (task_id, note, logged_at) VALUES (?, ?, ?)',
|
|
79
|
+
);
|
|
80
|
+
const insertFriction = db.prepare(
|
|
81
|
+
'INSERT INTO friction (task_id, note, logged_at) VALUES (?, ?, ?)',
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const orphaned = [];
|
|
85
|
+
|
|
86
|
+
for (const row of debt) {
|
|
87
|
+
if (taskExists.get(row.task_id) === undefined) {
|
|
88
|
+
orphaned.push({ kind: 'debt', taskId: row.task_id, note: row.note });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
insertDebt.run(row.task_id, row.note, row.logged_at);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const row of friction) {
|
|
95
|
+
// friction.task_id is nullable — an unattached note always survives.
|
|
96
|
+
if (row.task_id !== null && taskExists.get(row.task_id) === undefined) {
|
|
97
|
+
insertFriction.run(null, row.note, row.logged_at);
|
|
98
|
+
orphaned.push({ kind: 'friction', taskId: row.task_id, note: row.note });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
insertFriction.run(row.task_id, row.note, row.logged_at);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return orphaned;
|
|
105
|
+
}
|
|
106
|
+
|
|
46
107
|
// Topological sort over `depends_on`, tie-broken by the file order above
|
|
47
108
|
// so two runs over the same directory always replay identically.
|
|
48
109
|
//
|
|
@@ -71,9 +132,9 @@ function orderIntentsForReplay(records, isSatisfied) {
|
|
|
71
132
|
|
|
72
133
|
const entry = byId.get(id);
|
|
73
134
|
if (!entry) {
|
|
74
|
-
// Not a file in this directory
|
|
75
|
-
//
|
|
76
|
-
//
|
|
135
|
+
// Not a file in this directory, and the derived graph was cleared
|
|
136
|
+
// before replay, so the edge points at nothing and would fail the
|
|
137
|
+
// FOREIGN KEY.
|
|
77
138
|
if (isSatisfied(id)) return;
|
|
78
139
|
throw new Error(
|
|
79
140
|
`intent "${requiredBy}" depends_on "${id}", which has no intent file in ` +
|
|
@@ -98,12 +159,6 @@ function orderIntentsForReplay(records, isSatisfied) {
|
|
|
98
159
|
// Reads every intent file, normalizes it, orders the set by depends_on,
|
|
99
160
|
// and inserts the rows. Read-only with respect to the files themselves.
|
|
100
161
|
//
|
|
101
|
-
// The insert is unconditional (an intent id is never re-added through
|
|
102
|
-
// `intent add` in normal use), so rebuild — the one caller that must also
|
|
103
|
-
// tolerate an already-populated DB, per "re-derive from source-of-truth
|
|
104
|
-
// after suspected corruption" — skips any intent whose id is already
|
|
105
|
-
// present rather than letting the UNIQUE constraint fail the whole run.
|
|
106
|
-
//
|
|
107
162
|
// Ordering is resolved for the whole set BEFORE any row is written, so a
|
|
108
163
|
// cycle or a dangling depends_on fails the run without having half-built
|
|
109
164
|
// the graph.
|
|
@@ -232,10 +287,9 @@ function markCompletedTasks(db, commitSubjects) {
|
|
|
232
287
|
const setComplete = db.prepare("UPDATE tasks SET status = 'complete' WHERE id = ?");
|
|
233
288
|
for (const id of complete) setComplete.run(id);
|
|
234
289
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
// than leaving the stale status untouched.
|
|
290
|
+
// A once-task can be marked complete by the loop above and then fail
|
|
291
|
+
// one of the extra conditions on a later pass of the fixpoint walk.
|
|
292
|
+
// Reconcile it back rather than leaving the stale status untouched.
|
|
239
293
|
const reopen = db.prepare(
|
|
240
294
|
"UPDATE tasks SET status = 'planned' WHERE id = ? AND status = 'complete'",
|
|
241
295
|
);
|
|
@@ -268,6 +322,8 @@ export async function rebuildDb(
|
|
|
268
322
|
) {
|
|
269
323
|
applySchema(db);
|
|
270
324
|
|
|
325
|
+
const notes = clearDerivedGraph(db);
|
|
326
|
+
|
|
271
327
|
const intentsReplayed = await replayIntents(db, intentsDir);
|
|
272
328
|
|
|
273
329
|
const core = await loadCore(corePath);
|
|
@@ -277,7 +333,9 @@ export async function rebuildDb(
|
|
|
277
333
|
const commitSubjects = loadCommitSubjects();
|
|
278
334
|
const tasksMarkedComplete = markCompletedTasks(db, commitSubjects);
|
|
279
335
|
|
|
336
|
+
const orphanedNotes = restoreNotes(db, notes);
|
|
337
|
+
|
|
280
338
|
const drift = detectDrift(db, core, { overrides });
|
|
281
339
|
|
|
282
|
-
return { intentsReplayed, tasksMarkedComplete, drift };
|
|
340
|
+
return { intentsReplayed, tasksMarkedComplete, orphanedNotes, drift };
|
|
283
341
|
}
|