@indigoai-us/hq-cli 5.147.0 → 5.149.1

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 (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +8 -11
  3. package/dist/command-catalog.generated.d.ts +129 -0
  4. package/dist/command-catalog.generated.js +168 -0
  5. package/dist/commands/lanes.js +506 -23
  6. package/dist/commands/mcp-registration.d.ts +29 -16
  7. package/dist/commands/mcp-registration.js +254 -17
  8. package/dist/commands/mesh.js +82 -11
  9. package/dist/lib/lanes/index.d.ts +3 -2
  10. package/dist/lib/lanes/index.js +2 -1
  11. package/dist/lib/lanes/mesh-events.d.ts +8 -0
  12. package/dist/lib/lanes/mesh-events.js +83 -6
  13. package/dist/lib/lanes/outpost-dispatch.d.ts +12 -0
  14. package/dist/lib/lanes/outpost-dispatch.js +17 -0
  15. package/dist/lib/lanes/reconcile.js +39 -12
  16. package/dist/lib/lanes/session-relink.d.ts +25 -0
  17. package/dist/lib/lanes/session-relink.js +216 -0
  18. package/dist/lib/lanes/spawn.d.ts +1 -0
  19. package/dist/lib/lanes/spawn.js +48 -5
  20. package/dist/lib/lanes/store.js +109 -1
  21. package/dist/lib/lanes/stories.d.ts +17 -0
  22. package/dist/lib/lanes/stories.js +73 -0
  23. package/dist/lib/lanes/types.d.ts +20 -0
  24. package/dist/lib/lanes/types.js +9 -2
  25. package/dist/lib/lanes/watcher.js +69 -9
  26. package/dist/lib/mesh/live/daemon/doctor.d.ts +27 -0
  27. package/dist/lib/mesh/live/daemon/doctor.js +84 -0
  28. package/dist/lib/mesh/live/daemon/index.d.ts +3 -3
  29. package/dist/lib/mesh/live/daemon/index.js +2 -2
  30. package/dist/lib/mesh/live/daemon/install.d.ts +40 -0
  31. package/dist/lib/mesh/live/daemon/install.js +88 -0
  32. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  33. package/dist/lib/mesh/live/daemon/run.js +12 -1
  34. package/dist/lib/mesh/live/daemon/state.d.ts +4 -0
  35. package/dist/lib/mesh/project-board.d.ts +15 -0
  36. package/dist/lib/mesh/project-board.js +84 -0
  37. package/dist/lib/work-context/prd-sync.d.ts +5 -1
  38. package/dist/lib/work-context/prd-sync.js +22 -1
  39. package/dist/sentry.js +15 -7
  40. package/dist/utils/sentry-fingerprint.d.ts +11 -0
  41. package/dist/utils/sentry-fingerprint.js +56 -11
  42. package/package.json +5 -3
  43. package/scripts/mesh-daemon-if-stale.mjs +16 -0
@@ -12,22 +12,24 @@ import * as os from "node:os";
12
12
  import * as path from "node:path";
13
13
  import { ensureCognitoToken } from "../utils/cognito-session.js";
14
14
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
15
- import { buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
15
+ import { buildOutpostLaneCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
16
16
  import { parseUserYaml } from "../utils/user-yaml-error.js";
17
17
  import { vaultApiFetch } from "../utils/vault-api.js";
18
18
  import { openMeshTransport } from "../lib/mesh/client.js";
19
19
  import { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.js";
20
20
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
21
21
  import { AccountResolutionError, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, isLoggedOut, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasRecordedSessionId, providerSupportsSessionResume, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
22
+ import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
22
23
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
23
24
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
24
25
  import { providerCompactAfterMs } from "../lib/lanes/config.js";
25
26
  import { parseCliEffort, resolveLaneEffort } from "../lib/lanes/effort.js";
26
27
  import { LanesError } from "../lib/lanes/errors.js";
27
28
  import { accountQuotaView, loadQuotaState, quotaBlockFor } from "../lib/lanes/quota.js";
28
- import { SESSION_MOVE_REASON, copySessionFiles, planSessionCopy } from "../lib/lanes/move-account.js";
29
+ import { SESSION_MOVE_REASON } from "../lib/lanes/move-account.js";
30
+ import { ensureSessionHome } from "../lib/lanes/session-relink.js";
29
31
  import { defaultProbe, validateProfileLaneModel, validateLaneModel, } from "../lib/lanes/models.js";
30
- import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, stopLaneProcesses, } from "../lib/lanes/spawn.js";
32
+ import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, laneRunDir, readSpawnManifest, stopLaneProcesses, } from "../lib/lanes/spawn.js";
31
33
  export { spawnLaneWorker };
32
34
  export { questionBodyAbsPath };
33
35
  export { MAX_LANE_DEPTH };
@@ -40,8 +42,21 @@ const VENDOR_LOGIN = {
40
42
  grok: { command: "grok", args: ["login"] },
41
43
  };
42
44
  export { emitLaneDecisionEvents };
45
+ function withoutProviderHome(value) {
46
+ if (Array.isArray(value))
47
+ return value.map(withoutProviderHome);
48
+ if (value === null || typeof value !== "object")
49
+ return value;
50
+ const safe = {};
51
+ for (const [key, nested] of Object.entries(value)) {
52
+ if (key === "provider_home_at_spawn")
53
+ continue;
54
+ safe[key] = withoutProviderHome(nested);
55
+ }
56
+ return safe;
57
+ }
43
58
  function printJson(value) {
44
- console.log(JSON.stringify(value, null, 2));
59
+ console.log(JSON.stringify(withoutProviderHome(value), null, 2));
45
60
  }
46
61
  function failJson(err) {
47
62
  if (isLanesError(err)) {
@@ -1196,6 +1211,26 @@ async function emitLaneMeshEvent(ctx, company, threadId, event, payload) {
1196
1211
  return;
1197
1212
  await ctx.mesh.appendThreadEvent(company, threadId, event, payload);
1198
1213
  }
1214
+ async function emitLaneStartedForStories(ctx, company, lane, threadId) {
1215
+ if (!threadId)
1216
+ return;
1217
+ const failures = [];
1218
+ for (const story of laneStories(lane)) {
1219
+ try {
1220
+ await emitLaneMeshEvent(ctx, company, threadId, "lane_started", {
1221
+ lane_id: lane.lane_id,
1222
+ project_id: story.project_id,
1223
+ story_id: story.story_id,
1224
+ });
1225
+ await ctx.mesh.patchStoryStatus(company, story.project_id, story.story_id, "in_progress");
1226
+ }
1227
+ catch (err) {
1228
+ failures.push(err);
1229
+ }
1230
+ }
1231
+ if (failures.length > 0)
1232
+ throw failures[0];
1233
+ }
1199
1234
  export const PICKUP_TERMINAL_REASONS = [
1200
1235
  "account_not_logged_in",
1201
1236
  "unknown_account",
@@ -1375,6 +1410,7 @@ async function runCreate(ctx, opts) {
1375
1410
  const meshCompanyFlag = opts.meshCompany?.trim();
1376
1411
  const project = opts.project?.trim();
1377
1412
  const story = opts.story?.trim();
1413
+ const additionalStories = (opts.additionalStory ?? []).map((value) => value.trim());
1378
1414
  const briefFile = opts.briefFile?.trim();
1379
1415
  if (!project) {
1380
1416
  throw new LanesError("missing_project", "--project is required", {});
@@ -1382,6 +1418,16 @@ async function runCreate(ctx, opts) {
1382
1418
  if (!story) {
1383
1419
  throw new LanesError("missing_story", "--story is required", {});
1384
1420
  }
1421
+ if (additionalStories.some((value) => value.length === 0)) {
1422
+ throw new LanesError("missing_story", "--additional-story values must be non-empty", {});
1423
+ }
1424
+ const storyIds = [story, ...additionalStories];
1425
+ if (new Set(storyIds).size !== storyIds.length) {
1426
+ throw new LanesError("duplicate_story", "A lane cannot carry the same project/story more than once", {
1427
+ project_id: project,
1428
+ stories: storyIds,
1429
+ });
1430
+ }
1385
1431
  if (!briefFile) {
1386
1432
  throw new LanesError("missing_brief_file", "--brief-file is required", {});
1387
1433
  }
@@ -1689,6 +1735,13 @@ async function runCreate(ctx, opts) {
1689
1735
  : {}),
1690
1736
  project_id: project,
1691
1737
  story_id: story,
1738
+ stories: storyIds.map((storyId) => ({
1739
+ project_id: project,
1740
+ story_id: storyId,
1741
+ status: "in_progress",
1742
+ added_at: createdAt,
1743
+ updated_at: createdAt,
1744
+ })),
1692
1745
  thread_id: threadId,
1693
1746
  depth: laneDepth,
1694
1747
  senior,
@@ -1771,14 +1824,8 @@ async function runCreate(ctx, opts) {
1771
1824
  runCompaction: ctx.runCompaction,
1772
1825
  locateTranscript: ctx.locateTranscript,
1773
1826
  });
1774
- if (meshCompany) {
1775
- await emitLaneMeshEvent(ctx, meshCompany, threadId, "lane_started", {
1776
- lane_id: started.lane_id,
1777
- project_id: project,
1778
- story_id: story,
1779
- });
1780
- await ctx.mesh.patchStoryStatus(meshCompany, project, story, "in_progress");
1781
- }
1827
+ if (meshCompany)
1828
+ await emitLaneStartedForStories(ctx, meshCompany, started, threadId);
1782
1829
  }
1783
1830
  else if (outpostId) {
1784
1831
  started = await dispatchCreatedOutpostLane(ctx, {
@@ -1810,6 +1857,8 @@ async function runCreate(ctx, opts) {
1810
1857
  company: started.company,
1811
1858
  project_id: started.project_id,
1812
1859
  story_id: started.story_id,
1860
+ stories: laneStories(started),
1861
+ story_warning: storyWarning(started) ?? null,
1813
1862
  thread_id: started.thread_id ?? null,
1814
1863
  senior: started.senior,
1815
1864
  depth: started.depth,
@@ -1906,6 +1955,9 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1906
1955
  }
1907
1956
  }
1908
1957
  function parseRemotePickupJson(stdout) {
1958
+ return parseRemoteJson(stdout, "Outpost pickup");
1959
+ }
1960
+ function parseRemoteJson(stdout, surface) {
1909
1961
  const trimmed = stdout.trim();
1910
1962
  if (!trimmed)
1911
1963
  return null;
@@ -1914,14 +1966,92 @@ function parseRemotePickupJson(stdout) {
1914
1966
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
1915
1967
  return parsed;
1916
1968
  }
1917
- process.stderr.write("hq lanes: Outpost pickup stdout is not a JSON object\n");
1969
+ process.stderr.write(`hq lanes: ${surface} stdout is not a JSON object\n`);
1918
1970
  }
1919
1971
  catch (err) {
1920
- process.stderr.write(`hq lanes: failed to parse Outpost pickup JSON: ${err instanceof Error ? err.message : String(err)}\n`);
1972
+ process.stderr.write(`hq lanes: failed to parse ${surface} JSON: ${err instanceof Error ? err.message : String(err)}\n`);
1921
1973
  return null;
1922
1974
  }
1923
1975
  return null;
1924
1976
  }
1977
+ function laneIsHostedHere(ctx, lane) {
1978
+ if (lane.host.kind !== "outpost")
1979
+ return true;
1980
+ const identity = ctx.readOutpostIdentity();
1981
+ if (identity.kind === "unknown") {
1982
+ throw new LanesError("outpost_identity_unknown", `Cannot determine whether this process is Outpost ${lane.host.id}; refusing local lane delivery`, { lane_id: lane.lane_id, outpost_id: lane.host.id });
1983
+ }
1984
+ return identity.kind === "id" && identity.id === lane.host.id;
1985
+ }
1986
+ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
1987
+ if (lane.host.kind !== "outpost") {
1988
+ throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch to`, { lane_id: lane.lane_id });
1989
+ }
1990
+ const outpostId = lane.host.id;
1991
+ let dispatched;
1992
+ try {
1993
+ dispatched = await ctx.dispatchOutpostExec({
1994
+ outpostId,
1995
+ command: buildOutpostLaneCommand({
1996
+ action,
1997
+ laneId: lane.lane_id,
1998
+ text: options.text,
1999
+ project: options.project,
2000
+ story: options.story,
2001
+ account: options.account,
2002
+ model: options.model,
2003
+ compact: options.compact,
2004
+ }),
2005
+ });
2006
+ }
2007
+ catch (err) {
2008
+ throw new LanesError("outpost_dispatch_failed", `Could not dispatch ${action} for lane ${lane.lane_id} to Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, outpost_id: outpostId });
2009
+ }
2010
+ if (dispatched.done !== true ||
2011
+ dispatched.status !== "Success" ||
2012
+ dispatched.exitCode !== 0) {
2013
+ throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} did not confirm the ${action} command`, {
2014
+ lane_id: lane.lane_id,
2015
+ outpost_id: outpostId,
2016
+ command_id: dispatched.commandId,
2017
+ status: dispatched.status,
2018
+ done: dispatched.done ?? false,
2019
+ exit_code: dispatched.exitCode,
2020
+ });
2021
+ }
2022
+ const remote = parseRemoteJson(dispatched.stdout, "Outpost lane command");
2023
+ if (remote?.error !== undefined) {
2024
+ throw new LanesError("outpost_remote_command_failed", typeof remote.message === "string"
2025
+ ? remote.message
2026
+ : `Outpost ${outpostId} rejected the ${action} command`, {
2027
+ lane_id: lane.lane_id,
2028
+ outpost_id: outpostId,
2029
+ command_id: dispatched.commandId,
2030
+ remote_error: remote.error,
2031
+ });
2032
+ }
2033
+ const resultConfirmed = remote !== null &&
2034
+ remote.ok === true &&
2035
+ remote.action === action &&
2036
+ remote.lane_id === lane.lane_id &&
2037
+ (action === "message"
2038
+ ? remote.accepted === true
2039
+ : remote.delivery_status === "worker_spawned" ||
2040
+ remote.delivery_status === "queued_on_target_account");
2041
+ if (!resultConfirmed) {
2042
+ throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without a confirmed ${action} result for lane ${lane.lane_id}`, {
2043
+ lane_id: lane.lane_id,
2044
+ outpost_id: outpostId,
2045
+ command_id: dispatched.commandId,
2046
+ });
2047
+ }
2048
+ printJson({
2049
+ ...remote,
2050
+ outpost_id: outpostId,
2051
+ outpost_command_id: dispatched.commandId,
2052
+ outpost_transport: "outposts_exec",
2053
+ });
2054
+ }
1925
2055
  function ingestPickupPayload(hqRoot, payload, now) {
1926
2056
  const laneId = payload.lane.lane_id;
1927
2057
  if (!laneId) {
@@ -2116,10 +2246,11 @@ async function runList(ctx, opts) {
2116
2246
  .filter((lane) => {
2117
2247
  if (state && lane.state !== state)
2118
2248
  return false;
2119
- if (opts.project && lane.project_id !== opts.project)
2120
- return false;
2121
- if (opts.story && lane.story_id !== opts.story)
2249
+ if ((opts.project || opts.story) &&
2250
+ !laneStories(lane).some((story) => (!opts.project || story.project_id === opts.project) &&
2251
+ (!opts.story || story.story_id === opts.story))) {
2122
2252
  return false;
2253
+ }
2123
2254
  if (company && !recordMatchesCompany(lane, company)) {
2124
2255
  return false;
2125
2256
  }
@@ -2222,6 +2353,9 @@ async function prepareAccountMove(ctx, lane, input) {
2222
2353
  if (!providerSupportsSessionResume(lane.provider)) {
2223
2354
  throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
2224
2355
  }
2356
+ if (lane.provider === "grok") {
2357
+ throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
2358
+ }
2225
2359
  if (!hasRecordedSessionId(lane)) {
2226
2360
  throw new LanesError("no_provider_session", `Lane ${laneId} has no recorded provider session id, so there is no session to move`, { lane_id: laneId, provider: lane.provider });
2227
2361
  }
@@ -2282,12 +2416,14 @@ async function prepareAccountMove(ctx, lane, input) {
2282
2416
  throw new AccountResolutionError("unknown_account", `Lane ${laneId} records account ${JSON.stringify(lane.account)} which is not configured for ${lane.provider}`, { provider: lane.provider, account: lane.account ?? "", enabled: [] });
2283
2417
  }
2284
2418
  // Checks done. Copy the session files, then write the record.
2285
- const copy = copySessionFiles(planSessionCopy(lane.provider, {
2286
- sourceHome: sourceCfg.home,
2287
- targetHome: targetCfg.home,
2288
- cwd: hqRoot,
2419
+ const copy = ensureSessionHome({
2420
+ provider: lane.provider,
2289
2421
  sessionId,
2290
- }));
2422
+ cwd: hqRoot,
2423
+ targetHome: targetCfg.home,
2424
+ registeredHomes: Object.values(providerCfg.accounts).map((account) => account.home),
2425
+ providerHomeAtSpawn: lane.provider_home_at_spawn ?? sourceCfg.home,
2426
+ });
2291
2427
  const at = now.toISOString();
2292
2428
  const from = lane.account ?? null;
2293
2429
  // The capacity decision and the record write run under the target
@@ -2317,6 +2453,8 @@ async function prepareAccountMove(ctx, lane, input) {
2317
2453
  const next = {
2318
2454
  ...current,
2319
2455
  account: target,
2456
+ provider_session_id: sessionId,
2457
+ provider_session_id_absent: undefined,
2320
2458
  ...(input.cliModel !== undefined
2321
2459
  ? { model: input.cliModel, model_source: "cli" }
2322
2460
  : {}),
@@ -2344,6 +2482,11 @@ async function runMessage(ctx, laneId, opts) {
2344
2482
  throw new LanesError("missing_text", "--text is required", {});
2345
2483
  }
2346
2484
  let lane = readLane(hqRoot, laneId);
2485
+ if (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane)) {
2486
+ await dispatchOutpostLaneCommand(ctx, lane, "message", opts);
2487
+ return;
2488
+ }
2489
+ lane = laneWithRecoveredProviderSession(hqRoot, lane);
2347
2490
  // An account move is refused on its own terms first: a running lane and a
2348
2491
  // provider without session resume get the move's named errors, not the
2349
2492
  // generic delivery refusal below.
@@ -2413,7 +2556,6 @@ async function runMessage(ctx, laneId, opts) {
2413
2556
  // inbox, which would hand it to the drain hook a second time) and the
2414
2557
  // spawn happens before any attribution write so a failed spawn leaves
2415
2558
  // the record untouched.
2416
- archiveResumeMessage(hqRoot, laneId, dropboxMessage);
2417
2559
  resumed = spawnLaneWorker(hqRoot, lane, {
2418
2560
  env: ctx.env,
2419
2561
  modelDeps: {
@@ -2433,6 +2575,7 @@ async function runMessage(ctx, laneId, opts) {
2433
2575
  trigger: { kind: "message", text, message_id: messageId },
2434
2576
  compact: opts.compact !== false,
2435
2577
  });
2578
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
2436
2579
  }
2437
2580
  // Every accepted message moves the compaction reference forward.
2438
2581
  let next = casUpdateLane(hqRoot, laneId, (current) => ({
@@ -2448,6 +2591,18 @@ async function runMessage(ctx, laneId, opts) {
2448
2591
  ...current,
2449
2592
  project_id: projectId,
2450
2593
  story_id: storyId,
2594
+ stories: laneStory(current, projectId, storyId)
2595
+ ? laneStories(current)
2596
+ : [
2597
+ ...laneStories(current),
2598
+ {
2599
+ project_id: projectId,
2600
+ story_id: storyId,
2601
+ status: "in_progress",
2602
+ added_at: at,
2603
+ updated_at: at,
2604
+ },
2605
+ ],
2451
2606
  attribution_history: appendCappedHistory(current.attribution_history, {
2452
2607
  project_id: projectId,
2453
2608
  story_id: storyId,
@@ -2493,6 +2648,8 @@ async function runMessage(ctx, laneId, opts) {
2493
2648
  account_move: move ? accountMoveSummary(move) : null,
2494
2649
  project_id: next.project_id,
2495
2650
  story_id: next.story_id,
2651
+ stories: laneStories(next),
2652
+ story_warning: storyWarning(next) ?? null,
2496
2653
  lane: next,
2497
2654
  });
2498
2655
  }
@@ -2508,6 +2665,30 @@ function accountMoveSummary(move) {
2508
2665
  target_dir: move.copy.target_dir,
2509
2666
  };
2510
2667
  }
2668
+ function laneWithRecoveredProviderSession(hqRoot, lane) {
2669
+ if (lane.provider === "grok")
2670
+ return lane;
2671
+ const manifest = readSpawnManifest(laneRunDir(hqRoot, lane.lane_id));
2672
+ const sessionId = (typeof lane.provider_session_id === "string" && lane.provider_session_id.trim()
2673
+ ? lane.provider_session_id
2674
+ : undefined) ??
2675
+ (typeof lane.last_resume?.session_id === "string" && lane.last_resume.session_id.trim()
2676
+ ? lane.last_resume.session_id
2677
+ : undefined) ??
2678
+ (typeof manifest.resume?.session_id === "string" && manifest.resume.session_id.trim()
2679
+ ? manifest.resume.session_id
2680
+ : undefined);
2681
+ const providerHome = lane.provider_home_at_spawn ?? manifest.provider_home_at_spawn;
2682
+ return {
2683
+ ...lane,
2684
+ ...(lane.provider_session_id === undefined && sessionId !== undefined
2685
+ ? { provider_session_id: sessionId, provider_session_id_absent: undefined }
2686
+ : {}),
2687
+ ...(lane.provider_home_at_spawn === undefined && providerHome !== undefined
2688
+ ? { provider_home_at_spawn: providerHome }
2689
+ : {}),
2690
+ };
2691
+ }
2511
2692
  /**
2512
2693
  * `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
2513
2694
  * continues a lane in `interrupted`, `done` or `blocked` on its recorded
@@ -2520,6 +2701,11 @@ function accountMoveSummary(move) {
2520
2701
  async function runResume(ctx, laneId, opts) {
2521
2702
  const hqRoot = ctx.hqRoot();
2522
2703
  let lane = readLane(hqRoot, laneId);
2704
+ if (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane)) {
2705
+ await dispatchOutpostLaneCommand(ctx, lane, "resume", opts);
2706
+ return;
2707
+ }
2708
+ lane = laneWithRecoveredProviderSession(hqRoot, lane);
2523
2709
  if (opts.model !== undefined && opts.account === undefined) {
2524
2710
  throw new LanesError("model_requires_account", "--model on hq lanes resume is only accepted together with --account", {});
2525
2711
  }
@@ -2618,6 +2804,18 @@ async function runResume(ctx, laneId, opts) {
2618
2804
  ...current,
2619
2805
  project_id: projectId,
2620
2806
  story_id: storyId,
2807
+ stories: laneStory(current, projectId, storyId)
2808
+ ? laneStories(current)
2809
+ : [
2810
+ ...laneStories(current),
2811
+ {
2812
+ project_id: projectId,
2813
+ story_id: storyId,
2814
+ status: "in_progress",
2815
+ added_at: at,
2816
+ updated_at: at,
2817
+ },
2818
+ ],
2621
2819
  attribution_history: appendCappedHistory(current.attribution_history, {
2622
2820
  project_id: projectId,
2623
2821
  story_id: storyId,
@@ -2644,9 +2842,245 @@ async function runResume(ctx, laneId, opts) {
2644
2842
  : null,
2645
2843
  project_id: next.project_id,
2646
2844
  story_id: next.story_id,
2845
+ stories: laneStories(next),
2846
+ story_warning: storyWarning(next) ?? null,
2647
2847
  lane: next,
2648
2848
  });
2649
2849
  }
2850
+ function storyCommandTarget(lane, storyId, project) {
2851
+ const projectId = project?.trim() || lane.project_id;
2852
+ const normalizedStoryId = storyId.trim();
2853
+ if (!projectId) {
2854
+ throw new LanesError("missing_project", "--project is required", {});
2855
+ }
2856
+ if (!normalizedStoryId) {
2857
+ throw new LanesError("missing_story", "<story-id> is required", {});
2858
+ }
2859
+ const story = laneStory(lane, projectId, normalizedStoryId);
2860
+ if (!story) {
2861
+ throw new LanesError("story_not_on_lane", `Story ${projectId}/${normalizedStoryId} is not carried by lane ${lane.lane_id}`, { lane_id: lane.lane_id, project_id: projectId, story_id: normalizedStoryId });
2862
+ }
2863
+ return { projectId, storyId: normalizedStoryId, story };
2864
+ }
2865
+ function updateCarriedStory(lane, projectId, storyId, status, at, extras = {}) {
2866
+ const stories = laneStories(lane).map((story) => story.project_id === projectId && story.story_id === storyId
2867
+ ? {
2868
+ ...story,
2869
+ status,
2870
+ updated_at: at,
2871
+ blocked_reason: extras.blocked_reason,
2872
+ verified_at: extras.verified_at,
2873
+ verified_by: extras.verified_by,
2874
+ }
2875
+ : story);
2876
+ return {
2877
+ ...lane,
2878
+ project_id: projectId,
2879
+ story_id: storyId,
2880
+ stories,
2881
+ };
2882
+ }
2883
+ function addCarriedStory(lane, projectId, storyId, at) {
2884
+ if (laneStory(lane, projectId, storyId)) {
2885
+ throw new LanesError("story_already_on_lane", `Story ${projectId}/${storyId} is already carried by lane ${lane.lane_id}`, {
2886
+ lane_id: lane.lane_id,
2887
+ project_id: projectId,
2888
+ story_id: storyId,
2889
+ });
2890
+ }
2891
+ return {
2892
+ ...lane,
2893
+ project_id: projectId,
2894
+ story_id: storyId,
2895
+ stories: [
2896
+ ...laneStories(lane),
2897
+ { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
2898
+ ],
2899
+ };
2900
+ }
2901
+ function callerMayVerifyStory(ctx, lane) {
2902
+ const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
2903
+ if (callerLaneId) {
2904
+ return lane.senior.kind === "lane" && lane.senior.id === callerLaneId;
2905
+ }
2906
+ if (lane.senior.kind === "user")
2907
+ return true;
2908
+ if (lane.senior.kind === "lane")
2909
+ return false;
2910
+ return sessionIdFromEnv(ctx.env) === lane.senior.id;
2911
+ }
2912
+ async function ensureStoryThread(ctx, lane, projectId) {
2913
+ const company = lane.mesh_company ?? lane.company;
2914
+ if (!company)
2915
+ return lane;
2916
+ const threadId = await ctx.mesh.ensureProjectThread(company, projectId);
2917
+ if (threadId === lane.thread_id)
2918
+ return lane;
2919
+ return casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => ({
2920
+ ...current,
2921
+ thread_id: threadId,
2922
+ }));
2923
+ }
2924
+ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status, payload = {}) {
2925
+ const company = lane.mesh_company ?? lane.company;
2926
+ if (!company)
2927
+ return;
2928
+ const ready = await ensureStoryThread(ctx, lane, projectId);
2929
+ if (!ready.thread_id)
2930
+ return;
2931
+ await emitLaneMeshEvent(ctx, company, ready.thread_id, event, {
2932
+ lane_id: ready.lane_id,
2933
+ project_id: projectId,
2934
+ story_id: storyId,
2935
+ ...payload,
2936
+ });
2937
+ await ctx.mesh.patchStoryStatus(company, projectId, storyId, status === "blocked" ? "in_progress" : status);
2938
+ }
2939
+ function sameCommittedStory(current, expected, projectId, storyId) {
2940
+ const currentStory = laneStory(current, projectId, storyId);
2941
+ const expectedStory = laneStory(expected, projectId, storyId);
2942
+ return (current.project_id === expected.project_id &&
2943
+ current.story_id === expected.story_id &&
2944
+ JSON.stringify(currentStory) === JSON.stringify(expectedStory));
2945
+ }
2946
+ async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}) {
2947
+ try {
2948
+ await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload);
2949
+ }
2950
+ catch (err) {
2951
+ try {
2952
+ const current = readLane(ctx.hqRoot(), next.lane_id);
2953
+ if (!sameCommittedStory(current, next, projectId, storyId)) {
2954
+ throw new LanesError("story_transition_rollback_conflict", `Story ${projectId}/${storyId} changed while mesh publication failed`, { lane_id: next.lane_id, project_id: projectId, story_id: storyId });
2955
+ }
2956
+ updateLane(ctx.hqRoot(), next.lane_id, current.version, "command", (latest) => {
2957
+ if (!sameCommittedStory(latest, next, projectId, storyId)) {
2958
+ throw new LanesError("story_transition_rollback_conflict", `Story ${projectId}/${storyId} changed before rollback`, { lane_id: next.lane_id, project_id: projectId, story_id: storyId });
2959
+ }
2960
+ return {
2961
+ ...latest,
2962
+ project_id: before.project_id,
2963
+ story_id: before.story_id,
2964
+ stories: before.stories,
2965
+ };
2966
+ });
2967
+ }
2968
+ catch (rollbackErr) {
2969
+ process.stderr.write(`hq lanes: story transition rollback failed for ${next.lane_id}/${projectId}/${storyId}: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}\n`);
2970
+ }
2971
+ throw err;
2972
+ }
2973
+ }
2974
+ async function runStoryList(ctx, laneId) {
2975
+ const lane = readLane(ctx.hqRoot(), laneId);
2976
+ printJson({
2977
+ ok: true,
2978
+ action: "story.list",
2979
+ lane_id: laneId,
2980
+ stories: laneStories(lane),
2981
+ active_stories: activeLaneStories(lane),
2982
+ story_warning: storyWarning(lane) ?? null,
2983
+ });
2984
+ }
2985
+ async function runStoryAdd(ctx, laneId, storyId, opts) {
2986
+ const hqRoot = ctx.hqRoot();
2987
+ let lane = readLane(hqRoot, laneId);
2988
+ const projectId = opts.project?.trim() || lane.project_id;
2989
+ if (!projectId)
2990
+ throw new LanesError("missing_project", "--project is required", {});
2991
+ const normalizedStoryId = storyId.trim();
2992
+ if (!normalizedStoryId)
2993
+ throw new LanesError("missing_story", "<story-id> is required", {});
2994
+ const at = ctx.now().toISOString();
2995
+ lane = await ensureStoryThread(ctx, lane, projectId);
2996
+ const before = lane;
2997
+ const next = casUpdateLane(hqRoot, laneId, (current) => addCarriedStory(current, projectId, normalizedStoryId, at));
2998
+ await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
2999
+ const finalLane = readLane(hqRoot, laneId);
3000
+ printJson({
3001
+ ok: true,
3002
+ action: "story.add",
3003
+ lane_id: laneId,
3004
+ project_id: projectId,
3005
+ story_id: normalizedStoryId,
3006
+ status: "in_progress",
3007
+ story_warning: storyWarning(finalLane) ?? null,
3008
+ lane: finalLane,
3009
+ });
3010
+ }
3011
+ async function runStoryStart(ctx, laneId, storyId, opts) {
3012
+ const hqRoot = ctx.hqRoot();
3013
+ let lane = readLane(hqRoot, laneId);
3014
+ const target = storyCommandTarget(lane, storyId, opts.project);
3015
+ if (target.story.status === "done") {
3016
+ throw new LanesError("story_already_done", `Story ${target.projectId}/${target.storyId} is done and cannot be started again`, {
3017
+ lane_id: laneId,
3018
+ project_id: target.projectId,
3019
+ story_id: target.storyId,
3020
+ });
3021
+ }
3022
+ const at = ctx.now().toISOString();
3023
+ lane = await ensureStoryThread(ctx, lane, target.projectId);
3024
+ const before = lane;
3025
+ const next = casUpdateLane(hqRoot, laneId, (current) => updateCarriedStory(current, target.projectId, target.storyId, "in_progress", at));
3026
+ await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_started", "in_progress");
3027
+ const finalLane = readLane(hqRoot, laneId);
3028
+ printJson({ ok: true, action: "story.start", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "in_progress", story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
3029
+ }
3030
+ async function runStoryBlock(ctx, laneId, storyId, opts) {
3031
+ const hqRoot = ctx.hqRoot();
3032
+ let lane = readLane(hqRoot, laneId);
3033
+ const target = storyCommandTarget(lane, storyId, opts.project);
3034
+ if (target.story.status === "done") {
3035
+ throw new LanesError("story_already_done", `Story ${target.projectId}/${target.storyId} is done and cannot be blocked again`, {
3036
+ lane_id: laneId,
3037
+ project_id: target.projectId,
3038
+ story_id: target.storyId,
3039
+ });
3040
+ }
3041
+ const reason = opts.reason?.trim();
3042
+ if (!reason)
3043
+ throw new LanesError("missing_block_reason", "--reason is required", {});
3044
+ const at = ctx.now().toISOString();
3045
+ lane = await ensureStoryThread(ctx, lane, target.projectId);
3046
+ const before = lane;
3047
+ const next = casUpdateLane(hqRoot, laneId, (current) => updateCarriedStory(current, target.projectId, target.storyId, "blocked", at, { blocked_reason: reason }));
3048
+ await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_blocked", "blocked", { blocked_reason: reason });
3049
+ const finalLane = readLane(hqRoot, laneId);
3050
+ printJson({ ok: true, action: "story.block", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "blocked", blocked_reason: reason, story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
3051
+ }
3052
+ async function runStoryDone(ctx, laneId, storyId, opts) {
3053
+ const hqRoot = ctx.hqRoot();
3054
+ let lane = readLane(hqRoot, laneId);
3055
+ if (!opts.verified) {
3056
+ throw new LanesError("senior_verification_required", "Only a senior may mark a story done; pass --verified after reviewing or merging it", { lane_id: laneId });
3057
+ }
3058
+ if (!callerMayVerifyStory(ctx, lane)) {
3059
+ throw new LanesError("senior_verification_required", `Caller is not the senior for lane ${laneId}`, {
3060
+ lane_id: laneId,
3061
+ senior: lane.senior,
3062
+ });
3063
+ }
3064
+ const target = storyCommandTarget(lane, storyId, opts.project);
3065
+ if (target.story.status !== "review") {
3066
+ throw new LanesError("story_not_ready_for_verification", `Story ${target.projectId}/${target.storyId} must be in review before senior verification`, {
3067
+ lane_id: laneId,
3068
+ project_id: target.projectId,
3069
+ story_id: target.storyId,
3070
+ status: target.story.status,
3071
+ });
3072
+ }
3073
+ const at = ctx.now().toISOString();
3074
+ lane = await ensureStoryThread(ctx, lane, target.projectId);
3075
+ const before = lane;
3076
+ const next = casUpdateLane(hqRoot, laneId, (current) => updateCarriedStory(current, target.projectId, target.storyId, "done", at, {
3077
+ verified_at: at,
3078
+ verified_by: current.senior,
3079
+ }));
3080
+ await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_done", "done", { summary: "Senior verification complete" });
3081
+ const finalLane = readLane(hqRoot, laneId);
3082
+ printJson({ ok: true, action: "story.done", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "done", verified: true, story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
3083
+ }
2650
3084
  /**
2651
3085
  * Registration state of the drain hook in the lane's account home, read the
2652
3086
  * way the provider reads it. Null when the lane has no bound account yet
@@ -3443,6 +3877,7 @@ export function registerLanesCommand(parent, deps = {}) {
3443
3877
  .option("--mesh-company <slug|uid>", "Explicit Work Mesh company for a personal lane; membership is verified")
3444
3878
  .requiredOption("--project <slug>", "HQ project slug")
3445
3879
  .requiredOption("--story <id>", "Story id from the project's prd.json")
3880
+ .option("--additional-story <id>", "Additional story carried by this lane; repeatable and discouraged when active", collectRepeatable)
3446
3881
  .requiredOption("--brief-file <path>", "Path to the lane brief")
3447
3882
  .option("--worker <name>", "HQ worker id from the registry")
3448
3883
  .option("--provider <name>", "claude | codex | grok")
@@ -3489,6 +3924,54 @@ export function registerLanesCommand(parent, deps = {}) {
3489
3924
  .action(wrap(async (laneId) => {
3490
3925
  await runShow(ctx, laneId);
3491
3926
  }));
3927
+ const stories = lanes
3928
+ .command("story")
3929
+ .description("Track several project stories on one lane");
3930
+ stories
3931
+ .command("list")
3932
+ .description("List the stories carried by a lane")
3933
+ .argument("<lane-id>", "Lane id")
3934
+ .action(wrap(async (laneId) => {
3935
+ await runStoryList(ctx, laneId);
3936
+ }));
3937
+ stories
3938
+ .command("add")
3939
+ .description("Add and start another story on a lane")
3940
+ .argument("<lane-id>", "Lane id")
3941
+ .argument("<story-id>", "Story id")
3942
+ .option("--project <slug>", "Project id; defaults to the lane's current project")
3943
+ .action(wrap(async (laneId, storyId, opts) => {
3944
+ await runStoryAdd(ctx, laneId, storyId, opts);
3945
+ }));
3946
+ stories
3947
+ .command("start")
3948
+ .description("Start or resume a carried story")
3949
+ .argument("<lane-id>", "Lane id")
3950
+ .argument("<story-id>", "Story id")
3951
+ .option("--project <slug>", "Project id; defaults to the lane's current project")
3952
+ .action(wrap(async (laneId, storyId, opts) => {
3953
+ await runStoryStart(ctx, laneId, storyId, opts);
3954
+ }));
3955
+ stories
3956
+ .command("block")
3957
+ .description("Mark one carried story blocked")
3958
+ .argument("<lane-id>", "Lane id")
3959
+ .argument("<story-id>", "Story id")
3960
+ .requiredOption("--reason <text>", "Why the story is blocked")
3961
+ .option("--project <slug>", "Project id; defaults to the lane's current project")
3962
+ .action(wrap(async (laneId, storyId, opts) => {
3963
+ await runStoryBlock(ctx, laneId, storyId, opts);
3964
+ }));
3965
+ stories
3966
+ .command("done")
3967
+ .description("Mark a reviewed story done after senior verification")
3968
+ .argument("<lane-id>", "Lane id")
3969
+ .argument("<story-id>", "Story id")
3970
+ .requiredOption("--verified", "Confirm senior review or merge")
3971
+ .option("--project <slug>", "Project id; defaults to the lane's current project")
3972
+ .action(wrap(async (laneId, storyId, opts) => {
3973
+ await runStoryDone(ctx, laneId, storyId, opts);
3974
+ }));
3492
3975
  lanes
3493
3976
  .command("message")
3494
3977
  .description("Send a follow-up message to a lane")