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