dsh-desktop-kit 2.2.1 → 2.7.3

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 (130) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +114 -27
  3. package/client.js +3270 -32
  4. package/docs/AGENT-HARNESS.md +144 -0
  5. package/docs/ARCHITECTURE.md +38 -7
  6. package/docs/COMPATIBILITY.md +4 -2
  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 +5 -5
  14. package/docs/TEST-2.3.0.md +49 -0
  15. package/docs/TEST-2.7.2.md +46 -0
  16. package/docs/TEST-2.7.3.md +20 -0
  17. package/docs/TOKENS.md +2 -2
  18. package/docs/TROUBLESHOOTING.md +27 -0
  19. package/docs/UI-QA.md +28 -0
  20. package/docs/images/peak-toast-peak.png +0 -0
  21. package/docs/images/peak-toast-soon.png +0 -0
  22. package/docs/preview/peak-toast.html +155 -0
  23. package/index.js +18 -1
  24. package/package.json +26 -6
  25. package/plugins/dsh-agent-extensions/README.md +107 -0
  26. package/plugins/dsh-agent-extensions/client.js +157 -0
  27. package/plugins/dsh-agent-extensions/index.js +174 -0
  28. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  29. package/plugins/dsh-agent-extensions/lib/api.js +53 -0
  30. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  31. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  32. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  33. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  34. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  35. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  36. package/plugins/dsh-agent-extensions/package.json +20 -0
  37. package/plugins/dsh-agent-extensions/screenshots/agents-1440.png +0 -0
  38. package/plugins/dsh-agent-extensions/screenshots/agents-390.png +0 -0
  39. package/plugins/dsh-browser-qa/README.md +221 -0
  40. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  41. package/plugins/dsh-browser-qa/index.js +165 -0
  42. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  43. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  44. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  45. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  46. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  47. package/plugins/dsh-browser-qa/package.json +31 -0
  48. package/plugins/dsh-checkpoints/README.md +22 -0
  49. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  50. package/plugins/dsh-checkpoints/index.js +80 -0
  51. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  52. package/plugins/dsh-checkpoints/package.json +25 -0
  53. package/plugins/dsh-github-review/README.md +131 -0
  54. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  55. package/plugins/dsh-github-review/index.js +536 -0
  56. package/plugins/dsh-github-review/lib/gh.js +494 -0
  57. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  58. package/plugins/dsh-github-review/package.json +28 -0
  59. package/plugins/dsh-guardrails/README.md +264 -0
  60. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  61. package/plugins/dsh-guardrails/index.js +389 -0
  62. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  63. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  64. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  65. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  66. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  67. package/plugins/dsh-guardrails/package.json +26 -0
  68. package/plugins/dsh-mcp-manager/README.md +78 -0
  69. package/plugins/dsh-mcp-manager/client.js +373 -0
  70. package/plugins/dsh-mcp-manager/index.js +159 -0
  71. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  72. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  73. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  74. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  75. package/plugins/dsh-mcp-manager/package.json +18 -0
  76. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  77. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  78. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  79. package/plugins/dsh-memory-bridge/README.md +161 -0
  80. package/plugins/dsh-memory-bridge/client.js +2264 -0
  81. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  82. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  83. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  84. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  85. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  86. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  87. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  88. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  89. package/plugins/dsh-memory-bridge/index.js +378 -0
  90. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  91. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  92. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  93. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  94. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  95. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  96. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  97. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  98. package/plugins/dsh-memory-bridge/package.json +67 -0
  99. package/plugins/dsh-peak-guard/README.md +16 -3
  100. package/plugins/dsh-peak-guard/client.js +223 -8
  101. package/plugins/dsh-peak-guard/package.json +1 -1
  102. package/plugins/dsh-plan-mode/README.md +24 -0
  103. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  104. package/plugins/dsh-plan-mode/index.js +103 -0
  105. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  106. package/plugins/dsh-plan-mode/package.json +18 -0
  107. package/plugins/dsh-profile-transfer/client.js +277 -17
  108. package/plugins/dsh-profile-transfer/index.js +10 -0
  109. package/plugins/dsh-profile-transfer/package.json +3 -1
  110. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  111. package/plugins/dsh-project-memory/index.js +92 -2
  112. package/plugins/dsh-project-memory/package.json +1 -1
  113. package/plugins/dsh-repo-map/README.md +26 -0
  114. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  115. package/plugins/dsh-repo-map/index.js +99 -0
  116. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  117. package/plugins/dsh-repo-map/package.json +25 -0
  118. package/plugins/dsh-skill-autopilot/index.js +135 -11
  119. package/skills/delivery-gate/SKILL.md +31 -0
  120. package/skills/git-and-ci/SKILL.md +5 -1
  121. package/skills/manifest.json +31 -6
  122. package/skills/requirements-and-planning/SKILL.md +3 -0
  123. package/skills/research-first/SKILL.md +35 -0
  124. package/skills/research-first/references/query-patterns.md +20 -0
  125. package/skills/subagent-playbook/SKILL.md +36 -0
  126. package/skills/subagent-playbook/references/prompts.md +39 -0
  127. package/skills/ui-craft/SKILL.md +43 -0
  128. package/skills/visual-qa/SKILL.md +29 -0
  129. package/skills/visual-qa/references/commands.md +32 -0
  130. package/skills-baseline.json +219 -0
@@ -0,0 +1,473 @@
1
+ /**
2
+ * dsh-memory-bridge — GitHub transport primitives.
3
+ *
4
+ * The exchange repository holds the merged dossier itself (not per-machine
5
+ * snapshots). A machine keeps local dossiers as its working copy and a local
6
+ * snapshot of the last successfully synchronised content ("base"). A sync cycle
7
+ * is: fetch origin → reset the derived clone → three-way merge every file
8
+ * (local / base / origin) → commit → push with retry. A rejected push is never
9
+ * forced: the cycle re-fetches and merges again, so another machine's work
10
+ * cannot be overwritten by "whoever pushed last".
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+
17
+ import {
18
+ BridgeError,
19
+ DEFAULT_LIMITS,
20
+ JOURNAL_FILE,
21
+ SECTION_FILES,
22
+ canonicalizePaths,
23
+ ensureDir,
24
+ exists,
25
+ extractAutoScanBlocks,
26
+ isTemplateContent,
27
+ localizePaths,
28
+ normalizeLf,
29
+ readTextIfExists,
30
+ redactSecrets,
31
+ sha256,
32
+ stripAutoScan,
33
+ writeFileAtomic,
34
+ } from "./bridge.js";
35
+ import { splitJournal } from "./merge.js";
36
+
37
+ const GIT_TIMEOUT_MS = 120_000;
38
+ const MAX_GIT_OUTPUT = 64 * 1024;
39
+
40
+ /* ------------------------------------------------------------------ */
41
+ /* git process */
42
+ /* ------------------------------------------------------------------ */
43
+
44
+ function lastLine(value, max = 300) {
45
+ const text = String(value ?? "").trim().split(/\r?\n/u).filter(Boolean).at(-1) ?? "";
46
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
47
+ }
48
+
49
+ export function runProcess(command, args, { cwd, env = {}, timeoutMs = GIT_TIMEOUT_MS } = {}) {
50
+ return new Promise((resolve) => {
51
+ let child;
52
+ try {
53
+ child = spawn(command, args, {
54
+ cwd,
55
+ windowsHide: true,
56
+ env: {
57
+ ...process.env,
58
+ GIT_TERMINAL_PROMPT: "0",
59
+ GCM_INTERACTIVE: "Never",
60
+ GIT_MERGE_AUTOEDIT: "no",
61
+ ...env,
62
+ },
63
+ });
64
+ } catch (error) {
65
+ resolve({ ok: false, code: -1, stdout: "", stderr: `${command} spawn failed: ${String(error)}` });
66
+ return;
67
+ }
68
+ let stdout = "";
69
+ let stderr = "";
70
+ let settled = false;
71
+ const timer = setTimeout(() => {
72
+ if (settled) return;
73
+ try {
74
+ child.kill();
75
+ } catch {
76
+ /* already gone */
77
+ }
78
+ settled = true;
79
+ resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\nтаймаут ${command} ${timeoutMs} мс`.trim() });
80
+ }, timeoutMs);
81
+ timer.unref?.();
82
+ child.stdout?.on("data", (chunk) => {
83
+ stdout = `${stdout}${chunk.toString()}`.slice(-MAX_GIT_OUTPUT);
84
+ });
85
+ child.stderr?.on("data", (chunk) => {
86
+ stderr = `${stderr}${chunk.toString()}`.slice(-MAX_GIT_OUTPUT);
87
+ });
88
+ child.on("error", (error) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\n${String(error)}`.trim() });
93
+ });
94
+ child.on("close", (code) => {
95
+ if (settled) return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ resolve({ ok: code === 0, code: Number(code), stdout, stderr });
99
+ });
100
+ });
101
+ }
102
+
103
+ export function runGit(args, options = {}) {
104
+ return runProcess("git", args, options);
105
+ }
106
+
107
+ export function runGh(args, options = {}) {
108
+ return runProcess("gh", args, { timeoutMs: 60_000, ...options });
109
+ }
110
+
111
+ export async function gitVersion() {
112
+ const result = await runGit(["--version"], { timeoutMs: 15_000 });
113
+ return result.ok ? result.stdout.trim() : undefined;
114
+ }
115
+
116
+ export async function ghVersion() {
117
+ const result = await runGh(["--version"], { timeoutMs: 15_000 });
118
+ return result.ok ? result.stdout.trim().split(/\r?\n/u)[0] : undefined;
119
+ }
120
+
121
+ /** Login of the GitHub account authenticated in the `gh` CLI, if any. */
122
+ export async function ghLogin() {
123
+ const result = await runGh(["api", "user", "--jq", ".login"], { timeoutMs: 20_000 });
124
+ if (!result.ok) return undefined;
125
+ const login = result.stdout.trim();
126
+ return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/u.test(login) ? login : undefined;
127
+ }
128
+
129
+ /** `private`, `public`, `missing` or `unknown` (no gh / no access). */
130
+ export async function repoVisibility(repo) {
131
+ if (!isRepoSlug(repo)) return "unknown";
132
+ const result = await runGh(["repo", "view", repo, "--json", "visibility", "--jq", ".visibility"], { timeoutMs: 25_000 });
133
+ if (result.ok) {
134
+ const visibility = result.stdout.trim().toLowerCase();
135
+ return visibility === "private" || visibility === "public" ? visibility : "unknown";
136
+ }
137
+ if (/not found|could not resolve/i.test(result.stderr)) return "missing";
138
+ return "unknown";
139
+ }
140
+
141
+ export async function createPrivateRepo(repo, description = "Private exchange repository for DSH project-memory dossiers") {
142
+ if (!isRepoSlug(repo)) throw new BridgeError("github/bad-repo", "укажите репозиторий в виде owner/name");
143
+ const result = await runGh(["repo", "create", repo, "--private", "--add-readme", "--description", description], {
144
+ timeoutMs: 90_000,
145
+ });
146
+ if (!result.ok) {
147
+ if (/already exists|name already exists/i.test(result.stderr)) {
148
+ throw new BridgeError("github/repo-exists", `репозиторий ${repo} уже существует — нажмите «Подключить существующий»`);
149
+ }
150
+ throw new BridgeError("github/create-failed", gitError(result, `не удалось создать приватный репозиторий ${repo}`));
151
+ }
152
+ return { repo };
153
+ }
154
+
155
+ /* ------------------------------------------------------------------ */
156
+ /* repository URLs and local directories */
157
+ /* ------------------------------------------------------------------ */
158
+
159
+ const REPO_SLUG = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
160
+
161
+ export function isRepoSlug(value) {
162
+ return typeof value === "string" && REPO_SLUG.test(value.trim());
163
+ }
164
+
165
+ /** Default exchange repository name for one GitHub account. */
166
+ export function suggestedExchangeRepo(login) {
167
+ return typeof login === "string" && login.length > 0 ? `${login}/dsh-memory-exchange` : null;
168
+ }
169
+
170
+ /** Accept `owner/name` (GitHub), an HTTPS/SSH URL, or a local path (tests). */
171
+ export function repoRemoteUrl(repo) {
172
+ const value = String(repo ?? "").trim();
173
+ if (value.length === 0) throw new BridgeError("github/no-repo", "репозиторий не задан");
174
+ if (isRepoSlug(value)) return `https://github.com/${value}.git`;
175
+ if (/^https?:\/\//iu.test(value) || /^git@/iu.test(value)) return value;
176
+ if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value)) return value;
177
+ throw new BridgeError("github/bad-repo", "укажите репозиторий в виде owner/name");
178
+ }
179
+
180
+ export function repoFolderName(repo) {
181
+ return String(repo ?? "")
182
+ .trim()
183
+ .replace(/^https?:\/\/github\.com\//iu, "")
184
+ .replace(/\.git$/iu, "")
185
+ .replace(/[^A-Za-z0-9._-]+/gu, "-")
186
+ .slice(0, 80) || "exchange";
187
+ }
188
+
189
+ export function repoWebUrl(repo) {
190
+ const value = String(repo ?? "").trim();
191
+ return isRepoSlug(value) ? `https://github.com/${value}` : value;
192
+ }
193
+
194
+ function gitError(result, fallback) {
195
+ const detail = lastLine(result?.stderr) || lastLine(result?.stdout);
196
+ return detail.length > 0 ? `${fallback}: ${detail}` : fallback;
197
+ }
198
+
199
+ /* ------------------------------------------------------------------ */
200
+ /* clone / fetch / commit / push */
201
+ /* ------------------------------------------------------------------ */
202
+
203
+ export async function cloneRepo({ directory, repo }) {
204
+ const url = repoRemoteUrl(repo);
205
+ await ensureDir(path.dirname(directory));
206
+ if (await exists(path.join(directory, ".git"))) return { ok: true, cloned: false, url };
207
+ if (await exists(directory)) {
208
+ // A derived clone without .git is disposable: rebuild it from the remote.
209
+ await fs.rm(directory, { recursive: true, force: true });
210
+ }
211
+ const result = await runGit(["clone", "--origin", "origin", url, directory]);
212
+ if (!result.ok) throw new BridgeError("github/clone-failed", gitError(result, `не удалось клонировать ${url}`));
213
+ return { ok: true, cloned: true, url };
214
+ }
215
+
216
+ export async function verifyRemote(repo) {
217
+ const url = repoRemoteUrl(repo);
218
+ const result = await runGit(["ls-remote", "--heads", url], { timeoutMs: 45_000 });
219
+ if (!result.ok) throw new BridgeError("github/no-access", gitError(result, `нет доступа к ${url}`));
220
+ const branches = result.stdout
221
+ .split(/\r?\n/u)
222
+ .map((line) => line.split(/\s+/u).at(-1) ?? "")
223
+ .map((line) => line.replace(/^refs\/heads\//u, ""))
224
+ .filter(Boolean);
225
+ return { url, branches };
226
+ }
227
+
228
+ export async function fetchOrigin(directory, branch) {
229
+ return runGit(["fetch", "--prune", "origin", branch], { cwd: directory });
230
+ }
231
+
232
+ export async function remoteHeadExists(directory, branch) {
233
+ const result = await runGit(["rev-parse", "--verify", "--quiet", `origin/${branch}`], { cwd: directory });
234
+ return result.ok;
235
+ }
236
+
237
+ export async function resetToRemote(directory, branch) {
238
+ const result = await runGit(["reset", "--hard", `origin/${branch}`], { cwd: directory });
239
+ if (!result.ok) throw new BridgeError("github/reset-failed", gitError(result, "не удалось обновить локальный клон репозитория"));
240
+ const clean = await runGit(["clean", "-fd", "projects"], { cwd: directory });
241
+ if (!clean.ok) throw new BridgeError("github/clean-failed", gitError(clean, "не удалось очистить клон репозитория"));
242
+ }
243
+
244
+ export async function ensureRepoIdentity(directory, machine) {
245
+ const name = await runGit(["config", "user.name"], { cwd: directory });
246
+ if (!name.ok || name.stdout.trim().length === 0) {
247
+ await runGit(["config", "user.name", `DSH Memory Bridge (${machine?.label ?? "machine"})`], { cwd: directory });
248
+ }
249
+ const email = await runGit(["config", "user.email"], { cwd: directory });
250
+ if (!email.ok || email.stdout.trim().length === 0) {
251
+ const safe = String(machine?.machineId ?? "machine").replace(/[^A-Za-z0-9._-]+/gu, "");
252
+ await runGit(["config", "user.email", `${safe}@dsh-memory-bridge.local`], { cwd: directory });
253
+ }
254
+ }
255
+
256
+ export async function commitRepo(directory, message) {
257
+ const add = await runGit(["add", "-A"], { cwd: directory });
258
+ if (!add.ok) throw new BridgeError("github/add-failed", gitError(add, "не удалось подготовить изменения"));
259
+ const staged = await runGit(["diff", "--cached", "--quiet"], { cwd: directory });
260
+ if (staged.ok) return { committed: false };
261
+ const commit = await runGit(["commit", "-m", message], { cwd: directory });
262
+ if (!commit.ok) throw new BridgeError("github/commit-failed", gitError(commit, "не удалось создать commit"));
263
+ return { committed: true };
264
+ }
265
+
266
+ export async function pushRepo(directory, branch) {
267
+ return runGit(["push", "origin", `HEAD:${branch}`], { cwd: directory });
268
+ }
269
+
270
+ /** Create the first commit when the remote repository is completely empty. */
271
+ export async function seedEmptyRepo(directory, branch, machine) {
272
+ await ensureRepoIdentity(directory, machine);
273
+ await ensureDir(path.join(directory, "projects"));
274
+ const readme = path.join(directory, "README.md");
275
+ if (!(await exists(readme))) {
276
+ const created = new Date().toISOString().slice(0, 10);
277
+ await writeFileAtomic(
278
+ readme,
279
+ `# dsh-memory-exchange\n\n![dsh-memory-bridge](https://cdn.jsdelivr.net/npm/dsh-memory-bridge@latest/docs/images/hero.png)\n\n**Приватный репозиторий обмена памятью проектов DeepSeek Harness.**\n\nСоздан плагином [\`dsh-memory-bridge\`](https://www.npmjs.com/package/dsh-memory-bridge)\nна машине «${machine?.label ?? "machine"}» ${created}.\n\nСюда плагин складывает **объединённые досье проектов**; вручную редактировать\nфайлы не нужно — плагин сам сливает изменения с разных АРМ.\n\n## Что внутри\n\n| Путь | Назначение |\n|---|---|\n| \`projects/<проект>--<key8>/\` | досье проекта: 6 разделов + журнал |\n| \`projects/<проект>--<key8>/project.json\` | fingerprint, имя, последний автор записи |\n| \`JOURNAL/YYYY-MM.md\` | журнал сессий проекта |\n\n## Как это работает\n\n1. Каждая машина перед синхронизацией получает свежую версию из этого репозитория.\n2. Досье сливается «моё / прошлая общая версия / версия из GitHub» (трёхстороннее слияние).\n3. Если одна и та же строка изменена по-разному — плагин показывает конфликт и не перезаписывает файл молча.\n\n## Безопасность\n\n- Репозиторий **приватный**: доступ только у владельца GitHub-аккаунта.\n- Плагин вырезает секреты и PII, домашние пути заменяет токеном \`%DSH_HOME%\`.\n- Не добавляйте сюда ключи, пароли, дампы и другие секреты вручную.\n\n## Управление\n\nВ DSH: **Настройки → «Память проектов»**. Останавливать синхронизацию удалением\nрепозитория не нужно — выключите её переключателем на странице.\n`,
280
+ );
281
+ }
282
+ await runGit(["add", "-A"], { cwd: directory });
283
+ const commit = await runGit(["commit", "-m", `init: exchange repository (${machine?.label ?? "machine"})`], { cwd: directory });
284
+ if (!commit.ok) throw new BridgeError("github/init-failed", gitError(commit, "не удалось создать первый commit"));
285
+ const push = await pushRepo(directory, branch);
286
+ if (!push.ok) throw new BridgeError("github/push-failed", gitError(push, "не удалось отправить первый commit"));
287
+ }
288
+
289
+ /* ------------------------------------------------------------------ */
290
+ /* content canonicalisation and three-way merge */
291
+ /* ------------------------------------------------------------------ */
292
+
293
+ /** Text that participates in comparison/merge: canonical, redacted, no AUTO-SCAN. */
294
+ export function syncText(relativePath, rawText) {
295
+ const redacted = normalizeLf(redactSecrets(rawText ?? ""));
296
+ const manual = SECTION_FILES.includes(relativePath) ? stripAutoScan(redacted) : redacted;
297
+ return canonicalizePaths(manual).trimEnd();
298
+ }
299
+
300
+ /** Local file content from merged canonical text: local home + its own AUTO-SCAN blocks. */
301
+ export function localFileText(relativePath, mergedText, localRawText) {
302
+ const localized = localizePaths(mergedText).trimEnd();
303
+ if (!SECTION_FILES.includes(relativePath)) return `${localized}\n`;
304
+ const blocks = extractAutoScanBlocks(localRawText ?? "");
305
+ const parts = [localized];
306
+ for (const block of blocks) parts.push(block.trim());
307
+ return `${parts.filter((part) => part.length > 0).join("\n\n")}\n`;
308
+ }
309
+
310
+ export async function readTreeFiles(directory) {
311
+ const files = new Map();
312
+ const add = async (relativePath) => {
313
+ const text = await readTextIfExists(path.join(directory, ...relativePath.split("/")));
314
+ if (text !== undefined) files.set(relativePath, text);
315
+ };
316
+ for (const name of SECTION_FILES) await add(name);
317
+ try {
318
+ const entries = (await fs.readdir(path.join(directory, "JOURNAL"), { withFileTypes: true }))
319
+ .filter((entry) => entry.isFile() && /^\d{4}-\d{2}\.md$/u.test(entry.name))
320
+ .map((entry) => entry.name)
321
+ .sort();
322
+ for (const name of entries) await add(`JOURNAL/${name}`);
323
+ } catch {
324
+ /* no journal */
325
+ }
326
+ return files;
327
+ }
328
+
329
+ export async function writeTreeFile(directory, relativePath, text) {
330
+ const target = path.join(directory, ...relativePath.split("/"));
331
+ await writeFileAtomic(target, text);
332
+ }
333
+
334
+ async function writeTemp(file, text) {
335
+ await ensureDir(path.dirname(file));
336
+ await writeFileAtomic(file, text, { mode: 0o600 });
337
+ }
338
+
339
+ function resolveAdditiveHunk(hunk) {
340
+ const all = [...hunk.local, ...hunk.remote];
341
+ const meaningful = all.map((line) => line.trim()).filter((line) => line.length > 0);
342
+ if (meaningful.length === 0) return { ok: true, lines: [] };
343
+ const additive = meaningful.every(
344
+ (line) => /^[-*]\s+/u.test(line) || /^#{2,4}\s+/u.test(line) || /^\d+\.\s+/u.test(line),
345
+ );
346
+ if (!additive) return { ok: false };
347
+ const seen = new Set();
348
+ const lines = [];
349
+ for (const line of all) {
350
+ const key = line.trim();
351
+ if (key.length === 0) {
352
+ lines.push(line);
353
+ continue;
354
+ }
355
+ if (seen.has(key)) continue;
356
+ seen.add(key);
357
+ lines.push(line);
358
+ }
359
+ return { ok: true, lines };
360
+ }
361
+
362
+ /**
363
+ * Three-way merge via `git merge-file`; additive hunks are resolved to a union,
364
+ * everything else is returned as an unresolved conflict (the caller keeps the
365
+ * local file and asks the user).
366
+ * @returns {Promise<{ok:boolean, text:string, unresolved:number, hunks:number}>}
367
+ */
368
+ export async function mergeThreeWay({ localText, baseText, remoteText, tmpDir }) {
369
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
370
+ const localPath = path.join(tmpDir, `${stamp}.local.md`);
371
+ const basePath = path.join(tmpDir, `${stamp}.base.md`);
372
+ const remotePath = path.join(tmpDir, `${stamp}.remote.md`);
373
+ await writeTemp(localPath, `${normalizeLf(localText).trimEnd()}\n`);
374
+ await writeTemp(basePath, `${normalizeLf(baseText ?? "").trimEnd()}\n`);
375
+ await writeTemp(remotePath, `${normalizeLf(remoteText).trimEnd()}\n`);
376
+ try {
377
+ const result = await runGit(
378
+ ["merge-file", "-p", "--diff3", "-L", "local", "-L", "base", "-L", "github", localPath, basePath, remotePath],
379
+ { timeoutMs: 30_000 },
380
+ );
381
+ const merged = result.stdout.replace(/\r\n/gu, "\n");
382
+ if (result.ok && result.code === 0) {
383
+ return { ok: true, text: merged.trimEnd(), unresolved: 0, hunks: 0 };
384
+ }
385
+ if (result.code > 0) {
386
+ const lines = merged.split("\n");
387
+ const resolvedLines = [];
388
+ let unresolved = 0;
389
+ let hunks = 0;
390
+ let mode = "normal";
391
+ let current;
392
+ for (const line of lines) {
393
+ if (line.startsWith("<<<<<<<")) {
394
+ current = { local: [], remote: [] };
395
+ mode = "local";
396
+ hunks += 1;
397
+ continue;
398
+ }
399
+ if (line.startsWith("|||||||")) {
400
+ mode = "base";
401
+ continue;
402
+ }
403
+ if (line.startsWith("=======") && current !== undefined) {
404
+ mode = "remote";
405
+ continue;
406
+ }
407
+ if (line.startsWith(">>>>>>>") && current !== undefined) {
408
+ const resolved = resolveAdditiveHunk(current);
409
+ if (resolved.ok) resolvedLines.push(...resolved.lines);
410
+ else {
411
+ unresolved += 1;
412
+ resolvedLines.push(...current.local);
413
+ }
414
+ current = undefined;
415
+ mode = "normal";
416
+ continue;
417
+ }
418
+ if (current !== undefined) {
419
+ if (mode === "local") current.local.push(line);
420
+ else if (mode === "remote") current.remote.push(line);
421
+ continue;
422
+ }
423
+ resolvedLines.push(line);
424
+ }
425
+ return {
426
+ ok: unresolved === 0,
427
+ text: resolvedLines.join("\n").trimEnd(),
428
+ unresolved,
429
+ hunks,
430
+ };
431
+ }
432
+ throw new BridgeError("github/merge-failed", gitError(result, "git merge-file завершился ошибкой"));
433
+ } finally {
434
+ for (const file of [localPath, basePath, remotePath]) {
435
+ try {
436
+ await fs.rm(file, { force: true });
437
+ } catch {
438
+ /* temp cleanup best effort */
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ /** Journal-specific fallback: union entries, keep both sides when headers collide differently. */
445
+ export function mergeJournalAdditive(localText, remoteText) {
446
+ const local = splitJournal(localText);
447
+ const remote = splitJournal(remoteText);
448
+ const localKeys = new Set(local.entries.map((entry) => entry.key));
449
+ const headers = new Map(local.entries.map((entry) => [entry.header, entry]));
450
+ const conflicts = [];
451
+ const additions = [];
452
+ for (const entry of remote.entries) {
453
+ const twin = headers.get(entry.header);
454
+ if (twin !== undefined && twin.key !== entry.key) {
455
+ conflicts.push(entry);
456
+ continue;
457
+ }
458
+ if (localKeys.has(entry.key)) continue;
459
+ localKeys.add(entry.key);
460
+ additions.push(entry);
461
+ }
462
+ if (additions.length === 0) {
463
+ return { text: localText.trimEnd(), added: 0, conflicts };
464
+ }
465
+ const head = local.head.length > 0 ? local.head : remote.head;
466
+ const entries = [...local.entries, ...additions].sort((left, right) => (left.time ?? 0) - (right.time ?? 0));
467
+ const pieces = head.length > 0 ? [head, ...entries.map((entry) => entry.raw)] : entries.map((entry) => entry.raw);
468
+ return { text: `${pieces.join("\n\n").trimEnd()}\n`, added: additions.length, conflicts };
469
+ }
470
+
471
+ export function hashText(text) {
472
+ return sha256(text);
473
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * dsh-memory-bridge — safe merge decisions.
3
+ *
4
+ * Rules (v1), deliberately conservative:
5
+ * - a remote version is accepted wholesale only when the local file did not
6
+ * change since the last synchronised state (`sha256(local) === base[path]`),
7
+ * when the local file is still a fresh project template, or when it is missing;
8
+ * - journals are merged additively: remote entries never overwrite local ones;
9
+ * - any other concurrent change is a conflict: local stays, remote goes to
10
+ * `incoming/` and the conflict report — never guessed.
11
+ */
12
+
13
+ import { SECTION_FILES, JOURNAL_FILE, sha256, stripAutoScan, isTemplateContent } from "./bridge.js";
14
+
15
+ const JOURNAL_HEADER = /^#{2,3}\s+(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}))?/u;
16
+
17
+ function normaliseHeader(header) {
18
+ return String(header ?? "").replace(/\s+/gu, " ").trim();
19
+ }
20
+
21
+ function normaliseBody(body) {
22
+ return String(body ?? "")
23
+ .replace(/\r\n/gu, "\n")
24
+ .replace(/[ \t]+$/gmu, "")
25
+ .trim();
26
+ }
27
+
28
+ function entryTime(header) {
29
+ const match = JOURNAL_HEADER.exec(String(header ?? ""));
30
+ if (!match) return 0;
31
+ return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4] ?? 0), Number(match[5] ?? 0));
32
+ }
33
+
34
+ /**
35
+ * Split a journal file into a leading head and entries.
36
+ * Supports both formats used by the kit:
37
+ * `## YYYY-MM-DD — title` (manual records from the skill template)
38
+ * `### YYYY-MM-DD HH:MM · ...` (automatic records written by project-memory)
39
+ * @returns {{head:string, entries:Array<{header:string, body:string, raw:string, key:string, time:number}>}}
40
+ */
41
+ export function splitJournal(text) {
42
+ const value = String(text ?? "");
43
+ if (value.trim().length === 0) return { head: "", entries: [] };
44
+ const headLines = [];
45
+ const entries = [];
46
+ let current;
47
+ for (const line of value.split(/\r?\n/u)) {
48
+ if (JOURNAL_HEADER.test(line)) {
49
+ if (current) entries.push(current);
50
+ current = { header: line.trimEnd(), body: [] };
51
+ continue;
52
+ }
53
+ if (current) current.body.push(line);
54
+ else headLines.push(line);
55
+ }
56
+ if (current) entries.push(current);
57
+ return {
58
+ head: normaliseBody(headLines.join("\n")),
59
+ entries: entries.map(({ header, body }) => {
60
+ const bodyText = normaliseBody(body.join("\n"));
61
+ const raw = `${normaliseHeader(header)}\n${bodyText}`.trimEnd();
62
+ return {
63
+ header: normaliseHeader(header),
64
+ body: bodyText,
65
+ raw,
66
+ key: sha256(`${normaliseHeader(header)}\n${bodyText}`),
67
+ time: entryTime(header),
68
+ };
69
+ }),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Additive journal merge: local entries win, remote-only entries are inserted.
75
+ * @returns {{text:string, added:number, conflicts:Array<{header:string, remote:string}>}}
76
+ */
77
+ export function mergeJournal(localText, remoteText) {
78
+ const local = splitJournal(localText);
79
+ const remote = splitJournal(remoteText);
80
+ const known = new Set(local.entries.map((entry) => entry.key));
81
+ const added = [];
82
+ const conflicts = [];
83
+ const byHeader = new Map(local.entries.map((entry) => [entry.header, entry]));
84
+ for (const entry of remote.entries) {
85
+ const twin = byHeader.get(entry.header);
86
+ if (twin !== undefined && twin.key !== entry.key) {
87
+ conflicts.push({ header: entry.header, remote: entry.raw });
88
+ continue;
89
+ }
90
+ if (known.has(entry.key)) continue;
91
+ known.add(entry.key);
92
+ added.push(entry);
93
+ }
94
+ const merged = [...local.entries, ...added].sort((left, right) => {
95
+ if (left.time !== right.time) return left.time - right.time;
96
+ return left.header.localeCompare(right.header, "ru");
97
+ });
98
+ const pieces = [];
99
+ if (local.head.length > 0) pieces.push(local.head);
100
+ else if (remote.head.length > 0) pieces.push(remote.head);
101
+ for (const entry of merged) pieces.push(entry.raw);
102
+ return {
103
+ text: `${pieces.join("\n\n").trimEnd()}\n`,
104
+ added: added.length,
105
+ conflicts,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Decide what to do with every file present on either side.
111
+ *
112
+ * A remote version is accepted wholesale only when the remote bundle says it
113
+ * changed from the same base the local machine last synchronised with
114
+ * (`remote.files[].base === localBase[path]`). This blocks a stale machine from
115
+ * rolling a newer local file back while still allowing fast-forward.
116
+ *
117
+ * @param {{localContent:Map<string,string>, remoteContent:Map<string,string>,
118
+ * baseHashes:Record<string,string>|Map<string,string>,
119
+ * remoteBases?:Record<string,string|null>|Map<string,string|null>,
120
+ * force?:boolean}} input
121
+ * @returns {Array<{path:string, action:'none'|'take-remote'|'journal'|'conflict',
122
+ * local?:string, remote?:string, reason:string, force?:boolean}>}
123
+ */
124
+ export function decideSync({ localContent, remoteContent, baseHashes, remoteBases, force = false }) {
125
+ const base = baseHashes instanceof Map ? baseHashes : new Map(Object.entries(baseHashes ?? {}));
126
+ const remoteBaseMap = remoteBases instanceof Map ? remoteBases : new Map(Object.entries(remoteBases ?? {}));
127
+ const paths = new Set([...localContent.keys(), ...remoteContent.keys()]);
128
+ const decisions = [];
129
+ for (const relativePath of [...paths].sort()) {
130
+ const remote = remoteContent.get(relativePath);
131
+ if (remote === undefined) {
132
+ // A remote bundle without this file is not a deletion request: keep local.
133
+ decisions.push({ path: relativePath, action: "none", reason: "только локальный файл" });
134
+ continue;
135
+ }
136
+ const local = localContent.get(relativePath);
137
+ if (local === undefined) {
138
+ decisions.push({ path: relativePath, action: "take-remote", remote, reason: "локального файла нет" });
139
+ continue;
140
+ }
141
+ if (local === remote) {
142
+ decisions.push({ path: relativePath, action: "none", reason: "совпадает" });
143
+ continue;
144
+ }
145
+ const isJournal = JOURNAL_FILE.test(relativePath);
146
+ if (isJournal) {
147
+ decisions.push({ path: relativePath, action: "journal", local, remote, reason: "аддитивное слияние журнала" });
148
+ continue;
149
+ }
150
+ const knownHash = base.get(relativePath);
151
+ const remoteBase = remoteBaseMap.get(relativePath) ?? null;
152
+ const localHash = sha256(local);
153
+ if (force) {
154
+ decisions.push({ path: relativePath, action: "take-remote", local, remote, force: true, reason: "принудительный импорт (--force)" });
155
+ continue;
156
+ }
157
+ if (isTemplateContent(local) && (knownHash === undefined || knownHash === localHash)) {
158
+ decisions.push({ path: relativePath, action: "take-remote", local, remote, reason: "локальный файл — шаблон проекта" });
159
+ continue;
160
+ }
161
+ const remoteHash = sha256(remote);
162
+ if (knownHash !== undefined && remoteBase !== null && remoteBase === knownHash) {
163
+ if (localHash === knownHash) {
164
+ decisions.push({ path: relativePath, action: "take-remote", local, remote, reason: "локально не менялось, чужая версия из той же базы" });
165
+ continue;
166
+ }
167
+ if (remoteHash === knownHash) {
168
+ decisions.push({ path: relativePath, action: "none", reason: "чужая версия не менялась" });
169
+ continue;
170
+ }
171
+ decisions.push({ path: relativePath, action: "conflict", local, remote, reason: "обе стороны менялись от одной базы" });
172
+ continue;
173
+ }
174
+ if (knownHash === undefined) {
175
+ decisions.push({ path: relativePath, action: "conflict", local, remote, reason: "нет общей базы для безопасного слияния" });
176
+ continue;
177
+ }
178
+ if (remoteBase !== null && remoteBase !== knownHash) {
179
+ decisions.push({ path: relativePath, action: "conflict", local, remote, reason: "чужая версия происходит из другой базы" });
180
+ continue;
181
+ }
182
+ decisions.push({ path: relativePath, action: "conflict", local, remote, reason: "обе стороны менялись" });
183
+ }
184
+ return decisions;
185
+ }
186
+
187
+ /**
188
+ * Compose the local file text after accepting a remote version of a section:
189
+ * the remote manual content plus the local AUTO-SCAN blocks (local scanner
190
+ * results are machine-specific and never travel between machines).
191
+ */
192
+ export function composeSectionFromRemote(remoteText, localRawText) {
193
+ const manual = stripAutoScan(remoteText);
194
+ const blockMatch = String(localRawText ?? "").match(/<!--\s*AUTO-SCAN:START[^>]*-->[\s\S]*?<!--\s*AUTO-SCAN:END\s*-->/gu) ?? [];
195
+ const blocks = blockMatch.slice(0, 4);
196
+ const parts = [manual.trim()];
197
+ for (const block of blocks) parts.push(block.trim());
198
+ return `${parts.filter((part) => part.length > 0).join("\n\n")}\n`;
199
+ }
200
+
201
+ /** Content that actually travels: sections lose AUTO-SCAN blocks, journals keep everything. */
202
+ export function syncableText(relativePath, rawText) {
203
+ const redacted = String(rawText ?? "");
204
+ return SECTION_FILES.includes(relativePath) ? stripAutoScan(redacted) : redacted;
205
+ }