@skyf0xx/hedgehog 4.3.1 → 4.3.6
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 +157 -10
- package/package.json +1 -1
- package/src/agents/planner.md +44 -7
- 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 +29 -5
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,14 +548,22 @@ 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(` 3.
|
|
551
|
+
console.log(` 3. Describe what you want to build.`);
|
|
532
552
|
} else {
|
|
533
553
|
console.log(` 1. ${bold('git add -A && git commit -m "chore: install Hedgehog"')}`);
|
|
534
|
-
console.log(` 2.
|
|
535
|
-
}
|
|
554
|
+
console.log(` 2. Describe what you want to build.`);
|
|
555
|
+
}
|
|
556
|
+
// Hand off by path, not by name. Reading the file works on every host in
|
|
557
|
+
// the session that ran this install — no restart, no dependence on whether
|
|
558
|
+
// the harness re-scans its agent directory, and no chance of a bare
|
|
559
|
+
// `planner` resolving to an unrelated global agent of the same name.
|
|
560
|
+
const agents = HOSTS[host].agentsDir;
|
|
561
|
+
const skills = HOSTS[host].skillsDir;
|
|
536
562
|
console.log(
|
|
537
563
|
dim(
|
|
538
|
-
`
|
|
564
|
+
` Read ${bold(`${agents}/planner.md`)} and follow it — it runs planning\n` +
|
|
565
|
+
` intake (${skills}/hedgehog-planning-intake/SKILL.md), then hands\n` +
|
|
566
|
+
` off to ${agents}/bootstrap.md.`,
|
|
539
567
|
),
|
|
540
568
|
);
|
|
541
569
|
console.log();
|
|
@@ -636,6 +664,7 @@ async function dbRebuildCommand() {
|
|
|
636
664
|
console.log(
|
|
637
665
|
`${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
|
|
638
666
|
);
|
|
667
|
+
warnOrphanedNotes(result);
|
|
639
668
|
warnRebuildDrift(result, corePath);
|
|
640
669
|
}
|
|
641
670
|
|
|
@@ -667,6 +696,13 @@ async function dbCommand(args) {
|
|
|
667
696
|
// along with the rest of src/golden-cores/<core>). Neither exists yet on
|
|
668
697
|
// a deferred install (plain `init`, no explicit core flag) until
|
|
669
698
|
// `bootstrap` runs — this returns null until then.
|
|
699
|
+
// Synchronous on purpose: formatPacket renders in one pass and takes this
|
|
700
|
+
// as a predicate, so the FIRST ARRIVAL check cannot be an await. Paths are
|
|
701
|
+
// repo-relative, the same way scope globs are.
|
|
702
|
+
function packetExists(path) {
|
|
703
|
+
return existsSync(join(DEST_ROOT, path));
|
|
704
|
+
}
|
|
705
|
+
|
|
670
706
|
async function resolveCorePath() {
|
|
671
707
|
if (await exists(join(DEST_ROOT, AUTHORED_CORE_PATH))) {
|
|
672
708
|
return join(DEST_ROOT, AUTHORED_CORE_PATH);
|
|
@@ -821,6 +857,7 @@ async function planCommand(args = []) {
|
|
|
821
857
|
const driftDb = openDb({ readOnly: true });
|
|
822
858
|
let drifted;
|
|
823
859
|
try {
|
|
860
|
+
warnSingularModuleIdsAtPlan(core, driftDb);
|
|
824
861
|
drifted = detectDrift(driftDb, core, { overrides });
|
|
825
862
|
} finally {
|
|
826
863
|
driftDb.close();
|
|
@@ -957,6 +994,114 @@ async function intentCommand(args) {
|
|
|
957
994
|
|
|
958
995
|
console.log(` ${green('added')} ${intent.id}`);
|
|
959
996
|
console.log(` ${dim(`${intent.requirements.length} requirement(s), ${intent.depends_on.length} dependency(ies)`)}`);
|
|
997
|
+
await warnSingularModuleId(intent);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// On a module-axis core the intent id becomes {module} in every layer's
|
|
1001
|
+
// scope glob and verify command, and the generators the packet points at
|
|
1002
|
+
// take the module name plural. A singular id compiles a whole graph
|
|
1003
|
+
// scoped to a directory the generator will never write, and nothing
|
|
1004
|
+
// downstream catches it: plan compiles it, claim hands out a packet whose
|
|
1005
|
+
// own scaffold command contradicts its ALLOWED SCOPE.
|
|
1006
|
+
//
|
|
1007
|
+
// A warning rather than a refusal: plenty of real modules are singular
|
|
1008
|
+
// (`billing`, `search`), so the convention cannot be enforced without
|
|
1009
|
+
// rejecting correct ids. Raised here because this is the last moment the
|
|
1010
|
+
// fix is one file rename — three layers later it is a Correction Protocol
|
|
1011
|
+
// case across every compiled task.
|
|
1012
|
+
//
|
|
1013
|
+
// The convention is a heuristic, not a checkable property of the graph:
|
|
1014
|
+
// `tasks` and `task` are both structurally valid, and nothing downstream
|
|
1015
|
+
// knows which one the generator wants. So this only ever reports.
|
|
1016
|
+
function looksSingular(id) {
|
|
1017
|
+
return !/(s|ae|ia|people|children)$/i.test(id);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// `intent add` is one route to the compiler among several — `--file`, a
|
|
1021
|
+
// hand-written intents/*.json, and `db rebuild` all reach it without
|
|
1022
|
+
// passing through here — so `plan` re-raises the same check where every
|
|
1023
|
+
// route converges. See planCommand.
|
|
1024
|
+
async function warnSingularModuleId(intent) {
|
|
1025
|
+
const corePath = await resolveCorePath();
|
|
1026
|
+
if (!corePath) return;
|
|
1027
|
+
|
|
1028
|
+
let core;
|
|
1029
|
+
try {
|
|
1030
|
+
core = await loadCore(corePath);
|
|
1031
|
+
} catch {
|
|
1032
|
+
// A core that will not load is `plan`'s error to report, not this
|
|
1033
|
+
// advisory's.
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (!isModuleAxis(core)) return;
|
|
1037
|
+
if (!looksSingular(intent.id)) return;
|
|
1038
|
+
|
|
1039
|
+
console.log(
|
|
1040
|
+
`\n${yellow(bold('Module id looks singular.'))} On this core the intent id is ${bold('{module}')} in\n` +
|
|
1041
|
+
`every layer's scope, and the generators take it plural — so ${bold(intent.id)} compiles\n` +
|
|
1042
|
+
`scope for ${bold(`${intent.id}/`)} while the scaffold command writes ${bold(`${intent.id}s/`)}.\n` +
|
|
1043
|
+
`If ${bold(`${intent.id}s`)} is the name you meant, fix it now, before ${bold('hedgehog plan')}:\n` +
|
|
1044
|
+
` ${dim(`git mv ${INTENTS_DIR}/${intent.id}.json ${INTENTS_DIR}/${intent.id}s.json`)}\n` +
|
|
1045
|
+
` ${dim(`# edit "id" and every ${intent.id.toUpperCase()}-* requirement id, then:`)}\n` +
|
|
1046
|
+
` ${dim('hedgehog db rebuild')}\n`,
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// The same advisory at `plan`, where `--file`, a hand-written intent
|
|
1051
|
+
// file, and `db rebuild` all converge — none of them passes through
|
|
1052
|
+
// `intent add`, so on those routes this is the first and last cheap
|
|
1053
|
+
// warning before a whole graph is compiled against the id. It reports
|
|
1054
|
+
// while every one of an intent's tasks is still unstarted; once work has
|
|
1055
|
+
// landed the fix is a Correction Protocol case rather than a rename, and
|
|
1056
|
+
// repeating the `git mv` advice would point at the wrong recovery.
|
|
1057
|
+
//
|
|
1058
|
+
// One block for however many ids trip it, rather than repeating the
|
|
1059
|
+
// per-intent explanation: the reasoning is identical every time, and
|
|
1060
|
+
// stacking it once per intent buries the list of names under it. The
|
|
1061
|
+
// recovery is still per-id, so those lines are listed one per name.
|
|
1062
|
+
function warnSingularModuleIdsAtPlan(core, db) {
|
|
1063
|
+
if (!isModuleAxis(core)) return;
|
|
1064
|
+
|
|
1065
|
+
// Every intent whose tasks have all yet to start, not just the ones
|
|
1066
|
+
// this run compiled. `db rebuild` replays the committed intent files
|
|
1067
|
+
// *and* compiles their tasks, so a `plan` after it compiles nothing —
|
|
1068
|
+
// and that is precisely the route where nothing else has ever looked at
|
|
1069
|
+
// the id. The cheap-fix window is "no work has landed yet", which
|
|
1070
|
+
// outlasts any single `plan` invocation.
|
|
1071
|
+
const singular = db
|
|
1072
|
+
.prepare(
|
|
1073
|
+
`SELECT i.id FROM intents i
|
|
1074
|
+
WHERE i.id <> ?
|
|
1075
|
+
AND NOT EXISTS (
|
|
1076
|
+
SELECT 1 FROM tasks t
|
|
1077
|
+
WHERE t.intent_id = i.id
|
|
1078
|
+
AND t.status NOT IN ('proposed','planned','ready')
|
|
1079
|
+
)
|
|
1080
|
+
ORDER BY i.id`,
|
|
1081
|
+
)
|
|
1082
|
+
.all(CORE_INTENT_ID)
|
|
1083
|
+
.map((row) => row.id)
|
|
1084
|
+
.filter(looksSingular);
|
|
1085
|
+
if (singular.length === 0) return;
|
|
1086
|
+
|
|
1087
|
+
const many = singular.length > 1;
|
|
1088
|
+
console.log(
|
|
1089
|
+
`${yellow(bold(many ? `${singular.length} module ids look singular.` : 'Module id looks singular.'))} ` +
|
|
1090
|
+
`On this core an intent id is ${bold('{module}')} in\n` +
|
|
1091
|
+
`every layer's scope, and the generators take it plural — so ${many ? 'each of these compiles' : 'this compiles'}\n` +
|
|
1092
|
+
`scope for a directory the scaffold command will never write:\n` +
|
|
1093
|
+
singular.map((id) => ` ${bold(id)} ${dim(`— scope ${id}/, scaffold writes ${id}s/`)}`).join('\n') +
|
|
1094
|
+
`\n\nThis is a naming convention, not a rule — ${bold('billing')} and ${bold('search')} are ` +
|
|
1095
|
+
`legitimately\nsingular. If the plural is what you meant, it is still cheap to fix:\n` +
|
|
1096
|
+
singular
|
|
1097
|
+
.map(
|
|
1098
|
+
(id) =>
|
|
1099
|
+
` ${dim(`git mv ${INTENTS_DIR}/${id}.json ${INTENTS_DIR}/${id}s.json`)}\n` +
|
|
1100
|
+
` ${dim(`# edit "id" and every ${id.toUpperCase()}-* requirement id`)}`,
|
|
1101
|
+
)
|
|
1102
|
+
.join('\n') +
|
|
1103
|
+
`\n ${dim('hedgehog db rebuild')}\n`,
|
|
1104
|
+
);
|
|
960
1105
|
}
|
|
961
1106
|
|
|
962
1107
|
async function nextCommand() {
|
|
@@ -1010,7 +1155,7 @@ async function nextCommand() {
|
|
|
1010
1155
|
console.error('');
|
|
1011
1156
|
}
|
|
1012
1157
|
|
|
1013
|
-
console.log(formatNext(packet, await resolveCoreId()));
|
|
1158
|
+
console.log(formatNext(packet, await resolveCoreId(), packetExists));
|
|
1014
1159
|
}
|
|
1015
1160
|
|
|
1016
1161
|
function printStalledTasks(stalled) {
|
|
@@ -1237,7 +1382,7 @@ async function printPackets(tasks) {
|
|
|
1237
1382
|
const packet = taskPacket(db, task.id);
|
|
1238
1383
|
if (!packet) continue;
|
|
1239
1384
|
console.log();
|
|
1240
|
-
console.log(formatPacket(packet, taskStatusLine(packet.task), coreId));
|
|
1385
|
+
console.log(formatPacket(packet, taskStatusLine(packet.task), coreId, packetExists));
|
|
1241
1386
|
}
|
|
1242
1387
|
} finally {
|
|
1243
1388
|
db.close();
|
|
@@ -1467,7 +1612,9 @@ async function showCommand(args) {
|
|
|
1467
1612
|
return;
|
|
1468
1613
|
}
|
|
1469
1614
|
|
|
1470
|
-
console.log(
|
|
1615
|
+
console.log(
|
|
1616
|
+
formatPacket(packet, taskStatusLine(packet.task), await resolveCoreId(), packetExists),
|
|
1617
|
+
);
|
|
1471
1618
|
}
|
|
1472
1619
|
|
|
1473
1620
|
// `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
|
|
@@ -65,7 +87,12 @@ and the scaffolded workspace.
|
|
|
65
87
|
real app beyond a single page. If in doubt between this and
|
|
66
88
|
landing-page because the project has *both* a marketing page and a
|
|
67
89
|
real app behind it, this is `full-stack-app` — the page becomes routes
|
|
68
|
-
inside `apps/web`, not a separate project.
|
|
90
|
+
inside `apps/web`, not a separate project. Data that gets stored is
|
|
91
|
+
this core, at any size: a todo list, a notes app, a tracker of any
|
|
92
|
+
kind. Never talk the user down to browser-local storage, an in-memory
|
|
93
|
+
array, or a single-file page because the app sounds small, and never
|
|
94
|
+
offer that as a quicker start — the core ships a real database, and
|
|
95
|
+
reaching for less is the drift this discipline exists to prevent.
|
|
69
96
|
- **`landing-page`** — the description is a marketing/announcement/
|
|
70
97
|
waitlist/portfolio page (or a small handful of such pages) with no
|
|
71
98
|
persistent domain data of its own. A page that only collects an email
|
|
@@ -189,6 +216,11 @@ anything here a background job, or is it all instant reads and writes?",
|
|
|
189
216
|
default an add-on on or off without either a concrete trigger in the PRD
|
|
190
217
|
or a direct answer.
|
|
191
218
|
|
|
219
|
+
This gate holds identically on compressed intake — it is the reason that
|
|
220
|
+
mode has a batched round of questions at all. Whatever the brief doesn't
|
|
221
|
+
concretely trigger goes into that round; nothing here is inferred from
|
|
222
|
+
silence because the user asked not to be asked.
|
|
223
|
+
|
|
192
224
|
Write the decision to `.hedgehog/addons.yaml`, one entry per add-on with
|
|
193
225
|
its on/off state and the one-line reason it landed there:
|
|
194
226
|
|
|
@@ -219,7 +251,9 @@ accounts get added where there were none).
|
|
|
219
251
|
- Decide which core applies before running any planning-intake skill —
|
|
220
252
|
Phase 0 above.
|
|
221
253
|
- **full-stack-app**: owns `.hedgehog/BMAD/` (archival, written once,
|
|
222
|
-
never edited after
|
|
254
|
+
never edited after — including its `00-manifest.md`, which records
|
|
255
|
+
which intake mode produced it) and `.hedgehog/addons.yaml` as
|
|
256
|
+
artifacts; the
|
|
223
257
|
intent records Phase 1 writes via `hedgehog intent add` live in the
|
|
224
258
|
build graph, not a file this agent owns.
|
|
225
259
|
- **landing-page**: owns `.hedgehog/BMAD/` and
|
|
@@ -277,11 +311,14 @@ accounts get added where there were none).
|
|
|
277
311
|
that turns out to be wrong is a Correction Protocol case, not a quiet
|
|
278
312
|
rewrite here.
|
|
279
313
|
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
|
|
314
|
+
- **First run, full-stack-app**: run the vendored BMAD shelf — or, if
|
|
315
|
+
the user has explicitly chosen it after the conflict was surfaced,
|
|
316
|
+
compressed intake's batched round — then mine `04-prd.md` only into
|
|
317
|
+
intent records per the PRD→graph-row table (spec: "Mapping BMAD
|
|
318
|
+
output to intents") and the Add-ons decision (see above) — asking
|
|
319
|
+
the user directly only for whatever the PRD leaves unresolved. The
|
|
320
|
+
mining step is the same either way; only how the archive was
|
|
321
|
+
produced differs.
|
|
285
322
|
- **First run, landing-page**: run the same vendored BMAD shelf in
|
|
286
323
|
full, then mine `.hedgehog/BMAD/` into a draft subject statement
|
|
287
324
|
(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
|
}
|