@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.
- package/dashboard-dist/assets/index-Btdawh-I.js +64 -0
- package/dashboard-dist/index.html +1 -1
- package/package.json +1 -1
- package/src/lib/agents.js +19 -12
- package/src/lib/claudeCodeExec.js +7 -1
- package/src/lib/config.js +9 -0
- package/src/lib/daemon.js +16 -1
- package/src/lib/preview.js +40 -6
- package/src/server/index.js +127 -74
- package/dashboard-dist/assets/index-B-lZ4RcX.js +0 -64
|
@@ -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-
|
|
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
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:
|
|
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
|
-
-
|
|
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:
|
|
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.
|
|
217
|
-
4.
|
|
218
|
-
5.
|
|
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
|
-
/**
|
|
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;
|
package/src/lib/preview.js
CHANGED
|
@@ -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
|
|
98
|
-
|
|
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
|
-
|
|
134
|
+
previews.set(ticketId, { proc, url, command, port });
|
|
101
135
|
return { started: true, url, command, port, source, reachable };
|
|
102
136
|
}
|
|
103
137
|
|
package/src/server/index.js
CHANGED
|
@@ -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
|
|
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
|
|
294
|
-
previewNote: previewStatus?.started
|
|
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:
|
|
335
|
-
// regardless of mode —
|
|
336
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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(
|
|
376
|
-
} catch
|
|
377
|
-
return
|
|
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(
|
|
385
|
+
store.update(ticketId, { analyzing: true });
|
|
384
386
|
|
|
385
|
-
const onEvent = (event) => store.appendLog(
|
|
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(
|
|
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(
|
|
412
|
-
store.appendLog(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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(
|
|
447
|
-
} catch
|
|
448
|
-
return
|
|
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(
|
|
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(
|
|
456
|
+
const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
|
|
467
457
|
|
|
468
458
|
try {
|
|
469
|
-
const { dir: worktreeDir, branch, base } = await createTicketWorktree(
|
|
470
|
-
store.update(
|
|
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(
|
|
479
|
-
store.update(
|
|
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(
|
|
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(
|
|
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(
|
|
497
|
-
store.cancel(
|
|
498
|
-
appendRunLog(store.get(
|
|
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(
|
|
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) => {
|