@indigoai-us/hq-cli 5.293.0 → 5.295.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 (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +5 -1
  3. package/dist/command-catalog.generated.d.ts +54 -33
  4. package/dist/command-catalog.generated.js +68 -42
  5. package/dist/commands/auth.d.ts +1 -0
  6. package/dist/commands/auth.js +76 -1
  7. package/dist/commands/core-command-catalog.js +3 -2
  8. package/dist/commands/core-delegate-w1b.d.ts +2 -0
  9. package/dist/commands/core-delegate-w1b.js +28 -2
  10. package/dist/commands/core-delegate.d.ts +3 -1
  11. package/dist/commands/core-delegate.js +35 -5
  12. package/dist/commands/core-handoff-fast.d.ts +18 -0
  13. package/dist/commands/core-handoff-fast.js +103 -0
  14. package/dist/commands/core-handoff-open-steps.d.ts +21 -0
  15. package/dist/commands/core-handoff-open-steps.js +264 -0
  16. package/dist/commands/core-handoff.d.ts +4 -2
  17. package/dist/commands/core-handoff.js +22 -249
  18. package/dist/commands/core-jobs-native.d.ts +2 -0
  19. package/dist/commands/core-jobs-native.js +22 -11
  20. package/dist/commands/core-knowledge-prefs.d.ts +2 -0
  21. package/dist/commands/core-knowledge-prefs.js +13 -1
  22. package/dist/commands/core-ontology-native.d.ts +2 -0
  23. package/dist/commands/core-ontology-native.js +22 -2
  24. package/dist/commands/core-policy.d.ts +2 -0
  25. package/dist/commands/core-policy.js +18 -1
  26. package/dist/commands/core.js +2 -1
  27. package/dist/commands/daemon.d.ts +2 -1
  28. package/dist/commands/daemon.js +22 -7
  29. package/dist/commands/doctor.js +0 -74
  30. package/dist/commands/lanes-autopilot.d.ts +11 -0
  31. package/dist/commands/lanes-autopilot.js +336 -0
  32. package/dist/commands/lanes-message.js +23 -5
  33. package/dist/commands/lanes-questions.js +8 -5
  34. package/dist/commands/lanes-resume.js +0 -3
  35. package/dist/commands/lanes-shared.d.ts +18 -2
  36. package/dist/commands/lanes-stories.d.ts +14 -0
  37. package/dist/commands/lanes-stories.js +308 -0
  38. package/dist/commands/lanes.d.ts +1 -9
  39. package/dist/commands/lanes.js +216 -481
  40. package/dist/fast-core.js +53 -25
  41. package/dist/fast-scaffold.d.ts +15 -7
  42. package/dist/fast-scaffold.js +167 -17
  43. package/dist/index.js +1 -10
  44. package/dist/lib/core-utils/sentry-report.d.ts +0 -19
  45. package/dist/lib/core-utils/sentry-report.js +8 -61
  46. package/dist/lib/core-utils/worktree-resume.d.ts +2 -0
  47. package/dist/lib/core-utils/worktree-resume.js +16 -0
  48. package/dist/lib/daemon/migrate.d.ts +8 -1
  49. package/dist/lib/daemon/migrate.js +19 -7
  50. package/dist/lib/doctor/checks/claude-wiring.js +9 -0
  51. package/dist/lib/lanes/autopilot-metrics.d.ts +99 -0
  52. package/dist/lib/lanes/autopilot-metrics.js +301 -0
  53. package/dist/lib/lanes/flag-keys.d.ts +2 -0
  54. package/dist/lib/lanes/flag-keys.js +2 -0
  55. package/dist/lib/lanes/manager-status-gate.d.ts +0 -17
  56. package/dist/lib/lanes/manager-status-gate.js +2 -43
  57. package/dist/lib/lanes/outpost-dispatch.d.ts +2 -0
  58. package/dist/lib/lanes/outpost-dispatch.js +7 -1
  59. package/dist/lib/lanes/outpost-hire-cache.d.ts +0 -1
  60. package/dist/lib/lanes/outpost-hire-cache.js +0 -1
  61. package/dist/lib/lanes/outpost-snapshot-memo.d.ts +6 -0
  62. package/dist/lib/lanes/outpost-snapshot-memo.js +14 -0
  63. package/dist/lib/lanes/reconcile.d.ts +2 -3
  64. package/dist/lib/lanes/reconcile.js +44 -38
  65. package/dist/lib/lanes/spawn.d.ts +0 -2
  66. package/dist/lib/lanes/spawn.js +18 -5
  67. package/dist/lib/lanes/store.d.ts +2 -0
  68. package/dist/lib/lanes/store.js +54 -3
  69. package/dist/lib/lanes/types.d.ts +18 -1
  70. package/dist/lib/lanes/types.js +1 -1
  71. package/dist/lib/lanes/watcher.d.ts +4 -3
  72. package/dist/lib/lanes/watcher.js +102 -34
  73. package/dist/lib/mesh/live/daemon/run.js +18 -1
  74. package/dist/native-fast-route.d.ts +3 -1
  75. package/dist/native-fast-route.js +10 -8
  76. package/dist/utils/cognito-session.d.ts +6 -0
  77. package/dist/utils/cognito-session.js +9 -0
  78. package/dist/utils/contribution-table.d.ts +1 -1
  79. package/dist/utils/fast-stream-errors.d.ts +12 -0
  80. package/dist/utils/fast-stream-errors.js +18 -0
  81. package/dist/utils/scaffold-fast-import-guard.d.ts +2 -2
  82. package/dist/utils/scaffold-fast-import-guard.js +6 -3
  83. package/dist/utils/self-update.js +31 -0
  84. package/dist/utils/version-gate.js +22 -3
  85. package/package.json +1 -1
  86. package/dist/fast-lanes-list-route.d.ts +0 -8
  87. package/dist/fast-lanes-list-route.js +0 -32
  88. package/dist/fast-lanes-list.d.ts +0 -3
  89. package/dist/fast-lanes-list.js +0 -224
  90. package/dist/lib/doctor/client-health-sentry-flag.d.ts +0 -23
  91. package/dist/lib/doctor/client-health-sentry-flag.js +0 -99
  92. package/dist/lib/doctor/client-health-sentry.d.ts +0 -71
  93. package/dist/lib/doctor/client-health-sentry.js +0 -286
  94. package/dist/lib/lanes/outpost-snapshot-cache.d.ts +0 -26
  95. package/dist/lib/lanes/outpost-snapshot-cache.js +0 -215
  96. package/dist/lib/lanes/outpost-snapshot-refresh.d.ts +0 -21
  97. package/dist/lib/lanes/outpost-snapshot-refresh.js +0 -329
@@ -86,9 +86,9 @@ async function loadDefaults(over) {
86
86
  fs.writeFileSync(p, text, { mode: 0o644 });
87
87
  }),
88
88
  legacyUnits: over.legacyUnits ??
89
- (() => findLegacyUnits({
89
+ ((dir) => findLegacyUnits({
90
90
  platform,
91
- home,
91
+ home: dir ?? home,
92
92
  existsSync: fs.existsSync,
93
93
  listDir: (dir) => fs.readdirSync(dir),
94
94
  windowsTaskExists: (task) => run("schtasks.exe", ["/Query", "/TN", task]).status === 0,
@@ -246,11 +246,25 @@ async function runInstall(d, opts) {
246
246
  }
247
247
  d.out(`Installed and started: ${result.unitPath}`);
248
248
  }
249
- function migrateDeps(d, paths) {
249
+ /** Root acting for a box's login user needs that user's uid to reach their systemd manager. */
250
+ function loginUid(d, user) {
251
+ const r = d.run("id", ["-u", user]);
252
+ const uid = Number(r.stdout.trim());
253
+ if (r.status !== 0 || !Number.isInteger(uid) || uid < 0 || r.stdout.trim() === "") {
254
+ throw new DaemonCommandError(`Could not look up the uid of ${user}.`);
255
+ }
256
+ return uid;
257
+ }
258
+ /** Where the older units are: a box's login user's home when root runs this for them. */
259
+ function legacyUnitsFor(d, host) {
260
+ return d.isRoot && host.runAsUser ? d.legacyUnits(d.userHome(host.runAsUser)) : d.legacyUnits();
261
+ }
262
+ function migrateDeps(d, paths, runAsUser) {
250
263
  return {
251
264
  run: d.run,
252
265
  uid: d.uid,
253
266
  isRoot: d.isRoot,
267
+ userUnitsOwner: d.isRoot && runAsUser ? { user: runAsUser, uid: loginUid(d, runAsUser) } : undefined,
254
268
  adoptedDir: path.join(paths.root, "adopted"),
255
269
  recordPath: path.join(paths.root, "adopted.json"),
256
270
  // Box system units: root-owned, outside the login user's daemon folder (which handOver chowns to that user).
@@ -268,7 +282,8 @@ function unrestoredUnits(d, migrate) {
268
282
  async function runUninstall(d, opts) {
269
283
  const host = currentHost(d);
270
284
  const { paths, detected } = host;
271
- const migrate = migrateDeps(d, paths);
285
+ // Only a restore acts on the login user's units.
286
+ const migrate = migrateDeps(d, paths, opts.restoreLegacy ? host.runAsUser : undefined);
272
287
  const adopted = unrestoredUnits(d, migrate);
273
288
  if (opts.purge && !opts.restoreLegacy && adopted.length > 0) {
274
289
  throw new DaemonCommandError(`hq daemon took over ${adopted.map((u) => u.unitId).join(", ")}, and their original units are kept in ${migrate.adoptedDir}. ` +
@@ -547,13 +562,13 @@ async function runPinHqCloud(d, version, opts) {
547
562
  d.out("Run `hq daemon trigger updater`; wait for the hq-cloud update or current-version message in `hq daemon logs updater`, then restart `hq daemon restart sync`, `hq daemon restart heartbeat`, and `hq daemon restart session-host`.");
548
563
  }
549
564
  async function runMigrate(d, opts) {
550
- const units = d.legacyUnits();
565
+ const host = currentHost(d);
566
+ const units = legacyUnitsFor(d, host);
551
567
  if (units.length === 0) {
552
568
  d.out("No older HQ background units found. Nothing to migrate.");
553
569
  return;
554
570
  }
555
571
  if (opts.apply) {
556
- const host = currentHost(d);
557
572
  // Stopping the older units with nothing installed to replace them would
558
573
  // leave their services off.
559
574
  const installed = daemonUnitInstalled({
@@ -565,7 +580,7 @@ async function runMigrate(d, opts) {
565
580
  if (!installed) {
566
581
  throw new DaemonCommandError("hq daemon is not installed here yet. Run `hq daemon install` first, then `hq daemon migrate --apply`.");
567
582
  }
568
- const result = adoptLegacyUnits(units, migrateDeps(d, host.paths));
583
+ const result = adoptLegacyUnits(units, migrateDeps(d, host.paths, host.runAsUser));
569
584
  handOver(d, host);
570
585
  for (const a of result.adopted)
571
586
  d.out(`Took over ${a.unitId} from ${describeLegacyUnit(a)}`);
@@ -33,9 +33,6 @@ import { PARITY_FAMILY_ID, PARITY_FAMILY_TITLE, runParityReplay, } from "../lib/
33
33
  import { applyFixes } from "../lib/doctor/fix/apply.js";
34
34
  import { renderDoctorFixJson } from "../lib/doctor/fix/json-output.js";
35
35
  import { clientHealthReasonCodesEnabled as isActiveCompanyClientHealthReasonCodesEnabled, clientHealthReasonCodesEnabledForCompany, } from "../lib/doctor/client-health-reason-codes-flag.js";
36
- import { CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES, CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE, handleClientHealthSentryReport, } from "../lib/doctor/client-health-sentry.js";
37
- import { clientHealthSentryDirectEnabled, clientHealthSentryDirectEnabledForCompany, } from "../lib/doctor/client-health-sentry-flag.js";
38
- import { reportClientHealthEvents } from "../lib/core-utils/sentry-report.js";
39
36
  import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
40
37
  import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
41
38
  import * as readline from "node:readline";
@@ -244,26 +241,6 @@ function createClientHealthReasonCodeCompanyResolver(hqRoot) {
244
241
  });
245
242
  };
246
243
  }
247
- function createClientHealthSentryCompanyResolver(hqRoot) {
248
- const companies = readCompaniesManifestMap(hqRoot);
249
- return async (companySlug) => {
250
- if (companySlug === null)
251
- return clientHealthSentryDirectEnabled();
252
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(companySlug))
253
- return false;
254
- const manifestUid = companies?.[companySlug]?.cloud_uid;
255
- const activeSlug = process.env.HQ_COMPANY_SLUG?.trim() || "";
256
- const activeUid = process.env.HQ_COMPANY_UID?.trim() || "";
257
- const companyUid = typeof manifestUid === "string" && manifestUid.trim()
258
- ? manifestUid.trim()
259
- : companySlug === activeSlug
260
- ? activeUid
261
- : "";
262
- if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid))
263
- return false;
264
- return clientHealthSentryDirectEnabledForCompany({ companyUid, companySlug });
265
- };
266
- }
267
244
  /** Register the top-level `hq doctor` command so it appears in `hq --help`. */
268
245
  export function registerDoctorCommand(program) {
269
246
  program
@@ -279,9 +256,6 @@ export function registerDoctorCommand(program) {
279
256
  .option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
280
257
  .option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
281
258
  .addOption(new Option("--client-health-report", "Resolve the opt-in hq-flags gate used by the client-health report hook.").hideHelp())
282
- .addOption(new Option("--client-health-sentry", "Send the opt-in client-health Sentry report from a doctor JSON document.").hideHelp())
283
- .addOption(new Option("--json-input <file>", "Doctor JSON input for the client-health report.").hideHelp())
284
- .addOption(new Option("--client-health-fix-outcome <outcome>", "Safe fix outcome label for the client-health report.").hideHelp())
285
259
  .option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
286
260
  .option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
287
261
  .action(async (opts) => {
@@ -298,54 +272,6 @@ export function registerDoctorCommand(program) {
298
272
  process.exitCode = 1;
299
273
  return;
300
274
  }
301
- if (opts.clientHealthSentry === true) {
302
- const hqRoot = resolveHqRoot();
303
- if (!hqRoot || !opts.jsonInput) {
304
- process.stderr.write("hq doctor --client-health-sentry requires an HQ root and --json-input.\n");
305
- process.exitCode = 1;
306
- return;
307
- }
308
- try {
309
- const workspacePath = path.join(hqRoot, "workspace");
310
- const workspaceStat = fs.lstatSync(workspacePath);
311
- if (!workspaceStat.isDirectory() || workspaceStat.isSymbolicLink()) {
312
- throw new Error("workspace is not a local directory");
313
- }
314
- const statePath = path.join(hqRoot, "workspace", ".hq-client-health");
315
- const stateStat = fs.lstatSync(statePath);
316
- if (!stateStat.isDirectory() || stateStat.isSymbolicLink()) {
317
- throw new Error("client-health state is not a local directory");
318
- }
319
- const stateRoot = fs.realpathSync(statePath);
320
- const inputPath = fs.realpathSync(opts.jsonInput);
321
- const relativeInput = path.relative(stateRoot, inputPath);
322
- if (relativeInput === ".." || relativeInput.startsWith(`..${path.sep}`) || path.isAbsolute(relativeInput)) {
323
- throw new Error("input path is outside client-health state");
324
- }
325
- if (fs.statSync(inputPath).size > CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES) {
326
- throw new Error("input exceeds client-health size limit");
327
- }
328
- const result = await handleClientHealthSentryReport({
329
- hqRoot,
330
- doctorJson: fs.readFileSync(inputPath, "utf8"),
331
- fixOutcome: opts.clientHealthFixOutcome ?? "not_auto_fixable",
332
- }, {
333
- resolveFlagForCompany: createClientHealthSentryCompanyResolver(hqRoot),
334
- sendEvents: async (events) => (await reportClientHealthEvents(events)).code === 0,
335
- });
336
- process.exitCode =
337
- result === "not_handled"
338
- ? CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE
339
- : result === "send_failed"
340
- ? 1
341
- : 0;
342
- }
343
- catch {
344
- process.stderr.write("hq doctor --client-health-sentry could not handle the report.\n");
345
- process.exitCode = 1;
346
- }
347
- return;
348
- }
349
275
  // `--fix` is the only write path. It resolves the tree, applies the
350
276
  // allowlisted repairs behind a backup + confirmation, and returns its own
351
277
  // exit code; the read-only report below never runs in this branch.
@@ -0,0 +1,11 @@
1
+ import type { Command } from "commander";
2
+ import { type ReconcileHost } from "../lib/lanes/reconcile.js";
3
+ type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
4
+ export interface LaneAutopilotReportDeps {
5
+ hqRoot: () => string;
6
+ now: () => Date;
7
+ currentHost: () => ReconcileHost;
8
+ }
9
+ export declare function registerLaneAutopilotReportCommand(parent: Command, deps: LaneAutopilotReportDeps, wrap: WrapHandler): void;
10
+ export {};
11
+ //# sourceMappingURL=lanes-autopilot.d.ts.map
@@ -0,0 +1,336 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { laneRunsHere } from "../lib/lanes/reconcile.js";
4
+ import { listLaneReviews } from "../lib/lanes/reviews.js";
5
+ import { readAllLanes } from "../lib/lanes/store.js";
6
+ import { LANES_RUNS_REL } from "../lib/lanes/spawn.js";
7
+ import { normalizeIsoTimestamp } from "../lib/lanes/timestamp.js";
8
+ import { buildAutopilotMetricsReport, } from "../lib/lanes/autopilot-metrics.js";
9
+ import { LanesError } from "../lib/lanes/errors.js";
10
+ const OPERATOR_NUDGE_DEFECTS_REL = path.join("workspace", "lane-briefs", "operator-nudge-defects.jsonl");
11
+ const JUNIORS_REL = path.join("workspace", "lane-briefs", "juniors.json");
12
+ const DROPBOX_NAME = "dropbox";
13
+ const RESUME_SUFFIX = ".resume.jsonl";
14
+ function fail(error, message, details = {}) {
15
+ throw new LanesError(error, message, details);
16
+ }
17
+ function isObject(value) {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+ function safeReadText(root, file, missingOk = false) {
21
+ let stat;
22
+ try {
23
+ stat = fs.lstatSync(file);
24
+ }
25
+ catch (err) {
26
+ if (missingOk && err.code === "ENOENT")
27
+ return null;
28
+ return fail("autopilot_metrics_input_unreadable", `Could not inspect report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
29
+ }
30
+ if (!stat.isFile() || stat.isSymbolicLink()) {
31
+ return fail("autopilot_metrics_input_invalid", "Report input must be a regular file", { path: file });
32
+ }
33
+ let realRoot;
34
+ let realFile;
35
+ try {
36
+ realRoot = fs.realpathSync(root);
37
+ realFile = fs.realpathSync(file);
38
+ }
39
+ catch (err) {
40
+ return fail("autopilot_metrics_input_unreadable", `Could not resolve report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
41
+ }
42
+ if (!realFile.startsWith(`${realRoot}${path.sep}`)) {
43
+ return fail("autopilot_metrics_input_invalid", "Report input must stay under the HQ root", { path: file });
44
+ }
45
+ try {
46
+ return fs.readFileSync(file, "utf8");
47
+ }
48
+ catch (err) {
49
+ return fail("autopilot_metrics_input_unreadable", `Could not read report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
50
+ }
51
+ }
52
+ function parseJsonObject(root, file) {
53
+ const raw = safeReadText(root, file);
54
+ try {
55
+ const parsed = JSON.parse(raw ?? "");
56
+ if (isObject(parsed))
57
+ return parsed;
58
+ }
59
+ catch (err) {
60
+ return fail("autopilot_metrics_input_invalid", `Report input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, { path: file });
61
+ }
62
+ return fail("autopilot_metrics_input_invalid", "Report input must be a JSON object", { path: file });
63
+ }
64
+ function isCurrentHostLabel(label, host) {
65
+ return label === "local" ? host.outpostId === null : label === host.outpostId || label === host.hostname;
66
+ }
67
+ function loadJuniors(root, host) {
68
+ const file = path.join(root, JUNIORS_REL);
69
+ const parsed = parseJsonObject(root, file);
70
+ if (!Array.isArray(parsed.juniors))
71
+ return fail("autopilot_metrics_input_invalid", "Team registry must contain a juniors array", { path: file });
72
+ const seen = new Set();
73
+ return parsed.juniors.flatMap((value, index) => {
74
+ if (!isObject(value) || typeof value.host !== "string" || !value.host.trim()) {
75
+ return fail("autopilot_metrics_input_invalid", "Team registry row must identify its host", { path: file, index });
76
+ }
77
+ if (!isCurrentHostLabel(value.host, host))
78
+ return [];
79
+ if (["team", "lane", "status", "prd"].some((field) => typeof value[field] !== "string" || !value[field].trim())) {
80
+ return fail("autopilot_metrics_input_invalid", "Selected team registry row must contain team, lane, status, and prd strings", { path: file, index });
81
+ }
82
+ const entry = value;
83
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(entry.team) || entry.lane.includes("/") || entry.lane.includes("\\")) {
84
+ return fail("autopilot_metrics_input_invalid", "Team registry row contains an unsafe team or lane id", { path: file, index });
85
+ }
86
+ if (seen.has(entry.lane))
87
+ return fail("autopilot_metrics_mapping_ambiguous", "A lead lane appears more than once in the selected host registry", { path: file, lane_id: entry.lane });
88
+ seen.add(entry.lane);
89
+ return [entry];
90
+ });
91
+ }
92
+ function projectPath(root, raw, registryPath) {
93
+ if (path.isAbsolute(raw))
94
+ return fail("autopilot_metrics_mapping_invalid", "Project path must be relative to the HQ root", { path: registryPath, project_path: raw });
95
+ const absolute = path.resolve(root, raw);
96
+ if (!absolute.startsWith(`${path.resolve(root)}${path.sep}`)) {
97
+ return fail("autopilot_metrics_mapping_invalid", "Project path must stay under the HQ root", { path: registryPath, project_path: raw });
98
+ }
99
+ return absolute;
100
+ }
101
+ function loadProject(root, projectId, rawPrd) {
102
+ const registryPath = path.join(root, JUNIORS_REL);
103
+ const file = projectPath(root, rawPrd, registryPath);
104
+ const parsed = parseJsonObject(root, file);
105
+ const storiesValue = parsed.userStories === undefined ? parsed.stories : parsed.userStories;
106
+ if (!Array.isArray(storiesValue))
107
+ return fail("autopilot_metrics_input_invalid", "Project must contain a userStories or stories array", { path: file });
108
+ const stories = storiesValue.map((value, index) => {
109
+ if (!isObject(value) || typeof value.id !== "string" || !value.id.trim()) {
110
+ return fail("autopilot_metrics_input_invalid", "Project story must have a non-empty id", { path: file, index });
111
+ }
112
+ return value;
113
+ });
114
+ return { project_id: projectId, stories };
115
+ }
116
+ function parseIso(value, field, file) {
117
+ const normalized = normalizeIsoTimestamp(value);
118
+ if (normalized !== null)
119
+ return normalized;
120
+ if (typeof value === "string") {
121
+ const minutePrecision = /^(\d{4}-\d\d-\d\dT\d\d:\d\d)(Z|[+-]\d\d:\d\d)$/.exec(value);
122
+ if (minutePrecision) {
123
+ const withSeconds = normalizeIsoTimestamp(`${minutePrecision[1]}:00${minutePrecision[2]}`);
124
+ if (withSeconds !== null)
125
+ return withSeconds;
126
+ }
127
+ }
128
+ return fail("autopilot_metrics_input_invalid", `${field} must be an ISO timestamp`, { path: file, field });
129
+ }
130
+ function readJsonl(root, file, missingOk = false) {
131
+ const text = safeReadText(root, file, missingOk);
132
+ if (text === null)
133
+ return [];
134
+ const records = [];
135
+ for (const [index, line] of text.split("\n").entries()) {
136
+ if (!line.trim())
137
+ continue;
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(line);
141
+ }
142
+ catch (err) {
143
+ return fail("autopilot_metrics_input_invalid", `Report JSONL contains malformed JSON: ${err instanceof Error ? err.message : String(err)}`, { path: file, line: index + 1 });
144
+ }
145
+ if (!isObject(parsed))
146
+ return fail("autopilot_metrics_input_invalid", "Report JSONL rows must be objects", { path: file, line: index + 1 });
147
+ records.push(parsed);
148
+ }
149
+ return records;
150
+ }
151
+ function listRegularFiles(root, directory, output, depth = 0) {
152
+ let names;
153
+ try {
154
+ const stat = fs.lstatSync(directory);
155
+ if (stat.isSymbolicLink() || !stat.isDirectory())
156
+ return fail("autopilot_metrics_input_invalid", "Report path must be a regular directory", { path: directory });
157
+ const realRoot = fs.realpathSync(root);
158
+ const realDirectory = fs.realpathSync(directory);
159
+ if (!realDirectory.startsWith(`${realRoot}${path.sep}`))
160
+ return fail("autopilot_metrics_input_invalid", "Report directory must stay under the HQ root", { path: directory });
161
+ names = fs.readdirSync(directory);
162
+ }
163
+ catch (err) {
164
+ if (err.code === "ENOENT")
165
+ return;
166
+ if (err instanceof LanesError)
167
+ throw err;
168
+ return fail("autopilot_metrics_input_unreadable", `Could not list report directory: ${err instanceof Error ? err.message : String(err)}`, { path: directory });
169
+ }
170
+ for (const name of names) {
171
+ if (!name || name.startsWith("."))
172
+ continue;
173
+ const candidate = path.join(directory, name);
174
+ let stat;
175
+ try {
176
+ stat = fs.lstatSync(candidate);
177
+ }
178
+ catch (err) {
179
+ return fail("autopilot_metrics_input_unreadable", `Could not inspect report file: ${err instanceof Error ? err.message : String(err)}`, { path: candidate });
180
+ }
181
+ if (stat.isSymbolicLink())
182
+ return fail("autopilot_metrics_input_invalid", "Report directory may not contain symbolic links", { path: candidate });
183
+ if (stat.isFile() && name.endsWith(".jsonl"))
184
+ output.push(candidate);
185
+ else if (stat.isDirectory() && depth < 2)
186
+ listRegularFiles(root, candidate, output, depth + 1);
187
+ }
188
+ }
189
+ function readLeadMessages(root, laneId) {
190
+ const dropbox = path.join(root, LANES_RUNS_REL, laneId, DROPBOX_NAME);
191
+ const allFiles = [];
192
+ for (const name of ["draining", "inflight", "delivered", "undeliverable"])
193
+ listRegularFiles(root, path.join(dropbox, name), allFiles);
194
+ const inbox = path.join(dropbox, "inbox.jsonl");
195
+ try {
196
+ const stat = fs.lstatSync(inbox);
197
+ if (stat.isSymbolicLink() || !stat.isFile())
198
+ return fail("autopilot_metrics_input_invalid", "Lead inbox must be a regular file", { path: inbox });
199
+ allFiles.push(inbox);
200
+ }
201
+ catch (err) {
202
+ if (err.code !== "ENOENT")
203
+ throw err;
204
+ }
205
+ const seen = new Set();
206
+ const nudges = [];
207
+ const resumes = [];
208
+ for (const file of new Set(allFiles)) {
209
+ const resumeFile = file.endsWith(RESUME_SUFFIX);
210
+ for (const row of readJsonl(root, file)) {
211
+ // Undated records cannot be assigned to a report day, regardless of their file.
212
+ if (typeof row.at !== "string")
213
+ continue;
214
+ const at = parseIso(row.at, "at", file);
215
+ const text = typeof row.text === "string" ? row.text : "";
216
+ const messageId = typeof row.message_id === "string" ? row.message_id : `${at}\u0000${text}`;
217
+ if (!seen.has(messageId)) {
218
+ seen.add(messageId);
219
+ if (resumeFile || row.kind !== "monitor_event")
220
+ nudges.push({ team: "", at });
221
+ }
222
+ if (resumeFile)
223
+ resumes.push({ lane_id: laneId, at });
224
+ }
225
+ }
226
+ return { nudges, resumes };
227
+ }
228
+ function loadNudgeDefects(root, selectedTeams) {
229
+ const file = path.join(root, OPERATOR_NUDGE_DEFECTS_REL);
230
+ return readJsonl(root, file, true).flatMap((row, index) => {
231
+ if (typeof row.team === "string" && !selectedTeams.has(row.team))
232
+ return [];
233
+ const defect = typeof row.defect === "string" && row.defect.trim() ? row.defect : row.why;
234
+ if (typeof row.team !== "string" || !row.team.trim() || typeof defect !== "string" || !defect.trim()) {
235
+ return fail("autopilot_metrics_input_invalid", "Nudge-defect row must contain at, team, and defect (or legacy why)", { path: file, line: index + 1 });
236
+ }
237
+ return [{ at: parseIso(row.at, "at", file), team: row.team, defect }];
238
+ });
239
+ }
240
+ function loadReviews(root) {
241
+ const listing = listLaneReviews(root);
242
+ if (listing.skipped > 0)
243
+ return fail("autopilot_metrics_review_record_invalid", "One or more lane review records could not be read", { skipped: listing.skipped });
244
+ return listing.reviews.flatMap((review) => review.history.map((entry) => ({
245
+ repo: entry.repo,
246
+ pr: entry.pr,
247
+ recorded_at: entry.recorded_at,
248
+ worker_lane: entry.worker_lane,
249
+ })));
250
+ }
251
+ function selectLeads(root, host) {
252
+ const juniors = loadJuniors(root, host);
253
+ const lanes = readAllLanes(root);
254
+ const leads = [];
255
+ const projects = [];
256
+ const laneIds = new Set();
257
+ const projectIds = new Set();
258
+ for (const entry of juniors) {
259
+ const matchingLanes = lanes.filter((candidate) => candidate.lane_id === entry.lane || candidate.lane_id.startsWith(`${entry.lane}_`));
260
+ const lane = matchingLanes.find((candidate) => laneRunsHere(candidate, host));
261
+ if (!lane) {
262
+ return fail("autopilot_metrics_lead_missing", "Registered lead lane could not be read on this host", { team: entry.team, lane_id: entry.lane });
263
+ }
264
+ if (laneIds.has(lane.lane_id))
265
+ return fail("autopilot_metrics_mapping_ambiguous", "A lead lane appears more than once in the host report", { lane_id: lane.lane_id });
266
+ const projectKey = JSON.stringify([lane.project_id, projectPath(root, entry.prd, path.join(root, JUNIORS_REL))]);
267
+ const lead = { team: entry.team, lane_id: lane.lane_id, project_id: projectKey };
268
+ leads.push(lead);
269
+ laneIds.add(lane.lane_id);
270
+ if (!projectIds.has(projectKey)) {
271
+ projects.push(loadProject(root, projectKey, entry.prd));
272
+ projectIds.add(projectKey);
273
+ }
274
+ }
275
+ return { leads, projects, laneIds };
276
+ }
277
+ function windowFromOptions(now, options) {
278
+ const until = normalizeIsoTimestamp(now.toISOString());
279
+ if (until === null)
280
+ return fail("autopilot_metrics_window_invalid", "Current time is invalid");
281
+ if (options.since !== undefined && options.days !== undefined)
282
+ return fail("autopilot_metrics_window_invalid", "Use either --since or --days, not both");
283
+ if (options.since !== undefined) {
284
+ const since = normalizeIsoTimestamp(options.since);
285
+ if (since === null || Date.parse(since) >= Date.parse(until))
286
+ return fail("autopilot_metrics_window_invalid", "--since must be an ISO timestamp before now", { since: options.since });
287
+ return { since, until };
288
+ }
289
+ const days = options.days === undefined ? 3 : Number(options.days);
290
+ if (!Number.isSafeInteger(days) || days < 1 || days > 366)
291
+ return fail("autopilot_metrics_window_invalid", "--days must be an integer from 1 to 366", { days: options.days });
292
+ return { since: new Date(Date.parse(until) - days * 24 * 60 * 60 * 1000).toISOString(), until };
293
+ }
294
+ function printJson(value) {
295
+ console.log(JSON.stringify(value));
296
+ }
297
+ export function registerLaneAutopilotReportCommand(parent, deps, wrap) {
298
+ const autopilot = parent.command("autopilot").description("Measure automated lead-lane follow-through");
299
+ autopilot.command("report")
300
+ .description("Report operator nudges, review latency, and idle slots with ready work")
301
+ .option("--since <iso>", "Include activity since this ISO timestamp")
302
+ .option("--days <n>", "Include the most recent number of days")
303
+ .option("--json", "Print JSON")
304
+ .action(wrap(async (options) => {
305
+ const root = deps.hqRoot();
306
+ const window = windowFromOptions(deps.now(), options);
307
+ const host = deps.currentHost();
308
+ const selected = selectLeads(root, host);
309
+ const lanes = readAllLanes(root);
310
+ const nudges = [];
311
+ const resumes = [];
312
+ for (const lead of selected.leads) {
313
+ const events = readLeadMessages(root, lead.lane_id);
314
+ nudges.push(...events.nudges.map((event) => ({ ...event, team: lead.team })));
315
+ resumes.push(...events.resumes);
316
+ const record = lanes.find((lane) => lane.lane_id === lead.lane_id);
317
+ const lastResume = record?.last_resume?.at;
318
+ if (lastResume && !resumes.some((resume) => resume.lane_id === lead.lane_id && resume.at === lastResume))
319
+ resumes.push({ lane_id: lead.lane_id, at: lastResume });
320
+ }
321
+ const reviewListing = loadReviews(root);
322
+ const defects = loadNudgeDefects(root, new Set(selected.leads.map((lead) => lead.team)));
323
+ const result = buildAutopilotMetricsReport({
324
+ ...window,
325
+ leads: selected.leads,
326
+ lanes: lanes,
327
+ projects: selected.projects,
328
+ reviews: reviewListing,
329
+ nudges,
330
+ nudgeDefects: defects,
331
+ seniorResumes: resumes,
332
+ });
333
+ printJson(result);
334
+ }));
335
+ }
336
+ //# sourceMappingURL=lanes-autopilot.js.map
@@ -10,12 +10,11 @@ import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, w
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
12
  import { keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
13
- import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
14
13
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
15
14
  import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
16
15
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, callerMatchesSessionSenior, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
17
16
  export function createLaneMessageHandlers(deps) {
18
- const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
17
+ const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, dispatchPrimaryLaneMessage, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
19
18
  function currentSuccessorOf(lane, lanes, lineageAware, companyForLane) {
20
19
  const validLanes = lanes.filter((candidate) => Number.isFinite(Date.parse(candidate.created_at)));
21
20
  const newestFirst = [...validLanes].sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at));
@@ -221,7 +220,27 @@ export function createLaneMessageHandlers(deps) {
221
220
  if (!text) {
222
221
  throw new LanesError("missing_text", "--text is required", {});
223
222
  }
224
- const resolution = await deps.resolveCommandLane(ctx, laneId);
223
+ let resolution;
224
+ try {
225
+ resolution = await deps.resolveCommandLane(ctx, laneId);
226
+ }
227
+ catch (err) {
228
+ const identity = ctx.readOutpostIdentity();
229
+ const failedOutpostIds = err instanceof LanesError ? err.details.failed_outpost_ids : undefined;
230
+ const probedOutpostIds = err instanceof LanesError ? err.details.probed_outpost_ids : undefined;
231
+ const primaryLookupFailed = Array.isArray(failedOutpostIds) && failedOutpostIds.includes("primary");
232
+ const primaryReportedMissing = Array.isArray(probedOutpostIds) && probedOutpostIds.includes("primary");
233
+ if (!(err instanceof LanesError) ||
234
+ err.error !== "lane_not_found" ||
235
+ identity.kind !== "id" ||
236
+ identity.id === "primary" ||
237
+ primaryLookupFailed ||
238
+ primaryReportedMissing) {
239
+ throw err;
240
+ }
241
+ await dispatchPrimaryLaneMessage(ctx, laneId, { ...opts, text });
242
+ return;
243
+ }
225
244
  let lane = resolution.lane;
226
245
  if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
227
246
  const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
@@ -465,7 +484,6 @@ export function createLaneMessageHandlers(deps) {
465
484
  markAccepted();
466
485
  }
467
486
  else {
468
- const managerStatusGateEnabled = await managerStatusGateEnabledForLane(hqRoot, lane, { env: ctx.env });
469
487
  const dispatched = await dispatchLaneDelivery(lane, {
470
488
  dropbox: () => {
471
489
  // Live (or about-to-be-live) process: the PreToolUse drain delivers it.
@@ -523,7 +541,6 @@ export function createLaneMessageHandlers(deps) {
523
541
  }
524
542
  const started = spawnLaneWorker(hqRoot, lane, {
525
543
  env: ctx.env,
526
- managerStatusGateEnabled,
527
544
  modelDeps: {
528
545
  readFileSync: ctx.readFileSync,
529
546
  probe: ctx.probeProvider,
@@ -624,6 +641,7 @@ export function createLaneMessageHandlers(deps) {
624
641
  accepted: true,
625
642
  delivered: false,
626
643
  delivery: mechanism,
644
+ delivered_to: { host: lane.host.id, lane_id: laneId, mechanism },
627
645
  delivery_status: deliveryStatus,
628
646
  inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
629
647
  drain_hook: drainHook,
@@ -20,7 +20,7 @@ export function resolveAnswerChoice(choice, options, freeform) {
20
20
  throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
21
21
  }
22
22
  export function createLaneQuestionsHandlers(deps) {
23
- const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
23
+ const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
24
24
  function requireQuestionBody(hqRoot, question) {
25
25
  const body = readQuestionBody(hqRoot, question);
26
26
  if (!body) {
@@ -73,10 +73,11 @@ export function createLaneQuestionsHandlers(deps) {
73
73
  return;
74
74
  throw new LanesError("question_not_pending", `Question ${current.question_id} is ${current.status}`, { question_id: current.question_id, status: current.status });
75
75
  }
76
- async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
76
+ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options, lookupErrors = []) {
77
77
  if (lane.host.kind !== "outpost") {
78
78
  throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to answer its question`, { lane_id: lane.lane_id, question_id: questionId });
79
79
  }
80
+ assertQuestionAnswerAuthorized(ctx, lane);
80
81
  const outpostId = lane.host.id;
81
82
  let dispatched;
82
83
  try {
@@ -224,6 +225,7 @@ export function createLaneQuestionsHandlers(deps) {
224
225
  outpost_id: outpostId,
225
226
  outpost_command_id: dispatched.commandId,
226
227
  outpost_transport: "outposts_exec",
228
+ ...(lookupErrors.length > 0 ? { lookup_errors: lookupErrors } : {}),
227
229
  });
228
230
  }
229
231
  async function readOutpostQuestionDetail(ctx, owner) {
@@ -280,6 +282,7 @@ export function createLaneQuestionsHandlers(deps) {
280
282
  }
281
283
  async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
282
284
  const hostedLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === "outpost" &&
285
+ !laneIsHostedHere(ctx, lane) &&
283
286
  (!companyFilter ||
284
287
  effectiveLaneQuestionCompany(lane, hqRoot) === null ||
285
288
  questionCompanyRefMatches(effectiveLaneQuestionCompany(lane, hqRoot), companyFilter)));
@@ -584,13 +587,13 @@ export function createLaneQuestionsHandlers(deps) {
584
587
  throw err;
585
588
  const company = explicitCompany ?? await resolveActiveQuestionCompany(ctx, "Cannot look up this lane question because the active HQ company could not be resolved; re-run with --company <slug>");
586
589
  const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company);
587
- if (lookup.errors.length > 0) {
590
+ const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
591
+ if (matches.length === 0 && lookup.errors.length > 0) {
588
592
  throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
589
593
  question_id: questionId,
590
594
  hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
591
595
  });
592
596
  }
593
- const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
594
597
  if (matches.length === 0) {
595
598
  const allCompanyLookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
596
599
  const otherCompanyMatch = allCompanyLookup.questions.find((entry) => entry.question.question_id === questionId);
@@ -620,7 +623,7 @@ export function createLaneQuestionsHandlers(deps) {
620
623
  await dispatchOutpostQuestionAnswer(ctx, owner.lane, questionId, {
621
624
  ...opts,
622
625
  company: questionListCompanyArg(ownerCompany),
623
- });
626
+ }, lookup.errors);
624
627
  return;
625
628
  }
626
629
  await yieldEventLoop();