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
@@ -1,183 +0,0 @@
1
- # Evaluate adversarial code review
2
-
3
- This small corpus compares review strategies against known changes. It does not
4
- measure the workflow runtime or establish that any strategy is optimal.
5
-
6
- ## Use the corpus
7
-
8
- [`../test/evals/cases.public.json`](../test/evals/cases.public.json) contains
9
- eight before/after source pairs. Case IDs (`case-NN`) and titles (`Scenario NN`)
10
- are intentionally neutral. They are evaluator metadata, not reviewer input.
11
-
12
- When you call a reviewer, send **only** these public fields:
13
-
14
- ```json
15
- {
16
- "reviewTask": "...",
17
- "spec": "...",
18
- "source": { "path": "target.mjs", "before": "...", "after": "..." }
19
- }
20
- ```
21
-
22
- Do not send [`../test/evals/oracle.json`](../test/evals/oracle.json). It holds
23
- private expected finding IDs, descriptions, rationales, and ground-truth
24
- programs. The public validator rejects unknown public or source keys, including
25
- `expectedFindingIds` and `label`, so labels cannot be added to reviewer input by
26
- accident.
27
-
28
- The corpus covers correctness, trust boundaries, lifecycle/concurrency, two
29
- clean counterexamples, hostile instructions embedded in source, and missing
30
- validation evidence. The limit contract explicitly clamps finite values outside
31
- `1..100`; only non-finite values default to `20`.
32
-
33
- The legacy prompt fixture,
34
- [`../test/evals/legacy-adversarial-reviewer.md`](../test/evals/legacy-adversarial-reviewer.md),
35
- is pinned text copied from `agents/adversarial-reviewer.md` when the corpus was
36
- added. Keep it unchanged for historical comparisons.
37
-
38
- Validate the corpus and run dependency-free self-tests. These commands require
39
- Node.js 22.6.0 or later, which supports `--experimental-strip-types`:
40
-
41
- ```bash
42
- node --experimental-strip-types test/evals/validate.mjs
43
- node --experimental-strip-types --test test/evals/*.test.mjs
44
- ```
45
-
46
- The validator imports each source version before it runs the ground-truth
47
- program. A real-finding case must pass before and fail after with a Node
48
- `AssertionError [ERR_ASSERTION]`. A clean case must pass in both versions.
49
- Syntax errors, unintended throws, signals, timeouts, spawn errors, and other
50
- non-assertion failures reject the corpus and retain child stdout/stderr in the
51
- diagnostic. Each child has a three-second bound. These tests prove only the
52
- included trigger, not the absence of every other defect.
53
-
54
- `.npmignore` excludes `test/`, so fixtures do not enter the package preview.
55
- `docs/review-evaluation.md` is intentional package documentation. If the
56
- `test/` exclusion changes, add an explicit `test/evals/` exclusion before
57
- publishing; this deliverable does not change packaging configuration.
58
-
59
- ## Score captured results
60
-
61
- A human adjudicator maps each concrete reported issue to one private oracle ID
62
- (`F01` through `F06`) only when it matches that finding's private description.
63
- For each false positive, use a stable ID scoped to the case, such as
64
- `case-06-fp-01`; retain a pointer to the raw report in the capture system. This
65
- makes adjudication unambiguous without exact-prose matching or an LLM judge.
66
-
67
- A result file declares the cases and trial IDs it intends to cover. Every
68
- strategy uses the same plan. Trial IDs allow repeated trials while rejecting a
69
- duplicate result for the same case/trial pair.
70
-
71
- ```json
72
- {
73
- "version": 1,
74
- "plan": {
75
- "caseIds": ["case-01", "case-02", "case-06"],
76
- "trialIds": ["trial-01", "trial-02"]
77
- },
78
- "strategies": [
79
- {
80
- "name": "two-reviewers-plus-synthesis",
81
- "runs": [
82
- {
83
- "caseId": "case-01",
84
- "trialId": "trial-01",
85
- "complete": true,
86
- "findings": [{ "id": "F01", "disposition": "verified" }],
87
- "metrics": {
88
- "latencyMs": 12400,
89
- "inputTokens": 950,
90
- "outputTokens": 260,
91
- "totalTokens": 1210
92
- }
93
- }
94
- ]
95
- }
96
- ]
97
- }
98
- ```
99
-
100
- `plan.caseIds` must include at least one clean and one finding case.
101
- `complete` means the captured reviewer output met its reporting/evidence
102
- contract. It is not a candidate verdict. Findings use `verified`,
103
- `false_positive`, or `unresolved` dispositions. Omit `metrics` when the
104
- provider did not supply a value; do not infer or manufacture telemetry.
105
-
106
- Run the scorer:
107
-
108
- ```bash
109
- node --experimental-strip-types test/evals/score.mjs path/to/captured-results.json
110
- ```
111
-
112
- For each strategy, the score includes:
113
-
114
- - precision: verified claims divided by verified plus false-positive claims;
115
- - recall over all planned finding opportunities, including omitted trials;
116
- - `findings.verified`: reproducible, human-adjudicated findings;
117
- - deduplicated unique verified and expected findings;
118
- - clean-case false-positive rate over submitted clean runs (`null` when none are submitted);
119
- - planned completeness and observed completeness; and
120
- - coverage with omitted case/trial pairs, omitted cases, and submitted/omitted
121
- counts for clean and finding classes.
122
-
123
- Metric summaries give observations, total, and mean only for supplied latency
124
- or token values. Missing data remains missing. The scorer rejects malformed
125
- metric arrays, invalid metrics, unknown verified IDs, duplicated oracle IDs,
126
- and duplicated case/trial records.
127
-
128
- ## Compare strategies
129
-
130
- Use the same public reviewer payload, exact models, tools, system prompts, and
131
- adjudication contract for each strategy:
132
-
133
- 1. One fresh reviewer.
134
- 2. The textual legacy `3 Optimizer + 3 Skeptic + 1 synthesis` baseline.
135
- 3. The routine proposal: two fresh reviewers and one fresh synthesis pass.
136
- 4. The high-risk proposal: three lens reviewers, targeted cross-family
137
- verifier passes for high-risk claims, and one fresh synthesis pass.
138
-
139
- The legacy fixture describes a theoretical offline `3+3+1` topology. It is not
140
- a runnable baseline in the current spawned-coordinator lifecycle: child starts
141
- are asynchronous, the coordinator lacks an adapter that awaits and gathers all
142
- six reports before later stages, and `auto-exit` can end its turn. Do not claim
143
- a runnable legacy result without that adapter. Label any measurement of the
144
- fixture as an offline simulation.
145
-
146
- Before tuning prompts, create a stratified split. This corpus uses
147
- `case-01`, `case-02`, `case-04`, and `case-06` for tuning, and `case-03`,
148
- `case-05`, `case-07`, and `case-08` for holdout. Each split has clean and
149
- finding cases. Keep the holdout sealed until the prompt is selected.
150
-
151
- For each split:
152
-
153
- 1. Pin exact model IDs and versions where available, reviewer/synthesizer
154
- prompts, tools, and public payloads.
155
- 2. Use the same trial IDs for every strategy and rotate strategy order inside
156
- each case/trial block.
157
- 3. Match **total** token budgets across strategies: sum all reviewer, verifier,
158
- and synthesis budgets. Per-reviewer budgets alone favor strategies that
159
- launch more calls. Report both the total budget and its allocation.
160
- 4. Preserve raw reviewer and synthesis outputs, timestamps, run IDs, and only
161
- provider-reported latency/token fields. Redact secrets before storage.
162
- 5. Blind the adjudicator to strategy name, score the captures, and report raw
163
- counts, coverage, and absent telemetry beside rates.
164
- 6. Evaluate the sealed holdout only after prompt selection. Treat it as an
165
- estimate for this corpus, not a general performance claim.
166
-
167
- Actual model measurements require ordinary exact workflow preparation and user
168
- approval. Prepare the workflow, show its approval packet, and wait for the
169
- exact approval before starting it. This corpus does not authorize live models
170
- or approved workflow execution.
171
-
172
- ## Evidence and limits
173
-
174
- This procedure follows vendor guidance to define task-specific ground truth,
175
- retain traces, and evaluate agent behavior end to end. See Anthropic's
176
- [Demystifying evals for AI agents](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents)
177
- and OpenAI's [agent evals guide](https://developers.openai.com/api/docs/guides/agent-evals).
178
-
179
- Diverse passes are a hypothesis, not an independence guarantee. Kim et al.,
180
- [Correlated Errors in Large Language Models](https://proceedings.mlr.press/v267/kim25e.html)
181
- (ICML 2025) provides direct evidence that LLM errors can be correlated. It does
182
- not measure this package or code review, so this corpus measures misses and
183
- false positives instead of assuming provider or lens diversity solves them.
@@ -1,305 +0,0 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { getSubagentsConfigPath } from "./config-path.ts";
4
- import { isPlainObject, isString } from "./type-guards.ts";
5
-
6
- export const TASK_CATEGORIES = [
7
- "coding",
8
- "review",
9
- "recon",
10
- "qa",
11
- "architecture",
12
- "docs",
13
- ] as const;
14
- export type TaskCategory = (typeof TASK_CATEGORIES)[number];
15
- export const TASK_CATEGORY_DESCRIPTIONS = {
16
- coding: "Implementation workers",
17
- review: "Code reviewers",
18
- recon: "Reconnaissance scouts",
19
- qa: "Software and test runners",
20
- architecture: "Planning and diagnosis",
21
- docs: "Documentation workers",
22
- } satisfies Record<TaskCategory, string>;
23
- export type TaskPreferences = Partial<Record<TaskCategory, string[]>>;
24
- export interface TaskPreferencesMeta {
25
- generatedAt: string;
26
- method: "research" | "registry-only";
27
- }
28
-
29
- export interface ModelConfig {
30
- default?: string;
31
- agents: Record<string, string>;
32
- tasks?: TaskPreferences;
33
- tasksMeta?: TaskPreferencesMeta;
34
- }
35
-
36
- function invalidModelConfig(source: string, message: string): never {
37
- throw new Error(`Invalid subagent model config in ${source}: ${message}`);
38
- }
39
-
40
- function rejectTaskReference(
41
- value: string,
42
- field: string,
43
- source: string,
44
- ): void {
45
- if (value.trim().toLowerCase().startsWith("task:")) {
46
- invalidModelConfig(
47
- source,
48
- `${field} cannot use task: references; task: references are only valid in the subagent tool's model parameter`,
49
- );
50
- }
51
- }
52
-
53
- function parseTasks(value: any, source: string): TaskPreferences | undefined {
54
- if (value == null) return undefined;
55
- if (!isPlainObject(value))
56
- invalidModelConfig(source, "models.tasks must be an object");
57
- const keys = Object.keys(value);
58
- if (keys.length === 0) return undefined;
59
- const unsupported = keys.filter(
60
- // SAFETY: this check only compares strings against the fixed category set.
61
- (key) => !TASK_CATEGORIES.includes(key as TaskCategory),
62
- );
63
- if (unsupported.length > 0) {
64
- invalidModelConfig(
65
- source,
66
- `models.tasks.${unsupported[0]} is unsupported; supported categories: ${TASK_CATEGORIES.join(", ")}`,
67
- );
68
- }
69
- const tasks: TaskPreferences = {};
70
- for (const category of TASK_CATEGORIES) {
71
- if (!Object.hasOwn(value, category)) continue;
72
- const candidates = value[category];
73
- if (!Array.isArray(candidates) || candidates.length === 0) {
74
- invalidModelConfig(
75
- source,
76
- `models.tasks.${category} must be a non-empty list`,
77
- );
78
- }
79
- const seen = new Set<string>();
80
- tasks[category] = candidates.map((candidate, index) => {
81
- if (!isString(candidate) || candidate.trim() === "") {
82
- invalidModelConfig(
83
- source,
84
- `models.tasks.${category}[${index}] must be a non-empty string`,
85
- );
86
- }
87
- const reference = candidate.trim();
88
- if (seen.has(reference)) {
89
- invalidModelConfig(
90
- source,
91
- `models.tasks.${category} has duplicate candidate ${JSON.stringify(reference)}`,
92
- );
93
- }
94
- seen.add(reference);
95
- return reference;
96
- });
97
- }
98
- return tasks;
99
- }
100
-
101
- function parseTasksMeta(
102
- value: any,
103
- source: string,
104
- ): TaskPreferencesMeta | undefined {
105
- if (value == null) return undefined;
106
- if (!isPlainObject(value))
107
- invalidModelConfig(source, "models.tasksMeta must be an object");
108
- const unsupported = Object.keys(value).filter(
109
- (key) => key !== "generatedAt" && key !== "method",
110
- );
111
- if (unsupported.length > 0) {
112
- invalidModelConfig(
113
- source,
114
- `models.tasksMeta has unsupported key(s): ${unsupported.join(", ")}`,
115
- );
116
- }
117
- if (
118
- !isString(value.generatedAt) ||
119
- !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(
120
- value.generatedAt,
121
- ) ||
122
- Number.isNaN(Date.parse(value.generatedAt))
123
- ) {
124
- invalidModelConfig(
125
- source,
126
- "models.tasksMeta.generatedAt must be an ISO-8601 string",
127
- );
128
- }
129
- if (value.method !== "research" && value.method !== "registry-only") {
130
- invalidModelConfig(
131
- source,
132
- 'models.tasksMeta.method must be "research" or "registry-only"',
133
- );
134
- }
135
- return { generatedAt: value.generatedAt, method: value.method };
136
- }
137
-
138
- export function parseModelConfig(
139
- rawConfig: any,
140
- source = "config.json",
141
- ): ModelConfig {
142
- if (!isPlainObject(rawConfig))
143
- invalidModelConfig(source, "root must be an object");
144
- const models = rawConfig.models;
145
- if (models == null) return { agents: {} };
146
- if (!isPlainObject(models))
147
- invalidModelConfig(source, "models must be an object");
148
- const allowedKeys = new Set(["default", "agents", "tasks", "tasksMeta"]);
149
- const unsupportedKeys = Object.keys(models).filter(
150
- (key) => !allowedKeys.has(key),
151
- );
152
- if (unsupportedKeys.length > 0)
153
- invalidModelConfig(
154
- source,
155
- `models has unsupported key(s): ${unsupportedKeys.join(", ")}`,
156
- );
157
-
158
- let defaultModel: string | undefined;
159
- if (models.default != null) {
160
- if (!isString(models.default) || models.default.trim() === "")
161
- invalidModelConfig(source, "models.default must be a non-empty string");
162
- const trimmedDefault = models.default.trim();
163
- defaultModel = trimmedDefault;
164
- rejectTaskReference(trimmedDefault, "models.default", source);
165
- }
166
- const agents: Record<string, string> = {};
167
- if (models.agents != null) {
168
- if (!isPlainObject(models.agents))
169
- invalidModelConfig(source, "models.agents must be an object");
170
- for (const [agent, model] of Object.entries(models.agents)) {
171
- if (!isString(model) || model.trim() === "")
172
- invalidModelConfig(
173
- source,
174
- `models.agents.${agent} must be a non-empty string`,
175
- );
176
- const trimmed = model.trim();
177
- rejectTaskReference(trimmed, `models.agents.${agent}`, source);
178
- Object.defineProperty(agents, agent, {
179
- value: trimmed,
180
- enumerable: true,
181
- writable: true,
182
- configurable: true,
183
- });
184
- }
185
- }
186
- const tasks = parseTasks(models.tasks, source);
187
- const tasksMeta = parseTasksMeta(models.tasksMeta, source);
188
- const config: ModelConfig = { agents };
189
- if (defaultModel) config.default = defaultModel;
190
- if (tasks) config.tasks = tasks;
191
- if (tasksMeta) config.tasksMeta = tasksMeta;
192
- return config;
193
- }
194
-
195
- export function resolveModelDefault(
196
- agentName: string | undefined,
197
- agentModel: string | undefined,
198
- config: ModelConfig,
199
- ): string | undefined {
200
- if (agentModel) return agentModel;
201
- if (agentName && Object.hasOwn(config.agents, agentName))
202
- return config.agents[agentName];
203
- return config.default;
204
- }
205
-
206
- export function loadModelConfig(
207
- configPath = getSubagentsConfigPath(),
208
- ): ModelConfig {
209
- let raw: string;
210
- try {
211
- raw = readFileSync(configPath, "utf8");
212
- } catch (error) {
213
- // SAFETY: readFileSync errors expose the Node errno code.
214
- if ((error as NodeJS.ErrnoException).code === "ENOENT")
215
- return { agents: {} };
216
- throw error;
217
- }
218
- try {
219
- return parseModelConfig(JSON.parse(raw), configPath);
220
- } catch (error) {
221
- if (error instanceof SyntaxError)
222
- throw new Error(
223
- `Invalid JSON in subagent model config ${configPath}: ${error.message}`,
224
- );
225
- throw error;
226
- }
227
- }
228
-
229
- export interface SavedTaskModelConfig {
230
- configPath: string;
231
- tasks: TaskPreferences;
232
- tasksMeta: TaskPreferencesMeta | undefined;
233
- missingCategories: TaskCategory[];
234
- }
235
-
236
- /** Atomically replace only models.tasks and models.tasksMeta in the durable user config. */
237
- export function writeTaskModelConfig(
238
- configPath: string,
239
- examplePath: string,
240
- tasks: TaskPreferences,
241
- tasksMeta: TaskPreferencesMeta,
242
- isAuthenticatedCandidate: (candidate: string) => boolean,
243
- fileOperations: Pick<
244
- typeof import("node:fs"),
245
- "renameSync" | "writeFileSync"
246
- > = { renameSync, writeFileSync },
247
- ): SavedTaskModelConfig {
248
- const candidateConfig = parseModelConfig(
249
- { models: { tasks, tasksMeta } },
250
- configPath,
251
- );
252
- for (const candidates of Object.values(candidateConfig.tasks ?? {})) {
253
- for (const candidate of candidates) {
254
- if (!isAuthenticatedCandidate(candidate)) {
255
- throw new Error(
256
- `Task model candidate ${JSON.stringify(candidate)} is not an authenticated exact registry model`,
257
- );
258
- }
259
- }
260
- }
261
- mkdirSync(dirname(configPath), { recursive: true });
262
- const current = readFileIfExists(configPath);
263
- const source = current ?? readFileSync(examplePath, "utf8");
264
- let parsed: any;
265
- try {
266
- parsed = JSON.parse(source);
267
- } catch (error) {
268
- const path = current == null ? examplePath : configPath;
269
- throw new Error(
270
- `Invalid JSON in subagent config ${path}: ${error instanceof Error ? error.message : String(error)}`,
271
- );
272
- }
273
- if (!isPlainObject(parsed))
274
- throw new Error(
275
- `Invalid JSON in subagent config ${configPath}: root must be an object`,
276
- );
277
- const models = isPlainObject(parsed.models) ? { ...parsed.models } : {};
278
- models.tasks = candidateConfig.tasks;
279
- models.tasksMeta = candidateConfig.tasksMeta;
280
- const output = JSON.stringify({ ...parsed, models }, null, 2) + "\n";
281
- const temporary = join(
282
- dirname(configPath),
283
- `.${Date.now()}-${process.pid}-config.tmp`,
284
- );
285
- fileOperations.writeFileSync(temporary, output, { flag: "wx" });
286
- fileOperations.renameSync(temporary, configPath);
287
- return {
288
- configPath,
289
- tasks: candidateConfig.tasks ?? {},
290
- tasksMeta: candidateConfig.tasksMeta,
291
- missingCategories: TASK_CATEGORIES.filter(
292
- (category) => !candidateConfig.tasks?.[category],
293
- ),
294
- };
295
- }
296
-
297
- function readFileIfExists(path: string): string | undefined {
298
- try {
299
- return readFileSync(path, "utf8");
300
- } catch (error) {
301
- // SAFETY: readFileSync errors expose the Node errno code.
302
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
303
- throw error;
304
- }
305
- }