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,174 @@
1
+ /**
2
+ * dsh-agent-extensions — Claude-Code-like project extensions for DSH.
3
+ *
4
+ * Adds four project-local surfaces:
5
+ * - `.dsh/hooks.json` — trusted lifecycle hooks;
6
+ * - `.dsh/agents/*.md` — custom subagents via the `project_agent` tool;
7
+ * - `.dsh/commands/*.md` — project slash commands;
8
+ * - safe git checkpoints + `/rewind`.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { registerProjectHooks } from "./lib/hooks.js";
13
+ import { createCheckpoint } from "./lib/checkpoints.js";
14
+ import { runProjectAgent } from "./lib/agents.js";
15
+ import { registerChildPolicyHook } from "./lib/policy.js";
16
+ import { registerCommands } from "./lib/commands.js";
17
+ import { registerAgentApi } from "./lib/api.js";
18
+ import { ensureGlobalScaffold, ensureProjectScaffold } from "./lib/scaffold.js";
19
+ import { findDshDir, loadAgents, projectFor } from "./lib/project.js";
20
+
21
+ export const inject = ["tools", "systemPrompt"];
22
+
23
+ const TOOL = "project_agent";
24
+ const AUTO_CHECKPOINT_MS = 10 * 60_000;
25
+
26
+ function projectFromAgent(agent) {
27
+ const cwd = agent?.session?.header?.cwd;
28
+ return projectFor(cwd);
29
+ }
30
+
31
+ function toolDefinition(ctx) {
32
+ return {
33
+ name: TOOL,
34
+ description:
35
+ "Run or list project-local custom agents from `.dsh/agents/*.md`. `list` shows names/descriptions/models; `run` starts a child agent with its own context and returns only its final text. Use for repeated specialised work (reviewer, researcher, SEO, migration).",
36
+ parameters: {
37
+ type: "object",
38
+ additionalProperties: false,
39
+ properties: {
40
+ action: { type: "string", enum: ["list", "run"], description: "list project agents or run one by name" },
41
+ name: { type: "string", description: "Agent name from `.dsh/agents/<name>.md`" },
42
+ task: { type: "string", description: "Task/prompt for the child agent (required for run)" },
43
+ },
44
+ required: ["action"],
45
+ },
46
+ async execute(input, exec) {
47
+ const project = projectFromAgent(exec?.agent);
48
+ const agents = loadAgents(project.root, project.dsh ?? path.join(project.root, ".dsh"));
49
+ if (input?.action === "list") {
50
+ const text = agents.length
51
+ ? agents.map((agent) => `- ${agent.name}${agent.model ? ` · ${agent.model}` : ""}${agent.description ? ` — ${agent.description}` : ""}`).join("\n")
52
+ : "Нет проектных агентов. Создайте `.dsh/agents/<name>.md`.";
53
+ return { ok: true, text: `project_agent list:\n${text}` };
54
+ }
55
+ if (input?.action !== "run") return { ok: false, text: "project_agent: action must be list or run" };
56
+ const name = String(input?.name ?? "").trim();
57
+ const task = String(input?.task ?? "").trim();
58
+ if (!name || !task) return { ok: false, text: "project_agent: name and task are required for run" };
59
+ const definition = agents.find((agent) => agent.name === name);
60
+ if (!definition) return { ok: false, text: `project_agent: unknown agent "${name}"` };
61
+ try {
62
+ const result = await runProjectAgent(ctx, exec, definition, task);
63
+ return { ok: true, text: `project_agent ${name} (${result.stopReason}):\n${result.text}` };
64
+ } catch (error) {
65
+ return { ok: false, text: `project_agent ${name}: ${error instanceof Error ? error.message : String(error)}` };
66
+ }
67
+ },
68
+ };
69
+ }
70
+
71
+ export function apply(ctx) {
72
+ try {
73
+ const scaffold = ensureGlobalScaffold();
74
+ if (scaffold.created.length > 0) ctx.logger?.info?.(`[agent-extensions] scaffolded global: ${scaffold.created.join(", ")}`);
75
+ } catch (error) {
76
+ ctx.logger?.warn?.(`[agent-extensions] global scaffold skipped: ${error instanceof Error ? error.message : String(error)}`);
77
+ }
78
+ const hooks = registerProjectHooks(ctx);
79
+ ctx.effect(() => () => hooks.dispose(), "agent-extensions: project hooks");
80
+ const childPolicy = registerChildPolicyHook(ctx);
81
+ ctx.effect(() => () => childPolicy.dispose(), "agent-extensions: child policy");
82
+
83
+ try {
84
+ ctx.effect(
85
+ () =>
86
+ ctx.on("agent/created", (payload) => {
87
+ try {
88
+ const cwd = payload?.agent?.session?.header?.cwd;
89
+ const result = ensureProjectScaffold(cwd);
90
+ if (!result.skipped && result.created.length > 0) {
91
+ ctx.logger?.info?.(`[agent-extensions] scaffolded project ${cwd}: ${result.created.length} paths`);
92
+ }
93
+ } catch (error) {
94
+ ctx.logger?.warn?.(`[agent-extensions] project scaffold skipped: ${error instanceof Error ? error.message : String(error)}`);
95
+ }
96
+ }),
97
+ "agent-extensions: project scaffold",
98
+ );
99
+ } catch (error) {
100
+ ctx.logger?.warn?.(`[agent-extensions] project scaffold hook skipped: ${error instanceof Error ? error.message : String(error)}`);
101
+ }
102
+
103
+ try {
104
+ ctx.effect(() => ctx.tools.register(toolDefinition(ctx)), "agent-extensions: project_agent tool");
105
+ } catch (error) {
106
+ ctx.logger?.warn?.(`[agent-extensions] tool registration failed: ${error instanceof Error ? error.message : String(error)}`);
107
+ }
108
+
109
+ try {
110
+ ctx.systemPrompt.context({
111
+ name: "dsh-agent-extensions: project agents",
112
+ order: 134,
113
+ interpolate: false,
114
+ required: false,
115
+ text: (assemblyContext) => {
116
+ try {
117
+ const session = assemblyContext?.agent?.session;
118
+ if (!session) return "";
119
+ const project = projectFromAgent(assemblyContext.agent);
120
+ const agents = loadAgents(project.root, project.dsh ?? path.join(project.root, ".dsh"));
121
+ if (agents.length === 0) return "";
122
+ const lines = agents.slice(0, 20).map((agent) => `- ${agent.name}: ${agent.description || "project agent"}${agent.model ? ` (${agent.model})` : ""}`);
123
+ return `<project-agents>\n${lines.join("\n")}\nЗапускай через tool project_agent action=run name=<name> task=<...>.\n</project-agents>`;
124
+ } catch {
125
+ return "";
126
+ }
127
+ },
128
+ });
129
+ } catch (error) {
130
+ ctx.logger?.warn?.(`[agent-extensions] system prompt registration failed: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+
133
+ try {
134
+ ctx.inject(["commands"], (child) => registerCommands(child));
135
+ } catch (error) {
136
+ ctx.logger?.warn?.(`[agent-extensions] commands registration failed: ${error instanceof Error ? error.message : String(error)}`);
137
+ }
138
+
139
+ try {
140
+ ctx.inject(["connection"], (child) => registerAgentApi(child));
141
+ } catch (error) {
142
+ ctx.logger?.warn?.(`[agent-extensions] agents api skipped: ${error instanceof Error ? error.message : String(error)}`);
143
+ }
144
+
145
+ /* Auto-checkpoint before the first write in a session (at most once per 10 min). */
146
+ const lastAuto = new Map();
147
+ try {
148
+ ctx.effect(
149
+ () =>
150
+ ctx.on("fs/write-intent", async (target, actor, next) => {
151
+ const file = target?.displayPath ?? target?.path ?? "";
152
+ const sessionId = String(actor?.id ?? ctx.get("agents")?.currentInitiator?.()?.id ?? "");
153
+ if (!sessionId || !/\.(?:js|mjs|cjs|ts|tsx|jsx|css|scss|html|vue|svelte|astro|md|json|yml|yaml)$/iu.test(file)) return next();
154
+ if (/(?:^|[\\/])(?:node_modules|\.git|\.dsh|dist|build|coverage)(?:[\\/]|$)/iu.test(file)) return next();
155
+ const now = Date.now();
156
+ if (now - (lastAuto.get(sessionId) ?? 0) < AUTO_CHECKPOINT_MS) return next();
157
+ lastAuto.set(sessionId, now);
158
+ try {
159
+ const project = findDshDir(path.dirname(file));
160
+ const meta = createCheckpoint({ cwd: project.root, label: "auto before write" });
161
+ ctx.logger?.debug?.(`[agent-extensions] auto checkpoint ${meta.id}`);
162
+ } catch (error) {
163
+ ctx.logger?.debug?.(`[agent-extensions] auto checkpoint skipped: ${error instanceof Error ? error.message : String(error)}`);
164
+ }
165
+ return next();
166
+ }),
167
+ "agent-extensions: auto checkpoint",
168
+ );
169
+ } catch (error) {
170
+ ctx.logger?.warn?.(`[agent-extensions] auto checkpoint hook skipped: ${error instanceof Error ? error.message : String(error)}`);
171
+ }
172
+
173
+ ctx.logger?.info?.("[agent-extensions] ready");
174
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Project-local custom agents: `.dsh/agents/<name>.md`.
3
+ *
4
+ * The file frontmatter describes when to use the agent; the body becomes the
5
+ * child prompt. A child runs through the DSH subagents service with its own
6
+ * context and returns only its final text.
7
+ */
8
+ import crypto from "node:crypto";
9
+ import { loadAgents } from "./project.js";
10
+ import { clearChildPolicy, setChildPolicy } from "./policy.js";
11
+
12
+ function textFromOutput(output) {
13
+ if (!Array.isArray(output)) return "";
14
+ return output
15
+ .filter((block) => block && block.type === "text" && typeof block.text === "string")
16
+ .map((block) => block.text)
17
+ .join("\n")
18
+ .trim();
19
+ }
20
+
21
+ export async function runProjectAgent(ctx, exec, definition, task) {
22
+ const subagents = ctx.get("subagents");
23
+ if (!subagents || typeof subagents.startActivation !== "function") {
24
+ throw new Error("subagents service is unavailable");
25
+ }
26
+ const providers = typeof subagents.list === "function" ? subagents.list() : [];
27
+ const provider = providers.includes("spawn") ? "spawn" : providers[0];
28
+ if (!provider) throw new Error("no subagent provider is registered");
29
+ const controller = new AbortController();
30
+ const signal = exec?.signal;
31
+ const abort = () => controller.abort(signal?.reason ?? new Error("parent aborted"));
32
+ if (signal?.aborted) abort();
33
+ else signal?.addEventListener?.("abort", abort, { once: true });
34
+ const reservedId = crypto.randomUUID();
35
+ let activeId = reservedId;
36
+ setChildPolicy(reservedId, definition);
37
+ try {
38
+ const prompt = [`<project-agent name="${definition.name}">`, definition.body, "</project-agent>", "", "Task:", String(task ?? "")].join("\n");
39
+ const activation = await subagents.startActivation({
40
+ provider,
41
+ label: `agent:${definition.name}`,
42
+ childId: reservedId,
43
+ request: {
44
+ prompt: [{ type: "text", text: prompt }],
45
+ parent: exec.agent,
46
+ signal: controller.signal,
47
+ cwd: exec?.agent?.session?.header?.cwd,
48
+ ...(definition.model ? { agentOptions: { model: definition.model } } : {}),
49
+ },
50
+ signal: controller.signal,
51
+ delivery: "caller",
52
+ });
53
+ if (activation.childId && activation.childId !== reservedId) {
54
+ activeId = String(activation.childId);
55
+ setChildPolicy(activeId, definition);
56
+ clearChildPolicy(reservedId);
57
+ }
58
+ const result = await activation.result;
59
+ const text = textFromOutput(result?.output);
60
+ await activation.dispose?.();
61
+ if (!text) throw new Error(`project agent ${definition.name} returned no text`);
62
+ return { text, stopReason: result?.stopReason ?? "completed" };
63
+ } finally {
64
+ clearChildPolicy(reservedId);
65
+ clearChildPolicy(activeId);
66
+ signal?.removeEventListener?.("abort", abort);
67
+ }
68
+ }
69
+
70
+ export function projectAgentSummary(projectDir, dshDir) {
71
+ return loadAgents(projectDir, dshDir).map((agent) => ({
72
+ name: agent.name,
73
+ description: agent.description,
74
+ model: agent.model,
75
+ }));
76
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Authenticated RPC for the Agents settings page.
3
+ */
4
+ import { loadAgents, projectFor } from "./project.js";
5
+
6
+ export const RPC_NAMESPACE = "dsh-agent-extensions";
7
+ const ROUTE_BASE = "/api/dsh-agent-extensions";
8
+
9
+ function ok(rpcId, value) {
10
+ return Response.json({ type: "server-response", rpcId, result: { ok: true, value } });
11
+ }
12
+
13
+ function fail(rpcId, message) {
14
+ return Response.json({ type: "server-response", rpcId, result: { ok: false, error: { code: "agents/bad-request", message, details: {} } } });
15
+ }
16
+
17
+ async function envelope(request) {
18
+ const body = await request.json();
19
+ if (!body || body.type !== "client-request" || typeof body.rpcId !== "string" || typeof body.method !== "string") throw new Error("bad Connection envelope");
20
+ return { rpcId: body.rpcId, method: body.method, payload: body.payload ?? {} };
21
+ }
22
+
23
+ export function registerAgentApi(ctx) {
24
+ const route = {
25
+ path: `${ROUTE_BASE}/list`,
26
+ methods: ["POST"],
27
+ requestBody: "buffered",
28
+ fetch: async (request) => {
29
+ let rpcId = "invalid-request";
30
+ try {
31
+ const value = await envelope(request);
32
+ rpcId = value.rpcId;
33
+ if (value.method !== `${RPC_NAMESPACE}/list`) throw new Error("endpoint/method mismatch");
34
+ const session = value.payload?.sessionId ? ctx.get?.("sessions")?.get?.(value.payload.sessionId) : undefined;
35
+ const project = projectFor(session?.header?.cwd || value.payload?.cwd || process.cwd());
36
+ const agents = loadAgents(project.root, project.dsh).map((agent) => ({
37
+ name: agent.name,
38
+ description: agent.description,
39
+ model: agent.model,
40
+ tools: agent.tools,
41
+ permission: agent.permission,
42
+ }));
43
+ return ok(rpcId, { project: project.root, agents });
44
+ } catch (error) {
45
+ return fail(rpcId, error instanceof Error ? error.message : String(error));
46
+ }
47
+ },
48
+ };
49
+ ctx.effect(() => {
50
+ const dispose = ctx.connection.fetch.register(route);
51
+ return () => { try { void Promise.resolve(dispose()).catch(() => {}); } catch { /* unloading */ } };
52
+ }, "agent-extensions: agents route");
53
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Safe git checkpoints for agent extensions.
3
+ *
4
+ * Unlike a normal `git add`, this builds a tree through a temporary index, so
5
+ * the user's staged changes and HEAD stay untouched. Restore uses
6
+ * `git restore --source=<commit> --worktree -- .`: it restores tracked files
7
+ * from the snapshot but never deletes files created later.
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, env, allowFail = false) {
16
+ const result = spawnSync("git", args, {
17
+ cwd,
18
+ encoding: "utf8",
19
+ timeout: 30_000,
20
+ windowsHide: true,
21
+ env: { ...process.env, ...(env ?? {}) },
22
+ });
23
+ if (result.error) throw new Error(`git ${args[0]}: ${result.error.message}`);
24
+ if (result.status !== 0 && !allowFail) {
25
+ throw new Error(`git ${args.join(" ")}: ${(result.stderr || result.stdout || "").trim()}`);
26
+ }
27
+ return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
28
+ }
29
+
30
+ export function repoRoot(cwd) {
31
+ return git(cwd, ["rev-parse", "--show-toplevel"]).stdout.trim();
32
+ }
33
+
34
+ function homeDirectory(home) {
35
+ return home || process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
36
+ }
37
+
38
+ function checkpointDir(root, home) {
39
+ const digest = crypto.createHash("sha256").update(root.toLowerCase()).digest("hex").slice(0, 16);
40
+ return path.join(homeDirectory(home), "agent-extensions", "checkpoints", digest);
41
+ }
42
+
43
+ export function createCheckpoint({ cwd, label = "", home } = {}) {
44
+ const root = repoRoot(cwd);
45
+ const id = `cp-${new Date().toISOString().replace(/[-:T.Z]/gu, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`;
46
+ const indexPath = path.join(os.tmpdir(), `dsh-agent-extensions-index-${process.pid}-${Date.now()}`);
47
+ const env = { GIT_INDEX_FILE: indexPath };
48
+ try {
49
+ const hasHead = git(root, ["rev-parse", "--verify", "HEAD"], env, true).ok;
50
+ if (hasHead) git(root, ["read-tree", "HEAD"], env);
51
+ else git(root, ["read-tree", "--empty"], env);
52
+ git(root, ["add", "-A"], env);
53
+ const tree = git(root, ["write-tree"], env).stdout.trim();
54
+ const message = `dsh agent checkpoint ${id}${label ? `: ${label}` : ""}`;
55
+ const args = ["commit-tree", tree];
56
+ if (hasHead) args.push("-p", "HEAD");
57
+ args.push("-m", message);
58
+ const commit = git(root, args, env).stdout.trim();
59
+ git(root, ["update-ref", `refs/dsh-agent-extensions/${id}`, commit], env);
60
+ const meta = {
61
+ id,
62
+ label: String(label ?? ""),
63
+ createdAt: new Date().toISOString(),
64
+ repo: root,
65
+ commit,
66
+ tree,
67
+ files: git(root, ["ls-tree", "-r", "--name-only", commit], env).stdout.trim().split(/\r?\n/u).filter(Boolean).length,
68
+ };
69
+ const directory = checkpointDir(root, home);
70
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
71
+ fs.writeFileSync(path.join(directory, `${id}.json`), `${JSON.stringify(meta, null, 2)}\n`, { mode: 0o600 });
72
+ return meta;
73
+ } finally {
74
+ try { fs.rmSync(indexPath, { force: true }); } catch { /* best effort */ }
75
+ }
76
+ }
77
+
78
+ export function listCheckpoints({ cwd, home } = {}) {
79
+ const root = repoRoot(cwd);
80
+ const directory = checkpointDir(root, home);
81
+ if (!fs.existsSync(directory)) return [];
82
+ return fs
83
+ .readdirSync(directory)
84
+ .filter((name) => /^cp-.*\.json$/u.test(name))
85
+ .map((name) => {
86
+ try {
87
+ return JSON.parse(fs.readFileSync(path.join(directory, name), "utf8"));
88
+ } catch {
89
+ return null;
90
+ }
91
+ })
92
+ .filter(Boolean)
93
+ .sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)));
94
+ }
95
+
96
+ export function restoreCheckpoint({ cwd, id, home } = {}) {
97
+ const root = repoRoot(cwd);
98
+ const checkpoints = listCheckpoints({ cwd: root, home });
99
+ const checkpoint = checkpoints.find((item) => item.id === id);
100
+ if (!checkpoint) throw new Error(`checkpoint not found: ${id}`);
101
+ git(root, ["restore", "--source", checkpoint.commit, "--worktree", "--", "."]);
102
+ return checkpoint;
103
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Project commands and the small built-in command set:
3
+ * /init, /doctor, /verify, /hooks, /checkpoint, /rewind.
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import { createCheckpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
9
+ import { loadAgents, loadCommands, loadHooks, loadVerify, projectFor } from "./project.js";
10
+
11
+ function sessionProject(ctx) {
12
+ try {
13
+ const agent = ctx.get("agents")?.currentInitiator?.();
14
+ const cwd = agent?.session?.header?.cwd;
15
+ return projectFor(cwd);
16
+ } catch {
17
+ return projectFor(process.cwd());
18
+ }
19
+ }
20
+
21
+ function listLine(items, empty) {
22
+ return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : empty;
23
+ }
24
+
25
+ function initProject(projectDir, dshDir, force) {
26
+ fs.mkdirSync(dshDir, { recursive: true });
27
+ const projectFile = path.join(dshDir, "PROJECT.md");
28
+ if (fs.existsSync(projectFile) && !force) return `Уже существует: ${projectFile}. Передайте --force для перезаписи.`;
29
+ let pkg = {};
30
+ try { pkg = JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")); } catch { /* optional */ }
31
+ const dirs = [];
32
+ try {
33
+ for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
34
+ if (!entry.isDirectory()) continue;
35
+ if (["node_modules", ".git", ".dsh", "dist", "build", "coverage"].includes(entry.name)) continue;
36
+ dirs.push(entry.name);
37
+ }
38
+ } catch { /* ignore */ }
39
+ const lines = [
40
+ "# Project",
41
+ "",
42
+ `Root: ${projectDir}`,
43
+ pkg.name ? `Package: ${pkg.name}${pkg.version ? ` ${pkg.version}` : ""}` : "",
44
+ pkg.scripts?.test ? `Tests: ${pkg.scripts.test}` : "",
45
+ dirs.length > 0 ? `Directories: ${dirs.slice(0, 20).join(", ")}` : "",
46
+ "",
47
+ "## Commands",
48
+ "```",
49
+ pkg.scripts?.test ? `npm test` : "# tests not detected",
50
+ pkg.scripts?.build ? `npm run build` : "",
51
+ "```",
52
+ "",
53
+ "## Agent notes",
54
+ "- Проверяй изменения тестами и browser_qa для UI.",
55
+ "- Не коммить секреты и локальные данные.",
56
+ ].filter((line) => line !== "");
57
+ fs.writeFileSync(projectFile, `${lines.join("\n")}\n`, "utf8");
58
+ const rulesDir = path.join(dshDir, "rules");
59
+ fs.mkdirSync(rulesDir, { recursive: true });
60
+ const ruleFile = path.join(rulesDir, "project.md");
61
+ if (!fs.existsSync(ruleFile) || force) {
62
+ fs.writeFileSync(ruleFile, `---\nalways: true\n---\n# Правила проекта\n\n- Источник истины: ${path.basename(projectFile)}.\n- Перед сдачей — тесты и проверка UI.\n`, "utf8");
63
+ }
64
+ return `Создано: ${projectFile} и ${ruleFile}.`;
65
+ }
66
+
67
+ function runVerify(projectDir, dshDir) {
68
+ const configured = loadVerify(projectDir, dshDir);
69
+ let commands = configured.commands;
70
+ if (commands.length === 0) {
71
+ try {
72
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8"));
73
+ if (typeof pkg.scripts?.test === "string") commands = ["npm test"];
74
+ } catch { /* optional */ }
75
+ }
76
+ if (commands.length === 0) return { text: "Нет .dsh/verify.json и не найден npm test." };
77
+ const lines = [];
78
+ for (const command of commands) {
79
+ const result = spawnSync(command, {
80
+ cwd: projectDir,
81
+ shell: true,
82
+ encoding: "utf8",
83
+ timeout: 120_000,
84
+ windowsHide: true,
85
+ });
86
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim().split(/\r?\n/u).slice(-12).join("\n");
87
+ lines.push(`$ ${command}\nexit ${result.status ?? "?"}${output ? `\n${output}` : ""}`);
88
+ if (result.status !== 0) break;
89
+ }
90
+ return { text: lines.join("\n\n") };
91
+ }
92
+
93
+ function statusText(ctx) {
94
+ const project = sessionProject(ctx);
95
+ const dshDir = project.dsh ?? path.join(project.root, ".dsh");
96
+ const agents = loadAgents(project.root, dshDir);
97
+ const commands = loadCommands(project.root, dshDir);
98
+ const hooks = loadHooks(project.root, dshDir);
99
+ let checkpoints = [];
100
+ if (project.dsh) {
101
+ try { checkpoints = listCheckpoints({ cwd: project.root }); } catch { checkpoints = []; }
102
+ }
103
+ return [
104
+ `Project: ${project.root}`,
105
+ `.dsh: ${project.dsh ?? "нет"}`,
106
+ `Agents: ${agents.map((agent) => agent.name).join(", ") || "нет"}`,
107
+ `Commands: ${commands.map((command) => `/${command.name}`).join(", ") || "нет"}`,
108
+ `Hooks: ${hooks.trusted ? "trusted" : "не доверены"} · событий: ${Object.keys(hooks.hooks ?? {}).length}`,
109
+ `Checkpoints: ${checkpoints.length}`,
110
+ ].join("\n");
111
+ }
112
+
113
+ export function registerCommands(ctx) {
114
+ const register = (definition) => {
115
+ try {
116
+ return ctx.commands.register(definition);
117
+ } catch (error) {
118
+ ctx.logger?.warn?.(`[agent-extensions] command /${definition.name} skipped: ${error instanceof Error ? error.message : String(error)}`);
119
+ return undefined;
120
+ }
121
+ };
122
+
123
+ register({
124
+ name: "init",
125
+ description: "Создать .dsh/PROJECT.md и .dsh/rules/project.md",
126
+ input: { hint: "[--force]" },
127
+ recordInput: false,
128
+ handler: ({ rawInput }) => {
129
+ const project = sessionProject(ctx);
130
+ const dshDir = project.dsh ?? path.join(project.root, ".dsh");
131
+ return { kind: "success", text: initProject(project.root, dshDir, /--force/u.test(String(rawInput ?? ""))) };
132
+ },
133
+ });
134
+
135
+ register({
136
+ name: "doctor",
137
+ description: "Проверить hooks, agents, commands и checkpoints",
138
+ input: { hint: "" },
139
+ recordInput: false,
140
+ handler: () => ({ kind: "success", text: statusText(ctx) }),
141
+ });
142
+
143
+ register({
144
+ name: "hooks",
145
+ description: "Статус hooks, trust или отключение",
146
+ input: { hint: "status | trust | off" },
147
+ recordInput: false,
148
+ handler: ({ rawInput }) => {
149
+ const project = sessionProject(ctx);
150
+ if (!project.dsh) return { kind: "error", text: "Нет .dsh в проекте." };
151
+ const action = String(rawInput ?? "").trim().toLowerCase();
152
+ const marker = path.join(project.dsh, "hooks.trusted");
153
+ if (action === "trust") {
154
+ fs.writeFileSync(marker, `${new Date().toISOString()}\n`, "utf8");
155
+ return { kind: "success", text: "Hooks доверены. Команды из .dsh/hooks.json будут запускаться." };
156
+ }
157
+ if (action === "off") {
158
+ fs.rmSync(marker, { force: true });
159
+ return { kind: "success", text: "Доверие к hooks снято." };
160
+ }
161
+ const hooks = loadHooks(project.root, project.dsh);
162
+ return { kind: "success", text: `trusted: ${hooks.trusted}\nevents: ${Object.keys(hooks.hooks ?? {}).join(", ") || "none"}` };
163
+ },
164
+ });
165
+
166
+ register({
167
+ name: "verify",
168
+ description: "Запустить проверки проекта из .dsh/verify.json или npm test",
169
+ input: { hint: "" },
170
+ recordInput: false,
171
+ handler: () => {
172
+ const project = sessionProject(ctx);
173
+ const dshDir = project.dsh ?? path.join(project.root, ".dsh");
174
+ return { kind: "success", text: runVerify(project.root, dshDir).text };
175
+ },
176
+ });
177
+
178
+ register({
179
+ name: "checkpoint",
180
+ description: "Создать безопасный git-чекпоинт workspace",
181
+ input: { hint: "[label]" },
182
+ recordInput: false,
183
+ handler: ({ rawInput }) => {
184
+ const project = sessionProject(ctx);
185
+ try {
186
+ const meta = createCheckpoint({ cwd: project.root, label: String(rawInput ?? "").trim() });
187
+ return { kind: "success", text: `Checkpoint ${meta.id} (${meta.files} файлов).` };
188
+ } catch (error) {
189
+ return { kind: "error", text: `checkpoint: ${error instanceof Error ? error.message : String(error)}` };
190
+ }
191
+ },
192
+ });
193
+
194
+ register({
195
+ name: "rewind",
196
+ description: "Показать или восстановить checkpoint",
197
+ input: { hint: "[id]" },
198
+ recordInput: false,
199
+ handler: ({ rawInput }) => {
200
+ const project = sessionProject(ctx);
201
+ const id = String(rawInput ?? "").trim();
202
+ try {
203
+ if (!id) {
204
+ const items = listCheckpoints({ cwd: project.root }).slice(0, 10);
205
+ return { kind: "success", text: listLine(items.map((item) => `${item.id} · ${item.createdAt} · ${item.label || "без метки"}`), "Нет чекпоинтов.") };
206
+ }
207
+ const meta = restoreCheckpoint({ cwd: project.root, id });
208
+ return { kind: "success", text: `Восстановлен ${meta.id}. Файлы, созданные после, не удалены.` };
209
+ } catch (error) {
210
+ return { kind: "error", text: `rewind: ${error instanceof Error ? error.message : String(error)}` };
211
+ }
212
+ },
213
+ });
214
+
215
+ /* Project commands are loaded from the project of the *current* session. A
216
+ * single `/project` command is registered globally because DSH command
217
+ * names are process-wide; direct `/deploy` aliases would bind to whatever
218
+ * workspace happened to be first at plugin start. */
219
+ register({
220
+ name: "project",
221
+ description: "Список или запуск проектных команд из .dsh/commands",
222
+ input: { hint: "list | <name> [args]" },
223
+ recordInput: false,
224
+ handler: ({ rawInput }) => {
225
+ const project = sessionProject(ctx);
226
+ const dshDir = project.dsh ?? path.join(project.root, ".dsh");
227
+ const commands = loadCommands(project.root, dshDir);
228
+ const input = String(rawInput ?? "").trim();
229
+ if (input.length === 0 || input === "list") {
230
+ return {
231
+ kind: "success",
232
+ text: commands.length
233
+ ? commands.map((command) => `- /project ${command.name} — ${command.description}`).join("\n")
234
+ : "Нет .dsh/commands. Создайте файл .dsh/commands/<name>.md.",
235
+ };
236
+ }
237
+ const [name, ...rest] = input.split(/\s+/u);
238
+ const command = commands.find((item) => item.name === name);
239
+ if (!command) return { kind: "error", text: `Проектная команда не найдена: ${name}` };
240
+ try {
241
+ const agent = ctx.get("agents")?.currentInitiator?.();
242
+ if (!agent || typeof agent.steer !== "function") throw new Error("нет активного агента");
243
+ agent.steer({
244
+ id: `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
245
+ role: "user",
246
+ content: [{ type: "text", text: `${command.body}${rest.length > 0 ? `\n\n${rest.join(" ")}` : ""}` }],
247
+ source: { kind: "user" },
248
+ });
249
+ return { kind: "success", text: `Команда /project ${name} передана агенту.` };
250
+ } catch (error) {
251
+ return { kind: "error", text: error instanceof Error ? error.message : String(error) };
252
+ }
253
+ },
254
+ });
255
+ }