buildanything 2.0.0 → 2.1.2

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 (115) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +9 -1
  3. package/README.md +57 -61
  4. package/agents/a11y-architect.md +2 -0
  5. package/agents/briefing-officer.md +172 -0
  6. package/agents/business-model.md +14 -12
  7. package/agents/code-architect.md +6 -1
  8. package/agents/code-reviewer.md +3 -2
  9. package/agents/code-simplifier.md +12 -4
  10. package/agents/design-brand-guardian.md +19 -0
  11. package/agents/design-critic.md +16 -11
  12. package/agents/design-inclusive-visuals-specialist.md +2 -0
  13. package/agents/design-ui-designer.md +17 -0
  14. package/agents/design-ux-architect.md +15 -0
  15. package/agents/design-ux-researcher.md +102 -7
  16. package/agents/engineering-ai-engineer.md +2 -0
  17. package/agents/engineering-backend-architect.md +2 -0
  18. package/agents/engineering-data-engineer.md +2 -0
  19. package/agents/engineering-devops-automator.md +2 -0
  20. package/agents/engineering-frontend-developer.md +13 -0
  21. package/agents/engineering-mobile-app-builder.md +2 -0
  22. package/agents/engineering-rapid-prototyper.md +15 -2
  23. package/agents/engineering-security-engineer.md +2 -0
  24. package/agents/engineering-senior-developer.md +13 -0
  25. package/agents/engineering-sre.md +2 -0
  26. package/agents/engineering-technical-writer.md +2 -0
  27. package/agents/feature-intel.md +8 -7
  28. package/agents/ios-app-review-guardian.md +2 -0
  29. package/agents/ios-foundation-models-specialist.md +2 -0
  30. package/agents/ios-product-reality-auditor.md +292 -0
  31. package/agents/ios-storekit-specialist.md +2 -0
  32. package/agents/ios-swift-architect.md +1 -0
  33. package/agents/ios-swift-search.md +1 -0
  34. package/agents/ios-swift-ui-design.md +7 -4
  35. package/agents/marketing-app-store-optimizer.md +2 -0
  36. package/agents/planner.md +6 -1
  37. package/agents/pr-test-analyzer.md +3 -2
  38. package/agents/product-feedback-synthesizer.md +62 -0
  39. package/agents/product-owner.md +163 -0
  40. package/agents/product-reality-auditor.md +216 -0
  41. package/agents/product-spec-writer.md +176 -0
  42. package/agents/refactor-cleaner.md +9 -1
  43. package/agents/security-reviewer.md +2 -1
  44. package/agents/silent-failure-hunter.md +2 -1
  45. package/agents/swift-build-resolver.md +2 -0
  46. package/agents/swift-reviewer.md +2 -1
  47. package/agents/tech-feasibility.md +5 -3
  48. package/agents/testing-api-tester.md +2 -0
  49. package/agents/testing-evidence-collector.md +24 -0
  50. package/agents/testing-performance-benchmarker.md +2 -0
  51. package/agents/testing-reality-checker.md +2 -1
  52. package/agents/visual-research.md +7 -5
  53. package/bin/adapters/scribe-tool.ts +4 -2
  54. package/bin/adapters/write-lease-tool.ts +1 -1
  55. package/bin/buildanything-runtime.ts +20 -107
  56. package/bin/graph-index.js +24 -0
  57. package/bin/graph-index.ts +340 -0
  58. package/bin/mcp-servers/graph-mcp.js +26 -0
  59. package/bin/mcp-servers/graph-mcp.ts +481 -0
  60. package/bin/mcp-servers/orchestrator-mcp.js +26 -0
  61. package/bin/mcp-servers/orchestrator-mcp.ts +361 -0
  62. package/bin/setup.js +272 -111
  63. package/commands/build.md +424 -177
  64. package/commands/idea-sweep.md +2 -2
  65. package/commands/setup.md +15 -4
  66. package/commands/ux-review.md +3 -3
  67. package/commands/verify.md +3 -0
  68. package/docs/migration/phase-graph.yaml +573 -157
  69. package/hooks/design-md-lint +4 -0
  70. package/hooks/design-md-lint.ts +295 -0
  71. package/hooks/pre-tool-use.ts +37 -6
  72. package/hooks/record-mode-transitions.ts +63 -6
  73. package/hooks/subagent-start.ts +3 -2
  74. package/package.json +3 -1
  75. package/protocols/agent-prompt-authoring.md +165 -0
  76. package/protocols/architecture-schema.md +10 -3
  77. package/protocols/cleanup.md +4 -0
  78. package/protocols/decision-log.md +8 -4
  79. package/protocols/design-md-authoring.md +520 -0
  80. package/protocols/design-md-spec.md +362 -0
  81. package/protocols/fake-data-detector.md +1 -1
  82. package/protocols/ios-fake-data-detector.md +65 -0
  83. package/protocols/ios-phase-branches.md +128 -43
  84. package/protocols/launch-readiness.md +9 -5
  85. package/protocols/metric-loop.md +1 -1
  86. package/protocols/page-spec-schema.md +234 -0
  87. package/protocols/product-spec-schema.md +354 -0
  88. package/protocols/sprint-tasks-schema.md +53 -0
  89. package/protocols/state-schema.json +38 -3
  90. package/protocols/state-schema.md +32 -2
  91. package/protocols/verify.md +29 -1
  92. package/protocols/web-phase-branches.md +246 -76
  93. package/skills/ios/ios-bootstrap/SKILL.md +1 -1
  94. package/src/graph/ids.ts +86 -0
  95. package/src/graph/index.ts +32 -0
  96. package/src/graph/parser/architecture.ts +603 -0
  97. package/src/graph/parser/component-manifest.ts +268 -0
  98. package/src/graph/parser/decisions-jsonl.ts +407 -0
  99. package/src/graph/parser/design-md-pass2.ts +253 -0
  100. package/src/graph/parser/design-md.ts +477 -0
  101. package/src/graph/parser/page-spec.ts +496 -0
  102. package/src/graph/parser/product-spec.ts +930 -0
  103. package/src/graph/parser/screenshot.ts +342 -0
  104. package/src/graph/parser/sprint-tasks.ts +317 -0
  105. package/src/graph/storage/index.ts +1154 -0
  106. package/src/graph/types.ts +432 -0
  107. package/src/graph/util/dhash.ts +84 -0
  108. package/src/lrr/aggregator.ts +105 -10
  109. package/src/orchestrator/hooks/context-header.ts +34 -10
  110. package/src/orchestrator/hooks/token-accounting.ts +25 -14
  111. package/src/orchestrator/mcp/cycle-counter.ts +2 -1
  112. package/src/orchestrator/mcp/scribe.ts +27 -16
  113. package/src/orchestrator/mcp/write-lease.ts +30 -13
  114. package/src/orchestrator/phase4-shared-context.ts +20 -4
  115. package/protocols/visual-dna.md +0 -185
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Screenshot extractor — STUB for hypothetical environment.
3
+ *
4
+ * Slice 5 production mode will:
5
+ * - Replace stub captions/tags with multimodal subagent dispatches
6
+ * - Add real image decoding (Sharp / @napi-rs/image) for dimensions + palette
7
+ * - Cache extractions by SHA256 of image bytes to avoid redundant API calls
8
+ *
9
+ * The basename--class ID convention ensures the same image bytes posted under
10
+ * different image classes (e.g. "reference" vs "brand_drift") produce distinct
11
+ * node IDs, preventing ID collisions in the graph.
12
+ *
13
+ * Source of truth: docs/graph/11-slice5-schema.md
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { ids, kebab } from "../ids.js";
20
+ import type {
21
+ BrandDriftObservationNode,
22
+ Confidence,
23
+ DogfoodFindingNode,
24
+ GraphEdge,
25
+ GraphNode,
26
+ ImageComponentDetectionNode,
27
+ Relation,
28
+ ScreenshotNode,
29
+ } from "../types.js";
30
+ import { dhash } from "../util/dhash.js";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Constants
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const PRODUCED_BY_AGENT = "screenshot-extractor-stub";
37
+ const PRODUCED_AT_STEP = "varies";
38
+
39
+ /**
40
+ * Stub nodes are INFERRED: caption/dna_axis_tags/palette are LLM-derived even in production.
41
+ */
42
+ const SCREENSHOT_STUB_CONFIDENCE: Confidence = "INFERRED";
43
+
44
+ /** Schema tag for Slice 5 fragments — referenced in doc comment above. */
45
+ const SCHEMA_TAG = "buildanything-slice-5";
46
+
47
+ const VALID_IMAGE_CLASSES = new Set(["reference", "brand_drift", "dogfood"] as const);
48
+
49
+ const DNA_AXIS_KEYWORDS = [
50
+ "scope",
51
+ "density",
52
+ "character",
53
+ "material",
54
+ "motion",
55
+ "type",
56
+ "copy",
57
+ ] as const;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Public types
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface ScreenshotInput {
64
+ imagePath: string;
65
+ imageClass: "reference" | "brand_drift" | "dogfood";
66
+ imageBytes: Uint8Array;
67
+ linkedScreenId?: string | null;
68
+ linkedFindingId?: string | null;
69
+ findingSeverity?: "critical" | "major" | "minor";
70
+ findingDescription?: string;
71
+ }
72
+
73
+ export interface ScreenshotExtractResult {
74
+ ok: boolean;
75
+ nodes: GraphNode[];
76
+ edges: GraphEdge[];
77
+ errors: { message: string }[];
78
+ }
79
+
80
+ export interface BrandDriftObservationInput {
81
+ prodScreenshotId: string;
82
+ referenceScreenshotId: string;
83
+ axis: "scope" | "density" | "character" | "material" | "motion" | "type" | "copy";
84
+ score: number;
85
+ verdict: "drift" | "ok" | "needs-review";
86
+ observationId: string;
87
+ }
88
+
89
+ export interface BrandDriftObservationResult {
90
+ ok: boolean;
91
+ nodes: GraphNode[];
92
+ edges: GraphEdge[];
93
+ errors: { message: string }[];
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Helpers
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function makeEdge(
101
+ source: string,
102
+ target: string,
103
+ relation: Relation,
104
+ sourceFile: string,
105
+ ): GraphEdge {
106
+ return {
107
+ source,
108
+ target,
109
+ relation,
110
+ confidence: "EXTRACTED",
111
+ source_file: sourceFile,
112
+ source_location: "L0",
113
+ produced_by_agent: PRODUCED_BY_AGENT,
114
+ produced_at_step: PRODUCED_AT_STEP,
115
+ };
116
+ }
117
+
118
+ function basenameNoExt(filePath: string): string {
119
+ const last = filePath.split("/").pop() ?? filePath;
120
+ const dotIdx = last.lastIndexOf(".");
121
+ return dotIdx > 0 ? last.slice(0, dotIdx) : last;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Public API
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export function extractScreenshot(input: ScreenshotInput): ScreenshotExtractResult {
129
+ const errors: { message: string }[] = [];
130
+ const nodes: GraphNode[] = [];
131
+ const edges: GraphEdge[] = [];
132
+
133
+ // -- Validation -----------------------------------------------------------
134
+
135
+ if (!input.imagePath || !input.imagePath.trim()) {
136
+ errors.push({ message: "imagePath is empty or whitespace" });
137
+ return { ok: false, nodes, edges, errors };
138
+ }
139
+
140
+ if (!input.imageBytes || input.imageBytes.length === 0) {
141
+ errors.push({ message: "imageBytes is empty" });
142
+ return { ok: false, nodes, edges, errors };
143
+ }
144
+
145
+ if (!VALID_IMAGE_CLASSES.has(input.imageClass as any)) {
146
+ errors.push({
147
+ message: `imageClass "${input.imageClass}" is not one of: reference, brand_drift, dogfood`,
148
+ });
149
+ return { ok: false, nodes, edges, errors };
150
+ }
151
+
152
+ // -- Compute hashes -------------------------------------------------------
153
+
154
+ const perceptualHash = dhash(input.imageBytes);
155
+ const contentSha8 = createHash("sha256")
156
+ .update(input.imageBytes)
157
+ .digest("hex")
158
+ .slice(0, 8);
159
+
160
+ const rawBasename = basenameNoExt(input.imagePath);
161
+
162
+ // CRITICAL ID-collision fix: include imageClass in the basename used for ID
163
+ // so that the same bytes sent under different classes produce different IDs.
164
+ const idBasename = `${rawBasename}--${input.imageClass}`;
165
+ const screenshotId = ids.screenshot(kebab(idBasename), contentSha8);
166
+
167
+ // -- Class-aware stub extraction ------------------------------------------
168
+
169
+ let caption: string;
170
+ let dnaAxisTags: string[];
171
+ let dominantPalette: string[];
172
+
173
+ if (input.imageClass === "reference") {
174
+ caption = "Stub caption — Slice 5 production mode dispatches a multimodal subagent";
175
+
176
+ // Heuristic: scan rawBasename for DNA axis keywords
177
+ const lower = rawBasename.toLowerCase();
178
+ dnaAxisTags = DNA_AXIS_KEYWORDS.filter((kw) => lower.includes(kw));
179
+
180
+ dominantPalette = ["#000000", "#FFFFFF"];
181
+ } else {
182
+ caption = `Stub caption (${input.imageClass}) — Slice 5 production mode derives this from a multimodal subagent dispatch`;
183
+ dnaAxisTags = [];
184
+ dominantPalette = [];
185
+ }
186
+
187
+ // -- Dogfood finding node (emitted before screenshot so we can reference its id) --
188
+
189
+ const isDogfoodWithFinding =
190
+ input.imageClass === "dogfood" &&
191
+ input.linkedFindingId &&
192
+ input.linkedFindingId.trim();
193
+
194
+ const resolvedFindingId = isDogfoodWithFinding
195
+ ? ids.dogfoodFinding(input.linkedFindingId!)
196
+ : null;
197
+
198
+ if (isDogfoodWithFinding) {
199
+ const findingNode: DogfoodFindingNode = {
200
+ id: resolvedFindingId!,
201
+ label: input.linkedFindingId!,
202
+ entity_type: "dogfood_finding",
203
+ source_file: input.imagePath,
204
+ source_location: "L0",
205
+ confidence: SCREENSHOT_STUB_CONFIDENCE,
206
+ finding_id: input.linkedFindingId!,
207
+ severity: input.findingSeverity ?? "minor",
208
+ description:
209
+ input.findingDescription ??
210
+ "Stub finding — Slice 5 production mode reads evidence/dogfood/findings.json",
211
+ screenshot_id: screenshotId,
212
+ affected_screen_id: input.linkedScreenId ?? null,
213
+ };
214
+ nodes.push(findingNode);
215
+ }
216
+
217
+ // -- Screenshot node ------------------------------------------------------
218
+
219
+ const screenshotNode: ScreenshotNode = {
220
+ id: screenshotId,
221
+ label: rawBasename,
222
+ entity_type: "screenshot",
223
+ source_file: input.imagePath,
224
+ source_location: "L0",
225
+ confidence: SCREENSHOT_STUB_CONFIDENCE,
226
+ image_path: input.imagePath,
227
+ image_class: input.imageClass,
228
+ caption,
229
+ perceptual_hash: perceptualHash,
230
+ dominant_palette: dominantPalette,
231
+ image_dimensions: "0x0",
232
+ dna_axis_tags: dnaAxisTags,
233
+ linked_screen_id: input.linkedScreenId ?? null,
234
+ linked_finding_id: resolvedFindingId,
235
+ };
236
+ nodes.push(screenshotNode);
237
+
238
+ // -- Component detection (reference class only) ---------------------------
239
+
240
+ if (input.imageClass === "reference") {
241
+ const detectionId = ids.imageComponentDetection(
242
+ screenshotId,
243
+ "stub-component",
244
+ 1,
245
+ );
246
+ const detectionNode: ImageComponentDetectionNode = {
247
+ id: detectionId,
248
+ label: "stub-component",
249
+ entity_type: "image_component_detection",
250
+ source_file: input.imagePath,
251
+ source_location: "L0",
252
+ confidence: SCREENSHOT_STUB_CONFIDENCE,
253
+ screenshot_id: screenshotId,
254
+ component_label: "stub-component",
255
+ bounding_box: null,
256
+ detection_confidence: null,
257
+ };
258
+ nodes.push(detectionNode);
259
+ edges.push(
260
+ makeEdge(screenshotId, detectionId, "image_has_component_detection", input.imagePath),
261
+ );
262
+ }
263
+
264
+ // -- Linking edges --------------------------------------------------------
265
+
266
+ if (input.linkedScreenId && input.linkedScreenId.trim()) {
267
+ edges.push(
268
+ makeEdge(screenshotId, input.linkedScreenId, "screenshot_depicts_screen", input.imagePath),
269
+ );
270
+ }
271
+
272
+ if (isDogfoodWithFinding) {
273
+ edges.push(
274
+ makeEdge(screenshotId, resolvedFindingId!, "screenshot_evidences_finding", input.imagePath),
275
+ );
276
+ }
277
+
278
+ return { ok: true, nodes, edges, errors };
279
+ }
280
+
281
+ export function extractBrandDriftObservation(
282
+ input: BrandDriftObservationInput,
283
+ ): BrandDriftObservationResult {
284
+ const errors: { message: string }[] = [];
285
+ const nodes: GraphNode[] = [];
286
+ const edges: GraphEdge[] = [];
287
+
288
+ if (!input.prodScreenshotId || !input.prodScreenshotId.trim()) {
289
+ errors.push({ message: "prodScreenshotId is empty" });
290
+ }
291
+ if (!input.referenceScreenshotId || !input.referenceScreenshotId.trim()) {
292
+ errors.push({ message: "referenceScreenshotId is empty" });
293
+ }
294
+ if (!input.observationId || !input.observationId.trim()) {
295
+ errors.push({ message: "observationId is empty" });
296
+ }
297
+ if (errors.length > 0) {
298
+ return { ok: false, nodes, edges, errors };
299
+ }
300
+
301
+ const nodeId = ids.brandDriftObservation(input.observationId);
302
+
303
+ const node: BrandDriftObservationNode = {
304
+ id: nodeId,
305
+ label: input.observationId,
306
+ entity_type: "brand_drift_observation",
307
+ source_file: "<brand-guardian>",
308
+ source_location: "L0",
309
+ confidence: "INFERRED",
310
+ observation_id: input.observationId,
311
+ prod_screenshot_id: input.prodScreenshotId,
312
+ reference_screenshot_id: input.referenceScreenshotId,
313
+ axis: input.axis,
314
+ score: input.score,
315
+ verdict: input.verdict,
316
+ };
317
+ nodes.push(node);
318
+
319
+ const edgeBase = {
320
+ confidence: "INFERRED" as Confidence,
321
+ source_file: "<brand-guardian>",
322
+ source_location: "L0",
323
+ produced_by_agent: "design-brand-guardian",
324
+ produced_at_step: "5.1",
325
+ };
326
+
327
+ edges.push({
328
+ source: nodeId,
329
+ target: input.prodScreenshotId,
330
+ relation: "prod_drifts_from_reference_prod",
331
+ ...edgeBase,
332
+ });
333
+
334
+ edges.push({
335
+ source: nodeId,
336
+ target: input.referenceScreenshotId,
337
+ relation: "prod_drifts_from_reference_ref",
338
+ ...edgeBase,
339
+ });
340
+
341
+ return { ok: true, nodes, edges, errors };
342
+ }
@@ -0,0 +1,317 @@
1
+ // Deterministic, schema-aware extractor for sprint-tasks.md.
2
+ // Source of truth: docs/graph/09-slice4-schema.md.
3
+ // No LLM, no I/O. Caller passes the markdown content and path.
4
+
5
+ import { ids, sha256Hex } from "../ids.js";
6
+ import type {
7
+ ExtractError,
8
+ ExtractResult,
9
+ GraphEdge,
10
+ GraphFragment,
11
+ GraphNode,
12
+ Relation,
13
+ TaskNode,
14
+ } from "../types.js";
15
+
16
+ const PRODUCED_BY = "planner";
17
+ const PRODUCED_AT_STEP = "2.3.2";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Shared helpers (same pattern as component-manifest.ts / page-spec.ts)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ interface Line {
24
+ n: number;
25
+ text: string;
26
+ }
27
+
28
+ interface Ctx {
29
+ mdPath: string;
30
+ errors: ExtractError[];
31
+ nodes: GraphNode[];
32
+ edges: GraphEdge[];
33
+ }
34
+
35
+ function loc(line: number): string {
36
+ return `L${line}`;
37
+ }
38
+
39
+ function pushError(ctx: Ctx, line: number, message: string): void {
40
+ ctx.errors.push({ line, message });
41
+ }
42
+
43
+ function splitLines(content: string): Line[] {
44
+ return content.split(/\r?\n/).map((text, i) => ({ n: i + 1, text }));
45
+ }
46
+
47
+ function splitRow(text: string): string[] {
48
+ let s = text.trim();
49
+ if (s.startsWith("|")) s = s.slice(1);
50
+ if (s.endsWith("|")) s = s.slice(0, -1);
51
+ return s.split("|").map((c) => c.trim());
52
+ }
53
+
54
+ const SEP_RE = /^\s*\|?\s*[-:| ]+\s*\|?\s*$/;
55
+
56
+ interface TableRow {
57
+ cells: Record<string, string>;
58
+ line: number;
59
+ }
60
+
61
+ interface Table {
62
+ headerLine: number;
63
+ headers: string[];
64
+ rows: TableRow[];
65
+ }
66
+
67
+ function findTables(lines: Line[]): Table[] {
68
+ const tables: Table[] = [];
69
+ let i = 0;
70
+ while (i < lines.length - 1) {
71
+ const cur = lines[i];
72
+ const next = lines[i + 1];
73
+ if (
74
+ cur.text.includes("|") &&
75
+ next.text.includes("|") &&
76
+ SEP_RE.test(next.text)
77
+ ) {
78
+ const headers = splitRow(cur.text).map((h) => h.toLowerCase().trim());
79
+ const rows: TableRow[] = [];
80
+ let j = i + 2;
81
+ while (j < lines.length) {
82
+ const ln = lines[j];
83
+ if (!ln.text.includes("|")) break;
84
+ if (SEP_RE.test(ln.text)) { j++; continue; }
85
+ const cells = splitRow(ln.text);
86
+ if (cells.every((c) => c === "")) { j++; continue; }
87
+ const row: Record<string, string> = {};
88
+ for (let c = 0; c < headers.length; c++) {
89
+ row[headers[c]] = (cells[c] ?? "").trim();
90
+ }
91
+ rows.push({ cells: row, line: ln.n });
92
+ j++;
93
+ }
94
+ tables.push({ headerLine: cur.n, headers, rows });
95
+ i = j;
96
+ } else {
97
+ i++;
98
+ }
99
+ }
100
+ return tables;
101
+ }
102
+
103
+ function isEmptyRef(s: string): boolean {
104
+ const t = s.trim();
105
+ return t === "" || t === "\u2014" || t === "-" || t === "\u2013";
106
+ }
107
+
108
+ function sortNodes(nodes: GraphNode[]): GraphNode[] {
109
+ return [...nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
110
+ }
111
+
112
+ function sortEdges(edges: GraphEdge[]): GraphEdge[] {
113
+ return [...edges].sort((a, b) => {
114
+ const k = (e: GraphEdge): string => `${e.relation} ${e.source} ${e.target}`;
115
+ return k(a) < k(b) ? -1 : k(a) > k(b) ? 1 : 0;
116
+ });
117
+ }
118
+
119
+ function makeEdge(
120
+ ctx: Ctx,
121
+ source: string,
122
+ target: string,
123
+ relation: Relation,
124
+ line: number,
125
+ ): GraphEdge {
126
+ return {
127
+ source,
128
+ target,
129
+ relation,
130
+ confidence: "EXTRACTED",
131
+ source_file: ctx.mdPath,
132
+ source_location: loc(line),
133
+ produced_by_agent: PRODUCED_BY,
134
+ produced_at_step: PRODUCED_AT_STEP,
135
+ };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Owns-files parsing
140
+ // ---------------------------------------------------------------------------
141
+
142
+ function parseOwnsFiles(raw: string): string[] {
143
+ if (isEmptyRef(raw)) return [];
144
+ return raw
145
+ .split(",")
146
+ .map((s) => s.trim())
147
+ .filter((s) => s.length > 0);
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Required columns
152
+ // ---------------------------------------------------------------------------
153
+
154
+ const REQUIRED_COLUMNS = [
155
+ "task id",
156
+ "title",
157
+ "size",
158
+ "dependencies",
159
+ "behavioral test",
160
+ "owns files",
161
+ "implementing phase",
162
+ "feature",
163
+ "screens",
164
+ ];
165
+
166
+ const VALID_SIZES = new Set(["S", "M", "L"]);
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Public entrypoint
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export function extractSprintTasks(input: {
173
+ mdPath: string;
174
+ mdContent: string;
175
+ }): ExtractResult {
176
+ const { mdPath, mdContent } = input;
177
+ const lines = splitLines(mdContent);
178
+ const ctx: Ctx = { mdPath, errors: [], nodes: [], edges: [] };
179
+
180
+ const tables = findTables(lines);
181
+ if (tables.length === 0) {
182
+ return {
183
+ ok: false,
184
+ errors: [{ line: 0, message: "No pipe tables found in sprint-tasks.md" }],
185
+ };
186
+ }
187
+
188
+ // Collect all valid rows across tables
189
+ const allRows: { row: TableRow; headers: string[]; headerLine: number }[] = [];
190
+
191
+ for (const table of tables) {
192
+ if (table.headers.length < 9) {
193
+ pushError(
194
+ ctx,
195
+ table.headerLine,
196
+ `Table has fewer than 9 columns (got ${table.headers.length})`,
197
+ );
198
+ continue;
199
+ }
200
+
201
+ for (const col of REQUIRED_COLUMNS) {
202
+ if (!table.headers.includes(col)) {
203
+ const displayName = col
204
+ .split(" ")
205
+ .map((w) => w[0].toUpperCase() + w.slice(1))
206
+ .join(" ");
207
+ pushError(ctx, table.headerLine, `Missing required column: '${displayName}'`);
208
+ }
209
+ }
210
+
211
+ if (ctx.errors.length > 0) continue;
212
+
213
+ for (const row of table.rows) {
214
+ allRows.push({ row, headers: table.headers, headerLine: table.headerLine });
215
+ }
216
+ }
217
+
218
+ if (ctx.errors.length > 0) {
219
+ return { ok: false, errors: ctx.errors };
220
+ }
221
+
222
+ // Track task IDs for duplicate detection
223
+ const taskIdSeen = new Map<string, number>();
224
+
225
+ for (const { row } of allRows) {
226
+ const rawTaskId = row.cells["task id"] ?? "";
227
+ if (rawTaskId.trim() === "") {
228
+ pushError(ctx, row.line, `Anonymous task at L${row.line} — Task ID is required`);
229
+ continue;
230
+ }
231
+
232
+ const taskIdLower = rawTaskId.toLowerCase();
233
+ const prev = taskIdSeen.get(taskIdLower);
234
+ if (prev !== undefined) {
235
+ pushError(
236
+ ctx,
237
+ row.line,
238
+ `Duplicate Task ID '${rawTaskId}' at L${prev} and L${row.line}`,
239
+ );
240
+ continue;
241
+ }
242
+ taskIdSeen.set(taskIdLower, row.line);
243
+
244
+ const size = (row.cells["size"] ?? "").trim().toUpperCase();
245
+ if (!VALID_SIZES.has(size)) {
246
+ pushError(
247
+ ctx,
248
+ row.line,
249
+ `Invalid Size '${(row.cells["size"] ?? "").trim()}' at L${row.line} — must be S, M, or L`,
250
+ );
251
+ continue;
252
+ }
253
+
254
+ const taskId = rawTaskId.trim();
255
+ const title = (row.cells["title"] ?? "").trim();
256
+ const behavioralTest = (row.cells["behavioral test"] ?? "").trim();
257
+ const implementingPhase = (row.cells["implementing phase"] ?? "").trim();
258
+ const ownsFiles = parseOwnsFiles(row.cells["owns files"] ?? "");
259
+ const featureRaw = (row.cells['feature'] ?? '').trim();
260
+ const featureId = isEmptyRef(featureRaw) ? null : ids.feature(featureRaw);
261
+ const screensRaw = (row.cells['screens'] ?? '').trim();
262
+ const screenIds = isEmptyRef(screensRaw) ? [] : screensRaw.split(',').map((s) => s.trim()).filter((s) => s.length > 0).map((s) => ids.screen(s)).sort();
263
+
264
+ const node: TaskNode = {
265
+ id: ids.task(taskId),
266
+ label: title,
267
+ entity_type: "task",
268
+ source_file: mdPath,
269
+ source_location: loc(row.line),
270
+ confidence: "EXTRACTED",
271
+ task_id: taskId,
272
+ title,
273
+ size: size as "S" | "M" | "L",
274
+ behavioral_test: behavioralTest,
275
+ assigned_phase: implementingPhase,
276
+ feature_id: featureId,
277
+ screen_ids: screenIds,
278
+ owns_files: ownsFiles,
279
+ };
280
+ ctx.nodes.push(node);
281
+
282
+ // Feature edge
283
+ if (featureId) {
284
+ ctx.edges.push(makeEdge(ctx, node.id, featureId, "task_implements_feature", row.line));
285
+ }
286
+
287
+ // Screen edges
288
+ for (const screenId of screenIds) {
289
+ ctx.edges.push(makeEdge(ctx, node.id, screenId, "task_touches_screen", row.line));
290
+ }
291
+
292
+ // Dependency edges
293
+ const depsRaw = (row.cells["dependencies"] ?? "").trim();
294
+ if (!isEmptyRef(depsRaw)) {
295
+ const deps = depsRaw.split(",").map((d) => d.trim()).filter((d) => d.length > 0);
296
+ for (const dep of deps) {
297
+ ctx.edges.push(makeEdge(ctx, node.id, ids.task(dep), "task_depends_on", row.line));
298
+ }
299
+ }
300
+ }
301
+
302
+ if (ctx.errors.length > 0) {
303
+ return { ok: false, errors: ctx.errors };
304
+ }
305
+
306
+ const fragment: GraphFragment = {
307
+ version: 1,
308
+ schema: "buildanything-slice-4",
309
+ source_file: mdPath,
310
+ source_sha: sha256Hex(mdContent),
311
+ produced_at: new Date().toISOString(),
312
+ nodes: sortNodes(ctx.nodes),
313
+ edges: sortEdges(ctx.edges),
314
+ };
315
+
316
+ return { ok: true, fragment, errors: [] };
317
+ }