@indigoai-us/hq-cli 5.126.0 → 5.128.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 (78) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  3. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  4. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  5. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  6. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  7. package/dist/command-catalog.generated.d.ts +245 -11
  8. package/dist/command-catalog.generated.js +323 -11
  9. package/dist/command-registration-plan.d.ts +1 -0
  10. package/dist/command-registration-plan.js +11 -2
  11. package/dist/commands/agents.d.ts +12 -0
  12. package/dist/commands/agents.js +46 -2
  13. package/dist/commands/lanes.d.ts +55 -16
  14. package/dist/commands/lanes.js +1187 -250
  15. package/dist/commands/mesh.js +254 -19
  16. package/dist/lib/core-utils/sentry-report.d.ts +2 -0
  17. package/dist/lib/core-utils/sentry-report.js +47 -7
  18. package/dist/lib/lanes/accounts.d.ts +67 -7
  19. package/dist/lib/lanes/accounts.js +233 -43
  20. package/dist/lib/lanes/compaction.d.ts +96 -0
  21. package/dist/lib/lanes/compaction.js +197 -0
  22. package/dist/lib/lanes/config.d.ts +15 -0
  23. package/dist/lib/lanes/config.js +65 -8
  24. package/dist/lib/lanes/delivery.d.ts +151 -0
  25. package/dist/lib/lanes/delivery.js +354 -0
  26. package/dist/lib/lanes/dropbox.d.ts +96 -0
  27. package/dist/lib/lanes/dropbox.js +408 -0
  28. package/dist/lib/lanes/effort.d.ts +50 -0
  29. package/dist/lib/lanes/effort.js +109 -0
  30. package/dist/lib/lanes/errors.d.ts +2 -2
  31. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  32. package/dist/lib/lanes/hooks-install.js +326 -0
  33. package/dist/lib/lanes/hq-root.d.ts +19 -0
  34. package/dist/lib/lanes/hq-root.js +28 -0
  35. package/dist/lib/lanes/index.d.ts +35 -5
  36. package/dist/lib/lanes/index.js +19 -4
  37. package/dist/lib/lanes/liveness.d.ts +7 -1
  38. package/dist/lib/lanes/liveness.js +64 -5
  39. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  40. package/dist/lib/lanes/machine-identity.js +92 -0
  41. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  42. package/dist/lib/lanes/mesh-events.js +275 -0
  43. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  44. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  45. package/dist/lib/lanes/questions.d.ts +75 -0
  46. package/dist/lib/lanes/questions.js +243 -0
  47. package/dist/lib/lanes/quota.d.ts +95 -0
  48. package/dist/lib/lanes/quota.js +320 -0
  49. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  50. package/dist/lib/lanes/reconcile-install.js +221 -0
  51. package/dist/lib/lanes/reconcile.d.ts +102 -0
  52. package/dist/lib/lanes/reconcile.js +985 -0
  53. package/dist/lib/lanes/registry.d.ts +18 -5
  54. package/dist/lib/lanes/registry.js +293 -19
  55. package/dist/lib/lanes/spawn.d.ts +274 -0
  56. package/dist/lib/lanes/spawn.js +1363 -0
  57. package/dist/lib/lanes/store.d.ts +7 -3
  58. package/dist/lib/lanes/store.js +150 -32
  59. package/dist/lib/lanes/types.d.ts +346 -4
  60. package/dist/lib/lanes/types.js +138 -5
  61. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  62. package/dist/lib/lanes/watcher-main.js +22 -0
  63. package/dist/lib/lanes/watcher.d.ts +216 -0
  64. package/dist/lib/lanes/watcher.js +859 -0
  65. package/dist/lib/mesh/api.d.ts +71 -7
  66. package/dist/lib/mesh/api.js +167 -28
  67. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  68. package/dist/lib/mesh/live/backfill-held.js +66 -21
  69. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/run.js +111 -54
  71. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  72. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  73. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  74. package/dist/lib/scan-packages/index.d.ts +2 -0
  75. package/dist/lib/scan-packages/index.js +1 -1
  76. package/dist/lib/work-context/state.d.ts +12 -0
  77. package/dist/lib/work-context/state.js +131 -3
  78. package/package.json +3 -1
@@ -12,6 +12,8 @@ import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { workMeshSpoolPath, workMeshRoot } from "../mesh/live/paths.js";
15
+ import { daemonDir, daemonStatePath } from "../mesh/live/daemon/paths.js";
16
+ import { LanesError } from "./errors.js";
15
17
  import { DEFAULT_LIVE_WITHIN_MS, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, loadLanesConfig, } from "./config.js";
16
18
  import { lanesStoreRoot } from "./store.js";
17
19
  export const LIVE_WITHIN_MS = DEFAULT_LIVE_WITHIN_MS;
@@ -64,8 +66,9 @@ export function classifySessionLiveness(entry, nowMs, presenceConnected = false,
64
66
  const kind = entry.lastKind;
65
67
  if (kind === "session_end")
66
68
  return "ended";
67
- if (presenceConnected)
68
- return "live";
69
+ // Box-wide MQTT connected is not per-session presence. A crashed
70
+ // mid-turn session must still become `lost` so §6.8 can advance.
71
+ void presenceConnected;
69
72
  if (kind === "turn_start" && age < windows.turn_start_lost_ms)
70
73
  return "live";
71
74
  if (age <= windows.live_within_ms)
@@ -219,7 +222,7 @@ export function readLivenessIndex(hqRoot, deps = defaultLivenessDeps()) {
219
222
  * Return the compact index, rebuilding from the spool when missing or stale.
220
223
  * Never walks workspace/sessions/.
221
224
  */
222
- export function loadLivenessIndex(hqRoot, spoolPath, deps = defaultLivenessDeps()) {
225
+ export function loadLivenessIndex(hqRoot, spoolPath, deps = defaultLivenessDeps(), persist = true) {
223
226
  const spool = spoolPath ?? defaultSpoolPath(deps);
224
227
  const existing = (() => {
225
228
  try {
@@ -247,8 +250,62 @@ export function loadLivenessIndex(hqRoot, spoolPath, deps = defaultLivenessDeps(
247
250
  return existing;
248
251
  }
249
252
  }
253
+ if (!persist) {
254
+ let contents = "";
255
+ let stSize = 0;
256
+ let stMtime = 0;
257
+ try {
258
+ const st = deps.statSync(spool);
259
+ stSize = st.size;
260
+ stMtime = Math.floor(st.mtimeMs);
261
+ contents = deps.readFileSync(spool, "utf8");
262
+ }
263
+ catch (err) {
264
+ const code = err.code;
265
+ if (code !== "ENOENT")
266
+ throw err;
267
+ }
268
+ return buildLivenessIndexFromSpool(contents, {
269
+ spool_path: spool,
270
+ spool_bytes: stSize,
271
+ spool_mtime_ms: stMtime,
272
+ built_at: new Date(deps.now()).toISOString(),
273
+ });
274
+ }
250
275
  return rebuildLivenessIndex(hqRoot, spool, deps);
251
276
  }
277
+ /**
278
+ * Spec §9: a session is also `live` when the mesh daemon reports presence
279
+ * connected, not only from the spool. Missing state file → not connected.
280
+ * Unreadable or corrupt state is an error (unknown must not resolve to healthy).
281
+ */
282
+ export function readDaemonPresenceConnected(deps = defaultLivenessDeps()) {
283
+ const statePath = daemonStatePath(daemonDir(undefined, deps.homedir(), deps.env));
284
+ if (!deps.existsSync(statePath))
285
+ return false;
286
+ let raw;
287
+ try {
288
+ raw = deps.readFileSync(statePath, "utf8");
289
+ }
290
+ catch (err) {
291
+ process.stderr.write(`lanes liveness: failed to read daemon state ${statePath}: ${err instanceof Error ? err.message : String(err)}\n`);
292
+ throw err;
293
+ }
294
+ let parsed;
295
+ try {
296
+ parsed = JSON.parse(raw);
297
+ }
298
+ catch (err) {
299
+ throw new LanesError("daemon_presence_unreadable", `Mesh daemon state ${statePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, { path: statePath });
300
+ }
301
+ if (!isPlainObject(parsed)) {
302
+ throw new LanesError("daemon_presence_unreadable", `Mesh daemon state ${statePath} is not an object`, { path: statePath });
303
+ }
304
+ if (parsed.v !== 1) {
305
+ throw new LanesError("daemon_presence_unreadable", `Mesh daemon state ${statePath} has unexpected v ${JSON.stringify(parsed.v)}`, { path: statePath, v: parsed.v });
306
+ }
307
+ return parsed.mqttState === "connected";
308
+ }
252
309
  export function sessionLiveness(hqRoot, sessionId, opts = {}) {
253
310
  const deps = opts.deps ?? defaultLivenessDeps();
254
311
  const index = loadLivenessIndex(hqRoot, opts.spoolPath, deps);
@@ -256,16 +313,18 @@ export function sessionLiveness(hqRoot, sessionId, opts = {}) {
256
313
  if (!entry)
257
314
  return null;
258
315
  const windows = opts.windows ?? loadLanesConfig(hqRoot).liveness;
259
- return classifySessionLiveness(entry, opts.nowMs ?? deps.now(), opts.presenceConnected ?? false, windows);
316
+ const presenceConnected = opts.presenceConnected ?? readDaemonPresenceConnected(deps);
317
+ return classifySessionLiveness(entry, opts.nowMs ?? deps.now(), presenceConnected, windows);
260
318
  }
261
319
  export function listSessionLiveness(hqRoot, opts = {}) {
262
320
  const deps = opts.deps ?? defaultLivenessDeps();
263
321
  const index = loadLivenessIndex(hqRoot, opts.spoolPath, deps);
264
322
  const nowMs = opts.nowMs ?? deps.now();
265
323
  const windows = opts.windows ?? loadLanesConfig(hqRoot).liveness;
324
+ const presenceConnected = opts.presenceConnected ?? readDaemonPresenceConnected(deps);
266
325
  return Object.values(index.sessions).map((entry) => ({
267
326
  sessionId: entry.sessionId,
268
- state: classifySessionLiveness(entry, nowMs, opts.presenceConnected ?? false, windows),
327
+ state: classifySessionLiveness(entry, nowMs, presenceConnected, windows),
269
328
  lastKind: entry.lastKind,
270
329
  lastAt: entry.lastAt,
271
330
  }));
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared machine identity for Outpost self-target refusal (spec §5.2).
3
+ *
4
+ * One function, one fallback order, used by both the creating side
5
+ * (`origin_host` stamp) and the executing side (setup-time compare).
6
+ * EC2 instance id from IMDS, then hostname. Unresolvable is a status,
7
+ * never a guessed id.
8
+ *
9
+ * Only Outpost lane create and Outpost lane setup may call this. A local
10
+ * lane must not touch IMDS / the network.
11
+ */
12
+ export declare const IMDS_HOST = "169.254.169.254";
13
+ export declare const IMDS_TOKEN_URL = "http://169.254.169.254/latest/api/token";
14
+ export declare const IMDS_INSTANCE_ID_URL = "http://169.254.169.254/latest/meta-data/instance-id";
15
+ export declare const IMDS_TIMEOUT_MS = 250;
16
+ export type MachineIdentity = {
17
+ status: "ok";
18
+ id: string;
19
+ source: "imds" | "hostname";
20
+ imds?: string | null;
21
+ hostname?: string | null;
22
+ } | {
23
+ status: "unresolvable";
24
+ reason: string;
25
+ };
26
+ export interface MachineIdentityDeps {
27
+ /** Returns the IMDS instance id, or null when IMDS is unavailable. */
28
+ readImdsInstanceId?: () => string | null;
29
+ hostname?: () => string;
30
+ }
31
+ /** IMDSv2 token then instance-id. Any failure is null, not a thrown guess. */
32
+ export declare function defaultReadImdsInstanceId(): string | null;
33
+ export declare function resolveMachineIdentity(deps?: MachineIdentityDeps): MachineIdentity;
34
+ //# sourceMappingURL=machine-identity.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Shared machine identity for Outpost self-target refusal (spec §5.2).
3
+ *
4
+ * One function, one fallback order, used by both the creating side
5
+ * (`origin_host` stamp) and the executing side (setup-time compare).
6
+ * EC2 instance id from IMDS, then hostname. Unresolvable is a status,
7
+ * never a guessed id.
8
+ *
9
+ * Only Outpost lane create and Outpost lane setup may call this. A local
10
+ * lane must not touch IMDS / the network.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import * as os from "node:os";
14
+ export const IMDS_HOST = "169.254.169.254";
15
+ export const IMDS_TOKEN_URL = `http://${IMDS_HOST}/latest/api/token`;
16
+ export const IMDS_INSTANCE_ID_URL = `http://${IMDS_HOST}/latest/meta-data/instance-id`;
17
+ export const IMDS_TIMEOUT_MS = 250;
18
+ function curlImds(method, url, headers) {
19
+ const args = ["-sS", "-f", "-m", "1", "-X", method];
20
+ for (const [key, value] of Object.entries(headers)) {
21
+ args.push("-H", `${key}: ${value}`);
22
+ }
23
+ args.push(url);
24
+ try {
25
+ const result = spawnSync("curl", args, {
26
+ encoding: "utf8",
27
+ timeout: IMDS_TIMEOUT_MS,
28
+ });
29
+ if (result.error || result.status !== 0)
30
+ return null;
31
+ const body = (result.stdout ?? "").trim();
32
+ return body.length > 0 ? body : null;
33
+ }
34
+ catch (err) {
35
+ process.stderr.write(`hq lanes: IMDS request failed for ${url}: ${err instanceof Error ? err.message : String(err)}\n`);
36
+ return null;
37
+ }
38
+ }
39
+ /** IMDSv2 token then instance-id. Any failure is null, not a thrown guess. */
40
+ export function defaultReadImdsInstanceId() {
41
+ const token = curlImds("PUT", IMDS_TOKEN_URL, {
42
+ "X-aws-ec2-metadata-token-ttl-seconds": "60",
43
+ });
44
+ if (!token)
45
+ return null;
46
+ return curlImds("GET", IMDS_INSTANCE_ID_URL, {
47
+ "X-aws-ec2-metadata-token": token,
48
+ });
49
+ }
50
+ export function resolveMachineIdentity(deps = {}) {
51
+ const readImds = deps.readImdsInstanceId ?? defaultReadImdsInstanceId;
52
+ let imdsId = null;
53
+ try {
54
+ const raw = readImds();
55
+ imdsId = typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
56
+ }
57
+ catch (err) {
58
+ process.stderr.write(`hq lanes: IMDS identity lookup failed: ${err instanceof Error ? err.message : String(err)}\n`);
59
+ }
60
+ let hostname = null;
61
+ try {
62
+ const hostnameFn = deps.hostname ?? (() => os.hostname());
63
+ const host = hostnameFn();
64
+ hostname = typeof host === "string" && host.trim() !== "" ? host.trim() : null;
65
+ }
66
+ catch (err) {
67
+ process.stderr.write(`hq lanes: hostname identity lookup failed: ${err instanceof Error ? err.message : String(err)}\n`);
68
+ if (!imdsId) {
69
+ return {
70
+ status: "unresolvable",
71
+ reason: err instanceof Error ? err.message : String(err),
72
+ };
73
+ }
74
+ }
75
+ if (imdsId) {
76
+ return { status: "ok", id: imdsId, source: "imds", imds: imdsId, hostname };
77
+ }
78
+ if (hostname) {
79
+ return {
80
+ status: "ok",
81
+ id: hostname,
82
+ source: "hostname",
83
+ imds: null,
84
+ hostname,
85
+ };
86
+ }
87
+ return {
88
+ status: "unresolvable",
89
+ reason: "IMDS unavailable and hostname empty",
90
+ };
91
+ }
92
+ //# sourceMappingURL=machine-identity.js.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Lane → work mesh events (spec §8).
3
+ *
4
+ * The lane side speaks the spec's vocabulary: lane_started, lane_progress,
5
+ * lane_question, lane_question_at_user, lane_ask, lane_blocked, lane_done.
6
+ * The work-mesh append endpoint accepts a closed roster of event kinds
7
+ * (hq-pro `src/vault-service/handlers/work-mesh.ts`, `validKinds`) and
8
+ * strips every payload field its per-kind validator does not name
9
+ * (hq-pro `src/vault-service/work-mesh/events.ts`). A `lane_*` kind on the
10
+ * wire is a 400. `toWireThreadEvent` is therefore the single place that
11
+ * turns a lane event into a roster kind whose payload carries the lane
12
+ * identity in the text fields the server keeps.
13
+ *
14
+ * Mapping, and why each kind:
15
+ *
16
+ * | lane event | wire kind | server effect |
17
+ * |------------------------|----------------|----------------------------------|
18
+ * | lane_started | run_started | status-neutral activity |
19
+ * | lane_progress | run_progress | status-neutral activity |
20
+ * | lane_done | run_complete | status-neutral; story → review |
21
+ * | lane_blocked | note (system) | status-neutral; the project |
22
+ * | | | thread is shared by every lane, |
23
+ * | | | so one lane must not flip it to |
24
+ * | | | `blocked` (spec §8 keeps thread |
25
+ * | | | status out of lane events). The |
26
+ * | | | payload carries `blocked: true` |
27
+ * | | | and `blocked_reason` for filters |
28
+ * | lane_question | question | status-neutral, carries the id |
29
+ * | lane_question_at_user | question | same |
30
+ * | lane_ask | question | same; ids of the ask envelope |
31
+ *
32
+ * Token source for detached emitters (the per-lane watcher): the same
33
+ * `requireToken()` every `hq mesh session … --enqueue` hook call uses,
34
+ * which is `ensureCognitoToken()` over the person cache at
35
+ * `~/.hq/cognito-tokens.json`, or a machine mint when
36
+ * `HQ_MACHINE_CREDS_FILE` / the default agent creds file is readable
37
+ * (`src/utils/cognito-session.ts`). The watcher inherits the creator's
38
+ * `HOME` (spawn.ts `armLaneWatcher` copies the caller env and only adds
39
+ * `HQ_ROOT` / `HQ_LANE_ID`; the provider account home rides its own
40
+ * variable, never `HOME`), so it reads the same cache the hooks read. It
41
+ * passes `interactive: false`: a cache that cannot refresh throws instead
42
+ * of opening a browser nobody is watching, and the watcher records
43
+ * `mesh_event_failed` and moves on. No credential file is read here.
44
+ */
45
+ import type { LaneCompanyRef, LaneEnvelope, LaneRecord } from "./types.js";
46
+ export declare const LANE_MESH_EVENTS: readonly ["lane_started", "lane_progress", "lane_question", "lane_question_at_user", "lane_ask", "lane_blocked", "lane_done"];
47
+ export type LaneMeshEvent = (typeof LANE_MESH_EVENTS)[number];
48
+ /** Roster kinds this module emits. Subset of the server's `validKinds`. */
49
+ export declare const WIRE_EVENT_KINDS: readonly ["run_started", "run_progress", "run_complete", "question", "note"];
50
+ export type WireEventKind = (typeof WIRE_EVENT_KINDS)[number];
51
+ /** Server caps: activity title/summary 2000, question text 500, note text 500. */
52
+ export declare const WIRE_ACTIVITY_TEXT_MAX = 2000;
53
+ export declare const WIRE_QUESTION_TEXT_MAX = 500;
54
+ export declare const WIRE_NOTE_TEXT_MAX = 500;
55
+ export interface WireThreadEvent {
56
+ eventKind: WireEventKind;
57
+ payload: Record<string, unknown>;
58
+ }
59
+ export declare function parseLaneMeshEvent(value: unknown): LaneMeshEvent;
60
+ /**
61
+ * Lane event → roster kind + validator-shaped payload. Pure. Every text
62
+ * field is pre-clamped to the server cap so a long summary is truncated
63
+ * here rather than rejected there.
64
+ */
65
+ export declare function toWireThreadEvent(event: LaneMeshEvent, payload: Record<string, unknown>): WireThreadEvent;
66
+ /**
67
+ * The seam every lane emitter goes through. `appendThreadEvent` takes the
68
+ * spec's lane event name; the default implementation maps it to the wire
69
+ * kind. Tests substitute a recording implementation and see lane names.
70
+ */
71
+ export interface LaneMesh {
72
+ ensureProjectThread: (company: LaneCompanyRef, projectId: string) => Promise<string>;
73
+ appendThreadEvent: (company: LaneCompanyRef, threadId: string, event: LaneMeshEvent, payload: Record<string, unknown>, signal?: AbortSignal) => Promise<void>;
74
+ patchStoryStatus: (company: LaneCompanyRef, projectId: string, storyId: string, status: string) => Promise<void>;
75
+ }
76
+ export interface CreateLaneMeshOptions {
77
+ /** False for detached callers (the watcher). See the token note above. */
78
+ interactive?: boolean;
79
+ /** Test seam. Defaults to the shared `requireToken`. */
80
+ token?: () => Promise<string>;
81
+ }
82
+ export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
83
+ /** Company + thread present. A core/personal lane (company null) emits nothing. */
84
+ export declare function laneCanEmit(lane: Pick<LaneRecord, "company" | "thread_id">): lane is Pick<LaneRecord, "company" | "thread_id"> & {
85
+ company: LaneCompanyRef;
86
+ thread_id: string;
87
+ };
88
+ /**
89
+ * Spec §8 terminal-envelope events. `done` → lane_done and story → review;
90
+ * `blocked` → lane_blocked; `ask` → lane_ask with the queue ids. The story is
91
+ * never set to `done` here: promotion belongs to the senior.
92
+ */
93
+ export declare function emitLaneDecisionEvents(mesh: LaneMesh, lane: LaneRecord, envelope: Pick<LaneEnvelope, "decision" | "summary" | "blocked_reason">, opts?: {
94
+ questionIds?: string[];
95
+ signal?: AbortSignal;
96
+ }): Promise<void>;
97
+ export declare function emitLaneProgressEvent(mesh: LaneMesh, lane: LaneRecord, detail: string, signal?: AbortSignal): Promise<void>;
98
+ /**
99
+ * Throttle for lane_progress (spec §8 progress row). True when no progress
100
+ * event has been sent yet, or the last one is at least `minIntervalMs` old.
101
+ * The caller only asks on a tick that saw new activity; a silent tick never
102
+ * reaches this function.
103
+ */
104
+ export declare function progressEventDue(input: {
105
+ lastEmitMs: number | null;
106
+ nowMs: number;
107
+ minIntervalMs: number;
108
+ }): boolean;
109
+ //# sourceMappingURL=mesh-events.d.ts.map
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Lane → work mesh events (spec §8).
3
+ *
4
+ * The lane side speaks the spec's vocabulary: lane_started, lane_progress,
5
+ * lane_question, lane_question_at_user, lane_ask, lane_blocked, lane_done.
6
+ * The work-mesh append endpoint accepts a closed roster of event kinds
7
+ * (hq-pro `src/vault-service/handlers/work-mesh.ts`, `validKinds`) and
8
+ * strips every payload field its per-kind validator does not name
9
+ * (hq-pro `src/vault-service/work-mesh/events.ts`). A `lane_*` kind on the
10
+ * wire is a 400. `toWireThreadEvent` is therefore the single place that
11
+ * turns a lane event into a roster kind whose payload carries the lane
12
+ * identity in the text fields the server keeps.
13
+ *
14
+ * Mapping, and why each kind:
15
+ *
16
+ * | lane event | wire kind | server effect |
17
+ * |------------------------|----------------|----------------------------------|
18
+ * | lane_started | run_started | status-neutral activity |
19
+ * | lane_progress | run_progress | status-neutral activity |
20
+ * | lane_done | run_complete | status-neutral; story → review |
21
+ * | lane_blocked | note (system) | status-neutral; the project |
22
+ * | | | thread is shared by every lane, |
23
+ * | | | so one lane must not flip it to |
24
+ * | | | `blocked` (spec §8 keeps thread |
25
+ * | | | status out of lane events). The |
26
+ * | | | payload carries `blocked: true` |
27
+ * | | | and `blocked_reason` for filters |
28
+ * | lane_question | question | status-neutral, carries the id |
29
+ * | lane_question_at_user | question | same |
30
+ * | lane_ask | question | same; ids of the ask envelope |
31
+ *
32
+ * Token source for detached emitters (the per-lane watcher): the same
33
+ * `requireToken()` every `hq mesh session … --enqueue` hook call uses,
34
+ * which is `ensureCognitoToken()` over the person cache at
35
+ * `~/.hq/cognito-tokens.json`, or a machine mint when
36
+ * `HQ_MACHINE_CREDS_FILE` / the default agent creds file is readable
37
+ * (`src/utils/cognito-session.ts`). The watcher inherits the creator's
38
+ * `HOME` (spawn.ts `armLaneWatcher` copies the caller env and only adds
39
+ * `HQ_ROOT` / `HQ_LANE_ID`; the provider account home rides its own
40
+ * variable, never `HOME`), so it reads the same cache the hooks read. It
41
+ * passes `interactive: false`: a cache that cannot refresh throws instead
42
+ * of opening a browser nobody is watching, and the watcher records
43
+ * `mesh_event_failed` and moves on. No credential file is read here.
44
+ */
45
+ import { appendThreadEvent, clamp, ensureProjectThread, patchStoryStatus, requireToken, resolveMeshCompany, } from "../mesh/api.js";
46
+ import { LanesError } from "./errors.js";
47
+ export const LANE_MESH_EVENTS = [
48
+ "lane_started",
49
+ "lane_progress",
50
+ "lane_question",
51
+ "lane_question_at_user",
52
+ "lane_ask",
53
+ "lane_blocked",
54
+ "lane_done",
55
+ ];
56
+ /** Roster kinds this module emits. Subset of the server's `validKinds`. */
57
+ export const WIRE_EVENT_KINDS = [
58
+ "run_started",
59
+ "run_progress",
60
+ "run_complete",
61
+ "question",
62
+ "note",
63
+ ];
64
+ /** Server caps: activity title/summary 2000, question text 500, note text 500. */
65
+ export const WIRE_ACTIVITY_TEXT_MAX = 2000;
66
+ export const WIRE_QUESTION_TEXT_MAX = 500;
67
+ export const WIRE_NOTE_TEXT_MAX = 500;
68
+ export function parseLaneMeshEvent(value) {
69
+ if (typeof value === "string" &&
70
+ LANE_MESH_EVENTS.includes(value)) {
71
+ return value;
72
+ }
73
+ throw new LanesError("unknown_lane_mesh_event", `Unknown lane mesh event ${JSON.stringify(value)}. Accepted: ${LANE_MESH_EVENTS.join(", ")}`, { accepted: [...LANE_MESH_EVENTS], value });
74
+ }
75
+ function str(value) {
76
+ if (value === undefined || value === null)
77
+ return "";
78
+ return typeof value === "string" ? value : JSON.stringify(value);
79
+ }
80
+ function identity(payload) {
81
+ const lane = str(payload.lane_id) || "unknown-lane";
82
+ const project = str(payload.project_id);
83
+ const story = str(payload.story_id);
84
+ return project || story ? `${lane} ${project}/${story}` : lane;
85
+ }
86
+ function nonEmpty(text, fallback) {
87
+ return text.trim().length > 0 ? text : fallback;
88
+ }
89
+ /**
90
+ * Lane event → roster kind + validator-shaped payload. Pure. Every text
91
+ * field is pre-clamped to the server cap so a long summary is truncated
92
+ * here rather than rejected there.
93
+ */
94
+ export function toWireThreadEvent(event, payload) {
95
+ const kind = parseLaneMeshEvent(event);
96
+ const id = identity(payload);
97
+ switch (kind) {
98
+ case "lane_started":
99
+ return {
100
+ eventKind: "run_started",
101
+ payload: {
102
+ title: clamp(`lane_started ${str(payload.lane_id) || "unknown-lane"}`, WIRE_ACTIVITY_TEXT_MAX),
103
+ summary: clamp(`Lane ${id} started`, WIRE_ACTIVITY_TEXT_MAX),
104
+ },
105
+ };
106
+ case "lane_progress": {
107
+ const detail = str(payload.detail);
108
+ return {
109
+ eventKind: "run_progress",
110
+ payload: {
111
+ summary: clamp(nonEmpty(`lane_progress ${id}${detail ? `: ${detail}` : ""}`, "lane_progress"), WIRE_ACTIVITY_TEXT_MAX),
112
+ },
113
+ };
114
+ }
115
+ case "lane_done": {
116
+ const summary = str(payload.summary);
117
+ return {
118
+ eventKind: "run_complete",
119
+ payload: {
120
+ title: clamp(`lane_done ${str(payload.lane_id) || "unknown-lane"}`, WIRE_ACTIVITY_TEXT_MAX),
121
+ summary: clamp(nonEmpty(`${id}: ${summary}`.replace(/:\s*$/, ""), `lane_done ${id}`), WIRE_ACTIVITY_TEXT_MAX),
122
+ },
123
+ };
124
+ }
125
+ case "lane_blocked": {
126
+ const reason = str(payload.blocked_reason);
127
+ // The roster's `blocked` event kind would flip the shared project
128
+ // thread, which spec §8 keeps lane events away from. Use the accepted
129
+ // audit note kind to preserve a filterable blocked marker.
130
+ return {
131
+ eventKind: "note",
132
+ payload: {
133
+ text: clamp(`lane_blocked ${id}${reason ? `: ${reason}` : ""}`, WIRE_NOTE_TEXT_MAX),
134
+ noteKind: "audit",
135
+ },
136
+ };
137
+ }
138
+ case "lane_question":
139
+ case "lane_question_at_user": {
140
+ const questionId = str(payload.question_id);
141
+ const hop = payload.hop === undefined ? "" : ` hop ${str(payload.hop)}`;
142
+ const out = {
143
+ text: clamp(`${kind} ${id}${hop}`, WIRE_QUESTION_TEXT_MAX),
144
+ };
145
+ if (questionId)
146
+ out.questionId = questionId;
147
+ return { eventKind: "question", payload: out };
148
+ }
149
+ case "lane_ask": {
150
+ const ids = Array.isArray(payload.question_ids)
151
+ ? payload.question_ids.map((item) => str(item)).filter(Boolean)
152
+ : [];
153
+ const out = {
154
+ text: clamp(`lane_ask ${id}: ${ids.length} question${ids.length === 1 ? "" : "s"}${ids.length ? ` ${ids.join(" ")}` : ""}`, WIRE_QUESTION_TEXT_MAX),
155
+ };
156
+ if (ids[0])
157
+ out.questionId = ids[0];
158
+ return { eventKind: "question", payload: out };
159
+ }
160
+ default: {
161
+ const _exhaustive = kind;
162
+ return _exhaustive;
163
+ }
164
+ }
165
+ }
166
+ export function createLaneMesh(opts = {}) {
167
+ const interactive = opts.interactive ?? true;
168
+ const token = opts.token ?? (() => requireToken({ interactive }));
169
+ return {
170
+ async ensureProjectThread(company, projectId) {
171
+ const tok = await token();
172
+ const resolved = await resolveMeshCompany(tok, company.slug);
173
+ const ensured = await ensureProjectThread(tok, resolved, projectId, {
174
+ summary: `HQ lane ${projectId}`,
175
+ });
176
+ return ensured.threadId;
177
+ },
178
+ async appendThreadEvent(company, threadId, event, payload, signal) {
179
+ const wire = toWireThreadEvent(event, payload);
180
+ const tok = await token();
181
+ await appendThreadEvent(tok, company.uid, threadId, wire.eventKind, wire.payload, signal);
182
+ },
183
+ async patchStoryStatus(company, projectId, storyId, status) {
184
+ const tok = await token();
185
+ await patchStoryStatus(tok, company.uid, projectId, storyId, status);
186
+ },
187
+ };
188
+ }
189
+ /** Company + thread present. A core/personal lane (company null) emits nothing. */
190
+ export function laneCanEmit(lane) {
191
+ return lane.company !== null && lane.company !== undefined && !!lane.thread_id;
192
+ }
193
+ function baseIdentity(lane) {
194
+ return {
195
+ lane_id: lane.lane_id,
196
+ project_id: lane.project_id,
197
+ story_id: lane.story_id,
198
+ };
199
+ }
200
+ /**
201
+ * Spec §8 terminal-envelope events. `done` → lane_done and story → review;
202
+ * `blocked` → lane_blocked; `ask` → lane_ask with the queue ids. The story is
203
+ * never set to `done` here: promotion belongs to the senior.
204
+ */
205
+ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
206
+ if (!laneCanEmit(lane))
207
+ return;
208
+ const company = lane.company;
209
+ const threadId = lane.thread_id;
210
+ switch (envelope.decision) {
211
+ case "done": {
212
+ const outcomes = await Promise.allSettled([
213
+ mesh.appendThreadEvent(company, threadId, "lane_done", {
214
+ ...baseIdentity(lane),
215
+ summary: envelope.summary,
216
+ }, opts.signal),
217
+ mesh.patchStoryStatus(company, lane.project_id, lane.story_id, "review"),
218
+ ]);
219
+ const failure = outcomes.find((outcome) => outcome.status === "rejected");
220
+ if (failure?.status === "rejected")
221
+ throw failure.reason;
222
+ return;
223
+ }
224
+ case "blocked":
225
+ await mesh.appendThreadEvent(company, threadId, "lane_blocked", {
226
+ ...baseIdentity(lane),
227
+ blocked_reason: envelope.blocked_reason ?? "",
228
+ });
229
+ return;
230
+ case "ask":
231
+ // A mesh question event has one `questionId`; sending one event per id
232
+ // avoids silently losing ids when the display text reaches its wire cap.
233
+ {
234
+ const failures = [];
235
+ for (const questionId of opts.questionIds ?? []) {
236
+ try {
237
+ await mesh.appendThreadEvent(company, threadId, "lane_ask", {
238
+ ...baseIdentity(lane),
239
+ question_ids: [questionId],
240
+ }, opts.signal);
241
+ }
242
+ catch (err) {
243
+ failures.push(err);
244
+ }
245
+ }
246
+ if (failures.length > 0)
247
+ throw failures[0];
248
+ }
249
+ return;
250
+ default: {
251
+ const _exhaustive = envelope.decision;
252
+ return _exhaustive;
253
+ }
254
+ }
255
+ }
256
+ export async function emitLaneProgressEvent(mesh, lane, detail, signal) {
257
+ if (!laneCanEmit(lane))
258
+ return;
259
+ await mesh.appendThreadEvent(lane.company, lane.thread_id, "lane_progress", {
260
+ ...baseIdentity(lane),
261
+ detail,
262
+ }, signal);
263
+ }
264
+ /**
265
+ * Throttle for lane_progress (spec §8 progress row). True when no progress
266
+ * event has been sent yet, or the last one is at least `minIntervalMs` old.
267
+ * The caller only asks on a tick that saw new activity; a silent tick never
268
+ * reaches this function.
269
+ */
270
+ export function progressEventDue(input) {
271
+ if (input.lastEmitMs === null)
272
+ return true;
273
+ return input.nowMs - input.lastEmitMs >= input.minIntervalMs;
274
+ }
275
+ //# sourceMappingURL=mesh-events.js.map