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.
Files changed (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -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 +9 -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 +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,167 @@
1
+ // Pure worker-completion policy and check normalization.
2
+ //
3
+ // app/checkCompletion.js still loads state, verifies the worktree/commits with
4
+ // Git, and persists the transition; the rules below are repository-free.
5
+ // @ts-check
6
+ import { TowerToolError } from '../errors.js'
7
+
8
+ /** @typedef {import('../../types/index.js').Mission} Mission */
9
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
10
+
11
+ /**
12
+ * Hard cap for `tower_complete.report`, in UTF-8 bytes (32 KiB). The value is
13
+ * the default of `gates.maxReportBytes`; a repository may lower or raise it.
14
+ */
15
+ export const DEFAULT_MAX_REPORT_BYTES = 32 * 1024
16
+
17
+ /**
18
+ * UTF-8 byte length, so multi-byte text is not silently favoured by a
19
+ * character-count bound.
20
+ * @param {string} value
21
+ * @returns {number}
22
+ */
23
+ export function utf8ByteLength(value) {
24
+ return Buffer.byteLength(String(value), 'utf8')
25
+ }
26
+
27
+ /**
28
+ * Reject a report over the cap instead of truncating it: a silent cut loses
29
+ * evidence and cannot be recovered by the tower. The writer is told to keep the
30
+ * full text beside the branch and reference it.
31
+ * @param {string} report
32
+ * @param {number} [maxBytes]
33
+ * @returns {number} accepted UTF-8 byte length
34
+ */
35
+ export function assertReportSize(report, maxBytes = DEFAULT_MAX_REPORT_BYTES) {
36
+ const bytes = utf8ByteLength(report)
37
+ if (bytes > maxBytes) {
38
+ throw new TowerToolError(
39
+ 'invalid-argument',
40
+ `report is ${bytes} bytes (UTF-8), over the ${maxBytes}-byte cap; reports are rejected, not truncated — write the full text to a file in the mission worktree and reference the path in the report`,
41
+ {
42
+ nextAction: 'write the full text to a file in the mission worktree and reference the path in the report',
43
+ },
44
+ )
45
+ }
46
+ return bytes
47
+ }
48
+
49
+ /**
50
+ * Byte size of a stored completion report. Older documents have no
51
+ * `reportBytes`, so the body is measured on read; the field is never required.
52
+ * @param {any} completionReport
53
+ * @returns {number | null}
54
+ */
55
+ export function completionReportBytes(completionReport) {
56
+ if (!isPlainObject(completionReport)) return null
57
+ const stored = completionReport.reportBytes
58
+ if (Number.isInteger(stored) && stored >= 0) return stored
59
+ return typeof completionReport.report === 'string' ? utf8ByteLength(completionReport.report) : null
60
+ }
61
+
62
+ /**
63
+ * @param {string} message
64
+ * @param {string} [code]
65
+ * @returns {never}
66
+ */
67
+ function fail(message, code = 'invalid-argument') {
68
+ throw new TowerToolError(code, message)
69
+ }
70
+
71
+ /**
72
+ * @param {unknown} value
73
+ * @returns {boolean}
74
+ */
75
+ function isPlainObject(value) {
76
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
77
+ }
78
+
79
+ /**
80
+ * Normalize `tower_complete` check evidence.
81
+ * @param {any} checks
82
+ * @returns {any[]}
83
+ */
84
+ export function normalizeChecks(checks) {
85
+ if (checks === undefined) return []
86
+ if (!Array.isArray(checks)) fail('checks must be an array')
87
+ if (checks.length > 20) fail('checks may contain at most 20 entries')
88
+ return checks.map((check, index) => {
89
+ if (!isPlainObject(check)) fail(`checks[${index}] must be an object`)
90
+ const name = typeof check.name === 'string' ? check.name.trim() : ''
91
+ if (!name) fail(`checks[${index}].name must be a non-empty string`)
92
+ /** @type {Record<string, any>} */
93
+ const entry = { name }
94
+ if (typeof check.command === 'string' && check.command.trim()) entry.command = check.command.trim()
95
+ if (check.exitCode !== undefined && check.exitCode !== null) {
96
+ if (!Number.isInteger(check.exitCode)) fail(`checks[${index}].exitCode must be an integer`)
97
+ entry.exitCode = check.exitCode
98
+ }
99
+ if (typeof check.evidence === 'string' && check.evidence.trim()) entry.evidence = check.evidence.trim().slice(0, 4000)
100
+ return entry
101
+ })
102
+ }
103
+
104
+ /**
105
+ * Resolve and validate the reporting worker and the mission's completion
106
+ * preconditions that need no repository access.
107
+ * @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any, maxReportBytes?: number }} params
108
+ * @returns {RosterEntry}
109
+ */
110
+ export function assertCompletionRequest({ mission, roster, input, maxReportBytes }) {
111
+ const worker = (roster ?? []).find((entry) => entry.agentId === input.agentId)
112
+ if (!worker || worker.missionId !== mission.id) {
113
+ fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
114
+ }
115
+ if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
116
+ if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
117
+ fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
118
+ }
119
+ if (mission.owner && mission.owner !== worker.name) {
120
+ fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
121
+ }
122
+ if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
123
+ if (!['active', 'planned'].includes(mission.status)) {
124
+ fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
125
+ }
126
+ if (typeof input.report !== 'string' || input.report.trim().length === 0) {
127
+ fail('tower_complete requires a non-empty report')
128
+ }
129
+ assertReportSize(input.report.trim(), maxReportBytes)
130
+ const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
131
+ if (openTasks.length > 0) {
132
+ fail(
133
+ `mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
134
+ .map((task) => task.text)
135
+ .join('; ')}`,
136
+ )
137
+ }
138
+ const blockers = mission.blockers ?? []
139
+ if (blockers.length > 0) {
140
+ fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}`)
141
+ }
142
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
143
+ return worker
144
+ }
145
+
146
+ /**
147
+ * Unread inbox messages block completion.
148
+ * @param {{ mission: Mission, worker: RosterEntry, unread: unknown[] }} params
149
+ * @returns {void}
150
+ */
151
+ export function assertCompletionInbox({ mission, worker, unread }) {
152
+ if (unread.length > 0) {
153
+ fail(
154
+ `mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
155
+ )
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Project the completion report stored on the mission.
161
+ * @param {{ report: string, worker: RosterEntry, checks: any[], now: number, maxReportBytes?: number }} params
162
+ * @returns {any}
163
+ */
164
+ export function completionReport({ report, worker, checks, now, maxReportBytes }) {
165
+ const text = report.trim()
166
+ return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
167
+ }
@@ -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,186 @@
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
+ const caseInsensitive = state.caseInsensitive === true
173
+ for (const other of others) {
174
+ if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope, { caseInsensitive })) {
175
+ fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
176
+ }
177
+ }
178
+ mission.scope = scope
179
+ mission.scopeGeneration = (mission.scopeGeneration ?? 1) + 1
180
+ mission.status = 'active'
181
+ changes.push('scope widened')
182
+ }
183
+
184
+ if (changes.length === 0) fail('tower_mission requires at least one change')
185
+ return { changes }
186
+ }