@indigoai-us/hq-cli 5.345.44 → 5.345.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/command-catalog.generated.d.ts +17 -1
  3. package/dist/command-catalog.generated.js +21 -1
  4. package/dist/commands/access.js +6 -4
  5. package/dist/commands/bot.js +1 -1
  6. package/dist/commands/db-destroy.js +5 -4
  7. package/dist/commands/install-global.d.ts +3 -0
  8. package/dist/commands/install-global.js +24 -2
  9. package/dist/commands/lanes-message.js +84 -51
  10. package/dist/commands/lanes-resume.js +44 -0
  11. package/dist/commands/lanes-shared.d.ts +25 -0
  12. package/dist/commands/lanes.d.ts +4 -0
  13. package/dist/commands/lanes.js +253 -15
  14. package/dist/commands/meetings.js +4 -1
  15. package/dist/lib/anywhere-person-setting.d.ts +11 -0
  16. package/dist/lib/anywhere-person-setting.js +19 -0
  17. package/dist/lib/daemon/ops/mcp.d.ts +10 -1
  18. package/dist/lib/daemon/ops/mcp.js +17 -10
  19. package/dist/lib/daemon/run.js +8 -2
  20. package/dist/lib/doctor/checks/sync-runtime.d.ts +2 -0
  21. package/dist/lib/doctor/checks/sync-runtime.js +26 -12
  22. package/dist/lib/flag-snapshot-file.js +33 -25
  23. package/dist/lib/install/claude-targets.js +7 -2
  24. package/dist/lib/install/codex-targets.d.ts +6 -0
  25. package/dist/lib/install/codex-targets.js +25 -0
  26. package/dist/lib/lanes/flag-keys.d.ts +1 -0
  27. package/dist/lib/lanes/flag-keys.js +1 -0
  28. package/dist/lib/lanes/focus-guard.d.ts +65 -0
  29. package/dist/lib/lanes/focus-guard.js +91 -0
  30. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  31. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  32. package/dist/lib/lanes/store.js +29 -0
  33. package/dist/lib/lanes/story-ledger.d.ts +14 -0
  34. package/dist/lib/lanes/story-ledger.js +46 -0
  35. package/dist/lib/lanes/types.d.ts +10 -2
  36. package/dist/lib/lanes/types.js +4 -3
  37. package/dist/utils/hook-trust.d.ts +7 -0
  38. package/dist/utils/hook-trust.js +40 -14
  39. package/dist/utils/package-use-lease.d.ts +1 -0
  40. package/dist/utils/package-use-lease.js +29 -0
  41. package/dist/utils/self-update-backoff.d.ts +17 -0
  42. package/dist/utils/self-update-backoff.js +73 -0
  43. package/dist/utils/self-update.d.ts +7 -0
  44. package/dist/utils/self-update.js +36 -0
  45. package/dist/utils/vault-api.d.ts +33 -1
  46. package/dist/utils/vault-api.js +55 -5
  47. package/dist/utils/version-gate.d.ts +13 -0
  48. package/dist/utils/version-gate.js +35 -5
  49. package/package.json +1 -1
@@ -11,6 +11,8 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import { currentSyncLogFamily, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogGenerationPath, } from "../../../utils/sync-log-sources.js";
13
13
  export const SYNC_RUNTIME_WINDOW_MS = 60 * 60_000;
14
+ /** Ignore timing and restart signals until a newly started receiver has settled. */
15
+ export const SYNC_RUNTIME_RESTART_WARMUP_MS = 20 * 60_000;
14
16
  const LAG_SECONDS = 15 * 60;
15
17
  const LAG_MIN_RECEIPTS = 3;
16
18
  const RESTART_MIN_STARTS = 4;
@@ -62,7 +64,9 @@ function lineTimestamp(line) {
62
64
  /** Classify the last hour of sync log lines into privacy-safe results. */
63
65
  export function analyzeSyncRuntimeLog(lines, options) {
64
66
  const since = options.now - SYNC_RUNTIME_WINDOW_MS;
65
- let slowReceipts = 0;
67
+ const contextSince = since - SYNC_RUNTIME_RESTART_WARMUP_MS;
68
+ const slowReceiptTimes = [];
69
+ const receiverStartTimes = [];
66
70
  let denied = 0;
67
71
  let starts = 0;
68
72
  let deviceLimited = 0;
@@ -70,35 +74,45 @@ export function analyzeSyncRuntimeLog(lines, options) {
70
74
  const time = lineTimestamp(line);
71
75
  if (time === null)
72
76
  continue;
73
- if (!Number.isFinite(time) || time < since || time > options.now)
77
+ if (!Number.isFinite(time) || time < contextSince || time > options.now)
74
78
  continue;
75
79
  const stamp = TIMESTAMP.exec(line);
76
80
  const rest = stamp ? line.slice(stamp[0].length) : line;
77
- if (DEVICE_LIMIT.test(rest))
81
+ if (time >= since && DEVICE_LIMIT.test(rest))
78
82
  deviceLimited += 1;
79
83
  const record = parseRecord(rest);
80
84
  if (!record)
81
85
  continue;
82
86
  const event = record.event;
83
- if (event === "fanout.receive") {
87
+ if (event === "receiver.start") {
88
+ receiverStartTimes.push(time);
89
+ const settledAt = time + SYNC_RUNTIME_RESTART_WARMUP_MS;
90
+ // The lower bound preserves a full hour of settled restarts; the upper
91
+ // bound applies the 20-minute settle rule and excludes only the tail.
92
+ if (settledAt >= since && settledAt <= options.now)
93
+ starts += 1;
94
+ }
95
+ else if (event === "fanout.receive" && time >= since) {
84
96
  const latency = record.latencySeconds;
85
97
  if (typeof latency === "number" && latency > LAG_SECONDS)
86
- slowReceipts += 1;
87
- }
88
- else if (event === "receiver.start") {
89
- starts += 1;
98
+ slowReceiptTimes.push(time);
90
99
  }
91
- if ((record.type === "receiver" || record.type === "error" ||
92
- (typeof event === "string" && event.startsWith("receiver."))) &&
100
+ if (time >= since &&
101
+ (record.type === "receiver" || record.type === "error" ||
102
+ (typeof event === "string" && event.startsWith("receiver."))) &&
93
103
  PERMISSION.test(JSON.stringify(record.err ?? record.message ?? ""))) {
94
104
  denied += 1;
95
105
  }
96
106
  }
107
+ // Startup can temporarily expose queued receive latency and restart events
108
+ // while the receiver is warming its backlog. Only judge receipts outside the
109
+ // warm-up interval following the most recent preceding start.
110
+ const slowReceipts = slowReceiptTimes.filter((time) => !receiverStartTimes.some((start) => start <= time && time - start < SYNC_RUNTIME_RESTART_WARMUP_MS)).length;
97
111
  const conflicts = options.conflictCount ?? 0;
98
112
  const checks = [
99
113
  { id: "realtime-lag", reasonCode: "realtime-lag-stuck", failing: slowReceipts >= LAG_MIN_RECEIPTS, count: slowReceipts, label: "incoming changes delayed over 15 minutes" },
100
114
  { id: "receiver-permission", reasonCode: "receiver-permission-denied", failing: denied > 0, count: denied, label: "cloud permission refusals" },
101
- { id: "runner-restarts", reasonCode: "runner-restart-loop", failing: starts >= RESTART_MIN_STARTS, count: starts, label: "sync runner starts" },
115
+ { id: "runner-restarts", reasonCode: "runner-restart-loop", failing: starts >= RESTART_MIN_STARTS, count: starts, label: "settled sync runner starts" },
102
116
  { id: "device-limit", reasonCode: "device-limit-backoff", failing: deviceLimited > 0, count: deviceLimited, label: "device-limit backoffs" },
103
117
  { id: "conflicts", reasonCode: "repeated-conflicts", failing: conflicts >= CONFLICT_MIN_COUNT, count: conflicts, label: "unresolved conflicts" },
104
118
  ];
@@ -197,7 +211,7 @@ export function defaultSyncRuntimeResults(now, hqHome = path.join(os.homedir(),
197
211
  const logFile = family === null ? paths.desktop : paths[family];
198
212
  let lines;
199
213
  try {
200
- lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS, family === "daemon");
214
+ lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS - SYNC_RUNTIME_RESTART_WARMUP_MS, family === "daemon");
201
215
  }
202
216
  catch {
203
217
  return [
@@ -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
@@ -50,9 +50,14 @@ export function hqSkillsSource(hqRoot) {
50
50
  /** The exact hook command registered for one event (absolute path, quoted). */
51
51
  export function hookCommand(hqRoot, event, target = 'master') {
52
52
  const skipInsideRoot = `if [ -d "\${CLAUDE_PROJECT_DIR:-}" ] && [ "\${CLAUDE_PROJECT_DIR}" -ef ${shellQuote(hqRoot)} ]; then exit 0; fi; cwd=$(pwd -P 2>/dev/null || pwd); while [ -n "$cwd" ]; do if [ -f "$cwd/core/core.yaml" ] && [ -f "$cwd/companies/manifest.yaml" ]; then exit 0; fi; [ "$cwd" = / ] && break; cwd=\${cwd%/*}; [ -n "$cwd" ] || cwd=/; done; `;
53
+ // Many HQ hooks take their root from CLAUDE_PROJECT_DIR (some before HQ_ROOT). In a
54
+ // foreign project that is the user's folder, so they load no policies and write
55
+ // workspace/ state there. Point both at HQ; the payload's cwd still names the
56
+ // folder the session is working in.
57
+ const rootEnv = `HQ_ROOT=${shellQuote(hqRoot)} CLAUDE_PROJECT_DIR=${shellQuote(hqRoot)} `;
53
58
  if (target === 'shim')
54
- return `${skipInsideRoot}exec /bin/sh ${shellQuote(shimPath(hqRoot))} ${shellQuote(event)} --runtime claude`;
55
- return `${skipInsideRoot}BASH_ENV=/dev/null exec bash ${shellQuote(masterHookPath(hqRoot))} ${shellQuote(event)}`;
59
+ return `${skipInsideRoot}${rootEnv}exec /bin/sh ${shellQuote(shimPath(hqRoot))} ${shellQuote(event)} --runtime claude`;
60
+ return `${skipInsideRoot}${rootEnv}BASH_ENV=/dev/null exec bash ${shellQuote(masterHookPath(hqRoot))} ${shellQuote(event)}`;
56
61
  }
57
62
  /** Quote one argument for the POSIX shell used by Claude Code command hooks. */
58
63
  function shellQuote(value) {
@@ -30,6 +30,12 @@ type Doc = Record<string, unknown>;
30
30
  export declare function addCodexHooks(doc: Doc, hqRoot: string, target?: HookTarget): Doc;
31
31
  /** Remove only HQ adapter entries; drop containers this left empty. */
32
32
  export declare function removeCodexHooks(doc: Doc, hqRoot: string): Doc;
33
+ /**
34
+ * Codex trust keys (`<hooks.json>:<snake_event>:<group>:<hook>`) for the HQ
35
+ * entries in a hooks.json document. Foreign entries are left out so install
36
+ * never trusts a hook the user added themselves.
37
+ */
38
+ export declare function codexHookTrustKeys(doc: Doc, hooksJsonPath: string, hqRoot: string): string[];
33
39
  export declare function hasAllCodexHooks(doc: Doc, hqRoot: string, target?: HookTarget): boolean;
34
40
  export type LegacySkillsMirror = {
35
41
  kind: 'absent';
@@ -155,6 +155,31 @@ export function removeCodexHooks(doc, hqRoot) {
155
155
  next.hooks = hooks;
156
156
  return next;
157
157
  }
158
+ /**
159
+ * Codex trust keys (`<hooks.json>:<snake_event>:<group>:<hook>`) for the HQ
160
+ * entries in a hooks.json document. Foreign entries are left out so install
161
+ * never trusts a hook the user added themselves.
162
+ */
163
+ export function codexHookTrustKeys(doc, hooksJsonPath, hqRoot) {
164
+ const hooks = doc.hooks;
165
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
166
+ return [];
167
+ const keys = [];
168
+ for (const [event, value] of Object.entries(hooks)) {
169
+ if (!Array.isArray(value))
170
+ continue;
171
+ const snake = event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
172
+ value.forEach((group, g) => {
173
+ if (!group || !Array.isArray(group.hooks))
174
+ return;
175
+ group.hooks.forEach((entry, h) => {
176
+ if (isAdapterEntry(entry, hqRoot))
177
+ keys.push(`${hooksJsonPath}:${snake}:${g}:${h}`);
178
+ });
179
+ });
180
+ }
181
+ return keys;
182
+ }
158
183
  export function hasAllCodexHooks(doc, hqRoot, target = 'master') {
159
184
  return addCodexHooks(doc, hqRoot, target) === doc;
160
185
  }
@@ -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
  }
@@ -24,6 +24,20 @@ export interface StoryLedgerQuestionRef {
24
24
  project_id: string;
25
25
  story_id: string;
26
26
  }
27
+ export interface ProjectFocusOverride {
28
+ reason: string;
29
+ by_lane: string;
30
+ at: string;
31
+ story: string;
32
+ }
33
+ export declare function readProjectStoryFocusData(hqRoot: string, projectId: string, companySlug?: string): {
34
+ path: string;
35
+ metadata: Record<string, unknown>;
36
+ userStories: Array<Record<string, unknown>>;
37
+ focusOverrides: ProjectFocusOverride[];
38
+ };
39
+ export declare function appendProjectFocusOverride(hqRoot: string, projectId: string, companySlug: string | undefined, override: ProjectFocusOverride): void;
40
+ export declare function removeProjectFocusOverride(hqRoot: string, projectId: string, companySlug: string | undefined, override: ProjectFocusOverride): void;
27
41
  export interface LaneStatusSnapshot {
28
42
  schema_version: "hq-lanes-status.v1";
29
43
  project_id: string;
@@ -11,6 +11,52 @@ const LOCK_RETRY_MS = 10;
11
11
  const STORY_STATES = ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
12
12
  const MANAGER_OWNED_STATES = new Set(["review", "done", "dropped"]);
13
13
  const TERMINAL_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed"]);
14
+ function projectFocusOverrides(value) {
15
+ if (value === undefined)
16
+ return [];
17
+ if (!Array.isArray(value) || value.length > 100) {
18
+ throw new LanesError("project_focus_overrides_invalid", "Project focus_overrides must be an array with at most 100 entries", {});
19
+ }
20
+ return value.map((item, index) => {
21
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
22
+ throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} must be an object`, {});
23
+ }
24
+ const entry = item;
25
+ if (["reason", "by_lane", "at", "story"].some((key) => typeof entry[key] !== "string" || !entry[key].trim())) {
26
+ throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} is incomplete`, {});
27
+ }
28
+ if (!Number.isFinite(Date.parse(entry.at))) {
29
+ throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} has an invalid timestamp`, {});
30
+ }
31
+ return entry;
32
+ });
33
+ }
34
+ export function readProjectStoryFocusData(hqRoot, projectId, companySlug) {
35
+ const ledger = resolveProjectStoryLedger(hqRoot, projectId, companySlug);
36
+ const parsed = readLedger(ledger);
37
+ const metadata = parsed.metadata;
38
+ return {
39
+ path: ledger.path,
40
+ metadata: metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)
41
+ ? metadata
42
+ : {},
43
+ userStories: parsed.userStories,
44
+ focusOverrides: projectFocusOverrides(parsed.focus_overrides),
45
+ };
46
+ }
47
+ export function appendProjectFocusOverride(hqRoot, projectId, companySlug, override) {
48
+ updateLedgerAtomically(resolveProjectStoryLedger(hqRoot, projectId, companySlug), (current) => {
49
+ const existing = projectFocusOverrides(current.focus_overrides);
50
+ return { ...current, focus_overrides: [...existing, override].slice(-100) };
51
+ });
52
+ }
53
+ export function removeProjectFocusOverride(hqRoot, projectId, companySlug, override) {
54
+ updateLedgerAtomically(resolveProjectStoryLedger(hqRoot, projectId, companySlug), (current) => {
55
+ const remaining = projectFocusOverrides(current.focus_overrides).filter((entry) => entry.reason !== override.reason || entry.by_lane !== override.by_lane ||
56
+ entry.at !== override.at || entry.story !== override.story);
57
+ return { ...current, focus_overrides: remaining };
58
+ });
59
+ }
14
60
  export function laneStoryLedgerEnabled(companyUid) {
15
61
  return resolveProcessFlagGate(LANE_STORY_LEDGER_FLAG, { fallback: false }, () => false, companyUid);
16
62
  }
@@ -525,6 +525,13 @@ export interface LaneRecord {
525
525
  story_id: string;
526
526
  /** Explicit reason a lane was created without a project story. */
527
527
  no_story_reason?: string;
528
+ /** Human-authored focus guard overrides used to launch this lane onto a story. */
529
+ focus_overrides?: Array<{
530
+ reason: string;
531
+ by_lane: string;
532
+ at: string;
533
+ story: string;
534
+ }>;
528
535
  /** Last failed story-ledger synchronization, cleared after a successful retry. */
529
536
  ledger_sync_error?: string;
530
537
  /** Pending question ids owned by this lane. */
@@ -758,8 +765,9 @@ export interface LaneRecord {
758
765
  /** Last successful lane_progress mesh delivery, preserved across watcher restarts. */
759
766
  last_progress_event_at?: string;
760
767
  }
761
- /** The v1 fields that must exist on every durable lane record (spec §3). */
762
- export declare const REQUIRED_LANE_RECORD_FIELDS: readonly ["schema_version", "lane_id", "company", "project_id", "story_id", "depth", "senior", "senior_history", "state", "priority", "provider", "host", "version", "created_at", "updated_at", "attribution_history"];
768
+ /** The v1 fields that must exist on every durable lane record (spec §3).
769
+ * `company` is normalized from absence for pre-attribution legacy records. */
770
+ export declare const REQUIRED_LANE_RECORD_FIELDS: readonly ["schema_version", "lane_id", "project_id", "story_id", "depth", "senior", "senior_history", "state", "priority", "provider", "host", "version", "created_at", "updated_at", "attribution_history"];
763
771
  export declare const LANE_WRITERS: readonly ["creator", "command", "watcher", "reconcile", "spawn", "story-ledger"];
764
772
  export type LaneWriter = (typeof LANE_WRITERS)[number];
765
773
  /** Field ownership at the durable-record mutation boundary (spec §10.1). */
@@ -215,9 +215,10 @@ export function parseSeniorKind(value) {
215
215
  return value;
216
216
  throw new LanesError("unknown_senior_kind", `Unknown senior kind ${JSON.stringify(value)}. Accepted: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value });
217
217
  }
218
- /** The v1 fields that must exist on every durable lane record (spec §3). */
218
+ /** The v1 fields that must exist on every durable lane record (spec §3).
219
+ * `company` is normalized from absence for pre-attribution legacy records. */
219
220
  export const REQUIRED_LANE_RECORD_FIELDS = [
220
- "schema_version", "lane_id", "company", "project_id", "story_id", "depth",
221
+ "schema_version", "lane_id", "project_id", "story_id", "depth",
221
222
  "senior", "senior_history", "state", "priority", "provider", "host",
222
223
  "version", "created_at", "updated_at", "attribution_history",
223
224
  ];
@@ -225,7 +226,7 @@ export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spaw
225
226
  /** Field ownership at the durable-record mutation boundary (spec §10.1). */
226
227
  export const LANE_FIELD_WRITERS = {
227
228
  schema_version: ["creator"], lane_id: ["creator"], written_by_version: [], owner_written_by_version: [], company: ["creator", "command"], company_history: ["command"],
228
- project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
229
+ project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], focus_overrides: ["command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
229
230
  depth: ["command"], senior: ["command"], senior_history: ["command"],
230
231
  hire_grant: ["creator", "command"],
231
232
  released_hire_grant: ["command"],
@@ -53,6 +53,13 @@ export interface RuntimeHookTrustResult {
53
53
  export declare function createCodexAppServerClient(cwd: string, executable?: string, args?: string[]): Promise<CodexRpcClient>;
54
54
  /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
55
55
  export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
56
+ /**
57
+ * Trust the user-scope hooks `hq install --global --runtime codex` registered
58
+ * in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
59
+ * Codex skips untrusted hooks, so without this HQ stays inert in every session
60
+ * outside the HQ folder. Other hooks in the same file are never trusted here.
61
+ */
62
+ export declare function trustCodexUserHooks(cwd: string, keys: readonly string[], deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
56
63
  /**
57
64
  * Mark this HQ root as a trusted Claude Code workspace.
58
65
  *