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.
- package/CHANGELOG.md +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +427 -0
- package/domain/contracts/outputs.js +305 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +212 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +45 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +102 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /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
|
+
}
|
package/lib/briefing.js
ADDED
|
@@ -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
|
+
}
|