@indigoai-us/hq-cli 5.231.1 → 5.233.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,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.233.0] — 2026-09-27
6
+
7
+ ### Added
8
+
9
+ - `hq lanes wait` exits on the first selected envelope, pending-question, or
10
+ state change across the requested lanes. It uses a startup baseline and a
11
+ bounded two-second poll interval.
12
+
13
+ ## [5.232.0] — 2026-09-27
14
+
15
+ ### Added
16
+
17
+ - `checkpoint.ontology.sync-gated` (off by default) turns the checkpoint
18
+ ontology step on like `checkpoint.ontology`. Older CLI builds do not read it,
19
+ so it can be turned on widely without them. With either flag on, the step runs
20
+ only when the hq-cloud bundled with this CLI and the one the hq daemon runs
21
+ are both 6.18.7 or later; otherwise it skips and says which copy is older.
22
+ It also skips while the daemon's sync still runs from before its hq-cloud
23
+ copy was installed. `hq core ontology apply` checks again before it writes and
24
+ reports `sync_client_old` with the reason. Earlier hq-cloud releases pushed
25
+ stale copies of deleted entities.
26
+
5
27
  ## [5.231.1] — 2026-09-27
6
28
 
7
29
  ### Fixed
@@ -7773,6 +7773,27 @@ export declare const COMMAND_CATALOG: readonly [{
7773
7773
  readonly description: "JSON on stdout (always)";
7774
7774
  }];
7775
7775
  readonly subcommands: readonly [];
7776
+ }, {
7777
+ readonly name: "wait";
7778
+ readonly description: "Wait for the first requested change across lanes";
7779
+ readonly aliases: readonly [];
7780
+ readonly hidden: false;
7781
+ readonly usage: "[options]";
7782
+ readonly arguments: readonly [];
7783
+ readonly options: readonly [{
7784
+ readonly flags: "--any <lane-id...>";
7785
+ readonly description: "Lane ids to monitor";
7786
+ }, {
7787
+ readonly flags: "--for <change-type>";
7788
+ readonly description: "envelope | question | state";
7789
+ }, {
7790
+ readonly flags: "--timeout <seconds>";
7791
+ readonly description: "Positive timeout in seconds";
7792
+ }, {
7793
+ readonly flags: "--json";
7794
+ readonly description: "JSON output";
7795
+ }];
7796
+ readonly subcommands: readonly [];
7776
7797
  }, {
7777
7798
  readonly name: "watch";
7778
7799
  readonly description: "Watch lane state and question changes until all lanes are terminal";
@@ -10051,6 +10051,33 @@ export const COMMAND_CATALOG = [
10051
10051
  ],
10052
10052
  "subcommands": []
10053
10053
  },
10054
+ {
10055
+ "name": "wait",
10056
+ "description": "Wait for the first requested change across lanes",
10057
+ "aliases": [],
10058
+ "hidden": false,
10059
+ "usage": "[options]",
10060
+ "arguments": [],
10061
+ "options": [
10062
+ {
10063
+ "flags": "--any <lane-id...>",
10064
+ "description": "Lane ids to monitor"
10065
+ },
10066
+ {
10067
+ "flags": "--for <change-type>",
10068
+ "description": "envelope | question | state"
10069
+ },
10070
+ {
10071
+ "flags": "--timeout <seconds>",
10072
+ "description": "Positive timeout in seconds"
10073
+ },
10074
+ {
10075
+ "flags": "--json",
10076
+ "description": "JSON output"
10077
+ }
10078
+ ],
10079
+ "subcommands": []
10080
+ },
10054
10081
  {
10055
10082
  "name": "watch",
10056
10083
  "description": "Watch lane state and question changes until all lanes are terminal",
@@ -86,6 +86,8 @@ export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
86
86
  throw new OntologyUsageError("ontology: the run payload names no company");
87
87
  }
88
88
  const report = applyOntologyRun({ hqRoot, runDir, company, now: deps.now?.() });
89
+ if (report.status === "sync_client_old")
90
+ return `ontology: skipped (${report.skip_reason}); nothing written`;
89
91
  if (report.status !== "applied")
90
92
  return `ontology: ${report.status.replaceAll("_", " ")}; nothing written`;
91
93
  return (`ontology: ${report.created.length} created, ${report.updated.length} updated, ` +
@@ -52,6 +52,7 @@ import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
52
52
  import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
53
53
  import { parseCliEffort, resolveLaneEffort, } from "../lib/lanes/effort.js";
54
54
  import { LanesError } from "../lib/lanes/errors.js";
55
+ import { LANE_WAIT_INVALID_INPUT_EXIT_CODE, LANE_WAIT_TIMEOUT_EXIT_CODE, runLaneWait, } from "../lib/lanes/wait.js";
55
56
  import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
56
57
  import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages, } from "../lib/lanes/dropbox.js";
57
58
  import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision, } from "../lib/lanes/watcher.js";
@@ -2127,6 +2128,37 @@ function yieldEventLoop() {
2127
2128
  setImmediate(resolve);
2128
2129
  });
2129
2130
  }
2131
+ function laneWaitHumanLine(result) {
2132
+ const details = Object.entries(result.change)
2133
+ .filter(([key]) => key !== "type")
2134
+ .map(([key, value]) => `${key}=${String(value)}`)
2135
+ .join(" ");
2136
+ return `lane=${result.lane_id} change=${result.change.type}${details ? ` ${details}` : ""} at=${result.at}`;
2137
+ }
2138
+ async function runWait(ctx, opts) {
2139
+ const timeoutSeconds = opts.timeout === undefined ? Number.NaN : Number(opts.timeout);
2140
+ try {
2141
+ const result = await runLaneWait(ctx.hqRoot(), opts.any ?? [], opts.for ?? [], timeoutSeconds * 1000, { now: ctx.now, sleep: ctx.sleep });
2142
+ if (result) {
2143
+ console.log(opts.json ? JSON.stringify(result) : laneWaitHumanLine(result));
2144
+ return 0;
2145
+ }
2146
+ console.log(opts.json
2147
+ ? JSON.stringify({ ok: false, error: "wait_timeout" })
2148
+ : "wait_timeout");
2149
+ return LANE_WAIT_TIMEOUT_EXIT_CODE;
2150
+ }
2151
+ catch (err) {
2152
+ if (!isLanesError(err) || err.error !== "wait_invalid_input")
2153
+ throw err;
2154
+ const output = { ok: false, error: err.error, message: err.message };
2155
+ if (opts.json)
2156
+ console.log(JSON.stringify(output));
2157
+ else
2158
+ process.stderr.write(`${err.message}\n`);
2159
+ return LANE_WAIT_INVALID_INPUT_EXIT_CODE;
2160
+ }
2161
+ }
2130
2162
  function withCasRetry(fn, attempts = 5) {
2131
2163
  let last;
2132
2164
  for (let i = 0; i < attempts; i += 1) {
@@ -9492,6 +9524,14 @@ export function registerLanesCommand(parent, deps = {}) {
9492
9524
  .action(wrap(async (laneId, opts) => {
9493
9525
  await runAdmit(ctx, laneId, opts);
9494
9526
  }));
9527
+ lanes
9528
+ .command("wait")
9529
+ .description("Wait for the first requested change across lanes")
9530
+ .option("--any <lane-id...>", "Lane ids to monitor")
9531
+ .option("--for <change-type>", "envelope | question | state", collectRepeatable, [])
9532
+ .option("--timeout <seconds>", "Positive timeout in seconds")
9533
+ .option("--json", "JSON output")
9534
+ .action(wrapText(async (opts) => runWait(ctx, opts)));
9495
9535
  lanes
9496
9536
  .command("watch")
9497
9537
  .description("Watch lane state and question changes until all lanes are terminal")
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { type DuplicateReport, type MergeBackOutcome } from "./multi-writer.js";
9
9
  import { type CompanyBriefResult } from "./company-brief.js";
10
+ import { type SyncClientVersions } from "./sync-client.js";
10
11
  import { type WritableEntityType } from "./identity.js";
11
12
  import { type EntityIndex } from "./tree.js";
12
13
  export declare const CAPS: {
@@ -30,7 +31,9 @@ export interface ApplyReport {
30
31
  schema: "hq-checkpoint-ontology-report.v1";
31
32
  company: string;
32
33
  run_id: string;
33
- status: "applied" | "no_candidates" | "invalid_candidates";
34
+ status: "applied" | "no_candidates" | "invalid_candidates" | "sync_client_old";
35
+ /** Present only with status `sync_client_old`: which sync client copy is older or unknown. */
36
+ skip_reason?: string;
34
37
  planned_inputs: number;
35
38
  planned_tokens: number;
36
39
  candidates: number;
@@ -94,6 +97,8 @@ export interface ApplyOptions {
94
97
  /** The payload company. Must equal the plan's company. */
95
98
  company: string;
96
99
  now?: Date;
100
+ /** Test seam for the sync client check; defaults to reading this machine's hq-cloud copies. */
101
+ syncClientVersions?: () => SyncClientVersions;
97
102
  }
98
103
  export declare class ApplyError extends Error {
99
104
  constructor(message: string);
@@ -12,6 +12,7 @@ import { entityKey, freshAliases, MAX_NAME_LENGTH, ownNames } from "./aliases.js
12
12
  import { recordOutbox, scanDuplicates } from "./multi-writer.js";
13
13
  import { writeCompanyBrief } from "./company-brief.js";
14
14
  import { entitiesRoot, resolveOntologyCompany, safeWriteFile } from "./company.js";
15
+ import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
15
16
  import { appendBackRefs, parseEntityFile, patchEntityFile, previewSignalContent, renderNewEntity, validateEntityFrontmatter, } from "./entity-file.js";
16
17
  import { canonicalizeName, entityId, isRejectedName, isWritableEntityType, normalizeEntityName, squash, toSlug, } from "./identity.js";
17
18
  import { CANDIDATES_FILE, CANDIDATES_SCHEMA, PLAN_FILE, PLAN_SCHEMA, REPORT_FILE, readLedger, writeLedger, } from "./plan.js";
@@ -232,6 +233,15 @@ export function applyOntologyRun(options) {
232
233
  fs.writeFileSync(path.join(options.runDir, REPORT_FILE), `${JSON.stringify(report, null, 2)}\n`);
233
234
  return report;
234
235
  };
236
+ // CO-45: the sync client can change between plan and apply, so check it
237
+ // again before anything is written.
238
+ const syncClient = checkSyncClient((options.syncClientVersions ?? readSyncClientVersions)());
239
+ if (!syncClient.ok) {
240
+ report.status = "sync_client_old";
241
+ report.skip_reason = syncClient.reason;
242
+ report.inputs_pending = plan.inputs.filter((i) => i.kind !== "session").map((i) => i.id);
243
+ return finish();
244
+ }
235
245
  const candidatesPath = path.join(options.runDir, CANDIDATES_FILE);
236
246
  if (!fs.existsSync(candidatesPath)) {
237
247
  report.status = "no_candidates";
@@ -11,7 +11,16 @@ import { type CompanyBriefResult } from "./company-brief.js";
11
11
  import { type OntologyCompany } from "./company.js";
12
12
  import { type MultiWriterStart } from "./multi-writer.js";
13
13
  import { type EntityHint } from "./hints.js";
14
+ import { type SyncClientVersions } from "./sync-client.js";
14
15
  export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
16
+ /**
17
+ * Turns the step on like `checkpoint.ontology`, but only builds that check the
18
+ * sync client read it (CO-45, owner decision CO-D-005). Older CLI builds read
19
+ * only `checkpoint.ontology`, so this key can default on without starting the
20
+ * step next to an hq-cloud older than 6.18.7. Either key needs a sync client of
21
+ * 6.18.7 or later; see ./sync-client.ts.
22
+ */
23
+ export declare const ONTOLOGY_SYNC_GATED_FLAG = "checkpoint.ontology.sync-gated";
15
24
  export declare const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
16
25
  /**
17
26
  * Applies only when the payload's sibling backend is grok. On gold-v2, Claude
@@ -159,6 +168,8 @@ export interface PlanOptions {
159
168
  payload: CheckpointPayloadLike;
160
169
  readFlag: FlagReader;
161
170
  now?: Date;
171
+ /** This machine's hq-cloud versions; defaults to reading them. */
172
+ syncClientVersions?: () => SyncClientVersions;
162
173
  }
163
174
  export type PlanOutcome = {
164
175
  status: "skipped";
@@ -14,8 +14,17 @@ import { writeCompanyBrief } from "./company-brief.js";
14
14
  import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
15
15
  import { startMultiWriterRun } from "./multi-writer.js";
16
16
  import { entityHints, wordWeights } from "./hints.js";
17
+ import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
17
18
  import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
18
19
  export const ONTOLOGY_FLAG = "checkpoint.ontology";
20
+ /**
21
+ * Turns the step on like `checkpoint.ontology`, but only builds that check the
22
+ * sync client read it (CO-45, owner decision CO-D-005). Older CLI builds read
23
+ * only `checkpoint.ontology`, so this key can default on without starting the
24
+ * step next to an hq-cloud older than 6.18.7. Either key needs a sync client of
25
+ * 6.18.7 or later; see ./sync-client.ts.
26
+ */
27
+ export const ONTOLOGY_SYNC_GATED_FLAG = "checkpoint.ontology.sync-gated";
19
28
  export const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
20
29
  /**
21
30
  * Applies only when the payload's sibling backend is grok. On gold-v2, Claude
@@ -231,10 +240,12 @@ export async function planOntologyRun(options) {
231
240
  if (!resolution.ok)
232
241
  return { status: "skipped", reason: resolution.reason };
233
242
  const company = resolution.company;
234
- if (!(await options.readFlag(ONTOLOGY_FLAG, company))) {
243
+ const ontologyOn = (await options.readFlag(ONTOLOGY_FLAG, company)) || (await options.readFlag(ONTOLOGY_SYNC_GATED_FLAG, company));
244
+ const syncClient = ontologyOn ? checkSyncClient((options.syncClientVersions ?? readSyncClientVersions)()) : null;
245
+ if (!ontologyOn || !syncClient?.ok) {
235
246
  // A preference recorded while the ontology was on must not outlive it.
236
247
  fs.rmSync(backendPreferencePath(hqRoot, company), { force: true });
237
- return { status: "skipped", reason: "flag off" };
248
+ return { status: "skipped", reason: syncClient && !syncClient.ok ? syncClient.reason : "flag off" };
238
249
  }
239
250
  const briefOn = await options.readFlag(COMPANY_BRIEF_FLAG, company);
240
251
  const ignoreAnnotations = (await options.readFlag(IGNORE_ANNOTATIONS_FLAG, company)) && payload.backend === "grok";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The sync client check for the checkpoint ontology step (CO-45, owner
3
+ * decision CO-D-005).
4
+ *
5
+ * The step writes entity files locally and a sync client pushes them. hq-cloud
6
+ * before 6.18.7 pushed stale local copies of deleted entities as new objects
7
+ * and overwrote newer entities with older ones (INC-CO-RESURRECT). hq-flags
8
+ * cannot target by client version, so the step checks this machine's hq-cloud
9
+ * copies itself: the one bundled with this CLI (`hq sync`) and the one the hq
10
+ * daemon runs. If either is older than 6.18.7, or the bundled copy cannot be
11
+ * read, the step does not run. The daemon picks up a new hq-cloud only when
12
+ * its sync unit restarts, so a sync unit that started before its copy was
13
+ * installed may still run older code; the step does not run then either.
14
+ *
15
+ * The desktop app's sync runner is not visible from here. The vault's
16
+ * `vault.refuse-stale-entity-recreate` guard refuses those two writes from any
17
+ * client.
18
+ */
19
+ export declare const MIN_SYNC_CLIENT_VERSION = "6.18.7";
20
+ export interface SyncClientVersions {
21
+ /** hq-cloud bundled with this CLI, or null when it cannot be read. */
22
+ bundled: string | null;
23
+ /** hq-cloud the hq daemon runs, or null when it cannot be read. */
24
+ daemon: string | null;
25
+ /** True when the daemon's running sync unit started before that copy was installed. */
26
+ daemonRestartPending?: boolean;
27
+ }
28
+ export type SyncClientCheck = {
29
+ ok: true;
30
+ } | {
31
+ ok: false;
32
+ reason: string;
33
+ };
34
+ /** Read this machine's hq-cloud versions. Never throws. */
35
+ export declare function readSyncClientVersions(): SyncClientVersions;
36
+ /** Every readable copy must be at least 6.18.7, and the bundled copy must be readable. */
37
+ export declare function checkSyncClient(versions: SyncClientVersions): SyncClientCheck;
38
+ //# sourceMappingURL=sync-client.d.ts.map
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The sync client check for the checkpoint ontology step (CO-45, owner
3
+ * decision CO-D-005).
4
+ *
5
+ * The step writes entity files locally and a sync client pushes them. hq-cloud
6
+ * before 6.18.7 pushed stale local copies of deleted entities as new objects
7
+ * and overwrote newer entities with older ones (INC-CO-RESURRECT). hq-flags
8
+ * cannot target by client version, so the step checks this machine's hq-cloud
9
+ * copies itself: the one bundled with this CLI (`hq sync`) and the one the hq
10
+ * daemon runs. If either is older than 6.18.7, or the bundled copy cannot be
11
+ * read, the step does not run. The daemon picks up a new hq-cloud only when
12
+ * its sync unit restarts, so a sync unit that started before its copy was
13
+ * installed may still run older code; the step does not run then either.
14
+ *
15
+ * The desktop app's sync runner is not visible from here. The vault's
16
+ * `vault.refuse-stale-entity-recreate` guard refuses those two writes from any
17
+ * client.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import semver from "semver";
22
+ import { bundledHqCloud, currentHqCloud } from "../daemon/hq-cloud.js";
23
+ import { daemonPaths } from "../daemon/paths.js";
24
+ export const MIN_SYNC_CLIENT_VERSION = "6.18.7";
25
+ /** Read this machine's hq-cloud versions. Never throws. */
26
+ export function readSyncClientVersions() {
27
+ const bundled = bundledHqCloud()?.version ?? null;
28
+ let daemon = null;
29
+ let daemonRestartPending = false;
30
+ try {
31
+ const paths = daemonPaths();
32
+ const picked = currentHqCloud(paths.root);
33
+ daemon = picked?.version ?? null;
34
+ if (picked) {
35
+ try {
36
+ daemonRestartPending = syncStartedBefore(paths.statePath, path.join(picked.root, "package.json"));
37
+ }
38
+ catch (err) {
39
+ // Unknown start time: assume the running sync may be older.
40
+ daemonRestartPending = true;
41
+ console.warn(`ontology: could not read the hq daemon's sync start time: ${err instanceof Error ? err.message : String(err)}`);
42
+ }
43
+ }
44
+ }
45
+ catch (err) {
46
+ console.warn(`ontology: could not read the hq daemon's hq-cloud version: ${err instanceof Error ? err.message : String(err)}`);
47
+ }
48
+ return { bundled, daemon, daemonRestartPending };
49
+ }
50
+ /** Whether the daemon's running sync unit started before `copyPackageJson` was written. No state file: no running unit. */
51
+ function syncStartedBefore(statePath, copyPackageJson) {
52
+ let state;
53
+ try {
54
+ state = JSON.parse(fs.readFileSync(statePath, "utf8"));
55
+ }
56
+ catch (err) {
57
+ if (err.code === "ENOENT")
58
+ return false;
59
+ throw err;
60
+ }
61
+ const sync = state.units?.find((u) => u.id === "sync" && u.status === "running");
62
+ if (!sync || typeof sync.startedAt !== "number")
63
+ return false;
64
+ return fs.statSync(copyPackageJson).mtimeMs > sync.startedAt;
65
+ }
66
+ /** Every readable copy must be at least 6.18.7, and the bundled copy must be readable. */
67
+ export function checkSyncClient(versions) {
68
+ if (versions.bundled === null) {
69
+ return { ok: false, reason: `sync client version unknown; needs hq-cloud ${MIN_SYNC_CLIENT_VERSION} or later` };
70
+ }
71
+ for (const [name, version] of [["bundled", versions.bundled], ["daemon", versions.daemon]]) {
72
+ if (version === null)
73
+ continue;
74
+ const parsed = semver.valid(version);
75
+ if (parsed === null || semver.lt(parsed, MIN_SYNC_CLIENT_VERSION)) {
76
+ return { ok: false, reason: `sync client hq-cloud ${version} (${name}) is older than ${MIN_SYNC_CLIENT_VERSION}` };
77
+ }
78
+ }
79
+ if (versions.daemonRestartPending) {
80
+ return { ok: false, reason: `the hq daemon's sync has not restarted onto hq-cloud ${versions.daemon ?? "(unknown)"} yet` };
81
+ }
82
+ return { ok: true };
83
+ }
84
+ //# sourceMappingURL=sync-client.js.map
@@ -0,0 +1,39 @@
1
+ import type { LaneRecord, QuestionRecord } from "./types.js";
2
+ export declare const LANE_WAIT_POLL_INTERVAL_MS = 2000;
3
+ export declare const LANE_WAIT_MAX_LANES = 64;
4
+ export declare const LANE_WAIT_TIMEOUT_EXIT_CODE = 4;
5
+ export declare const LANE_WAIT_INVALID_INPUT_EXIT_CODE = 2;
6
+ export declare const LANE_WAIT_CHANGE_TYPES: readonly ["envelope", "question", "state"];
7
+ export type LaneWaitChangeType = (typeof LANE_WAIT_CHANGE_TYPES)[number];
8
+ export type LaneWaitChange = {
9
+ type: "envelope";
10
+ ref: string;
11
+ decision: "done" | "ask" | "blocked";
12
+ } | {
13
+ type: "question";
14
+ question_id: string;
15
+ } | {
16
+ type: "state";
17
+ from: string;
18
+ to: string;
19
+ };
20
+ export interface LaneWaitResult {
21
+ ok: true;
22
+ lane_id: string;
23
+ change: LaneWaitChange;
24
+ at: string;
25
+ }
26
+ export interface LaneWaitDeps {
27
+ readLane?: (hqRoot: string, laneId: string) => LaneRecord | null;
28
+ listQuestionIds?: (hqRoot: string) => string[];
29
+ readQuestion?: (hqRoot: string, questionId: string) => QuestionRecord | null;
30
+ now?: () => Date;
31
+ sleep?: (ms: number) => Promise<void>;
32
+ }
33
+ /**
34
+ * Wait for the first requested change after taking a read-only baseline.
35
+ * Each poll reads every selected lane once and runs no lane-store mutation or
36
+ * lock. Question records are scanned in one pass when question changes matter.
37
+ */
38
+ export declare function runLaneWait(hqRoot: string, laneIds: readonly string[], changeTypes: readonly string[], timeoutMs: number, deps?: LaneWaitDeps): Promise<LaneWaitResult | null>;
39
+ //# sourceMappingURL=wait.d.ts.map
@@ -0,0 +1,159 @@
1
+ import { listQuestionIds, readLane, readQuestion } from "./store.js";
2
+ import { LanesError } from "./errors.js";
3
+ export const LANE_WAIT_POLL_INTERVAL_MS = 2_000;
4
+ export const LANE_WAIT_MAX_LANES = 64;
5
+ export const LANE_WAIT_TIMEOUT_EXIT_CODE = 4;
6
+ export const LANE_WAIT_INVALID_INPUT_EXIT_CODE = 2;
7
+ export const LANE_WAIT_CHANGE_TYPES = ["envelope", "question", "state"];
8
+ function defaultSleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ function invalidInput(message, details = {}) {
12
+ throw new LanesError("wait_invalid_input", message, details);
13
+ }
14
+ function validateInput(laneIds, changeTypes, timeoutMs) {
15
+ const normalizedLaneIds = laneIds.map((laneId) => laneId.trim());
16
+ if (normalizedLaneIds.length === 0 || normalizedLaneIds.some((laneId) => laneId.length === 0)) {
17
+ invalidInput("hq lanes wait requires at least one non-empty lane id", { lane_ids: laneIds });
18
+ }
19
+ if (normalizedLaneIds.some((laneId) => laneId === "." || laneId === ".." || /[\\/]/.test(laneId))) {
20
+ invalidInput("Lane ids must not contain path separators", { lane_ids: laneIds });
21
+ }
22
+ const uniqueLaneIds = [...new Set(normalizedLaneIds)];
23
+ if (uniqueLaneIds.length > LANE_WAIT_MAX_LANES) {
24
+ invalidInput(`hq lanes wait accepts at most ${LANE_WAIT_MAX_LANES} lane ids`, {
25
+ max: LANE_WAIT_MAX_LANES,
26
+ });
27
+ }
28
+ if (changeTypes.length === 0 || changeTypes.some((kind) => !LANE_WAIT_CHANGE_TYPES.includes(kind))) {
29
+ invalidInput(`hq lanes wait requires one or more --for values: ${LANE_WAIT_CHANGE_TYPES.join(", ")}`, { accepted: [...LANE_WAIT_CHANGE_TYPES], values: changeTypes });
30
+ }
31
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isSafeInteger(Math.ceil(timeoutMs))) {
32
+ invalidInput("hq lanes wait requires a positive finite timeout", { timeout_ms: timeoutMs });
33
+ }
34
+ return {
35
+ laneIds: uniqueLaneIds,
36
+ changeTypes: [...new Set(changeTypes)],
37
+ };
38
+ }
39
+ function snapshotLanes(hqRoot, laneIds, changeTypes, deps) {
40
+ const readLaneRecord = deps.readLane ?? readLane;
41
+ const lanes = new Map();
42
+ for (const laneId of laneIds) {
43
+ const lane = readLaneRecord(hqRoot, laneId);
44
+ if (!lane) {
45
+ throw new LanesError("wait_lane_unreadable", `Lane ${laneId} could not be read while waiting`, { lane_id: laneId });
46
+ }
47
+ lanes.set(laneId, lane);
48
+ }
49
+ const pendingQuestions = new Map();
50
+ if (changeTypes.includes("question")) {
51
+ const listIds = deps.listQuestionIds ?? listQuestionIds;
52
+ const readQuestionRecord = deps.readQuestion ?? readQuestion;
53
+ for (const questionId of new Set(listIds(hqRoot))) {
54
+ const question = readQuestionRecord(hqRoot, questionId);
55
+ if (!question) {
56
+ throw new LanesError("wait_question_unreadable", `Question ${questionId} disappeared while waiting`, { question_id: questionId });
57
+ }
58
+ if (question.status === "pending" && lanes.has(question.lane_id)) {
59
+ pendingQuestions.set(questionId, question);
60
+ }
61
+ }
62
+ }
63
+ return { lanes, pendingQuestions };
64
+ }
65
+ function timestamp(value, fallbackMs) {
66
+ const parsed = value === undefined ? Number.NaN : Date.parse(value);
67
+ const atMs = Number.isFinite(parsed) ? parsed : fallbackMs;
68
+ return { at: new Date(atMs).toISOString(), atMs };
69
+ }
70
+ function envelopeSignature(lane) {
71
+ const envelope = lane.last_envelope;
72
+ return envelope === undefined
73
+ ? undefined
74
+ : `${envelope.ref}\0${envelope.decision}\0${envelope.at}`;
75
+ }
76
+ function candidatesForSnapshot(previous, current, laneIds, changeTypes, observedAtMs) {
77
+ const candidates = [];
78
+ laneIds.forEach((laneId, laneOrder) => {
79
+ const before = previous.lanes.get(laneId);
80
+ const after = current.lanes.get(laneId);
81
+ if (!before || !after)
82
+ return;
83
+ changeTypes.forEach((type, typeOrder) => {
84
+ if (type === "envelope" && envelopeSignature(before) !== envelopeSignature(after)) {
85
+ const envelope = after.last_envelope;
86
+ if (envelope) {
87
+ const when = timestamp(envelope.at, observedAtMs);
88
+ candidates.push({
89
+ laneId,
90
+ change: { type, ref: envelope.ref, decision: envelope.decision },
91
+ ...when,
92
+ laneOrder,
93
+ typeOrder,
94
+ });
95
+ }
96
+ }
97
+ if (type === "state" && before.state !== after.state) {
98
+ const when = timestamp(after.updated_at, observedAtMs);
99
+ candidates.push({
100
+ laneId,
101
+ change: { type, from: before.state, to: after.state },
102
+ ...when,
103
+ laneOrder,
104
+ typeOrder,
105
+ });
106
+ }
107
+ if (type === "question") {
108
+ for (const [questionId, question] of current.pendingQuestions) {
109
+ if (question.lane_id !== laneId || previous.pendingQuestions.has(questionId))
110
+ continue;
111
+ const when = timestamp(question.asked_at, observedAtMs);
112
+ candidates.push({
113
+ laneId,
114
+ change: { type, question_id: questionId },
115
+ ...when,
116
+ laneOrder,
117
+ typeOrder,
118
+ });
119
+ }
120
+ }
121
+ });
122
+ });
123
+ return candidates.sort((a, b) => a.atMs - b.atMs || a.laneOrder - b.laneOrder || a.typeOrder - b.typeOrder);
124
+ }
125
+ /**
126
+ * Wait for the first requested change after taking a read-only baseline.
127
+ * Each poll reads every selected lane once and runs no lane-store mutation or
128
+ * lock. Question records are scanned in one pass when question changes matter.
129
+ */
130
+ export async function runLaneWait(hqRoot, laneIds, changeTypes, timeoutMs, deps = {}) {
131
+ const input = validateInput(laneIds, changeTypes, timeoutMs);
132
+ const now = deps.now ?? (() => new Date());
133
+ const sleep = deps.sleep ?? defaultSleep;
134
+ const startedAtMs = now().getTime();
135
+ const deadlineMs = startedAtMs + timeoutMs;
136
+ let previous = snapshotLanes(hqRoot, input.laneIds, input.changeTypes, deps);
137
+ while (true) {
138
+ const remainingMs = deadlineMs - now().getTime();
139
+ if (remainingMs <= 0)
140
+ return null;
141
+ await sleep(Math.min(LANE_WAIT_POLL_INTERVAL_MS, remainingMs));
142
+ const observedAtMs = now().getTime();
143
+ const current = snapshotLanes(hqRoot, input.laneIds, input.changeTypes, deps);
144
+ const candidates = candidatesForSnapshot(previous, current, input.laneIds, input.changeTypes, observedAtMs);
145
+ const first = candidates.find((candidate) => candidate.atMs <= deadlineMs);
146
+ if (first) {
147
+ return {
148
+ ok: true,
149
+ lane_id: first.laneId,
150
+ change: first.change,
151
+ at: first.at,
152
+ };
153
+ }
154
+ if (observedAtMs >= deadlineMs)
155
+ return null;
156
+ previous = current;
157
+ }
158
+ }
159
+ //# sourceMappingURL=wait.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.231.1",
3
+ "version": "5.233.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {