@indigoai-us/hq-cli 5.289.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 (65) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/assets/scaffold/core/scripts/handoff-finalize.sh +670 -0
  3. package/assets/scaffold/core/scripts/handoff-open-steps.sh +81 -0
  4. package/assets/scaffold/core/scripts/handoff-post.sh +187 -0
  5. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  6. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  7. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  8. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  9. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  10. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  11. package/assets/scaffold/core/scripts/qmd-reindex-bg.sh +40 -0
  12. package/dist/command-catalog.generated.d.ts +110 -0
  13. package/dist/command-catalog.generated.js +136 -0
  14. package/dist/commands/cloud.d.ts +26 -14
  15. package/dist/commands/cloud.js +207 -56
  16. package/dist/commands/core-handoff.d.ts +22 -0
  17. package/dist/commands/core-handoff.js +1240 -0
  18. package/dist/commands/core-jobs-native.d.ts +8 -0
  19. package/dist/commands/core-jobs-native.js +624 -0
  20. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  21. package/dist/commands/core-native-jobs-flag.js +7 -0
  22. package/dist/commands/core-ontology.d.ts +6 -1
  23. package/dist/commands/core-ontology.js +42 -7
  24. package/dist/commands/core-policy.js +24 -5
  25. package/dist/commands/core.js +15 -1
  26. package/dist/commands/integrations-core.js +7 -0
  27. package/dist/commands/lanes.js +111 -35
  28. package/dist/commands/members.js +5 -0
  29. package/dist/commands/mesh.js +46 -25
  30. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  31. package/dist/commands/personal-targeted-push-flag.js +7 -0
  32. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  33. package/dist/commands/personal-targeted-push-paths.js +67 -0
  34. package/dist/commands/secrets.js +9 -0
  35. package/dist/commands/skill.d.ts +3 -0
  36. package/dist/commands/skill.js +28 -0
  37. package/dist/fast-core.js +1 -0
  38. package/dist/lib/billing/free-month.d.ts +18 -0
  39. package/dist/lib/billing/free-month.js +31 -0
  40. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  41. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  42. package/dist/lib/billing/plan-lock.d.ts +2 -6
  43. package/dist/lib/billing/plan-lock.js +5 -16
  44. package/dist/lib/checkpoint-ontology/entity-file.d.ts +21 -0
  45. package/dist/lib/checkpoint-ontology/entity-file.js +100 -0
  46. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +34 -2
  47. package/dist/lib/checkpoint-ontology/merge-copies.js +541 -27
  48. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  49. package/dist/lib/core-utils/secret-patterns.js +3 -2
  50. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  51. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  52. package/dist/lib/lanes/autopilot.d.ts +69 -0
  53. package/dist/lib/lanes/autopilot.js +418 -0
  54. package/dist/lib/lanes/mesh-events.js +2 -0
  55. package/dist/lib/lanes/store.d.ts +4 -2
  56. package/dist/lib/lanes/store.js +18 -9
  57. package/dist/lib/mesh/api.js +4 -0
  58. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  59. package/dist/lib/mesh/live/daemon/state.js +34 -2
  60. package/dist/main.js +23 -12
  61. package/dist/utils/plan-gate-error.d.ts +24 -0
  62. package/dist/utils/plan-gate-error.js +219 -9
  63. package/dist/utils/self-update.d.ts +3 -0
  64. package/dist/utils/self-update.js +232 -1
  65. 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))
@@ -24,6 +24,8 @@ export interface StoreDeps {
24
24
  readdirSync: typeof fs.readdirSync;
25
25
  /** Bulk readers may provide a pre-indexed conflict-sidecar lookup. */
26
26
  conflictSidecarPathsFor?: (recordPath: string) => readonly string[];
27
+ /** Injectable ULID source for deterministic lane-id generation. */
28
+ generateLaneUlid?: (timestampMs: number) => string;
27
29
  openSync: typeof fs.openSync;
28
30
  closeSync: typeof fs.closeSync;
29
31
  fsyncSync: typeof fs.fsyncSync;
@@ -36,7 +38,7 @@ export declare function questionRecordPath(hqRoot: string, questionId: string):
36
38
  export declare function pendingMarkerPath(hqRoot: string, questionId: string): string;
37
39
  export declare function sanitizeHostComponent(hostname: string): string;
38
40
  /** Collision-safe across machines: ULID plus a host component. */
39
- export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
41
+ export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> & Partial<Pick<StoreDeps, "generateLaneUlid">>): string;
40
42
  export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
41
43
  export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
42
44
  export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
@@ -52,7 +54,7 @@ export declare function mergeLaneRecords(live: LaneRecord, sidecars: readonly La
52
54
  export declare function mergeQuestionRecords(live: QuestionRecord, sidecars: readonly QuestionRecord[]): QuestionRecord;
53
55
  export declare function tryReadLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord | null;
54
56
  export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
55
- /** Reads every lane while scanning the lane directory for conflict sidecars once. */
57
+ /** Reads all lanes, indexing sidecars once and sorting by creation time then id. */
56
58
  export declare function readAllLanes(hqRoot: string, deps?: StoreDeps): LaneRecord[];
57
59
  export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
58
60
  export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
@@ -67,7 +67,8 @@ export function sanitizeHostComponent(hostname) {
67
67
  /** Collision-safe across machines: ULID plus a host component. */
68
68
  export function mintLaneId(deps = defaultStoreDeps()) {
69
69
  const host = sanitizeHostComponent(deps.hostname());
70
- return `${generateUlid(deps.now().getTime())}_${host}`;
70
+ const timestampMs = deps.now().getTime();
71
+ return `${(deps.generateLaneUlid ?? generateUlid)(timestampMs)}_${host}`;
71
72
  }
72
73
  export function mintQuestionId(deps = defaultStoreDeps()) {
73
74
  const host = sanitizeHostComponent(deps.hostname());
@@ -528,6 +529,7 @@ export function parseLaneRecord(raw, filePath) {
528
529
  value: raw.queued_reason,
529
530
  });
530
531
  }
532
+ let normalized;
531
533
  if (raw.queued_details !== undefined) {
532
534
  const details = raw.queued_details;
533
535
  if (details === null || typeof details !== "object" || Array.isArray(details) ||
@@ -548,11 +550,15 @@ export function parseLaneRecord(raw, filePath) {
548
550
  });
549
551
  }
550
552
  if (raw.queued_reason !== "worker_profile_changed") {
551
- throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
552
- path: filePath,
553
- field: "queued_details",
554
- queued_reason: raw.queued_reason,
555
- });
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;
556
562
  }
557
563
  }
558
564
  else if (detailsRecord.reason === "outpost_dispatch_unknown") {
@@ -830,7 +836,7 @@ export function parseLaneRecord(raw, filePath) {
830
836
  }
831
837
  // Spread-preserving: return the original object so unknown keys survive
832
838
  // a parse/rewrite cycle (spec §5.1 field-preservation).
833
- return raw;
839
+ return (normalized ?? raw);
834
840
  }
835
841
  function parseLaneAccountSelection(value, filePath) {
836
842
  const failure = (field) => new LaneSchemaError(`Invalid account_selection.${field}`, {
@@ -1178,7 +1184,7 @@ export function readLane(hqRoot, laneId, deps = defaultStoreDeps()) {
1178
1184
  throw new LaneNotFoundError("lane", laneId);
1179
1185
  return record;
1180
1186
  }
1181
- /** Reads every lane while scanning the lane directory for conflict sidecars once. */
1187
+ /** Reads all lanes, indexing sidecars once and sorting by creation time then id. */
1182
1188
  export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
1183
1189
  const laneIds = listLaneIds(hqRoot, deps);
1184
1190
  if (laneIds.length === 0)
@@ -1212,7 +1218,10 @@ export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
1212
1218
  ...deps,
1213
1219
  conflictSidecarPathsFor: (recordPath) => sidecarsByRecord.get(path.basename(recordPath)) ?? [],
1214
1220
  };
1215
- return laneIds.map((laneId) => readLane(hqRoot, laneId, indexedDeps));
1221
+ return laneIds
1222
+ .map((laneId) => readLane(hqRoot, laneId, indexedDeps))
1223
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) ||
1224
+ a.lane_id.localeCompare(b.lane_id));
1216
1225
  }
1217
1226
  export function tryReadQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
1218
1227
  const filePath = questionRecordPath(hqRoot, questionId);
@@ -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