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.
- package/README.md +12 -1
- package/README.ru.md +33 -14
- package/dist/adapters/cli/cli-entry.d.ts.map +1 -1
- package/dist/adapters/cli/cli-entry.js +10 -1
- package/dist/adapters/cli/cli-entry.js.map +1 -1
- 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-sync.d.ts.map +1 -1
- package/dist/adapters/cli/commands/memory-sync.js +3 -0
- package/dist/adapters/cli/commands/memory-sync.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 +274 -0
- package/dist/adapters/cli/commands/memory-upgrade.d.ts.map +1 -1
- package/dist/adapters/cli/commands/memory-upgrade.js +1354 -6
- package/dist/adapters/cli/commands/memory-upgrade.js.map +1 -1
- package/dist/adapters/cli/commands/upgrade-projects.d.ts +19 -0
- package/dist/adapters/cli/commands/upgrade-projects.d.ts.map +1 -0
- package/dist/adapters/cli/commands/upgrade-projects.js +85 -0
- package/dist/adapters/cli/commands/upgrade-projects.js.map +1 -0
- package/dist/adapters/cli/report-renderer.d.ts +43 -0
- package/dist/adapters/cli/report-renderer.d.ts.map +1 -0
- package/dist/adapters/cli/report-renderer.js +184 -0
- package/dist/adapters/cli/report-renderer.js.map +1 -0
- package/dist/adapters/fs/projects-registry.d.ts +21 -0
- package/dist/adapters/fs/projects-registry.d.ts.map +1 -1
- package/dist/adapters/fs/projects-registry.js +54 -1
- package/dist/adapters/fs/projects-registry.js.map +1 -1
- package/dist/adapters/fs/update-map.d.ts +102 -0
- package/dist/adapters/fs/update-map.d.ts.map +1 -0
- package/dist/adapters/fs/update-map.js +95 -0
- package/dist/adapters/fs/update-map.js.map +1 -0
- package/dist/adapters/fs/upgrade-lock.d.ts +23 -0
- package/dist/adapters/fs/upgrade-lock.d.ts.map +1 -0
- package/dist/adapters/fs/upgrade-lock.js +59 -0
- package/dist/adapters/fs/upgrade-lock.js.map +1 -0
- package/dist/adapters/render/opencode/opencode-renderer.d.ts +45 -5
- package/dist/adapters/render/opencode/opencode-renderer.d.ts.map +1 -1
- package/dist/adapters/render/opencode/opencode-renderer.js +434 -86
- 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 +71 -0
- package/dist/app/use-cases/project-step.d.ts.map +1 -0
- package/dist/app/use-cases/project-step.js +163 -0
- package/dist/app/use-cases/project-step.js.map +1 -0
- 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/scan-unregistered.d.ts +25 -0
- package/dist/app/use-cases/scan-unregistered.d.ts.map +1 -0
- package/dist/app/use-cases/scan-unregistered.js +74 -0
- package/dist/app/use-cases/scan-unregistered.js.map +1 -0
- package/dist/app/use-cases/seed-base-playbooks.d.ts +10 -0
- package/dist/app/use-cases/seed-base-playbooks.d.ts.map +1 -1
- package/dist/app/use-cases/seed-base-playbooks.js +54 -13
- package/dist/app/use-cases/seed-base-playbooks.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 +91 -0
- package/dist/app/use-cases/upgrade-projects.d.ts.map +1 -0
- package/dist/app/use-cases/upgrade-projects.js +494 -0
- package/dist/app/use-cases/upgrade-projects.js.map +1 -0
- package/dist/app/use-cases/verify-against-map.d.ts +52 -0
- package/dist/app/use-cases/verify-against-map.d.ts.map +1 -0
- package/dist/app/use-cases/verify-against-map.js +189 -0
- package/dist/app/use-cases/verify-against-map.js.map +1 -0
- 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 +62 -1
- package/dist/ports/base-set-renderer.port.d.ts.map +1 -1
- package/package.json +3 -2
- package/templates/.wolf/router.log +1 -1
- package/templates/base/AGENTS.md +6 -3
- 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
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
kept: true,
|
|
145
|
+
reason: `locally accepted (keep) at ${d.entry.base}`,
|
|
89
146
|
});
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
await
|
|
93
|
-
state
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
188
|
-
state
|
|
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
|
|
193
|
-
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)];
|
|
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
|
|
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
|
-
|
|
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
|
|
209
|
-
state
|
|
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
|
-
|
|
215
|
-
|
|
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
|
|
523
|
+
this.record(state, 'AGENTS.md', fresh);
|
|
220
524
|
return [{ file: 'AGENTS.md', action: 'skipped', reason: 'content identical (M2)' }];
|
|
221
525
|
}
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
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') {
|
|
225
545
|
return [
|
|
226
546
|
{
|
|
227
547
|
file: 'AGENTS.md',
|
|
228
548
|
action: 'skipped',
|
|
229
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|