@indigoai-us/hq-cli 5.345.55 → 5.345.57

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 (66) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +10 -1
  3. package/assets/scaffold/core/scripts/jobs-validate.sh +2 -1
  4. package/assets/scaffold/core/scripts/session-title.sh +18 -15
  5. package/dist/command-catalog.generated.d.ts +124 -0
  6. package/dist/command-catalog.generated.js +155 -0
  7. package/dist/commands/core-delegate-w1b.js +18 -5
  8. package/dist/commands/core-derive-trigger-facts-command.js +14 -0
  9. package/dist/commands/core-derive-trigger-facts-native.d.ts +1 -0
  10. package/dist/commands/core-derive-trigger-facts-native.js +20 -1
  11. package/dist/commands/doctor.d.ts +1 -0
  12. package/dist/commands/doctor.js +15 -2
  13. package/dist/commands/lanes-checks.d.ts +3 -0
  14. package/dist/commands/lanes-checks.js +34 -0
  15. package/dist/commands/lanes-loop.d.ts +12 -0
  16. package/dist/commands/lanes-loop.js +214 -0
  17. package/dist/commands/lanes-questions.js +37 -11
  18. package/dist/commands/lanes-resume.js +19 -1
  19. package/dist/commands/lanes-session-start.d.ts +2 -7
  20. package/dist/commands/lanes-session-start.js +9 -29
  21. package/dist/commands/lanes-shared.d.ts +14 -2
  22. package/dist/commands/lanes.d.ts +3 -1
  23. package/dist/commands/lanes.js +376 -35
  24. package/dist/commands/members.d.ts +4 -0
  25. package/dist/commands/members.js +23 -2
  26. package/dist/lanes-drain-hook.js +6 -2
  27. package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
  28. package/dist/lib/doctor/checks/sync-health.js +47 -23
  29. package/dist/lib/lanes/delivery.d.ts +3 -0
  30. package/dist/lib/lanes/delivery.js +5 -0
  31. package/dist/lib/lanes/dropbox.d.ts +7 -2
  32. package/dist/lib/lanes/dropbox.js +157 -10
  33. package/dist/lib/lanes/flag-keys.d.ts +12 -0
  34. package/dist/lib/lanes/flag-keys.js +12 -0
  35. package/dist/lib/lanes/hooks-install.d.ts +14 -11
  36. package/dist/lib/lanes/hooks-install.js +70 -50
  37. package/dist/lib/lanes/loop.d.ts +158 -0
  38. package/dist/lib/lanes/loop.js +743 -0
  39. package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
  40. package/dist/lib/lanes/outpost-dispatch.js +21 -7
  41. package/dist/lib/lanes/reaper.d.ts +58 -0
  42. package/dist/lib/lanes/reaper.js +403 -0
  43. package/dist/lib/lanes/reconcile.d.ts +19 -0
  44. package/dist/lib/lanes/reconcile.js +173 -6
  45. package/dist/lib/lanes/spawn.d.ts +4 -0
  46. package/dist/lib/lanes/spawn.js +41 -9
  47. package/dist/lib/lanes/status-fields.d.ts +12 -0
  48. package/dist/lib/lanes/status-fields.js +189 -0
  49. package/dist/lib/lanes/store.js +49 -2
  50. package/dist/lib/lanes/types.d.ts +18 -0
  51. package/dist/lib/lanes/types.js +3 -3
  52. package/dist/lib/lanes/watcher.js +3 -0
  53. package/dist/lib/lanes/worker-continuity.d.ts +18 -0
  54. package/dist/lib/lanes/worker-continuity.js +81 -0
  55. package/dist/lib/lanes/worker-process.d.ts +4 -0
  56. package/dist/lib/lanes/worker-process.js +77 -0
  57. package/dist/main.js +9 -3
  58. package/dist/utils/cli-telemetry.d.ts +4 -1
  59. package/dist/utils/cli-telemetry.js +8 -0
  60. package/dist/utils/jq-windows-compat.d.ts +13 -0
  61. package/dist/utils/jq-windows-compat.js +48 -0
  62. package/dist/utils/run-bundled-script.d.ts +3 -0
  63. package/dist/utils/run-bundled-script.js +9 -80
  64. package/dist/utils/windows-bash.d.ts +5 -0
  65. package/dist/utils/windows-bash.js +75 -0
  66. package/package.json +1 -1
@@ -48,6 +48,8 @@ export interface InviteOptions {
48
48
  companyUid: string;
49
49
  callerUid: string;
50
50
  token: string;
51
+ /** Reports only whether the request returned a 2xx response. */
52
+ onRequestResult?: (ok: boolean) => void;
51
53
  }
52
54
  /**
53
55
  * Outcome of `hq members invite`. Two shapes depending on server schema:
@@ -96,6 +98,8 @@ export interface ResendInviteOptions {
96
98
  companyUid: string;
97
99
  callerUid: string;
98
100
  token: string;
101
+ /** Reports only whether the request returned a 2xx response. */
102
+ onRequestResult?: (ok: boolean) => void;
99
103
  }
100
104
  /**
101
105
  * Outcome of `hq members invite --resend`. No new membership row was
@@ -3,6 +3,7 @@ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
3
3
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
4
4
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
5
5
  import { isPlanGateError } from "../utils/plan-gate-error.js";
6
+ import { queueCliFeatureUsed } from "../utils/cli-telemetry.js";
6
7
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
7
8
  const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
9
  const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/i;
@@ -107,6 +108,7 @@ export async function inviteMember(options) {
107
108
  body,
108
109
  });
109
110
  if (!res.ok) {
111
+ options.onRequestResult?.(false);
110
112
  const err = (await res.json().catch(() => ({})));
111
113
  throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
112
114
  }
@@ -116,6 +118,7 @@ export async function inviteMember(options) {
116
118
  throw new Error(`Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
117
119
  "This is a server-side regression — file an issue.");
118
120
  }
121
+ options.onRequestResult?.(true);
119
122
  // Two server schemas in the wild:
120
123
  // - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
121
124
  // the invitee redeems via `hq://accept/{token}`.
@@ -177,6 +180,7 @@ export async function resendInvite(options) {
177
180
  },
178
181
  });
179
182
  if (!res.ok) {
183
+ options.onRequestResult?.(false);
180
184
  const err = (await res.json().catch(() => ({})));
181
185
  throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
182
186
  }
@@ -184,6 +188,7 @@ export async function resendInvite(options) {
184
188
  if (data.resent !== true) {
185
189
  throw new Error("Resend endpoint returned 2xx without `resent: true` — the connected hq-pro likely doesn't support `resend: true` yet. Upgrade hq-pro or re-invite without --resend.");
186
190
  }
191
+ options.onRequestResult?.(true);
187
192
  return {
188
193
  resent: true,
189
194
  emailSent: data.emailSent === true,
@@ -436,6 +441,7 @@ export function registerMembersCommand(program) {
436
441
  let token;
437
442
  let companyUid;
438
443
  let companySlug;
444
+ let outcome = "failure";
439
445
  try {
440
446
  token = (await resolveVaultCredential()).token;
441
447
  companySlug = members.opts().company;
@@ -448,6 +454,9 @@ export function registerMembersCommand(program) {
448
454
  companyUid,
449
455
  callerUid,
450
456
  token,
457
+ onRequestResult: (ok) => {
458
+ outcome = ok ? "success" : "failure";
459
+ },
451
460
  });
452
461
  console.log(chalk.green(`Re-fired invite email for ${target.toLowerCase()} (existing pending row left untouched)`));
453
462
  console.log();
@@ -472,6 +481,9 @@ export function registerMembersCommand(program) {
472
481
  companyUid,
473
482
  callerUid,
474
483
  token,
484
+ onRequestResult: (ok) => {
485
+ outcome = ok ? "success" : "failure";
486
+ },
475
487
  });
476
488
  const agentTarget = detectTarget(target)?.isAgent === true;
477
489
  console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
@@ -612,10 +624,19 @@ export function registerMembersCommand(program) {
612
624
  }
613
625
  if (err instanceof InviteHttpError) {
614
626
  console.error(chalk.red(formatInviteHttpError(err.status, err.message, err.code)));
615
- process.exit(1);
627
+ process.exitCode = 1;
628
+ return;
616
629
  }
617
630
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
618
- process.exit(1);
631
+ process.exitCode = 1;
632
+ return;
633
+ }
634
+ finally {
635
+ queueCliFeatureUsed({
636
+ kind: "members_invite",
637
+ outcome,
638
+ ...(companyUid ? { companyUid } : {}),
639
+ });
619
640
  }
620
641
  });
621
642
  members
@@ -17,7 +17,7 @@ function parseHookInput(text) {
17
17
  throw new Error("invalid_hook_input_shape: expected a JSON object");
18
18
  }
19
19
  const value = parsed;
20
- for (const key of ["transcript_path", "session_id", "hook_event_name"]) {
20
+ for (const key of ["transcript_path", "session_id", "hook_event_name", "hookEventName"]) {
21
21
  if (value[key] !== undefined && value[key] !== null && typeof value[key] !== "string") {
22
22
  throw new Error(`invalid_hook_input_field: ${key} must be a string`);
23
23
  }
@@ -25,7 +25,11 @@ function parseHookInput(text) {
25
25
  return {
26
26
  ...(typeof value.transcript_path === "string" ? { transcript_path: value.transcript_path } : {}),
27
27
  ...(typeof value.session_id === "string" ? { session_id: value.session_id } : {}),
28
- ...(typeof value.hook_event_name === "string" ? { hook_event_name: value.hook_event_name } : {}),
28
+ ...(typeof value.hook_event_name === "string"
29
+ ? { hook_event_name: value.hook_event_name }
30
+ : typeof value.hookEventName === "string"
31
+ ? { hookEventName: value.hookEventName }
32
+ : {}),
29
33
  };
30
34
  }
31
35
  function laneIdIsSafe(laneId) {
@@ -59,6 +59,8 @@ export interface SyncManifestUploadStatus {
59
59
  snapshotId: string | null;
60
60
  sequence: number;
61
61
  baseUsable: boolean;
62
+ /** Local company slug for company-scoped remediation; absent for personal. */
63
+ companySlug?: string;
62
64
  }
63
65
  /** Injectable dependencies — defaults are the real collectors. */
64
66
  export interface SyncHealthDeps {
@@ -32,6 +32,7 @@ import * as path from "node:path";
32
32
  // ../../hq-cloud-manifest.js.
33
33
  import * as hqCloud from "@indigoai-us/hq-cloud";
34
34
  import { loadManifestExports, MANIFEST_UNAVAILABLE_REASON, } from "../../hq-cloud-manifest.js";
35
+ import { readCompaniesManifestMap } from "../../work-context/repo-remote.js";
35
36
  import { CLI_VERSION } from "../../../cli-version.js";
36
37
  import { readSyncVersion } from "../../../utils/feedback-versions.js";
37
38
  import { readHqVersion } from "../../../utils/pack-contributions.js";
@@ -52,9 +53,9 @@ function withDefaults(deps, hqRoot) {
52
53
  return {
53
54
  ...deps,
54
55
  runtime: deps.runtime ?? (() => []),
55
- manifestStatuses: deps.manifestStatuses ?? defaultManifestStatuses,
56
+ manifestStatuses: deps.manifestStatuses ?? ((journals) => defaultManifestStatuses(hqRoot, journals)),
56
57
  manifestAvailable: deps.manifestAvailable ?? (() => loadManifestExports() !== null),
57
- unresolvedScopes: deps.unresolvedScopes ?? defaultUnresolvedScopes,
58
+ unresolvedScopes: deps.unresolvedScopes ?? ((journals) => defaultUnresolvedScopes(hqRoot, journals)),
58
59
  localCompanyExists: deps.localCompanyExists ??
59
60
  ((slug) => defaultLocalCompanyExists(hqRoot, slug)),
60
61
  };
@@ -78,15 +79,35 @@ function defaultLocalCompanyExists(hqRoot, slug) {
78
79
  return false;
79
80
  }
80
81
  }
81
- /**
82
- * Company journal shards are unresolvable by the default collector: the
83
- * snapshot store keys company scopes by `companyUid`, which `listJournals()`
84
- * does not carry. Naming them here keeps them visible.
85
- */
86
- function defaultUnresolvedScopes(journals) {
87
- return journals
88
- .filter((entry) => !NON_COMPANY_JOURNAL_SLUGS.has(entry.slug))
89
- .map((entry) => entry.slug);
82
+ /** Resolve locally-known company journal slugs to the UID used by snapshots. */
83
+ function resolvedCompanyScopes(hqRoot, journals) {
84
+ const companies = readCompaniesManifestMap(hqRoot);
85
+ const seen = new Set();
86
+ const scopes = [];
87
+ for (const { slug } of journals) {
88
+ if (NON_COMPANY_JOURNAL_SLUGS.has(slug) ||
89
+ !isSafeCompanySlug(slug) ||
90
+ !defaultLocalCompanyExists(hqRoot, slug) ||
91
+ seen.has(slug)) {
92
+ continue;
93
+ }
94
+ seen.add(slug);
95
+ const uid = companies?.[slug]?.cloud_uid;
96
+ if (typeof uid === "string" && /^cmp_[A-Za-z0-9]{3,128}$/.test(uid.trim())) {
97
+ scopes.push({ kind: "company", companyUid: uid.trim(), slug });
98
+ }
99
+ }
100
+ return scopes;
101
+ }
102
+ function defaultUnresolvedScopes(hqRoot, journals) {
103
+ const resolved = new Set(resolvedCompanyScopes(hqRoot, journals).map((scope) => scope.slug));
104
+ return [
105
+ ...new Set(journals
106
+ .filter((entry) => !NON_COMPANY_JOURNAL_SLUGS.has(entry.slug) &&
107
+ defaultLocalCompanyExists(hqRoot, entry.slug) &&
108
+ !resolved.has(entry.slug))
109
+ .map((entry) => entry.slug)),
110
+ ];
90
111
  }
91
112
  function defaultVersions(hqRoot) {
92
113
  return {
@@ -119,21 +140,15 @@ const DEFAULT_DEPS = {
119
140
  // than no check.
120
141
  journals: () => hqCloud.listJournals(),
121
142
  now: () => new Date(),
122
- manifestStatuses: defaultManifestStatuses,
123
143
  runtime: defaultSyncRuntimeResults,
124
144
  };
125
145
  /**
126
146
  * Map the machine's journal shards onto the manifest scopes hq-cloud keys its
127
- * snapshot store by, then read each one's last upload.
128
- *
129
- * The journal slug `personal` is the personal tree; every other slug is a
130
- * company. Note the snapshot store keys company scopes by `companyUid`, which
131
- * the journal listing does not carry — so a company whose uid we cannot name
132
- * is simply not reported here rather than reported wrongly. That is the honest
133
- * outcome: this check exists to tell an operator when a manifest STOPPED being
134
- * uploaded, and inventing a scope key would make it lie in both directions.
147
+ * snapshot store by, then read each one's last upload. Company journal rows
148
+ * carry only slugs, so resolve their UIDs from the local companies manifest;
149
+ * a missing or malformed UID stays explicitly unresolved below.
135
150
  */
136
- function defaultManifestStatuses(journals) {
151
+ function defaultManifestStatuses(hqRoot, journals) {
137
152
  // Both the legacy `personal` shard and the current personal-vault
138
153
  // pseudo-slug name the SAME personal tree; a machine mid-migration has both
139
154
  // on disk, so collapse them to a single personal scope rather than asking
@@ -141,6 +156,7 @@ function defaultManifestStatuses(journals) {
141
156
  const scopes = journals.some((entry) => NON_COMPANY_JOURNAL_SLUGS.has(entry.slug))
142
157
  ? [{ kind: "personal" }]
143
158
  : [];
159
+ scopes.push(...resolvedCompanyScopes(hqRoot, journals));
144
160
  if (scopes.length === 0)
145
161
  return [];
146
162
  // Absent on an older hq-cloud; the NA row is emitted by `manifestResults`
@@ -148,7 +164,15 @@ function defaultManifestStatuses(journals) {
148
164
  const exports = loadManifestExports();
149
165
  if (!exports)
150
166
  return [];
151
- return exports.readManifestUploadStatus(exports.getStateDir(), scopes);
167
+ const companySlugsByUid = new Map(scopes
168
+ .filter((scope) => scope.kind === "company")
169
+ .map((scope) => [scope.companyUid, scope.slug]));
170
+ const statuses = exports.readManifestUploadStatus(exports.getStateDir(), scopes);
171
+ return statuses.map((status) => {
172
+ const companyUid = /^company:(cmp_[A-Za-z0-9]{3,128})$/.exec(status.scopeKey)?.[1];
173
+ const companySlug = companyUid ? companySlugsByUid.get(companyUid) : undefined;
174
+ return companySlug ? { ...status, companySlug } : status;
175
+ });
152
176
  }
153
177
  /** The legacy journal slug that named the personal (non-company) tree. */
154
178
  const PERSONAL_SCOPE_SLUG = "personal";
@@ -526,7 +550,7 @@ function manifestResults(deps, journals) {
526
550
  checkId,
527
551
  reasonCode: "stale-threshold",
528
552
  message: `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt}) — the daily audit pass is not running.`,
529
- remediation: "Run `hq sync manifest` and check HQ_SYNC_MANIFEST_DISABLED and the sync runner.",
553
+ remediation: `Run \`hq sync manifest${entry.companySlug ? ` --scope ${entry.companySlug}` : ""}\` and check HQ_SYNC_MANIFEST_DISABLED and the sync runner.`,
530
554
  };
531
555
  }
532
556
  return {
@@ -144,6 +144,8 @@ export type LaunchTrigger = {
144
144
  kind: "resume";
145
145
  /** Lane state at the moment of the command; recorded as `last_resume.resumed_from`. */
146
146
  from: ResumeCommandState | "failed" | "awaiting_input";
147
+ /** Loop phases may deliberately start a fresh session at a story boundary. */
148
+ fresh?: true;
147
149
  allowAwaitingInput?: true;
148
150
  fromFailed?: true;
149
151
  /** Senior that authorized a failed-lane resume, rechecked at the claim CAS. */
@@ -164,6 +166,7 @@ export type LaunchPlan = {
164
166
  export declare function resumeCommandRefusal(lane: DeliveryLane, options?: {
165
167
  allowFailed?: boolean;
166
168
  allowAwaitingInput?: boolean;
169
+ allowFresh?: boolean;
167
170
  }): {
168
171
  error: "lane_not_resumable" | "resume_unsupported" | "no_provider_session";
169
172
  message: string;
@@ -371,6 +371,8 @@ export function resumeCommandRefusal(lane, options = {}) {
371
371
  : "a lane in this state is not resumed."}`,
372
372
  };
373
373
  }
374
+ if (options.allowFresh === true)
375
+ return null;
374
376
  if (!providerSupportsSessionResume(lane.provider)) {
375
377
  return {
376
378
  error: "resume_unsupported",
@@ -411,7 +413,10 @@ export function planLaneLaunch(lane, trigger) {
411
413
  const refusal = resumeCommandRefusal(lane, {
412
414
  allowFailed: trigger.fromFailed === true || isFailedQuotaKeepAliveLane(lane),
413
415
  allowAwaitingInput: trigger.allowAwaitingInput === true,
416
+ allowFresh: trigger.fresh === true,
414
417
  });
418
+ if (trigger.fresh === true && refusal === null)
419
+ return { mode: "fresh" };
415
420
  if (refusal !== null || !hasRecordedSessionId(lane)) {
416
421
  throw new LanesError(refusal?.error ?? "no_provider_session", refusal?.message ?? `Lane is ${lane.state} and has no recorded provider session`, { state: lane.state, provider: lane.provider });
417
422
  }
@@ -53,12 +53,14 @@ export interface DrainDeps {
53
53
  }
54
54
  export interface LaneDrainHookInput {
55
55
  transcript_path?: string | null;
56
- session_id?: string;
57
56
  hook_event_name?: string;
57
+ hookEventName?: string;
58
+ session_id?: string;
58
59
  }
59
60
  export interface RunDrainHookDeps {
60
61
  readLane?: typeof tryReadLane;
61
62
  probeCompactionsSince?: typeof import("./compaction.js").probeProviderCompactionsSince;
63
+ readLifecycleDeliveryFlag?: () => Promise<boolean>;
62
64
  }
63
65
  export declare function mintClaimId(): string;
64
66
  export declare function assertSafeLaneId(laneId: string): void;
@@ -88,8 +90,11 @@ export interface ToolActivityRecord {
88
90
  export declare function recordToolActivity(hqRoot: string, laneId: string, at?: string): ToolActivityRecord;
89
91
  export declare function readToolActivity(hqRoot: string, laneId: string): ToolActivityRecord | null;
90
92
  export declare function appendDropboxMessage(hqRoot: string, laneId: string, message: DropboxMessage): DropboxMessage;
93
+ export declare function appendDropboxMessageOnce(hqRoot: string, laneId: string, message: DropboxMessage): DropboxMessage;
91
94
  /** Append using the same on-disk layout for lane and monitor-session inboxes. */
92
95
  export declare function appendDropboxMessageAtPath(box: string, message: DropboxMessage): DropboxMessage;
96
+ /** Append an idempotent message once, including retries after its inbox was archived. */
97
+ export declare function appendDropboxMessageOnceAtPath(box: string, message: DropboxMessage): DropboxMessage;
93
98
  /**
94
99
  * Drain this lane's inbox exactly once. Concurrent callers: one rename wins;
95
100
  * the loser sees ENOENT and returns whatever it separately claimed (usually
@@ -102,7 +107,7 @@ export declare function formatOperatorInstruction(messages: DropboxMessage[]): s
102
107
  export declare function formatMonitorEvent(message: DropboxMessage): string;
103
108
  /** Keep event order while framing monitor output separately from operator messages. */
104
109
  export declare function formatDropboxMessages(messages: DropboxMessage[]): string;
105
- export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "UserPromptSubmit", leadingNote?: string): string;
110
+ export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "PostToolUse" | "UserPromptSubmit", leadingNote?: string): string;
106
111
  export interface DrainHookResult {
107
112
  exitCode: number;
108
113
  stdout: string;
@@ -7,13 +7,15 @@
7
7
  * A read-then-truncate is not this mechanism and loses or duplicates under
8
8
  * overlap.
9
9
  */
10
- import { randomBytes } from "node:crypto";
10
+ import { createHash, randomBytes } from "node:crypto";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { LanesError } from "./errors.js";
14
14
  import { MESSAGE_COMPACTION_REPLAY_NOTE, MESSAGE_REPLAY_STATE_NAME, readMessageReplayState, updateMessageReplayTranscript, writeMessageReplayState, } from "./message-replay.js";
15
15
  import { LANES_RUNS_REL, readProcStarttime } from "./spawn.js";
16
16
  import { tryReadLane } from "./store.js";
17
+ import { readLanesRegistryFlagFailOpen } from "./registry-flag.js";
18
+ import { LANE_DROPBOX_LIFECYCLE_DELIVERY_FLAG } from "./flag-keys.js";
17
19
  export const DROPBOX_DIR = "dropbox";
18
20
  export const INBOX_NAME = "inbox.jsonl";
19
21
  export const DRAINING_DIR = "draining";
@@ -177,6 +179,9 @@ export function readToolActivity(hqRoot, laneId) {
177
179
  export function appendDropboxMessage(hqRoot, laneId, message) {
178
180
  return appendDropboxMessageAtPath(dropboxDir(hqRoot, laneId), message);
179
181
  }
182
+ export function appendDropboxMessageOnce(hqRoot, laneId, message) {
183
+ return appendDropboxMessageOnceAtPath(dropboxDir(hqRoot, laneId), message);
184
+ }
180
185
  /** Append using the same on-disk layout for lane and monitor-session inboxes. */
181
186
  export function appendDropboxMessageAtPath(box, message) {
182
187
  const inbox = path.join(box, INBOX_NAME);
@@ -184,6 +189,105 @@ export function appendDropboxMessageAtPath(box, message) {
184
189
  fs.appendFileSync(inbox, `${JSON.stringify(message)}\n`, "utf8");
185
190
  return message;
186
191
  }
192
+ /** Append an idempotent message once, including retries after its inbox was archived. */
193
+ export function appendDropboxMessageOnceAtPath(box, message) {
194
+ const messageId = message.message_id?.trim();
195
+ if (!messageId) {
196
+ throw new LanesError("dropbox_message_id_required", "Idempotent dropbox delivery requires a message id");
197
+ }
198
+ fs.mkdirSync(box, { recursive: true });
199
+ const digest = createHash("sha256").update(messageId).digest("hex");
200
+ const lockDir = path.join(box, `.message-${digest}.lock`);
201
+ const lockTimeoutAt = Date.now() + 5_000;
202
+ while (true) {
203
+ try {
204
+ fs.mkdirSync(lockDir);
205
+ break;
206
+ }
207
+ catch (error) {
208
+ if (error.code !== "EEXIST")
209
+ throw error;
210
+ try {
211
+ if (Date.now() - fs.statSync(lockDir).mtimeMs > 30_000) {
212
+ fs.rmSync(lockDir, { recursive: true, force: true });
213
+ continue;
214
+ }
215
+ }
216
+ catch (statError) {
217
+ if (statError.code === "ENOENT")
218
+ continue;
219
+ throw statError;
220
+ }
221
+ if (Date.now() >= lockTimeoutAt) {
222
+ throw new LanesError("dropbox_message_claim_busy", `Timed out claiming message ${messageId}`, { message_id: messageId });
223
+ }
224
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
225
+ }
226
+ }
227
+ try {
228
+ const inbox = path.join(box, INBOX_NAME);
229
+ const files = [inbox, ...messageHistoryFiles(box)];
230
+ for (const file of files) {
231
+ let content;
232
+ try {
233
+ content = fs.readFileSync(file, "utf8");
234
+ }
235
+ catch (error) {
236
+ if (error.code === "ENOENT")
237
+ continue;
238
+ throw error;
239
+ }
240
+ for (const line of content.split(/\r?\n/)) {
241
+ if (!line.trim())
242
+ continue;
243
+ let parsed;
244
+ try {
245
+ parsed = JSON.parse(line);
246
+ }
247
+ catch (error) {
248
+ throw new LanesError("dropbox_message_history_invalid", `Cannot verify message ${messageId} against ${file}`, {
249
+ message_id: messageId,
250
+ path: file,
251
+ cause: error instanceof Error ? error.message : String(error),
252
+ });
253
+ }
254
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) &&
255
+ parsed.message_id === messageId)
256
+ return message;
257
+ }
258
+ }
259
+ fs.appendFileSync(inbox, `${JSON.stringify(message)}\n`, "utf8");
260
+ return message;
261
+ }
262
+ finally {
263
+ fs.rmSync(lockDir, { recursive: true, force: true });
264
+ }
265
+ }
266
+ function messageHistoryFiles(box) {
267
+ const files = [];
268
+ for (const directory of [DRAINING_DIR, INFLIGHT_DIR, DELIVERED_DIR]) {
269
+ const root = path.join(box, directory);
270
+ const visit = (current) => {
271
+ let entries;
272
+ try {
273
+ entries = fs.readdirSync(current, { withFileTypes: true });
274
+ }
275
+ catch (error) {
276
+ if (error.code === "ENOENT")
277
+ return;
278
+ throw error;
279
+ }
280
+ for (const entry of entries) {
281
+ if (entry.isDirectory())
282
+ visit(path.join(current, entry.name));
283
+ else if (entry.isFile())
284
+ files.push(path.join(current, entry.name));
285
+ }
286
+ };
287
+ visit(root);
288
+ }
289
+ return files;
290
+ }
187
291
  function listNames(dir, deps) {
188
292
  try {
189
293
  return deps.readdirSync(dir).filter((name) => !name.startsWith("."));
@@ -426,6 +530,9 @@ export function hookAdditionalContextJson(messages, hookEventName = "PreToolUse"
426
530
  },
427
531
  })}\n`;
428
532
  }
533
+ function hookStopBlockJson(reason) {
534
+ return `${JSON.stringify({ decision: "block", reason })}\n`;
535
+ }
429
536
  function replayStatePath(hqRoot, laneId) {
430
537
  return path.join(dropboxDir(hqRoot, laneId), MESSAGE_REPLAY_STATE_NAME);
431
538
  }
@@ -465,14 +572,47 @@ export async function runDrainHook(env, input = {}, deps = {}) {
465
572
  stderr: "hq-lanes-drain: HQ_ROOT is unset while HQ_LANE_ID is set\n",
466
573
  };
467
574
  }
468
- // Liveness first: a failed drain must still leave proof the worker called
469
- // a tool. A failure here is reported, never fatal to delivery.
470
- let stderr = "";
471
- try {
472
- recordToolActivity(hqRoot, laneId);
575
+ const hookEvent = input.hook_event_name ?? input.hookEventName ?? "PreToolUse";
576
+ if (hookEvent !== "PreToolUse" && hookEvent !== "PostToolUse" && hookEvent !== "Stop") {
577
+ return { exitCode: 0, stdout: "", stderr: "" };
473
578
  }
474
- catch (err) {
475
- stderr += `hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`;
579
+ // Grok invokes lifecycle hooks, but their output cannot reach the model.
580
+ // Preserve queued messages for its next deliverable PreToolUse event.
581
+ if (env.HQ_LANE_PROVIDER === "grok" && hookEvent !== "PreToolUse") {
582
+ return { exitCode: 0, stdout: "", stderr: "" };
583
+ }
584
+ const hasQueuedMessages = dropboxInboxHasMessages(hqRoot, laneId);
585
+ if (hookEvent === "Stop" && !hasQueuedMessages) {
586
+ return { exitCode: 0, stdout: "", stderr: "" };
587
+ }
588
+ if ((hookEvent === "PostToolUse" || hookEvent === "Stop") && hasQueuedMessages) {
589
+ const lane = (deps.readLane ?? tryReadLane)(hqRoot, laneId);
590
+ const company = lane?.mesh_company ?? lane?.company ?? undefined;
591
+ let enabled = true;
592
+ try {
593
+ enabled = deps.readLifecycleDeliveryFlag
594
+ ? await deps.readLifecycleDeliveryFlag()
595
+ : await readLanesRegistryFlagFailOpen(LANE_DROPBOX_LIFECYCLE_DELIVERY_FLAG, {
596
+ ...(company ? { company } : {}),
597
+ env,
598
+ });
599
+ }
600
+ catch (err) {
601
+ process.stderr.write(`hq-lanes-drain: lifecycle delivery flag read failed open: ${err instanceof Error ? err.message : String(err)}\n`);
602
+ }
603
+ if (!enabled)
604
+ return { exitCode: 0, stdout: "", stderr: "" };
605
+ }
606
+ // PreToolUse remains the tool-activity liveness touch used by watcher.ts.
607
+ // PostToolUse and Stop add delivery opportunities without extra liveness writes.
608
+ let stderr = "";
609
+ if (hookEvent === "PreToolUse") {
610
+ try {
611
+ recordToolActivity(hqRoot, laneId);
612
+ }
613
+ catch (err) {
614
+ stderr += `hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`;
615
+ }
476
616
  }
477
617
  try {
478
618
  const turnId = env.HQ_LANE_TURN_ID ?? "";
@@ -515,15 +655,22 @@ export async function runDrainHook(env, input = {}, deps = {}) {
515
655
  }
516
656
  }
517
657
  if (replayNote && replayed && replayed.length > 0) {
658
+ const formatted = `${replayNote}\n\n${formatDropboxMessages(replayed)}`;
518
659
  return {
519
660
  exitCode: 0,
520
- stdout: hookAdditionalContextJson(replayed, "PreToolUse", replayNote),
661
+ stdout: hookEvent === "Stop"
662
+ ? hookStopBlockJson(formatted)
663
+ : hookAdditionalContextJson(replayed, hookEvent, replayNote),
521
664
  stderr,
522
665
  };
523
666
  }
524
667
  if (messages.length === 0)
525
668
  return { exitCode: 0, stdout: "", stderr };
526
- return { exitCode: 0, stdout: hookAdditionalContextJson(messages), stderr };
669
+ const formatted = formatDropboxMessages(messages);
670
+ if (hookEvent === "Stop") {
671
+ return { exitCode: 0, stdout: hookStopBlockJson(formatted), stderr };
672
+ }
673
+ return { exitCode: 0, stdout: hookAdditionalContextJson(messages, hookEvent), stderr };
527
674
  }
528
675
  catch (err) {
529
676
  const message = err instanceof Error ? err.message : String(err);
@@ -16,6 +16,8 @@ export declare const LANE_HIRE_GRANT_FINISHED_LANES_FLAG = "lanes.hire-grant-cou
16
16
  export declare const LANE_LIST_SKIP_REMOTE_REFRESH_FLAG = "lanes.list-skip-remote-refresh";
17
17
  /** Enables missed-deadline lead nudges and second-miss operator escalation. */
18
18
  export declare const LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
19
+ /** Enables durable six-hour escalation for lanes that remain blocked. */
20
+ export declare const LANE_BLOCKED_ESCALATION_FLAG = "lanes.blocked-escalation-6h";
19
21
  /** Enables company-scoped lookup of a hostless lane ULID across Outpost indexes. */
20
22
  export declare const LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG = "lanes.message-route-owner-host";
21
23
  /** Enables stale manager-status KPI reporting and one-time lead nudges. */
@@ -30,6 +32,16 @@ export declare const LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG = "lanes.autopilot
30
32
  export declare const LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG = "lanes.orphaned-bare-id-dropbox-repair";
31
33
  /** Allows a Claude lane to leave an exhausted profile account pin for an eligible account. */
32
34
  export declare const LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG = "lanes.profile-pin-exhausted-fallback";
35
+ /** Kill switch for PostToolUse and Stop drop-box delivery. Default on. */
36
+ export declare const LANE_DROPBOX_LIFECYCLE_DELIVERY_FLAG = "lanes.dropbox-lifecycle-delivery";
37
+ /** Kill switch for bounded local status fields in lane list/show JSON. Default on. */
38
+ export declare const LANE_STATUS_ROW_FIELDS_FLAG = "lanes.status-row-fields";
33
39
  /** Enables conduct-mode SessionStart bootstrapping (defaults on; operator kill switch). */
34
40
  export declare const LANE_CONDUCT_SESSION_START_FLAG = "lanes.conduct-session-start";
41
+ /** Kill switch for stopping local orphan lane process trees; off means report-only. */
42
+ export declare const LANE_REAP_STOP_FLAG = "lanes.reap-stop";
43
+ /** Enables same-senior worker lane continuity on create; keep ON as the kill switch default. */
44
+ export declare const LANE_WORKER_CONTINUITY_FLAG = "lanes.worker-continuity";
45
+ /** Kill switch for conduct loop lanes; absence or read failure keeps it enabled. */
46
+ export declare const LANE_CONDUCT_LOOP_FLAG = "lanes.conduct-loop";
35
47
  //# sourceMappingURL=flag-keys.d.ts.map
@@ -16,6 +16,8 @@ export const LANE_HIRE_GRANT_FINISHED_LANES_FLAG = "lanes.hire-grant-count-live-
16
16
  export const LANE_LIST_SKIP_REMOTE_REFRESH_FLAG = "lanes.list-skip-remote-refresh";
17
17
  /** Enables missed-deadline lead nudges and second-miss operator escalation. */
18
18
  export const LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
19
+ /** Enables durable six-hour escalation for lanes that remain blocked. */
20
+ export const LANE_BLOCKED_ESCALATION_FLAG = "lanes.blocked-escalation-6h";
19
21
  /** Enables company-scoped lookup of a hostless lane ULID across Outpost indexes. */
20
22
  export const LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG = "lanes.message-route-owner-host";
21
23
  /** Enables stale manager-status KPI reporting and one-time lead nudges. */
@@ -30,6 +32,16 @@ export const LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG = "lanes.autopilot-outpost
30
32
  export const LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG = "lanes.orphaned-bare-id-dropbox-repair";
31
33
  /** Allows a Claude lane to leave an exhausted profile account pin for an eligible account. */
32
34
  export const LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG = "lanes.profile-pin-exhausted-fallback";
35
+ /** Kill switch for PostToolUse and Stop drop-box delivery. Default on. */
36
+ export const LANE_DROPBOX_LIFECYCLE_DELIVERY_FLAG = "lanes.dropbox-lifecycle-delivery";
37
+ /** Kill switch for bounded local status fields in lane list/show JSON. Default on. */
38
+ export const LANE_STATUS_ROW_FIELDS_FLAG = "lanes.status-row-fields";
33
39
  /** Enables conduct-mode SessionStart bootstrapping (defaults on; operator kill switch). */
34
40
  export const LANE_CONDUCT_SESSION_START_FLAG = "lanes.conduct-session-start";
41
+ /** Kill switch for stopping local orphan lane process trees; off means report-only. */
42
+ export const LANE_REAP_STOP_FLAG = "lanes.reap-stop";
43
+ /** Enables same-senior worker lane continuity on create; keep ON as the kill switch default. */
44
+ export const LANE_WORKER_CONTINUITY_FLAG = "lanes.worker-continuity";
45
+ /** Kill switch for conduct loop lanes; absence or read failure keeps it enabled. */
46
+ export const LANE_CONDUCT_LOOP_FLAG = "lanes.conduct-loop";
35
47
  //# sourceMappingURL=flag-keys.js.map