@splinterzzz/ouro 0.1.0
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/LICENSE +21 -0
- package/dashboard-dist/assets/index-ETSLKv6w.css +1 -0
- package/dashboard-dist/assets/index-UHUWnWxM.js +63 -0
- package/dashboard-dist/favicon.svg +35 -0
- package/dashboard-dist/index.html +18 -0
- package/package.json +54 -0
- package/src/commands/dashboard.js +50 -0
- package/src/commands/init.js +75 -0
- package/src/commands/listen.js +164 -0
- package/src/commands/logs.js +91 -0
- package/src/commands/start.js +158 -0
- package/src/commands/status.js +69 -0
- package/src/commands/stop.js +39 -0
- package/src/index.js +81 -0
- package/src/lib/agentBackend.js +41 -0
- package/src/lib/agents.js +251 -0
- package/src/lib/claudeCodeExec.js +213 -0
- package/src/lib/codexExec.js +204 -0
- package/src/lib/config.js +91 -0
- package/src/lib/daemon.js +235 -0
- package/src/lib/env.js +135 -0
- package/src/lib/frontmatter.js +114 -0
- package/src/lib/github.js +60 -0
- package/src/lib/intake.js +151 -0
- package/src/lib/paths.js +57 -0
- package/src/lib/runs.js +55 -0
- package/src/lib/ship.js +114 -0
- package/src/lib/store.js +188 -0
- package/src/lib/telegram.js +60 -0
- package/src/lib/worktree.js +93 -0
- package/src/server/index.js +457 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Minimal YAML-frontmatter parser/serializer.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately not gray-matter: agent files are authored by ouro itself and
|
|
4
|
+
// only ever carry scalars and flat string lists, so a ~60-line reader beats a
|
|
5
|
+
// dependency here. It handles exactly what `.ouro/agents/*.md` can contain:
|
|
6
|
+
//
|
|
7
|
+
// ---
|
|
8
|
+
// name: Senior Engineer
|
|
9
|
+
// tools: [Read, Edit, Write] # inline list
|
|
10
|
+
// aliases: # or block list
|
|
11
|
+
// - se
|
|
12
|
+
// ---
|
|
13
|
+
// <body — the agent's system prompt>
|
|
14
|
+
//
|
|
15
|
+
// Anything richer (nested maps, multi-line scalars, anchors) is out of scope
|
|
16
|
+
// and parses as a plain string rather than throwing, so a hand-edited file can
|
|
17
|
+
// never take the dashboard down.
|
|
18
|
+
|
|
19
|
+
const FENCE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
20
|
+
|
|
21
|
+
function stripQuotes(value) {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (trimmed.length >= 2) {
|
|
24
|
+
const first = trimmed[0];
|
|
25
|
+
const last = trimmed[trimmed.length - 1];
|
|
26
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
27
|
+
return trimmed.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return trimmed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function coerce(value) {
|
|
34
|
+
const raw = value.trim();
|
|
35
|
+
if (raw === "") return "";
|
|
36
|
+
if (raw === "true") return true;
|
|
37
|
+
if (raw === "false") return false;
|
|
38
|
+
if (raw === "null" || raw === "~") return null;
|
|
39
|
+
// Inline list: [a, b, c]
|
|
40
|
+
if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
41
|
+
const inner = raw.slice(1, -1).trim();
|
|
42
|
+
if (!inner) return [];
|
|
43
|
+
return inner.split(",").map((item) => stripQuotes(item)).filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
// Unquoted numbers only — a quoted "42" stays a string on purpose.
|
|
46
|
+
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
|
|
47
|
+
return stripQuotes(raw);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Splits a markdown file into `{ data, body }`. A file with no frontmatter
|
|
52
|
+
* fence is treated as all-body, so a bare prompt file still loads.
|
|
53
|
+
*/
|
|
54
|
+
export function parseFrontmatter(text) {
|
|
55
|
+
const source = String(text ?? "");
|
|
56
|
+
const match = source.match(FENCE);
|
|
57
|
+
if (!match) return { data: {}, body: source.trim() };
|
|
58
|
+
|
|
59
|
+
const data = {};
|
|
60
|
+
const lines = match[1].split(/\r?\n/);
|
|
61
|
+
let blockKey = null; // set while consuming a `key:` followed by `- item` lines
|
|
62
|
+
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
65
|
+
|
|
66
|
+
const blockItem = line.match(/^\s*-\s+(.*)$/);
|
|
67
|
+
if (blockItem && blockKey) {
|
|
68
|
+
data[blockKey].push(stripQuotes(blockItem[1]));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pair = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
|
|
73
|
+
if (!pair) continue;
|
|
74
|
+
|
|
75
|
+
const [, key, rest] = pair;
|
|
76
|
+
if (rest.trim() === "") {
|
|
77
|
+
// `key:` with nothing after it opens a block list; if no `- item` lines
|
|
78
|
+
// follow, it collapses to an empty array, which is the sane reading.
|
|
79
|
+
blockKey = key;
|
|
80
|
+
data[key] = [];
|
|
81
|
+
} else {
|
|
82
|
+
blockKey = null;
|
|
83
|
+
data[key] = coerce(rest);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { data, body: source.slice(match[0].length).trim() };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function serializeValue(value) {
|
|
91
|
+
if (Array.isArray(value)) return `[${value.join(", ")}]`;
|
|
92
|
+
if (value === null || value === undefined) return "";
|
|
93
|
+
// Numbers and booleans must stay bare — quoting them would round-trip back
|
|
94
|
+
// as strings, and a `maxTurns` that reads as "40" instead of 40 fails the
|
|
95
|
+
// Number.isFinite check in agents.js and silently drops the value.
|
|
96
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
97
|
+
|
|
98
|
+
const str = String(value);
|
|
99
|
+
// Quote only when a bare scalar would re-parse as something other than the
|
|
100
|
+
// string it is: a leading YAML sigil, or an embedded `: ` that would look
|
|
101
|
+
// like a nested key.
|
|
102
|
+
if (str === "" || /^[[\]{}#&*!|>'"%@`-]/.test(str) || /^-?\d+(\.\d+)?$/.test(str) || str.includes(": ")) {
|
|
103
|
+
return JSON.stringify(str);
|
|
104
|
+
}
|
|
105
|
+
return str;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Inverse of parseFrontmatter — key order follows the object's own order. */
|
|
109
|
+
export function stringifyFrontmatter(data, body) {
|
|
110
|
+
const lines = Object.entries(data)
|
|
111
|
+
.filter(([, value]) => value !== undefined)
|
|
112
|
+
.map(([key, value]) => `${key}: ${serializeValue(value)}`);
|
|
113
|
+
return `---\n${lines.join("\n")}\n---\n\n${String(body ?? "").trim()}\n`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Thin `gh` wrapper. Shelling out to the user's authenticated gh beats talking
|
|
4
|
+
// to the GitHub API directly: it means ouro never handles a GitHub token, and
|
|
5
|
+
// it inherits whatever auth, host, and enterprise config they already use.
|
|
6
|
+
|
|
7
|
+
const GH_BIN = process.env.OURO_GH_BIN || "gh";
|
|
8
|
+
|
|
9
|
+
function run(args, { cwd } = {}) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let proc;
|
|
12
|
+
try {
|
|
13
|
+
proc = spawn(GH_BIN, args, { cwd, env: process.env });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
resolve({ code: -1, stdout: "", stderr: String(err.message || err) });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let stdout = "";
|
|
20
|
+
let stderr = "";
|
|
21
|
+
proc.stdout.on("data", (c) => (stdout += c));
|
|
22
|
+
proc.stderr.on("data", (c) => (stderr += c));
|
|
23
|
+
// ENOENT (gh not installed) lands here, not on close.
|
|
24
|
+
proc.on("error", (err) => resolve({ code: -1, stdout, stderr: String(err.message || err) }));
|
|
25
|
+
proc.on("close", (code) => resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() }));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function hasGh() {
|
|
30
|
+
return (await run(["--version"])).code === 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function isAuthenticated() {
|
|
34
|
+
return (await run(["auth", "status"])).code === 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Opens a PR from an already-pushed branch. Returns `{ url }` on success, or
|
|
39
|
+
* `{ error }` — never throws, because a failed PR must not lose the work that
|
|
40
|
+
* was already committed and pushed.
|
|
41
|
+
*/
|
|
42
|
+
export async function createPullRequest({ cwd, base, head, title, body }) {
|
|
43
|
+
const args = ["pr", "create", "--head", head, "--title", title, "--body", body];
|
|
44
|
+
if (base) args.push("--base", base);
|
|
45
|
+
|
|
46
|
+
const res = await run(args, { cwd });
|
|
47
|
+
|
|
48
|
+
if (res.code === 0) {
|
|
49
|
+
// gh prints the PR URL on stdout as its last line.
|
|
50
|
+
const url = res.stdout.split(/\s+/).find((t) => t.startsWith("http"));
|
|
51
|
+
return { url: url ?? res.stdout };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A PR that already exists isn't a failure — surface the existing one.
|
|
55
|
+
const combined = `${res.stderr}\n${res.stdout}`;
|
|
56
|
+
const existing = combined.match(/https:\/\/\S*\/pull\/\d+/);
|
|
57
|
+
if (existing) return { url: existing[0], existed: true };
|
|
58
|
+
|
|
59
|
+
return { error: res.stderr || res.stdout || `gh exited ${res.code}` };
|
|
60
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { askJson } from "./agentBackend.js";
|
|
2
|
+
|
|
3
|
+
// The Telegram intake agent.
|
|
4
|
+
//
|
|
5
|
+
// A raw message is not a ticket. "the login button is broken" has no repro, no
|
|
6
|
+
// platform, no severity — triaging it produces a confident-looking card built
|
|
7
|
+
// on nothing. So the bot behaves like a support agent instead of a webhook: it
|
|
8
|
+
// interviews the reporter until it can write a ticket an engineer could
|
|
9
|
+
// actually action, confirms the draft, and only then posts to the dashboard.
|
|
10
|
+
//
|
|
11
|
+
// State is per-chat and in memory. `ouro listen` is a restartable side process
|
|
12
|
+
// by design (see commands/listen.js) — losing a half-finished interview on
|
|
13
|
+
// restart is the right trade for not owning a database.
|
|
14
|
+
|
|
15
|
+
const MAX_QUESTIONS = 4; // hard stop; past this we draft from what we have
|
|
16
|
+
const MAX_TRANSCRIPT = 24; // messages retained per chat
|
|
17
|
+
|
|
18
|
+
const sessions = new Map(); // chatId -> { messages, questionCount, draft }
|
|
19
|
+
|
|
20
|
+
function session(chatId) {
|
|
21
|
+
if (!sessions.has(chatId)) {
|
|
22
|
+
sessions.set(chatId, { messages: [], questionCount: 0, draft: null });
|
|
23
|
+
}
|
|
24
|
+
return sessions.get(chatId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function reset(chatId) {
|
|
28
|
+
sessions.delete(chatId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasSession(chatId) {
|
|
32
|
+
return sessions.has(chatId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function record(state, role, text) {
|
|
36
|
+
state.messages.push({ role, text });
|
|
37
|
+
if (state.messages.length > MAX_TRANSCRIPT) {
|
|
38
|
+
state.messages.splice(0, state.messages.length - MAX_TRANSCRIPT);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function transcript(state) {
|
|
43
|
+
return state.messages.map((m) => `${m.role === "user" ? "Reporter" : "You"}: ${m.text}`).join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SYSTEM = `You are the intake agent for an engineering team. You are talking to someone over Telegram who wants something fixed or built. You are NOT the engineer — your only job is to understand the request well enough that an engineer could pick it up cold.
|
|
47
|
+
|
|
48
|
+
A good ticket answers: what is the observed behaviour, what was expected, where does it happen (page/screen/command/platform), how do you reproduce it, and how badly does it hurt. A feature request answers: what outcome does the user want, for whom, and what does done look like.
|
|
49
|
+
|
|
50
|
+
Ask about what is MISSING and would actually change the work. Never ask something the reporter already told you. Never ask for information an engineer could find faster by reading the code. One question at a time, short and specific — this is a chat, not a form.
|
|
51
|
+
|
|
52
|
+
When you could write a ticket an engineer would not have to come back and ask about, stop asking and draft it. Vague-but-small is fine to draft; vague-and-large is not.`;
|
|
53
|
+
|
|
54
|
+
const DECISION_CONTRACT = `Reply with ONLY a JSON object, no prose and no markdown fences, in exactly one of these two shapes:
|
|
55
|
+
|
|
56
|
+
{"action": "ask", "question": "<one short clarifying question>"}
|
|
57
|
+
|
|
58
|
+
{"action": "draft", "title": "<imperative, <=70 chars>", "body": "<the full ticket: observed, expected, repro steps, environment, impact — as much as the reporter gave you>", "summary": "<one sentence>", "priority": "low"|"medium"|"high"}`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decides the next move for a chat: ask another question, or draft the ticket.
|
|
62
|
+
* Falls back to drafting from the transcript if the model returns unparseable
|
|
63
|
+
* output — a reporter should never get stuck in an interview that can't end.
|
|
64
|
+
*/
|
|
65
|
+
export async function next(chatId, userText, { cwd = process.cwd() } = {}) {
|
|
66
|
+
const state = session(chatId);
|
|
67
|
+
record(state, "user", userText);
|
|
68
|
+
|
|
69
|
+
const forceDraft = state.questionCount >= MAX_QUESTIONS;
|
|
70
|
+
|
|
71
|
+
const prompt = `${SYSTEM}
|
|
72
|
+
|
|
73
|
+
Conversation so far:
|
|
74
|
+
${transcript(state)}
|
|
75
|
+
|
|
76
|
+
${
|
|
77
|
+
forceDraft
|
|
78
|
+
? `You have already asked ${state.questionCount} questions — the limit. Draft the ticket now from what you have, and note explicitly in the body whatever is still unknown.`
|
|
79
|
+
: `You have asked ${state.questionCount} of a maximum ${MAX_QUESTIONS} questions.`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
${DECISION_CONTRACT}`;
|
|
83
|
+
|
|
84
|
+
// A model that's unreachable, unparseable, or off-contract must not strand
|
|
85
|
+
// the reporter mid-interview — fall back to drafting the raw transcript, and
|
|
86
|
+
// flag it so the bot can say the ticket wasn't properly interviewed.
|
|
87
|
+
let decision = null;
|
|
88
|
+
try {
|
|
89
|
+
decision = await askJson({ prompt, cwd });
|
|
90
|
+
} catch {
|
|
91
|
+
return draftFallback(state);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!decision || (decision.action !== "ask" && decision.action !== "draft")) {
|
|
95
|
+
return draftFallback(state);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (decision.action === "ask" && !forceDraft) {
|
|
99
|
+
const question = String(decision.question ?? "").trim();
|
|
100
|
+
if (!question) return draftFallback(state);
|
|
101
|
+
state.questionCount += 1;
|
|
102
|
+
record(state, "agent", question);
|
|
103
|
+
return { action: "ask", question };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const draft = {
|
|
107
|
+
title: String(decision.title ?? "").trim().slice(0, 80) || firstLine(state),
|
|
108
|
+
body: String(decision.body ?? "").trim() || transcript(state),
|
|
109
|
+
summary: String(decision.summary ?? "").trim() || null,
|
|
110
|
+
priority: ["low", "medium", "high"].includes(decision.priority) ? decision.priority : "medium",
|
|
111
|
+
};
|
|
112
|
+
state.draft = draft;
|
|
113
|
+
return { action: "draft", draft };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function firstLine(state) {
|
|
117
|
+
const first = state.messages.find((m) => m.role === "user");
|
|
118
|
+
return (first?.text ?? "Untitled ticket").slice(0, 80);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function draftFallback(state) {
|
|
122
|
+
const draft = {
|
|
123
|
+
title: firstLine(state),
|
|
124
|
+
body: transcript(state),
|
|
125
|
+
summary: null,
|
|
126
|
+
priority: "medium",
|
|
127
|
+
degraded: true, // the caller tells the reporter this one wasn't interviewed
|
|
128
|
+
};
|
|
129
|
+
state.draft = draft;
|
|
130
|
+
return { action: "draft", draft };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getDraft(chatId) {
|
|
134
|
+
return sessions.get(chatId)?.draft ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Human-readable preview of a draft, for the confirm step. */
|
|
138
|
+
export function renderDraft(draft) {
|
|
139
|
+
const lines = [`*${escapeMarkdown(draft.title)}*`, "", escapeMarkdown(draft.body)];
|
|
140
|
+
if (draft.priority) lines.push("", `Priority: ${draft.priority}`);
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Telegram's legacy Markdown parser throws on unbalanced control characters,
|
|
145
|
+
// which arbitrary model output and pasted code will absolutely contain.
|
|
146
|
+
function escapeMarkdown(text) {
|
|
147
|
+
return String(text ?? "").replace(/([_*[\]`])/g, "\\$1");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const AFFIRMATIVE = new Set(["y", "yes", "yeah", "yep", "ok", "okay", "sure", "do it", "go", "create", "confirm", "ship it"]);
|
|
151
|
+
export const NEGATIVE = new Set(["n", "no", "nope", "cancel", "stop", "abort", "nevermind", "never mind"]);
|
package/src/lib/paths.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
// ouro always operates relative to the repo it was invoked in (process.cwd()),
|
|
5
|
+
// mirroring how git/npm root-detect. Keeps the "installed in your repo" promise literal.
|
|
6
|
+
export function repoRoot() {
|
|
7
|
+
return process.cwd();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ouroDir() {
|
|
11
|
+
return path.join(repoRoot(), ".ouro");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function configPath() {
|
|
15
|
+
return path.join(ouroDir(), "config.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ticketsPath() {
|
|
19
|
+
return path.join(ouroDir(), "tickets.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function worktreesDir() {
|
|
23
|
+
return path.join(ouroDir(), "worktrees");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Agents are markdown files rather than rows in tickets.json so they can be
|
|
27
|
+
// edited in an editor and reviewed in a diff. See lib/agents.js.
|
|
28
|
+
export function agentsDir() {
|
|
29
|
+
return path.join(ouroDir(), "agents");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Daemon bookkeeping: one pid file and one log per background service.
|
|
33
|
+
// See lib/daemon.js.
|
|
34
|
+
export function runDir() {
|
|
35
|
+
return path.join(ouroDir(), "run");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function logsDir() {
|
|
39
|
+
return path.join(ouroDir(), "logs");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Secrets for the background services. The daemon outlives the shell that
|
|
43
|
+
// started it, so it can't rely on that shell's exported env — this is what a
|
|
44
|
+
// rebooted machine reads OURO_TELEGRAM_BOT_TOKEN back out of. Gitignored.
|
|
45
|
+
export function envPath() {
|
|
46
|
+
return path.join(ouroDir(), ".env");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ensureOuroDir() {
|
|
50
|
+
for (const dir of [ouroDir(), worktreesDir(), agentsDir(), runDir(), logsDir()]) {
|
|
51
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isInitialized() {
|
|
56
|
+
return fs.existsSync(configPath());
|
|
57
|
+
}
|
package/src/lib/runs.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Registry of in-flight agent runs, keyed by ticket id.
|
|
2
|
+
//
|
|
3
|
+
// Cancellation is real, not cosmetic: each run gets an AbortController whose
|
|
4
|
+
// signal is handed to `spawn()`, so aborting sends SIGTERM to the CLI child
|
|
5
|
+
// process itself rather than just flipping a flag and letting the model keep
|
|
6
|
+
// burning tokens in the background.
|
|
7
|
+
//
|
|
8
|
+
// One run per ticket at a time — starting a second while one is live would
|
|
9
|
+
// leave the first unreachable, so `begin()` refuses instead.
|
|
10
|
+
|
|
11
|
+
const runs = new Map(); // ticketId -> { controller, startedAt, phase }
|
|
12
|
+
|
|
13
|
+
export function isRunning(ticketId) {
|
|
14
|
+
return runs.has(ticketId);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function begin(ticketId, phase = "run") {
|
|
18
|
+
if (runs.has(ticketId)) {
|
|
19
|
+
throw new Error(`Ticket ${ticketId} already has a run in flight`);
|
|
20
|
+
}
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
runs.set(ticketId, { controller, startedAt: Date.now(), phase });
|
|
23
|
+
return controller.signal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function end(ticketId) {
|
|
27
|
+
runs.delete(ticketId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True if there was a live run to kill; false if it had already finished. */
|
|
31
|
+
export function cancel(ticketId) {
|
|
32
|
+
const run = runs.get(ticketId);
|
|
33
|
+
if (!run) return false;
|
|
34
|
+
run.controller.abort();
|
|
35
|
+
runs.delete(ticketId);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Was this ticket's run killed by us, rather than exiting on its own? */
|
|
40
|
+
export function wasAborted(signal) {
|
|
41
|
+
return Boolean(signal?.aborted);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function activeRuns() {
|
|
45
|
+
return [...runs.entries()].map(([ticketId, run]) => ({
|
|
46
|
+
ticketId,
|
|
47
|
+
phase: run.phase,
|
|
48
|
+
startedAt: run.startedAt,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Kill everything — used when the dashboard process is shutting down. */
|
|
53
|
+
export function cancelAll() {
|
|
54
|
+
for (const id of [...runs.keys()]) cancel(id);
|
|
55
|
+
}
|
package/src/lib/ship.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { store } from "./store.js";
|
|
2
|
+
import { commitWorktree, pushTicketBranch, hasRemote, branchFor, worktreePath, worktreeChanges } from "./worktree.js";
|
|
3
|
+
import { hasGh, createPullRequest } from "./github.js";
|
|
4
|
+
|
|
5
|
+
// Turning a finished run into a reviewable PR.
|
|
6
|
+
//
|
|
7
|
+
// Before this, a run ended in the Review column with a diff living in a local
|
|
8
|
+
// worktree on an unpushed branch — real work in a place nobody else could see.
|
|
9
|
+
// Shipping is the step that makes it a thing a human can actually review.
|
|
10
|
+
//
|
|
11
|
+
// Each stage logs to the ticket, so the terminal dock narrates the push and PR
|
|
12
|
+
// the same way it narrates the agent's tool calls. Every failure mode leaves
|
|
13
|
+
// the ticket in `review` with the work intact: committing and pushing are
|
|
14
|
+
// worth keeping even when the PR itself can't be opened.
|
|
15
|
+
|
|
16
|
+
function commitMessage(ticket) {
|
|
17
|
+
const subject = ticket.title.length > 68 ? `${ticket.title.slice(0, 67)}…` : ticket.title;
|
|
18
|
+
const body = [ticket.summary, ticket.body].filter(Boolean).join("\n\n");
|
|
19
|
+
return `${subject}\n\n${body}\n\nTicket: ${ticket.id} (ouro)`.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function prBody(ticket) {
|
|
23
|
+
const lines = [];
|
|
24
|
+
if (ticket.summary) lines.push(ticket.summary, "");
|
|
25
|
+
if (ticket.body) lines.push(ticket.body, "");
|
|
26
|
+
lines.push("---", "");
|
|
27
|
+
lines.push(`Filed via ouro · ticket \`${ticket.id}\` · source: ${ticket.source}`);
|
|
28
|
+
if (ticket.agentId) lines.push(`Implemented by the \`${ticket.agentId}\` agent.`);
|
|
29
|
+
lines.push("", "🤖 Generated with [Claude Code](https://claude.com/claude-code)");
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Commit → push → open PR. Returns a result object rather than throwing; the
|
|
35
|
+
* caller (a route, or the end of a run) reports it.
|
|
36
|
+
*/
|
|
37
|
+
export async function shipTicket(ticketId) {
|
|
38
|
+
const ticket = store.get(ticketId);
|
|
39
|
+
if (!ticket) return { ok: false, error: "not found" };
|
|
40
|
+
|
|
41
|
+
const log = (text, type = "ship") => store.appendLog(ticketId, { type, text });
|
|
42
|
+
|
|
43
|
+
// 1. Is there anything to ship?
|
|
44
|
+
let changed;
|
|
45
|
+
try {
|
|
46
|
+
changed = await worktreeChanges(ticketId);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log(`No worktree to ship from: ${err.message || err}`, "error");
|
|
49
|
+
return { ok: false, error: "no worktree" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (changed.length === 0 && !ticket.diff) {
|
|
53
|
+
log("Nothing to ship — the agent changed no files.");
|
|
54
|
+
store.update(ticketId, { status: "done", shipNote: "No file changes — nothing to open a PR for." });
|
|
55
|
+
return { ok: true, empty: true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. Commit.
|
|
59
|
+
let commit;
|
|
60
|
+
try {
|
|
61
|
+
commit = await commitWorktree(ticketId, commitMessage(ticket));
|
|
62
|
+
if (commit.committed) log(`✓ committed ${commit.files.length} file(s) on ${branchFor(ticketId)}`);
|
|
63
|
+
else log("Already committed — nothing new to add.");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log(`Commit failed: ${err.message || err}`, "error");
|
|
66
|
+
return { ok: false, error: `commit failed: ${err.message || err}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Push. Without a remote there's nothing to open a PR against, but the
|
|
70
|
+
// commit above still stands — the branch is there locally.
|
|
71
|
+
if (!(await hasRemote())) {
|
|
72
|
+
const note = "No git remote — committed locally, but there's nowhere to push or open a PR.";
|
|
73
|
+
log(note, "warn");
|
|
74
|
+
store.update(ticketId, { shipNote: note });
|
|
75
|
+
return { ok: false, error: "no remote", committed: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await pushTicketBranch(ticketId);
|
|
80
|
+
log(`✓ pushed ${branchFor(ticketId)} to origin`);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const msg = String(err.message || err).split("\n").slice(0, 3).join(" ");
|
|
83
|
+
log(`Push failed: ${msg}`, "error");
|
|
84
|
+
store.update(ticketId, { shipNote: `Push failed: ${msg}` });
|
|
85
|
+
return { ok: false, error: `push failed: ${msg}`, committed: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 4. PR.
|
|
89
|
+
if (!(await hasGh())) {
|
|
90
|
+
const note = "Branch pushed, but the GitHub CLI (gh) isn't installed — open the PR yourself.";
|
|
91
|
+
log(note, "warn");
|
|
92
|
+
store.update(ticketId, { status: "done", shipNote: note });
|
|
93
|
+
return { ok: false, error: "gh missing", pushed: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result = await createPullRequest({
|
|
97
|
+
cwd: worktreePath(ticketId),
|
|
98
|
+
base: ticket.baseBranch ?? undefined,
|
|
99
|
+
head: branchFor(ticketId),
|
|
100
|
+
title: ticket.title,
|
|
101
|
+
body: prBody(ticket),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (result.error) {
|
|
105
|
+
const msg = result.error.split("\n").slice(0, 3).join(" ");
|
|
106
|
+
log(`PR failed: ${msg}`, "error");
|
|
107
|
+
store.update(ticketId, { shipNote: `Branch pushed, but gh couldn't open the PR: ${msg}` });
|
|
108
|
+
return { ok: false, error: `pr failed: ${msg}`, pushed: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log(`✓ ${result.existed ? "PR already open" : "opened PR"} · ${result.url}`, "ship-done");
|
|
112
|
+
store.update(ticketId, { status: "done", prUrl: result.url, shipNote: null });
|
|
113
|
+
return { ok: true, url: result.url };
|
|
114
|
+
}
|