@indigoai-us/hq-cli 5.299.0 → 5.300.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.300.0] — 2026-09-30
6
+ - With the default-off hq-flags gate `files.share-empty-prefix-warning`, `hq files share` warns when a granted prefix has no files. A failed or timed-out list check does not block the grant.
7
+
8
+ - Lane messages and resumes that switch project or story attribution now tell the provider which story is active. Reused completion artifacts no longer close the new story.
9
+ - Make signed-out `hq sync` commands fail fast with `hq login` guidance when stdin or stdout is not a terminal, while keeping browser login in a terminal.
10
+
5
11
  ## [5.299.0] — 2026-09-30
6
12
  - Add `hq lanes accounts clear-quota` and clear future quota marks after successful lane turns.
7
13
  - On macOS, `hq daemon` retires a loaded legacy mesh LaunchAgent even when its plist is missing, and daemon uninstall boots the job out before deleting its plist. Mesh-owner wait logs now repeat at most every ten minutes.
@@ -8509,10 +8509,10 @@ export declare const COMMAND_CATALOG: readonly [{
8509
8509
  readonly description: "Message text";
8510
8510
  }, {
8511
8511
  readonly flags: "--project <slug>";
8512
- readonly description: "Re-point project attribution from this message";
8512
+ readonly description: "Re-point project attribution; a changed story is named in the resumed prompt";
8513
8513
  }, {
8514
8514
  readonly flags: "--story <id>";
8515
- readonly description: "Re-point story attribution from this message";
8515
+ readonly description: "Re-point story attribution; a changed story is named in the resumed prompt";
8516
8516
  }, {
8517
8517
  readonly flags: "--account <id>";
8518
8518
  readonly description: "Move the lane's session to this account of its provider before the message resumes it";
@@ -8602,10 +8602,10 @@ export declare const COMMAND_CATALOG: readonly [{
8602
8602
  readonly description: "Required explanation for resuming from failed";
8603
8603
  }, {
8604
8604
  readonly flags: "--project <slug>";
8605
- readonly description: "Re-point project attribution from this resume";
8605
+ readonly description: "Re-point project attribution; a changed story is named in the resumed prompt";
8606
8606
  }, {
8607
8607
  readonly flags: "--story <id>";
8608
- readonly description: "Re-point story attribution from this resume";
8608
+ readonly description: "Re-point story attribution; a changed story is named in the resumed prompt";
8609
8609
  }, {
8610
8610
  readonly flags: "--account <id>";
8611
8611
  readonly description: "Move the lane's session to this account of its provider before resuming";
@@ -10972,11 +10972,11 @@ export const COMMAND_CATALOG = [
10972
10972
  },
10973
10973
  {
10974
10974
  "flags": "--project <slug>",
10975
- "description": "Re-point project attribution from this message"
10975
+ "description": "Re-point project attribution; a changed story is named in the resumed prompt"
10976
10976
  },
10977
10977
  {
10978
10978
  "flags": "--story <id>",
10979
- "description": "Re-point story attribution from this message"
10979
+ "description": "Re-point story attribution; a changed story is named in the resumed prompt"
10980
10980
  },
10981
10981
  {
10982
10982
  "flags": "--account <id>",
@@ -11094,11 +11094,11 @@ export const COMMAND_CATALOG = [
11094
11094
  },
11095
11095
  {
11096
11096
  "flags": "--project <slug>",
11097
- "description": "Re-point project attribution from this resume"
11097
+ "description": "Re-point project attribution; a changed story is named in the resumed prompt"
11098
11098
  },
11099
11099
  {
11100
11100
  "flags": "--story <id>",
11101
- "description": "Re-point story attribution from this resume"
11101
+ "description": "Re-point story attribution; a changed story is named in the resumed prompt"
11102
11102
  },
11103
11103
  {
11104
11104
  "flags": "--account <id>",
@@ -53,6 +53,11 @@ export class SyncExitError extends Error {
53
53
  this.health = opts.health ?? "failed";
54
54
  }
55
55
  }
56
+ async function ensureSyncCognitoToken() {
57
+ return ensureCognitoToken({
58
+ interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
59
+ });
60
+ }
56
61
  /**
57
62
  * Preserve the vault's typed write-scope refusal at the CLI boundary. This is
58
63
  * deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
@@ -845,11 +850,11 @@ export function registerCloudCommands(program) {
845
850
  }
846
851
  // Upload creds are pre-vended in entityContext, but prefix filtering
847
852
  // still needs vault-service membership/grant resolution.
848
- const accessToken = await ensureCognitoToken();
853
+ const accessToken = await ensureSyncCognitoToken();
849
854
  scopeVaultConfig = buildVaultConfig(accessToken);
850
855
  }
851
856
  else {
852
- const accessToken = await ensureCognitoToken();
857
+ const accessToken = await ensureSyncCognitoToken();
853
858
  vaultConfig = buildVaultConfig(accessToken);
854
859
  scopeVaultConfig = vaultConfig;
855
860
  }
@@ -1064,7 +1069,7 @@ export function registerCloudCommands(program) {
1064
1069
  console.log(chalk.bold("\nHQ Sync — Pull"));
1065
1070
  console.log(` HQ root: ${options.hqRoot}`);
1066
1071
  console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
1067
- const accessToken = await ensureCognitoToken();
1072
+ const accessToken = await ensureSyncCognitoToken();
1068
1073
  const vaultConfig = buildVaultConfig(accessToken);
1069
1074
  // US-011 (2026-05-21 fix): resolve the caller's sync-config for
1070
1075
  // the targeted membership BEFORE the pull, so we can (a) emit the
@@ -1368,7 +1373,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
1368
1373
  console.log("");
1369
1374
  let result;
1370
1375
  try {
1371
- const accessToken = await ensureCognitoToken();
1376
+ const accessToken = await ensureSyncCognitoToken();
1372
1377
  const vaultConfig = buildVaultConfig(accessToken);
1373
1378
  const realClient = new VaultClient(vaultConfig);
1374
1379
  const adapter = {
@@ -1461,7 +1466,7 @@ export async function runPullPersonal(hqRoot, onConflict) {
1461
1466
  console.log(` HQ root: ${hqRoot}`);
1462
1467
  console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1463
1468
  try {
1464
- const accessToken = await ensureCognitoToken();
1469
+ const accessToken = await ensureSyncCognitoToken();
1465
1470
  const vaultConfig = buildVaultConfig(accessToken);
1466
1471
  const client = new VaultClient(vaultConfig);
1467
1472
  const personalUid = await resolveCanonicalPersonUid({
@@ -1509,7 +1514,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1509
1514
  console.log("");
1510
1515
  let result;
1511
1516
  try {
1512
- const accessToken = await ensureCognitoToken();
1517
+ const accessToken = await ensureSyncCognitoToken();
1513
1518
  const vaultConfig = buildVaultConfig(accessToken);
1514
1519
  const realClient = new VaultClient(vaultConfig);
1515
1520
  const author = resolveUploadAuthor();
@@ -1637,7 +1642,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1637
1642
  console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
1638
1643
  console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1639
1644
  try {
1640
- const accessToken = await ensureCognitoToken();
1645
+ const accessToken = await ensureSyncCognitoToken();
1641
1646
  const vaultConfig = buildVaultConfig(accessToken);
1642
1647
  const author = resolveUploadAuthor();
1643
1648
  // Resolve the target. For --personal, look up the canonical person and
@@ -1,4 +1,5 @@
1
1
  import { Command } from "commander";
2
+ export declare const FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG = "files.share-empty-prefix-warning";
2
3
  /**
3
4
  * Parse a human-friendly duration string ("15m", "1h", "24h", "2d") into
4
5
  * milliseconds. Returns null on parse failure. Mirrors the parser used in
@@ -12,7 +12,10 @@ import { fetchChannels, matchChannelsByName } from "./dm.js";
12
12
  import { resolveCompanySlug, resolvePersonWithRosterFallback, } from "./people.js";
13
13
  import { findHqRoot } from "../utils/manifest.js";
14
14
  import { maxLength } from "../utils/table-width.js";
15
+ import { resolveCompanyFlagGate } from "../lib/flag-registry.js";
15
16
  const PERSON_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
17
+ export const FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG = "files.share-empty-prefix-warning";
18
+ const FILES_SHARE_EMPTY_PREFIX_CHECK_TIMEOUT_MS = 5_000;
16
19
  function collectMentions(value, previous) {
17
20
  return [...previous, value];
18
21
  }
@@ -589,6 +592,56 @@ export function registerFilesCommand(program) {
589
592
  // files-browse.ts) onto the same group without re-creating it.
590
593
  return files;
591
594
  }
595
+ /** Best-effort, default-off warning after the ACL grant has succeeded. */
596
+ async function warnIfEmptySharePrefix(params) {
597
+ if (isPrivateFolderPattern(params.prefix))
598
+ return;
599
+ let enabled;
600
+ try {
601
+ enabled = await resolveCompanyFlagGate(params.companyUid, params.token, FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG, { fallback: false }, () => false);
602
+ }
603
+ catch (error) {
604
+ const errorClass = error instanceof Error ? error.name : "unknown error";
605
+ console.error(`hq: Could not resolve ${FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG} (${errorClass}); using its fallback.`);
606
+ return;
607
+ }
608
+ if (!enabled)
609
+ return;
610
+ const listPrefix = params.fullVault
611
+ ? ""
612
+ : params.prefix.endsWith("*")
613
+ ? params.prefix.slice(0, -1)
614
+ : params.prefix;
615
+ try {
616
+ const query = {
617
+ company: params.companyUid,
618
+ };
619
+ if (listPrefix)
620
+ query.prefix = listPrefix;
621
+ const listed = await vaultApiFetch({
622
+ token: params.token,
623
+ path: "/v1/files/list",
624
+ query,
625
+ signal: AbortSignal.timeout(FILES_SHARE_EMPTY_PREFIX_CHECK_TIMEOUT_MS),
626
+ });
627
+ if (!listed.ok)
628
+ throw new Error(`files list failed (${listed.status})`);
629
+ const body = (await listed.json());
630
+ const objects = Array.isArray(body.objects) ? body.objects : [];
631
+ const hasMatchingObject = params.fullVault || params.prefix.endsWith("*")
632
+ ? objects.length > 0
633
+ : objects.some((object) => object.key === listPrefix);
634
+ if (!hasMatchingObject) {
635
+ console.warn(chalk.yellow(`Warning: no files exist under '${displayAclPattern(params.prefix)}'. ` +
636
+ `The recipient will see nothing until files sync there.`));
637
+ }
638
+ }
639
+ catch (error) {
640
+ const errorClass = error instanceof Error ? error.name : "unknown error";
641
+ console.warn(chalk.yellow(`Could not check whether '${displayAclPattern(params.prefix)}' has files; ` +
642
+ `the share succeeded (${errorClass}).`));
643
+ }
644
+ }
592
645
  async function resolveShareChannelTarget(params) {
593
646
  const token = (await resolveVaultCredential()).token;
594
647
  const channels = await fetchChannels(token);
@@ -757,6 +810,12 @@ async function runDirectGrant(params) {
757
810
  }
758
811
  const data = (await res.json());
759
812
  const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
813
+ await warnIfEmptySharePrefix({
814
+ token,
815
+ companyUid,
816
+ prefix: canonicalPrefix,
817
+ fullVault: params.fullVault === true,
818
+ });
760
819
  const verb = autoCreated ? "Created ACL and granted" : "Granted";
761
820
  if (params.fullVault) {
762
821
  console.log(chalk.green(`${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`));
@@ -8,6 +8,7 @@ import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
8
8
  import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages } from "../lib/lanes/dropbox.js";
9
9
  import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision } from "../lib/lanes/watcher.js";
10
10
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
11
+ import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
11
12
  import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
12
13
  import { keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
13
14
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
@@ -212,8 +213,11 @@ export function createLaneMessageHandlers(deps) {
212
213
  })();
213
214
  let accepted = false;
214
215
  let claimedMessages = [];
216
+ let storySwitchRollback;
217
+ let storySwitchDeliveryStarted = false;
215
218
  const markAccepted = () => {
216
219
  accepted = true;
220
+ storySwitchDeliveryStarted = true;
217
221
  opts.onAccepted?.();
218
222
  };
219
223
  let text = opts.text;
@@ -392,19 +396,21 @@ export function createLaneMessageHandlers(deps) {
392
396
  if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
393
397
  validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
394
398
  }
399
+ const notice = storySwitchNotice(lane.project_id, lane.story_id, projectId, storyId);
400
+ const promptedText = notice === undefined ? text : `${notice}\n\n${text}`;
395
401
  const framedMonitorEvent = opts.monitorEvent === undefined
396
402
  ? undefined
397
403
  : formatMonitorEvent({
398
404
  monitorId: opts.monitorEvent.monitor_id,
399
405
  description: opts.monitorEvent.description,
400
406
  sequence: opts.monitorEvent.sequence,
401
- text,
407
+ text: promptedText,
402
408
  });
403
- const deliveryText = framedMonitorEvent ?? text;
409
+ const deliveryText = framedMonitorEvent ?? promptedText;
404
410
  const dropboxMessage = {
405
411
  at,
406
412
  message_id: messageId,
407
- text,
413
+ text: promptedText,
408
414
  project_id: projectId,
409
415
  story_id: storyId,
410
416
  ...(opts.monitorEvent === undefined ? {} : {
@@ -414,6 +420,52 @@ export function createLaneMessageHandlers(deps) {
414
420
  sequence: opts.monitorEvent.sequence,
415
421
  }),
416
422
  };
423
+ if (projectId !== lane.project_id || storyId !== lane.story_id) {
424
+ lane = casUpdateLane(hqRoot, laneId, (current) => {
425
+ const already = current.attribution_history.some((entry) => entry.message_id === messageId);
426
+ if (already)
427
+ return current;
428
+ const existingStory = laneStory(current, projectId, storyId);
429
+ storySwitchRollback = {
430
+ message_id: messageId,
431
+ project_id: current.project_id,
432
+ story_id: current.story_id,
433
+ no_story_reason: current.no_story_reason,
434
+ ...(!existingStory ? {
435
+ added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
436
+ } : {}),
437
+ story_switch_unacknowledged: current.story_switch_unacknowledged,
438
+ };
439
+ return {
440
+ ...current,
441
+ project_id: projectId,
442
+ story_id: storyId,
443
+ no_story_reason: storyId ? undefined : current.no_story_reason,
444
+ stories: laneStory(current, projectId, storyId)
445
+ ? laneStories(current)
446
+ : [
447
+ ...laneStories(current),
448
+ { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
449
+ ],
450
+ attribution_history: appendCappedHistory(current.attribution_history, {
451
+ project_id: projectId,
452
+ story_id: storyId,
453
+ message_id: messageId,
454
+ at,
455
+ }),
456
+ story_switch_pending: {
457
+ previous_project_id: current.project_id,
458
+ previous_story_id: current.story_id,
459
+ project_id: projectId,
460
+ story_id: storyId,
461
+ message_id: messageId,
462
+ at,
463
+ previous_artifact_keys: artifactIdentityKeys(current.last_envelope?.artifacts),
464
+ },
465
+ story_switch_unacknowledged: undefined,
466
+ };
467
+ });
468
+ }
417
469
  let resumed = null;
418
470
  let move = null;
419
471
  let queuedOnAdmission = false;
@@ -491,6 +543,7 @@ export function createLaneMessageHandlers(deps) {
491
543
  throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
492
544
  }
493
545
  appendDropboxMessage(hqRoot, laneId, dropboxMessage);
546
+ storySwitchDeliveryStarted = true;
494
547
  return null;
495
548
  },
496
549
  resume: async () => {
@@ -565,6 +618,8 @@ export function createLaneMessageHandlers(deps) {
565
618
  compact: opts.compact !== false,
566
619
  effort: resumeEffort ?? recordedResumeEffort(lane),
567
620
  });
621
+ storySwitchDeliveryStarted = true;
622
+ deps.onLaneWorkerStarted?.(laneId, "message");
568
623
  archiveResumeMessage(hqRoot, laneId, dropboxMessage);
569
624
  return started;
570
625
  }, hireGrantContext);
@@ -576,41 +631,10 @@ export function createLaneMessageHandlers(deps) {
576
631
  markAccepted();
577
632
  }
578
633
  // Every accepted message moves the compaction reference forward.
579
- let next = casUpdateLane(hqRoot, laneId, (current) => ({
634
+ const next = casUpdateLane(hqRoot, laneId, (current) => ({
580
635
  ...resetKeepAliveProgress(current),
581
636
  last_message_at: at,
582
637
  }));
583
- if (projectId !== lane.project_id || storyId !== lane.story_id) {
584
- next = casUpdateLane(hqRoot, laneId, (current) => {
585
- const already = current.attribution_history.some((entry) => entry.message_id === messageId);
586
- if (already)
587
- return current;
588
- return {
589
- ...current,
590
- project_id: projectId,
591
- story_id: storyId,
592
- no_story_reason: storyId ? undefined : current.no_story_reason,
593
- stories: laneStory(current, projectId, storyId)
594
- ? laneStories(current)
595
- : [
596
- ...laneStories(current),
597
- {
598
- project_id: projectId,
599
- story_id: storyId,
600
- status: "in_progress",
601
- added_at: at,
602
- updated_at: at,
603
- },
604
- ],
605
- attribution_history: appendCappedHistory(current.attribution_history, {
606
- project_id: projectId,
607
- story_id: storyId,
608
- message_id: messageId,
609
- at,
610
- }),
611
- };
612
- });
613
- }
614
638
  // What this command can claim when it returns: the message is durably
615
639
  // written (accepted). Delivery to the model is asynchronous in both
616
640
  // mechanisms. `dropbox` delivers on the worker's next tool call, and only
@@ -660,6 +684,15 @@ export function createLaneMessageHandlers(deps) {
660
684
  });
661
685
  }
662
686
  catch (err) {
687
+ if (storySwitchRollback !== undefined && !storySwitchDeliveryStarted) {
688
+ try {
689
+ casUpdateLane(hqRoot, laneId, (current) => rollbackStorySwitchAttribution(current, storySwitchRollback));
690
+ }
691
+ catch (rollbackErr) {
692
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
693
+ process.stderr.write(`Could not roll back story attribution after message failure: ${rollbackMessage}\n`);
694
+ }
695
+ }
663
696
  if (claimedMessages.length > 0 && !accepted) {
664
697
  const error = err instanceof Error ? err.message : String(err);
665
698
  writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
@@ -5,6 +5,7 @@ import { parseCliEffort } from "../lib/lanes/effort.js";
5
5
  import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
6
6
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
7
7
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
8
+ import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
8
9
  import { spawnLaneWorker } from "../lib/lanes/spawn.js";
9
10
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, hasKeepAliveProgress, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
10
11
  export function createLaneResumeHandlers(deps) {
@@ -195,6 +196,8 @@ export function createLaneResumeHandlers(deps) {
195
196
  }
196
197
  const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
197
198
  const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
199
+ const notice = storySwitchNotice(lane.project_id, lane.story_id, projectId, storyId);
200
+ const deliveryText = notice === undefined ? text : `${notice}${text === undefined ? "" : `\n\n${text}`}`;
198
201
  let requestedMeshCompany;
199
202
  let meshThreadId;
200
203
  if (opts.meshCompany !== undefined) {
@@ -253,41 +256,60 @@ export function createLaneResumeHandlers(deps) {
253
256
  account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.selection.account,
254
257
  model: opts.model,
255
258
  });
256
- let move = null;
257
- if (opts.account !== undefined && !keepCurrentAccount) {
258
- move = await prepareAccountMove(ctx, lane, {
259
- account: opts.account,
260
- cliModel: opts.model?.trim() || undefined,
261
- ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
262
- ...(profileRepin !== undefined ? { profileRepin } : {}),
263
- ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
264
- ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
265
- pending: {
266
- trigger: "resume",
267
- resumed_from: resumedFrom,
268
- ...(opts.fromFailed === true ? { from_failed: true } : {}),
269
- ...(failedResumeExpectedSenior !== undefined
270
- ? { expected_senior: failedResumeExpectedSenior }
271
- : {}),
272
- ...(text !== undefined ? { text } : {}),
273
- ...(messageId !== undefined ? { message_id: messageId } : {}),
274
- compact: opts.compact !== false,
275
- },
259
+ let storySwitchRollback;
260
+ let storySwitchWorkerStarted = false;
261
+ if (messageId !== undefined && attributionChanged) {
262
+ lane = casUpdateLane(hqRoot, laneId, (current) => {
263
+ const already = current.attribution_history.some((entry) => entry.message_id === messageId);
264
+ if (already)
265
+ return current;
266
+ const existingStory = laneStory(current, projectId, storyId);
267
+ storySwitchRollback = {
268
+ message_id: messageId,
269
+ project_id: current.project_id,
270
+ story_id: current.story_id,
271
+ no_story_reason: current.no_story_reason,
272
+ ...(!existingStory ? {
273
+ added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
274
+ } : {}),
275
+ story_switch_unacknowledged: current.story_switch_unacknowledged,
276
+ };
277
+ return {
278
+ ...current,
279
+ project_id: projectId,
280
+ story_id: storyId,
281
+ no_story_reason: storyId ? undefined : current.no_story_reason,
282
+ stories: laneStory(current, projectId, storyId)
283
+ ? laneStories(current)
284
+ : [
285
+ ...laneStories(current),
286
+ { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
287
+ ],
288
+ attribution_history: appendCappedHistory(current.attribution_history, {
289
+ project_id: projectId,
290
+ story_id: storyId,
291
+ message_id: messageId,
292
+ at,
293
+ }),
294
+ story_switch_pending: {
295
+ previous_project_id: current.project_id,
296
+ previous_story_id: current.story_id,
297
+ project_id: projectId,
298
+ story_id: storyId,
299
+ message_id: messageId,
300
+ at,
301
+ previous_artifact_keys: artifactIdentityKeys(current.last_envelope?.artifacts),
302
+ },
303
+ story_switch_unacknowledged: undefined,
304
+ };
276
305
  });
277
- lane = move.lane;
278
306
  }
279
- else if (automaticAccountSelection !== undefined) {
280
- const selection = automaticAccountSelection.selection;
281
- if (selection.account !== lane.account) {
307
+ try {
308
+ let move = null;
309
+ if (opts.account !== undefined && !keepCurrentAccount) {
282
310
  move = await prepareAccountMove(ctx, lane, {
283
- account: selection.account,
284
- cliModel: undefined,
285
- ...(automaticAccountSelection.historyReason !== undefined
286
- ? { accountHistoryReason: automaticAccountSelection.historyReason }
287
- : {}),
288
- ...(automaticAccountSelection.preservedModel !== undefined
289
- ? { preservedModel: automaticAccountSelection.preservedModel }
290
- : {}),
311
+ account: opts.account,
312
+ cliModel: opts.model?.trim() || undefined,
291
313
  ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
292
314
  ...(profileRepin !== undefined ? { profileRepin } : {}),
293
315
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
@@ -299,204 +321,219 @@ export function createLaneResumeHandlers(deps) {
299
321
  ...(failedResumeExpectedSenior !== undefined
300
322
  ? { expected_senior: failedResumeExpectedSenior }
301
323
  : {}),
302
- ...(text !== undefined ? { text } : {}),
324
+ ...(deliveryText !== undefined ? { text: deliveryText } : {}),
303
325
  ...(messageId !== undefined ? { message_id: messageId } : {}),
304
326
  compact: opts.compact !== false,
305
327
  },
306
- accountSelection: selection,
307
328
  });
308
329
  lane = move.lane;
309
330
  }
310
- else {
311
- lane = persistClaudeAccountSelection(hqRoot, lane, selection);
312
- }
313
- }
314
- lane = recordResumeSetup(ctx, lane, {
315
- ...(move === null && resumeProfile !== undefined ? { profile: resumeProfile } : {}),
316
- ...(move === null && profileRepin !== undefined ? { profileRepin } : {}),
317
- ...(move === null && resumeEffort !== undefined ? { effort: resumeEffort } : {}),
318
- ...(keepAlive !== undefined ? { keepAlive } : {}),
319
- ...(move === null && keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
320
- ...(requestedMeshCompany !== undefined
321
- ? { meshCompany: requestedMeshCompany, threadId: meshThreadId }
322
- : {}),
323
- });
324
- let queuedOnAdmission = false;
325
- let resumed;
326
- if (move?.queued) {
327
- resumed = lane;
328
- }
329
- else {
330
- resumed = await withLaneAdmissionLock(hqRoot, lane.provider, async () => {
331
- const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
332
- if (admission.action === "refuse" && admission.error)
333
- throw admission.error;
334
- return withResumeHireAdmission(ctx, lane, () => {
335
- if (admission.action === "queue") {
336
- const sessionId = lane.provider_session_id;
337
- if (!sessionId) {
338
- throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this resume`, {
339
- lane_id: laneId,
340
- provider: lane.provider,
341
- });
342
- }
343
- queuedOnAdmission = true;
344
- return casUpdateLane(hqRoot, laneId, (current) => {
345
- if (current.version !== lane.version || current.state !== lane.state) {
346
- throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while resume admission was checked`, {
347
- lane_id: laneId,
348
- prepared_version: lane.version,
349
- version: current.version,
350
- state: current.state,
351
- });
352
- }
353
- return {
354
- ...current,
355
- state: "queued",
356
- queued_reason: admission.reason ?? "throttle",
357
- queued_details: undefined,
358
- queued_at: at,
359
- pending_resume: {
360
- session_id: sessionId,
361
- trigger: "resume",
362
- caller: ctx.env.HQ_LANE_ID?.trim() || "user",
363
- resumed_from: resumedFrom,
364
- ...(opts.fromFailed === true ? { from_failed: true } : {}),
365
- ...(failedResumeExpectedSenior !== undefined
366
- ? { expected_senior: failedResumeExpectedSenior }
367
- : {}),
368
- ...(text !== undefined ? { text } : {}),
369
- ...(messageId !== undefined ? { message_id: messageId } : {}),
370
- compact: opts.compact !== false,
371
- at,
372
- },
373
- };
374
- });
375
- }
376
- return spawnLaneWorker(hqRoot, lane, {
377
- env: ctx.env,
378
- modelDeps: {
379
- readFileSync: ctx.readFileSync,
380
- probe: ctx.probeProvider,
381
- env: ctx.env,
382
- },
383
- existsSync: ctx.existsSync,
384
- spawnProcess: ctx.spawnProcess,
385
- proveDetached: ctx.proveDetached,
386
- killProcess: ctx.killProcess,
387
- now: ctx.now,
388
- resolveMachineIdentity: ctx.resolveMachineIdentity,
389
- runCompaction: ctx.runCompaction,
390
- locateTranscript: ctx.locateTranscript,
391
- }, {
392
- trigger: {
393
- kind: "resume",
394
- from: resumedFrom,
395
- ...(opts.fromFailed === true ? { fromFailed: true } : {}),
331
+ else if (automaticAccountSelection !== undefined) {
332
+ const selection = automaticAccountSelection.selection;
333
+ if (selection.account !== lane.account) {
334
+ move = await prepareAccountMove(ctx, lane, {
335
+ account: selection.account,
336
+ cliModel: undefined,
337
+ ...(automaticAccountSelection.historyReason !== undefined
338
+ ? { accountHistoryReason: automaticAccountSelection.historyReason }
339
+ : {}),
340
+ ...(automaticAccountSelection.preservedModel !== undefined
341
+ ? { preservedModel: automaticAccountSelection.preservedModel }
342
+ : {}),
343
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
344
+ ...(profileRepin !== undefined ? { profileRepin } : {}),
345
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
346
+ ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
347
+ pending: {
348
+ trigger: "resume",
349
+ resumed_from: resumedFrom,
350
+ ...(opts.fromFailed === true ? { from_failed: true } : {}),
396
351
  ...(failedResumeExpectedSenior !== undefined
397
- ? { expectedSenior: failedResumeExpectedSenior }
352
+ ? { expected_senior: failedResumeExpectedSenior }
398
353
  : {}),
399
- ...(text !== undefined ? { text } : {}),
354
+ ...(deliveryText !== undefined ? { text: deliveryText } : {}),
400
355
  ...(messageId !== undefined ? { message_id: messageId } : {}),
356
+ compact: opts.compact !== false,
401
357
  },
402
- compact: opts.compact !== false,
403
- effort: resumeEffort ?? recordedResumeEffort(lane),
358
+ accountSelection: selection,
404
359
  });
405
- }, hireGrantContext);
406
- }, ctx.sleep);
407
- }
408
- if (text !== undefined && messageId !== undefined) {
409
- // Same audit copy a message resume leaves; nothing drains it. Archive only
410
- // after a successful spawn so a concurrent resume cannot leave a phantom.
411
- archiveResumeMessage(hqRoot, laneId, {
412
- at,
413
- message_id: messageId,
414
- text,
415
- project_id: projectId,
416
- story_id: storyId,
417
- resumed_from: resumedFrom,
360
+ lane = move.lane;
361
+ }
362
+ else {
363
+ lane = persistClaudeAccountSelection(hqRoot, lane, selection);
364
+ }
365
+ }
366
+ lane = recordResumeSetup(ctx, lane, {
367
+ ...(move === null && resumeProfile !== undefined ? { profile: resumeProfile } : {}),
368
+ ...(move === null && profileRepin !== undefined ? { profileRepin } : {}),
369
+ ...(move === null && resumeEffort !== undefined ? { effort: resumeEffort } : {}),
370
+ ...(keepAlive !== undefined ? { keepAlive } : {}),
371
+ ...(move === null && keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
372
+ ...(requestedMeshCompany !== undefined
373
+ ? { meshCompany: requestedMeshCompany, threadId: meshThreadId }
374
+ : {}),
418
375
  });
419
- }
420
- let next = resumed;
421
- if (failedResumeActor !== undefined &&
422
- failedResumeReason !== undefined &&
423
- recoveredFailure !== undefined) {
424
- next = casUpdateLane(hqRoot, laneId, (current) => ({
425
- ...current,
426
- failed_resume_history: appendCappedHistory(current.failed_resume_history ?? [], {
376
+ let queuedOnAdmission = false;
377
+ let resumed;
378
+ if (move?.queued) {
379
+ resumed = lane;
380
+ }
381
+ else {
382
+ resumed = await withLaneAdmissionLock(hqRoot, lane.provider, async () => {
383
+ const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
384
+ if (admission.action === "refuse" && admission.error)
385
+ throw admission.error;
386
+ return withResumeHireAdmission(ctx, lane, () => {
387
+ if (admission.action === "queue") {
388
+ const sessionId = lane.provider_session_id;
389
+ if (!sessionId) {
390
+ throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this resume`, {
391
+ lane_id: laneId,
392
+ provider: lane.provider,
393
+ });
394
+ }
395
+ queuedOnAdmission = true;
396
+ return casUpdateLane(hqRoot, laneId, (current) => {
397
+ if (current.version !== lane.version || current.state !== lane.state) {
398
+ throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while resume admission was checked`, {
399
+ lane_id: laneId,
400
+ prepared_version: lane.version,
401
+ version: current.version,
402
+ state: current.state,
403
+ });
404
+ }
405
+ return {
406
+ ...current,
407
+ state: "queued",
408
+ queued_reason: admission.reason ?? "throttle",
409
+ queued_details: undefined,
410
+ queued_at: at,
411
+ pending_resume: {
412
+ session_id: sessionId,
413
+ trigger: "resume",
414
+ caller: ctx.env.HQ_LANE_ID?.trim() || "user",
415
+ resumed_from: resumedFrom,
416
+ ...(opts.fromFailed === true ? { from_failed: true } : {}),
417
+ ...(failedResumeExpectedSenior !== undefined
418
+ ? { expected_senior: failedResumeExpectedSenior }
419
+ : {}),
420
+ ...(deliveryText !== undefined ? { text: deliveryText } : {}),
421
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
422
+ compact: opts.compact !== false,
423
+ at,
424
+ },
425
+ };
426
+ });
427
+ }
428
+ const started = spawnLaneWorker(hqRoot, lane, {
429
+ env: ctx.env,
430
+ modelDeps: {
431
+ readFileSync: ctx.readFileSync,
432
+ probe: ctx.probeProvider,
433
+ env: ctx.env,
434
+ },
435
+ existsSync: ctx.existsSync,
436
+ spawnProcess: ctx.spawnProcess,
437
+ proveDetached: ctx.proveDetached,
438
+ killProcess: ctx.killProcess,
439
+ now: ctx.now,
440
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
441
+ runCompaction: ctx.runCompaction,
442
+ locateTranscript: ctx.locateTranscript,
443
+ }, {
444
+ trigger: {
445
+ kind: "resume",
446
+ from: resumedFrom,
447
+ ...(opts.fromFailed === true ? { fromFailed: true } : {}),
448
+ ...(failedResumeExpectedSenior !== undefined
449
+ ? { expectedSenior: failedResumeExpectedSenior }
450
+ : {}),
451
+ ...(deliveryText !== undefined ? { text: deliveryText } : {}),
452
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
453
+ },
454
+ compact: opts.compact !== false,
455
+ effort: resumeEffort ?? recordedResumeEffort(lane),
456
+ });
457
+ storySwitchWorkerStarted = true;
458
+ deps.onLaneWorkerStarted?.(laneId, "resume");
459
+ return started;
460
+ }, hireGrantContext);
461
+ }, ctx.sleep);
462
+ }
463
+ if (deliveryText !== undefined && messageId !== undefined) {
464
+ // Same audit copy a message resume leaves; nothing drains it. Archive only
465
+ // after a successful spawn so a concurrent resume cannot leave a phantom.
466
+ archiveResumeMessage(hqRoot, laneId, {
427
467
  at,
428
- actor: failedResumeActor,
429
- reason: failedResumeReason,
430
- failure: recoveredFailure,
431
- }),
432
- }));
433
- }
434
- if (text !== undefined) {
435
- // An operator text moves the compaction reference forward, as a message does.
436
- next = casUpdateLane(hqRoot, laneId, (current) => ({
437
- ...resetKeepAliveProgress(current),
438
- last_message_at: at,
439
- }));
440
- }
441
- if (messageId !== undefined && attributionChanged) {
442
- next = casUpdateLane(hqRoot, laneId, (current) => {
443
- const already = current.attribution_history.some((entry) => entry.message_id === messageId);
444
- if (already)
445
- return current;
446
- return {
447
- ...current,
468
+ message_id: messageId,
469
+ text: deliveryText,
448
470
  project_id: projectId,
449
471
  story_id: storyId,
450
- no_story_reason: storyId ? undefined : current.no_story_reason,
451
- stories: laneStory(current, projectId, storyId)
452
- ? laneStories(current)
453
- : [
454
- ...laneStories(current),
455
- {
456
- project_id: projectId,
457
- story_id: storyId,
458
- status: "in_progress",
459
- added_at: at,
460
- updated_at: at,
461
- },
462
- ],
463
- attribution_history: appendCappedHistory(current.attribution_history, {
464
- project_id: projectId,
465
- story_id: storyId,
466
- message_id: messageId,
472
+ resumed_from: resumedFrom,
473
+ });
474
+ }
475
+ let next = resumed;
476
+ if (failedResumeActor !== undefined &&
477
+ failedResumeReason !== undefined &&
478
+ recoveredFailure !== undefined) {
479
+ next = casUpdateLane(hqRoot, laneId, (current) => ({
480
+ ...current,
481
+ failed_resume_history: appendCappedHistory(current.failed_resume_history ?? [], {
467
482
  at,
483
+ actor: failedResumeActor,
484
+ reason: failedResumeReason,
485
+ failure: recoveredFailure,
468
486
  }),
469
- };
487
+ }));
488
+ }
489
+ if (deliveryText !== undefined) {
490
+ // An operator text moves the compaction reference forward, as a message does.
491
+ next = casUpdateLane(hqRoot, laneId, (current) => ({
492
+ ...resetKeepAliveProgress(current),
493
+ last_message_at: at,
494
+ }));
495
+ }
496
+ if (text === undefined && hasKeepAliveProgress(next)) {
497
+ next = casUpdateLane(hqRoot, laneId, (current) => resetKeepAliveProgress(current));
498
+ }
499
+ printJson({
500
+ ok: true,
501
+ action: "resume",
502
+ lane_id: laneId,
503
+ resumed_from: resumedFrom,
504
+ message_id: messageId ?? null,
505
+ text: text ?? null,
506
+ delivery: "resume",
507
+ delivery_status: move?.queued
508
+ ? "queued_on_target_account"
509
+ : queuedOnAdmission
510
+ ? "queued_on_admission"
511
+ : "worker_spawned",
512
+ account_move: move ? accountMoveSummary(move) : null,
513
+ resumed_session_id: resumed.last_resume?.session_id ?? null,
514
+ last_resume: next.last_resume ?? null,
515
+ compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
516
+ ? resumed.last_compaction
517
+ : null,
518
+ project_id: next.project_id,
519
+ story_id: next.story_id,
520
+ stories: laneStories(next),
521
+ story_warning: storyWarning(next) ?? null,
522
+ lane: next,
470
523
  });
471
524
  }
472
- if (text === undefined && hasKeepAliveProgress(next)) {
473
- next = casUpdateLane(hqRoot, laneId, (current) => resetKeepAliveProgress(current));
525
+ catch (err) {
526
+ if (storySwitchRollback !== undefined && !storySwitchWorkerStarted) {
527
+ try {
528
+ casUpdateLane(hqRoot, laneId, (current) => rollbackStorySwitchAttribution(current, storySwitchRollback));
529
+ }
530
+ catch (rollbackErr) {
531
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
532
+ process.stderr.write(`Could not roll back story attribution after resume failure: ${rollbackMessage}\n`);
533
+ }
534
+ }
535
+ throw err;
474
536
  }
475
- printJson({
476
- ok: true,
477
- action: "resume",
478
- lane_id: laneId,
479
- resumed_from: resumedFrom,
480
- message_id: messageId ?? null,
481
- text: text ?? null,
482
- delivery: "resume",
483
- delivery_status: move?.queued
484
- ? "queued_on_target_account"
485
- : queuedOnAdmission
486
- ? "queued_on_admission"
487
- : "worker_spawned",
488
- account_move: move ? accountMoveSummary(move) : null,
489
- resumed_session_id: resumed.last_resume?.session_id ?? null,
490
- last_resume: next.last_resume ?? null,
491
- compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
492
- ? resumed.last_compaction
493
- : null,
494
- project_id: next.project_id,
495
- story_id: next.story_id,
496
- stories: laneStories(next),
497
- story_warning: storyWarning(next) ?? null,
498
- lane: next,
499
- });
500
537
  }
501
538
  return { runClaudeBalanceMoveTarget, runResume };
502
539
  }
@@ -144,6 +144,8 @@ export interface OutpostLaneDispatchBehavior {
144
144
  expectedOwnerHost?: LaneRecord["host"];
145
145
  }
146
146
  export interface LaneMessageHandlerDeps {
147
+ /** Test seam invoked after a provider worker has been started. */
148
+ onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
147
149
  archiveResumeMessage(hqRoot: string, laneId: string, message: {
148
150
  message_id: string;
149
151
  [key: string]: unknown;
@@ -184,7 +186,7 @@ export interface LaneMessageHandlerDeps {
184
186
  validateResumeProfileModel(ctx: Ctx, lane: LaneRecord, profile: WorkerProfile): void;
185
187
  withResumeHireAdmission<T>(ctx: Ctx, lane: LaneRecord, fn: () => T, grantContext?: OutpostHireGrantContext): Promise<T>;
186
188
  }
187
- export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "resolveResumeProfile" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
189
+ export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "resolveResumeProfile" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
188
190
  dispatchOutpostLaneCommand(ctx: Ctx, lane: LaneRecord, action: "resume", options: LaneResumeOptions, behavior?: OutpostLaneDispatchBehavior): Promise<void>;
189
191
  authorizeFailedResume(ctx: Ctx, lane: LaneRecord, encodedContext?: string): string;
190
192
  authorizeResumeProfileRepin(ctx: Ctx, lane: LaneRecord, profile: ResolvedResumeProfile | undefined, encodedContext: string | undefined): ResumeProfileRepinAuthorization | undefined;
@@ -29,6 +29,8 @@ export declare const LANE_REMOTE_MIRROR_SYNC_FLAG = "lanes.remote-done-mirror";
29
29
  /** The lane → mesh seam. Defined in lib/lanes/mesh-events.ts; the watcher shares it. */
30
30
  export { emitLaneDecisionEvents };
31
31
  export interface LanesCommandDeps {
32
+ /** Test seam for failures after a lane provider worker is started. */
33
+ onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
32
34
  hqRoot?: () => string;
33
35
  /** Start HQ-root walk here instead of process.cwd(). Tests only. */
34
36
  cwd?: () => string;
@@ -7837,6 +7837,7 @@ export function registerLanesCommand(parent, deps = {}) {
7837
7837
  writeAnswerDeliveryFailed,
7838
7838
  });
7839
7839
  const messageHandlerDeps = {
7840
+ onLaneWorkerStarted: deps.onLaneWorkerStarted,
7840
7841
  archiveResumeMessage,
7841
7842
  assertLaneRunsHereForAdmission,
7842
7843
  assertLaneRunsHereForOperatorMutation,
@@ -7862,6 +7863,7 @@ export function registerLanesCommand(parent, deps = {}) {
7862
7863
  };
7863
7864
  const messageHandlers = createLaneMessageHandlers(messageHandlerDeps);
7864
7865
  const resumeHandlers = createLaneResumeHandlers({
7866
+ onLaneWorkerStarted: deps.onLaneWorkerStarted,
7865
7867
  archiveResumeMessage,
7866
7868
  assertLaneRunsHereForAdmission,
7867
7869
  assertLaneRunsHereForOperatorMutation,
@@ -8214,8 +8216,8 @@ export function registerLanesCommand(parent, deps = {}) {
8214
8216
  .description("Send a follow-up message to a lane")
8215
8217
  .argument("<lane-id>", "Lane id")
8216
8218
  .requiredOption("--text <msg>", "Message text")
8217
- .option("--project <slug>", "Re-point project attribution from this message")
8218
- .option("--story <id>", "Re-point story attribution from this message")
8219
+ .option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
8220
+ .option("--story <id>", "Re-point story attribution; a changed story is named in the resumed prompt")
8219
8221
  .option("--account <id>", "Move the lane's session to this account of its provider before the message resumes it")
8220
8222
  .option("--model <id>", "Model for the moved lane (with --account only)")
8221
8223
  .option("--effort <level>", "Reasoning effort for the resumed provider turn")
@@ -8292,8 +8294,8 @@ export function registerLanesCommand(parent, deps = {}) {
8292
8294
  .option("--text <msg>", "Operator message for the resumed turn")
8293
8295
  .option("--from-failed", "Explicitly resume a failed lane as its senior")
8294
8296
  .option("--reason <text>", "Required explanation for resuming from failed")
8295
- .option("--project <slug>", "Re-point project attribution from this resume")
8296
- .option("--story <id>", "Re-point story attribution from this resume")
8297
+ .option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
8298
+ .option("--story <id>", "Re-point story attribution; a changed story is named in the resumed prompt")
8297
8299
  .option("--account <id>", "Move the lane's session to this account of its provider before resuming")
8298
8300
  .option("--model <id>", "Model for the moved lane (with --account only)")
8299
8301
  .option("--effort <level>", "Reasoning effort for the resumed provider turn")
@@ -32,6 +32,7 @@ import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuesti
32
32
  import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
33
33
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
34
34
  import { activeLaneStories, laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
35
+ import { applyStorySwitchEnvelope } from "./story-switch.js";
35
36
  import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
36
37
  import { PROVIDER_REJECTED_REASON, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
37
38
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
@@ -1271,11 +1272,28 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1271
1272
  }
1272
1273
  if (terminalEnvelope && latest) {
1273
1274
  const envelope = terminalEnvelope;
1274
- const storyStatus = terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
1275
+ next.last_envelope = {
1276
+ ref: latest.ref,
1277
+ decision: envelope.decision,
1278
+ at: nowIso,
1279
+ artifacts: envelope.artifacts,
1280
+ ...(envelope.blocked_reason !== undefined ? { blocked_reason: envelope.blocked_reason } : {}),
1281
+ };
1282
+ const storySwitch = applyStorySwitchEnvelope(current, envelope, nowIso, latest.ref);
1283
+ if (storySwitch.clearPending) {
1284
+ next.story_switch_pending = undefined;
1285
+ }
1286
+ if (storySwitch.storySwitchUnacknowledged !== undefined) {
1287
+ next.story_switch_unacknowledged = storySwitch.storySwitchUnacknowledged;
1288
+ next.attribution_history = storySwitch.attributionHistory ?? current.attribution_history;
1289
+ }
1290
+ const storyStatus = storySwitch.suppressCompletion
1291
+ ? "in_progress"
1292
+ : terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
1275
1293
  if (storyStatus !== undefined) {
1276
- next.story_status = envelope.story_status;
1277
- next.story_complete = envelope.story_complete;
1278
- next.story_complete_reason = envelope.story_complete_reason;
1294
+ next.story_status = storySwitch.suppressCompletion ? "in_progress" : envelope.story_status;
1295
+ next.story_complete = storySwitch.suppressCompletion ? false : envelope.story_complete;
1296
+ next.story_complete_reason = storySwitch.suppressCompletion ? undefined : envelope.story_complete_reason;
1279
1297
  next.stories = laneStories(current).map((story) => story.project_id === current.project_id && story.story_id === current.story_id
1280
1298
  ? {
1281
1299
  ...story,
@@ -0,0 +1,21 @@
1
+ import type { AttributionHistoryEntry, LaneEnvelope, LaneRecord, StorySwitchUnacknowledgedNote } from "./types.js";
2
+ export declare function storySwitchNotice(previousProjectId: string, previousStoryId: string, projectId: string, storyId: string): string | undefined;
3
+ export declare function artifactIdentityKeys(artifacts: readonly unknown[] | undefined): string[];
4
+ export interface StorySwitchRollbackSnapshot {
5
+ message_id: string;
6
+ project_id: string;
7
+ story_id: string;
8
+ no_story_reason?: string;
9
+ added_story?: NonNullable<LaneRecord["stories"]>[number];
10
+ story_switch_unacknowledged: StorySwitchUnacknowledgedNote | undefined;
11
+ }
12
+ /** Restore only the attribution write owned by this failed command attempt. */
13
+ export declare function rollbackStorySwitchAttribution(current: LaneRecord, snapshot: StorySwitchRollbackSnapshot): LaneRecord;
14
+ /** Apply and consume one attribution-switch marker when the next envelope arrives. */
15
+ export declare function applyStorySwitchEnvelope(lane: LaneRecord, envelope: LaneEnvelope, at: string, envelopeRef: string): {
16
+ suppressCompletion: boolean;
17
+ clearPending: boolean;
18
+ storySwitchUnacknowledged?: StorySwitchUnacknowledgedNote;
19
+ attributionHistory?: AttributionHistoryEntry[];
20
+ };
21
+ //# sourceMappingURL=story-switch.d.ts.map
@@ -0,0 +1,75 @@
1
+ export function storySwitchNotice(previousProjectId, previousStoryId, projectId, storyId) {
2
+ if (previousProjectId === projectId && previousStoryId === storyId)
3
+ return undefined;
4
+ const from = `${previousProjectId}/${previousStoryId || "(no story)"}`;
5
+ const to = `${projectId}/${storyId || "(no story)"}`;
6
+ return `Story changed: ${from} -> ${to}. The previous story is closed; report only on the new story in this turn's envelope.`;
7
+ }
8
+ export function artifactIdentityKeys(artifacts) {
9
+ if (!Array.isArray(artifacts))
10
+ return [];
11
+ return artifacts.flatMap((artifact) => {
12
+ if (typeof artifact === "string" && artifact.trim())
13
+ return [`value:${artifact.trim()}`];
14
+ if (artifact === null || typeof artifact !== "object" || Array.isArray(artifact))
15
+ return [];
16
+ const record = artifact;
17
+ const url = typeof record.url === "string" ? record.url.trim() : "";
18
+ const path = typeof record.path === "string" ? record.path.trim() : "";
19
+ const values = [url && `url:${url}`, path && `path:${path}`].filter((value) => Boolean(value));
20
+ return values;
21
+ }).sort();
22
+ }
23
+ /** Restore only the attribution write owned by this failed command attempt. */
24
+ export function rollbackStorySwitchAttribution(current, snapshot) {
25
+ if (current.story_switch_pending?.message_id !== snapshot.message_id)
26
+ return current;
27
+ const addedStory = snapshot.added_story;
28
+ const stories = (current.stories ?? []).filter((story) => !(addedStory && story.project_id === addedStory.project_id && story.story_id === addedStory.story_id &&
29
+ story.added_at === addedStory.added_at));
30
+ return {
31
+ ...current,
32
+ project_id: snapshot.project_id,
33
+ story_id: snapshot.story_id,
34
+ no_story_reason: snapshot.no_story_reason,
35
+ stories,
36
+ attribution_history: current.attribution_history.filter((entry) => entry.message_id !== snapshot.message_id),
37
+ story_switch_pending: undefined,
38
+ story_switch_unacknowledged: snapshot.story_switch_unacknowledged,
39
+ };
40
+ }
41
+ function sameKeys(left, right) {
42
+ return left.length > 0 && left.length === right.length && left.every((key, index) => key === right[index]);
43
+ }
44
+ /** Apply and consume one attribution-switch marker when the next envelope arrives. */
45
+ export function applyStorySwitchEnvelope(lane, envelope, at, envelopeRef) {
46
+ const pending = lane.story_switch_pending;
47
+ if (!pending)
48
+ return { suppressCompletion: false, clearPending: false };
49
+ if (envelope.story_complete !== true)
50
+ return { suppressCompletion: false, clearPending: false };
51
+ const currentKeys = artifactIdentityKeys(envelope.artifacts);
52
+ const suppressCompletion = sameKeys(pending.previous_artifact_keys, currentKeys);
53
+ if (!suppressCompletion)
54
+ return { suppressCompletion: false, clearPending: true };
55
+ const note = {
56
+ event: "story_switch_unacknowledged",
57
+ previous_project_id: pending.previous_project_id,
58
+ previous_story_id: pending.previous_story_id,
59
+ project_id: pending.project_id,
60
+ story_id: pending.story_id,
61
+ message_id: pending.message_id,
62
+ envelope_ref: envelopeRef,
63
+ at,
64
+ artifact_keys: currentKeys,
65
+ };
66
+ return {
67
+ suppressCompletion: true,
68
+ clearPending: true,
69
+ storySwitchUnacknowledged: note,
70
+ attributionHistory: lane.attribution_history.map((entry) => entry.message_id === pending.message_id
71
+ ? { ...entry, story_switch_unacknowledged: note }
72
+ : entry),
73
+ };
74
+ }
75
+ //# sourceMappingURL=story-switch.js.map
@@ -351,6 +351,27 @@ export interface AttributionHistoryEntry {
351
351
  story_id: string;
352
352
  message_id: string;
353
353
  at: string;
354
+ story_switch_unacknowledged?: StorySwitchUnacknowledgedNote;
355
+ }
356
+ export interface StorySwitchPending {
357
+ previous_project_id: string;
358
+ previous_story_id: string;
359
+ project_id: string;
360
+ story_id: string;
361
+ message_id: string;
362
+ at: string;
363
+ previous_artifact_keys: string[];
364
+ }
365
+ export interface StorySwitchUnacknowledgedNote {
366
+ event: "story_switch_unacknowledged";
367
+ previous_project_id: string;
368
+ previous_story_id: string;
369
+ project_id: string;
370
+ story_id: string;
371
+ message_id: string;
372
+ envelope_ref: string;
373
+ at: string;
374
+ artifact_keys: string[];
354
375
  }
355
376
  export interface LastEnvelopeRef {
356
377
  ref: string;
@@ -573,6 +594,10 @@ export interface LaneRecord {
573
594
  envelope_summary?: string;
574
595
  attribution_history: AttributionHistoryEntry[];
575
596
  last_envelope?: LastEnvelopeRef;
597
+ /** Pending attribution change, consumed by the next terminal envelope. */
598
+ story_switch_pending?: StorySwitchPending;
599
+ /** Last completion suppressed because the new story repeated prior evidence. */
600
+ story_switch_unacknowledged?: StorySwitchUnacknowledgedNote;
576
601
  /** Effective model id recorded at create/spawn. Omitted when the provider chooses. */
577
602
  model?: string;
578
603
  /** Where the effective model came from; repaired models stay pinned across the account move. */
@@ -227,7 +227,7 @@ export const LANE_FIELD_WRITERS = {
227
227
  priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], registry_worker: ["creator", "command", "spawn"], registry_worker_approval_history: ["command", "watcher", "reconcile"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
228
228
  keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile"], keep_alive_history: ["reconcile"], keep_alive_blocked_resume_streak: ["reconcile", "spawn", "watcher"], keep_alive_last_resume_at: ["command", "reconcile"], keep_alive_next_eligible_at: ["command", "reconcile"], keep_alive_exhausted_at: ["command", "reconcile"],
229
229
  provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
230
- version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command"],
230
+ version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command", "watcher", "reconcile"], story_switch_pending: ["command", "watcher", "reconcile"], story_switch_unacknowledged: ["watcher", "reconcile"],
231
231
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
232
232
  last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher", "reconcile"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
233
233
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
@@ -37,6 +37,7 @@ import { LanesError } from "./errors.js";
37
37
  import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
38
38
  import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, LANE_PROVIDERS, } from "./types.js";
39
39
  import { laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
40
+ import { applyStorySwitchEnvelope } from "./story-switch.js";
40
41
  import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpdateLane, withLaneHireLocks } from "./store.js";
41
42
  import { enforceRegistryApprovalGate } from "./approval-gate.js";
42
43
  import { readWorkerRunResult } from "./delivery.js";
@@ -414,6 +415,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
414
415
  }), current.registry_worker_approval_history ?? []);
415
416
  }
416
417
  if (envelope) {
418
+ const storySwitch = applyStorySwitchEnvelope(current, envelope, at, envelopeRef);
417
419
  if (envelope.decision !== "blocked")
418
420
  next.keep_alive_blocked_resume_streak = 0;
419
421
  next.last_envelope = {
@@ -423,11 +425,20 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
423
425
  artifacts: envelope.artifacts,
424
426
  ...(envelope.blocked_reason !== undefined ? { blocked_reason: envelope.blocked_reason } : {}),
425
427
  };
426
- const storyStatus = terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
428
+ if (storySwitch.clearPending) {
429
+ next.story_switch_pending = undefined;
430
+ }
431
+ if (storySwitch.storySwitchUnacknowledged !== undefined) {
432
+ next.story_switch_unacknowledged = storySwitch.storySwitchUnacknowledged;
433
+ next.attribution_history = storySwitch.attributionHistory ?? current.attribution_history;
434
+ }
435
+ const storyStatus = storySwitch.suppressCompletion
436
+ ? "in_progress"
437
+ : terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
427
438
  if (storyStatus !== undefined) {
428
- next.story_status = envelope.story_status;
429
- next.story_complete = envelope.story_complete;
430
- next.story_complete_reason = envelope.story_complete_reason;
439
+ next.story_status = storySwitch.suppressCompletion ? "in_progress" : envelope.story_status;
440
+ next.story_complete = storySwitch.suppressCompletion ? false : envelope.story_complete;
441
+ next.story_complete_reason = storySwitch.suppressCompletion ? undefined : envelope.story_complete_reason;
431
442
  const currentProject = current.project_id;
432
443
  const currentStory = current.story_id;
433
444
  const updatedStories = laneStories(current).map((story) => story.project_id === currentProject && story.story_id === currentStory
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.299.0",
3
+ "version": "5.300.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {