@saga-ai/cli 2.13.0 → 2.15.0

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