@indigoai-us/hq-cli 5.141.0 → 5.142.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 +21 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +77 -2
- package/dist/command-catalog.generated.d.ts +6 -0
- package/dist/command-catalog.generated.js +8 -0
- package/dist/commands/lanes.js +125 -26
- package/dist/lib/lanes/effort.d.ts +3 -2
- package/dist/lib/lanes/effort.js +5 -1
- package/dist/lib/lanes/errors.d.ts +1 -1
- package/dist/lib/lanes/index.d.ts +2 -0
- package/dist/lib/lanes/index.js +1 -0
- package/dist/lib/lanes/mesh-events.d.ts +4 -5
- package/dist/lib/lanes/mesh-events.js +14 -4
- package/dist/lib/lanes/profile.d.ts +48 -0
- package/dist/lib/lanes/profile.js +405 -0
- package/dist/lib/lanes/reconcile.js +25 -3
- package/dist/lib/lanes/registry.d.ts +3 -0
- package/dist/lib/lanes/registry.js +45 -1
- package/dist/lib/lanes/spawn.d.ts +14 -2
- package/dist/lib/lanes/spawn.js +238 -14
- package/dist/lib/lanes/store.js +10 -0
- package/dist/lib/lanes/types.d.ts +11 -1
- package/dist/lib/lanes/types.js +3 -3
- package/package.json +1 -1
|
@@ -80,11 +80,10 @@ export interface CreateLaneMeshOptions {
|
|
|
80
80
|
token?: () => Promise<string>;
|
|
81
81
|
}
|
|
82
82
|
export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
|
|
83
|
-
/**
|
|
84
|
-
export declare function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
};
|
|
83
|
+
/** Resolve the explicit mesh attribution before falling back to the lane tenant. */
|
|
84
|
+
export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
|
|
85
|
+
/** Company attribution + thread present. Unscoped personal lanes emit nothing. */
|
|
86
|
+
export declare function laneCanEmit(lane: Pick<LaneRecord, "company" | "mesh_company" | "thread_id">): boolean;
|
|
88
87
|
/**
|
|
89
88
|
* Spec §8 terminal-envelope events. `done` → lane_done and story → review;
|
|
90
89
|
* `blocked` → lane_blocked; `ask` → lane_ask with the queue ids. The story is
|
|
@@ -186,9 +186,13 @@ export function createLaneMesh(opts = {}) {
|
|
|
186
186
|
},
|
|
187
187
|
};
|
|
188
188
|
}
|
|
189
|
-
/**
|
|
189
|
+
/** Resolve the explicit mesh attribution before falling back to the lane tenant. */
|
|
190
|
+
export function laneMeshCompany(lane) {
|
|
191
|
+
return lane.mesh_company ?? lane.company ?? undefined;
|
|
192
|
+
}
|
|
193
|
+
/** Company attribution + thread present. Unscoped personal lanes emit nothing. */
|
|
190
194
|
export function laneCanEmit(lane) {
|
|
191
|
-
return lane
|
|
195
|
+
return laneMeshCompany(lane) !== undefined && !!lane.thread_id;
|
|
192
196
|
}
|
|
193
197
|
function baseIdentity(lane) {
|
|
194
198
|
const lastMove = lane.account_history?.[lane.account_history.length - 1];
|
|
@@ -209,8 +213,10 @@ function baseIdentity(lane) {
|
|
|
209
213
|
export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
|
|
210
214
|
if (!laneCanEmit(lane))
|
|
211
215
|
return;
|
|
212
|
-
const company = lane
|
|
216
|
+
const company = laneMeshCompany(lane);
|
|
213
217
|
const threadId = lane.thread_id;
|
|
218
|
+
if (!company || !threadId)
|
|
219
|
+
return;
|
|
214
220
|
switch (envelope.decision) {
|
|
215
221
|
case "done": {
|
|
216
222
|
const outcomes = await Promise.allSettled([
|
|
@@ -260,7 +266,11 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
|
|
|
260
266
|
export async function emitLaneProgressEvent(mesh, lane, detail, signal) {
|
|
261
267
|
if (!laneCanEmit(lane))
|
|
262
268
|
return;
|
|
263
|
-
|
|
269
|
+
const company = laneMeshCompany(lane);
|
|
270
|
+
const threadId = lane.thread_id;
|
|
271
|
+
if (!company || !threadId)
|
|
272
|
+
return;
|
|
273
|
+
await mesh.appendThreadEvent(company, threadId, "lane_progress", {
|
|
264
274
|
...baseIdentity(lane),
|
|
265
275
|
detail,
|
|
266
276
|
}, signal);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable lane worker profiles stored under personal/workers/<id>.
|
|
3
|
+
*
|
|
4
|
+
* A profile is deliberately independent from the worker registry. The
|
|
5
|
+
* registry describes a worker persona that can be resolved from the company
|
|
6
|
+
* and package trees; this module describes the provider launch contract that
|
|
7
|
+
* must be re-read for every spawn.
|
|
8
|
+
*/
|
|
9
|
+
import { type LaneProvider } from "./types.js";
|
|
10
|
+
export interface WorkerProfileMeta {
|
|
11
|
+
version?: string;
|
|
12
|
+
provider: LaneProvider;
|
|
13
|
+
model: string;
|
|
14
|
+
account: string;
|
|
15
|
+
effort: string;
|
|
16
|
+
fast_mode: boolean;
|
|
17
|
+
max_concurrent?: number;
|
|
18
|
+
source?: string;
|
|
19
|
+
accounts?: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface WorkerProfile {
|
|
22
|
+
id: string;
|
|
23
|
+
dir: string;
|
|
24
|
+
meta: WorkerProfileMeta;
|
|
25
|
+
systemPrompt: string;
|
|
26
|
+
settings?: Record<string, unknown>;
|
|
27
|
+
policyFiles: string[];
|
|
28
|
+
hookFiles: string[];
|
|
29
|
+
contentHash: string;
|
|
30
|
+
}
|
|
31
|
+
export interface WorkerProfileRuntime {
|
|
32
|
+
settingsPath?: string;
|
|
33
|
+
hooksDir?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function personalWorkerProfileDir(hqRoot: string, id: string): string;
|
|
36
|
+
/** A meta.yaml file is the explicit marker for a personal worker profile. */
|
|
37
|
+
export declare function workerProfileMarker(hqRoot: string, id: string): "absent" | "present";
|
|
38
|
+
export declare function loadWorkerProfile(hqRoot: string, id: string): WorkerProfile;
|
|
39
|
+
export declare function workerProfilePrompt(profile: WorkerProfile): string;
|
|
40
|
+
export declare function ignoredWorkerProfileFields(provider: LaneProvider): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Materialize provider settings and hook files in the lane run directory.
|
|
43
|
+
* The path is intentionally run-local so every fresh, resumed, moved, and
|
|
44
|
+
* repair spawn gets the profile currently on disk without mutating an account
|
|
45
|
+
* home or a shared provider configuration.
|
|
46
|
+
*/
|
|
47
|
+
export declare function materializeWorkerProfile(profile: WorkerProfile, runDir: string): WorkerProfileRuntime;
|
|
48
|
+
//# sourceMappingURL=profile.d.ts.map
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable lane worker profiles stored under personal/workers/<id>.
|
|
3
|
+
*
|
|
4
|
+
* A profile is deliberately independent from the worker registry. The
|
|
5
|
+
* registry describes a worker persona that can be resolved from the company
|
|
6
|
+
* and package trees; this module describes the provider launch contract that
|
|
7
|
+
* must be re-read for every spawn.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
13
|
+
import { WorkerResolutionError } from "./errors.js";
|
|
14
|
+
import { PROVIDER_EFFORT_LEVELS } from "./effort.js";
|
|
15
|
+
import { LANE_PROVIDERS, isLaneProvider, } from "./types.js";
|
|
16
|
+
const PROFILE_META = "meta.yaml";
|
|
17
|
+
const PROFILE_SYSTEM_PROMPT = "system-prompt.md";
|
|
18
|
+
const PROFILE_SETTINGS = "settings.json";
|
|
19
|
+
const PROFILE_POLICIES = "policies";
|
|
20
|
+
const PROFILE_HOOKS = "hooks";
|
|
21
|
+
const PROFILE_META_KEYS = new Set([
|
|
22
|
+
"version",
|
|
23
|
+
"provider",
|
|
24
|
+
"model",
|
|
25
|
+
"account",
|
|
26
|
+
"effort",
|
|
27
|
+
"fast_mode",
|
|
28
|
+
"max_concurrent",
|
|
29
|
+
"source",
|
|
30
|
+
"accounts",
|
|
31
|
+
]);
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function profileError(id, dir, message, details = {}) {
|
|
36
|
+
return new WorkerResolutionError("worker_profile_invalid", `Worker profile ${JSON.stringify(id)} at ${dir}: ${message}`, { worker: id, path: dir, ...details });
|
|
37
|
+
}
|
|
38
|
+
function validProfileId(id) {
|
|
39
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id);
|
|
40
|
+
}
|
|
41
|
+
function lstatOrAbsent(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
return fs.lstatSync(filePath);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const code = err.code;
|
|
47
|
+
if (code === "ENOENT")
|
|
48
|
+
return null;
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function requireRegularFile(id, dir, filePath, field) {
|
|
53
|
+
let stat;
|
|
54
|
+
try {
|
|
55
|
+
stat = fs.lstatSync(filePath);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
throw profileError(id, dir, `cannot read ${field}: ${err instanceof Error ? err.message : String(err)}`, { field, file: filePath });
|
|
59
|
+
}
|
|
60
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
61
|
+
throw profileError(id, dir, `${field} must be a regular file`, {
|
|
62
|
+
field,
|
|
63
|
+
file: filePath,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return stat;
|
|
67
|
+
}
|
|
68
|
+
function readRequiredText(id, dir, filePath, field) {
|
|
69
|
+
requireRegularFile(id, dir, filePath, field);
|
|
70
|
+
try {
|
|
71
|
+
return fs.readFileSync(filePath, "utf8");
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
throw profileError(id, dir, `cannot read ${field}: ${err instanceof Error ? err.message : String(err)}`, { field, file: filePath });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function collectFiles(id, dir, relativeDir) {
|
|
78
|
+
const absoluteDir = path.join(dir, relativeDir);
|
|
79
|
+
const stat = lstatOrAbsent(absoluteDir);
|
|
80
|
+
if (stat === null)
|
|
81
|
+
return [];
|
|
82
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
83
|
+
throw profileError(id, dir, `${relativeDir} must be a directory`, {
|
|
84
|
+
field: relativeDir,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const files = [];
|
|
88
|
+
const visit = (current, currentRelative) => {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
throw profileError(id, dir, `cannot read ${currentRelative}: ${err instanceof Error ? err.message : String(err)}`, { field: currentRelative });
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
97
|
+
const entryRelative = path.posix.join(currentRelative, entry.name);
|
|
98
|
+
const entryPath = path.join(dir, entryRelative);
|
|
99
|
+
if (entry.isSymbolicLink()) {
|
|
100
|
+
throw profileError(id, dir, `${entryRelative} must not be a symlink`, {
|
|
101
|
+
field: entryRelative,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
visit(entryPath, entryRelative);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!entry.isFile()) {
|
|
109
|
+
throw profileError(id, dir, `${entryRelative} must be a regular file`, {
|
|
110
|
+
field: entryRelative,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
files.push(entryRelative);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
visit(absoluteDir, relativeDir);
|
|
117
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
118
|
+
}
|
|
119
|
+
function parseMeta(id, dir, raw) {
|
|
120
|
+
if (!isPlainObject(raw)) {
|
|
121
|
+
throw profileError(id, dir, "meta.yaml must contain an object");
|
|
122
|
+
}
|
|
123
|
+
const unknown = Object.keys(raw).filter((key) => !PROFILE_META_KEYS.has(key));
|
|
124
|
+
if (unknown.length > 0) {
|
|
125
|
+
throw profileError(id, dir, `meta.yaml has unknown field(s): ${unknown.join(", ")}`, {
|
|
126
|
+
fields: unknown,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const provider = raw.provider;
|
|
130
|
+
if (typeof provider !== "string" || !isLaneProvider(provider.trim())) {
|
|
131
|
+
throw profileError(id, dir, "meta.yaml provider must be claude, codex, or grok", {
|
|
132
|
+
field: "provider",
|
|
133
|
+
value: provider,
|
|
134
|
+
accepted: [...LANE_PROVIDERS],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const providerValue = provider.trim();
|
|
138
|
+
const requiredString = (field) => {
|
|
139
|
+
const value = raw[field];
|
|
140
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
141
|
+
throw profileError(id, dir, `meta.yaml ${field} must be a non-empty string`, {
|
|
142
|
+
field,
|
|
143
|
+
value,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return value.trim();
|
|
147
|
+
};
|
|
148
|
+
const model = requiredString("model");
|
|
149
|
+
const account = requiredString("account");
|
|
150
|
+
const effort = requiredString("effort");
|
|
151
|
+
if (!PROVIDER_EFFORT_LEVELS[providerValue].includes(effort)) {
|
|
152
|
+
throw profileError(id, dir, `meta.yaml effort ${JSON.stringify(effort)} is invalid for ${providerValue}`, {
|
|
153
|
+
field: "effort",
|
|
154
|
+
provider: providerValue,
|
|
155
|
+
value: effort,
|
|
156
|
+
accepted: [...PROVIDER_EFFORT_LEVELS[providerValue]],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (typeof raw.fast_mode !== "boolean") {
|
|
160
|
+
throw profileError(id, dir, "meta.yaml fast_mode must be a boolean", {
|
|
161
|
+
field: "fast_mode",
|
|
162
|
+
value: raw.fast_mode,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
let version;
|
|
166
|
+
if (raw.version !== undefined) {
|
|
167
|
+
if (typeof raw.version !== "string" || raw.version.trim().length === 0) {
|
|
168
|
+
throw profileError(id, dir, "meta.yaml version must be a non-empty string", {
|
|
169
|
+
field: "version",
|
|
170
|
+
value: raw.version,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
version = raw.version.trim();
|
|
174
|
+
}
|
|
175
|
+
let maxConcurrent;
|
|
176
|
+
if (raw.max_concurrent !== undefined) {
|
|
177
|
+
if (typeof raw.max_concurrent !== "number" || !Number.isInteger(raw.max_concurrent) || raw.max_concurrent < 1) {
|
|
178
|
+
throw profileError(id, dir, "meta.yaml max_concurrent must be a positive integer", {
|
|
179
|
+
field: "max_concurrent",
|
|
180
|
+
value: raw.max_concurrent,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
maxConcurrent = raw.max_concurrent;
|
|
184
|
+
}
|
|
185
|
+
let source;
|
|
186
|
+
if (raw.source !== undefined) {
|
|
187
|
+
if (typeof raw.source !== "string" || raw.source.trim().length === 0) {
|
|
188
|
+
throw profileError(id, dir, "meta.yaml source must be a non-empty string when present", {
|
|
189
|
+
field: "source",
|
|
190
|
+
value: raw.source,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
source = raw.source.trim();
|
|
194
|
+
}
|
|
195
|
+
let accounts;
|
|
196
|
+
if (raw.accounts !== undefined) {
|
|
197
|
+
if (!Array.isArray(raw.accounts) || raw.accounts.length === 0 || raw.accounts.some((item) => typeof item !== "string" || item.trim().length === 0)) {
|
|
198
|
+
throw profileError(id, dir, "meta.yaml accounts must be a non-empty string array when present", {
|
|
199
|
+
field: "accounts",
|
|
200
|
+
value: raw.accounts,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
accounts = raw.accounts.map((item) => item.trim());
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
...(version !== undefined ? { version } : {}),
|
|
207
|
+
provider: providerValue,
|
|
208
|
+
model,
|
|
209
|
+
account,
|
|
210
|
+
effort,
|
|
211
|
+
fast_mode: raw.fast_mode,
|
|
212
|
+
...(maxConcurrent !== undefined ? { max_concurrent: maxConcurrent } : {}),
|
|
213
|
+
...(source !== undefined ? { source } : {}),
|
|
214
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function readSettings(id, dir) {
|
|
218
|
+
const filePath = path.join(dir, PROFILE_SETTINGS);
|
|
219
|
+
const stat = lstatOrAbsent(filePath);
|
|
220
|
+
if (stat === null)
|
|
221
|
+
return {};
|
|
222
|
+
requireRegularFile(id, dir, filePath, PROFILE_SETTINGS);
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
throw profileError(id, dir, `settings.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, {
|
|
229
|
+
field: PROFILE_SETTINGS,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (!isPlainObject(parsed)) {
|
|
233
|
+
throw profileError(id, dir, "settings.json must contain a JSON object", {
|
|
234
|
+
field: PROFILE_SETTINGS,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return { settings: parsed, relative: PROFILE_SETTINGS };
|
|
238
|
+
}
|
|
239
|
+
function hashProfileFiles(dir, relativeFiles) {
|
|
240
|
+
const hash = createHash("sha256");
|
|
241
|
+
for (const relative of relativeFiles.sort((a, b) => a.localeCompare(b))) {
|
|
242
|
+
const file = path.join(dir, relative);
|
|
243
|
+
hash.update(relative);
|
|
244
|
+
hash.update("\0");
|
|
245
|
+
hash.update(fs.readFileSync(file));
|
|
246
|
+
hash.update("\0");
|
|
247
|
+
}
|
|
248
|
+
return `sha256:${hash.digest("hex")}`;
|
|
249
|
+
}
|
|
250
|
+
export function personalWorkerProfileDir(hqRoot, id) {
|
|
251
|
+
return path.join(hqRoot, "personal", "workers", id);
|
|
252
|
+
}
|
|
253
|
+
/** A meta.yaml file is the explicit marker for a personal worker profile. */
|
|
254
|
+
export function workerProfileMarker(hqRoot, id) {
|
|
255
|
+
if (!validProfileId(id))
|
|
256
|
+
return "absent";
|
|
257
|
+
const dirStat = lstatOrAbsent(personalWorkerProfileDir(hqRoot, id));
|
|
258
|
+
if (dirStat === null || dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
259
|
+
return "absent";
|
|
260
|
+
}
|
|
261
|
+
const stat = lstatOrAbsent(path.join(personalWorkerProfileDir(hqRoot, id), PROFILE_META));
|
|
262
|
+
if (stat === null)
|
|
263
|
+
return "absent";
|
|
264
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
265
|
+
return "present";
|
|
266
|
+
return "present";
|
|
267
|
+
}
|
|
268
|
+
export function loadWorkerProfile(hqRoot, id) {
|
|
269
|
+
const dir = personalWorkerProfileDir(hqRoot, id);
|
|
270
|
+
if (!validProfileId(id)) {
|
|
271
|
+
throw profileError(id, dir, "profile id must be a simple directory name", {
|
|
272
|
+
field: "worker",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const dirStat = lstatOrAbsent(dir);
|
|
276
|
+
if (dirStat === null) {
|
|
277
|
+
throw profileError(id, dir, "profile directory does not exist", {
|
|
278
|
+
field: "profile",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
282
|
+
throw profileError(id, dir, "profile directory must be a regular directory", {
|
|
283
|
+
field: "profile",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const metaPath = path.join(dir, PROFILE_META);
|
|
287
|
+
const metaStat = lstatOrAbsent(metaPath);
|
|
288
|
+
if (metaStat === null) {
|
|
289
|
+
throw new WorkerResolutionError("worker_profile_invalid", `Worker profile ${JSON.stringify(id)} is missing ${metaPath}`, { worker: id, path: dir, missing: PROFILE_META });
|
|
290
|
+
}
|
|
291
|
+
const metaText = readRequiredText(id, dir, metaPath, PROFILE_META);
|
|
292
|
+
let metaRaw;
|
|
293
|
+
try {
|
|
294
|
+
metaRaw = parseUserYaml(metaText, metaPath);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
throw profileError(id, dir, `meta.yaml is not valid YAML: ${err instanceof Error ? err.message : String(err)}`, {
|
|
298
|
+
field: PROFILE_META,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const meta = parseMeta(id, dir, metaRaw);
|
|
302
|
+
const systemPromptPath = path.join(dir, PROFILE_SYSTEM_PROMPT);
|
|
303
|
+
const systemPrompt = readRequiredText(id, dir, systemPromptPath, PROFILE_SYSTEM_PROMPT);
|
|
304
|
+
if (systemPrompt.trim().length === 0) {
|
|
305
|
+
throw profileError(id, dir, "system-prompt.md must not be empty", {
|
|
306
|
+
field: PROFILE_SYSTEM_PROMPT,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const settingsResult = readSettings(id, dir);
|
|
310
|
+
const policyFiles = collectFiles(id, dir, PROFILE_POLICIES);
|
|
311
|
+
const hookFiles = collectFiles(id, dir, PROFILE_HOOKS);
|
|
312
|
+
const hashFiles = [
|
|
313
|
+
PROFILE_META,
|
|
314
|
+
PROFILE_SYSTEM_PROMPT,
|
|
315
|
+
...(settingsResult.relative ? [settingsResult.relative] : []),
|
|
316
|
+
...policyFiles,
|
|
317
|
+
...hookFiles,
|
|
318
|
+
];
|
|
319
|
+
return {
|
|
320
|
+
id,
|
|
321
|
+
dir,
|
|
322
|
+
meta,
|
|
323
|
+
systemPrompt,
|
|
324
|
+
...(settingsResult.settings !== undefined ? { settings: settingsResult.settings } : {}),
|
|
325
|
+
policyFiles,
|
|
326
|
+
hookFiles,
|
|
327
|
+
contentHash: hashProfileFiles(dir, hashFiles),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
export function workerProfilePrompt(profile) {
|
|
331
|
+
const parts = [profile.systemPrompt.trimEnd()];
|
|
332
|
+
for (const relative of profile.policyFiles) {
|
|
333
|
+
const content = readRequiredText(profile.id, profile.dir, path.join(profile.dir, relative), relative);
|
|
334
|
+
parts.push(`\n## Profile policy: ${relative}\n${content.trimEnd()}`);
|
|
335
|
+
}
|
|
336
|
+
return parts.join("\n");
|
|
337
|
+
}
|
|
338
|
+
export function ignoredWorkerProfileFields(provider) {
|
|
339
|
+
return provider === "claude" ? [] : ["settings", "hooks"];
|
|
340
|
+
}
|
|
341
|
+
function shellQuote(value) {
|
|
342
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Hooks may be flat (`hooks/check.sh`) or grouped by Claude lifecycle event
|
|
346
|
+
* (`hooks/PreToolUse/check.sh`). Flat hooks run at SessionStart.
|
|
347
|
+
*/
|
|
348
|
+
function profileHookEvent(relative) {
|
|
349
|
+
const parts = relative.split("/");
|
|
350
|
+
return parts.length >= 3 && parts[1] ? parts[1] : "SessionStart";
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Materialize provider settings and hook files in the lane run directory.
|
|
354
|
+
* The path is intentionally run-local so every fresh, resumed, moved, and
|
|
355
|
+
* repair spawn gets the profile currently on disk without mutating an account
|
|
356
|
+
* home or a shared provider configuration.
|
|
357
|
+
*/
|
|
358
|
+
export function materializeWorkerProfile(profile, runDir) {
|
|
359
|
+
const runtimeDir = path.join(runDir, "worker-profile");
|
|
360
|
+
fs.rmSync(runtimeDir, { recursive: true, force: true });
|
|
361
|
+
const hooksDir = profile.hookFiles.length > 0 ? path.join(runtimeDir, PROFILE_HOOKS) : undefined;
|
|
362
|
+
if (hooksDir !== undefined)
|
|
363
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
364
|
+
for (const relative of profile.hookFiles) {
|
|
365
|
+
const destination = path.join(runtimeDir, relative);
|
|
366
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
367
|
+
const source = path.join(profile.dir, relative);
|
|
368
|
+
fs.copyFileSync(source, destination);
|
|
369
|
+
fs.chmodSync(destination, fs.statSync(source).mode & 0o777);
|
|
370
|
+
}
|
|
371
|
+
const settingsPath = profile.settings !== undefined || hooksDir !== undefined
|
|
372
|
+
? path.join(runtimeDir, "settings.json")
|
|
373
|
+
: undefined;
|
|
374
|
+
if (settingsPath !== undefined) {
|
|
375
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
376
|
+
const settings = profile.settings === undefined
|
|
377
|
+
? {}
|
|
378
|
+
: JSON.parse(JSON.stringify(profile.settings));
|
|
379
|
+
if (hooksDir !== undefined) {
|
|
380
|
+
const registeredHooks = isPlainObject(settings.hooks)
|
|
381
|
+
? { ...settings.hooks }
|
|
382
|
+
: {};
|
|
383
|
+
for (const relative of profile.hookFiles) {
|
|
384
|
+
const event = profileHookEvent(relative);
|
|
385
|
+
const eventHooks = Array.isArray(registeredHooks[event])
|
|
386
|
+
? [...registeredHooks[event]]
|
|
387
|
+
: [];
|
|
388
|
+
eventHooks.push({
|
|
389
|
+
hooks: [{
|
|
390
|
+
type: "command",
|
|
391
|
+
command: `bash ${shellQuote(path.join(runtimeDir, relative))}`,
|
|
392
|
+
}],
|
|
393
|
+
});
|
|
394
|
+
registeredHooks[event] = eventHooks;
|
|
395
|
+
}
|
|
396
|
+
settings.hooks = registeredHooks;
|
|
397
|
+
}
|
|
398
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
...(settingsPath !== undefined ? { settingsPath } : {}),
|
|
402
|
+
...(hooksDir !== undefined ? { hooksDir } : {}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
//# sourceMappingURL=profile.js.map
|
|
@@ -11,6 +11,7 @@ import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
|
11
11
|
import { accountMaxRunning, loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
|
|
12
12
|
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
13
13
|
import { LanesError, isLaneVersionConflict } from "./errors.js";
|
|
14
|
+
import { loadWorkerProfile } from "./profile.js";
|
|
14
15
|
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, } from "./liveness.js";
|
|
15
16
|
import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, spawnEnvelopeRepair, spawnLaneWorker, } from "./spawn.js";
|
|
16
17
|
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
@@ -18,7 +19,7 @@ import { applyThrottleDecision, defaultThrottleDeps, emptyThrottleState, evaluat
|
|
|
18
19
|
import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
19
20
|
import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
|
|
20
21
|
import { SILENCE_MS, classifyLaneExit, isPidAlive, readLatestEnvelope, readLatestEnvelopeFile, readLatestEnvelopeRecord, } from "./watcher.js";
|
|
21
|
-
import { createLaneMesh, emitLaneDecisionEvents } from "./mesh-events.js";
|
|
22
|
+
import { createLaneMesh, emitLaneDecisionEvents, laneMeshCompany } from "./mesh-events.js";
|
|
22
23
|
import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
|
|
23
24
|
import { providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
|
|
24
25
|
/** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
|
|
@@ -317,13 +318,14 @@ async function emitAgedQuestionAtUser(summary, deps, lane, question) {
|
|
|
317
318
|
if (deps.dryRun)
|
|
318
319
|
return;
|
|
319
320
|
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
320
|
-
|
|
321
|
+
const company = laneMeshCompany(lane);
|
|
322
|
+
if (!mesh || !company || !lane.thread_id)
|
|
321
323
|
return;
|
|
322
324
|
const timeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
|
|
323
325
|
let timer;
|
|
324
326
|
try {
|
|
325
327
|
await Promise.race([
|
|
326
|
-
mesh.appendThreadEvent(
|
|
328
|
+
mesh.appendThreadEvent(company, lane.thread_id, "lane_question_at_user", { lane_id: lane.lane_id, question_id: question.question_id }),
|
|
327
329
|
new Promise((_resolve, reject) => {
|
|
328
330
|
timer = setTimeout(() => reject(new Error(`question-at-user mesh emit timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
329
331
|
}),
|
|
@@ -1011,6 +1013,26 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
|
1011
1013
|
continue;
|
|
1012
1014
|
}
|
|
1013
1015
|
}
|
|
1016
|
+
let profileMaxConcurrent;
|
|
1017
|
+
if (lane.worker_profile !== undefined) {
|
|
1018
|
+
try {
|
|
1019
|
+
profileMaxConcurrent = loadWorkerProfile(hqRoot, lane.worker_profile).meta.max_concurrent;
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
markDirty(summary, {
|
|
1023
|
+
kind: "lane",
|
|
1024
|
+
id: lane.lane_id,
|
|
1025
|
+
why: `profile admission failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1026
|
+
});
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (profileMaxConcurrent !== undefined) {
|
|
1031
|
+
const profileRunning = running.filter((row) => row.worker_profile === lane.worker_profile).length +
|
|
1032
|
+
spawned.filter((row) => row.worker_profile === lane.worker_profile).length;
|
|
1033
|
+
if (profileRunning >= profileMaxConcurrent)
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1014
1036
|
try {
|
|
1015
1037
|
if (deps.dryRun) {
|
|
1016
1038
|
summary.admitted.push({
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* `worker_tenancy_unresolved` when that cannot be determined.
|
|
24
24
|
*/
|
|
25
25
|
import * as fs from "node:fs";
|
|
26
|
+
import { type WorkerProfile } from "./profile.js";
|
|
26
27
|
export declare const WORKER_REGISTRY_REL: string;
|
|
27
28
|
export interface RegistryWorkerEntry {
|
|
28
29
|
id: string;
|
|
@@ -87,6 +88,8 @@ export interface ResolvedLaneWorker {
|
|
|
87
88
|
humanCheckpoints: string[];
|
|
88
89
|
/** Set when the worker's realpath lives in a package. */
|
|
89
90
|
pack?: string;
|
|
91
|
+
/** Set when this worker was resolved from personal/workers/<id>/meta.yaml. */
|
|
92
|
+
profile?: WorkerProfile;
|
|
90
93
|
}
|
|
91
94
|
export interface RegistryDeps {
|
|
92
95
|
existsSync: (p: string) => boolean;
|
|
@@ -28,6 +28,7 @@ import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
|
28
28
|
import { readContributes } from "../scan-packages/index.js";
|
|
29
29
|
import { contributionLinks, linkStatus, } from "../../utils/pack-contributions.js";
|
|
30
30
|
import { WorkerResolutionError } from "./errors.js";
|
|
31
|
+
import { loadWorkerProfile, workerProfileMarker, } from "./profile.js";
|
|
31
32
|
export const WORKER_REGISTRY_REL = path.join("core", "workers", "registry.yaml");
|
|
32
33
|
function defaultRegistryDeps() {
|
|
33
34
|
return {
|
|
@@ -247,6 +248,9 @@ function personalDirClaim(hqRoot, workerId, deps) {
|
|
|
247
248
|
const dir = personalWorkerDir(hqRoot, workerId);
|
|
248
249
|
let st;
|
|
249
250
|
try {
|
|
251
|
+
const linkStat = fs.lstatSync(dir);
|
|
252
|
+
if (linkStat.isSymbolicLink())
|
|
253
|
+
return "none";
|
|
250
254
|
st = deps.statSync(dir);
|
|
251
255
|
}
|
|
252
256
|
catch (err) {
|
|
@@ -369,7 +373,10 @@ function readRegistryOrEmpty(hqRoot, deps) {
|
|
|
369
373
|
}
|
|
370
374
|
export function resolveLaneWorker(hqRoot, workerId, laneCompany, deps = defaultRegistryDeps()) {
|
|
371
375
|
const personalRel = path.posix.join("personal", "workers", workerId);
|
|
372
|
-
const
|
|
376
|
+
const profile = workerProfileMarker(hqRoot, workerId) === "present"
|
|
377
|
+
? loadWorkerProfile(hqRoot, workerId)
|
|
378
|
+
: undefined;
|
|
379
|
+
const personalClaim = profile ? "profile" : personalDirClaim(hqRoot, workerId, deps);
|
|
373
380
|
if (personalClaim === "missing_yaml") {
|
|
374
381
|
throw new WorkerResolutionError("worker_not_found", `Worker ${JSON.stringify(workerId)} is at ${personalRel}/ but worker.yaml is missing`, { worker: workerId, path: `${personalRel}/`, missing: "worker.yaml" });
|
|
375
382
|
}
|
|
@@ -390,6 +397,43 @@ export function resolveLaneWorker(hqRoot, workerId, laneCompany, deps = defaultR
|
|
|
390
397
|
realpath: realpathOf(hqRoot, rel),
|
|
391
398
|
});
|
|
392
399
|
}
|
|
400
|
+
if (profile !== undefined && candidates.length > 0) {
|
|
401
|
+
throw new WorkerResolutionError("worker_profile_ambiguous", "Worker " +
|
|
402
|
+
JSON.stringify(workerId) +
|
|
403
|
+
" has both a personal profile and a registry worker; name one explicitly instead of falling through", {
|
|
404
|
+
worker: workerId,
|
|
405
|
+
profile_path: personalRel + "/",
|
|
406
|
+
registry_paths: candidates.map((item) => item.path),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (profile !== undefined) {
|
|
410
|
+
const description = "Reusable worker profile " + profile.id;
|
|
411
|
+
return {
|
|
412
|
+
id: profile.id,
|
|
413
|
+
path: personalRel + "/",
|
|
414
|
+
type: "WorkerProfile",
|
|
415
|
+
status: "active",
|
|
416
|
+
description,
|
|
417
|
+
tenancy: { kind: "global" },
|
|
418
|
+
workerYaml: {
|
|
419
|
+
worker: {
|
|
420
|
+
id: profile.id,
|
|
421
|
+
name: profile.id,
|
|
422
|
+
description,
|
|
423
|
+
type: "WorkerProfile",
|
|
424
|
+
status: "active",
|
|
425
|
+
},
|
|
426
|
+
instructions: "Apply reusable worker profile " + profile.id + " at every provider spawn.",
|
|
427
|
+
},
|
|
428
|
+
workerDir: profile.dir,
|
|
429
|
+
resolvedContextBase: [],
|
|
430
|
+
skillFiles: [],
|
|
431
|
+
defaultTimeout: undefined,
|
|
432
|
+
approvalRequired: false,
|
|
433
|
+
humanCheckpoints: [],
|
|
434
|
+
profile,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
393
437
|
if (personalClaim === "yaml") {
|
|
394
438
|
const rel = normalizeRel(personalRel);
|
|
395
439
|
const real = realpathOf(hqRoot, rel);
|