@indigoai-us/hq-cli 5.287.0 → 5.288.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,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.288.0] — 2026-09-29
6
+
5
7
  ## [5.287.0] — 2026-09-29
6
8
  - Group hook-timeout events by script while preserving triage diagnostics.
7
9
  - Preserve safe slow-child script basenames in hook-timeout diagnostics.
@@ -16,6 +18,40 @@
16
18
  directories are cloud-backed, and points to `hq sync pull --all` and
17
19
  `hq sync status` for cloud-backed companies.
18
20
 
21
+ ### Fixed
22
+
23
+ - Confirm primary-dispatched Outpost profile recovery when it is queued for
24
+ reconcile, and sync the cleared recovery state to the primary lane mirror.
25
+ - Cache-first `hq lanes list` now allows one detached Outpost summary refresh
26
+ per HQ root at a time, avoiding duplicate work from overlapping list calls.
27
+
28
+ ### Fixed
29
+
30
+ - The lanes hook reads only transcript bytes appended since its last check.
31
+ It still replays compaction messages and resets the offset when a transcript
32
+ is replaced or truncated.
33
+
34
+ ### Added
35
+
36
+ - `hq core ontology merge-copies --only <file>` handles only the copy -> original pairs listed in a JSON file and reports every other found pair as skipped (`not_listed`).
37
+
38
+ ### Changed
39
+
40
+ - The checkpoint ontology step always applies its ten quality rules (company
41
+ brief, annotation stripping on grok, grok-first backend, no link aliases,
42
+ first-name people, ambiguous concept links, dominant sibling link, hub
43
+ mention floor, concept-project link, named evidence). Their
44
+ `checkpoint.ontology.*` flags, on for everyone since 2026-09-29, are no
45
+ longer read.
46
+
47
+ ### Added
48
+
49
+ - Record company-scoped exposure for printed CLI plan-limit notices when the default-off `billing.limit-at-action-prompt` flag is enabled; count an upgrade engagement only after the user confirms opening the link.
50
+
51
+ ### Fixed
52
+
53
+ - `hq secrets` and other noninteractive person-token callers reuse a fresh same-principal session written by another process while a stale refresh is rejected.
54
+
19
55
  ## [5.286.0] — 2026-09-29
20
56
 
21
57
  ### Changed
@@ -27,11 +63,6 @@
27
63
  it, and output the old daemon had not read reaches the new one. Other
28
64
  platforms keep the anonymous pipe.
29
65
 
30
- ### Fixed
31
-
32
- - Cache-first `hq lanes list` now allows one detached Outpost summary refresh
33
- per HQ root at a time, avoiding duplicate work from overlapping list calls.
34
-
35
66
  ## [5.285.0] — 2026-09-29
36
67
 
37
68
  - Route eligible Linux CI jobs onto ARM runners while retaining existing test and platform gates.
@@ -42,6 +73,13 @@
42
73
 
43
74
  ### Fixed
44
75
 
76
+ - Recover a queued Outpost lane held for `worker_profile_changed` with its
77
+ printed `hq lanes resume <id> --worker <profile>` command. The lane owner's
78
+ profile copy is authoritative: the command records an authorized re-pin in
79
+ history, leaves the lane queued for normal reconcile admission, and syncs the
80
+ cleared recovery state back to the primary mirror. Other queued lanes still
81
+ require `hq lanes admit`.
82
+
45
83
  - A remote `hq lanes list` read no longer dispatches owner hire summaries,
46
84
  capacity probes, or mirror snapshots to other Outposts. Two Outposts that
47
85
  each held a lane placed on the other ran `hq lanes list --json` on each other
@@ -5205,6 +5205,9 @@ export declare const COMMAND_CATALOG: readonly [{
5205
5205
  }, {
5206
5206
  readonly flags: "--limit <n>";
5207
5207
  readonly description: "handle at most this many copies";
5208
+ }, {
5209
+ readonly flags: "--only <file>";
5210
+ readonly description: "handle only the copy -> original pairs listed in this JSON file";
5208
5211
  }, {
5209
5212
  readonly flags: "--json";
5210
5213
  readonly description: "print JSON";
@@ -6730,6 +6730,10 @@ export const COMMAND_CATALOG = [
6730
6730
  "flags": "--limit <n>",
6731
6731
  "description": "handle at most this many copies"
6732
6732
  },
6733
+ {
6734
+ "flags": "--only <file>",
6735
+ "description": "handle only the copy -> original pairs listed in this JSON file"
6736
+ },
6733
6737
  {
6734
6738
  "flags": "--json",
6735
6739
  "description": "print JSON"
@@ -20,6 +20,7 @@ import * as os from "node:os";
20
20
  import { CLI_VERSION } from "../cli-version.js";
21
21
  import { DEFAULT_VAULT_API_URL, personTokenCacheFile, } from "../utils/cognito-session.js";
22
22
  import { HQ_CLIENT_NAME } from "../utils/vault-api.js";
23
+ import { queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, } from "../utils/cli-telemetry.js";
23
24
  import { networkTransportErrorCode } from "../utils/network-transport-error.js";
24
25
  import { canOfferTeamUpgrade, formatUpgradeRefusal, isPlanGateError, offerUpgradeLink, planGateErrorFromPayload, } from "../utils/plan-gate-error.js";
25
26
  import { generateHostKeyPair, machineCredsFileExists, readExternalMachineCreds, writeHostKeyPair, writeMachineCreds, } from "../lib/agent-kit/creds.js";
@@ -373,9 +374,14 @@ export function registerAgentEnrollCommand(agent) {
373
374
  else if (err instanceof EnrollError && err.upgradeUrl) {
374
375
  const interactive = canOfferTeamUpgrade();
375
376
  console.error(chalk.red(formatEnrollFailureMessage(err, { urlOnly: !interactive })));
377
+ const exposure = err.code === "billing_required"
378
+ ? queueCliLimitNoticeExposure("agents", "refusal")
379
+ : null;
376
380
  if (interactive) {
377
381
  try {
378
- await offerUpgradeLink(err.upgradeUrl, { interactive: true });
382
+ const opened = await offerUpgradeLink(err.upgradeUrl, { interactive: true });
383
+ if (opened)
384
+ queueCliLimitNoticeEngagement(exposure);
379
385
  }
380
386
  catch (openErr) {
381
387
  const message = openErr instanceof Error ? openErr.message : String(openErr);
@@ -386,7 +392,9 @@ export function registerAgentEnrollCommand(agent) {
386
392
  else {
387
393
  console.error(chalk.red(err instanceof Error ? err.message : String(err)));
388
394
  }
389
- process.exit(1);
395
+ // Let the CLI's bounded finalization flush the best-effort prompt
396
+ // telemetry before the process exits with the same failure status.
397
+ process.exitCode = 1;
390
398
  }
391
399
  });
392
400
  }
@@ -62,6 +62,7 @@ export declare function agentCreatePlanLimitFromPayload(status: number, body: un
62
62
  /** Print a server-priced plan block without minting or rewriting its URL. */
63
63
  export declare function surfaceAgentCreatePlanLimit(planLimit: AgentCreatePlanLimit, opts?: {
64
64
  json?: boolean;
65
+ companyUid?: string;
65
66
  }): Promise<void>;
66
67
  /**
67
68
  * Resolve a closed-set option value, or exit(1) with a message naming the
@@ -34,6 +34,7 @@ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
34
34
  import { maxLength } from "../utils/table-width.js";
35
35
  import { DEFAULT_VAULT_API_URL } from "../utils/cognito-session.js";
36
36
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
37
+ import { queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, } from "../utils/cli-telemetry.js";
37
38
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
38
39
  import { consoleOrigin } from "./integrations-connect.js";
39
40
  import { launchSessionManagerPlugin, SessionManagerPluginLaunchError, TerminalSessionTimeoutError, waitForTerminalSession, } from "@indigoai-us/hq-cloud/outposts/node";
@@ -128,13 +129,14 @@ function formatServerPrice(amountMinor, currency) {
128
129
  async function offerAgentCheckout(url) {
129
130
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
130
131
  try {
131
- await offerUpgradeLink(url, {
132
+ return await offerUpgradeLink(url, {
132
133
  interactive: true,
133
134
  ask: (question) => new Promise((resolve) => rl.question(question, resolve)),
134
135
  });
135
136
  }
136
137
  catch {
137
138
  console.error("Could not open the upgrade page automatically. Use the URL above.");
139
+ return false;
138
140
  }
139
141
  finally {
140
142
  rl.close();
@@ -149,11 +151,14 @@ export async function surfaceAgentCreatePlanLimit(planLimit, opts = {}) {
149
151
  if (!planLimit.checkoutUrl) {
150
152
  console.error(reason);
151
153
  console.error("Ask a company owner to upgrade before creating an agent.");
154
+ queueCliLimitNoticeExposure("agents", "refusal", opts.companyUid);
152
155
  return;
153
156
  }
154
157
  console.error(formatUpgradeRefusal(reason, planLimit.checkoutUrl, { urlOnly: !interactive }));
155
- if (interactive)
156
- await offerAgentCheckout(planLimit.checkoutUrl);
158
+ const exposure = queueCliLimitNoticeExposure("agents", "refusal", opts.companyUid);
159
+ if (interactive && await offerAgentCheckout(planLimit.checkoutUrl)) {
160
+ queueCliLimitNoticeEngagement(exposure);
161
+ }
157
162
  }
158
163
  /**
159
164
  * Resolve a closed-set option value, or exit(1) with a message naming the
@@ -1548,7 +1553,10 @@ export function registerAgentsCommand(program) {
1548
1553
  if (!err.agentCreatePlanLimit) {
1549
1554
  throw new Error("HQ Pro returned an unrecognized agent payment-required response.");
1550
1555
  }
1551
- await surfaceAgentCreatePlanLimit(err.agentCreatePlanLimit, opts);
1556
+ await surfaceAgentCreatePlanLimit(err.agentCreatePlanLimit, {
1557
+ ...opts,
1558
+ companyUid,
1559
+ });
1552
1560
  // Keep the payment-required status through normal CLI teardown.
1553
1561
  // Unlike process.exit(), this is testable and cannot be caught by
1554
1562
  // this action's outer error boundary as a generic failure.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { Command } from "commander";
11
11
  import { type FlagReader } from "../lib/checkpoint-ontology/plan.js";
12
- import { type DeleteVaultKey } from "../lib/checkpoint-ontology/merge-copies.js";
12
+ import { type DeleteVaultKey, type ListedPair } from "../lib/checkpoint-ontology/merge-copies.js";
13
13
  export declare const ONTOLOGY_PAYLOAD_FILE = "payload.json";
14
14
  /** The hq-flags read, scoped to the payload company. */
15
15
  export declare const registryFlagReader: FlagReader;
@@ -32,8 +32,11 @@ export interface MergeCopiesCommandOptions {
32
32
  delete?: boolean;
33
33
  variants?: boolean;
34
34
  limit?: number;
35
+ only?: string;
35
36
  json?: boolean;
36
37
  }
38
+ /** Reads a `--only` file: a JSON array of `{ copy, original }` company-relative paths. */
39
+ export declare function readListedPairs(file: string): ListedPair[];
37
40
  /**
38
41
  * `hq core ontology merge-copies`: merge `<slug> <n>.md` copies into their
39
42
  * original (`--apply`), then delete merged copies from the vault
@@ -153,8 +153,31 @@ function mergeCopiesSummary(report) {
153
153
  lines.push(` held ${plan.copy}: ${plan.held}`);
154
154
  for (const error of report.errors)
155
155
  lines.push(` error ${error.path}: ${error.error}`);
156
+ for (const entry of report.only_unmatched ?? [])
157
+ lines.push(` not found ${entry.copy} -> ${entry.original}`);
156
158
  return lines.join("\n");
157
159
  }
160
+ /** Reads a `--only` file: a JSON array of `{ copy, original }` company-relative paths. */
161
+ export function readListedPairs(file) {
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
165
+ }
166
+ catch (error) {
167
+ throw new OntologyUsageError(`ontology: --only file ${file} is not readable JSON: ${error.message}`);
168
+ }
169
+ if (!Array.isArray(parsed) ||
170
+ parsed.length === 0 ||
171
+ !parsed.every((entry) => {
172
+ if (!entry || typeof entry !== "object")
173
+ return false;
174
+ const record = entry;
175
+ return typeof record.copy === "string" && typeof record.original === "string";
176
+ })) {
177
+ throw new OntologyUsageError("ontology: --only must be a non-empty JSON array of { copy, original } paths");
178
+ }
179
+ return parsed.map((entry) => ({ copy: entry.copy, original: entry.original }));
180
+ }
158
181
  /**
159
182
  * `hq core ontology merge-copies`: merge `<slug> <n>.md` copies into their
160
183
  * original (`--apply`), then delete merged copies from the vault
@@ -171,6 +194,7 @@ export async function runOntologyMergeCopies(hqRoot, slug, options, deps = {}) {
171
194
  if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1)) {
172
195
  throw new OntologyUsageError("ontology: --limit must be a positive whole number");
173
196
  }
197
+ const only = options.only !== undefined ? readListedPairs(options.only) : undefined;
174
198
  const resolution = resolveOntologyCompany(hqRoot, slug);
175
199
  if (!resolution.ok)
176
200
  throw new OntologyUsageError(`ontology: company rejected: ${resolution.reason}`);
@@ -195,6 +219,7 @@ export async function runOntologyMergeCopies(hqRoot, slug, options, deps = {}) {
195
219
  now: deps.now?.() ?? new Date(),
196
220
  ...(options.variants ? { pairKind: "variants" } : {}),
197
221
  ...(options.limit !== undefined ? { limit: options.limit } : {}),
222
+ ...(only ? { only } : {}),
198
223
  ...(deleteVaultKey ? { deleteVaultKey } : {}),
199
224
  });
200
225
  return options.json ? JSON.stringify(report, null, 2) : mergeCopiesSummary(report);
@@ -248,6 +273,7 @@ export function registerCoreOntologyCommand(core) {
248
273
  .option("--delete", "delete fully merged copies from the vault by exact key (after sync pushed the merge)")
249
274
  .option("--variants", "merge two-file duplicate groups whose names differ only in case, spacing or punctuation (checkpoint.ontology.merge-variants)")
250
275
  .option("--limit <n>", "handle at most this many copies", (value) => Number(value))
276
+ .option("--only <file>", "handle only the copy -> original pairs listed in this JSON file")
251
277
  .option("--json", "print JSON")
252
278
  .option("--hq-root <path>", "HQ installation to operate on")
253
279
  .action(async (options) => {
@@ -153,10 +153,27 @@ export function createLaneResumeHandlers(deps) {
153
153
  if (opts.account !== undefined && !providerSessionPortable(lane.provider)) {
154
154
  throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
155
155
  }
156
- if (lane.state === "queued") {
156
+ if (lane.state === "queued" &&
157
+ (lane.queued_reason !== "worker_profile_changed" || opts.worker === undefined)) {
157
158
  throw new LanesError("lane_not_resumable", `Lane ${laneId} is queued; use hq lanes admit ${laneId} to request admission`, { lane_id: laneId, state: lane.state, admission_command: `hq lanes admit ${laneId}` });
158
159
  }
159
- const refusal = resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
160
+ const queuedProfileRecovery = lane.state === "queued" &&
161
+ lane.queued_reason === "worker_profile_changed" &&
162
+ opts.worker !== undefined;
163
+ if (queuedProfileRecovery && (opts.text !== undefined ||
164
+ opts.project !== undefined ||
165
+ opts.story !== undefined ||
166
+ opts.account !== undefined ||
167
+ opts.model !== undefined ||
168
+ opts.effort !== undefined ||
169
+ opts.keepAlive !== undefined ||
170
+ opts.meshCompany !== undefined ||
171
+ opts.compact === false)) {
172
+ throw new LanesError("worker_profile_recovery_options_invalid", "Queued worker-profile recovery accepts only --worker; rerun hq lanes resume after reconcile admits the lane", { lane_id: laneId });
173
+ }
174
+ const refusal = queuedProfileRecovery
175
+ ? null
176
+ : resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
160
177
  if (refusal !== null) {
161
178
  throw new LanesError(refusal.error, refusal.message, {
162
179
  lane_id: laneId,
@@ -206,6 +223,28 @@ export function createLaneResumeHandlers(deps) {
206
223
  if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
207
224
  validateResumeProfileModel(ctx, lane, resumeProfile.profile);
208
225
  }
226
+ if (lane.state === "queued" && lane.queued_reason === "worker_profile_changed") {
227
+ if (resumeProfile === undefined) {
228
+ throw new LanesError("worker_profile_required", `Lane ${laneId} needs --worker ${lane.worker_profile ?? "<profile>"} to recover its queued profile`, { lane_id: laneId, worker: lane.worker_profile ?? null });
229
+ }
230
+ const recovered = recordResumeSetup(ctx, lane, {
231
+ profile: resumeProfile,
232
+ ...(profileRepin !== undefined ? { profileRepin } : {}),
233
+ clearWorkerProfileChangedQueue: true,
234
+ });
235
+ printJson({
236
+ ok: true,
237
+ action: "resume",
238
+ lane_id: laneId,
239
+ delivery: "profile_repin",
240
+ delivery_status: "queued_for_reconcile",
241
+ profile_repin: profileRepin !== undefined
242
+ ? recovered.worker_profile_repin_history?.at(-1) ?? null
243
+ : null,
244
+ lane: recovered,
245
+ });
246
+ return;
247
+ }
209
248
  const automaticAccountSelection = opts.account === undefined
210
249
  ? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile, { repairUnavailableAccount: true })
211
250
  : undefined;
@@ -113,6 +113,8 @@ export interface RecordResumeSetupInput {
113
113
  meshCompany?: LaneCompanyRef;
114
114
  threadId?: string;
115
115
  profileRepin?: ResumeProfileRepinAuthorization;
116
+ /** Clear only the recoverable worker_profile_changed queue after its profile is revalidated. */
117
+ clearWorkerProfileChangedQueue?: boolean;
116
118
  }
117
119
  export declare function accountRequestKeepsCurrentLane(lane: LaneRecord, rawAccount: string): boolean;
118
120
  export declare function assertCurrentResumeAccountAvailable(ctx: Ctx, lane: LaneRecord): void;
@@ -3792,6 +3792,29 @@ function authorizeResumeProfileRepin(ctx, lane, profile, encodedContext) {
3792
3792
  : workerProfileHashMismatch(lane, profile);
3793
3793
  }
3794
3794
  }
3795
+ if (lane.state === "queued" && lane.queued_reason === "worker_profile_changed" && profile !== undefined) {
3796
+ // The hash is for the executing Outpost's profile copy, not the primary
3797
+ // host's copy. Only the lane senior (or its authorized owner dispatch) may
3798
+ // clear this hold after resolving that local copy.
3799
+ if (lane.host.kind !== "outpost" || lane.worker_profile !== profile.id) {
3800
+ throw new LanesError("worker_profile_hash_mismatch", `Queued profile recovery for lane ${lane.lane_id} must use its original Outpost worker profile`, {
3801
+ lane_id: lane.lane_id,
3802
+ worker: profile.id,
3803
+ lane_worker: lane.worker_profile ?? null,
3804
+ outpost: lane.host.kind === "outpost" ? lane.host.id : null,
3805
+ });
3806
+ }
3807
+ if (ssmOwnerDispatch) {
3808
+ if (context === undefined)
3809
+ throw workerProfileHashMismatch(lane, profile);
3810
+ }
3811
+ else {
3812
+ const callerAuthorization = callerSeniorAuthorization(ctx, lane.senior);
3813
+ if (!callerAuthorization.authorized) {
3814
+ throw workerProfileHashMismatch(lane, profile, callerAuthorization);
3815
+ }
3816
+ }
3817
+ }
3795
3818
  if (profile === undefined ||
3796
3819
  (lane.host.kind !== "outpost" && !(ssmOwnerDispatch && laneGrantTargetHostedHere(ctx, lane))) ||
3797
3820
  lane.worker_profile_hash === profile.profile.contentHash) {
@@ -4059,7 +4082,13 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
4059
4082
  remote.lane !== null &&
4060
4083
  typeof remote.lane === "object" &&
4061
4084
  !Array.isArray(remote.lane) &&
4062
- remote.lane.state === "queued")));
4085
+ remote.lane.state === "queued") ||
4086
+ (remote.delivery_status === "queued_for_reconcile" &&
4087
+ remote.lane !== null &&
4088
+ typeof remote.lane === "object" &&
4089
+ !Array.isArray(remote.lane) &&
4090
+ remote.lane.state === "queued" &&
4091
+ remote.lane.queued_reason !== "worker_profile_changed")));
4063
4092
  if (!resultConfirmed) {
4064
4093
  throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without a confirmed ${action} result for lane ${lane.lane_id}`, {
4065
4094
  lane_id: lane.lane_id,
@@ -5853,6 +5882,7 @@ function recordResumeSetup(ctx, lane, input) {
5853
5882
  input.keepAlive === undefined &&
5854
5883
  input.cliModel === undefined &&
5855
5884
  input.meshCompany === undefined &&
5885
+ input.clearWorkerProfileChangedQueue !== true &&
5856
5886
  lane.keep_alive_blocked_reason !== "terminal_state")
5857
5887
  return lane;
5858
5888
  return casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => {
@@ -5862,12 +5892,28 @@ function recordResumeSetup(ctx, lane, input) {
5862
5892
  if (input.profileRepin !== undefined) {
5863
5893
  assertResumeProfileRepinStillAuthorized(ctx, current, input.profileRepin);
5864
5894
  }
5895
+ if (input.clearWorkerProfileChangedQueue === true && (current.state !== "queued" ||
5896
+ current.queued_reason !== "worker_profile_changed" ||
5897
+ input.profile === undefined ||
5898
+ current.worker_profile !== input.profile.id ||
5899
+ (current.worker_profile_hash !== input.profile.profile.contentHash && input.profileRepin === undefined))) {
5900
+ throw new LanesError("lane_resume_setup_conflict", `Lane ${lane.lane_id} is no longer eligible for worker-profile recovery; run the recovery command again`, {
5901
+ lane_id: lane.lane_id,
5902
+ state: current.state,
5903
+ queued_reason: current.queued_reason ?? null,
5904
+ });
5905
+ }
5865
5906
  const next = { ...current };
5866
5907
  // An operator resume takes the lane out of the terminal state reconcile
5867
5908
  // recorded, so keep-alive may continue it again after this turn.
5868
5909
  if (next.keep_alive_blocked_reason === "terminal_state")
5869
5910
  next.keep_alive_blocked_reason = undefined;
5870
5911
  applyResumeProfileAndEffort(current, next, { ...input, at: ctx.now().toISOString() });
5912
+ if (input.clearWorkerProfileChangedQueue === true) {
5913
+ next.queued_reason = undefined;
5914
+ next.queued_details = undefined;
5915
+ next.admission_reserved_at = undefined;
5916
+ }
5871
5917
  if (input.cliModel !== undefined) {
5872
5918
  next.model = input.cliModel.trim();
5873
5919
  next.model_source = "cli";
@@ -1,2 +1,14 @@
1
+ import type { LaneDrainHookInput } from "./lib/lanes/dropbox.js";
2
+ interface DrainHookResult {
3
+ exitCode: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ type DropboxModule = typeof import("./lib/lanes/dropbox.js");
8
+ export interface DrainHookRunnerDeps {
9
+ /** Test seam for proving the steady-state path does not load the drainer. */
10
+ loadDropbox?: () => Promise<Pick<DropboxModule, "runDrainHook">>;
11
+ }
12
+ export declare function runLaneDrainHook(env: NodeJS.ProcessEnv, input?: LaneDrainHookInput, inputError?: string, deps?: DrainHookRunnerDeps): Promise<DrainHookResult>;
1
13
  export {};
2
14
  //# sourceMappingURL=lanes-drain-hook.d.ts.map
@@ -1,6 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { MESSAGE_REPLAY_STATE_NAME, readMessageReplayState, updateMessageReplayTranscript, writeMessageReplayState, } from "./lib/lanes/message-replay.js";
4
6
  function parseHookInput(text) {
5
7
  if (text.trim() === "")
6
8
  return {};
@@ -72,38 +74,34 @@ function dropboxMayNeedDrain(box) {
72
74
  }
73
75
  return false;
74
76
  }
75
- function replayMayNeedWork(box, turnId, input) {
77
+ function replayMayNeedWork(box, turnId, input, env) {
76
78
  if (!/^[A-Za-z0-9-]{1,80}$/.test(turnId))
77
79
  return false;
78
- if (input.transcript_path)
79
- return true;
80
- let text;
81
- const statePath = path.join(box, "message-compaction-replay.json");
80
+ const statePath = path.join(box, MESSAGE_REPLAY_STATE_NAME);
81
+ let state;
82
+ let hasLegacyFlag = false;
82
83
  try {
83
- text = fs.readFileSync(statePath, "utf8");
84
+ state = readMessageReplayState(statePath, turnId, () => {
85
+ hasLegacyFlag = true;
86
+ });
84
87
  }
85
88
  catch {
86
89
  // Keep the existing named replay error behavior for missing or unreadable state.
87
90
  return true;
88
91
  }
92
+ if (hasLegacyFlag || !input.transcript_path)
93
+ return hasLegacyFlag;
94
+ if (!state.provider)
95
+ return true;
89
96
  try {
90
- const parsed = JSON.parse(text);
91
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
97
+ const update = updateMessageReplayTranscript(state, input.transcript_path, state.provider, env);
98
+ if (update === "compacted")
92
99
  return true;
93
- const state = parsed;
94
- if (state.schema_version !== 1 ||
95
- state.turn_id !== turnId ||
96
- (state.last_compaction_marker !== null &&
97
- (!Number.isSafeInteger(state.last_compaction_marker) || Number(state.last_compaction_marker) < 0)) ||
98
- (state.transcript_offset !== undefined &&
99
- (!Number.isSafeInteger(state.transcript_offset) || Number(state.transcript_offset) < 0)) ||
100
- (state.transcript_path !== undefined && typeof state.transcript_path !== "string") ||
101
- state.flag_enabled !== undefined) {
102
- return true;
103
- }
100
+ writeMessageReplayState(statePath, state);
104
101
  return false;
105
102
  }
106
103
  catch {
104
+ // The full path preserves the existing named replay error reporting.
107
105
  return true;
108
106
  }
109
107
  }
@@ -125,52 +123,42 @@ function recordToolActivity(hqRoot, laneId) {
125
123
  return `hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`;
126
124
  }
127
125
  }
128
- function finish(result, inputError) {
126
+ function finish(result) {
129
127
  if (result.stdout)
130
128
  process.stdout.write(result.stdout);
131
- if (inputError || result.stderr)
132
- process.stderr.write(`${inputError}${result.stderr}`);
129
+ if (result.stderr)
130
+ process.stderr.write(result.stderr);
133
131
  process.exitCode = result.exitCode;
134
132
  }
135
- async function runFullPath(input, inputError) {
136
- const { runDrainHook } = await import("./lib/lanes/dropbox.js");
137
- finish(await runDrainHook(process.env, input), inputError);
133
+ async function runFullPath(env, input, deps) {
134
+ const loadDropbox = deps.loadDropbox ?? (() => import("./lib/lanes/dropbox.js"));
135
+ const { runDrainHook } = await loadDropbox();
136
+ return runDrainHook(env, input);
138
137
  }
139
- async function main() {
140
- const chunks = [];
141
- for await (const chunk of process.stdin)
142
- chunks.push(Buffer.from(chunk));
143
- const text = Buffer.concat(chunks).toString("utf8");
144
- let input = {};
145
- let inputError = "";
146
- try {
147
- input = parseHookInput(text);
148
- }
149
- catch (err) {
150
- inputError = `hq-lanes-drain: ${err instanceof Error ? err.message : String(err)}\n`;
151
- }
152
- const laneId = process.env.HQ_LANE_ID ?? "";
138
+ export async function runLaneDrainHook(env, input = {}, inputError = "", deps = {}) {
139
+ const withInputError = (result) => ({
140
+ ...result,
141
+ stderr: `${inputError}${result.stderr}`,
142
+ });
143
+ const laneId = env.HQ_LANE_ID ?? "";
153
144
  if (laneId === "") {
154
- finish({ exitCode: 0, stdout: "", stderr: "" }, inputError);
155
- return;
145
+ return withInputError({ exitCode: 0, stdout: "", stderr: "" });
156
146
  }
157
- const hqRoot = process.env.HQ_ROOT ?? "";
147
+ const hqRoot = env.HQ_ROOT ?? "";
158
148
  if (hqRoot === "" || !laneIdIsSafe(laneId)) {
159
- await runFullPath(input, inputError);
160
- return;
149
+ return withInputError(await runFullPath(env, input, deps));
161
150
  }
162
151
  const box = path.join(hqRoot, "workspace", "lanes-runs", laneId, "dropbox");
163
152
  let noWork = false;
164
153
  try {
165
154
  noWork = !dropboxMayNeedDrain(box) &&
166
- !replayMayNeedWork(box, process.env.HQ_LANE_TURN_ID ?? "", input);
155
+ !replayMayNeedWork(box, env.HQ_LANE_TURN_ID ?? "", input, env);
167
156
  }
168
157
  catch {
169
158
  // Filesystem uncertainty must use the full path so it retains its named errors.
170
159
  }
171
160
  if (!noWork) {
172
- await runFullPath(input, inputError);
173
- return;
161
+ return withInputError(await runFullPath(env, input, deps));
174
162
  }
175
163
  let stderr = recordToolActivity(hqRoot, laneId);
176
164
  const directories = [
@@ -186,14 +174,30 @@ async function main() {
186
174
  catch (err) {
187
175
  const message = `Failed to open drop box ${box}: ${err instanceof Error ? err.message : String(err)}`;
188
176
  stderr += `hq-lanes-drain: ${message}\n`;
189
- finish({ exitCode: 1, stdout: "", stderr }, inputError);
190
- return;
177
+ return withInputError({ exitCode: 1, stdout: "", stderr });
178
+ }
179
+ return withInputError({ exitCode: 0, stdout: "", stderr });
180
+ }
181
+ async function main() {
182
+ const chunks = [];
183
+ for await (const chunk of process.stdin)
184
+ chunks.push(Buffer.from(chunk));
185
+ const text = Buffer.concat(chunks).toString("utf8");
186
+ let input = {};
187
+ let inputError = "";
188
+ try {
189
+ input = parseHookInput(text);
191
190
  }
192
- finish({ exitCode: 0, stdout: "", stderr }, inputError);
191
+ catch (err) {
192
+ inputError = `hq-lanes-drain: ${err instanceof Error ? err.message : String(err)}\n`;
193
+ }
194
+ finish(await runLaneDrainHook(process.env, input, inputError));
195
+ }
196
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
197
+ main().catch((err) => {
198
+ process.stderr.write(`hq-lanes-drain: hook_runner_failed: ${err instanceof Error ? err.message : String(err)}\n`);
199
+ // A hook runner failure must not stop the provider's requested tool call.
200
+ process.exitCode = 0;
201
+ });
193
202
  }
194
- main().catch((err) => {
195
- process.stderr.write(`hq-lanes-drain: hook_runner_failed: ${err instanceof Error ? err.message : String(err)}\n`);
196
- // A hook runner failure must not stop the provider's requested tool call.
197
- process.exitCode = 0;
198
- });
199
203
  //# sourceMappingURL=lanes-drain-hook.js.map
@@ -42,7 +42,7 @@ export declare const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
42
42
  * names differ only in spacing, case or punctuation (CO-65).
43
43
  */
44
44
  export declare const MERGE_VARIANTS_FLAG = "checkpoint.ontology.merge-variants";
45
- export type CopySkipReason = "no_original" | "original_invalid" | "identity_differs" | "not_in_duplicate_group" | "group_size" | "type_differs" | "names_differ" | "numbered_copy";
45
+ export type CopySkipReason = "no_original" | "original_invalid" | "identity_differs" | "not_in_duplicate_group" | "group_size" | "type_differs" | "names_differ" | "numbered_copy" | "not_listed";
46
46
  export type PairKind = "numbered" | "variants";
47
47
  export interface CopySkip {
48
48
  copy: string;
@@ -55,6 +55,11 @@ export interface CopyPair {
55
55
  /** Company-relative path of the original it merges into. */
56
56
  original: string;
57
57
  }
58
+ /** One entry of a `--only` list: an exact copy -> original pair. */
59
+ export interface ListedPair {
60
+ copy: string;
61
+ original: string;
62
+ }
58
63
  export interface CopyPlan extends CopyPair {
59
64
  add_aliases: string[];
60
65
  /** Copy names the original lacks that another entity already holds, so they do not move. */
@@ -98,6 +103,8 @@ export interface MergeCopiesReport {
98
103
  path: string;
99
104
  error: string;
100
105
  }[];
106
+ /** Listed entries the finder did not produce. Absent unless `--only` was set. */
107
+ only_unmatched?: ListedPair[];
101
108
  /** Entity-file links whose target file does not exist. */
102
109
  broken_links_before: number;
103
110
  broken_links_after: number;
@@ -114,6 +121,8 @@ export interface MergeCopiesOptions {
114
121
  pairKind?: PairKind;
115
122
  /** Handle at most this many copies (sorted by path). */
116
123
  limit?: number;
124
+ /** Handle only these exact pairs; every other found pair is skipped as `not_listed`. */
125
+ only?: ListedPair[];
117
126
  now: Date;
118
127
  deleteVaultKey?: DeleteVaultKey;
119
128
  }