@indigoai-us/hq-cli 5.345.18 → 5.345.20

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 CHANGED
@@ -2,10 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.20] — 2026-10-06
6
+ - Keep bundled scaffold scripts and `hq core register-project` aligned with the shared source, including company UID lookup from `companies/manifest.yaml`.
7
+
8
+ ## [5.345.19] — 2026-10-06
9
+ - Fix installation and updates of the published hq-anywhere pack through its built-in alias while continuing to reject reserved names from user-supplied remote sources.
10
+ - With `hq-anywhere-runtime` enabled, hqd now starts on Windows through the user-scoped named-pipe host and refuses service only when secure pipe setup or ACL verification fails. `hq doctor` describes unsafe named-pipe startup as an ACL setup failure and recommends the daemon install or restart action based on service state.
11
+
5
12
  ## [5.345.18] — 2026-10-06
6
13
  - Git Bash forwarders now keep slash commands intact and convert file path arguments that hq needs on Windows.
7
14
  - Lane placement and Claude account-balance refusals now show the requested placement, actual host, balance host when different, and the model values and sources used for an unavailable-account decision.
8
15
 
16
+ ## [5.345.17] — 2026-10-06
17
+ - Git Bash forwarders now keep slash commands intact and convert file path arguments that hq needs on Windows.
18
+
9
19
  ## [5.345.16] — 2026-10-06
10
20
  - Claude account model settings are defaults, so explicit lane models remain eligible; explicit account selections on create, resume, and message are recorded as CLI choices.
11
21
  - When `hq-anywhere-runtime` is enabled, `hq install --global --runtime claude --via plugin` installs the `hq` plugin from the `indigoai-us/hq-packages` Claude marketplace, and `hq install @indigoai-us/hq-anywhere` installs the Codex pack from that repository. The flag-off path refuses the HQ Anywhere alias without fetching a package source.
@@ -35,16 +35,23 @@ die() { echo "hq-delegate-grant: $*" >&2; exit 1; }
35
35
 
36
36
  command -v jq >/dev/null 2>&1 || die "jq is required but not installed"
37
37
 
38
- # jq.exe's binary mode avoids CRT newline conversion without changing real CR bytes.
38
+ # jq.exe on MSYS may use CRT text mode unless --binary is supported.
39
39
  case "${OSTYPE:-}:${MSYSTEM:-}" in
40
40
  msys*:*|*:MINGW*)
41
41
  jq() {
42
- local stdout_file stderr_file status
42
+ local stdout_file stderr_file status binary_supported
43
43
  stdout_file="$(mktemp)" || return $?
44
44
  stderr_file="$(mktemp)" || { status=$?; rm -f "$stdout_file"; return "$status"; }
45
- if command jq --binary "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
46
- command sed -b 's/\r$//' "$stdout_file"
47
- command sed -b 's/\r$//' "$stderr_file" >&2
45
+ if command jq --binary -n 'null' >/dev/null 2>&1; then binary_supported=1; else binary_supported=0; fi
46
+ if [[ "$binary_supported" == 1 ]]; then
47
+ if command jq --binary "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
48
+ command cat "$stdout_file"
49
+ command cat "$stderr_file" >&2
50
+ else
51
+ if command jq "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
52
+ command sed -b 's/\r$//' "$stdout_file"
53
+ command sed -b 's/\r$//' "$stderr_file" >&2
54
+ fi
48
55
  rm -f "$stdout_file" "$stderr_file"
49
56
  return "$status"
50
57
  }
@@ -90,7 +90,7 @@ fi
90
90
 
91
91
  # --- parse names (KEY= lines; comments and blanks ignored) --------------------
92
92
 
93
- NAMES="$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$ENV_SCHEMA" | cut -d= -f1 | sort -u)"
93
+ NAMES="$(grep -E '^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*=' "$ENV_SCHEMA" | cut -d= -f1 | sort -u)"
94
94
  if [ -z "$NAMES" ]; then
95
95
  update_manifest '.secrets = [] | .secretsSkipped = false'
96
96
  echo "hq-delegate-secrets: $ENV_SCHEMA declares no secret names — nothing to grant"
@@ -40,16 +40,23 @@ die() { echo "hq-delegate-send: $*" >&2; exit 1; }
40
40
 
41
41
  command -v jq >/dev/null 2>&1 || die "jq is required but not installed"
42
42
 
43
- # jq.exe's binary mode avoids CRT newline conversion without changing real CR bytes.
43
+ # jq.exe on MSYS may use CRT text mode unless --binary is supported.
44
44
  case "${OSTYPE:-}:${MSYSTEM:-}" in
45
45
  msys*:*|*:MINGW*)
46
46
  jq() {
47
- local stdout_file stderr_file status
47
+ local stdout_file stderr_file status binary_supported
48
48
  stdout_file="$(mktemp)" || return $?
49
49
  stderr_file="$(mktemp)" || { status=$?; rm -f "$stdout_file"; return "$status"; }
50
- if command jq --binary "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
51
- command sed -b 's/\r$//' "$stdout_file"
52
- command sed -b 's/\r$//' "$stderr_file" >&2
50
+ if command jq --binary -n 'null' >/dev/null 2>&1; then binary_supported=1; else binary_supported=0; fi
51
+ if [[ "$binary_supported" == 1 ]]; then
52
+ if command jq --binary "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
53
+ command cat "$stdout_file"
54
+ command cat "$stderr_file" >&2
55
+ else
56
+ if command jq "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
57
+ command sed -b 's/\r$//' "$stdout_file"
58
+ command sed -b 's/\r$//' "$stderr_file" >&2
59
+ fi
53
60
  rm -f "$stdout_file" "$stderr_file"
54
61
  return "$status"
55
62
  }
@@ -120,16 +120,23 @@ done
120
120
  command -v yq >/dev/null 2>&1 || die "yq is required (mikefarah/yq)"
121
121
  command -v jq >/dev/null 2>&1 || die "jq is required"
122
122
 
123
- # jq.exe's binary mode avoids CRT newline conversion without changing real CR bytes.
123
+ # jq.exe on MSYS may use CRT text mode unless --binary is supported.
124
124
  case "${OSTYPE:-}:${MSYSTEM:-}" in
125
125
  msys*:*|*:MINGW*)
126
126
  jq() {
127
- local stdout_file stderr_file status
127
+ local stdout_file stderr_file status binary_supported
128
128
  stdout_file="$(mktemp)" || return $?
129
129
  stderr_file="$(mktemp)" || { status=$?; rm -f "$stdout_file"; return "$status"; }
130
- if command jq --binary "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
131
- command sed -b 's/\r$//' "$stdout_file"
132
- command sed -b 's/\r$//' "$stderr_file" >&2
130
+ if command jq --binary -n 'null' >/dev/null 2>&1; then binary_supported=1; else binary_supported=0; fi
131
+ if [[ "$binary_supported" == 1 ]]; then
132
+ if command jq --binary "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
133
+ command cat "$stdout_file"
134
+ command cat "$stderr_file" >&2
135
+ else
136
+ if command jq "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
137
+ command sed -b 's/\r$//' "$stdout_file"
138
+ command sed -b 's/\r$//' "$stderr_file" >&2
139
+ fi
133
140
  rm -f "$stdout_file" "$stderr_file"
134
141
  return "$status"
135
142
  }
@@ -40,16 +40,23 @@ command -v jq >/dev/null 2>&1 || {
40
40
  exit 2
41
41
  }
42
42
 
43
- # jq.exe's binary mode avoids CRT newline conversion without changing real CR bytes.
43
+ # jq.exe on MSYS may use CRT text mode unless --binary is supported.
44
44
  case "${OSTYPE:-}:${MSYSTEM:-}" in
45
45
  msys*:*|*:MINGW*)
46
46
  jq() {
47
- local stdout_file stderr_file status
47
+ local stdout_file stderr_file status binary_supported
48
48
  stdout_file="$(mktemp)" || return $?
49
49
  stderr_file="$(mktemp)" || { status=$?; rm -f "$stdout_file"; return "$status"; }
50
- if command jq --binary "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
51
- command sed -b 's/\r$//' "$stdout_file"
52
- command sed -b 's/\r$//' "$stderr_file" >&2
50
+ if command jq --binary -n 'null' >/dev/null 2>&1; then binary_supported=1; else binary_supported=0; fi
51
+ if [[ "$binary_supported" == 1 ]]; then
52
+ if command jq --binary "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
53
+ command cat "$stdout_file"
54
+ command cat "$stderr_file" >&2
55
+ else
56
+ if command jq "$@" >"$stdout_file" 2>"$stderr_file"; then status=0; else status=$?; fi
57
+ command sed -b 's/\r$//' "$stdout_file"
58
+ command sed -b 's/\r$//' "$stderr_file" >&2
59
+ fi
53
60
  rm -f "$stdout_file" "$stderr_file"
54
61
  return "$status"
55
62
  }
@@ -54,45 +54,55 @@ hq_transcript_tail() {
54
54
  }
55
55
 
56
56
  # hq_transcript_tail_with_latest_assistant <path> [max_bytes] [scan_cap_bytes]
57
- # Return the normal bounded suffix plus the latest complete assistant record
58
- # when later metadata records have pushed it outside that suffix. The fallback
59
- # scan is bounded; status 3 means the latest assistant could not be verified
60
- # within the cap and security-sensitive callers must fail closed.
57
+ # Return the normal bounded suffix plus the newest complete assistant record
58
+ # when later records pushed it outside that suffix. The third argument remains
59
+ # accepted for caller compatibility, but reverse scanning is no longer capped.
60
+ # Return 1 only when the transcript cannot be read or no reverse-line tool is
61
+ # available; callers handling user-facing policy checks must fail open silently.
61
62
  hq_transcript_tail_with_latest_assistant() {
62
63
  [ "$#" -ge 1 ] && [ "$#" -le 3 ] || return 2
63
64
  local path="$1" max_bytes="${2:-$HQ_TRANSCRIPT_TAIL_MAX_BYTES}"
64
- local scan_cap="${3:-16777216}" size normal_tail assistant_record scan_tail
65
+ local scan_cap="${3:-16777216}" normal_tail assistant_record reverse_mode line=""
65
66
  case "$scan_cap" in ''|*[!0-9]*) return 2 ;; esac
66
67
  [ "$scan_cap" -gt 0 ] || return 2
67
68
 
69
+ # Most turns keep their newest assistant record in the ordinary bounded
70
+ # suffix, so preserve that fast path before any reverse scan.
68
71
  normal_tail="$(hq_transcript_tail "$path" "$max_bytes")" || return $?
69
- assistant_record="$(printf '%s\n' "$normal_tail" | jq -Rrc 'fromjson? | select(.type == "assistant")' | tail -n 1)"
72
+ assistant_record="$(printf '%s\n' "$normal_tail" | jq -Rrc 'fromjson? | select(.type == "assistant" or (.type == "response_item" and .payload.type == "message" and .payload.role == "assistant"))' | tail -n 1)"
70
73
  if [ -n "$assistant_record" ]; then
71
74
  [ -z "$normal_tail" ] || printf '%s\n' "$normal_tail"
72
75
  return 0
73
76
  fi
74
77
 
75
- size="$(hq_transcript_size "$path")" || {
76
- printf '%s\n' "hq_transcript_tail_with_latest_assistant: cannot read transcript size: $path" >&2
77
- return 1
78
- }
79
- if [ "$size" -le "$scan_cap" ]; then
80
- scan_tail="$(cat "$path"; printf '.')"
78
+ # GNU tac and BSD/macOS tail -r both stream records from EOF toward the
79
+ # beginning. grep discards ordinary tool-output lines before Bash reads them;
80
+ # jq confirms candidates and the loop exits at the newest assistant.
81
+ if command -v tac >/dev/null 2>&1; then
82
+ reverse_mode=tac
83
+ elif tail -r /dev/null >/dev/null 2>&1; then
84
+ reverse_mode=tail-r
81
85
  else
82
- scan_tail="$(tail -c "$((scan_cap + 1))" "$path" | sed '1d'; printf '.')"
86
+ printf '%s\n' "hq_transcript_tail_with_latest_assistant: no reverse-line reader is available" >&2
87
+ return 1
83
88
  fi
84
- scan_tail="${scan_tail%.}"
85
- assistant_record="$(printf '%s\n' "$scan_tail" | jq -Rrc 'fromjson? | select(.type == "assistant")' | tail -n 1)"
89
+
90
+ assistant_record="$(
91
+ if [ "$reverse_mode" = tac ]; then
92
+ { tac "$path" 2>/dev/null || true; }
93
+ else
94
+ { tail -r "$path" 2>/dev/null || true; }
95
+ fi | { grep -E '"assistant"' || true; } | while IFS= read -r line || [ -n "$line" ]; do
96
+ # grep filters large non-assistant records before Bash reads their lines.
97
+ if printf '%s\n' "$line" | jq -Rre 'fromjson? | select(.type == "assistant" or (.type == "response_item" and .payload.type == "message" and .payload.role == "assistant"))' >/dev/null 2>&1; then
98
+ printf '%s\n' "$line"
99
+ break
100
+ fi
101
+ done
102
+ )"
86
103
  if [ -n "$assistant_record" ]; then
87
104
  printf '%s\n' "$assistant_record"
88
- [ -z "$normal_tail" ] || printf '%s\n' "$normal_tail"
89
- return 0
90
105
  fi
91
-
92
- if [ "$size" -gt "$scan_cap" ]; then
93
- printf '%s\n' "hq_transcript_tail_with_latest_assistant: latest assistant record exceeds the ${scan_cap}-byte verification window" >&2
94
- return 3
95
- fi
96
-
97
106
  [ -z "$normal_tail" ] || printf '%s\n' "$normal_tail"
107
+ return 0
98
108
  }
@@ -80,6 +80,69 @@ function writeDoc(file, fm, body, dry) {
80
80
  const slugify = s => s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80);
81
81
  const today = () => new Date().toISOString().slice(0, 10);
82
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
+ }
83
146
 
84
147
  function listCandidates(dir) {
85
148
  if (!fs.existsSync(dir)) return [];
@@ -193,10 +256,30 @@ function main() {
193
256
  for (const slug of hit) {
194
257
  const e = entities.get(slug);
195
258
  const ffile = path.join(ont, "facts", `@${key}`, e.type, `${slug}.md`);
196
- const line = `- [${type}] ${body.split("\n")[0]} (signal ${id.slice(0, 12)})`;
259
+ const sid12 = id.slice(0, 12);
260
+ const line = `- [${type}] ${body.split("\n")[0]} (signal ${sid12})`;
197
261
  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);
262
+ const idxFile = path.join(path.dirname(ffile), `${slug}.history`, "_index.json");
263
+ const archiveIndex = readArchiveIndex(idxFile);
264
+ const alreadyArchived = archiveIndex.signalIds.includes(sid12);
265
+ if (!prev.body.includes(`signal ${sid12}`) && !alreadyArchived) {
266
+ let nextBody = [prev.body, line].filter(Boolean).join("\n");
267
+ const compact = compactFactBody(nextBody, {
268
+ slug,
269
+ archivedTotalBefore: archiveIndex.signalIds.length,
270
+ dry: a.dryRun,
271
+ });
272
+ if (compact.changed && !archiveIndex.unreadable) {
273
+ const archiveFile = path.join(path.dirname(ffile), `${slug}.history`, `${compact.stamp}.md`);
274
+ appendArchiveFile(archiveFile, today(), compact.archivedLines, a.dryRun);
275
+ if (!a.dryRun) {
276
+ const nextIdx = { version: 1, signalIds: [...archiveIndex.signalIds, ...compact.archivedIds] };
277
+ fs.mkdirSync(path.dirname(idxFile), { recursive: true });
278
+ fs.writeFileSync(idxFile, JSON.stringify(nextIdx) + "\n");
279
+ }
280
+ nextBody = compact.body;
281
+ }
282
+ writeDoc(ffile, prev.fm, nextBody, a.dryRun);
200
283
  report.facts_written++;
201
284
  }
202
285
  if (key === "company") {
@@ -32,10 +32,77 @@ while [ $# -gt 0 ]; do
32
32
  esac
33
33
  done
34
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"
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
36
40
  bash "$here/source-yaml-validate.sh" "$Y" >/dev/null || { bash "$here/source-yaml-validate.sh" "$Y" >&2; exit 2; }
37
41
 
38
- if [ -n "$done_item" ]; then echo "$(basename "$done_item")" >> "$ledger"; exit 0; fi
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
+ }
39
106
 
40
107
  get() { awk -v k="$1" '$0 ~ "^"k":" { sub("^"k":[[:space:]]*", ""); gsub(/^["\x27]|["\x27]$/, ""); print; exit }' "$Y"; }
41
108
  run="$(get run)"; rule="$(get audience_rule)"
@@ -53,11 +120,69 @@ item_list() { # frontmatter list field: inline [a, b] or block "- a"
53
120
  ' "$1"
54
121
  }
55
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
+
56
180
  n=0
57
181
  for f in "$D"/*.md; do
58
182
  [ -f "$f" ] || continue
59
183
  b="$(basename "$f")"
60
- [ -f "$ledger" ] && grep -qxF "$b" "$ledger" && continue
184
+ is_processed "$b" && continue
185
+ skipped_at_current "$b" && continue
61
186
  [ "$n" -ge "$limit" ] && break
62
187
  n=$((n+1))
63
188
  rel="sources/$ch/$b"
@@ -68,12 +193,24 @@ for f in "$D"/*.md; do
68
193
  *)
69
194
  aud="$(item_list "$f" audience | paste -sd, -)"
70
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, -)"
71
205
  if [ -z "$aud" ]; then
72
206
  aud="$("$HQ" files --company "$co" acl "$rel" --json 2>/dev/null \
73
207
  | 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)"
74
208
  fi ;;
75
209
  esac
76
- if [ -z "$aud" ]; then jq -cn --arg f "$rel" '{file:$f, skip:"no-audience"}'; continue; fi
210
+ if [ -z "$aud" ]; then
211
+ printf '%s\t%s\t%s\n' "$b" "no-audience" "$PARSER_VERSION" >> "$skip_ledger"
212
+ jq -cn --arg f "$rel" '{file:$f, skip:"no-audience"}'; continue
213
+ fi
77
214
  key="$(HQ_ROOT="$root" bash "$here/ontology-candidate.sh" key "$aud")"
78
215
  jq -cn --arg f "$rel" --arg k "$key" --arg a "$aud" '{file:$f, audience_key:$k, audience:($a|split(","))}'
79
216
  done