dsh-tower 2.6.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 +82 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +2 -1
- package/README.md +6 -1
- 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 +97 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +25 -24
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +29 -1
- package/cordis.patch.yml +50 -0
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +6 -4
- package/docs/guide/install-upgrade-migration.md +26 -0
- package/docs/operations/runbook.md +1 -1
- package/docs/reference/blockers.md +8 -4
- package/docs/reference/component-surface.md +7 -2
- package/docs/reference/config.generated.md +6 -4
- package/docs/reference/config.md +1 -2
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +33 -1
- package/docs/reference/testing.md +4 -2
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +2 -2
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +1 -2
- package/docs/zh/reference/testing.md +3 -2
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/config/schema.js +47 -17
- package/domain/contracts/outputs.js +0 -1
- package/domain/contracts/tool-inputs/missions.js +9 -1
- package/domain/evidence.js +283 -0
- package/domain/gate.js +11 -31
- 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/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/doctor.js +2 -1
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +8 -3
- package/host/tool-registry.js +14 -12
- 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 +13 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +2 -0
- package/scripts/component-surface.json +68 -20
- 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 +21 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +68 -4
- 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/spawn.js +47 -49
- package/tools/test.js +2 -1
- package/types/index.d.ts +66 -5
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(
|
package/app/checkCompletion.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Worker completion preparation and commit use cases.
|
|
2
|
+
import path from 'node:path'
|
|
2
3
|
import { TowerToolError } from '../domain/errors.js'
|
|
3
4
|
import {
|
|
4
5
|
normalizeChecks,
|
|
@@ -7,12 +8,79 @@ import {
|
|
|
7
8
|
completionReport,
|
|
8
9
|
applyCompletion,
|
|
9
10
|
} from '../domain/missions/completion.js'
|
|
11
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
10
12
|
import { fullMission } from '../domain/summary.js'
|
|
11
13
|
import { validateState } from '../domain/state.js'
|
|
12
14
|
import { sweepArchive } from './archive.js'
|
|
15
|
+
import { cleanupSandbox } from './sandbox.js'
|
|
16
|
+
import { trackedFingerprint } from './sharedCheckout.js'
|
|
13
17
|
|
|
14
18
|
export { normalizeChecks }
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Read the primitives the shape's completion invariants need. Which reads happen
|
|
22
|
+
* is declared by the shape, so this layer never asks "is this a survey?"; a
|
|
23
|
+
* detached sandbox declares `requiresSandbox` and its HEAD/commit count is read
|
|
24
|
+
* from the recorded sandbox path instead of the shared checkout (#148 W2).
|
|
25
|
+
* @param {any} tower
|
|
26
|
+
* @param {import('../types/index.js').Mission} mission
|
|
27
|
+
* @param {any} invariants
|
|
28
|
+
* @returns {Promise<{ commits: number, clean: boolean, dirtyPaths: string[], head: string, trackedChanged: string[], readCommit?: string, sandbox?: string | null }>}
|
|
29
|
+
*/
|
|
30
|
+
async function completionFacts(tower, mission, invariants) {
|
|
31
|
+
const worktree = /** @type {any} */ (mission.worktree)
|
|
32
|
+
const sandbox = invariants.requiresSandbox ? String(/** @type {any} */ (mission).sandbox ?? '') : ''
|
|
33
|
+
/** @type {{ commits: number, clean: boolean, dirtyPaths: string[], head: string, trackedChanged: string[], readCommit?: string, sandbox?: string | null }} */
|
|
34
|
+
const facts = { commits: 0, clean: true, dirtyPaths: [], head: '', trackedChanged: [], sandbox: sandbox || null }
|
|
35
|
+
if (invariants.requiresSandbox) {
|
|
36
|
+
if (!sandbox) {
|
|
37
|
+
throw new TowerToolError('invalid-argument', `mission ${mission.id} has no detached sandbox recorded; re-provision it or abandon the survey`)
|
|
38
|
+
}
|
|
39
|
+
const entries = await tower.deps.vcs.listWorktrees(tower.root)
|
|
40
|
+
if (!entries.some((entry) => entry.path && path.resolve(entry.path) === path.resolve(sandbox))) {
|
|
41
|
+
throw new TowerToolError(
|
|
42
|
+
'invalid-argument',
|
|
43
|
+
`mission ${mission.id} detached sandbox ${sandbox} is not registered with git; the disposable checkout was removed — re-provision the survey`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (invariants.requiresWorktree) await tower.assertWorktree(worktree)
|
|
48
|
+
const cwd = sandbox || worktree?.path
|
|
49
|
+
if (invariants.requiresCommits) {
|
|
50
|
+
const base = invariants.requiresSandbox ? /** @type {string} */ (mission.readCommit) : worktree.baseCommit
|
|
51
|
+
facts.commits = await tower.deps.vcs.countCommits(cwd, base)
|
|
52
|
+
}
|
|
53
|
+
if (invariants.requiresClean) {
|
|
54
|
+
facts.clean = await tower.deps.vcs.isClean(cwd)
|
|
55
|
+
if (!facts.clean) {
|
|
56
|
+
facts.dirtyPaths = (await tower.git(cwd, ['status', '--porcelain']))
|
|
57
|
+
.split('\n')
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((line) => line.slice(3).trim())
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
facts.readCommit = /** @type {string | undefined} */ (mission.readCommit)
|
|
63
|
+
if (invariants.requiresHeadMatch || invariants.requiresTrackedStatus) {
|
|
64
|
+
// A shared survey reads the shared checkout: HEAD and the tracked fingerprint
|
|
65
|
+
// must match the plan-time record exactly (a path that changed either way
|
|
66
|
+
// counts). A detached sandbox reads its own disposable checkout: only HEAD
|
|
67
|
+
// must still equal the recorded commit; writes there are expected.
|
|
68
|
+
facts.head = await tower.revParse(invariants.requiresSandbox ? sandbox : tower.root, 'HEAD')
|
|
69
|
+
if (invariants.requiresTrackedStatus) {
|
|
70
|
+
const current = trackedFingerprint(await tower.git(tower.root, ['status', '--porcelain']))
|
|
71
|
+
const planned = new Set(mission.readDirty ?? [])
|
|
72
|
+
const currentSet = new Set(current)
|
|
73
|
+
facts.trackedChanged = [
|
|
74
|
+
...new Set([
|
|
75
|
+
...current.filter((path) => !planned.has(path)),
|
|
76
|
+
...(mission.readDirty ?? []).filter((path) => !currentSet.has(path)),
|
|
77
|
+
]),
|
|
78
|
+
].sort()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return facts
|
|
82
|
+
}
|
|
83
|
+
|
|
16
84
|
export async function checkCompletion(tower, input) {
|
|
17
85
|
const state = await tower.load()
|
|
18
86
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
@@ -25,24 +93,22 @@ export async function checkCompletion(tower, input) {
|
|
|
25
93
|
input,
|
|
26
94
|
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
27
95
|
})
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
96
|
+
const shape = shapeFor(mission.kind)
|
|
97
|
+
const invariants = /** @type {{
|
|
98
|
+
* requiresWorktree: boolean, requiresSandbox: boolean, requiresCommits: boolean,
|
|
99
|
+
* requiresClean: boolean, requiresHeadMatch: boolean, requiresTrackedStatus: boolean,
|
|
100
|
+
* check: (ctx: any) => { commits: number },
|
|
101
|
+
* }} */ ('resolveCompletion' in shape && typeof shape.resolveCompletion === 'function'
|
|
102
|
+
? shape.resolveCompletion(mission, tower.config)
|
|
103
|
+
: shape.completion)
|
|
104
|
+
if (invariants.requiresWorktree && !mission.worktree) {
|
|
105
|
+
throw new TowerToolError('invalid-argument', `mission ${mission.id} has no worktree`)
|
|
35
106
|
}
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
.split('\n')
|
|
39
|
-
.filter(Boolean)
|
|
40
|
-
.map((line) => line.slice(3).trim())
|
|
41
|
-
throw new TowerToolError(
|
|
42
|
-
'worktree-dirty',
|
|
43
|
-
`mission ${mission.id} worktree has uncommitted changes (${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ', …' : ''}); commit, remove, or record them before tower_complete`,
|
|
44
|
-
)
|
|
107
|
+
if (invariants.requiresSandbox && !/** @type {any} */ (mission).sandbox) {
|
|
108
|
+
throw new TowerToolError('invalid-argument', `mission ${mission.id} has no detached sandbox; re-provision it or abandon the survey`)
|
|
45
109
|
}
|
|
110
|
+
const facts = await completionFacts(tower, mission, invariants)
|
|
111
|
+
const { commits } = invariants.check({ mission, ...facts })
|
|
46
112
|
const unread = tower.unreadFor(state, worker.name)
|
|
47
113
|
assertCompletionInbox({ mission, worker, unread })
|
|
48
114
|
return { state, mission, worker, commits }
|
|
@@ -52,7 +118,7 @@ export async function complete(tower, input) {
|
|
|
52
118
|
// Two-phase pilot (#51): expensive git/state checks run before the lock;
|
|
53
119
|
// the lock only revalidates the state revision and commits the transition.
|
|
54
120
|
const prepared = await tower.checkCompletion(input)
|
|
55
|
-
|
|
121
|
+
const result = await tower.deps.store.withLock(tower.paths, async () => {
|
|
56
122
|
const state = await tower.load()
|
|
57
123
|
if (state.revision !== prepared.state.revision) {
|
|
58
124
|
throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
|
|
@@ -69,7 +135,7 @@ export async function complete(tower, input) {
|
|
|
69
135
|
now,
|
|
70
136
|
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
71
137
|
})
|
|
72
|
-
const applied = applyCompletion({ mission, report: storedReport, now })
|
|
138
|
+
const applied = applyCompletion({ mission, report: storedReport, now, result: input.result })
|
|
73
139
|
await sweepArchive(tower, state, { now })
|
|
74
140
|
state.revision += 1
|
|
75
141
|
state.updatedAt = now
|
|
@@ -85,4 +151,27 @@ export async function complete(tower, input) {
|
|
|
85
151
|
tower.emitEvents(applied.events, { revision: state.revision, at: now })
|
|
86
152
|
return { mission: fullMission(mission), commits, unread: 0 }
|
|
87
153
|
})
|
|
154
|
+
// The completion transition is durable at this point. Removing the disposable
|
|
155
|
+
// sandbox is advisory: a failed cleanup is reported, never rolled back into
|
|
156
|
+
// the mission state or allowed to fail the completion.
|
|
157
|
+
let sandboxCleanup = null
|
|
158
|
+
if (result.mission.sandbox) {
|
|
159
|
+
const sandbox = result.mission.sandbox
|
|
160
|
+
try {
|
|
161
|
+
sandboxCleanup = await cleanupSandbox(tower, input.missionId)
|
|
162
|
+
} catch (error) {
|
|
163
|
+
sandboxCleanup = {
|
|
164
|
+
ok: false,
|
|
165
|
+
removed: false,
|
|
166
|
+
attempts: 0,
|
|
167
|
+
path: sandbox,
|
|
168
|
+
error: String(error?.message ?? error).slice(0, 400),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (sandboxCleanup?.ok) {
|
|
172
|
+
delete result.mission.sandbox
|
|
173
|
+
delete result.mission.sandboxCleanupError
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ...result, ...(sandboxCleanup ? { sandboxCleanup } : {}) }
|
|
88
177
|
}
|
package/app/enrichedMissions.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function enrichedMissions(tower, state, onlyIds) {
|
|
|
12
12
|
let tipMoved = false
|
|
13
13
|
if (latest && mission.worktree && (await tower.deps.store.exists(mission.worktree.path))) {
|
|
14
14
|
try {
|
|
15
|
-
const tip = await tower.
|
|
15
|
+
const tip = await tower.branchTip(mission)
|
|
16
16
|
tipMoved = tip !== latest.reviewedCommit
|
|
17
17
|
} catch {
|
|
18
18
|
tipMoved = false
|
package/app/freshness.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Dependency freshness (#148 W2 / D5): a build that depends on a survey wants
|
|
2
|
+
// the survey's evidence to describe the code it starts from. A survey records
|
|
3
|
+
// the exact commit it read; when that commit is not an ancestor-or-equal of the
|
|
4
|
+
// build's base, the evidence may be stale. This is advisory by default and a
|
|
5
|
+
// blocker only when `gates.requireFreshSurvey` is true.
|
|
6
|
+
// @ts-check
|
|
7
|
+
import { shapeFor } from '../domain/shapes/index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import('../types/index.js').Mission} Mission
|
|
11
|
+
* @typedef {import('../types/index.js').TowerState} TowerState
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One stable, self-describing freshness warning. The same shape is promoted to a
|
|
16
|
+
* blocker (with `detail` carrying the message) when the gate asks for it.
|
|
17
|
+
* @param {{ missionId: string, dep: { id: string }, readCommit: string, baseCommit: string }} input
|
|
18
|
+
* @returns {{ code: 'survey-stale', missionId: string, depId: string, readCommit: string, baseCommit: string, message: string }}
|
|
19
|
+
*/
|
|
20
|
+
export function surveyStaleWarning({ missionId, dep, readCommit, baseCommit }) {
|
|
21
|
+
const read = String(readCommit).slice(0, 12)
|
|
22
|
+
const base = String(baseCommit).slice(0, 12)
|
|
23
|
+
return {
|
|
24
|
+
code: 'survey-stale',
|
|
25
|
+
missionId,
|
|
26
|
+
depId: dep.id,
|
|
27
|
+
readCommit,
|
|
28
|
+
baseCommit,
|
|
29
|
+
message: `survey ${dep.id} read ${read}, which is not an ancestor of ${missionId}'s base ${base}; the survey evidence may not describe the code this mission starts from — re-plan the survey against the current base, or record why the read is still valid`,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compute `survey-stale` warnings for one build mission's survey dependencies.
|
|
35
|
+
* A survey dependency is recognised through the shape contract (`fields.readCommit`),
|
|
36
|
+
* not a kind literal. The base is `mission.baseCommit` for a provisioned build,
|
|
37
|
+
* otherwise the current state base (resolved once by the caller or here).
|
|
38
|
+
* @param {any} tower
|
|
39
|
+
* @param {object} input
|
|
40
|
+
* @param {Mission} input.mission
|
|
41
|
+
* @param {TowerState} input.state
|
|
42
|
+
* @param {Mission[]} [input.missions]
|
|
43
|
+
* @param {Map<string, { id?: string, status: string, kind?: string, readCommit?: string }>} [input.archived]
|
|
44
|
+
* @param {string | null} [input.baseCommit] already-resolved build base
|
|
45
|
+
* @returns {Promise<Array<ReturnType<typeof surveyStaleWarning>>>}
|
|
46
|
+
*/
|
|
47
|
+
export async function surveyStaleWarnings(tower, { mission, state, missions, archived = new Map(), baseCommit = null }) {
|
|
48
|
+
if (!shapeFor(mission.kind).fields.worktree) return []
|
|
49
|
+
const deps = mission.deps ?? []
|
|
50
|
+
if (deps.length === 0) return []
|
|
51
|
+
let base = baseCommit ?? /** @type {any} */ (mission).baseCommit ?? null
|
|
52
|
+
if (typeof base !== 'string' || base.length === 0) {
|
|
53
|
+
try {
|
|
54
|
+
base = await tower.revParse(tower.root, state.base)
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const byId = new Map((missions ?? state.missions).map((entry) => [entry.id, entry]))
|
|
60
|
+
/** @type {Array<ReturnType<typeof surveyStaleWarning>>} */
|
|
61
|
+
const warnings = []
|
|
62
|
+
for (const depId of deps) {
|
|
63
|
+
const dep = byId.get(depId) ?? archived.get(depId)
|
|
64
|
+
if (!dep || !shapeFor(dep.kind).fields.readCommit) continue
|
|
65
|
+
const readCommit = typeof /** @type {any} */ (dep).readCommit === 'string' ? /** @type {any} */ (dep).readCommit : null
|
|
66
|
+
if (!readCommit) continue
|
|
67
|
+
let fresh = false
|
|
68
|
+
try {
|
|
69
|
+
fresh = await tower.isAncestor(tower.root, readCommit, base)
|
|
70
|
+
} catch {
|
|
71
|
+
fresh = false
|
|
72
|
+
}
|
|
73
|
+
if (fresh) continue
|
|
74
|
+
warnings.push(surveyStaleWarning({ missionId: mission.id, dep: /** @type {{ id: string }} */ (dep), readCommit, baseCommit: base }))
|
|
75
|
+
}
|
|
76
|
+
return warnings
|
|
77
|
+
}
|
package/app/gate.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
|
|
|
3
3
|
import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
|
|
4
4
|
import { fullMission } from '../domain/summary.js'
|
|
5
5
|
import { latestReview, reviewsForBranch } from '../domain/integration/review.js'
|
|
6
|
+
import { assertActionSupported } from '../domain/shapes/index.js'
|
|
6
7
|
import {
|
|
7
8
|
baseDirtyBlocker,
|
|
8
9
|
baseWrongBranchBlocker,
|
|
@@ -14,8 +15,8 @@ import {
|
|
|
14
15
|
outOfScopeBlocker,
|
|
15
16
|
overwrittenPaths,
|
|
16
17
|
staleBaseBlocker,
|
|
17
|
-
surveyChangedFilesBlocker,
|
|
18
18
|
} from '../domain/integration/gate.js'
|
|
19
|
+
import { surveyStaleWarnings } from './freshness.js'
|
|
19
20
|
|
|
20
21
|
export async function evaluateGate(tower, input) {
|
|
21
22
|
const state = await tower.load()
|
|
@@ -31,6 +32,9 @@ export async function evaluateGate(tower, input) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
if (!mission) return missingMissionGateResult(input.missionId)
|
|
35
|
+
// A shape without a branch has no merge gate at all; refuse before doing
|
|
36
|
+
// any git work so the caller gets one actionable code (#148 D4).
|
|
37
|
+
assertActionSupported(mission, input.action === 'merge' ? 'merge' : 'preflight')
|
|
34
38
|
const archiveIndex = await tower.archiveIndex()
|
|
35
39
|
const archivedMissions = new Map(
|
|
36
40
|
(archiveIndex.ok ? archiveIndex.missions : []).map((entry) => [entry.id, entry]),
|
|
@@ -56,7 +60,7 @@ export async function evaluateGate(tower, input) {
|
|
|
56
60
|
} else {
|
|
57
61
|
try {
|
|
58
62
|
await tower.assertWorktree(worktree)
|
|
59
|
-
branchTip = await tower.
|
|
63
|
+
branchTip = await tower.branchTip(mission)
|
|
60
64
|
baseTip = await tower.revParse(tower.root, state.base)
|
|
61
65
|
const mergeBase = (await tower.git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
|
|
62
66
|
changedFiles = (await tower.git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
|
|
@@ -66,20 +70,14 @@ export async function evaluateGate(tower, input) {
|
|
|
66
70
|
const blocker = staleBaseBlocker({ branch: worktree.branch, base: state.base, baseTip })
|
|
67
71
|
push(blocker.code, blocker.detail)
|
|
68
72
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (blocker) push(blocker.code, blocker.detail)
|
|
72
|
-
}
|
|
73
|
-
if (mission.kind !== 'survey') {
|
|
74
|
-
const blocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
|
|
75
|
-
if (blocker) push(blocker.code, blocker.detail)
|
|
76
|
-
}
|
|
73
|
+
const scopeBlocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
|
|
74
|
+
if (scopeBlocker) push(scopeBlocker.code, scopeBlocker.detail)
|
|
77
75
|
const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
|
|
78
76
|
.split('\n')
|
|
79
77
|
.filter(Boolean)
|
|
80
78
|
.map((line) => line.slice(3).trim())
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
79
|
+
const dirtyBlocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
|
|
80
|
+
if (dirtyBlocker) push(dirtyBlocker.code, dirtyBlocker.detail)
|
|
83
81
|
} catch (error) {
|
|
84
82
|
push('branch-missing', String(error?.message ?? error))
|
|
85
83
|
}
|
|
@@ -89,6 +87,21 @@ export async function evaluateGate(tower, input) {
|
|
|
89
87
|
push(blocker.code, blocker.detail)
|
|
90
88
|
}
|
|
91
89
|
|
|
90
|
+
// #148 W2: survey evidence freshness is advisory by default. The stable
|
|
91
|
+
// warning shape is what plan/preflight report; `gates.requireFreshSurvey`
|
|
92
|
+
// promotes the same code to a blocker for repositories that need the read
|
|
93
|
+
// to describe the exact base they start from.
|
|
94
|
+
const sandboxWarnings = await surveyStaleWarnings(tower, {
|
|
95
|
+
mission,
|
|
96
|
+
state,
|
|
97
|
+
missions: state.missions,
|
|
98
|
+
archived: archivedMissions,
|
|
99
|
+
baseCommit: mission.baseCommit ?? null,
|
|
100
|
+
})
|
|
101
|
+
if (tower.config.gates?.requireFreshSurvey === true) {
|
|
102
|
+
for (const warning of sandboxWarnings) blockers.push({ ...warning, detail: warning.message })
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
const branch = worktree ? worktree.branch : null
|
|
93
106
|
const latest = latestReview(reviewsForBranch(reviews, branch))
|
|
94
107
|
// #140: `mergeGateBlockers` is pure and may not read the component registry, so
|
|
@@ -132,6 +145,7 @@ export async function evaluateGate(tower, input) {
|
|
|
132
145
|
review: latest,
|
|
133
146
|
blockers,
|
|
134
147
|
}),
|
|
148
|
+
warnings: sandboxWarnings,
|
|
135
149
|
...(archivedEntry ? { archived: true } : {}),
|
|
136
150
|
}
|
|
137
151
|
}
|
package/app/merge.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// prepare stale, so the whole prepare is retried, never partially applied.
|
|
19
19
|
import path from 'node:path'
|
|
20
20
|
import { validateState } from '../domain/state.js'
|
|
21
|
+
import { assertActionSupported } from '../domain/shapes/index.js'
|
|
22
|
+
import { markBlocked } from '../domain/missions/lifecycle.js'
|
|
21
23
|
import {
|
|
22
24
|
alreadyMergedResult,
|
|
23
25
|
applyLandedMerge,
|
|
@@ -134,7 +136,7 @@ async function observeLandedMerge(tower, state, mission) {
|
|
|
134
136
|
let branchTip
|
|
135
137
|
try {
|
|
136
138
|
baseTip = await tower.revParse(tower.root, state.base)
|
|
137
|
-
branchTip = await tower.
|
|
139
|
+
branchTip = await tower.branchTip(mission)
|
|
138
140
|
} catch {
|
|
139
141
|
return null
|
|
140
142
|
}
|
|
@@ -238,6 +240,8 @@ async function prepareFromState(tower, input, deps) {
|
|
|
238
240
|
if (archived.ok && archived.entry) mission = archived.entry.mission
|
|
239
241
|
}
|
|
240
242
|
if (!mission) return { result: missionMissingResult(input.missionId) }
|
|
243
|
+
// A branchless shape has no merge to prepare; refuse before any git work.
|
|
244
|
+
assertActionSupported(mission, 'merge')
|
|
241
245
|
|
|
242
246
|
// Reconcile a stale merge intent: Git runs here, outside every state lock.
|
|
243
247
|
if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
|
|
@@ -272,7 +276,7 @@ async function prepareFromState(tower, input, deps) {
|
|
|
272
276
|
// Capture the exact commits this merge is allowed to integrate. The merge
|
|
273
277
|
// command below uses the captured branch SHA, never the mutable branch name.
|
|
274
278
|
const baseTip = await tower.revParse(tower.root, 'HEAD')
|
|
275
|
-
const branchTip = await tower.
|
|
279
|
+
const branchTip = await tower.branchTip(mission)
|
|
276
280
|
const drift = gateTipDrift({
|
|
277
281
|
missionId: mission.id,
|
|
278
282
|
gateBaseTip: gate.baseTip,
|
|
@@ -522,8 +526,7 @@ async function commitMerge(tower, plan, recorded, outcome) {
|
|
|
522
526
|
const now = Date.now()
|
|
523
527
|
if (intentActive) delete state.mergeIntent
|
|
524
528
|
if (conflict) {
|
|
525
|
-
mission.
|
|
526
|
-
mission.blockers.push(mergeConflictNote(outcome.detail))
|
|
529
|
+
markBlocked(mission, mergeConflictNote(outcome.detail), now)
|
|
527
530
|
} else {
|
|
528
531
|
mission.notes.push(mergeHookFailureNote(outcome.detail, mission.status))
|
|
529
532
|
}
|
package/app/mission.js
CHANGED
|
@@ -4,6 +4,7 @@ import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
|
|
|
4
4
|
import { validateState } from '../domain/state.js'
|
|
5
5
|
import { fullMission } from '../domain/summary.js'
|
|
6
6
|
import { sweepArchive } from './archive.js'
|
|
7
|
+
import { cleanupSandbox } from './sandbox.js'
|
|
7
8
|
|
|
8
9
|
function fail(message, code = 'invalid-argument') {
|
|
9
10
|
throw new TowerToolError(code, message)
|
|
@@ -12,7 +13,7 @@ function fail(message, code = 'invalid-argument') {
|
|
|
12
13
|
export async function updateMission(tower, input = {}) {
|
|
13
14
|
const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
|
|
14
15
|
if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
|
|
15
|
-
|
|
16
|
+
const result = await tower.deps.store.withLock(tower.paths, async () => {
|
|
16
17
|
const state = await tower.load()
|
|
17
18
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
18
19
|
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
@@ -34,4 +35,26 @@ export async function updateMission(tower, input = {}) {
|
|
|
34
35
|
tower.emitEvents(events, { revision: state.revision, at: now })
|
|
35
36
|
return { revision: state.revision, mission: fullMission(mission) }
|
|
36
37
|
})
|
|
38
|
+
// Abandoning a detached survey disposes its sandbox. The state transition is
|
|
39
|
+
// already durable; cleanup is advisory and reports rather than throws.
|
|
40
|
+
let sandboxCleanup = null
|
|
41
|
+
if (result.mission.status === 'abandoned' && result.mission.sandbox) {
|
|
42
|
+
const sandbox = result.mission.sandbox
|
|
43
|
+
try {
|
|
44
|
+
sandboxCleanup = await cleanupSandbox(tower, missionId)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
sandboxCleanup = {
|
|
47
|
+
ok: false,
|
|
48
|
+
removed: false,
|
|
49
|
+
attempts: 0,
|
|
50
|
+
path: sandbox,
|
|
51
|
+
error: String(error?.message ?? error).slice(0, 400),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (sandboxCleanup?.ok) {
|
|
55
|
+
delete result.mission.sandbox
|
|
56
|
+
delete result.mission.sandboxCleanupError
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ...result, ...(sandboxCleanup ? { sandboxCleanup } : {}) }
|
|
37
60
|
}
|
package/app/missionContext.js
CHANGED
|
@@ -10,5 +10,6 @@ export async function missionContext(tower, missionId) {
|
|
|
10
10
|
const state = await tower.load()
|
|
11
11
|
const mission = state.missions.find((entry) => entry.id === missionId)
|
|
12
12
|
if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
|
|
13
|
-
|
|
13
|
+
const archived = await tower.archiveIndex()
|
|
14
|
+
return missionContextView({ state, mission, archived: archived.ok ? archived.missions : [] })
|
|
14
15
|
}
|