@indigoai-us/hq-cli 5.273.0-alpha.0 → 5.274.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +65 -2
  2. package/README.md +32 -1
  3. package/dist/command-catalog.generated.d.ts +83 -536
  4. package/dist/command-catalog.generated.js +129 -714
  5. package/dist/command-registration-plan.d.ts +12 -0
  6. package/dist/command-registration-plan.js +2 -0
  7. package/dist/commands/cloud.js +40 -0
  8. package/dist/commands/core-checkpoint.js +3 -1
  9. package/dist/commands/core-command-catalog.d.ts +32 -0
  10. package/dist/commands/core-command-catalog.js +58 -0
  11. package/dist/commands/core-ontology.js +9 -4
  12. package/dist/commands/core.d.ts +4 -0
  13. package/dist/commands/core.js +79 -75
  14. package/dist/commands/daemon.d.ts +0 -3
  15. package/dist/commands/daemon.js +1 -173
  16. package/dist/commands/deploy-comments.d.ts +3 -0
  17. package/dist/commands/deploy-comments.js +159 -0
  18. package/dist/commands/lanes-checks.d.ts +0 -1
  19. package/dist/commands/lanes-checks.js +217 -54
  20. package/dist/commands/lanes-hold.js +40 -2
  21. package/dist/commands/lanes-message.js +98 -14
  22. package/dist/commands/lanes-move.d.ts +12 -0
  23. package/dist/commands/lanes-move.js +342 -0
  24. package/dist/commands/lanes-resume.js +3 -1
  25. package/dist/commands/lanes-shared.d.ts +39 -3
  26. package/dist/commands/lanes-shared.js +59 -0
  27. package/dist/commands/lanes.d.ts +3 -5
  28. package/dist/commands/lanes.js +206 -478
  29. package/dist/commands/report.js +2 -14
  30. package/dist/commands/sync-mode.d.ts +0 -11
  31. package/dist/commands/sync-mode.js +0 -30
  32. package/dist/fast-core.js +1 -1
  33. package/dist/{commands/scaffold-fast.d.ts → fast-scaffold.d.ts} +1 -1
  34. package/dist/{commands/scaffold-fast.js → fast-scaffold.js} +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/lib/checkpoint-ontology/apply.d.ts +8 -0
  38. package/dist/lib/checkpoint-ontology/apply.js +33 -0
  39. package/dist/lib/checkpoint-ontology/plan.d.ts +15 -0
  40. package/dist/lib/checkpoint-ontology/plan.js +15 -0
  41. package/dist/lib/daemon/catalog.d.ts +2 -18
  42. package/dist/lib/daemon/catalog.js +2 -18
  43. package/dist/lib/daemon/config.d.ts +0 -6
  44. package/dist/lib/daemon/config.js +2 -15
  45. package/dist/lib/daemon/requests.d.ts +0 -4
  46. package/dist/lib/daemon/requests.js +0 -2
  47. package/dist/lib/daemon/run.js +19 -78
  48. package/dist/lib/daemon/supervisor.d.ts +0 -8
  49. package/dist/lib/daemon/supervisor.js +1 -101
  50. package/dist/lib/deploy-api.d.ts +6 -0
  51. package/dist/lib/deploy-api.js +21 -0
  52. package/dist/lib/doctor/checks/scaffold-cli-floor.d.ts +7 -0
  53. package/dist/lib/doctor/checks/scaffold-cli-floor.js +73 -0
  54. package/dist/lib/doctor/fix/apply.d.ts +17 -2
  55. package/dist/lib/doctor/fix/apply.js +111 -57
  56. package/dist/lib/doctor/fix/json-output.d.ts +9 -1
  57. package/dist/lib/doctor/fix/json-output.js +11 -0
  58. package/dist/lib/doctor/registry.js +2 -0
  59. package/dist/lib/first-sync-next-step.d.ts +37 -0
  60. package/dist/lib/first-sync-next-step.js +189 -0
  61. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  62. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  63. package/dist/lib/lanes/hire-grant-dispatch.d.ts +6 -1
  64. package/dist/lib/lanes/hire-grant-dispatch.js +78 -3
  65. package/dist/lib/lanes/keep-alive.d.ts +3 -2
  66. package/dist/lib/lanes/keep-alive.js +2 -2
  67. package/dist/lib/lanes/merge-command.d.ts +1 -0
  68. package/dist/lib/lanes/merge-command.js +299 -33
  69. package/dist/lib/lanes/orphans.d.ts +7 -0
  70. package/dist/lib/lanes/orphans.js +39 -4
  71. package/dist/lib/lanes/tracker-store.d.ts +2 -18
  72. package/dist/lib/lanes/tracker-store.js +1 -172
  73. package/dist/lib/scaffold-cli-floor.d.ts +9 -0
  74. package/dist/lib/scaffold-cli-floor.js +50 -0
  75. package/dist/main.js +4 -1
  76. package/dist/utils/cli-telemetry.d.ts +7 -1
  77. package/dist/utils/cli-telemetry.js +84 -8
  78. package/dist/utils/contribution-table.d.ts +1 -1
  79. package/dist/utils/scaffold-fast-import-guard.d.ts +3 -0
  80. package/dist/utils/scaffold-fast-import-guard.js +28 -0
  81. package/dist/utils/scaffold-packlist-guard.d.ts +7 -0
  82. package/dist/utils/scaffold-packlist-guard.js +23 -0
  83. package/dist/utils/scaffold-sibling-references.d.ts +21 -0
  84. package/dist/utils/scaffold-sibling-references.js +156 -0
  85. package/package.json +2 -2
  86. package/dist/commands/lanes-import.d.ts +0 -14
  87. package/dist/commands/lanes-import.js +0 -1210
  88. package/dist/commands/lanes-tracker.d.ts +0 -18
  89. package/dist/commands/lanes-tracker.js +0 -452
  90. package/dist/lib/daemon/sync-runner.d.ts +0 -58
  91. package/dist/lib/daemon/sync-runner.js +0 -277
@@ -7,10 +7,11 @@ import { evaluateTrackerChecks } from "../lib/lanes/tracker-checks.js";
7
7
  import { LANE_STORE_UPGRADE_COMMAND, isLaneStoreVersionNewer, laneRecordPath, listLaneIds, listQuestionIds, questionRecordPath, readLane, readQuestion } from "../lib/lanes/store.js";
8
8
  import { createLocalTrackerStore } from "../lib/lanes/tracker-store.js";
9
9
  import { sessionLiveness } from "../lib/lanes/liveness.js";
10
- import { findOrphanLanes, parseOrphanMinAge } from "../lib/lanes/orphans.js";
10
+ import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
11
+ import { DEFAULT_ORPHAN_USER_IDLE_HOURS, findOrphanLanes, parseOrphanMinAge, parseOrphanUserIdleHours, } from "../lib/lanes/orphans.js";
12
+ import { LANES_RUNS_REL } from "../lib/lanes/spawn.js";
11
13
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
12
14
  import { checkCompanyPath } from "../lib/lanes/checked-company-path.js";
13
- import { LANES_TRACKER_FLAG } from "./lanes-tracker.js";
14
15
  function fail(error, message, details = {}) {
15
16
  throw new LanesError(error, message, details);
16
17
  }
@@ -293,46 +294,176 @@ function readScopedLanes(root, company, projects) {
293
294
  return [readLane(root, id)];
294
295
  });
295
296
  }
296
- function readCompanyLanes(root, company, projectId) {
297
- return listLaneIds(root).flatMap((id) => {
298
- const file = laneRecordPath(root, id);
299
- const metadata = readJsonObject(file);
300
- if (metadata === null)
301
- return fail("tracker_checks_input_unreadable", "Lane record could not be read while checking its scope", { path: file });
302
- const laneCompany = isObject(metadata.company) ? metadata.company : null;
303
- if (laneCompany?.slug !== company.slug || laneCompany.uid !== company.uid)
304
- return [];
305
- if (projectId !== undefined && !laneMetadataMatchesScope(metadata, company, new Set([projectId])))
306
- return [];
307
- return [readLane(root, id)];
308
- });
309
- }
310
297
  function orphanSessionState(root, sessionId, now, deps) {
311
298
  if (deps.sessionState)
312
299
  return deps.sessionState(root, sessionId, now);
313
300
  return sessionLiveness(root, sessionId, { nowMs: now.getTime() });
314
301
  }
315
- function orphanFindings(root, company, now, minAgeMinutes, deps, projectId) {
316
- const candidates = readCompanyLanes(root, company, projectId);
317
- const lanes = [...candidates];
318
- const known = new Set(lanes.map((lane) => lane.lane_id));
319
- for (const candidate of candidates) {
320
- if (candidate.senior.kind !== "lane" || candidate.senior.id === undefined || known.has(candidate.senior.id))
302
+ const MAX_ORPHAN_SENIOR_DEPTH = 64;
303
+ function readOrphanLaneScopes(root) {
304
+ return new Map(listLaneIds(root).map((id) => {
305
+ const file = laneRecordPath(root, id);
306
+ const metadata = readJsonObject(file);
307
+ if (metadata === null)
308
+ return fail("tracker_checks_input_unreadable", "Lane record could not be read while resolving orphan scope", { path: file });
309
+ const companyRef = (value) => isObject(value) && typeof value.slug === "string" && typeof value.uid === "string"
310
+ ? { slug: value.slug, uid: value.uid }
311
+ : null;
312
+ const meshCompany = companyRef(metadata.mesh_company);
313
+ const lane = {
314
+ lane_id: id,
315
+ company: companyRef(metadata.company),
316
+ ...(meshCompany === null ? {} : { mesh_company: meshCompany }),
317
+ project_id: typeof metadata.project_id === "string" ? metadata.project_id : "",
318
+ };
319
+ const seniorLaneId = isObject(metadata.senior) && metadata.senior.kind === "lane" && typeof metadata.senior.id === "string"
320
+ ? metadata.senior.id
321
+ : undefined;
322
+ const projectIds = [lane.project_id];
323
+ if (Array.isArray(metadata.stories)) {
324
+ for (const story of metadata.stories) {
325
+ if (isObject(story) && typeof story.project_id === "string")
326
+ projectIds.push(story.project_id);
327
+ }
328
+ }
329
+ return [id, { lane, ...(seniorLaneId === undefined ? {} : { seniorLaneId }), projectIds }];
330
+ }));
331
+ }
332
+ function orphanCompanyResolution(root, scope, scopesById) {
333
+ const direct = resolveLaneCompany(root, scope.lane);
334
+ if (direct.company !== null)
335
+ return direct;
336
+ const visited = new Set([scope.lane.lane_id]);
337
+ let current = scope;
338
+ for (let depth = 0; depth < MAX_ORPHAN_SENIOR_DEPTH; depth += 1) {
339
+ const seniorId = current.seniorLaneId;
340
+ if (seniorId === undefined)
341
+ break;
342
+ if (visited.has(seniorId))
343
+ break;
344
+ visited.add(seniorId);
345
+ const senior = scopesById.get(seniorId);
346
+ if (senior === undefined)
347
+ break;
348
+ const resolved = resolveLaneCompany(root, senior.lane);
349
+ if (resolved.company !== null)
350
+ return { company: resolved.company, source: "senior" };
351
+ current = senior;
352
+ }
353
+ return { company: null, source: "unresolved" };
354
+ }
355
+ function orphanLastActivityAt(root, lane) {
356
+ const updatedAt = Date.parse(lane.updated_at);
357
+ if (!Number.isFinite(updatedAt))
358
+ return Number.NaN;
359
+ const recordedEnvelopeAt = lane.last_envelope === undefined ? Number.NaN : Date.parse(lane.last_envelope.at);
360
+ let latest = Number.isFinite(recordedEnvelopeAt) ? Math.max(updatedAt, recordedEnvelopeAt) : updatedAt;
361
+ const envelopesDirectory = path.join(root, LANES_RUNS_REL, lane.lane_id, "envelopes");
362
+ let entries;
363
+ try {
364
+ entries = fs.readdirSync(envelopesDirectory, { withFileTypes: true });
365
+ }
366
+ catch (err) {
367
+ if (err.code === "ENOENT")
368
+ return latest;
369
+ return fail("orphan_envelope_unreadable", "Lane envelope directory could not be read", { lane_id: lane.lane_id });
370
+ }
371
+ for (const entry of entries) {
372
+ if (!entry.name.endsWith(".json") || entry.name.startsWith("."))
321
373
  continue;
322
- const file = laneRecordPath(root, candidate.senior.id);
323
- const metadata = readJsonObject(file, "empty");
324
- const laneCompany = metadata && isObject(metadata.company) ? metadata.company : null;
325
- if (laneCompany?.slug !== company.slug || laneCompany.uid !== company.uid)
374
+ const file = path.join(envelopesDirectory, entry.name);
375
+ try {
376
+ const stat = fs.lstatSync(file);
377
+ if (stat.isFile() && !stat.isSymbolicLink() && Number.isFinite(stat.mtimeMs)) {
378
+ latest = Math.max(latest, stat.mtimeMs);
379
+ }
380
+ }
381
+ catch (err) {
382
+ if (err.code === "ENOENT")
383
+ continue;
384
+ return fail("orphan_envelope_unreadable", "Lane envelope timestamp could not be read", { lane_id: lane.lane_id });
385
+ }
386
+ }
387
+ return latest;
388
+ }
389
+ function orphanLaneHasOpenStory(root, lane, company) {
390
+ const carriedStories = lane.stories ?? [];
391
+ const references = [];
392
+ const addReference = (project_id, story_id) => {
393
+ if (!references.some((reference) => reference.project_id === project_id && reference.story_id === story_id)) {
394
+ references.push({ project_id, story_id });
395
+ }
396
+ };
397
+ if (lane.story_id !== "")
398
+ addReference(lane.project_id, lane.story_id);
399
+ for (const story of carriedStories) {
400
+ addReference(story.project_id, story.story_id);
401
+ if (story.status !== "done")
402
+ return true;
403
+ }
404
+ if (references.length === 0)
405
+ return false;
406
+ if (company === null)
407
+ return true;
408
+ for (const reference of references) {
409
+ const resolution = resolveLaneCompany(root, {
410
+ lane_id: lane.lane_id,
411
+ company: lane.company,
412
+ project_id: reference.project_id,
413
+ });
414
+ if (resolution.company === null || resolution.company.slug !== company.slug || resolution.company.uid !== company.uid) {
415
+ return true;
416
+ }
417
+ const projectId = validSegment(reference.project_id, "project");
418
+ const prdPath = path.join(root, "companies", company.slug, "projects", projectId, "prd.json");
419
+ const stories = readProject(root, company.slug, prdPath, true).stories;
420
+ const matching = stories.filter((story) => story.id === reference.story_id);
421
+ if (matching.some((story) => !storyIsDone(story)))
422
+ return true;
423
+ }
424
+ return false;
425
+ }
426
+ function orphanFindings(root, company, now, minAgeMinutes, deps, projectId, unscoped = company === null, userIdleHours = DEFAULT_ORPHAN_USER_IDLE_HOURS) {
427
+ const scopesById = readOrphanLaneScopes(root);
428
+ const companyByLane = new Map();
429
+ const selectedScopes = [...scopesById.values()].filter((scope) => {
430
+ if (projectId !== undefined && !scope.projectIds.includes(projectId))
431
+ return false;
432
+ const rawCompany = scope.lane.company;
433
+ if (company !== null && rawCompany !== null && (rawCompany.slug !== company.slug || rawCompany.uid !== company.uid))
434
+ return false;
435
+ const resolution = orphanCompanyResolution(root, scope, scopesById);
436
+ companyByLane.set(scope.lane.lane_id, resolution);
437
+ if (unscoped)
438
+ return resolution.company === null;
439
+ return company !== null && resolution.company?.slug === company.slug && resolution.company.uid === company.uid;
440
+ });
441
+ const selectedLanes = selectedScopes.map((scope) => readLane(root, scope.lane.lane_id));
442
+ const candidates = selectedLanes.filter((lane) => projectId === undefined ||
443
+ lane.project_id === projectId || (lane.stories ?? []).some((story) => story.project_id === projectId));
444
+ const candidateIds = new Set(candidates.map((lane) => lane.lane_id));
445
+ const lanesForFinding = new Map(candidates.map((lane) => [lane.lane_id, lane]));
446
+ for (const lane of candidates) {
447
+ if (lane.senior.kind !== "lane" || lane.senior.id === undefined || lanesForFinding.has(lane.senior.id))
326
448
  continue;
327
- lanes.push(readLane(root, candidate.senior.id));
328
- known.add(candidate.senior.id);
449
+ if (scopesById.has(lane.senior.id))
450
+ lanesForFinding.set(lane.senior.id, readLane(root, lane.senior.id));
329
451
  }
330
452
  return findOrphanLanes({
331
- lanes,
453
+ lanes: [...lanesForFinding.values()],
454
+ candidateLaneIds: candidateIds,
332
455
  now,
333
456
  minAgeMinutes,
457
+ userIdleHours,
334
458
  sessionState: (sessionId, at) => orphanSessionState(root, sessionId, at, deps),
335
- }).filter((finding) => candidates.some((lane) => lane.lane_id === finding.lane_id));
459
+ lastActivityAt: (lane) => orphanLastActivityAt(root, lane),
460
+ hasOpenStory: (lane) => orphanLaneHasOpenStory(root, lane, companyByLane.get(lane.lane_id)?.company ?? null),
461
+ })
462
+ .filter((finding) => candidateIds.has(finding.lane_id))
463
+ .map((finding) => ({
464
+ ...finding,
465
+ company_source: companyByLane.get(finding.lane_id)?.source ?? "unresolved",
466
+ }));
336
467
  }
337
468
  function sameCompanyLane(lane, company) {
338
469
  return lane.company?.slug === company.slug && lane.company.uid === company.uid;
@@ -582,9 +713,6 @@ export async function resolveTrackerCompany(deps, requested) {
582
713
  const company = await deps.resolveCompany(requested);
583
714
  if (!company.slug)
584
715
  fail("tracker_company_unresolved", "The active HQ company could not be resolved", {});
585
- if (!await deps.trackerEnabled(company)) {
586
- fail("tracker_disabled", `hq lanes tracker commands require the ${LANES_TRACKER_FLAG} flag`, { company: company.slug });
587
- }
588
716
  return company;
589
717
  }
590
718
  async function checkReport(root, company, projects, directiveProjects, projectFilter, minAgeMinutes, now, deps) {
@@ -631,8 +759,8 @@ async function checkReport(root, company, projects, directiveProjects, projectFi
631
759
  result: orphanedLanes.length === 0 ? "ok" : "failing",
632
760
  items: orphanedLanes.map((finding) => ({
633
761
  id: finding.lane_id,
634
- reason: finding.senior_state === "missing" ? "missing_senior" :
635
- finding.senior_state === "ended" ? "ended_session_senior" : "terminal_senior",
762
+ reason: finding.reason,
763
+ company_source: finding.company_source,
636
764
  senior: finding.senior,
637
765
  senior_state: finding.senior_state,
638
766
  age_minutes: finding.age_minutes,
@@ -648,7 +776,7 @@ async function checkReport(root, company, projects, directiveProjects, projectFi
648
776
  checks,
649
777
  };
650
778
  }
651
- async function orphanReport(root, company, now, minAgeMinutes, deps, options) {
779
+ async function orphanReport(root, company, now, minAgeMinutes, userIdleHours, deps, options) {
652
780
  const reason = options.reason?.trim();
653
781
  if (options.stop && !reason) {
654
782
  fail("orphan_stop_reason_required", "--stop requires a non-empty --reason", {});
@@ -658,39 +786,63 @@ async function orphanReport(root, company, now, minAgeMinutes, deps, options) {
658
786
  }
659
787
  if (reason !== undefined)
660
788
  assertNoSecretText("stop reason", reason);
661
- const orphans = orphanFindings(root, company, now, minAgeMinutes, deps);
662
- if (!options.stop) {
789
+ const unscoped = options.unscoped === true;
790
+ const orphans = orphanFindings(root, company, now, minAgeMinutes, deps, undefined, unscoped, userIdleHours);
791
+ const output = (action, stopped) => {
792
+ if (options.human) {
793
+ const companyText = unscoped ? "unscoped" : company?.slug ?? "unknown";
794
+ console.log(`company: ${companyText}`);
795
+ console.log("lane_id\tsenior\tsenior_state\treason\tcompany_source\tage_minutes\taccount\thost");
796
+ for (const finding of orphans) {
797
+ console.log([
798
+ finding.lane_id,
799
+ `${finding.senior.kind}:${finding.senior.id ?? ""}`,
800
+ finding.senior_state,
801
+ finding.reason,
802
+ finding.company_source,
803
+ finding.age_minutes,
804
+ finding.account ?? "",
805
+ `${finding.host.kind}:${finding.host.id}`,
806
+ ].join("\t"));
807
+ }
808
+ if (orphans.length === 0)
809
+ console.log("(none)");
810
+ if (stopped !== undefined) {
811
+ console.log("stopped:");
812
+ for (const entry of stopped)
813
+ console.log(`${entry.lane_id}\t${entry.state}`);
814
+ }
815
+ return;
816
+ }
663
817
  console.log(JSON.stringify({
664
818
  ok: true,
665
- action: "orphans",
666
- company: company.slug,
819
+ action,
820
+ company: company?.slug ?? null,
821
+ ...(unscoped ? { unscoped: true } : {}),
667
822
  generated_at: now.toISOString(),
668
823
  min_age_minutes: minAgeMinutes,
824
+ user_idle_hours: userIdleHours,
825
+ ...(reason === undefined ? {} : { reason }),
669
826
  orphans,
827
+ ...(stopped === undefined ? {} : { stopped }),
670
828
  }, null, 2));
829
+ };
830
+ if (!options.stop) {
831
+ output("orphans");
671
832
  return;
672
833
  }
673
834
  if (!deps.stopLane)
674
835
  fail("orphan_stop_unavailable", "The normal lane stop path is unavailable", {});
675
836
  const stopped = [];
676
837
  for (const finding of orphans) {
677
- const fresh = orphanFindings(root, company, deps.now(), minAgeMinutes, deps)
838
+ const fresh = orphanFindings(root, company, deps.now(), minAgeMinutes, deps, undefined, unscoped, userIdleHours)
678
839
  .find((candidate) => candidate.lane_id === finding.lane_id);
679
840
  if (!fresh)
680
841
  continue;
681
842
  await deps.stopLane(finding.lane_id, reason);
682
843
  stopped.push({ lane_id: finding.lane_id, state: readLane(root, finding.lane_id).state });
683
844
  }
684
- console.log(JSON.stringify({
685
- ok: true,
686
- action: "orphans.stop",
687
- company: company.slug,
688
- generated_at: now.toISOString(),
689
- min_age_minutes: minAgeMinutes,
690
- reason,
691
- orphans,
692
- stopped,
693
- }, null, 2));
845
+ output("orphans.stop", stopped);
694
846
  }
695
847
  export function registerLaneChecksCommand(parent, deps, wrap) {
696
848
  parent.command("checks")
@@ -713,16 +865,27 @@ export function registerLaneChecksCommand(parent, deps, wrap) {
713
865
  process.exitCode = 2;
714
866
  }));
715
867
  parent.command("orphans")
716
- .description("Report lanes whose lane or session senior has ended")
868
+ .description("Report lanes whose senior has ended or gone idle")
717
869
  .option("--company <slug>", "Select the company by slug")
718
870
  .option("--min-age <minutes>", "Minimum orphan lane age in minutes (default: 30)")
871
+ .option("--user-idle-hours <hours>", "Idle hours for user-senior lanes (default: 72)")
872
+ .option("--unscoped", "Report candidates whose company cannot be resolved")
719
873
  .option("--stop", "Stop the reported lanes through their normal owner path")
720
874
  .option("--reason <text>", "Required audit reason when --stop is set")
875
+ .option("--json", "Print machine-readable JSON")
876
+ .option("--human", "Print a human-readable table")
721
877
  .action(wrap(async (options) => {
722
- const company = await deps.resolveCompany(options.company);
878
+ if (options.unscoped && options.company !== undefined) {
879
+ fail("orphan_scope_conflict", "--unscoped cannot be combined with --company", {});
880
+ }
881
+ if (options.human && options.json) {
882
+ fail("conflicting_output_format", "Choose either --json or --human output", {});
883
+ }
884
+ const company = options.unscoped ? null : await deps.resolveCompany(options.company);
723
885
  const root = deps.hqRoot();
724
886
  const minAgeMinutes = parseOrphanMinAge(options.minAge);
725
- await orphanReport(root, company, deps.now(), minAgeMinutes, deps, options);
887
+ const userIdleHours = parseOrphanUserIdleHours(options.userIdleHours);
888
+ await orphanReport(root, company, deps.now(), minAgeMinutes, userIdleHours, deps, options);
726
889
  }));
727
890
  }
728
891
  //# sourceMappingURL=lanes-checks.js.map
@@ -547,13 +547,23 @@ export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
547
547
  let reviewContext = null;
548
548
  if (holdFlagOff) {
549
549
  reviewContext = await reviewCheckContext(hqRoot, deps);
550
- if (!reviewContext) {
550
+ }
551
+ let parsed;
552
+ try {
553
+ parsed = parseMergeCommands(options.command);
554
+ }
555
+ catch (error) {
556
+ if (holdFlagOff && !reviewContext) {
551
557
  printJson({ ok: true, hold_enforcement: "off" });
552
558
  return;
553
559
  }
560
+ throw error;
554
561
  }
555
- const parsed = parseMergeCommands(options.command);
556
562
  repoUnresolved = parsed.targets.some((target) => target.repo === null);
563
+ if (holdFlagOff && !reviewContext && !parsed.unparseableMerge) {
564
+ printJson({ ok: true, hold_enforcement: "off" });
565
+ return;
566
+ }
557
567
  const repos = parsed.targets.flatMap((target) => target.repo === null ? [] : [target.repo]);
558
568
  const now = deps.now();
559
569
  if (!holdFlagOff) {
@@ -581,6 +591,29 @@ export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
581
591
  return;
582
592
  }
583
593
  }
594
+ if (parsed.unparseableMerge) {
595
+ const activeHolds = listHolds(hqRoot)
596
+ .filter((existing) => holdView(existing, now).active);
597
+ let deniedUnresolvedHold;
598
+ for (const existing of activeHolds) {
599
+ const enabled = await (deps.mergeHoldEnabled ?? ((company) => readLanesRegistryFlag(LANES_REPO_MERGE_HOLD_FLAG, { company, env: deps.env })))(existing.company);
600
+ if (enabled) {
601
+ deniedUnresolvedHold ??= existing;
602
+ }
603
+ else {
604
+ gateWasOff = true;
605
+ }
606
+ }
607
+ if (deniedUnresolvedHold) {
608
+ printJson({
609
+ ok: false,
610
+ error: "repo_hold_command_invalid",
611
+ repo_unresolved: true,
612
+ });
613
+ process.exitCode = 3;
614
+ return;
615
+ }
616
+ }
584
617
  reviewContext ??= await reviewCheckContext(hqRoot, deps);
585
618
  if (reviewContext) {
586
619
  const reviewCheck = checkReviewTargets(hqRoot, reviewContext, parsed.targets);
@@ -589,6 +622,11 @@ export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
589
622
  process.exitCode = 3;
590
623
  return;
591
624
  }
625
+ if (parsed.unparseableMerge) {
626
+ printJson({ ok: false, error: "review_record_missing", repo_unresolved: true });
627
+ process.exitCode = 3;
628
+ return;
629
+ }
592
630
  reviewUnresolved = reviewCheck.unresolved;
593
631
  }
594
632
  printJson({
@@ -3,38 +3,115 @@ import * as path from "node:path";
3
3
  import { appendCappedHistory, appendDropboxMessage, deliveryRefusalReason, dropboxInboxPath, laneAcceptsOperatorMessage, laneDeliveryMechanism, operatorMessageRefusalReason, providerSessionPortable, providerSupportsSessionResume, readLane, sessionNotPortableMessage, validateProjectStories, withLaneAdmissionLock } from "../lib/lanes/index.js";
4
4
  import { LanesError } from "../lib/lanes/errors.js";
5
5
  import { parseCliEffort } from "../lib/lanes/effort.js";
6
- import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
6
+ import { parseOutpostHireGrantContext, sameHireGrantSenior } from "../lib/lanes/hire-grant-dispatch.js";
7
7
  import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
8
8
  import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages } from "../lib/lanes/dropbox.js";
9
9
  import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision } from "../lib/lanes/watcher.js";
10
10
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
11
11
  import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
12
- import { keepAliveExclusion, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
12
+ import { keepAliveExclusion, keepAliveSupersededBy, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
13
13
  import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
14
14
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
15
15
  import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
16
16
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
17
17
  export function createLaneMessageHandlers(deps) {
18
18
  const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
19
- async function messageLaneExclusion(ctx, lane) {
19
+ function currentSuccessorOf(lane, lanes, lineageAware, companyForLane) {
20
+ const validLanes = lanes.filter((candidate) => Number.isFinite(Date.parse(candidate.created_at)));
21
+ const newestFirst = [...validLanes].sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at));
22
+ const visited = new Set([lane.lane_id]);
23
+ let current = lane;
24
+ for (let depth = 0; depth < validLanes.length; depth += 1) {
25
+ const next = keepAliveSupersededBy(current, newestFirst, lineageAware, companyForLane);
26
+ if (!next || visited.has(next.lane_id))
27
+ break;
28
+ current = next;
29
+ visited.add(next.lane_id);
30
+ }
31
+ return current.lane_id === lane.lane_id ? undefined : current;
32
+ }
33
+ function callerMayMessageSupersededLane(ctx, lane, lanes, lineageAware, companyForLane, callerLaneIdOverride) {
34
+ if (lane.senior.kind !== "lane")
35
+ return false;
36
+ const callerLaneId = callerLaneIdOverride ?? ctx.env.HQ_LANE_ID?.trim();
37
+ if (!callerLaneId)
38
+ return false;
39
+ const senior = lanes.find((candidate) => candidate.lane_id === lane.senior.id);
40
+ const caller = lanes.find((candidate) => candidate.lane_id === callerLaneId);
41
+ if (!senior || !caller)
42
+ return false;
43
+ const workerCompany = companyForLane(lane);
44
+ const seniorCompany = companyForLane(senior);
45
+ const callerCompany = companyForLane(caller);
46
+ const projectId = lane.project_id;
47
+ if (!workerCompany ||
48
+ seniorCompany?.uid !== workerCompany.uid ||
49
+ callerCompany?.uid !== workerCompany.uid ||
50
+ !projectId ||
51
+ senior.project_id !== projectId ||
52
+ caller.project_id !== projectId)
53
+ return false;
54
+ if (caller.lane_id === senior.lane_id)
55
+ return true;
56
+ const projectLanes = lanes.filter((candidate) => companyForLane(candidate)?.uid === workerCompany.uid && candidate.project_id === projectId);
57
+ return currentSuccessorOf(lane, projectLanes, lineageAware, companyForLane)?.lane_id === caller.lane_id;
58
+ }
59
+ function keepAliveLaneFromSnapshot(snapshot) {
60
+ if (snapshot.created_at === undefined || !Object.hasOwn(snapshot, "company"))
61
+ return undefined;
62
+ return {
63
+ lane_id: snapshot.lane_id,
64
+ created_at: snapshot.created_at,
65
+ senior: snapshot.senior,
66
+ company: snapshot.company ?? null,
67
+ project_id: snapshot.project_id ?? "",
68
+ ...(snapshot.worker_profile === undefined ? {} : { worker_profile: snapshot.worker_profile }),
69
+ ...(snapshot.hire_grant === undefined ? {} : { hire_grant: snapshot.hire_grant }),
70
+ };
71
+ }
72
+ async function messageLaneExclusion(ctx, lane, grantContext) {
20
73
  if (lane.state === "killed" || lane.state === "cancelled") {
21
74
  return { lane, exclusion: lane.state };
22
75
  }
23
- const hqRoot = ctx.hqRoot();
76
+ const companyForLane = (candidate) => resolveLaneCompany(ctx.hqRoot(), candidate).company;
24
77
  const lineageAware = await readLanesRegistryFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, {
25
- company: resolveLaneCompany(hqRoot, lane).company ?? undefined,
78
+ company: companyForLane(lane),
26
79
  env: ctx.env,
27
80
  });
28
- const currentLane = readLane(hqRoot, lane.lane_id);
81
+ const currentLane = readLane(ctx.hqRoot(), lane.lane_id);
29
82
  if (currentLane.state === "killed" || currentLane.state === "cancelled") {
30
83
  return { lane: currentLane, exclusion: currentLane.state };
31
84
  }
32
- const lanes = loadAllLanes(hqRoot);
33
- const retiredLaneIds = loadRetiredLaneIds(hqRoot);
34
- return {
35
- lane: currentLane,
36
- exclusion: keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware),
37
- };
85
+ const localLanes = loadAllLanes(ctx.hqRoot());
86
+ let lanes = localLanes;
87
+ const retiredLaneIds = loadRetiredLaneIds(ctx.hqRoot());
88
+ const dispatchedCallerId = grantContext?.superseded_message_caller_id;
89
+ const dispatchedContextAuthorized = grantContext !== undefined &&
90
+ dispatchedCallerId !== undefined &&
91
+ ctx.isSsmOwnerDispatch() &&
92
+ sameHireGrantSenior(grantContext.expected_senior, currentLane.senior);
93
+ if (dispatchedContextAuthorized && grantContext) {
94
+ const merged = new Map(localLanes.map((record) => [record.lane_id, record]));
95
+ for (const snapshot of grantContext.hierarchy) {
96
+ const contextLane = keepAliveLaneFromSnapshot(snapshot);
97
+ if (contextLane)
98
+ merged.set(contextLane.lane_id, contextLane);
99
+ }
100
+ merged.set(currentLane.lane_id, currentLane);
101
+ lanes = [...merged.values()];
102
+ }
103
+ const exclusion = keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware, companyForLane);
104
+ const callerLaneId = dispatchedContextAuthorized ? dispatchedCallerId : undefined;
105
+ if (exclusion === "superseded" && callerMayMessageSupersededLane(ctx, currentLane, lanes, lineageAware, companyForLane, callerLaneId)) {
106
+ return {
107
+ lane: currentLane,
108
+ exclusion: undefined,
109
+ ...(!dispatchedContextAuthorized && ctx.env.HQ_LANE_ID?.trim()
110
+ ? { supersededMessageCallerId: ctx.env.HQ_LANE_ID.trim() }
111
+ : {}),
112
+ };
113
+ }
114
+ return { lane: currentLane, exclusion };
38
115
  }
39
116
  function recordMessageUndeliverable(ctx, lane, reason, messageCount) {
40
117
  writeWatcherDecision(watcherOutLogPath(ctx.hqRoot(), lane.lane_id), {
@@ -68,7 +145,14 @@ export function createLaneMessageHandlers(deps) {
68
145
  const resolution = await deps.resolveCommandLane(ctx, laneId);
69
146
  let lane = resolution.lane;
70
147
  if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
71
- await dispatchOutpostLaneCommand(ctx, lane, "message", opts, resolution.remoteOnly
148
+ const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
149
+ const supersededMessageCallerId = callerLaneId === undefined || resolution.remoteOnly
150
+ ? undefined
151
+ : (await messageLaneExclusion(ctx, lane)).supersededMessageCallerId;
152
+ await dispatchOutpostLaneCommand(ctx, lane, "message", {
153
+ ...opts,
154
+ ...(supersededMessageCallerId === undefined ? {} : { supersededMessageCallerId }),
155
+ }, resolution.remoteOnly
72
156
  ? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
73
157
  : undefined);
74
158
  return;
@@ -115,7 +199,7 @@ export function createLaneMessageHandlers(deps) {
115
199
  }
116
200
  }
117
201
  try {
118
- const eligibility = await messageLaneExclusion(ctx, lane);
202
+ const eligibility = await messageLaneExclusion(ctx, lane, hireGrantContext);
119
203
  lane = eligibility.lane;
120
204
  const exclusion = eligibility.exclusion;
121
205
  if (exclusion !== undefined) {
@@ -0,0 +1,12 @@
1
+ import type { Ctx, LaneMoveHandlerDeps } from "./lanes-shared.js";
2
+ export declare function createLaneMoveHandlers(deps: LaneMoveHandlerDeps): {
3
+ runMove: (ctx: Ctx, laneId: string, opts: {
4
+ placement?: string;
5
+ }) => Promise<void>;
6
+ runMoveTarget: (ctx: Ctx, laneId: string, opts: {
7
+ hostKind?: string;
8
+ hostId?: string;
9
+ account?: string;
10
+ }) => Promise<void>;
11
+ };
12
+ //# sourceMappingURL=lanes-move.d.ts.map