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,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
+ }
@@ -0,0 +1,179 @@
1
+ // Pure review submission policy: actor resolution, verdict validation, round
2
+ // accounting, and the resulting mission/record projections.
3
+ //
4
+ // app/review.js owns loading state, the worktree/tip Git reads, the lock, and
5
+ // persistence. Everything here is deterministic and repository-free.
6
+ // @ts-check
7
+ import { TowerToolError } from '../errors.js'
8
+ import { MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
9
+
10
+ /** @typedef {import('../../types/index.js').Mission} Mission */
11
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
12
+ /** @typedef {import('../../types/index.js').Review} Review */
13
+
14
+ export const MAX_REVIEW_ROUNDS = 5
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
+ * A mission with a worktree is the only reviewable subject; the caller checks
27
+ * that its status is `completed`.
28
+ * @param {Mission} mission
29
+ * @returns {void}
30
+ */
31
+ export function assertReviewableMission(mission) {
32
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree to review`)
33
+ }
34
+
35
+ /**
36
+ * Resolve who is submitting the review. An unregistered caller fails closed;
37
+ * only an assigned reviewer or the tower (break-glass) may review.
38
+ * @param {{ mission: Mission, caller: RosterEntry | undefined, actorRole: string | undefined, agentId: string | undefined, breakGlassReason: unknown }} params
39
+ * @returns {{ reviewerName: string, reviewerRole: string }}
40
+ */
41
+ export function resolveReviewActor({ mission, caller, actorRole, agentId, breakGlassReason }) {
42
+ if (actorRole === 'tower') {
43
+ if (typeof breakGlassReason !== 'string' || breakGlassReason.trim().length === 0) {
44
+ fail('a tower-submitted review is break-glass and requires a non-empty breakGlassReason', 'not-authorized')
45
+ }
46
+ return { reviewerName: 'tower', reviewerRole: 'tower' }
47
+ }
48
+ if (caller) {
49
+ if (caller.role !== 'reviewer') {
50
+ fail(`agent ${caller.name} is a ${caller.role}; only an assigned reviewer or the tower (break-glass) may review`)
51
+ }
52
+ if (caller.missionId !== mission.id) {
53
+ fail(`reviewer ${caller.name} was not assigned to mission ${mission.id}`)
54
+ }
55
+ if (mission.owner && caller.name === mission.owner) fail('the reviewer must differ from the worker', 'not-authorized')
56
+ return { reviewerName: caller.name, reviewerRole: 'reviewer' }
57
+ }
58
+ // Fail closed: an unknown caller is not the tower, and a missing roster row
59
+ // is not evidence of tower identity.
60
+ fail(`agent ${agentId ?? '(unknown)'} is not a registered reviewer or the tower`)
61
+ }
62
+
63
+ /**
64
+ * Validate the verdict payload fields that are independent of Git.
65
+ * @param {any} input
66
+ * @returns {{ status: string, merge: string, findings: string, decision: string, checks: string[] }}
67
+ */
68
+ export function normalizeReviewVerdict(input) {
69
+ if (!REVIEW_STATUS_RE.test(String(input.status ?? ''))) {
70
+ fail('status must be "clean", "p1-Nitems", or "p2-Nitems"')
71
+ }
72
+ if (!MERGE_VERDICTS.includes(input.merge)) {
73
+ fail(`merge verdict must be one of ${MERGE_VERDICTS.join('|')}`)
74
+ }
75
+ const findings = String(input.findings ?? '').trim()
76
+ if (!findings) fail('findings text is required; write "none" for a clean verdict')
77
+ if (input.status === 'clean' && findings.toLowerCase() !== 'none') {
78
+ fail('a clean verdict requires findings text "none"')
79
+ }
80
+ if (input.status !== 'clean' && findings.toLowerCase() === 'none') {
81
+ fail('a non-clean verdict must describe its findings')
82
+ }
83
+ const decision = String(input.decision ?? '').trim()
84
+ if (!decision) fail('decision (the reasoning behind the verdict) is required')
85
+ /** @type {string[]} */
86
+ let checks = []
87
+ if (input.checks !== undefined) {
88
+ if (!Array.isArray(input.checks)) fail('checks must be an array of strings')
89
+ checks = input.checks.map((entry) => String(entry))
90
+ }
91
+ return { status: input.status, merge: input.merge, findings, decision, checks }
92
+ }
93
+
94
+ /**
95
+ * A reviewer may only review the tip it was spawned on.
96
+ * @param {RosterEntry | undefined} caller
97
+ * @param {string} tip
98
+ * @returns {void}
99
+ */
100
+ export function assertReviewerSpawnTip(caller, tip) {
101
+ if (caller && caller.role === 'reviewer' && !String(caller.spawnTip ?? '').trim()) {
102
+ fail(`reviewer ${caller.name} has no recorded spawn tip; re-spawn the reviewer before reviewing`)
103
+ }
104
+ if (caller && caller.role === 'reviewer' && caller.spawnTip !== tip) {
105
+ fail(
106
+ `reviewer ${caller.name} modified the mission branch after it was spawned (${String(caller.spawnTip).slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
107
+ )
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Compute the review round and enforce the per-reviewer cap.
113
+ * @param {{ reviews: Review[] | undefined, branch: string, reviewerName: string, maxRoundsPerReviewer: unknown }} params
114
+ * @returns {{ reviewerRounds: number, round: number }}
115
+ */
116
+ export function reviewRound({ reviews, branch, reviewerName, maxRoundsPerReviewer }) {
117
+ const prior = (reviews ?? []).filter((review) => review.branch === branch)
118
+ const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
119
+ const maxReviewRounds = Number(maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
120
+ if (reviewerRounds >= maxReviewRounds) {
121
+ fail(
122
+ `reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
123
+ )
124
+ }
125
+ const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
126
+ return { reviewerRounds, round }
127
+ }
128
+
129
+ /**
130
+ * A non-clean verdict returns the mission to active and records the note; a
131
+ * clean verdict leaves the status untouched.
132
+ * @param {{ status: string, merge: string, round: number, reviewerName: string }} params
133
+ * @returns {{ returnToActive: boolean, note: string }}
134
+ */
135
+ export function reviewOutcome({ status, merge, round, reviewerName }) {
136
+ if (status !== 'clean') {
137
+ return {
138
+ returnToActive: true,
139
+ note: `review round ${round} by ${reviewerName}: ${status} (${merge}) — returned to active`,
140
+ }
141
+ }
142
+ return { returnToActive: false, note: `review round ${round} by ${reviewerName}: clean (${merge})` }
143
+ }
144
+
145
+ /**
146
+ * Project the persisted review record.
147
+ * @param {{ seq: number, missionId: string, branch: string, tip: string, verdict: { status: string, merge: string, findings: string, checks: string[], decision: string }, reviewerName: string, reviewerRole: string, round: number, now: number, breakGlassReason?: string }} params
148
+ * @returns {Review}
149
+ */
150
+ export function reviewRecord({
151
+ seq,
152
+ missionId,
153
+ branch,
154
+ tip,
155
+ verdict,
156
+ reviewerName,
157
+ reviewerRole,
158
+ round,
159
+ now,
160
+ breakGlassReason,
161
+ }) {
162
+ return {
163
+ id: `R${seq}`,
164
+ seq,
165
+ missionId,
166
+ branch,
167
+ reviewedCommit: tip,
168
+ status: verdict.status,
169
+ mergeVerdict: /** @type {import('../../types/index.js').MergeVerdict} */ (verdict.merge),
170
+ findings: verdict.findings,
171
+ checks: verdict.checks,
172
+ decision: verdict.decision,
173
+ reviewer: reviewerName,
174
+ reviewerRole,
175
+ round,
176
+ at: now,
177
+ ...(reviewerRole === 'tower' ? { breakGlassReason } : {}),
178
+ }
179
+ }
@@ -0,0 +1,121 @@
1
+ // Pure mission planning helpers.
2
+ // @ts-check
3
+ import { TowerToolError } from './errors.js'
4
+ import { scopesOverlap } from './scope.js'
5
+ import { isTerminal } from './mission.js'
6
+
7
+ /** @typedef {import('../types/index.js').Mission} Mission */
8
+
9
+ /**
10
+ * @param {string} message
11
+ * @returns {never}
12
+ */
13
+ function fail(message) {
14
+ throw new TowerToolError('invalid-argument', message)
15
+ }
16
+
17
+ /**
18
+ * @param {Mission[]} missions
19
+ * @returns {number}
20
+ */
21
+ function nextMissionNumber(missions) {
22
+ let max = 0
23
+ for (const mission of missions) {
24
+ const match = /^M(\d+)$/.exec(mission.id)
25
+ if (match) max = Math.max(max, Number(match[1]))
26
+ }
27
+ return max + 1
28
+ }
29
+
30
+ /**
31
+ * @param {Mission} mission
32
+ * @param {string} dep
33
+ * @returns {void}
34
+ */
35
+ function addDependency(mission, dep) {
36
+ if (mission.id === dep) return
37
+ if (!mission.deps.includes(dep)) mission.deps.push(dep)
38
+ }
39
+
40
+ /**
41
+ * @param {Map<string, Mission>} missionsById
42
+ * @param {string} fromId
43
+ * @param {string} toId
44
+ * @returns {boolean}
45
+ */
46
+ function hasDependencyPath(missionsById, fromId, toId) {
47
+ /** @type {Set<string>} */
48
+ const seen = new Set()
49
+ /** @type {string[]} */
50
+ const stack = [fromId]
51
+ while (stack.length > 0) {
52
+ const current = /** @type {string} */ (stack.pop())
53
+ if (current === toId) return true
54
+ if (seen.has(current)) continue
55
+ seen.add(current)
56
+ const mission = missionsById.get(current)
57
+ if (!mission) continue
58
+ for (const dep of mission.deps) stack.push(dep)
59
+ }
60
+ return false
61
+ }
62
+
63
+ /**
64
+ * @param {Mission[]} missions
65
+ * @returns {void}
66
+ */
67
+ function assertAcyclic(missions) {
68
+ /** @type {Map<string, Mission>} */
69
+ const byId = new Map(missions.map((mission) => [mission.id, mission]))
70
+ /** @type {Map<string, 'visiting' | 'done'>} */
71
+ const state = new Map()
72
+ /** @type {(id: string, trail: string[]) => void} */
73
+ const visit = (id, trail) => {
74
+ const mark = state.get(id)
75
+ if (mark === 'done') return
76
+ if (mark === 'visiting') fail(`mission dependencies have a cycle: ${[...trail, id].join(' -> ')}`)
77
+ state.set(id, 'visiting')
78
+ const mission = byId.get(id)
79
+ if (mission) for (const dep of mission.deps) visit(dep, [...trail, id])
80
+ state.set(id, 'done')
81
+ }
82
+ for (const mission of missions) visit(mission.id, [])
83
+ }
84
+
85
+ /**
86
+ * Kimi-style heuristic: overlapping build scopes are serialized by an
87
+ * auto-added dependency. It is conservative, not a proof of disjointness.
88
+ * `caseInsensitive` is the checkout's detected mode (#90); on such a filesystem
89
+ * `src/Api/**` and `src/api/**` are the same real files and must serialise.
90
+ * @param {Mission[]} newMissions
91
+ * @param {Mission[]} existingMissions
92
+ * @param {{ caseInsensitive?: boolean }} [options]
93
+ * @returns {void}
94
+ */
95
+ function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false } = {}) {
96
+ const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
97
+ const all = [...existingMissions, ...newMissions]
98
+ /** @type {Map<string, Mission>} */
99
+ const byId = new Map(all.map((mission) => [mission.id, mission]))
100
+
101
+ for (let i = 0; i < newMissions.length; i += 1) {
102
+ for (let j = i + 1; j < newMissions.length; j += 1) {
103
+ const earlier = newMissions[i]
104
+ const later = newMissions[j]
105
+ if (earlier.kind === 'survey' || later.kind === 'survey') continue
106
+ if (!scopesOverlap(earlier.scope, later.scope, { caseInsensitive })) continue
107
+ if (hasDependencyPath(byId, later.id, earlier.id)) continue
108
+ addDependency(later, earlier.id)
109
+ }
110
+ const mission = newMissions[i]
111
+ if (mission.kind === 'survey') continue
112
+ for (const other of openExisting) {
113
+ if (other.kind === 'survey') continue
114
+ if (!scopesOverlap(mission.scope, other.scope, { caseInsensitive })) continue
115
+ if (hasDependencyPath(byId, mission.id, other.id)) continue
116
+ addDependency(mission, other.id)
117
+ }
118
+ }
119
+ }
120
+
121
+ export { nextMissionNumber, addDependency, hasDependencyPath, assertAcyclic, addAutoDeps }
@@ -0,0 +1,58 @@
1
+ // Pure dashboard mission enrichment: select the latest review, project the
2
+ // review view, and merge it into the mission summary. The worktree existence
3
+ // check and `rev-parse HEAD` that detect a moved tip stay in
4
+ // app/enrichedMissions.js.
5
+ // @ts-check
6
+ import { missionSummary } from '../summary.js'
7
+ import { latestReview, reviewsForBranch } from '../integration/review.js'
8
+
9
+ /** @typedef {import('../../types/index.js').Mission} Mission */
10
+ /** @typedef {import('../../types/index.js').Review} Review */
11
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
12
+
13
+ /**
14
+ * Filter the missions the caller asked for, preserving state order.
15
+ * @param {Mission[]} missions
16
+ * @param {string[] | null | undefined} onlyIds
17
+ * @returns {Mission[]}
18
+ */
19
+ export function missionsToEnrich(missions, onlyIds) {
20
+ const wanted = onlyIds ? new Set(onlyIds) : null
21
+ return wanted ? missions.filter((mission) => wanted.has(mission.id)) : missions
22
+ }
23
+
24
+ /**
25
+ * @param {TowerState} state
26
+ * @param {Mission} mission
27
+ * @returns {Review | null}
28
+ */
29
+ export function latestReviewForMission(state, mission) {
30
+ const branch = mission.worktree ? mission.worktree.branch : null
31
+ return latestReview(reviewsForBranch(state.reviews, branch))
32
+ }
33
+
34
+ /**
35
+ * @param {object} input
36
+ * @param {Mission} input.mission
37
+ * @param {Review | null} input.latest
38
+ * @param {boolean} input.tipMoved
39
+ * @returns {object}
40
+ */
41
+ export function enrichedMissionView({ mission, latest, tipMoved }) {
42
+ return {
43
+ ...missionSummary(mission),
44
+ review: latest
45
+ ? {
46
+ id: latest.id,
47
+ round: latest.round,
48
+ status: latest.status,
49
+ mergeVerdict: latest.mergeVerdict,
50
+ reviewer: latest.reviewer,
51
+ reviewerRole: latest.reviewerRole,
52
+ reviewedCommit: latest.reviewedCommit,
53
+ at: latest.at,
54
+ }
55
+ : null,
56
+ tipMoved,
57
+ }
58
+ }
@@ -0,0 +1,53 @@
1
+ // Pure mission-context projection: reviews, findings, and dependency statuses
2
+ // for one mission. Loading state and activity stays in app/missionContext.js.
3
+ // @ts-check
4
+ import { fullMission, findingSummary } from '../summary.js'
5
+
6
+ /** @typedef {import('../../types/index.js').Mission} Mission */
7
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
8
+
9
+ /**
10
+ * @param {object} input
11
+ * @param {TowerState} input.state
12
+ * @param {Mission} input.mission
13
+ * @returns {object}
14
+ */
15
+ export function missionContextView({ state, mission }) {
16
+ const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
17
+ const branch = mission.worktree ? mission.worktree.branch : null
18
+ const reviews = branch
19
+ ? (state.reviews ?? [])
20
+ .filter((review) => review.branch === branch)
21
+ .slice()
22
+ .sort((left, right) => right.round - left.round)
23
+ .slice(0, 3)
24
+ : []
25
+ const findings = (state.findings ?? [])
26
+ .filter((finding) => finding.missionId === mission.id)
27
+ .slice()
28
+ .sort((left, right) => (right.seq ?? 0) - (left.seq ?? 0))
29
+ .slice(0, 10)
30
+ return {
31
+ base: state.base,
32
+ mission: fullMission(mission),
33
+ snapshotCommit: state.snapshotCommit ?? null,
34
+ completionReport: mission.completionReport ?? null,
35
+ reviews: reviews.map((review) => ({
36
+ id: review.id,
37
+ round: review.round,
38
+ status: review.status,
39
+ mergeVerdict: review.mergeVerdict,
40
+ reviewer: review.reviewer,
41
+ findings: review.findings,
42
+ checks: review.checks ?? [],
43
+ decision: review.decision,
44
+ reviewedCommit: review.reviewedCommit,
45
+ at: review.at,
46
+ })),
47
+ findings: findings.map((finding) => findingSummary(finding)),
48
+ deps: (mission.deps ?? []).map((id) => {
49
+ const dep = byId.get(id)
50
+ return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
51
+ }),
52
+ }
53
+ }