@indigoai-us/hq-cli 5.345.19 → 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,6 +2,9 @@
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
+
5
8
  ## [5.345.19] — 2026-10-06
6
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.
7
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.
@@ -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"
@@ -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
@@ -21,13 +21,44 @@ resolve_uid() {
21
21
  tr -d '[:space:]' < "$file"
22
22
  return
23
23
  fi
24
- die "no company uid for $company (set HQ_COMPANY_UID or companies/$company/.company-uid)"
24
+ if [ -f "$ROOT/companies/manifest.yaml" ]; then
25
+ local manifest_uid
26
+ manifest_uid="$(awk -v company="$company" '
27
+ /^companies:[[:space:]]*$/ { in_companies=1; next }
28
+ in_companies && /^[^[:space:]]/ { in_companies=0 }
29
+ in_companies && /^ [^ #][^:]*:/ {
30
+ slug=$1; sub(/[[:space:]]+$/, "", slug); sub(/:$/, "", slug); in_company=(slug == company); next
31
+ }
32
+ in_company && /^ [^[:space:]#]/ { exit }
33
+ in_company && $1 == "cloud_uid:" { gsub(/["\047]/, "", $2); sub(/[[:space:]]+$/, "", $2); print $2; exit }
34
+ ' "$ROOT/companies/manifest.yaml")"
35
+ if [ -n "$manifest_uid" ]; then
36
+ printf '%s\n' "$manifest_uid"
37
+ return
38
+ fi
39
+ fi
40
+ die "no company uid for $company (set HQ_COMPANY_UID, companies/$company/.company-uid, or manifest cloud_uid)"
41
+ }
42
+
43
+ mesh_project_id_for_prd() {
44
+ local company="$1" project="$2" board rel resolved
45
+ board="$(board_path "$company")"
46
+ rel="companies/$company/projects/$project/prd.json"
47
+ resolved=""
48
+ if [ -f "$board" ]; then
49
+ resolved="$(jq -r --arg path "$rel" --arg project "$project" '
50
+ [.projects[]? | select(.prd_path == $path or .id == $project)] | first | (.mesh_project_id // .id // empty)
51
+ ' "$board" 2>/dev/null | tr -d '\r' || true)"
52
+ fi
53
+ printf '%s\n' "${resolved:-$project}"
25
54
  }
26
55
 
27
56
  has_board() {
28
- local company="$1" project="$2" uid
57
+ local company="$1" project="$2" uid mesh_project
29
58
  uid="$(resolve_uid "$company")"
30
- [ -f "${HOME}/.hq/work-mesh/cache/projects/${uid}/${project}.json" ]
59
+ mesh_project="$(mesh_project_id_for_prd "$company" "$project")"
60
+ [ -f "${HOME}/.hq/work-mesh/cache/projects/${uid}/${mesh_project}.json" ] \
61
+ || [ -f "${HOME}/.hq/work-mesh/cache/projects/${uid}/${project}.json" ]
31
62
  }
32
63
 
33
64
  story_not_done() {
@@ -69,7 +100,7 @@ mark_pending() {
69
100
  prd="$ROOT/companies/$company/projects/$project/prd.json"
70
101
  rel="companies/$company/projects/$project/prd.json"
71
102
  now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
72
- title="$(jq -r '.name // empty' "$prd" 2>/dev/null || true)"
103
+ title="$(jq -r '.name // empty' "$prd" 2>/dev/null | tr -d '\r' || true)"
73
104
  [ -n "$title" ] || title="$project"
74
105
  mkdir -p "$(dirname "$board")"
75
106
  if [ ! -f "$board" ]; then
@@ -108,19 +139,21 @@ mark_pending() {
108
139
  register_one() {
109
140
  local company="$1" project="$2"
110
141
  local prd="$ROOT/companies/$company/projects/$project/prd.json"
142
+ local mesh_project
143
+ mesh_project="$(mesh_project_id_for_prd "$company" "$project")"
111
144
  [ -f "$prd" ] || die "registration incomplete: missing $prd"
112
145
  if ! cli_has_project_ensure; then
113
146
  mark_pending "$company" "$project"
114
147
  die "hq-cli ${MIN_HQ_CLI} or newer is required; ${company}/${project} stays local until then"
115
148
  fi
116
149
  local ensure_json
117
- if ! ensure_json="$(hq mesh project ensure "$project" --company "$company" --stories-file "$prd" --json)"; then
150
+ if ! ensure_json="$(hq mesh project ensure "$mesh_project" --company "$company" --stories-file "$prd" --json)"; then
118
151
  echo "registration incomplete: hq mesh project ensure failed for $company/$project" >&2
119
152
  exit 1
120
153
  fi
121
154
  local thread channel
122
- thread="$(printf '%s' "$ensure_json" | jq -er '.threadId // empty')" || die "registration incomplete: ensure output missing threadId"
123
- channel="$(printf '%s' "$ensure_json" | jq -er '.channelId // empty')" || die "registration incomplete: ensure output missing channelId"
155
+ thread="$(printf '%s' "$ensure_json" | jq -er '.threadId // empty' | tr -d '\r')" || die "registration incomplete: ensure output missing threadId"
156
+ channel="$(printf '%s' "$ensure_json" | jq -er '.channelId // empty' | tr -d '\r')" || die "registration incomplete: ensure output missing channelId"
124
157
  [ -n "$thread" ] || die "registration incomplete: empty threadId"
125
158
  [ -n "$channel" ] || die "registration incomplete: empty channelId"
126
159
 
@@ -128,7 +161,7 @@ register_one() {
128
161
  local rel="companies/$company/projects/$project/prd.json"
129
162
  local now title tmp
130
163
  now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
131
- title="$(jq -r '.name // empty' "$prd")"
164
+ title="$(jq -r '.name // empty' "$prd" | tr -d '\r')"
132
165
  [ -n "$title" ] || title="$project"
133
166
  mkdir -p "$(dirname "$board")"
134
167
  if [ ! -f "$board" ]; then
@@ -136,7 +169,8 @@ register_one() {
136
169
  fi
137
170
  tmp="$(mktemp)"
138
171
  jq \
139
- --arg id "$project" \
172
+ --arg id "$mesh_project" \
173
+ --arg project "$project" \
140
174
  --arg path "$rel" \
141
175
  --arg thread "$thread" \
142
176
  --arg channel "$channel" \
@@ -144,9 +178,9 @@ register_one() {
144
178
  --arg title "$title" \
145
179
  '
146
180
  .projects = (.projects // []) |
147
- if any(.projects[]; .id == $id or .prd_path == $path or .mesh_project_id == $id) then
181
+ if any(.projects[]; .id == $id or .id == $project or .prd_path == $path or .mesh_project_id == $id) then
148
182
  .projects |= map(
149
- if .id == $id or .prd_path == $path or .mesh_project_id == $id then
183
+ if .id == $id or .id == $project or .prd_path == $path or .mesh_project_id == $id then
150
184
  . + {threadId:$thread, channelId:$channel, updated_at:$now, prd_path:(.prd_path // $path), mesh_project_id:$id} | del(.pending_registration)
151
185
  else . end
152
186
  )
@@ -169,14 +203,45 @@ register_one() {
169
203
  jq -e \
170
204
  --arg id "$project" \
171
205
  --arg path "$rel" \
206
+ --arg mesh "$mesh_project" \
172
207
  --arg thread "$thread" \
173
208
  --arg channel "$channel" \
174
- 'any(.projects[]?; (.id == $id or .prd_path == $path or .mesh_project_id == $id) and .threadId == $thread and .channelId == $channel)' \
209
+ 'any(.projects[]?; (.id == $id or .prd_path == $path or .mesh_project_id == $mesh) and .mesh_project_id == $mesh and .threadId == $thread and .channelId == $channel)' \
175
210
  "$board" >/dev/null \
176
211
  || die "registration incomplete: board.json entry for $project did not verify"
177
212
  echo "registered $company/$project thread=$thread channel=$channel"
178
213
  }
179
214
 
215
+ register_brainstorm() {
216
+ local company="$1" project="$2" board title description ensure_json thread channel tmp
217
+ resolve_uid "$company" >/dev/null
218
+ board="$(board_path "$company")"
219
+ [ -f "$board" ] || die "registration incomplete: missing $board"
220
+ title="$(jq -r --arg id "$project" '[.projects[]? | select(.id == $id or .mesh_project_id == $id)] | first | .title // empty' "$board" | tr -d '\r')"
221
+ description="$(jq -r --arg id "$project" '[.projects[]? | select(.id == $id or .mesh_project_id == $id)] | first | .description // empty' "$board" | tr -d '\r')"
222
+ [ -n "$title" ] || die "registration incomplete: missing local Board project $company/$project"
223
+ if ! ensure_json="$(hq mesh project set "$project" --company "$company" --name "$title" --description "$description" --create --json)"; then
224
+ die "registration incomplete: hq mesh project set failed for $company/$project"
225
+ fi
226
+ thread="$(printf '%s' "$ensure_json" | jq -er '.registration.threadId // .threadId // empty' | tr -d '\r')" \
227
+ || die "registration incomplete: project set output missing threadId"
228
+ channel="$(printf '%s' "$ensure_json" | jq -er '.registration.channelId // .channelId // empty' | tr -d '\r')" \
229
+ || die "registration incomplete: project set output missing channelId"
230
+ [ -n "$thread" ] && [ -n "$channel" ] || die "registration incomplete: empty threadId or channelId"
231
+ tmp="$(mktemp)"
232
+ jq --arg id "$project" --arg thread "$thread" --arg channel "$channel" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
233
+ .projects |= map(if .id == $id or .mesh_project_id == $id then . + {
234
+ threadId:$thread, channelId:$channel, mesh_project_id:$id,
235
+ mesh_registration_prompt_state:"accepted", updated_at:$now
236
+ } | del(.pending_registration) else . end)
237
+ ' "$board" > "$tmp"
238
+ mv "$tmp" "$board"
239
+ jq -e --arg id "$project" --arg thread "$thread" --arg channel "$channel" \
240
+ 'any(.projects[]?; (.id == $id or .mesh_project_id == $id) and .threadId == $thread and .channelId == $channel)' \
241
+ "$board" >/dev/null || die "registration incomplete: local Board registration did not verify"
242
+ echo "registered $company/$project thread=$thread channel=$channel"
243
+ }
244
+
180
245
  audit() {
181
246
  local company="$1"
182
247
  [ -n "$company" ] || die "usage: register-project.sh --audit <company>"
@@ -248,7 +313,10 @@ retry_pending() {
248
313
  done < <(jq -r '[.projects[]? | select(.pending_registration == true) | .id // empty] | .[]' "$board" 2>/dev/null | tr -d '\r' || true)
249
314
  }
250
315
 
251
- if [ "${1:-}" = "--audit" ]; then
316
+ if [ "${1:-}" = "--brainstorm" ]; then
317
+ [ "$#" -eq 3 ] || die "usage: register-project.sh --brainstorm <company> <board-project-id>"
318
+ register_brainstorm "$2" "$3"
319
+ elif [ "${1:-}" = "--audit" ]; then
252
320
  audit "${2:-}"
253
321
  elif [ "${1:-}" = "--backfill" ]; then
254
322
  shift
@@ -258,5 +326,5 @@ elif [ "${1:-}" = "--retry-pending" ]; then
258
326
  elif [ $# -eq 2 ]; then
259
327
  register_one "$1" "$2"
260
328
  else
261
- die "usage: register-project.sh <company> <project> | --audit <company> | --backfill <company> --only-active [--yes] | --retry-pending <company>"
329
+ die "usage: register-project.sh <company> <project> | --brainstorm <company> <board-project-id> | --audit <company> | --backfill <company> --only-active [--yes] | --retry-pending <company>"
262
330
  fi
@@ -199,11 +199,11 @@ existing_state() {
199
199
  fi
200
200
 
201
201
  local record_thread record_session record_at record_epoch record_generation current_epoch
202
- record_thread="$(jq -r '.thread_id' "$record_path")"
203
- record_session="$(jq -r '.session_id' "$record_path")"
204
- record_at="$(jq -r '.resumed_at' "$record_path")"
205
- record_epoch="$(jq -r '.resumed_epoch' "$record_path")"
206
- record_generation="$(jq -r '.generation' "$record_path")"
202
+ record_thread="$(jq -r '.thread_id' "$record_path" | tr -d '\r')"
203
+ record_session="$(jq -r '.session_id' "$record_path" | tr -d '\r')"
204
+ record_at="$(jq -r '.resumed_at' "$record_path" | tr -d '\r')"
205
+ record_epoch="$(jq -r '.resumed_epoch' "$record_path" | tr -d '\r')"
206
+ record_generation="$(jq -r '.generation' "$record_path" | tr -d '\r')"
207
207
 
208
208
  if [[ "$record_thread" != "$thread_id" || ! "$record_epoch" =~ ^[0-9]+$ ]]; then
209
209
  emit_stale "invalid-record" "$record_session" "$record_at" "$record_generation"
@@ -113,7 +113,10 @@ const sortKeys = (v) =>
113
113
 
114
114
  function readJson(p, dflt) {
115
115
  if (!fs.existsSync(p)) return dflt;
116
- try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch (e) { return dflt; }
116
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch (e) {
117
+ console.error(`share-suggestion-state: unable to read ${p} (${e.name || "Error"})`);
118
+ process.exit(3);
119
+ }
117
120
  }
118
121
 
119
122
  function writeJson(p, payload) {
@@ -537,5 +540,10 @@ process.exit(0);
537
540
  JS
538
541
  }
539
542
 
540
- main "$@" || log_error "internal error"
543
+ # Exit 3 means an existing state file could not be read; callers see that
544
+ # failure. Every other error is logged and the helper exits 0.
545
+ main "$@"
546
+ status=$?
547
+ [ "$status" -eq 3 ] && exit 3
548
+ [ "$status" -eq 0 ] || log_error "internal error"
541
549
  exit 0
@@ -144,9 +144,41 @@ if [ "${HQ_WORK_MESH_RECONCILE_STUB:-}" = "1" ]; then
144
144
  fi
145
145
 
146
146
  HQ_BIN="$(command -v hq 2>/dev/null || true)"
147
- if [ -n "$HQ_BIN" ] && [ -f "$OBS_FILE" ]; then
148
- nohup "$HQ_BIN" mesh context reconcile --observation-file "$OBS_FILE" --machine \
149
- >/dev/null 2>&1 </dev/null &
150
- disown 2>/dev/null || true
147
+ if [ -z "$HQ_BIN" ]; then
148
+ printf '%s\n' 'Work Mesh reconcile skipped: hq CLI not found on PATH.' >&2
149
+ else
150
+ flag_reader="$ROOT/.claude/hooks/work-mesh-daemon-not-loaded-flag.cjs"
151
+ daemon_warning_enabled=false
152
+ if [[ -n "${HQ_FLAGS_API_URL:-}" \
153
+ && "${HQ_COMPANY_UID:-}" =~ ^cmp_[A-Za-z0-9]{3,128}$ ]] \
154
+ && command -v node >/dev/null 2>&1 && [[ -f "$flag_reader" ]]; then
155
+ daemon_warning_enabled="$(HQ_CLI_BIN="$HQ_BIN" node "$flag_reader")" || daemon_warning_enabled=false
156
+ fi
157
+ if [ "$daemon_warning_enabled" = "true" ]; then
158
+ # Status only reads the installed service and its live state. Keep this hook
159
+ # bounded, and fail quiet when the CLI or service manager cannot answer.
160
+ daemon_status_file="$(mktemp "${TMPDIR:-/tmp}/hq-work-mesh-daemon-status.XXXXXX" 2>/dev/null)" || daemon_status_file=""
161
+ if [ -n "$daemon_status_file" ]; then
162
+ HQ_NO_UPDATE_CHECK=1 "$HQ_BIN" mesh daemon status --json >"$daemon_status_file" 2>/dev/null &
163
+ daemon_status_pid=$!
164
+ ( sleep 2; kill "$daemon_status_pid" 2>/dev/null || true ) &
165
+ daemon_status_watchdog=$!
166
+ daemon_status_rc=0
167
+ wait "$daemon_status_pid" || daemon_status_rc=$?
168
+ kill "$daemon_status_watchdog" 2>/dev/null || true
169
+ wait "$daemon_status_watchdog" 2>/dev/null || true
170
+ if [ "$daemon_status_rc" -eq 0 ] && command -v jq >/dev/null 2>&1 && \
171
+ jq -e '(.ok == true) and (.running == false) and (.message | type == "string" and contains("installed ("))' \
172
+ "$daemon_status_file" >/dev/null 2>&1; then
173
+ printf '%s\n' 'Work Mesh daemon is installed but not loaded; start it with: hq mesh daemon install' >&2
174
+ fi
175
+ rm -f "$daemon_status_file" 2>/dev/null || true
176
+ fi
177
+ fi
178
+ if [ -f "$OBS_FILE" ]; then
179
+ nohup "$HQ_BIN" mesh context reconcile --observation-file "$OBS_FILE" --machine \
180
+ >/dev/null 2>&1 </dev/null &
181
+ disown 2>/dev/null || true
182
+ fi
151
183
  fi
152
184
  exit 0
@@ -249,7 +249,7 @@ for wt in "${CANDIDATES[@]:-}"; do
249
249
  created_epoch="$wt_mtime"
250
250
  stamp_file="$GC_META_DIR/$(path_key "$wt").json"
251
251
  if [[ -f "$stamp_file" ]]; then
252
- stamped=$(jq -r '.createdAtEpoch // empty' "$stamp_file" 2>/dev/null || echo "")
252
+ stamped=$(jq -r '.createdAtEpoch // empty' "$stamp_file" 2>/dev/null | tr -d '\r' || echo "")
253
253
  [[ -n "$stamped" ]] && created_epoch="$stamped"
254
254
  fi
255
255
  age_days=$(( (NOW - created_epoch) / 86400 ))
@@ -799,15 +799,119 @@ export function worklistCommand(args, root) {
799
799
  if (validation.code !== 0)
800
800
  return err(2, `${validation.stderr}${validation.stdout}${validation.stderr}`);
801
801
  const ledger = path.join(directory, ".processed");
802
+ const skipLedger = path.join(directory, ".skipped");
803
+ const parserVersion = 2;
802
804
  if (doneItem) {
803
- fs.appendFileSync(ledger, `${path.basename(doneItem)}\n`);
805
+ fs.appendFileSync(ledger, `${path.basename(doneItem)}\t${parserVersion}\n`);
804
806
  return ok();
805
807
  }
808
+ const hasFrontmatterField = (file, field) => {
809
+ if (!fs.existsSync(file))
810
+ return false;
811
+ let frontmatter = 0;
812
+ for (const rawLine of fs.readFileSync(file, "utf8").split("\n")) {
813
+ const line = rawLine.replace(/\r$/, "");
814
+ if (line === "---") {
815
+ frontmatter += 1;
816
+ if (frontmatter === 2)
817
+ return false;
818
+ continue;
819
+ }
820
+ if (frontmatter === 1 && line.startsWith(`${field}:`))
821
+ return true;
822
+ }
823
+ return false;
824
+ };
825
+ const rewriteIfChanged = (file, original, output) => {
826
+ if (output === original)
827
+ return;
828
+ const temporary = `${file}.native-${process.pid}`;
829
+ fs.writeFileSync(temporary, output);
830
+ fs.renameSync(temporary, file);
831
+ };
832
+ // Match the shell's parser-version migration. Unstamped legacy rows are
833
+ // stamped unless they carry the old resolved_participants bug signature;
834
+ // older parser versions are dropped so those sources are re-evaluated.
835
+ let processed = "";
836
+ if (fs.existsSync(ledger)) {
837
+ const original = fs.readFileSync(ledger, "utf8");
838
+ const kept = [];
839
+ let changed = false;
840
+ const rows = original.split("\n");
841
+ if (rows.at(-1) === "")
842
+ rows.pop();
843
+ for (const rawLine of rows) {
844
+ const fields = rawLine.split("\t");
845
+ const basename = (fields[0] ?? "").replace(/\r$/, "");
846
+ const version = (fields[1] ?? "").replace(/\r$/, "");
847
+ if (!basename)
848
+ continue;
849
+ if (version) {
850
+ if (/^[+-]?\d+$/.test(version) && Number(version) < parserVersion) {
851
+ changed = true;
852
+ continue;
853
+ }
854
+ kept.push(`${basename}\t${version}`);
855
+ continue;
856
+ }
857
+ changed = true;
858
+ if (hasFrontmatterField(path.join(directory, basename), "resolved_participants"))
859
+ continue;
860
+ kept.push(`${basename}\t${parserVersion}`);
861
+ }
862
+ processed = kept.join("\n");
863
+ if (kept.length > 0)
864
+ processed += "\n";
865
+ if (changed)
866
+ rewriteIfChanged(ledger, original, processed);
867
+ }
868
+ let skipped = "";
869
+ if (fs.existsSync(skipLedger)) {
870
+ const original = fs.readFileSync(skipLedger, "utf8");
871
+ const kept = [];
872
+ let changed = false;
873
+ const rows = original.split("\n");
874
+ if (rows.at(-1) === "")
875
+ rows.pop();
876
+ for (const rawLine of rows) {
877
+ const fields = rawLine.split("\t");
878
+ const basename = fields[0] ?? "";
879
+ const reason = fields[1] ?? "";
880
+ const version = fields[2] ?? "";
881
+ if (!basename)
882
+ continue;
883
+ if (!version || (/^[+-]?\d+$/.test(version) && Number(version) < parserVersion)) {
884
+ changed = true;
885
+ continue;
886
+ }
887
+ kept.push(`${basename}\t${reason}\t${version}`);
888
+ }
889
+ skipped = kept.join("\n");
890
+ if (kept.length > 0)
891
+ skipped += "\n";
892
+ if (changed)
893
+ rewriteIfChanged(skipLedger, original, skipped);
894
+ }
895
+ const isProcessed = (basename) => processed.split("\n").some((line) => {
896
+ if (!line)
897
+ return false;
898
+ const [name, version] = line.split("\t");
899
+ if (name !== basename)
900
+ return false;
901
+ if (version === undefined || version === "")
902
+ return true;
903
+ return Number(version) >= parserVersion;
904
+ });
905
+ const isSkippedAtCurrentVersion = (basename) => skipped.split("\n").some((line) => {
906
+ if (!line)
907
+ return false;
908
+ const fields = line.split("\t");
909
+ return fields[0] === basename && fields[2] === String(parserVersion);
910
+ });
806
911
  const run = awkField(sourceFile, "run", false);
807
912
  const rule = awkField(sourceFile, "audience_rule", false);
808
913
  if (run === "cloud" && !force)
809
914
  return err(4, "assigned to cloud agent (run: cloud); pass --force to run locally\n");
810
- const processed = fs.existsSync(ledger) ? fs.readFileSync(ledger, "utf8").split("\n") : [];
811
915
  const entries = fs.existsSync(directory)
812
916
  ? fs.readdirSync(directory).filter((name) => name.endsWith(".md") && !name.startsWith("."))
813
917
  .filter((name) => { try {
@@ -821,12 +925,12 @@ export function worklistCommand(args, root) {
821
925
  const output = [];
822
926
  let count = 0;
823
927
  for (const name of entries) {
824
- if (processed.includes(name))
928
+ if (isProcessed(name) || isSkippedAtCurrentVersion(name))
825
929
  continue;
826
930
  if (/^[+-]?\d+$/.test(limit) && count >= Number(limit))
827
931
  break;
828
932
  if (!/^[+-]?\d+$/.test(limit)) {
829
- process.stderr.write(`${resolveBundledAsset(`${ASSET}ontology-source-worklist.sh`)}: line 61: [: ${limit}: integer expression expected\n`);
933
+ process.stderr.write(`${resolveBundledAsset(`${ASSET}ontology-source-worklist.sh`)}: line 186: [: ${limit}: integer expression expected\n`);
830
934
  }
831
935
  count += 1;
832
936
  const file = path.join(directory, name);
@@ -860,6 +964,8 @@ export function worklistCommand(args, root) {
860
964
  }
861
965
  }
862
966
  if (!audience) {
967
+ fs.appendFileSync(skipLedger, `${name}\tno-audience\t${parserVersion}\n`);
968
+ skipped += `${name}\tno-audience\t${parserVersion}\n`;
863
969
  output.push(`${JSON.stringify({ file: relative, skip: "no-audience" })}\n`);
864
970
  continue;
865
971
  }
@@ -51,11 +51,44 @@ function companyUid(company, deps) {
51
51
  return direct;
52
52
  const file = path.join(deps.root, "companies", company, ".company-uid");
53
53
  try {
54
- return deps.fs.readFileSync(file, "utf8").replace(/[\s\u0085\u00a0]+/g, "");
54
+ const fromFile = deps.fs.readFileSync(file, "utf8").replace(/[\s\u0085\u00a0]+/g, "");
55
+ if (fromFile)
56
+ return fromFile;
57
+ }
58
+ catch { /* shell falls through to companies/manifest.yaml */ }
59
+ const manifest = path.join(deps.root, "companies", "manifest.yaml");
60
+ let contents;
61
+ try {
62
+ contents = deps.fs.readFileSync(manifest, "utf8");
55
63
  }
56
64
  catch {
57
65
  return null;
58
66
  }
67
+ let inCompanies = false;
68
+ let inCompany = false;
69
+ for (const line of contents.split(/\r?\n/u)) {
70
+ if (/^companies:[\t ]*$/u.test(line)) {
71
+ inCompanies = true;
72
+ continue;
73
+ }
74
+ if (inCompanies && /^[^\t ]/u.test(line)) {
75
+ inCompanies = false;
76
+ inCompany = false;
77
+ }
78
+ if (inCompanies && /^ {2}[^ #][^:]*:/u.test(line)) {
79
+ const slug = line.trim().split(/[\t ]+/u, 1)[0]?.replace(/:$/u, "");
80
+ inCompany = slug === company;
81
+ continue;
82
+ }
83
+ if (inCompany && /^ {2}[^\t ]/u.test(line))
84
+ break;
85
+ if (inCompany) {
86
+ const fields = line.trim().split(/[\t ]+/u);
87
+ if (fields[0] === "cloud_uid:" && fields[1])
88
+ return fields[1].replace(/["']/gu, "").replace(/[\t ]+$/u, "");
89
+ }
90
+ }
91
+ return null;
59
92
  }
60
93
  function boardPath(company, deps) { return path.join(deps.root, "companies", company, "board.json"); }
61
94
  function runHelpProbe(deps) {
@@ -217,7 +250,7 @@ function audit(company, deps) {
217
250
  return false;
218
251
  const uid = companyUid(company, deps);
219
252
  if (uid === null) {
220
- stderr += `no company uid for ${company} (set HQ_COMPANY_UID or companies/${company}/.company-uid)\n`;
253
+ stderr += `no company uid for ${company} (set HQ_COMPANY_UID, companies/${company}/.company-uid, or manifest cloud_uid)\n`;
221
254
  return true;
222
255
  }
223
256
  return !deps.fs.existsSync(path.join(deps.env.HOME || os.homedir(), ".hq/work-mesh/cache/projects", uid, `${project}.json`));
@@ -243,7 +276,7 @@ function storyNotDone(prd, deps, diagnostics) {
243
276
  diagnostics.push(jq.stderr);
244
277
  else if (jq.error?.code === "ENOENT") {
245
278
  const script = resolveBundledAsset(REGISTER_PROJECT_ASSET);
246
- diagnostics.push(`${script}: line 34: jq: command not found\n`);
279
+ diagnostics.push(`${script}: line 65: jq: command not found\n`);
247
280
  }
248
281
  return false;
249
282
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.19",
3
+ "version": "5.345.20",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {