mister-wolf 2.19.0 → 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.
- package/README.md +12 -1
- package/README.ru.md +19 -2
- package/dist/adapters/cli/commands/memory-doctor.d.ts.map +1 -1
- package/dist/adapters/cli/commands/memory-doctor.js.map +1 -1
- package/dist/adapters/cli/commands/memory-uninstall.d.ts.map +1 -1
- package/dist/adapters/cli/commands/memory-uninstall.js.map +1 -1
- package/dist/adapters/cli/commands/memory-upgrade.d.ts +143 -2
- package/dist/adapters/cli/commands/memory-upgrade.d.ts.map +1 -1
- package/dist/adapters/cli/commands/memory-upgrade.js +887 -41
- package/dist/adapters/cli/commands/memory-upgrade.js.map +1 -1
- package/dist/adapters/cli/commands/upgrade-projects.d.ts +4 -2
- package/dist/adapters/cli/commands/upgrade-projects.d.ts.map +1 -1
- package/dist/adapters/cli/commands/upgrade-projects.js +17 -7
- package/dist/adapters/cli/commands/upgrade-projects.js.map +1 -1
- package/dist/adapters/render/opencode/opencode-renderer.d.ts +35 -7
- package/dist/adapters/render/opencode/opencode-renderer.d.ts.map +1 -1
- package/dist/adapters/render/opencode/opencode-renderer.js +395 -115
- package/dist/adapters/render/opencode/opencode-renderer.js.map +1 -1
- package/dist/adapters/render/stamp.d.ts +2 -0
- package/dist/adapters/render/stamp.d.ts.map +1 -1
- package/dist/adapters/render/stamp.js +9 -1
- package/dist/adapters/render/stamp.js.map +1 -1
- package/dist/adapters/render/sync-state.d.ts +15 -3
- package/dist/adapters/render/sync-state.d.ts.map +1 -1
- package/dist/adapters/render/sync-state.js +52 -8
- package/dist/adapters/render/sync-state.js.map +1 -1
- package/dist/app/use-cases/decisions.d.ts +247 -0
- package/dist/app/use-cases/decisions.d.ts.map +1 -0
- package/dist/app/use-cases/decisions.js +528 -0
- package/dist/app/use-cases/decisions.js.map +1 -0
- package/dist/app/use-cases/init-project.d.ts.map +1 -1
- package/dist/app/use-cases/init-project.js +34 -1
- package/dist/app/use-cases/init-project.js.map +1 -1
- package/dist/app/use-cases/platform-mismatch.d.ts +2 -1
- package/dist/app/use-cases/platform-mismatch.d.ts.map +1 -1
- package/dist/app/use-cases/platform-mismatch.js.map +1 -1
- package/dist/app/use-cases/project-step.d.ts +20 -1
- package/dist/app/use-cases/project-step.d.ts.map +1 -1
- package/dist/app/use-cases/project-step.js +101 -15
- package/dist/app/use-cases/project-step.js.map +1 -1
- package/dist/app/use-cases/repair-platform.d.ts +2 -1
- package/dist/app/use-cases/repair-platform.d.ts.map +1 -1
- package/dist/app/use-cases/repair-platform.js.map +1 -1
- package/dist/app/use-cases/sync-base-set.d.ts +4 -2
- package/dist/app/use-cases/sync-base-set.d.ts.map +1 -1
- package/dist/app/use-cases/sync-base-set.js +4 -2
- package/dist/app/use-cases/sync-base-set.js.map +1 -1
- package/dist/app/use-cases/uninstall.d.ts +2 -1
- package/dist/app/use-cases/uninstall.d.ts.map +1 -1
- package/dist/app/use-cases/uninstall.js.map +1 -1
- package/dist/app/use-cases/upgrade-projects.d.ts +30 -0
- package/dist/app/use-cases/upgrade-projects.d.ts.map +1 -1
- package/dist/app/use-cases/upgrade-projects.js +119 -4
- package/dist/app/use-cases/upgrade-projects.js.map +1 -1
- package/dist/app/use-cases/verify-against-map.d.ts +5 -1
- package/dist/app/use-cases/verify-against-map.d.ts.map +1 -1
- package/dist/app/use-cases/verify-against-map.js +34 -6
- package/dist/app/use-cases/verify-against-map.js.map +1 -1
- package/dist/domain/line-diff.d.ts +36 -0
- package/dist/domain/line-diff.d.ts.map +1 -0
- package/dist/domain/line-diff.js +175 -0
- package/dist/domain/line-diff.js.map +1 -0
- package/dist/domain/protect-key.d.ts +21 -0
- package/dist/domain/protect-key.d.ts.map +1 -0
- package/dist/domain/protect-key.js +112 -0
- package/dist/domain/protect-key.js.map +1 -0
- package/dist/ports/base-set-renderer.port.d.ts +51 -3
- package/dist/ports/base-set-renderer.port.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/.wolf/router.log +1 -1
- package/templates/base/AGENTS.md +6 -3
- package/update-map.json +3 -3
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
reason:
|
|
144
|
+
kept: true,
|
|
145
|
+
reason: `locally accepted (keep) at ${d.entry.base}`,
|
|
91
146
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
await
|
|
95
|
-
state
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
174
|
-
*
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
231
|
-
if (
|
|
448
|
+
const block = findOnboardingBlock(current);
|
|
449
|
+
if (block === null)
|
|
232
450
|
return { file: 'AGENTS.md', action: 'foreign' };
|
|
233
|
-
|
|
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
|
|
255
|
-
state
|
|
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
|
|
260
|
-
return [
|
|
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
|
|
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
|
-
|
|
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
|
|
276
|
-
state
|
|
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
|
-
|
|
282
|
-
|
|
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
|
|
523
|
+
this.record(state, 'AGENTS.md', fresh);
|
|
287
524
|
return [{ file: 'AGENTS.md', action: 'skipped', reason: 'content identical (M2)' }];
|
|
288
525
|
}
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
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
|
-
|
|
297
|
-
reason:
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|