@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
@@ -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. */
@@ -37,7 +37,9 @@ import { laneDepthCap } from "../lib/lanes/depth-cap.js";
37
37
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
38
38
  import { runResidentCommandWithPackageLease } from "../utils/resident-package-preload.js";
39
39
  import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff } from "../lib/lanes/registry-flag.js";
40
- import { LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, } from "../lib/lanes/flag-keys.js";
40
+ import { appendProjectFocusOverride, readProjectStoryFocusData, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
41
+ import { appendLaneFocusOverride, assertFocusGuardAllows, makeFocusOverride } from "../lib/lanes/focus-guard.js";
42
+ import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, } from "../lib/lanes/flag-keys.js";
41
43
  import { configuredCodexRolloutHomes, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
42
44
  import { isControllerAliasOutpostId } from "../lib/lanes/admission-host.js";
43
45
  import { runLeadAutopilotPass } from "../lib/lanes/autopilot.js";
@@ -86,6 +88,133 @@ import { defaultProbe, validateProfileLaneModel, validateLaneModel, } from "../l
86
88
  import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, spawnEnvelopeRepairRequest, laneRunDir, readSpawnManifest, stopLaneProcesses, } from "../lib/lanes/spawn.js";
87
89
  import { handOffInstallHere } from "../lib/daemon/handoff.js";
88
90
  export { spawnLaneWorker };
91
+ export function openPullRequestsForProject(hqRoot, metadata) {
92
+ const configured = metadata.repos;
93
+ if (!Array.isArray(configured)) {
94
+ throw new Error("project does not name repositories");
95
+ }
96
+ if (configured.length === 0)
97
+ return [];
98
+ const all = [];
99
+ const reposRoot = fs.realpathSync(path.resolve(hqRoot, "repos")) + path.sep;
100
+ for (const repo of configured) {
101
+ if (repo === null || typeof repo !== "object" || typeof repo.path !== "string") {
102
+ throw new Error("project repository entry is invalid");
103
+ }
104
+ const repoPath = repo.path;
105
+ if (!/^repos\/(private|public)\/[a-zA-Z0-9._/-]+$/.test(repoPath)) {
106
+ throw new Error("project repository path is outside repos");
107
+ }
108
+ if (repoPath.split("/").some((segment) => segment === "." || segment === "..")) {
109
+ throw new Error("project repository path is outside repos");
110
+ }
111
+ const repoDir = path.resolve(hqRoot, repoPath);
112
+ const canonicalRepoDir = fs.realpathSync(repoDir);
113
+ if (!canonicalRepoDir.startsWith(reposRoot))
114
+ throw new Error("project repository path is outside repos");
115
+ const remoteResult = spawnSync("git", ["-C", repoDir, "remote", "get-url", "origin"], {
116
+ encoding: "utf8",
117
+ timeout: 5_000,
118
+ windowsHide: true,
119
+ });
120
+ if (remoteResult.status !== 0)
121
+ throw new Error("project repository remote could not be read");
122
+ const remote = remoteResult.stdout.trim();
123
+ const match = remote.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i);
124
+ if (!match)
125
+ throw new Error("project repository is not a GitHub repository");
126
+ const endpoint = `repos/${match[1]}/${match[2]}/pulls?state=open&per_page=100`;
127
+ const result = spawnSync("gh", ["api", "--paginate", "--slurp", endpoint], {
128
+ encoding: "utf8",
129
+ timeout: 20_000,
130
+ windowsHide: true,
131
+ });
132
+ if (result.status !== 0)
133
+ throw new Error("GitHub pull request lookup failed");
134
+ const pages = JSON.parse(result.stdout || "[]");
135
+ if (!Array.isArray(pages))
136
+ throw new Error("GitHub pull request response was invalid");
137
+ for (const page of pages) {
138
+ if (!Array.isArray(page))
139
+ throw new Error("GitHub pull request page was invalid");
140
+ for (const item of page) {
141
+ if (item === null || typeof item !== "object")
142
+ throw new Error("GitHub pull request entry was invalid");
143
+ const pr = item;
144
+ if (!Number.isInteger(pr.number) || typeof pr.created_at !== "string" || !Number.isFinite(Date.parse(pr.created_at))) {
145
+ throw new Error("GitHub pull request entry was invalid");
146
+ }
147
+ all.push({ number: pr.number, created_at: pr.created_at, repository: `${match[1]}/${match[2]}` });
148
+ }
149
+ }
150
+ }
151
+ return all;
152
+ }
153
+ async function focusGuardAssessment(ctx, input) {
154
+ if (input.overrideReason !== undefined) {
155
+ const normalizedReason = input.overrideReason.trim();
156
+ if (!normalizedReason) {
157
+ throw new LanesError("focus_override_reason_required", "--focus-override requires a non-empty reason", {
158
+ story_ids: input.storyIds,
159
+ });
160
+ }
161
+ assertNoSecretText("focus override reason", normalizedReason);
162
+ }
163
+ let enabled = false;
164
+ try {
165
+ enabled = ctx.focusGuardEnabled
166
+ ? await ctx.focusGuardEnabled(input.company ?? undefined)
167
+ : await readLanesRegistryFlagFailOff(LANE_FOCUS_GUARD_FLAG, {
168
+ company: input.company,
169
+ env: ctx.env,
170
+ });
171
+ }
172
+ catch (error) {
173
+ process.stderr.write(`${JSON.stringify({
174
+ event: "lanes.focus_guard.flag_read_failed",
175
+ error: error instanceof Error ? error.name : "UnknownError",
176
+ fallback: false,
177
+ })}\n`);
178
+ }
179
+ if (!enabled)
180
+ return { enabled: false, reasons: [] };
181
+ const project = readProjectStoryFocusData(ctx.hqRoot(), input.projectId, input.company?.slug);
182
+ let pullRequests;
183
+ let pullRequestLookupFailed = false;
184
+ if (input.checkTeamBlockers) {
185
+ try {
186
+ pullRequests = openPullRequestsForProject(ctx.hqRoot(), project.metadata);
187
+ }
188
+ catch (error) {
189
+ pullRequestLookupFailed = true;
190
+ process.stderr.write(`${JSON.stringify({
191
+ event: "lanes.focus_guard.pr_lookup_failed",
192
+ project_id: input.projectId,
193
+ error: error instanceof Error ? error.name : "UnknownError",
194
+ })}\n`);
195
+ }
196
+ }
197
+ const tenantLanes = loadAllLanes(ctx.hqRoot()).filter((lane) => lane.company?.slug === input.company?.slug && (lane.project_id === input.projectId ||
198
+ lane.stories?.some((story) => story.project_id === input.projectId) === true));
199
+ const reasons = assertFocusGuardAllows(enabled, {
200
+ storyIds: input.storyIds,
201
+ stories: project.userStories.map((story) => ({
202
+ id: typeof story.id === "string" ? story.id : "",
203
+ focus_id: story.focus_id,
204
+ kpi: story.kpi,
205
+ state: story.state,
206
+ status: story.status,
207
+ lane: story.lane,
208
+ lanes: story.lanes,
209
+ })),
210
+ lanes: tenantLanes,
211
+ ...(pullRequests === undefined ? {} : { pullRequests }),
212
+ pullRequestLookupFailed,
213
+ now: ctx.now(),
214
+ checkTeamBlockers: input.checkTeamBlockers,
215
+ }, input.overrideReason);
216
+ return { enabled: true, reasons };
217
+ }
89
218
  export { questionBodyAbsPath };
90
219
  export { MAX_LANE_DEPTH };
91
220
  export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
@@ -329,6 +458,7 @@ function resolveDeps(deps = {}) {
329
458
  fetchText: deps.fetchText ?? defaultFetchText,
330
459
  lanePlacementEnabled: deps.lanePlacementEnabled ?? defaultLanePlacementEnabled,
331
460
  storyLedgerEnabled: deps.storyLedgerEnabled ?? laneStoryLedgerEnabled,
461
+ focusGuardEnabled: deps.focusGuardEnabled,
332
462
  isSsmOwnerDispatch: deps.isSsmOwnerDispatch ?? (() => isSsmRunCommandProcess()),
333
463
  withGrantHireLocks: deps.withGrantHireLocks ?? withLaneHireLocks,
334
464
  mergeReviewEnabled: deps.mergeReviewEnabled,
@@ -3033,6 +3163,12 @@ async function runCreate(ctx, opts) {
3033
3163
  if (opts.story !== undefined && opts.noStory !== undefined) {
3034
3164
  throw new LanesError("story_selection_conflict", "Pass either --story or --no-story, not both", {});
3035
3165
  }
3166
+ if (opts.focusOverride !== undefined) {
3167
+ if (!opts.focusOverride.trim()) {
3168
+ throw new LanesError("focus_override_reason_required", "--focus-override requires a non-empty reason", {});
3169
+ }
3170
+ assertNoSecretText("focus override reason", opts.focusOverride.trim());
3171
+ }
3036
3172
  if (opts.noStory !== undefined && !noStoryReason) {
3037
3173
  throw new LanesError("missing_no_story_reason", "--no-story requires a non-empty reason", {});
3038
3174
  }
@@ -3046,6 +3182,9 @@ async function runCreate(ctx, opts) {
3046
3182
  throw new LanesError("missing_story", "--additional-story values must be non-empty", {});
3047
3183
  }
3048
3184
  const storyIds = story ? [story, ...additionalStories] : [];
3185
+ if (opts.focusOverride !== undefined && storyIds.length === 0) {
3186
+ throw new LanesError("focus_override_story_required", "--focus-override requires --story", {});
3187
+ }
3049
3188
  if (new Set(storyIds).size !== storyIds.length) {
3050
3189
  throw new LanesError("duplicate_story", "A lane cannot carry the same project/story more than once", {
3051
3190
  project_id: project,
@@ -3113,6 +3252,15 @@ async function runCreate(ctx, opts) {
3113
3252
  if (storyIds.length > 0 && ctx.storyLedgerEnabled()) {
3114
3253
  validateProjectStories(hqRoot, project, storyIds, company?.slug);
3115
3254
  }
3255
+ const focusAssessment = storyIds.length > 0
3256
+ ? await focusGuardAssessment(ctx, {
3257
+ projectId: project,
3258
+ storyIds,
3259
+ company,
3260
+ checkTeamBlockers: true,
3261
+ overrideReason: opts.focusOverride,
3262
+ })
3263
+ : { enabled: false, reasons: [] };
3116
3264
  let meshCompany = company;
3117
3265
  if (meshCompanyFlag) {
3118
3266
  const resolvedMeshCompany = await ctx.resolveCompany(meshCompanyFlag);
@@ -3767,6 +3915,53 @@ async function runCreate(ctx, opts) {
3767
3915
  }
3768
3916
  let lane = persisted.lane;
3769
3917
  const { admission, validation } = persisted;
3918
+ if (focusAssessment.enabled && opts.focusOverride !== undefined) {
3919
+ const focusOverride = makeFocusOverride({
3920
+ reason: opts.focusOverride,
3921
+ laneId: lane.lane_id,
3922
+ at: ctx.now().toISOString(),
3923
+ storyId: story ?? "",
3924
+ });
3925
+ try {
3926
+ appendProjectFocusOverride(hqRoot, project, company?.slug, focusOverride);
3927
+ lane = casUpdateLane(hqRoot, lane.lane_id, (current) => ({
3928
+ ...appendLaneFocusOverride(current, focusOverride),
3929
+ }));
3930
+ }
3931
+ catch (error) {
3932
+ try {
3933
+ removeProjectFocusOverride(hqRoot, project, company?.slug, focusOverride);
3934
+ }
3935
+ catch (cleanupError) {
3936
+ process.stderr.write(`${JSON.stringify({
3937
+ event: "lanes.focus_guard.override_rollback_failed",
3938
+ lane_id: lane.lane_id,
3939
+ error: cleanupError instanceof Error ? cleanupError.name : "UnknownError",
3940
+ })}\n`);
3941
+ }
3942
+ try {
3943
+ casUpdateLane(hqRoot, lane.lane_id, (current) => ({
3944
+ ...current,
3945
+ state: "failed",
3946
+ queued_reason: undefined,
3947
+ queued_details: undefined,
3948
+ admission_reserved_at: undefined,
3949
+ terminal_reason: "focus_override_persist_failed",
3950
+ }));
3951
+ }
3952
+ catch (markError) {
3953
+ process.stderr.write(`${JSON.stringify({
3954
+ event: "lanes.focus_guard.lane_fail_mark_failed",
3955
+ lane_id: lane.lane_id,
3956
+ error: markError instanceof Error ? markError.name : "UnknownError",
3957
+ })}\n`);
3958
+ }
3959
+ throw new LanesError("focus_override_persist_failed", "Could not persist the focus override; lane launch was refused", {
3960
+ lane_id: lane.lane_id,
3961
+ error: error instanceof Error ? error.name : "UnknownError",
3962
+ });
3963
+ }
3964
+ }
3770
3965
  if (meshCompany) {
3771
3966
  try {
3772
3967
  lane = await ensureLaneThreadRecord(ctx, lane);
@@ -4521,6 +4716,7 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
4521
4716
  text: options.text,
4522
4717
  project: options.project,
4523
4718
  story: options.story,
4719
+ focusOverride: options.focusOverride,
4524
4720
  account: options.account,
4525
4721
  model: options.model,
4526
4722
  effort: options.effort,
@@ -5939,17 +6135,23 @@ async function applyHireGrant(ctx, laneId, hireGrant, authorization, grantContex
5939
6135
  assigned_child_grants: allocated,
5940
6136
  });
5941
6137
  }
5942
- return retryUpdateLane(hqRoot, laneId, "command", (record) => ({
5943
- ...record,
5944
- hire_grant: hireGrant,
5945
- }));
6138
+ return retryUpdateLane(hqRoot, laneId, "command", (record) => isTerminalLaneState(record.state)
6139
+ ? { ...record, hire_grant: 0, released_hire_grant: hireGrant }
6140
+ : { ...record, hire_grant: hireGrant });
5946
6141
  });
5947
6142
  return updated;
5948
6143
  }
5949
6144
  async function printGrantResult(ctx, hqRoot, updated, hierarchy) {
5950
6145
  const finishedChildrenDoNotCount = await hireGrantFinishedChildrenDoNotCount(ctx, updated);
5951
6146
  const fields = laneHireFields(updated, hierarchy ?? loadAllLanes(hqRoot), undefined, finishedChildrenDoNotCount);
5952
- printJson({ ok: true, action: "grant", lane_id: updated.lane_id, lane: updated, ...fields });
6147
+ printJson({
6148
+ ok: true,
6149
+ action: "grant",
6150
+ lane_id: updated.lane_id,
6151
+ lane: updated,
6152
+ ...fields,
6153
+ hire_grant: updated.released_hire_grant ?? fields.hire_grant,
6154
+ });
5953
6155
  }
5954
6156
  /**
5955
6157
  * `hq lanes set`: change the model or effort a lane uses from its next
@@ -5958,8 +6160,15 @@ async function printGrantResult(ctx, hqRoot, updated, hierarchy) {
5958
6160
  * next `set` or an explicit resume `--effort`/`--worker`.
5959
6161
  */
5960
6162
  async function runSet(ctx, laneId, opts) {
6163
+ if (opts.company !== undefined) {
6164
+ if (opts.model !== undefined || opts.effort !== undefined || opts.reason !== undefined) {
6165
+ throw new LanesError("lane_set_company_exclusive", "--company cannot be combined with --model, --effort, or --reason", { lane_id: laneId });
6166
+ }
6167
+ await runRepairCompany(ctx, laneId, { company: opts.company });
6168
+ return;
6169
+ }
5961
6170
  if (opts.model === undefined && opts.effort === undefined) {
5962
- throw new LanesError("lane_set_empty", "hq lanes set needs --model or --effort", { lane_id: laneId });
6171
+ throw new LanesError("lane_set_empty", "hq lanes set needs --company, --model, or --effort", { lane_id: laneId });
5963
6172
  }
5964
6173
  const reason = opts.reason?.trim();
5965
6174
  if (opts.reason !== undefined && !reason) {
@@ -7573,7 +7782,7 @@ async function repairCompanyOnOwner(ctx, laneId, opts, emitResult = true) {
7573
7782
  if (current.company !== null) {
7574
7783
  if (sameLaneCompany(current.company, company))
7575
7784
  return current;
7576
- throw new LanesError("lane_company_already_set", `Lane ${laneId} already belongs to ${current.company.slug}; company repair only fills missing attribution`, { lane_id: laneId, company: current.company.slug, requested_company: company.slug });
7785
+ throw new LanesError("lane_company_already_set", `Cannot set lane ${laneId} to ${company.slug}: it already belongs to ${current.company.slug}`, { lane_id: laneId, company: current.company.slug, requested_company: company.slug });
7577
7786
  }
7578
7787
  return {
7579
7788
  ...current,
@@ -8879,6 +9088,7 @@ export function registerLanesCommand(parent, deps = {}) {
8879
9088
  casUpdateLane,
8880
9089
  dispatchOutpostLaneCommand,
8881
9090
  dispatchPrimaryLaneMessage,
9091
+ focusGuardAssessment,
8882
9092
  drainHookStatusFor,
8883
9093
  laneIsHostedHere,
8884
9094
  laneWithRecoveredProviderSession,
@@ -8910,6 +9120,7 @@ export function registerLanesCommand(parent, deps = {}) {
8910
9120
  automaticBalanceMoveHistoryReason,
8911
9121
  authorizeFailedResume,
8912
9122
  authorizeResumeProfileRepin,
9123
+ focusGuardAssessment,
8913
9124
  casUpdateLane,
8914
9125
  dispatchOutpostLaneCommand: async (commandCtx, lane, action, options, behavior) => {
8915
9126
  await dispatchOutpostLaneCommand(commandCtx, lane, action, options, behavior);
@@ -8985,6 +9196,10 @@ export function registerLanesCommand(parent, deps = {}) {
8985
9196
  hqRoot: ctx.hqRoot,
8986
9197
  resolveCompany: ctx.resolveCompany,
8987
9198
  now: ctx.now,
9199
+ focusDashboardEnabled: (company) => readLanesRegistryFlagFailOff(LANE_FOCUS_DASHBOARD_FLAG, {
9200
+ company,
9201
+ env: ctx.env,
9202
+ }, readLanesRegistryFlag),
8988
9203
  readQuestionBody: (root, question) => {
8989
9204
  const body = questionHandlers.requireQuestionBody(root, question);
8990
9205
  assertQuestionBodySecretSafe(body);
@@ -9021,6 +9236,7 @@ export function registerLanesCommand(parent, deps = {}) {
9021
9236
  .option("--mesh-company <slug|uid>", "Explicit Work Mesh company for a personal lane; membership is verified")
9022
9237
  .requiredOption("--project <slug>", "HQ project slug")
9023
9238
  .option("--story <id>", "Story id from the project's prd.json")
9239
+ .option("--focus-override <reason>", "Human-approved reason to override the lane focus guard")
9024
9240
  .addOption(noStoryOption)
9025
9241
  .option("--additional-story <id>", "Additional story carried by this lane; repeatable and discouraged when active", collectRepeatable)
9026
9242
  .requiredOption("--brief-file <path>", "Path to the lane brief")
@@ -9083,8 +9299,9 @@ export function registerLanesCommand(parent, deps = {}) {
9083
9299
  }));
9084
9300
  lanes
9085
9301
  .command("set")
9086
- .description("Change the model or effort a lane uses from its next resume")
9302
+ .description("Change lane settings or bind its missing company attribution")
9087
9303
  .argument("<lane-id>", "Lane id")
9304
+ .option("--company <slug>", "Bind missing company attribution to this lane")
9088
9305
  .option("--model <model>", "Model for the lane's next and later turns")
9089
9306
  .option("--effort <level>", "Effort for the lane's next and later turns")
9090
9307
  .option("--reason <text>", "Why the settings changed; recorded in settings_history")
@@ -9295,6 +9512,7 @@ export function registerLanesCommand(parent, deps = {}) {
9295
9512
  .option("--text-file <path>", "Read message text from a file")
9296
9513
  .option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
9297
9514
  .option("--story <id>", "Re-point story attribution; a changed story is named in the resumed prompt")
9515
+ .option("--focus-override <reason>", "Human-approved reason to override the lane focus guard")
9298
9516
  .option("--account <id>", "Move the lane's session to this account of its provider before the message resumes it")
9299
9517
  .option("--model <id>", "Model for the moved lane (with --account only)")
9300
9518
  .option("--effort <level>", "Reasoning effort for the resumed provider turn")
@@ -9351,6 +9569,7 @@ export function registerLanesCommand(parent, deps = {}) {
9351
9569
  .option("--reason <text>", "Required explanation for resuming from failed")
9352
9570
  .option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
9353
9571
  .option("--story <id>", "Re-point story attribution; a changed story is named in the resumed prompt")
9572
+ .option("--focus-override <reason>", "Human-approved reason to override the lane focus guard")
9354
9573
  .option("--account <id>", "Move the lane's session to this account of its provider before resuming")
9355
9574
  .option("--model <id>", "Model for the moved lane (with --account only)")
9356
9575
  .option("--effort <level>", "Reasoning effort for the resumed provider turn")
@@ -520,7 +520,10 @@ export function registerMeetingsCommand(program) {
520
520
  }
521
521
  const token = await ensureCognitoToken();
522
522
  const companyId = await getCompanyUid(token, companySlug);
523
- const recorderPersonUid = await resolveCallerPersonUid(token);
523
+ // The import endpoint records a person as the recorder (prs_* only).
524
+ const recorderPersonUid = await resolveCallerPersonUid(token, undefined, {
525
+ command: "hq meetings import",
526
+ });
524
527
  const res = await vaultApiFetch({
525
528
  token,
526
529
  method: "POST",
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { CLI_COMMAND_HELP_CATALOG_FLAG } from "./lib/cli-flag-keys.js";
12
12
  // heavy import (main.js) is lazy and reached only when a rejection fires.
13
13
  import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.js";
14
14
  import { installFastPathStreamErrorHandlers } from "./utils/fast-stream-errors.js";
15
+ import { fileURLToPath } from "node:url";
15
16
  // Install BEFORE any dispatch so a floated rejection on ANY path (the command
16
17
  // graph or the fast-core forwarder) becomes a non-zero exit. @sentry/node's
17
18
  // onUnhandledRejection integration defaults to 'warn' — it captures but does
@@ -20,6 +21,44 @@ import { installFastPathStreamErrorHandlers } from "./utils/fast-stream-errors.j
20
21
  // so runCli's finally still finalizes release health and flushes Sentry.
21
22
  installProcessRejectionBoundary();
22
23
  installFastPathStreamErrorHandlers();
24
+ async function runMainWithInstallTreeRecovery() {
25
+ let runCli;
26
+ const { registerCommandsWithRecovery } = await import("./startup-registration.js");
27
+ try {
28
+ const result = await registerCommandsWithRecovery({
29
+ register: async () => {
30
+ runCli = (await import("./main.js")).runCli;
31
+ },
32
+ argv: process.argv,
33
+ env: process.env,
34
+ entryPath: fileURLToPath(import.meta.url),
35
+ stderr: process.stderr,
36
+ });
37
+ if (result.reexecStatus !== undefined) {
38
+ process.exitCode = result.reexecStatus;
39
+ return;
40
+ }
41
+ if (!runCli)
42
+ throw new Error("HQ CLI startup did not load its command runner");
43
+ await runCli();
44
+ }
45
+ catch (error) {
46
+ // main.js may be the module that is missing, so the global rejection
47
+ // reporter cannot import its usual handler to print the recovery remedy.
48
+ const installTree = await import("./utils/install-tree-torn.js");
49
+ if (installTree.isInstallTreeTornError(error)) {
50
+ process.stderr.write(`${installTree.installTreeTornStderrLine(error)}\n`);
51
+ const sentry = await import("./sentry.js");
52
+ sentry.initSentry();
53
+ sentry.Sentry.captureException(error, installTree.installTreeTornCaptureContext(error));
54
+ sentry.finishSentrySession();
55
+ await sentry.Sentry.flush(2_000);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ throw error;
60
+ }
61
+ }
23
62
  function dispatchCliEntry() {
24
63
  const versionRequest = isVersionRequest(process.argv);
25
64
  const helpRequest = !versionRequest && isCommandHelpRequest(process.argv);
@@ -51,8 +90,7 @@ function dispatchCliEntry() {
51
90
  await runCommandHelp(process.argv);
52
91
  return;
53
92
  }
54
- const { runCli } = await import("./main.js");
55
- await runCli();
93
+ await runMainWithInstallTreeRecovery();
56
94
  });
57
95
  }
58
96
  else if (parsedFastCore?.entry.kind === "native" &&
@@ -73,8 +111,7 @@ function dispatchCliEntry() {
73
111
  await runFastCore(process.argv, parsedFastCore);
74
112
  }
75
113
  else {
76
- const { runCli } = await import("./main.js");
77
- await runCli();
114
+ await runMainWithInstallTreeRecovery();
78
115
  }
79
116
  });
80
117
  }
@@ -88,8 +125,7 @@ function dispatchCliEntry() {
88
125
  return runSecretsEnvFastPath(request);
89
126
  },
90
127
  runCurrent: async () => {
91
- const { runCli } = await import("./main.js");
92
- await runCli();
128
+ await runMainWithInstallTreeRecovery();
93
129
  },
94
130
  });
95
131
  });
@@ -111,7 +147,7 @@ function dispatchCliEntry() {
111
147
  // successful command into exit 13 when that teardown promise remains pending.
112
148
  // A floated rejection here no longer exits 0 silently: the boundary installed
113
149
  // above turns it into a non-zero exit (HQ-CLI-W).
114
- void import("./main.js").then(({ runCli }) => runCli());
150
+ void runMainWithInstallTreeRecovery();
115
151
  }
116
152
  }
117
153
  function isNpmPackageEntry(entryUrl) {
@@ -147,8 +147,11 @@ const SAFE_SLOW_PHASES = new Set([
147
147
  "child_wait",
148
148
  "probe",
149
149
  "parse",
150
+ "no_phase_recorded",
150
151
  ]);
151
- const DEBUG_CONTEXT_PHASES = new Set(SAFE_SLOW_PHASES);
152
+ // This sentinel describes an absent active-phase record, not a phase a hook
153
+ // measures in phase_timings.
154
+ const DEBUG_CONTEXT_PHASES = new Set([...SAFE_SLOW_PHASES].filter((phase) => phase !== "no_phase_recorded"));
152
155
  /**
153
156
  * The only hook diagnostics that can be attached as metadata. The list omits
154
157
  * command text, prompts, payloads, company names, and repositories. `hook_path`
@@ -278,6 +281,10 @@ function safeSlowPhase(value) {
278
281
  }
279
282
  function resolvedSlowPhase(metadata) {
280
283
  const reported = safeSlowPhase(metadata.slow_phase);
284
+ // The hook explicitly recorded that no phase was active. Do not replace that
285
+ // finding with a different phase inferred from aggregate timing data.
286
+ if (reported === "no_phase_recorded")
287
+ return reported;
281
288
  if (reported !== "unknown" && reported !== "other")
282
289
  return reported;
283
290
  // Which measured master-hook phase consumed the most time when the caller's
@@ -130,4 +130,9 @@ export declare class ControlPlaneDbClient {
130
130
  appId: string;
131
131
  }): Promise<RemoteDestroyAppResponse>;
132
132
  }
133
+ export type ControlPlaneHttpError = Error & {
134
+ status?: number;
135
+ code?: string;
136
+ reason?: string;
137
+ };
133
138
  //# sourceMappingURL=control-plane.d.ts.map
@@ -48,6 +48,8 @@ export class ControlPlaneDbClient {
48
48
  err.status = res.status;
49
49
  if (typeof payload?.code === "string")
50
50
  err.code = payload.code;
51
+ if (typeof payload?.reason === "string")
52
+ err.reason = payload.reason;
51
53
  throw err;
52
54
  }
53
55
  return JSON.parse(text);
@@ -145,12 +147,15 @@ export class ControlPlaneDbClient {
145
147
  function httpError(label, status, text) {
146
148
  let msg = `${label} failed (${status})`;
147
149
  let code;
150
+ let reason;
148
151
  try {
149
152
  const j = JSON.parse(text);
150
153
  if (typeof j.error === "string")
151
154
  msg = j.error;
152
155
  if (typeof j.code === "string")
153
156
  code = j.code;
157
+ if (typeof j.reason === "string")
158
+ reason = j.reason;
154
159
  }
155
160
  catch {
156
161
  /* keep */
@@ -159,6 +164,8 @@ function httpError(label, status, text) {
159
164
  err.status = status;
160
165
  if (code)
161
166
  err.code = code;
167
+ if (reason)
168
+ err.reason = reason;
162
169
  return err;
163
170
  }
164
171
  //# sourceMappingURL=control-plane.js.map