@splinterzzz/ouro 0.1.2 → 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-B-lZ4RcX.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.2",
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/agents.js CHANGED
@@ -187,16 +187,22 @@ const SEEDS = [
187
187
  name: "Senior Engineer",
188
188
  glyph: "◆",
189
189
  description: "Ships production changes with minimal, well-tested diffs.",
190
- model: DEFAULT_MODEL,
191
- tools: ["Read", "Grep", "Glob", "Edit", "Write", "Bash"],
190
+ model: "opus",
191
+ tools: ["Read", "Grep", "Glob", "Edit", "Write", "Bash", "*"],
192
192
  },
193
- body: `You are a senior engineer working in an isolated git worktree.
193
+ body: `You are a senior engineer working in an isolated git worktree, and you orchestrate — you do not do every edit yourself.
194
+
195
+ Orchestration, before you touch anything:
196
+ 1. Break the ticket into subtasks. Independent ones (separate components, unrelated modules — nothing that shares state or a file with anything else in flight) are delegation candidates; anything ambiguous, architectural, or touching shared state is yours to do directly.
197
+ 2. Dispatch each independent subtask to a Task subagent rather than editing it yourself. If the Task tool exposes a model choice, request sonnet or haiku for these — applying a known, scoped change to one file is mechanical execution, not a judgment call, and doesn't need your tier. If it doesn't expose model choice, dispatch anyway — the parallelism is the primary win, the cost saving is secondary.
198
+ 3. Do the judgment work yourself: resolving the ticket's ambiguity, anything touching shared state, and the final integration once subagents land.
199
+ 4. Keep verification (typecheck/lint/tests) serial, after every subagent lands — never per-subtask, and never delegated.
194
200
 
195
201
  Work to these standards:
196
- - Read the surrounding code before you edit it. Match its idiom, naming, and comment density.
202
+ - Read the surrounding code before you edit it (yourself, or brief a subagent to). Match its idiom, naming, and comment density.
197
203
  - Prefer the smallest diff that fully solves the ticket. No drive-by refactors.
198
204
  - Never delete or weaken a test to make something pass.
199
- - Run the relevant tests before you call the work done, and report what you ran.
205
+ - Verify with the fastest check that actually proves correctness: typecheck + lint first. Only run a full production build if the ticket specifically needs to validate build output — tsc and lint already catch what a build would, slower.
200
206
  - If the ticket is ambiguous, state the assumption you made in your final message rather than guessing silently.`,
201
207
  },
202
208
  {
@@ -205,17 +211,18 @@ Work to these standards:
205
211
  name: "Bug Fixer",
206
212
  glyph: "▲",
207
213
  description: "Reproduces first, then fixes the root cause — not the symptom.",
208
- model: DEFAULT_MODEL,
209
- tools: ["Read", "Grep", "Glob", "Edit", "Write", "Bash"],
214
+ model: "opus",
215
+ tools: ["Read", "Grep", "Glob", "Edit", "Write", "Bash", "*"],
210
216
  },
211
- body: `You are a debugging specialist working in an isolated git worktree.
217
+ body: `You are a debugging specialist working in an isolated git worktree, and you orchestrate — evidence-gathering delegates, diagnosis doesn't.
212
218
 
213
219
  Method, in order:
214
220
  1. Reproduce the bug and state the exact failing behaviour you observed.
215
- 2. Find the root cause. Trace it — do not pattern-match a plausible-looking fix.
216
- 3. Fix the cause, not the symptom. If the real fix is out of scope, say so explicitly.
217
- 4. Add or extend a test that fails before your change and passes after it.
218
- 5. Report the reproduction, the cause, and the fix separately in your final message.`,
221
+ 2. Find the root cause. Trace it — do not pattern-match a plausible-looking fix. If more than one cause is plausible, dispatch a Task subagent per hypothesis to gather evidence for or against it in parallel, rather than chasing them one at a time. If the Task tool exposes a model choice, request sonnet or haiku for these — each is a bounded, scoped investigation, not the diagnosis itself.
222
+ 3. Weigh the evidence and decide the real cause yourself a subagent reports what it found, it does not get to conclude the diagnosis.
223
+ 4. Fix the cause, not the symptom. If the real fix is out of scope, say so explicitly.
224
+ 5. Add or extend a test that fails before your change and passes after it.
225
+ 6. Report the reproduction, the cause, and the fix separately in your final message.`,
219
226
  },
220
227
  {
221
228
  id: "reviewer",
@@ -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.
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;
@@ -73,15 +73,41 @@ function waitForPort(port, timeoutMs = 15000) {
73
73
  });
74
74
  }
75
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
+
76
100
  /**
77
101
  * Start (or restart) the preview for a ticket. Returns a status object; never
78
102
  * throws. `started:false` means nothing resolved — the card shows "no preview"
79
- * and the stage goes on.
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.
80
106
  */
81
107
  export async function startPreview(ticketId, { cwd, signal } = {}) {
82
108
  stopPreview(ticketId); // never two for one ticket
83
109
 
84
- const { command, port, source } = await resolvePreview({ cwd, signal });
110
+ const { command, port: guessedPort, source } = await resolvePreview({ cwd, signal });
85
111
  if (!command) return { started: false, reason: "no preview command resolved", source };
86
112
 
87
113
  let proc;
@@ -91,13 +117,21 @@ export async function startPreview(ticketId, { cwd, signal } = {}) {
91
117
  return { started: false, reason: `spawn failed: ${err.message || err}`, source };
92
118
  }
93
119
  proc.on("error", () => {}); // a preview that fails to launch must not crash the stage
94
- proc.stdout?.on("data", () => {});
95
120
  proc.stderr?.on("data", () => {});
96
121
 
97
- const url = port ? `http://localhost:${port}` : null;
98
- previews.set(ticketId, { proc, url, command, port });
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;
99
133
 
100
- const reachable = port ? await waitForPort(port) : false;
134
+ previews.set(ticketId, { proc, url, command, port });
101
135
  return { started: true, url, command, port, source, reachable };
102
136
  }
103
137
 
@@ -9,8 +9,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
11
  import { runTests } from "../lib/staging.js";
12
- import { startPreview, stopPreview, previewInfo, stopAllPreviews } from "../lib/preview.js";
13
- import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, telegramTokenVar } from "../lib/config.js";
12
+ import { startPreview, stopPreview, previewInfo } from "../lib/preview.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";
@@ -289,9 +289,17 @@ export function createServer() {
289
289
 
290
290
  // Preview once, reused across QA attempts; torn down when the ticket leaves.
291
291
  const previewStatus = await startPreview(ticketId, { cwd: base.worktree, signal }).catch(() => null);
292
+ // Only surface a URL once it's confirmed reachable — an unreachable one
293
+ // (or a guessed port that was never actually confirmed) is worse than no
294
+ // link, since it silently points at nothing or, worse, someone else's
295
+ // server that happened to already be on that port.
292
296
  store.update(ticketId, {
293
- previewUrl: previewStatus?.url ?? null,
294
- previewNote: previewStatus?.started ? null : "no preview configured",
297
+ previewUrl: previewStatus?.reachable ? previewStatus.url : null,
298
+ previewNote: !previewStatus?.started
299
+ ? "no preview configured"
300
+ : previewStatus.reachable
301
+ ? null
302
+ : "preview didn't come up in time",
295
303
  });
296
304
 
297
305
  while (!signal.aborted) {
@@ -331,9 +339,10 @@ export function createServer() {
331
339
  appendRunLog(store.get(ticketId), shipOutcome(shipResult));
332
340
  return;
333
341
  }
334
- // Not ready. Loop-stop guard: a 2nd failure escalates to a human,
335
- // regardless of mode — don't loop forever.
336
- 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()) {
337
346
  store.update(ticketId, { awaitingQa: true, escalated: true });
338
347
  appendRunLog(store.get(ticketId), `QA failed ${attempt}× — escalated to human`);
339
348
  return;
@@ -353,36 +362,29 @@ export function createServer() {
353
362
  }
354
363
  }
355
364
 
356
- // --- tickets ---
357
-
358
- app.get("/api/tickets", (_req, res) => {
359
- res.json(store.list());
360
- });
361
-
362
- app.post("/api/tickets", (req, res) => {
363
- const { title, body, source, agentId, mode, priority, summary } = req.body;
364
- if (!title?.trim()) return res.status(400).json({ error: "title is required" });
365
- res.json(store.create({ title: title.trim(), body: body ?? "", source, agentId, mode, priority, summary }));
366
- });
367
-
368
- app.post("/api/tickets/:id/analyze", async (req, res) => {
369
- const ticket = store.get(req.params.id);
370
- if (!ticket) return res.status(404).json({ error: "not found" });
371
- 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 };
372
375
 
373
376
  let signal;
374
377
  try {
375
- signal = runs.begin(ticket.id, "analyze");
376
- } catch (err) {
377
- return res.status(409).json({ error: String(err.message || err) });
378
+ signal = runs.begin(ticketId, "analyze");
379
+ } catch {
380
+ return { ok: false };
378
381
  }
379
382
 
380
- res.json({ started: true });
381
383
  // Analyze is read-only and leaves the ticket where it is; a transient flag
382
384
  // drives the card's "Analyzing…" state without faking a worktree run.
383
- store.update(ticket.id, { analyzing: true });
385
+ store.update(ticketId, { analyzing: true });
384
386
 
385
- const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
387
+ const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
386
388
 
387
389
  try {
388
390
  // Always the Analyst agent for this step, regardless of the ticket's
@@ -398,8 +400,8 @@ export function createServer() {
398
400
  onEvent,
399
401
  agent: analyst,
400
402
  });
401
- if (signal.aborted) return; // cancelled mid-analysis — /cancel already reconciled it
402
- store.update(ticket.id, {
403
+ if (signal.aborted) return { ok: false }; // cancelled mid-analysis — /cancel already reconciled it
404
+ store.update(ticketId, {
403
405
  status: "analyzed",
404
406
  analyzing: false,
405
407
  summary: result.summary,
@@ -407,51 +409,39 @@ export function createServer() {
407
409
  filesLikelyAffected: Array.isArray(result.files_likely_affected) ? result.files_likely_affected : [],
408
410
  acceptanceCriteria: Array.isArray(result.acceptance_criteria) ? result.acceptance_criteria : [],
409
411
  });
412
+ return { ok: true };
410
413
  } catch (err) {
411
- store.update(ticket.id, { analyzing: false });
412
- 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 };
413
417
  } finally {
414
- runs.end(ticket.id);
415
- }
416
- });
417
-
418
- app.post("/api/tickets/:id/mode", (req, res) => {
419
- const { mode } = req.body; // "agent" | "human" | null (inherit board default)
420
- if (mode !== null && mode !== "agent" && mode !== "human") {
421
- return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
418
+ runs.end(ticketId);
422
419
  }
423
- const ticket = store.update(req.params.id, { mode });
424
- if (!ticket) return res.status(404).json({ error: "not found" });
425
- res.json(ticket);
426
- });
427
-
428
- app.post("/api/tickets/:id/agent", (req, res) => {
429
- const { agentId } = req.body;
430
- if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
431
- const ticket = store.update(req.params.id, { agentId: agentId ?? null });
432
- if (!ticket) return res.status(404).json({ error: "not found" });
433
- res.json(ticket);
434
- });
420
+ }
435
421
 
436
- app.post("/api/tickets/:id/run", async (req, res) => {
437
- const ticket = store.get(req.params.id);
438
- if (!ticket) return res.status(404).json({ error: "not found" });
439
- 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;
440
431
 
441
432
  const mode = resolveMode(ticket);
442
433
  const agent = resolveAgent(ticket);
443
434
 
444
435
  let signal;
445
436
  try {
446
- signal = runs.begin(ticket.id, mode);
447
- } catch (err) {
448
- return res.status(409).json({ error: String(err.message || err) });
437
+ signal = runs.begin(ticketId, mode);
438
+ } catch {
439
+ return;
449
440
  }
450
441
 
451
- res.json({ started: true, mode, agentId: agent?.id ?? null });
452
442
  // A fresh run resets the staging state so a re-run starts with a clean QA
453
443
  // budget (the loop-stop guard counts from here).
454
- store.update(ticket.id, {
444
+ store.update(ticketId, {
455
445
  status: "in_progress",
456
446
  mode,
457
447
  agentId: agent?.id ?? ticket.agentId,
@@ -463,11 +453,11 @@ export function createServer() {
463
453
  escalated: false,
464
454
  });
465
455
 
466
- const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
456
+ const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
467
457
 
468
458
  try {
469
- const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticket.id);
470
- 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 });
471
461
 
472
462
  const prompt = buildImplementationPrompt(ticket);
473
463
 
@@ -475,17 +465,17 @@ export function createServer() {
475
465
  // Full autonomy, single call.
476
466
  const result = await runAgent({ prompt, cwd: worktreeDir, onEvent, signal, agent });
477
467
  if (result.aborted) return; // cancel route already marked it
478
- const diff = await diffWorktree(ticket.id).catch(() => null);
479
- 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 });
480
470
  // Validate in Staging before anything ships — the QA gate stands between
481
471
  // a finished run and the PR.
482
- await enterStaging(ticket.id, { signal, onEvent, mode });
472
+ await enterStaging(ticketId, { signal, onEvent, mode });
483
473
  } else {
484
474
  // Human-in-the-loop: plan only, no writes yet. The card shows the
485
475
  // plan and waits for an explicit Approve before phase 2 runs.
486
476
  const result = await planTicket({ prompt, cwd: worktreeDir, onEvent, signal, agent });
487
477
  if (result.aborted) return;
488
- store.update(ticket.id, {
478
+ store.update(ticketId, {
489
479
  status: "staging",
490
480
  sessionId: result.sessionId,
491
481
  plan: result.lastMessage,
@@ -493,14 +483,77 @@ export function createServer() {
493
483
  });
494
484
  }
495
485
  } catch (err) {
496
- store.appendLog(ticket.id, { type: "error", text: String(err.message || err) });
497
- store.cancel(ticket.id, `Run failed: ${err.message || err}`);
498
- 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)}`);
499
489
  } finally {
500
- 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)}` }));
501
518
  }
502
519
  });
503
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
+
504
557
  app.post("/api/tickets/:id/approve", async (req, res) => {
505
558
  const ticket = store.get(req.params.id);
506
559
  if (!ticket) return res.status(404).json({ error: "not found" });
@@ -685,7 +738,7 @@ export function createServer() {
685
738
  // ouro already holding the port. Without it, a probe that gets a 200 only
686
739
  // proves someone is listening — not that the process we just spawned is
687
740
  // the one answering.
688
- 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 });
689
742
  });
690
743
 
691
744
  app.post("/api/config/backend", (req, res) => {