@splinterzzz/ouro 0.1.3 → 0.1.4

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-Btdawh-I.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.4",
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
@@ -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";
@@ -339,9 +339,10 @@ export function createServer() {
339
339
  appendRunLog(store.get(ticketId), shipOutcome(shipResult));
340
340
  return;
341
341
  }
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) {
342
+ // Not ready. Loop-stop guard: once we hit the configurable ceiling
343
+ // (maxQaAttempts) the ticket escalates to a human, regardless of mode —
344
+ // don't loop forever re-running the expensive engineer.
345
+ if (attempt >= getMaxQaAttempts()) {
345
346
  store.update(ticketId, { awaitingQa: true, escalated: true });
346
347
  appendRunLog(store.get(ticketId), `QA failed ${attempt}× — escalated to human`);
347
348
  return;
@@ -361,36 +362,29 @@ export function createServer() {
361
362
  }
362
363
  }
363
364
 
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" });
365
+ /**
366
+ * The read-only Analyst pass, factored out of its route so the auto-pipeline
367
+ * can chain it into a run without a second HTTP hop. Owns its own run
368
+ * registration and the transient `analyzing` flag, and returns `{ ok }` so a
369
+ * caller can decide whether to proceed — abort or a caught error is `ok:false`
370
+ * (the error is still logged to the ticket, as when a human clicked Analyze).
371
+ */
372
+ async function runAnalyze(ticketId) {
373
+ const ticket = store.get(ticketId);
374
+ if (!ticket || runs.isRunning(ticketId)) return { ok: false };
380
375
 
381
376
  let signal;
382
377
  try {
383
- signal = runs.begin(ticket.id, "analyze");
384
- } catch (err) {
385
- return res.status(409).json({ error: String(err.message || err) });
378
+ signal = runs.begin(ticketId, "analyze");
379
+ } catch {
380
+ return { ok: false };
386
381
  }
387
382
 
388
- res.json({ started: true });
389
383
  // Analyze is read-only and leaves the ticket where it is; a transient flag
390
384
  // drives the card's "Analyzing…" state without faking a worktree run.
391
- store.update(ticket.id, { analyzing: true });
385
+ store.update(ticketId, { analyzing: true });
392
386
 
393
- const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
387
+ const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
394
388
 
395
389
  try {
396
390
  // Always the Analyst agent for this step, regardless of the ticket's
@@ -406,8 +400,8 @@ export function createServer() {
406
400
  onEvent,
407
401
  agent: analyst,
408
402
  });
409
- if (signal.aborted) return; // cancelled mid-analysis — /cancel already reconciled it
410
- store.update(ticket.id, {
403
+ if (signal.aborted) return { ok: false }; // cancelled mid-analysis — /cancel already reconciled it
404
+ store.update(ticketId, {
411
405
  status: "analyzed",
412
406
  analyzing: false,
413
407
  summary: result.summary,
@@ -415,51 +409,39 @@ export function createServer() {
415
409
  filesLikelyAffected: Array.isArray(result.files_likely_affected) ? result.files_likely_affected : [],
416
410
  acceptanceCriteria: Array.isArray(result.acceptance_criteria) ? result.acceptance_criteria : [],
417
411
  });
412
+ return { ok: true };
418
413
  } catch (err) {
419
- store.update(ticket.id, { analyzing: false });
420
- store.appendLog(ticket.id, { type: "error", text: String(err.message || err) });
414
+ store.update(ticketId, { analyzing: false });
415
+ store.appendLog(ticketId, { type: "error", text: String(err.message || err) });
416
+ return { ok: false };
421
417
  } finally {
422
- runs.end(ticket.id);
423
- }
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" });
418
+ runs.end(ticketId);
430
419
  }
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
- });
420
+ }
443
421
 
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" });
422
+ /**
423
+ * The implementation run, factored out of its route for the same reason. Owns
424
+ * its own run registration, resolves mode/agent, resets the staging state to a
425
+ * clean QA budget, cuts the worktree, then either runs the agent straight into
426
+ * Staging (agent mode) or plans and waits for approval (human mode).
427
+ */
428
+ async function runImplementation(ticketId) {
429
+ const ticket = store.get(ticketId);
430
+ if (!ticket || runs.isRunning(ticketId)) return;
448
431
 
449
432
  const mode = resolveMode(ticket);
450
433
  const agent = resolveAgent(ticket);
451
434
 
452
435
  let signal;
453
436
  try {
454
- signal = runs.begin(ticket.id, mode);
455
- } catch (err) {
456
- return res.status(409).json({ error: String(err.message || err) });
437
+ signal = runs.begin(ticketId, mode);
438
+ } catch {
439
+ return;
457
440
  }
458
441
 
459
- res.json({ started: true, mode, agentId: agent?.id ?? null });
460
442
  // A fresh run resets the staging state so a re-run starts with a clean QA
461
443
  // budget (the loop-stop guard counts from here).
462
- store.update(ticket.id, {
444
+ store.update(ticketId, {
463
445
  status: "in_progress",
464
446
  mode,
465
447
  agentId: agent?.id ?? ticket.agentId,
@@ -471,11 +453,11 @@ export function createServer() {
471
453
  escalated: false,
472
454
  });
473
455
 
474
- const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
456
+ const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
475
457
 
476
458
  try {
477
- const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticket.id);
478
- store.update(ticket.id, { worktree: worktreeDir, branch, baseBranch: base });
459
+ const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticketId);
460
+ store.update(ticketId, { worktree: worktreeDir, branch, baseBranch: base });
479
461
 
480
462
  const prompt = buildImplementationPrompt(ticket);
481
463
 
@@ -483,17 +465,17 @@ export function createServer() {
483
465
  // Full autonomy, single call.
484
466
  const result = await runAgent({ prompt, cwd: worktreeDir, onEvent, signal, agent });
485
467
  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 });
468
+ const diff = await diffWorktree(ticketId).catch(() => null);
469
+ store.update(ticketId, { status: "staging", diff, sessionId: result.sessionId, awaitingApproval: false });
488
470
  // Validate in Staging before anything ships — the QA gate stands between
489
471
  // a finished run and the PR.
490
- await enterStaging(ticket.id, { signal, onEvent, mode });
472
+ await enterStaging(ticketId, { signal, onEvent, mode });
491
473
  } else {
492
474
  // Human-in-the-loop: plan only, no writes yet. The card shows the
493
475
  // plan and waits for an explicit Approve before phase 2 runs.
494
476
  const result = await planTicket({ prompt, cwd: worktreeDir, onEvent, signal, agent });
495
477
  if (result.aborted) return;
496
- store.update(ticket.id, {
478
+ store.update(ticketId, {
497
479
  status: "staging",
498
480
  sessionId: result.sessionId,
499
481
  plan: result.lastMessage,
@@ -501,14 +483,77 @@ export function createServer() {
501
483
  });
502
484
  }
503
485
  } 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)}`);
486
+ store.appendLog(ticketId, { type: "error", text: String(err.message || err) });
487
+ store.cancel(ticketId, `Run failed: ${err.message || err}`);
488
+ appendRunLog(store.get(ticketId), `failed: ${String(err.message || err).split("\n")[0].slice(0, 60)}`);
507
489
  } finally {
508
- runs.end(ticket.id);
490
+ runs.end(ticketId);
491
+ }
492
+ }
493
+
494
+ // Agent loop is hands-off from intake onward: a freshly created agent-mode
495
+ // ticket analyzes then runs on its own — the Telegram interview is the only
496
+ // human touch until the PR. A failed/cancelled analyze leaves it in Inbox
497
+ // rather than running blind. Human mode leaves it in Inbox for the buttons.
498
+ async function autoPipeline(ticketId) {
499
+ const analysis = await runAnalyze(ticketId);
500
+ if (!analysis?.ok) return;
501
+ if (store.get(ticketId)?.status !== "analyzed") return;
502
+ await runImplementation(ticketId);
503
+ }
504
+
505
+ // --- tickets ---
506
+
507
+ app.get("/api/tickets", (_req, res) => {
508
+ res.json(store.list());
509
+ });
510
+
511
+ app.post("/api/tickets", (req, res) => {
512
+ const { title, body, source, agentId, mode, priority, summary } = req.body;
513
+ if (!title?.trim()) return res.status(400).json({ error: "title is required" });
514
+ const ticket = store.create({ title: title.trim(), body: body ?? "", source, agentId, mode, priority, summary });
515
+ res.json(ticket);
516
+ if (resolveMode(ticket) === "agent") {
517
+ autoPipeline(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: `auto-pipeline: ${String(err.message || err)}` }));
509
518
  }
510
519
  });
511
520
 
521
+ app.post("/api/tickets/:id/analyze", (req, res) => {
522
+ const ticket = store.get(req.params.id);
523
+ if (!ticket) return res.status(404).json({ error: "not found" });
524
+ if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
525
+ res.json({ started: true });
526
+ runAnalyze(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: String(err.message || err) }));
527
+ });
528
+
529
+ app.post("/api/tickets/:id/mode", (req, res) => {
530
+ const { mode } = req.body; // "agent" | "human" | null (inherit board default)
531
+ if (mode !== null && mode !== "agent" && mode !== "human") {
532
+ return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
533
+ }
534
+ const ticket = store.update(req.params.id, { mode });
535
+ if (!ticket) return res.status(404).json({ error: "not found" });
536
+ res.json(ticket);
537
+ });
538
+
539
+ app.post("/api/tickets/:id/agent", (req, res) => {
540
+ const { agentId } = req.body;
541
+ if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
542
+ const ticket = store.update(req.params.id, { agentId: agentId ?? null });
543
+ if (!ticket) return res.status(404).json({ error: "not found" });
544
+ res.json(ticket);
545
+ });
546
+
547
+ app.post("/api/tickets/:id/run", (req, res) => {
548
+ const ticket = store.get(req.params.id);
549
+ if (!ticket) return res.status(404).json({ error: "not found" });
550
+ if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
551
+ const mode = resolveMode(ticket);
552
+ const agent = resolveAgent(ticket);
553
+ res.json({ started: true, mode, agentId: agent?.id ?? null });
554
+ runImplementation(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: String(err.message || err) }));
555
+ });
556
+
512
557
  app.post("/api/tickets/:id/approve", async (req, res) => {
513
558
  const ticket = store.get(req.params.id);
514
559
  if (!ticket) return res.status(404).json({ error: "not found" });
@@ -693,7 +738,7 @@ export function createServer() {
693
738
  // ouro already holding the port. Without it, a probe that gets a 200 only
694
739
  // proves someone is listening — not that the process we just spawned is
695
740
  // the one answering.
696
- res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), pid: process.pid, config });
741
+ res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), maxQaAttempts: getMaxQaAttempts(), pid: process.pid, config });
697
742
  });
698
743
 
699
744
  app.post("/api/config/backend", (req, res) => {