@indigoai-us/hq-cli 5.345.44 → 5.345.46

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 (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/command-catalog.generated.d.ts +17 -1
  3. package/dist/command-catalog.generated.js +21 -1
  4. package/dist/commands/access.js +6 -4
  5. package/dist/commands/bot.js +1 -1
  6. package/dist/commands/db-destroy.js +5 -4
  7. package/dist/commands/install-global.d.ts +3 -0
  8. package/dist/commands/install-global.js +24 -2
  9. package/dist/commands/lanes-message.js +84 -51
  10. package/dist/commands/lanes-resume.js +44 -0
  11. package/dist/commands/lanes-shared.d.ts +25 -0
  12. package/dist/commands/lanes.d.ts +4 -0
  13. package/dist/commands/lanes.js +253 -15
  14. package/dist/commands/meetings.js +4 -1
  15. package/dist/lib/anywhere-person-setting.d.ts +11 -0
  16. package/dist/lib/anywhere-person-setting.js +19 -0
  17. package/dist/lib/daemon/ops/mcp.d.ts +10 -1
  18. package/dist/lib/daemon/ops/mcp.js +17 -10
  19. package/dist/lib/daemon/run.js +8 -2
  20. package/dist/lib/doctor/checks/sync-runtime.d.ts +2 -0
  21. package/dist/lib/doctor/checks/sync-runtime.js +26 -12
  22. package/dist/lib/flag-snapshot-file.js +33 -25
  23. package/dist/lib/install/claude-targets.js +7 -2
  24. package/dist/lib/install/codex-targets.d.ts +6 -0
  25. package/dist/lib/install/codex-targets.js +25 -0
  26. package/dist/lib/lanes/flag-keys.d.ts +1 -0
  27. package/dist/lib/lanes/flag-keys.js +1 -0
  28. package/dist/lib/lanes/focus-guard.d.ts +65 -0
  29. package/dist/lib/lanes/focus-guard.js +91 -0
  30. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  31. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  32. package/dist/lib/lanes/store.js +29 -0
  33. package/dist/lib/lanes/story-ledger.d.ts +14 -0
  34. package/dist/lib/lanes/story-ledger.js +46 -0
  35. package/dist/lib/lanes/types.d.ts +10 -2
  36. package/dist/lib/lanes/types.js +4 -3
  37. package/dist/utils/hook-trust.d.ts +7 -0
  38. package/dist/utils/hook-trust.js +40 -14
  39. package/dist/utils/package-use-lease.d.ts +1 -0
  40. package/dist/utils/package-use-lease.js +29 -0
  41. package/dist/utils/self-update-backoff.d.ts +17 -0
  42. package/dist/utils/self-update-backoff.js +73 -0
  43. package/dist/utils/self-update.d.ts +7 -0
  44. package/dist/utils/self-update.js +36 -0
  45. package/dist/utils/vault-api.d.ts +33 -1
  46. package/dist/utils/vault-api.js +55 -5
  47. package/dist/utils/version-gate.d.ts +13 -0
  48. package/dist/utils/version-gate.js +35 -5
  49. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,7 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.46] — 2026-10-08
6
+ - Global Claude hooks installed by `hq install --global` now point `HQ_ROOT` and `CLAUDE_PROJECT_DIR` at the HQ root. Sessions outside the HQ folder now load HQ policies, and hook state stays inside HQ instead of creating a `workspace/` folder in the project. To update an existing install, re-run `hq install --global --runtime claude`.
7
+ - `hq install --global --runtime codex` now trusts the HQ hooks it registers in `~/.codex/hooks.json`. Codex skips untrusted hooks, so before this HQ hooks never ran in Codex sessions outside the HQ folder. Only HQ entries are trusted; other hooks in the file are left alone. If Codex cannot be reached, the install still completes and tells you to approve the hooks with `/hooks`.
8
+ - The local HQ MCP server (`hq mcp serve`) now uses your existing HQ sign-in when you have not run `hq mcp login`, so HQ tools work in Claude and Codex outside the HQ folder without a second browser login. This needs the matching hq-pro change that lets the hosted connector accept the HQ sign-in.
9
+ - The HQ background service no longer switches HQ Anywhere off when one re-read of your HQ Anywhere setting times out. It keeps the last value it read for up to 30 minutes; turning the setting off still applies on the next successful read.
10
+ - Sync restart checks now count settled restarts across the full one-hour window and leave out only the unsettled tail.
11
+ - `hq lanes stop` and `hq lanes reparent` now resolve lanes on registered Outposts and dispatch mutations to the owning host, including lanes without a primary mirror.
12
+
13
+ ## [5.345.45] — 2026-10-07
14
+ - A failed automatic CLI update now pauses automatic updates for 24 hours, including for newer releases, instead of retrying on every command. `hq rescue` still updates on request. Processes started by an update can no longer start another install, and update package-manager processes run with Corepack settings that cannot change your default pnpm version.
15
+
16
+ - Hosted agents can run `hq access`, `hq secrets --personal`, and the `hq db destroy` role check under their own `agt_*` identity instead of failing with "No person entity found". Person-only commands (`hq meetings import`, `hq bot create`) now tell an agent identity that the command is not available to agents.
17
+ - Add a default-off focus guard to lane creation and story-switch resume, with logged, auditable overrides.
18
+ - `hq lanes set <lane> --company <slug>` binds missing company attribution, including on legacy and terminal lane records.
19
+ - Use the per-user local application cache when Windows denies the configured flag snapshot cache.
20
+
5
21
  ## [5.345.44] — 2026-10-07
22
+ - Long-running CLI processes now refresh their package-use lease so desktop updates only reclaim holders whose liveness heartbeat has expired.
6
23
  - Quota-exhausted lanes can fail over within their provider, hold with per-account eligibility reasons when none is available, and retry when the earliest quota reset arrives. The default-off `lanes.quota-account-failover` gate controls the new recovery path.
7
24
 
8
25
  - When a company's plan does not include remote databases, `hq db provision`, `usage`, `sql`, `dump` and `destroy` now show which plan includes them, the $10 per database app price and the upgrade link.
@@ -11469,6 +11469,10 @@ export declare const COMMAND_CATALOG: readonly [{
11469
11469
  readonly flags: "--story <id>";
11470
11470
  readonly description: "Story id from the project's prd.json";
11471
11471
  readonly hidden: false;
11472
+ }, {
11473
+ readonly flags: "--focus-override <reason>";
11474
+ readonly description: "Human-approved reason to override the lane focus guard";
11475
+ readonly hidden: false;
11472
11476
  }, {
11473
11477
  readonly flags: "--no-story <reason>";
11474
11478
  readonly description: "Create a lane without a story and record why";
@@ -11683,7 +11687,7 @@ export declare const COMMAND_CATALOG: readonly [{
11683
11687
  readonly subcommands: readonly [];
11684
11688
  }, {
11685
11689
  readonly name: "set";
11686
- readonly description: "Change the model or effort a lane uses from its next resume";
11690
+ readonly description: "Change lane settings or bind its missing company attribution";
11687
11691
  readonly aliases: readonly [];
11688
11692
  readonly hidden: false;
11689
11693
  readonly helpOption: true;
@@ -11697,6 +11701,10 @@ export declare const COMMAND_CATALOG: readonly [{
11697
11701
  readonly description: "Lane id";
11698
11702
  }];
11699
11703
  readonly options: readonly [{
11704
+ readonly flags: "--company <slug>";
11705
+ readonly description: "Bind missing company attribution to this lane";
11706
+ readonly hidden: false;
11707
+ }, {
11700
11708
  readonly flags: "--model <model>";
11701
11709
  readonly description: "Model for the lane's next and later turns";
11702
11710
  readonly hidden: false;
@@ -12374,6 +12382,10 @@ export declare const COMMAND_CATALOG: readonly [{
12374
12382
  readonly flags: "--story <id>";
12375
12383
  readonly description: "Re-point story attribution; a changed story is named in the resumed prompt";
12376
12384
  readonly hidden: false;
12385
+ }, {
12386
+ readonly flags: "--focus-override <reason>";
12387
+ readonly description: "Human-approved reason to override the lane focus guard";
12388
+ readonly hidden: false;
12377
12389
  }, {
12378
12390
  readonly flags: "--account <id>";
12379
12391
  readonly description: "Move the lane's session to this account of its provider before the message resumes it";
@@ -12497,6 +12509,10 @@ export declare const COMMAND_CATALOG: readonly [{
12497
12509
  readonly flags: "--story <id>";
12498
12510
  readonly description: "Re-point story attribution; a changed story is named in the resumed prompt";
12499
12511
  readonly hidden: false;
12512
+ }, {
12513
+ readonly flags: "--focus-override <reason>";
12514
+ readonly description: "Human-approved reason to override the lane focus guard";
12515
+ readonly hidden: false;
12500
12516
  }, {
12501
12517
  readonly flags: "--account <id>";
12502
12518
  readonly description: "Move the lane's session to this account of its provider before resuming";
@@ -13932,6 +13932,11 @@ export const COMMAND_CATALOG = [
13932
13932
  "description": "Story id from the project's prd.json",
13933
13933
  "hidden": false
13934
13934
  },
13935
+ {
13936
+ "flags": "--focus-override <reason>",
13937
+ "description": "Human-approved reason to override the lane focus guard",
13938
+ "hidden": false
13939
+ },
13935
13940
  {
13936
13941
  "flags": "--no-story <reason>",
13937
13942
  "description": "Create a lane without a story and record why",
@@ -14198,7 +14203,7 @@ export const COMMAND_CATALOG = [
14198
14203
  },
14199
14204
  {
14200
14205
  "name": "set",
14201
- "description": "Change the model or effort a lane uses from its next resume",
14206
+ "description": "Change lane settings or bind its missing company attribution",
14202
14207
  "aliases": [],
14203
14208
  "hidden": false,
14204
14209
  "helpOption": true,
@@ -14214,6 +14219,11 @@ export const COMMAND_CATALOG = [
14214
14219
  }
14215
14220
  ],
14216
14221
  "options": [
14222
+ {
14223
+ "flags": "--company <slug>",
14224
+ "description": "Bind missing company attribution to this lane",
14225
+ "hidden": false
14226
+ },
14217
14227
  {
14218
14228
  "flags": "--model <model>",
14219
14229
  "description": "Model for the lane's next and later turns",
@@ -15045,6 +15055,11 @@ export const COMMAND_CATALOG = [
15045
15055
  "description": "Re-point story attribution; a changed story is named in the resumed prompt",
15046
15056
  "hidden": false
15047
15057
  },
15058
+ {
15059
+ "flags": "--focus-override <reason>",
15060
+ "description": "Human-approved reason to override the lane focus guard",
15061
+ "hidden": false
15062
+ },
15048
15063
  {
15049
15064
  "flags": "--account <id>",
15050
15065
  "description": "Move the lane's session to this account of its provider before the message resumes it",
@@ -15197,6 +15212,11 @@ export const COMMAND_CATALOG = [
15197
15212
  "description": "Re-point story attribution; a changed story is named in the resumed prompt",
15198
15213
  "hidden": false
15199
15214
  },
15215
+ {
15216
+ "flags": "--focus-override <reason>",
15217
+ "description": "Human-approved reason to override the lane focus guard",
15218
+ "hidden": false
15219
+ },
15200
15220
  {
15201
15221
  "flags": "--account <id>",
15202
15222
  "description": "Move the lane's session to this account of its provider before resuming",
@@ -19,7 +19,7 @@ import * as path from "node:path";
19
19
  import * as readline from "node:readline";
20
20
  import { ListObjectsV2Command, } from "@aws-sdk/client-s3";
21
21
  import { DEFAULT_HQ_ROOT } from "../utils/cognito-session.js";
22
- import { getCompanyUid, vaultApiFetch, resolveCallerPersonUid } from "../utils/vault-api.js";
22
+ import { getCompanyUid, vaultApiFetch, resolveCallerUid } from "../utils/vault-api.js";
23
23
  import { parseCompanySlugFromPath, resolveBrowseSession, makeCompanyPresignFactory, runGet, } from "./files-browse.js";
24
24
  import { listJournals, syncDoctor, } from "@indigoai-us/hq-cloud";
25
25
  import { listActiveMembers } from "./members.js";
@@ -705,9 +705,11 @@ export async function runAccessForPath(path, opts) {
705
705
  }));
706
706
  };
707
707
  const members = await listMembers();
708
- const callerUid = await resolveCallerPersonUid(token);
709
- const me = members.find((m) => m.personUid === callerUid);
710
- let requesterEmail = me?.personEmail;
708
+ const caller = await resolveCallerUid(token);
709
+ const me = members.find((m) => m.personUid === caller.uid);
710
+ // An agent has no mailbox and no person entity. `hq files share --with`
711
+ // accepts its agt_* uid, so the access request names the agent by uid.
712
+ let requesterEmail = caller.kind === "agent" ? caller.uid : me?.personEmail;
711
713
  const requesterName = me?.personName;
712
714
  if (!requesterEmail) {
713
715
  const claims = peekIdToken(token);
@@ -504,7 +504,7 @@ export async function runBotCreate(nameArg, opts) {
504
504
  throw Object.assign(new Error(`The name ${RESERVED_SETUP_BOT_NAME} is reserved for HQ's setup bot; create it with --worker ${RESERVED_SETUP_BOT_NAME}.`), { expected: true });
505
505
  }
506
506
  const token = await ownerToken();
507
- const owner = await resolveCallerPersonUid(token);
507
+ const owner = await resolveCallerPersonUid(token, undefined, { command: "hq bot create" });
508
508
  if (!owner)
509
509
  throw Object.assign(new Error("Your HQ login has no person entity yet; open the HQ desktop app once, then retry."), { expected: true });
510
510
  const api = new BotApi({ token: async () => token });
@@ -12,16 +12,17 @@ async function defaultCheckRole(deps, companyUid) {
12
12
  if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1")
13
13
  return "unknown";
14
14
  try {
15
- const [{ listActiveMembers }, { resolveCallerPersonUid }] = await Promise.all([
15
+ const [{ listActiveMembers }, { resolveCallerUid }] = await Promise.all([
16
16
  import("./members.js"),
17
17
  import("../utils/vault-api.js"),
18
18
  ]);
19
19
  const token = await deps.getAccessToken();
20
- const [personUid, members] = await Promise.all([
21
- resolveCallerPersonUid(token),
20
+ // An agent's membership row carries its agt_* uid in the personUid slot.
21
+ const [caller, members] = await Promise.all([
22
+ resolveCallerUid(token),
22
23
  listActiveMembers(token, companyUid),
23
24
  ]);
24
- const me = members.find((m) => m.personUid === personUid);
25
+ const me = members.find((m) => m.personUid === caller.uid);
25
26
  if (!me)
26
27
  return "unknown";
27
28
  return me.role === "owner" || me.role === "admin" ? "allowed" : "denied";
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { AnywhereRuntimeDisabledError } from './anywhere-runtime-flag.js';
3
+ import { type RuntimeHookTrustResult } from '../utils/hook-trust.js';
3
4
  import { type HookTarget } from '../lib/install/claude-targets.js';
4
5
  export interface GlobalInstallOptions {
5
6
  /** HQ checkout to install from (absolute). */
@@ -78,6 +79,8 @@ export interface GlobalCommandOpts {
78
79
  /** Root-discovery seams used by command tests and wrappers. */
79
80
  home?: string;
80
81
  cwd?: string;
82
+ /** Test seam for trusting the Codex hooks a global install registers. */
83
+ trustCodexHooks?: (cwd: string, keys: readonly string[]) => Promise<RuntimeHookTrustResult>;
81
84
  }
82
85
  export declare function resolveInstallRoot(explicit: string | undefined, options?: {
83
86
  env?: NodeJS.ProcessEnv;
@@ -23,10 +23,11 @@ import { grokConnectorInstructions } from '../lib/install/grok-targets.js';
23
23
  import { readRegistry, registryDir, registryFile, writeRegistry } from '../lib/registry/registry.js';
24
24
  import { readCompaniesManifestMap } from '../lib/work-context/repo-remote.js';
25
25
  import { resolveDefaultHqRoot } from '../utils/hq-root-resolver.js';
26
+ import { trustCodexUserHooks } from '../utils/hook-trust.js';
26
27
  import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK, SYSTEM_UNIT_DIR } from '../lib/daemon/install.js';
27
28
  import { readDaemonConfig } from '../lib/daemon/config.js';
28
29
  import { daemonPaths } from '../lib/daemon/paths.js';
29
- import { addAgentsMdBlock, addCodexHooks, classifyLegacySkills, codexAdapterPath, codexCharterSource, codexTargets, hasAllCodexHooks, HQ_OWNED_MARKER, LEGACY_BACKUP_NAME, LEGACY_SKILLS_NAME, removeAgentsMdBlock, removeCodexHooks, } from '../lib/install/codex-targets.js';
30
+ import { addAgentsMdBlock, addCodexHooks, classifyLegacySkills, codexAdapterPath, codexCharterSource, codexHookTrustKeys, codexTargets, hasAllCodexHooks, HQ_OWNED_MARKER, LEGACY_BACKUP_NAME, LEGACY_SKILLS_NAME, removeAgentsMdBlock, removeCodexHooks, } from '../lib/install/codex-targets.js';
30
31
  import { addClaudeMdBlock, addHqHooks, charterPath, claudeTargets, hasAllHqHooks, HQ_ANYWHERE_PACK, HQ_MCP_SERVER_NAME, hqSkillsSource, lineDiff, masterHookPath, shimPath, MCP_SUBPROCESS_PATH, removeClaudeMdBlock, removeHqHooks, SKILL_LINK_PREFIX, } from '../lib/install/claude-targets.js';
31
32
  /** True when an hq daemon unit (launchd plist or systemd unit) exists. */
32
33
  export function isDaemonInstalled(home, platform = process.platform, queryTask = (task) => spawnSync('schtasks.exe', ['/Query', '/TN', task], { encoding: 'utf8', windowsHide: true }).status === 0) {
@@ -1074,8 +1075,9 @@ export async function runInstallGlobal(opts) {
1074
1075
  }
1075
1076
  }
1076
1077
  const install = runtime === 'codex' ? installGlobalCodex : installGlobalClaude;
1078
+ const hqRoot = resolveInstallRoot(opts.hqRoot, { env, cwd: opts.cwd, home: opts.home });
1077
1079
  install({
1078
- hqRoot: resolveInstallRoot(opts.hqRoot, { env, cwd: opts.cwd, home: opts.home }),
1080
+ hqRoot,
1079
1081
  home: opts.home,
1080
1082
  dryRun: opts.dryRun,
1081
1083
  via: opts.via,
@@ -1084,6 +1086,26 @@ export async function runInstallGlobal(opts) {
1084
1086
  env,
1085
1087
  log: opts.log,
1086
1088
  });
1089
+ if (runtime === 'codex' && !opts.dryRun)
1090
+ await trustInstalledCodexHooks(hqRoot, opts);
1091
+ }
1092
+ /**
1093
+ * Codex skips untrusted hooks, so the hooks.json entries written above do
1094
+ * nothing until they are trusted. Trust exactly those entries; on failure keep
1095
+ * the install and tell the user how to finish.
1096
+ */
1097
+ async function trustInstalledCodexHooks(hqRoot, opts) {
1098
+ const log = opts.log ?? ((l) => console.log(l));
1099
+ const home = resolveEnv({ home: opts.home }).home;
1100
+ const hooksJson = codexTargets(home).hooksJson;
1101
+ const keys = codexHookTrustKeys(JSON.parse(fs.readFileSync(hooksJson, 'utf-8')), hooksJson, hqRoot);
1102
+ const result = await (opts.trustCodexHooks ?? trustCodexUserHooks)(home, keys);
1103
+ if (result.status === 'trusted')
1104
+ log(` hooks: trusted ${result.trusted} HQ hook(s) in Codex`);
1105
+ else if (result.status === 'failed') {
1106
+ log(` hooks: Codex hooks are not trusted yet (${result.reason ?? 'unknown error'}), so Codex will skip them.`);
1107
+ log(' To finish, open Codex and approve the HQ hooks with /hooks, or re-run `hq install --global --runtime codex`.');
1108
+ }
1087
1109
  }
1088
1110
  export function registerUninstallCommand(program) {
1089
1111
  program
@@ -14,6 +14,8 @@ import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLane
14
14
  import { findRetiredLaneEntry, keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
15
15
  import { isTerminalLaneState } from "../lib/lanes/types.js";
16
16
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
17
+ import { appendLaneFocusOverride, focusGuardRefusal, makeFocusOverride } from "../lib/lanes/focus-guard.js";
18
+ import { appendProjectFocusOverride, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
17
19
  import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
18
20
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, callerMatchesSessionSenior, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
19
21
  export function parseMonitorLaneMessagePayload(input) {
@@ -381,6 +383,28 @@ export function createLaneMessageHandlers(deps) {
381
383
  if (opts.story !== undefined && !opts.story.trim()) {
382
384
  throw new LanesError("missing_story", "--story must be non-empty", {});
383
385
  }
386
+ if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
387
+ validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
388
+ }
389
+ if (opts.focusOverride !== undefined && !storyId) {
390
+ throw new LanesError("focus_override_story_required", "--focus-override requires --story", { lane_id: laneId });
391
+ }
392
+ const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
393
+ const focusAssessment = opts.project !== undefined || opts.story !== undefined || opts.focusOverride !== undefined
394
+ ? await deps.focusGuardAssessment(ctx, {
395
+ projectId,
396
+ storyIds: storyId ? [storyId] : [],
397
+ company: lane.company,
398
+ checkTeamBlockers: attributionChanged,
399
+ overrideReason: opts.focusOverride,
400
+ })
401
+ : { enabled: false, reasons: [] };
402
+ if (focusAssessment.enabled && focusAssessment.reasons.length > 0 && opts.focusOverride === undefined) {
403
+ throw focusGuardRefusal(focusAssessment.reasons);
404
+ }
405
+ const focusOverride = focusAssessment.enabled && opts.focusOverride !== undefined
406
+ ? makeFocusOverride({ reason: opts.focusOverride, laneId, at: ctx.now().toISOString(), storyId: storyId })
407
+ : undefined;
384
408
  const refreshProfileSettings = lane.state === "done" &&
385
409
  (projectId !== lane.project_id || storyId !== lane.story_id) &&
386
410
  await deps.reuseProfileRefreshEnabled(ctx, lane);
@@ -567,12 +591,6 @@ export function createLaneMessageHandlers(deps) {
567
591
  });
568
592
  const messageId = mintMessageId();
569
593
  const at = ctx.now().toISOString();
570
- if (opts.story !== undefined && !opts.story.trim()) {
571
- throw new LanesError("missing_story", "--story must be non-empty", {});
572
- }
573
- if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
574
- validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
575
- }
576
594
  const notice = storySwitchNotice(lane.project_id, lane.story_id, projectId, storyId);
577
595
  const promptedText = notice === undefined ? text : `${notice}\n\n${text}`;
578
596
  const framedMonitorEvent = opts.monitorEvent === undefined
@@ -602,51 +620,66 @@ export function createLaneMessageHandlers(deps) {
602
620
  sequence: opts.monitorEvent.sequence,
603
621
  }),
604
622
  };
605
- if (projectId !== lane.project_id || storyId !== lane.story_id) {
606
- lane = casUpdateLane(hqRoot, laneId, (current) => {
607
- const already = current.attribution_history.some((entry) => entry.message_id === messageId);
608
- if (already)
609
- return current;
610
- const existingStory = laneStory(current, projectId, storyId);
611
- storySwitchRollback = {
612
- message_id: messageId,
613
- project_id: current.project_id,
614
- story_id: current.story_id,
615
- no_story_reason: current.no_story_reason,
616
- ...(!existingStory ? {
617
- added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
618
- } : {}),
619
- story_switch_unacknowledged: current.story_switch_unacknowledged,
620
- };
621
- return {
622
- ...current,
623
- project_id: projectId,
624
- story_id: storyId,
625
- no_story_reason: storyId ? undefined : current.no_story_reason,
626
- stories: laneStory(current, projectId, storyId)
627
- ? laneStories(current)
628
- : [
629
- ...laneStories(current),
630
- { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
631
- ],
632
- attribution_history: appendCappedHistory(current.attribution_history, {
633
- project_id: projectId,
634
- story_id: storyId,
635
- message_id: messageId,
636
- at,
637
- }),
638
- story_switch_pending: {
639
- previous_project_id: current.project_id,
640
- previous_story_id: current.story_id,
641
- project_id: projectId,
642
- story_id: storyId,
643
- message_id: messageId,
644
- at,
645
- previous_artifact_keys: artifactIdentityKeys(current.last_envelope?.artifacts),
646
- },
647
- story_switch_unacknowledged: undefined,
648
- };
649
- });
623
+ if (attributionChanged || focusOverride !== undefined) {
624
+ if (focusOverride !== undefined) {
625
+ appendProjectFocusOverride(hqRoot, projectId, lane.company?.slug, focusOverride);
626
+ }
627
+ try {
628
+ lane = casUpdateLane(hqRoot, laneId, (current) => {
629
+ let next = current;
630
+ if (attributionChanged) {
631
+ const already = current.attribution_history.some((entry) => entry.message_id === messageId);
632
+ if (!already) {
633
+ const existingStory = laneStory(current, projectId, storyId);
634
+ storySwitchRollback = {
635
+ message_id: messageId,
636
+ project_id: current.project_id,
637
+ story_id: current.story_id,
638
+ no_story_reason: current.no_story_reason,
639
+ ...(!existingStory ? {
640
+ added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
641
+ } : {}),
642
+ story_switch_unacknowledged: current.story_switch_unacknowledged,
643
+ };
644
+ next = {
645
+ ...current,
646
+ project_id: projectId,
647
+ story_id: storyId,
648
+ no_story_reason: storyId ? undefined : current.no_story_reason,
649
+ stories: laneStory(current, projectId, storyId)
650
+ ? laneStories(current)
651
+ : [
652
+ ...laneStories(current),
653
+ { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
654
+ ],
655
+ attribution_history: appendCappedHistory(current.attribution_history, {
656
+ project_id: projectId,
657
+ story_id: storyId,
658
+ message_id: messageId,
659
+ at,
660
+ }),
661
+ story_switch_pending: {
662
+ previous_project_id: current.project_id,
663
+ previous_story_id: current.story_id,
664
+ project_id: projectId,
665
+ story_id: storyId,
666
+ message_id: messageId,
667
+ at,
668
+ previous_artifact_keys: artifactIdentityKeys(current.last_envelope?.artifacts),
669
+ },
670
+ story_switch_unacknowledged: undefined,
671
+ };
672
+ }
673
+ }
674
+ return focusOverride === undefined ? next : appendLaneFocusOverride(next, focusOverride);
675
+ });
676
+ }
677
+ catch (error) {
678
+ if (focusOverride !== undefined) {
679
+ removeProjectFocusOverride(hqRoot, projectId, lane.company?.slug, focusOverride);
680
+ }
681
+ throw error;
682
+ }
650
683
  }
651
684
  let resumed = null;
652
685
  let move = null;
@@ -7,6 +7,8 @@ import { assertNoSecretText } from "../lib/lanes/text-scan.js";
7
7
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
8
8
  import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
9
9
  import { markSpawnFailure, spawnLaneWorker } from "../lib/lanes/spawn.js";
10
+ import { appendProjectFocusOverride, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
11
+ import { appendLaneFocusOverride, makeFocusOverride } from "../lib/lanes/focus-guard.js";
10
12
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, hasKeepAliveProgress, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
11
13
  export function createLaneResumeHandlers(deps) {
12
14
  const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, automaticBalanceMoveHistoryReason, authorizeFailedResume, authorizeResumeProfileRepin, casUpdateLane, dispatchOutpostLaneCommand, laneIsHostedHere, laneWithRecoveredProviderSession, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
@@ -200,10 +202,22 @@ export function createLaneResumeHandlers(deps) {
200
202
  if (opts.story !== undefined && !opts.story.trim()) {
201
203
  throw new LanesError("missing_story", "--story must be non-empty", {});
202
204
  }
205
+ if (opts.focusOverride !== undefined && !storyId) {
206
+ throw new LanesError("focus_override_story_required", "--focus-override requires a target story", { lane_id: laneId });
207
+ }
203
208
  if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
204
209
  validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
205
210
  }
206
211
  const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
212
+ const focusAssessment = opts.story !== undefined || opts.project !== undefined || opts.focusOverride !== undefined
213
+ ? await deps.focusGuardAssessment(ctx, {
214
+ projectId,
215
+ storyIds: storyId ? [storyId] : [],
216
+ company: lane.company,
217
+ checkTeamBlockers: attributionChanged,
218
+ overrideReason: opts.focusOverride,
219
+ })
220
+ : { enabled: false, reasons: [] };
207
221
  const refreshProfileSettings = lane.state === "done" && attributionChanged &&
208
222
  await deps.reuseProfileRefreshEnabled(ctx, lane);
209
223
  const refreshWorker = opts.worker ?? (refreshProfileSettings ? lane.worker_profile : undefined);
@@ -370,6 +384,36 @@ export function createLaneResumeHandlers(deps) {
370
384
  });
371
385
  }
372
386
  try {
387
+ if (focusAssessment.enabled && opts.focusOverride !== undefined) {
388
+ const focusOverride = makeFocusOverride({
389
+ reason: opts.focusOverride,
390
+ laneId: lane.lane_id,
391
+ at,
392
+ storyId,
393
+ });
394
+ try {
395
+ appendProjectFocusOverride(hqRoot, projectId, lane.company?.slug, focusOverride);
396
+ lane = casUpdateLane(hqRoot, laneId, (current) => ({
397
+ ...appendLaneFocusOverride(current, focusOverride),
398
+ }));
399
+ }
400
+ catch (error) {
401
+ try {
402
+ removeProjectFocusOverride(hqRoot, projectId, lane.company?.slug, focusOverride);
403
+ }
404
+ catch (cleanupError) {
405
+ process.stderr.write(`${JSON.stringify({
406
+ event: "lanes.focus_guard.override_rollback_failed",
407
+ lane_id: lane.lane_id,
408
+ error: cleanupError instanceof Error ? cleanupError.name : "UnknownError",
409
+ })}\n`);
410
+ }
411
+ throw new LanesError("focus_override_persist_failed", "Could not persist the focus override; lane resume was refused", {
412
+ lane_id: lane.lane_id,
413
+ error: error instanceof Error ? error.name : "UnknownError",
414
+ });
415
+ }
416
+ }
373
417
  let move = null;
374
418
  if (opts.account !== undefined && !keepCurrentAccount) {
375
419
  move = await prepareAccountMove(ctx, lane, {
@@ -3,6 +3,7 @@ import type { OutpostIdentity } from "../lib/lanes/admission-host.js";
3
3
  import type { OutpostDispatchResult } from "../lib/lanes/outpost-dispatch.js";
4
4
  import type { CpuBusySource } from "../lib/lanes/throttle.js";
5
5
  import type { ProbeCommand } from "../lib/lanes/models.js";
6
+ import type { FocusGuardReason } from "../lib/lanes/focus-guard.js";
6
7
  import type { KillProcessFn, ProveDetachedFn, SpawnProcessFn } from "../lib/lanes/spawn.js";
7
8
  import type { WatcherLoopDeps } from "../lib/lanes/watcher.js";
8
9
  import type { RunCompactionFn } from "../lib/lanes/compaction.js";
@@ -38,6 +39,7 @@ export interface LaneMessageOptions {
38
39
  textFile?: string;
39
40
  project?: string;
40
41
  story?: string;
42
+ focusOverride?: string;
41
43
  compact?: boolean;
42
44
  account?: string;
43
45
  model?: string;
@@ -68,6 +70,7 @@ export interface LaneResumeOptions {
68
70
  failedResumeContext?: string;
69
71
  project?: string;
70
72
  story?: string;
73
+ focusOverride?: string;
71
74
  compact?: boolean;
72
75
  account?: string;
73
76
  model?: string;
@@ -181,6 +184,16 @@ export interface LaneMessageHandlerDeps {
181
184
  casUpdateLane(hqRoot: string, laneId: string, updater: (current: LaneRecord) => LaneRecord): LaneRecord;
182
185
  dispatchOutpostLaneCommand(ctx: Ctx, lane: LaneRecord, action: "message" | "resume", options: LaneMessageOptions | LaneResumeOptions, behavior?: OutpostLaneDispatchBehavior): Promise<LaneMessageResponse | undefined>;
183
186
  dispatchPrimaryLaneMessage(ctx: Ctx, laneId: string, options: LaneMessageOptions): Promise<LaneMessageResponse | undefined>;
187
+ focusGuardAssessment(ctx: Ctx, input: {
188
+ projectId: string;
189
+ storyIds: readonly string[];
190
+ company: LaneCompanyRef | null;
191
+ checkTeamBlockers: boolean;
192
+ overrideReason?: string;
193
+ }): Promise<{
194
+ enabled: boolean;
195
+ reasons: FocusGuardReason[];
196
+ }>;
184
197
  drainHookStatusFor(ctx: Ctx, lane: LaneRecord): {
185
198
  registered: boolean;
186
199
  kind: string;
@@ -217,6 +230,16 @@ export interface LaneMessageHandlerDeps {
217
230
  withResumeHireAdmission<T>(ctx: Ctx, lane: LaneRecord, fn: () => T, grantContext?: OutpostHireGrantContext): Promise<T>;
218
231
  }
219
232
  export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "hasPendingQuestionForLane" | "pendingQuestionsForLane" | "cancelFallbackQuestionsForLane" | "resolveResumeProfile" | "reuseProfileRefreshEnabled" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
233
+ focusGuardAssessment(ctx: Ctx, input: {
234
+ projectId: string;
235
+ storyIds: readonly string[];
236
+ company: LaneCompanyRef | null;
237
+ checkTeamBlockers: boolean;
238
+ overrideReason?: string;
239
+ }): Promise<{
240
+ enabled: boolean;
241
+ reasons: FocusGuardReason[];
242
+ }>;
220
243
  dispatchOutpostLaneCommand(ctx: Ctx, lane: LaneRecord, action: "resume", options: LaneResumeOptions, behavior?: OutpostLaneDispatchBehavior): Promise<void>;
221
244
  authorizeFailedResume(ctx: Ctx, lane: LaneRecord, encodedContext?: string): string;
222
245
  authorizeResumeProfileRepin(ctx: Ctx, lane: LaneRecord, profile: ResolvedResumeProfile | undefined, encodedContext: string | undefined): ResumeProfileRepinAuthorization | undefined;
@@ -285,6 +308,8 @@ export interface Ctx {
285
308
  readLeadAutopilotFlag: (flag: string, options: {
286
309
  company: LaneCompanyRef;
287
310
  }) => Promise<boolean>;
311
+ /** Test seam for the default-off project focus guard flag. */
312
+ focusGuardEnabled?: (company?: LaneCompanyRef) => Promise<boolean>;
288
313
  storyLedgerEnabled: () => boolean;
289
314
  isSsmOwnerDispatch: () => boolean;
290
315
  withGrantHireLocks: typeof withLaneHireLocks;
@@ -11,6 +11,7 @@ import { type LanesMesh, type OutpostRouteIdentity, type SpawnLoginResult } from
11
11
  export type { LanesMesh, SpawnLoginResult } from "./lanes-shared.js";
12
12
  export { CLAUDE_ACCOUNT_BALANCE_SIGNALS_FLAG, LANE_OUTPOST_LOAD_BALANCING_FLAG } from "./lanes-shared.js";
13
13
  export { resolveAnswerChoice } from "./lanes-questions.js";
14
+ import { type FocusGuardPullRequest } from "../lib/lanes/focus-guard.js";
14
15
  import type { OutpostIdentity } from "../lib/lanes/admission-host.js";
15
16
  export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
16
17
  export type { OutpostIdentity } from "../lib/lanes/admission-host.js";
@@ -23,6 +24,7 @@ import type { WatcherLoopDeps } from "../lib/lanes/watcher.js";
23
24
  import { type KnownModelsDeps, type ProbeCommand } from "../lib/lanes/models.js";
24
25
  import { spawnLaneWorker, type KillProcessFn, type ProveDetachedFn, type SpawnProcessFn } from "../lib/lanes/spawn.js";
25
26
  export { spawnLaneWorker };
27
+ export declare function openPullRequestsForProject(hqRoot: string, metadata: Record<string, unknown>): FocusGuardPullRequest[];
26
28
  export { questionBodyAbsPath };
27
29
  export { MAX_LANE_DEPTH };
28
30
  export declare const LANES_RUNS_REL: string;
@@ -98,6 +100,8 @@ export interface LanesCommandDeps {
98
100
  lanePlacementEnabled?: (company: LaneCompanyRef | undefined) => Promise<boolean>;
99
101
  /** Test seam for the default-off story ledger flag. */
100
102
  storyLedgerEnabled?: () => boolean;
103
+ /** Test seam for the default-off focus guard flag. */
104
+ focusGuardEnabled?: (company?: LaneCompanyRef) => Promise<boolean>;
101
105
  /** Test seam for the SSM-only Outpost owner grant path. */
102
106
  isSsmOwnerDispatch?: () => boolean;
103
107
  /** Test seam for changing a lane between pre-lock and locked grant reads. */