@saga-ai/cli 0.7.0 → 0.7.2
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/README.md +21 -0
- package/dist/cli.cjs +47 -126
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ npx @saga-ai/cli init
|
|
|
16
16
|
npx @saga-ai/cli find <query>
|
|
17
17
|
npx @saga-ai/cli worktree <epic-slug> <story-slug>
|
|
18
18
|
npx @saga-ai/cli implement <story-slug>
|
|
19
|
+
npx @saga-ai/cli sessions list
|
|
19
20
|
npx @saga-ai/cli dashboard
|
|
20
21
|
```
|
|
21
22
|
|
|
@@ -49,6 +50,7 @@ saga find auth --type epic
|
|
|
49
50
|
|
|
50
51
|
Options:
|
|
51
52
|
- `--type <type>` - Type to search for: `epic` or `story` (default: story)
|
|
53
|
+
- `--status <status>` - Filter stories by status: `ready`, `completed`, `blocked`, etc.
|
|
52
54
|
|
|
53
55
|
Returns JSON with:
|
|
54
56
|
- `found: true` + `data` - Single match found
|
|
@@ -90,8 +92,27 @@ Options:
|
|
|
90
92
|
- `--max-time <n>` - Maximum time in minutes (default: 60)
|
|
91
93
|
- `--model <name>` - Model to use (default: opus)
|
|
92
94
|
|
|
95
|
+
By default, `implement` runs in **detached mode** using a tmux session. This allows the worker to continue running even if your terminal disconnects. Use `saga sessions` to monitor detached sessions.
|
|
96
|
+
|
|
93
97
|
**Note:** Requires `SAGA_PLUGIN_ROOT` environment variable to be set. This is automatically configured when running via the SAGA plugin.
|
|
94
98
|
|
|
99
|
+
### `saga sessions`
|
|
100
|
+
|
|
101
|
+
Manage tmux sessions created by detached `implement` runs.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
saga sessions list # List all SAGA sessions
|
|
105
|
+
saga sessions status <name> # Check if session is running
|
|
106
|
+
saga sessions logs <name> # Stream session output
|
|
107
|
+
saga sessions kill <name> # Terminate a session
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Subcommands:
|
|
111
|
+
- `list` - Returns JSON array of all SAGA sessions with name, status, and output file
|
|
112
|
+
- `status <name>` - Returns JSON `{running: boolean}` for the session
|
|
113
|
+
- `logs <name>` - Streams the session output file via `tail -f`
|
|
114
|
+
- `kill <name>` - Terminates the session, returns JSON `{killed: boolean}`
|
|
115
|
+
|
|
95
116
|
### `saga dashboard`
|
|
96
117
|
|
|
97
118
|
Start the SAGA dashboard server for viewing epics, stories, and progress.
|
package/dist/cli.cjs
CHANGED
|
@@ -630,6 +630,9 @@ async function createSession(epicSlug, storySlug, command) {
|
|
|
630
630
|
# Auto-generated wrapper script for SAGA session
|
|
631
631
|
set -e
|
|
632
632
|
|
|
633
|
+
# Mark this as an internal SAGA session (used by CLI to detect it's running inside tmux)
|
|
634
|
+
export SAGA_INTERNAL_SESSION=1
|
|
635
|
+
|
|
633
636
|
COMMAND_FILE="${commandFilePath}"
|
|
634
637
|
OUTPUT_FILE="${outputFile}"
|
|
635
638
|
SCRIPT_FILE="${wrapperScriptPath}"
|
|
@@ -958,64 +961,6 @@ function buildScopeSettings() {
|
|
|
958
961
|
}
|
|
959
962
|
};
|
|
960
963
|
}
|
|
961
|
-
function parseWorkerOutput(output) {
|
|
962
|
-
if (!output || !output.trim()) {
|
|
963
|
-
throw new Error("Worker output is empty");
|
|
964
|
-
}
|
|
965
|
-
let cliResponse;
|
|
966
|
-
try {
|
|
967
|
-
cliResponse = JSON.parse(output.trim());
|
|
968
|
-
} catch (e) {
|
|
969
|
-
throw new Error(`Invalid JSON in worker output: ${e}`);
|
|
970
|
-
}
|
|
971
|
-
if (!("structured_output" in cliResponse)) {
|
|
972
|
-
if (cliResponse.is_error) {
|
|
973
|
-
const errorMsg = cliResponse.result || "Unknown error";
|
|
974
|
-
throw new Error(`Worker failed: ${errorMsg}`);
|
|
975
|
-
}
|
|
976
|
-
throw new Error(`Worker output missing structured_output field. Got keys: ${Object.keys(cliResponse).join(", ")}`);
|
|
977
|
-
}
|
|
978
|
-
const parsed = cliResponse.structured_output;
|
|
979
|
-
if (!("status" in parsed)) {
|
|
980
|
-
throw new Error("Worker output missing required field: status");
|
|
981
|
-
}
|
|
982
|
-
if (!("summary" in parsed)) {
|
|
983
|
-
throw new Error("Worker output missing required field: summary");
|
|
984
|
-
}
|
|
985
|
-
if (!VALID_STATUSES.has(parsed.status)) {
|
|
986
|
-
throw new Error(`Invalid status value: ${parsed.status}. Must be one of: ${[...VALID_STATUSES].join(", ")}`);
|
|
987
|
-
}
|
|
988
|
-
return {
|
|
989
|
-
status: parsed.status,
|
|
990
|
-
summary: parsed.summary,
|
|
991
|
-
blocker: parsed.blocker ?? null
|
|
992
|
-
};
|
|
993
|
-
}
|
|
994
|
-
function spawnWorker(prompt, model, settings, workingDir) {
|
|
995
|
-
const args = [
|
|
996
|
-
"-p",
|
|
997
|
-
prompt,
|
|
998
|
-
"--model",
|
|
999
|
-
model,
|
|
1000
|
-
"--output-format",
|
|
1001
|
-
"json",
|
|
1002
|
-
"--json-schema",
|
|
1003
|
-
JSON.stringify(WORKER_OUTPUT_SCHEMA),
|
|
1004
|
-
"--settings",
|
|
1005
|
-
JSON.stringify(settings),
|
|
1006
|
-
"--dangerously-skip-permissions"
|
|
1007
|
-
];
|
|
1008
|
-
const result = (0, import_node_child_process2.spawnSync)("claude", args, {
|
|
1009
|
-
cwd: workingDir,
|
|
1010
|
-
encoding: "utf-8",
|
|
1011
|
-
maxBuffer: 50 * 1024 * 1024
|
|
1012
|
-
// 50MB buffer for large outputs
|
|
1013
|
-
});
|
|
1014
|
-
if (result.error) {
|
|
1015
|
-
throw new Error(`Failed to spawn worker: ${result.error.message}`);
|
|
1016
|
-
}
|
|
1017
|
-
return result.stdout || "";
|
|
1018
|
-
}
|
|
1019
964
|
function formatStreamLine(line) {
|
|
1020
965
|
try {
|
|
1021
966
|
const data = JSON.parse(line);
|
|
@@ -1042,6 +987,22 @@ function formatStreamLine(line) {
|
|
|
1042
987
|
return null;
|
|
1043
988
|
}
|
|
1044
989
|
}
|
|
990
|
+
function extractStructuredOutputFromToolCall(lines) {
|
|
991
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
992
|
+
try {
|
|
993
|
+
const data = JSON.parse(lines[i]);
|
|
994
|
+
if (data.type === "assistant" && data.message?.content) {
|
|
995
|
+
for (const block of data.message.content) {
|
|
996
|
+
if (block.type === "tool_use" && block.name === "StructuredOutput") {
|
|
997
|
+
return block.input;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
} catch {
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1045
1006
|
function parseStreamingResult(buffer) {
|
|
1046
1007
|
const lines = buffer.split("\n").filter((line) => line.trim());
|
|
1047
1008
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
@@ -1051,10 +1012,13 @@ function parseStreamingResult(buffer) {
|
|
|
1051
1012
|
if (data.is_error) {
|
|
1052
1013
|
throw new Error(`Worker failed: ${data.result || "Unknown error"}`);
|
|
1053
1014
|
}
|
|
1054
|
-
|
|
1015
|
+
let output = data.structured_output;
|
|
1016
|
+
if (!output) {
|
|
1017
|
+
output = extractStructuredOutputFromToolCall(lines);
|
|
1018
|
+
}
|
|
1019
|
+
if (!output) {
|
|
1055
1020
|
throw new Error("Worker result missing structured_output");
|
|
1056
1021
|
}
|
|
1057
|
-
const output = data.structured_output;
|
|
1058
1022
|
if (!VALID_STATUSES.has(output.status)) {
|
|
1059
1023
|
throw new Error(`Invalid status: ${output.status}`);
|
|
1060
1024
|
}
|
|
@@ -1123,7 +1087,7 @@ function spawnWorkerAsync(prompt, model, settings, workingDir) {
|
|
|
1123
1087
|
});
|
|
1124
1088
|
});
|
|
1125
1089
|
}
|
|
1126
|
-
async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot
|
|
1090
|
+
async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot) {
|
|
1127
1091
|
const worktree = (0, import_node_path5.join)(projectDir, ".saga", "worktrees", epicSlug, storySlug);
|
|
1128
1092
|
const validation = validateStoryFiles(worktree, epicSlug, storySlug);
|
|
1129
1093
|
if (!validation.valid) {
|
|
@@ -1166,55 +1130,25 @@ async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDi
|
|
|
1166
1130
|
}
|
|
1167
1131
|
cycles += 1;
|
|
1168
1132
|
let parsed;
|
|
1169
|
-
|
|
1170
|
-
console.log(`
|
|
1133
|
+
console.log(`
|
|
1171
1134
|
--- Worker ${cycles} started ---
|
|
1172
1135
|
`);
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1136
|
+
try {
|
|
1137
|
+
parsed = await spawnWorkerAsync(workerPrompt, model, settings, worktree);
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
return {
|
|
1140
|
+
status: "ERROR",
|
|
1141
|
+
summary: e.message,
|
|
1142
|
+
cycles,
|
|
1143
|
+
elapsedMinutes: (Date.now() - startTime) / 6e4,
|
|
1144
|
+
blocker: null,
|
|
1145
|
+
epicSlug,
|
|
1146
|
+
storySlug
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
console.log(`
|
|
1187
1150
|
--- Worker ${cycles} result: ${parsed.status} ---
|
|
1188
1151
|
`);
|
|
1189
|
-
} else {
|
|
1190
|
-
let output;
|
|
1191
|
-
try {
|
|
1192
|
-
output = spawnWorker(workerPrompt, model, settings, worktree);
|
|
1193
|
-
} catch (e) {
|
|
1194
|
-
return {
|
|
1195
|
-
status: "ERROR",
|
|
1196
|
-
summary: e.message,
|
|
1197
|
-
cycles,
|
|
1198
|
-
elapsedMinutes: (Date.now() - startTime) / 6e4,
|
|
1199
|
-
blocker: null,
|
|
1200
|
-
epicSlug,
|
|
1201
|
-
storySlug
|
|
1202
|
-
};
|
|
1203
|
-
}
|
|
1204
|
-
try {
|
|
1205
|
-
parsed = parseWorkerOutput(output);
|
|
1206
|
-
} catch (e) {
|
|
1207
|
-
return {
|
|
1208
|
-
status: "ERROR",
|
|
1209
|
-
summary: e.message,
|
|
1210
|
-
cycles,
|
|
1211
|
-
elapsedMinutes: (Date.now() - startTime) / 6e4,
|
|
1212
|
-
blocker: null,
|
|
1213
|
-
epicSlug,
|
|
1214
|
-
storySlug
|
|
1215
|
-
};
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
1152
|
summaries.push(parsed.summary);
|
|
1219
1153
|
if (parsed.status === "FINISH") {
|
|
1220
1154
|
finalStatus = "FINISH";
|
|
@@ -1241,7 +1175,7 @@ async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDi
|
|
|
1241
1175
|
};
|
|
1242
1176
|
}
|
|
1243
1177
|
function buildDetachedCommand(storySlug, projectPath, options) {
|
|
1244
|
-
const parts = ["saga", "implement", storySlug
|
|
1178
|
+
const parts = ["saga", "implement", storySlug];
|
|
1245
1179
|
parts.push("--path", projectPath);
|
|
1246
1180
|
if (options.maxCycles !== void 0) {
|
|
1247
1181
|
parts.push("--max-cycles", String(options.maxCycles));
|
|
@@ -1252,9 +1186,6 @@ function buildDetachedCommand(storySlug, projectPath, options) {
|
|
|
1252
1186
|
if (options.model !== void 0) {
|
|
1253
1187
|
parts.push("--model", options.model);
|
|
1254
1188
|
}
|
|
1255
|
-
if (options.stream) {
|
|
1256
|
-
parts.push("--stream");
|
|
1257
|
-
}
|
|
1258
1189
|
return shellEscapeArgs(parts);
|
|
1259
1190
|
}
|
|
1260
1191
|
async function implementCommand(storySlug, options) {
|
|
@@ -1297,18 +1228,12 @@ Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`)
|
|
|
1297
1228
|
const maxCycles = options.maxCycles ?? DEFAULT_MAX_CYCLES;
|
|
1298
1229
|
const maxTime = options.maxTime ?? DEFAULT_MAX_TIME;
|
|
1299
1230
|
const model = options.model ?? DEFAULT_MODEL;
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
if (!attached) {
|
|
1303
|
-
if (stream) {
|
|
1304
|
-
console.error("Warning: --stream is ignored in detached mode. Use --attached --stream for streaming output.");
|
|
1305
|
-
}
|
|
1231
|
+
const isInternalSession = process.env.SAGA_INTERNAL_SESSION === "1";
|
|
1232
|
+
if (!isInternalSession) {
|
|
1306
1233
|
const detachedCommand = buildDetachedCommand(storySlug, projectPath, {
|
|
1307
1234
|
maxCycles: options.maxCycles,
|
|
1308
1235
|
maxTime: options.maxTime,
|
|
1309
|
-
model: options.model
|
|
1310
|
-
stream: true
|
|
1311
|
-
// Always use streaming in detached mode so output is captured
|
|
1236
|
+
model: options.model
|
|
1312
1237
|
});
|
|
1313
1238
|
try {
|
|
1314
1239
|
const sessionResult = await createSession(
|
|
@@ -1334,7 +1259,6 @@ Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`)
|
|
|
1334
1259
|
console.log(` Epic: ${storyInfo.epicSlug}`);
|
|
1335
1260
|
console.log(` Story: ${storyInfo.storySlug}`);
|
|
1336
1261
|
console.log(` Worktree: ${storyInfo.worktreePath}`);
|
|
1337
|
-
console.log(` Streaming: ${stream ? "enabled" : "disabled"}`);
|
|
1338
1262
|
console.log("");
|
|
1339
1263
|
const result = await runLoop(
|
|
1340
1264
|
storyInfo.epicSlug,
|
|
@@ -1343,8 +1267,7 @@ Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`)
|
|
|
1343
1267
|
maxTime,
|
|
1344
1268
|
model,
|
|
1345
1269
|
projectPath,
|
|
1346
|
-
pluginRoot
|
|
1347
|
-
stream
|
|
1270
|
+
pluginRoot
|
|
1348
1271
|
);
|
|
1349
1272
|
console.log(JSON.stringify(result, null, 2));
|
|
1350
1273
|
if (result.status === "ERROR") {
|
|
@@ -2288,16 +2211,14 @@ program.command("init").description("Initialize .saga/ directory structure").opt
|
|
|
2288
2211
|
const globalOpts = program.opts();
|
|
2289
2212
|
await initCommand({ path: globalOpts.path, dryRun: options.dryRun });
|
|
2290
2213
|
});
|
|
2291
|
-
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").
|
|
2214
|
+
program.command("implement <story-slug>").description("Run story implementation").option("--max-cycles <n>", "Maximum number of implementation cycles", parseInt).option("--max-time <n>", "Maximum time in minutes", parseInt).option("--model <name>", "Model to use for implementation").option("--dry-run", "Validate dependencies without running implementation").action(async (storySlug, options) => {
|
|
2292
2215
|
const globalOpts = program.opts();
|
|
2293
2216
|
await implementCommand(storySlug, {
|
|
2294
2217
|
path: globalOpts.path,
|
|
2295
2218
|
maxCycles: options.maxCycles,
|
|
2296
2219
|
maxTime: options.maxTime,
|
|
2297
2220
|
model: options.model,
|
|
2298
|
-
dryRun: options.dryRun
|
|
2299
|
-
stream: options.stream,
|
|
2300
|
-
attached: options.attached
|
|
2221
|
+
dryRun: options.dryRun
|
|
2301
2222
|
});
|
|
2302
2223
|
});
|
|
2303
2224
|
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) => {
|