hilos-agent 0.1.15 → 0.4.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.
@@ -0,0 +1,270 @@
1
+ // Progress emitter (0274). The daemon-side glue between the 0272 vendor stream
2
+ // parser (agent-events.mjs) and the 0273 `post_progress` MCP tool: it folds the
3
+ // parser's normalized AgentEvents into ONE live "what I'm doing right now"
4
+ // snapshot and pushes it, coalesced + throttled, to a single thread-anchored
5
+ // status message. Retires the old 3-minute edit heartbeat — the card now moves
6
+ // as the run moves.
7
+ //
8
+ // Design rules (mirror the .mjs siblings):
9
+ // - PURE + node-builtins-only. It NEVER imports MCP: the caller injects `send`
10
+ // (a fn that calls post_progress) so this module is unit-tested offline with
11
+ // no network, no daemon, no CLI.
12
+ // - NEVER throws into the run. `send` is wrapped; a parser hiccup degrades.
13
+ // - Deterministic/testable: `now` is injected, and the trailing timer goes
14
+ // through injectable `setTimer`/`clearTimer` so tests drive it with no real
15
+ // setTimeout (fake clock + manual flush).
16
+ // - Defense-in-depth: every string is re-sanitized here (the parser already
17
+ // redacts, the server will redact again) and arrays are clamped.
18
+
19
+ import { createStepRing, sanitizeText } from "./agent-events.mjs";
20
+
21
+ /**
22
+ * Which parser/stream-flags a coding command wants, from its FIRST token.
23
+ * `claude`/`claude-code` → claude_code, `codex` → codex,
24
+ * `cursor`/`cursor-agent` → cursor, everything else → unknown. Handles an
25
+ * absolute path (`/usr/local/bin/claude`) by taking the basename.
26
+ * @param {string} codingCmd
27
+ * @returns {'claude_code'|'codex'|'cursor'|'unknown'}
28
+ */
29
+ export function detectVendor(codingCmd) {
30
+ const first = String(codingCmd || "").trim().split(/\s+/)[0] || "";
31
+ const base = (first.split(/[/\\]/).pop() || "").toLowerCase();
32
+ if (base === "claude" || base === "claude-code" || base === "claude_code") return "claude_code";
33
+ if (base === "codex") return "codex";
34
+ if (base === "cursor" || base === "cursor-agent") return "cursor";
35
+ return "unknown";
36
+ }
37
+
38
+ /**
39
+ * Extra args to make the code run EMIT a structured stream, appended to the code
40
+ * run's argv (NOT the display string) and ONLY for the code run. Only claude_code
41
+ * has a proven flag (`--output-format stream-json --verbose`, per lib/agent-cli.ts
42
+ * + scripts/verify-sandbox-mcp.mjs). codex/cursor return [] — their stream flags
43
+ * are deferred to 0278 rather than guessed (an unproven flag could break the run).
44
+ * @param {'claude_code'|'codex'|'cursor'|'unknown'} vendor
45
+ * @returns {string[]}
46
+ */
47
+ export function codeStreamArgs(vendor) {
48
+ if (vendor === "claude_code") return ["--output-format", "stream-json", "--verbose"];
49
+ return [];
50
+ }
51
+
52
+ // A raw stdout line that starts like JSON is the structured NDJSON we already
53
+ // parse into steps — keep it OUT of the `lastLine` text fallback (which exists
54
+ // for text-only CLIs), so a Claude run never surfaces raw JSON as its status.
55
+ const looksLikeJson = (line) => /^[[{]/.test(line);
56
+
57
+ /** Last non-empty, non-JSON line of a raw chunk (the text-only liveness tail). */
58
+ function rawTail(chunk) {
59
+ const lines = String(chunk ?? "")
60
+ .split("\n")
61
+ .map((s) => s.trim())
62
+ .filter(Boolean);
63
+ for (let i = lines.length - 1; i >= 0; i--) {
64
+ if (!looksLikeJson(lines[i])) return lines[i];
65
+ }
66
+ return "";
67
+ }
68
+
69
+ const defaultSetTimer = (fn, ms) => {
70
+ const id = setTimeout(fn, ms);
71
+ if (id && typeof id.unref === "function") id.unref();
72
+ return id;
73
+ };
74
+ const defaultClearTimer = (id) => clearTimeout(id);
75
+
76
+ /**
77
+ * Build a stateful progress emitter.
78
+ *
79
+ * Throttle — LEADING + TRAILING: the first update after a quiet gap is sent
80
+ * IMMEDIATELY (leading, so the card lights up the instant work starts), then
81
+ * further updates within `throttleMs` are coalesced and one TRAILING send fires
82
+ * at the end of the window (so a chatty CLI can't spam realtime). `flush()` and
83
+ * `done()` bypass the throttle for the final, guaranteed delivery.
84
+ *
85
+ * Coalesce: steps go through the 0272 ring (consecutive same-file edits collapse
86
+ * to one "Editing …", duplicate labels drop); `filesTouched` is a bounded,
87
+ * de-duped set.
88
+ *
89
+ * @param {object} o
90
+ * @param {{ push: (chunk: string) => any[], flush: () => any[] }} o.parser — a
91
+ * makeStreamParser(vendor) instance; this module owns feeding + flushing it.
92
+ * @param {(progress: object) => any} o.send — injected sender (calls post_progress).
93
+ * @param {() => number} [o.now] — clock (injectable for tests).
94
+ * @param {number} [o.throttleMs] — coalesce window (default 2000).
95
+ * @param {number} [o.stepLimit] — rolling step window (default 8).
96
+ * @param {number} [o.fileLimit] — filesTouched cap (default 20).
97
+ * @param {(fn: () => void, ms: number) => any} [o.setTimer] — trailing-timer arm.
98
+ * @param {(id: any) => void} [o.clearTimer] — trailing-timer clear.
99
+ */
100
+ export function createProgressEmitter({
101
+ parser,
102
+ send,
103
+ now = Date.now,
104
+ throttleMs = 2000,
105
+ stepLimit = 8,
106
+ fileLimit = 20,
107
+ setTimer = defaultSetTimer,
108
+ clearTimer = defaultClearTimer,
109
+ } = {}) {
110
+ const ring = createStepRing(stepLimit);
111
+ const files = [];
112
+ const fileSet = new Set();
113
+ const startedAt = now();
114
+
115
+ let state = "working";
116
+ let phase = null;
117
+ let lastLine = "";
118
+ let sessionId = null;
119
+ let errorMessage = null; // set only by done("error", reason) — the failure "why" (0294)
120
+
121
+ let lastSentAt = null; // null → the first send is a leading (immediate) send
122
+ let pending = false;
123
+ let timerId = null;
124
+
125
+ function addFile(path) {
126
+ const p = sanitizeText(path);
127
+ if (!p || fileSet.has(p)) return; // dedupe — a file appears once
128
+ files.push(p);
129
+ fileSet.add(p);
130
+ while (files.length > fileLimit) fileSet.delete(files.shift()); // bound, drop oldest
131
+ }
132
+
133
+ function fold(ev) {
134
+ if (!ev || typeof ev !== "object") return;
135
+ ring.push(ev); // steps[] (coalesces same-file edits + dup labels)
136
+ switch (ev.t) {
137
+ case "session":
138
+ if (ev.sessionId) sessionId = ev.sessionId;
139
+ break;
140
+ case "edit":
141
+ case "read":
142
+ if (ev.path) addFile(ev.path);
143
+ break;
144
+ case "note":
145
+ if (ev.text) lastLine = sanitizeText(ev.text); // a clean note beats the raw tail
146
+ break;
147
+ case "phase":
148
+ if (ev.name) phase = sanitizeText(ev.name);
149
+ break;
150
+ case "result":
151
+ if (ev.summary) lastLine = sanitizeText(ev.summary);
152
+ break;
153
+ default:
154
+ break;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * The current live progress payload (also what `send` receives).
160
+ * @returns {{ state: string, phase?: string, step?: string, steps: string[],
161
+ * filesTouched: string[], lastLine?: string, elapsedMs: number, sessionId?: string }}
162
+ */
163
+ function snapshot() {
164
+ const step = ring.latest();
165
+ const snap = {
166
+ state,
167
+ steps: ring.labels().map(sanitizeText).slice(-stepLimit),
168
+ filesTouched: files.map(sanitizeText).slice(-fileLimit),
169
+ elapsedMs: Math.max(0, now() - startedAt),
170
+ };
171
+ if (phase) snap.phase = sanitizeText(phase);
172
+ if (step) snap.step = sanitizeText(step);
173
+ const ll = sanitizeText(lastLine);
174
+ if (ll) snap.lastLine = ll;
175
+ if (sessionId) snap.sessionId = sanitizeText(sessionId); // for 0282 (server drops it today)
176
+ if (errorMessage) snap.errorMessage = errorMessage; // 0294 (older servers drop it)
177
+ return snap;
178
+ }
179
+
180
+ function clearTrailing() {
181
+ if (timerId != null) {
182
+ try {
183
+ clearTimer(timerId);
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ timerId = null;
188
+ }
189
+ }
190
+
191
+ function emit() {
192
+ // A progress send must NEVER throw into the run.
193
+ try {
194
+ const r = send(snapshot());
195
+ if (r && typeof r.catch === "function") r.catch(() => {});
196
+ } catch {
197
+ /* swallow */
198
+ }
199
+ }
200
+
201
+ function doSend() {
202
+ clearTrailing();
203
+ pending = false;
204
+ lastSentAt = now();
205
+ emit();
206
+ }
207
+
208
+ function armTrailing() {
209
+ if (timerId != null) return; // one timer at a time
210
+ const wait = lastSentAt == null ? 0 : Math.max(0, throttleMs - (now() - lastSentAt));
211
+ timerId = setTimer(() => {
212
+ timerId = null;
213
+ if (pending) doSend();
214
+ }, wait);
215
+ }
216
+
217
+ function scheduleSend() {
218
+ const t = now();
219
+ if (lastSentAt == null || t - lastSentAt >= throttleMs) {
220
+ doSend(); // leading — light up immediately
221
+ } else {
222
+ pending = true; // trailing — coalesce, one send at window end
223
+ armTrailing();
224
+ }
225
+ }
226
+
227
+ return {
228
+ /** Feed a raw stdout chunk: parse → fold → (throttled) send. */
229
+ feed(chunk) {
230
+ let events = [];
231
+ try {
232
+ events = parser.push(chunk) || [];
233
+ } catch {
234
+ events = []; // a parser hiccup must not break the run
235
+ }
236
+ const tail = rawTail(chunk); // text-only liveness fallback
237
+ if (tail) lastLine = sanitizeText(tail);
238
+ for (const ev of events) fold(ev); // a clean note/summary overrides the tail
239
+ scheduleSend();
240
+ },
241
+ /** Force-send the current snapshot now (bypasses the throttle). */
242
+ flush() {
243
+ doSend();
244
+ },
245
+ /**
246
+ * Final send: drain the parser, flip state, cancel timers. On an error
247
+ * finish, `reason` (the CLI's error / stderr tail / exit status) rides along
248
+ * as a sanitized `errorMessage` so the settled card can say WHY (0294).
249
+ */
250
+ done(finalState, reason) {
251
+ let tail = [];
252
+ try {
253
+ tail = parser.flush() || [];
254
+ } catch {
255
+ tail = [];
256
+ }
257
+ for (const ev of tail) fold(ev);
258
+ state = finalState === "error" ? "error" : "done";
259
+ if (state === "error") {
260
+ const r = sanitizeText(reason);
261
+ if (r) errorMessage = r;
262
+ }
263
+ clearTrailing();
264
+ pending = false;
265
+ lastSentAt = now();
266
+ emit();
267
+ },
268
+ snapshot,
269
+ };
270
+ }
package/src/resume.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // Session resume (0282) — the daemon's "true same-PR iteration context" layer.
2
+ //
3
+ // Today an iterate (0281) checks out the PR branch and re-runs the coding agent
4
+ // with the reviewer's feedback in the prompt: the CODE is there, but the agent's
5
+ // SESSION memory (its plan/reasoning from the first run) is gone. This module lets
6
+ // the follow-up RESUME that session (`claude -p --resume <id>`) so it continues
7
+ // with full context.
8
+ //
9
+ // Two pieces, both PURE where it matters + node-builtins-only:
10
+ // 1. buildResumeArgs(vendor, sessionId) — the vendor-gated `--resume` flags.
11
+ // 2. a tiny best-effort state store keyed by thread_root_msg_id at
12
+ // ~/.hilos/state.json. The LOCAL record proves a session exists on THIS
13
+ // machine — the confidence signal the handler gates `--resume` on (a
14
+ // providerSessionId that came ONLY from the server likely lives elsewhere).
15
+ //
16
+ // Design rules (mirror the .mjs siblings):
17
+ // - The pure transforms (parse/merge/prune/serialize) take an injected clock
18
+ // (nowMs) and never call Date.now/os, so they unit-test with a fixed time.
19
+ // - Every fs read/write is guarded: a state IO failure NEVER breaks a run.
20
+ // - We NEVER guess a vendor's resume flag. Only claude_code has a proven one;
21
+ // codex/cursor degrade to today's branch+feedback iterate (deferred to 0278+).
22
+
23
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ /** The daemon's state dir (same parent as ~/.hilos/agent.json — see config.mjs). */
28
+ export const HILOS_DIR = join(homedir(), ".hilos");
29
+ /** The state file inside HILOS_DIR (a JSON object map: threadRoot → entry). */
30
+ export const STATE_FILE = "state.json";
31
+ /** Prune entries older than this on every write (7 days) — the store stays small
32
+ * and a genuinely stale session can never be resurrected for a resume. */
33
+ export const STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
34
+
35
+ /**
36
+ * The `--resume` flags for a coding vendor, or [] when resume isn't safe/known.
37
+ *
38
+ * ONLY claude_code has a proven, confirmed resume flag (`--resume <id>`, compatible
39
+ * with `--output-format stream-json`). codex/cursor have UNCONFIRMED resume flags,
40
+ * so we emit NOTHING rather than guess — a wrong flag could break the run; they
41
+ * degrade to today's branch+feedback iterate (deferred to 0278/later). A falsy or
42
+ * non-string sessionId also returns [] (nothing to resume).
43
+ *
44
+ * @param {'claude_code'|'codex'|'cursor'|'unknown'} vendor
45
+ * @param {string|null|undefined} sessionId
46
+ * @returns {string[]}
47
+ */
48
+ export function buildResumeArgs(vendor, sessionId) {
49
+ if (vendor !== "claude_code") return [];
50
+ if (typeof sessionId !== "string" || !sessionId.trim()) return [];
51
+ return ["--resume", sessionId];
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Pure state transforms (no fs, no clock, no os — the caller injects nowMs).
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** Parse a state.json string into a plain object map. Malformed JSON, a non-object
59
+ * (array / string / null), or anything unexpected → {} (safe empty map). */
60
+ export function parseStateMap(text) {
61
+ try {
62
+ const o = JSON.parse(text);
63
+ if (o && typeof o === "object" && !Array.isArray(o)) return o;
64
+ } catch {
65
+ /* malformed — treat as empty */
66
+ }
67
+ return {};
68
+ }
69
+
70
+ /** Drop entries whose `updatedAt` is missing/unparseable or older than maxAgeMs
71
+ * relative to the injected nowMs. Returns a NEW map (does not mutate input). */
72
+ export function pruneStateMap(map, nowMs, maxAgeMs = STATE_MAX_AGE_MS) {
73
+ const out = {};
74
+ const src = map && typeof map === "object" && !Array.isArray(map) ? map : {};
75
+ for (const [k, v] of Object.entries(src)) {
76
+ if (!v || typeof v !== "object") continue;
77
+ const t = Date.parse(v.updatedAt);
78
+ if (Number.isFinite(t) && nowMs - t <= maxAgeMs) out[k] = v;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Prune the old map, then set/overwrite the entry for `threadRoot`. The fresh entry
85
+ * is added AFTER the prune so it can never be pruned by its own write, even if its
86
+ * timestamp is odd. Returns a NEW map. The caller stamps `entry.updatedAt`.
87
+ */
88
+ export function mergeStateMap(map, threadRoot, entry, nowMs, maxAgeMs = STATE_MAX_AGE_MS) {
89
+ const pruned = pruneStateMap(map, nowMs, maxAgeMs);
90
+ return { ...pruned, [threadRoot]: entry };
91
+ }
92
+
93
+ /** Serialize a state map for disk. Compact (minimal IO). */
94
+ export function serializeStateMap(map) {
95
+ return JSON.stringify(map);
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Guarded fs layer — best-effort, NEVER throws into a run.
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Read the whole state map from `${dir}/state.json`. Missing file / read error /
103
+ * malformed JSON → {}. */
104
+ export function readState(dir) {
105
+ try {
106
+ return parseStateMap(readFileSync(join(dir, STATE_FILE), "utf8"));
107
+ } catch {
108
+ return {};
109
+ }
110
+ }
111
+
112
+ /** The single entry for a thread root, or null. Used by the resume gate. */
113
+ export function readStateEntry(dir, threadRoot) {
114
+ if (!dir || !threadRoot) return null;
115
+ const e = readState(dir)[threadRoot];
116
+ return e && typeof e === "object" ? e : null;
117
+ }
118
+
119
+ /**
120
+ * Write/overwrite the entry for `threadRoot`, pruning stale entries. Best-effort:
121
+ * returns true on success, false on any failure (a state write NEVER breaks a run).
122
+ * `entry` should carry { runId, branch, prUrl, sessionId, machine, updatedAt }; the
123
+ * prune clock derives from entry.updatedAt (falling back to the injected now()).
124
+ *
125
+ * @param {string} dir
126
+ * @param {string} threadRoot
127
+ * @param {object} entry
128
+ * @param {{ now?: () => number }} [opts]
129
+ * @returns {boolean}
130
+ */
131
+ export function writeState(dir, threadRoot, entry, { now = Date.now } = {}) {
132
+ if (!dir || !threadRoot || !entry || typeof entry !== "object") return false;
133
+ try {
134
+ const stamped = Date.parse(entry.updatedAt);
135
+ const nowMs = Number.isFinite(stamped) ? stamped : now();
136
+ const next = mergeStateMap(readState(dir), threadRoot, entry, nowMs);
137
+ mkdirSync(dir, { recursive: true });
138
+ writeFileSync(join(dir, STATE_FILE), serializeStateMap(next));
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
package/src/review.mjs ADDED
@@ -0,0 +1,237 @@
1
+ // Cross-agent REVIEW EXECUTION (WS3, ticket 0288) — the pure core the daemon uses
2
+ // to turn a PR diff into a posted, ADVISORY review. Node-builtins-only, no I/O, so
3
+ // prompt shaping + output parsing + the ping-pong guard are all unit-testable.
4
+ //
5
+ // A review is advisory: it never merges, blocks, or edits files. buildReviewPrompt
6
+ // asks the CLI to ANALYZE a diff (read-only) and emit a structured verdict;
7
+ // parseReviewOutput turns whatever the model wrote back into a
8
+ // { verdict, summary, findings } the daemon hands to post_review (which @-tags the
9
+ // author). The human stays the sole merge gate.
10
+
11
+ export const REVIEW_VERDICTS = ["looks-good", "suggest-changes", "blocking"];
12
+ export const REVIEW_SEVERITIES = ["info", "warning", "blocking"];
13
+
14
+ // Bounds so a prompt-injected / runaway review can't bloat a message or the UI.
15
+ // (Mirror lib/reviews.ts sanitize intent — kept in sync by value, not import,
16
+ // since this module is dependency-free .mjs shipped in the daemon package.)
17
+ const MAX_FINDINGS = 50;
18
+ const MAX_NOTE = 600;
19
+ const MAX_FILE = 300;
20
+ const MAX_SUMMARY = 4000;
21
+
22
+ /** The first line the CLI must emit — parsed leniently, but asked for exactly. */
23
+ export const VERDICT_MARK = "VERDICT:";
24
+
25
+ /** Render one diff file for the prompt: a header + its (already byte-budgeted) patch. */
26
+ function renderDiffFile(f) {
27
+ const name = String(f?.filename || "unknown");
28
+ const status = f?.status ? ` (${f.status})` : "";
29
+ const add = Number.isFinite(f?.additions) ? f.additions : 0;
30
+ const del = Number.isFinite(f?.deletions) ? f.deletions : 0;
31
+ const head = `### ${name}${status} +${add} -${del}`;
32
+ const patch = typeof f?.patch === "string" && f.patch.trim() ? f.patch : "(no textual patch available)";
33
+ const cut = f?.patchTruncated ? "\n… (patch truncated to fit the review budget)" : "";
34
+ return `${head}\n${patch}${cut}`;
35
+ }
36
+
37
+ /**
38
+ * The prompt handed to the review CLI. It instructs a strictly READ-ONLY review of
39
+ * the provided diff (the diff is already byte-budgeted by get_pr_diff, so the CLI
40
+ * needs no checkout) and a structured verdict the daemon can parse.
41
+ *
42
+ * @param {{
43
+ * diffFiles?: Array<{filename:string,status?:string,additions?:number,deletions?:number,patch?:string,patchTruncated?:boolean}>,
44
+ * task?: string, reportSummary?: string, threadContext?: string, workspaceMemory?: string
45
+ * }} [o]
46
+ * @returns {string}
47
+ */
48
+ export function buildReviewPrompt(o) {
49
+ const { diffFiles = [], task, reportSummary, threadContext, workspaceMemory } = o || {};
50
+ const files = Array.isArray(diffFiles) ? diffFiles : [];
51
+ const diffBlock = files.length
52
+ ? files.map(renderDiffFile).join("\n\n")
53
+ : "(no files in this diff)";
54
+
55
+ let p =
56
+ `You are a senior engineer doing a CODE REVIEW of a pull request for a teammate. ` +
57
+ `Review is ADVISORY: you only give feedback — you do NOT merge, approve, or block, ` +
58
+ `and a human makes the final call.\n\n` +
59
+ `STRICTLY READ-ONLY: the full diff is included below, so you do NOT need a checkout. ` +
60
+ `Do NOT edit, create, or delete any files. Do NOT run git or the \`gh\` CLI. Do NOT ` +
61
+ `stage, commit, push, or open/close anything. ONLY analyze the diff and write your review.\n\n` +
62
+ `Respond in EXACTLY this shape:\n` +
63
+ `1. A FIRST line: \`${VERDICT_MARK} <looks-good|suggest-changes|blocking>\` ` +
64
+ `(looks-good = ship it; suggest-changes = non-blocking improvements; blocking = a real bug ` +
65
+ `or risk that should be fixed first).\n` +
66
+ `2. Then 1–3 sentences summarizing your assessment.\n` +
67
+ `3. Then, optionally, specific findings — one per line — as: ` +
68
+ `\`- [info|warning|blocking] path/to/file:line — what and why\`.\n\n` +
69
+ `Be concrete and kind. Prefer real correctness/security/regression issues over style nits.`;
70
+
71
+ if (task && task.trim()) {
72
+ p += `\n\nWhat this PR was meant to do:\n${task.trim()}`;
73
+ }
74
+ if (reportSummary && reportSummary.trim()) {
75
+ p += `\n\nThe author's own summary of the change:\n${reportSummary.trim()}`;
76
+ }
77
+ if (threadContext && threadContext.trim()) {
78
+ p += `\n\nConversation around the PR (background):\n${threadContext.trim().slice(-4000)}`;
79
+ }
80
+ if (workspaceMemory && workspaceMemory.trim()) {
81
+ p += `\n\nProject context (the team's soul):\n${workspaceMemory.trim().slice(-2000)}`;
82
+ }
83
+ p += `\n\nThe PR diff to review:\n\n${diffBlock}`;
84
+ return p;
85
+ }
86
+
87
+ /** Coerce to a known verdict, or null. */
88
+ function asVerdict(v) {
89
+ const s = String(v || "").trim().toLowerCase().replace(/\s+/g, "-");
90
+ return REVIEW_VERDICTS.includes(s) ? s : null;
91
+ }
92
+
93
+ /** Coerce to a known severity, or 'info'. */
94
+ function asSeverity(v) {
95
+ const s = String(v || "").trim().toLowerCase();
96
+ return REVIEW_SEVERITIES.includes(s) ? s : "info";
97
+ }
98
+
99
+ // A findings line: "- [severity] rest" (bullet + bracketed severity required, so a
100
+ // prose bullet in the summary isn't mistaken for a structured finding).
101
+ const FINDING_RE = /^\s*[-*]\s*\[(info|warning|warn|blocking|block|error|major|minor|nit)\]\s*(.*)$/i;
102
+ // Within `rest`, pull an optional "path:line" head before an em/en/hyphen dash.
103
+ const LOC_RE = /^([^\s—–:][^:\n]*?):(\d+)\s*[—–-]+\s*(.*)$/;
104
+ const PATH_ONLY_RE = /^([^\s—–][^—–\n]*?)\s*[—–-]+\s+(.*)$/;
105
+
106
+ /** Map loose severity words the model might use onto the three canonical ones. */
107
+ function canonSeverity(raw) {
108
+ const s = String(raw || "").toLowerCase();
109
+ if (s === "warn") return "warning";
110
+ if (s === "block" || s === "error" || s === "major") return "blocking";
111
+ if (s === "minor" || s === "nit") return "info";
112
+ return asSeverity(s);
113
+ }
114
+
115
+ /** Parse one findings line into a finding, or null if it isn't one. */
116
+ function parseFinding(line) {
117
+ const m = FINDING_RE.exec(line);
118
+ if (!m) return null;
119
+ const severity = canonSeverity(m[1]);
120
+ let rest = (m[2] || "").trim();
121
+ let file;
122
+ let lineNo;
123
+ let note = rest;
124
+ const loc = LOC_RE.exec(rest);
125
+ if (loc) {
126
+ file = loc[1].trim();
127
+ const n = parseInt(loc[2], 10);
128
+ if (Number.isFinite(n) && n > 0) lineNo = n;
129
+ note = loc[3].trim();
130
+ } else {
131
+ const po = PATH_ONLY_RE.exec(rest);
132
+ // Only treat the head as a path when it actually looks like one (has a dot or
133
+ // slash) — otherwise "- [warning] Missing null check — fix it" would wrongly
134
+ // read "Missing null check" as a filename.
135
+ if (po && /[./]/.test(po[1])) {
136
+ file = po[1].trim();
137
+ note = po[2].trim();
138
+ }
139
+ }
140
+ note = note.trim();
141
+ if (!note) note = rest || "(no detail)";
142
+ const out = { severity, note: note.slice(0, MAX_NOTE) };
143
+ if (file) out.file = file.slice(0, MAX_FILE);
144
+ if (lineNo) out.line = lineNo;
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Parse the review CLI's free text into a structured review. Total + lenient —
150
+ * NEVER throws. Rules:
151
+ * - verdict: an explicit `VERDICT: x` line wins; else the first canonical verdict
152
+ * token anywhere; else 'looks-good' ONLY if the text clearly says so; else, when
153
+ * there is any text at all, 'suggest-changes' (advisory default — never a false
154
+ * "looks good"); empty text → 'suggest-changes' with an empty summary.
155
+ * - findings: lines shaped `- [severity] path:line — note`.
156
+ * - summary: the text minus the verdict line and the finding lines, first non-empty
157
+ * block, capped.
158
+ * @param {string} text
159
+ * @returns {{ verdict: string, summary: string, findings: Array<{severity:string,file?:string,line?:number,note:string}> }}
160
+ */
161
+ export function parseReviewOutput(text) {
162
+ const raw = typeof text === "string" ? text : "";
163
+ const trimmed = raw.trim();
164
+ if (!trimmed) return { verdict: "suggest-changes", summary: "", findings: [] };
165
+
166
+ const lines = trimmed.split(/\r?\n/);
167
+ const findings = [];
168
+ const summaryLines = [];
169
+ let verdict = null;
170
+
171
+ for (const line of lines) {
172
+ // Explicit verdict line (highest confidence).
173
+ const vm = /verdict\s*[:\-]?\s*(looks[-\s]?good|suggest[-\s]?changes|blocking|approve|lgtm)/i.exec(line);
174
+ if (vm && !verdict) {
175
+ const tok = vm[1].toLowerCase();
176
+ verdict =
177
+ /approve|lgtm|looks/.test(tok) ? "looks-good" : tok.replace(/\s+/g, "-");
178
+ verdict = asVerdict(verdict) || (/approve|lgtm|looks/.test(tok) ? "looks-good" : verdict);
179
+ continue; // don't fold the verdict line into the summary
180
+ }
181
+ const f = parseFinding(line);
182
+ if (f) {
183
+ if (findings.length < MAX_FINDINGS) findings.push(f);
184
+ continue;
185
+ }
186
+ summaryLines.push(line);
187
+ }
188
+
189
+ // No explicit verdict line → infer from the prose, conservatively.
190
+ if (!verdict) {
191
+ const low = trimmed.toLowerCase();
192
+ if (/\bblocking\b/.test(low)) verdict = "blocking";
193
+ else if (/looks[-\s]?good|lgtm|ship it|approve/.test(low)) verdict = "looks-good";
194
+ else if (/suggest[-\s]?changes/.test(low)) verdict = "suggest-changes";
195
+ // A blocking finding forces at least 'suggest-changes' if nothing else said so.
196
+ else if (findings.some((f) => f.severity === "blocking")) verdict = "blocking";
197
+ else verdict = "suggest-changes"; // advisory default — never a false "looks good"
198
+ }
199
+ // Guard: if any finding is blocking, don't let a stray "looks-good" bless it.
200
+ if (verdict === "looks-good" && findings.some((f) => f.severity === "blocking")) {
201
+ verdict = "suggest-changes";
202
+ }
203
+
204
+ const summary = summaryLines
205
+ .join("\n")
206
+ .replace(/\n{3,}/g, "\n\n")
207
+ .trim()
208
+ .slice(0, MAX_SUMMARY);
209
+ return { verdict, summary, findings };
210
+ }
211
+
212
+ /**
213
+ * A stable key for "this reviewer has reviewed this PR" — so a reviewer doesn't
214
+ * re-review the same PR endlessly and an author<->reviewer pair can't ping-pong.
215
+ * @param {string} prUrl
216
+ * @param {string} reviewerId
217
+ * @returns {string}
218
+ */
219
+ export function reviewDedupeKey(prUrl, reviewerId) {
220
+ return `${String(reviewerId || "?")}::${String(prUrl || "?")}`;
221
+ }
222
+
223
+ /**
224
+ * The round/dedupe guard. Returns true only when it's safe to review again:
225
+ * - never past maxRounds for this key, and
226
+ * - never a second time for an exact `seenKey` already in `seen`.
227
+ * Pure — the caller owns the `seen`/rounds state (a Map/Set on the long-lived
228
+ * daemon process), so the ping-pong bound survives across poll passes.
229
+ * @param {{ rounds?: number, maxRounds?: number, seenKey?: string, seen?: Set<string> }} [o]
230
+ * @returns {boolean}
231
+ */
232
+ export function shouldReview(o) {
233
+ const { rounds = 0, maxRounds = 3, seenKey, seen } = o || {};
234
+ if (Number.isFinite(rounds) && rounds >= maxRounds) return false;
235
+ if (seen && seenKey && typeof seen.has === "function" && seen.has(seenKey)) return false;
236
+ return true;
237
+ }