@saga-ai/cli 2.14.0 → 2.15.1

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