dsh-tower 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/COMPATIBILITY.md +5 -1
  3. package/CONTRIBUTING.md +64 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +7 -4
  6. package/README.md +7 -4
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/docs/README.md +6 -2
  44. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  45. package/docs/adr/0002-single-state-json.md +50 -0
  46. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  47. package/docs/adr/0004-surface-budget.md +50 -0
  48. package/docs/adr/README.md +51 -0
  49. package/docs/agents/workflow.md +51 -0
  50. package/docs/architecture/overview.md +167 -36
  51. package/docs/guide/hot-reload.md +1 -1
  52. package/docs/guide/release.md +1 -1
  53. package/docs/operations/incident.md +82 -0
  54. package/docs/operations/runbook.md +12 -3
  55. package/docs/reference/blockers.md +2 -2
  56. package/docs/reference/config.generated.md +1 -0
  57. package/docs/reference/doctor.md +48 -2
  58. package/docs/reference/errors.generated.md +41 -37
  59. package/docs/reference/errors.md +53 -41
  60. package/docs/reference/events.md +38 -6
  61. package/docs/reference/surface-budget.md +128 -0
  62. package/docs/reference/tools.generated.md +5 -4
  63. package/docs/reference/tools.md +4 -4
  64. package/docs/zh/guide/hot-reload.md +1 -1
  65. package/docs/zh/reference/blockers.md +2 -2
  66. package/docs/zh/reference/doctor.md +29 -1
  67. package/docs/zh/reference/tools.md +4 -4
  68. package/domain/batch.js +2 -2
  69. package/domain/config/schema.js +25 -1
  70. package/domain/contracts/outputs.js +6 -2
  71. package/domain/contracts/tool-inputs/ops.js +1 -0
  72. package/domain/errors.js +297 -42
  73. package/domain/events.js +207 -0
  74. package/domain/integration/gate.js +17 -2
  75. package/domain/integration/merge.js +35 -0
  76. package/domain/lockBudget.js +30 -0
  77. package/domain/missions/completion.js +14 -0
  78. package/domain/missions/lifecycle.js +9 -3
  79. package/domain/missions/plan.js +14 -0
  80. package/domain/missions/review.js +90 -7
  81. package/domain/read/views.js +3 -0
  82. package/domain/state/archive.js +1 -1
  83. package/domain/state.js +1 -1
  84. package/domain/surface.js +248 -0
  85. package/{lib → host}/doctor.js +116 -9
  86. package/{lib → host}/modeStore.js +1 -1
  87. package/{lib → host}/tool-registry.js +106 -31
  88. package/host/tower.js +32 -0
  89. package/index.js +12 -9
  90. package/package.json +10 -3
  91. package/ports/archive.js +18 -0
  92. package/ports/caseSensitivity.js +15 -0
  93. package/ports/clock.js +10 -2
  94. package/ports/config.js +19 -0
  95. package/ports/contract.js +38 -0
  96. package/ports/events.js +24 -0
  97. package/ports/ids.js +10 -2
  98. package/ports/index.js +54 -2
  99. package/ports/ownership.js +17 -0
  100. package/ports/state.js +16 -0
  101. package/ports/store.js +52 -3
  102. package/ports/vcs.js +25 -4
  103. package/scripts/architecture-waivers.json +19 -0
  104. package/scripts/architecture.mjs +634 -23
  105. package/scripts/check-docs.mjs +8 -35
  106. package/scripts/check-pack.mjs +68 -39
  107. package/scripts/check-philosophy.mjs +266 -0
  108. package/scripts/domain-metrics.mjs +13 -4
  109. package/scripts/generate-config-reference.mjs +14 -1
  110. package/scripts/generate-error-reference.mjs +68 -14
  111. package/scripts/generate-events-reference.mjs +83 -0
  112. package/scripts/generate-tools-reference.mjs +11 -0
  113. package/scripts/issue-audit.mjs +2 -2
  114. package/scripts/lib/surface.mjs +87 -0
  115. package/scripts/pack-manifest.txt +58 -22
  116. package/scripts/regression-audit.mjs +164 -0
  117. package/scripts/release-check.mjs +1 -1
  118. package/scripts/surface-baseline.json +21 -0
  119. package/scripts/surface-baseline.mjs +120 -0
  120. package/tools/adopt.js +2 -3
  121. package/tools/batch.js +2 -3
  122. package/tools/complete.js +2 -3
  123. package/tools/diff.js +3 -3
  124. package/tools/doctor.js +7 -10
  125. package/tools/finding.js +2 -3
  126. package/tools/findings.js +3 -3
  127. package/tools/import-legacy.js +3 -5
  128. package/tools/inbox.js +3 -3
  129. package/tools/index.js +7 -7
  130. package/tools/init.js +3 -6
  131. package/tools/interrupt.js +2 -3
  132. package/tools/legacy-map.js +3 -5
  133. package/tools/merge.js +3 -3
  134. package/tools/mission.js +2 -3
  135. package/tools/plan.js +6 -5
  136. package/tools/preflight.js +2 -3
  137. package/tools/rebase.js +3 -3
  138. package/tools/recover.js +38 -13
  139. package/tools/reload.js +3 -6
  140. package/tools/resource.js +3 -5
  141. package/tools/review.js +3 -4
  142. package/tools/roster.js +2 -3
  143. package/tools/send.js +2 -2
  144. package/tools/spawn.js +8 -5
  145. package/tools/status.js +3 -3
  146. package/tools/teardown.js +2 -3
  147. package/tools/test.js +2 -2
  148. package/types/index.d.ts +1 -1
  149. package/ui-bundle/README.md +37 -3
  150. package/ui-bundle/client.cards.js +356 -0
  151. package/ui-bundle/client.denied.js +142 -0
  152. package/ui-bundle/client.dock.js +333 -0
  153. package/ui-bundle/client.helpers.js +271 -0
  154. package/ui-bundle/client.js +79 -1747
  155. package/ui-bundle/client.locales.js +247 -0
  156. package/ui-bundle/client.mode.js +206 -0
  157. package/ui-bundle/client.overlay.js +64 -0
  158. package/ui-bundle/client.styles.js +151 -0
  159. package/ui-bundle/client.toolview.js +82 -0
  160. package/ui-bundle/package.json +1 -0
  161. package/lib/persistence.js +0 -356
  162. package/lib/store.js +0 -133
  163. /package/{lib → adapters}/stateFile.js +0 -0
  164. /package/{lib → app}/briefing.js +0 -0
  165. /package/{lib → app}/spawn-support.js +0 -0
  166. /package/{lib → host}/compatibility.js +0 -0
  167. /package/{lib → host}/config.js +0 -0
  168. /package/{lib → host}/hmr.js +0 -0
  169. /package/{lib → host}/modeState.js +0 -0
  170. /package/{lib → host}/presets.js +0 -0
@@ -0,0 +1,333 @@
1
+ // dsh-tower-ui 0.2.2 — the composer dock surface (#11 + latest summary).
2
+ //
3
+ // The chip is history-derived; the dock only reads the `useChat` legacy slice
4
+ // and never polls or fetches Host state. Package-local chunk (#71).
5
+ window.__ModuleLoader__.load({
6
+ id: 'dsh-tower-ui',
7
+ chunk: 'client.dock.js',
8
+ factory() {
9
+ function createDock(env) {
10
+ const { React, h, translate, toolLabel, seatT, ST, dotStyle, helpers, mode } = env
11
+ const { modeModelFromLegacy } = env
12
+ const {
13
+ isPlainObject,
14
+ parsePayload,
15
+ isTowerTool,
16
+ countsOf,
17
+ toneForStatus,
18
+ shortCommit,
19
+ blockersOf,
20
+ } = helpers
21
+ const { nextDockWriter, publishMode, clearMode } = mode
22
+
23
+ function renderDockHint(t) {
24
+ return h(
25
+ 'div',
26
+ {
27
+ 'data-tower-ui': 'dock-hint',
28
+ title: translate(t, 'dock.hint.title'),
29
+ style: ST.hint,
30
+ },
31
+ h('span', { 'aria-hidden': true }, '⌘'),
32
+ h('span', null, '/tower'),
33
+ )
34
+ }
35
+ function latestTowerSignal(legacy) {
36
+ if (!isPlainObject(legacy)) return null
37
+ const running = Array.isArray(legacy.runningCalls) ? legacy.runningCalls : []
38
+ for (let index = running.length - 1; index >= 0; index -= 1) {
39
+ const block = running[index]
40
+ if (isPlainObject(block) && isTowerTool(block.name)) return { name: block.name, phase: 'start', block }
41
+ }
42
+ const nodes = Array.isArray(legacy.nodes) ? legacy.nodes : []
43
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
44
+ const block = nodes[index]
45
+ const name = isPlainObject(block) && isPlainObject(block.call) ? block.call.name : undefined
46
+ if (isTowerTool(name)) return { name, phase: 'result', block }
47
+ }
48
+ return null
49
+ }
50
+ function dockResultModel(toolName, payload) {
51
+ if (!isPlainObject(payload)) return { tool: toolName, running: false, tone: 'none', key: 'dock.generic' }
52
+ if (payload.__error === true) return { tool: toolName, running: false, tone: 'error', key: 'dock.generic' }
53
+ if (typeof payload.text === 'string') {
54
+ return { tool: toolName, running: false, tone: 'idle', key: 'dock.generic' }
55
+ }
56
+ const blockers = blockersOf(payload.blockers)
57
+ if (toolName === 'tower_status') {
58
+ const counts = countsOf(payload)
59
+ const open = typeof payload.openTasks === 'number' ? payload.openTasks : 0
60
+ if (counts !== null) {
61
+ return { tool: toolName, running: false, tone: open > 0 ? 'warn' : 'ok', key: 'dock.statusCounts', params: { total: counts.total, active: counts.active, open } }
62
+ }
63
+ return { tool: toolName, running: false, tone: 'ok', key: 'dock.generic' }
64
+ }
65
+ if (toolName === 'tower_roster') {
66
+ const roster = Array.isArray(payload.roster) ? payload.roster : []
67
+ let live = 0
68
+ for (const entry of roster) if (isPlainObject(entry) && entry.activity === 'running') live += 1
69
+ return { tool: toolName, running: false, tone: live > 0 ? 'ok' : 'idle', key: 'dock.rosterCounts', params: { live, total: roster.length } }
70
+ }
71
+ if (toolName === 'tower_review' && isPlainObject(payload.review)) {
72
+ return {
73
+ tool: toolName,
74
+ running: false,
75
+ tone: toneForStatus(payload.review.status),
76
+ key: 'dock.review',
77
+ params: {
78
+ status: typeof payload.review.status === 'string' ? payload.review.status : '?',
79
+ merge: typeof payload.review.mergeVerdict === 'string' ? payload.review.mergeVerdict : '?',
80
+ },
81
+ }
82
+ }
83
+ if (toolName === 'tower_preflight') {
84
+ return payload.ok === true
85
+ ? { tool: toolName, running: false, tone: 'ok', key: 'dock.ready' }
86
+ : { tool: toolName, running: false, tone: 'error', key: 'dock.blocked', params: { count: blockers.length } }
87
+ }
88
+ if (toolName === 'tower_merge') {
89
+ return payload.ok === true
90
+ ? { tool: toolName, running: false, tone: 'ok', key: 'dock.merged', params: { commit: shortCommit(payload.mergedCommit) } }
91
+ : { tool: toolName, running: false, tone: 'error', key: 'dock.blocked', params: { count: blockers.length } }
92
+ }
93
+ if (toolName === 'tower_complete' && isPlainObject(payload.mission)) {
94
+ return {
95
+ tool: toolName,
96
+ running: false,
97
+ tone: 'ok',
98
+ key: 'dock.complete',
99
+ params: { mission: typeof payload.mission.id === 'string' ? payload.mission.id : '?', commits: typeof payload.commits === 'number' ? payload.commits : 0 },
100
+ }
101
+ }
102
+ if (toolName === 'tower_mission' && isPlainObject(payload.mission)) {
103
+ return {
104
+ tool: toolName,
105
+ running: false,
106
+ tone: toneForStatus(payload.mission.status),
107
+ key: 'dock.mission',
108
+ params: { id: typeof payload.mission.id === 'string' ? payload.mission.id : '?', status: typeof payload.mission.status === 'string' ? payload.mission.status : '?' },
109
+ }
110
+ }
111
+ if (toolName === 'tower_spawn') {
112
+ return {
113
+ tool: toolName,
114
+ running: false,
115
+ tone: 'ok',
116
+ key: 'dock.spawn',
117
+ params: {
118
+ role: typeof payload.role === 'string' ? payload.role : '?',
119
+ name: typeof payload.name === 'string' ? payload.name : '?',
120
+ mission: typeof payload.missionId === 'string' ? payload.missionId : '?',
121
+ },
122
+ }
123
+ }
124
+ if (toolName === 'tower_teardown') {
125
+ return {
126
+ tool: toolName,
127
+ running: false,
128
+ tone: 'ok',
129
+ key: 'dock.removed',
130
+ params: { count: Array.isArray(payload.removed) ? payload.removed.length : 0 },
131
+ }
132
+ }
133
+ if (toolName === 'tower_interrupt') {
134
+ const name = isPlainObject(payload.entry) && typeof payload.entry.name === 'string' ? payload.entry.name : '?'
135
+ return payload.interrupted === true
136
+ ? { tool: toolName, running: false, tone: 'warn', key: 'dock.interrupt', params: { name } }
137
+ : { tool: toolName, running: false, tone: 'idle', key: 'dock.generic' }
138
+ }
139
+ if (toolName === 'tower_doctor') {
140
+ return {
141
+ tool: toolName,
142
+ running: false,
143
+ tone: payload.ok === true ? 'ok' : 'warn',
144
+ key: 'dock.doctor',
145
+ params: { status: payload.ok === true ? 'ok' : 'issues' },
146
+ }
147
+ }
148
+ if (toolName === 'tower_batch') {
149
+ const summary = isPlainObject(payload.summary) ? payload.summary : isPlainObject(payload.batch) ? payload.batch : null
150
+ const failed = summary !== null && Array.isArray(summary.failedChecks) ? summary.failedChecks.length : 0
151
+ const pending = summary !== null && Array.isArray(summary.pendingChecks) ? summary.pendingChecks.length : 0
152
+ const tone = payload.ok === true && failed === 0 && pending === 0 ? 'ok' : failed > 0 ? 'error' : 'warn'
153
+ return {
154
+ tool: toolName,
155
+ running: false,
156
+ tone,
157
+ key: 'dock.batch',
158
+ params: { status: summary !== null && typeof summary.status === 'string' ? summary.status : payload.ok === true ? 'ok' : 'issues' },
159
+ }
160
+ }
161
+ return { tool: toolName, running: false, tone: 'idle', key: 'dock.generic' }
162
+ }
163
+ function dockSelector(snapshot) {
164
+ try {
165
+ const legacy = snapshot ? snapshot.legacy : undefined
166
+ const modeModel = modeModelFromLegacy(legacy, helpers)
167
+ let summary = null
168
+ // While the chip is on it is the dock's message; the summary returns
169
+ // after /tower off.
170
+ if (!(modeModel.known === true && modeModel.on === true)) {
171
+ const signal = latestTowerSignal(legacy)
172
+ if (signal !== null) {
173
+ summary =
174
+ signal.phase !== 'result'
175
+ ? { tool: signal.name, running: true, tone: 'idle' }
176
+ : dockResultModel(signal.name, parsePayload(signal.block))
177
+ }
178
+ }
179
+ return JSON.stringify({
180
+ mode: modeModel,
181
+ summary,
182
+ // A Tower result with no mode event cannot prove the stance; surface
183
+ // that explicitly instead of pretending the session is off.
184
+ uncertain: modeModel.known !== true && summary !== null && summary.running !== true,
185
+ })
186
+ } catch (error) {
187
+ return ''
188
+ }
189
+ }
190
+ function modeStorageKey(sessionId) {
191
+ return 'dsh-tower-ui:mode:' + String(sessionId)
192
+ }
193
+ function readStoredMode(sessionId) {
194
+ if (typeof sessionId !== 'string' || sessionId === '') return null
195
+ try {
196
+ const raw = globalThis.localStorage && globalThis.localStorage.getItem(modeStorageKey(sessionId))
197
+ if (!raw) return null
198
+ const parsed = JSON.parse(raw)
199
+ if (!isPlainObject(parsed) || typeof parsed.at !== 'number') return null
200
+ if (Date.now() - parsed.at > 7 * 24 * 60 * 60 * 1000) return null
201
+ return parsed
202
+ } catch {
203
+ return null
204
+ }
205
+ }
206
+ function writeStoredMode(sessionId, current) {
207
+ if (typeof sessionId !== 'string' || sessionId === '') return
208
+ try {
209
+ if (!globalThis.localStorage) return
210
+ if (current.known === true && (current.on === true || current.on === false)) {
211
+ globalThis.localStorage.setItem(modeStorageKey(sessionId), JSON.stringify({
212
+ on: current.on,
213
+ denied: current.denied ?? null,
214
+ repo: current.repo ?? null,
215
+ lock: current.lock ?? null,
216
+ lastBy: current.lastBy ?? null,
217
+ lastReason: current.lastReason ?? null,
218
+ at: Date.now(),
219
+ }))
220
+ }
221
+ } catch {
222
+ // storage is best effort; the history-derived model remains the source
223
+ }
224
+ }
225
+ function modeTooltip(t, modeModel) {
226
+ const parts = [translate(t, 'dock.mode.tooltip')]
227
+ if (modeModel.source === 'stored') parts.push(translate(t, 'dock.mode.lastKnown'))
228
+ if (typeof modeModel.repo === 'string' && modeModel.repo !== '') parts.push(translate(t, 'dock.mode.repo', { repo: modeModel.repo }))
229
+ if (isPlainObject(modeModel.lock)) {
230
+ parts.push(translate(t, modeModel.lock.held === true ? 'dock.mode.lockHeld' : 'dock.mode.lockFree'))
231
+ }
232
+ if (typeof modeModel.lastBy === 'string' && modeModel.lastBy !== '') parts.push(translate(t, 'dock.mode.lastBy', { by: modeModel.lastBy }))
233
+ return parts.join(translate(t, 'card.sep'))
234
+ }
235
+ function renderModeChip(t, modeModel) {
236
+ // Non-interactive on purpose: inputActions.submit() would submit (and
237
+ // clobber) the composer draft; the tooltip is the affordance instead.
238
+ return h(
239
+ 'div',
240
+ {
241
+ 'data-tower-ui': 'dock-mode',
242
+ 'data-tower-mode': modeModel.on === true ? 'on' : 'off',
243
+ role: 'status',
244
+ 'aria-live': 'polite',
245
+ title: modeTooltip(t, modeModel),
246
+ style: ST.modeChip,
247
+ },
248
+ h('span', { 'aria-hidden': true, style: dotStyle('ok') }),
249
+ h('span', { style: ST.modeChipLabel }, translate(t, 'card.title')),
250
+ h('span', { style: ST.dim }, '·'),
251
+ h('span', { style: ST.modeChipOn }, translate(t, 'dock.mode.onLabel')),
252
+ )
253
+ }
254
+ function renderModeUnknown(t) {
255
+ return h(
256
+ 'div',
257
+ {
258
+ 'data-tower-ui': 'dock-mode',
259
+ 'data-tower-mode': 'unknown',
260
+ role: 'status',
261
+ 'aria-live': 'polite',
262
+ title: translate(t, 'dock.mode.unknownTooltip'),
263
+ style: ST.modeChip,
264
+ },
265
+ h('span', { 'aria-hidden': true, style: dotStyle('idle') }),
266
+ h('span', { style: ST.modeChipLabel }, translate(t, 'card.title')),
267
+ h('span', { style: ST.dim }, '·'),
268
+ h('span', { style: ST.modeChipOn }, translate(t, 'dock.mode.unknownLabel')),
269
+ )
270
+ }
271
+ function TowerDock(props) {
272
+ const t = seatT(props)
273
+ if (!props || typeof props.useChat !== 'function') return renderDockHint(t)
274
+ return h(TowerDockLive, Object.assign({}, props, { t }))
275
+ }
276
+ function TowerDockLive(props) {
277
+ const t = seatT(props)
278
+ const raw = props.useChat(dockSelector)
279
+ let model = null
280
+ if (typeof raw === 'string' && raw !== '') {
281
+ try {
282
+ model = JSON.parse(raw)
283
+ } catch (error) {
284
+ model = null
285
+ }
286
+ }
287
+ const modeFromHistory = isPlainObject(model) && isPlainObject(model.mode) ? model.mode : { known: false, on: null }
288
+ const stored = modeFromHistory.known !== true ? readStoredMode(props.sessionId) : null
289
+ const current = stored
290
+ ? Object.assign({}, modeFromHistory, stored, { known: true, source: 'stored' })
291
+ : modeFromHistory
292
+ writeStoredMode(props.sessionId, modeFromHistory)
293
+ const writerState = React.useState(function () {
294
+ return nextDockWriter()
295
+ })
296
+ const writer = writerState[0]
297
+ React.useEffect(
298
+ function () {
299
+ // Publish only the history-derived stance. A stored last-known value
300
+ // is display-only: it must not shadow native toolviews.
301
+ publishMode(writer, modeFromHistory)
302
+ return function () {
303
+ clearMode(writer)
304
+ }
305
+ },
306
+ [JSON.stringify(modeFromHistory)],
307
+ )
308
+ if (current.known === true && current.on === true) return renderModeChip(t, current)
309
+ if (isPlainObject(model) && model.uncertain === true) return renderModeUnknown(t)
310
+ const summary = isPlainObject(model) ? model.summary : null
311
+ if (!isPlainObject(summary)) return renderDockHint(t)
312
+ const text = summary.running === true
313
+ ? translate(t, 'dock.running', { tool: toolLabel(t, summary.tool) })
314
+ : summary.key
315
+ ? translate(t, summary.key, summary.params)
316
+ : translate(t, 'dock.generic')
317
+ const tone = summary.running === true ? 'idle' : summary.tone
318
+ return h(
319
+ 'div',
320
+ { 'data-tower-ui': 'dock', style: ST.dock, title: toolLabel(t, summary.tool) + translate(t, 'card.sep') + text },
321
+ h('span', { 'aria-hidden': true, style: dotStyle(tone) }),
322
+ h('span', { style: ST.dockTool }, toolLabel(t, summary.tool)),
323
+ h('span', { style: ST.dim }, '·'),
324
+ h('span', { style: ST.dockText }, text),
325
+ )
326
+ }
327
+
328
+ return { TowerDock }
329
+ }
330
+
331
+ return { createDock }
332
+ },
333
+ })
@@ -0,0 +1,271 @@
1
+ // dsh-tower-ui 0.2.2 — tool vocabulary and the defensive payload helpers.
2
+ //
3
+ // Every helper is defensive: a tool result is durable user-facing data and may
4
+ // be an older shape, a partial stream, an error, or plain text instead of
5
+ // JSON. Package-local chunk loaded by client.js (#71); the translation helpers
6
+ // are injected once so the module stays free of top-level state.
7
+ window.__ModuleLoader__.load({
8
+ id: 'dsh-tower-ui',
9
+ chunk: 'client.helpers.js',
10
+ factory() {
11
+ const MAX_TEXT = 150
12
+ const TOOL_NAMES = [
13
+ 'tower_status',
14
+ 'tower_roster',
15
+ 'tower_spawn',
16
+ 'tower_mission',
17
+ 'tower_complete',
18
+ 'tower_review',
19
+ 'tower_preflight',
20
+ 'tower_merge',
21
+ 'tower_teardown',
22
+ 'tower_interrupt',
23
+ 'tower_doctor',
24
+ 'tower_batch',
25
+ ]
26
+ const TOOL_SET = new Set(TOOL_NAMES)
27
+ // Mirrors host/config.js DEFAULT_DENY_TOOLS; overridden by the
28
+ // `deniedWhileActive` list of the latest `tower_mode` output when present.
29
+ const DEFAULT_DENIED_TOOLS = [
30
+ 'create_goal',
31
+ 'todo_write',
32
+ 'team_task_create',
33
+ 'team_task_update',
34
+ 'team_task_reopen',
35
+ 'team_task_list',
36
+ 'spawn_teammate',
37
+ 'wait_agent',
38
+ 'workflow',
39
+ 'ralph',
40
+ 'subagent',
41
+ 'subagent_fork',
42
+ ]
43
+
44
+ function createHelpers(translate) {
45
+ function isTowerTool(name) {
46
+ return typeof name === 'string' && TOOL_SET.has(name)
47
+ }
48
+ function truncate(value, max) {
49
+ const text = String(value === undefined || value === null ? '' : value)
50
+ .replace(/\s+/g, ' ')
51
+ .trim()
52
+ const limit = max === undefined ? MAX_TEXT : max
53
+ return text.length > limit ? text.slice(0, limit - 1) + '…' : text
54
+ }
55
+ function firstLine(value, max) {
56
+ const text = String(value === undefined || value === null ? '' : value)
57
+ const line = text.split('\n')[0]
58
+ return truncate(line, max)
59
+ }
60
+ function shortCommit(value) {
61
+ if (typeof value !== 'string' || value === '') return ''
62
+ return value.slice(0, 7)
63
+ }
64
+ function isPlainObject(value) {
65
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
66
+ }
67
+ function textOfBlock(block) {
68
+ if (!isPlainObject(block)) return ''
69
+ const parts = []
70
+ if (Array.isArray(block.content)) {
71
+ for (const item of block.content) {
72
+ if (!isPlainObject(item)) continue
73
+ if (typeof item.text === 'string') parts.push(item.text)
74
+ }
75
+ }
76
+ if (parts.length === 0 && typeof block.text === 'string') parts.push(block.text)
77
+ return parts.join('\n')
78
+ }
79
+ function errorOfBlock(block) {
80
+ if (!isPlainObject(block)) return null
81
+ if (isPlainObject(block.error)) return block.error
82
+ if (block.isError === true) return { name: 'ToolError', code: 'error' }
83
+ return null
84
+ }
85
+ function parsePayload(block) {
86
+ if (!isPlainObject(block)) return null
87
+ const error = errorOfBlock(block)
88
+ if (error !== null) return { __error: true, error }
89
+ const text = textOfBlock(block).trim()
90
+ if (text === '') return null
91
+ if (text[0] === '{' || text[0] === '[') {
92
+ try {
93
+ const parsed = JSON.parse(text)
94
+ if (isPlainObject(parsed)) return parsed
95
+ if (Array.isArray(parsed)) return { items: parsed }
96
+ } catch (error) {
97
+ /* fall through to text */
98
+ }
99
+ }
100
+ return { text }
101
+ }
102
+ function blockersOf(value) {
103
+ if (!Array.isArray(value)) return []
104
+ const out = []
105
+ for (const entry of value) {
106
+ if (typeof entry === 'string' && entry.trim() !== '') out.push(entry.trim())
107
+ else if (isPlainObject(entry)) {
108
+ const code = typeof entry.code === 'string' ? entry.code : 'blocker'
109
+ const detail = typeof entry.detail === 'string' ? entry.detail : ''
110
+ out.push(detail === '' ? code : code + ': ' + detail)
111
+ }
112
+ }
113
+ return out
114
+ }
115
+ function missionBlocksOf(value) {
116
+ if (!Array.isArray(value)) return []
117
+ return value.filter((entry) => typeof entry === 'string' && entry.trim() !== '').map((entry) => entry.trim())
118
+ }
119
+ function countsOf(payload) {
120
+ if (!isPlainObject(payload) || !isPlainObject(payload.counts)) return null
121
+ const counts = payload.counts
122
+ let total = 0
123
+ const byStatus = {}
124
+ let hasAny = false
125
+ for (const key of Object.keys(counts)) {
126
+ const value = counts[key]
127
+ if (typeof value !== 'number' || !Number.isFinite(value)) continue
128
+ hasAny = true
129
+ byStatus[key] = value
130
+ total += value
131
+ }
132
+ if (!hasAny) return null
133
+ return { total, byStatus, active: typeof byStatus.active === 'number' ? byStatus.active : 0 }
134
+ }
135
+ function taskCountsOf(tasks) {
136
+ if (Array.isArray(tasks)) {
137
+ const counts = { total: tasks.length, done: 0, open: 0, dropped: 0 }
138
+ for (const task of tasks) {
139
+ const status = isPlainObject(task) ? task.status : undefined
140
+ if (status === 'done') counts.done += 1
141
+ else if (status === 'dropped') counts.dropped += 1
142
+ else counts.open += 1
143
+ }
144
+ return counts
145
+ }
146
+ if (isPlainObject(tasks)) {
147
+ const total = typeof tasks.total === 'number' ? tasks.total : null
148
+ if (total === null) return null
149
+ const done = typeof tasks.done === 'number' ? tasks.done : 0
150
+ const open = typeof tasks.open === 'number' ? tasks.open : Math.max(0, total - done - (typeof tasks.dropped === 'number' ? tasks.dropped : 0))
151
+ return { total, done, open, dropped: typeof tasks.dropped === 'number' ? tasks.dropped : 0 }
152
+ }
153
+ return null
154
+ }
155
+ function toneForStatus(status) {
156
+ if (typeof status !== 'string') return 'none'
157
+ if (/^(merged|completed|clean|done|fixed|ok)$/.test(status)) return 'ok'
158
+ if (/^(blocked|abandoned|failed)$/.test(status) || /^p1-/.test(status)) return 'error'
159
+ if (/^(paused|stale|missing|p2-)/.test(status)) return 'warn'
160
+ return 'idle'
161
+ }
162
+ function toneForPayload(payload) {
163
+ if (!isPlainObject(payload)) return 'none'
164
+ if (payload.ok === false) return 'error'
165
+ if (blockersOf(payload.blockers).length > 0) return 'error'
166
+ if (payload.ok === true) return 'ok'
167
+ if (isPlainObject(payload.review)) return toneForStatus(payload.review.status)
168
+ if (isPlainObject(payload.mission)) return toneForStatus(payload.mission.status)
169
+ if (isPlainObject(payload.review) === false && payload.review === null) return 'idle'
170
+ return 'idle'
171
+ }
172
+ function checkEntries(checks) {
173
+ if (!Array.isArray(checks)) return []
174
+ const out = []
175
+ for (const entry of checks) {
176
+ if (typeof entry === 'string') {
177
+ const text = entry.trim()
178
+ if (text !== '') out.push({ name: truncate(text, 80), ok: null, code: null, detail: '' })
179
+ continue
180
+ }
181
+ if (!isPlainObject(entry)) continue
182
+ const name =
183
+ typeof entry.name === 'string' && entry.name !== ''
184
+ ? entry.name
185
+ : typeof entry.id === 'string' && entry.id !== ''
186
+ ? entry.id
187
+ : 'check'
188
+ const code = typeof entry.exitCode === 'number' ? entry.exitCode : null
189
+ const ok =
190
+ code !== null
191
+ ? code === 0
192
+ : entry.status === 'pass'
193
+ ? true
194
+ : entry.status === 'fail'
195
+ ? false
196
+ : entry.ok === true
197
+ ? true
198
+ : entry.ok === false
199
+ ? false
200
+ : null
201
+ out.push({
202
+ name: truncate(name, 80),
203
+ ok,
204
+ code,
205
+ detail: typeof entry.detail === 'string' ? entry.detail : typeof entry.evidence === 'string' ? entry.evidence : '',
206
+ })
207
+ }
208
+ return out
209
+ }
210
+ function checkText(t, entry) {
211
+ const base =
212
+ entry.code !== null
213
+ ? translate(t, entry.ok ? 'card.checkOk' : 'card.checkFail', {
214
+ name: entry.name,
215
+ code: entry.code,
216
+ })
217
+ : entry.name
218
+ return entry.detail ? truncate(base + ': ' + entry.detail, 120) : base
219
+ }
220
+ function checkSummary(t, checks) {
221
+ const entries = checkEntries(checks)
222
+ if (entries.length === 0) return ''
223
+ const passed = entries.filter((entry) => entry.ok === true).length
224
+ const known = entries.filter((entry) => entry.ok !== null).length
225
+ if (known > 0) return translate(t, 'card.checksCount', { passed, total: known })
226
+ return String(entries.length)
227
+ }
228
+ function missionLine(t, mission) {
229
+ if (!isPlainObject(mission)) return ''
230
+ const id = typeof mission.id === 'string' ? mission.id : translate(t, 'card.missionMissing')
231
+ const status = typeof mission.status === 'string' ? mission.status : translate(t, 'card.unknown')
232
+ const parts = [translate(t, 'card.missionLine', { id, status })]
233
+ const tasks = taskCountsOf(mission.tasks)
234
+ if (tasks !== null && tasks.total > 0) {
235
+ parts.push(translate(t, 'card.tasks', { done: tasks.done, total: tasks.total }))
236
+ }
237
+ if (missionBlocksOf(mission.blockers).length > 0) {
238
+ parts.push(String(missionBlocksOf(mission.blockers).length) + ' ⚠')
239
+ }
240
+ return parts.join(translate(t, 'card.sep'))
241
+ }
242
+
243
+ return {
244
+ MAX_TEXT,
245
+ TOOL_NAMES,
246
+ TOOL_SET,
247
+ DEFAULT_DENIED_TOOLS,
248
+ isTowerTool,
249
+ truncate,
250
+ firstLine,
251
+ shortCommit,
252
+ isPlainObject,
253
+ textOfBlock,
254
+ errorOfBlock,
255
+ parsePayload,
256
+ blockersOf,
257
+ missionBlocksOf,
258
+ countsOf,
259
+ taskCountsOf,
260
+ toneForStatus,
261
+ toneForPayload,
262
+ checkEntries,
263
+ checkText,
264
+ checkSummary,
265
+ missionLine,
266
+ }
267
+ }
268
+
269
+ return { MAX_TEXT, TOOL_NAMES, TOOL_SET, DEFAULT_DENIED_TOOLS, createHelpers }
270
+ },
271
+ })