@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
package/src/lib/store.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { nanoid } from "nanoid";
|
|
4
|
+
import { ticketsPath, ensureOuroDir } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
// Lightweight file-backed store. A hackathon-scale ticket board doesn't need
|
|
7
|
+
// sqlite/postgres — a JSON file + an in-memory EventEmitter for the WS layer
|
|
8
|
+
// is plenty, and it means `ouro init` produces zero external dependencies.
|
|
9
|
+
|
|
10
|
+
export const STATUSES = ["inbox", "triaged", "in_progress", "review", "done", "cancelled"];
|
|
11
|
+
|
|
12
|
+
// A streaming agent emits events far faster than a board needs to persist.
|
|
13
|
+
// Writes are coalesced onto a trailing timer so a chatty run costs one write
|
|
14
|
+
// per tick instead of one per line; every mutation still broadcasts instantly
|
|
15
|
+
// over WS, so the UI is live regardless of when the file lands.
|
|
16
|
+
const PERSIST_DEBOUNCE_MS = 250;
|
|
17
|
+
|
|
18
|
+
// Per-ticket log ceiling. The terminal dock only ever renders a tail, and an
|
|
19
|
+
// unbounded array would grow tickets.json without limit across a long run.
|
|
20
|
+
const MAX_LOG_ENTRIES = 400;
|
|
21
|
+
|
|
22
|
+
class TicketStore extends EventEmitter {
|
|
23
|
+
constructor() {
|
|
24
|
+
super();
|
|
25
|
+
this.tickets = [];
|
|
26
|
+
this._persistTimer = null;
|
|
27
|
+
this._load();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_load() {
|
|
31
|
+
ensureOuroDir();
|
|
32
|
+
if (fs.existsSync(ticketsPath())) {
|
|
33
|
+
try {
|
|
34
|
+
this.tickets = JSON.parse(fs.readFileSync(ticketsPath(), "utf-8"));
|
|
35
|
+
} catch {
|
|
36
|
+
this.tickets = [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A ticket left mid-run by a killed dashboard process has no live child
|
|
41
|
+
// to reattach to, so it would otherwise sit spinning forever. Reconcile
|
|
42
|
+
// it to a terminal state at startup instead.
|
|
43
|
+
let reconciled = 0;
|
|
44
|
+
for (const ticket of this.tickets) {
|
|
45
|
+
if (ticket.status === "in_progress") {
|
|
46
|
+
ticket.status = "cancelled";
|
|
47
|
+
ticket.cancelReason = "Dashboard stopped while this run was in flight.";
|
|
48
|
+
reconciled++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Write it back immediately. Reconciling only in memory leaves the file
|
|
52
|
+
// claiming in_progress until some unrelated edit happens to flush it —
|
|
53
|
+
// so anything reading tickets.json directly (or a crash before the next
|
|
54
|
+
// write) would still see a run that hasn't existed since the last boot.
|
|
55
|
+
if (reconciled > 0) this.flush();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_persist() {
|
|
59
|
+
if (this._persistTimer) return;
|
|
60
|
+
this._persistTimer = setTimeout(() => {
|
|
61
|
+
this._persistTimer = null;
|
|
62
|
+
this.flush();
|
|
63
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Write immediately. Called on shutdown so a pending tick isn't lost. */
|
|
67
|
+
flush() {
|
|
68
|
+
if (this._persistTimer) {
|
|
69
|
+
clearTimeout(this._persistTimer);
|
|
70
|
+
this._persistTimer = null;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
fs.writeFileSync(ticketsPath(), JSON.stringify(this.tickets, null, 2));
|
|
74
|
+
} catch {
|
|
75
|
+
// A failed write shouldn't take the dashboard down — the in-memory
|
|
76
|
+
// state is still authoritative for this process.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
list() {
|
|
81
|
+
return this.tickets;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get(id) {
|
|
85
|
+
return this.tickets.find((t) => t.id === id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
create({ title, body, source = "manual", agentId = null, mode = null, priority = null, summary = null }) {
|
|
89
|
+
const ticket = {
|
|
90
|
+
id: nanoid(8),
|
|
91
|
+
title,
|
|
92
|
+
body,
|
|
93
|
+
source,
|
|
94
|
+
status: "inbox", // inbox -> triaged -> in_progress -> review -> done | cancelled
|
|
95
|
+
mode, // "agent" | "human" — null inherits the board's default
|
|
96
|
+
agentId, // which .ouro/agents/<id>.md runs this ticket
|
|
97
|
+
priority,
|
|
98
|
+
summary,
|
|
99
|
+
sessionId: null,
|
|
100
|
+
log: [],
|
|
101
|
+
worktree: null,
|
|
102
|
+
branch: null, // ouro/<id>, created with the worktree
|
|
103
|
+
baseBranch: null, // what HEAD was when the worktree was cut — the PR's target
|
|
104
|
+
diff: null,
|
|
105
|
+
plan: null,
|
|
106
|
+
prUrl: null, // set once the ticket ships
|
|
107
|
+
shipNote: null, // why it didn't ship, when it didn't
|
|
108
|
+
awaitingApproval: false,
|
|
109
|
+
cancelReason: null,
|
|
110
|
+
createdAt: new Date().toISOString(),
|
|
111
|
+
updatedAt: new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
this.tickets.unshift(ticket);
|
|
114
|
+
this._persist();
|
|
115
|
+
this.emit("change", { type: "created", ticket });
|
|
116
|
+
return ticket;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
update(id, patch) {
|
|
120
|
+
const ticket = this.get(id);
|
|
121
|
+
if (!ticket) return null;
|
|
122
|
+
Object.assign(ticket, patch, { updatedAt: new Date().toISOString() });
|
|
123
|
+
this._persist();
|
|
124
|
+
this.emit("change", { type: "updated", ticket });
|
|
125
|
+
return ticket;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
appendLog(id, entry) {
|
|
129
|
+
const ticket = this.get(id);
|
|
130
|
+
if (!ticket) return null;
|
|
131
|
+
|
|
132
|
+
const record = { ts: new Date().toISOString(), ...entry };
|
|
133
|
+
ticket.log.push(record);
|
|
134
|
+
if (ticket.log.length > MAX_LOG_ENTRIES) {
|
|
135
|
+
ticket.log.splice(0, ticket.log.length - MAX_LOG_ENTRIES);
|
|
136
|
+
}
|
|
137
|
+
ticket.updatedAt = record.ts;
|
|
138
|
+
this._persist();
|
|
139
|
+
|
|
140
|
+
// Log traffic is the terminal dock's firehose. It ships the single new
|
|
141
|
+
// entry rather than the whole ticket, so a long run doesn't re-send its
|
|
142
|
+
// entire history down the socket on every line.
|
|
143
|
+
this.emit("change", { type: "log", ticketId: id, entry: record });
|
|
144
|
+
return ticket;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Terminal transition for a run that was killed. Distinct from `update`
|
|
149
|
+
* only in that it clears the mid-run fields a cancelled ticket shouldn't
|
|
150
|
+
* keep showing (pending approval, half-written plan).
|
|
151
|
+
*/
|
|
152
|
+
cancel(id, reason) {
|
|
153
|
+
const ticket = this.get(id);
|
|
154
|
+
if (!ticket) return null;
|
|
155
|
+
return this.update(id, {
|
|
156
|
+
status: "cancelled",
|
|
157
|
+
awaitingApproval: false,
|
|
158
|
+
cancelReason: reason ?? "Cancelled from the dashboard.",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Back to the board as a fresh triaged ticket, keeping title/body/agent. */
|
|
163
|
+
reopen(id) {
|
|
164
|
+
const ticket = this.get(id);
|
|
165
|
+
if (!ticket) return null;
|
|
166
|
+
return this.update(id, {
|
|
167
|
+
status: ticket.summary ? "triaged" : "inbox",
|
|
168
|
+
awaitingApproval: false,
|
|
169
|
+
cancelReason: null,
|
|
170
|
+
diff: null,
|
|
171
|
+
plan: null,
|
|
172
|
+
sessionId: null,
|
|
173
|
+
log: [],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
remove(id) {
|
|
178
|
+
const index = this.tickets.findIndex((t) => t.id === id);
|
|
179
|
+
if (index === -1) return false;
|
|
180
|
+
this.tickets.splice(index, 1);
|
|
181
|
+
this._persist();
|
|
182
|
+
this.emit("change", { type: "deleted", ticketId: id });
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Singleton — one store per running `ouro dashboard` process.
|
|
188
|
+
export const store = new TicketStore();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Telegram's Bot API, only as far as ouro needs it to answer one question:
|
|
2
|
+
// "is this token real?"
|
|
3
|
+
//
|
|
4
|
+
// Plain fetch rather than node-telegram-bot-api: the dashboard process has no
|
|
5
|
+
// business holding a polling client (that's what `ouro listen` is), and one
|
|
6
|
+
// getMe call doesn't justify instantiating a bot that would start a connection
|
|
7
|
+
// pool and a retry loop the moment it's constructed.
|
|
8
|
+
|
|
9
|
+
// @BotFather issues `<bot id>:<35-char secret>`. Checking the shape before a
|
|
10
|
+
// network call turns the usual paste mistakes — the bot's @name, a half-copied
|
|
11
|
+
// token, the whole BotFather message — into a specific error, rather than a
|
|
12
|
+
// round trip that comes back as an unexplained "Unauthorized".
|
|
13
|
+
const SHAPE = /^\d{5,}:[A-Za-z0-9_-]{30,}$/;
|
|
14
|
+
|
|
15
|
+
export function looksLikeToken(token) {
|
|
16
|
+
return SHAPE.test(String(token ?? "").trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `123456789:…QsT4` — enough to recognise which token is set, useless to
|
|
21
|
+
* anyone who reads it. The bot id half is public (it's in the token's own
|
|
22
|
+
* structure and every getMe response); only the secret half is masked.
|
|
23
|
+
*/
|
|
24
|
+
export function maskToken(token) {
|
|
25
|
+
const value = String(token ?? "").trim();
|
|
26
|
+
if (!value) return null;
|
|
27
|
+
const [id] = value.split(":");
|
|
28
|
+
return value.includes(":") ? `${id}:…${value.slice(-4)}` : `…${value.slice(-4)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Asks Telegram who this token belongs to. Returns `{ ok: true, bot }` or
|
|
33
|
+
* `{ ok: false, error }` — never throws, and never echoes the token back in an
|
|
34
|
+
* error string, since these land in an HTTP response and a browser console.
|
|
35
|
+
*/
|
|
36
|
+
export async function verifyBotToken(token) {
|
|
37
|
+
let res;
|
|
38
|
+
try {
|
|
39
|
+
res = await fetch(`https://api.telegram.org/bot${token}/getMe`, {
|
|
40
|
+
signal: AbortSignal.timeout(8000),
|
|
41
|
+
});
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// Offline, DNS, proxy, or Telegram blocked by the network — decidedly not
|
|
44
|
+
// "your token is wrong", and saying so would send someone to @BotFather
|
|
45
|
+
// for a token that was fine all along.
|
|
46
|
+
return { ok: false, error: `Couldn't reach Telegram (${err.name === "TimeoutError" ? "timed out" : err.message}).` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const body = await res.json().catch(() => ({}));
|
|
50
|
+
|
|
51
|
+
if (!res.ok || !body.ok) {
|
|
52
|
+
const why = body.description ?? `Telegram responded ${res.status}`;
|
|
53
|
+
return { ok: false, error: res.status === 401 ? "Telegram rejected this token — get a fresh one from @BotFather." : why };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
bot: { id: body.result.id, username: body.result.username, name: body.result.first_name },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import simpleGit from "simple-git";
|
|
4
|
+
import { repoRoot, worktreesDir } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every ticket that goes into agent mode gets its own git worktree on a
|
|
8
|
+
* throwaway branch. This means a live demo can show a real diff being
|
|
9
|
+
* generated without any risk to the actual working tree mid-presentation,
|
|
10
|
+
* and "Apply" is just a merge instead of hoping the agent didn't wander.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function branchFor(ticketId) {
|
|
14
|
+
return `ouro/${ticketId}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function worktreePath(ticketId) {
|
|
18
|
+
return path.join(worktreesDir(), ticketId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The branch the repo is on right now — the base a ticket branches from. */
|
|
22
|
+
export async function currentBranch() {
|
|
23
|
+
try {
|
|
24
|
+
return (await simpleGit(repoRoot()).revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function createTicketWorktree(ticketId) {
|
|
31
|
+
const git = simpleGit(repoRoot());
|
|
32
|
+
const branch = branchFor(ticketId);
|
|
33
|
+
const dir = worktreePath(ticketId);
|
|
34
|
+
|
|
35
|
+
// Captured before the worktree exists: this is what the PR should target,
|
|
36
|
+
// and by ship time HEAD may have moved on.
|
|
37
|
+
const base = await currentBranch();
|
|
38
|
+
|
|
39
|
+
if (fs.existsSync(dir)) return { dir, branch, base };
|
|
40
|
+
|
|
41
|
+
await git.raw(["worktree", "add", dir, "-b", branch]);
|
|
42
|
+
return { dir, branch, base };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function diffWorktree(ticketId) {
|
|
46
|
+
const git = simpleGit(worktreePath(ticketId));
|
|
47
|
+
// Include staged changes: an agent that ran `git add` would otherwise show
|
|
48
|
+
// an empty diff and look like it did nothing.
|
|
49
|
+
const [unstaged, staged] = await Promise.all([git.diff(), git.diff(["--cached"])]);
|
|
50
|
+
return [staged, unstaged].filter(Boolean).join("\n") || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Files the agent touched, staged or not. Empty means it changed nothing. */
|
|
54
|
+
export async function worktreeChanges(ticketId) {
|
|
55
|
+
const status = await simpleGit(worktreePath(ticketId)).status();
|
|
56
|
+
return [...new Set([...status.files.map((f) => f.path), ...status.not_added])];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Commits everything the agent produced. Agents aren't asked to commit — their
|
|
61
|
+
* prompt is about the work, not the plumbing — so ouro does it, which also
|
|
62
|
+
* keeps the commit message consistent regardless of which agent ran.
|
|
63
|
+
*/
|
|
64
|
+
export async function commitWorktree(ticketId, message) {
|
|
65
|
+
const git = simpleGit(worktreePath(ticketId));
|
|
66
|
+
const changed = await worktreeChanges(ticketId);
|
|
67
|
+
if (changed.length === 0) return { committed: false, files: [] };
|
|
68
|
+
|
|
69
|
+
await git.add(["-A"]);
|
|
70
|
+
await git.commit(message);
|
|
71
|
+
return { committed: true, files: changed };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function hasRemote() {
|
|
75
|
+
try {
|
|
76
|
+
return (await simpleGit(repoRoot()).getRemotes(false)).length > 0;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pushes the ticket branch and sets upstream. Throws with git's own message. */
|
|
83
|
+
export async function pushTicketBranch(ticketId) {
|
|
84
|
+
const git = simpleGit(worktreePath(ticketId));
|
|
85
|
+
const branch = branchFor(ticketId);
|
|
86
|
+
await git.push(["-u", "origin", branch]);
|
|
87
|
+
return branch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function removeTicketWorktree(ticketId) {
|
|
91
|
+
const git = simpleGit(repoRoot());
|
|
92
|
+
await git.raw(["worktree", "remove", worktreePath(ticketId), "--force"]).catch(() => {});
|
|
93
|
+
}
|