@indigoai-us/hq-cli 5.279.0 → 5.281.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,924 @@
1
+ /** Native TypeScript implementation of the local ontology scaffold scripts. */
2
+ import { createHash } from "node:crypto";
3
+ import { spawnSync } from "node:child_process";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { resolveProcessFlagGate } from "../lib/flag-registry.js";
7
+ import { runBundledScript } from "../utils/run-bundled-script.js";
8
+ import { resolveBundledAsset } from "../utils/hq-roots.js";
9
+ import { describeCoreCommand } from "./core-command-catalog.js";
10
+ const FLAG = "core-native-ontology";
11
+ const ASSET = "core/scripts/";
12
+ const SIGNAL_TYPES = ["decision", "commitment", "risk", "question", "action_item", "key_point", "participant_contribution", "summary"];
13
+ const CANDIDATE_SIGNAL_TYPES = ["action_item", "commitment", "decision", "risk", "question", "key_point", "participant_contribution", "summary"];
14
+ const ENTITY_TYPES = ["person", "project", "company", "concept"];
15
+ const DEFAULT_PREFS = {
16
+ signals_enabled: "true",
17
+ ontology_enabled: "true",
18
+ signals_capture: "false",
19
+ ontology_capture: "false",
20
+ meeting_notes_source: "hq-native",
21
+ notetaker: "recall",
22
+ };
23
+ const ok = (stdout = "") => ({ code: 0, stdout, stderr: "" });
24
+ const err = (code, stderr, stdout = "") => ({ code, stdout, stderr });
25
+ const shellSubstitution = (value) => value.replace(/\n+$/, "");
26
+ const byteSort = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
27
+ function trLowercase(value) {
28
+ const locale = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "C";
29
+ const asciiLower = (text) => text.replace(/[A-Z]/g, (letter) => String.fromCharCode(letter.charCodeAt(0) + 32));
30
+ if (/^(?:C|POSIX)(?:$|[._@])/i.test(locale))
31
+ return asciiLower(value);
32
+ const localeName = locale.split(/[.@]/, 1)[0].replaceAll("_", "-");
33
+ if (!/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(localeName))
34
+ return asciiLower(value);
35
+ return value.toLocaleLowerCase(localeName);
36
+ }
37
+ function writeResult(result) {
38
+ if (result.stdout)
39
+ process.stdout.write(result.stdout);
40
+ if (result.stderr)
41
+ process.stderr.write(result.stderr);
42
+ process.exitCode = result.code;
43
+ }
44
+ function runHq(args, output = "capture") {
45
+ const executable = process.env.HQ_BIN || "hq";
46
+ const result = spawnSync(executable, args, {
47
+ cwd: process.cwd(),
48
+ env: process.env,
49
+ encoding: "utf8",
50
+ stdio: output === "ignore" ? ["ignore", "ignore", "ignore"] : ["ignore", "pipe", "ignore"],
51
+ });
52
+ return { status: result.status, stdout: typeof result.stdout === "string" ? result.stdout : "" };
53
+ }
54
+ function awkField(file, field, stripComment) {
55
+ if (!fs.existsSync(file))
56
+ return "";
57
+ const lines = fs.readFileSync(file, "utf8").split("\n");
58
+ const prefix = `${field}:`;
59
+ const line = lines.find((entry) => entry.startsWith(prefix));
60
+ if (line === undefined)
61
+ return "";
62
+ let value = line.slice(prefix.length).replace(/^[\t\n\v\f\r ]+/, "");
63
+ if (stripComment)
64
+ value = value.replace(/[\t\n\v\f\r ]+#.*$/, "");
65
+ return value.replace(/^["']|["']$/g, "");
66
+ }
67
+ export function preferenceValue(root, company, field) {
68
+ if (!(field in DEFAULT_PREFS))
69
+ return err(2, `knowledge-prefs: unknown field '${field}'\n`);
70
+ const companyDir = path.join(root, "companies", company);
71
+ if (!fs.existsSync(companyDir) || !fs.statSync(companyDir).isDirectory()) {
72
+ return err(2, `knowledge-prefs: no company '${company}'\n`);
73
+ }
74
+ const files = [
75
+ path.join(companyDir, "settings", "knowledge", "preferences.yaml"),
76
+ path.join(root, "personal", "settings", "knowledge-preferences.yaml"),
77
+ ];
78
+ for (const file of files) {
79
+ const value = awkField(file, field, true);
80
+ if (value.length > 0)
81
+ return ok(`${value}\n`);
82
+ }
83
+ return ok(`${DEFAULT_PREFS[field]}\n`);
84
+ }
85
+ function normalizeAudience(value) {
86
+ const entries = value.split(",")
87
+ .map((item) => item.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, ""))
88
+ .filter((item) => item !== "")
89
+ .map((item) => item.includes("@") ? item.toLowerCase() : item);
90
+ return [...new Set(entries)].sort(byteSort).join(",");
91
+ }
92
+ function audienceKey(value) {
93
+ const audience = shellSubstitution(value.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/gm, ""));
94
+ if (!audience)
95
+ return err(2, "ontology-candidate: empty audience\n");
96
+ if (audience.toLowerCase() === "company")
97
+ return ok("company\n");
98
+ const key = createHash("sha256").update(normalizeAudience(audience)).digest("hex").slice(0, 16);
99
+ return ok(`${key}\n`);
100
+ }
101
+ function readKnowledgePreference(root, company, field) {
102
+ return preferenceValue(root, company, field);
103
+ }
104
+ export function sourceYamlValidate(file) {
105
+ if (!file)
106
+ return err(2, "usage: source-yaml-validate.sh <source.yaml>\n");
107
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
108
+ return err(2, `source-yaml-validate: no such file ${file}\n`);
109
+ }
110
+ let violations = 0;
111
+ let stdout = "";
112
+ const bad = (message) => { stdout += `${file}: ${message}\n`; violations += 1; };
113
+ const values = {};
114
+ for (const key of ["channel", "kind", "arrives", "pull_command", "audience_rule", "processor", "run", "schedule"]) {
115
+ values[key] = awkField(file, key, true);
116
+ }
117
+ const channel = values.channel;
118
+ const kind = values.kind;
119
+ const arrives = values.arrives;
120
+ const pull = values.pull_command;
121
+ const audience = values.audience_rule;
122
+ const processor = values.processor;
123
+ const run = values.run;
124
+ const schedule = values.schedule;
125
+ const folder = path.basename(path.dirname(file));
126
+ const oneOf = (name, value, allowed) => {
127
+ if (!` ${allowed} `.includes(` ${value} `))
128
+ bad(`${name} must be one of: ${allowed} (got '${value || "<missing>"}')`);
129
+ };
130
+ if (!channel)
131
+ bad("channel is required");
132
+ if (channel && channel !== folder)
133
+ bad(`channel '${channel}' must equal its folder name '${folder}'`);
134
+ oneOf("kind", kind, "meeting email slack doc custom");
135
+ oneOf("arrives", arrives, "drop pull cloud");
136
+ if (arrives === "pull" && !pull)
137
+ bad("pull_command is required when arrives: pull");
138
+ if (!audience)
139
+ bad("audience_rule is required (attendees|thread|channel|company|explicit); it is never defaulted");
140
+ else
141
+ oneOf("audience_rule", audience, "attendees thread channel company explicit");
142
+ if (!processor)
143
+ bad("processor is required (<worker>/<skill>)");
144
+ else if (!/^[a-z0-9-]+\/[a-z0-9-]+$/.test(processor))
145
+ bad(`processor must look like <worker>/<skill> (got '${processor}')`);
146
+ oneOf("run", run, "local cloud");
147
+ if (!schedule)
148
+ bad("schedule is required (cron or on-close)");
149
+ else if (schedule !== "on-close" && schedule.trim().split(/[\t\n\v\f\r ]+/).length !== 5) {
150
+ bad(`schedule must be a 5-field cron or on-close (got '${schedule}')`);
151
+ }
152
+ if (violations === 0)
153
+ stdout = `${file}: ok\n`;
154
+ return { code: violations === 0 ? 0 : 1, stdout, stderr: "" };
155
+ }
156
+ export function candidateCommand(args, root) {
157
+ const command = args[0] ?? "";
158
+ if (command === "key")
159
+ return audienceKey(args[1] ?? "");
160
+ if (command !== "write")
161
+ return err(2, "ontology-candidate: usage: ontology-candidate.sh write|key ...\n");
162
+ const values = { company: "", kind: "", type: "", audience: "", sourceRef: "", body: "", bodyFile: "", createdBy: process.env.HQ_USER_EMAIL ?? "" };
163
+ const aliases = {
164
+ "--company": "company", "--kind": "kind", "--type": "type", "--audience": "audience",
165
+ "--source-ref": "sourceRef", "--body": "body", "--body-file": "bodyFile", "--created-by": "createdBy",
166
+ };
167
+ for (let index = 1; index < args.length; index += 1) {
168
+ const name = args[index];
169
+ if (!(name in aliases))
170
+ return err(2, `ontology-candidate: unknown flag ${name}\n`);
171
+ if (index + 1 >= args.length) {
172
+ const lines = {
173
+ "--company": 50, "--kind": 51, "--type": 52, "--audience": 53,
174
+ "--source-ref": 54, "--body": 55, "--body-file": 56, "--created-by": 57,
175
+ };
176
+ return err(1, `${resolveBundledAsset(`${ASSET}ontology-candidate.sh`)}: line ${lines[name]}: $2: unbound variable\n`);
177
+ }
178
+ values[aliases[name]] = args[++index];
179
+ }
180
+ const { company, kind, type, audience, sourceRef } = values;
181
+ if (!company || !kind || !type || !audience || !sourceRef)
182
+ return err(2, "ontology-candidate: missing required flag\n");
183
+ let body = values.body;
184
+ if (values.bodyFile) {
185
+ if (!fs.existsSync(values.bodyFile) || !fs.statSync(values.bodyFile).isFile()) {
186
+ return err(2, `ontology-candidate: no body file ${values.bodyFile}\n`);
187
+ }
188
+ body = shellSubstitution(fs.readFileSync(values.bodyFile, "utf8"));
189
+ }
190
+ body = body.replace(/\n/g, " ").replace(/[\t\n\v\f\r ]+/g, " ").replace(/^ | $/g, "");
191
+ if (!body)
192
+ return err(2, "ontology-candidate: empty body\n");
193
+ let store;
194
+ let preference;
195
+ if (kind === "signal") {
196
+ store = "signals";
197
+ preference = "signals_capture";
198
+ }
199
+ else if (kind === "entity") {
200
+ store = "ontology";
201
+ preference = "ontology_capture";
202
+ }
203
+ else
204
+ return err(2, "ontology-candidate: kind must be signal or entity\n");
205
+ const allowed = kind === "signal" ? CANDIDATE_SIGNAL_TYPES : ENTITY_TYPES;
206
+ if (!allowed.includes(type))
207
+ return err(2, `ontology-candidate: unknown ${kind} type '${type}' (allowed: ${allowed.join(" ")})\n`);
208
+ const enabled = readKnowledgePreference(root, company, preference);
209
+ if (enabled.code !== 0)
210
+ return err(2, enabled.stderr);
211
+ if (shellSubstitution(enabled.stdout) !== "true")
212
+ return ok(`disabled for ${company} (${preference}=false)\n`);
213
+ const keyResult = audienceKey(audience);
214
+ if (keyResult.code !== 0)
215
+ return keyResult;
216
+ const key = shellSubstitution(keyResult.stdout);
217
+ const normalizedBody = trLowercase(body);
218
+ const id = createHash("sha256").update(`${kind}\n${type}\n${key}\n${normalizedBody}`).digest("hex");
219
+ const candidates = path.join(root, "companies", company, store, "_candidates");
220
+ if (fileNamedExists(candidates, `${id}.md`))
221
+ return ok(`exists ${id}\n`);
222
+ const day = new Date().toISOString().slice(0, 10);
223
+ const directory = path.join(candidates, day);
224
+ fs.mkdirSync(directory, { recursive: true });
225
+ const lines = ["---", `kind: ${kind}`, `type: ${type}`, `audience_key: ${key}`];
226
+ if (key !== "company")
227
+ lines.push(`audience: [${normalizeAudience(audience).replaceAll(",", ", ")}]`);
228
+ lines.push(`source_ref: "${sourceRef}"`);
229
+ if (values.createdBy)
230
+ lines.push(`created_by: ${values.createdBy}`);
231
+ lines.push(`created_at: ${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}`, "---", body);
232
+ const destination = path.join(directory, `${id}.md`);
233
+ fs.writeFileSync(`${destination}.tmp`, `${lines.join("\n")}\n`);
234
+ fs.renameSync(`${destination}.tmp`, destination);
235
+ return ok(`wrote ${store}/_candidates/${day}/${id}.md\n`);
236
+ }
237
+ function fileNamedExists(directory, name) {
238
+ if (!fs.existsSync(directory))
239
+ return false;
240
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
241
+ const current = path.join(directory, entry.name);
242
+ if (entry.isDirectory()) {
243
+ if (fileNamedExists(current, name))
244
+ return true;
245
+ }
246
+ else if (entry.name === name)
247
+ return true;
248
+ }
249
+ return false;
250
+ }
251
+ function frontmatter(file) {
252
+ const text = fs.readFileSync(file, "utf8");
253
+ const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
254
+ const output = { _body: (match ? match[2] : text).trim() };
255
+ if (match) {
256
+ for (const line of match[1].split("\n")) {
257
+ const item = line.match(/^([A-Za-z_]+):\s*(.*)$/);
258
+ if (item)
259
+ output[item[1]] = item[2].trim().replace(/^"(.*)"$/, "$1");
260
+ }
261
+ }
262
+ return output;
263
+ }
264
+ function walk(directory, predicate) {
265
+ if (!fs.existsSync(directory))
266
+ return [];
267
+ const output = [];
268
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
269
+ const file = path.join(directory, entry.name);
270
+ if (entry.isDirectory()) {
271
+ if (!entry.name.startsWith("_"))
272
+ output.push(...walk(file, predicate));
273
+ }
274
+ else if (predicate(file))
275
+ output.push(file);
276
+ }
277
+ return output;
278
+ }
279
+ export function briefCommand(args, defaultRoot) {
280
+ let company;
281
+ let root;
282
+ let days = 14;
283
+ let top = 10;
284
+ let json = false;
285
+ for (let index = 0; index < args.length; index += 1) {
286
+ const key = args[index];
287
+ if (key === "--company")
288
+ company = args[++index];
289
+ else if (key === "--hq-root")
290
+ root = args[++index];
291
+ else if (key === "--days")
292
+ days = Number(args[++index]);
293
+ else if (key === "--top")
294
+ top = Number(args[++index]);
295
+ else if (key === "--json")
296
+ json = true;
297
+ else
298
+ return err(2, `ontology-brief: unknown flag ${key}\n`);
299
+ }
300
+ if (!company)
301
+ return err(2, "usage: ontology-brief.mjs --company <co> [--days N] [--top N] [--json]\n");
302
+ const hqRoot = root || process.env.HQ_ROOT || defaultRoot;
303
+ const companyDir = path.join(hqRoot, "companies", company);
304
+ if (!fs.existsSync(companyDir))
305
+ return err(2, `ontology-brief: no company ${company}\n`);
306
+ const entities = walk(path.join(companyDir, "ontology", "entities"), (file) => file.endsWith(".md")).map(frontmatter);
307
+ const cutoff = Date.now() - days * 86400e3;
308
+ const signals = walk(path.join(companyDir, "signals"), (file) => file.endsWith(".md") && !file.includes(`${path.sep}_`))
309
+ .map((file) => ({ ...frontmatter(file), _scope: file.includes(`${path.sep}signals${path.sep}@`) ? "scoped" : "company" }))
310
+ .filter((signal) => SIGNAL_TYPES.includes(signal.type ?? ""));
311
+ const recent = signals.filter((signal) => !signal.created_at || Date.parse(signal.created_at) >= cutoff)
312
+ .sort((left, right) => (right.created_at ?? "").localeCompare(left.created_at ?? ""));
313
+ const facts = new Map();
314
+ for (const file of walk(path.join(companyDir, "ontology", "facts"), (item) => item.endsWith(".md"))) {
315
+ const document = frontmatter(file);
316
+ const scope = file.includes(`${path.sep}@company${path.sep}`) ? "company" : "scoped";
317
+ const slug = document.entity || path.basename(file, ".md");
318
+ for (const line of document._body.split("\n").filter((item) => item.startsWith("- "))) {
319
+ if (!facts.has(slug))
320
+ facts.set(slug, []);
321
+ facts.get(slug).push({ scope, line: line.slice(2) });
322
+ }
323
+ }
324
+ const hot = entities.map((entity) => ({ ...entity, _facts: facts.get(entity.slug ?? "") ?? [] }))
325
+ .sort((left, right) => (right._facts.length - left._facts.length) || ((Number(right.signal_count) || 0) - (Number(left.signal_count) || 0)))
326
+ .slice(0, top);
327
+ if (json) {
328
+ return ok(`${JSON.stringify({
329
+ company, entities: entities.length, signals: signals.length,
330
+ recent: recent.slice(0, top * 2).map((signal) => ({ type: signal.type, scope: signal._scope, text: signal.canonical_content || signal._body })),
331
+ hot: hot.map((entity) => ({ name: entity.canonical_name, type: entity.type, facts: entity._facts })),
332
+ })}\n`);
333
+ }
334
+ if (!entities.length && !signals.length)
335
+ return ok(`# ${company} — no local ontology yet\n\nNothing under ontology/ or signals/ that you can see.\n`);
336
+ const output = [`# ${company} — company brief (local)`, "", `${entities.length} entities · ${signals.length} signals you can see · last ${days} days`, "", "## Recent Signals"];
337
+ for (const type of SIGNAL_TYPES) {
338
+ const rows = recent.filter((signal) => signal.type === type).slice(0, 5);
339
+ if (!rows.length)
340
+ continue;
341
+ output.push("", `### ${type.replace("_", " ")}`);
342
+ for (const signal of rows)
343
+ output.push(`- ${signal.canonical_content || signal._body} _(${signal._scope})_`);
344
+ }
345
+ output.push("", "## Entities");
346
+ for (const entity of hot) {
347
+ output.push("", `### ${entity.canonical_name} — ${entity.type}`);
348
+ if (!entity._facts.length)
349
+ output.push("- no facts you can see");
350
+ for (const fact of entity._facts.slice(0, 6))
351
+ output.push(`- ${fact.line} _(${fact.scope})_`);
352
+ }
353
+ return ok(`${output.join("\n")}\n`);
354
+ }
355
+ const STOPWORDS = new Set([
356
+ "for", "calls", "call", "context", "summary", "reason", "the", "a", "an", "and",
357
+ "or", "this", "that", "it", "we", "they", "you", "i", "notes", "meeting", "update",
358
+ "question", "decision", "risk", "todo", "none", "n/a", "unknown",
359
+ ]);
360
+ function readGardenDoc(file) {
361
+ const text = fs.readFileSync(file, "utf8");
362
+ const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
363
+ if (!match)
364
+ return { fm: {}, body: text.trim() };
365
+ const fm = {};
366
+ for (const line of match[1].split("\n")) {
367
+ const item = line.match(/^([A-Za-z_]+):\s*(.*)$/);
368
+ if (!item)
369
+ continue;
370
+ let value = item[2].trim();
371
+ if (value.startsWith("[") && value.endsWith("]")) {
372
+ value = value.slice(1, -1).split(",").map((part) => part.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
373
+ }
374
+ else
375
+ value = value.replace(/^["']|["']$/g, "");
376
+ fm[item[1]] = value;
377
+ }
378
+ return { fm, body: match[2].trim() };
379
+ }
380
+ function writeGardenDoc(file, fm, body, dryRun) {
381
+ if (dryRun)
382
+ return;
383
+ const lines = ["---"];
384
+ for (const [key, value] of Object.entries(fm)) {
385
+ if (value === undefined || value === null || value === "")
386
+ continue;
387
+ lines.push(`${key}: ${Array.isArray(value) ? `[${value.join(", ")}]` : value}`);
388
+ }
389
+ lines.push("---", body, "");
390
+ fs.mkdirSync(path.dirname(file), { recursive: true });
391
+ fs.writeFileSync(`${file}.tmp`, lines.join("\n"));
392
+ fs.renameSync(`${file}.tmp`, file);
393
+ }
394
+ const slugify = (value) => value.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80);
395
+ const isoNow = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
396
+ function listCandidates(directory) {
397
+ if (!fs.existsSync(directory))
398
+ return [];
399
+ const output = [];
400
+ for (const name of fs.readdirSync(directory).sort()) {
401
+ if (name === "_done")
402
+ continue;
403
+ const current = path.join(directory, name);
404
+ if (!fs.statSync(current).isDirectory())
405
+ continue;
406
+ for (const file of fs.readdirSync(current).sort()) {
407
+ if (file.endsWith(".md"))
408
+ output.push(path.join(current, file));
409
+ }
410
+ }
411
+ return output;
412
+ }
413
+ export function gardenCommand(args, defaultRoot) {
414
+ let company;
415
+ let root;
416
+ let dryRun = false;
417
+ let json = false;
418
+ for (let index = 0; index < args.length; index += 1) {
419
+ const key = args[index];
420
+ if (key === "--company")
421
+ company = args[++index];
422
+ else if (key === "--hq-root")
423
+ root = args[++index];
424
+ else if (key === "--dry-run")
425
+ dryRun = true;
426
+ else if (key === "--json")
427
+ json = true;
428
+ else
429
+ return err(2, `ontology-garden: unknown flag ${key}\n`);
430
+ }
431
+ if (!company)
432
+ return err(2, "usage: ontology-garden.mjs --company <co> [--hq-root <p>] [--dry-run] [--json]\n");
433
+ const hqRoot = root || process.env.HQ_ROOT || defaultRoot;
434
+ const companyDir = path.join(hqRoot, "companies", company);
435
+ if (!fs.existsSync(companyDir))
436
+ return err(2, `ontology-garden: no company ${company}\n`);
437
+ const ontology = path.join(companyDir, "ontology");
438
+ const signals = path.join(companyDir, "signals");
439
+ const report = { company, entities_created: 0, entities_updated: 0, signals_promoted: 0, facts_written: 0, rejected: 0, candidates_processed: 0, dry_run: dryRun };
440
+ const rejected = [];
441
+ const moves = [];
442
+ const entities = new Map();
443
+ const nameIndex = new Map();
444
+ const entityDirectory = path.join(ontology, "entities");
445
+ if (fs.existsSync(entityDirectory)) {
446
+ for (const type of fs.readdirSync(entityDirectory)) {
447
+ const typeDirectory = path.join(entityDirectory, type);
448
+ if (!fs.statSync(typeDirectory).isDirectory())
449
+ continue;
450
+ for (const name of fs.readdirSync(typeDirectory)) {
451
+ if (!name.endsWith(".md"))
452
+ continue;
453
+ const file = path.join(typeDirectory, name);
454
+ const { fm } = readGardenDoc(file);
455
+ const slug = String(fm.slug || name.replace(/\.md$/, ""));
456
+ const aliases = Array.isArray(fm.aliases) ? fm.aliases : [];
457
+ const names = [fm.canonical_name, ...aliases].filter((value) => typeof value === "string" && value.length > 0);
458
+ entities.set(slug, { type: String(fm.type || type), file, fm, names });
459
+ for (const item of names)
460
+ nameIndex.set(item.toLowerCase(), slug);
461
+ }
462
+ }
463
+ }
464
+ for (const file of listCandidates(path.join(ontology, "_candidates"))) {
465
+ const { fm, body } = readGardenDoc(file);
466
+ const name = body.split("\n")[0].trim();
467
+ const type = String(fm.type ?? "");
468
+ const reject = (reason) => {
469
+ rejected.push({ at: isoNow(), name, type, reason, candidate: path.relative(companyDir, file) });
470
+ report.rejected += 1;
471
+ };
472
+ if (!ENTITY_TYPES.includes(type))
473
+ reject("unknown-type");
474
+ else if (!name || STOPWORDS.has(name.toLowerCase()) || name.length < 2)
475
+ reject("stopword");
476
+ else {
477
+ const slug = slugify(name);
478
+ const held = nameIndex.get(name.toLowerCase());
479
+ const existing = entities.get(held || slug);
480
+ if (existing && existing.type !== type)
481
+ reject(`type-conflict:${existing.type}`);
482
+ else if (existing) {
483
+ const aliases = new Set(Array.isArray(existing.fm.aliases) ? existing.fm.aliases : []);
484
+ if (name !== existing.fm.canonical_name && !aliases.has(name)) {
485
+ aliases.add(name);
486
+ existing.fm.aliases = [...aliases];
487
+ existing.fm.last_updated = isoNow();
488
+ writeGardenDoc(existing.file, existing.fm, "", dryRun);
489
+ report.entities_updated += 1;
490
+ nameIndex.set(name.toLowerCase(), held || slug);
491
+ }
492
+ }
493
+ else {
494
+ const entityFile = path.join(ontology, "entities", type, `${slug}.md`);
495
+ const entityFm = {
496
+ type, canonical_name: name, slug, aliases: [], signal_count: 0,
497
+ first_seen: String(fm.created_at || isoNow()), last_updated: isoNow(), enriched_by: "ontology-worker",
498
+ };
499
+ writeGardenDoc(entityFile, entityFm, "", dryRun);
500
+ entities.set(slug, { type, file: entityFile, fm: entityFm, names: [name] });
501
+ nameIndex.set(name.toLowerCase(), slug);
502
+ report.entities_created += 1;
503
+ }
504
+ }
505
+ moves.push(file);
506
+ report.candidates_processed += 1;
507
+ }
508
+ const index = [];
509
+ for (const file of listCandidates(path.join(signals, "_candidates"))) {
510
+ const { fm, body } = readGardenDoc(file);
511
+ const type = String(fm.type ?? "");
512
+ const key = String(fm.audience_key ?? "");
513
+ moves.push(file);
514
+ report.candidates_processed += 1;
515
+ if (!SIGNAL_TYPES.includes(type) || !key || !body) {
516
+ rejected.push({ at: isoNow(), type, reason: !key ? "missing-audience" : "invalid", candidate: path.relative(companyDir, file) });
517
+ report.rejected += 1;
518
+ continue;
519
+ }
520
+ if (key !== "company" && !(Array.isArray(fm.audience) && fm.audience.length > 0)) {
521
+ rejected.push({ at: isoNow(), type, reason: "scoped-without-principals", candidate: path.relative(companyDir, file) });
522
+ report.rejected += 1;
523
+ continue;
524
+ }
525
+ const id = path.basename(file, ".md");
526
+ const scopeDirectory = key === "company" ? signals : path.join(signals, `@${key}`);
527
+ const outputFile = path.join(scopeDirectory, type, `${id}.md`);
528
+ if (!fs.existsSync(outputFile)) {
529
+ writeGardenDoc(outputFile, {
530
+ signal_id: id, type, canonical_content: JSON.stringify(body.split("\n")[0]), audience_key: key,
531
+ source_ref: JSON.stringify(fm.source_ref || ""), created_at: String(fm.created_at || isoNow()),
532
+ }, body, dryRun);
533
+ report.signals_promoted += 1;
534
+ index.push({ signal_id: id, type, audience_key: key, path: path.relative(companyDir, outputFile) });
535
+ if (key !== "company") {
536
+ const audienceFile = path.join(scopeDirectory, "_audience.yaml");
537
+ if (!fs.existsSync(audienceFile) && !dryRun) {
538
+ fs.mkdirSync(scopeDirectory, { recursive: true });
539
+ fs.writeFileSync(audienceFile, `audience_key: ${key}\nprincipals: [${fm.audience.join(", ")}]\n`);
540
+ }
541
+ }
542
+ const lower = body.toLowerCase();
543
+ const hit = new Set();
544
+ for (const [name, slug] of nameIndex) {
545
+ if (name.length < 3)
546
+ continue;
547
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
548
+ const expression = new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}($|[^\\p{L}\\p{N}])`, "u");
549
+ if (expression.test(lower))
550
+ hit.add(slug);
551
+ }
552
+ for (const slug of hit) {
553
+ const entity = entities.get(slug);
554
+ const factFile = path.join(ontology, "facts", `@${key}`, entity.type, `${slug}.md`);
555
+ const line = `- [${type}] ${body.split("\n")[0]} (signal ${id.slice(0, 12)})`;
556
+ const previous = fs.existsSync(factFile)
557
+ ? readGardenDoc(factFile)
558
+ : { fm: { entity: slug, type: entity.type, audience_key: key }, body: "" };
559
+ if (!previous.body.includes(`signal ${id.slice(0, 12)}`)) {
560
+ writeGardenDoc(factFile, previous.fm, [previous.body, line].filter(Boolean).join("\n"), dryRun);
561
+ report.facts_written += 1;
562
+ }
563
+ if (key === "company") {
564
+ entity.fm.signal_count = String((parseInt(String(entity.fm.signal_count || "0"), 10) || 0) + 1);
565
+ entity.fm.last_updated = isoNow();
566
+ writeGardenDoc(entity.file, entity.fm, "", dryRun);
567
+ }
568
+ }
569
+ }
570
+ }
571
+ if (!dryRun) {
572
+ if (index.length) {
573
+ const file = path.join(signals, "_index", `${new Date().toISOString().slice(0, 10)}.json`);
574
+ const previous = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : [];
575
+ fs.mkdirSync(path.dirname(file), { recursive: true });
576
+ fs.writeFileSync(file, `${JSON.stringify([...previous, ...index], null, 2)}\n`);
577
+ }
578
+ if (rejected.length) {
579
+ const file = path.join(ontology, "_rejected", `${new Date().toISOString().slice(0, 10)}.jsonl`);
580
+ fs.mkdirSync(path.dirname(file), { recursive: true });
581
+ fs.appendFileSync(file, `${rejected.map((item) => JSON.stringify(item)).join("\n")}\n`);
582
+ }
583
+ for (const file of moves) {
584
+ const relative = path.relative(path.dirname(path.dirname(file)), file);
585
+ const destination = path.join(path.dirname(path.dirname(file)), "_done", relative);
586
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
587
+ fs.renameSync(file, destination);
588
+ }
589
+ if (report.candidates_processed > 0) {
590
+ fs.mkdirSync(ontology, { recursive: true });
591
+ fs.writeFileSync(path.join(ontology, ".last-run"), `${Date.now()}\n`);
592
+ }
593
+ }
594
+ return ok(json
595
+ ? `${JSON.stringify(report)}\n`
596
+ : `garden ${company}: +${report.entities_created} entities, ~${report.entities_updated} updated, +${report.signals_promoted} signals, +${report.facts_written} facts, ${report.rejected} rejected, ${report.candidates_processed} candidates${dryRun ? " (dry run)" : ""}\n`);
597
+ }
598
+ export function audienceGrant(args, root) {
599
+ let company = "";
600
+ let dryRun = false;
601
+ for (let index = 0; index < args.length; index += 1) {
602
+ const key = args[index];
603
+ if (key === "--company") {
604
+ if (index + 1 >= args.length)
605
+ return err(1, `${resolveBundledAsset(`${ASSET}ontology-audience-grant.sh`)}: line 26: $2: unbound variable\n`);
606
+ company = args[++index];
607
+ }
608
+ else if (key === "--dry-run")
609
+ dryRun = true;
610
+ else
611
+ return err(2, `unknown flag ${key}\n`);
612
+ }
613
+ if (!company)
614
+ return err(2, "usage: ontology-audience-grant.sh --company <co> [--dry-run]\n");
615
+ const companyDir = path.join(root, "companies", company);
616
+ if (!fs.existsSync(companyDir) || !fs.statSync(companyDir).isDirectory())
617
+ return err(2, `no company ${company}\n`);
618
+ let cloud = false;
619
+ const companyYaml = path.join(companyDir, "company.yaml");
620
+ if (fs.existsSync(companyYaml))
621
+ cloud = /^cloud:[\t\v\f\r ]*true/m.test(fs.readFileSync(companyYaml, "utf8"));
622
+ const manifest = path.join(root, "companies", "manifest.yaml");
623
+ if (!cloud && fs.existsSync(manifest)) {
624
+ let inCompany = false;
625
+ for (const line of fs.readFileSync(manifest, "utf8").split("\n")) {
626
+ if (/^ {2}[^ #][^:]*:/.test(line))
627
+ inCompany = line.trim().split(/[\t ]/)[0] === `${company}:`;
628
+ if (inCompany && /^ {4}cloud_uid:[\t\v\f\r ]*[^\t\n\v\f\r ]/.test(line))
629
+ cloud = true;
630
+ }
631
+ }
632
+ if (!cloud)
633
+ return ok("local-only, no grants\n");
634
+ const membersRun = runHq(["members", "--company", company, "list"]);
635
+ const memberLines = membersRun.stdout.split("\n").slice(1).map((line) => line.trim().split(/[\t ]+/)[0]?.toLowerCase() ?? "");
636
+ const members = shellSubstitution(memberLines.join("\n"));
637
+ if (!members)
638
+ return err(3, `could not list members of ${company}\n`);
639
+ let granted = 0;
640
+ let skipped = 0;
641
+ let failed = 0;
642
+ let stdout = "";
643
+ let stderr = "";
644
+ const grant = (folder, who) => {
645
+ const prefix = `${folder.replace(/\/$/, "")}/*`;
646
+ if (dryRun) {
647
+ stdout += `would grant read ${prefix} -> ${who}\n`;
648
+ return;
649
+ }
650
+ runHq(["files", "--company", company, "share", prefix, "--with", who, "--permission", "read"], "ignore");
651
+ const acl = runHq(["files", "--company", company, "acl", prefix, "--json"]);
652
+ let confirmed = false;
653
+ if (acl.status === 0) {
654
+ try {
655
+ const payload = JSON.parse(acl.stdout);
656
+ confirmed = (payload.direct ?? []).some((value) => {
657
+ if (!value || typeof value !== "object")
658
+ return false;
659
+ const row = value;
660
+ return ((row.granteeId === who) || (who === "@all" && row.granteeType === "company-wide"))
661
+ && ["read", "write", "admin"].includes(String(row.permission));
662
+ });
663
+ }
664
+ catch (parseError) {
665
+ // jq -e returns failure without a diagnostic on malformed JSON here.
666
+ void parseError;
667
+ }
668
+ }
669
+ if (confirmed) {
670
+ granted += 1;
671
+ stdout += `granted read ${prefix} -> ${who}\n`;
672
+ }
673
+ else {
674
+ failed += 1;
675
+ stderr += `READBACK FAILED: ${prefix} -> ${who}\n`;
676
+ }
677
+ };
678
+ const signalDir = path.join(companyDir, "signals");
679
+ const audienceFiles = fs.existsSync(signalDir)
680
+ ? fs.readdirSync(signalDir).filter((name) => /^@/.test(name)).sort(byteSort)
681
+ .map((name) => path.join(signalDir, name, "_audience.yaml")).filter((file) => fs.existsSync(file))
682
+ : [];
683
+ for (const audienceFile of audienceFiles) {
684
+ const key = path.basename(path.dirname(audienceFile)).slice(1);
685
+ const text = fs.readFileSync(audienceFile, "utf8");
686
+ const match = text.match(/^principals:[\t ]*\[(.*)\]$/m);
687
+ const principals = match
688
+ ? match[1].split(",").map((item) => item.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, ""))
689
+ .map((item) => item.includes("@") ? item.toLowerCase() : item).filter(Boolean)
690
+ : [];
691
+ const prefixes = [`signals/@${key}/`];
692
+ if (fs.existsSync(path.join(companyDir, "ontology", "facts", `@${key}`)))
693
+ prefixes.push(`ontology/facts/@${key}/`);
694
+ const sources = path.join(companyDir, "sources");
695
+ if (fs.existsSync(sources)) {
696
+ for (const channel of fs.readdirSync(sources).sort(byteSort)) {
697
+ if (fs.existsSync(path.join(sources, channel, `@${key}`)))
698
+ prefixes.push(`sources/${channel}/@${key}/`);
699
+ }
700
+ }
701
+ for (const who of principals) {
702
+ if (!/^prs_|^agt_/.test(who) && !members.split("\n").includes(who)) {
703
+ stderr += `skip ${who} (not a member of ${company})\n`;
704
+ skipped += 1;
705
+ continue;
706
+ }
707
+ for (const prefix of prefixes)
708
+ grant(prefix, who);
709
+ }
710
+ }
711
+ if (fs.existsSync(path.join(companyDir, "ontology", "facts", "@company")))
712
+ grant("ontology/facts/@company/", "@all");
713
+ for (const type of ["action_item", "commitment", "decision", "risk", "question", "key_point", "participant_contribution", "summary"]) {
714
+ if (fs.existsSync(path.join(signalDir, type)))
715
+ grant(`signals/${type}/`, "@all");
716
+ }
717
+ stdout += `audience grants for ${company}: ${granted} granted, ${skipped} skipped, ${failed} failed\n`;
718
+ return { code: failed === 0 ? 0 : 3, stdout, stderr };
719
+ }
720
+ function itemList(file, field) {
721
+ if (!fs.existsSync(file))
722
+ return [];
723
+ const lines = fs.readFileSync(file, "utf8").split("\n");
724
+ let frontmatterLines = 0;
725
+ let inList = false;
726
+ const output = [];
727
+ for (const line of lines) {
728
+ if (line === "---") {
729
+ frontmatterLines += 1;
730
+ if (frontmatterLines === 2)
731
+ break;
732
+ continue;
733
+ }
734
+ if (frontmatterLines !== 1)
735
+ continue;
736
+ if (line.startsWith(`${field}:`)) {
737
+ let value = line.slice(field.length + 1).replace(/^[\t ]+/, "");
738
+ if (value.startsWith("[")) {
739
+ value = value.replaceAll("[", "").replaceAll("]", "").replaceAll('"', "").replaceAll("'", "");
740
+ for (const item of value.split(",")) {
741
+ const trimmed = item.replace(/^[\t ]+|[\t ]+$/g, "");
742
+ if (trimmed)
743
+ output.push(trimmed);
744
+ }
745
+ break;
746
+ }
747
+ inList = true;
748
+ continue;
749
+ }
750
+ if (inList && /^[\t ]*-[\t ]+/.test(line)) {
751
+ output.push(line.replace(/^[\t ]*-[\t ]+/, "").replace(/["']/g, ""));
752
+ continue;
753
+ }
754
+ if (inList)
755
+ break;
756
+ }
757
+ return output;
758
+ }
759
+ export function worklistCommand(args, root) {
760
+ let company = "";
761
+ let channel = "";
762
+ let limit = "50";
763
+ let force = false;
764
+ let doneItem = "";
765
+ for (let index = 0; index < args.length; index += 1) {
766
+ const key = args[index];
767
+ if (key === "--company" || key === "--channel" || key === "--limit" || key === "--mark-done") {
768
+ if (index + 1 >= args.length) {
769
+ const line = key === "--mark-done" ? 30 : 29;
770
+ return err(1, `${resolveBundledAsset(`${ASSET}ontology-source-worklist.sh`)}: line ${line}: $2: unbound variable\n`);
771
+ }
772
+ const value = args[++index];
773
+ if (key === "--company")
774
+ company = value;
775
+ else if (key === "--channel")
776
+ channel = value;
777
+ else if (key === "--limit")
778
+ limit = value;
779
+ else
780
+ doneItem = value;
781
+ }
782
+ else if (key === "--force")
783
+ force = true;
784
+ else
785
+ return err(2, `unknown flag ${key}\n`);
786
+ }
787
+ if (!company || !channel)
788
+ return err(2, "usage: ontology-source-worklist.sh --company <co> --channel <ch> [--limit N] [--force] [--mark-done <file>]\n");
789
+ const directory = path.join(root, "companies", company, "sources", channel);
790
+ const sourceFile = path.join(directory, "source.yaml");
791
+ const validation = sourceYamlValidate(sourceFile);
792
+ if (validation.code !== 0)
793
+ return err(2, `${validation.stderr}${validation.stdout}${validation.stderr}`);
794
+ const ledger = path.join(directory, ".processed");
795
+ if (doneItem) {
796
+ fs.appendFileSync(ledger, `${path.basename(doneItem)}\n`);
797
+ return ok();
798
+ }
799
+ const run = awkField(sourceFile, "run", false);
800
+ const rule = awkField(sourceFile, "audience_rule", false);
801
+ if (run === "cloud" && !force)
802
+ return err(4, "assigned to cloud agent (run: cloud); pass --force to run locally\n");
803
+ const processed = fs.existsSync(ledger) ? fs.readFileSync(ledger, "utf8").split("\n") : [];
804
+ const entries = fs.existsSync(directory)
805
+ ? fs.readdirSync(directory).filter((name) => name.endsWith(".md") && !name.startsWith("."))
806
+ .filter((name) => { try {
807
+ return fs.statSync(path.join(directory, name)).isFile();
808
+ }
809
+ catch {
810
+ return false;
811
+ } })
812
+ .sort(byteSort)
813
+ : [];
814
+ const output = [];
815
+ let count = 0;
816
+ for (const name of entries) {
817
+ if (processed.includes(name))
818
+ continue;
819
+ if (/^[+-]?\d+$/.test(limit) && count >= Number(limit))
820
+ break;
821
+ if (!/^[+-]?\d+$/.test(limit)) {
822
+ process.stderr.write(`${resolveBundledAsset(`${ASSET}ontology-source-worklist.sh`)}: line 61: [: ${limit}: integer expression expected\n`);
823
+ }
824
+ count += 1;
825
+ const file = path.join(directory, name);
826
+ const relative = `sources/${channel}/${name}`;
827
+ let audience;
828
+ if (rule === "company")
829
+ audience = "company";
830
+ else if (rule === "explicit")
831
+ audience = itemList(file, "audience").join(",");
832
+ else {
833
+ audience = itemList(file, "audience").join(",");
834
+ if (!audience)
835
+ audience = itemList(file, "attendees").filter((value) => /@|^(prs|agt)_/.test(value)).join(",");
836
+ if (!audience) {
837
+ const acl = runHq(["files", "--company", company, "acl", relative, "--json"]);
838
+ try {
839
+ const payload = JSON.parse(acl.stdout);
840
+ const principals = (payload.direct ?? []).filter((value) => {
841
+ if (!value || typeof value !== "object")
842
+ return false;
843
+ const row = value;
844
+ return ["person", "email", "agent"].includes(String(row.granteeType))
845
+ && ["read", "write", "admin"].includes(String(row.permission));
846
+ }).map((value) => String(value.granteeId ?? "")).sort(byteSort);
847
+ audience = [...new Set(principals)].join(",");
848
+ }
849
+ catch (parseError) {
850
+ // jq emits no stdout or stderr for malformed ACL JSON in this pipeline.
851
+ void parseError;
852
+ }
853
+ }
854
+ }
855
+ if (!audience) {
856
+ output.push(`${JSON.stringify({ file: relative, skip: "no-audience" })}\n`);
857
+ continue;
858
+ }
859
+ const key = shellSubstitution(audienceKey(audience).stdout);
860
+ output.push(`${JSON.stringify({ file: relative, audience_key: key, audience: audience.split(",") })}\n`);
861
+ }
862
+ return ok(output.join(""));
863
+ }
864
+ export function registerCoreOntologyNativeCommands(ontology, rootFor) {
865
+ const entries = [
866
+ { name: "audience-grant", summary: "Grant scoped ontology audiences and verify each grant", asset: "ontology-audience-grant.sh", root: "live", run: audienceGrant },
867
+ { name: "brief", summary: "Render the local ontology company brief", asset: "ontology-brief.mjs", root: "live", run: (args, root) => briefCommand(args, root) },
868
+ { name: "candidate", summary: "Write a deterministic ontology candidate", asset: "ontology-candidate.sh", root: "live", run: candidateCommand },
869
+ { name: "garden", summary: "Promote local ontology candidates", asset: "ontology-garden.mjs", root: "live", run: (args, root) => gardenCommand(args, root) },
870
+ { name: "source-worklist", summary: "List unprocessed source items and their audience", asset: "ontology-source-worklist.sh", root: "live", run: worklistCommand },
871
+ { name: "source-yaml-validate", summary: "Validate a source.yaml file", asset: "source-yaml-validate.sh", root: "cwd", run: (args) => sourceYamlValidate(args[0]) },
872
+ ];
873
+ const rawArgv = process.argv.slice(2);
874
+ const coreIndex = rawArgv.indexOf("core");
875
+ const ontologyIndex = rawArgv.indexOf("ontology", coreIndex + 1);
876
+ const requestedLeaf = rawArgv[ontologyIndex + 1];
877
+ const leafIndex = ontologyIndex + 1;
878
+ const rootArgIndex = rawArgv.indexOf("--hq-root", leafIndex + 1);
879
+ const rootArgValue = rootArgIndex >= 0 ? rawArgv[rootArgIndex + 1] : undefined;
880
+ if (entries.some((entry) => entry.name === requestedLeaf) && rootArgIndex >= 0
881
+ && (!rootArgValue || rootArgValue.startsWith("-"))) {
882
+ // `core --hq-root <path>` is a required parent option. Once one of these
883
+ // script commands has started, the same spelling belongs to the script's
884
+ // own argv and may intentionally have no value. Let the leaf reproduce the
885
+ // old script's parser instead of Commander rejecting it first.
886
+ const rootOption = ontology.parent?.options.find((option) => option.long === "--hq-root");
887
+ if (rootOption) {
888
+ rootOption.required = false;
889
+ rootOption.optional = true;
890
+ }
891
+ }
892
+ for (const entry of entries) {
893
+ const command = ontology.command(entry.name)
894
+ .description(entry.summary)
895
+ .enablePositionalOptions()
896
+ .allowUnknownOption()
897
+ .allowExcessArguments()
898
+ .helpOption(false)
899
+ .argument("[args...]", "arguments accepted for shell ABI compatibility")
900
+ .action((args = [], _options, commandContext) => {
901
+ const argv = process.argv.slice(2);
902
+ const coreIndex = argv.indexOf("core");
903
+ const ontologyIndex = argv.indexOf("ontology", coreIndex + 1);
904
+ const commandIndex = argv.indexOf(entry.name, ontologyIndex + 1);
905
+ const forwarded = commandIndex >= 0 ? argv.slice(commandIndex + 1)
906
+ : commandContext.args.length > 0 ? commandContext.args : args;
907
+ const liveRoot = entry.root === "live" ? rootFor() : process.cwd();
908
+ if (!resolveProcessFlagGate(FLAG, { fallback: false }, () => false)) {
909
+ const result = runBundledScript({
910
+ asset: `${ASSET}${entry.asset}`,
911
+ args: forwarded,
912
+ cwd: process.cwd(),
913
+ ...(entry.root === "live" ? { hqRoot: liveRoot } : {}),
914
+ });
915
+ if (result.code !== 0)
916
+ process.exitCode = result.code;
917
+ return;
918
+ }
919
+ writeResult(entry.run(forwarded, entry.root === "live" ? liveRoot : process.cwd()));
920
+ });
921
+ describeCoreCommand(command, { execution: "native", asset: `${ASSET}${entry.asset}`, root: entry.root });
922
+ }
923
+ }
924
+ //# sourceMappingURL=core-ontology-native.js.map