@indigoai-us/hq-cli 5.271.0 → 5.272.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.272.0] — 2026-09-28
6
+
7
+ ### Added
8
+
9
+ - Add `hq core ontology merge-copies`, gated by
10
+ `checkpoint.ontology.merge-copies`. It merges each `<name> N.md` entity copy
11
+ into its own original (aliases, back-references, relationship links and
12
+ signal count), repoints links to the copy, and with `--delete` removes merged
13
+ copies from the vault. Dry run by default.
14
+
5
15
  ## [5.271.0] — 2026-09-28
6
16
 
7
17
  ### Fixed
@@ -5148,6 +5148,33 @@ export declare const COMMAND_CATALOG: readonly [{
5148
5148
  readonly description: "HQ installation to operate on";
5149
5149
  }];
5150
5150
  readonly subcommands: readonly [];
5151
+ }, {
5152
+ readonly name: "merge-copies";
5153
+ readonly description: "Merge '<name> N.md' entity copies into their original, then delete them from the vault (dry run by default)";
5154
+ readonly aliases: readonly [];
5155
+ readonly hidden: false;
5156
+ readonly usage: "[options]";
5157
+ readonly arguments: readonly [];
5158
+ readonly options: readonly [{
5159
+ readonly flags: "--company <slug>";
5160
+ readonly description: "company to change";
5161
+ }, {
5162
+ readonly flags: "--apply";
5163
+ readonly description: "merge each copy into its original and re-point links (writes entity files)";
5164
+ }, {
5165
+ readonly flags: "--delete";
5166
+ readonly description: "delete fully merged copies from the vault by exact key (after sync pushed the merge)";
5167
+ }, {
5168
+ readonly flags: "--limit <n>";
5169
+ readonly description: "handle at most this many copies";
5170
+ }, {
5171
+ readonly flags: "--json";
5172
+ readonly description: "print JSON";
5173
+ }, {
5174
+ readonly flags: "--hq-root <path>";
5175
+ readonly description: "HQ installation to operate on";
5176
+ }];
5177
+ readonly subcommands: readonly [];
5151
5178
  }, {
5152
5179
  readonly name: "apply";
5153
5180
  readonly description: "Validate the run's entity candidates and write them to the payload company";
@@ -6657,6 +6657,41 @@ export const COMMAND_CATALOG = [
6657
6657
  ],
6658
6658
  "subcommands": []
6659
6659
  },
6660
+ {
6661
+ "name": "merge-copies",
6662
+ "description": "Merge '<name> N.md' entity copies into their original, then delete them from the vault (dry run by default)",
6663
+ "aliases": [],
6664
+ "hidden": false,
6665
+ "usage": "[options]",
6666
+ "arguments": [],
6667
+ "options": [
6668
+ {
6669
+ "flags": "--company <slug>",
6670
+ "description": "company to change"
6671
+ },
6672
+ {
6673
+ "flags": "--apply",
6674
+ "description": "merge each copy into its original and re-point links (writes entity files)"
6675
+ },
6676
+ {
6677
+ "flags": "--delete",
6678
+ "description": "delete fully merged copies from the vault by exact key (after sync pushed the merge)"
6679
+ },
6680
+ {
6681
+ "flags": "--limit <n>",
6682
+ "description": "handle at most this many copies"
6683
+ },
6684
+ {
6685
+ "flags": "--json",
6686
+ "description": "print JSON"
6687
+ },
6688
+ {
6689
+ "flags": "--hq-root <path>",
6690
+ "description": "HQ installation to operate on"
6691
+ }
6692
+ ],
6693
+ "subcommands": []
6694
+ },
6660
6695
  {
6661
6696
  "name": "apply",
6662
6697
  "description": "Validate the run's entity candidates and write them to the payload company",
@@ -9,12 +9,15 @@
9
9
  */
10
10
  import { Command } from "commander";
11
11
  import { type FlagReader } from "../lib/checkpoint-ontology/plan.js";
12
+ import { type DeleteVaultKey } from "../lib/checkpoint-ontology/merge-copies.js";
12
13
  export declare const ONTOLOGY_PAYLOAD_FILE = "payload.json";
13
14
  /** The hq-flags read, scoped to the payload company. */
14
15
  export declare const registryFlagReader: FlagReader;
15
16
  export interface OntologyCommandDeps {
16
17
  readFlag?: FlagReader;
17
18
  now?: () => Date;
19
+ /** Builds the exact-key vault delete for a company uid. */
20
+ vaultDeleter?: (companyUid: string) => Promise<DeleteVaultKey>;
18
21
  }
19
22
  export declare function runOntologyPlan(hqRoot: string, runDirOption: string | undefined, deps?: OntologyCommandDeps): Promise<string>;
20
23
  export declare function runOntologyApply(hqRoot: string, runDirOption: string | undefined, deps?: OntologyCommandDeps): string;
@@ -24,5 +27,18 @@ export declare function runOntologyApply(hqRoot: string, runDirOption: string |
24
27
  * `checkpoint.ontology.multi-writer`.
25
28
  */
26
29
  export declare function runOntologyDuplicates(hqRoot: string, slug: string | undefined, json: boolean, deps?: OntologyCommandDeps): Promise<string>;
30
+ export interface MergeCopiesCommandOptions {
31
+ apply?: boolean;
32
+ delete?: boolean;
33
+ limit?: number;
34
+ json?: boolean;
35
+ }
36
+ /**
37
+ * `hq core ontology merge-copies`: merge `<slug> <n>.md` copies into their
38
+ * original (`--apply`), then delete merged copies from the vault
39
+ * (`--delete`). Without either it only reports. Gated by
40
+ * `checkpoint.ontology.merge-copies` and `checkpoint.ontology.multi-writer`.
41
+ */
42
+ export declare function runOntologyMergeCopies(hqRoot: string, slug: string | undefined, options: MergeCopiesCommandOptions, deps?: OntologyCommandDeps): Promise<string>;
27
43
  export declare function registerCoreOntologyCommand(core: Command): void;
28
44
  //# sourceMappingURL=core-ontology.d.ts.map
@@ -16,6 +16,9 @@ import { applyOntologyRun, ApplyError } from "../lib/checkpoint-ontology/apply.j
16
16
  import { resolveOntologyCompany, WriteBoundsError } from "../lib/checkpoint-ontology/company.js";
17
17
  import { findDuplicateGroups, summarizeMergeBack, } from "../lib/checkpoint-ontology/multi-writer.js";
18
18
  import { buildEntityIndex } from "../lib/checkpoint-ontology/tree.js";
19
+ import { MERGE_COPIES_FLAG, mergeCopies, } from "../lib/checkpoint-ontology/merge-copies.js";
20
+ import { callDeleteEndpoint } from "./files.js";
21
+ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
19
22
  export const ONTOLOGY_PAYLOAD_FILE = "payload.json";
20
23
  class OntologyUsageError extends Error {
21
24
  }
@@ -124,6 +127,72 @@ export async function runOntologyDuplicates(hqRoot, slug, json, deps = {}) {
124
127
  }
125
128
  return lines.join("\n");
126
129
  }
130
+ /** Delete one exact company vault key, refusing when the server would match anything else. */
131
+ async function companyVaultDeleter(companyUid) {
132
+ const token = (await resolveVaultCredential()).token;
133
+ return async (key) => {
134
+ const preview = await callDeleteEndpoint({ token, companyUid, prefix: key, dryRun: true });
135
+ if (preview.matched === 0)
136
+ return 0;
137
+ if (preview.matched !== 1 || preview.keys.length !== 1 || preview.keys[0] !== key) {
138
+ throw new Error(`vault preview matched ${preview.matched} object(s), not exactly ${key}`);
139
+ }
140
+ return (await callDeleteEndpoint({ token, companyUid, prefix: key, dryRun: false })).deleted;
141
+ };
142
+ }
143
+ function mergeCopiesSummary(report) {
144
+ const held = report.plans.filter((plan) => plan.held).length;
145
+ const lines = [
146
+ `ontology merge-copies (${report.mode.replace("_", " ")}) in ${report.company}: ${report.plans.length} of ${report.copies_found} copies, ${held} held, ${report.skipped.length} skipped`,
147
+ ` merged ${report.merged.length}, relinked ${report.relinked.length}, deleted ${report.deleted.length}, not ready ${report.not_ready.length}, errors ${report.errors.length}`,
148
+ ` duplicate groups ${report.duplicate_groups_before} -> ${report.duplicate_groups_after}; broken entity links ${report.broken_links_before} -> ${report.broken_links_after}`,
149
+ ];
150
+ for (const plan of report.plans.filter((entry) => entry.held))
151
+ lines.push(` held ${plan.copy}: ${plan.held}`);
152
+ for (const error of report.errors)
153
+ lines.push(` error ${error.path}: ${error.error}`);
154
+ return lines.join("\n");
155
+ }
156
+ /**
157
+ * `hq core ontology merge-copies`: merge `<slug> <n>.md` copies into their
158
+ * original (`--apply`), then delete merged copies from the vault
159
+ * (`--delete`). Without either it only reports. Gated by
160
+ * `checkpoint.ontology.merge-copies` and `checkpoint.ontology.multi-writer`.
161
+ */
162
+ export async function runOntologyMergeCopies(hqRoot, slug, options, deps = {}) {
163
+ if (options.apply && options.delete) {
164
+ throw new OntologyUsageError("ontology: run --apply first, let sync push the merged files, then run --delete");
165
+ }
166
+ if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1)) {
167
+ throw new OntologyUsageError("ontology: --limit must be a positive whole number");
168
+ }
169
+ const resolution = resolveOntologyCompany(hqRoot, slug);
170
+ if (!resolution.ok)
171
+ throw new OntologyUsageError(`ontology: company rejected: ${resolution.reason}`);
172
+ const company = resolution.company;
173
+ const readFlag = deps.readFlag ?? registryFlagReader;
174
+ for (const flag of [MERGE_COPIES_FLAG, MULTI_WRITER_FLAG]) {
175
+ if (!(await readFlag(flag, company))) {
176
+ return options.json ? JSON.stringify({ company: company.slug, skipped: `${flag} off` }) : `ontology merge-copies: skipped (${flag} is off)`;
177
+ }
178
+ }
179
+ const mode = options.apply ? "merge" : options.delete ? "delete" : "dry_run";
180
+ let deleteVaultKey;
181
+ if (mode === "delete") {
182
+ if (!company.cloudUid)
183
+ throw new OntologyUsageError("ontology: the company has no cloud_uid in the manifest");
184
+ deleteVaultKey = await (deps.vaultDeleter ?? companyVaultDeleter)(company.cloudUid);
185
+ }
186
+ const report = await mergeCopies({
187
+ hqRoot,
188
+ company,
189
+ mode,
190
+ now: deps.now?.() ?? new Date(),
191
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
192
+ ...(deleteVaultKey ? { deleteVaultKey } : {}),
193
+ });
194
+ return options.json ? JSON.stringify(report, null, 2) : mergeCopiesSummary(report);
195
+ }
127
196
  function fail(error) {
128
197
  if (error instanceof OntologyUsageError || error instanceof ApplyError || error instanceof WriteBoundsError) {
129
198
  process.stderr.write(`${error.message}\n`);
@@ -163,6 +232,23 @@ export function registerCoreOntologyCommand(core) {
163
232
  fail(error);
164
233
  }
165
234
  });
235
+ ontology
236
+ .command("merge-copies")
237
+ .description("Merge '<name> N.md' entity copies into their original, then delete them from the vault (dry run by default)")
238
+ .requiredOption("--company <slug>", "company to change")
239
+ .option("--apply", "merge each copy into its original and re-point links (writes entity files)")
240
+ .option("--delete", "delete fully merged copies from the vault by exact key (after sync pushed the merge)")
241
+ .option("--limit <n>", "handle at most this many copies", (value) => Number(value))
242
+ .option("--json", "print JSON")
243
+ .option("--hq-root <path>", "HQ installation to operate on")
244
+ .action(async (options) => {
245
+ try {
246
+ process.stdout.write(`${await runOntologyMergeCopies(rootFor(options), options.company, options)}\n`);
247
+ }
248
+ catch (error) {
249
+ fail(error);
250
+ }
251
+ });
166
252
  ontology
167
253
  .command("apply")
168
254
  .description("Validate the run's entity candidates and write them to the payload company")
@@ -62,6 +62,22 @@ export declare class FilesDeleteHttpError extends Error {
62
62
  export declare function formatFilesDeleteError(err: FilesDeleteHttpError, prefix: string): string;
63
63
  /** Injectable yes/no confirmation seam (stubbed in tests). */
64
64
  export type ConfirmFn = (message: string) => Promise<boolean>;
65
+ /**
66
+ * POST /v1/files/delete. Throws FilesDeleteHttpError on any non-2xx so the one
67
+ * caller renders a single consistent error path.
68
+ *
69
+ * Scope is EITHER a company vault (`companyUid` set) OR the caller's personal
70
+ * vault (`personal: true`). For the personal case the server resolves the target
71
+ * person + bucket from the authenticated caller — we send NO uid, just the
72
+ * `personal` flag — so there is nothing for the client to get wrong or spoof.
73
+ */
74
+ export declare function callDeleteEndpoint(params: {
75
+ token: string;
76
+ companyUid?: string;
77
+ personal?: boolean;
78
+ prefix: string;
79
+ dryRun: boolean;
80
+ }): Promise<FilesDeleteResponse>;
65
81
  interface RunFilesDeleteParams {
66
82
  prefix: string;
67
83
  dryRun: boolean;
@@ -879,7 +879,7 @@ function realConfirm(message) {
879
879
  * person + bucket from the authenticated caller — we send NO uid, just the
880
880
  * `personal` flag — so there is nothing for the client to get wrong or spoof.
881
881
  */
882
- async function callDeleteEndpoint(params) {
882
+ export async function callDeleteEndpoint(params) {
883
883
  const res = await vaultApiFetch({
884
884
  token: params.token,
885
885
  path: "/v1/files/delete",
@@ -0,0 +1,116 @@
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 { type OntologyCompany } from "./company.js";
32
+ import { type EntityIndex } from "./tree.js";
33
+ export declare const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
34
+ export type CopySkipReason = "no_original" | "original_invalid" | "identity_differs" | "not_in_duplicate_group";
35
+ export interface CopySkip {
36
+ copy: string;
37
+ reason: CopySkipReason;
38
+ }
39
+ export interface CopyPair {
40
+ type: string;
41
+ /** Company-relative path of the copy. */
42
+ copy: string;
43
+ /** Company-relative path of the original it merges into. */
44
+ original: string;
45
+ }
46
+ export interface CopyPlan extends CopyPair {
47
+ add_aliases: string[];
48
+ /** Copy names the original lacks that another entity already holds, so they do not move. */
49
+ dropped_aliases: string[];
50
+ add_backrefs: number;
51
+ add_relationship_lines: number;
52
+ /** The copy's higher `signal_count`, when it is higher. */
53
+ raise_signal_count?: number;
54
+ /** Keys the checkpoint does not own whose values differ; the original's value stays. */
55
+ kept_original_keys: string[];
56
+ /** Entity files that link to the copy. */
57
+ inbound_links: string[];
58
+ /** Signal files whose `entity_refs` name the copy. */
59
+ signal_refs: string[];
60
+ /** Why the copy cannot be merged or deleted from here. */
61
+ held?: "signal_refs" | "linked_from_unwritable" | "merge_invalid";
62
+ detail?: string;
63
+ /** Body lines of the copy the merged original lacks; a copy with any is never deleted. */
64
+ unmerged_body_lines: number;
65
+ }
66
+ export interface MergeCopiesReport {
67
+ company: string;
68
+ mode: "dry_run" | "merge" | "delete";
69
+ duplicate_groups_before: number;
70
+ duplicate_groups_after: number;
71
+ copies_found: number;
72
+ plans: CopyPlan[];
73
+ skipped: CopySkip[];
74
+ merged: string[];
75
+ relinked: string[];
76
+ deleted: string[];
77
+ /** Deleted copies the vault no longer had (a resumed run, or never synced). */
78
+ vault_already_absent: string[];
79
+ /** Copies still carrying content, links or refs, so not deleted this run. */
80
+ not_ready: {
81
+ copy: string;
82
+ reason: string;
83
+ }[];
84
+ errors: {
85
+ path: string;
86
+ error: string;
87
+ }[];
88
+ /** Entity-file links whose target file does not exist. */
89
+ broken_links_before: number;
90
+ broken_links_after: number;
91
+ /** Links in `company-brief.md` to a copy; the brief is re-rendered by the next checkpoint. */
92
+ brief_links_to_copies: string[];
93
+ }
94
+ /** Deletes one exact vault key; resolves to the number of objects deleted. */
95
+ export type DeleteVaultKey = (key: string) => Promise<number>;
96
+ export interface MergeCopiesOptions {
97
+ hqRoot: string;
98
+ company: OntologyCompany;
99
+ mode: "dry_run" | "merge" | "delete";
100
+ /** Handle at most this many copies (sorted by path). */
101
+ limit?: number;
102
+ now: Date;
103
+ deleteVaultKey?: DeleteVaultKey;
104
+ }
105
+ /** Copies in scope, and the ones skipped with a reason. Read-only. */
106
+ export declare function findCopyPairs(index: EntityIndex): {
107
+ pairs: CopyPair[];
108
+ skipped: CopySkip[];
109
+ };
110
+ /**
111
+ * Plan, merge or delete numbered copies for one company. The dry run writes
112
+ * nothing; merge writes entity files only; delete removes vault keys and
113
+ * local copy files only.
114
+ */
115
+ export declare function mergeCopies(options: MergeCopiesOptions): Promise<MergeCopiesReport>;
116
+ //# sourceMappingURL=merge-copies.d.ts.map
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.271.0",
3
+ "version": "5.272.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {