@saga-ai/cli 0.3.0 → 0.5.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.
Files changed (2) hide show
  1. package/dist/cli.cjs +203 -44
  2. package/package.json +1 -1
package/dist/cli.cjs CHANGED
@@ -3037,7 +3037,7 @@ var {
3037
3037
  } = import_index.default;
3038
3038
 
3039
3039
  // src/cli.ts
3040
- var import_node_path6 = require("node:path");
3040
+ var import_node_path7 = require("node:path");
3041
3041
  var import_node_fs6 = require("node:fs");
3042
3042
 
3043
3043
  // src/commands/init.ts
@@ -4957,7 +4957,7 @@ function buildScopeSettings() {
4957
4957
  hooks: {
4958
4958
  PreToolUse: [
4959
4959
  {
4960
- matcher: "Read|Write|Edit",
4960
+ matcher: "Read|Write|Edit|Glob|Grep",
4961
4961
  hooks: [hookCommand]
4962
4962
  }
4963
4963
  ]
@@ -5022,7 +5022,114 @@ function spawnWorker(prompt, model, settings, workingDir) {
5022
5022
  }
5023
5023
  return result.stdout || "";
5024
5024
  }
5025
- function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot) {
5025
+ function formatStreamLine(line) {
5026
+ try {
5027
+ const data = JSON.parse(line);
5028
+ if (data.type === "assistant" && data.message?.content) {
5029
+ for (const block of data.message.content) {
5030
+ if (block.type === "text" && block.text) {
5031
+ return block.text;
5032
+ }
5033
+ if (block.type === "tool_use") {
5034
+ return `[Tool: ${block.name}]`;
5035
+ }
5036
+ }
5037
+ }
5038
+ if (data.type === "system" && data.subtype === "init") {
5039
+ return `[Session started: ${data.session_id}]`;
5040
+ }
5041
+ if (data.type === "result") {
5042
+ const status = data.subtype === "success" ? "completed" : "failed";
5043
+ return `
5044
+ [Worker ${status} in ${Math.round(data.duration_ms / 1e3)}s]`;
5045
+ }
5046
+ return null;
5047
+ } catch {
5048
+ return null;
5049
+ }
5050
+ }
5051
+ function parseStreamingResult(buffer) {
5052
+ const lines = buffer.split("\n").filter((line) => line.trim());
5053
+ for (let i = lines.length - 1; i >= 0; i--) {
5054
+ try {
5055
+ const data = JSON.parse(lines[i]);
5056
+ if (data.type === "result") {
5057
+ if (data.is_error) {
5058
+ throw new Error(`Worker failed: ${data.result || "Unknown error"}`);
5059
+ }
5060
+ if (!data.structured_output) {
5061
+ throw new Error("Worker result missing structured_output");
5062
+ }
5063
+ const output = data.structured_output;
5064
+ if (!VALID_STATUSES.has(output.status)) {
5065
+ throw new Error(`Invalid status: ${output.status}`);
5066
+ }
5067
+ return {
5068
+ status: output.status,
5069
+ summary: output.summary || "",
5070
+ blocker: output.blocker ?? null
5071
+ };
5072
+ }
5073
+ } catch (e) {
5074
+ if (e instanceof Error && e.message.startsWith("Worker")) {
5075
+ throw e;
5076
+ }
5077
+ }
5078
+ }
5079
+ throw new Error("No result found in worker output");
5080
+ }
5081
+ function spawnWorkerAsync(prompt, model, settings, workingDir) {
5082
+ return new Promise((resolve2, reject) => {
5083
+ let buffer = "";
5084
+ const args = [
5085
+ "-p",
5086
+ prompt,
5087
+ "--model",
5088
+ model,
5089
+ "--output-format",
5090
+ "stream-json",
5091
+ "--verbose",
5092
+ "--json-schema",
5093
+ JSON.stringify(WORKER_OUTPUT_SCHEMA),
5094
+ "--settings",
5095
+ JSON.stringify(settings),
5096
+ "--dangerously-skip-permissions"
5097
+ ];
5098
+ const child = (0, import_node_child_process.spawn)("claude", args, {
5099
+ cwd: workingDir,
5100
+ stdio: ["ignore", "pipe", "pipe"]
5101
+ });
5102
+ child.stdout.on("data", (chunk) => {
5103
+ const text = chunk.toString();
5104
+ buffer += text;
5105
+ const lines = text.split("\n");
5106
+ for (const line of lines) {
5107
+ if (line.trim()) {
5108
+ const formatted = formatStreamLine(line);
5109
+ if (formatted) {
5110
+ process.stdout.write(formatted);
5111
+ }
5112
+ }
5113
+ }
5114
+ });
5115
+ child.stderr.on("data", (chunk) => {
5116
+ process.stderr.write(chunk);
5117
+ });
5118
+ child.on("error", (err) => {
5119
+ reject(new Error(`Failed to spawn worker: ${err.message}`));
5120
+ });
5121
+ child.on("close", (code) => {
5122
+ process.stdout.write("\n");
5123
+ try {
5124
+ const result = parseStreamingResult(buffer);
5125
+ resolve2(result);
5126
+ } catch (e) {
5127
+ reject(e);
5128
+ }
5129
+ });
5130
+ });
5131
+ }
5132
+ async function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, pluginRoot, stream = false) {
5026
5133
  const worktree = (0, import_node_path4.join)(projectDir, ".saga", "worktrees", epicSlug, storySlug);
5027
5134
  const validation = validateStoryFiles(worktree, epicSlug, storySlug);
5028
5135
  if (!validation.valid) {
@@ -5064,33 +5171,55 @@ function runLoop(epicSlug, storySlug, maxCycles, maxTime, model, projectDir, plu
5064
5171
  break;
5065
5172
  }
5066
5173
  cycles += 1;
5067
- let output;
5068
- try {
5069
- output = spawnWorker(workerPrompt, model, settings, worktree);
5070
- } catch (e) {
5071
- return {
5072
- status: "ERROR",
5073
- summary: e.message,
5074
- cycles,
5075
- elapsedMinutes: (Date.now() - startTime) / 6e4,
5076
- blocker: null,
5077
- epicSlug,
5078
- storySlug
5079
- };
5080
- }
5081
5174
  let parsed;
5082
- try {
5083
- parsed = parseWorkerOutput(output);
5084
- } catch (e) {
5085
- return {
5086
- status: "ERROR",
5087
- summary: e.message,
5088
- cycles,
5089
- elapsedMinutes: (Date.now() - startTime) / 6e4,
5090
- blocker: null,
5091
- epicSlug,
5092
- storySlug
5093
- };
5175
+ if (stream) {
5176
+ console.log(`
5177
+ --- Worker ${cycles} started ---
5178
+ `);
5179
+ try {
5180
+ parsed = await spawnWorkerAsync(workerPrompt, model, settings, worktree);
5181
+ } catch (e) {
5182
+ return {
5183
+ status: "ERROR",
5184
+ summary: e.message,
5185
+ cycles,
5186
+ elapsedMinutes: (Date.now() - startTime) / 6e4,
5187
+ blocker: null,
5188
+ epicSlug,
5189
+ storySlug
5190
+ };
5191
+ }
5192
+ console.log(`
5193
+ --- Worker ${cycles} result: ${parsed.status} ---
5194
+ `);
5195
+ } else {
5196
+ let output;
5197
+ try {
5198
+ output = spawnWorker(workerPrompt, model, settings, worktree);
5199
+ } catch (e) {
5200
+ return {
5201
+ status: "ERROR",
5202
+ summary: e.message,
5203
+ cycles,
5204
+ elapsedMinutes: (Date.now() - startTime) / 6e4,
5205
+ blocker: null,
5206
+ epicSlug,
5207
+ storySlug
5208
+ };
5209
+ }
5210
+ try {
5211
+ parsed = parseWorkerOutput(output);
5212
+ } catch (e) {
5213
+ return {
5214
+ status: "ERROR",
5215
+ summary: e.message,
5216
+ cycles,
5217
+ elapsedMinutes: (Date.now() - startTime) / 6e4,
5218
+ blocker: null,
5219
+ epicSlug,
5220
+ storySlug
5221
+ };
5222
+ }
5094
5223
  }
5095
5224
  summaries.push(parsed.summary);
5096
5225
  if (parsed.status === "FINISH") {
@@ -5157,19 +5286,22 @@ Searched in: ${(0, import_node_path4.join)(projectPath, ".saga", "worktrees")}`)
5157
5286
  const maxCycles = options.maxCycles ?? DEFAULT_MAX_CYCLES;
5158
5287
  const maxTime = options.maxTime ?? DEFAULT_MAX_TIME;
5159
5288
  const model = options.model ?? DEFAULT_MODEL;
5289
+ const stream = options.stream ?? false;
5160
5290
  console.log("Starting story implementation...");
5161
5291
  console.log(` Epic: ${storyInfo.epicSlug}`);
5162
5292
  console.log(` Story: ${storyInfo.storySlug}`);
5163
5293
  console.log(` Worktree: ${storyInfo.worktreePath}`);
5294
+ console.log(` Streaming: ${stream ? "enabled" : "disabled"}`);
5164
5295
  console.log("");
5165
- const result = runLoop(
5296
+ const result = await runLoop(
5166
5297
  storyInfo.epicSlug,
5167
5298
  storyInfo.storySlug,
5168
5299
  maxCycles,
5169
5300
  maxTime,
5170
5301
  model,
5171
5302
  projectPath,
5172
- pluginRoot
5303
+ pluginRoot,
5304
+ stream
5173
5305
  );
5174
5306
  console.log(JSON.stringify(result, null, 2));
5175
5307
  if (result.status === "ERROR") {
@@ -5196,10 +5328,12 @@ async function dashboardCommand(options) {
5196
5328
  }
5197
5329
 
5198
5330
  // src/commands/scope-validator.ts
5199
- function getFilePathFromInput(toolInput) {
5331
+ var import_node_path5 = require("node:path");
5332
+ function getFilePathFromInput(hookInput) {
5200
5333
  try {
5201
- const data = JSON.parse(toolInput);
5202
- return data.file_path || data.path || null;
5334
+ const data = JSON.parse(hookInput);
5335
+ const toolInput = data.tool_input || {};
5336
+ return toolInput.file_path || toolInput.path || null;
5203
5337
  } catch {
5204
5338
  return null;
5205
5339
  }
@@ -5213,6 +5347,15 @@ function normalizePath(path) {
5213
5347
  function isArchiveAccess(path) {
5214
5348
  return path.includes(".saga/archive");
5215
5349
  }
5350
+ function isWithinWorktree(filePath, worktreePath) {
5351
+ const absoluteFilePath = (0, import_node_path5.resolve)(filePath);
5352
+ const absoluteWorktree = (0, import_node_path5.resolve)(worktreePath);
5353
+ const relativePath = (0, import_node_path5.relative)(absoluteWorktree, absoluteFilePath);
5354
+ if (relativePath.startsWith("..") || (0, import_node_path5.resolve)(relativePath) === relativePath) {
5355
+ return false;
5356
+ }
5357
+ return true;
5358
+ }
5216
5359
  function checkStoryAccess(path, allowedEpic, allowedStory) {
5217
5360
  if (!path.includes(".saga/epics/")) {
5218
5361
  return true;
@@ -5233,24 +5376,27 @@ function checkStoryAccess(path, allowedEpic, allowedStory) {
5233
5376
  return pathEpic === allowedEpic;
5234
5377
  }
5235
5378
  }
5236
- function printScopeViolation(filePath, epicSlug, storySlug, reason) {
5379
+ function printScopeViolation(filePath, epicSlug, storySlug, worktreePath, reason) {
5237
5380
  console.error(`SCOPE VIOLATION: ${reason}
5238
5381
 
5239
5382
  Attempted path: ${filePath}
5240
5383
 
5241
5384
  Your scope is limited to:
5385
+ Worktree: ${worktreePath}
5242
5386
  Epic: ${epicSlug}
5243
5387
  Story: ${storySlug}
5244
- Allowed: .saga/epics/${epicSlug}/stories/${storySlug}/
5388
+ Allowed story files: .saga/epics/${epicSlug}/stories/${storySlug}/
5245
5389
 
5390
+ Workers cannot access files outside the worktree directory.
5246
5391
  To access other stories, start a new /implement session for that story.`);
5247
5392
  }
5248
5393
  async function scopeValidatorCommand() {
5394
+ const worktreePath = process.env.SAGA_PROJECT_DIR || "";
5249
5395
  const epicSlug = process.env.SAGA_EPIC_SLUG || "";
5250
5396
  const storySlug = process.env.SAGA_STORY_SLUG || "";
5251
- if (!epicSlug || !storySlug) {
5397
+ if (!worktreePath || !epicSlug || !storySlug) {
5252
5398
  console.error(
5253
- "ERROR: scope-validator requires SAGA_EPIC_SLUG and SAGA_STORY_SLUG environment variables"
5399
+ "ERROR: scope-validator requires SAGA_PROJECT_DIR, SAGA_EPIC_SLUG, and SAGA_STORY_SLUG environment variables"
5254
5400
  );
5255
5401
  process.exit(2);
5256
5402
  }
@@ -5264,11 +5410,22 @@ async function scopeValidatorCommand() {
5264
5410
  process.exit(0);
5265
5411
  }
5266
5412
  const normPath = normalizePath(filePath);
5413
+ if (!isWithinWorktree(normPath, worktreePath)) {
5414
+ printScopeViolation(
5415
+ filePath,
5416
+ epicSlug,
5417
+ storySlug,
5418
+ worktreePath,
5419
+ "Access outside worktree blocked\nReason: Workers can only access files within their assigned worktree directory."
5420
+ );
5421
+ process.exit(2);
5422
+ }
5267
5423
  if (isArchiveAccess(normPath)) {
5268
5424
  printScopeViolation(
5269
5425
  filePath,
5270
5426
  epicSlug,
5271
5427
  storySlug,
5428
+ worktreePath,
5272
5429
  "Access to archive folder blocked\nReason: The archive folder contains completed stories and is read-only during execution."
5273
5430
  );
5274
5431
  process.exit(2);
@@ -5278,6 +5435,7 @@ async function scopeValidatorCommand() {
5278
5435
  filePath,
5279
5436
  epicSlug,
5280
5437
  storySlug,
5438
+ worktreePath,
5281
5439
  "Access to other story blocked\nReason: Workers can only access their assigned story's files."
5282
5440
  );
5283
5441
  process.exit(2);
@@ -5308,7 +5466,7 @@ async function findCommand(query, options) {
5308
5466
  }
5309
5467
 
5310
5468
  // src/commands/worktree.ts
5311
- var import_node_path5 = require("node:path");
5469
+ var import_node_path6 = require("node:path");
5312
5470
  var import_node_fs5 = require("node:fs");
5313
5471
  var import_node_child_process2 = require("node:child_process");
5314
5472
  function runGitCommand(args, cwd) {
@@ -5340,7 +5498,7 @@ function getMainBranch(cwd) {
5340
5498
  }
5341
5499
  function createWorktree(projectPath, epicSlug, storySlug) {
5342
5500
  const branchName = `story-${storySlug}-epic-${epicSlug}`;
5343
- const worktreePath = (0, import_node_path5.join)(
5501
+ const worktreePath = (0, import_node_path6.join)(
5344
5502
  projectPath,
5345
5503
  ".saga",
5346
5504
  "worktrees",
@@ -5371,7 +5529,7 @@ function createWorktree(projectPath, epicSlug, storySlug) {
5371
5529
  error: `Failed to create branch: ${createBranchResult.output}`
5372
5530
  };
5373
5531
  }
5374
- const worktreeParent = (0, import_node_path5.join)(projectPath, ".saga", "worktrees", epicSlug);
5532
+ const worktreeParent = (0, import_node_path6.join)(projectPath, ".saga", "worktrees", epicSlug);
5375
5533
  (0, import_node_fs5.mkdirSync)(worktreeParent, { recursive: true });
5376
5534
  const createWorktreeResult = runGitCommand(
5377
5535
  ["worktree", "add", worktreePath, branchName],
@@ -5406,7 +5564,7 @@ async function worktreeCommand(epicSlug, storySlug, options) {
5406
5564
  }
5407
5565
 
5408
5566
  // src/cli.ts
5409
- var packageJsonPath = (0, import_node_path6.join)(__dirname, "..", "package.json");
5567
+ var packageJsonPath = (0, import_node_path7.join)(__dirname, "..", "package.json");
5410
5568
  var packageJson = JSON.parse((0, import_node_fs6.readFileSync)(packageJsonPath, "utf-8"));
5411
5569
  var program2 = new Command();
5412
5570
  program2.name("saga").description("CLI for SAGA - Structured Autonomous Goal Achievement").version(packageJson.version).addHelpCommand("help [command]", "Display help for a command");
@@ -5415,14 +5573,15 @@ program2.command("init").description("Initialize .saga/ directory structure").op
5415
5573
  const globalOpts = program2.opts();
5416
5574
  await initCommand({ path: globalOpts.path, dryRun: options.dryRun });
5417
5575
  });
5418
- program2.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) => {
5576
+ program2.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) => {
5419
5577
  const globalOpts = program2.opts();
5420
5578
  await implementCommand(storySlug, {
5421
5579
  path: globalOpts.path,
5422
5580
  maxCycles: options.maxCycles,
5423
5581
  maxTime: options.maxTime,
5424
5582
  model: options.model,
5425
- dryRun: options.dryRun
5583
+ dryRun: options.dryRun,
5584
+ stream: options.stream
5426
5585
  });
5427
5586
  });
5428
5587
  program2.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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saga-ai/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for SAGA - Structured Autonomous Goal Achievement",
5
5
  "type": "module",
6
6
  "bin": {