hilos-agent 0.7.0 → 0.9.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,305 @@
1
+ // The thread's pull request, daemon side (0704). 0701 resolves "which PR is this
2
+ // thread about?" once on the server and serves the answer to every substrate; the
3
+ // daemon reads it off `list_mentions` as `message.threadPr` and uses it for two
4
+ // decisions:
5
+ //
6
+ // 1. ADOPT vs FRESH — a mention in a PR thread means "continue THIS pull
7
+ // request": fetch its head branch, push onto it, never `git checkout -b`.
8
+ // 2. RELAY — "merge it" / "close it" is executed through the merge_pr /
9
+ // close_pr MCP tools rather than answered with "a human has to click".
10
+ //
11
+ // Pure + dependency-free apart from followup.mjs (the ONE truth table, shared
12
+ // with the server), so every rule below unit-tests without git, gh, or a network.
13
+ //
14
+ // Both decisions are deliberately conservative. The daemon is not the authority
15
+ // on either one: the server confirms the PR is really open before an iterate can
16
+ // land (and here `gh` does too), and `executePrAction` owns the merge/close gate
17
+ // — it re-classifies the cited instruction with a forced-tool model call, checks
18
+ // the author's workspace role, and refuses fail-closed. A daemon false NEGATIVE
19
+ // costs one extra mention; a daemon false POSITIVE is still caught server-side.
20
+
21
+ import { resolveFollowupMode } from "./followup.mjs";
22
+
23
+ /**
24
+ * @typedef {Object} ThreadPrAnchor
25
+ * @property {string} repoFullName
26
+ * @property {number} prNumber
27
+ * @property {string} prUrl
28
+ * @property {string|null} state 'open' | 'merged' | 'closed' | null (unknown)
29
+ * @property {string|null} branch head branch when the evidence recorded one
30
+ * @property {string|null} source 'run' | 'report' | 'message' | 'link'
31
+ */
32
+
33
+ /**
34
+ * Sanitize the `threadPr` block off a list_mentions payload. Everything here
35
+ * arrives over the wire, so nothing is trusted: a malformed anchor is simply no
36
+ * anchor, and the daemon behaves exactly as it did before 0704.
37
+ * @param {any} raw
38
+ * @returns {ThreadPrAnchor|null}
39
+ */
40
+ export function normalizeThreadPr(raw) {
41
+ if (!raw || typeof raw !== "object") return null;
42
+ const repoFullName = typeof raw.repoFullName === "string" ? raw.repoFullName.trim() : "";
43
+ const prUrl = typeof raw.prUrl === "string" ? raw.prUrl.trim() : "";
44
+ const prNumber = Number(raw.prNumber);
45
+ if (!repoFullName || !prUrl) return null;
46
+ if (!Number.isInteger(prNumber) || prNumber <= 0) return null;
47
+ const state = typeof raw.state === "string" && raw.state.trim() ? raw.state.trim().toLowerCase() : null;
48
+ const branch = typeof raw.branch === "string" && raw.branch.trim() ? raw.branch.trim() : null;
49
+ const source = typeof raw.source === "string" && raw.source.trim() ? raw.source.trim() : null;
50
+ return { repoFullName, prNumber, prUrl, state, branch, source };
51
+ }
52
+
53
+ /** Case-insensitive `owner/repo` equality — GitHub is case-insensitive on both. */
54
+ function sameRepo(a, b) {
55
+ return String(a || "").toLowerCase() === String(b || "").toLowerCase();
56
+ }
57
+
58
+ /**
59
+ * Should this mention adopt the thread's pull request instead of cutting a new
60
+ * branch? Mirrors the server's thread-anchor fallback in app/api/agent/route.ts
61
+ * signal for signal:
62
+ *
63
+ * - Only THREAD-LOCAL evidence may direct code onto a PR. A 'link' anchor is
64
+ * channel-wide — it would route an unrelated thread's work onto whatever PR
65
+ * the room happens to have linked.
66
+ * - The anchor must live in the repo this channel clones. A pasted cross-repo
67
+ * PR is context for chat and review, never an iterate target: the daemon
68
+ * would push a same-named branch into the wrong repository.
69
+ * - A known-terminal anchor (merged/closed) never adopts. Unknown state is
70
+ * only a hypothesis — `gh` proves it before anything is checked out
71
+ * (see confirmAnchorOpen).
72
+ * - The shared truth table decides intent: an explicit "in a separate PR"
73
+ * forks even inside a PR thread.
74
+ *
75
+ * @param {{ threadPr: any, repoFullName: string, signal: string|null|undefined }} o
76
+ * @returns {{ adopt: boolean, reason: string, anchor: ThreadPrAnchor|null }}
77
+ */
78
+ export function anchorIterateDecision({ threadPr, repoFullName, signal } = {}) {
79
+ const anchor = normalizeThreadPr(threadPr);
80
+ if (!anchor) return { adopt: false, reason: "no-anchor", anchor: null };
81
+ if (anchor.source === "link") return { adopt: false, reason: "link-anchor", anchor };
82
+ if (!sameRepo(anchor.repoFullName, repoFullName)) {
83
+ return { adopt: false, reason: "other-repo", anchor };
84
+ }
85
+ if (anchor.state === "merged" || anchor.state === "closed") {
86
+ return { adopt: false, reason: `anchor-${anchor.state}`, anchor };
87
+ }
88
+ // prOpen:true is the hypothesis this function is allowed to make (the terminal
89
+ // states are already out); hasActiveRun:true says "this thread owns work" —
90
+ // the anchor IS that work, whether or not a run row survived.
91
+ const mode = resolveFollowupMode({
92
+ hasActiveRun: true,
93
+ prOpen: true,
94
+ signal: signal || "ambiguous",
95
+ });
96
+ if (mode !== "iterate") return { adopt: false, reason: "new-scope", anchor };
97
+ return { adopt: true, reason: "adopt", anchor };
98
+ }
99
+
100
+ /**
101
+ * The staleness guard. `live` is what `gh pr view --json state,headRefName,…`
102
+ * reported for the anchor; this decides whether the daemon may check that branch
103
+ * out and push to it. Same shape as the server's confirmOpenPr: open, with a head
104
+ * branch, living in the base repo (a fork head is another repository's branch —
105
+ * pushing "to it" would touch an unrelated same-named branch here).
106
+ *
107
+ * No proof, no adoption: an unreachable `gh` returns null and the run starts a
108
+ * fresh branch rather than gambling on a possibly-merged PR.
109
+ *
110
+ * @param {{ live: any, anchor: ThreadPrAnchor }} o
111
+ * @returns {{ ok: boolean, branch: string|null, reason: string }}
112
+ */
113
+ export function confirmAnchorOpen({ live, anchor } = {}) {
114
+ if (!anchor) return { ok: false, branch: null, reason: "no-anchor" };
115
+ if (!live || typeof live !== "object") return { ok: false, branch: null, reason: "unverified" };
116
+ const state = typeof live.state === "string" ? live.state.trim().toLowerCase() : "";
117
+ if (state === "merged" || state === "closed") return { ok: false, branch: null, reason: state };
118
+ if (state !== "open") return { ok: false, branch: null, reason: "unverified" };
119
+ const headRepo = typeof live.headRepoFullName === "string" ? live.headRepoFullName : null;
120
+ if (headRepo && !sameRepo(headRepo, anchor.repoFullName)) {
121
+ return { ok: false, branch: null, reason: "fork-head" };
122
+ }
123
+ const branch = typeof live.headRefName === "string" && live.headRefName.trim() ? live.headRefName.trim() : null;
124
+ if (!branch) return { ok: false, branch: null, reason: "unverified" };
125
+ return { ok: true, branch, reason: "open" };
126
+ }
127
+
128
+ /**
129
+ * One line for the run's status card when an intended adoption did NOT happen —
130
+ * the person asked inside a PR thread, so silence would read as "it's pushing to
131
+ * my PR" while a fresh branch is being cut. Returns "" when there is nothing to
132
+ * confess (the reasons that are simply "this isn't an iterate").
133
+ * @param {{ reason: string, anchor: ThreadPrAnchor|null }} o
134
+ */
135
+ export function staleAnchorNote({ reason, anchor } = {}) {
136
+ if (!anchor) return "";
137
+ const pr = `PR #${anchor.prNumber}`;
138
+ if (reason === "merged" || reason === "anchor-merged") {
139
+ return `${pr} is already merged, so this goes on a new branch instead.`;
140
+ }
141
+ if (reason === "closed" || reason === "anchor-closed") {
142
+ return `${pr} is closed, so this goes on a new branch instead.`;
143
+ }
144
+ if (reason === "fork-head") {
145
+ return `${pr} is from a fork, so I can't push to its branch — this goes on a new branch instead.`;
146
+ }
147
+ if (reason === "unverified") {
148
+ return `I couldn't confirm ${pr} is still open, so this goes on a new branch instead.`;
149
+ }
150
+ if (reason === "checkout-failed") {
151
+ return `I couldn't check out ${pr}'s branch, so this goes on a new branch instead.`;
152
+ }
153
+ return "";
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Merge / close cue classification
158
+ // ---------------------------------------------------------------------------
159
+ //
160
+ // The server's own merge/close intent is an LLM call (decideAgentIntent with
161
+ // allowPrActions). The daemon cannot reach it: the MCP `classify_agent_intent`
162
+ // tool exposes ask/ship/deploy only — it never offers review/merge/close — so
163
+ // there is no shared classifier to import here. Per 0704 this is therefore a
164
+ // deliberately narrow phrase matcher over explicit instructions, and it is a
165
+ // TRIGGER, not an authority: every match is handed to merge_pr / close_pr, where
166
+ // the server re-classifies the cited human message with a forced-tool model call
167
+ // before it spends the App's write token.
168
+ //
169
+ // Bias: miss rather than guess. A missed cue costs one clearer sentence.
170
+
171
+ /** The pronouns/nouns an instruction can name the PR with. */
172
+ const OBJECT = String.raw`(?:it|this|that|them|the\s+pr|this\s+pr|that\s+pr|the\s+pull\s+request|this\s+pull\s+request|that\s+pull\s+request|it\s+in|this\s+one|that\s+one|el\s+pr|la\s+pr)`;
173
+ const PR_REF = String.raw`(?:#\d{1,6}|https?:\/\/\S*\/pull\/\d{1,6})`;
174
+
175
+ const MERGE_PATTERNS = [
176
+ new RegExp(String.raw`\bmerge\s+${OBJECT}\b`, "i"),
177
+ new RegExp(String.raw`\bmerge\s+${PR_REF}`, "i"),
178
+ // "land it" is unambiguous for a pull request. "ship it" is NOT — it is also
179
+ // the daemon's own word for "go do the work", so it stays out of this list.
180
+ new RegExp(String.raw`\bland\s+${OBJECT}\b`, "i"),
181
+ /\bmerge\s+away\b/i,
182
+ // Spanish / Portuguese imperatives, matching the multi-language spirit of the
183
+ // follow-up cue lists.
184
+ /\b(?:mergea(?:lo|la)?|merg[eé]alo|haz\s+merge|fusiona(?:lo|la)?|fusi[oó]nal[oa])\b/i,
185
+ ];
186
+
187
+ const CLOSE_PATTERNS = [
188
+ new RegExp(String.raw`\bclose\s+${OBJECT}\b`, "i"),
189
+ new RegExp(String.raw`\bclose\s+${PR_REF}`, "i"),
190
+ new RegExp(String.raw`\b(?:abandon|discard|scrap)\s+${OBJECT}\b`, "i"),
191
+ /\b(?:cierra(?:lo|la)?|ci[eé]rral[oa]|descarta(?:lo|la)?|desc[aá]rtal[oa])\b/i,
192
+ ];
193
+
194
+ /** A sentence that says the opposite, defers, or hypothesizes is not an
195
+ * instruction. Checked against the sentence the cue appeared in. */
196
+ const DISQUALIFIERS = [
197
+ // Negation and deferral.
198
+ /\b(?:don'?t|do\s+not|dont|never|no\s+need|hold\s+off|not\s+yet|wait|hang\s+on|instead\s+of)\b/i,
199
+ /\bno\s+(?:lo|la|le)\b/i,
200
+ /\b(?:todav[ií]a|a[uú]n)\s+no\b/i,
201
+ // Conditional / sequenced — "once CI passes, merge it" is a plan, not a go.
202
+ /\b(?:if|once|when|after|before|unless|until|assuming|in\s+case|whenever)\b/i,
203
+ /\b(?:si|cuando|despu[eé]s|antes)\b/i,
204
+ // Someone else is doing it.
205
+ /\b(?:i|we)(?:'|’)?(?:ll|\s+will|\s+am\s+going\s+to|\s+can|\s+should)\s+(?:merge|close)\b/i,
206
+ /\b(?:i|we)\s+(?:just\s+)?(?:merged|closed)\b/i,
207
+ // Vocabulary that contains the verb but means something else entirely.
208
+ /\bmerge\s+(?:conflict|commit|base|queue|strategy)/i,
209
+ /\bclose\s+(?:call|enough|to)\b/i,
210
+ /\b(?:already|just)\s+(?:merged|closed)\b/i,
211
+ ];
212
+
213
+ /** Politeness markers that keep a question-shaped sentence an instruction. */
214
+ const POLITE_REQUEST = /\b(?:can\s+you|could\s+you|would\s+you|will\s+you|please|puedes|podr[ií]as|por\s+favor)\b/i;
215
+
216
+ /** Strip `@handle` mentions so "@scout merge it" reads as "merge it". */
217
+ function stripMentions(text) {
218
+ return String(text || "").replace(/(^|\s)@[a-z0-9][a-z0-9._-]*/gi, " ");
219
+ }
220
+
221
+ /** Sentences, roughly. Newlines end a sentence too — chat rarely punctuates. */
222
+ function sentences(text) {
223
+ return text
224
+ .split(/(?<=[.!?…])\s+|\n+/)
225
+ .map((s) => s.trim())
226
+ .filter(Boolean);
227
+ }
228
+
229
+ function isInstruction(sentence) {
230
+ if (DISQUALIFIERS.some((re) => re.test(sentence))) return false;
231
+ // A question is a question ("should we merge it?") unless it's the polite
232
+ // shape of an order ("can you merge it?").
233
+ if (/\?\s*$/.test(sentence) && !POLITE_REQUEST.test(sentence)) return false;
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Does this message explicitly instruct a merge or a close of the thread's PR?
239
+ * Returns 'merge', 'close', or null. A message that appears to ask for both gets
240
+ * null — an ambiguous instruction is not one.
241
+ * @param {string} text
242
+ * @returns {'merge'|'close'|null}
243
+ */
244
+ export function classifyPrActionCue(text) {
245
+ const body = stripMentions(text);
246
+ if (!body.trim()) return null;
247
+ let merge = false;
248
+ let close = false;
249
+ for (const sentence of sentences(body)) {
250
+ if (!isInstruction(sentence)) continue;
251
+ if (MERGE_PATTERNS.some((re) => re.test(sentence))) merge = true;
252
+ if (CLOSE_PATTERNS.some((re) => re.test(sentence))) close = true;
253
+ }
254
+ if (merge && close) return null;
255
+ if (merge) return "merge";
256
+ if (close) return "close";
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * Should this mention be relayed to merge_pr / close_pr instead of routed as
262
+ * chat or code? Every "no" is a fall-through to today's behavior — the relay
263
+ * only ever ADDS a path.
264
+ *
265
+ * Requires an anchor: without one the daemon has no idea which PR "it" is, and
266
+ * guessing is exactly the failure 0701 exists to prevent. It deliberately does
267
+ * NOT check whether the anchor is in this channel's repo, whether the asker is
268
+ * allowed, or whether the message names a different PR — those are the server's
269
+ * calls, and its refusals are better written than anything decided here.
270
+ *
271
+ * @param {{ body: string, threadPr: any, mode: string|null, canRelay: boolean }} o
272
+ * @returns {{ relay: boolean, action: 'merge'|'close'|null, reason: string, anchor: ThreadPrAnchor|null }}
273
+ */
274
+ export function prActionRelayDecision({ body, threadPr, mode, canRelay } = {}) {
275
+ const anchor = normalizeThreadPr(threadPr);
276
+ const action = classifyPrActionCue(body);
277
+ if (!action) return { relay: false, action: null, reason: "no-cue", anchor };
278
+ // Chat only is the human saying this agent does not act here (the server
279
+ // refuses PR actions the same way when mayExecute is false).
280
+ if (mode === "ask") return { relay: false, action, reason: "chat-only", anchor };
281
+ if (!canRelay) return { relay: false, action, reason: "no-tool", anchor };
282
+ if (!anchor) return { relay: false, action, reason: "no-anchor", anchor };
283
+ return { relay: true, action, reason: "relay", anchor };
284
+ }
285
+
286
+ /**
287
+ * What to say after a relay. The server posts the outcome of every executed
288
+ * action AND every refusal that reached executePrAction (its `message`), so
289
+ * repeating it would double-post in the thread. Only the tool-level refusals —
290
+ * which come back as `error` and are posted by nobody — need a message from the
291
+ * daemon, and they are surfaced verbatim: the server's wording is the authority.
292
+ * @param {any} result the parsed merge_pr / close_pr tool result
293
+ * @returns {{ post: string|null, ok: boolean, alreadyPosted: boolean }}
294
+ */
295
+ export function prActionReply(result) {
296
+ if (!result || typeof result !== "object") {
297
+ return { post: null, ok: false, alreadyPosted: false };
298
+ }
299
+ if (result.ok === false && typeof result.error === "string" && result.error.trim()) {
300
+ return { post: result.error, ok: false, alreadyPosted: false };
301
+ }
302
+ // ok:true, or an executePrAction refusal — either way it already spoke in the
303
+ // thread with its own words.
304
+ return { post: null, ok: result.ok === true, alreadyPosted: true };
305
+ }