@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@splinterzzz/ouro",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Loop engineering CLI: repo-rooted kanban + agent loop, powered by Claude Code / Codex headless mode (no API key required).",
5
5
  "type": "module",
6
6
  "keywords": [
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
  };
@@ -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
  }
@@ -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 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.
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.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.
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 the test command. Returns { command, source } where source is
41
+ * Resolve one check's command. Returns { command, source } where source is
15
42
  * "config" | "inferred" | "none".
16
43
  */
17
- export async function resolveTestCommand({ cwd, signal }) {
18
- const configured = getStaging().testCommand;
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
- * suite can't wedge the stage. Never rejects — a spawn error resolves as a
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
- const proc = spawn(command, { cwd, shell: true, signal });
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 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.
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
- export async function runTests({ cwd, signal }) {
83
- const { command, source } = await resolveTestCommand({ cwd, signal });
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: "No test command resolved for this repo." };
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,
@@ -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 { runTests } from "../lib/staging.js";
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, testResult, preview) {
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("", "Tests (ouro already ran these — you cannot and need not re-run them):");
82
- if (testResult?.ran) {
83
- parts.push(
84
- `- command: ${testResult.command}`,
85
- `- result: ${testResult.passed ? "PASSED" : "FAILED"} (exit ${testResult.code})`,
86
- "- output tail:",
87
- testResult.output || "(no output)"
88
- );
89
- } else {
90
- parts.push(`- none run (${testResult?.output || "no test command resolved"})`);
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 test results. Never call something ready just because it was asked for.",
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. Tests — ouro runs them, deterministically.
324
- const testResult = await runTests({ cwd: ticket.worktree, signal });
325
- store.update(ticketId, { testResult });
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, testResult, previewInfo(ticketId)),
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
- testResult
341
+ checks.test
338
342
  );
339
343
  if (signal.aborted) return;
340
344
  const attempt = (store.get(ticketId).qaAttempts ?? 0) + 1;