@indigoai-us/hq-cli 5.345.43 → 5.345.45

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 (54) hide show
  1. package/CHANGELOG.md +18 -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/db-remote-deps.js +4 -0
  8. package/dist/commands/lanes-checks.d.ts +1 -0
  9. package/dist/commands/lanes-checks.js +6 -1
  10. package/dist/commands/lanes-message.js +84 -51
  11. package/dist/commands/lanes-report.d.ts +2 -1
  12. package/dist/commands/lanes-report.js +63 -6
  13. package/dist/commands/lanes-resume.js +44 -0
  14. package/dist/commands/lanes-shared.d.ts +25 -0
  15. package/dist/commands/lanes.d.ts +4 -0
  16. package/dist/commands/lanes.js +228 -9
  17. package/dist/commands/meetings.js +4 -1
  18. package/dist/index.js +43 -7
  19. package/dist/lib/core-utils/sentry-report.js +8 -1
  20. package/dist/lib/db/control-plane.d.ts +5 -0
  21. package/dist/lib/db/control-plane.js +7 -0
  22. package/dist/lib/flag-snapshot-file.js +33 -25
  23. package/dist/lib/lanes/flag-keys.d.ts +4 -0
  24. package/dist/lib/lanes/flag-keys.js +4 -0
  25. package/dist/lib/lanes/focus-guard.d.ts +65 -0
  26. package/dist/lib/lanes/focus-guard.js +91 -0
  27. package/dist/lib/lanes/grants.js +1 -1
  28. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  29. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  30. package/dist/lib/lanes/reconcile.d.ts +2 -0
  31. package/dist/lib/lanes/reconcile.js +39 -1
  32. package/dist/lib/lanes/spawn.d.ts +4 -0
  33. package/dist/lib/lanes/spawn.js +132 -6
  34. package/dist/lib/lanes/store.js +64 -0
  35. package/dist/lib/lanes/story-ledger.d.ts +14 -0
  36. package/dist/lib/lanes/story-ledger.js +46 -0
  37. package/dist/lib/lanes/tracker-report.d.ts +46 -0
  38. package/dist/lib/lanes/tracker-report.js +85 -1
  39. package/dist/lib/lanes/types.d.ts +12 -2
  40. package/dist/lib/lanes/types.js +5 -3
  41. package/dist/main.js +5 -1
  42. package/dist/startup-registration.d.ts +3 -0
  43. package/dist/startup-registration.js +11 -4
  44. package/dist/utils/package-use-lease.d.ts +1 -0
  45. package/dist/utils/package-use-lease.js +29 -0
  46. package/dist/utils/self-update-backoff.d.ts +17 -0
  47. package/dist/utils/self-update-backoff.js +73 -0
  48. package/dist/utils/self-update.d.ts +7 -0
  49. package/dist/utils/self-update.js +36 -0
  50. package/dist/utils/vault-api.d.ts +33 -1
  51. package/dist/utils/vault-api.js +55 -5
  52. package/dist/utils/version-gate.d.ts +13 -0
  53. package/dist/utils/version-gate.js +35 -5
  54. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.45] — 2026-10-07
6
+ - 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.
7
+
8
+ - 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.
9
+ - Add a default-off focus guard to lane creation and story-switch resume, with logged, auditable overrides.
10
+ - `hq lanes set <lane> --company <slug>` binds missing company attribution, including on legacy and terminal lane records.
11
+ - Use the per-user local application cache when Windows denies the configured flag snapshot cache.
12
+
13
+ ## [5.345.44] — 2026-10-07
14
+ - Long-running CLI processes now refresh their package-use lease so desktop updates only reclaim holders whose liveness heartbeat has expired.
15
+ - 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.
16
+
17
+ - 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.
18
+ - `hq lanes report` can show a gated focus list, focus-guard override reasons, and per-team 24-hour focus share.
19
+ - Return a child's hire grant to its senior when the lane becomes terminal, let the senior adjust or clear the saved grant, report grant updates correctly, and reject over-cap increases while allowing reductions that repair oversubscription.
20
+ - Recover CLI startup when a package update temporarily removes a module from the installed command graph.
21
+
22
+
5
23
  ## [5.345.43] — 2026-10-07
6
24
  - The published core-command E2E workflow now retries the exact npm release install for up to 10 minutes while registry propagation completes.
7
25
 
@@ -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";
@@ -42,6 +42,10 @@ export function describeRemoteError(error) {
42
42
  const status = error.status;
43
43
  const code = error.code;
44
44
  if (code === "REMOTE_DB_NOT_AVAILABLE" || status === 501) {
45
+ const reason = error.reason;
46
+ const message = error.message;
47
+ if (reason === "plan" && typeof message === "string" && message)
48
+ return message;
45
49
  return "Remote databases are not available yet.";
46
50
  }
47
51
  if (status === 402 || code === "PLAN_REQUIRED") {
@@ -22,6 +22,7 @@ export declare function mappingIncludesStory(mapping: ProjectMapping, storyId: s
22
22
  export interface ProjectReadResult {
23
23
  stories: Array<Record<string, unknown>>;
24
24
  mtime: number;
25
+ focus_overrides?: unknown;
25
26
  errors?: Array<{
26
27
  path: string;
27
28
  field: string;
@@ -272,7 +272,12 @@ export function readProject(root, company, prdPath, tolerantStories = false) {
272
272
  return [story];
273
273
  });
274
274
  try {
275
- return { stories, mtime: fs.statSync(prdPath).mtimeMs, ...(tolerantStories ? { errors } : {}) };
275
+ return {
276
+ stories,
277
+ mtime: fs.statSync(prdPath).mtimeMs,
278
+ ...(parsed.focus_overrides === undefined ? {} : { focus_overrides: parsed.focus_overrides }),
279
+ ...(tolerantStories ? { errors } : {}),
280
+ };
276
281
  }
277
282
  catch (err) {
278
283
  return fail("tracker_checks_input_unreadable", `Project task list could not be stat'ed: ${err instanceof Error ? err.message : String(err)}`, { path: prdPath });
@@ -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;
@@ -1,10 +1,11 @@
1
1
  import type { Command } from "commander";
2
2
  import { type QuestionBody } from "../lib/lanes/questions.js";
3
- import type { QuestionRecord } from "../lib/lanes/types.js";
3
+ import type { LaneCompanyRef, QuestionRecord } from "../lib/lanes/types.js";
4
4
  import { type LaneChecksDeps } from "./lanes-checks.js";
5
5
  type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
6
6
  export interface LaneReportsDeps extends LaneChecksDeps {
7
7
  readQuestionBody: (root: string, question: QuestionRecord) => QuestionBody;
8
+ focusDashboardEnabled: (company: LaneCompanyRef) => Promise<boolean>;
8
9
  }
9
10
  export declare function registerLaneReportCommands(parent: Command, deps: LaneReportsDeps, wrap: WrapHandler): void;
10
11
  export {};
@@ -163,7 +163,7 @@ function issueFromError(err, fallbackPath, fallbackField, ids = {}) {
163
163
  ...(typeof details.question_id === "string" ? { question_id: details.question_id } : ids.question_id ? { question_id: ids.question_id } : {}),
164
164
  };
165
165
  }
166
- async function teamInput(root, company, project, questions, deps, errors = []) {
166
+ async function teamInput(root, company, project, questions, deps, errors = [], includeFocusDashboard = false) {
167
167
  const projectDirectory = path.join(root, "companies", company.slug, "projects", project.projectId);
168
168
  const prdPath = path.join(projectDirectory, "prd.json");
169
169
  let prd = { stories: [], mtime: 0 };
@@ -213,10 +213,64 @@ async function teamInput(root, company, project, questions, deps, errors = []) {
213
213
  }
214
214
  normalizedUpdatedAt = normalized;
215
215
  }
216
+ const ownerValue = includeFocusDashboard ? story.owner ?? story.assignee : undefined;
217
+ let owner = null;
218
+ if (ownerValue !== undefined && ownerValue !== null) {
219
+ if (typeof ownerValue === "string" && ownerValue.trim())
220
+ owner = ownerValue.trim();
221
+ else
222
+ errors.push({ path: prdPath, field: "owner", story_id: id, message: "Story owner must be a non-empty string when present" });
223
+ }
224
+ const deadlineValue = includeFocusDashboard ? story.deadline ?? story.due_at ?? story.due_date : undefined;
225
+ let deadline = null;
226
+ if (deadlineValue !== undefined && deadlineValue !== null) {
227
+ if (typeof deadlineValue === "string" && deadlineValue.trim())
228
+ deadline = deadlineValue.trim();
229
+ else
230
+ errors.push({ path: prdPath, field: "deadline", story_id: id, message: "Story deadline must be a non-empty string when present" });
231
+ }
216
232
  const refs = storyReferences(story, prdPath, id, errors);
217
233
  pullRequests.push(...refs.pullRequests);
218
234
  releases.push(...refs.releases);
219
- stories.push({ id, title, status, references: refs.references, ...(normalizedUpdatedAt ? { updated_at: normalizedUpdatedAt } : {}), source_path: prdPath });
235
+ stories.push({
236
+ id,
237
+ title,
238
+ status,
239
+ focus_id: story.focus_id,
240
+ kpi: story.kpi,
241
+ owner,
242
+ deadline,
243
+ references: refs.references,
244
+ ...(normalizedUpdatedAt ? { updated_at: normalizedUpdatedAt } : {}),
245
+ source_path: prdPath,
246
+ });
247
+ }
248
+ const focusOverrides = [];
249
+ if (includeFocusDashboard && prd.focus_overrides !== undefined) {
250
+ if (!Array.isArray(prd.focus_overrides)) {
251
+ errors.push({ path: prdPath, field: "focus_overrides", message: "Project focus_overrides must be an array" });
252
+ }
253
+ else {
254
+ for (const [index, raw] of prd.focus_overrides.entries()) {
255
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
256
+ errors.push({ path: prdPath, field: `focus_overrides[${index}]`, message: "Focus override must be an object" });
257
+ continue;
258
+ }
259
+ const record = raw;
260
+ if (["reason", "by_lane", "at", "story"].some((field) => typeof record[field] !== "string" || !record[field].trim())) {
261
+ errors.push({ path: prdPath, field: `focus_overrides[${index}]`, message: "Focus override needs non-empty reason, by_lane, at, and story fields" });
262
+ continue;
263
+ }
264
+ const at = normalizeIsoTimestamp(record.at);
265
+ if (at === null) {
266
+ errors.push({ path: prdPath, field: `focus_overrides[${index}].at`, message: "Focus override at must be a valid timestamp" });
267
+ continue;
268
+ }
269
+ const override = { reason: record.reason, by_lane: record.by_lane, at, story: record.story };
270
+ if (mappingIncludesStory(project, override.story))
271
+ focusOverrides.push(override);
272
+ }
273
+ }
220
274
  }
221
275
  const teamQuestions = [];
222
276
  for (const record of questions.filter((question) => question.project_id === project.projectId
@@ -236,6 +290,7 @@ async function teamInput(root, company, project, questions, deps, errors = []) {
236
290
  team: project.team,
237
291
  project_id: project.projectId,
238
292
  stories,
293
+ focus_overrides: focusOverrides,
239
294
  pull_requests: pullRequests,
240
295
  releases,
241
296
  blockers: await trackerRecords(root, company, project, "blocker", errors),
@@ -246,7 +301,7 @@ async function teamInput(root, company, project, questions, deps, errors = []) {
246
301
  errors,
247
302
  };
248
303
  }
249
- async function allTeamInputs(root, company, deps) {
304
+ async function allTeamInputs(root, company, deps, includeFocusDashboard) {
250
305
  const mappings = loadProjectMappings(root, company.slug);
251
306
  const teams = [];
252
307
  for (const project of [...mappings.values()].sort((a, b) => a.team.localeCompare(b.team) || a.projectId.localeCompare(b.projectId))) {
@@ -258,7 +313,7 @@ async function allTeamInputs(root, company, deps) {
258
313
  catch (err) {
259
314
  errors.push(issueFromError(err, path.join(root, "companies", company.slug, "projects", project.projectId), "questions"));
260
315
  }
261
- teams.push(await teamInput(root, company, project, questions, deps, errors));
316
+ teams.push(await teamInput(root, company, project, questions, deps, errors, includeFocusDashboard));
262
317
  }
263
318
  return teams;
264
319
  }
@@ -282,6 +337,7 @@ export function registerLaneReportCommands(parent, deps, wrap) {
282
337
  const company = await resolveTrackerCompany(deps, options.company);
283
338
  const root = deps.hqRoot();
284
339
  const generatedAt = deps.now().toISOString();
340
+ const focusDashboardEnabled = await deps.focusDashboardEnabled(company);
285
341
  const previous = options.since === undefined ? readTrackerReportState(root, company.slug) : null;
286
342
  const since = options.since ?? previous?.last_report_at ?? FIRST_TRACKER_REPORT_SINCE;
287
343
  const input = {
@@ -289,7 +345,8 @@ export function registerLaneReportCommands(parent, deps, wrap) {
289
345
  generated_at: generatedAt,
290
346
  since,
291
347
  since_source: options.since !== undefined ? "explicit" : previous ? "previous_report" : "first_run",
292
- teams: await allTeamInputs(root, company, deps),
348
+ focus_dashboard_enabled: focusDashboardEnabled,
349
+ teams: await allTeamInputs(root, company, deps, focusDashboardEnabled),
293
350
  };
294
351
  const report = buildTrackerOwnerReport(input);
295
352
  const hasErrors = report.teams.some((team) => team.errors.length > 0);
@@ -323,7 +380,7 @@ export function registerLaneReportCommands(parent, deps, wrap) {
323
380
  catch (err) {
324
381
  errors.push(issueFromError(err, path.join(root, "companies", company.slug, "projects", candidates[0].projectId), "questions"));
325
382
  }
326
- const input = await teamInput(root, company, candidates[0], questions, deps, errors);
383
+ const input = await teamInput(root, company, candidates[0], questions, deps, errors, false);
327
384
  const page = buildTrackerTeamPage(input, company.slug, since, generatedAt);
328
385
  if (page.errors.length > 0)
329
386
  process.exitCode = 2;
@@ -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, {