dsh-desktop-kit 2.2.0 → 2.7.2

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 (128) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +113 -27
  3. package/client.js +3121 -32
  4. package/docs/AGENT-HARNESS.md +144 -0
  5. package/docs/ARCHITECTURE.md +38 -7
  6. package/docs/COMPATIBILITY.md +5 -3
  7. package/docs/DEVELOPMENT.md +56 -0
  8. package/docs/INSTALL.md +20 -11
  9. package/docs/LAPTOP-INSTALL.md +96 -0
  10. package/docs/ROADMAP-3.0.md +353 -0
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/SUBAGENTS.md +25 -0
  13. package/docs/TEST-0.2.2.md +6 -6
  14. package/docs/TEST-2.3.0.md +49 -0
  15. package/docs/TEST-2.7.2.md +46 -0
  16. package/docs/TOKENS.md +2 -2
  17. package/docs/TROUBLESHOOTING.md +27 -0
  18. package/docs/UI-QA.md +28 -0
  19. package/docs/images/peak-toast-peak.png +0 -0
  20. package/docs/images/peak-toast-soon.png +0 -0
  21. package/docs/preview/peak-toast.html +155 -0
  22. package/doctor.ps1 +122 -0
  23. package/index.js +18 -1
  24. package/install.ps1 +99 -0
  25. package/package.json +29 -6
  26. package/plugins/dsh-agent-extensions/README.md +99 -0
  27. package/plugins/dsh-agent-extensions/index.js +167 -0
  28. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  29. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  30. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  31. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  32. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  33. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  34. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  35. package/plugins/dsh-agent-extensions/package.json +9 -0
  36. package/plugins/dsh-browser-qa/README.md +221 -0
  37. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  38. package/plugins/dsh-browser-qa/index.js +165 -0
  39. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  40. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  41. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  42. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  43. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  44. package/plugins/dsh-browser-qa/package.json +31 -0
  45. package/plugins/dsh-checkpoints/README.md +22 -0
  46. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  47. package/plugins/dsh-checkpoints/index.js +80 -0
  48. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  49. package/plugins/dsh-checkpoints/package.json +25 -0
  50. package/plugins/dsh-github-review/README.md +131 -0
  51. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  52. package/plugins/dsh-github-review/index.js +536 -0
  53. package/plugins/dsh-github-review/lib/gh.js +494 -0
  54. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  55. package/plugins/dsh-github-review/package.json +28 -0
  56. package/plugins/dsh-guardrails/README.md +264 -0
  57. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  58. package/plugins/dsh-guardrails/index.js +389 -0
  59. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  60. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  61. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  62. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  63. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  64. package/plugins/dsh-guardrails/package.json +26 -0
  65. package/plugins/dsh-mcp-manager/README.md +78 -0
  66. package/plugins/dsh-mcp-manager/client.js +373 -0
  67. package/plugins/dsh-mcp-manager/index.js +159 -0
  68. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  69. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  70. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  71. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  72. package/plugins/dsh-mcp-manager/package.json +18 -0
  73. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  74. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  75. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  76. package/plugins/dsh-memory-bridge/README.md +161 -0
  77. package/plugins/dsh-memory-bridge/client.js +2264 -0
  78. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  79. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  83. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  84. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  85. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  86. package/plugins/dsh-memory-bridge/index.js +378 -0
  87. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  88. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  89. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  90. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  91. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  92. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  93. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  94. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  95. package/plugins/dsh-memory-bridge/package.json +67 -0
  96. package/plugins/dsh-peak-guard/README.md +16 -3
  97. package/plugins/dsh-peak-guard/client.js +223 -8
  98. package/plugins/dsh-peak-guard/package.json +1 -1
  99. package/plugins/dsh-plan-mode/README.md +24 -0
  100. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  101. package/plugins/dsh-plan-mode/index.js +103 -0
  102. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  103. package/plugins/dsh-plan-mode/package.json +18 -0
  104. package/plugins/dsh-profile-transfer/client.js +277 -17
  105. package/plugins/dsh-profile-transfer/index.js +10 -0
  106. package/plugins/dsh-profile-transfer/package.json +3 -1
  107. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  108. package/plugins/dsh-project-memory/index.js +92 -2
  109. package/plugins/dsh-project-memory/package.json +1 -1
  110. package/plugins/dsh-repo-map/README.md +26 -0
  111. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  112. package/plugins/dsh-repo-map/index.js +99 -0
  113. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  114. package/plugins/dsh-repo-map/package.json +25 -0
  115. package/plugins/dsh-skill-autopilot/index.js +135 -11
  116. package/skills/delivery-gate/SKILL.md +31 -0
  117. package/skills/git-and-ci/SKILL.md +5 -1
  118. package/skills/manifest.json +31 -6
  119. package/skills/requirements-and-planning/SKILL.md +3 -0
  120. package/skills/research-first/SKILL.md +35 -0
  121. package/skills/research-first/references/query-patterns.md +20 -0
  122. package/skills/subagent-playbook/SKILL.md +36 -0
  123. package/skills/subagent-playbook/references/prompts.md +39 -0
  124. package/skills/ui-craft/SKILL.md +43 -0
  125. package/skills/visual-qa/SKILL.md +29 -0
  126. package/skills/visual-qa/references/commands.md +32 -0
  127. package/skills-baseline.json +246 -0
  128. package/uninstall.ps1 +74 -0
@@ -0,0 +1,565 @@
1
+ /**
2
+ * dsh-memory-bridge — configuration, machine identity, project state, backups
3
+ * and the small filesystem helpers shared by the sync service.
4
+ *
5
+ * Everything the bridge owns lives under `<DSH_HOME>/memory-sync/`, outside
6
+ * `projects-memory/`, so the kit and `dsh-profile-transfer` keep their layout
7
+ * untouched.
8
+ */
9
+
10
+ import crypto from "node:crypto";
11
+ import fsp from "node:fs/promises";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { createRequire } from "node:module";
16
+
17
+ import {
18
+ BridgeError,
19
+ SECTION_FILES,
20
+ JOURNAL_FILE,
21
+ dshHome,
22
+ ensureDir,
23
+ exists,
24
+ hashContentMap,
25
+ isAllowedSyncPath,
26
+ memoryRoot,
27
+ readJsonIfExists,
28
+ readTextIfExists,
29
+ removeFileQuiet,
30
+ safeRelPath,
31
+ sha256,
32
+ syncRoot,
33
+ writeFileAtomic,
34
+ writeJsonAtomic,
35
+ } from "./bridge.js";
36
+
37
+ export const DEFAULT_CONFIG = Object.freeze({
38
+ version: 2,
39
+ enabled: false,
40
+ repo: "",
41
+ branch: "main",
42
+ localDir: "",
43
+ intervalSeconds: 300,
44
+ autoSync: true,
45
+ excludeProjects: [],
46
+ links: {},
47
+ });
48
+
49
+ export function pathsFor(home = dshHome()) {
50
+ const root = syncRoot(home);
51
+ return {
52
+ home,
53
+ root,
54
+ config: path.join(root, "config.json"),
55
+ machine: path.join(root, "machine.json"),
56
+ global: path.join(root, "global.json"),
57
+ githubRoot: path.join(root, "github"),
58
+ baseDir: path.join(root, "base"),
59
+ conflictsDir: path.join(root, "github-conflicts"),
60
+ tmpDir: path.join(root, "tmp"),
61
+ backupsDir: path.join(root, "backups"),
62
+ logFile: path.join(root, "log.txt"),
63
+ projectsMemory: memoryRoot(home),
64
+ };
65
+ }
66
+
67
+ /* ------------------------------------------------------------------ */
68
+ /* config */
69
+ /* ------------------------------------------------------------------ */
70
+
71
+ export function normaliseConfig(raw) {
72
+ const value = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
73
+ return {
74
+ ...DEFAULT_CONFIG,
75
+ ...value,
76
+ version: 2,
77
+ enabled: value.enabled === true,
78
+ repo: typeof value.repo === "string" ? value.repo.trim() : "",
79
+ branch: typeof value.branch === "string" && value.branch.trim().length > 0 ? value.branch.trim() : "main",
80
+ localDir: typeof value.localDir === "string" ? value.localDir : "",
81
+ intervalSeconds: clampNumber(value.intervalSeconds, 60, 3600, DEFAULT_CONFIG.intervalSeconds),
82
+ autoSync: value.autoSync !== false,
83
+ excludeProjects: Array.isArray(value.excludeProjects) ? value.excludeProjects.filter((item) => typeof item === "string") : [],
84
+ links: value.links !== null && typeof value.links === "object" && !Array.isArray(value.links) ? { ...value.links } : {},
85
+ };
86
+ }
87
+
88
+ function clampNumber(value, min, max, fallback) {
89
+ const number = Number(value);
90
+ if (!Number.isFinite(number)) return fallback;
91
+ return Math.max(min, Math.min(max, Math.trunc(number)));
92
+ }
93
+
94
+ export async function loadConfig(home = dshHome()) {
95
+ const parsed = await readJsonIfExists(pathsFor(home).config);
96
+ return normaliseConfig(parsed);
97
+ }
98
+
99
+ export async function saveConfig(home, config) {
100
+ const normalised = normaliseConfig(config);
101
+ await writeJsonAtomic(pathsFor(home).config, normalised);
102
+ return normalised;
103
+ }
104
+
105
+ /* ------------------------------------------------------------------ */
106
+ /* machine identity */
107
+ /* ------------------------------------------------------------------ */
108
+
109
+ export async function loadMachine(home = dshHome()) {
110
+ const file = pathsFor(home).machine;
111
+ const parsed = await readJsonIfExists(file);
112
+ if (parsed !== undefined && typeof parsed.machineId === "string" && parsed.machineId.length > 0) {
113
+ const label = typeof parsed.label === "string" ? parsed.label : "";
114
+ return {
115
+ machineId: parsed.machineId,
116
+ label,
117
+ createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
118
+ mismatch: label.length > 0 && label !== os.hostname(),
119
+ hostname: os.hostname(),
120
+ };
121
+ }
122
+ const machine = {
123
+ machineId: crypto.randomUUID(),
124
+ label: os.hostname(),
125
+ createdAt: new Date().toISOString(),
126
+ };
127
+ await writeJsonAtomic(file, machine);
128
+ return { ...machine, mismatch: false, hostname: os.hostname() };
129
+ }
130
+
131
+ export async function resetMachine(home = dshHome()) {
132
+ const machine = {
133
+ machineId: crypto.randomUUID(),
134
+ label: os.hostname(),
135
+ createdAt: new Date().toISOString(),
136
+ };
137
+ await writeJsonAtomic(pathsFor(home).machine, machine);
138
+ return machine;
139
+ }
140
+
141
+ /* ------------------------------------------------------------------ */
142
+ /* global state (approvals, pauses, profile-transfer signature) */
143
+ /* ------------------------------------------------------------------ */
144
+
145
+ export async function loadGlobal(home = dshHome()) {
146
+ const parsed = await readJsonIfExists(pathsFor(home).global);
147
+ const value = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
148
+ return {
149
+ version: 1,
150
+ approvedMachines: value.approvedMachines !== null && typeof value.approvedMachines === "object" ? { ...value.approvedMachines } : {},
151
+ pendingApprovals: value.pendingApprovals !== null && typeof value.pendingApprovals === "object" ? { ...value.pendingApprovals } : {},
152
+ lastProfileTransfer: value.lastProfileTransfer ?? null,
153
+ paused: value.paused ?? null,
154
+ lastCycleAt: typeof value.lastCycleAt === "string" ? value.lastCycleAt : null,
155
+ lastPullAt: typeof value.lastPullAt === "string" ? value.lastPullAt : null,
156
+ lastPushAt: typeof value.lastPushAt === "string" ? value.lastPushAt : null,
157
+ warnings: Array.isArray(value.warnings) ? value.warnings.slice(0, 20) : [],
158
+ };
159
+ }
160
+
161
+ export async function saveGlobal(home, global) {
162
+ await writeJsonAtomic(pathsFor(home).global, global);
163
+ }
164
+
165
+ /* ------------------------------------------------------------------ */
166
+ /* per-project state */
167
+ /* ------------------------------------------------------------------ */
168
+
169
+ export function emptyProjectState(projectKey, localSlug, fingerprint) {
170
+ return {
171
+ version: 1,
172
+ projectKey,
173
+ localSlug,
174
+ fingerprint,
175
+ base: {},
176
+ remotes: {},
177
+ lastExport: null,
178
+ exportRevision: 0,
179
+ conflictsSeen: {},
180
+ updatedAt: null,
181
+ };
182
+ }
183
+
184
+ export async function loadProjectState(home, projectKey) {
185
+ const file = path.join(pathsFor(home).stateDir, `${projectKey}.json`);
186
+ const parsed = await readJsonIfExists(file);
187
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
188
+ if (parsed.version !== 1) return undefined;
189
+ return {
190
+ ...emptyProjectState(parsed.projectKey ?? projectKey, parsed.localSlug, parsed.fingerprint ?? ""),
191
+ ...parsed,
192
+ base: parsed.base !== null && typeof parsed.base === "object" ? { ...parsed.base } : {},
193
+ remotes: parsed.remotes !== null && typeof parsed.remotes === "object" ? { ...parsed.remotes } : {},
194
+ conflictsSeen: parsed.conflictsSeen !== null && typeof parsed.conflictsSeen === "object" ? { ...parsed.conflictsSeen } : {},
195
+ };
196
+ }
197
+
198
+ export async function saveProjectState(home, state) {
199
+ state.updatedAt = new Date().toISOString();
200
+ await writeJsonAtomic(path.join(pathsFor(home).stateDir, `${state.projectKey}.json`), state);
201
+ }
202
+
203
+ /* ------------------------------------------------------------------ */
204
+ /* registry (read-only) and project mapping */
205
+ /* ------------------------------------------------------------------ */
206
+
207
+ export async function readRegistry(home = dshHome()) {
208
+ const parsed = await readJsonIfExists(path.join(memoryRoot(home), "registry.json"));
209
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { projects: [], settings: {} };
210
+ const projects = Array.isArray(parsed.projects)
211
+ ? parsed.projects.filter((project) => project !== null && typeof project === "object" && typeof project.slug === "string")
212
+ : [];
213
+ return { projects, settings: parsed.settings ?? {} };
214
+ }
215
+
216
+ export function projectKeyFor(fingerprint) {
217
+ return sha256(String(fingerprint ?? "")).slice(0, 12);
218
+ }
219
+
220
+ /** Projects that can participate in sync on this machine. */
221
+ export async function syncTargets(home, config) {
222
+ const registry = await readRegistry(home);
223
+ const links = config.links ?? {};
224
+ const excluded = new Set(config.excludeProjects ?? []);
225
+ const targets = [];
226
+ const skipped = [];
227
+ const seenSlugs = new Set();
228
+ for (const project of registry.projects) {
229
+ const slug = String(project.slug);
230
+ if (seenSlugs.has(slug)) continue;
231
+ seenSlugs.add(slug);
232
+ if (excluded.has(slug)) continue;
233
+ const fingerprint = String(project.fingerprint ?? "");
234
+ let projectKey;
235
+ if (fingerprint.startsWith("git:")) projectKey = projectKeyFor(fingerprint);
236
+ else if (typeof links[slug] === "string" && /^[a-f0-9]{12}$/u.test(links[slug])) projectKey = links[slug];
237
+ if (!projectKey) {
238
+ skipped.push({ slug, reason: "не-git проект без /sync link" });
239
+ continue;
240
+ }
241
+ const directory = path.join(memoryRoot(home), slug);
242
+ if (!(await exists(directory))) {
243
+ skipped.push({ slug, reason: "каталог досье не найден" });
244
+ continue;
245
+ }
246
+ targets.push({
247
+ slug,
248
+ name: typeof project.name === "string" ? project.name : slug,
249
+ fingerprint,
250
+ projectKey,
251
+ directory,
252
+ roots: Array.isArray(project.roots) ? project.roots.filter((root) => typeof root === "string") : [],
253
+ });
254
+ }
255
+ return { targets, skipped, registry };
256
+ }
257
+
258
+ /* ------------------------------------------------------------------ */
259
+ /* folder helpers */
260
+ /* ------------------------------------------------------------------ */
261
+
262
+ export async function resolveWritableFolder(folder, { home = dshHome(), projectRoots = [] } = {}) {
263
+ if (typeof folder !== "string" || folder.trim().length === 0) {
264
+ throw new BridgeError("folder/empty", "папка обмена не задана: /sync folder <путь>");
265
+ }
266
+ const resolved = path.resolve(folder.trim());
267
+ let stat;
268
+ try {
269
+ stat = await fsp.stat(resolved);
270
+ } catch {
271
+ throw new BridgeError("folder/missing", `папка обмена не найдена: ${resolved}`);
272
+ }
273
+ if (!stat.isDirectory()) throw new BridgeError("folder/not-directory", `это не папка: ${resolved}`);
274
+ const homeResolved = path.resolve(home);
275
+ const relativeHome = path.relative(homeResolved, resolved);
276
+ if (relativeHome.length === 0 || (!relativeHome.startsWith("..") && !path.isAbsolute(relativeHome))) {
277
+ throw new BridgeError("folder/inside-home", "папка обмена не должна находиться внутри DSH_HOME");
278
+ }
279
+ for (const root of projectRoots) {
280
+ const relative = path.relative(path.resolve(root), resolved);
281
+ if (relative.length === 0 || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
282
+ throw new BridgeError("folder/inside-project", `папка обмена не должна находиться внутри проекта: ${root}`);
283
+ }
284
+ }
285
+ const probe = path.join(resolved, `.dsh-memory-bridge-write-test-${process.pid}-${Date.now()}`);
286
+ try {
287
+ await fsp.writeFile(probe, "ok", { mode: 0o600 });
288
+ await removeFileQuiet(probe);
289
+ } catch {
290
+ throw new BridgeError("folder/not-writable", `нет прав на запись в папку обмена: ${resolved}`);
291
+ }
292
+ return resolved;
293
+ }
294
+
295
+ export function projectFolderName(project, config) {
296
+ if (config.folderNaming === "opaque") return project.projectKey;
297
+ const slug = String(project.slug ?? "project").replace(/[^A-Za-z0-9._-]+/gu, "-").slice(0, 40) || "project";
298
+ return `${slug}--${project.projectKey.slice(0, 8)}`;
299
+ }
300
+
301
+ /* ------------------------------------------------------------------ */
302
+ /* backups */
303
+ /* ------------------------------------------------------------------ */
304
+
305
+ function backupStamp() {
306
+ const d = new Date();
307
+ const pad = (value) => String(value).padStart(2, "0");
308
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
309
+ }
310
+
311
+ export async function backupDossier(home, slug, directory, limits = {}) {
312
+ const destination = path.join(pathsFor(home).backupsDir, slug, backupStamp());
313
+ await ensureDir(destination);
314
+ let copied = 0;
315
+ for (const name of SECTION_FILES) await copyIfExists(path.join(directory, name), path.join(destination, name), limits);
316
+ const journalDir = path.join(directory, "JOURNAL");
317
+ try {
318
+ for (const entry of await fsp.readdir(journalDir, { withFileTypes: true })) {
319
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
320
+ await copyIfExists(path.join(journalDir, entry.name), path.join(destination, "JOURNAL", entry.name), limits);
321
+ }
322
+ } catch {
323
+ /* no journal yet */
324
+ }
325
+ const manifest = { createdAt: new Date().toISOString(), slug, files: copied };
326
+ await writeJsonAtomic(path.join(destination, "backup.json"), manifest);
327
+ await pruneBackups(home, slug, 10);
328
+ return { directory: destination, ...manifest };
329
+ }
330
+
331
+ async function copyIfExists(source, target, limits = {}) {
332
+ try {
333
+ const stat = await fsp.lstat(source);
334
+ if (stat.isSymbolicLink() || !stat.isFile()) return;
335
+ if (Number.isFinite(limits.maxFileBytes) && stat.size > limits.maxFileBytes) return;
336
+ await ensureDir(path.dirname(target));
337
+ await fsp.copyFile(source, target);
338
+ } catch {
339
+ /* missing file is fine */
340
+ }
341
+ }
342
+
343
+ async function pruneBackups(home, slug, keep) {
344
+ const directory = path.join(pathsFor(home).backupsDir, slug);
345
+ try {
346
+ const entries = (await fsp.readdir(directory, { withFileTypes: true }))
347
+ .filter((entry) => entry.isDirectory())
348
+ .map((entry) => entry.name)
349
+ .sort()
350
+ .reverse();
351
+ for (const name of entries.slice(keep)) await fsp.rm(path.join(directory, name), { recursive: true, force: true });
352
+ } catch {
353
+ /* nothing to prune */
354
+ }
355
+ }
356
+
357
+ export async function listBackups(home, slug) {
358
+ const directory = path.join(pathsFor(home).backupsDir, slug);
359
+ try {
360
+ return (await fsp.readdir(directory, { withFileTypes: true }))
361
+ .filter((entry) => entry.isDirectory())
362
+ .map((entry) => entry.name)
363
+ .sort()
364
+ .reverse();
365
+ } catch {
366
+ return [];
367
+ }
368
+ }
369
+
370
+ export async function restoreBackup(home, slug, directory, backupId) {
371
+ const source = path.join(pathsFor(home).backupsDir, slug, backupId);
372
+ if (!(await exists(source))) throw new BridgeError("backup/missing", `бэкап не найден: ${backupId}`);
373
+ await backupDossier(home, slug, directory);
374
+ let restored = 0;
375
+ for (const name of SECTION_FILES) {
376
+ const from = path.join(source, name);
377
+ if (!(await exists(from))) continue;
378
+ await writeFileAtomic(path.join(directory, name), await fsp.readFile(from));
379
+ restored += 1;
380
+ }
381
+ const journalFrom = path.join(source, "JOURNAL");
382
+ try {
383
+ for (const entry of await fsp.readdir(journalFrom, { withFileTypes: true })) {
384
+ if (!entry.isFile()) continue;
385
+ await writeFileAtomic(path.join(directory, "JOURNAL", entry.name), await fsp.readFile(path.join(journalFrom, entry.name)));
386
+ restored += 1;
387
+ }
388
+ } catch {
389
+ /* no journal in backup */
390
+ }
391
+ const global = await loadGlobal(home);
392
+ global.paused = {
393
+ reason: "восстановлен бэкап досье — сверьте версии и выполните /sync pull (или /sync push, если восстановление намеренное)",
394
+ at: new Date().toISOString(),
395
+ };
396
+ await saveGlobal(home, global);
397
+ return { restored, source, paused: true };
398
+ }
399
+
400
+ /* ------------------------------------------------------------------ */
401
+ /* incoming / conflicts / log */
402
+ /* ------------------------------------------------------------------ */
403
+
404
+ export async function writeIncoming(home, projectKey, machineId, relativePath, text) {
405
+ const safe = safeRelPath(relativePath);
406
+ const target = path.join(pathsFor(home).incomingDir, projectKey, String(machineId).replace(/[^A-Za-z0-9._-]+/gu, "_"), ...safe.split("/"));
407
+ await writeFileAtomic(target, text);
408
+ return target;
409
+ }
410
+
411
+ export async function appendConflict(home, slug, { machineId, relativePath, remoteHash, reason, remoteText }) {
412
+ const key = `${machineId}:${relativePath}:${remoteHash}`;
413
+ const file = path.join(pathsFor(home).conflictsDir, `${slug}.md`);
414
+ const raw = (await readTextIfExists(file)) ?? `# Конфликты синхронизации — ${slug}\n\n> Локальная версия оставлена без изменений. Чужая версия лежит в incoming и в этом отчёте.\n`;
415
+ if (raw.includes(`<!-- conflict:${key} -->`)) return { added: false, key, file };
416
+ const entry = [
417
+ "",
418
+ `<!-- conflict:${key} -->`,
419
+ `## ${new Date().toISOString()} · ${machineId} · ${relativePath}`,
420
+ `Причина: ${reason}`,
421
+ `Чужая версия сохранена в каталоге incoming (memory-sync/incoming). Хэш: \`${remoteHash}\``,
422
+ "",
423
+ "```",
424
+ String(remoteText ?? "").slice(0, 4000),
425
+ "```",
426
+ "",
427
+ ].join("\n");
428
+ await writeFileAtomic(file, `${raw.trimEnd()}\n${entry}`);
429
+ return { added: true, key, file };
430
+ }
431
+
432
+ export async function readConflictReportText(home, slug) {
433
+ return readTextIfExists(path.join(pathsFor(home).conflictsDir, `${slug}.md`));
434
+ }
435
+
436
+ /** Snapshot of the content that was accepted as the local synced state. */
437
+ export async function writeBaseSnapshot(home, projectKey, content) {
438
+ const directory = path.join(pathsFor(home).baseDir, projectKey);
439
+ try {
440
+ await ensureDir(directory);
441
+ for (const [relativePath, text] of content) {
442
+ const safe = safeRelPath(relativePath);
443
+ await writeFileAtomic(path.join(directory, ...safe.split("/")), text);
444
+ }
445
+ } catch {
446
+ /* base snapshots are diagnostics only; the hashes in state.json are authoritative */
447
+ }
448
+ }
449
+
450
+ export async function logLine(home, message) {
451
+ try {
452
+ const file = pathsFor(home).logFile;
453
+ const line = `${new Date().toISOString()} ${String(message).replace(/\s+/gu, " ").slice(0, 500)}\n`;
454
+ await ensureDir(path.dirname(file));
455
+ await fsp.appendFile(file, line, { mode: 0o600 });
456
+ const stat = await fsp.stat(file);
457
+ if (stat.size > 160 * 1024) {
458
+ const raw = await fsp.readFile(file, "utf8");
459
+ const tail = raw.split(/\r?\n/u).slice(-300).join("\n");
460
+ await writeFileAtomic(file, `${tail}\n`);
461
+ }
462
+ } catch {
463
+ /* diagnostics must never break sync */
464
+ }
465
+ }
466
+
467
+ /* ------------------------------------------------------------------ */
468
+ /* environment probes */
469
+ /* ------------------------------------------------------------------ */
470
+
471
+ export function kitVersion() {
472
+ const candidates = [];
473
+ try {
474
+ const require = createRequire(import.meta.url);
475
+ candidates.push(require.resolve("dsh-desktop-kit/package.json"));
476
+ } catch {
477
+ /* installed through a local link: node_modules is not a sibling */
478
+ }
479
+ const profileDir = process.env.DSH_PROFILE_DIR;
480
+ if (typeof profileDir === "string" && profileDir.length > 0) {
481
+ candidates.push(path.join(profileDir, "node_modules", "dsh-desktop-kit", "package.json"));
482
+ }
483
+ const home = dshHome();
484
+ const profileName = process.env.DSH_PROFILE;
485
+ if (typeof profileName === "string" && profileName.length > 0) {
486
+ candidates.push(path.join(home, "profiles", profileName, "node_modules", "dsh-desktop-kit", "package.json"));
487
+ }
488
+ candidates.push(path.join(home, "profiles", "desktop", "node_modules", "dsh-desktop-kit", "package.json"));
489
+ for (const file of candidates) {
490
+ try {
491
+ const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
492
+ if (typeof manifest.version === "string") return manifest.version;
493
+ } catch {
494
+ /* try the next candidate */
495
+ }
496
+ }
497
+ return undefined;
498
+ }
499
+
500
+ export async function profileTransferSignature(home = dshHome()) {
501
+ try {
502
+ const stat = await fsp.stat(path.join(home, "profile-transfer-last.json"));
503
+ return { mtimeMs: stat.mtimeMs, size: stat.size };
504
+ } catch {
505
+ return null;
506
+ }
507
+ }
508
+
509
+ /* ------------------------------------------------------------------ */
510
+ /* local dossier read / sync content */
511
+ /* ------------------------------------------------------------------ */
512
+
513
+ /**
514
+ * Read the dossier files that participate in sync.
515
+ * @returns {Promise<{raw:Map<string,string>, content:Map<string,string>, skipped:Array}>}
516
+ */
517
+ export async function readLocalDossier(directory, { maxFileBytes, maxBundleBytes, maxFiles }) {
518
+ const raw = new Map();
519
+ const content = new Map();
520
+ const skipped = [];
521
+ let total = 0;
522
+ const add = async (relativePath) => {
523
+ const file = path.join(directory, ...relativePath.split("/"));
524
+ try {
525
+ const stat = await fsp.lstat(file);
526
+ if (stat.isSymbolicLink() || !stat.isFile()) return;
527
+ if (stat.size > maxFileBytes) {
528
+ skipped.push({ path: relativePath, reason: `больше лимита файла (${stat.size} B)` });
529
+ return;
530
+ }
531
+ const text = await fsp.readFile(file, "utf8");
532
+ total += Buffer.byteLength(text, "utf8");
533
+ if (total > maxBundleBytes) {
534
+ skipped.push({ path: relativePath, reason: "пакет превысил лимит" });
535
+ return;
536
+ }
537
+ raw.set(relativePath, text);
538
+ } catch (error) {
539
+ if (error?.code !== "ENOENT") skipped.push({ path: relativePath, reason: String(error?.message ?? error) });
540
+ }
541
+ };
542
+ for (const name of SECTION_FILES) await add(name);
543
+ try {
544
+ const journalDir = path.join(directory, "JOURNAL");
545
+ const entries = (await fsp.readdir(journalDir, { withFileTypes: true }))
546
+ .filter((entry) => entry.isFile() && /^\d{4}-\d{2}\.md$/u.test(entry.name))
547
+ .map((entry) => entry.name)
548
+ .sort();
549
+ for (const name of entries) {
550
+ if (raw.size >= maxFiles) {
551
+ skipped.push({ path: `JOURNAL/${name}`, reason: "превышено число файлов" });
552
+ continue;
553
+ }
554
+ await add(`JOURNAL/${name}`);
555
+ }
556
+ } catch {
557
+ /* no journal directory yet */
558
+ }
559
+ for (const [relativePath, text] of raw) {
560
+ content.set(relativePath, isAllowedSyncPath(relativePath) ? text : "");
561
+ }
562
+ return { raw, content, skipped };
563
+ }
564
+
565
+ export { hashContentMap };