@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
@@ -0,0 +1,261 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ const MAX_OUTPUT_LINES = 500;
6
+ const MAX_TAIL_CHARS = 4000;
7
+ function nextTaskId(existing) {
8
+ const nums = existing
9
+ .map((t) => /^task_(\d+)$/u.exec(t.id)?.[1])
10
+ .filter((n) => Boolean(n))
11
+ .map((n) => Number.parseInt(n, 10));
12
+ const next = nums.length > 0 ? Math.max(...nums) + 1 : 1;
13
+ return `task_${String(next).padStart(3, "0")}`;
14
+ }
15
+ function tailText(lines, maxChars = MAX_TAIL_CHARS) {
16
+ const joined = lines.join("\n");
17
+ if (joined.length <= maxChars)
18
+ return joined;
19
+ return `…[truncated]\n${joined.slice(-maxChars)}`;
20
+ }
21
+ /** Returns true if a process with this pid is still running. */
22
+ function isProcessRunning(pid) {
23
+ if (pid <= 0)
24
+ return false;
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ function isValidRecord(t) {
34
+ if (typeof t !== "object" || t === null)
35
+ return false;
36
+ const r = t;
37
+ return typeof r.id === "string"
38
+ && typeof r.command === "string"
39
+ && typeof r.cwd === "string"
40
+ && typeof r.pid === "number"
41
+ && typeof r.startedAt === "string"
42
+ && typeof r.status === "string"
43
+ && (r.status === "running" || r.status === "exited" || r.status === "killed");
44
+ }
45
+ export class BackgroundTaskManager {
46
+ persistPath;
47
+ defaultCwd;
48
+ runtime = new Map();
49
+ records = [];
50
+ loaded = false;
51
+ sessionToken = randomUUID();
52
+ constructor(persistPath, defaultCwd) {
53
+ this.persistPath = persistPath;
54
+ this.defaultCwd = defaultCwd;
55
+ }
56
+ async ensureLoaded() {
57
+ if (this.loaded)
58
+ return;
59
+ this.loaded = true;
60
+ try {
61
+ const raw = await readFile(this.persistPath, "utf8");
62
+ const parsed = JSON.parse(raw);
63
+ if (Array.isArray(parsed)) {
64
+ this.records = parsed.filter(isValidRecord);
65
+ }
66
+ }
67
+ catch {
68
+ this.records = [];
69
+ }
70
+ this.reconcileStaleTasks();
71
+ }
72
+ /** Mark persisted "running" tasks as exited when no live process tracks them. */
73
+ reconcileStaleTasks() {
74
+ let changed = false;
75
+ for (const rec of this.records) {
76
+ if (rec.status !== "running")
77
+ continue;
78
+ if (this.runtime.has(rec.id))
79
+ continue;
80
+ if (rec.sessionToken && rec.sessionToken !== this.sessionToken) {
81
+ rec.status = "exited";
82
+ rec.exitCode ??= null;
83
+ changed = true;
84
+ continue;
85
+ }
86
+ if (rec.pid > 0 && isProcessRunning(rec.pid))
87
+ continue;
88
+ rec.status = "exited";
89
+ rec.exitCode ??= null;
90
+ changed = true;
91
+ }
92
+ if (changed)
93
+ void this.persist();
94
+ }
95
+ async persist() {
96
+ await mkdir(dirname(this.persistPath), { recursive: true });
97
+ await writeFile(this.persistPath, JSON.stringify(this.records, null, 2) + "\n");
98
+ }
99
+ syncRecord(task) {
100
+ const idx = this.records.findIndex((r) => r.id === task.record.id);
101
+ task.record.outputTail = tailText(task.output);
102
+ if (idx === -1)
103
+ this.records.push({ ...task.record });
104
+ else
105
+ this.records[idx] = { ...task.record };
106
+ }
107
+ async getTask(taskId) {
108
+ await this.ensureLoaded();
109
+ this.reconcileStaleTasks();
110
+ const live = this.runtime.get(taskId);
111
+ if (live)
112
+ return { ...live.record };
113
+ return this.records.find((r) => r.id === taskId);
114
+ }
115
+ async spawn(command, cwd) {
116
+ await this.ensureLoaded();
117
+ const id = nextTaskId(this.records);
118
+ const workDir = cwd ?? this.defaultCwd;
119
+ const proc = spawn(command, {
120
+ cwd: workDir,
121
+ shell: "/bin/bash",
122
+ env: process.env,
123
+ detached: false,
124
+ stdio: ["ignore", "pipe", "pipe"]
125
+ });
126
+ const record = {
127
+ id,
128
+ command,
129
+ cwd: workDir,
130
+ pid: proc.pid ?? 0,
131
+ startedAt: new Date().toISOString(),
132
+ status: "running",
133
+ exitCode: null,
134
+ outputTail: "",
135
+ sessionToken: this.sessionToken
136
+ };
137
+ const output = [];
138
+ let partial = "";
139
+ const append = (chunk) => {
140
+ const text = partial + chunk.toString();
141
+ const lines = text.split("\n");
142
+ partial = lines.pop() ?? "";
143
+ for (const line of lines) {
144
+ if (line.length > 0)
145
+ output.push(line);
146
+ }
147
+ while (output.length > MAX_OUTPUT_LINES)
148
+ output.shift();
149
+ const rt = this.runtime.get(id);
150
+ if (rt) {
151
+ rt.output = output;
152
+ rt.record.outputTail = tailText(output);
153
+ }
154
+ };
155
+ const flushPartial = () => {
156
+ if (partial.length > 0) {
157
+ output.push(partial);
158
+ partial = "";
159
+ while (output.length > MAX_OUTPUT_LINES)
160
+ output.shift();
161
+ }
162
+ };
163
+ proc.stdout?.on("data", append);
164
+ proc.stderr?.on("data", append);
165
+ const runtime = { record, proc, output };
166
+ this.runtime.set(id, runtime);
167
+ this.records.push({ ...record });
168
+ await this.persist();
169
+ proc.on("close", (code) => {
170
+ flushPartial();
171
+ if (runtime.killedByUser) {
172
+ record.status = "killed";
173
+ record.exitCode = record.exitCode ?? 143;
174
+ }
175
+ else {
176
+ record.status = "exited";
177
+ record.exitCode = code;
178
+ }
179
+ record.outputTail = tailText(output);
180
+ this.syncRecord(runtime);
181
+ void this.persist();
182
+ this.runtime.delete(id);
183
+ });
184
+ proc.on("error", (err) => {
185
+ flushPartial();
186
+ output.push(`[spawn error] ${err.message}`);
187
+ record.status = "exited";
188
+ record.exitCode = 1;
189
+ record.outputTail = tailText(output);
190
+ this.syncRecord(runtime);
191
+ void this.persist();
192
+ this.runtime.delete(id);
193
+ });
194
+ return { ...record };
195
+ }
196
+ async list() {
197
+ await this.ensureLoaded();
198
+ this.reconcileStaleTasks();
199
+ for (const rt of this.runtime.values()) {
200
+ rt.record.outputTail = tailText(rt.output);
201
+ this.syncRecord(rt);
202
+ }
203
+ return this.records.map((r) => {
204
+ const live = this.runtime.get(r.id);
205
+ return live ? { ...live.record } : { ...r };
206
+ });
207
+ }
208
+ async getOutput(taskId, tailLines = 50) {
209
+ await this.ensureLoaded();
210
+ this.reconcileStaleTasks();
211
+ const live = this.runtime.get(taskId);
212
+ if (live) {
213
+ const lines = live.output.slice(-tailLines);
214
+ return lines.length > 0 ? lines.join("\n") : "(no output yet)";
215
+ }
216
+ const rec = this.records.find((r) => r.id === taskId);
217
+ if (!rec)
218
+ return `Task "${taskId}" not found.`;
219
+ const lines = rec.outputTail.split("\n").slice(-tailLines);
220
+ return lines.length > 0 ? lines.join("\n") : "(no output)";
221
+ }
222
+ async kill(taskId) {
223
+ await this.ensureLoaded();
224
+ this.reconcileStaleTasks();
225
+ const live = this.runtime.get(taskId);
226
+ if (live) {
227
+ live.killedByUser = true;
228
+ live.proc.kill("SIGTERM");
229
+ live.record.status = "killed";
230
+ live.record.exitCode = live.record.exitCode ?? 143;
231
+ this.syncRecord(live);
232
+ await this.persist();
233
+ return `Killed ${taskId} (pid ${live.record.pid}).`;
234
+ }
235
+ const rec = this.records.find((r) => r.id === taskId);
236
+ if (!rec)
237
+ return `Task "${taskId}" not found.`;
238
+ if (rec.status !== "running")
239
+ return `${taskId} is already ${rec.status}.`;
240
+ if (rec.sessionToken !== this.sessionToken) {
241
+ rec.status = "exited";
242
+ await this.persist();
243
+ return `Task ${taskId} was started in a previous session and cannot be killed from here. Marked as exited.`;
244
+ }
245
+ rec.status = "exited";
246
+ rec.exitCode ??= null;
247
+ await this.persist();
248
+ return `Task ${taskId} is not running in this session.`;
249
+ }
250
+ async formatContextForAgent() {
251
+ const tasks = await this.list();
252
+ const active = tasks.filter((t) => t.status === "running");
253
+ if (active.length === 0)
254
+ return "";
255
+ const lines = active.map((t) => ` - ${t.id}: [running pid ${t.pid}] ${t.command} (cwd: ${t.cwd})`);
256
+ return `\nActive background tasks:\n${lines.join("\n")}\nUse bash with action "output" and taskId to read logs, or action "kill" to stop a task.\n`;
257
+ }
258
+ }
259
+ export function createBackgroundTaskManager(runPath, workspacePath) {
260
+ return new BackgroundTaskManager(join(runPath, "background-tasks.json"), workspacePath);
261
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isReadOnlyBashCommand } from "./agent-tools.js";
3
+ describe("isReadOnlyBashCommand", () => {
4
+ it("allows common read-only commands", () => {
5
+ expect(isReadOnlyBashCommand("ls -la")).toBe(true);
6
+ expect(isReadOnlyBashCommand("cat package.json")).toBe(true);
7
+ expect(isReadOnlyBashCommand("git status")).toBe(true);
8
+ expect(isReadOnlyBashCommand("git log --oneline -5")).toBe(true);
9
+ expect(isReadOnlyBashCommand("git branch")).toBe(false);
10
+ expect(isReadOnlyBashCommand("git remote -v")).toBe(false);
11
+ });
12
+ it("rejects chained or mutating commands", () => {
13
+ expect(isReadOnlyBashCommand("ls; rm -rf /")).toBe(false);
14
+ expect(isReadOnlyBashCommand("git commit -m x")).toBe(false);
15
+ expect(isReadOnlyBashCommand("npm install")).toBe(false);
16
+ });
17
+ it("rejects multiline chaining and destructive find", () => {
18
+ expect(isReadOnlyBashCommand("git status\nrm -rf .")).toBe(false);
19
+ expect(isReadOnlyBashCommand("git status\nnpm install")).toBe(false);
20
+ expect(isReadOnlyBashCommand("find . -delete")).toBe(false);
21
+ expect(isReadOnlyBashCommand("find . -exec rm {} +")).toBe(false);
22
+ expect(isReadOnlyBashCommand("find . -type f")).toBe(true);
23
+ });
24
+ it("rejects path traversal and absolute paths", () => {
25
+ expect(isReadOnlyBashCommand("cat ../secret")).toBe(false);
26
+ expect(isReadOnlyBashCommand("grep -r token ..")).toBe(false);
27
+ expect(isReadOnlyBashCommand("ls /")).toBe(false);
28
+ expect(isReadOnlyBashCommand("cat .//etc/passwd")).toBe(false);
29
+ expect(isReadOnlyBashCommand("grep -r secret .//etc")).toBe(false);
30
+ expect(isReadOnlyBashCommand("cat package.json")).toBe(true);
31
+ expect(isReadOnlyBashCommand("grep -rn foo .")).toBe(true);
32
+ });
33
+ it("rejects privileged flags on allowlisted binaries", () => {
34
+ expect(isReadOnlyBashCommand("git diff --extcmd=sh")).toBe(false);
35
+ expect(isReadOnlyBashCommand("rg --pre=bash -- foo .")).toBe(false);
36
+ expect(isReadOnlyBashCommand("git -c alias.status=!rm status")).toBe(false);
37
+ });
38
+ });
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { Files } from "files-sdk";
4
4
  import { fs } from "files-sdk/fs";
5
5
  import { logEvent } from "../storage/run-store.js";
6
+ import { PLAN_MODE_MSG } from "./agent-tools.js";
6
7
  const MAX_CONTENT = 8000;
7
8
  function truncate(text, max = MAX_CONTENT) {
8
9
  if (text.length <= max)
@@ -18,7 +19,7 @@ async function takeAsync(iter, max) {
18
19
  }
19
20
  return results;
20
21
  }
21
- export function createFileTools(runPath, config, onApprovalRequired) {
22
+ export function createFileTools(runPath, config, onApprovalRequired, getPlanMode) {
22
23
  const dir = config.files.dir;
23
24
  const files = new Files({ adapter: fs({ root: dir }) });
24
25
  async function gate(toolName, description) {
@@ -102,6 +103,8 @@ export function createFileTools(runPath, config, onApprovalRequired) {
102
103
  destination: z.string().describe("Target file key.")
103
104
  }),
104
105
  execute: async ({ source, destination }) => {
106
+ if (getPlanMode?.())
107
+ return PLAN_MODE_MSG;
105
108
  if (!await gate("moveFile", `Move file:\n ${source} → ${destination}`)) {
106
109
  return "Move rejected by user.";
107
110
  }
@@ -116,6 +119,8 @@ export function createFileTools(runPath, config, onApprovalRequired) {
116
119
  key: z.string().describe("File key to delete.")
117
120
  }),
118
121
  execute: async ({ key }) => {
122
+ if (getPlanMode?.())
123
+ return PLAN_MODE_MSG;
119
124
  if (!await gate("deleteFile", `Delete file: "${key}"`)) {
120
125
  return "Delete rejected by user.";
121
126
  }
@@ -131,23 +131,41 @@ const STRATEGIES = {
131
131
  exa: exaSearch,
132
132
  firecrawl: firecrawlSearch
133
133
  };
134
+ function formatSearchError(error) {
135
+ return error instanceof Error ? error.message : String(error);
136
+ }
134
137
  export async function multiSearchWeb(queries, perQuery, config) {
135
138
  const provider = config.search.provider;
136
139
  const strategy = STRATEGIES[provider];
137
140
  const settled = await Promise.allSettled(queries.map((q, i) => strategy(q, config, perQuery[i] ?? {})));
138
- const searches = await Promise.all(settled.map(async (res, i) => {
141
+ return Promise.all(settled.map(async (res, i) => {
142
+ let error;
143
+ if (res.status === "rejected") {
144
+ error = formatSearchError(res.reason);
145
+ }
139
146
  if (res.status === "fulfilled" && res.value.length > 0) {
140
147
  return { query: queries[i], results: res.value };
141
148
  }
142
- // per-query fallback to Firecrawl
143
149
  if (provider !== "firecrawl" && hasEnv("FIRECRAWL_API_KEY")) {
144
150
  try {
145
151
  const fallback = await firecrawlSearch(queries[i], config, perQuery[i] ?? {});
146
- return { query: queries[i], results: fallback };
152
+ if (fallback.length > 0) {
153
+ return { query: queries[i], results: fallback };
154
+ }
155
+ if (!error)
156
+ error = "primary search returned no results; firecrawl fallback also returned no results";
157
+ }
158
+ catch (fallbackError) {
159
+ const fallbackMsg = formatSearchError(fallbackError);
160
+ error = error ? `${error}; firecrawl fallback failed: ${fallbackMsg}` : `firecrawl fallback failed: ${fallbackMsg}`;
147
161
  }
148
- catch { /* ignore */ }
149
162
  }
150
- return { query: queries[i], results: [] };
163
+ else if (res.status === "rejected" && provider !== "firecrawl" && !hasEnv("FIRECRAWL_API_KEY")) {
164
+ error = `${error} (set FIRECRAWL_API_KEY for automatic fallback)`;
165
+ }
166
+ else if (res.status === "fulfilled" && res.value.length === 0) {
167
+ error = error ?? "search returned no results";
168
+ }
169
+ return { query: queries[i], results: [], ...(error ? { error } : {}) };
151
170
  }));
152
- return searches;
153
171
  }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { SciraConfigSchema } from "../types/index.js";
3
+ import { multiSearchWeb } from "./search-web.js";
4
+ const BASE_CONFIG = SciraConfigSchema.parse({});
5
+ describe("multiSearchWeb", () => {
6
+ it("reports provider errors instead of silently returning empty results", async () => {
7
+ const origExa = process.env.EXA_API_KEY;
8
+ const origFc = process.env.FIRECRAWL_API_KEY;
9
+ process.env.EXA_API_KEY = "invalid";
10
+ process.env.FIRECRAWL_API_KEY = "invalid";
11
+ const config = { ...BASE_CONFIG, search: { ...BASE_CONFIG.search, provider: "exa" } };
12
+ const results = await multiSearchWeb(["test query"], [{}], config);
13
+ expect(results[0]?.results).toEqual([]);
14
+ expect(results[0]?.error).toMatch(/invalid|unauthorized|api key/i);
15
+ if (origExa)
16
+ process.env.EXA_API_KEY = origExa;
17
+ else
18
+ delete process.env.EXA_API_KEY;
19
+ if (origFc)
20
+ process.env.FIRECRAWL_API_KEY = origFc;
21
+ else
22
+ delete process.env.FIRECRAWL_API_KEY;
23
+ });
24
+ });
@@ -0,0 +1,140 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { tool } from "ai";
4
+ import { z } from "zod";
5
+ import { logEvent } from "../storage/run-store.js";
6
+ const TodoStatusSchema = z.enum(["pending", "in_progress", "completed", "cancelled"]);
7
+ function nextTodoId(existing) {
8
+ const nums = existing
9
+ .map((t) => /^todo_(\d+)$/u.exec(t.id)?.[1])
10
+ .filter((n) => Boolean(n))
11
+ .map((n) => Number.parseInt(n, 10));
12
+ const next = nums.length > 0 ? Math.max(...nums) + 1 : 1;
13
+ return `todo_${String(next).padStart(3, "0")}`;
14
+ }
15
+ async function loadTodos(path) {
16
+ try {
17
+ const raw = await readFile(path, "utf8");
18
+ const parsed = JSON.parse(raw);
19
+ if (!Array.isArray(parsed))
20
+ return [];
21
+ return parsed.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string");
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ async function saveTodos(path, items) {
28
+ await mkdir(dirname(path), { recursive: true });
29
+ await writeFile(path, JSON.stringify(items, null, 2) + "\n");
30
+ }
31
+ function formatTodoList(items) {
32
+ if (items.length === 0)
33
+ return "No todos.";
34
+ const icon = {
35
+ pending: "[ ]",
36
+ in_progress: "[~]",
37
+ completed: "[x]",
38
+ cancelled: "[-]"
39
+ };
40
+ return items
41
+ .map((t) => `${icon[t.status]} ${t.id}: ${t.content} (${t.status})`)
42
+ .join("\n");
43
+ }
44
+ export function createTodoTool(runPath) {
45
+ const todosPath = join(runPath, "todos.json");
46
+ return tool({
47
+ description: "Manage structured task todos for the current session. " +
48
+ "Actions: create (add items), edit (change content), mark (set status), remove (delete one), rewrite (replace entire list), list (show all). " +
49
+ "Statuses: pending, in_progress, completed, cancelled.",
50
+ inputSchema: z.object({
51
+ action: z.enum(["create", "edit", "mark", "remove", "rewrite", "list"]),
52
+ id: z.string().optional().describe("Todo id for edit, mark, or remove."),
53
+ content: z.string().optional().describe("Todo text for create, edit, or rewrite items."),
54
+ status: TodoStatusSchema.optional().describe("Status for mark action or rewrite items."),
55
+ items: z
56
+ .array(z.object({
57
+ id: z.string().optional(),
58
+ content: z.string(),
59
+ status: TodoStatusSchema.optional()
60
+ }))
61
+ .optional()
62
+ .describe("Items for create or rewrite.")
63
+ }),
64
+ execute: async ({ action, id, content, status, items }) => {
65
+ const now = new Date().toISOString();
66
+ let todos = await loadTodos(todosPath);
67
+ switch (action) {
68
+ case "list":
69
+ return formatTodoList(todos);
70
+ case "create": {
71
+ const toAdd = items ?? (content ? [{ content, status: status ?? "pending" }] : []);
72
+ if (toAdd.length === 0)
73
+ return "create requires content or items.";
74
+ for (const item of toAdd) {
75
+ const todoId = item.id ?? nextTodoId(todos);
76
+ todos.push({
77
+ id: todoId,
78
+ content: item.content,
79
+ status: item.status ?? "pending",
80
+ createdAt: now,
81
+ updatedAt: now
82
+ });
83
+ }
84
+ await saveTodos(todosPath, todos);
85
+ await logEvent(runPath, "todo.created", { count: toAdd.length });
86
+ return `Created ${toAdd.length} todo(s).\n\n${formatTodoList(todos)}`;
87
+ }
88
+ case "edit": {
89
+ if (!id || !content)
90
+ return "edit requires id and content.";
91
+ const idx = todos.findIndex((t) => t.id === id);
92
+ if (idx === -1)
93
+ return `Todo "${id}" not found.`;
94
+ todos[idx] = { ...todos[idx], content, updatedAt: now };
95
+ await saveTodos(todosPath, todos);
96
+ await logEvent(runPath, "todo.edited", { id });
97
+ return `Updated ${id}.\n\n${formatTodoList(todos)}`;
98
+ }
99
+ case "mark": {
100
+ if (!id || !status)
101
+ return "mark requires id and status.";
102
+ const idx = todos.findIndex((t) => t.id === id);
103
+ if (idx === -1)
104
+ return `Todo "${id}" not found.`;
105
+ todos[idx] = { ...todos[idx], status, updatedAt: now };
106
+ await saveTodos(todosPath, todos);
107
+ await logEvent(runPath, "todo.marked", { id, status });
108
+ return `Marked ${id} as ${status}.\n\n${formatTodoList(todos)}`;
109
+ }
110
+ case "remove": {
111
+ if (!id)
112
+ return "remove requires id.";
113
+ const before = todos.length;
114
+ todos = todos.filter((t) => t.id !== id);
115
+ if (todos.length === before)
116
+ return `Todo "${id}" not found.`;
117
+ await saveTodos(todosPath, todos);
118
+ await logEvent(runPath, "todo.removed", { id });
119
+ return `Removed ${id}.\n\n${formatTodoList(todos)}`;
120
+ }
121
+ case "rewrite": {
122
+ if (!items || items.length === 0)
123
+ return "rewrite requires a non-empty items array.";
124
+ todos = items.map((item, i) => ({
125
+ id: item.id ?? `todo_${String(i + 1).padStart(3, "0")}`,
126
+ content: item.content,
127
+ status: item.status ?? "pending",
128
+ createdAt: now,
129
+ updatedAt: now
130
+ }));
131
+ await saveTodos(todosPath, todos);
132
+ await logEvent(runPath, "todo.rewritten", { count: todos.length });
133
+ return `Rewrote todo list (${todos.length} items).\n\n${formatTodoList(todos)}`;
134
+ }
135
+ default:
136
+ return `Unknown action: ${action}`;
137
+ }
138
+ }
139
+ });
140
+ }
@@ -0,0 +1,91 @@
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
+ /** Normalize a run-scoped path to its harness basename (strips run: and ./ prefixes). */
21
+ export function harnessBasename(displayPath) {
22
+ return displayPath.replace(/^run:/u, "").replace(/^\.\//u, "");
23
+ }
24
+ export function isRunArtifactPath(candidate) {
25
+ if (candidate.startsWith("run:"))
26
+ return true;
27
+ const normalized = candidate.replace(/\\/g, "/").replace(/^\.\//u, "");
28
+ if (normalized.startsWith("snapshots/") || normalized.startsWith("artifacts/"))
29
+ return true;
30
+ // Harness files are referenced by bare filename at the run root, not nested paths.
31
+ if (normalized.includes("/"))
32
+ return false;
33
+ return RUN_ARTIFACT_FILES.has(normalized);
34
+ }
35
+ /**
36
+ * Project root: parent of `.scira` when the run lives under `.scira/runs/…`,
37
+ * otherwise the current working directory (unless cwd is inside `.scira`).
38
+ */
39
+ export function resolveProjectRoot(runPath, cwd = process.cwd()) {
40
+ const fromRun = projectRootFromPath(runPath);
41
+ if (fromRun)
42
+ return fromRun;
43
+ const fromCwd = projectRootFromPath(resolve(cwd));
44
+ if (fromCwd)
45
+ return fromCwd;
46
+ return resolve(cwd);
47
+ }
48
+ function projectRootFromPath(absPath) {
49
+ const normalized = resolve(absPath).replace(/\\/g, "/");
50
+ const marker = "/.scira/";
51
+ const idx = normalized.indexOf(marker);
52
+ if (idx >= 0)
53
+ return normalized.slice(0, idx) || "/";
54
+ if (normalized.endsWith("/.scira"))
55
+ return normalized.slice(0, -"/.scira".length) || "/";
56
+ return undefined;
57
+ }
58
+ export function resolveToolPath(runPath, workspacePath, candidate) {
59
+ const raw = candidate.trim();
60
+ if (raw.startsWith("run:")) {
61
+ const inner = raw.slice(4);
62
+ const abs = resolveInsideRun(runPath, inner);
63
+ return { abs, displayPath: inner, scope: "run" };
64
+ }
65
+ if (workspacePath && !isRunArtifactPath(raw)) {
66
+ const abs = resolveInsideWorkspace(workspacePath, raw);
67
+ return { abs, displayPath: raw, scope: "workspace" };
68
+ }
69
+ const abs = resolveInsideRun(runPath, raw);
70
+ return { abs, displayPath: raw, scope: "run" };
71
+ }
72
+ export function resolveInsideRun(runPath, candidate) {
73
+ const abs = isAbsolute(candidate) ? candidate : resolve(runPath, candidate);
74
+ const rel = relative(runPath, abs);
75
+ if (rel.startsWith("..") || isAbsolute(rel)) {
76
+ throw new Error(`Path "${candidate}" is outside the run directory and is not allowed.`);
77
+ }
78
+ return abs;
79
+ }
80
+ export function resolveInsideWorkspace(workspacePath, candidate) {
81
+ const abs = isAbsolute(candidate) ? candidate : resolve(workspacePath, candidate);
82
+ const rel = relative(workspacePath, abs);
83
+ if (rel.startsWith("..") || isAbsolute(rel)) {
84
+ throw new Error(`Path "${candidate}" is outside the project workspace.`);
85
+ }
86
+ const norm = rel.replace(/\\/g, "/");
87
+ if (norm === ".scira" || norm.startsWith(".scira/")) {
88
+ 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.`);
89
+ }
90
+ return abs;
91
+ }