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
@@ -4,8 +4,9 @@ import { basename, join, relative } from 'path';
4
4
  import { mkdir } from 'fs/promises';
5
5
  import { writeFileAtomic } from '../../fs/markdown-memory-store.js';
6
6
  import { OPENCODE_MANIFEST, substitute } from '../manifest.js';
7
- import { insertStamp, parseStamp } from '../stamp.js';
7
+ import { insertStamp, parseStamp, renderStamp, stripStampLines } from '../stamp.js';
8
8
  import { loadSyncState, saveSyncState, sha256 } from '../sync-state.js';
9
+ import { normalizeManaged, parseProtect } from '../../../domain/protect-key.js';
9
10
  import { wolfVersion } from '../templates-root.js';
10
11
  const NEUTRAL_DIRS = ['agents', 'skills', 'commands'];
11
12
  const LAYOUT = {
@@ -13,8 +14,47 @@ const LAYOUT = {
13
14
  skills: '.opencode/skills',
14
15
  commands: '.opencode/command',
15
16
  };
16
- /** Маркер онбординг-блока в AGENTS.md (спека §4.2). */
17
- const ONBOARDING_MARKER = '<!-- wolf:onboarding v2 -->';
17
+ /** Детект онбординг-блока версионно-агностичен (§4.5): литерал `vN` в коде
18
+ * запрещён, иначе после смены версии шаблона существующий блок не распознаётся
19
+ * и init/append допишет второй. Версия формата — только в шаблоне (ADR-3). */
20
+ const ONBOARDING_OPEN = /^<!-- wolf:onboarding v(\d+) -->$/;
21
+ const ONBOARDING_CLOSE = /^<!-- \/wolf:onboarding -->$/;
22
+ /** Блок между маркерами: null — блока нет, 'broken' — открывающий без закрывающего. */
23
+ function findOnboardingBlock(content) {
24
+ let offset = 0;
25
+ let start = -1;
26
+ let end = -1;
27
+ let version = 0;
28
+ for (const line of content.split('\n')) {
29
+ const text = line.replace(/\r$/, '');
30
+ if (start === -1) {
31
+ const m = text.match(ONBOARDING_OPEN);
32
+ if (m) {
33
+ start = offset;
34
+ version = Number(m[1]);
35
+ }
36
+ }
37
+ else if (end === -1 && ONBOARDING_CLOSE.test(text)) {
38
+ end = offset + line.length;
39
+ }
40
+ offset += line.length + 1;
41
+ }
42
+ if (start === -1)
43
+ return null;
44
+ return end === -1 ? 'broken' : { version, start, end };
45
+ }
46
+ /** Блок шаблона: единственный источник версии формата и текста обновления. */
47
+ function templateBlock(template) {
48
+ const block = findOnboardingBlock(template);
49
+ if (block === null || block === 'broken')
50
+ return null;
51
+ return { version: block.version, text: template.slice(block.start, block.end) };
52
+ }
53
+ const MUTATED_REASON = 'mutated after render — differs from recorded render and fresh render; left untouched';
54
+ /** Легаси-строка свидетеля → запись (loadSyncState уже нормализует; защита для типов). */
55
+ const asEntry = (v) => v === undefined ? undefined : typeof v === 'string' ? { hash: v, origin: 'render', base: '0.0.0' } : v;
56
+ /** Значение ключа для дайджеста: `true` | `X.Y.Z` | raw вне грамматики. */
57
+ const protectValue = (p) => p.value ?? p.raw ?? '';
18
58
  export class OpencodeBaseSetRenderer {
19
59
  baseTemplatesRoot;
20
60
  opts;
@@ -68,88 +108,136 @@ export class OpencodeBaseSetRenderer {
68
108
  const state = loadSyncState(baseDir);
69
109
  for (const f of this.templateFiles(baseDir)) {
70
110
  const rel = relative(baseDir, f.target);
71
- if (existsSync(f.target)) {
72
- // diff-ветка (§4.5): unstamped — чужое (wx); stamped — сравнение контента
73
- const current = readFileSync(f.target, 'utf-8');
74
- if (parseStamp(current) === null) {
111
+ const fresh = this.renderContent(f, opts?.bake, opts?.models);
112
+ const d = this.classify(rel, f.target, fresh, state);
113
+ switch (d.state) {
114
+ case 'missing':
115
+ await this.writeFresh(f.target, fresh);
116
+ this.record(state, rel, fresh);
117
+ outcomes.push({ file: rel, action: 'created' });
118
+ break;
119
+ case 'protected':
120
+ outcomes.push({ file: rel, action: 'skipped', protected: true, protect: protectValue(d.protect) });
121
+ break;
122
+ case 'unstamped':
123
+ // diff-ветка (§4.5): unstamped — чужое (wx)
75
124
  outcomes.push({ file: rel, action: 'skipped', reason: 'exists, unstamped (wx policy)' });
76
- continue;
77
- }
78
- const fresh = this.renderContent(f, opts?.bake, opts?.models);
79
- if (fresh === current) {
80
- state.files[rel] = sha256(fresh);
125
+ break;
126
+ case 'identical':
127
+ this.record(state, rel, fresh);
81
128
  outcomes.push({ file: rel, action: 'skipped', reason: 'content identical' });
82
- }
83
- else if (state.files[rel] !== undefined && sha256(current) !== state.files[rel]) {
129
+ break;
130
+ case 'heal':
131
+ await this.writeFresh(f.target, fresh);
132
+ this.record(state, rel, fresh);
133
+ outcomes.push({ file: rel, action: 'updated', reason: `stamp drift healed (base ${this.setVersion})` });
134
+ break;
135
+ case 'mutated':
84
136
  // K6 (спека 2.16 §4): локальная правка штампованного файла не перебивается
85
137
  // re-init — тот же mutated-skip, что в syncBaseSet (T010); blind reseed запрещён
138
+ outcomes.push({ file: rel, action: 'skipped', mutated: true, reason: MUTATED_REASON });
139
+ break;
140
+ case 'kept':
86
141
  outcomes.push({
87
142
  file: rel,
88
143
  action: 'skipped',
89
- mutated: true,
90
- reason: 'mutated after render — differs from recorded render and fresh render; left untouched',
144
+ kept: true,
145
+ reason: `locally accepted (keep) at ${d.entry.base}`,
91
146
  });
92
- }
93
- else {
94
- await writeFileAtomic(f.target, fresh);
95
- state.files[rel] = sha256(fresh);
147
+ break;
148
+ case 'rewrite':
149
+ await this.writeFresh(f.target, fresh);
150
+ this.record(state, rel, fresh);
96
151
  outcomes.push({ file: rel, action: 'updated', reason: 'content differs (diff branch, §4.5)' });
97
- }
98
- continue;
152
+ break;
99
153
  }
100
- state.files[rel] = sha256(await this.writeRendered(f, opts?.bake, opts?.models));
101
- outcomes.push({ file: rel, action: 'created' });
102
154
  }
103
155
  outcomes.push(...(await this.renderAgentsMd(baseDir, opts?.models, state)));
104
156
  saveSyncState(baseDir, state);
105
157
  return outcomes;
106
158
  }
107
- async syncBaseSet(baseDir, models) {
159
+ async syncBaseSet(baseDir, models, opts) {
108
160
  const ctx = models ?? 'omit'; // легаси без routing-объекта — omit (§4.5)
161
+ const backup = opts?.takeMutated?.backup;
109
162
  const outcomes = [];
110
163
  const seen = new Set();
111
164
  const state = loadSyncState(baseDir);
112
165
  for (const f of this.templateFiles(baseDir)) {
113
166
  seen.add(f.target);
114
167
  const rel = relative(baseDir, f.target);
115
- if (!existsSync(f.target)) {
116
- state.files[rel] = sha256(await this.writeRendered(f, undefined, ctx));
117
- outcomes.push({ file: rel, action: 'created' });
118
- continue;
119
- }
120
- const current = readFileSync(f.target, 'utf-8');
121
- if (parseStamp(current) === null) {
122
- outcomes.push({
123
- file: rel,
124
- action: 'conflict',
125
- reason: 'unstamped in place of a template one — the owner decides: rename / delete / accept the current one',
126
- });
127
- continue;
128
- }
129
168
  const fresh = this.renderContent(f, undefined, ctx);
130
- if (fresh === current) {
131
- state.files[rel] = sha256(fresh);
132
- outcomes.push({ file: rel, action: 'skipped', reason: 'content identical (M2)' });
133
- continue;
134
- }
135
- // T010 mutated-skip: контент отличается и от записанного рендера (свидетель) —
136
- // это локальная правка, не сдвиг канона; файл не трогаем, хэш не меняем.
137
- const recorded = state.files[rel];
138
- if (recorded !== undefined && sha256(current) !== recorded) {
139
- outcomes.push({
140
- file: rel,
141
- action: 'skipped',
142
- mutated: true,
143
- reason: 'mutated after render — differs from recorded render and fresh render; left untouched',
144
- });
145
- continue;
169
+ const d = this.classify(rel, f.target, fresh, state);
170
+ switch (d.state) {
171
+ case 'missing':
172
+ await this.writeFresh(f.target, fresh);
173
+ this.record(state, rel, fresh);
174
+ outcomes.push({ file: rel, action: 'created' });
175
+ break;
176
+ case 'protected':
177
+ outcomes.push({
178
+ file: rel,
179
+ action: 'skipped',
180
+ protected: true,
181
+ protect: protectValue(d.protect),
182
+ reason: d.protect.unrecognized
183
+ ? 'held by protect (unrecognized value — treated as protected)'
184
+ : 'held by protect',
185
+ });
186
+ break;
187
+ case 'unstamped':
188
+ outcomes.push({
189
+ file: rel,
190
+ action: 'conflict',
191
+ reason: 'unstamped in place of a template one — the owner decides: rename / delete / accept the current one',
192
+ });
193
+ break;
194
+ case 'identical':
195
+ this.record(state, rel, fresh);
196
+ outcomes.push({ file: rel, action: 'skipped', reason: 'content identical (M2)' });
197
+ break;
198
+ case 'heal':
199
+ await this.writeFresh(f.target, fresh);
200
+ this.record(state, rel, fresh);
201
+ outcomes.push({ file: rel, action: 'updated', reason: `stamp drift healed (base ${this.setVersion})` });
202
+ break;
203
+ case 'mutated': {
204
+ if (!backup) {
205
+ outcomes.push({ file: rel, action: 'skipped', mutated: true, reason: MUTATED_REASON });
206
+ break;
207
+ }
208
+ // Правило 6′ §4.3/§4.12: бэкап → эталон → свидетель (порядок обязателен, NFR-05)
209
+ const backupPath = await backup(rel, d.current);
210
+ await this.writeFresh(f.target, fresh);
211
+ this.record(state, rel, fresh);
212
+ outcomes.push({
213
+ file: rel,
214
+ action: 'updated',
215
+ backup: backupPath,
216
+ reason: 'take-on-mutated (--yes): local copy saved',
217
+ });
218
+ break;
219
+ }
220
+ case 'kept': {
221
+ const base = d.entry.base;
222
+ outcomes.push({
223
+ file: rel,
224
+ action: 'skipped',
225
+ kept: true,
226
+ reason: `locally accepted (keep) at ${base}${base !== this.setVersion ? `, base ${this.setVersion}` : ''}`,
227
+ });
228
+ break;
229
+ }
230
+ case 'rewrite': {
231
+ // Правило 7: записи свидетеля нет — локальный контент сначала в бэкап (NFR-05).
232
+ const backupPath = !d.entry && backup ? await backup(rel, d.current) : undefined;
233
+ await this.writeFresh(f.target, fresh);
234
+ this.record(state, rel, fresh);
235
+ outcomes.push({ file: rel, action: 'updated', ...(backupPath ? { backup: backupPath } : {}) });
236
+ break;
237
+ }
146
238
  }
147
- await mkdir(join(f.target, '..'), { recursive: true });
148
- await writeFileAtomic(f.target, fresh);
149
- state.files[rel] = sha256(fresh);
150
- outcomes.push({ file: rel, action: 'updated' });
151
239
  }
152
- outcomes.push(...(await this.syncAgentsMd(baseDir, ctx, state)));
240
+ outcomes.push(...(await this.syncAgentsMd(baseDir, ctx, state, backup)));
153
241
  const orphaned = [];
154
242
  const scanRoots = [
155
243
  join(baseDir, '.opencode/agents'),
@@ -168,10 +256,76 @@ export class OpencodeBaseSetRenderer {
168
256
  saveSyncState(baseDir, state);
169
257
  return { outcomes, orphaned };
170
258
  }
259
+ /** Блочный режим AGENTS.md (§4.5): управляется только текст между маркерами;
260
+ * контент выше и ниже — байт-в-байт. protect у AGENTS.md не поддерживается.
261
+ * Штампованная цель с блоком (миграция 2.19.0 → 2.20.0, M-1) после замены
262
+ * блока получает штамп текущей базы и свидетель; `state` передаёт только
263
+ * init-ветка (блочный файл без штампа свидетеля не имеет). */
264
+ async syncAgentsBlock(target, current, template, state) {
265
+ const block = findOnboardingBlock(current);
266
+ if (block === null) {
267
+ return {
268
+ file: 'AGENTS.md',
269
+ action: 'skipped',
270
+ reason: 'no onboarding block — left untouched (wx policy)',
271
+ };
272
+ }
273
+ const tpl = templateBlock(template);
274
+ if (block === 'broken' || tpl === null) {
275
+ return {
276
+ file: 'AGENTS.md',
277
+ action: 'skipped',
278
+ reason: 'onboarding markers broken — file treated as foreign (wx policy)',
279
+ };
280
+ }
281
+ // [start,end) включает завершающий `\r` закрывающего маркера: блок наследует
282
+ // EOL маркера (а не шаблона) и подстановка кончается тем же `\r`, иначе после
283
+ // маркера остаётся LF (m-7).
284
+ const eol = current[block.end - 1] === '\r' ? '\r\n' : '\n';
285
+ const text = eol === '\r\n' ? `${tpl.text.replace(/\n/g, '\r\n')}\r` : tpl.text;
286
+ const replaced = `${current.slice(0, block.start)}${text}${current.slice(block.end)}`;
287
+ const updated = this.restampAgents(replaced);
288
+ if (updated === current) {
289
+ return { file: 'AGENTS.md', action: 'skipped', reason: 'content identical (onboarding block)' };
290
+ }
291
+ await this.writeFresh(target, updated);
292
+ if (state)
293
+ this.recordAgentsBase(state, updated, template);
294
+ const blockChanged = current.slice(block.start, block.end) !== text;
295
+ return {
296
+ file: 'AGENTS.md',
297
+ action: 'updated',
298
+ reason: blockChanged
299
+ ? `onboarding block ${block.version} → ${tpl.version}`
300
+ : `stamp drift healed (base ${this.setVersion})`,
301
+ };
302
+ }
303
+ /** Штампованная цель с блоком: штамп приводится к текущей базе **на своём
304
+ * месте** (позицию задаёт `insertStamp` — после frontmatter, `---` остаётся
305
+ * первой строкой). Без нового регекса штампа (§4.4): канонический текст
306
+ * `renderStamp` заменяется строкой текущей базы; нестандартную запись штампа
307
+ * не переписываем (файл честно останется `mutated`). */
308
+ restampAgents(content) {
309
+ const stamp = parseStamp(content);
310
+ if (stamp === null || stamp.set === this.setVersion)
311
+ return content;
312
+ const old = renderStamp(stamp, 'AGENTS.md');
313
+ if (!content.includes(old))
314
+ return content;
315
+ return content.replace(old, renderStamp({ base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md'));
316
+ }
317
+ /** Свидетель после обновления блока: только если файл совпал с эталоном
318
+ * (EOL отличием не считается — проход приводит его правилом 7). Иначе
319
+ * локальный текст вне блока остаётся решением `mutated` и не перезаписывается. */
320
+ recordAgentsBase(state, written, template) {
321
+ const fresh = insertStamp(template, { base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md');
322
+ if (written === fresh || written.replace(/\r\n/g, '\n') === fresh)
323
+ this.record(state, 'AGENTS.md', written);
324
+ }
171
325
  /** T10 (REQ-08): read-only инспекция целей base-set — предикат того, что
172
326
  * сделает syncBaseSet, БЕЗ записей (ни writeFileAtomic, ни saveSyncState):
173
- * missing / foreign (unstamped, wx-политика) / identical / differs /
174
- * mutated (свидетель sync-state говорит о локальной правке). models по
327
+ * missing / protected / foreign (unstamped, wx-политика) / identical (в т.ч.
328
+ * штамп-дрейф: проходу нечего решать) / differs / mutated / kept. models по
175
329
  * умолчанию 'omit' — паритет с syncBaseSet, чей исход и предсказываем. */
176
330
  async inspectBaseSet(baseDir, opts) {
177
331
  const ctx = opts?.models ?? 'omit'; // легаси без routing-объекта — omit (§4.5)
@@ -179,30 +333,93 @@ export class OpencodeBaseSetRenderer {
179
333
  const outcomes = [];
180
334
  for (const f of this.templateFiles(baseDir)) {
181
335
  const rel = relative(baseDir, f.target);
182
- if (!existsSync(f.target)) {
183
- outcomes.push({ file: rel, action: 'missing' });
184
- continue;
185
- }
186
- const current = readFileSync(f.target, 'utf-8');
187
- if (parseStamp(current) === null) {
188
- outcomes.push({ file: rel, action: 'foreign' });
189
- continue;
190
- }
191
336
  const fresh = this.renderContent(f, opts?.bake, ctx);
192
- if (fresh === current) {
193
- outcomes.push({ file: rel, action: 'identical' });
194
- continue;
337
+ const d = this.classify(rel, f.target, fresh, state);
338
+ switch (d.state) {
339
+ case 'missing':
340
+ outcomes.push({ file: rel, action: 'missing' });
341
+ break;
342
+ case 'protected':
343
+ outcomes.push({
344
+ file: rel,
345
+ action: 'protected',
346
+ protected: true,
347
+ protect: protectValue(d.protect),
348
+ detail: d.protect.unrecognized ? 'protect value is not recognized — treated as protected' : undefined,
349
+ });
350
+ break;
351
+ case 'unstamped':
352
+ outcomes.push({ file: rel, action: 'foreign' });
353
+ break;
354
+ case 'identical':
355
+ case 'heal':
356
+ outcomes.push({ file: rel, action: 'identical' });
357
+ break;
358
+ case 'mutated':
359
+ outcomes.push({ file: rel, action: 'mutated' });
360
+ break;
361
+ case 'kept':
362
+ outcomes.push({ file: rel, action: 'kept', kept: true, keepBase: d.entry.base });
363
+ break;
364
+ case 'rewrite':
365
+ outcomes.push({ file: rel, action: 'differs' });
366
+ break;
195
367
  }
196
- outcomes.push({
197
- file: rel,
198
- action: state.files[rel] !== undefined && sha256(current) !== state.files[rel] ? 'mutated' : 'differs',
199
- });
200
368
  }
201
369
  const agents = this.inspectAgentsMd(baseDir, ctx, state);
202
370
  if (agents)
203
371
  outcomes.push(agents);
204
372
  return outcomes;
205
373
  }
374
+ /** Реестр защит (§4.11, REQ-09): read-only обход каталогов base-set, включая
375
+ * файлы вне карты. AGENTS.md в реестр не входит — protect у него не поддерживается. */
376
+ async listProtections(baseDir, base) {
377
+ const entries = [];
378
+ const roots = [
379
+ join(baseDir, '.opencode/agents'),
380
+ join(baseDir, '.opencode/skills'),
381
+ join(baseDir, '.opencode/command'),
382
+ join(baseDir, '.opencode/plugins'),
383
+ ];
384
+ for (const root of roots) {
385
+ if (!existsSync(root))
386
+ continue;
387
+ for (const abs of this.walkAbs(root)) {
388
+ const current = readFileSync(abs, 'utf-8');
389
+ const protect = parseProtect(current);
390
+ if (!protect.held)
391
+ continue;
392
+ const rel = relative(baseDir, abs);
393
+ const fresh = this.renderFreshFile(baseDir, rel);
394
+ entries.push({
395
+ project: baseDir,
396
+ file: rel,
397
+ value: protectValue(protect),
398
+ recognized: !protect.unrecognized,
399
+ stamp: parseStamp(current)?.set ?? null,
400
+ base,
401
+ behind: fresh === null ? null : normalizeManaged(current) !== fresh,
402
+ });
403
+ }
404
+ }
405
+ return entries.sort((a, b) => a.file.localeCompare(b.file));
406
+ }
407
+ /** Свежий рендер одного файла карты (эталон для `--show` и `behind`); null — вне карты. */
408
+ renderFreshFile(baseDir, rel, opts) {
409
+ // AGENTS.md — цель карты (agents_md), но не файл templateFiles: эталон для
410
+ // `--show`/`take` (design §4.8/§4.10) — штампованный whole-file рендер.
411
+ if (rel === 'AGENTS.md') {
412
+ const paths = this.agentsMdPaths(baseDir);
413
+ if (!paths)
414
+ return null;
415
+ return insertStamp(this.agentsMdContent(paths.tplPath, opts?.models ?? 'omit'), { base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md');
416
+ }
417
+ for (const f of this.templateFiles(baseDir)) {
418
+ if (relative(baseDir, f.target) === rel)
419
+ return this.renderContent(f, opts?.bake, opts?.models ?? 'omit');
420
+ }
421
+ return null;
422
+ }
206
423
  /** Read-only инспекция AGENTS.md (§4.2) — по фактическим веткам управления:
207
424
  * штампованный цельный файл — зона syncAgentsMd (сверка всего файла со
208
425
  * штампом, mutated по свидетелю); без штампа — маркер-блок append-ветки
@@ -216,21 +433,29 @@ export class OpencodeBaseSetRenderer {
216
433
  return { file: 'AGENTS.md', action: 'missing' };
217
434
  const current = readFileSync(paths.target, 'utf-8');
218
435
  const content = this.agentsMdContent(paths.tplPath, models);
219
- const norm = (s) => (s.endsWith('\n') ? s : `${s}\n`);
220
436
  if (parseStamp(current) !== null) {
221
437
  const fresh = insertStamp(content, { base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md');
222
438
  if (fresh === current)
223
439
  return { file: 'AGENTS.md', action: 'identical' };
224
- const recorded = state.files['AGENTS.md'];
225
- return {
226
- file: 'AGENTS.md',
227
- action: recorded !== undefined && sha256(current) !== recorded ? 'mutated' : 'differs',
228
- };
440
+ const entry = asEntry(state.files['AGENTS.md']);
441
+ const hash = sha256(normalizeManaged(current));
442
+ if (entry && entry.hash !== hash)
443
+ return { file: 'AGENTS.md', action: 'mutated' };
444
+ if (entry && entry.origin === 'keep')
445
+ return { file: 'AGENTS.md', action: 'kept', kept: true, keepBase: entry.base };
446
+ return { file: 'AGENTS.md', action: 'differs' };
229
447
  }
230
- const idx = current.indexOf(ONBOARDING_MARKER);
231
- if (idx === -1)
448
+ const block = findOnboardingBlock(current);
449
+ if (block === null)
232
450
  return { file: 'AGENTS.md', action: 'foreign' };
233
- return { file: 'AGENTS.md', action: norm(current.slice(idx)) === norm(content) ? 'identical' : 'differs' };
451
+ if (block === 'broken') {
452
+ return { file: 'AGENTS.md', action: 'foreign', detail: 'onboarding markers broken' };
453
+ }
454
+ const tpl = templateBlock(content);
455
+ if (tpl === null)
456
+ return { file: 'AGENTS.md', action: 'unknown' };
457
+ const same = block.version >= tpl.version && current.slice(block.start, block.end) === tpl.text;
458
+ return { file: 'AGENTS.md', action: same ? 'identical' : 'differs' };
234
459
  }
235
460
  /** AGENTS.md — цель в КОРНЕ проекта (§4.2); шаблона нет — тихо нет исхода. */
236
461
  agentsMdPaths(baseDir) {
@@ -251,56 +476,118 @@ export class OpencodeBaseSetRenderer {
251
476
  const content = this.agentsMdContent(paths.tplPath, models);
252
477
  if (!existsSync(paths.target)) {
253
478
  const written = insertStamp(content, { base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md');
254
- await writeFileAtomic(paths.target, written);
255
- state.files['AGENTS.md'] = sha256(written);
479
+ await this.writeFresh(paths.target, written);
480
+ this.record(state, 'AGENTS.md', written);
256
481
  return [{ file: 'AGENTS.md', action: 'created' }];
257
482
  }
258
483
  const current = readFileSync(paths.target, 'utf-8');
259
- if (current.includes(ONBOARDING_MARKER)) {
260
- return [{ file: 'AGENTS.md', action: 'skipped', reason: 'onboarding marker exists (§4.2)' }];
484
+ if (findOnboardingBlock(current) === 'broken') {
485
+ return [
486
+ {
487
+ file: 'AGENTS.md',
488
+ action: 'skipped',
489
+ reason: 'onboarding markers broken — file treated as foreign (wx policy)',
490
+ },
491
+ ];
492
+ }
493
+ if (findOnboardingBlock(current) !== null) {
494
+ // блок уже есть: и init обновляет его по версии формата (§4.5), а не «вечно пропускает»
495
+ return [await this.syncAgentsBlock(paths.target, current, content, state)];
261
496
  }
262
497
  // marker-append: маркированный блок без штампа в конец; чужой контент не трогаем
263
498
  const block = content.endsWith('\n') ? content : `${content}\n`;
264
499
  const sep = current.endsWith('\n') ? (current.endsWith('\n\n') ? '' : '\n') : '\n\n';
265
- await writeFileAtomic(paths.target, `${current}${sep}${block}`);
500
+ await this.writeFresh(paths.target, `${current}${sep}${block}`);
266
501
  return [{ file: 'AGENTS.md', action: 'appended', reason: 'marker-append (D3: foreign content untouched)' }];
267
502
  }
268
- /** Sync-ветка AGENTS.md: только штампованный цельный файл; append-блок синку не принадлежит. */
269
- async syncAgentsMd(baseDir, models, state) {
503
+ /** Sync-ветка AGENTS.md: только штампованный цельный файл; append-блок синку не принадлежит.
504
+ * protect у AGENTS.md не поддерживается (§4.5); правила 6/6.5/7 §4.3 действуют,
505
+ * включая take-on-mutated (6′) с бэкапом — механизм тот же, что у файлов карты (§4.12). */
506
+ async syncAgentsMd(baseDir, models, state, backup) {
270
507
  const paths = this.agentsMdPaths(baseDir);
271
508
  if (!paths)
272
509
  return [];
273
510
  const fresh = insertStamp(this.agentsMdContent(paths.tplPath, models), { base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md');
274
511
  if (!existsSync(paths.target)) {
275
- await writeFileAtomic(paths.target, fresh);
276
- state.files['AGENTS.md'] = sha256(fresh);
512
+ await this.writeFresh(paths.target, fresh);
513
+ this.record(state, 'AGENTS.md', fresh);
277
514
  return [{ file: 'AGENTS.md', action: 'created' }];
278
515
  }
279
516
  const current = readFileSync(paths.target, 'utf-8');
280
517
  if (parseStamp(current) === null) {
281
- return [
282
- { file: 'AGENTS.md', action: 'skipped', reason: 'no stamp (append block) — does not belong to sync (§4.2)' },
283
- ];
518
+ // Блочный режим (§4.5): управляется только блок между маркерами; append-ветка —
519
+ // исключительно init (решение L0 2026-10-25), upgrade чужой файл не дописывает.
520
+ return [await this.syncAgentsBlock(paths.target, current, this.agentsMdContent(paths.tplPath, models))];
284
521
  }
285
522
  if (fresh === current) {
286
- state.files['AGENTS.md'] = sha256(fresh);
523
+ this.record(state, 'AGENTS.md', fresh);
287
524
  return [{ file: 'AGENTS.md', action: 'skipped', reason: 'content identical (M2)' }];
288
525
  }
289
- // T010 mutated-skip — то же правило, что и для файлов набора
290
- const recorded = state.files['AGENTS.md'];
291
- if (recorded !== undefined && sha256(current) !== recorded) {
526
+ const entry = asEntry(state.files['AGENTS.md']);
527
+ const hash = sha256(normalizeManaged(current));
528
+ if (entry && entry.hash !== hash) {
529
+ if (!backup)
530
+ return [{ file: 'AGENTS.md', action: 'skipped', mutated: true, reason: MUTATED_REASON }];
531
+ // Правило 6′ §4.3/§4.12: бэкап → эталон → свидетель (порядок обязателен, NFR-05)
532
+ const backupPath = await backup('AGENTS.md', current);
533
+ await this.writeFresh(paths.target, fresh);
534
+ this.record(state, 'AGENTS.md', fresh);
535
+ return [
536
+ {
537
+ file: 'AGENTS.md',
538
+ action: 'updated',
539
+ backup: backupPath,
540
+ reason: 'take-on-mutated (--yes): local copy saved',
541
+ },
542
+ ];
543
+ }
544
+ if (entry && entry.origin === 'keep') {
292
545
  return [
293
546
  {
294
547
  file: 'AGENTS.md',
295
548
  action: 'skipped',
296
- mutated: true,
297
- reason: 'mutated after render — differs from recorded render and fresh render; left untouched',
549
+ kept: true,
550
+ reason: `locally accepted (keep) at ${entry.base}${entry.base !== this.setVersion ? `, base ${this.setVersion}` : ''}`,
298
551
  },
299
552
  ];
300
553
  }
301
- await writeFileAtomic(paths.target, fresh);
302
- state.files['AGENTS.md'] = sha256(fresh);
303
- return [{ file: 'AGENTS.md', action: 'updated' }];
554
+ // Правило 7: записи свидетеля нет — локальный контент сначала в бэкап (NFR-05).
555
+ const backupPath = !entry && backup ? await backup('AGENTS.md', current) : undefined;
556
+ await this.writeFresh(paths.target, fresh);
557
+ this.record(state, 'AGENTS.md', fresh);
558
+ return [{ file: 'AGENTS.md', action: 'updated', ...(backupPath ? { backup: backupPath } : {}) }];
559
+ }
560
+ /** Единый порядок решающих правил §4.3: missing → protect → unstamped →
561
+ * identical → штамп-дрейф → mutated → kept → rewrite. Режимы (sync/render/
562
+ * inspect) читают это решение и выполняют свою часть (запись или нет). */
563
+ classify(rel, target, fresh, state) {
564
+ if (!existsSync(target))
565
+ return { state: 'missing', fresh };
566
+ const current = readFileSync(target, 'utf-8');
567
+ const protect = parseProtect(current);
568
+ if (protect.held)
569
+ return { state: 'protected', fresh, current, protect };
570
+ if (parseStamp(current) === null)
571
+ return { state: 'unstamped', fresh, current };
572
+ const normalized = normalizeManaged(current);
573
+ if (normalized === fresh)
574
+ return { state: 'identical', fresh, current };
575
+ if (stripStampLines(normalized) === stripStampLines(fresh))
576
+ return { state: 'heal', fresh, current };
577
+ const entry = asEntry(state.files[rel]);
578
+ if (entry && sha256(normalized) !== entry.hash)
579
+ return { state: 'mutated', fresh, current, entry };
580
+ if (entry && entry.origin === 'keep')
581
+ return { state: 'kept', fresh, current, entry };
582
+ return { state: 'rewrite', fresh, current, entry };
583
+ }
584
+ /** Свидетель записи: хэш нормализованного контента + происхождение + база (§4.2). */
585
+ record(state, rel, written) {
586
+ state.files[rel] = { hash: sha256(normalizeManaged(written)), origin: 'render', base: this.setVersion };
587
+ }
588
+ async writeFresh(target, content) {
589
+ await mkdir(join(target, '..'), { recursive: true });
590
+ await writeFileAtomic(target, content);
304
591
  }
305
592
  renderContent(f, bake, models) {
306
593
  let rendered = substitute(readFileSync(f.tplPath, 'utf-8'), OPENCODE_MANIFEST, models);
@@ -310,12 +597,5 @@ export class OpencodeBaseSetRenderer {
310
597
  }
311
598
  return insertStamp(rendered, { base: f.baseName, set: this.setVersion }, f.baseName);
312
599
  }
313
- /** Пишет рендер и возвращает записанный контент (T010: хэш для sync-state). */
314
- async writeRendered(f, bake, models) {
315
- await mkdir(join(f.target, '..'), { recursive: true });
316
- const content = this.renderContent(f, bake, models);
317
- await writeFileAtomic(f.target, content);
318
- return content;
319
- }
320
600
  }
321
601
  //# sourceMappingURL=opencode-renderer.js.map