@indigoai-us/hq-cli 5.271.0 → 5.273.0-alpha.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,521 @@
1
+ /**
2
+ * Merge numbered entity copies (`<slug> 2.md`) into their original file and
3
+ * delete them (CO-54, owner decision CO-D-007 option 2).
4
+ *
5
+ * Gated by `checkpoint.ontology.merge-copies`. A copy is a file named
6
+ * `<slug> <n>.md` next to `<slug>.md` of the same type, with the same `id`,
7
+ * in one duplicate group. Other members of a group are never touched.
8
+ *
9
+ * Two steps, run separately so sync can push the merged original before its
10
+ * copy goes away:
11
+ *
12
+ * - merge: the copy's fresh aliases, back-references and relationship lines
13
+ * are added to the original, `signal_count` takes the higher value, and
14
+ * entity files that link to the copy are re-pointed at the original. Every
15
+ * rewritten file is recorded in the outbox before it is written, so sync
16
+ * pushes it. Keys the checkpoint does not own keep the original's value.
17
+ * - delete: a copy is deleted from the vault, by exact key, only when merging
18
+ * it again would change nothing and no entity file or signal links to it.
19
+ * The vault keeps every old version. The local file goes after the vault
20
+ * delete succeeds, or when the vault no longer has the key.
21
+ *
22
+ * Copies of one original merge in path order, each into the result of the
23
+ * previous one, and none of them counts as holding a name the others move.
24
+ * A copy whose body has lines the merged original lacks (prose the merge does
25
+ * not carry) is merged but never deleted. A copy linked from any file the
26
+ * merge cannot rewrite (a copy, a knowledge file, a signal) is held.
27
+ *
28
+ * A signal whose `entity_refs` names a copy holds that copy: signals are
29
+ * server-owned, so the ref cannot be re-pointed from here.
30
+ */
31
+ import * as fs from "node:fs";
32
+ import * as path from "node:path";
33
+ import { isDeepStrictEqual } from "node:util";
34
+ import { freshAliases, ownNames } from "./aliases.js";
35
+ import { entitiesRoot, safeWriteFile } from "./company.js";
36
+ import { appendBackRefs, OWNED_KEYS, parseEntityFile, patchEntityFile } from "./entity-file.js";
37
+ import { isWritableEntityType, normalizeEntityName } from "./identity.js";
38
+ import { findDuplicateGroups, readBackRefs, recordOutbox } from "./multi-writer.js";
39
+ import { buildEntityIndex, listSignalFiles, readSignal } from "./tree.js";
40
+ export const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
41
+ /** `<slug> <n>.md`, the name a file manager gives a second copy. */
42
+ const COPY_NAME = /^(.+) (\d+)\.md$/;
43
+ /** A relationship list item with a link, as the gardener and checkpoint write them. */
44
+ const LINK_ITEM = /^- .*\]\([^)\n]+\)\s*$/;
45
+ const BACKREF_ITEM = /^- Referenced in \[/;
46
+ const MD_LINK = /\]\(([^)\n]+)\)/g;
47
+ /** Keys that differ between copies for reasons that say nothing about content. */
48
+ const IGNORED_KEYS = new Set(["first_seen", "confidence"]);
49
+ function sectionLines(body) {
50
+ const match = /(^|\n)##\s+Relationships\s*\n([\s\S]*?)(?=\n##\s|$)/.exec(body);
51
+ return match ? match[2].split(/\r?\n/).map((line) => line.trimEnd()) : [];
52
+ }
53
+ function splitBody(content) {
54
+ const match = /^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/.exec(content);
55
+ return match ? { frontmatter: match[1], body: match[2] } : null;
56
+ }
57
+ /** Relationship lines of the copy that the original lacks (back-references excluded). */
58
+ function missingRelationshipLines(originalContent, copyContent) {
59
+ const original = splitBody(originalContent);
60
+ const copy = splitBody(copyContent);
61
+ if (!original || !copy)
62
+ return [];
63
+ const have = new Set(original.body.split(/\r?\n/).map((line) => line.trimEnd()));
64
+ const out = [];
65
+ for (const line of sectionLines(copy.body)) {
66
+ if (!LINK_ITEM.test(line) || BACKREF_ITEM.test(line) || have.has(line))
67
+ continue;
68
+ have.add(line);
69
+ out.push(line);
70
+ }
71
+ return out;
72
+ }
73
+ /** Append lines to `## Relationships`, creating the section when it is missing. */
74
+ function appendRelationshipLines(content, lines) {
75
+ if (lines.length === 0)
76
+ return content;
77
+ const parts = splitBody(content);
78
+ const body = parts.body;
79
+ const section = /(^|\n)(##\s+Relationships\s*\n)([\s\S]*?)(?=\n##\s|\s*$)/.exec(body);
80
+ let next;
81
+ if (section) {
82
+ const before = body.slice(0, section.index + section[1].length);
83
+ const kept = section[3].replace(/\s+$/, "");
84
+ const after = body.slice(section.index + section[0].length);
85
+ next = `${before}${section[2]}${kept ? `${kept}\n${lines.join("\n")}` : lines.join("\n")}\n${after}`;
86
+ }
87
+ else {
88
+ const trimmed = body.replace(/\s+$/, "");
89
+ next = `${trimmed.length > 0 ? `${trimmed}\n\n` : "\n"}## Relationships\n\n${lines.join("\n")}\n`;
90
+ }
91
+ return `${parts.frontmatter}${next}`;
92
+ }
93
+ /** Remove one entity from the name indexes so its names stop counting as taken. */
94
+ function unindex(index, rel) {
95
+ index.byRel.delete(rel);
96
+ for (const map of [index.canonical, index.alias, index.squashed]) {
97
+ for (const [key, rels] of map) {
98
+ if (!rels.delete(rel))
99
+ continue;
100
+ if (rels.size === 0)
101
+ map.delete(key);
102
+ }
103
+ }
104
+ }
105
+ /** Copies in scope, and the ones skipped with a reason. Read-only. */
106
+ export function findCopyPairs(index) {
107
+ const groupOf = new Map();
108
+ findDuplicateGroups(index).forEach((group, i) => {
109
+ for (const rel of group.paths)
110
+ groupOf.set(rel, i);
111
+ });
112
+ const pairs = [];
113
+ const skipped = [];
114
+ const candidates = [...index.byRel.values()]
115
+ .filter((entity) => isWritableEntityType(entity.type) && COPY_NAME.test(path.posix.basename(entity.rel)))
116
+ .sort((a, b) => a.rel.localeCompare(b.rel));
117
+ for (const copy of candidates) {
118
+ const base = COPY_NAME.exec(path.posix.basename(copy.rel))[1];
119
+ const original = `${path.posix.dirname(copy.rel)}/${base}.md`;
120
+ const target = index.byRel.get(original);
121
+ if (!target) {
122
+ skipped.push({ copy: copy.rel, reason: index.invalid.has(original) ? "original_invalid" : "no_original" });
123
+ }
124
+ else if (target.id !== copy.id || target.type !== copy.type) {
125
+ skipped.push({ copy: copy.rel, reason: "identity_differs" });
126
+ }
127
+ else if (groupOf.get(copy.rel) === undefined || groupOf.get(copy.rel) !== groupOf.get(original)) {
128
+ skipped.push({ copy: copy.rel, reason: "not_in_duplicate_group" });
129
+ }
130
+ else {
131
+ pairs.push({ type: copy.type, copy: copy.rel, original });
132
+ }
133
+ }
134
+ return { pairs, skipped };
135
+ }
136
+ /** Resolve a relative link in `fromRel` to a company-relative path, or null. */
137
+ function resolveLink(fromRel, href) {
138
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("/"))
139
+ return null;
140
+ let decoded;
141
+ try {
142
+ decoded = decodeURI(href.split("#")[0]);
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), decoded));
148
+ return resolved.startsWith("..") ? null : resolved;
149
+ }
150
+ function listEntityFiles(company) {
151
+ const root = entitiesRoot(company);
152
+ const out = [];
153
+ let types;
154
+ try {
155
+ types = fs.readdirSync(root, { withFileTypes: true });
156
+ }
157
+ catch (error) {
158
+ if (error.code === "ENOENT")
159
+ return out;
160
+ throw error;
161
+ }
162
+ for (const type of types.filter((entry) => entry.isDirectory())) {
163
+ for (const file of fs.readdirSync(path.join(root, type.name), { withFileTypes: true })) {
164
+ if (file.isFile() && file.name.endsWith(".md"))
165
+ out.push(`ontology/entities/${type.name}/${file.name}`);
166
+ }
167
+ }
168
+ return out.sort();
169
+ }
170
+ /** Company-relative `.md` files outside the entity tree and the rendered brief. */
171
+ function listOtherFiles(company) {
172
+ const out = [];
173
+ const walk = (rel) => {
174
+ for (const entry of fs.readdirSync(path.join(company.dir, rel), { withFileTypes: true })) {
175
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
176
+ continue;
177
+ const child = rel ? `${rel}/${entry.name}` : entry.name;
178
+ if (entry.isDirectory()) {
179
+ if (child !== "ontology/entities")
180
+ walk(child);
181
+ }
182
+ else if (entry.isFile() && entry.name.endsWith(".md") && child !== "company-brief.md") {
183
+ out.push(child);
184
+ }
185
+ }
186
+ };
187
+ walk("");
188
+ return out.sort();
189
+ }
190
+ /**
191
+ * Files outside the entity tree that link to a copy, by markdown link,
192
+ * `[[ontology/entities/...]]` annotation or a front-matter `path:` entry.
193
+ * These files are not the merge's to rewrite, so a copy they name is held.
194
+ */
195
+ function scanOtherLinks(company, copies) {
196
+ const out = new Map();
197
+ if (copies.size === 0)
198
+ return out;
199
+ const names = [...new Set([...copies].map((copy) => path.posix.basename(copy, ".md")))];
200
+ const add = (target, rel) => {
201
+ if (!copies.has(target))
202
+ return;
203
+ let set = out.get(target);
204
+ if (!set)
205
+ out.set(target, (set = new Set()));
206
+ set.add(rel);
207
+ };
208
+ for (const rel of listOtherFiles(company)) {
209
+ const content = read(company, rel);
210
+ if (!names.some((name) => content.includes(name) || content.includes(encodeURI(name))))
211
+ continue;
212
+ for (const match of content.matchAll(MD_LINK)) {
213
+ const target = resolveLink(rel, match[1]);
214
+ if (target)
215
+ add(target, rel);
216
+ }
217
+ for (const match of content.matchAll(/\[\[(ontology\/entities\/[^\]|\n]+?)(?:\|[^\]\n]*)?\]\]/g)) {
218
+ add(match[1].endsWith(".md") ? match[1] : `${match[1]}.md`, rel);
219
+ }
220
+ for (const match of content.matchAll(/^\s*path:\s*["']?(ontology\/entities\/[^"'\n]+?\.md)["']?\s*$/gm))
221
+ add(match[1], rel);
222
+ }
223
+ return out;
224
+ }
225
+ function scanLinks(company) {
226
+ const scan = { inbound: new Map(), broken: 0 };
227
+ for (const rel of listEntityFiles(company)) {
228
+ const content = fs.readFileSync(path.join(company.dir, rel), "utf8");
229
+ for (const match of content.matchAll(MD_LINK)) {
230
+ const target = resolveLink(rel, match[1]);
231
+ if (!target)
232
+ continue;
233
+ if (!fs.existsSync(path.join(company.dir, target)))
234
+ scan.broken += 1;
235
+ if (target === rel)
236
+ continue;
237
+ let set = scan.inbound.get(target);
238
+ if (!set)
239
+ scan.inbound.set(target, (set = new Set()));
240
+ set.add(rel);
241
+ }
242
+ }
243
+ return scan;
244
+ }
245
+ /** Signal files whose `entity_refs` name a copy, keyed by the copy's path. */
246
+ function scanSignalRefs(company, copies) {
247
+ const out = new Map();
248
+ if (copies.size === 0)
249
+ return out;
250
+ const byRef = new Map();
251
+ for (const copy of copies) {
252
+ const [, , type, file] = copy.split("/");
253
+ byRef.set(`${type}/${file.slice(0, -3)}`, copy);
254
+ }
255
+ for (const rel of listSignalFiles(company.dir)) {
256
+ for (const ref of readSignal(company.dir, rel)?.refs ?? []) {
257
+ const copy = byRef.get(ref);
258
+ if (copy)
259
+ out.set(copy, [...(out.get(copy) ?? []), rel]);
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+ /** Re-point every link in `content` that resolves to `copy` at `original`. */
265
+ function relink(fromRel, content, copy, original) {
266
+ return content.replace(MD_LINK, (whole, href) => {
267
+ if (resolveLink(fromRel, href) !== copy)
268
+ return whole;
269
+ const hash = href.indexOf("#");
270
+ const target = hash === -1 ? href : href.slice(0, hash);
271
+ const fragment = hash === -1 ? "" : href.slice(hash);
272
+ const cut = target.lastIndexOf("/");
273
+ const name = path.posix.basename(original);
274
+ const encoded = target.slice(cut + 1).includes("%20") ? encodeURI(name) : name;
275
+ return `](${target.slice(0, cut + 1)}${encoded}${fragment})`;
276
+ });
277
+ }
278
+ /**
279
+ * The original's content with the copy merged in. Never removes anything from
280
+ * the original. `siblings` are all copies of the same original; none of them
281
+ * counts as holding a name.
282
+ */
283
+ function mergeCopy(index, pair, siblings, originalContent, copyContent, now) {
284
+ const original = parseEntityFile(originalContent);
285
+ const copy = parseEntityFile(copyContent);
286
+ if (!original || !copy)
287
+ return "unparseable";
288
+ const copyEntity = index.byRel.get(pair.copy);
289
+ const originalEntity = index.byRel.get(pair.original);
290
+ if (!copyEntity || !originalEntity)
291
+ return "not_indexed";
292
+ const scratch = {
293
+ ...index,
294
+ byRel: new Map(index.byRel),
295
+ canonical: new Map([...index.canonical].map(([k, v]) => [k, new Set(v)])),
296
+ alias: new Map([...index.alias].map(([k, v]) => [k, new Set(v)])),
297
+ squashed: new Map([...index.squashed].map(([k, v]) => [k, new Set(v)])),
298
+ };
299
+ for (const sibling of siblings)
300
+ unindex(scratch, sibling);
301
+ const own = ownNames(scratch, pair.original, []);
302
+ const names = [copyEntity.canonical_name, ...copyEntity.aliases];
303
+ const aliases = freshAliases(scratch, pair.type, pair.original, own, names);
304
+ const moved = new Set(aliases.map((alias) => normalizeEntityName(alias, pair.type)));
305
+ const dropped = names.filter((name) => {
306
+ const normalized = normalizeEntityName(name, pair.type);
307
+ return normalized !== "" && !own.has(normalized) && !moved.has(normalized);
308
+ });
309
+ const withRefs = appendBackRefs(originalContent, readBackRefs(copy.body));
310
+ const lines = missingRelationshipLines(withRefs.content, copyContent);
311
+ const body = appendRelationshipLines(withRefs.content, lines);
312
+ const raise = copyEntity.signal_count > originalEntity.signal_count ? copyEntity.signal_count : undefined;
313
+ const patched = patchEntityFile(body, {
314
+ addAliases: aliases,
315
+ signalCount: copyEntity.signal_count,
316
+ lastUpdated: now.toISOString(),
317
+ bodyChanged: withRefs.added > 0 || lines.length > 0,
318
+ });
319
+ if (!patched.ok)
320
+ return patched.reason;
321
+ const have = new Set(splitBody(patched.content)?.body.split(/\r?\n/).map((line) => line.trim()) ?? []);
322
+ const leftover = (splitBody(copyContent)?.body.split(/\r?\n/) ?? [])
323
+ .map((line) => line.trim())
324
+ .filter((line) => line !== "" && !have.has(line)).length;
325
+ const keys = [...new Set([...Object.keys(original.frontmatter), ...Object.keys(copy.frontmatter)])].sort();
326
+ const kept = keys.filter((key) => !OWNED_KEYS.has(key) && !IGNORED_KEYS.has(key) && !isDeepStrictEqual(original.frontmatter[key], copy.frontmatter[key]));
327
+ return {
328
+ content: patched.content,
329
+ changed: patched.changed,
330
+ aliases,
331
+ dropped,
332
+ backrefs: withRefs.added,
333
+ lines: lines.length,
334
+ ...(raise !== undefined ? { raise } : {}),
335
+ kept,
336
+ leftover,
337
+ };
338
+ }
339
+ function read(company, rel) {
340
+ return fs.readFileSync(path.join(company.dir, rel), "utf8");
341
+ }
342
+ /** Links to copies in the company brief (rendered, so reported and not rewritten). */
343
+ function briefLinks(company, copies) {
344
+ let content;
345
+ try {
346
+ content = fs.readFileSync(path.join(company.dir, "company-brief.md"), "utf8");
347
+ }
348
+ catch {
349
+ return [];
350
+ }
351
+ const out = new Set();
352
+ for (const match of content.matchAll(MD_LINK)) {
353
+ const target = resolveLink("company-brief.md", match[1]);
354
+ if (target && copies.has(target))
355
+ out.add(target);
356
+ }
357
+ return [...out].sort();
358
+ }
359
+ function plan(company, index, pairs, allPairs, now) {
360
+ const links = scanLinks(company);
361
+ const copies = new Set(allPairs.map((pair) => pair.copy));
362
+ const other = scanOtherLinks(company, copies);
363
+ const signalRefs = scanSignalRefs(company, copies);
364
+ const siblingsOf = new Map();
365
+ for (const pair of allPairs)
366
+ siblingsOf.set(pair.original, [...(siblingsOf.get(pair.original) ?? []), pair.copy]);
367
+ const out = { plans: [], merges: new Map(), finals: new Map(), notReady: new Map() };
368
+ for (const pair of pairs) {
369
+ const inbound = [...new Set([...(links.inbound.get(pair.copy) ?? []), ...(other.get(pair.copy) ?? [])])].sort();
370
+ const refs = (signalRefs.get(pair.copy) ?? []).sort();
371
+ const siblings = siblingsOf.get(pair.original) ?? [pair.copy];
372
+ const copyContent = read(company, pair.copy);
373
+ const onDisk = read(company, pair.original);
374
+ const merged = mergeCopy(index, pair, siblings, out.finals.get(pair.original) ?? onDisk, copyContent, now);
375
+ const entry = {
376
+ ...pair,
377
+ add_aliases: typeof merged === "string" ? [] : merged.aliases,
378
+ dropped_aliases: typeof merged === "string" ? [] : merged.dropped,
379
+ add_backrefs: typeof merged === "string" ? 0 : merged.backrefs,
380
+ add_relationship_lines: typeof merged === "string" ? 0 : merged.lines,
381
+ ...(typeof merged !== "string" && merged.raise !== undefined ? { raise_signal_count: merged.raise } : {}),
382
+ kept_original_keys: typeof merged === "string" ? [] : merged.kept,
383
+ inbound_links: inbound,
384
+ signal_refs: refs,
385
+ unmerged_body_lines: typeof merged === "string" ? 0 : merged.leftover,
386
+ };
387
+ const unwritable = inbound.filter((rel) => !rel.startsWith("ontology/entities/") || !isWritableEntityType(rel.split("/")[2]) || copies.has(rel));
388
+ if (typeof merged === "string") {
389
+ entry.held = "merge_invalid";
390
+ entry.detail = merged;
391
+ }
392
+ else if (refs.length > 0) {
393
+ entry.held = "signal_refs";
394
+ }
395
+ else if (unwritable.length > 0) {
396
+ entry.held = "linked_from_unwritable";
397
+ entry.detail = unwritable.join(", ");
398
+ }
399
+ else {
400
+ out.merges.set(pair.copy, merged);
401
+ out.finals.set(pair.original, merged.content);
402
+ // Deletion is judged against the original as it is on disk now.
403
+ const current = mergeCopy(index, pair, siblings, onDisk, copyContent, now);
404
+ const reason = typeof current === "string"
405
+ ? "merge_invalid"
406
+ : current.changed
407
+ ? "not_merged"
408
+ : current.leftover > 0
409
+ ? "unmerged_body"
410
+ : inbound.length > 0
411
+ ? "linked"
412
+ : null;
413
+ if (reason)
414
+ out.notReady.set(pair.copy, reason);
415
+ }
416
+ out.plans.push(entry);
417
+ }
418
+ return out;
419
+ }
420
+ function writeEntity(hqRoot, company, rel, content, now) {
421
+ // Outbox first: sync pushes an entity file only when its bytes match.
422
+ const write = { rel, content };
423
+ recordOutbox(hqRoot, company, [write], now);
424
+ safeWriteFile(hqRoot, [entitiesRoot(company)], path.join(company.dir, rel), content);
425
+ }
426
+ /**
427
+ * Plan, merge or delete numbered copies for one company. The dry run writes
428
+ * nothing; merge writes entity files only; delete removes vault keys and
429
+ * local copy files only.
430
+ */
431
+ export async function mergeCopies(options) {
432
+ const { hqRoot, company, mode, now } = options;
433
+ const index = buildEntityIndex(company.dir);
434
+ const found = findCopyPairs(index);
435
+ const pairs = options.limit !== undefined ? found.pairs.slice(0, Math.max(0, options.limit)) : found.pairs;
436
+ const before = scanLinks(company).broken;
437
+ const { plans, merges, finals, notReady } = plan(company, index, pairs, found.pairs, now);
438
+ const report = {
439
+ company: company.slug,
440
+ mode,
441
+ duplicate_groups_before: findDuplicateGroups(index).length,
442
+ duplicate_groups_after: 0,
443
+ copies_found: found.pairs.length,
444
+ plans,
445
+ skipped: found.skipped,
446
+ merged: [],
447
+ relinked: [],
448
+ deleted: [],
449
+ vault_already_absent: [],
450
+ not_ready: [],
451
+ errors: [],
452
+ broken_links_before: before,
453
+ broken_links_after: before,
454
+ brief_links_to_copies: briefLinks(company, new Set(pairs.map((pair) => pair.copy))),
455
+ };
456
+ if (mode === "merge") {
457
+ // Originals first, once each with every copy merged in; then links, read
458
+ // back from disk so a relinked original keeps its merge.
459
+ for (const [original, content] of finals) {
460
+ try {
461
+ if (content === read(company, original))
462
+ continue;
463
+ writeEntity(hqRoot, company, original, content, now);
464
+ report.merged.push(original);
465
+ }
466
+ catch (error) {
467
+ report.errors.push({ path: original, error: error.name });
468
+ }
469
+ }
470
+ for (const entry of plans) {
471
+ if (!merges.has(entry.copy))
472
+ continue;
473
+ for (const rel of entry.inbound_links) {
474
+ try {
475
+ const content = read(company, rel);
476
+ const next = relink(rel, content, entry.copy, entry.original);
477
+ if (next === content)
478
+ continue;
479
+ writeEntity(hqRoot, company, rel, next, now);
480
+ if (!report.relinked.includes(rel))
481
+ report.relinked.push(rel);
482
+ }
483
+ catch (error) {
484
+ report.errors.push({ path: rel, error: error.name });
485
+ }
486
+ }
487
+ }
488
+ }
489
+ if (mode === "delete") {
490
+ if (!options.deleteVaultKey)
491
+ throw new Error("merge-copies: delete needs a vault client");
492
+ for (const entry of plans) {
493
+ const reason = entry.held ?? notReady.get(entry.copy) ?? null;
494
+ if (reason) {
495
+ report.not_ready.push({ copy: entry.copy, reason });
496
+ continue;
497
+ }
498
+ try {
499
+ const deleted = await options.deleteVaultKey(entry.copy);
500
+ // 0 means the exact key is already gone (a run that stopped before the
501
+ // local removal, or a copy that never synced): finish the local side.
502
+ if (deleted !== 1 && deleted !== 0) {
503
+ report.errors.push({ path: entry.copy, error: `vault_deleted_${deleted}` });
504
+ continue;
505
+ }
506
+ fs.rmSync(path.join(company.dir, entry.copy), { force: true });
507
+ report.deleted.push(entry.copy);
508
+ if (deleted === 0)
509
+ report.vault_already_absent.push(entry.copy);
510
+ }
511
+ catch (error) {
512
+ report.errors.push({ path: entry.copy, error: error.message || error.name });
513
+ }
514
+ }
515
+ }
516
+ const after = buildEntityIndex(company.dir);
517
+ report.duplicate_groups_after = findDuplicateGroups(after).length;
518
+ report.broken_links_after = scanLinks(company).broken;
519
+ return report;
520
+ }
521
+ //# sourceMappingURL=merge-copies.js.map
@@ -10,6 +10,8 @@
10
10
  * them instead of silently dropping them.
11
11
  */
12
12
  import type { MachineType } from "./machine-type.js";
13
+ import type { SyncOwner } from "./config.js";
14
+ import { type SyncRunnerProcess } from "./sync-runner.js";
13
15
  export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
14
16
  export type ServiceName = (typeof SERVICE_NAMES)[number];
15
17
  export declare function isServiceName(value: unknown): value is ServiceName;
@@ -51,6 +53,8 @@ export interface UnitPlan {
51
53
  restart: "always" | "on-failure";
52
54
  intervalSec?: number;
53
55
  initialDelaySec?: number;
56
+ /** Kill and restart this child after silence on its liveness stream. */
57
+ heartbeatTimeoutMs?: number;
54
58
  command: UnitCommand | null;
55
59
  env: Record<string, string>;
56
60
  blocked?: UnitBlock;
@@ -68,11 +72,23 @@ export interface PlanContext {
68
72
  sessionHostRunnerPath: string | null;
69
73
  /** Environment retained from legacy outpost sync systemd drop-ins. */
70
74
  legacySyncEnv: Record<string, string>;
75
+ /** Keep the local sync runner stopped until the operator resumes it. */
76
+ syncPaused: boolean;
77
+ /** Persisted/effective owner selected for this machine. */
78
+ syncOwner: SyncOwner;
79
+ /** A legacy desktop app or sync unit is still managing sync. */
80
+ desktopAppManagesSync: boolean;
81
+ /** Full process list, or null when the daemon could not read it. */
82
+ syncRunnerProcesses: readonly SyncRunnerProcess[] | null;
83
+ /** PID recorded in the watch-owner status, when it is readable. */
84
+ syncRunnerOwnerPid: number | null;
85
+ /** Sync runner children already started by this daemon before their lease is written. */
86
+ daemonSyncRunnerPids?: ReadonlySet<number>;
87
+ /** The protocol-event heartbeat watchdog timeout for the sync runner. */
88
+ syncHeartbeatTimeoutMs: number;
71
89
  signedIn: boolean;
72
90
  /** ~/.hq-agent/kit.json exists. */
73
91
  kitConfigured: boolean;
74
- /** The HQ desktop app runs its own sync on this machine. */
75
- desktopAppManagesSync: boolean;
76
92
  bots: readonly string[];
77
93
  /** Name of a pre-daemon unit that still runs this unit id, or null. */
78
94
  legacyOwner: (unitId: string) => string | null;
@@ -9,6 +9,7 @@
9
9
  * with no entrypoint yet stay in the catalog as `unavailable` so status shows
10
10
  * them instead of silently dropping them.
11
11
  */
12
+ import { SYNC_RUNNER_OWNER, syncRunnerUnleasedBlockReason } from "./sync-runner.js";
12
13
  export const SERVICE_NAMES = [
13
14
  "sync",
14
15
  "mesh",
@@ -127,14 +128,29 @@ function planService(service, ctx) {
127
128
  command: {
128
129
  type: "node",
129
130
  script: ctx.syncRunnerPath,
130
- args: ["--companies", "--direction", "both", "--on-conflict", "keep", "--hq-root", ctx.hqRoot ?? "", "--watch", "--event-push"],
131
+ args: ["--companies", "--direction", "both", "--on-conflict", "keep", "--hq-root", ctx.hqRoot ?? "", "--watch", "--event-push", "--owner", SYNC_RUNNER_OWNER],
131
132
  },
132
133
  env: { ...ctx.legacySyncEnv },
134
+ heartbeatTimeoutMs: ctx.syncHeartbeatTimeoutMs,
133
135
  });
136
+ let ownerBlock;
137
+ if (ctx.machineType === "desktop" && ctx.syncOwner === "desktop-app" && ctx.desktopAppManagesSync) {
138
+ ownerBlock = "the desktop app is still managing sync (run `hq daemon sync take-ownership` after it stops its runner)";
139
+ }
140
+ else if (ctx.machineType === "desktop" &&
141
+ ctx.syncOwner === "daemon" &&
142
+ !ctx.syncPaused &&
143
+ ctx.hqRoot &&
144
+ ctx.signedIn) {
145
+ ownerBlock = ctx.syncRunnerProcesses === null
146
+ ? "could not check for an existing sync runner; waiting to avoid starting a duplicate"
147
+ : syncRunnerUnleasedBlockReason(ctx.hqRoot, ctx.syncRunnerProcesses, ctx.syncRunnerOwnerPid, ctx.platform, ctx.daemonSyncRunnerPids);
148
+ }
134
149
  unit.blocked = firstBlock([
135
- [ctx.desktopAppManagesSync, "the HQ desktop app is handling sync on this machine"],
150
+ [ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
136
151
  [!ctx.hqRoot, NO_HQ_ROOT],
137
152
  [!ctx.signedIn, NOT_SIGNED_IN],
153
+ [!!ownerBlock, ownerBlock ?? ""],
138
154
  ]);
139
155
  return [unit];
140
156
  }
@@ -12,11 +12,17 @@ export interface DaemonConfig {
12
12
  hqRoot?: string;
13
13
  /** Exact hq-cloud package version preferred by this daemon, including prereleases. */
14
14
  hqCloudVersion?: string;
15
+ /** Persisted machine-local sync pause. */
16
+ syncPaused?: boolean;
17
+ /** Explicit desktop handoff; absent values preserve the legacy per-machine owner. */
18
+ syncOwner?: SyncOwner;
15
19
  /** Services turned on here beyond the machine type's defaults. */
16
20
  enable: string[];
17
21
  /** Default services turned off here. */
18
22
  disable: string[];
19
23
  }
24
+ export type SyncOwner = "daemon" | "desktop-app";
25
+ export declare function effectiveSyncOwner(config: Pick<DaemonConfig, "syncOwner">, machineType: MachineType): SyncOwner;
20
26
  export declare function emptyDaemonConfig(): DaemonConfig;
21
27
  export declare function readDaemonConfig(file: string): DaemonConfig;
22
28
  export declare function writeDaemonConfig(file: string, config: DaemonConfig): void;
@@ -10,8 +10,11 @@ import { valid as validSemver } from "semver";
10
10
  import { isServiceName } from "./catalog.js";
11
11
  import { isMachineType } from "./machine-type.js";
12
12
  import { ensurePrivateDir } from "./paths.js";
13
+ export function effectiveSyncOwner(config, machineType) {
14
+ return config.syncOwner ?? (machineType === "desktop" ? "desktop-app" : "daemon");
15
+ }
13
16
  export function emptyDaemonConfig() {
14
- return { version: 1, enable: [], disable: [] };
17
+ return { version: 1, syncPaused: false, enable: [], disable: [] };
15
18
  }
16
19
  function stringList(value) {
17
20
  return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
@@ -36,9 +39,16 @@ export function readDaemonConfig(file) {
36
39
  if (!raw || typeof raw !== "object")
37
40
  throw new Error(`hq daemon config at ${file} is not a JSON object`);
38
41
  const obj = raw;
39
- const config = { version: 1, enable: stringList(obj.enable), disable: stringList(obj.disable) };
42
+ const config = {
43
+ version: 1,
44
+ syncPaused: obj.syncPaused === true,
45
+ enable: stringList(obj.enable),
46
+ disable: stringList(obj.disable),
47
+ };
40
48
  if (isMachineType(obj.machineType))
41
49
  config.machineType = obj.machineType;
50
+ if (obj.syncOwner === "daemon" || obj.syncOwner === "desktop-app")
51
+ config.syncOwner = obj.syncOwner;
42
52
  if (typeof obj.hqRoot === "string" && obj.hqRoot.trim())
43
53
  config.hqRoot = obj.hqRoot;
44
54
  if (typeof obj.hqCloudVersion === "string" && validSemver(obj.hqCloudVersion) === obj.hqCloudVersion) {
@@ -56,6 +66,9 @@ export function writeDaemonConfig(file, config) {
56
66
  ordered.hqRoot = config.hqRoot;
57
67
  if (config.hqCloudVersion)
58
68
  ordered.hqCloudVersion = config.hqCloudVersion;
69
+ ordered.syncPaused = config.syncPaused === true;
70
+ if (config.syncOwner)
71
+ ordered.syncOwner = config.syncOwner;
59
72
  ordered.enable = config.enable;
60
73
  ordered.disable = config.disable;
61
74
  fs.writeFileSync(tmp, `${JSON.stringify(ordered, null, 2)}\n`, { mode: 0o600 });
@@ -15,6 +15,10 @@ export type ControlRequest = {
15
15
  action: "stop";
16
16
  unit: string;
17
17
  }
18
+ /** Re-read persisted configuration and apply any changed blocks immediately. */
19
+ | {
20
+ action: "reconcile";
21
+ }
18
22
  /** Restart the whole daemon, for example after the updater installed a new CLI. */
19
23
  | {
20
24
  action: "restart-daemon";