@indigoai-us/hq-cli 5.290.0 → 5.291.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 (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  3. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  4. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  5. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  6. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  7. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  8. package/dist/command-catalog.generated.d.ts +59 -0
  9. package/dist/command-catalog.generated.js +73 -0
  10. package/dist/commands/cloud.d.ts +26 -14
  11. package/dist/commands/cloud.js +207 -56
  12. package/dist/commands/core-jobs-native.d.ts +8 -0
  13. package/dist/commands/core-jobs-native.js +624 -0
  14. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  15. package/dist/commands/core-native-jobs-flag.js +7 -0
  16. package/dist/commands/core-policy.js +24 -5
  17. package/dist/commands/core.js +7 -0
  18. package/dist/commands/integrations-core.js +7 -0
  19. package/dist/commands/lanes.js +111 -35
  20. package/dist/commands/members.js +5 -0
  21. package/dist/commands/mesh.js +46 -25
  22. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  23. package/dist/commands/personal-targeted-push-flag.js +7 -0
  24. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  25. package/dist/commands/personal-targeted-push-paths.js +67 -0
  26. package/dist/commands/secrets.js +9 -0
  27. package/dist/commands/skill.d.ts +3 -0
  28. package/dist/commands/skill.js +28 -0
  29. package/dist/fast-core.js +1 -0
  30. package/dist/lib/billing/free-month.d.ts +18 -0
  31. package/dist/lib/billing/free-month.js +31 -0
  32. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  33. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  34. package/dist/lib/billing/plan-lock.d.ts +2 -6
  35. package/dist/lib/billing/plan-lock.js +5 -16
  36. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  37. package/dist/lib/core-utils/secret-patterns.js +3 -2
  38. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  39. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  40. package/dist/lib/lanes/autopilot.d.ts +69 -0
  41. package/dist/lib/lanes/autopilot.js +418 -0
  42. package/dist/lib/lanes/mesh-events.js +2 -0
  43. package/dist/lib/lanes/store.js +11 -6
  44. package/dist/lib/mesh/api.js +4 -0
  45. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  46. package/dist/lib/mesh/live/daemon/state.js +34 -2
  47. package/dist/main.js +23 -12
  48. package/dist/utils/plan-gate-error.d.ts +24 -0
  49. package/dist/utils/plan-gate-error.js +219 -9
  50. package/package.json +2 -2
@@ -0,0 +1,69 @@
1
+ import { readLanesRegistryFlag } from "./registry-flag.js";
2
+ import type { LaneRecord } from "./types.js";
3
+ export declare const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
4
+ export declare const LEAD_AUTOPILOT_DEFAULT_ENABLED = false;
5
+ export declare const LEAD_AUTOPILOT_REVIEW_DELAY_MS: number;
6
+ export declare const LEAD_AUTOPILOT_FILL_DELAY_MS: number;
7
+ export declare const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
8
+ export interface RegisteredLead {
9
+ team: string;
10
+ lane: string;
11
+ host: string;
12
+ status: string;
13
+ prd: string;
14
+ }
15
+ export interface AutopilotWake {
16
+ condition: "review_finished_worker" | "fill_grant";
17
+ lead_lane: string;
18
+ story_id: string;
19
+ worker_lane?: string;
20
+ prompt: string;
21
+ fingerprint: string;
22
+ }
23
+ export interface AutopilotPassResult {
24
+ ok: true;
25
+ host: string;
26
+ evaluated: number;
27
+ sent: AutopilotWake[];
28
+ started: Array<{
29
+ lead_lane: string;
30
+ story_id: string;
31
+ worker_lane: string;
32
+ }>;
33
+ skipped: Array<{
34
+ lead_lane: string;
35
+ reason: string;
36
+ }>;
37
+ }
38
+ interface ProjectStory {
39
+ id: string;
40
+ status: string;
41
+ briefPath?: string;
42
+ laneIds?: string[];
43
+ updatedAt?: string;
44
+ }
45
+ export declare function planLeadAutopilotWakes(input: {
46
+ lead: LaneRecord;
47
+ children: readonly LaneRecord[];
48
+ stories: readonly ProjectStory[];
49
+ now: Date;
50
+ keepAliveResumed?: boolean;
51
+ }): AutopilotWake[];
52
+ export declare function runLeadAutopilotPass(input: {
53
+ hqRoot: string;
54
+ host: string;
55
+ hostname: string;
56
+ outpostId: string | null;
57
+ now: Date;
58
+ dryRun?: boolean;
59
+ readFlag?: typeof readLanesRegistryFlag;
60
+ keepAliveResumedLeads?: ReadonlySet<string>;
61
+ startWorker?: (input: {
62
+ lead: LaneRecord;
63
+ story: ProjectStory;
64
+ }) => Promise<{
65
+ lane_id: string;
66
+ }>;
67
+ }): Promise<AutopilotPassResult>;
68
+ export {};
69
+ //# sourceMappingURL=autopilot.d.ts.map
@@ -0,0 +1,418 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { appendDropboxMessage } from "./dropbox.js";
4
+ import { LanesError } from "./errors.js";
5
+ import { readLanesRegistryFlag } from "./registry-flag.js";
6
+ import { readAllLanes } from "./store.js";
7
+ export const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
8
+ export const LEAD_AUTOPILOT_DEFAULT_ENABLED = false;
9
+ export const LEAD_AUTOPILOT_REVIEW_DELAY_MS = 15 * 60_000;
10
+ export const LEAD_AUTOPILOT_FILL_DELAY_MS = 15 * 60_000;
11
+ export const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
12
+ const LIVE_STATES = new Set(["running", "queued", "awaiting_input", "blocked"]);
13
+ const OPEN_STORY_STATES = new Set(["todo", "queued", "in_progress", "review", "blocked"]);
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16
+ }
17
+ function readJsonFile(filePath, code) {
18
+ let text;
19
+ try {
20
+ const stat = fs.lstatSync(filePath);
21
+ if (!stat.isFile() || stat.isSymbolicLink())
22
+ throw new Error("expected a regular file");
23
+ text = fs.readFileSync(filePath, "utf8");
24
+ }
25
+ catch (error) {
26
+ throw new LanesError(code, `Cannot read ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { path: filePath });
27
+ }
28
+ try {
29
+ return JSON.parse(text);
30
+ }
31
+ catch (error) {
32
+ throw new LanesError(code, `Cannot parse ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { path: filePath });
33
+ }
34
+ }
35
+ function parseRegistry(hqRoot) {
36
+ const file = path.join(hqRoot, "workspace", "lane-briefs", "juniors.json");
37
+ let stat;
38
+ try {
39
+ stat = fs.lstatSync(file);
40
+ }
41
+ catch (error) {
42
+ if (error.code === "ENOENT")
43
+ return [];
44
+ throw new LanesError("lead_autopilot_registry_invalid", `Cannot inspect ${file}: ${error instanceof Error ? error.message : String(error)}`, { path: file });
45
+ }
46
+ if (!stat.isFile() || stat.isSymbolicLink()) {
47
+ throw new LanesError("lead_autopilot_registry_invalid", `Invalid lead registry ${file}: expected a regular file`, { path: file });
48
+ }
49
+ const raw = readJsonFile(file, "lead_autopilot_registry_invalid");
50
+ if (isRecord(raw) && raw.juniors === undefined && Array.isArray(raw.retired))
51
+ return [];
52
+ if (!isRecord(raw) || !Array.isArray(raw.juniors)) {
53
+ throw new LanesError("lead_autopilot_registry_invalid", `Invalid lead registry ${file}: juniors must be an array`, { path: file });
54
+ }
55
+ const rows = [];
56
+ for (const [index, value] of raw.juniors.entries()) {
57
+ if (!isRecord(value) || ["team", "lane", "host", "status", "prd"].some((key) => typeof value[key] !== "string" || !value[key].trim())) {
58
+ throw new LanesError("lead_autopilot_registry_invalid", `Invalid lead registry ${file}: juniors[${index}] must name team, lane, host, status, and prd`, { path: file, index });
59
+ }
60
+ rows.push(value);
61
+ }
62
+ return rows;
63
+ }
64
+ function registeredFilePath(hqRoot, relativePath, errorCode) {
65
+ const root = path.resolve(hqRoot);
66
+ const file = path.resolve(root, relativePath);
67
+ if (file === root || !file.startsWith(`${root}${path.sep}`)) {
68
+ throw new LanesError(errorCode, `Registered path escapes HQ root: ${relativePath}`, { path: relativePath });
69
+ }
70
+ try {
71
+ const realRoot = fs.realpathSync(root);
72
+ const realFile = fs.realpathSync(file);
73
+ if (realFile === realRoot || !realFile.startsWith(`${realRoot}${path.sep}`)) {
74
+ throw new LanesError(errorCode, `Registered path resolves outside HQ root: ${relativePath}`, { path: relativePath });
75
+ }
76
+ }
77
+ catch (error) {
78
+ if (error instanceof LanesError)
79
+ throw error;
80
+ throw new LanesError(errorCode, `Cannot resolve registered path ${relativePath}: ${error instanceof Error ? error.message : String(error)}`, { path: relativePath });
81
+ }
82
+ return file;
83
+ }
84
+ function projectStories(hqRoot, relativePath, companySlug, projectId) {
85
+ if (companySlug !== undefined) {
86
+ const expected = path.join("companies", companySlug, "projects", projectId, "prd.json");
87
+ if (path.normalize(relativePath) !== expected) {
88
+ throw new LanesError("lead_autopilot_tenant_mismatch", `Project registry path does not match lead ${companySlug}/${projectId}`, { path: relativePath, company: companySlug, project_id: projectId });
89
+ }
90
+ }
91
+ const file = registeredFilePath(hqRoot, relativePath, "lead_autopilot_project_path_invalid");
92
+ const raw = readJsonFile(file, "lead_autopilot_project_invalid");
93
+ const rows = isRecord(raw) ? raw.userStories ?? raw.stories : undefined;
94
+ if (!Array.isArray(rows)) {
95
+ throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: userStories or stories must be an array`, { path: file });
96
+ }
97
+ return rows.map((story, index) => {
98
+ const status = isRecord(story) ? story.status ?? story.state : undefined;
99
+ if (!isRecord(story) || typeof story.id !== "string" || typeof status !== "string") {
100
+ throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] needs string id and status/state`, { path: file, index });
101
+ }
102
+ const sourceRef = isRecord(story.source) ? story.source.ref : undefined;
103
+ const rawBrief = story.brief_path ?? story.brief_file ?? sourceRef;
104
+ if (story.lanes !== undefined && (!Array.isArray(story.lanes) || story.lanes.some((laneId) => typeof laneId !== "string" || !laneId.trim()))) {
105
+ throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] lanes must be an array of non-empty lane IDs`, { path: file, index });
106
+ }
107
+ if (story.updated_at !== undefined && (typeof story.updated_at !== "string" || !Number.isFinite(Date.parse(story.updated_at)))) {
108
+ throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] updated_at must be a valid timestamp`, { path: file, index });
109
+ }
110
+ if (rawBrief !== undefined && (typeof rawBrief !== "string" || !rawBrief.trim())) {
111
+ throw new LanesError("lead_autopilot_project_invalid", `Invalid project ${file}: stories[${index}] brief path must be a non-empty string`, { path: file, index });
112
+ }
113
+ if (typeof rawBrief === "string" && companySlug !== undefined) {
114
+ const normalizedBrief = path.normalize(rawBrief);
115
+ if (normalizedBrief.startsWith(`companies${path.sep}`) && !normalizedBrief.startsWith(path.join("companies", companySlug) + path.sep)) {
116
+ throw new LanesError("lead_autopilot_tenant_mismatch", `Story brief path does not match lead ${companySlug}/${projectId}`, { path: rawBrief, company: companySlug, project_id: projectId });
117
+ }
118
+ }
119
+ let briefPath;
120
+ if (typeof rawBrief === "string") {
121
+ briefPath = registeredFilePath(hqRoot, rawBrief, "lead_autopilot_story_brief_path_invalid");
122
+ try {
123
+ const briefStat = fs.lstatSync(briefPath);
124
+ if (!briefStat.isFile() || briefStat.isSymbolicLink())
125
+ throw new Error("expected a regular file");
126
+ }
127
+ catch (error) {
128
+ throw new LanesError("lead_autopilot_story_brief_invalid", `Cannot read story brief ${rawBrief}: ${error instanceof Error ? error.message : String(error)}`, { path: rawBrief, story_id: story.id });
129
+ }
130
+ }
131
+ return {
132
+ id: story.id,
133
+ status,
134
+ ...(briefPath ? { briefPath } : {}),
135
+ ...(Array.isArray(story.lanes) ? { laneIds: story.lanes } : {}),
136
+ ...(typeof story.updated_at === "string" ? { updatedAt: story.updated_at } : {}),
137
+ };
138
+ });
139
+ }
140
+ function readStatus(hqRoot, relativePath) {
141
+ const file = registeredFilePath(hqRoot, relativePath, "lead_autopilot_status_path_invalid");
142
+ const status = readJsonFile(file, "lead_autopilot_status_invalid");
143
+ if (!isRecord(status))
144
+ throw new LanesError("lead_autopilot_status_invalid", `Invalid manager status ${file}: expected an object`, { path: file });
145
+ return status;
146
+ }
147
+ export function planLeadAutopilotWakes(input) {
148
+ const { lead, children, stories, now } = input;
149
+ if (input.keepAliveResumed)
150
+ return [];
151
+ const byStory = new Map(stories.map((story) => [story.id, story]));
152
+ const wakes = [];
153
+ const finishedReviews = children.flatMap((child) => {
154
+ if (!["done", "blocked", "failed", "interrupted"].includes(child.state))
155
+ return [];
156
+ const story = byStory.get(child.story_id);
157
+ if (!story || !OPEN_STORY_STATES.has(story.status))
158
+ return [];
159
+ const finishedAt = child.ended_at ?? child.updated_at;
160
+ const finishedMs = Date.parse(finishedAt);
161
+ if (!Number.isFinite(finishedMs) || now.getTime() - finishedMs < LEAD_AUTOPILOT_REVIEW_DELAY_MS)
162
+ return [];
163
+ return [{ laneId: child.lane_id, story, finishedAt }];
164
+ });
165
+ const knownChildIds = new Set(children.map((child) => child.lane_id));
166
+ const missingReviews = stories.flatMap((story) => {
167
+ if (!OPEN_STORY_STATES.has(story.status) || !story.updatedAt || !story.laneIds)
168
+ return [];
169
+ const finishedMs = Date.parse(story.updatedAt);
170
+ if (!Number.isFinite(finishedMs) || now.getTime() - finishedMs < LEAD_AUTOPILOT_REVIEW_DELAY_MS)
171
+ return [];
172
+ return story.laneIds.filter((laneId) => !knownChildIds.has(laneId)).map((laneId) => ({ laneId, story, finishedAt: story.updatedAt }));
173
+ });
174
+ const overdueReviews = [...finishedReviews, ...missingReviews].sort((a, b) => a.laneId.localeCompare(b.laneId));
175
+ if (overdueReviews.length > 0) {
176
+ const fingerprint = `review:${overdueReviews.map(({ laneId, story, finishedAt }) => `${laneId}:${story.id}:${story.status}:${finishedAt}`).join(",")}`;
177
+ const reviewList = overdueReviews.map(({ laneId, story }) => `lane ${laneId} (story ${story.id}, status ${story.status})`).join("; ");
178
+ wakes.push({
179
+ condition: "review_finished_worker",
180
+ lead_lane: lead.lane_id,
181
+ story_id: overdueReviews.map(({ story }) => story.id).join(","),
182
+ ...(overdueReviews.length === 1 ? { worker_lane: overdueReviews[0].laneId } : {}),
183
+ fingerprint,
184
+ prompt: `Review finished worker ${overdueReviews.length === 1 ? "lane" : "lanes"}: ${reviewList}. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.`,
185
+ });
186
+ }
187
+ const liveChildren = children.filter((child) => LIVE_STATES.has(child.state));
188
+ const liveCount = liveChildren.length;
189
+ const assigned = new Set(liveChildren.flatMap((child) => [child.story_id, ...(child.stories ?? []).map((story) => story.story_id)]));
190
+ const readyStory = stories.find((story) => story.status === "todo" && !assigned.has(story.id));
191
+ if (readyStory && liveCount < (lead.hire_grant ?? 0)) {
192
+ const fingerprint = `fill:${readyStory.id}:${liveCount}:${lead.hire_grant ?? 0}`;
193
+ wakes.push({
194
+ condition: "fill_grant",
195
+ lead_lane: lead.lane_id,
196
+ story_id: readyStory.id,
197
+ fingerprint,
198
+ prompt: `Start a worker for ready story ${readyStory.id}. You have ${liveCount} live workers against a grant of ${lead.hire_grant ?? 0}. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.`,
199
+ });
200
+ }
201
+ return wakes;
202
+ }
203
+ function readState(file) {
204
+ if (!fs.existsSync(file))
205
+ return { schema_version: "hq-lanes-autopilot-state.v1", sent: {} };
206
+ const value = readJsonFile(file, "lead_autopilot_state_invalid");
207
+ if (!isRecord(value) || value.schema_version !== "hq-lanes-autopilot-state.v1" || !isRecord(value.sent)) {
208
+ throw new LanesError("lead_autopilot_state_invalid", `Invalid autopilot state ${file}`, { path: file });
209
+ }
210
+ for (const [key, row] of Object.entries(value.sent)) {
211
+ if (!isRecord(row) || typeof row.fingerprint !== "string" || typeof row.at !== "string" ||
212
+ (row.status_updated_at !== undefined && typeof row.status_updated_at !== "string") ||
213
+ (row.acted !== undefined && typeof row.acted !== "boolean") ||
214
+ (row.auto_started !== undefined && typeof row.auto_started !== "boolean") ||
215
+ (row.worker_lane !== undefined && typeof row.worker_lane !== "string")) {
216
+ throw new LanesError("lead_autopilot_state_invalid", `Invalid autopilot state entry ${key}`, { path: file, key });
217
+ }
218
+ }
219
+ return value;
220
+ }
221
+ function writeState(file, state) {
222
+ fs.mkdirSync(path.dirname(file), { recursive: true });
223
+ const temp = `${file}.${process.pid}.tmp`;
224
+ fs.writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
225
+ fs.renameSync(temp, file);
226
+ }
227
+ async function runLeadAutopilotPassUnlocked(input) {
228
+ const { hqRoot, host, now } = input;
229
+ const result = { ok: true, host, evaluated: 0, sent: [], started: [], skipped: [] };
230
+ const leads = parseRegistry(hqRoot).filter((lead) => lead.host === host || (host === "local" && lead.host === input.hostname));
231
+ const lanes = readAllLanes(hqRoot);
232
+ const byId = new Map(lanes.map((lane) => [lane.lane_id, lane]));
233
+ const stateFile = path.join(hqRoot, "workspace", "lane-briefs", "autopilot-state.json");
234
+ const state = readState(stateFile);
235
+ for (const registered of leads) {
236
+ const lead = byId.get(registered.lane) ?? lanes.find((candidate) => candidate.lane_id.startsWith(`${registered.lane}_`));
237
+ if (!lead) {
238
+ result.skipped.push({ lead_lane: registered.lane, reason: "lead_lane_missing" });
239
+ continue;
240
+ }
241
+ if (lead.company === null) {
242
+ result.skipped.push({ lead_lane: lead.lane_id, reason: "lead_company_unresolved" });
243
+ continue;
244
+ }
245
+ const registeredLocalHost = registered.host === "local" || registered.host === input.hostname;
246
+ if ((registeredLocalHost && (lead.host.kind !== "local" || lead.host.id !== input.hostname)) ||
247
+ (!registeredLocalHost && (lead.host.kind !== "outpost" || lead.host.id !== registered.host || input.outpostId !== registered.host))) {
248
+ result.skipped.push({ lead_lane: lead.lane_id, reason: "lead_host_mismatch" });
249
+ continue;
250
+ }
251
+ const enabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_FLAG, { company: lead.company });
252
+ if (!enabled) {
253
+ result.skipped.push({ lead_lane: lead.lane_id, reason: "flag_off" });
254
+ continue;
255
+ }
256
+ const status = readStatus(hqRoot, registered.status);
257
+ const statusUpdatedAt = typeof status.updated_at === "string" && Number.isFinite(Date.parse(status.updated_at))
258
+ ? new Date(Date.parse(status.updated_at)).toISOString()
259
+ : undefined;
260
+ result.evaluated += 1;
261
+ const stories = projectStories(hqRoot, registered.prd, lead.company.slug, lead.project_id);
262
+ const children = lanes.filter((lane) => lane.senior.kind === "lane" && lane.senior.id === lead.lane_id);
263
+ const planned = planLeadAutopilotWakes({
264
+ lead,
265
+ children,
266
+ stories,
267
+ now,
268
+ keepAliveResumed: input.keepAliveResumedLeads?.has(lead.lane_id) === true,
269
+ });
270
+ let stateChanged = false;
271
+ const activeKeys = new Set(planned.map((wake) => `${lead.lane_id}:${wake.condition}`));
272
+ for (const key of Object.keys(state.sent)) {
273
+ if (key.startsWith(`${lead.lane_id}:`) && !activeKeys.has(key)) {
274
+ delete state.sent[key];
275
+ stateChanged = true;
276
+ }
277
+ }
278
+ for (const wake of planned) {
279
+ const key = `${lead.lane_id}:${wake.condition}`;
280
+ const prior = state.sent[key];
281
+ if (prior?.fingerprint === wake.fingerprint) {
282
+ if (wake.condition === "fill_grant" && input.startWorker && !prior.acted && !prior.auto_started) {
283
+ const wakeAt = Date.parse(prior.at);
284
+ if (!Number.isFinite(wakeAt)) {
285
+ throw new LanesError("lead_autopilot_state_invalid", `Invalid fill-grant wake time for ${lead.lane_id}`, { lead_lane: lead.lane_id, story_id: wake.story_id });
286
+ }
287
+ if (statusUpdatedAt === undefined || prior.status_updated_at === undefined) {
288
+ throw new LanesError("lead_autopilot_status_activity_unverifiable", `Cannot verify whether lead ${lead.lane_id} acted after its fill-grant wake`, { lead_lane: lead.lane_id, story_id: wake.story_id });
289
+ }
290
+ if (Date.parse(statusUpdatedAt) > Date.parse(prior.status_updated_at)) {
291
+ state.sent[key] = { ...prior, acted: true };
292
+ stateChanged = true;
293
+ }
294
+ else if (now.getTime() - wakeAt >= LEAD_AUTOPILOT_FILL_DELAY_MS) {
295
+ const story = stories.find((candidate) => candidate.id === wake.story_id && candidate.status === "todo");
296
+ if (!story?.briefPath) {
297
+ throw new LanesError("lead_autopilot_story_brief_missing", `Cannot auto-start story ${wake.story_id}: its project record has no valid brief path`, { lead_lane: lead.lane_id, story_id: wake.story_id, project_id: lead.project_id });
298
+ }
299
+ if (lead.state !== "running" && lead.state !== "queued") {
300
+ state.sent[key] = { ...prior, acted: true };
301
+ stateChanged = true;
302
+ result.skipped.push({ lead_lane: lead.lane_id, reason: "auto_start_lead_not_active" });
303
+ }
304
+ else {
305
+ const started = await input.startWorker({ lead, story });
306
+ state.sent[key] = { ...prior, auto_started: true, worker_lane: started.lane_id };
307
+ stateChanged = true;
308
+ result.started.push({ lead_lane: lead.lane_id, story_id: story.id, worker_lane: started.lane_id });
309
+ }
310
+ }
311
+ }
312
+ continue;
313
+ }
314
+ if (!input.dryRun) {
315
+ appendDropboxMessage(hqRoot, lead.lane_id, {
316
+ at: now.toISOString(),
317
+ message_id: `lead-autopilot:${wake.fingerprint}`,
318
+ kind: "system_notice",
319
+ text: wake.prompt,
320
+ });
321
+ state.sent[key] = {
322
+ fingerprint: wake.fingerprint,
323
+ at: now.toISOString(),
324
+ ...(statusUpdatedAt ? { status_updated_at: statusUpdatedAt } : {}),
325
+ };
326
+ stateChanged = true;
327
+ }
328
+ result.sent.push(wake);
329
+ }
330
+ if (!input.dryRun && stateChanged)
331
+ writeState(stateFile, state);
332
+ }
333
+ return result;
334
+ }
335
+ function reclaimStaleLock(lockPath) {
336
+ let before;
337
+ let text;
338
+ let after;
339
+ try {
340
+ before = fs.lstatSync(lockPath);
341
+ text = fs.readFileSync(lockPath, "utf8");
342
+ after = fs.lstatSync(lockPath);
343
+ }
344
+ catch (error) {
345
+ if (error.code === "ENOENT")
346
+ return true;
347
+ return false;
348
+ }
349
+ if (!before.isFile() || before.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino)
350
+ return false;
351
+ const pid = Number(text.split("\n", 1)[0]);
352
+ if (!Number.isSafeInteger(pid) || pid < 1)
353
+ return false;
354
+ try {
355
+ process.kill(pid, 0);
356
+ return false;
357
+ }
358
+ catch (error) {
359
+ if (error.code !== "ESRCH")
360
+ return false;
361
+ }
362
+ try {
363
+ const current = fs.lstatSync(lockPath);
364
+ if (current.dev !== before.dev || current.ino !== before.ino)
365
+ return false;
366
+ fs.unlinkSync(lockPath);
367
+ return true;
368
+ }
369
+ catch (error) {
370
+ if (error.code === "ENOENT")
371
+ return true;
372
+ return false;
373
+ }
374
+ }
375
+ export async function runLeadAutopilotPass(input) {
376
+ if (input.dryRun)
377
+ return runLeadAutopilotPassUnlocked(input);
378
+ const lockPath = path.join(input.hqRoot, "workspace", "lane-briefs", "autopilot.lock");
379
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
380
+ let lockFd;
381
+ for (let attempt = 0; attempt < 2; attempt += 1) {
382
+ try {
383
+ lockFd = fs.openSync(lockPath, "wx", 0o600);
384
+ fs.writeFileSync(lockFd, `${process.pid}\n${input.now.toISOString()}\n`, "utf8");
385
+ break;
386
+ }
387
+ catch (error) {
388
+ if (error.code === "EEXIST" && attempt === 0 && reclaimStaleLock(lockPath))
389
+ continue;
390
+ if (error.code === "EEXIST") {
391
+ throw new LanesError("lead_autopilot_lock_busy", `Another autopilot pass owns ${lockPath}`, { path: lockPath });
392
+ }
393
+ throw new LanesError("lead_autopilot_lock_failed", `Cannot lock autopilot state: ${error instanceof Error ? error.message : String(error)}`, { path: lockPath });
394
+ }
395
+ }
396
+ if (lockFd === undefined)
397
+ throw new LanesError("lead_autopilot_lock_busy", `Another autopilot pass owns ${lockPath}`, { path: lockPath });
398
+ try {
399
+ return await runLeadAutopilotPassUnlocked(input);
400
+ }
401
+ finally {
402
+ try {
403
+ fs.closeSync(lockFd);
404
+ }
405
+ catch (error) {
406
+ process.stderr.write(`hq lanes autopilot: lock close failed: ${error instanceof Error ? error.message : String(error)}\n`);
407
+ }
408
+ try {
409
+ fs.unlinkSync(lockPath);
410
+ }
411
+ catch (error) {
412
+ if (error.code !== "ENOENT") {
413
+ process.stderr.write(`hq lanes autopilot: lock removal failed: ${error instanceof Error ? error.message : String(error)}\n`);
414
+ }
415
+ }
416
+ }
417
+ }
418
+ //# sourceMappingURL=autopilot.js.map
@@ -452,6 +452,8 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
452
452
  if (!company || !threadId)
453
453
  return;
454
454
  const patchStoryStatus = async (status) => {
455
+ if (!lane.project_id?.trim() || !lane.story_id?.trim())
456
+ return;
455
457
  const suppressions = opts.storyStatusSuppressions;
456
458
  const key = laneStoryStatusSuppressionKey(company, lane.project_id, lane.story_id);
457
459
  if (suppressions?.has(key))
@@ -529,6 +529,7 @@ export function parseLaneRecord(raw, filePath) {
529
529
  value: raw.queued_reason,
530
530
  });
531
531
  }
532
+ let normalized;
532
533
  if (raw.queued_details !== undefined) {
533
534
  const details = raw.queued_details;
534
535
  if (details === null || typeof details !== "object" || Array.isArray(details) ||
@@ -549,11 +550,15 @@ export function parseLaneRecord(raw, filePath) {
549
550
  });
550
551
  }
551
552
  if (raw.queued_reason !== "worker_profile_changed") {
552
- throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
553
- path: filePath,
554
- field: "queued_details",
555
- queued_reason: raw.queued_reason,
556
- });
553
+ if (raw.queued_reason === undefined) {
554
+ throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
555
+ path: filePath,
556
+ field: "queued_details",
557
+ queued_reason: raw.queued_reason,
558
+ });
559
+ }
560
+ normalized = { ...raw };
561
+ delete normalized.queued_details;
557
562
  }
558
563
  }
559
564
  else if (detailsRecord.reason === "outpost_dispatch_unknown") {
@@ -831,7 +836,7 @@ export function parseLaneRecord(raw, filePath) {
831
836
  }
832
837
  // Spread-preserving: return the original object so unknown keys survive
833
838
  // a parse/rewrite cycle (spec §5.1 field-preservation).
834
- return raw;
839
+ return (normalized ?? raw);
835
840
  }
836
841
  function parseLaneAccountSelection(value, filePath) {
837
842
  const failure = (field) => new LaneSchemaError(`Invalid account_selection.${field}`, {
@@ -387,6 +387,10 @@ export async function appendThreadEvent(token, companyUid, threadId, eventKind,
387
387
  * credential. This body never includes actorUid.
388
388
  */
389
389
  export async function patchStory(token, companyUid, projectId, storyId, patch) {
390
+ if (typeof projectId !== "string" || projectId.trim() === "" ||
391
+ typeof storyId !== "string" || storyId.trim() === "") {
392
+ throw new TypeError("Work Mesh projectId and storyId must be non-empty strings");
393
+ }
390
394
  const body = { companyUid };
391
395
  if (patch.status)
392
396
  body.status = patch.status;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Daemon state.json under ~/.hq/work-mesh/daemon/ (0600, metadata-only).
3
3
  */
4
+ import * as fs from "node:fs";
4
5
  import type { MqttConnectionState } from "./presence.js";
5
6
  import type { FlushSummary } from "../flush.js";
6
7
  import type { DroppedPresenceCompany } from "./credentials.js";
@@ -39,6 +40,11 @@ export interface DaemonStateFile {
39
40
  }
40
41
  export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
41
42
  export declare function readDaemonState(dir: string): DaemonStateFile | null;
42
- export declare function writeDaemonState(dir: string, state: DaemonStateFile): void;
43
+ interface WriteDaemonStateOptions {
44
+ renameSync?: typeof fs.renameSync;
45
+ sleepSync?: (ms: number) => void;
46
+ }
47
+ export declare function writeDaemonState(dir: string, state: DaemonStateFile, options?: WriteDaemonStateOptions): void;
43
48
  export declare function patchDaemonState(dir: string, patch: Partial<DaemonStateFile>, now?: () => Date): DaemonStateFile;
49
+ export {};
44
50
  //# sourceMappingURL=state.d.ts.map
@@ -29,13 +29,45 @@ export function readDaemonState(dir) {
29
29
  return null;
30
30
  }
31
31
  }
32
- export function writeDaemonState(dir, state) {
32
+ const DAEMON_STATE_RENAME_RETRY_DELAYS_MS = [20, 50, 100];
33
+ function sleepSync(ms) {
34
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)), 0, 0, ms);
35
+ }
36
+ function isTransientRenameContention(err) {
37
+ return err !== null && typeof err === "object" && "code" in err &&
38
+ (err.code === "EPERM" || err.code === "EBUSY");
39
+ }
40
+ export function writeDaemonState(dir, state, options = {}) {
33
41
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
34
42
  const dest = daemonStatePath(dir);
35
43
  const tmp = path.join(dir, `.state.tmp.${process.pid}.${Date.now()}`);
36
44
  const body = `${JSON.stringify(state, null, 2)}\n`;
37
45
  fs.writeFileSync(tmp, body, { mode: 0o600 });
38
- fs.renameSync(tmp, dest);
46
+ const renameSync = options.renameSync ?? fs.renameSync;
47
+ const waitSync = options.sleepSync ?? sleepSync;
48
+ try {
49
+ for (let attempt = 0;; attempt += 1) {
50
+ try {
51
+ renameSync(tmp, dest);
52
+ break;
53
+ }
54
+ catch (err) {
55
+ const delay = DAEMON_STATE_RENAME_RETRY_DELAYS_MS[attempt];
56
+ if (!isTransientRenameContention(err) || delay === undefined)
57
+ throw err;
58
+ waitSync(delay);
59
+ }
60
+ }
61
+ }
62
+ catch (err) {
63
+ try {
64
+ fs.unlinkSync(tmp);
65
+ }
66
+ catch {
67
+ /* Preserve the rename failure; the destination remains the prior complete state. */
68
+ }
69
+ throw err;
70
+ }
39
71
  try {
40
72
  fs.chmodSync(dest, 0o600);
41
73
  }