hilos-agent 0.1.15 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,371 @@
1
+ // Normalized vendor stream parser (0272). The KEYSTONE of the "Agents feel
2
+ // alive" epic: coding CLIs each narrate their work in a different, unstable
3
+ // wire format (Claude Code emits `--output-format stream-json` NDJSON, Codex
4
+ // emits `--json` item events, Cursor emits unstructured text). This module turns
5
+ // any of them into ONE small, typed `AgentEvent` stream the UI can render as a
6
+ // live "what the agent is doing right now" card.
7
+ //
8
+ // Design rules that make this safe to point at an untrusted, evolving CLI:
9
+ // - PURE + dependency-free (node builtins only) so it stands alone and is
10
+ // unit-tested without a sandbox — mirror of the .mjs siblings' style.
11
+ // - NEVER throws. A vendor changing its format, a non-JSON log line, or an
12
+ // unknown event type must DEGRADE (emit nothing), never crash a run.
13
+ // - Every string that reaches the UI is sanitized (token-shaped secrets
14
+ // stripped, length capped) — a chatty CLI can echo a `Bearer …` header.
15
+ //
16
+ // Claude shapes here MATCH `lib/hosted-worklog.ts` / `lib/hosted-agent.ts`
17
+ // (`formatClaudeEvent`, `parseStreamResult`) so ticket 0274 can swap the daemon's
18
+ // hand-rolled `lastLine` capture (see handler.mjs onData ~:787) for this parser
19
+ // with zero behavior change. NOTHING imports this yet except the test — wiring is
20
+ // 0274.
21
+
22
+ /**
23
+ * @typedef {object} AgentEvent
24
+ * A normalized, render-ready step. Exactly one of these per meaningful thing the
25
+ * coding agent did. Small on purpose — the LiveRunCard maps `t` to a label.
26
+ * @property {'phase'|'edit'|'run'|'read'|'think'|'note'|'session'|'result'} t
27
+ * - 'session' → { sessionId } the CLI's resumable session id (init only)
28
+ * - 'edit' → { path } wrote/edited a file
29
+ * - 'read' → { path } read a file
30
+ * - 'run' → { cmd } ran a shell command
31
+ * - 'note' → { text } the agent's narration / thinking (think→note)
32
+ * - 'result' → { ok, summary? } the run finished (ok=false on error)
33
+ * - 'phase' → { name } reserved lifecycle marker (unused by v1
34
+ * parsers; kept so 0274 can add start/end
35
+ * phases without widening the type)
36
+ * @property {string} [sessionId]
37
+ * @property {string} [path]
38
+ * @property {string} [cmd]
39
+ * @property {string} [text]
40
+ * @property {string} [name]
41
+ * @property {boolean} [ok]
42
+ * @property {string} [summary]
43
+ */
44
+
45
+ // --- Security: sanitize everything that reaches the UI ---------------------
46
+
47
+ const MAX_LEN = 300;
48
+ const REDACTED = "[redacted]";
49
+
50
+ // Token-shaped strings a CLI might echo (auth headers, keys it was handed, hex/
51
+ // base64 blobs). Order matters — specific prefixes before the generic blobs so a
52
+ // `ghp_…` is labelled, not swallowed by the base64 rule.
53
+ const SECRET_PATTERNS = [
54
+ /\bBearer\s+[A-Za-z0-9._\-]+/gi, // "Bearer <token>"
55
+ /\bhilos_[A-Za-z0-9]{8,}/g, // hilos MCP bearer tokens
56
+ /\bsbp_[A-Za-z0-9]{16,}/g, // Supabase access/management tokens (SUPABASE_ACCESS_TOKEN)
57
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g, // fine-grained GitHub PAT
58
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g, // ghp_/gho_/ghu_/ghs_/ghr_ GitHub tokens
59
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
60
+ /\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI/Anthropic-style secret keys
61
+ /\b[A-Fa-f0-9]{32,}\b/g, // long hex blobs
62
+ /\b[A-Za-z0-9+/]{40,}={0,2}\b/g, // long base64 blobs
63
+ ];
64
+
65
+ /**
66
+ * Strip token-shaped secrets and cap length. Exported so the server (0274+) can
67
+ * reuse the exact same redaction on anything it echoes. Never throws; non-string
68
+ * input → "".
69
+ * @param {unknown} value
70
+ * @returns {string}
71
+ */
72
+ export function sanitizeText(value) {
73
+ if (typeof value !== "string") return "";
74
+ let out = value;
75
+ for (const re of SECRET_PATTERNS) out = out.replace(re, REDACTED);
76
+ if (out.length > MAX_LEN) out = out.slice(0, MAX_LEN - 1) + "…";
77
+ return out;
78
+ }
79
+
80
+ // --- Line parsing per vendor ----------------------------------------------
81
+
82
+ /** JSON.parse that returns null instead of throwing on a non-JSON line. */
83
+ function tryParse(line) {
84
+ const trimmed = String(line || "").trim();
85
+ if (!trimmed) return null;
86
+ try {
87
+ const obj = JSON.parse(trimmed);
88
+ return obj && typeof obj === "object" ? obj : null;
89
+ } catch {
90
+ return null; // log noise / partial line / plain text → ignore
91
+ }
92
+ }
93
+
94
+ /** Map a Claude/agent tool name to a normalized event kind (or null to skip). */
95
+ function toolKind(name) {
96
+ switch (name) {
97
+ case "Edit":
98
+ case "MultiEdit":
99
+ case "Write":
100
+ case "NotebookEdit":
101
+ return "edit";
102
+ case "Read":
103
+ return "read";
104
+ case "Bash":
105
+ return "run";
106
+ default:
107
+ // Grep/Glob/WebFetch/Task/etc. carry less "alive" signal; ignore in v1 so
108
+ // an unknown tool can never crash the parser. 0274 may promote some.
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** One assistant content block → an AgentEvent, or null. */
114
+ function blockToEvent(block) {
115
+ if (!block || typeof block !== "object") return null;
116
+ if (block.type === "text" && typeof block.text === "string") {
117
+ const text = sanitizeText(block.text.replace(/\s+/g, " ").trim());
118
+ return text ? { t: "note", text } : null;
119
+ }
120
+ if (block.type === "tool_use" && typeof block.name === "string") {
121
+ const kind = toolKind(block.name);
122
+ if (!kind) return null;
123
+ const input = block.input && typeof block.input === "object" ? block.input : {};
124
+ if (kind === "run") {
125
+ const cmd = typeof input.command === "string" ? input.command.replace(/\s+/g, " ").trim() : "";
126
+ return { t: "run", cmd: sanitizeText(cmd) };
127
+ }
128
+ const raw = input.file_path ?? input.path ?? input.notebook_path;
129
+ const path = typeof raw === "string" ? sanitizeText(raw) : "";
130
+ return { t: kind, path };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Parse ONE Claude Code `stream-json` NDJSON line → AgentEvent[]. Shapes mirror
137
+ * `lib/hosted-agent.ts`: system init (session_id), assistant turns (text +
138
+ * tool_use), and the final result. Everything else (user tool_result, unknown
139
+ * types) → [].
140
+ */
141
+ function parseClaudeLine(line) {
142
+ const obj = tryParse(line);
143
+ if (!obj) return [];
144
+ if (obj.type === "system" && typeof obj.session_id === "string") {
145
+ return [{ t: "session", sessionId: sanitizeText(obj.session_id) }];
146
+ }
147
+ if (obj.type === "result") {
148
+ const ok = obj.is_error !== true && obj.subtype !== "error";
149
+ const summary = typeof obj.result === "string" ? sanitizeText(obj.result) : undefined;
150
+ return [summary ? { t: "result", ok, summary } : { t: "result", ok }];
151
+ }
152
+ if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
153
+ const out = [];
154
+ for (const block of obj.message.content) {
155
+ const ev = blockToEvent(block);
156
+ if (ev) out.push(ev);
157
+ }
158
+ return out;
159
+ }
160
+ return [];
161
+ }
162
+
163
+ /**
164
+ * Parse ONE Codex `--json` item line → AgentEvent[]. Best-effort / experimental:
165
+ * Codex's format is younger and less documented, so we read the kind from either
166
+ * `item.type` (the `{type:"item.completed", item:{…}}` envelope) or a bare
167
+ * top-level `type`, and pull fields from whichever object carries them. Unknown
168
+ * kinds → [].
169
+ */
170
+ function parseCodexLine(line) {
171
+ const obj = tryParse(line);
172
+ if (!obj) return [];
173
+ const item = obj.item && typeof obj.item === "object" ? obj.item : obj;
174
+ const kind = item.type ?? obj.type;
175
+ switch (kind) {
176
+ case "command_execution": {
177
+ const raw = item.command ?? item.cmd;
178
+ const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
179
+ return cmd ? [{ t: "run", cmd: sanitizeText(cmd) }] : [];
180
+ }
181
+ case "file_change": {
182
+ const changes = Array.isArray(item.changes)
183
+ ? item.changes
184
+ : item.path
185
+ ? [{ path: item.path }]
186
+ : [];
187
+ const out = [];
188
+ for (const c of changes) {
189
+ const p = c && (c.path ?? c.file_path);
190
+ if (typeof p === "string" && p) out.push({ t: "edit", path: sanitizeText(p) });
191
+ }
192
+ return out;
193
+ }
194
+ case "agent_message": {
195
+ const raw = item.text ?? item.message;
196
+ const text = typeof raw === "string" ? sanitizeText(raw.replace(/\s+/g, " ").trim()) : "";
197
+ return text ? [{ t: "note", text }] : [];
198
+ }
199
+ case "task_complete":
200
+ case "turn.completed": {
201
+ const raw = item.text ?? item.message ?? obj.text;
202
+ const summary = typeof raw === "string" ? sanitizeText(raw) : undefined;
203
+ return [summary ? { t: "result", ok: true, summary } : { t: "result", ok: true }];
204
+ }
205
+ default:
206
+ return [];
207
+ }
208
+ }
209
+
210
+ /** claude / claude_code / claude-code all mean the Claude parser. */
211
+ function normalizeVendor(vendor) {
212
+ const v = String(vendor || "").toLowerCase();
213
+ if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
214
+ if (v === "codex") return "codex";
215
+ return "cursor"; // cursor + ANY unknown vendor → lastLine fallback
216
+ }
217
+
218
+ // --- Streaming parsers -----------------------------------------------------
219
+
220
+ /**
221
+ * The daemon pushes raw stdout chunks (see handler.mjs onData ~:787) that can
222
+ * split a JSON object mid-line. A structured parser buffers the partial tail and
223
+ * only parses COMPLETE lines; flush() drains a trailing unterminated line.
224
+ * @param {(line: string) => AgentEvent[]} parseLine
225
+ */
226
+ function makeLineBufferedParser(parseLine) {
227
+ let buf = "";
228
+ return {
229
+ /** @param {string} chunk @returns {AgentEvent[]} */
230
+ push(chunk) {
231
+ buf += String(chunk ?? "");
232
+ const out = [];
233
+ let nl;
234
+ while ((nl = buf.indexOf("\n")) !== -1) {
235
+ const line = buf.slice(0, nl);
236
+ buf = buf.slice(nl + 1);
237
+ for (const ev of parseLine(line)) out.push(ev);
238
+ }
239
+ return out;
240
+ },
241
+ /** @returns {AgentEvent[]} */
242
+ flush() {
243
+ const out = [];
244
+ if (buf.trim()) for (const ev of parseLine(buf)) out.push(ev);
245
+ buf = "";
246
+ return out;
247
+ },
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Fallback for Cursor and any vendor with no structured stream. There's nothing
253
+ * to parse, so we only remember the last non-empty line seen — matching exactly
254
+ * what the daemon does today (handler.mjs onData) so we NEVER regress below it —
255
+ * and emit at most one sparse note on flush.
256
+ */
257
+ function makeCursorParser() {
258
+ let lastLine = "";
259
+ return {
260
+ /** @returns {AgentEvent[]} */
261
+ push(chunk) {
262
+ const lines = String(chunk ?? "")
263
+ .split("\n")
264
+ .map((s) => s.trim())
265
+ .filter(Boolean);
266
+ if (lines.length) lastLine = lines[lines.length - 1];
267
+ return []; // no structured events mid-stream
268
+ },
269
+ /** @returns {AgentEvent[]} */
270
+ flush() {
271
+ if (!lastLine) return [];
272
+ const text = sanitizeText(lastLine);
273
+ lastLine = "";
274
+ return text ? [{ t: "note", text }] : [];
275
+ },
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Build a stateful stream parser for `vendor`.
281
+ * @param {'claude'|'claude_code'|'codex'|'cursor'|string} vendor
282
+ * @returns {{ push: (chunk: string) => AgentEvent[], flush: () => AgentEvent[] }}
283
+ */
284
+ export function makeStreamParser(vendor) {
285
+ const v = normalizeVendor(vendor);
286
+ if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
287
+ if (v === "codex") return makeLineBufferedParser(parseCodexLine);
288
+ return makeCursorParser();
289
+ }
290
+
291
+ // --- Human step labels (what the LiveRunCard shows) ------------------------
292
+
293
+ /** Describe a shell command in plain words (design rule: no emoji, calm copy). */
294
+ function describeRun(cmd) {
295
+ const c = String(cmd || "").trim();
296
+ if (!c) return "Running a command";
297
+ if (/\b(test|tests)\b|vitest|jest|pytest|\bgo test\b/i.test(c)) return "Running tests";
298
+ const short = c.length > 60 ? c.slice(0, 59) + "…" : c;
299
+ return `Running ${short}`;
300
+ }
301
+
302
+ /**
303
+ * One AgentEvent → a human step label, or null for events that aren't shown as
304
+ * steps (session/phase are metadata). Pure.
305
+ * @param {AgentEvent} event
306
+ * @returns {string | null}
307
+ */
308
+ export function stepLabel(event) {
309
+ switch (event && event.t) {
310
+ case "edit":
311
+ return event.path ? `Editing ${event.path}` : "Editing files";
312
+ case "read":
313
+ return event.path ? `Reading ${event.path}` : "Reading files";
314
+ case "run":
315
+ return describeRun(event.cmd);
316
+ case "note":
317
+ return event.text ? event.text : null;
318
+ case "result":
319
+ return event.ok ? "Done" : "Finished with errors";
320
+ default:
321
+ return null; // session / phase / unknown → not a step
322
+ }
323
+ }
324
+
325
+ /**
326
+ * A bounded ring buffer of the most recent human step labels. Coalesces
327
+ * sequential edits to the SAME file into one step (a run that touches one file
328
+ * ten times reads as one "Editing …" line) and drops immediate duplicate
329
+ * labels. `limit` steps kept (default 8) — the card shows a short rolling window.
330
+ * @param {number} [limit]
331
+ */
332
+ export function createStepRing(limit = 8) {
333
+ const steps = [];
334
+ let lastEditPath = null; // for collapsing consecutive same-file edits
335
+
336
+ /** @param {AgentEvent} event */
337
+ function push(event) {
338
+ const label = stepLabel(event);
339
+ if (!label) return;
340
+ if (event.t === "edit") {
341
+ if (lastEditPath === event.path) return; // same file again → collapse
342
+ lastEditPath = event.path;
343
+ } else {
344
+ lastEditPath = null; // any other step breaks an edit run
345
+ }
346
+ if (steps.length && steps[steps.length - 1] === label) return; // dup guard
347
+ steps.push(label);
348
+ if (steps.length > limit) steps.shift();
349
+ }
350
+
351
+ return {
352
+ push,
353
+ /** @returns {string[]} */
354
+ labels: () => steps.slice(),
355
+ /** @returns {string | null} */
356
+ latest: () => (steps.length ? steps[steps.length - 1] : null),
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Fold a batch of events through a fresh ring → the coalesced step labels. Pure
362
+ * convenience over createStepRing for callers that have the whole list.
363
+ * @param {AgentEvent[]} events
364
+ * @param {number} [limit]
365
+ * @returns {string[]}
366
+ */
367
+ export function summarizeSteps(events, limit = 8) {
368
+ const ring = createStepRing(limit);
369
+ for (const ev of events || []) ring.push(ev);
370
+ return ring.labels();
371
+ }
package/src/config.mjs CHANGED
@@ -63,6 +63,11 @@ const DEFAULTS = {
63
63
  // spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
64
64
  // UPDATEs. (Distinct from cli.mjs's 15s LOCAL terminal heartbeat.)
65
65
  heartbeatMs: 180000,
66
+ // Live-progress throttle (0274): when the server supports post_progress, the
67
+ // code run streams a coalesced "what I'm doing right now" card instead of the
68
+ // 3-min heartbeat. This caps how often it pushes an UPDATE (leading+trailing).
69
+ // Clamped to >=750ms so a misconfig can't hammer realtime.
70
+ progressMs: 2000,
66
71
  // Cap a chat reply / plan-ack so a stalled model can't dead-air the channel;
67
72
  // on timeout we post an honest "taking longer than expected" line.
68
73
  chatTimeoutMs: 90000,
@@ -88,6 +93,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
88
93
  codingCmd: process.env.CODING_CMD,
89
94
  chatCmd: process.env.HILOS_CHAT_CMD,
90
95
  heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
96
+ progressMs: process.env.HILOS_PROGRESS_MS ? Number(process.env.HILOS_PROGRESS_MS) : undefined,
91
97
  chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
92
98
  backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
93
99
  once: process.env.HILOS_ONCE === "1" ? true : undefined,
@@ -96,6 +102,8 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
96
102
  for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
97
103
  // Clamp the chat heartbeat: 0 (off) stays off, otherwise never below 15s.
98
104
  if (merged.heartbeatMs > 0) merged.heartbeatMs = Math.max(15000, Number(merged.heartbeatMs) || 0);
105
+ // Clamp the live-progress throttle to a >=750ms floor (a bad value → the default).
106
+ merged.progressMs = Math.max(750, Number(merged.progressMs) || 2000);
99
107
  if (joinPayload) {
100
108
  merged.url = joinPayload.url;
101
109
  merged.token = joinPayload.token;
@@ -122,6 +130,7 @@ const LIVE_FIELDS = [
122
130
  "decisionTimeoutMs",
123
131
  "decisionPollMs",
124
132
  "heartbeatMs",
133
+ "progressMs",
125
134
  "chatTimeoutMs",
126
135
  // NOT queueConcurrency: the queue is built once at startup, so it can't change
127
136
  // live. queueAcks IS live (intake() reads it per-mention from liveCfg).
@@ -146,8 +155,10 @@ export function reloadConfig(prev) {
146
155
  if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
147
156
  if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
148
157
  if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
158
+ if (process.env.HILOS_PROGRESS_MS) next.progressMs = Number(process.env.HILOS_PROGRESS_MS);
149
159
  if (process.env.HILOS_CHAT_TIMEOUT_MS) next.chatTimeoutMs = Number(process.env.HILOS_CHAT_TIMEOUT_MS);
150
160
  if (next.heartbeatMs > 0) next.heartbeatMs = Math.max(15000, Number(next.heartbeatMs) || 0);
161
+ next.progressMs = Math.max(750, Number(next.progressMs) || 2000);
151
162
  if (file.repos) next.repos = { ...DEFAULTS.repos, ...file.repos };
152
163
  return next;
153
164
  }
package/src/daemon.mjs CHANGED
@@ -89,3 +89,21 @@ export function mentionHandle(name) {
89
89
  .replace(/[^a-z0-9]+/g, "-")
90
90
  .replace(/^-+|-+$/g, "");
91
91
  }
92
+
93
+ /**
94
+ * A GitHub PR reference embedded in `text` for `repoFullName`, or null. The
95
+ * server tags a "request changes" rework ping with the existing PR's URL
96
+ * ("…update the PR <url>…"), so the daemon can update THAT PR's branch
97
+ * (same-branch iteration) instead of opening a brand-new one.
98
+ *
99
+ * Anchored on a "PR <url>" lead-in (and scoped to the channel's repo) so only an
100
+ * explicit "update the PR <url>" — the server ping, or a person naming a PR —
101
+ * triggers continuation. A stray reference link to some other PR (same or other
102
+ * repo, quoted for context) must NOT hijack the branch.
103
+ */
104
+ export function detectPrContinuation(text, repoFullName) {
105
+ const m = /\bPR\s+(https?:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+))/i.exec(String(text || ""));
106
+ if (!m) return null;
107
+ if (repoFullName && m[2].toLowerCase() !== String(repoFullName).toLowerCase()) return null;
108
+ return { url: m[1], repoFullName: m[2], number: Number(m[3]) };
109
+ }
@@ -0,0 +1,161 @@
1
+ // Same-PR follow-up resolver (0281) — the decision core for "a human replied in a
2
+ // thread asking for a change; do we continue the PR this thread already owns, or
3
+ // fork a new one?" Pure + dependency-free (it needs no imports at all) so it
4
+ // unit-tests without any I/O and is shared by the daemon (handler.mjs) and the
5
+ // hosted path (lib/, via TS import).
6
+ //
7
+ // The PRIMARY classifier is the LLM (the daemon's routeIntent reads intent in any
8
+ // language, in context); the cue lists below are ONLY a hint for that prompt and a
9
+ // deterministic FALLBACK when the model is unavailable. Never let the cue lists be
10
+ // the main brain — they're the safety net.
11
+
12
+ /**
13
+ * The three reads of a follow-up message, relative to the run its thread owns:
14
+ * 'change' — continue/adjust that work ("also make it blue", "actually revert")
15
+ * 'new-scope' — explicitly wants a SEPARATE PR ("do that in a new PR")
16
+ * 'ambiguous' — unclear; a plain follow-up with no explicit fresh-PR cue
17
+ * @typedef {'change' | 'new-scope' | 'ambiguous'} FollowupSignal
18
+ */
19
+
20
+ /**
21
+ * The routing decision:
22
+ * 'iterate' — continue the SAME branch/PR the thread already owns
23
+ * 'redirect' — supersede the old run and start a fresh, separate PR
24
+ * 'new' — today's default: a brand-new branch + run (no run to continue)
25
+ * @typedef {'iterate' | 'new' | 'redirect'} FollowupMode
26
+ */
27
+
28
+ /**
29
+ * Illustrative "continue this work" cues, multi-language friendly. NOT exhaustive
30
+ * and NOT the primary classifier — a hint for the LLM prompt and the deterministic
31
+ * fallback. Matched as substrings against a normalized (lowercased,
32
+ * punctuation-collapsed) message.
33
+ */
34
+ export const CHANGE_CUES = [
35
+ // English
36
+ "also",
37
+ "actually",
38
+ "instead",
39
+ "revert",
40
+ "undo",
41
+ "roll back",
42
+ "change it",
43
+ "make it",
44
+ "tweak",
45
+ "adjust",
46
+ "one more",
47
+ "on second thought",
48
+ "same pr",
49
+ "keep going",
50
+ // Spanish
51
+ "también",
52
+ "en vez",
53
+ "revierte",
54
+ "deshaz",
55
+ "cámbialo",
56
+ "ajusta",
57
+ // Portuguese / French / German (small illustrative set)
58
+ "ao invés",
59
+ "au lieu",
60
+ "annule",
61
+ "stattdessen",
62
+ ];
63
+
64
+ /**
65
+ * Illustrative "make it a SEPARATE PR" cues — an explicit request to fork rather
66
+ * than continue. Same role: LLM hint + deterministic fallback.
67
+ */
68
+ export const NEW_SCOPE_CUES = [
69
+ // English
70
+ "separate pr",
71
+ "separate branch",
72
+ "new pr",
73
+ "another pr",
74
+ "different pr",
75
+ "fresh pr",
76
+ "its own pr",
77
+ "own pr",
78
+ "new branch",
79
+ "as a new",
80
+ "in a new",
81
+ // Spanish
82
+ "pr aparte",
83
+ "pr separado",
84
+ "otro pr",
85
+ "nuevo pr",
86
+ "rama aparte",
87
+ // Portuguese / French
88
+ "nova pr",
89
+ "pr séparée",
90
+ ];
91
+
92
+ /** Lowercase + collapse any run of non-alphanumeric (unicode letters kept) to a
93
+ * single space, so matching is case- AND punctuation-insensitive. */
94
+ function normalize(text) {
95
+ return String(text || "")
96
+ .toLowerCase()
97
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
98
+ .replace(/\s+/g, " ")
99
+ .trim();
100
+ }
101
+
102
+ /** True when the normalized haystack contains the normalized cue, so "new PR!"
103
+ * matches "new pr". */
104
+ function hasCue(normalizedHaystack, cue) {
105
+ const c = normalize(cue);
106
+ if (!c) return false;
107
+ return normalizedHaystack === c || normalizedHaystack.includes(c);
108
+ }
109
+
110
+ /**
111
+ * Deterministic cue-based classifier — the FALLBACK when the LLM produced no
112
+ * signal. Explicit new-scope cues win over change cues (an explicit "in a new PR"
113
+ * is a stronger intent than a stray "also"); nothing matched → 'ambiguous'.
114
+ * @param {string} text
115
+ * @returns {FollowupSignal}
116
+ */
117
+ export function classifyFollowupCue(text) {
118
+ const h = normalize(text);
119
+ if (!h) return "ambiguous";
120
+ if (NEW_SCOPE_CUES.some((c) => hasCue(h, c))) return "new-scope";
121
+ if (CHANGE_CUES.some((c) => hasCue(h, c))) return "change";
122
+ return "ambiguous";
123
+ }
124
+
125
+ /** Coerce an arbitrary model/string signal into the enum; unknown → 'ambiguous'. */
126
+ export function normalizeSignal(raw) {
127
+ const s = normalize(raw);
128
+ if (s === "new scope" || s === "newscope" || s === "new" || s === "separate") return "new-scope";
129
+ if (s === "change" || s === "iterate" || s === "continue") return "change";
130
+ if (s === "ambiguous" || s === "unsure" || s === "unknown") return "ambiguous";
131
+ return "ambiguous";
132
+ }
133
+
134
+ /**
135
+ * The truth table. Deliberately small + exhaustive over
136
+ * (hasActiveRun × prOpen × signal):
137
+ *
138
+ * hasActiveRun | prOpen | signal -> mode
139
+ * -------------|--------|-------------|----------
140
+ * false | * | * -> 'new' (today's behavior — nothing to continue)
141
+ * true | true | 'change' -> 'iterate' (continue the same PR)
142
+ * true | true | 'ambiguous' -> 'iterate' (default a plain reply to the open PR it belongs to)
143
+ * true | true | 'new-scope' -> 'redirect' (explicit "separate PR" — supersede + fork)
144
+ * true | false | * -> 'new' (PR merged/closed — NEVER iterate onto it)
145
+ *
146
+ * Safety: iterate REQUIRES an open PR, so a false 'iterate' can't push a genuinely
147
+ * new task onto a merged/closed PR; an explicit new-scope always forks even when a
148
+ * PR is open.
149
+ *
150
+ * @param {{ hasActiveRun: boolean, prOpen: boolean, signal: FollowupSignal }} o
151
+ * @returns {FollowupMode}
152
+ */
153
+ export function resolveFollowupMode({ hasActiveRun, prOpen, signal } = {}) {
154
+ if (!hasActiveRun) return "new";
155
+ // Recency guard: only ever continue an OPEN PR. A run whose PR is merged/closed
156
+ // (or that never opened one) starts fresh — a follow-up there is new work.
157
+ if (!prOpen) return "new";
158
+ if (signal === "new-scope") return "redirect";
159
+ // 'change' or 'ambiguous' on an open PR → continue it.
160
+ return "iterate";
161
+ }