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.
- package/CHANGELOG.md +80 -0
- package/README.md +113 -27
- package/client.js +3121 -32
- package/docs/AGENT-HARNESS.md +144 -0
- package/docs/ARCHITECTURE.md +38 -7
- package/docs/COMPATIBILITY.md +4 -2
- package/docs/DEVELOPMENT.md +56 -0
- package/docs/INSTALL.md +20 -11
- package/docs/LAPTOP-INSTALL.md +96 -0
- package/docs/ROADMAP-3.0.md +353 -0
- package/docs/ROADMAP.md +1 -1
- package/docs/SUBAGENTS.md +25 -0
- package/docs/TEST-0.2.2.md +5 -5
- package/docs/TEST-2.3.0.md +49 -0
- package/docs/TEST-2.7.2.md +46 -0
- package/docs/TOKENS.md +2 -2
- package/docs/TROUBLESHOOTING.md +27 -0
- package/docs/UI-QA.md +28 -0
- package/docs/images/peak-toast-peak.png +0 -0
- package/docs/images/peak-toast-soon.png +0 -0
- package/docs/preview/peak-toast.html +155 -0
- package/index.js +18 -1
- package/package.json +26 -6
- package/plugins/dsh-agent-extensions/README.md +99 -0
- package/plugins/dsh-agent-extensions/index.js +167 -0
- package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
- package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
- package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
- package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
- package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
- package/plugins/dsh-agent-extensions/lib/project.js +144 -0
- package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
- package/plugins/dsh-agent-extensions/package.json +9 -0
- package/plugins/dsh-browser-qa/README.md +221 -0
- package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
- package/plugins/dsh-browser-qa/index.js +165 -0
- package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
- package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
- package/plugins/dsh-browser-qa/lib/pages.js +552 -0
- package/plugins/dsh-browser-qa/lib/qa.js +827 -0
- package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
- package/plugins/dsh-browser-qa/package.json +31 -0
- package/plugins/dsh-checkpoints/README.md +22 -0
- package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
- package/plugins/dsh-checkpoints/index.js +80 -0
- package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
- package/plugins/dsh-checkpoints/package.json +25 -0
- package/plugins/dsh-github-review/README.md +131 -0
- package/plugins/dsh-github-review/cordis.patch.yml +3 -0
- package/plugins/dsh-github-review/index.js +536 -0
- package/plugins/dsh-github-review/lib/gh.js +494 -0
- package/plugins/dsh-github-review/lib/normalize.js +662 -0
- package/plugins/dsh-github-review/package.json +28 -0
- package/plugins/dsh-guardrails/README.md +264 -0
- package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
- package/plugins/dsh-guardrails/index.js +389 -0
- package/plugins/dsh-guardrails/lib/hooks.js +262 -0
- package/plugins/dsh-guardrails/lib/modes.js +77 -0
- package/plugins/dsh-guardrails/lib/policy.js +592 -0
- package/plugins/dsh-guardrails/lib/scan.js +451 -0
- package/plugins/dsh-guardrails/lib/secrets.js +274 -0
- package/plugins/dsh-guardrails/package.json +26 -0
- package/plugins/dsh-mcp-manager/README.md +78 -0
- package/plugins/dsh-mcp-manager/client.js +373 -0
- package/plugins/dsh-mcp-manager/index.js +159 -0
- package/plugins/dsh-mcp-manager/lib/api.js +250 -0
- package/plugins/dsh-mcp-manager/lib/client.js +219 -0
- package/plugins/dsh-mcp-manager/lib/config.js +217 -0
- package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
- package/plugins/dsh-mcp-manager/package.json +18 -0
- package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
- package/plugins/dsh-memory-bridge/README.md +161 -0
- package/plugins/dsh-memory-bridge/client.js +2264 -0
- package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
- package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
- package/plugins/dsh-memory-bridge/icon.svg +13 -0
- package/plugins/dsh-memory-bridge/index.js +378 -0
- package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
- package/plugins/dsh-memory-bridge/lib/github.js +473 -0
- package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
- package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
- package/plugins/dsh-memory-bridge/lib/state.js +565 -0
- package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
- package/plugins/dsh-memory-bridge/locale/en.json +6 -0
- package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
- package/plugins/dsh-memory-bridge/package.json +67 -0
- package/plugins/dsh-peak-guard/README.md +16 -3
- package/plugins/dsh-peak-guard/client.js +223 -8
- package/plugins/dsh-peak-guard/package.json +1 -1
- package/plugins/dsh-plan-mode/README.md +24 -0
- package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
- package/plugins/dsh-plan-mode/index.js +103 -0
- package/plugins/dsh-plan-mode/lib/plan.js +89 -0
- package/plugins/dsh-plan-mode/package.json +18 -0
- package/plugins/dsh-profile-transfer/client.js +277 -17
- package/plugins/dsh-profile-transfer/index.js +10 -0
- package/plugins/dsh-profile-transfer/package.json +3 -1
- package/plugins/dsh-profile-transfer/transfer.js +101 -2
- package/plugins/dsh-project-memory/index.js +92 -2
- package/plugins/dsh-project-memory/package.json +1 -1
- package/plugins/dsh-repo-map/README.md +26 -0
- package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
- package/plugins/dsh-repo-map/index.js +99 -0
- package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
- package/plugins/dsh-repo-map/package.json +25 -0
- package/plugins/dsh-skill-autopilot/index.js +135 -11
- package/skills/delivery-gate/SKILL.md +31 -0
- package/skills/git-and-ci/SKILL.md +5 -1
- package/skills/manifest.json +31 -6
- package/skills/requirements-and-planning/SKILL.md +3 -0
- package/skills/research-first/SKILL.md +35 -0
- package/skills/research-first/references/query-patterns.md +20 -0
- package/skills/subagent-playbook/SKILL.md +36 -0
- package/skills/subagent-playbook/references/prompts.md +39 -0
- package/skills/ui-craft/SKILL.md +43 -0
- package/skills/visual-qa/SKILL.md +29 -0
- package/skills/visual-qa/references/commands.md +32 -0
- package/skills-baseline.json +219 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-defined project hooks.
|
|
3
|
+
*
|
|
4
|
+
* `.dsh/hooks.json` is opt-in: commands run only when `.dsh/hooks.trusted`
|
|
5
|
+
* exists. Events mirror the useful subset of Claude Code hooks:
|
|
6
|
+
* PreToolUse, PostToolUse, Stop, FileChanged, SessionStart, SessionEnd.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { findDshDir, loadHooks } from "./project.js";
|
|
11
|
+
|
|
12
|
+
const MAX_OUTPUT = 12_000;
|
|
13
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
14
|
+
const MAX_TIMEOUT = 120_000;
|
|
15
|
+
|
|
16
|
+
function sessionCwd(agent) {
|
|
17
|
+
const cwd = agent?.session?.header?.cwd;
|
|
18
|
+
return typeof cwd === "string" && cwd.length > 0 ? cwd : "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hookProject(cwd, fallbackFile) {
|
|
22
|
+
const start = cwd || (typeof fallbackFile === "string" ? path.dirname(fallbackFile) : process.cwd());
|
|
23
|
+
return findDshDir(start);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function matcherMatches(matcher, value) {
|
|
27
|
+
if (typeof matcher !== "string" || matcher.trim().length === 0) return true;
|
|
28
|
+
try {
|
|
29
|
+
return new RegExp(matcher, "iu").test(String(value ?? ""));
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runCommand(hook, payload, cwd, sessionId) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const timeout = Math.max(1_000, Math.min(MAX_TIMEOUT, Number(hook.timeoutMs) || DEFAULT_TIMEOUT));
|
|
38
|
+
let stdout = "";
|
|
39
|
+
let stderr = "";
|
|
40
|
+
let settled = false;
|
|
41
|
+
let child;
|
|
42
|
+
const finish = (result) => {
|
|
43
|
+
if (settled) return;
|
|
44
|
+
settled = true;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
resolve(result);
|
|
47
|
+
};
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
try { child?.kill(); } catch { /* best effort */ }
|
|
50
|
+
finish({ code: 124, stdout, stderr: `${stderr}\nhook timed out after ${timeout}ms`.trim(), timedOut: true });
|
|
51
|
+
}, timeout);
|
|
52
|
+
try {
|
|
53
|
+
child = spawn(String(hook.command), {
|
|
54
|
+
cwd,
|
|
55
|
+
shell: true,
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
env: {
|
|
58
|
+
...process.env,
|
|
59
|
+
DSH_HOOK_EVENT: String(hook.event ?? ""),
|
|
60
|
+
DSH_PROJECT_DIR: cwd,
|
|
61
|
+
DSH_SESSION_ID: sessionId,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
finish({ code: 1, stdout: "", stderr: String(error) });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
child.stdout?.on("data", (chunk) => {
|
|
69
|
+
if (stdout.length < MAX_OUTPUT) stdout += chunk.toString("utf8");
|
|
70
|
+
});
|
|
71
|
+
child.stderr?.on("data", (chunk) => {
|
|
72
|
+
if (stderr.length < MAX_OUTPUT) stderr += chunk.toString("utf8");
|
|
73
|
+
});
|
|
74
|
+
child.on("error", (error) => finish({ code: 1, stdout, stderr: String(error) }));
|
|
75
|
+
child.on("close", (code) => finish({ code: Number(code) || 0, stdout, stderr }));
|
|
76
|
+
try {
|
|
77
|
+
child.stdin?.end(JSON.stringify(payload));
|
|
78
|
+
} catch {
|
|
79
|
+
/* no stdin */
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function registerProjectHooks(ctx) {
|
|
85
|
+
const disposers = [];
|
|
86
|
+
const logger = ctx.logger;
|
|
87
|
+
|
|
88
|
+
async function runEvent(event, agent, payload, matchValue) {
|
|
89
|
+
const project = hookProject(sessionCwd(agent), payload?.file ?? payload?.path);
|
|
90
|
+
const loaded = loadHooks(project.root, project.dsh);
|
|
91
|
+
if (!loaded.trusted) return { blocked: false, results: [], untrusted: true };
|
|
92
|
+
const hooks = Array.isArray(loaded.hooks?.[event]) ? loaded.hooks[event] : [];
|
|
93
|
+
if (hooks.length === 0) return { blocked: false, results: [] };
|
|
94
|
+
const results = [];
|
|
95
|
+
for (const hook of hooks) {
|
|
96
|
+
if (!hook || typeof hook.command !== "string" || hook.command.trim().length === 0) continue;
|
|
97
|
+
if (!matcherMatches(hook.matcher, matchValue)) continue;
|
|
98
|
+
const result = await runCommand(hook, { event, ...payload }, project.root, String(agent?.id ?? ""));
|
|
99
|
+
results.push({ ...result, command: hook.command });
|
|
100
|
+
const code = result.code;
|
|
101
|
+
if (code !== 0) {
|
|
102
|
+
logger?.warn?.(`[agent-extensions] ${event} hook exited ${code}: ${(result.stderr || result.stdout).slice(0, 300)}`);
|
|
103
|
+
}
|
|
104
|
+
if (code === 2) return { blocked: true, results, reason: (result.stderr || result.stdout || `${event} hook blocked`).trim() };
|
|
105
|
+
}
|
|
106
|
+
return { blocked: false, results };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const on = (event, handler) => {
|
|
110
|
+
try {
|
|
111
|
+
const dispose = ctx.on(event, handler);
|
|
112
|
+
if (typeof dispose === "function") disposers.push(dispose);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
logger?.warn?.(`[agent-extensions] ${event} hook skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
on("tools/pre-execute", async (exec, next) => {
|
|
119
|
+
const result = await runEvent("PreToolUse", exec?.agent, {
|
|
120
|
+
tool: exec?.name,
|
|
121
|
+
arguments: exec?.arguments ?? {},
|
|
122
|
+
}, exec?.name);
|
|
123
|
+
if (result.blocked) return { kind: "deny", reason: `project hook: ${result.reason}` };
|
|
124
|
+
return typeof next === "function" ? next() : { kind: "allow" };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
on("tools/result", async (exec, result) => {
|
|
128
|
+
await runEvent("PostToolUse", exec?.agent, {
|
|
129
|
+
tool: exec?.name,
|
|
130
|
+
arguments: exec?.arguments ?? {},
|
|
131
|
+
ok: result?.isError !== true,
|
|
132
|
+
}, exec?.name);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
on("fs/write-intent", async (target, actor, next) => {
|
|
136
|
+
const file = target?.displayPath ?? target?.path ?? "";
|
|
137
|
+
const result = await runEvent("FileChanged", actor, { file, operation: "write" }, file);
|
|
138
|
+
if (result.blocked) throw new Error(`project hook: ${result.reason}`);
|
|
139
|
+
return typeof next === "function" ? next() : undefined;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
on("fs/edit-intent", async (target, actor, next) => {
|
|
143
|
+
const file = target?.displayPath ?? target?.path ?? "";
|
|
144
|
+
const result = await runEvent("FileChanged", actor, { file, operation: "edit" }, file);
|
|
145
|
+
if (result.blocked) throw new Error(`project hook: ${result.reason}`);
|
|
146
|
+
return typeof next === "function" ? next() : undefined;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
on("agent/turn-stopping", async (payload) => {
|
|
150
|
+
const agent = payload?.agent;
|
|
151
|
+
const result = await runEvent("Stop", agent, { turn: payload?.turn ?? 0 }, "");
|
|
152
|
+
if (result.blocked) {
|
|
153
|
+
try {
|
|
154
|
+
agent?.steer?.({
|
|
155
|
+
id: `hook-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
156
|
+
role: "user",
|
|
157
|
+
content: [{ type: "text", text: `<project-hook-stop>\n${result.reason}\n</project-hook-stop>` }],
|
|
158
|
+
source: { kind: "user" },
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger?.warn?.(`[agent-extensions] stop steer failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
on("agent/created", async (payload) => {
|
|
167
|
+
await runEvent("SessionStart", payload?.agent, { session: String(payload?.agent?.id ?? "") }, "");
|
|
168
|
+
});
|
|
169
|
+
on("agent/disposed", async (payload) => {
|
|
170
|
+
await runEvent("SessionEnd", payload?.agent, { session: String(payload?.agent?.id ?? "") }, "");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
dispose() {
|
|
175
|
+
for (const dispose of disposers) {
|
|
176
|
+
try { dispose(); } catch { /* best effort */ }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export { runCommand, matcherMatches };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforcement for project subagents.
|
|
3
|
+
*
|
|
4
|
+
* DSH's subagents service starts a child before a parent can attach a policy,
|
|
5
|
+
* so `project_agent` reserves the child id, records the policy, and only then
|
|
6
|
+
* starts the child. The global pre-execute hook denies calls made by that child
|
|
7
|
+
* when its tool allowlist or permission level forbids them.
|
|
8
|
+
*/
|
|
9
|
+
const MUTATING_TOOLS = new Set(["write", "edit", "apply_patch", "patch", "delete", "move", "mkdir", "touch"]);
|
|
10
|
+
const SHELL_TOOLS = new Set(["bash", "pwsh", "powershell", "shell", "sh", "zsh", "cmd", "terminal"]);
|
|
11
|
+
const MUTATING_SHELL = /(?:^|[;&|]\s*|\s)(?:rm|rmdir|mv|cp|mkdir|touch|truncate|tee|dd|chmod|chown|Set-Content|Add-Content|Out-File|New-Item|Remove-Item|Move-Item|Copy-Item|git\s+(?:commit|push|reset|checkout|clean|merge|rebase)|npm\s+(?:install|uninstall|update)|pnpm\s+(?:add|remove|install)|composer\s+(?:install|update)|php\s+artisan\s+(?:migrate|db:)|dropdb|createdb|psql\b[^\n]*(?:insert|update|delete|drop|truncate|alter))/iu;
|
|
12
|
+
|
|
13
|
+
const policies = new Map();
|
|
14
|
+
|
|
15
|
+
export function parseAllowedTools(value) {
|
|
16
|
+
if (typeof value !== "string") return new Set();
|
|
17
|
+
return new Set(value.split(/[,\s]+/u).map((name) => name.trim()).filter(Boolean));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setChildPolicy(childId, definition) {
|
|
21
|
+
if (!childId) return;
|
|
22
|
+
policies.set(String(childId), {
|
|
23
|
+
name: definition?.name ?? "project-agent",
|
|
24
|
+
allowed: parseAllowedTools(definition?.tools),
|
|
25
|
+
permission: definition?.permission === "read-only" || definition?.permission === "workspace-write"
|
|
26
|
+
? definition.permission
|
|
27
|
+
: "full",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clearChildPolicy(childId) {
|
|
32
|
+
if (childId) policies.delete(String(childId));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function checkChildTool(exec) {
|
|
36
|
+
const id = String(exec?.agent?.id ?? "");
|
|
37
|
+
const policy = policies.get(id);
|
|
38
|
+
if (!policy) return null;
|
|
39
|
+
const tool = String(exec?.name ?? "");
|
|
40
|
+
if (policy.permission === "read-only") {
|
|
41
|
+
if (MUTATING_TOOLS.has(tool)) {
|
|
42
|
+
return { kind: "deny", reason: `subagent ${policy.name}: permission read-only запрещает ${tool}` };
|
|
43
|
+
}
|
|
44
|
+
if (SHELL_TOOLS.has(tool)) {
|
|
45
|
+
const command = exec?.arguments && typeof exec.arguments === "object" ? exec.arguments.command : "";
|
|
46
|
+
if (typeof command === "string" && MUTATING_SHELL.test(command)) {
|
|
47
|
+
return { kind: "deny", reason: `subagent ${policy.name}: permission read-only запрещает изменяющую shell-команду` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (policy.allowed.size > 0 && !policy.allowed.has(tool)) {
|
|
52
|
+
return { kind: "deny", reason: `subagent ${policy.name}: tool ${tool} не входит в allowlist` };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerChildPolicyHook(ctx) {
|
|
58
|
+
const disposers = [];
|
|
59
|
+
const on = (event, handler) => {
|
|
60
|
+
try {
|
|
61
|
+
const dispose = ctx.on(event, handler);
|
|
62
|
+
if (typeof dispose === "function") disposers.push(dispose);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
ctx.logger?.warn?.(`[agent-extensions] policy hook ${event} skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
on("tools/pre-execute", (exec, next) => {
|
|
68
|
+
const hit = checkChildTool(exec);
|
|
69
|
+
if (hit) return { kind: "deny", reason: hit.reason };
|
|
70
|
+
return typeof next === "function" ? next() : { kind: "allow" };
|
|
71
|
+
});
|
|
72
|
+
on("agent/disposed", (payload) => clearChildPolicy(payload?.agent?.id));
|
|
73
|
+
return {
|
|
74
|
+
dispose() {
|
|
75
|
+
for (const dispose of disposers) {
|
|
76
|
+
try { dispose(); } catch { /* best effort */ }
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function childPolicyCount() {
|
|
83
|
+
return policies.size;
|
|
84
|
+
}
|