@indigoai-us/hq-cli 5.193.1 → 5.194.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.
@@ -0,0 +1,493 @@
1
+ /**
2
+ * `hq core ontology apply`: turn the model's candidates into entity writes.
3
+ *
4
+ * Every rule that affects identity, scope or cost lives here (design
5
+ * sections 4.3, 5 and 14). The model output is untrusted input: a malformed
6
+ * candidate is rejected and counted, never repaired.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { entitiesRoot, resolveOntologyCompany, safeWriteFile } from "./company.js";
12
+ import { appendBackRefs, parseEntityFile, patchEntityFile, previewSignalContent, renderNewEntity, validateEntityFrontmatter, } from "./entity-file.js";
13
+ import { canonicalizeName, entityId, isRejectedName, isWritableEntityType, normalizeEntityName, squash, toSlug, } from "./identity.js";
14
+ import { CANDIDATES_FILE, CANDIDATES_SCHEMA, PLAN_FILE, PLAN_SCHEMA, REPORT_FILE, readLedger, writeLedger, } from "./plan.js";
15
+ import { buildEntityIndex, indexAlias, indexEntity, listSignalFiles, readSignal } from "./tree.js";
16
+ export const CAPS = {
17
+ creates: 25,
18
+ updates: 100,
19
+ bytes: 200 * 1024,
20
+ newAliasesPerEntity: 3,
21
+ aliasesPerCandidate: 5,
22
+ nameLength: 200,
23
+ };
24
+ export const MIN_CREATE_CONFIDENCE = 0.7;
25
+ export class ApplyError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "ApplyError";
29
+ }
30
+ }
31
+ function sha256(content) {
32
+ return createHash("sha256").update(content).digest("hex");
33
+ }
34
+ /** Validate one raw candidate. Returns the candidate or a rejection reason. */
35
+ export function parseCandidate(raw) {
36
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
37
+ return "not_an_object";
38
+ const c = raw;
39
+ if (typeof c.input !== "string" || !c.input)
40
+ return "input";
41
+ if (!isWritableEntityType(c.type))
42
+ return "type";
43
+ if (typeof c.name !== "string" || !c.name.trim())
44
+ return "name";
45
+ if (c.name.length > CAPS.nameLength)
46
+ return "name_too_long";
47
+ if (typeof c.confidence !== "number" || !Number.isFinite(c.confidence) || c.confidence < 0 || c.confidence > 1) {
48
+ return "confidence";
49
+ }
50
+ const aliases = c.aliases === undefined ? [] : c.aliases;
51
+ if (!Array.isArray(aliases) || !aliases.every((alias) => typeof alias === "string"))
52
+ return "aliases";
53
+ return {
54
+ input: c.input,
55
+ type: c.type,
56
+ name: c.name,
57
+ aliases: aliases.slice(0, CAPS.aliasesPerCandidate),
58
+ confidence: c.confidence,
59
+ evidence: typeof c.evidence === "string" ? c.evidence : "",
60
+ };
61
+ }
62
+ function key(type, normalized) {
63
+ return `${type}\0${normalized}`;
64
+ }
65
+ /** Design section 5.3, tiers 1 to 4. */
66
+ export function resolveCandidate(index, type, canonical) {
67
+ const normalized = normalizeEntityName(canonical, type);
68
+ const byCanonical = [...(index.canonical.get(key(type, normalized)) ?? [])].sort();
69
+ if (byCanonical.length > 0)
70
+ return { tier: 1, target: byCanonical[0], duplicates: byCanonical.slice(1) };
71
+ const byAlias = [...(index.alias.get(key(type, normalized)) ?? [])].sort();
72
+ if (byAlias.length === 1)
73
+ return { tier: 2, target: byAlias[0] };
74
+ if (byAlias.length > 1)
75
+ return { tier: "ambiguous_alias", paths: byAlias };
76
+ const squashed = [...(index.squashed.get(key(type, squash(normalized))) ?? [])].sort();
77
+ if (squashed.length > 0)
78
+ return { tier: "suspect_duplicate", paths: squashed };
79
+ const otherTypes = [...(index.types.get(normalized) ?? [])].filter((other) => other !== type).sort();
80
+ if (otherTypes.length > 0)
81
+ return { tier: "type_conflict", types: otherTypes };
82
+ return { tier: "new" };
83
+ }
84
+ /**
85
+ * Aliases worth adding to the entity at `self`: new to it, and held by no
86
+ * other entity of any type, so an alias can never make a later match
87
+ * ambiguous. `own` holds the normalised names the entity already answers to.
88
+ */
89
+ function freshAliases(index, type, self, own, names) {
90
+ const seen = new Set(own);
91
+ const out = [];
92
+ for (const raw of names) {
93
+ const alias = canonicalizeName(raw);
94
+ if (!alias || alias.length > CAPS.nameLength)
95
+ continue;
96
+ const normalized = normalizeEntityName(alias, type);
97
+ if (!normalized || seen.has(normalized) || isRejectedName(normalized))
98
+ continue;
99
+ const holders = [
100
+ ...(index.canonical.get(key(type, normalized)) ?? []),
101
+ ...(index.alias.get(key(type, normalized)) ?? []),
102
+ ];
103
+ if (holders.some((holder) => holder !== self))
104
+ continue;
105
+ if ([...(index.types.get(normalized) ?? [])].some((other) => other !== type))
106
+ continue;
107
+ seen.add(normalized);
108
+ out.push(alias);
109
+ }
110
+ return out;
111
+ }
112
+ /** The normalised names an indexed entity answers to, plus aliases queued this run. */
113
+ function ownNames(index, rel, queued) {
114
+ const entity = index.byRel.get(rel);
115
+ return new Set([entity.canonical_name, ...entity.aliases, ...queued].map((name) => normalizeEntityName(name, entity.type)));
116
+ }
117
+ function readJson(file) {
118
+ return JSON.parse(fs.readFileSync(file, "utf8"));
119
+ }
120
+ /** Plan inputs that still match the tree. A changed file is skipped this run. */
121
+ function liveInputs(hqRoot, company, plan) {
122
+ const ledger = readLedger(hqRoot, company);
123
+ const out = new Map();
124
+ for (const input of plan.inputs) {
125
+ if (input.kind === "session") {
126
+ out.set(input.id, input);
127
+ continue;
128
+ }
129
+ const rel = input.path ?? "";
130
+ const safe = /^(knowledge\/|signals\/[a-z_]+\/)/.test(rel) && !rel.split("/").includes("..");
131
+ let live = false;
132
+ if (safe) {
133
+ try {
134
+ live = sha256(fs.readFileSync(path.join(company.dir, rel))) === input.sha256;
135
+ }
136
+ catch {
137
+ live = false;
138
+ }
139
+ }
140
+ const entry = ledger?.entries[rel];
141
+ out.set(input.id, live && entry?.state === "pending" && entry.sha256 === input.sha256 ? input : null);
142
+ }
143
+ return out;
144
+ }
145
+ export function applyOntologyRun(options) {
146
+ const started = Date.now();
147
+ const now = (options.now ?? new Date()).toISOString();
148
+ const plan = readJson(path.join(options.runDir, PLAN_FILE));
149
+ if (plan.schema !== PLAN_SCHEMA)
150
+ throw new ApplyError("ontology plan has an unexpected schema");
151
+ if (plan.company !== options.company)
152
+ throw new ApplyError("ontology plan company does not match the payload company");
153
+ const resolution = resolveOntologyCompany(options.hqRoot, plan.company);
154
+ if (!resolution.ok)
155
+ throw new ApplyError(`ontology company rejected: ${resolution.reason}`);
156
+ const company = resolution.company;
157
+ const entityRoot = entitiesRoot(company);
158
+ const report = {
159
+ schema: "hq-checkpoint-ontology-report.v1",
160
+ company: company.slug,
161
+ run_id: plan.run_id,
162
+ status: "applied",
163
+ planned_inputs: plan.inputs.length,
164
+ planned_tokens: plan.budget.planned_tokens,
165
+ candidates: 0,
166
+ created: [],
167
+ updated: [],
168
+ matched: 0,
169
+ session_links: [],
170
+ links: [],
171
+ rejected: [],
172
+ deferred: [],
173
+ suspect_duplicates: [],
174
+ backrefs_added: 0,
175
+ bytes_written: 0,
176
+ inputs_done: [],
177
+ inputs_pending: [],
178
+ write_errors: [],
179
+ duration_ms: 0,
180
+ };
181
+ const finish = () => {
182
+ report.duration_ms = Date.now() - started;
183
+ fs.writeFileSync(path.join(options.runDir, REPORT_FILE), `${JSON.stringify(report, null, 2)}\n`);
184
+ return report;
185
+ };
186
+ const candidatesPath = path.join(options.runDir, CANDIDATES_FILE);
187
+ if (!fs.existsSync(candidatesPath)) {
188
+ report.status = "no_candidates";
189
+ report.inputs_pending = plan.inputs.filter((i) => i.kind !== "session").map((i) => i.id);
190
+ return finish();
191
+ }
192
+ let rawCandidates;
193
+ try {
194
+ const doc = readJson(candidatesPath);
195
+ if (doc.schema !== CANDIDATES_SCHEMA || !Array.isArray(doc.candidates))
196
+ throw new Error("schema");
197
+ rawCandidates = doc.candidates;
198
+ }
199
+ catch {
200
+ report.status = "invalid_candidates";
201
+ report.inputs_pending = plan.inputs.filter((i) => i.kind !== "session").map((i) => i.id);
202
+ return finish();
203
+ }
204
+ report.candidates = rawCandidates.length;
205
+ const index = buildEntityIndex(company.dir);
206
+ const inputs = liveInputs(options.hqRoot, company, plan);
207
+ const blocked = new Set();
208
+ const updates = new Map();
209
+ const update = (rel) => {
210
+ let pending = updates.get(rel);
211
+ if (!pending) {
212
+ pending = { aliases: [], backrefs: [], countSignals: false, inputs: new Set() };
213
+ updates.set(rel, pending);
214
+ }
215
+ return pending;
216
+ };
217
+ const inputOrder = new Map(plan.inputs.map((input, i) => [input.id, i]));
218
+ const ordered = rawCandidates
219
+ .map((raw, i) => ({ raw, i, parsed: parseCandidate(raw) }))
220
+ .sort((a, b) => {
221
+ const ai = typeof a.parsed === "string" ? Infinity : inputOrder.get(a.parsed.input) ?? Infinity;
222
+ const bi = typeof b.parsed === "string" ? Infinity : inputOrder.get(b.parsed.input) ?? Infinity;
223
+ return ai - bi || a.i - b.i;
224
+ });
225
+ for (const { raw, parsed } of ordered) {
226
+ if (typeof parsed === "string") {
227
+ const r = (raw ?? {});
228
+ report.rejected.push({ input: String(r.input ?? ""), type: String(r.type ?? ""), name: String(r.name ?? ""), reason: `invalid_${parsed}` });
229
+ continue;
230
+ }
231
+ const candidate = parsed;
232
+ const note = (list, reason) => list.push({ input: candidate.input, type: candidate.type, name: candidate.name, reason });
233
+ if (!inputs.has(candidate.input)) {
234
+ note(report.rejected, "input_not_in_plan");
235
+ continue;
236
+ }
237
+ const input = inputs.get(candidate.input);
238
+ if (!input) {
239
+ note(report.deferred, "input_changed");
240
+ continue;
241
+ }
242
+ const canonical = canonicalizeName(candidate.name);
243
+ const resolved = resolveCandidate(index, candidate.type, canonical);
244
+ if (input.kind === "session") {
245
+ // D2: session content never creates or aliases a shared entity.
246
+ if (resolved.tier === 1 || resolved.tier === 2) {
247
+ const entity = index.byRel.get(resolved.target);
248
+ if (!report.session_links.some((link) => link.path === resolved.target)) {
249
+ report.session_links.push({ type: entity.type, canonical_name: entity.canonical_name, path: resolved.target });
250
+ }
251
+ }
252
+ else
253
+ note(report.rejected, "session_not_existing");
254
+ continue;
255
+ }
256
+ if (resolved.tier === 1 || resolved.tier === 2) {
257
+ report.matched += 1;
258
+ const linked = index.byRel.get(resolved.target);
259
+ report.links.push({ input: candidate.input, action: "link", type: linked.type, canonical_name: linked.canonical_name, path: resolved.target });
260
+ if (resolved.tier === 1 && resolved.duplicates.length > 0) {
261
+ report.suspect_duplicates.push({ name: canonical, type: candidate.type, paths: [resolved.target, ...resolved.duplicates] });
262
+ continue;
263
+ }
264
+ const pending = updates.get(resolved.target);
265
+ if (!pending && updates.size >= CAPS.updates) {
266
+ note(report.deferred, "cap_updates");
267
+ blocked.add(candidate.input);
268
+ continue;
269
+ }
270
+ const already = pending?.aliases ?? [];
271
+ const room = CAPS.newAliasesPerEntity - already.length;
272
+ const fresh = room > 0
273
+ ? freshAliases(index, candidate.type, resolved.target, ownNames(index, resolved.target, already), [canonical, ...candidate.aliases]).slice(0, room)
274
+ : [];
275
+ if (fresh.length > 0) {
276
+ const target = update(resolved.target);
277
+ target.aliases.push(...fresh);
278
+ target.inputs.add(candidate.input);
279
+ // Reserve them now so a later candidate cannot give the same alias
280
+ // to another entity in this run.
281
+ for (const alias of fresh)
282
+ indexAlias(index, candidate.type, resolved.target, alias);
283
+ }
284
+ continue;
285
+ }
286
+ // A generic word never becomes a new entity, whatever else it collides with.
287
+ if (isRejectedName(normalizeEntityName(canonical, candidate.type))) {
288
+ note(report.rejected, "stopword");
289
+ continue;
290
+ }
291
+ if (resolved.tier === "ambiguous_alias") {
292
+ note(report.deferred, "ambiguous_alias");
293
+ continue;
294
+ }
295
+ if (resolved.tier === "suspect_duplicate") {
296
+ note(report.deferred, "suspect_duplicate");
297
+ report.suspect_duplicates.push({ name: canonical, type: candidate.type, paths: resolved.paths });
298
+ continue;
299
+ }
300
+ if (resolved.tier === "type_conflict") {
301
+ note(report.rejected, "type_conflict");
302
+ continue;
303
+ }
304
+ // Tier 5: create.
305
+ if (candidate.confidence < MIN_CREATE_CONFIDENCE) {
306
+ note(report.rejected, "low_confidence");
307
+ continue;
308
+ }
309
+ const slug = toSlug(canonical);
310
+ if (!slug) {
311
+ note(report.rejected, "empty_slug");
312
+ continue;
313
+ }
314
+ const rel = `ontology/entities/${candidate.type}/${slug}.md`;
315
+ const target = path.join(company.dir, rel);
316
+ if (fs.existsSync(target) || index.byRel.has(rel) || index.invalid.has(rel)) {
317
+ // Same file name, different id (`Acme, Inc.` and `Acme Inc`): the
318
+ // cloud file-writer's overwrite guard keeps the existing file too.
319
+ note(report.rejected, "slug_collision");
320
+ continue;
321
+ }
322
+ if (report.created.length >= CAPS.creates) {
323
+ note(report.deferred, "cap_creates");
324
+ blocked.add(candidate.input);
325
+ continue;
326
+ }
327
+ const aliases = freshAliases(index, candidate.type, rel, new Set([normalizeEntityName(canonical, candidate.type)]), candidate.aliases).slice(0, CAPS.newAliasesPerEntity);
328
+ const content = renderNewEntity({ type: candidate.type, canonicalName: canonical, aliases, confidence: candidate.confidence, now, signalCount: 0 });
329
+ const reparsed = parseEntityFile(content);
330
+ const fields = reparsed ? validateEntityFrontmatter(reparsed.frontmatter) : "unparseable";
331
+ if (typeof fields === "string" || fields.canonical_name !== canonical || fields.id !== entityId(candidate.type, canonical)) {
332
+ note(report.rejected, "roundtrip_failed");
333
+ continue;
334
+ }
335
+ const bytes = Buffer.byteLength(content, "utf8");
336
+ if (report.bytes_written + bytes > CAPS.bytes) {
337
+ note(report.deferred, "cap_bytes");
338
+ blocked.add(candidate.input);
339
+ continue;
340
+ }
341
+ try {
342
+ safeWriteFile(options.hqRoot, [entityRoot], target, content);
343
+ }
344
+ catch (error) {
345
+ report.write_errors.push({ path: rel, reason: error.name });
346
+ blocked.add(candidate.input);
347
+ continue;
348
+ }
349
+ report.bytes_written += bytes;
350
+ report.created.push(rel);
351
+ report.links.push({ input: candidate.input, action: "create", type: candidate.type, canonical_name: canonical, path: rel });
352
+ indexEntity(index, { ...fields, rel, slug, last_updated: now });
353
+ }
354
+ applySignalRefs(company, plan, inputs, index, update);
355
+ for (const rel of [...updates.keys()].sort()) {
356
+ const pending = updates.get(rel);
357
+ const entity = index.byRel.get(rel);
358
+ if (!entity || !isWritableEntityType(entity.type))
359
+ continue;
360
+ const target = path.join(company.dir, rel);
361
+ const original = fs.readFileSync(target, "utf8");
362
+ const withRefs = appendBackRefs(original, pending.backrefs);
363
+ // Design 14.2: max(stored, recount), so the result is the same on every
364
+ // machine and on a rerun, and never lowers a value the gardener wrote.
365
+ const patched = patchEntityFile(withRefs.content, {
366
+ addAliases: pending.aliases,
367
+ signalCount: pending.countSignals ? signalCountFor(company, rel) : undefined,
368
+ lastUpdated: now,
369
+ bodyChanged: withRefs.added > 0,
370
+ });
371
+ if (!patched.ok) {
372
+ report.write_errors.push({ path: rel, reason: patched.reason });
373
+ for (const id of pending.inputs)
374
+ blocked.add(id);
375
+ continue;
376
+ }
377
+ if (!patched.changed)
378
+ continue;
379
+ const bytes = Buffer.byteLength(patched.content, "utf8");
380
+ if (report.bytes_written + bytes > CAPS.bytes || report.updated.length >= CAPS.updates) {
381
+ for (const id of pending.inputs)
382
+ blocked.add(id);
383
+ const reason = report.updated.length >= CAPS.updates ? "cap_updates" : "cap_bytes";
384
+ report.deferred.push({ input: [...pending.inputs][0] ?? "", type: entity.type, name: entity.canonical_name, reason });
385
+ continue;
386
+ }
387
+ try {
388
+ safeWriteFile(options.hqRoot, [entityRoot], target, patched.content);
389
+ }
390
+ catch (error) {
391
+ report.write_errors.push({ path: rel, reason: error.name });
392
+ for (const id of pending.inputs)
393
+ blocked.add(id);
394
+ continue;
395
+ }
396
+ report.bytes_written += bytes;
397
+ report.backrefs_added += withRefs.added;
398
+ // An entity created earlier in this run is reported once, as created.
399
+ if (!report.created.includes(rel))
400
+ report.updated.push(rel);
401
+ }
402
+ commitLedger(options.hqRoot, company, plan, inputs, blocked, report);
403
+ return finish();
404
+ }
405
+ let signalRefIndex = null;
406
+ /** Distinct root signal files whose `entity_refs` name this entity file. */
407
+ function signalCountFor(company, rel) {
408
+ if (!signalRefIndex || signalRefIndex.companyDir !== company.dir) {
409
+ const refs = new Map();
410
+ for (const signalRel of listSignalFiles(company.dir)) {
411
+ const signal = readSignal(company.dir, signalRel);
412
+ for (const ref of signal?.refs ?? []) {
413
+ let set = refs.get(ref);
414
+ if (!set)
415
+ refs.set(ref, (set = new Set()));
416
+ set.add(signalRel);
417
+ }
418
+ }
419
+ signalRefIndex = { companyDir: company.dir, refs };
420
+ }
421
+ const ref = rel.replace(/^ontology\/entities\//, "").replace(/\.md$/, "");
422
+ return signalRefIndex.refs.get(ref)?.size ?? 0;
423
+ }
424
+ /**
425
+ * The deterministic signal path (design 14.2): every company-audience signal
426
+ * in the plan adds one back-reference line to each entity it references, and
427
+ * `signal_count` becomes max(stored, recount). No model output is used.
428
+ */
429
+ function applySignalRefs(company, plan, inputs, index, update) {
430
+ signalRefIndex = null;
431
+ const seen = new Set();
432
+ for (const input of plan.inputs) {
433
+ if (input.kind !== "signal" || !inputs.get(input.id) || !input.path || seen.has(input.path))
434
+ continue;
435
+ seen.add(input.path);
436
+ const signal = readSignal(company.dir, input.path);
437
+ if (!signal)
438
+ continue;
439
+ for (const ref of signal.refs) {
440
+ const rel = `ontology/entities/${ref}.md`;
441
+ const entity = index.byRel.get(rel);
442
+ if (!entity || !isWritableEntityType(entity.type))
443
+ continue;
444
+ const pending = update(rel);
445
+ pending.countSignals = true;
446
+ pending.inputs.add(input.id);
447
+ pending.backrefs.push({
448
+ signalType: signal.type,
449
+ preview: previewSignalContent(signal.canonicalContent),
450
+ // Entity files sit three levels below the company root. The gardener
451
+ // writes `../../`, which appendBackRefs treats as the same target.
452
+ href: `../../../${input.path}`,
453
+ });
454
+ }
455
+ }
456
+ }
457
+ /** Mark finished inputs done. Blocked or changed inputs stay pending. */
458
+ function commitLedger(hqRoot, company, plan, inputs, blocked, report) {
459
+ const ledger = readLedger(hqRoot, company);
460
+ if (!ledger)
461
+ return;
462
+ const fileInputs = plan.inputs
463
+ .filter((input) => input.kind !== "session")
464
+ .sort((x, y) => (x.path ?? "").localeCompare(y.path ?? "") || (x.chunk?.index ?? 0) - (y.chunk?.index ?? 0));
465
+ for (const input of fileInputs) {
466
+ const live = inputs.get(input.id);
467
+ const entry = input.path ? ledger.entries[input.path] : undefined;
468
+ if (!live || blocked.has(input.id) || !entry || entry.sha256 !== input.sha256 || entry.state !== "pending") {
469
+ report.inputs_pending.push(input.id);
470
+ continue;
471
+ }
472
+ if (input.chunk) {
473
+ // Chunks advance only in order, so a blocked chunk is retried first.
474
+ if ((entry.next_chunk ?? 0) !== input.chunk.index) {
475
+ report.inputs_pending.push(input.id);
476
+ continue;
477
+ }
478
+ entry.next_chunk = input.chunk.index + 1;
479
+ report.inputs_done.push(input.id);
480
+ if (entry.next_chunk < input.chunk.count)
481
+ continue;
482
+ delete entry.next_chunk;
483
+ }
484
+ else {
485
+ report.inputs_done.push(input.id);
486
+ }
487
+ entry.state = "done";
488
+ delete entry.reason;
489
+ entry.run = plan.run_id;
490
+ }
491
+ writeLedger(hqRoot, company, ledger);
492
+ }
493
+ //# sourceMappingURL=apply.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Company resolution and write bounds for checkpoint ontology.
3
+ *
4
+ * The company always comes from the checkpoint payload and must be a key of
5
+ * `companies/manifest.yaml` with a directory at `companies/<slug>/`. Every
6
+ * write goes through {@link safeWriteFile}, which refuses any target whose
7
+ * real path leaves the allowed roots.
8
+ */
9
+ export interface OntologyCompany {
10
+ slug: string;
11
+ /** The hq-flags company uid, or null when the company is local-only. */
12
+ cloudUid: string | null;
13
+ /** `<hqRoot>/companies/<slug>` */
14
+ dir: string;
15
+ }
16
+ export type CompanyResolution = {
17
+ ok: true;
18
+ company: OntologyCompany;
19
+ } | {
20
+ ok: false;
21
+ reason: string;
22
+ };
23
+ export declare function resolveOntologyCompany(hqRoot: string, slug: string | null | undefined): CompanyResolution;
24
+ /** The entity root (`companies/<co>/ontology/entities`) of a company. */
25
+ export declare function entitiesRoot(company: OntologyCompany): string;
26
+ /** The machine-local, unsynced state directory for a company. */
27
+ export declare function stateRoot(hqRoot: string, company: OntologyCompany): string;
28
+ export declare class WriteBoundsError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * Write `content` to `target` only when it stays inside one of `roots` with no
33
+ * symlink anywhere between `anchor` (the HQ root) and the file. A root or a
34
+ * directory under it that is a symlink fails closed, and so does a target that
35
+ * is itself a symlink. Missing directories are created only after that check.
36
+ * The file is written to a temporary sibling and renamed, so a reader never
37
+ * sees half a file.
38
+ */
39
+ export declare function safeWriteFile(anchor: string, roots: string[], target: string, content: string): void;
40
+ //# sourceMappingURL=company.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Company resolution and write bounds for checkpoint ontology.
3
+ *
4
+ * The company always comes from the checkpoint payload and must be a key of
5
+ * `companies/manifest.yaml` with a directory at `companies/<slug>/`. Every
6
+ * write goes through {@link safeWriteFile}, which refuses any target whose
7
+ * real path leaves the allowed roots.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import yaml from "js-yaml";
12
+ const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
13
+ export function resolveOntologyCompany(hqRoot, slug) {
14
+ if (!slug)
15
+ return { ok: false, reason: "no company" };
16
+ if (!SLUG_RE.test(slug))
17
+ return { ok: false, reason: "invalid company" };
18
+ let manifest;
19
+ try {
20
+ manifest = yaml.load(fs.readFileSync(path.join(hqRoot, "companies", "manifest.yaml"), "utf8"), {
21
+ schema: yaml.CORE_SCHEMA,
22
+ });
23
+ }
24
+ catch (error) {
25
+ return { ok: false, reason: `manifest unreadable (${error.name})` };
26
+ }
27
+ const companies = manifest?.companies;
28
+ const entry = companies && Object.prototype.hasOwnProperty.call(companies, slug) ? companies[slug] : undefined;
29
+ if (!entry || typeof entry !== "object")
30
+ return { ok: false, reason: "company not in manifest" };
31
+ const dir = path.join(hqRoot, "companies", slug);
32
+ let stat;
33
+ try {
34
+ stat = fs.lstatSync(dir);
35
+ }
36
+ catch {
37
+ return { ok: false, reason: "company directory missing" };
38
+ }
39
+ if (!stat.isDirectory())
40
+ return { ok: false, reason: "company directory missing" };
41
+ const uid = entry.cloud_uid;
42
+ return { ok: true, company: { slug, cloudUid: typeof uid === "string" && uid.trim() ? uid.trim() : null, dir } };
43
+ }
44
+ /** The entity root (`companies/<co>/ontology/entities`) of a company. */
45
+ export function entitiesRoot(company) {
46
+ return path.join(company.dir, "ontology", "entities");
47
+ }
48
+ /** The machine-local, unsynced state directory for a company. */
49
+ export function stateRoot(hqRoot, company) {
50
+ return path.join(hqRoot, "workspace", "checkpoints", "ontology", company.slug);
51
+ }
52
+ export class WriteBoundsError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = "WriteBoundsError";
56
+ }
57
+ }
58
+ function isInside(root, target) {
59
+ const relative = path.relative(root, target);
60
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
61
+ }
62
+ /** The nearest existing ancestor of `target` (or `target` itself). */
63
+ function nearestExisting(target) {
64
+ let current = target;
65
+ while (!fs.existsSync(current)) {
66
+ const up = path.dirname(current);
67
+ if (up === current)
68
+ return current;
69
+ current = up;
70
+ }
71
+ return current;
72
+ }
73
+ /**
74
+ * Write `content` to `target` only when it stays inside one of `roots` with no
75
+ * symlink anywhere between `anchor` (the HQ root) and the file. A root or a
76
+ * directory under it that is a symlink fails closed, and so does a target that
77
+ * is itself a symlink. Missing directories are created only after that check.
78
+ * The file is written to a temporary sibling and renamed, so a reader never
79
+ * sees half a file.
80
+ */
81
+ export function safeWriteFile(anchor, roots, target, content) {
82
+ const resolved = path.resolve(target);
83
+ const root = roots.map((candidate) => path.resolve(candidate)).find((candidate) => isInside(candidate, resolved));
84
+ if (!root)
85
+ throw new WriteBoundsError(`refusing write outside the ontology bounds: ${target}`);
86
+ const realAnchor = fs.realpathSync(anchor);
87
+ const expected = (dir) => path.join(realAnchor, path.relative(path.resolve(anchor), dir));
88
+ const parent = path.dirname(resolved);
89
+ const existing = nearestExisting(parent);
90
+ if (fs.realpathSync(existing) !== expected(existing)) {
91
+ throw new WriteBoundsError(`refusing write through a symlink: ${target}`);
92
+ }
93
+ if (!isInside(path.resolve(anchor), existing) && existing !== path.resolve(anchor)) {
94
+ throw new WriteBoundsError(`refusing write outside the HQ root: ${target}`);
95
+ }
96
+ fs.mkdirSync(parent, { recursive: true });
97
+ try {
98
+ if (fs.lstatSync(resolved).isSymbolicLink()) {
99
+ throw new WriteBoundsError(`refusing write to a symlink: ${target}`);
100
+ }
101
+ }
102
+ catch (error) {
103
+ if (error.code !== "ENOENT")
104
+ throw error;
105
+ }
106
+ const tmp = path.join(parent, `.${path.basename(resolved)}.${process.pid}.tmp`);
107
+ fs.writeFileSync(tmp, content);
108
+ fs.renameSync(tmp, resolved);
109
+ }
110
+ //# sourceMappingURL=company.js.map