@scira/cli 0.1.2 → 0.1.3

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 (38) hide show
  1. package/README.md +54 -10
  2. package/dist/agent/background-tasks.js +173 -0
  3. package/dist/agent/research-agent.js +95 -38
  4. package/dist/agent/todos.js +140 -0
  5. package/dist/agent/tools.js +146 -143
  6. package/dist/agent/tools.test.js +33 -0
  7. package/dist/agent/workspace.js +85 -0
  8. package/dist/cli/commands/init.js +51 -39
  9. package/dist/cli/index.js +30 -14
  10. package/dist/config/env-guide.js +151 -0
  11. package/dist/config/env-guide.test.js +18 -0
  12. package/dist/config/env-store.js +53 -0
  13. package/dist/config/env-store.test.js +60 -0
  14. package/dist/tools/agent-tools.js +621 -0
  15. package/dist/tools/background-tasks.js +261 -0
  16. package/dist/tools/bash-policy.test.js +38 -0
  17. package/dist/tools/file-tools.js +6 -1
  18. package/dist/tools/search-web.js +24 -6
  19. package/dist/tools/search-web.test.js +24 -0
  20. package/dist/tools/todos.js +140 -0
  21. package/dist/tools/workspace.js +91 -0
  22. package/dist/tools/workspace.test.js +75 -0
  23. package/dist/tools/x-search.js +142 -0
  24. package/dist/ui/ink/SciraApp.js +11 -8
  25. package/dist/ui/ink/components/overlays.js +4 -4
  26. package/dist/ui/ink/constants.js +11 -3
  27. package/dist/ui/ink/hooks/use-agent-turn.js +24 -5
  28. package/dist/ui/ink/hooks/use-keyboard.js +3 -0
  29. package/dist/ui/ink/hooks/use-session.js +5 -3
  30. package/dist/ui/ink/hooks/use-settings.js +10 -8
  31. package/dist/ui/ink/hooks/use-submit.js +13 -2
  32. package/dist/ui/ink/hooks/use-theme.js +1 -1
  33. package/dist/ui/ink/lib/tool-result.js +72 -5
  34. package/dist/ui/ink/lib/utils.js +40 -3
  35. package/dist/ui/ink/theme-context.js +29 -26
  36. package/dist/ui/ink/theme.js +36 -9
  37. package/dist/ui/ink/theme.test.js +32 -5
  38. package/package.json +5 -2
@@ -1,7 +1,7 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { readFile, writeFile, mkdir } from "node:fs/promises";
4
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { dirname, join, relative } from "node:path";
5
5
  import { tool } from "ai";
6
6
  import { z } from "zod";
7
7
  import { multiSearchWeb } from "../tools/search-web.js";
@@ -11,6 +11,9 @@ import { logEvent } from "../storage/run-store.js";
11
11
  import { diffLines } from "diff";
12
12
  import { SKILL_NAMES, getSkill } from "./skills.js";
13
13
  import { createFileTools } from "../tools/file-tools.js";
14
+ import { createTodoTool } from "./todos.js";
15
+ import { resolveToolPath, resolveInsideWorkspace } from "./workspace.js";
16
+ export { resolveInsideRun } from "./workspace.js";
14
17
  const execAsync = promisify(exec);
15
18
  const MAX_OUTPUT = 8000;
16
19
  function truncate(text, max = MAX_OUTPUT) {
@@ -22,18 +25,7 @@ function truncate(text, max = MAX_OUTPUT) {
22
25
  function snapshotSlug(url) {
23
26
  return url.replace(/^https?:\/\//u, "").replace(/[^a-zA-Z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80) || "page";
24
27
  }
25
- /**
26
- * Resolve a model-provided path against the run directory and refuse to escape it.
27
- */
28
- export function resolveInsideRun(runPath, candidate) {
29
- const abs = isAbsolute(candidate) ? candidate : resolve(runPath, candidate);
30
- const rel = relative(runPath, abs);
31
- if (rel.startsWith("..") || isAbsolute(rel)) {
32
- throw new Error(`Path "${candidate}" is outside the run directory and is not allowed.`);
33
- }
34
- return abs;
35
- }
36
- export function createResearchTools(runPath, config, onApprovalRequired) {
28
+ export function createResearchTools(runPath, config, onApprovalRequired, workspacePath) {
37
29
  /** Gate a tool behind user approval unless approvalMode is "auto". */
38
30
  async function gate(toolName, description) {
39
31
  if (config.approvalMode === "auto" || !onApprovalRequired)
@@ -41,55 +33,60 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
41
33
  return onApprovalRequired(toolName, description);
42
34
  }
43
35
  const claimsPath = join(runPath, "claims.jsonl");
44
- return {
45
- bash: tool({
46
- description: "Run a shell command inside the run directory. Use for listing files, grepping notes, running scripts, git, etc. Returns combined stdout/stderr.",
47
- inputSchema: z.object({
48
- command: z.string().describe("The shell command to execute."),
49
- timeoutMs: z.number().int().positive().max(120000).optional().describe("Optional timeout in ms (default 60000).")
50
- }),
51
- execute: async ({ command, timeoutMs }) => {
52
- if (!await gate("bash", command))
53
- return "Command rejected by user.";
54
- // Block commands that attempt to leave the run directory.
55
- const escapesRunDir = /(?:^|[;&|`\n])\s*cd\s+[^.]/u.test(command)
56
- || /\.\.\/\.\.\//u.test(command)
57
- || /~[/\\]/u.test(command)
58
- || /\$HOME/u.test(command);
59
- if (escapesRunDir) {
60
- return "Command rejected: navigating outside the run directory is not allowed.";
61
- }
62
- await logEvent(runPath, "tool.bash", { command });
63
- try {
64
- const { stdout, stderr } = await execAsync(command, {
65
- cwd: runPath,
66
- timeout: timeoutMs ?? 60000,
67
- maxBuffer: 10 * 1024 * 1024,
68
- shell: "/bin/bash"
69
- });
70
- const out = [stdout, stderr].filter(Boolean).join("\n").trim();
71
- return truncate(out || "(no output)");
72
- }
73
- catch (error) {
74
- const err = error;
75
- const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
76
- return truncate(`Command failed:\n${out}`);
77
- }
78
- }
36
+ const filePathHint = workspacePath
37
+ ? "Project source paths (src/foo.ts, package.json, …) are relative to the project root. Harness files (plan.md, notes.md, report.md, sources.jsonl) live in the run directory — use those bare names."
38
+ : "Paths are relative to the run directory.";
39
+ const runBash = tool({
40
+ description: "Run a shell command inside the run directory. Use for listing files, grepping notes, running scripts, git, etc. Returns combined stdout/stderr.",
41
+ inputSchema: z.object({
42
+ command: z.string().describe("The shell command to execute."),
43
+ timeoutMs: z.number().int().positive().max(120000).optional().describe("Optional timeout in ms (default 60000).")
79
44
  }),
45
+ execute: async ({ command, timeoutMs }) => {
46
+ if (!await gate("bash", command))
47
+ return "Command rejected by user.";
48
+ const escapesRunDir = /(?:^|[;&|`\n])\s*cd\s+[^.]/u.test(command)
49
+ || /\.\.\/\.\.\//u.test(command)
50
+ || /~[/\\]/u.test(command)
51
+ || /\$HOME/u.test(command);
52
+ if (escapesRunDir) {
53
+ return "Command rejected: navigating outside the run directory is not allowed.";
54
+ }
55
+ await logEvent(runPath, "tool.bash", { command });
56
+ try {
57
+ const { stdout, stderr } = await execAsync(command, {
58
+ cwd: runPath,
59
+ timeout: timeoutMs ?? 60000,
60
+ maxBuffer: 10 * 1024 * 1024,
61
+ shell: "/bin/bash"
62
+ });
63
+ const out = [stdout, stderr].filter(Boolean).join("\n").trim();
64
+ return truncate(out || "(no output)");
65
+ }
66
+ catch (error) {
67
+ const err = error;
68
+ const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
69
+ return truncate(`Command failed:\n${out}`);
70
+ }
71
+ }
72
+ });
73
+ return {
74
+ ...(workspacePath ? {} : { bash: runBash }),
80
75
  writeFile: tool({
81
- description: "Create or overwrite a file inside the run directory (e.g. plan.md, notes.md, report.md, sources.jsonl). Paths are relative to the run directory.",
76
+ description: `Create or overwrite a file. ${filePathHint}`,
82
77
  inputSchema: z.object({
83
- path: z.string().describe("File path relative to the run directory."),
78
+ path: z.string().describe("File path."),
84
79
  content: z.string().describe("Full file content to write.")
85
80
  }),
86
81
  execute: async ({ path, content }) => {
87
- const isPlanOrReport = path === "plan.md" || path === "report.md";
88
- if (isPlanOrReport) {
82
+ const resolved = resolveToolPath(runPath, workspacePath, path);
83
+ const needsApproval = resolved.scope === "workspace"
84
+ || path === "plan.md"
85
+ || path === "report.md";
86
+ if (needsApproval) {
89
87
  let description;
90
- if (path === "report.md") {
91
- const abs = resolveInsideRun(runPath, path);
92
- const existing = await readFile(abs, "utf8").catch(() => "");
88
+ if (path === "report.md" && resolved.scope === "run") {
89
+ const existing = await readFile(resolved.abs, "utf8").catch(() => "");
93
90
  const parts = diffLines(existing, content);
94
91
  const added = parts.filter((p) => p.added).reduce((n, p) => n + (p.count ?? 0), 0);
95
92
  const removed = parts.filter((p) => p.removed).reduce((n, p) => n + (p.count ?? 0), 0);
@@ -100,40 +97,55 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
100
97
  .join("\n");
101
98
  description = `report.md (+${added} / -${removed} lines)\n\n${diffPreview}`;
102
99
  }
100
+ else if (resolved.scope === "workspace") {
101
+ const preview = content.length > 800 ? `${content.slice(0, 800)}\n…[${content.length} total chars]` : content;
102
+ description = `Write to ${resolved.displayPath}:\n\n${preview}`;
103
+ }
103
104
  else {
104
105
  description = `${path}\n\n${content.length > 600 ? `${content.slice(0, 600)}\n…` : content}`;
105
106
  }
106
107
  if (!await gate("writeFile", description))
107
- return `Write to ${path} rejected by user.`;
108
+ return `Write to ${resolved.displayPath} rejected by user.`;
108
109
  }
109
- const abs = resolveInsideRun(runPath, path);
110
- await mkdir(dirname(abs), { recursive: true });
111
- await writeFile(abs, content);
110
+ await mkdir(dirname(resolved.abs), { recursive: true });
111
+ await writeFile(resolved.abs, content);
112
112
  const eventType = path === "report.md" ? "report.updated" : path === "plan.md" ? "plan.updated" : "file.written";
113
- await logEvent(runPath, eventType, { path, chars: content.length });
114
- return `Wrote ${content.length} chars to ${path}`;
113
+ await logEvent(runPath, eventType, { path: resolved.displayPath, scope: resolved.scope, chars: content.length });
114
+ const where = resolved.scope === "workspace" ? "workspace" : "run";
115
+ return `Wrote ${content.length} chars to ${resolved.displayPath} (${where})`;
115
116
  }
116
117
  }),
117
118
  editFile: tool({
118
- description: "Replace an exact string in an existing file inside the run directory. The oldString must match exactly and be unique.",
119
+ description: `Replace an exact string in an existing file. ${filePathHint} The oldString must match exactly and be unique.`,
119
120
  inputSchema: z.object({
120
- path: z.string().describe("File path relative to the run directory."),
121
+ path: z.string().describe("File path."),
121
122
  oldString: z.string().describe("Exact text to replace."),
122
123
  newString: z.string().describe("Replacement text.")
123
124
  }),
124
125
  execute: async ({ path, oldString, newString }) => {
125
- const abs = resolveInsideRun(runPath, path);
126
- const current = await readFile(abs, "utf8");
126
+ const resolved = resolveToolPath(runPath, workspacePath, path);
127
+ const current = await readFile(resolved.abs, "utf8");
127
128
  const occurrences = current.split(oldString).length - 1;
128
129
  if (occurrences === 0) {
129
- return `No match for the given oldString in ${path}. No changes made.`;
130
+ return `No match for the given oldString in ${resolved.displayPath}. No changes made.`;
130
131
  }
131
132
  if (occurrences > 1) {
132
- return `oldString matched ${occurrences} times in ${path}; provide more context to make it unique. No changes made.`;
133
+ return `oldString matched ${occurrences} times in ${resolved.displayPath}; provide more context to make it unique. No changes made.`;
134
+ }
135
+ if (resolved.scope === "workspace") {
136
+ const diff = diffLines(current, current.replace(oldString, newString));
137
+ const preview = diff
138
+ .filter((p) => p.added || p.removed)
139
+ .flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
140
+ .slice(0, 15)
141
+ .join("\n");
142
+ if (!await gate("editFile", `Edit ${resolved.displayPath}:\n\n${preview}`)) {
143
+ return `Edit to ${resolved.displayPath} rejected by user.`;
144
+ }
133
145
  }
134
- await writeFile(abs, current.replace(oldString, newString));
135
- await logEvent(runPath, "file.edited", { path });
136
- return `Edited ${path}`;
146
+ await writeFile(resolved.abs, current.replace(oldString, newString));
147
+ await logEvent(runPath, "file.edited", { path: resolved.displayPath, scope: resolved.scope });
148
+ return `Edited ${resolved.displayPath}`;
137
149
  }
138
150
  }),
139
151
  createClaim: tool({
@@ -173,13 +185,13 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
173
185
  }
174
186
  }),
175
187
  readFile: tool({
176
- description: "Read a file inside the run directory.",
188
+ description: `Read a file. ${filePathHint}`,
177
189
  inputSchema: z.object({
178
- path: z.string().describe("File path relative to the run directory.")
190
+ path: z.string().describe("File path.")
179
191
  }),
180
192
  execute: async ({ path }) => {
181
- const abs = resolveInsideRun(runPath, path);
182
- return truncate(await readFile(abs, "utf8"));
193
+ const resolved = resolveToolPath(runPath, workspacePath, path);
194
+ return truncate(await readFile(resolved.abs, "utf8"));
183
195
  }
184
196
  }),
185
197
  webSearch: tool({
@@ -246,6 +258,7 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
246
258
  return skill.content;
247
259
  }
248
260
  }),
261
+ todo: createTodoTool(runPath),
249
262
  ...(config.files ? createFileTools(runPath, config, onApprovalRequired) : {})
250
263
  };
251
264
  }
@@ -254,8 +267,9 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
254
267
  * plus a single escalation tool the model can call (with user approval) to
255
268
  * switch into the full research harness.
256
269
  */
257
- export function createOneShotTools(runPath, config, onApprovalRequired, onEscalate) {
258
- const all = createResearchTools(runPath, config, onApprovalRequired);
270
+ export function createOneShotTools(runPath, config, onApprovalRequired, onEscalate, workspacePath, backgroundTasks) {
271
+ const all = createResearchTools(runPath, config, onApprovalRequired, workspacePath);
272
+ const coding = workspacePath ? createCodingTools(workspacePath, config, onApprovalRequired, backgroundTasks, runPath) : {};
259
273
  async function gate(toolName, description) {
260
274
  if (config.approvalMode === "auto" || !onApprovalRequired)
261
275
  return true;
@@ -264,6 +278,8 @@ export function createOneShotTools(runPath, config, onApprovalRequired, onEscala
264
278
  return {
265
279
  webSearch: all.webSearch,
266
280
  readUrl: all.readUrl,
281
+ todo: all.todo,
282
+ ...coding,
267
283
  requestFullResearch: tool({
268
284
  description: "Escalate from quick one-shot mode to the FULL research harness (skills, plan.md, claims, verification, sources.jsonl, report.md). " +
269
285
  "You MUST call this if the user's goal involves research, deep dives, analysis, comparisons, history, or any topic that would benefit from structured multi-source research. " +
@@ -286,76 +302,18 @@ export function createOneShotTools(runPath, config, onApprovalRequired, onEscala
286
302
  * Workspace-aware tools for coding agent capabilities.
287
303
  * Unlike research tools, these operate on the full workspace, not just the run directory.
288
304
  */
289
- export function createCodingTools(workspacePath, config, onApprovalRequired) {
305
+ export function createCodingTools(workspacePath, config, onApprovalRequired, backgroundTasks, runPath) {
290
306
  async function gate(toolName, description) {
291
307
  if (config.approvalMode === "auto" || !onApprovalRequired)
292
308
  return true;
293
309
  return onApprovalRequired(toolName, description);
294
310
  }
295
311
  function resolveWorkspacePath(candidate) {
296
- return isAbsolute(candidate) ? candidate : resolve(workspacePath, candidate);
312
+ return resolveInsideWorkspace(workspacePath, candidate);
297
313
  }
298
314
  return {
299
- readWorkspaceFile: tool({
300
- description: "Read a file from the workspace. Use for examining code, configs, or any workspace file.",
301
- inputSchema: z.object({
302
- path: z.string().describe("File path (absolute or relative to workspace root).")
303
- }),
304
- execute: async ({ path }) => {
305
- const abs = resolveWorkspacePath(path);
306
- const content = await readFile(abs, "utf8");
307
- return truncate(content);
308
- }
309
- }),
310
- writeWorkspaceFile: tool({
311
- description: "Create or overwrite a file in the workspace. Requires approval for safety.",
312
- inputSchema: z.object({
313
- path: z.string().describe("File path (absolute or relative to workspace root)."),
314
- content: z.string().describe("Full file content to write.")
315
- }),
316
- execute: async ({ path, content }) => {
317
- const abs = resolveWorkspacePath(path);
318
- const preview = content.length > 800 ? `${content.slice(0, 800)}\n…[${content.length} total chars]` : content;
319
- if (!await gate("writeWorkspaceFile", `Write to ${path}:\n\n${preview}`)) {
320
- return `Write to ${path} rejected by user.`;
321
- }
322
- await mkdir(dirname(abs), { recursive: true });
323
- await writeFile(abs, content);
324
- return `Wrote ${content.length} chars to ${path}`;
325
- }
326
- }),
327
- editWorkspaceFile: tool({
328
- description: "Replace an exact string in a workspace file. The oldString must match exactly and be unique.",
329
- inputSchema: z.object({
330
- path: z.string().describe("File path (absolute or relative to workspace root)."),
331
- oldString: z.string().describe("Exact text to replace."),
332
- newString: z.string().describe("Replacement text.")
333
- }),
334
- execute: async ({ path, oldString, newString }) => {
335
- const abs = resolveWorkspacePath(path);
336
- const current = await readFile(abs, "utf8");
337
- const occurrences = current.split(oldString).length - 1;
338
- if (occurrences === 0) {
339
- return `No match for the given oldString in ${path}. No changes made.`;
340
- }
341
- if (occurrences > 1) {
342
- return `oldString matched ${occurrences} times in ${path}; provide more context to make it unique. No changes made.`;
343
- }
344
- const diff = diffLines(current, current.replace(oldString, newString));
345
- const preview = diff
346
- .filter((p) => p.added || p.removed)
347
- .flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
348
- .slice(0, 15)
349
- .join("\n");
350
- if (!await gate("editWorkspaceFile", `Edit ${path}:\n\n${preview}`)) {
351
- return `Edit to ${path} rejected by user.`;
352
- }
353
- await writeFile(abs, current.replace(oldString, newString));
354
- return `Edited ${path}`;
355
- }
356
- }),
357
315
  listWorkspaceDir: tool({
358
- description: "List files and directories in a workspace directory.",
316
+ description: "List files and directories in the project workspace.",
359
317
  inputSchema: z.object({
360
318
  path: z.string().describe("Directory path (absolute or relative to workspace root)."),
361
319
  recursive: z.boolean().optional().describe("Recursively list subdirectories (default false).")
@@ -396,18 +354,63 @@ export function createCodingTools(workspacePath, config, onApprovalRequired) {
396
354
  }
397
355
  }
398
356
  }),
399
- runWorkspaceCommand: tool({
400
- description: "Execute a shell command in the workspace. Use for builds, tests, package installs, git, etc. Requires approval.",
357
+ bash: tool({
358
+ description: "Run shell commands in the workspace. " +
359
+ "Actions: run (foreground, default), background (start a long-running process like a dev server), list (show background tasks), output (read task logs), kill (stop a background task). " +
360
+ "Use background for servers and watchers; the task id and output remain available across turns.",
401
361
  inputSchema: z.object({
402
- command: z.string().describe("The shell command to execute."),
362
+ action: z
363
+ .enum(["run", "background", "list", "output", "kill"])
364
+ .optional()
365
+ .describe("run=foreground (default), background=spawn detached, list/output/kill manage background tasks."),
366
+ command: z.string().optional().describe("Shell command for run or background."),
367
+ taskId: z.string().optional().describe("Task id for output or kill."),
403
368
  cwd: z.string().optional().describe("Working directory (default: workspace root)."),
404
- timeoutMs: z.number().int().positive().max(300000).optional().describe("Timeout in ms (default 60000, max 300000).")
369
+ timeoutMs: z.number().int().positive().max(300000).optional().describe("Timeout for foreground run (default 60000)."),
370
+ tailLines: z.number().int().positive().max(200).optional().describe("Lines of output for action=output (default 50).")
405
371
  }),
406
- execute: async ({ command, cwd, timeoutMs }) => {
372
+ execute: async ({ action, command, taskId, cwd, timeoutMs, tailLines }) => {
373
+ const act = action ?? "run";
407
374
  const workDir = cwd ? resolveWorkspacePath(cwd) : workspacePath;
408
- if (!await gate("runWorkspaceCommand", `Run command in ${relative(workspacePath, workDir) || "."}:\n\n${command}`)) {
375
+ if (act === "list") {
376
+ if (!backgroundTasks)
377
+ return "No background task manager available.";
378
+ const tasks = await backgroundTasks.list();
379
+ if (tasks.length === 0)
380
+ return "No background tasks.";
381
+ return tasks
382
+ .map((t) => `${t.id} [${t.status}] pid=${t.pid} ${t.command}`)
383
+ .join("\n");
384
+ }
385
+ if (act === "output") {
386
+ if (!backgroundTasks)
387
+ return "No background task manager available.";
388
+ if (!taskId)
389
+ return "output requires taskId.";
390
+ return truncate(await backgroundTasks.getOutput(taskId, tailLines ?? 50));
391
+ }
392
+ if (act === "kill") {
393
+ if (!backgroundTasks)
394
+ return "No background task manager available.";
395
+ if (!taskId)
396
+ return "kill requires taskId.";
397
+ return await backgroundTasks.kill(taskId);
398
+ }
399
+ if (!command)
400
+ return `${act} requires command.`;
401
+ if (act === "background") {
402
+ if (!backgroundTasks)
403
+ return "Background tasks not available in this session.";
404
+ const task = await backgroundTasks.spawn(command, workDir);
405
+ if (runPath)
406
+ await logEvent(runPath, "tool.bash.background", { taskId: task.id, command });
407
+ return `Started background task ${task.id} (pid ${task.pid}): ${command}\nUse bash action=output taskId=${task.id} to read output.`;
408
+ }
409
+ if (!await gate("bash", `Run in ${relative(workspacePath, workDir) || "."}:\n\n${command}`)) {
409
410
  return "Command rejected by user.";
410
411
  }
412
+ if (runPath)
413
+ await logEvent(runPath, "tool.bash", { command, cwd: workDir });
411
414
  try {
412
415
  const { stdout, stderr } = await execAsync(command, {
413
416
  cwd: workDir,
@@ -1,6 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { resolveInsideRun } from "./tools.js";
3
+ import { isRunArtifactPath, resolveProjectRoot, resolveToolPath } from "./workspace.js";
3
4
  const RUN = "/tmp/scira-test-run";
5
+ const PROJECT = "/Users/me/my-app";
6
+ const RUN_UNDER_SCIRA = `${PROJECT}/.scira/runs/2024-test-abc`;
4
7
  describe("resolveInsideRun", () => {
5
8
  it("resolves a relative path inside the run dir", () => {
6
9
  expect(resolveInsideRun(RUN, "notes.md")).toBe(`${RUN}/notes.md`);
@@ -25,3 +28,33 @@ describe("resolveInsideRun", () => {
25
28
  expect(() => resolveInsideRun(RUN, home)).toThrow("outside the run directory");
26
29
  });
27
30
  });
31
+ describe("resolveProjectRoot", () => {
32
+ it("returns parent of .scira when run is under .scira/runs", () => {
33
+ expect(resolveProjectRoot(RUN_UNDER_SCIRA)).toBe(PROJECT);
34
+ });
35
+ });
36
+ describe("isRunArtifactPath", () => {
37
+ it("treats harness filenames as run artifacts", () => {
38
+ expect(isRunArtifactPath("plan.md")).toBe(true);
39
+ expect(isRunArtifactPath("notes.md")).toBe(true);
40
+ expect(isRunArtifactPath("src/foo.ts")).toBe(false);
41
+ });
42
+ it("treats run: prefix as run artifact", () => {
43
+ expect(isRunArtifactPath("run:custom.md")).toBe(true);
44
+ });
45
+ });
46
+ describe("resolveToolPath", () => {
47
+ it("routes source paths to workspace", () => {
48
+ const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "src/index.ts");
49
+ expect(resolved.scope).toBe("workspace");
50
+ expect(resolved.abs).toBe(`${PROJECT}/src/index.ts`);
51
+ });
52
+ it("routes plan.md to run directory", () => {
53
+ const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "plan.md");
54
+ expect(resolved.scope).toBe("run");
55
+ expect(resolved.abs).toBe(`${RUN_UNDER_SCIRA}/plan.md`);
56
+ });
57
+ it("blocks writes into .scira from workspace paths", () => {
58
+ expect(() => resolveToolPath(RUN_UNDER_SCIRA, PROJECT, ".scira/config.json")).toThrow("inside .scira");
59
+ });
60
+ });
@@ -0,0 +1,85 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+ /** Harness files that live under .scira/runs/<id>/, not in the project codebase. */
3
+ export const RUN_ARTIFACT_FILES = new Set([
4
+ "plan.md",
5
+ "notes.md",
6
+ "report.md",
7
+ "sources.jsonl",
8
+ "claims.jsonl",
9
+ "goal.md",
10
+ "RESEARCH.md",
11
+ "scope.json",
12
+ "progress.md",
13
+ "handoff.md",
14
+ "convo.json",
15
+ "todos.json",
16
+ "background-tasks.json",
17
+ "title.md",
18
+ "run.log.jsonl"
19
+ ]);
20
+ export function isRunArtifactPath(candidate) {
21
+ if (candidate.startsWith("run:"))
22
+ return true;
23
+ const normalized = candidate.replace(/\\/g, "/").replace(/^\.\//u, "");
24
+ if (normalized.startsWith("snapshots/") || normalized.startsWith("artifacts/"))
25
+ return true;
26
+ const base = normalized.split("/").pop() ?? normalized;
27
+ return RUN_ARTIFACT_FILES.has(base);
28
+ }
29
+ /**
30
+ * Project root: parent of `.scira` when the run lives under `.scira/runs/…`,
31
+ * otherwise the current working directory (unless cwd is inside `.scira`).
32
+ */
33
+ export function resolveProjectRoot(runPath, cwd = process.cwd()) {
34
+ const fromRun = projectRootFromPath(runPath);
35
+ if (fromRun)
36
+ return fromRun;
37
+ const fromCwd = projectRootFromPath(resolve(cwd));
38
+ if (fromCwd)
39
+ return fromCwd;
40
+ return resolve(cwd);
41
+ }
42
+ function projectRootFromPath(absPath) {
43
+ const normalized = resolve(absPath).replace(/\\/g, "/");
44
+ const marker = "/.scira/";
45
+ const idx = normalized.indexOf(marker);
46
+ if (idx >= 0)
47
+ return normalized.slice(0, idx) || "/";
48
+ if (normalized.endsWith("/.scira"))
49
+ return normalized.slice(0, -"/.scira".length) || "/";
50
+ return undefined;
51
+ }
52
+ export function resolveToolPath(runPath, workspacePath, candidate) {
53
+ const raw = candidate.trim();
54
+ if (raw.startsWith("run:")) {
55
+ const inner = raw.slice(4);
56
+ const abs = resolveInsideRun(runPath, inner);
57
+ return { abs, displayPath: inner, scope: "run" };
58
+ }
59
+ if (workspacePath && !isRunArtifactPath(raw)) {
60
+ const abs = resolveInsideWorkspace(workspacePath, raw);
61
+ return { abs, displayPath: raw, scope: "workspace" };
62
+ }
63
+ const abs = resolveInsideRun(runPath, raw);
64
+ return { abs, displayPath: raw, scope: "run" };
65
+ }
66
+ export function resolveInsideRun(runPath, candidate) {
67
+ const abs = isAbsolute(candidate) ? candidate : resolve(runPath, candidate);
68
+ const rel = relative(runPath, abs);
69
+ if (rel.startsWith("..") || isAbsolute(rel)) {
70
+ throw new Error(`Path "${candidate}" is outside the run directory and is not allowed.`);
71
+ }
72
+ return abs;
73
+ }
74
+ export function resolveInsideWorkspace(workspacePath, candidate) {
75
+ const abs = isAbsolute(candidate) ? candidate : resolve(workspacePath, candidate);
76
+ const rel = relative(workspacePath, abs);
77
+ if (rel.startsWith("..") || isAbsolute(rel)) {
78
+ throw new Error(`Path "${candidate}" is outside the project workspace.`);
79
+ }
80
+ const norm = rel.replace(/\\/g, "/");
81
+ if (norm === ".scira" || norm.startsWith(".scira/")) {
82
+ throw new Error(`Path "${candidate}" is inside .scira. Harness files use bare names (plan.md, notes.md, report.md). Source code paths are relative to the project root.`);
83
+ }
84
+ return abs;
85
+ }