@indigoai-us/hq-cli 5.345.55 → 5.345.56

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +10 -1
  3. package/assets/scaffold/core/scripts/jobs-validate.sh +2 -1
  4. package/assets/scaffold/core/scripts/session-title.sh +18 -15
  5. package/dist/command-catalog.generated.d.ts +40 -0
  6. package/dist/command-catalog.generated.js +51 -0
  7. package/dist/commands/core-delegate-w1b.js +18 -5
  8. package/dist/commands/core-derive-trigger-facts-command.js +14 -0
  9. package/dist/commands/core-derive-trigger-facts-native.d.ts +1 -0
  10. package/dist/commands/core-derive-trigger-facts-native.js +20 -1
  11. package/dist/commands/doctor.d.ts +1 -0
  12. package/dist/commands/doctor.js +15 -2
  13. package/dist/commands/lanes-checks.d.ts +3 -0
  14. package/dist/commands/lanes-checks.js +34 -0
  15. package/dist/commands/lanes-session-start.d.ts +2 -7
  16. package/dist/commands/lanes-session-start.js +9 -29
  17. package/dist/commands/lanes-shared.d.ts +2 -1
  18. package/dist/commands/lanes.d.ts +3 -1
  19. package/dist/commands/lanes.js +117 -16
  20. package/dist/commands/members.d.ts +4 -0
  21. package/dist/commands/members.js +23 -2
  22. package/dist/lanes-drain-hook.js +6 -2
  23. package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
  24. package/dist/lib/doctor/checks/sync-health.js +47 -23
  25. package/dist/lib/lanes/dropbox.d.ts +4 -2
  26. package/dist/lib/lanes/dropbox.js +54 -9
  27. package/dist/lib/lanes/flag-keys.d.ts +8 -0
  28. package/dist/lib/lanes/flag-keys.js +8 -0
  29. package/dist/lib/lanes/hooks-install.d.ts +14 -11
  30. package/dist/lib/lanes/hooks-install.js +70 -50
  31. package/dist/lib/lanes/reaper.d.ts +58 -0
  32. package/dist/lib/lanes/reaper.js +403 -0
  33. package/dist/lib/lanes/spawn.d.ts +2 -0
  34. package/dist/lib/lanes/spawn.js +32 -2
  35. package/dist/lib/lanes/status-fields.d.ts +12 -0
  36. package/dist/lib/lanes/status-fields.js +189 -0
  37. package/dist/lib/lanes/types.js +1 -1
  38. package/dist/lib/lanes/worker-continuity.d.ts +18 -0
  39. package/dist/lib/lanes/worker-continuity.js +81 -0
  40. package/dist/lib/lanes/worker-process.d.ts +4 -0
  41. package/dist/lib/lanes/worker-process.js +77 -0
  42. package/dist/main.js +9 -3
  43. package/dist/utils/cli-telemetry.d.ts +4 -1
  44. package/dist/utils/cli-telemetry.js +8 -0
  45. package/dist/utils/jq-windows-compat.d.ts +13 -0
  46. package/dist/utils/jq-windows-compat.js +48 -0
  47. package/dist/utils/run-bundled-script.d.ts +3 -0
  48. package/dist/utils/run-bundled-script.js +9 -80
  49. package/dist/utils/windows-bash.d.ts +5 -0
  50. package/dist/utils/windows-bash.js +75 -0
  51. package/package.json +1 -1
@@ -38,9 +38,11 @@ import { laneDepthCap } from "../lib/lanes/depth-cap.js";
38
38
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
39
39
  import { runResidentCommandWithPackageLease } from "../utils/resident-package-preload.js";
40
40
  import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff, readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
41
+ import { readLaneStatusRowFields } from "../lib/lanes/status-fields.js";
41
42
  import { appendProjectFocusOverride, readProjectStoryFocusData, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
42
43
  import { appendLaneFocusOverride, assertFocusGuardAllows, makeFocusOverride } from "../lib/lanes/focus-guard.js";
43
- import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, } from "../lib/lanes/flag-keys.js";
44
+ import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, } from "../lib/lanes/flag-keys.js";
45
+ import { findWorkerContinuityLane, sameLaneHost, workerContinuityPrompt } from "../lib/lanes/worker-continuity.js";
44
46
  import { configuredCodexRolloutHomes, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
45
47
  import { isControllerAliasOutpostId } from "../lib/lanes/admission-host.js";
46
48
  import { runLeadAutopilotPass } from "../lib/lanes/autopilot.js";
@@ -462,6 +464,7 @@ function resolveDeps(deps = {}) {
462
464
  focusGuardEnabled: deps.focusGuardEnabled,
463
465
  isSsmOwnerDispatch: deps.isSsmOwnerDispatch ?? (() => isSsmRunCommandProcess()),
464
466
  withGrantHireLocks: deps.withGrantHireLocks ?? withLaneHireLocks,
467
+ withAdmissionLock: deps.withAdmissionLock ?? withLaneAdmissionLock,
465
468
  mergeReviewEnabled: deps.mergeReviewEnabled,
466
469
  mergeHoldEnabled: deps.mergeHoldEnabled,
467
470
  };
@@ -3365,6 +3368,16 @@ async function runCreate(ctx, opts) {
3365
3368
  const provider = explicitProvider ??
3366
3369
  profile?.meta.provider ??
3367
3370
  parseLaneProvider("codex");
3371
+ const continuityEnabled = opts.worker !== undefined && await readLanesRegistryFlagFailOpen(LANE_WORKER_CONTINUITY_FLAG, { company: company ?? meshCompany, env: ctx.env });
3372
+ let workerContinuity = continuityEnabled
3373
+ ? findWorkerContinuityLane(loadAllLanes(hqRoot), {
3374
+ senior,
3375
+ worker: resolvedWorker?.id ?? opts.worker,
3376
+ provider,
3377
+ company: company ?? meshCompany,
3378
+ freshThread: opts.freshThread,
3379
+ })
3380
+ : { resumable: false };
3368
3381
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === provider;
3369
3382
  const cliModel = parseCliModel(opts.model);
3370
3383
  // Validated here because `claude --effort <unknown>` only warns and runs.
@@ -3739,6 +3752,24 @@ async function runCreate(ctx, opts) {
3739
3752
  // The lane id is created in `persist`; its Work Mesh thread is ensured
3740
3753
  // immediately afterward from that stable identity.
3741
3754
  const persist = async () => {
3755
+ const host = placementHostOverride ?? (outpostId !== undefined
3756
+ ? { kind: "outpost", id: outpostId }
3757
+ : { kind: "local", id: ctx.hostname() });
3758
+ // Repeat the busy check while holding provider admission serialization so
3759
+ // two concurrent creates cannot both claim one worker's provider thread.
3760
+ if (continuityEnabled) {
3761
+ workerContinuity = findWorkerContinuityLane(loadAllLanes(hqRoot), {
3762
+ senior,
3763
+ worker: resolvedWorker?.id ?? opts.worker,
3764
+ provider,
3765
+ company: company ?? meshCompany,
3766
+ freshThread: opts.freshThread,
3767
+ });
3768
+ if (workerContinuity.resumable && workerContinuity.previous &&
3769
+ !sameLaneHost(workerContinuity.previous.host, host)) {
3770
+ workerContinuity = { previous: workerContinuity.previous, resumable: false, fallbackReason: "host_mismatch" };
3771
+ }
3772
+ }
3742
3773
  let admission;
3743
3774
  let validation;
3744
3775
  if (!outpostId && provider === "claude" && explicitBalanceAccount === undefined &&
@@ -3842,9 +3873,6 @@ async function runCreate(ctx, opts) {
3842
3873
  throw admission.error;
3843
3874
  }
3844
3875
  }
3845
- const host = placementHostOverride ?? (outpostId !== undefined
3846
- ? { kind: "outpost", id: outpostId }
3847
- : { kind: "local", id: ctx.hostname() });
3848
3876
  const queuedReason = admission.action === "queue" ? admission.reason : undefined;
3849
3877
  const admissionReservedAt = admission.action === "start" ? ctx.now().toISOString() : undefined;
3850
3878
  const laneInit = {
@@ -3888,6 +3916,18 @@ async function runCreate(ctx, opts) {
3888
3916
  keep_alive: keepAlive,
3889
3917
  ...(keepAlive ? { keep_alive_opted_in_at: createdAt } : {}),
3890
3918
  provider,
3919
+ ...(workerContinuity.resumable && workerContinuity.previous
3920
+ ? {
3921
+ provider_session_id: workerContinuity.previous.provider_session_id,
3922
+ last_message_at: workerContinuity.previous.last_message_at ?? workerContinuity.previous.created_at,
3923
+ pending_resume: {
3924
+ session_id: workerContinuity.previous.provider_session_id,
3925
+ at: createdAt,
3926
+ trigger: "resume",
3927
+ resumed_from: workerContinuity.previous.state,
3928
+ },
3929
+ }
3930
+ : {}),
3891
3931
  provider_source: explicitProvider !== undefined
3892
3932
  ? "cli"
3893
3933
  : profile?.meta.provider !== undefined
@@ -3957,12 +3997,7 @@ async function runCreate(ctx, opts) {
3957
3997
  return { lane, admission, validation };
3958
3998
  };
3959
3999
  let persisted;
3960
- if (outpostId) {
3961
- persisted = await persist();
3962
- }
3963
- else {
3964
- persisted = await withLaneAdmissionLock(hqRoot, provider, persist, ctx.sleep);
3965
- }
4000
+ persisted = await ctx.withAdmissionLock(hqRoot, provider, persist, ctx.sleep);
3966
4001
  let lane = persisted.lane;
3967
4002
  const { admission, validation } = persisted;
3968
4003
  if (focusAssessment.enabled && opts.focusOverride !== undefined) {
@@ -4052,10 +4087,25 @@ async function runCreate(ctx, opts) {
4052
4087
  ensureDir(path.join(dest, "dropbox"));
4053
4088
  ensureDir(path.join(dest, "questions"));
4054
4089
  const taskBrief = fs.readFileSync(briefAbs, "utf8");
4055
- const briefText = resolvedWorker
4090
+ const previousResult = workerContinuity.previous
4091
+ ? readLatestEnvelopeRecord(hqRoot, workerContinuity.previous.lane_id)
4092
+ : undefined;
4093
+ const briefText = `${workerContinuity.resumable && workerContinuity.previous
4094
+ ? workerContinuityPrompt(workerContinuity.previous, previousResult?.envelope.summary, previousResult?.ref)
4095
+ : ""}${resolvedWorker
4056
4096
  ? composeWorkerBrief(resolvedWorker, taskBrief)
4057
- : composeGeneralBrief(taskBrief);
4097
+ : composeGeneralBrief(taskBrief)}`;
4058
4098
  fs.writeFileSync(path.join(dest, "brief.md"), briefText);
4099
+ if (workerContinuity.resumable && lane.pending_resume) {
4100
+ const newBriefPath = path.join(dest, "brief.md");
4101
+ lane = casUpdateLane(hqRoot, lane.lane_id, (current) => ({
4102
+ ...current,
4103
+ pending_resume: current.pending_resume ? {
4104
+ ...current.pending_resume,
4105
+ text: `The new brief at ${newBriefPath} is the instruction for this turn. Read and follow it.`,
4106
+ } : undefined,
4107
+ }));
4108
+ }
4059
4109
  if (timeoutSecs !== undefined) {
4060
4110
  writeJsonFile(timeoutPath(hqRoot, lane.lane_id), {
4061
4111
  timeout_secs: timeoutSecs,
@@ -4122,6 +4172,16 @@ async function runCreate(ctx, opts) {
4122
4172
  stories: laneStories(started),
4123
4173
  story_warning: combinedStoryWarning(started, createStoryWarnings),
4124
4174
  thread_id: started.thread_id ?? null,
4175
+ ...(continuityEnabled ? {
4176
+ worker_continuity: workerContinuity.resumable
4177
+ ? { action: "resume", previous_lane_id: workerContinuity.previous?.lane_id }
4178
+ : {
4179
+ action: "fresh",
4180
+ ...(workerContinuity.previous ? { previous_lane_id: workerContinuity.previous.lane_id } : {}),
4181
+ ...(opts.freshThread ? { reason: "fresh_thread_requested" } : {}),
4182
+ ...(workerContinuity.fallbackReason ? { reason: workerContinuity.fallbackReason } : {}),
4183
+ },
4184
+ } : {}),
4125
4185
  senior: started.senior,
4126
4186
  depth: started.depth,
4127
4187
  timeout_secs: timeoutSecs ?? null,
@@ -5343,6 +5403,24 @@ async function runPickup(ctx, opts) {
5343
5403
  lane: started,
5344
5404
  });
5345
5405
  }
5406
+ async function laneStatusFieldsForCommand(ctx, hqRoot, lane, now) {
5407
+ const resolved = resolveLaneCompany(hqRoot, lane).company;
5408
+ let enabled = true;
5409
+ try {
5410
+ enabled = await readLanesRegistryFlagFailOpen(LANE_STATUS_ROW_FIELDS_FLAG, {
5411
+ ...(resolved ? { company: resolved } : {}),
5412
+ env: ctx.env,
5413
+ });
5414
+ }
5415
+ catch (err) {
5416
+ process.stderr.write(`hq lanes: status row fields flag read failed open: ${err instanceof Error ? err.message : String(err)}\n`);
5417
+ }
5418
+ if (!enabled)
5419
+ return {};
5420
+ const host = reconcileHostFromCtx(ctx);
5421
+ const local = laneRunsHere(lane, host);
5422
+ return { ...readLaneStatusRowFields(hqRoot, lane, now.getTime(), local) };
5423
+ }
5346
5424
  async function runList(ctx, opts) {
5347
5425
  if (opts.json && opts.human) {
5348
5426
  throw new LanesError("conflicting_output_format", "Use either --json or --human for hq lanes list", { json: true, human: true });
@@ -5365,7 +5443,7 @@ async function runList(ctx, opts) {
5365
5443
  new Set(opts.hierarchy).size !== opts.hierarchy.length)) {
5366
5444
  throw new LanesError("lane_list_hierarchy_invalid", "--hierarchy must be repeated with distinct, non-empty lane ids without surrounding whitespace", {});
5367
5445
  }
5368
- if (opts.snapshot && (opts.human || opts.company || opts.project || opts.story || opts.state || opts.senior)) {
5446
+ if (opts.snapshot && (opts.human || opts.company || opts.project || opts.story || opts.state || opts.senior || opts.worker)) {
5369
5447
  throw new LanesError("lane_list_snapshot_invalid", "--snapshot only supports owner lane filters and JSON output", {});
5370
5448
  }
5371
5449
  if (opts.hierarchy !== undefined && !opts.snapshot) {
@@ -5414,21 +5492,29 @@ async function runList(ctx, opts) {
5414
5492
  }
5415
5493
  if (senior && seniorKey(lane.senior) !== seniorKey(senior))
5416
5494
  return false;
5495
+ if (opts.worker && lane.worker !== opts.worker)
5496
+ return false;
5417
5497
  return true;
5418
5498
  };
5419
5499
  const selectLanes = () => laneSource
5420
5500
  .filter((lane) => matchesLaneFilters(lane, true))
5421
5501
  .sort(compareLaneQueueOrder);
5422
5502
  let selectedLanes = selectLanes();
5503
+ const statusFieldsByLane = new Map();
5504
+ const statusNow = ctx.now();
5505
+ await Promise.all(selectedLanes.map(async (lane) => {
5506
+ statusFieldsByLane.set(lane.lane_id, await laneStatusFieldsForCommand(ctx, hqRoot, lane, statusNow));
5507
+ }));
5423
5508
  if (opts.snapshot) {
5424
5509
  printJson({
5425
5510
  ok: true,
5426
5511
  action: "list",
5427
5512
  lanes: selectedLanes.map((lane) => {
5513
+ const fields = statusFieldsByLane.get(lane.lane_id) ?? {};
5428
5514
  if (!isTerminalLaneState(lane.state))
5429
- return lane;
5515
+ return { ...lane, ...fields };
5430
5516
  const envelope = readLatestEnvelopeRecord(hqRoot, lane.lane_id)?.envelope;
5431
- return envelope?.summary ? { ...lane, envelope_summary: envelope.summary } : lane;
5517
+ return { ...lane, ...fields, ...(envelope?.summary ? { envelope_summary: envelope.summary } : {}) };
5432
5518
  }),
5433
5519
  });
5434
5520
  return;
@@ -5532,6 +5618,7 @@ async function runList(ctx, opts) {
5532
5618
  !laneRunsHere(lane, host);
5533
5619
  return {
5534
5620
  ...lane,
5621
+ ...statusFieldsByLane.get(lane.lane_id),
5535
5622
  ...hireByLane.get(lane.lane_id),
5536
5623
  ...(remoteRefresh.unverifiedRemote.has(lane.lane_id) ? { unverified_remote: true } : {}),
5537
5624
  stories: laneStories(lane),
@@ -5901,7 +5988,10 @@ async function runShow(ctx, laneId, hasPendingQuestionForLane, resolveLaneTarget
5901
5988
  })
5902
5989
  .slice(0, 20)
5903
5990
  .map(({ repo, pr, head_sha, recorded_at }) => ({ repo, pr, head_sha, recorded_at }));
5904
- const laneForShow = { ...lane };
5991
+ const laneForShow = {
5992
+ ...lane,
5993
+ ...await laneStatusFieldsForCommand(ctx, hqRoot, storedLane, ctx.now()),
5994
+ };
5905
5995
  delete laneForShow.resume_history;
5906
5996
  printJson({
5907
5997
  ok: true,
@@ -9248,6 +9338,15 @@ export function registerLanesCommand(parent, deps = {}) {
9248
9338
  presenceConnected: ctx.presenceConnected ?? false,
9249
9339
  deps: ctx.livenessDeps,
9250
9340
  }),
9341
+ stopEnabled: (lane) => {
9342
+ const resolution = resolveLaneCompany(ctx.hqRoot(), lane);
9343
+ const company = resolution.company ?? undefined;
9344
+ return readLanesRegistryFlag(LANE_REAP_STOP_FLAG, {
9345
+ company,
9346
+ ...(company ? { companyUidSource: laneCompanyUidSource(resolution.source) } : {}),
9347
+ env: ctx.env,
9348
+ });
9349
+ },
9251
9350
  }, wrap);
9252
9351
  registerLaneDropboxRepairCommand(lanes, {
9253
9352
  hqRoot: ctx.hqRoot,
@@ -9313,6 +9412,7 @@ export function registerLanesCommand(parent, deps = {}) {
9313
9412
  .option("--additional-story <id>", "Additional story carried by this lane; repeatable and discouraged when active", collectRepeatable)
9314
9413
  .requiredOption("--brief-file <path>", "Path to the lane brief")
9315
9414
  .option("--worker <name>", "HQ worker or reusable profile id")
9415
+ .option("--fresh-thread", "Skip worker continuity and start a cold provider session")
9316
9416
  .option("--provider <name>", "claude | codex | grok")
9317
9417
  .option("--account <id>", "Provider account id")
9318
9418
  .option("--model <id>", "Model id for the provider CLI")
@@ -9412,6 +9512,7 @@ export function registerLanesCommand(parent, deps = {}) {
9412
9512
  .option("--story <id>", "Filter by story")
9413
9513
  .option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
9414
9514
  .option("--senior <ref>", "Filter by senior ref")
9515
+ .option("--worker <id>", "Filter by worker id")
9415
9516
  .option("--lane <id>", "Filter by lane id (repeatable)", (value, previous = []) => [...previous, value], [])
9416
9517
  .addOption(new Option("--lane-ulid <ulid>", "Filter by lane ULID for internal owner lookup").hideHelp()
9417
9518
  .argParser((value, previous = []) => [...previous, value]))
@@ -48,6 +48,8 @@ export interface InviteOptions {
48
48
  companyUid: string;
49
49
  callerUid: string;
50
50
  token: string;
51
+ /** Reports only whether the request returned a 2xx response. */
52
+ onRequestResult?: (ok: boolean) => void;
51
53
  }
52
54
  /**
53
55
  * Outcome of `hq members invite`. Two shapes depending on server schema:
@@ -96,6 +98,8 @@ export interface ResendInviteOptions {
96
98
  companyUid: string;
97
99
  callerUid: string;
98
100
  token: string;
101
+ /** Reports only whether the request returned a 2xx response. */
102
+ onRequestResult?: (ok: boolean) => void;
99
103
  }
100
104
  /**
101
105
  * Outcome of `hq members invite --resend`. No new membership row was
@@ -3,6 +3,7 @@ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
3
3
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
4
4
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
5
5
  import { isPlanGateError } from "../utils/plan-gate-error.js";
6
+ import { queueCliFeatureUsed } from "../utils/cli-telemetry.js";
6
7
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
7
8
  const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
9
  const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/i;
@@ -107,6 +108,7 @@ export async function inviteMember(options) {
107
108
  body,
108
109
  });
109
110
  if (!res.ok) {
111
+ options.onRequestResult?.(false);
110
112
  const err = (await res.json().catch(() => ({})));
111
113
  throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
112
114
  }
@@ -116,6 +118,7 @@ export async function inviteMember(options) {
116
118
  throw new Error(`Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
117
119
  "This is a server-side regression — file an issue.");
118
120
  }
121
+ options.onRequestResult?.(true);
119
122
  // Two server schemas in the wild:
120
123
  // - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
121
124
  // the invitee redeems via `hq://accept/{token}`.
@@ -177,6 +180,7 @@ export async function resendInvite(options) {
177
180
  },
178
181
  });
179
182
  if (!res.ok) {
183
+ options.onRequestResult?.(false);
180
184
  const err = (await res.json().catch(() => ({})));
181
185
  throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
182
186
  }
@@ -184,6 +188,7 @@ export async function resendInvite(options) {
184
188
  if (data.resent !== true) {
185
189
  throw new Error("Resend endpoint returned 2xx without `resent: true` — the connected hq-pro likely doesn't support `resend: true` yet. Upgrade hq-pro or re-invite without --resend.");
186
190
  }
191
+ options.onRequestResult?.(true);
187
192
  return {
188
193
  resent: true,
189
194
  emailSent: data.emailSent === true,
@@ -436,6 +441,7 @@ export function registerMembersCommand(program) {
436
441
  let token;
437
442
  let companyUid;
438
443
  let companySlug;
444
+ let outcome = "failure";
439
445
  try {
440
446
  token = (await resolveVaultCredential()).token;
441
447
  companySlug = members.opts().company;
@@ -448,6 +454,9 @@ export function registerMembersCommand(program) {
448
454
  companyUid,
449
455
  callerUid,
450
456
  token,
457
+ onRequestResult: (ok) => {
458
+ outcome = ok ? "success" : "failure";
459
+ },
451
460
  });
452
461
  console.log(chalk.green(`Re-fired invite email for ${target.toLowerCase()} (existing pending row left untouched)`));
453
462
  console.log();
@@ -472,6 +481,9 @@ export function registerMembersCommand(program) {
472
481
  companyUid,
473
482
  callerUid,
474
483
  token,
484
+ onRequestResult: (ok) => {
485
+ outcome = ok ? "success" : "failure";
486
+ },
475
487
  });
476
488
  const agentTarget = detectTarget(target)?.isAgent === true;
477
489
  console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
@@ -612,10 +624,19 @@ export function registerMembersCommand(program) {
612
624
  }
613
625
  if (err instanceof InviteHttpError) {
614
626
  console.error(chalk.red(formatInviteHttpError(err.status, err.message, err.code)));
615
- process.exit(1);
627
+ process.exitCode = 1;
628
+ return;
616
629
  }
617
630
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
618
- process.exit(1);
631
+ process.exitCode = 1;
632
+ return;
633
+ }
634
+ finally {
635
+ queueCliFeatureUsed({
636
+ kind: "members_invite",
637
+ outcome,
638
+ ...(companyUid ? { companyUid } : {}),
639
+ });
619
640
  }
620
641
  });
621
642
  members
@@ -17,7 +17,7 @@ function parseHookInput(text) {
17
17
  throw new Error("invalid_hook_input_shape: expected a JSON object");
18
18
  }
19
19
  const value = parsed;
20
- for (const key of ["transcript_path", "session_id", "hook_event_name"]) {
20
+ for (const key of ["transcript_path", "session_id", "hook_event_name", "hookEventName"]) {
21
21
  if (value[key] !== undefined && value[key] !== null && typeof value[key] !== "string") {
22
22
  throw new Error(`invalid_hook_input_field: ${key} must be a string`);
23
23
  }
@@ -25,7 +25,11 @@ function parseHookInput(text) {
25
25
  return {
26
26
  ...(typeof value.transcript_path === "string" ? { transcript_path: value.transcript_path } : {}),
27
27
  ...(typeof value.session_id === "string" ? { session_id: value.session_id } : {}),
28
- ...(typeof value.hook_event_name === "string" ? { hook_event_name: value.hook_event_name } : {}),
28
+ ...(typeof value.hook_event_name === "string"
29
+ ? { hook_event_name: value.hook_event_name }
30
+ : typeof value.hookEventName === "string"
31
+ ? { hookEventName: value.hookEventName }
32
+ : {}),
29
33
  };
30
34
  }
31
35
  function laneIdIsSafe(laneId) {
@@ -59,6 +59,8 @@ export interface SyncManifestUploadStatus {
59
59
  snapshotId: string | null;
60
60
  sequence: number;
61
61
  baseUsable: boolean;
62
+ /** Local company slug for company-scoped remediation; absent for personal. */
63
+ companySlug?: string;
62
64
  }
63
65
  /** Injectable dependencies — defaults are the real collectors. */
64
66
  export interface SyncHealthDeps {
@@ -32,6 +32,7 @@ import * as path from "node:path";
32
32
  // ../../hq-cloud-manifest.js.
33
33
  import * as hqCloud from "@indigoai-us/hq-cloud";
34
34
  import { loadManifestExports, MANIFEST_UNAVAILABLE_REASON, } from "../../hq-cloud-manifest.js";
35
+ import { readCompaniesManifestMap } from "../../work-context/repo-remote.js";
35
36
  import { CLI_VERSION } from "../../../cli-version.js";
36
37
  import { readSyncVersion } from "../../../utils/feedback-versions.js";
37
38
  import { readHqVersion } from "../../../utils/pack-contributions.js";
@@ -52,9 +53,9 @@ function withDefaults(deps, hqRoot) {
52
53
  return {
53
54
  ...deps,
54
55
  runtime: deps.runtime ?? (() => []),
55
- manifestStatuses: deps.manifestStatuses ?? defaultManifestStatuses,
56
+ manifestStatuses: deps.manifestStatuses ?? ((journals) => defaultManifestStatuses(hqRoot, journals)),
56
57
  manifestAvailable: deps.manifestAvailable ?? (() => loadManifestExports() !== null),
57
- unresolvedScopes: deps.unresolvedScopes ?? defaultUnresolvedScopes,
58
+ unresolvedScopes: deps.unresolvedScopes ?? ((journals) => defaultUnresolvedScopes(hqRoot, journals)),
58
59
  localCompanyExists: deps.localCompanyExists ??
59
60
  ((slug) => defaultLocalCompanyExists(hqRoot, slug)),
60
61
  };
@@ -78,15 +79,35 @@ function defaultLocalCompanyExists(hqRoot, slug) {
78
79
  return false;
79
80
  }
80
81
  }
81
- /**
82
- * Company journal shards are unresolvable by the default collector: the
83
- * snapshot store keys company scopes by `companyUid`, which `listJournals()`
84
- * does not carry. Naming them here keeps them visible.
85
- */
86
- function defaultUnresolvedScopes(journals) {
87
- return journals
88
- .filter((entry) => !NON_COMPANY_JOURNAL_SLUGS.has(entry.slug))
89
- .map((entry) => entry.slug);
82
+ /** Resolve locally-known company journal slugs to the UID used by snapshots. */
83
+ function resolvedCompanyScopes(hqRoot, journals) {
84
+ const companies = readCompaniesManifestMap(hqRoot);
85
+ const seen = new Set();
86
+ const scopes = [];
87
+ for (const { slug } of journals) {
88
+ if (NON_COMPANY_JOURNAL_SLUGS.has(slug) ||
89
+ !isSafeCompanySlug(slug) ||
90
+ !defaultLocalCompanyExists(hqRoot, slug) ||
91
+ seen.has(slug)) {
92
+ continue;
93
+ }
94
+ seen.add(slug);
95
+ const uid = companies?.[slug]?.cloud_uid;
96
+ if (typeof uid === "string" && /^cmp_[A-Za-z0-9]{3,128}$/.test(uid.trim())) {
97
+ scopes.push({ kind: "company", companyUid: uid.trim(), slug });
98
+ }
99
+ }
100
+ return scopes;
101
+ }
102
+ function defaultUnresolvedScopes(hqRoot, journals) {
103
+ const resolved = new Set(resolvedCompanyScopes(hqRoot, journals).map((scope) => scope.slug));
104
+ return [
105
+ ...new Set(journals
106
+ .filter((entry) => !NON_COMPANY_JOURNAL_SLUGS.has(entry.slug) &&
107
+ defaultLocalCompanyExists(hqRoot, entry.slug) &&
108
+ !resolved.has(entry.slug))
109
+ .map((entry) => entry.slug)),
110
+ ];
90
111
  }
91
112
  function defaultVersions(hqRoot) {
92
113
  return {
@@ -119,21 +140,15 @@ const DEFAULT_DEPS = {
119
140
  // than no check.
120
141
  journals: () => hqCloud.listJournals(),
121
142
  now: () => new Date(),
122
- manifestStatuses: defaultManifestStatuses,
123
143
  runtime: defaultSyncRuntimeResults,
124
144
  };
125
145
  /**
126
146
  * Map the machine's journal shards onto the manifest scopes hq-cloud keys its
127
- * snapshot store by, then read each one's last upload.
128
- *
129
- * The journal slug `personal` is the personal tree; every other slug is a
130
- * company. Note the snapshot store keys company scopes by `companyUid`, which
131
- * the journal listing does not carry — so a company whose uid we cannot name
132
- * is simply not reported here rather than reported wrongly. That is the honest
133
- * outcome: this check exists to tell an operator when a manifest STOPPED being
134
- * uploaded, and inventing a scope key would make it lie in both directions.
147
+ * snapshot store by, then read each one's last upload. Company journal rows
148
+ * carry only slugs, so resolve their UIDs from the local companies manifest;
149
+ * a missing or malformed UID stays explicitly unresolved below.
135
150
  */
136
- function defaultManifestStatuses(journals) {
151
+ function defaultManifestStatuses(hqRoot, journals) {
137
152
  // Both the legacy `personal` shard and the current personal-vault
138
153
  // pseudo-slug name the SAME personal tree; a machine mid-migration has both
139
154
  // on disk, so collapse them to a single personal scope rather than asking
@@ -141,6 +156,7 @@ function defaultManifestStatuses(journals) {
141
156
  const scopes = journals.some((entry) => NON_COMPANY_JOURNAL_SLUGS.has(entry.slug))
142
157
  ? [{ kind: "personal" }]
143
158
  : [];
159
+ scopes.push(...resolvedCompanyScopes(hqRoot, journals));
144
160
  if (scopes.length === 0)
145
161
  return [];
146
162
  // Absent on an older hq-cloud; the NA row is emitted by `manifestResults`
@@ -148,7 +164,15 @@ function defaultManifestStatuses(journals) {
148
164
  const exports = loadManifestExports();
149
165
  if (!exports)
150
166
  return [];
151
- return exports.readManifestUploadStatus(exports.getStateDir(), scopes);
167
+ const companySlugsByUid = new Map(scopes
168
+ .filter((scope) => scope.kind === "company")
169
+ .map((scope) => [scope.companyUid, scope.slug]));
170
+ const statuses = exports.readManifestUploadStatus(exports.getStateDir(), scopes);
171
+ return statuses.map((status) => {
172
+ const companyUid = /^company:(cmp_[A-Za-z0-9]{3,128})$/.exec(status.scopeKey)?.[1];
173
+ const companySlug = companyUid ? companySlugsByUid.get(companyUid) : undefined;
174
+ return companySlug ? { ...status, companySlug } : status;
175
+ });
152
176
  }
153
177
  /** The legacy journal slug that named the personal (non-company) tree. */
154
178
  const PERSONAL_SCOPE_SLUG = "personal";
@@ -526,7 +550,7 @@ function manifestResults(deps, journals) {
526
550
  checkId,
527
551
  reasonCode: "stale-threshold",
528
552
  message: `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt}) — the daily audit pass is not running.`,
529
- remediation: "Run `hq sync manifest` and check HQ_SYNC_MANIFEST_DISABLED and the sync runner.",
553
+ remediation: `Run \`hq sync manifest${entry.companySlug ? ` --scope ${entry.companySlug}` : ""}\` and check HQ_SYNC_MANIFEST_DISABLED and the sync runner.`,
530
554
  };
531
555
  }
532
556
  return {
@@ -53,12 +53,14 @@ export interface DrainDeps {
53
53
  }
54
54
  export interface LaneDrainHookInput {
55
55
  transcript_path?: string | null;
56
- session_id?: string;
57
56
  hook_event_name?: string;
57
+ hookEventName?: string;
58
+ session_id?: string;
58
59
  }
59
60
  export interface RunDrainHookDeps {
60
61
  readLane?: typeof tryReadLane;
61
62
  probeCompactionsSince?: typeof import("./compaction.js").probeProviderCompactionsSince;
63
+ readLifecycleDeliveryFlag?: () => Promise<boolean>;
62
64
  }
63
65
  export declare function mintClaimId(): string;
64
66
  export declare function assertSafeLaneId(laneId: string): void;
@@ -102,7 +104,7 @@ export declare function formatOperatorInstruction(messages: DropboxMessage[]): s
102
104
  export declare function formatMonitorEvent(message: DropboxMessage): string;
103
105
  /** Keep event order while framing monitor output separately from operator messages. */
104
106
  export declare function formatDropboxMessages(messages: DropboxMessage[]): string;
105
- export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "UserPromptSubmit", leadingNote?: string): string;
107
+ export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "PostToolUse" | "UserPromptSubmit", leadingNote?: string): string;
106
108
  export interface DrainHookResult {
107
109
  exitCode: number;
108
110
  stdout: string;
@@ -14,6 +14,8 @@ import { LanesError } from "./errors.js";
14
14
  import { MESSAGE_COMPACTION_REPLAY_NOTE, MESSAGE_REPLAY_STATE_NAME, readMessageReplayState, updateMessageReplayTranscript, writeMessageReplayState, } from "./message-replay.js";
15
15
  import { LANES_RUNS_REL, readProcStarttime } from "./spawn.js";
16
16
  import { tryReadLane } from "./store.js";
17
+ import { readLanesRegistryFlagFailOpen } from "./registry-flag.js";
18
+ import { LANE_DROPBOX_LIFECYCLE_DELIVERY_FLAG } from "./flag-keys.js";
17
19
  export const DROPBOX_DIR = "dropbox";
18
20
  export const INBOX_NAME = "inbox.jsonl";
19
21
  export const DRAINING_DIR = "draining";
@@ -426,6 +428,9 @@ export function hookAdditionalContextJson(messages, hookEventName = "PreToolUse"
426
428
  },
427
429
  })}\n`;
428
430
  }
431
+ function hookStopBlockJson(reason) {
432
+ return `${JSON.stringify({ decision: "block", reason })}\n`;
433
+ }
429
434
  function replayStatePath(hqRoot, laneId) {
430
435
  return path.join(dropboxDir(hqRoot, laneId), MESSAGE_REPLAY_STATE_NAME);
431
436
  }
@@ -465,14 +470,47 @@ export async function runDrainHook(env, input = {}, deps = {}) {
465
470
  stderr: "hq-lanes-drain: HQ_ROOT is unset while HQ_LANE_ID is set\n",
466
471
  };
467
472
  }
468
- // Liveness first: a failed drain must still leave proof the worker called
469
- // a tool. A failure here is reported, never fatal to delivery.
470
- let stderr = "";
471
- try {
472
- recordToolActivity(hqRoot, laneId);
473
+ const hookEvent = input.hook_event_name ?? input.hookEventName ?? "PreToolUse";
474
+ if (hookEvent !== "PreToolUse" && hookEvent !== "PostToolUse" && hookEvent !== "Stop") {
475
+ return { exitCode: 0, stdout: "", stderr: "" };
473
476
  }
474
- catch (err) {
475
- stderr += `hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`;
477
+ // Grok invokes lifecycle hooks, but their output cannot reach the model.
478
+ // Preserve queued messages for its next deliverable PreToolUse event.
479
+ if (env.HQ_LANE_PROVIDER === "grok" && hookEvent !== "PreToolUse") {
480
+ return { exitCode: 0, stdout: "", stderr: "" };
481
+ }
482
+ const hasQueuedMessages = dropboxInboxHasMessages(hqRoot, laneId);
483
+ if (hookEvent === "Stop" && !hasQueuedMessages) {
484
+ return { exitCode: 0, stdout: "", stderr: "" };
485
+ }
486
+ if ((hookEvent === "PostToolUse" || hookEvent === "Stop") && hasQueuedMessages) {
487
+ const lane = (deps.readLane ?? tryReadLane)(hqRoot, laneId);
488
+ const company = lane?.mesh_company ?? lane?.company ?? undefined;
489
+ let enabled = true;
490
+ try {
491
+ enabled = deps.readLifecycleDeliveryFlag
492
+ ? await deps.readLifecycleDeliveryFlag()
493
+ : await readLanesRegistryFlagFailOpen(LANE_DROPBOX_LIFECYCLE_DELIVERY_FLAG, {
494
+ ...(company ? { company } : {}),
495
+ env,
496
+ });
497
+ }
498
+ catch (err) {
499
+ process.stderr.write(`hq-lanes-drain: lifecycle delivery flag read failed open: ${err instanceof Error ? err.message : String(err)}\n`);
500
+ }
501
+ if (!enabled)
502
+ return { exitCode: 0, stdout: "", stderr: "" };
503
+ }
504
+ // PreToolUse remains the tool-activity liveness touch used by watcher.ts.
505
+ // PostToolUse and Stop add delivery opportunities without extra liveness writes.
506
+ let stderr = "";
507
+ if (hookEvent === "PreToolUse") {
508
+ try {
509
+ recordToolActivity(hqRoot, laneId);
510
+ }
511
+ catch (err) {
512
+ stderr += `hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`;
513
+ }
476
514
  }
477
515
  try {
478
516
  const turnId = env.HQ_LANE_TURN_ID ?? "";
@@ -515,15 +553,22 @@ export async function runDrainHook(env, input = {}, deps = {}) {
515
553
  }
516
554
  }
517
555
  if (replayNote && replayed && replayed.length > 0) {
556
+ const formatted = `${replayNote}\n\n${formatDropboxMessages(replayed)}`;
518
557
  return {
519
558
  exitCode: 0,
520
- stdout: hookAdditionalContextJson(replayed, "PreToolUse", replayNote),
559
+ stdout: hookEvent === "Stop"
560
+ ? hookStopBlockJson(formatted)
561
+ : hookAdditionalContextJson(replayed, hookEvent, replayNote),
521
562
  stderr,
522
563
  };
523
564
  }
524
565
  if (messages.length === 0)
525
566
  return { exitCode: 0, stdout: "", stderr };
526
- return { exitCode: 0, stdout: hookAdditionalContextJson(messages), stderr };
567
+ const formatted = formatDropboxMessages(messages);
568
+ if (hookEvent === "Stop") {
569
+ return { exitCode: 0, stdout: hookStopBlockJson(formatted), stderr };
570
+ }
571
+ return { exitCode: 0, stdout: hookAdditionalContextJson(messages, hookEvent), stderr };
527
572
  }
528
573
  catch (err) {
529
574
  const message = err instanceof Error ? err.message : String(err);