dsh-plugin-worktrees 0.1.0
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/AGENTS.md +120 -0
- package/CHANGELOG.md +266 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/README.zh.md +151 -0
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +30 -0
- package/docs/DESIGN.md +783 -0
- package/docs/TASKS.md +164 -0
- package/lib/config.js +126 -0
- package/lib/engine-face.js +328 -0
- package/lib/git-port.js +773 -0
- package/lib/index.js +402 -0
- package/lib/merge-queue.js +832 -0
- package/lib/naming.js +107 -0
- package/lib/repo-gate.js +202 -0
- package/lib/state-store.js +512 -0
- package/lib/tools/worktree-cleanup.js +127 -0
- package/lib/tools/worktree-create.js +212 -0
- package/lib/tools/worktree-list.js +234 -0
- package/lib/tools/worktree-merge.js +396 -0
- package/lib/tools/worktree-queue.js +330 -0
- package/lib/tools/worktree-status.js +194 -0
- package/lib/worktree-service.js +673 -0
- package/package.json +55 -0
- package/scripts/link-harness-dsh-tools.sh +95 -0
- package/scripts/lint.js +142 -0
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-worktrees",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "git worktree parallel write isolation + serial merge integration plugin for the DeepSeek Harness",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib/",
|
|
9
|
+
"scripts/",
|
|
10
|
+
"cordis.patch.yml",
|
|
11
|
+
"README.md",
|
|
12
|
+
"README.zh.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"AGENTS.md",
|
|
15
|
+
"SECURITY.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"docs/"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"lint": "node scripts/lint.js",
|
|
22
|
+
"setup:peer": "bash scripts/link-harness-dsh-tools.sh"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"deepseek-harness",
|
|
26
|
+
"cordis",
|
|
27
|
+
"git",
|
|
28
|
+
"worktree",
|
|
29
|
+
"merge-queue"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"zod": "^3.23.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
39
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
|
40
|
+
},
|
|
41
|
+
"dsh": {
|
|
42
|
+
"bundle": {
|
|
43
|
+
"patch": "./cordis.patch.yml"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/Luck9Star/dsh-worktrees.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/Luck9Star/dsh-worktrees#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/Luck9Star/dsh-worktrees/issues"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# link-harness-dsh-tools.sh — symlink the RUNNING harness's @deepseek-ai/dsh-tools
|
|
3
|
+
# into this repo's node_modules.
|
|
4
|
+
#
|
|
5
|
+
# Why: @deepseek-ai/dsh-tools is a peerDependency. dsh-tools registers its
|
|
6
|
+
# tool-runtime scheduler under a module-level Symbol (TOOL_RUNTIME_SCHEDULER),
|
|
7
|
+
# so a second physical copy of the package (e.g. hoisted into a profile tree by
|
|
8
|
+
# a regular dependency, or installed here for development) is a SECOND module
|
|
9
|
+
# instance with a SECOND Symbol. The agent loop then fails every tool call:
|
|
10
|
+
# Cannot read properties of undefined (reading 'prepare')
|
|
11
|
+
# Linking to the copy the harness itself runs keeps a single instance.
|
|
12
|
+
#
|
|
13
|
+
# Re-run after a dsh upgrade (new npx cache dir) or an `npm install` here.
|
|
14
|
+
#
|
|
15
|
+
# Note: dsh-worktrees carries NO patches/ directory (zero host patches — the
|
|
16
|
+
# plugin patches nothing into the harness), so the resolve_live_root logic from
|
|
17
|
+
# the dsh-plugin-subagents family's patches/resolve-root.sh is INLINED below
|
|
18
|
+
# (red line 11 stays intact: no hardcoded cache-hash paths, no
|
|
19
|
+
# `ls ~/.npm/_npx/* | tail -1` pick-a-root heuristics). resolve_live_root walks
|
|
20
|
+
# `command -v dsh` -> realpath -> upward to the node_modules parent (or honors
|
|
21
|
+
# DSH_HARNESS_ROOT), and the peer package is read from that root. Use this
|
|
22
|
+
# script (or the explicit HARNESS_DSH_TOOLS override) to keep dsh-tools in sync
|
|
23
|
+
# after a harness move.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
27
|
+
TARGET="${REPO_DIR}/node_modules/@deepseek-ai/dsh-tools"
|
|
28
|
+
|
|
29
|
+
# --- inline resolve_live_root (this plugin has no patches/ dir) --------------
|
|
30
|
+
# Resolves the live dsh harness root: the parent of the node_modules directory
|
|
31
|
+
# that holds the `dsh` binary currently on PATH.
|
|
32
|
+
resolve_live_root() {
|
|
33
|
+
# 1. An explicit DSH_HARNESS_ROOT beats PATH resolution.
|
|
34
|
+
if [ -n "${DSH_HARNESS_ROOT:-}" ]; then
|
|
35
|
+
_rr_root="${DSH_HARNESS_ROOT}"
|
|
36
|
+
else
|
|
37
|
+
# 2. `command -v dsh` -> realpath -> walk up to the node_modules parent.
|
|
38
|
+
_rr_bin="$(command -v dsh 2>/dev/null || true)"
|
|
39
|
+
if [ -z "${_rr_bin}" ]; then
|
|
40
|
+
printf 'resolve-root: ERROR: dsh not found on PATH — is the harness installed?\n' >&2
|
|
41
|
+
return 1
|
|
42
|
+
fi
|
|
43
|
+
if ! _rr_real="$(realpath "${_rr_bin}" 2>/dev/null)"; then
|
|
44
|
+
printf 'resolve-root: ERROR: realpath failed for %s\n' "${_rr_bin}" >&2
|
|
45
|
+
return 1
|
|
46
|
+
fi
|
|
47
|
+
_rr_dir="$(dirname "${_rr_real}")"
|
|
48
|
+
_rr_root=""
|
|
49
|
+
while [ -n "${_rr_dir}" ] && [ "${_rr_dir}" != "/" ]; do
|
|
50
|
+
if [ "$(basename "${_rr_dir}")" = "node_modules" ]; then
|
|
51
|
+
_rr_root="$(dirname "${_rr_dir}")"
|
|
52
|
+
break
|
|
53
|
+
fi
|
|
54
|
+
_rr_dir="$(dirname "${_rr_dir}")"
|
|
55
|
+
done
|
|
56
|
+
if [ -z "${_rr_root}" ]; then
|
|
57
|
+
printf 'resolve-root: ERROR: %s is not under any node_modules — this is not a live dsh harness root\n' "${_rr_real}" >&2
|
|
58
|
+
return 1
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
# 3. Self-verification: the root must actually carry a dsh-tools peer.
|
|
62
|
+
if [ ! -d "${_rr_root}/node_modules/@deepseek-ai/dsh-tools" ]; then
|
|
63
|
+
printf 'resolve-root: ERROR: %s/node_modules/@deepseek-ai/dsh-tools does not exist — this is not a live dsh harness root\n' "${_rr_root}" >&2
|
|
64
|
+
return 1
|
|
65
|
+
fi
|
|
66
|
+
printf '%s\n' "${_rr_root}"
|
|
67
|
+
}
|
|
68
|
+
# -----------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
# Allow an explicit override: HARNESS_DSH_TOOLS=/path/to/dsh-tools ./scripts/link-harness-dsh-tools.sh
|
|
71
|
+
if [ -n "${HARNESS_DSH_TOOLS:-}" ]; then
|
|
72
|
+
HARNESS_TOOLS="${HARNESS_DSH_TOOLS}"
|
|
73
|
+
else
|
|
74
|
+
LIVE_ROOT="$(resolve_live_root)" || {
|
|
75
|
+
echo "ERROR: cannot resolve the live dsh harness root (see resolve-root diagnostics above)" >&2
|
|
76
|
+
echo "Hint: HARNESS_DSH_TOOLS=/path/to/dsh-tools $0" >&2
|
|
77
|
+
exit 1
|
|
78
|
+
}
|
|
79
|
+
HARNESS_TOOLS="${LIVE_ROOT}/node_modules/@deepseek-ai/dsh-tools"
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
if [ ! -d "${HARNESS_TOOLS}" ]; then
|
|
83
|
+
echo "ERROR: ${HARNESS_TOOLS} is not a directory" >&2
|
|
84
|
+
echo "Hint: point HARNESS_DSH_TOOLS at the live dsh-tools package, e.g. HARNESS_DSH_TOOLS=\$(npm root -g)/@deepseek-ai/dsh-tools $0" >&2
|
|
85
|
+
exit 1
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
mkdir -p "${REPO_DIR}/node_modules/@deepseek-ai"
|
|
89
|
+
if [ -L "${TARGET}" ] && [ "$(readlink "${TARGET}")" = "${HARNESS_TOOLS}" ]; then
|
|
90
|
+
echo "OK: ${TARGET} -> ${HARNESS_TOOLS} (already correct)"
|
|
91
|
+
exit 0
|
|
92
|
+
fi
|
|
93
|
+
rm -rf "${TARGET}"
|
|
94
|
+
ln -s "${HARNESS_TOOLS}" "${TARGET}"
|
|
95
|
+
echo "LINKED: ${TARGET} -> ${HARNESS_TOOLS}"
|
package/scripts/lint.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// dsh-worktrees lint: cross-platform syntax check + git-exit discipline (T12).
|
|
2
|
+
//
|
|
3
|
+
// 1. `node --check` every .js/.mjs under lib/ and test/ (as before, T01).
|
|
4
|
+
// 2. A static git-exit audit over lib/ (DESIGN §11.3 red line 1 = §12 rule 1):
|
|
5
|
+
// the ONLY module that may invoke a process-spawning call (`spawn(`,
|
|
6
|
+
// `spawnSync(`, `exec(`, `execSync(`, `execFile(`, `execFileSync(`) is
|
|
7
|
+
// lib/git-port.js. Every other module must go through GitPort — this is a
|
|
8
|
+
// mechanical guard that keeps the "unique git ingress" red line intact.
|
|
9
|
+
// 3. An embedded self-test asserts the audit fires on a positive sample
|
|
10
|
+
// (a non-git-port module calling spawn()) and stays silent on the negative
|
|
11
|
+
// sample (git-port.js calling spawn()) — pinning the audit's own behaviour.
|
|
12
|
+
//
|
|
13
|
+
// Comments are scanned too (the static discipline is intentionally
|
|
14
|
+
// conservative), but only REAL call shapes are flagged: the regex requires an
|
|
15
|
+
// identifier followed by `(`, so prose that merely mentions the word ("this
|
|
16
|
+
// module never spawns git itself", "spawn failure") does not trip it. Only a
|
|
17
|
+
// genuine `name(` after one of the spawn/exec method names is a violation.
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
19
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
const root = fileURLToPath(new URL('..', import.meta.url))
|
|
24
|
+
|
|
25
|
+
// The single module that owns every git subprocess. All other lib/ modules
|
|
26
|
+
// must inject a GitPort (or be pure) and never spawn git themselves.
|
|
27
|
+
const GIT_PORT_FILE = 'git-port.js'
|
|
28
|
+
|
|
29
|
+
// Real spawn/exec call forms: `name(` where name is one of the node:child_process
|
|
30
|
+
// launchers (case-sensitive; lower-case `spawn` triggers on both `spawn(` and
|
|
31
|
+
// method receivers like `this.spawn(` because `\b` boundary lets the dot separate).
|
|
32
|
+
// An explicit `\s*\(` ensures only an actual call (open paren) is a violation —
|
|
33
|
+
// bare mentions ("never spawns git itself", "spawn failure") stay clean.
|
|
34
|
+
const EXIT_RE = /\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\(/g
|
|
35
|
+
|
|
36
|
+
// Line of match start within a source string.
|
|
37
|
+
function lineAt(src, index) {
|
|
38
|
+
return (src.slice(0, index).match(/\n/g) || []).length + 1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Audit a set of sources ({filePath: source}). Returns violations of the git-exit
|
|
42
|
+
// discipline: [{ file, line, snippet }]. git-port.js is exempt (the sole git exit).
|
|
43
|
+
export function checkGitExitDiscipline(sources) {
|
|
44
|
+
const violations = []
|
|
45
|
+
for (const file of Object.keys(sources)) {
|
|
46
|
+
if (join(file).endsWith(GIT_PORT_FILE)) continue // the one sanctioned exit
|
|
47
|
+
const src = sources[file]
|
|
48
|
+
EXIT_RE.lastIndex = 0
|
|
49
|
+
let m
|
|
50
|
+
while ((m = EXIT_RE.exec(src)) !== null) {
|
|
51
|
+
const line = lineAt(src, m.index)
|
|
52
|
+
const lineStart = src.lastIndexOf('\n', m.index) + 1
|
|
53
|
+
const lineEnd = src.indexOf('\n', m.index)
|
|
54
|
+
const snippet = src.slice(lineStart, lineEnd === -1 ? src.length : lineEnd).trim()
|
|
55
|
+
violations.push({ file, line, snippet })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return violations
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Embedded self-test (T12): pin the audit's behaviour with one positive and one
|
|
62
|
+
// negative sample. Returns 0 on pass, throws/returns non-zero on failure.
|
|
63
|
+
export function selfTest() {
|
|
64
|
+
const positive = {
|
|
65
|
+
'lib/tools/worktree-x.js':
|
|
66
|
+
"import { spawn } from 'node:child_process'\n" +
|
|
67
|
+
'const child = spawn("git", ["merge", "--abort"]) // must be flagged\n',
|
|
68
|
+
}
|
|
69
|
+
const negative = {
|
|
70
|
+
'lib/git-port.js': "import { spawn } from 'node:child_process'\nconst c = spawn('git', [])\n",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pos = checkGitExitDiscipline(positive)
|
|
74
|
+
if (pos.length !== 1) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`self-test FAIL: positive sample (spawn in non-git-port lib module) ` +
|
|
77
|
+
`expected 1 violation, got ${pos.length}`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
const exp = /lib[/\\]tools[/\\]worktree-x\.js/
|
|
81
|
+
if (pos[0].file !== 'lib/tools/worktree-x.js' || !pos[0].line || !pos[0].snippet) {
|
|
82
|
+
throw new Error(`self-test FAIL: positive sample flagged wrong record: ${JSON.stringify(pos[0])}`)
|
|
83
|
+
}
|
|
84
|
+
if (!exp.test(pos[0].file)) {
|
|
85
|
+
throw new Error(`self-test FAIL: expected a lib/tools violation, got ${pos[0].file}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const neg = checkGitExitDiscipline(negative)
|
|
89
|
+
if (neg.length !== 0) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`self-test FAIL: negative sample (spawn in git-port.js) expected 0 violations, ` +
|
|
92
|
+
`got ${neg.length}: ${JSON.stringify(neg)}`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function walk(dir, files) {
|
|
99
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
100
|
+
const p = join(dir, entry.name)
|
|
101
|
+
if (entry.isDirectory()) walk(p, files)
|
|
102
|
+
else if (entry.name.endsWith('.js') || entry.name.endsWith('.mjs')) {
|
|
103
|
+
files.push(p)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function run() {
|
|
109
|
+
// 1. Syntax-check every module under lib/ and test/.
|
|
110
|
+
const files = []
|
|
111
|
+
walk(join(root, 'lib'), files)
|
|
112
|
+
walk(join(root, 'test'), files)
|
|
113
|
+
for (const file of files) {
|
|
114
|
+
execFileSync(process.execPath, ['--check', file], { stdio: 'inherit' })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Git-exit discipline audit over lib/ (all files, including lib/tools/).
|
|
118
|
+
const dirFiles = []
|
|
119
|
+
walk(join(root, 'lib'), dirFiles)
|
|
120
|
+
const sources = {}
|
|
121
|
+
for (const file of dirFiles) {
|
|
122
|
+
sources[file] = readFileSync(file, 'utf8')
|
|
123
|
+
}
|
|
124
|
+
const violations = checkGitExitDiscipline(sources)
|
|
125
|
+
if (violations.length > 0) {
|
|
126
|
+
console.error(`git-exit discipline violation (only ${GIT_PORT_FILE} may spawn):`)
|
|
127
|
+
for (const { file, line, snippet } of violations) {
|
|
128
|
+
console.error(` ${file}:${line}: ${snippet}`)
|
|
129
|
+
}
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3. Pin the audit's own behaviour with the embedded positive/negative samples.
|
|
134
|
+
selfTest()
|
|
135
|
+
|
|
136
|
+
console.log(
|
|
137
|
+
`lint ok: ${files.length} files syntax-checked, ` +
|
|
138
|
+
`${dirFiles.length} lib sources git-exit-clean, self-test passed`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
run()
|