@mainahq/core 1.0.3 → 1.1.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.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/src/ai/__tests__/delegation.test.ts +55 -1
  3. package/src/ai/delegation.ts +5 -3
  4. package/src/context/__tests__/budget.test.ts +29 -6
  5. package/src/context/__tests__/engine.test.ts +1 -0
  6. package/src/context/__tests__/selector.test.ts +23 -3
  7. package/src/context/__tests__/wiki.test.ts +349 -0
  8. package/src/context/budget.ts +12 -8
  9. package/src/context/engine.ts +37 -0
  10. package/src/context/selector.ts +30 -4
  11. package/src/context/wiki.ts +296 -0
  12. package/src/db/index.ts +12 -0
  13. package/src/feedback/__tests__/capture.test.ts +166 -0
  14. package/src/feedback/__tests__/signals.test.ts +144 -0
  15. package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
  16. package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
  17. package/src/feedback/capture.ts +102 -0
  18. package/src/feedback/signals.ts +68 -0
  19. package/src/index.ts +104 -0
  20. package/src/init/__tests__/init.test.ts +400 -3
  21. package/src/init/index.ts +368 -12
  22. package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
  23. package/src/prompts/defaults/index.ts +3 -1
  24. package/src/prompts/defaults/wiki-compile.md +20 -0
  25. package/src/prompts/defaults/wiki-query.md +18 -0
  26. package/src/stats/__tests__/tool-usage.test.ts +133 -0
  27. package/src/stats/tracker.ts +92 -0
  28. package/src/verify/__tests__/pipeline.test.ts +11 -8
  29. package/src/verify/pipeline.ts +13 -1
  30. package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
  31. package/src/verify/tools/wiki-lint-runner.ts +38 -0
  32. package/src/verify/tools/wiki-lint.ts +898 -0
  33. package/src/wiki/__tests__/compiler.test.ts +389 -0
  34. package/src/wiki/__tests__/extractors/code.test.ts +99 -0
  35. package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
  36. package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
  37. package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
  38. package/src/wiki/__tests__/graph.test.ts +344 -0
  39. package/src/wiki/__tests__/hooks.test.ts +119 -0
  40. package/src/wiki/__tests__/indexer.test.ts +285 -0
  41. package/src/wiki/__tests__/linker.test.ts +230 -0
  42. package/src/wiki/__tests__/louvain.test.ts +229 -0
  43. package/src/wiki/__tests__/query.test.ts +316 -0
  44. package/src/wiki/__tests__/schema.test.ts +114 -0
  45. package/src/wiki/__tests__/signals.test.ts +474 -0
  46. package/src/wiki/__tests__/state.test.ts +168 -0
  47. package/src/wiki/__tests__/tracking.test.ts +118 -0
  48. package/src/wiki/__tests__/types.test.ts +387 -0
  49. package/src/wiki/compiler.ts +1075 -0
  50. package/src/wiki/extractors/code.ts +90 -0
  51. package/src/wiki/extractors/decision.ts +217 -0
  52. package/src/wiki/extractors/feature.ts +206 -0
  53. package/src/wiki/extractors/workflow.ts +112 -0
  54. package/src/wiki/graph.ts +445 -0
  55. package/src/wiki/hooks.ts +49 -0
  56. package/src/wiki/indexer.ts +105 -0
  57. package/src/wiki/linker.ts +117 -0
  58. package/src/wiki/louvain.ts +190 -0
  59. package/src/wiki/prompts/compile-architecture.md +59 -0
  60. package/src/wiki/prompts/compile-decision.md +66 -0
  61. package/src/wiki/prompts/compile-entity.md +56 -0
  62. package/src/wiki/prompts/compile-feature.md +60 -0
  63. package/src/wiki/prompts/compile-module.md +42 -0
  64. package/src/wiki/prompts/wiki-query.md +25 -0
  65. package/src/wiki/query.ts +338 -0
  66. package/src/wiki/schema.ts +111 -0
  67. package/src/wiki/signals.ts +368 -0
  68. package/src/wiki/state.ts +89 -0
  69. package/src/wiki/tracking.ts +30 -0
  70. package/src/wiki/types.ts +169 -0
  71. package/src/workflow/context.ts +26 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Code Entity Extractor — thin adapter for wiki compilation.
3
+ *
4
+ * Uses regex-based extraction of exported entities from TypeScript files.
5
+ * This is a lightweight approach for the wiki foundation — the full
6
+ * tree-sitter + PageRank analysis lives in the Semantic layer and will
7
+ * be integrated in Sprint 1 (Knowledge Graph).
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import type { Result } from "../../db/index";
13
+
14
+ // ─── Types ───────────────────────────────────────────────────────────────
15
+
16
+ export interface CodeEntity {
17
+ name: string;
18
+ kind: "function" | "class" | "interface" | "type" | "variable" | "enum";
19
+ file: string;
20
+ line: number;
21
+ exported: boolean;
22
+ }
23
+
24
+ // ─── Extraction ──────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Extract entities from a single TypeScript file using regex patterns.
28
+ * Captures exported functions, classes, interfaces, types, variables, and enums.
29
+ */
30
+ function extractFromFile(repoRoot: string, relativePath: string): CodeEntity[] {
31
+ const fullPath = join(repoRoot, relativePath);
32
+ if (!existsSync(fullPath)) return [];
33
+
34
+ let content: string;
35
+ try {
36
+ content = readFileSync(fullPath, "utf-8");
37
+ } catch {
38
+ return [];
39
+ }
40
+
41
+ const entities: CodeEntity[] = [];
42
+ const lines = content.split("\n");
43
+
44
+ const patterns: Array<{
45
+ regex: RegExp;
46
+ kind: CodeEntity["kind"];
47
+ }> = [
48
+ { regex: /^export\s+(?:async\s+)?function\s+(\w+)/, kind: "function" },
49
+ { regex: /^export\s+class\s+(\w+)/, kind: "class" },
50
+ { regex: /^export\s+interface\s+(\w+)/, kind: "interface" },
51
+ { regex: /^export\s+type\s+(\w+)/, kind: "type" },
52
+ { regex: /^export\s+(?:const|let|var)\s+(\w+)/, kind: "variable" },
53
+ { regex: /^export\s+enum\s+(\w+)/, kind: "enum" },
54
+ ];
55
+
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const line = lines[i] ?? "";
58
+ for (const { regex, kind } of patterns) {
59
+ const match = line.match(regex);
60
+ if (match?.[1]) {
61
+ entities.push({
62
+ name: match[1],
63
+ kind,
64
+ file: relativePath,
65
+ line: i + 1,
66
+ exported: true,
67
+ });
68
+ break;
69
+ }
70
+ }
71
+ }
72
+
73
+ return entities;
74
+ }
75
+
76
+ // ─── Public API ──────────────────────────────────────────────────────────
77
+
78
+ export function extractCodeEntities(
79
+ repoRoot: string,
80
+ files: string[],
81
+ ): Result<CodeEntity[]> {
82
+ const allEntities: CodeEntity[] = [];
83
+
84
+ for (const file of files) {
85
+ const entities = extractFromFile(repoRoot, file);
86
+ allEntities.push(...entities);
87
+ }
88
+
89
+ return { ok: true, value: allEntities };
90
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Decision Extractor — parses adr/*.md into structured ExtractedDecision records.
3
+ *
4
+ * ADRs follow the structured format from `maina design`:
5
+ * # ADR-NNNN: Title (or # NNNN. Title)
6
+ * ## Status
7
+ * ## Context
8
+ * ## Decision
9
+ * ## Rationale / Consequences
10
+ * ## Alternatives Considered
11
+ */
12
+
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { basename, join } from "node:path";
15
+ import type { Result } from "../../db/index";
16
+ import type { DecisionStatus, ExtractedDecision } from "../types";
17
+
18
+ // ─── Parsing Helpers ─────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Extract sections from an ADR markdown file.
22
+ * Returns a map of lowercase section name → content.
23
+ *
24
+ * Handles nested subsections (### Positive, ### Negative) by merging
25
+ * them into the parent ## section rather than creating separate entries.
26
+ */
27
+ function parseSections(content: string): Map<string, string> {
28
+ const sections = new Map<string, string>();
29
+ const lines = content.split("\n");
30
+ let currentSection = "";
31
+ const currentLines: string[] = [];
32
+
33
+ function flushSection(): void {
34
+ if (currentSection) {
35
+ sections.set(currentSection, currentLines.join("\n").trim());
36
+ }
37
+ currentLines.length = 0;
38
+ }
39
+
40
+ for (const line of lines) {
41
+ // Match ## headings (h2) but NOT ### headings (h3)
42
+ const h2Match = line.match(/^##\s+(?!#)(.+)/);
43
+ if (h2Match) {
44
+ flushSection();
45
+ currentSection = (h2Match[1] ?? "").trim().toLowerCase();
46
+ continue;
47
+ }
48
+ // ### subsections get merged into the current parent section
49
+ if (currentSection) {
50
+ currentLines.push(line);
51
+ }
52
+ }
53
+ flushSection();
54
+
55
+ return sections;
56
+ }
57
+
58
+ /**
59
+ * Extract ID from filename. Handles:
60
+ * "0002-jwt-strategy.md" → "0002-jwt-strategy"
61
+ */
62
+ function extractId(filename: string): string {
63
+ return basename(filename, ".md");
64
+ }
65
+
66
+ /**
67
+ * Extract title from first H1 heading. Handles:
68
+ * "# ADR-0002: Use JWT for Authentication"
69
+ * "# 0002. Use JWT for Authentication"
70
+ */
71
+ function extractTitle(content: string): string {
72
+ const firstLine = content.split("\n")[0] ?? "";
73
+ const heading = firstLine.replace(/^#+\s*/, "");
74
+
75
+ // Strip "ADR-NNNN: " or "NNNN. " prefixes
76
+ const stripped = heading.replace(/^ADR-\d+:\s*/, "").replace(/^\d+\.\s*/, "");
77
+
78
+ return stripped.trim();
79
+ }
80
+
81
+ /**
82
+ * Normalize status string to DecisionStatus.
83
+ * The status section may contain the status on its own line,
84
+ * possibly with additional text (e.g. "Superseded by ADR-0005").
85
+ * Extract the first meaningful line and match against known statuses.
86
+ */
87
+ function normalizeStatus(raw: string): DecisionStatus {
88
+ // Take the first non-empty line from the status section
89
+ const firstLine =
90
+ raw
91
+ .split("\n")
92
+ .map((l) => l.trim())
93
+ .find((l) => l.length > 0) ?? "";
94
+ const lower = firstLine.toLowerCase();
95
+ if (lower.startsWith("accepted")) return "accepted";
96
+ if (lower.startsWith("proposed")) return "proposed";
97
+ if (lower.startsWith("deprecated")) return "deprecated";
98
+ if (lower.startsWith("superseded")) return "superseded";
99
+ return "proposed";
100
+ }
101
+
102
+ /**
103
+ * Extract bullet list items from a section.
104
+ */
105
+ function extractBulletItems(text: string): string[] {
106
+ const items: string[] = [];
107
+ for (const line of text.split("\n")) {
108
+ const match = line.match(/^-\s+(.+)/);
109
+ if (match?.[1]) {
110
+ items.push(match[1].trim());
111
+ }
112
+ }
113
+ return items;
114
+ }
115
+
116
+ /**
117
+ * Extract file/entity references from content.
118
+ * Looks for paths like src/auth/jwt.ts or packages/core/src/...
119
+ */
120
+ function extractEntityMentions(content: string): string[] {
121
+ const mentions: string[] = [];
122
+ const regex = /(?:src\/|packages\/)[^\s,)]+\.(?:ts|js|tsx|jsx|py|go|rs)/g;
123
+ let match: RegExpExecArray | null;
124
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard pattern for regex exec
125
+ while ((match = regex.exec(content)) !== null) {
126
+ mentions.push(match[0]);
127
+ }
128
+ return [...new Set(mentions)];
129
+ }
130
+
131
+ // ─── Public API ──────────────────────────────────────────────────────────
132
+
133
+ export function extractSingleDecision(
134
+ adrPath: string,
135
+ ): Result<ExtractedDecision> {
136
+ if (!existsSync(adrPath)) {
137
+ return { ok: false, error: `ADR file does not exist: ${adrPath}` };
138
+ }
139
+
140
+ let content: string;
141
+ try {
142
+ content = readFileSync(adrPath, "utf-8");
143
+ } catch (_e) {
144
+ return { ok: false, error: `Failed to read ADR: ${adrPath}` };
145
+ }
146
+
147
+ const id = extractId(adrPath);
148
+ const title = extractTitle(content);
149
+ const sections = parseSections(content);
150
+
151
+ const statusRaw = sections.get("status") ?? "";
152
+ const status = normalizeStatus(statusRaw);
153
+
154
+ const context = sections.get("context") ?? "";
155
+ const decision = sections.get("decision") ?? "";
156
+
157
+ // Rationale can be under "rationale", "consequences", or "positive"
158
+ const rationale =
159
+ sections.get("rationale") ??
160
+ sections.get("consequences") ??
161
+ sections.get("positive") ??
162
+ "";
163
+
164
+ // Alternatives under various headings — gracefully return empty if missing
165
+ const altSection =
166
+ sections.get("alternatives considered") ??
167
+ sections.get("alternatives") ??
168
+ "";
169
+ const alternativesRejected = extractBulletItems(altSection);
170
+
171
+ // Entity mentions: check "entities" section first, then always fall back to full content scan
172
+ const entitiesSection = sections.get("entities") ?? "";
173
+ const entityBullets = extractBulletItems(entitiesSection);
174
+ const entityMentions =
175
+ entityBullets.length > 0 ? entityBullets : extractEntityMentions(content);
176
+
177
+ return {
178
+ ok: true,
179
+ value: {
180
+ id,
181
+ title,
182
+ status,
183
+ context,
184
+ decision,
185
+ rationale,
186
+ alternativesRejected,
187
+ entityMentions,
188
+ constitutionAlignment: [],
189
+ },
190
+ };
191
+ }
192
+
193
+ export function extractDecisions(adrDir: string): Result<ExtractedDecision[]> {
194
+ if (!existsSync(adrDir)) {
195
+ return { ok: false, error: `ADR directory does not exist: ${adrDir}` };
196
+ }
197
+
198
+ let entries: string[];
199
+ try {
200
+ entries = readdirSync(adrDir);
201
+ } catch {
202
+ return { ok: false, error: `Failed to read ADR directory: ${adrDir}` };
203
+ }
204
+
205
+ const decisions: ExtractedDecision[] = [];
206
+
207
+ for (const entry of entries) {
208
+ if (!entry.endsWith(".md")) continue;
209
+
210
+ const result = extractSingleDecision(join(adrDir, entry));
211
+ if (result.ok) {
212
+ decisions.push(result.value);
213
+ }
214
+ }
215
+
216
+ return { ok: true, value: decisions };
217
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Feature Extractor — parses plan.md, spec.md, tasks.md from feature directories
3
+ * into structured ExtractedFeature records.
4
+ *
5
+ * These are already structured markdown generated by maina, so parsing
6
+ * is deterministic (not LLM-dependent).
7
+ */
8
+
9
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { basename, join } from "node:path";
11
+ import type { Result } from "../../db/index";
12
+ import type { ExtractedFeature, TaskItem } from "../types";
13
+
14
+ // ─── Parsing Helpers ─────────────────────────────────────────────────────
15
+
16
+ function readOptionalFile(path: string): string | null {
17
+ if (!existsSync(path)) return null;
18
+ try {
19
+ return readFileSync(path, "utf-8");
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Extract title from plan.md or spec.md heading.
27
+ * Handles:
28
+ * "# Implementation Plan: Token Refresh"
29
+ * "# Feature: Token Refresh"
30
+ * "# Token Refresh"
31
+ */
32
+ function extractTitle(content: string): string {
33
+ const firstLine = content.split("\n")[0] ?? "";
34
+ const heading = firstLine.replace(/^#+\s*/, "");
35
+
36
+ // Strip common prefixes
37
+ const prefixes = ["Implementation Plan:", "Feature:", "Plan:"];
38
+ for (const prefix of prefixes) {
39
+ if (heading.startsWith(prefix)) {
40
+ return heading.slice(prefix.length).trim();
41
+ }
42
+ }
43
+ return heading.trim();
44
+ }
45
+
46
+ /**
47
+ * Extract tasks from markdown checklist format.
48
+ * Supports: "- [ ] T001: description" and "- [x] T002: description"
49
+ */
50
+ function extractTasks(content: string): TaskItem[] {
51
+ const tasks: TaskItem[] = [];
52
+ const lines = content.split("\n");
53
+
54
+ for (const line of lines) {
55
+ const match = line.match(/^-\s+\[([ xX])\]\s+(T\d+):\s+(.+)/);
56
+ if (match?.[2]) {
57
+ tasks.push({
58
+ id: match[2],
59
+ description: (match[3] ?? "").trim(),
60
+ completed: match[1] !== " ",
61
+ });
62
+ }
63
+ }
64
+
65
+ return tasks;
66
+ }
67
+
68
+ /**
69
+ * Extract acceptance criteria / assertions from spec.md.
70
+ * Looks for checklist items under Acceptance Criteria section.
71
+ */
72
+ function extractSpecAssertions(specContent: string): string[] {
73
+ const assertions: string[] = [];
74
+ const lines = specContent.split("\n");
75
+ let inSection = false;
76
+
77
+ for (const line of lines) {
78
+ // Detect start of acceptance criteria section
79
+ if (/^##\s+Acceptance Criteria/i.test(line)) {
80
+ inSection = true;
81
+ continue;
82
+ }
83
+ // End of section at next heading
84
+ if (inSection && /^##\s/.test(line)) {
85
+ break;
86
+ }
87
+ if (inSection) {
88
+ const match = line.match(/^-\s+\[[ xX]?\]\s+(.+)/);
89
+ if (match?.[1]) {
90
+ assertions.push(match[1].trim());
91
+ }
92
+ }
93
+ }
94
+
95
+ return assertions;
96
+ }
97
+
98
+ /**
99
+ * Extract scope from spec.md. Looks for text under ## Scope section.
100
+ */
101
+ function extractScope(specContent: string): string {
102
+ const lines = specContent.split("\n");
103
+ let inSection = false;
104
+ const scopeLines: string[] = [];
105
+
106
+ for (const line of lines) {
107
+ if (/^##\s+Scope/i.test(line)) {
108
+ inSection = true;
109
+ continue;
110
+ }
111
+ if (inSection && /^##\s/.test(line)) {
112
+ break;
113
+ }
114
+ if (inSection && line.trim()) {
115
+ scopeLines.push(line.trim());
116
+ }
117
+ }
118
+
119
+ return scopeLines.join(" ");
120
+ }
121
+
122
+ // ─── Public API ──────────────────────────────────────────────────────────
123
+
124
+ export function extractSingleFeature(
125
+ featureDir: string,
126
+ ): Result<ExtractedFeature> {
127
+ if (!existsSync(featureDir)) {
128
+ return {
129
+ ok: false,
130
+ error: `Feature directory does not exist: ${featureDir}`,
131
+ };
132
+ }
133
+
134
+ const id = basename(featureDir);
135
+ const planContent = readOptionalFile(join(featureDir, "plan.md"));
136
+ const specContent = readOptionalFile(join(featureDir, "spec.md"));
137
+ const tasksContent = readOptionalFile(join(featureDir, "tasks.md"));
138
+
139
+ // Extract title from plan or spec
140
+ let title = "";
141
+ if (planContent) {
142
+ title = extractTitle(planContent);
143
+ } else if (specContent) {
144
+ title = extractTitle(specContent);
145
+ }
146
+
147
+ // Extract tasks from tasks.md or plan.md
148
+ let tasks: TaskItem[] = [];
149
+ if (tasksContent) {
150
+ tasks = extractTasks(tasksContent);
151
+ } else if (planContent) {
152
+ tasks = extractTasks(planContent);
153
+ }
154
+
155
+ // Extract spec assertions and scope
156
+ const specAssertions = specContent ? extractSpecAssertions(specContent) : [];
157
+ const scope = specContent ? extractScope(specContent) : "";
158
+
159
+ return {
160
+ ok: true,
161
+ value: {
162
+ id,
163
+ title,
164
+ scope,
165
+ specQualityScore: 0,
166
+ specAssertions,
167
+ tasks,
168
+ entitiesModified: [],
169
+ decisionsCreated: [],
170
+ branch: "",
171
+ prNumber: null,
172
+ merged: false,
173
+ },
174
+ };
175
+ }
176
+
177
+ export function extractFeatures(
178
+ featuresDir: string,
179
+ ): Result<ExtractedFeature[]> {
180
+ if (!existsSync(featuresDir)) {
181
+ return {
182
+ ok: false,
183
+ error: `Features directory does not exist: ${featuresDir}`,
184
+ };
185
+ }
186
+
187
+ const entries = readdirSync(featuresDir);
188
+ const features: ExtractedFeature[] = [];
189
+
190
+ for (const entry of entries) {
191
+ const entryPath = join(featuresDir, entry);
192
+ try {
193
+ const stat = statSync(entryPath);
194
+ if (!stat.isDirectory()) continue;
195
+ } catch {
196
+ continue;
197
+ }
198
+
199
+ const result = extractSingleFeature(entryPath);
200
+ if (result.ok) {
201
+ features.push(result.value);
202
+ }
203
+ }
204
+
205
+ return { ok: true, value: features };
206
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Workflow Trace Extractor — parses .maina/workflow/current.md into
3
+ * structured ExtractedWorkflowTrace records.
4
+ *
5
+ * Workflow traces follow the rolling markdown format:
6
+ * # Workflow: feature-name
7
+ * ## step-name (ISO-timestamp)
8
+ * Summary text.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import type { Result } from "../../db/index";
14
+ import type { ExtractedWorkflowTrace, WorkflowStep } from "../types";
15
+
16
+ // ─── Parsing ─────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Extract feature name from "# Workflow: feature-name" header.
20
+ */
21
+ function extractFeatureId(firstLine: string): string {
22
+ const match = firstLine.match(/^#\s+Workflow:\s*(.+)/);
23
+ return match?.[1]?.trim() ?? "";
24
+ }
25
+
26
+ /**
27
+ * Parse workflow steps from markdown content.
28
+ * Each step is an H2 heading with optional ISO timestamp in parens.
29
+ */
30
+ function parseSteps(content: string): WorkflowStep[] {
31
+ const steps: WorkflowStep[] = [];
32
+ const lines = content.split("\n");
33
+ let currentCommand = "";
34
+ let currentTimestamp = "";
35
+ const summaryLines: string[] = [];
36
+
37
+ function flushStep(): void {
38
+ if (currentCommand) {
39
+ steps.push({
40
+ command: currentCommand,
41
+ timestamp: currentTimestamp,
42
+ summary: summaryLines.join("\n").trim(),
43
+ });
44
+ }
45
+ summaryLines.length = 0;
46
+ }
47
+
48
+ for (const line of lines) {
49
+ // Match step headers like "## brainstorm (2026-04-07T10:00:00.000Z)"
50
+ const stepMatch = line.match(
51
+ /^##\s+(\S+)(?:\s+\((\d{4}-\d{2}-\d{2}T[^)]+)\))?/,
52
+ );
53
+ if (stepMatch) {
54
+ flushStep();
55
+ currentCommand = stepMatch[1] ?? "";
56
+ currentTimestamp = stepMatch[2] ?? "";
57
+ continue;
58
+ }
59
+
60
+ // Skip H1 header
61
+ if (line.startsWith("# ")) {
62
+ continue;
63
+ }
64
+
65
+ if (currentCommand) {
66
+ summaryLines.push(line);
67
+ }
68
+ }
69
+ flushStep();
70
+
71
+ return steps;
72
+ }
73
+
74
+ // ─── Public API ──────────────────────────────────────────────────────────
75
+
76
+ export function extractWorkflowTrace(
77
+ mainaDir: string,
78
+ ): Result<ExtractedWorkflowTrace> {
79
+ const workflowPath = join(mainaDir, "workflow", "current.md");
80
+
81
+ if (!existsSync(workflowPath)) {
82
+ return {
83
+ ok: false,
84
+ error: `Workflow file does not exist: ${workflowPath}`,
85
+ };
86
+ }
87
+
88
+ let content: string;
89
+ try {
90
+ content = readFileSync(workflowPath, "utf-8");
91
+ } catch {
92
+ return {
93
+ ok: false,
94
+ error: `Failed to read workflow file: ${workflowPath}`,
95
+ };
96
+ }
97
+
98
+ const firstLine = content.split("\n")[0] ?? "";
99
+ const featureId = extractFeatureId(firstLine);
100
+ const steps = parseSteps(content);
101
+
102
+ return {
103
+ ok: true,
104
+ value: {
105
+ featureId,
106
+ steps,
107
+ wikiRefsRead: [],
108
+ wikiRefsWritten: [],
109
+ rlSignals: [],
110
+ },
111
+ };
112
+ }