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,54 @@
1
+ // tower_resource: advisory resource leases.
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 { acquireLease, releaseLease, inspectLeases } from '../lib/leases.js'
6
+ import { ResourceInput } from '../domain/contracts/tool-inputs/index.js'
7
+ import { LEASE_ACTION } from '../domain/vocabulary.js'
8
+
9
+ /**
10
+ * @param {any} ctx
11
+ * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any }} deps
12
+ */
13
+ export function registerResourceTool(ctx, deps) {
14
+ const { define, actorFor, requireTowerActor } = deps
15
+ define(ctx, {
16
+ name: 'tower_resource',
17
+ description:
18
+ 'Advisory resource leases per repository (coordination only; no OS CPU/memory hard cap). acquire/release require a token and are tower-only; status is read-only. Expired leases are never taken over silently.',
19
+ parameters: ResourceInput,
20
+ output: output(SCHEMAS.resource),
21
+ async execute(args, exec) {
22
+ const tower = await Tower.open(args.repoRoot)
23
+ const actor = await actorFor(tower, exec)
24
+ const redact = (leases) => leases.map(({ token, ...lease }) => lease)
25
+ if (args.action === LEASE_ACTION.status) {
26
+ const info = await inspectLeases(tower.commonDir)
27
+ const leases = redact(info.leases ?? [])
28
+ return { ok: true, action: LEASE_ACTION.status, ...info, leases }
29
+ }
30
+ requireTowerActor(actor, args.action === LEASE_ACTION.acquire ? 'acquire a lease' : 'release a lease')
31
+ if (typeof args.name !== 'string' || args.name.trim().length === 0) {
32
+ throw new TowerToolError('invalid-argument', 'tower_resource requires name')
33
+ }
34
+ const name = args.name.trim()
35
+ const ttlMs = Number(args.ttlMs ?? tower.config.leases?.ttlMs ?? 900000)
36
+ if (args.action === LEASE_ACTION.acquire) {
37
+ const lease = await acquireLease(tower.commonDir, {
38
+ name,
39
+ holder: args.holder ?? actor.name,
40
+ token: args.token ?? crypto.randomUUID(),
41
+ pid: process.pid,
42
+ ttlMs,
43
+ })
44
+ return { ok: true, action: LEASE_ACTION.acquire, lease }
45
+ }
46
+ const token = args.token
47
+ if (typeof token !== 'string' || token.length === 0) {
48
+ throw new TowerToolError('invalid-argument', 'tower_resource release requires token')
49
+ }
50
+ const released = await releaseLease(tower.commonDir, { name, token, force: args.force === true })
51
+ return { ok: true, action: LEASE_ACTION.release, released, leases: redact((await inspectLeases(tower.commonDir)).leases) }
52
+ },
53
+ })
54
+ }
@@ -0,0 +1,45 @@
1
+ // tower_review: independent review verdict.
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 { ReviewInput } from '../domain/contracts/tool-inputs/index.js'
6
+ import { isReviewerRole, isTowerRole } from '../domain/vocabulary.js'
7
+
8
+ /**
9
+ * @param {any} ctx
10
+ * @param {{ define: import('../types/index.js').DefineTool, actorFor: any }} deps
11
+ */
12
+ export function registerReviewTool(ctx, deps) {
13
+ const { define, actorFor } = deps
14
+ define(ctx, {
15
+ name: 'tower_review',
16
+ description:
17
+ 'Submit an independent review verdict for a completed mission, bound to the current branch tip. status is "clean", "p1-Nitems", or "p2-Nitems"; merge is "merge", "fix-then-merge", or "hold" (only clean + merge is later approval). Non-clean returns the mission to active. Reviewers are capped at 5 rounds per branch; a tower-submitted review is break-glass and requires breakGlassReason.',
18
+ parameters: ReviewInput,
19
+ aliases: {"missionId":["id"]},
20
+ requiresMissionId: true,
21
+ board: true,
22
+ output: output(SCHEMAS.review),
23
+ async execute(args, exec) {
24
+ const tower = await Tower.open(args.repoRoot)
25
+ const actor = await actorFor(tower, exec)
26
+ if (!isReviewerRole(actor.role) && !isTowerRole(actor.role)) {
27
+ throw new TowerToolError('not-authorized', `only an assigned reviewer or the tower may review; ${actor.name} is ${actor.role}`)
28
+ }
29
+ if (isTowerRole(actor.role) && (typeof args.breakGlassReason !== 'string' || args.breakGlassReason.trim().length === 0)) {
30
+ throw new TowerToolError('not-authorized', 'a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
31
+ }
32
+ return tower.submitReview({
33
+ missionId: args.missionId,
34
+ agentId: String(exec.agent.id),
35
+ actor,
36
+ status: args.status,
37
+ merge: args.merge,
38
+ findings: args.findings,
39
+ checks: args.checks,
40
+ decision: args.decision,
41
+ breakGlassReason: args.breakGlassReason,
42
+ })
43
+ },
44
+ })
45
+ }
@@ -0,0 +1,60 @@
1
+ // tower_roster: authoritative roster activity.
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 { RosterInput } from '../domain/contracts/tool-inputs/index.js'
6
+ import { PARENT_STATUS_OK, ROSTER_ACTIVITY, ROSTER_GONE_STATES } from '../domain/vocabulary.js'
7
+
8
+ /**
9
+ * @param {any} ctx
10
+ * @param {{ define: import('../types/index.js').DefineTool }} deps
11
+ */
12
+ export function registerRosterTool(ctx, deps) {
13
+ const { define } = deps
14
+ define(ctx, {
15
+ name: 'tower_roster',
16
+ description:
17
+ 'List tower roster agents with authoritative live activity from the subagent catalog (running/inactive/missing/unknown). Read-only; never resumes children. Use before completion or merge decisions.',
18
+ parameters: RosterInput,
19
+ output: output(SCHEMAS.roster),
20
+ async execute(args) {
21
+ const tower = await Tower.open(args.repoRoot)
22
+ const view = await tower.rosterView()
23
+ const subagents = ctx.get('subagents')
24
+ if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot observe roster')
25
+
26
+ const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
27
+ const activity = new Map()
28
+ const parentStatus = {}
29
+ for (const parent of parents) {
30
+ try {
31
+ const rows = await subagents.listDescendants(parent)
32
+ for (const row of rows) activity.set(String(row.id), row.activity ?? ROSTER_ACTIVITY.unknown)
33
+ parentStatus[parent] = PARENT_STATUS_OK
34
+ } catch (error) {
35
+ parentStatus[parent] = `unavailable: ${String(error?.message ?? error)}`
36
+ }
37
+ }
38
+
39
+ const staleMs = Number(tower.config.spawn?.staleMs) || 0
40
+ const now = Date.now()
41
+ const roster = view.roster.map((entry) => {
42
+ const observed = activity.get(entry.agentId)
43
+ const known = parentStatus[entry.parentSessionId] === PARENT_STATUS_OK
44
+ const stale =
45
+ staleMs > 0 &&
46
+ observed === undefined &&
47
+ known &&
48
+ !ROSTER_GONE_STATES.includes(entry.state) &&
49
+ now - Number(entry.spawnedAt ?? 0) > staleMs
50
+ return {
51
+ ...entry,
52
+ activity: observed ?? (known ? ROSTER_ACTIVITY.missing : ROSTER_ACTIVITY.unknown),
53
+ observed: observed !== undefined,
54
+ stale,
55
+ }
56
+ })
57
+ return { revision: view.revision, roster, parentStatus, missions: view.missions }
58
+ },
59
+ })
60
+ }
package/tools/send.js ADDED
@@ -0,0 +1,147 @@
1
+ // tower_send: durable inbox message delivery.
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 { SendInput } from '../domain/contracts/tool-inputs/index.js'
6
+ import {
7
+ ROLE,
8
+ ROSTER_ACTIVITY,
9
+ isBroadcast,
10
+ isRunningActivity,
11
+ isTowerRecipient,
12
+ isTowerRole,
13
+ isUnknownRole,
14
+ } from '../domain/vocabulary.js'
15
+
16
+ /**
17
+ * @param {any} ctx
18
+ * @param {{
19
+ * define: import('../types/index.js').DefineTool,
20
+ * actorFor: any,
21
+ * checkSendRate: any,
22
+ * }} deps
23
+ */
24
+ export function registerSendTool(ctx, deps) {
25
+ const { define, actorFor, checkSendRate } = deps
26
+ define(ctx, {
27
+ name: 'tower_send',
28
+ description:
29
+ 'Send a durable inbox message to a tower participant: a roster agent name, "tower", or "all" (broadcast). The message is persisted first; when the recipient is a currently running roster agent the tower also steers it into that child\'s current turn. Returns delivery results.',
30
+ parameters: SendInput,
31
+ aliases: { consentRef: ['consent_ref'] },
32
+ output: output(SCHEMAS.send),
33
+ async execute(args, exec) {
34
+ const tower = await Tower.open(args.repoRoot)
35
+ checkSendRate(tower.config, args.repoRoot)
36
+ const actor = await actorFor(tower, exec)
37
+ const view = await tower.rosterView()
38
+ const from = isTowerRole(actor.role) ? ROLE.tower : actor.name
39
+ if (isUnknownRole(actor.role)) {
40
+ throw new TowerToolError('not-authorized', 'tower_send requires a registered roster agent or the tower')
41
+ }
42
+ if (isBroadcast(args.to) && !isTowerRole(actor.role)) {
43
+ throw new TowerToolError('not-authorized', 'only the tower may broadcast to "all"')
44
+ }
45
+ const sendConfig = tower.config.send ?? {}
46
+ const interrupt =
47
+ args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
48
+ if (interrupt && !isTowerRole(actor.role)) {
49
+ throw new TowerToolError('not-authorized', 'only the tower may interrupt a recipient with tower_send')
50
+ }
51
+ if (!isTowerRole(actor.role)) {
52
+ const self = view.roster.find((entry) => entry.agentId === actor.agentId)
53
+ const target = view.roster.find((entry) => entry.name === args.to)
54
+ const allowed =
55
+ isTowerRecipient(args.to) ||
56
+ (self && target && target.agentId !== self.agentId && target.missionId === self.missionId)
57
+ if (!allowed) {
58
+ throw new TowerToolError('not-authorized', `${actor.name} may only message the tower or agents on the same mission`)
59
+ }
60
+ }
61
+ if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
62
+ throw new TowerToolError('invalid-argument', 'tower_send requires a subject')
63
+ }
64
+ if (typeof args.body !== 'string' || args.body.trim().length === 0) {
65
+ throw new TowerToolError('invalid-argument', 'tower_send requires a body')
66
+ }
67
+ const message = await tower.recordMessage({
68
+ from,
69
+ to: args.to,
70
+ subject: args.subject.trim(),
71
+ body: args.body,
72
+ scope: args.scope,
73
+ consentRef: args.consentRef,
74
+ })
75
+
76
+ const subagents = ctx.get('subagents')
77
+ const results = []
78
+ if (!subagents) {
79
+ results.push({ target: '*', pushed: false, reason: 'subagents service unavailable; persisted only' })
80
+ } else {
81
+ const roster = view.roster
82
+ const parents = [...new Set(roster.map((entry) => entry.parentSessionId).filter(Boolean))]
83
+ const activity = new Map()
84
+ for (const parent of parents) {
85
+ try {
86
+ const rows = await subagents.listDescendants(parent)
87
+ for (const row of rows) activity.set(String(row.id), row.activity ?? ROSTER_ACTIVITY.unknown)
88
+ } catch {
89
+ // an unreadable parent catalog only means "do not wake it"
90
+ }
91
+ }
92
+ const targets =
93
+ isBroadcast(args.to) ? roster.map((entry) => entry.name) : [args.to]
94
+ for (const targetName of targets) {
95
+ if (isTowerRecipient(targetName)) {
96
+ results.push({ target: ROLE.tower, pushed: false, reason: 'tower reads its inbox' })
97
+ continue
98
+ }
99
+ const entry = roster.find((candidate) => candidate.name === targetName)
100
+ if (!entry) {
101
+ results.push({ target: targetName, pushed: false, reason: 'not in roster' })
102
+ continue
103
+ }
104
+ if (!isRunningActivity(activity.get(entry.agentId))) {
105
+ results.push({ target: targetName, pushed: false, reason: 'not running; queued in inbox' })
106
+ continue
107
+ }
108
+ let interrupted = false
109
+ if (interrupt) {
110
+ try {
111
+ subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
112
+ interrupted = true
113
+ await tower.recordInterrupt({
114
+ agentId: entry.agentId,
115
+ reason: `tower_send${message?.id ? ` ${message.id}` : ''}: ${args.subject.trim().slice(0, 120)}`,
116
+ })
117
+ } catch (error) {
118
+ results.push({ target: targetName, pushed: false, reason: `interrupt failed: ${String(error?.message ?? error)}` })
119
+ continue
120
+ }
121
+ }
122
+ try {
123
+ await subagents.sendMessage(
124
+ exec.agent,
125
+ entry.agentId,
126
+ [
127
+ {
128
+ type: 'text',
129
+ text: `[tower inbox ${message.id}] ${message.subject}\n\n${message.body}`,
130
+ },
131
+ ],
132
+ { signal: exec.signal },
133
+ )
134
+ results.push({ target: targetName, pushed: true, interrupted })
135
+ } catch (error) {
136
+ results.push({ target: targetName, pushed: false, reason: String(error?.message ?? error) })
137
+ }
138
+ }
139
+ }
140
+ if (results.length > 0) await tower.recordDelivery(message.id, results)
141
+ return {
142
+ message: { id: message.id, from, to: message.to, subject: message.subject },
143
+ deliveries: results,
144
+ }
145
+ },
146
+ })
147
+ }
package/tools/spawn.js ADDED
@@ -0,0 +1,196 @@
1
+ // tower_spawn: provision and start a worker/reviewer.
2
+ import { Tower } from '../app/tower.js'
3
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
+ import { TowerToolError, classifyError } from '../domain/errors.js'
5
+ import { SpawnInput } from '../domain/contracts/tool-inputs/index.js'
6
+ import { DEFAULT_ROLE, ROLES as ROLE_ENUM, SPAWN_OUTCOME, isCompletedStopReason, isReviewerRole, isSpawnAdmission, isWorkerRole } from '../domain/vocabulary.js'
7
+ import {
8
+ assertSpawnableMission,
9
+ precheckSpawnServices,
10
+ precheckWorkerOwnership,
11
+ resolveRosterName,
12
+ } from '../lib/spawn-prechecks.js'
13
+ import { buildAgentOptions, buildSpawnBriefing, waitForSpawnResult } from '../lib/spawn-support.js'
14
+
15
+ /**
16
+ * @param {any} ctx
17
+ * @param {{
18
+ * define: import('../types/index.js').DefineTool,
19
+ * actorFor: any,
20
+ * requireTowerActor: any,
21
+ * enqueueSpawn: any,
22
+ * observeActivity: any,
23
+ * spawnBackoffState: any,
24
+ * recordSpawnThrottle: any,
25
+ * recordSpawnSuccess: any,
26
+ * runMissionDiscovery: any,
27
+ * textOf: any,
28
+ * }} deps
29
+ */
30
+ export function registerSpawnTool(ctx, deps) {
31
+ const {
32
+ define, actorFor, requireTowerActor, enqueueSpawn, observeActivity,
33
+ spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess,
34
+ runMissionDiscovery, textOf,
35
+ } = deps
36
+ define(ctx, {
37
+ name: 'tower_spawn',
38
+ description:
39
+ 'Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true.',
40
+ parameters: SpawnInput,
41
+ aliases: {"missionId":["id"],"waitMs":["wait_ms"]},
42
+ requiresMissionId: true,
43
+ board: true,
44
+ output: output(SCHEMAS.spawn),
45
+ async execute(args, exec) {
46
+ const tower = await Tower.open(args.repoRoot)
47
+ const actor = await actorFor(tower, exec)
48
+ requireTowerActor(actor, 'spawn tower agents')
49
+ const role = args.role ?? DEFAULT_ROLE
50
+ if (!ROLE_ENUM.includes(role)) throw new TowerToolError('invalid-argument', `role must be one of ${ROLE_ENUM.join('|')}`)
51
+ // Repository config (if any) already overrides the profile config here.
52
+ const config = tower.config
53
+ const preset = isReviewerRole(role) ? config.spawn.reviewerPreset : config.spawn.workerPreset
54
+ const model = isReviewerRole(role) ? config.spawn.reviewerModel : config.spawn.workerModel
55
+ const context = await tower.missionContext(args.missionId)
56
+ assertSpawnableMission({ role, context })
57
+ const spawned = await enqueueSpawn(tower.root, async () => {
58
+ const observed = await observeActivity(ctx, tower)
59
+ await tower.reconcileObservedInactive(observed.activity)
60
+ const backoff = spawnBackoffState(tower.root, config.spawn.maxLiveWorkers)
61
+ if (backoff.blockedUntil > Date.now()) {
62
+ const seconds = Math.ceil((backoff.blockedUntil - Date.now()) / 1000)
63
+ throw new TowerToolError('provider-cooldown', `provider cooldown active; retry in ${seconds}s (${backoff.failures} recent failures)`)
64
+ }
65
+ const view = await tower.rosterView()
66
+ const name = resolveRosterName({ role, missionId: args.missionId, requested: args.name, roster: view.roster })
67
+ const takeover = await precheckWorkerOwnership({
68
+ role, tower, ctx, view, missionId: args.missionId, takeover: args.takeover,
69
+ maxLiveWorkers: config.spawn.maxLiveWorkers, budget: backoff.budget, observeActivity,
70
+ })
71
+ const { subagents, provider } = await precheckSpawnServices({ tower, ctx, config, preset, actor, exec })
72
+
73
+ const provisioned = await tower.provision({
74
+ missionId: args.missionId,
75
+ worktreeRoot: args.worktreeRoot,
76
+ includeDirtyBase: args.includeDirtyBase !== false,
77
+ })
78
+ const { mission, worktree } = provisioned
79
+ const briefing = buildSpawnBriefing({ role, towerRoot: tower.root, context, worktree, instructions: args.instructions })
80
+ const agentOptions = buildAgentOptions(model)
81
+
82
+ if (isWorkerRole(role) && isSpawnAdmission(tower.config.tests?.admission)) {
83
+ const discovery = await runMissionDiscovery(tower, mission)
84
+ if (discovery.configured && discovery.receipt.ok !== true) {
85
+ throw new TowerToolError(
86
+ 'tests-failed',
87
+ `test admission failed for ${mission.id}: ${discovery.receipt.error}; no child was started`,
88
+ )
89
+ }
90
+ }
91
+
92
+ let activation = null
93
+ let agentId = null
94
+ let entry = null
95
+ await tower.reserveRoster({
96
+ missionId: mission.id,
97
+ name,
98
+ role,
99
+ parentSessionId: String(exec.agent.id),
100
+ cwd: worktree.path,
101
+ branch: worktree.branch,
102
+ })
103
+ try {
104
+ activation = await subagents.startActivation({
105
+ provider,
106
+ label: name,
107
+ request: {
108
+ cwd: worktree.path,
109
+ agentPreset: preset,
110
+ prompt: [{ type: 'text', text: briefing }],
111
+ parent: exec.agent,
112
+ maxDepth: config.spawn.maxDepth ?? 1,
113
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
114
+ },
115
+ signal: exec.signal,
116
+ delivery: 'parent',
117
+ })
118
+ agentId = String(activation.childId)
119
+ entry = await tower.recordSpawn({
120
+ missionId: mission.id,
121
+ name,
122
+ role,
123
+ agentId,
124
+ parentSessionId: String(exec.agent.id),
125
+ cwd: worktree.path,
126
+ branch: worktree.branch,
127
+ takeover,
128
+ })
129
+ recordSpawnSuccess(tower.root, config.spawn.maxLiveWorkers)
130
+ } catch (error) {
131
+ if (classifyError(error).retryable === true) {
132
+ recordSpawnThrottle(tower.root, config.spawn.maxLiveWorkers)
133
+ }
134
+ // The child may already be running even when recordSpawn failed: stop
135
+ // it and mark the reserved entry failed so it cannot keep writing
136
+ // unobserved.
137
+ if (agentId) {
138
+ try {
139
+ await Promise.resolve(subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent }))
140
+ } catch {
141
+ // best effort; the error below is the actionable one
142
+ }
143
+ }
144
+ await tower
145
+ .recordSpawnOutcome({
146
+ missionId: mission.id,
147
+ name,
148
+ state: SPAWN_OUTCOME.failed,
149
+ detail: `spawn failed${agentId ? ` after activation ${agentId}` : ' before activation'}: ${String(error?.message ?? error).slice(0, 300)}`,
150
+ })
151
+ .catch(() => {})
152
+ throw error
153
+ }
154
+
155
+ const recordOutcome = (state, detail) =>
156
+ tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
157
+
158
+ const report = {
159
+ name,
160
+ agentId,
161
+ role,
162
+ missionId: mission.id,
163
+ preset,
164
+ provider,
165
+ maxDepth: config.spawn.maxDepth ?? 1,
166
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
167
+ worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
168
+ created: provisioned.created,
169
+ briefingChars: briefing.length,
170
+ takeover,
171
+ rosterEntry: entry,
172
+ }
173
+
174
+ return { report, activation, recordOutcome }
175
+ })
176
+ const { report, activation, recordOutcome } = spawned
177
+ if (args.wait !== true) {
178
+ activation.result
179
+ .then((result) => {
180
+ const state = isCompletedStopReason(result?.stopReason) ? SPAWN_OUTCOME.inactive : SPAWN_OUTCOME.failed
181
+ return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
182
+ })
183
+ .catch((error) => recordOutcome(SPAWN_OUTCOME.failed, String(error?.message ?? error)))
184
+ return report
185
+ }
186
+ return waitForSpawnResult({
187
+ activation,
188
+ report,
189
+ recordOutcome,
190
+ maxWaitMs: config.spawn.maxWaitMs ?? 90000,
191
+ requestedWaitMs: args.waitMs ?? 180000,
192
+ textOf,
193
+ })
194
+ },
195
+ })
196
+ }
@@ -0,0 +1,94 @@
1
+ // tower_status: dashboard and mission detail.
2
+ import { Tower } from '../app/tower.js'
3
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
+ import { classifyError } from '../domain/errors.js'
5
+ import { StatusInput } from '../domain/contracts/tool-inputs/index.js'
6
+ import { ROSTER_INACTIVE_STATES, isMarkdownFormat, isReservedState, isWorkerRole } from '../domain/vocabulary.js'
7
+
8
+ /**
9
+ * @param {any} ctx
10
+ * @param {{
11
+ * define: import('../types/index.js').DefineTool,
12
+ * workspaceHook: any,
13
+ * spawnBackoffView: any,
14
+ * observeActivity: any,
15
+ * }} deps
16
+ */
17
+ export function registerStatusTool(ctx, deps) {
18
+ const { define, workspaceHook, spawnBackoffView, observeActivity } = deps
19
+ define(ctx, {
20
+ name: 'tower_status',
21
+ description:
22
+ 'Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, paged mission summaries (scope/deps/tasks/worktree/owner), roster size, findings, config path and the activity tail. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; format:"markdown" returns the human-readable mission board. Read-only.',
23
+ parameters: StatusInput,
24
+ aliases: { mission_id: ['missionId'], include_gate: ['includeGate'] },
25
+ output: output(SCHEMAS.status),
26
+ async execute(args, exec) {
27
+ const tower = await Tower.open(args.repoRoot)
28
+ const boardPath = tower.boardFile()
29
+ const mode = workspaceHook?.modeInfo?.(exec) ?? null
30
+ const statusState = await tower.load()
31
+ const inflight = (statusState.roster ?? []).filter(
32
+ (entry) => isWorkerRole(entry.role) && !ROSTER_INACTIVE_STATES.includes(entry.state) && !isReservedState(entry.state),
33
+ ).length
34
+ const spawn = spawnBackoffView(tower.root, tower.config.spawn?.maxLiveWorkers, inflight)
35
+ if (args.missionId) {
36
+ const [status, detail] = await Promise.all([
37
+ tower.status({ missionIds: [args.missionId] }),
38
+ tower.missionDetail(args.missionId),
39
+ ])
40
+ return {
41
+ ...status,
42
+ missions: status.missions.filter((mission) => mission.id === args.missionId),
43
+ missionId: args.missionId,
44
+ detail,
45
+ boardPath,
46
+ mode,
47
+ spawn,
48
+ }
49
+ }
50
+ const state = await tower.load()
51
+ const total = state.missions.length
52
+ const offset = typeof args.offset === 'number' && Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 0
53
+ const limit = typeof args.limit === 'number' && Number.isInteger(args.limit) ? Math.max(1, Math.min(100, args.limit)) : 20
54
+ const pageIds = state.missions.slice(offset, offset + limit).map((mission) => mission.id)
55
+ const status = await tower.status({ missionIds: pageIds })
56
+ let page = status.missions
57
+ if (args.includeGate === true) {
58
+ const { activity, parentStatus } = await observeActivity(ctx, tower)
59
+ page = await Promise.all(
60
+ page.map(async (mission) => {
61
+ try {
62
+ const gate = await tower.evaluateGate({ missionId: mission.id, activity, parentStatus })
63
+ return {
64
+ ...mission,
65
+ gate: {
66
+ ok: gate.ok,
67
+ blockers: (gate.blockers ?? []).map((blocker) => ({ code: blocker.code, detail: blocker.detail })),
68
+ },
69
+ }
70
+ } catch (error) {
71
+ return { ...mission, gate: { ok: false, error: classifyError(error).message } }
72
+ }
73
+ }),
74
+ )
75
+ }
76
+ const result = {
77
+ ...status,
78
+ missions: page,
79
+ total,
80
+ offset,
81
+ nextOffset: offset + page.length < total ? offset + page.length : null,
82
+ boardPath,
83
+ mode,
84
+ spawn,
85
+ }
86
+ if (isMarkdownFormat(args.format)) {
87
+ const state = await tower.load()
88
+ const output = /** @type {any} */ (result)
89
+ output.markdown = tower.boardMarkdown(state)
90
+ }
91
+ return result
92
+ },
93
+ })
94
+ }
@@ -0,0 +1,42 @@
1
+ // tower_teardown — remove eligible mission worktrees.
2
+ //
3
+ // Input: TeardownInput (dryRun, force, exclude) from contracts.
4
+ // Output: removed/kept decisions for every mission.
5
+ // Authorization: tower only; live agents always keep their worktree.
6
+ // Side effects: removes worktrees/branches, persists activity.
7
+ // Failure modes: dirty without force, branch delete failure.
8
+ // Pure eligibility decisions live in domain/integration.
9
+ // See docs/reference/tools.generated.md for the public shape.
10
+ // tower_teardown: remove eligible mission worktrees.
11
+ import { Tower } from '../app/tower.js'
12
+ import { SCHEMAS, output } from '../domain/contracts/outputs.js'
13
+ import { TeardownInput } from '../domain/contracts/tool-inputs/index.js'
14
+
15
+ /**
16
+ * @param {any} ctx
17
+ * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any, observeActivity: any }} deps
18
+ */
19
+ export function registerTeardownTool(ctx, deps) {
20
+ const { define, actorFor, requireTowerActor, observeActivity } = deps
21
+ define(ctx, {
22
+ name: 'tower_teardown',
23
+ description:
24
+ 'Tear down eligible mission worktrees after their missions are merged or abandoned. A worktree whose roster agent is running is always kept (force cannot override); dirty worktrees need force; exclude keeps named missions. dry_run previews the remove/keep decisions without changing anything.',
25
+ parameters: TeardownInput,
26
+ aliases: {"dry_run":["dryRun"]},
27
+ board: true,
28
+ output: output(SCHEMAS.teardown),
29
+ async execute(args, exec) {
30
+ const tower = await Tower.open(args.repoRoot)
31
+ const actor = await actorFor(tower, exec)
32
+ requireTowerActor(actor, 'tear down worktrees')
33
+ const { activity } = await observeActivity(ctx, tower)
34
+ return tower.teardown({
35
+ force: args.force === true,
36
+ exclude: args.exclude,
37
+ dryRun: args.dry_run === true,
38
+ activity,
39
+ })
40
+ },
41
+ })
42
+ }