dsh-tower 1.3.0 → 2.0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.en.md +22 -8
  4. package/README.md +20 -8
  5. package/cordis.patch.yml +10 -0
  6. package/docs/README.md +8 -4
  7. package/docs/architecture/overview.md +13 -9
  8. package/docs/faq.md +33 -2
  9. package/docs/guide/hot-reload.md +189 -0
  10. package/docs/guide/install-upgrade-migration.md +11 -0
  11. package/docs/guide/quickstart.md +33 -1
  12. package/docs/operations/runbook.md +23 -0
  13. package/docs/operations/security.md +23 -0
  14. package/docs/reference/blockers.md +10 -2
  15. package/docs/reference/config.md +65 -2
  16. package/docs/reference/doctor.md +31 -0
  17. package/docs/reference/errors.md +50 -0
  18. package/docs/reference/events.md +45 -0
  19. package/docs/reference/operations.md +52 -0
  20. package/docs/reference/testing.md +61 -0
  21. package/docs/reference/tools.md +65 -23
  22. package/docs/zh/README.md +58 -0
  23. package/docs/zh/faq.md +72 -0
  24. package/docs/zh/guide/hot-reload.md +178 -0
  25. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  26. package/docs/zh/guide/quickstart.md +96 -0
  27. package/docs/zh/operations/security.md +42 -0
  28. package/docs/zh/reference/blockers.md +62 -0
  29. package/docs/zh/reference/config.md +113 -0
  30. package/docs/zh/reference/doctor.md +30 -0
  31. package/docs/zh/reference/errors.md +52 -0
  32. package/docs/zh/reference/events.md +46 -0
  33. package/docs/zh/reference/operations.md +48 -0
  34. package/docs/zh/reference/testing.md +60 -0
  35. package/docs/zh/reference/tools.md +75 -0
  36. package/index.js +261 -13
  37. package/lib/batch.js +578 -0
  38. package/lib/config.js +270 -8
  39. package/lib/doctor.js +165 -0
  40. package/lib/errors.js +111 -0
  41. package/lib/hmr.js +196 -0
  42. package/lib/leases.js +317 -0
  43. package/lib/legacy.js +413 -0
  44. package/lib/modeStore.js +28 -1
  45. package/lib/store.js +24 -1
  46. package/lib/tests.js +628 -0
  47. package/lib/tools.js +1554 -182
  48. package/lib/tower.js +957 -102
  49. package/lib/workspaceLock.js +117 -0
  50. package/lib/worktreeOwnership.js +166 -0
  51. package/package.json +4 -1
  52. package/reviewer.js +1 -0
  53. package/scripts/check-docs.mjs +135 -0
  54. package/scripts/enable-dev-hmr.mjs +347 -0
  55. package/skills/tower-review.md +4 -2
  56. package/skills/tower-worker-protocol.md +3 -1
  57. package/worker.js +4 -2
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+ // dsh-tower — enable DSH HMR for this repository inside one DSH profile.
3
+ //
4
+ // DSH's `@deepseek-ai/dsh-hmr` service watches the directories listed in its
5
+ // `root` config and reconciles the profile when files change. The shipped
6
+ // profile keeps `root: []` (nothing is watched), so a development checkout has
7
+ // to append one loader override to the *user* patch layer:
8
+ //
9
+ // <DSH_HOME>/profiles/<profile>/cordis.patch.yml
10
+ //
11
+ // This script owns exactly one marked block in that file:
12
+ //
13
+ // # dsh-tower:dev-hmr
14
+ // - id: hmr
15
+ // config:
16
+ // root:
17
+ // - '<absolute repo path with forward slashes>'
18
+ //
19
+ // It is idempotent (re-running replaces the block), preserves every other
20
+ // line, supports --dry-run and --remove, and never creates a profile.
21
+ //
22
+ // Usage:
23
+ // node scripts/enable-dev-hmr.mjs [--profile desktop] [--root <dir>]
24
+ // [--dry-run] [--remove]
25
+ //
26
+ // Exit codes: 0 = success or nothing to do, 1 = bad arguments or I/O failure.
27
+ import fs from 'node:fs'
28
+ import os from 'node:os'
29
+ import path from 'node:path'
30
+ import { pathToFileURL } from 'node:url'
31
+
32
+ /** Comment line that starts the managed block. */
33
+ export const MARKER = '# dsh-tower:dev-hmr'
34
+ const PATCH_FILENAME = 'cordis.patch.yml'
35
+
36
+ /** Render the managed block (no leading or trailing blank line). */
37
+ export function renderBlock(root) {
38
+ const quoted = String(root).replaceAll("'", "''")
39
+ return [MARKER, '- id: hmr', ' config:', ' root:', ` - '${quoted}'`].join('\n')
40
+ }
41
+
42
+ /** Convert a native path to the forward-slash form DSH HMR configs use. */
43
+ export function forwardSlashes(value) {
44
+ return String(value).replaceAll('\\', '/')
45
+ }
46
+
47
+ function eolOf(text) {
48
+ return text.includes('\r\n') ? '\r\n' : '\n'
49
+ }
50
+
51
+ // Split into content lines without the trailing empty string produced by a
52
+ // final newline. Line endings are normalised to \n while editing.
53
+ function contentLines(text) {
54
+ const lines = String(text).replaceAll('\r\n', '\n').split('\n')
55
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
56
+ return lines
57
+ }
58
+
59
+ function serializeLines(lines, eol) {
60
+ return lines.length === 0 ? '' : `${lines.join(eol)}${eol}`
61
+ }
62
+
63
+ function isTopLevelEntry(line) {
64
+ return /^-[ \t]/.test(line) || line === '-'
65
+ }
66
+
67
+ /**
68
+ * Locate the managed block in a list of lines.
69
+ * The marker names the block; the block is the first loader entry after it
70
+ * plus every indented continuation line. A blank line or an unindented line
71
+ * ends the block, so unrelated comments and entries after it stay untouched.
72
+ * @returns {{ start: number, end: number } | null} half-open line range
73
+ */
74
+ export function findBlock(lines) {
75
+ const start = lines.findIndex((line) => line.trim() === MARKER)
76
+ if (start === -1) return null
77
+ let index = start + 1
78
+ while (index < lines.length && lines[index].trim() === '') index += 1
79
+ if (index >= lines.length) return { start, end: lines.length }
80
+ if (!isTopLevelEntry(lines[index])) return { start, end: index }
81
+ index += 1
82
+ while (index < lines.length) {
83
+ const line = lines[index]
84
+ if (line.trim() === '' || !/^[ \t]/.test(line)) break
85
+ index += 1
86
+ }
87
+ return { start, end: index }
88
+ }
89
+
90
+ function findAllBlocks(lines) {
91
+ const spans = []
92
+ let cursor = 0
93
+ while (cursor < lines.length) {
94
+ const span = findBlock(lines.slice(cursor))
95
+ if (span === null) break
96
+ spans.push({ start: span.start + cursor, end: span.end + cursor })
97
+ cursor = span.end + cursor
98
+ }
99
+ return spans
100
+ }
101
+
102
+ /**
103
+ * Insert the managed block, or replace the existing one in place.
104
+ * All lines outside the block are preserved.
105
+ */
106
+ export function upsertBlock(text, root) {
107
+ const eol = eolOf(text)
108
+ const lines = contentLines(text)
109
+ const block = renderBlock(root).split('\n')
110
+ const spans = findAllBlocks(lines)
111
+
112
+ if (spans.length === 0) {
113
+ if (lines.length > 0 && lines[lines.length - 1].trim() !== '') lines.push('')
114
+ lines.push(...block)
115
+ return serializeLines(lines, eol)
116
+ }
117
+
118
+ // Keep the first block, drop any accidental duplicates, then replace.
119
+ for (let index = spans.length - 1; index >= 1; index -= 1) {
120
+ const span = spans[index]
121
+ lines.splice(span.start, span.end - span.start)
122
+ }
123
+ const first = spans[0]
124
+ lines.splice(first.start, first.end - first.start, ...block)
125
+
126
+ // Keep one blank line between the block and the next top-level entry so the
127
+ // generated file stays canonical and idempotent.
128
+ const next = first.start + block.length
129
+ if (lines[next] !== undefined && lines[next].trim() !== '') lines.splice(next, 0, '')
130
+ return serializeLines(lines, eol)
131
+ }
132
+
133
+ /** Remove the managed block; other lines are preserved. No block means no change. */
134
+ export function removeBlock(text) {
135
+ const eol = eolOf(text)
136
+ const lines = contentLines(text)
137
+ const spans = findAllBlocks(lines)
138
+ if (spans.length === 0) return text
139
+ for (let index = spans.length - 1; index >= 0; index -= 1) {
140
+ const span = spans[index]
141
+ lines.splice(span.start, span.end - span.start)
142
+ // Collapse only the blank run left at this seam; never touch blank lines
143
+ // elsewhere in the user's file.
144
+ const seam = span.start
145
+ if (seam > 0 && seam < lines.length && lines[seam - 1].trim() === '' && lines[seam].trim() === '') {
146
+ lines.splice(seam, 1)
147
+ }
148
+ }
149
+ // The append path adds one separating blank line before the block; drop a
150
+ // trailing blank run so removing the block restores the previous EOF.
151
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
152
+ return serializeLines(lines, eol)
153
+ }
154
+
155
+ function expandHome(value) {
156
+ const raw = String(value ?? '').trim()
157
+ if (raw === '~') return os.homedir()
158
+ if (raw.startsWith('~/') || raw.startsWith('~\\')) return path.join(os.homedir(), raw.slice(2))
159
+ return raw
160
+ }
161
+
162
+ /** Resolve the DSH home directory exactly as `_env.cjs` does. */
163
+ export function resolveHome(env = process.env) {
164
+ const configured = expandHome(env.DSH_HOME)
165
+ return path.resolve(configured.length > 0 ? configured : path.join(os.homedir(), '.dsh'))
166
+ }
167
+
168
+ /** Resolve, canonicalise and forward-slash the watched repository root. */
169
+ export function resolveRoot(value, cwd = process.cwd()) {
170
+ const raw = value === undefined || value === null ? cwd : String(value)
171
+ if (/[\r\n]/.test(raw)) throw new Error(`--root must not contain line breaks: ${raw}`)
172
+ const absolute = path.resolve(cwd, raw)
173
+ let real
174
+ try {
175
+ real = fs.realpathSync(absolute)
176
+ } catch (error) {
177
+ throw new Error(`--root must be an existing directory: ${absolute} (${error.message})`)
178
+ }
179
+ if (!fs.statSync(real).isDirectory()) throw new Error(`--root is not a directory: ${real}`)
180
+ return forwardSlashes(real)
181
+ }
182
+
183
+ export function parseArgs(argv) {
184
+ const options = { profile: 'desktop', root: undefined, dryRun: false, remove: false, help: false }
185
+ for (let index = 0; index < argv.length; index += 1) {
186
+ const arg = String(argv[index])
187
+ if (arg === '--dry-run') {
188
+ options.dryRun = true
189
+ } else if (arg === '--remove') {
190
+ options.remove = true
191
+ } else if (arg === '--help' || arg === '-h') {
192
+ options.help = true
193
+ } else if (arg === '--profile' || arg === '--root') {
194
+ const value = argv[index + 1]
195
+ if (value === undefined || String(value).startsWith('--')) throw new Error(`${arg} requires a value`)
196
+ index += 1
197
+ if (arg === '--profile') options.profile = String(value)
198
+ else options.root = String(value)
199
+ } else if (arg.startsWith('--profile=')) {
200
+ options.profile = arg.slice('--profile='.length)
201
+ } else if (arg.startsWith('--root=')) {
202
+ options.root = arg.slice('--root='.length)
203
+ } else {
204
+ throw new Error(`unknown argument: ${arg}`)
205
+ }
206
+ }
207
+ return options
208
+ }
209
+
210
+ function printUsage() {
211
+ console.log(`Usage: node scripts/enable-dev-hmr.mjs [options]
212
+
213
+ Options:
214
+ --profile <name> DSH profile to edit (default: desktop)
215
+ --root <dir> repository directory DSH HMR should watch
216
+ (default: the current working directory, realpath'ed)
217
+ --dry-run print the patch diff without writing
218
+ --remove remove the dsh-tower dev-hmr block
219
+ --help show this help
220
+
221
+ Target file: <DSH_HOME>/profiles/<profile>/cordis.patch.yml
222
+ DSH_HOME defaults to ~/.dsh. The script never creates a profile and writes
223
+ nothing outside the user patch layer.`)
224
+ }
225
+
226
+ function printNextSteps(root) {
227
+ console.log('Restart DSH once so the profile patch (and the HMR watch roots) takes effect.')
228
+ console.log('After the restart:')
229
+ console.log(` - HMR watches ${root} and reloads Tower code after edits while DSH stays open.`)
230
+ console.log(' - Run /tower reload (or call the tower_reload tool) to reconcile on demand.')
231
+ console.log(' - Run this script with --remove to disable the watcher again (restart once after).')
232
+ console.log('Note: the HMR root list accepts absolute paths; the value above is absolute and uses forward slashes.')
233
+ }
234
+
235
+ function printDiff(writeLine, file, before, after) {
236
+ const beforeLines = String(before).replaceAll('\r\n', '\n').split('\n')
237
+ const afterLines = String(after).replaceAll('\r\n', '\n').split('\n')
238
+ let prefix = 0
239
+ while (
240
+ prefix < beforeLines.length &&
241
+ prefix < afterLines.length &&
242
+ beforeLines[prefix] === afterLines[prefix]
243
+ ) {
244
+ prefix += 1
245
+ }
246
+ let suffix = 0
247
+ while (
248
+ suffix < beforeLines.length - prefix &&
249
+ suffix < afterLines.length - prefix &&
250
+ beforeLines[beforeLines.length - 1 - suffix] === afterLines[afterLines.length - 1 - suffix]
251
+ ) {
252
+ suffix += 1
253
+ }
254
+ writeLine(`--- ${file}`)
255
+ writeLine(`+++ ${file} (dsh-tower dev-hmr)`)
256
+ writeLine(`@@ line ${prefix + 1} @@`)
257
+ for (let index = prefix; index < beforeLines.length - suffix; index += 1) {
258
+ writeLine(`-${beforeLines[index]}`)
259
+ }
260
+ for (let index = prefix; index < afterLines.length - suffix; index += 1) {
261
+ writeLine(`+${afterLines[index]}`)
262
+ }
263
+ }
264
+
265
+ function profilePatchPath(home, profile) {
266
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile) || profile === '.' || profile === '..') {
267
+ throw new Error(`invalid profile name: ${profile}`)
268
+ }
269
+ const profileDir = path.join(home, 'profiles', profile)
270
+ if (!fs.existsSync(profileDir) || !fs.statSync(profileDir).isDirectory()) {
271
+ throw new Error(
272
+ `DSH profile "${profile}" does not exist at ${profileDir}. ` +
273
+ `Create it first (for example: dsh --profile ${profile} --from-default-profile web --dump-config); ` +
274
+ 'this script never creates a profile.',
275
+ )
276
+ }
277
+ return path.join(profileDir, PATCH_FILENAME)
278
+ }
279
+
280
+ /**
281
+ * Run the CLI. Returns the process exit code; throws nothing.
282
+ * `env` and `cwd` are injectable so tests can isolate $DSH_HOME.
283
+ */
284
+ export function main(argv = process.argv.slice(2), env = process.env, cwd = process.cwd()) {
285
+ try {
286
+ const options = parseArgs(argv)
287
+ if (options.help) {
288
+ printUsage()
289
+ return 0
290
+ }
291
+ const home = resolveHome(env)
292
+ const file = profilePatchPath(home, options.profile)
293
+ const original = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
294
+
295
+ if (options.remove) {
296
+ const next = removeBlock(original)
297
+ if (next === original) {
298
+ console.log(`No dsh-tower dev-hmr block in ${file}; nothing to remove.`)
299
+ return 0
300
+ }
301
+ if (options.dryRun) {
302
+ printDiff(console.log, file, original, next)
303
+ console.log('Dry run: no file written.')
304
+ return 0
305
+ }
306
+ fs.writeFileSync(file, next, 'utf8')
307
+ console.log(`Removed the dsh-tower dev-hmr block from ${file}.`)
308
+ console.log('Restart DSH once so the profile patch is reloaded without the HMR root.')
309
+ return 0
310
+ }
311
+
312
+ const root = resolveRoot(options.root, cwd)
313
+ const next = upsertBlock(original, root)
314
+ if (next === original) {
315
+ console.log(`The dsh-tower dev-hmr block in ${file} is already up to date for ${root}.`)
316
+ printNextSteps(root)
317
+ return 0
318
+ }
319
+ if (options.dryRun) {
320
+ printDiff(console.log, file, original, next)
321
+ console.log('Dry run: no file written.')
322
+ return 0
323
+ }
324
+ fs.writeFileSync(file, next, 'utf8')
325
+ console.log(`Wrote the dsh-tower dev-hmr block to ${file}`)
326
+ printNextSteps(root)
327
+ return 0
328
+ } catch (error) {
329
+ console.error(`enable-dev-hmr: ${error?.message ?? error}`)
330
+ return 1
331
+ }
332
+ }
333
+
334
+ // Only run when invoked as a script; importing this file from a test is inert.
335
+ const invokedDirectly = (() => {
336
+ try {
337
+ const entry = process.argv[1]
338
+ if (entry === undefined) return false
339
+ return pathToFileURL(path.resolve(entry)).href === import.meta.url
340
+ } catch {
341
+ return false
342
+ }
343
+ })()
344
+
345
+ if (invokedDirectly) {
346
+ process.exitCode = main()
347
+ }
@@ -4,11 +4,13 @@ You are the independent reviewer of a completed Tower mission. You are read-only
4
4
 
5
5
  1. Read the mission briefing and the review binding: the reviewed tip must equal the current branch tip.
6
6
  2. Inspect the diff with `tower_diff` (files, commits, patch). Do not run write, edit, pwsh, commit, rebase, or merge.
7
- 3. Verify: every task is met; all changes are inside the mission scope; no unrelated or generated files; no secrets; tests or evidence are adequate.
7
+ 3. Verify: every task is met; all changes are inside the mission scope; no unrelated or generated files; no secrets; tests or evidence are adequate. Cross-check the worker's `tower_complete.checks` evidence against the diff; do not accept claimed checks you cannot corroborate.
8
8
  4. Check the reviewed commit is the tip you inspected. If it moved, stop and report a blocker instead of approving.
9
9
  5. Submit exactly one `tower_review`:
10
10
  - `clean` + `merge` + `findings: "none"` when everything passes.
11
11
  - `p1-Nitems` or `p2-Nitems` + `hold` or `fix-then-merge` with concrete findings when it does not.
12
- 6. Put out-of-scope observations in `tower_finding`, never in the branch.
12
+ 6. Put out-of-scope observations in `tower_finding`, never in the branch. `tower_findings` lists and
13
+ reads findings; only the tower or a worker may change a finding's status, so record evidence instead
14
+ of closing it yourself.
13
15
 
14
16
  The verdict is bound to the inspected tip; do not modify the branch.
@@ -5,8 +5,10 @@ You are a Tower worker: one mission, one worktree, one commit series.
5
5
  1. Read the briefing: mission id, branch, task list, scope globs, dependencies, and context.
6
6
  2. Work only inside the assigned worktree. Never modify the main checkout or other missions.
7
7
  3. Keep changes inside the scope globs; report anything else as a `tower_finding` instead of editing it.
8
+ Review your own findings later with `tower_findings` and close the resolved ones with `status: "fixed"`.
8
9
  4. As you finish each task call `tower_mission` with `task_done` (and `note` when useful).
9
- 5. Run the verification available to you (tests, type check, build). Record it in your final message.
10
+ 5. Run the verification available to you (tests, type check, build) and record it as `tower_complete.checks`
11
+ entries (`{name, command, exitCode, evidence}`); `gates.requireChecks` can make at least one mandatory.
10
12
  6. Commit on the mission branch with a clear message. Do not push, spawn agents, review, or merge.
11
13
  7. If blocked, call `tower_mission` with `blocker`, then stop and report exactly what is blocking.
12
14
 
package/worker.js CHANGED
@@ -16,14 +16,16 @@ const ALLOW = [
16
16
  'tower_inbox',
17
17
  'tower_complete',
18
18
  'tower_finding',
19
+ 'tower_findings',
19
20
  'skill',
20
21
  ]
21
22
 
22
23
  const PROTOCOL = [
23
24
  'You are a Tower worker in a DeepSeek Harness Tower workspace.',
24
25
  'Work only inside the git worktree named in your briefing; never modify the main checkout.',
25
- 'Commit your work on the mission branch. Update the mission as you go with tower_mission',
26
- '(task_done, blocker, status), and put the full handoff in your final message: what changed,',
26
+ 'Commit your work on the mission branch. Update progress with tower_mission (task_done/task_drop,',
27
+ 'blocker, note); lifecycle status and scope are owned by the tower. Finish with tower_complete',
28
+ '(report + checks evidence), then put the full handoff in your final message: what changed,',
27
29
  'every file path, how you verified it, and anything left undone.',
28
30
  'Do not spawn other agents, do not review, and do not merge branches; the tower owns integration.',
29
31
  ].join(' ')