@splinterzzz/ouro 0.1.3 → 0.1.5

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.
@@ -9,7 +9,7 @@
9
9
  flash white around a near-black app on load. -->
10
10
  <meta name="theme-color" content="#0b0a12" />
11
11
  <title>ouro</title>
12
- <script type="module" crossorigin src="/assets/index-DERJjla9.js"></script>
12
+ <script type="module" crossorigin src="/assets/index-D-niaPon.js"></script>
13
13
  <link rel="stylesheet" crossorigin href="/assets/index-C21ZXjPS.css">
14
14
  </head>
15
15
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@splinterzzz/ouro",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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": [
@@ -55,7 +55,10 @@ function runClaude(args, { cwd, onEvent, signal } = {}) {
55
55
  return new Promise((resolve, reject) => {
56
56
  // `signal` wires cancellation straight to the child: aborting SIGTERMs the
57
57
  // CLI rather than leaving it running detached. See lib/runs.js.
58
- const proc = spawn(CLAUDE_BIN, args, { cwd, env: process.env, signal });
58
+ // windowsHide: the dashboard daemon spawns this per run — without it, each
59
+ // run flashes an empty `claude` console window on Windows. Output is piped
60
+ // and parsed here, so the child never needs a visible console of its own.
61
+ const proc = spawn(CLAUDE_BIN, args, { cwd, env: process.env, signal, windowsHide: true });
59
62
 
60
63
  const rl = readline.createInterface({ input: proc.stdout });
61
64
  let sessionId = null;
@@ -63,7 +63,9 @@ function agentFlags(agent) {
63
63
  function runCodexExec(args, { cwd, onEvent, signal } = {}) {
64
64
  return new Promise((resolve, reject) => {
65
65
  // `signal` makes cancellation kill the child process. See lib/runs.js.
66
- const proc = spawn(CODEX_BIN, args, { cwd, env: process.env, signal });
66
+ // windowsHide: keep the per-run child from flashing an empty console window
67
+ // on Windows — output is piped and parsed here, not shown in a console.
68
+ const proc = spawn(CODEX_BIN, args, { cwd, env: process.env, signal, windowsHide: true });
67
69
 
68
70
  const rl = readline.createInterface({ input: proc.stdout });
69
71
  let sessionId = null;
package/src/lib/config.js CHANGED
@@ -16,6 +16,10 @@ const DEFAULTS = {
16
16
  // explicit "Create PR" button instead — pushing is outward-facing, so this
17
17
  // is deliberately a knob and not a hardcode.
18
18
  autoShip: true,
19
+ // Ceiling on agent-loop QA loop-back re-runs (enterStaging) before the ticket
20
+ // escalates to a human — a safety valve against a runaway loop that keeps
21
+ // failing QA and re-running the (expensive) engineer forever.
22
+ maxQaAttempts: 3,
19
23
  telegram: {
20
24
  botTokenEnvVar: "OURO_TELEGRAM_BOT_TOKEN",
21
25
  chatIdEnvVar: "OURO_TELEGRAM_CHAT_ID",
@@ -94,6 +98,11 @@ export function getAutoShip() {
94
98
  return readConfig().autoShip !== false;
95
99
  }
96
100
 
101
+ export function getMaxQaAttempts() {
102
+ const n = readConfig().maxQaAttempts;
103
+ return Number.isInteger(n) && n >= 1 ? n : 3;
104
+ }
105
+
97
106
  /** Staging config, each field null when unset (ouro then resolves it per-repo). */
98
107
  export function getStaging() {
99
108
  const s = readConfig().staging ?? {};
package/src/lib/daemon.js CHANGED
@@ -60,9 +60,24 @@ export function updateRecord(name, patch) {
60
60
  return next;
61
61
  }
62
62
 
63
- /** Signal 0 probes for existence without delivering anything. */
63
+ /**
64
+ * Signal 0 probes for existence without delivering anything. On win32 this is
65
+ * unreliable on its own: PIDs recycle fast, and once our dead pid is reused by
66
+ * an unrelated process owned by another user/session, `process.kill(pid, 0)`
67
+ * throws EPERM (exists, just not ours) — indistinguishable from our own
68
+ * still-alive service. That false positive pins a stale pid file forever
69
+ * (`stop` reports "refused to die" against a ghost). Cross-check with
70
+ * `tasklist` so a recycled pid isn't mistaken for our service.
71
+ */
64
72
  export function isAlive(pid) {
65
73
  if (!pid) return false;
74
+ if (process.platform === "win32") {
75
+ const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
76
+ encoding: "utf-8",
77
+ });
78
+ const out = result.stdout || "";
79
+ return out.includes(`"${pid}"`);
80
+ }
66
81
  try {
67
82
  process.kill(pid, 0);
68
83
  return true;
@@ -10,7 +10,7 @@ import { contextManifest, listContextFiles, listReferencedFiles } from "../lib/a
10
10
  import { appendRunLog, readRunLog } from "../lib/ouroLog.js";
11
11
  import { runTests } from "../lib/staging.js";
12
12
  import { startPreview, stopPreview, previewInfo } from "../lib/preview.js";
13
- import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, telegramTokenVar } from "../lib/config.js";
13
+ import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, getMaxQaAttempts, telegramTokenVar } from "../lib/config.js";
14
14
  import { readEnvVars, writeEnvVars } from "../lib/env.js";
15
15
  import { looksLikeToken, maskToken, verifyBotToken } from "../lib/telegram.js";
16
16
  import { startService, stopService, serviceStatus, isAlive, tailLog, uptime } from "../lib/daemon.js";
@@ -103,7 +103,7 @@ export function qaPrompt(ticket, testResult, preview) {
103
103
  "3. If it is not a UI change, tests-only is fine.",
104
104
  "",
105
105
  "Judge the running result against the acceptance criteria and the test results. Never call something ready just because it was asked for.",
106
- '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 for the human in human-in-loop mode.'
106
+ '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
107
  );
108
108
  return parts.join("\n");
109
109
  }
@@ -139,10 +139,25 @@ export function qaSummaryLine(v) {
139
139
  }
140
140
 
141
141
  export function qaFeedbackBlock(v) {
142
- if (!v?.reasons?.length) return "";
143
- return `\n\nThe QA gate sent this back. Address every point before it can ship:\n${v.reasons
144
- .map((r, i) => `${i + 1}. ${r}`)
145
- .join("\n")}`;
142
+ const blocks = [];
143
+ if (v?.reasons?.length) {
144
+ blocks.push(
145
+ `The QA gate sent this back. Address every point before it can ship:\n${v.reasons
146
+ .map((r, i) => `${i + 1}. ${r}`)
147
+ .join("\n")}`
148
+ );
149
+ }
150
+ // Agent loop: no human will answer QA's questions, so the engineer resolves
151
+ // them itself. Only reached on the agent-loop loop-back path (enterStaging),
152
+ // so framing them as "yours to answer" is always accurate here.
153
+ if (v?.questions?.length) {
154
+ blocks.push(
155
+ `QA raised these open questions. This is an agent loop — no human will answer them, so resolve each one yourself: investigate the code, decide, act on it, and state the answer in your final message:\n${v.questions
156
+ .map((q, i) => `${i + 1}. ${q}`)
157
+ .join("\n")}`
158
+ );
159
+ }
160
+ return blocks.length ? `\n\n${blocks.join("\n\n")}` : "";
146
161
  }
147
162
 
148
163
  /**
@@ -339,9 +354,10 @@ export function createServer() {
339
354
  appendRunLog(store.get(ticketId), shipOutcome(shipResult));
340
355
  return;
341
356
  }
342
- // Not ready. Loop-stop guard: a 2nd failure escalates to a human,
343
- // regardless of mode — don't loop forever.
344
- if (attempt >= 2) {
357
+ // Not ready. Loop-stop guard: once we hit the configurable ceiling
358
+ // (maxQaAttempts) the ticket escalates to a human, regardless of mode —
359
+ // don't loop forever re-running the expensive engineer.
360
+ if (attempt >= getMaxQaAttempts()) {
345
361
  store.update(ticketId, { awaitingQa: true, escalated: true });
346
362
  appendRunLog(store.get(ticketId), `QA failed ${attempt}× — escalated to human`);
347
363
  return;
@@ -361,36 +377,29 @@ export function createServer() {
361
377
  }
362
378
  }
363
379
 
364
- // --- tickets ---
365
-
366
- app.get("/api/tickets", (_req, res) => {
367
- res.json(store.list());
368
- });
369
-
370
- app.post("/api/tickets", (req, res) => {
371
- const { title, body, source, agentId, mode, priority, summary } = req.body;
372
- if (!title?.trim()) return res.status(400).json({ error: "title is required" });
373
- res.json(store.create({ title: title.trim(), body: body ?? "", source, agentId, mode, priority, summary }));
374
- });
375
-
376
- app.post("/api/tickets/:id/analyze", async (req, res) => {
377
- const ticket = store.get(req.params.id);
378
- if (!ticket) return res.status(404).json({ error: "not found" });
379
- if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
380
+ /**
381
+ * The read-only Analyst pass, factored out of its route so the auto-pipeline
382
+ * can chain it into a run without a second HTTP hop. Owns its own run
383
+ * registration and the transient `analyzing` flag, and returns `{ ok }` so a
384
+ * caller can decide whether to proceed — abort or a caught error is `ok:false`
385
+ * (the error is still logged to the ticket, as when a human clicked Analyze).
386
+ */
387
+ async function runAnalyze(ticketId) {
388
+ const ticket = store.get(ticketId);
389
+ if (!ticket || runs.isRunning(ticketId)) return { ok: false };
380
390
 
381
391
  let signal;
382
392
  try {
383
- signal = runs.begin(ticket.id, "analyze");
384
- } catch (err) {
385
- return res.status(409).json({ error: String(err.message || err) });
393
+ signal = runs.begin(ticketId, "analyze");
394
+ } catch {
395
+ return { ok: false };
386
396
  }
387
397
 
388
- res.json({ started: true });
389
398
  // Analyze is read-only and leaves the ticket where it is; a transient flag
390
399
  // drives the card's "Analyzing…" state without faking a worktree run.
391
- store.update(ticket.id, { analyzing: true });
400
+ store.update(ticketId, { analyzing: true });
392
401
 
393
- const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
402
+ const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
394
403
 
395
404
  try {
396
405
  // Always the Analyst agent for this step, regardless of the ticket's
@@ -406,8 +415,8 @@ export function createServer() {
406
415
  onEvent,
407
416
  agent: analyst,
408
417
  });
409
- if (signal.aborted) return; // cancelled mid-analysis — /cancel already reconciled it
410
- store.update(ticket.id, {
418
+ if (signal.aborted) return { ok: false }; // cancelled mid-analysis — /cancel already reconciled it
419
+ store.update(ticketId, {
411
420
  status: "analyzed",
412
421
  analyzing: false,
413
422
  summary: result.summary,
@@ -415,51 +424,39 @@ export function createServer() {
415
424
  filesLikelyAffected: Array.isArray(result.files_likely_affected) ? result.files_likely_affected : [],
416
425
  acceptanceCriteria: Array.isArray(result.acceptance_criteria) ? result.acceptance_criteria : [],
417
426
  });
427
+ return { ok: true };
418
428
  } catch (err) {
419
- store.update(ticket.id, { analyzing: false });
420
- store.appendLog(ticket.id, { type: "error", text: String(err.message || err) });
429
+ store.update(ticketId, { analyzing: false });
430
+ store.appendLog(ticketId, { type: "error", text: String(err.message || err) });
431
+ return { ok: false };
421
432
  } finally {
422
- runs.end(ticket.id);
433
+ runs.end(ticketId);
423
434
  }
424
- });
425
-
426
- app.post("/api/tickets/:id/mode", (req, res) => {
427
- const { mode } = req.body; // "agent" | "human" | null (inherit board default)
428
- if (mode !== null && mode !== "agent" && mode !== "human") {
429
- return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
430
- }
431
- const ticket = store.update(req.params.id, { mode });
432
- if (!ticket) return res.status(404).json({ error: "not found" });
433
- res.json(ticket);
434
- });
435
-
436
- app.post("/api/tickets/:id/agent", (req, res) => {
437
- const { agentId } = req.body;
438
- if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
439
- const ticket = store.update(req.params.id, { agentId: agentId ?? null });
440
- if (!ticket) return res.status(404).json({ error: "not found" });
441
- res.json(ticket);
442
- });
435
+ }
443
436
 
444
- app.post("/api/tickets/:id/run", async (req, res) => {
445
- const ticket = store.get(req.params.id);
446
- if (!ticket) return res.status(404).json({ error: "not found" });
447
- if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
437
+ /**
438
+ * The implementation run, factored out of its route for the same reason. Owns
439
+ * its own run registration, resolves mode/agent, resets the staging state to a
440
+ * clean QA budget, cuts the worktree, then either runs the agent straight into
441
+ * Staging (agent mode) or plans and waits for approval (human mode).
442
+ */
443
+ async function runImplementation(ticketId) {
444
+ const ticket = store.get(ticketId);
445
+ if (!ticket || runs.isRunning(ticketId)) return;
448
446
 
449
447
  const mode = resolveMode(ticket);
450
448
  const agent = resolveAgent(ticket);
451
449
 
452
450
  let signal;
453
451
  try {
454
- signal = runs.begin(ticket.id, mode);
455
- } catch (err) {
456
- return res.status(409).json({ error: String(err.message || err) });
452
+ signal = runs.begin(ticketId, mode);
453
+ } catch {
454
+ return;
457
455
  }
458
456
 
459
- res.json({ started: true, mode, agentId: agent?.id ?? null });
460
457
  // A fresh run resets the staging state so a re-run starts with a clean QA
461
458
  // budget (the loop-stop guard counts from here).
462
- store.update(ticket.id, {
459
+ store.update(ticketId, {
463
460
  status: "in_progress",
464
461
  mode,
465
462
  agentId: agent?.id ?? ticket.agentId,
@@ -471,11 +468,11 @@ export function createServer() {
471
468
  escalated: false,
472
469
  });
473
470
 
474
- const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
471
+ const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
475
472
 
476
473
  try {
477
- const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticket.id);
478
- store.update(ticket.id, { worktree: worktreeDir, branch, baseBranch: base });
474
+ const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticketId);
475
+ store.update(ticketId, { worktree: worktreeDir, branch, baseBranch: base });
479
476
 
480
477
  const prompt = buildImplementationPrompt(ticket);
481
478
 
@@ -483,17 +480,17 @@ export function createServer() {
483
480
  // Full autonomy, single call.
484
481
  const result = await runAgent({ prompt, cwd: worktreeDir, onEvent, signal, agent });
485
482
  if (result.aborted) return; // cancel route already marked it
486
- const diff = await diffWorktree(ticket.id).catch(() => null);
487
- store.update(ticket.id, { status: "staging", diff, sessionId: result.sessionId, awaitingApproval: false });
483
+ const diff = await diffWorktree(ticketId).catch(() => null);
484
+ store.update(ticketId, { status: "staging", diff, sessionId: result.sessionId, awaitingApproval: false });
488
485
  // Validate in Staging before anything ships — the QA gate stands between
489
486
  // a finished run and the PR.
490
- await enterStaging(ticket.id, { signal, onEvent, mode });
487
+ await enterStaging(ticketId, { signal, onEvent, mode });
491
488
  } else {
492
489
  // Human-in-the-loop: plan only, no writes yet. The card shows the
493
490
  // plan and waits for an explicit Approve before phase 2 runs.
494
491
  const result = await planTicket({ prompt, cwd: worktreeDir, onEvent, signal, agent });
495
492
  if (result.aborted) return;
496
- store.update(ticket.id, {
493
+ store.update(ticketId, {
497
494
  status: "staging",
498
495
  sessionId: result.sessionId,
499
496
  plan: result.lastMessage,
@@ -501,12 +498,75 @@ export function createServer() {
501
498
  });
502
499
  }
503
500
  } catch (err) {
504
- store.appendLog(ticket.id, { type: "error", text: String(err.message || err) });
505
- store.cancel(ticket.id, `Run failed: ${err.message || err}`);
506
- appendRunLog(store.get(ticket.id), `failed: ${String(err.message || err).split("\n")[0].slice(0, 60)}`);
501
+ store.appendLog(ticketId, { type: "error", text: String(err.message || err) });
502
+ store.cancel(ticketId, `Run failed: ${err.message || err}`);
503
+ appendRunLog(store.get(ticketId), `failed: ${String(err.message || err).split("\n")[0].slice(0, 60)}`);
507
504
  } finally {
508
- runs.end(ticket.id);
505
+ runs.end(ticketId);
509
506
  }
507
+ }
508
+
509
+ // Agent loop is hands-off from intake onward: a freshly created agent-mode
510
+ // ticket analyzes then runs on its own — the Telegram interview is the only
511
+ // human touch until the PR. A failed/cancelled analyze leaves it in Inbox
512
+ // rather than running blind. Human mode leaves it in Inbox for the buttons.
513
+ async function autoPipeline(ticketId) {
514
+ const analysis = await runAnalyze(ticketId);
515
+ if (!analysis?.ok) return;
516
+ if (store.get(ticketId)?.status !== "analyzed") return;
517
+ await runImplementation(ticketId);
518
+ }
519
+
520
+ // --- tickets ---
521
+
522
+ app.get("/api/tickets", (_req, res) => {
523
+ res.json(store.list());
524
+ });
525
+
526
+ app.post("/api/tickets", (req, res) => {
527
+ const { title, body, source, agentId, mode, priority, summary } = req.body;
528
+ if (!title?.trim()) return res.status(400).json({ error: "title is required" });
529
+ const ticket = store.create({ title: title.trim(), body: body ?? "", source, agentId, mode, priority, summary });
530
+ res.json(ticket);
531
+ if (resolveMode(ticket) === "agent") {
532
+ autoPipeline(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: `auto-pipeline: ${String(err.message || err)}` }));
533
+ }
534
+ });
535
+
536
+ app.post("/api/tickets/:id/analyze", (req, res) => {
537
+ const ticket = store.get(req.params.id);
538
+ if (!ticket) return res.status(404).json({ error: "not found" });
539
+ if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
540
+ res.json({ started: true });
541
+ runAnalyze(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: String(err.message || err) }));
542
+ });
543
+
544
+ app.post("/api/tickets/:id/mode", (req, res) => {
545
+ const { mode } = req.body; // "agent" | "human" | null (inherit board default)
546
+ if (mode !== null && mode !== "agent" && mode !== "human") {
547
+ return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
548
+ }
549
+ const ticket = store.update(req.params.id, { mode });
550
+ if (!ticket) return res.status(404).json({ error: "not found" });
551
+ res.json(ticket);
552
+ });
553
+
554
+ app.post("/api/tickets/:id/agent", (req, res) => {
555
+ const { agentId } = req.body;
556
+ if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
557
+ const ticket = store.update(req.params.id, { agentId: agentId ?? null });
558
+ if (!ticket) return res.status(404).json({ error: "not found" });
559
+ res.json(ticket);
560
+ });
561
+
562
+ app.post("/api/tickets/:id/run", (req, res) => {
563
+ const ticket = store.get(req.params.id);
564
+ if (!ticket) return res.status(404).json({ error: "not found" });
565
+ if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
566
+ const mode = resolveMode(ticket);
567
+ const agent = resolveAgent(ticket);
568
+ res.json({ started: true, mode, agentId: agent?.id ?? null });
569
+ runImplementation(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: String(err.message || err) }));
510
570
  });
511
571
 
512
572
  app.post("/api/tickets/:id/approve", async (req, res) => {
@@ -693,7 +753,7 @@ export function createServer() {
693
753
  // ouro already holding the port. Without it, a probe that gets a 200 only
694
754
  // proves someone is listening — not that the process we just spawned is
695
755
  // the one answering.
696
- res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), pid: process.pid, config });
756
+ res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), maxQaAttempts: getMaxQaAttempts(), pid: process.pid, config });
697
757
  });
698
758
 
699
759
  app.post("/api/config/backend", (req, res) => {