@indigoai-us/hq-cli 5.119.13 → 5.119.15

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.119.15] — 2026-09-18
6
+
7
+ ### Fixed
8
+
9
+ - `hq people resolve <name>` now reports ambiguity when the name matches more
10
+ than one person instead of selecting the first match.
11
+
5
12
  ## [5.119.13] — 2026-09-18
6
13
 
7
14
  ### Added
@@ -11,6 +18,16 @@
11
18
  out it says to run `/update-hq`; if the lookup cannot reach the network it
12
19
  says so instead of going quiet. `hq version --json` prints the same payload.
13
20
 
21
+ ### Fixed
22
+
23
+ - `hq core rebuild-index company-knowledge` no longer silently replaces a
24
+ hand-written company knowledge INDEX. If the file is not the last generated
25
+ output, the command refuses, prints a short diff, and asks for `--accept`.
26
+ `<!-- hq:keep -->` opts the whole file out; `<!-- hq:keep -->` … `<!-- /hq:keep -->`
27
+ fences are kept while the table regenerates. Subdirectory README headings
28
+ are projected into the description column so the generated index is usable
29
+ without hand edits.
30
+
14
31
  ## [5.119.12] — 2026-09-18
15
32
 
16
33
  ### Fixed
@@ -8,7 +8,7 @@
8
8
  # - .md files → first `#` heading (stripped)
9
9
  # - .yaml files → `description:` field if present
10
10
  # - .json files → `description` field if present
11
- # - directories → "{N} item(s)"
11
+ # - directories → README.md `#` heading plus "{N} item(s)", or "{N} item(s)"
12
12
  #
13
13
  # Most company knowledge dirs are embedded git repos (160000 gitlinks). The
14
14
  # generated INDEX.md lives inside the inner repo; HQ git won't track its
@@ -42,7 +42,15 @@ describe_item() {
42
42
  if [[ -d "$path" ]]; then
43
43
  local n
44
44
  n=$(find "$path" -mindepth 1 -maxdepth 1 ! -name '.*' 2>/dev/null | wc -l | tr -d ' ')
45
- echo "${n} item(s)"
45
+ local h=""
46
+ if [[ -f "$path/README.md" ]]; then
47
+ h=$(awk '/^# / { sub(/^# +/, ""); print; exit }' "$path/README.md" 2>/dev/null || true)
48
+ fi
49
+ if [[ -n "$h" ]]; then
50
+ echo "${h} (${n} item(s))"
51
+ else
52
+ echo "${n} item(s)"
53
+ fi
46
54
  return
47
55
  fi
48
56
  case "$name" in
@@ -70,6 +78,26 @@ describe_item() {
70
78
  esac
71
79
  }
72
80
 
81
+ sha256_file() {
82
+ if command -v sha256sum >/dev/null 2>&1; then
83
+ sha256sum "$1" | awk '{print $1}'
84
+ else
85
+ shasum -a 256 "$1" | awk '{print $1}'
86
+ fi
87
+ }
88
+
89
+ stamp_generated() {
90
+ local tmp="$1"
91
+ local out="$2"
92
+ local norm
93
+ norm=$(mktemp)
94
+ sed 's/^> Auto-generated. Updated: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/> Auto-generated. Updated: <date>/' "$tmp" > "$norm"
95
+ local hash
96
+ hash=$(sha256_file "$norm")
97
+ rm -f "$norm"
98
+ awk -v h="$hash" '{print} /^> Auto-generated. Updated: / {print "<!-- hq-generated-sha256: " h " -->"}' "$tmp" > "$out"
99
+ }
100
+
73
101
  write_knowledge_index() {
74
102
  local co="$1"
75
103
  local kdir="companies/${co}/knowledge"
@@ -77,6 +105,8 @@ write_knowledge_index() {
77
105
  local out="${kdir}/INDEX.md"
78
106
  local title
79
107
  title="$(titleize "$co") Knowledge"
108
+ local tmp
109
+ tmp=$(mktemp)
80
110
 
81
111
  {
82
112
  echo "# ${title}"
@@ -108,7 +138,10 @@ write_knowledge_index() {
108
138
  find -L "$kdir" -mindepth 1 -maxdepth 1 -type f 2>/dev/null | sort
109
139
  }
110
140
  )
111
- } > "$out"
141
+ } > "$tmp"
142
+
143
+ stamp_generated "$tmp" "$out"
144
+ rm -f "$tmp"
112
145
 
113
146
  echo "rebuild-company-knowledge-index: wrote ${out}" >&2
114
147
  }
@@ -408,7 +408,19 @@ export function registerCoreCommands(program) {
408
408
  const scope = core.opts();
409
409
  const hqRoot = resolveLiveRoot({ hqRoot: scope.hqRoot });
410
410
  const operands = cmd.args.length > 0 ? cmd.args : args;
411
- renderIndexTarget(entry.renderer, { root: hqRoot, log: (message) => process.stderr.write(`${message}\n`) }, operands.slice(1));
411
+ const passthrough = operands.slice(1);
412
+ const force = passthrough.includes("--accept");
413
+ const rendererArgs = passthrough.filter((arg) => arg !== "--accept");
414
+ const refused = [];
415
+ renderIndexTarget(entry.renderer, {
416
+ root: hqRoot,
417
+ log: (message) => process.stderr.write(`${message}\n`),
418
+ force,
419
+ refused,
420
+ }, rendererArgs);
421
+ if (refused.length > 0) {
422
+ throw expectedUserError(`rebuild-index: refused to overwrite ${refused.length} hand-edited INDEX.md file(s). Re-run with --accept to overwrite.`);
423
+ }
412
424
  });
413
425
  for (const entry of SCAFFOLD_COMMANDS) {
414
426
  core
@@ -1,11 +1,13 @@
1
- import { basename, date, immediateEntries, isHidden, log, readJson, readText, sanitize, titleize, truncate, write } from "./shared.js";
1
+ import * as fs from "fs";
2
+ import { at, basename, date, extractKeepFences, generatedFingerprint, generatedStamp, hasUnclosedKeepMarker, heading, immediateEntries, isHidden, log, looksAutoGenerated, readJson, readText, sanitize, stampGenerated, titleize, truncate, write, } from "./shared.js";
2
3
  function describe(item) {
3
4
  const name = basename(item);
4
- // `find -L` follows a symlink only for its direct target. fs.statSync mirrors that.
5
5
  try {
6
- if ((awaitStat(item)).isDirectory()) {
7
- const fs = requireFs();
8
- return `${fs.readdirSync(item).filter((entry) => !entry.startsWith(".")).length} item(s)`;
6
+ if (fs.statSync(item).isDirectory()) {
7
+ const entries = fs.readdirSync(item).filter((entry) => !entry.startsWith("."));
8
+ const n = entries.length;
9
+ const summary = heading(`${item}/README.md`);
10
+ return summary ? `${summary} (${n} item(s))` : `${n} item(s)`;
9
11
  }
10
12
  }
11
13
  catch {
@@ -23,13 +25,55 @@ function describe(item) {
23
25
  }
24
26
  return name;
25
27
  }
26
- // Avoid a heavier tree abstraction here: the source script's `find -L` is
27
- // specifically about knowledge gitlinks, so stat is intentionally local.
28
- function requireFs() { return fsModule; }
29
- import * as fsModule from "fs";
30
- function awaitStat(file) { return fsModule.statSync(file); }
28
+ function briefDiff(existing, next) {
29
+ const oldLines = existing.split("\n");
30
+ const newLines = next.split("\n");
31
+ const lines = ["--- existing", "+++ generated"];
32
+ const limit = Math.max(oldLines.length, newLines.length);
33
+ let shown = 0;
34
+ for (let i = 0; i < limit && shown < 20; i += 1) {
35
+ const a = oldLines[i];
36
+ const b = newLines[i];
37
+ if (a === b)
38
+ continue;
39
+ if (a !== undefined)
40
+ lines.push(`- ${a}`);
41
+ if (b !== undefined)
42
+ lines.push(`+ ${b}`);
43
+ shown += 1;
44
+ }
45
+ if (shown === 20)
46
+ lines.push("…");
47
+ return lines.join("\n");
48
+ }
49
+ function composeIndex(raw, existing) {
50
+ const stamped = stampGenerated(raw);
51
+ const fences = existing ? extractKeepFences(existing) : "";
52
+ if (!fences)
53
+ return stamped;
54
+ return `${stamped.replace(/\n+$/, "\n")}\n${fences}\n`;
55
+ }
56
+ function shouldOverwrite(context, existing, next) {
57
+ if (!existing)
58
+ return "write";
59
+ if (context.force)
60
+ return "write";
61
+ if (hasUnclosedKeepMarker(existing))
62
+ return "skip";
63
+ const stored = generatedStamp(existing);
64
+ if (stored && stored === generatedFingerprint(existing))
65
+ return "write";
66
+ if (stored)
67
+ return "refuse";
68
+ if (looksAutoGenerated(existing))
69
+ return "write";
70
+ if (generatedFingerprint(existing) === generatedFingerprint(next))
71
+ return "write";
72
+ return "refuse";
73
+ }
31
74
  export function renderCompanyKnowledge(context) {
32
75
  const written = [];
76
+ let refused = 0;
33
77
  for (const companyDir of immediateEntries(context.root, "companies", "dir")) {
34
78
  const company = basename(companyDir);
35
79
  if (company.startsWith("_") || isHidden(company))
@@ -37,24 +81,24 @@ export function renderCompanyKnowledge(context) {
37
81
  const relative = `companies/${company}/knowledge`;
38
82
  const knowledge = `${companyDir}/knowledge`;
39
83
  try {
40
- if (!fsModule.statSync(knowledge).isDirectory())
84
+ if (!fs.statSync(knowledge).isDirectory())
41
85
  continue;
42
86
  }
43
87
  catch {
44
88
  continue;
45
89
  }
46
- const entries = fsModule.readdirSync(knowledge).flatMap((name) => {
90
+ const entries = fs.readdirSync(knowledge).flatMap((name) => {
47
91
  const item = `${knowledge}/${name}`;
48
92
  try {
49
- return fsModule.statSync(item).isDirectory() ? [item] : [];
93
+ return fs.statSync(item).isDirectory() ? [item] : [];
50
94
  }
51
95
  catch {
52
96
  return [];
53
97
  }
54
- }).sort().concat(fsModule.readdirSync(knowledge).flatMap((name) => {
98
+ }).sort().concat(fs.readdirSync(knowledge).flatMap((name) => {
55
99
  const item = `${knowledge}/${name}`;
56
100
  try {
57
- return fsModule.statSync(item).isFile() ? [item] : [];
101
+ return fs.statSync(item).isFile() ? [item] : [];
58
102
  }
59
103
  catch {
60
104
  return [];
@@ -68,18 +112,37 @@ export function renderCompanyKnowledge(context) {
68
112
  const value = truncate(sanitize(describe(item)), 100) || "—";
69
113
  let directory = false;
70
114
  try {
71
- directory = fsModule.statSync(item).isDirectory();
115
+ directory = fs.statSync(item).isDirectory();
72
116
  }
73
117
  catch { /* omitted */ }
74
118
  lines.push(`| \`${name}${directory ? "/" : ""}\` | ${value} |`);
75
119
  }
76
120
  lines.push("");
77
121
  const output = `${relative}/INDEX.md`;
78
- write(context, output, lines.join("\n"));
122
+ const raw = lines.join("\n");
123
+ const existing = readText(at(context.root, output));
124
+ const next = composeIndex(raw, existing);
125
+ const decision = shouldOverwrite(context, existing, next);
126
+ if (decision === "skip") {
127
+ log(context, `rebuild-company-knowledge-index: skipped ${output} (<!-- hq:keep -->)`);
128
+ continue;
129
+ }
130
+ if (decision === "refuse") {
131
+ refused += 1;
132
+ context.refused?.push(output);
133
+ log(context, `rebuild-company-knowledge-index: refused ${output} (hand-written or edited; differs from last generated output)`);
134
+ log(context, briefDiff(existing ?? "", next));
135
+ log(context, "Re-run with --accept to overwrite, or add <!-- hq:keep --> to opt out.");
136
+ continue;
137
+ }
138
+ write(context, output, next);
79
139
  written.push(output);
80
140
  log(context, `rebuild-company-knowledge-index: wrote ${output}`);
81
141
  }
82
142
  log(context, `rebuild-company-knowledge-index: regenerated ${written.length} knowledge INDEX.md file(s)`);
143
+ if (refused > 0) {
144
+ log(context, `rebuild-company-knowledge-index: refused to overwrite ${refused} hand-edited INDEX.md file(s)`);
145
+ }
83
146
  return { written };
84
147
  }
85
148
  //# sourceMappingURL=company-knowledge.js.map
@@ -3,6 +3,10 @@ export type RenderContext = {
3
3
  now?: Date;
4
4
  log?: (message: string) => void;
5
5
  output?: (message: string) => void;
6
+ /** Overwrite hand-edited INDEX.md files (`rebuild-index … --accept`). */
7
+ force?: boolean;
8
+ /** Relative paths that were not overwritten because they differ from last generated output. */
9
+ refused?: string[];
6
10
  };
7
11
  export type RenderResult = {
8
12
  written: string[];
@@ -26,6 +30,14 @@ export declare function timestamp(now?: Date): string;
26
30
  /** Write via a same-directory temporary file, then atomically replace the destination. */
27
31
  export declare function atomicWrite(file: string, content: string): void;
28
32
  export declare function write(context: RenderContext, relative: string, content: string): string;
33
+ /** Drop keep fences, the generated stamp, and the rolling date so two generated bodies can be compared. */
34
+ export declare function generatedFingerprint(content: string): string;
35
+ /** Insert a content stamp after the Auto-generated line. Idempotent. */
36
+ export declare function stampGenerated(content: string): string;
37
+ export declare function extractKeepFences(content: string): string;
38
+ export declare function hasUnclosedKeepMarker(content: string): boolean;
39
+ export declare function looksAutoGenerated(content: string): boolean;
40
+ export declare function generatedStamp(content: string): string | undefined;
29
41
  export declare function log(context: RenderContext, message: string): void;
30
42
  export declare function projectStatus(root: string, project: string, prdPath: string, fallback: string): string;
31
43
  export declare function basename(file: string): string;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "crypto";
1
2
  import * as fs from "fs";
2
3
  import * as os from "os";
3
4
  import * as path from "path";
@@ -96,6 +97,42 @@ export function write(context, relative, content) {
96
97
  atomicWrite(at(context.root, relative), content);
97
98
  return relative;
98
99
  }
100
+ const GENERATED_DATE_LINE = /^> Auto-generated\. Updated: \d{4}-\d{2}-\d{2}$/m;
101
+ const GENERATED_STAMP_LINE = /^<!-- hq-generated-sha256: [a-f0-9]{64} -->\n?/m;
102
+ function keepFencePattern() {
103
+ return /<!-- hq:keep -->\r?\n?[\s\S]*?<!-- \/hq:keep -->/g;
104
+ }
105
+ /** Drop keep fences, the generated stamp, and the rolling date so two generated bodies can be compared. */
106
+ export function generatedFingerprint(content) {
107
+ const normalized = content
108
+ .replace(keepFencePattern(), "")
109
+ .replace(GENERATED_STAMP_LINE, "")
110
+ .replace(GENERATED_DATE_LINE, "> Auto-generated. Updated: <date>")
111
+ .replace(/\n+$/, "\n");
112
+ return createHash("sha256").update(normalized, "utf8").digest("hex");
113
+ }
114
+ /** Insert a content stamp after the Auto-generated line. Idempotent. */
115
+ export function stampGenerated(content) {
116
+ const unstamped = content.replace(GENERATED_STAMP_LINE, "");
117
+ const hash = generatedFingerprint(unstamped);
118
+ return unstamped.replace(GENERATED_DATE_LINE, (line) => `${line}\n<!-- hq-generated-sha256: ${hash} -->`);
119
+ }
120
+ export function extractKeepFences(content) {
121
+ return (content.match(keepFencePattern()) ?? []).join("\n\n");
122
+ }
123
+ export function hasUnclosedKeepMarker(content) {
124
+ const opens = content.match(/<!-- hq:keep -->/g)?.length ?? 0;
125
+ if (opens === 0)
126
+ return false;
127
+ const closes = content.match(/<!-- \/hq:keep -->/g)?.length ?? 0;
128
+ return opens > closes;
129
+ }
130
+ export function looksAutoGenerated(content) {
131
+ return GENERATED_DATE_LINE.test(content);
132
+ }
133
+ export function generatedStamp(content) {
134
+ return content.match(/^<!-- hq-generated-sha256: ([a-f0-9]{64}) -->/m)?.[1];
135
+ }
99
136
  export function log(context, message) { context.log?.(message); }
100
137
  export function projectStatus(root, project, prdPath, fallback) {
101
138
  const state = readJson(at(root, `workspace/orchestrator/${project}/state.json`));
@@ -86,17 +86,10 @@ export type ResolveResult = {
86
86
  /**
87
87
  * Resolve a person NAME to their email, built on top of {@link searchPeople}.
88
88
  *
89
- * Match precedence (narrowest first) so a precise query isn't drowned out by
90
- * looser substring hits:
91
- * 1. exact name match (case-insensitive, trimmed)
92
- * 2. exact folder-slug match
93
- * 3. substring search over name/email/slug
94
- *
95
- * The first tier that yields any match decides the result:
96
- * - exactly one match with an email → `found`
97
- * - exactly one match, no email → `no_email`
98
- * - more than one match → `ambiguous` (caller disambiguates)
99
- * - no match in any tier → `not_found`
89
+ * Resolution is deliberately stricter than a convenience ranking. Every
90
+ * case-insensitive name, email, or slug substring match is a candidate; an
91
+ * exact name never silently selects one person when the query also names
92
+ * other people. The caller must disambiguate any multi-person result.
100
93
  */
101
94
  export declare function resolveNameToEmail(people: PersonRecord[], name: string): ResolveResult;
102
95
  //# sourceMappingURL=people.d.ts.map
@@ -122,31 +122,16 @@ export function searchPeople(people, keyword) {
122
122
  /**
123
123
  * Resolve a person NAME to their email, built on top of {@link searchPeople}.
124
124
  *
125
- * Match precedence (narrowest first) so a precise query isn't drowned out by
126
- * looser substring hits:
127
- * 1. exact name match (case-insensitive, trimmed)
128
- * 2. exact folder-slug match
129
- * 3. substring search over name/email/slug
130
- *
131
- * The first tier that yields any match decides the result:
132
- * - exactly one match with an email → `found`
133
- * - exactly one match, no email → `no_email`
134
- * - more than one match → `ambiguous` (caller disambiguates)
135
- * - no match in any tier → `not_found`
125
+ * Resolution is deliberately stricter than a convenience ranking. Every
126
+ * case-insensitive name, email, or slug substring match is a candidate; an
127
+ * exact name never silently selects one person when the query also names
128
+ * other people. The caller must disambiguate any multi-person result.
136
129
  */
137
130
  export function resolveNameToEmail(people, name) {
138
131
  const query = name.trim();
139
132
  if (!query)
140
133
  return { status: "not_found" };
141
- const lowered = query.toLowerCase();
142
- const exactName = people.filter((p) => p.name.toLowerCase() === lowered);
143
- const exactSlug = people.filter((p) => p.slug.toLowerCase() === lowered);
144
- const substring = searchPeople(people, query);
145
- const matches = exactName.length > 0
146
- ? exactName
147
- : exactSlug.length > 0
148
- ? exactSlug
149
- : substring;
134
+ const matches = searchPeople(people, query);
150
135
  if (matches.length === 0)
151
136
  return { status: "not_found" };
152
137
  if (matches.length > 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.119.13",
3
+ "version": "5.119.15",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {