@indigoai-us/hq-cli 5.221.0 → 5.222.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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.222.0] — 2026-09-26
6
+
7
+ - Checkpoint ontology, behind `checkpoint.ontology.multi-writer` (off by
8
+ default): entity versions that sync set aside after losing a push race are
9
+ merged back at the start of each plan (aliases, back-references, the higher
10
+ `signal_count`, the later `last_updated`). Any other difference keeps both
11
+ and leaves the set-aside copy, and an entity deleted since is not
12
+ re-created. Every entity file the step writes is recorded in
13
+ `.hq/ontology-outbox/<company>.json`. The apply report counts duplicate
14
+ entity groups, and `hq core ontology duplicates --company <slug> [--json]`
15
+ lists them.
16
+
5
17
  ## [5.221.0] — 2026-09-26
6
18
 
7
19
  ### Added
@@ -5124,6 +5124,24 @@ export declare const COMMAND_CATALOG: readonly [{
5124
5124
  readonly description: "HQ installation to operate on";
5125
5125
  }];
5126
5126
  readonly subcommands: readonly [];
5127
+ }, {
5128
+ readonly name: "duplicates";
5129
+ readonly description: "List same-type entities whose names collide, with a proposed survivor (read-only)";
5130
+ readonly aliases: readonly [];
5131
+ readonly hidden: false;
5132
+ readonly usage: "[options]";
5133
+ readonly arguments: readonly [];
5134
+ readonly options: readonly [{
5135
+ readonly flags: "--company <slug>";
5136
+ readonly description: "company to scan";
5137
+ }, {
5138
+ readonly flags: "--json";
5139
+ readonly description: "print JSON";
5140
+ }, {
5141
+ readonly flags: "--hq-root <path>";
5142
+ readonly description: "HQ installation to operate on";
5143
+ }];
5144
+ readonly subcommands: readonly [];
5127
5145
  }, {
5128
5146
  readonly name: "apply";
5129
5147
  readonly description: "Validate the run's entity candidates and write them to the payload company";
@@ -6626,6 +6626,29 @@ export const COMMAND_CATALOG = [
6626
6626
  ],
6627
6627
  "subcommands": []
6628
6628
  },
6629
+ {
6630
+ "name": "duplicates",
6631
+ "description": "List same-type entities whose names collide, with a proposed survivor (read-only)",
6632
+ "aliases": [],
6633
+ "hidden": false,
6634
+ "usage": "[options]",
6635
+ "arguments": [],
6636
+ "options": [
6637
+ {
6638
+ "flags": "--company <slug>",
6639
+ "description": "company to scan"
6640
+ },
6641
+ {
6642
+ "flags": "--json",
6643
+ "description": "print JSON"
6644
+ },
6645
+ {
6646
+ "flags": "--hq-root <path>",
6647
+ "description": "HQ installation to operate on"
6648
+ }
6649
+ ],
6650
+ "subcommands": []
6651
+ },
6629
6652
  {
6630
6653
  "name": "apply",
6631
6654
  "description": "Validate the run's entity candidates and write them to the payload company",
@@ -18,5 +18,11 @@ export interface OntologyCommandDeps {
18
18
  }
19
19
  export declare function runOntologyPlan(hqRoot: string, runDirOption: string | undefined, deps?: OntologyCommandDeps): Promise<string>;
20
20
  export declare function runOntologyApply(hqRoot: string, runDirOption: string | undefined, deps?: OntologyCommandDeps): string;
21
+ /**
22
+ * `hq core ontology duplicates`: every group of same-type entities whose
23
+ * names collide, with a proposed survivor. Read-only; gated by
24
+ * `checkpoint.ontology.multi-writer`.
25
+ */
26
+ export declare function runOntologyDuplicates(hqRoot: string, slug: string | undefined, json: boolean, deps?: OntologyCommandDeps): Promise<string>;
21
27
  export declare function registerCoreOntologyCommand(core: Command): void;
22
28
  //# sourceMappingURL=core-ontology.d.ts.map
@@ -11,9 +11,11 @@ import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { resolveLiveRoot } from "../utils/hq-roots.js";
13
13
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
14
- import { planOntologyRun } from "../lib/checkpoint-ontology/plan.js";
14
+ import { MULTI_WRITER_FLAG, planOntologyRun, } from "../lib/checkpoint-ontology/plan.js";
15
15
  import { applyOntologyRun, ApplyError } from "../lib/checkpoint-ontology/apply.js";
16
- import { WriteBoundsError } from "../lib/checkpoint-ontology/company.js";
16
+ import { resolveOntologyCompany, WriteBoundsError } from "../lib/checkpoint-ontology/company.js";
17
+ import { findDuplicateGroups, summarizeMergeBack, } from "../lib/checkpoint-ontology/multi-writer.js";
18
+ import { buildEntityIndex } from "../lib/checkpoint-ontology/tree.js";
17
19
  export const ONTOLOGY_PAYLOAD_FILE = "payload.json";
18
20
  class OntologyUsageError extends Error {
19
21
  }
@@ -49,6 +51,16 @@ function companyBriefSuffix(brief) {
49
51
  return "";
50
52
  return `; company brief ${brief.status}${brief.reason ? ` (${brief.reason})` : ""}`;
51
53
  }
54
+ function multiWriterSuffix(start) {
55
+ if (!start)
56
+ return "";
57
+ const parts = [];
58
+ if (start.merge_back.length > 0)
59
+ parts.push(`set-aside versions: ${summarizeMergeBack(start.merge_back)}`);
60
+ if (start.error)
61
+ parts.push(`multi-writer error ${start.error}`);
62
+ return parts.length > 0 ? `; ${parts.join("; ")}` : "";
63
+ }
52
64
  export async function runOntologyPlan(hqRoot, runDirOption, deps = {}) {
53
65
  const runDir = requireRunDir(runDirOption);
54
66
  const outcome = await planOntologyRun({
@@ -59,12 +71,13 @@ export async function runOntologyPlan(hqRoot, runDirOption, deps = {}) {
59
71
  now: deps.now?.(),
60
72
  });
61
73
  if (outcome.status === "skipped") {
62
- return `ontology: skipped (${outcome.reason})${companyBriefSuffix(outcome.companyBrief)}`;
74
+ return `ontology: skipped (${outcome.reason})${companyBriefSuffix(outcome.companyBrief)}${multiWriterSuffix(outcome.multiWriter)}`;
63
75
  }
64
76
  const { plan } = outcome;
65
77
  return (`ontology: planned ${plan.inputs.length} input(s), ~${plan.budget.planned_tokens} tokens, ` +
66
78
  `${plan.pending_after_run} left for later runs; plan at ${outcome.planPath}` +
67
- companyBriefSuffix(outcome.companyBrief));
79
+ companyBriefSuffix(outcome.companyBrief) +
80
+ multiWriterSuffix(plan.multi_writer));
68
81
  }
69
82
  export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
70
83
  const runDir = requireRunDir(runDirOption);
@@ -78,7 +91,36 @@ export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
78
91
  return (`ontology: ${report.created.length} created, ${report.updated.length} updated, ` +
79
92
  `${report.matched} matched, ${report.rejected.length} rejected, ${report.deferred.length} deferred, ` +
80
93
  `${report.suspect_duplicates.length} suspect duplicate(s), ${report.write_errors.length} write error(s)` +
81
- companyBriefSuffix(report.company_brief));
94
+ companyBriefSuffix(report.company_brief) +
95
+ (report.multi_writer?.duplicates ? `; ${report.multi_writer.duplicates.groups} duplicate group(s) in the company` : "") +
96
+ (report.multi_writer?.error ? `; multi-writer error ${report.multi_writer.error}` : ""));
97
+ }
98
+ /**
99
+ * `hq core ontology duplicates`: every group of same-type entities whose
100
+ * names collide, with a proposed survivor. Read-only; gated by
101
+ * `checkpoint.ontology.multi-writer`.
102
+ */
103
+ export async function runOntologyDuplicates(hqRoot, slug, json, deps = {}) {
104
+ const resolution = resolveOntologyCompany(hqRoot, slug);
105
+ if (!resolution.ok)
106
+ throw new OntologyUsageError(`ontology: company rejected: ${resolution.reason}`);
107
+ const company = resolution.company;
108
+ if (!(await (deps.readFlag ?? registryFlagReader)(MULTI_WRITER_FLAG, company))) {
109
+ return json ? JSON.stringify({ company: company.slug, skipped: "flag off" }) : `ontology duplicates: skipped (${MULTI_WRITER_FLAG} is off)`;
110
+ }
111
+ const groups = findDuplicateGroups(buildEntityIndex(company.dir));
112
+ if (json)
113
+ return JSON.stringify({ company: company.slug, groups }, null, 2);
114
+ if (groups.length === 0)
115
+ return `ontology duplicates: none in ${company.slug}`;
116
+ const lines = [`ontology duplicates: ${groups.length} group(s) in ${company.slug}`];
117
+ for (const group of groups) {
118
+ lines.push(`${group.type} (${group.reason.replaceAll("_", " ")}), keep ${group.survivor}`);
119
+ for (const rel of group.paths)
120
+ if (rel !== group.survivor)
121
+ lines.push(` ${rel}`);
122
+ }
123
+ return lines.join("\n");
82
124
  }
83
125
  function fail(error) {
84
126
  if (error instanceof OntologyUsageError || error instanceof ApplyError || error instanceof WriteBoundsError) {
@@ -105,6 +147,20 @@ export function registerCoreOntologyCommand(core) {
105
147
  fail(error);
106
148
  }
107
149
  });
150
+ ontology
151
+ .command("duplicates")
152
+ .description("List same-type entities whose names collide, with a proposed survivor (read-only)")
153
+ .requiredOption("--company <slug>", "company to scan")
154
+ .option("--json", "print JSON")
155
+ .option("--hq-root <path>", "HQ installation to operate on")
156
+ .action(async (options) => {
157
+ try {
158
+ process.stdout.write(`${await runOntologyDuplicates(rootFor(options), options.company, options.json === true)}\n`);
159
+ }
160
+ catch (error) {
161
+ fail(error);
162
+ }
163
+ });
108
164
  ontology
109
165
  .command("apply")
110
166
  .description("Validate the run's entity candidates and write them to the payload company")
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Alias rules shared by `apply` and the multi-writer merge-back.
3
+ */
4
+ import type { EntityIndex } from "./tree.js";
5
+ /** Longest canonical name or alias the writer accepts. */
6
+ export declare const MAX_NAME_LENGTH = 200;
7
+ /** The index key for a normalised name of one type. */
8
+ export declare function entityKey(type: string, normalized: string): string;
9
+ /**
10
+ * Aliases worth adding to the entity at `self`: new to it, and held by no
11
+ * other entity of any type, so an alias can never make a later match
12
+ * ambiguous. `own` holds the normalised names the entity already answers to.
13
+ */
14
+ export declare function freshAliases(index: EntityIndex, type: string, self: string, own: Set<string>, names: string[]): string[];
15
+ /** The normalised names an indexed entity answers to, plus aliases queued this run. */
16
+ export declare function ownNames(index: EntityIndex, rel: string, queued: string[]): Set<string>;
17
+ //# sourceMappingURL=aliases.d.ts.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Alias rules shared by `apply` and the multi-writer merge-back.
3
+ */
4
+ import { canonicalizeName, isRejectedName, normalizeEntityName } from "./identity.js";
5
+ /** Longest canonical name or alias the writer accepts. */
6
+ export const MAX_NAME_LENGTH = 200;
7
+ /** The index key for a normalised name of one type. */
8
+ export function entityKey(type, normalized) {
9
+ return `${type}\0${normalized}`;
10
+ }
11
+ /**
12
+ * Aliases worth adding to the entity at `self`: new to it, and held by no
13
+ * other entity of any type, so an alias can never make a later match
14
+ * ambiguous. `own` holds the normalised names the entity already answers to.
15
+ */
16
+ export function freshAliases(index, type, self, own, names) {
17
+ const seen = new Set(own);
18
+ const out = [];
19
+ for (const raw of names) {
20
+ const alias = canonicalizeName(raw);
21
+ if (!alias || alias.length > MAX_NAME_LENGTH)
22
+ continue;
23
+ const normalized = normalizeEntityName(alias, type);
24
+ if (!normalized || seen.has(normalized) || isRejectedName(normalized))
25
+ continue;
26
+ const holders = [
27
+ ...(index.canonical.get(entityKey(type, normalized)) ?? []),
28
+ ...(index.alias.get(entityKey(type, normalized)) ?? []),
29
+ ];
30
+ if (holders.some((holder) => holder !== self))
31
+ continue;
32
+ if ([...(index.types.get(normalized) ?? [])].some((other) => other !== type))
33
+ continue;
34
+ seen.add(normalized);
35
+ out.push(alias);
36
+ }
37
+ return out;
38
+ }
39
+ /** The normalised names an indexed entity answers to, plus aliases queued this run. */
40
+ export function ownNames(index, rel, queued) {
41
+ const entity = index.byRel.get(rel);
42
+ return new Set([entity.canonical_name, ...entity.aliases, ...queued].map((name) => normalizeEntityName(name, entity.type)));
43
+ }
44
+ //# sourceMappingURL=aliases.js.map
@@ -5,6 +5,7 @@
5
5
  * sections 4.3, 5 and 14). The model output is untrusted input: a malformed
6
6
  * candidate is rejected and counted, never repaired.
7
7
  */
8
+ import { type DuplicateReport, type MergeBackOutcome } from "./multi-writer.js";
8
9
  import { type CompanyBriefResult } from "./company-brief.js";
9
10
  import { type WritableEntityType } from "./identity.js";
10
11
  import { type EntityIndex } from "./tree.js";
@@ -76,6 +77,15 @@ export interface ApplyReport {
76
77
  }>;
77
78
  /** Present only when the plan ran the company brief and this apply created or updated entities. */
78
79
  company_brief?: CompanyBriefResult;
80
+ /** Present only when `checkpoint.ontology.multi-writer` was on for the plan (design section 20). */
81
+ multi_writer?: {
82
+ conflicts: MergeBackOutcome[];
83
+ /** Entity files this apply wrote and recorded in the outbox. */
84
+ outbox_recorded: number;
85
+ outbox_reset?: true;
86
+ error?: string;
87
+ duplicates?: DuplicateReport;
88
+ };
79
89
  duration_ms: number;
80
90
  }
81
91
  export interface ApplyOptions {
@@ -8,6 +8,8 @@
8
8
  import { createHash } from "node:crypto";
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
+ import { entityKey, freshAliases, MAX_NAME_LENGTH, ownNames } from "./aliases.js";
12
+ import { recordOutbox, scanDuplicates } from "./multi-writer.js";
11
13
  import { writeCompanyBrief } from "./company-brief.js";
12
14
  import { entitiesRoot, resolveOntologyCompany, safeWriteFile } from "./company.js";
13
15
  import { appendBackRefs, parseEntityFile, patchEntityFile, previewSignalContent, renderNewEntity, validateEntityFrontmatter, } from "./entity-file.js";
@@ -20,7 +22,7 @@ export const CAPS = {
20
22
  bytes: 200 * 1024,
21
23
  newAliasesPerEntity: 3,
22
24
  aliasesPerCandidate: 5,
23
- nameLength: 200,
25
+ nameLength: MAX_NAME_LENGTH,
24
26
  };
25
27
  export const MIN_CREATE_CONFIDENCE = 0.7;
26
28
  export class ApplyError extends Error {
@@ -60,21 +62,18 @@ export function parseCandidate(raw) {
60
62
  evidence: typeof c.evidence === "string" ? c.evidence : "",
61
63
  };
62
64
  }
63
- function key(type, normalized) {
64
- return `${type}\0${normalized}`;
65
- }
66
65
  /** Design section 5.3, tiers 1 to 4. */
67
66
  export function resolveCandidate(index, type, canonical) {
68
67
  const normalized = normalizeEntityName(canonical, type);
69
- const byCanonical = [...(index.canonical.get(key(type, normalized)) ?? [])].sort();
68
+ const byCanonical = [...(index.canonical.get(entityKey(type, normalized)) ?? [])].sort();
70
69
  if (byCanonical.length > 0)
71
70
  return { tier: 1, target: byCanonical[0], duplicates: byCanonical.slice(1) };
72
- const byAlias = [...(index.alias.get(key(type, normalized)) ?? [])].sort();
71
+ const byAlias = [...(index.alias.get(entityKey(type, normalized)) ?? [])].sort();
73
72
  if (byAlias.length === 1)
74
73
  return { tier: 2, target: byAlias[0] };
75
74
  if (byAlias.length > 1)
76
75
  return { tier: "ambiguous_alias", paths: byAlias };
77
- const squashed = [...(index.squashed.get(key(type, squash(normalized))) ?? [])].sort();
76
+ const squashed = [...(index.squashed.get(entityKey(type, squash(normalized))) ?? [])].sort();
78
77
  if (squashed.length > 0)
79
78
  return { tier: "suspect_duplicate", paths: squashed };
80
79
  const otherTypes = [...(index.types.get(normalized) ?? [])].filter((other) => other !== type).sort();
@@ -95,14 +94,14 @@ export function overlappingEntities(index, type, canonical, aliases) {
95
94
  // A generic alias still counts when an existing entity already holds it.
96
95
  if (!normalized)
97
96
  continue;
98
- for (const rel of index.canonical.get(key(type, normalized)) ?? [])
97
+ for (const rel of index.canonical.get(entityKey(type, normalized)) ?? [])
99
98
  paths.add(rel);
100
- for (const rel of index.alias.get(key(type, normalized)) ?? [])
99
+ for (const rel of index.alias.get(entityKey(type, normalized)) ?? [])
101
100
  paths.add(rel);
102
101
  }
103
102
  if (type === "person") {
104
103
  const words = new Set(normalizeEntityName(canonical, type).split(" "));
105
- const prefix = key(type, "");
104
+ const prefix = entityKey(type, "");
106
105
  for (const [name, rels] of index.canonical) {
107
106
  if (!name.startsWith(prefix))
108
107
  continue;
@@ -114,39 +113,6 @@ export function overlappingEntities(index, type, canonical, aliases) {
114
113
  }
115
114
  return [...paths].sort();
116
115
  }
117
- /**
118
- * Aliases worth adding to the entity at `self`: new to it, and held by no
119
- * other entity of any type, so an alias can never make a later match
120
- * ambiguous. `own` holds the normalised names the entity already answers to.
121
- */
122
- function freshAliases(index, type, self, own, names) {
123
- const seen = new Set(own);
124
- const out = [];
125
- for (const raw of names) {
126
- const alias = canonicalizeName(raw);
127
- if (!alias || alias.length > CAPS.nameLength)
128
- continue;
129
- const normalized = normalizeEntityName(alias, type);
130
- if (!normalized || seen.has(normalized) || isRejectedName(normalized))
131
- continue;
132
- const holders = [
133
- ...(index.canonical.get(key(type, normalized)) ?? []),
134
- ...(index.alias.get(key(type, normalized)) ?? []),
135
- ];
136
- if (holders.some((holder) => holder !== self))
137
- continue;
138
- if ([...(index.types.get(normalized) ?? [])].some((other) => other !== type))
139
- continue;
140
- seen.add(normalized);
141
- out.push(alias);
142
- }
143
- return out;
144
- }
145
- /** The normalised names an indexed entity answers to, plus aliases queued this run. */
146
- function ownNames(index, rel, queued) {
147
- const entity = index.byRel.get(rel);
148
- return new Set([entity.canonical_name, ...entity.aliases, ...queued].map((name) => normalizeEntityName(name, entity.type)));
149
- }
150
116
  function readJson(file) {
151
117
  return JSON.parse(fs.readFileSync(file, "utf8"));
152
118
  }
@@ -211,7 +177,33 @@ export function applyOntologyRun(options) {
211
177
  write_errors: [],
212
178
  duration_ms: 0,
213
179
  };
180
+ const outboxRels = new Set();
181
+ if (plan.multi_writer) {
182
+ report.multi_writer = {
183
+ conflicts: plan.multi_writer.merge_back ?? [],
184
+ outbox_recorded: 0,
185
+ ...(plan.multi_writer.error ? { error: plan.multi_writer.error } : {}),
186
+ };
187
+ }
188
+ // Design 20.4: the outbox entry is written before the entity file, so sync
189
+ // never sees a checkpoint write without one. A failed outbox write stops
190
+ // the entity write and leaves its inputs pending for the next run.
191
+ const recordBeforeWrite = (rel, content) => {
192
+ if (!report.multi_writer)
193
+ return null;
194
+ try {
195
+ if (recordOutbox(options.hqRoot, company, [{ rel, content }], options.now ?? new Date()).reset) {
196
+ report.multi_writer.outbox_reset = true;
197
+ }
198
+ return null;
199
+ }
200
+ catch (error) {
201
+ return `outbox:${error.name}`;
202
+ }
203
+ };
214
204
  const finish = () => {
205
+ if (report.multi_writer)
206
+ report.multi_writer.outbox_recorded = outboxRels.size;
215
207
  report.duration_ms = Date.now() - started;
216
208
  fs.writeFileSync(path.join(options.runDir, REPORT_FILE), `${JSON.stringify(report, null, 2)}\n`);
217
209
  return report;
@@ -377,6 +369,12 @@ export function applyOntologyRun(options) {
377
369
  blocked.add(candidate.input);
378
370
  continue;
379
371
  }
372
+ const createOutboxError = recordBeforeWrite(rel, content);
373
+ if (createOutboxError) {
374
+ report.write_errors.push({ path: rel, reason: createOutboxError });
375
+ blocked.add(candidate.input);
376
+ continue;
377
+ }
380
378
  try {
381
379
  safeWriteFile(options.hqRoot, [entityRoot], target, content);
382
380
  }
@@ -386,9 +384,11 @@ export function applyOntologyRun(options) {
386
384
  continue;
387
385
  }
388
386
  report.bytes_written += bytes;
387
+ if (report.multi_writer)
388
+ outboxRels.add(rel);
389
389
  report.created.push(rel);
390
390
  report.links.push({ input: candidate.input, action: "create", type: candidate.type, canonical_name: canonical, path: rel });
391
- indexEntity(index, { ...fields, rel, slug, last_updated: now });
391
+ indexEntity(index, { ...fields, rel, slug, last_updated: now, first_seen: now });
392
392
  }
393
393
  applySignalRefs(company, plan, inputs, index, update);
394
394
  for (const rel of [...updates.keys()].sort()) {
@@ -423,6 +423,13 @@ export function applyOntologyRun(options) {
423
423
  report.deferred.push({ input: [...pending.inputs][0] ?? "", type: entity.type, name: entity.canonical_name, reason });
424
424
  continue;
425
425
  }
426
+ const patchOutboxError = recordBeforeWrite(rel, patched.content);
427
+ if (patchOutboxError) {
428
+ report.write_errors.push({ path: rel, reason: patchOutboxError });
429
+ for (const id of pending.inputs)
430
+ blocked.add(id);
431
+ continue;
432
+ }
426
433
  try {
427
434
  safeWriteFile(options.hqRoot, [entityRoot], target, patched.content);
428
435
  }
@@ -434,6 +441,8 @@ export function applyOntologyRun(options) {
434
441
  }
435
442
  report.bytes_written += bytes;
436
443
  report.backrefs_added += withRefs.added;
444
+ if (report.multi_writer)
445
+ outboxRels.add(rel);
437
446
  // An entity created earlier in this run is reported once, as created.
438
447
  if (!report.created.includes(rel))
439
448
  report.updated.push(rel);
@@ -443,6 +452,14 @@ export function applyOntologyRun(options) {
443
452
  if (plan.company_brief && report.created.length + report.updated.length > 0) {
444
453
  report.company_brief = writeCompanyBrief(options.hqRoot, company, options.now ?? new Date());
445
454
  }
455
+ if (report.multi_writer) {
456
+ try {
457
+ report.multi_writer.duplicates = scanDuplicates(company);
458
+ }
459
+ catch (error) {
460
+ report.multi_writer.error = `duplicates:${error.name}`;
461
+ }
462
+ }
446
463
  return finish();
447
464
  }
448
465
  let signalRefIndex = null;
@@ -43,6 +43,8 @@ export interface NewEntity {
43
43
  }
44
44
  /** A new entity file in the cloud schema, with a `# Name` heading. */
45
45
  export declare function renderNewEntity(entity: NewEntity): string;
46
+ /** The frontmatter keys a checkpoint may change in an existing file. */
47
+ export declare const OWNED_KEYS: ReadonlySet<string>;
46
48
  export interface EntityPatch {
47
49
  addAliases?: string[];
48
50
  signalCount?: number;
@@ -111,7 +111,8 @@ export function renderNewEntity(entity) {
111
111
  "",
112
112
  ].join("\n");
113
113
  }
114
- const OWNED_KEYS = new Set(["aliases", "signal_count", "last_updated"]);
114
+ /** The frontmatter keys a checkpoint may change in an existing file. */
115
+ export const OWNED_KEYS = new Set(["aliases", "signal_count", "last_updated"]);
115
116
  /**
116
117
  * Replace one top-level key in a raw frontmatter block. The key's value may
117
118
  * span several lines (a block sequence); continuation lines are the ones that
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Several checkpoint writers in one company vault (design section 20).
3
+ *
4
+ * Gated by `checkpoint.ontology.multi-writer`. Three parts:
5
+ *
6
+ * - The outbox records the exact bytes this machine's checkpoint wrote to
7
+ * each entity file. Sync pushes an entity file only when it still matches
8
+ * its outbox entry, so an old pulled copy is never uploaded (C1, C2).
9
+ * - Merge-back re-applies the monotone part of an entity version that sync
10
+ * set aside after losing a push race (C3, C4). A version whose entity was
11
+ * deleted stays aside (C6); one that differs in keys the checkpoint does
12
+ * not own is merged for the owned keys and also stays aside (C8).
13
+ * - The duplicate scan reports entities of one type whose names collide, with
14
+ * a proposed survivor. Nothing is deleted (C5, C7).
15
+ */
16
+ import { type OntologyCompany } from "./company.js";
17
+ import { type SignalBackRef } from "./entity-file.js";
18
+ import { type EntityIndex } from "./tree.js";
19
+ export declare const OUTBOX_SCHEMA = "hq-ontology-outbox.v1";
20
+ export declare const OUTBOX_DIR = ".hq/ontology-outbox";
21
+ export declare const OUTBOX_RETENTION_MS: number;
22
+ /** Where sync parks a losing local body (hq-cloud `CONFLICT_BACKUPS_DIR`). */
23
+ export declare const CONFLICT_BACKUPS_DIR = ".hq/conflict-backups";
24
+ export declare const MERGED_BACKUPS_DIR = ".hq/conflict-backups/ontology-merged";
25
+ export declare const STALE_BACKUP_MS: number;
26
+ export declare const DUPLICATE_REPORT_LIMIT = 20;
27
+ export interface OutboxEntry {
28
+ sha256: string;
29
+ written_at: string;
30
+ }
31
+ export interface Outbox {
32
+ schema: typeof OUTBOX_SCHEMA;
33
+ company: string;
34
+ entries: Record<string, OutboxEntry>;
35
+ }
36
+ export interface OutboxWrite {
37
+ /** Company-relative vault key, `ontology/entities/<type>/<file>.md`. */
38
+ rel: string;
39
+ content: string;
40
+ }
41
+ export declare function outboxPath(hqRoot: string, company: OntologyCompany): string;
42
+ /** The company's outbox. A missing file is an empty outbox; an unreadable one throws. */
43
+ export declare function readOutbox(hqRoot: string, company: OntologyCompany): Outbox;
44
+ /**
45
+ * Record entity bytes this checkpoint wrote, and drop entries older than the
46
+ * retention window. An unreadable outbox is replaced: a lost entry only makes
47
+ * sync hold that file back, which is the safe direction.
48
+ */
49
+ export declare function recordOutbox(hqRoot: string, company: OntologyCompany, writes: OutboxWrite[], now: Date): {
50
+ reset: boolean;
51
+ };
52
+ export type MergeBackResult = "merged" | "kept_both" | "stale" | "deleted" | "identity_conflict" | "invalid" | "write_error";
53
+ export interface MergeBackOutcome {
54
+ /** HQ-relative path of the set-aside version. */
55
+ backup: string;
56
+ /** Company-relative entity file it belongs to. */
57
+ target: string;
58
+ result: MergeBackResult;
59
+ /** For `kept_both`, the keys that differ (`body` for prose); for `invalid` and `write_error`, the reason. */
60
+ detail?: string;
61
+ }
62
+ /** Back-reference lines in a body, as refs `appendBackRefs` can re-add. */
63
+ export declare function readBackRefs(body: string): SignalBackRef[];
64
+ export interface MergeBackRun {
65
+ outcomes: MergeBackOutcome[];
66
+ /** Entity files rewritten; each was recorded in the outbox before it was written. */
67
+ writes: OutboxWrite[];
68
+ outbox_reset?: true;
69
+ }
70
+ /**
71
+ * Re-apply set-aside entity versions of this company to the current files
72
+ * (design 20.4). Never deletes an entity file and never re-creates one.
73
+ */
74
+ export declare function mergeBackConflicts(hqRoot: string, company: OntologyCompany, now: Date): MergeBackRun;
75
+ export interface DuplicateGroup {
76
+ type: string;
77
+ /** `same_name`: equal canonical names, or one's canonical name is the other's unshared alias. `similar_name`: canonical names match once spaces are removed. */
78
+ reason: "same_name" | "similar_name";
79
+ paths: string[];
80
+ /** Highest `signal_count`, then earliest `first_seen`, then path. */
81
+ survivor: string;
82
+ }
83
+ export interface DuplicateReport {
84
+ groups: number;
85
+ shown: DuplicateGroup[];
86
+ }
87
+ /** Groups of writable entities of one type whose names collide. Read-only. */
88
+ export declare function findDuplicateGroups(index: EntityIndex): DuplicateGroup[];
89
+ /** The duplicate scan for a company, capped for the apply report. */
90
+ export declare function scanDuplicates(company: OntologyCompany, limit?: number): DuplicateReport;
91
+ export interface MultiWriterStart {
92
+ merge_back: MergeBackOutcome[];
93
+ /** Why merge-back or the outbox write stopped, when it did. */
94
+ error?: string;
95
+ outbox_reset?: true;
96
+ }
97
+ /**
98
+ * The start of a checkpoint run: merge back set-aside versions (each merged
99
+ * file is recorded in the outbox before it is written). Runs in `plan`, so a
100
+ * run with no changed inputs still merges. Errors are returned, not thrown,
101
+ * so the rest of the checkpoint goes on.
102
+ */
103
+ export declare function startMultiWriterRun(hqRoot: string, company: OntologyCompany, now: Date): MultiWriterStart;
104
+ /** Count merge-back outcomes by result, for one-line summaries. */
105
+ export declare function summarizeMergeBack(outcomes: MergeBackOutcome[]): string;
106
+ //# sourceMappingURL=multi-writer.d.ts.map
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Several checkpoint writers in one company vault (design section 20).
3
+ *
4
+ * Gated by `checkpoint.ontology.multi-writer`. Three parts:
5
+ *
6
+ * - The outbox records the exact bytes this machine's checkpoint wrote to
7
+ * each entity file. Sync pushes an entity file only when it still matches
8
+ * its outbox entry, so an old pulled copy is never uploaded (C1, C2).
9
+ * - Merge-back re-applies the monotone part of an entity version that sync
10
+ * set aside after losing a push race (C3, C4). A version whose entity was
11
+ * deleted stays aside (C6); one that differs in keys the checkpoint does
12
+ * not own is merged for the owned keys and also stays aside (C8).
13
+ * - The duplicate scan reports entities of one type whose names collide, with
14
+ * a proposed survivor. Nothing is deleted (C5, C7).
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { isDeepStrictEqual } from "node:util";
20
+ import { entityKey, freshAliases, ownNames } from "./aliases.js";
21
+ import { entitiesRoot, safeWriteFile } from "./company.js";
22
+ import { appendBackRefs, OWNED_KEYS, parseEntityFile, patchEntityFile, validateEntityFrontmatter, } from "./entity-file.js";
23
+ import { isWritableEntityType, squash } from "./identity.js";
24
+ import { buildEntityIndex, indexAlias } from "./tree.js";
25
+ export const OUTBOX_SCHEMA = "hq-ontology-outbox.v1";
26
+ export const OUTBOX_DIR = ".hq/ontology-outbox";
27
+ export const OUTBOX_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
28
+ /** Where sync parks a losing local body (hq-cloud `CONFLICT_BACKUPS_DIR`). */
29
+ export const CONFLICT_BACKUPS_DIR = ".hq/conflict-backups";
30
+ export const MERGED_BACKUPS_DIR = ".hq/conflict-backups/ontology-merged";
31
+ export const STALE_BACKUP_MS = 7 * 24 * 60 * 60 * 1000;
32
+ export const DUPLICATE_REPORT_LIMIT = 20;
33
+ /**
34
+ * Keys that differ between two versions of a new entity for reasons that say
35
+ * nothing about its content. The vault's value stands and the difference is
36
+ * not reported.
37
+ */
38
+ const IGNORED_ON_MERGE = new Set(["first_seen"]);
39
+ /** hq-cloud `buildConflictBackupPath`: `<file>.md.<YYYY-MM-DDTHH-MM-SSZ>-<machine>.md`. */
40
+ const BACKUP_NAME_RE = /^(.+\.md)\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)-([a-f0-9]+|unknown)\.md$/;
41
+ const TYPE_DIR_RE = /^[a-z][a-z0-9-]*$/;
42
+ /** One back-reference line, as `appendBackRefs` and the gardener write it. */
43
+ const BACKREF_LINE_RE = /^- Referenced in \[([A-Za-z_]+): (.*)\]\(([^)\s]+)\)\s*$/;
44
+ function sha256(content) {
45
+ return createHash("sha256").update(content, "utf8").digest("hex");
46
+ }
47
+ export function outboxPath(hqRoot, company) {
48
+ return path.join(hqRoot, ...OUTBOX_DIR.split("/"), `${company.slug}.json`);
49
+ }
50
+ /** The company's outbox. A missing file is an empty outbox; an unreadable one throws. */
51
+ export function readOutbox(hqRoot, company) {
52
+ const empty = { schema: OUTBOX_SCHEMA, company: company.slug, entries: {} };
53
+ let raw;
54
+ try {
55
+ raw = fs.readFileSync(outboxPath(hqRoot, company), "utf8");
56
+ }
57
+ catch (error) {
58
+ if (error.code === "ENOENT")
59
+ return empty;
60
+ throw error;
61
+ }
62
+ const doc = JSON.parse(raw);
63
+ if (doc.schema !== OUTBOX_SCHEMA || doc.company !== company.slug || !doc.entries || typeof doc.entries !== "object") {
64
+ throw new Error("outbox has an unexpected shape");
65
+ }
66
+ return { schema: OUTBOX_SCHEMA, company: company.slug, entries: doc.entries };
67
+ }
68
+ /**
69
+ * Record entity bytes this checkpoint wrote, and drop entries older than the
70
+ * retention window. An unreadable outbox is replaced: a lost entry only makes
71
+ * sync hold that file back, which is the safe direction.
72
+ */
73
+ export function recordOutbox(hqRoot, company, writes, now) {
74
+ let outbox;
75
+ let reset = false;
76
+ try {
77
+ outbox = readOutbox(hqRoot, company);
78
+ }
79
+ catch {
80
+ outbox = { schema: OUTBOX_SCHEMA, company: company.slug, entries: {} };
81
+ reset = true;
82
+ }
83
+ const cutoff = now.getTime() - OUTBOX_RETENTION_MS;
84
+ const entries = {};
85
+ for (const [rel, entry] of Object.entries(outbox.entries)) {
86
+ const at = Date.parse(entry?.written_at ?? "");
87
+ if (typeof entry?.sha256 === "string" && Number.isFinite(at) && at >= cutoff)
88
+ entries[rel] = entry;
89
+ }
90
+ for (const write of writes)
91
+ entries[write.rel] = { sha256: sha256(write.content), written_at: now.toISOString() };
92
+ const sorted = Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
93
+ const doc = { schema: OUTBOX_SCHEMA, company: company.slug, entries: sorted };
94
+ safeWriteFile(hqRoot, [path.join(hqRoot, ...OUTBOX_DIR.split("/"))], outboxPath(hqRoot, company), `${JSON.stringify(doc, null, 2)}\n`);
95
+ return { reset };
96
+ }
97
+ /** A directory that exists and is not a symlink. */
98
+ function isRealDir(dir) {
99
+ try {
100
+ return fs.lstatSync(dir).isDirectory();
101
+ }
102
+ catch (error) {
103
+ if (error.code === "ENOENT")
104
+ return false;
105
+ throw error;
106
+ }
107
+ }
108
+ /** Set-aside entity versions of this company, oldest first per entity. */
109
+ function listEntityBackups(hqRoot, company) {
110
+ const segments = [...CONFLICT_BACKUPS_DIR.split("/"), "companies", company.slug, "ontology", "entities"];
111
+ for (let i = 1; i <= segments.length; i += 1) {
112
+ if (!isRealDir(path.join(hqRoot, ...segments.slice(0, i))))
113
+ return [];
114
+ }
115
+ const root = path.join(hqRoot, ...segments);
116
+ const out = [];
117
+ for (const typeDir of fs.readdirSync(root, { withFileTypes: true })) {
118
+ // Other entity types (contacts, deals) belong to other writers.
119
+ if (!typeDir.isDirectory() || !TYPE_DIR_RE.test(typeDir.name) || !isWritableEntityType(typeDir.name))
120
+ continue;
121
+ for (const file of fs.readdirSync(path.join(root, typeDir.name), { withFileTypes: true })) {
122
+ const match = file.isFile() ? BACKUP_NAME_RE.exec(file.name) : null;
123
+ if (!match)
124
+ continue;
125
+ out.push({
126
+ full: path.join(root, typeDir.name, file.name),
127
+ hqRel: [...segments, typeDir.name, file.name].join("/"),
128
+ target: `ontology/entities/${typeDir.name}/${match[1]}`,
129
+ stampedAt: match[2],
130
+ });
131
+ }
132
+ }
133
+ return out.sort((a, b) => a.target.localeCompare(b.target) || a.stampedAt.localeCompare(b.stampedAt) || a.hqRel.localeCompare(b.hqRel));
134
+ }
135
+ /** The later of two ISO timestamps; an unparseable one loses. */
136
+ function later(a, b) {
137
+ const ta = Date.parse(a);
138
+ const tb = Date.parse(b);
139
+ if (!Number.isFinite(ta))
140
+ return b;
141
+ if (!Number.isFinite(tb))
142
+ return a;
143
+ return tb > ta ? b : a;
144
+ }
145
+ /** Back-reference lines in a body, as refs `appendBackRefs` can re-add. */
146
+ export function readBackRefs(body) {
147
+ const out = [];
148
+ for (const line of body.split(/\r?\n/)) {
149
+ const match = BACKREF_LINE_RE.exec(line);
150
+ if (match)
151
+ out.push({ signalType: match[1], preview: match[2], href: match[3] });
152
+ }
153
+ return out;
154
+ }
155
+ /** The body with back-reference lines and blank lines removed, for comparing prose. */
156
+ function proseOf(body) {
157
+ return body
158
+ .split(/\r?\n/)
159
+ .filter((line) => !BACKREF_LINE_RE.test(line) && !/^##\s+Relationships\s*$/.test(line))
160
+ .map((line) => line.trimEnd())
161
+ .filter((line) => line !== "")
162
+ .join("\n");
163
+ }
164
+ /** Keys the checkpoint does not own whose values differ, plus `body` for prose. */
165
+ function unownedDifferences(backup, current) {
166
+ const keys = [...new Set([...Object.keys(backup.frontmatter), ...Object.keys(current.frontmatter)])].sort();
167
+ const out = keys.filter((key) => !OWNED_KEYS.has(key) && !IGNORED_ON_MERGE.has(key) && !isDeepStrictEqual(backup.frontmatter[key], current.frontmatter[key]));
168
+ if (proseOf(backup.body) !== proseOf(current.body))
169
+ out.push("body");
170
+ return out;
171
+ }
172
+ /**
173
+ * Re-apply set-aside entity versions of this company to the current files
174
+ * (design 20.4). Never deletes an entity file and never re-creates one.
175
+ */
176
+ export function mergeBackConflicts(hqRoot, company, now) {
177
+ const run = { outcomes: [], writes: [] };
178
+ const backups = listEntityBackups(hqRoot, company);
179
+ if (backups.length === 0)
180
+ return run;
181
+ const index = buildEntityIndex(company.dir);
182
+ const entityRoot = entitiesRoot(company);
183
+ for (const backup of backups) {
184
+ const outcome = (result, detail) => run.outcomes.push({ backup: backup.hqRel, target: backup.target, result, ...(detail ? { detail } : {}) });
185
+ const saved = parseEntityFile(fs.readFileSync(backup.full, "utf8"));
186
+ const savedFields = saved ? validateEntityFrontmatter(saved.frontmatter) : "unparseable";
187
+ if (!saved || typeof savedFields === "string") {
188
+ outcome("invalid", `backup:${savedFields}`);
189
+ continue;
190
+ }
191
+ const savedUpdated = String(saved.frontmatter.last_updated);
192
+ const savedAt = Date.parse(savedUpdated);
193
+ if (!Number.isFinite(savedAt)) {
194
+ outcome("invalid", "backup:last_updated");
195
+ continue;
196
+ }
197
+ if (now.getTime() - savedAt > STALE_BACKUP_MS) {
198
+ outcome("stale");
199
+ continue;
200
+ }
201
+ const targetFull = path.join(company.dir, backup.target);
202
+ let targetStat;
203
+ try {
204
+ targetStat = fs.lstatSync(targetFull);
205
+ }
206
+ catch (error) {
207
+ if (error.code !== "ENOENT")
208
+ throw error;
209
+ outcome("deleted");
210
+ continue;
211
+ }
212
+ if (!targetStat.isFile()) {
213
+ outcome("invalid", "current:not_a_file");
214
+ continue;
215
+ }
216
+ const currentContent = fs.readFileSync(targetFull, "utf8");
217
+ const current = parseEntityFile(currentContent);
218
+ const currentFields = current ? validateEntityFrontmatter(current.frontmatter) : "unparseable";
219
+ if (!current || typeof currentFields === "string") {
220
+ outcome("invalid", `current:${currentFields}`);
221
+ continue;
222
+ }
223
+ if (savedFields.id !== currentFields.id || savedFields.type !== currentFields.type) {
224
+ outcome("identity_conflict");
225
+ continue;
226
+ }
227
+ if (!isWritableEntityType(currentFields.type)) {
228
+ outcome("invalid", "current:type_not_checkpoint_owned");
229
+ continue;
230
+ }
231
+ const differences = unownedDifferences(saved, current);
232
+ const type = currentFields.type;
233
+ const aliases = index.byRel.has(backup.target)
234
+ ? freshAliases(index, type, backup.target, ownNames(index, backup.target, []), savedFields.aliases)
235
+ : [];
236
+ const withRefs = appendBackRefs(currentContent, readBackRefs(saved.body));
237
+ const currentUpdated = String(current.frontmatter.last_updated);
238
+ const lastUpdated = later(currentUpdated, savedUpdated);
239
+ const patched = patchEntityFile(withRefs.content, {
240
+ addAliases: aliases,
241
+ signalCount: savedFields.signal_count,
242
+ lastUpdated,
243
+ // A later timestamp alone is a change too; otherwise the merge result
244
+ // would depend on which version this machine holds.
245
+ bodyChanged: withRefs.added > 0 || lastUpdated !== currentUpdated,
246
+ });
247
+ if (!patched.ok) {
248
+ outcome("write_error", patched.reason);
249
+ continue;
250
+ }
251
+ if (patched.changed) {
252
+ // Outbox first: a file sync may push must never lack its entry, and a
253
+ // failed outbox write leaves the file and the backup for the next run.
254
+ try {
255
+ if (recordOutbox(hqRoot, company, [{ rel: backup.target, content: patched.content }], now).reset)
256
+ run.outbox_reset = true;
257
+ }
258
+ catch (error) {
259
+ outcome("write_error", `outbox:${error.name}`);
260
+ continue;
261
+ }
262
+ try {
263
+ safeWriteFile(hqRoot, [entityRoot], targetFull, patched.content);
264
+ }
265
+ catch (error) {
266
+ outcome("write_error", error.name);
267
+ continue;
268
+ }
269
+ run.writes = run.writes.filter((write) => write.rel !== backup.target);
270
+ run.writes.push({ rel: backup.target, content: patched.content });
271
+ for (const alias of aliases)
272
+ indexAlias(index, type, backup.target, alias);
273
+ const entity = index.byRel.get(backup.target);
274
+ if (entity)
275
+ entity.aliases = [...entity.aliases, ...aliases];
276
+ }
277
+ if (differences.length > 0) {
278
+ outcome("kept_both", differences.join(","));
279
+ continue;
280
+ }
281
+ const mergedFull = path.join(hqRoot, ...MERGED_BACKUPS_DIR.split("/"), ...backup.hqRel.split("/").slice(CONFLICT_BACKUPS_DIR.split("/").length));
282
+ fs.mkdirSync(path.dirname(mergedFull), { recursive: true });
283
+ fs.renameSync(backup.full, mergedFull);
284
+ outcome("merged");
285
+ }
286
+ return run;
287
+ }
288
+ /** Groups of writable entities of one type whose names collide. Read-only. */
289
+ export function findDuplicateGroups(index) {
290
+ const parent = new Map();
291
+ const find = (rel) => {
292
+ let root = rel;
293
+ while (parent.get(root) !== root)
294
+ root = parent.get(root);
295
+ parent.set(rel, root);
296
+ return root;
297
+ };
298
+ const union = (rels) => {
299
+ const list = [...rels].filter((rel) => isWritableEntityType(index.byRel.get(rel)?.type));
300
+ for (const rel of list)
301
+ if (!parent.has(rel))
302
+ parent.set(rel, rel);
303
+ for (const rel of list.slice(1)) {
304
+ const a = find(list[0]);
305
+ const b = find(rel);
306
+ if (a !== b)
307
+ parent.set(b, a);
308
+ }
309
+ };
310
+ // Only the matches `resolveCandidate` treats as one entity link files: equal
311
+ // canonical names, or a canonical name that is exactly one other file's
312
+ // alias. An alias several files share (a Slack id, "hq") is ambiguous, not
313
+ // a duplicate, and following it chains unrelated entities together.
314
+ const exactPairs = new Set();
315
+ const sameName = (rels) => {
316
+ if (rels.length < 2)
317
+ return;
318
+ union(rels);
319
+ for (const rel of rels)
320
+ exactPairs.add(rel);
321
+ };
322
+ const squashed = new Map();
323
+ for (const [name, rels] of index.canonical) {
324
+ const holders = index.alias.get(name);
325
+ const aliasHolder = holders && holders.size === 1 ? [...holders] : [];
326
+ sameName([...new Set([...rels, ...aliasHolder])]);
327
+ const [type, normalized] = name.split("\0");
328
+ const squashKey = entityKey(type, squash(normalized));
329
+ squashed.set(squashKey, [...(squashed.get(squashKey) ?? []), ...rels]);
330
+ }
331
+ // Canonical names that match once spaces and punctuation are removed.
332
+ for (const rels of squashed.values())
333
+ if (rels.length > 1)
334
+ union(rels);
335
+ const groups = new Map();
336
+ for (const rel of parent.keys()) {
337
+ const root = find(rel);
338
+ groups.set(root, [...(groups.get(root) ?? []), rel]);
339
+ }
340
+ const out = [];
341
+ for (const members of groups.values()) {
342
+ if (members.length < 2)
343
+ continue;
344
+ const paths = members.sort();
345
+ const ranked = [...paths].sort((a, b) => {
346
+ const ea = index.byRel.get(a);
347
+ const eb = index.byRel.get(b);
348
+ const fa = Date.parse(ea.first_seen ?? "");
349
+ const fb = Date.parse(eb.first_seen ?? "");
350
+ return (eb.signal_count - ea.signal_count ||
351
+ (Number.isFinite(fa) ? fa : Infinity) - (Number.isFinite(fb) ? fb : Infinity) ||
352
+ a.localeCompare(b));
353
+ });
354
+ out.push({
355
+ type: index.byRel.get(paths[0]).type,
356
+ reason: paths.filter((rel) => exactPairs.has(rel)).length >= 2 ? "same_name" : "similar_name",
357
+ paths,
358
+ survivor: ranked[0],
359
+ });
360
+ }
361
+ return out.sort((a, b) => b.paths.length - a.paths.length || a.paths[0].localeCompare(b.paths[0]));
362
+ }
363
+ /** The duplicate scan for a company, capped for the apply report. */
364
+ export function scanDuplicates(company, limit = DUPLICATE_REPORT_LIMIT) {
365
+ const groups = findDuplicateGroups(buildEntityIndex(company.dir));
366
+ return { groups: groups.length, shown: groups.slice(0, limit) };
367
+ }
368
+ /**
369
+ * The start of a checkpoint run: merge back set-aside versions (each merged
370
+ * file is recorded in the outbox before it is written). Runs in `plan`, so a
371
+ * run with no changed inputs still merges. Errors are returned, not thrown,
372
+ * so the rest of the checkpoint goes on.
373
+ */
374
+ export function startMultiWriterRun(hqRoot, company, now) {
375
+ let merged;
376
+ try {
377
+ merged = mergeBackConflicts(hqRoot, company, now);
378
+ }
379
+ catch (error) {
380
+ return { merge_back: [], error: `merge_back:${error.name}` };
381
+ }
382
+ return { merge_back: merged.outcomes, ...(merged.outbox_reset ? { outbox_reset: true } : {}) };
383
+ }
384
+ /** Count merge-back outcomes by result, for one-line summaries. */
385
+ export function summarizeMergeBack(outcomes) {
386
+ const counts = new Map();
387
+ for (const outcome of outcomes)
388
+ counts.set(outcome.result, (counts.get(outcome.result) ?? 0) + 1);
389
+ return [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([result, n]) => `${n} ${result.replaceAll("_", " ")}`).join(", ");
390
+ }
391
+ //# sourceMappingURL=multi-writer.js.map
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { type CompanyBriefResult } from "./company-brief.js";
11
11
  import { type OntologyCompany } from "./company.js";
12
+ import { type MultiWriterStart } from "./multi-writer.js";
12
13
  export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
13
14
  export declare const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
14
15
  export declare const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
@@ -25,6 +26,12 @@ export declare const IGNORE_ANNOTATIONS_FLAG = "checkpoint.ontology.ignore-annot
25
26
  * state directory, so the foreground checkpoint makes no network call.
26
27
  */
27
28
  export declare const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
29
+ /**
30
+ * Several writers in one company vault (design section 20): record written
31
+ * entity bytes in the outbox, merge back set-aside versions, and report
32
+ * duplicate entities.
33
+ */
34
+ export declare const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
28
35
  export declare const PLAN_FILE = "ontology-plan.json";
29
36
  export declare const CANDIDATES_FILE = "ontology-candidates.json";
30
37
  export declare const REPORT_FILE = "ontology-report.json";
@@ -88,6 +95,8 @@ export interface OntologyPlan {
88
95
  status: CompanyBriefResult["status"];
89
96
  reason?: string;
90
97
  };
98
+ /** Present only when `checkpoint.ontology.multi-writer` was on for this plan. */
99
+ multi_writer?: MultiWriterStart;
91
100
  inputs: PlanInput[];
92
101
  /** The session's summary, decisions and learnings, for the `session` input. */
93
102
  session_text: string | null;
@@ -123,6 +132,7 @@ export type PlanOutcome = {
123
132
  status: "skipped";
124
133
  reason: string;
125
134
  companyBrief?: CompanyBriefResult;
135
+ multiWriter?: MultiWriterStart;
126
136
  } | {
127
137
  status: "planned";
128
138
  plan: OntologyPlan;
@@ -12,6 +12,7 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { writeCompanyBrief } from "./company-brief.js";
14
14
  import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
15
+ import { startMultiWriterRun } from "./multi-writer.js";
15
16
  import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
16
17
  export const ONTOLOGY_FLAG = "checkpoint.ontology";
17
18
  export const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
@@ -29,6 +30,12 @@ export const IGNORE_ANNOTATIONS_FLAG = "checkpoint.ontology.ignore-annotations";
29
30
  * state directory, so the foreground checkpoint makes no network call.
30
31
  */
31
32
  export const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
33
+ /**
34
+ * Several writers in one company vault (design section 20): record written
35
+ * entity bytes in the outbox, merge back set-aside versions, and report
36
+ * duplicate entities.
37
+ */
38
+ export const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
32
39
  export const PLAN_FILE = "ontology-plan.json";
33
40
  export const CANDIDATES_FILE = "ontology-candidates.json";
34
41
  export const REPORT_FILE = "ontology-report.json";
@@ -212,14 +219,19 @@ export async function planOntologyRun(options) {
212
219
  return { status: "skipped", reason: "flag off" };
213
220
  }
214
221
  const sharedInputs = await options.readFlag(SHARED_INPUTS_FLAG, company);
215
- // Before the ledger, so a nothing-changed plan still records the brief.
216
- const companyBrief = (await options.readFlag(COMPANY_BRIEF_FLAG, company))
217
- ? writeCompanyBrief(hqRoot, company, options.now ?? new Date())
218
- : undefined;
222
+ const briefOn = await options.readFlag(COMPANY_BRIEF_FLAG, company);
219
223
  const ignoreAnnotations = (await options.readFlag(IGNORE_ANNOTATIONS_FLAG, company)) && payload.backend === "grok";
220
224
  recordBackendPreference(hqRoot, company, await options.readFlag(GROK_BACKEND_FLAG, company));
225
+ const multiWriterOn = await options.readFlag(MULTI_WRITER_FLAG, company);
221
226
  const touched = sessionTouched(hqRoot, company, payload.files);
227
+ // A first run takes its boundary from the entity tree, so the ledger is
228
+ // refreshed before merge-back moves any `last_updated` forward.
222
229
  const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched, sharedInputs);
230
+ // Merge-back and the brief come before the nothing-changed return, so such
231
+ // a plan still merges set-aside versions and records the brief; the brief
232
+ // follows merge-back so its counts include merged entities.
233
+ const multiWriter = multiWriterOn ? startMultiWriterRun(hqRoot, company, options.now ?? new Date()) : undefined;
234
+ const companyBrief = briefOn ? writeCompanyBrief(hqRoot, company, options.now ?? new Date()) : undefined;
223
235
  const pending = Object.entries(ledger.entries)
224
236
  .filter(([, entry]) => entry.state === "pending" && (sharedInputs || entry.reason === "session-touched"))
225
237
  .sort(([aRel, a], [bRel, b]) => reasonRank(a.reason) - reasonRank(b.reason) || a.mtime_ms - b.mtime_ms || aRel.localeCompare(bRel));
@@ -268,7 +280,12 @@ export async function planOntologyRun(options) {
268
280
  writeLedger(hqRoot, company, ledger);
269
281
  if (inputs.length === 0) {
270
282
  const reason = pending.length > 0 ? "over budget" : "nothing changed";
271
- return companyBrief ? { status: "skipped", reason, companyBrief } : { status: "skipped", reason };
283
+ return {
284
+ status: "skipped",
285
+ reason,
286
+ ...(companyBrief ? { companyBrief } : {}),
287
+ ...(multiWriter ? { multiWriter } : {}),
288
+ };
272
289
  }
273
290
  // Session content is scoped to the session's user (design section 6, D2).
274
291
  // v1 records the existing entities it names as links in the report and
@@ -304,6 +321,7 @@ export async function planOntologyRun(options) {
304
321
  : { status: companyBrief.status },
305
322
  }
306
323
  : {}),
324
+ ...(multiWriter ? { multi_writer: multiWriter } : {}),
307
325
  inputs,
308
326
  session_text: sessionText,
309
327
  pending_after_run: remaining,
@@ -9,6 +9,8 @@ export interface IndexedEntity extends EntityFields {
9
9
  rel: string;
10
10
  slug: string;
11
11
  last_updated: string | null;
12
+ /** Absent for an entity created in this run before it was re-read. */
13
+ first_seen?: string | null;
12
14
  }
13
15
  export interface EntityIndex {
14
16
  byRel: Map<string, IndexedEntity>;
@@ -78,12 +78,13 @@ export function buildEntityIndex(companyDir) {
78
78
  index.invalid.add(rel);
79
79
  continue;
80
80
  }
81
- const lastUpdated = parsed.frontmatter.last_updated;
81
+ const { last_updated: lastUpdated, first_seen: firstSeen } = parsed.frontmatter;
82
82
  indexEntity(index, {
83
83
  ...fields,
84
84
  rel,
85
85
  slug: file.name.slice(0, -3),
86
86
  last_updated: typeof lastUpdated === "string" ? lastUpdated : null,
87
+ first_seen: typeof firstSeen === "string" ? firstSeen : null,
87
88
  });
88
89
  }
89
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.221.0",
3
+ "version": "5.222.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {