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/lib/archive.js ADDED
@@ -0,0 +1,279 @@
1
+ // Terminal-mission archive storage (#73).
2
+ //
3
+ // Layout, under the Tower state directory:
4
+ //
5
+ // archive/index.json compact entries; tower_status pages from here
6
+ // archive/<year>.json full missions plus their linked reviews/findings
7
+ //
8
+ // Both files carry `archiveVersion` (independent of the state SCHEMA_VERSION).
9
+ // Reads are deliberately tolerant: a truncated or foreign archive returns a
10
+ // diagnostic result and never throws, so `tower_status` still renders the hot
11
+ // state. Writes happen before the state document is compacted, and dedupe by
12
+ // mission id, so a crash between the two files is recoverable on the next
13
+ // mutation instead of losing a mission.
14
+ import path from 'node:path'
15
+ import { fsp } from '../adapters/store-fs.js'
16
+ import {
17
+ ARCHIVE_VERSION,
18
+ archiveDocumentError,
19
+ dedupeById,
20
+ indexEntryFromArchived,
21
+ } from '../domain/state/archive.js'
22
+
23
+ /** @typedef {import('../types/index.js').Mission} Mission */
24
+
25
+ /**
26
+ * @param {{ dir: string }} paths
27
+ * @returns {{ dir: string, index: string, year: (year: number) => string }}
28
+ */
29
+ export function archivePaths(paths) {
30
+ const dir = path.join(paths.dir, 'archive')
31
+ return { dir, index: path.join(dir, 'index.json'), year: (year) => path.join(dir, `${year}.json`) }
32
+ }
33
+
34
+ /**
35
+ * @param {any} error
36
+ * @returns {boolean}
37
+ */
38
+ function isMissing(error) {
39
+ return error?.code === 'ENOENT'
40
+ }
41
+
42
+ /**
43
+ * @param {string} where
44
+ * @param {string} message
45
+ * @returns {string}
46
+ */
47
+ function diagnostic(where, message) {
48
+ return `tower archive is unreadable at ${where}: ${message}`
49
+ }
50
+
51
+ /**
52
+ * Read `archive/index.json` without throwing. A missing index means "no
53
+ * archive yet"; a corrupt one is reported as `ok:false` with the diagnostic.
54
+ * @param {{ readJson: (file: string) => Promise<any> }} store
55
+ * @param {{ dir: string }} paths
56
+ * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
57
+ */
58
+ export async function readArchiveIndex(store, paths) {
59
+ const file = archivePaths(paths).index
60
+ let document
61
+ try {
62
+ document = await store.readJson(file)
63
+ } catch (error) {
64
+ if (isMissing(error)) return { ok: true, file, missions: [], exists: false }
65
+ return { ok: false, file, missions: [], error: diagnostic(file, String(error?.message ?? error)), exists: true }
66
+ }
67
+ const error = archiveDocumentError(document, 'index', ` at ${file}`)
68
+ if (error) return { ok: false, file, missions: [], error, exists: true }
69
+ return { ok: true, file, missions: document.missions, exists: true }
70
+ }
71
+
72
+ /**
73
+ * @param {{ readJson: (file: string) => Promise<any> }} store
74
+ * @param {{ dir: string }} paths
75
+ * @param {number} year
76
+ * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
77
+ */
78
+ export async function readArchiveYear(store, paths, year) {
79
+ const file = archivePaths(paths).year(year)
80
+ let document
81
+ try {
82
+ document = await store.readJson(file)
83
+ } catch (error) {
84
+ if (isMissing(error)) return { ok: true, file, missions: [], exists: false }
85
+ return { ok: false, file, missions: [], error: diagnostic(file, String(error?.message ?? error)), exists: true }
86
+ }
87
+ const error = archiveDocumentError(document, 'year', ` at ${file}`)
88
+ if (error) return { ok: false, file, missions: [], error, exists: true }
89
+ return { ok: true, file, missions: document.missions, exists: true }
90
+ }
91
+
92
+ /**
93
+ * Append full entries to their year files, then to the index. Returns
94
+ * `ok:false` (and writes nothing for the index) when an existing archive file
95
+ * is unreadable, so a corrupt archive is never silently replaced.
96
+ * @param {{ readJson: (file: string) => Promise<any>, writeJsonAtomic: (file: string, value: unknown) => Promise<void>, ensureDir: (dir: string) => Promise<void> }} store
97
+ * @param {{ dir: string }} paths
98
+ * @param {{ entries: Array<Record<string, any>>, now: number }} input
99
+ * @returns {Promise<{ ok: boolean, count: number, files: string[], error?: string }>}
100
+ */
101
+ export async function writeArchiveEntries(store, paths, { entries, now }) {
102
+ if (entries.length === 0) return { ok: true, count: 0, files: [] }
103
+ const target = archivePaths(paths)
104
+
105
+ // Read the index first: if it is corrupt, refusing to write keeps the old
106
+ // history byte-identical and leaves the state missions intact for a retry.
107
+ const index = await readArchiveIndex(store, paths)
108
+ if (!index.ok) return { ok: false, count: 0, files: [], error: index.error }
109
+
110
+ /** @type {Map<number, Array<Record<string, any>>>} */
111
+ const byYear = new Map()
112
+ for (const entry of entries) {
113
+ const year = entry.archivedYear
114
+ if (!Number.isInteger(year)) return { ok: false, count: 0, files: [], error: `archive entry ${entry.id} has no archivedYear` }
115
+ const bucket = byYear.get(year) ?? []
116
+ bucket.push(entry)
117
+ byYear.set(year, bucket)
118
+ }
119
+
120
+ /** @type {Array<{ year: number, file: string, missions: any[] }>} */
121
+ const yearDocuments = []
122
+ for (const [year, batch] of [...byYear.entries()].sort((left, right) => left[0] - right[0])) {
123
+ const existing = await readArchiveYear(store, paths, year)
124
+ if (!existing.ok) return { ok: false, count: 0, files: [], error: existing.error }
125
+ yearDocuments.push({
126
+ year,
127
+ file: existing.file,
128
+ missions: dedupeById([...existing.missions, ...batch]),
129
+ })
130
+ }
131
+
132
+ await store.ensureDir(target.dir)
133
+ for (const document of yearDocuments) {
134
+ await store.writeJsonAtomic(document.file, {
135
+ archiveVersion: ARCHIVE_VERSION,
136
+ year: document.year,
137
+ updatedAt: now,
138
+ missions: document.missions,
139
+ })
140
+ }
141
+
142
+ const indexed = dedupeById([...index.missions, ...entries.map(indexEntryFromArchived)])
143
+ await store.writeJsonAtomic(target.index, {
144
+ archiveVersion: ARCHIVE_VERSION,
145
+ updatedAt: now,
146
+ missions: indexed,
147
+ })
148
+
149
+ return {
150
+ ok: true,
151
+ count: entries.length,
152
+ files: [...yearDocuments.map((document) => document.file), target.index],
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Load one archived mission with its linked reviews/findings.
158
+ * @param {{ readJson: (file: string) => Promise<any> }} store
159
+ * @param {{ dir: string }} paths
160
+ * @param {string} missionId
161
+ * @returns {Promise<{ ok: boolean, entry?: Record<string, any>, unreadable?: boolean, missing?: boolean, error?: string }>}
162
+ */
163
+ export async function readArchivedEntry(store, paths, missionId) {
164
+ const index = await readArchiveIndex(store, paths)
165
+ if (!index.ok) return { ok: false, unreadable: true, error: index.error }
166
+ const indexEntry = index.missions.find((entry) => entry.id === missionId)
167
+ if (!indexEntry) return { ok: false, missing: true }
168
+ const year = await readArchiveYear(store, paths, indexEntry.archivedYear)
169
+ if (!year.ok) return { ok: false, unreadable: true, error: year.error }
170
+ const entry = year.missions.find((candidate) => candidate.id === missionId)
171
+ if (!entry) {
172
+ return {
173
+ ok: false,
174
+ unreadable: true,
175
+ error: `tower archive index lists ${missionId} in ${year.file}, but the year file has no such entry`,
176
+ }
177
+ }
178
+ return { ok: true, entry }
179
+ }
180
+
181
+ /**
182
+ * @param {string} file
183
+ * @param {string} kind
184
+ * @returns {Promise<{ file: string, kind: string, ok: boolean, bytes: number, missions: number, error?: string, document?: any }>}
185
+ */
186
+ async function inspectFile(file, kind) {
187
+ let text
188
+ try {
189
+ text = await fsp.readFile(file, 'utf8')
190
+ } catch (error) {
191
+ return { file, kind, ok: false, bytes: 0, missions: 0, error: diagnostic(file, String(error?.message ?? error)) }
192
+ }
193
+ const bytes = Buffer.byteLength(text)
194
+ let document
195
+ try {
196
+ document = JSON.parse(text)
197
+ } catch (error) {
198
+ return { file, kind, ok: false, bytes, missions: 0, error: diagnostic(file, `invalid JSON (${error.message})`) }
199
+ }
200
+ const error = archiveDocumentError(document, /** @type {'index'|'year'} */ (kind), ` at ${file}`)
201
+ if (error) return { file, kind, ok: false, bytes, missions: 0, error, document }
202
+ return { file, kind, ok: true, bytes, missions: document.missions.length, document }
203
+ }
204
+
205
+ /**
206
+ * Read-only inventory for `tower_doctor`: file sizes, mission counts, year
207
+ * files and every unreadable file's diagnostic. A missing archive directory is
208
+ * a healthy empty archive, not an error.
209
+ * @param {{ dir: string, state?: string }} paths
210
+ * @returns {Promise<{
211
+ * dir: string, exists: boolean, bytes: number, missions: number, years: number[],
212
+ * files: Array<Record<string, any>>, unreadable: string[],
213
+ * index: { ok: boolean, count: number, error?: string } | null,
214
+ * }>}
215
+ */
216
+ export async function inspectArchive(paths) {
217
+ const target = archivePaths(/** @type {{ dir: string }} */ (paths))
218
+ let names
219
+ try {
220
+ names = await fsp.readdir(target.dir)
221
+ } catch (error) {
222
+ if (isMissing(error)) {
223
+ return { dir: target.dir, exists: false, bytes: 0, missions: 0, years: [], files: [], unreadable: [], index: null }
224
+ }
225
+ return {
226
+ dir: target.dir,
227
+ exists: true,
228
+ bytes: 0,
229
+ missions: 0,
230
+ years: [],
231
+ files: [],
232
+ unreadable: [diagnostic(target.dir, String(error?.message ?? error))],
233
+ index: null,
234
+ }
235
+ }
236
+
237
+ const jsonFiles = names.filter((name) => name.endsWith('.json')).sort()
238
+ const files = []
239
+ let bytes = 0
240
+ let index = null
241
+ const yearIds = new Set()
242
+ for (const name of jsonFiles) {
243
+ const file = path.join(target.dir, name)
244
+ const kind = name === 'index.json' ? 'index' : 'year'
245
+ const inspected = await inspectFile(file, kind)
246
+ files.push({
247
+ file,
248
+ kind,
249
+ ok: inspected.ok,
250
+ bytes: inspected.bytes,
251
+ missions: inspected.missions,
252
+ ...(inspected.error ? { error: inspected.error } : {}),
253
+ })
254
+ bytes += inspected.bytes
255
+ if (kind === 'index') {
256
+ index = { ok: inspected.ok, count: inspected.missions, ...(inspected.error ? { error: inspected.error } : {}) }
257
+ } else if (inspected.ok) {
258
+ for (const entry of inspected.document.missions) yearIds.add(entry.id)
259
+ }
260
+ }
261
+ const unreadable = files
262
+ .filter((file) => !file.ok && typeof file.error === 'string')
263
+ .map((file) => /** @type {string} */ (file.error))
264
+ const years = files
265
+ .filter((file) => file.kind === 'year' && file.ok)
266
+ .map((file) => Number(path.basename(file.file, '.json')))
267
+ .filter((year) => Number.isInteger(year))
268
+ .sort((left, right) => left - right)
269
+ return {
270
+ dir: target.dir,
271
+ exists: true,
272
+ bytes,
273
+ missions: index?.ok ? index.count : yearIds.size,
274
+ years,
275
+ files,
276
+ unreadable,
277
+ index,
278
+ }
279
+ }
@@ -0,0 +1,176 @@
1
+ // Mission briefings: pure text builders for worker/reviewer prompts.
2
+
3
+ function clip(text, max = 2000) {
4
+ const value = String(text ?? '')
5
+ return value.length > max ? `${value.slice(0, max)}\n… truncated` : value
6
+ }
7
+
8
+ export function workerBriefing({
9
+ repoRoot,
10
+ base,
11
+ mission,
12
+ deps,
13
+ worktree,
14
+ instructions,
15
+ reviews = [],
16
+ findings = [],
17
+ completionReport = null,
18
+ }) {
19
+ const completion = /** @type {import('../types/index.js').CompletionReport | null} */ (completionReport)
20
+ const lines = []
21
+ lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
22
+ lines.push('')
23
+ lines.push(`You are the tower worker for mission ${mission.id} in repository ${repoRoot}.`)
24
+ lines.push('')
25
+ lines.push(`Work ONLY in this git worktree: ${worktree.path}`)
26
+ lines.push(`Branch: ${worktree.branch} (based on ${base} @ ${String(worktree.baseCommit).slice(0, 12)})`)
27
+ lines.push('')
28
+ lines.push(`Kind: ${mission.kind}`)
29
+ lines.push(
30
+ `Scope (only files you may touch): ${mission.scope.length > 0 ? mission.scope.join(', ') : '(none — survey is read-only)'}`,
31
+ )
32
+ if (mission.tasks.length > 0) {
33
+ lines.push('Tasks:')
34
+ for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
35
+ }
36
+ if (deps.length > 0) {
37
+ lines.push('Dependencies:')
38
+ for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
39
+ }
40
+ if (mission.context) {
41
+ lines.push('Context (verbatim from the user):')
42
+ lines.push(mission.context)
43
+ }
44
+ if ((mission.references ?? []).length > 0) {
45
+ lines.push('References (read-only pointers; read them before deciding):')
46
+ for (const reference of mission.references) lines.push(`- ${reference}`)
47
+ }
48
+ if (mission.reviewPolicy === 'none') {
49
+ lines.push(`Review policy: none (user exemption: ${mission.reviewExemptionReason ?? 'missing reason'})`)
50
+ }
51
+ if (completion) {
52
+ lines.push('')
53
+ lines.push(`Previous completion report (by ${completion.by ?? 'worker'}):`)
54
+ lines.push(clip(completion.report, 1500))
55
+ if ((completion.checks ?? []).length > 0) {
56
+ lines.push('Previous checks:')
57
+ for (const check of completion.checks ?? []) lines.push(`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}`)
58
+ }
59
+ }
60
+ if (reviews.length > 0) {
61
+ lines.push('')
62
+ lines.push('Previous review rounds for this branch (newest first) — address every finding:')
63
+ for (const review of reviews) {
64
+ lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
65
+ if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
66
+ if (review.decision) lines.push(` decision: ${clip(review.decision, 600)}`)
67
+ }
68
+ }
69
+ if (findings.length > 0) {
70
+ lines.push('')
71
+ lines.push('Findings related to this mission:')
72
+ for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
73
+ }
74
+ lines.push('')
75
+ lines.push('Rules:')
76
+ lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
77
+ lines.push('- Stay inside the declared scope; out-of-scope changes are rejected later.')
78
+ lines.push('- Commit your work on the mission branch. Do not merge, push, or spawn other agents.')
79
+ lines.push(
80
+ `- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
81
+ )
82
+ lines.push(
83
+ `- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>",checks:[{name,command,exitCode,evidence}]}). Unread inbox messages block completion, so read them with tower_inbox first.`,
84
+ )
85
+ lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
86
+ if (instructions) {
87
+ lines.push('')
88
+ lines.push('Extra tower instructions:')
89
+ lines.push(instructions)
90
+ }
91
+ return lines.join('\n')
92
+ }
93
+
94
+ export function reviewerBriefing({
95
+ repoRoot,
96
+ base,
97
+ mission,
98
+ deps,
99
+ worktree,
100
+ instructions,
101
+ reviews = [],
102
+ findings = [],
103
+ completionReport = null,
104
+ }) {
105
+ const completion = /** @type {import('../types/index.js').CompletionReport | null} */ (completionReport)
106
+ const lines = []
107
+ lines.push(`# Tower review — mission ${mission.id} — ${mission.title}`)
108
+ lines.push('')
109
+ lines.push(`You are the independent reviewer for mission ${mission.id} in repository ${repoRoot}.`)
110
+ lines.push('')
111
+ lines.push(`Review branch: ${worktree.branch}`)
112
+ lines.push(`Worktree: ${worktree.path}`)
113
+ lines.push(`Reviewed base: ${base} @ ${String(worktree.baseCommit).slice(0, 12)}`)
114
+ lines.push('')
115
+ lines.push(`Kind: ${mission.kind}`)
116
+ if (mission.tasks.length > 0) {
117
+ lines.push('The worker was asked to complete:')
118
+ for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
119
+ }
120
+ if (deps.length > 0) {
121
+ lines.push('Dependencies:')
122
+ for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
123
+ }
124
+ if (mission.context) {
125
+ lines.push('Context (verbatim from the user):')
126
+ lines.push(mission.context)
127
+ }
128
+ if ((mission.references ?? []).length > 0) {
129
+ lines.push('References (read-only pointers):')
130
+ for (const reference of mission.references) lines.push(`- ${reference}`)
131
+ }
132
+ if (completion) {
133
+ lines.push('')
134
+ lines.push(`Completion report (by ${completion.by ?? 'worker'}):`)
135
+ lines.push(clip(completion.report, 2000))
136
+ if ((completion.checks ?? []).length > 0) {
137
+ lines.push('Completion checks the worker ran:')
138
+ for (const check of completion.checks ?? []) {
139
+ lines.push(
140
+ `- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}${check.evidence ? ` — ${clip(check.evidence, 400)}` : ''}`,
141
+ )
142
+ }
143
+ }
144
+ }
145
+ if (reviews.length > 0) {
146
+ lines.push('')
147
+ lines.push('Prior review rounds for this branch (newest first):')
148
+ for (const review of reviews) {
149
+ lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
150
+ if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
151
+ }
152
+ }
153
+ if (findings.length > 0) {
154
+ lines.push('')
155
+ lines.push('Related findings:')
156
+ for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
157
+ }
158
+ lines.push('')
159
+ lines.push('Rules:')
160
+ lines.push('- Read-only: do not modify files, commit, or merge.')
161
+ lines.push('- Inspect the diff from the reviewed base to the branch tip and map every task to the diff.')
162
+ lines.push('- Check scope, correctness, tests, and security. Report severity and concrete evidence.')
163
+ lines.push(
164
+ `- Submit the formal verdict with tower_review({repoRoot:"${repoRoot}",missionId:"${mission.id}",status:"clean",merge:"merge",findings:"none",checks:["..."],decision:"..."}). Use status "p1-Nitems"/"p2-Nitems" and merge "fix-then-merge"/"hold" for problems; a non-clean verdict returns the mission to active.`,
165
+ )
166
+ lines.push(
167
+ `- File out-of-scope issues with tower_finding({repoRoot:"${repoRoot}",type:"bug",title:"...",summary:"...",details:"...",suggested_fix:"..."}) instead of fixing them.`,
168
+ )
169
+ lines.push('- Your final message is the review report; the tool call above is the formal verdict.')
170
+ if (instructions) {
171
+ lines.push('')
172
+ lines.push('Extra tower instructions:')
173
+ lines.push(instructions)
174
+ }
175
+ return lines.join('\n')
176
+ }
@@ -0,0 +1,66 @@
1
+ // Case-insensitive-filesystem detection (#90).
2
+ //
3
+ // On Windows and macOS the checkout's filesystem folds case, while Git reports
4
+ // the casing stored in the index. Scope matching has to know which world it is
5
+ // in, so `tower_init` probes once and records the answer in state.
6
+ //
7
+ // The probe creates one uniquely-named mixed-case file in the checkout root
8
+ // (the filesystem scope matching actually compares) and looks its lower-cased
9
+ // name up. It returns:
10
+ // true the lookup found the file: the filesystem folds case;
11
+ // false the lookup was ENOENT: the filesystem is case-sensitive;
12
+ // null the probe could not run (read-only checkout, EPERM, an unexpected
13
+ // stat failure). "Unknown" is deliberately not "case-sensitive": init
14
+ // keeps working and `tower_doctor` reports the safe default.
15
+ import path from 'node:path'
16
+ import { fsp } from '../adapters/store-fs.js'
17
+
18
+ /** Prefix is public so tests and support code can recognise a leaked probe. */
19
+ export const CASE_PROBE_PREFIX = '.tower-case-probe-'
20
+
21
+ /**
22
+ * A name that is guaranteed to differ from its lower-cased form, so the lookup
23
+ * actually distinguishes the two filesystem behaviours.
24
+ * @param {() => number} random
25
+ * @returns {string}
26
+ */
27
+ function probeName(random) {
28
+ return `${CASE_PROBE_PREFIX}Z${random().toString(36).slice(2, 10)}`
29
+ }
30
+
31
+ /**
32
+ * @param {string} dir directory on the filesystem under test
33
+ * @param {{ fs?: typeof fsp, name?: string, random?: () => number }} [options]
34
+ * `fs` is a seam for the failure branches, which cannot be produced portably
35
+ * by permissions on every OS (Windows does not honour chmod the way POSIX does).
36
+ * @returns {Promise<boolean | null>}
37
+ */
38
+ export async function detectCaseSensitivity(dir, options = {}) {
39
+ const fs = options.fs ?? fsp
40
+ const name = options.name ?? probeName(options.random ?? Math.random)
41
+ const probePath = path.join(dir, name)
42
+ const lowerPath = path.join(dir, name.toLowerCase())
43
+ if (lowerPath === probePath) return null
44
+
45
+ try {
46
+ await fs.writeFile(probePath, '')
47
+ } catch {
48
+ return null
49
+ }
50
+
51
+ try {
52
+ await fs.stat(lowerPath)
53
+ return true
54
+ } catch (error) {
55
+ const code = /** @type {{ code?: string }} */ (error)?.code
56
+ return code === 'ENOENT' ? false : null
57
+ } finally {
58
+ // Best effort: a leaked probe would dirty `git status` of the state dir and
59
+ // the detection result is already known.
60
+ try {
61
+ await fs.unlink(probePath)
62
+ } catch {
63
+ // the probe file may already be gone (or the failure branch never wrote it)
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,108 @@
1
+ // Release compatibility provenance. COMPATIBILITY.md is shipped in the package;
2
+ // no network lookup is involved.
3
+ import { fs } from '../adapters/store-fs.js'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
8
+
9
+ export function parseVersion(value) {
10
+ const text = String(value ?? '').trim()
11
+ const match = text.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
12
+ if (!match) return null
13
+ return {
14
+ numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
15
+ prerelease: match[4] ? match[4].split('.') : [],
16
+ }
17
+ }
18
+
19
+ function comparePrerelease(a, b) {
20
+ if (a.length === 0 && b.length === 0) return 0
21
+ if (a.length === 0) return 1
22
+ if (b.length === 0) return -1
23
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
24
+ if (a[index] === undefined) return -1
25
+ if (b[index] === undefined) return 1
26
+ const aNumber = /^\d+$/.test(a[index])
27
+ const bNumber = /^\d+$/.test(b[index])
28
+ if (aNumber && bNumber) {
29
+ const delta = Number(a[index]) - Number(b[index])
30
+ if (delta !== 0) return delta
31
+ } else if (aNumber !== bNumber) {
32
+ return aNumber ? -1 : 1
33
+ } else if (a[index] !== b[index]) {
34
+ return a[index] < b[index] ? -1 : 1
35
+ }
36
+ }
37
+ return 0
38
+ }
39
+
40
+ export function compareVersions(a, b) {
41
+ const left = typeof a === 'string' ? parseVersion(a) : a
42
+ const right = typeof b === 'string' ? parseVersion(b) : b
43
+ if (!left || !right) return null
44
+ for (let index = 0; index < 3; index += 1) {
45
+ if (left.numbers[index] !== right.numbers[index]) return left.numbers[index] < right.numbers[index] ? -1 : 1
46
+ }
47
+ return comparePrerelease(left.prerelease, right.prerelease)
48
+ }
49
+
50
+ export function parseCompatibility(text) {
51
+ const rows = []
52
+ for (const line of String(text ?? '').split('\n')) {
53
+ if (!/^\|/.test(line)) continue
54
+ const cells = line.split('|').slice(1, -1).map((cell) => cell.trim())
55
+ if (cells.length < 4) continue
56
+ if (!parseVersion(cells[0])) continue
57
+ rows.push({ hostVersion: cells[0], commit: cells[1], verifiedOn: cells[2], ran: cells[3] })
58
+ }
59
+ return rows
60
+ }
61
+
62
+ export function readCompatibility(file = path.join(PACKAGE_ROOT, 'COMPATIBILITY.md')) {
63
+ try {
64
+ return parseCompatibility(fs.readFileSync(file, 'utf8'))
65
+ } catch {
66
+ return []
67
+ }
68
+ }
69
+
70
+ export function assessHostVersion(hostVersion, rows) {
71
+ const parsed = parseVersion(hostVersion)
72
+ if (!parsed) return { verdict: 'unknown', row: null, detail: 'cannot parse the running host version' }
73
+ const exact = rows.find((row) => compareVersions(row.hostVersion, parsed) === 0)
74
+ if (exact) return { verdict: 'verified', row: exact, detail: `${exact.hostVersion} verified on ${exact.verifiedOn}` }
75
+ const known = rows
76
+ .map((row) => ({ row, cmp: compareVersions(parsed, row.hostVersion) }))
77
+ .filter((entry) => entry.cmp !== null)
78
+ if (known.length === 0) return { verdict: 'unknown', row: null, detail: 'COMPATIBILITY.md has no version rows' }
79
+ const newest = known.reduce((best, entry) => (entry.cmp > best.cmp ? entry : best), known[0])
80
+ if (newest.cmp > 0) {
81
+ return {
82
+ verdict: 'untested-newer',
83
+ row: newest.row,
84
+ detail: `${hostVersion} is newer than every verified build (latest row ${newest.row.hostVersion})`,
85
+ }
86
+ }
87
+ return {
88
+ verdict: 'untested-older',
89
+ row: newest.row,
90
+ detail: `${hostVersion} is older than the verified builds (latest row ${newest.row.hostVersion})`,
91
+ }
92
+ }
93
+
94
+ export function detectHostVersion(ctx, env = process.env) {
95
+ for (const value of [env.DSH_VERSION, env.DSH_HOST_VERSION, env.npm_config_dsh_version]) {
96
+ if (typeof value === 'string' && parseVersion(value)) return value
97
+ }
98
+ for (const serviceName of ['app', 'runtime', 'host']) {
99
+ try {
100
+ const service = ctx?.get?.(serviceName)
101
+ const value = service && (service.version ?? service.build?.version)
102
+ if (typeof value === 'string' && parseVersion(value)) return value
103
+ } catch {
104
+ // defensive: a missing service is not a doctor failure
105
+ }
106
+ }
107
+ return null
108
+ }