@comfanion/workflow 4.38.1-dev.9 → 4.38.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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Memory Leak Stress Tests
3
+ *
4
+ * Goal: prove the 3 plugins are NOT the source of a memory leak.
5
+ *
6
+ * Strategy:
7
+ * 1. Run each plugin's hot path N times in a tight loop
8
+ * 2. Measure heap snapshots at intervals
9
+ * 3. Assert heap growth is sub-linear (flat / bounded)
10
+ * 4. Specifically test known risk areas:
11
+ * - custom-compaction: closure variables (lastActiveAgent, lastSessionId)
12
+ * - file-indexer: module-level pendingFiles Map
13
+ * - version-check: stateless (baseline)
14
+ *
15
+ * NOTE: Tests use only the public plugin interface (no internal imports).
16
+ *
17
+ * Run only leak tests:
18
+ * bun test plugins/__tests__/leak-stress.test.ts
19
+ */
20
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
21
+ import { join } from "path"
22
+ import {
23
+ createMockCtx,
24
+ createTempDir,
25
+ cleanupTempDir,
26
+ FIXTURE_SESSION_STATE,
27
+ FIXTURE_STORY_MD,
28
+ FIXTURE_EPIC_STATE,
29
+ FIXTURE_TODOS,
30
+ FIXTURE_CONFIG_YAML,
31
+ } from "./helpers/mock-ctx"
32
+ import { CustomCompactionPlugin } from "../custom-compaction"
33
+ import { FileIndexerPlugin } from "../file-indexer"
34
+ import { VersionCheckPlugin } from "../version-check"
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+ function heapMB(): number {
40
+ return process.memoryUsage().heapUsed / 1024 / 1024
41
+ }
42
+
43
+ function tryGC(): void {
44
+ if (typeof globalThis.gc === "function") {
45
+ globalThis.gc()
46
+ }
47
+ }
48
+
49
+ /** Take N heap snapshots at equal intervals during a callback. */
50
+ async function profile(
51
+ iterations: number,
52
+ snapshots: number,
53
+ fn: (i: number) => Promise<void>,
54
+ ): Promise<number[]> {
55
+ const interval = Math.floor(iterations / snapshots)
56
+ const heaps: number[] = []
57
+
58
+ tryGC()
59
+ heaps.push(heapMB())
60
+
61
+ for (let i = 0; i < iterations; i++) {
62
+ await fn(i)
63
+ if ((i + 1) % interval === 0) {
64
+ tryGC()
65
+ heaps.push(heapMB())
66
+ }
67
+ }
68
+
69
+ return heaps
70
+ }
71
+
72
+ /**
73
+ * Check that heap growth is bounded:
74
+ * last snapshot - first snapshot < maxGrowthMB.
75
+ */
76
+ function assertBoundedGrowth(
77
+ heaps: number[],
78
+ maxGrowthMB: number,
79
+ label: string,
80
+ ): void {
81
+ const growth = heaps[heaps.length - 1] - heaps[0]
82
+ const peak = Math.max(...heaps)
83
+ const valley = Math.min(...heaps)
84
+
85
+ console.log(
86
+ ` [${label}] heap snapshots (MB): ${heaps.map((h) => h.toFixed(1)).join(" -> ")}`,
87
+ )
88
+ console.log(
89
+ ` [${label}] growth: ${growth.toFixed(1)} MB, peak: ${peak.toFixed(1)} MB, valley: ${valley.toFixed(1)} MB`,
90
+ )
91
+
92
+ expect(growth).toBeLessThan(maxGrowthMB)
93
+ }
94
+
95
+ // =============================================================================
96
+ // 1. CUSTOM COMPACTION -- hot path: chat.message + compaction
97
+ // =============================================================================
98
+ describe("leak: custom-compaction", () => {
99
+ const ITERATIONS = 500
100
+ const SNAPSHOTS = 5
101
+ const MAX_GROWTH_MB = 30
102
+
103
+ it("chat.message + compaction cycle doesn't leak", async () => {
104
+ const dir = await createTempDir({
105
+ ".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
106
+ ".opencode/state/todos.json": FIXTURE_TODOS,
107
+ "docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md":
108
+ FIXTURE_STORY_MD,
109
+ })
110
+ const ctx = createMockCtx(dir)
111
+ const hooks = await CustomCompactionPlugin(ctx as any)
112
+
113
+ const heaps = await profile(ITERATIONS, SNAPSHOTS, async (i) => {
114
+ await hooks["chat.message"]!(
115
+ { agent: "dev", sessionID: `s-${i}` } as any,
116
+ { message: {}, parts: [] } as any,
117
+ )
118
+ const output = { context: [] as string[], prompt: undefined }
119
+ await hooks["experimental.session.compacting"]!(
120
+ { sessionID: `s-${i}` } as any,
121
+ output as any,
122
+ )
123
+ })
124
+
125
+ assertBoundedGrowth(heaps, MAX_GROWTH_MB, "compaction-cycle")
126
+ await cleanupTempDir(dir)
127
+ })
128
+
129
+ it("chat.message rapid-fire doesn't accumulate strings", async () => {
130
+ const dir = await createTempDir()
131
+ const ctx = createMockCtx(dir)
132
+ const hooks = await CustomCompactionPlugin(ctx as any)
133
+
134
+ const heaps = await profile(2000, SNAPSHOTS, async (i) => {
135
+ await hooks["chat.message"]!(
136
+ { agent: `agent-${i % 50}`, sessionID: `s-${i}` } as any,
137
+ { message: {}, parts: [] } as any,
138
+ )
139
+ })
140
+
141
+ assertBoundedGrowth(heaps, MAX_GROWTH_MB, "chat.message-rapid")
142
+ await cleanupTempDir(dir)
143
+ })
144
+
145
+ it("event hook doesn't leak on repeated session.idle", async () => {
146
+ const dir = await createTempDir()
147
+ const ctx = createMockCtx(dir)
148
+ const hooks = await CustomCompactionPlugin(ctx as any)
149
+
150
+ const heaps = await profile(1000, SNAPSHOTS, async () => {
151
+ await hooks.event!({ event: { type: "session.idle" } as any })
152
+ })
153
+
154
+ assertBoundedGrowth(heaps, MAX_GROWTH_MB, "event-idle")
155
+ await cleanupTempDir(dir)
156
+ })
157
+
158
+ it("plugin factory doesn't leak across many instances", async () => {
159
+ const heaps = await profile(100, SNAPSHOTS, async (i) => {
160
+ const dir = await createTempDir()
161
+ const ctx = createMockCtx(dir)
162
+ const hooks = await CustomCompactionPlugin(ctx as any)
163
+ await hooks["chat.message"]!(
164
+ { agent: "dev", sessionID: `s-${i}` } as any,
165
+ { message: {}, parts: [] } as any,
166
+ )
167
+ const output = { context: [] as string[], prompt: undefined }
168
+ await hooks["experimental.session.compacting"]!(
169
+ { sessionID: `s-${i}` } as any,
170
+ output as any,
171
+ )
172
+ await cleanupTempDir(dir)
173
+ })
174
+
175
+ assertBoundedGrowth(heaps, 50, "compaction-instances")
176
+ })
177
+ })
178
+
179
+ // =============================================================================
180
+ // 2. FILE INDEXER -- hot path: event handler (file.edited queueing)
181
+ // =============================================================================
182
+ describe("leak: file-indexer", () => {
183
+ const ITERATIONS = 1000
184
+ const SNAPSHOTS = 5
185
+ const MAX_GROWTH_MB = 30
186
+
187
+ it("file.edited event handler doesn't leak (cycling file names)", async () => {
188
+ const dir = await createTempDir({
189
+ ".opencode/config.yaml": FIXTURE_CONFIG_YAML,
190
+ })
191
+ const ctx = createMockCtx(dir)
192
+ const hooks = await FileIndexerPlugin(ctx as any)
193
+
194
+ const heaps = await profile(ITERATIONS, SNAPSHOTS, async (i) => {
195
+ await hooks.event!({
196
+ event: {
197
+ type: "file.edited",
198
+ properties: { file: join(dir, `src/file-${i % 50}.ts`) },
199
+ } as any,
200
+ })
201
+ })
202
+
203
+ assertBoundedGrowth(heaps, MAX_GROWTH_MB, "file-edited-events")
204
+ await cleanupTempDir(dir)
205
+ })
206
+
207
+ it("disabled plugin doesn't accumulate anything", async () => {
208
+ const dir = await createTempDir({
209
+ ".opencode/config.yaml": `vectorizer:\n enabled: false\n auto_index: false\n`,
210
+ })
211
+ const ctx = createMockCtx(dir)
212
+ const hooks = await FileIndexerPlugin(ctx as any)
213
+
214
+ const heaps = await profile(2000, SNAPSHOTS, async () => {
215
+ await hooks.event!({
216
+ event: {
217
+ type: "file.edited",
218
+ properties: { file: join(dir, "src/app.ts") },
219
+ } as any,
220
+ })
221
+ })
222
+
223
+ assertBoundedGrowth(heaps, 10, "disabled-plugin")
224
+ await cleanupTempDir(dir)
225
+ })
226
+ })
227
+
228
+ // =============================================================================
229
+ // 3. VERSION CHECK -- hot path: event handler (no-op)
230
+ // =============================================================================
231
+ describe("leak: version-check", () => {
232
+ const ITERATIONS = 2000
233
+ const SNAPSHOTS = 5
234
+ const MAX_GROWTH_MB = 10
235
+
236
+ it("no-op event handler doesn't leak", async () => {
237
+ const dir = await createTempDir()
238
+ const ctx = createMockCtx(dir)
239
+ const hooks = await VersionCheckPlugin(ctx as any)
240
+
241
+ const heaps = await profile(ITERATIONS, SNAPSHOTS, async () => {
242
+ await hooks.event!({ event: { type: "session.idle" } as any })
243
+ })
244
+
245
+ assertBoundedGrowth(heaps, MAX_GROWTH_MB, "version-noop")
246
+ await cleanupTempDir(dir)
247
+ })
248
+
249
+ it("multiple plugin instances don't leak", async () => {
250
+ const heaps = await profile(100, SNAPSHOTS, async () => {
251
+ const dir = await createTempDir()
252
+ const ctx = createMockCtx(dir)
253
+ const hooks = await VersionCheckPlugin(ctx as any)
254
+ await hooks.event!({ event: { type: "session.idle" } as any })
255
+ await cleanupTempDir(dir)
256
+ })
257
+
258
+ assertBoundedGrowth(heaps, 30, "version-instances")
259
+ })
260
+ })
261
+
262
+ // =============================================================================
263
+ // 4. COMBINED -- simulate a realistic session with all 3 plugins
264
+ // =============================================================================
265
+ describe("leak: combined realistic session", () => {
266
+ it("all 3 plugins running together don't leak", async () => {
267
+ const dir = await createTempDir({
268
+ ".opencode/session-state.yaml": FIXTURE_SESSION_STATE,
269
+ ".opencode/state/todos.json": FIXTURE_TODOS,
270
+ ".opencode/config.yaml": FIXTURE_CONFIG_YAML,
271
+ "docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md":
272
+ FIXTURE_STORY_MD,
273
+ })
274
+ const ctx = createMockCtx(dir)
275
+
276
+ const [compaction, indexer, versionCheck] = await Promise.all([
277
+ CustomCompactionPlugin(ctx as any),
278
+ FileIndexerPlugin(ctx as any),
279
+ VersionCheckPlugin(ctx as any),
280
+ ])
281
+
282
+ const heaps = await profile(300, 5, async (i) => {
283
+ // Simulate realistic session cycle:
284
+ // 1. Agent sends message
285
+ await compaction["chat.message"]!(
286
+ { agent: "dev", sessionID: `s-${i}` } as any,
287
+ { message: {}, parts: [] } as any,
288
+ )
289
+
290
+ // 2. File gets edited
291
+ await indexer.event!({
292
+ event: {
293
+ type: "file.edited",
294
+ properties: { file: join(dir, `src/module-${i % 20}.ts`) },
295
+ } as any,
296
+ })
297
+
298
+ // 3. Session idle
299
+ await compaction.event!({ event: { type: "session.idle" } as any })
300
+ await versionCheck.event!({ event: { type: "session.idle" } as any })
301
+
302
+ // 4. Every 50 iterations, simulate compaction
303
+ if (i % 50 === 0) {
304
+ const output = { context: [] as string[], prompt: undefined }
305
+ await compaction["experimental.session.compacting"]!(
306
+ { sessionID: `s-${i}` } as any,
307
+ output as any,
308
+ )
309
+ }
310
+ })
311
+
312
+ assertBoundedGrowth(heaps, 40, "combined-session")
313
+ await cleanupTempDir(dir)
314
+ })
315
+ })
@@ -0,0 +1,223 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { writeFile } from "fs/promises"
4
+ import { VersionCheckPlugin } from "../version-check"
5
+ import { createMockCtx, createTempDir, cleanupTempDir } from "./helpers/mock-ctx"
6
+
7
+ // =============================================================================
8
+ // LOCAL REPLICAS of internal functions (not exported from plugin)
9
+ // These mirror the source logic so we can unit-test the algorithms directly.
10
+ // =============================================================================
11
+
12
+ /** Replica of compareVersions from version-check.ts (with pre-release strip fix) */
13
+ function compareVersions(local: string, latest: string): number {
14
+ const strip = (v: string) => v.replace(/-.*$/, '')
15
+ const localParts = strip(local).split('.').map(Number)
16
+ const latestParts = strip(latest).split('.').map(Number)
17
+
18
+ for (let i = 0; i < 3; i++) {
19
+ const l = localParts[i] || 0
20
+ const r = latestParts[i] || 0
21
+ if (l < r) return -1
22
+ if (l > r) return 1
23
+ }
24
+ return 0
25
+ }
26
+
27
+ // =============================================================================
28
+ // UNIT TESTS: compareVersions (replica)
29
+ // =============================================================================
30
+ describe("compareVersions", () => {
31
+ it("returns 0 for equal versions", () => {
32
+ expect(compareVersions("1.0.0", "1.0.0")).toBe(0)
33
+ expect(compareVersions("4.36.21", "4.36.21")).toBe(0)
34
+ })
35
+
36
+ it("returns -1 when local < latest (patch)", () => {
37
+ expect(compareVersions("1.0.0", "1.0.1")).toBe(-1)
38
+ })
39
+
40
+ it("returns -1 when local < latest (minor)", () => {
41
+ expect(compareVersions("1.0.0", "1.1.0")).toBe(-1)
42
+ })
43
+
44
+ it("returns -1 when local < latest (major)", () => {
45
+ expect(compareVersions("1.0.0", "2.0.0")).toBe(-1)
46
+ })
47
+
48
+ it("returns 1 when local > latest (patch)", () => {
49
+ expect(compareVersions("1.0.2", "1.0.1")).toBe(1)
50
+ })
51
+
52
+ it("returns 1 when local > latest (minor)", () => {
53
+ expect(compareVersions("1.2.0", "1.1.9")).toBe(1)
54
+ })
55
+
56
+ it("returns 1 when local > latest (major)", () => {
57
+ expect(compareVersions("3.0.0", "2.99.99")).toBe(1)
58
+ })
59
+
60
+ it("handles versions with missing parts", () => {
61
+ expect(compareVersions("1.0", "1.0.0")).toBe(0)
62
+ expect(compareVersions("1", "1.0.0")).toBe(0)
63
+ expect(compareVersions("1.0.0", "1.0")).toBe(0)
64
+ })
65
+
66
+ it("handles large version numbers", () => {
67
+ expect(compareVersions("100.200.300", "100.200.300")).toBe(0)
68
+ expect(compareVersions("100.200.300", "100.200.301")).toBe(-1)
69
+ })
70
+
71
+ it("handles pre-release versions (strips suffix)", () => {
72
+ expect(compareVersions("4.38.1-beta.1", "4.38.1")).toBe(0)
73
+ expect(compareVersions("4.38.1-dev.16", "4.38.2")).toBe(-1)
74
+ expect(compareVersions("5.0.0-rc.1", "4.99.99")).toBe(1)
75
+ expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBe(0) // both strip to 1.0.0
76
+ })
77
+
78
+ it("does not return NaN for pre-release versions", () => {
79
+ const result = compareVersions("4.38.1-beta.1", "4.38.2")
80
+ expect(result).not.toBeNaN()
81
+ expect(result).toBe(-1)
82
+ })
83
+ })
84
+
85
+ // =============================================================================
86
+ // INTEGRATION: Plugin initialization & hooks
87
+ // =============================================================================
88
+ describe("VersionCheckPlugin", () => {
89
+ let tempDir: string
90
+
91
+ beforeEach(async () => {
92
+ tempDir = await createTempDir()
93
+ })
94
+
95
+ afterEach(async () => {
96
+ await cleanupTempDir(tempDir)
97
+ })
98
+
99
+ it("returns hooks object with event handler", async () => {
100
+ const ctx = createMockCtx(tempDir)
101
+ const hooks = await VersionCheckPlugin(ctx as any)
102
+
103
+ expect(hooks).toBeDefined()
104
+ expect(hooks.event).toBeFunction()
105
+ })
106
+
107
+ it("event handler is a no-op (does not throw)", async () => {
108
+ const ctx = createMockCtx(tempDir)
109
+ const hooks = await VersionCheckPlugin(ctx as any)
110
+
111
+ // Call event with various event types - should not throw
112
+ await hooks.event!({ event: { type: "session.idle" } as any })
113
+ await hooks.event!({ event: { type: "file.edited" } as any })
114
+ await hooks.event!({ event: { type: "unknown.event" } as any })
115
+ })
116
+
117
+ it("does not throw when client.tui is missing", async () => {
118
+ const ctx = {
119
+ directory: tempDir,
120
+ worktree: tempDir,
121
+ client: {},
122
+ project: { name: "test" },
123
+ serverUrl: new URL("http://localhost:3000"),
124
+ $: {},
125
+ }
126
+ // Should not throw even with minimal client
127
+ const hooks = await VersionCheckPlugin(ctx as any)
128
+ expect(hooks).toBeDefined()
129
+ })
130
+
131
+ it("getLocalVersion reads build-info.json via plugin behavior", async () => {
132
+ // Write build-info.json — the plugin reads this internally during setTimeout
133
+ await writeFile(
134
+ join(tempDir, ".opencode", "build-info.json"),
135
+ JSON.stringify({ version: "4.38.0" })
136
+ )
137
+ const ctx = createMockCtx(tempDir)
138
+ const hooks = await VersionCheckPlugin(ctx as any)
139
+ // Plugin initializes successfully
140
+ expect(hooks).toBeDefined()
141
+ })
142
+
143
+ it("getLocalVersion fallback to config.yaml", async () => {
144
+ await writeFile(
145
+ join(tempDir, ".opencode", "config.yaml"),
146
+ `version: "3.5.0"\nproject_name: test\n`
147
+ )
148
+ const ctx = createMockCtx(tempDir)
149
+ const hooks = await VersionCheckPlugin(ctx as any)
150
+ expect(hooks).toBeDefined()
151
+ })
152
+
153
+ it("getLanguage defaults to 'en' when config missing", async () => {
154
+ // No config.yaml — language detection should default to 'en'
155
+ const ctx = createMockCtx(tempDir)
156
+ const hooks = await VersionCheckPlugin(ctx as any)
157
+ expect(hooks).toBeDefined()
158
+ })
159
+
160
+ it("loads and saves cache via plugin lifecycle", async () => {
161
+ // Write version info so plugin has something to work with
162
+ await writeFile(
163
+ join(tempDir, ".opencode", "build-info.json"),
164
+ JSON.stringify({ version: "4.38.0" })
165
+ )
166
+ const ctx = createMockCtx(tempDir)
167
+ const hooks = await VersionCheckPlugin(ctx as any)
168
+ expect(hooks).toBeDefined()
169
+ // Plugin will attempt to check version in background (setTimeout)
170
+ // No assertion needed — just ensure no crash
171
+ })
172
+ })
173
+
174
+ // =============================================================================
175
+ // MEMORY SAFETY
176
+ // =============================================================================
177
+ describe("memory safety", () => {
178
+ let tempDir: string
179
+
180
+ beforeEach(async () => {
181
+ tempDir = await createTempDir()
182
+ })
183
+
184
+ afterEach(async () => {
185
+ await cleanupTempDir(tempDir)
186
+ })
187
+
188
+ it("no state accumulation across multiple event calls", async () => {
189
+ const ctx = createMockCtx(tempDir)
190
+ const hooks = await VersionCheckPlugin(ctx as any)
191
+
192
+ // Call event handler many times - should not accumulate state
193
+ for (let i = 0; i < 1000; i++) {
194
+ await hooks.event!({ event: { type: "session.idle" } as any })
195
+ }
196
+
197
+ // If we get here without OOM or excessive delay, no leak
198
+ expect(true).toBe(true)
199
+ })
200
+
201
+ it("multiple plugin initializations don't leak", async () => {
202
+ const memBefore = process.memoryUsage().heapUsed
203
+
204
+ for (let i = 0; i < 50; i++) {
205
+ const dir = await createTempDir()
206
+ const ctx = createMockCtx(dir)
207
+ const hooks = await VersionCheckPlugin(ctx as any)
208
+ await hooks.event!({ event: { type: "session.idle" } as any })
209
+ await cleanupTempDir(dir)
210
+ }
211
+
212
+ // Force GC if available
213
+ if (typeof Bun !== "undefined" && (globalThis as any).gc) {
214
+ ;(globalThis as any).gc()
215
+ }
216
+
217
+ const memAfter = process.memoryUsage().heapUsed
218
+ const growthMB = (memAfter - memBefore) / 1024 / 1024
219
+
220
+ // Memory growth should be < 50MB for 50 iterations
221
+ expect(growthMB).toBeLessThan(50)
222
+ })
223
+ })
@@ -260,8 +260,9 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
260
260
  const fullPath = join(statePath, stateFile)
261
261
  const content = await readFile(fullPath, "utf-8")
262
262
 
263
- // Check if this epic is in-progress
264
- if (content.includes("status: \"in-progress\"") || content.includes("status: in-progress")) {
263
+ // Check if this epic is in-progress (normalize variants)
264
+ const contentLower = content.toLowerCase()
265
+ if (contentLower.includes("status: \"in-progress\"") || contentLower.includes("status: in-progress") || contentLower.includes("status: \"in_progress\"") || contentLower.includes("status: in_progress")) {
265
266
  // Parse epic state
266
267
  const epicIdMatch = content.match(/epic_id:\s*["']?([^"'\n]+)["']?/i)
267
268
  const epicTitleMatch = content.match(/epic_title:\s*["']?([^"'\n]+)["']?/i)
@@ -343,11 +344,18 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
343
344
  return m ? m[1].trim() : null
344
345
  }
345
346
  const list = (parent: string, key: string): string[] => {
347
+ // First try inline format: key: [T1, T2] or key: T1, T2
346
348
  const val = nested(parent, key)
347
- if (!val) return []
348
- // Handle [T1, T2] or T1, T2
349
- const clean = val.replace(/^\[/, '').replace(/\]$/, '')
350
- return clean.split(',').map(s => s.trim()).filter(Boolean)
349
+ if (val) {
350
+ const clean = val.replace(/^\[/, '').replace(/\]$/, '')
351
+ return clean.split(',').map(s => s.trim()).filter(Boolean)
352
+ }
353
+ // Fallback: YAML block list under parent.key
354
+ const section = content.match(new RegExp(`^${parent}:\\s*\\n([\\s\\S]*?)(?=^\\w+:|$)`, 'm'))
355
+ if (!section) return []
356
+ const keyBlock = section[1].match(new RegExp(`^\\s+${key}:\\s*\\n((?:\\s+-\\s+.+\\n?)*)`, 'm'))
357
+ if (!keyBlock) return []
358
+ return [...keyBlock[1].matchAll(/^\s+-\s+(.+)$/gm)].map(m => m[1].trim())
351
359
  }
352
360
 
353
361
  // Parse key_decisions as list
@@ -422,7 +430,7 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
422
430
  // Parse story file
423
431
  const storyContent = await readFile(join(directory, storyPath), "utf-8")
424
432
  const titleMatch = storyContent.match(/^#\s+(.+)/m)
425
- const statusMatch = storyContent.match(/\*\*Status:\*\*\s*(\w+)/i)
433
+ const statusMatch = storyContent.match(/\*\*Status:\*\*\s*([\w-]+)/i)
426
434
 
427
435
  const completedTasks: string[] = []
428
436
  const pendingTasks: string[] = []
@@ -547,7 +555,7 @@ DO NOT skip this step. DO NOT ask user what to do. Just read these files first.`
547
555
  try {
548
556
  const storyContent = await readFile(join(directory, storyPath), "utf-8")
549
557
  const titleMatch = storyContent.match(/^#\s+(.+)/m)
550
- const statusMatch = storyContent.match(/\*\*Status:\*\*\s*(\w+)/i)
558
+ const statusMatch = storyContent.match(/\*\*Status:\*\*\s*([\w-]+)/i)
551
559
 
552
560
  const completedTasks: string[] = []
553
561
  const pendingTasks: string[] = []
@@ -802,7 +810,8 @@ ${ctx.relevantFiles.map(f => `- \`${f}\``).join("\n")}`)
802
810
  async function formatInstructions(ctx: SessionContext): Promise<string> {
803
811
  const agent = ctx.activeAgent?.toLowerCase()
804
812
  const hasInProgressTasks = ctx.todos.some(t => t.status === "in_progress")
805
- const hasInProgressStory = ctx.story?.status === "in-progress"
813
+ const storyStatus = ctx.story?.status?.toLowerCase().replace(/_/g, '-') || ''
814
+ const hasInProgressStory = storyStatus === "in-progress"
806
815
 
807
816
  // Check if we're in epic workflow
808
817
  const epicState = await getActiveEpicState()
@@ -1022,6 +1031,9 @@ This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
1022
1031
  await log(directory, `=== COMPACTION STARTED ===`)
1023
1032
  await log(directory, ` lastActiveAgent: ${lastActiveAgent}`)
1024
1033
 
1034
+ // Guard: ensure output.context is an array
1035
+ if (!output.context) output.context = []
1036
+
1025
1037
  // Use tracked agent or try to detect from session
1026
1038
  const agent = lastActiveAgent
1027
1039
  const ctx = await buildContext(agent)
@@ -1039,6 +1051,14 @@ This is /dev-epic autopilot mode. Execute stories sequentially until epic done.`
1039
1051
  const ss = ctx.sessionState
1040
1052
  const briefing = buildBriefing(agent, ss, ctx, readCommands)
1041
1053
  output.context.push(briefing)
1054
+
1055
+ // Push agent-specific context (story details, epic progress, AC) and resume instructions
1056
+ if (context) {
1057
+ output.context.push(context)
1058
+ }
1059
+ if (instructions) {
1060
+ output.context.push(instructions)
1061
+ }
1042
1062
 
1043
1063
  await log(directory, ` -> output.context pushed (${output.context.length} items)`)
1044
1064
  await log(directory, `=== COMPACTION DONE ===`)