@indigoai-us/hq-cli 5.221.0 → 5.223.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.
@@ -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