@saga-ai/cli 2.14.0 → 2.15.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.
package/dist/cli.cjs CHANGED
@@ -24,189 +24,35 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli.ts
27
+ var import_node_fs9 = require("node:fs");
28
+ var import_node_path15 = require("node:path");
29
+ var import_node_process10 = __toESM(require("node:process"), 1);
27
30
  var import_commander = require("commander");
28
- var import_node_path9 = require("node:path");
29
- var import_node_fs8 = require("node:fs");
30
-
31
- // src/commands/init.ts
32
- var import_node_path2 = require("node:path");
33
- var import_node_fs2 = require("node:fs");
34
31
 
35
- // src/utils/project-discovery.ts
36
- var import_node_fs = require("node:fs");
37
- var import_node_path = require("node:path");
38
- function findProjectRoot(startDir) {
39
- let currentDir = startDir ?? process.cwd();
40
- while (true) {
41
- const sagaDir = (0, import_node_path.join)(currentDir, ".saga");
42
- if ((0, import_node_fs.existsSync)(sagaDir)) {
43
- return currentDir;
44
- }
45
- const parentDir = (0, import_node_path.dirname)(currentDir);
46
- if (parentDir === currentDir) {
47
- return null;
48
- }
49
- currentDir = parentDir;
50
- }
51
- }
52
- function resolveProjectPath(explicitPath) {
53
- if (explicitPath) {
54
- const sagaDir = (0, import_node_path.join)(explicitPath, ".saga");
55
- if (!(0, import_node_fs.existsSync)(sagaDir)) {
56
- throw new Error(
57
- `No .saga/ directory found at specified path: ${explicitPath}
58
- Make sure the path points to a SAGA project root.`
59
- );
60
- }
61
- return explicitPath;
62
- }
63
- const projectRoot = findProjectRoot();
64
- if (!projectRoot) {
65
- throw new Error(
66
- 'Could not find a SAGA project.\nNo .saga/ directory found in the current directory or any parent.\nRun "saga init" to initialize a new project, or use --path to specify the project location.'
67
- );
68
- }
69
- return projectRoot;
70
- }
32
+ // src/commands/dashboard.ts
33
+ var import_node_process3 = __toESM(require("node:process"), 1);
71
34
 
72
- // src/commands/init.ts
73
- var WORKTREES_PATTERN = ".saga/worktrees/";
74
- function runInitDryRun(targetPath) {
75
- const sagaDir = (0, import_node_path2.join)(targetPath, ".saga");
76
- const sagaExists = (0, import_node_fs2.existsSync)(sagaDir);
77
- const directories = [
78
- { name: "epics", path: (0, import_node_path2.join)(sagaDir, "epics") },
79
- { name: "archive", path: (0, import_node_path2.join)(sagaDir, "archive") },
80
- { name: "worktrees", path: (0, import_node_path2.join)(sagaDir, "worktrees") }
81
- ].map((dir) => ({
82
- path: dir.path,
83
- exists: (0, import_node_fs2.existsSync)(dir.path),
84
- action: (0, import_node_fs2.existsSync)(dir.path) ? "exists (skip)" : "will create"
85
- }));
86
- const gitignorePath = (0, import_node_path2.join)(targetPath, ".gitignore");
87
- const gitignoreExists = (0, import_node_fs2.existsSync)(gitignorePath);
88
- let hasPattern = false;
89
- if (gitignoreExists) {
90
- const content = (0, import_node_fs2.readFileSync)(gitignorePath, "utf-8");
91
- hasPattern = content.includes(WORKTREES_PATTERN);
92
- }
93
- let gitignoreAction;
94
- if (!gitignoreExists) {
95
- gitignoreAction = "will create with worktrees pattern";
96
- } else if (hasPattern) {
97
- gitignoreAction = "already has pattern (skip)";
98
- } else {
99
- gitignoreAction = "will append worktrees pattern";
100
- }
101
- return {
102
- targetPath,
103
- sagaExists,
104
- directories,
105
- gitignore: {
106
- path: gitignorePath,
107
- exists: gitignoreExists,
108
- hasPattern,
109
- action: gitignoreAction
110
- }
111
- };
112
- }
113
- function printInitDryRunResults(result) {
114
- console.log("Dry Run - Init Command");
115
- console.log("======================\n");
116
- console.log(`Target directory: ${result.targetPath}`);
117
- console.log(`SAGA project exists: ${result.sagaExists ? "yes" : "no"}
118
- `);
119
- console.log("Directories:");
120
- for (const dir of result.directories) {
121
- const icon = dir.exists ? "-" : "+";
122
- console.log(` ${icon} ${dir.path}`);
123
- console.log(` Action: ${dir.action}`);
124
- }
125
- console.log("\nGitignore:");
126
- console.log(` Path: ${result.gitignore.path}`);
127
- console.log(` Action: ${result.gitignore.action}`);
128
- console.log("\nDry run complete. No changes made.");
129
- }
130
- function resolveTargetPath(explicitPath) {
131
- if (explicitPath) {
132
- return explicitPath;
133
- }
134
- const existingRoot = findProjectRoot();
135
- if (existingRoot) {
136
- return existingRoot;
137
- }
138
- return process.cwd();
139
- }
140
- function createDirectoryStructure(projectRoot) {
141
- const sagaDir = (0, import_node_path2.join)(projectRoot, ".saga");
142
- (0, import_node_fs2.mkdirSync)((0, import_node_path2.join)(sagaDir, "epics"), { recursive: true });
143
- (0, import_node_fs2.mkdirSync)((0, import_node_path2.join)(sagaDir, "archive"), { recursive: true });
144
- (0, import_node_fs2.mkdirSync)((0, import_node_path2.join)(sagaDir, "worktrees"), { recursive: true });
145
- console.log("Created .saga/ directory structure");
146
- }
147
- function updateGitignore(projectRoot) {
148
- const gitignorePath = (0, import_node_path2.join)(projectRoot, ".gitignore");
149
- if ((0, import_node_fs2.existsSync)(gitignorePath)) {
150
- const content = (0, import_node_fs2.readFileSync)(gitignorePath, "utf-8");
151
- if (content.includes(WORKTREES_PATTERN)) {
152
- console.log(".gitignore already contains worktrees pattern");
153
- } else {
154
- (0, import_node_fs2.appendFileSync)(
155
- gitignorePath,
156
- `
157
- # Claude Tasks - Worktrees (git worktree isolation for stories)
158
- ${WORKTREES_PATTERN}
159
- `
160
- );
161
- console.log("Updated .gitignore with worktrees pattern");
162
- }
163
- } else {
164
- (0, import_node_fs2.writeFileSync)(
165
- gitignorePath,
166
- `# Claude Tasks - Worktrees (git worktree isolation for stories)
167
- ${WORKTREES_PATTERN}
168
- `
169
- );
170
- console.log("Created .gitignore with worktrees pattern");
171
- }
172
- }
173
- async function initCommand(options) {
174
- if (options.path) {
175
- if (!(0, import_node_fs2.existsSync)(options.path)) {
176
- console.error(`Error: Specified path '${options.path}' does not exist`);
177
- process.exit(1);
178
- }
179
- if (!(0, import_node_fs2.statSync)(options.path).isDirectory()) {
180
- console.error(`Error: Specified path '${options.path}' is not a directory`);
181
- process.exit(1);
182
- }
183
- }
184
- const targetPath = resolveTargetPath(options.path);
185
- if (options.dryRun) {
186
- const dryRunResult = runInitDryRun(targetPath);
187
- printInitDryRunResults(dryRunResult);
188
- return;
189
- }
190
- createDirectoryStructure(targetPath);
191
- updateGitignore(targetPath);
192
- console.log(`Initialized .saga/ at ${targetPath}`);
193
- }
35
+ // src/server/index.ts
36
+ var import_node_http = require("node:http");
37
+ var import_node_path8 = require("node:path");
38
+ var import_express3 = __toESM(require("express"), 1);
194
39
 
195
- // src/commands/implement.ts
196
- var import_node_child_process2 = require("node:child_process");
197
- var import_node_path5 = require("node:path");
198
- var import_node_fs5 = require("node:fs");
40
+ // src/server/routes.ts
41
+ var import_node_path4 = require("node:path");
42
+ var import_express2 = require("express");
199
43
 
200
- // src/utils/finder.ts
201
- var import_node_fs3 = require("node:fs");
202
- var import_node_path3 = require("node:path");
203
- var import_fuse = __toESM(require("fuse.js"), 1);
44
+ // src/server/parser.ts
45
+ var import_promises2 = require("node:fs/promises");
46
+ var import_node_path2 = require("node:path");
47
+ var import_gray_matter2 = __toESM(require("gray-matter"), 1);
204
48
 
205
49
  // src/utils/saga-scanner.ts
206
- var import_promises = require("fs/promises");
207
- var import_fs = require("fs");
208
- var import_path = require("path");
50
+ var import_node_fs = require("node:fs");
51
+ var import_promises = require("node:fs/promises");
52
+ var import_node_path = require("node:path");
209
53
  var import_gray_matter = __toESM(require("gray-matter"), 1);
54
+ var EPIC_TITLE_PATTERN = /^#\s+(.+)$/m;
55
+ var STORY_MD_SUFFIX_PATTERN = /\/story\.md$/;
210
56
  async function isDirectory(path) {
211
57
  try {
212
58
  const stats = await (0, import_promises.stat)(path);
@@ -224,18 +70,18 @@ async function fileExists(path) {
224
70
  }
225
71
  }
226
72
  function extractEpicTitle(content) {
227
- const match = content.match(/^#\s+(.+)$/m);
73
+ const match = content.match(EPIC_TITLE_PATTERN);
228
74
  return match ? match[1].trim() : null;
229
75
  }
230
76
  async function parseStoryFile(storyPath, epicSlug, options = {}) {
231
77
  try {
232
78
  const content = await (0, import_promises.readFile)(storyPath, "utf-8");
233
- const storyDir = storyPath.replace(/\/story\.md$/, "");
79
+ const storyDir = storyPath.replace(STORY_MD_SUFFIX_PATTERN, "");
234
80
  const dirName = storyDir.split("/").pop() || "unknown";
235
81
  const parsed = (0, import_gray_matter.default)(content);
236
82
  const frontmatter = parsed.data;
237
83
  const body = parsed.content;
238
- const journalPath = (0, import_path.join)(storyDir, "journal.md");
84
+ const journalPath = (0, import_node_path.join)(storyDir, "journal.md");
239
85
  const hasJournal = await fileExists(journalPath);
240
86
  return {
241
87
  slug: frontmatter.id || frontmatter.slug || dirName,
@@ -254,24 +100,23 @@ async function parseStoryFile(storyPath, epicSlug, options = {}) {
254
100
  }
255
101
  }
256
102
  async function scanWorktrees(sagaRoot) {
257
- const worktreesDir = (0, import_path.join)(sagaRoot, ".saga", "worktrees");
258
- const stories = [];
103
+ const worktreesDir = (0, import_node_path.join)(sagaRoot, ".saga", "worktrees");
259
104
  if (!await isDirectory(worktreesDir)) {
260
- return stories;
105
+ return [];
261
106
  }
262
107
  const epicEntries = await (0, import_promises.readdir)(worktreesDir);
263
- for (const epicSlug of epicEntries) {
264
- const epicWorktreesDir = (0, import_path.join)(worktreesDir, epicSlug);
108
+ const epicPromises = epicEntries.map(async (epicSlug) => {
109
+ const epicWorktreesDir = (0, import_node_path.join)(worktreesDir, epicSlug);
265
110
  if (!await isDirectory(epicWorktreesDir)) {
266
- continue;
111
+ return [];
267
112
  }
268
113
  const storyEntries = await (0, import_promises.readdir)(epicWorktreesDir);
269
- for (const storySlug of storyEntries) {
270
- const worktreePath = (0, import_path.join)(epicWorktreesDir, storySlug);
114
+ const storyPromises = storyEntries.map(async (storySlug) => {
115
+ const worktreePath = (0, import_node_path.join)(epicWorktreesDir, storySlug);
271
116
  if (!await isDirectory(worktreePath)) {
272
- continue;
117
+ return null;
273
118
  }
274
- const storyPath = (0, import_path.join)(
119
+ const storyPath = (0, import_node_path.join)(
275
120
  worktreePath,
276
121
  ".saga",
277
122
  "epics",
@@ -280,67 +125,65 @@ async function scanWorktrees(sagaRoot) {
280
125
  storySlug,
281
126
  "story.md"
282
127
  );
283
- const story = await parseStoryFile(storyPath, epicSlug, { worktreePath });
284
- if (story) {
285
- stories.push(story);
286
- }
287
- }
288
- }
289
- return stories;
128
+ return await parseStoryFile(storyPath, epicSlug, { worktreePath });
129
+ });
130
+ const stories = await Promise.all(storyPromises);
131
+ return stories.filter((story) => story !== null);
132
+ });
133
+ const epicStories = await Promise.all(epicPromises);
134
+ return epicStories.flat();
290
135
  }
291
136
  async function scanEpicsStories(sagaRoot) {
292
- const epicsDir = (0, import_path.join)(sagaRoot, ".saga", "epics");
293
- const stories = [];
137
+ const epicsDir = (0, import_node_path.join)(sagaRoot, ".saga", "epics");
294
138
  if (!await isDirectory(epicsDir)) {
295
- return stories;
139
+ return [];
296
140
  }
297
141
  const epicEntries = await (0, import_promises.readdir)(epicsDir);
298
- for (const epicSlug of epicEntries) {
299
- const storiesDir = (0, import_path.join)(epicsDir, epicSlug, "stories");
142
+ const epicPromises = epicEntries.map(async (epicSlug) => {
143
+ const storiesDir = (0, import_node_path.join)(epicsDir, epicSlug, "stories");
300
144
  if (!await isDirectory(storiesDir)) {
301
- continue;
145
+ return [];
302
146
  }
303
147
  const storyEntries = await (0, import_promises.readdir)(storiesDir);
304
- for (const storySlug of storyEntries) {
305
- const storyDir = (0, import_path.join)(storiesDir, storySlug);
148
+ const storyPromises = storyEntries.map(async (storySlug) => {
149
+ const storyDir = (0, import_node_path.join)(storiesDir, storySlug);
306
150
  if (!await isDirectory(storyDir)) {
307
- continue;
308
- }
309
- const storyPath = (0, import_path.join)(storyDir, "story.md");
310
- const story = await parseStoryFile(storyPath, epicSlug);
311
- if (story) {
312
- stories.push(story);
151
+ return null;
313
152
  }
314
- }
315
- }
316
- return stories;
153
+ const storyPath = (0, import_node_path.join)(storyDir, "story.md");
154
+ return await parseStoryFile(storyPath, epicSlug);
155
+ });
156
+ const stories = await Promise.all(storyPromises);
157
+ return stories.filter((story) => story !== null);
158
+ });
159
+ const epicStories = await Promise.all(epicPromises);
160
+ return epicStories.flat();
317
161
  }
318
162
  async function scanArchive(sagaRoot) {
319
- const archiveDir = (0, import_path.join)(sagaRoot, ".saga", "archive");
320
- const stories = [];
163
+ const archiveDir = (0, import_node_path.join)(sagaRoot, ".saga", "archive");
321
164
  if (!await isDirectory(archiveDir)) {
322
- return stories;
165
+ return [];
323
166
  }
324
167
  const epicEntries = await (0, import_promises.readdir)(archiveDir);
325
- for (const epicSlug of epicEntries) {
326
- const epicArchiveDir = (0, import_path.join)(archiveDir, epicSlug);
168
+ const epicPromises = epicEntries.map(async (epicSlug) => {
169
+ const epicArchiveDir = (0, import_node_path.join)(archiveDir, epicSlug);
327
170
  if (!await isDirectory(epicArchiveDir)) {
328
- continue;
171
+ return [];
329
172
  }
330
173
  const storyEntries = await (0, import_promises.readdir)(epicArchiveDir);
331
- for (const storySlug of storyEntries) {
332
- const storyDir = (0, import_path.join)(epicArchiveDir, storySlug);
174
+ const storyPromises = storyEntries.map(async (storySlug) => {
175
+ const storyDir = (0, import_node_path.join)(epicArchiveDir, storySlug);
333
176
  if (!await isDirectory(storyDir)) {
334
- continue;
335
- }
336
- const storyPath = (0, import_path.join)(storyDir, "story.md");
337
- const story = await parseStoryFile(storyPath, epicSlug, { archived: true });
338
- if (story) {
339
- stories.push(story);
177
+ return null;
340
178
  }
341
- }
342
- }
343
- return stories;
179
+ const storyPath = (0, import_node_path.join)(storyDir, "story.md");
180
+ return await parseStoryFile(storyPath, epicSlug, { archived: true });
181
+ });
182
+ const stories = await Promise.all(storyPromises);
183
+ return stories.filter((story) => story !== null);
184
+ });
185
+ const epicStories = await Promise.all(epicPromises);
186
+ return epicStories.flat();
344
187
  }
345
188
  async function scanAllStories(sagaRoot) {
346
189
  const [worktreeStories, epicsStories, archivedStories] = await Promise.all([
@@ -374,18 +217,17 @@ async function scanAllStories(sagaRoot) {
374
217
  return result;
375
218
  }
376
219
  async function scanEpics(sagaRoot) {
377
- const epicsDir = (0, import_path.join)(sagaRoot, ".saga", "epics");
378
- const epics = [];
220
+ const epicsDir = (0, import_node_path.join)(sagaRoot, ".saga", "epics");
379
221
  if (!await isDirectory(epicsDir)) {
380
- return epics;
222
+ return [];
381
223
  }
382
224
  const epicEntries = await (0, import_promises.readdir)(epicsDir);
383
- for (const epicSlug of epicEntries) {
384
- const epicPath = (0, import_path.join)(epicsDir, epicSlug);
225
+ const epicPromises = epicEntries.map(async (epicSlug) => {
226
+ const epicPath = (0, import_node_path.join)(epicsDir, epicSlug);
385
227
  if (!await isDirectory(epicPath)) {
386
- continue;
228
+ return null;
387
229
  }
388
- const epicMdPath = (0, import_path.join)(epicPath, "epic.md");
230
+ const epicMdPath = (0, import_node_path.join)(epicPath, "epic.md");
389
231
  let content = "";
390
232
  let title = epicSlug;
391
233
  try {
@@ -393,241 +235,222 @@ async function scanEpics(sagaRoot) {
393
235
  title = extractEpicTitle(content) || epicSlug;
394
236
  } catch {
395
237
  }
396
- epics.push({
238
+ return {
397
239
  slug: epicSlug,
398
240
  title,
399
241
  epicPath,
400
242
  epicMdPath,
401
243
  content
402
- });
403
- }
404
- return epics;
244
+ };
245
+ });
246
+ const epics = await Promise.all(epicPromises);
247
+ return epics.filter((epic) => epic !== null);
405
248
  }
406
249
  function worktreesDirectoryExists(projectPath) {
407
- return (0, import_fs.existsSync)((0, import_path.join)(projectPath, ".saga", "worktrees"));
250
+ return (0, import_node_fs.existsSync)((0, import_node_path.join)(projectPath, ".saga", "worktrees"));
408
251
  }
409
252
  function epicsDirectoryExists(projectPath) {
410
- return (0, import_fs.existsSync)((0, import_path.join)(projectPath, ".saga", "epics"));
253
+ return (0, import_node_fs.existsSync)((0, import_node_path.join)(projectPath, ".saga", "epics"));
411
254
  }
412
255
 
413
- // src/utils/finder.ts
414
- var FUZZY_THRESHOLD = 0.3;
415
- var MATCH_THRESHOLD = 0.6;
416
- var SCORE_SIMILARITY_THRESHOLD = 0.1;
417
- function extractContext(body, maxLength = 300) {
418
- const contextMatch = body.match(/##\s*Context\s*\n+([\s\S]*?)(?=\n##|\Z|$)/i);
419
- if (!contextMatch) {
420
- return "";
256
+ // src/server/parser.ts
257
+ var STORY_MD_SUFFIX_PATTERN2 = /\/story\.md$/;
258
+ var JOURNAL_SECTION_PATTERN = /^##\s+/m;
259
+ function toApiStoryStatus(status) {
260
+ return status === "in_progress" ? "inProgress" : status;
261
+ }
262
+ function toApiTaskStatus(status) {
263
+ return status === "in_progress" ? "inProgress" : status;
264
+ }
265
+ function validateStatus(status) {
266
+ const validStatuses = ["ready", "in_progress", "blocked", "completed"];
267
+ if (typeof status === "string" && validStatuses.includes(status)) {
268
+ return status;
421
269
  }
422
- let context = contextMatch[1].trim();
423
- if (context.length > maxLength) {
424
- return context.slice(0, maxLength - 3) + "...";
270
+ return "ready";
271
+ }
272
+ function validateTaskStatus(status) {
273
+ const validStatuses = ["pending", "in_progress", "completed"];
274
+ if (typeof status === "string" && validStatuses.includes(status)) {
275
+ return status;
425
276
  }
426
- return context;
277
+ return "pending";
427
278
  }
428
- function normalize(str) {
429
- return str.toLowerCase().replace(/[-_]/g, " ");
279
+ function parseTasks(tasks) {
280
+ if (!Array.isArray(tasks)) {
281
+ return [];
282
+ }
283
+ return tasks.filter((t) => typeof t === "object" && t !== null).map((t) => ({
284
+ id: typeof t.id === "string" ? t.id : "unknown",
285
+ title: typeof t.title === "string" ? t.title : "Unknown Task",
286
+ status: toApiTaskStatus(validateTaskStatus(t.status))
287
+ }));
430
288
  }
431
- function toStoryInfo(story) {
289
+ async function toStoryDetail(story, sagaRoot) {
290
+ let tasks = [];
291
+ try {
292
+ const content = await (0, import_promises2.readFile)(story.storyPath, "utf-8");
293
+ const parsed = (0, import_gray_matter2.default)(content);
294
+ tasks = parseTasks(parsed.data.tasks);
295
+ } catch {
296
+ tasks = parseTasks(story.frontmatter.tasks);
297
+ }
432
298
  return {
433
299
  slug: story.slug,
434
- title: story.title,
435
- status: story.status,
436
- context: extractContext(story.body),
437
300
  epicSlug: story.epicSlug,
438
- storyPath: story.storyPath,
439
- worktreePath: story.worktreePath || ""
301
+ title: story.title,
302
+ status: toApiStoryStatus(validateStatus(story.status)),
303
+ tasks,
304
+ archived: story.archived,
305
+ paths: {
306
+ storyMd: (0, import_node_path2.relative)(sagaRoot, story.storyPath),
307
+ ...story.journalPath ? { journalMd: (0, import_node_path2.relative)(sagaRoot, story.journalPath) } : {},
308
+ ...story.worktreePath ? { worktree: (0, import_node_path2.relative)(sagaRoot, story.worktreePath) } : {}
309
+ }
440
310
  };
441
311
  }
442
- function findEpic(projectPath, query) {
443
- const epicsDir = (0, import_node_path3.join)(projectPath, ".saga", "epics");
444
- if (!epicsDirectoryExists(projectPath)) {
445
- return {
446
- found: false,
447
- error: "No .saga/epics/ directory found"
448
- };
449
- }
450
- const epicSlugs = (0, import_node_fs3.readdirSync)(epicsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
451
- if (epicSlugs.length === 0) {
452
- return {
453
- found: false,
454
- error: `No epic found matching '${query}'`
455
- };
312
+ async function buildEpic(scannedEpic, epicStories, sagaRoot) {
313
+ const stories = await Promise.all(epicStories.map((s) => toStoryDetail(s, sagaRoot)));
314
+ const storyCounts = {
315
+ total: stories.length,
316
+ ready: stories.filter((s) => s.status === "ready").length,
317
+ inProgress: stories.filter((s) => s.status === "inProgress").length,
318
+ blocked: stories.filter((s) => s.status === "blocked").length,
319
+ completed: stories.filter((s) => s.status === "completed").length
320
+ };
321
+ return {
322
+ slug: scannedEpic.slug,
323
+ title: scannedEpic.title,
324
+ content: scannedEpic.content,
325
+ storyCounts,
326
+ stories,
327
+ path: (0, import_node_path2.relative)(sagaRoot, scannedEpic.epicPath)
328
+ };
329
+ }
330
+ async function parseStory(storyPath, epicSlug) {
331
+ const { join: join14 } = await import("node:path");
332
+ const { stat: stat4 } = await import("node:fs/promises");
333
+ let content;
334
+ try {
335
+ content = await (0, import_promises2.readFile)(storyPath, "utf-8");
336
+ } catch {
337
+ return null;
456
338
  }
457
- const queryNormalized = query.toLowerCase().replace(/_/g, "-");
458
- for (const slug of epicSlugs) {
459
- if (slug.toLowerCase() === queryNormalized) {
460
- return {
461
- found: true,
462
- data: { slug }
463
- };
464
- }
339
+ const storyDir = storyPath.replace(STORY_MD_SUFFIX_PATTERN2, "");
340
+ const dirName = storyDir.split("/").pop() || "unknown";
341
+ let frontmatter = {};
342
+ try {
343
+ const parsed = (0, import_gray_matter2.default)(content);
344
+ frontmatter = parsed.data;
345
+ } catch {
465
346
  }
466
- const epics = epicSlugs.map((slug) => ({ slug }));
467
- const fuse = new import_fuse.default(epics, {
468
- keys: ["slug"],
469
- threshold: MATCH_THRESHOLD,
470
- includeScore: true
471
- });
472
- const results = fuse.search(query);
473
- if (results.length === 0) {
474
- return {
475
- found: false,
476
- error: `No epic found matching '${query}'`
477
- };
478
- }
479
- if (results.length === 1) {
480
- return {
481
- found: true,
482
- data: results[0].item
483
- };
484
- }
485
- const bestScore = results[0].score ?? 0;
486
- const similarMatches = results.filter(
487
- (r) => (r.score ?? 0) - bestScore <= SCORE_SIMILARITY_THRESHOLD
488
- );
489
- if (similarMatches.length > 1) {
490
- return {
491
- found: false,
492
- matches: similarMatches.map((r) => r.item)
493
- };
494
- }
495
- if (bestScore <= FUZZY_THRESHOLD) {
496
- return {
497
- found: true,
498
- data: results[0].item
499
- };
347
+ const slug = frontmatter.id || frontmatter.slug || dirName;
348
+ const title = frontmatter.title || dirName;
349
+ const status = toApiStoryStatus(validateStatus(frontmatter.status));
350
+ const tasks = parseTasks(frontmatter.tasks);
351
+ const journalPath = join14(storyDir, "journal.md");
352
+ let hasJournal = false;
353
+ try {
354
+ await stat4(journalPath);
355
+ hasJournal = true;
356
+ } catch {
500
357
  }
501
358
  return {
502
- found: false,
503
- matches: results.map((r) => r.item)
359
+ slug,
360
+ epicSlug,
361
+ title,
362
+ status,
363
+ tasks,
364
+ paths: {
365
+ storyMd: storyPath,
366
+ ...hasJournal ? { journalMd: journalPath } : {}
367
+ }
504
368
  };
505
369
  }
506
- async function findStory(projectPath, query, options = {}) {
507
- if (!worktreesDirectoryExists(projectPath) && !epicsDirectoryExists(projectPath)) {
508
- return {
509
- found: false,
510
- error: "No .saga/worktrees/ or .saga/epics/ directory found. Run /generate-stories first."
511
- };
512
- }
513
- const scannedStories = await scanAllStories(projectPath);
514
- if (scannedStories.length === 0) {
515
- return {
516
- found: false,
517
- error: `No story found matching '${query}'`
518
- };
519
- }
520
- let allStories = scannedStories.map(toStoryInfo);
521
- if (options.status) {
522
- allStories = allStories.filter((story) => story.status === options.status);
523
- if (allStories.length === 0) {
524
- return {
525
- found: false,
526
- error: `No story found matching '${query}' with status '${options.status}'`
527
- };
370
+ async function parseJournal(journalPath) {
371
+ try {
372
+ const content = await (0, import_promises2.readFile)(journalPath, "utf-8");
373
+ const entries = [];
374
+ const sections = content.split(JOURNAL_SECTION_PATTERN).slice(1);
375
+ for (const section of sections) {
376
+ const lines = section.split("\n");
377
+ const headerLine = lines[0] || "";
378
+ const sectionContent = lines.slice(1).join("\n").trim();
379
+ if (headerLine.toLowerCase().startsWith("session:")) {
380
+ const timestamp = headerLine.substring("session:".length).trim();
381
+ entries.push({
382
+ timestamp,
383
+ type: "session",
384
+ content: sectionContent
385
+ });
386
+ } else if (headerLine.toLowerCase().startsWith("blocker:")) {
387
+ const title = headerLine.substring("blocker:".length).trim();
388
+ entries.push({
389
+ timestamp: "",
390
+ // Blockers may not have timestamps
391
+ type: "blocker",
392
+ content: `${title}
393
+
394
+ ${sectionContent}`.trim()
395
+ });
396
+ } else if (headerLine.toLowerCase().startsWith("resolution:")) {
397
+ const title = headerLine.substring("resolution:".length).trim();
398
+ entries.push({
399
+ timestamp: "",
400
+ // Resolutions may not have timestamps
401
+ type: "resolution",
402
+ content: `${title}
403
+
404
+ ${sectionContent}`.trim()
405
+ });
406
+ }
528
407
  }
408
+ return entries;
409
+ } catch {
410
+ return [];
529
411
  }
530
- const queryNormalized = normalize(query);
531
- for (const story of allStories) {
532
- if (normalize(story.slug) === queryNormalized) {
533
- return {
534
- found: true,
535
- data: story
536
- };
537
- }
412
+ }
413
+ async function scanSagaDirectory(sagaRoot) {
414
+ const [scannedEpics, scannedStories] = await Promise.all([
415
+ scanEpics(sagaRoot),
416
+ scanAllStories(sagaRoot)
417
+ ]);
418
+ const storiesByEpic = /* @__PURE__ */ new Map();
419
+ for (const story of scannedStories) {
420
+ const existing = storiesByEpic.get(story.epicSlug) || [];
421
+ existing.push(story);
422
+ storiesByEpic.set(story.epicSlug, existing);
538
423
  }
539
- const fuse = new import_fuse.default(allStories, {
540
- keys: [
541
- { name: "slug", weight: 2 },
542
- // Prioritize slug matches
543
- { name: "title", weight: 1 }
544
- ],
545
- threshold: MATCH_THRESHOLD,
546
- includeScore: true
424
+ const epicPromises = scannedEpics.map((scannedEpic) => {
425
+ const epicStories = storiesByEpic.get(scannedEpic.slug) || [];
426
+ return buildEpic(scannedEpic, epicStories, sagaRoot);
547
427
  });
548
- const results = fuse.search(query);
549
- if (results.length === 0) {
550
- return {
551
- found: false,
552
- error: `No story found matching '${query}'`
553
- };
554
- }
555
- if (results.length === 1) {
556
- return {
557
- found: true,
558
- data: results[0].item
559
- };
560
- }
561
- const bestScore = results[0].score ?? 0;
562
- const similarMatches = results.filter(
563
- (r) => (r.score ?? 0) - bestScore <= SCORE_SIMILARITY_THRESHOLD
564
- );
565
- if (similarMatches.length > 1) {
566
- return {
567
- found: false,
568
- matches: similarMatches.map((r) => r.item)
569
- };
570
- }
571
- if (bestScore <= FUZZY_THRESHOLD) {
572
- return {
573
- found: true,
574
- data: results[0].item
575
- };
576
- }
577
- return {
578
- found: false,
579
- matches: results.map((r) => r.item)
580
- };
428
+ return Promise.all(epicPromises);
581
429
  }
582
430
 
431
+ // src/server/session-routes.ts
432
+ var import_express = require("express");
433
+
583
434
  // src/lib/sessions.ts
584
435
  var import_node_child_process = require("node:child_process");
585
- var import_node_fs4 = require("node:fs");
586
- var import_promises2 = require("node:fs/promises");
587
- var import_node_path4 = require("node:path");
436
+ var import_node_fs2 = require("node:fs");
437
+ var import_promises3 = require("node:fs/promises");
438
+ var import_node_path3 = require("node:path");
439
+ var import_node_process = __toESM(require("node:process"), 1);
440
+ var SESSION_NAME_PARTS_COUNT = 4;
441
+ var PREVIEW_LINES_COUNT = 5;
442
+ var PREVIEW_MAX_LENGTH = 500;
443
+ var SLUG_PATTERN = /^[a-z0-9-]+$/;
444
+ var SESSION_NAME_PATTERN = /^(saga[-_][-_]?[a-z0-9_-]+):/;
588
445
  var OUTPUT_DIR = "/tmp/saga-sessions";
589
- function shellEscape(str) {
590
- return "'" + str.replace(/'/g, "'\\''") + "'";
591
- }
592
- function shellEscapeArgs(args) {
593
- return args.map(shellEscape).join(" ");
594
- }
595
- function validateSlug(slug) {
596
- if (!slug || slug.length === 0) {
597
- return false;
598
- }
599
- if (!/^[a-z0-9-]+$/.test(slug)) {
600
- return false;
601
- }
602
- if (slug.startsWith("-") || slug.endsWith("-")) {
603
- return false;
604
- }
605
- return true;
606
- }
607
446
  function checkTmuxAvailable() {
608
447
  const result = (0, import_node_child_process.spawnSync)("which", ["tmux"], { encoding: "utf-8" });
609
448
  if (result.status !== 0) {
610
449
  throw new Error("tmux is not installed or not found in PATH");
611
450
  }
612
451
  }
613
- async function createSession(epicSlug, storySlug, command) {
614
- if (!validateSlug(epicSlug)) {
615
- throw new Error(`Invalid epic slug: '${epicSlug}'. Must contain only [a-z0-9-] and not start/end with hyphen.`);
616
- }
617
- if (!validateSlug(storySlug)) {
618
- throw new Error(`Invalid story slug: '${storySlug}'. Must contain only [a-z0-9-] and not start/end with hyphen.`);
619
- }
620
- checkTmuxAvailable();
621
- if (!(0, import_node_fs4.existsSync)(OUTPUT_DIR)) {
622
- (0, import_node_fs4.mkdirSync)(OUTPUT_DIR, { recursive: true });
623
- }
624
- const timestamp = Date.now();
625
- const sessionName = `saga-${epicSlug}-${storySlug}-${timestamp}`;
626
- const outputFile = (0, import_node_path4.join)(OUTPUT_DIR, `${sessionName}.out`);
627
- const commandFilePath = (0, import_node_path4.join)(OUTPUT_DIR, `${sessionName}.cmd`);
628
- const wrapperScriptPath = (0, import_node_path4.join)(OUTPUT_DIR, `${sessionName}.sh`);
629
- (0, import_node_fs4.writeFileSync)(commandFilePath, command, { mode: 384 });
630
- const wrapperScriptContent = `#!/bin/bash
452
+ function generateWrapperScript(commandFilePath, outputFile, wrapperScriptPath) {
453
+ return `#!/bin/bash
631
454
  # Auto-generated wrapper script for SAGA session
632
455
  set -e
633
456
 
@@ -658,26 +481,100 @@ else
658
481
  exec script -q -c "$COMMAND" "$OUTPUT_FILE"
659
482
  fi
660
483
  `;
661
- (0, import_node_fs4.writeFileSync)(wrapperScriptPath, wrapperScriptContent, { mode: 448 });
662
- const createResult = (0, import_node_child_process.spawnSync)("tmux", [
663
- "new-session",
664
- "-d",
665
- // detached
666
- "-s",
667
- sessionName,
668
- // session name
484
+ }
485
+ async function extractFileTimestamps(outputFile, status) {
486
+ try {
487
+ const stats = await (0, import_promises3.stat)(outputFile);
488
+ return {
489
+ startTime: stats.birthtime,
490
+ endTime: status === "completed" ? stats.mtime : void 0
491
+ };
492
+ } catch {
493
+ return { startTime: /* @__PURE__ */ new Date() };
494
+ }
495
+ }
496
+ async function generateOutputPreview(outputFile) {
497
+ try {
498
+ const content = await (0, import_promises3.readFile)(outputFile, "utf-8");
499
+ const lines = content.split("\n").filter((line) => line.length > 0);
500
+ if (lines.length === 0) {
501
+ return void 0;
502
+ }
503
+ const lastLines = lines.slice(-PREVIEW_LINES_COUNT);
504
+ let preview = lastLines.join("\n");
505
+ if (preview.length > PREVIEW_MAX_LENGTH) {
506
+ const truncated = preview.slice(0, PREVIEW_MAX_LENGTH);
507
+ const lastNewline = truncated.lastIndexOf("\n");
508
+ preview = lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated;
509
+ }
510
+ return preview;
511
+ } catch {
512
+ return void 0;
513
+ }
514
+ }
515
+ function validateSessionSlugs(epicSlug, storySlug) {
516
+ if (!validateSlug(epicSlug)) {
517
+ throw new Error(
518
+ `Invalid epic slug: '${epicSlug}'. Must contain only [a-z0-9-] and not start/end with hyphen.`
519
+ );
520
+ }
521
+ if (!validateSlug(storySlug)) {
522
+ throw new Error(
523
+ `Invalid story slug: '${storySlug}'. Must contain only [a-z0-9-] and not start/end with hyphen.`
524
+ );
525
+ }
526
+ }
527
+ function createSessionFiles(sessionName, command) {
528
+ const outputFile = (0, import_node_path3.join)(OUTPUT_DIR, `${sessionName}.out`);
529
+ const commandFilePath = (0, import_node_path3.join)(OUTPUT_DIR, `${sessionName}.cmd`);
530
+ const wrapperScriptPath = (0, import_node_path3.join)(OUTPUT_DIR, `${sessionName}.sh`);
531
+ (0, import_node_fs2.writeFileSync)(commandFilePath, command, { mode: 384 });
532
+ const wrapperScriptContent = generateWrapperScript(
533
+ commandFilePath,
534
+ outputFile,
669
535
  wrapperScriptPath
670
- // run the wrapper script
671
- ], { encoding: "utf-8" });
536
+ );
537
+ (0, import_node_fs2.writeFileSync)(wrapperScriptPath, wrapperScriptContent, { mode: 448 });
538
+ return { wrapperScriptPath, outputFile };
539
+ }
540
+ function shellEscape(str) {
541
+ return `'${str.replace(/'/g, "'\\''")}'`;
542
+ }
543
+ function shellEscapeArgs(args) {
544
+ return args.map(shellEscape).join(" ");
545
+ }
546
+ function validateSlug(slug) {
547
+ if (typeof slug !== "string" || slug.length === 0) {
548
+ return false;
549
+ }
550
+ if (!SLUG_PATTERN.test(slug)) {
551
+ return false;
552
+ }
553
+ if (slug.startsWith("-") || slug.endsWith("-")) {
554
+ return false;
555
+ }
556
+ return true;
557
+ }
558
+ function createSession(epicSlug, storySlug, command) {
559
+ validateSessionSlugs(epicSlug, storySlug);
560
+ checkTmuxAvailable();
561
+ if (!(0, import_node_fs2.existsSync)(OUTPUT_DIR)) {
562
+ (0, import_node_fs2.mkdirSync)(OUTPUT_DIR, { recursive: true });
563
+ }
564
+ const timestamp = Date.now();
565
+ const sessionName = `saga-${epicSlug}-${storySlug}-${timestamp}`;
566
+ const { wrapperScriptPath, outputFile } = createSessionFiles(sessionName, command);
567
+ const createResult = (0, import_node_child_process.spawnSync)(
568
+ "tmux",
569
+ ["new-session", "-d", "-s", sessionName, wrapperScriptPath],
570
+ { encoding: "utf-8" }
571
+ );
672
572
  if (createResult.status !== 0) {
673
573
  throw new Error(`Failed to create tmux session: ${createResult.stderr || "unknown error"}`);
674
574
  }
675
- return {
676
- sessionName,
677
- outputFile
678
- };
575
+ return { sessionName, outputFile };
679
576
  }
680
- async function listSessions() {
577
+ function listSessions() {
681
578
  const result = (0, import_node_child_process.spawnSync)("tmux", ["ls"], { encoding: "utf-8" });
682
579
  if (result.status !== 0) {
683
580
  return [];
@@ -685,20 +582,20 @@ async function listSessions() {
685
582
  const sessions = [];
686
583
  const lines = result.stdout.trim().split("\n");
687
584
  for (const line of lines) {
688
- const match = line.match(/^(saga-[a-z0-9]+(?:-[a-z0-9]+)*-\d+):/);
585
+ const match = line.match(SESSION_NAME_PATTERN);
689
586
  if (match) {
690
587
  const name = match[1];
691
588
  sessions.push({
692
589
  name,
693
590
  status: "running",
694
591
  // If it shows up in tmux ls, it's running
695
- outputFile: (0, import_node_path4.join)(OUTPUT_DIR, `${name}.out`)
592
+ outputFile: (0, import_node_path3.join)(OUTPUT_DIR, `${name}.out`)
696
593
  });
697
594
  }
698
595
  }
699
596
  return sessions;
700
597
  }
701
- async function getSessionStatus(sessionName) {
598
+ function getSessionStatus(sessionName) {
702
599
  const result = (0, import_node_child_process.spawnSync)("tmux", ["has-session", "-t", sessionName], {
703
600
  encoding: "utf-8"
704
601
  });
@@ -706,9 +603,9 @@ async function getSessionStatus(sessionName) {
706
603
  running: result.status === 0
707
604
  };
708
605
  }
709
- async function streamLogs(sessionName) {
710
- const outputFile = (0, import_node_path4.join)(OUTPUT_DIR, `${sessionName}.out`);
711
- if (!(0, import_node_fs4.existsSync)(outputFile)) {
606
+ function streamLogs(sessionName) {
607
+ const outputFile = (0, import_node_path3.join)(OUTPUT_DIR, `${sessionName}.out`);
608
+ if (!(0, import_node_fs2.existsSync)(outputFile)) {
712
609
  throw new Error(`Output file not found: ${outputFile}`);
713
610
  }
714
611
  return new Promise((resolve2, reject) => {
@@ -716,25 +613,25 @@ async function streamLogs(sessionName) {
716
613
  stdio: ["ignore", "pipe", "pipe"]
717
614
  });
718
615
  child.stdout?.on("data", (chunk) => {
719
- process.stdout.write(chunk);
616
+ import_node_process.default.stdout.write(chunk);
720
617
  });
721
618
  child.stderr?.on("data", (chunk) => {
722
- process.stderr.write(chunk);
619
+ import_node_process.default.stderr.write(chunk);
723
620
  });
724
621
  child.on("error", (err) => {
725
622
  reject(new Error(`Failed to stream logs: ${err.message}`));
726
623
  });
727
- child.on("close", (code) => {
624
+ child.on("close", (_code) => {
728
625
  resolve2();
729
626
  });
730
627
  const sigintHandler = () => {
731
628
  child.kill("SIGTERM");
732
- process.removeListener("SIGINT", sigintHandler);
629
+ import_node_process.default.removeListener("SIGINT", sigintHandler);
733
630
  };
734
- process.on("SIGINT", sigintHandler);
631
+ import_node_process.default.on("SIGINT", sigintHandler);
735
632
  });
736
633
  }
737
- async function killSession(sessionName) {
634
+ function killSession(sessionName) {
738
635
  const result = (0, import_node_child_process.spawnSync)("tmux", ["kill-session", "-t", sessionName], {
739
636
  encoding: "utf-8"
740
637
  });
@@ -743,15 +640,15 @@ async function killSession(sessionName) {
743
640
  };
744
641
  }
745
642
  function parseSessionName(name) {
746
- if (!name || !name.startsWith("saga__")) {
643
+ if (!name?.startsWith("saga__")) {
747
644
  return null;
748
645
  }
749
646
  const parts = name.split("__");
750
- if (parts.length !== 4) {
647
+ if (parts.length !== SESSION_NAME_PARTS_COUNT) {
751
648
  return null;
752
649
  }
753
650
  const [, epicSlug, storySlug, pid] = parts;
754
- if (!epicSlug || !storySlug || !pid) {
651
+ if (!(epicSlug && storySlug && pid)) {
755
652
  return null;
756
653
  }
757
654
  return {
@@ -764,35 +661,16 @@ async function buildSessionInfo(name, status) {
764
661
  if (!parsed) {
765
662
  return null;
766
663
  }
767
- const outputFile = (0, import_node_path4.join)(OUTPUT_DIR, `${name}.out`);
768
- const outputAvailable = (0, import_node_fs4.existsSync)(outputFile);
664
+ const outputFile = (0, import_node_path3.join)(OUTPUT_DIR, `${name}.out`);
665
+ const outputAvailable = (0, import_node_fs2.existsSync)(outputFile);
769
666
  let startTime = /* @__PURE__ */ new Date();
770
667
  let endTime;
771
668
  let outputPreview;
772
669
  if (outputAvailable) {
773
- try {
774
- const stats = await (0, import_promises2.stat)(outputFile);
775
- startTime = stats.birthtime;
776
- if (status === "completed") {
777
- endTime = stats.mtime;
778
- }
779
- } catch {
780
- }
781
- try {
782
- const content = await (0, import_promises2.readFile)(outputFile, "utf-8");
783
- const lines = content.split("\n").filter((line) => line.length > 0);
784
- if (lines.length > 0) {
785
- const lastLines = lines.slice(-5);
786
- let preview = lastLines.join("\n");
787
- if (preview.length > 500) {
788
- const truncated = preview.slice(0, 500);
789
- const lastNewline = truncated.lastIndexOf("\n");
790
- preview = lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated;
791
- }
792
- outputPreview = preview;
793
- }
794
- } catch {
795
- }
670
+ const timestamps = await extractFileTimestamps(outputFile, status);
671
+ startTime = timestamps.startTime;
672
+ endTime = timestamps.endTime;
673
+ outputPreview = await generateOutputPreview(outputFile);
796
674
  }
797
675
  return {
798
676
  name,
@@ -807,1676 +685,2003 @@ async function buildSessionInfo(name, status) {
807
685
  };
808
686
  }
809
687
 
810
- // src/commands/implement.ts
811
- var DEFAULT_MAX_CYCLES = 10;
812
- var DEFAULT_MAX_TIME = 60;
813
- var DEFAULT_MODEL = "opus";
814
- var VALID_STATUSES = /* @__PURE__ */ new Set(["ONGOING", "FINISH", "BLOCKED"]);
815
- var WORKER_PROMPT_RELATIVE = "worker-prompt.md";
816
- var WORKER_OUTPUT_SCHEMA = {
817
- type: "object",
818
- properties: {
819
- status: {
820
- type: "string",
821
- enum: ["ONGOING", "FINISH", "BLOCKED"]
822
- },
823
- summary: {
824
- type: "string",
825
- description: "What was accomplished this session"
826
- },
827
- blocker: {
828
- type: ["string", "null"],
829
- description: "Brief description if BLOCKED, null otherwise"
830
- }
831
- },
832
- required: ["status", "summary"]
833
- };
834
- async function findStory2(projectPath, storySlug) {
835
- const result = await findStory(projectPath, storySlug);
836
- if (!result.found) {
837
- return null;
688
+ // src/lib/session-polling.ts
689
+ var POLLING_INTERVAL_MS = 3e3;
690
+ var pollingInterval = null;
691
+ var currentSessions = [];
692
+ var isFirstPoll = true;
693
+ function createSessionMap(sessions) {
694
+ return new Map(sessions.map((s) => [s.name, s]));
695
+ }
696
+ function hasSessionSetChanged(newMap, currentMap) {
697
+ for (const name of newMap.keys()) {
698
+ if (!currentMap.has(name)) {
699
+ return true;
700
+ }
838
701
  }
839
- return {
840
- epicSlug: result.data.epicSlug,
841
- storySlug: result.data.slug,
842
- storyPath: result.data.storyPath,
843
- worktreePath: result.data.worktreePath
844
- };
702
+ for (const name of currentMap.keys()) {
703
+ if (!newMap.has(name)) {
704
+ return true;
705
+ }
706
+ }
707
+ return false;
845
708
  }
846
- function computeStoryPath(worktree, epicSlug, storySlug) {
847
- return (0, import_node_path5.join)(worktree, ".saga", "epics", epicSlug, "stories", storySlug, "story.md");
709
+ function hasSessionPropertiesChanged(newMap, currentMap) {
710
+ for (const [name, newSession] of newMap) {
711
+ const currentSession = currentMap.get(name);
712
+ if (!currentSession) {
713
+ continue;
714
+ }
715
+ if (currentSession.status !== newSession.status) {
716
+ return true;
717
+ }
718
+ if (currentSession.outputPreview !== newSession.outputPreview) {
719
+ return true;
720
+ }
721
+ }
722
+ return false;
848
723
  }
849
- function validateStoryFiles(worktree, epicSlug, storySlug) {
850
- if (!(0, import_node_fs5.existsSync)(worktree)) {
851
- return {
852
- valid: false,
853
- error: `Worktree not found at ${worktree}
854
-
855
- The story worktree has not been created yet. This can happen if:
856
- 1. The story was generated but the worktree wasn't set up
857
- 2. The worktree was deleted or moved
858
-
859
- To create the worktree, use: /task-resume ${storySlug}`
860
- };
724
+ function detectChanges(newSessions) {
725
+ if (isFirstPoll) {
726
+ return true;
861
727
  }
862
- const storyPath = computeStoryPath(worktree, epicSlug, storySlug);
863
- if (!(0, import_node_fs5.existsSync)(storyPath)) {
864
- return {
865
- valid: false,
866
- error: `story.md not found in worktree.
867
-
868
- Expected location: ${storyPath}
869
-
870
- The worktree exists but the story definition file is missing.
871
- This may indicate an incomplete story setup.`
872
- };
728
+ if (newSessions.length !== currentSessions.length) {
729
+ return true;
873
730
  }
874
- return { valid: true };
875
- }
876
- function getSkillRoot(pluginRoot) {
877
- return (0, import_node_path5.join)(pluginRoot, "skills", "execute-story");
731
+ const newSessionMap = createSessionMap(newSessions);
732
+ const currentSessionMap = createSessionMap(currentSessions);
733
+ if (hasSessionSetChanged(newSessionMap, currentSessionMap)) {
734
+ return true;
735
+ }
736
+ return hasSessionPropertiesChanged(newSessionMap, currentSessionMap);
878
737
  }
879
- function checkCommandExists(command) {
738
+ async function buildSessionInfoSafe(sessionName, status) {
880
739
  try {
881
- const result = (0, import_node_child_process2.spawnSync)("which", [command], { encoding: "utf-8" });
882
- if (result.status === 0 && result.stdout.trim()) {
883
- return { exists: true, path: result.stdout.trim() };
884
- }
885
- return { exists: false };
740
+ return await buildSessionInfo(sessionName, status);
886
741
  } catch {
887
- return { exists: false };
742
+ return null;
888
743
  }
889
744
  }
890
- function runDryRun(storyInfo, projectPath, pluginRoot) {
891
- const checks = [];
892
- let allPassed = true;
893
- if (pluginRoot) {
894
- checks.push({
895
- name: "SAGA_PLUGIN_ROOT",
896
- path: pluginRoot,
897
- passed: true
898
- });
899
- } else {
900
- checks.push({
901
- name: "SAGA_PLUGIN_ROOT",
902
- passed: false,
903
- error: "Environment variable not set"
904
- });
905
- allPassed = false;
906
- }
907
- const claudeCheck = checkCommandExists("claude");
908
- checks.push({
909
- name: "claude CLI",
910
- path: claudeCheck.path,
911
- passed: claudeCheck.exists,
912
- error: claudeCheck.exists ? void 0 : "Command not found in PATH"
745
+ async function discoverSessions() {
746
+ const rawSessions = listSessions();
747
+ const sessionPromises = rawSessions.map((session) => {
748
+ const statusResult = getSessionStatus(session.name);
749
+ const status = statusResult.running ? "running" : "completed";
750
+ return buildSessionInfoSafe(session.name, status);
913
751
  });
914
- if (!claudeCheck.exists) allPassed = false;
915
- if (pluginRoot) {
916
- const skillRoot = getSkillRoot(pluginRoot);
917
- const workerPromptPath = (0, import_node_path5.join)(skillRoot, WORKER_PROMPT_RELATIVE);
918
- if ((0, import_node_fs5.existsSync)(workerPromptPath)) {
919
- checks.push({
920
- name: "Worker prompt",
921
- path: workerPromptPath,
922
- passed: true
923
- });
924
- } else {
925
- checks.push({
926
- name: "Worker prompt",
927
- path: workerPromptPath,
928
- passed: false,
929
- error: "File not found"
752
+ const results = await Promise.all(sessionPromises);
753
+ const detailedSessions = results.filter((s) => s !== null);
754
+ detailedSessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
755
+ return detailedSessions;
756
+ }
757
+ async function pollSessions(broadcast) {
758
+ try {
759
+ const sessions = await discoverSessions();
760
+ const hasChanges = detectChanges(sessions);
761
+ if (hasChanges) {
762
+ currentSessions = sessions;
763
+ isFirstPoll = false;
764
+ broadcast({
765
+ type: "sessions:updated",
766
+ data: sessions
930
767
  });
931
- allPassed = false;
932
768
  }
769
+ } catch {
933
770
  }
934
- checks.push({
935
- name: "Story found",
936
- path: `${storyInfo.storySlug} (epic: ${storyInfo.epicSlug})`,
937
- passed: true
938
- });
939
- if ((0, import_node_fs5.existsSync)(storyInfo.worktreePath)) {
940
- checks.push({
941
- name: "Worktree exists",
942
- path: storyInfo.worktreePath,
943
- passed: true
944
- });
945
- } else {
946
- checks.push({
947
- name: "Worktree exists",
948
- path: storyInfo.worktreePath,
949
- passed: false,
950
- error: "Directory not found"
951
- });
952
- allPassed = false;
953
- }
954
- if ((0, import_node_fs5.existsSync)(storyInfo.worktreePath)) {
955
- const storyMdPath = computeStoryPath(
956
- storyInfo.worktreePath,
957
- storyInfo.epicSlug,
958
- storyInfo.storySlug
959
- );
960
- if ((0, import_node_fs5.existsSync)(storyMdPath)) {
961
- checks.push({
962
- name: "story.md in worktree",
963
- path: storyMdPath,
964
- passed: true
965
- });
966
- } else {
967
- checks.push({
968
- name: "story.md in worktree",
969
- path: storyMdPath,
970
- passed: false,
971
- error: "File not found"
972
- });
973
- allPassed = false;
974
- }
771
+ }
772
+ function getCurrentSessions() {
773
+ return [...currentSessions];
774
+ }
775
+ function startSessionPolling(broadcast) {
776
+ stopSessionPolling();
777
+ pollSessions(broadcast);
778
+ pollingInterval = setInterval(() => {
779
+ pollSessions(broadcast);
780
+ }, POLLING_INTERVAL_MS);
781
+ }
782
+ function stopSessionPolling() {
783
+ if (pollingInterval) {
784
+ clearInterval(pollingInterval);
785
+ pollingInterval = null;
975
786
  }
976
- return {
977
- success: allPassed,
978
- checks,
979
- story: {
980
- epicSlug: storyInfo.epicSlug,
981
- storySlug: storyInfo.storySlug,
982
- worktreePath: storyInfo.worktreePath
983
- }
984
- };
787
+ currentSessions = [];
788
+ isFirstPoll = true;
985
789
  }
986
- function printDryRunResults(result) {
987
- console.log("Dry Run Validation");
988
- console.log("==================\n");
989
- for (const check of result.checks) {
990
- const icon = check.passed ? "\u2713" : "\u2717";
991
- const status = check.passed ? "OK" : "FAILED";
992
- if (check.passed) {
993
- console.log(`${icon} ${check.name}: ${check.path || status}`);
994
- } else {
995
- console.log(`${icon} ${check.name}: ${check.error}`);
996
- if (check.path) {
997
- console.log(` Expected: ${check.path}`);
790
+
791
+ // src/server/session-routes.ts
792
+ var HTTP_NOT_FOUND = 404;
793
+ var HTTP_INTERNAL_ERROR = 500;
794
+ function createSessionApiRouter() {
795
+ const router = (0, import_express.Router)();
796
+ router.get("/sessions", (_req, res) => {
797
+ try {
798
+ let sessions = getCurrentSessions();
799
+ const { epicSlug, storySlug, status } = _req.query;
800
+ if (epicSlug && typeof epicSlug === "string") {
801
+ sessions = sessions.filter((s) => s.epicSlug === epicSlug);
802
+ if (storySlug && typeof storySlug === "string") {
803
+ sessions = sessions.filter((s) => s.storySlug === storySlug);
804
+ }
805
+ }
806
+ if (status && typeof status === "string" && (status === "running" || status === "completed")) {
807
+ sessions = sessions.filter((s) => s.status === status);
998
808
  }
809
+ res.json(sessions);
810
+ } catch (_error) {
811
+ res.status(HTTP_INTERNAL_ERROR).json({ error: "Failed to fetch sessions" });
999
812
  }
1000
- }
1001
- console.log("");
1002
- if (result.success) {
1003
- console.log("Dry run complete. All checks passed. Ready to implement.");
1004
- } else {
1005
- console.log("Dry run failed. Please fix the issues above before running implement.");
1006
- }
813
+ });
814
+ router.get("/sessions/:sessionName", (req, res) => {
815
+ try {
816
+ const { sessionName } = req.params;
817
+ const sessions = getCurrentSessions();
818
+ const session = sessions.find((s) => s.name === sessionName);
819
+ if (!session) {
820
+ res.status(HTTP_NOT_FOUND).json({ error: "Session not found" });
821
+ return;
822
+ }
823
+ res.json(session);
824
+ } catch (_error) {
825
+ res.status(HTTP_INTERNAL_ERROR).json({ error: "Failed to fetch session" });
826
+ }
827
+ });
828
+ return router;
1007
829
  }
1008
- function loadWorkerPrompt(pluginRoot) {
1009
- const skillRoot = getSkillRoot(pluginRoot);
1010
- const promptPath = (0, import_node_path5.join)(skillRoot, WORKER_PROMPT_RELATIVE);
1011
- if (!(0, import_node_fs5.existsSync)(promptPath)) {
1012
- throw new Error(`Worker prompt not found at ${promptPath}`);
1013
- }
1014
- return (0, import_node_fs5.readFileSync)(promptPath, "utf-8");
830
+
831
+ // src/server/routes.ts
832
+ var HTTP_NOT_FOUND2 = 404;
833
+ var HTTP_INTERNAL_ERROR2 = 500;
834
+ function getEpics(sagaRoot) {
835
+ return scanSagaDirectory(sagaRoot);
1015
836
  }
1016
- function buildScopeSettings() {
1017
- const hookCommand = "npx @saga-ai/cli scope-validator";
837
+ function toEpicSummary(epic) {
1018
838
  return {
1019
- hooks: {
1020
- PreToolUse: [
1021
- {
1022
- matcher: "Read|Write|Edit|Glob|Grep",
1023
- hooks: [hookCommand]
1024
- }
1025
- ]
1026
- }
839
+ slug: epic.slug,
840
+ title: epic.title,
841
+ storyCounts: epic.storyCounts,
842
+ path: epic.path
1027
843
  };
1028
844
  }
1029
- function formatStreamLine(line) {
1030
- try {
1031
- const data = JSON.parse(line);
1032
- if (data.type === "assistant" && data.message?.content) {
1033
- for (const block of data.message.content) {
1034
- if (block.type === "text" && block.text) {
1035
- return block.text;
1036
- }
1037
- if (block.type === "tool_use") {
1038
- return `[Tool: ${block.name}]`;
1039
- }
1040
- }
1041
- }
1042
- if (data.type === "system" && data.subtype === "init") {
1043
- return `[Session started: ${data.session_id}]`;
1044
- }
1045
- if (data.type === "result") {
1046
- const status = data.subtype === "success" ? "completed" : "failed";
1047
- return `
1048
- [Worker ${status} in ${Math.round(data.duration_ms / 1e3)}s]`;
845
+ function registerEpicsRoutes(router, sagaRoot) {
846
+ router.get("/epics", async (_req, res) => {
847
+ try {
848
+ const epics = await getEpics(sagaRoot);
849
+ const summaries = epics.map(toEpicSummary);
850
+ res.json(summaries);
851
+ } catch (_error) {
852
+ res.status(HTTP_INTERNAL_ERROR2).json({ error: "Failed to fetch epics" });
1049
853
  }
1050
- return null;
1051
- } catch {
1052
- return null;
1053
- }
1054
- }
1055
- function extractStructuredOutputFromToolCall(lines) {
1056
- for (let i = lines.length - 1; i >= 0; i--) {
854
+ });
855
+ router.get("/epics/:slug", async (req, res) => {
1057
856
  try {
1058
- const data = JSON.parse(lines[i]);
1059
- if (data.type === "assistant" && data.message?.content) {
1060
- for (const block of data.message.content) {
1061
- if (block.type === "tool_use" && block.name === "StructuredOutput") {
1062
- return block.input;
1063
- }
1064
- }
857
+ const { slug } = req.params;
858
+ const epics = await getEpics(sagaRoot);
859
+ const epic = epics.find((e) => e.slug === slug);
860
+ if (!epic) {
861
+ res.status(HTTP_NOT_FOUND2).json({ error: `Epic not found: ${slug}` });
862
+ return;
1065
863
  }
1066
- } catch {
864
+ res.json(epic);
865
+ } catch (_error) {
866
+ res.status(HTTP_INTERNAL_ERROR2).json({ error: "Failed to fetch epic" });
1067
867
  }
1068
- }
1069
- return null;
868
+ });
1070
869
  }
1071
- function parseStreamingResult(buffer) {
1072
- const lines = buffer.split("\n").filter((line) => line.trim());
1073
- for (let i = lines.length - 1; i >= 0; i--) {
870
+ function registerStoriesRoutes(router, sagaRoot) {
871
+ router.get("/stories/:epicSlug/:storySlug", async (req, res) => {
1074
872
  try {
1075
- const data = JSON.parse(lines[i]);
1076
- if (data.type === "result") {
1077
- if (data.is_error) {
1078
- throw new Error(`Worker failed: ${data.result || "Unknown error"}`);
1079
- }
1080
- let output = data.structured_output;
1081
- if (!output) {
1082
- output = extractStructuredOutputFromToolCall(lines);
1083
- }
1084
- if (!output) {
1085
- throw new Error("Worker result missing structured_output");
1086
- }
1087
- if (!VALID_STATUSES.has(output.status)) {
1088
- throw new Error(`Invalid status: ${output.status}`);
1089
- }
1090
- return {
1091
- status: output.status,
1092
- summary: output.summary || "",
1093
- blocker: output.blocker ?? null
1094
- };
1095
- }
1096
- } catch (e) {
1097
- if (e instanceof Error && e.message.startsWith("Worker")) {
1098
- throw e;
873
+ const { epicSlug, storySlug } = req.params;
874
+ const epics = await getEpics(sagaRoot);
875
+ const epic = epics.find((e) => e.slug === epicSlug);
876
+ if (!epic) {
877
+ res.status(HTTP_NOT_FOUND2).json({ error: `Epic not found: ${epicSlug}` });
878
+ return;
879
+ }
880
+ const story = epic.stories.find((s) => s.slug === storySlug);
881
+ if (!story) {
882
+ res.status(HTTP_NOT_FOUND2).json({ error: `Story not found: ${storySlug}` });
883
+ return;
884
+ }
885
+ if (story.paths.journalMd) {
886
+ const journalPath = (0, import_node_path4.join)(sagaRoot, story.paths.journalMd);
887
+ const journal = await parseJournal(journalPath);
888
+ if (journal.length > 0) {
889
+ story.journal = journal;
890
+ }
1099
891
  }
892
+ res.json(story);
893
+ } catch (_error) {
894
+ res.status(HTTP_INTERNAL_ERROR2).json({ error: "Failed to fetch story" });
1100
895
  }
1101
- }
1102
- throw new Error("No result found in worker output");
896
+ });
1103
897
  }
1104
- function spawnWorkerAsync(prompt, model, settings, workingDir) {
1105
- return new Promise((resolve2, reject) => {
1106
- let buffer = "";
1107
- const args = [
1108
- "-p",
1109
- prompt,
1110
- "--model",
1111
- model,
1112
- "--output-format",
1113
- "stream-json",
1114
- "--verbose",
1115
- "--json-schema",
1116
- JSON.stringify(WORKER_OUTPUT_SCHEMA),
1117
- "--settings",
1118
- JSON.stringify(settings),
1119
- "--dangerously-skip-permissions"
1120
- ];
1121
- const child = (0, import_node_child_process2.spawn)("claude", args, {
1122
- cwd: workingDir,
1123
- stdio: ["ignore", "pipe", "pipe"]
898
+ function createApiRouter(sagaRoot) {
899
+ const router = (0, import_express2.Router)();
900
+ registerEpicsRoutes(router, sagaRoot);
901
+ registerStoriesRoutes(router, sagaRoot);
902
+ router.use(createSessionApiRouter());
903
+ router.use((_req, res) => {
904
+ res.status(HTTP_NOT_FOUND2).json({ error: "API endpoint not found" });
905
+ });
906
+ return router;
907
+ }
908
+
909
+ // src/server/websocket.ts
910
+ var import_node_path7 = require("node:path");
911
+ var import_ws = require("ws");
912
+
913
+ // src/lib/log-stream-manager.ts
914
+ var import_node_fs3 = require("node:fs");
915
+ var import_promises4 = require("node:fs/promises");
916
+ var import_node_path5 = require("node:path");
917
+ var import_chokidar = __toESM(require("chokidar"), 1);
918
+ var LogStreamManager = class {
919
+ /**
920
+ * Active file watchers indexed by session name
921
+ */
922
+ watchers = /* @__PURE__ */ new Map();
923
+ /**
924
+ * Current file position (byte offset) per session for incremental reads
925
+ */
926
+ filePositions = /* @__PURE__ */ new Map();
927
+ /**
928
+ * Client subscriptions per session
929
+ */
930
+ subscriptions = /* @__PURE__ */ new Map();
931
+ /**
932
+ * Function to send messages to clients
933
+ */
934
+ sendToClient;
935
+ /**
936
+ * Create a new LogStreamManager instance
937
+ *
938
+ * @param sendToClient - Function to send log data messages to clients
939
+ */
940
+ constructor(sendToClient2) {
941
+ this.sendToClient = sendToClient2;
942
+ }
943
+ /**
944
+ * Get the number of subscriptions for a session
945
+ *
946
+ * @param sessionName - The session to check
947
+ * @returns Number of subscribed clients
948
+ */
949
+ getSubscriptionCount(sessionName) {
950
+ const subs = this.subscriptions.get(sessionName);
951
+ return subs ? subs.size : 0;
952
+ }
953
+ /**
954
+ * Check if a watcher exists for a session
955
+ *
956
+ * @param sessionName - The session to check
957
+ * @returns True if a watcher exists
958
+ */
959
+ hasWatcher(sessionName) {
960
+ return this.watchers.has(sessionName);
961
+ }
962
+ /**
963
+ * Get the current file position for a session
964
+ *
965
+ * @param sessionName - The session to check
966
+ * @returns The current byte offset, or 0 if not tracked
967
+ */
968
+ getFilePosition(sessionName) {
969
+ return this.filePositions.get(sessionName) ?? 0;
970
+ }
971
+ /**
972
+ * Subscribe a client to a session's log stream
973
+ *
974
+ * Reads the full file content and sends it as the initial message.
975
+ * Adds the client to the subscription set for incremental updates.
976
+ * Creates a file watcher if this is the first subscriber.
977
+ *
978
+ * @param sessionName - The session to subscribe to
979
+ * @param ws - The WebSocket client to subscribe
980
+ */
981
+ async subscribe(sessionName, ws) {
982
+ const outputFile = (0, import_node_path5.join)(OUTPUT_DIR, `${sessionName}.out`);
983
+ if (!(0, import_node_fs3.existsSync)(outputFile)) {
984
+ this.sendToClient(ws, {
985
+ type: "logs:error",
986
+ sessionName,
987
+ error: `Output file not found: ${outputFile}`
988
+ });
989
+ return;
990
+ }
991
+ const content = await (0, import_promises4.readFile)(outputFile, "utf-8");
992
+ this.sendToClient(ws, {
993
+ type: "logs:data",
994
+ sessionName,
995
+ data: content,
996
+ isInitial: true,
997
+ isComplete: false
1124
998
  });
1125
- child.stdout.on("data", (chunk) => {
1126
- const text = chunk.toString();
1127
- buffer += text;
1128
- const lines = text.split("\n");
1129
- for (const line of lines) {
1130
- if (line.trim()) {
1131
- const formatted = formatStreamLine(line);
1132
- if (formatted) {
1133
- process.stdout.write(formatted);
1134
- }
1135
- }
1136
- }
999
+ this.filePositions.set(sessionName, content.length);
1000
+ let subs = this.subscriptions.get(sessionName);
1001
+ if (!subs) {
1002
+ subs = /* @__PURE__ */ new Set();
1003
+ this.subscriptions.set(sessionName, subs);
1004
+ }
1005
+ subs.add(ws);
1006
+ if (!this.watchers.has(sessionName)) {
1007
+ this.createWatcher(sessionName, outputFile);
1008
+ }
1009
+ }
1010
+ /**
1011
+ * Create a chokidar file watcher for a session's output file
1012
+ *
1013
+ * The watcher detects changes and triggers incremental content delivery
1014
+ * to all subscribed clients.
1015
+ *
1016
+ * @param sessionName - The session name
1017
+ * @param outputFile - Path to the session output file
1018
+ */
1019
+ createWatcher(sessionName, outputFile) {
1020
+ const watcher = import_chokidar.default.watch(outputFile, {
1021
+ persistent: true,
1022
+ awaitWriteFinish: false
1137
1023
  });
1138
- child.stderr.on("data", (chunk) => {
1139
- process.stderr.write(chunk);
1024
+ watcher.on("change", async () => {
1025
+ await this.sendIncrementalContent(sessionName, outputFile);
1140
1026
  });
1141
- child.on("error", (err) => {
1142
- reject(new Error(`Failed to spawn worker: ${err.message}`));
1027
+ this.watchers.set(sessionName, watcher);
1028
+ }
1029
+ /**
1030
+ * Clean up a watcher and associated state for a session
1031
+ *
1032
+ * Closes the file watcher and removes all tracking state for the session.
1033
+ * Should be called when the last subscriber unsubscribes or disconnects.
1034
+ *
1035
+ * @param sessionName - The session to clean up
1036
+ */
1037
+ cleanupWatcher(sessionName) {
1038
+ const watcher = this.watchers.get(sessionName);
1039
+ if (watcher) {
1040
+ watcher.close();
1041
+ this.watchers.delete(sessionName);
1042
+ }
1043
+ this.filePositions.delete(sessionName);
1044
+ this.subscriptions.delete(sessionName);
1045
+ }
1046
+ /**
1047
+ * Send incremental content to all subscribed clients for a session
1048
+ *
1049
+ * Reads from the last known position to the end of the file and sends
1050
+ * the new content to all subscribed clients.
1051
+ *
1052
+ * @param sessionName - The session name
1053
+ * @param outputFile - Path to the session output file
1054
+ */
1055
+ async sendIncrementalContent(sessionName, outputFile) {
1056
+ const lastPosition = this.filePositions.get(sessionName) ?? 0;
1057
+ const fileStat = await (0, import_promises4.stat)(outputFile);
1058
+ const currentSize = fileStat.size;
1059
+ if (currentSize <= lastPosition) {
1060
+ return;
1061
+ }
1062
+ const newContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
1063
+ this.filePositions.set(sessionName, currentSize);
1064
+ const subs = this.subscriptions.get(sessionName);
1065
+ if (subs) {
1066
+ const message = {
1067
+ type: "logs:data",
1068
+ sessionName,
1069
+ data: newContent,
1070
+ isInitial: false,
1071
+ isComplete: false
1072
+ };
1073
+ for (const ws of subs) {
1074
+ this.sendToClient(ws, message);
1075
+ }
1076
+ }
1077
+ }
1078
+ /**
1079
+ * Read file content from a specific position
1080
+ *
1081
+ * @param filePath - Path to the file
1082
+ * @param start - Starting byte position
1083
+ * @param end - Ending byte position
1084
+ * @returns The content read from the file
1085
+ */
1086
+ readFromPosition(filePath, start, end) {
1087
+ return new Promise((resolve2, reject) => {
1088
+ let content = "";
1089
+ const stream = (0, import_node_fs3.createReadStream)(filePath, {
1090
+ start,
1091
+ end: end - 1,
1092
+ // createReadStream end is inclusive
1093
+ encoding: "utf-8"
1094
+ });
1095
+ stream.on("data", (chunk) => {
1096
+ content += chunk;
1097
+ });
1098
+ stream.on("end", () => {
1099
+ resolve2(content);
1100
+ });
1101
+ stream.on("error", reject);
1143
1102
  });
1144
- child.on("close", (code) => {
1145
- process.stdout.write("\n");
1146
- try {
1147
- const result = parseStreamingResult(buffer);
1148
- resolve2(result);
1149
- } catch (e) {
1150
- reject(e);
1103
+ }
1104
+ /**
1105
+ * Unsubscribe a client from a session's log stream
1106
+ *
1107
+ * Removes the client from the subscription set. If this was the last
1108
+ * subscriber, cleans up the watcher and associated state.
1109
+ *
1110
+ * @param sessionName - The session to unsubscribe from
1111
+ * @param ws - The WebSocket client to unsubscribe
1112
+ */
1113
+ unsubscribe(sessionName, ws) {
1114
+ const subs = this.subscriptions.get(sessionName);
1115
+ if (subs) {
1116
+ subs.delete(ws);
1117
+ if (subs.size === 0) {
1118
+ this.cleanupWatcher(sessionName);
1119
+ }
1120
+ }
1121
+ }
1122
+ /**
1123
+ * Handle client disconnect by removing from all subscriptions
1124
+ *
1125
+ * Should be called when a WebSocket connection closes to clean up
1126
+ * any subscriptions the client may have had. Also triggers watcher
1127
+ * cleanup for any sessions that no longer have subscribers.
1128
+ *
1129
+ * @param ws - The WebSocket client that disconnected
1130
+ */
1131
+ handleClientDisconnect(ws) {
1132
+ for (const [sessionName, subs] of this.subscriptions) {
1133
+ subs.delete(ws);
1134
+ if (subs.size === 0) {
1135
+ this.cleanupWatcher(sessionName);
1136
+ }
1137
+ }
1138
+ }
1139
+ /**
1140
+ * Notify that a session has completed
1141
+ *
1142
+ * Reads any remaining content from the file and sends it with isComplete=true
1143
+ * to all subscribed clients, then cleans up the watcher regardless of
1144
+ * subscription count. Called by session polling when it detects completion.
1145
+ *
1146
+ * @param sessionName - The session that has completed
1147
+ */
1148
+ async notifySessionCompleted(sessionName) {
1149
+ const subs = this.subscriptions.get(sessionName);
1150
+ if (!subs || subs.size === 0) {
1151
+ return;
1152
+ }
1153
+ const outputFile = (0, import_node_path5.join)(OUTPUT_DIR, `${sessionName}.out`);
1154
+ let finalContent = "";
1155
+ try {
1156
+ if ((0, import_node_fs3.existsSync)(outputFile)) {
1157
+ const lastPosition = this.filePositions.get(sessionName) ?? 0;
1158
+ const fileStat = await (0, import_promises4.stat)(outputFile);
1159
+ const currentSize = fileStat.size;
1160
+ if (currentSize > lastPosition) {
1161
+ finalContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
1162
+ }
1151
1163
  }
1152
- });
1153
- });
1154
- }
1155
- async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot) {
1156
- const worktree = (0, import_node_path5.join)(projectDir, ".saga", "worktrees", epicSlug, storySlug);
1157
- const validation = validateStoryFiles(worktree, epicSlug, storySlug);
1158
- if (!validation.valid) {
1159
- return {
1160
- status: "ERROR",
1161
- summary: validation.error || "Story validation failed",
1162
- cycles: 0,
1163
- elapsedMinutes: 0,
1164
- blocker: null,
1165
- epicSlug,
1166
- storySlug
1164
+ } catch {
1165
+ }
1166
+ const message = {
1167
+ type: "logs:data",
1168
+ sessionName,
1169
+ data: finalContent,
1170
+ isInitial: false,
1171
+ isComplete: true
1167
1172
  };
1173
+ for (const ws of subs) {
1174
+ this.sendToClient(ws, message);
1175
+ }
1176
+ this.cleanupWatcher(sessionName);
1168
1177
  }
1169
- let workerPrompt;
1170
- try {
1171
- workerPrompt = loadWorkerPrompt(pluginRoot);
1172
- } catch (e) {
1178
+ /**
1179
+ * Clean up all watchers and subscriptions
1180
+ *
1181
+ * Call this when shutting down the server.
1182
+ */
1183
+ async dispose() {
1184
+ const closePromises = [];
1185
+ for (const [, watcher] of this.watchers) {
1186
+ closePromises.push(watcher.close());
1187
+ }
1188
+ await Promise.all(closePromises);
1189
+ this.watchers.clear();
1190
+ this.filePositions.clear();
1191
+ this.subscriptions.clear();
1192
+ }
1193
+ };
1194
+
1195
+ // src/server/watcher.ts
1196
+ var import_node_events = require("node:events");
1197
+ var import_node_path6 = require("node:path");
1198
+ var import_chokidar2 = __toESM(require("chokidar"), 1);
1199
+ var MIN_PATH_PARTS = 4;
1200
+ var ARCHIVE_STORY_PARTS = 5;
1201
+ var EPIC_FILE_PARTS = 4;
1202
+ var STORY_FILE_PARTS = 6;
1203
+ var DEBOUNCE_DELAY_MS = 100;
1204
+ function isStoryMarkdownFile(fileName) {
1205
+ return fileName === "story.md" || fileName === "journal.md";
1206
+ }
1207
+ function parseArchivePath(parts, epicSlug) {
1208
+ if (parts.length >= ARCHIVE_STORY_PARTS) {
1209
+ const storySlug = parts[3];
1210
+ const fileName = parts[4];
1211
+ if (isStoryMarkdownFile(fileName)) {
1212
+ return {
1213
+ epicSlug,
1214
+ storySlug,
1215
+ archived: true,
1216
+ isEpicFile: false,
1217
+ isStoryFile: true,
1218
+ isMainStoryFile: fileName === "story.md"
1219
+ };
1220
+ }
1221
+ }
1222
+ return null;
1223
+ }
1224
+ function parseEpicsPath(parts, epicSlug) {
1225
+ if (parts.length === EPIC_FILE_PARTS && parts[3] === "epic.md") {
1173
1226
  return {
1174
- status: "ERROR",
1175
- summary: e.message,
1176
- cycles: 0,
1177
- elapsedMinutes: 0,
1178
- blocker: null,
1179
1227
  epicSlug,
1180
- storySlug
1228
+ archived: false,
1229
+ isEpicFile: true,
1230
+ isStoryFile: false,
1231
+ isMainStoryFile: false
1181
1232
  };
1182
1233
  }
1183
- const settings = buildScopeSettings();
1184
- const startTime = Date.now();
1185
- let cycles = 0;
1186
- const summaries = [];
1187
- let lastBlocker = null;
1188
- let finalStatus = null;
1189
- const maxTimeMs = maxTime * 60 * 1e3;
1190
- while (cycles < maxCycles) {
1191
- const elapsedMs = Date.now() - startTime;
1192
- if (elapsedMs >= maxTimeMs) {
1193
- finalStatus = "TIMEOUT";
1194
- break;
1195
- }
1196
- cycles += 1;
1197
- let parsed;
1198
- console.log(`
1199
- --- Worker ${cycles} started ---
1200
- `);
1201
- try {
1202
- parsed = await spawnWorkerAsync(workerPrompt, model, settings, worktree);
1203
- } catch (e) {
1234
+ if (parts.length >= STORY_FILE_PARTS && parts[3] === "stories") {
1235
+ const storySlug = parts[4];
1236
+ const fileName = parts[5];
1237
+ if (isStoryMarkdownFile(fileName)) {
1204
1238
  return {
1205
- status: "ERROR",
1206
- summary: e.message,
1207
- cycles,
1208
- elapsedMinutes: (Date.now() - startTime) / 6e4,
1209
- blocker: null,
1210
1239
  epicSlug,
1211
- storySlug
1240
+ storySlug,
1241
+ archived: false,
1242
+ isEpicFile: false,
1243
+ isStoryFile: true,
1244
+ isMainStoryFile: fileName === "story.md"
1212
1245
  };
1213
1246
  }
1214
- console.log(`
1215
- --- Worker ${cycles} result: ${parsed.status} ---
1216
- `);
1217
- summaries.push(parsed.summary);
1218
- if (parsed.status === "FINISH") {
1219
- finalStatus = "FINISH";
1220
- break;
1221
- } else if (parsed.status === "BLOCKED") {
1222
- finalStatus = "BLOCKED";
1223
- lastBlocker = parsed.blocker || null;
1224
- break;
1225
- }
1226
1247
  }
1227
- if (finalStatus === null) {
1228
- finalStatus = "MAX_CYCLES";
1248
+ return null;
1249
+ }
1250
+ function parseFilePath(filePath, sagaRoot) {
1251
+ const relativePath = (0, import_node_path6.relative)(sagaRoot, filePath);
1252
+ const parts = relativePath.split(import_node_path6.sep);
1253
+ if (parts[0] !== ".saga" || parts.length < MIN_PATH_PARTS) {
1254
+ return null;
1229
1255
  }
1230
- const elapsedMinutes = (Date.now() - startTime) / 6e4;
1231
- const combinedSummary = summaries.length === 1 ? summaries[0] : summaries.join(" | ");
1256
+ const epicSlug = parts[2];
1257
+ const isArchive = parts[1] === "archive";
1258
+ const isEpics = parts[1] === "epics";
1259
+ if (isArchive) {
1260
+ return parseArchivePath(parts, epicSlug);
1261
+ }
1262
+ if (isEpics) {
1263
+ return parseEpicsPath(parts, epicSlug);
1264
+ }
1265
+ return null;
1266
+ }
1267
+ function createDebouncer(delayMs) {
1268
+ const pending = /* @__PURE__ */ new Map();
1232
1269
  return {
1233
- status: finalStatus,
1234
- summary: combinedSummary,
1235
- cycles,
1236
- elapsedMinutes: Math.round(elapsedMinutes * 100) / 100,
1237
- blocker: lastBlocker,
1238
- epicSlug,
1239
- storySlug
1270
+ schedule(key, data, callback) {
1271
+ const existing = pending.get(key);
1272
+ if (existing) {
1273
+ clearTimeout(existing.timer);
1274
+ }
1275
+ const timer = setTimeout(() => {
1276
+ pending.delete(key);
1277
+ callback(data);
1278
+ }, delayMs);
1279
+ pending.set(key, { timer, data });
1280
+ },
1281
+ clear() {
1282
+ for (const { timer } of pending.values()) {
1283
+ clearTimeout(timer);
1284
+ }
1285
+ pending.clear();
1286
+ }
1240
1287
  };
1241
1288
  }
1242
- function buildDetachedCommand(storySlug, projectPath, options) {
1243
- const parts = ["saga", "implement", storySlug];
1244
- parts.push("--path", projectPath);
1245
- if (options.maxCycles !== void 0) {
1246
- parts.push("--max-cycles", String(options.maxCycles));
1247
- }
1248
- if (options.maxTime !== void 0) {
1249
- parts.push("--max-time", String(options.maxTime));
1289
+ function getEpicEventType(eventType) {
1290
+ if (eventType === "add") {
1291
+ return "epic:added";
1250
1292
  }
1251
- if (options.model !== void 0) {
1252
- parts.push("--model", options.model);
1293
+ if (eventType === "unlink") {
1294
+ return "epic:removed";
1253
1295
  }
1254
- return shellEscapeArgs(parts);
1296
+ return "epic:changed";
1255
1297
  }
1256
- async function implementCommand(storySlug, options) {
1257
- let projectPath;
1258
- try {
1259
- projectPath = resolveProjectPath(options.path);
1260
- } catch (error) {
1261
- console.error(`Error: ${error.message}`);
1262
- process.exit(1);
1298
+ function getStoryEventType(eventType, isMainStoryFile) {
1299
+ if (!isMainStoryFile) {
1300
+ return "story:changed";
1263
1301
  }
1264
- const storyInfo = await findStory2(projectPath, storySlug);
1265
- if (!storyInfo) {
1266
- console.error(`Error: Story '${storySlug}' not found in SAGA project.`);
1267
- console.error(`
1268
- Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`);
1269
- console.error("\nMake sure the story worktree exists and has a story.md file.");
1270
- console.error("Run /generate-stories to create story worktrees.");
1271
- process.exit(1);
1272
- }
1273
- const pluginRoot = process.env.SAGA_PLUGIN_ROOT;
1274
- if (options.dryRun) {
1275
- const dryRunResult = runDryRun(storyInfo, projectPath, pluginRoot);
1276
- printDryRunResults(dryRunResult);
1277
- process.exit(dryRunResult.success ? 0 : 1);
1302
+ if (eventType === "add") {
1303
+ return "story:added";
1278
1304
  }
1279
- if (!(0, import_node_fs5.existsSync)(storyInfo.worktreePath)) {
1280
- console.error(`Error: Worktree not found at ${storyInfo.worktreePath}`);
1281
- console.error("\nThe story worktree has not been created yet.");
1282
- console.error("Make sure the story was properly generated with /generate-stories.");
1283
- process.exit(1);
1305
+ if (eventType === "unlink") {
1306
+ return "story:removed";
1284
1307
  }
1285
- if (!pluginRoot) {
1286
- console.error("Error: SAGA_PLUGIN_ROOT environment variable is not set.");
1287
- console.error("\nThis variable is required for the implementation script.");
1288
- console.error("When running via the /implement skill, this is set automatically.");
1289
- console.error("\nIf running manually, set it to the plugin root directory:");
1290
- console.error(" export SAGA_PLUGIN_ROOT=/path/to/plugin");
1291
- process.exit(1);
1308
+ return "story:changed";
1309
+ }
1310
+ function determineEventType(eventType, parsed) {
1311
+ if (parsed.isEpicFile) {
1312
+ return getEpicEventType(eventType);
1292
1313
  }
1293
- const maxCycles = options.maxCycles ?? DEFAULT_MAX_CYCLES;
1294
- const maxTime = options.maxTime ?? DEFAULT_MAX_TIME;
1295
- const model = options.model ?? DEFAULT_MODEL;
1296
- const isInternalSession = process.env.SAGA_INTERNAL_SESSION === "1";
1297
- if (!isInternalSession) {
1298
- const detachedCommand = buildDetachedCommand(storySlug, projectPath, {
1299
- maxCycles: options.maxCycles,
1300
- maxTime: options.maxTime,
1301
- model: options.model
1302
- });
1303
- try {
1304
- const sessionResult = await createSession(
1305
- storyInfo.epicSlug,
1306
- storyInfo.storySlug,
1307
- detachedCommand
1308
- );
1309
- console.log(JSON.stringify({
1310
- mode: "detached",
1311
- sessionName: sessionResult.sessionName,
1312
- outputFile: sessionResult.outputFile,
1313
- epicSlug: storyInfo.epicSlug,
1314
- storySlug: storyInfo.storySlug,
1315
- worktreePath: storyInfo.worktreePath
1316
- }, null, 2));
1314
+ if (parsed.isStoryFile) {
1315
+ return getStoryEventType(eventType, parsed.isMainStoryFile);
1316
+ }
1317
+ return null;
1318
+ }
1319
+ function createDebounceKey(parsed) {
1320
+ const { epicSlug, storySlug, archived } = parsed;
1321
+ return storySlug ? `story:${epicSlug}:${storySlug}:${archived}` : `epic:${epicSlug}`;
1322
+ }
1323
+ function createChokidarWatcher(sagaRoot) {
1324
+ const epicsDir = (0, import_node_path6.join)(sagaRoot, ".saga", "epics");
1325
+ const archiveDir = (0, import_node_path6.join)(sagaRoot, ".saga", "archive");
1326
+ return import_chokidar2.default.watch([epicsDir, archiveDir], {
1327
+ persistent: true,
1328
+ ignoreInitial: true,
1329
+ usePolling: true,
1330
+ interval: DEBOUNCE_DELAY_MS
1331
+ });
1332
+ }
1333
+ async function createSagaWatcher(sagaRoot) {
1334
+ const emitter = new import_node_events.EventEmitter();
1335
+ const debouncer = createDebouncer(DEBOUNCE_DELAY_MS);
1336
+ const watcher = createChokidarWatcher(sagaRoot);
1337
+ let closed = false;
1338
+ let ready = false;
1339
+ const handleFileEvent = (eventType, filePath) => {
1340
+ if (closed || !ready) {
1341
+ return;
1342
+ }
1343
+ const parsed = parseFilePath(filePath, sagaRoot);
1344
+ if (!parsed) {
1345
+ return;
1346
+ }
1347
+ const watcherEventType = determineEventType(eventType, parsed);
1348
+ if (!watcherEventType) {
1317
1349
  return;
1318
- } catch (error) {
1319
- console.error(`Error: Failed to create detached session: ${error.message}`);
1320
- process.exit(1);
1321
1350
  }
1351
+ const event = {
1352
+ type: watcherEventType,
1353
+ epicSlug: parsed.epicSlug,
1354
+ storySlug: parsed.storySlug,
1355
+ archived: parsed.archived,
1356
+ path: (0, import_node_path6.relative)(sagaRoot, filePath)
1357
+ };
1358
+ debouncer.schedule(createDebounceKey(parsed), event, (e) => {
1359
+ if (!closed) {
1360
+ emitter.emit(e.type, e);
1361
+ }
1362
+ });
1363
+ };
1364
+ watcher.on("add", (path) => handleFileEvent("add", path));
1365
+ watcher.on("change", (path) => handleFileEvent("change", path));
1366
+ watcher.on("unlink", (path) => handleFileEvent("unlink", path));
1367
+ watcher.on("error", (error) => {
1368
+ if (!closed) {
1369
+ emitter.emit("error", error);
1370
+ }
1371
+ });
1372
+ await new Promise((resolve2) => {
1373
+ watcher.on("ready", () => {
1374
+ ready = true;
1375
+ resolve2();
1376
+ });
1377
+ });
1378
+ return {
1379
+ on(event, listener) {
1380
+ emitter.on(event, listener);
1381
+ return this;
1382
+ },
1383
+ async close() {
1384
+ closed = true;
1385
+ debouncer.clear();
1386
+ await watcher.close();
1387
+ }
1388
+ };
1389
+ }
1390
+
1391
+ // src/server/websocket.ts
1392
+ var HEARTBEAT_INTERVAL_MS = 3e4;
1393
+ function makeStoryKey(epicSlug, storySlug) {
1394
+ return `${epicSlug}:${storySlug}`;
1395
+ }
1396
+ function toEpicSummary2(epic) {
1397
+ return {
1398
+ slug: epic.slug,
1399
+ title: epic.title,
1400
+ storyCounts: epic.storyCounts,
1401
+ path: epic.path
1402
+ };
1403
+ }
1404
+ function sendToClient(ws, message) {
1405
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
1406
+ ws.send(JSON.stringify(message));
1322
1407
  }
1323
- console.log("Starting story implementation...");
1324
- console.log(` Epic: ${storyInfo.epicSlug}`);
1325
- console.log(` Story: ${storyInfo.storySlug}`);
1326
- console.log(` Worktree: ${storyInfo.worktreePath}`);
1327
- console.log("");
1328
- const result = await runLoop(
1329
- storyInfo.epicSlug,
1330
- storyInfo.storySlug,
1331
- maxCycles,
1332
- maxTime,
1333
- model,
1334
- projectPath,
1335
- pluginRoot
1336
- );
1337
- console.log(JSON.stringify(result, null, 2));
1338
- if (result.status === "ERROR") {
1339
- process.exit(1);
1408
+ }
1409
+ function sendLogMessage(ws, message) {
1410
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
1411
+ ws.send(JSON.stringify({ event: message.type, data: message }));
1340
1412
  }
1341
1413
  }
1342
-
1343
- // src/server/index.ts
1344
- var import_express3 = __toESM(require("express"), 1);
1345
- var import_http = require("http");
1346
- var import_path6 = require("path");
1347
-
1348
- // src/server/routes.ts
1349
- var import_express2 = require("express");
1350
-
1351
- // src/server/parser.ts
1352
- var import_promises3 = require("fs/promises");
1353
- var import_path2 = require("path");
1354
- var import_gray_matter2 = __toESM(require("gray-matter"), 1);
1355
- async function toStoryDetail(story, sagaRoot) {
1356
- let tasks = [];
1357
- try {
1358
- const content = await (0, import_promises3.readFile)(story.storyPath, "utf-8");
1359
- const parsed = (0, import_gray_matter2.default)(content);
1360
- tasks = parseTasks(parsed.data.tasks);
1361
- } catch {
1362
- tasks = parseTasks(story.frontmatter.tasks);
1414
+ function handleStorySubscription(state, data, subscribe) {
1415
+ const { epicSlug, storySlug } = data || {};
1416
+ if (epicSlug && storySlug) {
1417
+ const key = makeStoryKey(epicSlug, storySlug);
1418
+ if (subscribe) {
1419
+ state.subscribedStories.add(key);
1420
+ } else {
1421
+ state.subscribedStories.delete(key);
1422
+ }
1363
1423
  }
1364
- return {
1365
- slug: story.slug,
1366
- epicSlug: story.epicSlug,
1367
- title: story.title,
1368
- status: validateStatus(story.status),
1369
- tasks,
1370
- archived: story.archived,
1371
- paths: {
1372
- storyMd: (0, import_path2.relative)(sagaRoot, story.storyPath),
1373
- ...story.journalPath ? { journalMd: (0, import_path2.relative)(sagaRoot, story.journalPath) } : {},
1374
- ...story.worktreePath ? { worktree: (0, import_path2.relative)(sagaRoot, story.worktreePath) } : {}
1424
+ }
1425
+ function handleLogsSubscription(logStreamManager, ws, data, subscribe) {
1426
+ const { sessionName } = data || {};
1427
+ if (sessionName) {
1428
+ if (subscribe) {
1429
+ logStreamManager.subscribe(sessionName, ws);
1430
+ } else {
1431
+ logStreamManager.unsubscribe(sessionName, ws);
1375
1432
  }
1376
- };
1433
+ }
1377
1434
  }
1378
- function validateStatus(status) {
1379
- const validStatuses = ["ready", "in_progress", "blocked", "completed"];
1380
- if (typeof status === "string" && validStatuses.includes(status)) {
1381
- return status;
1435
+ function processClientMessage(message, state, logStreamManager) {
1436
+ switch (message.event) {
1437
+ case "subscribe:story":
1438
+ handleStorySubscription(state, message.data, true);
1439
+ break;
1440
+ case "unsubscribe:story":
1441
+ handleStorySubscription(state, message.data, false);
1442
+ break;
1443
+ case "subscribe:logs":
1444
+ handleLogsSubscription(logStreamManager, state.ws, message.data, true);
1445
+ break;
1446
+ case "unsubscribe:logs":
1447
+ handleLogsSubscription(logStreamManager, state.ws, message.data, false);
1448
+ break;
1449
+ default:
1450
+ break;
1382
1451
  }
1383
- return "ready";
1384
1452
  }
1385
- function parseTasks(tasks) {
1386
- if (!Array.isArray(tasks)) {
1387
- return [];
1453
+ function hasSubscribers(clients, storyKey) {
1454
+ for (const [, state] of clients) {
1455
+ if (state.subscribedStories.has(storyKey)) {
1456
+ return true;
1457
+ }
1388
1458
  }
1389
- return tasks.filter((t) => typeof t === "object" && t !== null).map((t) => ({
1390
- id: typeof t.id === "string" ? t.id : "unknown",
1391
- title: typeof t.title === "string" ? t.title : "Unknown Task",
1392
- status: validateTaskStatus(t.status)
1393
- }));
1459
+ return false;
1394
1460
  }
1395
- function validateTaskStatus(status) {
1396
- const validStatuses = ["pending", "in_progress", "completed"];
1397
- if (typeof status === "string" && validStatuses.includes(status)) {
1398
- return status;
1399
- }
1400
- return "pending";
1461
+ function getStoryPath(sagaRoot, epicSlug, storySlug, archived) {
1462
+ return archived ? (0, import_node_path7.join)(sagaRoot, ".saga", "archive", epicSlug, storySlug, "story.md") : (0, import_node_path7.join)(sagaRoot, ".saga", "epics", epicSlug, "stories", storySlug, "story.md");
1401
1463
  }
1402
- async function parseStory(storyPath, epicSlug) {
1403
- const { join: join14 } = await import("path");
1404
- const { stat: stat4 } = await import("fs/promises");
1405
- let content;
1406
- try {
1407
- content = await (0, import_promises3.readFile)(storyPath, "utf-8");
1408
- } catch {
1464
+ async function parseAndEnrichStory(sagaRoot, storyPath, epicSlug, archived) {
1465
+ const story = await parseStory(storyPath, epicSlug);
1466
+ if (!story) {
1409
1467
  return null;
1410
1468
  }
1411
- const storyDir = storyPath.replace(/\/story\.md$/, "");
1412
- const dirName = storyDir.split("/").pop() || "unknown";
1413
- let frontmatter = {};
1469
+ story.paths.storyMd = (0, import_node_path7.relative)(sagaRoot, story.paths.storyMd);
1470
+ if (story.paths.journalMd) {
1471
+ story.paths.journalMd = (0, import_node_path7.relative)(sagaRoot, story.paths.journalMd);
1472
+ }
1473
+ story.archived = archived;
1474
+ if (story.paths.journalMd) {
1475
+ const journalPath = (0, import_node_path7.join)(sagaRoot, story.paths.journalMd);
1476
+ const journal = await parseJournal(journalPath);
1477
+ if (journal.length > 0) {
1478
+ story.journal = journal;
1479
+ }
1480
+ }
1481
+ return story;
1482
+ }
1483
+ async function handleStoryChangeEvent(event, sagaRoot, clients, broadcastToSubscribers, handleEpicChange) {
1484
+ const { epicSlug, storySlug, archived } = event;
1485
+ if (!storySlug) {
1486
+ return;
1487
+ }
1488
+ const storyKey = makeStoryKey(epicSlug, storySlug);
1489
+ if (!hasSubscribers(clients, storyKey)) {
1490
+ await handleEpicChange();
1491
+ return;
1492
+ }
1414
1493
  try {
1415
- const parsed = (0, import_gray_matter2.default)(content);
1416
- frontmatter = parsed.data;
1494
+ const storyPath = getStoryPath(sagaRoot, epicSlug, storySlug, archived);
1495
+ const story = await parseAndEnrichStory(sagaRoot, storyPath, epicSlug, archived);
1496
+ if (story) {
1497
+ broadcastToSubscribers(storyKey, { event: "story:updated", data: story });
1498
+ }
1499
+ await handleEpicChange();
1417
1500
  } catch {
1418
1501
  }
1419
- const slug = frontmatter.id || frontmatter.slug || dirName;
1420
- const title = frontmatter.title || dirName;
1421
- const status = validateStatus(frontmatter.status);
1422
- const tasks = parseTasks(frontmatter.tasks);
1423
- const journalPath = join14(storyDir, "journal.md");
1424
- let hasJournal = false;
1502
+ }
1503
+ function handleClientMessage(data, state, logStreamManager) {
1425
1504
  try {
1426
- await stat4(journalPath);
1427
- hasJournal = true;
1505
+ const message = JSON.parse(data.toString());
1506
+ if (message.type === "ping") {
1507
+ sendToClient(state.ws, { event: "pong", data: null });
1508
+ return;
1509
+ }
1510
+ if (message.event) {
1511
+ processClientMessage(message, state, logStreamManager);
1512
+ }
1428
1513
  } catch {
1429
1514
  }
1515
+ }
1516
+ function setupClientHandlers(ws, state, clients, logStreamManager) {
1517
+ ws.on("pong", () => {
1518
+ state.isAlive = true;
1519
+ });
1520
+ ws.on("message", (data) => {
1521
+ handleClientMessage(data, state, logStreamManager);
1522
+ });
1523
+ ws.on("close", () => {
1524
+ clients.delete(ws);
1525
+ logStreamManager.handleClientDisconnect(ws);
1526
+ });
1527
+ ws.on("error", () => {
1528
+ clients.delete(ws);
1529
+ logStreamManager.handleClientDisconnect(ws);
1530
+ });
1531
+ }
1532
+ function handleNewConnection(ws, clients, logStreamManager) {
1533
+ const state = {
1534
+ ws,
1535
+ subscribedStories: /* @__PURE__ */ new Set(),
1536
+ isAlive: true
1537
+ };
1538
+ clients.set(ws, state);
1539
+ setupClientHandlers(ws, state, clients, logStreamManager);
1540
+ }
1541
+ function setupWatcherHandlers(watcher, sagaRoot, clients, broadcast, broadcastToSubscribers) {
1542
+ const handleEpicChange = async () => {
1543
+ try {
1544
+ const epics = await scanSagaDirectory(sagaRoot);
1545
+ const summaries = epics.map(toEpicSummary2);
1546
+ broadcast({ event: "epics:updated", data: summaries });
1547
+ } catch {
1548
+ }
1549
+ };
1550
+ watcher.on("epic:added", handleEpicChange);
1551
+ watcher.on("epic:changed", handleEpicChange);
1552
+ watcher.on("epic:removed", handleEpicChange);
1553
+ const handleStoryChange = (event) => {
1554
+ handleStoryChangeEvent(event, sagaRoot, clients, broadcastToSubscribers, handleEpicChange);
1555
+ };
1556
+ watcher.on("story:added", handleStoryChange);
1557
+ watcher.on("story:changed", handleStoryChange);
1558
+ watcher.on("story:removed", handleStoryChange);
1559
+ watcher.on("error", () => {
1560
+ });
1561
+ }
1562
+ function setupSessionPolling(broadcast, logStreamManager) {
1563
+ let previousSessionStates = /* @__PURE__ */ new Map();
1564
+ startSessionPolling((msg) => {
1565
+ broadcast({ event: msg.type, data: msg.data });
1566
+ const currentStates = /* @__PURE__ */ new Map();
1567
+ for (const session of msg.data) {
1568
+ currentStates.set(session.name, session.status);
1569
+ const previousStatus = previousSessionStates.get(session.name);
1570
+ if (previousStatus === "running" && session.status === "completed") {
1571
+ logStreamManager.notifySessionCompleted(session.name);
1572
+ }
1573
+ }
1574
+ previousSessionStates = currentStates;
1575
+ });
1576
+ }
1577
+ function setupHeartbeat(clients) {
1578
+ return setInterval(() => {
1579
+ for (const [ws, state] of clients) {
1580
+ if (!state.isAlive) {
1581
+ clients.delete(ws);
1582
+ ws.terminate();
1583
+ continue;
1584
+ }
1585
+ state.isAlive = false;
1586
+ ws.ping();
1587
+ }
1588
+ }, HEARTBEAT_INTERVAL_MS);
1589
+ }
1590
+ function createWebSocketInstance(state) {
1591
+ const {
1592
+ wss,
1593
+ clients,
1594
+ watcher,
1595
+ logStreamManager,
1596
+ heartbeatInterval,
1597
+ broadcast,
1598
+ broadcastToSubscribers
1599
+ } = state;
1430
1600
  return {
1431
- slug,
1432
- epicSlug,
1433
- title,
1434
- status,
1435
- tasks,
1436
- paths: {
1437
- storyMd: storyPath,
1438
- ...hasJournal ? { journalMd: journalPath } : {}
1601
+ broadcastEpicsUpdated(epics) {
1602
+ broadcast({ event: "epics:updated", data: epics });
1603
+ },
1604
+ broadcastStoryUpdated(story) {
1605
+ const key = makeStoryKey(story.epicSlug, story.slug);
1606
+ broadcastToSubscribers(key, { event: "story:updated", data: story });
1607
+ },
1608
+ async close() {
1609
+ clearInterval(heartbeatInterval);
1610
+ stopSessionPolling();
1611
+ await logStreamManager.dispose();
1612
+ for (const [ws] of clients) {
1613
+ ws.close();
1614
+ }
1615
+ clients.clear();
1616
+ if (watcher) {
1617
+ await watcher.close();
1618
+ }
1619
+ return new Promise((resolve2, reject) => {
1620
+ wss.close((err) => {
1621
+ if (err) {
1622
+ reject(err);
1623
+ } else {
1624
+ resolve2();
1625
+ }
1626
+ });
1627
+ });
1439
1628
  }
1440
1629
  };
1441
1630
  }
1442
- async function parseJournal(journalPath) {
1631
+ async function createWebSocketServer(httpServer, sagaRoot) {
1632
+ const wss = new import_ws.WebSocketServer({ server: httpServer });
1633
+ const clients = /* @__PURE__ */ new Map();
1634
+ let watcher = null;
1443
1635
  try {
1444
- const content = await (0, import_promises3.readFile)(journalPath, "utf-8");
1445
- const entries = [];
1446
- const sections = content.split(/^##\s+/m).slice(1);
1447
- for (const section of sections) {
1448
- const lines = section.split("\n");
1449
- const headerLine = lines[0] || "";
1450
- const sectionContent = lines.slice(1).join("\n").trim();
1451
- if (headerLine.toLowerCase().startsWith("session:")) {
1452
- const timestamp = headerLine.substring("session:".length).trim();
1453
- entries.push({
1454
- timestamp,
1455
- type: "session",
1456
- content: sectionContent
1457
- });
1458
- } else if (headerLine.toLowerCase().startsWith("blocker:")) {
1459
- const title = headerLine.substring("blocker:".length).trim();
1460
- entries.push({
1461
- timestamp: "",
1462
- // Blockers may not have timestamps
1463
- type: "blocker",
1464
- content: `${title}
1636
+ watcher = await createSagaWatcher(sagaRoot);
1637
+ } catch {
1638
+ }
1639
+ const logStreamManager = new LogStreamManager(sendLogMessage);
1640
+ const broadcast = (message) => {
1641
+ for (const [ws] of clients) {
1642
+ sendToClient(ws, message);
1643
+ }
1644
+ };
1645
+ const broadcastToSubscribers = (storyKey, message) => {
1646
+ for (const [ws, state] of clients) {
1647
+ if (state.subscribedStories.has(storyKey)) {
1648
+ sendToClient(ws, message);
1649
+ }
1650
+ }
1651
+ };
1652
+ const heartbeatInterval = setupHeartbeat(clients);
1653
+ setupSessionPolling(broadcast, logStreamManager);
1654
+ wss.on("connection", (ws) => {
1655
+ handleNewConnection(ws, clients, logStreamManager);
1656
+ });
1657
+ if (watcher) {
1658
+ setupWatcherHandlers(watcher, sagaRoot, clients, broadcast, broadcastToSubscribers);
1659
+ }
1660
+ return createWebSocketInstance({
1661
+ wss,
1662
+ clients,
1663
+ watcher,
1664
+ logStreamManager,
1665
+ heartbeatInterval,
1666
+ broadcast,
1667
+ broadcastToSubscribers
1668
+ });
1669
+ }
1465
1670
 
1466
- ${sectionContent}`.trim()
1467
- });
1468
- } else if (headerLine.toLowerCase().startsWith("resolution:")) {
1469
- const title = headerLine.substring("resolution:".length).trim();
1470
- entries.push({
1471
- timestamp: "",
1472
- // Resolutions may not have timestamps
1473
- type: "resolution",
1474
- content: `${title}
1671
+ // src/server/index.ts
1672
+ var DEFAULT_PORT = 3847;
1673
+ function createApp(sagaRoot) {
1674
+ const app = (0, import_express3.default)();
1675
+ app.use(import_express3.default.json());
1676
+ app.use((_req, res, next) => {
1677
+ res.header("Access-Control-Allow-Origin", "*");
1678
+ res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
1679
+ res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
1680
+ next();
1681
+ });
1682
+ app.get("/api/health", (_req, res) => {
1683
+ res.json({ status: "ok" });
1684
+ });
1685
+ app.use("/api", createApiRouter(sagaRoot));
1686
+ const clientDistPath = (0, import_node_path8.join)(__dirname, "client");
1687
+ const _indexHtmlPath = (0, import_node_path8.join)(clientDistPath, "index.html");
1688
+ app.use(import_express3.default.static(clientDistPath));
1689
+ app.get("/{*splat}", (_req, res) => {
1690
+ res.sendFile("index.html", { root: clientDistPath });
1691
+ });
1692
+ return app;
1693
+ }
1694
+ async function startServer(config) {
1695
+ const port = config.port ?? DEFAULT_PORT;
1696
+ const app = createApp(config.sagaRoot);
1697
+ const httpServer = (0, import_node_http.createServer)(app);
1698
+ const wsServer = await createWebSocketServer(httpServer, config.sagaRoot);
1699
+ return new Promise((resolve2, reject) => {
1700
+ httpServer.on("error", reject);
1701
+ httpServer.listen(port, () => {
1702
+ resolve2({
1703
+ app,
1704
+ httpServer,
1705
+ wsServer,
1706
+ port,
1707
+ close: async () => {
1708
+ await wsServer.close();
1709
+ return new Promise((resolveClose, rejectClose) => {
1710
+ httpServer.close((err) => {
1711
+ if (err) {
1712
+ rejectClose(err);
1713
+ } else {
1714
+ resolveClose();
1715
+ }
1716
+ });
1717
+ });
1718
+ }
1719
+ });
1720
+ });
1721
+ });
1722
+ }
1475
1723
 
1476
- ${sectionContent}`.trim()
1477
- });
1478
- }
1724
+ // src/utils/project-discovery.ts
1725
+ var import_node_fs4 = require("node:fs");
1726
+ var import_node_path9 = require("node:path");
1727
+ var import_node_process2 = __toESM(require("node:process"), 1);
1728
+ function findProjectRoot(startDir) {
1729
+ let currentDir = startDir ?? import_node_process2.default.cwd();
1730
+ while (true) {
1731
+ const sagaDir = (0, import_node_path9.join)(currentDir, ".saga");
1732
+ if ((0, import_node_fs4.existsSync)(sagaDir)) {
1733
+ return currentDir;
1479
1734
  }
1480
- return entries;
1481
- } catch {
1482
- return [];
1735
+ const parentDir = (0, import_node_path9.dirname)(currentDir);
1736
+ if (parentDir === currentDir) {
1737
+ return null;
1738
+ }
1739
+ currentDir = parentDir;
1483
1740
  }
1484
1741
  }
1485
- async function scanSagaDirectory(sagaRoot) {
1486
- const [scannedEpics, scannedStories] = await Promise.all([
1487
- scanEpics(sagaRoot),
1488
- scanAllStories(sagaRoot)
1489
- ]);
1490
- const storiesByEpic = /* @__PURE__ */ new Map();
1491
- for (const story of scannedStories) {
1492
- const existing = storiesByEpic.get(story.epicSlug) || [];
1493
- existing.push(story);
1494
- storiesByEpic.set(story.epicSlug, existing);
1742
+ function resolveProjectPath(explicitPath) {
1743
+ if (explicitPath) {
1744
+ const sagaDir = (0, import_node_path9.join)(explicitPath, ".saga");
1745
+ if (!(0, import_node_fs4.existsSync)(sagaDir)) {
1746
+ throw new Error(
1747
+ `No .saga/ directory found at specified path: ${explicitPath}
1748
+ Make sure the path points to a SAGA project root.`
1749
+ );
1750
+ }
1751
+ return explicitPath;
1495
1752
  }
1496
- const epics = [];
1497
- for (const scannedEpic of scannedEpics) {
1498
- const epicStories = storiesByEpic.get(scannedEpic.slug) || [];
1499
- const stories = await Promise.all(
1500
- epicStories.map((s) => toStoryDetail(s, sagaRoot))
1753
+ const projectRoot = findProjectRoot();
1754
+ if (!projectRoot) {
1755
+ throw new Error(
1756
+ 'Could not find a SAGA project.\nNo .saga/ directory found in the current directory or any parent.\nRun "saga init" to initialize a new project, or use --path to specify the project location.'
1501
1757
  );
1502
- const storyCounts = {
1503
- total: stories.length,
1504
- ready: stories.filter((s) => s.status === "ready").length,
1505
- inProgress: stories.filter((s) => s.status === "in_progress").length,
1506
- blocked: stories.filter((s) => s.status === "blocked").length,
1507
- completed: stories.filter((s) => s.status === "completed").length
1508
- };
1509
- epics.push({
1510
- slug: scannedEpic.slug,
1511
- title: scannedEpic.title,
1512
- content: scannedEpic.content,
1513
- storyCounts,
1514
- stories,
1515
- path: (0, import_path2.relative)(sagaRoot, scannedEpic.epicPath)
1516
- });
1517
1758
  }
1518
- return epics;
1759
+ return projectRoot;
1519
1760
  }
1520
1761
 
1521
- // src/server/routes.ts
1522
- var import_path3 = require("path");
1523
-
1524
- // src/server/session-routes.ts
1525
- var import_express = require("express");
1526
-
1527
- // src/lib/session-polling.ts
1528
- var POLLING_INTERVAL_MS = 3e3;
1529
- var pollingInterval = null;
1530
- var currentSessions = [];
1531
- var isFirstPoll = true;
1532
- function getCurrentSessions() {
1533
- return [...currentSessions];
1534
- }
1535
- function startSessionPolling(broadcast) {
1536
- stopSessionPolling();
1537
- pollSessions(broadcast);
1538
- pollingInterval = setInterval(() => {
1539
- pollSessions(broadcast);
1540
- }, POLLING_INTERVAL_MS);
1541
- }
1542
- function stopSessionPolling() {
1543
- if (pollingInterval) {
1544
- clearInterval(pollingInterval);
1545
- pollingInterval = null;
1546
- }
1547
- currentSessions = [];
1548
- isFirstPoll = true;
1549
- }
1550
- async function pollSessions(broadcast) {
1762
+ // src/commands/dashboard.ts
1763
+ async function dashboardCommand(options) {
1764
+ let projectPath;
1551
1765
  try {
1552
- const sessions = await discoverSessions();
1553
- const hasChanges = detectChanges(sessions);
1554
- if (hasChanges) {
1555
- currentSessions = sessions;
1556
- isFirstPoll = false;
1557
- broadcast({
1558
- type: "sessions:updated",
1559
- sessions
1560
- });
1561
- }
1766
+ projectPath = resolveProjectPath(options.path);
1562
1767
  } catch (error) {
1563
- console.error("Error polling sessions:", error);
1768
+ console.error(error instanceof Error ? error.message : "Failed to resolve SAGA project path");
1769
+ import_node_process3.default.exit(1);
1564
1770
  }
1565
- }
1566
- async function discoverSessions() {
1567
- const rawSessions = await listSessions();
1568
- const detailedSessions = [];
1569
- for (const session of rawSessions) {
1570
- try {
1571
- const statusResult = await getSessionStatus(session.name);
1572
- const status = statusResult.running ? "running" : "completed";
1573
- const detailed = await buildSessionInfo(session.name, status);
1574
- if (detailed) {
1575
- detailedSessions.push(detailed);
1576
- }
1577
- } catch (error) {
1578
- console.error(`Error building session info for ${session.name}:`, error);
1579
- }
1771
+ try {
1772
+ const server = await startServer({
1773
+ sagaRoot: projectPath,
1774
+ port: options.port
1775
+ });
1776
+ console.log(`SAGA Dashboard server running on http://localhost:${server.port}`);
1777
+ console.log(`Project: ${projectPath}`);
1778
+ import_node_process3.default.on("SIGINT", async () => {
1779
+ await server.close();
1780
+ import_node_process3.default.exit(0);
1781
+ });
1782
+ import_node_process3.default.on("SIGTERM", async () => {
1783
+ await server.close();
1784
+ import_node_process3.default.exit(0);
1785
+ });
1786
+ } catch (_error) {
1787
+ import_node_process3.default.exit(1);
1580
1788
  }
1581
- detailedSessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
1582
- return detailedSessions;
1583
1789
  }
1584
- function detectChanges(newSessions) {
1585
- if (isFirstPoll) {
1586
- return true;
1587
- }
1588
- if (newSessions.length !== currentSessions.length) {
1589
- return true;
1790
+
1791
+ // src/commands/find.ts
1792
+ var import_node_process4 = __toESM(require("node:process"), 1);
1793
+
1794
+ // src/utils/finder.ts
1795
+ var import_node_fs5 = require("node:fs");
1796
+ var import_node_path10 = require("node:path");
1797
+ var import_fuse = __toESM(require("fuse.js"), 1);
1798
+ var FUZZY_THRESHOLD = 0.3;
1799
+ var MATCH_THRESHOLD = 0.6;
1800
+ var SCORE_SIMILARITY_THRESHOLD = 0.1;
1801
+ var DEFAULT_CONTEXT_MAX_LENGTH = 300;
1802
+ var ELLIPSIS_LENGTH = 3;
1803
+ var CONTEXT_SECTION_REGEX = /##\s*Context\s*\n+([\s\S]*?)(?=\n##|Z|$)/i;
1804
+ function extractContext(body, maxLength = DEFAULT_CONTEXT_MAX_LENGTH) {
1805
+ const contextMatch = body.match(CONTEXT_SECTION_REGEX);
1806
+ if (!contextMatch) {
1807
+ return "";
1590
1808
  }
1591
- const newSessionMap = /* @__PURE__ */ new Map();
1592
- for (const session of newSessions) {
1593
- newSessionMap.set(session.name, session);
1809
+ const context = contextMatch[1].trim();
1810
+ if (context.length > maxLength) {
1811
+ return `${context.slice(0, maxLength - ELLIPSIS_LENGTH)}...`;
1594
1812
  }
1595
- const currentSessionMap = /* @__PURE__ */ new Map();
1596
- for (const session of currentSessions) {
1597
- currentSessionMap.set(session.name, session);
1813
+ return context;
1814
+ }
1815
+ function normalize(str) {
1816
+ return str.toLowerCase().replace(/[-_]/g, " ");
1817
+ }
1818
+ function toStoryInfo(story) {
1819
+ return {
1820
+ slug: story.slug,
1821
+ title: story.title,
1822
+ status: story.status,
1823
+ context: extractContext(story.body),
1824
+ epicSlug: story.epicSlug,
1825
+ storyPath: story.storyPath,
1826
+ worktreePath: story.worktreePath || ""
1827
+ };
1828
+ }
1829
+ function processFuzzyResults(results) {
1830
+ if (results.length === 1) {
1831
+ return { found: true, data: results[0].item };
1598
1832
  }
1599
- for (const name of newSessionMap.keys()) {
1600
- if (!currentSessionMap.has(name)) {
1601
- return true;
1602
- }
1833
+ const bestScore = results[0].score ?? 0;
1834
+ const similarMatches = results.filter(
1835
+ (r) => (r.score ?? 0) - bestScore <= SCORE_SIMILARITY_THRESHOLD
1836
+ );
1837
+ if (similarMatches.length > 1) {
1838
+ return { found: false, matches: similarMatches.map((r) => r.item) };
1603
1839
  }
1604
- for (const name of currentSessionMap.keys()) {
1605
- if (!newSessionMap.has(name)) {
1606
- return true;
1607
- }
1840
+ if (bestScore <= FUZZY_THRESHOLD) {
1841
+ return { found: true, data: results[0].item };
1608
1842
  }
1609
- for (const [name, newSession] of newSessionMap) {
1610
- const currentSession = currentSessionMap.get(name);
1611
- if (currentSession && currentSession.status !== newSession.status) {
1612
- return true;
1843
+ return { found: false, matches: results.map((r) => r.item) };
1844
+ }
1845
+ function getEpicSlugs(projectPath) {
1846
+ const epicsDir = (0, import_node_path10.join)(projectPath, ".saga", "epics");
1847
+ return (0, import_node_fs5.readdirSync)(epicsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1848
+ }
1849
+ function findExactEpicMatch(epicSlugs, queryNormalized) {
1850
+ for (const slug of epicSlugs) {
1851
+ if (slug.toLowerCase() === queryNormalized) {
1852
+ return { slug };
1613
1853
  }
1614
1854
  }
1615
- return false;
1855
+ return null;
1616
1856
  }
1617
-
1618
- // src/server/session-routes.ts
1619
- function createSessionApiRouter() {
1620
- const router = (0, import_express.Router)();
1621
- router.get("/sessions", (_req, res) => {
1622
- try {
1623
- let sessions = getCurrentSessions();
1624
- const { epicSlug, storySlug, status } = _req.query;
1625
- if (epicSlug && typeof epicSlug === "string") {
1626
- sessions = sessions.filter((s) => s.epicSlug === epicSlug);
1627
- if (storySlug && typeof storySlug === "string") {
1628
- sessions = sessions.filter((s) => s.storySlug === storySlug);
1629
- }
1630
- }
1631
- if (status && typeof status === "string" && (status === "running" || status === "completed")) {
1632
- sessions = sessions.filter((s) => s.status === status);
1633
- }
1634
- res.json(sessions);
1635
- } catch (error) {
1636
- console.error("Error fetching sessions:", error);
1637
- res.status(500).json({ error: "Failed to fetch sessions" });
1638
- }
1639
- });
1640
- router.get("/sessions/:sessionName", (req, res) => {
1641
- try {
1642
- const { sessionName } = req.params;
1643
- const sessions = getCurrentSessions();
1644
- const session = sessions.find((s) => s.name === sessionName);
1645
- if (!session) {
1646
- res.status(404).json({ error: "Session not found" });
1647
- return;
1648
- }
1649
- res.json(session);
1650
- } catch (error) {
1651
- console.error("Error fetching session:", error);
1652
- res.status(500).json({ error: "Failed to fetch session" });
1653
- }
1857
+ function fuzzySearchEpics(epicSlugs, query) {
1858
+ const epics = epicSlugs.map((slug) => ({ slug }));
1859
+ const fuse = new import_fuse.default(epics, {
1860
+ keys: ["slug"],
1861
+ threshold: MATCH_THRESHOLD,
1862
+ includeScore: true
1654
1863
  });
1655
- return router;
1656
- }
1657
-
1658
- // src/server/routes.ts
1659
- async function getEpics(sagaRoot) {
1660
- return scanSagaDirectory(sagaRoot);
1864
+ const results = fuse.search(query);
1865
+ if (results.length === 0) {
1866
+ return { found: false, error: `No epic found matching '${query}'` };
1867
+ }
1868
+ return processFuzzyResults(results);
1661
1869
  }
1662
- function toEpicSummary(epic) {
1663
- return {
1664
- slug: epic.slug,
1665
- title: epic.title,
1666
- storyCounts: epic.storyCounts,
1667
- path: epic.path
1668
- };
1870
+ function findEpic(projectPath, query) {
1871
+ if (!epicsDirectoryExists(projectPath)) {
1872
+ return { found: false, error: "No .saga/epics/ directory found" };
1873
+ }
1874
+ const epicSlugs = getEpicSlugs(projectPath);
1875
+ if (epicSlugs.length === 0) {
1876
+ return { found: false, error: `No epic found matching '${query}'` };
1877
+ }
1878
+ const queryNormalized = query.toLowerCase().replace(/_/g, "-");
1879
+ const exactMatch = findExactEpicMatch(epicSlugs, queryNormalized);
1880
+ if (exactMatch) {
1881
+ return { found: true, data: exactMatch };
1882
+ }
1883
+ return fuzzySearchEpics(epicSlugs, query);
1669
1884
  }
1670
- function createApiRouter(sagaRoot) {
1671
- const router = (0, import_express2.Router)();
1672
- router.get("/epics", async (_req, res) => {
1673
- try {
1674
- const epics = await getEpics(sagaRoot);
1675
- const summaries = epics.map(toEpicSummary);
1676
- res.json(summaries);
1677
- } catch (error) {
1678
- console.error("Error fetching epics:", error);
1679
- res.status(500).json({ error: "Failed to fetch epics" });
1680
- }
1681
- });
1682
- router.get("/epics/:slug", async (req, res) => {
1683
- try {
1684
- const { slug } = req.params;
1685
- const epics = await getEpics(sagaRoot);
1686
- const epic = epics.find((e) => e.slug === slug);
1687
- if (!epic) {
1688
- res.status(404).json({ error: `Epic not found: ${slug}` });
1689
- return;
1690
- }
1691
- res.json(epic);
1692
- } catch (error) {
1693
- console.error("Error fetching epic:", error);
1694
- res.status(500).json({ error: "Failed to fetch epic" });
1695
- }
1696
- });
1697
- router.get("/stories/:epicSlug/:storySlug", async (req, res) => {
1698
- try {
1699
- const { epicSlug, storySlug } = req.params;
1700
- const epics = await getEpics(sagaRoot);
1701
- const epic = epics.find((e) => e.slug === epicSlug);
1702
- if (!epic) {
1703
- res.status(404).json({ error: `Epic not found: ${epicSlug}` });
1704
- return;
1705
- }
1706
- const story = epic.stories.find((s) => s.slug === storySlug);
1707
- if (!story) {
1708
- res.status(404).json({ error: `Story not found: ${storySlug}` });
1709
- return;
1710
- }
1711
- if (story.paths.journalMd) {
1712
- const journalPath = (0, import_path3.join)(sagaRoot, story.paths.journalMd);
1713
- const journal = await parseJournal(journalPath);
1714
- if (journal.length > 0) {
1715
- story.journal = journal;
1716
- }
1717
- }
1718
- res.json(story);
1719
- } catch (error) {
1720
- console.error("Error fetching story:", error);
1721
- res.status(500).json({ error: "Failed to fetch story" });
1885
+ function findExactStoryMatch(allStories, queryNormalized) {
1886
+ for (const story of allStories) {
1887
+ if (normalize(story.slug) === queryNormalized) {
1888
+ return story;
1722
1889
  }
1723
- });
1724
- router.use(createSessionApiRouter());
1725
- router.use((_req, res) => {
1726
- res.status(404).json({ error: "API endpoint not found" });
1727
- });
1728
- return router;
1890
+ }
1891
+ return null;
1729
1892
  }
1730
-
1731
- // src/server/websocket.ts
1732
- var import_ws = require("ws");
1733
-
1734
- // src/server/watcher.ts
1735
- var import_chokidar = __toESM(require("chokidar"), 1);
1736
- var import_events = require("events");
1737
- var import_path4 = require("path");
1738
- function parseFilePath(filePath, sagaRoot) {
1739
- const relativePath = (0, import_path4.relative)(sagaRoot, filePath);
1740
- const parts = relativePath.split(import_path4.sep);
1741
- if (parts[0] !== ".saga" || parts.length < 4) {
1742
- return null;
1893
+ function fuzzySearchStories(allStories, query) {
1894
+ const fuse = new import_fuse.default(allStories, {
1895
+ keys: [
1896
+ { name: "slug", weight: 2 },
1897
+ // Prioritize slug matches
1898
+ { name: "title", weight: 1 }
1899
+ ],
1900
+ threshold: MATCH_THRESHOLD,
1901
+ includeScore: true
1902
+ });
1903
+ const results = fuse.search(query);
1904
+ if (results.length === 0) {
1905
+ return { found: false, error: `No story found matching '${query}'` };
1743
1906
  }
1744
- const isArchive = parts[1] === "archive";
1745
- const isEpics = parts[1] === "epics";
1746
- if (!isArchive && !isEpics) {
1747
- return null;
1907
+ return processFuzzyResults(results);
1908
+ }
1909
+ async function loadAndFilterStories(projectPath, query, options) {
1910
+ const scannedStories = await scanAllStories(projectPath);
1911
+ if (scannedStories.length === 0) {
1912
+ return { found: false, error: `No story found matching '${query}'` };
1748
1913
  }
1749
- const epicSlug = parts[2];
1750
- if (isArchive) {
1751
- if (parts.length >= 5) {
1752
- const storySlug = parts[3];
1753
- const fileName = parts[4];
1754
- if (fileName === "story.md" || fileName === "journal.md") {
1755
- return {
1756
- epicSlug,
1757
- storySlug,
1758
- archived: true,
1759
- isEpicFile: false,
1760
- isStoryFile: true,
1761
- isMainStoryFile: fileName === "story.md"
1762
- };
1763
- }
1914
+ let allStories = scannedStories.map(toStoryInfo);
1915
+ if (options.status) {
1916
+ allStories = allStories.filter((story) => story.status === options.status);
1917
+ if (allStories.length === 0) {
1918
+ return {
1919
+ found: false,
1920
+ error: `No story found matching '${query}' with status '${options.status}'`
1921
+ };
1764
1922
  }
1765
- return null;
1766
1923
  }
1767
- if (parts.length === 4 && parts[3] === "epic.md") {
1924
+ return allStories;
1925
+ }
1926
+ async function findStory(projectPath, query, options = {}) {
1927
+ if (!(worktreesDirectoryExists(projectPath) || epicsDirectoryExists(projectPath))) {
1768
1928
  return {
1769
- epicSlug,
1770
- archived: false,
1771
- isEpicFile: true,
1772
- isStoryFile: false,
1773
- isMainStoryFile: false
1929
+ found: false,
1930
+ error: "No .saga/worktrees/ or .saga/epics/ directory found. Run /generate-stories first."
1774
1931
  };
1775
1932
  }
1776
- if (parts.length >= 6 && parts[3] === "stories") {
1777
- const storySlug = parts[4];
1778
- const fileName = parts[5];
1779
- if (fileName === "story.md" || fileName === "journal.md") {
1780
- return {
1781
- epicSlug,
1782
- storySlug,
1783
- archived: false,
1784
- isEpicFile: false,
1785
- isStoryFile: true,
1786
- isMainStoryFile: fileName === "story.md"
1787
- };
1788
- }
1933
+ const storiesOrError = await loadAndFilterStories(projectPath, query, options);
1934
+ if (!Array.isArray(storiesOrError)) {
1935
+ return storiesOrError;
1789
1936
  }
1790
- return null;
1937
+ const allStories = storiesOrError;
1938
+ const queryNormalized = normalize(query);
1939
+ const exactMatch = findExactStoryMatch(allStories, queryNormalized);
1940
+ if (exactMatch) {
1941
+ return { found: true, data: exactMatch };
1942
+ }
1943
+ return fuzzySearchStories(allStories, query);
1791
1944
  }
1792
- function createDebouncer(delayMs) {
1793
- const pending = /* @__PURE__ */ new Map();
1794
- return {
1795
- schedule(key, data, callback) {
1796
- const existing = pending.get(key);
1797
- if (existing) {
1798
- clearTimeout(existing.timer);
1799
- }
1800
- const timer = setTimeout(() => {
1801
- pending.delete(key);
1802
- callback(data);
1803
- }, delayMs);
1804
- pending.set(key, { timer, data });
1805
- },
1806
- clear() {
1807
- for (const { timer } of pending.values()) {
1808
- clearTimeout(timer);
1809
- }
1810
- pending.clear();
1811
- }
1812
- };
1945
+
1946
+ // src/commands/find.ts
1947
+ async function findCommand(query, options) {
1948
+ let projectPath;
1949
+ try {
1950
+ projectPath = resolveProjectPath(options.path);
1951
+ } catch (_error) {
1952
+ import_node_process4.default.exit(1);
1953
+ }
1954
+ const type = options.type ?? "story";
1955
+ let result;
1956
+ if (type === "epic") {
1957
+ result = findEpic(projectPath, query);
1958
+ } else {
1959
+ result = await findStory(projectPath, query, { status: options.status });
1960
+ }
1961
+ console.log(JSON.stringify(result, null, 2));
1962
+ if (!result.found) {
1963
+ import_node_process4.default.exit(1);
1964
+ }
1813
1965
  }
1814
- async function createSagaWatcher(sagaRoot) {
1815
- const emitter = new import_events.EventEmitter();
1816
- const debouncer = createDebouncer(100);
1817
- const epicsDir = (0, import_path4.join)(sagaRoot, ".saga", "epics");
1818
- const archiveDir = (0, import_path4.join)(sagaRoot, ".saga", "archive");
1819
- const watcher = import_chokidar.default.watch([epicsDir, archiveDir], {
1820
- persistent: true,
1821
- ignoreInitial: true,
1822
- // Don't emit events for existing files
1823
- // Use polling for reliable cross-platform behavior in tests
1824
- // This is fine since we only watch epics/archive (~20 files), not entire .saga/ (79K+ files)
1825
- usePolling: true,
1826
- interval: 100
1827
- });
1828
- let closed = false;
1829
- let ready = false;
1830
- const handleFileEvent = (eventType, filePath) => {
1831
- if (closed || !ready) return;
1832
- const parsed = parseFilePath(filePath, sagaRoot);
1833
- if (!parsed) return;
1834
- const { epicSlug, storySlug, archived, isEpicFile, isStoryFile, isMainStoryFile } = parsed;
1835
- const key = storySlug ? `story:${epicSlug}:${storySlug}:${archived}` : `epic:${epicSlug}`;
1836
- let watcherEventType;
1837
- if (isEpicFile) {
1838
- watcherEventType = eventType === "add" ? "epic:added" : eventType === "unlink" ? "epic:removed" : "epic:changed";
1839
- } else if (isStoryFile) {
1840
- if (isMainStoryFile) {
1841
- watcherEventType = eventType === "add" ? "story:added" : eventType === "unlink" ? "story:removed" : "story:changed";
1842
- } else {
1843
- watcherEventType = "story:changed";
1844
- }
1845
- } else {
1846
- return;
1847
- }
1848
- const event = {
1849
- type: watcherEventType,
1850
- epicSlug,
1851
- storySlug,
1852
- archived,
1853
- path: (0, import_path4.relative)(sagaRoot, filePath)
1854
- };
1855
- debouncer.schedule(key, event, (debouncedEvent) => {
1856
- if (!closed) {
1857
- emitter.emit(debouncedEvent.type, debouncedEvent);
1858
- }
1859
- });
1860
- };
1861
- watcher.on("add", (path) => handleFileEvent("add", path));
1862
- watcher.on("change", (path) => handleFileEvent("change", path));
1863
- watcher.on("unlink", (path) => handleFileEvent("unlink", path));
1864
- watcher.on("error", (error) => {
1865
- if (!closed) {
1866
- emitter.emit("error", error);
1867
- }
1868
- });
1869
- await new Promise((resolve2) => {
1870
- watcher.on("ready", () => {
1871
- ready = true;
1872
- resolve2();
1873
- });
1874
- });
1875
- return {
1876
- on(event, listener) {
1877
- emitter.on(event, listener);
1878
- return this;
1966
+
1967
+ // src/commands/implement.ts
1968
+ var import_node_child_process2 = require("node:child_process");
1969
+ var import_node_fs6 = require("node:fs");
1970
+ var import_node_path11 = require("node:path");
1971
+ var import_node_process5 = __toESM(require("node:process"), 1);
1972
+ var DEFAULT_MAX_CYCLES = 10;
1973
+ var DEFAULT_MAX_TIME = 60;
1974
+ var DEFAULT_MODEL = "opus";
1975
+ var VALID_STATUSES = /* @__PURE__ */ new Set(["ONGOING", "FINISH", "BLOCKED"]);
1976
+ var WORKER_PROMPT_RELATIVE = "worker-prompt.md";
1977
+ var MS_PER_SECOND = 1e3;
1978
+ var MS_PER_MINUTE = 6e4;
1979
+ var SECONDS_PER_MINUTE = 60;
1980
+ var ROUNDING_PRECISION = 100;
1981
+ var WORKER_OUTPUT_SCHEMA = {
1982
+ type: "object",
1983
+ properties: {
1984
+ status: {
1985
+ type: "string",
1986
+ enum: ["ONGOING", "FINISH", "BLOCKED"]
1879
1987
  },
1880
- async close() {
1881
- closed = true;
1882
- debouncer.clear();
1883
- await watcher.close();
1988
+ summary: {
1989
+ type: "string",
1990
+ description: "What was accomplished this session"
1991
+ },
1992
+ blocker: {
1993
+ type: ["string", "null"],
1994
+ description: "Brief description if BLOCKED, null otherwise"
1884
1995
  }
1996
+ },
1997
+ required: ["status", "summary"]
1998
+ };
1999
+ async function findStory2(projectPath, storySlug) {
2000
+ const result = await findStory(projectPath, storySlug);
2001
+ if (!result.found) {
2002
+ return null;
2003
+ }
2004
+ return {
2005
+ epicSlug: result.data.epicSlug,
2006
+ storySlug: result.data.slug,
2007
+ storyPath: result.data.storyPath,
2008
+ worktreePath: result.data.worktreePath
1885
2009
  };
1886
2010
  }
2011
+ function computeStoryPath(worktree, epicSlug, storySlug) {
2012
+ return (0, import_node_path11.join)(worktree, ".saga", "epics", epicSlug, "stories", storySlug, "story.md");
2013
+ }
2014
+ function validateStoryFiles(worktree, epicSlug, storySlug) {
2015
+ if (!(0, import_node_fs6.existsSync)(worktree)) {
2016
+ return {
2017
+ valid: false,
2018
+ error: `Worktree not found at ${worktree}
1887
2019
 
1888
- // src/server/websocket.ts
1889
- var import_path5 = require("path");
2020
+ The story worktree has not been created yet. This can happen if:
2021
+ 1. The story was generated but the worktree wasn't set up
2022
+ 2. The worktree was deleted or moved
1890
2023
 
1891
- // src/lib/log-stream-manager.ts
1892
- var import_chokidar2 = __toESM(require("chokidar"), 1);
1893
- var import_node_fs6 = require("node:fs");
1894
- var import_promises4 = require("node:fs/promises");
1895
- var import_node_path6 = require("node:path");
1896
- var LogStreamManager = class {
1897
- /**
1898
- * Active file watchers indexed by session name
1899
- */
1900
- watchers = /* @__PURE__ */ new Map();
1901
- /**
1902
- * Current file position (byte offset) per session for incremental reads
1903
- */
1904
- filePositions = /* @__PURE__ */ new Map();
1905
- /**
1906
- * Client subscriptions per session
1907
- */
1908
- subscriptions = /* @__PURE__ */ new Map();
1909
- /**
1910
- * Function to send messages to clients
1911
- */
1912
- sendToClient;
1913
- /**
1914
- * Create a new LogStreamManager instance
1915
- *
1916
- * @param sendToClient - Function to send log data messages to clients
1917
- */
1918
- constructor(sendToClient) {
1919
- this.sendToClient = sendToClient;
1920
- }
1921
- /**
1922
- * Get the number of subscriptions for a session
1923
- *
1924
- * @param sessionName - The session to check
1925
- * @returns Number of subscribed clients
1926
- */
1927
- getSubscriptionCount(sessionName) {
1928
- const subs = this.subscriptions.get(sessionName);
1929
- return subs ? subs.size : 0;
1930
- }
1931
- /**
1932
- * Check if a watcher exists for a session
1933
- *
1934
- * @param sessionName - The session to check
1935
- * @returns True if a watcher exists
1936
- */
1937
- hasWatcher(sessionName) {
1938
- return this.watchers.has(sessionName);
2024
+ To create the worktree, use: /task-resume ${storySlug}`
2025
+ };
1939
2026
  }
1940
- /**
1941
- * Get the current file position for a session
1942
- *
1943
- * @param sessionName - The session to check
1944
- * @returns The current byte offset, or 0 if not tracked
1945
- */
1946
- getFilePosition(sessionName) {
1947
- return this.filePositions.get(sessionName) ?? 0;
2027
+ const storyPath = computeStoryPath(worktree, epicSlug, storySlug);
2028
+ if (!(0, import_node_fs6.existsSync)(storyPath)) {
2029
+ return {
2030
+ valid: false,
2031
+ error: `story.md not found in worktree.
2032
+
2033
+ Expected location: ${storyPath}
2034
+
2035
+ The worktree exists but the story definition file is missing.
2036
+ This may indicate an incomplete story setup.`
2037
+ };
1948
2038
  }
1949
- /**
1950
- * Subscribe a client to a session's log stream
1951
- *
1952
- * Reads the full file content and sends it as the initial message.
1953
- * Adds the client to the subscription set for incremental updates.
1954
- * Creates a file watcher if this is the first subscriber.
1955
- *
1956
- * @param sessionName - The session to subscribe to
1957
- * @param ws - The WebSocket client to subscribe
1958
- */
1959
- async subscribe(sessionName, ws) {
1960
- const outputFile = (0, import_node_path6.join)(OUTPUT_DIR, `${sessionName}.out`);
1961
- if (!(0, import_node_fs6.existsSync)(outputFile)) {
1962
- this.sendToClient(ws, {
1963
- type: "logs:error",
1964
- sessionName,
1965
- error: `Output file not found: ${outputFile}`
1966
- });
1967
- return;
1968
- }
1969
- const content = await (0, import_promises4.readFile)(outputFile, "utf-8");
1970
- this.sendToClient(ws, {
1971
- type: "logs:data",
1972
- sessionName,
1973
- data: content,
1974
- isInitial: true,
1975
- isComplete: false
1976
- });
1977
- this.filePositions.set(sessionName, content.length);
1978
- let subs = this.subscriptions.get(sessionName);
1979
- if (!subs) {
1980
- subs = /* @__PURE__ */ new Set();
1981
- this.subscriptions.set(sessionName, subs);
1982
- }
1983
- subs.add(ws);
1984
- if (!this.watchers.has(sessionName)) {
1985
- this.createWatcher(sessionName, outputFile);
2039
+ return { valid: true };
2040
+ }
2041
+ function getSkillRoot(pluginRoot) {
2042
+ return (0, import_node_path11.join)(pluginRoot, "skills", "execute-story");
2043
+ }
2044
+ function checkCommandExists(command) {
2045
+ try {
2046
+ const result = (0, import_node_child_process2.spawnSync)("which", [command], { encoding: "utf-8" });
2047
+ if (result.status === 0 && result.stdout.trim()) {
2048
+ return { exists: true, path: result.stdout.trim() };
1986
2049
  }
2050
+ return { exists: false };
2051
+ } catch {
2052
+ return { exists: false };
1987
2053
  }
1988
- /**
1989
- * Create a chokidar file watcher for a session's output file
1990
- *
1991
- * The watcher detects changes and triggers incremental content delivery
1992
- * to all subscribed clients.
1993
- *
1994
- * @param sessionName - The session name
1995
- * @param outputFile - Path to the session output file
1996
- */
1997
- createWatcher(sessionName, outputFile) {
1998
- const watcher = import_chokidar2.default.watch(outputFile, {
1999
- persistent: true,
2000
- awaitWriteFinish: false
2001
- });
2002
- watcher.on("change", async () => {
2003
- await this.sendIncrementalContent(sessionName, outputFile);
2004
- });
2005
- this.watchers.set(sessionName, watcher);
2054
+ }
2055
+ function checkPluginRoot(pluginRoot) {
2056
+ if (pluginRoot) {
2057
+ return { name: "SAGA_PLUGIN_ROOT", path: pluginRoot, passed: true };
2006
2058
  }
2007
- /**
2008
- * Clean up a watcher and associated state for a session
2009
- *
2010
- * Closes the file watcher and removes all tracking state for the session.
2011
- * Should be called when the last subscriber unsubscribes or disconnects.
2012
- *
2013
- * @param sessionName - The session to clean up
2014
- */
2015
- cleanupWatcher(sessionName) {
2016
- const watcher = this.watchers.get(sessionName);
2017
- if (watcher) {
2018
- watcher.close();
2019
- this.watchers.delete(sessionName);
2020
- }
2021
- this.filePositions.delete(sessionName);
2022
- this.subscriptions.delete(sessionName);
2059
+ return { name: "SAGA_PLUGIN_ROOT", passed: false, error: "Environment variable not set" };
2060
+ }
2061
+ function checkClaudeCli() {
2062
+ const claudeCheck = checkCommandExists("claude");
2063
+ return {
2064
+ name: "claude CLI",
2065
+ path: claudeCheck.path,
2066
+ passed: claudeCheck.exists,
2067
+ error: claudeCheck.exists ? void 0 : "Command not found in PATH"
2068
+ };
2069
+ }
2070
+ function checkWorkerPrompt(pluginRoot) {
2071
+ const skillRoot = getSkillRoot(pluginRoot);
2072
+ const workerPromptPath = (0, import_node_path11.join)(skillRoot, WORKER_PROMPT_RELATIVE);
2073
+ const exists = (0, import_node_fs6.existsSync)(workerPromptPath);
2074
+ return {
2075
+ name: "Worker prompt",
2076
+ path: workerPromptPath,
2077
+ passed: exists,
2078
+ error: exists ? void 0 : "File not found"
2079
+ };
2080
+ }
2081
+ function checkWorktreeExists(worktreePath) {
2082
+ const exists = (0, import_node_fs6.existsSync)(worktreePath);
2083
+ return {
2084
+ name: "Worktree exists",
2085
+ path: worktreePath,
2086
+ passed: exists,
2087
+ error: exists ? void 0 : "Directory not found"
2088
+ };
2089
+ }
2090
+ function checkStoryMdExists(storyInfo) {
2091
+ if (!(0, import_node_fs6.existsSync)(storyInfo.worktreePath)) {
2092
+ return null;
2023
2093
  }
2024
- /**
2025
- * Send incremental content to all subscribed clients for a session
2026
- *
2027
- * Reads from the last known position to the end of the file and sends
2028
- * the new content to all subscribed clients.
2029
- *
2030
- * @param sessionName - The session name
2031
- * @param outputFile - Path to the session output file
2032
- */
2033
- async sendIncrementalContent(sessionName, outputFile) {
2034
- const lastPosition = this.filePositions.get(sessionName) ?? 0;
2035
- const fileStat = await (0, import_promises4.stat)(outputFile);
2036
- const currentSize = fileStat.size;
2037
- if (currentSize <= lastPosition) {
2038
- return;
2094
+ const storyMdPath = computeStoryPath(
2095
+ storyInfo.worktreePath,
2096
+ storyInfo.epicSlug,
2097
+ storyInfo.storySlug
2098
+ );
2099
+ const exists = (0, import_node_fs6.existsSync)(storyMdPath);
2100
+ return {
2101
+ name: "story.md in worktree",
2102
+ path: storyMdPath,
2103
+ passed: exists,
2104
+ error: exists ? void 0 : "File not found"
2105
+ };
2106
+ }
2107
+ function runDryRun(storyInfo, _projectPath, pluginRoot) {
2108
+ const checks = [];
2109
+ checks.push(checkPluginRoot(pluginRoot));
2110
+ checks.push(checkClaudeCli());
2111
+ if (pluginRoot) {
2112
+ checks.push(checkWorkerPrompt(pluginRoot));
2113
+ }
2114
+ checks.push({
2115
+ name: "Story found",
2116
+ path: `${storyInfo.storySlug} (epic: ${storyInfo.epicSlug})`,
2117
+ passed: true
2118
+ });
2119
+ checks.push(checkWorktreeExists(storyInfo.worktreePath));
2120
+ const storyMdCheck = checkStoryMdExists(storyInfo);
2121
+ if (storyMdCheck) {
2122
+ checks.push(storyMdCheck);
2123
+ }
2124
+ const allPassed = checks.every((check) => check.passed);
2125
+ return {
2126
+ success: allPassed,
2127
+ checks,
2128
+ story: {
2129
+ epicSlug: storyInfo.epicSlug,
2130
+ storySlug: storyInfo.storySlug,
2131
+ worktreePath: storyInfo.worktreePath
2039
2132
  }
2040
- const newContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
2041
- this.filePositions.set(sessionName, currentSize);
2042
- const subs = this.subscriptions.get(sessionName);
2043
- if (subs) {
2044
- const message = {
2045
- type: "logs:data",
2046
- sessionName,
2047
- data: newContent,
2048
- isInitial: false,
2049
- isComplete: false
2050
- };
2051
- for (const ws of subs) {
2052
- this.sendToClient(ws, message);
2053
- }
2133
+ };
2134
+ }
2135
+ function formatCheckResult(check) {
2136
+ const icon = check.passed ? "\u2713" : "\u2717";
2137
+ const status = check.passed ? "OK" : "FAILED";
2138
+ const lines = [];
2139
+ if (check.passed) {
2140
+ const pathSuffix = check.path ? ` (${check.path})` : "";
2141
+ lines.push(` ${icon} ${check.name}: ${status}${pathSuffix}`);
2142
+ } else {
2143
+ const errorSuffix = check.error ? ` - ${check.error}` : "";
2144
+ lines.push(` ${icon} ${check.name}: ${status}${errorSuffix}`);
2145
+ if (check.path) {
2146
+ lines.push(` Path: ${check.path}`);
2054
2147
  }
2055
2148
  }
2056
- /**
2057
- * Read file content from a specific position
2058
- *
2059
- * @param filePath - Path to the file
2060
- * @param start - Starting byte position
2061
- * @param end - Ending byte position
2062
- * @returns The content read from the file
2063
- */
2064
- readFromPosition(filePath, start, end) {
2065
- return new Promise((resolve2, reject) => {
2066
- let content = "";
2067
- const stream = (0, import_node_fs6.createReadStream)(filePath, {
2068
- start,
2069
- end: end - 1,
2070
- // createReadStream end is inclusive
2071
- encoding: "utf-8"
2072
- });
2073
- stream.on("data", (chunk) => {
2074
- content += chunk;
2075
- });
2076
- stream.on("end", () => {
2077
- resolve2(content);
2078
- });
2079
- stream.on("error", reject);
2080
- });
2081
- }
2082
- /**
2083
- * Unsubscribe a client from a session's log stream
2084
- *
2085
- * Removes the client from the subscription set. If this was the last
2086
- * subscriber, cleans up the watcher and associated state.
2087
- *
2088
- * @param sessionName - The session to unsubscribe from
2089
- * @param ws - The WebSocket client to unsubscribe
2090
- */
2091
- unsubscribe(sessionName, ws) {
2092
- const subs = this.subscriptions.get(sessionName);
2093
- if (subs) {
2094
- subs.delete(ws);
2095
- if (subs.size === 0) {
2096
- this.cleanupWatcher(sessionName);
2097
- }
2149
+ return lines;
2150
+ }
2151
+ function printDryRunResults(result) {
2152
+ console.log("Dry Run: Implement Story Validation");
2153
+ console.log("");
2154
+ console.log("Checks:");
2155
+ for (const check of result.checks) {
2156
+ for (const line of formatCheckResult(check)) {
2157
+ console.log(line);
2098
2158
  }
2099
2159
  }
2100
- /**
2101
- * Handle client disconnect by removing from all subscriptions
2102
- *
2103
- * Should be called when a WebSocket connection closes to clean up
2104
- * any subscriptions the client may have had. Also triggers watcher
2105
- * cleanup for any sessions that no longer have subscribers.
2106
- *
2107
- * @param ws - The WebSocket client that disconnected
2108
- */
2109
- handleClientDisconnect(ws) {
2110
- for (const [sessionName, subs] of this.subscriptions) {
2111
- subs.delete(ws);
2112
- if (subs.size === 0) {
2113
- this.cleanupWatcher(sessionName);
2114
- }
2160
+ console.log("");
2161
+ const summary = result.success ? "All checks passed. Ready to implement." : "Some checks failed. Please resolve the issues above.";
2162
+ console.log(summary);
2163
+ }
2164
+ function loadWorkerPrompt(pluginRoot) {
2165
+ const skillRoot = getSkillRoot(pluginRoot);
2166
+ const promptPath = (0, import_node_path11.join)(skillRoot, WORKER_PROMPT_RELATIVE);
2167
+ if (!(0, import_node_fs6.existsSync)(promptPath)) {
2168
+ throw new Error(`Worker prompt not found at ${promptPath}`);
2169
+ }
2170
+ return (0, import_node_fs6.readFileSync)(promptPath, "utf-8");
2171
+ }
2172
+ var SCOPE_VALIDATED_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep"];
2173
+ var HOOK_PRE_TOOL_USE = "PreToolUse";
2174
+ function buildScopeSettings() {
2175
+ const hookCommand = "npx @saga-ai/cli scope-validator";
2176
+ return {
2177
+ hooks: {
2178
+ [HOOK_PRE_TOOL_USE]: [
2179
+ {
2180
+ matcher: SCOPE_VALIDATED_TOOLS.join("|"),
2181
+ hooks: [hookCommand]
2182
+ }
2183
+ ]
2184
+ }
2185
+ };
2186
+ }
2187
+ function formatAssistantContent(content) {
2188
+ for (const block of content) {
2189
+ const blockData = block;
2190
+ if (blockData.type === "text" && blockData.text) {
2191
+ return blockData.text;
2192
+ }
2193
+ if (blockData.type === "tool_use") {
2194
+ return `[Tool: ${blockData.name}]`;
2115
2195
  }
2116
2196
  }
2117
- /**
2118
- * Notify that a session has completed
2119
- *
2120
- * Reads any remaining content from the file and sends it with isComplete=true
2121
- * to all subscribed clients, then cleans up the watcher regardless of
2122
- * subscription count. Called by session polling when it detects completion.
2123
- *
2124
- * @param sessionName - The session that has completed
2125
- */
2126
- async notifySessionCompleted(sessionName) {
2127
- const subs = this.subscriptions.get(sessionName);
2128
- if (!subs || subs.size === 0) {
2129
- return;
2197
+ return null;
2198
+ }
2199
+ function formatStreamLine(line) {
2200
+ try {
2201
+ const data = JSON.parse(line);
2202
+ if (data.type === "assistant" && data.message?.content) {
2203
+ return formatAssistantContent(data.message.content);
2130
2204
  }
2131
- const outputFile = (0, import_node_path6.join)(OUTPUT_DIR, `${sessionName}.out`);
2132
- let finalContent = "";
2133
- try {
2134
- if ((0, import_node_fs6.existsSync)(outputFile)) {
2135
- const lastPosition = this.filePositions.get(sessionName) ?? 0;
2136
- const fileStat = await (0, import_promises4.stat)(outputFile);
2137
- const currentSize = fileStat.size;
2138
- if (currentSize > lastPosition) {
2139
- finalContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
2140
- }
2141
- }
2142
- } catch {
2205
+ if (data.type === "system" && data.subtype === "init") {
2206
+ return `[Session started: ${data.session_id}]`;
2143
2207
  }
2144
- const message = {
2145
- type: "logs:data",
2146
- sessionName,
2147
- data: finalContent,
2148
- isInitial: false,
2149
- isComplete: true
2150
- };
2151
- for (const ws of subs) {
2152
- this.sendToClient(ws, message);
2208
+ if (data.type === "result") {
2209
+ const status = data.subtype === "success" ? "completed" : "failed";
2210
+ return `
2211
+ [Worker ${status} in ${Math.round(data.duration_ms / MS_PER_SECOND)}s]`;
2153
2212
  }
2154
- this.cleanupWatcher(sessionName);
2213
+ return null;
2214
+ } catch {
2215
+ return null;
2155
2216
  }
2156
- /**
2157
- * Clean up all watchers and subscriptions
2158
- *
2159
- * Call this when shutting down the server.
2160
- */
2161
- async dispose() {
2162
- const closePromises = [];
2163
- for (const [, watcher] of this.watchers) {
2164
- closePromises.push(watcher.close());
2217
+ }
2218
+ function extractStructuredOutputFromToolCall(lines) {
2219
+ for (let i = lines.length - 1; i >= 0; i--) {
2220
+ try {
2221
+ const data = JSON.parse(lines[i]);
2222
+ if (data.type === "assistant" && data.message?.content) {
2223
+ for (const block of data.message.content) {
2224
+ if (block.type === "tool_use" && block.name === "StructuredOutput") {
2225
+ return block.input;
2226
+ }
2227
+ }
2228
+ }
2229
+ } catch {
2165
2230
  }
2166
- await Promise.all(closePromises);
2167
- this.watchers.clear();
2168
- this.filePositions.clear();
2169
- this.subscriptions.clear();
2170
2231
  }
2171
- };
2172
-
2173
- // src/server/websocket.ts
2174
- function makeStoryKey(epicSlug, storySlug) {
2175
- return `${epicSlug}:${storySlug}`;
2232
+ return null;
2176
2233
  }
2177
- function toEpicSummary2(epic) {
2234
+ function validateAndExtractOutput(output) {
2235
+ if (!VALID_STATUSES.has(output.status)) {
2236
+ throw new Error(`Invalid status: ${output.status}`);
2237
+ }
2178
2238
  return {
2179
- slug: epic.slug,
2180
- title: epic.title,
2181
- storyCounts: epic.storyCounts,
2182
- path: epic.path
2239
+ status: output.status,
2240
+ summary: output.summary || "",
2241
+ blocker: output.blocker ?? null
2183
2242
  };
2184
2243
  }
2185
- async function createWebSocketServer(httpServer, sagaRoot) {
2186
- const wss = new import_ws.WebSocketServer({ server: httpServer });
2187
- const clients = /* @__PURE__ */ new Map();
2188
- let watcher = null;
2189
- try {
2190
- watcher = await createSagaWatcher(sagaRoot);
2191
- } catch (err) {
2192
- console.warn("Failed to create file watcher:", err);
2193
- }
2194
- const heartbeatInterval = setInterval(() => {
2195
- for (const [ws, state] of clients) {
2196
- if (!state.isAlive) {
2197
- clients.delete(ws);
2198
- ws.terminate();
2199
- continue;
2200
- }
2201
- state.isAlive = false;
2202
- ws.ping();
2203
- }
2204
- }, 3e4);
2205
- function sendToClient(ws, message) {
2206
- if (ws.readyState === import_ws.WebSocket.OPEN) {
2207
- ws.send(JSON.stringify(message));
2208
- }
2244
+ function processResultLine(data, lines) {
2245
+ if (data.is_error) {
2246
+ throw new Error(`Worker failed: ${data.result || "Unknown error"}`);
2209
2247
  }
2210
- function sendLogMessage(ws, message) {
2211
- if (ws.readyState === import_ws.WebSocket.OPEN) {
2212
- ws.send(JSON.stringify({ event: message.type, data: message }));
2213
- }
2248
+ let output = data.structured_output;
2249
+ if (!output) {
2250
+ output = extractStructuredOutputFromToolCall(lines) ?? void 0;
2214
2251
  }
2215
- const logStreamManager = new LogStreamManager(sendLogMessage);
2216
- function broadcast(message) {
2217
- for (const [ws] of clients) {
2218
- sendToClient(ws, message);
2219
- }
2252
+ if (!output) {
2253
+ throw new Error("Worker result missing structured_output");
2220
2254
  }
2221
- let previousSessionStates = /* @__PURE__ */ new Map();
2222
- startSessionPolling((msg) => {
2223
- broadcast({ event: msg.type, data: msg.sessions });
2224
- const currentStates = /* @__PURE__ */ new Map();
2225
- for (const session of msg.sessions) {
2226
- currentStates.set(session.name, session.status);
2227
- const previousStatus = previousSessionStates.get(session.name);
2228
- if (previousStatus === "running" && session.status === "completed") {
2229
- logStreamManager.notifySessionCompleted(session.name);
2255
+ return validateAndExtractOutput(output);
2256
+ }
2257
+ function parseStreamingResult(buffer) {
2258
+ const lines = buffer.split("\n").filter((line) => line.trim());
2259
+ for (let i = lines.length - 1; i >= 0; i--) {
2260
+ try {
2261
+ const data = JSON.parse(lines[i]);
2262
+ if (data.type === "result") {
2263
+ return processResultLine(data, lines);
2230
2264
  }
2231
- }
2232
- previousSessionStates = currentStates;
2233
- });
2234
- function broadcastToSubscribers(storyKey, message) {
2235
- for (const [ws, state] of clients) {
2236
- if (state.subscribedStories.has(storyKey)) {
2237
- sendToClient(ws, message);
2265
+ } catch (e) {
2266
+ if (e instanceof Error && e.message.startsWith("Worker")) {
2267
+ throw e;
2238
2268
  }
2239
2269
  }
2240
2270
  }
2241
- wss.on("connection", (ws) => {
2242
- const state = {
2243
- ws,
2244
- subscribedStories: /* @__PURE__ */ new Set(),
2245
- isAlive: true
2246
- };
2247
- clients.set(ws, state);
2248
- ws.on("pong", () => {
2249
- state.isAlive = true;
2271
+ throw new Error("No result found in worker output");
2272
+ }
2273
+ function spawnWorkerAsync(prompt, model, settings, workingDir) {
2274
+ return new Promise((resolve2, reject) => {
2275
+ let buffer = "";
2276
+ const args = [
2277
+ "-p",
2278
+ prompt,
2279
+ "--model",
2280
+ model,
2281
+ "--output-format",
2282
+ "stream-json",
2283
+ "--verbose",
2284
+ "--json-schema",
2285
+ JSON.stringify(WORKER_OUTPUT_SCHEMA),
2286
+ "--settings",
2287
+ JSON.stringify(settings),
2288
+ "--dangerously-skip-permissions"
2289
+ ];
2290
+ const child = (0, import_node_child_process2.spawn)("claude", args, {
2291
+ cwd: workingDir,
2292
+ stdio: ["ignore", "pipe", "pipe"]
2250
2293
  });
2251
- ws.on("message", (data) => {
2252
- try {
2253
- const message = JSON.parse(data.toString());
2254
- if (!message.event) {
2255
- return;
2256
- }
2257
- switch (message.event) {
2258
- case "subscribe:story": {
2259
- const { epicSlug, storySlug } = message.data || {};
2260
- if (epicSlug && storySlug) {
2261
- const key = makeStoryKey(epicSlug, storySlug);
2262
- state.subscribedStories.add(key);
2263
- }
2264
- break;
2265
- }
2266
- case "unsubscribe:story": {
2267
- const { epicSlug, storySlug } = message.data || {};
2268
- if (epicSlug && storySlug) {
2269
- const key = makeStoryKey(epicSlug, storySlug);
2270
- state.subscribedStories.delete(key);
2271
- }
2272
- break;
2273
- }
2274
- case "subscribe:logs": {
2275
- const { sessionName } = message.data || {};
2276
- if (sessionName) {
2277
- logStreamManager.subscribe(sessionName, ws);
2278
- }
2279
- break;
2280
- }
2281
- case "unsubscribe:logs": {
2282
- const { sessionName } = message.data || {};
2283
- if (sessionName) {
2284
- logStreamManager.unsubscribe(sessionName, ws);
2285
- }
2286
- break;
2294
+ child.stdout.on("data", (chunk) => {
2295
+ const text = chunk.toString();
2296
+ buffer += text;
2297
+ const lines = text.split("\n");
2298
+ for (const line of lines) {
2299
+ if (line.trim()) {
2300
+ const formatted = formatStreamLine(line);
2301
+ if (formatted) {
2302
+ import_node_process5.default.stdout.write(formatted);
2287
2303
  }
2288
- default:
2289
- break;
2290
2304
  }
2291
- } catch {
2292
2305
  }
2293
2306
  });
2294
- ws.on("close", () => {
2295
- clients.delete(ws);
2296
- logStreamManager.handleClientDisconnect(ws);
2307
+ child.stderr.on("data", (chunk) => {
2308
+ import_node_process5.default.stderr.write(chunk);
2297
2309
  });
2298
- ws.on("error", (err) => {
2299
- console.error("WebSocket error:", err);
2300
- clients.delete(ws);
2301
- logStreamManager.handleClientDisconnect(ws);
2310
+ child.on("error", (err) => {
2311
+ reject(new Error(`Failed to spawn worker: ${err.message}`));
2302
2312
  });
2303
- });
2304
- if (watcher) {
2305
- const handleEpicChange = async () => {
2306
- try {
2307
- const epics = await scanSagaDirectory(sagaRoot);
2308
- const summaries = epics.map(toEpicSummary2);
2309
- broadcast({ event: "epics:updated", data: summaries });
2310
- } catch (err) {
2311
- console.error("Error broadcasting epic update:", err);
2312
- }
2313
- };
2314
- watcher.on("epic:added", handleEpicChange);
2315
- watcher.on("epic:changed", handleEpicChange);
2316
- watcher.on("epic:removed", handleEpicChange);
2317
- const handleStoryChange = async (event) => {
2318
- const { epicSlug, storySlug, archived } = event;
2319
- if (!storySlug) return;
2320
- const storyKey = makeStoryKey(epicSlug, storySlug);
2321
- let hasSubscribers = false;
2322
- for (const [, state] of clients) {
2323
- if (state.subscribedStories.has(storyKey)) {
2324
- hasSubscribers = true;
2325
- break;
2326
- }
2327
- }
2328
- if (!hasSubscribers) {
2329
- await handleEpicChange();
2330
- return;
2331
- }
2313
+ child.on("close", (_code) => {
2314
+ import_node_process5.default.stdout.write("\n");
2332
2315
  try {
2333
- const storyPath = archived ? (0, import_path5.join)(sagaRoot, ".saga", "archive", epicSlug, storySlug, "story.md") : (0, import_path5.join)(sagaRoot, ".saga", "epics", epicSlug, "stories", storySlug, "story.md");
2334
- const story = await parseStory(storyPath, epicSlug);
2335
- if (story) {
2336
- story.paths.storyMd = (0, import_path5.relative)(sagaRoot, story.paths.storyMd);
2337
- if (story.paths.journalMd) {
2338
- story.paths.journalMd = (0, import_path5.relative)(sagaRoot, story.paths.journalMd);
2339
- }
2340
- story.archived = archived;
2341
- if (story.paths.journalMd) {
2342
- const journalPath = (0, import_path5.join)(sagaRoot, story.paths.journalMd);
2343
- const journal = await parseJournal(journalPath);
2344
- if (journal.length > 0) {
2345
- story.journal = journal;
2346
- }
2347
- }
2348
- broadcastToSubscribers(storyKey, { event: "story:updated", data: story });
2349
- }
2350
- await handleEpicChange();
2351
- } catch (err) {
2352
- console.error("Error broadcasting story update:", err);
2316
+ const result = parseStreamingResult(buffer);
2317
+ resolve2(result);
2318
+ } catch (e) {
2319
+ reject(e);
2353
2320
  }
2354
- };
2355
- watcher.on("story:added", handleStoryChange);
2356
- watcher.on("story:changed", handleStoryChange);
2357
- watcher.on("story:removed", handleStoryChange);
2358
- watcher.on("error", (err) => {
2359
- console.error("Watcher error:", err);
2360
2321
  });
2322
+ });
2323
+ }
2324
+ function createErrorResult(epicSlug, storySlug, summary, cycles, elapsedMinutes) {
2325
+ return {
2326
+ status: "ERROR",
2327
+ summary,
2328
+ cycles,
2329
+ elapsedMinutes,
2330
+ blocker: null,
2331
+ epicSlug,
2332
+ storySlug
2333
+ };
2334
+ }
2335
+ function validateLoopResources(worktree, epicSlug, storySlug, pluginRoot) {
2336
+ const validation = validateStoryFiles(worktree, epicSlug, storySlug);
2337
+ if (!validation.valid) {
2338
+ return { valid: false, error: validation.error || "Story validation failed" };
2339
+ }
2340
+ try {
2341
+ const workerPrompt = loadWorkerPrompt(pluginRoot);
2342
+ return { valid: true, workerPrompt };
2343
+ } catch (e) {
2344
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
2345
+ }
2346
+ }
2347
+ function buildLoopResult(epicSlug, storySlug, finalStatus, summaries, cycles, elapsedMinutes, lastBlocker) {
2348
+ const combinedSummary = summaries.length === 1 ? summaries[0] : summaries.join(" | ");
2349
+ return {
2350
+ status: finalStatus,
2351
+ summary: combinedSummary,
2352
+ cycles,
2353
+ elapsedMinutes: Math.round(elapsedMinutes * ROUNDING_PRECISION) / ROUNDING_PRECISION,
2354
+ blocker: lastBlocker,
2355
+ epicSlug,
2356
+ storySlug
2357
+ };
2358
+ }
2359
+ async function executeWorkerCycle(config, state) {
2360
+ if (Date.now() - config.startTime >= config.maxTimeMs) {
2361
+ state.finalStatus = "TIMEOUT";
2362
+ return { continue: false };
2363
+ }
2364
+ if (state.cycles >= config.maxCycles) {
2365
+ return { continue: false };
2366
+ }
2367
+ state.cycles += 1;
2368
+ try {
2369
+ const parsed = await spawnWorkerAsync(
2370
+ config.workerPrompt,
2371
+ config.model,
2372
+ config.settings,
2373
+ config.worktree
2374
+ );
2375
+ state.summaries.push(parsed.summary);
2376
+ if (parsed.status === "FINISH") {
2377
+ state.finalStatus = "FINISH";
2378
+ return { continue: false };
2379
+ }
2380
+ if (parsed.status === "BLOCKED") {
2381
+ state.finalStatus = "BLOCKED";
2382
+ state.lastBlocker = parsed.blocker || null;
2383
+ return { continue: false };
2384
+ }
2385
+ return { continue: true };
2386
+ } catch (e) {
2387
+ const elapsed = (Date.now() - config.startTime) / MS_PER_MINUTE;
2388
+ return {
2389
+ continue: false,
2390
+ result: createErrorResult(
2391
+ config.epicSlug,
2392
+ config.storySlug,
2393
+ e instanceof Error ? e.message : String(e),
2394
+ state.cycles,
2395
+ elapsed
2396
+ )
2397
+ };
2398
+ }
2399
+ }
2400
+ function executeWorkerLoop(workerPrompt, model, settings, worktree, maxCycles, maxTimeMs, startTime, epicSlug, storySlug) {
2401
+ const config = {
2402
+ workerPrompt,
2403
+ model,
2404
+ settings,
2405
+ worktree,
2406
+ maxCycles,
2407
+ maxTimeMs,
2408
+ startTime,
2409
+ epicSlug,
2410
+ storySlug
2411
+ };
2412
+ const state = { summaries: [], cycles: 0, lastBlocker: null, finalStatus: null };
2413
+ const runNextCycle = async () => {
2414
+ const cycleResult = await executeWorkerCycle(config, state);
2415
+ if (cycleResult.result) {
2416
+ return cycleResult.result;
2417
+ }
2418
+ if (cycleResult.continue) {
2419
+ return runNextCycle();
2420
+ }
2421
+ return state;
2422
+ };
2423
+ return runNextCycle();
2424
+ }
2425
+ async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot) {
2426
+ const worktree = (0, import_node_path11.join)(projectDir, ".saga", "worktrees", epicSlug, storySlug);
2427
+ const resources = validateLoopResources(worktree, epicSlug, storySlug, pluginRoot);
2428
+ if (!resources.valid) {
2429
+ return createErrorResult(epicSlug, storySlug, resources.error, 0, 0);
2430
+ }
2431
+ const settings = buildScopeSettings();
2432
+ const startTime = Date.now();
2433
+ const maxTimeMs = maxTime * SECONDS_PER_MINUTE * MS_PER_SECOND;
2434
+ const result = await executeWorkerLoop(
2435
+ resources.workerPrompt,
2436
+ model,
2437
+ settings,
2438
+ worktree,
2439
+ maxCycles,
2440
+ maxTimeMs,
2441
+ startTime,
2442
+ epicSlug,
2443
+ storySlug
2444
+ );
2445
+ if ("status" in result && result.status === "ERROR") {
2446
+ return result;
2447
+ }
2448
+ const state = result;
2449
+ const finalStatus = state.finalStatus ?? "MAX_CYCLES";
2450
+ const elapsedMinutes = (Date.now() - startTime) / MS_PER_MINUTE;
2451
+ return buildLoopResult(
2452
+ epicSlug,
2453
+ storySlug,
2454
+ finalStatus,
2455
+ state.summaries,
2456
+ state.cycles,
2457
+ elapsedMinutes,
2458
+ state.lastBlocker
2459
+ );
2460
+ }
2461
+ function buildDetachedCommand(storySlug, projectPath, options) {
2462
+ const parts = ["saga", "implement", storySlug];
2463
+ parts.push("--path", projectPath);
2464
+ if (options.maxCycles !== void 0) {
2465
+ parts.push("--max-cycles", String(options.maxCycles));
2466
+ }
2467
+ if (options.maxTime !== void 0) {
2468
+ parts.push("--max-time", String(options.maxTime));
2469
+ }
2470
+ if (options.model !== void 0) {
2471
+ parts.push("--model", options.model);
2472
+ }
2473
+ return shellEscapeArgs(parts);
2474
+ }
2475
+ function handleDryRun(storyInfo, projectPath, pluginRoot) {
2476
+ const dryRunResult = runDryRun(storyInfo, projectPath, pluginRoot);
2477
+ printDryRunResults(dryRunResult);
2478
+ import_node_process5.default.exit(dryRunResult.success ? 0 : 1);
2479
+ }
2480
+ async function handleDetachedMode(storySlug, storyInfo, projectPath, options) {
2481
+ const detachedCommand = buildDetachedCommand(storySlug, projectPath, {
2482
+ maxCycles: options.maxCycles,
2483
+ maxTime: options.maxTime,
2484
+ model: options.model
2485
+ });
2486
+ try {
2487
+ const sessionInfo = await createSession(
2488
+ storyInfo.epicSlug,
2489
+ storyInfo.storySlug,
2490
+ detachedCommand
2491
+ );
2492
+ console.log(JSON.stringify(sessionInfo, null, 2));
2493
+ } catch (error) {
2494
+ console.error(
2495
+ `Error creating session: ${error instanceof Error ? error.message : String(error)}`
2496
+ );
2497
+ import_node_process5.default.exit(1);
2498
+ }
2499
+ }
2500
+ async function handleInternalSession(storyInfo, projectPath, pluginRoot, options) {
2501
+ const maxCycles = options.maxCycles ?? DEFAULT_MAX_CYCLES;
2502
+ const maxTime = options.maxTime ?? DEFAULT_MAX_TIME;
2503
+ const model = options.model ?? DEFAULT_MODEL;
2504
+ console.log("Starting story implementation...");
2505
+ console.log(`Story: ${storyInfo.storySlug} (epic: ${storyInfo.epicSlug})`);
2506
+ console.log(`Max cycles: ${maxCycles}, Max time: ${maxTime}min, Model: ${model}`);
2507
+ console.log("");
2508
+ const result = await runLoop(
2509
+ storyInfo.epicSlug,
2510
+ storyInfo.storySlug,
2511
+ maxCycles,
2512
+ maxTime,
2513
+ model,
2514
+ projectPath,
2515
+ pluginRoot
2516
+ );
2517
+ if (result.status === "ERROR") {
2518
+ console.error(`Error: ${result.summary}`);
2519
+ import_node_process5.default.exit(1);
2520
+ }
2521
+ console.log(`
2522
+ Implementation ${result.status}: ${result.summary}`);
2523
+ }
2524
+ async function implementCommand(storySlug, options) {
2525
+ let projectPath;
2526
+ try {
2527
+ projectPath = resolveProjectPath(options.path);
2528
+ } catch (_error) {
2529
+ console.error("Error: SAGA project not found. Run saga init first or use --path option.");
2530
+ import_node_process5.default.exit(1);
2531
+ }
2532
+ const storyInfo = await findStory2(projectPath, storySlug);
2533
+ if (!storyInfo) {
2534
+ console.error(`Error: Story '${storySlug}' not found in project.`);
2535
+ console.error("Use /generate-stories to create stories for an epic first.");
2536
+ import_node_process5.default.exit(1);
2537
+ }
2538
+ const pluginRoot = import_node_process5.default.env.SAGA_PLUGIN_ROOT;
2539
+ if (options.dryRun) {
2540
+ handleDryRun(storyInfo, projectPath, pluginRoot);
2541
+ }
2542
+ if (!pluginRoot) {
2543
+ console.error("Error: SAGA_PLUGIN_ROOT environment variable is not set.");
2544
+ console.error("This is required to find the worker prompt template.");
2545
+ import_node_process5.default.exit(1);
2546
+ }
2547
+ if (!(0, import_node_fs6.existsSync)(storyInfo.worktreePath)) {
2548
+ console.error(`Error: Worktree not found at ${storyInfo.worktreePath}`);
2549
+ import_node_process5.default.exit(1);
2550
+ }
2551
+ const isInternalSession = import_node_process5.default.env.SAGA_INTERNAL_SESSION === "1";
2552
+ if (isInternalSession) {
2553
+ await handleInternalSession(storyInfo, projectPath, pluginRoot, options);
2554
+ } else {
2555
+ await handleDetachedMode(storySlug, storyInfo, projectPath, options);
2556
+ }
2557
+ }
2558
+
2559
+ // src/commands/init.ts
2560
+ var import_node_fs7 = require("node:fs");
2561
+ var import_node_path12 = require("node:path");
2562
+ var import_node_process6 = __toESM(require("node:process"), 1);
2563
+ var WORKTREES_PATTERN = ".saga/worktrees/";
2564
+ function runInitDryRun(targetPath) {
2565
+ const sagaDir = (0, import_node_path12.join)(targetPath, ".saga");
2566
+ const sagaExists = (0, import_node_fs7.existsSync)(sagaDir);
2567
+ const directories = [
2568
+ { name: "epics", path: (0, import_node_path12.join)(sagaDir, "epics") },
2569
+ { name: "archive", path: (0, import_node_path12.join)(sagaDir, "archive") },
2570
+ { name: "worktrees", path: (0, import_node_path12.join)(sagaDir, "worktrees") }
2571
+ ].map((dir) => ({
2572
+ path: dir.path,
2573
+ exists: (0, import_node_fs7.existsSync)(dir.path),
2574
+ action: (0, import_node_fs7.existsSync)(dir.path) ? "exists (skip)" : "will create"
2575
+ }));
2576
+ const gitignorePath = (0, import_node_path12.join)(targetPath, ".gitignore");
2577
+ const gitignoreExists = (0, import_node_fs7.existsSync)(gitignorePath);
2578
+ let hasPattern = false;
2579
+ if (gitignoreExists) {
2580
+ const content = (0, import_node_fs7.readFileSync)(gitignorePath, "utf-8");
2581
+ hasPattern = content.includes(WORKTREES_PATTERN);
2582
+ }
2583
+ let gitignoreAction;
2584
+ if (!gitignoreExists) {
2585
+ gitignoreAction = "will create with worktrees pattern";
2586
+ } else if (hasPattern) {
2587
+ gitignoreAction = "already has pattern (skip)";
2588
+ } else {
2589
+ gitignoreAction = "will append worktrees pattern";
2361
2590
  }
2362
2591
  return {
2363
- broadcastEpicsUpdated(epics) {
2364
- broadcast({ event: "epics:updated", data: epics });
2365
- },
2366
- broadcastStoryUpdated(story) {
2367
- const key = makeStoryKey(story.epicSlug, story.slug);
2368
- broadcastToSubscribers(key, { event: "story:updated", data: story });
2369
- },
2370
- async close() {
2371
- clearInterval(heartbeatInterval);
2372
- stopSessionPolling();
2373
- await logStreamManager.dispose();
2374
- for (const [ws] of clients) {
2375
- ws.close();
2376
- }
2377
- clients.clear();
2378
- if (watcher) {
2379
- await watcher.close();
2380
- }
2381
- return new Promise((resolve2, reject) => {
2382
- wss.close((err) => {
2383
- if (err) {
2384
- reject(err);
2385
- } else {
2386
- resolve2();
2387
- }
2388
- });
2389
- });
2592
+ targetPath,
2593
+ sagaExists,
2594
+ directories,
2595
+ gitignore: {
2596
+ path: gitignorePath,
2597
+ exists: gitignoreExists,
2598
+ hasPattern,
2599
+ action: gitignoreAction
2390
2600
  }
2391
2601
  };
2392
2602
  }
2393
-
2394
- // src/server/index.ts
2395
- var DEFAULT_PORT = 3847;
2396
- function createApp(sagaRoot) {
2397
- const app = (0, import_express3.default)();
2398
- app.use(import_express3.default.json());
2399
- app.use((_req, res, next) => {
2400
- res.header("Access-Control-Allow-Origin", "*");
2401
- res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
2402
- res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
2403
- next();
2404
- });
2405
- app.get("/api/health", (_req, res) => {
2406
- res.json({ status: "ok" });
2407
- });
2408
- app.use("/api", createApiRouter(sagaRoot));
2409
- const clientDistPath = (0, import_path6.join)(__dirname, "client");
2410
- const indexHtmlPath = (0, import_path6.join)(clientDistPath, "index.html");
2411
- app.use(import_express3.default.static(clientDistPath));
2412
- app.get("/{*splat}", (_req, res) => {
2413
- res.sendFile("index.html", { root: clientDistPath });
2414
- });
2415
- return app;
2603
+ function printInitDryRunResults(result) {
2604
+ console.log("Dry Run: SAGA Initialization");
2605
+ console.log(`Target: ${result.targetPath}`);
2606
+ console.log("");
2607
+ console.log("Directories:");
2608
+ for (const dir of result.directories) {
2609
+ const icon = dir.exists ? "-" : "+";
2610
+ console.log(` ${icon} ${dir.path}: ${dir.action}`);
2611
+ }
2612
+ console.log("");
2613
+ console.log(".gitignore:");
2614
+ console.log(` ${result.gitignore.action}`);
2615
+ console.log("");
2616
+ console.log("No changes made.");
2416
2617
  }
2417
- async function startServer(config) {
2418
- const port = config.port ?? DEFAULT_PORT;
2419
- const app = createApp(config.sagaRoot);
2420
- const httpServer = (0, import_http.createServer)(app);
2421
- const wsServer = await createWebSocketServer(httpServer, config.sagaRoot);
2422
- return new Promise((resolve2, reject) => {
2423
- httpServer.on("error", reject);
2424
- httpServer.listen(port, () => {
2425
- console.log(`SAGA Dashboard server running on http://localhost:${port}`);
2426
- resolve2({
2427
- app,
2428
- httpServer,
2429
- wsServer,
2430
- port,
2431
- close: async () => {
2432
- await wsServer.close();
2433
- return new Promise((resolveClose, rejectClose) => {
2434
- httpServer.close((err) => {
2435
- if (err) {
2436
- rejectClose(err);
2437
- } else {
2438
- resolveClose();
2439
- }
2440
- });
2441
- });
2442
- }
2443
- });
2444
- });
2445
- });
2618
+ function resolveTargetPath(explicitPath) {
2619
+ if (explicitPath) {
2620
+ return explicitPath;
2621
+ }
2622
+ const existingRoot = findProjectRoot();
2623
+ if (existingRoot) {
2624
+ return existingRoot;
2625
+ }
2626
+ return import_node_process6.default.cwd();
2446
2627
  }
2447
-
2448
- // src/commands/dashboard.ts
2449
- async function dashboardCommand(options) {
2450
- let projectPath;
2451
- try {
2452
- projectPath = resolveProjectPath(options.path);
2453
- } catch (error) {
2454
- console.error(`Error: ${error.message}`);
2455
- process.exit(1);
2628
+ function createDirectoryStructure(projectRoot) {
2629
+ const sagaDir = (0, import_node_path12.join)(projectRoot, ".saga");
2630
+ (0, import_node_fs7.mkdirSync)((0, import_node_path12.join)(sagaDir, "epics"), { recursive: true });
2631
+ (0, import_node_fs7.mkdirSync)((0, import_node_path12.join)(sagaDir, "archive"), { recursive: true });
2632
+ (0, import_node_fs7.mkdirSync)((0, import_node_path12.join)(sagaDir, "worktrees"), { recursive: true });
2633
+ }
2634
+ function updateGitignore(projectRoot) {
2635
+ const gitignorePath = (0, import_node_path12.join)(projectRoot, ".gitignore");
2636
+ if ((0, import_node_fs7.existsSync)(gitignorePath)) {
2637
+ const content = (0, import_node_fs7.readFileSync)(gitignorePath, "utf-8");
2638
+ if (content.includes(WORKTREES_PATTERN)) {
2639
+ } else {
2640
+ (0, import_node_fs7.appendFileSync)(
2641
+ gitignorePath,
2642
+ `
2643
+ # Claude Tasks - Worktrees (git worktree isolation for stories)
2644
+ ${WORKTREES_PATTERN}
2645
+ `
2646
+ );
2647
+ }
2648
+ } else {
2649
+ (0, import_node_fs7.writeFileSync)(
2650
+ gitignorePath,
2651
+ `# Claude Tasks - Worktrees (git worktree isolation for stories)
2652
+ ${WORKTREES_PATTERN}
2653
+ `
2654
+ );
2456
2655
  }
2457
- try {
2458
- const server = await startServer({
2459
- sagaRoot: projectPath,
2460
- port: options.port
2461
- });
2462
- console.log(`Project: ${projectPath}`);
2463
- process.on("SIGINT", async () => {
2464
- console.log("\nShutting down dashboard server...");
2465
- await server.close();
2466
- process.exit(0);
2467
- });
2468
- process.on("SIGTERM", async () => {
2469
- await server.close();
2470
- process.exit(0);
2471
- });
2472
- } catch (error) {
2473
- console.error(`Error starting server: ${error.message}`);
2474
- process.exit(1);
2656
+ }
2657
+ function initCommand(options) {
2658
+ if (options.path) {
2659
+ if (!(0, import_node_fs7.existsSync)(options.path)) {
2660
+ console.error(`Error: Path does not exist: ${options.path}`);
2661
+ import_node_process6.default.exit(1);
2662
+ }
2663
+ if (!(0, import_node_fs7.statSync)(options.path).isDirectory()) {
2664
+ console.error(`Error: Path is not a directory: ${options.path}`);
2665
+ import_node_process6.default.exit(1);
2666
+ }
2667
+ }
2668
+ const targetPath = resolveTargetPath(options.path);
2669
+ if (options.dryRun) {
2670
+ const dryRunResult = runInitDryRun(targetPath);
2671
+ printInitDryRunResults(dryRunResult);
2672
+ return;
2475
2673
  }
2674
+ createDirectoryStructure(targetPath);
2675
+ updateGitignore(targetPath);
2676
+ console.log(`Created .saga/ at ${targetPath}`);
2476
2677
  }
2477
2678
 
2478
2679
  // src/commands/scope-validator.ts
2479
- var import_node_path7 = require("node:path");
2680
+ var import_node_path13 = require("node:path");
2681
+ var import_node_process7 = __toESM(require("node:process"), 1);
2682
+ var FILE_PATH_WIDTH = 50;
2683
+ var EPIC_STORY_WIDTH = 43;
2684
+ var REASON_WIDTH = 56;
2480
2685
  function getFilePathFromInput(hookInput) {
2481
2686
  try {
2482
2687
  const data = JSON.parse(hookInput);
@@ -2496,10 +2701,10 @@ function isArchiveAccess(path) {
2496
2701
  return path.includes(".saga/archive");
2497
2702
  }
2498
2703
  function isWithinWorktree(filePath, worktreePath) {
2499
- const absoluteFilePath = (0, import_node_path7.resolve)(filePath);
2500
- const absoluteWorktree = (0, import_node_path7.resolve)(worktreePath);
2501
- const relativePath = (0, import_node_path7.relative)(absoluteWorktree, absoluteFilePath);
2502
- if (relativePath.startsWith("..") || (0, import_node_path7.resolve)(relativePath) === relativePath) {
2704
+ const absoluteFilePath = (0, import_node_path13.resolve)(filePath);
2705
+ const absoluteWorktree = (0, import_node_path13.resolve)(worktreePath);
2706
+ const relativePath = (0, import_node_path13.relative)(absoluteWorktree, absoluteFilePath);
2707
+ if (relativePath.startsWith("..") || (0, import_node_path13.resolve)(relativePath) === relativePath) {
2503
2708
  return false;
2504
2709
  }
2505
2710
  return true;
@@ -2517,106 +2722,108 @@ function checkStoryAccess(path, allowedEpic, allowedStory) {
2517
2722
  return true;
2518
2723
  }
2519
2724
  const pathEpic = parts[epicsIdx + 1];
2520
- if (parts.length > epicsIdx + 3 && parts[epicsIdx + 2] === "stories") {
2521
- const pathStory = parts[epicsIdx + 3];
2725
+ const storiesFolderIndex = 2;
2726
+ const storySlugIndex = 3;
2727
+ if (parts.length > epicsIdx + storySlugIndex && parts[epicsIdx + storiesFolderIndex] === "stories") {
2728
+ const pathStory = parts[epicsIdx + storySlugIndex];
2522
2729
  return pathEpic === allowedEpic && pathStory === allowedStory;
2523
- } else {
2524
- return pathEpic === allowedEpic;
2525
2730
  }
2731
+ return pathEpic === allowedEpic;
2526
2732
  }
2527
2733
  function printScopeViolation(filePath, epicSlug, storySlug, worktreePath, reason) {
2528
- console.error(`SCOPE VIOLATION: ${reason}
2529
-
2530
- Attempted path: ${filePath}
2531
-
2532
- Your scope is limited to:
2533
- Worktree: ${worktreePath}
2534
- Epic: ${epicSlug}
2535
- Story: ${storySlug}
2536
- Allowed story files: .saga/epics/${epicSlug}/stories/${storySlug}/
2537
-
2538
- Workers cannot access files outside the worktree directory.
2539
- To access other stories, start a new /implement session for that story.`);
2540
- }
2541
- async function scopeValidatorCommand() {
2542
- const worktreePath = process.env.SAGA_PROJECT_DIR || "";
2543
- const epicSlug = process.env.SAGA_EPIC_SLUG || "";
2544
- const storySlug = process.env.SAGA_STORY_SLUG || "";
2545
- if (!worktreePath || !epicSlug || !storySlug) {
2546
- console.error(
2547
- "ERROR: scope-validator requires SAGA_PROJECT_DIR, SAGA_EPIC_SLUG, and SAGA_STORY_SLUG environment variables"
2548
- );
2549
- process.exit(2);
2550
- }
2734
+ const message = [
2735
+ "",
2736
+ "\u256D\u2500 Scope Violation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E",
2737
+ "\u2502 \u2502",
2738
+ `\u2502 File: ${filePath.slice(0, FILE_PATH_WIDTH).padEnd(FILE_PATH_WIDTH)}\u2502`,
2739
+ "\u2502 \u2502",
2740
+ `\u2502 ${reason.split("\n")[0].padEnd(REASON_WIDTH)}\u2502`,
2741
+ "\u2502 \u2502",
2742
+ "\u2502 Current scope: \u2502",
2743
+ `\u2502 Epic: ${epicSlug.slice(0, EPIC_STORY_WIDTH).padEnd(EPIC_STORY_WIDTH)}\u2502`,
2744
+ `\u2502 Story: ${storySlug.slice(0, EPIC_STORY_WIDTH).padEnd(EPIC_STORY_WIDTH)}\u2502`,
2745
+ `\u2502 Worktree: ${worktreePath.slice(0, EPIC_STORY_WIDTH).padEnd(EPIC_STORY_WIDTH)}\u2502`,
2746
+ "\u2502 \u2502",
2747
+ "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F",
2748
+ ""
2749
+ ].join("\n");
2750
+ import_node_process7.default.stderr.write(message);
2751
+ }
2752
+ async function readStdinInput() {
2551
2753
  const chunks = [];
2552
- for await (const chunk of process.stdin) {
2754
+ for await (const chunk of import_node_process7.default.stdin) {
2553
2755
  chunks.push(chunk);
2554
2756
  }
2555
- const toolInput = Buffer.concat(chunks).toString("utf-8");
2556
- const filePath = getFilePathFromInput(toolInput);
2557
- if (!filePath) {
2558
- process.exit(0);
2757
+ return Buffer.concat(chunks).toString("utf-8");
2758
+ }
2759
+ function getScopeEnvironment() {
2760
+ const worktreePath = import_node_process7.default.env.SAGA_PROJECT_DIR || "";
2761
+ const epicSlug = import_node_process7.default.env.SAGA_EPIC_SLUG || "";
2762
+ const storySlug = import_node_process7.default.env.SAGA_STORY_SLUG || "";
2763
+ if (!(worktreePath && epicSlug && storySlug)) {
2764
+ return null;
2559
2765
  }
2766
+ return { worktreePath, epicSlug, storySlug };
2767
+ }
2768
+ function validatePath(filePath, worktreePath, epicSlug, storySlug) {
2560
2769
  const normPath = normalizePath(filePath);
2561
2770
  if (!isWithinWorktree(normPath, worktreePath)) {
2562
- printScopeViolation(
2563
- filePath,
2564
- epicSlug,
2565
- storySlug,
2566
- worktreePath,
2567
- "Access outside worktree blocked\nReason: Workers can only access files within their assigned worktree directory."
2568
- );
2569
- process.exit(2);
2771
+ return "Access outside worktree blocked\nReason: Workers can only access files within their assigned worktree directory.";
2570
2772
  }
2571
2773
  if (isArchiveAccess(normPath)) {
2572
- printScopeViolation(
2573
- filePath,
2574
- epicSlug,
2575
- storySlug,
2576
- worktreePath,
2577
- "Access to archive folder blocked\nReason: The archive folder contains completed stories and is read-only during execution."
2578
- );
2579
- process.exit(2);
2774
+ return "Access to archive folder blocked\nReason: The archive folder contains completed stories and is read-only during execution.";
2580
2775
  }
2581
2776
  if (!checkStoryAccess(normPath, epicSlug, storySlug)) {
2582
- printScopeViolation(
2583
- filePath,
2584
- epicSlug,
2585
- storySlug,
2586
- worktreePath,
2587
- "Access to other story blocked\nReason: Workers can only access their assigned story's files."
2588
- );
2589
- process.exit(2);
2777
+ return "Access to other story blocked\nReason: Workers can only access their assigned story's files.";
2778
+ }
2779
+ return null;
2780
+ }
2781
+ async function scopeValidatorCommand() {
2782
+ const env = getScopeEnvironment();
2783
+ if (!env) {
2784
+ import_node_process7.default.exit(2);
2785
+ }
2786
+ const toolInput = await readStdinInput();
2787
+ const filePath = getFilePathFromInput(toolInput);
2788
+ if (!filePath) {
2789
+ import_node_process7.default.exit(0);
2790
+ }
2791
+ const violation = validatePath(filePath, env.worktreePath, env.epicSlug, env.storySlug);
2792
+ if (violation) {
2793
+ printScopeViolation(filePath, env.epicSlug, env.storySlug, env.worktreePath, violation);
2794
+ import_node_process7.default.exit(2);
2590
2795
  }
2591
- process.exit(0);
2796
+ import_node_process7.default.exit(0);
2592
2797
  }
2593
2798
 
2594
- // src/commands/find.ts
2595
- async function findCommand(query, options) {
2596
- let projectPath;
2799
+ // src/commands/sessions/index.ts
2800
+ var import_node_process8 = __toESM(require("node:process"), 1);
2801
+ async function sessionsListCommand() {
2802
+ const sessions = await listSessions();
2803
+ console.log(JSON.stringify(sessions, null, 2));
2804
+ }
2805
+ async function sessionsStatusCommand(sessionName) {
2806
+ const status = await getSessionStatus(sessionName);
2807
+ console.log(JSON.stringify(status, null, 2));
2808
+ }
2809
+ async function sessionsLogsCommand(sessionName) {
2597
2810
  try {
2598
- projectPath = resolveProjectPath(options.path);
2811
+ await streamLogs(sessionName);
2599
2812
  } catch (error) {
2600
- console.log(JSON.stringify({ found: false, error: error.message }));
2601
- process.exit(1);
2602
- }
2603
- const type = options.type ?? "story";
2604
- let result;
2605
- if (type === "epic") {
2606
- result = findEpic(projectPath, query);
2607
- } else {
2608
- result = await findStory(projectPath, query, { status: options.status });
2813
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
2814
+ import_node_process8.default.exit(1);
2609
2815
  }
2816
+ }
2817
+ async function sessionsKillCommand(sessionName) {
2818
+ const result = await killSession(sessionName);
2610
2819
  console.log(JSON.stringify(result, null, 2));
2611
- if (!result.found) {
2612
- process.exit(1);
2613
- }
2614
2820
  }
2615
2821
 
2616
2822
  // src/commands/worktree.ts
2617
- var import_node_path8 = require("node:path");
2618
- var import_node_fs7 = require("node:fs");
2619
2823
  var import_node_child_process3 = require("node:child_process");
2824
+ var import_node_fs8 = require("node:fs");
2825
+ var import_node_path14 = require("node:path");
2826
+ var import_node_process9 = __toESM(require("node:process"), 1);
2620
2827
  function runGitCommand(args, cwd) {
2621
2828
  try {
2622
2829
  const output = (0, import_node_child_process3.execSync)(`git ${args.join(" ")}`, {
@@ -2626,7 +2833,8 @@ function runGitCommand(args, cwd) {
2626
2833
  });
2627
2834
  return { success: true, output: output.trim() };
2628
2835
  } catch (error) {
2629
- const stderr = error.stderr?.toString().trim() || error.message;
2836
+ const execError = error;
2837
+ const stderr = execError.stderr?.toString().trim() || execError.message || String(error);
2630
2838
  return { success: false, output: stderr };
2631
2839
  }
2632
2840
  }
@@ -2635,10 +2843,7 @@ function branchExists(branchName, cwd) {
2635
2843
  return result.success;
2636
2844
  }
2637
2845
  function getMainBranch(cwd) {
2638
- const result = runGitCommand(
2639
- ["symbolic-ref", "refs/remotes/origin/HEAD"],
2640
- cwd
2641
- );
2846
+ const result = runGitCommand(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
2642
2847
  if (result.success) {
2643
2848
  return result.output.replace("refs/remotes/origin/", "");
2644
2849
  }
@@ -2646,20 +2851,14 @@ function getMainBranch(cwd) {
2646
2851
  }
2647
2852
  function createWorktree(projectPath, epicSlug, storySlug) {
2648
2853
  const branchName = `story-${storySlug}-epic-${epicSlug}`;
2649
- const worktreePath = (0, import_node_path8.join)(
2650
- projectPath,
2651
- ".saga",
2652
- "worktrees",
2653
- epicSlug,
2654
- storySlug
2655
- );
2854
+ const worktreePath = (0, import_node_path14.join)(projectPath, ".saga", "worktrees", epicSlug, storySlug);
2656
2855
  if (branchExists(branchName, projectPath)) {
2657
2856
  return {
2658
2857
  success: false,
2659
2858
  error: `Branch already exists: ${branchName}`
2660
2859
  };
2661
2860
  }
2662
- if ((0, import_node_fs7.existsSync)(worktreePath)) {
2861
+ if ((0, import_node_fs8.existsSync)(worktreePath)) {
2663
2862
  return {
2664
2863
  success: false,
2665
2864
  error: `Worktree directory already exists: ${worktreePath}`
@@ -2677,8 +2876,8 @@ function createWorktree(projectPath, epicSlug, storySlug) {
2677
2876
  error: `Failed to create branch: ${createBranchResult.output}`
2678
2877
  };
2679
2878
  }
2680
- const worktreeParent = (0, import_node_path8.join)(projectPath, ".saga", "worktrees", epicSlug);
2681
- (0, import_node_fs7.mkdirSync)(worktreeParent, { recursive: true });
2879
+ const worktreeParent = (0, import_node_path14.join)(projectPath, ".saga", "worktrees", epicSlug);
2880
+ (0, import_node_fs8.mkdirSync)(worktreeParent, { recursive: true });
2682
2881
  const createWorktreeResult = runGitCommand(
2683
2882
  ["worktree", "add", worktreePath, branchName],
2684
2883
  projectPath
@@ -2695,47 +2894,28 @@ function createWorktree(projectPath, epicSlug, storySlug) {
2695
2894
  branch: branchName
2696
2895
  };
2697
2896
  }
2698
- async function worktreeCommand(epicSlug, storySlug, options) {
2897
+ function worktreeCommand(epicSlug, storySlug, options) {
2699
2898
  let projectPath;
2700
2899
  try {
2701
2900
  projectPath = resolveProjectPath(options.path);
2702
2901
  } catch (error) {
2703
- const result2 = { success: false, error: error.message };
2704
- console.log(JSON.stringify(result2));
2705
- process.exit(1);
2902
+ const result2 = {
2903
+ success: false,
2904
+ error: error instanceof Error ? error.message : String(error)
2905
+ };
2906
+ console.log(JSON.stringify(result2, null, 2));
2907
+ import_node_process9.default.exit(1);
2706
2908
  }
2707
2909
  const result = createWorktree(projectPath, epicSlug, storySlug);
2708
- console.log(JSON.stringify(result));
2910
+ console.log(JSON.stringify(result, null, 2));
2709
2911
  if (!result.success) {
2710
- process.exit(1);
2711
- }
2712
- }
2713
-
2714
- // src/commands/sessions/index.ts
2715
- async function sessionsListCommand() {
2716
- const sessions = await listSessions();
2717
- console.log(JSON.stringify(sessions, null, 2));
2718
- }
2719
- async function sessionsStatusCommand(sessionName) {
2720
- const status = await getSessionStatus(sessionName);
2721
- console.log(JSON.stringify(status, null, 2));
2722
- }
2723
- async function sessionsLogsCommand(sessionName) {
2724
- try {
2725
- await streamLogs(sessionName);
2726
- } catch (error) {
2727
- console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
2728
- process.exit(1);
2912
+ import_node_process9.default.exit(1);
2729
2913
  }
2730
2914
  }
2731
- async function sessionsKillCommand(sessionName) {
2732
- const result = await killSession(sessionName);
2733
- console.log(JSON.stringify(result, null, 2));
2734
- }
2735
2915
 
2736
2916
  // src/cli.ts
2737
- var packageJsonPath = (0, import_node_path9.join)(__dirname, "..", "package.json");
2738
- var packageJson = JSON.parse((0, import_node_fs8.readFileSync)(packageJsonPath, "utf-8"));
2917
+ var packageJsonPath = (0, import_node_path15.join)(__dirname, "..", "package.json");
2918
+ var packageJson = JSON.parse((0, import_node_fs9.readFileSync)(packageJsonPath, "utf-8"));
2739
2919
  var program = new import_commander.Command();
2740
2920
  program.name("saga").description("CLI for SAGA - Structured Autonomous Goal Achievement").version(packageJson.version).addHelpCommand("help [command]", "Display help for a command");
2741
2921
  program.option("-p, --path <dir>", "Path to SAGA project directory (overrides auto-discovery)");
@@ -2743,16 +2923,18 @@ program.command("init").description("Initialize .saga/ directory structure").opt
2743
2923
  const globalOpts = program.opts();
2744
2924
  await initCommand({ path: globalOpts.path, dryRun: options.dryRun });
2745
2925
  });
2746
- program.command("implement <story-slug>").description("Run story implementation").option("--max-cycles <n>", "Maximum number of implementation cycles", parseInt).option("--max-time <n>", "Maximum time in minutes", parseInt).option("--model <name>", "Model to use for implementation").option("--dry-run", "Validate dependencies without running implementation").action(async (storySlug, options) => {
2747
- const globalOpts = program.opts();
2748
- await implementCommand(storySlug, {
2749
- path: globalOpts.path,
2750
- maxCycles: options.maxCycles,
2751
- maxTime: options.maxTime,
2752
- model: options.model,
2753
- dryRun: options.dryRun
2754
- });
2755
- });
2926
+ program.command("implement <story-slug>").description("Run story implementation").option("--max-cycles <n>", "Maximum number of implementation cycles", Number.parseInt).option("--max-time <n>", "Maximum time in minutes", Number.parseInt).option("--model <name>", "Model to use for implementation").option("--dry-run", "Validate dependencies without running implementation").action(
2927
+ async (storySlug, options) => {
2928
+ const globalOpts = program.opts();
2929
+ await implementCommand(storySlug, {
2930
+ path: globalOpts.path,
2931
+ maxCycles: options.maxCycles,
2932
+ maxTime: options.maxTime,
2933
+ model: options.model,
2934
+ dryRun: options.dryRun
2935
+ });
2936
+ }
2937
+ );
2756
2938
  program.command("find <query>").description("Find an epic or story by slug/title").option("--type <type>", "Type to search for: epic or story (default: story)").option("--status <status>", "Filter stories by status (e.g., ready, in-progress, completed)").action(async (query, options) => {
2757
2939
  const globalOpts = program.opts();
2758
2940
  await findCommand(query, {
@@ -2765,7 +2947,7 @@ program.command("worktree <epic-slug> <story-slug>").description("Create git wor
2765
2947
  const globalOpts = program.opts();
2766
2948
  await worktreeCommand(epicSlug, storySlug, { path: globalOpts.path });
2767
2949
  });
2768
- program.command("dashboard").description("Start the dashboard server").option("--port <n>", "Port to run the server on (default: 3847)", parseInt).action(async (options) => {
2950
+ program.command("dashboard").description("Start the dashboard server").option("--port <n>", "Port to run the server on (default: 3847)", Number.parseInt).action(async (options) => {
2769
2951
  const globalOpts = program.opts();
2770
2952
  await dashboardCommand({
2771
2953
  path: globalOpts.path,
@@ -2790,6 +2972,6 @@ sessionsCommand.command("kill <name>").description("Terminate a session").action
2790
2972
  });
2791
2973
  program.on("command:*", (operands) => {
2792
2974
  console.error(`error: unknown command '${operands[0]}'`);
2793
- process.exit(1);
2975
+ import_node_process10.default.exit(1);
2794
2976
  });
2795
2977
  program.parse();