@saga-ai/cli 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/cli.cjs +322 -49
- package/package.json +14 -3
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
|
|
@@ -83,15 +85,37 @@ saga implement my-story
|
|
|
83
85
|
saga implement my-story --max-cycles 5
|
|
84
86
|
saga implement my-story --max-time 30
|
|
85
87
|
saga implement my-story --model sonnet
|
|
88
|
+
saga implement my-story --attached --stream
|
|
86
89
|
```
|
|
87
90
|
|
|
88
91
|
Options:
|
|
89
92
|
- `--max-cycles <n>` - Maximum number of worker cycles (default: 10)
|
|
90
93
|
- `--max-time <n>` - Maximum time in minutes (default: 60)
|
|
91
94
|
- `--model <name>` - Model to use (default: opus)
|
|
95
|
+
- `--attached` - Run in attached mode (synchronous). Default is detached (runs in tmux session)
|
|
96
|
+
- `--stream` - Stream worker output to stdout (only works with `--attached`)
|
|
97
|
+
|
|
98
|
+
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.
|
|
92
99
|
|
|
93
100
|
**Note:** Requires `SAGA_PLUGIN_ROOT` environment variable to be set. This is automatically configured when running via the SAGA plugin.
|
|
94
101
|
|
|
102
|
+
### `saga sessions`
|
|
103
|
+
|
|
104
|
+
Manage tmux sessions created by detached `implement` runs.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
saga sessions list # List all SAGA sessions
|
|
108
|
+
saga sessions status <name> # Check if session is running
|
|
109
|
+
saga sessions logs <name> # Stream session output
|
|
110
|
+
saga sessions kill <name> # Terminate a session
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Subcommands:
|
|
114
|
+
- `list` - Returns JSON array of all SAGA sessions with name, status, and output file
|
|
115
|
+
- `status <name>` - Returns JSON `{running: boolean}` for the session
|
|
116
|
+
- `logs <name>` - Streams the session output file via `tail -f`
|
|
117
|
+
- `kill <name>` - Terminates the session, returns JSON `{killed: boolean}`
|
|
118
|
+
|
|
95
119
|
### `saga dashboard`
|
|
96
120
|
|
|
97
121
|
Start the SAGA dashboard server for viewing epics, stories, and progress.
|
package/dist/cli.cjs
CHANGED
|
@@ -25,8 +25,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
25
25
|
|
|
26
26
|
// src/cli.ts
|
|
27
27
|
var import_commander = require("commander");
|
|
28
|
-
var
|
|
29
|
-
var
|
|
28
|
+
var import_node_path8 = require("node:path");
|
|
29
|
+
var import_node_fs7 = require("node:fs");
|
|
30
30
|
|
|
31
31
|
// src/commands/init.ts
|
|
32
32
|
var import_node_path2 = require("node:path");
|
|
@@ -193,9 +193,9 @@ async function initCommand(options) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
// src/commands/implement.ts
|
|
196
|
-
var
|
|
197
|
-
var
|
|
198
|
-
var
|
|
196
|
+
var import_node_child_process2 = require("node:child_process");
|
|
197
|
+
var import_node_path5 = require("node:path");
|
|
198
|
+
var import_node_fs5 = require("node:fs");
|
|
199
199
|
|
|
200
200
|
// src/utils/finder.ts
|
|
201
201
|
var import_node_fs3 = require("node:fs");
|
|
@@ -503,7 +503,7 @@ function findEpic(projectPath, query) {
|
|
|
503
503
|
matches: results.map((r) => r.item)
|
|
504
504
|
};
|
|
505
505
|
}
|
|
506
|
-
async function findStory(projectPath, query) {
|
|
506
|
+
async function findStory(projectPath, query, options = {}) {
|
|
507
507
|
if (!worktreesDirectoryExists(projectPath) && !epicsDirectoryExists(projectPath)) {
|
|
508
508
|
return {
|
|
509
509
|
found: false,
|
|
@@ -517,7 +517,16 @@ async function findStory(projectPath, query) {
|
|
|
517
517
|
error: `No story found matching '${query}'`
|
|
518
518
|
};
|
|
519
519
|
}
|
|
520
|
-
|
|
520
|
+
let allStories = scannedStories.map(toStoryInfo);
|
|
521
|
+
if (options.status) {
|
|
522
|
+
allStories = allStories.filter((story) => story.status === options.status);
|
|
523
|
+
if (allStories.length === 0) {
|
|
524
|
+
return {
|
|
525
|
+
found: false,
|
|
526
|
+
error: `No story found matching '${query}' with status '${options.status}'`
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}
|
|
521
530
|
const queryNormalized = normalize(query);
|
|
522
531
|
for (const story of allStories) {
|
|
523
532
|
if (normalize(story.slug) === queryNormalized) {
|
|
@@ -571,6 +580,165 @@ async function findStory(projectPath, query) {
|
|
|
571
580
|
};
|
|
572
581
|
}
|
|
573
582
|
|
|
583
|
+
// src/lib/sessions.ts
|
|
584
|
+
var import_node_child_process = require("node:child_process");
|
|
585
|
+
var import_node_fs4 = require("node:fs");
|
|
586
|
+
var import_node_path4 = require("node:path");
|
|
587
|
+
var OUTPUT_DIR = "/tmp/saga-sessions";
|
|
588
|
+
function shellEscape(str) {
|
|
589
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
590
|
+
}
|
|
591
|
+
function shellEscapeArgs(args) {
|
|
592
|
+
return args.map(shellEscape).join(" ");
|
|
593
|
+
}
|
|
594
|
+
function validateSlug(slug) {
|
|
595
|
+
if (!slug || slug.length === 0) {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
if (slug.startsWith("-") || slug.endsWith("-")) {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
function checkTmuxAvailable() {
|
|
607
|
+
const result = (0, import_node_child_process.spawnSync)("which", ["tmux"], { encoding: "utf-8" });
|
|
608
|
+
if (result.status !== 0) {
|
|
609
|
+
throw new Error("tmux is not installed or not found in PATH");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async function createSession(epicSlug, storySlug, command) {
|
|
613
|
+
if (!validateSlug(epicSlug)) {
|
|
614
|
+
throw new Error(`Invalid epic slug: '${epicSlug}'. Must contain only [a-z0-9-] and not start/end with hyphen.`);
|
|
615
|
+
}
|
|
616
|
+
if (!validateSlug(storySlug)) {
|
|
617
|
+
throw new Error(`Invalid story slug: '${storySlug}'. Must contain only [a-z0-9-] and not start/end with hyphen.`);
|
|
618
|
+
}
|
|
619
|
+
checkTmuxAvailable();
|
|
620
|
+
if (!(0, import_node_fs4.existsSync)(OUTPUT_DIR)) {
|
|
621
|
+
(0, import_node_fs4.mkdirSync)(OUTPUT_DIR, { recursive: true });
|
|
622
|
+
}
|
|
623
|
+
const timestamp = Date.now();
|
|
624
|
+
const sessionName = `saga-${epicSlug}-${storySlug}-${timestamp}`;
|
|
625
|
+
const outputFile = (0, import_node_path4.join)(OUTPUT_DIR, `${sessionName}.out`);
|
|
626
|
+
const commandFilePath = (0, import_node_path4.join)(OUTPUT_DIR, `${sessionName}.cmd`);
|
|
627
|
+
const wrapperScriptPath = (0, import_node_path4.join)(OUTPUT_DIR, `${sessionName}.sh`);
|
|
628
|
+
(0, import_node_fs4.writeFileSync)(commandFilePath, command, { mode: 384 });
|
|
629
|
+
const wrapperScriptContent = `#!/bin/bash
|
|
630
|
+
# Auto-generated wrapper script for SAGA session
|
|
631
|
+
set -e
|
|
632
|
+
|
|
633
|
+
COMMAND_FILE="${commandFilePath}"
|
|
634
|
+
OUTPUT_FILE="${outputFile}"
|
|
635
|
+
SCRIPT_FILE="${wrapperScriptPath}"
|
|
636
|
+
|
|
637
|
+
# Read the command from file
|
|
638
|
+
COMMAND="$(cat "$COMMAND_FILE")"
|
|
639
|
+
|
|
640
|
+
# Cleanup temporary files on exit
|
|
641
|
+
cleanup() {
|
|
642
|
+
rm -f "$COMMAND_FILE" "$SCRIPT_FILE"
|
|
643
|
+
}
|
|
644
|
+
trap cleanup EXIT
|
|
645
|
+
|
|
646
|
+
# Execute the command with output capture
|
|
647
|
+
# script syntax differs between macOS and Linux:
|
|
648
|
+
# macOS (Darwin): script -q <file> <shell> -c <command>
|
|
649
|
+
# Linux: script -q -c <command> <file>
|
|
650
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
651
|
+
# -F: flush output after each write (ensures immediate visibility)
|
|
652
|
+
exec script -qF "$OUTPUT_FILE" /bin/bash -c "$COMMAND"
|
|
653
|
+
else
|
|
654
|
+
exec script -q -c "$COMMAND" "$OUTPUT_FILE"
|
|
655
|
+
fi
|
|
656
|
+
`;
|
|
657
|
+
(0, import_node_fs4.writeFileSync)(wrapperScriptPath, wrapperScriptContent, { mode: 448 });
|
|
658
|
+
const createResult = (0, import_node_child_process.spawnSync)("tmux", [
|
|
659
|
+
"new-session",
|
|
660
|
+
"-d",
|
|
661
|
+
// detached
|
|
662
|
+
"-s",
|
|
663
|
+
sessionName,
|
|
664
|
+
// session name
|
|
665
|
+
wrapperScriptPath
|
|
666
|
+
// run the wrapper script
|
|
667
|
+
], { encoding: "utf-8" });
|
|
668
|
+
if (createResult.status !== 0) {
|
|
669
|
+
throw new Error(`Failed to create tmux session: ${createResult.stderr || "unknown error"}`);
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
sessionName,
|
|
673
|
+
outputFile
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
async function listSessions() {
|
|
677
|
+
const result = (0, import_node_child_process.spawnSync)("tmux", ["ls"], { encoding: "utf-8" });
|
|
678
|
+
if (result.status !== 0) {
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
681
|
+
const sessions = [];
|
|
682
|
+
const lines = result.stdout.trim().split("\n");
|
|
683
|
+
for (const line of lines) {
|
|
684
|
+
const match = line.match(/^(saga-[a-z0-9]+(?:-[a-z0-9]+)*-\d+):/);
|
|
685
|
+
if (match) {
|
|
686
|
+
const name = match[1];
|
|
687
|
+
sessions.push({
|
|
688
|
+
name,
|
|
689
|
+
status: "running",
|
|
690
|
+
// If it shows up in tmux ls, it's running
|
|
691
|
+
outputFile: (0, import_node_path4.join)(OUTPUT_DIR, `${name}.out`)
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return sessions;
|
|
696
|
+
}
|
|
697
|
+
async function getSessionStatus(sessionName) {
|
|
698
|
+
const result = (0, import_node_child_process.spawnSync)("tmux", ["has-session", "-t", sessionName], {
|
|
699
|
+
encoding: "utf-8"
|
|
700
|
+
});
|
|
701
|
+
return {
|
|
702
|
+
running: result.status === 0
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
async function streamLogs(sessionName) {
|
|
706
|
+
const outputFile = (0, import_node_path4.join)(OUTPUT_DIR, `${sessionName}.out`);
|
|
707
|
+
if (!(0, import_node_fs4.existsSync)(outputFile)) {
|
|
708
|
+
throw new Error(`Output file not found: ${outputFile}`);
|
|
709
|
+
}
|
|
710
|
+
return new Promise((resolve2, reject) => {
|
|
711
|
+
const child = (0, import_node_child_process.spawn)("tail", ["-f", outputFile], {
|
|
712
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
713
|
+
});
|
|
714
|
+
child.stdout?.on("data", (chunk) => {
|
|
715
|
+
process.stdout.write(chunk);
|
|
716
|
+
});
|
|
717
|
+
child.stderr?.on("data", (chunk) => {
|
|
718
|
+
process.stderr.write(chunk);
|
|
719
|
+
});
|
|
720
|
+
child.on("error", (err) => {
|
|
721
|
+
reject(new Error(`Failed to stream logs: ${err.message}`));
|
|
722
|
+
});
|
|
723
|
+
child.on("close", (code) => {
|
|
724
|
+
resolve2();
|
|
725
|
+
});
|
|
726
|
+
const sigintHandler = () => {
|
|
727
|
+
child.kill("SIGTERM");
|
|
728
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
729
|
+
};
|
|
730
|
+
process.on("SIGINT", sigintHandler);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
async function killSession(sessionName) {
|
|
734
|
+
const result = (0, import_node_child_process.spawnSync)("tmux", ["kill-session", "-t", sessionName], {
|
|
735
|
+
encoding: "utf-8"
|
|
736
|
+
});
|
|
737
|
+
return {
|
|
738
|
+
killed: result.status === 0
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
574
742
|
// src/commands/implement.ts
|
|
575
743
|
var DEFAULT_MAX_CYCLES = 10;
|
|
576
744
|
var DEFAULT_MAX_TIME = 60;
|
|
@@ -608,10 +776,10 @@ async function findStory2(projectPath, storySlug) {
|
|
|
608
776
|
};
|
|
609
777
|
}
|
|
610
778
|
function computeStoryPath(worktree, epicSlug, storySlug) {
|
|
611
|
-
return (0,
|
|
779
|
+
return (0, import_node_path5.join)(worktree, ".saga", "epics", epicSlug, "stories", storySlug, "story.md");
|
|
612
780
|
}
|
|
613
781
|
function validateStoryFiles(worktree, epicSlug, storySlug) {
|
|
614
|
-
if (!(0,
|
|
782
|
+
if (!(0, import_node_fs5.existsSync)(worktree)) {
|
|
615
783
|
return {
|
|
616
784
|
valid: false,
|
|
617
785
|
error: `Worktree not found at ${worktree}
|
|
@@ -624,7 +792,7 @@ To create the worktree, use: /task-resume ${storySlug}`
|
|
|
624
792
|
};
|
|
625
793
|
}
|
|
626
794
|
const storyPath = computeStoryPath(worktree, epicSlug, storySlug);
|
|
627
|
-
if (!(0,
|
|
795
|
+
if (!(0, import_node_fs5.existsSync)(storyPath)) {
|
|
628
796
|
return {
|
|
629
797
|
valid: false,
|
|
630
798
|
error: `story.md not found in worktree.
|
|
@@ -638,11 +806,11 @@ This may indicate an incomplete story setup.`
|
|
|
638
806
|
return { valid: true };
|
|
639
807
|
}
|
|
640
808
|
function getSkillRoot(pluginRoot) {
|
|
641
|
-
return (0,
|
|
809
|
+
return (0, import_node_path5.join)(pluginRoot, "skills", "execute-story");
|
|
642
810
|
}
|
|
643
811
|
function checkCommandExists(command) {
|
|
644
812
|
try {
|
|
645
|
-
const result = (0,
|
|
813
|
+
const result = (0, import_node_child_process2.spawnSync)("which", [command], { encoding: "utf-8" });
|
|
646
814
|
if (result.status === 0 && result.stdout.trim()) {
|
|
647
815
|
return { exists: true, path: result.stdout.trim() };
|
|
648
816
|
}
|
|
@@ -678,8 +846,8 @@ function runDryRun(storyInfo, projectPath, pluginRoot) {
|
|
|
678
846
|
if (!claudeCheck.exists) allPassed = false;
|
|
679
847
|
if (pluginRoot) {
|
|
680
848
|
const skillRoot = getSkillRoot(pluginRoot);
|
|
681
|
-
const workerPromptPath = (0,
|
|
682
|
-
if ((0,
|
|
849
|
+
const workerPromptPath = (0, import_node_path5.join)(skillRoot, WORKER_PROMPT_RELATIVE);
|
|
850
|
+
if ((0, import_node_fs5.existsSync)(workerPromptPath)) {
|
|
683
851
|
checks.push({
|
|
684
852
|
name: "Worker prompt",
|
|
685
853
|
path: workerPromptPath,
|
|
@@ -700,7 +868,7 @@ function runDryRun(storyInfo, projectPath, pluginRoot) {
|
|
|
700
868
|
path: `${storyInfo.storySlug} (epic: ${storyInfo.epicSlug})`,
|
|
701
869
|
passed: true
|
|
702
870
|
});
|
|
703
|
-
if ((0,
|
|
871
|
+
if ((0, import_node_fs5.existsSync)(storyInfo.worktreePath)) {
|
|
704
872
|
checks.push({
|
|
705
873
|
name: "Worktree exists",
|
|
706
874
|
path: storyInfo.worktreePath,
|
|
@@ -715,13 +883,13 @@ function runDryRun(storyInfo, projectPath, pluginRoot) {
|
|
|
715
883
|
});
|
|
716
884
|
allPassed = false;
|
|
717
885
|
}
|
|
718
|
-
if ((0,
|
|
886
|
+
if ((0, import_node_fs5.existsSync)(storyInfo.worktreePath)) {
|
|
719
887
|
const storyMdPath = computeStoryPath(
|
|
720
888
|
storyInfo.worktreePath,
|
|
721
889
|
storyInfo.epicSlug,
|
|
722
890
|
storyInfo.storySlug
|
|
723
891
|
);
|
|
724
|
-
if ((0,
|
|
892
|
+
if ((0, import_node_fs5.existsSync)(storyMdPath)) {
|
|
725
893
|
checks.push({
|
|
726
894
|
name: "story.md in worktree",
|
|
727
895
|
path: storyMdPath,
|
|
@@ -771,11 +939,11 @@ function printDryRunResults(result) {
|
|
|
771
939
|
}
|
|
772
940
|
function loadWorkerPrompt(pluginRoot) {
|
|
773
941
|
const skillRoot = getSkillRoot(pluginRoot);
|
|
774
|
-
const promptPath = (0,
|
|
775
|
-
if (!(0,
|
|
942
|
+
const promptPath = (0, import_node_path5.join)(skillRoot, WORKER_PROMPT_RELATIVE);
|
|
943
|
+
if (!(0, import_node_fs5.existsSync)(promptPath)) {
|
|
776
944
|
throw new Error(`Worker prompt not found at ${promptPath}`);
|
|
777
945
|
}
|
|
778
|
-
return (0,
|
|
946
|
+
return (0, import_node_fs5.readFileSync)(promptPath, "utf-8");
|
|
779
947
|
}
|
|
780
948
|
function buildScopeSettings() {
|
|
781
949
|
const hookCommand = "npx @saga-ai/cli scope-validator";
|
|
@@ -837,7 +1005,7 @@ function spawnWorker(prompt, model, settings, workingDir) {
|
|
|
837
1005
|
JSON.stringify(settings),
|
|
838
1006
|
"--dangerously-skip-permissions"
|
|
839
1007
|
];
|
|
840
|
-
const result = (0,
|
|
1008
|
+
const result = (0, import_node_child_process2.spawnSync)("claude", args, {
|
|
841
1009
|
cwd: workingDir,
|
|
842
1010
|
encoding: "utf-8",
|
|
843
1011
|
maxBuffer: 50 * 1024 * 1024
|
|
@@ -874,6 +1042,22 @@ function formatStreamLine(line) {
|
|
|
874
1042
|
return null;
|
|
875
1043
|
}
|
|
876
1044
|
}
|
|
1045
|
+
function extractStructuredOutputFromToolCall(lines) {
|
|
1046
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1047
|
+
try {
|
|
1048
|
+
const data = JSON.parse(lines[i]);
|
|
1049
|
+
if (data.type === "assistant" && data.message?.content) {
|
|
1050
|
+
for (const block of data.message.content) {
|
|
1051
|
+
if (block.type === "tool_use" && block.name === "StructuredOutput") {
|
|
1052
|
+
return block.input;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
} catch {
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
877
1061
|
function parseStreamingResult(buffer) {
|
|
878
1062
|
const lines = buffer.split("\n").filter((line) => line.trim());
|
|
879
1063
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
@@ -883,10 +1067,13 @@ function parseStreamingResult(buffer) {
|
|
|
883
1067
|
if (data.is_error) {
|
|
884
1068
|
throw new Error(`Worker failed: ${data.result || "Unknown error"}`);
|
|
885
1069
|
}
|
|
886
|
-
|
|
1070
|
+
let output = data.structured_output;
|
|
1071
|
+
if (!output) {
|
|
1072
|
+
output = extractStructuredOutputFromToolCall(lines);
|
|
1073
|
+
}
|
|
1074
|
+
if (!output) {
|
|
887
1075
|
throw new Error("Worker result missing structured_output");
|
|
888
1076
|
}
|
|
889
|
-
const output = data.structured_output;
|
|
890
1077
|
if (!VALID_STATUSES.has(output.status)) {
|
|
891
1078
|
throw new Error(`Invalid status: ${output.status}`);
|
|
892
1079
|
}
|
|
@@ -921,7 +1108,7 @@ function spawnWorkerAsync(prompt, model, settings, workingDir) {
|
|
|
921
1108
|
JSON.stringify(settings),
|
|
922
1109
|
"--dangerously-skip-permissions"
|
|
923
1110
|
];
|
|
924
|
-
const child = (0,
|
|
1111
|
+
const child = (0, import_node_child_process2.spawn)("claude", args, {
|
|
925
1112
|
cwd: workingDir,
|
|
926
1113
|
stdio: ["ignore", "pipe", "pipe"]
|
|
927
1114
|
});
|
|
@@ -956,7 +1143,7 @@ function spawnWorkerAsync(prompt, model, settings, workingDir) {
|
|
|
956
1143
|
});
|
|
957
1144
|
}
|
|
958
1145
|
async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot, stream = false) {
|
|
959
|
-
const worktree = (0,
|
|
1146
|
+
const worktree = (0, import_node_path5.join)(projectDir, ".saga", "worktrees", epicSlug, storySlug);
|
|
960
1147
|
const validation = validateStoryFiles(worktree, epicSlug, storySlug);
|
|
961
1148
|
if (!validation.valid) {
|
|
962
1149
|
return {
|
|
@@ -1072,6 +1259,23 @@ async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDi
|
|
|
1072
1259
|
storySlug
|
|
1073
1260
|
};
|
|
1074
1261
|
}
|
|
1262
|
+
function buildDetachedCommand(storySlug, projectPath, options) {
|
|
1263
|
+
const parts = ["saga", "implement", storySlug, "--attached"];
|
|
1264
|
+
parts.push("--path", projectPath);
|
|
1265
|
+
if (options.maxCycles !== void 0) {
|
|
1266
|
+
parts.push("--max-cycles", String(options.maxCycles));
|
|
1267
|
+
}
|
|
1268
|
+
if (options.maxTime !== void 0) {
|
|
1269
|
+
parts.push("--max-time", String(options.maxTime));
|
|
1270
|
+
}
|
|
1271
|
+
if (options.model !== void 0) {
|
|
1272
|
+
parts.push("--model", options.model);
|
|
1273
|
+
}
|
|
1274
|
+
if (options.stream) {
|
|
1275
|
+
parts.push("--stream");
|
|
1276
|
+
}
|
|
1277
|
+
return shellEscapeArgs(parts);
|
|
1278
|
+
}
|
|
1075
1279
|
async function implementCommand(storySlug, options) {
|
|
1076
1280
|
let projectPath;
|
|
1077
1281
|
try {
|
|
@@ -1084,7 +1288,7 @@ async function implementCommand(storySlug, options) {
|
|
|
1084
1288
|
if (!storyInfo) {
|
|
1085
1289
|
console.error(`Error: Story '${storySlug}' not found in SAGA project.`);
|
|
1086
1290
|
console.error(`
|
|
1087
|
-
Searched in: ${(0,
|
|
1291
|
+
Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`);
|
|
1088
1292
|
console.error("\nMake sure the story worktree exists and has a story.md file.");
|
|
1089
1293
|
console.error("Run /generate-stories to create story worktrees.");
|
|
1090
1294
|
process.exit(1);
|
|
@@ -1095,7 +1299,7 @@ Searched in: ${(0, import_node_path4.join)(projectPath, ".saga", "worktrees")}`)
|
|
|
1095
1299
|
printDryRunResults(dryRunResult);
|
|
1096
1300
|
process.exit(dryRunResult.success ? 0 : 1);
|
|
1097
1301
|
}
|
|
1098
|
-
if (!(0,
|
|
1302
|
+
if (!(0, import_node_fs5.existsSync)(storyInfo.worktreePath)) {
|
|
1099
1303
|
console.error(`Error: Worktree not found at ${storyInfo.worktreePath}`);
|
|
1100
1304
|
console.error("\nThe story worktree has not been created yet.");
|
|
1101
1305
|
console.error("Make sure the story was properly generated with /generate-stories.");
|
|
@@ -1113,6 +1317,38 @@ Searched in: ${(0, import_node_path4.join)(projectPath, ".saga", "worktrees")}`)
|
|
|
1113
1317
|
const maxTime = options.maxTime ?? DEFAULT_MAX_TIME;
|
|
1114
1318
|
const model = options.model ?? DEFAULT_MODEL;
|
|
1115
1319
|
const stream = options.stream ?? false;
|
|
1320
|
+
const attached = options.attached ?? false;
|
|
1321
|
+
if (!attached) {
|
|
1322
|
+
if (stream) {
|
|
1323
|
+
console.error("Warning: --stream is ignored in detached mode. Use --attached --stream for streaming output.");
|
|
1324
|
+
}
|
|
1325
|
+
const detachedCommand = buildDetachedCommand(storySlug, projectPath, {
|
|
1326
|
+
maxCycles: options.maxCycles,
|
|
1327
|
+
maxTime: options.maxTime,
|
|
1328
|
+
model: options.model,
|
|
1329
|
+
stream: true
|
|
1330
|
+
// Always use streaming in detached mode so output is captured
|
|
1331
|
+
});
|
|
1332
|
+
try {
|
|
1333
|
+
const sessionResult = await createSession(
|
|
1334
|
+
storyInfo.epicSlug,
|
|
1335
|
+
storyInfo.storySlug,
|
|
1336
|
+
detachedCommand
|
|
1337
|
+
);
|
|
1338
|
+
console.log(JSON.stringify({
|
|
1339
|
+
mode: "detached",
|
|
1340
|
+
sessionName: sessionResult.sessionName,
|
|
1341
|
+
outputFile: sessionResult.outputFile,
|
|
1342
|
+
epicSlug: storyInfo.epicSlug,
|
|
1343
|
+
storySlug: storyInfo.storySlug,
|
|
1344
|
+
worktreePath: storyInfo.worktreePath
|
|
1345
|
+
}, null, 2));
|
|
1346
|
+
return;
|
|
1347
|
+
} catch (error) {
|
|
1348
|
+
console.error(`Error: Failed to create detached session: ${error.message}`);
|
|
1349
|
+
process.exit(1);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1116
1352
|
console.log("Starting story implementation...");
|
|
1117
1353
|
console.log(` Epic: ${storyInfo.epicSlug}`);
|
|
1118
1354
|
console.log(` Story: ${storyInfo.storySlug}`);
|
|
@@ -1194,7 +1430,7 @@ function validateTaskStatus(status) {
|
|
|
1194
1430
|
return "pending";
|
|
1195
1431
|
}
|
|
1196
1432
|
async function parseStory(storyPath, epicSlug) {
|
|
1197
|
-
const { join:
|
|
1433
|
+
const { join: join12 } = await import("path");
|
|
1198
1434
|
const { stat: stat2 } = await import("fs/promises");
|
|
1199
1435
|
let content;
|
|
1200
1436
|
try {
|
|
@@ -1214,7 +1450,7 @@ async function parseStory(storyPath, epicSlug) {
|
|
|
1214
1450
|
const title = frontmatter.title || dirName;
|
|
1215
1451
|
const status = validateStatus(frontmatter.status);
|
|
1216
1452
|
const tasks = parseTasks(frontmatter.tasks);
|
|
1217
|
-
const journalPath =
|
|
1453
|
+
const journalPath = join12(storyDir, "journal.md");
|
|
1218
1454
|
let hasJournal = false;
|
|
1219
1455
|
try {
|
|
1220
1456
|
await stat2(journalPath);
|
|
@@ -1804,7 +2040,7 @@ async function dashboardCommand(options) {
|
|
|
1804
2040
|
}
|
|
1805
2041
|
|
|
1806
2042
|
// src/commands/scope-validator.ts
|
|
1807
|
-
var
|
|
2043
|
+
var import_node_path6 = require("node:path");
|
|
1808
2044
|
function getFilePathFromInput(hookInput) {
|
|
1809
2045
|
try {
|
|
1810
2046
|
const data = JSON.parse(hookInput);
|
|
@@ -1824,10 +2060,10 @@ function isArchiveAccess(path) {
|
|
|
1824
2060
|
return path.includes(".saga/archive");
|
|
1825
2061
|
}
|
|
1826
2062
|
function isWithinWorktree(filePath, worktreePath) {
|
|
1827
|
-
const absoluteFilePath = (0,
|
|
1828
|
-
const absoluteWorktree = (0,
|
|
1829
|
-
const relativePath = (0,
|
|
1830
|
-
if (relativePath.startsWith("..") || (0,
|
|
2063
|
+
const absoluteFilePath = (0, import_node_path6.resolve)(filePath);
|
|
2064
|
+
const absoluteWorktree = (0, import_node_path6.resolve)(worktreePath);
|
|
2065
|
+
const relativePath = (0, import_node_path6.relative)(absoluteWorktree, absoluteFilePath);
|
|
2066
|
+
if (relativePath.startsWith("..") || (0, import_node_path6.resolve)(relativePath) === relativePath) {
|
|
1831
2067
|
return false;
|
|
1832
2068
|
}
|
|
1833
2069
|
return true;
|
|
@@ -1933,7 +2169,7 @@ async function findCommand(query, options) {
|
|
|
1933
2169
|
if (type === "epic") {
|
|
1934
2170
|
result = findEpic(projectPath, query);
|
|
1935
2171
|
} else {
|
|
1936
|
-
result = await findStory(projectPath, query);
|
|
2172
|
+
result = await findStory(projectPath, query, { status: options.status });
|
|
1937
2173
|
}
|
|
1938
2174
|
console.log(JSON.stringify(result, null, 2));
|
|
1939
2175
|
if (!result.found) {
|
|
@@ -1942,12 +2178,12 @@ async function findCommand(query, options) {
|
|
|
1942
2178
|
}
|
|
1943
2179
|
|
|
1944
2180
|
// src/commands/worktree.ts
|
|
1945
|
-
var
|
|
1946
|
-
var
|
|
1947
|
-
var
|
|
2181
|
+
var import_node_path7 = require("node:path");
|
|
2182
|
+
var import_node_fs6 = require("node:fs");
|
|
2183
|
+
var import_node_child_process3 = require("node:child_process");
|
|
1948
2184
|
function runGitCommand(args, cwd) {
|
|
1949
2185
|
try {
|
|
1950
|
-
const output = (0,
|
|
2186
|
+
const output = (0, import_node_child_process3.execSync)(`git ${args.join(" ")}`, {
|
|
1951
2187
|
cwd,
|
|
1952
2188
|
encoding: "utf-8",
|
|
1953
2189
|
stdio: ["pipe", "pipe", "pipe"]
|
|
@@ -1974,7 +2210,7 @@ function getMainBranch(cwd) {
|
|
|
1974
2210
|
}
|
|
1975
2211
|
function createWorktree(projectPath, epicSlug, storySlug) {
|
|
1976
2212
|
const branchName = `story-${storySlug}-epic-${epicSlug}`;
|
|
1977
|
-
const worktreePath = (0,
|
|
2213
|
+
const worktreePath = (0, import_node_path7.join)(
|
|
1978
2214
|
projectPath,
|
|
1979
2215
|
".saga",
|
|
1980
2216
|
"worktrees",
|
|
@@ -1987,7 +2223,7 @@ function createWorktree(projectPath, epicSlug, storySlug) {
|
|
|
1987
2223
|
error: `Branch already exists: ${branchName}`
|
|
1988
2224
|
};
|
|
1989
2225
|
}
|
|
1990
|
-
if ((0,
|
|
2226
|
+
if ((0, import_node_fs6.existsSync)(worktreePath)) {
|
|
1991
2227
|
return {
|
|
1992
2228
|
success: false,
|
|
1993
2229
|
error: `Worktree directory already exists: ${worktreePath}`
|
|
@@ -2005,8 +2241,8 @@ function createWorktree(projectPath, epicSlug, storySlug) {
|
|
|
2005
2241
|
error: `Failed to create branch: ${createBranchResult.output}`
|
|
2006
2242
|
};
|
|
2007
2243
|
}
|
|
2008
|
-
const worktreeParent = (0,
|
|
2009
|
-
(0,
|
|
2244
|
+
const worktreeParent = (0, import_node_path7.join)(projectPath, ".saga", "worktrees", epicSlug);
|
|
2245
|
+
(0, import_node_fs6.mkdirSync)(worktreeParent, { recursive: true });
|
|
2010
2246
|
const createWorktreeResult = runGitCommand(
|
|
2011
2247
|
["worktree", "add", worktreePath, branchName],
|
|
2012
2248
|
projectPath
|
|
@@ -2039,9 +2275,31 @@ async function worktreeCommand(epicSlug, storySlug, options) {
|
|
|
2039
2275
|
}
|
|
2040
2276
|
}
|
|
2041
2277
|
|
|
2278
|
+
// src/commands/sessions/index.ts
|
|
2279
|
+
async function sessionsListCommand() {
|
|
2280
|
+
const sessions = await listSessions();
|
|
2281
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
2282
|
+
}
|
|
2283
|
+
async function sessionsStatusCommand(sessionName) {
|
|
2284
|
+
const status = await getSessionStatus(sessionName);
|
|
2285
|
+
console.log(JSON.stringify(status, null, 2));
|
|
2286
|
+
}
|
|
2287
|
+
async function sessionsLogsCommand(sessionName) {
|
|
2288
|
+
try {
|
|
2289
|
+
await streamLogs(sessionName);
|
|
2290
|
+
} catch (error) {
|
|
2291
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
2292
|
+
process.exit(1);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
async function sessionsKillCommand(sessionName) {
|
|
2296
|
+
const result = await killSession(sessionName);
|
|
2297
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2042
2300
|
// src/cli.ts
|
|
2043
|
-
var packageJsonPath = (0,
|
|
2044
|
-
var packageJson = JSON.parse((0,
|
|
2301
|
+
var packageJsonPath = (0, import_node_path8.join)(__dirname, "..", "package.json");
|
|
2302
|
+
var packageJson = JSON.parse((0, import_node_fs7.readFileSync)(packageJsonPath, "utf-8"));
|
|
2045
2303
|
var program = new import_commander.Command();
|
|
2046
2304
|
program.name("saga").description("CLI for SAGA - Structured Autonomous Goal Achievement").version(packageJson.version).addHelpCommand("help [command]", "Display help for a command");
|
|
2047
2305
|
program.option("-p, --path <dir>", "Path to SAGA project directory (overrides auto-discovery)");
|
|
@@ -2049,7 +2307,7 @@ program.command("init").description("Initialize .saga/ directory structure").opt
|
|
|
2049
2307
|
const globalOpts = program.opts();
|
|
2050
2308
|
await initCommand({ path: globalOpts.path, dryRun: options.dryRun });
|
|
2051
2309
|
});
|
|
2052
|
-
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").option("--stream", "Stream worker output in real-time").action(async (storySlug, options) => {
|
|
2310
|
+
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").option("--stream", "Stream worker output in real-time").option("--attached", "Run in attached mode (synchronous, tied to terminal)").action(async (storySlug, options) => {
|
|
2053
2311
|
const globalOpts = program.opts();
|
|
2054
2312
|
await implementCommand(storySlug, {
|
|
2055
2313
|
path: globalOpts.path,
|
|
@@ -2057,14 +2315,16 @@ program.command("implement <story-slug>").description("Run story implementation"
|
|
|
2057
2315
|
maxTime: options.maxTime,
|
|
2058
2316
|
model: options.model,
|
|
2059
2317
|
dryRun: options.dryRun,
|
|
2060
|
-
stream: options.stream
|
|
2318
|
+
stream: options.stream,
|
|
2319
|
+
attached: options.attached
|
|
2061
2320
|
});
|
|
2062
2321
|
});
|
|
2063
|
-
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)").action(async (query, options) => {
|
|
2322
|
+
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) => {
|
|
2064
2323
|
const globalOpts = program.opts();
|
|
2065
2324
|
await findCommand(query, {
|
|
2066
2325
|
path: globalOpts.path,
|
|
2067
|
-
type: options.type
|
|
2326
|
+
type: options.type,
|
|
2327
|
+
status: options.status
|
|
2068
2328
|
});
|
|
2069
2329
|
});
|
|
2070
2330
|
program.command("worktree <epic-slug> <story-slug>").description("Create git worktree for a story").action(async (epicSlug, storySlug) => {
|
|
@@ -2081,6 +2341,19 @@ program.command("dashboard").description("Start the dashboard server").option("-
|
|
|
2081
2341
|
program.command("scope-validator").description("Validate file operations against story scope (internal)").action(async () => {
|
|
2082
2342
|
await scopeValidatorCommand();
|
|
2083
2343
|
});
|
|
2344
|
+
var sessionsCommand = program.command("sessions").description("Manage SAGA tmux sessions");
|
|
2345
|
+
sessionsCommand.command("list").description("List all SAGA sessions").action(async () => {
|
|
2346
|
+
await sessionsListCommand();
|
|
2347
|
+
});
|
|
2348
|
+
sessionsCommand.command("status <name>").description("Show session status").action(async (name) => {
|
|
2349
|
+
await sessionsStatusCommand(name);
|
|
2350
|
+
});
|
|
2351
|
+
sessionsCommand.command("logs <name>").description("Stream session output").action(async (name) => {
|
|
2352
|
+
await sessionsLogsCommand(name);
|
|
2353
|
+
});
|
|
2354
|
+
sessionsCommand.command("kill <name>").description("Terminate a session").action(async (name) => {
|
|
2355
|
+
await sessionsKillCommand(name);
|
|
2356
|
+
});
|
|
2084
2357
|
program.on("command:*", (operands) => {
|
|
2085
2358
|
console.error(`error: unknown command '${operands[0]}'`);
|
|
2086
2359
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saga-ai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "CLI for SAGA - Structured Autonomous Goal Achievement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -60,7 +60,16 @@
|
|
|
60
60
|
"typescript": "^5.7.0",
|
|
61
61
|
"vite": "^6.0.5",
|
|
62
62
|
"vitest": "^3.2.4",
|
|
63
|
-
"xstate": "^5.26.0"
|
|
63
|
+
"xstate": "^5.26.0",
|
|
64
|
+
"storybook": "^10.2.1",
|
|
65
|
+
"@storybook/react-vite": "^10.2.1",
|
|
66
|
+
"@chromatic-com/storybook": "^5.0.0",
|
|
67
|
+
"@storybook/addon-vitest": "^10.2.1",
|
|
68
|
+
"@storybook/addon-a11y": "^10.2.1",
|
|
69
|
+
"@storybook/addon-docs": "^10.2.1",
|
|
70
|
+
"@storybook/addon-onboarding": "^10.2.1",
|
|
71
|
+
"playwright": "^1.58.0",
|
|
72
|
+
"@vitest/browser": "^3.2.4"
|
|
64
73
|
},
|
|
65
74
|
"engines": {
|
|
66
75
|
"node": ">=18.0.0"
|
|
@@ -77,6 +86,8 @@
|
|
|
77
86
|
"dev:client": "vite --config src/client/vite.config.ts",
|
|
78
87
|
"test": "vitest run",
|
|
79
88
|
"test:watch": "vitest",
|
|
80
|
-
"publish:npm": "pnpm build && pnpm test && pnpm publish --access public"
|
|
89
|
+
"publish:npm": "pnpm build && pnpm test && pnpm publish --access public",
|
|
90
|
+
"storybook": "storybook dev -p 6006 -c src/client/.storybook",
|
|
91
|
+
"build-storybook": "storybook build -c src/client/.storybook -o storybook-static"
|
|
81
92
|
}
|
|
82
93
|
}
|