@indigoai-us/hq-cli 5.345.105 → 5.345.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assets/scaffold/core/scripts/hq-task.mjs +259 -0
  3. package/assets/scaffold/core/scripts/hq-task.sh +31 -0
  4. package/assets/scaffold/core/scripts/pipeline-worker-table.sh +285 -0
  5. package/dist/command-catalog.generated.d.ts +135 -5
  6. package/dist/command-catalog.generated.js +161 -5
  7. package/dist/commands/auth.js +42 -0
  8. package/dist/commands/core-hq-task-fast.d.ts +4 -0
  9. package/dist/commands/core-hq-task-fast.js +11 -0
  10. package/dist/commands/core-hq-task-native.d.ts +20 -0
  11. package/dist/commands/core-hq-task-native.js +275 -0
  12. package/dist/commands/core-native-bucket-c-flag.d.ts +3 -0
  13. package/dist/commands/core-native-bucket-c-flag.js +6 -0
  14. package/dist/commands/core-native-family-flag.d.ts +0 -2
  15. package/dist/commands/core-native-family-flag.js +0 -4
  16. package/dist/commands/core-native-pipeline-flag.d.ts +3 -0
  17. package/dist/commands/core-native-pipeline-flag.js +6 -0
  18. package/dist/commands/core-ontology-native.js +24 -30
  19. package/dist/commands/core-pipeline-workers-command.d.ts +3 -0
  20. package/dist/commands/core-pipeline-workers-command.js +37 -0
  21. package/dist/commands/core-pipeline-workers-native.d.ts +13 -0
  22. package/dist/commands/core-pipeline-workers-native.js +361 -0
  23. package/dist/commands/core.js +12 -10
  24. package/dist/commands/lanes.js +32 -7
  25. package/dist/commands/logout.d.ts +6 -1
  26. package/dist/commands/logout.js +18 -2
  27. package/dist/commands/outposts.d.ts +5 -0
  28. package/dist/commands/outposts.js +5 -0
  29. package/dist/fast-scaffold.d.ts +14 -1
  30. package/dist/fast-scaffold.js +149 -5
  31. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +56937 -64550
  32. package/dist/hq-cloud-sync-runner/package.json +1 -1
  33. package/dist/index.js +15 -1
  34. package/dist/lib/daemon/remote-control-pools.d.ts +2 -2
  35. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  36. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  37. package/dist/lib/lanes/reconcile.js +4 -0
  38. package/dist/lib/lanes/remote-control-lane.d.ts +33 -0
  39. package/dist/lib/lanes/remote-control-lane.js +276 -0
  40. package/dist/lib/lanes/spawn.d.ts +5 -0
  41. package/dist/lib/lanes/spawn.js +65 -14
  42. package/dist/lib/lanes/store.js +7 -0
  43. package/dist/lib/lanes/types.d.ts +2 -0
  44. package/dist/lib/lanes/types.js +1 -1
  45. package/dist/lib/lanes/watcher.js +8 -1
  46. package/dist/utils/cognito-config.d.ts +5 -0
  47. package/dist/utils/cognito-config.js +14 -0
  48. package/dist/utils/cognito-revoke.d.ts +33 -0
  49. package/dist/utils/cognito-revoke.js +42 -0
  50. package/dist/utils/cognito-session.d.ts +12 -4
  51. package/dist/utils/cognito-session.js +42 -9
  52. package/dist/utils/token-keychain.d.ts +96 -0
  53. package/dist/utils/token-keychain.js +218 -0
  54. package/package.json +2 -2
  55. package/assets/scaffold/core/scripts/ontology-audience-grant.sh +0 -81
  56. package/assets/scaffold/core/scripts/ontology-brief.mjs +0 -93
  57. package/assets/scaffold/core/scripts/ontology-candidate.sh +0 -100
  58. package/assets/scaffold/core/scripts/ontology-garden.mjs +0 -327
  59. package/assets/scaffold/core/scripts/ontology-source-worklist.sh +0 -226
  60. package/assets/scaffold/core/scripts/source-yaml-validate.sh +0 -36
@@ -1,93 +0,0 @@
1
- #!/usr/bin/env node
2
- // ontology-brief.mjs — render a company brief from the reader's LOCAL tree.
3
- // Spec: core/knowledge/public/hq-core/ontology-local-spec.md
4
- //
5
- // Usage: node core/scripts/ontology-brief.mjs --company <co> [--hq-root <p>] [--days N] [--top N] [--json]
6
- //
7
- // Reads ontology/entities/, every ontology/facts/@*/ and signals/{type}/ +
8
- // signals/@*/{type}/ present locally. Sync pull only delivers what the reader
9
- // may read, so the brief shows exactly their visible facts and signals.
10
- // Writes nothing. Prints markdown (default) or JSON. Exit 0 with an empty brief
11
- // ("no local ontology") when nothing is there.
12
- import fs from "node:fs";
13
- import path from "node:path";
14
-
15
- const TYPES = ["decision", "commitment", "risk", "question", "action_item", "key_point", "participant_contribution", "summary"];
16
- const args = process.argv.slice(2);
17
- const opt = { days: 14, top: 10, json: false };
18
- for (let i = 0; i < args.length; i++) {
19
- const k = args[i];
20
- if (k === "--company") opt.company = args[++i];
21
- else if (k === "--hq-root") opt.root = args[++i];
22
- else if (k === "--days") opt.days = Number(args[++i]);
23
- else if (k === "--top") opt.top = Number(args[++i]);
24
- else if (k === "--json") opt.json = true;
25
- else { console.error(`ontology-brief: unknown flag ${k}`); process.exit(2); }
26
- }
27
- if (!opt.company) { console.error("usage: ontology-brief.mjs --company <co> [--days N] [--top N] [--json]"); process.exit(2); }
28
- opt.root = opt.root || process.env.HQ_ROOT || path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
29
- const C = path.join(opt.root, "companies", opt.company);
30
- if (!fs.existsSync(C)) { console.error(`ontology-brief: no company ${opt.company}`); process.exit(2); }
31
-
32
- function fm(file) {
33
- const t = fs.readFileSync(file, "utf8");
34
- const m = t.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
35
- const out = { _body: (m ? m[2] : t).trim() };
36
- if (m) for (const line of m[1].split("\n")) {
37
- const mm = line.match(/^([A-Za-z_]+):\s*(.*)$/);
38
- if (mm) out[mm[1]] = mm[2].trim().replace(/^"(.*)"$/, "$1");
39
- }
40
- return out;
41
- }
42
- const walk = (dir, pred) => {
43
- if (!fs.existsSync(dir)) return [];
44
- const out = [];
45
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
46
- const p = path.join(dir, e.name);
47
- if (e.isDirectory()) { if (!e.name.startsWith("_")) out.push(...walk(p, pred)); }
48
- else if (pred(p)) out.push(p);
49
- }
50
- return out;
51
- };
52
-
53
- const entities = walk(path.join(C, "ontology", "entities"), p => p.endsWith(".md")).map(f => fm(f));
54
- const cutoff = Date.now() - opt.days * 86400e3;
55
- const signals = walk(path.join(C, "signals"), p => p.endsWith(".md") && !p.includes(`${path.sep}_`))
56
- .map(f => ({ ...fm(f), _scope: f.includes(`${path.sep}signals${path.sep}@`) ? "scoped" : "company" }))
57
- .filter(s => TYPES.includes(s.type));
58
- const recent = signals.filter(s => !s.created_at || Date.parse(s.created_at) >= cutoff)
59
- .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
60
- const facts = new Map(); // slug -> [{scope, line}]
61
- for (const f of walk(path.join(C, "ontology", "facts"), p => p.endsWith(".md"))) {
62
- const d = fm(f); const scope = f.includes(`${path.sep}@company${path.sep}`) ? "company" : "scoped";
63
- const slug = d.entity || path.basename(f, ".md");
64
- for (const line of d._body.split("\n").filter(l => l.startsWith("- "))) {
65
- if (!facts.has(slug)) facts.set(slug, []);
66
- facts.get(slug).push({ scope, line: line.slice(2) });
67
- }
68
- }
69
- const hot = entities.map(e => ({ ...e, _facts: facts.get(e.slug) || [] }))
70
- .sort((a, b) => (b._facts.length - a._facts.length) || ((+b.signal_count || 0) - (+a.signal_count || 0)))
71
- .slice(0, opt.top);
72
-
73
- if (opt.json) {
74
- console.log(JSON.stringify({ company: opt.company, entities: entities.length, signals: signals.length,
75
- recent: recent.slice(0, opt.top * 2).map(s => ({ type: s.type, scope: s._scope, text: s.canonical_content || s._body })),
76
- hot: hot.map(e => ({ name: e.canonical_name, type: e.type, facts: e._facts })) }));
77
- process.exit(0);
78
- }
79
- if (!entities.length && !signals.length) { console.log(`# ${opt.company} — no local ontology yet\n\nNothing under ontology/ or signals/ that you can see.`); process.exit(0); }
80
- const out = [`# ${opt.company} — company brief (local)`, "", `${entities.length} entities · ${signals.length} signals you can see · last ${opt.days} days`, "", "## Recent Signals"];
81
- for (const t of TYPES) {
82
- const rows = recent.filter(s => s.type === t).slice(0, 5);
83
- if (!rows.length) continue;
84
- out.push("", `### ${t.replace("_", " ")}`);
85
- for (const s of rows) out.push(`- ${s.canonical_content || s._body} _(${s._scope})_`);
86
- }
87
- out.push("", "## Entities");
88
- for (const e of hot) {
89
- out.push("", `### ${e.canonical_name} — ${e.type}`);
90
- if (!e._facts.length) out.push("- no facts you can see");
91
- for (const f of e._facts.slice(0, 6)) out.push(`- ${f.line} _(${f.scope})_`);
92
- }
93
- console.log(out.join("\n"));
@@ -1,100 +0,0 @@
1
- #!/usr/bin/env bash
2
- # ontology-candidate.sh — write one signal or entity candidate for the ontology
3
- # worker to garden. Deterministic, no model call. Spec:
4
- # core/knowledge/public/hq-core/ontology-local-spec.md
5
- #
6
- # Usage:
7
- # ontology-candidate.sh write --company <co> --kind signal|entity --type <t> \
8
- # --audience <company|email[,email...]> --source-ref <ref> \
9
- # (--body-file <path> | --body <text>) [--created-by <email>]
10
- # ontology-candidate.sh key <company|email[,email...]> # print the audience key
11
- #
12
- # Exit 0: written, already present (no-op), or capture disabled for the company.
13
- # Exit 2: bad usage, unknown type, or empty body.
14
- set -euo pipefail
15
-
16
- root="${HQ_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
17
- here="$(cd "$(dirname "$0")" && pwd)"
18
-
19
- SIGNAL_TYPES="action_item commitment decision risk question key_point participant_contribution summary"
20
- ENTITY_TYPES="person project company concept"
21
-
22
- die() { echo "ontology-candidate: $*" >&2; exit 2; }
23
-
24
- sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}'; else shasum -a 256 | awk '{print $1}'; fi; }
25
-
26
- normalize_audience() { # stdout: sorted, lowercased, trimmed, comma-joined
27
- # Emails are lowercased; person/agent uids (prs_…, agt_…) keep their case.
28
- printf '%s' "$1" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
29
- | awk '/@/ { print tolower($0); next } { print }' \
30
- | grep -v '^$' | LC_ALL=C sort -u | paste -sd, -
31
- }
32
-
33
- audience_key() {
34
- local a; a="$(printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
35
- [ -n "$a" ] || die "empty audience"
36
- if [ "$(printf '%s' "$a" | tr '[:upper:]' '[:lower:]')" = "company" ]; then echo company; return; fi
37
- printf '%s' "$(normalize_audience "$a")" | sha | cut -c1-16
38
- }
39
-
40
- cmd="${1:-}"; shift || true
41
- case "$cmd" in
42
- key) audience_key "${1:-}"; exit 0 ;;
43
- write) ;;
44
- *) die "usage: ontology-candidate.sh write|key ..." ;;
45
- esac
46
-
47
- co="" kind="" type="" audience="" ref="" body="" body_file="" by="${HQ_USER_EMAIL:-}"
48
- while [ $# -gt 0 ]; do
49
- case "$1" in
50
- --company) co="$2"; shift 2 ;;
51
- --kind) kind="$2"; shift 2 ;;
52
- --type) type="$2"; shift 2 ;;
53
- --audience) audience="$2"; shift 2 ;;
54
- --source-ref) ref="$2"; shift 2 ;;
55
- --body) body="$2"; shift 2 ;;
56
- --body-file) body_file="$2"; shift 2 ;;
57
- --created-by) by="$2"; shift 2 ;;
58
- *) die "unknown flag $1" ;;
59
- esac
60
- done
61
- [ -n "$co" ] && [ -n "$kind" ] && [ -n "$type" ] && [ -n "$audience" ] && [ -n "$ref" ] || die "missing required flag"
62
- [ -n "$body_file" ] && { [ -f "$body_file" ] || die "no body file $body_file"; body="$(cat "$body_file")"; }
63
- body="$(printf '%s' "$body" | tr '\n' ' ' | sed 's/[[:space:]]\{1,\}/ /g;s/^ //;s/ $//')"
64
- [ -n "$body" ] || die "empty body"
65
-
66
- case "$kind" in
67
- signal) allowed="$SIGNAL_TYPES"; flag=signals_capture; store=signals ;;
68
- entity) allowed="$ENTITY_TYPES"; flag=ontology_capture; store=ontology ;;
69
- *) die "kind must be signal or entity" ;;
70
- esac
71
- case " $allowed " in *" $type "*) ;; *) die "unknown $kind type '$type' (allowed: $allowed)" ;; esac
72
-
73
- enabled="$(HQ_ROOT="$root" bash "$here/knowledge-prefs.sh" get "$co" "$flag")" || exit 2
74
- if [ "$enabled" != "true" ]; then echo "disabled for $co ($flag=false)"; exit 0; fi
75
-
76
- key="$(audience_key "$audience")"
77
- norm_body="$(printf '%s' "$body" | tr '[:upper:]' '[:lower:]')"
78
- id="$(printf '%s\n%s\n%s\n%s' "$kind" "$type" "$key" "$norm_body" | sha)"
79
-
80
- dir="$root/companies/$co/$store/_candidates/$(date -u +%Y-%m-%d)"
81
- # Idempotent across days: the same candidate already written (pending or done) is a no-op.
82
- if [ -n "$(find "$root/companies/$co/$store/_candidates" -name "$id.md" -print -quit 2>/dev/null)" ]; then
83
- echo "exists $id"; exit 0
84
- fi
85
- mkdir -p "$dir"
86
- {
87
- echo "---"
88
- echo "kind: $kind"
89
- echo "type: $type"
90
- echo "audience_key: $key"
91
- if [ "$key" != "company" ]; then
92
- echo "audience: [$(normalize_audience "$audience" | sed 's/,/, /g')]"
93
- fi
94
- echo "source_ref: \"$ref\""
95
- [ -n "$by" ] && echo "created_by: $by"
96
- echo "created_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
97
- echo "---"
98
- echo "$body"
99
- } > "$dir/$id.md.tmp" && mv "$dir/$id.md.tmp" "$dir/$id.md"
100
- echo "wrote $store/_candidates/$(basename "$dir")/$id.md"
@@ -1,327 +0,0 @@
1
- #!/usr/bin/env node
2
- // ontology-garden.mjs — promote signal and entity candidates into a company's
3
- // local ontology and signal stores. Deterministic: no model call, no network.
4
- // Spec: core/knowledge/public/hq-core/ontology-local-spec.md
5
- //
6
- // Usage: node core/scripts/ontology-garden.mjs --company <co> [--hq-root <path>] [--dry-run] [--json]
7
- //
8
- // What it does, in order:
9
- // 1. Entity candidates -> ontology/entities/{type}/{slug}.md (identity only).
10
- // Stopword names and a name already held by an entity of another type are
11
- // rejected to ontology/_rejected/{date}.jsonl.
12
- // 2. Signal candidates -> signals/{type}/{sha}.md (audience company) or
13
- // signals/@{key}/{type}/{sha}.md (scoped), plus signals/@{key}/_audience.yaml
14
- // and signals/_index/{date}.json.
15
- // 3. Facts -> for each promoted signal, every known entity whose
16
- // name or alias appears in it gets a line in
17
- // ontology/facts/@{key}/{type}/{slug}.md (never in the entity file).
18
- // Company-audience signals bump the entity's signal_count.
19
- // 4. Processed candidates move to _candidates/_done/{date}/; .last-run is
20
- // bumped only when at least one candidate was processed.
21
- // A second run over the same input changes nothing.
22
- import fs from "node:fs";
23
- import path from "node:path";
24
-
25
- const STOPWORDS = new Set([
26
- "for", "calls", "call", "context", "summary", "reason", "the", "a", "an", "and",
27
- "or", "this", "that", "it", "we", "they", "you", "i", "notes", "meeting", "update",
28
- "question", "decision", "risk", "todo", "none", "n/a", "unknown",
29
- ]);
30
- const SIGNAL_TYPES = new Set(["action_item", "commitment", "decision", "risk", "question",
31
- "key_point", "participant_contribution", "summary"]);
32
- const ENTITY_TYPES = new Set(["person", "project", "company", "concept"]);
33
-
34
- function parseArgs(argv) {
35
- const a = { dryRun: false, json: false };
36
- for (let i = 0; i < argv.length; i++) {
37
- const k = argv[i];
38
- if (k === "--company") a.company = argv[++i];
39
- else if (k === "--hq-root") a.root = argv[++i];
40
- else if (k === "--dry-run") a.dryRun = true;
41
- else if (k === "--json") a.json = true;
42
- else { console.error(`ontology-garden: unknown flag ${k}`); process.exit(2); }
43
- }
44
- if (!a.company) { console.error("usage: ontology-garden.mjs --company <co> [--hq-root <p>] [--dry-run] [--json]"); process.exit(2); }
45
- a.root = a.root || process.env.HQ_ROOT || path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
46
- return a;
47
- }
48
-
49
- // Minimal frontmatter reader for the flat key: value shape the writers emit.
50
- function readDoc(file) {
51
- const text = fs.readFileSync(file, "utf8");
52
- const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
53
- if (!m) return { fm: {}, body: text.trim() };
54
- const fm = {};
55
- for (const line of m[1].split("\n")) {
56
- const mm = line.match(/^([A-Za-z_]+):\s*(.*)$/);
57
- if (!mm) continue;
58
- let v = mm[2].trim();
59
- if (v.startsWith("[") && v.endsWith("]")) v = v.slice(1, -1).split(",").map(s => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
60
- else v = v.replace(/^["']|["']$/g, "");
61
- fm[mm[1]] = v;
62
- }
63
- return { fm, body: m[2].trim() };
64
- }
65
-
66
- function writeDoc(file, fm, body, dry) {
67
- if (dry) return;
68
- const lines = ["---"];
69
- for (const [k, v] of Object.entries(fm)) {
70
- if (v === undefined || v === null || v === "") continue;
71
- lines.push(`${k}: ${Array.isArray(v) ? `[${v.join(", ")}]` : v}`);
72
- }
73
- lines.push("---", body, "");
74
- fs.mkdirSync(path.dirname(file), { recursive: true });
75
- const tmp = `${file}.tmp`;
76
- fs.writeFileSync(tmp, lines.join("\n"));
77
- fs.renameSync(tmp, file);
78
- }
79
-
80
- const slugify = s => s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80);
81
- const today = () => new Date().toISOString().slice(0, 10);
82
- const now = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
83
- const yyyyMm = () => new Date().toISOString().slice(0, 7);
84
-
85
- // ── Fact-file compaction ─────────────────────────────────────────────
86
- // Fact pages append one line per signal-mention of an entity (see step 3
87
- // below). Without a bound a hot entity accumulates every mention it has
88
- // ever received, so the page becomes a flat log. Compaction keeps the
89
- // most recent N lines inline and rolls the older ones into a dated,
90
- // append-only archive next to the fact file. `signal_count` on the
91
- // entity itself is unaffected — it is the true total.
92
- const BACKREF_COMPACT_THRESHOLD = Number(process.env.HQ_ONTOLOGY_BACKREF_THRESHOLD || 50);
93
- const BACKREF_KEEP_INLINE = Number(process.env.HQ_ONTOLOGY_BACKREF_KEEP_INLINE || 25);
94
- const SUMMARY_MARKER = "<!-- backref-archive -->";
95
-
96
- function isBackrefLine(line) { return /^- \[[a-z_]+\] .+ \(signal [0-9a-f]{12}\)\s*$/.test(line); }
97
- function isSummaryLine(line) { return line.includes(SUMMARY_MARKER); }
98
-
99
- function readArchiveIndex(file) {
100
- if (!fs.existsSync(file)) return { version: 1, signalIds: [] };
101
- try {
102
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
103
- if (parsed && Array.isArray(parsed.signalIds)) {
104
- return { version: 1, signalIds: parsed.signalIds.filter(s => typeof s === "string") };
105
- }
106
- } catch (err) {
107
- console.warn(`ontology-garden: archive index unreadable at ${file}: ${err.message || err}; skipping compaction`);
108
- }
109
- // Present but unreadable: never compact on top of it, or the rewrite would
110
- // drop every archived id it lists.
111
- return { version: 1, signalIds: [], unreadable: true };
112
- }
113
-
114
- // Compact `body` when its backref-line count exceeds the threshold.
115
- // Returns { body, archivedLines, archivedIds, changed }.
116
- function compactFactBody(body, { slug, archivedTotalBefore, dry }) {
117
- const lines = body.split("\n").map(l => l.trimEnd()).filter(l => l.length > 0);
118
- const backrefLines = lines.filter(isBackrefLine);
119
- const other = lines.filter(l => !isBackrefLine(l) && !isSummaryLine(l));
120
- if (backrefLines.length <= BACKREF_COMPACT_THRESHOLD) {
121
- return { body, archivedLines: [], archivedIds: [], changed: false };
122
- }
123
- const archiveCount = backrefLines.length - BACKREF_KEEP_INLINE;
124
- const archivedLines = backrefLines.slice(0, archiveCount);
125
- const inlineLines = backrefLines.slice(archiveCount);
126
- const archivedIds = archivedLines
127
- .map(l => (l.match(/\(signal ([0-9a-f]{12})\)$/) || [])[1])
128
- .filter(Boolean);
129
- const archivedTotal = archivedTotalBefore + archivedLines.length;
130
- const stamp = yyyyMm();
131
- const summary = `- ${SUMMARY_MARKER} _${archivedTotal} older mention${archivedTotal === 1 ? "" : "s"} archived in [history](./${slug}.history/${stamp}.md)_`;
132
- const nextBody = [summary, ...other, ...inlineLines].join("\n");
133
- return { body: nextBody, archivedLines, archivedIds, changed: true, stamp };
134
- }
135
-
136
- function appendArchiveFile(file, isoDate, lines, dry) {
137
- if (dry) return;
138
- const batch = `\n<!-- archived ${isoDate} -->\n${lines.join("\n")}\n`;
139
- const prev = fs.existsSync(file) ? fs.readFileSync(file, "utf8").replace(/\s+$/, "") : "";
140
- const next = prev ? `${prev}\n${batch}` : `# Back-reference archive\n${batch}`;
141
- fs.mkdirSync(path.dirname(file), { recursive: true });
142
- const tmp = `${file}.tmp`;
143
- fs.writeFileSync(tmp, next);
144
- fs.renameSync(tmp, file);
145
- }
146
-
147
- function listCandidates(dir) {
148
- if (!fs.existsSync(dir)) return [];
149
- const out = [];
150
- for (const d of fs.readdirSync(dir).sort()) {
151
- if (d === "_done") continue;
152
- const full = path.join(dir, d);
153
- if (!fs.statSync(full).isDirectory()) continue;
154
- for (const f of fs.readdirSync(full).sort()) if (f.endsWith(".md")) out.push(path.join(full, f));
155
- }
156
- return out;
157
- }
158
-
159
- function main() {
160
- const a = parseArgs(process.argv.slice(2));
161
- const backrefThreshold = Number(process.env.HQ_ONTOLOGY_BACKREF_THRESHOLD || 50);
162
- const backrefKeepInline = Number(process.env.HQ_ONTOLOGY_BACKREF_KEEP_INLINE || 25);
163
- if (!(backrefThreshold > backrefKeepInline)) {
164
- console.error(`ontology-garden: BACKREF_THRESHOLD (${backrefThreshold}) must be greater than BACKREF_KEEP_INLINE (${backrefKeepInline})`);
165
- process.exit(1);
166
- }
167
- const co = path.join(a.root, "companies", a.company);
168
- if (!fs.existsSync(co)) { console.error(`ontology-garden: no company ${a.company}`); process.exit(2); }
169
- const ont = path.join(co, "ontology"), sig = path.join(co, "signals");
170
- const report = { company: a.company, entities_created: 0, entities_updated: 0, signals_promoted: 0,
171
- facts_written: 0, rejected: 0, candidates_processed: 0, dry_run: a.dryRun };
172
- const rejected = [];
173
- const moves = [];
174
-
175
- // Load the current entity index: slug -> {type, file, fm, names[]}
176
- const entities = new Map();
177
- const nameIndex = new Map(); // lowercased name/alias -> slug
178
- const entDir = path.join(ont, "entities");
179
- if (fs.existsSync(entDir)) for (const t of fs.readdirSync(entDir)) {
180
- const td = path.join(entDir, t);
181
- if (!fs.statSync(td).isDirectory()) continue;
182
- for (const f of fs.readdirSync(td)) {
183
- if (!f.endsWith(".md")) continue;
184
- const file = path.join(td, f); const { fm } = readDoc(file);
185
- const slug = fm.slug || f.replace(/\.md$/, "");
186
- const names = [fm.canonical_name, ...(Array.isArray(fm.aliases) ? fm.aliases : [])].filter(Boolean);
187
- entities.set(slug, { type: fm.type || t, file, fm, names });
188
- for (const n of names) nameIndex.set(n.toLowerCase(), slug);
189
- }
190
- }
191
-
192
- // 1. Entity candidates
193
- for (const file of listCandidates(path.join(ont, "_candidates"))) {
194
- const { fm, body } = readDoc(file);
195
- const name = body.split("\n")[0].trim();
196
- const type = fm.type;
197
- const reject = reason => { rejected.push({ at: now(), name, type, reason, candidate: path.relative(co, file) }); report.rejected++; };
198
- if (!ENTITY_TYPES.has(type)) reject("unknown-type");
199
- else if (!name || STOPWORDS.has(name.toLowerCase()) || name.length < 2) reject("stopword");
200
- else {
201
- const slug = slugify(name);
202
- const held = nameIndex.get(name.toLowerCase());
203
- const existing = entities.get(held || slug);
204
- if (existing && existing.type !== type) reject(`type-conflict:${existing.type}`);
205
- else if (existing) {
206
- const aliases = new Set(Array.isArray(existing.fm.aliases) ? existing.fm.aliases : []);
207
- if (name !== existing.fm.canonical_name && !aliases.has(name)) {
208
- aliases.add(name); existing.fm.aliases = [...aliases]; existing.fm.last_updated = now();
209
- writeDoc(existing.file, existing.fm, "", a.dryRun); report.entities_updated++;
210
- nameIndex.set(name.toLowerCase(), held || slug);
211
- }
212
- } else {
213
- const efile = path.join(ont, "entities", type, `${slug}.md`);
214
- const efm = { type, canonical_name: name, slug, aliases: [], signal_count: 0,
215
- first_seen: fm.created_at || now(), last_updated: now(), enriched_by: "ontology-worker" };
216
- writeDoc(efile, efm, "", a.dryRun);
217
- entities.set(slug, { type, file: efile, fm: efm, names: [name] });
218
- nameIndex.set(name.toLowerCase(), slug);
219
- report.entities_created++;
220
- }
221
- }
222
- moves.push(file); report.candidates_processed++;
223
- }
224
-
225
- // 2 + 3. Signal candidates -> signals + facts
226
- const index = [];
227
- for (const file of listCandidates(path.join(sig, "_candidates"))) {
228
- const { fm, body } = readDoc(file);
229
- const type = fm.type; const key = fm.audience_key;
230
- moves.push(file); report.candidates_processed++;
231
- if (!SIGNAL_TYPES.has(type) || !key || !body) {
232
- rejected.push({ at: now(), type, reason: !key ? "missing-audience" : "invalid", candidate: path.relative(co, file) }); report.rejected++;
233
- continue;
234
- }
235
- if (key !== "company" && !(Array.isArray(fm.audience) && fm.audience.length)) {
236
- rejected.push({ at: now(), type, reason: "scoped-without-principals", candidate: path.relative(co, file) }); report.rejected++;
237
- continue;
238
- }
239
- const id = path.basename(file, ".md");
240
- const scopeDir = key === "company" ? sig : path.join(sig, `@${key}`);
241
- const out = path.join(scopeDir, type, `${id}.md`);
242
- if (!fs.existsSync(out)) {
243
- writeDoc(out, { signal_id: id, type, canonical_content: JSON.stringify(body.split("\n")[0]), audience_key: key,
244
- source_ref: JSON.stringify(fm.source_ref || ""), created_at: fm.created_at || now() }, body, a.dryRun);
245
- report.signals_promoted++;
246
- index.push({ signal_id: id, type, audience_key: key, path: path.relative(co, out) });
247
- if (key !== "company") {
248
- const audFile = path.join(scopeDir, "_audience.yaml");
249
- if (!fs.existsSync(audFile) && !a.dryRun) {
250
- fs.mkdirSync(scopeDir, { recursive: true });
251
- fs.writeFileSync(audFile, `audience_key: ${key}\nprincipals: [${fm.audience.join(", ")}]\n`);
252
- }
253
- }
254
- // Facts: every known entity named in the signal.
255
- const lower = body.toLowerCase();
256
- const hit = new Set();
257
- for (const [n, slug] of nameIndex) {
258
- if (n.length < 3) continue;
259
- const re = new RegExp(`(^|[^\\p{L}\\p{N}])${n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{N}])`, "u");
260
- if (re.test(lower)) hit.add(slug);
261
- }
262
- for (const slug of hit) {
263
- const e = entities.get(slug);
264
- const ffile = path.join(ont, "facts", `@${key}`, e.type, `${slug}.md`);
265
- const sid12 = id.slice(0, 12);
266
- const line = `- [${type}] ${body.split("\n")[0]} (signal ${sid12})`;
267
- const prev = fs.existsSync(ffile) ? readDoc(ffile) : { fm: { entity: slug, type: e.type, audience_key: key }, body: "" };
268
- const idxFile = path.join(path.dirname(ffile), `${slug}.history`, "_index.json");
269
- const archiveIndex = readArchiveIndex(idxFile);
270
- const alreadyArchived = archiveIndex.signalIds.includes(sid12);
271
- if (!prev.body.includes(`signal ${sid12}`) && !alreadyArchived) {
272
- let nextBody = [prev.body, line].filter(Boolean).join("\n");
273
- const compact = compactFactBody(nextBody, {
274
- slug,
275
- archivedTotalBefore: archiveIndex.signalIds.length,
276
- dry: a.dryRun,
277
- });
278
- if (compact.changed && !archiveIndex.unreadable) {
279
- const archiveFile = path.join(path.dirname(ffile), `${slug}.history`, `${compact.stamp}.md`);
280
- appendArchiveFile(archiveFile, today(), compact.archivedLines, a.dryRun);
281
- if (!a.dryRun) {
282
- const nextIdx = { version: 1, signalIds: [...archiveIndex.signalIds, ...compact.archivedIds] };
283
- fs.mkdirSync(path.dirname(idxFile), { recursive: true });
284
- fs.writeFileSync(idxFile, JSON.stringify(nextIdx) + "\n");
285
- }
286
- nextBody = compact.body;
287
- }
288
- writeDoc(ffile, prev.fm, nextBody, a.dryRun);
289
- report.facts_written++;
290
- }
291
- if (key === "company") {
292
- e.fm.signal_count = String((parseInt(e.fm.signal_count || "0", 10) || 0) + 1);
293
- e.fm.last_updated = now();
294
- writeDoc(e.file, e.fm, "", a.dryRun);
295
- }
296
- }
297
- }
298
- }
299
-
300
- if (!a.dryRun) {
301
- if (index.length) {
302
- const f = path.join(sig, "_index", `${today()}.json`);
303
- const prev = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : [];
304
- fs.mkdirSync(path.dirname(f), { recursive: true });
305
- fs.writeFileSync(f, JSON.stringify([...prev, ...index], null, 2) + "\n");
306
- }
307
- if (rejected.length) {
308
- const f = path.join(ont, "_rejected", `${today()}.jsonl`);
309
- fs.mkdirSync(path.dirname(f), { recursive: true });
310
- fs.appendFileSync(f, rejected.map(r => JSON.stringify(r)).join("\n") + "\n");
311
- }
312
- for (const file of moves) {
313
- const rel = path.relative(path.dirname(path.dirname(file)), file); // {date}/{sha}.md
314
- const dest = path.join(path.dirname(path.dirname(file)), "_done", rel);
315
- fs.mkdirSync(path.dirname(dest), { recursive: true });
316
- fs.renameSync(file, dest);
317
- }
318
- if (report.candidates_processed > 0) {
319
- fs.mkdirSync(ont, { recursive: true });
320
- fs.writeFileSync(path.join(ont, ".last-run"), `${Date.now()}\n`);
321
- }
322
- }
323
- if (a.json) console.log(JSON.stringify(report));
324
- else console.log(`garden ${a.company}: +${report.entities_created} entities, ~${report.entities_updated} updated, +${report.signals_promoted} signals, +${report.facts_written} facts, ${report.rejected} rejected, ${report.candidates_processed} candidates${a.dryRun ? " (dry run)" : ""}`);
325
- }
326
-
327
- main();