dsh-tower 1.2.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 (61) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.en.md +60 -23
  4. package/README.md +54 -21
  5. package/SECURITY.md +20 -0
  6. package/cordis.patch.yml +32 -0
  7. package/docs/README.md +17 -0
  8. package/docs/architecture/overview.md +70 -0
  9. package/docs/examples/first-mission.md +58 -0
  10. package/docs/faq.md +74 -0
  11. package/docs/guide/hot-reload.md +189 -0
  12. package/docs/guide/install-upgrade-migration.md +59 -0
  13. package/docs/guide/quickstart.md +97 -0
  14. package/docs/operations/runbook.md +80 -0
  15. package/docs/operations/security.md +62 -0
  16. package/docs/reference/blockers.md +58 -0
  17. package/docs/reference/config.md +114 -0
  18. package/docs/reference/doctor.md +31 -0
  19. package/docs/reference/errors.md +50 -0
  20. package/docs/reference/events.md +45 -0
  21. package/docs/reference/operations.md +52 -0
  22. package/docs/reference/testing.md +61 -0
  23. package/docs/reference/tools.md +75 -0
  24. package/docs/zh/README.md +58 -0
  25. package/docs/zh/faq.md +72 -0
  26. package/docs/zh/guide/hot-reload.md +178 -0
  27. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  28. package/docs/zh/guide/quickstart.md +96 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +30 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +48 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +293 -49
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +381 -0
  41. package/lib/doctor.js +165 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +47 -16
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1594 -160
  50. package/lib/tower.js +1025 -101
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +21 -4
  54. package/reviewer.js +40 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-recovery.md +40 -0
  58. package/skills/tower-review.md +16 -0
  59. package/skills/tower-worker-protocol.md +15 -0
  60. package/skills.js +63 -0
  61. package/worker.js +7 -4
@@ -0,0 +1,117 @@
1
+ // Advisory repository workspace lock: one Tower session at a time when
2
+ // `mode.workspaceLock` is enabled. Same-user trusted model, like the rest of
3
+ // the Tower store: it prevents accidental double control, not a malicious
4
+ // writer.
5
+ import fsp from 'node:fs/promises'
6
+ import path from 'node:path'
7
+ import crypto from 'node:crypto'
8
+
9
+ export function workspaceLockPath(commonDir) {
10
+ return path.join(commonDir, 'dsh-tower', 'tower.lock')
11
+ }
12
+
13
+ export function workspaceLockToken() {
14
+ return crypto.randomUUID()
15
+ }
16
+
17
+ async function readRecord(commonDir) {
18
+ const file = workspaceLockPath(commonDir)
19
+ try {
20
+ const text = await fsp.readFile(file, 'utf8')
21
+ const parsed = JSON.parse(text)
22
+ if (!parsed || typeof parsed !== 'object') return { file, record: null, corrupt: true }
23
+ return { file, record: parsed, corrupt: false }
24
+ } catch (error) {
25
+ if (error?.code === 'ENOENT') return { file, record: null, corrupt: false }
26
+ return { file, record: null, corrupt: true }
27
+ }
28
+ }
29
+
30
+ async function removeLock(file) {
31
+ for (let attempt = 0; attempt < 5; attempt += 1) {
32
+ try {
33
+ await fsp.unlink(file)
34
+ return
35
+ } catch (error) {
36
+ if (error?.code === 'ENOENT') return
37
+ if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code) || attempt === 4) throw error
38
+ await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)))
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Try to become the repository's Tower session.
45
+ * @returns {{acquired:boolean, record?:object, holder?:object|null, tookOver?:boolean, corrupt?:boolean}}
46
+ */
47
+ export async function acquireWorkspaceLock(commonDir, options) {
48
+ const { token, sessionId, agentId, staleMs = 300000, now = Date.now() } = options
49
+ await fsp.mkdir(path.dirname(workspaceLockPath(commonDir)), { recursive: true })
50
+ for (let attempt = 0; attempt < 3; attempt += 1) {
51
+ const record = { token, sessionId, agentId, pid: process.pid, at: now }
52
+ try {
53
+ await fsp.writeFile(workspaceLockPath(commonDir), JSON.stringify(record), { flag: 'wx' })
54
+ return { acquired: true, record, tookOver: attempt > 0 }
55
+ } catch (error) {
56
+ if (error?.code !== 'EEXIST') throw error
57
+ const { record: current, corrupt, file } = await readRecord(commonDir)
58
+ if (corrupt || !current) {
59
+ // A corrupt lock is treated like an unknown holder; only a stale one
60
+ // is removed, so a concurrent writer is never silently overwritten.
61
+ let stale = false
62
+ try {
63
+ const stat = await fsp.stat(file)
64
+ stale = now - stat.mtimeMs > staleMs
65
+ } catch {
66
+ stale = true
67
+ }
68
+ if (stale) {
69
+ await removeLock(file)
70
+ continue
71
+ }
72
+ return { acquired: false, holder: null, corrupt: true }
73
+ }
74
+ if (current.sessionId === sessionId && current.agentId === agentId) {
75
+ await fsp.writeFile(workspaceLockPath(commonDir), JSON.stringify({ ...record, token: current.token }))
76
+ return { acquired: true, record: { ...record, token: current.token }, tookOver: false }
77
+ }
78
+ if (now - Number(current.at ?? 0) > staleMs) {
79
+ await removeLock(file)
80
+ continue
81
+ }
82
+ return { acquired: false, holder: current }
83
+ }
84
+ }
85
+ return { acquired: false, holder: null, corrupt: true }
86
+ }
87
+
88
+ /** Refresh the timestamp only when the lock still belongs to this session. */
89
+ export async function touchWorkspaceLock(commonDir, options) {
90
+ const { token, now = Date.now() } = options
91
+ const { record, file } = await readRecord(commonDir)
92
+ if (!record || record.token !== token) return false
93
+ await fsp.writeFile(file, JSON.stringify({ ...record, at: now }))
94
+ return true
95
+ }
96
+
97
+ /** Release the lock only when this session owns it. */
98
+ export async function releaseWorkspaceLock(commonDir, options) {
99
+ const { token } = options
100
+ const { record, file } = await readRecord(commonDir)
101
+ if (!record || (token && record.token !== token)) return false
102
+ await removeLock(file)
103
+ return true
104
+ }
105
+
106
+ export async function inspectWorkspaceLock(commonDir, options = {}) {
107
+ const { sessionId, staleMs = 300000, now = Date.now() } = options
108
+ const { record, corrupt } = await readRecord(commonDir)
109
+ if (!record) return { held: false, corrupt, self: false, holder: null, stale: false }
110
+ return {
111
+ held: true,
112
+ corrupt,
113
+ self: sessionId !== undefined && record.sessionId === sessionId,
114
+ holder: record,
115
+ stale: now - Number(record.at ?? 0) > staleMs,
116
+ }
117
+ }
@@ -0,0 +1,166 @@
1
+ // Worktree ownership verification.
2
+ //
3
+ // A stored worktree path is an identity claim, not a sandbox: before Tower
4
+ // trusts it (preflight/merge/review/diff), the path must still resolve to the
5
+ // same real directory, must not have been replaced by a symlink or junction,
6
+ // and must still be the Git worktree of the recorded branch in the recorded
7
+ // repository. Every mismatch is rejected with an actionable message; nothing
8
+ // is repaired or recreated here.
9
+ import fsp from 'node:fs/promises'
10
+ import path from 'node:path'
11
+
12
+ import { git } from './git.js'
13
+
14
+ function fail(code, message) {
15
+ const error = new Error(message)
16
+ error.code = code
17
+ throw error
18
+ }
19
+
20
+ function isNonEmptyString(value) {
21
+ return typeof value === 'string' && value.trim().length > 0
22
+ }
23
+
24
+ function compareKey(target) {
25
+ const resolved = path.resolve(target)
26
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
27
+ }
28
+
29
+ function samePath(a, b) {
30
+ return compareKey(a) === compareKey(b)
31
+ }
32
+
33
+ async function realpathOrResolve(target) {
34
+ try {
35
+ return await fsp.realpath(target)
36
+ } catch (error) {
37
+ if (error?.code === 'ENOENT') return path.resolve(target)
38
+ throw error
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolve a path to its canonical realpath. Requires an absolute path and an
44
+ * existing target; a dangling target is reported as a missing path.
45
+ */
46
+ export async function canonicalPath(target) {
47
+ if (!isNonEmptyString(target)) fail('invalid-argument', 'path is required')
48
+ if (!path.isAbsolute(target)) fail('invalid-argument', `path must be an absolute path: ${target}`)
49
+ try {
50
+ return await fsp.realpath(target)
51
+ } catch (error) {
52
+ if (error?.code === 'ENOENT') fail('invalid-transition', `path does not exist: ${path.resolve(target)}`)
53
+ throw error
54
+ }
55
+ }
56
+
57
+ async function gitTop(resolved, label) {
58
+ try {
59
+ return await git(resolved, ['rev-parse', '--show-toplevel'])
60
+ } catch (error) {
61
+ fail('invalid-transition', `${label} ${resolved} is not a Git worktree: ${error.message}`)
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Verify that a stored worktree still belongs to this repository, branch, and
67
+ * mission.
68
+ *
69
+ * @param {object} input
70
+ * @param {string} input.storedPath absolute worktree path recorded in state
71
+ * @param {string} [input.storedRealPath] realpath recorded when the worktree was created
72
+ * @param {string} input.branch expected branch
73
+ * @param {string} input.commonDir expected Git common dir (shared by worktrees)
74
+ * @param {string} [input.missionId] mission id, used in error messages
75
+ * @returns {Promise<{path:string, realPath:string}>}
76
+ */
77
+ export async function assertOwnership(input = {}) {
78
+ const storedPath = input?.storedPath
79
+ const storedRealPath = input?.storedRealPath
80
+ const branch = input?.branch
81
+ const commonDir = input?.commonDir
82
+ const missionId = input?.missionId
83
+
84
+ if (!isNonEmptyString(storedPath)) fail('invalid-argument', 'storedPath is required')
85
+ if (!path.isAbsolute(storedPath)) fail('invalid-argument', `storedPath must be an absolute path: ${storedPath}`)
86
+ if (!isNonEmptyString(branch)) fail('invalid-argument', 'branch is required')
87
+ if (!isNonEmptyString(commonDir)) fail('invalid-argument', 'commonDir is required')
88
+ if (!path.isAbsolute(commonDir)) fail('invalid-argument', `commonDir must be an absolute path: ${commonDir}`)
89
+ if (missionId !== undefined && !isNonEmptyString(missionId)) {
90
+ fail('invalid-argument', `missionId must be a non-empty string: ${JSON.stringify(missionId)}`)
91
+ }
92
+ if (storedRealPath !== undefined && storedRealPath !== null && storedRealPath !== '') {
93
+ if (!isNonEmptyString(storedRealPath) || !path.isAbsolute(storedRealPath)) {
94
+ fail('invalid-argument', `storedRealPath must be an absolute path: ${storedRealPath}`)
95
+ }
96
+ }
97
+
98
+ const label = missionId ? `mission ${missionId} worktree` : 'worktree'
99
+ const resolved = path.resolve(storedPath)
100
+
101
+ let linkStat
102
+ try {
103
+ linkStat = await fsp.lstat(resolved)
104
+ } catch (error) {
105
+ if (error?.code === 'ENOENT') fail('invalid-transition', `${label} is missing: ${resolved}`)
106
+ throw error
107
+ }
108
+ if (linkStat.isSymbolicLink()) {
109
+ fail(
110
+ 'invalid-transition',
111
+ `${label} ${resolved} is a symlink or junction; refusing to treat a redirected path as the owned worktree`,
112
+ )
113
+ }
114
+
115
+ const realPath = await canonicalPath(resolved)
116
+ const stat = await fsp.stat(realPath)
117
+ if (!stat.isDirectory()) fail('invalid-transition', `${label} is not a directory: ${resolved}`)
118
+
119
+ if (storedRealPath !== undefined && storedRealPath !== null && storedRealPath !== '' && !samePath(storedRealPath, realPath)) {
120
+ fail(
121
+ 'invalid-transition',
122
+ `${label} real path changed: expected ${path.resolve(storedRealPath)}, found ${realPath}; refusing to use a replaced worktree`,
123
+ )
124
+ }
125
+
126
+ const topRaw = await gitTop(resolved, label)
127
+ const top = await canonicalPath(topRaw)
128
+ if (!samePath(top, realPath)) {
129
+ fail(
130
+ 'invalid-transition',
131
+ `${label} path ${resolved} is not the Git worktree root (top-level is ${top}); refusing to trust a nested or redirected path`,
132
+ )
133
+ }
134
+
135
+ let head
136
+ try {
137
+ head = await git(resolved, ['rev-parse', '--abbrev-ref', 'HEAD'])
138
+ } catch (error) {
139
+ fail('invalid-transition', `${label} ${resolved} has no readable HEAD: ${error.message}`)
140
+ }
141
+ const actualBranch = head === 'HEAD' ? null : head.trim()
142
+ if (actualBranch !== branch) {
143
+ fail(
144
+ 'invalid-transition',
145
+ `${label} ${resolved} is on ${actualBranch ?? 'detached HEAD'}, expected ${branch}`,
146
+ )
147
+ }
148
+
149
+ let commonRaw
150
+ try {
151
+ commonRaw = await git(resolved, ['rev-parse', '--git-common-dir'])
152
+ } catch (error) {
153
+ fail('invalid-transition', `${label} ${resolved} has no readable Git common dir: ${error.message}`)
154
+ }
155
+ const actualCommon = path.isAbsolute(commonRaw) ? path.resolve(commonRaw) : path.resolve(resolved, commonRaw)
156
+ const expectedCommon = await realpathOrResolve(path.resolve(commonDir))
157
+ const actualCommonReal = await realpathOrResolve(actualCommon)
158
+ if (!samePath(actualCommonReal, expectedCommon)) {
159
+ fail(
160
+ 'invalid-transition',
161
+ `${label} ${resolved} belongs to a different repository (common dir ${actualCommonReal}, expected ${expectedCommon})`,
162
+ )
163
+ }
164
+
165
+ return { path: resolved, realPath }
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -28,20 +28,30 @@
28
28
  "exports": {
29
29
  ".": "./index.js",
30
30
  "./worker": "./worker.js",
31
+ "./reviewer": "./reviewer.js",
32
+ "./skills": "./skills.js",
31
33
  "./locale/*.json": "./locale/*.json",
32
- "./icon": "./icon.svg"
34
+ "./icon": "./icon.svg",
35
+ "./package.json": "./package.json"
33
36
  },
34
37
  "files": [
35
38
  "index.js",
36
39
  "worker.js",
40
+ "reviewer.js",
41
+ "skills.js",
42
+ "skills/",
37
43
  "lib/*.js",
38
44
  "cordis.patch.yml",
45
+ "scripts/",
39
46
  "locale/*.json",
40
47
  "icon.svg",
41
48
  "README.md",
42
49
  "README.en.md",
43
50
  "CHANGELOG.md",
44
- "LICENSE"
51
+ "LICENSE",
52
+ "CONTRIBUTING.md",
53
+ "SECURITY.md",
54
+ "docs/"
45
55
  ],
46
56
  "dsh": {
47
57
  "bundle": {
@@ -50,6 +60,13 @@
50
60
  },
51
61
  "scripts": {
52
62
  "test": "node --test tests/*.test.mjs",
53
- "test:watch": "node --test --watch tests/*.test.mjs"
63
+ "test:watch": "node --test --watch tests/*.test.mjs",
64
+ "test:coverage": "node --test --experimental-test-coverage tests/*.test.mjs",
65
+ "dev:hmr": "node scripts/enable-dev-hmr.mjs",
66
+ "docs:check": "node scripts/check-docs.mjs",
67
+ "check": "npm test && npm run docs:check"
68
+ },
69
+ "dependencies": {
70
+ "@deepseek-ai/schemastery": "~3.18.5"
54
71
  }
55
72
  }
package/reviewer.js ADDED
@@ -0,0 +1,40 @@
1
+ // tower-reviewer preset policy: read-only review surface. The reviewer must
2
+ // never modify the mission branch: tower_review binds the verdict to the tip
3
+ // recorded when the reviewer was spawned, so a modified tip is rejected.
4
+ export const name = 'dsh-tower/reviewer'
5
+ export const inject = ['tools', 'systemPrompt']
6
+
7
+ const ALLOW = [
8
+ 'read',
9
+ 'glob',
10
+ 'grep',
11
+ 'tower_diff',
12
+ 'tower_status',
13
+ 'tower_send',
14
+ 'tower_inbox',
15
+ 'tower_finding',
16
+ 'tower_findings',
17
+ 'tower_review',
18
+ 'skill',
19
+ ]
20
+
21
+ const PROTOCOL = [
22
+ 'You are an independent Tower reviewer in a DeepSeek Harness Tower workspace.',
23
+ 'You are read-only: do not write, edit, commit, rebase, or merge, and never modify the mission branch.',
24
+ 'Inspect the mission diff with tower_diff, check scope, tasks, and the reviewed tip, then submit exactly one',
25
+ 'tower_review verdict (clean + merge, or p1-/p2-items + hold/fix-then-merge) with findings and decision.',
26
+ 'You must not be the mission worker. Use tower_finding for out-of-scope issues instead of editing files.',
27
+ ].join(' ')
28
+
29
+ export function apply(ctx) {
30
+ try {
31
+ ctx.tools.restrict({ allow: ALLOW })
32
+ } catch {
33
+ // a composition without the tool registry still gets the protocol text
34
+ }
35
+ try {
36
+ ctx.systemPrompt.section({ name: 'tower:reviewer-protocol', order: 12000, text: PROTOCOL })
37
+ } catch {
38
+ // prompt assembly remains valid without the section
39
+ }
40
+ }
@@ -0,0 +1,135 @@
1
+ // Documentation consistency check: tool names, blocker codes, error codes and
2
+ // release numbers must not drift from the implementation. The Chinese
3
+ // reference pages must cover the same names. Run: node scripts/check-docs.mjs
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
+ const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
10
+ const exists = (file) => fs.existsSync(path.join(root, file))
11
+
12
+ const toolsSource = read('lib/tools.js') + read('index.js')
13
+ const documentedTools = read('docs/reference/tools.md')
14
+ const toolNames = [...new Set([...toolsSource.matchAll(/name: '(tower_[a-z_]+)'/g)].map((match) => match[1]))]
15
+ .filter((name) => name !== 'tower-control')
16
+ .sort()
17
+ const missingTools = toolNames.filter((name) => !documentedTools.includes(`\`${name}\``))
18
+
19
+ const towerSource = read('lib/tower.js')
20
+ const blockersSource = read('docs/reference/blockers.md')
21
+ const blockerCodes = new Set()
22
+ for (const match of towerSource.matchAll(/push\(\s*'([a-z][a-z-]+)'/g)) blockerCodes.add(match[1])
23
+ for (const code of [
24
+ 'mission-missing',
25
+ 'mission-not-completed',
26
+ 'tasks-open',
27
+ 'blockers-present',
28
+ 'branch-missing',
29
+ 'stale-base',
30
+ 'read-only-survey',
31
+ 'out-of-scope',
32
+ 'base-dirty',
33
+ 'deps-unmerged',
34
+ 'no-review',
35
+ 'review-mission-mismatch',
36
+ 'not-clean',
37
+ 'review-hold',
38
+ 'review-not-approved',
39
+ 'tip-moved',
40
+ 'worker-busy',
41
+ 'base-wrong-branch',
42
+ 'branch-owned-by-closed-missions',
43
+ 'merge-conflict',
44
+ 'merge-hook-failed',
45
+ 'merge-shape',
46
+ 'rebase-conflict',
47
+ 'worktree-dirty',
48
+ 'no-check-evidence',
49
+ 'worker-stale',
50
+ 'worker-status-unknown',
51
+ 'tests-missing',
52
+ 'tests-failed',
53
+ 'review-exemption-invalid',
54
+ 'merge-intent-pending',
55
+ 'batch-invalid',
56
+ ]) blockerCodes.add(code)
57
+ const missingBlockers = [...blockerCodes].filter((code) => !blockersSource.includes(`\`${code}\``)).sort()
58
+
59
+ const eventNames = [
60
+ 'init',
61
+ 'plan',
62
+ 'mission',
63
+ 'spawn',
64
+ 'complete',
65
+ 'review',
66
+ 'merge',
67
+ 'rebase',
68
+ 'teardown',
69
+ 'finding',
70
+ 'findings',
71
+ 'send',
72
+ 'inbox',
73
+ 'interrupt',
74
+ 'diff',
75
+ 'adopt',
76
+ 'roster',
77
+ 'status',
78
+ 'preflight',
79
+ 'doctor',
80
+ 'test',
81
+ 'batch',
82
+ 'resource',
83
+ 'import_legacy',
84
+ 'legacy_map',
85
+ ]
86
+ const documentedEvents = read('docs/reference/events.md')
87
+ const missingEvents = eventNames.filter((name) => !documentedEvents.includes(`\`tower/${name}\``))
88
+
89
+ const errorsSource = read('lib/errors.js')
90
+ const documentedErrors = read('docs/reference/errors.md')
91
+ const errorCodes = [...new Set([...errorsSource.matchAll(/^ {2}'([a-z][a-z-]+)':/gm)].map((match) => match[1]))].sort()
92
+ const missingErrors = errorCodes.filter((code) => !documentedErrors.includes(`\`${code}\``))
93
+
94
+ const packageJson = JSON.parse(read('package.json'))
95
+ const changelog = read('CHANGELOG.md')
96
+ const missingChangelog = !changelog.includes(`## ${packageJson.version}`)
97
+
98
+ const problems = []
99
+ if (missingTools.length > 0) problems.push(`docs/reference/tools.md misses tools: ${missingTools.join(', ')}`)
100
+ if (missingBlockers.length > 0) problems.push(`docs/reference/blockers.md misses codes: ${missingBlockers.join(', ')}`)
101
+ if (missingErrors.length > 0) problems.push(`docs/reference/errors.md misses codes: ${missingErrors.join(', ')}`)
102
+ if (missingEvents.length > 0) problems.push(`docs/reference/events.md misses events: ${missingEvents.join(', ')}`)
103
+ if (missingChangelog) problems.push(`CHANGELOG.md misses version ${packageJson.version}`)
104
+
105
+ // Chinese parity: when the translation tree exists it must cover the same
106
+ // reference names, so a tool or code cannot be added only in English.
107
+ if (exists('docs/zh')) {
108
+ const zhPages = {
109
+ 'docs/zh/README.md': null,
110
+ 'docs/zh/reference/tools.md': (text) => toolNames.filter((name) => !text.includes(`\`${name}\``)),
111
+ 'docs/zh/reference/blockers.md': (text) => [...blockerCodes].filter((code) => !text.includes(`\`${code}\``)),
112
+ 'docs/zh/reference/errors.md': (text) => errorCodes.filter((code) => !text.includes(`\`${code}\``)),
113
+ 'docs/zh/reference/events.md': (text) => eventNames.filter((name) => !text.includes(`\`tower/${name}\``)),
114
+ 'docs/zh/reference/config.md': null,
115
+ 'docs/zh/guide/hot-reload.md': null,
116
+ }
117
+ for (const [page, check] of Object.entries(zhPages)) {
118
+ if (!exists(page)) {
119
+ problems.push(`${page} is missing (Chinese docs parity)`)
120
+ continue
121
+ }
122
+ if (!check) continue
123
+ const missing = check(read(page))
124
+ if (missing.length > 0) problems.push(`${page} misses: ${missing.join(', ')}`)
125
+ }
126
+ }
127
+
128
+ if (problems.length > 0) {
129
+ console.error('documentation check failed:')
130
+ for (const problem of problems) console.error(`- ${problem}`)
131
+ process.exit(1)
132
+ }
133
+ console.log(
134
+ `docs ok: ${toolNames.length} tools, ${blockerCodes.size} blocker codes, ${errorCodes.length} error codes, ${eventNames.length} events, version ${packageJson.version}`,
135
+ )