@splinterzzz/ouro 0.1.0 → 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.
- package/dashboard-dist/assets/index-B-lZ4RcX.js +64 -0
- package/dashboard-dist/assets/index-C21ZXjPS.css +1 -0
- package/dashboard-dist/index.html +2 -2
- package/package.json +1 -1
- package/src/commands/dashboard.js +2 -0
- package/src/commands/init.js +168 -15
- package/src/index.js +7 -1
- package/src/lib/agentBackend.js +5 -1
- package/src/lib/agents.js +70 -5
- package/src/lib/artifacts.js +98 -0
- package/src/lib/claudeCodeExec.js +53 -8
- package/src/lib/codexExec.js +41 -9
- package/src/lib/config.js +19 -0
- package/src/lib/ouroLog.js +91 -0
- package/src/lib/paths.js +8 -1
- package/src/lib/preview.js +120 -0
- package/src/lib/ship.js +2 -2
- package/src/lib/staging.js +97 -0
- package/src/lib/store.js +52 -4
- package/src/server/index.js +339 -19
- package/dashboard-dist/assets/index-ETSLKv6w.css +0 -1
- package/dashboard-dist/assets/index-UHUWnWxM.js +0 -63
|
@@ -122,22 +122,26 @@ export async function askJson({ prompt, cwd, signal }) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/lib/codexExec.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
130
|
+
* Analyze: read-only, schema-constrained call. Cheap, safe, no repo writes.
|
|
128
131
|
*/
|
|
129
|
-
export async function
|
|
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
|
|
132
|
-
const schemaPath = path.join(os.tmpdir(), `ouro-
|
|
133
|
-
fs.writeFileSync(schemaPath, JSON.stringify(
|
|
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
|
|
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 `
|
|
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", "
|
|
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 ->
|
|
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
|
|
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 ? "
|
|
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,
|