@indigoai-us/hq-cli 5.91.0 → 5.93.0

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,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.93.0]
6
+
7
+ ### Fixed
8
+
9
+ - `hq core archive-old-threads --gated` no longer fails on Linux. The retained
10
+ shell asset probed `stat -f %m` first, which on GNU coreutils is a filesystem
11
+ query: it consumed the format string as a path, printed `File: …` for the
12
+ real argument, and exited 0, so the `stat -c %Y` fallback never ran and the
13
+ age arithmetic aborted on an unbound `File`. The once-per-day gate exited 1
14
+ instead of gating. Probes GNU first now. (#327)
15
+
16
+ ### Changed
17
+
18
+ - `hq core archive-old-threads` and `hq core qmd-reindex-after-sync` run as
19
+ native, tested TypeScript instead of bundled shell assets, proven equivalent
20
+ by a shell-vs-native differential suite. The reindex command now reuses the
21
+ existing search-index collection policy rather than restating the
22
+ registration convention, so the two can no longer drift. Scaffold scripts
23
+ still ship for loose HQ trees. (#327)
24
+
25
+ ## [5.92.0]
26
+
27
+ ### Changed
28
+
29
+ - `hq core codex-skill-bridge` now runs as native, tested TypeScript instead of
30
+ a bundled shell asset, completing the `hq core` utility migration. Install,
31
+ status, doctor, dry-run, stale-HQ-root repair, blocked-target refusal, legacy
32
+ `.Codex` handling, and the local-config path rewrite are all preserved and
33
+ proven byte-identical to the shell implementation by a differential suite
34
+ covering sixteen scenarios. The scaffold script still ships for loose HQ
35
+ trees. (#325)
36
+
5
37
  ## [5.91.0]
6
38
 
7
39
  ### Changed
@@ -32,8 +32,12 @@ done
32
32
 
33
33
  TOUCHFILE="workspace/.last-archive-run"
34
34
  if [[ "$GATED" == "true" && -f "$TOUCHFILE" ]]; then
35
- # macOS `stat -f %m` gives mtime epoch; GNU `stat -c %Y`. Try both.
36
- mtime=$(stat -f %m "$TOUCHFILE" 2>/dev/null || stat -c %Y "$TOUCHFILE" 2>/dev/null || echo 0)
35
+ # GNU `stat -c %Y` first, then macOS `stat -f %m`. Order matters: on GNU,
36
+ # `-f` queries a FILE SYSTEM, so `stat -f %m <file>` treats `%m` as a path,
37
+ # prints `File: …` for the real argument, and still exits 0 — the fallback
38
+ # never fired and the arithmetic below died on unbound `File`, breaking
39
+ # --gated on Linux entirely. Matches core/scripts/lib/portable.sh.
40
+ mtime=$(stat -c %Y "$TOUCHFILE" 2>/dev/null || stat -f %m "$TOUCHFILE" 2>/dev/null || echo 0)
37
41
  now=$(date +%s)
38
42
  if [[ $((now - mtime)) -lt 86400 ]]; then
39
43
  echo "archive-old-threads: gated (ran <24h ago, skipping)" >&2
@@ -41,6 +41,9 @@ import { ontologyReadmeDrift } from "../lib/core-utils/ontology-readme-drift.js"
41
41
  import { resizeScreenshot } from "../lib/core-utils/resize-screenshot.js";
42
42
  import { tokenUsageReport } from "../lib/core-utils/token-usage-report.js";
43
43
  import { createWorktree } from "../lib/core-utils/worktree.js";
44
+ import { runCodexSkillBridgeCommand } from "../lib/core-utils/codex-skill-bridge-entry.js";
45
+ import { archiveOldThreads } from "../lib/core-utils/archive-old-threads.js";
46
+ import { qmdReindexAfterSync } from "../lib/core-utils/qmd-reindex-after-sync.js";
44
47
  /**
45
48
  * These commands are now native implementations. Their retained shell assets
46
49
  * are deliberately claimed in SCAFFOLD_ONLY_ASSETS: loose HQ trees still ship
@@ -53,6 +56,9 @@ export const NATIVE_UTILITY_COMMANDS = [
53
56
  { name: "resize-screenshot", root: "cwd", summary: "Downscale a screenshot for context efficiency", run: (args) => resizeScreenshot(args) },
54
57
  { name: "token-usage-report", root: "cwd", summary: "Report session token usage", run: (args) => tokenUsageReport(args) },
55
58
  { name: "worktree", root: "live", summary: "Create a git worktree under workspace/worktrees/", run: (args, context) => createWorktree(args, { cwd: context.cwd, hqRoot: context.hqRoot }) },
59
+ { name: "codex-skill-bridge", root: "live", summary: "Install/inspect the Codex skill bridge", run: (args, context) => runCodexSkillBridgeCommand(args, { root: context.hqRoot ?? context.cwd }) },
60
+ { name: "archive-old-threads", root: "live", summary: "Archive aged session-thread files", run: (args, context) => archiveOldThreads(args, { hqRoot: context.hqRoot ?? context.cwd }) },
61
+ { name: "qmd-reindex-after-sync", root: "cwd", summary: "Refresh qmd collections after a sync", run: (args, context) => qmdReindexAfterSync(args, { cwd: context.cwd }) },
56
62
  ];
57
63
  export const NATIVE_SCAFFOLD_COMMANDS = [
58
64
  {
@@ -159,12 +165,6 @@ export const REBUILD_INDEX_TARGETS = [
159
165
  */
160
166
  export const SCAFFOLD_COMMANDS = [
161
167
  // ---- One-shot migrations and maintenance (live tree) ----
162
- {
163
- name: "archive-old-threads",
164
- asset: "core/scripts/archive-old-threads.sh",
165
- root: "live",
166
- summary: "Archive aged session-thread files",
167
- },
168
168
  {
169
169
  name: "backfill-company-skill-mirrors",
170
170
  asset: "core/scripts/backfill-company-skill-mirrors.sh",
@@ -177,19 +177,6 @@ export const SCAFFOLD_COMMANDS = [
177
177
  root: "cwd",
178
178
  summary: "One-shot: mirror threads into company workspaces",
179
179
  },
180
- {
181
- name: "qmd-reindex-after-sync",
182
- asset: "core/scripts/qmd-reindex-after-sync.sh",
183
- root: "cwd",
184
- summary: "Refresh qmd collections after a sync",
185
- },
186
- // ---- Reporting and utilities (live tree) ----
187
- {
188
- name: "codex-skill-bridge",
189
- asset: "core/scripts/codex-skill-bridge.sh",
190
- root: "live",
191
- summary: "Install/inspect the Codex skill bridge",
192
- },
193
180
  ];
194
181
  /**
195
182
  * Bundled solely for HQ-root scaffolds, which still need these as loose files.
@@ -197,6 +184,9 @@ export const SCAFFOLD_COMMANDS = [
197
184
  * direct `hq core` command.
198
185
  */
199
186
  export const SCAFFOLD_ONLY_ASSETS = [
187
+ { asset: "core/scripts/archive-old-threads.sh", root: "live", summary: "Scaffold-only native utility companion" },
188
+ { asset: "core/scripts/codex-skill-bridge.sh", root: "live", summary: "Scaffold-only native utility companion" },
189
+ { asset: "core/scripts/qmd-reindex-after-sync.sh", root: "cwd", summary: "Scaffold-only native utility companion" },
200
190
  { asset: "core/scripts/detect-stale-core-policy-mirror.sh", root: "live", summary: "Scaffold-only native utility companion" },
201
191
  { asset: "core/scripts/hq-status-summary.sh", root: "cwd", summary: "Scaffold-only native utility companion" },
202
192
  { asset: "core/scripts/ontology-readme-drift.sh", root: "live", summary: "Scaffold-only native utility companion" },
@@ -0,0 +1,8 @@
1
+ import { type UtilityIo } from "./common.js";
2
+ export type ArchiveOptions = UtilityIo & {
3
+ hqRoot?: string;
4
+ /** Injected for tests; defaults to the wall clock. */
5
+ now?: () => Date;
6
+ };
7
+ export declare function archiveOldThreads(args?: string[], options?: ArchiveOptions): number;
8
+ //# sourceMappingURL=archive-old-threads.d.ts.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Native implementation of `core/scripts/archive-old-threads.sh`.
3
+ *
4
+ * Moves `workspace/threads/T-YYYYMMDD-*.json` older than the cutoff into
5
+ * `workspace/threads/archive/YYYY-MM/`. Idempotent: files already under
6
+ * `archive/` are never listed, so repeat runs are no-ops.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { ioFor, line } from "./common.js";
11
+ /** `date -v-Nd +%Y%m%d` / `date -d "-N days" +%Y%m%d`, in local time. */
12
+ function cutoffStamp(now, days) {
13
+ const cutoff = new Date(now.getTime());
14
+ cutoff.setDate(cutoff.getDate() - days);
15
+ const year = cutoff.getFullYear();
16
+ const month = `${cutoff.getMonth() + 1}`.padStart(2, "0");
17
+ const day = `${cutoff.getDate()}`.padStart(2, "0");
18
+ return `${year}${month}${day}`;
19
+ }
20
+ export function archiveOldThreads(args = [], options = {}) {
21
+ const { stderr } = ioFor(options);
22
+ const hqRoot = options.hqRoot ?? process.env.HQ_ROOT ?? process.cwd();
23
+ const now = options.now ?? (() => new Date());
24
+ let cutoffDays = 60;
25
+ let dryRun = false;
26
+ let gated = false;
27
+ for (let index = 0; index < args.length; index++) {
28
+ const arg = args[index];
29
+ if (arg === "--days") {
30
+ cutoffDays = Number(args[++index]);
31
+ }
32
+ else if (arg === "--dry-run") {
33
+ dryRun = true;
34
+ }
35
+ else if (arg === "--gated") {
36
+ gated = true;
37
+ }
38
+ else {
39
+ line(stderr, `Unknown arg: ${arg}`);
40
+ return 2;
41
+ }
42
+ }
43
+ const touchfile = path.join(hqRoot, "workspace/.last-archive-run");
44
+ if (gated && fs.existsSync(touchfile)) {
45
+ const mtime = Math.floor(fs.statSync(touchfile).mtimeMs / 1000);
46
+ const seconds = Math.floor(now().getTime() / 1000);
47
+ if (seconds - mtime < 86_400) {
48
+ line(stderr, "archive-old-threads: gated (ran <24h ago, skipping)");
49
+ return 0;
50
+ }
51
+ }
52
+ const threadsDir = path.join(hqRoot, "workspace/threads");
53
+ const archiveRoot = path.join(threadsDir, "archive");
54
+ fs.mkdirSync(archiveRoot, { recursive: true });
55
+ const cutoff = cutoffStamp(now(), cutoffDays);
56
+ // `ls workspace/threads/T-*.json`: top level only, name-ordered, archive/ excluded.
57
+ let names;
58
+ try {
59
+ names = fs
60
+ .readdirSync(threadsDir)
61
+ .filter((name) => name.startsWith("T-") && name.endsWith(".json"))
62
+ .filter((name) => fs.lstatSync(path.join(threadsDir, name)).isFile())
63
+ .sort();
64
+ }
65
+ catch {
66
+ names = [];
67
+ }
68
+ let moved = 0;
69
+ let skipped = 0;
70
+ for (const name of names) {
71
+ const match = name.match(/^T-(\d{8})-/);
72
+ if (!match) {
73
+ skipped += 1;
74
+ continue;
75
+ }
76
+ const datePart = match[1];
77
+ // Both stamps are 8-digit YYYYMMDD, so numeric and lexical order agree.
78
+ if (Number(datePart) >= Number(cutoff))
79
+ continue;
80
+ const bucket = path.join(archiveRoot, `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}`);
81
+ if (dryRun) {
82
+ line(stderr, `WOULD MOVE: ${path.join("workspace/threads", name)} -> ${path.relative(hqRoot, bucket)}/`);
83
+ }
84
+ else {
85
+ fs.mkdirSync(bucket, { recursive: true });
86
+ fs.renameSync(path.join(threadsDir, name), path.join(bucket, name));
87
+ }
88
+ moved += 1;
89
+ }
90
+ if (!dryRun && gated) {
91
+ fs.mkdirSync(path.dirname(touchfile), { recursive: true });
92
+ fs.writeFileSync(touchfile, "");
93
+ }
94
+ line(stderr, `archive-old-threads: cutoff=${cutoffDays}d (before ${cutoff}) moved=${moved} skipped=${skipped} dry_run=${dryRun}`);
95
+ return 0;
96
+ }
97
+ //# sourceMappingURL=archive-old-threads.js.map
@@ -0,0 +1,13 @@
1
+ /** CLI adapter for the native Codex skill bridge. */
2
+ import { type UtilityIo } from "./common.js";
3
+ export type CodexSkillBridgeOptions = UtilityIo & {
4
+ root?: string;
5
+ home?: string;
6
+ };
7
+ /**
8
+ * Mirrors the shell entry point: usage on `--help` (exit 0) or a missing
9
+ * command (exit 1), single-line stderr plus exit 1 for refusals, and the
10
+ * unhealthy-check count as `status`/`install`'s exit code.
11
+ */
12
+ export declare function runCodexSkillBridgeCommand(args?: string[], options?: CodexSkillBridgeOptions): number;
13
+ //# sourceMappingURL=codex-skill-bridge-entry.d.ts.map
@@ -0,0 +1,48 @@
1
+ /** CLI adapter for the native Codex skill bridge. */
2
+ import { ioFor, line } from "./common.js";
3
+ import { BridgeError, BridgeHelp, USAGE, parseArgs, runCodexSkillBridge, } from "./codex-skill-bridge.js";
4
+ /**
5
+ * Mirrors the shell entry point: usage on `--help` (exit 0) or a missing
6
+ * command (exit 1), single-line stderr plus exit 1 for refusals, and the
7
+ * unhealthy-check count as `status`/`install`'s exit code.
8
+ */
9
+ export function runCodexSkillBridgeCommand(args = [], options = {}) {
10
+ const { stdout, stderr } = ioFor(options);
11
+ const defaultRoot = options.root ?? process.env.HQ_ROOT ?? process.cwd();
12
+ const home = options.home ?? process.env.HOME ?? "";
13
+ let parsed;
14
+ try {
15
+ parsed = parseArgs(args, defaultRoot);
16
+ }
17
+ catch (error) {
18
+ if (error instanceof BridgeHelp) {
19
+ line(stdout, USAGE);
20
+ return 0;
21
+ }
22
+ if (error instanceof BridgeError) {
23
+ // An empty message is the shell's "no command given" path: usage, exit 1.
24
+ if (!error.message) {
25
+ line(stdout, USAGE);
26
+ return 1;
27
+ }
28
+ line(stderr, error.message);
29
+ return 1;
30
+ }
31
+ throw error;
32
+ }
33
+ try {
34
+ return runCodexSkillBridge(parsed, {
35
+ home,
36
+ out: (text) => line(stdout, text),
37
+ err: (text) => line(stderr, text),
38
+ });
39
+ }
40
+ catch (error) {
41
+ if (error instanceof BridgeError) {
42
+ line(stderr, error.message);
43
+ return 1;
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+ //# sourceMappingURL=codex-skill-bridge-entry.js.map
@@ -0,0 +1,31 @@
1
+ export type BridgeCommand = "status" | "install" | "doctor";
2
+ export type BridgeOptions = {
3
+ command: BridgeCommand;
4
+ hqRoot: string;
5
+ oldRoots: string[];
6
+ repairLocalConfig: boolean;
7
+ dryRun: boolean;
8
+ };
9
+ export type BridgeIo = {
10
+ home: string;
11
+ out: (line: string) => void;
12
+ err: (line: string) => void;
13
+ };
14
+ /** Thrown for the shell's `fail`/refusal paths: message to stderr, exit 1. */
15
+ export declare class BridgeError extends Error {
16
+ name: string;
17
+ }
18
+ export declare const USAGE = "Usage:\n scripts/codex-skill-bridge.sh status [--root <path>]\n scripts/codex-skill-bridge.sh install [--root <path>] [--repair-local-config] [--old-root <path>]\n scripts/codex-skill-bridge.sh doctor [--root <path>] [--old-root <path>] [--dry-run]\n\nCommands:\n status Show bridge health, output-style bridge health, and stale HQ roots without changing files.\n install Install or repair HQ-owned Claude -> Codex bridges.\n doctor Repair bridges and rewrite local machine config from old HQ roots to this root.\n\nOptions:\n --root <path> HQ root to repair. Defaults to this script's parent.\n --old-root <path> Old HQ root to rewrite. Repeatable. Auto-detected when omitted.\n --repair-local-config With install, also rewrite local config paths.\n --dry-run With doctor, print intended config rewrites without changing files.";
19
+ /** Signals `--help`: caller prints usage and exits 0. */
20
+ export declare class BridgeHelp extends Error {
21
+ }
22
+ export declare function parseArgs(argv: string[], defaultRoot: string): BridgeOptions;
23
+ /** `strip_known_hq_subpath`: returns undefined when no known suffix matches. */
24
+ export declare function stripKnownHqSubpath(target: string): string | undefined;
25
+ /** `is_hq_root_like`: basename HQ, no brace placeholders, not the bare path. */
26
+ export declare function isHqRootLike(candidate: string): boolean;
27
+ /** `output_style_slug`: lowercase, collapse space/underscore, strip oddities. */
28
+ export declare function outputStyleSlug(name: string): string;
29
+ /** Runs one bridge command; returns the process exit code. */
30
+ export declare function runCodexSkillBridge(options: BridgeOptions, io: BridgeIo): number;
31
+ //# sourceMappingURL=codex-skill-bridge.d.ts.map
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Native port of `core/scripts/codex-skill-bridge.sh`.
3
+ *
4
+ * The bridge symlinks HQ's `.claude` sources into the places Codex and the
5
+ * agents runtime look for them, and repairs links (and optionally local config
6
+ * paths) left pointing at an HQ root that has since moved.
7
+ *
8
+ * Every path derives from the resolved HQ root and `HOME`, so callers — and
9
+ * the differential suite — can exercise the whole surface hermetically.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ /** Thrown for the shell's `fail`/refusal paths: message to stderr, exit 1. */
14
+ export class BridgeError extends Error {
15
+ name = "BridgeError";
16
+ }
17
+ export const USAGE = `Usage:
18
+ scripts/codex-skill-bridge.sh status [--root <path>]
19
+ scripts/codex-skill-bridge.sh install [--root <path>] [--repair-local-config] [--old-root <path>]
20
+ scripts/codex-skill-bridge.sh doctor [--root <path>] [--old-root <path>] [--dry-run]
21
+
22
+ Commands:
23
+ status Show bridge health, output-style bridge health, and stale HQ roots without changing files.
24
+ install Install or repair HQ-owned Claude -> Codex bridges.
25
+ doctor Repair bridges and rewrite local machine config from old HQ roots to this root.
26
+
27
+ Options:
28
+ --root <path> HQ root to repair. Defaults to this script's parent.
29
+ --old-root <path> Old HQ root to rewrite. Repeatable. Auto-detected when omitted.
30
+ --repair-local-config With install, also rewrite local config paths.
31
+ --dry-run With doctor, print intended config rewrites without changing files.`;
32
+ /** Signals `--help`: caller prints usage and exits 0. */
33
+ export class BridgeHelp extends Error {
34
+ }
35
+ export function parseArgs(argv, defaultRoot) {
36
+ let command = "";
37
+ let hqRoot = defaultRoot;
38
+ const oldRoots = [];
39
+ let repairLocalConfig = false;
40
+ let dryRun = false;
41
+ for (let index = 0; index < argv.length; index++) {
42
+ const arg = argv[index];
43
+ if (arg === "status" || arg === "install" || arg === "doctor") {
44
+ if (command)
45
+ throw new BridgeError("Only one command may be provided.");
46
+ command = arg;
47
+ }
48
+ else if (arg === "--root") {
49
+ if (index + 1 >= argv.length)
50
+ throw new BridgeError("--root requires a path.");
51
+ hqRoot = argv[++index];
52
+ }
53
+ else if (arg.startsWith("--root=")) {
54
+ hqRoot = arg.slice("--root=".length);
55
+ }
56
+ else if (arg === "--old-root") {
57
+ if (index + 1 >= argv.length)
58
+ throw new BridgeError("--old-root requires a path.");
59
+ oldRoots.push(argv[++index]);
60
+ }
61
+ else if (arg.startsWith("--old-root=")) {
62
+ oldRoots.push(arg.slice("--old-root=".length));
63
+ }
64
+ else if (arg === "--repair-local-config") {
65
+ repairLocalConfig = true;
66
+ }
67
+ else if (arg === "--dry-run") {
68
+ dryRun = true;
69
+ }
70
+ else if (arg === "-h" || arg === "--help") {
71
+ throw new BridgeHelp();
72
+ }
73
+ else {
74
+ throw new BridgeError(`Unknown argument: ${arg}`);
75
+ }
76
+ }
77
+ if (!command)
78
+ throw new BridgeError("");
79
+ return { command, hqRoot, oldRoots, repairLocalConfig, dryRun };
80
+ }
81
+ /** `canonical_dir`: the shell resolves via `cd … && pwd`, so symlinks collapse. */
82
+ function canonicalDir(directory) {
83
+ return fs.realpathSync(directory);
84
+ }
85
+ function configurePaths(hqRootInput, home) {
86
+ const hqRoot = canonicalDir(hqRootInput);
87
+ const claudeSource = path.join(hqRoot, ".claude");
88
+ const projectCodexDir = path.join(hqRoot, ".codex");
89
+ const legacyCodexDir = path.join(hqRoot, ".Codex");
90
+ return {
91
+ hqRoot,
92
+ skillsSource: path.join(claudeSource, "skills"),
93
+ claudeSource,
94
+ commandsSource: path.join(claudeSource, "commands"),
95
+ hooksSource: path.join(claudeSource, "hooks"),
96
+ policiesSource: path.join(claudeSource, "policies"),
97
+ settingsFile: path.join(claudeSource, "settings.json"),
98
+ outputStylesSource: path.join(claudeSource, "output-styles"),
99
+ globalSkillsTarget: path.join(home, ".codex", "skills", "hq"),
100
+ globalAgentsSkillsTarget: path.join(home, ".agents", "skills", "hq"),
101
+ repoAgentsSkillsTarget: path.join(hqRoot, ".agents", "skills"),
102
+ projectCodexDir,
103
+ projectClaudeTarget: path.join(projectCodexDir, "claude"),
104
+ projectPromptsTarget: path.join(projectCodexDir, "prompts"),
105
+ projectOutputStyleTarget: path.join(projectCodexDir, "output-style.md"),
106
+ legacyCodexDir,
107
+ legacyClaudeTarget: path.join(legacyCodexDir, "claude"),
108
+ legacyPromptsTarget: path.join(legacyCodexDir, "prompts"),
109
+ legacyOutputStyleTarget: path.join(legacyCodexDir, "output-style.md"),
110
+ };
111
+ }
112
+ function isSymlink(target) {
113
+ try {
114
+ return fs.lstatSync(target).isSymbolicLink();
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ /** `[[ -e ]]`: follows links, so a dangling symlink is not "exists". */
121
+ function exists(target) {
122
+ return fs.existsSync(target);
123
+ }
124
+ function listEntries(directory) {
125
+ try {
126
+ return fs.readdirSync(directory);
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ }
132
+ /** Directory or symlink child holding a SKILL.md, matching the shell's find. */
133
+ function skillDirectories(skillsSource) {
134
+ return listEntries(skillsSource).filter((name) => {
135
+ const child = path.join(skillsSource, name);
136
+ let isDirOrLink = false;
137
+ try {
138
+ const stat = fs.lstatSync(child);
139
+ isDirOrLink = stat.isDirectory() || stat.isSymbolicLink();
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ return isDirOrLink && fs.existsSync(path.join(child, "SKILL.md"));
145
+ });
146
+ }
147
+ function markdownFiles(directory) {
148
+ return listEntries(directory).filter((name) => {
149
+ if (!name.endsWith(".md"))
150
+ return false;
151
+ try {
152
+ return fs.lstatSync(path.join(directory, name)).isFile();
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ });
158
+ }
159
+ function plainFiles(directory) {
160
+ return listEntries(directory).filter((name) => {
161
+ try {
162
+ return fs.lstatSync(path.join(directory, name)).isFile();
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ });
168
+ }
169
+ /**
170
+ * `normalize_link_target`: absolute targets pass through; relative ones resolve
171
+ * against the link's directory, with existing paths canonicalised.
172
+ */
173
+ function normalizeLinkTarget(target) {
174
+ const linkTarget = fs.readlinkSync(target);
175
+ if (path.isAbsolute(linkTarget))
176
+ return linkTarget;
177
+ const targetDir = canonicalDir(path.dirname(target));
178
+ const resolved = path.join(targetDir, linkTarget);
179
+ if (fs.existsSync(resolved)) {
180
+ if (fs.statSync(resolved).isDirectory())
181
+ return canonicalDir(resolved);
182
+ return path.join(canonicalDir(path.dirname(resolved)), path.basename(resolved));
183
+ }
184
+ return resolved;
185
+ }
186
+ /** `strip_known_hq_subpath`: returns undefined when no known suffix matches. */
187
+ export function stripKnownHqSubpath(target) {
188
+ const outputStyle = target.match(/^(.*)\/\.claude\/output-styles\/[^/]*\.md$/);
189
+ if (outputStyle)
190
+ return outputStyle[1];
191
+ for (const suffix of ["/.claude/skills", "/.claude/commands", "/.claude"]) {
192
+ if (target.endsWith(suffix))
193
+ return target.slice(0, -suffix.length);
194
+ }
195
+ return undefined;
196
+ }
197
+ /** `is_hq_root_like`: basename HQ, no brace placeholders, not the bare path. */
198
+ export function isHqRootLike(candidate) {
199
+ if (candidate.includes("{") || candidate.includes("}"))
200
+ return false;
201
+ if (candidate === "/Documents/HQ")
202
+ return false;
203
+ return path.basename(candidate) === "HQ";
204
+ }
205
+ function activeOutputStyleName(paths) {
206
+ let contents;
207
+ try {
208
+ contents = fs.readFileSync(paths.settingsFile, "utf8");
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ // The shell greps line-wise and keeps the LAST match.
214
+ let match;
215
+ for (const line of contents.split("\n")) {
216
+ const found = line.match(/"outputStyle"[ \t]*:[ \t]*"([^"]*)"/);
217
+ if (found)
218
+ match = found[1];
219
+ }
220
+ return match;
221
+ }
222
+ /** `output_style_slug`: lowercase, collapse space/underscore, strip oddities. */
223
+ export function outputStyleSlug(name) {
224
+ return name
225
+ .toLowerCase()
226
+ .replace(/[ \t\n_]+/g, "-")
227
+ .replace(/[^a-z0-9.-]/g, "-")
228
+ .replace(/-+/g, "-")
229
+ .replace(/^-/, "")
230
+ .replace(/-$/, "");
231
+ }
232
+ function resolveOutputStyleSourceFile(paths) {
233
+ const styleName = activeOutputStyleName(paths);
234
+ if (!styleName)
235
+ return undefined;
236
+ const source = path.join(paths.outputStylesSource, `${outputStyleSlug(styleName)}.md`);
237
+ try {
238
+ if (!fs.lstatSync(source).isFile())
239
+ return undefined;
240
+ }
241
+ catch {
242
+ return undefined;
243
+ }
244
+ return source;
245
+ }
246
+ /** `bridge_target_is_repairable`: same suffix under a different HQ-like root. */
247
+ function bridgeTargetIsRepairable(currentTarget, source, hqRoot) {
248
+ const expectedSuffix = source.startsWith(hqRoot) ? source.slice(hqRoot.length) : source;
249
+ const targetRoot = stripKnownHqSubpath(currentTarget);
250
+ if (!targetRoot)
251
+ return false;
252
+ if (!isHqRootLike(targetRoot))
253
+ return false;
254
+ if (targetRoot === hqRoot)
255
+ return false;
256
+ return currentTarget.slice(targetRoot.length) === expectedSuffix;
257
+ }
258
+ function printLinkStatus(io, state, paths, label, source, target) {
259
+ io.out(`${label}:`);
260
+ io.out(` source: ${source}`);
261
+ io.out(` target: ${target}`);
262
+ if (isSymlink(target)) {
263
+ const resolvedTarget = normalizeLinkTarget(target);
264
+ io.out(" bridge: installed");
265
+ io.out(` points to: ${resolvedTarget}`);
266
+ if (resolvedTarget === source) {
267
+ io.out(" status: healthy");
268
+ }
269
+ else if (bridgeTargetIsRepairable(resolvedTarget, source, paths.hqRoot)) {
270
+ io.out(" status: stale HQ root (repairable)");
271
+ state.failures += 1;
272
+ }
273
+ else {
274
+ io.out(" status: unexpected target");
275
+ state.failures += 1;
276
+ }
277
+ }
278
+ else if (exists(target)) {
279
+ io.out(" bridge: blocked");
280
+ io.out(" status: target exists and is not a symlink");
281
+ state.failures += 1;
282
+ }
283
+ else {
284
+ io.out(" bridge: not installed");
285
+ state.failures += 1;
286
+ }
287
+ }
288
+ function repairOrCreateSymlink(io, options, paths, label, source, target) {
289
+ fs.mkdirSync(path.dirname(target), { recursive: true });
290
+ if (isSymlink(target)) {
291
+ const resolvedTarget = normalizeLinkTarget(target);
292
+ if (resolvedTarget === source) {
293
+ io.out(`${label} already installed.`);
294
+ return;
295
+ }
296
+ if (bridgeTargetIsRepairable(resolvedTarget, source, paths.hqRoot)) {
297
+ if (options.dryRun) {
298
+ io.out(`Would repair ${label}: ${resolvedTarget} -> ${source}`);
299
+ return;
300
+ }
301
+ fs.unlinkSync(target);
302
+ fs.symlinkSync(source, target);
303
+ io.out(`Repaired ${label}: ${resolvedTarget} -> ${source}`);
304
+ return;
305
+ }
306
+ throw new BridgeError(`Refusing to replace existing symlink: ${target} -> ${resolvedTarget}`);
307
+ }
308
+ if (exists(target)) {
309
+ throw new BridgeError(`Refusing to overwrite existing path: ${target}`);
310
+ }
311
+ if (options.dryRun) {
312
+ io.out(`Would install ${label}: ${target} -> ${source}`);
313
+ return;
314
+ }
315
+ fs.symlinkSync(source, target);
316
+ io.out(`Installed ${label}.`);
317
+ }
318
+ function configFiles(paths) {
319
+ return [
320
+ path.join(paths.hqRoot, ".mcp.json"),
321
+ path.join(paths.claudeSource, "settings.json"),
322
+ path.join(paths.claudeSource, "settings.local.json"),
323
+ path.join(paths.projectCodexDir, "config.toml"),
324
+ path.join(paths.legacyCodexDir, "config.toml"),
325
+ ].filter((file) => {
326
+ try {
327
+ return fs.statSync(file).isFile();
328
+ }
329
+ catch {
330
+ return false;
331
+ }
332
+ });
333
+ }
334
+ /** `extract_hq_roots_from_file`: absolute paths ending in `/HQ` or `/HQ/`. */
335
+ function extractHqRootsFromFile(file) {
336
+ let contents;
337
+ try {
338
+ contents = fs.readFileSync(file, "utf8");
339
+ }
340
+ catch {
341
+ return [];
342
+ }
343
+ const matches = contents.match(/\/[^"\s]+\/HQ(\/|$)/gm) ?? [];
344
+ return matches.map((match) => (match.endsWith("/") ? match.slice(0, -1) : match));
345
+ }
346
+ function collectStaleRoots(paths, options, includeExplicit) {
347
+ const roots = [];
348
+ if (includeExplicit) {
349
+ for (const root of options.oldRoots)
350
+ if (root)
351
+ roots.push(root);
352
+ }
353
+ for (const target of [
354
+ paths.globalSkillsTarget,
355
+ paths.globalAgentsSkillsTarget,
356
+ paths.repoAgentsSkillsTarget,
357
+ paths.projectClaudeTarget,
358
+ paths.projectPromptsTarget,
359
+ paths.projectOutputStyleTarget,
360
+ paths.legacyClaudeTarget,
361
+ paths.legacyPromptsTarget,
362
+ paths.legacyOutputStyleTarget,
363
+ ]) {
364
+ if (!isSymlink(target))
365
+ continue;
366
+ const stripped = stripKnownHqSubpath(normalizeLinkTarget(target));
367
+ if (stripped)
368
+ roots.push(stripped);
369
+ }
370
+ for (const file of configFiles(paths))
371
+ roots.push(...extractHqRootsFromFile(file));
372
+ // `sort -u` then filter, matching the shell's ordering exactly.
373
+ return [...new Set(roots)]
374
+ .sort()
375
+ .filter((root) => root && root !== paths.hqRoot && isHqRootLike(root));
376
+ }
377
+ function printStaleRootReport(io, paths, options) {
378
+ const roots = collectStaleRoots(paths, options, false);
379
+ if (roots.length === 0) {
380
+ io.out("Stale HQ roots: none detected");
381
+ return;
382
+ }
383
+ io.out("Stale HQ roots detected:");
384
+ for (const root of roots)
385
+ io.out(` - ${root}`);
386
+ }
387
+ function rewriteLocalConfigRoots(io, paths, options) {
388
+ const roots = collectStaleRoots(paths, options, true);
389
+ if (roots.length === 0) {
390
+ io.out("No stale local config roots to rewrite.");
391
+ return;
392
+ }
393
+ let rewroteAny = false;
394
+ for (const file of configFiles(paths)) {
395
+ let changedFile = false;
396
+ for (const oldRoot of roots) {
397
+ if (!oldRoot)
398
+ continue;
399
+ let contents = fs.readFileSync(file, "utf8");
400
+ if (!contents.includes(oldRoot))
401
+ continue;
402
+ if (options.dryRun) {
403
+ io.out(`Would rewrite ${file}: ${oldRoot} -> ${paths.hqRoot}`);
404
+ continue;
405
+ }
406
+ contents = contents.split(oldRoot).join(paths.hqRoot);
407
+ fs.writeFileSync(file, contents);
408
+ changedFile = true;
409
+ }
410
+ if (changedFile) {
411
+ io.out(`Rewrote local config paths in ${file}.`);
412
+ rewroteAny = true;
413
+ }
414
+ }
415
+ if (!rewroteAny && !options.dryRun)
416
+ io.out("No local config files needed rewriting.");
417
+ }
418
+ function printOutputStyleStatus(io, state, paths) {
419
+ const styleName = activeOutputStyleName(paths);
420
+ io.out(`Active output style: ${styleName || "not configured"}`);
421
+ const source = resolveOutputStyleSourceFile(paths);
422
+ if (source) {
423
+ printLinkStatus(io, state, paths, "Project Codex output-style bridge", source, paths.projectOutputStyleTarget);
424
+ if (exists(paths.legacyCodexDir) || isSymlink(paths.legacyOutputStyleTarget)) {
425
+ io.out("");
426
+ printLinkStatus(io, state, paths, "Legacy .Codex output-style bridge", source, paths.legacyOutputStyleTarget);
427
+ }
428
+ return;
429
+ }
430
+ io.out("Project Codex output-style bridge:");
431
+ io.out(" source: unavailable");
432
+ io.out(` target: ${paths.projectOutputStyleTarget}`);
433
+ io.out(" status: active output style has no .claude/output-styles/*.md bridge source");
434
+ state.failures += 1;
435
+ }
436
+ /** Returns the shell's status exit code: the number of unhealthy checks. */
437
+ function printStatus(io, paths, options) {
438
+ const skillTotal = skillDirectories(paths.skillsSource).length;
439
+ const skillWith = skillDirectories(paths.skillsSource).filter((name) => fs.existsSync(path.join(paths.skillsSource, name, "agents", "openai.yaml"))).length;
440
+ const state = { failures: 0 };
441
+ io.out(`HQ Claude source: ${paths.claudeSource}`);
442
+ io.out(`Skills in source: ${skillTotal} (${skillWith} with agents/openai.yaml, ${skillTotal - skillWith} without)`);
443
+ io.out(`Hooks in source: ${plainFiles(paths.hooksSource).length}`);
444
+ io.out(`Policies in source: ${markdownFiles(paths.policiesSource).length}`);
445
+ io.out("");
446
+ printLinkStatus(io, state, paths, "Global skills bridge (legacy)", paths.skillsSource, paths.globalSkillsTarget);
447
+ io.out("");
448
+ printLinkStatus(io, state, paths, "Global agents skills bridge", paths.skillsSource, paths.globalAgentsSkillsTarget);
449
+ io.out("");
450
+ printLinkStatus(io, state, paths, "Repo agents skills bridge", paths.skillsSource, paths.repoAgentsSkillsTarget);
451
+ io.out("");
452
+ printLinkStatus(io, state, paths, "Project Claude mirror", paths.claudeSource, paths.projectClaudeTarget);
453
+ io.out("");
454
+ printOutputStyleStatus(io, state, paths);
455
+ if (exists(paths.legacyCodexDir) ||
456
+ isSymlink(paths.legacyClaudeTarget) ||
457
+ isSymlink(paths.legacyPromptsTarget)) {
458
+ io.out("");
459
+ printLinkStatus(io, state, paths, "Legacy .Codex Claude mirror", paths.claudeSource, paths.legacyClaudeTarget);
460
+ }
461
+ io.out("");
462
+ printStaleRootReport(io, paths, options);
463
+ return state.failures;
464
+ }
465
+ function validateSources(paths) {
466
+ if (!fs.existsSync(paths.skillsSource) || !fs.statSync(paths.skillsSource).isDirectory()) {
467
+ throw new BridgeError(`Missing skills source directory: ${paths.skillsSource}`);
468
+ }
469
+ if (!fs.existsSync(paths.claudeSource) || !fs.statSync(paths.claudeSource).isDirectory()) {
470
+ throw new BridgeError(`Missing Claude source directory: ${paths.claudeSource}`);
471
+ }
472
+ }
473
+ function installBridge(io, paths, options) {
474
+ validateSources(paths);
475
+ const outputStyleSource = resolveOutputStyleSourceFile(paths);
476
+ if (!outputStyleSource) {
477
+ throw new BridgeError(`Active outputStyle must have a matching ${paths.outputStylesSource}/<style>.md file.`);
478
+ }
479
+ repairOrCreateSymlink(io, options, paths, "global Codex skill bridge (legacy)", paths.skillsSource, paths.globalSkillsTarget);
480
+ repairOrCreateSymlink(io, options, paths, "global agents skill bridge", paths.skillsSource, paths.globalAgentsSkillsTarget);
481
+ repairOrCreateSymlink(io, options, paths, "repo agents skill bridge", paths.skillsSource, paths.repoAgentsSkillsTarget);
482
+ repairOrCreateSymlink(io, options, paths, "project Codex Claude mirror", paths.claudeSource, paths.projectClaudeTarget);
483
+ repairOrCreateSymlink(io, options, paths, "project Codex output-style bridge", outputStyleSource, paths.projectOutputStyleTarget);
484
+ if (exists(paths.legacyCodexDir) ||
485
+ isSymlink(paths.legacyClaudeTarget) ||
486
+ isSymlink(paths.legacyPromptsTarget)) {
487
+ repairOrCreateSymlink(io, options, paths, "legacy .Codex Claude mirror", paths.claudeSource, paths.legacyClaudeTarget);
488
+ repairOrCreateSymlink(io, options, paths, "legacy .Codex output-style bridge", outputStyleSource, paths.legacyOutputStyleTarget);
489
+ }
490
+ if (options.repairLocalConfig) {
491
+ io.out("");
492
+ rewriteLocalConfigRoots(io, paths, options);
493
+ }
494
+ io.out("");
495
+ return printStatus(io, paths, options);
496
+ }
497
+ /** Runs one bridge command; returns the process exit code. */
498
+ export function runCodexSkillBridge(options, io) {
499
+ const paths = configurePaths(options.hqRoot, io.home);
500
+ if (options.command === "status")
501
+ return printStatus(io, paths, options);
502
+ const effective = options.command === "doctor" ? { ...options, repairLocalConfig: true } : options;
503
+ return installBridge(io, paths, effective);
504
+ }
505
+ //# sourceMappingURL=codex-skill-bridge.js.map
@@ -0,0 +1,15 @@
1
+ import type { UtilityIo } from "./common.js";
2
+ export type QmdReindexOptions = UtilityIo & {
3
+ cwd?: string;
4
+ /** Test seams; default to the real search-index implementations. */
5
+ resolveBin?: () => string;
6
+ reconcile?: (hqRoot: string, options: {
7
+ bin: string;
8
+ }) => unknown;
9
+ run?: (args: string[], options: {
10
+ bin: string;
11
+ cwd: string;
12
+ }) => unknown;
13
+ };
14
+ export declare function qmdReindexAfterSync(args?: string[], options?: QmdReindexOptions): number;
15
+ //# sourceMappingURL=qmd-reindex-after-sync.d.ts.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Native implementation of `core/scripts/qmd-reindex-after-sync.sh`.
3
+ *
4
+ * The shell script restated the collection-registration convention that
5
+ * `src/lib/search-index` already owns; this port reuses that single source
6
+ * instead, so the two can no longer drift.
7
+ *
8
+ * Contract preserved verbatim: a missing qmd or a non-HQ path is a silent
9
+ * success (a sync must never fail because indexing could not run), the lexical
10
+ * update always runs, and embeddings rebuild only on `--embed`.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { reconcileCollections, resolveQmdBin, runQmd } from "../search-index/index.js";
15
+ export function qmdReindexAfterSync(args = [], options = {}) {
16
+ let hqRoot = "";
17
+ let embed = false;
18
+ for (const arg of args) {
19
+ if (arg === "--embed")
20
+ embed = true;
21
+ else if (arg.startsWith("--"))
22
+ continue; // unknown flags are ignored, as in the shell
23
+ else if (!hqRoot)
24
+ hqRoot = arg;
25
+ }
26
+ if (!hqRoot)
27
+ hqRoot = options.cwd ?? process.cwd();
28
+ let bin;
29
+ try {
30
+ bin = (options.resolveBin ?? resolveQmdBin)();
31
+ }
32
+ catch {
33
+ return 0; // qmd absent: silent no-op.
34
+ }
35
+ if (!fs.existsSync(path.join(hqRoot, "core/core.yaml")))
36
+ return 0;
37
+ const reconcile = options.reconcile ?? ((root, opts) => reconcileCollections(root, opts));
38
+ const run = options.run ?? ((argv, opts) => runQmd(argv, opts));
39
+ // Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
40
+ try {
41
+ reconcile(hqRoot, { bin });
42
+ }
43
+ catch {
44
+ /* registration is advisory; an update still helps */
45
+ }
46
+ try {
47
+ run(["update"], { bin, cwd: hqRoot });
48
+ }
49
+ catch {
50
+ /* never fail a sync on index freshness */
51
+ }
52
+ if (embed) {
53
+ try {
54
+ run(["embed"], { bin, cwd: hqRoot });
55
+ }
56
+ catch {
57
+ /* embeddings are deferred-cost and optional */
58
+ }
59
+ }
60
+ return 0;
61
+ }
62
+ //# sourceMappingURL=qmd-reindex-after-sync.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.91.0",
3
+ "version": "5.93.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {