@indigoai-us/hq-cli 5.345.81 → 5.345.83

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.345.83] — 2026-10-11
6
+ - HQ-root scaffolds now package the shared runtime used by generated shell forwarders.
7
+ - Lanes hook installation now replaces stale hq-cli drain registrations across
8
+ Claude settings and Codex config, including entries whose old install path no
9
+ longer exists. Other hooks are preserved, duplicate drains are removed, and a
10
+ repeat install leaves the registration file byte-identical.
11
+
12
+ ## [5.345.82] — 2026-10-11
13
+ - Session-start health no longer treats an ordinary HQ Core upgrade warning
14
+ as an unrepaired doctor finding. `hq doctor` still shows the upgrade;
15
+ `--fix` still cannot apply it.
16
+ - `hq storage status` runs the local report, the offload scan and the cloud
17
+ listing at the same time, and lists cloud file history by folder in parallel
18
+ instead of one page at a time. Cloud credentials are vended right before the
19
+ first listing call and vended again before they expire, so a long scan or
20
+ dry run no longer fails with "The provided token has expired."
21
+
5
22
  ## [5.345.81] — 2026-10-11
6
23
  - HQ Storage (`hq storage status|prune|offload|fetch`) is available behind the Indigo-only `hq-storage` flag and fails closed when the flag is off. Requires @indigoai-us/hq-cloud 6.18.61, whose sync skips `.hq-offload/` and supports cloud-only `.hqcloud` placeholders.
7
24
  - `hq storage status` reports local autosave history and cloud file-version usage per company, grouped by age. `hq storage prune` deletes old noncurrent cloud file versions in companies where you are an owner or admin, and never touches the current version. A version's age counts from when it was replaced, so a recent undo copy of an old file is kept. `status --json` marks each company with `can_delete`, and still shows local usage when you are signed out.
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env bash
2
+ # Shared runtime for generated hq CLI forwarders.
3
+
4
+ forwarder_exec() {
5
+ local script_name="$1" min_cli="$2" root_mode="$3" command="$4" stdin_closed="$5"
6
+ shift 5
7
+ local forwarder_library_dir="${BASH_SOURCE[0]%/*}"
8
+ local path_rule index value path_option path_value
9
+ local -a forwarded_args path_rules path_options path_values converted_values command_parts
10
+ forwarded_args=("$@")
11
+ local forwarded_arg_count=$#
12
+
13
+ if [ "$root_mode" = live-project ]; then
14
+ HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}}"
15
+ elif [ "$root_mode" = live ]; then
16
+ HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
17
+ fi
18
+
19
+ if ! command -v hq >/dev/null 2>&1; then
20
+ printf "%s: requires the hq CLI — this script's implementation now ships with it.\n" "$script_name" >&2
21
+ printf 'Install it with: npm install -g @indigoai-us/hq-cli\n' >&2
22
+ exit 127
23
+ fi
24
+
25
+ [ "$forwarder_library_dir" != "${BASH_SOURCE[0]}" ] || forwarder_library_dir=.
26
+ if [ -f "$forwarder_library_dir/hq-cli-floor.sh" ]; then
27
+ # shellcheck source=lib/hq-cli-floor.sh
28
+ . "$forwarder_library_dir/hq-cli-floor.sh"
29
+ hq_cli_floor_check "$script_name" "$min_cli"
30
+ fi
31
+
32
+ if [ "$stdin_closed" = true ]; then
33
+ if ! { true 3<&0; } 2>/dev/null; then
34
+ export HQ_CORE_STDIN_CLOSED=1
35
+ fi
36
+ fi
37
+
38
+ if [ "$root_mode" = live ] || [ "$root_mode" = live-project ]; then
39
+ if command -v cygpath >/dev/null 2>&1; then
40
+ HQ_ROOT="$(cygpath -m "$HQ_ROOT")"
41
+ fi
42
+ fi
43
+
44
+ if command -v cygpath >/dev/null 2>&1; then
45
+ IFS=';' read -r -a path_rules <<< "$PATH_OPERANDS"
46
+ for path_rule in "${path_rules[@]}"; do
47
+ case "$path_rule" in
48
+ none) ;;
49
+ position:*)
50
+ index="${path_rule#position:}"
51
+ if (( index < forwarded_arg_count )); then convert_forwarded_path "$index"; fi
52
+ ;;
53
+ first-nonoption)
54
+ for ((index = 0; index < forwarded_arg_count; index++)); do
55
+ case "${forwarded_args[index]}" in --*) ;; *) convert_forwarded_path "$index"; break ;; esac
56
+ done
57
+ ;;
58
+ all-nonoptions)
59
+ for ((index = 0; index < forwarded_arg_count; index++)); do
60
+ case "${forwarded_args[index]}" in --*) ;; *) convert_forwarded_path "$index" ;; esac
61
+ done
62
+ ;;
63
+ options:*)
64
+ IFS=, read -r -a path_options <<< "${path_rule#options:}"
65
+ for ((index = 0; index < forwarded_arg_count; index++)); do
66
+ for path_option in "${path_options[@]}"; do
67
+ case "${forwarded_args[index]}" in
68
+ "$path_option")
69
+ index=$((index + 1))
70
+ if (( index < forwarded_arg_count )); then convert_forwarded_path "$index"; fi
71
+ break
72
+ ;;
73
+ "$path_option"=*)
74
+ value="${forwarded_args[index]#*=}"
75
+ case "$value" in /*) forwarded_args[index]="${path_option}=$(cygpath -m "$value")" ;; esac
76
+ break
77
+ ;;
78
+ esac
79
+ done
80
+ done
81
+ ;;
82
+ comma:*)
83
+ path_option="${path_rule#comma:}"
84
+ for ((index = 0; index < forwarded_arg_count; index++)); do
85
+ case "${forwarded_args[index]}" in
86
+ "$path_option")
87
+ index=$((index + 1))
88
+ if (( index < forwarded_arg_count )); then
89
+ IFS=, read -r -a path_values <<< "${forwarded_args[index]}"
90
+ converted_values=()
91
+ for path_value in "${path_values[@]}"; do
92
+ case "$path_value" in /*) converted_values+=("$(cygpath -m "$path_value")") ;; *) converted_values+=("$path_value") ;; esac
93
+ done
94
+ value="$(IFS=,; printf '%s' "${converted_values[*]}")"
95
+ forwarded_args[index]="$value"
96
+ fi
97
+ break
98
+ ;;
99
+ "$path_option"=*)
100
+ value="${forwarded_args[index]#*=}"
101
+ case "$value" in
102
+ /*,*|/*)
103
+ IFS=, read -r -a path_values <<< "$value"
104
+ converted_values=()
105
+ for path_value in "${path_values[@]}"; do
106
+ case "$path_value" in /*) converted_values+=("$(cygpath -m "$path_value")") ;; *) converted_values+=("$path_value") ;; esac
107
+ done
108
+ value="$(IFS=,; printf '%s' "${converted_values[*]}")"
109
+ forwarded_args[index]="${path_option}=$value"
110
+ ;;
111
+ esac
112
+ break
113
+ ;;
114
+ esac
115
+ done
116
+ ;;
117
+ *) printf 'unknown path_operands rule: %s\n' "$path_rule" >&2; exit 70 ;;
118
+ esac
119
+ done
120
+ fi
121
+
122
+ IFS=' ' read -r -a command_parts <<< "$command"
123
+ if [ "$root_mode" = live ] || [ "$root_mode" = live-project ]; then
124
+ if [ "$forwarded_arg_count" -gt 0 ]; then
125
+ MSYS2_ARG_CONV_EXCL='*' exec hq core --hq-root "$HQ_ROOT" "${command_parts[@]}" "${forwarded_args[@]}"
126
+ else
127
+ MSYS2_ARG_CONV_EXCL='*' exec hq core --hq-root "$HQ_ROOT" "${command_parts[@]}"
128
+ fi
129
+ elif [ "$forwarded_arg_count" -gt 0 ]; then
130
+ MSYS2_ARG_CONV_EXCL='*' exec hq core "${command_parts[@]}" "${forwarded_args[@]}"
131
+ else
132
+ MSYS2_ARG_CONV_EXCL='*' exec hq core "${command_parts[@]}"
133
+ fi
134
+ }
135
+
136
+ convert_forwarded_path() {
137
+ local arg_index="$1"
138
+ local value="${forwarded_args[$arg_index]}"
139
+ case "$value" in
140
+ /*) forwarded_args[$arg_index]="$(cygpath -m "$value")" ;;
141
+ esac
142
+ }
@@ -2931,6 +2931,10 @@ export declare const COMMAND_CATALOG: readonly [{
2931
2931
  readonly flags: "--company <slug>";
2932
2932
  readonly description: "Only this company (must be one of yours)";
2933
2933
  readonly hidden: false;
2934
+ }, {
2935
+ readonly flags: "--no-cache";
2936
+ readonly description: "List cloud file history live instead of using figures from the last 15 minutes";
2937
+ readonly hidden: false;
2934
2938
  }];
2935
2939
  readonly subcommands: readonly [];
2936
2940
  }, {
@@ -3533,6 +3533,11 @@ export const COMMAND_CATALOG = [
3533
3533
  "flags": "--company <slug>",
3534
3534
  "description": "Only this company (must be one of yours)",
3535
3535
  "hidden": false
3536
+ },
3537
+ {
3538
+ "flags": "--no-cache",
3539
+ "description": "List cloud file history live instead of using figures from the last 15 minutes",
3540
+ "hidden": false
3536
3541
  }
3537
3542
  ],
3538
3543
  "subcommands": []
@@ -268,6 +268,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
268
268
  { asset: "core/scripts/lib/session-scope-capability.sh", root: "live", summary: "Session scope-capability lib sourced by hq-session" },
269
269
  { asset: "core/scripts/lib/session-id.sh", root: "live", summary: "Session-id resolver lib sourced by hq-session" },
270
270
  { asset: "core/scripts/lib/hq-cli-floor.sh", root: "live", summary: "Shared CLI version-floor helper sourced by generated forwarders" },
271
+ { asset: "core/scripts/lib/forwarder.sh", root: "live", summary: "Shared runtime sourced by generated scaffold forwarders" },
271
272
  { asset: "core/scripts/lib/work-mesh-enqueue.sh", root: "live", summary: "Work Mesh Live spool enqueue lib sourced by hooks" },
272
273
  { asset: "core/scripts/lib/work-mesh-live-hook.sh", root: "live", summary: "Work Mesh Live hook helpers sourced by rebind" },
273
274
  { asset: "core/scripts/lib/work-mesh-live-rebind.sh", root: "live", summary: "Work Mesh Live session rebind library sourced by hooks and the native rollback command" },
@@ -59,6 +59,11 @@ export interface RunDoctorOptions {
59
59
  resolveClientHealthReasonCodesForCompany?: (companySlug: string | null) => Promise<boolean>;
60
60
  /** Resolve the separate status-report flag per company in sync results. */
61
61
  resolveClientHealthCheckStatusForCompany?: (companySlug: string | null) => Promise<boolean>;
62
+ /**
63
+ * Omit ordinary hq-core upgrade WARNs from `--json` (the SessionStart health
64
+ * hook). Default `--json` and text output still include them.
65
+ */
66
+ clientHealthReport?: boolean;
62
67
  /** In text mode, also print every PASS result. Default: false. */
63
68
  verbose?: boolean;
64
69
  /** In text mode, emit ANSI colour. Default: false (no escape sequences). */
@@ -181,6 +181,7 @@ export async function runDoctor(options = {}) {
181
181
  scope,
182
182
  clientHealthReasonCodesEnabledCheckIds,
183
183
  clientHealthCheckStatusEnabledCheckIds,
184
+ clientHealthReport: options.clientHealthReport === true,
184
185
  })));
185
186
  }
186
187
  else {
@@ -437,6 +438,7 @@ export function registerDoctorCommand(program) {
437
438
  only: parseOnly(opts.only),
438
439
  resolveClientHealthReasonCodesForCompany: clientHealthReasonCodesResolver,
439
440
  resolveClientHealthCheckStatusForCompany: clientHealthCheckStatusResolver,
441
+ clientHealthReport: opts.clientHealthReport === true,
440
442
  });
441
443
  // Set the exit code rather than calling process.exit, so the CLI's
442
444
  // normal shutdown (telemetry flush) still runs. Non-zero means either an
@@ -51,6 +51,8 @@ export interface CloudCompanyStatus {
51
51
  delete_markers: number;
52
52
  tranches: CloudTranche[];
53
53
  error?: string;
54
+ /** When these figures were listed (ISO). Older than now when served from the cache. */
55
+ listed_at?: string;
54
56
  }
55
57
  export interface StorageStatus {
56
58
  local: LocalReport;
@@ -112,7 +114,24 @@ export interface StorageDeps {
112
114
  log: (line: string) => void;
113
115
  /** Offload section for `status`. Absent in deps that only prune. */
114
116
  getOffload?: () => Promise<OffloadStatus>;
117
+ /** Cache of cloud status per company (see cloudStatusCache). Absent = always list. */
118
+ cloudCache?: CloudStatusCache;
115
119
  }
120
+ /**
121
+ * Listing every version of a large vault takes minutes (about 2 for 2.4M
122
+ * versions, most of it parsing S3's XML), so `status` keeps each company's
123
+ * cloud figures for CLOUD_CACHE_TTL_MS. Only complete, successful listings are
124
+ * kept; a real cloud prune drops the company's entry; dry runs and real prunes
125
+ * always list live.
126
+ */
127
+ export interface CloudStatusCache {
128
+ read(company: CompanyRef): CloudCompanyStatus | undefined;
129
+ write(company: CompanyRef, status: CloudCompanyStatus): void;
130
+ drop(company: CompanyRef): void;
131
+ }
132
+ export declare const CLOUD_CACHE_TTL_MS: number;
133
+ /** File cache under <hq>/workspace/.storage-cache/cloud-<company uid>.json. */
134
+ export declare function cloudStatusCache(hqRoot: string, now: () => Date, ttlMs?: number): CloudStatusCache;
116
135
  export declare function localScriptPath(hqRoot: string): string;
117
136
  /**
118
137
  * Core `drop-backups --expired` right before a real local prune; undefined
@@ -148,6 +167,16 @@ export interface VersionEntry {
148
167
  * to its own LastModified.
149
168
  */
150
169
  export declare function annotateNoncurrentSince(entries: VersionEntry[]): VersionEntry[];
170
+ /** Folder levels split into parallel listings (`a/`, then `a/b/`). */
171
+ export declare const LIST_SPLIT_DEPTH = 2;
172
+ /** Listings in flight at once per bucket. */
173
+ export declare const LIST_CONCURRENCY = 32;
174
+ /**
175
+ * A printable-ASCII key strictly between `lo` and `hi` in S3's (UTF-8 byte)
176
+ * order, or undefined when none is found. Used to split one long listing into
177
+ * two that run at the same time.
178
+ */
179
+ export declare function keyBetween(lo: string, hi: string): string | undefined;
151
180
  /** Plain message when S3 refuses a version delete for one company. */
152
181
  export declare function deleteDeniedMessage(slug: string): string;
153
182
  export declare function describeCloudError(err: unknown, action: "list" | "delete", slug?: string): string;
@@ -162,6 +191,7 @@ export declare function selectPrunable(versions: VersionEntry[], cutoff: Date):
162
191
  export declare function pruneCloud(company: CompanyRef, cutoff: Date, dryRun: boolean, deps: StorageDeps): Promise<CloudPruneResult>;
163
192
  export declare function runStorageStatus(opts: {
164
193
  company?: string;
194
+ noCache?: boolean;
165
195
  }, deps: StorageDeps): Promise<StorageStatus>;
166
196
  export declare function parseCutoff(value: string, flag: string): Date;
167
197
  export declare function runStoragePrune(opts: {
@@ -195,5 +225,25 @@ export declare const NEEDS_YES = "No terminal to confirm in. Re-run with --yes t
195
225
  export declare function confirmOnTty(message: string, input?: NodeJS.ReadableStream & {
196
226
  isTTY?: boolean;
197
227
  }, output?: NodeJS.WritableStream): Promise<boolean>;
228
+ /** STS credentials from one vend, with the time they stop working. */
229
+ export interface VendedCredentials {
230
+ accessKeyId: string;
231
+ secretAccessKey: string;
232
+ sessionToken: string;
233
+ expiresAt?: string;
234
+ }
235
+ /**
236
+ * AWS credential provider that vends fresh credentials on first use and again
237
+ * whenever the previous ones are within `marginMs` of expiring. Each vend calls
238
+ * `vend` anew, which signs in again (the CLI refreshes an expiring Cognito
239
+ * session there), so a cloud listing or delete that starts after a long local
240
+ * scan, or that itself outlasts one STS session, never sends an expired token.
241
+ */
242
+ export declare function refreshingVendedCredentials(vend: () => Promise<VendedCredentials>, now?: () => number, marginMs?: number): () => Promise<{
243
+ accessKeyId: string;
244
+ secretAccessKey: string;
245
+ sessionToken: string;
246
+ expiration?: Date;
247
+ }>;
198
248
  export declare function registerStorageCommand(program: Command): Command;
199
249
  //# sourceMappingURL=storage.d.ts.map
@@ -56,6 +56,45 @@ export function canDeleteCloudHistory(company) {
56
56
  return company.role !== undefined && DELETE_ROLES.has(company.role);
57
57
  }
58
58
  export const DELETE_ROLE_REASON = "Only company owners and admins can delete cloud file history.";
59
+ export const CLOUD_CACHE_TTL_MS = 15 * 60 * 1000;
60
+ /** File cache under <hq>/workspace/.storage-cache/cloud-<company uid>.json. */
61
+ export function cloudStatusCache(hqRoot, now, ttlMs = CLOUD_CACHE_TTL_MS) {
62
+ const dir = path.join(hqRoot, "workspace", ".storage-cache");
63
+ const file = (c) => path.join(dir, `cloud-${c.uid.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
64
+ return {
65
+ read(c) {
66
+ try {
67
+ const raw = JSON.parse(fs.readFileSync(file(c), "utf8"));
68
+ const at = Date.parse(raw.listed_at ?? "");
69
+ const age = now().getTime() - at;
70
+ if (raw.uid !== c.uid || raw.available !== true || Number.isNaN(at) || age < 0 || age >= ttlMs)
71
+ return undefined;
72
+ const { uid: _uid, ...status } = raw;
73
+ // Role can change between runs; it comes from the live membership.
74
+ return { ...status, company: c.slug, can_delete: canDeleteCloudHistory(c) };
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ },
80
+ write(c, status) {
81
+ if (!status.available)
82
+ return;
83
+ try {
84
+ fs.mkdirSync(dir, { recursive: true });
85
+ const tmp = `${file(c)}.tmp.${process.pid}`;
86
+ fs.writeFileSync(tmp, JSON.stringify({ ...status, uid: c.uid }));
87
+ fs.renameSync(tmp, file(c));
88
+ }
89
+ catch {
90
+ // A cache that cannot be written only costs the next status a listing.
91
+ }
92
+ },
93
+ drop(c) {
94
+ fs.rmSync(file(c), { force: true });
95
+ },
96
+ };
97
+ }
59
98
  // ── Local (hq-core script) ──────────────────────────────────────────────────
60
99
  export function localScriptPath(hqRoot) {
61
100
  return path.join(hqRoot, "core", "scripts", "hq-storage.sh");
@@ -149,52 +188,195 @@ export function annotateNoncurrentSince(entries) {
149
188
  function noncurrentAge(v) {
150
189
  return v.noncurrentSince ?? v.lastModified;
151
190
  }
152
- async function* listAllVersions(store) {
153
- let keyMarker;
154
- let versionIdMarker;
155
- do {
156
- const page = await store.s3.send(new ListObjectVersionsCommand({
157
- Bucket: store.bucket,
158
- KeyMarker: keyMarker,
159
- VersionIdMarker: versionIdMarker,
160
- }));
161
- for (const v of page.Versions ?? []) {
162
- if (!v.Key || !v.VersionId)
163
- continue;
164
- yield {
165
- key: v.Key,
166
- versionId: v.VersionId,
167
- isLatest: Boolean(v.IsLatest),
168
- lastModified: v.LastModified ? new Date(v.LastModified) : new Date(0),
169
- size: v.Size ?? 0,
170
- isDeleteMarker: false,
171
- };
191
+ function toEntries(page) {
192
+ const out = [];
193
+ for (const v of page.Versions ?? []) {
194
+ if (!v.Key || !v.VersionId)
195
+ continue;
196
+ out.push({
197
+ key: v.Key,
198
+ versionId: v.VersionId,
199
+ isLatest: Boolean(v.IsLatest),
200
+ lastModified: v.LastModified ? new Date(v.LastModified) : new Date(0),
201
+ size: v.Size ?? 0,
202
+ isDeleteMarker: false,
203
+ });
204
+ }
205
+ for (const m of page.DeleteMarkers ?? []) {
206
+ if (!m.Key || !m.VersionId)
207
+ continue;
208
+ out.push({
209
+ key: m.Key,
210
+ versionId: m.VersionId,
211
+ isLatest: Boolean(m.IsLatest),
212
+ lastModified: m.LastModified ? new Date(m.LastModified) : new Date(0),
213
+ size: 0,
214
+ isDeleteMarker: true,
215
+ });
216
+ }
217
+ return out;
218
+ }
219
+ /** Folder levels split into parallel listings (`a/`, then `a/b/`). */
220
+ export const LIST_SPLIT_DEPTH = 2;
221
+ /** Listings in flight at once per bucket. */
222
+ export const LIST_CONCURRENCY = 32;
223
+ function utf8Compare(a, b) {
224
+ return Buffer.compare(Buffer.from(a), Buffer.from(b));
225
+ }
226
+ /**
227
+ * A printable-ASCII key strictly between `lo` and `hi` in S3's (UTF-8 byte)
228
+ * order, or undefined when none is found. Used to split one long listing into
229
+ * two that run at the same time.
230
+ */
231
+ export function keyBetween(lo, hi) {
232
+ const a = Buffer.from(lo);
233
+ const b = Buffer.from(hi);
234
+ const FIRST = 0x20;
235
+ const LAST = 0x7e;
236
+ let k = 0;
237
+ while (k < a.length && k < b.length && a[k] === b[k])
238
+ k++;
239
+ const out = [...a.subarray(0, k)];
240
+ // Walk down from the first differing byte, keeping `lo`'s bytes while the
241
+ // gap is too small, until a byte strictly between both bounds fits.
242
+ for (let i = k; i < k + 8; i++) {
243
+ const ca = i < a.length ? a[i] : FIRST - 1;
244
+ const cb = i === k ? (i < b.length ? b[i] : LAST + 1) : LAST + 1;
245
+ const loB = Math.max(ca, FIRST - 1);
246
+ const hiB = Math.min(cb, LAST + 1);
247
+ if (hiB - loB >= 2) {
248
+ out.push((loB + hiB) >> 1);
249
+ const bytes = Buffer.from(out);
250
+ const mid = bytes.toString("utf8");
251
+ // The kept part of `lo` may end inside a multi-byte character.
252
+ if (!Buffer.from(mid).equals(bytes))
253
+ return undefined;
254
+ return utf8Compare(lo, mid) < 0 && utf8Compare(mid, hi) < 0 ? mid : undefined;
172
255
  }
173
- for (const m of page.DeleteMarkers ?? []) {
174
- if (!m.Key || !m.VersionId)
175
- continue;
176
- yield {
177
- key: m.Key,
178
- versionId: m.VersionId,
179
- isLatest: Boolean(m.IsLatest),
180
- lastModified: m.LastModified ? new Date(m.LastModified) : new Date(0),
181
- size: 0,
182
- isDeleteMarker: true,
183
- };
256
+ if (i >= a.length || a[i] < FIRST || a[i] > LAST)
257
+ return undefined;
258
+ out.push(a[i]);
259
+ }
260
+ return undefined;
261
+ }
262
+ /**
263
+ * Every version and delete marker in the bucket. One ListObjectVersions call
264
+ * returns at most 1,000 entries, so a sequential walk of a vault with millions
265
+ * of versions takes many minutes (and outlives a 15-minute STS session). The
266
+ * walk runs up to LIST_CONCURRENCY listings at once:
267
+ * - down to LIST_SPLIT_DEPTH levels a folder is listed with a "/" delimiter
268
+ * (its own entries plus its subfolders, each queued as its own listing);
269
+ * - deeper folders are listed whole, as key ranges: while listings are idle,
270
+ * a long range hands its second half (split at keyBetween) to a new one;
271
+ * - a vault policy can refuse a Prefix listing of one folder (private DM
272
+ * folders) while allowing the plain listing the sequential walk used, so a
273
+ * refused folder is listed as a key range of the plain listing instead.
274
+ * Ranges never overlap and every version of a key falls in exactly one, so the
275
+ * result is the same set as one flat listing. Any other failure fails the walk.
276
+ */
277
+ async function listAllVersions(store) {
278
+ const all = [];
279
+ const queue = [{ prefix: "", depth: 0, split: LIST_SPLIT_DEPTH > 0, usePrefix: true }];
280
+ let active = 0;
281
+ let failed;
282
+ const runOne = async (w) => {
283
+ const found = [];
284
+ const children = [];
285
+ let hi = w.hi;
286
+ let keyMarker = w.lo ?? (w.usePrefix || !w.prefix ? undefined : w.prefix.slice(0, -1));
287
+ let versionIdMarker;
288
+ let done = false;
289
+ try {
290
+ do {
291
+ const page = await store.s3.send(new ListObjectVersionsCommand({
292
+ Bucket: store.bucket,
293
+ KeyMarker: keyMarker,
294
+ VersionIdMarker: versionIdMarker,
295
+ ...(w.usePrefix && w.prefix ? { Prefix: w.prefix } : {}),
296
+ ...(w.split ? { Delimiter: "/" } : {}),
297
+ }));
298
+ for (const e of toEntries(page)) {
299
+ if (!e.key.startsWith(w.prefix)) {
300
+ // Only the plain listing returns keys outside the folder.
301
+ if (utf8Compare(e.key, w.prefix) > 0)
302
+ done = true;
303
+ continue;
304
+ }
305
+ if (hi !== undefined && utf8Compare(e.key, hi) > 0) {
306
+ done = true;
307
+ continue;
308
+ }
309
+ found.push(e);
310
+ }
311
+ if (w.split) {
312
+ for (const cp of page.CommonPrefixes ?? []) {
313
+ if (cp.Prefix && cp.Prefix !== w.prefix) {
314
+ const depth = w.depth + 1;
315
+ children.push({ prefix: cp.Prefix, depth, split: depth < LIST_SPLIT_DEPTH, usePrefix: w.usePrefix });
316
+ }
317
+ }
318
+ }
319
+ if (page.IsTruncated && !page.NextKeyMarker) {
320
+ throw new Error("File history listing was cut short (no next marker). Nothing was changed.");
321
+ }
322
+ keyMarker = page.IsTruncated && !done ? page.NextKeyMarker : undefined;
323
+ versionIdMarker = page.IsTruncated && !done ? page.NextVersionIdMarker : undefined;
324
+ // Hand the rest of a long range to an idle slot: this listing keeps
325
+ // keys up to `mid`, the new one starts after it.
326
+ if (keyMarker !== undefined && !w.split && active + queue.length < LIST_CONCURRENCY) {
327
+ const end = hi ?? `${w.prefix}\x7f`;
328
+ const mid = keyBetween(keyMarker, end);
329
+ if (mid !== undefined && mid.startsWith(w.prefix)) {
330
+ queue.push({ ...w, lo: mid, hi });
331
+ hi = mid;
332
+ pump();
333
+ }
334
+ }
335
+ } while (keyMarker !== undefined && failed === undefined);
184
336
  }
185
- if (page.IsTruncated && !page.NextKeyMarker) {
186
- throw new Error("File history listing was cut short (no next marker). Nothing was changed.");
337
+ catch (err) {
338
+ if (!w.usePrefix || !isAccessDenied(err))
339
+ throw err;
340
+ // Refused for this folder: list it again from the plain listing. Nothing
341
+ // from the refused attempt is kept, so nothing counts twice.
342
+ // Same key range as this attempt (a split-off rest is already queued).
343
+ queue.push({ ...w, split: false, usePrefix: false, hi });
344
+ return;
187
345
  }
188
- keyMarker = page.IsTruncated ? page.NextKeyMarker : undefined;
189
- versionIdMarker = page.IsTruncated ? page.NextVersionIdMarker : undefined;
190
- } while (keyMarker !== undefined);
346
+ for (const e of found)
347
+ all.push(e);
348
+ for (const c of children)
349
+ queue.push(c);
350
+ };
351
+ let finish;
352
+ const finished = new Promise((resolve) => (finish = resolve));
353
+ function pump() {
354
+ if (failed === undefined) {
355
+ while (active < LIST_CONCURRENCY && queue.length > 0) {
356
+ const next = queue.shift();
357
+ active += 1;
358
+ runOne(next)
359
+ .catch((err) => {
360
+ failed ??= err;
361
+ })
362
+ .finally(() => {
363
+ active -= 1;
364
+ pump();
365
+ });
366
+ }
367
+ }
368
+ if (active === 0 && (queue.length === 0 || failed !== undefined))
369
+ finish();
370
+ }
371
+ pump();
372
+ await finished;
373
+ if (failed !== undefined)
374
+ throw failed;
375
+ return all;
191
376
  }
192
377
  /** Every version and delete marker in the bucket, with `noncurrentSince` set. */
193
378
  async function listVersions(store) {
194
- const all = [];
195
- for await (const v of listAllVersions(store))
196
- all.push(v);
197
- return annotateNoncurrentSince(all);
379
+ return annotateNoncurrentSince(await listAllVersions(store));
198
380
  }
199
381
  function emptyTranches() {
200
382
  return TRANCHES.map((t) => ({ id: t.id, label: t.label, count: 0, bytes: 0 }));
@@ -264,7 +446,7 @@ export async function getCloudStatus(company, deps) {
264
446
  error: describeCloudError(err, "list"),
265
447
  };
266
448
  }
267
- return status;
449
+ return { ...status, listed_at: now.toISOString() };
268
450
  }
269
451
  /**
270
452
  * Versions eligible for prune: noncurrent content versions and noncurrent
@@ -345,29 +527,42 @@ export async function pruneCloud(company, cutoff, dryRun, deps) {
345
527
  // ── Orchestrators ───────────────────────────────────────────────────────────
346
528
  export async function runStorageStatus(opts, deps) {
347
529
  // Read-only: no backup drop, no gc. Pending backup bytes come from report.
348
- const local = await getLocalReport(deps);
349
- const offload = deps.getOffload ? await deps.getOffload().catch(offloadUnavailable) : undefined;
350
- let memberships;
351
- try {
352
- memberships = await deps.listCompanies();
353
- }
354
- catch (err) {
355
- // Local sections still report when cloud is unreachable (signed out, API key).
356
- return {
357
- local,
358
- cloud: [],
359
- cloud_error: err instanceof Error ? err.message : String(err),
360
- ...(offload ? { offload } : {}),
361
- generated_at: deps.now().toISOString(),
530
+ // The local report, the offload scan and the cloud listing are independent,
531
+ // so they run at the same time: status takes as long as the slowest one.
532
+ const localP = getLocalReport(deps);
533
+ const offloadP = deps.getOffload ? deps.getOffload().catch(offloadUnavailable) : Promise.resolve(undefined);
534
+ const cloudP = (async () => {
535
+ let memberships;
536
+ try {
537
+ memberships = await deps.listCompanies();
538
+ }
539
+ catch (err) {
540
+ // Local sections still report when cloud is unreachable (signed out, API key).
541
+ return { cloud: [], cloud_error: err instanceof Error ? err.message : String(err) };
542
+ }
543
+ const companies = selectCompanies(memberships, opts.company);
544
+ const cached = async (c) => {
545
+ const hit = opts.noCache ? undefined : deps.cloudCache?.read(c);
546
+ if (hit)
547
+ return hit;
548
+ const fresh = await getCloudStatus(c, deps);
549
+ deps.cloudCache?.write(c, fresh);
550
+ return fresh;
362
551
  };
363
- }
364
- const companies = selectCompanies(memberships, opts.company);
365
- const cloud = [];
366
- for (const c of companies)
367
- cloud.push(await getCloudStatus(c, deps));
552
+ return { cloud: await Promise.all(companies.map(cached)) };
553
+ })();
554
+ // Settle all three before returning, so no scan is left running behind a throw.
555
+ const [localR, offloadR, cloudR] = await Promise.allSettled([localP, offloadP, cloudP]);
556
+ for (const r of [localR, cloudR])
557
+ if (r.status === "rejected")
558
+ throw r.reason;
559
+ const local = localR.value;
560
+ const offload = offloadR.status === "fulfilled" ? offloadR.value : undefined;
561
+ const { cloud, cloud_error } = cloudR.value;
368
562
  return {
369
563
  local,
370
564
  cloud,
565
+ ...(cloud_error !== undefined ? { cloud_error } : {}),
371
566
  ...(offload ? { offload } : {}),
372
567
  generated_at: deps.now().toISOString(),
373
568
  };
@@ -422,8 +617,12 @@ export async function runStoragePrune(opts, deps) {
422
617
  }
423
618
  const cloud = [];
424
619
  if (cloudCutoff) {
425
- for (const c of companies)
620
+ for (const c of companies) {
621
+ // A real prune changes the figures: the next status lists live.
622
+ if (!opts.dryRun)
623
+ deps.cloudCache?.drop(c);
426
624
  cloud.push(await pruneCloud(c, cloudCutoff, opts.dryRun, deps));
625
+ }
427
626
  for (const c of skipped) {
428
627
  cloud.push({
429
628
  company: c.slug,
@@ -774,10 +973,43 @@ async function buildOffloadDeps() {
774
973
  },
775
974
  };
776
975
  }
976
+ /**
977
+ * AWS credential provider that vends fresh credentials on first use and again
978
+ * whenever the previous ones are within `marginMs` of expiring. Each vend calls
979
+ * `vend` anew, which signs in again (the CLI refreshes an expiring Cognito
980
+ * session there), so a cloud listing or delete that starts after a long local
981
+ * scan, or that itself outlasts one STS session, never sends an expired token.
982
+ */
983
+ export function refreshingVendedCredentials(vend, now = Date.now, marginMs = 5 * 60 * 1000) {
984
+ let cached;
985
+ let pending;
986
+ return async () => {
987
+ if (cached && cached.expiration && cached.expiration.getTime() - now() > marginMs)
988
+ return cached;
989
+ pending ??= vend()
990
+ .then((c) => {
991
+ const t = c.expiresAt ? Date.parse(c.expiresAt) : NaN;
992
+ cached = {
993
+ accessKeyId: c.accessKeyId,
994
+ secretAccessKey: c.secretAccessKey,
995
+ sessionToken: c.sessionToken,
996
+ // Unknown expiry: treat as already due so the next call vends again.
997
+ expiration: new Date(Number.isNaN(t) ? now() : t),
998
+ };
999
+ return cached;
1000
+ })
1001
+ .finally(() => {
1002
+ pending = undefined;
1003
+ });
1004
+ return pending;
1005
+ };
1006
+ }
777
1007
  async function buildDeps() {
778
- // Sign-in is lazy so `status` can still report local sections when signed out.
779
- let session;
780
- const signedIn = () => (session ??= signedInVault());
1008
+ // Sign-in is lazy so `status` can still report local sections when signed out,
1009
+ // and it is resolved again for every cloud call (never once at the start): the
1010
+ // local scan before the cloud listing can outlast a session token, and
1011
+ // resolveVaultCredential refreshes an expiring Cognito session.
1012
+ const signedIn = () => signedInVault();
781
1013
  const offloadDeps = await buildOffloadDeps();
782
1014
  return {
783
1015
  getOffload: () => getOffloadStatus(offloadDeps, {
@@ -786,6 +1018,7 @@ async function buildDeps() {
786
1018
  }),
787
1019
  hqRoot: findHqRoot(),
788
1020
  now: () => new Date(),
1021
+ cloudCache: cloudStatusCache(findHqRoot(), () => new Date()),
789
1022
  runScript: defaultRunScript,
790
1023
  confirm: (message) => confirmOnTty(message),
791
1024
  log: (line) => console.log(line),
@@ -799,10 +1032,16 @@ async function buildDeps() {
799
1032
  },
800
1033
  openCompany: async (company) => {
801
1034
  const { vault } = await signedIn();
802
- const vend = await vault.sts.vend({ companyUid: company.uid });
803
1035
  const entity = await vault.entity.get(company.uid);
804
1036
  const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
805
- const s3 = new S3Client({ region: DEFAULT_COGNITO.region, credentials: vend.credentials });
1037
+ // Credentials are vended right before the first S3 call and re-vended
1038
+ // (with a freshly refreshed sign-in) before they expire.
1039
+ const credentials = refreshingVendedCredentials(async () => {
1040
+ const { vault: fresh } = await signedIn();
1041
+ const vend = await fresh.sts.vend({ companyUid: company.uid });
1042
+ return { ...vend.credentials, expiresAt: vend.expiresAt };
1043
+ });
1044
+ const s3 = new S3Client({ region: DEFAULT_COGNITO.region, credentials });
806
1045
  return { bucket, s3 };
807
1046
  },
808
1047
  };
@@ -817,9 +1056,10 @@ export function registerStorageCommand(program) {
817
1056
  .description("Show local autosave history and cloud file history sizes")
818
1057
  .option("--json", "Print JSON")
819
1058
  .option("--company <slug>", "Only this company (must be one of yours)")
1059
+ .option("--no-cache", "List cloud file history live instead of using figures from the last 15 minutes")
820
1060
  .action(async (opts) => {
821
1061
  try {
822
- const status = await runStorageStatus({ company: opts.company }, await buildDeps());
1062
+ const status = await runStorageStatus({ company: opts.company, noCache: opts.cache === false }, await buildDeps());
823
1063
  console.log(opts.json ? JSON.stringify(status, null, 2) : formatStorageStatus(status));
824
1064
  }
825
1065
  catch (err) {
@@ -119,6 +119,16 @@ export function deriveRemediation(result) {
119
119
  command: remediation ?? `hq bot start ${target}`,
120
120
  };
121
121
  }
122
+ // Available hq-core upgrade. WARN is intentional in `hq doctor` and is
123
+ // not a broken sync; --fix has no mechanical repair for it.
124
+ if (checkId === "sync.update.core") {
125
+ return {
126
+ autoFixable: false,
127
+ fixClass: null,
128
+ action: "Ordinary hq-core upgrade, not a failed repair. hq doctor --fix will leave this WARN in place. Run /update-hq in a fresh session.",
129
+ command: "",
130
+ };
131
+ }
122
132
  // refresh-sync — a company journal that is stale or never-synced, or a
123
133
  // live personal / personal-vault journal. Leftover shards are NA at the
124
134
  // check layer, so they never reach this classifier. A SessionStart
@@ -104,7 +104,23 @@ export interface BuildDoctorJsonInput {
104
104
  clientHealthReasonCodesEnabledCheckIds?: ReadonlySet<string>;
105
105
  /** Check ids whose own company enabled per-check status reporting in hq-flags. */
106
106
  clientHealthCheckStatusEnabledCheckIds?: ReadonlySet<string>;
107
+ /**
108
+ * JSON consumed by the SessionStart health hook (`--client-health-report`).
109
+ * Ordinary hq-core upgrade WARNs stay in human `hq doctor` output and in
110
+ * default `--json`, but they are omitted here: `--fix` cannot apply a
111
+ * release, and older hooks treated the leftover WARN as an unrepaired
112
+ * failure.
113
+ */
114
+ clientHealthReport?: boolean;
107
115
  }
116
+ /**
117
+ * True when a sync finding is an ordinary, non-repairable hq-core upgrade.
118
+ * FAIL stays visible: that is a broken local core, not "a newer release exists".
119
+ */
120
+ export declare function isOrdinaryCoreUpdateWarn(result: {
121
+ checkId: string;
122
+ status: DoctorStatus;
123
+ }): boolean;
108
124
  /** Assemble the machine-readable document from a completed run. */
109
125
  export declare function buildDoctorJson(input: BuildDoctorJsonInput): DoctorJsonDocument;
110
126
  /** Serialise the document as pretty-printed JSON with a trailing newline. */
@@ -29,11 +29,21 @@ import { deriveRemediation } from "./fix/remediation.js";
29
29
  * was passed, so a consumer can see what `--fix` would do without invoking it.
30
30
  */
31
31
  export const DOCTOR_JSON_SCHEMA_VERSION = 2;
32
+ /**
33
+ * True when a sync finding is an ordinary, non-repairable hq-core upgrade.
34
+ * FAIL stays visible: that is a broken local core, not "a newer release exists".
35
+ */
36
+ export function isOrdinaryCoreUpdateWarn(result) {
37
+ return result.checkId === "sync.update.core" && result.status === "WARN";
38
+ }
32
39
  /** Assemble the machine-readable document from a completed run. */
33
40
  export function buildDoctorJson(input) {
34
41
  const results = [];
35
42
  for (const run of input.families) {
36
43
  for (const result of run.results) {
44
+ if (input.clientHealthReport === true && isOrdinaryCoreUpdateWarn(result)) {
45
+ continue;
46
+ }
37
47
  results.push(toJsonResult(run.family.id, result, input.clientHealthReasonCodesEnabledCheckIds, input.clientHealthCheckStatusEnabledCheckIds));
38
48
  }
39
49
  }
@@ -149,9 +149,18 @@ function isInstalledDrainEntry(entryPath) {
149
149
  * Both paths target the same hq-cli drain entry, so recognize an installed
150
150
  * package entry alongside the exact entry belonging to this CLI invocation.
151
151
  */
152
- function drainCommandsMatch(candidate, expected) {
153
- if (candidate === expected)
154
- return true;
152
+ function drainCommandsMatch(candidate, expected, requireInstalledPaths = false) {
153
+ if (candidate === expected) {
154
+ if (!requireInstalledPaths)
155
+ return true;
156
+ const withoutProviderEnv = candidate.startsWith("HQ_LANE_PROVIDER=grok ")
157
+ ? candidate.slice("HQ_LANE_PROVIDER=grok ".length)
158
+ : candidate;
159
+ const currentArgv = parseShellQuotedArguments(withoutProviderEnv);
160
+ return currentArgv !== null && currentArgv.length === 2 &&
161
+ path.parse(currentArgv[0]).name.toLowerCase() === "node" &&
162
+ fs.existsSync(currentArgv[0]) && fs.existsSync(currentArgv[1]);
163
+ }
155
164
  const withoutProviderEnv = candidate.startsWith("HQ_LANE_PROVIDER=grok ")
156
165
  ? candidate.slice("HQ_LANE_PROVIDER=grok ".length)
157
166
  : candidate;
@@ -162,9 +171,9 @@ function drainCommandsMatch(candidate, expected) {
162
171
  return false;
163
172
  if (!isInstalledDrainEntry(argv[1]))
164
173
  return false;
165
- return fs.existsSync(argv[0]) && fs.existsSync(argv[1]);
174
+ return !requireInstalledPaths || (fs.existsSync(argv[0]) && fs.existsSync(argv[1]));
166
175
  }
167
- function groupsContainDrainCommand(groups, expected) {
176
+ function groupsContainDrainCommand(groups, expected, requireInstalledPaths = false) {
168
177
  if (!Array.isArray(groups))
169
178
  return false;
170
179
  return groups.some((group) => {
@@ -173,29 +182,51 @@ function groupsContainDrainCommand(groups, expected) {
173
182
  return group.hooks.some((hook) => isPlainObject(hook) &&
174
183
  hook.type === "command" &&
175
184
  typeof hook.command === "string" &&
176
- drainCommandsMatch(hook.command, expected));
185
+ drainCommandsMatch(hook.command, expected, requireInstalledPaths));
177
186
  });
178
187
  }
179
- function tomlHasDrainCommand(parsed, expected) {
188
+ function tomlHasDrainCommand(parsed, expected, requireInstalledPaths = false) {
180
189
  const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : null;
181
190
  return hooks !== null &&
182
- ["PreToolUse", "PostToolUse", "Stop"].every((event) => groupsContainDrainCommand(hooks[event], expected));
191
+ ["PreToolUse", "PostToolUse", "Stop"].every((event) => groupsContainDrainCommand(hooks[event], expected, requireInstalledPaths));
183
192
  }
184
- function removeCommandFromGroups(groups, command) {
193
+ function removeDrainCommandsFromGroups(groups, expected, legacyCommand) {
185
194
  let changed = false;
195
+ let keptCurrent = false;
186
196
  const next = groups.flatMap((group) => {
187
197
  if (!isPlainObject(group) || !Array.isArray(group.hooks))
188
198
  return [group];
189
- const hooks = group.hooks.filter((hook) => !(isPlainObject(hook) &&
190
- hook.type === "command" &&
191
- hook.command === command));
199
+ const hooks = group.hooks.filter((hook) => {
200
+ if (!isPlainObject(hook) || hook.type !== "command" || typeof hook.command !== "string")
201
+ return true;
202
+ const isCurrent = hook.command === expected;
203
+ const isDrain = isCurrent || drainCommandsMatch(hook.command, expected) || hook.command === legacyCommand;
204
+ if (isCurrent && !keptCurrent) {
205
+ keptCurrent = true;
206
+ return true;
207
+ }
208
+ if (isDrain)
209
+ changed = true;
210
+ return !isDrain;
211
+ });
192
212
  if (hooks.length === group.hooks.length)
193
213
  return [group];
194
- changed = true;
195
214
  return hooks.length === 0 ? [] : [{ ...group, hooks }];
196
215
  });
197
216
  return { groups: next, changed };
198
217
  }
218
+ function groupHasExactlyOneCommand(groups, command) {
219
+ let count = 0;
220
+ for (const group of groups) {
221
+ if (!isPlainObject(group) || !Array.isArray(group.hooks))
222
+ continue;
223
+ for (const hook of group.hooks) {
224
+ if (isPlainObject(hook) && hook.type === "command" && hook.command === command)
225
+ count += 1;
226
+ }
227
+ }
228
+ return count === 1;
229
+ }
199
230
  export function renderDrainHookJson(shPath, provider = "grok") {
200
231
  const command = drainHookCommand(shPath, provider);
201
232
  const registration = {
@@ -237,21 +268,18 @@ export function mergeClaudeSettingsHook(existingText, command, settingsPath, leg
237
268
  }
238
269
  eventGroups.set(event, groups);
239
270
  }
240
- const pre = eventGroups.get("PreToolUse");
241
- if (!Array.isArray(pre)) {
242
- throw new LanesError("settings_unparseable", `${settingsPath} has a non-array "hooks.PreToolUse"; refusing to rewrite it`, { path: settingsPath });
243
- }
244
- const migration = legacyCommand && legacyCommand !== command
245
- ? removeCommandFromGroups(pre, legacyCommand)
246
- : { groups: pre, changed: false };
247
- const allRegistered = events.every((event) => groupsContainCommand(eventGroups.get(event), command));
248
- if (allRegistered && !migration.changed) {
271
+ const cleanups = new Map(events.map((event) => [
272
+ event,
273
+ removeDrainCommandsFromGroups(eventGroups.get(event), command, legacyCommand),
274
+ ]));
275
+ if (events.every((event) => groupHasExactlyOneCommand(cleanups.get(event).groups, command) &&
276
+ !cleanups.get(event).changed)) {
249
277
  return { text: existingText ?? "", changed: false };
250
278
  }
251
279
  const nextHooks = { ...hooks };
252
280
  for (const event of events) {
253
- const groups = event === "PreToolUse" ? migration.groups : eventGroups.get(event);
254
- nextHooks[event] = groupsContainCommand(groups, command) ? groups : [
281
+ const groups = cleanups.get(event).groups;
282
+ nextHooks[event] = groupHasExactlyOneCommand(groups, command) ? groups : [
255
283
  ...groups,
256
284
  { hooks: [{ type: "command", command, timeout: DRAIN_HOOK_TIMEOUT_SECONDS }] },
257
285
  ];
@@ -405,6 +433,145 @@ function parseTomlOrThrow(text, configPath, stage) {
405
433
  }
406
434
  return parsed;
407
435
  }
436
+ function renderTomlInline(value) {
437
+ if (typeof value === "string")
438
+ return tomlBasicString(value);
439
+ if (typeof value === "boolean" || typeof value === "number")
440
+ return String(value);
441
+ if (Array.isArray(value))
442
+ return `[${value.map(renderTomlInline).join(", ")}]`;
443
+ if (isPlainObject(value)) {
444
+ const fields = Object.entries(value).map(([key, entry]) => `${JSON.stringify(key)} = ${renderTomlInline(entry)}`);
445
+ return `{ ${fields.join(", ")} }`;
446
+ }
447
+ throw new LanesError("config_toml_migration_failed", "Could not safely render an inline Codex hook while removing stale lane drains");
448
+ }
449
+ function isDrainHookValue(hook, expected, legacyCommand) {
450
+ return isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string" &&
451
+ (drainCommandsMatch(hook.command, expected) || hook.command === legacyCommand);
452
+ }
453
+ function removeStaleCodexDrainHooks(text, parsed, expected, legacyCommand, configPath) {
454
+ const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : {};
455
+ const lines = text.split("\n");
456
+ const spans = [];
457
+ let changed = false;
458
+ for (const event of ["PreToolUse", "PostToolUse", "Stop"]) {
459
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
460
+ const parentIndexes = lines.flatMap((line, index) => {
461
+ const header = tomlHeader(line);
462
+ return header?.array && header.path === `hooks.${event}` ? [index] : [];
463
+ });
464
+ if (parentIndexes.length !== groups.length) {
465
+ throw new LanesError("config_toml_migration_failed", `Could not safely map ${event} drain hooks in ${configPath}; refusing to add a second drain`, { path: configPath, event, groups: groups.length, headers: parentIndexes.length });
466
+ }
467
+ let keptCurrent = false;
468
+ for (let groupIndex = 0; groupIndex < parentIndexes.length; groupIndex += 1) {
469
+ const start = parentIndexes[groupIndex];
470
+ let end = start + 1;
471
+ for (; end < lines.length; end += 1) {
472
+ const header = tomlHeader(lines[end]);
473
+ if (!header)
474
+ continue;
475
+ if (header.array && header.path === `hooks.${event}`)
476
+ break;
477
+ if (header.path.startsWith(`hooks.${event}.`))
478
+ continue;
479
+ break;
480
+ }
481
+ const hookHeaders = lines.flatMap((line, index) => {
482
+ if (index <= start || index >= end)
483
+ return [];
484
+ const header = tomlHeader(line);
485
+ return header?.array && header.path === `hooks.${event}.hooks` ? [index] : [];
486
+ });
487
+ const group = groups[groupIndex];
488
+ const groupHooks = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
489
+ if (hookHeaders.length !== groupHooks.length) {
490
+ const keptInlineHooks = [];
491
+ let inlineNeedsEdit = false;
492
+ for (const hook of groupHooks) {
493
+ const command = isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string"
494
+ ? hook.command
495
+ : undefined;
496
+ if (!command) {
497
+ keptInlineHooks.push(hook);
498
+ continue;
499
+ }
500
+ const current = command === expected;
501
+ const drain = isDrainHookValue(hook, expected, legacyCommand);
502
+ if (!drain || (current && !keptCurrent)) {
503
+ if (current)
504
+ keptCurrent = true;
505
+ keptInlineHooks.push(hook);
506
+ }
507
+ else {
508
+ inlineNeedsEdit = true;
509
+ }
510
+ }
511
+ if (!inlineNeedsEdit)
512
+ continue;
513
+ if (hookHeaders.length === 0) {
514
+ const hooksLine = lines.findIndex((line, index) => index > start && index < end && /^\s*hooks\s*=/.test(line));
515
+ if (hooksLine === -1) {
516
+ throw new LanesError("config_toml_migration_failed", `Could not safely find inline ${event} hooks in ${configPath}; refusing to add a second drain`, { path: configPath, event, groupIndex });
517
+ }
518
+ changed = true;
519
+ if (keptInlineHooks.length === 0) {
520
+ spans.push({ start, end });
521
+ }
522
+ else {
523
+ const indent = /^\s*/.exec(lines[hooksLine])?.[0] ?? "";
524
+ lines[hooksLine] = `${indent}hooks = ${renderTomlInline(keptInlineHooks)}`;
525
+ }
526
+ continue;
527
+ }
528
+ throw new LanesError("config_toml_migration_failed", `Could not safely map ${event} hook entries in ${configPath}; refusing to add a second drain`, { path: configPath, event, groupIndex, hooks: groupHooks.length, headers: hookHeaders.length });
529
+ }
530
+ const removeHooks = [];
531
+ for (let hookIndex = 0; hookIndex < hookHeaders.length; hookIndex += 1) {
532
+ const hookStart = hookHeaders[hookIndex];
533
+ let hookEnd = hookStart + 1;
534
+ for (; hookEnd < end; hookEnd += 1) {
535
+ const header = tomlHeader(lines[hookEnd]);
536
+ if (header?.array && header.path === `hooks.${event}.hooks`)
537
+ break;
538
+ if (header && !header.path.startsWith(`hooks.${event}.hooks.`))
539
+ break;
540
+ }
541
+ const hook = groupHooks[hookIndex];
542
+ const command = isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string"
543
+ ? hook.command
544
+ : undefined;
545
+ if (!command)
546
+ continue;
547
+ const current = command === expected;
548
+ const drain = drainCommandsMatch(command, expected) || command === legacyCommand;
549
+ if (!drain || (current && !keptCurrent)) {
550
+ if (current)
551
+ keptCurrent = true;
552
+ continue;
553
+ }
554
+ removeHooks.push({ start: hookStart, end: hookEnd });
555
+ }
556
+ if (removeHooks.length === 0)
557
+ continue;
558
+ changed = true;
559
+ if (removeHooks.length === hookHeaders.length) {
560
+ spans.push({ start, end });
561
+ }
562
+ else {
563
+ spans.push(...removeHooks);
564
+ }
565
+ }
566
+ }
567
+ if (!changed)
568
+ return { text, changed: false };
569
+ for (const span of spans.sort((a, b) => b.start - a.start)) {
570
+ lines.splice(span.start, span.end - span.start);
571
+ }
572
+ const cleaned = lines.join("\n");
573
+ return { text: cleaned, changed: true };
574
+ }
408
575
  /**
409
576
  * Set `hooks = true` inside `[features]` by editing lines, so the operator's
410
577
  * comments and layout survive. A `[features]` header that exists gets the
@@ -442,12 +609,21 @@ export function mergeCodexConfigHook(existingText, command, configPath, legacyCo
442
609
  const original = existingText ?? "";
443
610
  const parsed = parseTomlOrThrow(original, configPath, "before merge");
444
611
  const migration = removeLegacyCodexHook(original, parsed, legacyCommand, configPath);
445
- const textAfterMigration = migration.text;
612
+ const parsedAfterMigration = parseTomlOrThrow(migration.text, configPath, "after legacy hook removal");
613
+ const drainCleanup = removeStaleCodexDrainHooks(migration.text, parsedAfterMigration, command, legacyCommand, configPath);
614
+ const changedByCleanup = migration.changed || drainCleanup.changed;
615
+ const textAfterMigration = drainCleanup.text;
446
616
  const current = parseTomlOrThrow(textAfterMigration, configPath, "after legacy hook removal");
447
617
  const hasPreToolCommand = tomlHasCommand(current, command);
448
618
  const hasCommand = tomlHasDrainCommand(current, command);
449
619
  const featureOn = tomlHooksFeatureEnabled(current);
450
- if (hasCommand && featureOn && !migration.changed) {
620
+ if (hasCommand && featureOn && !changedByCleanup &&
621
+ ["PreToolUse", "PostToolUse", "Stop"].every((event) => {
622
+ const groups = isPlainObject(current.hooks) && Array.isArray(current.hooks[event])
623
+ ? current.hooks[event]
624
+ : [];
625
+ return groupHasExactlyOneCommand(groups, command);
626
+ })) {
451
627
  return { text: original, changed: false };
452
628
  }
453
629
  let text = textAfterMigration;
@@ -509,7 +685,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
509
685
  const toml = parseToml(text);
510
686
  return {
511
687
  ...base,
512
- registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command) && tomlHooksFeatureEnabled(toml),
688
+ registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command, true) && tomlHooksFeatureEnabled(toml),
513
689
  };
514
690
  }
515
691
  const json = JSON.parse(text);
@@ -517,7 +693,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
517
693
  return {
518
694
  ...base,
519
695
  registered: hooks !== null &&
520
- (parsed === "grok" ? ["PreToolUse"] : ["PreToolUse", "PostToolUse", "Stop"]).every((event) => groupsContainDrainCommand(hooks[event], command)),
696
+ (parsed === "grok" ? ["PreToolUse"] : ["PreToolUse", "PostToolUse", "Stop"]).every((event) => groupsContainDrainCommand(hooks[event], command, true)),
521
697
  };
522
698
  }
523
699
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.81",
3
+ "version": "5.345.83",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {