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.
@@ -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
+ }
@@ -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
- // Telegram semantics, deliberately: persisting the cursor acknowledges
11
- // DELIVERY into the daemon's queue, not completion of the work. A crash after
12
- // enqueue can still lose an in-flight task (exactly as today); what can no
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
- return at < floor ? new Date(floor).toISOString() : value;
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(agentId, isoTimestamp, { dir = CURSOR_STATE_DIR, log } = {}) {
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(cursorPath(agentId, dir), JSON.stringify({ mentionCursor: isoTimestamp }));
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}`);