@splinterzzz/ouro 0.1.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.
@@ -0,0 +1,39 @@
1
+ import chalk from "chalk";
2
+ import { SERVICES, stopService } from "../lib/daemon.js";
3
+
4
+ // Stops the listener first, then the dashboard: the bot posts tickets to the
5
+ // dashboard's API, so killing the dashboard first would leave the bot briefly
6
+ // answering people with "couldn't reach the ouro dashboard".
7
+ const ORDER = ["listen", "dashboard"];
8
+
9
+ export async function stopCommand() {
10
+ const results = [];
11
+ for (const name of ORDER) {
12
+ results.push(await stopService(name));
13
+ }
14
+
15
+ for (const r of results) {
16
+ if (r.stopped) {
17
+ console.log(chalk.green("✔ stopped ") + chalk.gray(`${r.name} (pid ${r.pid})${r.forced ? " — forced" : ""}`));
18
+ } else if (r.reason === "not running") {
19
+ console.log(chalk.gray(`• ${r.name} wasn't running`));
20
+ } else {
21
+ console.log(chalk.yellow(`! ${r.name}: ${r.reason}`));
22
+ }
23
+ }
24
+
25
+ const stuck = results.filter((r) => !r.stopped && r.reason === "refused to die");
26
+ if (stuck.length) {
27
+ console.log("");
28
+ console.log(chalk.red("Some processes wouldn't stop. Kill them by pid manually:"));
29
+ for (const r of stuck) console.log(chalk.gray(` ${r.name}: pid ${r.pid}`));
30
+ process.exitCode = 1;
31
+ return;
32
+ }
33
+
34
+ if (results.every((r) => r.reason === "not running")) return;
35
+ console.log("");
36
+ console.log(chalk.gray("ouro is stopped. Any in-flight agent runs were killed with it."));
37
+ }
38
+
39
+ export { SERVICES };
package/src/index.js ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import { loadEnvFile } from "./lib/env.js";
5
+ import { initCommand } from "./commands/init.js";
6
+ import { dashboardCommand } from "./commands/dashboard.js";
7
+ import { listenCommand } from "./commands/listen.js";
8
+ import { startCommand } from "./commands/start.js";
9
+ import { stopCommand } from "./commands/stop.js";
10
+ import { statusCommand } from "./commands/status.js";
11
+ import { logsCommand } from "./commands/logs.js";
12
+
13
+ // Before any command reads a token: the detached daemon has no shell to
14
+ // inherit exports from, so `.ouro/.env` is what survives a closed terminal or
15
+ // a reboot. A real export still wins over the file. See lib/env.js.
16
+ loadEnvFile();
17
+
18
+ const program = new Command();
19
+
20
+ program
21
+ .name("ouro")
22
+ .description(
23
+ "Loop engineering CLI — roots into your repo, gives you a kanban board\n" +
24
+ "and agents that run on your existing Claude Code / Codex subscription.\n" +
25
+ "No API key required."
26
+ )
27
+ .version("0.1.0");
28
+
29
+ program
30
+ .command("init")
31
+ .description("Configure the current repo for ouro (creates .ouro/ config + ticket store)")
32
+ .option("--backend <backend>", "claude-code | codex", "claude-code")
33
+ .action(initCommand);
34
+
35
+ program
36
+ .command("start")
37
+ .description("Start the dashboard + Telegram intake agent in the background (survives closing the terminal)")
38
+ .option("-p, --port <port>", "port to run the dashboard server on", "4747")
39
+ .option("--no-listen", "start only the dashboard, without the Telegram intake agent")
40
+ .action(startCommand);
41
+
42
+ program.command("stop").description("Stop the background services and any agent runs they own").action(stopCommand);
43
+
44
+ program
45
+ .command("restart")
46
+ .description("Stop, then start, the background services")
47
+ .option("-p, --port <port>", "port to run the dashboard server on", "4747")
48
+ .option("--no-listen", "start only the dashboard, without the Telegram intake agent")
49
+ .action(async (opts) => {
50
+ await stopCommand();
51
+ console.log("");
52
+ await startCommand(opts);
53
+ });
54
+
55
+ program.command("status").description("Show what's running in the background").action(statusCommand);
56
+
57
+ program
58
+ .command("logs [service]")
59
+ .description("Show background service logs (dashboard | listen)")
60
+ .option("-f, --follow", "keep printing new output as it arrives")
61
+ .option("-n, --lines <n>", "how many lines of history to show", "40")
62
+ .action(logsCommand);
63
+
64
+ // The two foreground commands `start` supervises. Run them directly to watch a
65
+ // service in this terminal — useful when debugging one that won't stay up.
66
+ program
67
+ .command("dashboard")
68
+ .description("Run the dashboard in the foreground (see also: ouro start)")
69
+ .option("-p, --port <port>", "port to run the dashboard server on", "4747")
70
+ .option("--no-open", "don't auto-open the browser")
71
+ .action(dashboardCommand);
72
+
73
+ program
74
+ .command("listen")
75
+ .description("Run the Telegram intake agent in the foreground (see also: ouro start)")
76
+ .action(listenCommand);
77
+
78
+ program.parseAsync(process.argv).catch((err) => {
79
+ console.error(chalk.red("ouro: fatal error"), err);
80
+ process.exit(1);
81
+ });
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs";
2
+ import { configPath } from "./paths.js";
3
+ import * as codex from "./codexExec.js";
4
+ import * as claudeCode from "./claudeCodeExec.js";
5
+
6
+ const BACKENDS = {
7
+ codex,
8
+ "claude-code": claudeCode,
9
+ };
10
+
11
+ export function getBackendName() {
12
+ try {
13
+ const config = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
14
+ return BACKENDS[config.backend] ? config.backend : "claude-code";
15
+ } catch {
16
+ return "claude-code";
17
+ }
18
+ }
19
+
20
+ export function setBackendName(name) {
21
+ if (!BACKENDS[name]) throw new Error(`Unknown backend: ${name}`);
22
+ const config = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
23
+ config.backend = name;
24
+ fs.writeFileSync(configPath(), JSON.stringify(config, null, 2));
25
+ return name;
26
+ }
27
+
28
+ function backend() {
29
+ return BACKENDS[getBackendName()];
30
+ }
31
+
32
+ // Unified interface — every route calls through here instead of importing
33
+ // codexExec/claudeCodeExec directly, so switching backends is a one-line
34
+ // config change picked up on the next call.
35
+ export const triage = (args) => backend().triage(args);
36
+ export const runAgent = (args) => backend().runAgent(args);
37
+ export const planTicket = (args) => backend().planTicket(args);
38
+ export const executeTicket = (args) => backend().executeTicket(args);
39
+ // Read-only, JSON-in/JSON-out. Backs the Telegram intake interview, which
40
+ // needs a cheap per-turn decision rather than a full agent run.
41
+ export const askJson = (args) => backend().askJson(args);
@@ -0,0 +1,251 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { EventEmitter } from "node:events";
4
+ import { agentsDir, ensureOuroDir } from "./paths.js";
5
+ import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
6
+
7
+ // Agents are plain markdown on disk — `.ouro/agents/<id>.md` — so they're
8
+ // diffable, reviewable in a PR, and editable in your editor without ouro
9
+ // running. The dashboard is just one writer among several; every read goes
10
+ // back to the filesystem rather than an in-memory cache, so a file edited
11
+ // behind ouro's back shows up on the next request instead of being clobbered.
12
+
13
+ export const TOOL_UNIVERSE = ["Read", "Grep", "Glob", "Edit", "Write", "Bash", "WebFetch", "WebSearch"];
14
+
15
+ // Tools that can mutate the worktree or reach the network. Surfaced with a
16
+ // warning affordance in the UI so granting them is a deliberate act.
17
+ export const DANGER_TOOLS = new Set(["Edit", "Write", "Bash", "WebFetch", "WebSearch"]);
18
+
19
+ const DEFAULT_MODEL = "sonnet";
20
+ const DEFAULT_GLYPH = "◆";
21
+ const DEFAULT_TOOLS = ["Read", "Grep", "Glob", "Edit", "Write", "Bash"];
22
+
23
+ export const agentEvents = new EventEmitter();
24
+
25
+ /** `Senior Engineer` -> `senior-engineer`. Also the filename stem. */
26
+ export function slugify(name) {
27
+ const slug = String(name ?? "")
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-+|-+$/g, "")
31
+ .slice(0, 48);
32
+ return slug || "agent";
33
+ }
34
+
35
+ function agentPath(id) {
36
+ return path.join(agentsDir(), `${id}.md`);
37
+ }
38
+
39
+ /** Guards against `id` escaping .ouro/agents via `../` or an absolute path. */
40
+ function assertSafeId(id) {
41
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(String(id ?? ""))) {
42
+ throw new Error(`Invalid agent id: ${id}`);
43
+ }
44
+ }
45
+
46
+ function toAgent(id, text) {
47
+ const { data, body } = parseFrontmatter(text);
48
+ const tools = Array.isArray(data.tools)
49
+ ? data.tools
50
+ : typeof data.tools === "string" && data.tools.trim()
51
+ ? data.tools.split(/[,\s]+/).filter(Boolean)
52
+ : DEFAULT_TOOLS;
53
+
54
+ return {
55
+ id,
56
+ name: data.name || id,
57
+ glyph: data.glyph || DEFAULT_GLYPH,
58
+ description: data.description || "",
59
+ model: data.model || DEFAULT_MODEL,
60
+ tools,
61
+ systemPrompt: body,
62
+ };
63
+ }
64
+
65
+ export function listAgents() {
66
+ ensureOuroDir();
67
+ let files = [];
68
+ try {
69
+ files = fs.readdirSync(agentsDir()).filter((f) => f.endsWith(".md"));
70
+ } catch {
71
+ return [];
72
+ }
73
+
74
+ return files
75
+ .map((file) => {
76
+ const id = file.replace(/\.md$/, "");
77
+ try {
78
+ return toAgent(id, fs.readFileSync(path.join(agentsDir(), file), "utf-8"));
79
+ } catch {
80
+ return null; // an unreadable file shouldn't blank the whole list
81
+ }
82
+ })
83
+ .filter(Boolean)
84
+ .sort((a, b) => a.name.localeCompare(b.name));
85
+ }
86
+
87
+ export function getAgent(id) {
88
+ assertSafeId(id);
89
+ try {
90
+ return toAgent(id, fs.readFileSync(agentPath(id), "utf-8"));
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** Raw file text — backs the dashboard's "edit as .md" mode. */
97
+ export function getAgentRaw(id) {
98
+ assertSafeId(id);
99
+ try {
100
+ return fs.readFileSync(agentPath(id), "utf-8");
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function write(id, text) {
107
+ ensureOuroDir();
108
+ fs.writeFileSync(agentPath(id), text);
109
+ const agent = getAgent(id);
110
+ agentEvents.emit("change", { type: "agent", agent, id });
111
+ return agent;
112
+ }
113
+
114
+ /** Writes hand-authored markdown through verbatim, after a parse sanity check. */
115
+ export function saveAgentRaw(id, text) {
116
+ assertSafeId(id);
117
+ parseFrontmatter(text); // throws only on truly broken input; keeps bad files out
118
+ return write(id, text);
119
+ }
120
+
121
+ export function saveAgent(id, patch) {
122
+ assertSafeId(id);
123
+ const current = getAgent(id);
124
+ if (!current) return null;
125
+
126
+ const next = { ...current, ...patch };
127
+ return write(
128
+ id,
129
+ stringifyFrontmatter(
130
+ {
131
+ name: next.name,
132
+ glyph: next.glyph,
133
+ description: next.description,
134
+ model: next.model,
135
+ tools: next.tools,
136
+ },
137
+ next.systemPrompt
138
+ )
139
+ );
140
+ }
141
+
142
+ export function createAgent({ name }) {
143
+ ensureOuroDir();
144
+ const base = slugify(name);
145
+
146
+ // Never overwrite an existing agent — suffix until the name is free.
147
+ let id = base;
148
+ for (let i = 2; fs.existsSync(agentPath(id)); i++) id = `${base}-${i}`;
149
+
150
+ return write(
151
+ id,
152
+ stringifyFrontmatter(
153
+ {
154
+ name: name || id,
155
+ glyph: DEFAULT_GLYPH,
156
+ description: "",
157
+ model: DEFAULT_MODEL,
158
+ tools: DEFAULT_TOOLS,
159
+ },
160
+ `You are ${name || id}. Describe how this agent should work here — this body is sent to the model as its system prompt.`
161
+ )
162
+ );
163
+ }
164
+
165
+ export function deleteAgent(id) {
166
+ assertSafeId(id);
167
+ try {
168
+ fs.unlinkSync(agentPath(id));
169
+ agentEvents.emit("change", { type: "agent-deleted", id });
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ // Shipped defaults. Written only when `.ouro/agents/` has no files at all, so
177
+ // `ouro init` in an existing repo never resurrects an agent you deleted.
178
+ const SEEDS = [
179
+ {
180
+ id: "senior-engineer",
181
+ data: {
182
+ name: "Senior Engineer",
183
+ glyph: "◆",
184
+ description: "Ships production changes with minimal, well-tested diffs.",
185
+ model: DEFAULT_MODEL,
186
+ tools: ["Read", "Grep", "Glob", "Edit", "Write", "Bash"],
187
+ },
188
+ body: `You are a senior engineer working in an isolated git worktree.
189
+
190
+ Work to these standards:
191
+ - Read the surrounding code before you edit it. Match its idiom, naming, and comment density.
192
+ - Prefer the smallest diff that fully solves the ticket. No drive-by refactors.
193
+ - Never delete or weaken a test to make something pass.
194
+ - Run the relevant tests before you call the work done, and report what you ran.
195
+ - If the ticket is ambiguous, state the assumption you made in your final message rather than guessing silently.`,
196
+ },
197
+ {
198
+ id: "bug-fixer",
199
+ data: {
200
+ name: "Bug Fixer",
201
+ glyph: "▲",
202
+ description: "Reproduces first, then fixes the root cause — not the symptom.",
203
+ model: DEFAULT_MODEL,
204
+ tools: ["Read", "Grep", "Glob", "Edit", "Write", "Bash"],
205
+ },
206
+ body: `You are a debugging specialist working in an isolated git worktree.
207
+
208
+ Method, in order:
209
+ 1. Reproduce the bug and state the exact failing behaviour you observed.
210
+ 2. Find the root cause. Trace it — do not pattern-match a plausible-looking fix.
211
+ 3. Fix the cause, not the symptom. If the real fix is out of scope, say so explicitly.
212
+ 4. Add or extend a test that fails before your change and passes after it.
213
+ 5. Report the reproduction, the cause, and the fix separately in your final message.`,
214
+ },
215
+ {
216
+ id: "reviewer",
217
+ data: {
218
+ name: "Reviewer",
219
+ glyph: "○",
220
+ description: "Read-only. Audits a diff for correctness and risk.",
221
+ model: DEFAULT_MODEL,
222
+ tools: ["Read", "Grep", "Glob"],
223
+ },
224
+ body: `You are a code reviewer. You have read-only tools — you cannot edit, and should not try.
225
+
226
+ Review for, in priority order:
227
+ 1. Correctness bugs that would fail at runtime.
228
+ 2. Missing or weakened test coverage.
229
+ 3. Unnecessary complexity that a simpler construct would cover.
230
+
231
+ For each finding give: the file and line, what breaks, and the concrete input or state that triggers it. Skip anything you cannot substantiate — a speculative finding is worse than no finding.`,
232
+ },
233
+ ];
234
+
235
+ export function seedDefaultAgents() {
236
+ ensureOuroDir();
237
+ const existing = fs.existsSync(agentsDir()) ? fs.readdirSync(agentsDir()).filter((f) => f.endsWith(".md")) : [];
238
+ if (existing.length > 0) return 0;
239
+
240
+ for (const seed of SEEDS) {
241
+ fs.writeFileSync(agentPath(seed.id), stringifyFrontmatter(seed.data, seed.body));
242
+ }
243
+ return SEEDS.length;
244
+ }
245
+
246
+ /** The agent a ticket runs as when it has no explicit assignment. */
247
+ export function defaultAgentId() {
248
+ const all = listAgents();
249
+ if (all.length === 0) return null;
250
+ return (all.find((a) => a.id === "senior-engineer") ?? all[0]).id;
251
+ }
@@ -0,0 +1,213 @@
1
+ import { spawn } from "node:child_process";
2
+ import readline from "node:readline";
3
+
4
+ /**
5
+ * NOTE, same caveat as codexExec.js: field names in Claude Code's
6
+ * stream-json events (`type`, `subtype`, `session_id`, `result`) are based
7
+ * on public docs, not verified against a live run in this environment.
8
+ * Run `claude -p "say hello" --output-format stream-json --verbose` once and
9
+ * diff against `parseLine()`/the close handler below before relying on this.
10
+ */
11
+
12
+ const CLAUDE_BIN = process.env.OURO_CLAUDE_BIN || "claude";
13
+
14
+ const READ_ONLY_TOOLS = ["Read", "Grep", "Glob"];
15
+ const DEFAULT_WRITE_TOOLS = ["Read", "Edit", "Write", "Bash", "Grep", "Glob"];
16
+
17
+ function parseLine(line) {
18
+ try {
19
+ return JSON.parse(line);
20
+ } catch {
21
+ return { type: "raw", text: line };
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Turns an agent (from `.ouro/agents/*.md`) into CLI flags. `restrictTo`
27
+ * intersects the agent's granted tools with what the phase permits, so a
28
+ * plan phase stays read-only even if the agent is granted Write — the agent
29
+ * file widens what's possible, never what a read-only phase allows.
30
+ */
31
+ function agentFlags(agent, restrictTo) {
32
+ if (!agent) {
33
+ return restrictTo ? ["--allowedTools", restrictTo.join(",")] : [];
34
+ }
35
+
36
+ const granted = agent.tools?.length ? agent.tools : DEFAULT_WRITE_TOOLS;
37
+ const allowed = restrictTo ? granted.filter((t) => restrictTo.includes(t)) : granted;
38
+
39
+ const flags = [];
40
+ if (allowed.length) flags.push("--allowedTools", allowed.join(","));
41
+ if (agent.model) flags.push("--model", agent.model);
42
+ // Append rather than replace: Claude Code's own system prompt carries the
43
+ // tool contract, so blowing it away with --system-prompt breaks tool use.
44
+ if (agent.systemPrompt?.trim()) flags.push("--append-system-prompt", agent.systemPrompt.trim());
45
+ return flags;
46
+ }
47
+
48
+ function runClaude(args, { cwd, onEvent, signal } = {}) {
49
+ return new Promise((resolve, reject) => {
50
+ // `signal` wires cancellation straight to the child: aborting SIGTERMs the
51
+ // CLI rather than leaving it running detached. See lib/runs.js.
52
+ const proc = spawn(CLAUDE_BIN, args, { cwd, env: process.env, signal });
53
+
54
+ const rl = readline.createInterface({ input: proc.stdout });
55
+ let sessionId = null;
56
+ let lastMessage = null;
57
+
58
+ rl.on("line", (line) => {
59
+ if (!line.trim()) return;
60
+ const event = parseLine(line);
61
+ if (event.session_id) sessionId = event.session_id;
62
+ if (event.type === "result") {
63
+ lastMessage = event.result ?? lastMessage;
64
+ }
65
+ onEvent?.(event);
66
+ });
67
+
68
+ let stderrBuf = "";
69
+ proc.stderr.on("data", (chunk) => {
70
+ stderrBuf += chunk.toString();
71
+ onEvent?.({ type: "stderr", text: chunk.toString() });
72
+ });
73
+
74
+ proc.on("error", (err) => {
75
+ // An abort surfaces here as ABORT_ERR — that's an expected cancel, not a
76
+ // crash, so it resolves rather than rejecting into the route's catch.
77
+ if (err.name === "AbortError" || signal?.aborted) {
78
+ resolve({ code: null, sessionId, lastMessage, stderr: stderrBuf, aborted: true });
79
+ return;
80
+ }
81
+ reject(err);
82
+ });
83
+
84
+ proc.on("close", (code) => {
85
+ resolve({ code, sessionId, lastMessage, stderr: stderrBuf, aborted: Boolean(signal?.aborted) });
86
+ });
87
+ });
88
+ }
89
+
90
+ /** Strips ``` fences a model sometimes wraps JSON in, then parses. */
91
+ function parseJsonish(text) {
92
+ const cleaned = String(text ?? "").replace(/```json|```/g, "").trim();
93
+ try {
94
+ return JSON.parse(cleaned);
95
+ } catch {
96
+ // Fall back to the outermost {...} in case prose leaked in around it.
97
+ const start = cleaned.indexOf("{");
98
+ const end = cleaned.lastIndexOf("}");
99
+ if (start !== -1 && end > start) {
100
+ try {
101
+ return JSON.parse(cleaned.slice(start, end + 1));
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * One-shot, read-only, JSON-in/JSON-out call. Backs the Telegram intake
112
+ * interview (lib/intake.js), which needs a cheap turn-by-turn decision and no
113
+ * repo writes. Returns null when the model didn't produce parseable JSON, so
114
+ * callers decide the fallback rather than getting a half-object.
115
+ */
116
+ export async function askJson({ prompt, cwd, signal }) {
117
+ const { lastMessage } = await runClaude(
118
+ ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", READ_ONLY_TOOLS.join(",")],
119
+ { cwd, signal }
120
+ );
121
+ return parseJsonish(lastMessage);
122
+ }
123
+
124
+ /**
125
+ * Triage: read-only tools only, no file writes. Claude Code doesn't have a
126
+ * sandbox flag like Codex — read-only-ness comes from restricting
127
+ * --allowedTools instead.
128
+ */
129
+ export async function triage({ prompt, cwd, signal }) {
130
+ const { lastMessage } = await runClaude(
131
+ [
132
+ "-p",
133
+ `${prompt}\n\nRespond with ONLY a JSON object: {"summary": string, "priority": "low"|"medium"|"high", "files_likely_affected": string[]}. No prose, no markdown fences.`,
134
+ "--output-format",
135
+ "stream-json",
136
+ "--verbose",
137
+ "--allowedTools",
138
+ READ_ONLY_TOOLS.join(","),
139
+ ],
140
+ { cwd, signal }
141
+ );
142
+
143
+ return (
144
+ parseJsonish(lastMessage) ?? {
145
+ summary: lastMessage ?? "(no summary returned)",
146
+ priority: "medium",
147
+ files_likely_affected: [],
148
+ }
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Agent mode: full autonomy, write tools allowed, no interactive approval.
154
+ * Tools/model/system prompt come from the ticket's assigned agent .md.
155
+ */
156
+ export function runAgent({ prompt, cwd, onEvent, signal, agent }) {
157
+ return runClaude(
158
+ [
159
+ "-p",
160
+ prompt,
161
+ "--output-format",
162
+ "stream-json",
163
+ "--verbose",
164
+ ...agentFlags(agent, null),
165
+ ...(agent ? [] : ["--allowedTools", DEFAULT_WRITE_TOOLS.join(",")]),
166
+ "--permission-mode",
167
+ "bypassPermissions",
168
+ ],
169
+ { cwd, onEvent, signal }
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Human-in-the-loop, phase 1: plan only, read-only tools, no edits. Returns
175
+ * the session_id so the UI can resume it once the person approves.
176
+ */
177
+ export function planTicket({ prompt, cwd, onEvent, signal, agent }) {
178
+ return runClaude(
179
+ [
180
+ "-p",
181
+ `${prompt}\n\nDo NOT edit any files yet. First produce a short numbered plan of what you'd change and why.`,
182
+ "--output-format",
183
+ "stream-json",
184
+ "--verbose",
185
+ ...agentFlags(agent, READ_ONLY_TOOLS),
186
+ ...(agent ? [] : ["--allowedTools", READ_ONLY_TOOLS.join(",")]),
187
+ ],
188
+ { cwd, onEvent, signal }
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Human-in-the-loop, phase 2: resume the planning session with write tools
194
+ * enabled, after the person clicks Approve.
195
+ */
196
+ export function executeTicket({ cwd, sessionId, onEvent, signal, agent }) {
197
+ return runClaude(
198
+ [
199
+ "--resume",
200
+ sessionId,
201
+ "-p",
202
+ "Approved. Implement the plan now and run relevant tests.",
203
+ "--output-format",
204
+ "stream-json",
205
+ "--verbose",
206
+ ...agentFlags(agent, null),
207
+ ...(agent ? [] : ["--allowedTools", DEFAULT_WRITE_TOOLS.join(",")]),
208
+ "--permission-mode",
209
+ "acceptEdits",
210
+ ],
211
+ { cwd, onEvent, signal }
212
+ );
213
+ }