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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +9 -1
- package/README.md +57 -61
- package/agents/a11y-architect.md +2 -0
- package/agents/briefing-officer.md +172 -0
- package/agents/business-model.md +14 -12
- package/agents/code-architect.md +6 -1
- package/agents/code-reviewer.md +3 -2
- package/agents/code-simplifier.md +12 -4
- package/agents/design-brand-guardian.md +19 -0
- package/agents/design-critic.md +16 -11
- package/agents/design-inclusive-visuals-specialist.md +2 -0
- package/agents/design-ui-designer.md +17 -0
- package/agents/design-ux-architect.md +15 -0
- package/agents/design-ux-researcher.md +102 -7
- package/agents/engineering-ai-engineer.md +2 -0
- package/agents/engineering-backend-architect.md +2 -0
- package/agents/engineering-data-engineer.md +2 -0
- package/agents/engineering-devops-automator.md +2 -0
- package/agents/engineering-frontend-developer.md +13 -0
- package/agents/engineering-mobile-app-builder.md +2 -0
- package/agents/engineering-rapid-prototyper.md +15 -2
- package/agents/engineering-security-engineer.md +2 -0
- package/agents/engineering-senior-developer.md +13 -0
- package/agents/engineering-sre.md +2 -0
- package/agents/engineering-technical-writer.md +2 -0
- package/agents/feature-intel.md +8 -7
- package/agents/ios-app-review-guardian.md +2 -0
- package/agents/ios-foundation-models-specialist.md +2 -0
- package/agents/ios-product-reality-auditor.md +292 -0
- package/agents/ios-storekit-specialist.md +2 -0
- package/agents/ios-swift-architect.md +1 -0
- package/agents/ios-swift-search.md +1 -0
- package/agents/ios-swift-ui-design.md +7 -4
- package/agents/marketing-app-store-optimizer.md +2 -0
- package/agents/planner.md +6 -1
- package/agents/pr-test-analyzer.md +3 -2
- package/agents/product-feedback-synthesizer.md +62 -0
- package/agents/product-owner.md +163 -0
- package/agents/product-reality-auditor.md +216 -0
- package/agents/product-spec-writer.md +176 -0
- package/agents/refactor-cleaner.md +9 -1
- package/agents/security-reviewer.md +2 -1
- package/agents/silent-failure-hunter.md +2 -1
- package/agents/swift-build-resolver.md +2 -0
- package/agents/swift-reviewer.md +2 -1
- package/agents/tech-feasibility.md +5 -3
- package/agents/testing-api-tester.md +2 -0
- package/agents/testing-evidence-collector.md +24 -0
- package/agents/testing-performance-benchmarker.md +2 -0
- package/agents/testing-reality-checker.md +2 -1
- package/agents/visual-research.md +7 -5
- package/bin/adapters/scribe-tool.ts +4 -2
- package/bin/adapters/write-lease-tool.ts +1 -1
- package/bin/buildanything-runtime.ts +20 -107
- package/bin/graph-index.js +24 -0
- package/bin/graph-index.ts +340 -0
- package/bin/mcp-servers/graph-mcp.js +26 -0
- package/bin/mcp-servers/graph-mcp.ts +481 -0
- package/bin/mcp-servers/orchestrator-mcp.js +26 -0
- package/bin/mcp-servers/orchestrator-mcp.ts +361 -0
- package/bin/setup.js +272 -111
- package/commands/build.md +424 -177
- package/commands/idea-sweep.md +2 -2
- package/commands/setup.md +15 -4
- package/commands/ux-review.md +3 -3
- package/commands/verify.md +3 -0
- package/docs/migration/phase-graph.yaml +573 -157
- package/hooks/design-md-lint +4 -0
- package/hooks/design-md-lint.ts +295 -0
- package/hooks/pre-tool-use.ts +37 -6
- package/hooks/record-mode-transitions.ts +63 -6
- package/hooks/subagent-start.ts +3 -2
- package/package.json +3 -1
- package/protocols/agent-prompt-authoring.md +165 -0
- package/protocols/architecture-schema.md +10 -3
- package/protocols/cleanup.md +4 -0
- package/protocols/decision-log.md +8 -4
- package/protocols/design-md-authoring.md +520 -0
- package/protocols/design-md-spec.md +362 -0
- package/protocols/fake-data-detector.md +1 -1
- package/protocols/ios-fake-data-detector.md +65 -0
- package/protocols/ios-phase-branches.md +128 -43
- package/protocols/launch-readiness.md +9 -5
- package/protocols/metric-loop.md +1 -1
- package/protocols/page-spec-schema.md +234 -0
- package/protocols/product-spec-schema.md +354 -0
- package/protocols/sprint-tasks-schema.md +53 -0
- package/protocols/state-schema.json +38 -3
- package/protocols/state-schema.md +32 -2
- package/protocols/verify.md +29 -1
- package/protocols/web-phase-branches.md +246 -76
- package/skills/ios/ios-bootstrap/SKILL.md +1 -1
- package/src/graph/ids.ts +86 -0
- package/src/graph/index.ts +32 -0
- package/src/graph/parser/architecture.ts +603 -0
- package/src/graph/parser/component-manifest.ts +268 -0
- package/src/graph/parser/decisions-jsonl.ts +407 -0
- package/src/graph/parser/design-md-pass2.ts +253 -0
- package/src/graph/parser/design-md.ts +477 -0
- package/src/graph/parser/page-spec.ts +496 -0
- package/src/graph/parser/product-spec.ts +930 -0
- package/src/graph/parser/screenshot.ts +342 -0
- package/src/graph/parser/sprint-tasks.ts +317 -0
- package/src/graph/storage/index.ts +1154 -0
- package/src/graph/types.ts +432 -0
- package/src/graph/util/dhash.ts +84 -0
- package/src/lrr/aggregator.ts +105 -10
- package/src/orchestrator/hooks/context-header.ts +34 -10
- package/src/orchestrator/hooks/token-accounting.ts +25 -14
- package/src/orchestrator/mcp/cycle-counter.ts +2 -1
- package/src/orchestrator/mcp/scribe.ts +27 -16
- package/src/orchestrator/mcp/write-lease.ts +30 -13
- package/src/orchestrator/phase4-shared-context.ts +20 -4
- 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
|
+
}
|