@indigoai-us/hq-cli 5.302.0 → 5.304.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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.304.0] — 2026-10-01
6
+ - `hq lanes show` skips owner refresh only when a failed Outpost dispatch explicitly recorded `pickup_persisted: false` and no owner heartbeat; it reports `remote_refresh: "skipped_undispatched"` in that case.
7
+
8
+ ## [5.303.0] — 2026-10-01
9
+ - Fixed bundled shell scripts failing on Windows when Git Bash is installed but `bash` is not on `PATH`.
10
+ - The bundled token usage script now reads Claude sessions from `CLAUDE_CONFIG_DIR/projects` and the standard Claude project directories, including nested session folders (subagent transcripts are skipped), when no project directory is given.
11
+
5
12
  ## [5.302.0] — 2026-10-01
6
13
 
7
14
  - Generate company project indexes while preserving hand-written content unless `--accept` is used.
@@ -12,7 +12,8 @@
12
12
 
13
13
  set -euo pipefail
14
14
 
15
- PROJECT_DIR="${CLAUDE_PROJECTS_DIR:-$HOME/.claude/projects/-Users-{your-name}-Documents-HQ}"
15
+ PROJECT_DIR=""
16
+ PROJECT_DIR_SET=0
16
17
  LAST_DAYS=7
17
18
  SINCE=""
18
19
  JSON_MODE=0
@@ -31,6 +32,7 @@ while [ $# -gt 0 ]; do
31
32
  exit 1
32
33
  fi
33
34
  PROJECT_DIR="$2"
35
+ PROJECT_DIR_SET=1
34
36
  shift 2
35
37
  ;;
36
38
  --compare-windows) shift ;; # flag, presence triggers compare mode via --before/--after
@@ -47,14 +49,47 @@ done
47
49
 
48
50
  command -v node >/dev/null 2>&1 || { echo "token-usage-report: node is required" >&2; exit 1; }
49
51
 
52
+ # Resolve roots in the same precedence order as the native command. Explicit
53
+ # project directories and environment overrides select one exact activity root;
54
+ # otherwise include standard, desktop-saved, and discovered Claude roots.
55
+ PROJECT_DIRS=("")
56
+ add_project_dir() {
57
+ local candidate="$1" existing
58
+ [ -d "$candidate" ] || return 0
59
+ for existing in "${PROJECT_DIRS[@]}"; do [ "$existing" = "$candidate" ] && return 0; done
60
+ PROJECT_DIRS+=("$candidate")
61
+ }
62
+ trim_env_path() {
63
+ local value="$1"
64
+ value="${value#"${value%%[![:space:]]*}"}"
65
+ value="${value%"${value##*[![:space:]]}"}"
66
+ printf '%s' "$value"
67
+ }
68
+ if [ "$PROJECT_DIR_SET" -eq 1 ]; then
69
+ if [ ! -d "$PROJECT_DIR" ]; then echo "Project dir not found: $PROJECT_DIR" >&2; exit 1; fi
70
+ add_project_dir "$PROJECT_DIR"
71
+ elif [ -n "$(trim_env_path "${CLAUDE_PROJECTS_DIR:-}")" ]; then
72
+ add_project_dir "$(trim_env_path "${CLAUDE_PROJECTS_DIR:-}")"
73
+ elif [ -n "$(trim_env_path "${CLAUDE_CONFIG_DIR:-}")" ]; then
74
+ add_project_dir "$(trim_env_path "${CLAUDE_CONFIG_DIR:-}")/projects"
75
+ else
76
+ add_project_dir "$HOME/.claude/projects"
77
+ SAVED_PROJECT_DIR="$(node -e 'try { const value = require(process.argv[1]).claudeProjectsDir; if (typeof value === "string" && value.trim()) process.stdout.write(value.trim()); } catch {}' "$HOME/.hq/menubar.json")"
78
+ [ -z "$SAVED_PROJECT_DIR" ] || add_project_dir "$SAVED_PROJECT_DIR"
79
+ for candidate in "$HOME"/.claude-*; do
80
+ [ -d "$candidate/projects" ] && add_project_dir "$candidate/projects"
81
+ done
82
+ fi
83
+ if [ "${#PROJECT_DIRS[@]}" -le 1 ]; then echo "Claude activity folders not found" >&2; exit 1; fi
84
+
50
85
  # --- Compare-windows mode (US-011) ---
51
86
  # Format: --before YYYY-MM-DD:YYYY-MM-DD --after YYYY-MM-DD:YYYY-MM-DD
52
87
  if [ -n "$COMPARE_BEFORE" ] && [ -n "$COMPARE_AFTER" ]; then
53
- node - "$PROJECT_DIR" "$COMPARE_BEFORE" "$COMPARE_AFTER" "$MIN_HOURS" "$JSON_MODE" <<'JS'
88
+ node - "$COMPARE_BEFORE" "$COMPARE_AFTER" "$MIN_HOURS" "$JSON_MODE" "${PROJECT_DIRS[@]:1}" <<'JS'
54
89
  const fs = require("fs");
55
90
  const path = require("path");
56
91
 
57
- const [proj, before, after, minHoursS, jsonModeS] = process.argv.slice(2);
92
+ const [before, after, minHoursS, jsonModeS, ...projectDirs] = process.argv.slice(2);
58
93
  const minHours = parseFloat(minHoursS);
59
94
  const jsonMode = parseInt(jsonModeS, 10) === 1;
60
95
 
@@ -63,13 +98,24 @@ const [bStart, bEnd] = parseRange(before);
63
98
  const [aStart, aEnd] = parseRange(after);
64
99
 
65
100
  const fmt = (n) => n.toLocaleString("en-US");
66
- const listJsonl = () => {
67
- try { return fs.readdirSync(proj).filter((f) => f.endsWith(".jsonl")).sort(); } catch (e) { return []; }
101
+ const listJsonl = (projectDir) => {
102
+ const found = [];
103
+ const visit = (current, relative) => {
104
+ let entries;
105
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { return; }
106
+ for (const entry of entries) {
107
+ const nextRelative = path.join(relative, entry.name);
108
+ if (entry.isDirectory() && entry.name !== "subagents") visit(path.join(current, entry.name), nextRelative);
109
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) found.push(nextRelative);
110
+ }
111
+ };
112
+ visit(projectDir, "");
113
+ return found.sort();
68
114
  };
69
115
 
70
116
  function collect(windowStart, windowEnd) {
71
117
  const rows = [];
72
- for (const name of listJsonl()) {
118
+ for (const proj of projectDirs) for (const name of listJsonl(proj)) {
73
119
  const f = path.join(proj, name);
74
120
  let cr = 0, firstTs = null, lastTs = null;
75
121
  const sid = name.replace(/\.jsonl$/, "");
@@ -130,16 +176,11 @@ JS
130
176
  exit 0
131
177
  fi
132
178
 
133
- if [ ! -d "$PROJECT_DIR" ]; then
134
- echo "Project dir not found: $PROJECT_DIR" >&2
135
- exit 1
136
- fi
137
-
138
- node - "$PROJECT_DIR" "$LAST_DAYS" "$SINCE" "$JSON_MODE" <<'JS'
179
+ node - "$LAST_DAYS" "$SINCE" "$JSON_MODE" "${PROJECT_DIRS[@]:1}" <<'JS'
139
180
  const fs = require("fs");
140
181
  const path = require("path");
141
182
 
142
- const [projectDir, lastDaysS, sinceS, jsonModeS] = process.argv.slice(2);
183
+ const [lastDaysS, sinceS, jsonModeS, ...projectDirs] = process.argv.slice(2);
143
184
  const lastDays = parseInt(lastDaysS, 10);
144
185
  const jsonMode = parseInt(jsonModeS, 10) === 1;
145
186
 
@@ -156,11 +197,20 @@ const fmt = (n) => n.toLocaleString("en-US");
156
197
  const dayTotals = {};
157
198
  const sessionTotals = {};
158
199
 
159
- let names;
160
- try { names = fs.readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).sort(); } catch (e) { names = []; }
161
-
162
- // Parent sessions only (top-level *.jsonl)
163
- for (const name of names) {
200
+ // Parent sessions only; listJsonl skips each session's subagents/ tree.
201
+ for (const projectDir of projectDirs) {
202
+ const listJsonl = (current, relative = "") => {
203
+ let entries;
204
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { return []; }
205
+ return entries.flatMap((entry) => {
206
+ const nextRelative = path.join(relative, entry.name);
207
+ if (entry.isDirectory() && entry.name !== "subagents") return listJsonl(path.join(current, entry.name), nextRelative);
208
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) return [nextRelative];
209
+ return [];
210
+ }).sort();
211
+ };
212
+ const names = listJsonl(projectDir);
213
+ for (const name of names) {
164
214
  const f = path.join(projectDir, name);
165
215
  let mtime;
166
216
  try { mtime = fs.statSync(f).mtime; } catch (e) { continue; }
@@ -170,6 +220,7 @@ for (const name of names) {
170
220
  let firstTs = null, lastTs = null, firstUser = "";
171
221
  let subCount = 0;
172
222
  const sid = name.replace(/\.jsonl$/, "");
223
+ const sessionId = path.basename(path.dirname(projectDir)) + "/" + sid;
173
224
  let content;
174
225
  try { content = fs.readFileSync(f, "utf8"); } catch (e) { continue; }
175
226
  for (const line of content.split(/\r?\n/)) {
@@ -196,15 +247,16 @@ for (const name of names) {
196
247
  if (!day) day = mt;
197
248
  if (day < cutoff) continue;
198
249
  if (!dayTotals[day]) dayTotals[day] = { sessions: new Set(), inp: 0, out: 0, cc: 0, cr: 0 };
199
- dayTotals[day].sessions.add(sid);
250
+ dayTotals[day].sessions.add(sessionId);
200
251
  dayTotals[day].inp += inp;
201
252
  dayTotals[day].out += out;
202
253
  dayTotals[day].cc += cc;
203
254
  dayTotals[day].cr += cr;
204
- sessionTotals[sid] = {
255
+ sessionTotals[sessionId] = {
205
256
  day: day, inp: inp, out: out, cc: cc, cr: cr,
206
257
  eff: Math.trunc(eff), subagents: subCount, first_user: firstUser.split("\n").join(" "),
207
258
  };
259
+ }
208
260
  }
209
261
 
210
262
  const sortedDays = Object.keys(dayTotals).sort();
@@ -132,6 +132,8 @@ export declare function parseMaxRuntimeSeconds(raw: unknown, workerId: string):
132
132
  export declare const PICKUP_TERMINAL_REASONS: readonly ["account_not_logged_in", "unknown_account", "account_disabled", "model_unsupported"];
133
133
  export declare const DISPATCH_TERMINAL_REASON: "outpost_dispatch_failed";
134
134
  export type PickupTerminalReason = (typeof PICKUP_TERMINAL_REASONS)[number];
135
+ /** Only explicit non-persistence evidence can prove a failed dispatch has no owner snapshot. */
136
+ export declare function isUndispatchedOutpostMirror(lane: LaneRecord): boolean;
135
137
  /**
136
138
  * The CAS updater a pickup failure applies. A lane that is already terminal
137
139
  * for any reason (a cancel that landed after the caller's last read, an
@@ -1284,9 +1284,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds, opt
1284
1284
  ((parentLaneIds === undefined || (lane.senior.kind === "lane" &&
1285
1285
  lane.senior.id !== undefined &&
1286
1286
  parentLaneIds.has(lane.senior.id))) || options.laneIds?.has(lane.lane_id) === true) &&
1287
- !(isTerminalLaneState(lane.state) &&
1288
- lane.terminal_reason === DISPATCH_TERMINAL_REASON &&
1289
- lane.last_host_report_at === undefined) &&
1287
+ !isUndispatchedOutpostMirror(lane) &&
1290
1288
  (options.includeTerminalMirrors === true || !isTerminalLaneState(lane.state)))
1291
1289
  .sort((a, b) => {
1292
1290
  const terminalOrder = Number(isTerminalLaneState(a.state)) - Number(isTerminalLaneState(b.state));
@@ -2524,6 +2522,14 @@ export const PICKUP_TERMINAL_REASONS = [
2524
2522
  "model_unsupported",
2525
2523
  ];
2526
2524
  export const DISPATCH_TERMINAL_REASON = "outpost_dispatch_failed";
2525
+ /** Only explicit non-persistence evidence can prove a failed dispatch has no owner snapshot. */
2526
+ export function isUndispatchedOutpostMirror(lane) {
2527
+ return lane.host.kind === "outpost" &&
2528
+ isTerminalLaneState(lane.state) &&
2529
+ lane.terminal_reason === DISPATCH_TERMINAL_REASON &&
2530
+ lane.terminal_details?.pickup_persisted === false &&
2531
+ lane.last_host_report_at == null;
2532
+ }
2527
2533
  function parsePickupTerminalReason(value) {
2528
2534
  if (PICKUP_TERMINAL_REASONS.includes(value)) {
2529
2535
  return value;
@@ -3647,6 +3653,9 @@ async function dispatchCreatedOutpostLane(ctx, input) {
3647
3653
  command_id: dispatched.commandId,
3648
3654
  exit_code: dispatched.exitCode,
3649
3655
  status: dispatched.status,
3656
+ ...(remote?.pickup_persisted === false || ownerError.details.pickup_persisted === false
3657
+ ? { pickup_persisted: false }
3658
+ : {}),
3650
3659
  owner_error: ownerError,
3651
3660
  });
3652
3661
  await emitLaneTerminalStateSoft(ctx, failed);
@@ -5038,13 +5047,17 @@ async function runOutpostIdentityBackfillTarget(ctx, opts) {
5038
5047
  }
5039
5048
  async function refreshOutpostLaneCopiesForShow(ctx, lane, localLanes) {
5040
5049
  if (lane.host.kind !== "outpost" || laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
5041
- return lane;
5050
+ return { lane };
5051
+ }
5052
+ if (isUndispatchedOutpostMirror(lane)) {
5053
+ return { lane, remoteRefresh: "skipped_undispatched" };
5042
5054
  }
5043
5055
  const outpostId = lane.host.id;
5044
5056
  const descendantIds = localLanes
5045
5057
  .filter((candidate) => candidate.lane_id !== lane.lane_id &&
5046
5058
  candidate.host.kind === "outpost" && candidate.host.id === outpostId &&
5047
5059
  walkHitsLane(ctx.hqRoot(), candidate.senior, lane.lane_id, ctx))
5060
+ .filter((candidate) => !isUndispatchedOutpostMirror(candidate))
5048
5061
  .map((candidate) => candidate.lane_id);
5049
5062
  const refresh = async (laneId) => {
5050
5063
  try {
@@ -5061,13 +5074,14 @@ async function refreshOutpostLaneCopiesForShow(ctx, lane, localLanes) {
5061
5074
  };
5062
5075
  const refreshed = await mapWithConcurrency([lane.lane_id, ...descendantIds], OUTPOST_HIRE_SUMMARY_CONCURRENCY, refresh);
5063
5076
  const refreshedLane = refreshed[0] ?? null;
5064
- return refreshedLane ?? lane;
5077
+ return { lane: refreshedLane ?? lane };
5065
5078
  }
5066
5079
  async function runShow(ctx, laneId, hasPendingQuestionForLane) {
5067
5080
  const hqRoot = ctx.hqRoot();
5068
5081
  const initialLane = readLane(hqRoot, laneId);
5069
5082
  const initialLanes = loadAllLanes(hqRoot);
5070
- const storedLane = await refreshOutpostLaneCopiesForShow(ctx, initialLane, initialLanes);
5083
+ const showRefresh = await refreshOutpostLaneCopiesForShow(ctx, initialLane, initialLanes);
5084
+ const storedLane = showRefresh.lane;
5071
5085
  const allLanes = loadAllLanes(hqRoot);
5072
5086
  const hire = await laneHirePresentation(ctx, storedLane, allLanes);
5073
5087
  const remoteQueued = storedLane.state === "queued" &&
@@ -5152,6 +5166,7 @@ async function runShow(ctx, laneId, hasPendingQuestionForLane) {
5152
5166
  stalled_reason: lane.stalled_reason ?? null,
5153
5167
  ask_delivery_failed: askDeliveryFailed,
5154
5168
  delivery,
5169
+ ...(showRefresh.remoteRefresh === undefined ? {} : { remote_refresh: showRefresh.remoteRefresh }),
5155
5170
  provider_session_id: lane.provider_session_id ?? null,
5156
5171
  provider_session_id_absent: lane.provider_session_id_absent ?? null,
5157
5172
  envelope_repair_mode: lane.envelope_repair?.mode ?? null,
@@ -35,16 +35,8 @@ import type { BotLogger } from "./log.js";
35
35
  export declare const CLI_UPDATE_RESTART_EXIT_CODE = 75;
36
36
  /** How often the installed package.json is read. */
37
37
  export declare const CLI_UPDATE_CHECK_INTERVAL_MS = 60000;
38
- /** Consecutive checks that must agree on the new version before acting. */
39
- export declare const CLI_UPDATE_CONFIRMATIONS = 2;
40
- /** At most this many update restarts per window, whatever the versions. */
41
- export declare const CLI_UPDATE_RESTART_LIMIT = 4;
42
- export declare const CLI_UPDATE_RESTART_WINDOW_MS: number;
43
- /** A restart that came back on the old version blocks that target this long. */
44
- export declare const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS: number;
45
38
  /** Set to "1" to turn the watcher off (development runs). */
46
39
  export declare const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
47
- export declare const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
48
40
  export type InstalledCliRead = {
49
41
  ok: true;
50
42
  version: string;
@@ -42,15 +42,15 @@ export const CLI_UPDATE_RESTART_EXIT_CODE = 75;
42
42
  /** How often the installed package.json is read. */
43
43
  export const CLI_UPDATE_CHECK_INTERVAL_MS = 60_000;
44
44
  /** Consecutive checks that must agree on the new version before acting. */
45
- export const CLI_UPDATE_CONFIRMATIONS = 2;
45
+ const CLI_UPDATE_CONFIRMATIONS = 2;
46
46
  /** At most this many update restarts per window, whatever the versions. */
47
- export const CLI_UPDATE_RESTART_LIMIT = 4;
48
- export const CLI_UPDATE_RESTART_WINDOW_MS = 60 * 60_000;
47
+ const CLI_UPDATE_RESTART_LIMIT = 4;
48
+ const CLI_UPDATE_RESTART_WINDOW_MS = 60 * 60_000;
49
49
  /** A restart that came back on the old version blocks that target this long. */
50
- export const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS = 6 * 60 * 60_000;
50
+ const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS = 6 * 60 * 60_000;
51
51
  /** Set to "1" to turn the watcher off (development runs). */
52
52
  export const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
53
- export const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
53
+ const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
54
54
  /**
55
55
  * The hq-cli version installed behind `entry`, resolved the way the next launch
56
56
  * of the same command line will resolve it: follow the symlinks now (a pnpm or
@@ -18,9 +18,7 @@
18
18
  * - A fire is recorded before it is handed over, so a crash never runs the
19
19
  * same due time twice.
20
20
  */
21
- import { type BotApi, type BotJobRecord, type InboxItem, type JobRunCompletion } from "./api.js";
22
- export declare const LOCAL_JOBS_FILE = "jobs.json";
23
- export declare const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
21
+ import { type BotApi, type InboxItem } from "./api.js";
24
22
  export interface LocalJob {
25
23
  jobId: string;
26
24
  prompt: string;
@@ -40,9 +38,7 @@ export interface LocalJob {
40
38
  lastStatus?: string;
41
39
  runCount?: number;
42
40
  }
43
- export declare function localJobsPath(dir: string): string;
44
41
  export declare function readLocalJobs(dir: string): LocalJob[];
45
- export declare function writeLocalJobs(dir: string, jobs: LocalJob[]): void;
46
42
  /**
47
43
  * Run a read-change-write of `jobs.json` under an exclusive lock. The running
48
44
  * bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
@@ -67,40 +63,24 @@ export interface CronMatcher {
67
63
  export declare function toStandardCron(expr: string): string | null;
68
64
  /** Parse a standard 5-field cron, or null when it is not one. */
69
65
  export declare function parseCron(expr: string): CronMatcher | null;
70
- export declare function cronMatches(cron: CronMatcher, date: Date, timeZone: string): boolean;
71
- /**
72
- * The newest time this job was due, at or before `now` and after the last one
73
- * already fired. Null when nothing new is due.
74
- */
75
- /** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
76
- export declare function rateIntervalMs(expr: string): number | null;
77
- export declare function isRunnableSchedule(expr: string): boolean;
78
66
  export declare function latestDueFire(job: LocalJob, now: Date): Date | null;
79
67
  /** The next time this job is due after `now`, within a week; null otherwise. */
80
68
  export declare function nextDueFire(job: LocalJob, now: Date): Date | null;
81
- export declare function toJobRecord(job: LocalJob, now?: Date): BotJobRecord;
82
69
  export declare function addLocalJob(dir: string, input: {
83
70
  prompt: string;
84
71
  cron: string;
85
72
  timezone: string;
86
73
  context?: Record<string, unknown>;
87
74
  }, now?: Date): LocalJob;
88
- /** Remove a job. Returns false when the bot has no job with that id. */
89
- export declare function removeLocalJob(dir: string, jobId: string): boolean;
90
75
  /** Forget the once-per-minute check (tests, and a bot restarted in-process). */
91
76
  export declare function resetLocalJobChecks(): void;
92
77
  export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[];
93
- export declare function isLocalJobMessage(messageId: string): boolean;
94
- /** Record how a local run finished and release the job for its next fire. */
95
- export declare function finishLocalJobRun(dir: string, jobId: string, messageId: string, completion: Pick<JobRunCompletion, "status">, now?: Date): void;
96
78
  /**
97
79
  * The bot's cloud client, with its scheduled runs answered on this computer:
98
80
  * due local jobs join the inbox, and their ack and completion are recorded
99
81
  * locally instead of being sent to the cloud.
100
82
  */
101
83
  export declare function withLocalJobs(api: BotApi, dir: string, clock?: () => Date): BotApi;
102
- /** The most jobs one bot may hold (the same cap the cloud uses). */
103
- export declare const MAX_LOCAL_JOBS = 25;
104
84
  /**
105
85
  * `hq bot jobs` against the bot's own folder: the same three calls the cloud
106
86
  * client offers, answered from `jobs.json`.
@@ -22,15 +22,15 @@ import * as fs from "node:fs";
22
22
  import * as path from "node:path";
23
23
  import { randomUUID } from "node:crypto";
24
24
  import { BotApiError } from "./api.js";
25
- export const LOCAL_JOBS_FILE = "jobs.json";
26
- export const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
25
+ const LOCAL_JOBS_FILE = "jobs.json";
26
+ const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
27
27
  /** How far back a missed fire is still looked for (one week). */
28
28
  const LOOKBACK_MINUTES = 7 * 24 * 60;
29
29
  /** A run that never reported back releases its job after this long. */
30
30
  const IN_FLIGHT_RELEASE_MS = 60 * 60 * 1000;
31
31
  /** A fire is "late" (a catch-up) once this long past its due time. */
32
32
  const STALE_AFTER_MS = 10 * 60 * 1000;
33
- export function localJobsPath(dir) {
33
+ function localJobsPath(dir) {
34
34
  return path.join(dir, LOCAL_JOBS_FILE);
35
35
  }
36
36
  export function readLocalJobs(dir) {
@@ -42,7 +42,7 @@ export function readLocalJobs(dir) {
42
42
  return [];
43
43
  }
44
44
  }
45
- export function writeLocalJobs(dir, jobs) {
45
+ function writeLocalJobs(dir, jobs) {
46
46
  const file = localJobsPath(dir);
47
47
  const tmp = `${file}.${process.pid}.tmp`;
48
48
  fs.writeFileSync(tmp, JSON.stringify({ v: 1, jobs }, null, 2));
@@ -210,7 +210,7 @@ function zonedParts(date, timeZone) {
210
210
  weekday: WEEKDAY[get("weekday")] ?? -1,
211
211
  };
212
212
  }
213
- export function cronMatches(cron, date, timeZone) {
213
+ function cronMatches(cron, date, timeZone) {
214
214
  const t = zonedParts(date, timeZone);
215
215
  if (!cron.minutes.has(t.minute) || !cron.hours.has(t.hour) || !cron.months.has(t.month))
216
216
  return false;
@@ -226,7 +226,7 @@ export function cronMatches(cron, date, timeZone) {
226
226
  * already fired. Null when nothing new is due.
227
227
  */
228
228
  /** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
229
- export function rateIntervalMs(expr) {
229
+ function rateIntervalMs(expr) {
230
230
  const match = /^rate\((\d+)\s+(minute|minutes|hour|hours|day|days)\)$/.exec(expr.trim());
231
231
  if (!match)
232
232
  return null;
@@ -234,7 +234,7 @@ export function rateIntervalMs(expr) {
234
234
  const unit = match[2].startsWith("minute") ? 60_000 : match[2].startsWith("hour") ? 3_600_000 : 86_400_000;
235
235
  return n > 0 ? n * unit : null;
236
236
  }
237
- export function isRunnableSchedule(expr) {
237
+ function isRunnableSchedule(expr) {
238
238
  return rateIntervalMs(expr) !== null || parseCron(expr) !== null;
239
239
  }
240
240
  export function latestDueFire(job, now) {
@@ -281,7 +281,7 @@ export function nextDueFire(job, now) {
281
281
  return null;
282
282
  }
283
283
  // ─── The job list (what `hq bot jobs` reads and writes) ──────────────────────
284
- export function toJobRecord(job, now = new Date()) {
284
+ function toJobRecord(job, now = new Date()) {
285
285
  const next = nextDueFire(job, now);
286
286
  return {
287
287
  jobId: job.jobId,
@@ -311,7 +311,7 @@ export function addLocalJob(dir, input, now = new Date()) {
311
311
  return job;
312
312
  }
313
313
  /** Remove a job. Returns false when the bot has no job with that id. */
314
- export function removeLocalJob(dir, jobId) {
314
+ function removeLocalJob(dir, jobId) {
315
315
  return withJobsLock(dir, () => {
316
316
  const jobs = readLocalJobs(dir);
317
317
  const next = jobs.filter((job) => job.jobId !== jobId);
@@ -375,11 +375,11 @@ export function takeDueLocalJobRuns(dir, now = new Date()) {
375
375
  return items;
376
376
  });
377
377
  }
378
- export function isLocalJobMessage(messageId) {
378
+ function isLocalJobMessage(messageId) {
379
379
  return messageId.startsWith(LOCAL_JOB_MESSAGE_PREFIX);
380
380
  }
381
381
  /** Record how a local run finished and release the job for its next fire. */
382
- export function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
382
+ function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
383
383
  withJobsLock(dir, () => {
384
384
  const jobs = readLocalJobs(dir);
385
385
  let changed = false;
@@ -428,7 +428,7 @@ export function withLocalJobs(api, dir, clock = () => new Date()) {
428
428
  return local;
429
429
  }
430
430
  /** The most jobs one bot may hold (the same cap the cloud uses). */
431
- export const MAX_LOCAL_JOBS = 25;
431
+ const MAX_LOCAL_JOBS = 25;
432
432
  /**
433
433
  * `hq bot jobs` against the bot's own folder: the same three calls the cloud
434
434
  * client offers, answered from `jobs.json`.
@@ -23,7 +23,6 @@ export interface DaemonConfig {
23
23
  }
24
24
  export type SyncOwner = "daemon" | "desktop-app";
25
25
  export declare function effectiveSyncOwner(config: Pick<DaemonConfig, "syncOwner">, machineType: MachineType): SyncOwner;
26
- export declare function emptyDaemonConfig(): DaemonConfig;
27
26
  export declare function readDaemonConfig(file: string): DaemonConfig;
28
27
  export declare function writeDaemonConfig(file: string, config: DaemonConfig): void;
29
28
  /**
@@ -13,7 +13,7 @@ import { ensurePrivateDir } from "./paths.js";
13
13
  export function effectiveSyncOwner(config, machineType) {
14
14
  return config.syncOwner ?? (machineType === "desktop" ? "desktop-app" : "daemon");
15
15
  }
16
- export function emptyDaemonConfig() {
16
+ function emptyDaemonConfig() {
17
17
  return { version: 1, syncPaused: false, enable: [], disable: [] };
18
18
  }
19
19
  function stringList(value) {
@@ -55,7 +55,7 @@ export declare const DEFAULT_PER_CASE_TIMEOUT_MS = 10000;
55
55
  */
56
56
  export declare const SANDBOX_COPY_RELPATHS: readonly string[];
57
57
  /** One gate execution's inputs. */
58
- export interface GateRunInput {
58
+ interface GateRunInput {
59
59
  /** Absolute path to the sandboxed `hook-gate.sh`. */
60
60
  gatePath: string;
61
61
  /** Absolute path to the sandboxed hook script the gate delegates to. */
@@ -72,7 +72,7 @@ export interface GateRunInput {
72
72
  timeoutMs: number;
73
73
  }
74
74
  /** One gate execution's observed outcome. */
75
- export interface GateRunResult {
75
+ interface GateRunResult {
76
76
  /** True when the execution exceeded {@link GateRunInput.timeoutMs}. */
77
77
  timedOut: boolean;
78
78
  /** Gate exit code, or null when it did not exit normally (timeout/signal). */
@@ -81,14 +81,7 @@ export interface GateRunResult {
81
81
  stderr: string;
82
82
  }
83
83
  /** Executes one gate invocation. Injectable so the aggregation is unit-testable. */
84
- export type GateRunner = (input: GateRunInput) => GateRunResult;
85
- /**
86
- * The real runner: spawn `bash hook-gate.sh <id> <script>` with the payload on
87
- * stdin, `HQ_HOOK_PROFILE` set, and a hard timeout. A timeout is detected by the
88
- * `ETIMEDOUT` error code spawnSync reports; the process is killed with SIGKILL so
89
- * a hook cannot ignore the bound.
90
- */
91
- export declare const defaultGateRunner: GateRunner;
84
+ type GateRunner = (input: GateRunInput) => GateRunResult;
92
85
  /** Options for {@link runDeepGuardTests}. All are injectable for testing. */
93
86
  export interface DeepTestOptions {
94
87
  /** Per-case time bound. Default: {@link DEFAULT_PER_CASE_TIMEOUT_MS}. */
@@ -109,4 +102,5 @@ export declare function cleanupAllDeepSandboxes(): void;
109
102
  * an empty list — creating no sandbox — when there is nothing to run.
110
103
  */
111
104
  export declare function runDeepGuardTests(context: CheckContext, options?: DeepTestOptions): Promise<CheckResult[]>;
105
+ export {};
112
106
  //# sourceMappingURL=executor.d.ts.map
@@ -74,7 +74,7 @@ export const SANDBOX_COPY_RELPATHS = [
74
74
  * `ETIMEDOUT` error code spawnSync reports; the process is killed with SIGKILL so
75
75
  * a hook cannot ignore the bound.
76
76
  */
77
- export const defaultGateRunner = (input) => {
77
+ const defaultGateRunner = (input) => {
78
78
  const result = spawnSync("bash", [input.gatePath, input.hookId, input.hookScriptPath], {
79
79
  input: input.payload,
80
80
  cwd: input.cwd,
@@ -46,14 +46,6 @@ import { type PlatformId } from "../payload-shapes.js";
46
46
  export declare const PARITY_FAMILY_ID = "deep-parity";
47
47
  /** Human title of the parity family. */
48
48
  export declare const PARITY_FAMILY_TITLE = "Cross-platform parity replay";
49
- /** Default per-case, per-platform time bound. */
50
- export declare const DEFAULT_PARITY_TIMEOUT_MS = 10000;
51
- /** The gate profile the replay runs each hook under. Strict is the broadest
52
- * allowlist, so profile gating never suppresses the hook whose LOGIC is being
53
- * compared across platforms — profile-specific behaviour is US-008's concern. */
54
- export declare const PARITY_GATE_PROFILE = "strict";
55
- /** The session id stamped onto every replay payload. */
56
- export declare const PARITY_SESSION_ID = "hq-doctor-parity-replay";
57
49
  /** One platform replay's inputs. */
58
50
  export interface PlatformRunInput {
59
51
  /** Which platform's adapter path to exercise. */
@@ -85,27 +77,7 @@ export interface PlatformRunOutput {
85
77
  /** Executes one platform replay. Injectable so the comparison is unit-testable. */
86
78
  export type PlatformRunner = (input: PlatformRunInput) => PlatformRunOutput;
87
79
  /** The real runner set, keyed by platform. */
88
- export type PlatformRunners = Record<PlatformId, PlatformRunner>;
89
- /** The real Claude runner: the canonical script through the Claude gate. */
90
- export declare const defaultClaudeRunner: PlatformRunner;
91
- /** The real Codex runner: the duplicated mirror through the Codex gate. */
92
- export declare const defaultCodexRunner: PlatformRunner;
93
- /**
94
- * The real Grok runner. Grok shares the canonical `.claude/hooks/` scripts with
95
- * Claude — it has no mirror of its own — and its adapter's whole job is to read
96
- * the camelCase payload, map the tool alias back to canonical, build the Claude
97
- * JSON, and pipe it to the same `hook-gate.sh`. We reproduce that routing here
98
- * (via {@link toGrokPayload} + {@link grokToSnakePayload}) rather than spawn the
99
- * adapter directly, for two reasons: the live adapter runs a whole tool
100
- * *sequence* where the replay needs one isolated hook, and its user-global
101
- * bridge cannot be simulated from a project-local run (US-005 keeps bridge
102
- * presence a wiring check). The Grok block protocol still applies — the gate's
103
- * non-zero exit is what the adapter would translate into a stdout `deny`
104
- * ({@link interpretGrokBlock} in verdictFor recognises both).
105
- */
106
- export declare const defaultGrokRunner: PlatformRunner;
107
- /** The default real runner set wired to each platform's real adapter path. */
108
- export declare const defaultPlatformRunners: PlatformRunners;
80
+ type PlatformRunners = Record<PlatformId, PlatformRunner>;
109
81
  /** Options for {@link runParityReplay}. All injectable for testing. */
110
82
  export interface ParityOptions {
111
83
  /** Per-case, per-platform time bound. Default: {@link DEFAULT_PARITY_TIMEOUT_MS}. */
@@ -126,4 +98,5 @@ export declare function cleanupAllParitySandboxes(): void;
126
98
  * empty list — creating no sandbox — when there is nothing to replay.
127
99
  */
128
100
  export declare function runParityReplay(context: CheckContext, options?: ParityOptions): Promise<CheckResult[]>;
101
+ export {};
129
102
  //# sourceMappingURL=parity.d.ts.map
@@ -52,13 +52,13 @@ export const PARITY_FAMILY_ID = "deep-parity";
52
52
  /** Human title of the parity family. */
53
53
  export const PARITY_FAMILY_TITLE = "Cross-platform parity replay";
54
54
  /** Default per-case, per-platform time bound. */
55
- export const DEFAULT_PARITY_TIMEOUT_MS = 10_000;
55
+ const DEFAULT_PARITY_TIMEOUT_MS = 10_000;
56
56
  /** The gate profile the replay runs each hook under. Strict is the broadest
57
57
  * allowlist, so profile gating never suppresses the hook whose LOGIC is being
58
58
  * compared across platforms — profile-specific behaviour is US-008's concern. */
59
- export const PARITY_GATE_PROFILE = "strict";
59
+ const PARITY_GATE_PROFILE = "strict";
60
60
  /** The session id stamped onto every replay payload. */
61
- export const PARITY_SESSION_ID = "hq-doctor-parity-replay";
61
+ const PARITY_SESSION_ID = "hq-doctor-parity-replay";
62
62
  function spawnWithStdin(command, args, stdin, input) {
63
63
  const result = spawnSync(command, args, {
64
64
  input: stdin,
@@ -96,12 +96,12 @@ function runThroughGate(input, platformDir, payload) {
96
96
  return { ...spawned };
97
97
  }
98
98
  /** The real Claude runner: the canonical script through the Claude gate. */
99
- export const defaultClaudeRunner = (input) => runThroughGate(input, ".claude", JSON.stringify(toClaudePayload(input.testCase, {
99
+ const defaultClaudeRunner = (input) => runThroughGate(input, ".claude", JSON.stringify(toClaudePayload(input.testCase, {
100
100
  cwd: input.sandbox,
101
101
  sessionId: PARITY_SESSION_ID,
102
102
  })));
103
103
  /** The real Codex runner: the duplicated mirror through the Codex gate. */
104
- export const defaultCodexRunner = (input) => runThroughGate(input, ".codex", JSON.stringify(toCodexPayload(input.testCase, {
104
+ const defaultCodexRunner = (input) => runThroughGate(input, ".codex", JSON.stringify(toCodexPayload(input.testCase, {
105
105
  cwd: input.sandbox,
106
106
  sessionId: PARITY_SESSION_ID,
107
107
  })));
@@ -118,7 +118,7 @@ export const defaultCodexRunner = (input) => runThroughGate(input, ".codex", JSO
118
118
  * non-zero exit is what the adapter would translate into a stdout `deny`
119
119
  * ({@link interpretGrokBlock} in verdictFor recognises both).
120
120
  */
121
- export const defaultGrokRunner = (input) => {
121
+ const defaultGrokRunner = (input) => {
122
122
  const grokPayload = toGrokPayload(input.testCase, {
123
123
  cwd: input.sandbox,
124
124
  sessionId: PARITY_SESSION_ID,
@@ -127,7 +127,7 @@ export const defaultGrokRunner = (input) => {
127
127
  return runThroughGate(input, ".claude", JSON.stringify(snake));
128
128
  };
129
129
  /** The default real runner set wired to each platform's real adapter path. */
130
- export const defaultPlatformRunners = {
130
+ const defaultPlatformRunners = {
131
131
  claude: defaultClaudeRunner,
132
132
  codex: defaultCodexRunner,
133
133
  grok: defaultGrokRunner,
@@ -46,21 +46,10 @@ export declare const DEFAULT_PER_CASE_TIMEOUT_MS = 15000;
46
46
  * come under sandboxed execution.
47
47
  */
48
48
  export type Precondition = "git-repo" | "session-metadata" | "company-scaffold";
49
- /** Every precondition the default seeder knows how to provide. */
50
- export declare const KNOWN_PRECONDITIONS: readonly Precondition[];
51
- /**
52
- * Tree entries copied from the live tree into every sandbox — the same curated
53
- * set US-008 uses: the hook scripts and gate, the settings a hook may read, the
54
- * Codex/Grok mirrors, and `core/` (helpers hooks source). Missing entries are
55
- * skipped so a partial tree still sandboxes cleanly.
56
- */
57
- export declare const SANDBOX_SEED_RELPATHS: readonly string[];
58
49
  /** Remove every side-effect sandbox still on disk. Safe to call repeatedly. */
59
50
  export declare function cleanupAllSandboxes(): void;
60
51
  /** The sandbox roots this process is still tracking (for tests). */
61
52
  export declare function trackedSandboxRoots(): string[];
62
- /** Remove one sandbox and stop tracking it. Idempotent. */
63
- export declare function destroySandbox(root: string): void;
64
53
  /** The outcome of seeding one sandbox. */
65
54
  export interface SeedResult {
66
55
  /** Absolute, realpath'd sandbox root under the OS temp dir. */
@@ -91,7 +80,7 @@ export interface SeedOptions {
91
80
  */
92
81
  export declare function seedSandbox(hqRoot: string, opts?: SeedOptions): SeedResult;
93
82
  /** One hook execution's inputs. */
94
- export interface HookRunInput {
83
+ interface HookRunInput {
95
84
  /** Absolute path to the sandboxed hook script. */
96
85
  hookScriptPath: string;
97
86
  /** The JSON payload piped to the hook's stdin. */
@@ -104,7 +93,7 @@ export interface HookRunInput {
104
93
  timeoutMs: number;
105
94
  }
106
95
  /** One hook execution's observed result (exit code is diagnostic only). */
107
- export interface HookRunResult {
96
+ interface HookRunResult {
108
97
  /** True when the execution exceeded {@link HookRunInput.timeoutMs}. */
109
98
  timedOut: boolean;
110
99
  /** Exit code, or null when it did not exit normally (timeout/signal). */
@@ -113,17 +102,9 @@ export interface HookRunResult {
113
102
  stderr: string;
114
103
  }
115
104
  /** Runs one hook. Injectable so the tier's aggregation is unit-testable. */
116
- export type HookRunner = (input: HookRunInput) => HookRunResult;
117
- /**
118
- * The real runner: fire the hook script directly with the payload on stdin, its
119
- * cwd and session env pointed at the sandbox, and a hard timeout. Unlike a pure
120
- * guard (US-008), a side-effecting hook is run directly rather than through the
121
- * gate — its exit code is irrelevant here; what matters is the effect it leaves
122
- * on the sandbox, which effects.ts reads afterwards.
123
- */
124
- export declare const defaultHookRunner: HookRunner;
105
+ type HookRunner = (input: HookRunInput) => HookRunResult;
125
106
  /** One parsed side-effecting fixture case (its own shape, not the US-007 schema). */
126
- export interface SideEffectCase {
107
+ interface SideEffectCase {
127
108
  name: string;
128
109
  event: string;
129
110
  tool: string;
@@ -136,13 +117,13 @@ export interface SideEffectCase {
136
117
  expectedFailure?: ExpectedFailure;
137
118
  }
138
119
  /** A validated side-effecting fixture. */
139
- export interface SideEffectFixture {
120
+ interface SideEffectFixture {
140
121
  hookId: string;
141
122
  relpath: string;
142
123
  cases: SideEffectCase[];
143
124
  }
144
125
  /** A side-effecting fixture that could not be parsed. */
145
- export interface SideEffectFixtureError {
126
+ interface SideEffectFixtureError {
146
127
  hookId: string | null;
147
128
  relpath: string;
148
129
  message: string;
@@ -187,4 +168,5 @@ export interface SideEffectTestOptions {
187
168
  * Returns an empty list when there is nothing side-effecting to run.
188
169
  */
189
170
  export declare function runSideEffectTests(context: CheckContext, options?: SideEffectTestOptions): Promise<CheckResult[]>;
171
+ export {};
190
172
  //# sourceMappingURL=sandbox.d.ts.map
@@ -46,7 +46,7 @@ import { captureEffectBaseline, describeEffect, defaultGitProbe, evaluateEffects
46
46
  /** Default per-case time bound. No latency target, but no case may hang. */
47
47
  export const DEFAULT_PER_CASE_TIMEOUT_MS = 15_000;
48
48
  /** Every precondition the default seeder knows how to provide. */
49
- export const KNOWN_PRECONDITIONS = [
49
+ const KNOWN_PRECONDITIONS = [
50
50
  "git-repo",
51
51
  "session-metadata",
52
52
  "company-scaffold",
@@ -57,7 +57,7 @@ export const KNOWN_PRECONDITIONS = [
57
57
  * Codex/Grok mirrors, and `core/` (helpers hooks source). Missing entries are
58
58
  * skipped so a partial tree still sandboxes cleanly.
59
59
  */
60
- export const SANDBOX_SEED_RELPATHS = [
60
+ const SANDBOX_SEED_RELPATHS = [
61
61
  ".claude/hooks",
62
62
  ".claude/settings.json",
63
63
  ".claude/settings.local.json",
@@ -100,7 +100,7 @@ function removeTree(root) {
100
100
  }
101
101
  }
102
102
  /** Remove one sandbox and stop tracking it. Idempotent. */
103
- export function destroySandbox(root) {
103
+ function destroySandbox(root) {
104
104
  removeTree(root);
105
105
  trackedSandboxes.delete(root);
106
106
  }
@@ -198,7 +198,7 @@ function git(root, args) {
198
198
  * gate — its exit code is irrelevant here; what matters is the effect it leaves
199
199
  * on the sandbox, which effects.ts reads afterwards.
200
200
  */
201
- export const defaultHookRunner = (input) => {
201
+ const defaultHookRunner = (input) => {
202
202
  const result = spawnSync("bash", [input.hookScriptPath], {
203
203
  input: input.payload,
204
204
  cwd: input.cwd,
@@ -1,7 +1,5 @@
1
1
  /** File name of the snapshot inside the published report. */
2
2
  export declare const REPORT_PREVIEW_FILE = "hq-preview.png";
3
- export declare const REPORT_PREVIEW_WIDTH = 1280;
4
- export declare const REPORT_PREVIEW_HEIGHT = 720;
5
3
  /** First installed browser that can take a snapshot, or null. */
6
4
  export declare function findSnapshotBrowser(env?: NodeJS.ProcessEnv, platform?: string, exists?: (file: string) => boolean): string | null;
7
5
  export declare function snapshotArgs(indexFile: string, outFile: string, profileDir: string): string[];
@@ -13,8 +13,8 @@ import * as path from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  /** File name of the snapshot inside the published report. */
15
15
  export const REPORT_PREVIEW_FILE = "hq-preview.png";
16
- export const REPORT_PREVIEW_WIDTH = 1280;
17
- export const REPORT_PREVIEW_HEIGHT = 720;
16
+ const REPORT_PREVIEW_WIDTH = 1280;
17
+ const REPORT_PREVIEW_HEIGHT = 720;
18
18
  const SNAPSHOT_TIMEOUT_MS = 30_000;
19
19
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
20
20
  const BROWSER_PATHS = {
@@ -29,6 +29,7 @@
29
29
  * script needs to see termination, not a success code.
30
30
  */
31
31
  import { spawnSync } from "child_process";
32
+ import * as fs from "node:fs";
32
33
  import * as path from "path";
33
34
  import { resolveBundledAsset } from "./hq-roots.js";
34
35
  /**
@@ -41,6 +42,93 @@ export function interpreterFor(asset) {
41
42
  const ext = path.extname(asset).toLowerCase();
42
43
  return ext === ".mjs" || ext === ".js" || ext === ".cjs" ? "node" : "bash";
43
44
  }
45
+ function envValue(env, name) {
46
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
47
+ return key ? env[key] : undefined;
48
+ }
49
+ function isFile(file, lookupErrors) {
50
+ try {
51
+ return fs.statSync(file).isFile();
52
+ }
53
+ catch (error) {
54
+ // Missing PATH candidates are expected during lookup; retain the cause so
55
+ // the final actionable error can expose why no interpreter was resolved.
56
+ lookupErrors.push(error);
57
+ return false;
58
+ }
59
+ }
60
+ function isWithinWindowsDirectory(file, directory) {
61
+ const normalizedFile = path.win32.resolve(file).toLowerCase();
62
+ const normalizedDirectory = path.win32.resolve(directory).toLowerCase();
63
+ return (normalizedFile === normalizedDirectory ||
64
+ normalizedFile.startsWith(`${normalizedDirectory}${path.win32.sep}`));
65
+ }
66
+ function isExcludedWindowsBash(file, env) {
67
+ if (path.win32.basename(file).toLowerCase() !== "bash.exe")
68
+ return false;
69
+ const windowsDir = envValue(env, "SystemRoot") ?? envValue(env, "WINDIR") ?? "C:\\Windows";
70
+ const systemBash = path.win32.resolve(windowsDir, "System32", "bash.exe");
71
+ if (path.win32.resolve(file).toLowerCase() === systemBash.toLowerCase())
72
+ return true;
73
+ const localAppData = envValue(env, "LOCALAPPDATA");
74
+ return Boolean(localAppData &&
75
+ isWithinWindowsDirectory(file, path.win32.resolve(localAppData, "Microsoft", "WindowsApps")));
76
+ }
77
+ function findOnWindowsPath(executable, env, lookupErrors) {
78
+ const pathValue = envValue(env, "PATH") ?? "";
79
+ const extensions = executable.includes(".")
80
+ ? [""]
81
+ : (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD")
82
+ .split(";")
83
+ .filter(Boolean)
84
+ .map((ext) => ext.toLowerCase());
85
+ for (const entry of pathValue.split(";").filter(Boolean)) {
86
+ const directory = entry.trim().replace(/^"|"$/g, "");
87
+ for (const extension of extensions) {
88
+ const candidate = path.win32.join(directory, `${executable}${extension}`);
89
+ if (!isExcludedWindowsBash(candidate, env) && isFile(candidate, lookupErrors))
90
+ return candidate;
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+ function resolveWindowsBash(env) {
96
+ const lookupErrors = [];
97
+ const configured = envValue(env, "CLAUDE_CODE_GIT_BASH_PATH");
98
+ if (configured && !isExcludedWindowsBash(configured, env) && isFile(configured, lookupErrors)) {
99
+ return { executable: configured, cause: lookupErrors.at(-1) };
100
+ }
101
+ const pathBash = findOnWindowsPath("bash", env, lookupErrors);
102
+ if (pathBash)
103
+ return { executable: pathBash, cause: lookupErrors.at(-1) };
104
+ const gitExe = findOnWindowsPath("git.exe", env, lookupErrors);
105
+ if (gitExe) {
106
+ const derived = path.win32.resolve(path.win32.dirname(gitExe), "..", "bin", "bash.exe");
107
+ if (!isExcludedWindowsBash(derived, env) && isFile(derived, lookupErrors)) {
108
+ return { executable: derived, cause: lookupErrors.at(-1) };
109
+ }
110
+ }
111
+ const programFiles = envValue(env, "ProgramFiles") ?? "C:\\Program Files";
112
+ const localAppData = envValue(env, "LOCALAPPDATA");
113
+ const standardPaths = [
114
+ path.win32.join(programFiles, "Git", "bin", "bash.exe"),
115
+ ...(localAppData ? [path.win32.join(localAppData, "Programs", "Git", "bin", "bash.exe")] : []),
116
+ ];
117
+ const executable = standardPaths.find((candidate) => !isExcludedWindowsBash(candidate, env) && isFile(candidate, lookupErrors));
118
+ return {
119
+ executable,
120
+ cause: lookupErrors.at(-1) ??
121
+ Object.assign(new Error("Bash was not found (ENOENT)"), { code: "ENOENT" }),
122
+ };
123
+ }
124
+ function bashNotFoundError(asset, cause) {
125
+ const isHandoff = asset.replaceAll("\\", "/").endsWith("core/scripts/handoff-finalize.sh");
126
+ const recovery = isHandoff
127
+ ? "The handoff was not saved; rerun the command."
128
+ : "The script did not run; rerun this command.";
129
+ return new Error(`Bash was not found to run bundled script ${asset}. Install Git for Windows or ` +
130
+ `set CLAUDE_CODE_GIT_BASH_PATH. ${recovery}`, { cause });
131
+ }
44
132
  /**
45
133
  * Execute a bundled scaffold script and return its exit code.
46
134
  *
@@ -56,12 +144,26 @@ export function runBundledScript(opts) {
56
144
  env.CLAUDE_PROJECT_DIR = opts.hqRoot;
57
145
  }
58
146
  Object.assign(env, opts.env ?? {});
59
- const result = spawnSync(interpreterFor(opts.asset), [script, ...(opts.args ?? [])], {
147
+ const interpreter = interpreterFor(opts.asset);
148
+ const windowsBash = process.platform === "win32" && interpreter === "bash"
149
+ ? resolveWindowsBash(env)
150
+ : undefined;
151
+ const executable = windowsBash?.executable ?? interpreter;
152
+ if (windowsBash && !windowsBash.executable) {
153
+ throw bashNotFoundError(opts.asset, windowsBash.cause);
154
+ }
155
+ const result = spawnSync(executable, [script, ...(opts.args ?? [])], {
60
156
  cwd: opts.cwd,
61
157
  env,
62
158
  stdio: "inherit",
63
159
  });
64
160
  if (result.error) {
161
+ const spawnError = result.error;
162
+ if (process.platform === "win32" &&
163
+ interpreter === "bash" &&
164
+ spawnError.code === "ENOENT") {
165
+ throw bashNotFoundError(opts.asset, spawnError);
166
+ }
65
167
  throw new Error(`Failed to run bundled script ${opts.asset}: ${result.error.message}`);
66
168
  }
67
169
  if (result.signal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.302.0",
3
+ "version": "5.304.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {