@indigoai-us/hq-cli 5.345.106 → 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.
- package/CHANGELOG.md +16 -0
- package/assets/scaffold/core/scripts/hq-task.mjs +259 -0
- package/assets/scaffold/core/scripts/hq-task.sh +31 -0
- package/assets/scaffold/core/scripts/pipeline-worker-table.sh +285 -0
- package/dist/command-catalog.generated.d.ts +135 -5
- package/dist/command-catalog.generated.js +161 -5
- package/dist/commands/auth.js +42 -0
- package/dist/commands/core-hq-task-fast.d.ts +4 -0
- package/dist/commands/core-hq-task-fast.js +11 -0
- package/dist/commands/core-hq-task-native.d.ts +20 -0
- package/dist/commands/core-hq-task-native.js +275 -0
- package/dist/commands/core-native-bucket-c-flag.d.ts +3 -0
- package/dist/commands/core-native-bucket-c-flag.js +6 -0
- package/dist/commands/core-native-family-flag.d.ts +0 -2
- package/dist/commands/core-native-family-flag.js +0 -4
- package/dist/commands/core-native-pipeline-flag.d.ts +3 -0
- package/dist/commands/core-native-pipeline-flag.js +6 -0
- package/dist/commands/core-ontology-native.js +24 -30
- package/dist/commands/core-pipeline-workers-command.d.ts +3 -0
- package/dist/commands/core-pipeline-workers-command.js +37 -0
- package/dist/commands/core-pipeline-workers-native.d.ts +13 -0
- package/dist/commands/core-pipeline-workers-native.js +361 -0
- package/dist/commands/core.js +12 -10
- package/dist/commands/lanes.js +32 -7
- package/dist/commands/outposts.d.ts +5 -0
- package/dist/commands/outposts.js +5 -0
- package/dist/fast-scaffold.d.ts +1 -1
- package/dist/fast-scaffold.js +13 -5
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +56937 -64550
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/daemon/remote-control-pools.d.ts +2 -2
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/reconcile.js +4 -0
- package/dist/lib/lanes/remote-control-lane.d.ts +33 -0
- package/dist/lib/lanes/remote-control-lane.js +276 -0
- package/dist/lib/lanes/spawn.d.ts +5 -0
- package/dist/lib/lanes/spawn.js +65 -14
- package/dist/lib/lanes/store.js +7 -0
- package/dist/lib/lanes/types.d.ts +2 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.js +8 -1
- package/dist/utils/cognito-session.d.ts +12 -4
- package/dist/utils/cognito-session.js +42 -9
- package/dist/utils/token-keychain.d.ts +96 -0
- package/dist/utils/token-keychain.js +218 -0
- package/package.json +2 -2
- package/assets/scaffold/core/scripts/ontology-audience-grant.sh +0 -81
- package/assets/scaffold/core/scripts/ontology-brief.mjs +0 -93
- package/assets/scaffold/core/scripts/ontology-candidate.sh +0 -100
- package/assets/scaffold/core/scripts/ontology-garden.mjs +0 -327
- package/assets/scaffold/core/scripts/ontology-source-worklist.sh +0 -226
- package/assets/scaffold/core/scripts/source-yaml-validate.sh +0 -36
|
@@ -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();
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# ontology-source-worklist.sh — list a declared source's unprocessed items with
|
|
3
|
-
# each item's resolved audience. The ontology worker's process-source skill
|
|
4
|
-
# reads this list, extracts candidates from each item, and marks it done.
|
|
5
|
-
# Specs: source-yaml-spec.md, ontology-local-spec.md (core/knowledge/public/hq-core/)
|
|
6
|
-
#
|
|
7
|
-
# Usage:
|
|
8
|
-
# ontology-source-worklist.sh --company <co> --channel <ch> [--limit N] [--force]
|
|
9
|
-
# ontology-source-worklist.sh --company <co> --channel <ch> --mark-done <item-file>
|
|
10
|
-
#
|
|
11
|
-
# Output: one JSON object per line:
|
|
12
|
-
# {"file":"sources/meetings/x.md","audience_key":"…","audience":["prs_…","a@x.com"]}
|
|
13
|
-
# {"file":"sources/meetings/y.md","skip":"no-audience"}
|
|
14
|
-
# Audience by audience_rule:
|
|
15
|
-
# attendees / thread / channel — the item's `audience:` or `attendees:` list if
|
|
16
|
-
# it has one, else the direct read grants on the item's vault key
|
|
17
|
-
# (hq files acl), which is how cloud ingestion records who was privy.
|
|
18
|
-
# explicit — the item's own `audience:` frontmatter only.
|
|
19
|
-
# company — "company".
|
|
20
|
-
# An item with no resolvable audience is emitted as a skip, never as company.
|
|
21
|
-
# Exit 0; 2 bad usage / invalid source.yaml; 4 run: cloud source without --force.
|
|
22
|
-
set -uo pipefail
|
|
23
|
-
HQ="${HQ_BIN:-hq}"
|
|
24
|
-
root="${HQ_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
|
|
25
|
-
here="$(cd "$(dirname "$0")" && pwd)"
|
|
26
|
-
co="" ch="" limit=50 force=0 done_item=""
|
|
27
|
-
while [ $# -gt 0 ]; do
|
|
28
|
-
case "$1" in
|
|
29
|
-
--company) co="$2"; shift 2 ;; --channel) ch="$2"; shift 2 ;; --limit) limit="$2"; shift 2 ;;
|
|
30
|
-
--force) force=1; shift ;; --mark-done) done_item="$2"; shift 2 ;;
|
|
31
|
-
*) echo "unknown flag $1" >&2; exit 2 ;;
|
|
32
|
-
esac
|
|
33
|
-
done
|
|
34
|
-
[ -n "$co" ] && [ -n "$ch" ] || { echo "usage: ontology-source-worklist.sh --company <co> --channel <ch> [--limit N] [--force] [--mark-done <file>]" >&2; exit 2; }
|
|
35
|
-
D="$root/companies/$co/sources/$ch"; Y="$D/source.yaml"; ledger="$D/.processed"; skip_ledger="$D/.skipped"
|
|
36
|
-
# Bumped when the audience parser gains new recognisable frontmatter fields; items
|
|
37
|
-
# previously skipped as no-audience under an older PARSER_VERSION get re-evaluated
|
|
38
|
-
# on the next run instead of staying stuck forever (see feedback_b8d61e28).
|
|
39
|
-
PARSER_VERSION=2
|
|
40
|
-
bash "$here/source-yaml-validate.sh" "$Y" >/dev/null || { bash "$here/source-yaml-validate.sh" "$Y" >&2; exit 2; }
|
|
41
|
-
|
|
42
|
-
if [ -n "$done_item" ]; then printf '%s\t%s\n' "$(basename "$done_item")" "$PARSER_VERSION" >> "$ledger"; exit 0; fi
|
|
43
|
-
|
|
44
|
-
# Migrate `.processed` to parser-version stamps so a successfully processed item
|
|
45
|
-
# stays processed across runs. Entries are `basename\tversion`; new mark-done
|
|
46
|
-
# writes always include the stamp. Convergence rule for legacy UNSTAMPED
|
|
47
|
-
# entries from the pre-fix slack-dm bug (254 DMs recorded in `.processed` as
|
|
48
|
-
# plain basenames while no candidates were ever written): drop ones whose file
|
|
49
|
-
# carries `resolved_participants:` (the exact bug signature) so the main loop
|
|
50
|
-
# re-evaluates them exactly once; the next mark-done writes them back stamped
|
|
51
|
-
# at the current version. Any other unstamped entry is upgraded in place to a
|
|
52
|
-
# stamp — never re-stripped again. Stamped entries at a version below current
|
|
53
|
-
# PARSER_VERSION are also dropped so a parser upgrade re-evaluates them.
|
|
54
|
-
# ontology-candidate ids are content-addressed, so a true reprocess is idempotent.
|
|
55
|
-
if [ -f "$ledger" ]; then
|
|
56
|
-
tmp="$ledger.heal.$$"
|
|
57
|
-
: > "$tmp"
|
|
58
|
-
changed=0
|
|
59
|
-
while IFS=$'\t' read -r b ver || [ -n "$b" ]; do
|
|
60
|
-
b="${b%$'\r'}"; ver="${ver%$'\r'}"
|
|
61
|
-
[ -n "$b" ] || continue
|
|
62
|
-
if [ -n "${ver:-}" ]; then
|
|
63
|
-
if [ "$ver" -lt "$PARSER_VERSION" ] 2>/dev/null; then changed=1; continue; fi
|
|
64
|
-
printf '%s\t%s\n' "$b" "$ver" >> "$tmp"
|
|
65
|
-
continue
|
|
66
|
-
fi
|
|
67
|
-
item="$D/$b"
|
|
68
|
-
if [ -f "$item" ] && awk '/^---$/{fm++; if(fm==2) exit} fm==1 && /^resolved_participants:/{found=1; exit} END{exit !found}' "$item" 2>/dev/null; then
|
|
69
|
-
changed=1; continue
|
|
70
|
-
fi
|
|
71
|
-
printf '%s\t%s\n' "$b" "$PARSER_VERSION" >> "$tmp"
|
|
72
|
-
changed=1
|
|
73
|
-
done < "$ledger"
|
|
74
|
-
if [ "$changed" = 1 ]; then mv "$tmp" "$ledger"; else rm -f "$tmp"; fi
|
|
75
|
-
fi
|
|
76
|
-
is_processed() { # 0 if $1 is recorded in .processed at >= current parser version (or unstamped — treated as current after migration)
|
|
77
|
-
[ -f "$ledger" ] || return 1
|
|
78
|
-
awk -F '\t' -v b="$1" -v v="$PARSER_VERSION" '
|
|
79
|
-
{ sub(/\r$/, "") }
|
|
80
|
-
$1==b { if (NF < 2 || $2=="") { found=1; exit } if (($2+0) >= v) { found=1; exit } }
|
|
81
|
-
END { exit !found }
|
|
82
|
-
' "$ledger"
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
# Walk `.skipped`: entries whose recorded version is < current PARSER_VERSION
|
|
86
|
-
# are dropped so the main loop re-evaluates them. Entries at the current
|
|
87
|
-
# version stay as the ignore set for this run.
|
|
88
|
-
declare_skipped=""
|
|
89
|
-
if [ -f "$skip_ledger" ]; then
|
|
90
|
-
tmp="$skip_ledger.rev.$$"
|
|
91
|
-
: > "$tmp"
|
|
92
|
-
changed=0
|
|
93
|
-
while IFS=$'\t' read -r b reason ver || [ -n "$b" ]; do
|
|
94
|
-
[ -n "$b" ] || continue
|
|
95
|
-
if [ -z "${ver:-}" ] || [ "$ver" -lt "$PARSER_VERSION" ] 2>/dev/null; then
|
|
96
|
-
changed=1; continue
|
|
97
|
-
fi
|
|
98
|
-
printf '%s\t%s\t%s\n' "$b" "$reason" "$ver" >> "$tmp"
|
|
99
|
-
done < "$skip_ledger"
|
|
100
|
-
if [ "$changed" = 1 ]; then mv "$tmp" "$skip_ledger"; else rm -f "$tmp"; fi
|
|
101
|
-
fi
|
|
102
|
-
skipped_at_current() { # 0 if $1 is recorded as skipped at the current parser version
|
|
103
|
-
[ -f "$skip_ledger" ] || return 1
|
|
104
|
-
awk -F '\t' -v b="$1" -v v="$PARSER_VERSION" '$1==b && $3==v { found=1; exit } END { exit !found }' "$skip_ledger"
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
get() { awk -v k="$1" '$0 ~ "^"k":" { sub("^"k":[[:space:]]*", ""); gsub(/^["\x27]|["\x27]$/, ""); print; exit }' "$Y"; }
|
|
108
|
-
run="$(get run)"; rule="$(get audience_rule)"
|
|
109
|
-
if [ "$run" = cloud ] && [ "$force" = 0 ]; then echo "assigned to cloud agent (run: cloud); pass --force to run locally" >&2; exit 4; fi
|
|
110
|
-
|
|
111
|
-
item_list() { # frontmatter list field: inline [a, b] or block "- a"
|
|
112
|
-
awk -v k="$2" '
|
|
113
|
-
BEGIN{fm=0}
|
|
114
|
-
/^---$/ { fm++; if (fm==2) exit; next }
|
|
115
|
-
fm==1 && $0 ~ "^"k":" { v=$0; sub("^"k":[[:space:]]*", "", v)
|
|
116
|
-
if (v ~ /^\[/) { gsub(/[\[\]"\x27]/, "", v); n=split(v, a, ","); for(i=1;i<=n;i++){gsub(/^[ \t]+|[ \t]+$/, "", a[i]); if(a[i]!="") print a[i]}; exit }
|
|
117
|
-
inlist=1; next }
|
|
118
|
-
fm==1 && inlist && /^[[:space:]]*-[[:space:]]/ { v=$0; sub(/^[[:space:]]*-[[:space:]]*/, "", v); gsub(/["\x27]/, "", v); print v; next }
|
|
119
|
-
fm==1 && inlist { exit }
|
|
120
|
-
' "$1"
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
# participant_ids: for a frontmatter block list of mappings under key $2 (e.g.
|
|
124
|
-
# resolved_participants:), emit one id per entry: the entry's `person_uid:` if
|
|
125
|
-
# present, otherwise its `email:`. Entries with only `slack_user_id:` (no
|
|
126
|
-
# person_uid, no email) are skipped — the audience must stay exactly the
|
|
127
|
-
# resolvable DM participants, never widened. Scope: ONLY the mapping list
|
|
128
|
-
# directly under $2 in frontmatter.
|
|
129
|
-
participant_ids() {
|
|
130
|
-
awk -v k="$2" '
|
|
131
|
-
{ sub(/\r$/, "") }
|
|
132
|
-
BEGIN{fm=0; inlist=0; have_pid=0; have_email=0; pid=""; em=""}
|
|
133
|
-
function flush() {
|
|
134
|
-
if (have_pid && pid != "") { print pid }
|
|
135
|
-
else if (have_email && em != "") { print em }
|
|
136
|
-
have_pid=0; have_email=0; pid=""; em=""
|
|
137
|
-
}
|
|
138
|
-
/^---$/ { fm++; if (fm==2) { if (inlist) flush(); exit } next }
|
|
139
|
-
fm==1 && $0 ~ "^"k":" { inlist=1; next }
|
|
140
|
-
fm==1 && inlist {
|
|
141
|
-
# end of list = a line that is not indented beyond col 0 and not a bare "-"
|
|
142
|
-
if ($0 ~ /^[^ \t-]/) { flush(); inlist=0; next }
|
|
143
|
-
is_dash = ($0 ~ /^[[:space:]]*-[[:space:]]/)
|
|
144
|
-
if (is_dash) {
|
|
145
|
-
flush()
|
|
146
|
-
s=$0; sub(/^[[:space:]]*-[[:space:]]*/, "", s)
|
|
147
|
-
# inline mapping (JSON-style) on same line as the dash
|
|
148
|
-
if (s ~ /^\{/) {
|
|
149
|
-
gsub(/[{}"\x27]/, "", s); n=split(s, kv, ",")
|
|
150
|
-
for (i=1;i<=n;i++) {
|
|
151
|
-
gsub(/^[ \t]+|[ \t]+$/, "", kv[i])
|
|
152
|
-
if (match(kv[i], /^person_uid[[:space:]]*:[[:space:]]*/)) { pid=substr(kv[i], RLENGTH+1); have_pid=1 }
|
|
153
|
-
else if (match(kv[i], /^email[[:space:]]*:[[:space:]]*/)) { em=substr(kv[i], RLENGTH+1); have_email=1 }
|
|
154
|
-
}
|
|
155
|
-
flush()
|
|
156
|
-
next
|
|
157
|
-
}
|
|
158
|
-
# First mapping key on same line as the dash, e.g.
|
|
159
|
-
# - person_uid: prs_01ALICE
|
|
160
|
-
# Fall through to the key/value handlers below with the de-dashed line.
|
|
161
|
-
line = s
|
|
162
|
-
} else {
|
|
163
|
-
line = $0; sub(/^[[:space:]]+/, "", line)
|
|
164
|
-
}
|
|
165
|
-
if (line ~ /^person_uid:/) {
|
|
166
|
-
v=line; sub(/^person_uid:[[:space:]]*/, "", v); gsub(/["\x27]/, "", v); gsub(/^[ \t]+|[ \t]+$/, "", v)
|
|
167
|
-
if (v != "") { pid=v; have_pid=1 }
|
|
168
|
-
next
|
|
169
|
-
}
|
|
170
|
-
if (line ~ /^email:/) {
|
|
171
|
-
v=line; sub(/^email:[[:space:]]*/, "", v); gsub(/["\x27]/, "", v); gsub(/^[ \t]+|[ \t]+$/, "", v)
|
|
172
|
-
if (v != "") { em=v; have_email=1 }
|
|
173
|
-
next
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
END { if (inlist) flush() }
|
|
177
|
-
' "$1"
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
n=0
|
|
181
|
-
for f in "$D"/*.md; do
|
|
182
|
-
[ -f "$f" ] || continue
|
|
183
|
-
b="$(basename "$f")"
|
|
184
|
-
is_processed "$b" && continue
|
|
185
|
-
skipped_at_current "$b" && continue
|
|
186
|
-
[ "$n" -ge "$limit" ] && break
|
|
187
|
-
n=$((n+1))
|
|
188
|
-
rel="sources/$ch/$b"
|
|
189
|
-
aud=""
|
|
190
|
-
case "$rule" in
|
|
191
|
-
company) aud="company" ;;
|
|
192
|
-
explicit) aud="$(item_list "$f" audience | paste -sd, -)" ;;
|
|
193
|
-
*)
|
|
194
|
-
aud="$(item_list "$f" audience | paste -sd, -)"
|
|
195
|
-
[ -n "$aud" ] || aud="$(item_list "$f" attendees | grep -E '@|^(prs|agt)_' | paste -sd, -)"
|
|
196
|
-
# Slack DM items (hq-pro inbound slack-personal-dm.ts) only carry
|
|
197
|
-
# resolved_participants: a block list of mappings with person_uid /
|
|
198
|
-
# slack_user_id / email. Derive audience from those, scoped exactly to
|
|
199
|
-
# the DM's own participants (never widened to company). Only entries
|
|
200
|
-
# whose person_uid or email resolves become audience ids; a slack_user_id
|
|
201
|
-
# alone is not a usable audience key, so that entry is dropped — matching
|
|
202
|
-
# the frontmatter contract exercised in hq-pro
|
|
203
|
-
# test/sources/inbound/slack-personal-dm.test.ts.
|
|
204
|
-
[ -n "$aud" ] || aud="$(participant_ids "$f" resolved_participants | grep -E '@|^(prs|agt)_' | paste -sd, -)"
|
|
205
|
-
if [ -z "$aud" ]; then
|
|
206
|
-
# A transient ACL or jq failure must not turn a source into a durable
|
|
207
|
-
# no-audience skip. Retry once, while treating valid empty ACL output as
|
|
208
|
-
# a completed lookup that needs no retry.
|
|
209
|
-
for attempt in 1 2; do
|
|
210
|
-
acl_output=""
|
|
211
|
-
if acl_output="$("$HQ" files --company "$co" acl "$rel" --json 2>/dev/null \
|
|
212
|
-
| jq -r '[.direct[]? | select(.granteeType=="person" or .granteeType=="email" or .granteeType=="agent") | select(.permission=="read" or .permission=="write" or .permission=="admin") | .granteeId] | unique | join(",")' 2>/dev/null)"; then
|
|
213
|
-
aud="$acl_output"
|
|
214
|
-
break
|
|
215
|
-
fi
|
|
216
|
-
aud=""
|
|
217
|
-
done
|
|
218
|
-
fi ;;
|
|
219
|
-
esac
|
|
220
|
-
if [ -z "$aud" ]; then
|
|
221
|
-
printf '%s\t%s\t%s\n' "$b" "no-audience" "$PARSER_VERSION" >> "$skip_ledger"
|
|
222
|
-
jq -cn --arg f "$rel" '{file:$f, skip:"no-audience"}'; continue
|
|
223
|
-
fi
|
|
224
|
-
key="$(HQ_ROOT="$root" bash "$here/ontology-candidate.sh" key "$aud")"
|
|
225
|
-
jq -cn --arg f "$rel" --arg k "$key" --arg a "$aud" '{file:$f, audience_key:$k, audience:($a|split(","))}'
|
|
226
|
-
done
|