dsh-tower 2.1.0 → 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 (170) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/COMPATIBILITY.md +5 -1
  3. package/CONTRIBUTING.md +64 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +7 -4
  6. package/README.md +7 -4
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/docs/README.md +6 -2
  44. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  45. package/docs/adr/0002-single-state-json.md +50 -0
  46. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  47. package/docs/adr/0004-surface-budget.md +50 -0
  48. package/docs/adr/README.md +51 -0
  49. package/docs/agents/workflow.md +51 -0
  50. package/docs/architecture/overview.md +167 -36
  51. package/docs/guide/hot-reload.md +1 -1
  52. package/docs/guide/release.md +1 -1
  53. package/docs/operations/incident.md +82 -0
  54. package/docs/operations/runbook.md +12 -3
  55. package/docs/reference/blockers.md +2 -2
  56. package/docs/reference/config.generated.md +1 -0
  57. package/docs/reference/doctor.md +48 -2
  58. package/docs/reference/errors.generated.md +41 -37
  59. package/docs/reference/errors.md +53 -41
  60. package/docs/reference/events.md +38 -6
  61. package/docs/reference/surface-budget.md +128 -0
  62. package/docs/reference/tools.generated.md +5 -4
  63. package/docs/reference/tools.md +4 -4
  64. package/docs/zh/guide/hot-reload.md +1 -1
  65. package/docs/zh/reference/blockers.md +2 -2
  66. package/docs/zh/reference/doctor.md +29 -1
  67. package/docs/zh/reference/tools.md +4 -4
  68. package/domain/batch.js +2 -2
  69. package/domain/config/schema.js +25 -1
  70. package/domain/contracts/outputs.js +6 -2
  71. package/domain/contracts/tool-inputs/ops.js +1 -0
  72. package/domain/errors.js +297 -42
  73. package/domain/events.js +207 -0
  74. package/domain/integration/gate.js +17 -2
  75. package/domain/integration/merge.js +35 -0
  76. package/domain/lockBudget.js +30 -0
  77. package/domain/missions/completion.js +14 -0
  78. package/domain/missions/lifecycle.js +9 -3
  79. package/domain/missions/plan.js +14 -0
  80. package/domain/missions/review.js +90 -7
  81. package/domain/read/views.js +3 -0
  82. package/domain/state/archive.js +1 -1
  83. package/domain/state.js +1 -1
  84. package/domain/surface.js +248 -0
  85. package/{lib → host}/doctor.js +116 -9
  86. package/{lib → host}/modeStore.js +1 -1
  87. package/{lib → host}/tool-registry.js +106 -31
  88. package/host/tower.js +32 -0
  89. package/index.js +12 -9
  90. package/package.json +10 -3
  91. package/ports/archive.js +18 -0
  92. package/ports/caseSensitivity.js +15 -0
  93. package/ports/clock.js +10 -2
  94. package/ports/config.js +19 -0
  95. package/ports/contract.js +38 -0
  96. package/ports/events.js +24 -0
  97. package/ports/ids.js +10 -2
  98. package/ports/index.js +54 -2
  99. package/ports/ownership.js +17 -0
  100. package/ports/state.js +16 -0
  101. package/ports/store.js +52 -3
  102. package/ports/vcs.js +25 -4
  103. package/scripts/architecture-waivers.json +19 -0
  104. package/scripts/architecture.mjs +634 -23
  105. package/scripts/check-docs.mjs +8 -35
  106. package/scripts/check-pack.mjs +68 -39
  107. package/scripts/check-philosophy.mjs +266 -0
  108. package/scripts/domain-metrics.mjs +13 -4
  109. package/scripts/generate-config-reference.mjs +14 -1
  110. package/scripts/generate-error-reference.mjs +68 -14
  111. package/scripts/generate-events-reference.mjs +83 -0
  112. package/scripts/generate-tools-reference.mjs +11 -0
  113. package/scripts/issue-audit.mjs +2 -2
  114. package/scripts/lib/surface.mjs +87 -0
  115. package/scripts/pack-manifest.txt +58 -22
  116. package/scripts/regression-audit.mjs +164 -0
  117. package/scripts/release-check.mjs +1 -1
  118. package/scripts/surface-baseline.json +21 -0
  119. package/scripts/surface-baseline.mjs +120 -0
  120. package/tools/adopt.js +2 -3
  121. package/tools/batch.js +2 -3
  122. package/tools/complete.js +2 -3
  123. package/tools/diff.js +3 -3
  124. package/tools/doctor.js +7 -10
  125. package/tools/finding.js +2 -3
  126. package/tools/findings.js +3 -3
  127. package/tools/import-legacy.js +3 -5
  128. package/tools/inbox.js +3 -3
  129. package/tools/index.js +7 -7
  130. package/tools/init.js +3 -6
  131. package/tools/interrupt.js +2 -3
  132. package/tools/legacy-map.js +3 -5
  133. package/tools/merge.js +3 -3
  134. package/tools/mission.js +2 -3
  135. package/tools/plan.js +6 -5
  136. package/tools/preflight.js +2 -3
  137. package/tools/rebase.js +3 -3
  138. package/tools/recover.js +38 -13
  139. package/tools/reload.js +3 -6
  140. package/tools/resource.js +3 -5
  141. package/tools/review.js +3 -4
  142. package/tools/roster.js +2 -3
  143. package/tools/send.js +2 -2
  144. package/tools/spawn.js +8 -5
  145. package/tools/status.js +3 -3
  146. package/tools/teardown.js +2 -3
  147. package/tools/test.js +2 -2
  148. package/types/index.d.ts +1 -1
  149. package/ui-bundle/README.md +37 -3
  150. package/ui-bundle/client.cards.js +356 -0
  151. package/ui-bundle/client.denied.js +142 -0
  152. package/ui-bundle/client.dock.js +333 -0
  153. package/ui-bundle/client.helpers.js +271 -0
  154. package/ui-bundle/client.js +79 -1747
  155. package/ui-bundle/client.locales.js +247 -0
  156. package/ui-bundle/client.mode.js +206 -0
  157. package/ui-bundle/client.overlay.js +64 -0
  158. package/ui-bundle/client.styles.js +151 -0
  159. package/ui-bundle/client.toolview.js +82 -0
  160. package/ui-bundle/package.json +1 -0
  161. package/lib/persistence.js +0 -356
  162. package/lib/store.js +0 -133
  163. /package/{lib → adapters}/stateFile.js +0 -0
  164. /package/{lib → app}/briefing.js +0 -0
  165. /package/{lib → app}/spawn-support.js +0 -0
  166. /package/{lib → host}/compatibility.js +0 -0
  167. /package/{lib → host}/config.js +0 -0
  168. /package/{lib → host}/hmr.js +0 -0
  169. /package/{lib → host}/modeState.js +0 -0
  170. /package/{lib → host}/presets.js +0 -0
@@ -123,11 +123,26 @@ export function baseDirtyBlocker(wouldOverwrite) {
123
123
  : null
124
124
  }
125
125
 
126
+ /**
127
+ * Whether a dependency already satisfies the gate. A build mission must be
128
+ * merged (or abandoned), but a survey is terminal-success at `completed`: it is
129
+ * read-only and has no branch to merge, so requiring `merged` would keep the
130
+ * dependent mission blocked forever (#107). Archived entries carry `kind` for
131
+ * the same decision.
132
+ * @param {{ kind?: string, status?: string } | null | undefined} dep
133
+ * @returns {boolean}
134
+ */
135
+ export function dependencySatisfied(dep) {
136
+ if (!dep) return false
137
+ if (dep.status === 'merged' || dep.status === 'abandoned') return true
138
+ return dep.kind === 'survey' && dep.status === 'completed'
139
+ }
140
+
126
141
  /**
127
142
  * @param {object} input
128
143
  * @param {string[]} input.deps
129
144
  * @param {TowerState} input.state
130
- * @param {Map<string, { status: string }>} [input.archived] compact archived missions (#73)
145
+ * @param {Map<string, { status: string, kind?: string }>} [input.archived] compact archived missions (#73)
131
146
  * @returns {Blocker[]}
132
147
  */
133
148
  export function dependencyBlockers({ deps = [], state, archived = new Map() }) {
@@ -135,7 +150,7 @@ export function dependencyBlockers({ deps = [], state, archived = new Map() }) {
135
150
  const blockers = []
136
151
  for (const depId of deps) {
137
152
  const dep = state.missions.find((entry) => entry.id === depId) ?? archived.get(depId)
138
- if (!dep || !['merged', 'abandoned'].includes(dep.status)) {
153
+ if (!dependencySatisfied(dep)) {
139
154
  blockers.push({ code: 'deps-unmerged', detail: `${depId} is ${dep ? dep.status : 'missing'}` })
140
155
  }
141
156
  }
@@ -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
+ }
@@ -4,6 +4,7 @@
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 */
@@ -165,3 +166,16 @@ export function completionReport({ report, worker, checks, now, maxReportBytes }
165
166
  const text = report.trim()
166
167
  return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
167
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)] }
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) {
@@ -182,5 +188,5 @@ export function applyMissionUpdate({ mission, state, input, config }) {
182
188
  }
183
189
 
184
190
  if (changes.length === 0) fail('tower_mission requires at least one change')
185
- return { changes }
191
+ return { state: mission, changes, events }
186
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
@@ -40,6 +40,7 @@ export function missionDetailView({ state, mission, activity }) {
40
40
  * @param {object[]} input.missions enriched mission views
41
41
  * @param {number | null} [input.archivedMissions] archived mission count, or null when unreadable
42
42
  * @param {object} [input.archive] archive index view/diagnostic
43
+ * @param {{ held: boolean, pid?: number | null, phase?: string | null, heldMs?: number | null }} [input.gitLock]
43
44
  * @returns {object}
44
45
  */
45
46
  export function statusView({
@@ -51,6 +52,7 @@ export function statusView({
51
52
  missions,
52
53
  archivedMissions = 0,
53
54
  archive = null,
55
+ gitLock = { held: false },
54
56
  }) {
55
57
  /** @type {Record<string, number>} */
56
58
  const counts = {}
@@ -100,6 +102,7 @@ export function statusView({
100
102
  })),
101
103
  missions,
102
104
  archivedMissions,
105
+ gitLock,
103
106
  ...(archive ? { archive } : {}),
104
107
  activity,
105
108
  }
@@ -9,7 +9,7 @@
9
9
  // This module is pure: it declares the archive document contract, selects what
10
10
  // is eligible under the configured policy, and builds the records. Reading and
11
11
  // writing the files, and the lock that makes them atomic with state.json, live
12
- // in `lib/archive.js` and `app/archive.js`.
12
+ // in `adapters/archive.js` and `app/archive.js`.
13
13
  // @ts-check
14
14
  import { isTerminal } from '../mission.js'
15
15
 
package/domain/state.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // `validateState` proves a document matches the *current* schema.
9
9
  // `prepareState` is the load-path decision: it inspects the version, applies the
10
10
  // ordered migration registry, then validates the target schema. Both are pure;
11
- // the side effects (backup, write, activity) live in `lib/stateFile.js`.
11
+ // the side effects (backup, write, activity) live in `adapters/stateFile.js`.
12
12
  // @ts-check
13
13
  import { TowerToolError } from './errors.js'
14
14
  import { SCHEMA_VERSION } from './mission.js'
@@ -0,0 +1,248 @@
1
+ // Concept budget: surface metrics, the committed baseline and its classifications (#84).
2
+ //
3
+ // The repository does not measure "how many concepts a user has to hold" by
4
+ // counting lines. It measures the surface — config leaf keys, config sections and
5
+ // registered tools — and keeps a committed baseline. Growth is refused by
6
+ // default: a baseline decision that raises a count must name either an ADR (why
7
+ // the surface has to grow) or deletions that are provably gone from the live
8
+ // surface (the added concept replaced a removed one).
9
+ //
10
+ // This module is pure: no fs, no git. `scripts/surface-baseline.mjs` supplies
11
+ // the live counts and the file reads; `scripts/lib/surface.mjs` wraps them.
12
+ export const SURFACE_BASELINE_FILE = 'scripts/surface-baseline.json'
13
+ export const SURFACE_CLASSIFICATION_FILE = 'docs/reference/surface-budget.md'
14
+ export const SURFACE_KEYS = ['configKeys', 'sections', 'tools']
15
+ export const CONFIG_CLASSES = ['core', 'tuning', 'niche']
16
+ export const TOOL_CLASSES = ['core', 'optional']
17
+ export const SURFACE_SCHEMA_VERSION = 1
18
+
19
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
20
+
21
+ /**
22
+ * The human-readable count line shared by `tower_doctor` and the generated
23
+ * references: `config keys: N · sections: N · tools: N`.
24
+ * @param {{ configKeys?: number, sections?: number, tools?: number }} counts
25
+ */
26
+ export function surfaceLine(counts = {}) {
27
+ const value = (key) => (Number.isInteger(counts[key]) ? String(counts[key]) : '?')
28
+ return `config keys: ${value('configKeys')} · sections: ${value('sections')} · tools: ${value('tools')}`
29
+ }
30
+
31
+ function isPlainObject(value) {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
33
+ }
34
+
35
+ function surfaceError(message) {
36
+ return new Error(message)
37
+ }
38
+
39
+ /**
40
+ * Parse and validate `scripts/surface-baseline.json`. Throws with an actionable
41
+ * message when the file is malformed; returns a normalized copy otherwise.
42
+ * @param {string | object} source
43
+ */
44
+ export function parseBaseline(source) {
45
+ let value
46
+ if (typeof source === 'string') {
47
+ try {
48
+ value = JSON.parse(source)
49
+ } catch (error) {
50
+ throw surfaceError(`surface baseline is not valid JSON: ${error?.message ?? error}`)
51
+ }
52
+ } else {
53
+ value = source
54
+ }
55
+ if (!isPlainObject(value)) throw surfaceError('surface baseline must be a JSON object')
56
+ if (value.schema !== undefined && value.schema !== SURFACE_SCHEMA_VERSION) {
57
+ throw surfaceError(`surface baseline schema ${value.schema} is not supported (expected ${SURFACE_SCHEMA_VERSION})`)
58
+ }
59
+ if (typeof value.date !== 'string' || !ISO_DATE.test(value.date)) {
60
+ throw surfaceError(`surface baseline needs a date (YYYY-MM-DD), got ${JSON.stringify(value.date)}`)
61
+ }
62
+ if (!isPlainObject(value.surfaces)) throw surfaceError('surface baseline needs a "surfaces" object')
63
+ const surfaces = {}
64
+ for (const key of SURFACE_KEYS) {
65
+ const entry = value.surfaces[key]
66
+ if (!isPlainObject(entry)) throw surfaceError(`surface baseline misses the "${key}" entry`)
67
+ if (!Number.isInteger(entry.seed) || entry.seed < 0) {
68
+ throw surfaceError(`surface baseline ${key}.seed must be a non-negative integer`)
69
+ }
70
+ if (!Number.isInteger(entry.value) || entry.value < 0) {
71
+ throw surfaceError(`surface baseline ${key}.value must be a non-negative integer`)
72
+ }
73
+ if (!Array.isArray(entry.decisions)) throw surfaceError(`surface baseline ${key}.decisions must be an array`)
74
+ surfaces[key] = {
75
+ seed: entry.seed,
76
+ value: entry.value,
77
+ decisions: entry.decisions.map((decision, index) => {
78
+ if (!isPlainObject(decision)) {
79
+ throw surfaceError(`surface baseline ${key}.decisions[${index}] must be an object`)
80
+ }
81
+ if (!Number.isInteger(decision.from) || decision.from < 0) {
82
+ throw surfaceError(`surface baseline ${key}.decisions[${index}].from must be a non-negative integer`)
83
+ }
84
+ if (!Number.isInteger(decision.to) || decision.to < 0) {
85
+ throw surfaceError(`surface baseline ${key}.decisions[${index}].to must be a non-negative integer`)
86
+ }
87
+ return { ...decision }
88
+ }),
89
+ }
90
+ }
91
+ return { schema: SURFACE_SCHEMA_VERSION, date: value.date, surfaces }
92
+ }
93
+
94
+ /**
95
+ * The rows of the classification table, already parsed. `kind` is `config` or
96
+ * `tools`, `name` is the key/tool name, `className` is the declared class.
97
+ * @param {string} markdown
98
+ */
99
+ export function parseClassification(markdown) {
100
+ const text = String(markdown ?? '')
101
+ const dateMatch = /^\s*(?:[-*]\s*)?Decision date:\s*(\d{4}-\d{2}-\d{2})\s*$/im.exec(text)
102
+ const entries = []
103
+ let kind = null
104
+ for (const rawLine of text.split(/\r?\n/)) {
105
+ const heading = /^#{1,6}\s+(.+?)\s*$/.exec(rawLine)
106
+ if (heading) {
107
+ const title = heading[1].toLowerCase()
108
+ kind = title.includes('config') ? 'config' : title.includes('tool') ? 'tools' : null
109
+ continue
110
+ }
111
+ if (!kind) continue
112
+ const match = /^\|\s*`([^`]+)`\s*\|\s*([a-z-]+)\s*\|\s*(.*?)\s*\|\s*$/.exec(rawLine)
113
+ if (!match) continue
114
+ entries.push({ kind, name: match[1].trim(), className: match[2].trim().toLowerCase(), note: match[3].trim() })
115
+ }
116
+ return { date: dateMatch ? dateMatch[1] : null, entries }
117
+ }
118
+
119
+ /**
120
+ * Validate the classification table against the live surface. Every live key and
121
+ * tool must appear exactly once with a legal class; every `niche` row must carry
122
+ * a removal note, so "we will remove it later" is a written promise rather than
123
+ * an implication.
124
+ * @param {string} markdown
125
+ * @param {{ configKeys: string[], sections: string[], tools: string[] }} liveNames
126
+ * @param {{ baselineDate?: string }} [options]
127
+ * @returns {{ date: string | null, entries: Array<{ kind: string, name: string, className: string, note: string }>, findings: Array<{ code: string, level: string, message: string }> }}
128
+ */
129
+ export function validateClassification(markdown, liveNames, options = {}) {
130
+ const parsed = parseClassification(markdown)
131
+ const findings = []
132
+ const text = String(markdown ?? '')
133
+ const record = (code, message) => findings.push({ code, level: 'error', message })
134
+ if (!parsed.date) {
135
+ record('classification-date', 'classification table needs a "Decision date: YYYY-MM-DD" line')
136
+ } else if (options.baselineDate && parsed.date !== options.baselineDate) {
137
+ record(
138
+ 'classification-date',
139
+ `classification date ${parsed.date} does not match the baseline date ${options.baselineDate}`,
140
+ )
141
+ }
142
+
143
+ const checkRows = (kind, expected, legal) => {
144
+ const rows = parsed.entries.filter((entry) => entry.kind === kind)
145
+ const seen = new Map()
146
+ for (const row of rows) {
147
+ if (seen.has(row.name)) record('classification-duplicate', `${kind === 'config' ? 'config key' : 'tool'} ${row.name} is classified twice`)
148
+ seen.set(row.name, row)
149
+ if (!legal.includes(row.className)) {
150
+ record(
151
+ 'classification-class',
152
+ `${kind === 'config' ? 'config key' : 'tool'} ${row.name} has class "${row.className}" (expected ${legal.join('/')})`,
153
+ )
154
+ }
155
+ }
156
+ for (const name of expected) {
157
+ if (!seen.has(name)) record('classification-missing', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not classified`)
158
+ }
159
+ const expectedSet = new Set(expected)
160
+ for (const name of seen.keys()) {
161
+ if (!expectedSet.has(name)) record('classification-unknown', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not in the live surface`)
162
+ }
163
+ return rows
164
+ }
165
+
166
+ const configRows = checkRows('config', liveNames.configKeys, CONFIG_CLASSES)
167
+ checkRows('tools', liveNames.tools, TOOL_CLASSES)
168
+ const nicheRows = configRows.filter((row) => row.className === 'niche')
169
+ for (const row of nicheRows) {
170
+ if (!/removal note:/i.test(row.note)) {
171
+ record('niche-removal-note', `niche config key ${row.name} needs a "Removal note:" in its table row`)
172
+ }
173
+ }
174
+ if (nicheRows.length === 0 && !/no key qualifies as niche/i.test(text)) {
175
+ record('niche-removal-note', 'no niche config key has a removal note and the table does not say "no key qualifies as niche"')
176
+ }
177
+ return { date: parsed.date, entries: parsed.entries, findings }
178
+ }
179
+
180
+ /**
181
+ * Validate a committed baseline against the live counts and its own decision
182
+ * chain. This is the brake: `current[surface] !== entry.value` fails, and every
183
+ * decision that grows a count must name an existing ADR or deletions that are
184
+ * provably absent from the live surface.
185
+ *
186
+ * @param {ReturnType<typeof parseBaseline>} baseline
187
+ * @param {{ configKeys: number, sections: number, tools: number, liveNames: { configKeys: string[], sections: string[], tools: string[] } }} current
188
+ * @param {{ exists?: (relativePath: string) => boolean }} [options]
189
+ * @returns {Array<{ code: string, level: string, message: string }>}
190
+ */
191
+ export function validateBaseline(baseline, current, options = {}) {
192
+ const exists = options.exists ?? (() => false)
193
+ const findings = []
194
+ const record = (code, message) => findings.push({ code, level: 'error', message })
195
+
196
+ for (const key of SURFACE_KEYS) {
197
+ const entry = baseline.surfaces[key]
198
+ const live = current[key]
199
+ if (!Number.isInteger(live)) {
200
+ record('surface-missing', `live ${key} count is unavailable`)
201
+ continue
202
+ }
203
+ if (live !== entry.value) {
204
+ const direction = live > entry.value ? 'grew' : 'shrank'
205
+ record(
206
+ 'stale-baseline',
207
+ `${key} ${direction} from the committed baseline ${entry.value} to ${live}; update ${SURFACE_BASELINE_FILE} with an ADR or a deletion record before landing`,
208
+ )
209
+ }
210
+ let running = entry.seed
211
+ entry.decisions.forEach((decision, index) => {
212
+ const where = `${key}.decisions[${index}]`
213
+ if (decision.from !== running) {
214
+ record('broken-chain', `${where}.from ${decision.from} does not continue from ${running}`)
215
+ }
216
+ if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
217
+ record('broken-chain', `${where}.date is not YYYY-MM-DD`)
218
+ }
219
+ const growth = decision.to - decision.from
220
+ if (growth > 0) {
221
+ const deletions = Array.isArray(decision.deletions)
222
+ ? decision.deletions.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim())
223
+ : []
224
+ const adr = typeof decision.adr === 'string' && decision.adr.trim() ? decision.adr.trim().replace(/\\/g, '/') : null
225
+ const adrExists = Boolean(adr) && adr.startsWith('docs/adr/') && adr.endsWith('.md') && exists(adr)
226
+ const liveNames = new Set(current.liveNames?.[key] ?? [])
227
+ const stillLive = deletions.filter((name) => liveNames.has(name))
228
+ const deletionEvidence = deletions.length >= growth && stillLive.length === 0
229
+ if (!adrExists && !deletionEvidence) {
230
+ const reasons = []
231
+ if (adr && !adrExists) reasons.push(`ADR ${adr} does not exist under docs/adr/`)
232
+ if (deletions.length === 0) reasons.push('no deletions recorded')
233
+ else if (stillLive.length > 0) reasons.push(`deleted name(s) are still in the live surface: ${stillLive.join(', ')}`)
234
+ else if (deletions.length < growth) reasons.push(`only ${deletions.length} deletion(s) for a growth of ${growth}`)
235
+ record('growth-without-evidence', `${where} grows ${key} by ${growth} without an ADR or a matching deletion (${reasons.join('; ')})`)
236
+ }
237
+ }
238
+ running = decision.to
239
+ })
240
+ if (running !== entry.value && !(entry.decisions.length === 0 && entry.value < running)) {
241
+ record(
242
+ 'broken-chain',
243
+ `${key} value ${entry.value} does not match the last decision (${running}); every count change needs a decision record`,
244
+ )
245
+ }
246
+ }
247
+ return findings
248
+ }