@indigoai-us/hq-cli 5.290.0 → 5.292.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 +66 -3
- package/assets/scaffold/core/scripts/handoff-finalize.sh +18 -0
- package/assets/scaffold/core/scripts/handoff-post.sh +71 -6
- package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
- package/assets/scaffold/core/scripts/hq-delegate-bundle.sh +307 -0
- package/assets/scaffold/core/scripts/hq-delegate-grant.sh +226 -0
- package/assets/scaffold/core/scripts/hq-delegate-publish.sh +91 -0
- package/assets/scaffold/core/scripts/hq-delegate-send.sh +259 -0
- package/assets/scaffold/core/scripts/hq-delegate-verify.sh +213 -0
- package/assets/scaffold/core/scripts/hq-job-notify.sh +389 -0
- package/assets/scaffold/core/scripts/hq-job-probe.sh +835 -0
- package/assets/scaffold/core/scripts/hq-job-remediate.sh +359 -0
- package/assets/scaffold/core/scripts/hq-job-run.sh +1025 -0
- 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/outpost-jobs-reconcile.sh +830 -0
- package/dist/command-catalog.generated.d.ts +165 -1
- package/dist/command-catalog.generated.js +205 -1
- package/dist/commands/cloud.d.ts +26 -14
- package/dist/commands/cloud.js +207 -56
- package/dist/commands/core-delegate-w1b.d.ts +8 -0
- package/dist/commands/core-delegate-w1b.js +1049 -0
- package/dist/commands/core-delegate.d.ts +28 -0
- package/dist/commands/core-delegate.js +15 -13
- package/dist/commands/core-handoff.js +152 -23
- package/dist/commands/core-jobs-native.d.ts +23 -0
- package/dist/commands/core-jobs-native.js +1331 -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 +22 -4
- package/dist/commands/core-ontology.js +90 -8
- package/dist/commands/core-policy.js +24 -5
- package/dist/commands/core.js +17 -0
- 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/drop-aliases.d.ts +41 -0
- package/dist/lib/checkpoint-ontology/drop-aliases.js +76 -0
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +6 -3
- package/dist/lib/checkpoint-ontology/entity-file.js +12 -6
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +27 -8
- package/dist/lib/checkpoint-ontology/merge-copies.js +64 -97
- package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
- package/dist/lib/core-utils/secret-patterns.js +3 -2
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +2 -0
- 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.js +11 -6
- 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/scaffold-sibling-references.js +4 -4
- package/dist/utils/self-update.d.ts +6 -0
- package/dist/utils/self-update.js +28 -6
- package/dist/utils/staged-install.d.ts +34 -0
- package/dist/utils/staged-install.js +296 -0
- package/package.json +2 -2
- package/scripts/mesh-daemon-if-stale.mjs +4 -0
|
@@ -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))
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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}`, {
|
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
|
|
@@ -29,13 +29,45 @@ export function readDaemonState(dir) {
|
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
|
|
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
|
|
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
|
}
|