@slowcook-ai/cli 0.6.14 → 0.7.1

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.
@@ -11,11 +11,29 @@ export interface RelationshipOptions {
11
11
  }
12
12
  export declare function analyzeRelationship(input: RelationshipInput, options: RelationshipOptions): Promise<RelationshipVerdict>;
13
13
  export declare function parseVerdict(raw: string): RelationshipVerdict;
14
+ /**
15
+ * Format a spec reference for user-facing prose (issue comments, PR bodies).
16
+ * Prefers GitHub's native issue-number reference (\`#N\`) which auto-renders
17
+ * as a hyperlink. Falls back to the internal \`story-N\` id if the spec has
18
+ * no \`source_issue\` recorded.
19
+ *
20
+ * Inside YAML specs and internal slowcook state, keep using \`story-N\` —
21
+ * it's the stable canonical identifier. This function is only for prose.
22
+ */
23
+ export declare function specRefForProse(spec: Spec): string;
14
24
  /** Comment body posted to the issue when relationship analysis surfaces a conflict. */
15
25
  export declare function overlapCommentBody(verdict: Extract<RelationshipVerdict, {
16
26
  kind: "overlap";
17
- }>): string;
27
+ }>, activeSpecs?: Spec[]): string;
28
+ /**
29
+ * Comment body for the follow_up verdict — posted informationally, does NOT
30
+ * halt refinement. Tells the PM "I noticed this builds on X, will cite it in
31
+ * the resulting spec, proceeding with refinement now."
32
+ */
33
+ export declare function followUpCommentBody(verdict: Extract<RelationshipVerdict, {
34
+ kind: "follow_up";
35
+ }>, activeSpecs?: Spec[]): string;
18
36
  export declare function contradictionCommentBody(verdict: Extract<RelationshipVerdict, {
19
37
  kind: "contradiction";
20
- }>, hasChangeOfMind: boolean): string;
38
+ }>, hasChangeOfMind: boolean, activeSpecs?: Spec[]): string;
21
39
  //# sourceMappingURL=relationship.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"relationship.d.ts","sourceRoot":"","sources":["../../../src/commands/refine/relationship.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,IAAI,EAAE,mBAAmB,EAAgB,MAAM,mBAAmB,CAAC;AASjF,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,IAAI,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,SAAS,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,iBAAiB,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAiDD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,CA0B7D;AAmBD,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC,GAAG,MAAM,CAerG;AAED,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE;IAAE,IAAI,EAAE,eAAe,CAAA;CAAE,CAAC,EAChE,eAAe,EAAE,OAAO,GACvB,MAAM,CAwBR"}
1
+ {"version":3,"file":"relationship.d.ts","sourceRoot":"","sources":["../../../src/commands/refine/relationship.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,IAAI,EAAE,mBAAmB,EAAgB,MAAM,mBAAmB,CAAC;AASjF,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,IAAI,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,SAAS,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,iBAAiB,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAiDD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,CA6B7D;AAmBD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAIlD;AAYD,uFAAuF;AACvF,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC,EAC1D,WAAW,GAAE,IAAI,EAAO,GACvB,MAAM,CAeR;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,CAAC,EAC5D,WAAW,GAAE,IAAI,EAAO,GACvB,MAAM,CASR;AAED,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE;IAAE,IAAI,EAAE,eAAe,CAAA;CAAE,CAAC,EAChE,eAAe,EAAE,OAAO,EACxB,WAAW,GAAE,IAAI,EAAO,GACvB,MAAM,CAwBR"}
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { RELATIONSHIP_ANALYST_SYSTEM } from "./prompts.js";
3
3
  const VerdictSchema = z.object({
4
- kind: z.enum(["new_or_independent", "overlap", "contradiction"]),
4
+ kind: z.enum(["new_or_independent", "follow_up", "overlap", "contradiction"]),
5
5
  conflicting_ids: z.array(z.string()),
6
6
  reasoning: z.string(),
7
7
  });
@@ -80,6 +80,9 @@ export function parseVerdict(raw) {
80
80
  if (v.kind === "new_or_independent") {
81
81
  return { kind: "new_or_independent", reasoning: v.reasoning };
82
82
  }
83
+ if (v.kind === "follow_up") {
84
+ return { kind: "follow_up", related_ids: v.conflicting_ids, reasoning: v.reasoning };
85
+ }
83
86
  if (v.kind === "overlap") {
84
87
  return { kind: "overlap", conflicting_ids: v.conflicting_ids, reasoning: v.reasoning };
85
88
  }
@@ -103,9 +106,34 @@ function extractJsonObject(raw) {
103
106
  }
104
107
  return trimmed;
105
108
  }
109
+ /**
110
+ * Format a spec reference for user-facing prose (issue comments, PR bodies).
111
+ * Prefers GitHub's native issue-number reference (\`#N\`) which auto-renders
112
+ * as a hyperlink. Falls back to the internal \`story-N\` id if the spec has
113
+ * no \`source_issue\` recorded.
114
+ *
115
+ * Inside YAML specs and internal slowcook state, keep using \`story-N\` —
116
+ * it's the stable canonical identifier. This function is only for prose.
117
+ */
118
+ export function specRefForProse(spec) {
119
+ const issueNum = spec.source_issue?.match(/#?(\d+)/)?.[1];
120
+ if (issueNum)
121
+ return `#${issueNum} (story-${spec.story_id})`;
122
+ return `story-${spec.story_id}`;
123
+ }
124
+ function specsRef(ids, activeSpecs) {
125
+ return ids
126
+ .map((id) => {
127
+ const found = activeSpecs.find((s) => s.story_id === id);
128
+ if (found)
129
+ return specRefForProse(found);
130
+ return `\`story-${id}\``;
131
+ })
132
+ .join(", ");
133
+ }
106
134
  /** Comment body posted to the issue when relationship analysis surfaces a conflict. */
107
- export function overlapCommentBody(verdict) {
108
- const specs = verdict.conflicting_ids.map((id) => `\`story-${id}\``).join(", ");
135
+ export function overlapCommentBody(verdict, activeSpecs = []) {
136
+ const specs = specsRef(verdict.conflicting_ids, activeSpecs);
109
137
  return `### slowcook · overlap detected
110
138
 
111
139
  This issue overlaps with existing active specs: ${specs}.
@@ -120,8 +148,23 @@ Please choose how to proceed:
120
148
 
121
149
  I'll pause refinement until the issue body or labels are updated.`;
122
150
  }
123
- export function contradictionCommentBody(verdict, hasChangeOfMind) {
124
- const specs = verdict.conflicting_ids.map((id) => `\`story-${id}\``).join(", ");
151
+ /**
152
+ * Comment body for the follow_up verdict posted informationally, does NOT
153
+ * halt refinement. Tells the PM "I noticed this builds on X, will cite it in
154
+ * the resulting spec, proceeding with refinement now."
155
+ */
156
+ export function followUpCommentBody(verdict, activeSpecs = []) {
157
+ const specs = specsRef(verdict.related_ids, activeSpecs);
158
+ return `### slowcook · follow-up detected
159
+
160
+ This issue builds on top of ${specs} — it fulfills scope the prior spec(s) explicitly deferred via \`non_goals\` or "future story" phrasing. That's a legitimate scope-growth pattern, not a duplication. **Continuing with refinement.**
161
+
162
+ **Reasoning:** ${verdict.reasoning}
163
+
164
+ The resulting spec will list the predecessor(s) in its \`related_specs\` field so the chain is auditable.`;
165
+ }
166
+ export function contradictionCommentBody(verdict, hasChangeOfMind, activeSpecs = []) {
167
+ const specs = specsRef(verdict.conflicting_ids, activeSpecs);
125
168
  if (hasChangeOfMind) {
126
169
  return `### slowcook · change-of-mind authorized
127
170
 
@@ -1 +1 @@
1
- {"version":3,"file":"relationship.js","sourceRoot":"","sources":["../../../src/commands/refine/relationship.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,EAAE,2BAA2B,EAAE,MAAM,cAAc,CAAC;AAE3D,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7B,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,oBAAoB,EAAE,SAAS,EAAE,eAAe,CAAC,CAAC;IAChE,eAAe,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACpC,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAAC,CAAC;AAaH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,KAAwB,EACxB,OAA4B;IAE5B,MAAM,WAAW,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;IAC5C,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC;QACrC,MAAM,EAAE,2BAA2B;QACnC,WAAW,EAAE,IAAI;QACjB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC;QAClD,SAAS,EAAE,IAAI;QACf,yEAAyE;KAC1E,CAAC,CAAC;IAEH,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IACjC,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAwB;IAChD,MAAM,WAAW,GACf,KAAK,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC;QAC5B,CAAC,CAAC,gCAAgC;QAClC,CAAC,CAAC,KAAK,CAAC,WAAW;aACd,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,QAAQ,MAAM,CAAC,CAAC,KAAK,aAAa,CAAC,CAAC,MAAM,KAAK,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC;aAC3F,IAAI,CAAC,MAAM,CAAC,CAAC;IAEtB,OAAO;;SAEA,KAAK,CAAC,UAAU;;;EAGvB,KAAK,CAAC,SAAS;;;;EAIf,WAAW;;;;+HAIkH,CAAC;AAChI,CAAC;AAED,SAAS,WAAW,CAAC,IAAU;IAC7B,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC;QAC3B,KAAK,CAAC,IAAI,CAAC,eAAe,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC1D,CAAC;IACD,IAAI,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC;QAClD,MAAM,SAAS,GAAG,IAAI,CAAC,YAAY;aAChC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;YACT,MAAM,GAAG,GAAG,CAAuC,CAAC;YACpD,OAAO,GAAG,GAAG,CAAC,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC;QACnD,CAAC,CAAC;aACD,IAAI,CAAC,IAAI,CAAC,CAAC;QACd,KAAK,CAAC,IAAI,CAAC,QAAQ,SAAS,EAAE,CAAC,CAAC;IAClC,CAAC;IACD,IAAI,IAAI,CAAC,oBAAoB,CAAC,MAAM,EAAE,CAAC;QACrC,KAAK,CAAC,IAAI,CAAC,eAAe,IAAI,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IACjF,CAAC;IACD,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QAC1B,KAAK,CAAC,IAAI,CAAC,cAAc,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACxD,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,MAAM,OAAO,GAAG,iBAAiB,CAAC,GAAG,CAAC,CAAC;IACvC,IAAI,IAAa,CAAC;IAClB,IAAI,CAAC;QACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CACb,4DAA4D,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CACpF,CAAC;IACJ,CAAC;IACD,MAAM,MAAM,GAAG,aAAa,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IAC7C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CACb,yDAAyD,MAAM,CAAC,KAAK,CAAC,MAAM;aACzE,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,OAAO,EAAE,CAAC;aAC/C,IAAI,CAAC,IAAI,CAAC,EAAE,CAChB,CAAC;IACJ,CAAC;IACD,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC;IACtB,IAAI,CAAC,CAAC,IAAI,KAAK,oBAAoB,EAAE,CAAC;QACpC,OAAO,EAAE,IAAI,EAAE,oBAAoB,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC;IAChE,CAAC;IACD,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,eAAe,EAAE,CAAC,CAAC,eAAe,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC;IACzF,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,eAAe,EAAE,CAAC,CAAC,eAAe,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC;AAC/F,CAAC;AAED,sFAAsF;AACtF,SAAS,iBAAiB,CAAC,GAAW;IACpC,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IAC3B,eAAe;IACf,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,OAAO,CAAC;IAC5C,qBAAqB;IACrB,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;IAC5D,IAAI,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9C,sCAAsC;IACtC,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACxC,MAAM,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAC3C,IAAI,UAAU,KAAK,CAAC,CAAC,IAAI,SAAS,GAAG,UAAU,EAAE,CAAC;QAChD,OAAO,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,SAAS,GAAG,CAAC,CAAC,CAAC;IAClD,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,kBAAkB,CAAC,OAA0D;IAC3F,MAAM,KAAK,GAAG,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChF,OAAO;;kDAEyC,KAAK;;iBAEtC,OAAO,CAAC,SAAS;;;;;;;;kEAQgC,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,wBAAwB,CACtC,OAAgE,EAChE,eAAwB;IAExB,MAAM,KAAK,GAAG,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChF,IAAI,eAAe,EAAE,CAAC;QACpB,OAAO;;gDAEqC,KAAK;;iBAEpC,OAAO,CAAC,SAAS;;wCAEM,CAAC;IACvC,CAAC;IACD,OAAO;;gDAEuC,KAAK;;iBAEpC,OAAO,CAAC,SAAS;;;;;;;;+DAQ6B,CAAC;AAChE,CAAC"}
1
+ {"version":3,"file":"relationship.js","sourceRoot":"","sources":["../../../src/commands/refine/relationship.ts"],"names":[],"mappings":"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"}
@@ -34,44 +34,44 @@ export declare const schemas: {
34
34
  supersedes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
35
35
  superseded_by: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNull]>>;
36
36
  }, "strip", z.ZodTypeAny, {
37
- title: string;
38
37
  status: "draft" | "active" | "superseded";
38
+ title: string;
39
+ supersedes?: string[] | undefined;
40
+ superseded_by?: string | null | undefined;
39
41
  source_issue?: string | undefined;
40
42
  tags?: string[] | undefined;
41
43
  summary?: string | undefined;
42
- supersedes?: string[] | undefined;
43
- superseded_by?: string | null | undefined;
44
44
  }, {
45
- title: string;
46
45
  status: "draft" | "active" | "superseded";
46
+ title: string;
47
+ supersedes?: string[] | undefined;
48
+ superseded_by?: string | null | undefined;
47
49
  source_issue?: string | undefined;
48
50
  tags?: string[] | undefined;
49
51
  summary?: string | undefined;
50
- supersedes?: string[] | undefined;
51
- superseded_by?: string | null | undefined;
52
52
  }>>;
53
53
  }, "strip", z.ZodTypeAny, {
54
54
  schema_version: 1;
55
55
  stories: Record<string, {
56
- title: string;
57
56
  status: "draft" | "active" | "superseded";
57
+ title: string;
58
+ supersedes?: string[] | undefined;
59
+ superseded_by?: string | null | undefined;
58
60
  source_issue?: string | undefined;
59
61
  tags?: string[] | undefined;
60
62
  summary?: string | undefined;
61
- supersedes?: string[] | undefined;
62
- superseded_by?: string | null | undefined;
63
63
  }>;
64
64
  $schema?: string | undefined;
65
65
  }, {
66
66
  schema_version: 1;
67
67
  stories: Record<string, {
68
- title: string;
69
68
  status: "draft" | "active" | "superseded";
69
+ title: string;
70
+ supersedes?: string[] | undefined;
71
+ superseded_by?: string | null | undefined;
70
72
  source_issue?: string | undefined;
71
73
  tags?: string[] | undefined;
72
74
  summary?: string | undefined;
73
- supersedes?: string[] | undefined;
74
- superseded_by?: string | null | undefined;
75
75
  }>;
76
76
  $schema?: string | undefined;
77
77
  }>;
@@ -109,20 +109,20 @@ export declare const schemas: {
109
109
  note: z.ZodOptional<z.ZodString>;
110
110
  }, "strip", z.ZodTypeAny, {
111
111
  id: string;
112
- relationship: "superseded" | "overlap" | "related";
112
+ relationship: "overlap" | "superseded" | "related";
113
113
  note?: string | undefined;
114
114
  }, {
115
115
  id: string;
116
- relationship: "superseded" | "overlap" | "related";
116
+ relationship: "overlap" | "superseded" | "related";
117
117
  note?: string | undefined;
118
118
  }>, "many">>;
119
119
  }, "strip", z.ZodTypeAny, {
120
- title: string;
121
120
  status: "draft" | "active" | "superseded";
122
- supersedes: string[];
123
- superseded_by: string | null;
124
121
  story_id: string;
122
+ title: string;
125
123
  created_at: string;
124
+ supersedes: string[];
125
+ superseded_by: string | null;
126
126
  actors: {
127
127
  name: string;
128
128
  notes?: string | undefined;
@@ -131,25 +131,25 @@ export declare const schemas: {
131
131
  invariants: string[];
132
132
  acceptance_scenarios: string[];
133
133
  non_goals: string[];
134
- source_issue?: string | undefined;
135
- $schema?: string | undefined;
136
134
  token_budget_usd?: number | undefined;
137
135
  estimate?: "small" | "medium" | "large" | undefined;
136
+ source_issue?: string | undefined;
138
137
  refined_by?: string | undefined;
139
138
  api_contract?: unknown[] | undefined;
140
139
  ui_behavior?: Record<string, string> | undefined;
141
140
  related_specs?: {
142
141
  id: string;
143
- relationship: "superseded" | "overlap" | "related";
142
+ relationship: "overlap" | "superseded" | "related";
144
143
  note?: string | undefined;
145
144
  }[] | undefined;
145
+ $schema?: string | undefined;
146
146
  }, {
147
- title: string;
148
147
  status: "draft" | "active" | "superseded";
149
- supersedes: string[];
150
- superseded_by: string | null;
151
148
  story_id: string;
149
+ title: string;
152
150
  created_at: string;
151
+ supersedes: string[];
152
+ superseded_by: string | null;
153
153
  actors: {
154
154
  name: string;
155
155
  notes?: string | undefined;
@@ -158,18 +158,18 @@ export declare const schemas: {
158
158
  invariants: string[];
159
159
  acceptance_scenarios: string[];
160
160
  non_goals: string[];
161
- source_issue?: string | undefined;
162
- $schema?: string | undefined;
163
161
  token_budget_usd?: number | undefined;
164
162
  estimate?: "small" | "medium" | "large" | undefined;
163
+ source_issue?: string | undefined;
165
164
  refined_by?: string | undefined;
166
165
  api_contract?: unknown[] | undefined;
167
166
  ui_behavior?: Record<string, string> | undefined;
168
167
  related_specs?: {
169
168
  id: string;
170
- relationship: "superseded" | "overlap" | "related";
169
+ relationship: "overlap" | "superseded" | "related";
171
170
  note?: string | undefined;
172
171
  }[] | undefined;
172
+ $schema?: string | undefined;
173
173
  }>;
174
174
  SpecIndexEntry: z.ZodObject<{
175
175
  title: z.ZodString;
@@ -180,21 +180,21 @@ export declare const schemas: {
180
180
  supersedes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
181
181
  superseded_by: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNull]>>;
182
182
  }, "strip", z.ZodTypeAny, {
183
- title: string;
184
183
  status: "draft" | "active" | "superseded";
184
+ title: string;
185
+ supersedes?: string[] | undefined;
186
+ superseded_by?: string | null | undefined;
185
187
  source_issue?: string | undefined;
186
188
  tags?: string[] | undefined;
187
189
  summary?: string | undefined;
188
- supersedes?: string[] | undefined;
189
- superseded_by?: string | null | undefined;
190
190
  }, {
191
- title: string;
192
191
  status: "draft" | "active" | "superseded";
192
+ title: string;
193
+ supersedes?: string[] | undefined;
194
+ superseded_by?: string | null | undefined;
193
195
  source_issue?: string | undefined;
194
196
  tags?: string[] | undefined;
195
197
  summary?: string | undefined;
196
- supersedes?: string[] | undefined;
197
- superseded_by?: string | null | undefined;
198
198
  }>;
199
199
  };
200
200
  /** Minimal helper for the agent to build a SpecIndexEntry from a Spec. */
@@ -43,6 +43,39 @@ export type TestgenOutcome = {
43
43
  */
44
44
  export declare function runTestgen(ctx: TestgenContext): Promise<TestgenOutcome>;
45
45
  export declare function buildProjectContext(repoRoot: string): string;
46
+ /**
47
+ * Phase B2 (0.7.0) testgen output: one test file, zero-or-more route stubs,
48
+ * zero-or-more mock helpers. The LLM emits these as XML-tagged blocks
49
+ * (see TESTGEN_SYSTEM for the exact format); slowcook parses, de-duplicates
50
+ * against existing files, and writes only what's new.
51
+ */
52
+ export interface TestgenBundle {
53
+ testContent: string;
54
+ stubs: Array<{
55
+ path: string;
56
+ contents: string;
57
+ }>;
58
+ helpers: Array<{
59
+ path: string;
60
+ contents: string;
61
+ }>;
62
+ }
63
+ /**
64
+ * Parse XML-tagged multi-artifact output into a TestgenBundle.
65
+ *
66
+ * Accepted shape (from the prompt):
67
+ * <test_file>...</test_file>
68
+ * <stub path="src/app/api/foo/route.ts">...</stub> (zero or more)
69
+ * <helper path="tests/helpers/mocks/bar.ts">...</helper> (zero or more)
70
+ *
71
+ * Tolerant of code-fenced output: if the LLM wraps the whole thing in
72
+ * ```, we strip it. If a block's contents are themselves code-fenced,
73
+ * we strip those too — tier-1 test / helper / stub files are raw TS.
74
+ *
75
+ * Throws if `<test_file>` is missing or empty — that's the one mandatory
76
+ * artifact.
77
+ */
78
+ export declare function parseTestgenBundle(raw: string, storyId: string): TestgenBundle;
46
79
  /**
47
80
  * Tier-1 conformance lint. Run on every generated test file before commit.
48
81
  * Catches patterns the prompt forbids — inline `vi.mock`, `fetch(...)`,
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../../src/commands/testgen/agent.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAQ,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AASlD,eAAO,MAAM,iBAAiB,gBAAgB,CAAC;AAC/C,eAAO,MAAM,qBAAqB,oBAAoB,CAAC;AAEvD,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AACzD,eAAO,MAAM,aAAa,uBAAuB,CAAC;AAClD,eAAO,MAAM,gBAAgB,wBAAwB,CAAC;AAEtD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,YAAY,CAAC;IACpB,GAAG,EAAE,SAAS,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,GAAG,EAAE,OAAO,CAAC;IACb,2EAA2E;IAC3E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,mCAAmC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,gCAAgC;IAChC,GAAG,EAAE,IAAI,CAAC;CACX;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,EAAE,CAAA;CAAE,GACzG;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5E;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAqH7E;AAgCD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA6E5D;AA4BD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AA6DD,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAepF;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BjF"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../../src/commands/testgen/agent.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,YAAY,EAAQ,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AASlD,eAAO,MAAM,iBAAiB,gBAAgB,CAAC;AAC/C,eAAO,MAAM,qBAAqB,oBAAoB,CAAC;AAEvD,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AACzD,eAAO,MAAM,aAAa,uBAAuB,CAAC;AAClD,eAAO,MAAM,gBAAgB,wBAAwB,CAAC;AAEtD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,YAAY,CAAC;IACpB,GAAG,EAAE,SAAS,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,GAAG,EAAE,OAAO,CAAC;IACb,2EAA2E;IAC3E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,mCAAmC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,gCAAgC;IAChC,GAAG,EAAE,IAAI,CAAC;CACX;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,EAAE,CAAA;CAAE,GACzG;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5E;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CA8I7E;AAkED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA8F5D;AAsCD;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpD;AAqBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,aAAa,CAiC9E;AASD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AA6DD,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAepF;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BjF"}
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, } from "node:fs";
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync, } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { buildManifest, } from "@slowcook-ai/core";
@@ -26,14 +26,14 @@ export async function runTestgen(ctx) {
26
26
  const toRemove = [];
27
27
  for (const spec of specs) {
28
28
  const projectContext = buildProjectContext(ctx.repoRoot);
29
- const fileContents = await generateTestFile(spec, ctx, projectContext);
29
+ const bundle = await generateTestBundle(spec, ctx, projectContext);
30
30
  const testPath = join(TESTS_INTEGRATION_DIR, `story-${spec.story_id}.test.ts`);
31
31
  // Tier-1 conformance gate: if the LLM slipped back to tier-0 habits
32
32
  // (inline vi.mock, fetch(), etc.), we refuse to ship the file. Halts
33
33
  // loudly here rather than quietly producing HTTP-loopback tests the
34
34
  // brewing loop can't ratchet against. The caller can re-run with a
35
35
  // different seed or hand-edit and re-run testgen.
36
- const violations = lintTierOneTest(testPath, fileContents);
36
+ const violations = lintTierOneTest(testPath, bundle.testContent);
37
37
  if (violations.length > 0) {
38
38
  const details = violations
39
39
  .slice(0, 10)
@@ -44,7 +44,12 @@ export async function runTestgen(ctx) {
44
44
  `The LLM emitted patterns banned by docs/plans/0.7-testgen-two-tier.md §4.1-§7.3. ` +
45
45
  `Re-run testgen with a different model/seed, or hand-edit the generated file to use project mock helpers.`);
46
46
  }
47
- const manifestIds = extractTestIdsFromFile(testPath, fileContents);
47
+ // De-dupe stubs + helpers: skip anything whose target file exists and
48
+ // isn't a @slowcook-stub (for stubs) or isn't empty (for helpers). This
49
+ // lets testgen re-run safely without clobbering in-progress impls.
50
+ const stubsToWrite = bundle.stubs.filter((s) => shouldWriteStub(ctx.repoRoot, s.path));
51
+ const helpersToWrite = bundle.helpers.filter((h) => shouldWriteHelper(ctx.repoRoot, h.path));
52
+ const manifestIds = extractTestIdsFromFile(testPath, bundle.testContent);
48
53
  const manifest = buildManifest({
49
54
  slowcookVersion: ctx.cliVersion,
50
55
  storyId: spec.story_id,
@@ -52,7 +57,14 @@ export async function runTestgen(ctx) {
52
57
  suites: [{ suite: "backend", command: "npx vitest list", test_count: manifestIds.length }],
53
58
  now: ctx.now,
54
59
  });
55
- generated.push({ spec, testPath, fileContents, manifest });
60
+ generated.push({
61
+ spec,
62
+ testPath,
63
+ fileContents: bundle.testContent,
64
+ manifest,
65
+ stubs: stubsToWrite,
66
+ helpers: helpersToWrite,
67
+ });
56
68
  for (const superseded of spec.supersedes) {
57
69
  toRemove.push(superseded);
58
70
  }
@@ -62,6 +74,12 @@ export async function runTestgen(ctx) {
62
74
  writeFileAt(ctx.repoRoot, g.testPath, g.fileContents);
63
75
  const manifestPath = join(MANIFESTS_DIR, `story-${g.spec.story_id}.json`);
64
76
  writeFileAt(ctx.repoRoot, manifestPath, JSON.stringify(g.manifest, null, 2) + "\n");
77
+ for (const stub of g.stubs) {
78
+ writeFileAt(ctx.repoRoot, stub.path, stub.contents);
79
+ }
80
+ for (const helper of g.helpers) {
81
+ writeFileAt(ctx.repoRoot, helper.path, helper.contents);
82
+ }
65
83
  }
66
84
  const actuallyRemoved = [];
67
85
  for (const id of toRemove) {
@@ -77,6 +95,10 @@ export async function runTestgen(ctx) {
77
95
  for (const g of generated) {
78
96
  await ctx.forge.git.stage(g.testPath);
79
97
  await ctx.forge.git.stage(join(MANIFESTS_DIR, `story-${g.spec.story_id}.json`));
98
+ for (const stub of g.stubs)
99
+ await ctx.forge.git.stage(stub.path);
100
+ for (const helper of g.helpers)
101
+ await ctx.forge.git.stage(helper.path);
80
102
  }
81
103
  for (const id of actuallyRemoved) {
82
104
  await ctx.forge.git.stage(join(TESTS_INTEGRATION_DIR, `story-${id}.test.ts`));
@@ -123,6 +145,38 @@ export async function runTestgen(ctx) {
123
145
  throw e;
124
146
  }
125
147
  }
148
+ /**
149
+ * Decide whether to write a stub file. Write when:
150
+ * - The target doesn't exist — most common case, new story.
151
+ * - The target exists but is itself a @slowcook-stub (marker on line 1).
152
+ * Lets testgen re-runs refresh stubs as spec evolves.
153
+ * Skip when:
154
+ * - The target exists and has real implementation (no stub marker).
155
+ * Could be a brownfield consumer where the route already exists, or
156
+ * brewing has already replaced the stub body. Either way, don't clobber.
157
+ */
158
+ function shouldWriteStub(repoRoot, path) {
159
+ const full = join(repoRoot, path);
160
+ if (!existsSync(full))
161
+ return true;
162
+ try {
163
+ const first = readFileSync(full, "utf8").split("\n")[0] ?? "";
164
+ return first.includes("@slowcook-stub");
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ }
170
+ /**
171
+ * Decide whether to write a helper file. Write when the target doesn't
172
+ * exist. Never clobber an existing helper — the consumer may have
173
+ * hand-customised it, and the generated version would lose those edits.
174
+ * Operator can delete the file and re-run testgen to get a fresh
175
+ * auto-generated helper.
176
+ */
177
+ function shouldWriteHelper(repoRoot, path) {
178
+ return !existsSync(join(repoRoot, path));
179
+ }
126
180
  function collectTargetSpecs(ctx) {
127
181
  const index = readIndex(ctx.repoRoot);
128
182
  const all = Object.entries(index.stories)
@@ -185,10 +239,26 @@ export function buildProjectContext(repoRoot) {
185
239
  else {
186
240
  bits.push("\nNo existing integration tests — this will be the first in the repo.");
187
241
  }
242
+ // List existing API route files so the LLM knows NOT to emit a <stub>
243
+ // block for them. (A route that already exists has a real impl; stubbing
244
+ // over it would clobber production code. The consumer may also have
245
+ // brownfield code that pre-existed slowcook adoption — listed here so
246
+ // testgen respects what's there.)
247
+ const appDir = join(repoRoot, "src", "app");
248
+ if (existsSync(appDir)) {
249
+ const routes = listAppRouterFiles(appDir).sort();
250
+ if (routes.length > 0) {
251
+ bits.push(`\n### Existing API route files (under src/app/)\n\nThese already exist — do NOT emit a \`<stub>\` block for any of them. If the test imports from one of these, assume the route file exists and skip stub generation.`);
252
+ for (const r of routes.slice(0, 50))
253
+ bits.push(`- \`${r}\``);
254
+ if (routes.length > 50)
255
+ bits.push(`- … (${routes.length - 50} more)`);
256
+ }
257
+ }
188
258
  // List existing mock helpers so the LLM knows which to import. The
189
259
  // helper pattern is load-bearing for the future record-and-replay swap
190
260
  // (plans/0.7-testgen-two-tier.md §4.3). Helpers NOT listed here will
191
- // surface as TODO(helper) comments in the generated test.
261
+ // be auto-generated by testgen B2 as <helper> blocks.
192
262
  const helpersDir = join(repoRoot, MOCK_HELPERS_DIR);
193
263
  if (existsSync(helpersDir)) {
194
264
  const helpers = readdirSync(helpersDir)
@@ -214,28 +284,111 @@ export function buildProjectContext(repoRoot) {
214
284
  }
215
285
  }
216
286
  else {
217
- bits.push(`\n### Mock helpers\n\nNo \`${MOCK_HELPERS_DIR}/\` directory yet — this project hasn't set up the helper pattern. Emit \`TODO(helper): <service>\` comments for each external dependency the handler calls; an operator will hand-author the helpers before brewing can run (helper auto-generation ships in a later slowcook release).`);
287
+ bits.push(`\n### Mock helpers\n\nNo \`${MOCK_HELPERS_DIR}/\` directory yet — this project hasn't set up the helper pattern. Emit a \`<helper>\` block for each external dependency the handler calls, matching the helper-file shape in the system prompt. Also emit a \`<helper path="${MOCK_HELPERS_DIR}/index.ts">\` barrel re-exporting your new helpers.`);
218
288
  }
219
289
  return bits.join("\n");
220
290
  }
221
- async function generateTestFile(spec, ctx, projectContext) {
291
+ /**
292
+ * Walk src/app/ and return every route file path (repo-relative) that
293
+ * Next.js App Router treats as an endpoint. We surface these to the
294
+ * testgen LLM so it doesn't emit a <stub> for a file that already
295
+ * exists.
296
+ */
297
+ function listAppRouterFiles(appDir) {
298
+ const out = [];
299
+ const walk = (dir) => {
300
+ let entries;
301
+ try {
302
+ entries = readdirSync(dir);
303
+ }
304
+ catch {
305
+ return;
306
+ }
307
+ for (const name of entries) {
308
+ const full = join(dir, name);
309
+ let stat;
310
+ try {
311
+ stat = statSync(full);
312
+ }
313
+ catch {
314
+ continue;
315
+ }
316
+ if (stat.isDirectory())
317
+ walk(full);
318
+ else if (stat.isFile() && /^(route|page)\.(ts|tsx)$/.test(name)) {
319
+ // Return repo-relative path. Parent dirs up to "src/app" are derivable
320
+ // from appDir; we trim appDir and prefix "src/app".
321
+ const rel = full.slice(full.indexOf("src/app"));
322
+ out.push(rel);
323
+ }
324
+ }
325
+ };
326
+ walk(appDir);
327
+ return out;
328
+ }
329
+ async function generateTestBundle(spec, ctx, projectContext) {
222
330
  const systemPrompt = TESTGEN_SYSTEM(projectContext);
223
- const userMessage = `Here is the spec YAML. Generate the Vitest integration test file:\n\n\`\`\`yaml\n${YAML.stringify(spec)}\n\`\`\``;
331
+ const userMessage = `Here is the spec YAML. Generate the tier-1 test bundle (test file + any needed stubs + any needed helpers):\n\n\`\`\`yaml\n${YAML.stringify(spec)}\n\`\`\``;
224
332
  const raw = await ctx.llm.complete({
225
333
  system: systemPrompt,
226
334
  cacheSystem: true,
227
335
  model: ctx.model,
228
336
  messages: [{ role: "user", content: userMessage }],
229
- maxTokens: 8192,
337
+ maxTokens: 16384,
230
338
  });
231
- return stripCodeFence(raw);
339
+ return parseTestgenBundle(raw, spec.story_id);
340
+ }
341
+ /**
342
+ * Parse XML-tagged multi-artifact output into a TestgenBundle.
343
+ *
344
+ * Accepted shape (from the prompt):
345
+ * <test_file>...</test_file>
346
+ * <stub path="src/app/api/foo/route.ts">...</stub> (zero or more)
347
+ * <helper path="tests/helpers/mocks/bar.ts">...</helper> (zero or more)
348
+ *
349
+ * Tolerant of code-fenced output: if the LLM wraps the whole thing in
350
+ * ```, we strip it. If a block's contents are themselves code-fenced,
351
+ * we strip those too — tier-1 test / helper / stub files are raw TS.
352
+ *
353
+ * Throws if `<test_file>` is missing or empty — that's the one mandatory
354
+ * artifact.
355
+ */
356
+ export function parseTestgenBundle(raw, storyId) {
357
+ const trimmed = raw.trim();
358
+ // Strip outer code fence if the LLM wrapped everything
359
+ const outerFenceMatch = trimmed.match(/^```[a-z]*\s*\n([\s\S]*)\n```$/);
360
+ const body = outerFenceMatch && outerFenceMatch[1] ? outerFenceMatch[1] : trimmed;
361
+ const testMatch = body.match(/<test_file>([\s\S]*?)<\/test_file>/);
362
+ if (!testMatch || !testMatch[1]) {
363
+ throw new Error(`testgen: LLM output for story-${storyId} missing a <test_file> block. ` +
364
+ `Got ${body.length} chars starting with: ${body.slice(0, 120)}...`);
365
+ }
366
+ const testContent = stripInnerFence(testMatch[1]);
367
+ const stubs = [];
368
+ const stubRe = /<stub\s+path="([^"]+)">([\s\S]*?)<\/stub>/g;
369
+ let m;
370
+ while ((m = stubRe.exec(body)) !== null) {
371
+ const p = m[1] ?? "";
372
+ const c = m[2] ?? "";
373
+ if (p && c.trim())
374
+ stubs.push({ path: p, contents: stripInnerFence(c) });
375
+ }
376
+ const helpers = [];
377
+ const helperRe = /<helper\s+path="([^"]+)">([\s\S]*?)<\/helper>/g;
378
+ while ((m = helperRe.exec(body)) !== null) {
379
+ const p = m[1] ?? "";
380
+ const c = m[2] ?? "";
381
+ if (p && c.trim())
382
+ helpers.push({ path: p, contents: stripInnerFence(c) });
383
+ }
384
+ return { testContent, stubs, helpers };
232
385
  }
233
- function stripCodeFence(raw) {
386
+ function stripInnerFence(raw) {
234
387
  const t = raw.trim();
235
- const fence = t.match(/^```(?:typescript|ts)?\s*\n([\s\S]*)\n```$/);
388
+ const fence = t.match(/^```(?:typescript|ts|tsx)?\s*\n([\s\S]*)\n```$/);
236
389
  if (fence && fence[1])
237
390
  return fence[1];
238
- return t;
391
+ return t + "\n"; // ensure trailing newline for file writes
239
392
  }
240
393
  /**
241
394
  * Two scan modes:
@@ -461,6 +614,24 @@ function buildPrBody(args) {
461
614
  const manifestCount = g.manifest.tests.length;
462
615
  sections.push(`- \`story-${g.spec.story_id}\` — *${g.spec.title}* — ${manifestCount} test(s) in \`${g.testPath}\``);
463
616
  }
617
+ const allStubs = args.generated.flatMap((g) => g.stubs);
618
+ if (allStubs.length > 0) {
619
+ sections.push("");
620
+ sections.push("## Generated stubs (route files)");
621
+ sections.push("Minimal throwing route files so tier-1 tests can collect. Each carries an \`@slowcook-stub\` marker on line 1. **Brewing will replace these bodies** with the real implementation across its iterations. Reviewer check: correct file path + export signature + \`@slowcook-stub\` marker present. If the signature is wrong the whole PR is wrong — flag it now.");
622
+ for (const s of allStubs) {
623
+ sections.push(`- \`${s.path}\``);
624
+ }
625
+ }
626
+ const allHelpers = args.generated.flatMap((g) => g.helpers);
627
+ if (allHelpers.length > 0) {
628
+ sections.push("");
629
+ sections.push("## Generated mock helpers");
630
+ sections.push("Signature-asserting fakes for external services the handlers consume. **Three load-bearing properties**: (1) calling the real module's exported function with wrong args throws loudly (catches the class of bug where tests pass via mock-arg-ignoring but production crashes); (2) every chained method pushes to \`client.calls\` so tests assert against that instead of poking \`vi.fn\` internals; (3) config is intent-level (\`user\`, \`tables\`) not implementation-level (\`return_value_for_from\`). Reviewer check: the \`realShaped*\` wrapper exists AND matches the real module's signature from \`src/\`.");
631
+ for (const h of allHelpers) {
632
+ sections.push(`- \`${h.path}\``);
633
+ }
634
+ }
464
635
  if (args.removedStoryIds.length > 0) {
465
636
  sections.push("");
466
637
  sections.push("## Tests removed (supersede chain)");