@indigoai-us/hq-cli 5.96.0 → 5.97.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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.97.0]
6
+
7
+ ### Added
8
+
9
+ - `hq reindex` now keeps files over GitHub's 100MB limit out of the HQ repo.
10
+ The HQ root is a git repo that `git add -A` sweeps wholesale after every
11
+ sync, and session logs and package stores routinely pass that limit — once
12
+ such a blob is committed, every later push is rejected with GH001
13
+ permanently, because the blob is in history. Oversized files now gain an
14
+ anchored `.gitignore` rule, and any already in the index are dropped with
15
+ `git rm --cached`, which leaves the working-tree file untouched. Every
16
+ affected path is named on stdout, since untracking mutates the user's index
17
+ and must never be silent. The scan runs through git rather than the
18
+ filesystem, so nested checkouts under `repos/` are excluded automatically,
19
+ and it is throttled to once an hour per root so the hook-driven reindex path
20
+ stays cheap. (#346)
21
+
5
22
  ## [5.96.0]
6
23
 
7
24
  ### Changed
@@ -30,6 +30,7 @@ import * as yaml from 'js-yaml';
30
30
  import { reindex, rescue } from '@indigoai-us/hq-cloud';
31
31
  import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
32
32
  import { findHqRoot } from '../utils/manifest.js';
33
+ import { guardLargeFiles } from '../utils/large-file-guard.js';
33
34
  const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
34
35
  const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
35
36
  /** Resolve the same root the repair/check commands must operate on. */
@@ -205,6 +206,25 @@ export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
205
206
  printHookHealthWarning(hqRoot, 'hook configuration repair could not run');
206
207
  }
207
208
  }
209
+ /**
210
+ * Keep files above GitHub's 100MB limit out of the HQ repo, and say so.
211
+ *
212
+ * Untracking is a mutation of the user's index, so it is never silent: every
213
+ * affected path is named on stdout. Throttled internally to one scan per hour
214
+ * per root, so the hook-driven reindex path stays cheap.
215
+ */
216
+ function reportLargeFileGuard(hqRoot) {
217
+ const { scanned, ignored, untracked } = guardLargeFiles(hqRoot);
218
+ if (!scanned)
219
+ return;
220
+ const removed = new Set(untracked);
221
+ for (const file of untracked) {
222
+ console.log(`reindex: ${file} exceeds GitHub's 100MB limit — removed from git tracking and ignored (the file itself is untouched)`);
223
+ }
224
+ for (const file of ignored.filter((f) => !removed.has(f))) {
225
+ console.log(`reindex: ${file} exceeds GitHub's 100MB limit — added to .gitignore`);
226
+ }
227
+ }
208
228
  export function registerReindexCommand(program) {
209
229
  program
210
230
  .command('reindex')
@@ -239,6 +259,9 @@ export function registerReindexCommand(program) {
239
259
  repairExtremeHookDrift(hqRoot, status === 0);
240
260
  if (status === 0)
241
261
  await trustHqRuntimeHooks(hqRoot);
262
+ // Runs even when reindex failed: a failed reindex does not make an
263
+ // oversized blob any less likely to wedge the next push.
264
+ reportLargeFileGuard(hqRoot);
242
265
  process.exit(status);
243
266
  });
244
267
  }
@@ -0,0 +1,39 @@
1
+ /** GitHub rejects any single file above this size. */
2
+ export declare const DEFAULT_THRESHOLD_BYTES: number;
3
+ /** At most one scan per hour per root. */
4
+ export declare const SCAN_THROTTLE_MS: number;
5
+ export interface LargeFileGuardResult {
6
+ /** False when the root is not a git repo, or the scan was throttled. */
7
+ scanned: boolean;
8
+ /** Repo-relative paths newly added to `.gitignore`. */
9
+ ignored: string[];
10
+ /** Repo-relative paths removed from the index (still on disk). */
11
+ untracked: string[];
12
+ }
13
+ export interface LargeFileGuardOptions {
14
+ /** Defaults to {@link DEFAULT_THRESHOLD_BYTES}. */
15
+ thresholdBytes?: number;
16
+ /** Injectable clock, in epoch milliseconds. */
17
+ now?: number;
18
+ /** Bypass the throttle. */
19
+ force?: boolean;
20
+ }
21
+ /**
22
+ * Render a repo-relative path as a literal `.gitignore` pattern.
23
+ *
24
+ * The leading slash anchors it to the repo root — without it a bare filename
25
+ * would also ignore same-named files in every subdirectory. Glob and
26
+ * character-class metacharacters are escaped so the rule matches the one real
27
+ * path and nothing else, and a trailing space is escaped because git strips
28
+ * unescaped ones.
29
+ */
30
+ export declare function toGitignorePattern(relPath: string): string;
31
+ /**
32
+ * Scan `hqRoot` for files above the size limit, ignore them, and drop any that
33
+ * are already in the index.
34
+ *
35
+ * Never throws: every failure path degrades to "did nothing", because a guard
36
+ * that can take `hq reindex` down is worse than the problem it prevents.
37
+ */
38
+ export declare function guardLargeFiles(hqRoot: string, options?: LargeFileGuardOptions): LargeFileGuardResult;
39
+ //# sourceMappingURL=large-file-guard.d.ts.map
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Large-file guard — keep blobs over GitHub's hard limit out of the HQ repo.
3
+ *
4
+ * HQ's root is a git repo that `git add -A` sweeps wholesale (the desktop app's
5
+ * git-mirror does exactly that after every sync). Session logs and package
6
+ * stores routinely blow past GitHub's 100 MB per-file limit, and once such a
7
+ * blob is committed every subsequent push is rejected with GH001 — permanently,
8
+ * since the blob is in history. Recovering means rewriting history.
9
+ *
10
+ * This guard runs from `hq reindex` and closes the door before that happens:
11
+ * - oversized files gain a `.gitignore` rule, so `git add -A` skips them;
12
+ * - oversized files already in the index are dropped from it with
13
+ * `git rm --cached`, which leaves the working-tree file untouched.
14
+ *
15
+ * Design constraints that shaped this:
16
+ *
17
+ * - **Git-aware, not filesystem-aware.** A naive `find -size +100M` over an HQ
18
+ * root takes ~37s and surfaces files inside nested checkouts under `repos/`,
19
+ * which the outer repo does not own. Driving everything through git costs
20
+ * ~13s and gets nested-repo boundaries right for free.
21
+ * - **Index, not HEAD.** `git rm --cached` mutates the index and leaves HEAD
22
+ * alone until the next commit, so enumerating HEAD would re-report the same
23
+ * file on every run.
24
+ * - **Throttled.** `hq reindex` also fires from a PostToolUse hook shim on
25
+ * skill/worker/policy edits. A stamp file in the git dir bounds the scan to
26
+ * once an hour so a policy-editing session doesn't pay the cost repeatedly.
27
+ */
28
+ import { spawnSync } from "node:child_process";
29
+ import * as fs from "node:fs";
30
+ import * as path from "node:path";
31
+ /** GitHub rejects any single file above this size. */
32
+ export const DEFAULT_THRESHOLD_BYTES = 100 * 1024 * 1024;
33
+ /** At most one scan per hour per root. */
34
+ export const SCAN_THROTTLE_MS = 60 * 60 * 1000;
35
+ /** Lives in the git dir, so it is never committed, synced, or seen by status. */
36
+ const STAMP_BASENAME = "hq-large-file-scan.stamp";
37
+ const GITIGNORE_HEADER = "# hq reindex: files over GitHub's 100MB limit (added automatically)";
38
+ /**
39
+ * git output for a fully-populated HQ root runs to tens of megabytes
40
+ * (~450k tracked paths). The Node default of 1 MiB would truncate it.
41
+ */
42
+ const MAX_GIT_BUFFER = 512 * 1024 * 1024;
43
+ const SKIPPED = Object.freeze({
44
+ scanned: false,
45
+ ignored: [],
46
+ untracked: [],
47
+ });
48
+ function git(cwd, args, input) {
49
+ try {
50
+ const out = spawnSync("git", args, {
51
+ cwd,
52
+ input,
53
+ encoding: "utf8",
54
+ maxBuffer: MAX_GIT_BUFFER,
55
+ });
56
+ if (out.error || out.status !== 0)
57
+ return { ok: false, stdout: "" };
58
+ return { ok: true, stdout: out.stdout ?? "" };
59
+ }
60
+ catch {
61
+ // A missing cwd or an absent git binary must degrade to "guard did
62
+ // nothing", never take reindex down with it.
63
+ return { ok: false, stdout: "" };
64
+ }
65
+ }
66
+ /** Absolute path to the repo's git dir, or undefined when cwd is not a repo. */
67
+ function resolveGitDir(hqRoot) {
68
+ if (!fs.existsSync(hqRoot))
69
+ return undefined;
70
+ const out = git(hqRoot, ["rev-parse", "--git-dir"]);
71
+ if (!out.ok)
72
+ return undefined;
73
+ const raw = out.stdout.trim();
74
+ if (!raw)
75
+ return undefined;
76
+ return path.isAbsolute(raw) ? raw : path.resolve(hqRoot, raw);
77
+ }
78
+ function isThrottled(stampPath, now) {
79
+ try {
80
+ const last = Number(fs.readFileSync(stampPath, "utf8").trim());
81
+ if (!Number.isFinite(last))
82
+ return false;
83
+ return now - last < SCAN_THROTTLE_MS;
84
+ }
85
+ catch {
86
+ return false; // no stamp yet, or unreadable — scan.
87
+ }
88
+ }
89
+ function writeStamp(stampPath, now) {
90
+ try {
91
+ fs.writeFileSync(stampPath, `${now}\n`);
92
+ }
93
+ catch {
94
+ // Losing the stamp only costs an extra scan next time.
95
+ }
96
+ }
97
+ /** Split a NUL-delimited git record stream, dropping the trailing empty field. */
98
+ function splitNul(raw) {
99
+ return raw.split("\0").filter((entry) => entry.length > 0);
100
+ }
101
+ /**
102
+ * Oversized blobs in the *index*.
103
+ *
104
+ * `git ls-files -s` yields `<mode> <object> <stage>\t<path>`; the object ids are
105
+ * piped through `cat-file --batch-check` so sizes come from the object database
106
+ * rather than ~450k filesystem stats.
107
+ */
108
+ function oversizedInIndex(hqRoot, threshold) {
109
+ const listed = git(hqRoot, ["ls-files", "-s", "-z"]);
110
+ if (!listed.ok)
111
+ return [];
112
+ const entries = [];
113
+ for (const record of splitNul(listed.stdout)) {
114
+ const tab = record.indexOf("\t");
115
+ if (tab === -1)
116
+ continue;
117
+ const fields = record.slice(0, tab).split(" ");
118
+ if (fields.length < 2)
119
+ continue;
120
+ entries.push({ oid: fields[1], file: record.slice(tab + 1) });
121
+ }
122
+ if (entries.length === 0)
123
+ return [];
124
+ const sizes = git(hqRoot, ["cat-file", "--batch-check=%(objectsize)"], `${entries.map((e) => e.oid).join("\n")}\n`);
125
+ if (!sizes.ok)
126
+ return [];
127
+ // One output line per input line, in order.
128
+ const lines = sizes.stdout.split("\n");
129
+ const oversized = [];
130
+ for (let i = 0; i < entries.length; i += 1) {
131
+ const size = Number(lines[i]);
132
+ if (Number.isFinite(size) && size > threshold)
133
+ oversized.push(entries[i].file);
134
+ }
135
+ return oversized;
136
+ }
137
+ /**
138
+ * Oversized files git would pick up on the next `add -A` — untracked or
139
+ * modified. `git status` already honours `.gitignore` and stops at nested
140
+ * repository boundaries, so neither needs handling here.
141
+ */
142
+ function oversizedInWorktree(hqRoot, threshold) {
143
+ const status = git(hqRoot, ["status", "--porcelain", "-z", "--untracked-files=all"]);
144
+ if (!status.ok)
145
+ return [];
146
+ const records = splitNul(status.stdout);
147
+ const oversized = [];
148
+ for (let i = 0; i < records.length; i += 1) {
149
+ const record = records[i];
150
+ if (record.length < 4)
151
+ continue;
152
+ const code = record.slice(0, 2);
153
+ const file = record.slice(3);
154
+ // Renames and copies emit the source path as a second record; consume it
155
+ // so it is not parsed as a status line of its own.
156
+ if (code.startsWith("R") || code.startsWith("C"))
157
+ i += 1;
158
+ if (code.includes("D"))
159
+ continue; // going away — nothing to guard
160
+ try {
161
+ const stat = fs.statSync(path.join(hqRoot, file));
162
+ if (stat.isFile() && stat.size > threshold)
163
+ oversized.push(file);
164
+ }
165
+ catch {
166
+ // Vanished between status and stat; nothing to do.
167
+ }
168
+ }
169
+ return oversized;
170
+ }
171
+ /**
172
+ * Render a repo-relative path as a literal `.gitignore` pattern.
173
+ *
174
+ * The leading slash anchors it to the repo root — without it a bare filename
175
+ * would also ignore same-named files in every subdirectory. Glob and
176
+ * character-class metacharacters are escaped so the rule matches the one real
177
+ * path and nothing else, and a trailing space is escaped because git strips
178
+ * unescaped ones.
179
+ */
180
+ export function toGitignorePattern(relPath) {
181
+ const escaped = relPath.replace(/([\\*?[\]!#])/g, "\\$1");
182
+ const anchored = `/${escaped}`;
183
+ return anchored.endsWith(" ") ? `${anchored.slice(0, -1)}\\ ` : anchored;
184
+ }
185
+ function appendGitignore(hqRoot, patterns) {
186
+ if (patterns.length === 0)
187
+ return true;
188
+ const file = path.join(hqRoot, ".gitignore");
189
+ let existing = "";
190
+ try {
191
+ existing = fs.readFileSync(file, "utf8");
192
+ }
193
+ catch {
194
+ // No .gitignore yet — it gets created below.
195
+ }
196
+ const present = new Set(existing.split("\n").map((line) => line.trim()));
197
+ const fresh = patterns.filter((p) => !present.has(p));
198
+ if (fresh.length === 0)
199
+ return true;
200
+ const needsNewline = existing.length > 0 && !existing.endsWith("\n");
201
+ const header = existing.includes(GITIGNORE_HEADER) ? "" : `${GITIGNORE_HEADER}\n`;
202
+ try {
203
+ fs.appendFileSync(file, `${needsNewline ? "\n" : ""}${header}${fresh.join("\n")}\n`);
204
+ return true;
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ }
210
+ /**
211
+ * Scan `hqRoot` for files above the size limit, ignore them, and drop any that
212
+ * are already in the index.
213
+ *
214
+ * Never throws: every failure path degrades to "did nothing", because a guard
215
+ * that can take `hq reindex` down is worse than the problem it prevents.
216
+ */
217
+ export function guardLargeFiles(hqRoot, options = {}) {
218
+ const threshold = options.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
219
+ const now = options.now ?? Date.now();
220
+ const gitDir = resolveGitDir(hqRoot);
221
+ if (!gitDir)
222
+ return SKIPPED;
223
+ const stampPath = path.join(gitDir, STAMP_BASENAME);
224
+ if (!options.force && isThrottled(stampPath, now))
225
+ return SKIPPED;
226
+ writeStamp(stampPath, now);
227
+ const tracked = oversizedInIndex(hqRoot, threshold);
228
+ const trackedSet = new Set(tracked);
229
+ const worktree = oversizedInWorktree(hqRoot, threshold).filter((p) => !trackedSet.has(p));
230
+ const all = [...tracked, ...worktree];
231
+ if (all.length === 0)
232
+ return { scanned: true, ignored: [], untracked: [] };
233
+ // Ignore first, so a concurrent `git add -A` cannot re-stage what we are
234
+ // about to remove from the index.
235
+ const patterns = all.map(toGitignorePattern);
236
+ const wroteIgnore = appendGitignore(hqRoot, patterns);
237
+ const untracked = [];
238
+ for (const file of tracked) {
239
+ const removed = git(hqRoot, ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", file]);
240
+ if (removed.ok)
241
+ untracked.push(file);
242
+ }
243
+ return {
244
+ scanned: true,
245
+ ignored: wroteIgnore ? all : [],
246
+ untracked,
247
+ };
248
+ }
249
+ //# sourceMappingURL=large-file-guard.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.96.0",
3
+ "version": "5.97.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {