@indigoai-us/hq-cli 5.126.0 → 5.127.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 (76) hide show
  1. package/CHANGELOG.md +211 -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/lanes/accounts.d.ts +67 -7
  17. package/dist/lib/lanes/accounts.js +233 -43
  18. package/dist/lib/lanes/compaction.d.ts +96 -0
  19. package/dist/lib/lanes/compaction.js +197 -0
  20. package/dist/lib/lanes/config.d.ts +15 -0
  21. package/dist/lib/lanes/config.js +65 -8
  22. package/dist/lib/lanes/delivery.d.ts +151 -0
  23. package/dist/lib/lanes/delivery.js +354 -0
  24. package/dist/lib/lanes/dropbox.d.ts +96 -0
  25. package/dist/lib/lanes/dropbox.js +408 -0
  26. package/dist/lib/lanes/effort.d.ts +50 -0
  27. package/dist/lib/lanes/effort.js +109 -0
  28. package/dist/lib/lanes/errors.d.ts +2 -2
  29. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  30. package/dist/lib/lanes/hooks-install.js +326 -0
  31. package/dist/lib/lanes/hq-root.d.ts +19 -0
  32. package/dist/lib/lanes/hq-root.js +28 -0
  33. package/dist/lib/lanes/index.d.ts +35 -5
  34. package/dist/lib/lanes/index.js +19 -4
  35. package/dist/lib/lanes/liveness.d.ts +7 -1
  36. package/dist/lib/lanes/liveness.js +64 -5
  37. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  38. package/dist/lib/lanes/machine-identity.js +92 -0
  39. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  40. package/dist/lib/lanes/mesh-events.js +275 -0
  41. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  42. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  43. package/dist/lib/lanes/questions.d.ts +75 -0
  44. package/dist/lib/lanes/questions.js +243 -0
  45. package/dist/lib/lanes/quota.d.ts +95 -0
  46. package/dist/lib/lanes/quota.js +320 -0
  47. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  48. package/dist/lib/lanes/reconcile-install.js +221 -0
  49. package/dist/lib/lanes/reconcile.d.ts +102 -0
  50. package/dist/lib/lanes/reconcile.js +985 -0
  51. package/dist/lib/lanes/registry.d.ts +18 -5
  52. package/dist/lib/lanes/registry.js +293 -19
  53. package/dist/lib/lanes/spawn.d.ts +274 -0
  54. package/dist/lib/lanes/spawn.js +1363 -0
  55. package/dist/lib/lanes/store.d.ts +7 -3
  56. package/dist/lib/lanes/store.js +150 -32
  57. package/dist/lib/lanes/types.d.ts +346 -4
  58. package/dist/lib/lanes/types.js +138 -5
  59. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  60. package/dist/lib/lanes/watcher-main.js +22 -0
  61. package/dist/lib/lanes/watcher.d.ts +216 -0
  62. package/dist/lib/lanes/watcher.js +859 -0
  63. package/dist/lib/mesh/api.d.ts +71 -7
  64. package/dist/lib/mesh/api.js +167 -28
  65. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  66. package/dist/lib/mesh/live/backfill-held.js +66 -21
  67. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  68. package/dist/lib/mesh/live/daemon/run.js +111 -54
  69. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  71. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  72. package/dist/lib/scan-packages/index.d.ts +2 -0
  73. package/dist/lib/scan-packages/index.js +1 -1
  74. package/dist/lib/work-context/state.d.ts +12 -0
  75. package/dist/lib/work-context/state.js +131 -3
  76. package/package.json +3 -1
@@ -7,6 +7,158 @@ export declare const LANE_SCHEMA_VERSION: "hq-lane.v1";
7
7
  export declare const QUESTION_SCHEMA_VERSION: "hq-lane-question.v1";
8
8
  export declare const ENVELOPE_SCHEMA_VERSION: "hq-lane-envelope.v1";
9
9
  export declare const CONFIG_SCHEMA_VERSION: "hq-lanes-config.v1";
10
+ /**
11
+ * The envelope contract as a JSON schema. `parseLaneEnvelope` is the runtime
12
+ * check; this is the text handed to a worker on the repair turn (spec §4) so
13
+ * the two never drift apart in what they name.
14
+ */
15
+ export declare const LANE_ENVELOPE_JSON_SCHEMA: {
16
+ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
17
+ readonly title: "hq-lane-envelope.v1";
18
+ readonly type: "object";
19
+ readonly additionalProperties: false;
20
+ readonly required: readonly ["schema_version", "decision", "summary", "story_status", "artifacts"];
21
+ readonly properties: {
22
+ readonly schema_version: {
23
+ readonly const: "hq-lane-envelope.v1";
24
+ };
25
+ readonly decision: {
26
+ readonly enum: readonly ["done", "ask", "blocked"];
27
+ };
28
+ readonly summary: {
29
+ readonly type: "string";
30
+ readonly minLength: 1;
31
+ };
32
+ readonly questions: {
33
+ readonly type: "array";
34
+ readonly items: {
35
+ readonly type: "object";
36
+ readonly additionalProperties: false;
37
+ readonly required: readonly ["question", "context", "options"];
38
+ readonly properties: {
39
+ readonly question: {
40
+ readonly type: "string";
41
+ };
42
+ readonly context: {
43
+ readonly type: "string";
44
+ };
45
+ readonly options: {
46
+ readonly type: "array";
47
+ readonly items: {
48
+ readonly type: "string";
49
+ };
50
+ };
51
+ readonly recommended: {
52
+ readonly type: "string";
53
+ };
54
+ };
55
+ };
56
+ };
57
+ readonly story_status: {
58
+ readonly enum: readonly ["queued", "in_progress", "review", "done"];
59
+ };
60
+ readonly artifacts: {
61
+ readonly type: "array";
62
+ };
63
+ readonly blocked_reason: {
64
+ readonly type: "string";
65
+ };
66
+ readonly story_complete: {
67
+ readonly type: "boolean";
68
+ };
69
+ readonly story_complete_reason: {
70
+ readonly type: "string";
71
+ readonly minLength: 1;
72
+ };
73
+ };
74
+ readonly allOf: readonly [{
75
+ readonly if: {
76
+ readonly properties: {
77
+ readonly decision: {
78
+ readonly const: "ask";
79
+ };
80
+ };
81
+ };
82
+ readonly then: {
83
+ readonly required: readonly ["questions"];
84
+ readonly properties: {
85
+ readonly questions: {
86
+ readonly minItems: 1;
87
+ };
88
+ };
89
+ };
90
+ readonly else: {
91
+ readonly not: {
92
+ readonly required: readonly ["questions"];
93
+ };
94
+ };
95
+ }, {
96
+ readonly if: {
97
+ readonly properties: {
98
+ readonly decision: {
99
+ readonly const: "blocked";
100
+ };
101
+ };
102
+ };
103
+ readonly then: {
104
+ readonly required: readonly ["blocked_reason"];
105
+ readonly properties: {
106
+ readonly blocked_reason: {
107
+ readonly minLength: 1;
108
+ };
109
+ };
110
+ };
111
+ readonly else: {
112
+ readonly not: {
113
+ readonly required: readonly ["blocked_reason"];
114
+ };
115
+ };
116
+ }, {
117
+ readonly if: {
118
+ readonly required: readonly ["story_complete"];
119
+ readonly properties: {
120
+ readonly story_complete: {
121
+ readonly const: true;
122
+ };
123
+ };
124
+ };
125
+ readonly then: {
126
+ readonly required: readonly ["story_complete_reason"];
127
+ readonly properties: {
128
+ readonly story_status: {
129
+ readonly const: "done";
130
+ };
131
+ readonly story_complete_reason: {
132
+ readonly minLength: 1;
133
+ };
134
+ };
135
+ };
136
+ readonly else: {
137
+ readonly if: {
138
+ readonly required: readonly ["story_complete"];
139
+ };
140
+ readonly then: {
141
+ readonly required: readonly ["story_complete_reason"];
142
+ readonly properties: {
143
+ readonly story_status: {
144
+ readonly not: {
145
+ readonly const: "done";
146
+ };
147
+ };
148
+ readonly story_complete_reason: {
149
+ readonly minLength: 1;
150
+ };
151
+ };
152
+ };
153
+ readonly else: {
154
+ readonly not: {
155
+ readonly required: readonly ["story_complete_reason"];
156
+ };
157
+ };
158
+ };
159
+ }];
160
+ readonly description: string;
161
+ };
10
162
  export declare const LANE_STATES: readonly ["queued", "running", "awaiting_input", "blocked", "done", "failed", "interrupted", "cancelled", "killed"];
11
163
  export type LaneState = (typeof LANE_STATES)[number];
12
164
  /** Exact terminal set from spec §6.7. `awaiting_input` and `blocked` are not terminal. */
@@ -14,7 +166,7 @@ export declare const TERMINAL_LANE_STATES: readonly ["done", "failed", "interrup
14
166
  export type TerminalLaneState = (typeof TERMINAL_LANE_STATES)[number];
15
167
  export declare const LANE_PROVIDERS: readonly ["claude", "codex", "grok"];
16
168
  export type LaneProvider = (typeof LANE_PROVIDERS)[number];
17
- export declare const QUEUED_REASONS: readonly ["cap", "queue_position", "throttle"];
169
+ export declare const QUEUED_REASONS: readonly ["cap", "queue_position", "throttle", "provider_binary_missing"];
18
170
  export type QueuedReason = (typeof QUEUED_REASONS)[number];
19
171
  export declare const HOST_KINDS: readonly ["local", "outpost"];
20
172
  export type HostKind = (typeof HOST_KINDS)[number];
@@ -24,6 +176,12 @@ export declare const ENVELOPE_DECISIONS: readonly ["done", "ask", "blocked"];
24
176
  export type EnvelopeDecision = (typeof ENVELOPE_DECISIONS)[number];
25
177
  export declare const STORY_STATUSES: readonly ["queued", "in_progress", "review", "done"];
26
178
  export type StoryStatus = (typeof STORY_STATUSES)[number];
179
+ /**
180
+ * `expired` is accepted on read for records that already carry it. Spec §9
181
+ * ageing advances hop with `unanswered_timeout` and never expires a question,
182
+ * so reconcile (the only writer that may age without an operator) does not
183
+ * write this value. Kept in the enum so `--status expired` and old files parse.
184
+ */
27
185
  export declare const QUESTION_STATUSES: readonly ["pending", "answered", "cancelled", "expired"];
28
186
  export type QuestionStatus = (typeof QUESTION_STATUSES)[number];
29
187
  export declare const RAISED_VIA: readonly ["cli", "envelope"];
@@ -82,7 +240,8 @@ export interface LastEnvelopeRef {
82
240
  export interface LaneRecord {
83
241
  schema_version: typeof LANE_SCHEMA_VERSION | string;
84
242
  lane_id: string;
85
- company: LaneCompanyRef;
243
+ /** `null` is a core/personal lane (spec §6.2 / §10). Not an empty slug. */
244
+ company: LaneCompanyRef | null;
86
245
  project_id: string;
87
246
  story_id: string;
88
247
  thread_id?: string;
@@ -93,14 +252,159 @@ export interface LaneRecord {
93
252
  queued_reason?: QueuedReason;
94
253
  priority: number;
95
254
  worker?: string;
255
+ /** Registry-relative directory of the resolved worker, when --worker was set. */
256
+ worker_path?: string;
257
+ /** Package name when the worker's realpath is under core/packages. */
258
+ worker_pack?: string;
96
259
  provider: LaneProvider;
97
260
  account?: string;
98
261
  host: LaneHost;
262
+ /**
263
+ * Creating machine's identity when `host.kind` is `outpost` (spec §5.2).
264
+ * Distinct from `host`, which names where the lane is meant to run.
265
+ */
266
+ origin_host?: string;
267
+ /** How `origin_host` was resolved. Required to compare like-with-like. */
268
+ origin_host_source?: "imds" | "hostname";
99
269
  version: number;
100
270
  created_at: string;
101
271
  updated_at: string;
102
272
  attribution_history: AttributionHistoryEntry[];
103
273
  last_envelope?: LastEnvelopeRef;
274
+ /** Effective model id recorded at create/spawn. Omitted when the provider chooses. */
275
+ model?: string;
276
+ /** Effective effort level passed to the provider at the last spawn (effort.ts). */
277
+ effort?: string;
278
+ /** Where `effort` came from. `cli` pins it across later spawns. */
279
+ effort_source?: "cli" | "account" | "provider" | "default_rule" | "none";
280
+ /** Time of the last operator message accepted for this lane. Drives compaction. */
281
+ last_message_at?: string;
282
+ /** Last automatic compaction of a resumed session (compaction.ts). */
283
+ last_compaction?: LaneCompactionRecord;
284
+ /**
285
+ * Copied from the last ingested envelope. Omitted when no envelope has
286
+ * been ingested — absence is not false.
287
+ */
288
+ story_complete?: boolean;
289
+ story_complete_reason?: string;
290
+ /** Copied from the last ingested envelope when present. */
291
+ story_status?: StoryStatus;
292
+ /** Set by the reconcile pass when `--timeout` has passed (spec §5.7). */
293
+ overdue?: boolean;
294
+ overdue_at?: string;
295
+ /** Executing-host heartbeat; stale values mean the host stopped reporting. */
296
+ last_host_report_at?: string;
297
+ /**
298
+ * Set by the watcher when a running worker has made no tool call and
299
+ * produced no output for the silence window; cleared on the next progress
300
+ * signal or terminal write. Not a verdict: a long single tool call looks
301
+ * the same as a hang. See watcher.ts.
302
+ */
303
+ stalled?: boolean;
304
+ stalled_at?: string;
305
+ stalled_reason?: string;
306
+ /** Set when the reconcile pass force-admits under the starvation bound. */
307
+ admitted_reason?: string;
308
+ terminal_reason?: string;
309
+ /**
310
+ * Structured detail behind a named `terminal_reason` (dispatch, pickup and
311
+ * spawn failures), or the missing-binary search path behind
312
+ * `provider_binary_missing`. Written by the command and spawn writers.
313
+ */
314
+ terminal_details?: Record<string, unknown>;
315
+ /** SSM command id of the `hq outposts exec` pickup that started this lane. */
316
+ outpost_command_id?: string;
317
+ /**
318
+ * Provider reset time copied from a 429 result. Absent when the text
319
+ * named none. Distinct from the operator's account enabled switch.
320
+ */
321
+ quota_reset_at?: string;
322
+ /** Set when a 429 refused one model for credits, e.g. "needs credits for claude-fable-5-1". */
323
+ model_quota_marker?: string;
324
+ /**
325
+ * Provider session id parsed from the worker's own stdout when a resumable
326
+ * provider exits: Claude's JSON `result.session_id`, Codex's
327
+ * `thread.started.thread_id`. Absent when the provider has none or the
328
+ * output carried none; `provider_session_id_absent` names why. Never a
329
+ * guessed or path-derived value.
330
+ */
331
+ provider_session_id?: string;
332
+ provider_session_id_absent?: string;
333
+ /** Set at spawn when this run continued a recorded session instead of starting fresh. */
334
+ last_resume?: LaneResumeRecord;
335
+ /** Cache counters from the last exited run's result. Nothing else from the payload. */
336
+ last_run_usage?: LaneRunUsage;
337
+ /**
338
+ * Spec §4: one repair turn when the final output fails parseLaneEnvelope.
339
+ * Written by the watcher before the repair spawn, updated on the repair
340
+ * run's exit. Absent when every envelope this lane wrote parsed first time.
341
+ */
342
+ envelope_repair?: LaneEnvelopeRepair;
343
+ /** Last successful lane_progress mesh delivery, preserved across watcher restarts. */
344
+ last_progress_event_at?: string;
345
+ }
346
+ /** The v1 fields that must exist on every durable lane record (spec §3). */
347
+ export declare const REQUIRED_LANE_RECORD_FIELDS: readonly ["schema_version", "lane_id", "company", "project_id", "story_id", "depth", "senior", "senior_history", "state", "priority", "provider", "host", "version", "created_at", "updated_at", "attribution_history"];
348
+ export declare const LANE_WRITERS: readonly ["creator", "command", "watcher", "reconcile", "spawn"];
349
+ export type LaneWriter = (typeof LANE_WRITERS)[number];
350
+ /** Field ownership at the durable-record mutation boundary (spec §10.1). */
351
+ export declare const LANE_FIELD_WRITERS: Readonly<Record<keyof LaneRecord, readonly LaneWriter[]>>;
352
+ export interface LaneEnvelopeRepair {
353
+ /** False when the parse failed but no repair turn could run (see `skipped_reason`). */
354
+ attempted: boolean;
355
+ /**
356
+ * How the repair turn ran: `resume` continues the recorded session (claude,
357
+ * codex); `fresh_turn` is a new single-turn invocation carrying the raw
358
+ * output (grok). Absent when no repair ran.
359
+ */
360
+ mode?: "resume" | "fresh_turn";
361
+ succeeded: boolean;
362
+ first_error: string;
363
+ /** Parser error from the repair run's output, when that also failed. */
364
+ second_error?: string;
365
+ /** Why no repair turn ran: the provider cannot resume, or no session id was recorded. */
366
+ skipped_reason?: string;
367
+ /** Envelope slot the repair was asked to rewrite. */
368
+ envelope_ref?: string;
369
+ at: string;
370
+ }
371
+ export interface LaneResumeRecord {
372
+ session_id: string;
373
+ at: string;
374
+ trigger: "answered_questions" | "message" | "resume";
375
+ message_id?: string;
376
+ /**
377
+ * `hq lanes resume` only: the state the lane was in when the operator
378
+ * resumed it (`interrupted`, `done` or `blocked`) and the text they sent.
379
+ */
380
+ resumed_from?: "interrupted" | "done" | "blocked";
381
+ text?: string;
382
+ }
383
+ export interface LaneRunUsage {
384
+ cache_read_input_tokens: number | null;
385
+ cache_creation_input_tokens: number | null;
386
+ }
387
+ export interface LaneCompactionRecord {
388
+ at: string;
389
+ /** `claude_print_compact`: `claude -p --resume <sid> "/compact"` before the resume. */
390
+ mechanism: "claude_print_compact";
391
+ session_id: string;
392
+ /** `compacted` only when the session transcript gained a compact boundary. */
393
+ outcome: "compacted" | "no_effect" | "failed";
394
+ reason?: string;
395
+ duration_ms: number;
396
+ /** Milliseconds between the reference time and the resume that compacted. */
397
+ elapsed_ms: number;
398
+ /** `last_message_at`, or `created_at` when no message was ever sent. */
399
+ reference_at: string;
400
+ /** Context size the provider recorded at the compact boundary. */
401
+ pre_tokens: number | null;
402
+ /** Usage of the last run before compaction. */
403
+ before: LaneRunUsage | null;
404
+ /** Usage of the first run after compaction, once that run has exited. */
405
+ after: LaneRunUsage | null;
406
+ /** `last_resume.at` of the run that followed this compaction. */
407
+ resume_at?: string;
104
408
  }
105
409
  export interface QuestionBodyRef {
106
410
  store: QuestionBodyStore;
@@ -115,16 +419,27 @@ export interface RoutingHistoryEntry {
115
419
  }
116
420
  export interface QuestionAnswer {
117
421
  choice: string;
422
+ /** Whether the answer selected an offered option or supplied new text. */
423
+ answer_kind?: "option" | "freeform";
118
424
  notes_ref?: string;
119
425
  answered_by: string;
120
426
  answered_at: string;
121
427
  answered_level: number;
122
428
  }
429
+ /**
430
+ * A question whose options amount to "continue" or "stop" the brief's own
431
+ * work. The brief already authorises finishing everything it lists, so such a
432
+ * question is flagged for the operator; nothing answers it automatically.
433
+ */
434
+ export type QuestionKind = "continue";
123
435
  export interface QuestionRecord {
124
436
  schema_version: typeof QUESTION_SCHEMA_VERSION | string;
125
437
  question_id: string;
126
438
  lane_id: string;
127
- company: LaneCompanyRef;
439
+ /** Set by the raiser when the options are only continue/stop. */
440
+ question_kind?: QuestionKind;
441
+ /** `null` when the originating lane has no company. */
442
+ company: LaneCompanyRef | null;
128
443
  project_id: string;
129
444
  story_id: string;
130
445
  thread_id?: string;
@@ -140,6 +455,8 @@ export interface QuestionRecord {
140
455
  body: QuestionBodyRef;
141
456
  routing_history: RoutingHistoryEntry[];
142
457
  answer?: QuestionAnswer;
458
+ /** Merge-on-read preservation of answers retained by conflict sidecars. */
459
+ answer_history?: QuestionAnswer[];
143
460
  }
144
461
  export interface EnvelopeQuestion {
145
462
  question: string;
@@ -155,17 +472,42 @@ export interface LaneEnvelope {
155
472
  story_status: StoryStatus;
156
473
  artifacts: unknown[];
157
474
  blocked_reason?: string;
475
+ story_complete?: boolean;
476
+ story_complete_reason?: string;
158
477
  }
159
478
  export interface AccountConfig {
160
479
  home: string;
161
480
  enabled: boolean;
162
481
  disabled_reason?: string;
163
482
  max_running?: number;
483
+ /** Default model for this account. Beats the provider default; beaten by --model. */
484
+ model?: string;
485
+ /** Default effort for this account. Beats the provider default; beaten by --effort. */
486
+ effort?: string;
164
487
  }
165
488
  export interface ProviderConfig {
166
489
  max_running: number;
167
490
  max_queued: number;
168
- default_account: string;
491
+ /**
492
+ * Accepted and ignored for selection. Kept so existing config files parse.
493
+ * Account pick is least-loaded among enabled, logged-in accounts.
494
+ */
495
+ default_account?: string;
496
+ /** Default model for this provider when the account does not set one. */
497
+ model?: string;
498
+ /** Default effort for this provider when the account does not set one. */
499
+ effort?: string;
500
+ /**
501
+ * Resume-time compaction threshold: a resumed session whose last message is
502
+ * at least this old is compacted first. Absent means the one-hour default.
503
+ */
504
+ compact_after_ms?: number;
505
+ /**
506
+ * Watcher throttle for `lane_progress` thread events: at most one per this
507
+ * interval, and only on a tick that saw new tool activity or output. Absent
508
+ * means the five-minute default.
509
+ */
510
+ progress_event_min_interval_ms?: number;
169
511
  accounts: Record<string, AccountConfig>;
170
512
  }
171
513
  export interface ThrottleConfig {
@@ -8,6 +8,73 @@ export const LANE_SCHEMA_VERSION = "hq-lane.v1";
8
8
  export const QUESTION_SCHEMA_VERSION = "hq-lane-question.v1";
9
9
  export const ENVELOPE_SCHEMA_VERSION = "hq-lane-envelope.v1";
10
10
  export const CONFIG_SCHEMA_VERSION = "hq-lanes-config.v1";
11
+ /**
12
+ * The envelope contract as a JSON schema. `parseLaneEnvelope` is the runtime
13
+ * check; this is the text handed to a worker on the repair turn (spec §4) so
14
+ * the two never drift apart in what they name.
15
+ */
16
+ export const LANE_ENVELOPE_JSON_SCHEMA = {
17
+ $schema: "https://json-schema.org/draft/2020-12/schema",
18
+ title: "hq-lane-envelope.v1",
19
+ type: "object",
20
+ additionalProperties: false,
21
+ required: ["schema_version", "decision", "summary", "story_status", "artifacts"],
22
+ properties: {
23
+ schema_version: { const: "hq-lane-envelope.v1" },
24
+ decision: { enum: ["done", "ask", "blocked"] },
25
+ summary: { type: "string", minLength: 1 },
26
+ questions: {
27
+ type: "array",
28
+ items: {
29
+ type: "object",
30
+ additionalProperties: false,
31
+ required: ["question", "context", "options"],
32
+ properties: {
33
+ question: { type: "string" },
34
+ context: { type: "string" },
35
+ options: { type: "array", items: { type: "string" } },
36
+ recommended: { type: "string" },
37
+ },
38
+ },
39
+ },
40
+ story_status: { enum: ["queued", "in_progress", "review", "done"] },
41
+ artifacts: { type: "array" },
42
+ blocked_reason: { type: "string" },
43
+ story_complete: { type: "boolean" },
44
+ story_complete_reason: { type: "string", minLength: 1 },
45
+ },
46
+ allOf: [
47
+ {
48
+ if: { properties: { decision: { const: "ask" } } },
49
+ then: { required: ["questions"], properties: { questions: { minItems: 1 } } },
50
+ else: { not: { required: ["questions"] } },
51
+ },
52
+ {
53
+ if: { properties: { decision: { const: "blocked" } } },
54
+ then: { required: ["blocked_reason"], properties: { blocked_reason: { minLength: 1 } } },
55
+ else: { not: { required: ["blocked_reason"] } },
56
+ },
57
+ {
58
+ if: { required: ["story_complete"], properties: { story_complete: { const: true } } },
59
+ then: {
60
+ required: ["story_complete_reason"],
61
+ properties: { story_status: { const: "done" }, story_complete_reason: { minLength: 1 } },
62
+ },
63
+ else: {
64
+ if: { required: ["story_complete"] },
65
+ then: {
66
+ required: ["story_complete_reason"],
67
+ properties: { story_status: { not: { const: "done" } }, story_complete_reason: { minLength: 1 } },
68
+ },
69
+ else: { not: { required: ["story_complete_reason"] } },
70
+ },
71
+ },
72
+ ],
73
+ description: "questions is present and non-empty exactly when decision is ask. " +
74
+ "blocked_reason is present exactly when decision is blocked. " +
75
+ "story_complete true requires story_status done; story_complete false requires story_status other than done; " +
76
+ "story_complete_reason is required whenever story_complete is present.",
77
+ };
11
78
  export const LANE_STATES = [
12
79
  "queued",
13
80
  "running",
@@ -28,7 +95,7 @@ export const TERMINAL_LANE_STATES = [
28
95
  "killed",
29
96
  ];
30
97
  export const LANE_PROVIDERS = ["claude", "codex", "grok"];
31
- export const QUEUED_REASONS = ["cap", "queue_position", "throttle"];
98
+ export const QUEUED_REASONS = ["cap", "queue_position", "throttle", "provider_binary_missing"];
32
99
  export const HOST_KINDS = ["local", "outpost"];
33
100
  export const SENIOR_KINDS = ["session", "lane", "user"];
34
101
  export const ENVELOPE_DECISIONS = ["done", "ask", "blocked"];
@@ -38,6 +105,12 @@ export const STORY_STATUSES = [
38
105
  "review",
39
106
  "done",
40
107
  ];
108
+ /**
109
+ * `expired` is accepted on read for records that already carry it. Spec §9
110
+ * ageing advances hop with `unanswered_timeout` and never expires a question,
111
+ * so reconcile (the only writer that may age without an operator) does not
112
+ * write this value. Kept in the enum so `--status expired` and old files parse.
113
+ */
41
114
  export const QUESTION_STATUSES = [
42
115
  "pending",
43
116
  "answered",
@@ -112,6 +185,29 @@ export function parseSeniorKind(value) {
112
185
  return value;
113
186
  throw new LanesError("unknown_senior_kind", `Unknown senior kind ${JSON.stringify(value)}. Accepted: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value });
114
187
  }
188
+ /** The v1 fields that must exist on every durable lane record (spec §3). */
189
+ export const REQUIRED_LANE_RECORD_FIELDS = [
190
+ "schema_version", "lane_id", "company", "project_id", "story_id", "depth",
191
+ "senior", "senior_history", "state", "priority", "provider", "host",
192
+ "version", "created_at", "updated_at", "attribution_history",
193
+ ];
194
+ export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spawn"];
195
+ /** Field ownership at the durable-record mutation boundary (spec §10.1). */
196
+ export const LANE_FIELD_WRITERS = {
197
+ schema_version: ["creator"], lane_id: ["creator"], company: ["creator"],
198
+ project_id: ["command"], story_id: ["command"], thread_id: ["creator", "command"],
199
+ depth: ["command"], senior: ["command"], senior_history: ["command"],
200
+ state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"],
201
+ priority: ["command"], worker: ["creator"], worker_path: ["creator"], worker_pack: ["creator"],
202
+ provider: ["creator"], account: ["creator", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
203
+ version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
204
+ last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn"], effort: ["creator", "spawn"], effort_source: ["creator", "spawn"],
205
+ last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
206
+ overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
207
+ admitted_reason: ["reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn"],
208
+ quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
209
+ provider_session_id: ["watcher"], provider_session_id_absent: ["watcher"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], last_progress_event_at: ["watcher"],
210
+ };
115
211
  function isPlainObject(value) {
116
212
  return value !== null && typeof value === "object" && !Array.isArray(value);
117
213
  }
@@ -145,6 +241,8 @@ export function parseLaneEnvelope(raw) {
145
241
  "story_status",
146
242
  "artifacts",
147
243
  "blocked_reason",
244
+ "story_complete",
245
+ "story_complete_reason",
148
246
  ].includes(key));
149
247
  if (extra.length > 0) {
150
248
  throw new LaneSchemaError(`Lane envelope rejects unknown properties: ${extra.join(", ")}`, { extra });
@@ -169,9 +267,7 @@ export function parseLaneEnvelope(raw) {
169
267
  }
170
268
  }
171
269
  else if (questions !== undefined) {
172
- if (!Array.isArray(questions) || questions.length !== 0) {
173
- throw new LaneSchemaError('questions must be omitted or empty unless decision is "ask"');
174
- }
270
+ throw new LaneSchemaError('questions is present exactly when decision is "ask"');
175
271
  }
176
272
  if (raw.decision === "blocked") {
177
273
  if (typeof raw.blocked_reason !== "string" || raw.blocked_reason.length === 0) {
@@ -181,13 +277,50 @@ export function parseLaneEnvelope(raw) {
181
277
  else if (raw.blocked_reason !== undefined) {
182
278
  throw new LaneSchemaError('blocked_reason is present exactly when decision is "blocked"');
183
279
  }
280
+ const storyStatus = raw.story_status;
281
+ let storyComplete;
282
+ let storyCompleteReason;
283
+ if (raw.story_complete !== undefined) {
284
+ if (typeof raw.story_complete !== "boolean") {
285
+ throw new LaneSchemaError("story_complete must be a boolean", {
286
+ field: "story_complete",
287
+ });
288
+ }
289
+ if (typeof raw.story_complete_reason !== "string" ||
290
+ raw.story_complete_reason.trim().length === 0) {
291
+ throw new LaneSchemaError("story_complete_reason is required when story_complete is present", { field: "story_complete_reason" });
292
+ }
293
+ if (raw.story_complete === true && storyStatus !== "done") {
294
+ throw new LaneSchemaError("story_complete true requires story_status done", {
295
+ error: "story_complete_status_mismatch",
296
+ story_complete: true,
297
+ story_status: storyStatus,
298
+ });
299
+ }
300
+ if (raw.story_complete === false && storyStatus === "done") {
301
+ throw new LaneSchemaError("story_complete false forbids story_status done", {
302
+ error: "story_complete_status_mismatch",
303
+ story_complete: false,
304
+ story_status: storyStatus,
305
+ });
306
+ }
307
+ storyComplete = raw.story_complete;
308
+ storyCompleteReason = raw.story_complete_reason.trim();
309
+ }
310
+ else if (raw.story_complete_reason !== undefined) {
311
+ throw new LaneSchemaError("story_complete_reason is present exactly when story_complete is present", { field: "story_complete_reason" });
312
+ }
184
313
  const envelope = {
185
314
  schema_version: ENVELOPE_SCHEMA_VERSION,
186
315
  decision: raw.decision,
187
316
  summary,
188
- story_status: raw.story_status,
317
+ story_status: storyStatus,
189
318
  artifacts: raw.artifacts,
190
319
  };
320
+ if (storyComplete !== undefined) {
321
+ envelope.story_complete = storyComplete;
322
+ envelope.story_complete_reason = storyCompleteReason;
323
+ }
191
324
  if (Array.isArray(questions) && questions.length > 0) {
192
325
  envelope.questions = questions.map((item, index) => {
193
326
  if (!isPlainObject(item)) {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Detached entry for the per-lane watcher. Spawned with
3
+ * `detached: true` from `spawnLaneWorker`; not a session job.
4
+ *
5
+ * Usage: node watcher-main.js <lane-id>
6
+ * Requires HQ_ROOT in the environment.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=watcher-main.d.ts.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Detached entry for the per-lane watcher. Spawned with
3
+ * `detached: true` from `spawnLaneWorker`; not a session job.
4
+ *
5
+ * Usage: node watcher-main.js <lane-id>
6
+ * Requires HQ_ROOT in the environment.
7
+ */
8
+ import { runWatcherLoop } from "./watcher.js";
9
+ function main() {
10
+ const laneId = process.argv[2]?.trim();
11
+ const hqRoot = process.env.HQ_ROOT?.trim();
12
+ if (!laneId || !hqRoot) {
13
+ process.stderr.write("hq lanes watcher: requires lane id argv and HQ_ROOT in the environment\n");
14
+ process.exit(1);
15
+ }
16
+ void runWatcherLoop(hqRoot, laneId).catch((err) => {
17
+ process.stderr.write(`hq lanes watcher: ${err instanceof Error ? err.message : String(err)}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
18
+ process.exit(1);
19
+ });
20
+ }
21
+ main();
22
+ //# sourceMappingURL=watcher-main.js.map