@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.
- package/dashboard-dist/assets/index-C21ZXjPS.css +1 -0
- package/dashboard-dist/assets/index-DERJjla9.js +64 -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 +89 -17
- package/src/lib/artifacts.js +98 -0
- package/src/lib/claudeCodeExec.js +60 -9
- 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 +154 -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 +347 -19
- package/dashboard-dist/assets/index-ETSLKv6w.css +0 -1
- package/dashboard-dist/assets/index-UHUWnWxM.js +0 -63
|
@@ -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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
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,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
|
|
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
|
+
}
|