@indigoai-us/hq-cli 5.188.1 → 5.190.0

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `hq lanes` — worker lane management and decision queue (spec §5–§7, §10–§11).
3
3
  *
4
- * Existing commands print JSON on stdout and take no interactive input, except
4
+ * Existing commands print JSON on stdout by default and take no interactive input, except
5
5
  * `accounts login`, which hands the terminal to the vendor's own sign-in.
6
6
  * `watch` and the monitor reminder are the line-oriented text surfaces.
7
7
  */
@@ -10,6 +10,7 @@ import { randomBytes } from "node:crypto";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
+ import { Option } from "commander";
13
14
  import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
14
15
  import { ensureCognitoToken } from "../utils/cognito-session.js";
15
16
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
@@ -20,7 +21,7 @@ import { createOutpostHireGrantContext, parseOutpostHireGrantContext, sameHireGr
20
21
  import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
21
22
  export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
22
23
  export { LANE_HIRE_GRANTS_FLAG };
23
- import { buildOutpostCapacityCommand, buildOutpostBalanceMoveCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
24
+ import { buildOutpostCapacityCommand, buildOutpostBalanceMoveCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, outpostRejectedEffortOption, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
24
25
  import { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "../lib/lanes/host-capacity.js";
25
26
  import { BALANCE_MOVABLE_STATES, CLAUDE_ACCOUNT_BALANCE_FLAG, accountLoadsForReport, assertBalanceApplyEnabled, planClaudeBalanceMoves, selectClaudeAccount, } from "../lib/lanes/claude-account-balance.js";
26
27
  import { parseUserYaml } from "../utils/user-yaml-error.js";
@@ -29,12 +30,12 @@ import { vaultApiFetch } from "../utils/vault-api.js";
29
30
  import { openMeshTransport } from "../lib/mesh/client.js";
30
31
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
31
32
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
32
- import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
33
+ import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
33
34
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
34
35
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
35
36
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
36
37
  import { providerCompactAfterMs } from "../lib/lanes/config.js";
37
- import { parseCliEffort, resolveLaneEffort } from "../lib/lanes/effort.js";
38
+ import { parseCliEffort, resolveLaneEffort, } from "../lib/lanes/effort.js";
38
39
  import { LanesError } from "../lib/lanes/errors.js";
39
40
  import { accountQuotaView, loadQuotaState, quotaBlockFor } from "../lib/lanes/quota.js";
40
41
  import { SESSION_MOVE_REASON } from "../lib/lanes/move-account.js";
@@ -257,6 +258,7 @@ function resolveDeps(deps = {}) {
257
258
  fetchText: deps.fetchText ?? defaultFetchText,
258
259
  lanePlacementEnabled: deps.lanePlacementEnabled ?? defaultLanePlacementEnabled,
259
260
  laneHireGrantsEnabled: deps.laneHireGrantsEnabled ?? defaultLaneHireGrantsEnabled,
261
+ storyLedgerEnabled: deps.storyLedgerEnabled ?? laneStoryLedgerEnabled,
260
262
  isSsmOwnerDispatch: deps.isSsmOwnerDispatch ?? (() => isSsmRunCommandProcess()),
261
263
  withGrantHireLocks: deps.withGrantHireLocks ?? withLaneHireLocks,
262
264
  laneClaudeAccountBalanceEnabled: deps.laneClaudeAccountBalanceEnabled ?? defaultLaneClaudeAccountBalanceEnabled,
@@ -2112,18 +2114,28 @@ async function runCreate(ctx, opts) {
2112
2114
  const meshCompanyFlag = opts.meshCompany?.trim();
2113
2115
  const project = opts.project?.trim();
2114
2116
  const story = opts.story?.trim();
2117
+ const noStoryReason = opts.noStory?.trim();
2115
2118
  const additionalStories = (opts.additionalStory ?? []).map((value) => value.trim());
2116
2119
  const briefFile = opts.briefFile?.trim();
2117
2120
  if (!project) {
2118
2121
  throw new LanesError("missing_project", "--project is required", {});
2119
2122
  }
2120
- if (!story) {
2123
+ if (opts.story !== undefined && opts.noStory !== undefined) {
2124
+ throw new LanesError("story_selection_conflict", "Pass either --story or --no-story, not both", {});
2125
+ }
2126
+ if (opts.noStory !== undefined && !noStoryReason) {
2127
+ throw new LanesError("missing_no_story_reason", "--no-story requires a non-empty reason", {});
2128
+ }
2129
+ if (!story && !noStoryReason) {
2121
2130
  throw new LanesError("missing_story", "--story is required", {});
2122
2131
  }
2132
+ if (noStoryReason && additionalStories.length > 0) {
2133
+ throw new LanesError("story_selection_conflict", "--additional-story requires --story and cannot be used with --no-story", {});
2134
+ }
2123
2135
  if (additionalStories.some((value) => value.length === 0)) {
2124
2136
  throw new LanesError("missing_story", "--additional-story values must be non-empty", {});
2125
2137
  }
2126
- const storyIds = [story, ...additionalStories];
2138
+ const storyIds = story ? [story, ...additionalStories] : [];
2127
2139
  if (new Set(storyIds).size !== storyIds.length) {
2128
2140
  throw new LanesError("duplicate_story", "A lane cannot carry the same project/story more than once", {
2129
2141
  project_id: project,
@@ -2174,6 +2186,9 @@ async function runCreate(ctx, opts) {
2174
2186
  throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
2175
2187
  }
2176
2188
  // A no-company lane must not call company resolution (openMeshTransport).
2189
+ if (storyIds.length > 0 && ctx.storyLedgerEnabled()) {
2190
+ validateProjectStories(hqRoot, project, storyIds, company?.slug);
2191
+ }
2177
2192
  let meshCompany = company;
2178
2193
  if (meshCompanyFlag) {
2179
2194
  const resolvedMeshCompany = await ctx.resolveCompany(meshCompanyFlag);
@@ -2189,7 +2204,7 @@ async function runCreate(ctx, opts) {
2189
2204
  const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
2190
2205
  assertSameTenant(company, senior, seniorCompany);
2191
2206
  const createStoryWarnings = [];
2192
- if (meshCompany) {
2207
+ if (meshCompany && storyIds.length > 0) {
2193
2208
  const warning = await storyBoardWarning(ctx, meshCompany, project, storyIds);
2194
2209
  if (warning !== undefined)
2195
2210
  createStoryWarnings.push(warning);
@@ -2630,7 +2645,8 @@ async function runCreate(ctx, opts) {
2630
2645
  ? { mesh_company: meshCompany }
2631
2646
  : {}),
2632
2647
  project_id: project,
2633
- story_id: story,
2648
+ story_id: story ?? "",
2649
+ ...(noStoryReason ? { no_story_reason: noStoryReason } : {}),
2634
2650
  stories: storyIds.map((storyId) => ({
2635
2651
  project_id: project,
2636
2652
  story_id: storyId,
@@ -3100,6 +3116,8 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3100
3116
  story: options.story,
3101
3117
  account: options.account,
3102
3118
  model: options.model,
3119
+ effort: options.effort,
3120
+ worker: options.worker,
3103
3121
  hire: options.hire,
3104
3122
  grantContext,
3105
3123
  compact: options.compact,
@@ -3110,6 +3128,18 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3110
3128
  throw new LanesError("outpost_dispatch_failed", `Could not dispatch ${action} for lane ${lane.lane_id} to Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, outpost_id: outpostId });
3111
3129
  }
3112
3130
  const ownerError = ownerErrorFromOutput(dispatched.stdout);
3131
+ if (options.effort !== undefined && outpostRejectedEffortOption(dispatched)) {
3132
+ throw new LanesError("outpost_effort_unsupported", `Outpost ${outpostId} rejected --effort; update hq-cli on that Outpost before changing a lane's effort remotely`, {
3133
+ lane_id: lane.lane_id,
3134
+ outpost_id: outpostId,
3135
+ command_id: dispatched.commandId,
3136
+ effort: options.effort,
3137
+ status: dispatched.status,
3138
+ done: dispatched.done ?? false,
3139
+ exit_code: dispatched.exitCode,
3140
+ owner_error: ownerError,
3141
+ });
3142
+ }
3113
3143
  if (ownerError) {
3114
3144
  throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} rejected the ${action} command: ${ownerError.message}`, {
3115
3145
  lane_id: lane.lane_id,
@@ -3402,6 +3432,9 @@ async function runPickup(ctx, opts) {
3402
3432
  });
3403
3433
  }
3404
3434
  async function runList(ctx, opts) {
3435
+ if (opts.json && opts.human) {
3436
+ throw new LanesError("conflicting_output_format", "Use either --json or --human for hq lanes list", { json: true, human: true });
3437
+ }
3405
3438
  const hqRoot = ctx.hqRoot();
3406
3439
  const state = opts.state ? parseLaneState(opts.state) : undefined;
3407
3440
  const senior = opts.senior ? parseSeniorRef(opts.senior) : undefined;
@@ -3416,9 +3449,11 @@ async function runList(ctx, opts) {
3416
3449
  .filter((lane) => {
3417
3450
  if (state && lane.state !== state)
3418
3451
  return false;
3419
- if ((opts.project || opts.story) &&
3420
- !laneStories(lane).some((story) => (!opts.project || story.project_id === opts.project) &&
3421
- (!opts.story || story.story_id === opts.story))) {
3452
+ const carriesFilter = laneStories(lane).some((story) => (!opts.project || story.project_id === opts.project) &&
3453
+ (!opts.story || story.story_id === opts.story));
3454
+ const matchingNoStory = !opts.story && opts.project !== undefined &&
3455
+ lane.no_story_reason !== undefined && lane.project_id === opts.project;
3456
+ if ((opts.project || opts.story) && !carriesFilter && !matchingNoStory) {
3422
3457
  return false;
3423
3458
  }
3424
3459
  if (company && !recordMatchesCompany(lane, company)) {
@@ -3451,6 +3486,9 @@ async function runList(ctx, opts) {
3451
3486
  return {
3452
3487
  ...lane,
3453
3488
  ...hireByLane.get(lane.lane_id),
3489
+ stories: laneStories(lane),
3490
+ story_ids: laneStories(lane).map((story) => story.story_id),
3491
+ no_story_reason: lane.no_story_reason ?? null,
3454
3492
  ...(remoteQueued
3455
3493
  ? { state: "remote", remote_state: lane.state, queued_reason: undefined }
3456
3494
  : {}),
@@ -3472,12 +3510,74 @@ async function runList(ctx, opts) {
3472
3510
  const host_capacity = ctx.lanePlacementEnabled()
3473
3511
  ? (await loadHostCapacitySnapshots(ctx, hqRoot)).entries
3474
3512
  : undefined;
3475
- printJson({
3513
+ const result = {
3476
3514
  ok: true,
3477
3515
  action: "list",
3478
3516
  lanes,
3479
3517
  ...(host_capacity !== undefined ? { host_capacity } : {}),
3518
+ };
3519
+ if (opts.human)
3520
+ console.log(formatLaneListHuman(lanes));
3521
+ else
3522
+ printJson(result);
3523
+ }
3524
+ function formatLaneListHuman(lanes) {
3525
+ if (lanes.length === 0)
3526
+ return "No lanes.";
3527
+ return lanes.map((lane) => {
3528
+ const ids = lane.story_ids;
3529
+ const storyLabel = ids.length > 0
3530
+ ? "stories: " + ids.join(", ")
3531
+ : "no story: " + (lane.no_story_reason ?? "reason not recorded");
3532
+ return lane.lane_id + " " + lane.state + " " + lane.provider + " " + lane.project_id +
3533
+ " " + storyLabel;
3534
+ }).join("\n");
3535
+ }
3536
+ async function runStatus(ctx, opts) {
3537
+ if (!ctx.storyLedgerEnabled()) {
3538
+ throw new LanesError("story_ledger_disabled", "hq lanes status requires the lanes.story-ledger flag", {
3539
+ flag: "lanes.story-ledger",
3540
+ });
3541
+ }
3542
+ const hqRoot = ctx.hqRoot();
3543
+ const company = opts.company ? await ctx.resolveCompany(opts.company) : undefined;
3544
+ const lanes = loadAllLanes(hqRoot).filter((lane) => lane.company?.slug === company?.slug);
3545
+ const pending = new Map();
3546
+ for (const lane of lanes) {
3547
+ const refs = [];
3548
+ for (const questionId of lane.pending_question_ids ?? []) {
3549
+ const question = readQuestion(hqRoot, questionId);
3550
+ const sameCompany = question.company === null
3551
+ ? lane.company === null
3552
+ : lane.company !== null &&
3553
+ question.company.uid === lane.company.uid &&
3554
+ question.company.slug === lane.company.slug;
3555
+ if (question.lane_id !== lane.lane_id || !sameCompany) {
3556
+ throw new LanesError("story_ledger_question_mismatch", `Pending question ${questionId} does not belong to lane ${lane.lane_id}`, { lane_id: lane.lane_id, question_id: questionId, question_lane_id: question.lane_id });
3557
+ }
3558
+ if (question.status === "pending") {
3559
+ refs.push({
3560
+ question_id: question.question_id,
3561
+ project_id: question.project_id,
3562
+ story_id: question.story_id,
3563
+ });
3564
+ }
3565
+ }
3566
+ pending.set(lane.lane_id, refs);
3567
+ }
3568
+ const snapshot = buildLaneStatusSnapshot({
3569
+ hqRoot,
3570
+ projectId: opts.project,
3571
+ ...(company ? { companySlug: company.slug } : {}),
3572
+ ...(company ? { companyUid: company.uid } : {}),
3573
+ lanes,
3574
+ generatedAt: ctx.now().toISOString(),
3575
+ pendingQuestions: pending,
3480
3576
  });
3577
+ if (opts.json)
3578
+ printJson(snapshot);
3579
+ else
3580
+ console.log(formatLaneStatusHuman(snapshot));
3481
3581
  }
3482
3582
  async function runCapacity(ctx) {
3483
3583
  const hqRoot = ctx.hqRoot();
@@ -4336,34 +4436,43 @@ function validateResumeProfileModel(ctx, lane, profile) {
4336
4436
  env: ctx.env,
4337
4437
  });
4338
4438
  }
4439
+ function applyResumeProfileAndEffort(current, next, input) {
4440
+ if (input.profile !== undefined) {
4441
+ const profile = input.profile.profile;
4442
+ next.worker = input.profile.id;
4443
+ next.worker_path = input.profile.path;
4444
+ next.worker_pack = input.profile.pack;
4445
+ next.worker_profile = profile.id;
4446
+ next.worker_profile_hash = profile.contentHash;
4447
+ next.worker_profile_ignored = ignoredWorkerProfileFields(current.provider);
4448
+ next.worker_policy_delivery = undefined;
4449
+ if (next.model_source !== "cli") {
4450
+ if (profile.meta.provider === current.provider && profile.meta.model !== undefined) {
4451
+ next.model = profile.meta.model;
4452
+ next.model_source = "profile";
4453
+ }
4454
+ else if (profile.meta.provider === current.provider) {
4455
+ next.model = undefined;
4456
+ next.model_source = "none";
4457
+ }
4458
+ }
4459
+ }
4460
+ if (input.effort !== undefined) {
4461
+ next.effort = input.effort.effort;
4462
+ next.effort_source = input.effort.source;
4463
+ }
4464
+ }
4339
4465
  function recordResumeSetup(ctx, lane, input) {
4340
- if (input.profile === undefined && input.meshCompany === undefined)
4466
+ if (input.profile === undefined &&
4467
+ input.effort === undefined &&
4468
+ input.meshCompany === undefined)
4341
4469
  return lane;
4342
4470
  return casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => {
4343
4471
  if (current.version !== lane.version) {
4344
4472
  throw new LanesError("lane_resume_setup_conflict", `Lane ${lane.lane_id} changed while resume settings were prepared; run the resume again`, { lane_id: lane.lane_id, prepared_version: lane.version, version: current.version });
4345
4473
  }
4346
4474
  const next = { ...current };
4347
- if (input.profile !== undefined) {
4348
- const profile = input.profile.profile;
4349
- next.worker = input.profile.id;
4350
- next.worker_path = input.profile.path;
4351
- next.worker_pack = input.profile.pack;
4352
- next.worker_profile = profile.id;
4353
- next.worker_profile_hash = profile.contentHash;
4354
- next.worker_profile_ignored = ignoredWorkerProfileFields(current.provider);
4355
- next.worker_policy_delivery = undefined;
4356
- if (current.model_source !== "cli") {
4357
- if (profile.meta.provider === current.provider && profile.meta.model !== undefined) {
4358
- next.model = profile.meta.model;
4359
- next.model_source = "profile";
4360
- }
4361
- else if (profile.meta.provider === current.provider) {
4362
- next.model = undefined;
4363
- next.model_source = "none";
4364
- }
4365
- }
4366
- }
4475
+ applyResumeProfileAndEffort(current, next, input);
4367
4476
  if (input.meshCompany !== undefined) {
4368
4477
  if (current.company === null)
4369
4478
  next.mesh_company = input.meshCompany;
@@ -4373,6 +4482,40 @@ function recordResumeSetup(ctx, lane, input) {
4373
4482
  return next;
4374
4483
  });
4375
4484
  }
4485
+ /** Resolve an explicit resume override or newly attached profile like create does. */
4486
+ function resolveResumeEffort(ctx, lane, input) {
4487
+ if (input.cliEffort === undefined && input.profile === undefined)
4488
+ return undefined;
4489
+ const config = loadLanesConfig(ctx.hqRoot());
4490
+ const providerConfig = config.providers[lane.provider];
4491
+ const accountId = input.account?.trim() || lane.account;
4492
+ const accountConfig = accountId === undefined
4493
+ ? undefined
4494
+ : providerConfig.accounts[accountId];
4495
+ const profileDefaultsApply = input.profile?.profile.meta.provider === lane.provider;
4496
+ const pinnedModel = lane.model_source === undefined || lane.model_source === "cli"
4497
+ ? lane.model
4498
+ : undefined;
4499
+ const model = resolveLaneModel({
4500
+ cliModel: input.model?.trim() || pinnedModel,
4501
+ profileModel: profileDefaultsApply ? input.profile?.profile.meta.model : undefined,
4502
+ accountModel: accountConfig?.model,
4503
+ providerModel: providerConfig.model,
4504
+ });
4505
+ return resolveLaneEffort({
4506
+ cliEffort: input.cliEffort,
4507
+ profileEffort: profileDefaultsApply ? input.profile?.profile.meta.effort : undefined,
4508
+ accountEffort: accountConfig?.effort,
4509
+ providerEffort: providerConfig.effort,
4510
+ model,
4511
+ });
4512
+ }
4513
+ function recordedResumeEffort(lane) {
4514
+ return {
4515
+ ...(lane.effort !== undefined ? { effort: lane.effort } : {}),
4516
+ source: lane.effort_source ?? "none",
4517
+ };
4518
+ }
4376
4519
  /**
4377
4520
  * US-023: move the lane's provider session to `--account` (same provider)
4378
4521
  * before a resume or a message resume. Every check runs before anything is
@@ -4440,7 +4583,7 @@ async function prepareAccountMove(ctx, lane, input) {
4440
4583
  }).logged_in !== true) {
4441
4584
  throw new LanesError("account_not_logged_in", `Account ${target} for ${lane.provider} is not logged in (no credential file)`, { provider: lane.provider, account: target, lane_id: laneId });
4442
4585
  }
4443
- const profile = input.workerProfile ?? (lane.worker_profile !== undefined
4586
+ const profile = input.workerProfile?.profile ?? (lane.worker_profile !== undefined
4444
4587
  ? loadWorkerProfile(hqRoot, lane.worker_profile)
4445
4588
  : undefined);
4446
4589
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === lane.provider;
@@ -4542,6 +4685,10 @@ async function prepareAccountMove(ctx, lane, input) {
4542
4685
  ? undefined
4543
4686
  : { session_id: sessionId, at, ...input.pending },
4544
4687
  };
4688
+ applyResumeProfileAndEffort(current, next, {
4689
+ ...(input.workerProfile !== undefined ? { profile: input.workerProfile } : {}),
4690
+ ...(input.effort !== undefined ? { effort: input.effort } : {}),
4691
+ });
4545
4692
  if (input.deferTurnStart) {
4546
4693
  next.state = current.state;
4547
4694
  }
@@ -4618,6 +4765,10 @@ async function runMessage(ctx, laneId, opts) {
4618
4765
  return;
4619
4766
  }
4620
4767
  assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
4768
+ const cliEffort = parseCliEffort(lane.provider, opts.effort);
4769
+ if (cliEffort !== undefined && !providerSupportsSessionResume(lane.provider)) {
4770
+ throw new LanesError("effort_resume_unsupported", `Provider ${lane.provider} cannot change effort on a resumed session`, { lane_id: laneId, provider: lane.provider });
4771
+ }
4621
4772
  // Check before transcript recovery, account probing, or message writes.
4622
4773
  // The final state transition repeats this check under the senior lock.
4623
4774
  await withResumeHireAdmission(ctx, lane, () => undefined);
@@ -4649,16 +4800,34 @@ async function runMessage(ctx, laneId, opts) {
4649
4800
  if (opts.worker !== undefined && mechanism === "dropbox") {
4650
4801
  throw new LanesError("worker_profile_requires_resume", `Lane ${laneId} is still running; --worker can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
4651
4802
  }
4803
+ if (cliEffort !== undefined && mechanism === "dropbox") {
4804
+ throw new LanesError("lane_effort_requires_resume", `Lane ${laneId} is still running; --effort can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
4805
+ }
4652
4806
  const resumeProfile = opts.worker !== undefined
4653
4807
  ? resolveResumeProfile(ctx, lane, opts.worker)
4654
4808
  : undefined;
4655
4809
  if (opts.model !== undefined && opts.account === undefined) {
4656
4810
  throw new LanesError("model_requires_account", "--model on hq lanes message is only accepted together with --account", {});
4657
4811
  }
4812
+ const automaticAccountSelection = opts.account === undefined && mechanism === "resume"
4813
+ ? await selectAutomaticClaudeResumeAccount(ctx, lane)
4814
+ : undefined;
4815
+ const resumeEffort = resolveResumeEffort(ctx, lane, {
4816
+ cliEffort,
4817
+ profile: resumeProfile,
4818
+ account: opts.account ?? automaticAccountSelection?.account,
4819
+ model: opts.model,
4820
+ });
4658
4821
  const messageId = mintMessageId();
4659
4822
  const at = ctx.now().toISOString();
4660
4823
  const projectId = opts.project?.trim() || lane.project_id;
4661
4824
  const storyId = opts.story?.trim() || lane.story_id;
4825
+ if (opts.story !== undefined && !opts.story.trim()) {
4826
+ throw new LanesError("missing_story", "--story must be non-empty", {});
4827
+ }
4828
+ if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
4829
+ validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
4830
+ }
4662
4831
  const dropboxMessage = {
4663
4832
  at,
4664
4833
  message_id: messageId,
@@ -4675,7 +4844,8 @@ async function runMessage(ctx, laneId, opts) {
4675
4844
  move = await prepareAccountMove(ctx, lane, {
4676
4845
  account: opts.account,
4677
4846
  cliModel: opts.model?.trim() || undefined,
4678
- ...(resumeProfile !== undefined ? { workerProfile: resumeProfile.profile } : {}),
4847
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
4848
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
4679
4849
  pending: { trigger: "message", text, message_id: messageId, compact: opts.compact !== false },
4680
4850
  });
4681
4851
  lane = move.lane;
@@ -4684,13 +4854,14 @@ async function runMessage(ctx, laneId, opts) {
4684
4854
  }
4685
4855
  }
4686
4856
  else if (mechanism === "resume") {
4687
- const selection = await selectAutomaticClaudeResumeAccount(ctx, lane);
4857
+ const selection = automaticAccountSelection;
4688
4858
  if (selection !== undefined) {
4689
4859
  if (selection.account !== lane.account) {
4690
4860
  move = await prepareAccountMove(ctx, lane, {
4691
4861
  account: selection.account,
4692
4862
  cliModel: undefined,
4693
- ...(resumeProfile !== undefined ? { workerProfile: resumeProfile.profile } : {}),
4863
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
4864
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
4694
4865
  pending: {
4695
4866
  trigger: "message",
4696
4867
  text,
@@ -4708,10 +4879,14 @@ async function runMessage(ctx, laneId, opts) {
4708
4879
  }
4709
4880
  }
4710
4881
  }
4711
- if (resumeProfile !== undefined) {
4712
- if (opts.account === undefined)
4713
- validateResumeProfileModel(ctx, lane, resumeProfile.profile);
4714
- lane = recordResumeSetup(ctx, lane, { profile: resumeProfile });
4882
+ if (resumeProfile !== undefined && opts.account === undefined) {
4883
+ validateResumeProfileModel(ctx, lane, resumeProfile.profile);
4884
+ }
4885
+ if (move === null && (resumeProfile !== undefined || resumeEffort !== undefined)) {
4886
+ lane = recordResumeSetup(ctx, lane, {
4887
+ ...(resumeProfile !== undefined ? { profile: resumeProfile } : {}),
4888
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
4889
+ });
4715
4890
  }
4716
4891
  if (move?.queued) {
4717
4892
  // Parked at the target's cap: reconcile resumes the session there.
@@ -4785,6 +4960,7 @@ async function runMessage(ctx, laneId, opts) {
4785
4960
  }, {
4786
4961
  trigger: { kind: "message", text, message_id: messageId },
4787
4962
  compact: opts.compact !== false,
4963
+ effort: resumeEffort ?? recordedResumeEffort(lane),
4788
4964
  });
4789
4965
  archiveResumeMessage(hqRoot, laneId, dropboxMessage);
4790
4966
  return started;
@@ -4805,6 +4981,7 @@ async function runMessage(ctx, laneId, opts) {
4805
4981
  ...current,
4806
4982
  project_id: projectId,
4807
4983
  story_id: storyId,
4984
+ no_story_reason: storyId ? undefined : current.no_story_reason,
4808
4985
  stories: laneStory(current, projectId, storyId)
4809
4986
  ? laneStories(current)
4810
4987
  : [
@@ -5071,6 +5248,10 @@ async function runResume(ctx, laneId, opts) {
5071
5248
  // slot that its senior has already assigned elsewhere.
5072
5249
  await withResumeHireAdmission(ctx, lane, () => undefined);
5073
5250
  assertLaneRunsHereForOperatorMutation(ctx, lane, "resume");
5251
+ const cliEffort = parseCliEffort(lane.provider, opts.effort);
5252
+ if (cliEffort !== undefined && !providerSupportsSessionResume(lane.provider)) {
5253
+ throw new LanesError("effort_resume_unsupported", `Provider ${lane.provider} cannot change effort on a resumed session`, { lane_id: laneId, provider: lane.provider });
5254
+ }
5074
5255
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
5075
5256
  if (opts.model !== undefined && opts.account === undefined) {
5076
5257
  throw new LanesError("model_requires_account", "--model on hq lanes resume is only accepted together with --account", {});
@@ -5102,6 +5283,12 @@ async function runResume(ctx, laneId, opts) {
5102
5283
  const at = ctx.now().toISOString();
5103
5284
  const projectId = opts.project?.trim() || lane.project_id;
5104
5285
  const storyId = opts.story?.trim() || lane.story_id;
5286
+ if (opts.story !== undefined && !opts.story.trim()) {
5287
+ throw new LanesError("missing_story", "--story must be non-empty", {});
5288
+ }
5289
+ if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
5290
+ validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
5291
+ }
5105
5292
  const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
5106
5293
  const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
5107
5294
  let requestedMeshCompany;
@@ -5127,12 +5314,19 @@ async function runResume(ctx, laneId, opts) {
5127
5314
  const automaticAccountSelection = opts.account === undefined
5128
5315
  ? await selectAutomaticClaudeResumeAccount(ctx, lane)
5129
5316
  : undefined;
5317
+ const resumeEffort = resolveResumeEffort(ctx, lane, {
5318
+ cliEffort,
5319
+ profile: resumeProfile,
5320
+ account: opts.account ?? automaticAccountSelection?.account,
5321
+ model: opts.model,
5322
+ });
5130
5323
  let move = null;
5131
5324
  if (opts.account !== undefined) {
5132
5325
  move = await prepareAccountMove(ctx, lane, {
5133
5326
  account: opts.account,
5134
5327
  cliModel: opts.model?.trim() || undefined,
5135
- ...(resumeProfile !== undefined ? { workerProfile: resumeProfile.profile } : {}),
5328
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
5329
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5136
5330
  pending: {
5137
5331
  trigger: "resume",
5138
5332
  resumed_from: resumedFrom,
@@ -5148,7 +5342,8 @@ async function runResume(ctx, laneId, opts) {
5148
5342
  move = await prepareAccountMove(ctx, lane, {
5149
5343
  account: automaticAccountSelection.account,
5150
5344
  cliModel: undefined,
5151
- ...(resumeProfile !== undefined ? { workerProfile: resumeProfile.profile } : {}),
5345
+ ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
5346
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5152
5347
  pending: {
5153
5348
  trigger: "resume",
5154
5349
  resumed_from: resumedFrom,
@@ -5165,7 +5360,8 @@ async function runResume(ctx, laneId, opts) {
5165
5360
  }
5166
5361
  }
5167
5362
  lane = recordResumeSetup(ctx, lane, {
5168
- ...(resumeProfile !== undefined ? { profile: resumeProfile } : {}),
5363
+ ...(move === null && resumeProfile !== undefined ? { profile: resumeProfile } : {}),
5364
+ ...(move === null && resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5169
5365
  ...(requestedMeshCompany !== undefined
5170
5366
  ? { meshCompany: requestedMeshCompany, threadId: meshThreadId }
5171
5367
  : {}),
@@ -5239,6 +5435,7 @@ async function runResume(ctx, laneId, opts) {
5239
5435
  ...(messageId !== undefined ? { message_id: messageId } : {}),
5240
5436
  },
5241
5437
  compact: opts.compact !== false,
5438
+ effort: resumeEffort ?? recordedResumeEffort(lane),
5242
5439
  });
5243
5440
  });
5244
5441
  }, ctx.sleep);
@@ -5272,6 +5469,7 @@ async function runResume(ctx, laneId, opts) {
5272
5469
  ...current,
5273
5470
  project_id: projectId,
5274
5471
  story_id: storyId,
5472
+ no_story_reason: storyId ? undefined : current.no_story_reason,
5275
5473
  stories: laneStory(current, projectId, storyId)
5276
5474
  ? laneStories(current)
5277
5475
  : [
@@ -5362,8 +5560,7 @@ function addCarriedStory(lane, projectId, storyId, at) {
5362
5560
  }
5363
5561
  return {
5364
5562
  ...lane,
5365
- project_id: projectId,
5366
- story_id: storyId,
5563
+ ...(lane.story_id.trim() ? {} : { project_id: projectId, story_id: storyId }),
5367
5564
  stories: [
5368
5565
  ...laneStories(lane),
5369
5566
  { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
@@ -5423,7 +5620,7 @@ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status,
5423
5620
  const company = lane.mesh_company ?? lane.company;
5424
5621
  if (!company)
5425
5622
  return;
5426
- const ready = await ensureStoryThread(ctx, lane, projectId);
5623
+ const ready = await ensureStoryThread(ctx, lane, projectId, storyId);
5427
5624
  if (!ready.thread_id)
5428
5625
  return;
5429
5626
  await emitLaneMeshEvent(ctx, company, ready.thread_id, event, {
@@ -5495,6 +5692,9 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
5495
5692
  const normalizedStoryId = storyId.trim();
5496
5693
  if (!normalizedStoryId)
5497
5694
  throw new LanesError("missing_story", "<story-id> is required", {});
5695
+ if (ctx.storyLedgerEnabled()) {
5696
+ validateProjectStories(hqRoot, projectId, [normalizedStoryId], lane.company?.slug);
5697
+ }
5498
5698
  const company = lane.mesh_company ?? lane.company;
5499
5699
  const warnings = [];
5500
5700
  if (company) {
@@ -5510,7 +5710,11 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
5510
5710
  const before = lane;
5511
5711
  const next = casUpdateLane(hqRoot, laneId, (current) => {
5512
5712
  assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
5513
- return addCarriedStory(current, projectId, normalizedStoryId, at);
5713
+ const added = addCarriedStory(current, projectId, normalizedStoryId, at);
5714
+ return {
5715
+ ...added,
5716
+ no_story_reason: undefined,
5717
+ };
5514
5718
  });
5515
5719
  const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
5516
5720
  if (transitionWarning !== undefined)
@@ -6855,13 +7059,16 @@ export function registerLanesCommand(parent, deps = {}) {
6855
7059
  .command("lanes", { hidden: true })
6856
7060
  .description("Create, supervise, and steer worker lanes; routed decision queue");
6857
7061
  registerOutpostPrunerCommand(lanes);
7062
+ const noStoryOption = new Option("--no-story <reason>", "Create a lane without a story and record why");
7063
+ noStoryOption.attributeName = () => "noStory";
6858
7064
  lanes
6859
7065
  .command("create")
6860
7066
  .description("Register a lane, run admission, and return immediately")
6861
7067
  .option("--company <slug|uid>", "Company slug or cloud uid; omit for a core/personal lane")
6862
7068
  .option("--mesh-company <slug|uid>", "Explicit Work Mesh company for a personal lane; membership is verified")
6863
7069
  .requiredOption("--project <slug>", "HQ project slug")
6864
- .requiredOption("--story <id>", "Story id from the project's prd.json")
7070
+ .option("--story <id>", "Story id from the project's prd.json")
7071
+ .addOption(noStoryOption)
6865
7072
  .option("--additional-story <id>", "Additional story carried by this lane; repeatable and discouraged when active", collectRepeatable)
6866
7073
  .requiredOption("--brief-file <path>", "Path to the lane brief")
6867
7074
  .option("--worker <name>", "HQ worker or reusable profile id")
@@ -6911,10 +7118,20 @@ export function registerLanesCommand(parent, deps = {}) {
6911
7118
  .option("--story <id>", "Filter by story")
6912
7119
  .option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
6913
7120
  .option("--senior <ref>", "Filter by senior ref")
6914
- .option("--json", "JSON on stdout (always)")
7121
+ .option("--json", "Emit JSON on stdout (default)")
7122
+ .option("--human", "Emit human-readable output")
6915
7123
  .action(wrap(async (opts) => {
6916
7124
  await runList(ctx, opts);
6917
7125
  }));
7126
+ lanes
7127
+ .command("status")
7128
+ .description("Summarize project stories, lanes, PRs, blockers, and pending questions")
7129
+ .requiredOption("--project <slug>", "HQ project slug")
7130
+ .option("--company <slug|uid>", "Company project scope; omit for a personal project")
7131
+ .option("--json", "Emit JSON on stdout")
7132
+ .action(wrap(async (opts) => {
7133
+ await runStatus(ctx, opts);
7134
+ }));
6918
7135
  lanes
6919
7136
  .command("capacity")
6920
7137
  .description("Report this host's lane capacity by provider and account")
@@ -7049,6 +7266,7 @@ export function registerLanesCommand(parent, deps = {}) {
7049
7266
  .option("--story <id>", "Re-point story attribution from this message")
7050
7267
  .option("--account <id>", "Move the lane's session to this account of its provider before the message resumes it")
7051
7268
  .option("--model <id>", "Model for the moved lane (with --account only)")
7269
+ .option("--effort <level>", "Reasoning effort for the resumed provider turn")
7052
7270
  .option("--worker <profile>", "Attach a personal worker profile when this message resumes the lane")
7053
7271
  .option("--no-compact", "Skip the resume-time compaction check for this message")
7054
7272
  .action(wrap(async (laneId, opts) => {
@@ -7063,6 +7281,7 @@ export function registerLanesCommand(parent, deps = {}) {
7063
7281
  .option("--story <id>", "Re-point story attribution from this resume")
7064
7282
  .option("--account <id>", "Move the lane's session to this account of its provider before resuming")
7065
7283
  .option("--model <id>", "Model for the moved lane (with --account only)")
7284
+ .option("--effort <level>", "Reasoning effort for the resumed provider turn")
7066
7285
  .option("--worker <profile>", "Attach a personal worker profile for the resumed turn")
7067
7286
  .option("--mesh-company <slug>", "Attach Work Mesh attribution after verifying company membership")
7068
7287
  .option("--no-compact", "Skip the resume-time compaction check for this resume")