dsh-tower 2.0.4 → 2.1.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 (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -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 +9 -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 +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
package/app/init.js ADDED
@@ -0,0 +1,291 @@
1
+ // `tower_init` use case, split into three phases (#51):
2
+ //
3
+ // prepare — dirty/committed/base/snapshot decisions, all lock-free (no state
4
+ // lock and no git lock; git reads only).
5
+ // act — the only Git mutations (stash/update-ref/for-each-ref) run under
6
+ // the repo-scoped git mutex, never under the state lock.
7
+ // commit — one short state lock writes/claims/reanchors, revalidating the
8
+ // state revision captured by prepare.
9
+ import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl } from '../lib/store.js'
10
+ import { detectCaseSensitivity } from '../lib/caseSensitivity.js'
11
+ import { validateState } from '../domain/state.js'
12
+ import { TowerToolError } from '../domain/errors.js'
13
+ import {
14
+ SNAPSHOT_REF_PREFIX,
15
+ caseProbeActivityText,
16
+ claimCaller,
17
+ createdInitResult,
18
+ existingInitResult,
19
+ existingInitWriteResult,
20
+ explicitBase,
21
+ fallbackBase,
22
+ initActivityText,
23
+ initialState,
24
+ invalidBaseMessage,
25
+ ownedByOther,
26
+ ownerClaimActivityText,
27
+ reanchorActivityText,
28
+ reanchorBlockedMessage,
29
+ shouldReanchor,
30
+ shouldSnapshot,
31
+ snapshotRefFor,
32
+ } from '../domain/integration/init.js'
33
+
34
+ function fail(message, code = 'invalid-argument') {
35
+ throw new TowerToolError(code, message)
36
+ }
37
+
38
+ /**
39
+ * Probe the checkout's own filesystem through the injected seam. The default
40
+ * composition uses the real probe; a composition that predates #90 keeps
41
+ * working through the fallback.
42
+ * @param {any} tower
43
+ * @returns {Promise<boolean | null>}
44
+ */
45
+ async function detectCaseMode(tower) {
46
+ const detect = tower.deps?.caseSensitivity?.detect
47
+ if (typeof detect === 'function') return detect(tower.root)
48
+ return detectCaseSensitivity(tower.root)
49
+ }
50
+
51
+ /** Phase 1 of `Tower.init` (#51). A read-only init returns its result here. */
52
+ async function prepareInit(tower, input) {
53
+ const dirty = !(await tower.isClean(tower.root))
54
+ const committed = ((await tower.tryGit(tower.root, ['rev-list', '-n', '1', '--all'])) ?? '').trim().length > 0
55
+
56
+ const resolveBase = async () => {
57
+ const explicit = explicitBase(input.base)
58
+ if (explicit) {
59
+ const message = invalidBaseMessage(explicit)
60
+ if (message) fail(message)
61
+ if (committed && (await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${explicit}`])) === null) {
62
+ fail(`base branch "${explicit}" does not exist locally`)
63
+ }
64
+ return explicit
65
+ }
66
+ const fallback = fallbackBase({ branch: tower.branch, committed })
67
+ if ('error' in fallback) fail(fallback.error)
68
+ return /** @type {{ base: string }} */ (fallback).base
69
+ }
70
+
71
+ if (await exists(tower.paths.state)) {
72
+ const state = await tower.load()
73
+ const owner = state.towerAgentId ? String(state.towerAgentId) : null
74
+ const caller = input.towerAgentId ? String(input.towerAgentId) : null
75
+ if (ownedByOther(owner, caller)) {
76
+ fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
77
+ }
78
+ // "Detect once": a state that already carries a boolean is never re-probed,
79
+ // so a transient probe failure cannot silently flip the comparison mode.
80
+ const known = state.caseInsensitive === true || state.caseInsensitive === false
81
+ const caseInsensitive = known ? null : await detectCaseMode(tower)
82
+ let reanchorBase = null
83
+ if (input.reanchor === true) {
84
+ const refusal = reanchorBlockedMessage(state.missions)
85
+ if (refusal) fail(refusal, 'invalid-transition')
86
+ reanchorBase = await resolveBase()
87
+ }
88
+ const claim = claimCaller(owner, caller) !== null
89
+ const reanchor = shouldReanchor({ reanchor: input.reanchor, reanchorBase, currentBase: state.base })
90
+ if (!claim && !reanchor && caseInsensitive === null) {
91
+ return { early: existingInitResult({ state, statePath: tower.paths.state, dirty }) }
92
+ }
93
+ return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty, caseInsensitive }
94
+ }
95
+
96
+ return {
97
+ create: true,
98
+ base: await resolveBase(),
99
+ snapshot: shouldSnapshot({ snapshotDirty: input.snapshotDirty, dirty }),
100
+ dirty,
101
+ caseInsensitive: await detectCaseMode(tower),
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Phase 2a of `Tower.init` (#51): create the dirty snapshot and its keeping ref
107
+ * under the git mutex. The prepared "no state file yet" decision is
108
+ * revalidated first so a concurrent init cannot leave an unreferenced snapshot.
109
+ */
110
+ async function actInitCreate(tower, prepared) {
111
+ return withGitLock(tower.paths, async () => {
112
+ if (await exists(tower.paths.state)) return { stale: true }
113
+ if (!prepared.snapshot) return { snapshotCommit: null, snapshotRef: null }
114
+ const snapshotCommit = ((await tower.tryGit(tower.root, ['stash', 'create'])) ?? '').trim() || null
115
+ if (!snapshotCommit) {
116
+ fail('snapshotDirty was requested but no snapshot commit could be created; commit or stash the working tree first')
117
+ }
118
+ // A dangling commit can be pruned by git gc; keep a ref so the mission
119
+ // baseline stays reachable for the life of the state.
120
+ const snapshotRef = snapshotRefFor(snapshotCommit)
121
+ await tower.git(tower.root, ['update-ref', snapshotRef, snapshotCommit])
122
+ return { snapshotCommit, snapshotRef }
123
+ })
124
+ }
125
+
126
+ /** Phase 2b of `Tower.init` (#51): snapshot-ref cleanup for a base reanchor. */
127
+ async function actInitReanchor(tower, prepared) {
128
+ return withGitLock(tower.paths, async () => {
129
+ const state = await tower.load()
130
+ if (state.revision !== prepared.stateRevision) return { staleRevision: state.revision }
131
+ if (state.base === prepared.base) return { deleted: [] }
132
+ const refs =
133
+ (await tower.tryGit(tower.root, ['for-each-ref', '--format=%(refname) %(objectname)', SNAPSHOT_REF_PREFIX])) ?? ''
134
+ const deleted = []
135
+ for (const line of refs.split('\n').map((entry) => entry.trim()).filter(Boolean)) {
136
+ const [ref, sha] = line.split(' ')
137
+ if (!ref) continue
138
+ try {
139
+ await tower.git(tower.root, ['update-ref', '-d', ref])
140
+ deleted.push({ ref, sha })
141
+ } catch {
142
+ // best effort: the single-phase code never failed init on a stale snapshot ref
143
+ }
144
+ }
145
+ return { deleted }
146
+ })
147
+ }
148
+
149
+ /** Restore snapshot refs when the reanchor commit is rejected after phase 2. */
150
+ async function restoreInitRefs(tower, deleted) {
151
+ const entries = deleted.filter((entry) => typeof entry.sha === 'string' && entry.sha.length > 0)
152
+ if (entries.length === 0) return
153
+ await withGitLock(tower.paths, async () => {
154
+ for (const entry of entries) {
155
+ await tower.git(tower.root, ['update-ref', entry.ref, entry.sha]).catch(() => {})
156
+ }
157
+ })
158
+ }
159
+
160
+ /** Phase 3a of `Tower.init` (#51): write the initial state under a short lock. */
161
+ async function commitInitCreate(tower, prepared, acted, input) {
162
+ return withLock(tower.paths, async () => {
163
+ // Revision revalidation for the create path: prepare saw "absent", so a
164
+ // state file that appeared meanwhile must never be overwritten.
165
+ if (await exists(tower.paths.state)) {
166
+ throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
167
+ }
168
+ const now = Date.now()
169
+ const state = initialState({
170
+ repoRoot: tower.root,
171
+ commonDir: tower.commonDir,
172
+ base: prepared.base,
173
+ dirty: prepared.dirty,
174
+ towerAgentId: input.towerAgentId ? String(input.towerAgentId) : null,
175
+ snapshotCommit: acted.snapshotCommit,
176
+ snapshotRef: acted.snapshotRef,
177
+ caseInsensitive: prepared.caseInsensitive,
178
+ now,
179
+ })
180
+ validateState(state, tower.paths.state)
181
+ await writeJsonAtomic(tower.paths.state, state)
182
+ await appendJsonl(tower.paths.activity, {
183
+ at: now,
184
+ type: 'init',
185
+ text: initActivityText({
186
+ base: prepared.base,
187
+ snapshotCommit: acted.snapshotCommit,
188
+ dirty: prepared.dirty,
189
+ caseInsensitive: prepared.caseInsensitive,
190
+ }),
191
+ })
192
+ return createdInitResult({
193
+ repoRoot: tower.root,
194
+ commonDir: tower.commonDir,
195
+ statePath: tower.paths.state,
196
+ base: prepared.base,
197
+ dirty: prepared.dirty,
198
+ snapshotCommit: acted.snapshotCommit,
199
+ snapshotRef: acted.snapshotRef,
200
+ caseInsensitive: prepared.caseInsensitive,
201
+ })
202
+ })
203
+ }
204
+
205
+ /** Phase 3b of `Tower.init` (#51): owner claim and reanchor writes under a short lock. */
206
+ async function commitInitExisting(tower, prepared, input) {
207
+ return withLock(tower.paths, async () => {
208
+ const state = await tower.load()
209
+ if (state.revision !== prepared.stateRevision) {
210
+ throw new TowerToolError(
211
+ 'stale-base',
212
+ `tower state changed while init was being prepared (${prepared.stateRevision} -> ${state.revision}); retry`,
213
+ )
214
+ }
215
+ const now = Date.now()
216
+ const owner = state.towerAgentId ? String(state.towerAgentId) : null
217
+ const caller = input.towerAgentId ? String(input.towerAgentId) : null
218
+ if (ownedByOther(owner, caller)) {
219
+ fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
220
+ }
221
+ const claimant = claimCaller(owner, caller)
222
+ if (claimant) {
223
+ state.towerAgentId = claimant
224
+ state.revision += 1
225
+ state.updatedAt = now
226
+ validateState(state, tower.paths.state)
227
+ await writeJsonAtomic(tower.paths.state, state)
228
+ await appendJsonl(tower.paths.activity, {
229
+ at: now,
230
+ type: 'init',
231
+ text: ownerClaimActivityText(claimant),
232
+ })
233
+ }
234
+ let reanchored = false
235
+ // Record the one-time case-sensitivity probe (#90). An unknown result is
236
+ // not persisted, so a later init can retry it without bumping the revision
237
+ // on every call.
238
+ if (prepared.caseInsensitive !== null && state.caseInsensitive !== prepared.caseInsensitive) {
239
+ state.caseInsensitive = prepared.caseInsensitive
240
+ state.revision += 1
241
+ state.updatedAt = now
242
+ validateState(state, tower.paths.state)
243
+ await writeJsonAtomic(tower.paths.state, state)
244
+ await appendJsonl(tower.paths.activity, {
245
+ at: now,
246
+ type: 'init',
247
+ text: caseProbeActivityText(prepared.caseInsensitive),
248
+ })
249
+ }
250
+ if (prepared.reanchor && state.base !== prepared.base) {
251
+ state.base = prepared.base
252
+ state.baseChangedAt = now
253
+ delete state.snapshotCommit
254
+ delete state.snapshotRef
255
+ reanchored = true
256
+ state.revision += 1
257
+ state.updatedAt = now
258
+ validateState(state, tower.paths.state)
259
+ await writeJsonAtomic(tower.paths.state, state)
260
+ await appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
261
+ }
262
+ return existingInitWriteResult({ state, statePath: tower.paths.state, dirty: prepared.dirty, reanchored })
263
+ })
264
+ }
265
+
266
+ /** #51 two-phase init: prepare/act/commit with stale-state retries. */
267
+ export async function init(tower, input = {}) {
268
+ await ensureDir(tower.paths.dir)
269
+ const prepared = await prepareInit(tower, input)
270
+ if (prepared.early) return prepared.early
271
+ if (prepared.create) {
272
+ const acted = await actInitCreate(tower, prepared)
273
+ if (acted.stale) {
274
+ throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
275
+ }
276
+ return commitInitCreate(tower, prepared, acted, input)
277
+ }
278
+ const acted = prepared.reanchor ? await actInitReanchor(tower, prepared) : { deleted: [] }
279
+ if (acted.staleRevision !== undefined) {
280
+ throw new TowerToolError(
281
+ 'stale-base',
282
+ `tower state changed while init was being prepared (${prepared.stateRevision} -> ${acted.staleRevision}); retry`,
283
+ )
284
+ }
285
+ try {
286
+ return await commitInitExisting(tower, prepared, input)
287
+ } catch (error) {
288
+ if (acted.deleted.length > 0) await restoreInitRefs(tower, acted.deleted)
289
+ throw error
290
+ }
291
+ }