@indigoai-us/hq-cli 5.345.44 → 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 (35) hide show
  1. package/CHANGELOG.md +9 -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/lanes-message.js +84 -51
  8. package/dist/commands/lanes-resume.js +44 -0
  9. package/dist/commands/lanes-shared.d.ts +25 -0
  10. package/dist/commands/lanes.d.ts +4 -0
  11. package/dist/commands/lanes.js +213 -4
  12. package/dist/commands/meetings.js +4 -1
  13. package/dist/lib/flag-snapshot-file.js +33 -25
  14. package/dist/lib/lanes/flag-keys.d.ts +1 -0
  15. package/dist/lib/lanes/flag-keys.js +1 -0
  16. package/dist/lib/lanes/focus-guard.d.ts +65 -0
  17. package/dist/lib/lanes/focus-guard.js +91 -0
  18. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  19. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  20. package/dist/lib/lanes/store.js +29 -0
  21. package/dist/lib/lanes/story-ledger.d.ts +14 -0
  22. package/dist/lib/lanes/story-ledger.js +46 -0
  23. package/dist/lib/lanes/types.d.ts +10 -2
  24. package/dist/lib/lanes/types.js +4 -3
  25. package/dist/utils/package-use-lease.d.ts +1 -0
  26. package/dist/utils/package-use-lease.js +29 -0
  27. package/dist/utils/self-update-backoff.d.ts +17 -0
  28. package/dist/utils/self-update-backoff.js +73 -0
  29. package/dist/utils/self-update.d.ts +7 -0
  30. package/dist/utils/self-update.js +36 -0
  31. package/dist/utils/vault-api.d.ts +33 -1
  32. package/dist/utils/vault-api.js +55 -5
  33. package/dist/utils/version-gate.d.ts +13 -0
  34. package/dist/utils/version-gate.js +35 -5
  35. package/package.json +1 -1
@@ -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_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";
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,
@@ -5964,8 +6160,15 @@ async function printGrantResult(ctx, hqRoot, updated, hierarchy) {
5964
6160
  * next `set` or an explicit resume `--effort`/`--worker`.
5965
6161
  */
5966
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
+ }
5967
6170
  if (opts.model === undefined && opts.effort === undefined) {
5968
- 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 });
5969
6172
  }
5970
6173
  const reason = opts.reason?.trim();
5971
6174
  if (opts.reason !== undefined && !reason) {
@@ -7579,7 +7782,7 @@ async function repairCompanyOnOwner(ctx, laneId, opts, emitResult = true) {
7579
7782
  if (current.company !== null) {
7580
7783
  if (sameLaneCompany(current.company, company))
7581
7784
  return current;
7582
- 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 });
7583
7786
  }
7584
7787
  return {
7585
7788
  ...current,
@@ -8885,6 +9088,7 @@ export function registerLanesCommand(parent, deps = {}) {
8885
9088
  casUpdateLane,
8886
9089
  dispatchOutpostLaneCommand,
8887
9090
  dispatchPrimaryLaneMessage,
9091
+ focusGuardAssessment,
8888
9092
  drainHookStatusFor,
8889
9093
  laneIsHostedHere,
8890
9094
  laneWithRecoveredProviderSession,
@@ -8916,6 +9120,7 @@ export function registerLanesCommand(parent, deps = {}) {
8916
9120
  automaticBalanceMoveHistoryReason,
8917
9121
  authorizeFailedResume,
8918
9122
  authorizeResumeProfileRepin,
9123
+ focusGuardAssessment,
8919
9124
  casUpdateLane,
8920
9125
  dispatchOutpostLaneCommand: async (commandCtx, lane, action, options, behavior) => {
8921
9126
  await dispatchOutpostLaneCommand(commandCtx, lane, action, options, behavior);
@@ -9031,6 +9236,7 @@ export function registerLanesCommand(parent, deps = {}) {
9031
9236
  .option("--mesh-company <slug|uid>", "Explicit Work Mesh company for a personal lane; membership is verified")
9032
9237
  .requiredOption("--project <slug>", "HQ project slug")
9033
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")
9034
9240
  .addOption(noStoryOption)
9035
9241
  .option("--additional-story <id>", "Additional story carried by this lane; repeatable and discouraged when active", collectRepeatable)
9036
9242
  .requiredOption("--brief-file <path>", "Path to the lane brief")
@@ -9093,8 +9299,9 @@ export function registerLanesCommand(parent, deps = {}) {
9093
9299
  }));
9094
9300
  lanes
9095
9301
  .command("set")
9096
- .description("Change the model or effort a lane uses from its next resume")
9302
+ .description("Change lane settings or bind its missing company attribution")
9097
9303
  .argument("<lane-id>", "Lane id")
9304
+ .option("--company <slug>", "Bind missing company attribution to this lane")
9098
9305
  .option("--model <model>", "Model for the lane's next and later turns")
9099
9306
  .option("--effort <level>", "Effort for the lane's next and later turns")
9100
9307
  .option("--reason <text>", "Why the settings changed; recorded in settings_history")
@@ -9305,6 +9512,7 @@ export function registerLanesCommand(parent, deps = {}) {
9305
9512
  .option("--text-file <path>", "Read message text from a file")
9306
9513
  .option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
9307
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")
9308
9516
  .option("--account <id>", "Move the lane's session to this account of its provider before the message resumes it")
9309
9517
  .option("--model <id>", "Model for the moved lane (with --account only)")
9310
9518
  .option("--effort <level>", "Reasoning effort for the resumed provider turn")
@@ -9361,6 +9569,7 @@ export function registerLanesCommand(parent, deps = {}) {
9361
9569
  .option("--reason <text>", "Required explanation for resuming from failed")
9362
9570
  .option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
9363
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")
9364
9573
  .option("--account <id>", "Move the lane's session to this account of its provider before resuming")
9365
9574
  .option("--model <id>", "Model for the moved lane (with --account only)")
9366
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",
@@ -78,29 +78,38 @@ function isPermissionFailure(error) {
78
78
  const code = error?.code;
79
79
  return code === "EACCES" || code === "EPERM";
80
80
  }
81
- function fallbackSnapshotPath(companyUid, create) {
82
- if (typeof process.getuid !== "function")
81
+ function fallbackSnapshotPath(companyUid, create, env = process.env) {
82
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
83
+ const root = uid === undefined
84
+ ? env.LOCALAPPDATA?.trim()
85
+ : path.join(os.tmpdir(), `hq-cli-flag-snapshots-${uid}`);
86
+ if (!root)
83
87
  return undefined;
84
- const uid = process.getuid();
85
- const root = path.join(os.tmpdir(), `hq-cli-flag-snapshots-${uid}`);
86
- const directories = [root, path.join(root, "hq"), path.join(root, "hq", "flags")];
88
+ const fallbackRoot = uid === undefined ? path.join(root, "hq-cli", "flag-snapshots") : root;
89
+ const directories = uid === undefined
90
+ ? [path.join(root, "hq-cli"), fallbackRoot]
91
+ : [fallbackRoot, path.join(fallbackRoot, "hq"), path.join(fallbackRoot, "hq", "flags")];
87
92
  for (const directory of directories) {
88
93
  try {
89
94
  if (create)
90
95
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
91
96
  const stat = fs.lstatSync(directory);
92
- if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid)
93
- return undefined;
94
- if (create)
95
- fs.chmodSync(directory, 0o700);
96
- else if ((stat.mode & 0o077) !== 0)
97
+ if (!stat.isDirectory() || stat.isSymbolicLink())
97
98
  return undefined;
99
+ if (uid !== undefined) {
100
+ if (stat.uid !== uid)
101
+ return undefined;
102
+ if (create)
103
+ fs.chmodSync(directory, 0o700);
104
+ else if ((stat.mode & 0o077) !== 0)
105
+ return undefined;
106
+ }
98
107
  }
99
108
  catch {
100
109
  return undefined;
101
110
  }
102
111
  }
103
- return path.join(directories[2], `snapshot-${companyUid}.json`);
112
+ return path.join(directories[directories.length - 1], `snapshot-${companyUid}.json`);
104
113
  }
105
114
  function fallbackPersonSnapshotPath(create) {
106
115
  if (typeof process.getuid !== "function")
@@ -275,7 +284,7 @@ export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
275
284
  }
276
285
  const cacheRootKind = env.XDG_CACHE_HOME?.trim() ? "xdg" : "home";
277
286
  const writeFallback = (originalError) => {
278
- const fallbackFile = fallbackSnapshotPath(companyUid, true);
287
+ const fallbackFile = fallbackSnapshotPath(companyUid, true, env);
279
288
  if (!fallbackFile) {
280
289
  if (originalError) {
281
290
  markFallbackFailed(originalError);
@@ -347,21 +356,20 @@ export function readFlagSnapshotFile(companyUid, options = {}) {
347
356
  const file = flagSnapshotPath(companyUid, options.env);
348
357
  if (!file)
349
358
  return null;
350
- if (fallbackCacheUsed) {
351
- const fallback = fallbackSnapshotPath(companyUid, false);
352
- const snapshot = fallback ? parseSnapshotFile(companyUid, fallback, options) : null;
353
- if (snapshot)
354
- return snapshot;
355
- }
356
359
  const primary = parseSnapshotFile(companyUid, file, options);
357
- if (primary)
360
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
361
+ if (primary && !fallbackCacheUsed && uid !== undefined)
358
362
  return primary;
359
- const fallback = fallbackSnapshotPath(companyUid, false);
360
- if (!fallback)
361
- return null;
362
- const snapshot = parseSnapshotFile(companyUid, fallback, options);
363
- if (snapshot)
363
+ const fallback = fallbackSnapshotPath(companyUid, false, options.env);
364
+ const fallbackSnapshot = fallback ? parseSnapshotFile(companyUid, fallback, options) : null;
365
+ // A process can restart after a denied Windows rename wrote a newer snapshot
366
+ // to LOCALAPPDATA. Compare both valid stores so an older primary does not
367
+ // mask that successful fallback write. Equal timestamps keep the primary.
368
+ if (fallbackSnapshot
369
+ && (!primary || Date.parse(fallbackSnapshot.fetched_at) > Date.parse(primary.fetched_at))) {
364
370
  fallbackCacheUsed = true;
365
- return snapshot;
371
+ return fallbackSnapshot;
372
+ }
373
+ return primary;
366
374
  }
367
375
  //# sourceMappingURL=flag-snapshot-file.js.map
@@ -1,4 +1,5 @@
1
1
  /** hq-flags keys used by lane command behavior. */
2
+ export declare const LANE_FOCUS_GUARD_FLAG = "lanes-focus-guard";
2
3
  export declare const LANE_FOCUS_DASHBOARD_FLAG = "lanes-focus-dashboard";
3
4
  export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
4
5
  /** Enables same-provider automatic lane recovery after provider quota exhaustion. */
@@ -1,4 +1,5 @@
1
1
  /** hq-flags keys used by lane command behavior. */
2
+ export const LANE_FOCUS_GUARD_FLAG = "lanes-focus-guard";
2
3
  export const LANE_FOCUS_DASHBOARD_FLAG = "lanes-focus-dashboard";
3
4
  export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
4
5
  /** Enables same-provider automatic lane recovery after provider quota exhaustion. */
@@ -0,0 +1,65 @@
1
+ import { LanesError } from "./errors.js";
2
+ export declare const FOCUS_GUARD_MAX_PR_AGE_MS: number;
3
+ export interface FocusGuardStory {
4
+ id: string;
5
+ focus_id?: unknown;
6
+ kpi?: unknown;
7
+ state?: unknown;
8
+ status?: unknown;
9
+ lane?: unknown;
10
+ lanes?: unknown;
11
+ }
12
+ export interface FocusGuardPullRequest {
13
+ number: number;
14
+ created_at: string;
15
+ repository?: string;
16
+ }
17
+ export interface FocusGuardLane {
18
+ lane_id: string;
19
+ state: string;
20
+ }
21
+ export type FocusGuardReason = {
22
+ code: "story_missing_focus";
23
+ story_id: string;
24
+ } | {
25
+ code: "open_pr_over_48h";
26
+ number: number;
27
+ age_hours: number;
28
+ repository?: string;
29
+ } | {
30
+ code: "stale_story";
31
+ story_id: string;
32
+ lane_id: string | null;
33
+ } | {
34
+ code: "pr_lookup_failed";
35
+ };
36
+ export interface FocusGuardOverride {
37
+ reason: string;
38
+ by_lane: string;
39
+ at: string;
40
+ story: string;
41
+ }
42
+ export declare function appendLaneFocusOverride<T extends {
43
+ focus_overrides?: FocusGuardOverride[];
44
+ }>(lane: T, override: FocusGuardOverride): T & {
45
+ focus_overrides: FocusGuardOverride[];
46
+ };
47
+ export declare function evaluateFocusGuard(input: {
48
+ storyIds: readonly string[];
49
+ stories: readonly FocusGuardStory[];
50
+ lanes: readonly FocusGuardLane[];
51
+ pullRequests?: readonly FocusGuardPullRequest[];
52
+ pullRequestLookupFailed?: boolean;
53
+ now: Date;
54
+ checkTeamBlockers: boolean;
55
+ }): FocusGuardReason[];
56
+ export declare function evaluateFocusGuardIfEnabled(enabled: boolean, input: Parameters<typeof evaluateFocusGuard>[0]): FocusGuardReason[];
57
+ export declare function assertFocusGuardAllows(enabled: boolean, input: Parameters<typeof evaluateFocusGuard>[0], overrideReason?: string): FocusGuardReason[];
58
+ export declare function focusGuardRefusal(reasons: readonly FocusGuardReason[]): LanesError;
59
+ export declare function makeFocusOverride(input: {
60
+ reason?: string;
61
+ laneId: string;
62
+ at: string;
63
+ storyId: string;
64
+ }): FocusGuardOverride | undefined;
65
+ //# sourceMappingURL=focus-guard.d.ts.map
@@ -0,0 +1,91 @@
1
+ import { LanesError } from "./errors.js";
2
+ import { isTerminalLaneState } from "./types.js";
3
+ export const FOCUS_GUARD_MAX_PR_AGE_MS = 48 * 60 * 60 * 1000;
4
+ export function appendLaneFocusOverride(lane, override) {
5
+ return { ...lane, focus_overrides: [...(lane.focus_overrides ?? []), override].slice(-100) };
6
+ }
7
+ function nonempty(value) {
8
+ return typeof value === "string" && value.trim().length > 0;
9
+ }
10
+ function storyLaneIds(story) {
11
+ const candidates = [story.lane, ...(Array.isArray(story.lanes) ? story.lanes : [])];
12
+ return [...new Set(candidates.flatMap((item) => {
13
+ if (typeof item === "string" && item.trim())
14
+ return [item.trim()];
15
+ if (item !== null && typeof item === "object" && typeof item.lane === "string") {
16
+ const lane = item.lane.trim();
17
+ return lane ? [lane] : [];
18
+ }
19
+ if (item !== null && typeof item === "object" && typeof item.lane_id === "string") {
20
+ const laneId = item.lane_id.trim();
21
+ return laneId ? [laneId] : [];
22
+ }
23
+ return [];
24
+ }))];
25
+ }
26
+ export function evaluateFocusGuard(input) {
27
+ const reasons = [];
28
+ const storyById = new Map(input.stories.map((story) => [story.id.trim(), story]));
29
+ for (const id of input.storyIds) {
30
+ const story = storyById.get(id);
31
+ if (!story || (!nonempty(story.focus_id) && !nonempty(story.kpi))) {
32
+ reasons.push({ code: "story_missing_focus", story_id: id });
33
+ }
34
+ }
35
+ if (!input.checkTeamBlockers)
36
+ return reasons;
37
+ if (input.pullRequestLookupFailed) {
38
+ reasons.push({ code: "pr_lookup_failed" });
39
+ }
40
+ else {
41
+ for (const pr of input.pullRequests ?? []) {
42
+ const ageMs = input.now.getTime() - Date.parse(pr.created_at);
43
+ if (Number.isFinite(ageMs) && ageMs > FOCUS_GUARD_MAX_PR_AGE_MS) {
44
+ reasons.push({
45
+ code: "open_pr_over_48h",
46
+ number: pr.number,
47
+ age_hours: Math.floor(ageMs / (60 * 60 * 1000)),
48
+ ...(pr.repository === undefined ? {} : { repository: pr.repository }),
49
+ });
50
+ }
51
+ }
52
+ }
53
+ const liveLaneIds = new Set(input.lanes
54
+ .filter((lane) => !isTerminalLaneState(lane.state))
55
+ .map((lane) => lane.lane_id));
56
+ for (const story of input.stories) {
57
+ const storyStatus = typeof story.state === "string" ? story.state : story.status;
58
+ if (storyStatus !== "in_progress")
59
+ continue;
60
+ const assigned = storyLaneIds(story);
61
+ if (assigned.length === 0 || assigned.every((laneId) => !liveLaneIds.has(laneId))) {
62
+ reasons.push({ code: "stale_story", story_id: story.id, lane_id: assigned[0] ?? null });
63
+ }
64
+ }
65
+ return reasons;
66
+ }
67
+ export function evaluateFocusGuardIfEnabled(enabled, input) {
68
+ return enabled ? evaluateFocusGuard(input) : [];
69
+ }
70
+ export function assertFocusGuardAllows(enabled, input, overrideReason) {
71
+ const reasons = evaluateFocusGuardIfEnabled(enabled, input);
72
+ if (enabled && reasons.length > 0 && overrideReason === undefined)
73
+ throw focusGuardRefusal(reasons);
74
+ return reasons;
75
+ }
76
+ export function focusGuardRefusal(reasons) {
77
+ return new LanesError("focus_guard_refused", `Lane launch refused by focus guard: ${reasons.map((reason) => reason.code).join(", ")}`, { reasons });
78
+ }
79
+ export function makeFocusOverride(input) {
80
+ if (input.reason === undefined)
81
+ return undefined;
82
+ const reason = input.reason.trim();
83
+ if (!reason) {
84
+ throw new LanesError("focus_override_reason_required", "--focus-override requires a non-empty reason", {
85
+ lane_id: input.laneId,
86
+ story_id: input.storyId,
87
+ });
88
+ }
89
+ return { reason, by_lane: input.laneId, at: input.at, story: input.storyId };
90
+ }
91
+ //# sourceMappingURL=focus-guard.js.map
@@ -33,6 +33,7 @@ export interface OutpostLaneCommand {
33
33
  text?: string;
34
34
  project?: string;
35
35
  story?: string;
36
+ focusOverride?: string;
36
37
  account?: string;
37
38
  model?: string;
38
39
  effort?: string;
@@ -199,6 +199,7 @@ export function buildOutpostLaneCommand(input) {
199
199
  ["--text", input.text],
200
200
  ["--project", input.project],
201
201
  ["--story", input.story],
202
+ ["--focus-override", input.focusOverride],
202
203
  ["--account", input.account],
203
204
  ["--model", input.model],
204
205
  ["--effort", input.effort],
@@ -392,6 +392,10 @@ export function parseLaneRecord(raw, filePath) {
392
392
  path: filePath,
393
393
  });
394
394
  }
395
+ // Company attribution was added after lane records already existed on disk.
396
+ // Treat its historical absence as the same unbound state as an explicit null.
397
+ if (raw.company === undefined)
398
+ raw.company = null;
395
399
  for (const field of REQUIRED_LANE_RECORD_FIELDS) {
396
400
  if (!(field in raw)) {
397
401
  throw new LaneSchemaError(`Missing required lane record field ${field}`, { path: filePath, field });
@@ -423,6 +427,31 @@ export function parseLaneRecord(raw, filePath) {
423
427
  requireInt(raw.version, "version");
424
428
  requireInt(raw.depth, "depth");
425
429
  requireInt(raw.priority, "priority");
430
+ if (raw.focus_overrides !== undefined) {
431
+ if (!Array.isArray(raw.focus_overrides) || raw.focus_overrides.length > 100) {
432
+ throw new LaneSchemaError("focus_overrides must be an array with at most 100 entries", {
433
+ path: filePath,
434
+ field: "focus_overrides",
435
+ });
436
+ }
437
+ for (const [index, entry] of raw.focus_overrides.entries()) {
438
+ if (!isPlainObject(entry)) {
439
+ throw new LaneSchemaError("focus_overrides entries must be objects", {
440
+ path: filePath,
441
+ field: `focus_overrides[${index}]`,
442
+ });
443
+ }
444
+ for (const field of ["reason", "by_lane", "story"]) {
445
+ if (typeof entry[field] !== "string" || !entry[field].trim()) {
446
+ throw new LaneSchemaError(`focus_overrides[${index}].${field} must be non-empty`, {
447
+ path: filePath,
448
+ field: `focus_overrides[${index}].${field}`,
449
+ });
450
+ }
451
+ }
452
+ requireTimestamp(entry.at, `focus_overrides[${index}].at`, filePath);
453
+ }
454
+ }
426
455
  if (raw.ended_at !== undefined) {
427
456
  requireTimestamp(raw.ended_at, "ended_at", filePath);
428
457
  }