pi-herdr-agents 2.0.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +20 -14
  2. package/CHANGELOG.md +63 -1
  3. package/CONTEXT.md +86 -32
  4. package/README.md +262 -141
  5. package/RELEASING.md +4 -2
  6. package/config.json.example +0 -3
  7. package/docs/README.md +38 -15
  8. package/docs/adr/0001-btw-ephemeral-side-questions.md +1 -1
  9. package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +1 -1
  10. package/docs/adr/0003-installable-role-packs.md +21 -13
  11. package/docs/adr/0009-remove-workflow-subsystem.md +1 -1
  12. package/docs/adr/0011-explicit-worktree-cleanup.md +1 -1
  13. package/docs/adr/0012-adopt-maestro-seams-in-repo.md +45 -0
  14. package/docs/adr/0013-pack-neutral-execution-host.md +63 -0
  15. package/docs/adr/0014-operator-cancel-terminal-intent.md +134 -0
  16. package/docs/worktree-subagents.md +10 -6
  17. package/maestro/adapters/fake/fake-harness-adapter.ts +263 -0
  18. package/{pi-extension/subagents/activity.ts → maestro/adapters/pi/activity-file.ts} +15 -72
  19. package/{pi-extension/subagents → maestro/adapters/pi/child}/subagent-done.ts +30 -3
  20. package/{pi-extension/subagents → maestro/adapters/pi}/completion.ts +4 -4
  21. package/{pi-extension/subagents → maestro/adapters/pi}/launch.ts +388 -285
  22. package/maestro/adapters/pi/model-registry.ts +46 -0
  23. package/maestro/adapters/pi/pi-harness-adapter.ts +701 -0
  24. package/maestro/adapters/pi/process-identity.ts +303 -0
  25. package/{pi-extension/subagents → maestro/adapters/pi}/session.ts +1 -13
  26. package/maestro/adapters/pi/task-model-init.ts +55 -0
  27. package/maestro/core/activity.ts +29 -0
  28. package/maestro/core/config/model-config.ts +473 -0
  29. package/{pi-extension/subagents → maestro/core/config}/pane-config.ts +4 -6
  30. package/{pi-extension/subagents → maestro/core/config}/persistent-config.ts +4 -6
  31. package/{pi-extension/subagents → maestro/core/config}/role-config.ts +15 -10
  32. package/{pi-extension/subagents → maestro/core/config}/supervision-config.ts +4 -6
  33. package/{pi-extension/subagents → maestro/core/config}/task-model-init.ts +45 -58
  34. package/maestro/core/config/task-model-types.ts +22 -0
  35. package/maestro/core/harness-adapter.ts +117 -0
  36. package/maestro/core/index.ts +3 -0
  37. package/{pi-extension/subagents → maestro/core}/lifecycle.ts +27 -126
  38. package/maestro/core/roles/discovery.ts +555 -0
  39. package/{pi-extension/subagents/runtime-routing.ts → maestro/core/routing.ts} +36 -43
  40. package/maestro/core/shell.ts +3 -0
  41. package/{pi-extension/subagents → maestro/core}/status.ts +3 -7
  42. package/{pi-extension/subagents → maestro/core}/supervision.ts +1 -1
  43. package/maestro/core/surface-provider.ts +185 -0
  44. package/maestro/core/types.ts +449 -0
  45. package/maestro/core/worktree-cleanup.ts +374 -0
  46. package/maestro/core/worktree.ts +93 -0
  47. package/maestro/runtime/index.ts +44 -0
  48. package/maestro/runtime/pi-run-session.ts +1210 -0
  49. package/maestro/runtime/run-session.ts +801 -0
  50. package/maestro/runtime/surface-retention.ts +6 -0
  51. package/maestro/runtime/task-model-init.ts +22 -0
  52. package/maestro/runtime/worktree-operations.ts +627 -0
  53. package/maestro/surfaces/fake/fake-surface-provider.ts +311 -0
  54. package/maestro/surfaces/herdr/herdr-surface-provider.ts +200 -0
  55. package/{pi-extension/subagents → maestro/surfaces/herdr}/herdr.ts +65 -14
  56. package/{pi-extension/subagents → maestro/surfaces/herdr}/terminal.ts +7 -5
  57. package/package.json +10 -9
  58. package/pi-extension/subagents/config-path.ts +5 -1
  59. package/pi-extension/subagents/index.ts +848 -1805
  60. package/pi-extension/subagents/model-registry.ts +46 -0
  61. package/skills/pi-herdr-agents/SKILL.md +276 -0
  62. package/agents/adversarial-reviewer.md +0 -153
  63. package/agents/planner.md +0 -87
  64. package/agents/poteto.md +0 -32
  65. package/agents/reviewer.md +0 -132
  66. package/agents/scout.md +0 -105
  67. package/agents/visual-tester.md +0 -234
  68. package/agents/worker.md +0 -121
  69. package/docs/review-evaluation.md +0 -183
  70. package/pi-extension/subagents/model-config.ts +0 -305
  71. package/pi-extension/subagents/plan-skill.md +0 -280
  72. package/pi-extension/subagents/worktree-cleanup.ts +0 -789
  73. package/skills/orchestrate/SKILL.md +0 -91
  74. package/skills/orchestrate/adversarial-review-example.js +0 -400
  75. package/skills/orchestrate/adversarial-review.md +0 -87
  76. /package/{pi-extension/subagents → maestro/core/config}/type-guards.ts +0 -0
  77. /package/{pi-extension/subagents → maestro/core}/wake.ts +0 -0
@@ -0,0 +1,473 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ writeSync,
12
+ } from "node:fs";
13
+ import { hostname } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { isPlainObject, isString } from "./type-guards.ts";
16
+
17
+ import {
18
+ TASK_CATEGORIES,
19
+ type TaskCategory,
20
+ type TaskPreferences,
21
+ type TaskPreferencesMeta,
22
+ } from "./task-model-types.ts";
23
+
24
+ export interface ModelConfig {
25
+ default?: string;
26
+ agents: Record<string, string>;
27
+ tasks?: TaskPreferences;
28
+ tasksMeta?: TaskPreferencesMeta;
29
+ }
30
+
31
+ function invalidModelConfig(source: string, message: string): never {
32
+ throw new Error(`Invalid subagent model config in ${source}: ${message}`);
33
+ }
34
+
35
+ function rejectTaskReference(
36
+ value: string,
37
+ field: string,
38
+ source: string,
39
+ ): void {
40
+ if (value.trim().toLowerCase().startsWith("task:")) {
41
+ invalidModelConfig(
42
+ source,
43
+ `${field} cannot use task: references; task: references are only valid in the subagent tool's model parameter`,
44
+ );
45
+ }
46
+ }
47
+
48
+ function parseTasks(value: any, source: string): TaskPreferences | undefined {
49
+ if (value == null) return undefined;
50
+ if (!isPlainObject(value))
51
+ invalidModelConfig(source, "models.tasks must be an object");
52
+ const keys = Object.keys(value);
53
+ if (keys.length === 0) return undefined;
54
+ const unsupported = keys.filter(
55
+ // SAFETY: this check only compares strings against the fixed category set.
56
+ (key) => !TASK_CATEGORIES.includes(key as TaskCategory),
57
+ );
58
+ if (unsupported.length > 0) {
59
+ invalidModelConfig(
60
+ source,
61
+ `models.tasks.${unsupported[0]} is unsupported; supported categories: ${TASK_CATEGORIES.join(", ")}`,
62
+ );
63
+ }
64
+ const tasks: TaskPreferences = {};
65
+ for (const category of TASK_CATEGORIES) {
66
+ if (!Object.hasOwn(value, category)) continue;
67
+ const candidates = value[category];
68
+ if (!Array.isArray(candidates) || candidates.length === 0) {
69
+ invalidModelConfig(
70
+ source,
71
+ `models.tasks.${category} must be a non-empty list`,
72
+ );
73
+ }
74
+ const seen = new Set<string>();
75
+ tasks[category] = candidates.map((candidate, index) => {
76
+ if (!isString(candidate) || candidate.trim() === "") {
77
+ invalidModelConfig(
78
+ source,
79
+ `models.tasks.${category}[${index}] must be a non-empty string`,
80
+ );
81
+ }
82
+ const reference = candidate.trim();
83
+ if (seen.has(reference)) {
84
+ invalidModelConfig(
85
+ source,
86
+ `models.tasks.${category} has duplicate candidate ${JSON.stringify(reference)}`,
87
+ );
88
+ }
89
+ seen.add(reference);
90
+ return reference;
91
+ });
92
+ }
93
+ return tasks;
94
+ }
95
+
96
+ function parseTasksMeta(
97
+ value: any,
98
+ source: string,
99
+ ): TaskPreferencesMeta | undefined {
100
+ if (value == null) return undefined;
101
+ if (!isPlainObject(value))
102
+ invalidModelConfig(source, "models.tasksMeta must be an object");
103
+ const unsupported = Object.keys(value).filter(
104
+ (key) => key !== "generatedAt" && key !== "method",
105
+ );
106
+ if (unsupported.length > 0) {
107
+ invalidModelConfig(
108
+ source,
109
+ `models.tasksMeta has unsupported key(s): ${unsupported.join(", ")}`,
110
+ );
111
+ }
112
+ if (
113
+ !isString(value.generatedAt) ||
114
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(
115
+ value.generatedAt,
116
+ ) ||
117
+ Number.isNaN(Date.parse(value.generatedAt))
118
+ ) {
119
+ invalidModelConfig(
120
+ source,
121
+ "models.tasksMeta.generatedAt must be an ISO-8601 string",
122
+ );
123
+ }
124
+ if (value.method !== "research" && value.method !== "registry-only") {
125
+ invalidModelConfig(
126
+ source,
127
+ 'models.tasksMeta.method must be "research" or "registry-only"',
128
+ );
129
+ }
130
+ return { generatedAt: value.generatedAt, method: value.method };
131
+ }
132
+
133
+ export function parseModelConfig(
134
+ rawConfig: any,
135
+ source = "config.json",
136
+ ): ModelConfig {
137
+ if (!isPlainObject(rawConfig))
138
+ invalidModelConfig(source, "root must be an object");
139
+ const models = rawConfig.models;
140
+ if (models == null) return { agents: {} };
141
+ if (!isPlainObject(models))
142
+ invalidModelConfig(source, "models must be an object");
143
+ const allowedKeys = new Set(["default", "agents", "tasks", "tasksMeta"]);
144
+ const unsupportedKeys = Object.keys(models).filter(
145
+ (key) => !allowedKeys.has(key),
146
+ );
147
+ if (unsupportedKeys.length > 0)
148
+ invalidModelConfig(
149
+ source,
150
+ `models has unsupported key(s): ${unsupportedKeys.join(", ")}`,
151
+ );
152
+
153
+ let defaultModel: string | undefined;
154
+ if (models.default != null) {
155
+ if (!isString(models.default) || models.default.trim() === "")
156
+ invalidModelConfig(source, "models.default must be a non-empty string");
157
+ const trimmedDefault = models.default.trim();
158
+ defaultModel = trimmedDefault;
159
+ rejectTaskReference(trimmedDefault, "models.default", source);
160
+ }
161
+ const agents: Record<string, string> = {};
162
+ if (models.agents != null) {
163
+ if (!isPlainObject(models.agents))
164
+ invalidModelConfig(source, "models.agents must be an object");
165
+ for (const [agent, model] of Object.entries(models.agents)) {
166
+ if (!isString(model) || model.trim() === "")
167
+ invalidModelConfig(
168
+ source,
169
+ `models.agents.${agent} must be a non-empty string`,
170
+ );
171
+ const trimmed = model.trim();
172
+ rejectTaskReference(trimmed, `models.agents.${agent}`, source);
173
+ Object.defineProperty(agents, agent, {
174
+ value: trimmed,
175
+ enumerable: true,
176
+ writable: true,
177
+ configurable: true,
178
+ });
179
+ }
180
+ }
181
+ const tasks = parseTasks(models.tasks, source);
182
+ const tasksMeta = parseTasksMeta(models.tasksMeta, source);
183
+ const config: ModelConfig = { agents };
184
+ if (defaultModel) config.default = defaultModel;
185
+ if (tasks) config.tasks = tasks;
186
+ if (tasksMeta) config.tasksMeta = tasksMeta;
187
+ return config;
188
+ }
189
+
190
+ export function resolveModelDefault(
191
+ agentName: string | undefined,
192
+ agentModel: string | undefined,
193
+ config: ModelConfig,
194
+ ): string | undefined {
195
+ if (agentModel) return agentModel;
196
+ if (agentName && Object.hasOwn(config.agents, agentName))
197
+ return config.agents[agentName];
198
+ return config.default;
199
+ }
200
+
201
+ export function loadModelConfig(configDir: string): ModelConfig {
202
+ const configPath = join(configDir, "config.json");
203
+ let raw: string;
204
+ try {
205
+ raw = readFileSync(configPath, "utf8");
206
+ } catch (error) {
207
+ // SAFETY: readFileSync errors expose the Node errno code.
208
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
209
+ return { agents: {} };
210
+ throw error;
211
+ }
212
+ try {
213
+ return parseModelConfig(JSON.parse(raw), configPath);
214
+ } catch (error) {
215
+ if (error instanceof SyntaxError)
216
+ throw new Error(
217
+ `Invalid JSON in subagent model config ${configPath}: ${error.message}`,
218
+ );
219
+ throw error;
220
+ }
221
+ }
222
+
223
+ export interface SavedTaskModelConfig {
224
+ configPath: string;
225
+ tasks: TaskPreferences;
226
+ tasksMeta: TaskPreferencesMeta | undefined;
227
+ missingCategories: TaskCategory[];
228
+ /** Revision of the exact bytes this call published. */
229
+ configRevision: string;
230
+ }
231
+
232
+ /** Revision of an absent config file. */
233
+ export const MISSING_CONFIG_REVISION = "missing";
234
+ const CONFIG_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/;
235
+
236
+ /** `sha256:<lowercase hex>` over the exact file bytes, never normalized JSON. */
237
+ export function computeConfigRevision(bytes: Uint8Array): string {
238
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
239
+ }
240
+
241
+ export function isConfigRevision(value: any): value is string {
242
+ return (
243
+ value === MISSING_CONFIG_REVISION ||
244
+ (isString(value) && CONFIG_REVISION_PATTERN.test(value))
245
+ );
246
+ }
247
+
248
+ /** Read the exact-byte revision of a config file, or `missing` when absent. */
249
+ export function readConfigRevision(configPath: string): string {
250
+ const bytes = readBytesIfExists(configPath);
251
+ return bytes == null ? MISSING_CONFIG_REVISION : computeConfigRevision(bytes);
252
+ }
253
+
254
+ export type TaskModelConfigWriteErrorCode =
255
+ | "invalid-revision"
256
+ | "stale-revision"
257
+ | "busy";
258
+
259
+ export class TaskModelConfigWriteError extends Error {
260
+ readonly code: TaskModelConfigWriteErrorCode;
261
+ constructor(code: TaskModelConfigWriteErrorCode, message: string) {
262
+ super(message);
263
+ this.name = "TaskModelConfigWriteError";
264
+ this.code = code;
265
+ }
266
+ }
267
+
268
+ export interface WriteTaskModelConfigOptions {
269
+ /**
270
+ * Exact-byte revision the caller proposed against. Omitted keeps the
271
+ * unconditional write; `missing` requires the file to be absent.
272
+ */
273
+ expectedConfigRevision?: string;
274
+ fileOperations?: Pick<
275
+ typeof import("node:fs"),
276
+ "renameSync" | "writeFileSync"
277
+ >;
278
+ }
279
+
280
+ /**
281
+ * Atomically replace only models.tasks and models.tasksMeta in the durable user config.
282
+ *
283
+ * Every call holds an exclusive sibling lock file while it reads one snapshot,
284
+ * checks the optional revision precondition, and renames a private temporary file
285
+ * into place. The lock is advisory: it serializes cooperating writers only and
286
+ * cannot constrain editors or processes that ignore it. Contention and stale locks
287
+ * fail closed without waiting or breaking another owner's lock.
288
+ */
289
+ export function writeTaskModelConfig(
290
+ configPath: string,
291
+ examplePath: string,
292
+ tasks: TaskPreferences,
293
+ tasksMeta: TaskPreferencesMeta,
294
+ isAuthenticatedCandidate: (candidate: string) => boolean,
295
+ options: WriteTaskModelConfigOptions = {},
296
+ ): SavedTaskModelConfig {
297
+ const expected = options.expectedConfigRevision;
298
+ if (expected !== undefined && !isConfigRevision(expected)) {
299
+ throw new TaskModelConfigWriteError(
300
+ "invalid-revision",
301
+ `Invalid expectedConfigRevision: use "sha256:" followed by 64 lowercase hex digits of the exact config bytes, or "${MISSING_CONFIG_REVISION}" when the config file is absent. Omit it only for an unconditional write.`,
302
+ );
303
+ }
304
+ const fileOperations = options.fileOperations ?? {
305
+ renameSync,
306
+ writeFileSync,
307
+ };
308
+ const candidateConfig = parseModelConfig(
309
+ { models: { tasks, tasksMeta } },
310
+ configPath,
311
+ );
312
+ for (const candidates of Object.values(candidateConfig.tasks ?? {})) {
313
+ for (const candidate of candidates) {
314
+ if (!isAuthenticatedCandidate(candidate)) {
315
+ throw new Error(
316
+ `Task model candidate ${JSON.stringify(candidate)} is not an authenticated exact registry model`,
317
+ );
318
+ }
319
+ }
320
+ }
321
+ mkdirSync(dirname(configPath), { recursive: true });
322
+ return withConfigWriteLock(configPath, () => {
323
+ const currentBytes = readBytesIfExists(configPath);
324
+ if (expected !== undefined) {
325
+ const actual =
326
+ currentBytes == null
327
+ ? MISSING_CONFIG_REVISION
328
+ : computeConfigRevision(currentBytes);
329
+ if (actual !== expected) {
330
+ let state = "the config file bytes changed since the proposal was read";
331
+ if (expected === MISSING_CONFIG_REVISION)
332
+ state = "the proposal expected no config file, but one now exists";
333
+ else if (currentBytes == null)
334
+ state =
335
+ "the proposal expected an existing config file, but it is now missing";
336
+ throw new TaskModelConfigWriteError(
337
+ "stale-revision",
338
+ `Stale task model config revision for ${configPath}: ${state}. Configuration was not replaced. Re-read the config, prepare a fresh proposal, and obtain fresh approval before writing.`,
339
+ );
340
+ }
341
+ }
342
+ const current = currentBytes?.toString("utf8");
343
+ const source = current ?? readFileSync(examplePath, "utf8");
344
+ let parsed: any;
345
+ try {
346
+ parsed = JSON.parse(source);
347
+ } catch (error) {
348
+ const path = current == null ? examplePath : configPath;
349
+ throw new Error(
350
+ `Invalid JSON in subagent config ${path}: ${error instanceof Error ? error.message : String(error)}`,
351
+ );
352
+ }
353
+ if (!isPlainObject(parsed))
354
+ throw new Error(
355
+ `Invalid JSON in subagent config ${configPath}: root must be an object`,
356
+ );
357
+ const models = isPlainObject(parsed.models) ? { ...parsed.models } : {};
358
+ models.tasks = candidateConfig.tasks;
359
+ models.tasksMeta = candidateConfig.tasksMeta;
360
+ const output = JSON.stringify({ ...parsed, models }, null, 2) + "\n";
361
+ const temporary = join(
362
+ dirname(configPath),
363
+ `.${Date.now()}-${process.pid}-${randomUUID()}-config.tmp`,
364
+ );
365
+ let published = false;
366
+ try {
367
+ fileOperations.writeFileSync(temporary, output, { flag: "wx" });
368
+ fileOperations.renameSync(temporary, configPath);
369
+ published = true;
370
+ } finally {
371
+ if (!published) rmSync(temporary, { force: true });
372
+ }
373
+ return {
374
+ configPath,
375
+ tasks: candidateConfig.tasks ?? {},
376
+ tasksMeta: candidateConfig.tasksMeta,
377
+ missingCategories: TASK_CATEGORIES.filter(
378
+ (category) => !candidateConfig.tasks?.[category],
379
+ ),
380
+ configRevision: computeConfigRevision(Buffer.from(output, "utf8")),
381
+ };
382
+ });
383
+ }
384
+
385
+ /** Sibling advisory lock path shared by every cooperating config writer. */
386
+ export function getConfigWriteLockPath(configPath: string): string {
387
+ return `${configPath}.lock`;
388
+ }
389
+
390
+ function withConfigWriteLock<T>(configPath: string, write: () => T): T {
391
+ const lockPath = getConfigWriteLockPath(configPath);
392
+ const token = randomUUID();
393
+ let descriptor: number;
394
+ try {
395
+ descriptor = openSync(lockPath, "wx");
396
+ } catch (error) {
397
+ // SAFETY: openSync errors expose the Node errno code.
398
+ if ((error as NodeJS.ErrnoException).code === "EEXIST")
399
+ throw new TaskModelConfigWriteError("busy", describeHeldLock(lockPath));
400
+ throw error;
401
+ }
402
+ try {
403
+ try {
404
+ writeSync(
405
+ descriptor,
406
+ JSON.stringify({
407
+ pid: process.pid,
408
+ hostname: hostname(),
409
+ token,
410
+ createdAt: new Date().toISOString(),
411
+ }),
412
+ );
413
+ } finally {
414
+ closeSync(descriptor);
415
+ }
416
+ } catch (error) {
417
+ rmSync(lockPath, { force: true });
418
+ throw error;
419
+ }
420
+ try {
421
+ return write();
422
+ } finally {
423
+ releaseConfigWriteLock(lockPath, token);
424
+ }
425
+ }
426
+
427
+ function releaseConfigWriteLock(lockPath: string, token: string): void {
428
+ let owner: any;
429
+ try {
430
+ owner = JSON.parse(readFileSync(lockPath, "utf8"));
431
+ } catch {
432
+ return;
433
+ }
434
+ // Never remove a lock this invocation does not own.
435
+ if (isPlainObject(owner) && owner.token === token) unlinkSync(lockPath);
436
+ }
437
+
438
+ function describeHeldLock(lockPath: string): string {
439
+ let owner = "an unidentified owner";
440
+ let stale = "";
441
+ try {
442
+ const parsed = JSON.parse(readFileSync(lockPath, "utf8"));
443
+ if (isPlainObject(parsed) && Number.isInteger(parsed.pid)) {
444
+ owner = `pid ${parsed.pid}${isString(parsed.hostname) ? ` on ${parsed.hostname}` : ""}${isString(parsed.createdAt) ? ` since ${parsed.createdAt}` : ""}`;
445
+ if (parsed.hostname === hostname() && !isProcessAlive(parsed.pid))
446
+ stale =
447
+ " That process is no longer running, so the lock appears stale.";
448
+ }
449
+ } catch {
450
+ // Unreadable lock contents still mean another writer may be active.
451
+ }
452
+ return `Task model config writer busy: lock ${lockPath} is held by ${owner}. Configuration was not replaced.${stale} Retry after the other writer finishes; remove the lock manually only after confirming no writer is active. Locks are never broken automatically.`;
453
+ }
454
+
455
+ function isProcessAlive(pid: number): boolean {
456
+ try {
457
+ process.kill(pid, 0);
458
+ return true;
459
+ } catch (error) {
460
+ // SAFETY: process.kill errors expose the Node errno code.
461
+ return (error as NodeJS.ErrnoException).code === "EPERM";
462
+ }
463
+ }
464
+
465
+ function readBytesIfExists(path: string): Buffer | undefined {
466
+ try {
467
+ return readFileSync(path);
468
+ } catch (error) {
469
+ // SAFETY: readFileSync errors expose the Node errno code.
470
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
471
+ throw error;
472
+ }
473
+ }
@@ -1,8 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import {
3
- getSubagentsConfigExamplePath,
4
- getSubagentsConfigPath,
5
- } from "./config-path.ts";
2
+ import { join } from "node:path";
6
3
  import { isPlainObject, isString } from "./type-guards.ts";
7
4
 
8
5
  export type PaneMode = "grouped" | "tab" | "split";
@@ -133,9 +130,10 @@ function readPaneConfigFile(
133
130
  }
134
131
 
135
132
  export function loadPaneConfig(
136
- configPath = getSubagentsConfigPath(),
137
- examplePath = getSubagentsConfigExamplePath(),
133
+ configDir: string,
134
+ examplePath: string,
138
135
  ): PaneConfig {
136
+ const configPath = join(configDir, "config.json");
139
137
  const { sourcePath, rawConfig } = readPaneConfigFile(configPath, examplePath);
140
138
  let parsed;
141
139
  try {
@@ -1,8 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import {
3
- getSubagentsConfigExamplePath,
4
- getSubagentsConfigPath,
5
- } from "./config-path.ts";
2
+ import { join } from "node:path";
6
3
  import { isFiniteNumber, isRecord } from "./type-guards.ts";
7
4
 
8
5
  export const DEFAULT_MAX_PERSISTENT_AGENTS = 3;
@@ -91,9 +88,10 @@ function readPersistentConfigFile(
91
88
  }
92
89
 
93
90
  export function loadPersistentConfig(
94
- configPath = getSubagentsConfigPath(),
95
- examplePath = getSubagentsConfigExamplePath(),
91
+ configDir: string,
92
+ examplePath: string,
96
93
  ): PersistentConfig {
94
+ const configPath = join(configDir, "config.json");
97
95
  const { sourcePath, rawConfig } = readPersistentConfigFile(
98
96
  configPath,
99
97
  examplePath,
@@ -1,12 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
- import {
3
- getSubagentsConfigExamplePath,
4
- getSubagentsConfigPath,
5
- } from "./config-path.ts";
2
+ import { join } from "node:path";
6
3
  import { isBoolean, isPlainObject } from "./type-guards.ts";
7
4
 
8
5
  export interface RoleConfig {
9
- bundled: boolean;
6
+ /** Accepted no-op settings, each with actionable migration text. */
7
+ deprecations: string[];
10
8
  }
11
9
 
12
10
  function invalidRoleConfig(source: string, message: string): never {
@@ -20,7 +18,7 @@ export function parseRoleConfig(
20
18
  if (!isPlainObject(rawConfig)) {
21
19
  invalidRoleConfig(source, "root must be an object");
22
20
  }
23
- if (!Object.hasOwn(rawConfig, "roles")) return { bundled: true };
21
+ if (!Object.hasOwn(rawConfig, "roles")) return { deprecations: [] };
24
22
  if (!isPlainObject(rawConfig.roles)) {
25
23
  invalidRoleConfig(source, "roles must be an object");
26
24
  }
@@ -34,11 +32,17 @@ export function parseRoleConfig(
34
32
  `roles has unsupported key(s): ${unsupportedKeys.join(", ")}`,
35
33
  );
36
34
  }
37
- if (!Object.hasOwn(rawConfig.roles, "bundled")) return { bundled: true };
35
+ if (!Object.hasOwn(rawConfig.roles, "bundled")) return { deprecations: [] };
38
36
  if (!isBoolean(rawConfig.roles.bundled)) {
39
37
  invalidRoleConfig(source, "roles.bundled must be a boolean");
40
38
  }
41
- return { bundled: rawConfig.roles.bundled };
39
+ // The host ships no roles, so both legacy booleans are no-ops. Never rewrite
40
+ // user configuration to remove the key.
41
+ return {
42
+ deprecations: [
43
+ `Deprecated setting roles.bundled (${rawConfig.roles.bundled}) in ${source} is ignored: pi-herdr-agents no longer ships bundled roles. Install a role pack or add global or project definitions for the roles you use, then remove roles.bundled. The file was not changed.`,
44
+ ],
45
+ };
42
46
  }
43
47
 
44
48
  interface RoleConfigSource {
@@ -79,9 +83,10 @@ function readRoleConfigFile(
79
83
  }
80
84
 
81
85
  export function loadRoleConfig(
82
- configPath = getSubagentsConfigPath(),
83
- examplePath = getSubagentsConfigExamplePath(),
86
+ configDir: string,
87
+ examplePath: string,
84
88
  ): RoleConfig {
89
+ const configPath = join(configDir, "config.json");
85
90
  const { sourcePath, rawConfig } = readRoleConfigFile(configPath, examplePath);
86
91
  let parsed;
87
92
  try {
@@ -1,8 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import {
3
- getSubagentsConfigExamplePath,
4
- getSubagentsConfigPath,
5
- } from "./config-path.ts";
2
+ import { join } from "node:path";
6
3
  import { isBoolean, isFiniteNumber, isRecord } from "./type-guards.ts";
7
4
 
8
5
  export interface SupervisionConfig {
@@ -62,9 +59,10 @@ export function parseSupervisionConfig(
62
59
  }
63
60
 
64
61
  export function loadSupervisionConfig(
65
- configPath = getSubagentsConfigPath(),
66
- examplePath = getSubagentsConfigExamplePath(),
62
+ configDir: string,
63
+ examplePath: string,
67
64
  ): SupervisionConfig {
65
+ const configPath = join(configDir, "config.json");
68
66
  let sourcePath = configPath;
69
67
  let rawConfig: string;
70
68
  try {