@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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `hq core ontology plan`: pick this run's inputs.
3
+ *
4
+ * Runs in the detached checkpoint sibling. It resolves the flag, finds the
5
+ * company's changed knowledge and signal files with a content-hash ledger,
6
+ * applies the per-run budget, and writes `<runDir>/ontology-plan.json` for the
7
+ * backend model to read. The model only fills a candidates file; everything
8
+ * that affects identity, scope and cost stays in code.
9
+ */
10
+ import { type OntologyCompany } from "./company.js";
11
+ export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
12
+ export declare const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
13
+ export declare const PLAN_FILE = "ontology-plan.json";
14
+ export declare const CANDIDATES_FILE = "ontology-candidates.json";
15
+ export declare const REPORT_FILE = "ontology-report.json";
16
+ export declare const PLAN_SCHEMA = "hq-checkpoint-ontology-plan.v1";
17
+ export declare const CANDIDATES_SCHEMA = "hq-checkpoint-ontology-candidates.v1";
18
+ export declare const LEDGER_SCHEMA = "hq-checkpoint-ontology-ledger.v1";
19
+ export declare const BUDGET: {
20
+ readonly maxInputs: 20;
21
+ readonly maxTokens: 60000;
22
+ readonly chunkTokens: 12000;
23
+ };
24
+ export type PendingReason = "session-touched" | "shared" | "first-run-gap" | "missing-refs";
25
+ export interface LedgerEntry {
26
+ sha256: string;
27
+ size: number;
28
+ mtime_ms: number;
29
+ state: "seen" | "pending" | "done";
30
+ reason?: PendingReason;
31
+ /** Next chunk to process for a file larger than one chunk. */
32
+ next_chunk?: number;
33
+ run?: string;
34
+ }
35
+ export interface Ledger {
36
+ schema: typeof LEDGER_SCHEMA;
37
+ company: string;
38
+ /** Latest entity `last_updated` when the ledger was seeded (design 14.3). */
39
+ boundary: string | null;
40
+ entries: Record<string, LedgerEntry>;
41
+ }
42
+ export interface PlanInput {
43
+ /** Candidate `input` value: the company-relative path, `#<chunk>` when chunked, or `session`. */
44
+ id: string;
45
+ kind: "knowledge" | "signal" | "session";
46
+ /** Company-relative path of the file to read; null for `session` (its text is in the plan). */
47
+ path: string | null;
48
+ audience: "company" | "session";
49
+ reason: PendingReason;
50
+ sha256: string | null;
51
+ est_tokens: number;
52
+ /** 1-based inclusive line range to read, for a file larger than one chunk. */
53
+ lines: [number, number] | null;
54
+ chunk: {
55
+ index: number;
56
+ count: number;
57
+ } | null;
58
+ /** For signals: `entity_refs` items with no entity file yet. */
59
+ missing_refs?: string[];
60
+ }
61
+ export interface OntologyPlan {
62
+ schema: typeof PLAN_SCHEMA;
63
+ company: string;
64
+ run_id: string;
65
+ created_at: string;
66
+ first_run: boolean;
67
+ boundary: string | null;
68
+ shared_inputs: boolean;
69
+ inputs: PlanInput[];
70
+ /** The session's summary, decisions and learnings, for the `session` input. */
71
+ session_text: string | null;
72
+ pending_after_run: number;
73
+ budget: {
74
+ max_inputs: number;
75
+ max_tokens: number;
76
+ chunk_tokens: number;
77
+ planned_tokens: number;
78
+ };
79
+ candidates_file: string;
80
+ instructions: string[];
81
+ }
82
+ export interface CheckpointPayloadLike {
83
+ company?: unknown;
84
+ files?: unknown;
85
+ summary?: unknown;
86
+ decisions?: unknown;
87
+ learnings?: unknown;
88
+ insights?: unknown;
89
+ }
90
+ export type FlagReader = (flagKey: string, company: OntologyCompany) => Promise<boolean>;
91
+ export interface PlanOptions {
92
+ hqRoot: string;
93
+ runDir: string;
94
+ payload: CheckpointPayloadLike;
95
+ readFlag: FlagReader;
96
+ now?: Date;
97
+ }
98
+ export type PlanOutcome = {
99
+ status: "skipped";
100
+ reason: string;
101
+ } | {
102
+ status: "planned";
103
+ plan: OntologyPlan;
104
+ planPath: string;
105
+ };
106
+ export declare function ledgerPath(hqRoot: string, company: OntologyCompany): string;
107
+ export declare function readLedger(hqRoot: string, company: OntologyCompany): Ledger | null;
108
+ export declare function writeLedger(hqRoot: string, company: OntologyCompany, ledger: Ledger): void;
109
+ /** Company-relative paths of the payload's touched files that are inside this company. */
110
+ export declare function sessionTouched(hqRoot: string, company: OntologyCompany, files: unknown): Set<string>;
111
+ /** 1-based line ranges, each at most `chunkTokens` tokens (bytes / 4). */
112
+ export declare function chunkLines(content: string, chunkTokens: number): Array<[number, number]>;
113
+ /**
114
+ * Update the ledger with the current tree and return it. New or changed files
115
+ * become pending when this machine should process them (design 14.1) and
116
+ * seen otherwise. The first run seeds from the entity boundary (design 14.3).
117
+ */
118
+ export declare function refreshLedger(company: OntologyCompany, existing: Ledger | null, touched: Set<string>, sharedInputs: boolean): {
119
+ ledger: Ledger;
120
+ firstRun: boolean;
121
+ };
122
+ /** Plan this run. Never throws for a skip; returns the reason instead. */
123
+ export declare function planOntologyRun(options: PlanOptions): Promise<PlanOutcome>;
124
+ //# sourceMappingURL=plan.d.ts.map
@@ -0,0 +1,284 @@
1
+ /**
2
+ * `hq core ontology plan`: pick this run's inputs.
3
+ *
4
+ * Runs in the detached checkpoint sibling. It resolves the flag, finds the
5
+ * company's changed knowledge and signal files with a content-hash ledger,
6
+ * applies the per-run budget, and writes `<runDir>/ontology-plan.json` for the
7
+ * backend model to read. The model only fills a candidates file; everything
8
+ * that affects identity, scope and cost stays in code.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
14
+ import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
15
+ export const ONTOLOGY_FLAG = "checkpoint.ontology";
16
+ export const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
17
+ export const PLAN_FILE = "ontology-plan.json";
18
+ export const CANDIDATES_FILE = "ontology-candidates.json";
19
+ export const REPORT_FILE = "ontology-report.json";
20
+ export const PLAN_SCHEMA = "hq-checkpoint-ontology-plan.v1";
21
+ export const CANDIDATES_SCHEMA = "hq-checkpoint-ontology-candidates.v1";
22
+ export const LEDGER_SCHEMA = "hq-checkpoint-ontology-ledger.v1";
23
+ export const BUDGET = {
24
+ maxInputs: 20,
25
+ maxTokens: 60_000,
26
+ chunkTokens: 12_000,
27
+ };
28
+ const CANDIDATE_INSTRUCTIONS = [
29
+ `Write ${CANDIDATES_FILE} in the run directory as {"schema":"${CANDIDATES_SCHEMA}","candidates":[...]} and nothing else.`,
30
+ "Each candidate: {input, type, name, aliases, confidence, evidence}. input is one of this plan's input ids; type is person, project, company or concept; name is the entity's full proper name; aliases are other names the input uses for it (at most 5); confidence is 0 to 1; evidence is a short quote from the input.",
31
+ "Read only the files and line ranges this plan lists. For a signal, read its body and name the entities in missing_refs if the signal supports them.",
32
+ "Name the specific people, projects, products, companies and named concepts that the input says something about. That includes the company that owns this knowledge and its own products when the input describes them. Name a third-party tool, vendor or service only when a section of the input is about it; skip ones that appear in a list, a stack summary or a passing mention. Skip generic words, file names and headings.",
33
+ "For the session input (its text is session_text in this plan), name only entities that already exist in the company and that the session did work on. Session candidates are recorded as links, never created.",
34
+ "Do not create, edit, move or delete anything under ontology/, signals/ or sources/ yourself.",
35
+ ];
36
+ function sha256File(file) {
37
+ return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
38
+ }
39
+ export function ledgerPath(hqRoot, company) {
40
+ return path.join(stateRoot(hqRoot, company), "ledger.json");
41
+ }
42
+ export function readLedger(hqRoot, company) {
43
+ let raw;
44
+ try {
45
+ raw = fs.readFileSync(ledgerPath(hqRoot, company), "utf8");
46
+ }
47
+ catch (error) {
48
+ if (error.code === "ENOENT")
49
+ return null;
50
+ throw error;
51
+ }
52
+ const parsed = JSON.parse(raw);
53
+ if (parsed.schema !== LEDGER_SCHEMA || parsed.company !== company.slug || typeof parsed.entries !== "object") {
54
+ throw new Error(`ontology ledger for ${company.slug} has an unexpected schema`);
55
+ }
56
+ return parsed;
57
+ }
58
+ export function writeLedger(hqRoot, company, ledger) {
59
+ const target = ledgerPath(hqRoot, company);
60
+ safeWriteFile(hqRoot, [stateRoot(hqRoot, company)], target, `${JSON.stringify(ledger, null, 2)}\n`);
61
+ }
62
+ /** Company-relative paths of the payload's touched files that are inside this company. */
63
+ export function sessionTouched(hqRoot, company, files) {
64
+ const out = new Set();
65
+ if (!Array.isArray(files))
66
+ return out;
67
+ for (const file of files) {
68
+ if (typeof file !== "string" || !file.trim())
69
+ continue;
70
+ const absolute = path.resolve(hqRoot, file.trim());
71
+ const rel = path.relative(company.dir, absolute);
72
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
73
+ continue;
74
+ out.add(rel.split(path.sep).join("/"));
75
+ }
76
+ return out;
77
+ }
78
+ /** 1-based line ranges, each at most `chunkTokens` tokens (bytes / 4). */
79
+ export function chunkLines(content, chunkTokens) {
80
+ const lines = content.split("\n");
81
+ const maxBytes = chunkTokens * 4;
82
+ const chunks = [];
83
+ let start = 1;
84
+ let bytes = 0;
85
+ lines.forEach((line, i) => {
86
+ const size = Buffer.byteLength(line, "utf8") + 1;
87
+ if (bytes > 0 && bytes + size > maxBytes) {
88
+ chunks.push([start, i]);
89
+ start = i + 1;
90
+ bytes = 0;
91
+ }
92
+ bytes += size;
93
+ });
94
+ chunks.push([start, lines.length]);
95
+ return chunks;
96
+ }
97
+ function estTokens(bytes) {
98
+ return Math.ceil(bytes / 4);
99
+ }
100
+ /** Latest `last_updated` over the company's writable entity types (design 14.3). */
101
+ function entityBoundary(index) {
102
+ let best = null;
103
+ let bestMs = -Infinity;
104
+ for (const entity of index.byRel.values()) {
105
+ if (!["person", "project", "company", "concept"].includes(entity.type) || !entity.last_updated)
106
+ continue;
107
+ const ms = Date.parse(entity.last_updated);
108
+ if (Number.isFinite(ms) && ms > bestMs) {
109
+ bestMs = ms;
110
+ best = entity.last_updated;
111
+ }
112
+ }
113
+ return best;
114
+ }
115
+ function missingRefs(companyDir, rel) {
116
+ const signal = readSignal(companyDir, rel);
117
+ if (!signal)
118
+ return [];
119
+ return signal.refs.filter((ref) => !fs.existsSync(path.join(companyDir, "ontology", "entities", `${ref}.md`)));
120
+ }
121
+ /**
122
+ * Update the ledger with the current tree and return it. New or changed files
123
+ * become pending when this machine should process them (design 14.1) and
124
+ * seen otherwise. The first run seeds from the entity boundary (design 14.3).
125
+ */
126
+ export function refreshLedger(company, existing, touched, sharedInputs) {
127
+ const firstRun = existing === null;
128
+ const index = firstRun ? buildEntityIndex(company.dir) : null;
129
+ const boundary = existing ? existing.boundary : entityBoundary(index);
130
+ const boundaryMs = boundary ? Date.parse(boundary) : Number.NaN;
131
+ const entries = {};
132
+ const files = [...listKnowledgeFiles(company.dir), ...listSignalFiles(company.dir)];
133
+ for (const rel of files) {
134
+ const absolute = path.join(company.dir, rel);
135
+ const stat = fs.statSync(absolute);
136
+ const previous = existing?.entries[rel];
137
+ const unchangedStat = previous && previous.size === stat.size && previous.mtime_ms === stat.mtimeMs;
138
+ const sha256 = unchangedStat ? previous.sha256 : sha256File(absolute);
139
+ const base = { sha256, size: stat.size, mtime_ms: stat.mtimeMs };
140
+ if (previous && previous.sha256 === sha256) {
141
+ // Same content. A pending entry stays pending until apply finishes it.
142
+ // A file another run recorded as seen is still this session's input.
143
+ entries[rel] = previous.state === "seen" && touched.has(rel)
144
+ ? { ...base, state: "pending", reason: "session-touched" }
145
+ : { ...previous, ...base };
146
+ continue;
147
+ }
148
+ let reason = null;
149
+ if (touched.has(rel))
150
+ reason = "session-touched";
151
+ else if (sharedInputs && firstRun) {
152
+ if (Number.isFinite(boundaryMs) && stat.mtimeMs > boundaryMs)
153
+ reason = "first-run-gap";
154
+ else if (rel.startsWith("signals/") && missingRefs(company.dir, rel).length > 0)
155
+ reason = "missing-refs";
156
+ }
157
+ else if (sharedInputs)
158
+ reason = "shared";
159
+ entries[rel] = reason ? { ...base, state: "pending", reason } : { ...base, state: "seen" };
160
+ }
161
+ return { ledger: { schema: LEDGER_SCHEMA, company: company.slug, boundary, entries }, firstRun };
162
+ }
163
+ function reasonRank(reason) {
164
+ return reason === "session-touched" ? 0 : 1;
165
+ }
166
+ /** Plan this run. Never throws for a skip; returns the reason instead. */
167
+ export async function planOntologyRun(options) {
168
+ const { hqRoot, runDir, payload } = options;
169
+ const resolution = resolveOntologyCompany(hqRoot, typeof payload.company === "string" ? payload.company : null);
170
+ if (!resolution.ok)
171
+ return { status: "skipped", reason: resolution.reason };
172
+ const company = resolution.company;
173
+ if (!(await options.readFlag(ONTOLOGY_FLAG, company)))
174
+ return { status: "skipped", reason: "flag off" };
175
+ const sharedInputs = await options.readFlag(SHARED_INPUTS_FLAG, company);
176
+ const touched = sessionTouched(hqRoot, company, payload.files);
177
+ const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched, sharedInputs);
178
+ const pending = Object.entries(ledger.entries)
179
+ .filter(([, entry]) => entry.state === "pending" && (sharedInputs || entry.reason === "session-touched"))
180
+ .sort(([aRel, a], [bRel, b]) => reasonRank(a.reason) - reasonRank(b.reason) || a.mtime_ms - b.mtime_ms || aRel.localeCompare(bRel));
181
+ const inputs = [];
182
+ let tokens = 0;
183
+ let remaining = 0;
184
+ for (const [rel, entry] of pending) {
185
+ if (inputs.length >= BUDGET.maxInputs || tokens >= BUDGET.maxTokens) {
186
+ remaining += 1;
187
+ continue;
188
+ }
189
+ const lines = fs.readFileSync(path.join(company.dir, rel), "utf8").split("\n");
190
+ const chunks = chunkLines(lines.join("\n"), BUDGET.chunkTokens);
191
+ const kind = rel.startsWith("signals/") ? "signal" : "knowledge";
192
+ let index = entry.next_chunk ?? 0;
193
+ for (; index < chunks.length; index += 1) {
194
+ const [start, end] = chunks[index];
195
+ const cost = estTokens(Buffer.byteLength(lines.slice(start - 1, end).join("\n"), "utf8"));
196
+ if (inputs.length >= BUDGET.maxInputs || tokens + cost > BUDGET.maxTokens)
197
+ break;
198
+ inputs.push({
199
+ id: chunks.length > 1 ? `${rel}#${index}` : rel,
200
+ kind,
201
+ path: rel,
202
+ audience: "company",
203
+ reason: entry.reason ?? "shared",
204
+ sha256: entry.sha256,
205
+ est_tokens: cost,
206
+ lines: chunks.length > 1 ? [start, end] : null,
207
+ chunk: chunks.length > 1 ? { index, count: chunks.length } : null,
208
+ ...(kind === "signal" ? { missing_refs: missingRefs(company.dir, rel) } : {}),
209
+ });
210
+ tokens += cost;
211
+ }
212
+ if (index < chunks.length)
213
+ remaining += 1;
214
+ }
215
+ writeLedger(hqRoot, company, ledger);
216
+ if (inputs.length === 0) {
217
+ return { status: "skipped", reason: pending.length > 0 ? "over budget" : "nothing changed" };
218
+ }
219
+ // Session content is scoped to the session's user (design section 6, D2).
220
+ // v1 records the existing entities it names as links in the report and
221
+ // writes nothing from it, so it rides along only when files changed.
222
+ const sessionText = sessionContent(payload);
223
+ if (sessionText) {
224
+ const cost = estTokens(Buffer.byteLength(sessionText, "utf8"));
225
+ inputs.unshift({
226
+ id: "session",
227
+ kind: "session",
228
+ path: null,
229
+ audience: "session",
230
+ reason: "session-touched",
231
+ sha256: null,
232
+ est_tokens: cost,
233
+ lines: null,
234
+ chunk: null,
235
+ });
236
+ tokens += cost;
237
+ }
238
+ const plan = {
239
+ schema: PLAN_SCHEMA,
240
+ company: company.slug,
241
+ run_id: path.basename(runDir),
242
+ created_at: (options.now ?? new Date()).toISOString(),
243
+ first_run: firstRun,
244
+ boundary: ledger.boundary,
245
+ shared_inputs: sharedInputs,
246
+ inputs,
247
+ session_text: sessionText,
248
+ pending_after_run: remaining,
249
+ budget: {
250
+ max_inputs: BUDGET.maxInputs,
251
+ max_tokens: BUDGET.maxTokens,
252
+ chunk_tokens: BUDGET.chunkTokens,
253
+ planned_tokens: tokens,
254
+ },
255
+ candidates_file: CANDIDATES_FILE,
256
+ instructions: CANDIDATE_INSTRUCTIONS,
257
+ };
258
+ fs.mkdirSync(runDir, { recursive: true });
259
+ const planPath = path.join(runDir, PLAN_FILE);
260
+ fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
261
+ return { status: "planned", plan, planPath };
262
+ }
263
+ const SESSION_TEXT_MAX_BYTES = 8_000;
264
+ /** The session summary, decisions and learnings from the payload, capped. */
265
+ function sessionContent(payload) {
266
+ const parts = [];
267
+ if (typeof payload.summary === "string" && payload.summary.trim())
268
+ parts.push(payload.summary.trim());
269
+ for (const key of ["decisions", "learnings", "insights"]) {
270
+ const items = payload[key];
271
+ if (!Array.isArray(items))
272
+ continue;
273
+ for (const item of items)
274
+ if (typeof item === "string" && item.trim())
275
+ parts.push(`- ${item.trim()}`);
276
+ }
277
+ if (parts.length === 0)
278
+ return null;
279
+ const text = parts.join("\n");
280
+ return Buffer.byteLength(text, "utf8") > SESSION_TEXT_MAX_BYTES
281
+ ? Buffer.from(text, "utf8").subarray(0, SESSION_TEXT_MAX_BYTES).toString("utf8").replace(/\uFFFD$/, "")
282
+ : text;
283
+ }
284
+ //# sourceMappingURL=plan.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * In-memory indexes over a company's ontology and signals trees.
3
+ */
4
+ import { type EntityFields } from "./entity-file.js";
5
+ /** The eight company-audience signal types (`signals/{type}/*.md`). */
6
+ export declare const SIGNAL_TYPES: readonly ["action_item", "commitment", "decision", "risk", "question", "key_point", "participant_contribution", "summary"];
7
+ export interface IndexedEntity extends EntityFields {
8
+ /** Company-relative path, e.g. `ontology/entities/person/jane-doe.md`. */
9
+ rel: string;
10
+ slug: string;
11
+ last_updated: string | null;
12
+ }
13
+ export interface EntityIndex {
14
+ byRel: Map<string, IndexedEntity>;
15
+ /** `<type>\0<normalised canonical name>` → entity paths. */
16
+ canonical: Map<string, Set<string>>;
17
+ /** `<type>\0<normalised alias>` → entity paths. */
18
+ alias: Map<string, Set<string>>;
19
+ /** `<type>\0<squashed name or alias>` → entity paths. */
20
+ squashed: Map<string, Set<string>>;
21
+ /** Normalised name or alias → the types that hold it. */
22
+ types: Map<string, Set<string>>;
23
+ /** Files under `ontology/entities/` that fail validation. Never written. */
24
+ invalid: Set<string>;
25
+ }
26
+ export declare function emptyIndex(): EntityIndex;
27
+ /** Add one parsed entity to the index (also used for entities created mid-run). */
28
+ export declare function indexEntity(index: EntityIndex, entity: IndexedEntity): void;
29
+ /** Record that the entity at `rel` answers to `alias`. */
30
+ export declare function indexAlias(index: EntityIndex, type: string, rel: string, alias: string): void;
31
+ /**
32
+ * Index every entity file of every type under `ontology/entities/`. Other
33
+ * types (CRM, accounting, legacy `tool`) are indexed so a name they hold is a
34
+ * type conflict, but they are never written.
35
+ */
36
+ export declare function buildEntityIndex(companyDir: string): EntityIndex;
37
+ export interface SignalRecord {
38
+ /** Company-relative path, `signals/<type>/<file>.md`. */
39
+ rel: string;
40
+ type: string;
41
+ canonicalContent: string;
42
+ /** `entity_refs` items such as `person/jane-doe`. */
43
+ refs: string[];
44
+ }
45
+ /** Parse the frontmatter of one company-audience signal file. */
46
+ export declare function readSignal(companyDir: string, rel: string): SignalRecord | null;
47
+ /** Company-relative paths of every root signal file, sorted. */
48
+ export declare function listSignalFiles(companyDir: string): string[];
49
+ /** Company-relative paths of every knowledge markdown file, sorted. */
50
+ export declare function listKnowledgeFiles(companyDir: string): string[];
51
+ //# sourceMappingURL=tree.d.ts.map
@@ -0,0 +1,163 @@
1
+ /**
2
+ * In-memory indexes over a company's ontology and signals trees.
3
+ */
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { normalizeEntityName, squash } from "./identity.js";
7
+ import { parseEntityFile, validateEntityFrontmatter } from "./entity-file.js";
8
+ /** The eight company-audience signal types (`signals/{type}/*.md`). */
9
+ export const SIGNAL_TYPES = [
10
+ "action_item", "commitment", "decision", "risk", "question", "key_point",
11
+ "participant_contribution", "summary",
12
+ ];
13
+ function add(map, key, value) {
14
+ let set = map.get(key);
15
+ if (!set) {
16
+ set = new Set();
17
+ map.set(key, set);
18
+ }
19
+ set.add(value);
20
+ }
21
+ export function emptyIndex() {
22
+ return {
23
+ byRel: new Map(),
24
+ canonical: new Map(),
25
+ alias: new Map(),
26
+ squashed: new Map(),
27
+ types: new Map(),
28
+ invalid: new Set(),
29
+ };
30
+ }
31
+ /** Add one parsed entity to the index (also used for entities created mid-run). */
32
+ export function indexEntity(index, entity) {
33
+ index.byRel.set(entity.rel, entity);
34
+ const canonical = normalizeEntityName(entity.canonical_name, entity.type);
35
+ if (canonical) {
36
+ add(index.canonical, `${entity.type}\0${canonical}`, entity.rel);
37
+ add(index.squashed, `${entity.type}\0${squash(canonical)}`, entity.rel);
38
+ add(index.types, canonical, entity.type);
39
+ }
40
+ for (const alias of entity.aliases)
41
+ indexAlias(index, entity.type, entity.rel, alias);
42
+ }
43
+ /** Record that the entity at `rel` answers to `alias`. */
44
+ export function indexAlias(index, type, rel, alias) {
45
+ const normalized = normalizeEntityName(alias, type);
46
+ if (!normalized)
47
+ return;
48
+ add(index.alias, `${type}\0${normalized}`, rel);
49
+ add(index.squashed, `${type}\0${squash(normalized)}`, rel);
50
+ add(index.types, normalized, type);
51
+ }
52
+ /**
53
+ * Index every entity file of every type under `ontology/entities/`. Other
54
+ * types (CRM, accounting, legacy `tool`) are indexed so a name they hold is a
55
+ * type conflict, but they are never written.
56
+ */
57
+ export function buildEntityIndex(companyDir) {
58
+ const index = emptyIndex();
59
+ const root = path.join(companyDir, "ontology", "entities");
60
+ let types;
61
+ try {
62
+ types = fs.readdirSync(root, { withFileTypes: true });
63
+ }
64
+ catch (error) {
65
+ if (error.code === "ENOENT")
66
+ return index;
67
+ throw error;
68
+ }
69
+ for (const typeDir of types.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
70
+ const dir = path.join(root, typeDir.name);
71
+ for (const file of fs.readdirSync(dir, { withFileTypes: true })) {
72
+ if (!file.isFile() || !file.name.endsWith(".md"))
73
+ continue;
74
+ const rel = `ontology/entities/${typeDir.name}/${file.name}`;
75
+ const parsed = parseEntityFile(fs.readFileSync(path.join(dir, file.name), "utf8"));
76
+ const fields = parsed ? validateEntityFrontmatter(parsed.frontmatter) : "unparseable";
77
+ if (typeof fields === "string" || !parsed) {
78
+ index.invalid.add(rel);
79
+ continue;
80
+ }
81
+ const lastUpdated = parsed.frontmatter.last_updated;
82
+ indexEntity(index, {
83
+ ...fields,
84
+ rel,
85
+ slug: file.name.slice(0, -3),
86
+ last_updated: typeof lastUpdated === "string" ? lastUpdated : null,
87
+ });
88
+ }
89
+ }
90
+ return index;
91
+ }
92
+ /** Parse the frontmatter of one company-audience signal file. */
93
+ export function readSignal(companyDir, rel) {
94
+ let content;
95
+ try {
96
+ content = fs.readFileSync(path.join(companyDir, rel), "utf8");
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ const parsed = parseEntityFile(content);
102
+ if (!parsed)
103
+ return null;
104
+ const fm = parsed.frontmatter;
105
+ const refs = Array.isArray(fm.entity_refs)
106
+ ? fm.entity_refs.filter((ref) => typeof ref === "string" && /^[a-z_-]+\/[^/]+$/.test(ref))
107
+ : [];
108
+ return {
109
+ rel,
110
+ type: typeof fm.type === "string" ? fm.type : rel.split("/")[1],
111
+ canonicalContent: typeof fm.canonical_content === "string" ? fm.canonical_content : "",
112
+ refs,
113
+ };
114
+ }
115
+ /** Company-relative paths of every root signal file, sorted. */
116
+ export function listSignalFiles(companyDir) {
117
+ const out = [];
118
+ for (const type of SIGNAL_TYPES) {
119
+ let files;
120
+ try {
121
+ files = fs.readdirSync(path.join(companyDir, "signals", type), { withFileTypes: true });
122
+ }
123
+ catch (error) {
124
+ if (error.code === "ENOENT")
125
+ continue;
126
+ throw error;
127
+ }
128
+ for (const file of files) {
129
+ if (file.isFile() && file.name.endsWith(".md"))
130
+ out.push(`signals/${type}/${file.name}`);
131
+ }
132
+ }
133
+ return out.sort();
134
+ }
135
+ /** Company-relative paths of every knowledge markdown file, sorted. */
136
+ export function listKnowledgeFiles(companyDir) {
137
+ const out = [];
138
+ const walk = (rel) => {
139
+ let entries;
140
+ try {
141
+ entries = fs.readdirSync(path.join(companyDir, rel), { withFileTypes: true });
142
+ }
143
+ catch (error) {
144
+ if (error.code === "ENOENT")
145
+ return;
146
+ throw error;
147
+ }
148
+ for (const entry of entries) {
149
+ // Dot files are local state; `@{key}` folders are scoped audiences.
150
+ if (entry.name.startsWith(".") || entry.name.startsWith("@"))
151
+ continue;
152
+ const child = `${rel}/${entry.name}`;
153
+ // Symlinks are not followed: a link could point into another company.
154
+ if (entry.isDirectory())
155
+ walk(child);
156
+ else if (entry.isFile() && entry.name.endsWith(".md"))
157
+ out.push(child);
158
+ }
159
+ };
160
+ walk("knowledge");
161
+ return out.sort();
162
+ }
163
+ //# sourceMappingURL=tree.js.map
@@ -10,6 +10,7 @@ import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import { workContextSessionsDir } from "../../../work-context/paths.js";
12
12
  import { readSessionState } from "../../../work-context/state.js";
13
+ import { mapReaderStoryStatus } from "../../story-status.js";
13
14
  import { sessionBoardPath } from "./paths.js";
14
15
  export const BOARD_REFRESH_INTERVAL_MS = 60_000;
15
16
  function listBoundSessionIds(workContextRoot) {
@@ -147,7 +148,7 @@ function storiesFromProject(project) {
147
148
  : typeof row.name === "string"
148
149
  ? row.name.slice(0, 120)
149
150
  : undefined,
150
- status: typeof row.status === "string" ? row.status : undefined,
151
+ status: mapReaderStoryStatus(row),
151
152
  });
152
153
  }
153
154
  return stories;
@@ -1,4 +1,5 @@
1
1
  import { appendThreadEvent, ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
2
+ import { mapReaderStoryStatus } from "./story-status.js";
2
3
  function httpStatus(err) {
3
4
  if (err && typeof err === "object" && "status" in err && typeof err.status === "number") {
4
5
  return err.status;
@@ -19,7 +20,7 @@ export function mergeProjectStories(current, planned, opts) {
19
20
  ...(live.assigneeUid ? { assigneeUid: live.assigneeUid } : {}),
20
21
  };
21
22
  }
22
- const status = live.status || story.status;
23
+ const status = mapReaderStoryStatus(live) || story.status;
23
24
  return {
24
25
  ...story,
25
26
  ...(status ? { status } : {}),
@@ -30,8 +31,9 @@ export function mergeProjectStories(current, planned, opts) {
30
31
  return [...merged, ...current.filter((story) => !plannedIds.has(story.id))];
31
32
  }
32
33
  export function boardStoryStatus(story) {
33
- if (story.status)
34
- return story.status;
34
+ const status = mapReaderStoryStatus(story);
35
+ if (status)
36
+ return status;
35
37
  return story.passes === true ? "done" : "queued";
36
38
  }
37
39
  /**