@splinterzzz/ouro 0.1.5 → 0.1.6
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/package.json +1 -1
- package/src/lib/config.js +4 -0
- package/src/lib/preview.js +2 -2
- package/src/lib/staging.js +63 -25
- package/src/lib/store.js +3 -1
- package/src/server/index.js +22 -18
package/package.json
CHANGED
package/src/lib/config.js
CHANGED
|
@@ -30,6 +30,8 @@ const DEFAULTS = {
|
|
|
30
30
|
// preview is expected to listen (used to build the clickable URL).
|
|
31
31
|
staging: {
|
|
32
32
|
testCommand: null,
|
|
33
|
+
lintCommand: null,
|
|
34
|
+
buildCommand: null,
|
|
33
35
|
previewCommand: null,
|
|
34
36
|
previewPort: null,
|
|
35
37
|
},
|
|
@@ -108,6 +110,8 @@ export function getStaging() {
|
|
|
108
110
|
const s = readConfig().staging ?? {};
|
|
109
111
|
return {
|
|
110
112
|
testCommand: s.testCommand ?? null,
|
|
113
|
+
lintCommand: s.lintCommand ?? null,
|
|
114
|
+
buildCommand: s.buildCommand ?? null,
|
|
111
115
|
previewCommand: s.previewCommand ?? null,
|
|
112
116
|
previewPort: s.previewPort ?? null,
|
|
113
117
|
};
|
package/src/lib/preview.js
CHANGED
|
@@ -45,7 +45,7 @@ function killTree(proc) {
|
|
|
45
45
|
if (!proc?.pid) return;
|
|
46
46
|
try {
|
|
47
47
|
if (process.platform === "win32") {
|
|
48
|
-
spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore" });
|
|
48
|
+
spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
49
49
|
} else {
|
|
50
50
|
proc.kill("SIGTERM");
|
|
51
51
|
}
|
|
@@ -112,7 +112,7 @@ export async function startPreview(ticketId, { cwd, signal } = {}) {
|
|
|
112
112
|
|
|
113
113
|
let proc;
|
|
114
114
|
try {
|
|
115
|
-
proc = spawn(command, { cwd, shell: true });
|
|
115
|
+
proc = spawn(command, { cwd, shell: true, windowsHide: true });
|
|
116
116
|
} catch (err) {
|
|
117
117
|
return { started: false, reason: `spawn failed: ${err.message || err}`, source };
|
|
118
118
|
}
|
package/src/lib/staging.js
CHANGED
|
@@ -2,37 +2,57 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { getStaging } from "./config.js";
|
|
3
3
|
import { askJson } from "./agentBackend.js";
|
|
4
4
|
|
|
5
|
-
// Staging
|
|
6
|
-
// QA agent judges the result — so the
|
|
7
|
-
//
|
|
5
|
+
// Staging checks. ouro runs the deterministic checks (test, lint, build) and the
|
|
6
|
+
// QA agent judges the result — so the commands have to be resolved and run here,
|
|
7
|
+
// not left to the agent's Bash tool (QA is read-only by design).
|
|
8
8
|
//
|
|
9
|
-
// Resolution order: config.staging
|
|
10
|
-
// agent inference from the repo
|
|
11
|
-
//
|
|
9
|
+
// Resolution order per check: config.staging.<kind>Command if set, else a cheap
|
|
10
|
+
// read-only agent inference from the repo. If nothing resolves, that check is
|
|
11
|
+
// reported as not-run — the QA gate treats a check with no command in the repo
|
|
12
|
+
// as N/A, never a failure, so a repo without a linter or build step still ships.
|
|
13
|
+
|
|
14
|
+
// One entry per deterministic check. `configKey` is the config.staging field
|
|
15
|
+
// that pins the command; `infer` is the read-only prompt used when it isn't set.
|
|
16
|
+
const CHECKS = [
|
|
17
|
+
{
|
|
18
|
+
kind: "test",
|
|
19
|
+
configKey: "testCommand",
|
|
20
|
+
infer:
|
|
21
|
+
"Infer the single shell command that runs this project's automated tests, from its config (package.json scripts, Makefile, pyproject, etc.). " +
|
|
22
|
+
'Respond with ONLY JSON: {"command": string|null}. Use null if the repo has no test setup. No prose, no fences.',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
kind: "lint",
|
|
26
|
+
configKey: "lintCommand",
|
|
27
|
+
infer:
|
|
28
|
+
'Infer the single shell command that lints this project, from its config (a package.json "lint" script, eslint/ruff/flake8 config, etc.). ' +
|
|
29
|
+
'Respond with ONLY JSON: {"command": string|null}. Use null if the repo has no linter. No prose, no fences.',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
kind: "build",
|
|
33
|
+
configKey: "buildCommand",
|
|
34
|
+
infer:
|
|
35
|
+
'Infer the single shell command that builds or compiles this project, from its config (a package.json "build" script, tsc, a bundler config, etc.). ' +
|
|
36
|
+
'Respond with ONLY JSON: {"command": string|null}. Use null if the repo has no build step. No prose, no fences.',
|
|
37
|
+
},
|
|
38
|
+
];
|
|
12
39
|
|
|
13
40
|
/**
|
|
14
|
-
* Resolve
|
|
41
|
+
* Resolve one check's command. Returns { command, source } where source is
|
|
15
42
|
* "config" | "inferred" | "none".
|
|
16
43
|
*/
|
|
17
|
-
|
|
18
|
-
const configured = getStaging()
|
|
44
|
+
async function resolveCommand({ configKey, infer, cwd, signal }) {
|
|
45
|
+
const configured = getStaging()[configKey];
|
|
19
46
|
if (configured) return { command: String(configured), source: "config" };
|
|
20
47
|
|
|
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
|
-
|
|
48
|
+
const inferred = await askJson({ prompt: infer, cwd, signal }).catch(() => null);
|
|
29
49
|
const command = typeof inferred?.command === "string" ? inferred.command.trim() : "";
|
|
30
50
|
return { command: command || null, source: command ? "inferred" : "none" };
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
/**
|
|
34
54
|
* Run a shell command, capturing output. Bounded by a timeout so a hanging
|
|
35
|
-
*
|
|
55
|
+
* check can't wedge the stage. Never rejects — a spawn error resolves as a
|
|
36
56
|
* failed result the QA agent can read.
|
|
37
57
|
*/
|
|
38
58
|
export function runShell(command, { cwd, signal, timeoutMs = 10 * 60 * 1000 } = {}) {
|
|
@@ -40,7 +60,9 @@ export function runShell(command, { cwd, signal, timeoutMs = 10 * 60 * 1000 } =
|
|
|
40
60
|
let stdout = "";
|
|
41
61
|
let stderr = "";
|
|
42
62
|
// shell:true so a full command string ("npm test", "pytest -q") runs as-is.
|
|
43
|
-
|
|
63
|
+
// windowsHide: the shell child is cmd.exe on win32 — without it every check
|
|
64
|
+
// run (and every QA loop-back retry) flashes a console window.
|
|
65
|
+
const proc = spawn(command, { cwd, shell: true, signal, windowsHide: true });
|
|
44
66
|
|
|
45
67
|
const timer = setTimeout(() => {
|
|
46
68
|
try {
|
|
@@ -75,18 +97,18 @@ function tail(text, lines = 40) {
|
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
/**
|
|
78
|
-
* Resolve + run
|
|
79
|
-
*
|
|
80
|
-
* absent, never failing on the absence itself.
|
|
100
|
+
* Resolve + run one check. `ran: false` means no command resolved — the QA gate
|
|
101
|
+
* reads that as N/A for this repo, never a failure.
|
|
81
102
|
*/
|
|
82
|
-
|
|
83
|
-
const { command, source } = await
|
|
103
|
+
async function runOne(check, { cwd, signal }) {
|
|
104
|
+
const { command, source } = await resolveCommand({ ...check, cwd, signal });
|
|
84
105
|
if (!command) {
|
|
85
|
-
return { ran: false, command: null, source, passed: null, output:
|
|
106
|
+
return { kind: check.kind, ran: false, command: null, source, passed: null, output: `No ${check.kind} command resolved for this repo.` };
|
|
86
107
|
}
|
|
87
108
|
|
|
88
109
|
const res = await runShell(command, { cwd, signal });
|
|
89
110
|
return {
|
|
111
|
+
kind: check.kind,
|
|
90
112
|
ran: true,
|
|
91
113
|
command,
|
|
92
114
|
source,
|
|
@@ -95,3 +117,19 @@ export async function runTests({ cwd, signal }) {
|
|
|
95
117
|
output: tail(`${res.stdout}\n${res.stderr}`),
|
|
96
118
|
};
|
|
97
119
|
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run every deterministic check (test, lint, build), in order. Sequential so
|
|
123
|
+
* build and test don't race on the same worktree. Returns { test, lint, build },
|
|
124
|
+
* each a structured result for the card and the QA agent. Honors `signal` —
|
|
125
|
+
* once aborted, remaining checks are skipped as not-run.
|
|
126
|
+
*/
|
|
127
|
+
export async function runChecks({ cwd, signal }) {
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const check of CHECKS) {
|
|
130
|
+
out[check.kind] = signal?.aborted
|
|
131
|
+
? { kind: check.kind, ran: false, command: null, source: "none", passed: null, output: "cancelled" }
|
|
132
|
+
: await runOne(check, { cwd, signal });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
package/src/lib/store.js
CHANGED
|
@@ -126,7 +126,8 @@ class TicketStore extends EventEmitter {
|
|
|
126
126
|
acceptanceCriteria: [],
|
|
127
127
|
analyzing: false, // transient: the read-only Analyze pass is in flight
|
|
128
128
|
// Staging stage (Feature 9).
|
|
129
|
-
testResult: null, // { ran, command, source, passed, code, output }
|
|
129
|
+
testResult: null, // { ran, command, source, passed, code, output } — the test check
|
|
130
|
+
checks: null, // { test, lint, build } — every deterministic check ouro ran for QA
|
|
130
131
|
previewUrl: null, // clickable local-preview link while one is running
|
|
131
132
|
previewNote: null, // why there's no preview, when there isn't
|
|
132
133
|
qaVerdict: null, // { ready, summary, reasons, uiChange, visualMethod, questions }
|
|
@@ -210,6 +211,7 @@ class TicketStore extends EventEmitter {
|
|
|
210
211
|
awaitingQa: false,
|
|
211
212
|
escalated: false,
|
|
212
213
|
testResult: null,
|
|
214
|
+
checks: null,
|
|
213
215
|
qaVerdict: null,
|
|
214
216
|
qaAttempts: 0,
|
|
215
217
|
previewUrl: null,
|
package/src/server/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { analyze, runAgent, planTicket, executeTicket, qaReview, getBackendName,
|
|
|
8
8
|
import { createTicketWorktree, diffWorktree } from "../lib/worktree.js";
|
|
9
9
|
import { contextManifest, listContextFiles, listReferencedFiles } from "../lib/artifacts.js";
|
|
10
10
|
import { appendRunLog, readRunLog } from "../lib/ouroLog.js";
|
|
11
|
-
import {
|
|
11
|
+
import { runChecks } from "../lib/staging.js";
|
|
12
12
|
import { startPreview, stopPreview, previewInfo } from "../lib/preview.js";
|
|
13
13
|
import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, getMaxQaAttempts, telegramTokenVar } from "../lib/config.js";
|
|
14
14
|
import { readEnvVars, writeEnvVars } from "../lib/env.js";
|
|
@@ -60,7 +60,7 @@ export function shipOutcome(result) {
|
|
|
60
60
|
|
|
61
61
|
// The full context the Senior QA Engineer agent judges against. ouro has already
|
|
62
62
|
// run the tests; the agent validates the running result and the visual review.
|
|
63
|
-
export function qaPrompt(ticket,
|
|
63
|
+
export function qaPrompt(ticket, checks, preview) {
|
|
64
64
|
const parts = [
|
|
65
65
|
`You are the QA gate validating ticket [#${ticket.id}] in its git worktree, after it was implemented. You have READ-ONLY tools (Read/Grep/Glob) — you validate, you never modify or execute.`,
|
|
66
66
|
"",
|
|
@@ -78,16 +78,19 @@ export function qaPrompt(ticket, testResult, preview) {
|
|
|
78
78
|
parts.push("", "No explicit acceptance criteria were recorded — validate against the ticket intent above.");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
parts.push("", "
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
81
|
+
parts.push("", "Checks (ouro already ran these — you cannot and need not re-run them):");
|
|
82
|
+
for (const [label, r] of [
|
|
83
|
+
["Tests", checks?.test],
|
|
84
|
+
["Lint", checks?.lint],
|
|
85
|
+
["Build", checks?.build],
|
|
86
|
+
]) {
|
|
87
|
+
if (r?.ran) {
|
|
88
|
+
parts.push(`- ${label}: ${r.passed ? "PASSED" : "FAILED"} (exit ${r.code}) — ${r.command}`);
|
|
89
|
+
// Only the failing output earns space in the prompt; a PASSED line is enough.
|
|
90
|
+
if (!r.passed) parts.push(" output tail:", r.output || "(no output)");
|
|
91
|
+
} else {
|
|
92
|
+
parts.push(`- ${label}: not run — no such command in this repo (N/A, not a failure).`);
|
|
93
|
+
}
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
parts.push("", "Change under review (git diff of the worktree):");
|
|
@@ -102,7 +105,7 @@ export function qaPrompt(ticket, testResult, preview) {
|
|
|
102
105
|
"2. So if it IS a UI change, read the changed UI files and any rendered/built HTML with your Read tool and assess visually from those — do NOT silently skip UI validation.",
|
|
103
106
|
"3. If it is not a UI change, tests-only is fine.",
|
|
104
107
|
"",
|
|
105
|
-
"Judge the running result against the acceptance criteria and the
|
|
108
|
+
"Judge the running result against the acceptance criteria and the check results above. A check that RAN and FAILED is grounds to send this back. A check listed as not run (no such command in this repo) is N/A — never block, and never raise a question, solely because a check was not run or could not be verified; ouro runs every check it can and you cannot run more. Never call something ready just because it was asked for.",
|
|
106
109
|
'Respond with ONLY a JSON object, no prose and no fences: {"ready": boolean, "summary": string, "reasons": string[], "ui_change": boolean, "visual_method": "screenshot"|"html"|"none", "questions": string[]}. When not ready, `reasons` must be concrete and actionable. `questions` are open items to resolve before shipping — in an agent loop the engineer answers them itself, in human-in-loop the person does.'
|
|
107
110
|
);
|
|
108
111
|
return parts.join("\n");
|
|
@@ -320,21 +323,22 @@ export function createServer() {
|
|
|
320
323
|
while (!signal.aborted) {
|
|
321
324
|
const ticket = store.get(ticketId);
|
|
322
325
|
|
|
323
|
-
// 1.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
+
// 1. Checks — ouro runs test, lint, and build deterministically. testResult
|
|
327
|
+
// stays the test result (the card renders it); checks carries all three.
|
|
328
|
+
const checks = await runChecks({ cwd: ticket.worktree, signal });
|
|
329
|
+
store.update(ticketId, { testResult: checks.test, checks });
|
|
326
330
|
if (signal.aborted) return;
|
|
327
331
|
|
|
328
332
|
// 2. QA agent judges the running result against the acceptance criteria.
|
|
329
333
|
const verdict = normalizeVerdict(
|
|
330
334
|
await qaReview({
|
|
331
|
-
prompt: qaPrompt(ticket,
|
|
335
|
+
prompt: qaPrompt(ticket, checks, previewInfo(ticketId)),
|
|
332
336
|
cwd: ticket.worktree,
|
|
333
337
|
signal,
|
|
334
338
|
onEvent,
|
|
335
339
|
agent: getAgent("senior-qa-engineer"),
|
|
336
340
|
}).catch(() => null),
|
|
337
|
-
|
|
341
|
+
checks.test
|
|
338
342
|
);
|
|
339
343
|
if (signal.aborted) return;
|
|
340
344
|
const attempt = (store.get(ticketId).qaAttempts ?? 0) + 1;
|