@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
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Push an Outpost-targeted lane through `hq outposts exec` (spec §5.2).
3
+ *
4
+ * The transport is the existing OutpostsClient submit/wait pair — the same
5
+ * path as `hq outposts exec --async`. Create stages a payload, then runs
6
+ * `hq lanes pickup --payload-url` on the box. A submit or wait failure is
7
+ * `outpost_dispatch_failed`, never a queued lane nobody will pick up.
8
+ */
9
+ import { OutpostsClient } from "@indigoai-us/hq-cloud/outposts";
10
+ import type { LaneRecord } from "./types.js";
11
+ export declare const PICKUP_EXEC_TIMEOUT_SECONDS = 120;
12
+ export declare const PICKUP_WAIT_MS: number;
13
+ export interface OutpostPickupPayload {
14
+ lane: LaneRecord;
15
+ brief: string;
16
+ timeout?: {
17
+ timeout_secs: number;
18
+ deadline_at: string;
19
+ };
20
+ }
21
+ export interface OutpostDispatchResult {
22
+ commandId: string;
23
+ status: string;
24
+ done?: boolean;
25
+ exitCode: number | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ }
29
+ export interface OutpostExecPort {
30
+ stageExecInput: (outpostId?: string) => Promise<{
31
+ putUrl: string;
32
+ getUrl: string;
33
+ }>;
34
+ submitExec: (command: string, outpostId?: string, timeoutSeconds?: number) => Promise<{
35
+ commandId: string;
36
+ }>;
37
+ waitForExecResult: (commandId: string, outpostId?: string, options?: {
38
+ signal?: AbortSignal;
39
+ }) => Promise<{
40
+ status: string;
41
+ done: boolean;
42
+ exitCode?: number | null;
43
+ stdout?: string;
44
+ stderr?: string;
45
+ }>;
46
+ }
47
+ export declare function parseOutpostPickupPayload(raw: unknown): OutpostPickupPayload;
48
+ /** Same wrapping `hq outposts exec` applies: run from the box's HQ folder. */
49
+ export declare function buildOutpostPickupCommand(payloadUrl: string): string;
50
+ export declare function stageOutpostPayload(port: Pick<OutpostExecPort, "stageExecInput">, outpostId: string, bytes: Uint8Array, put?: typeof fetch): Promise<{
51
+ getUrl: string;
52
+ }>;
53
+ export declare function dispatchOutpostPickup(port: OutpostExecPort, input: {
54
+ outpostId: string;
55
+ command: string;
56
+ timeoutSeconds?: number;
57
+ waitMs?: number;
58
+ }): Promise<OutpostDispatchResult>;
59
+ export declare function dispatchErrorMessage(err: unknown): string;
60
+ /** Factory matching `hq outposts exec`'s client wiring. */
61
+ export declare function createOutpostsClient(opts: {
62
+ transport: ConstructorParameters<typeof OutpostsClient>[0]["transport"];
63
+ token: ConstructorParameters<typeof OutpostsClient>[0]["token"];
64
+ }): OutpostsClient;
65
+ //# sourceMappingURL=outpost-dispatch.d.ts.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Push an Outpost-targeted lane through `hq outposts exec` (spec §5.2).
3
+ *
4
+ * The transport is the existing OutpostsClient submit/wait pair — the same
5
+ * path as `hq outposts exec --async`. Create stages a payload, then runs
6
+ * `hq lanes pickup --payload-url` on the box. A submit or wait failure is
7
+ * `outpost_dispatch_failed`, never a queued lane nobody will pick up.
8
+ */
9
+ import { OutpostsClient, joinCommandParts, withRemoteHqDir } from "@indigoai-us/hq-cloud/outposts";
10
+ import { LanesError } from "./errors.js";
11
+ export const PICKUP_EXEC_TIMEOUT_SECONDS = 120;
12
+ export const PICKUP_WAIT_MS = PICKUP_EXEC_TIMEOUT_SECONDS * 1000;
13
+ export function parseOutpostPickupPayload(raw) {
14
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
15
+ throw new LanesError("outpost_payload_invalid", "Outpost pickup payload must be a JSON object", {});
16
+ }
17
+ const body = raw;
18
+ if (body.lane === null || typeof body.lane !== "object" || Array.isArray(body.lane)) {
19
+ throw new LanesError("outpost_payload_invalid", "Outpost pickup payload.lane must be an object", {});
20
+ }
21
+ if (typeof body.brief !== "string") {
22
+ throw new LanesError("outpost_payload_invalid", "Outpost pickup payload.brief must be a string", {});
23
+ }
24
+ let timeout;
25
+ if (body.timeout !== undefined && body.timeout !== null) {
26
+ if (typeof body.timeout !== "object" || Array.isArray(body.timeout)) {
27
+ throw new LanesError("outpost_payload_invalid", "Outpost pickup payload.timeout must be an object", {});
28
+ }
29
+ const t = body.timeout;
30
+ if (typeof t.timeout_secs !== "number" || typeof t.deadline_at !== "string") {
31
+ throw new LanesError("outpost_payload_invalid", "Outpost pickup payload.timeout needs timeout_secs and deadline_at", {});
32
+ }
33
+ timeout = { timeout_secs: t.timeout_secs, deadline_at: t.deadline_at };
34
+ }
35
+ return {
36
+ lane: body.lane,
37
+ brief: body.brief,
38
+ timeout,
39
+ };
40
+ }
41
+ /** Same wrapping `hq outposts exec` applies: run from the box's HQ folder. */
42
+ export function buildOutpostPickupCommand(payloadUrl) {
43
+ return withRemoteHqDir(joinCommandParts(["hq", "lanes", "pickup", "--payload-url", payloadUrl]));
44
+ }
45
+ export async function stageOutpostPayload(port, outpostId, bytes, put = fetch) {
46
+ const staged = await port.stageExecInput(outpostId);
47
+ const upload = await put(staged.putUrl, {
48
+ method: "PUT",
49
+ headers: { "Content-Length": String(bytes.byteLength) },
50
+ body: bytes,
51
+ });
52
+ if (!upload.ok) {
53
+ throw new LanesError("outpost_dispatch_failed", `Could not upload lane payload: HTTP ${upload.status}`, { status: upload.status, outpost_id: outpostId });
54
+ }
55
+ return { getUrl: staged.getUrl };
56
+ }
57
+ export async function dispatchOutpostPickup(port, input) {
58
+ const submitted = await port.submitExec(input.command, input.outpostId, input.timeoutSeconds ?? PICKUP_EXEC_TIMEOUT_SECONDS);
59
+ const ac = new AbortController();
60
+ const waitMs = input.waitMs ?? PICKUP_WAIT_MS;
61
+ const timer = setTimeout(() => ac.abort(), waitMs);
62
+ try {
63
+ const result = await port.waitForExecResult(submitted.commandId, input.outpostId, { signal: ac.signal });
64
+ return {
65
+ commandId: submitted.commandId,
66
+ status: result.status,
67
+ done: result.done,
68
+ exitCode: result.exitCode ?? null,
69
+ stdout: result.stdout ?? "",
70
+ stderr: result.stderr ?? "",
71
+ };
72
+ }
73
+ catch (err) {
74
+ if (ac.signal.aborted) {
75
+ throw new LanesError("outpost_dispatch_failed", `Timed out waiting for Outpost pickup (${waitMs}ms)`, { outpost_id: input.outpostId, command_id: submitted.commandId });
76
+ }
77
+ throw err;
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+ export function dispatchErrorMessage(err) {
84
+ return err instanceof Error ? err.message : String(err);
85
+ }
86
+ /** Factory matching `hq outposts exec`'s client wiring. */
87
+ export function createOutpostsClient(opts) {
88
+ return new OutpostsClient(opts);
89
+ }
90
+ //# sourceMappingURL=outpost-dispatch.js.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
3
+ * path and for envelope `decision: "ask"` ingestion. Two writers here would
4
+ * drift on hop, origin, raised_via, body store, and routing_history.
5
+ */
6
+ import { type LaneEnvelope, type LaneRecord, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
7
+ export interface QuestionBody {
8
+ question: string;
9
+ context: string;
10
+ options: string[];
11
+ recommended?: string;
12
+ notes?: string;
13
+ }
14
+ export declare function parseQuestionBodyStore(value: unknown): QuestionBodyStore;
15
+ export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company">): QuestionBodyStore;
16
+ export declare function questionBodyRef(questionId: string): string;
17
+ export declare function sha256Text(text: string): string;
18
+ /** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
19
+ export declare function questionBodyAbsPath(hqRoot: string, question: Pick<QuestionRecord, "company" | "body" | "question_id">): string;
20
+ export declare function writeQuestionBody(hqRoot: string, question: Pick<QuestionRecord, "company" | "body" | "question_id">, body: QuestionBody): {
21
+ store: QuestionBodyStore;
22
+ ref: string;
23
+ digest: string;
24
+ };
25
+ export interface RaiseLaneQuestionInput {
26
+ hqRoot: string;
27
+ lane: LaneRecord;
28
+ body: QuestionBody;
29
+ raisedVia: RaisedVia;
30
+ at: string;
31
+ questionId?: string;
32
+ }
33
+ /**
34
+ * Persist one question the way `hq lanes ask` does: createQuestion, write the
35
+ * tenant body, then CAS routing_history with reason `asked`.
36
+ */
37
+ export declare function raiseLaneQuestion(input: RaiseLaneQuestionInput): QuestionRecord;
38
+ /**
39
+ * Options that, trimmed, case-folded and stripped of trailing punctuation,
40
+ * are exactly {continue, stop} in either order. The brief authorises the work
41
+ * it lists, so this question asks the operator nothing the brief did not
42
+ * already settle; it is recorded as `question_kind: "continue"`.
43
+ */
44
+ export declare function classifyQuestionKind(options: readonly string[]): QuestionKind | undefined;
45
+ /** The store a lane's question bodies live in, derived from the lane and never the reverse. */
46
+ export declare function questionBodyPathForLane(hqRoot: string, lane: Pick<LaneRecord, "company">, questionId: string): string;
47
+ /**
48
+ * Free-text notes the operator attached with `questions answer --notes`.
49
+ * Read from the lane's own store (spec §6.5): the company store for a company
50
+ * lane, the personal store otherwise. Returns undefined when the answer has
51
+ * no notes_ref or the body carries no notes; a body the host cannot read is
52
+ * reported and skipped rather than failing the resume.
53
+ */
54
+ export declare function readAnswerNotes(hqRoot: string, lane: Pick<LaneRecord, "company">, question: Pick<QuestionRecord, "question_id" | "answer">): string | undefined;
55
+ export interface AnsweredQuestionEntry {
56
+ question_id: string;
57
+ choice: string | undefined;
58
+ answered_at: string | undefined;
59
+ answered_by: string | undefined;
60
+ notes?: string;
61
+ }
62
+ /**
63
+ * The `answered-questions.json` a resumed worker is handed (spec §6.5). Ids,
64
+ * choices and timestamps come from the routing record; the operator's notes
65
+ * come from the lane's own body store and are written only into the run
66
+ * directory on the executing host.
67
+ */
68
+ export declare function answeredQuestionEntries(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company">, questions: QuestionRecord[]): AnsweredQuestionEntry[];
69
+ export declare function envelopeQuestionId(laneId: string, envelopeRef: string, index: number): string;
70
+ /**
71
+ * Spec §6.1: each envelope questions[] entry becomes one queue record, via
72
+ * raiseLaneQuestion (the same function the CLI ask path uses).
73
+ */
74
+ export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string): QuestionRecord[];
75
+ //# sourceMappingURL=questions.d.ts.map
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
3
+ * path and for envelope `decision: "ask"` ingestion. Two writers here would
4
+ * drift on hop, origin, raised_via, body store, and routing_history.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { LanesError } from "./errors.js";
10
+ import { appendCappedHistory, createQuestion, retryUpdateQuestion, } from "./store.js";
11
+ import { QUESTION_BODY_STORES, } from "./types.js";
12
+ export function parseQuestionBodyStore(value) {
13
+ if (typeof value === "string" &&
14
+ QUESTION_BODY_STORES.includes(value)) {
15
+ return value;
16
+ }
17
+ throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
18
+ }
19
+ export function bodyStoreForLane(lane) {
20
+ return lane.company ? "company" : "personal";
21
+ }
22
+ export function questionBodyRef(questionId) {
23
+ return path.posix.join("lanes", "questions", `${questionId}.json`);
24
+ }
25
+ export function sha256Text(text) {
26
+ return `sha256:${createHash("sha256").update(text).digest("hex")}`;
27
+ }
28
+ /** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
29
+ export function questionBodyAbsPath(hqRoot, question) {
30
+ const store = parseQuestionBodyStore(question.body.store);
31
+ if (store === "company") {
32
+ if (!question.company?.slug) {
33
+ throw new LanesError("question_body_store_unresolved", "Company question body is missing a company slug", { question_id: question.question_id });
34
+ }
35
+ return path.join(hqRoot, "companies", question.company.slug, "lanes", "questions", `${question.question_id}.json`);
36
+ }
37
+ return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
38
+ }
39
+ export function writeQuestionBody(hqRoot, question, body) {
40
+ const store = parseQuestionBodyStore(question.body.store);
41
+ const filePath = questionBodyAbsPath(hqRoot, question);
42
+ const serialized = `${JSON.stringify(body, null, 2)}\n`;
43
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
44
+ fs.writeFileSync(filePath, serialized, "utf8");
45
+ return {
46
+ store,
47
+ ref: questionBodyRef(question.question_id),
48
+ digest: sha256Text(serialized),
49
+ };
50
+ }
51
+ /**
52
+ * Persist one question the way `hq lanes ask` does: createQuestion, write the
53
+ * tenant body, then CAS routing_history with reason `asked`.
54
+ */
55
+ export function raiseLaneQuestion(input) {
56
+ const store = bodyStoreForLane(input.lane);
57
+ let questionId = input.questionId;
58
+ if (!questionId) {
59
+ const created = createQuestion(input.hqRoot, {
60
+ lane_id: input.lane.lane_id,
61
+ company: input.lane.company,
62
+ project_id: input.lane.project_id,
63
+ story_id: input.lane.story_id,
64
+ thread_id: input.lane.thread_id,
65
+ raised_via: input.raisedVia,
66
+ asked_at: input.at,
67
+ origin: { kind: "lane", id: input.lane.lane_id },
68
+ hop: 1,
69
+ status: "pending",
70
+ body: { store, ref: "", digest: "" },
71
+ routing_history: [],
72
+ });
73
+ questionId = created.question_id;
74
+ }
75
+ else {
76
+ try {
77
+ createQuestion(input.hqRoot, {
78
+ question_id: questionId,
79
+ lane_id: input.lane.lane_id,
80
+ company: input.lane.company,
81
+ project_id: input.lane.project_id,
82
+ story_id: input.lane.story_id,
83
+ thread_id: input.lane.thread_id,
84
+ raised_via: input.raisedVia,
85
+ asked_at: input.at,
86
+ origin: { kind: "lane", id: input.lane.lane_id },
87
+ hop: 1,
88
+ status: "pending",
89
+ body: { store, ref: "", digest: "" },
90
+ routing_history: [],
91
+ });
92
+ }
93
+ catch (err) {
94
+ if (!(err instanceof LanesError) || err.error !== "question_exists") {
95
+ throw err;
96
+ }
97
+ }
98
+ }
99
+ const stored = writeQuestionBody(input.hqRoot, {
100
+ company: input.lane.company,
101
+ question_id: questionId,
102
+ body: { store, ref: "", digest: "" },
103
+ }, input.body);
104
+ const questionKind = classifyQuestionKind(input.body.options);
105
+ return retryUpdateQuestion(input.hqRoot, questionId, (current) => {
106
+ const asked = current.routing_history.some((entry) => entry.reason === "asked");
107
+ return {
108
+ ...current,
109
+ ...(questionKind !== undefined ? { question_kind: questionKind } : {}),
110
+ body: stored,
111
+ raised_via: input.raisedVia,
112
+ routing_history: asked
113
+ ? current.routing_history
114
+ : appendCappedHistory(current.routing_history, {
115
+ hop: 1,
116
+ audience: input.lane.senior,
117
+ at: input.at,
118
+ reason: "asked",
119
+ }),
120
+ };
121
+ });
122
+ }
123
+ /**
124
+ * Options that, trimmed, case-folded and stripped of trailing punctuation,
125
+ * are exactly {continue, stop} in either order. The brief authorises the work
126
+ * it lists, so this question asks the operator nothing the brief did not
127
+ * already settle; it is recorded as `question_kind: "continue"`.
128
+ */
129
+ export function classifyQuestionKind(options) {
130
+ if (options.length !== 2)
131
+ return undefined;
132
+ const folded = options.map((option) => option
133
+ .trim()
134
+ .toLowerCase()
135
+ .replace(/[.!?…]+$/u, "")
136
+ .trim());
137
+ const isContinueStop = folded.includes("continue") && folded.includes("stop") && folded[0] !== folded[1];
138
+ return isContinueStop ? "continue" : undefined;
139
+ }
140
+ /** The store a lane's question bodies live in, derived from the lane and never the reverse. */
141
+ export function questionBodyPathForLane(hqRoot, lane, questionId) {
142
+ return questionBodyAbsPath(hqRoot, {
143
+ company: lane.company,
144
+ question_id: questionId,
145
+ body: { store: bodyStoreForLane(lane), ref: "", digest: "" },
146
+ });
147
+ }
148
+ /**
149
+ * Free-text notes the operator attached with `questions answer --notes`.
150
+ * Read from the lane's own store (spec §6.5): the company store for a company
151
+ * lane, the personal store otherwise. Returns undefined when the answer has
152
+ * no notes_ref or the body carries no notes; a body the host cannot read is
153
+ * reported and skipped rather than failing the resume.
154
+ */
155
+ export function readAnswerNotes(hqRoot, lane, question) {
156
+ if (!question.answer?.notes_ref)
157
+ return undefined;
158
+ const filePath = questionBodyPathForLane(hqRoot, lane, question.question_id);
159
+ let text;
160
+ try {
161
+ text = fs.readFileSync(filePath, "utf8");
162
+ }
163
+ catch (err) {
164
+ const code = err.code;
165
+ process.stderr.write(`hq lanes: answer notes for ${question.question_id} unreadable at ${filePath} (${code ?? (err instanceof Error ? err.message : String(err))})\n`);
166
+ return undefined;
167
+ }
168
+ let parsed;
169
+ try {
170
+ parsed = JSON.parse(text);
171
+ }
172
+ catch (err) {
173
+ process.stderr.write(`hq lanes: answer notes for ${question.question_id} at ${filePath} is not JSON: ${err instanceof Error ? err.message : String(err)}\n`);
174
+ return undefined;
175
+ }
176
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
177
+ return undefined;
178
+ const notes = parsed.notes;
179
+ return typeof notes === "string" && notes.length > 0 ? notes : undefined;
180
+ }
181
+ /**
182
+ * The `answered-questions.json` a resumed worker is handed (spec §6.5). Ids,
183
+ * choices and timestamps come from the routing record; the operator's notes
184
+ * come from the lane's own body store and are written only into the run
185
+ * directory on the executing host.
186
+ */
187
+ export function answeredQuestionEntries(hqRoot, lane, questions) {
188
+ return questions
189
+ .filter((q) => q.origin.id === lane.lane_id && q.status === "answered" && q.answer)
190
+ .map((q) => {
191
+ const entry = {
192
+ question_id: q.question_id,
193
+ choice: q.answer?.choice,
194
+ answered_at: q.answer?.answered_at,
195
+ answered_by: q.answer?.answered_by,
196
+ };
197
+ const notes = readAnswerNotes(hqRoot, lane, q);
198
+ if (notes !== undefined)
199
+ entry.notes = notes;
200
+ return entry;
201
+ });
202
+ }
203
+ export function envelopeQuestionId(laneId, envelopeRef, index) {
204
+ const stem = path.basename(envelopeRef, ".json").replace(/[^A-Za-z0-9_-]/g, "");
205
+ const digest = createHash("sha256")
206
+ .update(`${laneId}\0${envelopeRef}\0${index}`)
207
+ .digest("hex")
208
+ .slice(0, 10);
209
+ return `qenv_${stem}_${index}_${digest}`;
210
+ }
211
+ function envelopeQuestionBody(item) {
212
+ const body = {
213
+ question: item.question,
214
+ context: item.context,
215
+ options: item.options,
216
+ };
217
+ if (item.recommended !== undefined)
218
+ body.recommended = item.recommended;
219
+ return body;
220
+ }
221
+ /**
222
+ * Spec §6.1: each envelope questions[] entry becomes one queue record, via
223
+ * raiseLaneQuestion (the same function the CLI ask path uses).
224
+ */
225
+ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at) {
226
+ const items = envelope.questions ?? [];
227
+ const raised = [];
228
+ for (let i = 0; i < items.length; i += 1) {
229
+ const item = items[i];
230
+ if (!item)
231
+ continue;
232
+ raised.push(raiseLaneQuestion({
233
+ hqRoot,
234
+ lane,
235
+ body: envelopeQuestionBody(item),
236
+ raisedVia: "envelope",
237
+ at,
238
+ questionId: envelopeQuestionId(lane.lane_id, envelopeRef, i),
239
+ }));
240
+ }
241
+ return raised;
242
+ }
243
+ //# sourceMappingURL=questions.js.map
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Machine-written provider quota, keyed by account and model.
3
+ *
4
+ * `enabled` and `disabled_reason` live in personal/lanes/config.json and are
5
+ * the operator's hand-written switch. `quota_reset_at` and `needs_credits`
6
+ * are written here, in personal/lanes/quota.json, from the provider's own
7
+ * 429. They are separate fields because a quota hold expires on its own:
8
+ * copying it into `disabled_reason` or flipping `enabled` would erase the
9
+ * operator's reason, and would leave the account switched off after the
10
+ * provider reset. The balancer reads this file and does not rewrite the
11
+ * operator fields.
12
+ *
13
+ * A weekly-limit 429 names a reset and applies to every model on that
14
+ * account (measured: account b, every model, until the reset). A credits
15
+ * 429 names one model and no reset (measured: account d refuses
16
+ * claude-fable-5-1 and still runs claude-opus-5). The record is therefore
17
+ * per (account, model), plus an account-wide reset when the text is a
18
+ * usage limit rather than a switch-model refusal.
19
+ */
20
+ import type { LaneProvider } from "./types.js";
21
+ export declare const QUOTA_STATE_REL: string;
22
+ export declare const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
23
+ export interface ModelQuota {
24
+ quota_reset_at?: string;
25
+ needs_credits?: boolean;
26
+ /** Operator-facing marker, e.g. "needs credits for claude-fable-5-1". */
27
+ marker?: string;
28
+ }
29
+ export interface AccountQuota {
30
+ /** Set when the 429 is an account-wide usage limit with a reset time. */
31
+ all_models_quota_reset_at?: string;
32
+ models: Record<string, ModelQuota>;
33
+ }
34
+ export interface QuotaState {
35
+ schema_version: typeof QUOTA_SCHEMA_VERSION;
36
+ providers: Partial<Record<LaneProvider, Record<string, AccountQuota>>>;
37
+ }
38
+ export interface QuotaSignal {
39
+ reason: "account_quota_exhausted" | "account_model_unavailable";
40
+ quota_reset_at?: string;
41
+ model?: string;
42
+ needs_credits?: boolean;
43
+ marker?: string;
44
+ /** True when the text is a usage limit with a reset, not a per-model credits refusal. */
45
+ account_wide?: boolean;
46
+ }
47
+ export declare function quotaStatePath(hqRoot: string): string;
48
+ export declare function loadQuotaState(hqRoot: string): QuotaState;
49
+ /**
50
+ * Parse "resets Sep 24, 2am (UTC)" into an ISO timestamp. The year is the
51
+ * current UTC year, rolled forward when that instant is already past.
52
+ * Returns null when the text names no reset.
53
+ */
54
+ export declare function parseProviderResetTime(text: string, now: Date): string | null;
55
+ /**
56
+ * Read only `api_error_status`, `is_error`, and `result` off the last
57
+ * `type: "result"` object. Nothing else from the payload is returned, and
58
+ * this function does not log.
59
+ */
60
+ export declare function quotaFieldsFromOutput(text: string): {
61
+ api_error_status: unknown;
62
+ is_error: unknown;
63
+ result: unknown;
64
+ } | null;
65
+ export declare function quotaSignalFromFields(fields: {
66
+ api_error_status: unknown;
67
+ is_error: unknown;
68
+ result: unknown;
69
+ }, model: string | undefined, now: Date): QuotaSignal | null;
70
+ export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
71
+ export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, signal: QuotaSignal): QuotaState;
72
+ export interface QuotaBlock {
73
+ blocked: boolean;
74
+ reason?: "account_quota_exhausted" | "account_model_unavailable";
75
+ quota_reset_at?: string;
76
+ marker?: string;
77
+ model?: string;
78
+ }
79
+ /**
80
+ * Skip an account only for the models it cannot run. An account-wide reset
81
+ * blocks every model. A needs-credits marker blocks only that model, with
82
+ * no expiry. A past reset does not block.
83
+ */
84
+ export declare function quotaBlockFor(state: QuotaState, provider: LaneProvider, accountId: string, model: string | undefined, now: Date): QuotaBlock;
85
+ export interface AccountQuotaView {
86
+ quota_reset_at: string | null;
87
+ model_quota: Array<{
88
+ model: string;
89
+ quota_reset_at?: string;
90
+ needs_credits?: boolean;
91
+ marker?: string;
92
+ }>;
93
+ }
94
+ export declare function accountQuotaView(state: QuotaState, provider: LaneProvider, accountId: string): AccountQuotaView;
95
+ //# sourceMappingURL=quota.d.ts.map