@indigoai-us/hq-cli 5.280.0 → 5.282.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/assets/scaffold/core/scripts/knowledge-prefs.sh +55 -0
- package/assets/scaffold/core/scripts/ontology-audience-grant.sh +81 -0
- package/assets/scaffold/core/scripts/ontology-brief.mjs +93 -0
- package/assets/scaffold/core/scripts/ontology-candidate.sh +100 -0
- package/assets/scaffold/core/scripts/ontology-garden.mjs +238 -0
- package/assets/scaffold/core/scripts/ontology-source-worklist.sh +79 -0
- package/assets/scaffold/core/scripts/source-yaml-validate.sh +36 -0
- package/dist/command-catalog.generated.d.ts +80 -2
- package/dist/command-catalog.generated.js +98 -2
- package/dist/commands/auth.js +40 -2
- package/dist/commands/core-ontology-native.d.ts +16 -0
- package/dist/commands/core-ontology-native.js +924 -0
- package/dist/commands/core-ontology.js +11 -0
- package/dist/commands/core.js +9 -0
- package/dist/commands/group-grants.js +3 -2
- package/dist/commands/lanes-message.js +1 -0
- package/dist/commands/lanes-move.js +2 -0
- package/dist/commands/lanes-resume.js +1 -0
- package/dist/commands/lanes-shared.d.ts +6 -0
- package/dist/commands/lanes.d.ts +1 -0
- package/dist/commands/lanes.js +283 -13
- package/dist/lib/lanes/keep-alive.js +1 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +3 -1
- package/dist/lib/lanes/outpost-dispatch.js +4 -0
- package/dist/lib/lanes/reconcile.d.ts +7 -1
- package/dist/lib/lanes/reconcile.js +88 -16
- package/dist/lib/lanes/spawn.js +5 -0
- package/dist/lib/lanes/store.js +38 -0
- package/dist/lib/lanes/throttle.d.ts +29 -2
- package/dist/lib/lanes/throttle.js +145 -5
- package/dist/lib/lanes/types.d.ts +12 -1
- package/dist/lib/lanes/types.js +3 -2
- package/dist/lib/lanes/watcher.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.282.0] — 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Pin the third-party package setup action in the fast-route benchmark workflow to its resolved commit.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `hq lanes reconcile` admits every queued lane that fits its caps in one pass
|
|
14
|
+
once CPU throttle clears or the starvation bound is reached.
|
|
15
|
+
- Reconcile compares cumulative CPU counters between passes and records the
|
|
16
|
+
measurement source; a queued Outpost lane with a changed worker profile now
|
|
17
|
+
reports the expected and actual hashes plus the senior re-pin command without
|
|
18
|
+
blocking later eligible lanes.
|
|
19
|
+
|
|
20
|
+
## [5.281.0] — 2026-09-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Add native TypeScript handlers for six `hq core ontology` commands. The new path is gated by `core-native-ontology`, which defaults off; the original scripts stay bundled for rollback.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- The default-off `lanes.remote-done-mirror` hq-flags gate refreshes nonterminal
|
|
29
|
+
Outpost mirrors before hire admission and during reconcile. It copies the
|
|
30
|
+
owner's terminal state and envelope summary to the primary. Unreachable
|
|
31
|
+
mirrors stay counted and are reported as unverified. `hq lanes logs` reads
|
|
32
|
+
logs from the owning Outpost.
|
|
33
|
+
|
|
5
34
|
## [5.280.0] — 2026-09-29
|
|
6
35
|
|
|
7
36
|
### Fixed
|
|
@@ -10,6 +39,8 @@
|
|
|
10
39
|
defaulting to 120 seconds. `hq lanes list` capacity probes use a separate
|
|
11
40
|
15-second timeout. A lane ID with a registered host suffix routes to that
|
|
12
41
|
Outpost first.
|
|
42
|
+
- hq group-grants outbound help now makes --group required. hq auth status
|
|
43
|
+
displays token expiry as a UTC time and relative duration.
|
|
13
44
|
|
|
14
45
|
## [5.279.0] — 2026-09-29
|
|
15
46
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# knowledge-prefs.sh — resolve a company's knowledge-store preference.
|
|
3
|
+
#
|
|
4
|
+
# Usage: knowledge-prefs.sh get <company> <field>
|
|
5
|
+
#
|
|
6
|
+
# Resolution, per field, first match wins:
|
|
7
|
+
# 1. companies/<company>/settings/knowledge/preferences.yaml
|
|
8
|
+
# 2. personal/settings/knowledge-preferences.yaml
|
|
9
|
+
# 3. built-in default (below)
|
|
10
|
+
#
|
|
11
|
+
# Built-in defaults:
|
|
12
|
+
# signals_enabled true readers (/signals) look for signals
|
|
13
|
+
# ontology_enabled true readers (/ontology) look for ontology
|
|
14
|
+
# signals_capture false session-close skills write signal candidates
|
|
15
|
+
# ontology_capture false session-close skills write entity candidates
|
|
16
|
+
# meeting_notes_source hq-native
|
|
17
|
+
# notetaker recall
|
|
18
|
+
#
|
|
19
|
+
# Capture is opt-in per company, so installs that never set it see no change.
|
|
20
|
+
# Only top-level scalar keys are read; a commented-out line does not count.
|
|
21
|
+
# Exits 2 on bad usage, unknown field, or missing company directory.
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
usage() { echo "usage: knowledge-prefs.sh get <company> <field>" >&2; exit 2; }
|
|
25
|
+
[ "${1:-}" = "get" ] || usage
|
|
26
|
+
co="${2:-}"; field="${3:-}"
|
|
27
|
+
[ -n "$co" ] && [ -n "$field" ] || usage
|
|
28
|
+
|
|
29
|
+
root="${HQ_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
|
|
30
|
+
|
|
31
|
+
case "$field" in
|
|
32
|
+
signals_enabled|ontology_enabled) default=true ;;
|
|
33
|
+
signals_capture|ontology_capture) default=false ;;
|
|
34
|
+
meeting_notes_source) default=hq-native ;;
|
|
35
|
+
notetaker) default=recall ;;
|
|
36
|
+
*) echo "knowledge-prefs: unknown field '$field'" >&2; exit 2 ;;
|
|
37
|
+
esac
|
|
38
|
+
|
|
39
|
+
[ -d "$root/companies/$co" ] || { echo "knowledge-prefs: no company '$co'" >&2; exit 2; }
|
|
40
|
+
|
|
41
|
+
read_field() {
|
|
42
|
+
local file="$1"
|
|
43
|
+
[ -f "$file" ] || return 1
|
|
44
|
+
awk -v k="$field" '
|
|
45
|
+
$0 ~ "^"k":" {
|
|
46
|
+
sub("^"k":[[:space:]]*", ""); sub("[[:space:]]+#.*$", ""); gsub(/^["\x27]|["\x27]$/, "")
|
|
47
|
+
if (length($0)) { print; found=1; exit }
|
|
48
|
+
}
|
|
49
|
+
END { exit found ? 0 : 1 }
|
|
50
|
+
' "$file"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
read_field "$root/companies/$co/settings/knowledge/preferences.yaml" \
|
|
54
|
+
|| read_field "$root/personal/settings/knowledge-preferences.yaml" \
|
|
55
|
+
|| echo "$default"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ontology-audience-grant.sh — grant each scoped audience folder to exactly the
|
|
3
|
+
# people in its audience, and prove every grant by readback.
|
|
4
|
+
# Spec: core/knowledge/public/hq-core/ontology-local-spec.md
|
|
5
|
+
#
|
|
6
|
+
# Usage: ontology-audience-grant.sh --company <co> [--dry-run]
|
|
7
|
+
#
|
|
8
|
+
# For every signals/@{key}/_audience.yaml under companies/<co>/:
|
|
9
|
+
# - grants read on signals/@{key}/, ontology/facts/@{key}/, and each
|
|
10
|
+
# sources/{channel}/@{key}/ that exists, to each principal that is an
|
|
11
|
+
# active company member (non-members are skipped with a warning);
|
|
12
|
+
# - one prefix grant per principal per folder, never per file;
|
|
13
|
+
# - each grant is the recursive shared glob `<folder>/*` (a bare `<folder>/`
|
|
14
|
+
# is a private create-only folder since hq-pro #3662);
|
|
15
|
+
# - reads every grant back with `hq files acl --json`; a missing grant exits 3.
|
|
16
|
+
# Company-audience paths get @all read on signals/{type}/ and
|
|
17
|
+
# ontology/facts/@company/ only. Nothing else under ontology/, signals/,
|
|
18
|
+
# sources/ is granted to @all.
|
|
19
|
+
# Local-only companies (company.yaml cloud: false or absent cloud_uid) exit 0
|
|
20
|
+
# with "local-only, no grants".
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
HQ="${HQ_BIN:-hq}"
|
|
23
|
+
root="${HQ_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
|
|
24
|
+
co="" dry=0
|
|
25
|
+
while [ $# -gt 0 ]; do
|
|
26
|
+
case "$1" in --company) co="$2"; shift 2 ;; --dry-run) dry=1; shift ;; *) echo "unknown flag $1" >&2; exit 2 ;; esac
|
|
27
|
+
done
|
|
28
|
+
[ -n "$co" ] || { echo "usage: ontology-audience-grant.sh --company <co> [--dry-run]" >&2; exit 2; }
|
|
29
|
+
C="$root/companies/$co"; [ -d "$C" ] || { echo "no company $co" >&2; exit 2; }
|
|
30
|
+
|
|
31
|
+
cloud=no
|
|
32
|
+
grep -Eq '^cloud:[[:space:]]*true' "$C/company.yaml" 2>/dev/null && cloud=yes
|
|
33
|
+
if [ "$cloud" = no ] && [ -f "$root/companies/manifest.yaml" ]; then
|
|
34
|
+
# manifest: companies:\n <co>:\n cloud_uid: ... (two-space company keys)
|
|
35
|
+
awk -v co="$co" '/^ [^ #][^:]*:/{inco=($1==co":")} inco && /^ cloud_uid:[[:space:]]*[^[:space:]]/{f=1} END{exit !f}' "$root/companies/manifest.yaml" && cloud=yes
|
|
36
|
+
fi
|
|
37
|
+
[ "$cloud" = yes ] || { echo "local-only, no grants"; exit 0; }
|
|
38
|
+
|
|
39
|
+
members="$("$HQ" members --company "$co" list 2>/dev/null | awk 'NR>1{print tolower($1)}')"
|
|
40
|
+
[ -n "$members" ] || { echo "could not list members of $co" >&2; exit 3; }
|
|
41
|
+
|
|
42
|
+
granted=0 skipped=0 failed=0
|
|
43
|
+
grant() { # <folder/> <principal>
|
|
44
|
+
# Grant the recursive shared glob `<folder>/*`, never the bare `<folder>/`:
|
|
45
|
+
# since hq-pro #3662 a bare trailing slash is a private create-only folder
|
|
46
|
+
# (read on it exposes no children) and ACL reads echo the pattern verbatim.
|
|
47
|
+
local p="${1%/}/*" who="$2"
|
|
48
|
+
if [ "$dry" = 1 ]; then echo "would grant read $p -> $who"; return 0; fi
|
|
49
|
+
"$HQ" files --company "$co" share "$p" --with "$who" --permission read >/dev/null 2>&1 || true
|
|
50
|
+
if "$HQ" files --company "$co" acl "$p" --json 2>/dev/null | jq -e --arg w "$who" \
|
|
51
|
+
'.direct[]? | select((.granteeId==$w) or ($w=="@all" and .granteeType=="company-wide")) | select(.permission=="read" or .permission=="write" or .permission=="admin")' >/dev/null; then
|
|
52
|
+
granted=$((granted+1)); echo "granted read $p -> $who"
|
|
53
|
+
else
|
|
54
|
+
failed=$((failed+1)); echo "READBACK FAILED: $p -> $who" >&2
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for aud in "$C"/signals/@*/_audience.yaml; do
|
|
59
|
+
[ -f "$aud" ] || continue
|
|
60
|
+
key="$(basename "$(dirname "$aud")")"; key="${key#@}"
|
|
61
|
+
principals="$(sed -n 's/^principals:[[:space:]]*\[\(.*\)\]/\1/p' "$aud" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | awk '/@/ { print tolower($0); next } { print }' | grep -v '^$')"
|
|
62
|
+
prefixes="signals/@$key/"
|
|
63
|
+
[ -d "$C/ontology/facts/@$key" ] && prefixes="$prefixes ontology/facts/@$key/"
|
|
64
|
+
for d in "$C"/sources/*/@"$key"; do [ -d "$d" ] && prefixes="$prefixes sources/$(basename "$(dirname "$d")")/@$key/"; done
|
|
65
|
+
for who in $principals; do
|
|
66
|
+
# prs_/agt_ principals come from the source's own vault ACL (e.g. meeting
|
|
67
|
+
# attendees), so they are already company identities; emails must be members.
|
|
68
|
+
case "$who" in
|
|
69
|
+
prs_*|agt_*) ;;
|
|
70
|
+
*) if ! printf '%s\n' "$members" | grep -qxF "$who"; then echo "skip $who (not a member of $co)" >&2; skipped=$((skipped+1)); continue; fi ;;
|
|
71
|
+
esac
|
|
72
|
+
for p in $prefixes; do grant "$p" "$who"; done
|
|
73
|
+
done
|
|
74
|
+
done
|
|
75
|
+
[ -d "$C/ontology/facts/@company" ] && grant "ontology/facts/@company/" "@all"
|
|
76
|
+
for t in action_item commitment decision risk question key_point participant_contribution summary; do
|
|
77
|
+
[ -d "$C/signals/$t" ] && grant "signals/$t/" "@all"
|
|
78
|
+
done
|
|
79
|
+
|
|
80
|
+
echo "audience grants for $co: $granted granted, $skipped skipped, $failed failed"
|
|
81
|
+
[ "$failed" -eq 0 ] || exit 3
|
|
@@ -0,0 +1,93 @@
|
|
|
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"));
|
|
@@ -0,0 +1,100 @@
|
|
|
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 find "$root/companies/$co/$store/_candidates" -name "$id.md" 2>/dev/null | grep -q .; 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"
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
|
|
84
|
+
function listCandidates(dir) {
|
|
85
|
+
if (!fs.existsSync(dir)) return [];
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const d of fs.readdirSync(dir).sort()) {
|
|
88
|
+
if (d === "_done") continue;
|
|
89
|
+
const full = path.join(dir, d);
|
|
90
|
+
if (!fs.statSync(full).isDirectory()) continue;
|
|
91
|
+
for (const f of fs.readdirSync(full).sort()) if (f.endsWith(".md")) out.push(path.join(full, f));
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function main() {
|
|
97
|
+
const a = parseArgs(process.argv.slice(2));
|
|
98
|
+
const co = path.join(a.root, "companies", a.company);
|
|
99
|
+
if (!fs.existsSync(co)) { console.error(`ontology-garden: no company ${a.company}`); process.exit(2); }
|
|
100
|
+
const ont = path.join(co, "ontology"), sig = path.join(co, "signals");
|
|
101
|
+
const report = { company: a.company, entities_created: 0, entities_updated: 0, signals_promoted: 0,
|
|
102
|
+
facts_written: 0, rejected: 0, candidates_processed: 0, dry_run: a.dryRun };
|
|
103
|
+
const rejected = [];
|
|
104
|
+
const moves = [];
|
|
105
|
+
|
|
106
|
+
// Load the current entity index: slug -> {type, file, fm, names[]}
|
|
107
|
+
const entities = new Map();
|
|
108
|
+
const nameIndex = new Map(); // lowercased name/alias -> slug
|
|
109
|
+
const entDir = path.join(ont, "entities");
|
|
110
|
+
if (fs.existsSync(entDir)) for (const t of fs.readdirSync(entDir)) {
|
|
111
|
+
const td = path.join(entDir, t);
|
|
112
|
+
if (!fs.statSync(td).isDirectory()) continue;
|
|
113
|
+
for (const f of fs.readdirSync(td)) {
|
|
114
|
+
if (!f.endsWith(".md")) continue;
|
|
115
|
+
const file = path.join(td, f); const { fm } = readDoc(file);
|
|
116
|
+
const slug = fm.slug || f.replace(/\.md$/, "");
|
|
117
|
+
const names = [fm.canonical_name, ...(Array.isArray(fm.aliases) ? fm.aliases : [])].filter(Boolean);
|
|
118
|
+
entities.set(slug, { type: fm.type || t, file, fm, names });
|
|
119
|
+
for (const n of names) nameIndex.set(n.toLowerCase(), slug);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 1. Entity candidates
|
|
124
|
+
for (const file of listCandidates(path.join(ont, "_candidates"))) {
|
|
125
|
+
const { fm, body } = readDoc(file);
|
|
126
|
+
const name = body.split("\n")[0].trim();
|
|
127
|
+
const type = fm.type;
|
|
128
|
+
const reject = reason => { rejected.push({ at: now(), name, type, reason, candidate: path.relative(co, file) }); report.rejected++; };
|
|
129
|
+
if (!ENTITY_TYPES.has(type)) reject("unknown-type");
|
|
130
|
+
else if (!name || STOPWORDS.has(name.toLowerCase()) || name.length < 2) reject("stopword");
|
|
131
|
+
else {
|
|
132
|
+
const slug = slugify(name);
|
|
133
|
+
const held = nameIndex.get(name.toLowerCase());
|
|
134
|
+
const existing = entities.get(held || slug);
|
|
135
|
+
if (existing && existing.type !== type) reject(`type-conflict:${existing.type}`);
|
|
136
|
+
else if (existing) {
|
|
137
|
+
const aliases = new Set(Array.isArray(existing.fm.aliases) ? existing.fm.aliases : []);
|
|
138
|
+
if (name !== existing.fm.canonical_name && !aliases.has(name)) {
|
|
139
|
+
aliases.add(name); existing.fm.aliases = [...aliases]; existing.fm.last_updated = now();
|
|
140
|
+
writeDoc(existing.file, existing.fm, "", a.dryRun); report.entities_updated++;
|
|
141
|
+
nameIndex.set(name.toLowerCase(), held || slug);
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
const efile = path.join(ont, "entities", type, `${slug}.md`);
|
|
145
|
+
const efm = { type, canonical_name: name, slug, aliases: [], signal_count: 0,
|
|
146
|
+
first_seen: fm.created_at || now(), last_updated: now(), enriched_by: "ontology-worker" };
|
|
147
|
+
writeDoc(efile, efm, "", a.dryRun);
|
|
148
|
+
entities.set(slug, { type, file: efile, fm: efm, names: [name] });
|
|
149
|
+
nameIndex.set(name.toLowerCase(), slug);
|
|
150
|
+
report.entities_created++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
moves.push(file); report.candidates_processed++;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2 + 3. Signal candidates -> signals + facts
|
|
157
|
+
const index = [];
|
|
158
|
+
for (const file of listCandidates(path.join(sig, "_candidates"))) {
|
|
159
|
+
const { fm, body } = readDoc(file);
|
|
160
|
+
const type = fm.type; const key = fm.audience_key;
|
|
161
|
+
moves.push(file); report.candidates_processed++;
|
|
162
|
+
if (!SIGNAL_TYPES.has(type) || !key || !body) {
|
|
163
|
+
rejected.push({ at: now(), type, reason: !key ? "missing-audience" : "invalid", candidate: path.relative(co, file) }); report.rejected++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (key !== "company" && !(Array.isArray(fm.audience) && fm.audience.length)) {
|
|
167
|
+
rejected.push({ at: now(), type, reason: "scoped-without-principals", candidate: path.relative(co, file) }); report.rejected++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const id = path.basename(file, ".md");
|
|
171
|
+
const scopeDir = key === "company" ? sig : path.join(sig, `@${key}`);
|
|
172
|
+
const out = path.join(scopeDir, type, `${id}.md`);
|
|
173
|
+
if (!fs.existsSync(out)) {
|
|
174
|
+
writeDoc(out, { signal_id: id, type, canonical_content: JSON.stringify(body.split("\n")[0]), audience_key: key,
|
|
175
|
+
source_ref: JSON.stringify(fm.source_ref || ""), created_at: fm.created_at || now() }, body, a.dryRun);
|
|
176
|
+
report.signals_promoted++;
|
|
177
|
+
index.push({ signal_id: id, type, audience_key: key, path: path.relative(co, out) });
|
|
178
|
+
if (key !== "company") {
|
|
179
|
+
const audFile = path.join(scopeDir, "_audience.yaml");
|
|
180
|
+
if (!fs.existsSync(audFile) && !a.dryRun) {
|
|
181
|
+
fs.mkdirSync(scopeDir, { recursive: true });
|
|
182
|
+
fs.writeFileSync(audFile, `audience_key: ${key}\nprincipals: [${fm.audience.join(", ")}]\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Facts: every known entity named in the signal.
|
|
186
|
+
const lower = body.toLowerCase();
|
|
187
|
+
const hit = new Set();
|
|
188
|
+
for (const [n, slug] of nameIndex) {
|
|
189
|
+
if (n.length < 3) continue;
|
|
190
|
+
const re = new RegExp(`(^|[^\\p{L}\\p{N}])${n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{N}])`, "u");
|
|
191
|
+
if (re.test(lower)) hit.add(slug);
|
|
192
|
+
}
|
|
193
|
+
for (const slug of hit) {
|
|
194
|
+
const e = entities.get(slug);
|
|
195
|
+
const ffile = path.join(ont, "facts", `@${key}`, e.type, `${slug}.md`);
|
|
196
|
+
const line = `- [${type}] ${body.split("\n")[0]} (signal ${id.slice(0, 12)})`;
|
|
197
|
+
const prev = fs.existsSync(ffile) ? readDoc(ffile) : { fm: { entity: slug, type: e.type, audience_key: key }, body: "" };
|
|
198
|
+
if (!prev.body.includes(`signal ${id.slice(0, 12)}`)) {
|
|
199
|
+
writeDoc(ffile, prev.fm, [prev.body, line].filter(Boolean).join("\n"), a.dryRun);
|
|
200
|
+
report.facts_written++;
|
|
201
|
+
}
|
|
202
|
+
if (key === "company") {
|
|
203
|
+
e.fm.signal_count = String((parseInt(e.fm.signal_count || "0", 10) || 0) + 1);
|
|
204
|
+
e.fm.last_updated = now();
|
|
205
|
+
writeDoc(e.file, e.fm, "", a.dryRun);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!a.dryRun) {
|
|
212
|
+
if (index.length) {
|
|
213
|
+
const f = path.join(sig, "_index", `${today()}.json`);
|
|
214
|
+
const prev = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : [];
|
|
215
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
216
|
+
fs.writeFileSync(f, JSON.stringify([...prev, ...index], null, 2) + "\n");
|
|
217
|
+
}
|
|
218
|
+
if (rejected.length) {
|
|
219
|
+
const f = path.join(ont, "_rejected", `${today()}.jsonl`);
|
|
220
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
221
|
+
fs.appendFileSync(f, rejected.map(r => JSON.stringify(r)).join("\n") + "\n");
|
|
222
|
+
}
|
|
223
|
+
for (const file of moves) {
|
|
224
|
+
const rel = path.relative(path.dirname(path.dirname(file)), file); // {date}/{sha}.md
|
|
225
|
+
const dest = path.join(path.dirname(path.dirname(file)), "_done", rel);
|
|
226
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
227
|
+
fs.renameSync(file, dest);
|
|
228
|
+
}
|
|
229
|
+
if (report.candidates_processed > 0) {
|
|
230
|
+
fs.mkdirSync(ont, { recursive: true });
|
|
231
|
+
fs.writeFileSync(path.join(ont, ".last-run"), `${Date.now()}\n`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (a.json) console.log(JSON.stringify(report));
|
|
235
|
+
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)" : ""}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
main();
|