dsh-tower 2.1.0-beta.1 → 2.3.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 (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
@@ -116,6 +116,7 @@ export function existingInitResult({ state, statePath, dirty }) {
116
116
  reanchored: false,
117
117
  dirty,
118
118
  snapshotCommit: state.snapshotCommit ?? null,
119
+ caseInsensitive: state.caseInsensitive ?? null,
119
120
  }
120
121
  }
121
122
 
@@ -128,10 +129,21 @@ export function existingInitResult({ state, statePath, dirty }) {
128
129
  * @param {string | null} [input.towerAgentId]
129
130
  * @param {string | null} [input.snapshotCommit]
130
131
  * @param {string | null} [input.snapshotRef]
132
+ * @param {boolean | null} [input.caseInsensitive]
131
133
  * @param {number} input.now
132
134
  * @returns {TowerState}
133
135
  */
134
- export function initialState({ repoRoot, commonDir, base, dirty, towerAgentId = null, snapshotCommit = null, snapshotRef = null, now }) {
136
+ export function initialState({
137
+ repoRoot,
138
+ commonDir,
139
+ base,
140
+ dirty,
141
+ towerAgentId = null,
142
+ snapshotCommit = null,
143
+ snapshotRef = null,
144
+ caseInsensitive = null,
145
+ now,
146
+ }) {
135
147
  return {
136
148
  schemaVersion: SCHEMA_VERSION,
137
149
  repoRoot,
@@ -141,6 +153,7 @@ export function initialState({ repoRoot, commonDir, base, dirty, towerAgentId =
141
153
  createdAt: now,
142
154
  updatedAt: now,
143
155
  dirtyBase: dirty,
156
+ caseInsensitive,
144
157
  ...(towerAgentId ? { towerAgentId: String(towerAgentId) } : {}),
145
158
  ...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
146
159
  missions: [],
@@ -156,9 +169,19 @@ export function initialState({ repoRoot, commonDir, base, dirty, towerAgentId =
156
169
  * @param {boolean} input.dirty
157
170
  * @param {string | null} input.snapshotCommit
158
171
  * @param {string | null} input.snapshotRef
172
+ * @param {boolean | null} [input.caseInsensitive]
159
173
  * @returns {object}
160
174
  */
161
- export function createdInitResult({ repoRoot, commonDir, statePath, base, dirty, snapshotCommit, snapshotRef }) {
175
+ export function createdInitResult({
176
+ repoRoot,
177
+ commonDir,
178
+ statePath,
179
+ base,
180
+ dirty,
181
+ snapshotCommit,
182
+ snapshotRef,
183
+ caseInsensitive = null,
184
+ }) {
162
185
  return {
163
186
  created: true,
164
187
  repoRoot,
@@ -171,6 +194,7 @@ export function createdInitResult({ repoRoot, commonDir, statePath, base, dirty,
171
194
  dirty,
172
195
  snapshotCommit,
173
196
  snapshotRef,
197
+ caseInsensitive,
174
198
  }
175
199
  }
176
200
 
@@ -194,6 +218,7 @@ export function existingInitWriteResult({ state, statePath, dirty, reanchored })
194
218
  reanchored,
195
219
  dirty,
196
220
  snapshotCommit: state.snapshotCommit ?? null,
221
+ caseInsensitive: state.caseInsensitive ?? null,
197
222
  }
198
223
  }
199
224
 
@@ -202,10 +227,21 @@ export function existingInitWriteResult({ state, statePath, dirty, reanchored })
202
227
  * @param {string} input.base
203
228
  * @param {string | null} [input.snapshotCommit]
204
229
  * @param {boolean} input.dirty
230
+ * @param {boolean | null} [input.caseInsensitive]
205
231
  * @returns {string}
206
232
  */
207
- export function initActivityText({ base, snapshotCommit = null, dirty }) {
208
- return `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}`
233
+ export function initActivityText({ base, snapshotCommit = null, dirty, caseInsensitive = null }) {
234
+ const mode = caseInsensitive === null ? '' : ` (${caseInsensitive ? 'case-insensitive' : 'case-sensitive'} filesystem)`
235
+ return `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}${mode}`
236
+ }
237
+
238
+ /**
239
+ * Activity line for a probe recorded on an existing state.
240
+ * @param {boolean} caseInsensitive
241
+ * @returns {string}
242
+ */
243
+ export function caseProbeActivityText(caseInsensitive) {
244
+ return `case sensitivity detected: ${caseInsensitive ? 'case-insensitive' : 'case-sensitive'}`
209
245
  }
210
246
 
211
247
  /**
@@ -3,6 +3,7 @@
3
3
  // writes, the state lock, and persistence stay in app/merge.js.
4
4
  // @ts-check
5
5
  import { isReviewExempt, latestReview, reviewsForBranch } from './review.js'
6
+ import { mergedEvent } from '../events.js'
6
7
 
7
8
  /** @typedef {import('../../types/index.js').Mission} Mission */
8
9
  /** @typedef {import('../../types/index.js').Review} Review */
@@ -182,6 +183,40 @@ export function mergeSuccessNote(branchTip, base, newTip) {
182
183
  return `merged ${branchTip.slice(0, 7)} into ${base} as ${newTip.slice(0, 7)}`
183
184
  }
184
185
 
186
+ /**
187
+ * Apply a successful two-phase merge to the mission draft and describe the
188
+ * domain fact, including the commit that landed. Persistence stays in
189
+ * app/merge.js at the commit phase.
190
+ * @param {{ mission: Mission, branchTip: string, base: string, newTip: string, now: number }} params
191
+ * @returns {{ state: Mission, events: Array<{ type: string, missionId: string, commit: string }> }}
192
+ */
193
+ export function applyMergeSuccess({ mission, branchTip, base, newTip, now }) {
194
+ mission.status = 'merged'
195
+ mission.mergedAt = now
196
+ mission.mergedCommit = newTip
197
+ mission.updatedAt = now
198
+ mission.notes = mission.notes ?? []
199
+ mission.notes.push(mergeSuccessNote(branchTip, base, newTip))
200
+ return { state: mission, events: [mergedEvent(mission, newTip)] }
201
+ }
202
+
203
+ /**
204
+ * Apply an already-landed merge reconciliation to the mission draft. `note` is
205
+ * the evidence line the caller built from the observed commit and parents.
206
+ * @param {{ mission: Mission, mergedCommit: string, note: string, now: number }} params
207
+ * @returns {{ state: Mission, events: Array<{ type: string, missionId: string, commit: string }> }}
208
+ */
209
+ export function applyLandedMerge({ mission, mergedCommit, note, now }) {
210
+ mission.status = 'merged'
211
+ mission.mergedAt = now
212
+ mission.mergedCommit = mergedCommit
213
+ mission.blockers = []
214
+ mission.notes = mission.notes ?? []
215
+ mission.notes.push(note)
216
+ mission.updatedAt = now
217
+ return { state: mission, events: [mergedEvent(mission, mergedCommit)] }
218
+ }
219
+
185
220
  /**
186
221
  * @param {string} detail
187
222
  * @returns {string}
@@ -0,0 +1,30 @@
1
+ // Git-mutex budget policy (#93), shared by the app layer and the store
2
+ // infrastructure (#69).
3
+ //
4
+ // The waiter behind the repo-scoped git mutex must outlast a merge that is
5
+ // entitled to `mergeTimeoutMs`: a merge legally holds the mutex for its whole
6
+ // plan.timeoutMs and then runs a few cheap evidence/cleanup git calls before
7
+ // releasing it (#93). The budget is pure policy over the resolved Tower config,
8
+ // so it lives in the domain rather than in an adapter; app/tower.js and
9
+ // adapters/store.js both compute it from here.
10
+ export const GIT_LOCK_MARGIN_MS = 60000
11
+
12
+ /** The default merge timeout when a caller passes a config without `merge`. */
13
+ export const DEFAULT_MERGE_TIMEOUT_MS = 900000
14
+
15
+ /**
16
+ * @param {{ merge?: { timeoutMs?: number } } | null | undefined} config
17
+ * @param {number} [mergeTimeoutMs] the caller's resolved merge timeout
18
+ * @returns {number}
19
+ */
20
+ export function gitLockTimeoutMs(config, mergeTimeoutMs) {
21
+ const explicit = Number(mergeTimeoutMs)
22
+ const configured = Number(config?.merge?.timeoutMs)
23
+ const base =
24
+ Number.isInteger(explicit) && explicit > 0
25
+ ? explicit
26
+ : Number.isInteger(configured) && configured > 0
27
+ ? configured
28
+ : DEFAULT_MERGE_TIMEOUT_MS
29
+ return base + GIT_LOCK_MARGIN_MS
30
+ }
package/domain/mission.js CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  /** @type {readonly MissionStatus[]} */
11
11
  export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
12
- export const SCHEMA_VERSION = 1
12
+ export const SCHEMA_VERSION = 3
13
13
  /** @type {readonly MergeVerdict[]} */
14
14
  export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
15
15
  export const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
@@ -4,10 +4,62 @@
4
4
  // Git, and persists the transition; the rules below are repository-free.
5
5
  // @ts-check
6
6
  import { TowerToolError } from '../errors.js'
7
+ import { completedEvent } from '../events.js'
7
8
 
8
9
  /** @typedef {import('../../types/index.js').Mission} Mission */
9
10
  /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
10
11
 
12
+ /**
13
+ * Hard cap for `tower_complete.report`, in UTF-8 bytes (32 KiB). The value is
14
+ * the default of `gates.maxReportBytes`; a repository may lower or raise it.
15
+ */
16
+ export const DEFAULT_MAX_REPORT_BYTES = 32 * 1024
17
+
18
+ /**
19
+ * UTF-8 byte length, so multi-byte text is not silently favoured by a
20
+ * character-count bound.
21
+ * @param {string} value
22
+ * @returns {number}
23
+ */
24
+ export function utf8ByteLength(value) {
25
+ return Buffer.byteLength(String(value), 'utf8')
26
+ }
27
+
28
+ /**
29
+ * Reject a report over the cap instead of truncating it: a silent cut loses
30
+ * evidence and cannot be recovered by the tower. The writer is told to keep the
31
+ * full text beside the branch and reference it.
32
+ * @param {string} report
33
+ * @param {number} [maxBytes]
34
+ * @returns {number} accepted UTF-8 byte length
35
+ */
36
+ export function assertReportSize(report, maxBytes = DEFAULT_MAX_REPORT_BYTES) {
37
+ const bytes = utf8ByteLength(report)
38
+ if (bytes > maxBytes) {
39
+ throw new TowerToolError(
40
+ 'invalid-argument',
41
+ `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`,
42
+ {
43
+ nextAction: 'write the full text to a file in the mission worktree and reference the path in the report',
44
+ },
45
+ )
46
+ }
47
+ return bytes
48
+ }
49
+
50
+ /**
51
+ * Byte size of a stored completion report. Older documents have no
52
+ * `reportBytes`, so the body is measured on read; the field is never required.
53
+ * @param {any} completionReport
54
+ * @returns {number | null}
55
+ */
56
+ export function completionReportBytes(completionReport) {
57
+ if (!isPlainObject(completionReport)) return null
58
+ const stored = completionReport.reportBytes
59
+ if (Number.isInteger(stored) && stored >= 0) return stored
60
+ return typeof completionReport.report === 'string' ? utf8ByteLength(completionReport.report) : null
61
+ }
62
+
11
63
  /**
12
64
  * @param {string} message
13
65
  * @param {string} [code]
@@ -53,10 +105,10 @@ export function normalizeChecks(checks) {
53
105
  /**
54
106
  * Resolve and validate the reporting worker and the mission's completion
55
107
  * preconditions that need no repository access.
56
- * @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any }} params
108
+ * @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any, maxReportBytes?: number }} params
57
109
  * @returns {RosterEntry}
58
110
  */
59
- export function assertCompletionRequest({ mission, roster, input }) {
111
+ export function assertCompletionRequest({ mission, roster, input, maxReportBytes }) {
60
112
  const worker = (roster ?? []).find((entry) => entry.agentId === input.agentId)
61
113
  if (!worker || worker.missionId !== mission.id) {
62
114
  fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
@@ -75,6 +127,7 @@ export function assertCompletionRequest({ mission, roster, input }) {
75
127
  if (typeof input.report !== 'string' || input.report.trim().length === 0) {
76
128
  fail('tower_complete requires a non-empty report')
77
129
  }
130
+ assertReportSize(input.report.trim(), maxReportBytes)
78
131
  const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
79
132
  if (openTasks.length > 0) {
80
133
  fail(
@@ -106,9 +159,23 @@ export function assertCompletionInbox({ mission, worker, unread }) {
106
159
 
107
160
  /**
108
161
  * Project the completion report stored on the mission.
109
- * @param {{ report: string, worker: RosterEntry, checks: any[], now: number }} params
162
+ * @param {{ report: string, worker: RosterEntry, checks: any[], now: number, maxReportBytes?: number }} params
110
163
  * @returns {any}
111
164
  */
112
- export function completionReport({ report, worker, checks, now }) {
113
- return { report: report.trim(), by: worker.name, at: now, checks }
165
+ export function completionReport({ report, worker, checks, now, maxReportBytes }) {
166
+ const text = report.trim()
167
+ return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
168
+ }
169
+
170
+ /**
171
+ * Apply the completion transition to the mission draft and describe the domain
172
+ * fact. Persistence stays in app/checkCompletion.js.
173
+ * @param {{ mission: Mission, report: any, now: number }} params
174
+ * @returns {{ state: Mission, events: Array<{ type: string, missionId: string }> }}
175
+ */
176
+ export function applyCompletion({ mission, report, now }) {
177
+ mission.status = 'completed'
178
+ mission.completionReport = report
179
+ mission.updatedAt = now
180
+ return { state: mission, events: [completedEvent(mission)] }
114
181
  }
@@ -5,6 +5,7 @@
5
5
  // in-memory mission draft it is given.
6
6
  // @ts-check
7
7
  import { TowerToolError } from '../errors.js'
8
+ import { abandonedEvent, completedEvent } from '../events.js'
8
9
  import { MISSION_STATUSES, TERMINAL_STATUSES, canTransition, pauseCauseError } from '../mission.js'
9
10
  import { normalizeScope, scopesOverlap } from '../scope.js'
10
11
 
@@ -62,9 +63,10 @@ export function assertMissionActor(mission, actor, input) {
62
63
 
63
64
  /**
64
65
  * Apply one validated `tower_mission` patch to the mission draft and return
65
- * the human-readable change list used by the activity log.
66
+ * the human-readable change list used by the activity log plus the domain
67
+ * events the transition describes.
66
68
  * @param {{ mission: Mission, state: TowerState, input: any, config: any }} params
67
- * @returns {{ changes: string[] }}
69
+ * @returns {{ state: Mission, changes: string[], events: Array<{ type: string, missionId: string }> }}
68
70
  */
69
71
  export function applyMissionUpdate({ mission, state, input, config }) {
70
72
  const actor = input.actor ?? { name: 'tower', role: 'tower' }
@@ -77,6 +79,8 @@ export function applyMissionUpdate({ mission, state, input, config }) {
77
79
  }
78
80
  /** @type {string[]} */
79
81
  const changes = []
82
+ /** @type {Array<{ type: string, missionId: string }>} */
83
+ const events = []
80
84
 
81
85
  if (input.status !== undefined) {
82
86
  if (typeof input.status !== 'string' || !MISSION_STATUSES.includes(input.status)) {
@@ -104,6 +108,8 @@ export function applyMissionUpdate({ mission, state, input, config }) {
104
108
  }
105
109
  mission.status = input.status
106
110
  changes.push(`status=${input.status}`)
111
+ if (input.status === 'completed') events.push(completedEvent(mission))
112
+ if (input.status === 'abandoned') events.push(abandonedEvent(mission))
107
113
  }
108
114
 
109
115
  if (input.task_done !== undefined) {
@@ -169,8 +175,9 @@ export function applyMissionUpdate({ mission, state, input, config }) {
169
175
  const others = state.missions.filter(
170
176
  (entry) => entry.id !== mission.id && !TERMINAL.has(entry.status) && entry.kind !== 'survey',
171
177
  )
178
+ const caseInsensitive = state.caseInsensitive === true
172
179
  for (const other of others) {
173
- if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope)) {
180
+ if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope, { caseInsensitive })) {
174
181
  fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
175
182
  }
176
183
  }
@@ -181,5 +188,5 @@ export function applyMissionUpdate({ mission, state, input, config }) {
181
188
  }
182
189
 
183
190
  if (changes.length === 0) fail('tower_mission requires at least one change')
184
- return { changes }
191
+ return { state: mission, changes, events }
185
192
  }
@@ -4,9 +4,11 @@
4
4
  // activity entry; everything here is deterministic and repository-free.
5
5
  // @ts-check
6
6
  import { TowerToolError } from '../errors.js'
7
+ import { plannedEvents } from '../events.js'
7
8
  import { normalizeScope, validateTitle } from '../scope.js'
8
9
 
9
10
  /** @typedef {import('../../types/index.js').Mission} Mission */
11
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
10
12
 
11
13
  /**
12
14
  * @param {string} message
@@ -147,3 +149,15 @@ export function newMission(id, input, now) {
147
149
  updatedAt: now,
148
150
  }))
149
151
  }
152
+
153
+ /**
154
+ * Append the freshly projected missions to the state document and describe the
155
+ * resulting domain facts. Persistence and revision accounting stay in app/plan.
156
+ * @param {TowerState} state
157
+ * @param {Mission[]} created
158
+ * @returns {{ state: TowerState, events: Array<{ type: string, missionId: string }> }}
159
+ */
160
+ export function applyPlan(state, created) {
161
+ state.missions.push(...created)
162
+ return { state, events: plannedEvents(created) }
163
+ }
@@ -5,6 +5,7 @@
5
5
  // persistence. Everything here is deterministic and repository-free.
6
6
  // @ts-check
7
7
  import { TowerToolError } from '../errors.js'
8
+ import { reviewedEvent } from '../events.js'
8
9
  import { MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
9
10
 
10
11
  /** @typedef {import('../../types/index.js').Mission} Mission */
@@ -109,11 +110,67 @@ export function assertReviewerSpawnTip(caller, tip) {
109
110
  }
110
111
 
111
112
  /**
112
- * Compute the review round and enforce the per-reviewer cap.
113
- * @param {{ reviews: Review[] | undefined, branch: string, reviewerName: string, maxRoundsPerReviewer: unknown }} params
113
+ * The latest review of one branch. Higher rounds win; routes that share a round
114
+ * are ordered by submission sequence, so "latest" is the most recently recorded
115
+ * verdict (#108).
116
+ * @param {Review[]} reviews
117
+ * @returns {Review | null}
118
+ */
119
+ function latestBranchReview(reviews) {
120
+ /** @type {Review | null} */
121
+ let latest = null
122
+ for (const review of reviews) {
123
+ if (!latest) {
124
+ latest = review
125
+ continue
126
+ }
127
+ const round = Number(review.round) || 0
128
+ const latestRound = Number(latest.round) || 0
129
+ if (round > latestRound) latest = review
130
+ else if (round === latestRound && (Number(review.seq) || 0) > (Number(latest.seq) || 0)) latest = review
131
+ }
132
+ return latest
133
+ }
134
+
135
+ /**
136
+ * Decide whether this submission starts a new review round or appends to the
137
+ * current one (#108). A completed mission always starts a new round. A mission
138
+ * that is `active` accepts a later route for the tip that its current round
139
+ * reviewed: the first non-clean verdict already returned it to `active`, and the
140
+ * remaining routes spawned against the same tip must still record their
141
+ * verdicts. A verdict for a tip that moved is rejected with an explanation.
142
+ * @param {{ mission: Mission, reviews: Review[] | undefined, branch: string, tip: string }} params
143
+ * @returns {{ appended: boolean, round: number | null }}
144
+ */
145
+ export function reviewAdmission({ mission, reviews, branch, tip }) {
146
+ if (mission.status === 'completed') return { appended: false, round: null }
147
+ if (mission.status !== 'active') {
148
+ fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
149
+ }
150
+ const latest = latestBranchReview((reviews ?? []).filter((review) => review.branch === branch))
151
+ if (!latest) {
152
+ fail(`mission ${mission.id} is active and has no review round to append to; only a completed mission can be reviewed`)
153
+ }
154
+ if (latest.reviewedCommit !== tip) {
155
+ const cause =
156
+ latest.status === 'clean'
157
+ ? 'the reviewed tip moved'
158
+ : `review round ${latest.round} (${latest.status}) returned it to active and the reviewed tip moved`
159
+ fail(
160
+ `mission ${mission.id} is active: ${cause} (${String(latest.reviewedCommit).slice(0, 7)} -> ${tip.slice(0, 7)}); only review routes for the same reviewed tip can append to round ${latest.round} — return the mission to completed before starting a new round`,
161
+ )
162
+ }
163
+ return { appended: true, round: latest.round }
164
+ }
165
+
166
+ /**
167
+ * Compute the review round and enforce the per-reviewer cap. `round` joins the
168
+ * round `reviewAdmission` selected for an appended route; omitted, the
169
+ * submission opens the next round.
170
+ * @param {{ reviews: Review[] | undefined, branch: string, reviewerName: string, maxRoundsPerReviewer: unknown, round?: number | null }} params
114
171
  * @returns {{ reviewerRounds: number, round: number }}
115
172
  */
116
- export function reviewRound({ reviews, branch, reviewerName, maxRoundsPerReviewer }) {
173
+ export function reviewRound({ reviews, branch, reviewerName, maxRoundsPerReviewer, round: sharedRound }) {
117
174
  const prior = (reviews ?? []).filter((review) => review.branch === branch)
118
175
  const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
119
176
  const maxReviewRounds = Number(maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
@@ -122,17 +179,28 @@ export function reviewRound({ reviews, branch, reviewerName, maxRoundsPerReviewe
122
179
  `reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
123
180
  )
124
181
  }
125
- const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
182
+ const round = Number.isInteger(sharedRound)
183
+ ? /** @type {number} */ (sharedRound)
184
+ : prior.length === 0
185
+ ? 1
186
+ : Math.max(...prior.map((review) => review.round)) + 1
126
187
  return { reviewerRounds, round }
127
188
  }
128
189
 
129
190
  /**
130
191
  * 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
192
+ * clean verdict leaves the status untouched. An appended route records the
193
+ * verdict without flipping the status again (#108).
194
+ * @param {{ status: string, merge: string, round: number, reviewerName: string, appended?: boolean }} params
133
195
  * @returns {{ returnToActive: boolean, note: string }}
134
196
  */
135
- export function reviewOutcome({ status, merge, round, reviewerName }) {
197
+ export function reviewOutcome({ status, merge, round, reviewerName, appended = false }) {
198
+ if (appended) {
199
+ return {
200
+ returnToActive: false,
201
+ note: `review round ${round} by ${reviewerName}: ${status} (${merge}) — appended to the same round; the mission stays active until it is re-completed`,
202
+ }
203
+ }
136
204
  if (status !== 'clean') {
137
205
  return {
138
206
  returnToActive: true,
@@ -142,6 +210,21 @@ export function reviewOutcome({ status, merge, round, reviewerName }) {
142
210
  return { returnToActive: false, note: `review round ${round} by ${reviewerName}: clean (${merge})` }
143
211
  }
144
212
 
213
+ /**
214
+ * Apply a recorded review's mission-level effect to the draft and describe the
215
+ * domain fact. Persistence, the review record and the round accounting stay in
216
+ * app/review.js.
217
+ * @param {{ mission: Mission, outcome: { returnToActive: boolean, note: string }, now: number }} params
218
+ * @returns {{ state: Mission, events: Array<{ type: string, missionId: string }> }}
219
+ */
220
+ export function applyReviewOutcome({ mission, outcome, now }) {
221
+ if (outcome.returnToActive) mission.status = 'active'
222
+ mission.notes = mission.notes ?? []
223
+ mission.notes.push(outcome.note)
224
+ mission.updatedAt = now
225
+ return { state: mission, events: [reviewedEvent(mission)] }
226
+ }
227
+
145
228
  /**
146
229
  * Project the persisted review record.
147
230
  * @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
@@ -85,11 +85,14 @@ function assertAcyclic(missions) {
85
85
  /**
86
86
  * Kimi-style heuristic: overlapping build scopes are serialized by an
87
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.
88
90
  * @param {Mission[]} newMissions
89
91
  * @param {Mission[]} existingMissions
92
+ * @param {{ caseInsensitive?: boolean }} [options]
90
93
  * @returns {void}
91
94
  */
92
- function addAutoDeps(newMissions, existingMissions) {
95
+ function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false } = {}) {
93
96
  const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
94
97
  const all = [...existingMissions, ...newMissions]
95
98
  /** @type {Map<string, Mission>} */
@@ -100,7 +103,7 @@ function addAutoDeps(newMissions, existingMissions) {
100
103
  const earlier = newMissions[i]
101
104
  const later = newMissions[j]
102
105
  if (earlier.kind === 'survey' || later.kind === 'survey') continue
103
- if (!scopesOverlap(earlier.scope, later.scope)) continue
106
+ if (!scopesOverlap(earlier.scope, later.scope, { caseInsensitive })) continue
104
107
  if (hasDependencyPath(byId, later.id, earlier.id)) continue
105
108
  addDependency(later, earlier.id)
106
109
  }
@@ -108,7 +111,7 @@ function addAutoDeps(newMissions, existingMissions) {
108
111
  if (mission.kind === 'survey') continue
109
112
  for (const other of openExisting) {
110
113
  if (other.kind === 'survey') continue
111
- if (!scopesOverlap(mission.scope, other.scope)) continue
114
+ if (!scopesOverlap(mission.scope, other.scope, { caseInsensitive })) continue
112
115
  if (hasDependencyPath(byId, mission.id, other.id)) continue
113
116
  addDependency(mission, other.id)
114
117
  }
@@ -38,9 +38,22 @@ export function missionDetailView({ state, mission, activity }) {
38
38
  * @param {number} input.unreadTower
39
39
  * @param {object} input.config
40
40
  * @param {object[]} input.missions enriched mission views
41
+ * @param {number | null} [input.archivedMissions] archived mission count, or null when unreadable
42
+ * @param {object} [input.archive] archive index view/diagnostic
43
+ * @param {{ held: boolean, pid?: number | null, phase?: string | null, heldMs?: number | null }} [input.gitLock]
41
44
  * @returns {object}
42
45
  */
43
- export function statusView({ state, statePath, activity, unreadTower, config = {}, missions }) {
46
+ export function statusView({
47
+ state,
48
+ statePath,
49
+ activity,
50
+ unreadTower,
51
+ config = {},
52
+ missions,
53
+ archivedMissions = 0,
54
+ archive = null,
55
+ gitLock = { held: false },
56
+ }) {
44
57
  /** @type {Record<string, number>} */
45
58
  const counts = {}
46
59
  for (const status of MISSION_STATUSES) counts[status] = 0
@@ -88,6 +101,9 @@ export function statusView({ state, statePath, activity, unreadTower, config = {
88
101
  at: finding.at,
89
102
  })),
90
103
  missions,
104
+ archivedMissions,
105
+ gitLock,
106
+ ...(archive ? { archive } : {}),
91
107
  activity,
92
108
  }
93
109
  }
package/domain/report.js CHANGED
@@ -7,9 +7,10 @@ import { taskCounts } from './summary.js'
7
7
 
8
8
  /**
9
9
  * @param {TowerState} state
10
+ * @param {{ count?: number, unreadable?: boolean, message?: string } | null} [archive]
10
11
  * @returns {string}
11
12
  */
12
- export function boardMarkdown(state) {
13
+ export function boardMarkdown(state, archive = null) {
13
14
  const lines = []
14
15
  lines.push(`# Tower missions — ${state.repoRoot}`)
15
16
  lines.push('')
@@ -23,6 +24,13 @@ export function boardMarkdown(state) {
23
24
  `Missions: ${state.missions.length} · ` +
24
25
  MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
25
26
  )
27
+ if (archive) {
28
+ lines.push(
29
+ archive.unreadable === true
30
+ ? `Archived: unreadable (${archive.message ?? 'archive index could not be read'})`
31
+ : `Archived: ${archive.count ?? 0} terminal mission(s) in archive/ (not listed; use tower_status includeArchived)`,
32
+ )
33
+ }
26
34
  const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
27
35
  lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
28
36
  lines.push('')