@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.
- package/CHANGELOG.md +61 -0
- package/assets/scaffold/core/scripts/handoff-finalize.sh +670 -0
- package/assets/scaffold/core/scripts/handoff-open-steps.sh +81 -0
- package/assets/scaffold/core/scripts/handoff-post.sh +187 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
- package/assets/scaffold/core/scripts/hq-session.sh +182 -21
- package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
- package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
- package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
- package/assets/scaffold/core/scripts/qmd-reindex-bg.sh +40 -0
- package/dist/command-catalog.generated.d.ts +110 -0
- package/dist/command-catalog.generated.js +136 -0
- package/dist/commands/cloud.d.ts +26 -14
- package/dist/commands/cloud.js +207 -56
- package/dist/commands/core-handoff.d.ts +22 -0
- package/dist/commands/core-handoff.js +1240 -0
- package/dist/commands/core-jobs-native.d.ts +8 -0
- package/dist/commands/core-jobs-native.js +624 -0
- package/dist/commands/core-native-jobs-flag.d.ts +4 -0
- package/dist/commands/core-native-jobs-flag.js +7 -0
- package/dist/commands/core-ontology.d.ts +6 -1
- package/dist/commands/core-ontology.js +42 -7
- package/dist/commands/core-policy.js +24 -5
- package/dist/commands/core.js +15 -1
- package/dist/commands/integrations-core.js +7 -0
- package/dist/commands/lanes.js +111 -35
- package/dist/commands/members.js +5 -0
- package/dist/commands/mesh.js +46 -25
- package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
- package/dist/commands/personal-targeted-push-flag.js +7 -0
- package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
- package/dist/commands/personal-targeted-push-paths.js +67 -0
- package/dist/commands/secrets.js +9 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.js +28 -0
- package/dist/fast-core.js +1 -0
- package/dist/lib/billing/free-month.d.ts +18 -0
- package/dist/lib/billing/free-month.js +31 -0
- package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
- package/dist/lib/billing/plan-limit-refusals.js +187 -0
- package/dist/lib/billing/plan-lock.d.ts +2 -6
- package/dist/lib/billing/plan-lock.js +5 -16
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +21 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +100 -0
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +34 -2
- package/dist/lib/checkpoint-ontology/merge-copies.js +541 -27
- package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
- package/dist/lib/core-utils/secret-patterns.js +3 -2
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/autopilot.d.ts +69 -0
- package/dist/lib/lanes/autopilot.js +418 -0
- package/dist/lib/lanes/mesh-events.js +2 -0
- package/dist/lib/lanes/store.d.ts +4 -2
- package/dist/lib/lanes/store.js +18 -9
- package/dist/lib/mesh/api.js +4 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
- package/dist/lib/mesh/live/daemon/state.js +34 -2
- package/dist/main.js +23 -12
- package/dist/utils/plan-gate-error.d.ts +24 -0
- package/dist/utils/plan-gate-error.js +219 -9
- package/dist/utils/self-update.d.ts +3 -0
- package/dist/utils/self-update.js +232 -1
- 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
|
|
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;
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
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
|
|
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);
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|