wogiflow 1.0.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 (221) hide show
  1. package/.workflow/agents/reviewer.md +81 -0
  2. package/.workflow/agents/security.md +94 -0
  3. package/.workflow/agents/story-writer.md +58 -0
  4. package/.workflow/bridges/base-bridge.js +395 -0
  5. package/.workflow/bridges/claude-bridge.js +434 -0
  6. package/.workflow/bridges/index.js +130 -0
  7. package/.workflow/lib/assumption-detector.js +481 -0
  8. package/.workflow/lib/config-substitution.js +371 -0
  9. package/.workflow/lib/failure-categories.js +478 -0
  10. package/.workflow/state/app-map.md.template +15 -0
  11. package/.workflow/state/architecture.md.template +24 -0
  12. package/.workflow/state/component-index.json.template +5 -0
  13. package/.workflow/state/decisions.md.template +15 -0
  14. package/.workflow/state/feedback-patterns.md.template +9 -0
  15. package/.workflow/state/knowledge-sync.json.template +6 -0
  16. package/.workflow/state/progress.md.template +14 -0
  17. package/.workflow/state/ready.json.template +7 -0
  18. package/.workflow/state/request-log.md.template +14 -0
  19. package/.workflow/state/session-state.json.template +11 -0
  20. package/.workflow/state/stack.md.template +33 -0
  21. package/.workflow/state/testing.md.template +36 -0
  22. package/.workflow/templates/claude-md.hbs +257 -0
  23. package/.workflow/templates/correction-report.md +67 -0
  24. package/.workflow/templates/gemini-md.hbs +52 -0
  25. package/README.md +1802 -0
  26. package/bin/flow +205 -0
  27. package/lib/index.js +33 -0
  28. package/lib/installer.js +467 -0
  29. package/lib/release-channel.js +269 -0
  30. package/lib/skill-registry.js +526 -0
  31. package/lib/upgrader.js +401 -0
  32. package/lib/utils.js +305 -0
  33. package/package.json +64 -0
  34. package/scripts/flow +985 -0
  35. package/scripts/flow-adaptive-learning.js +1259 -0
  36. package/scripts/flow-aggregate.js +488 -0
  37. package/scripts/flow-archive +133 -0
  38. package/scripts/flow-auto-context.js +1015 -0
  39. package/scripts/flow-auto-learn.js +615 -0
  40. package/scripts/flow-bridge.js +223 -0
  41. package/scripts/flow-browser-suggest.js +316 -0
  42. package/scripts/flow-bug.js +247 -0
  43. package/scripts/flow-cascade.js +711 -0
  44. package/scripts/flow-changelog +85 -0
  45. package/scripts/flow-checkpoint.js +483 -0
  46. package/scripts/flow-cli.js +403 -0
  47. package/scripts/flow-code-intelligence.js +760 -0
  48. package/scripts/flow-complexity.js +502 -0
  49. package/scripts/flow-config-set.js +152 -0
  50. package/scripts/flow-constants.js +157 -0
  51. package/scripts/flow-context +152 -0
  52. package/scripts/flow-context-init.js +482 -0
  53. package/scripts/flow-context-monitor.js +384 -0
  54. package/scripts/flow-context-scoring.js +886 -0
  55. package/scripts/flow-correct.js +458 -0
  56. package/scripts/flow-damage-control.js +985 -0
  57. package/scripts/flow-deps +101 -0
  58. package/scripts/flow-diff.js +700 -0
  59. package/scripts/flow-done +151 -0
  60. package/scripts/flow-done.js +489 -0
  61. package/scripts/flow-durable-session.js +1541 -0
  62. package/scripts/flow-entropy-monitor.js +345 -0
  63. package/scripts/flow-export-profile +349 -0
  64. package/scripts/flow-export-scanner.js +1046 -0
  65. package/scripts/flow-figma-confirm.js +400 -0
  66. package/scripts/flow-figma-extract.js +496 -0
  67. package/scripts/flow-figma-generate.js +683 -0
  68. package/scripts/flow-figma-index.js +909 -0
  69. package/scripts/flow-figma-match.js +617 -0
  70. package/scripts/flow-figma-mcp-server.js +518 -0
  71. package/scripts/flow-figma-pipeline.js +414 -0
  72. package/scripts/flow-file-ops.js +301 -0
  73. package/scripts/flow-gate-confidence.js +825 -0
  74. package/scripts/flow-guided-edit.js +659 -0
  75. package/scripts/flow-health +185 -0
  76. package/scripts/flow-health.js +413 -0
  77. package/scripts/flow-hooks.js +556 -0
  78. package/scripts/flow-http-client.js +249 -0
  79. package/scripts/flow-hybrid-detect.js +167 -0
  80. package/scripts/flow-hybrid-interactive.js +591 -0
  81. package/scripts/flow-hybrid-test.js +152 -0
  82. package/scripts/flow-import-profile +439 -0
  83. package/scripts/flow-init +253 -0
  84. package/scripts/flow-instruction-richness.js +827 -0
  85. package/scripts/flow-jira-integration.js +579 -0
  86. package/scripts/flow-knowledge-router.js +522 -0
  87. package/scripts/flow-knowledge-sync.js +589 -0
  88. package/scripts/flow-linear-integration.js +631 -0
  89. package/scripts/flow-links.js +774 -0
  90. package/scripts/flow-log-manager.js +559 -0
  91. package/scripts/flow-loop-enforcer.js +1246 -0
  92. package/scripts/flow-loop-retry-learning.js +630 -0
  93. package/scripts/flow-lsp.js +923 -0
  94. package/scripts/flow-map-index +348 -0
  95. package/scripts/flow-map-sync +201 -0
  96. package/scripts/flow-memory-blocks.js +668 -0
  97. package/scripts/flow-memory-compactor.js +350 -0
  98. package/scripts/flow-memory-db.js +1110 -0
  99. package/scripts/flow-memory-sync.js +484 -0
  100. package/scripts/flow-metrics.js +353 -0
  101. package/scripts/flow-migrate-ids.js +370 -0
  102. package/scripts/flow-model-adapter.js +802 -0
  103. package/scripts/flow-model-router.js +884 -0
  104. package/scripts/flow-models.js +1231 -0
  105. package/scripts/flow-morning.js +517 -0
  106. package/scripts/flow-multi-approach.js +660 -0
  107. package/scripts/flow-new-feature +86 -0
  108. package/scripts/flow-onboard +1042 -0
  109. package/scripts/flow-orchestrate-llm.js +459 -0
  110. package/scripts/flow-orchestrate.js +3592 -0
  111. package/scripts/flow-output.js +123 -0
  112. package/scripts/flow-parallel-detector.js +399 -0
  113. package/scripts/flow-parallel-dispatch.js +987 -0
  114. package/scripts/flow-parallel.js +428 -0
  115. package/scripts/flow-pattern-enforcer.js +600 -0
  116. package/scripts/flow-prd-manager.js +282 -0
  117. package/scripts/flow-progress.js +323 -0
  118. package/scripts/flow-project-analyzer.js +975 -0
  119. package/scripts/flow-prompt-composer.js +487 -0
  120. package/scripts/flow-providers.js +1381 -0
  121. package/scripts/flow-queue.js +308 -0
  122. package/scripts/flow-ready +82 -0
  123. package/scripts/flow-ready.js +189 -0
  124. package/scripts/flow-regression.js +396 -0
  125. package/scripts/flow-response-parser.js +450 -0
  126. package/scripts/flow-resume.js +284 -0
  127. package/scripts/flow-rules-sync.js +439 -0
  128. package/scripts/flow-run-trace.js +718 -0
  129. package/scripts/flow-safety.js +587 -0
  130. package/scripts/flow-search +104 -0
  131. package/scripts/flow-security.js +481 -0
  132. package/scripts/flow-session-end +106 -0
  133. package/scripts/flow-session-end.js +437 -0
  134. package/scripts/flow-session-state.js +671 -0
  135. package/scripts/flow-setup-hooks +216 -0
  136. package/scripts/flow-setup-hooks.js +377 -0
  137. package/scripts/flow-skill-create.js +329 -0
  138. package/scripts/flow-skill-creator.js +572 -0
  139. package/scripts/flow-skill-generator.js +1046 -0
  140. package/scripts/flow-skill-learn.js +880 -0
  141. package/scripts/flow-skill-matcher.js +578 -0
  142. package/scripts/flow-spec-generator.js +820 -0
  143. package/scripts/flow-stack-wizard.js +895 -0
  144. package/scripts/flow-standup +162 -0
  145. package/scripts/flow-start +74 -0
  146. package/scripts/flow-start.js +235 -0
  147. package/scripts/flow-status +110 -0
  148. package/scripts/flow-status.js +301 -0
  149. package/scripts/flow-step-browser.js +83 -0
  150. package/scripts/flow-step-changelog.js +217 -0
  151. package/scripts/flow-step-comments.js +306 -0
  152. package/scripts/flow-step-complexity.js +234 -0
  153. package/scripts/flow-step-coverage.js +218 -0
  154. package/scripts/flow-step-knowledge.js +193 -0
  155. package/scripts/flow-step-pr-tests.js +364 -0
  156. package/scripts/flow-step-regression.js +89 -0
  157. package/scripts/flow-step-review.js +516 -0
  158. package/scripts/flow-step-security.js +162 -0
  159. package/scripts/flow-step-silent-failures.js +290 -0
  160. package/scripts/flow-step-simplifier.js +346 -0
  161. package/scripts/flow-story +105 -0
  162. package/scripts/flow-story.js +500 -0
  163. package/scripts/flow-suspend.js +252 -0
  164. package/scripts/flow-sync-daemon.js +654 -0
  165. package/scripts/flow-task-analyzer.js +606 -0
  166. package/scripts/flow-team-dashboard.js +748 -0
  167. package/scripts/flow-team-sync.js +752 -0
  168. package/scripts/flow-team.js +977 -0
  169. package/scripts/flow-tech-options.js +528 -0
  170. package/scripts/flow-templates.js +812 -0
  171. package/scripts/flow-tiered-learning.js +728 -0
  172. package/scripts/flow-trace +204 -0
  173. package/scripts/flow-transcript-chunking.js +1106 -0
  174. package/scripts/flow-transcript-digest.js +7918 -0
  175. package/scripts/flow-transcript-language.js +465 -0
  176. package/scripts/flow-transcript-parsing.js +1085 -0
  177. package/scripts/flow-transcript-stories.js +2194 -0
  178. package/scripts/flow-update-map +224 -0
  179. package/scripts/flow-utils.js +2242 -0
  180. package/scripts/flow-verification.js +644 -0
  181. package/scripts/flow-verify.js +1177 -0
  182. package/scripts/flow-voice-input.js +638 -0
  183. package/scripts/flow-watch +168 -0
  184. package/scripts/flow-workflow-steps.js +521 -0
  185. package/scripts/flow-workflow.js +1029 -0
  186. package/scripts/flow-worktree.js +489 -0
  187. package/scripts/hooks/adapters/base-adapter.js +102 -0
  188. package/scripts/hooks/adapters/claude-code.js +359 -0
  189. package/scripts/hooks/adapters/index.js +79 -0
  190. package/scripts/hooks/core/component-check.js +341 -0
  191. package/scripts/hooks/core/index.js +35 -0
  192. package/scripts/hooks/core/loop-check.js +241 -0
  193. package/scripts/hooks/core/session-context.js +294 -0
  194. package/scripts/hooks/core/task-gate.js +177 -0
  195. package/scripts/hooks/core/validation.js +230 -0
  196. package/scripts/hooks/entry/claude-code/post-tool-use.js +65 -0
  197. package/scripts/hooks/entry/claude-code/pre-tool-use.js +89 -0
  198. package/scripts/hooks/entry/claude-code/session-end.js +87 -0
  199. package/scripts/hooks/entry/claude-code/session-start.js +46 -0
  200. package/scripts/hooks/entry/claude-code/stop.js +43 -0
  201. package/scripts/postinstall.js +139 -0
  202. package/templates/browser-test-flow.json +56 -0
  203. package/templates/bug-report.md +43 -0
  204. package/templates/component-detail.md +42 -0
  205. package/templates/component.stories.tsx +49 -0
  206. package/templates/context/constraints.md +83 -0
  207. package/templates/context/conventions.md +177 -0
  208. package/templates/context/stack.md +60 -0
  209. package/templates/correction-report.md +90 -0
  210. package/templates/feature-proposal.md +35 -0
  211. package/templates/hybrid/_base.md +254 -0
  212. package/templates/hybrid/_patterns.md +45 -0
  213. package/templates/hybrid/create-component.md +127 -0
  214. package/templates/hybrid/create-file.md +56 -0
  215. package/templates/hybrid/create-hook.md +145 -0
  216. package/templates/hybrid/create-service.md +70 -0
  217. package/templates/hybrid/fix-bug.md +33 -0
  218. package/templates/hybrid/modify-file.md +55 -0
  219. package/templates/story.md +68 -0
  220. package/templates/task.json +56 -0
  221. package/templates/trace.md +69 -0
@@ -0,0 +1,1541 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Durable Session Manager
5
+ *
6
+ * Unified step tracking that survives crashes/context resets.
7
+ * Replaces both loop-session.json and hybrid-session.json with
8
+ * a single durable-session.json.
9
+ *
10
+ * Features:
11
+ * - Step-based execution for all task types
12
+ * - Resume from exact step after crash
13
+ * - Skip completed steps automatically
14
+ * - Suspension support (time, poll, manual, file-based)
15
+ * - Backward compatibility with existing APIs
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { execSync } = require('child_process');
21
+ const { getConfig, getProjectRoot, MAX_SESSION_HISTORY, withLock, writeJson, ensureDir } = require('./flow-utils');
22
+ const { validateCommand } = require('./flow-workflow');
23
+ const { validatePathWithinProject } = require('./flow-security');
24
+
25
+ // ============================================================================
26
+ // Constants
27
+ // ============================================================================
28
+
29
+ const SESSION_VERSION = '2.0';
30
+ const SESSION_FILE = 'durable-session.json';
31
+ const HISTORY_FILE = 'durable-history.json';
32
+ const LEGACY_HYBRID_FILE = 'hybrid-session.json'; // Deprecated, cleaned up on new session
33
+ // MAX_HISTORY imported from flow-utils as MAX_SESSION_HISTORY
34
+
35
+ const STEP_STATUS = {
36
+ PENDING: 'pending',
37
+ IN_PROGRESS: 'in_progress',
38
+ COMPLETED: 'completed',
39
+ FAILED: 'failed',
40
+ SKIPPED: 'skipped',
41
+ SUSPENDED: 'suspended'
42
+ };
43
+
44
+ const STEP_TYPE = {
45
+ ACCEPTANCE_CRITERIA: 'acceptance-criteria',
46
+ HYBRID_EXECUTION: 'hybrid-execution',
47
+ QUALITY_GATE: 'quality-gate',
48
+ CUSTOM: 'custom'
49
+ };
50
+
51
+ const SUSPENSION_TYPE = {
52
+ CI_CD: 'ci-cd',
53
+ SCHEDULED: 'scheduled',
54
+ RATE_LIMIT: 'rate-limit',
55
+ HUMAN_REVIEW: 'human-review',
56
+ EXTERNAL_EVENT: 'external-event',
57
+ LONG_RUNNING: 'long-running'
58
+ };
59
+
60
+ const RESUME_CONDITION = {
61
+ TIME: 'time',
62
+ POLL: 'poll',
63
+ MANUAL: 'manual',
64
+ FILE: 'file'
65
+ };
66
+
67
+ // ============================================================================
68
+ // Path Helpers
69
+ // ============================================================================
70
+
71
+ function getSessionPath() {
72
+ const projectRoot = getProjectRoot();
73
+ return path.join(projectRoot, '.workflow', 'state', SESSION_FILE);
74
+ }
75
+
76
+ function getHistoryPath() {
77
+ const projectRoot = getProjectRoot();
78
+ return path.join(projectRoot, '.workflow', 'state', HISTORY_FILE);
79
+ }
80
+
81
+ function getLegacyHybridPath() {
82
+ const projectRoot = getProjectRoot();
83
+ return path.join(projectRoot, '.workflow', 'state', LEGACY_HYBRID_FILE);
84
+ }
85
+
86
+ /**
87
+ * Clean up legacy hybrid-session.json if it exists
88
+ * Called when creating a new durable session to prevent orphaned state
89
+ */
90
+ function cleanupLegacyHybridSession() {
91
+ const legacyPath = getLegacyHybridPath();
92
+ if (fs.existsSync(legacyPath)) {
93
+ try {
94
+ fs.unlinkSync(legacyPath);
95
+ console.log('[Migration] Removed legacy hybrid-session.json - now using durable-session.json');
96
+ } catch (err) {
97
+ // Non-fatal - just log and continue
98
+ console.warn(`[Warning] Could not remove legacy hybrid-session.json: ${err.message}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ // ============================================================================
104
+ // Core Session Management
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Create a new durable session
109
+ * @param {string} taskId - Task identifier (e.g., "TASK-042")
110
+ * @param {string} taskType - Type: "task", "loop", "bulk"
111
+ * @param {Array} steps - Array of step definitions
112
+ * @returns {Object} Created session
113
+ */
114
+ function createDurableSession(taskId, taskType, steps = []) {
115
+ const sessionPath = getSessionPath();
116
+
117
+ // Check if session already exists for this task
118
+ const existing = loadDurableSession();
119
+ if (existing && existing.taskId === taskId) {
120
+ // Return existing session for resume
121
+ return existing;
122
+ }
123
+
124
+ // Clean up legacy hybrid-session.json if present (migration to v2.0)
125
+ cleanupLegacyHybridSession();
126
+
127
+ const session = createSessionObject(taskId, taskType, steps);
128
+ saveDurableSession(session);
129
+ return session;
130
+ }
131
+
132
+ /**
133
+ * Create a new durable session with file locking (async version)
134
+ * SECURITY: Prevents race conditions when multiple processes try to create sessions
135
+ *
136
+ * @param {string} taskId - Task identifier
137
+ * @param {string} taskType - Type: "task", "loop", "bulk"
138
+ * @param {Array} steps - Array of step definitions
139
+ * @returns {Promise<Object>} Created or existing session
140
+ */
141
+ async function createDurableSessionAsync(taskId, taskType, steps = []) {
142
+ const sessionPath = getSessionPath();
143
+
144
+ return withLock(sessionPath, () => {
145
+ // Check if session already exists for this task (inside lock)
146
+ const existing = loadDurableSession();
147
+ if (existing && existing.taskId === taskId) {
148
+ // Return existing session for resume
149
+ return existing;
150
+ }
151
+
152
+ // Clean up legacy hybrid-session.json if present (migration to v2.0)
153
+ cleanupLegacyHybridSession();
154
+
155
+ const session = createSessionObject(taskId, taskType, steps);
156
+ saveDurableSession(session);
157
+ return session;
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Create a session object (internal helper)
163
+ */
164
+ function createSessionObject(taskId, taskType, steps = []) {
165
+ return {
166
+ version: SESSION_VERSION,
167
+ sessionId: `sess-${Date.now()}`,
168
+ taskId,
169
+ taskType,
170
+ startedAt: new Date().toISOString(),
171
+ updatedAt: new Date().toISOString(),
172
+
173
+ // Cache config once to avoid repeated access in loop
174
+ steps: (() => {
175
+ const config = getConfig();
176
+ const defaultMaxAttempts = config.durableSteps?.defaultMaxAttempts || 5;
177
+ return steps.map((step, index) => normalizeStep(step, index, defaultMaxAttempts));
178
+ })(),
179
+
180
+ execution: {
181
+ currentStepIndex: 0,
182
+ iteration: 0,
183
+ totalRetries: 0,
184
+ checkpointsCreated: 0
185
+ },
186
+
187
+ suspension: null,
188
+
189
+ metrics: {
190
+ stepsCompleted: 0,
191
+ stepsFailed: 0,
192
+ stepsSkipped: 0,
193
+ tokensSaved: 0
194
+ },
195
+
196
+ // Task queue for multi-task execution (v2.1)
197
+ taskQueue: {
198
+ enabled: false,
199
+ tasks: [], // Array of task IDs to process
200
+ currentIndex: 0, // Current position in queue
201
+ source: null, // How queue was created: "bulk", "natural", "manual"
202
+ queuedAt: null,
203
+ completedTasks: [] // Track completed task IDs
204
+ }
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Normalize a step to the standard schema
210
+ * @param {string|Object} step - Step definition
211
+ * @param {number} index - Step index
212
+ * @param {number} defaultMaxAttempts - Default max attempts from config (passed to avoid repeated getConfig calls)
213
+ */
214
+ function normalizeStep(step, index, defaultMaxAttempts = 5) {
215
+ // Handle string input (backward compat with acceptance criteria)
216
+ if (typeof step === 'string') {
217
+ return {
218
+ id: `step-${String(index + 1).padStart(3, '0')}`,
219
+ type: STEP_TYPE.ACCEPTANCE_CRITERIA,
220
+ description: step,
221
+ status: STEP_STATUS.PENDING,
222
+ priority: index + 1,
223
+ startedAt: null,
224
+ completedAt: null,
225
+ attempts: 0,
226
+ maxAttempts: defaultMaxAttempts,
227
+ lastAttemptAt: null,
228
+ verificationProof: null,
229
+ error: null,
230
+ metadata: {}
231
+ };
232
+ }
233
+
234
+ // Handle object input
235
+ return {
236
+ id: step.id || `step-${String(index + 1).padStart(3, '0')}`,
237
+ type: step.type || STEP_TYPE.CUSTOM,
238
+ description: step.description || step.action || '',
239
+ status: step.status || STEP_STATUS.PENDING,
240
+ priority: step.priority || index + 1,
241
+ startedAt: step.startedAt || null,
242
+ completedAt: step.completedAt || null,
243
+ attempts: step.attempts || 0,
244
+ maxAttempts: step.maxAttempts || defaultMaxAttempts,
245
+ lastAttemptAt: step.lastAttemptAt || null,
246
+ verificationProof: step.verificationProof || null,
247
+ error: step.error || null,
248
+ metadata: step.metadata || {}
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Load the current durable session
254
+ * @returns {Object|null} Session or null if none exists
255
+ */
256
+ function loadDurableSession() {
257
+ const sessionPath = getSessionPath();
258
+
259
+ if (!fs.existsSync(sessionPath)) {
260
+ return null;
261
+ }
262
+
263
+ try {
264
+ const content = fs.readFileSync(sessionPath, 'utf-8');
265
+ const session = JSON.parse(content);
266
+
267
+ // Validate session structure
268
+ if (!session || typeof session !== 'object') {
269
+ if (process.env.DEBUG) {
270
+ console.warn('[DEBUG] Invalid session: not an object');
271
+ }
272
+ return null;
273
+ }
274
+
275
+ // Ensure steps array exists
276
+ if (!Array.isArray(session.steps)) {
277
+ session.steps = [];
278
+ }
279
+
280
+ // Ensure execution object exists
281
+ if (!session.execution || typeof session.execution !== 'object') {
282
+ session.execution = {
283
+ currentStepIndex: 0,
284
+ iteration: 0,
285
+ totalRetries: 0,
286
+ checkpointsCreated: 0
287
+ };
288
+ }
289
+
290
+ // Ensure metrics object exists
291
+ if (!session.metrics || typeof session.metrics !== 'object') {
292
+ session.metrics = {
293
+ stepsCompleted: 0,
294
+ stepsFailed: 0,
295
+ stepsSkipped: 0,
296
+ tokensSaved: 0
297
+ };
298
+ }
299
+
300
+ return session;
301
+ } catch (error) {
302
+ if (process.env.DEBUG) {
303
+ console.warn(`[DEBUG] Could not parse durable session: ${error.message}`);
304
+ }
305
+ return null;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Save the durable session
311
+ * @param {Object} session - Session to save
312
+ */
313
+ function saveDurableSession(session) {
314
+ const sessionPath = getSessionPath();
315
+
316
+ // Ensure directory exists
317
+ ensureDir(path.dirname(sessionPath));
318
+
319
+ session.updatedAt = new Date().toISOString();
320
+ // Use atomic writeJson to prevent data corruption on concurrent access
321
+ writeJson(sessionPath, session);
322
+ }
323
+
324
+ /**
325
+ * Archive the session to history and remove active session
326
+ * @param {string} status - Final status: "completed", "failed", "cancelled"
327
+ * @returns {Object|null} Archived session
328
+ */
329
+ function archiveDurableSession(status = 'completed') {
330
+ const session = loadDurableSession();
331
+ if (!session) return null;
332
+
333
+ // Finalize session
334
+ session.status = status;
335
+ session.endedAt = new Date().toISOString();
336
+
337
+ // Calculate final metrics
338
+ session.metrics.stepsCompleted = session.steps.filter(s => s.status === STEP_STATUS.COMPLETED).length;
339
+ session.metrics.stepsFailed = session.steps.filter(s => s.status === STEP_STATUS.FAILED).length;
340
+ session.metrics.stepsSkipped = session.steps.filter(s => s.status === STEP_STATUS.SKIPPED).length;
341
+
342
+ // Load and update history
343
+ const historyPath = getHistoryPath();
344
+ let history = [];
345
+
346
+ if (fs.existsSync(historyPath)) {
347
+ try {
348
+ history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
349
+ } catch {
350
+ history = [];
351
+ }
352
+ }
353
+
354
+ history.push(session);
355
+
356
+ // Keep only last N sessions
357
+ if (history.length > MAX_SESSION_HISTORY) {
358
+ history = history.slice(-MAX_SESSION_HISTORY);
359
+ }
360
+
361
+ // Use atomic writeJson for history file
362
+ writeJson(historyPath, history);
363
+
364
+ // Remove active session
365
+ const sessionPath = getSessionPath();
366
+ if (fs.existsSync(sessionPath)) {
367
+ fs.unlinkSync(sessionPath);
368
+ }
369
+
370
+ // Trigger loop retry learning analysis for completed sessions
371
+ const config = getConfig();
372
+ if (config.skillLearning?.learnFromLoopRetries !== false && status === 'completed') {
373
+ try {
374
+ const { analyzeCompletedSession } = require('./flow-loop-retry-learning');
375
+ analyzeCompletedSession(session);
376
+ } catch (err) {
377
+ // Silent fail - learning is non-critical
378
+ if (process.env.DEBUG) {
379
+ console.warn('[DEBUG] Loop retry learning failed:', err.message);
380
+ }
381
+ }
382
+ }
383
+
384
+ return session;
385
+ }
386
+
387
+ // ============================================================================
388
+ // Step Management
389
+ // ============================================================================
390
+
391
+ /**
392
+ * Get the next pending step
393
+ * @param {Object} session - Current session
394
+ * @returns {Object|null} Next pending step or null
395
+ */
396
+ function getNextPendingStep(session) {
397
+ if (!session) session = loadDurableSession();
398
+ if (!session) return null;
399
+
400
+ return session.steps.find(s =>
401
+ s.status === STEP_STATUS.PENDING ||
402
+ s.status === STEP_STATUS.FAILED
403
+ );
404
+ }
405
+
406
+ /**
407
+ * Get step by ID
408
+ * @param {string} stepId - Step ID
409
+ * @returns {Object|null} Step or null
410
+ */
411
+ function getStep(stepId) {
412
+ const session = loadDurableSession();
413
+ if (!session) return null;
414
+
415
+ return session.steps.find(s => s.id === stepId);
416
+ }
417
+
418
+ /**
419
+ * Mark a step as started (in_progress)
420
+ * @param {string} stepId - Step ID
421
+ * @returns {Object|null} Updated session
422
+ */
423
+ function markStepStarted(stepId) {
424
+ const session = loadDurableSession();
425
+ if (!session) return null;
426
+
427
+ const step = session.steps.find(s => s.id === stepId);
428
+ if (!step) return null;
429
+
430
+ step.status = STEP_STATUS.IN_PROGRESS;
431
+ step.startedAt = new Date().toISOString();
432
+ step.attempts++;
433
+ step.lastAttemptAt = new Date().toISOString();
434
+
435
+ // Update current step index
436
+ const stepIndex = session.steps.findIndex(s => s.id === stepId);
437
+ session.execution.currentStepIndex = stepIndex;
438
+
439
+ saveDurableSession(session);
440
+ return session;
441
+ }
442
+
443
+ /**
444
+ * Mark a step as completed
445
+ * @param {string} stepId - Step ID
446
+ * @param {string|Object} verificationProof - Proof of completion
447
+ * @returns {Object|null} Updated session
448
+ */
449
+ function markStepCompleted(stepId, verificationProof = null) {
450
+ const session = loadDurableSession();
451
+ if (!session) return null;
452
+
453
+ const step = session.steps.find(s => s.id === stepId);
454
+ if (!step) return null;
455
+
456
+ step.status = STEP_STATUS.COMPLETED;
457
+ step.completedAt = new Date().toISOString();
458
+ step.verificationProof = verificationProof;
459
+ step.error = null;
460
+
461
+ session.metrics.stepsCompleted++;
462
+
463
+ saveDurableSession(session);
464
+ return session;
465
+ }
466
+
467
+ /**
468
+ * Mark a step as failed
469
+ * @param {string} stepId - Step ID
470
+ * @param {string|Object} error - Error details
471
+ * @returns {Object|null} Updated session
472
+ */
473
+ function markStepFailed(stepId, error = null) {
474
+ const session = loadDurableSession();
475
+ if (!session) return null;
476
+
477
+ const step = session.steps.find(s => s.id === stepId);
478
+ if (!step) return null;
479
+
480
+ step.status = STEP_STATUS.FAILED;
481
+ step.error = error;
482
+
483
+ session.metrics.stepsFailed++;
484
+ session.execution.totalRetries++;
485
+
486
+ saveDurableSession(session);
487
+ return session;
488
+ }
489
+
490
+ /**
491
+ * Mark a step as skipped
492
+ * @param {string} stepId - Step ID
493
+ * @param {string} reason - Reason for skipping
494
+ * @returns {Object|null} Updated session
495
+ */
496
+ function markStepSkipped(stepId, reason = null) {
497
+ const session = loadDurableSession();
498
+ if (!session) return null;
499
+
500
+ const step = session.steps.find(s => s.id === stepId);
501
+ if (!step) return null;
502
+
503
+ step.status = STEP_STATUS.SKIPPED;
504
+ step.completedAt = new Date().toISOString();
505
+ step.verificationProof = reason ? `Skipped: ${reason}` : 'Skipped by user';
506
+
507
+ session.metrics.stepsSkipped++;
508
+
509
+ saveDurableSession(session);
510
+ return session;
511
+ }
512
+
513
+ /**
514
+ * Add new steps to an existing session
515
+ * @param {Array} newSteps - Steps to add
516
+ * @returns {Object|null} Updated session
517
+ */
518
+ function addSteps(newSteps) {
519
+ const session = loadDurableSession();
520
+ if (!session) return null;
521
+
522
+ const startIndex = session.steps.length;
523
+ // Cache config once to avoid repeated access in loop
524
+ const config = getConfig();
525
+ const defaultMaxAttempts = config.durableSteps?.defaultMaxAttempts || 5;
526
+ const normalizedSteps = newSteps.map((s, i) => normalizeStep(s, startIndex + i, defaultMaxAttempts));
527
+
528
+ session.steps.push(...normalizedSteps);
529
+
530
+ saveDurableSession(session);
531
+ return session;
532
+ }
533
+
534
+ // ============================================================================
535
+ // Resume Support
536
+ // ============================================================================
537
+
538
+ /**
539
+ * Check if session can be resumed from a specific step
540
+ * @param {Object} session - Session to check
541
+ * @returns {Object} Resume info: { canResume, fromStep, completedCount }
542
+ */
543
+ function canResumeFromStep(session) {
544
+ if (!session) session = loadDurableSession();
545
+ if (!session) {
546
+ return { canResume: false, reason: 'no-session' };
547
+ }
548
+
549
+ // Check if suspended
550
+ if (session.suspension) {
551
+ const resumeCheck = checkResumeCondition(session.suspension);
552
+ if (!resumeCheck.canResume) {
553
+ return {
554
+ canResume: false,
555
+ reason: 'suspended',
556
+ suspension: session.suspension,
557
+ conditionStatus: resumeCheck
558
+ };
559
+ }
560
+ }
561
+
562
+ const completed = session.steps.filter(s => s.status === STEP_STATUS.COMPLETED);
563
+ const pending = session.steps.filter(s =>
564
+ s.status === STEP_STATUS.PENDING ||
565
+ s.status === STEP_STATUS.FAILED ||
566
+ s.status === STEP_STATUS.IN_PROGRESS
567
+ );
568
+
569
+ if (pending.length === 0) {
570
+ return { canResume: false, reason: 'all-complete', completedCount: completed.length };
571
+ }
572
+
573
+ const nextStep = pending[0];
574
+
575
+ return {
576
+ canResume: true,
577
+ fromStep: nextStep,
578
+ completedCount: completed.length,
579
+ totalSteps: session.steps.length,
580
+ pendingCount: pending.length
581
+ };
582
+ }
583
+
584
+ /**
585
+ * Get context for resuming a session
586
+ * @param {Object} session - Session to get context for
587
+ * @returns {Object} Resume context
588
+ */
589
+ function getResumeContext(session) {
590
+ if (!session) session = loadDurableSession();
591
+ if (!session) return null;
592
+
593
+ const resumeInfo = canResumeFromStep(session);
594
+
595
+ return {
596
+ taskId: session.taskId,
597
+ taskType: session.taskType,
598
+ sessionId: session.sessionId,
599
+ startedAt: session.startedAt,
600
+ ...resumeInfo,
601
+ iteration: session.execution.iteration,
602
+ retries: session.execution.totalRetries,
603
+ metrics: session.metrics,
604
+ suspension: session.suspension
605
+ };
606
+ }
607
+
608
+ /**
609
+ * Skip all completed steps and return remaining work
610
+ * @param {Object} session - Session to process
611
+ * @returns {Array} Remaining steps to execute
612
+ */
613
+ function getRemainingSteps(session) {
614
+ if (!session) session = loadDurableSession();
615
+ if (!session) return [];
616
+
617
+ return session.steps.filter(s =>
618
+ s.status === STEP_STATUS.PENDING ||
619
+ s.status === STEP_STATUS.FAILED ||
620
+ s.status === STEP_STATUS.IN_PROGRESS
621
+ );
622
+ }
623
+
624
+ // ============================================================================
625
+ // Execution Tracking
626
+ // ============================================================================
627
+
628
+ /**
629
+ * Increment iteration counter
630
+ * @returns {Object|null} Updated session
631
+ */
632
+ function incrementIteration() {
633
+ const session = loadDurableSession();
634
+ if (!session) return null;
635
+
636
+ session.execution.iteration++;
637
+
638
+ saveDurableSession(session);
639
+ return session;
640
+ }
641
+
642
+ /**
643
+ * Update tokens saved (for hybrid mode tracking)
644
+ * @param {number} tokens - Tokens saved
645
+ * @returns {Object|null} Updated session
646
+ */
647
+ function addTokensSaved(tokens) {
648
+ const session = loadDurableSession();
649
+ if (!session) return null;
650
+
651
+ session.metrics.tokensSaved += tokens;
652
+
653
+ saveDurableSession(session);
654
+ return session;
655
+ }
656
+
657
+ /**
658
+ * Check if all steps are complete
659
+ * @returns {Object} Completion status
660
+ */
661
+ function checkCompletion() {
662
+ const session = loadDurableSession();
663
+ if (!session) {
664
+ return { complete: true, reason: 'no-session' };
665
+ }
666
+
667
+ const config = getConfig();
668
+
669
+ const pending = session.steps.filter(s => s.status === STEP_STATUS.PENDING);
670
+ const failed = session.steps.filter(s => s.status === STEP_STATUS.FAILED);
671
+ const completed = session.steps.filter(s => s.status === STEP_STATUS.COMPLETED);
672
+ const skipped = session.steps.filter(s => s.status === STEP_STATUS.SKIPPED);
673
+ const inProgress = session.steps.filter(s => s.status === STEP_STATUS.IN_PROGRESS);
674
+ const suspended = session.steps.filter(s => s.status === STEP_STATUS.SUSPENDED);
675
+
676
+ // Session is suspended - not complete, waiting for resume
677
+ if (suspended.length > 0) {
678
+ return {
679
+ complete: false,
680
+ suspended: true,
681
+ suspendedSteps: suspended.length,
682
+ reason: 'session-suspended',
683
+ summary: `Session suspended with ${suspended.length} step(s) waiting to resume`
684
+ };
685
+ }
686
+
687
+ // All done?
688
+ if (pending.length === 0 && failed.length === 0 && inProgress.length === 0) {
689
+ return {
690
+ complete: true,
691
+ reason: 'all-complete',
692
+ summary: `All ${completed.length} steps completed${skipped.length > 0 ? ` (${skipped.length} skipped)` : ''}`
693
+ };
694
+ }
695
+
696
+ // Max retries?
697
+ const maxRetries = config.durableSteps?.maxRetries || config.loops?.maxRetries || 5;
698
+ if (session.execution.totalRetries >= maxRetries) {
699
+ return {
700
+ complete: true,
701
+ reason: 'max-retries',
702
+ forced: true,
703
+ summary: `Max retries (${maxRetries}) reached. ${failed.length} steps still failing.`
704
+ };
705
+ }
706
+
707
+ // Max iterations?
708
+ const maxIterations = config.durableSteps?.maxIterations || config.loops?.maxIterations || 20;
709
+ if (session.execution.iteration >= maxIterations) {
710
+ return {
711
+ complete: true,
712
+ reason: 'max-iterations',
713
+ forced: true,
714
+ summary: `Max iterations (${maxIterations}) reached.`
715
+ };
716
+ }
717
+
718
+ // SECURITY: Max duration check to prevent indefinitely running sessions
719
+ const maxDurationMs = (config.durableSteps?.maxDurationMinutes || 120) * 60 * 1000; // Default 2 hours
720
+ const sessionDuration = Date.now() - new Date(session.startedAt).getTime();
721
+ if (sessionDuration >= maxDurationMs) {
722
+ return {
723
+ complete: true,
724
+ reason: 'max-duration',
725
+ forced: true,
726
+ summary: `Max session duration (${config.durableSteps?.maxDurationMinutes || 120} minutes) reached.`
727
+ };
728
+ }
729
+
730
+ return {
731
+ complete: false,
732
+ pending: pending.length,
733
+ failed: failed.length,
734
+ inProgress: inProgress.length,
735
+ completed: completed.length,
736
+ skipped: skipped.length,
737
+ suspended: suspended.length
738
+ };
739
+ }
740
+
741
+ // ============================================================================
742
+ // Suspension Support
743
+ // ============================================================================
744
+
745
+ /**
746
+ * Suspend the current session
747
+ * @param {Object} suspensionConfig - Suspension configuration
748
+ * @returns {Object|null} Updated session
749
+ */
750
+ function suspendSession(suspensionConfig) {
751
+ const session = loadDurableSession();
752
+ if (!session) return null;
753
+
754
+ // Find current step
755
+ const currentStep = session.steps.find(s => s.status === STEP_STATUS.IN_PROGRESS);
756
+
757
+ session.suspension = {
758
+ type: suspensionConfig.type,
759
+ reason: suspensionConfig.reason || `Suspended: ${suspensionConfig.type}`,
760
+ suspendedAt: new Date().toISOString(),
761
+ suspendedAtStep: currentStep?.id || null,
762
+ resumeCondition: suspensionConfig.resumeCondition,
763
+ notifications: suspensionConfig.notifications || {
764
+ onSuspend: true,
765
+ onResume: true,
766
+ reminderAfterHours: 24
767
+ }
768
+ };
769
+
770
+ // Mark current step as suspended
771
+ if (currentStep) {
772
+ currentStep.status = STEP_STATUS.SUSPENDED;
773
+ }
774
+
775
+ saveDurableSession(session);
776
+ return session;
777
+ }
778
+
779
+ /**
780
+ * Check if session is suspended
781
+ * @returns {boolean}
782
+ */
783
+ function isSuspended() {
784
+ const session = loadDurableSession();
785
+ return session?.suspension !== null;
786
+ }
787
+
788
+ /**
789
+ * Get suspension status
790
+ * @returns {Object|null} Suspension details or null
791
+ */
792
+ function getSuspensionStatus() {
793
+ const session = loadDurableSession();
794
+ if (!session || !session.suspension) return null;
795
+
796
+ const resumeCheck = checkResumeCondition(session.suspension);
797
+
798
+ return {
799
+ ...session.suspension,
800
+ canResume: resumeCheck.canResume,
801
+ resumeReason: resumeCheck.reason,
802
+ taskId: session.taskId
803
+ };
804
+ }
805
+
806
+ /**
807
+ * Check if resume condition is met
808
+ * @param {Object} suspension - Suspension object
809
+ * @returns {Object} { canResume: boolean, reason: string }
810
+ */
811
+ function checkResumeCondition(suspension) {
812
+ if (!suspension || !suspension.resumeCondition) {
813
+ return { canResume: true, reason: 'no-condition' };
814
+ }
815
+
816
+ const condition = suspension.resumeCondition;
817
+
818
+ switch (condition.type) {
819
+ case RESUME_CONDITION.TIME:
820
+ return checkTimeCondition(condition.time);
821
+
822
+ case RESUME_CONDITION.POLL:
823
+ return checkPollCondition(condition.poll);
824
+
825
+ case RESUME_CONDITION.MANUAL:
826
+ return checkManualCondition(condition.manual);
827
+
828
+ case RESUME_CONDITION.FILE:
829
+ return checkFileCondition(condition.file);
830
+
831
+ default:
832
+ return { canResume: false, reason: `Unknown condition type: ${condition.type}` };
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Check time-based resume condition
838
+ */
839
+ function checkTimeCondition(config) {
840
+ if (!config || !config.resumeAfter) {
841
+ return { canResume: true, reason: 'no-time-set' };
842
+ }
843
+
844
+ const resumeTime = new Date(config.resumeAfter);
845
+ const now = new Date();
846
+
847
+ if (now >= resumeTime) {
848
+ return { canResume: true, reason: 'time-elapsed' };
849
+ }
850
+
851
+ const remaining = Math.ceil((resumeTime - now) / 1000);
852
+ return {
853
+ canResume: false,
854
+ reason: 'waiting-for-time',
855
+ remainingSeconds: remaining,
856
+ resumeAt: config.resumeAfter
857
+ };
858
+ }
859
+
860
+ /**
861
+ * Check poll-based resume condition (e.g., CI/CD)
862
+ *
863
+ * SECURITY: Commands are validated before execution to prevent injection.
864
+ * Only safe commands (no dangerous patterns) are allowed.
865
+ */
866
+ function checkPollCondition(config) {
867
+ if (!config || !config.command) {
868
+ return { canResume: false, reason: 'no-poll-command' };
869
+ }
870
+
871
+ // SECURITY: Validate command before execution
872
+ const validation = validateCommand(config.command);
873
+ if (validation.blocked) {
874
+ return {
875
+ canResume: false,
876
+ reason: 'poll-command-blocked',
877
+ error: `SECURITY: ${validation.reason}`
878
+ };
879
+ }
880
+
881
+ if (!validation.safe) {
882
+ return {
883
+ canResume: false,
884
+ reason: 'poll-command-unsafe',
885
+ error: `SECURITY: Command validation failed - ${validation.reason}`
886
+ };
887
+ }
888
+
889
+ try {
890
+ const result = execSync(config.command, {
891
+ encoding: 'utf-8',
892
+ timeout: 30000,
893
+ stdio: ['pipe', 'pipe', 'pipe']
894
+ }).trim();
895
+
896
+ if (result === config.expectedValue) {
897
+ return { canResume: true, reason: 'poll-condition-met', value: result };
898
+ }
899
+
900
+ return {
901
+ canResume: false,
902
+ reason: 'poll-condition-not-met',
903
+ currentValue: result,
904
+ expectedValue: config.expectedValue
905
+ };
906
+ } catch (error) {
907
+ return {
908
+ canResume: false,
909
+ reason: 'poll-command-failed',
910
+ error: error.message
911
+ };
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Check manual approval condition
917
+ */
918
+ function checkManualCondition(config) {
919
+ if (!config) {
920
+ return { canResume: false, reason: 'no-manual-config' };
921
+ }
922
+
923
+ if (config.approvedAt && config.approvedBy) {
924
+ return { canResume: true, reason: 'manually-approved' };
925
+ }
926
+
927
+ return {
928
+ canResume: false,
929
+ reason: 'awaiting-approval',
930
+ prompt: config.prompt
931
+ };
932
+ }
933
+
934
+ /**
935
+ * Check file-based resume condition
936
+ */
937
+ function checkFileCondition(config) {
938
+ if (!config || !config.watchPath) {
939
+ return { canResume: false, reason: 'no-file-path' };
940
+ }
941
+
942
+ const projectRoot = getProjectRoot();
943
+ const filePath = path.isAbsolute(config.watchPath)
944
+ ? config.watchPath
945
+ : path.join(projectRoot, config.watchPath);
946
+
947
+ // Security: Validate path is within project root to prevent path traversal
948
+ if (!validatePathWithinProject(filePath, projectRoot)) {
949
+ return {
950
+ canResume: false,
951
+ reason: 'path-traversal-blocked',
952
+ error: 'Watch path must be within project directory'
953
+ };
954
+ }
955
+
956
+ if (!fs.existsSync(filePath)) {
957
+ return {
958
+ canResume: false,
959
+ reason: 'file-not-found',
960
+ watchPath: config.watchPath
961
+ };
962
+ }
963
+
964
+ // If expected content specified, check it
965
+ if (config.expectedContent) {
966
+ try {
967
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
968
+ const matches = deepEqual(content, config.expectedContent);
969
+
970
+ if (matches) {
971
+ return { canResume: true, reason: 'file-content-matches' };
972
+ }
973
+
974
+ return {
975
+ canResume: false,
976
+ reason: 'file-content-mismatch',
977
+ expected: config.expectedContent,
978
+ actual: content
979
+ };
980
+ } catch (error) {
981
+ return {
982
+ canResume: false,
983
+ reason: 'file-parse-error',
984
+ error: error.message
985
+ };
986
+ }
987
+ }
988
+
989
+ // Just check existence
990
+ return { canResume: true, reason: 'file-exists' };
991
+ }
992
+
993
+ /**
994
+ * Resume a suspended session
995
+ * @param {Object} options - Resume options
996
+ * @returns {Object|null} Updated session
997
+ */
998
+ function resumeSession(options = {}) {
999
+ const session = loadDurableSession();
1000
+ if (!session || !session.suspension) return null;
1001
+
1002
+ // Update manual approval if provided
1003
+ if (options.approve && session.suspension.resumeCondition?.type === RESUME_CONDITION.MANUAL) {
1004
+ session.suspension.resumeCondition.manual.approvedAt = new Date().toISOString();
1005
+ session.suspension.resumeCondition.manual.approvedBy = options.approvedBy || 'user';
1006
+ }
1007
+
1008
+ // Check if can resume
1009
+ const resumeCheck = checkResumeCondition(session.suspension);
1010
+ if (!resumeCheck.canResume && !options.force) {
1011
+ return {
1012
+ error: 'Cannot resume yet',
1013
+ ...resumeCheck
1014
+ };
1015
+ }
1016
+
1017
+ // Clear suspension
1018
+ const suspendedStepId = session.suspension.suspendedAtStep;
1019
+ session.suspension = null;
1020
+
1021
+ // Resume suspended step
1022
+ if (suspendedStepId) {
1023
+ const step = session.steps.find(s => s.id === suspendedStepId);
1024
+ if (step && step.status === STEP_STATUS.SUSPENDED) {
1025
+ step.status = STEP_STATUS.PENDING;
1026
+ }
1027
+ }
1028
+
1029
+ saveDurableSession(session);
1030
+ return session;
1031
+ }
1032
+
1033
+ // ============================================================================
1034
+ // Backward Compatibility - Loop Enforcer API
1035
+ // ============================================================================
1036
+
1037
+ /**
1038
+ * Start a loop (backward compat with flow-loop-enforcer)
1039
+ * @deprecated Use createDurableSession instead
1040
+ */
1041
+ function startLoop(taskId, acceptanceCriteria) {
1042
+ return createDurableSession(taskId, 'task', acceptanceCriteria);
1043
+ }
1044
+
1045
+ /**
1046
+ * Get active loop (backward compat)
1047
+ * @deprecated Use loadDurableSession instead
1048
+ */
1049
+ function getActiveLoop() {
1050
+ const session = loadDurableSession();
1051
+ if (!session) return null;
1052
+
1053
+ // Convert to old format for compatibility
1054
+ // Map step-NNN back to AC-N format for backward compatibility
1055
+ return {
1056
+ taskId: session.taskId,
1057
+ startedAt: session.startedAt,
1058
+ acceptanceCriteria: session.steps.map((s, index) => ({
1059
+ // Convert step-NNN to AC-N format for backward compat
1060
+ id: s.id.startsWith('step-') ? `AC-${index + 1}` : s.id,
1061
+ description: s.description,
1062
+ status: s.status === STEP_STATUS.COMPLETED ? 'completed' :
1063
+ s.status === STEP_STATUS.FAILED ? 'failed' :
1064
+ s.status === STEP_STATUS.SKIPPED ? 'skipped' : 'pending',
1065
+ attempts: s.attempts,
1066
+ lastAttempt: s.lastAttemptAt,
1067
+ verificationResult: s.verificationProof
1068
+ })),
1069
+ iteration: session.execution.iteration,
1070
+ retries: session.execution.totalRetries,
1071
+ status: session.suspension ? 'suspended' : 'in_progress'
1072
+ };
1073
+ }
1074
+
1075
+ /**
1076
+ * Update criterion (backward compat)
1077
+ * @deprecated Use markStepCompleted/markStepFailed instead
1078
+ */
1079
+ function updateCriterion(criterionId, status, verificationResult = null) {
1080
+ // Convert AC-N format to step-NNN format for backward compatibility
1081
+ const stepId = criterionId.startsWith('AC-')
1082
+ ? `step-${criterionId.replace('AC-', '').padStart(3, '0')}`
1083
+ : criterionId;
1084
+
1085
+ if (status === 'completed') {
1086
+ return markStepCompleted(stepId, verificationResult);
1087
+ } else if (status === 'failed') {
1088
+ return markStepFailed(stepId, verificationResult);
1089
+ } else if (status === 'skipped') {
1090
+ return markStepSkipped(stepId, verificationResult);
1091
+ }
1092
+ return null;
1093
+ }
1094
+
1095
+ /**
1096
+ * Can exit loop (backward compat)
1097
+ * @deprecated Use checkCompletion instead
1098
+ */
1099
+ function canExitLoop() {
1100
+ const completion = checkCompletion();
1101
+
1102
+ return {
1103
+ canExit: completion.complete,
1104
+ reason: completion.reason,
1105
+ summary: completion.summary,
1106
+ pending: completion.pending,
1107
+ failed: completion.failed,
1108
+ completed: completion.completed,
1109
+ skipped: completion.skipped
1110
+ };
1111
+ }
1112
+
1113
+ /**
1114
+ * End loop (backward compat)
1115
+ * @deprecated Use archiveDurableSession instead
1116
+ */
1117
+ function endLoop(status = 'completed') {
1118
+ return archiveDurableSession(status);
1119
+ }
1120
+
1121
+ // ============================================================================
1122
+ // Backward Compatibility - Hybrid Session API
1123
+ // ============================================================================
1124
+
1125
+ /**
1126
+ * Get hybrid session format (backward compat)
1127
+ * @deprecated Use loadDurableSession instead
1128
+ */
1129
+ function getHybridSession() {
1130
+ const session = loadDurableSession();
1131
+ if (!session) return null;
1132
+
1133
+ return {
1134
+ sessionId: session.sessionId,
1135
+ startedAt: session.startedAt,
1136
+ autoExecute: false,
1137
+ currentPlan: null,
1138
+ executedSteps: session.steps
1139
+ .filter(s => s.status === STEP_STATUS.COMPLETED)
1140
+ .map(s => s.id),
1141
+ failedSteps: session.steps
1142
+ .filter(s => s.status === STEP_STATUS.FAILED)
1143
+ .map(s => s.id),
1144
+ pendingSteps: session.steps
1145
+ .filter(s => s.status === STEP_STATUS.PENDING || s.status === STEP_STATUS.IN_PROGRESS)
1146
+ .map(s => s.id),
1147
+ totalTokensSaved: session.metrics.tokensSaved
1148
+ };
1149
+ }
1150
+
1151
+ // ============================================================================
1152
+ // Utilities
1153
+ // ============================================================================
1154
+
1155
+ /**
1156
+ * Deep equality check for objects
1157
+ */
1158
+ function deepEqual(obj1, obj2) {
1159
+ if (obj1 === obj2) return true;
1160
+ if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return false;
1161
+ if (obj1 === null || obj2 === null) return false;
1162
+
1163
+ const keys1 = Object.keys(obj1);
1164
+ const keys2 = Object.keys(obj2);
1165
+
1166
+ if (keys1.length !== keys2.length) return false;
1167
+
1168
+ for (const key of keys1) {
1169
+ if (!keys2.includes(key)) return false;
1170
+ if (!deepEqual(obj1[key], obj2[key])) return false;
1171
+ }
1172
+
1173
+ return true;
1174
+ }
1175
+
1176
+ /**
1177
+ * Get session statistics from history
1178
+ */
1179
+ function getSessionStats() {
1180
+ const historyPath = getHistoryPath();
1181
+
1182
+ if (!fs.existsSync(historyPath)) {
1183
+ return { totalSessions: 0, completed: 0, failed: 0, cancelled: 0, avgSteps: 0, avgTokensSaved: 0 };
1184
+ }
1185
+
1186
+ try {
1187
+ const history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
1188
+ const completed = history.filter(h => h.status === 'completed').length;
1189
+ const failed = history.filter(h => h.status === 'failed').length;
1190
+ const avgSteps = history.length > 0
1191
+ ? history.reduce((sum, h) => sum + (h.steps?.length || 0), 0) / history.length
1192
+ : 0;
1193
+
1194
+ return {
1195
+ totalSessions: history.length,
1196
+ completed,
1197
+ failed,
1198
+ cancelled: history.length - completed - failed,
1199
+ avgSteps: Math.round(avgSteps * 10) / 10,
1200
+ avgTokensSaved: history.length > 0
1201
+ ? Math.round(history.reduce((sum, h) => sum + (h.metrics?.tokensSaved || 0), 0) / history.length)
1202
+ : 0
1203
+ };
1204
+ } catch {
1205
+ return { totalSessions: 0, completed: 0, failed: 0, cancelled: 0, avgSteps: 0, avgTokensSaved: 0 };
1206
+ }
1207
+ }
1208
+
1209
+ // ============================================================================
1210
+ // Task Queue Management (v2.1)
1211
+ // ============================================================================
1212
+
1213
+ /**
1214
+ * Initialize a task queue for multi-task execution
1215
+ * @param {string[]} taskIds - Array of task IDs to queue
1216
+ * @param {string} source - Source of queue: "bulk", "natural", "manual"
1217
+ * @returns {Object} Updated session
1218
+ */
1219
+ function initTaskQueue(taskIds, source = 'manual') {
1220
+ const session = loadDurableSession();
1221
+ if (!session) {
1222
+ throw new Error('No active session to initialize queue');
1223
+ }
1224
+
1225
+ session.taskQueue = {
1226
+ enabled: true,
1227
+ tasks: taskIds,
1228
+ currentIndex: 0,
1229
+ source,
1230
+ queuedAt: new Date().toISOString(),
1231
+ completedTasks: []
1232
+ };
1233
+
1234
+ // Ensure first task matches current session
1235
+ if (taskIds[0] && session.taskId !== taskIds[0]) {
1236
+ console.warn(`[Queue] First task ${taskIds[0]} doesn't match current session ${session.taskId}`);
1237
+ }
1238
+
1239
+ session.updatedAt = new Date().toISOString();
1240
+ saveDurableSession(session);
1241
+ return session;
1242
+ }
1243
+
1244
+ /**
1245
+ * Get current queue status
1246
+ * @returns {Object} Queue status: { hasQueue, hasMoreTasks, currentTask, nextTask, remaining, completed }
1247
+ */
1248
+ function getQueueStatus() {
1249
+ const session = loadDurableSession();
1250
+ if (!session || !session.taskQueue?.enabled) {
1251
+ return {
1252
+ hasQueue: false,
1253
+ hasMoreTasks: false,
1254
+ currentTask: session?.taskId || null,
1255
+ nextTask: null,
1256
+ remaining: 0,
1257
+ completed: 0,
1258
+ total: 0
1259
+ };
1260
+ }
1261
+
1262
+ const queue = session.taskQueue;
1263
+ const remaining = queue.tasks.length - queue.currentIndex - 1;
1264
+ const nextTask = remaining > 0 ? queue.tasks[queue.currentIndex + 1] : null;
1265
+
1266
+ return {
1267
+ hasQueue: true,
1268
+ hasMoreTasks: remaining > 0,
1269
+ currentTask: queue.tasks[queue.currentIndex],
1270
+ nextTask,
1271
+ remaining,
1272
+ completed: queue.completedTasks.length,
1273
+ total: queue.tasks.length,
1274
+ source: queue.source
1275
+ };
1276
+ }
1277
+
1278
+ /**
1279
+ * Advance to next task in queue (called when current task completes)
1280
+ * @returns {Object} { advanced, nextTaskId, queueComplete }
1281
+ */
1282
+ function advanceTaskQueue() {
1283
+ const session = loadDurableSession();
1284
+ if (!session || !session.taskQueue?.enabled) {
1285
+ return { advanced: false, nextTaskId: null, queueComplete: true };
1286
+ }
1287
+
1288
+ const queue = session.taskQueue;
1289
+
1290
+ // Mark current task as completed
1291
+ const currentTaskId = queue.tasks[queue.currentIndex];
1292
+ if (currentTaskId && !queue.completedTasks.includes(currentTaskId)) {
1293
+ queue.completedTasks.push(currentTaskId);
1294
+ }
1295
+
1296
+ // Check if more tasks
1297
+ if (queue.currentIndex >= queue.tasks.length - 1) {
1298
+ // Queue complete
1299
+ session.updatedAt = new Date().toISOString();
1300
+ saveDurableSession(session);
1301
+ return {
1302
+ advanced: false,
1303
+ nextTaskId: null,
1304
+ queueComplete: true,
1305
+ completedTasks: queue.completedTasks
1306
+ };
1307
+ }
1308
+
1309
+ // Advance to next task
1310
+ queue.currentIndex++;
1311
+ const nextTaskId = queue.tasks[queue.currentIndex];
1312
+
1313
+ session.updatedAt = new Date().toISOString();
1314
+ saveDurableSession(session);
1315
+
1316
+ return {
1317
+ advanced: true,
1318
+ nextTaskId,
1319
+ queueComplete: false,
1320
+ remaining: queue.tasks.length - queue.currentIndex - 1
1321
+ };
1322
+ }
1323
+
1324
+ /**
1325
+ * Clear the task queue
1326
+ * @returns {boolean} Success
1327
+ */
1328
+ function clearTaskQueue() {
1329
+ const session = loadDurableSession();
1330
+ if (!session) return false;
1331
+
1332
+ session.taskQueue = {
1333
+ enabled: false,
1334
+ tasks: [],
1335
+ currentIndex: 0,
1336
+ source: null,
1337
+ queuedAt: null,
1338
+ completedTasks: session.taskQueue?.completedTasks || []
1339
+ };
1340
+
1341
+ session.updatedAt = new Date().toISOString();
1342
+ saveDurableSession(session);
1343
+ return true;
1344
+ }
1345
+
1346
+ /**
1347
+ * Check if should continue to next task (used by stop hook)
1348
+ * @returns {Object} { shouldContinue, nextTaskId, message }
1349
+ */
1350
+ function checkQueueContinuation() {
1351
+ const config = getConfig();
1352
+ const queueConfig = config.taskQueue || {};
1353
+
1354
+ // Check if queue feature is enabled
1355
+ if (queueConfig.enabled === false) {
1356
+ return { shouldContinue: false, reason: 'queue_disabled' };
1357
+ }
1358
+
1359
+ const status = getQueueStatus();
1360
+
1361
+ if (!status.hasQueue) {
1362
+ return { shouldContinue: false, reason: 'no_queue' };
1363
+ }
1364
+
1365
+ if (!status.hasMoreTasks) {
1366
+ return {
1367
+ shouldContinue: false,
1368
+ reason: 'queue_complete',
1369
+ message: `All ${status.total} tasks completed!`,
1370
+ completedTasks: status.completed
1371
+ };
1372
+ }
1373
+
1374
+ // Check if should pause between tasks
1375
+ if (queueConfig.pauseBetweenTasks) {
1376
+ return {
1377
+ shouldContinue: false,
1378
+ shouldPrompt: true,
1379
+ nextTaskId: status.nextTask,
1380
+ message: `Task complete. Next: ${status.nextTask} (${status.remaining} remaining). Continue?`
1381
+ };
1382
+ }
1383
+
1384
+ // Auto-continue (default)
1385
+ return {
1386
+ shouldContinue: true,
1387
+ nextTaskId: status.nextTask,
1388
+ remaining: status.remaining,
1389
+ message: `Task complete. Auto-continuing to: ${status.nextTask}`
1390
+ };
1391
+ }
1392
+
1393
+ // ============================================================================
1394
+ // Exports
1395
+ // ============================================================================
1396
+
1397
+ module.exports = {
1398
+ // Constants
1399
+ SESSION_VERSION,
1400
+ STEP_STATUS,
1401
+ STEP_TYPE,
1402
+ SUSPENSION_TYPE,
1403
+ RESUME_CONDITION,
1404
+
1405
+ // Core session management
1406
+ createDurableSession,
1407
+ createDurableSessionAsync, // Async version with file locking
1408
+ loadDurableSession,
1409
+ saveDurableSession,
1410
+ archiveDurableSession,
1411
+
1412
+ // Step management
1413
+ getNextPendingStep,
1414
+ getStep,
1415
+ markStepStarted,
1416
+ markStepCompleted,
1417
+ markStepFailed,
1418
+ markStepSkipped,
1419
+ addSteps,
1420
+ getRemainingSteps,
1421
+
1422
+ // Resume support
1423
+ canResumeFromStep,
1424
+ getResumeContext,
1425
+
1426
+ // Execution tracking
1427
+ incrementIteration,
1428
+ addTokensSaved,
1429
+ checkCompletion,
1430
+
1431
+ // Suspension
1432
+ suspendSession,
1433
+ isSuspended,
1434
+ getSuspensionStatus,
1435
+ checkResumeCondition,
1436
+ resumeSession,
1437
+
1438
+ // Backward compatibility - Loop Enforcer
1439
+ startLoop,
1440
+ getActiveLoop,
1441
+ updateCriterion,
1442
+ canExitLoop,
1443
+ endLoop,
1444
+
1445
+ // Backward compatibility - Hybrid Session
1446
+ getHybridSession,
1447
+
1448
+ // Utilities
1449
+ getSessionStats,
1450
+ normalizeStep,
1451
+
1452
+ // Task Queue (v2.1)
1453
+ initTaskQueue,
1454
+ getQueueStatus,
1455
+ advanceTaskQueue,
1456
+ clearTaskQueue,
1457
+ checkQueueContinuation
1458
+ };
1459
+
1460
+ // ============================================================================
1461
+ // CLI Interface
1462
+ // ============================================================================
1463
+
1464
+ if (require.main === module) {
1465
+ const args = process.argv.slice(2);
1466
+ const command = args[0];
1467
+
1468
+ switch (command) {
1469
+ case 'status': {
1470
+ const session = loadDurableSession();
1471
+ if (!session) {
1472
+ console.log('No active durable session');
1473
+ process.exit(0);
1474
+ }
1475
+
1476
+ const completion = checkCompletion();
1477
+ const suspension = getSuspensionStatus();
1478
+
1479
+ console.log('\nπŸ“Š Durable Session Status');
1480
+ console.log('─'.repeat(40));
1481
+ console.log(`Task: ${session.taskId}`);
1482
+ console.log(`Type: ${session.taskType}`);
1483
+ console.log(`Started: ${session.startedAt}`);
1484
+ console.log(`Iteration: ${session.execution.iteration}`);
1485
+ console.log(`Retries: ${session.execution.totalRetries}`);
1486
+ console.log('');
1487
+ console.log(`Steps: ${session.steps.length} total`);
1488
+ console.log(` βœ… Completed: ${session.metrics.stepsCompleted}`);
1489
+ console.log(` ❌ Failed: ${session.metrics.stepsFailed}`);
1490
+ console.log(` ⏭️ Skipped: ${session.metrics.stepsSkipped}`);
1491
+ console.log(` ⏳ Pending: ${completion.pending || 0}`);
1492
+
1493
+ if (suspension) {
1494
+ console.log('');
1495
+ console.log('⏸️ SUSPENDED');
1496
+ console.log(` Type: ${suspension.type}`);
1497
+ console.log(` Reason: ${suspension.reason}`);
1498
+ console.log(` Can Resume: ${suspension.canResume ? 'Yes' : 'No'}`);
1499
+ if (!suspension.canResume) {
1500
+ console.log(` Resume Reason: ${suspension.resumeReason}`);
1501
+ }
1502
+ }
1503
+
1504
+ console.log('─'.repeat(40));
1505
+ break;
1506
+ }
1507
+
1508
+ case 'stats': {
1509
+ const stats = getSessionStats();
1510
+ console.log('\nπŸ“ˆ Session Statistics');
1511
+ console.log('─'.repeat(40));
1512
+ console.log(`Total Sessions: ${stats.totalSessions}`);
1513
+ console.log(`Completed: ${stats.completed}`);
1514
+ console.log(`Failed: ${stats.failed}`);
1515
+ console.log(`Cancelled: ${stats.cancelled}`);
1516
+ console.log(`Avg Steps: ${stats.avgSteps}`);
1517
+ console.log(`Avg Tokens Saved: ${stats.avgTokensSaved}`);
1518
+ console.log('─'.repeat(40));
1519
+ break;
1520
+ }
1521
+
1522
+ case 'clear': {
1523
+ const sessionPath = getSessionPath();
1524
+ if (fs.existsSync(sessionPath)) {
1525
+ fs.unlinkSync(sessionPath);
1526
+ console.log('βœ… Active session cleared');
1527
+ } else {
1528
+ console.log('No active session to clear');
1529
+ }
1530
+ break;
1531
+ }
1532
+
1533
+ default:
1534
+ console.log('Usage: node flow-durable-session.js <command>');
1535
+ console.log('');
1536
+ console.log('Commands:');
1537
+ console.log(' status - Show current session status');
1538
+ console.log(' stats - Show session statistics');
1539
+ console.log(' clear - Clear active session');
1540
+ }
1541
+ }