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.
- package/README.md +104 -0
- package/bin/hilos-agent.mjs +20 -2
- package/package.json +1 -1
- package/src/agent-events.mjs +371 -0
- package/src/config.mjs +25 -0
- package/src/daemon.mjs +67 -0
- package/src/followup.mjs +161 -0
- package/src/handler.mjs +1351 -70
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +95 -0
- package/src/progress-emitter.mjs +270 -0
- package/src/resume.mjs +143 -0
- package/src/review.mjs +237 -0
- package/src/run.mjs +133 -12
|
@@ -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
|
+
}
|