mister-wolf 2.20.1 → 2.21.1

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 (43) hide show
  1. package/README.md +1 -1
  2. package/README.ru.md +1 -1
  3. package/dist/adapters/platforms/opencode-adapter.d.ts +36 -3
  4. package/dist/adapters/platforms/opencode-adapter.d.ts.map +1 -1
  5. package/dist/adapters/platforms/opencode-adapter.js +380 -165
  6. package/dist/adapters/platforms/opencode-adapter.js.map +1 -1
  7. package/dist/adapters/platforms/opencode-config-keys.d.ts +43 -11
  8. package/dist/adapters/platforms/opencode-config-keys.d.ts.map +1 -1
  9. package/dist/adapters/platforms/opencode-config-keys.js +64 -19
  10. package/dist/adapters/platforms/opencode-config-keys.js.map +1 -1
  11. package/dist/app/use-cases/init-project.d.ts.map +1 -1
  12. package/dist/app/use-cases/init-project.js +3 -1
  13. package/dist/app/use-cases/init-project.js.map +1 -1
  14. package/dist/app/use-cases/uninstall.js +1 -1
  15. package/dist/app/use-cases/uninstall.js.map +1 -1
  16. package/dist/ports/platform-adapter.port.d.ts +11 -2
  17. package/dist/ports/platform-adapter.port.d.ts.map +1 -1
  18. package/package.json +1 -1
  19. package/templates/.wolf/router.log +1 -1
  20. package/templates/base/AGENTS.md +24 -0
  21. package/templates/base/agents/executor-lead.md +154 -22
  22. package/templates/base/agents/mr-wolf.md +52 -4
  23. package/templates/base/agents/steward.md +71 -32
  24. package/templates/base/agents/worker-implementer.md +44 -31
  25. package/templates/base/agents/worker-researcher.md +37 -18
  26. package/templates/base/agents/worker-reviewer.md +73 -50
  27. package/templates/base/playbooks/complaint-protocol.md +7 -2
  28. package/templates/base/playbooks/executor-lead-playbook.md +33 -21
  29. package/templates/base/playbooks/steward-nastavnik.md +28 -6
  30. package/templates/base/playbooks/worker-implementer-playbook.md +13 -10
  31. package/templates/base/playbooks/worker-researcher-playbook.md +10 -9
  32. package/templates/base/playbooks/worker-reviewer-playbook.md +17 -65
  33. package/templates/base/skills/receiving-code-review/SKILL.md +8 -0
  34. package/templates/base/skills/requesting-code-review/SKILL.md +10 -0
  35. package/templates/base/skills/using-skills/SKILL.md +138 -6
  36. package/templates/base/skills/verification-before-completion/SKILL.md +18 -1
  37. package/templates/base/skills/wolf-debug/SKILL.md +37 -3
  38. package/templates/base/skills/wolf-execute/SKILL.md +18 -8
  39. package/templates/base/skills/wolf-handoff/SKILL.md +8 -1
  40. package/templates/base/skills/wolf-plan/SKILL.md +1 -1
  41. package/templates/base/skills/wolf-review/SKILL.md +73 -53
  42. package/templates/base/skills/wolf-sdd/SKILL.md +20 -5
  43. package/update-map.json +25 -25
@@ -2,13 +2,13 @@ import * as fs from 'fs/promises';
2
2
  import { existsSync } from 'fs';
3
3
  import { execFile } from 'child_process';
4
4
  import { promisify } from 'util';
5
- import { basename, join } from 'path';
5
+ import { basename, join, relative } from 'path';
6
6
  import { parseJsonc } from './jsonc.js';
7
7
  import { writeFileAtomic } from '../fs/markdown-memory-store.js';
8
8
  import { UserFacingError } from '../../domain/errors.js';
9
9
  import { parseStamp } from '../render/stamp.js';
10
10
  import yaml from 'js-yaml';
11
- import { DEFAULT_AGENT, DEFAULT_AGENT_KEY, MCP_KEY, MCP_ROOT, MCP_SERVERS, SUBAGENT_DEPTH, SUBAGENT_DEPTH_KEY, WOLF_MCP_COMMAND, deletePath, getPath, getRecordAt, isWolfMcpEntry, legacyPathOf, blockedSegment, mcpEntryNative, pathWritable, readWolfMcp, setPath, } from './opencode-config-keys.js';
11
+ import { CONFIG_LOCATIONS, DEFAULT_AGENT, DEFAULT_AGENT_KEY, MCP_KEY, MCP_ROOT, MCP_SERVERS, SUBAGENT_DEPTH, SUBAGENT_DEPTH_KEY, WOLF_MCP_COMMAND, WRITE_LOCATION, WRITE_LOCATION_ID, deletePath, getPath, getRecordAt, isWolfMcpEntry, legacyPathOf, blockedSegment, locatePath, mcpEntryNative, mergeSurface, pathWritable, relPathOf, setPath, } from './opencode-config-keys.js';
12
12
  export { DEFAULT_AGENT, SUBAGENT_DEPTH };
13
13
  /** Каноническая команда wolf: removeWolf/configIssues не получают cmd, ownership проверяем ею. */
14
14
  const WOLF_CMD = WOLF_MCP_COMMAND;
@@ -61,253 +61,464 @@ export class OpencodeAdapter {
61
61
  return { status: 'absent', message: err instanceof Error ? err.message : String(err) };
62
62
  }
63
63
  }
64
- /** Существующий конфиг (jsonc — только если нет json); для новой установки — opencode.json. */
65
- configFile(projectRoot) {
66
- if (existsSync(join(projectRoot, 'opencode.jsonc')) && !existsSync(join(projectRoot, 'opencode.json'))) {
67
- return join(projectRoot, 'opencode.jsonc');
64
+ /**
65
+ * Файл локации: jsonc — только если json отсутствует (правило прежнее),
66
+ * иначе json. Если файла нет — возвращается дефолтный путь (цель записи).
67
+ */
68
+ locationFile(projectRoot, location) {
69
+ const dir = location.dir === '' ? projectRoot : join(projectRoot, location.dir);
70
+ const jsonc = join(dir, `${location.fileBase}.jsonc`);
71
+ const json = join(dir, `${location.fileBase}.json`);
72
+ return existsSync(jsonc) && !existsSync(json) ? jsonc : json;
73
+ }
74
+ /** Корневой конфиг — единственное место, куда wolf пишет свои ключи. */
75
+ rootFile(projectRoot) {
76
+ return this.locationFile(projectRoot, WRITE_LOCATION);
77
+ }
78
+ /**
79
+ * Поверхность конфига (6.7): обе локации + эффективный (слейдженный) вид.
80
+ * Битый/нечитаемый файл ИЗОЛИРУЕТСЯ: он попадает в `errors`, а остальные локации
81
+ * читаются как обычно (прежнее поведение «всё или ничего» чинило в обратную сторону).
82
+ */
83
+ async loadSurface(projectRoot) {
84
+ const files = [];
85
+ const errors = [];
86
+ for (const location of CONFIG_LOCATIONS) {
87
+ const file = this.locationFile(projectRoot, location);
88
+ if (!existsSync(file))
89
+ continue;
90
+ const rel = relative(projectRoot, file);
91
+ let raw;
92
+ try {
93
+ raw = await fs.readFile(file, 'utf-8');
94
+ }
95
+ catch (err) {
96
+ errors.push({
97
+ location: location.id,
98
+ file,
99
+ rel,
100
+ kind: 'not readable',
101
+ message: `${file} is not readable: ${err instanceof Error ? err.message : err}`,
102
+ });
103
+ continue;
104
+ }
105
+ let parsed;
106
+ try {
107
+ parsed = parseJsonc(raw);
108
+ }
109
+ catch (err) {
110
+ // паритет с ClaudeAdapter: ошибки парсинга — UserFacingError, не сырой SyntaxError
111
+ errors.push({
112
+ location: location.id,
113
+ file,
114
+ rel,
115
+ kind: 'not valid JSONC',
116
+ message: `${file} is not valid JSONC: ${err instanceof Error ? err.message : err}`,
117
+ });
118
+ continue;
119
+ }
120
+ const cfg = asRecord(parsed);
121
+ if (cfg === undefined) {
122
+ errors.push({
123
+ location: location.id,
124
+ file,
125
+ rel,
126
+ kind: 'not a JSON object',
127
+ message: `${file} is not a JSON object — refusing to touch it`,
128
+ });
129
+ continue;
130
+ }
131
+ files.push({ location: location.id, file, rel, cfg });
68
132
  }
69
- return join(projectRoot, 'opencode.json');
133
+ return { files, errors, effective: mergeSurface(files) };
134
+ }
135
+ /** Битая/нечитаемая корневая локация (та, куда wolf пишет) — прежний контракт: ошибка наружу. */
136
+ static rootError(surface) {
137
+ return surface.errors.find((error) => error.location === WRITE_LOCATION_ID);
70
138
  }
139
+ /**
140
+ * Эффективный конфиг обеих локаций; null — конфиг-файлов нет ни в одной локации.
141
+ * Битый корневой файл — UserFacingError (прежний контракт для корня);
142
+ * битый `.opencode` — не мешает: возвращаем вид читаемых файлов.
143
+ */
71
144
  async readConfig(projectRoot) {
72
- const file = this.configFile(projectRoot);
73
- let raw = null;
74
- try {
75
- raw = await fs.readFile(file, 'utf-8');
76
- }
77
- catch {
145
+ const surface = await this.loadSurface(projectRoot);
146
+ const rootError = OpencodeAdapter.rootError(surface);
147
+ if (rootError !== undefined)
148
+ throw new UserFacingError(rootError.message);
149
+ if (surface.files.length === 0)
78
150
  return null;
79
- }
80
- let parsed;
81
- try {
82
- parsed = parseJsonc(raw);
83
- }
84
- catch (err) {
85
- // паритет с ClaudeAdapter: ошибки парсинга — UserFacingError, не сырой SyntaxError
86
- throw new UserFacingError(`${file} is not valid JSONC: ${err instanceof Error ? err.message : err}`);
87
- }
88
- return asConfig(parsed, file);
151
+ return surface.effective;
89
152
  }
90
- async writeConfig(projectRoot, cmd) {
91
- const file = this.configFile(projectRoot);
92
- const cfg = (await this.readConfig(projectRoot)) ?? {};
153
+ /**
154
+ * Запись wolf-ключей (6.7): решения принимаются по эффективному виду обеих
155
+ * локаций, запись — только в корневой конфиг (в .opencode wolf не пишет).
156
+ *
157
+ * opts.createFile=false — режим repair-only: файл конфига не создаётся, если его нет
158
+ * (минимальный init чинит существующий конфиг и никогда не создаёт платформенный).
159
+ */
160
+ async writeConfig(projectRoot, cmd, opts) {
161
+ const surface = await this.loadSurface(projectRoot);
162
+ // битый/нечитаемый корень — прежнее поведение: файл не трогаем, ошибка наружу
163
+ const rootError = OpencodeAdapter.rootError(surface);
164
+ if (rootError !== undefined)
165
+ throw new UserFacingError(rootError.message);
166
+ const file = this.rootFile(projectRoot);
167
+ const rootExists = surface.files.some((f) => f.location === WRITE_LOCATION_ID);
168
+ if (!rootExists && opts?.createFile === false) {
169
+ // минимальный init: конфига нет — не создаём (документированное обещание)
170
+ const other = surface.files.find((f) => f.location !== WRITE_LOCATION_ID);
171
+ const reason = other !== undefined
172
+ ? `no root ${relPathOf(WRITE_LOCATION)} to repair — the project config is ${other.rel}; a minimal init never creates a platform config (use wolf init --platform ${this.id})`
173
+ : `no root ${relPathOf(WRITE_LOCATION)} to repair — a minimal init never creates a platform config (use wolf init --platform ${this.id})`;
174
+ return { action: 'skipped', reason };
175
+ }
176
+ const rootCfg = rootConfigOf(surface);
93
177
  // 6.3: пишем нативную форму mcp.servers.wolf, легаси mcp.wolf снимаем только если он наш
94
178
  const desired = mcpEntryNative(cmd);
95
- const mcpWritable = pathWritable(cfg, MCP_KEY.writePath);
96
- const nativeEntry = getPath(cfg, MCP_KEY.writePath);
97
- const mcpOk = mcpWritable && JSON.stringify(nativeEntry) === JSON.stringify(desired);
98
179
  const legacyMcpPath = legacyPathOf(MCP_KEY);
99
- const legacyMcp = getRecordAt(cfg, legacyMcpPath);
100
- const legacyMcpOurs = legacyMcp !== undefined && isWolfMcpEntry(legacyMcp, cmd);
101
- const reasons = [];
102
- const mcpBlocked = blockedSegment(cfg, MCP_KEY.writePath);
180
+ const nativeAt = locatePath(surface.files, MCP_KEY.writePath);
181
+ const legacyAt = locatePath(surface.files, legacyMcpPath);
182
+ const legacyMcpOurs = legacyAt !== undefined && isWolfMcpEntry(legacyAt.value, cmd);
183
+ const mcpWritable = pathWritable(rootCfg, MCP_KEY.writePath);
184
+ const blockedReasons = [];
185
+ const agentReasons = [];
186
+ const depthReasons = [];
187
+ const mcpBlocked = blockedSegment(rootCfg, MCP_KEY.writePath);
103
188
  if (mcpBlocked !== undefined) {
104
- reasons.push(`${mcpBlocked} is not an object; ${MCP_KEY.label} is not written — fix the conflicting key manually`);
189
+ blockedReasons.push(`${mcpBlocked} is not an object; ${MCP_KEY.label} is not written — fix the conflicting key manually`);
105
190
  }
106
- // §6.1: default_agent и experimental.subagent_depth мерджатся рядом с mcp.servers.wolf
107
- const da = getPath(cfg, DEFAULT_AGENT_KEY.writePath);
191
+ // §6.1: default_agent — по эффективному виду (корень + .opencode)
192
+ const da = locatePath(surface.files, DEFAULT_AGENT_KEY.writePath)?.value;
108
193
  if (da !== undefined && da !== DEFAULT_AGENT) {
109
- reasons.push(`${DEFAULT_AGENT_KEY.label}=${da} is taken; ${DEFAULT_AGENT} not assigned`);
194
+ agentReasons.push(`${DEFAULT_AGENT_KEY.label}=${da} is taken; ${DEFAULT_AGENT} not assigned`);
110
195
  }
111
- if (legacyMcp !== undefined) {
112
- if (legacyMcpOurs) {
113
- reasons.push(`removed deprecated top-level ${legacyMcpPath.join('.')} (migrated to ${MCP_KEY.label})`);
196
+ // 6.7: объявленное в .opencode не перекрываем вторым ключом в корне
197
+ let writeMcp = false;
198
+ let replaceMcp = false;
199
+ let mcpNativeReason;
200
+ if (nativeAt !== undefined) {
201
+ if (JSON.stringify(nativeAt.value) === JSON.stringify(desired)) {
202
+ // уже канонично — писать нечего
203
+ }
204
+ else if (nativeAt.location === WRITE_LOCATION_ID) {
205
+ if (mcpWritable) {
206
+ writeMcp = true;
207
+ replaceMcp = true;
208
+ mcpNativeReason = `replaced non-canonical ${MCP_KEY.label} entry with the canonical Wolf entry`;
209
+ }
114
210
  }
115
211
  else {
116
- reasons.push(`top-level ${legacyMcpPath.join('.')} is deprecated and foreign (command differs); ${MCP_KEY.label} is used`);
212
+ mcpNativeReason = `${MCP_KEY.label} is declared in ${relOf(surface, nativeAt.location)} — the .opencode value overrides the root one, so a root entry would have no effect; wolf leaves it untouched (wolf writes the root config)`;
213
+ }
214
+ }
215
+ else if (legacyAt === undefined || !legacyMcpOurs || legacyAt.location === WRITE_LOCATION_ID) {
216
+ writeMcp = mcpWritable;
217
+ }
218
+ // легаси-форма: снимаем только свою и только в корне (писать можно лишь туда)
219
+ let dropLegacyMcp = false;
220
+ let mcpLegacyReason;
221
+ if (legacyAt !== undefined) {
222
+ if (!legacyMcpOurs) {
223
+ mcpLegacyReason = `top-level ${legacyMcpPath.join('.')} is deprecated and foreign (command differs); ${MCP_KEY.label} is used`;
224
+ }
225
+ else if (legacyAt.location === WRITE_LOCATION_ID) {
226
+ dropLegacyMcp = true;
227
+ mcpLegacyReason = `removed deprecated top-level ${legacyMcpPath.join('.')} (migrated to ${MCP_KEY.label})`;
228
+ }
229
+ else if (nativeAt === undefined) {
230
+ mcpLegacyReason = `wolf entry is in the deprecated top-level ${legacyMcpPath.join('.')} form in ${relOf(surface, legacyAt.location)} — the .opencode value overrides the root one, so a root entry would have no effect; migrate it to ${MCP_KEY.label} manually`;
231
+ }
232
+ else {
233
+ mcpLegacyReason = `deprecated top-level ${legacyMcpPath.join('.')} in ${relOf(surface, legacyAt.location)} is ignored by opencode v2 — the .opencode value overrides the root one; remove it manually`;
117
234
  }
118
235
  }
119
- const migrateLegacyMcp = legacyMcpOurs;
120
- const replaced = nativeEntry !== undefined && !mcpOk;
121
- if (replaced)
122
- reasons.push(`replaced non-canonical ${MCP_KEY.label} entry with the canonical Wolf entry`);
123
236
  // opencode v2: действует только experimental.subagent_depth. Верхнеуровневый ключ —
124
237
  // deprecated: наше точное значение мигрируем, чужое не трогаем и сообщаем.
125
238
  const depthPath = SUBAGENT_DEPTH_KEY.writePath;
126
239
  const depthLeaf = leaf(SUBAGENT_DEPTH_KEY);
127
240
  const depthLabel = dotted(SUBAGENT_DEPTH_KEY);
128
241
  const legacyDepthPath = legacyPathOf(SUBAGENT_DEPTH_KEY);
129
- const experimental = getRecordAt(cfg, depthPath.slice(0, -1));
130
- const expSd = experimental?.[depthLeaf];
131
- const legacySd = getPath(cfg, legacyDepthPath);
132
- const depthOk = depthIsOurs(expSd);
133
- const legacyOurs = legacySd === SUBAGENT_DEPTH;
242
+ const depthAt = locatePath(surface.files, depthPath);
243
+ const legacyDepthAt = locatePath(surface.files, legacyDepthPath);
244
+ const depth = depthAt?.value;
245
+ const legacyDepth = legacyDepthAt?.value;
246
+ const depthOk = depthIsOurs(depth);
247
+ const legacyOurs = legacyDepth === SUBAGENT_DEPTH;
134
248
  let setDepth = false;
135
249
  let dropLegacy = false;
136
- const depthWritable = pathWritable(cfg, depthPath);
250
+ const depthWritable = pathWritable(rootCfg, depthPath);
137
251
  if (!depthWritable) {
138
- reasons.push(`experimental is not an object; ${depthLeaf} is not written — set ${depthLabel}: ${SUBAGENT_DEPTH} manually`);
252
+ depthReasons.push(`experimental is not an object; ${depthLeaf} is not written — set ${depthLabel}: ${SUBAGENT_DEPTH} manually`);
139
253
  }
140
254
  else if (depthOk) {
141
255
  if (legacyOurs) {
142
- dropLegacy = true; // оба ключа наши: легаси — только шум, убираем
143
- reasons.push(`removed deprecated top-level ${depthLeaf}=${SUBAGENT_DEPTH} (ignored by opencode v2)`);
256
+ if (legacyDepthAt.location === WRITE_LOCATION_ID) {
257
+ dropLegacy = true; // оба ключа наши: легаси — только шум, убираем
258
+ depthReasons.push(`removed deprecated top-level ${depthLeaf}=${SUBAGENT_DEPTH} (ignored by opencode v2)`);
259
+ }
260
+ else {
261
+ depthReasons.push(`top-level ${depthLeaf}=${SUBAGENT_DEPTH} in ${relOf(surface, legacyDepthAt.location)} is deprecated and ignored by opencode v2 — the .opencode value overrides the root one; remove it manually`);
262
+ }
144
263
  }
145
- else if (legacySd !== undefined) {
146
- reasons.push(`top-level ${depthLeaf}=${legacySd} is deprecated and ignored by opencode v2; ${depthLabel} is used`);
264
+ else if (legacyDepth !== undefined) {
265
+ depthReasons.push(`top-level ${depthLeaf}=${legacyDepth} is deprecated and ignored by opencode v2; ${depthLabel} is used`);
147
266
  }
148
267
  }
149
- else if (expSd !== undefined) {
150
- reasons.push(`${depthLabel}=${expSd} is taken; the three-level scheme will not work, set >=2`);
268
+ else if (depth !== undefined) {
269
+ depthReasons.push(`${depthLabel}=${depth} is taken; the three-level scheme will not work, set >=2`);
151
270
  }
152
271
  else if (legacyOurs) {
153
272
  setDepth = true;
154
- dropLegacy = true;
155
- reasons.push(`migrated ${depthLeaf} to ${depthLabel}`);
273
+ if (legacyDepthAt.location === WRITE_LOCATION_ID) {
274
+ dropLegacy = true;
275
+ depthReasons.push(`migrated ${depthLeaf} to ${depthLabel}`);
276
+ }
277
+ else {
278
+ // легаси в .opencode удалить не можем (wolf пишет только в корень) — но и второй
279
+ // ключ не нужен: каноническую форму wolf записывает в корневой конфиг сам.
280
+ depthReasons.push(`top-level ${depthLeaf}=${SUBAGENT_DEPTH} in ${relOf(surface, legacyDepthAt.location)} is deprecated and ignored by opencode v2 — wolf wrote ${depthLabel}=${SUBAGENT_DEPTH} in the root config; remove the deprecated key manually`);
281
+ }
156
282
  }
157
- else if (legacySd !== undefined) {
158
- reasons.push(`top-level ${depthLeaf}=${legacySd} is taken; the key is deprecated and ignored by opencode v2 — set ${depthLabel} >=${SUBAGENT_DEPTH}`);
283
+ else if (legacyDepth !== undefined) {
284
+ depthReasons.push(`top-level ${depthLeaf}=${legacyDepth} is taken; the key is deprecated and ignored by opencode v2 — set ${depthLabel} >=${SUBAGENT_DEPTH}`);
159
285
  }
160
286
  else {
161
287
  setDepth = true;
162
288
  }
289
+ const reasons = [
290
+ ...blockedReasons,
291
+ ...agentReasons,
292
+ ...(mcpLegacyReason !== undefined ? [mcpLegacyReason] : []),
293
+ ...(mcpNativeReason !== undefined ? [mcpNativeReason] : []),
294
+ ...depthReasons,
295
+ // битая/нечитаемая локация не мешает починке корня — но называем её
296
+ ...surface.errors.map((error) => `skipped: ${error.rel} is ${error.kind}`),
297
+ ];
163
298
  const reason = reasons.length > 0 ? reasons.join('; ') : undefined;
164
- // F6 (спека 2.1.0 §2.4): фактические wolf-ключи после ensured — только наши
299
+ // F6 (спека 2.1.0 §2.4): фактические wolf-ключи после ensured — по эффективному виду
165
300
  // (mcp.servers.wolf каноничен; default_agent/subagent_depth — если ключ наш, не занят чужим значением)
166
301
  const ours = () => {
167
- const keys = mcpWritable ? [MCP_KEY.label] : [];
168
- if (getPath(cfg, DEFAULT_AGENT_KEY.writePath) === DEFAULT_AGENT)
302
+ const effective = effectiveAfter(surface, rootCfg);
303
+ const keys = [];
304
+ if (isWolfMcpEntry(getPath(effective, MCP_KEY.writePath), cmd))
305
+ keys.push(MCP_KEY.label);
306
+ if (getPath(effective, DEFAULT_AGENT_KEY.writePath) === DEFAULT_AGENT)
169
307
  keys.push(`${DEFAULT_AGENT_KEY.label}=${DEFAULT_AGENT}`);
170
- const sd = getRecordAt(cfg, depthPath.slice(0, -1))?.[depthLeaf];
308
+ const sd = getPath(effective, depthPath);
171
309
  if (depthIsOurs(sd))
172
310
  keys.push(`${depthLeaf}=${sd}`);
173
311
  return keys;
174
312
  };
313
+ // F-10: отчёт init называет файл, где реально лежат wolf-ключи (при отсутствии
314
+ // записи — файл, где объявлен эффективный wolf-ключ; иначе — файл записи).
315
+ const wolfKeyFile = () => {
316
+ const mcp = locatePath(surface.files, MCP_KEY.writePath);
317
+ if (mcp !== undefined)
318
+ return relOf(surface, mcp.location);
319
+ const agent = locatePath(surface.files, DEFAULT_AGENT_KEY.writePath);
320
+ if (agent !== undefined)
321
+ return relOf(surface, agent.location);
322
+ const depth = locatePath(surface.files, SUBAGENT_DEPTH_KEY.writePath);
323
+ if (depth !== undefined)
324
+ return relOf(surface, depth.location);
325
+ return undefined;
326
+ };
175
327
  // unchanged — по ВСЕМ ключам: корректный mcp.servers.wolf не маскирует отсутствие default_agent/subagent_depth;
176
328
  // занятое чужим значением место depth тоже settled (не перезаписываем — только reason);
177
329
  // наш легаси mcp.wolf — не settled: его надо снять записью.
178
- if ((mcpOk || !mcpWritable) && da !== undefined && !setDepth && !dropLegacy && !migrateLegacyMcp)
179
- return { action: 'unchanged', reason, configFile: basename(file), keys: ours() };
180
- if (mcpWritable)
181
- setPath(cfg, MCP_KEY.writePath, desired);
182
- if (migrateLegacyMcp)
183
- deletePath(cfg, legacyMcpPath);
184
- if (da === undefined)
185
- setPath(cfg, DEFAULT_AGENT_KEY.writePath, DEFAULT_AGENT); // конфликтный ключ не трогаем
330
+ const setAgent = da === undefined;
331
+ if (!writeMcp && !dropLegacyMcp && !setAgent && !setDepth && !dropLegacy)
332
+ return { action: 'unchanged', reason, configFile: wolfKeyFile() ?? basename(file), keys: ours() };
333
+ if (writeMcp)
334
+ setPath(rootCfg, MCP_KEY.writePath, desired);
335
+ if (dropLegacyMcp)
336
+ deletePath(rootCfg, legacyMcpPath);
337
+ if (setAgent)
338
+ setPath(rootCfg, DEFAULT_AGENT_KEY.writePath, DEFAULT_AGENT); // конфликтный ключ не трогаем
186
339
  if (setDepth)
187
- setPath(cfg, depthPath, SUBAGENT_DEPTH);
340
+ setPath(rootCfg, depthPath, SUBAGENT_DEPTH);
188
341
  if (dropLegacy)
189
- deletePath(cfg, legacyDepthPath);
190
- await writeFileAtomic(file, JSON.stringify(cfg, null, 2) + '\n');
191
- return { action: replaced ? 'replaced' : 'written', reason, configFile: basename(file), keys: ours() };
342
+ deletePath(rootCfg, legacyDepthPath);
343
+ await writeFileAtomic(file, JSON.stringify(rootCfg, null, 2) + '\n');
344
+ return { action: replaceMcp ? 'replaced' : 'written', reason, configFile: basename(file), keys: ours() };
192
345
  }
346
+ /**
347
+ * 6.7: wolf-запись MCP (нативная и легаси, только если она наша) удаляется
348
+ * из ВСЕХ локаций, где она реально объявлена.
349
+ */
193
350
  async removeWolf(projectRoot) {
194
- const file = this.configFile(projectRoot);
195
- const cfg = await this.readConfig(projectRoot);
196
- if (cfg === null)
351
+ const surface = await this.loadSurface(projectRoot);
352
+ if (surface.files.length === 0)
197
353
  return false;
198
354
  const legacyMcpPath = legacyPathOf(MCP_KEY);
199
- const nativeEntry = getPath(cfg, MCP_KEY.writePath);
200
- const legacyEntry = getPath(cfg, legacyMcpPath);
201
355
  let removed = false;
202
- if (nativeEntry !== undefined && isWolfMcpEntry(nativeEntry, WOLF_CMD)) {
203
- deletePath(cfg, MCP_KEY.writePath);
204
- removed = true;
205
- }
206
- if (legacyEntry !== undefined && isWolfMcpEntry(legacyEntry, WOLF_CMD)) {
207
- deletePath(cfg, legacyMcpPath);
356
+ for (const file of surface.files) {
357
+ let changed = false;
358
+ const nativeEntry = getPath(file.cfg, MCP_KEY.writePath);
359
+ const legacyEntry = getPath(file.cfg, legacyMcpPath);
360
+ if (nativeEntry !== undefined && isWolfMcpEntry(nativeEntry, WOLF_CMD)) {
361
+ deletePath(file.cfg, MCP_KEY.writePath);
362
+ changed = true;
363
+ }
364
+ if (legacyEntry !== undefined && isWolfMcpEntry(legacyEntry, WOLF_CMD)) {
365
+ deletePath(file.cfg, legacyMcpPath);
366
+ changed = true;
367
+ }
368
+ if (!changed)
369
+ continue;
208
370
  removed = true;
371
+ await writeFileAtomic(file.file, JSON.stringify(file.cfg, null, 2) + '\n');
209
372
  }
210
- if (!removed)
211
- return false;
212
- await writeFileAtomic(file, JSON.stringify(cfg, null, 2) + '\n');
213
- return true;
373
+ return removed;
214
374
  }
215
375
  /**
216
376
  * 2.16.2 (uninstall, F-07): только wolf-ключи с точным нашим значением.
217
377
  * Пользовательские default_agent/subagent_depth (не наши точные значения) не трогаем.
218
378
  * Глубина: `experimental.subagent_depth === 2` (действующий путь) + легаси верхнеуровневый.
219
379
  * 6.3: удаляем обе формы MCP (нативную и легаси) — только если запись наша.
380
+ * 6.7: снимаем ключи из ВСЕХ локаций, где они объявлены; битый файл — в причину.
220
381
  */
221
382
  async removeWolfKeys(projectRoot) {
222
- const file = this.configFile(projectRoot);
223
- const cfg = await this.readConfig(projectRoot).catch(() => null);
224
- if (cfg === null)
225
- return { action: 'skipped', reason: `no ${basename(file)}`, configFile: basename(file) };
226
- const removed = [];
227
- const legacyMcpPath = legacyPathOf(MCP_KEY);
228
- const nativeEntry = getPath(cfg, MCP_KEY.writePath);
229
- const legacyEntry = getPath(cfg, legacyMcpPath);
230
- if (nativeEntry !== undefined && isWolfMcpEntry(nativeEntry, WOLF_CMD)) {
231
- deletePath(cfg, MCP_KEY.writePath);
232
- removed.push(MCP_KEY.label);
233
- }
234
- if (legacyEntry !== undefined && isWolfMcpEntry(legacyEntry, WOLF_CMD)) {
235
- deletePath(cfg, legacyMcpPath);
236
- if (!removed.includes(MCP_KEY.label))
237
- removed.push(MCP_KEY.label);
238
- }
239
- if (getPath(cfg, DEFAULT_AGENT_KEY.writePath) === DEFAULT_AGENT) {
240
- deletePath(cfg, DEFAULT_AGENT_KEY.writePath);
241
- removed.push(`${DEFAULT_AGENT_KEY.label}=${DEFAULT_AGENT}`);
383
+ const file = this.rootFile(projectRoot);
384
+ const surface = await this.loadSurface(projectRoot);
385
+ const broken = surface.errors.map((error) => `skipped: ${error.rel} is ${error.kind}`);
386
+ if (surface.files.length === 0) {
387
+ // «no opencode.json» — только когда конфиг-файлов нет НИ В ОДНОЙ локации
388
+ return {
389
+ action: 'skipped',
390
+ reason: broken.length > 0 ? broken.join('; ') : `no ${basename(file)}`,
391
+ configFile: basename(file),
392
+ };
242
393
  }
394
+ let mcpRemoved = false;
395
+ let agentRemoved = false;
396
+ let depthRemoved = false;
397
+ const changed = [];
398
+ const legacyMcpPath = legacyPathOf(MCP_KEY);
243
399
  const depthLeaf = leaf(SUBAGENT_DEPTH_KEY);
244
400
  const legacyDepthPath = legacyPathOf(SUBAGENT_DEPTH_KEY);
245
- let depthRemoved = false;
246
- if (getPath(cfg, SUBAGENT_DEPTH_KEY.writePath) === SUBAGENT_DEPTH) {
247
- deletePath(cfg, SUBAGENT_DEPTH_KEY.writePath);
248
- depthRemoved = true;
249
- }
250
- if (getPath(cfg, legacyDepthPath) === SUBAGENT_DEPTH) {
251
- // легаси верхнеуровневый ключ (deprecated в opencode v2) — тоже наш
252
- deletePath(cfg, legacyDepthPath);
253
- depthRemoved = true;
401
+ // корень первым — так же, как в configFile списке результата
402
+ for (const file of [...surface.files].reverse()) {
403
+ let touched = false;
404
+ const nativeEntry = getPath(file.cfg, MCP_KEY.writePath);
405
+ const legacyEntry = getPath(file.cfg, legacyMcpPath);
406
+ if (nativeEntry !== undefined && isWolfMcpEntry(nativeEntry, WOLF_CMD)) {
407
+ deletePath(file.cfg, MCP_KEY.writePath);
408
+ mcpRemoved = true;
409
+ touched = true;
410
+ }
411
+ if (legacyEntry !== undefined && isWolfMcpEntry(legacyEntry, WOLF_CMD)) {
412
+ deletePath(file.cfg, legacyMcpPath);
413
+ mcpRemoved = true;
414
+ touched = true;
415
+ }
416
+ if (getPath(file.cfg, DEFAULT_AGENT_KEY.writePath) === DEFAULT_AGENT) {
417
+ deletePath(file.cfg, DEFAULT_AGENT_KEY.writePath);
418
+ agentRemoved = true;
419
+ touched = true;
420
+ }
421
+ if (getPath(file.cfg, SUBAGENT_DEPTH_KEY.writePath) === SUBAGENT_DEPTH) {
422
+ deletePath(file.cfg, SUBAGENT_DEPTH_KEY.writePath);
423
+ depthRemoved = true;
424
+ touched = true;
425
+ }
426
+ if (getPath(file.cfg, legacyDepthPath) === SUBAGENT_DEPTH) {
427
+ // легаси верхнеуровневый ключ (deprecated в opencode v2) — тоже наш
428
+ deletePath(file.cfg, legacyDepthPath);
429
+ depthRemoved = true;
430
+ touched = true;
431
+ }
432
+ if (!touched)
433
+ continue; // перезаписываем только файлы, из которых сняты наши ключи
434
+ changed.push(file.rel);
435
+ await writeFileAtomic(file.file, JSON.stringify(file.cfg, null, 2) + '\n');
254
436
  }
255
- if (depthRemoved)
256
- removed.push(`${depthLeaf}=${SUBAGENT_DEPTH}`);
437
+ const removed = [
438
+ ...(mcpRemoved ? [MCP_KEY.label] : []),
439
+ ...(agentRemoved ? [`${DEFAULT_AGENT_KEY.label}=${DEFAULT_AGENT}`] : []),
440
+ ...(depthRemoved ? [`${depthLeaf}=${SUBAGENT_DEPTH}`] : []),
441
+ ];
257
442
  if (removed.length === 0) {
258
443
  return {
259
444
  action: 'unchanged',
260
- reason: 'no wolf keys with our exact values',
445
+ reason: ['no wolf keys with our exact values', ...broken].join('; '),
261
446
  configFile: basename(file),
262
447
  keys: [],
263
448
  };
264
449
  }
265
- await writeFileAtomic(file, JSON.stringify(cfg, null, 2) + '\n');
266
- return { action: 'removed', configFile: basename(file), keys: removed };
450
+ return {
451
+ action: 'removed',
452
+ ...(broken.length > 0 ? { reason: broken.join('; ') } : {}),
453
+ configFile: changed.join(', '),
454
+ keys: removed,
455
+ };
267
456
  }
268
457
  /**
269
458
  * 6.1/6.3/6.4: doctor-проверки, которые знает только opencode-адаптер —
270
459
  * все ключи, которые Wolf пишет, + смешанный frontmatter managed-агентов.
271
460
  * Строки идут в общий `issues` проекта и маскируются doctor'ом как обычно.
461
+ * 6.7: проверки — по эффективному виду обеих локаций; битый файл — свой issue с именем.
272
462
  */
273
463
  async configIssues(projectRoot) {
274
- let cfg;
275
- try {
276
- cfg = await this.readConfig(projectRoot);
277
- }
278
- catch (err) {
279
- // «missing» советовал бы init, который честно скипает такой конфиг; различаем
280
- // битый JSONC и валидный JSON не-объект (массив/строка) — советы разные по смыслу.
281
- const message = err instanceof Error ? err.message : String(err);
282
- const kind = message.includes('not valid JSONC') ? 'not valid JSONC' : 'not a JSON object';
283
- return [`${this.id}: config is ${kind} — fix it or remove the file, then run wolf init`];
464
+ const surface = await this.loadSurface(projectRoot);
465
+ const issues = surface.errors.map((error) => `${this.id}: ${error.rel} is ${error.kind} — fix it or remove the file, then run wolf init`);
466
+ if (surface.files.length === 0) {
467
+ // «missing» советовал бы init, который честно скипает битый конфиг
468
+ if (issues.length === 0)
469
+ return [`${this.id}: wolf entry missing — run wolf init`];
470
+ return issues;
284
471
  }
285
- if (cfg === null)
286
- return [`${this.id}: wolf entry missing — run wolf init`];
287
472
  return [
288
- ...mcpIssues(cfg),
289
- ...defaultAgentIssues(cfg),
290
- ...depthIssues(cfg),
473
+ ...issues,
474
+ ...mcpIssues(surface),
475
+ ...defaultAgentIssues(surface.effective),
476
+ ...depthIssues(surface),
291
477
  ...(await mixedFrontmatterIssues(projectRoot, this.id)),
292
478
  ];
293
479
  }
294
480
  }
295
- /** 6.3: wolf-сервер отсутствует в обеих формах / легаси-форма подлежит миграции. */
296
- function mcpIssues(cfg) {
481
+ /** Собственный конфиг корневой локации (не слейдженный): в него wolf пишет. */
482
+ function rootConfigOf(surface) {
483
+ return surface.files.find((f) => f.location === WRITE_LOCATION_ID)?.cfg ?? {};
484
+ }
485
+ /** Путь файла локации относительно корня проекта (для сообщений). */
486
+ function relOf(surface, location) {
487
+ return surface.files.find((f) => f.location === location)?.rel ?? '';
488
+ }
489
+ /** Эффективный вид после записи: обновлённый корень + .opencode (порядок — в mergeSurface). */
490
+ function effectiveAfter(surface, rootCfg) {
491
+ return mergeSurface(surface.files, { location: WRITE_LOCATION_ID, cfg: rootCfg });
492
+ }
493
+ /** 6.3/6.7: wolf-сервер отсутствует в обеих формах / легаси-форма подлежит миграции. */
494
+ function mcpIssues(surface) {
495
+ const cfg = surface.effective;
297
496
  // запись невозможна: init не вылечит такой конфиг — не советуем его
298
497
  const blocked = blockedSegment(cfg, MCP_KEY.writePath);
299
498
  if (blocked !== undefined)
300
499
  return [`opencode: ${blocked} is not an object — ${MCP_KEY.label} is not written; fix it manually`];
301
- const wolf = readWolfMcp(cfg, WOLF_CMD);
302
- if (wolf.form === 'none')
500
+ const legacyPath = legacyPathOf(MCP_KEY);
501
+ const nativeAt = locatePath(surface.files, MCP_KEY.writePath);
502
+ const legacyAt = locatePath(surface.files, legacyPath);
503
+ // сервер не объявлен НИ В ОДНОЙ локации — только тогда «wolf entry missing»
504
+ if (nativeAt === undefined && legacyAt === undefined)
303
505
  return [`opencode: wolf entry missing — run wolf init`];
304
- if (wolf.legacyEntry === undefined)
506
+ if (legacyAt === undefined)
305
507
  return [];
306
- const legacyPath = legacyPathOf(MCP_KEY);
508
+ const ours = isWolfMcpEntry(legacyAt.value, WOLF_CMD);
509
+ // легаси в .opencode: init туда не пишет — советуем ручную миграцию с именем файла
510
+ if (legacyAt.location === WRITE_LOCATION_ID) {
511
+ return [
512
+ ours
513
+ ? `opencode: wolf entry is in the deprecated top-level ${legacyPath.join('.')} form — run wolf init to migrate it to ${MCP_KEY.label}`
514
+ : `opencode: top-level ${legacyPath.join('.')} is foreign and deprecated (opencode v2 reads ${MCP_ROOT}.${MCP_SERVERS}) — wolf leaves it untouched`,
515
+ ];
516
+ }
517
+ const rel = relOf(surface, legacyAt.location);
307
518
  return [
308
- wolf.legacyOurs
309
- ? `opencode: wolf entry is in the deprecated top-level ${legacyPath.join('.')} form — run wolf init to migrate it to ${MCP_KEY.label}`
310
- : `opencode: top-level ${legacyPath.join('.')} is foreign and deprecated (opencode v2 reads ${MCP_ROOT}.${MCP_SERVERS}) — wolf leaves it untouched`,
519
+ ours
520
+ ? `opencode: wolf entry is in the deprecated top-level ${legacyPath.join('.')} form in ${rel} — migrate it to ${MCP_KEY.label} manually`
521
+ : `opencode: top-level ${legacyPath.join('.')} in ${rel} is foreign and deprecated (opencode v2 reads ${MCP_ROOT}.${MCP_SERVERS}) — wolf leaves it untouched`,
311
522
  ];
312
523
  }
313
524
  /** 6.1: default_agent === mr-wolf, иначе L0 стартует не тем агентом. */
@@ -323,8 +534,9 @@ function defaultAgentIssues(cfg) {
323
534
  ];
324
535
  return [];
325
536
  }
326
- /** 6.1: experimental.subagent_depth >= 2 + deprecated верхнеуровневый ключ. */
327
- function depthIssues(cfg) {
537
+ /** 6.1/6.7: experimental.subagent_depth >= 2 + deprecated верхнеуровневый ключ (по эффективному виду). */
538
+ function depthIssues(surface) {
539
+ const cfg = surface.effective;
328
540
  const out = [];
329
541
  const depthPath = SUBAGENT_DEPTH_KEY.writePath;
330
542
  const parentPath = depthPath.slice(0, -1);
@@ -343,11 +555,21 @@ function depthIssues(cfg) {
343
555
  out.push(`opencode: ${depthLabel}=${String(depth)} — the three-level scheme needs >=${SUBAGENT_DEPTH}; foreign value is never overwritten`);
344
556
  }
345
557
  const legacyPath = legacyPathOf(SUBAGENT_DEPTH_KEY);
346
- const legacy = getPath(cfg, legacyPath);
347
- if (legacy !== undefined) {
348
- out.push(legacy === SUBAGENT_DEPTH
349
- ? `opencode: top-level ${legacyPath.join('.')}=${SUBAGENT_DEPTH} is deprecated and ignored by opencode v2 — run wolf init to migrate it to ${depthLabel}`
350
- : `opencode: top-level ${legacyPath.join('.')}=${String(legacy)} is deprecated and ignored by opencode v2 — foreign value, set ${depthLabel} >=${SUBAGENT_DEPTH} manually`);
558
+ const legacyAt = locatePath(surface.files, legacyPath);
559
+ if (legacyAt !== undefined) {
560
+ const legacy = legacyAt.value;
561
+ // легаси в .opencode: init туда не пишет — миграция ручная, с именем файла
562
+ if (legacyAt.location === WRITE_LOCATION_ID) {
563
+ out.push(legacy === SUBAGENT_DEPTH
564
+ ? `opencode: top-level ${legacyPath.join('.')}=${SUBAGENT_DEPTH} is deprecated and ignored by opencode v2 — run wolf init to migrate it to ${depthLabel}`
565
+ : `opencode: top-level ${legacyPath.join('.')}=${String(legacy)} is deprecated and ignored by opencode v2 — foreign value, set ${depthLabel} >=${SUBAGENT_DEPTH} manually`);
566
+ }
567
+ else {
568
+ const rel = relOf(surface, legacyAt.location);
569
+ out.push(legacy === SUBAGENT_DEPTH
570
+ ? `opencode: top-level ${legacyPath.join('.')}=${SUBAGENT_DEPTH} in ${rel} is deprecated and ignored by opencode v2 — migrate it to ${depthLabel} manually`
571
+ : `opencode: top-level ${legacyPath.join('.')}=${String(legacy)} in ${rel} is deprecated and ignored by opencode v2 — foreign value, set ${depthLabel} >=${SUBAGENT_DEPTH} manually`);
572
+ }
351
573
  }
352
574
  return out;
353
575
  }
@@ -395,13 +617,6 @@ function frontmatterOf(raw) {
395
617
  return undefined;
396
618
  }
397
619
  }
398
- function asConfig(value, file) {
399
- const cfg = asRecord(value);
400
- if (cfg === undefined) {
401
- throw new UserFacingError(`${file} is not a JSON object — refusing to touch it`);
402
- }
403
- return cfg;
404
- }
405
620
  function asRecord(value) {
406
621
  return value !== null && typeof value === 'object' && !Array.isArray(value)
407
622
  ? value