@indigoai-us/hq-cli 5.345.77 → 5.345.79

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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.79] — 2026-10-11
6
+ - `hq lanes` no longer fails every list, message, or admission command when
7
+ one lane still has leftover queue details from an older worker-profile wait.
8
+ Those leftover details are dropped on read, cleared when the queue reason
9
+ changes, and one unreadable lane is skipped so the rest of the host stays
10
+ usable.
11
+ - `hq doctor --fix` still refreshes a stale company sync journal when
12
+ `.claude/`, `.codex/`, or `.grok/` has uncommitted changes. Those dirty
13
+ files still block hook-file repairs unless you pass `--force`.
14
+
5
15
  ## [5.345.77] — 2026-10-10
6
16
  - Local bots no longer fail a turn when the installed Codex is too old for the
7
17
  model it was asked to use (often the model a newer Codex app set in your own
@@ -5,9 +5,12 @@
5
5
  * wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
6
6
  *
7
7
  * 1. Dirty-tree refusal. If the working tree has uncommitted changes under
8
- * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
9
- * unless `--force`, so a repair can never be tangled up with unrelated
10
- * in-flight edits to the security layer.
8
+ * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses hook-file repairs
9
+ * and exits non-zero unless `--force`, so those writes can never be
10
+ * tangled up with unrelated in-flight edits to the security layer.
11
+ * Repairs that do not write those trees (a stale-company `refresh-sync`
12
+ * pull) still run: the SessionStart health hook never passes `--force`,
13
+ * and blocking the pull left `sync.journal.<slug>` WARN after `--fix`.
11
14
  * 2. Allowlist only. It repairs the classified safe classes — restore an
12
15
  * execute bit, add a hook id to the gate profiles it is missing from,
13
16
  * re-register a script present on disk, or pull a company whose local
@@ -5,9 +5,12 @@
5
5
  * wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
6
6
  *
7
7
  * 1. Dirty-tree refusal. If the working tree has uncommitted changes under
8
- * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
9
- * unless `--force`, so a repair can never be tangled up with unrelated
10
- * in-flight edits to the security layer.
8
+ * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses hook-file repairs
9
+ * and exits non-zero unless `--force`, so those writes can never be
10
+ * tangled up with unrelated in-flight edits to the security layer.
11
+ * Repairs that do not write those trees (a stale-company `refresh-sync`
12
+ * pull) still run: the SessionStart health hook never passes `--force`,
13
+ * and blocking the pull left `sync.journal.<slug>` WARN after `--fix`.
11
14
  * 2. Allowlist only. It repairs the classified safe classes — restore an
12
15
  * execute bit, add a hook id to the gate profiles it is missing from,
13
16
  * re-register a script present on disk, or pull a company whose local
@@ -107,20 +110,34 @@ export async function applyFixes(options) {
107
110
  ...over,
108
111
  });
109
112
  // 1. Dirty-tree refusal (AC8). Never write over uncommitted security-layer
110
- // edits unless the user forces it.
111
- if (!options.force) {
112
- const dirty = (options.dirtyCheck ?? uncommittedHookConfigChanges)(hqRoot);
113
- if (dirty.length > 0) {
114
- writeErr(`hq doctor --fix: refusing to run — uncommitted changes under ` +
115
- `${HOOK_CONFIG_DIRS.join(", ")}:\n` +
116
- dirty.map((line) => ` ${line}`).join("\n") +
117
- `\nCommit or stash them, or re-run with --force.\n`);
118
- return empty({ exitCode: 1, refused: "dirty-tree" });
119
- }
120
- }
113
+ // edits unless the user forces it. Probe once; hook-file plans are
114
+ // dropped per pass so a stale-journal pull can still run.
115
+ const dirty = options.force
116
+ ? []
117
+ : (options.dirtyCheck ?? uncommittedHookConfigChanges)(hqRoot);
118
+ const refuseDirtyHookWrites = (plans) => {
119
+ if (dirty.length === 0)
120
+ return plans;
121
+ const allowed = plans.filter((plan) => plan.relpath.length === 0);
122
+ if (allowed.length === plans.length)
123
+ return plans;
124
+ if (allowed.length === 0)
125
+ return "refuse";
126
+ writeErr(`hq doctor --fix: skipping hook-file repairs — uncommitted changes under ` +
127
+ `${HOOK_CONFIG_DIRS.join(", ")}. Refreshing sync journals still runs; ` +
128
+ `re-run with --force to repair hook files.\n`);
129
+ return allowed;
130
+ };
131
+ const refuseDirtyTree = () => {
132
+ writeErr(`hq doctor --fix: refusing to run — uncommitted changes under ` +
133
+ `${HOOK_CONFIG_DIRS.join(", ")}:\n` +
134
+ dirty.map((line) => ` ${line}`).join("\n") +
135
+ `\nCommit or stash them, or re-run with --force.\n`);
136
+ return empty({ exitCode: 1, refused: "dirty-tree" });
137
+ };
121
138
  // Resolve the customer-visible manual-only output gate once per fix run.
122
- // Keep it behind the dirty-tree guard, and fail open to the pre-change
123
- // output if the identity is absent or the registry lookup fails.
139
+ // Fail open to the pre-change output if the identity is absent or the
140
+ // registry lookup fails.
124
141
  let showManualOnlyRemediations = false;
125
142
  try {
126
143
  showManualOnlyRemediations =
@@ -172,7 +189,16 @@ export async function applyFixes(options) {
172
189
  ];
173
190
  };
174
191
  for (let pass = 1; pass <= MAX_FIX_PASSES; pass += 1) {
175
- const plans = buildPlans(findings);
192
+ const plannedThisPass = refuseDirtyHookWrites(buildPlans(findings));
193
+ if (plannedThisPass === "refuse") {
194
+ if (planned.length === 0)
195
+ return refuseDirtyTree();
196
+ write(`Pass ${pass}/${MAX_FIX_PASSES}: remaining repairs write hook files; ` +
197
+ `stopped because the tree is dirty.\n`);
198
+ refused = "dirty-tree";
199
+ break;
200
+ }
201
+ const plans = plannedThisPass;
176
202
  if (plans.length === 0)
177
203
  break;
178
204
  fixableCount += plans.length;
@@ -677,6 +677,7 @@ export async function retryLaneOnAlternateAccount(hqRoot, lane, reason, writer =
677
677
  ...lane,
678
678
  state: "queued",
679
679
  queued_reason: "account_retry",
680
+ queued_details: undefined,
680
681
  queued_at: at,
681
682
  terminal_reason: undefined,
682
683
  terminal_details: undefined,
@@ -386,6 +386,28 @@ function parseLaneStories(raw, filePath) {
386
386
  }
387
387
  }
388
388
  }
389
+ /** Profile, disk, and admit details are only valid for their matching queue reason. */
390
+ function queuedDetailsAreStale(state, queuedReason, detailsReason) {
391
+ if (detailsReason === "worker_profile_changed") {
392
+ return queuedReason !== "worker_profile_changed";
393
+ }
394
+ if (detailsReason === "low_disk") {
395
+ return state !== "queued" || queuedReason !== "low_disk";
396
+ }
397
+ if (detailsReason === "admit_failed") {
398
+ return state !== "queued" || queuedReason !== "admit_failed";
399
+ }
400
+ return false;
401
+ }
402
+ function withoutStaleQueuedDetails(record) {
403
+ const details = record.queued_details;
404
+ if (details === undefined)
405
+ return record;
406
+ if (!queuedDetailsAreStale(record.state, record.queued_reason, details.reason)) {
407
+ return record;
408
+ }
409
+ return { ...record, queued_details: undefined };
410
+ }
389
411
  export function parseLaneRecord(raw, filePath) {
390
412
  if (!isPlainObject(raw)) {
391
413
  throw new LaneSchemaError("Lane record must be a JSON object", {
@@ -638,7 +660,11 @@ export function parseLaneRecord(raw, filePath) {
638
660
  });
639
661
  }
640
662
  const detailsRecord = details;
641
- if (detailsRecord.reason === "worker_profile_changed") {
663
+ if (queuedDetailsAreStale(raw.state, raw.queued_reason, detailsRecord.reason)) {
664
+ normalized = { ...raw };
665
+ delete normalized.queued_details;
666
+ }
667
+ else if (detailsRecord.reason === "worker_profile_changed") {
642
668
  if ((detailsRecord.expected_hash !== null && typeof detailsRecord.expected_hash !== "string") ||
643
669
  typeof detailsRecord.actual_hash !== "string" || detailsRecord.actual_hash === "" ||
644
670
  typeof detailsRecord.recovery_command !== "string" || detailsRecord.recovery_command.trim() === "") {
@@ -647,17 +673,6 @@ export function parseLaneRecord(raw, filePath) {
647
673
  field: "queued_details",
648
674
  });
649
675
  }
650
- if (raw.queued_reason !== "worker_profile_changed") {
651
- if (raw.queued_reason === undefined) {
652
- throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
653
- path: filePath,
654
- field: "queued_details",
655
- queued_reason: raw.queued_reason,
656
- });
657
- }
658
- normalized = { ...raw };
659
- delete normalized.queued_details;
660
- }
661
676
  }
662
677
  else if (detailsRecord.reason === "outpost_dispatch_unknown") {
663
678
  if (raw.state !== "queued" ||
@@ -1505,10 +1520,10 @@ export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
1505
1520
  const identity = indexedDeps.outpostIdentity();
1506
1521
  const isRemoteMirror = outpostId !== undefined && (identity.kind === "not_outpost" ||
1507
1522
  (identity.kind === "id" && identity.id !== outpostId));
1508
- if (!isRemoteMirror)
1509
- throw err;
1510
1523
  const field = typeof err.details.field === "string" ? err.details.field : "unknown_field";
1511
- process.stderr.write(`lanes store: skipping malformed mirrored lane ${laneId} field ${field}: ${err.message}\n`);
1524
+ process.stderr.write(isRemoteMirror
1525
+ ? `lanes store: skipping malformed mirrored lane ${laneId} field ${field}: ${err.message}\n`
1526
+ : `lanes store: skipping unreadable lane ${laneId} field ${field}: ${err.message}\n`);
1512
1527
  }
1513
1528
  }
1514
1529
  return records.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) ||
@@ -1631,13 +1646,14 @@ export function createLane(hqRoot, init, deps = defaultStoreDeps()) {
1631
1646
  senior_history: init.senior_history ?? [],
1632
1647
  attribution_history: init.attribution_history ?? [],
1633
1648
  };
1634
- parseLaneRecord(record, filePath);
1649
+ const createdRecord = withoutStaleQueuedDetails(record);
1650
+ parseLaneRecord(createdRecord, filePath);
1635
1651
  const created = withRecordLock(filePath, deps, () => {
1636
1652
  if (deps.existsSync(filePath)) {
1637
1653
  throw new LanesError("lane_exists", `Lane ${lane_id} already exists`, { lane_id });
1638
1654
  }
1639
- atomicWriteJson(filePath, record, deps);
1640
- return record;
1655
+ atomicWriteJson(filePath, createdRecord, deps);
1656
+ return createdRecord;
1641
1657
  });
1642
1658
  return syncStoryLedgerForLane(hqRoot, created, deps);
1643
1659
  }
@@ -1689,30 +1705,30 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
1689
1705
  }
1690
1706
  if (!isTerminalLaneState(current.state) && isTerminalLaneState(updated.state)) {
1691
1707
  const releasedGrant = updated.hire_grant ?? current.hire_grant ?? 0;
1692
- return {
1708
+ return withoutStaleQueuedDetails({
1693
1709
  ...updated,
1694
1710
  released_hire_grant: releasedGrant,
1695
1711
  hire_grant: 0,
1696
- };
1712
+ });
1697
1713
  }
1698
1714
  if (isTerminalLaneState(current.state) && !isTerminalLaneState(updated.state) &&
1699
1715
  current.released_hire_grant !== undefined) {
1700
- return {
1716
+ return withoutStaleQueuedDetails({
1701
1717
  ...updated,
1702
1718
  hire_grant: current.released_hire_grant,
1703
1719
  released_hire_grant: undefined,
1704
- };
1720
+ });
1705
1721
  }
1706
1722
  if (isTerminalLaneState(updated.state) &&
1707
1723
  (updated.hire_grant !== current.hire_grant ||
1708
1724
  updated.released_hire_grant !== current.released_hire_grant)) {
1709
- return {
1725
+ return withoutStaleQueuedDetails({
1710
1726
  ...updated,
1711
1727
  hire_grant: 0,
1712
1728
  released_hire_grant: updated.released_hire_grant ?? updated.hire_grant ?? 0,
1713
- };
1729
+ });
1714
1730
  }
1715
- return updated;
1731
+ return withoutStaleQueuedDetails(updated);
1716
1732
  }, deps, {
1717
1733
  beforeWrite: (current) => assertLaneStoreWriteAllowed(current, laneId, writer),
1718
1734
  stamp: (current) => ({ written_by_version: newestLaneStoreVersion(current.written_by_version) }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.77",
3
+ "version": "5.345.79",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {