@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.
- package/CHANGELOG.md +17 -0
- package/dist/command-catalog.generated.d.ts +17 -1
- package/dist/command-catalog.generated.js +21 -1
- package/dist/commands/access.js +6 -4
- package/dist/commands/bot.js +1 -1
- package/dist/commands/db-destroy.js +5 -4
- package/dist/commands/install-global.d.ts +3 -0
- package/dist/commands/install-global.js +24 -2
- package/dist/commands/lanes-message.js +84 -51
- package/dist/commands/lanes-resume.js +44 -0
- package/dist/commands/lanes-shared.d.ts +25 -0
- package/dist/commands/lanes.d.ts +4 -0
- package/dist/commands/lanes.js +253 -15
- package/dist/commands/meetings.js +4 -1
- package/dist/lib/anywhere-person-setting.d.ts +11 -0
- package/dist/lib/anywhere-person-setting.js +19 -0
- package/dist/lib/daemon/ops/mcp.d.ts +10 -1
- package/dist/lib/daemon/ops/mcp.js +17 -10
- package/dist/lib/daemon/run.js +8 -2
- package/dist/lib/doctor/checks/sync-runtime.d.ts +2 -0
- package/dist/lib/doctor/checks/sync-runtime.js +26 -12
- package/dist/lib/flag-snapshot-file.js +33 -25
- package/dist/lib/install/claude-targets.js +7 -2
- package/dist/lib/install/codex-targets.d.ts +6 -0
- package/dist/lib/install/codex-targets.js +25 -0
- package/dist/lib/lanes/flag-keys.d.ts +1 -0
- package/dist/lib/lanes/flag-keys.js +1 -0
- package/dist/lib/lanes/focus-guard.d.ts +65 -0
- package/dist/lib/lanes/focus-guard.js +91 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
- package/dist/lib/lanes/outpost-dispatch.js +1 -0
- package/dist/lib/lanes/store.js +29 -0
- package/dist/lib/lanes/story-ledger.d.ts +14 -0
- package/dist/lib/lanes/story-ledger.js +46 -0
- package/dist/lib/lanes/types.d.ts +10 -2
- package/dist/lib/lanes/types.js +4 -3
- package/dist/utils/hook-trust.d.ts +7 -0
- package/dist/utils/hook-trust.js +40 -14
- package/dist/utils/package-use-lease.d.ts +1 -0
- package/dist/utils/package-use-lease.js +29 -0
- package/dist/utils/self-update-backoff.d.ts +17 -0
- package/dist/utils/self-update-backoff.js +73 -0
- package/dist/utils/self-update.d.ts +7 -0
- package/dist/utils/self-update.js +36 -0
- package/dist/utils/vault-api.d.ts +33 -1
- package/dist/utils/vault-api.js +55 -5
- package/dist/utils/version-gate.d.ts +13 -0
- package/dist/utils/version-gate.js +35 -5
- 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
|
-
|
|
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 <
|
|
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 === "
|
|
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
|
-
|
|
87
|
-
}
|
|
88
|
-
else if (event === "receiver.start") {
|
|
89
|
-
starts += 1;
|
|
98
|
+
slowReceiptTimes.push(time);
|
|
90
99
|
}
|
|
91
|
-
if (
|
|
92
|
-
(
|
|
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
|
-
|
|
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
|
|
85
|
-
const
|
|
86
|
-
|
|
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()
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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],
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -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
|
-
|
|
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). */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -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", "
|
|
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
|
*
|