@indigoai-us/hq-cli 5.92.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,26 @@
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
+
5
25
  ## [5.92.0]
6
26
 
7
27
  ### 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
@@ -42,6 +42,8 @@ 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
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";
45
47
  /**
46
48
  * These commands are now native implementations. Their retained shell assets
47
49
  * are deliberately claimed in SCAFFOLD_ONLY_ASSETS: loose HQ trees still ship
@@ -55,6 +57,8 @@ export const NATIVE_UTILITY_COMMANDS = [
55
57
  { name: "token-usage-report", root: "cwd", summary: "Report session token usage", run: (args) => tokenUsageReport(args) },
56
58
  { name: "worktree", root: "live", summary: "Create a git worktree under workspace/worktrees/", run: (args, context) => createWorktree(args, { cwd: context.cwd, hqRoot: context.hqRoot }) },
57
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 }) },
58
62
  ];
59
63
  export const NATIVE_SCAFFOLD_COMMANDS = [
60
64
  {
@@ -161,12 +165,6 @@ export const REBUILD_INDEX_TARGETS = [
161
165
  */
162
166
  export const SCAFFOLD_COMMANDS = [
163
167
  // ---- One-shot migrations and maintenance (live tree) ----
164
- {
165
- name: "archive-old-threads",
166
- asset: "core/scripts/archive-old-threads.sh",
167
- root: "live",
168
- summary: "Archive aged session-thread files",
169
- },
170
168
  {
171
169
  name: "backfill-company-skill-mirrors",
172
170
  asset: "core/scripts/backfill-company-skill-mirrors.sh",
@@ -179,12 +177,6 @@ export const SCAFFOLD_COMMANDS = [
179
177
  root: "cwd",
180
178
  summary: "One-shot: mirror threads into company workspaces",
181
179
  },
182
- {
183
- name: "qmd-reindex-after-sync",
184
- asset: "core/scripts/qmd-reindex-after-sync.sh",
185
- root: "cwd",
186
- summary: "Refresh qmd collections after a sync",
187
- },
188
180
  ];
189
181
  /**
190
182
  * Bundled solely for HQ-root scaffolds, which still need these as loose files.
@@ -192,7 +184,9 @@ export const SCAFFOLD_COMMANDS = [
192
184
  * direct `hq core` command.
193
185
  */
194
186
  export const SCAFFOLD_ONLY_ASSETS = [
187
+ { asset: "core/scripts/archive-old-threads.sh", root: "live", summary: "Scaffold-only native utility companion" },
195
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" },
196
190
  { asset: "core/scripts/detect-stale-core-policy-mirror.sh", root: "live", summary: "Scaffold-only native utility companion" },
197
191
  { asset: "core/scripts/hq-status-summary.sh", root: "cwd", summary: "Scaffold-only native utility companion" },
198
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,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.92.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": {