@mainahq/core 1.0.3 → 1.1.0

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,323 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ extractDecisions,
7
+ extractSingleDecision,
8
+ } from "../../extractors/decision";
9
+
10
+ let tmpDir: string;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = join(
14
+ tmpdir(),
15
+ `wiki-decision-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
16
+ );
17
+ mkdirSync(tmpDir, { recursive: true });
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tmpDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("Decision Extractor", () => {
25
+ describe("extractSingleDecision", () => {
26
+ it("happy path: should extract a full ADR", () => {
27
+ const adrPath = join(tmpDir, "0002-jwt-strategy.md");
28
+ writeFileSync(
29
+ adrPath,
30
+ [
31
+ "# ADR-0002: Use JWT for Authentication",
32
+ "",
33
+ "## Status",
34
+ "Accepted",
35
+ "",
36
+ "## Context",
37
+ "We need stateless auth for microservices.",
38
+ "",
39
+ "## Decision",
40
+ "Use JWT tokens with RS256 signing.",
41
+ "",
42
+ "## Rationale",
43
+ "Stateless, scalable, widely supported.",
44
+ "",
45
+ "## Alternatives Considered",
46
+ "- Session-based auth — rejected because stateful",
47
+ "- OAuth2 only — rejected because too complex",
48
+ "",
49
+ "## Entities",
50
+ "- src/auth/jwt.ts",
51
+ "- src/middleware/auth.ts",
52
+ ].join("\n"),
53
+ );
54
+
55
+ const result = extractSingleDecision(adrPath);
56
+ expect(result.ok).toBe(true);
57
+ if (!result.ok) return;
58
+
59
+ expect(result.value.id).toBe("0002-jwt-strategy");
60
+ expect(result.value.title).toBe("Use JWT for Authentication");
61
+ expect(result.value.status).toBe("accepted");
62
+ expect(result.value.context).toContain("stateless auth");
63
+ expect(result.value.decision).toContain("JWT tokens");
64
+ expect(result.value.rationale).toContain("Stateless");
65
+ expect(result.value.alternativesRejected).toHaveLength(2);
66
+ expect(result.value.entityMentions).toHaveLength(2);
67
+ });
68
+
69
+ it("should handle ADR with minimal sections", () => {
70
+ const adrPath = join(tmpDir, "0001-simple.md");
71
+ writeFileSync(
72
+ adrPath,
73
+ [
74
+ "# ADR-0001: Simple Decision",
75
+ "",
76
+ "## Status",
77
+ "Proposed",
78
+ "",
79
+ "## Decision",
80
+ "Do the thing.",
81
+ ].join("\n"),
82
+ );
83
+
84
+ const result = extractSingleDecision(adrPath);
85
+ expect(result.ok).toBe(true);
86
+ if (!result.ok) return;
87
+
88
+ expect(result.value.id).toBe("0001-simple");
89
+ expect(result.value.status).toBe("proposed");
90
+ expect(result.value.alternativesRejected).toHaveLength(0);
91
+ });
92
+
93
+ it("should normalize status to lowercase", () => {
94
+ const adrPath = join(tmpDir, "0003-caps.md");
95
+ writeFileSync(
96
+ adrPath,
97
+ "# ADR-0003: Caps Status\n\n## Status\nDEPRECATED\n\n## Decision\nOld thing.",
98
+ );
99
+
100
+ const result = extractSingleDecision(adrPath);
101
+ expect(result.ok).toBe(true);
102
+ if (!result.ok) return;
103
+ expect(result.value.status).toBe("deprecated");
104
+ });
105
+
106
+ it("should handle superseded status", () => {
107
+ const adrPath = join(tmpDir, "0004-old.md");
108
+ writeFileSync(
109
+ adrPath,
110
+ "# ADR-0004: Old Decision\n\n## Status\nSuperseded by ADR-0005\n\n## Decision\nOld approach.",
111
+ );
112
+
113
+ const result = extractSingleDecision(adrPath);
114
+ expect(result.ok).toBe(true);
115
+ if (!result.ok) return;
116
+ expect(result.value.status).toBe("superseded");
117
+ });
118
+
119
+ it("edge case: non-existent file returns error", () => {
120
+ const result = extractSingleDecision(join(tmpDir, "nonexistent.md"));
121
+ expect(result.ok).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe("extractDecisions", () => {
126
+ it("should extract all ADRs from a directory", () => {
127
+ writeFileSync(
128
+ join(tmpDir, "0001-first.md"),
129
+ "# ADR-0001: First\n\n## Status\nAccepted\n\n## Decision\nDo A.",
130
+ );
131
+ writeFileSync(
132
+ join(tmpDir, "0002-second.md"),
133
+ "# ADR-0002: Second\n\n## Status\nProposed\n\n## Decision\nDo B.",
134
+ );
135
+
136
+ const result = extractDecisions(tmpDir);
137
+ expect(result.ok).toBe(true);
138
+ if (!result.ok) return;
139
+
140
+ expect(result.value).toHaveLength(2);
141
+ });
142
+
143
+ it("should skip non-markdown files", () => {
144
+ writeFileSync(join(tmpDir, "README.txt"), "Not an ADR");
145
+ writeFileSync(
146
+ join(tmpDir, "0001-real.md"),
147
+ "# ADR-0001: Real\n\n## Status\nAccepted\n\n## Decision\nX.",
148
+ );
149
+
150
+ const result = extractDecisions(tmpDir);
151
+ expect(result.ok).toBe(true);
152
+ if (!result.ok) return;
153
+ expect(result.value).toHaveLength(1);
154
+ });
155
+
156
+ it("should return empty array for empty directory", () => {
157
+ const result = extractDecisions(tmpDir);
158
+ expect(result.ok).toBe(true);
159
+ if (!result.ok) return;
160
+ expect(result.value).toHaveLength(0);
161
+ });
162
+
163
+ it("should return error for non-existent directory", () => {
164
+ const result = extractDecisions(join(tmpDir, "nonexistent"));
165
+ expect(result.ok).toBe(false);
166
+ });
167
+ });
168
+
169
+ describe("maina ADR format: # NNNN. Title", () => {
170
+ it("should parse maina-style ADR with nested consequences", () => {
171
+ const adrPath = join(tmpDir, "0001-karpathy-spec.md");
172
+ writeFileSync(
173
+ adrPath,
174
+ [
175
+ "# 0001. Karpathy-Principled Spec Quality System",
176
+ "",
177
+ "Date: 2026-04-03",
178
+ "",
179
+ "## Status",
180
+ "",
181
+ "Proposed",
182
+ "",
183
+ "## Context",
184
+ "",
185
+ "After 8 sprints and 769 tests, maina has a complete verification pipeline.",
186
+ "",
187
+ "## Decision",
188
+ "",
189
+ "Build a spec quality scoring system that scores specs 0-100.",
190
+ "",
191
+ "## Consequences",
192
+ "",
193
+ "### Positive",
194
+ "",
195
+ "- Specs become measurably better over time",
196
+ "- Developers can't rationalize skipping verification",
197
+ "",
198
+ "### Negative",
199
+ "",
200
+ "- Additional overhead on each commit",
201
+ "- False positives from measurability heuristics",
202
+ "",
203
+ "### Neutral",
204
+ "",
205
+ "- Requires cultural shift",
206
+ ].join("\n"),
207
+ );
208
+
209
+ const result = extractSingleDecision(adrPath);
210
+ expect(result.ok).toBe(true);
211
+ if (!result.ok) return;
212
+
213
+ expect(result.value.id).toBe("0001-karpathy-spec");
214
+ expect(result.value.title).toBe(
215
+ "Karpathy-Principled Spec Quality System",
216
+ );
217
+ expect(result.value.status).toBe("proposed");
218
+ expect(result.value.context).toContain("769 tests");
219
+ expect(result.value.decision).toContain("spec quality scoring");
220
+ // Consequences should be merged into rationale
221
+ expect(result.value.rationale).toContain("Positive");
222
+ expect(result.value.rationale).toContain("measurably better");
223
+ expect(result.value.rationale).toContain("Negative");
224
+ expect(result.value.rationale).toContain("overhead");
225
+ // No alternatives section in maina ADRs
226
+ expect(result.value.alternativesRejected).toHaveLength(0);
227
+ });
228
+
229
+ it("should parse ADR with HLD/LLD and code paths", () => {
230
+ const adrPath = join(tmpDir, "0002-multi-lang.md");
231
+ writeFileSync(
232
+ adrPath,
233
+ [
234
+ "# 0002. Multi-language verify pipeline",
235
+ "",
236
+ "Date: 2026-04-03",
237
+ "",
238
+ "## Status",
239
+ "",
240
+ "Accepted",
241
+ "",
242
+ "## Context",
243
+ "",
244
+ "Verify pipeline was hardcoded for TypeScript.",
245
+ "",
246
+ "## Decision",
247
+ "",
248
+ "Introduce a LanguageProfile abstraction.",
249
+ "Uses src/verify/syntax-guard.ts and packages/core/src/context/semantic.ts.",
250
+ "",
251
+ "## Consequences",
252
+ "",
253
+ "### Positive",
254
+ "",
255
+ "- Maina works with Python, Go, and Rust projects",
256
+ "",
257
+ "### Negative",
258
+ "",
259
+ "- More tools to maintain",
260
+ ].join("\n"),
261
+ );
262
+
263
+ const result = extractSingleDecision(adrPath);
264
+ expect(result.ok).toBe(true);
265
+ if (!result.ok) return;
266
+
267
+ expect(result.value.id).toBe("0002-multi-lang");
268
+ expect(result.value.title).toBe("Multi-language verify pipeline");
269
+ expect(result.value.status).toBe("accepted");
270
+ // Entity mentions extracted from full content
271
+ expect(result.value.entityMentions.length).toBeGreaterThan(0);
272
+ expect(result.value.entityMentions).toContain(
273
+ "src/verify/syntax-guard.ts",
274
+ );
275
+ });
276
+ });
277
+
278
+ describe("dogfood: extract from maina's own ADRs", () => {
279
+ it("should extract all 12 ADRs from maina's adr/ directory", () => {
280
+ const adrDir = join(process.cwd(), "adr");
281
+
282
+ const result = extractDecisions(adrDir);
283
+ expect(result.ok).toBe(true);
284
+ if (!result.ok) return;
285
+
286
+ expect(result.value).toHaveLength(12);
287
+ for (const d of result.value) {
288
+ expect(d.id).toBeTruthy();
289
+ expect(d.title).toBeTruthy();
290
+ expect(d.title).not.toMatch(/^\d+\./);
291
+ expect(["proposed", "accepted", "deprecated", "superseded"]).toContain(
292
+ d.status,
293
+ );
294
+ expect(d.context.length).toBeGreaterThan(0);
295
+ expect(d.decision.length).toBeGreaterThan(0);
296
+ // All maina ADRs have Consequences sections
297
+ expect(d.rationale.length).toBeGreaterThan(0);
298
+ }
299
+ });
300
+
301
+ it("should extract correct titles from maina ADRs", () => {
302
+ const adrDir = join(process.cwd(), "adr");
303
+ const result = extractDecisions(adrDir);
304
+ expect(result.ok).toBe(true);
305
+ if (!result.ok) return;
306
+
307
+ const titles = result.value.map((d) => d.title);
308
+ expect(titles).toContain("Karpathy-Principled Spec Quality System");
309
+ expect(titles).toContain("Multi-language verify pipeline");
310
+ expect(titles).toContain("Visual verification with Playwright");
311
+ });
312
+
313
+ it("should detect accepted status on maina ADRs", () => {
314
+ const adrDir = join(process.cwd(), "adr");
315
+ const result = extractDecisions(adrDir);
316
+ expect(result.ok).toBe(true);
317
+ if (!result.ok) return;
318
+
319
+ const accepted = result.value.filter((d) => d.status === "accepted");
320
+ expect(accepted.length).toBeGreaterThanOrEqual(3);
321
+ });
322
+ });
323
+ });
@@ -0,0 +1,186 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ extractFeatures,
7
+ extractSingleFeature,
8
+ } from "../../extractors/feature";
9
+
10
+ let tmpDir: string;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = join(
14
+ tmpdir(),
15
+ `wiki-feature-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
16
+ );
17
+ mkdirSync(tmpDir, { recursive: true });
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tmpDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("Feature Extractor", () => {
25
+ describe("extractSingleFeature", () => {
26
+ it("happy path: should extract from plan.md + spec.md + tasks.md", () => {
27
+ const featureDir = join(tmpDir, "001-token-refresh");
28
+ mkdirSync(featureDir, { recursive: true });
29
+
30
+ writeFileSync(
31
+ join(featureDir, "plan.md"),
32
+ [
33
+ "# Implementation Plan: Token Refresh",
34
+ "",
35
+ "## Architecture",
36
+ "- Pattern: Background timer with JWT rotation",
37
+ "",
38
+ "## Tasks",
39
+ "- [ ] T001: Add refresh timer",
40
+ "- [x] T002: Wire error handler",
41
+ ].join("\n"),
42
+ );
43
+
44
+ writeFileSync(
45
+ join(featureDir, "spec.md"),
46
+ [
47
+ "# Feature: Token Refresh",
48
+ "",
49
+ "## Acceptance Criteria",
50
+ "- [ ] Tokens refresh 5 minutes before expiry",
51
+ "- [ ] Failed refresh triggers re-login",
52
+ "",
53
+ "## Scope",
54
+ "Add automatic JWT token refresh to the auth module",
55
+ ].join("\n"),
56
+ );
57
+
58
+ writeFileSync(
59
+ join(featureDir, "tasks.md"),
60
+ [
61
+ "# Task Breakdown",
62
+ "",
63
+ "## Tasks",
64
+ "- [ ] T001: Add refresh timer",
65
+ "- [x] T002: Wire error handler",
66
+ ].join("\n"),
67
+ );
68
+
69
+ const result = extractSingleFeature(featureDir);
70
+ expect(result.ok).toBe(true);
71
+ if (!result.ok) return;
72
+
73
+ expect(result.value.id).toBe("001-token-refresh");
74
+ expect(result.value.title).toBe("Token Refresh");
75
+ expect(result.value.scope).toContain("JWT token refresh");
76
+ expect(result.value.tasks).toHaveLength(2);
77
+ expect(result.value.tasks[0]?.completed).toBe(false);
78
+ expect(result.value.tasks[1]?.completed).toBe(true);
79
+ expect(result.value.specAssertions).toHaveLength(2);
80
+ });
81
+
82
+ it("should extract from plan.md only when spec and tasks are missing", () => {
83
+ const featureDir = join(tmpDir, "002-simple");
84
+ mkdirSync(featureDir, { recursive: true });
85
+
86
+ writeFileSync(
87
+ join(featureDir, "plan.md"),
88
+ [
89
+ "# Implementation Plan: Simple Feature",
90
+ "",
91
+ "## Tasks",
92
+ "- [ ] T001: Do the thing",
93
+ ].join("\n"),
94
+ );
95
+
96
+ const result = extractSingleFeature(featureDir);
97
+ expect(result.ok).toBe(true);
98
+ if (!result.ok) return;
99
+
100
+ expect(result.value.id).toBe("002-simple");
101
+ expect(result.value.title).toBe("Simple Feature");
102
+ expect(result.value.specAssertions).toHaveLength(0);
103
+ });
104
+
105
+ it("edge case: empty feature directory", () => {
106
+ const featureDir = join(tmpDir, "003-empty");
107
+ mkdirSync(featureDir, { recursive: true });
108
+
109
+ const result = extractSingleFeature(featureDir);
110
+ expect(result.ok).toBe(true);
111
+ if (!result.ok) return;
112
+
113
+ expect(result.value.id).toBe("003-empty");
114
+ expect(result.value.title).toBe("");
115
+ expect(result.value.tasks).toHaveLength(0);
116
+ });
117
+
118
+ it("edge case: non-existent directory returns error", () => {
119
+ const result = extractSingleFeature(join(tmpDir, "nonexistent"));
120
+ expect(result.ok).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe("extractFeatures", () => {
125
+ it("should extract all features from a features directory", () => {
126
+ // Create two features
127
+ const feat1 = join(tmpDir, "001-auth");
128
+ mkdirSync(feat1, { recursive: true });
129
+ writeFileSync(
130
+ join(feat1, "plan.md"),
131
+ "# Implementation Plan: Auth\n\n## Tasks\n- [ ] T001: Login",
132
+ );
133
+
134
+ const feat2 = join(tmpDir, "002-cache");
135
+ mkdirSync(feat2, { recursive: true });
136
+ writeFileSync(
137
+ join(feat2, "plan.md"),
138
+ "# Implementation Plan: Cache\n\n## Tasks\n- [ ] T001: LRU",
139
+ );
140
+
141
+ const result = extractFeatures(tmpDir);
142
+ expect(result.ok).toBe(true);
143
+ if (!result.ok) return;
144
+
145
+ expect(result.value).toHaveLength(2);
146
+ expect(result.value.map((f) => f.id)).toContain("001-auth");
147
+ expect(result.value.map((f) => f.id)).toContain("002-cache");
148
+ });
149
+
150
+ it("should return empty array for empty features directory", () => {
151
+ const result = extractFeatures(tmpDir);
152
+ expect(result.ok).toBe(true);
153
+ if (!result.ok) return;
154
+ expect(result.value).toHaveLength(0);
155
+ });
156
+
157
+ it("should skip non-directory entries", () => {
158
+ writeFileSync(join(tmpDir, "README.md"), "# Features");
159
+ const feat1 = join(tmpDir, "001-auth");
160
+ mkdirSync(feat1, { recursive: true });
161
+ writeFileSync(join(feat1, "plan.md"), "# Implementation Plan: Auth");
162
+
163
+ const result = extractFeatures(tmpDir);
164
+ expect(result.ok).toBe(true);
165
+ if (!result.ok) return;
166
+ expect(result.value).toHaveLength(1);
167
+ });
168
+ });
169
+
170
+ describe("dogfood: extract from maina's own features", () => {
171
+ it("should extract features from maina's .maina/features/", () => {
172
+ const mainaFeaturesDir = join(process.cwd(), ".maina", "features");
173
+
174
+ const result = extractFeatures(mainaFeaturesDir);
175
+ expect(result.ok).toBe(true);
176
+ if (!result.ok) return;
177
+
178
+ // Maina has 35+ features at this point
179
+ expect(result.value.length).toBeGreaterThan(0);
180
+ // Each feature should have an id
181
+ for (const f of result.value) {
182
+ expect(f.id).toBeTruthy();
183
+ }
184
+ });
185
+ });
186
+ });
@@ -0,0 +1,131 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { extractWorkflowTrace } from "../../extractors/workflow";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = join(
11
+ tmpdir(),
12
+ `wiki-workflow-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
13
+ );
14
+ mkdirSync(join(tmpDir, "workflow"), { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(tmpDir, { recursive: true, force: true });
19
+ });
20
+
21
+ describe("Workflow Trace Extractor", () => {
22
+ describe("extractWorkflowTrace", () => {
23
+ it("happy path: should extract steps from workflow markdown", () => {
24
+ writeFileSync(
25
+ join(tmpDir, "workflow", "current.md"),
26
+ [
27
+ "# Workflow: token-refresh",
28
+ "",
29
+ "## brainstorm (2026-04-07T10:00:00.000Z)",
30
+ "Explored auth options with wiki context.",
31
+ "",
32
+ "## plan (2026-04-07T10:30:00.000Z)",
33
+ "Scaffolded feature 001-token-refresh.",
34
+ "",
35
+ "## commit (2026-04-07T12:00:00.000Z)",
36
+ "Committed initial implementation. 10 tools passed.",
37
+ ].join("\n"),
38
+ );
39
+
40
+ const result = extractWorkflowTrace(tmpDir);
41
+ expect(result.ok).toBe(true);
42
+ if (!result.ok) return;
43
+
44
+ expect(result.value.featureId).toBe("token-refresh");
45
+ expect(result.value.steps).toHaveLength(3);
46
+ expect(result.value.steps[0]?.command).toBe("brainstorm");
47
+ expect(result.value.steps[0]?.timestamp).toBe("2026-04-07T10:00:00.000Z");
48
+ expect(result.value.steps[0]?.summary).toContain("Explored auth");
49
+ expect(result.value.steps[2]?.command).toBe("commit");
50
+ });
51
+
52
+ it("should handle workflow with only header", () => {
53
+ writeFileSync(
54
+ join(tmpDir, "workflow", "current.md"),
55
+ "# Workflow: empty-feature\n",
56
+ );
57
+
58
+ const result = extractWorkflowTrace(tmpDir);
59
+ expect(result.ok).toBe(true);
60
+ if (!result.ok) return;
61
+
62
+ expect(result.value.featureId).toBe("empty-feature");
63
+ expect(result.value.steps).toHaveLength(0);
64
+ });
65
+
66
+ it("should handle multi-line summaries", () => {
67
+ writeFileSync(
68
+ join(tmpDir, "workflow", "current.md"),
69
+ [
70
+ "# Workflow: multi-line",
71
+ "",
72
+ "## verify (2026-04-07T14:00:00.000Z)",
73
+ "Ran verification pipeline.",
74
+ "10 tools passed, 0 findings.",
75
+ "Coverage: 85%.",
76
+ ].join("\n"),
77
+ );
78
+
79
+ const result = extractWorkflowTrace(tmpDir);
80
+ expect(result.ok).toBe(true);
81
+ if (!result.ok) return;
82
+
83
+ expect(result.value.steps).toHaveLength(1);
84
+ expect(result.value.steps[0]?.summary).toContain("10 tools passed");
85
+ expect(result.value.steps[0]?.summary).toContain("Coverage: 85%");
86
+ });
87
+
88
+ it("should return error when no workflow file exists", () => {
89
+ rmSync(join(tmpDir, "workflow", "current.md"), { force: true });
90
+
91
+ const result = extractWorkflowTrace(tmpDir);
92
+ expect(result.ok).toBe(false);
93
+ });
94
+
95
+ it("should handle workflow without feature name header", () => {
96
+ writeFileSync(
97
+ join(tmpDir, "workflow", "current.md"),
98
+ [
99
+ "# Workflow",
100
+ "",
101
+ "## commit (2026-04-07T12:00:00.000Z)",
102
+ "Quick commit.",
103
+ ].join("\n"),
104
+ );
105
+
106
+ const result = extractWorkflowTrace(tmpDir);
107
+ expect(result.ok).toBe(true);
108
+ if (!result.ok) return;
109
+
110
+ expect(result.value.featureId).toBe("");
111
+ expect(result.value.steps).toHaveLength(1);
112
+ });
113
+
114
+ it("edge case: step without timestamp", () => {
115
+ writeFileSync(
116
+ join(tmpDir, "workflow", "current.md"),
117
+ ["# Workflow: no-time", "", "## brainstorm", "No timestamp here."].join(
118
+ "\n",
119
+ ),
120
+ );
121
+
122
+ const result = extractWorkflowTrace(tmpDir);
123
+ expect(result.ok).toBe(true);
124
+ if (!result.ok) return;
125
+
126
+ expect(result.value.steps).toHaveLength(1);
127
+ expect(result.value.steps[0]?.command).toBe("brainstorm");
128
+ expect(result.value.steps[0]?.timestamp).toBe("");
129
+ });
130
+ });
131
+ });