@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.
Files changed (3) hide show
  1. package/README.md +24 -0
  2. package/dist/cli.cjs +322 -49
  3. 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 import_node_path7 = require("node:path");
29
- var import_node_fs6 = require("node:fs");
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 import_node_child_process = require("node:child_process");
197
- var import_node_path4 = require("node:path");
198
- var import_node_fs4 = require("node:fs");
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
- const allStories = scannedStories.map(toStoryInfo);
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, import_node_path4.join)(worktree, ".saga", "epics", epicSlug, "stories", storySlug, "story.md");
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, import_node_fs4.existsSync)(worktree)) {
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, import_node_fs4.existsSync)(storyPath)) {
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, import_node_path4.join)(pluginRoot, "skills", "execute-story");
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, import_node_child_process.spawnSync)("which", [command], { encoding: "utf-8" });
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, import_node_path4.join)(skillRoot, WORKER_PROMPT_RELATIVE);
682
- if ((0, import_node_fs4.existsSync)(workerPromptPath)) {
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, import_node_fs4.existsSync)(storyInfo.worktreePath)) {
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, import_node_fs4.existsSync)(storyInfo.worktreePath)) {
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, import_node_fs4.existsSync)(storyMdPath)) {
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, import_node_path4.join)(skillRoot, WORKER_PROMPT_RELATIVE);
775
- if (!(0, import_node_fs4.existsSync)(promptPath)) {
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, import_node_fs4.readFileSync)(promptPath, "utf-8");
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, import_node_child_process.spawnSync)("claude", args, {
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
- if (!data.structured_output) {
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, import_node_child_process.spawn)("claude", args, {
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, import_node_path4.join)(projectDir, ".saga", "worktrees", epicSlug, storySlug);
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, import_node_path4.join)(projectPath, ".saga", "worktrees")}`);
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, import_node_fs4.existsSync)(storyInfo.worktreePath)) {
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: join11 } = await import("path");
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 = join11(storyDir, "journal.md");
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 import_node_path5 = require("node:path");
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, import_node_path5.resolve)(filePath);
1828
- const absoluteWorktree = (0, import_node_path5.resolve)(worktreePath);
1829
- const relativePath = (0, import_node_path5.relative)(absoluteWorktree, absoluteFilePath);
1830
- if (relativePath.startsWith("..") || (0, import_node_path5.resolve)(relativePath) === relativePath) {
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 import_node_path6 = require("node:path");
1946
- var import_node_fs5 = require("node:fs");
1947
- var import_node_child_process2 = require("node:child_process");
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, import_node_child_process2.execSync)(`git ${args.join(" ")}`, {
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, import_node_path6.join)(
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, import_node_fs5.existsSync)(worktreePath)) {
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, import_node_path6.join)(projectPath, ".saga", "worktrees", epicSlug);
2009
- (0, import_node_fs5.mkdirSync)(worktreeParent, { recursive: true });
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, import_node_path7.join)(__dirname, "..", "package.json");
2044
- var packageJson = JSON.parse((0, import_node_fs6.readFileSync)(packageJsonPath, "utf-8"));
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.6.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
  }