dsh-tower 2.5.0 → 2.7.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 +114 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +1 -1
- package/GLOSSARY.md +6 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +18 -16
- package/README.md +18 -11
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +104 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +31 -20
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +34 -6
- package/client.js +321 -0
- package/cordis.patch.yml +55 -3
- package/docs/adr/0005-component-surface.md +58 -13
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +18 -10
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +58 -7
- package/docs/guide/quickstart.md +20 -10
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/runbook.md +1 -1
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +20 -11
- package/docs/reference/component-surface.md +20 -8
- package/docs/reference/config.generated.md +8 -5
- package/docs/reference/config.md +30 -5
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +53 -4
- package/docs/reference/testing.md +5 -3
- package/docs/reference/tools.generated.md +7 -7
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +25 -6
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +13 -9
- package/docs/zh/reference/config.md +22 -5
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +5 -4
- package/docs/zh/reference/tools.md +3 -3
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/components.js +12 -4
- package/domain/config/schema.js +87 -22
- package/domain/contracts/outputs.js +2 -1
- package/domain/contracts/tool-inputs/missions.js +10 -2
- package/domain/errors.js +6 -0
- package/domain/evidence.js +283 -0
- package/domain/gate.js +17 -34
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/integration/review.js +203 -9
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/config.js +40 -3
- package/host/doctor.js +20 -8
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +17 -11
- package/host/tool-registry.js +23 -13
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +19 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-components.mjs +120 -6
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +5 -0
- package/scripts/component-surface.json +83 -29
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +22 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +75 -4
- package/scripts/types-coverage.mjs +7 -0
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/review.js +1 -1
- package/tools/spawn.js +48 -50
- package/tools/status.js +8 -1
- package/tools/test.js +2 -1
- package/types/index.d.ts +76 -5
package/adapters/legacy.js
CHANGED
|
@@ -12,16 +12,48 @@
|
|
|
12
12
|
import { fsp } from './store-fs.js'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import crypto from 'node:crypto'
|
|
15
|
+
import { isShapeKind } from '../domain/shapes/index.js'
|
|
15
16
|
|
|
16
17
|
const SCHEMA_VERSION = 1
|
|
17
18
|
const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
|
|
18
19
|
const DSH_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
|
|
19
|
-
// Tower Codex also retires missions; retired is
|
|
20
|
+
// Tower Codex also retires missions; `retired` is accepted input vocabulary and
|
|
21
|
+
// is remapped below. It is never a DSH mission status.
|
|
20
22
|
const CODEX_STATUSES = [...DSH_STATUSES, 'retired']
|
|
21
23
|
const DSH_ID_RE = /^M\d+$/
|
|
22
24
|
const CODEX_ID_RE = /^T\d{4,}$/
|
|
23
25
|
const SHA256_RE = /^[a-f0-9]{64}$/
|
|
24
26
|
|
|
27
|
+
// The strict adapter contract (#149 W4). One payload is exactly one legacy
|
|
28
|
+
// format; every status and field alias this module accepts is declared here.
|
|
29
|
+
// Missions are normalised to the shape registry's DSH vocabulary: a status
|
|
30
|
+
// remap is applied and reported in `remapped`, never silently invented, and a
|
|
31
|
+
// value the adapter cannot carry is reported in `unresolved`/`dropped`.
|
|
32
|
+
export const LEGACY_FORMATS = Object.freeze({
|
|
33
|
+
'tower-codex': Object.freeze({
|
|
34
|
+
idPattern: CODEX_ID_RE,
|
|
35
|
+
idHint: 'T####',
|
|
36
|
+
statuses: CODEX_STATUSES,
|
|
37
|
+
statusRemap: Object.freeze({ retired: 'abandoned' }),
|
|
38
|
+
baseBranchField: 'baseBranch',
|
|
39
|
+
requiresBaseBranch: true,
|
|
40
|
+
}),
|
|
41
|
+
dsh: Object.freeze({
|
|
42
|
+
idPattern: DSH_ID_RE,
|
|
43
|
+
idHint: 'M#',
|
|
44
|
+
statuses: DSH_STATUSES,
|
|
45
|
+
statusRemap: Object.freeze({}),
|
|
46
|
+
baseBranchField: 'base',
|
|
47
|
+
requiresBaseBranch: false,
|
|
48
|
+
}),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Only these fields cross the adapter boundary. Everything else a legacy
|
|
52
|
+
// mission carries is validated for safety, then reported as dropped: the
|
|
53
|
+
// adapter deliberately does not invent DSH semantics for it.
|
|
54
|
+
const CARRIED_MISSION_FIELDS = Object.freeze(['id', 'status', 'kind'])
|
|
55
|
+
const DROPPED_REASON = 'validated for safety but not carried into the summary; the exact payload bytes remain in the stored snapshot'
|
|
56
|
+
|
|
25
57
|
function fail(code, message) {
|
|
26
58
|
/** @type {Error & {code?: string}} */
|
|
27
59
|
const error = new Error(message)
|
|
@@ -86,6 +118,9 @@ function invalidResult(error, format = null) {
|
|
|
86
118
|
missionIds: [],
|
|
87
119
|
missionCount: 0,
|
|
88
120
|
paused: [],
|
|
121
|
+
unresolved: [],
|
|
122
|
+
dropped: [],
|
|
123
|
+
remapped: [],
|
|
89
124
|
events: null,
|
|
90
125
|
legacySnapshots: null,
|
|
91
126
|
repoRoot: null,
|
|
@@ -140,31 +175,89 @@ function validateReviews(reviews, id) {
|
|
|
140
175
|
return null
|
|
141
176
|
}
|
|
142
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Detect one legacy format per payload. A declared `format` is authoritative;
|
|
180
|
+
* otherwise every mission id must belong to exactly one namespace. Empty
|
|
181
|
+
* mission lists use the top-level shape (a Tower Codex snapshot has a
|
|
182
|
+
* baseBranch; DSH state uses `base`).
|
|
183
|
+
* @param {any} payload
|
|
184
|
+
* @returns {{ format: 'tower-codex'|'dsh'|null, error: string|null }}
|
|
185
|
+
*/
|
|
186
|
+
function detectFormat(payload) {
|
|
187
|
+
if (payload.format !== undefined) {
|
|
188
|
+
if (typeof payload.format !== 'string' || !Object.prototype.hasOwnProperty.call(LEGACY_FORMATS, payload.format)) {
|
|
189
|
+
return { format: null, error: `unsupported legacy format ${JSON.stringify(payload.format)} (expected tower-codex or dsh)` }
|
|
190
|
+
}
|
|
191
|
+
return { format: payload.format, error: null }
|
|
192
|
+
}
|
|
193
|
+
if (payload.missions.length === 0) {
|
|
194
|
+
return { format: isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh', error: null }
|
|
195
|
+
}
|
|
196
|
+
let codex = 0
|
|
197
|
+
let dsh = 0
|
|
198
|
+
for (const mission of payload.missions) {
|
|
199
|
+
const id = mission?.id
|
|
200
|
+
if (typeof id === 'string' && CODEX_ID_RE.test(id)) codex += 1
|
|
201
|
+
else if (typeof id === 'string' && DSH_ID_RE.test(id)) dsh += 1
|
|
202
|
+
}
|
|
203
|
+
if (codex === payload.missions.length) return { format: 'tower-codex', error: null }
|
|
204
|
+
if (dsh === payload.missions.length) return { format: 'dsh', error: null }
|
|
205
|
+
for (const mission of payload.missions) {
|
|
206
|
+
const id = mission?.id
|
|
207
|
+
if (typeof id !== 'string' || (!CODEX_ID_RE.test(id) && !DSH_ID_RE.test(id))) {
|
|
208
|
+
return {
|
|
209
|
+
format: null,
|
|
210
|
+
error: `unsupported legacy mission id ${JSON.stringify(id)} (expected T#### for Tower Codex or M# for DSH)`,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { format: null, error: 'legacy snapshot mixes mission id namespaces (T#### and M#); one snapshot is one format' }
|
|
215
|
+
}
|
|
216
|
+
|
|
143
217
|
/**
|
|
144
218
|
* @param {any} payload
|
|
145
219
|
* @param {'tower-codex'|'dsh'} format
|
|
146
|
-
* @
|
|
220
|
+
* @param {{ idPattern: RegExp, idHint: string, statuses: string[], statusRemap: Record<string, string>, baseBranchField: string, requiresBaseBranch: boolean }} spec
|
|
221
|
+
* @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[],
|
|
222
|
+
* unresolved:Array<{missionId:string, field:string, reason:string}>,
|
|
223
|
+
* dropped:Array<{field:string, reason:string}>,
|
|
224
|
+
* remapped:Array<{missionId:string, field:string, from:string, to:string}>}}
|
|
147
225
|
*/
|
|
148
|
-
function validateMissions(payload, format) {
|
|
149
|
-
const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
|
|
150
|
-
const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
|
|
226
|
+
function validateMissions(payload, format, spec) {
|
|
151
227
|
const missions = []
|
|
152
228
|
const missionIds = []
|
|
153
229
|
const paused = []
|
|
230
|
+
const unresolved = []
|
|
231
|
+
const remapped = []
|
|
232
|
+
const droppedFields = new Set()
|
|
154
233
|
const seen = new Set()
|
|
155
234
|
for (let index = 0; index < payload.missions.length; index += 1) {
|
|
156
235
|
const mission = payload.missions[index]
|
|
157
|
-
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [] }
|
|
158
|
-
if (typeof mission.id !== 'string' || !
|
|
159
|
-
return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${
|
|
236
|
+
if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
237
|
+
if (typeof mission.id !== 'string' || !spec.idPattern.test(mission.id)) {
|
|
238
|
+
return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${spec.idHint})`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
160
239
|
}
|
|
161
|
-
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [] }
|
|
240
|
+
if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
162
241
|
seen.add(mission.id)
|
|
163
|
-
if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
|
|
164
|
-
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [] }
|
|
242
|
+
if (typeof mission.status !== 'string' || !spec.statuses.includes(mission.status)) {
|
|
243
|
+
return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
244
|
+
}
|
|
245
|
+
const mappedStatus = spec.statusRemap[mission.status]
|
|
246
|
+
if (mappedStatus !== undefined) {
|
|
247
|
+
remapped.push({ missionId: mission.id, field: 'status', from: mission.status, to: mappedStatus })
|
|
248
|
+
}
|
|
249
|
+
const status = mappedStatus ?? mission.status
|
|
250
|
+
if (!DSH_STATUSES.includes(status)) {
|
|
251
|
+
return { error: `legacy status ${JSON.stringify(mission.status)} has no declared DSH mapping`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
165
252
|
}
|
|
166
|
-
|
|
167
|
-
|
|
253
|
+
let kind = 'build'
|
|
254
|
+
if (mission.kind !== undefined) {
|
|
255
|
+
if (!isShapeKind(mission.kind)) {
|
|
256
|
+
return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
257
|
+
}
|
|
258
|
+
kind = mission.kind
|
|
259
|
+
} else {
|
|
260
|
+
unresolved.push({ missionId: mission.id, field: 'kind', reason: 'legacy mission has no kind; assumed build' })
|
|
168
261
|
}
|
|
169
262
|
for (const problem of [
|
|
170
263
|
validateScope(mission.scope, mission.id),
|
|
@@ -172,13 +265,24 @@ function validateMissions(payload, format) {
|
|
|
172
265
|
validateDeps(mission.deps, mission.id),
|
|
173
266
|
validateReviews(mission.reviews, mission.id),
|
|
174
267
|
]) {
|
|
175
|
-
if (problem) return { error: problem, missions: [], missionIds: [], paused: [] }
|
|
268
|
+
if (problem) return { error: problem, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
|
|
176
269
|
}
|
|
177
|
-
|
|
270
|
+
for (const key of Object.keys(mission)) {
|
|
271
|
+
if (!CARRIED_MISSION_FIELDS.includes(key)) droppedFields.add(key)
|
|
272
|
+
}
|
|
273
|
+
missions.push({ id: mission.id, status, kind })
|
|
178
274
|
missionIds.push(mission.id)
|
|
179
|
-
if (
|
|
275
|
+
if (status === 'paused') paused.push(mission.id)
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
missions,
|
|
279
|
+
missionIds,
|
|
280
|
+
paused,
|
|
281
|
+
unresolved,
|
|
282
|
+
dropped: [...droppedFields].sort().map((field) => ({ field, reason: DROPPED_REASON })),
|
|
283
|
+
remapped,
|
|
284
|
+
error: null,
|
|
180
285
|
}
|
|
181
|
-
return { missions, missionIds, paused, error: null }
|
|
182
286
|
}
|
|
183
287
|
|
|
184
288
|
/**
|
|
@@ -189,9 +293,12 @@ function validateMissions(payload, format) {
|
|
|
189
293
|
*
|
|
190
294
|
* @returns {{ok:boolean, format:'tower-codex'|'dsh'|null, schemaVersion:number|null,
|
|
191
295
|
* missions:Array<{id:string,status:string,kind:string}>, missionIds:string[],
|
|
192
|
-
* missionCount:number, paused:string[],
|
|
193
|
-
*
|
|
194
|
-
*
|
|
296
|
+
* missionCount:number, paused:string[],
|
|
297
|
+
* unresolved:Array<{missionId:string, field:string, reason:string}>,
|
|
298
|
+
* dropped:Array<{field:string, reason:string}>,
|
|
299
|
+
* remapped:Array<{missionId:string, field:string, from:string, to:string}>,
|
|
300
|
+
* events:number|null, legacySnapshots:number|null, repoRoot:string|null,
|
|
301
|
+
* baseBranch:string|null, error:string|null}}
|
|
195
302
|
*/
|
|
196
303
|
export function validateLegacySnapshot(payload) {
|
|
197
304
|
if (!isPlainObject(payload)) return invalidResult('legacy snapshot must be a JSON object')
|
|
@@ -204,30 +311,26 @@ export function validateLegacySnapshot(payload) {
|
|
|
204
311
|
}
|
|
205
312
|
if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
|
|
206
313
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
|
|
211
|
-
} else {
|
|
212
|
-
const firstId = payload.missions[0]?.id
|
|
213
|
-
if (typeof firstId === 'string' && CODEX_ID_RE.test(firstId)) format = 'tower-codex'
|
|
214
|
-
else if (typeof firstId === 'string' && DSH_ID_RE.test(firstId)) format = 'dsh'
|
|
215
|
-
else {
|
|
216
|
-
return invalidResult(
|
|
217
|
-
`unsupported legacy mission id ${JSON.stringify(firstId)} (expected T#### for Tower Codex or M# for DSH)`,
|
|
218
|
-
)
|
|
219
|
-
}
|
|
314
|
+
const detected = detectFormat(payload)
|
|
315
|
+
if (detected.error || !detected.format) {
|
|
316
|
+
return invalidResult(detected.error ?? 'legacy snapshot format could not be determined')
|
|
220
317
|
}
|
|
318
|
+
const format = detected.format
|
|
319
|
+
const spec = LEGACY_FORMATS[format]
|
|
221
320
|
|
|
222
321
|
const rejected = (error) => invalidResult(error, format)
|
|
223
|
-
const validated = validateMissions(payload, format)
|
|
322
|
+
const validated = validateMissions(payload, format, spec)
|
|
224
323
|
if (validated.error) return rejected(validated.error)
|
|
225
324
|
|
|
226
325
|
if (isNonEmptyString(payload.repoRoot) === false) {
|
|
227
326
|
return rejected(`legacy ${format} snapshot has no repoRoot string`)
|
|
228
327
|
}
|
|
229
|
-
if (
|
|
230
|
-
return rejected(
|
|
328
|
+
if (spec.requiresBaseBranch && isNonEmptyString(payload[spec.baseBranchField]) === false) {
|
|
329
|
+
return rejected(`legacy ${format} snapshot has no baseBranch string`)
|
|
330
|
+
}
|
|
331
|
+
const baseBranchValue = payload[spec.baseBranchField]
|
|
332
|
+
if (baseBranchValue !== undefined && baseBranchValue !== null && !isNonEmptyString(baseBranchValue)) {
|
|
333
|
+
return rejected(`legacy ${format} snapshot has no baseBranch string`)
|
|
231
334
|
}
|
|
232
335
|
if (payload.events !== undefined && !Array.isArray(payload.events)) {
|
|
233
336
|
return rejected('legacy snapshot events must be an array')
|
|
@@ -247,10 +350,13 @@ export function validateLegacySnapshot(payload) {
|
|
|
247
350
|
missionIds: validated.missionIds,
|
|
248
351
|
missionCount: validated.missionIds.length,
|
|
249
352
|
paused: validated.paused,
|
|
353
|
+
unresolved: validated.unresolved,
|
|
354
|
+
dropped: validated.dropped,
|
|
355
|
+
remapped: validated.remapped,
|
|
250
356
|
events: Array.isArray(payload.events) ? payload.events.length : null,
|
|
251
357
|
legacySnapshots: Array.isArray(payload.legacySnapshots) ? payload.legacySnapshots.length : null,
|
|
252
358
|
repoRoot: payload.repoRoot.trim(),
|
|
253
|
-
baseBranch:
|
|
359
|
+
baseBranch: isNonEmptyString(baseBranchValue) ? baseBranchValue.trim() : null,
|
|
254
360
|
error: null,
|
|
255
361
|
}
|
|
256
362
|
}
|
|
@@ -342,6 +448,9 @@ export async function importLegacySnapshot(input = {}) {
|
|
|
342
448
|
missionIds: validation.missionIds,
|
|
343
449
|
paused: validation.paused,
|
|
344
450
|
pausedCount: validation.paused.length,
|
|
451
|
+
unresolved: validation.unresolved,
|
|
452
|
+
dropped: validation.dropped,
|
|
453
|
+
remapped: validation.remapped,
|
|
345
454
|
events: validation.events,
|
|
346
455
|
legacySnapshots: validation.legacySnapshots,
|
|
347
456
|
sourcePath: resolvedSource,
|
|
@@ -362,6 +471,9 @@ export async function importLegacySnapshot(input = {}) {
|
|
|
362
471
|
missionIds: validation.missionIds,
|
|
363
472
|
missionCount: validation.missionCount,
|
|
364
473
|
pausedMissionIds: validation.paused,
|
|
474
|
+
unresolved: validation.unresolved,
|
|
475
|
+
droppedFields: validation.dropped.map((entry) => entry.field),
|
|
476
|
+
remapped: validation.remapped,
|
|
365
477
|
bytes: buffer.length,
|
|
366
478
|
importedAt,
|
|
367
479
|
}
|
package/adapters/stateFile.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// `state.migrated {from,to}` to the activity log.
|
|
16
16
|
// A migration that throws therefore leaves state.json byte-identical.
|
|
17
17
|
import { inspectStateVersion, prepareState } from '../domain/state.js'
|
|
18
|
+
import { legacySurveyNeedsRead } from '../domain/state/migrations.js'
|
|
18
19
|
import { isStateLockHeld } from './store.js'
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -37,6 +38,7 @@ import { isStateLockHeld } from './store.js'
|
|
|
37
38
|
* now?: number,
|
|
38
39
|
* persist?: boolean,
|
|
39
40
|
* migrations?: readonly import('../domain/state/migrations.js').StateMigration[],
|
|
41
|
+
* resolveLegacySurvey?: (rawState: Record<string, any>) => Promise<import('../domain/state/migrations.js').LegacySurveyRead | null>,
|
|
40
42
|
* }} input
|
|
41
43
|
* @returns {Promise<StateLoadResult>}
|
|
42
44
|
*/
|
|
@@ -54,7 +56,15 @@ export async function loadStateFile(input) {
|
|
|
54
56
|
await store.writeJsonAtomic(backupPath, raw)
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
// A legacy survey planned but never provisioned recorded no base at all, so
|
|
60
|
+
// the document alone cannot anchor it. Resolve the repository base exactly
|
|
61
|
+
// once, in the same moment as the migration, and hand it to the pure step.
|
|
62
|
+
const legacySurveyRead =
|
|
63
|
+
version.status === 'migratable' && legacySurveyNeedsRead(raw) && typeof input.resolveLegacySurvey === 'function'
|
|
64
|
+
? await input.resolveLegacySurvey(raw)
|
|
65
|
+
: null
|
|
66
|
+
|
|
67
|
+
const prepared = prepareState(raw, paths.state, { migrations, legacySurveyRead })
|
|
58
68
|
if (!prepared.changed) {
|
|
59
69
|
return { state: prepared.state, version, migration: null }
|
|
60
70
|
}
|
package/app/archive.js
CHANGED
|
@@ -144,10 +144,34 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
144
144
|
force: options.force === true,
|
|
145
145
|
protectedIds,
|
|
146
146
|
})
|
|
147
|
-
|
|
147
|
+
// #148 W2: never archive a mission whose disposable sandbox is still on disk.
|
|
148
|
+
// Archiving would move the only record of the path out of hot state while the
|
|
149
|
+
// checkout leaks; the mission stays hot (protected) until cleanup succeeds on
|
|
150
|
+
// a later mutate or tower_teardown, and this sweep names the caused path.
|
|
151
|
+
const sandboxPending = selected
|
|
152
|
+
.filter((mission) => typeof /** @type {any} */ (mission).sandbox === 'string' && /** @type {any} */ (mission).sandbox.length > 0)
|
|
153
|
+
.map((mission) => ({ missionId: mission.id, sandbox: /** @type {any} */ (mission).sandbox }))
|
|
154
|
+
const pendingIds = new Set(sandboxPending.map((entry) => entry.missionId))
|
|
155
|
+
const archivable = selected.filter((mission) => !pendingIds.has(mission.id))
|
|
156
|
+
for (const entry of sandboxPending) {
|
|
157
|
+
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
158
|
+
at: now,
|
|
159
|
+
type: 'archive',
|
|
160
|
+
missionId: entry.missionId,
|
|
161
|
+
text: `deferred: detached sandbox at ${entry.sandbox} is still present; cleanup retries on tower_teardown`,
|
|
162
|
+
}).catch(() => {})
|
|
163
|
+
}
|
|
164
|
+
if (archivable.length === 0) {
|
|
165
|
+
return {
|
|
166
|
+
archived: [],
|
|
167
|
+
written: false,
|
|
168
|
+
error: null,
|
|
169
|
+
...(sandboxPending.length > 0 ? { sandboxPending } : {}),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
148
172
|
|
|
149
173
|
const archivedYear = archiveYearFor(now)
|
|
150
|
-
const entries =
|
|
174
|
+
const entries = archivable.map((mission) =>
|
|
151
175
|
archivedEntry(mission, {
|
|
152
176
|
archivedAt: now,
|
|
153
177
|
archivedYear,
|
|
@@ -165,9 +189,9 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
165
189
|
if (!written.ok) return { archived: [], written: false, error: written.error ?? 'archive write failed' }
|
|
166
190
|
|
|
167
191
|
// The archive now holds the full record: compact state.json in the same lock.
|
|
168
|
-
const ids = new Set(
|
|
192
|
+
const ids = new Set(archivable.map((mission) => mission.id))
|
|
169
193
|
const branches = new Set(
|
|
170
|
-
|
|
194
|
+
archivable
|
|
171
195
|
.map((mission) => mission.worktree?.branch)
|
|
172
196
|
.filter((branch) => typeof branch === 'string'),
|
|
173
197
|
)
|
|
@@ -184,7 +208,7 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
184
208
|
state.revision += 1
|
|
185
209
|
state.updatedAt = now
|
|
186
210
|
|
|
187
|
-
for (const mission of
|
|
211
|
+
for (const mission of archivable) {
|
|
188
212
|
await tower.deps.store.appendJsonl(tower.paths.activity, {
|
|
189
213
|
at: now,
|
|
190
214
|
type: 'archive',
|
|
@@ -199,5 +223,6 @@ export async function sweepArchive(tower, state, options = {}) {
|
|
|
199
223
|
files: written.files,
|
|
200
224
|
error: null,
|
|
201
225
|
year: archivedYear,
|
|
226
|
+
...(sandboxPending.length > 0 ? { sandboxPending } : {}),
|
|
202
227
|
}
|
|
203
228
|
}
|
package/app/briefing.js
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
// Mission briefings: pure text builders for worker/reviewer prompts.
|
|
2
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
2
3
|
|
|
3
4
|
function clip(text, max = 2000) {
|
|
4
5
|
const value = String(text ?? '')
|
|
5
6
|
return value.length > max ? `${value.slice(0, max)}\n… truncated` : value
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Dependency lines shared by the worker and reviewer briefings. A survey
|
|
11
|
+
* dependency carries its recorded result (#148 D6) alongside its status, with
|
|
12
|
+
* bounded formatting so a large evidence set cannot crowd out the mission.
|
|
13
|
+
* @param {any[]} deps
|
|
14
|
+
* @returns {string[]}
|
|
15
|
+
*/
|
|
16
|
+
function dependencyLines(deps) {
|
|
17
|
+
const lines = []
|
|
18
|
+
for (const dep of deps) {
|
|
19
|
+
lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status}${dep.kind ? `, ${dep.kind}` : ''})`)
|
|
20
|
+
const result = dep.result
|
|
21
|
+
if (!result || typeof result !== 'object') continue
|
|
22
|
+
const evidence = []
|
|
23
|
+
if (Array.isArray(result.paths) && result.paths.length > 0) evidence.push(`paths: ${result.paths.slice(0, 20).join(', ')}`)
|
|
24
|
+
if (Array.isArray(result.symbols) && result.symbols.length > 0) evidence.push(`symbols: ${result.symbols.slice(0, 20).join(', ')}`)
|
|
25
|
+
if (Array.isArray(result.plan) && result.plan.length > 0) evidence.push(`plan: ${clip(result.plan.slice(0, 20).join('; '), 1200)}`)
|
|
26
|
+
if (typeof result.notes === 'string' && result.notes.trim()) evidence.push(`notes: ${clip(result.notes.trim(), 1200)}`)
|
|
27
|
+
if (evidence.length > 0) lines.push(` evidence: ${evidence.join(' | ')}`)
|
|
28
|
+
}
|
|
29
|
+
return lines
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export function workerBriefing({
|
|
9
33
|
repoRoot,
|
|
10
34
|
base,
|
|
@@ -17,25 +41,44 @@ export function workerBriefing({
|
|
|
17
41
|
completionReport = null,
|
|
18
42
|
}) {
|
|
19
43
|
const completion = /** @type {import('../types/index.js').CompletionReport | null} */ (completionReport)
|
|
44
|
+
const shape = shapeFor(mission.kind)
|
|
45
|
+
// The checkout the worker actually runs in: a recorded detached sandbox wins
|
|
46
|
+
// over the (absent) worktree, then a build worktree, then the shared main
|
|
47
|
+
// checkout. The spawn cwd uses the same precedence, so the prompt matches the
|
|
48
|
+
// process the agent is started in.
|
|
49
|
+
const cwd = mission.sandbox ?? worktree?.path ?? repoRoot
|
|
20
50
|
const lines = []
|
|
21
51
|
lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
|
|
22
52
|
lines.push('')
|
|
23
53
|
lines.push(`You are the tower worker for mission ${mission.id} in repository ${repoRoot}.`)
|
|
24
54
|
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
55
|
lines.push(
|
|
30
|
-
|
|
56
|
+
...shape.briefing(mission, {
|
|
57
|
+
repoRoot,
|
|
58
|
+
base,
|
|
59
|
+
worktree,
|
|
60
|
+
readCommit: /** @type {string | undefined} */ (mission.readCommit),
|
|
61
|
+
readDirty: mission.readDirty,
|
|
62
|
+
}),
|
|
31
63
|
)
|
|
64
|
+
lines.push('')
|
|
65
|
+
lines.push(`Kind: ${mission.kind}`)
|
|
66
|
+
// The empty scope means different things by shape: a build still owns its
|
|
67
|
+
// worktree, a detached survey owns a disposable writable sandbox, and only a
|
|
68
|
+
// shared survey is strictly read-only.
|
|
69
|
+
const emptyScope = shape.fields.worktree
|
|
70
|
+
? '(none declared — stay inside your worktree)'
|
|
71
|
+
: shape.fields.sandbox && mission.sandbox
|
|
72
|
+
? '(none declared — the detached survey sandbox is disposable: write and run commands inside it, but never commit or leave it)'
|
|
73
|
+
: '(none — survey is read-only)'
|
|
74
|
+
lines.push(`Scope (only files you may touch): ${mission.scope.length > 0 ? mission.scope.join(', ') : emptyScope}`)
|
|
32
75
|
if (mission.tasks.length > 0) {
|
|
33
76
|
lines.push('Tasks:')
|
|
34
77
|
for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
|
|
35
78
|
}
|
|
36
79
|
if (deps.length > 0) {
|
|
37
80
|
lines.push('Dependencies:')
|
|
38
|
-
|
|
81
|
+
lines.push(...dependencyLines(deps))
|
|
39
82
|
}
|
|
40
83
|
if (mission.context) {
|
|
41
84
|
lines.push('Context (verbatim from the user):')
|
|
@@ -57,6 +100,11 @@ export function workerBriefing({
|
|
|
57
100
|
for (const check of completion.checks ?? []) lines.push(`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}`)
|
|
58
101
|
}
|
|
59
102
|
}
|
|
103
|
+
if (mission.result) {
|
|
104
|
+
lines.push('')
|
|
105
|
+
lines.push('Recorded evidence (from a previous completion):')
|
|
106
|
+
lines.push(`- ${JSON.stringify(mission.result)}`)
|
|
107
|
+
}
|
|
60
108
|
if (reviews.length > 0) {
|
|
61
109
|
lines.push('')
|
|
62
110
|
lines.push('Previous review rounds for this branch (newest first) — address every finding:')
|
|
@@ -73,16 +121,34 @@ export function workerBriefing({
|
|
|
73
121
|
}
|
|
74
122
|
lines.push('')
|
|
75
123
|
lines.push('Rules:')
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
124
|
+
if (shape.fields.worktree) {
|
|
125
|
+
lines.push(`- Pass workdir="${cwd}" on every shell command; never edit the main checkout.`)
|
|
126
|
+
lines.push('- Stay inside the declared scope; out-of-scope changes are rejected later.')
|
|
127
|
+
lines.push('- Commit your work on the mission branch. Do not merge, push, or spawn other agents.')
|
|
128
|
+
} else if (shape.fields.sandbox && mission.sandbox) {
|
|
129
|
+
lines.push(`- Pass workdir="${mission.sandbox}" on every shell command; that checkout is a disposable detached sandbox, not the main checkout.`)
|
|
130
|
+
lines.push('- You may write files and run commands inside the sandbox, but never commit: completion requires the sandbox HEAD to still equal the recorded commit and zero new commits.')
|
|
131
|
+
lines.push(`- Stay inside the sandbox; never edit the main checkout ${repoRoot}. Do not merge, push, or spawn other agents.`)
|
|
132
|
+
} else {
|
|
133
|
+
lines.push(`- Read the shared checkout at ${cwd}; it is the main checkout, not your own worktree.`)
|
|
134
|
+
lines.push(
|
|
135
|
+
'- Make no commits: this mission reads one fixed commit. Do not write, edit, merge, push, rebase, or spawn other agents.',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
79
138
|
lines.push(
|
|
80
139
|
`- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
|
|
81
140
|
)
|
|
141
|
+
const resultHint = shape.acceptsResult
|
|
142
|
+
? ' Optionally record evidence as result:{paths?:string[],symbols?:string[],plan?:string[],notes?:string}.'
|
|
143
|
+
: ''
|
|
144
|
+
lines.push(
|
|
145
|
+
`- 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}]}).${resultHint} Unread inbox messages block completion, so read them with tower_inbox first.`,
|
|
146
|
+
)
|
|
82
147
|
lines.push(
|
|
83
|
-
|
|
148
|
+
shape.fields.worktree
|
|
149
|
+
? '- Your final message is the full handoff: files changed, how you verified, what remains.'
|
|
150
|
+
: '- Your final message is the full handoff: what you inspected, how you verified it, what remains.',
|
|
84
151
|
)
|
|
85
|
-
lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
|
|
86
152
|
if (instructions) {
|
|
87
153
|
lines.push('')
|
|
88
154
|
lines.push('Extra tower instructions:')
|
|
@@ -91,12 +157,19 @@ export function workerBriefing({
|
|
|
91
157
|
return lines.join('\n')
|
|
92
158
|
}
|
|
93
159
|
|
|
160
|
+
/**
|
|
161
|
+
* @param {{ repoRoot: string, base: string, mission: any, deps: any[],
|
|
162
|
+
* worktree: any, review?: { commit: string, path: string } | null,
|
|
163
|
+
* instructions?: string, reviews?: any[], findings?: any[],
|
|
164
|
+
* completionReport?: any }} input
|
|
165
|
+
*/
|
|
94
166
|
export function reviewerBriefing({
|
|
95
167
|
repoRoot,
|
|
96
168
|
base,
|
|
97
169
|
mission,
|
|
98
170
|
deps,
|
|
99
171
|
worktree,
|
|
172
|
+
review = null,
|
|
100
173
|
instructions,
|
|
101
174
|
reviews = [],
|
|
102
175
|
findings = [],
|
|
@@ -111,6 +184,12 @@ export function reviewerBriefing({
|
|
|
111
184
|
lines.push(`Review branch: ${worktree.branch}`)
|
|
112
185
|
lines.push(`Worktree: ${worktree.path}`)
|
|
113
186
|
lines.push(`Reviewed base: ${base} @ ${String(worktree.baseCommit).slice(0, 12)}`)
|
|
187
|
+
if (review) {
|
|
188
|
+
lines.push(`Review commit: ${String(review.commit).slice(0, 12)} (the evidence your verdict is stamped with)`)
|
|
189
|
+
lines.push(`Checkout: ${review.path}`)
|
|
190
|
+
lines.push('')
|
|
191
|
+
lines.push(`Read and inspect the review commit in ${review.path}; that checkout is removed after your verdict.`)
|
|
192
|
+
}
|
|
114
193
|
lines.push('')
|
|
115
194
|
lines.push(`Kind: ${mission.kind}`)
|
|
116
195
|
if (mission.tasks.length > 0) {
|
|
@@ -119,7 +198,7 @@ export function reviewerBriefing({
|
|
|
119
198
|
}
|
|
120
199
|
if (deps.length > 0) {
|
|
121
200
|
lines.push('Dependencies:')
|
|
122
|
-
|
|
201
|
+
lines.push(...dependencyLines(deps))
|
|
123
202
|
}
|
|
124
203
|
if (mission.context) {
|
|
125
204
|
lines.push('Context (verbatim from the user):')
|
|
@@ -158,6 +237,10 @@ export function reviewerBriefing({
|
|
|
158
237
|
lines.push('')
|
|
159
238
|
lines.push('Rules:')
|
|
160
239
|
lines.push('- Read-only: do not modify files, commit, or merge.')
|
|
240
|
+
if (review) {
|
|
241
|
+
lines.push(`- Pass workdir="${review.path}" on every command: the checkout is a detached read of commit ${String(review.commit).slice(0, 12)}, not the mutable mission worktree.`)
|
|
242
|
+
lines.push('- tower_diff shows the recorded commit your verdict will stamp; do not review anything newer.')
|
|
243
|
+
}
|
|
161
244
|
lines.push('- Inspect the diff from the reviewed base to the branch tip and map every task to the diff.')
|
|
162
245
|
lines.push('- Check scope, correctness, tests, and security. Report severity and concrete evidence.')
|
|
163
246
|
lines.push(
|