@indigoai-us/hq-cli 5.329.0 → 5.330.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.330.0] — 2026-10-02
6
+ - `hq lanes message` and `hq lanes resume` can refresh model, effort, and account settings from the current worker profile when a done lane starts a new story, behind the default-off `lanes.reuse-refreshes-profile` flag. Reused settings are recorded in lane history; provider-changing reuse is refused without changing the lane.
7
+
8
+ - The default-off `sync.warn-invalid-ontology-entities` hq-flag warns before push with each invalid local entity path and its frontmatter field names. Sync continues and leaves the files unchanged; omitted `aliases` is accepted because hq-pro supplies an empty-list default, while a malformed aliases value is still reported.
9
+
5
10
  ## [5.329.0] — 2026-10-02
6
11
  - `hq lanes keep-alive-audit` no longer reports a keep-alive lead as not restarted when the matching launch has rolled out of capped resume histories; if only queue-time evidence remains, it reports a confirmed untimed restart instead of assigning an on-time or late launch time.
7
12
 
@@ -239,6 +239,11 @@ export interface ShareCallResult {
239
239
  export interface PushAllDeps {
240
240
  vaultClient: PullAllVaultClient;
241
241
  share: (options: ShareCallOptions) => Promise<ShareCallResult>;
242
+ beforeShare?: (input: {
243
+ slug: string;
244
+ companyUid: string;
245
+ companyRoot: string;
246
+ }) => Promise<void>;
242
247
  /** DEV-1768: resolve membership push scope (same as pull). */
243
248
  resolveScope?: (companyUid: string, slug: string) => Promise<PullScope>;
244
249
  }
@@ -17,10 +17,11 @@ import chalk from "chalk";
17
17
  import * as fs from "fs";
18
18
  import * as path from "path";
19
19
  import { share, sync, getStateDir, listJournals, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, syncDoctor, } from "@indigoai-us/hq-cloud";
20
- import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
20
+ import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, loadCachedTokens, } from "../utils/cognito-session.js";
21
21
  import { resolveUploadAuthor } from "../utils/upload-author.js";
22
22
  import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
23
23
  import { beginSyncHealthReport, } from "../utils/client-health.js";
24
+ import { companySlugFromSyncPaths, warnAboutInvalidOntologyEntities, } from "../lib/sync-invalid-entity-warning.js";
24
25
  import { isWorkBearingSyncResult, recordFirstWorkBearingSync, } from "../lib/first-sync-next-step.js";
25
26
  import { maybeShowCliInviteNudge } from "../lib/cli-invite-nudge.js";
26
27
  import { createPlanLimitRefusalTracker, describeRefusedCount, describeRefusedFiles, mergePlanLimitRefusals, renderPlanLimitRefusalLines, trackingProgressRecorder, } from "../lib/billing/plan-limit-refusals.js";
@@ -504,6 +505,22 @@ export async function pushAll(options, deps) {
504
505
  }
505
506
  }
506
507
  try {
508
+ if (entry.slug !== "personal") {
509
+ try {
510
+ await deps.beforeShare?.({
511
+ slug: entry.slug,
512
+ companyUid: entry.companyUid,
513
+ companyRoot: path.join(options.hqRoot, "companies", entry.slug),
514
+ });
515
+ }
516
+ catch (error) {
517
+ const name = error instanceof Error ? error.name : "UnknownError";
518
+ const errorClass = /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name)
519
+ ? name
520
+ : "UnknownError";
521
+ process.stderr.write(`HQ Sync entity diagnostic failed (${errorClass}); continuing upload.\n`);
522
+ }
523
+ }
507
524
  const r = await deps.share(entry.shareOptions);
508
525
  result.filesUploaded += r.filesUploaded;
509
526
  result.bytesUploaded += r.bytesUploaded;
@@ -926,6 +943,20 @@ export function registerCloudCommands(program) {
926
943
  });
927
944
  const planLimitTracker = createPlanLimitRefusalTracker(companySlugForBilling(progressCompany));
928
945
  syncHealth = beginSyncHealthReport();
946
+ if (!personalMode) {
947
+ const companyUid = entityContext?.uid ??
948
+ (targetCompany?.startsWith("cmp_") ? targetCompany : "");
949
+ const companySlug = companySlugFromSyncPaths(options.hqRoot, targetPaths);
950
+ if (companySlug) {
951
+ await warnAboutInvalidOntologyEntities({
952
+ hqRoot: options.hqRoot,
953
+ companyRoot: path.join(options.hqRoot, "companies", companySlug),
954
+ companyUid,
955
+ companySlug,
956
+ idToken: loadCachedTokens()?.idToken ?? "",
957
+ });
958
+ }
959
+ }
929
960
  const result = await share({
930
961
  paths: targetPaths,
931
962
  company: targetCompany,
@@ -1541,6 +1572,13 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1541
1572
  }, {
1542
1573
  vaultClient: adapter,
1543
1574
  resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
1575
+ beforeShare: async ({ slug, companyUid, companyRoot }) => warnAboutInvalidOntologyEntities({
1576
+ hqRoot,
1577
+ companyRoot,
1578
+ companyUid,
1579
+ companySlug: slug,
1580
+ idToken: loadCachedTokens()?.idToken ?? "",
1581
+ }),
1544
1582
  share: async (opts) => {
1545
1583
  const progressCompany = opts.personalMode
1546
1584
  ? null
@@ -1692,6 +1730,18 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1692
1730
  const pushScope = await resolveCliPullScope(scopeClient, targetCompany ?? pushSlug, hqRoot, (companyUid) => { nudgeResolvedCompanyUid = companyUid; });
1693
1731
  nowPushPrefixSet = pushScope?.prefixSet;
1694
1732
  }
1733
+ if (!personalMode) {
1734
+ const companySlug = companySlugFromSyncPaths(hqRoot, pushPaths);
1735
+ if (companySlug) {
1736
+ await warnAboutInvalidOntologyEntities({
1737
+ hqRoot,
1738
+ companyRoot: path.join(hqRoot, "companies", companySlug),
1739
+ companyUid: targetCompany?.startsWith("cmp_") ? targetCompany : "",
1740
+ companySlug,
1741
+ idToken: loadCachedTokens()?.idToken ?? "",
1742
+ });
1743
+ }
1744
+ }
1695
1745
  const pushProgressCompany = personalMode ? null : (pushSlug ?? null);
1696
1746
  const planLimitTracker = createPlanLimitRefusalTracker(companySlugForBilling(pushProgressCompany));
1697
1747
  const pushResult = await share({
@@ -297,6 +297,25 @@ export function createLaneMessageHandlers(deps) {
297
297
  return;
298
298
  }
299
299
  assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
300
+ const projectId = opts.project?.trim() || lane.project_id;
301
+ const storyId = opts.story?.trim() || lane.story_id;
302
+ if (opts.story !== undefined && !opts.story.trim()) {
303
+ throw new LanesError("missing_story", "--story must be non-empty", {});
304
+ }
305
+ const refreshProfileSettings = lane.state === "done" &&
306
+ (projectId !== lane.project_id || storyId !== lane.story_id) &&
307
+ await deps.reuseProfileRefreshEnabled(ctx, lane);
308
+ const refreshWorker = opts.worker ?? (refreshProfileSettings ? lane.worker_profile : undefined);
309
+ let resumeProfile = refreshProfileSettings && refreshWorker !== undefined
310
+ ? resolveResumeProfile(ctx, lane, refreshWorker, true)
311
+ : undefined;
312
+ if (resumeProfile !== undefined) {
313
+ const providerPinned = lane.provider_source === undefined || lane.provider_source === "cli";
314
+ const effectiveProvider = providerPinned ? lane.provider : resumeProfile.profile.meta.provider;
315
+ if (effectiveProvider !== lane.provider) {
316
+ throw new LanesError("profile_provider_changed", `Worker profile now selects ${effectiveProvider}; this lane runs ${lane.provider}; create a new lane`, { lane_id: lane.lane_id, lane_provider: lane.provider, profile_provider: effectiveProvider });
317
+ }
318
+ }
300
319
  if (lane.state === "running") {
301
320
  const workerPidPath = path.join(laneRunDir(hqRoot, laneId), "worker.pid");
302
321
  const workerRecordExists = fs.existsSync(workerPidPath);
@@ -413,8 +432,8 @@ export function createLaneMessageHandlers(deps) {
413
432
  if (cliEffort !== undefined && mechanism === "dropbox") {
414
433
  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 });
415
434
  }
416
- const resumeProfile = opts.worker !== undefined
417
- ? resolveResumeProfile(ctx, lane, opts.worker)
435
+ resumeProfile ??= refreshWorker !== undefined
436
+ ? resolveResumeProfile(ctx, lane, refreshWorker, refreshProfileSettings)
418
437
  : undefined;
419
438
  if (opts.model !== undefined && opts.account === undefined) {
420
439
  throw new LanesError("model_requires_account", "--model on hq lanes message is only accepted together with --account", {});
@@ -425,6 +444,7 @@ export function createLaneMessageHandlers(deps) {
425
444
  try {
426
445
  automaticAccountSelection = await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile, {
427
446
  tolerateInvalidHostLaneLists: lane.host.kind === "local",
447
+ ...(refreshProfileSettings ? { refreshProfileSettings: true } : {}),
428
448
  });
429
449
  }
430
450
  catch (err) {
@@ -446,7 +466,15 @@ export function createLaneMessageHandlers(deps) {
446
466
  automaticAccountSelection = undefined;
447
467
  }
448
468
  }
449
- const resumeEffort = resolveResumeEffort(ctx, lane, {
469
+ const profileRefreshAccount = refreshProfileSettings && resumeProfile !== undefined &&
470
+ lane.account_source !== undefined && lane.account_source !== "cli" &&
471
+ resumeProfile.profile.meta.provider === lane.provider
472
+ ? resumeProfile.profile.meta.account ?? (resumeProfile.profile.meta.accounts !== undefined &&
473
+ !resumeProfile.profile.meta.accounts.includes(lane.account ?? "")
474
+ ? resumeProfile.profile.meta.accounts[0]
475
+ : undefined)
476
+ : undefined;
477
+ const resumeEffort = refreshProfileSettings && cliEffort === undefined ? undefined : resolveResumeEffort(ctx, lane, {
450
478
  cliEffort,
451
479
  profile: resumeProfile,
452
480
  account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.selection.account,
@@ -454,8 +482,6 @@ export function createLaneMessageHandlers(deps) {
454
482
  });
455
483
  const messageId = mintMessageId();
456
484
  const at = ctx.now().toISOString();
457
- const projectId = opts.project?.trim() || lane.project_id;
458
- const storyId = opts.story?.trim() || lane.story_id;
459
485
  if (opts.story !== undefined && !opts.story.trim()) {
460
486
  throw new LanesError("missing_story", "--story must be non-empty", {});
461
487
  }
@@ -589,6 +615,7 @@ export function createLaneMessageHandlers(deps) {
589
615
  account: opts.account,
590
616
  cliModel: opts.model?.trim() || undefined,
591
617
  ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
618
+ ...(refreshProfileSettings ? { refreshProfileSettings: true, accountSource: "cli" } : {}),
592
619
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
593
620
  ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
594
621
  pending: {
@@ -616,6 +643,7 @@ export function createLaneMessageHandlers(deps) {
616
643
  cliModel: undefined,
617
644
  ...(automatic.historyReason !== undefined ? { accountHistoryReason: automatic.historyReason } : {}),
618
645
  ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
646
+ ...(refreshProfileSettings ? { refreshProfileSettings: true, accountSource: "balanced" } : {}),
619
647
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
620
648
  ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
621
649
  pending: {
@@ -638,13 +666,38 @@ export function createLaneMessageHandlers(deps) {
638
666
  lane = persistClaudeAccountSelection(hqRoot, lane, selection);
639
667
  }
640
668
  }
669
+ if (move === null && profileRefreshAccount !== undefined && profileRefreshAccount !== lane.account) {
670
+ move = await prepareAccountMove(ctx, lane, {
671
+ account: profileRefreshAccount,
672
+ cliModel: undefined,
673
+ workerProfile: resumeProfile,
674
+ refreshProfileSettings: true,
675
+ accountSource: "profile",
676
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
677
+ ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
678
+ pending: {
679
+ trigger: messageHistoryTrigger,
680
+ text: deliveryText,
681
+ ...(isMonitorEvent ? { monitor_event: true } : {}),
682
+ message_id: messageId,
683
+ compact: opts.compact !== false,
684
+ },
685
+ });
686
+ lane = move.lane;
687
+ if (move.queued) {
688
+ storySwitchDeliveryStarted = true;
689
+ deps.onLaneMessageQueued?.(laneId, "account_move");
690
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
691
+ }
692
+ }
641
693
  }
642
- if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
694
+ if (resumeProfile !== undefined && !refreshProfileSettings && (opts.account === undefined || keepCurrentAccount)) {
643
695
  validateResumeProfileModel(ctx, lane, resumeProfile.profile);
644
696
  }
645
697
  if (move === null && (resumeProfile !== undefined || resumeEffort !== undefined || (keepCurrentAccount && opts.model !== undefined))) {
646
698
  lane = recordResumeSetup(ctx, lane, {
647
699
  ...(resumeProfile !== undefined ? { profile: resumeProfile } : {}),
700
+ ...(refreshProfileSettings ? { refreshProfileSettings: true } : {}),
648
701
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
649
702
  ...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
650
703
  });
@@ -185,6 +185,28 @@ export function createLaneResumeHandlers(deps) {
185
185
  const pendingQuestion = lane.state === "awaiting_input"
186
186
  ? await deps.hasPendingQuestionForLane(ctx, lane)
187
187
  : false;
188
+ const projectId = opts.project?.trim() || lane.project_id;
189
+ const storyId = opts.story?.trim() || lane.story_id;
190
+ if (opts.story !== undefined && !opts.story.trim()) {
191
+ throw new LanesError("missing_story", "--story must be non-empty", {});
192
+ }
193
+ if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
194
+ validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
195
+ }
196
+ const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
197
+ const refreshProfileSettings = lane.state === "done" && attributionChanged &&
198
+ await deps.reuseProfileRefreshEnabled(ctx, lane);
199
+ const refreshWorker = opts.worker ?? (refreshProfileSettings ? lane.worker_profile : undefined);
200
+ let resumeProfile = refreshProfileSettings && refreshWorker !== undefined
201
+ ? resolveResumeProfile(ctx, lane, refreshWorker, true)
202
+ : undefined;
203
+ if (resumeProfile !== undefined) {
204
+ const providerPinned = lane.provider_source === undefined || lane.provider_source === "cli";
205
+ const effectiveProvider = providerPinned ? lane.provider : resumeProfile.profile.meta.provider;
206
+ if (effectiveProvider !== lane.provider) {
207
+ throw new LanesError("profile_provider_changed", `Worker profile now selects ${effectiveProvider}; this lane runs ${lane.provider}; create a new lane`, { lane_id: lane.lane_id, lane_provider: lane.provider, profile_provider: effectiveProvider });
208
+ }
209
+ }
188
210
  const refusal = queuedProfileRecovery || (lane.state === "awaiting_input" && !pendingQuestion)
189
211
  ? null
190
212
  : resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
@@ -200,15 +222,6 @@ export function createLaneResumeHandlers(deps) {
200
222
  const resumedFrom = lane.state;
201
223
  const text = opts.text?.trim() ? opts.text : undefined;
202
224
  const at = ctx.now().toISOString();
203
- const projectId = opts.project?.trim() || lane.project_id;
204
- const storyId = opts.story?.trim() || lane.story_id;
205
- if (opts.story !== undefined && !opts.story.trim()) {
206
- throw new LanesError("missing_story", "--story must be non-empty", {});
207
- }
208
- if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
209
- validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
210
- }
211
- const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
212
225
  const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
213
226
  const notice = storySwitchNotice(lane.project_id, lane.story_id, projectId, storyId);
214
227
  const deliveryText = notice === undefined ? text : `${notice}${text === undefined ? "" : `\n\n${text}`}`;
@@ -226,8 +239,8 @@ export function createLaneResumeHandlers(deps) {
226
239
  }
227
240
  meshThreadId = await ctx.mesh.ensureProjectThread(requestedMeshCompany, projectId);
228
241
  }
229
- const resumeProfile = opts.worker !== undefined
230
- ? resolveResumeProfile(ctx, lane, opts.worker)
242
+ resumeProfile ??= refreshWorker !== undefined
243
+ ? resolveResumeProfile(ctx, lane, refreshWorker, refreshProfileSettings)
231
244
  : undefined;
232
245
  const profileRepin = authorizeResumeProfileRepin(ctx, lane, resumeProfile, opts.profileRepinContext);
233
246
  const keepAlive = opts.keepAlive === true
@@ -236,7 +249,7 @@ export function createLaneResumeHandlers(deps) {
236
249
  const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
237
250
  if (keepCurrentAccount)
238
251
  assertCurrentResumeAccountAvailable(ctx, lane);
239
- if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
252
+ if (resumeProfile !== undefined && !refreshProfileSettings && (opts.account === undefined || keepCurrentAccount)) {
240
253
  validateResumeProfileModel(ctx, lane, resumeProfile.profile);
241
254
  }
242
255
  if (lane.state === "queued" && lane.queued_reason === "worker_profile_changed") {
@@ -262,9 +275,20 @@ export function createLaneResumeHandlers(deps) {
262
275
  return;
263
276
  }
264
277
  const automaticAccountSelection = opts.account === undefined
265
- ? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile, { repairUnavailableAccount: true })
278
+ ? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile, {
279
+ repairUnavailableAccount: true,
280
+ ...(refreshProfileSettings ? { refreshProfileSettings: true } : {}),
281
+ })
282
+ : undefined;
283
+ const profileRefreshAccount = refreshProfileSettings && resumeProfile !== undefined &&
284
+ lane.account_source !== undefined && lane.account_source !== "cli" &&
285
+ resumeProfile.profile.meta.provider === lane.provider
286
+ ? resumeProfile.profile.meta.account ?? (resumeProfile.profile.meta.accounts !== undefined &&
287
+ !resumeProfile.profile.meta.accounts.includes(lane.account ?? "")
288
+ ? resumeProfile.profile.meta.accounts[0]
289
+ : undefined)
266
290
  : undefined;
267
- const resumeEffort = resolveResumeEffort(ctx, lane, {
291
+ const resumeEffort = refreshProfileSettings && cliEffort === undefined ? undefined : resolveResumeEffort(ctx, lane, {
268
292
  cliEffort,
269
293
  profile: resumeProfile,
270
294
  account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.selection.account,
@@ -325,6 +349,7 @@ export function createLaneResumeHandlers(deps) {
325
349
  account: opts.account,
326
350
  cliModel: opts.model?.trim() || undefined,
327
351
  ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
352
+ ...(refreshProfileSettings ? { refreshProfileSettings: true, accountSource: "cli" } : {}),
328
353
  ...(profileRepin !== undefined ? { profileRepin } : {}),
329
354
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
330
355
  ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
@@ -355,6 +380,7 @@ export function createLaneResumeHandlers(deps) {
355
380
  ? { preservedModel: automaticAccountSelection.preservedModel }
356
381
  : {}),
357
382
  ...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
383
+ ...(refreshProfileSettings ? { refreshProfileSettings: true, accountSource: "balanced" } : {}),
358
384
  ...(profileRepin !== undefined ? { profileRepin } : {}),
359
385
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
360
386
  ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
@@ -377,8 +403,33 @@ export function createLaneResumeHandlers(deps) {
377
403
  lane = persistClaudeAccountSelection(hqRoot, lane, selection);
378
404
  }
379
405
  }
406
+ else if (profileRefreshAccount !== undefined && profileRefreshAccount !== lane.account) {
407
+ move = await prepareAccountMove(ctx, lane, {
408
+ account: profileRefreshAccount,
409
+ cliModel: undefined,
410
+ workerProfile: resumeProfile,
411
+ refreshProfileSettings: true,
412
+ accountSource: "profile",
413
+ ...(profileRepin !== undefined ? { profileRepin } : {}),
414
+ ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
415
+ ...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
416
+ pending: {
417
+ trigger: "resume",
418
+ resumed_from: resumedFrom,
419
+ ...(opts.fromFailed === true ? { from_failed: true } : {}),
420
+ ...(failedResumeExpectedSenior !== undefined ? { expected_senior: failedResumeExpectedSenior } : {}),
421
+ ...(deliveryText !== undefined ? { text: deliveryText } : {}),
422
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
423
+ compact: opts.compact !== false,
424
+ },
425
+ });
426
+ lane = move.lane;
427
+ }
380
428
  lane = recordResumeSetup(ctx, lane, {
381
- ...(move === null && resumeProfile !== undefined ? { profile: resumeProfile } : {}),
429
+ ...(move === null && resumeProfile !== undefined ? {
430
+ profile: resumeProfile,
431
+ ...(refreshProfileSettings ? { refreshProfileSettings: true } : {}),
432
+ } : {}),
382
433
  ...(move === null && profileRepin !== undefined ? { profileRepin } : {}),
383
434
  ...(move === null && resumeEffort !== undefined ? { effort: resumeEffort } : {}),
384
435
  ...(keepAlive !== undefined ? { keepAlive } : {}),
@@ -107,6 +107,10 @@ export interface PrepareAccountMoveInput {
107
107
  /** Copy the session and change account while preserving the between-turn state. */
108
108
  deferTurnStart?: boolean;
109
109
  accountSelection?: LaneAccountSelection;
110
+ /** Apply current profile settings during a done-lane story reuse that also moves accounts. */
111
+ refreshProfileSettings?: boolean;
112
+ /** Provenance recorded for the account this move selects. */
113
+ accountSource?: "cli" | "balanced" | "profile";
110
114
  }
111
115
  export interface RecordResumeSetupInput {
112
116
  profile?: ResolvedResumeProfile;
@@ -118,6 +122,8 @@ export interface RecordResumeSetupInput {
118
122
  profileRepin?: ResumeProfileRepinAuthorization;
119
123
  /** Clear only the recoverable worker_profile_changed queue after its profile is revalidated. */
120
124
  clearWorkerProfileChangedQueue?: boolean;
125
+ /** Apply current profile provider/model/account/effort for a done-lane story reuse. */
126
+ refreshProfileSettings?: boolean;
121
127
  }
122
128
  export declare function accountRequestKeepsCurrentLane(lane: LaneRecord, rawAccount: string): boolean;
123
129
  export declare function assertCurrentResumeAccountAvailable(ctx: Ctx, lane: LaneRecord): void;
@@ -191,7 +197,8 @@ export interface LaneMessageHandlerDeps {
191
197
  resolveActiveQuestionCompany(ctx: Ctx, message?: string): Promise<LaneCompanyRef>;
192
198
  hasPendingQuestionForLane(ctx: Ctx, lane: LaneRecord): Promise<boolean>;
193
199
  cancelFallbackQuestionsForLane(ctx: Ctx, lane: LaneRecord, trigger: "message" | "resume", at: string): Promise<FallbackQuestionCancellationResult>;
194
- resolveResumeProfile(ctx: Ctx, lane: LaneRecord, workerId: string): ResolvedResumeProfile;
200
+ resolveResumeProfile(ctx: Ctx, lane: LaneRecord, workerId: string, allowProviderChange?: boolean): ResolvedResumeProfile;
201
+ reuseProfileRefreshEnabled(ctx: Ctx, lane: LaneRecord): Promise<boolean>;
195
202
  selectAutomaticClaudeResumeAccount(ctx: Ctx, lane: LaneRecord, workerProfile?: ResolvedResumeProfile, options?: {
196
203
  repairUnavailableAccount?: boolean;
197
204
  tolerateInvalidHostLaneLists?: boolean;
@@ -203,7 +210,7 @@ export interface LaneMessageHandlerDeps {
203
210
  validateResumeProfileModel(ctx: Ctx, lane: LaneRecord, profile: WorkerProfile): void;
204
211
  withResumeHireAdmission<T>(ctx: Ctx, lane: LaneRecord, fn: () => T, grantContext?: OutpostHireGrantContext): Promise<T>;
205
212
  }
206
- export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "hasPendingQuestionForLane" | "cancelFallbackQuestionsForLane" | "resolveResumeProfile" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
213
+ export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "hasPendingQuestionForLane" | "cancelFallbackQuestionsForLane" | "resolveResumeProfile" | "reuseProfileRefreshEnabled" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
207
214
  dispatchOutpostLaneCommand(ctx: Ctx, lane: LaneRecord, action: "resume", options: LaneResumeOptions, behavior?: OutpostLaneDispatchBehavior): Promise<void>;
208
215
  authorizeFailedResume(ctx: Ctx, lane: LaneRecord, encodedContext?: string): string;
209
216
  authorizeResumeProfileRepin(ctx: Ctx, lane: LaneRecord, profile: ResolvedResumeProfile | undefined, encodedContext: string | undefined): ResumeProfileRepinAuthorization | undefined;
@@ -35,7 +35,7 @@ import { withCliFeatureUsed } from "../utils/cli-telemetry.js";
35
35
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
36
36
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
37
37
  import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff } from "../lib/lanes/registry-flag.js";
38
- import { 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, } from "../lib/lanes/flag-keys.js";
38
+ import { 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_REUSE_REFRESHES_PROFILE_FLAG, } from "../lib/lanes/flag-keys.js";
39
39
  import { CODEX_ROLLOUT_RETENTION_FLAG, configuredCodexRolloutHomes, lowDiskGuardEnabledForLane, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
40
40
  import { isControllerAliasOutpostId } from "../lib/lanes/admission-host.js";
41
41
  import { runLeadAutopilotPass } from "../lib/lanes/autopilot.js";
@@ -500,6 +500,15 @@ function hireGrantFinishedChildrenDoNotCount(ctx, lane) {
500
500
  env: ctx.env,
501
501
  }, readLanesRegistryFlag);
502
502
  }
503
+ function reuseProfileRefreshEnabled(ctx, lane) {
504
+ const resolution = resolveLaneCompany(ctx.hqRoot(), lane);
505
+ const company = resolution.company ?? undefined;
506
+ return readLanesRegistryFlagFailOff(LANE_REUSE_REFRESHES_PROFILE_FLAG, {
507
+ company,
508
+ ...(company ? { companyUidSource: laneCompanyUidSource(resolution.source) } : {}),
509
+ env: ctx.env,
510
+ }, readLanesRegistryFlag);
511
+ }
503
512
  function mergeOwnerLaneHierarchy(supplied, ownerLanes) {
504
513
  const byId = new Map();
505
514
  for (const lane of supplied)
@@ -3581,7 +3590,17 @@ async function runCreate(ctx, opts) {
3581
3590
  keep_alive: keepAlive,
3582
3591
  ...(keepAlive ? { keep_alive_opted_in_at: createdAt } : {}),
3583
3592
  provider,
3593
+ provider_source: explicitProvider !== undefined
3594
+ ? "cli"
3595
+ : profile?.meta.provider !== undefined
3596
+ ? "profile"
3597
+ : "default",
3584
3598
  account: pinnedAccount,
3599
+ account_source: accountWasExplicit
3600
+ ? "cli"
3601
+ : profileAccount !== undefined
3602
+ ? "profile"
3603
+ : "balanced",
3585
3604
  ...(accountSelection !== undefined ? { account_selection: accountSelection } : {}),
3586
3605
  ...(defaultPlacement !== undefined
3587
3606
  ? { placement_default: defaultPlacement }
@@ -6238,14 +6257,14 @@ async function runAdmit(ctx, laneId, opts) {
6238
6257
  });
6239
6258
  }, ctx.sleep);
6240
6259
  }
6241
- function resolveResumeProfile(ctx, lane, workerId) {
6260
+ function resolveResumeProfile(ctx, lane, workerId, allowProviderChange = false) {
6242
6261
  const worker = resolveLaneWorker(ctx.hqRoot(), workerId.trim(), lane.company?.slug ?? lane.mesh_company?.slug, undefined, lane.worker_profile === workerId.trim() && lane.registry_worker !== undefined
6243
6262
  ? { registryWorkerRecord: lane.registry_worker }
6244
6263
  : {});
6245
6264
  if (worker.profile === undefined) {
6246
6265
  throw new LanesError("worker_profile_required", `Worker ${JSON.stringify(workerId)} resolves to a registry worker, not a reusable provider profile; resume --worker requires personal/workers/<id>/meta.yaml`, { worker: workerId, path: worker.path });
6247
6266
  }
6248
- if (worker.profile.meta.provider !== lane.provider) {
6267
+ if (!allowProviderChange && worker.profile.meta.provider !== lane.provider) {
6249
6268
  throw new LanesError("worker_profile_provider_mismatch", `Worker profile ${JSON.stringify(workerId)} uses provider ${worker.profile.meta.provider}, but lane ${lane.lane_id} uses ${lane.provider}; resume cannot change a lane's provider`, {
6250
6269
  worker: workerId,
6251
6270
  lane_id: lane.lane_id,
@@ -6299,7 +6318,7 @@ function applyResumeProfileAndEffort(current, next, input) {
6299
6318
  };
6300
6319
  next.worker_profile_repin_history = appendCappedHistory(current.worker_profile_repin_history ?? [], entry);
6301
6320
  }
6302
- next.worker_profile_ignored = ignoredWorkerProfileFields(current.provider);
6321
+ next.worker_profile_ignored = ignoredWorkerProfileFields(next.provider);
6303
6322
  next.worker_policy_delivery = undefined;
6304
6323
  if (profile.meta.keep_alive !== undefined) {
6305
6324
  next.keep_alive = profile.meta.keep_alive;
@@ -6308,7 +6327,64 @@ function applyResumeProfileAndEffort(current, next, input) {
6308
6327
  : undefined;
6309
6328
  next.keep_alive_blocked_reason = undefined;
6310
6329
  }
6311
- if (next.model_source !== "cli" && next.model_source !== "preserved") {
6330
+ if (input.refreshProfileSettings === true) {
6331
+ const providerPinned = current.provider_source === undefined || current.provider_source === "cli";
6332
+ const accountPinned = next.account_source === undefined || next.account_source === "cli";
6333
+ const targetProvider = providerPinned ? current.provider : profile.meta.provider;
6334
+ if (targetProvider !== current.provider) {
6335
+ throw new LanesError("profile_provider_changed", `Worker profile now selects ${targetProvider}; this lane runs ${current.provider}; create a new lane`, { lane_id: current.lane_id, lane_provider: current.provider, profile_provider: targetProvider });
6336
+ }
6337
+ const profileMatchesProvider = profile.meta.provider === targetProvider;
6338
+ const nextModel = next.model_source === "cli" || next.model_source === "preserved"
6339
+ ? next.model
6340
+ : current.model_source === "cli" || current.model_source === "preserved" ||
6341
+ (providerPinned && !profileMatchesProvider)
6342
+ ? current.model
6343
+ : profileMatchesProvider ? profile.meta.model : undefined;
6344
+ const nextEffort = current.effort_source === "cli" || (providerPinned && !profileMatchesProvider)
6345
+ ? current.effort
6346
+ : profileMatchesProvider ? profile.meta.effort : undefined;
6347
+ const profileAccounts = profileMatchesProvider ? profile.meta.accounts : undefined;
6348
+ const profileAccount = profileMatchesProvider && profileAccounts === undefined
6349
+ ? profile.meta.account
6350
+ : profileAccounts?.includes(next.account ?? "") ? next.account : profileAccounts?.[0];
6351
+ next.provider = targetProvider;
6352
+ next.provider_source = providerPinned ? current.provider_source : "profile";
6353
+ if (!accountPinned && profileMatchesProvider) {
6354
+ next.account = profileAccount;
6355
+ next.account_source = profileAccount === undefined ? "balanced" : "profile";
6356
+ next.account_selection = undefined;
6357
+ }
6358
+ if (profileAccounts !== undefined) {
6359
+ if (accountPinned && current.account !== undefined && !profileAccounts.includes(current.account)) {
6360
+ throw new LanesError("profile_account_not_allowed", `Worker profile ${profile.id} no longer allows pinned lane account ${current.account}`, { lane_id: current.lane_id, worker: profile.id, account: current.account, allowed_accounts: profileAccounts });
6361
+ }
6362
+ next.admission_accounts = [...profileAccounts];
6363
+ }
6364
+ next.model = nextModel;
6365
+ next.model_source = next.model_source === "cli" || next.model_source === "preserved"
6366
+ ? next.model_source
6367
+ : current.model_source === "cli" || current.model_source === "preserved"
6368
+ ? current.model_source
6369
+ : nextModel === undefined ? "none" : "profile";
6370
+ next.effort = nextEffort;
6371
+ next.effort_source = current.effort_source === "cli"
6372
+ ? "cli"
6373
+ : nextEffort === undefined ? "none" : "profile";
6374
+ next.profile_settings_pinned = true;
6375
+ if (current.provider !== next.provider || current.model !== next.model || current.effort !== next.effort) {
6376
+ const entry = {
6377
+ at: input.at ?? new Date().toISOString(),
6378
+ actor: `profile:${profile.id}`,
6379
+ reason: "worker_profile_refresh",
6380
+ ...(current.provider !== next.provider ? { provider: { from: current.provider, to: next.provider } } : {}),
6381
+ ...(current.model !== next.model ? { model: { from: current.model ?? null, to: next.model ?? "(provider default)" } } : {}),
6382
+ ...(current.effort !== next.effort ? { effort: { from: current.effort ?? null, to: next.effort ?? "(provider default)" } } : {}),
6383
+ };
6384
+ next.settings_history = appendCappedHistory(current.settings_history ?? [], entry);
6385
+ }
6386
+ }
6387
+ else if (next.model_source !== "cli" && next.model_source !== "preserved") {
6312
6388
  if (profile.meta.provider === current.provider && profile.meta.model !== undefined) {
6313
6389
  next.model = profile.meta.model;
6314
6390
  next.model_source = "profile";
@@ -6355,6 +6431,7 @@ function recordResumeSetup(ctx, lane, input) {
6355
6431
  input.cliModel === undefined &&
6356
6432
  input.meshCompany === undefined &&
6357
6433
  input.clearWorkerProfileChangedQueue !== true &&
6434
+ input.refreshProfileSettings !== true &&
6358
6435
  lane.keep_alive_blocked_reason !== "terminal_state")
6359
6436
  return lane;
6360
6437
  return casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => {
@@ -6470,6 +6547,12 @@ async function prepareAccountMove(ctx, lane, input) {
6470
6547
  const profile = input.workerProfile?.profile ?? (lane.worker_profile !== undefined
6471
6548
  ? loadWorkerProfile(hqRoot, lane.worker_profile)
6472
6549
  : undefined);
6550
+ if (input.refreshProfileSettings === true &&
6551
+ profile?.meta.provider === lane.provider &&
6552
+ profile.meta.accounts !== undefined &&
6553
+ !profile.meta.accounts.includes(target)) {
6554
+ throw new LanesError("profile_account_not_allowed", `Account ${target} is not in worker profile ${profile.id}'s allowed accounts`, { lane_id: laneId, worker: profile.id, account: target, allowed_accounts: profile.meta.accounts });
6555
+ }
6473
6556
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === lane.provider;
6474
6557
  const pinnedModel = input.preservedModel ?? (input.workerProfile !== undefined
6475
6558
  ? lane.model_source === "cli" || lane.model_source === "preserved" ? lane.model : undefined
@@ -6556,6 +6639,7 @@ async function prepareAccountMove(ctx, lane, input) {
6556
6639
  const next = {
6557
6640
  ...current,
6558
6641
  account: target,
6642
+ ...(input.accountSource !== undefined ? { account_source: input.accountSource } : {}),
6559
6643
  ...(input.accountSelection !== undefined ? { account_selection: input.accountSelection } : {}),
6560
6644
  admission_accounts: undefined,
6561
6645
  provider_session_id: sessionId,
@@ -6584,6 +6668,7 @@ async function prepareAccountMove(ctx, lane, input) {
6584
6668
  ...(input.workerProfile !== undefined ? { profile: input.workerProfile } : {}),
6585
6669
  ...(input.profileRepin !== undefined ? { profileRepin: input.profileRepin } : {}),
6586
6670
  ...(input.effort !== undefined ? { effort: input.effort } : {}),
6671
+ ...(input.refreshProfileSettings === true ? { refreshProfileSettings: true } : {}),
6587
6672
  at,
6588
6673
  });
6589
6674
  if (input.deferTurnStart) {
@@ -6631,9 +6716,11 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, opti
6631
6716
  const profileAccount = profileDefaultsApply ? profile?.meta.account : undefined;
6632
6717
  const profileAccounts = profileDefaultsApply ? profile?.meta.accounts : undefined;
6633
6718
  const profilePin = profileAccounts ?? (profileAccount === undefined ? undefined : [profileAccount]);
6634
- const allowedAccounts = profilePin !== undefined && lane.admission_accounts !== undefined
6635
- ? profilePin.filter((account) => lane.admission_accounts.includes(account))
6636
- : profilePin ?? lane.admission_accounts;
6719
+ const allowedAccounts = options.refreshProfileSettings === true
6720
+ ? profilePin ?? lane.admission_accounts
6721
+ : profilePin !== undefined && lane.admission_accounts !== undefined
6722
+ ? profilePin.filter((account) => lane.admission_accounts.includes(account))
6723
+ : profilePin ?? lane.admission_accounts;
6637
6724
  const pinnedModel = workerProfile !== undefined
6638
6725
  ? lane.model_source === "cli" || lane.model_source === "preserved" ? lane.model : undefined
6639
6726
  : lane.model_source === undefined || lane.model_source === "cli" || lane.model_source === "preserved"
@@ -8280,6 +8367,7 @@ export function registerLanesCommand(parent, deps = {}) {
8280
8367
  hasPendingQuestionForLane: questionHandlers.hasPendingQuestionForLane,
8281
8368
  cancelFallbackQuestionsForLane: questionHandlers.cancelFallbackQuestionsForLane,
8282
8369
  resolveResumeProfile,
8370
+ reuseProfileRefreshEnabled,
8283
8371
  selectAutomaticClaudeResumeAccount,
8284
8372
  validateResumeProfileModel,
8285
8373
  withResumeHireAdmission,
@@ -8307,6 +8395,7 @@ export function registerLanesCommand(parent, deps = {}) {
8307
8395
  hasPendingQuestionForLane: questionHandlers.hasPendingQuestionForLane,
8308
8396
  cancelFallbackQuestionsForLane: questionHandlers.cancelFallbackQuestionsForLane,
8309
8397
  resolveResumeProfile,
8398
+ reuseProfileRefreshEnabled,
8310
8399
  selectAutomaticClaudeResumeAccount,
8311
8400
  validateResumeProfileModel,
8312
8401
  withResumeHireAdmission,
@@ -1,5 +1,7 @@
1
1
  /** hq-flags keys used by lane command behavior. */
2
2
  export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
3
+ /** Refresh provider/model/effort from a worker profile when a done lane takes a new story. */
4
+ export declare const LANE_REUSE_REFRESHES_PROFILE_FLAG = "lanes.reuse-refreshes-profile";
3
5
  /** Enables one automatic continuation after a missing-envelope repair. */
4
6
  export declare const LANE_ENVELOPE_REPAIR_CONTINUE_FLAG = "lanes.envelope-repair-continue";
5
7
  /** Count only children with live work against their senior's hire grant. */
@@ -1,5 +1,7 @@
1
1
  /** hq-flags keys used by lane command behavior. */
2
2
  export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
3
+ /** Refresh provider/model/effort from a worker profile when a done lane takes a new story. */
4
+ export const LANE_REUSE_REFRESHES_PROFILE_FLAG = "lanes.reuse-refreshes-profile";
3
5
  /** Enables one automatic continuation after a missing-envelope repair. */
4
6
  export const LANE_ENVELOPE_REPAIR_CONTINUE_FLAG = "lanes.envelope-repair-continue";
5
7
  /** Count only children with live work against their senior's hire grant. */
@@ -1733,8 +1733,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1733
1733
  });
1734
1734
  }
1735
1735
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === existing.provider;
1736
- const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
1737
- const profileEffort = profileDefaultsApply ? profile.meta.effort : undefined;
1736
+ const profileModel = profileDefaultsApply && existing.profile_settings_pinned !== true ? profile.meta.model : undefined;
1737
+ const profileEffort = profileDefaultsApply && existing.profile_settings_pinned !== true ? profile.meta.effort : undefined;
1738
1738
  const ignoredProfileFields = profile
1739
1739
  ? ignoredWorkerProfileFields(existing.provider)
1740
1740
  : [];
@@ -1765,7 +1765,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1765
1765
  : undefined;
1766
1766
  // `hq lanes set` on a running lane leaves the change pending until this spawn.
1767
1767
  const pendingSettings = existing.pending_settings;
1768
- let pinnedModel = pendingSettings?.model ?? (existing.model_source === undefined || existing.model_source === "cli" || existing.model_source === "preserved"
1768
+ let pinnedModel = pendingSettings?.model ?? (existing.profile_settings_pinned === true || existing.model_source === undefined || existing.model_source === "cli" || existing.model_source === "preserved"
1769
1769
  ? existing.model
1770
1770
  : undefined);
1771
1771
  const accountDeps = {
@@ -1929,7 +1929,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1929
1929
  : undefined;
1930
1930
  const effort = launch.effort ?? pendingResumeEffort ?? resolveLaneEffort({
1931
1931
  cliEffort: pendingSettings?.effort ?? (existing.effort_source === "cli" ? existing.effort : undefined),
1932
- profileEffort,
1932
+ profileEffort: existing.profile_settings_pinned === true ? existing.effort : profileEffort,
1933
1933
  accountEffort: accountCfg?.effort,
1934
1934
  providerEffort: config.providers[lane.provider].effort,
1935
1935
  model,
@@ -2138,7 +2138,11 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2138
2138
  ? "cli"
2139
2139
  : preservedAccountModel !== undefined || existing.model_source === "preserved"
2140
2140
  ? "preserved"
2141
- : "cli"
2141
+ : existing.model_source === "cli"
2142
+ ? "cli"
2143
+ : existing.profile_settings_pinned === true
2144
+ ? "profile"
2145
+ : "cli"
2142
2146
  : profileModel !== undefined
2143
2147
  ? "profile"
2144
2148
  : accountCfg?.model !== undefined
@@ -2483,8 +2487,8 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2483
2487
  })
2484
2488
  : undefined;
2485
2489
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === lane.provider;
2486
- const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
2487
- const profileEffort = profileDefaultsApply ? profile.meta.effort : undefined;
2490
+ const profileModel = profileDefaultsApply && lane.profile_settings_pinned !== true ? profile.meta.model : undefined;
2491
+ const profileEffort = profileDefaultsApply && lane.profile_settings_pinned !== true ? profile.meta.effort : undefined;
2488
2492
  const ignoredProfileFields = profile
2489
2493
  ? ignoredWorkerProfileFields(lane.provider)
2490
2494
  : [];
@@ -2506,7 +2510,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2506
2510
  ignoredFields: ignoredProfileFields,
2507
2511
  }
2508
2512
  : undefined;
2509
- const pinnedModel = lane.model_source === undefined || lane.model_source === "cli" || lane.model_source === "preserved"
2513
+ const pinnedModel = lane.profile_settings_pinned === true || lane.model_source === undefined || lane.model_source === "cli" || lane.model_source === "preserved"
2510
2514
  ? lane.model
2511
2515
  : undefined;
2512
2516
  const resolved = resolveAccount(lane.provider, lane.account, config, {
@@ -2534,7 +2538,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2534
2538
  });
2535
2539
  const effort = resolveLaneEffort({
2536
2540
  cliEffort: lane.effort_source === "cli" ? lane.effort : undefined,
2537
- profileEffort,
2541
+ profileEffort: lane.profile_settings_pinned === true ? lane.effort : profileEffort,
2538
2542
  accountEffort: accountCfg?.effort,
2539
2543
  providerEffort: config.providers[lane.provider].effort,
2540
2544
  model,
@@ -2618,7 +2622,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2618
2622
  if (model !== undefined) {
2619
2623
  next.model = model;
2620
2624
  next.model_source = pinnedModel !== undefined
2621
- ? lane.model_source === "preserved" ? "preserved" : "cli"
2625
+ ? lane.model_source === "preserved" ? "preserved" : lane.model_source === "profile" || lane.profile_settings_pinned === true ? "profile" : "cli"
2622
2626
  : profileModel !== undefined
2623
2627
  ? "profile"
2624
2628
  : accountCfg?.model !== undefined
@@ -652,6 +652,27 @@ export function parseLaneRecord(raw, filePath) {
652
652
  if (!isLaneProvider(raw.provider)) {
653
653
  throw new LaneSchemaError(`Unknown provider ${JSON.stringify(raw.provider)}`, { path: filePath, value: raw.provider });
654
654
  }
655
+ if (raw.provider_source !== undefined && !["cli", "profile", "default"].includes(String(raw.provider_source))) {
656
+ throw new LaneSchemaError("provider_source must be cli, profile, or default", {
657
+ path: filePath,
658
+ field: "provider_source",
659
+ value: raw.provider_source,
660
+ });
661
+ }
662
+ if (raw.account_source !== undefined && !["cli", "profile", "balanced"].includes(String(raw.account_source))) {
663
+ throw new LaneSchemaError("account_source must be cli, profile, or balanced", {
664
+ path: filePath,
665
+ field: "account_source",
666
+ value: raw.account_source,
667
+ });
668
+ }
669
+ if (raw.profile_settings_pinned !== undefined && typeof raw.profile_settings_pinned !== "boolean") {
670
+ throw new LaneSchemaError("profile_settings_pinned must be a boolean", {
671
+ path: filePath,
672
+ field: "profile_settings_pinned",
673
+ value: raw.profile_settings_pinned,
674
+ });
675
+ }
655
676
  if (raw.worker_policy_delivery !== undefined &&
656
677
  raw.worker_policy_delivery !== "hook" &&
657
678
  raw.worker_policy_delivery !== "prompt") {
@@ -867,8 +888,8 @@ export function parseLaneRecord(raw, filePath) {
867
888
  for (const [index, entry] of raw.settings_history.entries()) {
868
889
  const field = `settings_history[${index}]`;
869
890
  if (!isPlainObject(entry) ||
870
- Object.keys(entry).some((key) => !["at", "actor", "model", "effort", "reason"].includes(key)) ||
871
- (entry.model === undefined && entry.effort === undefined)) {
891
+ Object.keys(entry).some((key) => !["at", "actor", "provider", "model", "effort", "reason"].includes(key)) ||
892
+ (entry.provider === undefined && entry.model === undefined && entry.effort === undefined)) {
872
893
  throw new LaneSchemaError(`${field} must be a settings history entry`, { path: filePath, field });
873
894
  }
874
895
  requireTimestamp(entry.at, `${field}.at`, filePath);
@@ -878,6 +899,12 @@ export function parseLaneRecord(raw, filePath) {
878
899
  if (entry.reason !== undefined && (typeof entry.reason !== "string" || entry.reason.trim() === "")) {
879
900
  throw new LaneSchemaError(`${field}.reason must be a non-empty string when present`, { path: filePath, field });
880
901
  }
902
+ if (entry.provider !== undefined && (!isPlainObject(entry.provider) ||
903
+ Object.keys(entry.provider).some((key) => key !== "from" && key !== "to") ||
904
+ !isLaneProvider(entry.provider.from) ||
905
+ !isLaneProvider(entry.provider.to))) {
906
+ throw new LaneSchemaError(`${field}.provider must be a {from, to} change`, { path: filePath, field });
907
+ }
881
908
  for (const key of ["model", "effort"]) {
882
909
  const change = entry[key];
883
910
  if (change === undefined)
@@ -312,6 +312,10 @@ export interface LaneStopHistoryEntry {
312
312
  export interface LaneSettingsHistoryEntry {
313
313
  at: string;
314
314
  actor: string;
315
+ provider?: {
316
+ from: LaneProvider;
317
+ to: LaneProvider;
318
+ };
315
319
  model?: {
316
320
  from: string | null;
317
321
  to: string;
@@ -578,7 +582,13 @@ export interface LaneRecord {
578
582
  /** How policy files were delivered on the most recent profile spawn. */
579
583
  worker_policy_delivery?: "hook" | "prompt";
580
584
  provider: LaneProvider;
585
+ /** Whether provider came from an explicit create override or the worker profile/default. */
586
+ provider_source?: "cli" | "profile" | "default";
581
587
  account?: string;
588
+ /** Explicit create account choices remain pinned during profile refreshes. */
589
+ account_source?: "cli" | "profile" | "balanced";
590
+ /** Reuse snapshots the worker profile's resolved settings for the new story. */
591
+ profile_settings_pinned?: boolean;
582
592
  /** Recent Claude account-selection evidence for a create or automatic resume move. */
583
593
  account_selection?: LaneAccountSelection;
584
594
  /** Eligible accounts for a queue entry created without an explicit account. */
@@ -231,7 +231,7 @@ export const LANE_FIELD_WRITERS = {
231
231
  pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
232
232
  priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], registry_worker: ["creator", "command", "spawn"], registry_worker_approval_history: ["command", "watcher", "reconcile"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
233
233
  keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile"], keep_alive_history: ["reconcile"], keep_alive_blocked_resume_streak: ["reconcile", "spawn", "watcher"], keep_alive_last_resume_at: ["command", "reconcile"], keep_alive_next_eligible_at: ["command", "reconcile"], keep_alive_exhausted_at: ["command", "reconcile"],
234
- provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], fallback_question_cancellation_failures: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
234
+ provider: ["creator", "command"], provider_source: ["creator", "command"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_source: ["creator", "command"], profile_settings_pinned: ["command"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], fallback_question_cancellation_failures: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
235
235
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command", "watcher", "reconcile"], story_switch_pending: ["command", "watcher", "reconcile"], story_switch_unacknowledged: ["watcher", "reconcile"],
236
236
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
237
237
  last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher", "reconcile"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
@@ -0,0 +1,32 @@
1
+ import type { FlagClient, FlagClientOptions } from "@indigoai-us/hq-flags-client";
2
+ export declare const SYNC_INVALID_ENTITY_WARNING_FLAG = "sync.warn-invalid-ontology-entities";
3
+ export declare const SYNC_INVALID_ENTITY_WARNING_FALLBACK = false;
4
+ export declare const SYNC_INVALID_ENTITY_WARNING_TIMEOUT_MS = 1500;
5
+ type FlagClientLike = Pick<FlagClient, "ready" | "isEnabled" | "close">;
6
+ export interface InvalidEntityFrontmatter {
7
+ path: string;
8
+ fields: string[];
9
+ }
10
+ export interface SyncInvalidEntityWarningDependencies {
11
+ env?: NodeJS.ProcessEnv;
12
+ createClient?: (options: FlagClientOptions) => FlagClientLike;
13
+ idToken?: string;
14
+ writeLine?: (line: string) => void;
15
+ }
16
+ /** Check only the entity directory that this sync push will upload. */
17
+ export declare function findInvalidOntologyEntities(args: {
18
+ companyRoot: string;
19
+ hqRoot: string;
20
+ }): InvalidEntityFrontmatter[];
21
+ export declare function companySlugFromSyncPaths(hqRoot: string, syncPaths: string[]): string | undefined;
22
+ /** Warns by path and field names only. Any diagnostic failure leaves sync running. */
23
+ export declare function warnAboutInvalidOntologyEntities(args: {
24
+ hqRoot: string;
25
+ companyRoot: string;
26
+ companyUid: string;
27
+ companySlug: string;
28
+ idToken?: string;
29
+ dependencies?: Omit<SyncInvalidEntityWarningDependencies, "idToken">;
30
+ }): Promise<void>;
31
+ export {};
32
+ //# sourceMappingURL=sync-invalid-entity-warning.d.ts.map
@@ -0,0 +1,205 @@
1
+ import { createFlagClient, readLocalOverride } from "@indigoai-us/hq-flags-client";
2
+ import { createHash } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as yaml from "js-yaml";
6
+ import { resolveFlagRegistryEndpoint } from "./flag-registry-endpoint.js";
7
+ export const SYNC_INVALID_ENTITY_WARNING_FLAG = "sync.warn-invalid-ontology-entities";
8
+ export const SYNC_INVALID_ENTITY_WARNING_FALLBACK = false;
9
+ export const SYNC_INVALID_ENTITY_WARNING_TIMEOUT_MS = 1500;
10
+ const ENTITY_TYPES = new Set([
11
+ "person", "project", "company", "concept", "product", "tool",
12
+ "tool/technology", "model", "platform", "contact", "deal", "contract",
13
+ "invoice", "account", "journal-entry", "ar-invoice", "ap-bill",
14
+ ]);
15
+ const REQUIRED_FIELDS = [
16
+ "id", "type", "canonical_name", "confidence", "status",
17
+ "domain", "first_seen", "last_updated", "signal_count",
18
+ ];
19
+ function isRecord(value) {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+ function validRequiredFields(frontmatter) {
23
+ const invalid = new Set();
24
+ for (const field of REQUIRED_FIELDS) {
25
+ if (!(field in frontmatter))
26
+ invalid.add(field);
27
+ }
28
+ if (typeof frontmatter.id !== "string" || frontmatter.id.length === 0) {
29
+ invalid.add("id");
30
+ }
31
+ if (typeof frontmatter.type !== "string" || !ENTITY_TYPES.has(frontmatter.type)) {
32
+ invalid.add("type");
33
+ }
34
+ if (typeof frontmatter.canonical_name !== "string" ||
35
+ frontmatter.canonical_name.length === 0) {
36
+ invalid.add("canonical_name");
37
+ }
38
+ if ("aliases" in frontmatter &&
39
+ (!Array.isArray(frontmatter.aliases) ||
40
+ !frontmatter.aliases.every((value) => typeof value === "string"))) {
41
+ invalid.add("aliases");
42
+ }
43
+ if (typeof frontmatter.confidence !== "number" ||
44
+ !Number.isFinite(frontmatter.confidence) ||
45
+ frontmatter.confidence < 0 ||
46
+ frontmatter.confidence > 1) {
47
+ invalid.add("confidence");
48
+ }
49
+ if (frontmatter.status !== "draft" && frontmatter.status !== "confirmed") {
50
+ invalid.add("status");
51
+ }
52
+ if (!Array.isArray(frontmatter.domain) ||
53
+ !frontmatter.domain.every((value) => typeof value === "string")) {
54
+ invalid.add("domain");
55
+ }
56
+ if (typeof frontmatter.first_seen !== "string" || frontmatter.first_seen.length === 0) {
57
+ invalid.add("first_seen");
58
+ }
59
+ if (typeof frontmatter.last_updated !== "string" ||
60
+ frontmatter.last_updated.length === 0) {
61
+ invalid.add("last_updated");
62
+ }
63
+ if (typeof frontmatter.signal_count !== "number" ||
64
+ !Number.isInteger(frontmatter.signal_count) ||
65
+ frontmatter.signal_count < 0) {
66
+ invalid.add("signal_count");
67
+ }
68
+ if (invalid.size === 0 &&
69
+ frontmatter.identity_version !== 2 &&
70
+ frontmatter.id !== createHash("sha256")
71
+ .update(`${frontmatter.type}:${frontmatter.canonical_name}`)
72
+ .digest("hex")) {
73
+ invalid.add("id");
74
+ }
75
+ return [...invalid].sort();
76
+ }
77
+ function parseInvalidEntityFile(filePath, displayPath) {
78
+ try {
79
+ const content = fs.readFileSync(filePath, "utf8");
80
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
81
+ if (!match)
82
+ return { path: displayPath, fields: ["_frontmatter"] };
83
+ // Match the ontology reader's YAML parser semantics: ISO date scalars stay
84
+ // strings so Zod's `z.string()` fields are checked as the server checks them.
85
+ const parsed = yaml.load(match[1], { schema: yaml.CORE_SCHEMA });
86
+ if (!isRecord(parsed))
87
+ return { path: displayPath, fields: ["_root"] };
88
+ const fields = validRequiredFields(parsed);
89
+ return fields.length ? { path: displayPath, fields } : null;
90
+ }
91
+ catch {
92
+ // YAML parser errors can embed user content; report the location only.
93
+ return { path: displayPath, fields: ["_frontmatter"] };
94
+ }
95
+ }
96
+ /** Check only the entity directory that this sync push will upload. */
97
+ export function findInvalidOntologyEntities(args) {
98
+ const entityRoot = path.join(args.companyRoot, "ontology", "entities");
99
+ if (!fs.existsSync(entityRoot))
100
+ return [];
101
+ const found = [];
102
+ const typeEntries = fs.readdirSync(entityRoot, { withFileTypes: true });
103
+ for (const typeEntry of typeEntries) {
104
+ if (!typeEntry.isDirectory())
105
+ continue;
106
+ const typeRoot = path.join(entityRoot, typeEntry.name);
107
+ for (const entityEntry of fs.readdirSync(typeRoot, { withFileTypes: true })) {
108
+ if (!entityEntry.isFile() || !entityEntry.name.endsWith(".md"))
109
+ continue;
110
+ const filePath = path.join(typeRoot, entityEntry.name);
111
+ const relativePath = path.relative(args.hqRoot, filePath).split(path.sep).join("/");
112
+ const invalid = parseInvalidEntityFile(filePath, relativePath);
113
+ if (invalid)
114
+ found.push(invalid);
115
+ }
116
+ }
117
+ return found.sort((a, b) => a.path.localeCompare(b.path));
118
+ }
119
+ export function companySlugFromSyncPaths(hqRoot, syncPaths) {
120
+ const companiesRoot = path.resolve(hqRoot, "companies");
121
+ for (const syncPath of syncPaths) {
122
+ const relative = path.relative(companiesRoot, path.resolve(syncPath));
123
+ if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
124
+ continue;
125
+ const slug = relative.split(path.sep)[0];
126
+ if (slug && slug !== ".")
127
+ return slug;
128
+ }
129
+ return undefined;
130
+ }
131
+ function safeErrorClass(error) {
132
+ const name = error instanceof Error ? error.name : "UnknownError";
133
+ return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
134
+ }
135
+ async function warningEnabled(args) {
136
+ const env = args.dependencies.env ?? process.env;
137
+ const override = readLocalOverride(SYNC_INVALID_ENTITY_WARNING_FLAG, env);
138
+ if (override !== null)
139
+ return override;
140
+ const companyUid = args.companyUid || env.HQ_COMPANY_UID?.trim() || "";
141
+ const companySlug = args.companySlug || env.HQ_COMPANY_SLUG?.trim() || "";
142
+ if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
143
+ return SYNC_INVALID_ENTITY_WARNING_FALLBACK;
144
+ }
145
+ const token = args.dependencies.idToken ?? "";
146
+ if (!token)
147
+ return SYNC_INVALID_ENTITY_WARNING_FALLBACK;
148
+ let client;
149
+ let failed = false;
150
+ try {
151
+ client = (args.dependencies.createClient ?? createFlagClient)({
152
+ endpoint: resolveFlagRegistryEndpoint(undefined, env),
153
+ companyUid,
154
+ ...(/^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(companySlug)
155
+ ? { companyIdentifiers: [companySlug] }
156
+ : {}),
157
+ env: {},
158
+ getToken: () => token,
159
+ refreshIntervalMs: 0,
160
+ requestTimeoutMs: SYNC_INVALID_ENTITY_WARNING_TIMEOUT_MS,
161
+ onError: (error) => {
162
+ failed = true;
163
+ (args.dependencies.writeLine ?? ((line) => process.stderr.write(line)))(`HQ Sync entity-warning flag lookup failed (${safeErrorClass(error)}); defaulting off.\n`);
164
+ },
165
+ });
166
+ await client.ready();
167
+ return !failed && client.isEnabled(SYNC_INVALID_ENTITY_WARNING_FLAG, {
168
+ fallback: SYNC_INVALID_ENTITY_WARNING_FALLBACK,
169
+ });
170
+ }
171
+ catch (error) {
172
+ (args.dependencies.writeLine ?? ((line) => process.stderr.write(line)))(`HQ Sync entity-warning flag lookup failed (${safeErrorClass(error)}); defaulting off.\n`);
173
+ return SYNC_INVALID_ENTITY_WARNING_FALLBACK;
174
+ }
175
+ finally {
176
+ client?.close();
177
+ }
178
+ }
179
+ /** Warns by path and field names only. Any diagnostic failure leaves sync running. */
180
+ export async function warnAboutInvalidOntologyEntities(args) {
181
+ try {
182
+ const enabled = await warningEnabled({
183
+ companyUid: args.companyUid,
184
+ companySlug: args.companySlug,
185
+ dependencies: { ...args.dependencies, idToken: args.idToken },
186
+ });
187
+ if (!enabled)
188
+ return;
189
+ const invalid = findInvalidOntologyEntities(args);
190
+ const writeLine = args.dependencies?.writeLine ?? ((line) => process.stderr.write(line));
191
+ for (const item of invalid) {
192
+ writeLine(`HQ Sync warning: ${item.path} has frontmatter rejected by ontology matching: ${item.fields.join(", ")}. Sync will continue and leave the file unchanged. Fix the entity if you want it included in ontology results.\n`);
193
+ }
194
+ }
195
+ catch (error) {
196
+ const writeLine = args.dependencies?.writeLine ?? ((line) => process.stderr.write(line));
197
+ try {
198
+ writeLine(`HQ Sync entity diagnostic failed (${safeErrorClass(error)}); sync will continue.\n`);
199
+ }
200
+ catch {
201
+ // A closed stderr must not prevent the upload from preserving user data.
202
+ }
203
+ }
204
+ }
205
+ //# sourceMappingURL=sync-invalid-entity-warning.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.329.0",
3
+ "version": "5.330.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {