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.
Files changed (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /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
+ }