dsh-desktop-kit 2.2.1 → 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 (125) hide show
  1. package/CHANGELOG.md +80 -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 +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/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/index.js +18 -1
  23. package/package.json +26 -6
  24. package/plugins/dsh-agent-extensions/README.md +99 -0
  25. package/plugins/dsh-agent-extensions/index.js +167 -0
  26. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  27. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  28. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  29. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  30. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  31. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  32. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  33. package/plugins/dsh-agent-extensions/package.json +9 -0
  34. package/plugins/dsh-browser-qa/README.md +221 -0
  35. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  36. package/plugins/dsh-browser-qa/index.js +165 -0
  37. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  38. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  39. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  40. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  41. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  42. package/plugins/dsh-browser-qa/package.json +31 -0
  43. package/plugins/dsh-checkpoints/README.md +22 -0
  44. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  45. package/plugins/dsh-checkpoints/index.js +80 -0
  46. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  47. package/plugins/dsh-checkpoints/package.json +25 -0
  48. package/plugins/dsh-github-review/README.md +131 -0
  49. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  50. package/plugins/dsh-github-review/index.js +536 -0
  51. package/plugins/dsh-github-review/lib/gh.js +494 -0
  52. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  53. package/plugins/dsh-github-review/package.json +28 -0
  54. package/plugins/dsh-guardrails/README.md +264 -0
  55. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  56. package/plugins/dsh-guardrails/index.js +389 -0
  57. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  58. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  59. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  60. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  61. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  62. package/plugins/dsh-guardrails/package.json +26 -0
  63. package/plugins/dsh-mcp-manager/README.md +78 -0
  64. package/plugins/dsh-mcp-manager/client.js +373 -0
  65. package/plugins/dsh-mcp-manager/index.js +159 -0
  66. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  67. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  68. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  69. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  70. package/plugins/dsh-mcp-manager/package.json +18 -0
  71. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  72. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  73. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  74. package/plugins/dsh-memory-bridge/README.md +161 -0
  75. package/plugins/dsh-memory-bridge/client.js +2264 -0
  76. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  77. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  78. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  79. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  83. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  84. package/plugins/dsh-memory-bridge/index.js +378 -0
  85. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  86. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  87. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  88. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  89. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  90. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  91. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  92. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  93. package/plugins/dsh-memory-bridge/package.json +67 -0
  94. package/plugins/dsh-peak-guard/README.md +16 -3
  95. package/plugins/dsh-peak-guard/client.js +223 -8
  96. package/plugins/dsh-peak-guard/package.json +1 -1
  97. package/plugins/dsh-plan-mode/README.md +24 -0
  98. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  99. package/plugins/dsh-plan-mode/index.js +103 -0
  100. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  101. package/plugins/dsh-plan-mode/package.json +18 -0
  102. package/plugins/dsh-profile-transfer/client.js +277 -17
  103. package/plugins/dsh-profile-transfer/index.js +10 -0
  104. package/plugins/dsh-profile-transfer/package.json +3 -1
  105. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  106. package/plugins/dsh-project-memory/index.js +92 -2
  107. package/plugins/dsh-project-memory/package.json +1 -1
  108. package/plugins/dsh-repo-map/README.md +26 -0
  109. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  110. package/plugins/dsh-repo-map/index.js +99 -0
  111. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  112. package/plugins/dsh-repo-map/package.json +25 -0
  113. package/plugins/dsh-skill-autopilot/index.js +135 -11
  114. package/skills/delivery-gate/SKILL.md +31 -0
  115. package/skills/git-and-ci/SKILL.md +5 -1
  116. package/skills/manifest.json +31 -6
  117. package/skills/requirements-and-planning/SKILL.md +3 -0
  118. package/skills/research-first/SKILL.md +35 -0
  119. package/skills/research-first/references/query-patterns.md +20 -0
  120. package/skills/subagent-playbook/SKILL.md +36 -0
  121. package/skills/subagent-playbook/references/prompts.md +39 -0
  122. package/skills/ui-craft/SKILL.md +43 -0
  123. package/skills/visual-qa/SKILL.md +29 -0
  124. package/skills/visual-qa/references/commands.md +32 -0
  125. package/skills-baseline.json +219 -0
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Hard UI gate for browser_qa.
3
+ *
4
+ * It observes UI file writes and browser_qa audit results. At
5
+ * `agent/turn-stopping` a dirty session gets a steer message instead of being
6
+ * allowed to close: the model must run audit until overflow, console errors
7
+ * and markup issues are zero. A bounded number of reminders prevents an
8
+ * accidental infinite turn.
9
+ */
10
+ import crypto from "node:crypto";
11
+
12
+ const UI_FILE = /\.(?:html?|xhtml|css|scss|sass|less|jsx|tsx|vue|svelte|astro|mdx)$/iu;
13
+ const SKIP_PATH = /(?:^|[\\/])(?:node_modules|\.git|\.dsh|dist|build|coverage|qa)(?:[\\/]|$)/iu;
14
+ const SHELL_WRITE = /(?:^|[\s"'=])(?:>|>>|Set-Content|Add-Content|Out-File|Copy-Item|Move-Item|cp|mv|sed\s+-i|perl\s+-pi|tee)\b/iu;
15
+ const MAX_REMINDERS = 4;
16
+ const MAX_PATHS = 12;
17
+
18
+ function shellUiPath(command) {
19
+ const match = String(command).match(/(?:[A-Za-z]:[\\/]|\.{0,2}[\\/])?[^\s"'<>|;]+\.(?:html?|xhtml|css|scss|sass|less|jsx|tsx|vue|svelte|astro|mdx)/iu);
20
+ return match?.[0] ?? "";
21
+ }
22
+
23
+ function pathOf(target) {
24
+ const value = target?.displayPath ?? target?.path ?? target?.file ?? "";
25
+ return typeof value === "string" ? value : "";
26
+ }
27
+
28
+ function sessionIdOf(ctx, actor, agent) {
29
+ const direct = actor?.id ?? actor?.session?.id ?? agent?.id;
30
+ if (typeof direct === "string" && direct.length > 0) return direct;
31
+ try {
32
+ const current = ctx.get?.("agents")?.currentInitiator?.();
33
+ return typeof current?.id === "string" ? current.id : "";
34
+ } catch {
35
+ return "";
36
+ }
37
+ }
38
+
39
+ function findAudit(value, depth = 0) {
40
+ if (!value || typeof value !== "object" || depth > 4) return null;
41
+ if (value.summary && (Array.isArray(value.reports) || value.summary.widthsChecked !== undefined || value.summary.markupIssues !== undefined)) {
42
+ return value;
43
+ }
44
+ for (const key of ["value", "result", "data", "structuredContent", "output"]) {
45
+ const found = findAudit(value[key], depth + 1);
46
+ if (found) return found;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function resultText(result) {
52
+ if (typeof result?.text === "string") return result.text;
53
+ const content = result?.content;
54
+ if (!Array.isArray(content)) return "";
55
+ return content
56
+ .filter((block) => block && block.type === "text" && typeof block.text === "string")
57
+ .map((block) => block.text)
58
+ .join("\n");
59
+ }
60
+
61
+ /** The normalized tool result is `{content:[{type:'text',text}]}`, so parse the audit summary. */
62
+ function auditFromText(result) {
63
+ if (result?.isError === true) return null;
64
+ const text = resultText(result);
65
+ if (!text.includes("browser_qa audit:")) return null;
66
+ const summary = text.match(/Итог: overflow на (\d+) из (\d+)[^\n]*console errors: (\d+)[^\n]*markup: (\d+)/iu);
67
+ if (summary) {
68
+ return {
69
+ reports: [],
70
+ summary: {
71
+ widthsWithOverflow: Number(summary[1]),
72
+ widthsChecked: Number(summary[2]),
73
+ consoleErrors: Number(summary[3]),
74
+ markupIssues: Number(summary[4]),
75
+ },
76
+ };
77
+ }
78
+ const rows = [...text.matchAll(/- (\d+)px: overflow (?:нет|(\d+)px)[^\n]*console errors: (\d+)[^\n]*markup: (\d+)/giu)];
79
+ if (rows.length === 0) return null;
80
+ return {
81
+ reports: rows.map((row) => ({ width: Number(row[1]) })),
82
+ summary: {
83
+ widthsWithOverflow: rows.filter((row) => row[2] !== undefined).length,
84
+ widthsChecked: rows.length,
85
+ consoleErrors: rows.reduce((sum, row) => sum + Number(row[3]), 0),
86
+ markupIssues: rows.reduce((sum, row) => sum + Number(row[4]), 0),
87
+ },
88
+ };
89
+ }
90
+
91
+ function cleanAudit(summary) {
92
+ const checked = Number(summary?.widthsChecked ?? 1);
93
+ return (
94
+ checked > 0 &&
95
+ Number(summary?.widthsWithOverflow ?? 0) === 0 &&
96
+ Number(summary?.consoleErrors ?? 0) === 0 &&
97
+ Number(summary?.markupIssues ?? 0) === 0
98
+ );
99
+ }
100
+
101
+ function summaryText(summary) {
102
+ if (!summary) return "audit ещё не запускался";
103
+ return `audit: overflow ${summary.widthsWithOverflow ?? "?"} · console ${summary.consoleErrors ?? "?"} · markup ${summary.markupIssues ?? "?"}`;
104
+ }
105
+
106
+ export function createUiGate(ctx) {
107
+ const states = new Map();
108
+ const logger = ctx.logger;
109
+
110
+ function stateFor(sessionId) {
111
+ let state = states.get(sessionId);
112
+ if (!state) {
113
+ state = { dirty: false, lastEditAt: 0, auditAt: 0, clean: false, summary: null, paths: new Set(), reminders: 0 };
114
+ states.set(sessionId, state);
115
+ if (states.size > 200) states.delete(states.keys().next().value);
116
+ }
117
+ return state;
118
+ }
119
+
120
+ function markUi(target, actor) {
121
+ const file = pathOf(target);
122
+ if (!UI_FILE.test(file) || SKIP_PATH.test(file)) return;
123
+ const sessionId = sessionIdOf(ctx, actor);
124
+ if (!sessionId) return;
125
+ const state = stateFor(sessionId);
126
+ state.dirty = true;
127
+ state.lastEditAt = Date.now();
128
+ state.reminders = 0;
129
+ state.paths.add(file);
130
+ if (state.paths.size > MAX_PATHS) state.paths.delete(state.paths.values().next().value);
131
+ }
132
+
133
+ function onToolResult(exec, result) {
134
+ if (exec?.name !== "browser_qa") return;
135
+ const action = exec?.arguments?.action ?? exec?.args?.action ?? exec?.input?.action;
136
+ if (action !== "audit") return;
137
+ const payload = findAudit(result) ?? auditFromText(result);
138
+ if (!payload) return;
139
+ const sessionId = sessionIdOf(ctx, exec?.agent, exec?.agent);
140
+ if (!sessionId) return;
141
+ const state = stateFor(sessionId);
142
+ state.auditAt = Date.now();
143
+ state.clean = cleanAudit(payload.summary);
144
+ state.summary = payload.summary ?? null;
145
+ if (state.clean && state.lastEditAt <= state.auditAt) {
146
+ state.dirty = false;
147
+ state.reminders = 0;
148
+ state.paths.clear();
149
+ } else {
150
+ state.reminders = 0;
151
+ }
152
+ logger?.debug?.(`[ui-gate] ${sessionId}: ${summaryText(payload.summary)}`);
153
+ }
154
+
155
+ async function turnStopping(payload) {
156
+ const agent = payload?.agent;
157
+ const sessionId = sessionIdOf(ctx, agent, agent);
158
+ if (!sessionId) return;
159
+ const state = states.get(sessionId);
160
+ if (!state?.dirty) return;
161
+ if (state.reminders >= MAX_REMINDERS) {
162
+ logger?.warn?.(`[ui-gate] ${sessionId}: ${MAX_REMINDERS} reminders without a clean audit; allowing the turn to close`);
163
+ state.dirty = false;
164
+ return;
165
+ }
166
+ state.reminders += 1;
167
+ const files = [...state.paths].slice(0, 6).join(", ") || "UI-файлы";
168
+ const text = [
169
+ "<ui-gate>",
170
+ `Изменены UI-файлы: ${files}.`,
171
+ `Последний ${summaryText(state.summary)}.`,
172
+ "Ход не закрыт: запусти browser_qa action=audit для этой страницы/файла на 390/768/1440 (лучше с outDir), исправь overflow, console errors и markup (alt, label, duplicate id, heading order).",
173
+ "После исправлений снова запусти audit. Не заявляй «готово», пока audit не станет чистым.",
174
+ "</ui-gate>",
175
+ ].join("\n");
176
+ try {
177
+ agent.steer({
178
+ id: crypto.randomUUID(),
179
+ role: "user",
180
+ content: [{ type: "text", text }],
181
+ source: { kind: "user" },
182
+ });
183
+ logger?.info?.(`[ui-gate] ${sessionId}: steered for ${summaryText(state.summary)}`);
184
+ } catch (error) {
185
+ logger?.warn?.(`[ui-gate] ${sessionId}: steer failed: ${error instanceof Error ? error.message : String(error)}`);
186
+ }
187
+ }
188
+
189
+ const disposers = [];
190
+ if (typeof ctx.on === "function") {
191
+ for (const event of ["fs/write-intent", "fs/edit-intent"]) {
192
+ try {
193
+ const dispose = ctx.on(event, async (target, actor, next) => {
194
+ const decision = typeof next === "function" ? await next() : undefined;
195
+ markUi(target, actor);
196
+ return decision;
197
+ });
198
+ if (typeof dispose === "function") disposers.push(dispose);
199
+ } catch (error) {
200
+ logger?.warn?.(`[ui-gate] ${event} hook skipped: ${error instanceof Error ? error.message : String(error)}`);
201
+ }
202
+ }
203
+ try {
204
+ const dispose = ctx.on("tools/result", (exec, result) => onToolResult(exec, result));
205
+ if (typeof dispose === "function") disposers.push(dispose);
206
+ } catch (error) {
207
+ logger?.warn?.(`[ui-gate] tools/result hook skipped: ${error instanceof Error ? error.message : String(error)}`);
208
+ }
209
+ try {
210
+ const dispose = ctx.on("tools/pre-execute", async (exec, next) => {
211
+ const decision = typeof next === "function" ? await next() : { kind: "allow" };
212
+ const command = exec?.arguments?.command;
213
+ if (
214
+ decision?.kind === "allow" &&
215
+ (exec?.name === "pwsh" || exec?.name === "bash" || exec?.name === "shell") &&
216
+ typeof command === "string" &&
217
+ SHELL_WRITE.test(command)
218
+ ) {
219
+ const file = shellUiPath(command);
220
+ if (file) markUi({ displayPath: file }, exec.agent);
221
+ }
222
+ return decision;
223
+ });
224
+ if (typeof dispose === "function") disposers.push(dispose);
225
+ } catch (error) {
226
+ logger?.warn?.(`[ui-gate] tools/pre-execute hook skipped: ${error instanceof Error ? error.message : String(error)}`);
227
+ }
228
+ try {
229
+ const dispose = ctx.on("agent/turn-stopping", (payload) => turnStopping(payload));
230
+ if (typeof dispose === "function") disposers.push(dispose);
231
+ } catch (error) {
232
+ logger?.warn?.(`[ui-gate] turn-stopping hook skipped: ${error instanceof Error ? error.message : String(error)}`);
233
+ }
234
+ try {
235
+ const dispose = ctx.on("agent/disposed", (payload) => {
236
+ const id = sessionIdOf(ctx, payload?.agent, payload?.agent);
237
+ if (id) states.delete(id);
238
+ });
239
+ if (typeof dispose === "function") disposers.push(dispose);
240
+ } catch {
241
+ /* optional */
242
+ }
243
+ }
244
+
245
+ return {
246
+ markUi,
247
+ onToolResult,
248
+ turnStopping,
249
+ dispose() {
250
+ for (const dispose of disposers) {
251
+ try { dispose(); } catch { /* best effort */ }
252
+ }
253
+ states.clear();
254
+ },
255
+ };
256
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "dsh-browser-qa",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Headless-Chrome visual QA for DSH agents: screenshots and responsive horizontal-overflow/console-error audits through the DevTools protocol, with no Playwright dependency.",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "exports": {
10
+ ".": "./index.js",
11
+ "./lib": "./lib/qa.js",
12
+ "./lib/chrome": "./lib/chrome.js",
13
+ "./lib/pages": "./lib/pages.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "lib/**",
19
+ "cordis.patch.yml",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22.0.0"
24
+ },
25
+ "dsh": {
26
+ "manifestVersion": 1,
27
+ "bundle": {
28
+ "patch": "./cordis.patch.yml"
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,22 @@
1
+ # dsh-checkpoints
2
+
3
+ Безопасные снимки workspace на git-объектах.
4
+
5
+ ## Инструмент `checkpoint`
6
+
7
+ - `action: "create"` — снимок текущего дерева (включая untracked) в
8
+ `refs/dsh-checkpoints/<id>`; HEAD не двигается, индекс не меняется;
9
+ - `action: "list"` — список снимков;
10
+ - `action: "restore"` — вернуть файлы из снимка; перед откатом автоматически
11
+ создаётся страховочный снимок;
12
+ - `action: "delete"` — удалить ref и метаданные.
13
+
14
+ Метаданные — `~/.dsh/checkpoints/<repo-hash>/<id>.json`, права `0700/0600`.
15
+
16
+ ## Важно
17
+
18
+ - Откат **не удаляет** файлы, появившиеся после снимка; он восстанавливает
19
+ файлы, которые были в снимке. Такой откат не стирает новую работу.
20
+ - Снимок — полноценный git-tree: в него попадают все файлы, включая `.env`.
21
+ Это локальный приватный объект; не отправляйте его в публичный репозиторий.
22
+ - Нужен git-репозиторий; при отсутствии HEAD снимок создаётся без родителя.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-checkpoints
3
+ name: dsh-checkpoints
@@ -0,0 +1,80 @@
1
+ /**
2
+ * dsh-checkpoints — safe workspace snapshots via git objects.
3
+ */
4
+ import path from "node:path";
5
+
6
+ import { createCheckpoint, deleteCheckpoint, listCheckpoints, restoreCheckpoint } from "./lib/checkpoints.js";
7
+
8
+ export const inject = ["tools"];
9
+
10
+ function rootOf(args, exec) {
11
+ const explicit = typeof args?.root === "string" ? args.root.trim() : "";
12
+ const cwd = exec?.agent?.session?.header?.cwd;
13
+ return path.resolve(explicit || cwd || process.cwd());
14
+ }
15
+
16
+ function home() {
17
+ return process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || "", ".dsh");
18
+ }
19
+
20
+ function truncate(text, max = 20_000) {
21
+ return text.length <= max ? text : `${text.slice(0, max)}\n…`;
22
+ }
23
+
24
+ export function apply(ctx) {
25
+ ctx.tools.register({
26
+ name: "checkpoint",
27
+ description:
28
+ "Create, list, restore or delete a safe git-backed workspace checkpoint. Use create before risky edits and restore to roll back. Snapshots are local objects under refs/dsh-checkpoints; restore never deletes files created after the snapshot.",
29
+ parameters: {
30
+ type: "object",
31
+ additionalProperties: false,
32
+ properties: {
33
+ action: { type: "string", enum: ["create", "list", "restore", "delete"], description: "create | list | restore | delete" },
34
+ label: { type: "string", description: "Human label for create." },
35
+ id: { type: "string", description: "Checkpoint id (or prefix) for restore/delete." },
36
+ root: { type: "string", description: "Project root; defaults to Session cwd." },
37
+ },
38
+ required: ["action"],
39
+ },
40
+ timeoutMs: 30_000,
41
+ output: {
42
+ schema: { type: "object", additionalProperties: true },
43
+ render(_args, value) {
44
+ const text = typeof value?.text === "string" ? value.text : JSON.stringify(value);
45
+ return [{ type: "text", text: truncate(text) }];
46
+ },
47
+ },
48
+ async execute(args, exec) {
49
+ const cwd = rootOf(args, exec);
50
+ const action = String(args?.action ?? "list");
51
+ if (action === "create") {
52
+ const meta = createCheckpoint({ cwd, label: args?.label, home: home() });
53
+ return { ok: true, checkpoint: meta, text: `Создан checkpoint ${meta.id} · файлов ${meta.files}${meta.label ? ` · ${meta.label}` : ""}` };
54
+ }
55
+ if (action === "restore") {
56
+ if (typeof args?.id !== "string" || args.id.trim().length === 0) return { ok: false, text: "checkpoint restore: нужен id" };
57
+ const result = restoreCheckpoint({ cwd, id: args.id.trim(), home: home() });
58
+ return {
59
+ ok: true,
60
+ ...result,
61
+ text: `Восстановлен ${result.restored.id} (${result.restored.label || "без метки"}). Перед откатом создан ${result.safety?.id ?? "—"}.`,
62
+ };
63
+ }
64
+ if (action === "delete") {
65
+ if (typeof args?.id !== "string" || args.id.trim().length === 0) return { ok: false, text: "checkpoint delete: нужен id" };
66
+ const meta = deleteCheckpoint({ cwd, id: args.id.trim(), home: home() });
67
+ return { ok: true, checkpoint: meta, text: `Удалён checkpoint ${meta.id}` };
68
+ }
69
+ const list = listCheckpoints({ cwd, home: home() });
70
+ return {
71
+ ok: true,
72
+ checkpoints: list,
73
+ text: list.length === 0
74
+ ? "Checkpoints нет. Создать: checkpoint action=create label=... "
75
+ : ["Checkpoints:", ...list.map((item) => `- ${item.id} · ${item.createdAt} · ${item.files} файлов${item.label ? ` · ${item.label}` : ""}`)].join("\n"),
76
+ };
77
+ },
78
+ });
79
+ ctx.logger?.info?.("[checkpoints] tool ready");
80
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Git-backed workspace checkpoints.
3
+ *
4
+ * A checkpoint is an unreachable commit stored under refs/dsh-checkpoints/<id>.
5
+ * It captures tracked + untracked files (a full tree) without moving HEAD.
6
+ * Restore checks out that tree into the worktree; it never deletes files that
7
+ * were created after the checkpoint, which keeps the action non-destructive.
8
+ */
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+
15
+ function git(cwd, args, { allowFail = false } = {}) {
16
+ const result = spawnSync("git", args, {
17
+ cwd,
18
+ encoding: "utf8",
19
+ timeout: 20_000,
20
+ windowsHide: true,
21
+ });
22
+ if (result.error) throw new Error(`git ${args[0]}: ${result.error.message}`);
23
+ if (result.status !== 0 && !allowFail) {
24
+ throw new Error(`git ${args.join(" ")}: ${(result.stderr || result.stdout || "").trim()}`);
25
+ }
26
+ return { ok: result.status === 0, status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
27
+ }
28
+
29
+ export function repoRoot(cwd) {
30
+ const result = git(cwd, ["rev-parse", "--show-toplevel"]);
31
+ return result.stdout.trim();
32
+ }
33
+
34
+ function checkpointHome(home, root) {
35
+ const digest = crypto.createHash("sha256").update(root.toLowerCase()).digest("hex").slice(0, 16);
36
+ return path.join(home, "checkpoints", digest);
37
+ }
38
+
39
+ export function createCheckpoint({ cwd, label = "", home = path.join(os.homedir(), ".dsh") }) {
40
+ const root = repoRoot(cwd);
41
+ const id = `cp-${new Date().toISOString().replace(/[-:T.Z]/gu, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`;
42
+ const hasHead = git(root, ["rev-parse", "--verify", "HEAD"], { allowFail: true }).ok;
43
+ git(root, ["add", "-A"]);
44
+ const tree = git(root, ["write-tree"]).stdout.trim();
45
+ const message = `dsh checkpoint ${id}${label ? `: ${label}` : ""}`;
46
+ const commitArgs = ["commit-tree", tree];
47
+ if (hasHead) commitArgs.push("-p", "HEAD");
48
+ commitArgs.push("-m", message);
49
+ const commit = git(root, commitArgs).stdout.trim();
50
+ git(root, ["update-ref", `refs/dsh-checkpoints/${id}`, commit]);
51
+ if (hasHead) {
52
+ git(root, ["reset"], { allowFail: true });
53
+ } else {
54
+ git(root, ["read-tree", "--empty"], { allowFail: true });
55
+ }
56
+ const meta = {
57
+ id,
58
+ label: String(label ?? ""),
59
+ createdAt: new Date().toISOString(),
60
+ repo: root,
61
+ ref: `refs/dsh-checkpoints/${id}`,
62
+ commit,
63
+ tree,
64
+ files: git(root, ["ls-tree", "-r", "--name-only", commit]).stdout.trim().split(/\r?\n/u).filter(Boolean).length,
65
+ };
66
+ const directory = checkpointHome(home, root);
67
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
68
+ fs.writeFileSync(path.join(directory, `${id}.json`), `${JSON.stringify(meta, null, 2)}\n`, { mode: 0o600 });
69
+ return meta;
70
+ }
71
+
72
+ export function listCheckpoints({ cwd, home = path.join(os.homedir(), ".dsh") }) {
73
+ const root = repoRoot(cwd);
74
+ const directory = checkpointHome(home, root);
75
+ if (!fs.existsSync(directory)) return [];
76
+ return fs
77
+ .readdirSync(directory)
78
+ .filter((name) => name.endsWith(".json"))
79
+ .map((name) => {
80
+ try {
81
+ return JSON.parse(fs.readFileSync(path.join(directory, name), "utf8"));
82
+ } catch {
83
+ return null;
84
+ }
85
+ })
86
+ .filter(Boolean)
87
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
88
+ }
89
+
90
+ export function restoreCheckpoint({ cwd, id, home = path.join(os.homedir(), ".dsh"), safety = true }) {
91
+ const root = repoRoot(cwd);
92
+ const list = listCheckpoints({ cwd, home });
93
+ const meta = list.find((item) => item.id === id || item.id.startsWith(id));
94
+ if (!meta) throw new Error(`checkpoint не найден: ${id}`);
95
+ const exists = git(root, ["rev-parse", "--verify", `${meta.ref}^{commit}`], { allowFail: true });
96
+ if (!exists.ok) throw new Error(`git-ref ${meta.ref} потерян`);
97
+ const before = safety ? createCheckpoint({ cwd, home, label: `before restore ${meta.id}` }) : null;
98
+ git(root, ["checkout", meta.commit, "--", "."], { allowFail: false });
99
+ return { restored: meta, safety: before };
100
+ }
101
+
102
+ export function deleteCheckpoint({ cwd, id, home = path.join(os.homedir(), ".dsh") }) {
103
+ const root = repoRoot(cwd);
104
+ const list = listCheckpoints({ cwd, home });
105
+ const meta = list.find((item) => item.id === id || item.id.startsWith(id));
106
+ if (!meta) throw new Error(`checkpoint не найден: ${id}`);
107
+ git(root, ["update-ref", "-d", meta.ref], { allowFail: true });
108
+ const file = path.join(checkpointHome(home, root), `${meta.id}.json`);
109
+ fs.rmSync(file, { force: true });
110
+ return meta;
111
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "dsh-checkpoints",
3
+ "version": "1.0.0",
4
+ "description": "Safe git-backed workspace checkpoints for DSH coding agents: create a snapshot before risky edits, list and restore it without losing unrelated work.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./lib": "./lib/checkpoints.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "lib/**",
16
+ "cordis.patch.yml",
17
+ "README.md"
18
+ ],
19
+ "dsh": {
20
+ "manifestVersion": 1,
21
+ "bundle": {
22
+ "patch": "./cordis.patch.yml"
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,131 @@
1
+ # dsh-github-review
2
+
3
+ Ревью GitHub pull request'ов одним инструментом `github_review` поверх официального
4
+ `gh` CLI — без копирования diff в чат руками и без read-only «угадывания» состояния PR.
5
+
6
+ ## Зачем
7
+
8
+ Модель часто спрашивают «что в PR / почему CI красный / что просили в ревью».
9
+ Инструмент отдаёт это компактно и с жёсткими лимитами:
10
+
11
+ - `status` — установлен ли `gh`, авторизован ли он, какой репозиторий и ветка видны из cwd;
12
+ - `pr` — заголовок, автор, base/head, body, файлы, additions/deletions, коммиты;
13
+ - `checks` — CI-проверки и отдельный список проваленных;
14
+ - `comments` — review-треды (inline) и обсуждение PR;
15
+ - `diff` — diff PR с байтовым лимитом.
16
+
17
+ По умолчанию инструмент **read-only**. Единственная мутация — `comment`, и она
18
+ выполняется только при явном подтверждении. Merge, close, reopen, force-push **не
19
+ реализованы** и отклоняются отдельным сообщением.
20
+
21
+ ## Требования
22
+
23
+ - [GitHub CLI](https://cli.github.com) `gh` **2.x** в `PATH` (проверено на 2.96.0; если
24
+ `gh pr checks` в вашей версии не поддерживает `--json`, плагин сам переключается
25
+ на разбор текстовой таблицы);
26
+ - авторизация:
27
+
28
+ ```bash
29
+ gh auth login
30
+ ```
31
+
32
+ достаточно scope `repo` (для чтения приватных репозиториев) и прав на чтение Actions;
33
+ - git-репозиторий с remote `origin` на GitHub (или явный `repo=owner/name`);
34
+ - сеть для всех действий, кроме `status` (там только локальные `gh --version`,
35
+ `gh auth status` и `git remote get-url`).
36
+
37
+ ## Инструмент `github_review`
38
+
39
+ | action | что делает | параметры |
40
+ | --- | --- | --- |
41
+ | `status` | `gh --version`, `gh auth status`, определение репозитория/ветки из cwd | `cwd`, `repo` |
42
+ | `pr` | текущий PR (или по номеру): title, author, base/head, body, files, +/−, commits | `pr`, `repo`, `cwd` |
43
+ | `checks` | CI checks + сводка по fail/pending/skipping/cancel | `pr`, `repo`, `cwd` |
44
+ | `comments` | review threads, conversation, состояния review (approved / changes requested) | `pr`, `repo`, `cwd` |
45
+ | `diff` | `gh pr diff` с байтовым лимитом | `pr`, `repo`, `cwd`, `maxBytes` |
46
+ | `comment` | отправить один комментарий в PR | `confirm: true`, `pr`, `body`, `repo`, `cwd` |
47
+
48
+ `pr` по умолчанию — PR текущей ветки. Если PR не найден, инструмент вернёт
49
+ подсказку «укажите номер», а не ошибку gh.
50
+
51
+ ### Примеры
52
+
53
+ ```
54
+ github_review action=status
55
+ github_review action=pr
56
+ github_review action=pr pr=42
57
+ github_review action=checks pr=42
58
+ github_review action=comments pr=42
59
+ github_review action=diff pr=42 maxBytes=65536
60
+ github_review action=comment pr=42 confirm=true body="Проверил таймауты — ок, но нужен тест на обрыв."
61
+ ```
62
+
63
+ Ответ `diff` содержит `patch` (не больше `maxBytes`, по умолчанию 200 КиБ) и
64
+ заголовок с реальным размером; в чат рендерится не больше 48k символов.
65
+
66
+ ## Безопасность
67
+
68
+ - **argv, не shell.** Все вызовы `gh`/`git` — массив аргументов через
69
+ `spawn`/`spawnSync` с `shell: false`. Номер PR, `repo`, `body` и cwd не могут
70
+ стать shell-синтаксисом.
71
+ - **Строгая валидация.** `repo` принимается только как `owner/name` (или
72
+ `host/owner/name` для GHES) по регулярному выражению; `pr` — только целое
73
+ положительное число. Некорректные значения отклоняются до запуска процесса.
74
+ - **Комментарий — только с подтверждением.** `comment` требует одновременно
75
+ `confirm: true` (строго boolean), явный номер PR и непустой `body`. Без этого —
76
+ отказ, никаких сетевых вызовов. `body` передаётся через stdin
77
+ (`gh pr comment --body-file -`), а не аргументом командной строки.
78
+ - **Таймауты.** Каждый вызов ограничен по времени (10–45 с) и по буферу вывода;
79
+ diff читается потоком и останавливается по достижении лимита.
80
+ - **Секреты.** Плагин не читает token-файлы и не печатает значения `GH_TOKEN`/
81
+ `GITHUB_TOKEN`; вывод gh и текст ошибок очищаются от токенов/ключей/JWT/
82
+ `Authorization`/cookie/учётных данных в URL. Diff и body — это содержимое PR:
83
+ если в PR есть секрет, он попадёт в diff как есть. Поэтому не публикуйте diff
84
+ PR с секретами в незащищённые каналы и не коммитьте `.env`.
85
+ - **Никаких лишних мутаций.** Нет merge/close/reopen/force-push/approve — эти
86
+ действия возвращают `unsupported-action`.
87
+
88
+ ## Лимиты
89
+
90
+ | Что | Лимит |
91
+ | --- | --- |
92
+ | body PR | 16 000 символов |
93
+ | файлов в `pr` | 60 (остальные — счётчиком) |
94
+ | коммитов в `pr` | 40 |
95
+ | checks | 200 |
96
+ | conversation | 40 |
97
+ | inline-тредов | 40, комментариев в `comments` — 100 |
98
+ | комментарий в PR | 60 000 символов |
99
+ | diff | по умолчанию 200 КиБ, диапазон 4 КиБ … 2 МиБ (`maxBytes`) |
100
+ | рендер в чат | 24k символов (48k для `diff`) |
101
+
102
+ ## Ограничения
103
+
104
+ - Только GitHub (github.com и GHES через `gh`); GitLab/Bitbucket не поддерживаются.
105
+ - Inline-комментарии берутся из REST API: состояние `resolved`/`outdated` и полная
106
+ пагинация тредов недоступны — плагин честно помечает это в ответе.
107
+ - `checks` на старых `gh` разбирает текстовую таблицу; форматирование таблицы
108
+ может отличаться между версиями.
109
+ - Текущая ветка без `pr=` должна иметь локальную git-ветку и remote; для PR из
110
+ форка надёжнее передать номер.
111
+ - Нет создания PR, ревью-аппрувов, назначения ревьюеров, редактирования PR.
112
+ - Сеть и права токена — ответственность пользователя: при отсутствии прав
113
+ инструмент вернёт сообщение об ошибке gh, а не попытается обойти её.
114
+
115
+ ## Тесты
116
+
117
+ ```bash
118
+ node tools/github-review-tests.mjs
119
+ ```
120
+
121
+ Тесты герметичны: разбор PR/checks/comments/auth на фикстурах, проверка
122
+ редакции секретов, байтовых лимитов, безопасной передачи argv, регистрации
123
+ инструмента и отказа мутациям. Live-блок только проверяет локальную установку
124
+ `gh` (`--version`, `auth status`, git remote) и пропускается, если `gh` нет.
125
+
126
+ ## Интеграция в набор
127
+
128
+ Плагин — стандартный host-плагин набора: `package.json` с `dsh.bundle.patch`,
129
+ `cordis.patch.yml` с insert-строкой и `export const inject = ["tools"]` в
130
+ `index.js`. Набор подключает его как дочерний плагин (`plugins/dsh-github-review/index.js`),
131
+ инструмент становится доступен модели как `github_review`.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-github-review
3
+ name: dsh-github-review