@indigoai-us/hq-cli 5.220.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/command-catalog.generated.d.ts +55 -0
  3. package/dist/command-catalog.generated.js +71 -0
  4. package/dist/commands/core-ontology.d.ts +6 -0
  5. package/dist/commands/core-ontology.js +61 -5
  6. package/dist/commands/lanes-checks.d.ts +36 -2
  7. package/dist/commands/lanes-checks.js +67 -9
  8. package/dist/commands/lanes-report.d.ts +11 -0
  9. package/dist/commands/lanes-report.js +330 -0
  10. package/dist/commands/lanes.d.ts +2 -1
  11. package/dist/commands/lanes.js +24 -6
  12. package/dist/lib/checkpoint-ontology/aliases.d.ts +17 -0
  13. package/dist/lib/checkpoint-ontology/aliases.js +44 -0
  14. package/dist/lib/checkpoint-ontology/apply.d.ts +10 -0
  15. package/dist/lib/checkpoint-ontology/apply.js +61 -44
  16. package/dist/lib/checkpoint-ontology/entity-file.d.ts +2 -0
  17. package/dist/lib/checkpoint-ontology/entity-file.js +2 -1
  18. package/dist/lib/checkpoint-ontology/multi-writer.d.ts +106 -0
  19. package/dist/lib/checkpoint-ontology/multi-writer.js +391 -0
  20. package/dist/lib/checkpoint-ontology/plan.d.ts +10 -0
  21. package/dist/lib/checkpoint-ontology/plan.js +23 -5
  22. package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
  23. package/dist/lib/checkpoint-ontology/tree.js +2 -1
  24. package/dist/lib/lanes/timestamp.d.ts +3 -0
  25. package/dist/lib/lanes/timestamp.js +26 -0
  26. package/dist/lib/lanes/tracker-report-state.d.ts +10 -0
  27. package/dist/lib/lanes/tracker-report-state.js +124 -0
  28. package/dist/lib/lanes/tracker-report.d.ts +203 -0
  29. package/dist/lib/lanes/tracker-report.js +522 -0
  30. package/package.json +1 -1
@@ -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
  }
@@ -0,0 +1,3 @@
1
+ /** Parse an extended ISO timestamp and return JavaScript's canonical UTC form. */
2
+ export declare function normalizeIsoTimestamp(value: unknown): string | null;
3
+ //# sourceMappingURL=timestamp.d.ts.map
@@ -0,0 +1,26 @@
1
+ /** Parse an extended ISO timestamp and return JavaScript's canonical UTC form. */
2
+ export function normalizeIsoTimestamp(value) {
3
+ if (typeof value !== "string")
4
+ return null;
5
+ const match = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.(\d+))?(Z|([+-])(\d\d):(\d\d))$/.exec(value);
6
+ if (!match)
7
+ return null;
8
+ const year = Number(match[1]);
9
+ const month = Number(match[2]);
10
+ const day = Number(match[3]);
11
+ const hour = Number(match[4]);
12
+ const minute = Number(match[5]);
13
+ const second = Number(match[6]);
14
+ const offsetHour = match[9] === undefined ? 0 : Number(match[10]);
15
+ const offsetMinute = match[9] === undefined ? 0 : Number(match[11]);
16
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
17
+ const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
18
+ if (month < 1 || month > 12 || day < 1 || daysInMonth === undefined || day > daysInMonth ||
19
+ hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59)
20
+ return null;
21
+ const parsed = Date.parse(value);
22
+ if (!Number.isFinite(parsed))
23
+ return null;
24
+ return new Date(parsed).toISOString();
25
+ }
26
+ //# sourceMappingURL=timestamp.js.map
@@ -0,0 +1,10 @@
1
+ export declare const FIRST_TRACKER_REPORT_SINCE = "1970-01-01T00:00:00.000Z";
2
+ export interface TrackerReportState {
3
+ schema_version: "hq-lanes-report-state.v1";
4
+ company: string;
5
+ last_report_at: string;
6
+ }
7
+ export declare function trackerReportStatePath(root: string, company: string): string;
8
+ export declare function readTrackerReportState(root: string, company: string): TrackerReportState | null;
9
+ export declare function writeTrackerReportState(root: string, company: string, at: string): TrackerReportState;
10
+ //# sourceMappingURL=tracker-report-state.d.ts.map