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
@@ -0,0 +1,12 @@
1
+ // Default composition root for Tower dependencies (#50).
2
+ import * as store from '../lib/store.js'
3
+ import * as vcs from '../lib/git.js'
4
+ import * as caseSensitivity from '../lib/caseSensitivity.js'
5
+
6
+ export const defaultDeps = {
7
+ store,
8
+ vcs,
9
+ caseSensitivity,
10
+ clock: { now: () => Date.now() },
11
+ ids: { uuid: () => crypto.randomUUID() },
12
+ }
@@ -0,0 +1,6 @@
1
+ // The only lib/ module that imports node:fs (#50).
2
+ import fs from 'node:fs'
3
+ import fsp from 'node:fs/promises'
4
+
5
+ const { createWriteStream } = fs
6
+ export { fs, fsp, createWriteStream }
@@ -0,0 +1,4 @@
1
+ // The only lib/ module that imports node:child_process (#50).
2
+ import { execFile, spawn } from 'node:child_process'
3
+
4
+ export { execFile, spawn }
package/app/archive.js ADDED
@@ -0,0 +1,190 @@
1
+ // Terminal-mission archival use case (#73).
2
+ //
3
+ // The policy sweep runs inside an existing state lock, immediately before the
4
+ // caller writes state.json: archive files are written first, then the state
5
+ // document is compacted, so a crash between the two leaves the mission in state
6
+ // and the archive entry is deduped by id on retry. Nothing is deleted: the full
7
+ // mission, its linked reviews and its findings are moved to archive/<year>.json.
8
+ import { appendJsonl } from '../lib/store.js'
9
+ import { readArchiveIndex, readArchivedEntry, writeArchiveEntries } from '../lib/archive.js'
10
+ import { hasLiveAgent } from '../domain/integration/liveness.js'
11
+ import { missionSummary } from '../domain/summary.js'
12
+ import {
13
+ archiveYearFor,
14
+ archivedEntry,
15
+ isClosedFinding,
16
+ linkedFindings,
17
+ linkedReviews,
18
+ selectForArchive,
19
+ } from '../domain/state/archive.js'
20
+
21
+ /** @typedef {import('../types/index.js').Mission} Mission */
22
+ /** @typedef {import('../types/index.js').TowerState} TowerState */
23
+
24
+ /**
25
+ * Read `archive/index.json` through the Tower's store. Never throws: the
26
+ * returned `ok:false` carries the diagnostic that status/doctor surface.
27
+ * @param {any} tower
28
+ * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
29
+ */
30
+ export function archiveIndex(tower) {
31
+ return readArchiveIndex(tower.deps.store, tower.paths)
32
+ }
33
+
34
+ /**
35
+ * Load one archived mission with its linked reviews and findings.
36
+ * @param {any} tower
37
+ * @param {string} missionId
38
+ */
39
+ export function archivedMission(tower, missionId) {
40
+ return readArchivedEntry(tower.deps.store, tower.paths, missionId)
41
+ }
42
+
43
+ /**
44
+ * A compact archived mission is rendered through the same summary projection as
45
+ * an active one, plus `archived: true`. The completion report and task bodies
46
+ * are intentionally absent: they live in the year file and are loaded only by
47
+ * `tower_status missionId`.
48
+ * @param {Record<string, any>} entry
49
+ * @returns {any}
50
+ */
51
+ export function archivedSummary(entry) {
52
+ const mission = /** @type {any} */ ({
53
+ id: entry.id,
54
+ title: entry.title,
55
+ kind: entry.kind ?? 'build',
56
+ status: entry.status,
57
+ scope: Array.isArray(entry.scope) ? entry.scope : [],
58
+ deps: Array.isArray(entry.deps) ? entry.deps : [],
59
+ owner: entry.owner ?? null,
60
+ worktree: null,
61
+ blockers: [],
62
+ tasks: [],
63
+ reviewPolicy: 'required-clean',
64
+ mergedCommit: entry.mergedCommit ?? null,
65
+ mergedAt: entry.mergedAt ?? null,
66
+ updatedAt: entry.updatedAt ?? null,
67
+ })
68
+ return {
69
+ ...missionSummary(mission),
70
+ archived: true,
71
+ archivedAt: entry.archivedAt,
72
+ archivedYear: entry.archivedYear ?? null,
73
+ branch: entry.branch ?? null,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Status/board view of the archive index: a count plus a diagnostic, never a
79
+ * thrown error.
80
+ * @param {{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }} index
81
+ * @returns {Record<string, any>}
82
+ */
83
+ export function archiveView(index) {
84
+ const years = index.ok
85
+ ? [...new Set(index.missions.map((entry) => entry.archivedYear).filter((year) => Number.isInteger(year)))].sort(
86
+ (left, right) => left - right,
87
+ )
88
+ : []
89
+ return {
90
+ count: index.ok ? index.missions.length : 0,
91
+ years,
92
+ file: index.file,
93
+ present: index.exists === true,
94
+ unreadable: index.ok ? false : true,
95
+ ...(index.ok ? {} : { message: index.error ?? 'tower archive is unreadable' }),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Move the eligible terminal missions out of `state` and into the archive.
101
+ * Mutates `state` in memory (missions, linked reviews and closed findings) and
102
+ * leaves the caller to validate/persist it under the lock it already holds.
103
+ *
104
+ * @param {any} tower
105
+ * @param {TowerState} state
106
+ * @param {{ now?: number, force?: boolean, activity?: Record<string, string | undefined> }} [options]
107
+ * @returns {Promise<{ archived: string[], written: boolean, files?: string[], error: string | null, year?: number }>}
108
+ */
109
+ export async function sweepArchive(tower, state, options = {}) {
110
+ const policy = tower.config.archive ?? {}
111
+ if (policy.enabled === false) return { archived: [], written: false, error: null }
112
+ const now = options.now ?? Date.now()
113
+ const activity = options.activity ?? {}
114
+
115
+ // Open batches still need their members; a live agent's mission is never
116
+ // archived even if it is terminal (defence in depth; teardown keeps it too).
117
+ const protectedIds = new Set()
118
+ for (const batch of state.batches ?? []) {
119
+ const record = /** @type {Record<string, any>} */ (batch)
120
+ if (record.status !== 'open') continue
121
+ for (const missionId of Array.isArray(record.missionIds) ? record.missionIds : []) {
122
+ protectedIds.add(String(missionId))
123
+ }
124
+ }
125
+ for (const mission of state.missions) {
126
+ if (hasLiveAgent(state, mission.id, activity)) protectedIds.add(mission.id)
127
+ }
128
+
129
+ const selected = selectForArchive(state.missions, policy, {
130
+ now,
131
+ force: options.force === true,
132
+ protectedIds,
133
+ })
134
+ if (selected.length === 0) return { archived: [], written: false, error: null }
135
+
136
+ const archivedYear = archiveYearFor(now)
137
+ const entries = selected.map((mission) =>
138
+ archivedEntry(mission, {
139
+ archivedAt: now,
140
+ archivedYear,
141
+ reviews: linkedReviews(mission, state.reviews),
142
+ findings: linkedFindings(mission, state.findings),
143
+ }),
144
+ )
145
+
146
+ let written
147
+ try {
148
+ written = await writeArchiveEntries(tower.deps.store, tower.paths, { entries, now })
149
+ } catch (error) {
150
+ return { archived: [], written: false, error: `archive write failed: ${String(error?.message ?? error)}` }
151
+ }
152
+ if (!written.ok) return { archived: [], written: false, error: written.error ?? 'archive write failed' }
153
+
154
+ // The archive now holds the full record: compact state.json in the same lock.
155
+ const ids = new Set(selected.map((mission) => mission.id))
156
+ const branches = new Set(
157
+ selected
158
+ .map((mission) => mission.worktree?.branch)
159
+ .filter((branch) => typeof branch === 'string'),
160
+ )
161
+ state.missions = state.missions.filter((mission) => !ids.has(mission.id))
162
+ if (Array.isArray(state.reviews)) {
163
+ state.reviews = state.reviews.filter((review) => !ids.has(review.missionId) && !branches.has(review.branch))
164
+ }
165
+ // Open findings stay actionable in state.json; the archive keeps a snapshot.
166
+ if (Array.isArray(state.findings)) {
167
+ state.findings = state.findings.filter(
168
+ (finding) => !(finding.missionId != null && ids.has(finding.missionId) && isClosedFinding(finding)),
169
+ )
170
+ }
171
+ state.revision += 1
172
+ state.updatedAt = now
173
+
174
+ for (const mission of selected) {
175
+ await appendJsonl(tower.paths.activity, {
176
+ at: now,
177
+ type: 'archive',
178
+ missionId: mission.id,
179
+ text: `archived to archive/${archivedYear}.json`,
180
+ }).catch(() => {})
181
+ }
182
+
183
+ return {
184
+ archived: [...ids],
185
+ written: true,
186
+ files: written.files,
187
+ error: null,
188
+ year: archivedYear,
189
+ }
190
+ }
package/app/batch.js ADDED
@@ -0,0 +1,79 @@
1
+ // Batch use cases.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
4
+ import { validateState } from '../domain/state.js'
5
+ import { validateCreate as validateBatchCreate, validateClose as validateBatchClose } from '../domain/batch.js'
6
+ import {
7
+ nextBatchId,
8
+ createBatchRecord,
9
+ closeBatchRecord,
10
+ abandonBatchCheck,
11
+ applyBatchAbandon,
12
+ batchStatusPayload,
13
+ } from '../domain/collab/batch.js'
14
+
15
+ function fail(message, code = 'batch-invalid') {
16
+ throw new TowerToolError(code, message)
17
+ }
18
+
19
+ export async function createBatch(tower, input = {}, actor = 'tower') {
20
+ return withLock(tower.paths, async () => {
21
+ const state = await tower.load()
22
+ const normalized = validateBatchCreate(state, input)
23
+ if (!Array.isArray(state.batches)) state.batches = []
24
+ const batch = createBatchRecord(nextBatchId(state.batches), normalized, actor, Date.now())
25
+ state.batches.push(batch)
26
+ state.revision += 1
27
+ state.updatedAt = batch.createdAt
28
+ validateState(state, tower.paths.state)
29
+ await writeJsonAtomic(tower.paths.state, state)
30
+ await appendJsonl(tower.paths.activity, {
31
+ at: batch.createdAt,
32
+ type: 'batch',
33
+ text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
34
+ })
35
+ return batch
36
+ })
37
+ }
38
+
39
+ export async function closeBatch(tower, batchId, options = {}, actor = 'tower') {
40
+ return withLock(tower.paths, async () => {
41
+ const state = await tower.load()
42
+ const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
43
+ if (!batch) fail(`unknown batch "${batchId}"`)
44
+ const result = validateBatchClose(state, batch, { ...options, state })
45
+ const blockers = Array.isArray(result.blockers) ? result.blockers : []
46
+ if (blockers.length > 0) return { ok: false, batch, blockers, checks: result.checks ?? [] }
47
+ closeBatchRecord(batch, options, actor, Date.now())
48
+ state.revision += 1
49
+ state.updatedAt = batch.closedAt
50
+ validateState(state, tower.paths.state)
51
+ await writeJsonAtomic(tower.paths.state, state)
52
+ await appendJsonl(tower.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
53
+ return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
54
+ })
55
+ }
56
+
57
+ /** @param {string} batchId @param {string} [actor] @param {string | null} [reason] */
58
+ export async function abandonBatch(tower, batchId, actor = 'tower', reason = null) {
59
+ return withLock(tower.paths, async () => {
60
+ const state = await tower.load()
61
+ const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
62
+ if (!batch) fail(`unknown batch "${batchId}"`)
63
+ const { alreadyAbandoned } = abandonBatchCheck(batch, batchId)
64
+ if (alreadyAbandoned) return { ok: true, batch, alreadyAbandoned: true }
65
+ const now = Date.now()
66
+ applyBatchAbandon({ batch, actor, reason, now })
67
+ state.revision += 1
68
+ state.updatedAt = batch.abandonedAt
69
+ validateState(state, tower.paths.state)
70
+ await writeJsonAtomic(tower.paths.state, state)
71
+ await appendJsonl(tower.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
72
+ return { ok: true, batch, alreadyAbandoned: false }
73
+ })
74
+ }
75
+
76
+ export async function batchStatus(tower, batchId) {
77
+ const state = await tower.load()
78
+ return batchStatusPayload(state, batchId)
79
+ }
package/app/board.js ADDED
@@ -0,0 +1,27 @@
1
+ // Mission board (MISSIONS.md) writing use cases.
2
+ import path from 'node:path'
3
+ import { fsp } from '../adapters/store-fs.js'
4
+ import { renameWithRetry } from '../lib/store.js'
5
+ import { archiveView } from './archive.js'
6
+
7
+ export function boardFile(tower) {
8
+ return tower.config.docs?.boardLocation === 'repo'
9
+ ? path.join(tower.root, 'MISSIONS.md')
10
+ : path.join(tower.paths.dir, 'MISSIONS.md')
11
+ }
12
+
13
+ export async function writeBoard(tower) {
14
+ if (tower.config.docs?.board === false) return null
15
+ try {
16
+ const state = await tower.load()
17
+ const index = await tower.archiveIndex()
18
+ const file = boardFile(tower)
19
+ await tower.ensureExcluded(file)
20
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
21
+ await fsp.writeFile(tmp, tower.boardMarkdown(state, archiveView(index)), 'utf8')
22
+ await renameWithRetry(tmp, file)
23
+ return file
24
+ } catch {
25
+ return null
26
+ }
27
+ }
@@ -0,0 +1,89 @@
1
+ // Worker completion preparation and commit use cases.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+ import {
4
+ normalizeChecks,
5
+ assertCompletionRequest,
6
+ assertCompletionInbox,
7
+ completionReport,
8
+ } from '../domain/missions/completion.js'
9
+ import { fullMission } from '../domain/summary.js'
10
+ import { validateState } from '../domain/state.js'
11
+ import { countCommits, isClean } from '../lib/git.js'
12
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
13
+ import { sweepArchive } from './archive.js'
14
+
15
+ export { normalizeChecks }
16
+
17
+ export async function checkCompletion(tower, input) {
18
+ const state = await tower.load()
19
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
20
+ if (!mission) {
21
+ throw new TowerToolError('invalid-argument', `unknown mission "${input.missionId}"`)
22
+ }
23
+ const worker = assertCompletionRequest({
24
+ mission,
25
+ roster: state.roster,
26
+ input,
27
+ maxReportBytes: tower.config.gates?.maxReportBytes,
28
+ })
29
+ await tower.assertWorktree(mission.worktree)
30
+ const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
31
+ if (commits < 1) {
32
+ throw new TowerToolError(
33
+ 'invalid-argument',
34
+ `mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
35
+ )
36
+ }
37
+ if (!(await isClean(mission.worktree.path))) {
38
+ const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
39
+ .split('\n')
40
+ .filter(Boolean)
41
+ .map((line) => line.slice(3).trim())
42
+ throw new TowerToolError(
43
+ 'worktree-dirty',
44
+ `mission ${mission.id} worktree has uncommitted changes (${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ', …' : ''}); commit, remove, or record them before tower_complete`,
45
+ )
46
+ }
47
+ const unread = tower.unreadFor(state, worker.name)
48
+ assertCompletionInbox({ mission, worker, unread })
49
+ return { state, mission, worker, commits }
50
+ }
51
+
52
+ export async function complete(tower, input) {
53
+ // Two-phase pilot (#51): expensive git/state checks run before the lock;
54
+ // the lock only revalidates the state revision and commits the transition.
55
+ const prepared = await tower.checkCompletion(input)
56
+ return withLock(tower.paths, async () => {
57
+ const state = await tower.load()
58
+ if (state.revision !== prepared.state.revision) {
59
+ throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
60
+ }
61
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
62
+ if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${input.missionId}"`)
63
+ const { worker, commits } = prepared
64
+ const checks = normalizeChecks(input.checks)
65
+ const now = Date.now()
66
+ mission.status = 'completed'
67
+ mission.completionReport = completionReport({
68
+ report: input.report,
69
+ worker,
70
+ checks,
71
+ now,
72
+ maxReportBytes: tower.config.gates?.maxReportBytes,
73
+ })
74
+ mission.updatedAt = now
75
+ await sweepArchive(tower, state, { now })
76
+ state.revision += 1
77
+ state.updatedAt = now
78
+
79
+ validateState(state, tower.paths.state)
80
+ await writeJsonAtomic(tower.paths.state, state)
81
+ await appendJsonl(tower.paths.activity, {
82
+ at: now,
83
+ type: 'complete',
84
+ missionId: mission.id,
85
+ text: `${worker.name} reported completion (${commits} commit(s))`,
86
+ })
87
+ return { mission: fullMission(mission), commits, unread: 0 }
88
+ })
89
+ }
@@ -0,0 +1,26 @@
1
+ // Dashboard mission enrichment use case.
2
+ import { exists } from '../lib/store.js'
3
+ import { revParse } from '../lib/git.js'
4
+ import {
5
+ enrichedMissionView,
6
+ latestReviewForMission,
7
+ missionsToEnrich,
8
+ } from '../domain/read/enrichedMissions.js'
9
+
10
+ export async function enrichedMissions(tower, state, onlyIds) {
11
+ const out = []
12
+ for (const mission of missionsToEnrich(state.missions, onlyIds)) {
13
+ const latest = latestReviewForMission(state, mission)
14
+ let tipMoved = false
15
+ if (latest && mission.worktree && (await exists(mission.worktree.path))) {
16
+ try {
17
+ const tip = await revParse(mission.worktree.path, 'HEAD')
18
+ tipMoved = tip !== latest.reviewedCommit
19
+ } catch {
20
+ tipMoved = false
21
+ }
22
+ }
23
+ out.push(enrichedMissionView({ mission, latest, tipMoved }))
24
+ }
25
+ return out
26
+ }
@@ -0,0 +1,78 @@
1
+ // Findings use cases.
2
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
+ import { validateState } from '../domain/state.js'
4
+ import { TowerToolError } from '../domain/errors.js'
5
+ import {
6
+ normalizeFindingInput,
7
+ newFinding,
8
+ normalizeFindingActor,
9
+ wantsFindingUpdate,
10
+ readFindingsView,
11
+ applyFindingUpdate,
12
+ findingUpdateView,
13
+ } from '../domain/missions/findings.js'
14
+
15
+ function fail(message, code = 'invalid-argument') {
16
+ throw new TowerToolError(code, message)
17
+ }
18
+
19
+ export async function addFinding(tower, input) {
20
+ return withLock(tower.paths, async () => {
21
+ const state = await tower.load()
22
+ if (!Array.isArray(state.findings)) state.findings = []
23
+ const fields = normalizeFindingInput(input, state.missions)
24
+ const now = Date.now()
25
+ const seq = (state.findingSeq ?? 0) + 1
26
+ state.findingSeq = seq
27
+ const finding = newFinding(fields, { seq, now, reportedBy: input.reportedBy })
28
+ state.findings.push(finding)
29
+ state.revision += 1
30
+ state.updatedAt = now
31
+
32
+ validateState(state, tower.paths.state)
33
+ await writeJsonAtomic(tower.paths.state, state)
34
+ await appendJsonl(tower.paths.activity, {
35
+ at: now,
36
+ type: 'finding',
37
+ missionId: finding.missionId,
38
+ text: `${finding.id} ${finding.type}: ${finding.title}`,
39
+ })
40
+ return finding
41
+ })
42
+ }
43
+
44
+ /** List or read findings. Read-only paths never take the writer lock. */
45
+ export async function readFindings(tower, input = {}) {
46
+ const state = await tower.load()
47
+ return readFindingsView(state.findings ?? [], input)
48
+ }
49
+
50
+ /**
51
+ * List, read, or transition findings. Listing and reading are lock-free;
52
+ * only a transition takes the writer lock. Reviewers may only read.
53
+ */
54
+ export async function findings(tower, input = {}) {
55
+ const actor = normalizeFindingActor(input.actor)
56
+ if (!wantsFindingUpdate(input)) return tower.readFindings(input)
57
+
58
+ return withLock(tower.paths, async () => {
59
+ const state = await tower.load()
60
+ const all = state.findings ?? []
61
+ const finding = all.find((entry) => entry.id === input.id)
62
+ if (!finding) fail(`unknown finding "${input.id}"`, 'finding-not-found')
63
+ const now = Date.now()
64
+ const { noteAdded } = applyFindingUpdate({ finding, input, actor, now })
65
+ state.revision += 1
66
+ state.updatedAt = now
67
+ validateState(state, tower.paths.state)
68
+ await writeJsonAtomic(tower.paths.state, state)
69
+ await appendJsonl(tower.paths.activity, {
70
+ at: now,
71
+ type: 'finding',
72
+ missionId: finding.missionId,
73
+ text: `${finding.id} ${finding.status}${noteAdded ? ' +note' : ''} (${actor.name})`,
74
+ })
75
+ // Counts must reflect the transition just recorded.
76
+ return findingUpdateView(finding, all)
77
+ })
78
+ }
package/app/gate.js ADDED
@@ -0,0 +1,126 @@
1
+ // Merge/preflight gate evaluation.
2
+ import { currentBranch } from '../lib/git.js'
3
+ import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
4
+ import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
5
+ import { fullMission } from '../domain/summary.js'
6
+ import { latestReview, reviewsForBranch } from '../domain/integration/review.js'
7
+ import {
8
+ baseDirtyBlocker,
9
+ baseWrongBranchBlocker,
10
+ dependencyBlockers,
11
+ gateResult,
12
+ mergeIntentPendingBlocker,
13
+ missingMissionGateResult,
14
+ noWorktreeBlocker,
15
+ outOfScopeBlocker,
16
+ overwrittenPaths,
17
+ staleBaseBlocker,
18
+ surveyChangedFilesBlocker,
19
+ } from '../domain/integration/gate.js'
20
+
21
+ export async function evaluateGate(tower, input) {
22
+ const state = await tower.load()
23
+ let mission = state.missions.find((entry) => entry.id === input.missionId)
24
+ let archivedEntry = null
25
+ if (!mission) {
26
+ // A completed mission may have been archived by policy; preflight and
27
+ // merge must behave the same for it as before the move (#73).
28
+ const archived = await tower.archivedMission(input.missionId)
29
+ if (archived.ok && archived.entry) {
30
+ archivedEntry = archived.entry
31
+ mission = archived.entry.mission
32
+ }
33
+ }
34
+ if (!mission) return missingMissionGateResult(input.missionId)
35
+ const archiveIndex = await tower.archiveIndex()
36
+ const archivedMissions = new Map(
37
+ (archiveIndex.ok ? archiveIndex.missions : []).map((entry) => [entry.id, entry]),
38
+ )
39
+ const reviews = archivedEntry ? [...(state.reviews ?? []), ...(archivedEntry.reviews ?? [])] : state.reviews
40
+ const activity = input.activity ?? {}
41
+ const blockers = []
42
+ const push = (code, detail) => blockers.push({ code, detail })
43
+
44
+ for (const blocker of completionBlockers(mission, tower.config)) push(blocker.code, blocker.detail)
45
+ if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
46
+ const blocker = mergeIntentPendingBlocker(state.mergeIntent)
47
+ push(blocker.code, blocker.detail)
48
+ }
49
+
50
+ const worktree = mission.worktree ?? null
51
+ let branchTip = null
52
+ let baseTip = null
53
+ let changedFiles = []
54
+ if (!worktree) {
55
+ const blocker = noWorktreeBlocker(mission.id)
56
+ push(blocker.code, blocker.detail)
57
+ } else {
58
+ try {
59
+ await tower.assertWorktree(worktree)
60
+ branchTip = await tower.revParse(worktree.path, 'HEAD')
61
+ baseTip = await tower.revParse(tower.root, state.base)
62
+ const mergeBase = (await tower.git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
63
+ changedFiles = (await tower.git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
64
+ .split('\n')
65
+ .filter(Boolean)
66
+ if (!(await tower.isAncestor(tower.root, baseTip, branchTip))) {
67
+ const blocker = staleBaseBlocker({ branch: worktree.branch, base: state.base, baseTip })
68
+ push(blocker.code, blocker.detail)
69
+ }
70
+ if (mission.kind === 'survey') {
71
+ const blocker = surveyChangedFilesBlocker({ missionId: mission.id, changedFiles })
72
+ if (blocker) push(blocker.code, blocker.detail)
73
+ }
74
+ if (mission.kind !== 'survey') {
75
+ const blocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
76
+ if (blocker) push(blocker.code, blocker.detail)
77
+ }
78
+ const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
79
+ .split('\n')
80
+ .filter(Boolean)
81
+ .map((line) => line.slice(3).trim())
82
+ const blocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
83
+ if (blocker) push(blocker.code, blocker.detail)
84
+ } catch (error) {
85
+ push('branch-missing', String(error?.message ?? error))
86
+ }
87
+ }
88
+
89
+ for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state, archived: archivedMissions })) {
90
+ push(blocker.code, blocker.detail)
91
+ }
92
+
93
+ const branch = worktree ? worktree.branch : null
94
+ const latest = latestReview(reviewsForBranch(reviews, branch))
95
+ for (const blocker of mergeGateBlockers({
96
+ mission,
97
+ roster: state.roster ?? [],
98
+ branch,
99
+ branchTip,
100
+ review: latest,
101
+ config: tower.config,
102
+ activity,
103
+ parentStatus: input.parentStatus,
104
+ inactiveStates: ROSTER_INACTIVE_STATES,
105
+ rebaseWaived: latest ? tower.rebaseWaived(mission, latest) : false,
106
+ })) push(blocker.code, blocker.detail)
107
+
108
+ const checkoutBranch = await currentBranch(tower.root)
109
+ const wrongBranch = baseWrongBranchBlocker({ checkoutBranch, base: state.base })
110
+ if (wrongBranch) push(wrongBranch.code, wrongBranch.detail)
111
+
112
+ return {
113
+ ...gateResult({
114
+ mission: fullMission(mission),
115
+ worktree,
116
+ branchTip,
117
+ baseTip,
118
+ changedFiles,
119
+ review: latest,
120
+ blockers,
121
+ }),
122
+ ...(archivedEntry ? { archived: true } : {}),
123
+ }
124
+ }
125
+
126
+ /** Read-only mission diff for reviewers: branch changes against the merge base. */