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