hilos-agent 0.10.1 → 0.11.2
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/LICENSE +21 -0
- package/README.md +54 -10
- package/bin/hilos-agent.mjs +87 -14
- package/package.json +7 -5
- package/src/acp-session.mjs +16 -1
- package/src/cli.mjs +34 -1
- package/src/codex-mcp-session.mjs +36 -0
- package/src/config.mjs +39 -5
- package/src/conflicts.mjs +156 -0
- package/src/cursor-store.mjs +43 -8
- package/src/handler.mjs +1197 -127
- package/src/iterate-claim-recovery.mjs +574 -0
- package/src/local-harness-events.mjs +370 -0
- package/src/local-harness-transport.mjs +157 -0
- package/src/mcp.mjs +411 -34
- package/src/model-resolve.mjs +3 -1
- package/src/opencode-session.mjs +22 -2
- package/src/progress-emitter.mjs +1 -1
- package/src/queue.mjs +70 -3
- package/src/redact.mjs +360 -7
- package/src/run-inputs.mjs +211 -0
- package/src/run.mjs +462 -76
- package/src/thread-pr.mjs +26 -4
- package/src/webmcp-bridge.mjs +77 -4
|
@@ -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
|
+
}
|
package/src/cursor-store.mjs
CHANGED
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
// restart resumes from it — bounded by `catchupMs`, because replaying a week of
|
|
8
8
|
// stale asks after a vacation would be worse than skipping them.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// longer happen is a mention arriving into a dead daemon and never being seen.
|
|
10
|
+
// Persisting the cursor acknowledges a safely completed queue lifecycle, not
|
|
11
|
+
// delivery into volatile memory. New servers add the mention-table ingestion
|
|
12
|
+
// sequence, so edits/rebuilds and equal-timestamp siblings cannot be skipped.
|
|
14
13
|
//
|
|
15
14
|
// Dependency-free and injectable-dir like hook.mjs's state store, so tests run
|
|
16
15
|
// against a temp dir and never touch a real ~/.hilos.
|
|
@@ -38,6 +37,13 @@ export function cursorPath(agentId, dir = CURSOR_STATE_DIR) {
|
|
|
38
37
|
* @returns {string|null} ISO timestamp
|
|
39
38
|
*/
|
|
40
39
|
export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_DIR, now = Date.now } = {}) {
|
|
40
|
+
return loadMentionCursorState(agentId, { catchupMs, dir, now })?.createdAt ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadMentionCursorState(
|
|
44
|
+
agentId,
|
|
45
|
+
{ catchupMs = 0, dir = CURSOR_STATE_DIR, now = Date.now } = {},
|
|
46
|
+
) {
|
|
41
47
|
if (!agentId || !(catchupMs > 0)) return null;
|
|
42
48
|
let parsed;
|
|
43
49
|
try {
|
|
@@ -50,7 +56,24 @@ export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_D
|
|
|
50
56
|
const at = new Date(value).getTime();
|
|
51
57
|
if (Number.isNaN(at)) return null;
|
|
52
58
|
const floor = now() - catchupMs;
|
|
53
|
-
|
|
59
|
+
const mentionSeq = Number.isSafeInteger(parsed.mentionCursorSeq) && parsed.mentionCursorSeq >= 0
|
|
60
|
+
? parsed.mentionCursorSeq
|
|
61
|
+
: null;
|
|
62
|
+
if (mentionSeq != null) {
|
|
63
|
+
// Sequence progress belongs to the ingestion checkpoint, not to the
|
|
64
|
+
// original message timestamp: editing a year-old message creates fresh
|
|
65
|
+
// sequence work. New state records when the checkpoint was written so the
|
|
66
|
+
// catch-up window still bounds genuinely abandoned daemons. Pre-release
|
|
67
|
+
// sequence files without this additive field remain usable.
|
|
68
|
+
if (typeof parsed.mentionCursorSavedAt === "string") {
|
|
69
|
+
const savedAt = new Date(parsed.mentionCursorSavedAt).getTime();
|
|
70
|
+
if (!Number.isFinite(savedAt)) return null;
|
|
71
|
+
if (savedAt < floor) return { createdAt: new Date(floor).toISOString(), mentionSeq: null };
|
|
72
|
+
}
|
|
73
|
+
return { createdAt: value, mentionSeq };
|
|
74
|
+
}
|
|
75
|
+
if (at < floor) return { createdAt: new Date(floor).toISOString(), mentionSeq: null };
|
|
76
|
+
return { createdAt: value, mentionSeq: null };
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
/**
|
|
@@ -60,14 +83,26 @@ export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_D
|
|
|
60
83
|
*
|
|
61
84
|
* @param {string} agentId
|
|
62
85
|
* @param {string} isoTimestamp
|
|
63
|
-
* @param {{ dir?: string, log?: { error?: (m: string) => void } }} [opts]
|
|
86
|
+
* @param {{ dir?: string, log?: { error?: (m: string) => void }, mentionSeq?: number|null, now?: () => number }} [opts]
|
|
64
87
|
* @returns {boolean} whether the write landed
|
|
65
88
|
*/
|
|
66
|
-
export function saveMentionCursor(
|
|
89
|
+
export function saveMentionCursor(
|
|
90
|
+
agentId,
|
|
91
|
+
isoTimestamp,
|
|
92
|
+
{ dir = CURSOR_STATE_DIR, log, mentionSeq = null, now = Date.now } = {},
|
|
93
|
+
) {
|
|
67
94
|
if (!agentId || typeof isoTimestamp !== "string" || !isoTimestamp) return false;
|
|
68
95
|
try {
|
|
69
96
|
mkdirSync(dir, { recursive: true });
|
|
70
|
-
writeFileSync(
|
|
97
|
+
writeFileSync(
|
|
98
|
+
cursorPath(agentId, dir),
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
mentionCursor: isoTimestamp,
|
|
101
|
+
...(Number.isSafeInteger(mentionSeq) && mentionSeq >= 0
|
|
102
|
+
? { mentionCursorSeq: mentionSeq, mentionCursorSavedAt: new Date(now()).toISOString() }
|
|
103
|
+
: {}),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
71
106
|
return true;
|
|
72
107
|
} catch (e) {
|
|
73
108
|
log?.error?.(`mention cursor not persisted: ${e?.message || e}`);
|