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,268 @@
1
+ // Deterministic, schema-aware extractor for component-manifest.md.
2
+ // Source of truth: docs/graph/05-slice2-schema.md §4.2.
3
+ // No LLM, no I/O. Caller passes the markdown content and path.
4
+
5
+ import { ids, kebab, sha256Hex } from "../ids.js";
6
+ import type {
7
+ ComponentManifestEntryNode,
8
+ ComponentSlotNode,
9
+ ExtractError,
10
+ ExtractResult,
11
+ GraphEdge,
12
+ GraphFragment,
13
+ GraphNode,
14
+ } from "../types.js";
15
+
16
+ const PRODUCED_BY = "design-ui-designer";
17
+ const PRODUCED_AT_STEP = "3.2";
18
+
19
+ const HARD_GATE_RE = /\[(?:hg|hard-gate)\]|\(hg\)/i;
20
+ const SEP_RE = /^\s*\|?\s*[-:| ]+\s*\|?\s*$/;
21
+ const EXPECTED_HEADERS = ["slot", "library", "variant", "source ref", "notes"];
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
+ interface TableRow {
55
+ cells: string[]; // raw cell values in column order
56
+ line: number;
57
+ }
58
+
59
+ interface Table {
60
+ headerLine: number;
61
+ rows: TableRow[];
62
+ }
63
+
64
+ // Find all pipe tables in the file. A table starts with a header row
65
+ // followed immediately by a separator row. Rows continue until a non-pipe
66
+ // line or end of file.
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 rows: TableRow[] = [];
79
+ let j = i + 2;
80
+ while (j < lines.length) {
81
+ const ln = lines[j];
82
+ if (!ln.text.includes("|")) break;
83
+ // Skip separator-only rows (all dashes/colons/whitespace/pipes)
84
+ if (SEP_RE.test(ln.text)) { j++; continue; }
85
+ const cells = splitRow(ln.text);
86
+ // Skip empty rows (all cells blank)
87
+ if (cells.every((c) => c === "")) { j++; continue; }
88
+ rows.push({ cells, line: ln.n });
89
+ j++;
90
+ }
91
+ tables.push({ headerLine: cur.n, rows });
92
+ i = j;
93
+ } else {
94
+ i++;
95
+ }
96
+ }
97
+ return tables;
98
+ }
99
+
100
+ function isEmptyRef(s: string): boolean {
101
+ const t = s.trim();
102
+ return t === "" || t === "—" || t === "-" || t === "–";
103
+ }
104
+
105
+ function sortNodes(nodes: GraphNode[]): GraphNode[] {
106
+ return [...nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
107
+ }
108
+
109
+ function sortEdges(edges: GraphEdge[]): GraphEdge[] {
110
+ return [...edges].sort((a, b) => {
111
+ const k = (e: GraphEdge): string => `${e.relation} ${e.source} ${e.target}`;
112
+ return k(a) < k(b) ? -1 : k(a) > k(b) ? 1 : 0;
113
+ });
114
+ }
115
+
116
+ export function extractComponentManifest(input: {
117
+ mdPath: string;
118
+ mdContent: string;
119
+ }): ExtractResult {
120
+ const { mdPath, mdContent } = input;
121
+ const lines = splitLines(mdContent);
122
+ const ctx: Ctx = { mdPath, errors: [], nodes: [], edges: [] };
123
+
124
+ const tables = findTables(lines);
125
+ if (tables.length === 0) {
126
+ return {
127
+ ok: false,
128
+ errors: [{ line: 0, message: "No pipe tables found in component manifest" }],
129
+ };
130
+ }
131
+
132
+ // Validate headers and collect all rows across tables
133
+ const allRows: TableRow[] = [];
134
+ for (const table of tables) {
135
+ const headerCells = splitRow(lines[table.headerLine - 1].text);
136
+ const normalized = headerCells.map((h) => h.toLowerCase().trim());
137
+
138
+ if (normalized.length < 5) {
139
+ pushError(ctx, table.headerLine, `L${table.headerLine}: table has fewer than 5 columns`);
140
+ continue;
141
+ }
142
+ let headerOk = true;
143
+ for (let c = 0; c < EXPECTED_HEADERS.length; c++) {
144
+ if (normalized[c] !== EXPECTED_HEADERS[c]) {
145
+ pushError(
146
+ ctx,
147
+ table.headerLine,
148
+ `Table header must be: Slot | Library | Variant | Source ref | Notes (got "${headerCells[c]}" at column ${c + 1})`,
149
+ );
150
+ headerOk = false;
151
+ break;
152
+ }
153
+ }
154
+ if (!headerOk) continue;
155
+ allRows.push(...table.rows);
156
+ }
157
+
158
+ if (ctx.errors.length > 0) {
159
+ return { ok: false, errors: ctx.errors };
160
+ }
161
+
162
+ // Track slots for dedup: kebab → first line number
163
+ const slotSeen = new Map<string, number[]>();
164
+
165
+ for (const row of allRows) {
166
+ const rawSlot = row.cells[0] ?? "";
167
+ const slotKebab = kebab(rawSlot);
168
+ if (!slotKebab) {
169
+ pushError(ctx, row.line, `Empty slot name at L${row.line}`);
170
+ continue;
171
+ }
172
+
173
+ const existing = slotSeen.get(slotKebab);
174
+ if (existing) {
175
+ existing.push(row.line);
176
+ } else {
177
+ slotSeen.set(slotKebab, [row.line]);
178
+ }
179
+ }
180
+
181
+ // Check for duplicates
182
+ for (const [slotKebab, lineNums] of slotSeen) {
183
+ if (lineNums.length > 1) {
184
+ const refs = lineNums.map((n) => `L${n}`).join(" and ");
185
+ pushError(ctx, lineNums[0], `Duplicate slot "${slotKebab}" at ${refs}`);
186
+ }
187
+ }
188
+
189
+ if (ctx.errors.length > 0) {
190
+ return { ok: false, errors: ctx.errors };
191
+ }
192
+
193
+ // Emit nodes and edges
194
+ const emittedSlots = new Set<string>();
195
+
196
+ for (const row of allRows) {
197
+ const rawSlot = row.cells[0] ?? "";
198
+ const slotKebab = kebab(rawSlot);
199
+ const rawLibrary = (row.cells[1] ?? "").trim();
200
+ const rawVariant = (row.cells[2] ?? "").trim();
201
+ const rawSourceRef = (row.cells[3] ?? "").trim();
202
+ const rawNotes = (row.cells[4] ?? "").trim();
203
+
204
+ const isTbd =
205
+ rawLibrary.toLowerCase() === "tbd" || rawVariant.toLowerCase() === "tbd";
206
+
207
+ const library = rawLibrary.toLowerCase();
208
+ const variant = isTbd ? rawVariant.toLowerCase() : rawVariant.trim();
209
+ const sourceRef = isEmptyRef(rawSourceRef) ? null : rawSourceRef;
210
+ const hardGate = isTbd ? false : HARD_GATE_RE.test(rawNotes);
211
+ const fallbackProse = rawNotes.replace(HARD_GATE_RE, "").trim();
212
+
213
+ const entryNode: ComponentManifestEntryNode = {
214
+ id: ids.manifestEntry(rawSlot),
215
+ label: slotKebab,
216
+ entity_type: "component_manifest_entry",
217
+ source_file: mdPath,
218
+ source_location: loc(row.line),
219
+ confidence: "EXTRACTED",
220
+ slot: slotKebab,
221
+ library,
222
+ variant,
223
+ source_ref: sourceRef,
224
+ hard_gate: hardGate,
225
+ ...(fallbackProse ? { fallback_plan: fallbackProse } : {}),
226
+ };
227
+ ctx.nodes.push(entryNode);
228
+
229
+ // Emit ComponentSlotNode (deduplicated)
230
+ if (!emittedSlots.has(slotKebab)) {
231
+ emittedSlots.add(slotKebab);
232
+ const slotNode: ComponentSlotNode = {
233
+ id: ids.componentSlot(rawSlot),
234
+ label: slotKebab,
235
+ entity_type: "component_slot",
236
+ source_file: mdPath,
237
+ source_location: loc(row.line),
238
+ confidence: "EXTRACTED",
239
+ slot_name: slotKebab,
240
+ };
241
+ ctx.nodes.push(slotNode);
242
+ }
243
+
244
+ // Edge: slot → manifest entry
245
+ ctx.edges.push({
246
+ source: ids.componentSlot(rawSlot),
247
+ target: ids.manifestEntry(rawSlot),
248
+ relation: "slot_filled_by",
249
+ confidence: "EXTRACTED",
250
+ source_file: mdPath,
251
+ source_location: loc(row.line),
252
+ produced_by_agent: PRODUCED_BY,
253
+ produced_at_step: PRODUCED_AT_STEP,
254
+ });
255
+ }
256
+
257
+ const fragment: GraphFragment = {
258
+ version: 1,
259
+ schema: "buildanything-slice-2",
260
+ source_file: mdPath,
261
+ source_sha: sha256Hex(mdContent),
262
+ produced_at: new Date().toISOString(),
263
+ nodes: sortNodes(ctx.nodes),
264
+ edges: sortEdges(ctx.edges),
265
+ };
266
+
267
+ return { ok: true, fragment, errors: [] };
268
+ }
@@ -0,0 +1,407 @@
1
+ // Deterministic JSONL parser for docs/plans/decisions.jsonl.
2
+ // Source of truth: docs/graph/09-slice4-schema.md §4.3.
3
+ // No LLM, no I/O — caller passes file path and content.
4
+
5
+ import { ids, kebab, sha256Hex } from "../ids.js";
6
+ import type {
7
+ DecisionNode,
8
+ ExtractError,
9
+ ExtractResult,
10
+ GraphEdge,
11
+ GraphFragment,
12
+ GraphNode,
13
+ Relation,
14
+ } from "../types.js";
15
+
16
+ const PRODUCED_BY = "orchestrator-scribe";
17
+ const PRODUCED_AT_STEP = "cross-phase";
18
+ const VALID_STATUSES = new Set(["open", "triggered", "resolved"]);
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Ref → node ID resolution for decision_drove edges
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Resolve a ref anchor (e.g. "architecture.md#backend/persistence") to a
25
+ * target graph node ID. Returns null when the ref cannot be mapped. */
26
+ function resolveRefToNodeId(ref: string): string | null {
27
+ const hashIdx = ref.indexOf("#");
28
+ if (hashIdx < 0) return null;
29
+ const file = ref.slice(0, hashIdx);
30
+ const anchor = ref.slice(hashIdx + 1);
31
+ if (!anchor) return null;
32
+
33
+ // architecture.md#<module>/<subsection> → module__<module>
34
+ if (file.endsWith("architecture.md")) {
35
+ const slashIdx = anchor.indexOf("/");
36
+ const moduleName = slashIdx >= 0 ? anchor.slice(0, slashIdx) : anchor;
37
+ return ids.architectureModule(moduleName);
38
+ }
39
+
40
+ // design-doc.md#feature-<name> or product-spec.md#feature-<name> → feature__<name>
41
+ if (file.endsWith("design-doc.md") || file.endsWith("product-spec.md")) {
42
+ const featureMatch = anchor.match(/^feature[- ](.+)$/i);
43
+ if (featureMatch) return ids.feature(featureMatch[1]);
44
+ }
45
+
46
+ // sprint-tasks.md#<task-id> → task__<task-id>
47
+ if (file.endsWith("sprint-tasks.md")) {
48
+ return ids.task(anchor);
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ interface RawRow {
55
+ decision_id: string;
56
+ summary: string;
57
+ decided_by: string;
58
+ related_decision_id: string | null;
59
+ revisit_criterion?: string | null;
60
+ status: "open" | "triggered" | "resolved";
61
+ phase: string;
62
+ step_id?: string | null;
63
+ at?: string;
64
+ ref?: string | null;
65
+ }
66
+
67
+ interface ParsedRow {
68
+ raw: RawRow;
69
+ line: number;
70
+ }
71
+
72
+ function loc(line: number): string {
73
+ return `L${line}`;
74
+ }
75
+
76
+ function makeEdge(
77
+ source: string,
78
+ target: string,
79
+ relation: Relation,
80
+ sourceFile: string,
81
+ line: number,
82
+ decidedBy: string,
83
+ ): GraphEdge {
84
+ return {
85
+ source,
86
+ target,
87
+ relation,
88
+ confidence: "EXTRACTED",
89
+ source_file: sourceFile,
90
+ source_location: loc(line),
91
+ produced_by_agent: decidedBy || PRODUCED_BY,
92
+ produced_at_step: PRODUCED_AT_STEP,
93
+ };
94
+ }
95
+
96
+ function isCommentOrBlank(line: string): boolean {
97
+ const trimmed = line.trim();
98
+ if (trimmed.length === 0) return true;
99
+ return trimmed.startsWith("//") || trimmed.startsWith("#");
100
+ }
101
+
102
+ function stripBom(content: string): string {
103
+ return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
104
+ }
105
+
106
+ function validateRow(row: unknown, line: number): { ok: true; row: RawRow } | { ok: false; error: ExtractError } {
107
+ if (typeof row !== "object" || row === null || Array.isArray(row)) {
108
+ return { ok: false, error: { line, message: `Line ${line}: row is not a JSON object` } };
109
+ }
110
+ const r = row as Record<string, unknown>;
111
+
112
+ // The on-disk field for the human-readable summary is `decision` (per
113
+ // src/orchestrator/mcp/scribe.ts DecisionRow). The schema doc and the
114
+ // DecisionNode type call it `summary`. Accept either, prefer `summary`
115
+ // when both are present.
116
+ const summaryRaw = r.summary ?? r.decision;
117
+ if (typeof summaryRaw !== "string" || summaryRaw.length === 0) {
118
+ return {
119
+ ok: false,
120
+ error: { line, message: `Line ${line}: missing or empty required field 'summary' (or 'decision')` },
121
+ };
122
+ }
123
+
124
+ const requiredStrings = ["decision_id", "decided_by", "phase"] as const;
125
+ for (const field of requiredStrings) {
126
+ if (typeof r[field] !== "string" || (r[field] as string).length === 0) {
127
+ return {
128
+ ok: false,
129
+ error: { line, message: `Line ${line}: missing or empty required field '${field}'` },
130
+ };
131
+ }
132
+ }
133
+
134
+ // related_decision_id is required by the spec, but scribe.ts didn't write it
135
+ // for early rows (it became part of the row shape later). Tolerate missing
136
+ // field as null — only reject when present and not string|null.
137
+ const rel = "related_decision_id" in r ? r.related_decision_id : null;
138
+ if (rel !== null && typeof rel !== "string") {
139
+ return {
140
+ ok: false,
141
+ error: { line, message: `Line ${line}: 'related_decision_id' must be string or null` },
142
+ };
143
+ }
144
+
145
+ if (typeof r.status !== "string" || !VALID_STATUSES.has(r.status)) {
146
+ return {
147
+ ok: false,
148
+ error: {
149
+ line,
150
+ message: `Line ${line}: invalid 'status' value '${String(r.status)}' (must be open|triggered|resolved)`,
151
+ },
152
+ };
153
+ }
154
+
155
+ let revisit: string | null = null;
156
+ if ("revisit_criterion" in r) {
157
+ const rc = r.revisit_criterion;
158
+ if (rc !== null && typeof rc !== "string") {
159
+ return {
160
+ ok: false,
161
+ error: { line, message: `Line ${line}: 'revisit_criterion' must be string or null` },
162
+ };
163
+ }
164
+ revisit = rc as string | null;
165
+ }
166
+
167
+ let stepId: string | null = null;
168
+ if ("step_id" in r) {
169
+ const s = r.step_id;
170
+ if (s !== null && typeof s !== "string") {
171
+ return {
172
+ ok: false,
173
+ error: { line, message: `Line ${line}: 'step_id' must be string or null` },
174
+ };
175
+ }
176
+ stepId = s as string | null;
177
+ }
178
+
179
+ // Prefer `at` (spec); fall back to `timestamp` (on-disk per scribe.ts).
180
+ let at: string | undefined;
181
+ const atRaw = r.at ?? r.timestamp;
182
+ if (atRaw !== undefined) {
183
+ if (typeof atRaw !== "string") {
184
+ return { ok: false, error: { line, message: `Line ${line}: 'at'/'timestamp' must be a string` } };
185
+ }
186
+ at = atRaw;
187
+ }
188
+
189
+ // ref field: optional, string or null. Points to an architecture/design-doc anchor.
190
+ let ref: string | null = null;
191
+ if ("ref" in r && r.ref !== undefined && r.ref !== null) {
192
+ if (typeof r.ref !== "string") {
193
+ return { ok: false, error: { line, message: `Line ${line}: 'ref' must be a string or null` } };
194
+ }
195
+ ref = r.ref;
196
+ }
197
+
198
+ return {
199
+ ok: true,
200
+ row: {
201
+ decision_id: r.decision_id as string,
202
+ summary: summaryRaw,
203
+ decided_by: r.decided_by as string,
204
+ related_decision_id: rel as string | null,
205
+ revisit_criterion: revisit,
206
+ status: r.status as RawRow["status"],
207
+ phase: r.phase as string,
208
+ step_id: stepId,
209
+ at,
210
+ ref,
211
+ },
212
+ };
213
+ }
214
+
215
+ function detectCycles(parsed: ParsedRow[]): string[] {
216
+ // Build adjacency on related_decision_id pointers (covers both supersedes and relates_to edges
217
+ // since both follow the same parent pointer). Detect cycles via iterative DFS coloring.
218
+ const ids = new Set(parsed.map((p) => p.raw.decision_id));
219
+ const next = new Map<string, string>();
220
+ for (const p of parsed) {
221
+ const target = p.raw.related_decision_id;
222
+ if (target && ids.has(target)) next.set(p.raw.decision_id, target);
223
+ }
224
+ const WHITE = 0;
225
+ const GRAY = 1;
226
+ const BLACK = 2;
227
+ const color = new Map<string, number>();
228
+ for (const id of ids) color.set(id, WHITE);
229
+
230
+ const cycles: string[] = [];
231
+ for (const start of ids) {
232
+ if (color.get(start) !== WHITE) continue;
233
+ // Iterative walk along single-pointer chain (next.get is ≤1 successor).
234
+ const path: string[] = [];
235
+ const onPath = new Set<string>();
236
+ let node: string | undefined = start;
237
+ while (node !== undefined) {
238
+ const c = color.get(node);
239
+ if (c === GRAY && onPath.has(node)) {
240
+ const idx = path.indexOf(node);
241
+ const cycleNodes = path.slice(idx).concat(node);
242
+ cycles.push(cycleNodes.join(" -> "));
243
+ break;
244
+ }
245
+ if (c === BLACK) break;
246
+ color.set(node, GRAY);
247
+ path.push(node);
248
+ onPath.add(node);
249
+ node = next.get(node);
250
+ }
251
+ for (const n of path) color.set(n, BLACK);
252
+ }
253
+ return cycles;
254
+ }
255
+
256
+ function compareRows(a: ParsedRow, b: ParsedRow): number {
257
+ const aAt = a.raw.at;
258
+ const bAt = b.raw.at;
259
+ if (aAt && bAt) {
260
+ if (aAt < bAt) return -1;
261
+ if (aAt > bAt) return 1;
262
+ } else if (aAt && !bAt) {
263
+ return -1;
264
+ } else if (!aAt && bAt) {
265
+ return 1;
266
+ }
267
+ return a.raw.decision_id < b.raw.decision_id ? -1 : a.raw.decision_id > b.raw.decision_id ? 1 : 0;
268
+ }
269
+
270
+ function sortNodes(nodes: GraphNode[]): GraphNode[] {
271
+ return [...nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
272
+ }
273
+
274
+ function sortEdges(edges: GraphEdge[]): GraphEdge[] {
275
+ return [...edges].sort((a, b) => {
276
+ const k = (e: GraphEdge): string =>
277
+ `${e.relation} ${e.source} ${e.target} ${e.source_location ?? ""}`;
278
+ return k(a) < k(b) ? -1 : k(a) > k(b) ? 1 : 0;
279
+ });
280
+ }
281
+
282
+ export function extractDecisionsJsonl(input: { mdPath: string; mdContent: string }): ExtractResult {
283
+ const { mdPath } = input;
284
+ const content = stripBom(input.mdContent);
285
+ const errors: ExtractError[] = [];
286
+ const lines = content.split(/\r?\n/);
287
+
288
+ // Pass 1: parse and validate every line. Fail-loud on any malformed/invalid row.
289
+ const parsed: ParsedRow[] = [];
290
+ const seenIds = new Map<string, number>();
291
+
292
+ for (let i = 0; i < lines.length; i++) {
293
+ const lineNo = i + 1;
294
+ const text = lines[i];
295
+ if (isCommentOrBlank(text)) continue;
296
+
297
+ let json: unknown;
298
+ try {
299
+ json = JSON.parse(text);
300
+ } catch (err) {
301
+ const msg = err instanceof Error ? err.message : String(err);
302
+ errors.push({ line: lineNo, message: `Line ${lineNo}: malformed JSON: ${msg}` });
303
+ return { ok: false, errors };
304
+ }
305
+
306
+ const validation = validateRow(json, lineNo);
307
+ if (!validation.ok) {
308
+ errors.push(validation.error);
309
+ return { ok: false, errors };
310
+ }
311
+
312
+ const row = validation.row;
313
+ const prior = seenIds.get(row.decision_id);
314
+ if (prior !== undefined) {
315
+ errors.push({
316
+ line: lineNo,
317
+ message: `Line ${lineNo}: duplicate decision_id '${row.decision_id}' (first seen at line ${prior})`,
318
+ });
319
+ return { ok: false, errors };
320
+ }
321
+ seenIds.set(row.decision_id, lineNo);
322
+ parsed.push({ raw: row, line: lineNo });
323
+ }
324
+
325
+ // Determinism: sort by `at` chronological, fall back to decision_id lex.
326
+ parsed.sort(compareRows);
327
+
328
+ // Pass 2: emit nodes and edges with full context (need the parent's status to pick supersedes vs relates).
329
+ const byId = new Map<string, ParsedRow>();
330
+ for (const p of parsed) byId.set(p.raw.decision_id, p);
331
+
332
+ const nodes: GraphNode[] = [];
333
+ const edges: GraphEdge[] = [];
334
+
335
+ for (const p of parsed) {
336
+ const r = p.raw;
337
+ const nodeId = ids.decision(r.decision_id);
338
+ const node: DecisionNode = {
339
+ id: nodeId,
340
+ label: r.decision_id,
341
+ entity_type: "decision",
342
+ source_file: mdPath,
343
+ source_location: loc(p.line),
344
+ confidence: "EXTRACTED",
345
+ decision_id: r.decision_id,
346
+ summary: r.summary,
347
+ decided_by: r.decided_by,
348
+ related_decision_id: r.related_decision_id,
349
+ revisit_criterion: r.revisit_criterion ?? null,
350
+ status: r.status,
351
+ phase: r.phase,
352
+ step_id: r.step_id ?? null,
353
+ ref: r.ref ?? null,
354
+ };
355
+ nodes.push(node);
356
+
357
+ if (r.related_decision_id) {
358
+ const targetId = ids.decision(r.related_decision_id);
359
+ const parent = byId.get(r.related_decision_id);
360
+ // Supersedes when child is resolved AND parent exists in file with status open|triggered.
361
+ // Otherwise relates_to (covers parent missing from file, parent already resolved, child not resolved).
362
+ let relation: Relation = "decision_relates_to";
363
+ if (
364
+ r.status === "resolved" &&
365
+ parent !== undefined &&
366
+ (parent.raw.status === "open" || parent.raw.status === "triggered")
367
+ ) {
368
+ relation = "decision_supersedes";
369
+ }
370
+ edges.push(makeEdge(nodeId, targetId, relation, mdPath, p.line, r.decided_by));
371
+ }
372
+
373
+ // decision_drove: resolve ref anchor to a target node ID
374
+ if (r.ref) {
375
+ const droveTarget = resolveRefToNodeId(r.ref);
376
+ if (droveTarget) {
377
+ edges.push(makeEdge(nodeId, droveTarget, "decision_drove", mdPath, p.line, r.decided_by));
378
+ } else {
379
+ errors.push({ line: p.line, message: `WARNING: ref '${r.ref}' could not be resolved to a graph node` });
380
+ }
381
+ }
382
+ }
383
+
384
+ // Cycle detection on the related-decision pointer graph. Fail-loud per schema.
385
+ const cycles = detectCycles(parsed);
386
+ if (cycles.length > 0) {
387
+ return {
388
+ ok: false,
389
+ errors: cycles.map((cycle) => ({
390
+ line: 0,
391
+ message: `Cycle detected in decision relations: ${cycle}`,
392
+ })),
393
+ };
394
+ }
395
+
396
+ const fragment: GraphFragment = {
397
+ version: 1,
398
+ schema: "buildanything-slice-4",
399
+ source_file: mdPath,
400
+ source_sha: sha256Hex(input.mdContent),
401
+ produced_at: new Date().toISOString(),
402
+ nodes: sortNodes(nodes),
403
+ edges: sortEdges(edges),
404
+ };
405
+
406
+ return { ok: true, fragment, errors };
407
+ }