dsh-tower 2.0.4 → 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 (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  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 +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ // Release gate (#97, #76): a tag is a claim, so make it cost evidence.
3
+ //
4
+ // The gate compares three facts on every run:
5
+ //
6
+ // 1. `package.json` version vs the previous reachable `v<semver>` tag,
7
+ // 2. whether the CHANGELOG carries release content (a non-empty Unreleased
8
+ // section, or a non-empty section whose heading names the version),
9
+ // 3. on a tag push, that the pushed tag is exactly `v<version>`.
10
+ //
11
+ // It always prints `git describe`, the previous tag, the commit count since
12
+ // that tag and whether Unreleased is non-empty, so a large backlog is visible
13
+ // before an audit finds it. On a PR/branch push findings are warnings (exit 0);
14
+ // on a tag push a contradiction is an error (exit 1).
15
+ //
16
+ // Tag-push pitfall: `git describe --tags --abbrev=0` returns the *pushed* tag
17
+ // itself (and otherwise the nearest tag of any shape), so the previous tag is
18
+ // resolved from `<tag>^` and filtered to `v<semver>` (falling back to the newest
19
+ // version tag that is an ancestor).
20
+ //
21
+ // Usage:
22
+ // node scripts/release-check.mjs [--mode pr|tag] [--tag <name>] [--json]
23
+ //
24
+ // On CI the mode comes from GITHUB_REF_TYPE (`tag` on a tag push) and the tag
25
+ // name from GITHUB_REF_NAME. Locally, verify before tagging with
26
+ // `npm run release:check -- --mode tag --tag v<version>`.
27
+ import fs from 'node:fs'
28
+ import path from 'node:path'
29
+ import { spawnSync } from 'node:child_process'
30
+ import { compareVersions } from '../lib/compatibility.js'
31
+ import { findingRecorder, parseArgs, runMain, UsageError } from './lib/cli.mjs'
32
+
33
+ const USAGE = `usage: node scripts/release-check.mjs [--mode pr|tag] [--tag <name>] [--json] [--help]
34
+
35
+ --mode "pr" warns only (default); "tag" fails on a contradiction
36
+ --tag pushed tag name (default: GITHUB_REF_NAME in tag mode)
37
+ --json print the facts and findings as JSON
38
+ --help print this text`
39
+
40
+ const VERSION_TAG = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
41
+
42
+ // A version is stored without its tag prefix; a tag is `v` + that version. The
43
+ // release gate talks about both, so the conversion lives in one place.
44
+ function versionTag(version) {
45
+ return `v${stripTag(version)}`
46
+ }
47
+
48
+ function stripTag(tag) {
49
+ return String(tag).replace(/^v/i, '')
50
+ }
51
+
52
+ function escapeRegExp(value) {
53
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
54
+ }
55
+
56
+ function trimSection(lines) {
57
+ return lines.join('\n').trim()
58
+ }
59
+
60
+ function sectionBody(text, headingMatches) {
61
+ const lines = String(text ?? '').split(/\r?\n/)
62
+ const start = lines.findIndex((line) => /^##\s+/.test(line) && headingMatches(line))
63
+ if (start < 0) return null
64
+ const body = []
65
+ for (let index = start + 1; index < lines.length; index += 1) {
66
+ if (/^##\s+/.test(lines[index])) break
67
+ body.push(lines[index])
68
+ }
69
+ return trimSection(body)
70
+ }
71
+
72
+ export function unreleasedSection(changelog) {
73
+ return sectionBody(changelog, (line) => /unreleased|未发布/i.test(line))
74
+ }
75
+
76
+ export function versionSection(changelog, version) {
77
+ if (!version) return null
78
+ const pattern = new RegExp(`(?:^|[^0-9A-Za-z.-])${escapeRegExp(version)}(?![0-9A-Za-z.-])`)
79
+ return sectionBody(changelog, (line) => pattern.test(line.replace(/^##\s+/, '')))
80
+ }
81
+
82
+ // A section that only holds an HTML comment or a horizontal rule is a
83
+ // placeholder, not release notes.
84
+ export function hasContent(text) {
85
+ if (text === null || text === undefined) return false
86
+ const cleaned = String(text)
87
+ .replace(/<!--[\s\S]*?-->/g, '')
88
+ .replace(/^\s*[-*_]{3,}\s*$/gm, '')
89
+ .trim()
90
+ return cleaned.length > 0
91
+ }
92
+
93
+ export function evaluateRelease(input = {}) {
94
+ const version = String(input.version ?? '').trim()
95
+ if (!version) throw new UsageError('package.json has no version')
96
+ const mode = input.mode ?? 'pr'
97
+ if (mode !== 'pr' && mode !== 'tag') throw new UsageError(`--mode must be "pr" or "tag", not ${JSON.stringify(mode)}`)
98
+ const tag = input.tag ? String(input.tag) : null
99
+ const previousTag = input.previousTag ? String(input.previousTag) : null
100
+ const changelog = input.changelog ?? ''
101
+ const commitsSince = Number.isFinite(input.commitsSince) ? input.commitsSince : null
102
+ const describe = input.describe ?? null
103
+ const isTag = mode === 'tag'
104
+ const level = isTag ? 'error' : 'warning'
105
+ const suffix = isTag ? '' : ' (warning only outside a tag push)'
106
+ const count = commitsSince === null ? 'unknown' : String(commitsSince)
107
+
108
+ const unreleasedText = unreleasedSection(changelog)
109
+ const unreleased = hasContent(unreleasedText)
110
+ const versionNotes = versionSection(changelog, version)
111
+ const notesKind = unreleased ? 'unreleased' : hasContent(versionNotes) ? 'version-section' : 'none'
112
+ const previousVersion = previousTag ? stripTag(previousTag) : null
113
+ const findings = []
114
+ const record = findingRecorder(findings)
115
+
116
+ if (!previousTag) {
117
+ record('no-baseline', 'warning', `no reachable previous tag; cannot compare version ${version} against a release${suffix}`)
118
+ } else {
119
+ const direction = compareVersions(version, previousVersion)
120
+ // An unparseable version pair is treated as "did not move": publishing a
121
+ // version the release math cannot reason about is not evidence.
122
+ if (direction === null || direction <= 0) {
123
+ const relation = direction === 0 || direction === null ? 'equals' : 'is older than'
124
+ record(
125
+ 'version-not-bumped',
126
+ level,
127
+ `version did not move: package.json ${version} ${relation} the previous tag ${previousTag}; ` +
128
+ `${count} commits since that tag; Unreleased is ${unreleased ? 'non-empty' : 'empty'}${suffix}`,
129
+ )
130
+ }
131
+ }
132
+ if (notesKind === 'none') {
133
+ record(
134
+ 'no-release-notes',
135
+ level,
136
+ `CHANGELOG has no release notes for ${version}: the Unreleased section is empty and there is ` +
137
+ `no non-empty "## ${version}(…)" section${suffix}`,
138
+ )
139
+ }
140
+
141
+ if (isTag) {
142
+ if (!tag) {
143
+ record('tag-missing', 'error', 'tag push mode needs the tag name (GITHUB_REF_NAME or --tag)')
144
+ } else if (tag !== versionTag(version)) {
145
+ record(
146
+ 'tag-name-mismatch',
147
+ 'error',
148
+ `tag ${tag} does not match package version ${version}; expected ${versionTag(version)}`,
149
+ )
150
+ }
151
+ }
152
+
153
+ const errors = findings.filter((finding) => finding.level === 'error').length
154
+ const warnings = findings.filter((finding) => finding.level === 'warning').length
155
+ return {
156
+ ok: errors === 0,
157
+ mode,
158
+ findings,
159
+ errors,
160
+ warnings,
161
+ facts: { version, mode, tag, previousTag, previousVersion, commitsSince, describe, unreleased, notesKind },
162
+ }
163
+ }
164
+
165
+ // --- Git facts ---------------------------------------------------------------
166
+
167
+ function runGit(root, args) {
168
+ const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' })
169
+ if (result.error || result.status !== 0) return null
170
+ return (result.stdout ?? '').trim()
171
+ }
172
+
173
+ function versionTags(root) {
174
+ const output = runGit(root, ['tag', '--list'])
175
+ if (!output) return []
176
+ return output.split('\n').map((line) => line.trim()).filter((tag) => VERSION_TAG.test(tag))
177
+ }
178
+
179
+ function newestAncestorTag(root, revision, exclude) {
180
+ const tags = versionTags(root).filter((tag) => tag !== exclude && runGit(root, ['merge-base', '--is-ancestor', tag, revision]) !== null)
181
+ tags.sort((left, right) => (compareVersions(right, left) ?? 0))
182
+ return tags[0] ?? null
183
+ }
184
+
185
+ // The latest reachable *version* tag. `git describe` happily reports any tag
186
+ // near HEAD, so a maintenance tag (`ui-0.2.2`) must be filtered out here or the
187
+ // release math compares a version against a non-version string.
188
+ function describeVersionTag(root, revision) {
189
+ const described = runGit(root, ['describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*', revision])
190
+ return described && VERSION_TAG.test(described) ? described : null
191
+ }
192
+
193
+ export function resolvePreviousTag(root, tag) {
194
+ const tagExists = tag ? runGit(root, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`]) !== null : false
195
+ const revision = tag && tagExists ? tag : 'HEAD'
196
+ // Never compare against the pushed tag itself: it is the release, not its
197
+ // predecessor. `<tag>^` is the commit that was HEAD before the tag existed.
198
+ if (tagExists) {
199
+ const parent = describeVersionTag(root, `${tag}^`)
200
+ if (parent && parent !== tag) return parent
201
+ }
202
+ const described = describeVersionTag(root, revision)
203
+ if (described && described !== tag) return described
204
+ return newestAncestorTag(root, revision, tag)
205
+ }
206
+
207
+ export function collectFacts(root, options = {}) {
208
+ if (runGit(root, ['rev-parse', '--git-dir']) === null) {
209
+ throw new UsageError(`${root} is not a Git repository; the release gate compares the version against tags`)
210
+ }
211
+ const previousTag = options.previousTag ?? resolvePreviousTag(root, options.tag ?? null)
212
+ const commitsSince = previousTag ? Number(runGit(root, ['rev-list', '--count', `${previousTag}..HEAD`])) : null
213
+ const describe = runGit(root, ['describe', '--tags', '--always'])
214
+ return {
215
+ previousTag,
216
+ commitsSince: Number.isFinite(commitsSince) ? commitsSince : null,
217
+ describe: describe ?? null,
218
+ }
219
+ }
220
+
221
+ // --- CLI ---------------------------------------------------------------------
222
+
223
+ function printFacts(facts) {
224
+ const dash = (value) => (value === null || value === undefined || value === '' ? '-' : value)
225
+ console.log(
226
+ `release-check: version=${facts.version} mode=${facts.mode} tag=${dash(facts.tag)} ` +
227
+ `previous-tag=${dash(facts.previousTag)} commits-since=${dash(facts.commitsSince)} ` +
228
+ `describe=${dash(facts.describe)} unreleased=${facts.unreleased ? 'yes' : 'no'} notes=${facts.notesKind}`,
229
+ )
230
+ }
231
+
232
+ function cli(argv) {
233
+ const options = parseArgs(argv, {
234
+ initial: { mode: null, tag: null, json: false, help: false },
235
+ values: { '--mode': 'mode', '--tag': 'tag' },
236
+ flags: { '--json': 'json', '--help': 'help', '-h': 'help' },
237
+ })
238
+ if (options.help) {
239
+ console.log(USAGE)
240
+ return 0
241
+ }
242
+ const mode = options.mode ?? (process.env.GITHUB_REF_TYPE === 'tag' ? 'tag' : 'pr')
243
+ let tag = options.tag
244
+ if (!tag && mode === 'tag' && process.env.GITHUB_REF_TYPE === 'tag') tag = process.env.GITHUB_REF_NAME || null
245
+
246
+ const root = process.cwd()
247
+ const packagePath = path.join(root, 'package.json')
248
+ const changelogPath = path.join(root, 'CHANGELOG.md')
249
+ let version
250
+ try {
251
+ version = JSON.parse(fs.readFileSync(packagePath, 'utf8')).version
252
+ } catch (error) {
253
+ console.error(`release-check: cannot read ${packagePath}: ${error?.message ?? error}`)
254
+ return 2
255
+ }
256
+ let changelog
257
+ try {
258
+ changelog = fs.readFileSync(changelogPath, 'utf8')
259
+ } catch (error) {
260
+ console.error(`release-check: cannot read ${changelogPath}: ${error?.message ?? error}`)
261
+ return 2
262
+ }
263
+
264
+ let result
265
+ try {
266
+ const gitFacts = collectFacts(root, { tag })
267
+ result = evaluateRelease({ version, mode, tag, changelog, ...gitFacts })
268
+ } catch (error) {
269
+ console.error(`release-check: ${error.message}`)
270
+ return 2
271
+ }
272
+
273
+ if (options.json) {
274
+ console.log(
275
+ JSON.stringify(
276
+ {
277
+ root,
278
+ package: packagePath,
279
+ changelog: changelogPath,
280
+ ...result,
281
+ },
282
+ null,
283
+ 2,
284
+ ),
285
+ )
286
+ return result.ok ? 0 : 1
287
+ }
288
+
289
+ printFacts(result.facts)
290
+ for (const finding of result.findings) console.log(`${finding.level} [${finding.code}] ${finding.message}`)
291
+ if (result.ok) {
292
+ console.log(
293
+ result.warnings > 0
294
+ ? `release-check: ok (${result.warnings} warning(s), no tag blocking this run)`
295
+ : 'release-check: ok',
296
+ )
297
+ } else {
298
+ console.log(`release-check: ${result.errors} error(s), ${result.warnings} warning(s); refusing the release claim`)
299
+ }
300
+ return result.ok ? 0 : 1
301
+ }
302
+
303
+ await runMain(import.meta.url, () => cli(process.argv.slice(2)), { errorPrefix: 'release-check', usage: USAGE })
package/tools/adopt.js ADDED
@@ -0,0 +1,72 @@
1
+ // tower_adopt: reconcile the roster with the subagent catalog.
2
+ import { Tower } from '../app/tower.js'
3
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
+ import { TowerToolError } from '../domain/errors.js'
5
+ import { AdoptInput } from '../domain/contracts/tool-inputs/index.js'
6
+ import { PARENT_STATUS_OK, ROSTER_ACTIVITY, ROSTER_STATE } from '../domain/vocabulary.js'
7
+
8
+ /**
9
+ * @param {any} ctx
10
+ * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any }} deps
11
+ */
12
+ export function registerAdoptTool(ctx, deps) {
13
+ const { define, actorFor, requireTowerActor } = deps
14
+ define(ctx, {
15
+ name: 'tower_adopt',
16
+ description:
17
+ 'Reconcile the roster with the durable subagent catalog after a restart or new session. Agents that are provably gone are marked retired (records are kept) or missing (retireMissing:false, so the tower can inspect/take over); agents whose parent catalog cannot be read stay unknown and are never changed. Only the tower may adopt.',
18
+ parameters: AdoptInput,
19
+ aliases: { retire_missing: ['retireMissing'] },
20
+ output: output(SCHEMAS.adopt),
21
+ async execute(args, exec) {
22
+ const tower = await Tower.open(args.repoRoot)
23
+ const actor = await actorFor(tower, exec)
24
+ requireTowerActor(actor, 'adopt the roster')
25
+ const view = await tower.rosterView()
26
+ const subagents = ctx.get('subagents')
27
+ if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot adopt')
28
+
29
+ const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
30
+ const activity = new Map()
31
+ const parentStatus = {}
32
+ for (const parent of parents) {
33
+ try {
34
+ const rows = await subagents.listDescendants(parent)
35
+ for (const row of rows) activity.set(String(row.id), row.activity ?? ROSTER_ACTIVITY.unknown)
36
+ parentStatus[parent] = PARENT_STATUS_OK
37
+ } catch (error) {
38
+ parentStatus[parent] = `unavailable: ${String(error?.message ?? error)}`
39
+ }
40
+ }
41
+
42
+ const retiredCandidates = []
43
+ const presentIds = []
44
+ const absentIds = []
45
+ for (const entry of view.roster) {
46
+ if (entry.state === ROSTER_STATE.retired) continue
47
+ if (parentStatus[entry.parentSessionId] !== PARENT_STATUS_OK) continue
48
+ if (activity.has(entry.agentId)) {
49
+ presentIds.push(entry.agentId)
50
+ } else {
51
+ absentIds.push(entry.agentId)
52
+ }
53
+ }
54
+ const observations = await tower.recordRosterObservations({ present: presentIds, absent: absentIds, confirmations: 2 })
55
+ retiredCandidates.push(...observations.candidates)
56
+ const retireMissing = args.retireMissing !== false
57
+ const { retired } = retireMissing ? await tower.markRetired(retiredCandidates) : { retired: [] }
58
+ const { missing } = retireMissing ? { missing: [] } : await tower.markMissing(retiredCandidates)
59
+ const after = await tower.rosterView()
60
+ return {
61
+ retired,
62
+ missing,
63
+ unconfirmed: absentIds.filter((agentId) => !retiredCandidates.includes(agentId)),
64
+ skippedUnknownParents: Object.entries(parentStatus)
65
+ .filter(([, status]) => status !== PARENT_STATUS_OK)
66
+ .map(([parent]) => parent),
67
+ roster: after.roster,
68
+ parentStatus,
69
+ }
70
+ },
71
+ })
72
+ }
package/tools/batch.js ADDED
@@ -0,0 +1,78 @@
1
+ // tower_batch: finite evidence batches.
2
+ import { Tower } from '../app/tower.js'
3
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
+ import { TowerToolError } from '../domain/errors.js'
5
+ import { BatchInput } from '../domain/contracts/tool-inputs/index.js'
6
+ import { BATCH_ACTION } from '../domain/vocabulary.js'
7
+
8
+ /**
9
+ * @param {any} ctx
10
+ * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any, summarizeBatch: any, verifyBatchReceiptFiles: any }} deps
11
+ */
12
+ export function registerBatchTool(ctx, deps) {
13
+ const { define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles } = deps
14
+ define(ctx, {
15
+ name: 'tower_batch',
16
+ description:
17
+ 'Manage finite evidence batches: create freezes missionIds/requiredChecks/receiptPaths, status summarizes members, close verifies every required check and receipt hash, abandon retires a stale open batch. Membership and exclusions are immutable after create; close/abandon never remove missions.',
18
+ parameters: BatchInput,
19
+ board: true,
20
+ output: output(SCHEMAS.batch),
21
+ async execute(args, exec) {
22
+ const tower = await Tower.open(args.repoRoot)
23
+ const actor = await actorFor(tower, exec)
24
+ if (args.action === BATCH_ACTION.status) {
25
+ const state = await tower.load()
26
+ const batch = args.batchId
27
+ ? (state.batches ?? []).find((entry) => entry.id === args.batchId)
28
+ : null
29
+ if (args.batchId && !batch) throw new TowerToolError('invalid-argument', `unknown batch "${args.batchId}"`)
30
+ return {
31
+ ok: true,
32
+ action: BATCH_ACTION.status,
33
+ batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
34
+ ...(batch ? { batch: summarizeBatch(state, batch) } : {}),
35
+ ...(args.batchId ? { summary: summarizeBatch(state, batch) } : {}),
36
+ }
37
+ }
38
+ requireTowerActor(actor, args.action === BATCH_ACTION.create ? 'create a batch' : args.action === BATCH_ACTION.abandon ? 'abandon a batch' : 'close a batch')
39
+ if (args.action === BATCH_ACTION.create) {
40
+ const batch = await tower.createBatch(args, actor.name)
41
+ return { ok: true, action: BATCH_ACTION.create, batch, batches: (await tower.batchStatus()).batches }
42
+ }
43
+ if (typeof args.batchId !== 'string' || args.batchId.trim().length === 0) {
44
+ throw new TowerToolError('invalid-argument', `tower_batch ${args.action} requires batchId`)
45
+ }
46
+ const batchId = args.batchId.trim()
47
+ if (args.action === BATCH_ACTION.abandon) {
48
+ const result = await tower.abandonBatch(batchId, actor.name, /** @type {any} */ (args.reason))
49
+ return { ok: true, action: BATCH_ACTION.abandon, batch: result.batch, blockers: [] }
50
+ }
51
+ const closeState = await tower.load()
52
+ const closeBatch = (closeState.batches ?? []).find((entry) => entry.id === batchId)
53
+ if (closeBatch) {
54
+ const receiptProblems = await verifyBatchReceiptFiles(tower.root, closeBatch, args)
55
+ if (receiptProblems.length > 0) {
56
+ return { ok: false, action: BATCH_ACTION.close, batch: closeBatch, blockers: receiptProblems, checks: [] }
57
+ }
58
+ }
59
+ const result = await tower.closeBatch(batchId, {
60
+ checks: args.checks,
61
+ treeTip: args.treeTip,
62
+ receiptHashes: args.receiptHashes,
63
+ }, actor.name)
64
+ return {
65
+ ok: result.ok,
66
+ action: BATCH_ACTION.close,
67
+ batch: result.batch,
68
+ blockers: (result.blockers ?? []).map((blocker) => ({
69
+ code: blocker.code ?? 'batch-invalid',
70
+ detail: blocker.detail ?? blocker.message ?? String(blocker),
71
+ ...(blocker.cleanup ? { cleanup: blocker.cleanup } : {}),
72
+ ...(blocker.timeout ? { timeout: true } : {}),
73
+ })),
74
+ checks: result.checks ?? [],
75
+ }
76
+ },
77
+ })
78
+ }
@@ -0,0 +1,53 @@
1
+ // tower_complete: worker completion admission.
2
+ import { Tower } from '../app/tower.js'
3
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
+ import { TowerToolError } from '../domain/errors.js'
5
+ import { CompleteInput } from '../domain/contracts/tool-inputs/index.js'
6
+ import { isCompleteAdmission } from '../domain/vocabulary.js'
7
+
8
+ /**
9
+ * @param {any} ctx
10
+ * @param {{ define: import('../types/index.js').DefineTool, runMissionDiscovery: any }} deps
11
+ */
12
+ export function registerCompleteTool(ctx, deps) {
13
+ const { define, runMissionDiscovery } = deps
14
+ define(ctx, {
15
+ name: 'tower_complete',
16
+ description:
17
+ 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means ready for preflight and optional review, not merged.',
18
+ parameters: CompleteInput,
19
+ aliases: {"missionId":["id"]},
20
+ requiresMissionId: true,
21
+ board: true,
22
+ output: output(SCHEMAS.complete),
23
+ async execute(args, exec) {
24
+ const tower = await Tower.open(args.repoRoot)
25
+ // Validate authorization/state before executing repository-controlled
26
+ // test code or writing a test receipt.
27
+ await tower.checkCompletion({
28
+ missionId: args.missionId,
29
+ agentId: String(exec.agent.id),
30
+ report: args.report,
31
+ })
32
+ const context = await tower.missionContext(args.missionId)
33
+ const checks = [...(Array.isArray(args.checks) ? args.checks : [])]
34
+ const testsConfig = /** @type {any} */ (tower.config.tests ?? {})
35
+ if (testsConfig.enabled !== false && isCompleteAdmission(testsConfig.admission)) {
36
+ const discovery = await runMissionDiscovery(tower, { id: args.missionId, worktree: context.mission.worktree })
37
+ if (discovery.configured && discovery.receipt.ok !== true) {
38
+ throw new TowerToolError('tests-failed', `test discovery failed for ${args.missionId}: ${discovery.receipt.error}`)
39
+ }
40
+ if (discovery.configured && testsConfig.autoChecks !== false && discovery.check) {
41
+ if (checks.length >= 20) checks[19] = discovery.check
42
+ else checks.push(discovery.check)
43
+ }
44
+ }
45
+ return tower.complete({
46
+ missionId: args.missionId,
47
+ agentId: String(exec.agent.id),
48
+ report: args.report,
49
+ checks,
50
+ })
51
+ },
52
+ })
53
+ }
package/tools/diff.js ADDED
@@ -0,0 +1,39 @@
1
+ // The result is bound to the recorded reviewed tip only when requested.
2
+ // Callers must treat the patch as read-only evidence.
3
+ // The tool never checks out or mutates the mission branch.
4
+ // Reviewers may use it without holding the state lock.
5
+ // tower_diff — read-only mission branch view.
6
+ //
7
+ // Input: DiffInput (missionId/id, maxBytes) from domain contracts.
8
+ // Output: merge base, changed files, commits, capped patch.
9
+ // Authorization: tower, reviewer, or assigned worker may inspect.
10
+ // Side effects: none; it never writes state, git, or the repository.
11
+ // Failure modes: unknown mission, missing worktree, stale branch.
12
+ // Errors are classified as [tower:<code>] by the registry.
13
+ // The patch cap is enforced by the pure read projection.
14
+ // See docs/reference/tools.generated.md for the public shape.
15
+ // tower_diff: read-only mission branch view.
16
+ import { Tower } from '../app/tower.js'
17
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
18
+ import { DiffInput } from '../domain/contracts/tool-inputs/index.js'
19
+
20
+ /**
21
+ * @param {any} ctx
22
+ * @param {{ define: import('../types/index.js').DefineTool }} deps
23
+ */
24
+ export function registerDiffTool(ctx, deps) {
25
+ const { define } = deps
26
+ define(ctx, {
27
+ name: 'tower_diff',
28
+ description:
29
+ 'Read-only view of a mission branch for review: merge base, changed files, commits, and a capped patch against the recorded base. Safe for reviewers; it never modifies the repository.',
30
+ parameters: DiffInput,
31
+ aliases: {"missionId":["id"]},
32
+ requiresMissionId: true,
33
+ output: output(SCHEMAS.diff),
34
+ async execute(args) {
35
+ const tower = await Tower.open(args.repoRoot)
36
+ return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
37
+ },
38
+ })
39
+ }
@@ -0,0 +1,50 @@
1
+ // tower_doctor: read-only health check.
2
+ import { Tower } from '../app/tower.js'
3
+ import { resolveRepo } from '../lib/git.js'
4
+ import { runtimeConfig } from '../lib/config.js'
5
+ import { runDoctor } from '../lib/doctor.js'
6
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
7
+ import { DoctorInput } from '../domain/contracts/tool-inputs/index.js'
8
+ import { DOCTOR_STATUS } from '../domain/vocabulary.js'
9
+
10
+ /**
11
+ * @param {any} ctx
12
+ * @param {import('../types/index.js').DefineTool} define
13
+ */
14
+ export function registerDoctorTool(ctx, define) {
15
+ define(ctx, {
16
+ name: 'tower_doctor',
17
+ description:
18
+ 'Read-only environment and repository health check: git, state schema, config, presets/subagents/HMR, workspace lock, roster staleness, worktree ownership, test discovery, leases. Returns checks with pass/warn/fail and an environment contract. Never writes state.',
19
+ parameters: DoctorInput,
20
+ output: output(SCHEMAS.doctor),
21
+ async execute(args, exec) {
22
+ let tower
23
+ let configError = null
24
+ try {
25
+ tower = await Tower.open(args.repoRoot)
26
+ } catch (error) {
27
+ const message = String(error?.message ?? error)
28
+ if (error?.code !== 'config-invalid' && error?.code !== 'config-unknown-key') throw error
29
+ configError = message
30
+ const repo = await resolveRepo(args.repoRoot)
31
+ tower = new Tower(repo, { ...runtimeConfig(), configPath: null, repoConfigPresent: true })
32
+ }
33
+ const report = await runDoctor(/** @type {any} */ ({ tower, ctx, agent: exec.agent }))
34
+ if (!configError) return report
35
+ return {
36
+ ...report,
37
+ ok: false,
38
+ checks: [
39
+ {
40
+ id: 'config',
41
+ status: DOCTOR_STATUS.fail,
42
+ detail: configError,
43
+ fix: 'repair <git-common-dir>/dsh-tower/config.json and re-run tower_doctor',
44
+ },
45
+ ...(report.checks ?? []),
46
+ ],
47
+ }
48
+ },
49
+ })
50
+ }
@@ -0,0 +1,40 @@
1
+ // tower_finding: file one structured finding.
2
+ import { Tower } from '../app/tower.js'
3
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
+ import { FindingInput } from '../domain/contracts/tool-inputs/index.js'
5
+ import { ROLE } from '../domain/vocabulary.js'
6
+
7
+ /**
8
+ * @param {any} ctx
9
+ * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireKnownActor: any }} deps
10
+ */
11
+ export function registerFindingTool(ctx, deps) {
12
+ const { define, actorFor, requireKnownActor } = deps
13
+ define(ctx, {
14
+ name: 'tower_finding',
15
+ description:
16
+ 'File a structured finding (bug / improve / vuln / idea) for the tower to route. Use this for anything notable OUTSIDE your mission scope instead of editing out-of-scope files. Severity is one of low|medium|high|critical. Use tower_findings to list, read, or close findings later.',
17
+ parameters: FindingInput,
18
+ aliases: {"missionId":["mission_id"]},
19
+ board: true,
20
+ output: output(SCHEMAS.finding),
21
+ async execute(args, exec) {
22
+ const tower = await Tower.open(args.repoRoot)
23
+ const actor = await actorFor(tower, exec)
24
+ requireKnownActor(actor, 'tower_finding')
25
+ const view = await tower.rosterView()
26
+ const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
27
+ return tower.addFinding({
28
+ type: args.type,
29
+ title: args.title,
30
+ severity: args.severity,
31
+ summary: args.summary,
32
+ location: args.location,
33
+ details: args.details,
34
+ suggested_fix: args.suggested_fix,
35
+ missionId: args.missionId,
36
+ reportedBy: self ? self.name : ROLE.tower,
37
+ })
38
+ },
39
+ })
40
+ }
@@ -0,0 +1,39 @@
1
+ // tower_findings: list, read, or update findings.
2
+ import { Tower } from '../app/tower.js'
3
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
+ import { FindingsInput } from '../domain/contracts/tool-inputs/index.js'
5
+ import { ROLE } from '../domain/vocabulary.js'
6
+
7
+ /**
8
+ * @param {any} ctx
9
+ * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireKnownActor: any }} deps
10
+ */
11
+ export function registerFindingsTool(ctx, deps) {
12
+ const { define, actorFor, requireKnownActor } = deps
13
+ define(ctx, {
14
+ name: 'tower_findings',
15
+ description:
16
+ 'List, read, or update findings for a repository. Without id it lists findings newest-first with counts by status (filters: status, type, severity, missionId, open, limit). With id it returns that finding; with id plus status and/or note it records a lifecycle transition. Statuses are open -> triaged -> fixed|wontfix, and fixed/wontfix can be reopened to open. Only the tower or a worker may change a finding; reviewers may only list and read. Read-only when no id is given.',
17
+ parameters: FindingsInput,
18
+ aliases: {"missionId":["mission_id"]},
19
+ output: output(SCHEMAS.findings),
20
+ async execute(args, exec) {
21
+ const tower = await Tower.open(args.repoRoot)
22
+ const actor = await actorFor(tower, exec)
23
+ requireKnownActor(actor, 'tower_findings')
24
+ const view = await tower.rosterView()
25
+ const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
26
+ return tower.findings({
27
+ id: args.id,
28
+ status: args.status,
29
+ note: args.note,
30
+ type: args.type,
31
+ severity: args.severity,
32
+ missionId: args.missionId,
33
+ open: args.open,
34
+ limit: args.limit,
35
+ actor: { name: self ? self.name : ROLE.tower, role: self ? self.role : ROLE.tower },
36
+ })
37
+ },
38
+ })
39
+ }