@skyf0xx/hedgehog 4.3.0 → 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/README.md +25 -26
- package/bin/cli.mjs +176 -9
- package/package.json +10 -2
- 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
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
* Wire format (the `--fields` option): a comma-separated list of
|
|
8
8
|
* `name:type` pairs, with a trailing `?` on the type marking the column
|
|
9
9
|
* nullable — `title:string,done:boolean,dueDate:timestamp?`.
|
|
10
|
+
*
|
|
11
|
+
* A `string` takes an optional length in parentheses —
|
|
12
|
+
* `title:string(500)`, or `title:string(500)?` when it is also nullable.
|
|
13
|
+
* The value threads to both the Drizzle `varchar` and the Zod `.max()`,
|
|
14
|
+
* so the two bounds are the same number by construction: a Zod bound that
|
|
15
|
+
* outran its column would let an over-long value past validation and fail
|
|
16
|
+
* at the driver as a 500 instead of a 400.
|
|
10
17
|
*/
|
|
11
18
|
|
|
12
19
|
export type FieldType =
|
|
@@ -20,8 +27,13 @@ export interface Field {
|
|
|
20
27
|
name: string;
|
|
21
28
|
type: FieldType;
|
|
22
29
|
nullable: boolean;
|
|
30
|
+
/** Max characters for a `string`; undefined on every other type. */
|
|
31
|
+
length?: number;
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
/** The `varchar` bound a `string` gets when the field list names none. */
|
|
35
|
+
export const DEFAULT_STRING_LENGTH = 255;
|
|
36
|
+
|
|
25
37
|
const FIELD_TYPES: readonly FieldType[] = [
|
|
26
38
|
'string',
|
|
27
39
|
'text',
|
|
@@ -43,18 +55,35 @@ export function parseFields(raw: string): Field[] {
|
|
|
43
55
|
);
|
|
44
56
|
}
|
|
45
57
|
const nullable = rawType.endsWith('?');
|
|
46
|
-
const
|
|
58
|
+
const bare = nullable ? rawType.slice(0, -1) : rawType;
|
|
59
|
+
|
|
60
|
+
const sized = /^(\w+)\((\d+)\)$/.exec(bare);
|
|
61
|
+
const type = (sized ? sized[1] : bare) as FieldType;
|
|
62
|
+
const length = sized ? Number(sized[2]) : undefined;
|
|
63
|
+
|
|
47
64
|
if (!FIELD_TYPES.includes(type)) {
|
|
48
65
|
throw new Error(
|
|
49
66
|
`Field "${entry}" has unknown type "${type}". Known types: ${FIELD_TYPES.join(', ')}.`,
|
|
50
67
|
);
|
|
51
68
|
}
|
|
69
|
+
// A length on anything but `string` has no column to bind to, so it
|
|
70
|
+
// would be silently dropped — the exact failure mode the syntax
|
|
71
|
+
// exists to remove. `text` is unbounded by design: cap it and you
|
|
72
|
+
// want a `string`.
|
|
73
|
+
if (length !== undefined && type !== 'string') {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Field "${entry}" puts a length on "${type}", which has no length. Only string does (title:string(500)).`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (length !== undefined && length < 1) {
|
|
79
|
+
throw new Error(`Field "${entry}" has length ${length}; it must be at least 1.`);
|
|
80
|
+
}
|
|
52
81
|
if (RESERVED_FIELD_NAMES.includes(name)) {
|
|
53
82
|
throw new Error(
|
|
54
83
|
`Field "${name}" collides with a column every table already carries (${RESERVED_FIELD_NAMES.join(', ')}).`,
|
|
55
84
|
);
|
|
56
85
|
}
|
|
57
|
-
return { name, type, nullable };
|
|
86
|
+
return { name, type, nullable, length };
|
|
58
87
|
});
|
|
59
88
|
|
|
60
89
|
if (fields.length === 0) {
|
|
@@ -88,10 +117,15 @@ const DRIZZLE_IMPORT: Record<FieldType, string> = {
|
|
|
88
117
|
timestamp: 'timestamp',
|
|
89
118
|
};
|
|
90
119
|
|
|
120
|
+
/** The character bound for a `string`, named or defaulted. */
|
|
121
|
+
export function stringLength(field: Field): number {
|
|
122
|
+
return field.length ?? DEFAULT_STRING_LENGTH;
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
export function drizzleColumn(field: Field): string {
|
|
92
126
|
const column = `'${columnName(field.name)}'`;
|
|
93
127
|
const base: Record<FieldType, string> = {
|
|
94
|
-
string: `varchar(${column}, { length:
|
|
128
|
+
string: `varchar(${column}, { length: ${stringLength(field)} })`,
|
|
95
129
|
text: `text(${column})`,
|
|
96
130
|
boolean: `boolean(${column})`,
|
|
97
131
|
integer: `integer(${column})`,
|
|
@@ -116,7 +150,7 @@ export function isDateField(field: Field): boolean {
|
|
|
116
150
|
/** The Zod schema for a field as it appears in a hand-written override. */
|
|
117
151
|
export function zodSchema(field: Field): string {
|
|
118
152
|
const base: Record<FieldType, string> = {
|
|
119
|
-
string:
|
|
153
|
+
string: `z.string().min(1).max(${stringLength(field)})`,
|
|
120
154
|
text: 'z.string()',
|
|
121
155
|
boolean: 'z.boolean()',
|
|
122
156
|
integer: 'z.number().int()',
|
|
@@ -196,19 +196,29 @@ export function useRemove${entityPascal}() {
|
|
|
196
196
|
toggleField
|
|
197
197
|
? `
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Sends the id and nothing else. The server reads the current
|
|
201
|
+
* ${toggleField} and flips it inside one transaction, so a stale row in
|
|
202
|
+
* this cache cannot overwrite a newer state — which is what computing
|
|
203
|
+
* the new value here from a cached row would do, losing one of any two
|
|
204
|
+
* flips that raced.
|
|
205
|
+
*/
|
|
199
206
|
export function useToggle${entityPascal}() {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
const queryClient = useQueryClient();
|
|
208
|
+
|
|
209
|
+
return useMutation({
|
|
210
|
+
mutationFn: async (id: string) => {
|
|
211
|
+
const response = await client.toggle({ params: { id } });
|
|
212
|
+
if (response.status !== 200) {
|
|
213
|
+
throw new Error(\`Failed to toggle ${names.entityCamel} \${id}.\`);
|
|
214
|
+
}
|
|
215
|
+
return response.body;
|
|
216
|
+
},
|
|
217
|
+
onSuccess: (_result, id) => {
|
|
218
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.lists() });
|
|
219
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.detail(id) });
|
|
220
|
+
},
|
|
221
|
+
});
|
|
212
222
|
}`
|
|
213
223
|
: ''
|
|
214
224
|
}
|
|
@@ -266,6 +276,13 @@ describe('${camel} hooks', () => {
|
|
|
266
276
|
const { useToggle${entityPascal} } = await import('./use-${names.module}');
|
|
267
277
|
|
|
268
278
|
expect(useToggle${entityPascal}).toBeTypeOf('function');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('routes the toggle at a server endpoint that takes no body', async () => {
|
|
282
|
+
const { ${camel}Contract } = await import('contracts');
|
|
283
|
+
|
|
284
|
+
expect(${camel}Contract.toggle.method).toBe('POST');
|
|
285
|
+
expect(String(${camel}Contract.toggle.body)).toBe('Symbol(ContractNoBody)');
|
|
269
286
|
});`
|
|
270
287
|
: ''
|
|
271
288
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"toggleField": {
|
|
14
14
|
"type": "string",
|
|
15
|
-
"description": "A boolean field from the schema layer to expose as a
|
|
15
|
+
"description": "A boolean field from the schema layer to expose as a server-side toggle. Pass the same value to the schema, contract, service, controller, and hook layers. Omit when the module has none."
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"required": ["module"]
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"fields": {
|
|
14
14
|
"type": "string",
|
|
15
|
-
"description": "Comma-separated name:type pairs; a trailing ? marks the column nullable. Types: string, text, boolean, integer, timestamp. Example: title:string,done:boolean,dueDate:timestamp?",
|
|
15
|
+
"description": "Comma-separated name:type pairs; a trailing ? marks the column nullable, and string takes an optional length in parentheses (default 255). Types: string, text, boolean, integer, timestamp. Example: title:string(500),done:boolean,dueDate:timestamp?",
|
|
16
16
|
"x-prompt": "Fields (name:type, comma-separated)?"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
@@ -4,6 +4,7 @@ import { moduleNames, ModuleNames } from '../naming';
|
|
|
4
4
|
|
|
5
5
|
interface ServiceGeneratorOptions {
|
|
6
6
|
module: string;
|
|
7
|
+
toggleField?: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export default async function serviceGenerator(
|
|
@@ -22,10 +23,13 @@ export default async function serviceGenerator(
|
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
tree.write(`${root}/src/lib/${names.entityKebab}.errors.ts`, errorsFile(names));
|
|
25
|
-
tree.write(
|
|
26
|
+
tree.write(
|
|
27
|
+
`${root}/src/lib/${names.entityKebab}.service.ts`,
|
|
28
|
+
serviceFile(names, options.toggleField),
|
|
29
|
+
);
|
|
26
30
|
tree.write(
|
|
27
31
|
`${root}/src/lib/${names.entityKebab}.service.spec.ts`,
|
|
28
|
-
serviceSpecFile(names),
|
|
32
|
+
serviceSpecFile(names, options.toggleField),
|
|
29
33
|
);
|
|
30
34
|
tree.write(`${root}/src/index.ts`, barrelFile(names));
|
|
31
35
|
|
|
@@ -58,7 +62,35 @@ export class ${entityPascal}InvalidStateError extends Error {
|
|
|
58
62
|
`;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The flip is computed from the row this transaction just read, never
|
|
67
|
+
* from a value the caller supplied: the client has no way to send a
|
|
68
|
+
* stale one, so two toggles racing against each other serialize into two
|
|
69
|
+
* flips instead of both writing the same value and losing one.
|
|
70
|
+
*/
|
|
71
|
+
function toggleMethod(names: ModuleNames, toggleField: string): string {
|
|
72
|
+
const { entityPascal, entityCamel } = names;
|
|
73
|
+
|
|
74
|
+
return `
|
|
75
|
+
|
|
76
|
+
async toggle${entityPascal}(id: string): Promise<${entityPascal}> {
|
|
77
|
+
return this.${entityCamel}s.transaction(async (repository) => {
|
|
78
|
+
const existing = await repository.findById(id);
|
|
79
|
+
if (!existing) {
|
|
80
|
+
throw new ${entityPascal}NotFoundError(id);
|
|
81
|
+
}
|
|
82
|
+
const updated = await repository.update(id, {
|
|
83
|
+
${toggleField}: !existing.${toggleField},
|
|
84
|
+
} as ${entityPascal}Patch);
|
|
85
|
+
if (!updated) {
|
|
86
|
+
throw new ${entityPascal}NotFoundError(id);
|
|
87
|
+
}
|
|
88
|
+
return updated;
|
|
89
|
+
});
|
|
90
|
+
}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function serviceFile(names: ModuleNames, toggleField?: string): string {
|
|
62
94
|
const { entityPascal, entityCamel, entityKebab, module } = names;
|
|
63
95
|
|
|
64
96
|
return `import type {
|
|
@@ -115,12 +147,12 @@ export class ${entityPascal}Service {
|
|
|
115
147
|
if (!removed) {
|
|
116
148
|
throw new ${entityPascal}NotFoundError(id);
|
|
117
149
|
}
|
|
118
|
-
}
|
|
150
|
+
}${toggleField ? toggleMethod(names, toggleField) : ''}
|
|
119
151
|
}
|
|
120
152
|
`;
|
|
121
153
|
}
|
|
122
154
|
|
|
123
|
-
function serviceSpecFile(names: ModuleNames): string {
|
|
155
|
+
function serviceSpecFile(names: ModuleNames, toggleField?: string): string {
|
|
124
156
|
const { entityPascal, entityCamel, entityKebab } = names;
|
|
125
157
|
|
|
126
158
|
return `import { describe, expect, it, vi } from 'vitest';
|
|
@@ -183,7 +215,51 @@ describe('${entityPascal}Service', () => {
|
|
|
183
215
|
${entityPascal}NotFoundError,
|
|
184
216
|
);
|
|
185
217
|
});
|
|
186
|
-
|
|
218
|
+
${
|
|
219
|
+
toggleField
|
|
220
|
+
? `
|
|
221
|
+
it('derives the new ${toggleField} from stored state, not from the caller', async () => {
|
|
222
|
+
const repository = repositoryDouble({
|
|
223
|
+
findById: vi.fn(async () => ({ id: 'present', ${toggleField}: true }) as never),
|
|
224
|
+
update: vi.fn(async (_id, patch) => ({ id: 'present', ...patch }) as never),
|
|
225
|
+
});
|
|
226
|
+
const service = new ${entityPascal}Service(repository);
|
|
227
|
+
|
|
228
|
+
await service.toggle${entityPascal}('present');
|
|
229
|
+
|
|
230
|
+
expect(repository.update).toHaveBeenCalledWith('present', {
|
|
231
|
+
${toggleField}: false,
|
|
232
|
+
});
|
|
233
|
+
expect(repository.transaction).toHaveBeenCalledOnce();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('flips twice back to the original value, so no flip is lost', async () => {
|
|
237
|
+
let stored = false;
|
|
238
|
+
const repository = repositoryDouble({
|
|
239
|
+
findById: vi.fn(async () => ({ id: 'present', ${toggleField}: stored }) as never),
|
|
240
|
+
update: vi.fn(async (_id, patch) => {
|
|
241
|
+
stored = (patch as { ${toggleField}: boolean }).${toggleField};
|
|
242
|
+
return { id: 'present', ${toggleField}: stored } as never;
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
const service = new ${entityPascal}Service(repository);
|
|
246
|
+
|
|
247
|
+
await service.toggle${entityPascal}('present');
|
|
248
|
+
expect(stored).toBe(true);
|
|
249
|
+
await service.toggle${entityPascal}('present');
|
|
250
|
+
expect(stored).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('rejects a toggle against a ${entityCamel} that is not there', async () => {
|
|
254
|
+
const service = new ${entityPascal}Service(repositoryDouble());
|
|
255
|
+
|
|
256
|
+
await expect(service.toggle${entityPascal}('missing')).rejects.toBeInstanceOf(
|
|
257
|
+
${entityPascal}NotFoundError,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
`
|
|
261
|
+
: ''
|
|
262
|
+
}});
|
|
187
263
|
`;
|
|
188
264
|
}
|
|
189
265
|
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
"description": "Domain module name, plural kebab-case (e.g. tasks, order-items).",
|
|
10
10
|
"$default": { "$source": "argv", "index": 0 },
|
|
11
11
|
"x-prompt": "Domain module name (plural kebab-case)?"
|
|
12
|
+
},
|
|
13
|
+
"toggleField": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "A boolean field from the schema layer to expose as a server-side toggle. Pass the same value to the schema, contract, service, controller, and hook layers. Omit when the module has none."
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"required": ["module"]
|
|
@@ -136,6 +136,32 @@ runtime `fs.readdirSync` scan for sibling files would find nothing in the
|
|
|
136
136
|
built output even though the same scan works when Vitest runs the same
|
|
137
137
|
source directly. Generating literal imports keeps both paths identical.
|
|
138
138
|
|
|
139
|
+
## The route index
|
|
140
|
+
|
|
141
|
+
`apps/web/src/app/page.tsx` is the same category of file on the web side,
|
|
142
|
+
and gets the same answer. The screen layer's scope is
|
|
143
|
+
`apps/web/src/app/{module}/**`, module-disjoint and — by the same
|
|
144
|
+
`validateCore` rule — unable to be widened to reach one level up, so the
|
|
145
|
+
root page is outside every layer's scope. Left to itself it can never
|
|
146
|
+
learn about the modules built beneath it, and a finished build ships a
|
|
147
|
+
landing page with no route into the app it just built.
|
|
148
|
+
|
|
149
|
+
`tools/generate-module-routes.cjs` globs `apps/web/src/app/*/page.tsx`
|
|
150
|
+
and writes `apps/web/src/app/module-routes.ts` — an exported
|
|
151
|
+
`moduleRoutes` array of `{ href, label }`, one per route directory found,
|
|
152
|
+
skipping Next.js route groups (`(group)`) and `_`/`.`-prefixed
|
|
153
|
+
directories, which are not routes. The root page imports that one
|
|
154
|
+
generated file and renders a link per entry, or a short "no modules yet"
|
|
155
|
+
line when the array is empty, which is what the shipped core has. A
|
|
156
|
+
module's screen layer only ever creates its own `page.tsx` inside its own
|
|
157
|
+
`apps/web/src/app/{module}/` directory — always in scope — and nothing
|
|
158
|
+
ever hand-edits the root page or the generated file.
|
|
159
|
+
|
|
160
|
+
`generate-module-routes` is wired as an explicit Nx target on `apps/web`
|
|
161
|
+
(`apps/web/package.json`'s `nx.targets`), cached, with `build`, `dev`,
|
|
162
|
+
`test` and `typecheck` picking it up through `nx.json`'s `targetDefaults`
|
|
163
|
+
the same way its API sibling does.
|
|
164
|
+
|
|
139
165
|
## Steps
|
|
140
166
|
|
|
141
167
|
### 1. Confirm this hasn't already run
|
|
@@ -117,7 +117,10 @@ Every layer's `scope` and `verify` in Step 3 draws from this record.
|
|
|
117
117
|
## Step 3 — derive the layers
|
|
118
118
|
|
|
119
119
|
Read `.hedgehog/BMAD/` for what the system actually does, then decide the
|
|
120
|
-
layers it builds in.
|
|
120
|
+
layers it builds in. Read `00-manifest.md` first for which files the
|
|
121
|
+
archive holds: a compressed intake leaves `04-prd.md` as the only source
|
|
122
|
+
for this step, and where it doesn't settle something a layer boundary
|
|
123
|
+
depends on, ask rather than infer. A layer earns its place by owning a distinct
|
|
121
124
|
artifact that can be verified on its own. Order by dependency first (a
|
|
122
125
|
layer that another layer imports comes first), by contract second (a
|
|
123
126
|
layer that pins an external interface — a schema, a wire format, a public
|
|
@@ -620,6 +623,11 @@ to change only until the file lands. Hard stop.
|
|
|
620
623
|
- That this is an authored core: the sequence was designed for this
|
|
621
624
|
project, not battle-tested across many, and it carries the same
|
|
622
625
|
enforcement as a Golden Core but a weaker guarantee.
|
|
626
|
+
- If `.hedgehog/BMAD/00-manifest.md` records a compressed intake: that
|
|
627
|
+
this architecture was designed from a brief and one batched round
|
|
628
|
+
rather than from elicited drivers, so the stack and layer choices rest
|
|
629
|
+
on thinner input than a full shelf run would give them. Name it here
|
|
630
|
+
rather than in passing — it's part of what the user is accepting.
|
|
623
631
|
|
|
624
632
|
Then state plainly what happens on confirmation, before it happens:
|
|
625
633
|
|
|
@@ -130,7 +130,9 @@ Phase A — one claimed packet per dispatch, in its own context. Step
|
|
|
130
130
|
the `hook` layer's task is `complete` and before `front-end-eng` starts
|
|
131
131
|
the `screen` layer — via `ux-planner`, starting from whatever `planner`
|
|
132
132
|
filed in `docs/design/<module>-notes.md` at planning intake, or the raw
|
|
133
|
-
UX spec directly if that file is absent
|
|
133
|
+
UX spec directly if that file is absent, or — where the archive holds
|
|
134
|
+
neither — from the contract and hook plus whatever the user supplies when
|
|
135
|
+
it asks. Its first run for a module also
|
|
134
136
|
signals to the user that Phase B has started, and is the point a mockup,
|
|
135
137
|
screenshot, or export (Google Stitch, Figma) can be handed over. It
|
|
136
138
|
writes `docs/design/<module>.md`, not its own compiled layer — the
|
|
@@ -220,10 +222,10 @@ starts from its own:
|
|
|
220
222
|
|
|
221
223
|
```bash
|
|
222
224
|
nx g ./tools/generators:schema --module=<module> --fields='<name:type,...>'
|
|
223
|
-
nx g ./tools/generators:contract --module=<module> --fields='<name:type,...>'
|
|
225
|
+
nx g ./tools/generators:contract --module=<module> --fields='<name:type,...>' [--toggleField=<boolField>]
|
|
224
226
|
nx g ./tools/generators:repository --module=<module>
|
|
225
|
-
nx g ./tools/generators:service --module=<module>
|
|
226
|
-
nx g ./tools/generators:controller --module=<module> --fields='<name:type,...>'
|
|
227
|
+
nx g ./tools/generators:service --module=<module> [--toggleField=<boolField>]
|
|
228
|
+
nx g ./tools/generators:controller --module=<module> --fields='<name:type,...>' [--toggleField=<boolField>]
|
|
227
229
|
nx g ./tools/generators:hook --module=<module> [--toggleField=<boolField>]
|
|
228
230
|
nx g ./tools/generators:screen --module=<module>
|
|
229
231
|
```
|
|
@@ -234,8 +236,26 @@ over `string`, `text`, `boolean`, `integer`, and `timestamp`, with a
|
|
|
234
236
|
trailing `?` marking the column nullable
|
|
235
237
|
(`--fields='title:string,done:boolean,dueDate:timestamp?'`); `contract`
|
|
236
238
|
and `controller` take the same list the module's `schema` was generated
|
|
237
|
-
with. `
|
|
238
|
-
|
|
239
|
+
with. A `string` field takes an optional length in parentheses
|
|
240
|
+
(`title:string(500)`), threaded to both the Drizzle `varchar` and the Zod
|
|
241
|
+
`.max()` so the two cannot disagree; omitted, it is 255. Check every
|
|
242
|
+
length against the intent's own rules in the packet's **RELEVANT RULES** —
|
|
243
|
+
a rule like "at most 500 characters" is the field list's business, not
|
|
244
|
+
the authored delta's, and a Zod bound that outruns its column surfaces as
|
|
245
|
+
a driver error at the database rather than a 400 at the boundary.
|
|
246
|
+
|
|
247
|
+
`--toggleField` names a boolean field from the schema to expose as a
|
|
248
|
+
toggle, and is passed to `contract`, `service`, `controller`, and `hook`
|
|
249
|
+
alike — one flag, four layers, so the route, the domain method, the
|
|
250
|
+
handler, and the mutation are generated from one source. The toggle is
|
|
251
|
+
server-side by construction: `POST /<module>/:id/toggle` carries no body,
|
|
252
|
+
and the service reads the current value and flips it inside the same
|
|
253
|
+
transaction its `update` uses. The client sends only the id, so a stale
|
|
254
|
+
cached row cannot overwrite a newer state — which is exactly what
|
|
255
|
+
computing the new value on the client would do, losing one of any two
|
|
256
|
+
flips that raced. Note that `@ts-rest/core` generates no `body` parameter
|
|
257
|
+
for a `c.noBody()` route: the call is `client.toggle({ params: { id } })`,
|
|
258
|
+
and passing `body: undefined` is a compile error.
|
|
239
259
|
|
|
240
260
|
Each generator lands the whole conventional shape of its layer in one
|
|
241
261
|
deterministic step — the package shell (`package.json`, `tsconfig*.json`,
|
|
@@ -263,6 +283,13 @@ depend on. Never register a module by editing `app.module.ts` — a shared
|
|
|
263
283
|
file no module-scoped task can safely touch. Validation is ts-rest + Zod,
|
|
264
284
|
so this core has no Nest DTOs and no class-validator.
|
|
265
285
|
|
|
286
|
+
The root page is the same: `apps/web/src/app/page.tsx` renders whatever
|
|
287
|
+
`module-routes.ts` holds, and that file is regenerated by the
|
|
288
|
+
`generate-module-routes` Nx target from every
|
|
289
|
+
`apps/web/src/app/*/page.tsx` on disk. A screen layer creates its own
|
|
290
|
+
`page.tsx` in its own directory and the root page picks it up — never
|
|
291
|
+
edit either the root page or the generated file to add a link.
|
|
292
|
+
|
|
266
293
|
**A new package needs wiring into the workspace before `hedgehog verify`
|
|
267
294
|
runs on it.** A package that exists on disk isn't yet part of the
|
|
268
295
|
workspace:
|
|
@@ -327,7 +354,12 @@ generator's `timestamp.ts` is one (a shared Zod util every module in the
|
|
|
327
354
|
package imports, written once, sibling to `src/index.ts`). That file needs
|
|
328
355
|
the same widening as the shell itself.
|
|
329
356
|
|
|
330
|
-
|
|
357
|
+
The packet says so: when the package a scope points into has no
|
|
358
|
+
`package.json` on disk yet, `hedgehog next`/`show` prints a **FIRST
|
|
359
|
+
ARRIVAL** section under ALLOWED SCOPE carrying the exact command for that
|
|
360
|
+
task. Run it before building — the widening is only available while the
|
|
361
|
+
task is still `ready`, and a verify that rejects the shell paths blocks
|
|
362
|
+
the task and turns this into a five-command recovery.
|
|
331
363
|
|
|
332
364
|
```bash
|
|
333
365
|
hedgehog override add TASKS-CONTRACT \
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hedgehog-planning-intake
|
|
3
|
-
description: Use on any core for first-run planning intake — Phase 0 runs the vendored BMAD-METHOD planning shelf, shared by every core, and Phase 1 (mining `04-prd.md` into intent records plus the Add-ons decision) is full-stack-app's own procedure. Also use for the Re-entry pass, which mines new scope into additional intents without re-running the shelf, on any core with a module axis to add an intent to (full-stack-app, authored) — landing-page has none, so its own new-scope path runs through `hedgehog-landing-loop`'s Correction Protocol instead. Invoked by the `planner` agent, which decides the path; don't run standalone. landing-page runs this skill's Phase 0 on first run, then mines the same archive through `hedgehog-landing-loop`'s own planning-intake section, that core's counterpart to this skill's Phase 1. An authored core runs this skill's Phase 0, then `hedgehog-core-design`, then this skill's Phase 1 mining against the designed layer sequence. A brownfield adoption (`hedgehog-adopt`) never runs this skill's shelf at all — the drivers BMAD elicits are already settled facts of a repo that already exists.
|
|
3
|
+
description: Use on any core for first-run planning intake — Phase 0 runs the vendored BMAD-METHOD planning shelf, shared by every core, and Phase 1 (mining `04-prd.md` into intent records plus the Add-ons decision) is full-stack-app's own procedure. Phase 0 also defines compressed intake, the path a user's explicit "just build it" choice takes on full-stack-app and authored cores: one batched round of questions in place of the shelf, writing the same archive at the same path so Phase 1, `ux-planner`, and the Re-entry pass all keep their documented source. Also use for the Re-entry pass, which mines new scope into additional intents without re-running the shelf, on any core with a module axis to add an intent to (full-stack-app, authored) — landing-page has none, so its own new-scope path runs through `hedgehog-landing-loop`'s Correction Protocol instead. Invoked by the `planner` agent, which decides the path; don't run standalone. landing-page runs this skill's Phase 0 on first run, then mines the same archive through `hedgehog-landing-loop`'s own planning-intake section, that core's counterpart to this skill's Phase 1. An authored core runs this skill's Phase 0, then `hedgehog-core-design`, then this skill's Phase 1 mining against the designed layer sequence. A brownfield adoption (`hedgehog-adopt`) never runs this skill's shelf at all — the drivers BMAD elicits are already settled facts of a repo that already exists.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Hedgehog Planning Intake
|
|
@@ -95,7 +95,80 @@ Write each skill's output to `.hedgehog/BMAD/`, per the fixed layout:
|
|
|
95
95
|
|
|
96
96
|
Every file/folder carries a one-line attribution header. `00-manifest.md`
|
|
97
97
|
states the source repo, pinned version (`vendor-skills/BMAD/ATTRIBUTION.md` has
|
|
98
|
-
the pinned commit), date,
|
|
98
|
+
the pinned commit), date, which intake mode ran (`full`, below, or
|
|
99
|
+
`compressed`), and which skills ran.
|
|
100
|
+
|
|
101
|
+
### Compressed intake (full-stack-app, authored core)
|
|
102
|
+
|
|
103
|
+
A user who opens with "just build it" — no clarifying questions — is
|
|
104
|
+
asking for something Phase 0's live elicitation can't give them.
|
|
105
|
+
`planner` surfaces that conflict rather than resolving it silently (see
|
|
106
|
+
that agent), and **compressed intake is the defined path when the user
|
|
107
|
+
chooses it**. It is never the default and never offered as the
|
|
108
|
+
easier option: it runs only on an explicit choice, after the conflict has
|
|
109
|
+
been named.
|
|
110
|
+
|
|
111
|
+
Compressed intake replaces the shelf with **one batched round of
|
|
112
|
+
questions covering only what can't be inferred from the user's brief**,
|
|
113
|
+
then writes the archive below directly. Everything else about intake is
|
|
114
|
+
unchanged — Phase 1 mining, Confirm & Lock, and the Add-ons gate all run
|
|
115
|
+
exactly as they do on a full run, against the archive this mode writes.
|
|
116
|
+
|
|
117
|
+
Not available on landing-page: that core's whole chain is a traceability
|
|
118
|
+
audit rooted in a subject statement mined from BMAD's material, so
|
|
119
|
+
compressing the elicitation removes the thing the chain audits against.
|
|
120
|
+
A "just build it" landing-page request is a conflict to surface, not a
|
|
121
|
+
mode to switch into.
|
|
122
|
+
|
|
123
|
+
**The Add-ons decision is what the batched round is for.** Auth, Queue,
|
|
124
|
+
and Mobile must each be *answered* — inferred from a concrete trigger in
|
|
125
|
+
the user's brief, or asked directly in that one round. Compressed intake
|
|
126
|
+
compresses BMAD's elicitation, never `planner`'s gate; an add-on left as
|
|
127
|
+
a guess is the same error here as on a full run.
|
|
128
|
+
|
|
129
|
+
Write the manifest and the PRD always, and the experience spec where the
|
|
130
|
+
brief gives it something to say — at the same path and in the same
|
|
131
|
+
layout:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
.hedgehog/BMAD/
|
|
135
|
+
00-manifest.md # mode: compressed, date, what the batched round covered
|
|
136
|
+
04-prd.md # §3 Glossary and §4 Features only, mined from the brief
|
|
137
|
+
05-ux-spec/
|
|
138
|
+
EXPERIENCE.md # flows and behaviour, where the brief states them
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- **`04-prd.md`** carries the load: Phase 1 below reads §3 Glossary and
|
|
142
|
+
§4 Features, so compressed intake writes exactly those two sections,
|
|
143
|
+
derived from the brief plus the batched answers, in the shape that
|
|
144
|
+
mining table expects. Not a full BMAD PRD — the minimum shape Phase 1
|
|
145
|
+
can walk.
|
|
146
|
+
- **`05-ux-spec/EXPERIENCE.md`** only where the brief actually states
|
|
147
|
+
flows or behaviour ("a list you can filter", "mark done inline"). No
|
|
148
|
+
`DESIGN.md`: visual identity is what a compressed brief is least
|
|
149
|
+
likely to state, and inventing one is exactly the improvisation this
|
|
150
|
+
mode exists to prevent. `ux-planner` reads whichever of the two the
|
|
151
|
+
archive holds, and treats an absent file as its cue to ask (see that
|
|
152
|
+
agent).
|
|
153
|
+
- **`01-brainstorming.md`, `02-brief.md`, `03-prfaq.md`, and
|
|
154
|
+
`06-research.md` are not written.** Those exist on a full run to
|
|
155
|
+
produce a good PRD; compressed intake reaches the PRD by a different
|
|
156
|
+
route. `00-manifest.md` naming them as not-run is the record — an
|
|
157
|
+
empty placeholder file is not.
|
|
158
|
+
|
|
159
|
+
`00-manifest.md` states `mode: compressed`, the date, which files were
|
|
160
|
+
written and which weren't, what the batched round asked, and which
|
|
161
|
+
add-ons were answered directly versus triggered by the brief. That
|
|
162
|
+
manifest is the single record of how this project was planned: **the
|
|
163
|
+
archive exists on every core after intake, whichever mode ran**, so an
|
|
164
|
+
absent `.hedgehog/BMAD/` means intake never ran, not that a compressed
|
|
165
|
+
path was taken.
|
|
166
|
+
|
|
167
|
+
On an authored core, `hedgehog-core-design` reads this archive to pick a
|
|
168
|
+
stack and derive layers. A compressed PRD is thinner input for that than
|
|
169
|
+
a full shelf run, so say so plainly at that skill's own Confirm & Lock —
|
|
170
|
+
the architecture is being designed from a brief rather than from elicited
|
|
171
|
+
drivers, and that's the user's call to accept there.
|
|
99
172
|
|
|
100
173
|
`.hedgehog/BMAD/` is archival and immutable once written, on every core.
|
|
101
174
|
Nothing in `hedgehog-loop`'s day-to-day operation, `hedgehog-bootstrap`,
|
|
@@ -117,6 +190,9 @@ Read `.hedgehog/BMAD/04-prd.md` only — §3 Glossary and §4 Features.
|
|
|
117
190
|
Nothing else in `.hedgehog/BMAD/` is read again: brainstorming, brief,
|
|
118
191
|
PR-FAQ, and deep-recon existed to produce a good PRD, and the UX spec is
|
|
119
192
|
read later, once per module, by `ux-planner`, not by this mining pass.
|
|
193
|
+
This is the same read on either intake mode — a compressed archive writes
|
|
194
|
+
those two sections directly, so mining has its documented source
|
|
195
|
+
whichever mode ran.
|
|
120
196
|
Mining is mechanical, not interpretive — one graph row per PRD element,
|
|
121
197
|
per this table:
|
|
122
198
|
|
|
@@ -134,6 +210,21 @@ Procedure:
|
|
|
134
210
|
`outcome` drawn directly from the Feature's description (split the
|
|
135
211
|
description across the two if it names both the capability and the
|
|
136
212
|
result; otherwise the same sentence can serve both).
|
|
213
|
+
|
|
214
|
+
On a module-axis core (`full-stack-app`, and any authored core whose
|
|
215
|
+
layers scope by `{module}`), **name the id plural** — `tasks`, not
|
|
216
|
+
`task`; `order-items`, not `order-item`. The id is substituted as
|
|
217
|
+
`{module}` into every layer's scope glob and verify command, and the
|
|
218
|
+
generators each layer's packet names take the module plural, so a
|
|
219
|
+
singular id compiles a graph scoped to a directory the generator will
|
|
220
|
+
never write. This is the moment that choice is cheapest: it is one
|
|
221
|
+
string here, and a Correction Protocol case across every compiled task
|
|
222
|
+
three layers later. `hedgehog intent add` and `hedgehog plan` both
|
|
223
|
+
report an id that looks singular — `plan` for as long as none of that
|
|
224
|
+
intent's tasks has been started, since every route into the graph
|
|
225
|
+
(`--file`, a hand-written intent file, `db rebuild`) converges there.
|
|
226
|
+
Both are advisory: `billing` and `search` are legitimately singular, so
|
|
227
|
+
read the report and decide, rather than renaming on sight.
|
|
137
228
|
2. **Walk that Feature's FRs.** Each FR's "Consequences (testable)" list
|
|
138
229
|
items become that intent's `requirements` with `kind='acceptance'`,
|
|
139
230
|
one per item, verbatim or lightly tightened — no rephrasing that
|
|
@@ -177,7 +268,9 @@ stops being true, so it's a hard stop, not a recap in passing.
|
|
|
177
268
|
`requirements` (rule/acceptance), and its `depends_on` list.
|
|
178
269
|
- The Add-ons decision (Auth / Queue / Mobile, each explicitly on or
|
|
179
270
|
off, with the one-line reason).
|
|
180
|
-
- Which
|
|
271
|
+
- Which intake mode ran, and on a full run which BMAD skills ran — or,
|
|
272
|
+
on a compressed run, what the batched round asked and what was inferred
|
|
273
|
+
from the brief without asking. Either way, where the output lives
|
|
181
274
|
(`.hedgehog/BMAD/`).
|
|
182
275
|
|
|
183
276
|
Then state plainly what happens on confirmation, before it happens:
|
|
@@ -215,9 +308,13 @@ their commits.
|
|
|
215
308
|
|
|
216
309
|
1. **Read `.hedgehog/BMAD/` for context**, chiefly `02-brief.md` and
|
|
217
310
|
`04-prd.md` — what this project is, and what its existing vocabulary
|
|
218
|
-
calls things. Read-
|
|
219
|
-
|
|
220
|
-
|
|
311
|
+
calls things. Read `00-manifest.md` first for which of those the
|
|
312
|
+
archive actually holds: on a compressed archive that's `04-prd.md` and
|
|
313
|
+
the manifest's own record of the batched round, which carry the same
|
|
314
|
+
two things this step needs (the product, and its vocabulary).
|
|
315
|
+
Read-only. The new scope has to sit inside the same product and reuse
|
|
316
|
+
its terms; you're extending a project, not starting a neighbouring
|
|
317
|
+
one.
|
|
221
318
|
2. **Read the existing graph**: `hedgehog status` for what's built, and
|
|
222
319
|
the existing intent ids for the vocabulary already in play. New scope
|
|
223
320
|
names must not collide with an existing intent id.
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
Backend-first, schema → contract → repository → service → controller, then
|
|
4
4
|
hook → UX rationale → screen, per domain module. See `.hedgehog/BMAD/` for
|
|
5
5
|
the archival planning intake output — BMAD-METHOD's brainstorming, brief,
|
|
6
|
-
PRD, and UX spec, written once by `planner` and never edited after.
|
|
6
|
+
PRD, and UX spec, written once by `planner` and never edited after. Its
|
|
7
|
+
`00-manifest.md` records which intake mode produced it; a compressed
|
|
8
|
+
archive holds the PRD and whatever flows the brief stated, and nothing
|
|
9
|
+
else.
|
|
7
10
|
`.hedgehog/addons.yaml` carries this core's Add-ons decision
|
|
8
11
|
(Auth/Queue/Mobile, each on or off) — check it before assuming any
|
|
9
12
|
add-on's infra exists.
|
|
@@ -49,7 +52,9 @@ steps from memory:
|
|
|
49
52
|
- **`ux-planner`** — once per module in Phase B, after the hook exists and
|
|
50
53
|
before the screen: writes `docs/design/<module>.md`, reading
|
|
51
54
|
`.hedgehog/BMAD/05-ux-spec/` directly (or
|
|
52
|
-
`docs/design/<module>-notes.md` if a prior run already filed one).
|
|
55
|
+
`docs/design/<module>-notes.md` if a prior run already filed one). Where
|
|
56
|
+
the archive holds no UX spec, it asks for visual input rather than
|
|
57
|
+
inferring a direction.
|
|
53
58
|
- **`front-end-eng`** — builds each module's Phase B layers (hook, screen)
|
|
54
59
|
from the ux-planner rationale, one `hedgehog next` packet at a time,
|
|
55
60
|
gated by `hedgehog verify`.
|