@indigoai-us/hq-cli 5.193.0 → 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.
- package/CHANGELOG.md +32 -0
- package/dist/command-catalog.generated.d.ts +42 -0
- package/dist/command-catalog.generated.js +53 -0
- package/dist/commands/core-checkpoint.d.ts +13 -2
- package/dist/commands/core-checkpoint.js +25 -4
- package/dist/commands/core-ontology.d.ts +22 -0
- package/dist/commands/core-ontology.js +114 -0
- package/dist/commands/core.js +2 -0
- package/dist/commands/lanes.js +26 -5
- package/dist/commands/mesh.js +45 -6
- package/dist/lib/checkpoint-ontology/apply.d.ts +113 -0
- package/dist/lib/checkpoint-ontology/apply.js +493 -0
- package/dist/lib/checkpoint-ontology/company.d.ts +40 -0
- package/dist/lib/checkpoint-ontology/company.js +110 -0
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +83 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +264 -0
- package/dist/lib/checkpoint-ontology/identity.d.ts +37 -0
- package/dist/lib/checkpoint-ontology/identity.js +95 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +124 -0
- package/dist/lib/checkpoint-ontology/plan.js +284 -0
- package/dist/lib/checkpoint-ontology/tree.d.ts +51 -0
- package/dist/lib/checkpoint-ontology/tree.js +163 -0
- package/dist/lib/mesh/live/daemon/board-refresh.js +2 -1
- package/dist/lib/mesh/project-board.js +5 -3
- package/dist/lib/mesh/story-status.d.ts +22 -0
- package/dist/lib/mesh/story-status.js +49 -0
- package/dist/lib/work-context/prd-sync.d.ts +1 -1
- package/dist/lib/work-context/prd-sync.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
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 { type WritableEntityType } from "./identity.js";
|
|
9
|
+
import { type EntityIndex } from "./tree.js";
|
|
10
|
+
export declare const CAPS: {
|
|
11
|
+
readonly creates: 25;
|
|
12
|
+
readonly updates: 100;
|
|
13
|
+
readonly bytes: number;
|
|
14
|
+
readonly newAliasesPerEntity: 3;
|
|
15
|
+
readonly aliasesPerCandidate: 5;
|
|
16
|
+
readonly nameLength: 200;
|
|
17
|
+
};
|
|
18
|
+
export declare const MIN_CREATE_CONFIDENCE = 0.7;
|
|
19
|
+
export interface Candidate {
|
|
20
|
+
input: string;
|
|
21
|
+
type: WritableEntityType;
|
|
22
|
+
name: string;
|
|
23
|
+
aliases: string[];
|
|
24
|
+
confidence: number;
|
|
25
|
+
evidence: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ApplyReport {
|
|
28
|
+
schema: "hq-checkpoint-ontology-report.v1";
|
|
29
|
+
company: string;
|
|
30
|
+
run_id: string;
|
|
31
|
+
status: "applied" | "no_candidates" | "invalid_candidates";
|
|
32
|
+
planned_inputs: number;
|
|
33
|
+
planned_tokens: number;
|
|
34
|
+
candidates: number;
|
|
35
|
+
created: string[];
|
|
36
|
+
updated: string[];
|
|
37
|
+
matched: number;
|
|
38
|
+
session_links: Array<{
|
|
39
|
+
type: string;
|
|
40
|
+
canonical_name: string;
|
|
41
|
+
path: string;
|
|
42
|
+
}>;
|
|
43
|
+
/** Per input, the entities it resolved to: `link` for an existing entity, `create` for a new file. */
|
|
44
|
+
links: Array<{
|
|
45
|
+
input: string;
|
|
46
|
+
action: "link" | "create";
|
|
47
|
+
type: string;
|
|
48
|
+
canonical_name: string;
|
|
49
|
+
path: string;
|
|
50
|
+
}>;
|
|
51
|
+
rejected: Array<{
|
|
52
|
+
input: string;
|
|
53
|
+
type: string;
|
|
54
|
+
name: string;
|
|
55
|
+
reason: string;
|
|
56
|
+
}>;
|
|
57
|
+
deferred: Array<{
|
|
58
|
+
input: string;
|
|
59
|
+
type: string;
|
|
60
|
+
name: string;
|
|
61
|
+
reason: string;
|
|
62
|
+
}>;
|
|
63
|
+
suspect_duplicates: Array<{
|
|
64
|
+
name: string;
|
|
65
|
+
type: string;
|
|
66
|
+
paths: string[];
|
|
67
|
+
}>;
|
|
68
|
+
backrefs_added: number;
|
|
69
|
+
bytes_written: number;
|
|
70
|
+
inputs_done: string[];
|
|
71
|
+
inputs_pending: string[];
|
|
72
|
+
write_errors: Array<{
|
|
73
|
+
path: string;
|
|
74
|
+
reason: string;
|
|
75
|
+
}>;
|
|
76
|
+
duration_ms: number;
|
|
77
|
+
}
|
|
78
|
+
export interface ApplyOptions {
|
|
79
|
+
hqRoot: string;
|
|
80
|
+
runDir: string;
|
|
81
|
+
/** The payload company. Must equal the plan's company. */
|
|
82
|
+
company: string;
|
|
83
|
+
now?: Date;
|
|
84
|
+
}
|
|
85
|
+
export declare class ApplyError extends Error {
|
|
86
|
+
constructor(message: string);
|
|
87
|
+
}
|
|
88
|
+
/** Validate one raw candidate. Returns the candidate or a rejection reason. */
|
|
89
|
+
export declare function parseCandidate(raw: unknown): Candidate | string;
|
|
90
|
+
type Resolution = {
|
|
91
|
+
tier: 1;
|
|
92
|
+
target: string;
|
|
93
|
+
duplicates: string[];
|
|
94
|
+
} | {
|
|
95
|
+
tier: 2;
|
|
96
|
+
target: string;
|
|
97
|
+
} | {
|
|
98
|
+
tier: "ambiguous_alias";
|
|
99
|
+
paths: string[];
|
|
100
|
+
} | {
|
|
101
|
+
tier: "suspect_duplicate";
|
|
102
|
+
paths: string[];
|
|
103
|
+
} | {
|
|
104
|
+
tier: "type_conflict";
|
|
105
|
+
types: string[];
|
|
106
|
+
} | {
|
|
107
|
+
tier: "new";
|
|
108
|
+
};
|
|
109
|
+
/** Design section 5.3, tiers 1 to 4. */
|
|
110
|
+
export declare function resolveCandidate(index: EntityIndex, type: string, canonical: string): Resolution;
|
|
111
|
+
export declare function applyOntologyRun(options: ApplyOptions): ApplyReport;
|
|
112
|
+
export {};
|
|
113
|
+
//# sourceMappingURL=apply.d.ts.map
|
|
@@ -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
|