@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.
- package/CHANGELOG.md +9 -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/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 +213 -4
- package/dist/commands/meetings.js +4 -1
- package/dist/lib/flag-snapshot-file.js +33 -25
- 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/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
package/dist/commands/lanes.js
CHANGED
|
@@ -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 {
|
|
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", `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
}
|