@indigoai-us/hq-cli 5.345.55 → 5.345.57

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 (66) hide show
  1. package/CHANGELOG.md +18 -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 +124 -0
  6. package/dist/command-catalog.generated.js +155 -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-loop.d.ts +12 -0
  16. package/dist/commands/lanes-loop.js +214 -0
  17. package/dist/commands/lanes-questions.js +37 -11
  18. package/dist/commands/lanes-resume.js +19 -1
  19. package/dist/commands/lanes-session-start.d.ts +2 -7
  20. package/dist/commands/lanes-session-start.js +9 -29
  21. package/dist/commands/lanes-shared.d.ts +14 -2
  22. package/dist/commands/lanes.d.ts +3 -1
  23. package/dist/commands/lanes.js +376 -35
  24. package/dist/commands/members.d.ts +4 -0
  25. package/dist/commands/members.js +23 -2
  26. package/dist/lanes-drain-hook.js +6 -2
  27. package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
  28. package/dist/lib/doctor/checks/sync-health.js +47 -23
  29. package/dist/lib/lanes/delivery.d.ts +3 -0
  30. package/dist/lib/lanes/delivery.js +5 -0
  31. package/dist/lib/lanes/dropbox.d.ts +7 -2
  32. package/dist/lib/lanes/dropbox.js +157 -10
  33. package/dist/lib/lanes/flag-keys.d.ts +12 -0
  34. package/dist/lib/lanes/flag-keys.js +12 -0
  35. package/dist/lib/lanes/hooks-install.d.ts +14 -11
  36. package/dist/lib/lanes/hooks-install.js +70 -50
  37. package/dist/lib/lanes/loop.d.ts +158 -0
  38. package/dist/lib/lanes/loop.js +743 -0
  39. package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
  40. package/dist/lib/lanes/outpost-dispatch.js +21 -7
  41. package/dist/lib/lanes/reaper.d.ts +58 -0
  42. package/dist/lib/lanes/reaper.js +403 -0
  43. package/dist/lib/lanes/reconcile.d.ts +19 -0
  44. package/dist/lib/lanes/reconcile.js +173 -6
  45. package/dist/lib/lanes/spawn.d.ts +4 -0
  46. package/dist/lib/lanes/spawn.js +41 -9
  47. package/dist/lib/lanes/status-fields.d.ts +12 -0
  48. package/dist/lib/lanes/status-fields.js +189 -0
  49. package/dist/lib/lanes/store.js +49 -2
  50. package/dist/lib/lanes/types.d.ts +18 -0
  51. package/dist/lib/lanes/types.js +3 -3
  52. package/dist/lib/lanes/watcher.js +3 -0
  53. package/dist/lib/lanes/worker-continuity.d.ts +18 -0
  54. package/dist/lib/lanes/worker-continuity.js +81 -0
  55. package/dist/lib/lanes/worker-process.d.ts +4 -0
  56. package/dist/lib/lanes/worker-process.js +77 -0
  57. package/dist/main.js +9 -3
  58. package/dist/utils/cli-telemetry.d.ts +4 -1
  59. package/dist/utils/cli-telemetry.js +8 -0
  60. package/dist/utils/jq-windows-compat.d.ts +13 -0
  61. package/dist/utils/jq-windows-compat.js +48 -0
  62. package/dist/utils/run-bundled-script.d.ts +3 -0
  63. package/dist/utils/run-bundled-script.js +9 -80
  64. package/dist/utils/windows-bash.d.ts +5 -0
  65. package/dist/utils/windows-bash.js +75 -0
  66. package/package.json +1 -1
@@ -38,12 +38,18 @@ 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, LANE_CONDUCT_LOOP_FLAG, } from "../lib/lanes/flag-keys.js";
45
+ import { findWorkerContinuityLane, sameLaneHost, workerContinuityPrompt } from "../lib/lanes/worker-continuity.js";
46
+ import { enqueueLoopEnvelope, interruptLoopQueue, loopControllerIsAlive, loopStatus, validateLoopEnvelope } from "../lib/lanes/loop.js";
47
+ import { runLaneLoopController, startLaneLoopController } from "./lanes-loop.js";
44
48
  import { configuredCodexRolloutHomes, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
45
49
  import { isControllerAliasOutpostId } from "../lib/lanes/admission-host.js";
46
50
  import { runLeadAutopilotPass } from "../lib/lanes/autopilot.js";
51
+ import { appendDropboxMessageOnce, appendDropboxMessageOnceAtPath } from "../lib/lanes/dropbox.js";
52
+ import { monitorSessionDir } from "../lib/monitor.js";
47
53
  import { syncOutpostLeadRegistries } from "../lib/lanes/autopilot-registry.js";
48
54
  import { OutpostSnapshotInvocationMemo } from "../lib/lanes/outpost-snapshot-memo.js";
49
55
  import { createOutpostHireGrantContext, parseOutpostHireGrantContext, selectOutpostHireGrantHierarchy, sameHireGrantSenior, } from "../lib/lanes/hire-grant-dispatch.js";
@@ -462,6 +468,7 @@ function resolveDeps(deps = {}) {
462
468
  focusGuardEnabled: deps.focusGuardEnabled,
463
469
  isSsmOwnerDispatch: deps.isSsmOwnerDispatch ?? (() => isSsmRunCommandProcess()),
464
470
  withGrantHireLocks: deps.withGrantHireLocks ?? withLaneHireLocks,
471
+ withAdmissionLock: deps.withAdmissionLock ?? withLaneAdmissionLock,
465
472
  mergeReviewEnabled: deps.mergeReviewEnabled,
466
473
  mergeHoldEnabled: deps.mergeHoldEnabled,
467
474
  };
@@ -3323,6 +3330,16 @@ async function runCreate(ctx, opts) {
3323
3330
  }
3324
3331
  meshCompany = resolvedMeshCompany;
3325
3332
  }
3333
+ if (opts.loop === true && !(await readLanesRegistryFlagFailOpen(LANE_CONDUCT_LOOP_FLAG, {
3334
+ company: company ?? meshCompany,
3335
+ defaultValue: true,
3336
+ env: ctx.env,
3337
+ }))) {
3338
+ throw new LanesError("loop_lanes_disabled", `Loop lanes are disabled by the ${LANE_CONDUCT_LOOP_FLAG} kill switch`, {});
3339
+ }
3340
+ if (opts.loop === true && (opts.outpost?.trim() || requestedPlacement?.kind === "outpost" || defaultPlacement?.kind === "outpost")) {
3341
+ throw new LanesError("loop_lane_outpost_unsupported", "Loop lanes currently require the local host", { outpost_id: opts.outpost?.trim() ?? (requestedPlacement?.kind === "outpost" ? requestedPlacement.id : defaultPlacement?.kind === "outpost" ? defaultPlacement.id : undefined) });
3342
+ }
3326
3343
  let placementCompanyResolved = false;
3327
3344
  let placementCompany;
3328
3345
  let placementGate;
@@ -3365,6 +3382,16 @@ async function runCreate(ctx, opts) {
3365
3382
  const provider = explicitProvider ??
3366
3383
  profile?.meta.provider ??
3367
3384
  parseLaneProvider("codex");
3385
+ const continuityEnabled = opts.worker !== undefined && await readLanesRegistryFlagFailOpen(LANE_WORKER_CONTINUITY_FLAG, { company: company ?? meshCompany, env: ctx.env });
3386
+ let workerContinuity = continuityEnabled
3387
+ ? findWorkerContinuityLane(loadAllLanes(hqRoot), {
3388
+ senior,
3389
+ worker: resolvedWorker?.id ?? opts.worker,
3390
+ provider,
3391
+ company: company ?? meshCompany,
3392
+ freshThread: opts.freshThread,
3393
+ })
3394
+ : { resumable: false };
3368
3395
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === provider;
3369
3396
  const cliModel = parseCliModel(opts.model);
3370
3397
  // Validated here because `claude --effort <unknown>` only warns and runs.
@@ -3375,6 +3402,9 @@ async function runCreate(ctx, opts) {
3375
3402
  const profileAccounts = profileDefaultsApply ? profile?.meta.accounts : undefined;
3376
3403
  const keepAlive = opts.keepAlive === true ||
3377
3404
  (profileDefaultsApply && profile?.meta.keep_alive === true);
3405
+ if (opts.loop === true && keepAlive) {
3406
+ throw new LanesError("loop_keep_alive_conflict", "Loop lanes cannot use keep-alive", { worker: resolvedWorker?.id ?? opts.worker ?? null });
3407
+ }
3378
3408
  if (keepAlive && !providerSupportsSessionResume(provider)) {
3379
3409
  throw new LanesError("keep_alive_provider_unsupported", `Provider ${provider} cannot resume recorded sessions, so keep-alive is unavailable`, { provider });
3380
3410
  }
@@ -3739,6 +3769,27 @@ async function runCreate(ctx, opts) {
3739
3769
  // The lane id is created in `persist`; its Work Mesh thread is ensured
3740
3770
  // immediately afterward from that stable identity.
3741
3771
  const persist = async () => {
3772
+ const host = placementHostOverride ?? (outpostId !== undefined
3773
+ ? { kind: "outpost", id: outpostId }
3774
+ : { kind: "local", id: ctx.hostname() });
3775
+ if (opts.loop === true && host.kind === "outpost") {
3776
+ throw new LanesError("loop_lane_outpost_unsupported", "Loop lanes currently require the local host", { outpost_id: host.id });
3777
+ }
3778
+ // Repeat the busy check while holding provider admission serialization so
3779
+ // two concurrent creates cannot both claim one worker's provider thread.
3780
+ if (continuityEnabled) {
3781
+ workerContinuity = findWorkerContinuityLane(loadAllLanes(hqRoot), {
3782
+ senior,
3783
+ worker: resolvedWorker?.id ?? opts.worker,
3784
+ provider,
3785
+ company: company ?? meshCompany,
3786
+ freshThread: opts.freshThread,
3787
+ });
3788
+ if (workerContinuity.resumable && workerContinuity.previous &&
3789
+ !sameLaneHost(workerContinuity.previous.host, host)) {
3790
+ workerContinuity = { previous: workerContinuity.previous, resumable: false, fallbackReason: "host_mismatch" };
3791
+ }
3792
+ }
3742
3793
  let admission;
3743
3794
  let validation;
3744
3795
  if (!outpostId && provider === "claude" && explicitBalanceAccount === undefined &&
@@ -3842,9 +3893,6 @@ async function runCreate(ctx, opts) {
3842
3893
  throw admission.error;
3843
3894
  }
3844
3895
  }
3845
- const host = placementHostOverride ?? (outpostId !== undefined
3846
- ? { kind: "outpost", id: outpostId }
3847
- : { kind: "local", id: ctx.hostname() });
3848
3896
  const queuedReason = admission.action === "queue" ? admission.reason : undefined;
3849
3897
  const admissionReservedAt = admission.action === "start" ? ctx.now().toISOString() : undefined;
3850
3898
  const laneInit = {
@@ -3886,8 +3934,21 @@ async function runCreate(ctx, opts) {
3886
3934
  }
3887
3935
  : {}),
3888
3936
  keep_alive: keepAlive,
3937
+ ...(opts.loop === true ? { loop_mode: true } : {}),
3889
3938
  ...(keepAlive ? { keep_alive_opted_in_at: createdAt } : {}),
3890
3939
  provider,
3940
+ ...(workerContinuity.resumable && workerContinuity.previous
3941
+ ? {
3942
+ provider_session_id: workerContinuity.previous.provider_session_id,
3943
+ last_message_at: workerContinuity.previous.last_message_at ?? workerContinuity.previous.created_at,
3944
+ pending_resume: {
3945
+ session_id: workerContinuity.previous.provider_session_id,
3946
+ at: createdAt,
3947
+ trigger: "resume",
3948
+ resumed_from: workerContinuity.previous.state,
3949
+ },
3950
+ }
3951
+ : {}),
3891
3952
  provider_source: explicitProvider !== undefined
3892
3953
  ? "cli"
3893
3954
  : profile?.meta.provider !== undefined
@@ -3957,12 +4018,7 @@ async function runCreate(ctx, opts) {
3957
4018
  return { lane, admission, validation };
3958
4019
  };
3959
4020
  let persisted;
3960
- if (outpostId) {
3961
- persisted = await persist();
3962
- }
3963
- else {
3964
- persisted = await withLaneAdmissionLock(hqRoot, provider, persist, ctx.sleep);
3965
- }
4021
+ persisted = await ctx.withAdmissionLock(hqRoot, provider, persist, ctx.sleep);
3966
4022
  let lane = persisted.lane;
3967
4023
  const { admission, validation } = persisted;
3968
4024
  if (focusAssessment.enabled && opts.focusOverride !== undefined) {
@@ -4052,10 +4108,25 @@ async function runCreate(ctx, opts) {
4052
4108
  ensureDir(path.join(dest, "dropbox"));
4053
4109
  ensureDir(path.join(dest, "questions"));
4054
4110
  const taskBrief = fs.readFileSync(briefAbs, "utf8");
4055
- const briefText = resolvedWorker
4111
+ const previousResult = workerContinuity.previous
4112
+ ? readLatestEnvelopeRecord(hqRoot, workerContinuity.previous.lane_id)
4113
+ : undefined;
4114
+ const briefText = `${workerContinuity.resumable && workerContinuity.previous
4115
+ ? workerContinuityPrompt(workerContinuity.previous, previousResult?.envelope.summary, previousResult?.ref)
4116
+ : ""}${resolvedWorker
4056
4117
  ? composeWorkerBrief(resolvedWorker, taskBrief)
4057
- : composeGeneralBrief(taskBrief);
4118
+ : composeGeneralBrief(taskBrief)}`;
4058
4119
  fs.writeFileSync(path.join(dest, "brief.md"), briefText);
4120
+ if (workerContinuity.resumable && lane.pending_resume) {
4121
+ const newBriefPath = path.join(dest, "brief.md");
4122
+ lane = casUpdateLane(hqRoot, lane.lane_id, (current) => ({
4123
+ ...current,
4124
+ pending_resume: current.pending_resume ? {
4125
+ ...current.pending_resume,
4126
+ text: `The new brief at ${newBriefPath} is the instruction for this turn. Read and follow it.`,
4127
+ } : undefined,
4128
+ }));
4129
+ }
4059
4130
  if (timeoutSecs !== undefined) {
4060
4131
  writeJsonFile(timeoutPath(hqRoot, lane.lane_id), {
4061
4132
  timeout_secs: timeoutSecs,
@@ -4063,7 +4134,21 @@ async function runCreate(ctx, opts) {
4063
4134
  });
4064
4135
  }
4065
4136
  let started = lane;
4066
- if (admission.action === "start") {
4137
+ let loopControllerPid;
4138
+ if (opts.loop === true) {
4139
+ lane = casUpdateLane(hqRoot, lane.lane_id, (current) => admission.action === "start"
4140
+ ? {
4141
+ ...current,
4142
+ state: "running",
4143
+ queued_reason: undefined,
4144
+ queued_details: undefined,
4145
+ }
4146
+ : { ...current });
4147
+ started = lane;
4148
+ if (admission.action === "start")
4149
+ loopControllerPid = startLaneLoopController(ctx, lane.lane_id, true);
4150
+ }
4151
+ else if (admission.action === "start") {
4067
4152
  started = spawnLaneWorker(hqRoot, lane, {
4068
4153
  env: ctx.env,
4069
4154
  modelDeps: {
@@ -4119,9 +4204,20 @@ async function runCreate(ctx, opts) {
4119
4204
  company: started.company,
4120
4205
  project_id: started.project_id,
4121
4206
  story_id: started.story_id,
4207
+ ...(started.loop_mode ? { loop: loopStatus(runDir(hqRoot, started.lane_id), started.lane_id), loop_pid: loopControllerPid ?? null } : {}),
4122
4208
  stories: laneStories(started),
4123
4209
  story_warning: combinedStoryWarning(started, createStoryWarnings),
4124
4210
  thread_id: started.thread_id ?? null,
4211
+ ...(continuityEnabled ? {
4212
+ worker_continuity: workerContinuity.resumable
4213
+ ? { action: "resume", previous_lane_id: workerContinuity.previous?.lane_id }
4214
+ : {
4215
+ action: "fresh",
4216
+ ...(workerContinuity.previous ? { previous_lane_id: workerContinuity.previous.lane_id } : {}),
4217
+ ...(opts.freshThread ? { reason: "fresh_thread_requested" } : {}),
4218
+ ...(workerContinuity.fallbackReason ? { reason: workerContinuity.fallbackReason } : {}),
4219
+ },
4220
+ } : {}),
4125
4221
  senior: started.senior,
4126
4222
  depth: started.depth,
4127
4223
  timeout_secs: timeoutSecs ?? null,
@@ -5343,6 +5439,24 @@ async function runPickup(ctx, opts) {
5343
5439
  lane: started,
5344
5440
  });
5345
5441
  }
5442
+ async function laneStatusFieldsForCommand(ctx, hqRoot, lane, now) {
5443
+ const resolved = resolveLaneCompany(hqRoot, lane).company;
5444
+ let enabled = true;
5445
+ try {
5446
+ enabled = await readLanesRegistryFlagFailOpen(LANE_STATUS_ROW_FIELDS_FLAG, {
5447
+ ...(resolved ? { company: resolved } : {}),
5448
+ env: ctx.env,
5449
+ });
5450
+ }
5451
+ catch (err) {
5452
+ process.stderr.write(`hq lanes: status row fields flag read failed open: ${err instanceof Error ? err.message : String(err)}\n`);
5453
+ }
5454
+ if (!enabled)
5455
+ return {};
5456
+ const host = reconcileHostFromCtx(ctx);
5457
+ const local = laneRunsHere(lane, host);
5458
+ return { ...readLaneStatusRowFields(hqRoot, lane, now.getTime(), local) };
5459
+ }
5346
5460
  async function runList(ctx, opts) {
5347
5461
  if (opts.json && opts.human) {
5348
5462
  throw new LanesError("conflicting_output_format", "Use either --json or --human for hq lanes list", { json: true, human: true });
@@ -5365,7 +5479,7 @@ async function runList(ctx, opts) {
5365
5479
  new Set(opts.hierarchy).size !== opts.hierarchy.length)) {
5366
5480
  throw new LanesError("lane_list_hierarchy_invalid", "--hierarchy must be repeated with distinct, non-empty lane ids without surrounding whitespace", {});
5367
5481
  }
5368
- if (opts.snapshot && (opts.human || opts.company || opts.project || opts.story || opts.state || opts.senior)) {
5482
+ if (opts.snapshot && (opts.human || opts.company || opts.project || opts.story || opts.state || opts.senior || opts.worker)) {
5369
5483
  throw new LanesError("lane_list_snapshot_invalid", "--snapshot only supports owner lane filters and JSON output", {});
5370
5484
  }
5371
5485
  if (opts.hierarchy !== undefined && !opts.snapshot) {
@@ -5414,21 +5528,30 @@ async function runList(ctx, opts) {
5414
5528
  }
5415
5529
  if (senior && seniorKey(lane.senior) !== seniorKey(senior))
5416
5530
  return false;
5531
+ if (opts.worker && lane.worker !== opts.worker)
5532
+ return false;
5417
5533
  return true;
5418
5534
  };
5419
5535
  const selectLanes = () => laneSource
5420
5536
  .filter((lane) => matchesLaneFilters(lane, true))
5421
5537
  .sort(compareLaneQueueOrder);
5422
5538
  let selectedLanes = selectLanes();
5539
+ const statusFieldsByLane = new Map();
5540
+ const statusNow = ctx.now();
5541
+ await Promise.all(selectedLanes.map(async (lane) => {
5542
+ statusFieldsByLane.set(lane.lane_id, await laneStatusFieldsForCommand(ctx, hqRoot, lane, statusNow));
5543
+ }));
5423
5544
  if (opts.snapshot) {
5424
5545
  printJson({
5425
5546
  ok: true,
5426
5547
  action: "list",
5427
5548
  lanes: selectedLanes.map((lane) => {
5549
+ const fields = statusFieldsByLane.get(lane.lane_id) ?? {};
5550
+ const loop = lane.loop_mode === true ? { loop: loopStatus(runDir(hqRoot, lane.lane_id), lane.lane_id) } : {};
5428
5551
  if (!isTerminalLaneState(lane.state))
5429
- return lane;
5552
+ return { ...lane, ...fields, ...loop };
5430
5553
  const envelope = readLatestEnvelopeRecord(hqRoot, lane.lane_id)?.envelope;
5431
- return envelope?.summary ? { ...lane, envelope_summary: envelope.summary } : lane;
5554
+ return { ...lane, ...fields, ...loop, ...(envelope?.summary ? { envelope_summary: envelope.summary } : {}) };
5432
5555
  }),
5433
5556
  });
5434
5557
  return;
@@ -5532,7 +5655,9 @@ async function runList(ctx, opts) {
5532
5655
  !laneRunsHere(lane, host);
5533
5656
  return {
5534
5657
  ...lane,
5658
+ ...statusFieldsByLane.get(lane.lane_id),
5535
5659
  ...hireByLane.get(lane.lane_id),
5660
+ ...(lane.loop_mode === true ? { loop: loopStatus(runDir(hqRoot, lane.lane_id), lane.lane_id) } : {}),
5536
5661
  ...(remoteRefresh.unverifiedRemote.has(lane.lane_id) ? { unverified_remote: true } : {}),
5537
5662
  stories: laneStories(lane),
5538
5663
  story_ids: laneStories(lane).map((story) => story.story_id),
@@ -5901,7 +6026,11 @@ async function runShow(ctx, laneId, hasPendingQuestionForLane, resolveLaneTarget
5901
6026
  })
5902
6027
  .slice(0, 20)
5903
6028
  .map(({ repo, pr, head_sha, recorded_at }) => ({ repo, pr, head_sha, recorded_at }));
5904
- const laneForShow = { ...lane };
6029
+ const laneForShow = {
6030
+ ...lane,
6031
+ ...await laneStatusFieldsForCommand(ctx, hqRoot, storedLane, ctx.now()),
6032
+ ...(storedLane.loop_mode === true ? { loop: loopStatus(runDir(hqRoot, laneId), laneId) } : {}),
6033
+ };
5905
6034
  delete laneForShow.resume_history;
5906
6035
  printJson({
5907
6036
  ok: true,
@@ -7695,6 +7824,7 @@ async function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount =
7695
7824
  }
7696
7825
  assertLaneNotRunningForReparent(lane);
7697
7826
  const pending = pendingQuestionsForLane(hqRoot, laneId);
7827
+ const pendingAudiences = new Map(pending.map((question) => [question.question_id, resolveAudience(hqRoot, question, ctx)]));
7698
7828
  const oldDepth = derivedLaneDepth(hqRoot, lane.senior, ctx);
7699
7829
  if (newDepth > oldDepth && pending.length > 0) {
7700
7830
  throw new LanesError("demotion_with_pending_questions", "Cannot increase a lane's depth while it has pending questions", { lane_id: laneId, from_depth: oldDepth, to_depth: newDepth });
@@ -7759,17 +7889,24 @@ async function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount =
7759
7889
  }
7760
7890
  for (const question of pending) {
7761
7891
  casUpdateQuestion(hqRoot, question.question_id, (current) => {
7892
+ const previousAudience = pendingAudiences.get(current.question_id);
7893
+ const nextAudience = resolveAudience(hqRoot, current, ctx);
7894
+ const audienceChanged = previousAudience !== undefined &&
7895
+ seniorKey(previousAudience) !== seniorKey(nextAudience);
7762
7896
  const already = current.routing_history.some((entry) => entry.reason === "reparented" && entry.at === at);
7763
- if (already)
7897
+ if (already && !audienceChanged)
7764
7898
  return current;
7765
7899
  return {
7766
7900
  ...current,
7767
- routing_history: appendCappedHistory(current.routing_history, {
7768
- hop: current.hop,
7769
- audience: resolveAudience(hqRoot, current, ctx),
7770
- at,
7771
- reason: "reparented",
7772
- }),
7901
+ ...(audienceChanged ? { seen_at: null, seen_session_id: null } : {}),
7902
+ routing_history: already
7903
+ ? current.routing_history
7904
+ : appendCappedHistory(current.routing_history, {
7905
+ hop: current.hop,
7906
+ audience: nextAudience,
7907
+ at,
7908
+ reason: "reparented",
7909
+ }),
7773
7910
  };
7774
7911
  });
7775
7912
  }
@@ -8380,7 +8517,7 @@ async function runStop(ctx, laneId, reason, quiet = false) {
8380
8517
  const hqRoot = ctx.hqRoot();
8381
8518
  const target = await resolveCommandLaneTarget(ctx, laneId);
8382
8519
  const lane = target.lane;
8383
- if (!quiet && lane.state !== "queued" && !isTerminalLaneState(lane.state)) {
8520
+ if (!quiet && lane.loop_mode !== true && lane.state !== "queued" && !isTerminalLaneState(lane.state)) {
8384
8521
  process.stderr.write(stopTerminalWarning(lane.lane_id));
8385
8522
  }
8386
8523
  if (target.remoteOnly) {
@@ -8392,6 +8529,23 @@ async function runStop(ctx, laneId, reason, quiet = false) {
8392
8529
  return;
8393
8530
  }
8394
8531
  assertLaneRunsHereForOperatorMutation(ctx, lane, "stop");
8532
+ if (lane.loop_mode === true) {
8533
+ const runDirPath = runDir(hqRoot, lane.lane_id);
8534
+ const loop = loopStatus(runDirPath, lane.lane_id);
8535
+ if (loop.state === "stopped" || loop.state === "failed") {
8536
+ throw new LanesError("already_terminal", `Loop lane ${lane.lane_id} is already ${lane.state}`, { lane_id: lane.lane_id, state: lane.state });
8537
+ }
8538
+ const controllerAlive = loopControllerIsAlive(runDirPath);
8539
+ if (!controllerAlive && lane.state === "running") {
8540
+ await runInterrupt(ctx, lane.lane_id, "loop controller exited; recovering queued work before stop", { preserveLoopQueue: true, quiet: true });
8541
+ }
8542
+ const queued = enqueueLoopEnvelope({ runDir: runDirPath, laneId: lane.lane_id, envelope: { kind: "stop" }, now: ctx.now });
8543
+ if (!controllerAlive && lane.state !== "queued")
8544
+ startLaneLoopController(ctx, lane.lane_id);
8545
+ if (!quiet)
8546
+ printJson({ ok: true, action: "stop", lane_id: lane.lane_id, terminal: false, queued_stop: queued });
8547
+ return;
8548
+ }
8395
8549
  if (isTerminalLaneState(lane.state)) {
8396
8550
  throw new LanesError("already_terminal", `Lane ${laneId} is already ${lane.state}`, { lane_id: laneId, state: lane.state });
8397
8551
  }
@@ -8447,7 +8601,7 @@ function stopTerminalWarning(laneId) {
8447
8601
  * session so `hq lanes resume` continues it. Keep-alive resumes only
8448
8602
  * `exited_with_partial_output` interruptions, so it leaves this lane alone.
8449
8603
  */
8450
- async function runInterrupt(ctx, laneId, reason) {
8604
+ async function runInterrupt(ctx, laneId, reason, options = {}) {
8451
8605
  const operatorReason = reason?.trim();
8452
8606
  if (reason !== undefined && !operatorReason) {
8453
8607
  throw new LanesError("lane_interrupt_reason_invalid", "Interrupt reason must not be empty", {});
@@ -8460,6 +8614,14 @@ async function runInterrupt(ctx, laneId, reason) {
8460
8614
  throw new LanesError("interrupt_remote_unsupported", `Lane ${laneId} runs on Outpost ${lane.host.id}; run hq lanes interrupt on that Outpost`, { lane_id: laneId, outpost_id: lane.host.id });
8461
8615
  }
8462
8616
  assertLaneRunsHereForOperatorMutation(ctx, lane, "interrupt");
8617
+ const queueInterrupt = lane.loop_mode === true && options.preserveLoopQueue !== true
8618
+ ? interruptLoopQueue(runDir(hqRoot, lane.lane_id))
8619
+ : undefined;
8620
+ if (lane.loop_mode === true && lane.state !== "running") {
8621
+ if (!options.quiet)
8622
+ printJson({ ok: true, action: "interrupt", lane_id: lane.lane_id, terminal: false, resumable: true, ...queueInterrupt });
8623
+ return;
8624
+ }
8463
8625
  const assertInterruptible = (current) => {
8464
8626
  if (current.state !== "running") {
8465
8627
  throw new LanesError("lane_not_running", current.state === "queued"
@@ -8502,13 +8664,15 @@ async function runInterrupt(ctx, laneId, reason) {
8502
8664
  const resumable = resumeCommandRefusal(laneWithRecoveredProviderSession(hqRoot, updated)) === null;
8503
8665
  if (!resumable)
8504
8666
  process.stderr.write(interruptNotResumableWarning(laneId));
8505
- printJson({
8506
- ok: true,
8507
- action: "interrupt",
8508
- lane: updated,
8509
- terminal: false,
8510
- resumable,
8511
- });
8667
+ if (!options.quiet)
8668
+ printJson({
8669
+ ok: true,
8670
+ action: "interrupt",
8671
+ lane: updated,
8672
+ terminal: false,
8673
+ resumable,
8674
+ ...(queueInterrupt === undefined ? {} : queueInterrupt),
8675
+ });
8512
8676
  }
8513
8677
  /**
8514
8678
  * The watcher skips lanes that are already terminal, so it never records the
@@ -8672,13 +8836,13 @@ function questionListCompanyArg(company) {
8672
8836
  }
8673
8837
  return value;
8674
8838
  }
8675
- async function readOutpostQuestionRecords(ctx, outpostId, company) {
8839
+ async function readOutpostQuestionRecords(ctx, outpostId, company, questionId, visibilityFilter) {
8676
8840
  const companyArg = questionListCompanyArg(company);
8677
8841
  let result;
8678
8842
  try {
8679
8843
  result = await ctx.dispatchOutpostExec({
8680
8844
  outpostId,
8681
- command: buildOutpostQuestionListCommand({ company: companyArg }),
8845
+ command: buildOutpostQuestionListCommand({ company: companyArg, ...visibilityFilter }),
8682
8846
  timeoutSeconds: CAPACITY_PROBE_TIMEOUT_MS / 1000,
8683
8847
  waitMs: CAPACITY_PROBE_TIMEOUT_MS,
8684
8848
  });
@@ -8711,6 +8875,8 @@ async function readOutpostQuestionRecords(ctx, outpostId, company) {
8711
8875
  throw new LanesError("outpost_question_list_invalid", `Outpost ${outpostId} returned an invalid question record`, { outpost_id: outpostId, index });
8712
8876
  }
8713
8877
  const rowObject = row;
8878
+ if (questionId !== undefined && rowObject.question_id !== questionId)
8879
+ continue;
8714
8880
  let question;
8715
8881
  try {
8716
8882
  question = parseQuestionRecord(row);
@@ -8840,6 +9006,31 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
8840
9006
  runCompaction: ctx.runCompaction,
8841
9007
  locateTranscript: ctx.locateTranscript,
8842
9008
  },
9009
+ spawn: (root, candidate) => {
9010
+ if (candidate.loop_mode !== true || candidate.state !== "queued") {
9011
+ return spawnLaneWorker(root, candidate, {
9012
+ env: ctx.env,
9013
+ modelDeps: { readFileSync: ctx.readFileSync, probe: ctx.probeProvider, env: ctx.env },
9014
+ existsSync: ctx.existsSync,
9015
+ spawnProcess: ctx.spawnProcess,
9016
+ proveDetached: ctx.proveDetached,
9017
+ killProcess: ctx.killProcess,
9018
+ now: ctx.now,
9019
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
9020
+ runCompaction: ctx.runCompaction,
9021
+ locateTranscript: ctx.locateTranscript,
9022
+ });
9023
+ }
9024
+ const admitted = casUpdateLane(root, candidate.lane_id, (current) => ({
9025
+ ...current,
9026
+ state: "running",
9027
+ queued_reason: undefined,
9028
+ queued_details: undefined,
9029
+ admission_reserved_at: undefined,
9030
+ }));
9031
+ startLaneLoopController(ctx, candidate.lane_id, true);
9032
+ return admitted;
9033
+ },
8843
9034
  throttleDeps: ctx.throttleDeps,
8844
9035
  livenessDeps: livenessDepsFromCtx(ctx),
8845
9036
  presenceConnected: ctx.presenceConnected,
@@ -8847,6 +9038,63 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
8847
9038
  recoverQueuedMessages,
8848
9039
  mesh: ctx.mesh,
8849
9040
  env: ctx.env,
9041
+ deliverBlockedEscalation: async (delivery) => {
9042
+ const currentHost = reconcileHostFromCtx(ctx);
9043
+ const targetOutpostId = delivery.recipient_host?.kind === "outpost"
9044
+ ? delivery.recipient_host.id
9045
+ : "primary";
9046
+ const targetIsCurrent = targetOutpostId === (currentHost.outpostId ?? "primary");
9047
+ if (targetIsCurrent)
9048
+ throw new LanesError("blocked_escalation_local_delivery_misrouted", "Local blocked escalations must use the durable local inbox path");
9049
+ if (!delivery.recipient.id)
9050
+ throw new LanesError("blocked_escalation_recipient_invalid", "Escalation recipient has no id");
9051
+ const waitMs = outpostSingleLaneWaitMs(ctx.env);
9052
+ const dispatched = await ctx.dispatchOutpostExec({
9053
+ outpostId: targetOutpostId,
9054
+ timeoutSeconds: Math.ceil(waitMs / 1000),
9055
+ waitMs,
9056
+ command: buildOutpostLaneCommand({
9057
+ action: "blocked-escalation-target",
9058
+ laneId: delivery.recipient.id,
9059
+ recipientKind: delivery.recipient.kind === "session" ? "session" : "lane",
9060
+ text: delivery.text,
9061
+ messageId: delivery.message_id,
9062
+ }),
9063
+ });
9064
+ if (dispatched.exitCode !== 0)
9065
+ throw new LanesError("blocked_escalation_remote_delivery_failed", dispatchErrorMessage(dispatched), { outpost_id: targetOutpostId });
9066
+ },
9067
+ resolveEscalationLane: async (laneId) => {
9068
+ const currentHost = reconcileHostFromCtx(ctx);
9069
+ if (currentHost.outpostId === null)
9070
+ return null;
9071
+ const waitMs = outpostSingleLaneWaitMs(ctx.env);
9072
+ const dispatched = await ctx.dispatchOutpostExec({
9073
+ outpostId: "primary",
9074
+ command: buildOutpostLaneCommand({ action: "show", laneId }),
9075
+ timeoutSeconds: Math.ceil(waitMs / 1000),
9076
+ waitMs,
9077
+ });
9078
+ if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0) {
9079
+ throw new LanesError("blocked_escalation_ancestor_unavailable", `Could not read ancestor lane ${laneId} from the primary hierarchy`, {
9080
+ lane_id: laneId,
9081
+ command_id: dispatched.commandId,
9082
+ exit_code: dispatched.exitCode,
9083
+ });
9084
+ }
9085
+ const remote = parseRemoteJson(dispatched.stdout, "Blocked escalation ancestor");
9086
+ if (remote?.ok !== true || remote.action !== "show" || remote.lane === undefined) {
9087
+ throw new LanesError("blocked_escalation_ancestor_invalid", `Primary did not return ancestor lane ${laneId}`, { lane_id: laneId });
9088
+ }
9089
+ const ancestor = parseLaneRecord(normalizeLaneRecordView(remote.lane));
9090
+ if (ancestor.lane_id !== laneId) {
9091
+ throw new LanesError("blocked_escalation_ancestor_invalid", `Primary returned a different lane for ancestor lookup ${laneId}`, {
9092
+ requested_lane_id: laneId,
9093
+ returned_lane_id: ancestor.lane_id,
9094
+ });
9095
+ }
9096
+ return ancestor;
9097
+ },
8850
9098
  ...(opts.terminalKeepAliveCandidate !== undefined
8851
9099
  ? { terminalKeepAliveCandidate: opts.terminalKeepAliveCandidate }
8852
9100
  : {}),
@@ -9248,6 +9496,15 @@ export function registerLanesCommand(parent, deps = {}) {
9248
9496
  presenceConnected: ctx.presenceConnected ?? false,
9249
9497
  deps: ctx.livenessDeps,
9250
9498
  }),
9499
+ stopEnabled: (lane) => {
9500
+ const resolution = resolveLaneCompany(ctx.hqRoot(), lane);
9501
+ const company = resolution.company ?? undefined;
9502
+ return readLanesRegistryFlag(LANE_REAP_STOP_FLAG, {
9503
+ company,
9504
+ ...(company ? { companyUidSource: laneCompanyUidSource(resolution.source) } : {}),
9505
+ env: ctx.env,
9506
+ });
9507
+ },
9251
9508
  }, wrap);
9252
9509
  registerLaneDropboxRepairCommand(lanes, {
9253
9510
  hqRoot: ctx.hqRoot,
@@ -9313,6 +9570,8 @@ export function registerLanesCommand(parent, deps = {}) {
9313
9570
  .option("--additional-story <id>", "Additional story carried by this lane; repeatable and discouraged when active", collectRepeatable)
9314
9571
  .requiredOption("--brief-file <path>", "Path to the lane brief")
9315
9572
  .option("--worker <name>", "HQ worker or reusable profile id")
9573
+ .option("--fresh-thread", "Skip worker continuity and start a cold provider session")
9574
+ .option("--loop", "Keep one worker lane registered to process queued phase envelopes")
9316
9575
  .option("--provider <name>", "claude | codex | grok")
9317
9576
  .option("--account <id>", "Provider account id")
9318
9577
  .option("--model <id>", "Model id for the provider CLI")
@@ -9335,6 +9594,62 @@ export function registerLanesCommand(parent, deps = {}) {
9335
9594
  .action(wrap(async (laneId, opts) => {
9336
9595
  await runApproveOwnerTarget(ctx, laneId, opts);
9337
9596
  }));
9597
+ lanes
9598
+ .command("_loop-worker", { hidden: true })
9599
+ .argument("<lane-id>", "Loop lane id")
9600
+ .action(wrap(async (laneId) => {
9601
+ await runLaneLoopController(ctx, laneId, {
9602
+ resumeLane: (id, options) => resumeHandlers.runResume(ctx, id, options),
9603
+ interruptLane: (id, reason, options) => runInterrupt(ctx, id, reason, options),
9604
+ emitTerminal: (lane) => emitLaneTerminalStateSoft(ctx, lane),
9605
+ });
9606
+ }));
9607
+ lanes
9608
+ .command("enqueue")
9609
+ .description("Validate and queue one phase envelope for a loop lane")
9610
+ .argument("<lane-id>", "Loop lane id")
9611
+ .requiredOption("--envelope <file>", "Generic phase or hq-phase-envelope/v1 JSON file")
9612
+ .action(wrap(async (laneId, opts) => {
9613
+ const hqRoot = ctx.hqRoot();
9614
+ const lane = readLane(hqRoot, laneId);
9615
+ if (lane.loop_mode !== true)
9616
+ throw new LanesError("lane_not_loop_mode", `Lane ${laneId} is not a loop lane`, { lane_id: laneId });
9617
+ const authorization = callerSeniorAuthorization(ctx, lane.senior);
9618
+ if (!authorization.authorized)
9619
+ throw new LanesError("lane_enqueue_unauthorized", `Only the lane's senior may enqueue phases`, { lane_id: laneId, reason: authorization.reason });
9620
+ assertLaneRunsHereForAdmission(ctx, lane);
9621
+ const enabled = await readLanesRegistryFlagFailOpen(LANE_CONDUCT_LOOP_FLAG, { company: lane.company ?? lane.mesh_company, defaultValue: true, env: ctx.env });
9622
+ if (!enabled)
9623
+ throw new LanesError("loop_lanes_disabled", `Loop lanes are disabled by the ${LANE_CONDUCT_LOOP_FLAG} kill switch`, { lane_id: laneId });
9624
+ const source = resolveCallerPath(ctx.callerCwd(), opts.envelope ?? "");
9625
+ let envelope;
9626
+ try {
9627
+ envelope = validateLoopEnvelope(JSON.parse(fs.readFileSync(source, "utf8")));
9628
+ }
9629
+ catch (error) {
9630
+ throw new LanesError("loop_envelope_invalid", `Could not read a valid JSON envelope from ${source}: ${error instanceof Error ? error.message : String(error)}`, { lane_id: laneId, path: source });
9631
+ }
9632
+ const envelopeData = envelope;
9633
+ if (envelopeData.engine !== undefined && envelopeData.engine !== lane.provider) {
9634
+ throw new LanesError("loop_envelope_engine_mismatch", `Envelope engine ${String(envelopeData.engine)} does not match lane provider ${lane.provider}`, { lane_id: lane.lane_id, provider: lane.provider });
9635
+ }
9636
+ if (envelopeData.schema === "hq-phase-envelope/v1" && envelopeData.worker_id !== lane.worker) {
9637
+ throw new LanesError("loop_envelope_worker_mismatch", `Envelope worker ${String(envelopeData.worker_id)} does not match lane worker ${lane.worker ?? "<none>"}`, { lane_id: lane.lane_id, worker: lane.worker ?? null });
9638
+ }
9639
+ const runDirPath = runDir(hqRoot, lane.lane_id);
9640
+ const loop = loopStatus(runDirPath, lane.lane_id);
9641
+ if (loop.state === "stopped" || loop.state === "failed") {
9642
+ throw new LanesError("loop_not_running", `Loop lane ${lane.lane_id} is ${loop.state}`, { lane_id: lane.lane_id, loop_state: loop.state });
9643
+ }
9644
+ const controllerAlive = loopControllerIsAlive(runDirPath);
9645
+ const result = enqueueLoopEnvelope({ runDir: runDirPath, laneId: lane.lane_id, envelope, now: ctx.now });
9646
+ if (!controllerAlive && lane.state === "running") {
9647
+ await runInterrupt(ctx, lane.lane_id, "loop controller exited; recovering the queued phase", { preserveLoopQueue: true, quiet: true });
9648
+ }
9649
+ const shouldRestart = !controllerAlive && lane.state !== "queued";
9650
+ const controllerPid = shouldRestart ? startLaneLoopController(ctx, lane.lane_id) : undefined;
9651
+ printJson({ ok: true, action: "enqueue", ...result, lane: lane.lane_id, ...(controllerPid ? { controller_pid: controllerPid } : {}) });
9652
+ }));
9338
9653
  lanes
9339
9654
  .command("reparent-target", { hidden: true })
9340
9655
  .argument("<lane-id>", "Lane id")
@@ -9412,6 +9727,7 @@ export function registerLanesCommand(parent, deps = {}) {
9412
9727
  .option("--story <id>", "Filter by story")
9413
9728
  .option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
9414
9729
  .option("--senior <ref>", "Filter by senior ref")
9730
+ .option("--worker <id>", "Filter by worker id")
9415
9731
  .option("--lane <id>", "Filter by lane id (repeatable)", (value, previous = []) => [...previous, value], [])
9416
9732
  .addOption(new Option("--lane-ulid <ulid>", "Filter by lane ULID for internal owner lookup").hideHelp()
9417
9733
  .argParser((value, previous = []) => [...previous, value]))
@@ -9600,6 +9916,31 @@ export function registerLanesCommand(parent, deps = {}) {
9600
9916
  .action(wrap(async (laneId) => {
9601
9917
  printJson(await messageHandlers.recoverQueuedMessages(ctx, laneId));
9602
9918
  }));
9919
+ lanes
9920
+ .command("_enqueue-blocked-escalation <recipient-kind> <recipient-id>", { hidden: true })
9921
+ .option("--text <text>", "Escalation notice")
9922
+ .option("--message-id <id>", "Idempotency key")
9923
+ .action(wrap(async (recipientKind, recipientId, opts) => {
9924
+ if (!ctx.isSsmOwnerDispatch())
9925
+ throw new LanesError("blocked_escalation_dispatch_forbidden", "Blocked escalation inbox writes require owner dispatch");
9926
+ if (recipientKind !== "lane" && recipientKind !== "session")
9927
+ throw new LanesError("blocked_escalation_recipient_invalid", `Unknown recipient kind ${recipientKind}`);
9928
+ if (!opts.text?.trim() || !opts.messageId?.trim())
9929
+ throw new LanesError("blocked_escalation_payload_invalid", "Escalation inbox write requires text and message id");
9930
+ const message = { at: ctx.now().toISOString(), message_id: opts.messageId, kind: "blocked_escalation", text: opts.text };
9931
+ if (recipientKind === "lane") {
9932
+ appendDropboxMessageOnce(ctx.hqRoot(), recipientId, message);
9933
+ }
9934
+ else {
9935
+ const dirs = ["claude", "codex", "grok"]
9936
+ .map((provider) => monitorSessionDir(ctx.hqRoot(), provider, recipientId))
9937
+ .filter((dir) => ctx.existsSync(dir));
9938
+ if (dirs.length !== 1)
9939
+ throw new LanesError("blocked_escalation_recipient_unavailable", `Expected one durable inbox for session ${recipientId}; found ${dirs.length}`);
9940
+ appendDropboxMessageOnceAtPath(path.join(dirs[0], "dropbox"), message);
9941
+ }
9942
+ printJson({ ok: true, recipient_kind: recipientKind, recipient_id: recipientId, message_id: opts.messageId });
9943
+ }));
9603
9944
  lanes
9604
9945
  .command("_spawn-envelope-repair", { hidden: true })
9605
9946
  .action(wrap(async () => {