@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.
- package/CHANGELOG.md +24 -0
- package/dist/command-catalog.generated.d.ts +18 -0
- package/dist/command-catalog.generated.js +23 -0
- package/dist/commands/core-ontology.d.ts +6 -0
- package/dist/commands/core-ontology.js +61 -5
- package/dist/commands/lanes.js +156 -50
- package/dist/lib/checkpoint-ontology/aliases.d.ts +17 -0
- package/dist/lib/checkpoint-ontology/aliases.js +44 -0
- package/dist/lib/checkpoint-ontology/apply.d.ts +10 -0
- package/dist/lib/checkpoint-ontology/apply.js +61 -44
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +2 -1
- package/dist/lib/checkpoint-ontology/multi-writer.d.ts +106 -0
- package/dist/lib/checkpoint-ontology/multi-writer.js +391 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +10 -0
- package/dist/lib/checkpoint-ontology/plan.js +23 -5
- package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/tree.js +2 -1
- package/dist/lib/lanes/reconcile.js +1 -1
- package/dist/lib/lanes/spawn.js +62 -11
- package/dist/lib/lanes/types.d.ts +3 -3
- package/dist/lib/lanes/watch.d.ts +18 -1
- package/dist/lib/lanes/watch.js +122 -2
- package/package.json +1 -1
|
@@ -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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
97
|
+
for (const rel of index.canonical.get(entityKey(type, normalized)) ?? [])
|
|
99
98
|
paths.add(rel);
|
|
100
|
-
for (const rel of index.alias.get(
|
|
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 =
|
|
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
|
-
|
|
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
|