mister-wolf 2.18.5 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +12 -1
  2. package/README.ru.md +33 -14
  3. package/dist/adapters/cli/cli-entry.d.ts.map +1 -1
  4. package/dist/adapters/cli/cli-entry.js +10 -1
  5. package/dist/adapters/cli/cli-entry.js.map +1 -1
  6. package/dist/adapters/cli/commands/memory-doctor.d.ts.map +1 -1
  7. package/dist/adapters/cli/commands/memory-doctor.js.map +1 -1
  8. package/dist/adapters/cli/commands/memory-sync.d.ts.map +1 -1
  9. package/dist/adapters/cli/commands/memory-sync.js +3 -0
  10. package/dist/adapters/cli/commands/memory-sync.js.map +1 -1
  11. package/dist/adapters/cli/commands/memory-uninstall.d.ts.map +1 -1
  12. package/dist/adapters/cli/commands/memory-uninstall.js.map +1 -1
  13. package/dist/adapters/cli/commands/memory-upgrade.d.ts +274 -0
  14. package/dist/adapters/cli/commands/memory-upgrade.d.ts.map +1 -1
  15. package/dist/adapters/cli/commands/memory-upgrade.js +1354 -6
  16. package/dist/adapters/cli/commands/memory-upgrade.js.map +1 -1
  17. package/dist/adapters/cli/commands/upgrade-projects.d.ts +19 -0
  18. package/dist/adapters/cli/commands/upgrade-projects.d.ts.map +1 -0
  19. package/dist/adapters/cli/commands/upgrade-projects.js +85 -0
  20. package/dist/adapters/cli/commands/upgrade-projects.js.map +1 -0
  21. package/dist/adapters/cli/report-renderer.d.ts +43 -0
  22. package/dist/adapters/cli/report-renderer.d.ts.map +1 -0
  23. package/dist/adapters/cli/report-renderer.js +184 -0
  24. package/dist/adapters/cli/report-renderer.js.map +1 -0
  25. package/dist/adapters/fs/projects-registry.d.ts +21 -0
  26. package/dist/adapters/fs/projects-registry.d.ts.map +1 -1
  27. package/dist/adapters/fs/projects-registry.js +54 -1
  28. package/dist/adapters/fs/projects-registry.js.map +1 -1
  29. package/dist/adapters/fs/update-map.d.ts +102 -0
  30. package/dist/adapters/fs/update-map.d.ts.map +1 -0
  31. package/dist/adapters/fs/update-map.js +95 -0
  32. package/dist/adapters/fs/update-map.js.map +1 -0
  33. package/dist/adapters/fs/upgrade-lock.d.ts +23 -0
  34. package/dist/adapters/fs/upgrade-lock.d.ts.map +1 -0
  35. package/dist/adapters/fs/upgrade-lock.js +59 -0
  36. package/dist/adapters/fs/upgrade-lock.js.map +1 -0
  37. package/dist/adapters/render/opencode/opencode-renderer.d.ts +45 -5
  38. package/dist/adapters/render/opencode/opencode-renderer.d.ts.map +1 -1
  39. package/dist/adapters/render/opencode/opencode-renderer.js +434 -86
  40. package/dist/adapters/render/opencode/opencode-renderer.js.map +1 -1
  41. package/dist/adapters/render/stamp.d.ts +2 -0
  42. package/dist/adapters/render/stamp.d.ts.map +1 -1
  43. package/dist/adapters/render/stamp.js +9 -1
  44. package/dist/adapters/render/stamp.js.map +1 -1
  45. package/dist/adapters/render/sync-state.d.ts +15 -3
  46. package/dist/adapters/render/sync-state.d.ts.map +1 -1
  47. package/dist/adapters/render/sync-state.js +52 -8
  48. package/dist/adapters/render/sync-state.js.map +1 -1
  49. package/dist/app/use-cases/decisions.d.ts +247 -0
  50. package/dist/app/use-cases/decisions.d.ts.map +1 -0
  51. package/dist/app/use-cases/decisions.js +528 -0
  52. package/dist/app/use-cases/decisions.js.map +1 -0
  53. package/dist/app/use-cases/init-project.d.ts.map +1 -1
  54. package/dist/app/use-cases/init-project.js +34 -1
  55. package/dist/app/use-cases/init-project.js.map +1 -1
  56. package/dist/app/use-cases/platform-mismatch.d.ts +2 -1
  57. package/dist/app/use-cases/platform-mismatch.d.ts.map +1 -1
  58. package/dist/app/use-cases/platform-mismatch.js.map +1 -1
  59. package/dist/app/use-cases/project-step.d.ts +71 -0
  60. package/dist/app/use-cases/project-step.d.ts.map +1 -0
  61. package/dist/app/use-cases/project-step.js +163 -0
  62. package/dist/app/use-cases/project-step.js.map +1 -0
  63. package/dist/app/use-cases/repair-platform.d.ts +2 -1
  64. package/dist/app/use-cases/repair-platform.d.ts.map +1 -1
  65. package/dist/app/use-cases/repair-platform.js.map +1 -1
  66. package/dist/app/use-cases/scan-unregistered.d.ts +25 -0
  67. package/dist/app/use-cases/scan-unregistered.d.ts.map +1 -0
  68. package/dist/app/use-cases/scan-unregistered.js +74 -0
  69. package/dist/app/use-cases/scan-unregistered.js.map +1 -0
  70. package/dist/app/use-cases/seed-base-playbooks.d.ts +10 -0
  71. package/dist/app/use-cases/seed-base-playbooks.d.ts.map +1 -1
  72. package/dist/app/use-cases/seed-base-playbooks.js +54 -13
  73. package/dist/app/use-cases/seed-base-playbooks.js.map +1 -1
  74. package/dist/app/use-cases/sync-base-set.d.ts +4 -2
  75. package/dist/app/use-cases/sync-base-set.d.ts.map +1 -1
  76. package/dist/app/use-cases/sync-base-set.js +4 -2
  77. package/dist/app/use-cases/sync-base-set.js.map +1 -1
  78. package/dist/app/use-cases/uninstall.d.ts +2 -1
  79. package/dist/app/use-cases/uninstall.d.ts.map +1 -1
  80. package/dist/app/use-cases/uninstall.js.map +1 -1
  81. package/dist/app/use-cases/upgrade-projects.d.ts +91 -0
  82. package/dist/app/use-cases/upgrade-projects.d.ts.map +1 -0
  83. package/dist/app/use-cases/upgrade-projects.js +494 -0
  84. package/dist/app/use-cases/upgrade-projects.js.map +1 -0
  85. package/dist/app/use-cases/verify-against-map.d.ts +52 -0
  86. package/dist/app/use-cases/verify-against-map.d.ts.map +1 -0
  87. package/dist/app/use-cases/verify-against-map.js +189 -0
  88. package/dist/app/use-cases/verify-against-map.js.map +1 -0
  89. package/dist/domain/line-diff.d.ts +36 -0
  90. package/dist/domain/line-diff.d.ts.map +1 -0
  91. package/dist/domain/line-diff.js +175 -0
  92. package/dist/domain/line-diff.js.map +1 -0
  93. package/dist/domain/protect-key.d.ts +21 -0
  94. package/dist/domain/protect-key.d.ts.map +1 -0
  95. package/dist/domain/protect-key.js +112 -0
  96. package/dist/domain/protect-key.js.map +1 -0
  97. package/dist/ports/base-set-renderer.port.d.ts +62 -1
  98. package/dist/ports/base-set-renderer.port.d.ts.map +1 -1
  99. package/package.json +3 -2
  100. package/templates/.wolf/router.log +1 -1
  101. package/templates/base/AGENTS.md +6 -3
  102. package/update-map.json +204 -0
@@ -1,10 +1,12 @@
1
1
  // src/adapters/render/opencode/opencode-renderer.ts
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
3
3
  import { basename, join, relative } from 'path';
4
- import { mkdir, writeFile } from 'fs/promises';
4
+ import { mkdir } from 'fs/promises';
5
+ import { writeFileAtomic } from '../../fs/markdown-memory-store.js';
5
6
  import { OPENCODE_MANIFEST, substitute } from '../manifest.js';
6
- import { insertStamp, parseStamp } from '../stamp.js';
7
+ import { insertStamp, parseStamp, renderStamp, stripStampLines } from '../stamp.js';
7
8
  import { loadSyncState, saveSyncState, sha256 } from '../sync-state.js';
9
+ import { normalizeManaged, parseProtect } from '../../../domain/protect-key.js';
8
10
  import { wolfVersion } from '../templates-root.js';
9
11
  const NEUTRAL_DIRS = ['agents', 'skills', 'commands'];
10
12
  const LAYOUT = {
@@ -12,8 +14,47 @@ const LAYOUT = {
12
14
  skills: '.opencode/skills',
13
15
  commands: '.opencode/command',
14
16
  };
15
- /** Маркер онбординг-блока в AGENTS.md (спека §4.2). */
16
- 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 ?? '';
17
58
  export class OpencodeBaseSetRenderer {
18
59
  baseTemplatesRoot;
19
60
  opts;
@@ -67,86 +108,136 @@ export class OpencodeBaseSetRenderer {
67
108
  const state = loadSyncState(baseDir);
68
109
  for (const f of this.templateFiles(baseDir)) {
69
110
  const rel = relative(baseDir, f.target);
70
- if (existsSync(f.target)) {
71
- // diff-ветка (§4.5): unstamped — чужое (wx); stamped — сравнение контента
72
- const current = readFileSync(f.target, 'utf-8');
73
- 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)
74
124
  outcomes.push({ file: rel, action: 'skipped', reason: 'exists, unstamped (wx policy)' });
75
- continue;
76
- }
77
- const fresh = this.renderContent(f, opts?.bake, opts?.models);
78
- if (fresh === current) {
79
- state.files[rel] = sha256(fresh);
125
+ break;
126
+ case 'identical':
127
+ this.record(state, rel, fresh);
80
128
  outcomes.push({ file: rel, action: 'skipped', reason: 'content identical' });
81
- }
82
- 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':
83
136
  // K6 (спека 2.16 §4): локальная правка штампованного файла не перебивается
84
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':
85
141
  outcomes.push({
86
142
  file: rel,
87
143
  action: 'skipped',
88
- 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}`,
89
146
  });
90
- }
91
- else {
92
- await writeFile(f.target, fresh, 'utf-8');
93
- state.files[rel] = sha256(fresh);
147
+ break;
148
+ case 'rewrite':
149
+ await this.writeFresh(f.target, fresh);
150
+ this.record(state, rel, fresh);
94
151
  outcomes.push({ file: rel, action: 'updated', reason: 'content differs (diff branch, §4.5)' });
95
- }
96
- continue;
152
+ break;
97
153
  }
98
- state.files[rel] = sha256(await this.writeRendered(f, opts?.bake, opts?.models));
99
- outcomes.push({ file: rel, action: 'created' });
100
154
  }
101
155
  outcomes.push(...(await this.renderAgentsMd(baseDir, opts?.models, state)));
102
156
  saveSyncState(baseDir, state);
103
157
  return outcomes;
104
158
  }
105
- async syncBaseSet(baseDir, models) {
159
+ async syncBaseSet(baseDir, models, opts) {
106
160
  const ctx = models ?? 'omit'; // легаси без routing-объекта — omit (§4.5)
161
+ const backup = opts?.takeMutated?.backup;
107
162
  const outcomes = [];
108
163
  const seen = new Set();
109
164
  const state = loadSyncState(baseDir);
110
165
  for (const f of this.templateFiles(baseDir)) {
111
166
  seen.add(f.target);
112
167
  const rel = relative(baseDir, f.target);
113
- if (!existsSync(f.target)) {
114
- state.files[rel] = sha256(await this.writeRendered(f, undefined, ctx));
115
- outcomes.push({ file: rel, action: 'created' });
116
- continue;
117
- }
118
- const current = readFileSync(f.target, 'utf-8');
119
- if (parseStamp(current) === null) {
120
- outcomes.push({
121
- file: rel,
122
- action: 'conflict',
123
- reason: 'unstamped in place of a template one — the owner decides: rename / delete / accept the current one',
124
- });
125
- continue;
126
- }
127
168
  const fresh = this.renderContent(f, undefined, ctx);
128
- if (fresh === current) {
129
- state.files[rel] = sha256(fresh);
130
- outcomes.push({ file: rel, action: 'skipped', reason: 'content identical (M2)' });
131
- continue;
132
- }
133
- // T010 mutated-skip: контент отличается и от записанного рендера (свидетель) —
134
- // это локальная правка, не сдвиг канона; файл не трогаем, хэш не меняем.
135
- const recorded = state.files[rel];
136
- if (recorded !== undefined && sha256(current) !== recorded) {
137
- outcomes.push({
138
- file: rel,
139
- action: 'skipped',
140
- reason: 'mutated after render — differs from recorded render and fresh render; left untouched',
141
- });
142
- 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
+ }
143
238
  }
144
- await mkdir(join(f.target, '..'), { recursive: true });
145
- await writeFile(f.target, fresh, 'utf-8');
146
- state.files[rel] = sha256(fresh);
147
- outcomes.push({ file: rel, action: 'updated' });
148
239
  }
149
- outcomes.push(...(await this.syncAgentsMd(baseDir, ctx, state)));
240
+ outcomes.push(...(await this.syncAgentsMd(baseDir, ctx, state, backup)));
150
241
  const orphaned = [];
151
242
  const scanRoots = [
152
243
  join(baseDir, '.opencode/agents'),
@@ -165,6 +256,207 @@ export class OpencodeBaseSetRenderer {
165
256
  saveSyncState(baseDir, state);
166
257
  return { outcomes, orphaned };
167
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
+ }
325
+ /** T10 (REQ-08): read-only инспекция целей base-set — предикат того, что
326
+ * сделает syncBaseSet, БЕЗ записей (ни writeFileAtomic, ни saveSyncState):
327
+ * missing / protected / foreign (unstamped, wx-политика) / identical (в т.ч.
328
+ * штамп-дрейф: проходу нечего решать) / differs / mutated / kept. models по
329
+ * умолчанию 'omit' — паритет с syncBaseSet, чей исход и предсказываем. */
330
+ async inspectBaseSet(baseDir, opts) {
331
+ const ctx = opts?.models ?? 'omit'; // легаси без routing-объекта — omit (§4.5)
332
+ const state = loadSyncState(baseDir);
333
+ const outcomes = [];
334
+ for (const f of this.templateFiles(baseDir)) {
335
+ const rel = relative(baseDir, f.target);
336
+ const fresh = this.renderContent(f, opts?.bake, ctx);
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;
367
+ }
368
+ }
369
+ const agents = this.inspectAgentsMd(baseDir, ctx, state);
370
+ if (agents)
371
+ outcomes.push(agents);
372
+ return outcomes;
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
+ }
423
+ /** Read-only инспекция AGENTS.md (§4.2) — по фактическим веткам управления:
424
+ * штампованный цельный файл — зона syncAgentsMd (сверка всего файла со
425
+ * штампом, mutated по свидетелю); без штампа — маркер-блок append-ветки
426
+ * init (синку не принадлежит, свидетеля не имеет); нет ни штампа, ни
427
+ * маркера — чужой файл на месте цели (wx-политика → foreign). */
428
+ inspectAgentsMd(baseDir, models, state) {
429
+ const paths = this.agentsMdPaths(baseDir);
430
+ if (!paths)
431
+ return null;
432
+ if (!existsSync(paths.target))
433
+ return { file: 'AGENTS.md', action: 'missing' };
434
+ const current = readFileSync(paths.target, 'utf-8');
435
+ const content = this.agentsMdContent(paths.tplPath, models);
436
+ if (parseStamp(current) !== null) {
437
+ const fresh = insertStamp(content, { base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md');
438
+ if (fresh === current)
439
+ return { file: 'AGENTS.md', action: 'identical' };
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' };
447
+ }
448
+ const block = findOnboardingBlock(current);
449
+ if (block === null)
450
+ return { file: 'AGENTS.md', action: 'foreign' };
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' };
459
+ }
168
460
  /** AGENTS.md — цель в КОРНЕ проекта (§4.2); шаблона нет — тихо нет исхода. */
169
461
  agentsMdPaths(baseDir) {
170
462
  const tplPath = join(this.baseTemplatesRoot, 'AGENTS.md');
@@ -184,55 +476,118 @@ export class OpencodeBaseSetRenderer {
184
476
  const content = this.agentsMdContent(paths.tplPath, models);
185
477
  if (!existsSync(paths.target)) {
186
478
  const written = insertStamp(content, { base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md');
187
- await writeFile(paths.target, written, 'utf-8');
188
- state.files['AGENTS.md'] = sha256(written);
479
+ await this.writeFresh(paths.target, written);
480
+ this.record(state, 'AGENTS.md', written);
189
481
  return [{ file: 'AGENTS.md', action: 'created' }];
190
482
  }
191
483
  const current = readFileSync(paths.target, 'utf-8');
192
- if (current.includes(ONBOARDING_MARKER)) {
193
- 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)];
194
496
  }
195
497
  // marker-append: маркированный блок без штампа в конец; чужой контент не трогаем
196
498
  const block = content.endsWith('\n') ? content : `${content}\n`;
197
499
  const sep = current.endsWith('\n') ? (current.endsWith('\n\n') ? '' : '\n') : '\n\n';
198
- await writeFile(paths.target, `${current}${sep}${block}`, 'utf-8');
500
+ await this.writeFresh(paths.target, `${current}${sep}${block}`);
199
501
  return [{ file: 'AGENTS.md', action: 'appended', reason: 'marker-append (D3: foreign content untouched)' }];
200
502
  }
201
- /** Sync-ветка AGENTS.md: только штампованный цельный файл; append-блок синку не принадлежит. */
202
- 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) {
203
507
  const paths = this.agentsMdPaths(baseDir);
204
508
  if (!paths)
205
509
  return [];
206
510
  const fresh = insertStamp(this.agentsMdContent(paths.tplPath, models), { base: 'AGENTS.md', set: this.setVersion }, 'AGENTS.md');
207
511
  if (!existsSync(paths.target)) {
208
- await writeFile(paths.target, fresh, 'utf-8');
209
- state.files['AGENTS.md'] = sha256(fresh);
512
+ await this.writeFresh(paths.target, fresh);
513
+ this.record(state, 'AGENTS.md', fresh);
210
514
  return [{ file: 'AGENTS.md', action: 'created' }];
211
515
  }
212
516
  const current = readFileSync(paths.target, 'utf-8');
213
517
  if (parseStamp(current) === null) {
214
- return [
215
- { file: 'AGENTS.md', action: 'skipped', reason: 'no stamp (append block) — does not belong to sync (§4.2)' },
216
- ];
518
+ // Блочный режим (§4.5): управляется только блок между маркерами; append-ветка —
519
+ // исключительно init (решение L0 2026-10-25), upgrade чужой файл не дописывает.
520
+ return [await this.syncAgentsBlock(paths.target, current, this.agentsMdContent(paths.tplPath, models))];
217
521
  }
218
522
  if (fresh === current) {
219
- state.files['AGENTS.md'] = sha256(fresh);
523
+ this.record(state, 'AGENTS.md', fresh);
220
524
  return [{ file: 'AGENTS.md', action: 'skipped', reason: 'content identical (M2)' }];
221
525
  }
222
- // T010 mutated-skip — то же правило, что и для файлов набора
223
- const recorded = state.files['AGENTS.md'];
224
- 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') {
225
545
  return [
226
546
  {
227
547
  file: 'AGENTS.md',
228
548
  action: 'skipped',
229
- 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}` : ''}`,
230
551
  },
231
552
  ];
232
553
  }
233
- await writeFile(paths.target, fresh, 'utf-8');
234
- state.files['AGENTS.md'] = sha256(fresh);
235
- 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);
236
591
  }
237
592
  renderContent(f, bake, models) {
238
593
  let rendered = substitute(readFileSync(f.tplPath, 'utf-8'), OPENCODE_MANIFEST, models);
@@ -242,12 +597,5 @@ export class OpencodeBaseSetRenderer {
242
597
  }
243
598
  return insertStamp(rendered, { base: f.baseName, set: this.setVersion }, f.baseName);
244
599
  }
245
- /** Пишет рендер и возвращает записанный контент (T010: хэш для sync-state). */
246
- async writeRendered(f, bake, models) {
247
- await mkdir(join(f.target, '..'), { recursive: true });
248
- const content = this.renderContent(f, bake, models);
249
- await writeFile(f.target, content, 'utf-8');
250
- return content;
251
- }
252
600
  }
253
601
  //# sourceMappingURL=opencode-renderer.js.map