dsh-tower 2.0.3 → 2.1.0-beta.1
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 +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- 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 +404 -0
- package/domain/contracts/outputs.js +279 -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 +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -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 +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -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 +44 -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 +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- 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 +44 -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 +94 -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 +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Pure finding normalization, filtering, and transition policy.
|
|
2
|
+
//
|
|
3
|
+
// app/findings.js owns loading state, the writer lock, sequence assignment,
|
|
4
|
+
// persistence, and the activity entry.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { TowerToolError } from '../errors.js'
|
|
7
|
+
import { FINDING_SEVERITIES, FINDING_STATUSES, FINDING_TRANSITIONS, FINDING_TYPES } from '../mission.js'
|
|
8
|
+
import { countFindings, findingSummary } from '../summary.js'
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('../../types/index.js').Finding} Finding */
|
|
11
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {string} [code]
|
|
16
|
+
* @returns {never}
|
|
17
|
+
*/
|
|
18
|
+
function fail(message, code = 'invalid-argument') {
|
|
19
|
+
throw new TowerToolError(code, message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {unknown} value
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isNonEmpty(value) {
|
|
27
|
+
return value !== undefined && value !== null && value !== ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate and project the user-supplied fields of a new finding.
|
|
32
|
+
* @param {any} input
|
|
33
|
+
* @param {Mission[]} missions
|
|
34
|
+
* @returns {any}
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeFindingInput(input, missions) {
|
|
37
|
+
if (!FINDING_TYPES.includes(input.type)) {
|
|
38
|
+
fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
|
|
39
|
+
}
|
|
40
|
+
const title = String(input.title ?? '').trim()
|
|
41
|
+
if (!title) fail('finding title is required')
|
|
42
|
+
const summary = String(input.summary ?? '').trim()
|
|
43
|
+
if (!summary) fail('finding summary is required')
|
|
44
|
+
const details = String(input.details ?? '').trim()
|
|
45
|
+
if (!details) fail('finding details are required')
|
|
46
|
+
const suggestedFix = String(input.suggested_fix ?? '').trim()
|
|
47
|
+
if (!suggestedFix) fail('finding suggested_fix is required')
|
|
48
|
+
const severity = input.severity ?? null
|
|
49
|
+
if (severity !== null && !FINDING_SEVERITIES.includes(severity)) {
|
|
50
|
+
fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
|
|
51
|
+
}
|
|
52
|
+
let mission = null
|
|
53
|
+
if (isNonEmpty(input.missionId)) {
|
|
54
|
+
mission = missions.find((entry) => entry.id === input.missionId) ?? null
|
|
55
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
type: input.type,
|
|
59
|
+
title,
|
|
60
|
+
severity,
|
|
61
|
+
summary,
|
|
62
|
+
location: typeof input.location === 'string' && input.location.trim() ? input.location.trim() : null,
|
|
63
|
+
details,
|
|
64
|
+
suggested_fix: suggestedFix,
|
|
65
|
+
missionId: mission ? mission.id : null,
|
|
66
|
+
branch: mission && mission.worktree ? mission.worktree.branch : null,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Project the complete finding record that gets persisted.
|
|
72
|
+
* @param {any} fields
|
|
73
|
+
* @param {{ seq: number, now: number, reportedBy: unknown }} params
|
|
74
|
+
* @returns {Finding}
|
|
75
|
+
*/
|
|
76
|
+
export function newFinding(fields, { seq, now, reportedBy }) {
|
|
77
|
+
return {
|
|
78
|
+
id: `F${seq}`,
|
|
79
|
+
seq,
|
|
80
|
+
...fields,
|
|
81
|
+
reportedBy,
|
|
82
|
+
at: now,
|
|
83
|
+
status: 'open',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {any} actor
|
|
89
|
+
* @returns {{ name: string, role: string }}
|
|
90
|
+
*/
|
|
91
|
+
export function normalizeFindingActor(actor) {
|
|
92
|
+
const resolved = actor ?? { name: 'tower', role: 'tower' }
|
|
93
|
+
if (typeof resolved.role !== 'string') fail('finding actor role must be a string')
|
|
94
|
+
return resolved
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {any} input
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function wantsFindingUpdate(input) {
|
|
102
|
+
const hasId = isNonEmpty(input.id)
|
|
103
|
+
return hasId && (input.status !== undefined || input.note !== undefined)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Pure read/list projection. Throws on an unknown id or an invalid filter.
|
|
108
|
+
* @param {Finding[]} findings
|
|
109
|
+
* @param {any} input
|
|
110
|
+
* @returns {any}
|
|
111
|
+
*/
|
|
112
|
+
export function readFindingsView(findings, input) {
|
|
113
|
+
const counts = countFindings(findings)
|
|
114
|
+
if (isNonEmpty(input.id)) {
|
|
115
|
+
const finding = findings.find((entry) => entry.id === input.id)
|
|
116
|
+
if (!finding) fail(`unknown finding "${input.id}"`, 'finding-not-found')
|
|
117
|
+
return { finding: { ...finding }, counts, total: findings.length, filtered: 1, action: 'read' }
|
|
118
|
+
}
|
|
119
|
+
if (input.status !== undefined && input.status !== 'all') {
|
|
120
|
+
if (!FINDING_STATUSES.includes(input.status)) {
|
|
121
|
+
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let visible = findings
|
|
125
|
+
if (input.status !== undefined && input.status !== 'all') {
|
|
126
|
+
visible = visible.filter((finding) => (finding.status ?? 'open') === input.status)
|
|
127
|
+
}
|
|
128
|
+
if (input.type !== undefined) {
|
|
129
|
+
if (!FINDING_TYPES.includes(input.type)) fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
|
|
130
|
+
visible = visible.filter((finding) => finding.type === input.type)
|
|
131
|
+
}
|
|
132
|
+
if (input.severity !== undefined) {
|
|
133
|
+
if (!FINDING_SEVERITIES.includes(input.severity)) {
|
|
134
|
+
fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
|
|
135
|
+
}
|
|
136
|
+
visible = visible.filter((finding) => finding.severity === input.severity)
|
|
137
|
+
}
|
|
138
|
+
if (isNonEmpty(input.missionId)) {
|
|
139
|
+
visible = visible.filter((finding) => finding.missionId === input.missionId)
|
|
140
|
+
}
|
|
141
|
+
if (input.open === true) {
|
|
142
|
+
visible = visible.filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
143
|
+
}
|
|
144
|
+
const limit = Math.max(1, Math.min(200, Number.isInteger(input.limit) ? input.limit : 50))
|
|
145
|
+
const page = visible.slice(-limit).reverse()
|
|
146
|
+
return {
|
|
147
|
+
findings: page.map((finding) => findingSummary(finding)),
|
|
148
|
+
counts,
|
|
149
|
+
total: findings.length,
|
|
150
|
+
filtered: visible.length,
|
|
151
|
+
action: 'list',
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Apply one finding lifecycle transition to the draft finding.
|
|
157
|
+
* @param {{ finding: Finding, input: any, actor: { name: string, role: string }, now: number }} params
|
|
158
|
+
* @returns {{ noteAdded: boolean }}
|
|
159
|
+
*/
|
|
160
|
+
export function applyFindingUpdate({ finding, input, actor, now }) {
|
|
161
|
+
if (actor.role === 'reviewer') {
|
|
162
|
+
fail('reviewers may list and read findings, but only the tower or a worker may change finding status', 'not-authorized')
|
|
163
|
+
}
|
|
164
|
+
let noteAdded = false
|
|
165
|
+
if (input.status !== undefined) {
|
|
166
|
+
if (input.status === 'all') {
|
|
167
|
+
fail('finding status "all" is only a list filter; pass a concrete status when updating')
|
|
168
|
+
}
|
|
169
|
+
if (!FINDING_STATUSES.includes(input.status)) {
|
|
170
|
+
fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
|
|
171
|
+
}
|
|
172
|
+
const current = finding.status ?? 'open'
|
|
173
|
+
const allowed = FINDING_TRANSITIONS[current] ?? []
|
|
174
|
+
if (!allowed.includes(input.status)) {
|
|
175
|
+
fail(`finding ${finding.id} cannot move from ${current} to ${input.status}`, 'invalid-transition')
|
|
176
|
+
}
|
|
177
|
+
finding.status = input.status
|
|
178
|
+
if (input.status === 'fixed' || input.status === 'wontfix') finding.closedAt = now
|
|
179
|
+
else delete finding.closedAt
|
|
180
|
+
}
|
|
181
|
+
if (input.note !== undefined) {
|
|
182
|
+
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
183
|
+
fail('finding note must be a non-empty string')
|
|
184
|
+
}
|
|
185
|
+
const record = /** @type {any} */ (finding)
|
|
186
|
+
if (!Array.isArray(record.notes)) record.notes = []
|
|
187
|
+
record.notes.push({ by: actor.name, at: now, text: input.note.trim() })
|
|
188
|
+
noteAdded = true
|
|
189
|
+
}
|
|
190
|
+
finding.updatedAt = now
|
|
191
|
+
return { noteAdded }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {Finding} finding
|
|
196
|
+
* @param {Finding[]} all
|
|
197
|
+
* @returns {any}
|
|
198
|
+
*/
|
|
199
|
+
export function findingUpdateView(finding, all) {
|
|
200
|
+
return { finding: { ...finding }, counts: countFindings(all), total: all.length, filtered: 1, action: 'updated' }
|
|
201
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Pure mission lifecycle transition policy.
|
|
2
|
+
//
|
|
3
|
+
// app/mission.js supplies the loaded mission/state and persists the result;
|
|
4
|
+
// this module owns the authorization and transition rules and mutates only the
|
|
5
|
+
// in-memory mission draft it is given.
|
|
6
|
+
// @ts-check
|
|
7
|
+
import { TowerToolError } from '../errors.js'
|
|
8
|
+
import { MISSION_STATUSES, TERMINAL_STATUSES, canTransition, pauseCauseError } from '../mission.js'
|
|
9
|
+
import { normalizeScope, scopesOverlap } from '../scope.js'
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
12
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
13
|
+
|
|
14
|
+
const TERMINAL = TERMINAL_STATUSES
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} message
|
|
18
|
+
* @param {string} [code]
|
|
19
|
+
* @returns {never}
|
|
20
|
+
*/
|
|
21
|
+
function fail(message, code = 'invalid-argument') {
|
|
22
|
+
throw new TowerToolError(code, message)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function normalizePauseCause(value) {
|
|
30
|
+
const error = pauseCauseError(/** @type {string | null | undefined} */ (value))
|
|
31
|
+
if (error) fail(error)
|
|
32
|
+
return value === undefined || value === null || value === '' ? 'user' : /** @type {string} */ (value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reject actor/input combinations a worker may not perform. The tower owns
|
|
37
|
+
* status, scope, blockers, and pause metadata; a worker only reports work.
|
|
38
|
+
* @param {Mission} mission
|
|
39
|
+
* @param {{ name?: string, role?: string }} actor
|
|
40
|
+
* @param {any} input
|
|
41
|
+
* @returns {void}
|
|
42
|
+
*/
|
|
43
|
+
export function assertMissionActor(mission, actor, input) {
|
|
44
|
+
const workerRequest = actor.role === 'worker'
|
|
45
|
+
if (actor.role === 'reviewer') {
|
|
46
|
+
fail('only the tower or a worker may update a mission; reviewers submit tower_review', 'not-authorized')
|
|
47
|
+
}
|
|
48
|
+
if (workerRequest && mission.owner && mission.owner !== actor.name) {
|
|
49
|
+
fail(`worker ${actor.name} does not own mission ${mission.id} (owner: ${mission.owner})`, 'not-authorized')
|
|
50
|
+
}
|
|
51
|
+
if (workerRequest) {
|
|
52
|
+
if (input.status !== undefined) {
|
|
53
|
+
fail('only the tower can change mission status directly; workers report completion with tower_complete', 'not-authorized')
|
|
54
|
+
}
|
|
55
|
+
if (input.scope !== undefined) fail('only the tower can change mission scope', 'not-authorized')
|
|
56
|
+
if (input.clear_blockers === true) fail('only the tower can clear blockers', 'not-authorized')
|
|
57
|
+
if (input.pause_cause !== undefined || input.authorization !== undefined) {
|
|
58
|
+
fail('only the tower can record pause/authorization metadata', 'not-authorized')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply one validated `tower_mission` patch to the mission draft and return
|
|
65
|
+
* the human-readable change list used by the activity log.
|
|
66
|
+
* @param {{ mission: Mission, state: TowerState, input: any, config: any }} params
|
|
67
|
+
* @returns {{ changes: string[] }}
|
|
68
|
+
*/
|
|
69
|
+
export function applyMissionUpdate({ mission, state, input, config }) {
|
|
70
|
+
const actor = input.actor ?? { name: 'tower', role: 'tower' }
|
|
71
|
+
assertMissionActor(mission, actor, input)
|
|
72
|
+
if (input.pause_cause !== undefined && input.status !== 'paused') {
|
|
73
|
+
fail('pause_cause is only valid together with status "paused"')
|
|
74
|
+
}
|
|
75
|
+
if (input.status === 'abandoned' && (typeof input.note !== 'string' || input.note.trim().length === 0)) {
|
|
76
|
+
fail('abandoning a mission requires a non-empty note (reason)')
|
|
77
|
+
}
|
|
78
|
+
/** @type {string[]} */
|
|
79
|
+
const changes = []
|
|
80
|
+
|
|
81
|
+
if (input.status !== undefined) {
|
|
82
|
+
if (typeof input.status !== 'string' || !MISSION_STATUSES.includes(input.status)) {
|
|
83
|
+
fail(`unknown mission status "${input.status}"`)
|
|
84
|
+
}
|
|
85
|
+
if (input.status === 'merged') {
|
|
86
|
+
fail('merged is recorded only by tower_merge, not by a generic mission patch', 'invalid-transition')
|
|
87
|
+
}
|
|
88
|
+
if (!canTransition(mission.status, input.status)) {
|
|
89
|
+
fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`, 'invalid-transition')
|
|
90
|
+
}
|
|
91
|
+
if (input.status === 'paused') {
|
|
92
|
+
mission.pauseCause = normalizePauseCause(input.pause_cause)
|
|
93
|
+
}
|
|
94
|
+
if (mission.status === 'paused' && input.status === 'active') {
|
|
95
|
+
const requireAuthorization = config.mode.requireResumeAuthorization === true
|
|
96
|
+
if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
|
|
97
|
+
if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
|
|
98
|
+
fail('resuming a user-paused mission requires a non-empty authorization reference')
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (typeof input.authorization === 'string' && input.authorization.trim()) {
|
|
102
|
+
mission.authorizationRef = input.authorization.trim()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
mission.status = input.status
|
|
106
|
+
changes.push(`status=${input.status}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (input.task_done !== undefined) {
|
|
110
|
+
const needle = String(input.task_done)
|
|
111
|
+
const tasks = /** @type {any[]} */ (mission.tasks)
|
|
112
|
+
const task = tasks.find(
|
|
113
|
+
(entry) => entry.status === 'open' && entry.text.toLowerCase().includes(needle.toLowerCase()),
|
|
114
|
+
)
|
|
115
|
+
if (!task) fail(`no open task matching "${needle}" in mission ${mission.id}`)
|
|
116
|
+
task.status = 'done'
|
|
117
|
+
changes.push(`task_done="${task.text}"`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (input.task_drop !== undefined) {
|
|
121
|
+
if (typeof input.task_drop_reason !== 'string' || input.task_drop_reason.trim().length === 0) {
|
|
122
|
+
fail('task_drop requires task_drop_reason')
|
|
123
|
+
}
|
|
124
|
+
const needle = String(input.task_drop)
|
|
125
|
+
const tasks = /** @type {any[]} */ (mission.tasks)
|
|
126
|
+
const task = tasks.find(
|
|
127
|
+
(entry) => entry.status === 'open' && entry.text.toLowerCase().includes(needle.toLowerCase()),
|
|
128
|
+
)
|
|
129
|
+
if (!task) fail(`no open task matching "${needle}" in mission ${mission.id}`)
|
|
130
|
+
task.status = 'dropped'
|
|
131
|
+
task.note = input.task_drop_reason.trim()
|
|
132
|
+
const notes = /** @type {string[]} */ (mission.notes)
|
|
133
|
+
notes.push(`task dropped: ${task.text} — ${task.note}`)
|
|
134
|
+
changes.push(`task_drop="${task.text}"`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (input.blocker !== undefined) {
|
|
138
|
+
if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
|
|
139
|
+
fail('blocker must be a non-empty string')
|
|
140
|
+
}
|
|
141
|
+
if (TERMINAL.has(mission.status)) {
|
|
142
|
+
fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
|
|
143
|
+
}
|
|
144
|
+
const blockers = /** @type {string[]} */ (mission.blockers)
|
|
145
|
+
blockers.push(input.blocker.trim())
|
|
146
|
+
mission.status = 'blocked'
|
|
147
|
+
changes.push('blocker added')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (input.clear_blockers === true) {
|
|
151
|
+
mission.blockers = []
|
|
152
|
+
changes.push('blockers cleared')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (input.note !== undefined) {
|
|
156
|
+
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
157
|
+
fail('note must be a non-empty string')
|
|
158
|
+
}
|
|
159
|
+
const notes = /** @type {string[]} */ (mission.notes)
|
|
160
|
+
notes.push(input.note.trim())
|
|
161
|
+
changes.push('note added')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (input.scope !== undefined) {
|
|
165
|
+
if (!['active', 'completed'].includes(mission.status)) {
|
|
166
|
+
fail(`scope can only be widened for active or completed missions, not ${mission.status}`)
|
|
167
|
+
}
|
|
168
|
+
const scope = normalizeScope(input.scope, { allowEmpty: mission.kind === 'survey' })
|
|
169
|
+
const others = state.missions.filter(
|
|
170
|
+
(entry) => entry.id !== mission.id && !TERMINAL.has(entry.status) && entry.kind !== 'survey',
|
|
171
|
+
)
|
|
172
|
+
for (const other of others) {
|
|
173
|
+
if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope)) {
|
|
174
|
+
fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
mission.scope = scope
|
|
178
|
+
mission.scopeGeneration = (mission.scopeGeneration ?? 1) + 1
|
|
179
|
+
mission.status = 'active'
|
|
180
|
+
changes.push('scope widened')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (changes.length === 0) fail('tower_mission requires at least one change')
|
|
184
|
+
return { changes }
|
|
185
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Pure mission planning input normalization and projections.
|
|
2
|
+
//
|
|
3
|
+
// The use case in app/plan.js owns the state lock, load, persistence, and the
|
|
4
|
+
// activity entry; everything here is deterministic and repository-free.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { TowerToolError } from '../errors.js'
|
|
7
|
+
import { normalizeScope, validateTitle } from '../scope.js'
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} message
|
|
13
|
+
* @returns {never}
|
|
14
|
+
*/
|
|
15
|
+
function fail(message) {
|
|
16
|
+
throw new TowerToolError('invalid-argument', message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
function isPlainObject(value) {
|
|
24
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate and normalize one `tower_plan` mission entry.
|
|
29
|
+
* @param {any} input
|
|
30
|
+
* @param {number} index
|
|
31
|
+
* @returns {any}
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeMissionInput(input, index) {
|
|
34
|
+
if (!isPlainObject(input)) fail(`missions[${index}] must be an object`)
|
|
35
|
+
const title = validateTitle(input.title)
|
|
36
|
+
const kind = input.kind === 'survey' ? 'survey' : 'build'
|
|
37
|
+
const scope = normalizeScope(input.scope ?? [], { allowEmpty: kind === 'survey' })
|
|
38
|
+
/** @type {Array<{ text: string, status: string }>} */
|
|
39
|
+
const tasks = []
|
|
40
|
+
if (input.tasks !== undefined) {
|
|
41
|
+
if (!Array.isArray(input.tasks)) fail(`missions[${index}].tasks must be an array of strings`)
|
|
42
|
+
for (const task of input.tasks) {
|
|
43
|
+
if (typeof task !== 'string' || task.trim().length === 0) {
|
|
44
|
+
fail(`missions[${index}].tasks entries must be non-empty strings`)
|
|
45
|
+
}
|
|
46
|
+
tasks.push({ text: task.trim(), status: 'open' })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** @type {string[]} */
|
|
50
|
+
const deps = []
|
|
51
|
+
if (input.deps !== undefined) {
|
|
52
|
+
if (!Array.isArray(input.deps)) fail(`missions[${index}].deps must be an array of mission ids`)
|
|
53
|
+
for (const dep of input.deps) {
|
|
54
|
+
if (typeof dep !== 'string' || dep.trim().length === 0) {
|
|
55
|
+
fail(`missions[${index}].deps entries must be non-empty mission ids`)
|
|
56
|
+
}
|
|
57
|
+
if (!deps.includes(dep.trim())) deps.push(dep.trim())
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
|
|
61
|
+
/** @type {string[]} */
|
|
62
|
+
const references = []
|
|
63
|
+
if (input.references !== undefined) {
|
|
64
|
+
if (!Array.isArray(input.references)) fail(`missions[${index}].references must be an array of pointers`)
|
|
65
|
+
if (input.references.length > 20) fail(`missions[${index}].references supports at most 20 pointers`)
|
|
66
|
+
for (const reference of input.references) {
|
|
67
|
+
if (typeof reference !== 'string' || reference.trim().length === 0) {
|
|
68
|
+
fail(`missions[${index}].references entries must be non-empty strings`)
|
|
69
|
+
}
|
|
70
|
+
if (!references.includes(reference.trim())) references.push(reference.trim())
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const reviewPolicy = input.reviewPolicy === undefined ? 'required-clean' : input.reviewPolicy
|
|
74
|
+
if (!['required-clean', 'none'].includes(reviewPolicy)) {
|
|
75
|
+
fail(`missions[${index}].reviewPolicy must be "required-clean" or "none"`)
|
|
76
|
+
}
|
|
77
|
+
const reviewExemptionReason = typeof input.reviewExemptionReason === 'string' ? input.reviewExemptionReason.trim() : ''
|
|
78
|
+
if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
|
|
79
|
+
fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
|
|
80
|
+
}
|
|
81
|
+
return { title, kind, scope, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate the plan envelope and normalize every mission entry.
|
|
86
|
+
* @param {any} input
|
|
87
|
+
* @returns {any[]}
|
|
88
|
+
*/
|
|
89
|
+
export function normalizePlanMissions(input) {
|
|
90
|
+
if (!Array.isArray(input.missions) || input.missions.length === 0) {
|
|
91
|
+
fail('tower_plan needs at least one mission')
|
|
92
|
+
}
|
|
93
|
+
return input.missions.map((mission, index) => normalizeMissionInput(mission, index))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Assign the next contiguous mission ids without mutating the input.
|
|
98
|
+
* @param {any[]} prepared
|
|
99
|
+
* @param {number} start
|
|
100
|
+
* @returns {any[]}
|
|
101
|
+
*/
|
|
102
|
+
export function assignMissionIds(prepared, start) {
|
|
103
|
+
return prepared.map((mission, index) => ({ ...mission, id: `M${start + index}` }))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every explicit dependency must name an existing or newly assigned mission.
|
|
108
|
+
* @param {any[]} assigned
|
|
109
|
+
* @param {Mission[]} existingMissions
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
112
|
+
export function assertKnownDependencies(assigned, existingMissions) {
|
|
113
|
+
const known = new Set([...existingMissions.map((mission) => mission.id), ...assigned.map((mission) => mission.id)])
|
|
114
|
+
for (const mission of assigned) {
|
|
115
|
+
for (const dep of mission.deps) {
|
|
116
|
+
if (!known.has(dep)) fail(`mission ${mission.id} depends on unknown mission "${dep}"`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Project a normalized, id-assigned input into a fresh mission record.
|
|
123
|
+
* @param {string} id
|
|
124
|
+
* @param {any} input
|
|
125
|
+
* @param {number} now
|
|
126
|
+
* @returns {Mission}
|
|
127
|
+
*/
|
|
128
|
+
export function newMission(id, input, now) {
|
|
129
|
+
return /** @type {Mission} */ (/** @type {any} */ ({
|
|
130
|
+
id,
|
|
131
|
+
title: input.title,
|
|
132
|
+
kind: input.kind,
|
|
133
|
+
scope: input.scope,
|
|
134
|
+
tasks: input.tasks,
|
|
135
|
+
deps: input.deps,
|
|
136
|
+
context: input.context,
|
|
137
|
+
references: input.references ?? [],
|
|
138
|
+
reviewPolicy: input.reviewPolicy ?? 'required-clean',
|
|
139
|
+
reviewExemptionReason: input.reviewExemptionReason ?? '',
|
|
140
|
+
status: 'planned',
|
|
141
|
+
owner: null,
|
|
142
|
+
blockers: [],
|
|
143
|
+
notes: [],
|
|
144
|
+
reviews: [],
|
|
145
|
+
scopeGeneration: 1,
|
|
146
|
+
createdAt: now,
|
|
147
|
+
updatedAt: now,
|
|
148
|
+
}))
|
|
149
|
+
}
|