@splinterzzz/ouro 0.1.1 → 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.
@@ -34,10 +34,16 @@ function agentFlags(agent, restrictTo) {
34
34
  }
35
35
 
36
36
  const granted = agent.tools?.length ? agent.tools : DEFAULT_WRITE_TOOLS;
37
+ // "*" means fully unrestricted — every native Claude Code tool (including
38
+ // Task, for subagent fan-out) plus any project skills and connected
39
+ // MCP/plugin tools, none of which ouro's fixed TOOL_UNIVERSE enumerates.
40
+ // Only honored outside a read-only phase — Analyze/Plan/QA must never
41
+ // widen past restrictTo no matter what the agent itself requests.
42
+ const unrestricted = granted.includes("*") && !restrictTo;
37
43
  const allowed = restrictTo ? granted.filter((t) => restrictTo.includes(t)) : granted;
38
44
 
39
45
  const flags = [];
40
- if (allowed.length) flags.push("--allowedTools", allowed.join(","));
46
+ if (!unrestricted && allowed.length) flags.push("--allowedTools", allowed.join(","));
41
47
  if (agent.model) flags.push("--model", agent.model);
42
48
  // Append rather than replace: Claude Code's own system prompt carries the
43
49
  // tool contract, so blowing it away with --system-prompt breaks tool use.
@@ -122,22 +128,26 @@ export async function askJson({ prompt, cwd, signal }) {
122
128
  }
123
129
 
124
130
  /**
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.
131
+ * Analyze: a read-only agent pass (the Analyst agent) that scopes the ticket.
132
+ * Read-only-ness comes from restricting --allowedTools — Claude Code has no
133
+ * sandbox flag like Codex. The agent's tools are intersected with the read-only
134
+ * set, so even a mis-granted Write can't take effect here. Streams events to
135
+ * `onEvent` so the analysis is visible on the card like any other run, and
136
+ * returns structured findings — crucially the acceptance criteria that plan/
137
+ * execute and the QA gate are both held to.
128
138
  */
129
- export async function triage({ prompt, cwd, signal }) {
139
+ export async function analyze({ prompt, cwd, signal, agent, onEvent }) {
130
140
  const { lastMessage } = await runClaude(
131
141
  [
132
142
  "-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.`,
143
+ `${prompt}\n\nRespond with ONLY a JSON object: {"summary": string, "priority": "low"|"medium"|"high", "files_likely_affected": string[], "acceptance_criteria": string[]}. Each acceptance_criteria item is a concrete, checkable definition-of-done statement a test or reviewer could mark pass/fail. No prose, no markdown fences.`,
134
144
  "--output-format",
135
145
  "stream-json",
136
146
  "--verbose",
137
- "--allowedTools",
138
- READ_ONLY_TOOLS.join(","),
147
+ ...agentFlags(agent, READ_ONLY_TOOLS),
148
+ ...(agent ? [] : ["--allowedTools", READ_ONLY_TOOLS.join(",")]),
139
149
  ],
140
- { cwd, signal }
150
+ { cwd, signal, onEvent }
141
151
  );
142
152
 
143
153
  return (
@@ -145,6 +155,7 @@ export async function triage({ prompt, cwd, signal }) {
145
155
  summary: lastMessage ?? "(no summary returned)",
146
156
  priority: "medium",
147
157
  files_likely_affected: [],
158
+ acceptance_criteria: [],
148
159
  }
149
160
  );
150
161
  }
@@ -170,6 +181,46 @@ export function runAgent({ prompt, cwd, onEvent, signal, agent }) {
170
181
  );
171
182
  }
172
183
 
184
+ /**
185
+ * Staging QA gate: the Senior QA Engineer agent validates the running result.
186
+ *
187
+ * Deliberately READ-ONLY — Read/Grep/Glob, no Bash, no bypassPermissions. QA is
188
+ * a validator, not an implementer: ouro runs the tests and hands QA the results
189
+ * and the diff, so QA only needs to read the change, the source, and any built
190
+ * HTML. Denying Bash matters because ticket title/body are untrusted (Telegram
191
+ * intake is external), and an unrestricted Bash validator would turn a prompt-
192
+ * injected ticket into arbitrary command execution. Returns the parsed JSON
193
+ * verdict (null if unparseable).
194
+ */
195
+ export async function qaReview({ prompt, cwd, signal, onEvent, agent }) {
196
+ const { lastMessage } = await runClaude(
197
+ [
198
+ "-p",
199
+ prompt,
200
+ "--output-format",
201
+ "stream-json",
202
+ "--verbose",
203
+ ...agentFlags(agent, READ_ONLY_TOOLS),
204
+ ...(agent ? [] : ["--allowedTools", READ_ONLY_TOOLS.join(",")]),
205
+ ],
206
+ { cwd, signal, onEvent }
207
+ );
208
+ return parseJsonish(lastMessage);
209
+ }
210
+
211
+ /**
212
+ * Read-only run that returns the raw final message. Backs `ouro init --spec`
213
+ * (reverse-engineering a CLAUDE.md): the agent explores but never writes — ouro
214
+ * writes the file from what comes back, so "read-only" stays literally true.
215
+ */
216
+ export async function generateSpec({ prompt, cwd, signal, onEvent }) {
217
+ const { lastMessage } = await runClaude(
218
+ ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", READ_ONLY_TOOLS.join(",")],
219
+ { cwd, signal, onEvent }
220
+ );
221
+ return lastMessage;
222
+ }
223
+
173
224
  /**
174
225
  * Human-in-the-loop, phase 1: plan only, read-only tools, no edits. Returns
175
226
  * the session_id so the UI can resume it once the person approves.
@@ -16,14 +16,17 @@ import path from "node:path";
16
16
 
17
17
  const CODEX_BIN = process.env.OURO_CODEX_BIN || "codex";
18
18
 
19
- const TRIAGE_SCHEMA = {
19
+ const ANALYZE_SCHEMA = {
20
20
  type: "object",
21
21
  properties: {
22
22
  summary: { type: "string" },
23
23
  priority: { type: "string", enum: ["low", "medium", "high"] },
24
24
  files_likely_affected: { type: "array", items: { type: "string" } },
25
+ // Checkable definition-of-done items. Plan/execute implement against these
26
+ // and the QA gate validates against them, so they must be concrete.
27
+ acceptance_criteria: { type: "array", items: { type: "string" } },
25
28
  },
26
- required: ["summary", "priority", "files_likely_affected"],
29
+ required: ["summary", "priority", "files_likely_affected", "acceptance_criteria"],
27
30
  additionalProperties: false,
28
31
  };
29
32
 
@@ -124,18 +127,18 @@ export async function askJson({ prompt, cwd, signal }) {
124
127
  }
125
128
 
126
129
  /**
127
- * Triage: read-only, schema-constrained call. Cheap, safe, no repo writes.
130
+ * Analyze: read-only, schema-constrained call. Cheap, safe, no repo writes.
128
131
  */
129
- export async function triage({ prompt, cwd, signal }) {
132
+ export async function analyze({ prompt, cwd, signal, agent, onEvent }) {
130
133
  // os.tmpdir(), not a hardcoded /tmp — this has to work on Windows too. The
131
- // pid suffix keeps concurrent triages off one another's schema file.
132
- const schemaPath = path.join(os.tmpdir(), `ouro-triage-schema-${process.pid}.json`);
133
- fs.writeFileSync(schemaPath, JSON.stringify(TRIAGE_SCHEMA));
134
+ // pid suffix keeps concurrent analyses off one another's schema file.
135
+ const schemaPath = path.join(os.tmpdir(), `ouro-analyze-schema-${process.pid}.json`);
136
+ fs.writeFileSync(schemaPath, JSON.stringify(ANALYZE_SCHEMA));
134
137
 
135
138
  try {
136
139
  const { lastMessage } = await runCodexExec(
137
- ["exec", prompt, "--json", "--sandbox", "read-only", "--output-schema", schemaPath],
138
- { cwd, signal }
140
+ ["exec", prompt, "--json", "--sandbox", "read-only", "--output-schema", schemaPath, ...agentFlags(agent)],
141
+ { cwd, signal, onEvent }
139
142
  );
140
143
 
141
144
  return (
@@ -143,6 +146,7 @@ export async function triage({ prompt, cwd, signal }) {
143
146
  summary: lastMessage ?? "(no summary returned)",
144
147
  priority: "medium",
145
148
  files_likely_affected: [],
149
+ acceptance_criteria: [],
146
150
  }
147
151
  );
148
152
  } finally {
@@ -161,6 +165,34 @@ export function runAgent({ prompt, cwd, onEvent, signal, agent }) {
161
165
  );
162
166
  }
163
167
 
168
+ /**
169
+ * Staging QA gate on the Codex backend. Read-only sandbox — Codex has no
170
+ * per-tool grant, so read-only is how "validate, don't modify" is enforced (the
171
+ * tests were already run by ouro). Returns the parsed JSON verdict.
172
+ */
173
+ export async function qaReview({ prompt, cwd, signal, onEvent, agent }) {
174
+ const { lastMessage } = await runCodexExec(["exec", prompt, "--json", "--sandbox", "read-only", ...agentFlags(agent)], {
175
+ cwd,
176
+ signal,
177
+ onEvent,
178
+ });
179
+ return parseJsonish(lastMessage);
180
+ }
181
+
182
+ /**
183
+ * Read-only run that returns the raw final message. Backs `ouro init --spec` —
184
+ * the agent explores under a read-only sandbox and ouro writes the file, so
185
+ * "read-only" holds literally.
186
+ */
187
+ export async function generateSpec({ prompt, cwd, signal, onEvent }) {
188
+ const { lastMessage } = await runCodexExec(["exec", prompt, "--json", "--sandbox", "read-only"], {
189
+ cwd,
190
+ signal,
191
+ onEvent,
192
+ });
193
+ return lastMessage;
194
+ }
195
+
164
196
  /**
165
197
  * Human-in-the-loop, phase 1: plan only, read-only sandbox, no writes.
166
198
  * Non-interactive headless mode can't reliably pause mid-run for approval
package/src/lib/config.js CHANGED
@@ -20,6 +20,15 @@ const DEFAULTS = {
20
20
  botTokenEnvVar: "OURO_TELEGRAM_BOT_TOKEN",
21
21
  chatIdEnvVar: "OURO_TELEGRAM_CHAT_ID",
22
22
  },
23
+ // Staging stage (Feature 9). All null by default: ouro resolves the test and
24
+ // preview commands from the repo when these aren't set, so a fresh repo needs
25
+ // zero config. Set them to pin exact commands. previewPort is where the
26
+ // preview is expected to listen (used to build the clickable URL).
27
+ staging: {
28
+ testCommand: null,
29
+ previewCommand: null,
30
+ previewPort: null,
31
+ },
23
32
  };
24
33
 
25
34
  export function readConfig() {
@@ -85,6 +94,16 @@ export function getAutoShip() {
85
94
  return readConfig().autoShip !== false;
86
95
  }
87
96
 
97
+ /** Staging config, each field null when unset (ouro then resolves it per-repo). */
98
+ export function getStaging() {
99
+ const s = readConfig().staging ?? {};
100
+ return {
101
+ testCommand: s.testCommand ?? null,
102
+ previewCommand: s.previewCommand ?? null,
103
+ previewPort: s.previewPort ?? null,
104
+ };
105
+ }
106
+
88
107
  export function setAutoShip(on) {
89
108
  writeConfig({ autoShip: Boolean(on) });
90
109
  return Boolean(on);
@@ -0,0 +1,91 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { contextDir } from "./paths.js";
4
+
5
+ // ouro-log.md — a human-readable run history. Not a git-log clone: the skimmable
6
+ // summary for people who don't want to read git history.
7
+ //
8
+ // One entry per run, appended at run end regardless of outcome (shipped, failed,
9
+ // no-change, cancelled — all recorded). Templated from ticket state ouro already
10
+ // holds — NOT an extra LLM call. It lives in .ouro/context/, so it's both a
11
+ // viewable log AND an auto-attached artifact (cross-ticket memory), and it's
12
+ // committed (see .ouro/.gitignore) as team memory that travels with the repo.
13
+ //
14
+ // Populated post-`ouro init` only — there is no retrace of prior git history.
15
+
16
+ const LOG_FILENAME = "ouro-log.md";
17
+
18
+ const HEADER = `# ouro run log
19
+
20
+ Skimmable run history — one line per run, newest at the bottom of each day.
21
+ Templated from ticket state (not an LLM call) and committed as team memory.
22
+ `;
23
+
24
+ function logPath() {
25
+ return path.join(contextDir(), LOG_FILENAME);
26
+ }
27
+
28
+ export function logFilename() {
29
+ return LOG_FILENAME;
30
+ }
31
+
32
+ function modeLabel(mode) {
33
+ return mode === "agent" ? "agent loop" : "human-in-loop";
34
+ }
35
+
36
+ function prNumber(url) {
37
+ const m = String(url || "").match(/\/pull\/(\d+)/);
38
+ return m ? `#${m[1]}` : null;
39
+ }
40
+
41
+ // File count from the stored unified diff — one "diff --git" line per file. Kept
42
+ // here rather than re-shelling to git so the log never depends on the worktree
43
+ // still existing at write time.
44
+ function fileCount(diff) {
45
+ if (!diff) return 0;
46
+ return (diff.match(/^diff --git /gm) || []).length;
47
+ }
48
+
49
+ /**
50
+ * Append one entry for a finished run. `outcome` is the short result phrase;
51
+ * PR link and file count are derived from the ticket. Best-effort: a failure to
52
+ * write the log (e.g. .ouro/context/ absent pre-init) never fails the run.
53
+ */
54
+ export function appendRunLog(ticket, outcome, when = new Date()) {
55
+ if (!ticket) return;
56
+ try {
57
+ const dateStr = when.toISOString().slice(0, 10); // YYYY-MM-DD
58
+ const timeStr = when.toTimeString().slice(0, 5); // HH:MM (local)
59
+
60
+ const bits = [outcome];
61
+ const pr = prNumber(ticket.prUrl);
62
+ if (pr) bits.push(`PR ${pr}`);
63
+ const files = fileCount(ticket.diff);
64
+ if (files) bits.push(`${files} file${files === 1 ? "" : "s"}`);
65
+
66
+ const title = String(ticket.title ?? "").replace(/\s+/g, " ").trim() || "(untitled)";
67
+ const entry = `- **${timeStr}** · [#${ticket.id}] ${title} · ${modeLabel(ticket.mode)}\n → ${bits.join(" · ")}\n`;
68
+
69
+ let existing;
70
+ try {
71
+ existing = fs.readFileSync(logPath(), "utf-8");
72
+ } catch {
73
+ existing = HEADER; // first write seeds the header
74
+ }
75
+
76
+ // A fresh date header only when today isn't already present.
77
+ const needsDateHeader = !existing.includes(`\n## ${dateStr}\n`);
78
+ fs.writeFileSync(logPath(), existing + (needsDateHeader ? `\n## ${dateStr}\n` : "") + entry);
79
+ } catch {
80
+ // Logging is memory, not control flow — never let it break a run.
81
+ }
82
+ }
83
+
84
+ /** Raw markdown of the log, or "" if it doesn't exist yet. Backs the Logs tab. */
85
+ export function readRunLog() {
86
+ try {
87
+ return fs.readFileSync(logPath(), "utf-8");
88
+ } catch {
89
+ return "";
90
+ }
91
+ }
package/src/lib/paths.js CHANGED
@@ -29,6 +29,13 @@ export function agentsDir() {
29
29
  return path.join(ouroDir(), "agents");
30
30
  }
31
31
 
32
+ // Shared, committed context payload — the droppable artifacts every run can
33
+ // discover (manifest-injected, never content-dumped) plus the ouro-log.md run
34
+ // history. Committed via .ouro/.gitignore's `!context/`. See lib/artifacts.js.
35
+ export function contextDir() {
36
+ return path.join(ouroDir(), "context");
37
+ }
38
+
32
39
  // Daemon bookkeeping: one pid file and one log per background service.
33
40
  // See lib/daemon.js.
34
41
  export function runDir() {
@@ -47,7 +54,7 @@ export function envPath() {
47
54
  }
48
55
 
49
56
  export function ensureOuroDir() {
50
- for (const dir of [ouroDir(), worktreesDir(), agentsDir(), runDir(), logsDir()]) {
57
+ for (const dir of [ouroDir(), worktreesDir(), agentsDir(), contextDir(), runDir(), logsDir()]) {
51
58
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
52
59
  }
53
60
  }
@@ -0,0 +1,154 @@
1
+ import { spawn } from "node:child_process";
2
+ import net from "node:net";
3
+ import { getStaging } from "./config.js";
4
+ import { askJson } from "./agentBackend.js";
5
+
6
+ // Staging preview servers — launch, track, and stop the local deployment a
7
+ // ticket is reviewed against. Tracked per-ticket so it can be torn down when the
8
+ // ticket ships, loops back, or is cancelled.
9
+ //
10
+ // Everything here is best-effort: if no command resolves, or it won't start, or
11
+ // the port never opens, the stage proceeds tests-only. A preview problem must
12
+ // NEVER fail the stage (spec).
13
+
14
+ const previews = new Map(); // ticketId -> { proc, url, command, port }
15
+
16
+ /**
17
+ * Resolve how to start the preview: config.staging.previewCommand + previewPort
18
+ * if set, else a cheap read-only agent inference. Returns { command, port,
19
+ * source } with source "config" | "inferred" | "none".
20
+ */
21
+ export async function resolvePreview({ cwd, signal }) {
22
+ const s = getStaging();
23
+ if (s.previewCommand && s.previewPort) {
24
+ return { command: String(s.previewCommand), port: Number(s.previewPort), source: "config" };
25
+ }
26
+
27
+ const inferred = await askJson({
28
+ prompt:
29
+ "Infer how to start this project's local preview / dev server and the port it listens on, from its config (package.json scripts, framework config). " +
30
+ 'Respond with ONLY JSON: {"command": string|null, "port": number|null}. Use null if there is no runnable preview. No prose, no fences.',
31
+ cwd,
32
+ signal,
33
+ }).catch(() => null);
34
+
35
+ const command = typeof inferred?.command === "string" ? inferred.command.trim() : "";
36
+ if (!command) return { command: null, port: s.previewPort ? Number(s.previewPort) : null, source: "none" };
37
+ const port = Number.isInteger(inferred?.port) ? inferred.port : s.previewPort ? Number(s.previewPort) : null;
38
+ return { command, port, source: "inferred" };
39
+ }
40
+
41
+ // Kill the whole process tree. A `shell:true` child on Windows is cmd.exe with
42
+ // the real server underneath it — proc.kill() would leave the grandchild (and
43
+ // the port) alive, so use taskkill /T there.
44
+ function killTree(proc) {
45
+ if (!proc?.pid) return;
46
+ try {
47
+ if (process.platform === "win32") {
48
+ spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore" });
49
+ } else {
50
+ proc.kill("SIGTERM");
51
+ }
52
+ } catch {
53
+ /* already gone */
54
+ }
55
+ }
56
+
57
+ // Best-effort wait until something accepts a TCP connection on the port.
58
+ function waitForPort(port, timeoutMs = 15000) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ return new Promise((resolve) => {
61
+ const attempt = () => {
62
+ const sock = net.connect({ host: "127.0.0.1", port }, () => {
63
+ sock.destroy();
64
+ resolve(true);
65
+ });
66
+ sock.on("error", () => {
67
+ sock.destroy();
68
+ if (Date.now() > deadline) resolve(false);
69
+ else setTimeout(attempt, 400);
70
+ });
71
+ };
72
+ attempt();
73
+ });
74
+ }
75
+
76
+ // Most dev servers (Next, Vite, CRA, ...) print the URL they actually bound
77
+ // once they're up — which can differ from the guessed port if it was taken
78
+ // (Next silently increments to the next free one). Sniffing stdout for that
79
+ // line is the only way to know the real port rather than trusting the guess.
80
+ const URL_IN_OUTPUT = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::(\d+))?/i;
81
+
82
+ function sniffUrl(proc, timeoutMs) {
83
+ return new Promise((resolve) => {
84
+ let done = false;
85
+ const finish = (url) => {
86
+ if (done) return;
87
+ done = true;
88
+ proc.stdout?.off("data", onData);
89
+ resolve(url);
90
+ };
91
+ const onData = (chunk) => {
92
+ const match = URL_IN_OUTPUT.exec(chunk.toString());
93
+ if (match) finish(match[0].replace("0.0.0.0", "localhost"));
94
+ };
95
+ proc.stdout?.on("data", onData);
96
+ setTimeout(() => finish(null), timeoutMs);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Start (or restart) the preview for a ticket. Returns a status object; never
102
+ * throws. `started:false` means nothing resolved — the card shows "no preview"
103
+ * and the stage goes on. `reachable:false` means it launched but never came up
104
+ * (or came up on a port that never got confirmed) — callers must not surface
105
+ * `url` to the UI in that case, it points at nothing trustworthy.
106
+ */
107
+ export async function startPreview(ticketId, { cwd, signal } = {}) {
108
+ stopPreview(ticketId); // never two for one ticket
109
+
110
+ const { command, port: guessedPort, source } = await resolvePreview({ cwd, signal });
111
+ if (!command) return { started: false, reason: "no preview command resolved", source };
112
+
113
+ let proc;
114
+ try {
115
+ proc = spawn(command, { cwd, shell: true });
116
+ } catch (err) {
117
+ return { started: false, reason: `spawn failed: ${err.message || err}`, source };
118
+ }
119
+ proc.on("error", () => {}); // a preview that fails to launch must not crash the stage
120
+ proc.stderr?.on("data", () => {});
121
+
122
+ const timeoutMs = 15000;
123
+ const [sniffed, portOpen] = await Promise.all([
124
+ sniffUrl(proc, timeoutMs),
125
+ guessedPort ? waitForPort(guessedPort, timeoutMs) : Promise.resolve(false),
126
+ ]);
127
+ // stdout gave us the real bound URL — trust it over the pre-spawn guess,
128
+ // since that's the only way to catch Next.js et al. auto-incrementing off
129
+ // an already-occupied port.
130
+ const url = sniffed ?? (portOpen ? `http://localhost:${guessedPort}` : null);
131
+ const port = sniffed ? Number(URL_IN_OUTPUT.exec(sniffed)?.[1] ?? guessedPort) : guessedPort;
132
+ const reachable = Boolean(sniffed) || portOpen;
133
+
134
+ previews.set(ticketId, { proc, url, command, port });
135
+ return { started: true, url, command, port, source, reachable };
136
+ }
137
+
138
+ export function stopPreview(ticketId) {
139
+ const p = previews.get(ticketId);
140
+ if (!p) return false;
141
+ killTree(p.proc);
142
+ previews.delete(ticketId);
143
+ return true;
144
+ }
145
+
146
+ export function previewInfo(ticketId) {
147
+ const p = previews.get(ticketId);
148
+ return p ? { url: p.url, command: p.command, port: p.port } : null;
149
+ }
150
+
151
+ /** Tear down every preview — used when the dashboard process shuts down. */
152
+ export function stopAllPreviews() {
153
+ for (const id of [...previews.keys()]) stopPreview(id);
154
+ }
package/src/lib/ship.js CHANGED
@@ -4,13 +4,13 @@ import { hasGh, createPullRequest } from "./github.js";
4
4
 
5
5
  // Turning a finished run into a reviewable PR.
6
6
  //
7
- // Before this, a run ended in the Review column with a diff living in a local
7
+ // Before this, a run ended in the Staging column with a diff living in a local
8
8
  // worktree on an unpushed branch — real work in a place nobody else could see.
9
9
  // Shipping is the step that makes it a thing a human can actually review.
10
10
  //
11
11
  // Each stage logs to the ticket, so the terminal dock narrates the push and PR
12
12
  // the same way it narrates the agent's tool calls. Every failure mode leaves
13
- // the ticket in `review` with the work intact: committing and pushing are
13
+ // the ticket in `staging` with the work intact: committing and pushing are
14
14
  // worth keeping even when the PR itself can't be opened.
15
15
 
16
16
  function commitMessage(ticket) {
@@ -0,0 +1,97 @@
1
+ import { spawn } from "node:child_process";
2
+ import { getStaging } from "./config.js";
3
+ import { askJson } from "./agentBackend.js";
4
+
5
+ // Staging test execution. ouro runs the tests (deterministic capture) and the
6
+ // QA agent judges the result — so the test command has to be resolved and run
7
+ // here, not left to the agent's Bash tool.
8
+ //
9
+ // Resolution order: config.staging.testCommand if set, else a cheap read-only
10
+ // agent inference from the repo (package.json scripts, CLAUDE.md, etc.). If
11
+ // nothing resolves, the stage proceeds tests-only-absent — never a hard failure.
12
+
13
+ /**
14
+ * Resolve the test command. Returns { command, source } where source is
15
+ * "config" | "inferred" | "none".
16
+ */
17
+ export async function resolveTestCommand({ cwd, signal }) {
18
+ const configured = getStaging().testCommand;
19
+ if (configured) return { command: String(configured), source: "config" };
20
+
21
+ const inferred = await askJson({
22
+ prompt:
23
+ "Infer the single shell command that runs this project's automated tests, from its config (package.json scripts, Makefile, pyproject, etc.). " +
24
+ 'Respond with ONLY JSON: {"command": string|null}. Use null if the repo has no test setup. No prose, no fences.',
25
+ cwd,
26
+ signal,
27
+ }).catch(() => null);
28
+
29
+ const command = typeof inferred?.command === "string" ? inferred.command.trim() : "";
30
+ return { command: command || null, source: command ? "inferred" : "none" };
31
+ }
32
+
33
+ /**
34
+ * Run a shell command, capturing output. Bounded by a timeout so a hanging
35
+ * suite can't wedge the stage. Never rejects — a spawn error resolves as a
36
+ * failed result the QA agent can read.
37
+ */
38
+ export function runShell(command, { cwd, signal, timeoutMs = 10 * 60 * 1000 } = {}) {
39
+ return new Promise((resolve) => {
40
+ let stdout = "";
41
+ let stderr = "";
42
+ // shell:true so a full command string ("npm test", "pytest -q") runs as-is.
43
+ const proc = spawn(command, { cwd, shell: true, signal });
44
+
45
+ const timer = setTimeout(() => {
46
+ try {
47
+ proc.kill();
48
+ } catch {
49
+ /* already gone */
50
+ }
51
+ }, timeoutMs);
52
+
53
+ proc.stdout?.on("data", (d) => (stdout += d.toString()));
54
+ proc.stderr?.on("data", (d) => (stderr += d.toString()));
55
+
56
+ proc.on("error", (err) => {
57
+ clearTimeout(timer);
58
+ resolve({ code: null, passed: false, stdout, stderr: `${stderr}${err.message || err}` });
59
+ });
60
+ proc.on("close", (code) => {
61
+ clearTimeout(timer);
62
+ resolve({ code, passed: code === 0, stdout, stderr });
63
+ });
64
+ });
65
+ }
66
+
67
+ // Keep only the tail — the QA agent and the card need the verdict-relevant end
68
+ // of the output (failures, summary line), not a multi-thousand-line firehose.
69
+ function tail(text, lines = 40) {
70
+ return String(text || "")
71
+ .trim()
72
+ .split("\n")
73
+ .slice(-lines)
74
+ .join("\n");
75
+ }
76
+
77
+ /**
78
+ * Resolve + run the tests. Returns a structured result for the card and the QA
79
+ * agent. `ran: false` means no command resolved — the stage goes on tests-only-
80
+ * absent, never failing on the absence itself.
81
+ */
82
+ export async function runTests({ cwd, signal }) {
83
+ const { command, source } = await resolveTestCommand({ cwd, signal });
84
+ if (!command) {
85
+ return { ran: false, command: null, source, passed: null, output: "No test command resolved for this repo." };
86
+ }
87
+
88
+ const res = await runShell(command, { cwd, signal });
89
+ return {
90
+ ran: true,
91
+ command,
92
+ source,
93
+ passed: res.passed,
94
+ code: res.code,
95
+ output: tail(`${res.stdout}\n${res.stderr}`),
96
+ };
97
+ }