hilos-agent 0.10.0 → 0.11.1
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 +44 -5
- package/bin/hilos-agent.mjs +47 -0
- package/package.json +2 -2
- package/src/cli.mjs +29 -1
- package/src/codex-mcp-session.mjs +3 -1
- package/src/config.mjs +18 -0
- package/src/conflicts.mjs +156 -0
- package/src/handler.mjs +963 -108
- package/src/local-harness-events.mjs +370 -0
- package/src/local-harness-transport.mjs +157 -0
- package/src/progress-emitter.mjs +16 -32
- package/src/redact.mjs +360 -7
- package/src/run-inputs.mjs +211 -0
- package/src/run.mjs +27 -0
- package/src/thread-pr.mjs +26 -4
- package/src/web-tools.mjs +188 -0
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
|
50
50
|
"codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "opencode run", "agy -p", any command
|
|
51
51
|
"codingModel": "", // Codex tier ("most-capable" | "balanced" | "fastest"), resolved against this account's own model list; "" = the tool's default
|
|
52
52
|
"chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
|
|
53
|
+
"webSearch": true, // native public-web tools are on by default; false stops Hilos enablement/instructions
|
|
53
54
|
"defaultBranch": "main",
|
|
54
55
|
"gate": false, // default: open a PR directly. true = approve-before-push
|
|
55
56
|
"heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
|
|
@@ -71,10 +72,19 @@ a stderr tail) instead of claiming "no changes". Chat replies use the faster
|
|
|
71
72
|
`chatCmd` (when unset, derived from `codingCmd`'s tool — a Claude daemon chats
|
|
72
73
|
with Haiku, a Codex daemon with `codex exec`, and so on) bounded by
|
|
73
74
|
`chatTimeoutMs`. The chat-vs-code pass only classifies; the separate chat
|
|
74
|
-
responder keeps the CLI's normal tools.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
responder keeps the CLI's normal tools. Public web is on by default and stays
|
|
76
|
+
native to the selected CLI: Hilos pre-approves Claude Code's read-only
|
|
77
|
+
`WebSearch`/`WebFetch`, enables Codex search, turns on OpenCode's official Exa
|
|
78
|
+
search switch, preserves Hermes's full web-capable coding toolset, and keeps
|
|
79
|
+
Cursor and Antigravity's built-ins. Generated chat replies use Cursor `ask`
|
|
80
|
+
with safe auto-review, OpenCode
|
|
81
|
+
`plan`, Antigravity `plan`, Codex's read-only sandbox, or Hermes `safe,web`
|
|
82
|
+
where available; coding runs keep their normal tool/approval policy. Run
|
|
83
|
+
`hilos-agent web doctor` to inspect the configured contract. Set
|
|
84
|
+
`"webSearch": false`, `HILOS_WEB_SEARCH=0`, or `--no-web-search` to stop Hilos
|
|
85
|
+
from enabling or requesting web use; the CLI's own global/project tool policy
|
|
86
|
+
still applies. Custom commands retain their own tools without guessed flags.
|
|
87
|
+
The responsive surface needs a hilos server new enough to expose
|
|
78
88
|
`edit_message`; older servers just skip the live edits.
|
|
79
89
|
|
|
80
90
|
```sh
|
|
@@ -148,7 +158,16 @@ can never approve or merge hilos work. See the complete contract in
|
|
|
148
158
|
PR thread is executed, not described. The daemon relays the request to hilos
|
|
149
159
|
with the id of the message that asked; hilos verifies the person's role and
|
|
150
160
|
that their message really asks for it, then acts with the workspace's GitHub
|
|
151
|
-
App.
|
|
161
|
+
App. If GitHub reports a same-repository merge conflict, the daemon keeps the
|
|
162
|
+
same turn alive: it fetches the base branch, starts a real two-parent merge on
|
|
163
|
+
the existing PR branch, names the conflicted files to the coding agent,
|
|
164
|
+
verifies that no unmerged entries or conflict markers remain, and pushes the
|
|
165
|
+
repaired head for fresh human review. It never opens a replacement PR or
|
|
166
|
+
silently merges after repair. Fork PRs stop with an explicit writable-branch
|
|
167
|
+
boundary. The daemon never merges on its own judgment and holds no merge
|
|
168
|
+
rights. With `gate:true`, this existing-branch repair still pushes so the PR
|
|
169
|
+
can return for fresh review. It is the only approve-before-push exception;
|
|
170
|
+
the repaired head remains unmerged and needs a new approval.
|
|
152
171
|
- **Open a PR** (default) — it commits, pushes with *your* `git`/`gh`, opens a PR,
|
|
153
172
|
and posts a report card with the link. Review on the card: **Approve** merges,
|
|
154
173
|
**Reject** closes, **Request changes** re-works.
|
|
@@ -386,6 +405,16 @@ hilos's GitHub App only for workspace owners/admins). Want a human checkpoint
|
|
|
386
405
|
before anything is pushed? Set `"gate": true`. Keep your token in the config file
|
|
387
406
|
or `HILOS_TOKEN`, never in shared shell history.
|
|
388
407
|
|
|
408
|
+
### Durable activity, not raw output
|
|
409
|
+
|
|
410
|
+
On a current hilos server, daemon-launched Claude Code, Codex, Cursor, and
|
|
411
|
+
OpenCode repo runs automatically keep the same bounded activity facts used by
|
|
412
|
+
the live card—phases, tool/file activity, notes, reported usage, and completion—
|
|
413
|
+
in hilos's runtime-neutral event record. Writes are batched, retry-safe,
|
|
414
|
+
bounded to two one-second attempts, re-authorized and redacted on the server,
|
|
415
|
+
and never allowed to fail the coding run. Raw stdout is not sent by this path.
|
|
416
|
+
An older server simply leaves it off.
|
|
417
|
+
|
|
389
418
|
### Run transcripts are opt-in
|
|
390
419
|
|
|
391
420
|
The room gets what a teammate needs to see: a plan, live progress, a report, a
|
|
@@ -416,3 +445,13 @@ restart — and **deleting** the key counts as off, not as "leave it as it was".
|
|
|
416
445
|
|
|
417
446
|
Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`, `HILOS_ONCE=1`,
|
|
418
447
|
`HILOS_BACKFILL=1`, `HILOS_UPLOAD_TRANSCRIPTS=1|0`.
|
|
448
|
+
|
|
449
|
+
## Releasing
|
|
450
|
+
|
|
451
|
+
Bump `version` in `package.json`, merge, then tag that commit
|
|
452
|
+
`hilos-agent-v<version>` and push the tag. The `npm-publish` workflow checks
|
|
453
|
+
the tag against the tree, parses every source file on Node 20, 22, and 24,
|
|
454
|
+
and publishes to npm (without provenance while the repository is private:
|
|
455
|
+
npm refuses a provenance bundle from a private source repository, 1182).
|
|
456
|
+
`workflow_dispatch` runs the same pipeline as a dry run. Needs the
|
|
457
|
+
`NPM_TOKEN` repository secret (1094).
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// codingCmd in hilos-agent.json to change it and the daemon picks it up on its
|
|
19
19
|
// next poll — no restart needed.
|
|
20
20
|
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
21
22
|
import { readFileSync } from "node:fs";
|
|
22
23
|
import { fileURLToPath } from "node:url";
|
|
23
24
|
|
|
@@ -26,6 +27,8 @@ import { readPrivateJoin } from "../src/join-input.mjs";
|
|
|
26
27
|
import { run } from "../src/run.mjs";
|
|
27
28
|
import { hookMain, hooksMain } from "../src/hook.mjs";
|
|
28
29
|
import { runWebMcpCommand } from "../src/webmcp-bridge.mjs";
|
|
30
|
+
import { detectVendor, fastChatCmd, webCapability } from "../src/progress-emitter.mjs";
|
|
31
|
+
import { commandArgv } from "../src/argv.mjs";
|
|
29
32
|
|
|
30
33
|
function packageVersion() {
|
|
31
34
|
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
@@ -46,6 +49,8 @@ function parseArgs(argv) {
|
|
|
46
49
|
else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
|
|
47
50
|
else if (a === "--coding-model") flags.codingModel = argv[++i];
|
|
48
51
|
else if (a === "--chat-cmd") flags.chatCmd = argv[++i];
|
|
52
|
+
else if (a === "--no-web-search") flags.webSearch = false;
|
|
53
|
+
else if (a === "--web-search") flags.webSearch = true;
|
|
49
54
|
else if (a === "--once") flags.once = true;
|
|
50
55
|
else if (a === "--backfill") flags.backfill = true;
|
|
51
56
|
else if (a === "--no-gate") flags.gate = false;
|
|
@@ -75,6 +80,7 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
|
|
|
75
80
|
hilos-agent webmcp tools list registered, person-allowlisted read tools
|
|
76
81
|
hilos-agent webmcp call <name> '<json object>'
|
|
77
82
|
hilos-agent webmcp close close the isolated browser session
|
|
83
|
+
hilos-agent web doctor report this CLI's native public-web capability
|
|
78
84
|
hilos-agent run the daemon (watch @mentions, propose diffs)
|
|
79
85
|
hilos-agent hooks install stream this repo's Codex, Claude, and Cursor
|
|
80
86
|
sessions to hilos and continue replies in the same
|
|
@@ -98,6 +104,7 @@ Options:
|
|
|
98
104
|
--chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
|
|
99
105
|
derived from the coding command, so a Codex or Cursor
|
|
100
106
|
daemon chats with its own tool)
|
|
107
|
+
--no-web-search stop hilos from enabling/requesting native public web
|
|
101
108
|
--once one poll then exit (cron-friendly)
|
|
102
109
|
--backfill also act on mentions that predate startup
|
|
103
110
|
--no-gate propose only; don't wait for approval / push
|
|
@@ -169,6 +176,46 @@ async function main() {
|
|
|
169
176
|
return;
|
|
170
177
|
}
|
|
171
178
|
|
|
179
|
+
if (cmd === "web") {
|
|
180
|
+
const operation = positional[1] || "doctor";
|
|
181
|
+
if (operation !== "doctor") {
|
|
182
|
+
throw new Error(`Unknown web command: ${operation}. Try \`hilos-agent web doctor\`.`);
|
|
183
|
+
}
|
|
184
|
+
const cliFlags = { ...flags };
|
|
185
|
+
delete cliFlags.help;
|
|
186
|
+
const cfg = resolveConfig({ flags: cliFlags });
|
|
187
|
+
const codingCommand = cfg.codingCmd;
|
|
188
|
+
const chatCommand = cfg.chatCmd || fastChatCmd(detectVendor(codingCommand)) || codingCommand;
|
|
189
|
+
const describe = (command) => {
|
|
190
|
+
const argv = commandArgv(command);
|
|
191
|
+
const vendor = detectVendor(command);
|
|
192
|
+
const capability = webCapability(vendor, {
|
|
193
|
+
enabled: cfg.webSearch !== false,
|
|
194
|
+
args: argv.slice(1),
|
|
195
|
+
});
|
|
196
|
+
const binary = argv[0] || "";
|
|
197
|
+
const probed = binary
|
|
198
|
+
? spawnSync(binary, ["--version"], { encoding: "utf8", timeout: 5_000 })
|
|
199
|
+
: null;
|
|
200
|
+
const binaryAvailable = Boolean(binary) && !probed?.error;
|
|
201
|
+
const version = String(probed?.stdout || probed?.stderr || "").trim().split("\n")[0] || null;
|
|
202
|
+
return { vendor, binary, binaryAvailable, version, command, ...capability };
|
|
203
|
+
};
|
|
204
|
+
const chat = describe(chatCommand);
|
|
205
|
+
const code = codingCommand === chatCommand ? chat : describe(codingCommand);
|
|
206
|
+
console.log(JSON.stringify({
|
|
207
|
+
ok: [chat, code].every((lane) => lane.status === "enabled" && lane.binaryAvailable),
|
|
208
|
+
chat,
|
|
209
|
+
code,
|
|
210
|
+
note: !chat.binaryAvailable || !code.binaryAvailable
|
|
211
|
+
? "One or more selected CLI binaries are not installed or not on PATH."
|
|
212
|
+
: chat.verified && code.verified
|
|
213
|
+
? "Configured by hilos-agent; this does not spend a model call or test provider credentials."
|
|
214
|
+
: "Custom commands keep their own tool configuration; hilos-agent does not guess flags.",
|
|
215
|
+
}, null, 2));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
172
219
|
// run (default) — when --join is passed without init, connect straight away.
|
|
173
220
|
const cliFlags = { ...flags };
|
|
174
221
|
delete cliFlags.join;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"README.md"
|
|
13
13
|
],
|
|
14
14
|
"engines": {
|
|
15
|
-
"node": ">=
|
|
15
|
+
"node": ">=20"
|
|
16
16
|
},
|
|
17
17
|
"homepage": "https://hilos.sh",
|
|
18
18
|
"repository": {
|
package/src/cli.mjs
CHANGED
|
@@ -332,6 +332,13 @@ function runCliOnce(opts) {
|
|
|
332
332
|
// inside runCli (not a wrapper) so every call site, present and future, gets it.
|
|
333
333
|
const UNKNOWN_TRUST_RE = /unknown option '--trust'/;
|
|
334
334
|
|
|
335
|
+
// Compat retry (0974): --auto-review is the current Cursor CLI's safe,
|
|
336
|
+
// server-classified approval mode, which lets read-only Web calls run in the
|
|
337
|
+
// conversational `ask` profile. Older Cursor CLIs do not know that flag. On
|
|
338
|
+
// the exact commander error, retry without it so upgrading Hilos cannot make
|
|
339
|
+
// an otherwise-working older local CLI fail before it sees the prompt.
|
|
340
|
+
const UNKNOWN_CURSOR_AUTO_REVIEW_RE = /unknown option '--auto-review'/;
|
|
341
|
+
|
|
335
342
|
// Compat retry (0777): the permission gate adds `--permission-prompt-tool` (and
|
|
336
343
|
// the `--mcp-config` that serves it) to claude runs. The flag is real but
|
|
337
344
|
// undocumented on 2.1.233, so a Claude Code old enough not to know it would
|
|
@@ -373,7 +380,28 @@ export async function runCli(opts) {
|
|
|
373
380
|
args.includes("--trust") &&
|
|
374
381
|
UNKNOWN_TRUST_RE.test(first.stderr || "")
|
|
375
382
|
) {
|
|
376
|
-
|
|
383
|
+
const withoutTrust = args.filter((a) => a !== "--trust");
|
|
384
|
+
const retried = await runCliOnce({ ...opts, args: withoutTrust });
|
|
385
|
+
if (
|
|
386
|
+
retried.status !== 0 &&
|
|
387
|
+
!retried.aborted &&
|
|
388
|
+
withoutTrust.includes("--auto-review") &&
|
|
389
|
+
UNKNOWN_CURSOR_AUTO_REVIEW_RE.test(retried.stderr || "")
|
|
390
|
+
) {
|
|
391
|
+
return runCliOnce({
|
|
392
|
+
...opts,
|
|
393
|
+
args: withoutTrust.filter((a) => a !== "--auto-review"),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return retried;
|
|
397
|
+
}
|
|
398
|
+
if (
|
|
399
|
+
first.status !== 0 &&
|
|
400
|
+
!first.aborted &&
|
|
401
|
+
args.includes("--auto-review") &&
|
|
402
|
+
UNKNOWN_CURSOR_AUTO_REVIEW_RE.test(first.stderr || "")
|
|
403
|
+
) {
|
|
404
|
+
return runCliOnce({ ...opts, args: args.filter((a) => a !== "--auto-review") });
|
|
377
405
|
}
|
|
378
406
|
if (
|
|
379
407
|
first.status !== 0 &&
|
|
@@ -149,6 +149,7 @@ export function mapCodexDecision(reply, availableDecisions) {
|
|
|
149
149
|
* prompt?: string,
|
|
150
150
|
* env?: Record<string, string | undefined>,
|
|
151
151
|
* model?: string | null,
|
|
152
|
+
* webSearch?: boolean,
|
|
152
153
|
* sandbox?: string,
|
|
153
154
|
* approvalPolicy?: string,
|
|
154
155
|
* resumeThreadId?: string | null,
|
|
@@ -173,6 +174,7 @@ export async function runCodexMcpSession({
|
|
|
173
174
|
prompt = "",
|
|
174
175
|
env,
|
|
175
176
|
model = null,
|
|
177
|
+
webSearch = true,
|
|
176
178
|
sandbox = "workspace-write",
|
|
177
179
|
approvalPolicy = "untrusted",
|
|
178
180
|
resumeThreadId = null,
|
|
@@ -464,7 +466,7 @@ export async function runCodexMcpSession({
|
|
|
464
466
|
// The gated transport cannot inherit `codex exec` argv. Carry the
|
|
465
467
|
// same public web-search capability through the MCP tool's config;
|
|
466
468
|
// permission policy and sandboxing remain unchanged.
|
|
467
|
-
config: { tools: { web_search: true } },
|
|
469
|
+
...(webSearch ? { config: { tools: { web_search: true } } } : {}),
|
|
468
470
|
...(model ? { model } : {}),
|
|
469
471
|
},
|
|
470
472
|
};
|
package/src/config.mjs
CHANGED
|
@@ -59,6 +59,10 @@ const DEFAULTS = {
|
|
|
59
59
|
// { origins: { "https://example.com": { readTools: ["search_docs"] } },
|
|
60
60
|
// profile?: "/absolute/private/browser/profile" }
|
|
61
61
|
webMcp: null,
|
|
62
|
+
// Public web is a baseline agent capability. Supported local CLIs use their
|
|
63
|
+
// own search/fetch tools; false stops Hilos from enabling or requesting it.
|
|
64
|
+
// The CLI's own global/project policy remains the ultimate authority.
|
|
65
|
+
webSearch: true,
|
|
62
66
|
// acceptEdits lets the CLI make file edits without prompting (bias to action);
|
|
63
67
|
// it still won't run arbitrary commands. Override in hilos-agent.json if you
|
|
64
68
|
// want a stricter (or `--dangerously-skip-permissions`) command.
|
|
@@ -173,6 +177,12 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
173
177
|
codingCmd: process.env.CODING_CMD,
|
|
174
178
|
codingModel: process.env.HILOS_CODING_MODEL,
|
|
175
179
|
chatCmd: process.env.HILOS_CHAT_CMD,
|
|
180
|
+
webSearch:
|
|
181
|
+
/^(1|on|true)$/i.test(process.env.HILOS_WEB_SEARCH || "")
|
|
182
|
+
? true
|
|
183
|
+
: /^(0|off|false)$/i.test(process.env.HILOS_WEB_SEARCH || "")
|
|
184
|
+
? false
|
|
185
|
+
: undefined,
|
|
176
186
|
heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
|
|
177
187
|
progressMs: process.env.HILOS_PROGRESS_MS ? Number(process.env.HILOS_PROGRESS_MS) : undefined,
|
|
178
188
|
chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
|
|
@@ -258,6 +268,7 @@ const LIVE_FIELDS = [
|
|
|
258
268
|
"folders",
|
|
259
269
|
"deploy",
|
|
260
270
|
"webMcp",
|
|
271
|
+
"webSearch",
|
|
261
272
|
];
|
|
262
273
|
|
|
263
274
|
/**
|
|
@@ -304,6 +315,10 @@ export function reloadConfig(prev) {
|
|
|
304
315
|
// bridge on the next poll rather than preserving stale origins/tools until a
|
|
305
316
|
// restart. An explicit null and a deleted edited key both mean disabled.
|
|
306
317
|
if (changed("webMcp")) next.webMcp = file.webMcp ?? null;
|
|
318
|
+
// Native web is safe-on by default. Removing an edited `webSearch:false`
|
|
319
|
+
// returns to that default on the next poll instead of pinning a stale opt-out
|
|
320
|
+
// until restart; the environment override below still wins.
|
|
321
|
+
if (changed("webSearch")) next.webSearch = file.webSearch !== false;
|
|
307
322
|
// The environment stays the operator's override on reload, in both
|
|
308
323
|
// directions — a machine that opted out with =0 must not be opted back in by
|
|
309
324
|
// a file edit (0792).
|
|
@@ -314,6 +329,8 @@ export function reloadConfig(prev) {
|
|
|
314
329
|
if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
|
|
315
330
|
if (process.env.HILOS_CODING_MODEL) next.codingModel = process.env.HILOS_CODING_MODEL;
|
|
316
331
|
if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
|
|
332
|
+
if (/^(1|on|true)$/i.test(process.env.HILOS_WEB_SEARCH || "")) next.webSearch = true;
|
|
333
|
+
if (/^(0|off|false)$/i.test(process.env.HILOS_WEB_SEARCH || "")) next.webSearch = false;
|
|
317
334
|
if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
|
|
318
335
|
if (process.env.HILOS_PROGRESS_MS) next.progressMs = Number(process.env.HILOS_PROGRESS_MS);
|
|
319
336
|
if (process.env.HILOS_CHAT_TIMEOUT_MS) next.chatTimeoutMs = Number(process.env.HILOS_CHAT_TIMEOUT_MS);
|
|
@@ -338,6 +355,7 @@ export function writeStarterConfig(path, partial = {}) {
|
|
|
338
355
|
repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
|
|
339
356
|
codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
|
|
340
357
|
...(partial.codingModel ? { codingModel: partial.codingModel } : {}),
|
|
358
|
+
webSearch: partial.webSearch !== false,
|
|
341
359
|
defaultBranch: DEFAULTS.defaultBranch,
|
|
342
360
|
// false = open a PR directly (bias to action); true = approve-before-push.
|
|
343
361
|
gate: false,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// 0991 — deterministic Git ownership for local merge-conflict recovery.
|
|
2
|
+
// The coding model edits files; this wrapper creates and validates the merge.
|
|
3
|
+
|
|
4
|
+
const MAX_NAMED_FILES = 40;
|
|
5
|
+
|
|
6
|
+
const lines = (value) =>
|
|
7
|
+
String(value || "")
|
|
8
|
+
.split("\n")
|
|
9
|
+
.map((line) => line.trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
|
|
12
|
+
export function validConflictRecovery(value, repoFullName) {
|
|
13
|
+
if (!value || typeof value !== "object" || value.repairable !== true) return null;
|
|
14
|
+
const required = ["repoFullName", "prUrl", "branch", "baseBranch", "headSha"];
|
|
15
|
+
if (required.some((key) => typeof value[key] !== "string" || !value[key].trim())) return null;
|
|
16
|
+
if (!Number.isInteger(value.prNumber) || value.prNumber <= 0) return null;
|
|
17
|
+
if (
|
|
18
|
+
repoFullName &&
|
|
19
|
+
value.repoFullName.toLowerCase() !== String(repoFullName).toLowerCase()
|
|
20
|
+
) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
repoFullName: value.repoFullName,
|
|
25
|
+
prNumber: value.prNumber,
|
|
26
|
+
prUrl: value.prUrl,
|
|
27
|
+
branch: value.branch,
|
|
28
|
+
baseBranch: value.baseBranch,
|
|
29
|
+
headSha: value.headSha,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function conflictRepairBrief(conflict) {
|
|
34
|
+
return (
|
|
35
|
+
`Resolve the merge conflicts on existing pull request #${conflict.prNumber} ` +
|
|
36
|
+
`(${conflict.prUrl}). Preserve the intended work from both \`${conflict.branch}\` and ` +
|
|
37
|
+
`\`${conflict.baseBranch}\`, run the repository's required verification, and update only ` +
|
|
38
|
+
`that existing pull request. Do not merge it; return the changed head for fresh human review.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function conflictPromptBlock({ baseBranch, files }) {
|
|
43
|
+
if (!files.length) {
|
|
44
|
+
return (
|
|
45
|
+
`hilos has already merged \`${baseBranch}\` into this pull request branch and Git resolved every file cleanly. ` +
|
|
46
|
+
"The checkout is still mid-merge so hilos can make the two-parent merge commit after you finish. " +
|
|
47
|
+
"Run the repository's required verification and make no edits unless verification exposes a real integration problem. Do not run git."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const named = files.slice(0, MAX_NAMED_FILES);
|
|
51
|
+
const rest = files.length - named.length;
|
|
52
|
+
return [
|
|
53
|
+
`hilos has already started a real merge of \`${baseBranch}\` into this pull request branch. Resolve EVERY conflict by editing the files into the correct combined result. Keep BOTH sides' work unless they are genuinely exclusive; never discard the base branch merely to remove a marker. Regenerate generated files from their sources when the repository provides a generator. Do not run git — hilos owns the merge commit and refuses to push unresolved entries or marker lines.`,
|
|
54
|
+
...(named.length ? ["Conflicted files:", ...named.map((file) => `- ${file}`)] : []),
|
|
55
|
+
...(rest > 0 ? [`…and ${rest} more.`] : []),
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Fetch + start the two-parent merge before the coding model runs. */
|
|
60
|
+
export function startConflictMerge({ git, cwd, conflict }) {
|
|
61
|
+
const fetched = git(cwd, [
|
|
62
|
+
"fetch",
|
|
63
|
+
"origin",
|
|
64
|
+
`+refs/heads/${conflict.baseBranch}:refs/remotes/origin/${conflict.baseBranch}`,
|
|
65
|
+
]);
|
|
66
|
+
if (fetched.status !== 0) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
reason: `couldn't fetch ${conflict.baseBranch}: ${String(fetched.stderr || "").trim().slice(0, 200)}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const merged = git(cwd, [
|
|
74
|
+
"merge",
|
|
75
|
+
"--no-commit",
|
|
76
|
+
"--no-ff",
|
|
77
|
+
`origin/${conflict.baseBranch}`,
|
|
78
|
+
]);
|
|
79
|
+
const files = lines(git(cwd, ["diff", "--name-only", "--diff-filter=U"]).stdout);
|
|
80
|
+
if (merged.status !== 0 && files.length === 0) {
|
|
81
|
+
git(cwd, ["merge", "--abort"]);
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: `couldn't start the merge: ${String(merged.stderr || "").trim().slice(0, 200)}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const mergeHead = git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
|
89
|
+
if (merged.status === 0 && mergeHead.status !== 0) {
|
|
90
|
+
return { ok: true, state: "up-to-date", files: [] };
|
|
91
|
+
}
|
|
92
|
+
if (mergeHead.status !== 0) {
|
|
93
|
+
git(cwd, ["merge", "--abort"]);
|
|
94
|
+
return { ok: false, reason: "Git did not leave a merge to complete." };
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, state: files.length ? "conflicts" : "clean", files };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Check BEFORE git add: staging clears unmerged index entries. Marker scanning
|
|
100
|
+
* catches a model that ran git add itself despite the prompt.
|
|
101
|
+
* @param {{ git: (cwd: string, args: string[]) => { stdout?: string }, cwd: string, knownFiles?: string[] }} args
|
|
102
|
+
*/
|
|
103
|
+
export function remainingConflictFiles({ git, cwd, knownFiles = [] }) {
|
|
104
|
+
const unmerged = lines(git(cwd, ["diff", "--name-only", "--diff-filter=U"]).stdout);
|
|
105
|
+
const known = new Set(knownFiles);
|
|
106
|
+
// The original paths remain unmerged in Git's index until the wrapper stages
|
|
107
|
+
// the model's edited result. That state alone is expected here; only a NEW
|
|
108
|
+
// unmerged path (the model started another merge) is structurally unsafe.
|
|
109
|
+
const unexpectedUnmerged = unmerged.filter((file) => !known.has(file));
|
|
110
|
+
// With no original conflicts there is nothing useful to scan. Running
|
|
111
|
+
// `git grep` without pathspecs would inspect the entire repository and could
|
|
112
|
+
// mistake a deliberate marker example in documentation for this merge's
|
|
113
|
+
// unresolved work.
|
|
114
|
+
const markers = knownFiles.length
|
|
115
|
+
? lines(
|
|
116
|
+
git(cwd, [
|
|
117
|
+
"grep",
|
|
118
|
+
"-l",
|
|
119
|
+
"-e",
|
|
120
|
+
"^<<<<<<< ",
|
|
121
|
+
"-e",
|
|
122
|
+
"^>>>>>>> ",
|
|
123
|
+
"--",
|
|
124
|
+
...knownFiles,
|
|
125
|
+
]).stdout,
|
|
126
|
+
)
|
|
127
|
+
: [];
|
|
128
|
+
return [...new Set([...unexpectedUnmerged, ...markers])].sort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A coding CLI may run git despite the prompt. Accept its self-commit only
|
|
132
|
+
* when Git proves it completed the prepared merge; reject an aborted/reset
|
|
133
|
+
* merge before anything can be pushed.
|
|
134
|
+
* @param {{ git: (cwd: string, args: string[]) => { status: number, stdout?: string }, cwd: string, baseBranch: string }} args
|
|
135
|
+
*/
|
|
136
|
+
export function validateConflictMergeState({ git, cwd, baseBranch }) {
|
|
137
|
+
if (git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).status === 0) {
|
|
138
|
+
return { ok: true, committedByAgent: false };
|
|
139
|
+
}
|
|
140
|
+
const baseIsAncestor =
|
|
141
|
+
git(cwd, ["merge-base", "--is-ancestor", `origin/${baseBranch}`, "HEAD"]).status === 0;
|
|
142
|
+
const parents = lines(git(cwd, ["rev-list", "--parents", "-n", "1", "HEAD"]).stdout)[0]
|
|
143
|
+
?.split(/\s+/)
|
|
144
|
+
.filter(Boolean) ?? [];
|
|
145
|
+
if (baseIsAncestor && parents.length >= 3) {
|
|
146
|
+
return { ok: true, committedByAgent: true };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: `the prepared merge of ${baseBranch} was aborted or replaced before verification`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function conflictCommitTitle(baseBranch) {
|
|
155
|
+
return `Merge ${baseBranch}: resolve conflicts`;
|
|
156
|
+
}
|