mister-wolf 2.19.0 → 2.20.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 (92) hide show
  1. package/README.md +12 -1
  2. package/README.ru.md +19 -2
  3. package/dist/adapters/cli/commands/memory-doctor.d.ts.map +1 -1
  4. package/dist/adapters/cli/commands/memory-doctor.js.map +1 -1
  5. package/dist/adapters/cli/commands/memory-uninstall.d.ts.map +1 -1
  6. package/dist/adapters/cli/commands/memory-uninstall.js.map +1 -1
  7. package/dist/adapters/cli/commands/memory-upgrade.d.ts +143 -2
  8. package/dist/adapters/cli/commands/memory-upgrade.d.ts.map +1 -1
  9. package/dist/adapters/cli/commands/memory-upgrade.js +887 -41
  10. package/dist/adapters/cli/commands/memory-upgrade.js.map +1 -1
  11. package/dist/adapters/cli/commands/upgrade-projects.d.ts +4 -2
  12. package/dist/adapters/cli/commands/upgrade-projects.d.ts.map +1 -1
  13. package/dist/adapters/cli/commands/upgrade-projects.js +17 -7
  14. package/dist/adapters/cli/commands/upgrade-projects.js.map +1 -1
  15. package/dist/adapters/platforms/index.d.ts.map +1 -1
  16. package/dist/adapters/platforms/index.js +2 -1
  17. package/dist/adapters/platforms/index.js.map +1 -1
  18. package/dist/adapters/platforms/opencode-adapter.d.ts +13 -4
  19. package/dist/adapters/platforms/opencode-adapter.d.ts.map +1 -1
  20. package/dist/adapters/platforms/opencode-adapter.js +278 -55
  21. package/dist/adapters/platforms/opencode-adapter.js.map +1 -1
  22. package/dist/adapters/platforms/opencode-config-keys.d.ts +78 -0
  23. package/dist/adapters/platforms/opencode-config-keys.d.ts.map +1 -0
  24. package/dist/adapters/platforms/opencode-config-keys.js +169 -0
  25. package/dist/adapters/platforms/opencode-config-keys.js.map +1 -0
  26. package/dist/adapters/render/opencode/opencode-renderer.d.ts +35 -7
  27. package/dist/adapters/render/opencode/opencode-renderer.d.ts.map +1 -1
  28. package/dist/adapters/render/opencode/opencode-renderer.js +395 -115
  29. package/dist/adapters/render/opencode/opencode-renderer.js.map +1 -1
  30. package/dist/adapters/render/stamp.d.ts +2 -0
  31. package/dist/adapters/render/stamp.d.ts.map +1 -1
  32. package/dist/adapters/render/stamp.js +9 -1
  33. package/dist/adapters/render/stamp.js.map +1 -1
  34. package/dist/adapters/render/sync-state.d.ts +15 -3
  35. package/dist/adapters/render/sync-state.d.ts.map +1 -1
  36. package/dist/adapters/render/sync-state.js +52 -8
  37. package/dist/adapters/render/sync-state.js.map +1 -1
  38. package/dist/app/use-cases/decisions.d.ts +247 -0
  39. package/dist/app/use-cases/decisions.d.ts.map +1 -0
  40. package/dist/app/use-cases/decisions.js +528 -0
  41. package/dist/app/use-cases/decisions.js.map +1 -0
  42. package/dist/app/use-cases/doctor.d.ts.map +1 -1
  43. package/dist/app/use-cases/doctor.js +6 -0
  44. package/dist/app/use-cases/doctor.js.map +1 -1
  45. package/dist/app/use-cases/init-project.d.ts.map +1 -1
  46. package/dist/app/use-cases/init-project.js +39 -3
  47. package/dist/app/use-cases/init-project.js.map +1 -1
  48. package/dist/app/use-cases/platform-mismatch.d.ts +2 -1
  49. package/dist/app/use-cases/platform-mismatch.d.ts.map +1 -1
  50. package/dist/app/use-cases/platform-mismatch.js.map +1 -1
  51. package/dist/app/use-cases/project-step.d.ts +20 -1
  52. package/dist/app/use-cases/project-step.d.ts.map +1 -1
  53. package/dist/app/use-cases/project-step.js +101 -15
  54. package/dist/app/use-cases/project-step.js.map +1 -1
  55. package/dist/app/use-cases/repair-platform.d.ts +2 -1
  56. package/dist/app/use-cases/repair-platform.d.ts.map +1 -1
  57. package/dist/app/use-cases/repair-platform.js +4 -1
  58. package/dist/app/use-cases/repair-platform.js.map +1 -1
  59. package/dist/app/use-cases/sync-base-set.d.ts +4 -2
  60. package/dist/app/use-cases/sync-base-set.d.ts.map +1 -1
  61. package/dist/app/use-cases/sync-base-set.js +4 -2
  62. package/dist/app/use-cases/sync-base-set.js.map +1 -1
  63. package/dist/app/use-cases/tool-librarian.d.ts.map +1 -1
  64. package/dist/app/use-cases/tool-librarian.js +3 -1
  65. package/dist/app/use-cases/tool-librarian.js.map +1 -1
  66. package/dist/app/use-cases/uninstall.d.ts +2 -1
  67. package/dist/app/use-cases/uninstall.d.ts.map +1 -1
  68. package/dist/app/use-cases/uninstall.js.map +1 -1
  69. package/dist/app/use-cases/upgrade-projects.d.ts +30 -0
  70. package/dist/app/use-cases/upgrade-projects.d.ts.map +1 -1
  71. package/dist/app/use-cases/upgrade-projects.js +119 -4
  72. package/dist/app/use-cases/upgrade-projects.js.map +1 -1
  73. package/dist/app/use-cases/verify-against-map.d.ts +5 -1
  74. package/dist/app/use-cases/verify-against-map.d.ts.map +1 -1
  75. package/dist/app/use-cases/verify-against-map.js +34 -6
  76. package/dist/app/use-cases/verify-against-map.js.map +1 -1
  77. package/dist/domain/line-diff.d.ts +36 -0
  78. package/dist/domain/line-diff.d.ts.map +1 -0
  79. package/dist/domain/line-diff.js +175 -0
  80. package/dist/domain/line-diff.js.map +1 -0
  81. package/dist/domain/protect-key.d.ts +21 -0
  82. package/dist/domain/protect-key.d.ts.map +1 -0
  83. package/dist/domain/protect-key.js +112 -0
  84. package/dist/domain/protect-key.js.map +1 -0
  85. package/dist/ports/base-set-renderer.port.d.ts +51 -3
  86. package/dist/ports/base-set-renderer.port.d.ts.map +1 -1
  87. package/dist/ports/platform-adapter.port.d.ts +8 -2
  88. package/dist/ports/platform-adapter.port.d.ts.map +1 -1
  89. package/package.json +1 -1
  90. package/templates/.wolf/router.log +1 -1
  91. package/templates/base/AGENTS.md +6 -3
  92. package/update-map.json +2 -2
@@ -8,7 +8,10 @@ import { Command } from 'commander';
8
8
  import { runUpgrade } from '../../../app/use-cases/upgrade.js';
9
9
  import { UserFacingError } from '../../../domain/errors.js';
10
10
  import { runProjectStep } from '../../../app/use-cases/project-step.js';
11
- import { mapCategories, STATUS_TEXT, statusOf } from '../../../app/use-cases/upgrade-projects.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';
12
15
  import { projectPath } from '../../../app/use-cases/verify-against-map.js';
13
16
  import { compareVersions, loadUpdateMap } from '../../fs/update-map.js';
14
17
  import { ProjectsRegistry } from '../../fs/projects-registry.js';
@@ -152,7 +155,7 @@ function planSection(cwd, map) {
152
155
  }
153
156
  /** Детали шага одного проекта (REQ-05): base-set/лица/схема/сверка — формат
154
157
  * строк T7 projectsSection. */
155
- function stepDetailLines(report) {
158
+ function stepDetailLines(report, map) {
156
159
  const out = [];
157
160
  const counts = { updated: 0, created: 0, appended: 0, identical: 0, mutated: 0, conflict: 0 };
158
161
  for (const o of report.sync.outcomes) {
@@ -186,6 +189,8 @@ function stepDetailLines(report) {
186
189
  if (v.errors > 0)
187
190
  parts.push(`${v.errors} errors`);
188
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: файл + путь бэкапа
189
194
  return out;
190
195
  }
191
196
  /** Счётчик статуса одного проекта — лейблы паркового словаря T7. */
@@ -220,6 +225,14 @@ export async function runUpgradeHere(deps, opts = {}) {
220
225
  return {
221
226
  exitCode: 0,
222
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
+ },
223
236
  };
224
237
  }
225
238
  const confirmedRemovals = map.files.filter((f) => f.category === 'removed').map(projectPath);
@@ -227,7 +240,10 @@ export async function runUpgradeHere(deps, opts = {}) {
227
240
  let report = null;
228
241
  let error = null;
229
242
  try {
230
- report = await runStep(deps.makeStepDeps(deps.cwd), deps.cwd, map, { confirmedRemovals });
243
+ report = await runStep(deps.makeStepDeps(deps.cwd), deps.cwd, map, {
244
+ confirmedRemovals,
245
+ takeMutated: opts.yes === true, // §4.12: --yes = тихий take с бэкапом и в --here
246
+ });
231
247
  }
232
248
  catch (err) {
233
249
  error = msg(err); // изоляция сбоя шага: error-строка, не крах команды (REQ-06)
@@ -250,11 +266,26 @@ export async function runUpgradeHere(deps, opts = {}) {
250
266
  const children = [
251
267
  plan,
252
268
  line(marker, error !== null ? `${basename(deps.cwd)} — error: ${error}` : `${basename(deps.cwd)} — ${STATUS_TEXT[status]}`, status === 'up-to-date'),
253
- ...(report ? stepDetailLines(report) : []),
269
+ ...(report ? stepDetailLines(report, map) : []),
254
270
  { kind: 'summary', counters, done: false },
255
271
  line(marker, finalText),
256
272
  ];
257
- return { exitCode, report: { kind: 'section', title: HERE_TITLE, children } };
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 };
258
289
  }
259
290
  /** Прод-deps шага для root (контейнер-прецедент по адаптерам, но БЕЗ
260
291
  * createCliContainer: его selfHealRegistry пишет реестр, а --here реестр
@@ -298,7 +329,7 @@ export function termEnv(stdout, env) {
298
329
  }
299
330
  /** CLI-обвязка --here: прод-deps + TTY-подтверждение (план показан ДО вопроса,
300
331
  * REQ-02) + печать отчёта renderer'ом и простановка exit-кода. */
301
- async function upgradeHereAction(cwd, yes) {
332
+ async function upgradeHereAction(cwd, yes, json = false) {
302
333
  const isTty = Boolean(process.stdout.isTTY);
303
334
  const env = termEnv(process.stdout, process.env);
304
335
  const confirm = isTty
@@ -314,10 +345,504 @@ async function upgradeHereAction(cwd, yes) {
314
345
  }
315
346
  }
316
347
  : undefined;
317
- const res = await runUpgradeHere({ cwd, loadMap: () => loadUpdateMap(), makeStepDeps: hereStepDeps }, { yes, tty: isTty, confirm });
318
- process.stdout.write(`${renderReport(res.report, env)}\n`);
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
+ }
319
368
  process.exitCode = res.exitCode;
320
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
+ }
321
846
  const CLASS_TEXT = {
322
847
  current: 'current',
323
848
  behind: 'behind',
@@ -340,7 +865,14 @@ export function classifyProject(map, outcomes, removedExisting) {
340
865
  removedPresent: [...removedExisting],
341
866
  deprecated: [],
342
867
  outsideMap: [],
868
+ protected: [],
869
+ kept: [],
870
+ details: [],
343
871
  };
872
+ for (const o of outcomes) {
873
+ if (o.detail)
874
+ out.details.push({ file: o.file, detail: o.detail });
875
+ }
344
876
  const byFile = new Map(outcomes.map((o) => [o.file, o.action]));
345
877
  const targetRels = new Set();
346
878
  const absorb = (rel, action) => {
@@ -354,6 +886,10 @@ export function classifyProject(map, outcomes, removedExisting) {
354
886
  out.differs.push(rel);
355
887
  else if (action === 'missing')
356
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);
357
893
  };
358
894
  for (const entry of map.files) {
359
895
  const rel = projectPath(entry);
@@ -384,8 +920,9 @@ const CHECK_TITLE = 'wolf upgrade — check';
384
920
  /** Детали проекта одной строкой: `2 differ · 1 missing · mutated: …`. */
385
921
  function freshnessDetails(f) {
386
922
  const parts = [];
923
+ // REQ-01 AC2: путь каждого differ — не выборка и не усечение (§4.15).
387
924
  if (f.differs.length > 0)
388
- parts.push(`${f.differs.length} differ`);
925
+ parts.push(`${f.differs.length} differ: ${f.differs.join(', ')}`);
389
926
  if (f.missing.length > 0)
390
927
  parts.push(`${f.missing.length} missing`);
391
928
  if (f.mutated.length > 0)
@@ -398,6 +935,10 @@ function freshnessDetails(f) {
398
935
  parts.push(`removed present: ${f.removedPresent.join(', ')}`);
399
936
  if (f.outsideMap.length > 0)
400
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)`);
401
942
  return parts.length > 0 ? parts.join(' · ') : null;
402
943
  }
403
944
  const stampNote = (root) => {
@@ -405,12 +946,46 @@ const stampNote = (root) => {
405
946
  const floor = stamps.min !== null && stamps.min !== stamps.max ? ` (stamps from ${stamps.min})` : '';
406
947
  return `stamps ${stamps.max ?? 'unknown'}${floor}`;
407
948
  };
408
- /** Секция одного проекта (REQ-08): имя · путь · класс · штамп/состав (справочно) ·
409
- * детали · deprecated-строка. Сбой инспекции — error-строка, не крах (REQ-06). */
410
- async function projectCheckLines(entry, map, deps) {
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) {
411
982
  const name = basename(entry.path);
983
+ const protections = deps.protections ? jsonProtections(await deps.protections(entry.path)) : [];
412
984
  if (!existsSync(entry.path)) {
413
- return [line('info', `${name} · ${entry.path} — ${CLASS_TEXT.unknown} (path does not exist)`, true)];
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
+ };
414
989
  }
415
990
  try {
416
991
  const outcomes = await deps.inspect(entry.path);
@@ -426,27 +1001,51 @@ async function projectCheckLines(entry, map, deps) {
426
1001
  const details = freshnessDetails(f);
427
1002
  if (details)
428
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
+ }
429
1007
  if (f.deprecated.length > 0) {
430
1008
  lines.push(line('info', `deprecated: ${f.deprecated.join(', ')} (report-only)`, true));
431
1009
  }
432
- return lines;
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
+ };
433
1024
  }
434
1025
  catch (err) {
435
- return [line('error', `${name} — error: ${msg(err)}`)];
1026
+ return {
1027
+ lines: [line('error', `${name} — error: ${msg(err)}`)],
1028
+ row: emptyCheckRow(name, entry.path, protections),
1029
+ };
436
1030
  }
437
1031
  }
438
1032
  /** `--check`-сводка парка (REQ-08): CLI-строка (installed vs available) →
439
1033
  * перечисление проектов реестра. Деградации не крахят: битый реестр/npm —
440
1034
  * error-строка; карты нет — штампы с пометкой недостоверности и классом
441
- * unknown (никогда current); пустой реестр — «no projects». exit 0. */
1035
+ * unknown (никогда current); пустой реестр — «no projects». exit 0.
1036
+ * Документ `--json` строится из тех же строк (§4.13: текст и JSON не расходятся). */
442
1037
  export async function runUpgradeCheck(deps) {
443
1038
  const children = [];
1039
+ const rows = [];
1040
+ let available = null;
444
1041
  try {
445
- children.push(line('info', `cli: installed ${deps.installedVersion()} · available ${await deps.registryVersion()}`));
1042
+ available = await deps.registryVersion();
1043
+ children.push(line('info', `cli: installed ${deps.installedVersion()} · available ${available}`));
446
1044
  }
447
1045
  catch (err) {
448
1046
  children.push(line('error', `cli: installed ${deps.installedVersion()} · available unknown — ${msg(err)}`));
449
1047
  }
1048
+ let mapVersion = null;
450
1049
  let registered = null;
451
1050
  try {
452
1051
  registered = await deps.registry.listStrict();
@@ -467,8 +1066,12 @@ export async function runUpgradeCheck(deps) {
467
1066
  map = null;
468
1067
  }
469
1068
  if (map) {
470
- for (const entry of registered)
471
- children.push(...(await projectCheckLines(entry, map, deps)));
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
+ }
472
1075
  }
473
1076
  else {
474
1077
  // Деградация (design §«--check»): карты нет у самого CLI — только штампы
@@ -477,29 +1080,213 @@ export async function runUpgradeCheck(deps) {
477
1080
  for (const entry of registered) {
478
1081
  children.push(line('info', `${basename(entry.path)} · ${entry.path} — ${CLASS_TEXT.unknown}`));
479
1082
  children.push(line('info', stampNote(entry.path), true));
1083
+ rows.push(emptyCheckRow(basename(entry.path), entry.path, []));
480
1084
  }
481
1085
  }
482
1086
  }
483
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));
484
1093
  children.push(line('ok', 'Done (read-only)'));
485
- return { exitCode: 0, report: { kind: 'section', title: CHECK_TITLE, children } };
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
+ };
486
1106
  }
487
- /** CLI-обвязка check-сводки (REQ-08): реестр — как projects (wolfUserConfigDir),
488
- * инспекция — read-only inspectBaseSet ('omit' — паритет sync-рендера);
489
- * exit не меняется (--check всегда 0). */
490
- async function upgradeCheckAction(viewVersion) {
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();
491
1150
  const renderer = new OpencodeBaseSetRenderer(templatesRoot(), {
492
1151
  harnessTemplatesRoot: harnessTemplatesRoot('opencode'),
493
1152
  });
494
- const res = await runUpgradeCheck({
495
- registry: new ProjectsRegistry(wolfUserConfigDir()),
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,
496
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
+ },
497
1204
  registryVersion: viewVersion,
498
1205
  installedVersion: readInstalledVersion,
499
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'),
500
1234
  });
501
- process.stdout.write(`${renderReport(res.report, termEnv(process.stdout, process.env))}\n`);
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
+ };
502
1288
  }
1289
+ const npmFailureJsonDoc = (installed, error) => phase1JsonDoc(installed, null, 1, error);
503
1290
  /** Re-exec нового бинарника (ADR-2, «Контракт spawn»): state-файл
504
1291
  * `{ from, to, startedAt }` пишется ДО spawn; spawn без shell со stdio
505
1292
  * inherit и env: process.env (склейка фаз, REQ-15); код родителя = код
@@ -522,6 +1309,9 @@ export async function reExecUpgradeProjects(from, to, npmPrefix, opts = {}) {
522
1309
  '--no-banner',
523
1310
  '--state',
524
1311
  statePath,
1312
+ // §4.18: единственный флаг вывода, который доходит до ребёнка; режимные
1313
+ // флаги (--resolve/--show/--review/--check) до re-exec не доходят вовсе.
1314
+ ...(opts.json === true ? ['--json'] : []),
525
1315
  ];
526
1316
  const child = (opts.spawnImpl ?? spawn)(childBin, argv, { stdio: 'inherit', env: process.env });
527
1317
  const exit = opts.exit ?? ((code) => process.exit(code));
@@ -549,37 +1339,84 @@ export async function reExecUpgradeProjects(from, to, npmPrefix, opts = {}) {
549
1339
  * реестру нечему обновляться, spawn не нужен (exit 0); linked-refusal —
550
1340
  * отказ printOutcome. reExec-шов — юнит/R01.3-контур (шпион вместо spawn). */
551
1341
  export async function runUpgradeWithProjects(deps, opts = {}) {
1342
+ const json = opts.json === true;
552
1343
  const env = termEnv(process.stdout, process.env);
553
- process.stdout.write(`${renderReport({ kind: 'banner', version: deps.currentVersion }, env)}\n`);
1344
+ if (!json)
1345
+ process.stdout.write(`${renderReport({ kind: 'banner', version: deps.currentVersion }, env)}\n`);
554
1346
  let outcome;
555
1347
  try {
556
1348
  outcome = await runUpgrade(deps, false);
557
1349
  }
558
1350
  catch (err) {
559
- console.log('└ Failed'); // секция закрыта; Error:-строку и exit 1 даст runCli
1351
+ if (json)
1352
+ printJson(npmFailureJsonDoc(deps.currentVersion, msg(err)));
1353
+ else
1354
+ console.log('└ Failed'); // секция закрыта; Error:-строку и exit 1 даст runCli
560
1355
  throw err;
561
1356
  }
562
- printOutcome(outcome);
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
+ }
563
1369
  const reExec = deps.reExec ?? reExecUpgradeProjects;
1370
+ const reExecOpts = { yes: opts.yes, ...(json ? { json } : {}) };
564
1371
  if (outcome.kind === 'updated') {
565
- await reExec(outcome.from, outcome.to, deps.npmPrefix, { yes: opts.yes });
1372
+ await reExec(outcome.from, outcome.to, deps.npmPrefix, reExecOpts);
566
1373
  }
567
1374
  else if (outcome.kind === 'up-to-date') {
568
- await reExec(outcome.current, outcome.current, deps.npmPrefix, { yes: opts.yes });
1375
+ await reExec(outcome.current, outcome.current, deps.npmPrefix, reExecOpts);
569
1376
  }
570
1377
  }
1378
+ /** commander-коллектор повторяемого `--resolve` (значение может нести список через запятую). */
1379
+ function collectResolve(value, previous) {
1380
+ return [...(previous ?? []), value];
1381
+ }
571
1382
  export function memoryUpgradeCommand() {
572
1383
  return new Command('upgrade')
573
- .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)')
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')
574
1385
  .option('--check', 'Only check for a newer version, do not install anything')
575
1386
  .option('--here', 'Update this project only: full project step in cwd (no npm, no re-exec, no registry)')
576
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')
577
1392
  .action(async (options) => {
578
- if (options.here === true) {
579
- await upgradeHereAction(safeCwd(), options.yes === 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);
580
1417
  return; // npm-ветка при --here не выполняется вообще (REQ-14)
581
1418
  }
582
- const checkOnly = options.check === true;
1419
+ const checkOnly = route.mode === 'check';
583
1420
  // npm view один на запуск: CLI-часть и парковая сводка делят результат
584
1421
  let available = null;
585
1422
  const viewOnce = async () => {
@@ -594,13 +1431,22 @@ export function memoryUpgradeCommand() {
594
1431
  registryVersion: viewOnce,
595
1432
  installLatest,
596
1433
  };
597
- if (options.check === true) {
598
- printOutcome(await runUpgrade(deps, true));
599
- await upgradeCheckAction(viewOnce); // порядок: CLI-версии → парк (REQ-08)
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);
600
1446
  return;
601
1447
  }
602
- // npm-фаза + re-exec фазы 2 (T8): --here выше вышел раньше, re-exec там нет
603
- await runUpgradeWithProjects(deps, { yes: options.yes === true });
1448
+ // npm-фаза + re-exec фазы 2 (T8): режимы выше вышли раньше, re-exec там нет
1449
+ await runUpgradeWithProjects(deps, { yes: options.yes === true, json });
604
1450
  });
605
1451
  }
606
1452
  //# sourceMappingURL=memory-upgrade.js.map