@indigoai-us/hq-cli 5.88.1 → 5.89.1

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,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.89.1]
6
+
7
+ ### Fixed
8
+
9
+ - `hq core worker lint` now partitions its duplicate check by ownership domain
10
+ (`core/workers`, each `core/packages/<pack>`, each `companies/<co>`) and
11
+ excludes dot-directories (`.archive/`) and `._*` AppleDouble files. A
12
+ whole-tree scan of a real install previously reported false positives for
13
+ packs that intentionally vendor a byte-identical copy of a core worker skill,
14
+ for archived pack snapshots, and for macOS metadata files. Within-domain
15
+ duplicates are still flagged. (policy
16
+ hq-lint-comparison-domains-must-match-ownership)
17
+
18
+ ## [5.89.0]
19
+
20
+ ### Added
21
+
22
+ - Native worker-registry generation and pack contribution scanning via `hq core
23
+ generate-workers-registry` and `hq core scan-packages`, replacing the staging
24
+ shell scripts; `hq reindex` now uses the native generator, with output
25
+ differential-proven against frozen staging references. Syntactically invalid
26
+ `worker.yaml` files are now quarantined rather than aborting the registry run.
27
+ (#315)
28
+
5
29
  ## [5.88.1]
6
30
 
7
31
  ### Fixed
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # FORWARDER — native implementation: `hq core generate-workers-registry`.
3
+ #
4
+ # This preserves the legacy script ABI. It discovers the live HQ root exactly
5
+ # as the original script did, forwards every argument untouched, and execs the
6
+ # CLI so stdout, stderr, exit status, and signal disposition remain the child's.
7
+
8
+ set -euo pipefail
9
+
10
+ HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
11
+
12
+ if ! command -v hq >/dev/null 2>&1; then
13
+ echo "generate-workers-registry.sh: requires the hq CLI — this script's implementation now ships with it." >&2
14
+ echo "Install it with: npm install -g @indigoai-us/hq-cli" >&2
15
+ exit 127
16
+ fi
17
+
18
+ exec hq core --hq-root "$HQ_ROOT" generate-workers-registry "$@"
@@ -12,22 +12,37 @@
12
12
  #
13
13
  # This linter makes that convention enforceable. It flags two failure shapes:
14
14
  #
15
- # DUPLICATE — two or more NON-symlink skill files, in any worker under the
16
- # scanned roots, whose byte content is identical. Identical bytes
17
- # across two real files is exactly an un-single-sourced copy: the
18
- # moment someone edits one, they drift. (Symlinks that resolve to
19
- # a shared canonical are single-sourced and are NOT flagged, even
20
- # though their resolved content matches the canonical.)
15
+ # DUPLICATE — two or more NON-symlink skill files, WITHIN A SINGLE OWNERSHIP
16
+ # DOMAIN, whose byte content is identical. Identical bytes across
17
+ # two real files in the same domain is exactly an un-single-sourced
18
+ # copy: the moment someone edits one, they drift. (Symlinks that
19
+ # resolve to a shared canonical are single-sourced and are NOT
20
+ # flagged, even though their resolved content matches.)
21
21
  # BROKEN — a skill entry that is a symlink whose target does not resolve.
22
22
  #
23
23
  # It deliberately does NOT flag two same-NAMED skills whose content differs
24
24
  # (e.g. an API-level vs a browser-level e2e skill): distinct content means they
25
25
  # are distinct skills that merely share a filename, not a drifted share.
26
26
  #
27
+ # OWNERSHIP DOMAINS — filesystem adjacency does NOT establish duplicate
28
+ # ownership, so comparison is partitioned by the tree that OWNS a skill (policy
29
+ # hq-lint-comparison-domains-must-match-ownership):
30
+ #
31
+ # - `core/workers` — the curated first-party worker library
32
+ # - `core/packages/<pack>` — one installed pack (self-contained; may legally
33
+ # vendor a byte-identical copy of a core worker)
34
+ # - `companies/<co>` — one tenant's workers
35
+ #
36
+ # A byte-identical file in TWO DIFFERENT domains (a pack vendoring a core skill,
37
+ # or two packs shipping the same skill) is intentional distribution, NOT drift,
38
+ # and is never flagged. Excluded from the scan entirely: dot-directories
39
+ # (`.archive/`, `.git/`, …) and `._*` AppleDouble OS-metadata files — byte-
40
+ # identical by design, and not skills.
41
+ #
27
42
  # Usage: lint-shared-worker-skills.sh [root ...]
28
43
  # Default roots: core/workers core/packages (the SHIPPED hq-core scope —
29
- # company `_shared-skills/` stores live under companies/<co>/ and are linted
30
- # per-tenant, not here).
44
+ # company workers live under companies/<co>/ and are linted per-tenant; pass
45
+ # them explicitly to include them).
31
46
  #
32
47
  # Exit 0 + "OK:" line when clean; exit 1 + a report naming every offending group
33
48
  # when not. Matches the loud-and-specific style of lint-skill-script-refs.sh.
@@ -54,11 +69,29 @@ content_hash() {
54
69
  fi
55
70
  }
56
71
 
72
+ # The ownership domain that OWNS a skill path — the boundary within which two
73
+ # byte-identical files are a real duplicate. Across domains, identical content
74
+ # is intentional (a pack vendoring a core skill, two packs shipping the same
75
+ # skill), so it must never be compared. See policy header above.
76
+ domain_of() {
77
+ local p="${1#./}" rest
78
+ case "$p" in
79
+ core/packages/*) rest="${p#core/packages/}"; printf 'core/packages/%s' "${rest%%/*}" ;;
80
+ companies/*) rest="${p#companies/}"; printf 'companies/%s' "${rest%%/*}" ;;
81
+ core/workers/*|core/workers) printf 'core/workers' ;;
82
+ # Arbitrary root (custom scans, tests): the top path segment is the domain.
83
+ *) printf '%s' "${p%%/*}" ;;
84
+ esac
85
+ }
86
+
57
87
  # Collect skill entries: regular files AND symlinks named *.md under any
58
88
  # .../skills/... path. -type l must be matched explicitly — a symlink is not a
59
89
  # -type f, so a skills symlink would otherwise be invisible to the scan.
90
+ # EXCLUDED: any dot-directory component (`*/.*/*` — covers `.archive/`, `.git/`)
91
+ # and `._*` AppleDouble files; both are byte-identical by design and not skills.
60
92
  mapfile -t entries < <(
61
- find "${roots[@]}" \( -type f -o -type l \) -path '*/skills/*.md' 2>/dev/null | sort
93
+ find "${roots[@]}" \( -type f -o -type l \) -path '*/skills/*.md' \
94
+ -not -path '*/.*/*' -not -name '._*' 2>/dev/null | sort
62
95
  )
63
96
 
64
97
  # Empty is clean — and guards `"${entries[@]}"` under `set -u` on bash 3.2
@@ -69,10 +102,13 @@ if [[ ${#entries[@]} -eq 0 ]]; then
69
102
  fi
70
103
 
71
104
  broken=()
72
- # Parallel arrays keyed by content hash: hash_keys[i] is a hash, and
73
- # hash_regfiles[i] is a newline-joined list of the NON-symlink files with that
74
- # hash. Bash 3.2 (macOS) has no associative arrays in a portable-guaranteed way,
75
- # so a linear scan over parallel arrays keeps this runnable everywhere HQ runs.
105
+ # Parallel arrays keyed by "<domain>|<content-hash>": hash_keys[i] is that
106
+ # composite key, hash_regfiles[i] a newline-joined list of the NON-symlink files
107
+ # carrying it. Keying on domain AS WELL AS hash is what confines a duplicate
108
+ # finding to a single ownership tree two identical files in different domains
109
+ # land in different buckets and are never flagged. Bash 3.2 (macOS) has no
110
+ # portable associative arrays, so a linear scan over parallel arrays keeps this
111
+ # runnable everywhere HQ runs.
76
112
  hash_keys=()
77
113
  hash_regfiles=()
78
114
 
@@ -94,12 +130,13 @@ for entry in "${entries[@]}"; do
94
130
  fi
95
131
  continue
96
132
  fi
97
- # Regular file: hash its bytes and bucket it. Two regular files sharing a
98
- # bucket are two copies of the same skill the drift hazard.
99
- h="$(content_hash "$entry")"
100
- idx="$(hash_index "$h")"
133
+ # Regular file: bucket it by (ownership domain, byte content). Two regular
134
+ # files sharing a bucket are two copies of the same skill in the same tree —
135
+ # the drift hazard.
136
+ key="$(domain_of "$entry")|$(content_hash "$entry")"
137
+ idx="$(hash_index "$key")"
101
138
  if [[ "$idx" == "-1" ]]; then
102
- hash_keys+=("$h")
139
+ hash_keys+=("$key")
103
140
  hash_regfiles+=("$entry")
104
141
  else
105
142
  hash_regfiles[$idx]="${hash_regfiles[$idx]}"$'\n'"$entry"
@@ -116,7 +153,8 @@ for i in "${!hash_keys[@]}"; do
116
153
  echo "lint-shared-worker-skills: FAIL — duplicated worker skills (single-source these via _shared-skills/ + relative symlinks):" >&2
117
154
  fi
118
155
  findings=$((findings + 1))
119
- echo " DUPLICATE (identical content, ${count} copies — pick one canonical and symlink the rest):" >&2
156
+ domain="${hash_keys[$i]%%|*}"
157
+ echo " DUPLICATE in domain '${domain}' (identical content, ${count} copies — pick one canonical and symlink the rest):" >&2
120
158
  printf ' %s\n' "$group" >&2
121
159
  fi
122
160
  done
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ # FORWARDER — native implementation: `hq core scan-packages`.
3
+ #
4
+ # The original script treated $HQ_ROOT (or the caller's cwd) as authoritative.
5
+ # Keep that root binding and exec the hidden command so the shell ABI survives.
6
+
7
+ set -euo pipefail
8
+
9
+ HQ_ROOT="${HQ_ROOT:-$PWD}"
10
+
11
+ if ! command -v hq >/dev/null 2>&1; then
12
+ echo "scan-packages.sh: requires the hq CLI — this script's implementation now ships with it." >&2
13
+ echo "Install it with: npm install -g @indigoai-us/hq-cli" >&2
14
+ exit 127
15
+ fi
16
+
17
+ exec hq core --hq-root "$HQ_ROOT" scan-packages "$@"
@@ -77,6 +77,15 @@ export type WorkerSubcommand = ScaffoldAsset & {
77
77
  /** Subcommand name under the `worker` group — `hq core worker <name>`. */
78
78
  name: string;
79
79
  };
80
+ /**
81
+ * Native commands that still claim a scaffold forwarder asset. The forwarder
82
+ * is the backwards-compatible path for old callers; this command is the sole
83
+ * implementation and must never dispatch back through that asset.
84
+ */
85
+ export type NativeScaffoldCommand = ScaffoldAsset & {
86
+ name: string;
87
+ };
88
+ export declare const NATIVE_SCAFFOLD_COMMANDS: NativeScaffoldCommand[];
80
89
  /**
81
90
  * The `hq core worker <name>` subgroup — worker-scoped skill maintenance.
82
91
  *
@@ -33,6 +33,22 @@ import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
33
33
  import { resolveLiveRoot } from "../utils/hq-roots.js";
34
34
  import { runBundledScript } from "../utils/run-bundled-script.js";
35
35
  import { renderIndexTarget } from "../lib/index-render/index.js";
36
+ import { generateWorkersRegistry } from "../lib/workers-registry/index.js";
37
+ import { scanPackages } from "../lib/scan-packages/index.js";
38
+ export const NATIVE_SCAFFOLD_COMMANDS = [
39
+ {
40
+ name: "generate-workers-registry",
41
+ asset: "core/scripts/generate-workers-registry.sh",
42
+ root: "live",
43
+ summary: "Regenerate the derived workers registry",
44
+ },
45
+ {
46
+ name: "scan-packages",
47
+ asset: "core/scripts/scan-packages.sh",
48
+ root: "live",
49
+ summary: "Wire installed content-pack contributions",
50
+ },
51
+ ];
36
52
  /**
37
53
  * The `hq core worker <name>` subgroup — worker-scoped skill maintenance.
38
54
  *
@@ -252,6 +268,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
252
268
  /** Every claimed bundled asset, used by packaging and interpreter tests. */
253
269
  export const SCAFFOLD_ASSETS = [
254
270
  ...SCAFFOLD_COMMANDS,
271
+ ...NATIVE_SCAFFOLD_COMMANDS,
255
272
  ...SCAFFOLD_ONLY_ASSETS,
256
273
  ...WORKER_SUBCOMMANDS,
257
274
  ];
@@ -310,6 +327,27 @@ export function registerCoreCommands(program) {
310
327
  // This group primarily hosts manifest-driven bundled assets, but it also
311
328
  // hosts native TypeScript plumbing when a scaffold contract needs it.
312
329
  registerCoreCheckpointCommand(core);
330
+ // These command names intentionally own native implementations. Their
331
+ // similarly named scaffold assets are forwarders, so sending them through
332
+ // runEntry would recurse back into this command forever.
333
+ for (const entry of NATIVE_SCAFFOLD_COMMANDS) {
334
+ core
335
+ .command(entry.name)
336
+ .description(entry.summary)
337
+ .allowUnknownOption()
338
+ .allowExcessArguments()
339
+ .helpOption(false)
340
+ .argument("[args...]", "arguments accepted for shell ABI compatibility")
341
+ .action(() => {
342
+ const scope = core.opts();
343
+ const hqRoot = resolveLiveRoot({ hqRoot: scope.hqRoot });
344
+ const result = entry.name === "generate-workers-registry"
345
+ ? generateWorkersRegistry(hqRoot)
346
+ : scanPackages(hqRoot, { quiet: process.env.HQ_SCAN_QUIET === "1" });
347
+ if (result.status !== 0)
348
+ process.exit(result.status);
349
+ });
350
+ }
313
351
  // `hq core worker <name>` — a nested subgroup for worker-scoped skill
314
352
  // maintenance. Nested (rather than flat `hq core worker-<name>`) so the two
315
353
  // related operations read as one family.
@@ -0,0 +1,13 @@
1
+ type Logger = (message: string) => void;
2
+ export interface ScanPackagesOptions {
3
+ quiet?: boolean;
4
+ log?: Logger;
5
+ warn?: Logger;
6
+ }
7
+ export interface ScanPackagesResult {
8
+ status: 0 | 1;
9
+ }
10
+ /** Wire installed pack contributions into their table-declared host locations. */
11
+ export declare function scanPackages(hqRoot: string, options?: ScanPackagesOptions): ScanPackagesResult;
12
+ export {};
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,218 @@
1
+ /** Native implementation of core/scripts/scan-packages.sh. */
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as yaml from 'js-yaml';
5
+ import { contributionLinks } from '../../utils/pack-contributions.js';
6
+ function isDirectory(target) { try {
7
+ return fs.statSync(target).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ } }
12
+ function existsOrSymlink(target) { try {
13
+ fs.lstatSync(target);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ } }
19
+ function normalized(relative) { return relative.replaceAll(path.sep, '/'); }
20
+ function readContributes(manifest) {
21
+ // This deliberately mirrors the staging script's shallow awk grammar rather
22
+ // than accepting YAML forms the legacy ABI never recognized (notably a
23
+ // non-empty inline list). Mapping remains single-sourced in
24
+ // contributionLinks/CONTRIBUTION_TABLE; this only preserves input syntax.
25
+ const contributes = {};
26
+ let inside = false;
27
+ let key;
28
+ for (const line of fs.readFileSync(manifest, 'utf8').split('\n')) {
29
+ if (/^\s*#/.test(line) || /^\s*$/.test(line))
30
+ continue;
31
+ if (/^contributes:\s*$/.test(line)) {
32
+ inside = true;
33
+ key = undefined;
34
+ continue;
35
+ }
36
+ if (inside && /^[^\s]/.test(line)) {
37
+ inside = false;
38
+ key = undefined;
39
+ continue;
40
+ }
41
+ const empty = inside && line.match(/^ {2}([a-zA-Z_][a-zA-Z0-9_-]*):\s*\[\s*\]\s*$/);
42
+ if (empty) {
43
+ key = undefined;
44
+ continue;
45
+ }
46
+ const header = inside && line.match(/^ {2}([a-zA-Z_][a-zA-Z0-9_-]*):\s*$/);
47
+ if (header) {
48
+ key = header[1];
49
+ continue;
50
+ }
51
+ const currentKey = key;
52
+ const item = inside && currentKey !== undefined && line.match(/^ {4}-\s+(.+)$/);
53
+ if (!item || currentKey === undefined)
54
+ continue;
55
+ let value = item[1].replace(/\s*#.*$/, '').trim();
56
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
57
+ value = value.slice(1, -1);
58
+ if (!value)
59
+ continue;
60
+ (contributes[currentKey] ??= []).push(value);
61
+ }
62
+ return contributes;
63
+ }
64
+ function workerId(file) {
65
+ try {
66
+ const parsed = yaml.load(fs.readFileSync(file, 'utf8'));
67
+ const worker = parsed && typeof parsed === 'object' ? parsed.worker : undefined;
68
+ return worker && typeof worker === 'object' ? String(worker.id ?? '') : '';
69
+ }
70
+ catch {
71
+ return '';
72
+ }
73
+ }
74
+ function allWorkerYamlFiles(root, relative) {
75
+ const output = [];
76
+ const walk = (next, seen) => {
77
+ const absolute = path.join(root, next);
78
+ if (!isDirectory(absolute))
79
+ return;
80
+ let physical;
81
+ try {
82
+ physical = fs.realpathSync(absolute);
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (seen.has(physical))
88
+ return;
89
+ const beneath = new Set(seen).add(physical);
90
+ let names;
91
+ try {
92
+ names = fs.readdirSync(absolute);
93
+ }
94
+ catch {
95
+ return;
96
+ }
97
+ for (const name of names) {
98
+ const child = path.join(next, name);
99
+ const childAbsolute = path.join(root, child);
100
+ if (name === 'worker.yaml' && (() => { try {
101
+ return fs.statSync(childAbsolute).isFile();
102
+ }
103
+ catch {
104
+ return false;
105
+ } })())
106
+ output.push(child);
107
+ else if (isDirectory(childAbsolute))
108
+ walk(child, beneath);
109
+ }
110
+ };
111
+ walk(relative, new Set());
112
+ return output;
113
+ }
114
+ function workerIdClashes(hqRoot, source) {
115
+ const sourceYaml = path.join(source, 'worker.yaml');
116
+ if (!fs.existsSync(sourceYaml))
117
+ return undefined;
118
+ const id = workerId(sourceYaml);
119
+ if (!id)
120
+ return undefined;
121
+ let physical;
122
+ try {
123
+ physical = fs.realpathSync(sourceYaml);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ for (const file of [...allWorkerYamlFiles(hqRoot, 'core/workers'), ...allWorkerYamlFiles(hqRoot, 'companies')]) {
129
+ const relative = normalized(file);
130
+ if (relative.includes('/_template/') || relative.includes('/_overrides/'))
131
+ continue;
132
+ if (workerId(path.join(hqRoot, file)) !== id)
133
+ continue;
134
+ try {
135
+ if (fs.realpathSync(path.join(hqRoot, file)) === physical)
136
+ continue;
137
+ }
138
+ catch { /* compare as a clash */ }
139
+ return relative;
140
+ }
141
+ return undefined;
142
+ }
143
+ function ensureSymlink(link, info, warn) {
144
+ if (!existsOrSymlink(link.src)) {
145
+ warn(`payload missing: ${link.src} (declared but not shipped)`);
146
+ return;
147
+ }
148
+ fs.mkdirSync(path.dirname(link.dst), { recursive: true });
149
+ try {
150
+ const stat = fs.lstatSync(link.dst);
151
+ if (stat.isSymbolicLink()) {
152
+ const existing = fs.readlinkSync(link.dst);
153
+ if (existing === link.src)
154
+ return;
155
+ warn(`collision: ${link.dst} already points at ${existing} (wanted ${link.src}) — skipping`);
156
+ return;
157
+ }
158
+ warn(`collision: ${link.dst} exists as regular file/dir — host content wins, skipping`);
159
+ return;
160
+ }
161
+ catch (error) {
162
+ if (error.code !== 'ENOENT')
163
+ throw error;
164
+ }
165
+ fs.symlinkSync(link.src, link.dst);
166
+ info(`linked ${link.dst} -> ${link.src}`);
167
+ }
168
+ /** Wire installed pack contributions into their table-declared host locations. */
169
+ export function scanPackages(hqRoot, options = {}) {
170
+ const log = options.log ?? ((message) => process.stdout.write(`${message}\n`));
171
+ const warnSink = options.warn ?? ((message) => process.stderr.write(`${message}\n`));
172
+ const info = (message) => { if (!options.quiet)
173
+ log(` ${message}`); };
174
+ const warn = (message) => warnSink(` [warn] ${message}`);
175
+ const packages = path.join(hqRoot, 'core/packages');
176
+ if (!isDirectory(packages)) {
177
+ info('[scan-packages] no core/packages/ dir; nothing to wire');
178
+ return { status: 0 };
179
+ }
180
+ let packageNames;
181
+ try {
182
+ packageNames = fs.readdirSync(packages).filter((name) => !name.startsWith('.')).sort();
183
+ }
184
+ catch (error) {
185
+ warnSink(`[scan-packages] error: ${error.message}`);
186
+ return { status: 1 };
187
+ }
188
+ let any = false;
189
+ try {
190
+ for (const name of packageNames) {
191
+ const packDir = path.join(packages, name);
192
+ const manifest = path.join(packDir, 'package.yaml');
193
+ if (!isDirectory(packDir) || !fs.existsSync(manifest))
194
+ continue;
195
+ any = true;
196
+ info(`[scan-packages] wiring ${name}`);
197
+ for (const link of contributionLinks(hqRoot, packDir, readContributes(manifest))) {
198
+ if (link.key === 'workers') {
199
+ const clash = workerIdClashes(hqRoot, link.src);
200
+ if (clash) {
201
+ const id = workerId(path.join(link.src, 'worker.yaml'));
202
+ warn(`worker-id collision: pack '${name}' worker id '${id}' is already registered at /${clash} — refusing to wire ${link.dst} (would hard-fail the worker registry on a duplicate id). Resolve the duplicate id (or namespace the pack worker), then re-run.`);
203
+ continue;
204
+ }
205
+ }
206
+ ensureSymlink(link, info, warn);
207
+ }
208
+ }
209
+ }
210
+ catch (error) {
211
+ warnSink(`[scan-packages] error: ${error.message}`);
212
+ return { status: 1 };
213
+ }
214
+ if (!any)
215
+ info('[scan-packages] no hq-pack manifests found in core/packages');
216
+ return { status: 0 };
217
+ }
218
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,18 @@
1
+ type Logger = (message: string) => void;
2
+ export interface GenerateWorkersRegistryOptions {
3
+ now?: Date;
4
+ /** Receives the shell-compatible stderr lines, without their trailing newline. */
5
+ log?: Logger;
6
+ }
7
+ export interface GenerateWorkersRegistryResult {
8
+ status: 0 | 1;
9
+ written: boolean;
10
+ quarantined: number;
11
+ }
12
+ /**
13
+ * Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
14
+ * are quarantined but do not prevent all valid workers from being registered.
15
+ */
16
+ export declare function generateWorkersRegistry(hqRoot: string, options?: GenerateWorkersRegistryOptions): GenerateWorkersRegistryResult;
17
+ export {};
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,206 @@
1
+ /** Native implementation of core/scripts/generate-workers-registry.sh. */
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as yaml from 'js-yaml';
5
+ function isDirectory(target) {
6
+ try {
7
+ return fs.statSync(target).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function isFile(target) {
14
+ try {
15
+ return fs.statSync(target).isFile();
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ /** A sorted `find -L … -name worker.yaml -type f` equivalent, including symlinked pack workers. */
22
+ function workerYamlFiles(root, relativeRoot) {
23
+ const output = [];
24
+ const walk = (relative, ancestors) => {
25
+ const absolute = path.join(root, relative);
26
+ if (!isDirectory(absolute))
27
+ return;
28
+ let physical;
29
+ try {
30
+ physical = fs.realpathSync(absolute);
31
+ }
32
+ catch {
33
+ return;
34
+ }
35
+ if (ancestors.has(physical))
36
+ return; // `find -L` reports loops; a bounded walk is safer here.
37
+ const nextAncestors = new Set(ancestors).add(physical);
38
+ let names;
39
+ try {
40
+ names = fs.readdirSync(absolute).sort();
41
+ }
42
+ catch {
43
+ return;
44
+ }
45
+ for (const name of names) {
46
+ const childRelative = path.join(relative, name);
47
+ const child = path.join(root, childRelative);
48
+ if (name === 'worker.yaml' && isFile(child))
49
+ output.push(childRelative);
50
+ else if (isDirectory(child))
51
+ walk(childRelative, nextAncestors);
52
+ }
53
+ };
54
+ walk(relativeRoot, new Set());
55
+ return output;
56
+ }
57
+ const UNIT_SEPARATOR = String.fromCharCode(31);
58
+ function scalar(value) {
59
+ if (value === null || value === undefined)
60
+ return '';
61
+ return String(value).replace(/\n+$/, '').replace(/\n/g, ' ').replaceAll(UNIT_SEPARATOR, ' ');
62
+ }
63
+ /** yq's `.worker.<field> // ""` shape; malformed YAML degrades to empty required fields. */
64
+ function readWorkerFields(file) {
65
+ try {
66
+ const document = yaml.load(fs.readFileSync(file, 'utf8'));
67
+ const worker = document && typeof document === 'object'
68
+ ? document.worker
69
+ : undefined;
70
+ const fields = worker && typeof worker === 'object'
71
+ ? worker
72
+ : {};
73
+ return {
74
+ id: scalar(fields.id), type: scalar(fields.type), description: scalar(fields.description),
75
+ status: scalar(fields.status), company: scalar(fields.company), team: scalar(fields.team),
76
+ };
77
+ }
78
+ catch {
79
+ return { id: '', type: '', description: '', status: '', company: '', team: '' };
80
+ }
81
+ }
82
+ function visibilityOf(relativePath) {
83
+ return relativePath.startsWith('companies/') ? 'private' : 'public';
84
+ }
85
+ function packAttribution(root, relativeDirectory) {
86
+ try {
87
+ const physical = fs.realpathSync(path.join(root, relativeDirectory));
88
+ const match = physical.match(/(?:^|[/\\])core[/\\]packages[/\\]([^/\\]+)/);
89
+ return match ? ` [source: pack ${match[1]}]` : '';
90
+ }
91
+ catch {
92
+ return '';
93
+ }
94
+ }
95
+ function quote(value) {
96
+ // Deliberately mirrors `${value//\"/\\\"}` in the source shell script.
97
+ return value.replace(/"/g, '\\"');
98
+ }
99
+ function isoSeconds(now) {
100
+ return now.toISOString().replace(/\.\d{3}Z$/, 'Z');
101
+ }
102
+ function registryText(now, entries) {
103
+ const lines = [
104
+ '# Workers Registry — AUTO-GENERATED by core/scripts/generate-workers-registry.sh',
105
+ '# DO NOT EDIT. Source of truth: worker.yaml in each worker\'s directory.',
106
+ '# Triggered by .claude/hooks/reindex.sh on Stop / PostToolUse-Write.',
107
+ '# To register a new worker: create its worker.yaml. Registry regenerates.',
108
+ '', 'version: "5.0"', `generated_at: "${isoSeconds(now)}"`, '', 'workers:',
109
+ ];
110
+ for (const entry of entries) {
111
+ lines.push(` - id: "${quote(entry.id)}"`);
112
+ lines.push(` path: "${quote(entry.path)}"`);
113
+ lines.push(` type: "${quote(entry.type)}"`);
114
+ lines.push(` visibility: "${entry.visibility}"`);
115
+ if (entry.team)
116
+ lines.push(` team: "${quote(entry.team)}"`);
117
+ if (entry.company)
118
+ lines.push(` company: "${quote(entry.company)}"`);
119
+ lines.push(` status: "${quote(entry.status)}"`);
120
+ lines.push(` description: "${quote(entry.description)}"`);
121
+ }
122
+ return `${lines.join('\n')}\n`;
123
+ }
124
+ function withoutTimestamp(content) {
125
+ return content.split('\n').filter((line) => !line.startsWith('generated_at:')).join('\n');
126
+ }
127
+ /**
128
+ * Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
129
+ * are quarantined but do not prevent all valid workers from being registered.
130
+ */
131
+ export function generateWorkersRegistry(hqRoot, options = {}) {
132
+ const log = options.log ?? ((message) => process.stderr.write(`${message}\n`));
133
+ const entries = [];
134
+ let quarantined = 0;
135
+ const files = [
136
+ ...workerYamlFiles(hqRoot, 'core/workers'),
137
+ ...workerYamlFiles(hqRoot, 'companies'),
138
+ ...workerYamlFiles(hqRoot, 'personal/workers'),
139
+ ].sort();
140
+ for (const relativeFile of files) {
141
+ if (relativeFile.startsWith('companies/_template/') || /(?:^|[/\\])_overrides(?:[/\\]|$)/.test(relativeFile))
142
+ continue;
143
+ const fields = readWorkerFields(path.join(hqRoot, relativeFile));
144
+ const missing = ['id', 'type', 'description'].filter((key) => !fields[key]);
145
+ if (missing.length > 0) {
146
+ const relativeDir = path.dirname(relativeFile);
147
+ const attribution = packAttribution(hqRoot, relativeDir);
148
+ const remedy = attribution
149
+ ? `this worker ships from an installed pack — its on-disk copy is likely stale; run 'hq packs update ${attribution.slice(' [source: pack '.length, -1)}' to refresh it (you cannot edit the protected core/ copy directly). If it persists after refreshing, the pack's worker.yaml needs the field(s) added upstream`
150
+ : 'fix the worker.yaml and re-run';
151
+ log(`generate-workers-registry: QUARANTINED ${relativeFile.replaceAll(path.sep, '/')} — missing required field(s): ${missing.join(', ')}${attribution} (excluded from registry; ${remedy})`);
152
+ quarantined++;
153
+ continue;
154
+ }
155
+ entries.push({
156
+ ...fields,
157
+ status: fields.status === 'null' || !fields.status ? 'active' : fields.status,
158
+ company: fields.company === 'null' ? '' : fields.company,
159
+ team: fields.team === 'null' ? '' : fields.team,
160
+ path: `${path.dirname(relativeFile).replaceAll(path.sep, '/')}/`,
161
+ visibility: visibilityOf(relativeFile),
162
+ });
163
+ }
164
+ entries.sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path));
165
+ const kept = [];
166
+ const hasDuplicates = entries.some((entry, index) => index > 0 && entries[index - 1].id === entry.id);
167
+ if (hasDuplicates) {
168
+ log('generate-workers-registry: WARNING duplicate worker id(s) detected — keeping the first copy of each and skipping the rest (registry still generated; resolve the clash to silence this):');
169
+ }
170
+ for (let index = 0; index < entries.length;) {
171
+ let end = index + 1;
172
+ while (end < entries.length && entries[end].id === entries[index].id)
173
+ end++;
174
+ const group = entries.slice(index, end);
175
+ const winner = group[0];
176
+ kept.push(winner);
177
+ if (group.length > 1) {
178
+ log(` duplicate id '${winner.id}' — KEEPING ${winner.path}${packAttribution(hqRoot, winner.path)}`);
179
+ for (const skipped of group.slice(1)) {
180
+ log(` duplicate id '${skipped.id}' — SKIPPING ${skipped.path}${packAttribution(hqRoot, skipped.path)}`);
181
+ }
182
+ }
183
+ index = end;
184
+ }
185
+ if (hasDuplicates)
186
+ log(' Fix: change worker.id in one of the colliding worker.yaml files (or namespace the pack\'s id) to make it unique.');
187
+ const output = registryText(options.now ?? new Date(), kept);
188
+ const registry = path.join(hqRoot, 'core/workers/registry.yaml');
189
+ let written = true;
190
+ if (fs.existsSync(registry) && withoutTimestamp(fs.readFileSync(registry, 'utf8')) === withoutTimestamp(output))
191
+ written = false;
192
+ if (written) {
193
+ fs.mkdirSync(path.dirname(registry), { recursive: true });
194
+ fs.writeFileSync(registry, output);
195
+ log('generate-workers-registry: wrote core/workers/registry.yaml');
196
+ }
197
+ else {
198
+ log('generate-workers-registry: core/workers/registry.yaml unchanged');
199
+ }
200
+ if (quarantined > 0) {
201
+ log(`generate-workers-registry: wrote registry for all VALID workers; quarantined ${quarantined} problem(s) above (loud + partial, never a silent total block). Fix the reported worker.yaml file(s) and re-run to register them.`);
202
+ return { status: 1, written, quarantined };
203
+ }
204
+ return { status: 0, written, quarantined };
205
+ }
206
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.88.1",
3
+ "version": "5.89.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {