dsh-tower 2.0.3 → 2.1.0-beta.1

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 (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,108 @@
1
+ // Release compatibility provenance. COMPATIBILITY.md is shipped in the package;
2
+ // no network lookup is involved.
3
+ import { fs } from '../adapters/store-fs.js'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
8
+
9
+ export function parseVersion(value) {
10
+ const text = String(value ?? '').trim()
11
+ const match = text.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
12
+ if (!match) return null
13
+ return {
14
+ numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
15
+ prerelease: match[4] ? match[4].split('.') : [],
16
+ }
17
+ }
18
+
19
+ function comparePrerelease(a, b) {
20
+ if (a.length === 0 && b.length === 0) return 0
21
+ if (a.length === 0) return 1
22
+ if (b.length === 0) return -1
23
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
24
+ if (a[index] === undefined) return -1
25
+ if (b[index] === undefined) return 1
26
+ const aNumber = /^\d+$/.test(a[index])
27
+ const bNumber = /^\d+$/.test(b[index])
28
+ if (aNumber && bNumber) {
29
+ const delta = Number(a[index]) - Number(b[index])
30
+ if (delta !== 0) return delta
31
+ } else if (aNumber !== bNumber) {
32
+ return aNumber ? -1 : 1
33
+ } else if (a[index] !== b[index]) {
34
+ return a[index] < b[index] ? -1 : 1
35
+ }
36
+ }
37
+ return 0
38
+ }
39
+
40
+ export function compareVersions(a, b) {
41
+ const left = typeof a === 'string' ? parseVersion(a) : a
42
+ const right = typeof b === 'string' ? parseVersion(b) : b
43
+ if (!left || !right) return null
44
+ for (let index = 0; index < 3; index += 1) {
45
+ if (left.numbers[index] !== right.numbers[index]) return left.numbers[index] < right.numbers[index] ? -1 : 1
46
+ }
47
+ return comparePrerelease(left.prerelease, right.prerelease)
48
+ }
49
+
50
+ export function parseCompatibility(text) {
51
+ const rows = []
52
+ for (const line of String(text ?? '').split('\n')) {
53
+ if (!/^\|/.test(line)) continue
54
+ const cells = line.split('|').slice(1, -1).map((cell) => cell.trim())
55
+ if (cells.length < 4) continue
56
+ if (!parseVersion(cells[0])) continue
57
+ rows.push({ hostVersion: cells[0], commit: cells[1], verifiedOn: cells[2], ran: cells[3] })
58
+ }
59
+ return rows
60
+ }
61
+
62
+ export function readCompatibility(file = path.join(PACKAGE_ROOT, 'COMPATIBILITY.md')) {
63
+ try {
64
+ return parseCompatibility(fs.readFileSync(file, 'utf8'))
65
+ } catch {
66
+ return []
67
+ }
68
+ }
69
+
70
+ export function assessHostVersion(hostVersion, rows) {
71
+ const parsed = parseVersion(hostVersion)
72
+ if (!parsed) return { verdict: 'unknown', row: null, detail: 'cannot parse the running host version' }
73
+ const exact = rows.find((row) => compareVersions(row.hostVersion, parsed) === 0)
74
+ if (exact) return { verdict: 'verified', row: exact, detail: `${exact.hostVersion} verified on ${exact.verifiedOn}` }
75
+ const known = rows
76
+ .map((row) => ({ row, cmp: compareVersions(parsed, row.hostVersion) }))
77
+ .filter((entry) => entry.cmp !== null)
78
+ if (known.length === 0) return { verdict: 'unknown', row: null, detail: 'COMPATIBILITY.md has no version rows' }
79
+ const newest = known.reduce((best, entry) => (entry.cmp > best.cmp ? entry : best), known[0])
80
+ if (newest.cmp > 0) {
81
+ return {
82
+ verdict: 'untested-newer',
83
+ row: newest.row,
84
+ detail: `${hostVersion} is newer than every verified build (latest row ${newest.row.hostVersion})`,
85
+ }
86
+ }
87
+ return {
88
+ verdict: 'untested-older',
89
+ row: newest.row,
90
+ detail: `${hostVersion} is older than the verified builds (latest row ${newest.row.hostVersion})`,
91
+ }
92
+ }
93
+
94
+ export function detectHostVersion(ctx, env = process.env) {
95
+ for (const value of [env.DSH_VERSION, env.DSH_HOST_VERSION, env.npm_config_dsh_version]) {
96
+ if (typeof value === 'string' && parseVersion(value)) return value
97
+ }
98
+ for (const serviceName of ['app', 'runtime', 'host']) {
99
+ try {
100
+ const service = ctx?.get?.(serviceName)
101
+ const value = service && (service.version ?? service.build?.version)
102
+ if (typeof value === 'string' && parseVersion(value)) return value
103
+ } catch {
104
+ // defensive: a missing service is not a doctor failure
105
+ }
106
+ }
107
+ return null
108
+ }
package/lib/config.js CHANGED
@@ -1,317 +1,50 @@
1
- // Tower configuration: one declared schema, resolved defaults, and the
2
- // process-local effective snapshot consumed by the domain modules.
1
+ // Tower configuration: process-local profile snapshot and repository layer.
3
2
  //
4
- // Layers, lowest precedence first:
5
- // 1. code defaults (the schemastery `.default(...)` values)
6
- // 2. profile config (the Cordis patch passes it to `apply()`)
7
- // 3. repository config: <git-common-dir>/dsh-tower/config.json
8
- // 4. per-tool arguments (e.g. worktreeRoot, timeoutMs)
3
+ // The pure Schemastery schema, default resolution, and raw-config validation
4
+ // live in domain/config/schema.js (#56 P2a). This module keeps only the layers
5
+ // that need process or filesystem state:
9
6
  //
10
- // Defaults are exactly the 1.2.0 behavior; the shipped cordis patch keeps
11
- // `allowAgentControl: true` via the legacy top-level key.
12
- import fsp from 'node:fs/promises'
7
+ // profile -> the raw config `apply()` received, resolved with code defaults
8
+ // on every read (runtimeConfig)
9
+ // repo -> <git-common-dir>/dsh-tower/config.json merged over the profile
10
+ //
11
+ // `Config` and `resolveTowerConfig` are re-exported for compatibility; new
12
+ // code should import them from domain/config/schema.js.
13
+ import { fsp } from '../adapters/store-fs.js'
13
14
  import path from 'node:path'
14
- import Schema from '@deepseek-ai/schemastery'
15
- import { TowerToolError } from './errors.js'
16
-
17
- export const DEFAULT_DENY_TOOLS = [
18
- 'create_goal',
19
- 'todo_write',
20
- 'team_task_create',
21
- 'team_task_update',
22
- 'team_task_reopen',
23
- 'team_task_list',
24
- 'spawn_teammate',
25
- 'wait_agent',
26
- 'workflow',
27
- 'ralph',
28
- 'subagent',
29
- 'subagent_fork',
30
- ]
31
-
32
- const LEGACY_KEYS = ['section', 'allowAgentControl']
33
- const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'leases', 'dev']
15
+ import {
16
+ Config,
17
+ DEFAULT_DENY_TOOLS,
18
+ assertKnownKeys,
19
+ assertSafeKeys,
20
+ clone,
21
+ deepMerge,
22
+ isPlainObject,
23
+ normalizeLegacy,
24
+ resolveTowerConfig,
25
+ } from '../domain/config/schema.js'
26
+ import { TowerToolError } from '../domain/errors.js'
27
+
28
+ export { Config, DEFAULT_DENY_TOOLS, resolveTowerConfig }
34
29
 
35
- const ModelSchema = () =>
36
- Schema.object({
37
- provider: Schema.string().description('Provider route override for this role.'),
38
- model: Schema.string().description('Model id override for this role.'),
39
- reasoningEffort: Schema.string().description('Optional reasoning effort override.'),
40
- maxTokens: Schema.natural().description('Optional output token cap.'),
41
- })
42
-
43
- export const Config = Schema.object({
44
- section: Schema.string().description(
45
- 'Legacy alias for mode.promptSection: replaces the whole Tower-mode system prompt text.',
46
- ),
47
- allowAgentControl: Schema.boolean().description(
48
- 'Legacy alias for mode.allowAgentControl: register tower_control so the model may toggle the mode.',
49
- ),
50
- init: Schema.object({
51
- autoGitInit: Schema.boolean()
52
- .default(false)
53
- .description('tower_init may run git init and create an empty initial commit when the directory is not a repository.'),
54
- snapshotDirty: Schema.boolean()
55
- .default(false)
56
- .description('tower_init may snapshot uncommitted tracked changes into a dangling commit used as the mission baseline.'),
57
- })
58
- .default({})
59
- .description('Repository initialization policy.'),
60
- mode: Schema.object({
61
- promptSection: Schema.string().description('Replacement Tower-mode system prompt text. Empty keeps the built-in reminder.'),
62
- sectionAppend: Schema.string().description('Extra text appended to the Tower-mode system prompt section (team conventions).'),
63
- allowAgentControl: Schema.boolean().description('Register tower_control so the model may enter/leave the mode with a reason.'),
64
- autoPauseGoal: Schema.boolean().default(true).description('Pause an active goal when entering Tower mode.'),
65
- requireResumeAuthorization: Schema.boolean()
66
- .default(false)
67
- .description('Require an authorization reference when resuming a mission paused with pauseCause=user.'),
68
- workspaceLock: Schema.boolean()
69
- .default(false)
70
- .description('Allow only one Tower session per repository (advisory <git-common-dir>/dsh-tower/tower.lock).'),
71
- workspaceLockScope: Schema.union(['all', 'mutations'])
72
- .default('all')
73
- .description('all = every repo-scoped tool acquires the workspace lock; mutations = only tools that write state/worktrees do.'),
74
- planModePolicy: Schema.union(['clear', 'refuse'])
75
- .default('clear')
76
- .description('On Tower entry: clear an active plan mode, or refuse to enter until the plan is exited.'),
77
- workspaceStaleMs: Schema.natural()
78
- .min(1000)
79
- .default(300000)
80
- .description('A workspace lock older than this is stale and can be taken over.'),
81
- heartbeatMs: Schema.natural().min(1000).default(10000).description('Workspace-lock heartbeat interval while Tower mode is on.'),
82
- denyTools: Schema.array(Schema.string())
83
- .default(DEFAULT_DENY_TOOLS)
84
- .description('Tools refused while Tower mode is active.'),
85
- storePath: Schema.string().description('Directory for modes.json; defaults to $DSH_HOME/tower-mode.'),
86
- maxEntries: Schema.natural().min(1).max(2000).default(200).description('Maximum persisted mode entries.'),
87
- })
88
- .default({})
89
- .description('Tower-mode behavior and persistence.'),
90
- review: Schema.object({
91
- requireReview: Schema.boolean()
92
- .default(false)
93
- .description('Require an independent clean review before merge. Default false: review is optional; set true to enforce the review gate globally.'),
94
- maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
95
- })
96
- .default({})
97
- .description('Review policy.'),
98
- merge: Schema.object({
99
- timeoutMs: Schema.natural().min(1000).default(900000).description('Default tower_merge timeout, including pre-merge hooks.'),
100
- maxBufferBytes: Schema.natural().min(65536).default(67108864).description('Captured merge output buffer.'),
101
- })
102
- .default({})
103
- .description('Merge execution policy.'),
104
- worktrees: Schema.object({
105
- root: Schema.string().description('Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>.'),
106
- })
107
- .default({})
108
- .description('Worktree placement.'),
109
- spawn: Schema.object({
110
- workerPreset: Schema.string().default('tower-worker').description('Agent preset for worker missions.'),
111
- reviewerPreset: Schema.string().default('tower-reviewer').description('Agent preset for reviewers.'),
112
- maxDepth: Schema.natural().min(1).default(1).description('Subagent depth requested when spawning tower agents.'),
113
- provider: Schema.string().description('Subagent provider id; empty picks spawn, then the first registered provider.'),
114
- workerModel: ModelSchema().description('Model route for workers; omitted inherits the parent session.'),
115
- reviewerModel: ModelSchema().description('Model route for reviewers; omitted inherits the parent session (recommended).'),
116
- maxLiveWorkers: Schema.natural()
117
- .min(1)
118
- .max(64)
119
- .default(4)
120
- .description('Maximum live worker entries allowed per repository at once.'),
121
- staleMs: Schema.natural()
122
- .min(0)
123
- .default(15000)
124
- .description('Roster entries with no catalog activity after this age are marked stale and block merge (0 disables).'),
125
- maxWaitMs: Schema.natural()
126
- .min(1000)
127
- .max(600000)
128
- .default(90000)
129
- .description('Upper bound for tower_spawn waitMs (default 90s, Kimi-compatible).'),
130
- requireLease: Schema.string().description('Optional lease name tower_spawn must hold before starting a worker.'),
131
- })
132
- .default({})
133
- .description('Worker and reviewer presets plus model routing.'),
134
- send: Schema.object({
135
- maxPerMinute: Schema.natural()
136
- .min(0)
137
- .default(120)
138
- .description('Per-repository tower_send rate limit per minute (0 disables).'),
139
- interruptDefault: Schema.boolean()
140
- .default(false)
141
- .description('Default value of tower_send.interrupt when the caller omits it.'),
142
- })
143
- .default({})
144
- .description('Message delivery policy.'),
145
- tests: Schema.object({
146
- enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
147
- admission: Schema.union(['spawn', 'complete', 'off'])
148
- .default('complete')
149
- .description('Run discovery before spawning a worker, when completing, or only on explicit tower_test.'),
150
- timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
151
- maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
152
- autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
153
- allowPackageDiscovery: Schema.boolean()
154
- .default(true)
155
- .description('Allow package.json tower.testDiscovery commands from the mission worktree (executes repository code; see security docs).'),
156
- })
157
- .default({})
158
- .description('Declared test discovery and admission.'),
159
- gates: Schema.object({
160
- requireChecks: Schema.boolean()
161
- .default(false)
162
- .description('Require at least one completion check (tower_complete.checks) before a mission can merge.'),
163
- requireTests: Schema.boolean()
164
- .default(false)
165
- .description('Require a passing test-discovery receipt for the reviewed worktree tip before merge.'),
166
- allowReviewExemption: Schema.boolean()
167
- .default(false)
168
- .description('Allow reviewPolicy:none missions with a non-empty reviewExemptionReason to skip review blockers.'),
169
- })
170
- .default({})
171
- .description('Merge-gate strictness.'),
172
- docs: Schema.object({
173
- board: Schema.boolean()
174
- .default(true)
175
- .description('Write a human-readable MISSIONS.md after mission changes.'),
176
- boardLocation: Schema.union(['common', 'repo'])
177
- .default('common')
178
- .description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md.'),
179
- })
180
- .default({})
181
- .description('Human-readable artifacts.'),
182
- leases: Schema.object({
183
- ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
184
- })
185
- .default({})
186
- .description('Advisory resource leases.'),
187
- dev: Schema.object({
188
- hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
189
- reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
190
- reloadRequiredIds: Schema.array(Schema.string())
191
- .default(['tower', 'tower-skills', 'preset-tower-worker', 'preset-tower-reviewer'])
192
- .description('Loader entry ids passed to reconcileProfile as required.'),
193
- })
194
- .default({})
195
- .description('Development and hot-reload behavior.'),
196
- }).description('DSH Tower configuration')
30
+ const REPO_CONFIG_MAX_BYTES = 1024 * 1024
197
31
 
198
- let effective = null
199
32
  // The last raw profile config `apply()` received; repository config is merged
200
33
  // over it so a repo can tune spawn/merge/review without editing the profile.
201
34
  let profileRaw = {}
202
35
 
203
- function clone(value) {
204
- return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
205
- }
206
-
207
- function isPlainObject(value) {
208
- return value !== null && typeof value === 'object' && !Array.isArray(value)
209
- }
210
-
211
- function deepMerge(base, override) {
212
- if (override === undefined) return clone(base) ?? {}
213
- if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
214
- const out = clone(base)
215
- for (const [key, value] of Object.entries(override)) {
216
- out[key] = value === undefined ? out[key] : deepMerge(base[key], value)
217
- }
218
- return out
219
- }
220
-
221
- /** Move the legacy top-level aliases into their canonical group keys. */
222
- function normalizeLegacy(raw = {}) {
223
- const out = clone(raw) ?? {}
224
- if (out.section !== undefined) {
225
- const mode = { ...(out.mode ?? {}) }
226
- if (mode.promptSection === undefined) mode.promptSection = out.section
227
- out.mode = mode
228
- delete out.section
229
- }
230
- if (out.allowAgentControl !== undefined) {
231
- const mode = { ...(out.mode ?? {}) }
232
- if (mode.allowAgentControl === undefined) mode.allowAgentControl = out.allowAgentControl
233
- out.mode = mode
234
- delete out.allowAgentControl
235
- }
236
- return out
237
- }
238
-
239
- function assertKnownKeys(raw, label) {
240
- const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
241
- if (unknown.length > 0) {
242
- throw new TowerToolError(
243
- 'config-unknown-key',
244
- `${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, worktrees?, spawn?, send?, tests?, gates?, docs?, leases?, dev? }`,
245
- )
246
- }
247
- }
248
-
249
- const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
250
- const REPO_CONFIG_MAX_BYTES = 1024 * 1024
251
-
252
- /** Reject prototype-pollution style keys anywhere in a repository config. */
253
- function assertSafeKeys(value, label) {
254
- if (Array.isArray(value)) {
255
- for (const item of value) assertSafeKeys(item, label)
256
- return
257
- }
258
- if (!isPlainObject(value)) return
259
- for (const [key, child] of Object.entries(value)) {
260
- if (FORBIDDEN_KEYS.has(key)) {
261
- throw new TowerToolError('config-invalid', `${label} contains the forbidden key "${key}"`)
262
- }
263
- assertSafeKeys(child, label)
264
- }
265
- }
266
-
267
- export function resolveTowerConfig(rawConfig) {
268
- const raw = rawConfig ?? {}
269
- if (raw !== null && typeof raw !== 'object') throw new Error('Tower config must be an object')
270
- assertKnownKeys(raw, 'Tower config')
271
- let parsed
272
- try {
273
- parsed = Config(raw)
274
- } catch (error) {
275
- throw new Error(`Tower config is invalid: ${String(error?.message ?? error)}`)
276
- }
277
- const mode = clone(parsed.mode ?? {})
278
- // Canonical keys win; the legacy top-level aliases only fill gaps.
279
- const rawMode = raw.mode ?? {}
280
- if (rawMode.promptSection === undefined && raw.section !== undefined) mode.promptSection = raw.section
281
- if (rawMode.allowAgentControl === undefined && raw.allowAgentControl !== undefined) {
282
- mode.allowAgentControl = raw.allowAgentControl
283
- }
284
- // Apply the canonical default only after alias resolution: a validated
285
- // Cordis config may omit this key, and an explicit legacy top-level
286
- // `allowAgentControl:true` must not be shadowed by a schema default.
287
- if (mode.allowAgentControl === undefined) mode.allowAgentControl = false
288
- if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
289
- return {
290
- section: mode.promptSection ?? '',
291
- init: clone(parsed.init ?? {}),
292
- mode,
293
- review: clone(parsed.review ?? {}),
294
- merge: clone(parsed.merge ?? {}),
295
- worktrees: clone(parsed.worktrees ?? {}),
296
- spawn: clone(parsed.spawn ?? {}),
297
- send: clone(parsed.send ?? {}),
298
- tests: clone(parsed.tests ?? {}),
299
- gates: clone(parsed.gates ?? {}),
300
- docs: clone(parsed.docs ?? {}),
301
- leases: clone(parsed.leases ?? {}),
302
- dev: clone(parsed.dev ?? {}),
303
- }
304
- }
305
-
306
36
  export function configureRuntime(config) {
307
37
  profileRaw = clone(config ?? {}) ?? {}
308
- effective = resolveTowerConfig(config)
309
- return effective
38
+ return resolveTowerConfig(config)
310
39
  }
311
40
 
312
41
  export function runtimeConfig() {
313
- if (!effective) effective = resolveTowerConfig({})
314
- return effective
42
+ return resolveTowerConfig(profileRaw)
43
+ }
44
+
45
+ /** Raw profile config before defaults are materialized (for doctor warnings). */
46
+ export function profileConfig() {
47
+ return clone(profileRaw) ?? {}
315
48
  }
316
49
 
317
50
  export function repoConfigPath(commonDir) {
@@ -373,7 +106,27 @@ export async function readRepoConfig(commonDir) {
373
106
  export async function resolveRepoConfig(repo) {
374
107
  const base = runtimeConfig()
375
108
  const layer = await readRepoConfig(repo.commonDir)
376
- const merged = deepMerge(base, layer.config)
109
+ // The mode guard is a session/profile concern. A repository checkout may
110
+ // only tighten it, never weaken denyTools, workspaceLock, allowAgentControl,
111
+ // plan policy, or resume authorization.
112
+ const repoLayer = { ...layer.config }
113
+ delete repoLayer.mode
114
+ delete repoLayer.section
115
+ delete repoLayer.allowAgentControl
116
+ const merged = deepMerge(base, repoLayer)
117
+ const repoMode = layer.config.mode
118
+ if (repoMode && typeof repoMode === 'object' && !Array.isArray(repoMode)) {
119
+ merged.mode = { ...(merged.mode ?? {}) }
120
+ if (repoMode.requireResumeAuthorization === true) merged.mode.requireResumeAuthorization = true
121
+ if (repoMode.workspaceLock === true) merged.mode.workspaceLock = true
122
+ if (repoMode.autoPauseGoal === true) merged.mode.autoPauseGoal = true
123
+ if (repoMode.workspaceLockScope === 'all') merged.mode.workspaceLockScope = 'all'
124
+ if (repoMode.planModePolicy === 'refuse') merged.mode.planModePolicy = 'refuse'
125
+ if (repoMode.allowAgentControl === false) merged.mode.allowAgentControl = false
126
+ if (Array.isArray(repoMode.denyTools)) {
127
+ merged.mode.denyTools = [...new Set([...(base.mode?.denyTools ?? DEFAULT_DENY_TOOLS), ...repoMode.denyTools])]
128
+ }
129
+ }
377
130
  if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
378
131
  merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
379
132
  }
@@ -385,4 +138,3 @@ export async function resolveRepoConfig(repo) {
385
138
  profileConfigured: Object.keys(profileRaw).length > 0,
386
139
  }
387
140
  }
388
-
package/lib/doctor.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // Read-only environment contract and health checks for a Tower repository.
2
- import { execFile } from 'node:child_process'
2
+ import { execFile } from '../adapters/vcs-git.js'
3
3
  import { promisify } from 'node:util'
4
4
  import path from 'node:path'
5
- import fsp from 'node:fs/promises'
5
+ import { fsp } from '../adapters/store-fs.js'
6
6
  import { git } from './git.js'
7
+ import { readStateLock } from './store.js'
8
+ import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
9
+ import { profileConfig } from './config.js'
7
10
  import { inspectWorkspaceLock } from './workspaceLock.js'
8
11
 
9
12
  const execFileAsync = promisify(execFile)
@@ -31,21 +34,83 @@ function check(id, status, detail, action) {
31
34
  }
32
35
 
33
36
  /** Run every read-only diagnostic; never writes state or takes the state lock. */
34
- export async function runDoctor({ tower, ctx, agent }) {
37
+ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
35
38
  const checks = []
36
39
  const version = await gitVersion()
37
40
  checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
38
41
 
42
+ const detectedHost = hostVersion ?? detectHostVersion(ctx)
43
+ const compatibilityRows = readCompatibility(compatibilityFile)
44
+ const hostAssessment = assessHostVersion(detectedHost, compatibilityRows)
45
+ checks.push(
46
+ check(
47
+ 'host-version',
48
+ hostAssessment.verdict === 'verified' ? 'pass' : 'warn',
49
+ detectedHost
50
+ ? `${detectedHost}: ${hostAssessment.detail}`
51
+ : `unknown host version; ${hostAssessment.detail}. Include host version + COMPATIBILITY.md row in bug reports.`,
52
+ 'see COMPATIBILITY.md for verified host builds',
53
+ ),
54
+ )
55
+
56
+ const rows = Array.isArray(loaderRows)
57
+ ? loaderRows
58
+ : (() => {
59
+ try {
60
+ const loader = ctx?.get?.('loader') ?? ctx?.loader
61
+ const source = loader?.entries ?? loader?.rows ?? loader?.store?.entries
62
+ if (Array.isArray(source)) return source
63
+ if (source && typeof source === 'object') return Object.values(source)
64
+ } catch {
65
+ // loader inventory is optional
66
+ }
67
+ return null
68
+ })()
69
+ if (rows === null) {
70
+ checks.push(check('loader-conflicts', 'warn', 'loader inventory unavailable; cannot detect co-installed Tower packages'))
71
+ } else {
72
+ const towerRows = rows.filter((row) => row && row.name === 'dsh-tower')
73
+ const foreign = towerRows.filter((row) => row.id !== 'tower')
74
+ checks.push(
75
+ towerRows.length > 1 || foreign.length > 0
76
+ ? check(
77
+ 'loader-conflicts',
78
+ 'fail',
79
+ `another loader row resolves to "dsh-tower" (${towerRows.map((row) => row.id ?? '?').join(', ')}); uninstall the conflicting dsh-plugins-tower row`,
80
+ 'see README "Known conflicts"',
81
+ )
82
+ : check('loader-conflicts', 'pass', 'no conflicting dsh-tower loader rows'),
83
+ )
84
+ }
85
+
39
86
  checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
40
87
  let state = null
41
88
  try {
42
89
  state = await tower.load()
43
90
  checks.push(check('state', 'pass', `${tower.paths.state} revision ${state.revision}`))
91
+ checks.push(
92
+ state.towerAgentId
93
+ ? check('tower-owner', 'pass', `bound to ${String(state.towerAgentId)}`)
94
+ : check('tower-owner', 'warn', 'state has no towerAgentId; write tools will fail closed', 'run tower_init with the restored session to re-claim ownership'),
95
+ )
44
96
  } catch (error) {
45
97
  checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
46
98
  }
47
99
 
48
100
  const config = tower.config ?? {}
101
+ const profile = profileConfig()
102
+ const staleDefaults = []
103
+ if (profile.review && profile.review.requireReview === undefined) {
104
+ staleDefaults.push('review.requireReview now defaults to false (2.0.3)')
105
+ }
106
+ if (profile.mode && profile.mode.allowAgentControl === undefined) {
107
+ staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
108
+ }
109
+ checks.push(
110
+ staleDefaults.length > 0
111
+ ? check('default-reversals', 'warn', staleDefaults.join('; '), 'pin the key explicitly in the profile if the old posture is required')
112
+ : check('default-reversals', 'pass', 'profile does not rely on a reversed default'),
113
+ )
49
114
  checks.push(
50
115
  check(
51
116
  'config',
@@ -78,11 +143,13 @@ export async function runDoctor({ tower, ctx, agent }) {
78
143
  checks.push(hmr ? check('hmr', 'pass', 'service mounted') : check('hmr', 'warn', 'HMR is not mounted; /tower reload is unavailable'))
79
144
 
80
145
  if (state) {
81
- const stateLock = path.join(tower.paths.dir, 'state.lock')
146
+ const lock = await readStateLock(tower.paths)
82
147
  checks.push(
83
- (await exists(stateLock))
84
- ? check('state-lock', 'warn', `${stateLock} exists (a writer may be active or a crash left it behind)`)
85
- : check('state-lock', 'pass', 'no state lock file'),
148
+ !lock.record
149
+ ? check('state-lock', 'pass', 'no state lock file')
150
+ : lock.alive === false
151
+ ? check('state-lock', 'warn', `stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone); the next mutation reclaims it atomically`)
152
+ : check('state-lock', 'warn', `state lock held by pid ${lock.record.pid ?? 'unknown'}; retry after it finishes`),
86
153
  )
87
154
  const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
88
155
  try {
@@ -95,7 +162,11 @@ export async function runDoctor({ tower, ctx, agent }) {
95
162
  }
96
163
 
97
164
  const live = (state.roster ?? []).filter((entry) => !['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive'].includes(entry.state))
98
- checks.push(live.length > 0 ? check('roster', 'warn', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`) : check('roster', 'pass', 'no live workers'))
165
+ checks.push(
166
+ live.length > 0
167
+ ? check('roster', 'pass', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`)
168
+ : check('roster', 'pass', 'no live workers'),
169
+ )
99
170
 
100
171
  let missingWorktrees = 0
101
172
  let dirtyWorktrees = 0
@@ -121,7 +192,7 @@ export async function runDoctor({ tower, ctx, agent }) {
121
192
  const removedNote = removedWorktrees > 0 ? ` (${removedWorktrees} removed by teardown)` : ''
122
193
  checks.push(
123
194
  missingWorktrees > 0
124
- ? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, 'run tower_preflight or tower_recover')
195
+ ? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, 'inspect tower_status, restore the worktree/branch, then re-run tower_doctor')
125
196
  : dirtyWorktrees > 0
126
197
  ? check('worktrees', 'warn', `${dirtyWorktrees} worktree(s) have uncommitted changes${removedNote}`)
127
198
  : check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
@@ -155,6 +226,28 @@ export async function runDoctor({ tower, ctx, agent }) {
155
226
  )
156
227
  }
157
228
 
229
+ try {
230
+ const text = await fsp.readFile(path.join(tower.paths.dir, 'lock-holds.jsonl'), 'utf8')
231
+ const samples = text.split('\n').filter(Boolean).slice(-200).map((line) => JSON.parse(line).ms).filter((ms) => Number.isFinite(ms)).sort((a, b) => a - b)
232
+ if (samples.length === 0) {
233
+ checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
234
+ } else {
235
+ const p = (q) => samples[Math.min(samples.length - 1, Math.floor(q * samples.length))]
236
+ const p50 = p(0.5)
237
+ const p95 = p(0.95)
238
+ checks.push(
239
+ check(
240
+ 'lock-holds',
241
+ p95 > 1000 ? 'warn' : 'pass',
242
+ `${samples.length} sample(s): p50 ${p50}ms, p95 ${p95}ms`,
243
+ p95 > 1000 ? 'a mutation is holding the state lock across slow work; inspect the activity/lock-holds log' : undefined,
244
+ ),
245
+ )
246
+ }
247
+ } catch {
248
+ checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
249
+ }
250
+
158
251
  const failed = checks.filter((item) => item.status === 'fail').length
159
252
  return {
160
253
  ok: failed === 0,
@@ -165,6 +258,12 @@ export async function runDoctor({ tower, ctx, agent }) {
165
258
  repoRoot: tower.root,
166
259
  commonDir: tower.commonDir,
167
260
  stateDir: tower.paths.dir,
261
+ gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
262
+ mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
263
+ testsAdmission: tower.config.tests?.admission ?? 'off',
264
+ hostVersion: detectedHost,
265
+ hostCompatibility: hostAssessment.verdict,
266
+ hostCompatibilityDetail: hostAssessment.detail,
168
267
  },
169
268
  mode: agent ? 'ready' : 'unknown',
170
269
  }