@indigoai-us/hq-cli 5.236.0 → 5.238.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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.238.0] — 2026-09-27
6
+
7
+ ### Added: Lane orphan reporting
8
+
9
+ - `hq lanes checks` includes an orphaned-lane check. The company-scoped
10
+ `hq lanes orphans` report lists candidates without changing their state. Add
11
+ `--stop --reason <text>` to stop candidates that remain orphaned on recheck
12
+ and record the reason in lane history.
13
+
14
+ ## [5.237.0] — 2026-09-27
15
+
16
+ ### Added
17
+
18
+ - Lane-store creates and updates record their hq-cli version. User-invoked
19
+ writes from older CLIs are refused, while background writers preserve newer
20
+ stamps and record Outpost owner versions separately. `hq lanes checks` warns
21
+ about newer stamps, and `HQ_LANES_ALLOW_OLDER_WRITE=1` provides a one-command
22
+ override with a warning.
23
+
5
24
  ## [5.236.0] — 2026-09-27
6
25
 
7
26
  ### Changed
@@ -7300,6 +7300,33 @@ export declare const COMMAND_CATALOG: readonly [{
7300
7300
  }, {
7301
7301
  readonly flags: "--project <slug>";
7302
7302
  readonly description: "Limit checks to one company project";
7303
+ }, {
7304
+ readonly flags: "--min-age <minutes>";
7305
+ readonly description: "Minimum orphan lane age in minutes (default: 30)";
7306
+ }, {
7307
+ readonly flags: "--json";
7308
+ readonly description: "JSON on stdout (always)";
7309
+ }];
7310
+ readonly subcommands: readonly [];
7311
+ }, {
7312
+ readonly name: "orphans";
7313
+ readonly description: "Report lanes whose lane or session senior has ended";
7314
+ readonly aliases: readonly [];
7315
+ readonly hidden: false;
7316
+ readonly usage: "[options]";
7317
+ readonly arguments: readonly [];
7318
+ readonly options: readonly [{
7319
+ readonly flags: "--company <slug>";
7320
+ readonly description: "Select the company by slug";
7321
+ }, {
7322
+ readonly flags: "--min-age <minutes>";
7323
+ readonly description: "Minimum orphan lane age in minutes (default: 30)";
7324
+ }, {
7325
+ readonly flags: "--stop";
7326
+ readonly description: "Stop the reported lanes through their normal owner path";
7327
+ }, {
7328
+ readonly flags: "--reason <text>";
7329
+ readonly description: "Required audit reason when --stop is set";
7303
7330
  }, {
7304
7331
  readonly flags: "--json";
7305
7332
  readonly description: "JSON on stdout (always)";
@@ -8159,6 +8186,9 @@ export declare const COMMAND_CATALOG: readonly [{
8159
8186
  readonly variadic: false;
8160
8187
  }];
8161
8188
  readonly options: readonly [{
8189
+ readonly flags: "--reason <text>";
8190
+ readonly description: "Record the reason in the lane's terminal history";
8191
+ }, {
8162
8192
  readonly flags: "--json";
8163
8193
  readonly description: "JSON on stdout (always)";
8164
8194
  }];
@@ -9436,6 +9436,41 @@ export const COMMAND_CATALOG = [
9436
9436
  "flags": "--project <slug>",
9437
9437
  "description": "Limit checks to one company project"
9438
9438
  },
9439
+ {
9440
+ "flags": "--min-age <minutes>",
9441
+ "description": "Minimum orphan lane age in minutes (default: 30)"
9442
+ },
9443
+ {
9444
+ "flags": "--json",
9445
+ "description": "JSON on stdout (always)"
9446
+ }
9447
+ ],
9448
+ "subcommands": []
9449
+ },
9450
+ {
9451
+ "name": "orphans",
9452
+ "description": "Report lanes whose lane or session senior has ended",
9453
+ "aliases": [],
9454
+ "hidden": false,
9455
+ "usage": "[options]",
9456
+ "arguments": [],
9457
+ "options": [
9458
+ {
9459
+ "flags": "--company <slug>",
9460
+ "description": "Select the company by slug"
9461
+ },
9462
+ {
9463
+ "flags": "--min-age <minutes>",
9464
+ "description": "Minimum orphan lane age in minutes (default: 30)"
9465
+ },
9466
+ {
9467
+ "flags": "--stop",
9468
+ "description": "Stop the reported lanes through their normal owner path"
9469
+ },
9470
+ {
9471
+ "flags": "--reason <text>",
9472
+ "description": "Required audit reason when --stop is set"
9473
+ },
9439
9474
  {
9440
9475
  "flags": "--json",
9441
9476
  "description": "JSON on stdout (always)"
@@ -10555,6 +10590,10 @@ export const COMMAND_CATALOG = [
10555
10590
  }
10556
10591
  ],
10557
10592
  "options": [
10593
+ {
10594
+ "flags": "--reason <text>",
10595
+ "description": "Record the reason in the lane's terminal history"
10596
+ },
10558
10597
  {
10559
10598
  "flags": "--json",
10560
10599
  "description": "JSON on stdout (always)"
@@ -6,6 +6,8 @@ export interface LaneChecksDeps {
6
6
  resolveCompany: (company?: string) => Promise<LaneCompanyRef>;
7
7
  trackerEnabled: (company: LaneCompanyRef) => boolean | Promise<boolean>;
8
8
  now: () => Date;
9
+ stopLane?: (laneId: string, reason: string) => Promise<void>;
10
+ sessionState?: (root: string, sessionId: string, now: Date) => string | null;
9
11
  }
10
12
  type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
11
13
  export interface ProjectMapping {
@@ -1,10 +1,14 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { CLI_VERSION } from "../cli-version.js";
3
4
  import { loadLanesConfig } from "../lib/lanes/config.js";
4
5
  import { LanesError, isLanesError } from "../lib/lanes/errors.js";
5
6
  import { evaluateTrackerChecks } from "../lib/lanes/tracker-checks.js";
6
- import { laneRecordPath, listLaneIds, listQuestionIds, questionRecordPath, readLane, readQuestion } from "../lib/lanes/store.js";
7
+ import { LANE_STORE_UPGRADE_COMMAND, isLaneStoreVersionNewer, laneRecordPath, listLaneIds, listQuestionIds, questionRecordPath, readLane, readQuestion } from "../lib/lanes/store.js";
7
8
  import { createLocalTrackerStore } from "../lib/lanes/tracker-store.js";
9
+ import { sessionLiveness } from "../lib/lanes/liveness.js";
10
+ import { findOrphanLanes, parseOrphanMinAge } from "../lib/lanes/orphans.js";
11
+ import { assertNoSecretText } from "../lib/lanes/text-scan.js";
8
12
  import { LANES_TRACKER_FLAG } from "./lanes-tracker.js";
9
13
  function fail(error, message, details = {}) {
10
14
  throw new LanesError(error, message, details);
@@ -319,6 +323,47 @@ function readScopedLanes(root, company, projects) {
319
323
  return [readLane(root, id)];
320
324
  });
321
325
  }
326
+ function readCompanyLanes(root, company, projectId) {
327
+ return listLaneIds(root).flatMap((id) => {
328
+ const file = laneRecordPath(root, id);
329
+ const metadata = readJsonObject(file);
330
+ if (metadata === null)
331
+ return fail("tracker_checks_input_unreadable", "Lane record could not be read while checking its scope", { path: file });
332
+ const laneCompany = isObject(metadata.company) ? metadata.company : null;
333
+ if (laneCompany?.slug !== company.slug || laneCompany.uid !== company.uid)
334
+ return [];
335
+ if (projectId !== undefined && !laneMetadataMatchesScope(metadata, company, new Set([projectId])))
336
+ return [];
337
+ return [readLane(root, id)];
338
+ });
339
+ }
340
+ function orphanSessionState(root, sessionId, now, deps) {
341
+ if (deps.sessionState)
342
+ return deps.sessionState(root, sessionId, now);
343
+ return sessionLiveness(root, sessionId, { nowMs: now.getTime() });
344
+ }
345
+ function orphanFindings(root, company, now, minAgeMinutes, deps, projectId) {
346
+ const candidates = readCompanyLanes(root, company, projectId);
347
+ const lanes = [...candidates];
348
+ const known = new Set(lanes.map((lane) => lane.lane_id));
349
+ for (const candidate of candidates) {
350
+ if (candidate.senior.kind !== "lane" || candidate.senior.id === undefined || known.has(candidate.senior.id))
351
+ continue;
352
+ const file = laneRecordPath(root, candidate.senior.id);
353
+ const metadata = readJsonObject(file, "empty");
354
+ const laneCompany = metadata && isObject(metadata.company) ? metadata.company : null;
355
+ if (laneCompany?.slug !== company.slug || laneCompany.uid !== company.uid)
356
+ continue;
357
+ lanes.push(readLane(root, candidate.senior.id));
358
+ known.add(candidate.senior.id);
359
+ }
360
+ return findOrphanLanes({
361
+ lanes,
362
+ now,
363
+ minAgeMinutes,
364
+ sessionState: (sessionId, at) => orphanSessionState(root, sessionId, at, deps),
365
+ }).filter((finding) => candidates.some((lane) => lane.lane_id === finding.lane_id));
366
+ }
322
367
  function sameCompanyLane(lane, company) {
323
368
  return lane.company?.slug === company.slug && lane.company.uid === company.uid;
324
369
  }
@@ -572,9 +617,28 @@ export async function resolveTrackerCompany(deps, requested) {
572
617
  }
573
618
  return company;
574
619
  }
575
- async function checkReport(root, company, projects, directiveProjects, projectFilter, now) {
620
+ async function checkReport(root, company, projects, directiveProjects, projectFilter, minAgeMinutes, now, deps) {
576
621
  const selectedProjects = [...projects.values()];
577
622
  const laneRecords = readScopedLanes(root, company, selectedProjects);
623
+ const warnings = laneRecords.flatMap((lane) => {
624
+ const storedVersion = [lane.written_by_version, lane.owner_written_by_version]
625
+ .filter((version) => version !== undefined)
626
+ .reduce((newest, candidate) => {
627
+ if (newest === undefined || isLaneStoreVersionNewer(candidate, newest))
628
+ return candidate;
629
+ return newest;
630
+ }, undefined);
631
+ return storedVersion !== undefined && isLaneStoreVersionNewer(storedVersion, CLI_VERSION)
632
+ ? [{
633
+ code: "lane_store_newer_cli",
634
+ lane_id: lane.lane_id,
635
+ stored_version: storedVersion,
636
+ running_version: CLI_VERSION,
637
+ upgrade_command: LANE_STORE_UPGRADE_COMMAND,
638
+ message: `Lane ${lane.lane_id} has a local or owner-host copy last written by hq-cli ${storedVersion}, newer than running hq-cli ${CLI_VERSION}. Upgrade before writing.`
639
+ }]
640
+ : [];
641
+ });
578
642
  const teams = readTeamInputs(root, company, selectedProjects, laneRecords);
579
643
  const questionRecords = readCompanyQuestions(root, company, projectFilter);
580
644
  const trackerRecords = await readTrackerCheckRecords(root, company, selectedProjects);
@@ -582,38 +646,113 @@ async function checkReport(root, company, projects, directiveProjects, projectFi
582
646
  team: project.team,
583
647
  ...(project.leadLaneId === undefined ? {} : { lead_lane_id: project.leadLaneId }),
584
648
  }));
649
+ const checks = evaluateTrackerChecks({
650
+ now,
651
+ thresholds: loadLanesConfig(root).tracker.checks,
652
+ questions: questionRecords,
653
+ teams,
654
+ ...trackerRecords,
655
+ directives: trackerRecords.directives,
656
+ directive_teams: directiveTeams,
657
+ });
658
+ const orphanedLanes = orphanFindings(root, company, now, minAgeMinutes, deps, projectFilter);
659
+ checks.push({
660
+ id: "orphaned_lane",
661
+ result: orphanedLanes.length === 0 ? "ok" : "failing",
662
+ items: orphanedLanes.map((finding) => ({
663
+ id: finding.lane_id,
664
+ reason: finding.senior_state === "missing" ? "missing_senior" :
665
+ finding.senior_state === "ended" ? "ended_session_senior" : "terminal_senior",
666
+ senior: finding.senior,
667
+ senior_state: finding.senior_state,
668
+ age_minutes: finding.age_minutes,
669
+ account: finding.account,
670
+ host: finding.host,
671
+ })),
672
+ });
585
673
  return {
586
674
  ok: true,
587
675
  company: company.slug,
588
676
  generated_at: now.toISOString(),
589
- checks: evaluateTrackerChecks({
590
- now,
591
- thresholds: loadLanesConfig(root).tracker.checks,
592
- questions: questionRecords,
593
- teams,
594
- ...trackerRecords,
595
- directives: trackerRecords.directives,
596
- directive_teams: directiveTeams,
597
- }),
677
+ ...(warnings.length === 0 ? {} : { warnings }),
678
+ checks,
598
679
  };
599
680
  }
681
+ async function orphanReport(root, company, now, minAgeMinutes, deps, options) {
682
+ const reason = options.reason?.trim();
683
+ if (options.stop && !reason) {
684
+ fail("orphan_stop_reason_required", "--stop requires a non-empty --reason", {});
685
+ }
686
+ if (!options.stop && options.reason !== undefined) {
687
+ fail("orphan_reason_requires_stop", "--reason can only be used with --stop", {});
688
+ }
689
+ if (reason !== undefined)
690
+ assertNoSecretText("stop reason", reason);
691
+ const orphans = orphanFindings(root, company, now, minAgeMinutes, deps);
692
+ if (!options.stop) {
693
+ console.log(JSON.stringify({
694
+ ok: true,
695
+ action: "orphans",
696
+ company: company.slug,
697
+ generated_at: now.toISOString(),
698
+ min_age_minutes: minAgeMinutes,
699
+ orphans,
700
+ }, null, 2));
701
+ return;
702
+ }
703
+ if (!deps.stopLane)
704
+ fail("orphan_stop_unavailable", "The normal lane stop path is unavailable", {});
705
+ const stopped = [];
706
+ for (const finding of orphans) {
707
+ const fresh = orphanFindings(root, company, deps.now(), minAgeMinutes, deps)
708
+ .find((candidate) => candidate.lane_id === finding.lane_id);
709
+ if (!fresh)
710
+ continue;
711
+ await deps.stopLane(finding.lane_id, reason);
712
+ stopped.push({ lane_id: finding.lane_id, state: readLane(root, finding.lane_id).state });
713
+ }
714
+ console.log(JSON.stringify({
715
+ ok: true,
716
+ action: "orphans.stop",
717
+ company: company.slug,
718
+ generated_at: now.toISOString(),
719
+ min_age_minutes: minAgeMinutes,
720
+ reason,
721
+ orphans,
722
+ stopped,
723
+ }, null, 2));
724
+ }
600
725
  export function registerLaneChecksCommand(parent, deps, wrap) {
601
726
  parent.command("checks")
602
727
  .description("Evaluate tracker and lane health checks")
603
728
  .option("--company <slug>", "Select the company by slug")
604
729
  .option("--project <slug>", "Limit checks to one company project")
730
+ .option("--min-age <minutes>", "Minimum orphan lane age in minutes (default: 30)")
605
731
  .action(wrap(async (options) => {
606
732
  const company = await resolveTrackerCompany(deps, options.company);
607
733
  const root = deps.hqRoot();
608
734
  const projectFilter = options.project === undefined ? undefined : validSegment(options.project, "project");
735
+ const minAgeMinutes = parseOrphanMinAge(options.minAge);
609
736
  const projects = loadProjectMappings(root, company.slug, projectFilter);
610
737
  const directiveProjects = projectFilter === undefined
611
738
  ? projects
612
739
  : loadProjectMappings(root, company.slug);
613
- const report = await checkReport(root, company, projects, directiveProjects, projectFilter, deps.now());
740
+ const report = await checkReport(root, company, projects, directiveProjects, projectFilter, minAgeMinutes, deps.now(), deps);
614
741
  console.log(JSON.stringify(report, null, 2));
615
742
  if (report.checks.some((item) => item.result === "failing"))
616
743
  process.exitCode = 2;
617
744
  }));
745
+ parent.command("orphans")
746
+ .description("Report lanes whose lane or session senior has ended")
747
+ .option("--company <slug>", "Select the company by slug")
748
+ .option("--min-age <minutes>", "Minimum orphan lane age in minutes (default: 30)")
749
+ .option("--stop", "Stop the reported lanes through their normal owner path")
750
+ .option("--reason <text>", "Required audit reason when --stop is set")
751
+ .action(wrap(async (options) => {
752
+ const company = await deps.resolveCompany(options.company);
753
+ const root = deps.hqRoot();
754
+ const minAgeMinutes = parseOrphanMinAge(options.minAge);
755
+ await orphanReport(root, company, deps.now(), minAgeMinutes, deps, options);
756
+ }));
618
757
  }
619
758
  //# sourceMappingURL=lanes-checks.js.map
@@ -3462,6 +3462,10 @@ function outpostRejectedFailedResumeOption(result) {
3462
3462
  return result.exitCode !== 0 &&
3463
3463
  /unknown option\s+(?:['"])?--(?:from-failed|failed-resume-context)(?:['"])?(?:\s|$)/i.test(`${result.stderr}\n${result.stdout}`);
3464
3464
  }
3465
+ function outpostRejectedStopReasonOption(result) {
3466
+ return result.exitCode !== 0 &&
3467
+ /unknown option\s+(?:['"])?--reason(?:['"])?(?:\s|$)/i.test(`${result.stderr}\n${result.stdout}`);
3468
+ }
3465
3469
  function verifyOutpostProfileContent(hqRoot, lane) {
3466
3470
  if (lane.host.kind !== "outpost" || lane.worker_profile === undefined)
3467
3471
  return;
@@ -7635,7 +7639,7 @@ async function readOutpostTerminalLane(ctx, laneId, outpostId, stopCommandId) {
7635
7639
  commandId,
7636
7640
  };
7637
7641
  }
7638
- async function dispatchOutpostLaneStop(ctx, lane) {
7642
+ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
7639
7643
  if (lane.host.kind !== "outpost") {
7640
7644
  throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch stop to`, { lane_id: lane.lane_id });
7641
7645
  }
@@ -7644,7 +7648,7 @@ async function dispatchOutpostLaneStop(ctx, lane) {
7644
7648
  try {
7645
7649
  dispatched = await ctx.dispatchOutpostExec({
7646
7650
  outpostId,
7647
- command: buildOutpostLaneCommand({ action: "stop", laneId: lane.lane_id }),
7651
+ command: buildOutpostLaneCommand({ action: "stop", laneId: lane.lane_id, reason }),
7648
7652
  timeoutSeconds: 120,
7649
7653
  waitMs: 120_000,
7650
7654
  });
@@ -7652,6 +7656,13 @@ async function dispatchOutpostLaneStop(ctx, lane) {
7652
7656
  catch (err) {
7653
7657
  throw new LanesError("outpost_dispatch_failed", `Could not dispatch stop for lane ${lane.lane_id} to Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, outpost_id: outpostId });
7654
7658
  }
7659
+ if (reason !== undefined && outpostRejectedStopReasonOption(dispatched)) {
7660
+ throw new LanesError("outpost_cli_upgrade_required", `Outpost ${outpostId} needs an hq-cli release containing stop reason support before it can stop lane ${lane.lane_id}`, {
7661
+ lane_id: lane.lane_id,
7662
+ outpost_id: outpostId,
7663
+ required_option: "--reason",
7664
+ });
7665
+ }
7655
7666
  const ownerError = ownerErrorFromOutput(dispatched.stdout);
7656
7667
  if (ownerError) {
7657
7668
  throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} rejected the stop command: ${ownerError.message}`, {
@@ -7742,27 +7753,34 @@ async function dispatchOutpostLaneStop(ctx, lane) {
7742
7753
  cancelledQuestions = parseRemoteCancelledQuestionIds(remote.cancelled_questions, lane.lane_id, outpostId, commandId);
7743
7754
  }
7744
7755
  const updated = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, ownerLane);
7745
- printJson({
7746
- ok: true,
7747
- action: "stop",
7748
- lane: updated,
7749
- cancelled_questions: cancelledQuestions,
7750
- outpost_id: outpostId,
7751
- outpost_command_id: commandId,
7752
- ...(syncCommandId ? { outpost_sync_command_id: syncCommandId } : {}),
7753
- outpost_result: {
7754
- state,
7755
- already_terminal: alreadyTerminal,
7756
- status: dispatched.status,
7757
- exit_code: dispatched.exitCode,
7758
- },
7759
- });
7756
+ if (!quiet)
7757
+ printJson({
7758
+ ok: true,
7759
+ action: "stop",
7760
+ lane: updated,
7761
+ cancelled_questions: cancelledQuestions,
7762
+ outpost_id: outpostId,
7763
+ outpost_command_id: commandId,
7764
+ ...(syncCommandId ? { outpost_sync_command_id: syncCommandId } : {}),
7765
+ outpost_result: {
7766
+ state,
7767
+ already_terminal: alreadyTerminal,
7768
+ status: dispatched.status,
7769
+ exit_code: dispatched.exitCode,
7770
+ },
7771
+ });
7760
7772
  }
7761
- async function runStop(ctx, laneId) {
7773
+ async function runStop(ctx, laneId, reason, quiet = false) {
7774
+ const operatorReason = reason?.trim();
7775
+ if (reason !== undefined && !operatorReason) {
7776
+ throw new LanesError("lane_stop_reason_invalid", "Stop reason must not be empty", {});
7777
+ }
7778
+ if (operatorReason !== undefined)
7779
+ assertNoSecretText("stop reason", operatorReason);
7762
7780
  const hqRoot = ctx.hqRoot();
7763
7781
  const lane = readLane(hqRoot, laneId);
7764
7782
  if (lane.host.kind === "outpost" && !laneRunsHereForOperatorMutation(ctx, lane)) {
7765
- await dispatchOutpostLaneStop(ctx, lane);
7783
+ await dispatchOutpostLaneStop(ctx, lane, operatorReason, quiet);
7766
7784
  return;
7767
7785
  }
7768
7786
  assertLaneRunsHereForOperatorMutation(ctx, lane, "stop");
@@ -7779,6 +7797,16 @@ async function runStop(ctx, laneId) {
7779
7797
  ...current,
7780
7798
  state: stopCommittedState(current.state),
7781
7799
  queued_reason: undefined,
7800
+ ...(operatorReason === undefined ? {} : {
7801
+ stop_history: [
7802
+ ...(current.stop_history ?? []),
7803
+ { at, reason: operatorReason },
7804
+ ],
7805
+ terminal_details: {
7806
+ ...(current.terminal_details ?? {}),
7807
+ operator_reason: operatorReason,
7808
+ },
7809
+ }),
7782
7810
  };
7783
7811
  });
7784
7812
  if (updated.state === "killed") {
@@ -7790,12 +7818,13 @@ async function runStop(ctx, laneId) {
7790
7818
  }
7791
7819
  await emitLaneTerminalStateSoft(ctx, updated);
7792
7820
  const cancelled = cancelPendingQuestionsForLane(hqRoot, laneId, at);
7793
- printJson({
7794
- ok: true,
7795
- action: "stop",
7796
- lane: updated,
7797
- cancelled_questions: cancelled.map((q) => q.question_id),
7798
- });
7821
+ if (!quiet)
7822
+ printJson({
7823
+ ok: true,
7824
+ action: "stop",
7825
+ lane: updated,
7826
+ cancelled_questions: cancelled.map((q) => q.question_id),
7827
+ });
7799
7828
  }
7800
7829
  async function runAsk(ctx, opts) {
7801
7830
  const hqRoot = ctx.hqRoot();
@@ -9206,6 +9235,12 @@ export function registerLanesCommand(parent, deps = {}) {
9206
9235
  resolveCompany: ctx.resolveCompany,
9207
9236
  trackerEnabled: ctx.laneTrackerEnabled,
9208
9237
  now: ctx.now,
9238
+ stopLane: (laneId, reason) => runStop(ctx, laneId, reason, true),
9239
+ sessionState: (root, sessionId, now) => sessionLiveness(root, sessionId, {
9240
+ nowMs: now.getTime(),
9241
+ presenceConnected: ctx.presenceConnected ?? false,
9242
+ deps: ctx.livenessDeps,
9243
+ }),
9209
9244
  }, wrap);
9210
9245
  registerLaneReportCommands(lanes, {
9211
9246
  hqRoot: ctx.hqRoot,
@@ -9542,8 +9577,9 @@ export function registerLanesCommand(parent, deps = {}) {
9542
9577
  .command("stop")
9543
9578
  .description("Cancel a queued lane or kill a running one")
9544
9579
  .argument("<lane-id>", "Lane id")
9545
- .action(wrap(async (laneId) => {
9546
- await runStop(ctx, laneId);
9580
+ .option("--reason <text>", "Record the reason in the lane's terminal history")
9581
+ .action(wrap(async (laneId, options) => {
9582
+ await runStop(ctx, laneId, options.reason);
9547
9583
  }));
9548
9584
  lanes
9549
9585
  .command("ask")
@@ -0,0 +1,18 @@
1
+ import { type LaneRecord, type SeniorRef } from "./types.js";
2
+ export declare const DEFAULT_ORPHAN_MIN_AGE_MINUTES = 30;
3
+ export interface OrphanLaneFinding {
4
+ lane_id: string;
5
+ senior: SeniorRef;
6
+ senior_state: string;
7
+ age_minutes: number;
8
+ account: string | null;
9
+ host: LaneRecord["host"];
10
+ }
11
+ export declare function parseOrphanMinAge(value: string | undefined): number;
12
+ export declare function findOrphanLanes(input: {
13
+ lanes: readonly LaneRecord[];
14
+ now: Date;
15
+ minAgeMinutes: number;
16
+ sessionState: (sessionId: string, now: Date) => string | null;
17
+ }): OrphanLaneFinding[];
18
+ //# sourceMappingURL=orphans.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { LanesError } from "./errors.js";
2
+ import { isTerminalLaneState } from "./types.js";
3
+ export const DEFAULT_ORPHAN_MIN_AGE_MINUTES = 30;
4
+ const LIVE_LANE_STATES = new Set(["running", "queued", "blocked", "awaiting_input"]);
5
+ export function parseOrphanMinAge(value) {
6
+ if (value === undefined)
7
+ return DEFAULT_ORPHAN_MIN_AGE_MINUTES;
8
+ if (!/^(0|[1-9]\d*)$/.test(value)) {
9
+ throw new LanesError("orphan_min_age_invalid", "Minimum orphan age must be a non-negative whole number of minutes", { value });
10
+ }
11
+ const minutes = Number(value);
12
+ if (!Number.isSafeInteger(minutes) || minutes > Math.floor(Number.MAX_SAFE_INTEGER / 60_000)) {
13
+ throw new LanesError("orphan_min_age_invalid", "Minimum orphan age is outside the supported range", { value });
14
+ }
15
+ return minutes;
16
+ }
17
+ export function findOrphanLanes(input) {
18
+ const nowMs = input.now.getTime();
19
+ if (!Number.isFinite(nowMs)) {
20
+ throw new LanesError("orphan_clock_invalid", "Current time for orphan detection is invalid", {});
21
+ }
22
+ const byId = new Map(input.lanes.map((lane) => [lane.lane_id, lane]));
23
+ const minAgeMs = input.minAgeMinutes * 60_000;
24
+ const findings = [];
25
+ for (const lane of input.lanes) {
26
+ if (!LIVE_LANE_STATES.has(lane.state))
27
+ continue;
28
+ if (lane.keep_alive_standing === true || lane.senior.kind === "user")
29
+ continue;
30
+ const createdAt = Date.parse(lane.created_at);
31
+ if (!Number.isFinite(createdAt)) {
32
+ throw new LanesError("orphan_lane_timestamp_invalid", `Lane ${lane.lane_id} has an invalid created_at timestamp`, { lane_id: lane.lane_id, field: "created_at" });
33
+ }
34
+ const ageMs = nowMs - createdAt;
35
+ if (ageMs < 0) {
36
+ throw new LanesError("orphan_lane_timestamp_future", `Lane ${lane.lane_id} was created after the current time`, { lane_id: lane.lane_id, created_at: lane.created_at });
37
+ }
38
+ if (ageMs < minAgeMs)
39
+ continue;
40
+ let seniorState;
41
+ if (lane.senior.kind === "lane") {
42
+ const senior = byId.get(lane.senior.id ?? "");
43
+ if (!senior) {
44
+ seniorState = "missing";
45
+ }
46
+ else if (isTerminalLaneState(senior.state)) {
47
+ seniorState = senior.state;
48
+ }
49
+ }
50
+ else if (lane.senior.kind === "session" && lane.senior.id !== undefined) {
51
+ if (input.sessionState(lane.senior.id, input.now) === "ended")
52
+ seniorState = "ended";
53
+ }
54
+ if (seniorState === undefined)
55
+ continue;
56
+ findings.push({
57
+ lane_id: lane.lane_id,
58
+ senior: { ...lane.senior },
59
+ senior_state: seniorState,
60
+ age_minutes: Math.floor(ageMs / 60_000),
61
+ account: lane.account ?? null,
62
+ host: { ...lane.host },
63
+ });
64
+ }
65
+ return findings.sort((a, b) => b.age_minutes - a.age_minutes || a.lane_id.localeCompare(b.lane_id));
66
+ }
67
+ //# sourceMappingURL=orphans.js.map
@@ -52,6 +52,9 @@ export declare function tryReadLane(hqRoot: string, laneId: string, deps?: Store
52
52
  export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
53
53
  export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
54
54
  export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
55
+ /** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
56
+ export declare function isLaneStoreVersionNewer(storedVersion: string, runningVersion: string): boolean;
57
+ export declare const LANE_STORE_UPGRADE_COMMAND = "npm install -g @indigoai-us/hq-cli@latest";
55
58
  export declare function createLane(hqRoot: string, init: Omit<LaneRecord, "schema_version" | "lane_id" | "version" | "created_at" | "updated_at"> & Partial<Pick<LaneRecord, "schema_version" | "lane_id" | "version" | "created_at" | "updated_at">>, deps?: StoreDeps): LaneRecord;
56
59
  /** Hierarchy lock: separate from record writes so callers can CAS-write while holding it. */
57
60
  export declare function withLaneHireLocks<T>(hqRoot: string, laneIds: readonly string[], fn: () => T, deps?: StoreDeps): T;
@@ -10,6 +10,8 @@ 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 semver from "semver";
14
+ import { CLI_VERSION } from "../../cli-version.js";
13
15
  import { generateUlid } from "../mesh/live/ulid.js";
14
16
  import { resolveLanesHqRoot } from "./hq-root.js";
15
17
  import { LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesError, isLaneVersionConflict, } from "./errors.js";
@@ -391,6 +393,26 @@ export function parseLaneRecord(raw, filePath) {
391
393
  const schema_version = requireString(raw.schema_version, "schema_version");
392
394
  parseMajorSchema(schema_version, "hq-lane", 1);
393
395
  requireString(raw.lane_id, "lane_id");
396
+ if (raw.written_by_version !== undefined) {
397
+ const writtenByVersion = requireString(raw.written_by_version, "written_by_version");
398
+ if (!semver.valid(writtenByVersion)) {
399
+ throw new LaneSchemaError("written_by_version must be a valid semver version", {
400
+ path: filePath,
401
+ field: "written_by_version",
402
+ value: writtenByVersion,
403
+ });
404
+ }
405
+ }
406
+ if (raw.owner_written_by_version !== undefined) {
407
+ const ownerWrittenByVersion = requireString(raw.owner_written_by_version, "owner_written_by_version");
408
+ if (!semver.valid(ownerWrittenByVersion)) {
409
+ throw new LaneSchemaError("owner_written_by_version must be a valid semver version", {
410
+ path: filePath,
411
+ field: "owner_written_by_version",
412
+ value: ownerWrittenByVersion,
413
+ });
414
+ }
415
+ }
394
416
  requireInt(raw.version, "version");
395
417
  requireInt(raw.depth, "depth");
396
418
  requireInt(raw.priority, "priority");
@@ -569,6 +591,24 @@ export function parseLaneRecord(raw, filePath) {
569
591
  path: filePath,
570
592
  });
571
593
  }
594
+ if (raw.stop_history !== undefined) {
595
+ if (!Array.isArray(raw.stop_history)) {
596
+ throw new LaneSchemaError("stop_history must be an array when present", {
597
+ path: filePath,
598
+ field: "stop_history",
599
+ });
600
+ }
601
+ for (const [index, entry] of raw.stop_history.entries()) {
602
+ const field = `stop_history[${index}]`;
603
+ if (!isPlainObject(entry) || Object.keys(entry).some((key) => !["at", "reason"].includes(key))) {
604
+ throw new LaneSchemaError(`${field} must be a stop history entry`, { path: filePath, field });
605
+ }
606
+ if (typeof entry.reason !== "string" || entry.reason.trim() === "") {
607
+ throw new LaneSchemaError(`${field}.reason must be a non-empty string`, { path: filePath, field });
608
+ }
609
+ requireTimestamp(entry.at, `${field}.at`, filePath);
610
+ }
611
+ }
572
612
  if (raw.worker_profile_repin_history !== undefined) {
573
613
  if (!Array.isArray(raw.worker_profile_repin_history)) {
574
614
  throw new LaneSchemaError("worker_profile_repin_history must be an array when present", {
@@ -885,6 +925,11 @@ export function mergeLaneRecords(live, sidecars) {
885
925
  failed_resume_history: unionHistory(all.map((record) => record.failed_resume_history ?? []), entryIdentity, base.failed_resume_history ?? []),
886
926
  }
887
927
  : {}),
928
+ ...(all.some((record) => record.stop_history !== undefined)
929
+ ? {
930
+ stop_history: unionHistory(all.map((record) => record.stop_history ?? []), entryIdentity, base.stop_history ?? []),
931
+ }
932
+ : {}),
888
933
  stories: mergeLaneStories(all, base),
889
934
  };
890
935
  }
@@ -950,6 +995,52 @@ export function readQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
950
995
  throw new LaneNotFoundError("question", questionId);
951
996
  return record;
952
997
  }
998
+ /** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
999
+ export function isLaneStoreVersionNewer(storedVersion, runningVersion) {
1000
+ if (!semver.valid(storedVersion)) {
1001
+ throw new LanesError("lane_store_version_invalid", "Lane store writer version is not valid semver", {
1002
+ stored_version: storedVersion,
1003
+ });
1004
+ }
1005
+ if (!semver.valid(runningVersion)) {
1006
+ throw new LanesError("lane_store_version_invalid", "Running hq-cli version is not valid semver", {
1007
+ running_version: runningVersion,
1008
+ });
1009
+ }
1010
+ return semver.gt(storedVersion, runningVersion);
1011
+ }
1012
+ const OLDER_LANE_STORE_WRITE_OVERRIDE = "HQ_LANES_ALLOW_OLDER_WRITE";
1013
+ export const LANE_STORE_UPGRADE_COMMAND = "npm install -g @indigoai-us/hq-cli@latest";
1014
+ const warnedBackgroundWriterLaneIds = new Set();
1015
+ function assertLaneStoreWriteAllowed(lane, laneId, writer) {
1016
+ const storedVersion = lane.written_by_version;
1017
+ if (storedVersion === undefined || !isLaneStoreVersionNewer(storedVersion, CLI_VERSION))
1018
+ return;
1019
+ const warning = `Lane ${laneId} was last written by hq-cli ${storedVersion}, newer than running hq-cli ${CLI_VERSION}. Upgrade with \`${LANE_STORE_UPGRADE_COMMAND}\` before writing.`;
1020
+ if (writer === "command" || writer === "creator") {
1021
+ if (process.env[OLDER_LANE_STORE_WRITE_OVERRIDE] === "1") {
1022
+ console.warn(`${warning} Proceeding because ${OLDER_LANE_STORE_WRITE_OVERRIDE}=1 was set for this command.`);
1023
+ return;
1024
+ }
1025
+ throw new LanesError("lane_store_newer_cli", `${warning} For a one-command override, set ${OLDER_LANE_STORE_WRITE_OVERRIDE}=1.`, {
1026
+ lane_id: laneId,
1027
+ stored_version: storedVersion,
1028
+ running_version: CLI_VERSION,
1029
+ upgrade_command: LANE_STORE_UPGRADE_COMMAND,
1030
+ override: `${OLDER_LANE_STORE_WRITE_OVERRIDE}=1`,
1031
+ });
1032
+ }
1033
+ if (!warnedBackgroundWriterLaneIds.has(laneId)) {
1034
+ warnedBackgroundWriterLaneIds.add(laneId);
1035
+ console.warn(`${warning} Continuing background ${writer} writes and preserving the newer stamp.`);
1036
+ }
1037
+ }
1038
+ function newestLaneStoreVersion(storedVersion) {
1039
+ if (storedVersion !== undefined && isLaneStoreVersionNewer(storedVersion, CLI_VERSION)) {
1040
+ return storedVersion;
1041
+ }
1042
+ return CLI_VERSION;
1043
+ }
953
1044
  function recordsEquivalentAsideFromClock(current, updated) {
954
1045
  if (updated === current)
955
1046
  return true;
@@ -961,7 +1052,7 @@ function recordsEquivalentAsideFromClock(current, updated) {
961
1052
  };
962
1053
  return JSON.stringify(strip(current)) === JSON.stringify(strip(updated));
963
1054
  }
964
- function casWrite(filePath, expectedVersion, parse, merge, updater, deps) {
1055
+ function casWrite(filePath, expectedVersion, parse, merge, updater, deps, options = {}) {
965
1056
  return withRecordLock(filePath, deps, () => {
966
1057
  const current = readMergedRecord(filePath, parse, merge, deps);
967
1058
  if (current === null) {
@@ -980,9 +1071,11 @@ function casWrite(filePath, expectedVersion, parse, merge, updater, deps) {
980
1071
  if (recordsEquivalentAsideFromClock(current, updated)) {
981
1072
  return current;
982
1073
  }
1074
+ options.beforeWrite?.(current);
983
1075
  const next = {
984
1076
  ...current,
985
1077
  ...updated,
1078
+ ...options.stamp?.(current),
986
1079
  version: current.version + 1,
987
1080
  updated_at: deps.now().toISOString(),
988
1081
  };
@@ -996,6 +1089,7 @@ export function createLane(hqRoot, init, deps = defaultStoreDeps()) {
996
1089
  const filePath = laneRecordPath(hqRoot, lane_id);
997
1090
  const record = {
998
1091
  ...init,
1092
+ written_by_version: CLI_VERSION,
999
1093
  hire_grant: init.hire_grant ?? 0,
1000
1094
  schema_version: init.schema_version ?? LANE_SCHEMA_VERSION,
1001
1095
  lane_id,
@@ -1060,7 +1154,10 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
1060
1154
  }
1061
1155
  }
1062
1156
  return updated;
1063
- }, deps);
1157
+ }, deps, {
1158
+ beforeWrite: (current) => assertLaneStoreWriteAllowed(current, laneId, writer),
1159
+ stamp: (current) => ({ written_by_version: newestLaneStoreVersion(current.written_by_version) }),
1160
+ });
1064
1161
  if (laneStoryLedgerEnabled() && before && laneLedgerRelevantChange(before, updated)) {
1065
1162
  return syncStoryLedgerForLane(hqRoot, updated, deps);
1066
1163
  }
@@ -1182,7 +1279,12 @@ export function syncLaneRecordFromOwner(hqRoot, laneId, outpostId, rawOwnerLane,
1182
1279
  current.outpost_command_id !== undefined
1183
1280
  ? { ...ownerLane, outpost_command_id: current.outpost_command_id }
1184
1281
  : ownerLane;
1185
- const synced = mergeLaneRecords(ownerSnapshot, [current]);
1282
+ const merged = mergeLaneRecords(ownerSnapshot, [current]);
1283
+ const synced = {
1284
+ ...merged,
1285
+ written_by_version: newestLaneStoreVersion(current.written_by_version),
1286
+ owner_written_by_version: ownerLane.written_by_version,
1287
+ };
1186
1288
  parseLaneRecord(synced, filePath);
1187
1289
  atomicWriteJson(filePath, synced, deps);
1188
1290
  return synced;
@@ -473,12 +473,16 @@ function setLaneEntry(story, lane, laneState, laneStoryStatus, prs, pendingQuest
473
473
  item.terminal === true);
474
474
  const stateChanged = oldState !== laneState;
475
475
  if (stateChanged || (terminal && !alreadyRecorded)) {
476
+ const operatorReason = lane.terminal_details?.operator_reason;
476
477
  history.push({
477
478
  lane_id: lane.lane_id,
478
479
  state: laneState,
479
480
  at,
480
481
  prs,
481
482
  ...(terminal ? { terminal: true } : {}),
483
+ ...(terminal && typeof operatorReason === "string" && operatorReason.trim() !== ""
484
+ ? { reason: operatorReason }
485
+ : {}),
482
486
  });
483
487
  }
484
488
  const entry = {
@@ -69,7 +69,7 @@ export interface TrackerCheckFinding {
69
69
  [key: string]: unknown;
70
70
  }
71
71
  export interface TrackerCheckResult {
72
- id: "questions" | "lead_lanes" | "freshness" | "blockers" | "kpis" | "directives";
72
+ id: "questions" | "lead_lanes" | "freshness" | "blockers" | "kpis" | "directives" | "orphaned_lane";
73
73
  result: "ok" | "failing";
74
74
  items: TrackerCheckFinding[];
75
75
  }
@@ -283,6 +283,10 @@ export interface SeniorHistoryEntry {
283
283
  by: string;
284
284
  reason: string;
285
285
  }
286
+ export interface LaneStopHistoryEntry {
287
+ at: string;
288
+ reason: string;
289
+ }
286
290
  /** Evidence for a senior-authorized Outpost worker-profile content re-pin. */
287
291
  export interface WorkerProfileRepinHistoryEntry {
288
292
  old_hash: string | null;
@@ -318,6 +322,10 @@ export interface LaneStory {
318
322
  export interface LaneRecord {
319
323
  schema_version: typeof LANE_SCHEMA_VERSION | string;
320
324
  lane_id: string;
325
+ /** CLI release that most recently wrote this lane record. Absent on legacy rows. */
326
+ written_by_version?: string;
327
+ /** CLI release that most recently wrote the owner-host copy synced to primary. */
328
+ owner_written_by_version?: string;
321
329
  /** `null` is a core/personal lane (spec §6.2 / §10). Not an empty slug. */
322
330
  company: LaneCompanyRef | null;
323
331
  /** Explicit Work Mesh attribution for a personal lane; never inferred from cwd. */
@@ -465,6 +473,8 @@ export interface LaneRecord {
465
473
  /** Set when the reconcile pass force-admits under the starvation bound. */
466
474
  admitted_reason?: string;
467
475
  terminal_reason?: string;
476
+ /** Reasons supplied to explicit stop commands, retained independently of optional project ledgers. */
477
+ stop_history?: LaneStopHistoryEntry[];
468
478
  /** Classification time when this lane entered interrupted; stable across later metadata writes. */
469
479
  interrupted_at?: string;
470
480
  /**
@@ -216,7 +216,7 @@ export const REQUIRED_LANE_RECORD_FIELDS = [
216
216
  export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spawn", "story-ledger"];
217
217
  /** Field ownership at the durable-record mutation boundary (spec §10.1). */
218
218
  export const LANE_FIELD_WRITERS = {
219
- schema_version: ["creator"], lane_id: ["creator"], company: ["creator"],
219
+ schema_version: ["creator"], lane_id: ["creator"], written_by_version: [], owner_written_by_version: [], company: ["creator"],
220
220
  project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
221
221
  depth: ["command"], senior: ["command"], senior_history: ["command"],
222
222
  hire_grant: ["creator", "command"],
@@ -230,7 +230,7 @@ export const LANE_FIELD_WRITERS = {
230
230
  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"],
231
231
  last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
232
232
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
233
- admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], interrupted_at: ["watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
233
+ admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command"], interrupted_at: ["watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
234
234
  quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
235
235
  provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
236
236
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.236.0",
3
+ "version": "5.238.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {