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