@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.
Files changed (30) hide show
  1. package/README.md +25 -26
  2. package/bin/cli.mjs +176 -9
  3. package/package.json +10 -2
  4. package/src/agents/planner.md +38 -6
  5. package/src/agents/ux-planner.md +29 -13
  6. package/src/db/core.mjs +10 -8
  7. package/src/db/next.mjs +72 -3
  8. package/src/db/rebuild.mjs +72 -14
  9. package/src/golden-cores/full-stack-app/apps/web/package.json +12 -0
  10. package/src/golden-cores/full-stack-app/apps/web/src/app/module-routes.ts +13 -0
  11. package/src/golden-cores/full-stack-app/apps/web/src/app/page.tsx +21 -1
  12. package/src/golden-cores/full-stack-app/nx.json +7 -3
  13. package/src/golden-cores/full-stack-app/packages/db/src/lib/db.ts +15 -1
  14. package/src/golden-cores/full-stack-app/tools/generate-module-routes.cjs +89 -0
  15. package/src/golden-cores/full-stack-app/tools/generators/contract/generator.ts +59 -6
  16. package/src/golden-cores/full-stack-app/tools/generators/contract/schema.json +5 -1
  17. package/src/golden-cores/full-stack-app/tools/generators/controller/generator.ts +75 -8
  18. package/src/golden-cores/full-stack-app/tools/generators/controller/schema.json +5 -1
  19. package/src/golden-cores/full-stack-app/tools/generators/fields.ts +38 -4
  20. package/src/golden-cores/full-stack-app/tools/generators/hook/generator.ts +29 -12
  21. package/src/golden-cores/full-stack-app/tools/generators/hook/schema.json +1 -1
  22. package/src/golden-cores/full-stack-app/tools/generators/schema/schema.json +1 -1
  23. package/src/golden-cores/full-stack-app/tools/generators/service/generator.ts +82 -6
  24. package/src/golden-cores/full-stack-app/tools/generators/service/schema.json +4 -0
  25. package/src/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +26 -0
  26. package/src/skills/hedgehog-core-design/SKILL.md +9 -1
  27. package/src/skills/hedgehog-loop/SKILL.md +39 -7
  28. package/src/skills/hedgehog-planning-intake/SKILL.md +103 -6
  29. package/src/templates/CLAUDE.core.full-stack-app.md +7 -2
  30. 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 type = (nullable ? rawType.slice(0, -1) : rawType) as FieldType;
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: 255 })`,
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: 'z.string().min(1).max(255)',
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 update = useUpdate${entityPascal}();
201
-
202
- return {
203
- ...update,
204
- mutate: (variables: { id: string; ${toggleField}: boolean }) =>
205
- update.mutate({ id: variables.id, ${toggleField}: !variables.${toggleField} }),
206
- mutateAsync: (variables: { id: string; ${toggleField}: boolean }) =>
207
- update.mutateAsync({
208
- id: variables.id,
209
- ${toggleField}: !variables.${toggleField},
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 dedicated toggle mutation. Omit when the module has none."
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(`${root}/src/lib/${names.entityKebab}.service.ts`, serviceFile(names));
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
- function serviceFile(names: ModuleNames): string {
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. A layer earns its place by owning a distinct
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. Its first run for a module also
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. `hook`'s optional `--toggleField` names a boolean field from the
238
- schema to expose as a dedicated toggle mutation.
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
- Widen that one task, before building it:
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, and which skills ran.
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 BMAD skills ran and where their output lives
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-only. The new scope has to sit inside the same
219
- product and reuse its terms; you're extending a project, not starting
220
- a neighbouring one.
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`.