mister-wolf 2.18.5 → 2.20.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 (102) hide show
  1. package/README.md +12 -1
  2. package/README.ru.md +33 -14
  3. package/dist/adapters/cli/cli-entry.d.ts.map +1 -1
  4. package/dist/adapters/cli/cli-entry.js +10 -1
  5. package/dist/adapters/cli/cli-entry.js.map +1 -1
  6. package/dist/adapters/cli/commands/memory-doctor.d.ts.map +1 -1
  7. package/dist/adapters/cli/commands/memory-doctor.js.map +1 -1
  8. package/dist/adapters/cli/commands/memory-sync.d.ts.map +1 -1
  9. package/dist/adapters/cli/commands/memory-sync.js +3 -0
  10. package/dist/adapters/cli/commands/memory-sync.js.map +1 -1
  11. package/dist/adapters/cli/commands/memory-uninstall.d.ts.map +1 -1
  12. package/dist/adapters/cli/commands/memory-uninstall.js.map +1 -1
  13. package/dist/adapters/cli/commands/memory-upgrade.d.ts +274 -0
  14. package/dist/adapters/cli/commands/memory-upgrade.d.ts.map +1 -1
  15. package/dist/adapters/cli/commands/memory-upgrade.js +1354 -6
  16. package/dist/adapters/cli/commands/memory-upgrade.js.map +1 -1
  17. package/dist/adapters/cli/commands/upgrade-projects.d.ts +19 -0
  18. package/dist/adapters/cli/commands/upgrade-projects.d.ts.map +1 -0
  19. package/dist/adapters/cli/commands/upgrade-projects.js +85 -0
  20. package/dist/adapters/cli/commands/upgrade-projects.js.map +1 -0
  21. package/dist/adapters/cli/report-renderer.d.ts +43 -0
  22. package/dist/adapters/cli/report-renderer.d.ts.map +1 -0
  23. package/dist/adapters/cli/report-renderer.js +184 -0
  24. package/dist/adapters/cli/report-renderer.js.map +1 -0
  25. package/dist/adapters/fs/projects-registry.d.ts +21 -0
  26. package/dist/adapters/fs/projects-registry.d.ts.map +1 -1
  27. package/dist/adapters/fs/projects-registry.js +54 -1
  28. package/dist/adapters/fs/projects-registry.js.map +1 -1
  29. package/dist/adapters/fs/update-map.d.ts +102 -0
  30. package/dist/adapters/fs/update-map.d.ts.map +1 -0
  31. package/dist/adapters/fs/update-map.js +95 -0
  32. package/dist/adapters/fs/update-map.js.map +1 -0
  33. package/dist/adapters/fs/upgrade-lock.d.ts +23 -0
  34. package/dist/adapters/fs/upgrade-lock.d.ts.map +1 -0
  35. package/dist/adapters/fs/upgrade-lock.js +59 -0
  36. package/dist/adapters/fs/upgrade-lock.js.map +1 -0
  37. package/dist/adapters/render/opencode/opencode-renderer.d.ts +45 -5
  38. package/dist/adapters/render/opencode/opencode-renderer.d.ts.map +1 -1
  39. package/dist/adapters/render/opencode/opencode-renderer.js +434 -86
  40. package/dist/adapters/render/opencode/opencode-renderer.js.map +1 -1
  41. package/dist/adapters/render/stamp.d.ts +2 -0
  42. package/dist/adapters/render/stamp.d.ts.map +1 -1
  43. package/dist/adapters/render/stamp.js +9 -1
  44. package/dist/adapters/render/stamp.js.map +1 -1
  45. package/dist/adapters/render/sync-state.d.ts +15 -3
  46. package/dist/adapters/render/sync-state.d.ts.map +1 -1
  47. package/dist/adapters/render/sync-state.js +52 -8
  48. package/dist/adapters/render/sync-state.js.map +1 -1
  49. package/dist/app/use-cases/decisions.d.ts +247 -0
  50. package/dist/app/use-cases/decisions.d.ts.map +1 -0
  51. package/dist/app/use-cases/decisions.js +528 -0
  52. package/dist/app/use-cases/decisions.js.map +1 -0
  53. package/dist/app/use-cases/init-project.d.ts.map +1 -1
  54. package/dist/app/use-cases/init-project.js +34 -1
  55. package/dist/app/use-cases/init-project.js.map +1 -1
  56. package/dist/app/use-cases/platform-mismatch.d.ts +2 -1
  57. package/dist/app/use-cases/platform-mismatch.d.ts.map +1 -1
  58. package/dist/app/use-cases/platform-mismatch.js.map +1 -1
  59. package/dist/app/use-cases/project-step.d.ts +71 -0
  60. package/dist/app/use-cases/project-step.d.ts.map +1 -0
  61. package/dist/app/use-cases/project-step.js +163 -0
  62. package/dist/app/use-cases/project-step.js.map +1 -0
  63. package/dist/app/use-cases/repair-platform.d.ts +2 -1
  64. package/dist/app/use-cases/repair-platform.d.ts.map +1 -1
  65. package/dist/app/use-cases/repair-platform.js.map +1 -1
  66. package/dist/app/use-cases/scan-unregistered.d.ts +25 -0
  67. package/dist/app/use-cases/scan-unregistered.d.ts.map +1 -0
  68. package/dist/app/use-cases/scan-unregistered.js +74 -0
  69. package/dist/app/use-cases/scan-unregistered.js.map +1 -0
  70. package/dist/app/use-cases/seed-base-playbooks.d.ts +10 -0
  71. package/dist/app/use-cases/seed-base-playbooks.d.ts.map +1 -1
  72. package/dist/app/use-cases/seed-base-playbooks.js +54 -13
  73. package/dist/app/use-cases/seed-base-playbooks.js.map +1 -1
  74. package/dist/app/use-cases/sync-base-set.d.ts +4 -2
  75. package/dist/app/use-cases/sync-base-set.d.ts.map +1 -1
  76. package/dist/app/use-cases/sync-base-set.js +4 -2
  77. package/dist/app/use-cases/sync-base-set.js.map +1 -1
  78. package/dist/app/use-cases/uninstall.d.ts +2 -1
  79. package/dist/app/use-cases/uninstall.d.ts.map +1 -1
  80. package/dist/app/use-cases/uninstall.js.map +1 -1
  81. package/dist/app/use-cases/upgrade-projects.d.ts +91 -0
  82. package/dist/app/use-cases/upgrade-projects.d.ts.map +1 -0
  83. package/dist/app/use-cases/upgrade-projects.js +494 -0
  84. package/dist/app/use-cases/upgrade-projects.js.map +1 -0
  85. package/dist/app/use-cases/verify-against-map.d.ts +52 -0
  86. package/dist/app/use-cases/verify-against-map.d.ts.map +1 -0
  87. package/dist/app/use-cases/verify-against-map.js +189 -0
  88. package/dist/app/use-cases/verify-against-map.js.map +1 -0
  89. package/dist/domain/line-diff.d.ts +36 -0
  90. package/dist/domain/line-diff.d.ts.map +1 -0
  91. package/dist/domain/line-diff.js +175 -0
  92. package/dist/domain/line-diff.js.map +1 -0
  93. package/dist/domain/protect-key.d.ts +21 -0
  94. package/dist/domain/protect-key.d.ts.map +1 -0
  95. package/dist/domain/protect-key.js +112 -0
  96. package/dist/domain/protect-key.js.map +1 -0
  97. package/dist/ports/base-set-renderer.port.d.ts +62 -1
  98. package/dist/ports/base-set-renderer.port.d.ts.map +1 -1
  99. package/package.json +3 -2
  100. package/templates/.wolf/router.log +1 -1
  101. package/templates/base/AGENTS.md +6 -3
  102. package/update-map.json +204 -0
@@ -1,10 +1,35 @@
1
1
  import { spawn } from 'child_process';
2
- import { realpathSync, readFileSync } from 'fs';
2
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
3
4
  import { fileURLToPath } from 'url';
4
- import { dirname, join } from 'path';
5
+ import { basename, dirname, join } from 'path';
6
+ import { createInterface } from 'node:readline/promises';
5
7
  import { Command } from 'commander';
6
8
  import { runUpgrade } from '../../../app/use-cases/upgrade.js';
7
9
  import { UserFacingError } from '../../../domain/errors.js';
10
+ import { runProjectStep } from '../../../app/use-cases/project-step.js';
11
+ import { backupDigestLines, jsonProjectFiles, mapCategories, STATUS_TEXT, statusOf, verifyNameLines, } from '../../../app/use-cases/upgrade-projects.js';
12
+ import { applyResolutions, collectDecisions, parseResolveValues, planResolutions, resolveShowTarget, similarTargets, emptyPlan, jsonDecisions, jsonDiverged, jsonProtections, } from '../../../app/use-cases/decisions.js';
13
+ import { lineDiff, formatDiff } from '../../../domain/line-diff.js';
14
+ import { normalizeManaged, parseProtect } from '../../../domain/protect-key.js';
15
+ import { projectPath } from '../../../app/use-cases/verify-against-map.js';
16
+ import { compareVersions, loadUpdateMap } from '../../fs/update-map.js';
17
+ import { ProjectsRegistry } from '../../fs/projects-registry.js';
18
+ import { wolfUserConfigDir } from '../../fs/user-config.js';
19
+ import { loadSyncState } from '../../render/sync-state.js';
20
+ import { parseStamp } from '../../render/stamp.js';
21
+ import { harnessTemplatesRoot, templatesRoot } from '../../render/templates-root.js';
22
+ import { OpencodeBaseSetRenderer } from '../../render/opencode/opencode-renderer.js';
23
+ import { MarkdownMemoryStore } from '../../fs/markdown-memory-store.js';
24
+ import { JsonlEventLog } from '../../fs/jsonl-event-log.js';
25
+ import { SQLiteSearchIndex } from '../../sqlite/sqlite-search-index.js';
26
+ import { SystemClock } from '../../fs/system-clock.js';
27
+ import { HashIdGenerator } from '../../fs/hash-id-generator.js';
28
+ import { FsMemoryLock } from '../../fs/memory-lock.js';
29
+ import { eventsPath, indexPath, memoryDir } from '../../fs/project-paths.js';
30
+ import { colorsEnabled } from '../../../domain/facet-colors.js';
31
+ import { renderReport, } from '../report-renderer.js';
32
+ import { safeCwd } from '../cli-entry.js';
8
33
  // wolf upgrade (бриф 2026-09-02): npm-самообновление глобальной установки.
9
34
  // spawn npm идёт через PATH — e2e подменяет npm фейком первой записью PATH,
10
35
  // реальный registry в тестах не дёргается.
@@ -85,20 +110,1343 @@ function printOutcome(outcome) {
85
110
  throw new UserFacingError(`a dev/linked copy is installed (${outcome.binaryPath ?? 'path unknown'}) — upgrade not applicable; to update: npm rm -g mister-wolf && npm i -g mister-wolf`);
86
111
  }
87
112
  }
113
+ const HERE_TITLE = 'wolf upgrade — here';
114
+ const msg = (err) => (err instanceof Error ? err.message : String(err));
115
+ const line = (marker, text, dim = false) => ({ kind: 'line', marker, text, dim });
116
+ /** Справочный состав/штамп cwd (формат T7 stampVersions): max/min set= по
117
+ * штампованным файлам свидетеля рендера; штамп недостоверен — свидетель, не истина. */
118
+ function stampVersions(root) {
119
+ let max = null;
120
+ let min = null;
121
+ for (const rel of Object.keys(loadSyncState(root).files)) {
122
+ let stamp;
123
+ try {
124
+ stamp = parseStamp(readFileSync(join(root, rel), 'utf-8'));
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ if (!stamp)
130
+ continue;
131
+ if (max === null || compareVersions(stamp.set, max) > 0)
132
+ max = stamp.set;
133
+ if (min === null || compareVersions(stamp.set, min) < 0)
134
+ min = stamp.set;
135
+ }
136
+ return { max, min };
137
+ }
138
+ /** Одиночный план (правила REQ-02 для одного проекта, формат строк T7):
139
+ * имя · путь · состав/штамп → цель · категории карты числами · режим
140
+ * «вне реестра». Пinned не проверяем — --here осознанный ручной инструмент. */
141
+ function planSection(cwd, map) {
142
+ const cats = mapCategories(map);
143
+ const stamps = stampVersions(cwd);
144
+ const floor = stamps.min !== null && stamps.min !== stamps.max ? ` (stamps from ${stamps.min})` : '';
145
+ return {
146
+ kind: 'section',
147
+ title: 'Plan',
148
+ children: [
149
+ line('info', `plan: ${basename(cwd)} · ${cwd}`),
150
+ line('info', `composition ${stamps.max ?? 'unknown'}${floor} → ${map.version}`, true),
151
+ line('info', `map: ${cats.new} new · ${cats.changed} changed · ${cats.deprecated} deprecated · ${cats.removed} removed · seed×${map.faces.filter((f) => f.seed).length}`, true),
152
+ line('info', 'outside the registry — manual mode', true),
153
+ ],
154
+ };
155
+ }
156
+ /** Детали шага одного проекта (REQ-05): base-set/лица/схема/сверка — формат
157
+ * строк T7 projectsSection. */
158
+ function stepDetailLines(report, map) {
159
+ const out = [];
160
+ const counts = { updated: 0, created: 0, appended: 0, identical: 0, mutated: 0, conflict: 0 };
161
+ for (const o of report.sync.outcomes) {
162
+ if (o.action === 'skipped') {
163
+ if (o.mutated)
164
+ counts.mutated++;
165
+ else
166
+ counts.identical++;
167
+ }
168
+ else {
169
+ counts[o.action]++;
170
+ }
171
+ }
172
+ const bsParts = Object.entries(counts)
173
+ .filter(([, n]) => n > 0)
174
+ .map(([k, n]) => `${n} ${k}`);
175
+ if (bsParts.length > 0)
176
+ out.push(line('info', `base-set: ${bsParts.join(', ')}`, true));
177
+ if (report.seed.length > 0) {
178
+ const faces = report.seed.map((s) => `${s.file.replace(/-playbook\.md$/, '')} ${s.kind ?? s.action}`).join(', ');
179
+ out.push(line('info', `faces: ${faces}`, true));
180
+ }
181
+ if (report.schema.status !== 'ok') {
182
+ out.push(line('warn', `schema: ${report.schema.status}${report.schema.error ? ` — ${report.schema.error}` : ''}`, true));
183
+ }
184
+ const v = report.verify.summary;
185
+ const ok = report.verify.items.length - v.needsDecision - v.errors;
186
+ const parts = [`${ok}/${report.verify.items.length} ok`];
187
+ if (v.needsDecision > 0)
188
+ parts.push(`${v.needsDecision} needs decision`);
189
+ if (v.errors > 0)
190
+ parts.push(`${v.errors} errors`);
191
+ out.push(line(v.errors > 0 ? 'error' : v.needsDecision > 0 ? 'warn' : 'info', `verify: ${parts.join(' · ')}`, true));
192
+ out.push(...verifyNameLines(report.verify, map));
193
+ out.push(...backupDigestLines(report)); // §4.12: файл + путь бэкапа
194
+ return out;
195
+ }
196
+ /** Счётчик статуса одного проекта — лейблы паркового словаря T7. */
197
+ const STATUS_COUNTER = {
198
+ updated: 'updated',
199
+ 'up-to-date': 'up-to-date',
200
+ 'needs-decision': 'needs decision',
201
+ error: 'errors',
202
+ };
203
+ /** Ручной режим (REQ-14): полный проектный шаг (T6) в cwd из текущего
204
+ * бинарника. Guard — именно .wolf/memory (не .wolf): каталог с .wolf без
205
+ * памяти — не живой проект, честный отказ с подсказкой init (exit 1 кладёт
206
+ * caller: UserFacingError → одна строка Error:, прецедент runCli).
207
+ * Отчёт — один проект REQ-05: план → статус+детали → summary → финальная
208
+ * строка exit-таблицы (та же таблица, что у прохода парка). Без banner. */
209
+ export async function runUpgradeHere(deps, opts = {}) {
210
+ if (!existsSync(join(deps.cwd, '.wolf', 'memory'))) {
211
+ throw new UserFacingError(`no wolf memory in ${deps.cwd} — run wolf init first`);
212
+ }
213
+ const map = deps.loadMap();
214
+ const plan = planSection(deps.cwd, map);
215
+ // Подтверждение — правила REQ-02 для одиночного плана; список покрывает и
216
+ // удаления (ADR-4), второго вопроса шаг не задаёт.
217
+ let proceed = opts.yes === true;
218
+ let declinedByUser = false;
219
+ if (!proceed && opts.tty && opts.confirm) {
220
+ proceed = await opts.confirm(plan);
221
+ declinedByUser = !proceed;
222
+ }
223
+ if (!proceed) {
224
+ const finalText = declinedByUser ? 'Cancelled — no changes' : 'Cancelled: --yes is required';
225
+ return {
226
+ exitCode: 0,
227
+ report: { kind: 'section', title: HERE_TITLE, children: [plan, line('info', finalText, true)] },
228
+ project: {
229
+ name: basename(deps.cwd),
230
+ path: deps.cwd,
231
+ status: 'skipped',
232
+ files: [],
233
+ protected: [],
234
+ backups: [],
235
+ },
236
+ };
237
+ }
238
+ const confirmedRemovals = map.files.filter((f) => f.category === 'removed').map(projectPath);
239
+ const runStep = deps.runStep ?? runProjectStep;
240
+ let report = null;
241
+ let error = null;
242
+ try {
243
+ report = await runStep(deps.makeStepDeps(deps.cwd), deps.cwd, map, {
244
+ confirmedRemovals,
245
+ takeMutated: opts.yes === true, // §4.12: --yes = тихий take с бэкапом и в --here
246
+ });
247
+ }
248
+ catch (err) {
249
+ error = msg(err); // изоляция сбоя шага: error-строка, не крах команды (REQ-06)
250
+ }
251
+ const status = report ? statusOf(report) : 'error';
252
+ const cats = mapCategories(map);
253
+ const seeded = report ? report.seed.filter((s) => s.kind === 'seeded' || s.kind === 'legacy-absorbed').length : 0;
254
+ const counters = [
255
+ { label: STATUS_COUNTER[status], count: 1 },
256
+ { label: 'new', count: cats.new },
257
+ { label: 'changed', count: cats.changed },
258
+ { label: 'deprecated', count: cats.deprecated },
259
+ { label: 'removed', count: cats.removed },
260
+ { label: 'faces seeded', count: seeded },
261
+ ];
262
+ // Exit-модель (design §«Exit-модель», та же таблица для одного проекта)
263
+ const exitCode = status === 'error' ? 1 : status === 'needs-decision' ? 2 : 0;
264
+ const finalText = status === 'error' ? 'Failed' : status === 'needs-decision' ? 'Done with attention' : 'Done';
265
+ const marker = status === 'error' ? 'error' : status === 'needs-decision' ? 'warn' : 'ok';
266
+ const children = [
267
+ plan,
268
+ line(marker, error !== null ? `${basename(deps.cwd)} — error: ${error}` : `${basename(deps.cwd)} — ${STATUS_TEXT[status]}`, status === 'up-to-date'),
269
+ ...(report ? stepDetailLines(report, map) : []),
270
+ { kind: 'summary', counters, done: false },
271
+ line(marker, finalText),
272
+ ];
273
+ const project = {
274
+ name: basename(deps.cwd),
275
+ path: deps.cwd,
276
+ status: status === 'up-to-date'
277
+ ? 'up-to-date'
278
+ : status === 'needs-decision'
279
+ ? 'needs-decision'
280
+ : status === 'error'
281
+ ? 'error'
282
+ : 'updated',
283
+ ...(error !== null ? { error } : {}),
284
+ files: report ? jsonProjectFiles(report, map) : [],
285
+ protected: [],
286
+ backups: report?.backups ?? [],
287
+ };
288
+ return { exitCode, report: { kind: 'section', title: HERE_TITLE, children }, project };
289
+ }
290
+ /** Прод-deps шага для root (контейнер-прецедент по адаптерам, но БЕЗ
291
+ * createCliContainer: его selfHealRegistry пишет реестр, а --here реестр
292
+ * не трогает — REQ-14). Привязка к root напрямую, как per-project deps T7.
293
+ * Экспорт — точечный reuse фазы 2 (T8 upgrade-projects), не дубль сборки. */
294
+ export function hereStepDeps(root) {
295
+ const tplPlaybooks = join(templatesRoot(), 'playbooks');
296
+ const files = new Map();
297
+ if (existsSync(tplPlaybooks)) {
298
+ for (const f of readdirSync(tplPlaybooks)) {
299
+ if (f.endsWith('.md'))
300
+ files.set(f, readFileSync(join(tplPlaybooks, f), 'utf-8'));
301
+ }
302
+ }
303
+ return {
304
+ renderer: new OpencodeBaseSetRenderer(templatesRoot(), {
305
+ harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
306
+ }),
307
+ files,
308
+ store: new MarkdownMemoryStore(root),
309
+ log: new JsonlEventLog(eventsPath(root)),
310
+ index: new SQLiteSearchIndex(indexPath(root)),
311
+ clock: new SystemClock(),
312
+ idGen: new HashIdGenerator(),
313
+ lock: new FsMemoryLock(memoryDir(root)),
314
+ };
315
+ }
316
+ /** TermEnv из реального stdout/env (REQ-15): не-TTY/пайп/NO_COLOR → plain;
317
+ * глубина цвета деградирует COLORTERM → TERM → 16; ширина — columns (80).
318
+ * Экспорт — точечный reuse фазы 2 (T8 upgrade-projects). */
319
+ export function termEnv(stdout, env) {
320
+ if (!colorsEnabled(stdout, env))
321
+ return { tty: false, colors: 'mono', width: 80 };
322
+ const colorterm = env.COLORTERM ?? '';
323
+ const colors = colorterm.includes('truecolor') || colorterm.includes('24bit')
324
+ ? 'truecolor'
325
+ : (env.TERM ?? '').includes('256color')
326
+ ? '256'
327
+ : '16';
328
+ return { tty: true, colors, width: stdout.columns ?? 80 };
329
+ }
330
+ /** CLI-обвязка --here: прод-deps + TTY-подтверждение (план показан ДО вопроса,
331
+ * REQ-02) + печать отчёта renderer'ом и простановка exit-кода. */
332
+ async function upgradeHereAction(cwd, yes, json = false) {
333
+ const isTty = Boolean(process.stdout.isTTY);
334
+ const env = termEnv(process.stdout, process.env);
335
+ const confirm = isTty
336
+ ? async (plans) => {
337
+ process.stdout.write(`${renderReport(plans, env)}\n`);
338
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
339
+ try {
340
+ const ans = (await rl.question('Upgrade this project? [y/N] ')).trim().toLowerCase();
341
+ return ans === 'y' || ans === 'yes';
342
+ }
343
+ finally {
344
+ rl.close();
345
+ }
346
+ }
347
+ : undefined;
348
+ const res = await runUpgradeHere({ cwd, loadMap: () => loadUpdateMap(), makeStepDeps: hereStepDeps }, { yes, tty: json ? false : isTty, confirm: json ? undefined : confirm });
349
+ if (json) {
350
+ const renderer = new OpencodeBaseSetRenderer(templatesRoot(), {
351
+ harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
352
+ });
353
+ const map = loadUpdateMap();
354
+ const reg = await collectDecisions({ ...decisionProdDeps(cwd, renderer), loadMap: () => map });
355
+ printJson(await modeJsonDoc({
356
+ mode: 'here',
357
+ exit: res.exitCode,
358
+ registry: reg,
359
+ map,
360
+ renderer,
361
+ projects: [res.project],
362
+ backups: res.project.backups,
363
+ }));
364
+ }
365
+ else {
366
+ process.stdout.write(`${renderReport(res.report, env)}\n`);
367
+ }
368
+ process.exitCode = res.exitCode;
369
+ }
370
+ /** Чистый разбор режима (тестируется без IO); T8/T9/T11 расширяют набор флагов. */
371
+ export function upgradeRoute(flags) {
372
+ const resolve = flags.resolve ?? [];
373
+ const resolveGiven = resolve.length > 0;
374
+ const showGiven = flags.show !== undefined;
375
+ const reviewGiven = flags.review === true;
376
+ // Несовместимые комбинации (§4.18): два способа действия в одном вызове
377
+ if (reviewGiven && flags.json === true) {
378
+ return { mode: 'pass', error: '--review is interactive; --json is not supported' };
379
+ }
380
+ if (reviewGiven && resolveGiven) {
381
+ return { mode: 'pass', error: '--review and --resolve are two ways to apply decisions — run one of them' };
382
+ }
383
+ if (reviewGiven && flags.yes === true) {
384
+ return { mode: 'pass', error: '--yes describes a pass, not a review — drop one of --review/--yes' };
385
+ }
386
+ if (reviewGiven && showGiven) {
387
+ return { mode: 'pass', error: '--review and --show are different read-only modes — run one of them' };
388
+ }
389
+ if (reviewGiven && flags.check === true) {
390
+ return { mode: 'pass', error: '--check and --review are different modes — run one of them' };
391
+ }
392
+ if (showGiven && resolveGiven) {
393
+ return { mode: 'pass', error: '--show views a file, --resolve writes — run them separately' };
394
+ }
395
+ if (showGiven && flags.yes === true) {
396
+ return { mode: 'pass', error: '--yes describes a pass, not a view — drop one of --show/--yes' };
397
+ }
398
+ if (showGiven && flags.check === true) {
399
+ return { mode: 'pass', error: '--check and --show are different read-only modes — run one of them' };
400
+ }
401
+ if (resolveGiven && flags.yes === true) {
402
+ return { mode: 'pass', error: '--yes describes a pass, not single decisions — drop one of --resolve/--yes' };
403
+ }
404
+ if (resolveGiven && flags.check === true) {
405
+ return { mode: 'pass', error: '--check is read-only and --resolve writes — run them separately' };
406
+ }
407
+ if (flags.check === true)
408
+ return { mode: 'check' }; // --check сильнее --here (§4.18)
409
+ if (showGiven)
410
+ return { mode: 'show' };
411
+ if (reviewGiven)
412
+ return { mode: 'review' };
413
+ if (resolveGiven)
414
+ return { mode: 'resolve' };
415
+ if (flags.here === true)
416
+ return { mode: 'here' };
417
+ return { mode: 'pass' };
418
+ }
419
+ /* ---------- --resolve (T7, REQ-06, design §4.7/§4.8/§4.12) ---------- */
420
+ const RESOLVE_TITLE = 'wolf upgrade — resolve';
421
+ /** Нумерованный список решений (§4.7/§4.9): один источник для --resolve и --review. */
422
+ export function decisionLines(reg) {
423
+ if (reg.targets.length === 0)
424
+ return [line('info', 'no decisions — nothing needs a decision', true)];
425
+ const noun = reg.targets.length === 1 ? 'file needs' : 'files need';
426
+ const out = [line('warn', `${reg.targets.length} ${noun} a decision`, true)];
427
+ for (const t of reg.targets)
428
+ out.push(line('info', ` ${t.n} ${t.project} ${t.file} ${t.code}`, true));
429
+ return out;
430
+ }
431
+ /** Пропущенные проходом проекты (§4.7): причина видна, а не теряется. */
432
+ export function skippedProjectLines(reg) {
433
+ if (reg.skippedProjects.length === 0)
434
+ return [];
435
+ const out = [line('info', `skipped projects: ${reg.skippedProjects.length}`, true)];
436
+ for (const p of reg.skippedProjects)
437
+ out.push(line('info', ` ${p.name} [${p.reason}] — skipped`, true));
438
+ return out;
439
+ }
440
+ /** Лица памяти: видны и держат exit 2, но адресуемого глагола нет (зона Стюарда). */
441
+ export function stewardZoneLines(diverged) {
442
+ if (diverged.length === 0)
443
+ return [];
444
+ const out = [line('warn', `steward zone (not addressable by --resolve): ${diverged.length}`, true)];
445
+ for (const d of diverged) {
446
+ out.push(line('info', ` ${d.project} ${d.file} diverged v${d.version ?? '?'}`, true));
447
+ }
448
+ return out;
449
+ }
450
+ /** Дайджест применения: счётчики по глаголам + файл и путь бэкапа (§4.12). */
451
+ export function digestLines(applied) {
452
+ if (applied.length === 0)
453
+ return [line('info', 'resolved: none', true)];
454
+ const counts = { take: 0, keep: 0, protect: 0, pin: 0 };
455
+ for (const a of applied)
456
+ counts[a.verb]++;
457
+ const parts = Object.entries(counts)
458
+ .filter(([, n]) => n > 0)
459
+ .map(([verb, n]) => `${verb}: ${n}`);
460
+ const out = [line('ok', `resolved: ${applied.length} (${parts.join(', ')})`)];
461
+ for (const a of applied) {
462
+ const suffix = a.backup !== undefined ? ` (local saved: ${a.backup})` : a.noop === true ? ' — no-op' : '';
463
+ out.push(line('info', `${a.verb} ${a.project}/${a.file}${suffix}`, true));
464
+ }
465
+ return out;
466
+ }
467
+ /** `--resolve` целиком: сбор реестра (read-only) → разбор всех аргументов →
468
+ * применение ровно заказанного → повторный сбор → дайджест и exit. Ни npm-фазы,
469
+ * ни re-exec, ни проектного прохода (§4.8 «Взаимодействие с проходом»). */
470
+ export async function runUpgradeResolve(deps, opts) {
471
+ const map = deps.loadMap();
472
+ const collectDeps = { ...deps, loadMap: () => map };
473
+ const before = await collectDecisions(collectDeps);
474
+ const parsed = parseResolveValues(opts.values);
475
+ const plan = parsed.errors.length > 0
476
+ ? { ops: [], errors: [] }
477
+ : planResolutions(before, map, parsed.requests, { here: opts.here, cwd: opts.cwd });
478
+ const errors = [...parsed.errors, ...plan.errors];
479
+ const children = [];
480
+ if (errors.length > 0) {
481
+ // Двухфазность (AC3): все ошибки собраны до первой записи — записей нет
482
+ for (const error of errors)
483
+ children.push(line('error', error));
484
+ children.push(...decisionLines(before));
485
+ children.push(line('error', 'Failed'));
486
+ return {
487
+ exitCode: 1,
488
+ report: { kind: 'section', title: RESOLVE_TITLE, children },
489
+ registry: before,
490
+ backups: [],
491
+ errors,
492
+ };
493
+ }
494
+ const applied = await applyResolutions(plan.ops, { map, renderer: deps.renderer, now: deps.now });
495
+ const after = await collectDecisions(collectDeps);
496
+ for (const error of applied.errors)
497
+ children.push(line('error', error));
498
+ children.push(...digestLines(applied.applied));
499
+ children.push(...skippedProjectLines(after));
500
+ children.push(...decisionLines(after));
501
+ children.push(...stewardZoneLines(after.diverged));
502
+ const exitCode = applied.errors.length > 0 ? 1 : after.targets.length > 0 || after.diverged.length > 0 ? 2 : 0;
503
+ const finalText = exitCode === 1 ? 'Failed' : exitCode === 2 ? 'Done with attention' : 'Done';
504
+ children.push(line(exitCode === 1 ? 'error' : exitCode === 2 ? 'warn' : 'ok', finalText));
505
+ return {
506
+ exitCode,
507
+ report: { kind: 'section', title: RESOLVE_TITLE, children },
508
+ registry: after,
509
+ backups: applied.backups,
510
+ errors: applied.errors,
511
+ };
512
+ }
513
+ /** Шаблоны лиц релиза (basename → сырой контент) — сравнение тела для diverged. */
514
+ function faceTemplates() {
515
+ const dir = join(templatesRoot(), 'playbooks');
516
+ const files = new Map();
517
+ if (existsSync(dir)) {
518
+ for (const f of readdirSync(dir)) {
519
+ if (f.endsWith('.md'))
520
+ files.set(f, readFileSync(join(dir, f), 'utf-8'));
521
+ }
522
+ }
523
+ return files;
524
+ }
525
+ /** Прод-deps реестра решений: парк (реестр проектов) или `--here` (только cwd). */
526
+ function decisionProdDeps(here, renderer, registry = new ProjectsRegistry(wolfUserConfigDir())) {
527
+ return {
528
+ ...(here === null ? { registry } : { here }),
529
+ loadMap: () => loadUpdateMap(),
530
+ inspect: (root) => renderer.inspectBaseSet(root, { models: 'omit' }),
531
+ liveFaces: async (root) => {
532
+ const faces = await new MarkdownMemoryStore(root).list({ type: 'face' });
533
+ return faces.map((o) => {
534
+ const face = o;
535
+ return {
536
+ ownerSkill: face.owner_skill,
537
+ version: face.version,
538
+ body: face.body,
539
+ initiator: face.initiator,
540
+ };
541
+ });
542
+ },
543
+ faceTemplates: faceTemplates(),
544
+ };
545
+ }
546
+ /** CLI-обвязка `--resolve`: прод-deps + печать отчёта + exit-код. */
547
+ async function upgradeResolveAction(values, here, json = false) {
548
+ const cwd = safeCwd();
549
+ const renderer = new OpencodeBaseSetRenderer(templatesRoot(), {
550
+ harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
551
+ });
552
+ const res = await runUpgradeResolve({ ...decisionProdDeps(here ? cwd : null, renderer), renderer }, { here, cwd, values });
553
+ if (json) {
554
+ const map = loadUpdateMap();
555
+ printJson(await modeJsonDoc({
556
+ mode: 'resolve',
557
+ exit: res.exitCode,
558
+ registry: res.registry,
559
+ map,
560
+ renderer,
561
+ backups: res.backups,
562
+ ...(res.errors.length > 0 ? { error: res.errors.join('; ') } : {}),
563
+ }));
564
+ }
565
+ else {
566
+ process.stdout.write(`${renderReport(res.report, termEnv(process.stdout, process.env))}\n`);
567
+ }
568
+ process.exitCode = res.exitCode;
569
+ }
570
+ /* ---------- --show (T8, REQ-08, design §4.10): read-only просмотр цели ---------- */
571
+ const SHOW_TITLE = 'wolf upgrade — show';
572
+ const protectText = (p) => {
573
+ if (!p.held)
574
+ return 'none';
575
+ const value = p.value ?? p.raw ?? '';
576
+ const kind = p.value === 'true' ? 'held' : p.unrecognized ? 'raw' : 'pinned';
577
+ return `${value || '∅'} (${kind}${p.unrecognized ? '; unrecognized — treated as protected' : '; recognized'})`;
578
+ };
579
+ /** Отпечаток свидетеля: `origin @ base` либо `n/a` (записи нет). */
580
+ const witnessText = (entry, protectedFile) => {
581
+ if (protectedFile)
582
+ return 'n/a (protected)';
583
+ if (entry === undefined || typeof entry !== 'object' || entry === null)
584
+ return 'n/a';
585
+ const e = entry;
586
+ return `${e.origin ?? 'render'} @ ${e.base ?? '?'} (${(e.hash ?? '').slice(0, 12)}…)`;
587
+ };
588
+ /** `--show <file|N>`: protect, обе версии и diff локальный → эталон; ничего не пишет. */
589
+ export async function runUpgradeShow(deps, opts) {
590
+ const map = deps.loadMap();
591
+ const reg = await collectDecisions({ ...deps, loadMap: () => map });
592
+ const resolved = resolveShowTarget(reg, map, opts.value, { here: opts.here, cwd: opts.cwd });
593
+ const children = [];
594
+ if (resolved.target === undefined) {
595
+ children.push(line('error', resolved.error ?? `unknown target ${opts.value}`));
596
+ children.push(...decisionLines(reg));
597
+ children.push(line('error', 'Failed'));
598
+ return {
599
+ exitCode: 1,
600
+ report: { kind: 'section', title: SHOW_TITLE, children },
601
+ registry: reg,
602
+ errors: [resolved.error ?? `unknown target ${opts.value}`],
603
+ };
604
+ }
605
+ const t = resolved.target;
606
+ const abs = join(t.root, t.file);
607
+ const current = existsSync(abs) ? readFileSync(abs, 'utf-8') : null;
608
+ const protect = current !== null ? parseProtect(current) : { held: false, value: null, raw: null };
609
+ const protectHeld = protect.held === true;
610
+ const stamp = current !== null ? parseStamp(current) : null;
611
+ const witness = witnessText(loadSyncState(t.root).files[t.file], protectHeld);
612
+ const fresh = deps.renderer.renderFreshFile(t.root, t.file);
613
+ children.push(line('info', `file: ${t.file} (project: ${t.project} ${t.root})`));
614
+ children.push(line('info', `protect: ${protectText(protect)}`));
615
+ if (fresh === null) {
616
+ // Файл вне карты: эталона нет, take запрещён матрицей §4.8
617
+ children.push(line('info', `base: n/a (file outside the update map) stamp: ${stamp?.set ?? 'n/a'} drift: n/a`));
618
+ children.push(line('info', 'ⓘ no base to take; the file is held as a sanctioned local variation', true));
619
+ children.push(line('info', `release: wolf upgrade --resolve ${t.file}=protect|pin`, true));
620
+ return { exitCode: 0, report: { kind: 'section', title: SHOW_TITLE, children }, registry: reg, errors: [] };
621
+ }
622
+ const drift = current === null ? 'missing' : normalizeManaged(current) === fresh ? 'none (content equals base)' : 'local ≠ base';
623
+ children.push(line('info', `base: ${map.version} file stamp: ${stamp?.set ?? 'n/a'} witness: ${witness} drift: ${drift}`));
624
+ if (current !== null) {
625
+ const diff = lineDiff(normalizeManaged(current), fresh);
626
+ children.push(line('info', `--- a/local (as is) +++ b/base ${map.version} (what --resolve …=take gives you)`, true));
627
+ const markerOf = (text) => (text.startsWith('+') ? 'ok' : text.startsWith('-') ? 'warn' : 'info');
628
+ for (const text of formatDiff(diff))
629
+ children.push(line(markerOf(text), ` ${text}`, true));
630
+ }
631
+ children.push(line('info', `release: wolf upgrade --resolve ${t.file}=take`, true));
632
+ return { exitCode: 0, report: { kind: 'section', title: SHOW_TITLE, children }, registry: reg, errors: [] };
633
+ }
634
+ /** CLI-обвязка `--show`: прод-deps реестра решений + печать отчёта + exit-код. */
635
+ async function upgradeShowAction(value, here, json = false) {
636
+ const cwd = safeCwd();
637
+ const renderer = new OpencodeBaseSetRenderer(templatesRoot(), {
638
+ harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
639
+ });
640
+ const res = await runUpgradeShow({ ...decisionProdDeps(here ? cwd : null, renderer), renderer }, { here, cwd, value });
641
+ if (json) {
642
+ printJson(await modeJsonDoc({
643
+ mode: 'show',
644
+ exit: res.exitCode,
645
+ registry: res.registry,
646
+ map: loadUpdateMap(),
647
+ renderer,
648
+ ...(res.errors.length > 0 ? { error: res.errors.join('; ') } : {}),
649
+ }));
650
+ }
651
+ else {
652
+ process.stdout.write(`${renderReport(res.report, termEnv(process.stdout, process.env))}\n`);
653
+ }
654
+ process.exitCode = res.exitCode;
655
+ }
656
+ /* ---------- --review (T9, REQ-07, design §4.9): TTY-цикл и non-TTY рецепт ---------- */
657
+ const REVIEW_TITLE = 'wolf upgrade — review';
658
+ const VERB_KEYS = { t: 'take', k: 'keep', p: 'protect', v: 'pin' };
659
+ /** Строка цели в списке §4.9: номер, проект, путь, код, база. */
660
+ function reviewTargetLine(t, base) {
661
+ return ` ${t.n} ${t.project} ${t.file} ${t.code} base ${base}`;
662
+ }
663
+ /** Non-TTY рецепт (design §4.9, AC2): список + готовые команды, без записей. */
664
+ function reviewRecipeNodes(reg, base, here) {
665
+ const out = [];
666
+ if (reg.targets.length === 0) {
667
+ out.push(line('info', 'review (non-interactive): no decisions — nothing needs a decision', true));
668
+ }
669
+ else {
670
+ const noun = reg.targets.length === 1 ? 'file needs' : 'files need';
671
+ out.push(line('warn', `review (non-interactive): ${reg.targets.length} ${noun} a decision`, true));
672
+ for (const t of reg.targets)
673
+ out.push(line('info', reviewTargetLine(t, base), true));
674
+ }
675
+ out.push(...skippedProjectLines(reg));
676
+ out.push(...stewardZoneLines(reg.diverged));
677
+ if (reg.targets.length > 0) {
678
+ const scope = here ? ' --here' : '';
679
+ const resolves = reg.targets.map((t) => `--resolve ${t.n}=take`).join(' ');
680
+ out.push(line('info', `review interactively: wolf upgrade${scope} --review (in a TTY)`, true));
681
+ out.push(line('info', `apply a decision: wolf upgrade${scope} ${resolves} (or keep|protect|pin)`, true));
682
+ out.push(line('info', `inspect a file: wolf upgrade${scope} --show ${reg.targets[0].n}`, true));
683
+ }
684
+ return out;
685
+ }
686
+ /** Экран одного решения (design §4.9): заголовок, размеры, diff ≤ 40 строк, клавиши. */
687
+ function reviewScreen(t, index, total, map, renderer) {
688
+ const lines = [`[${index}/${total}] ${t.project}/${t.file} — ${t.code} · base ${map.version}`];
689
+ const abs = join(t.root, t.file);
690
+ const current = existsSync(abs) ? readFileSync(abs, 'utf-8') : null;
691
+ const fresh = renderer.renderFreshFile(t.root, t.file);
692
+ const localLines = current === null ? 0 : normalizeManaged(current).replace(/\r\n/g, '\n').split('\n').length;
693
+ const baseLines = fresh === null ? 0 : fresh.replace(/\r\n/g, '\n').split('\n').length;
694
+ if (fresh === null) {
695
+ lines.push(` local: ${localLines} lines · base: n/a (file outside the update map) · no diff`);
696
+ }
697
+ else if (current === null) {
698
+ lines.push(` local: missing · base: ${baseLines} lines · take creates the file`);
699
+ }
700
+ else {
701
+ // a = эталон, b = локальный (§4.9): «−» — строки базы, «+» — локальные правки
702
+ const diff = lineDiff(fresh, normalizeManaged(current), { maxOutput: 40 });
703
+ lines.push(` local: ${localLines} lines · base: ${baseLines} lines · −${diff.removed} +${diff.added}`);
704
+ lines.push(` --- a/base ${map.version} +++ b/local`);
705
+ for (const text of formatDiff(diff))
706
+ lines.push(` ${text}`);
707
+ }
708
+ lines.push(' choice: [t]ake [k]eep [p]rotect [v]pin [s]kip (a — apply to all similar, q — quit)');
709
+ return lines;
710
+ }
711
+ /**
712
+ * `--review`: TTY-цикл по реестру (экраны с diff, клавиши t/k/p/v/s/a/q; EOF
713
+ * завершает цикл) или non-TTY рецепт без единой записи. Применение — тот же
714
+ * `applyResolutions`, что у `--resolve` (одна семантика, один бэкап-путь).
715
+ */
716
+ export async function runUpgradeReview(deps, opts) {
717
+ const map = deps.loadMap();
718
+ const collectDeps = { ...deps, loadMap: () => map };
719
+ const before = await collectDecisions(collectDeps);
720
+ const applied = [];
721
+ const errors = [];
722
+ const handled = new Set();
723
+ const applyOne = async (target, verb) => {
724
+ const plan = planResolutions(before, map, [{ target: String(target.n), verb }], {
725
+ here: opts.here,
726
+ cwd: opts.cwd,
727
+ });
728
+ if (plan.errors.length > 0) {
729
+ errors.push(...plan.errors);
730
+ return;
731
+ }
732
+ const res = await applyResolutions(plan.ops, { map, renderer: deps.renderer, now: deps.now });
733
+ applied.push(...res.applied);
734
+ errors.push(...res.errors);
735
+ handled.add(target.n);
736
+ };
737
+ if (!opts.io.tty) {
738
+ const nodes = reviewRecipeNodes(before, map.version, opts.here);
739
+ const exitCode = before.targets.length > 0 || before.diverged.length > 0 ? 2 : 0;
740
+ return {
741
+ exitCode,
742
+ report: { kind: 'section', title: REVIEW_TITLE, children: nodes },
743
+ applied,
744
+ errors,
745
+ registry: before,
746
+ };
747
+ }
748
+ const total = before.targets.length;
749
+ for (let i = 0; i < before.targets.length; i++) {
750
+ const target = before.targets[i];
751
+ if (handled.has(target.n))
752
+ continue;
753
+ // пустой ввод и неизвестная клавиша — переспрос без изменения файлов (§4.9)
754
+ let answer = null;
755
+ for (;;) {
756
+ opts.io.write(`${reviewScreen(target, i + 1, total, map, deps.renderer).join('\n')}\n`);
757
+ answer = (await opts.io.ask('> '))?.trim().toLowerCase() ?? null;
758
+ if (answer === null || answer === 'q' || answer === 's' || answer === 'a')
759
+ break;
760
+ if (VERB_KEYS[answer] !== undefined)
761
+ break;
762
+ }
763
+ if (answer === null || answer === 'q')
764
+ break;
765
+ if (answer === 's') {
766
+ handled.add(target.n);
767
+ continue;
768
+ }
769
+ if (answer === 'a') {
770
+ // «применить ко всем похожим»: отдельный вопрос о глаголе для группы
771
+ const group = [target, ...similarTargets(before, target).filter((t) => !handled.has(t.n))];
772
+ opts.io.write(`apply to all ${group.length} ${target.kind}/${target.code}: ${group.map((t) => t.file).join(', ')}\n`);
773
+ const verbAnswer = (await opts.io.ask('verb for all: [t]ake [k]eep [p]rotect [v]pin or [enter] back > '))?.trim().toLowerCase() ??
774
+ null;
775
+ const groupVerb = verbAnswer === null ? undefined : VERB_KEYS[verbAnswer];
776
+ if (groupVerb === undefined) {
777
+ i--; // без изменения файлов — возврат к текущей цели (переспрос), не пропуск
778
+ continue;
779
+ }
780
+ for (const member of group)
781
+ await applyOne(member, groupVerb);
782
+ continue;
783
+ }
784
+ const verb = VERB_KEYS[answer];
785
+ await applyOne(target, verb);
786
+ // «ко всем похожим» одним вопросом, если остались такие же цели
787
+ const similar = similarTargets(before, target).filter((t) => !handled.has(t.n));
788
+ if (similar.length > 0) {
789
+ opts.io.write(`apply "${verb}" to ${similar.length} more ${target.kind}/${target.code}?\n`);
790
+ for (const s of similar)
791
+ opts.io.write(` ${s.n} ${s.project} ${s.file}\n`);
792
+ const confirmed = (await opts.io.ask('[y/N] '))?.trim().toLowerCase() ?? null;
793
+ if (confirmed === 'y' || confirmed === 'yes') {
794
+ for (const member of similar)
795
+ await applyOne(member, verb);
796
+ }
797
+ }
798
+ }
799
+ const after = await collectDecisions(collectDeps);
800
+ const digest = [...digestLines(applied), ...decisionLines(after), ...stewardZoneLines(after.diverged)];
801
+ for (const error of errors)
802
+ opts.io.write(`error: ${error}\n`);
803
+ opts.io.write(`${digest.map((node) => (node.kind === 'line' ? node.text : '')).join('\n')}\n`);
804
+ const exitCode = errors.length > 0 ? 1 : after.targets.length > 0 || after.diverged.length > 0 ? 2 : 0;
805
+ opts.io.write(`${exitCode === 1 ? 'Failed' : exitCode === 2 ? 'Done with attention' : 'Done'}\n`);
806
+ return {
807
+ exitCode,
808
+ report: { kind: 'section', title: REVIEW_TITLE, children: digest },
809
+ applied,
810
+ errors,
811
+ registry: after,
812
+ };
813
+ }
814
+ /** Прод-IO цикла: stdout + readline; EOF/Ctrl-D → null (цикл завершается). */
815
+ async function upgradeReviewAction(here) {
816
+ const cwd = safeCwd();
817
+ const renderer = new OpencodeBaseSetRenderer(templatesRoot(), {
818
+ harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
819
+ });
820
+ const tty = Boolean(process.stdout.isTTY && process.stdin.isTTY);
821
+ const rl = tty ? createInterface({ input: process.stdin, output: process.stdout }) : null;
822
+ const io = {
823
+ tty,
824
+ write: (text) => process.stdout.write(text),
825
+ ask: async (prompt) => {
826
+ if (!rl)
827
+ return null;
828
+ try {
829
+ return await rl.question(prompt);
830
+ }
831
+ catch {
832
+ return null; // stdin закрыт (Ctrl-D/Ctrl-C)
833
+ }
834
+ },
835
+ };
836
+ try {
837
+ const res = await runUpgradeReview({ ...decisionProdDeps(here ? cwd : null, renderer), renderer }, { here, cwd, io });
838
+ if (!tty)
839
+ process.stdout.write(`${renderReport(res.report, termEnv(process.stdout, process.env))}\n`);
840
+ process.exitCode = res.exitCode;
841
+ }
842
+ finally {
843
+ rl?.close();
844
+ }
845
+ }
846
+ const CLASS_TEXT = {
847
+ current: 'current',
848
+ behind: 'behind',
849
+ 'needs-decision': 'needs decision',
850
+ unknown: 'unknown — out of coverage',
851
+ };
852
+ /** Чистая классификация (design §«--check»), приоритет правил — как в брифе:
853
+ * mutated → «требует решения»; differs/missing по целям карты и существующий
854
+ * removed-файл → «отстаёт»; foreign/unknown → «unknown — вне покрытия»;
855
+ * иначе «актуальный». AGENTS.md — цель карты agents_md тем же словарём.
856
+ * deprecated — report-only (REQ-11), вне-карты — справочно. */
857
+ export function classifyProject(map, outcomes, removedExisting) {
858
+ const out = {
859
+ cls: 'current',
860
+ differs: [],
861
+ missing: [],
862
+ mutated: [],
863
+ foreign: [],
864
+ uncertain: [],
865
+ removedPresent: [...removedExisting],
866
+ deprecated: [],
867
+ outsideMap: [],
868
+ protected: [],
869
+ kept: [],
870
+ details: [],
871
+ };
872
+ for (const o of outcomes) {
873
+ if (o.detail)
874
+ out.details.push({ file: o.file, detail: o.detail });
875
+ }
876
+ const byFile = new Map(outcomes.map((o) => [o.file, o.action]));
877
+ const targetRels = new Set();
878
+ const absorb = (rel, action) => {
879
+ if (action === 'mutated')
880
+ out.mutated.push(rel);
881
+ else if (action === 'foreign')
882
+ out.foreign.push(rel);
883
+ else if (action === 'unknown')
884
+ out.uncertain.push(rel);
885
+ else if (action === 'differs')
886
+ out.differs.push(rel);
887
+ else if (action === 'missing')
888
+ out.missing.push(rel);
889
+ else if (action === 'protected')
890
+ out.protected.push(rel);
891
+ else if (action === 'kept')
892
+ out.kept.push(rel);
893
+ };
894
+ for (const entry of map.files) {
895
+ const rel = projectPath(entry);
896
+ targetRels.add(rel);
897
+ if (entry.category === 'deprecated') {
898
+ out.deprecated.push(rel); // report-only (REQ-11)
899
+ continue;
900
+ }
901
+ absorb(rel, byFile.get(rel));
902
+ }
903
+ if (map.agents_md.category !== 'deprecated')
904
+ absorb('AGENTS.md', byFile.get('AGENTS.md'));
905
+ for (const o of outcomes) {
906
+ if (o.file !== 'AGENTS.md' && !targetRels.has(o.file))
907
+ out.outsideMap.push(o.file);
908
+ }
909
+ out.cls =
910
+ out.mutated.length > 0
911
+ ? 'needs-decision'
912
+ : out.differs.length > 0 || out.missing.length > 0 || out.removedPresent.length > 0
913
+ ? 'behind'
914
+ : out.foreign.length > 0 || out.uncertain.length > 0
915
+ ? 'unknown'
916
+ : 'current';
917
+ return out;
918
+ }
919
+ const CHECK_TITLE = 'wolf upgrade — check';
920
+ /** Детали проекта одной строкой: `2 differ · 1 missing · mutated: …`. */
921
+ function freshnessDetails(f) {
922
+ const parts = [];
923
+ // REQ-01 AC2: путь каждого differ — не выборка и не усечение (§4.15).
924
+ if (f.differs.length > 0)
925
+ parts.push(`${f.differs.length} differ: ${f.differs.join(', ')}`);
926
+ if (f.missing.length > 0)
927
+ parts.push(`${f.missing.length} missing`);
928
+ if (f.mutated.length > 0)
929
+ parts.push(`mutated: ${f.mutated.join(', ')}`);
930
+ if (f.foreign.length > 0)
931
+ parts.push(`foreign: ${f.foreign.join(', ')}`);
932
+ if (f.uncertain.length > 0)
933
+ parts.push(`unknown: ${f.uncertain.join(', ')}`);
934
+ if (f.removedPresent.length > 0)
935
+ parts.push(`removed present: ${f.removedPresent.join(', ')}`);
936
+ if (f.outsideMap.length > 0)
937
+ parts.push(`${f.outsideMap.length} files outside the map`);
938
+ if (f.kept.length > 0)
939
+ parts.push(`kept (local): ${f.kept.join(', ')}`);
940
+ if (f.protected.length > 0)
941
+ parts.push(`${f.protected.length} protected (intentional)`);
942
+ return parts.length > 0 ? parts.join(' · ') : null;
943
+ }
944
+ const stampNote = (root) => {
945
+ const stamps = stampVersions(root);
946
+ const floor = stamps.min !== null && stamps.min !== stamps.max ? ` (stamps from ${stamps.min})` : '';
947
+ return `stamps ${stamps.max ?? 'unknown'}${floor}`;
948
+ };
949
+ /** Секция Protections (§4.11, REQ-09): печатается ВСЕГДА и последней в `--check`;
950
+ * пусто — строка `Protections: none`. Строится из того же `ProtectEntry[]`, что и
951
+ * `--json.protections`, поэтому текст и JSON не расходятся. */
952
+ function protectionNodes(entries, base, scanned = true) {
953
+ if (entries.length === 0)
954
+ return [
955
+ line('info', scanned ? 'Protections: none' : 'Protections: not scanned (map or projects registry unavailable)', true),
956
+ ];
957
+ const nodes = entries.map((e) => {
958
+ const value = e.recognized ? `protect: ${e.value}` : `protect: ${e.value} (unrecognized — treated as protected)`;
959
+ const drift = e.behind === null ? 'drift: n/a' : e.behind ? `behind (base ${base ?? 'unknown'})` : 'ok';
960
+ return line('info', `${e.project} · ${e.path} · ${value} · ${drift}`, true);
961
+ });
962
+ const noun = entries.length === 1 ? 'file' : 'files';
963
+ nodes.push(line('info', `${entries.length} protected ${noun} · release: wolf upgrade --resolve <file>=take`, true));
964
+ return [{ kind: 'section', title: 'Protections', children: nodes }];
965
+ }
966
+ /** Пустая строка документа `--json` (нет чтения/ошибка/карты). */
967
+ function emptyCheckRow(name, path, protectedFiles) {
968
+ return {
969
+ name,
970
+ path,
971
+ class: 'unknown',
972
+ differ: [],
973
+ missing: [],
974
+ mutated: [],
975
+ foreign: [],
976
+ details: [],
977
+ protected: protectedFiles,
978
+ };
979
+ }
980
+ /** Секция одного проекта (REQ-08): строки отчёта + машинная строка (§4.13). */
981
+ async function projectCheck(entry, map, deps) {
982
+ const name = basename(entry.path);
983
+ const protections = deps.protections ? jsonProtections(await deps.protections(entry.path)) : [];
984
+ if (!existsSync(entry.path)) {
985
+ return {
986
+ lines: [line('info', `${name} · ${entry.path} — ${CLASS_TEXT.unknown} (path does not exist)`, true)],
987
+ row: emptyCheckRow(name, entry.path, protections),
988
+ };
989
+ }
990
+ try {
991
+ const outcomes = await deps.inspect(entry.path);
992
+ const removedExisting = map.files
993
+ .filter((e) => e.category === 'removed' && existsSync(join(entry.path, projectPath(e))))
994
+ .map(projectPath);
995
+ const f = classifyProject(map, outcomes, removedExisting);
996
+ const marker = f.cls === 'current' ? 'ok' : f.cls === 'unknown' ? 'info' : 'warn';
997
+ const lines = [
998
+ line(marker, `${name} · ${entry.path} — ${CLASS_TEXT[f.cls]}`, f.cls === 'current'),
999
+ line('info', `${stampNote(entry.path)} · map ${map.version}`, true),
1000
+ ];
1001
+ const details = freshnessDetails(f);
1002
+ if (details)
1003
+ lines.push(line('info', details, true));
1004
+ for (const d of f.details) {
1005
+ lines.push(line('info', `detail: ${d.detail} — treated as foreign (${d.file})`, true));
1006
+ }
1007
+ if (f.deprecated.length > 0) {
1008
+ lines.push(line('info', `deprecated: ${f.deprecated.join(', ')} (report-only)`, true));
1009
+ }
1010
+ return {
1011
+ lines,
1012
+ row: {
1013
+ name,
1014
+ path: entry.path,
1015
+ class: f.cls,
1016
+ differ: f.differs,
1017
+ missing: f.missing,
1018
+ mutated: f.mutated,
1019
+ foreign: f.foreign,
1020
+ details: f.details.map((d) => d.detail),
1021
+ protected: protections,
1022
+ },
1023
+ };
1024
+ }
1025
+ catch (err) {
1026
+ return {
1027
+ lines: [line('error', `${name} — error: ${msg(err)}`)],
1028
+ row: emptyCheckRow(name, entry.path, protections),
1029
+ };
1030
+ }
1031
+ }
1032
+ /** `--check`-сводка парка (REQ-08): CLI-строка (installed vs available) →
1033
+ * перечисление проектов реестра. Деградации не крахят: битый реестр/npm —
1034
+ * error-строка; карты нет — штампы с пометкой недостоверности и классом
1035
+ * unknown (никогда current); пустой реестр — «no projects». exit 0.
1036
+ * Документ `--json` строится из тех же строк (§4.13: текст и JSON не расходятся). */
1037
+ export async function runUpgradeCheck(deps) {
1038
+ const children = [];
1039
+ const rows = [];
1040
+ let available = null;
1041
+ try {
1042
+ available = await deps.registryVersion();
1043
+ children.push(line('info', `cli: installed ${deps.installedVersion()} · available ${available}`));
1044
+ }
1045
+ catch (err) {
1046
+ children.push(line('error', `cli: installed ${deps.installedVersion()} · available unknown — ${msg(err)}`));
1047
+ }
1048
+ let mapVersion = null;
1049
+ let registered = null;
1050
+ try {
1051
+ registered = await deps.registry.listStrict();
1052
+ }
1053
+ catch (err) {
1054
+ children.push(line('error', `failed to read the projects registry: ${msg(err)}`));
1055
+ }
1056
+ if (registered !== null) {
1057
+ if (registered.length === 0) {
1058
+ children.push(line('info', 'no projects in the registry — run wolf init in a project to register it'));
1059
+ }
1060
+ else {
1061
+ let map = null;
1062
+ try {
1063
+ map = deps.loadMap();
1064
+ }
1065
+ catch {
1066
+ map = null;
1067
+ }
1068
+ if (map) {
1069
+ mapVersion = map.version;
1070
+ for (const entry of registered) {
1071
+ const checked = await projectCheck(entry, map, deps);
1072
+ children.push(...checked.lines);
1073
+ rows.push(checked.row);
1074
+ }
1075
+ }
1076
+ else {
1077
+ // Деградация (design §«--check»): карты нет у самого CLI — только штампы
1078
+ // (свидетель касания, не истина) с явной пометкой недостоверности.
1079
+ children.push(line('warn', 'map unavailable in this CLI — stamps only, unreliable'));
1080
+ for (const entry of registered) {
1081
+ children.push(line('info', `${basename(entry.path)} · ${entry.path} — ${CLASS_TEXT.unknown}`));
1082
+ children.push(line('info', stampNote(entry.path), true));
1083
+ rows.push(emptyCheckRow(basename(entry.path), entry.path, []));
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ // §4.11: секция Protections — последняя (перед финальной строкой), печатается всегда;
1089
+ // при деградации (нет реестра/карты/реестра защит) сканирования не было — не «none»
1090
+ // пустой реестр — сканировать нечего («none» честен), карта нужна только при проектах
1091
+ const protectionsScanned = deps.protections !== undefined && registered !== null && (registered.length === 0 || mapVersion !== null);
1092
+ children.push(...protectionNodes(rows.flatMap((row) => row.protected), mapVersion, protectionsScanned));
1093
+ children.push(line('ok', 'Done (read-only)'));
1094
+ return {
1095
+ exitCode: 0,
1096
+ report: { kind: 'section', title: CHECK_TITLE, children },
1097
+ json: {
1098
+ schema: 'wolf.upgrade.check/1',
1099
+ exit: 0,
1100
+ cli: { installed: deps.installedVersion(), available },
1101
+ projects: rows,
1102
+ diverged: deps.diverged ?? [],
1103
+ protections: rows.flatMap((row) => row.protected),
1104
+ },
1105
+ };
1106
+ }
1107
+ /** `--here --check` (§4.11, REQ-09 AC1–AC3, NFR-03): read-only инспекция ОДНОГО
1108
+ * cwd + реестр защит. Ни реестра проектов, ни npm-фазы, ни sync-state, ни
1109
+ * бэкапов — exit всегда 0 (снапшот mtime+sha256 до/после идентичен, тест). */
1110
+ export async function runUpgradeCheckHere(deps) {
1111
+ const children = [];
1112
+ const rows = [];
1113
+ let map = null;
1114
+ try {
1115
+ map = deps.loadMap();
1116
+ }
1117
+ catch {
1118
+ map = null; // деградация: штампы с пометкой недостоверности, класс unknown
1119
+ }
1120
+ if (map) {
1121
+ const checked = await projectCheck({ path: deps.here, schema_version: 2, initialized_at: '' }, map, deps);
1122
+ children.push(...checked.lines);
1123
+ rows.push(checked.row);
1124
+ }
1125
+ else {
1126
+ children.push(line('warn', 'map unavailable in this CLI — stamps only, unreliable'));
1127
+ children.push(line('info', `${basename(deps.here)} · ${deps.here} — ${CLASS_TEXT.unknown}`));
1128
+ children.push(line('info', stampNote(deps.here), true));
1129
+ rows.push(emptyCheckRow(basename(deps.here), deps.here, []));
1130
+ }
1131
+ children.push(...protectionNodes(rows.flatMap((row) => row.protected), map?.version ?? null, deps.protections !== undefined && map !== null));
1132
+ children.push(line('ok', 'Done (read-only)'));
1133
+ return {
1134
+ exitCode: 0,
1135
+ report: { kind: 'section', title: CHECK_TITLE, children },
1136
+ json: {
1137
+ schema: 'wolf.upgrade.check/1',
1138
+ exit: 0,
1139
+ // npm view в этом режиме не вызывается — доступной версии нет (поле схемы nullable)
1140
+ cli: { installed: deps.installedVersion(), available: null },
1141
+ projects: rows,
1142
+ diverged: [],
1143
+ protections: rows.flatMap((row) => row.protected),
1144
+ },
1145
+ };
1146
+ }
1147
+ /** CLI-обвязка `--here --check` (§4.11): только cwd, без реестра и npm-фазы. */
1148
+ async function upgradeCheckHereAction(json) {
1149
+ const cwd = safeCwd();
1150
+ const renderer = new OpencodeBaseSetRenderer(templatesRoot(), {
1151
+ harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
1152
+ });
1153
+ let base = null;
1154
+ try {
1155
+ base = loadUpdateMap().version;
1156
+ }
1157
+ catch {
1158
+ base = null;
1159
+ }
1160
+ const res = await runUpgradeCheckHere({
1161
+ here: cwd,
1162
+ loadMap: () => loadUpdateMap(),
1163
+ installedVersion: readInstalledVersion,
1164
+ inspect: (root) => renderer.inspectBaseSet(root, { models: 'omit' }),
1165
+ ...(base !== null ? { protections: (root) => renderer.listProtections(root, base) } : {}),
1166
+ });
1167
+ if (json)
1168
+ printJson(res.json);
1169
+ else
1170
+ process.stdout.write(`${renderReport(res.report, termEnv(process.stdout, process.env))}\n`);
1171
+ }
1172
+ /** CLI-обвязка check-сводки парка (REQ-08): реестр — как projects
1173
+ * (wolfUserConfigDir), инспекция — read-only inspectBaseSet ('omit' — паритет
1174
+ * sync-рендера); exit не меняется (--check всегда 0). `--here` в эту ветку не
1175
+ * доходит — у него свой read-only путь (design §4.18: `--check` сильнее `--here`). */
1176
+ async function upgradeCheckAction(viewVersion, json = false) {
1177
+ const renderer = new OpencodeBaseSetRenderer(templatesRoot(), {
1178
+ harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
1179
+ });
1180
+ const registry = new ProjectsRegistry(wolfUserConfigDir());
1181
+ let map = null;
1182
+ try {
1183
+ map = loadUpdateMap();
1184
+ }
1185
+ catch {
1186
+ map = null; // деградация уже обработана runUpgradeCheck
1187
+ }
1188
+ let diverged;
1189
+ if (json) {
1190
+ // `--json.diverged` (§4.13): read-only сбор реестра решений (только при --json)
1191
+ const reg = await collectDecisions({
1192
+ ...decisionProdDeps(null, renderer, registry),
1193
+ loadMap: () => loadUpdateMap(),
1194
+ });
1195
+ diverged = jsonDiverged(reg);
1196
+ }
1197
+ const res = await runUpgradeCheck({
1198
+ registry,
1199
+ loadMap: () => {
1200
+ if (!map)
1201
+ throw new Error('update map is not available in this CLI');
1202
+ return map;
1203
+ },
1204
+ registryVersion: viewVersion,
1205
+ installedVersion: readInstalledVersion,
1206
+ inspect: (root) => renderer.inspectBaseSet(root, { models: 'omit' }),
1207
+ ...(map ? { protections: (root) => renderer.listProtections(root, map.version) } : {}),
1208
+ ...(diverged !== undefined ? { diverged } : {}),
1209
+ });
1210
+ if (json)
1211
+ printJson(res.json);
1212
+ else
1213
+ process.stdout.write(`${renderReport(res.report, termEnv(process.stdout, process.env))}\n`);
1214
+ }
1215
+ /* ---------- --json (T11, REQ-11, design §4.13): единственный документ в stdout ---------- */
1216
+ /** Единственный JSON в stdout: `JSON.stringify` без отступов + перевод строки. */
1217
+ export function printJson(doc) {
1218
+ process.stdout.write(`${JSON.stringify(doc)}\n`);
1219
+ }
1220
+ /** Реестр защит всех активных проектов (`--json.protections`, §4.11/§4.13). */
1221
+ async function collectProtections(reg, renderer, map) {
1222
+ const out = [];
1223
+ for (const project of reg.projects) {
1224
+ const entries = await renderer.listProtections(project.root, map.version);
1225
+ out.push(...jsonProtections(entries));
1226
+ }
1227
+ return out;
1228
+ }
1229
+ /** Документ `wolf.upgrade/1` режима pass (§4.13): факты прохода + остаток
1230
+ * решений, лица и защиты (read-only, после прохода). */
1231
+ export async function buildPassJsonDoc(deps, res, cli, error) {
1232
+ const renderer = new OpencodeBaseSetRenderer(templatesRoot(), {
1233
+ harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
1234
+ });
1235
+ const map = deps.loadMap();
1236
+ const reg = await collectDecisions({ ...decisionProdDeps(null, renderer, deps.registry), loadMap: () => map });
1237
+ const protections = await collectProtections(reg, renderer, map);
1238
+ return {
1239
+ schema: 'wolf.upgrade/1',
1240
+ exit: res.exitCode,
1241
+ mode: 'pass',
1242
+ cli,
1243
+ ...(error !== undefined ? { error } : {}),
1244
+ plan: res.plan,
1245
+ projects: res.projects.map((p) => ({
1246
+ ...p,
1247
+ protected: protections.filter((x) => x.project === p.name),
1248
+ })),
1249
+ decisions: jsonDecisions(reg),
1250
+ diverged: jsonDiverged(reg),
1251
+ protections,
1252
+ backups: res.projects.flatMap((p) => p.backups),
1253
+ };
1254
+ }
1255
+ /** Документ `wolf.upgrade/1` для режимов без прохода (resolve/show/here). */
1256
+ async function modeJsonDoc(args) {
1257
+ const protections = await collectProtections(args.registry, args.renderer, args.map);
1258
+ return {
1259
+ schema: 'wolf.upgrade/1',
1260
+ exit: args.exit,
1261
+ mode: args.mode,
1262
+ cli: { installed: readInstalledVersion(), available: null },
1263
+ ...(args.error !== undefined ? { error: args.error } : {}),
1264
+ plan: emptyPlan(),
1265
+ projects: args.projects ?? [],
1266
+ decisions: jsonDecisions(args.registry),
1267
+ diverged: jsonDiverged(args.registry),
1268
+ protections,
1269
+ backups: args.backups ?? [],
1270
+ };
1271
+ }
1272
+ /** Документ фазы 1, когда проход не дошёл до проектов (§4.13): npm-сбой,
1273
+ * linked-refusal (error + exit 1) или local-newer (exit 0, без error). */
1274
+ function phase1JsonDoc(installed, available, exit, error) {
1275
+ return {
1276
+ schema: 'wolf.upgrade/1',
1277
+ exit,
1278
+ mode: 'pass',
1279
+ cli: { installed, available },
1280
+ ...(error !== undefined ? { error } : {}),
1281
+ plan: emptyPlan(),
1282
+ projects: [],
1283
+ decisions: [],
1284
+ diverged: [],
1285
+ protections: [],
1286
+ backups: [],
1287
+ };
1288
+ }
1289
+ const npmFailureJsonDoc = (installed, error) => phase1JsonDoc(installed, null, 1, error);
1290
+ /** Re-exec нового бинарника (ADR-2, «Контракт spawn»): state-файл
1291
+ * `{ from, to, startedAt }` пишется ДО spawn; spawn без shell со stdio
1292
+ * inherit и env: process.env (склейка фаз, REQ-15); код родителя = код
1293
+ * ребёнка (убит сигналом → 1); сбой spawn/старта → UserFacingError —
1294
+ * npm-фаза идемпотентна, повторный `wolf upgrade` при up-to-date сразу
1295
+ * уходит в проектный проход. State — только контекст отчёта (NFR-02),
1296
+ * чистка не требуется (ОС-tmp). */
1297
+ export async function reExecUpgradeProjects(from, to, npmPrefix, opts = {}) {
1298
+ const stateDir = opts.stateDir ?? mkdtempSync(join(tmpdir(), 'wolf-upgrade-state-'));
1299
+ const statePath = join(stateDir, 'state.json');
1300
+ writeFileSync(statePath, JSON.stringify({ from, to, startedAt: (opts.now ?? Date.now)() }));
1301
+ const childBin = opts.childBin ?? process.env.WOLF_UPGRADE_CHILD_BIN ?? join(npmPrefix ?? tmpdir(), 'bin', 'wolf');
1302
+ const argv = [
1303
+ 'upgrade-projects',
1304
+ '--from',
1305
+ from,
1306
+ '--to',
1307
+ to,
1308
+ ...(opts.yes === true ? ['--yes'] : []),
1309
+ '--no-banner',
1310
+ '--state',
1311
+ statePath,
1312
+ // §4.18: единственный флаг вывода, который доходит до ребёнка; режимные
1313
+ // флаги (--resolve/--show/--review/--check) до re-exec не доходят вовсе.
1314
+ ...(opts.json === true ? ['--json'] : []),
1315
+ ];
1316
+ const child = (opts.spawnImpl ?? spawn)(childBin, argv, { stdio: 'inherit', env: process.env });
1317
+ const exit = opts.exit ?? ((code) => process.exit(code));
1318
+ await new Promise((resolve, reject) => {
1319
+ let settled = false; // error и close могут прийти оба (порядок не гарантирован)
1320
+ child.on('error', () => {
1321
+ if (settled)
1322
+ return;
1323
+ settled = true;
1324
+ reject(new UserFacingError('CLI updated; projects not touched — run wolf upgrade again for the project pass'));
1325
+ });
1326
+ child.on('close', (code) => {
1327
+ if (settled)
1328
+ return;
1329
+ settled = true;
1330
+ exit(code ?? 1); // code null — ребёнок убит сигналом
1331
+ resolve(); // прод: exit не возвращает; юнит: exit — колбэк
1332
+ });
1333
+ });
1334
+ }
1335
+ /** npm-фаза + re-exec (T8, REQ-01/04, ADR-2): шапку печатает ТОЛЬКО фаза 1
1336
+ * (склейка фаз, REQ-15 — фаза 2 идёт с --no-banner); npm-сбой закрывает
1337
+ * секцию `└ Failed` (REQ-01 AC3) и падает ДО spawn — проекты не тронуты;
1338
+ * up-to-date НЕ выход — фаза идемпотентна, идём к re-exec; local-newer —
1339
+ * реестру нечему обновляться, spawn не нужен (exit 0); linked-refusal —
1340
+ * отказ printOutcome. reExec-шов — юнит/R01.3-контур (шпион вместо spawn). */
1341
+ export async function runUpgradeWithProjects(deps, opts = {}) {
1342
+ const json = opts.json === true;
1343
+ const env = termEnv(process.stdout, process.env);
1344
+ if (!json)
1345
+ process.stdout.write(`${renderReport({ kind: 'banner', version: deps.currentVersion }, env)}\n`);
1346
+ let outcome;
1347
+ try {
1348
+ outcome = await runUpgrade(deps, false);
1349
+ }
1350
+ catch (err) {
1351
+ if (json)
1352
+ printJson(npmFailureJsonDoc(deps.currentVersion, msg(err)));
1353
+ else
1354
+ console.log('└ Failed'); // секция закрыта; Error:-строку и exit 1 даст runCli
1355
+ throw err;
1356
+ }
1357
+ if (!json)
1358
+ printOutcome(outcome); // §4.13: фаза 1 при --json молчит
1359
+ // §4.13: документ печатает фаза 1, если до проектов дело не дошло
1360
+ if (json && outcome.kind === 'linked-refusal') {
1361
+ const message = `a dev/linked copy is installed (${outcome.binaryPath ?? 'path unknown'}) — upgrade not applicable; to update: npm rm -g mister-wolf && npm i -g mister-wolf`;
1362
+ printJson(phase1JsonDoc(deps.currentVersion, null, 1, message));
1363
+ throw new UserFacingError(message);
1364
+ }
1365
+ if (json && outcome.kind === 'local-newer') {
1366
+ printJson(phase1JsonDoc(deps.currentVersion, outcome.latest, 0));
1367
+ return; // реестру нечему обновляться: exit 0, без spawn
1368
+ }
1369
+ const reExec = deps.reExec ?? reExecUpgradeProjects;
1370
+ const reExecOpts = { yes: opts.yes, ...(json ? { json } : {}) };
1371
+ if (outcome.kind === 'updated') {
1372
+ await reExec(outcome.from, outcome.to, deps.npmPrefix, reExecOpts);
1373
+ }
1374
+ else if (outcome.kind === 'up-to-date') {
1375
+ await reExec(outcome.current, outcome.current, deps.npmPrefix, reExecOpts);
1376
+ }
1377
+ }
1378
+ /** commander-коллектор повторяемого `--resolve` (значение может нести список через запятую). */
1379
+ function collectResolve(value, previous) {
1380
+ return [...(previous ?? []), value];
1381
+ }
88
1382
  export function memoryUpgradeCommand() {
89
1383
  return new Command('upgrade')
90
- .description('Upgrade the global wolf installation to the latest npm version (runs npm install -g mister-wolf@latest); --check only compares versions, no install')
1384
+ .description('One command updates everything: npm phase (npm install -g mister-wolf@latest) followed by the registered-projects pass; --check only reports CLI + park freshness, no install; --here runs the full project update step in the current directory only (no npm self-update, no registry); --resolve applies owner decisions (<file|N>=take|keep|protect|pin) without a pass; --show prints one target with the local → base diff (read-only); --review walks the decisions with a diff (TTY) or prints the recipe; --json prints a single machine-readable document')
91
1385
  .option('--check', 'Only check for a newer version, do not install anything')
1386
+ .option('--here', 'Update this project only: full project step in cwd (no npm, no re-exec, no registry)')
1387
+ .option('--yes', 'Apply the plan without interactive confirmation (--here or the projects pass)')
1388
+ .option('--resolve <target=verb>', 'Resolve a decision: <file|N>=take|keep|protect|pin (repeatable, comma-separated)', collectResolve)
1389
+ .option('--show <file|N>', 'Show one target: protect, both versions and the local → base diff (read-only)')
1390
+ .option('--review', 'Review decisions one by one with a diff (TTY); without a TTY prints the recipe')
1391
+ .option('--json', 'Print a single machine-readable document (wolf.upgrade/1) on stdout')
92
1392
  .action(async (options) => {
93
- const checkOnly = options.check === true;
1393
+ const json = options.json === true;
1394
+ const route = upgradeRoute(options);
1395
+ if (route.error !== undefined) {
1396
+ // §4.13: при --json stdout содержит единственный документ с error
1397
+ if (json)
1398
+ printJson(npmFailureJsonDoc(readInstalledVersion(), route.error));
1399
+ throw new UserFacingError(route.error);
1400
+ }
1401
+ const cwd = safeCwd();
1402
+ if (route.mode === 'review') {
1403
+ await upgradeReviewAction(options.here === true);
1404
+ return; // non-TTY ничего не пишет; TTY применяет по решениям (REQ-07)
1405
+ }
1406
+ if (route.mode === 'show') {
1407
+ await upgradeShowAction(options.show, options.here === true, json);
1408
+ return; // read-only: файлы, свидетель и бэкапы не трогаются (NFR-03)
1409
+ }
1410
+ if (route.mode === 'resolve') {
1411
+ // Решения применяются в фазе 1: без npm-фазы, re-exec и прохода (§4.18)
1412
+ await upgradeResolveAction(options.resolve ?? [], options.here === true, json);
1413
+ return;
1414
+ }
1415
+ if (route.mode === 'here') {
1416
+ await upgradeHereAction(cwd, options.yes === true, json);
1417
+ return; // npm-ветка при --here не выполняется вообще (REQ-14)
1418
+ }
1419
+ const checkOnly = route.mode === 'check';
1420
+ // npm view один на запуск: CLI-часть и парковая сводка делят результат
1421
+ let available = null;
1422
+ const viewOnce = async () => {
1423
+ if (available === null)
1424
+ available = await registryVersion();
1425
+ return available;
1426
+ };
94
1427
  const deps = {
95
1428
  currentVersion: readInstalledVersion(),
96
1429
  binaryPath: process.argv[1] ? realPathOrNull(process.argv[1]) : null,
97
1430
  npmPrefix: checkOnly ? null : await npmGlobalPrefix(), // --check тип установки не проверяет
98
- registryVersion,
1431
+ registryVersion: viewOnce,
99
1432
  installLatest,
100
1433
  };
101
- printOutcome(await runUpgrade(deps, checkOnly));
1434
+ if (route.mode === 'check' && options.here === true) {
1435
+ // §4.11: `--here --check` — отдельный read-only проход по cwd, без
1436
+ // CLI/npm-фазы, реестра, sync-state и бэкапов (диспетчеризация в upgradeRoute)
1437
+ await upgradeCheckHereAction(json);
1438
+ return;
1439
+ }
1440
+ if (route.mode === 'check') {
1441
+ // §4.13: при --json фаза 1 молчит — единственный документ печатает check
1442
+ if (!json)
1443
+ printOutcome(await runUpgrade(deps, true));
1444
+ // порядок: CLI-версии → парк (REQ-08)
1445
+ await upgradeCheckAction(viewOnce, json);
1446
+ return;
1447
+ }
1448
+ // npm-фаза + re-exec фазы 2 (T8): режимы выше вышли раньше, re-exec там нет
1449
+ await runUpgradeWithProjects(deps, { yes: options.yes === true, json });
102
1450
  });
103
1451
  }
104
1452
  //# sourceMappingURL=memory-upgrade.js.map