@splinterzzz/ouro 0.1.1 → 0.1.2

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.
@@ -122,22 +122,26 @@ export async function askJson({ prompt, cwd, signal }) {
122
122
  }
123
123
 
124
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.
125
+ * Analyze: a read-only agent pass (the Analyst agent) that scopes the ticket.
126
+ * Read-only-ness comes from restricting --allowedTools — Claude Code has no
127
+ * sandbox flag like Codex. The agent's tools are intersected with the read-only
128
+ * set, so even a mis-granted Write can't take effect here. Streams events to
129
+ * `onEvent` so the analysis is visible on the card like any other run, and
130
+ * returns structured findings — crucially the acceptance criteria that plan/
131
+ * execute and the QA gate are both held to.
128
132
  */
129
- export async function triage({ prompt, cwd, signal }) {
133
+ export async function analyze({ prompt, cwd, signal, agent, onEvent }) {
130
134
  const { lastMessage } = await runClaude(
131
135
  [
132
136
  "-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.`,
137
+ `${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
138
  "--output-format",
135
139
  "stream-json",
136
140
  "--verbose",
137
- "--allowedTools",
138
- READ_ONLY_TOOLS.join(","),
141
+ ...agentFlags(agent, READ_ONLY_TOOLS),
142
+ ...(agent ? [] : ["--allowedTools", READ_ONLY_TOOLS.join(",")]),
139
143
  ],
140
- { cwd, signal }
144
+ { cwd, signal, onEvent }
141
145
  );
142
146
 
143
147
  return (
@@ -145,6 +149,7 @@ export async function triage({ prompt, cwd, signal }) {
145
149
  summary: lastMessage ?? "(no summary returned)",
146
150
  priority: "medium",
147
151
  files_likely_affected: [],
152
+ acceptance_criteria: [],
148
153
  }
149
154
  );
150
155
  }
@@ -170,6 +175,46 @@ export function runAgent({ prompt, cwd, onEvent, signal, agent }) {
170
175
  );
171
176
  }
172
177
 
178
+ /**
179
+ * Staging QA gate: the Senior QA Engineer agent validates the running result.
180
+ *
181
+ * Deliberately READ-ONLY — Read/Grep/Glob, no Bash, no bypassPermissions. QA is
182
+ * a validator, not an implementer: ouro runs the tests and hands QA the results
183
+ * and the diff, so QA only needs to read the change, the source, and any built
184
+ * HTML. Denying Bash matters because ticket title/body are untrusted (Telegram
185
+ * intake is external), and an unrestricted Bash validator would turn a prompt-
186
+ * injected ticket into arbitrary command execution. Returns the parsed JSON
187
+ * verdict (null if unparseable).
188
+ */
189
+ export async function qaReview({ prompt, cwd, signal, onEvent, agent }) {
190
+ const { lastMessage } = await runClaude(
191
+ [
192
+ "-p",
193
+ prompt,
194
+ "--output-format",
195
+ "stream-json",
196
+ "--verbose",
197
+ ...agentFlags(agent, READ_ONLY_TOOLS),
198
+ ...(agent ? [] : ["--allowedTools", READ_ONLY_TOOLS.join(",")]),
199
+ ],
200
+ { cwd, signal, onEvent }
201
+ );
202
+ return parseJsonish(lastMessage);
203
+ }
204
+
205
+ /**
206
+ * Read-only run that returns the raw final message. Backs `ouro init --spec`
207
+ * (reverse-engineering a CLAUDE.md): the agent explores but never writes — ouro
208
+ * writes the file from what comes back, so "read-only" stays literally true.
209
+ */
210
+ export async function generateSpec({ prompt, cwd, signal, onEvent }) {
211
+ const { lastMessage } = await runClaude(
212
+ ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", READ_ONLY_TOOLS.join(",")],
213
+ { cwd, signal, onEvent }
214
+ );
215
+ return lastMessage;
216
+ }
217
+
173
218
  /**
174
219
  * Human-in-the-loop, phase 1: plan only, read-only tools, no edits. Returns
175
220
  * 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,120 @@
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
+ /**
77
+ * Start (or restart) the preview for a ticket. Returns a status object; never
78
+ * throws. `started:false` means nothing resolved — the card shows "no preview"
79
+ * and the stage goes on.
80
+ */
81
+ export async function startPreview(ticketId, { cwd, signal } = {}) {
82
+ stopPreview(ticketId); // never two for one ticket
83
+
84
+ const { command, port, source } = await resolvePreview({ cwd, signal });
85
+ if (!command) return { started: false, reason: "no preview command resolved", source };
86
+
87
+ let proc;
88
+ try {
89
+ proc = spawn(command, { cwd, shell: true });
90
+ } catch (err) {
91
+ return { started: false, reason: `spawn failed: ${err.message || err}`, source };
92
+ }
93
+ proc.on("error", () => {}); // a preview that fails to launch must not crash the stage
94
+ proc.stdout?.on("data", () => {});
95
+ proc.stderr?.on("data", () => {});
96
+
97
+ const url = port ? `http://localhost:${port}` : null;
98
+ previews.set(ticketId, { proc, url, command, port });
99
+
100
+ const reachable = port ? await waitForPort(port) : false;
101
+ return { started: true, url, command, port, source, reachable };
102
+ }
103
+
104
+ export function stopPreview(ticketId) {
105
+ const p = previews.get(ticketId);
106
+ if (!p) return false;
107
+ killTree(p.proc);
108
+ previews.delete(ticketId);
109
+ return true;
110
+ }
111
+
112
+ export function previewInfo(ticketId) {
113
+ const p = previews.get(ticketId);
114
+ return p ? { url: p.url, command: p.command, port: p.port } : null;
115
+ }
116
+
117
+ /** Tear down every preview — used when the dashboard process shuts down. */
118
+ export function stopAllPreviews() {
119
+ for (const id of [...previews.keys()]) stopPreview(id);
120
+ }
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
+ }
package/src/lib/store.js CHANGED
@@ -7,7 +7,12 @@ import { ticketsPath, ensureOuroDir } from "./paths.js";
7
7
  // sqlite/postgres — a JSON file + an in-memory EventEmitter for the WS layer
8
8
  // is plenty, and it means `ouro init` produces zero external dependencies.
9
9
 
10
- export const STATUSES = ["inbox", "triaged", "in_progress", "review", "done", "cancelled"];
10
+ export const STATUSES = ["inbox", "analyzed", "in_progress", "staging", "done", "cancelled"];
11
+
12
+ // Legacy status values from before the Analyze/Staging rename. Mapped forward on
13
+ // load so a tickets.json written by an older ouro doesn't strand cards in a
14
+ // column that no longer exists.
15
+ const STATUS_MIGRATION = { triaged: "analyzed", review: "staging" };
11
16
 
12
17
  // A streaming agent emits events far faster than a board needs to persist.
13
18
  // Writes are coalesced onto a trailing timer so a chatty run costs one write
@@ -42,11 +47,30 @@ class TicketStore extends EventEmitter {
42
47
  // it to a terminal state at startup instead.
43
48
  let reconciled = 0;
44
49
  for (const ticket of this.tickets) {
50
+ // Forward-migrate legacy status names (triaged → analyzed, review → staging).
51
+ if (STATUS_MIGRATION[ticket.status]) {
52
+ ticket.status = STATUS_MIGRATION[ticket.status];
53
+ reconciled++;
54
+ }
45
55
  if (ticket.status === "in_progress") {
46
56
  ticket.status = "cancelled";
47
57
  ticket.cancelReason = "Dashboard stopped while this run was in flight.";
48
58
  reconciled++;
49
59
  }
60
+ // A read-only Analyze pass has no worktree to reconcile — it just leaves
61
+ // the ticket where it was. But a stranded `analyzing` flag would keep the
62
+ // card stuck on "Analyzing…" forever, so clear it.
63
+ if (ticket.analyzing) {
64
+ ticket.analyzing = false;
65
+ reconciled++;
66
+ }
67
+ // A preview server is a child of the dashboard process — it didn't survive
68
+ // the restart, so a stored URL now points at nothing.
69
+ if (ticket.previewUrl) {
70
+ ticket.previewUrl = null;
71
+ ticket.previewNote = "preview stopped when the dashboard restarted";
72
+ reconciled++;
73
+ }
50
74
  }
51
75
  // Write it back immediately. Reconciling only in memory leaves the file
52
76
  // claiming in_progress until some unrelated edit happens to flush it —
@@ -91,11 +115,24 @@ class TicketStore extends EventEmitter {
91
115
  title,
92
116
  body,
93
117
  source,
94
- status: "inbox", // inbox -> triaged -> in_progress -> review -> done | cancelled
118
+ status: "inbox", // inbox -> analyzed -> in_progress -> staging -> done | cancelled
95
119
  mode, // "agent" | "human" — null inherits the board's default
96
120
  agentId, // which .ouro/agents/<id>.md runs this ticket
97
121
  priority,
98
122
  summary,
123
+ // Analyst findings — carried forward into plan/execute (Feature 1 findings
124
+ // passthrough) and validated by the QA gate.
125
+ filesLikelyAffected: [],
126
+ acceptanceCriteria: [],
127
+ analyzing: false, // transient: the read-only Analyze pass is in flight
128
+ // Staging stage (Feature 9).
129
+ testResult: null, // { ran, command, source, passed, code, output }
130
+ previewUrl: null, // clickable local-preview link while one is running
131
+ previewNote: null, // why there's no preview, when there isn't
132
+ qaVerdict: null, // { ready, summary, reasons, uiChange, visualMethod, questions }
133
+ qaAttempts: 0, // QA passes so far — the loop-stop guard escalates at 2
134
+ awaitingQa: false, // QA posted its verdict and is waiting on a human (popup)
135
+ escalated: false, // hit the loop-stop guard: a human must decide
99
136
  sessionId: null,
100
137
  log: [],
101
138
  worktree: null,
@@ -155,17 +192,28 @@ class TicketStore extends EventEmitter {
155
192
  return this.update(id, {
156
193
  status: "cancelled",
157
194
  awaitingApproval: false,
195
+ awaitingQa: false,
196
+ escalated: false,
197
+ analyzing: false,
158
198
  cancelReason: reason ?? "Cancelled from the dashboard.",
159
199
  });
160
200
  }
161
201
 
162
- /** Back to the board as a fresh triaged ticket, keeping title/body/agent. */
202
+ /** Back to the board as a fresh analyzed ticket, keeping title/body/agent. */
163
203
  reopen(id) {
164
204
  const ticket = this.get(id);
165
205
  if (!ticket) return null;
166
206
  return this.update(id, {
167
- status: ticket.summary ? "triaged" : "inbox",
207
+ status: ticket.summary ? "analyzed" : "inbox",
168
208
  awaitingApproval: false,
209
+ analyzing: false,
210
+ awaitingQa: false,
211
+ escalated: false,
212
+ testResult: null,
213
+ qaVerdict: null,
214
+ qaAttempts: 0,
215
+ previewUrl: null,
216
+ previewNote: null,
169
217
  cancelReason: null,
170
218
  diff: null,
171
219
  plan: null,