dsh-tower 2.0.3 → 2.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,105 @@
1
+ import { TowerToolError } from './errors.js'
2
+ // Pure scope helpers shared by planning and gates.
3
+ // @ts-check
4
+ const GLOB_META = /[*?[{]/
5
+
6
+ /**
7
+ * @param {string} message
8
+ * @returns {never}
9
+ */
10
+ function fail(message) {
11
+ throw new TowerToolError('invalid-argument', message)
12
+ }
13
+
14
+ /**
15
+ * @param {string} pattern
16
+ * @returns {RegExp}
17
+ */
18
+ function globToRegExp(pattern) {
19
+ let out = '^'
20
+ for (let index = 0; index < pattern.length; index += 1) {
21
+ const char = pattern[index]
22
+ if (char === '*') {
23
+ if (pattern[index + 1] === '*') {
24
+ out += '.*'
25
+ index += 1
26
+ } else {
27
+ out += '[^/]*'
28
+ }
29
+ } else if (char === '?') {
30
+ out += '[^/]'
31
+ } else {
32
+ out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
33
+ }
34
+ }
35
+ return new RegExp(`${out}$`)
36
+ }
37
+
38
+ /**
39
+ * @param {string[] | null | undefined} scope
40
+ * @param {string} file
41
+ * @returns {boolean}
42
+ */
43
+ export function fileInScope(scope, file) {
44
+ return (scope ?? []).some((pattern) => globToRegExp(pattern).test(file))
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} title
49
+ * @returns {string}
50
+ */
51
+ export function validateTitle(title) {
52
+ if (typeof title !== 'string' || title.trim().length === 0) fail('mission title is required')
53
+ if (!/^[\x20-\x7E]+$/.test(title)) fail(`mission title must be printable ASCII: ${JSON.stringify(title)}`)
54
+ return title.trim()
55
+ }
56
+
57
+ /**
58
+ * @param {unknown} scope
59
+ * @param {{ allowEmpty?: boolean }} [options]
60
+ * @returns {string[]}
61
+ */
62
+ export function normalizeScope(scope, { allowEmpty = false } = {}) {
63
+ if (!Array.isArray(scope)) fail('mission scope must be an array of relative globs')
64
+ if (scope.length === 0 && !allowEmpty) fail('mission scope must not be empty for a build mission')
65
+ /** @type {string[]} */
66
+ const out = []
67
+ for (const raw of scope) {
68
+ if (typeof raw !== 'string' || raw.trim().length === 0) fail('scope entries must be non-empty strings')
69
+ const value = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '').trim()
70
+ if (value.length === 0) fail('scope entries must be non-empty strings')
71
+ if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) fail(`scope must be relative: ${raw}`)
72
+ if (value.split('/').some((part) => part === '..')) fail(`scope must not contain "..": ${raw}`)
73
+ if (!out.includes(value)) out.push(value)
74
+ }
75
+ return out
76
+ }
77
+
78
+ /**
79
+ * @param {string} pattern
80
+ * @returns {string}
81
+ */
82
+ function scopePrefix(pattern) {
83
+ const index = pattern.search(GLOB_META)
84
+ const literal = index < 0 ? pattern : pattern.slice(0, index)
85
+ return literal.replace(/\/+$/, '')
86
+ }
87
+
88
+ /**
89
+ * @param {string[]} a
90
+ * @param {string[]} b
91
+ * @returns {boolean}
92
+ */
93
+ export function scopesOverlap(a, b) {
94
+ for (const pa of a) {
95
+ for (const pb of b) {
96
+ if (pa === pb) return true
97
+ const x = scopePrefix(pa)
98
+ const y = scopePrefix(pb)
99
+ if (!x || !y) return true
100
+ if (x === y) return true
101
+ if (x.startsWith(`${y}/`) || y.startsWith(`${x}/`)) return true
102
+ }
103
+ }
104
+ return false
105
+ }
@@ -0,0 +1,233 @@
1
+ // Declarative state-file schema (#56 P4).
2
+ //
3
+ // `StateSchema` describes the structural contract of
4
+ // `<git-common-dir>/dsh-tower/state.json`. Object schemas allow additional
5
+ // properties on purpose: state files are forward/backward compatible and legacy
6
+ // rows carry extra fields. Semantic policy (uniqueness, status/verdict
7
+ // vocabularies, review exemption, worktree records and the revision counter)
8
+ // lives in `./semantics.js`, so this module remains a pure declaration.
9
+ // @ts-check
10
+ import { Type } from '@sinclair/typebox'
11
+ import { Value } from '@sinclair/typebox/value'
12
+ import { SCHEMA_VERSION } from '../mission.js'
13
+
14
+ const optional = { additionalProperties: true }
15
+
16
+ const NonNegativeInteger = Type.Integer({ minimum: 0 })
17
+
18
+ const WorktreeSchema = Type.Object({}, optional)
19
+
20
+ const MissionSchema = Type.Object(
21
+ {
22
+ id: Type.String(),
23
+ status: Type.Optional(Type.String()),
24
+ tasks: Type.Array(Type.Unknown()),
25
+ deps: Type.Array(Type.Unknown()),
26
+ references: Type.Optional(Type.Array(Type.Unknown())),
27
+ reviewPolicy: Type.Optional(Type.String()),
28
+ testRuns: Type.Optional(Type.Array(Type.Unknown())),
29
+ worktree: Type.Optional(Type.Union([WorktreeSchema, Type.Null()])),
30
+ },
31
+ optional,
32
+ )
33
+
34
+ const RosterEntrySchema = Type.Object(
35
+ {
36
+ name: Type.String(),
37
+ agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
38
+ },
39
+ optional,
40
+ )
41
+
42
+ const ReviewSchema = Type.Object(
43
+ {
44
+ id: Type.String(),
45
+ status: Type.Optional(Type.String()),
46
+ mergeVerdict: Type.Optional(Type.String()),
47
+ },
48
+ optional,
49
+ )
50
+
51
+ const FindingSchema = Type.Object(
52
+ {
53
+ id: Type.String(),
54
+ status: Type.Optional(Type.String()),
55
+ },
56
+ optional,
57
+ )
58
+
59
+ const MessageSchema = Type.Object(
60
+ {
61
+ id: Type.String(),
62
+ to: Type.String(),
63
+ readBy: Type.Array(Type.Unknown()),
64
+ },
65
+ optional,
66
+ )
67
+
68
+ /** Structural contract for one persisted Tower state document. */
69
+ export const StateSchema = Type.Object(
70
+ {
71
+ schemaVersion: Type.Literal(SCHEMA_VERSION),
72
+ revision: Type.Optional(Type.Number()),
73
+ base: Type.Optional(Type.String()),
74
+ repoRoot: Type.Optional(Type.String()),
75
+ commonDir: Type.Optional(Type.String()),
76
+ createdAt: Type.Optional(Type.Number()),
77
+ updatedAt: Type.Optional(Type.Number()),
78
+ dirtyBase: Type.Optional(Type.Boolean()),
79
+ towerAgentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
80
+ snapshotCommit: Type.Optional(Type.Union([Type.String(), Type.Null()])),
81
+ snapshotRef: Type.Optional(Type.Union([Type.String(), Type.Null()])),
82
+ missions: Type.Array(MissionSchema),
83
+ roster: Type.Optional(Type.Array(RosterEntrySchema)),
84
+ reviews: Type.Optional(Type.Array(ReviewSchema)),
85
+ findings: Type.Optional(Type.Array(FindingSchema)),
86
+ messages: Type.Optional(Type.Array(MessageSchema)),
87
+ batches: Type.Optional(Type.Array(Type.Unknown())),
88
+ legacySnapshots: Type.Optional(Type.Array(Type.Unknown())),
89
+ legacyMaps: Type.Optional(Type.Array(Type.Unknown())),
90
+ deliveries: Type.Optional(Type.Array(Type.Unknown())),
91
+ mergeIntent: Type.Optional(Type.Union([Type.Object({}, optional), Type.Null()])),
92
+ rebaseIntent: Type.Optional(Type.Union([Type.Object({}, optional), Type.Null()])),
93
+ messageSeq: Type.Optional(NonNegativeInteger),
94
+ reviewSeq: Type.Optional(NonNegativeInteger),
95
+ findingSeq: Type.Optional(NonNegativeInteger),
96
+ batchSeq: Type.Optional(NonNegativeInteger),
97
+ deliverySeq: Type.Optional(NonNegativeInteger),
98
+ },
99
+ optional,
100
+ )
101
+
102
+ /**
103
+ * @param {unknown} value
104
+ * @returns {boolean}
105
+ */
106
+ function isPlainObject(value) {
107
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
108
+ }
109
+
110
+ /** @type {readonly string[]} */
111
+ const ARRAY_KEYS = ['batches', 'legacySnapshots', 'legacyMaps', 'deliveries']
112
+ /** @type {readonly string[]} */
113
+ const SEQUENCE_KEYS = ['messageSeq', 'reviewSeq', 'findingSeq', 'batchSeq', 'deliverySeq']
114
+
115
+ /**
116
+ * Reproduce the historical `[tower:state-corrupt]` diagnostics for the
117
+ * structural failures the schema rejects. Keeping them in the state vocabulary
118
+ * (rather than exposing TypeBox paths) keeps operator-facing messages stable.
119
+ * @param {Record<string, any>} state
120
+ * @param {string} where
121
+ * @returns {string | null}
122
+ */
123
+ function firstShapeMessage(state, where) {
124
+ if (state.schemaVersion !== SCHEMA_VERSION) {
125
+ return `unsupported tower state schemaVersion ${state.schemaVersion}${where}`
126
+ }
127
+ if (!Array.isArray(state.missions)) return `tower state has no missions array${where}`
128
+ for (const mission of state.missions) {
129
+ if (!isPlainObject(mission) || typeof mission.id !== 'string') return `mission entry without id${where}`
130
+ const id = mission.id
131
+ if (!Array.isArray(mission.tasks)) return `mission ${id} has no tasks array${where}`
132
+ if (!Array.isArray(mission.deps)) return `mission ${id} has no deps array${where}`
133
+ if (mission.references !== undefined && !Array.isArray(mission.references)) {
134
+ return `mission ${id} has no references array${where}`
135
+ }
136
+ if (mission.reviewPolicy !== undefined && typeof mission.reviewPolicy !== 'string') {
137
+ return `mission ${id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`
138
+ }
139
+ if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
140
+ return `mission ${id} has no testRuns array${where}`
141
+ }
142
+ if (mission.worktree !== undefined && mission.worktree !== null && !isPlainObject(mission.worktree)) {
143
+ return `mission ${id} has an invalid worktree record${where}`
144
+ }
145
+ }
146
+ for (const key of ['base', 'repoRoot', 'commonDir']) {
147
+ if (state[key] !== undefined && typeof state[key] !== 'string') return `tower state ${key} is not a string${where}`
148
+ }
149
+ for (const key of ['createdAt', 'updatedAt']) {
150
+ if (state[key] !== undefined && typeof state[key] !== 'number') {
151
+ return `tower state ${key} is not a number${where}`
152
+ }
153
+ }
154
+ if (state.dirtyBase !== undefined && typeof state.dirtyBase !== 'boolean') {
155
+ return `tower state dirtyBase is not a boolean${where}`
156
+ }
157
+ for (const key of ['towerAgentId', 'snapshotCommit', 'snapshotRef']) {
158
+ if (state[key] !== undefined && state[key] !== null && typeof state[key] !== 'string') {
159
+ return `tower state ${key} is not a string or null${where}`
160
+ }
161
+ }
162
+ if (state.revision !== undefined && typeof state.revision !== 'number') {
163
+ return `tower state revision is not a non-negative integer${where}`
164
+ }
165
+ for (const key of ARRAY_KEYS) {
166
+ if (state[key] !== undefined && !Array.isArray(state[key])) return `tower state ${key} is not an array${where}`
167
+ }
168
+ for (const key of SEQUENCE_KEYS) {
169
+ if (state[key] !== undefined && !(Number.isInteger(state[key]) && state[key] >= 0)) {
170
+ return `tower state ${key} is not a non-negative integer${where}`
171
+ }
172
+ }
173
+ if (state.mergeIntent !== undefined && state.mergeIntent !== null && !isPlainObject(state.mergeIntent)) {
174
+ return `tower state mergeIntent is not an object${where}`
175
+ }
176
+ if (state.rebaseIntent !== undefined && state.rebaseIntent !== null && !isPlainObject(state.rebaseIntent)) {
177
+ return `tower state rebaseIntent is not an object${where}`
178
+ }
179
+ if (state.roster !== undefined) {
180
+ if (!Array.isArray(state.roster)) return `tower state roster is not an array${where}`
181
+ for (const entry of state.roster) {
182
+ if (!isPlainObject(entry) || typeof entry.name !== 'string') return `invalid roster entry${where}`
183
+ if (entry.agentId !== undefined && entry.agentId !== null && typeof entry.agentId !== 'string') {
184
+ return `invalid roster entry${where}`
185
+ }
186
+ }
187
+ }
188
+ if (state.messages !== undefined) {
189
+ if (!Array.isArray(state.messages)) return `tower state messages is not an array${where}`
190
+ for (const message of state.messages) {
191
+ if (!isPlainObject(message) || typeof message.id !== 'string' || typeof message.to !== 'string') {
192
+ return `invalid message entry${where}`
193
+ }
194
+ if (!Array.isArray(message.readBy)) return `message ${message.id} has no readBy array${where}`
195
+ }
196
+ }
197
+ if (state.findings !== undefined) {
198
+ if (!Array.isArray(state.findings)) return `tower state findings is not an array${where}`
199
+ for (const finding of state.findings) {
200
+ if (!isPlainObject(finding) || typeof finding.id !== 'string') return `invalid finding entry${where}`
201
+ if (finding.status !== undefined && typeof finding.status !== 'string') {
202
+ return `finding ${finding.id} has unknown status ${finding.status}${where}`
203
+ }
204
+ }
205
+ }
206
+ if (state.reviews !== undefined) {
207
+ if (!Array.isArray(state.reviews)) return `tower state reviews is not an array${where}`
208
+ for (const review of state.reviews) {
209
+ if (!isPlainObject(review) || typeof review.id !== 'string') return `invalid review entry${where}`
210
+ if (review.status !== undefined && typeof review.status !== 'string') {
211
+ return `review ${review.id} has unknown status ${review.status}${where}`
212
+ }
213
+ if (review.mergeVerdict !== undefined && typeof review.mergeVerdict !== 'string') {
214
+ return `review ${review.id} has unknown merge verdict ${review.mergeVerdict}${where}`
215
+ }
216
+ }
217
+ }
218
+ return null
219
+ }
220
+
221
+ /**
222
+ * Pure structural check. Returns the legacy diagnostic, or null when the state
223
+ * satisfies `StateSchema`. `where` is the already-formatted " at <path>" suffix.
224
+ * @param {unknown} state
225
+ * @param {string} [where]
226
+ * @returns {string | null}
227
+ */
228
+ export function stateShapeError(state, where = '') {
229
+ if (!isPlainObject(state)) return `tower state is not an object${where}`
230
+ if (Value.Check(StateSchema, state)) return null
231
+ const record = /** @type {Record<string, any>} */ (state)
232
+ return firstShapeMessage(record, where) ?? `tower state does not match the state schema${where}`
233
+ }
@@ -0,0 +1,117 @@
1
+ // Semantic state-file checks (#56 P4).
2
+ //
3
+ // `stateShapeError` proves the structural contract; this module owns the policy
4
+ // the shape cannot express: unique ids, status/verdict vocabularies, the review
5
+ // exemption, roster/agent uniqueness, worktree records and the revision
6
+ // counter. Messages keep the historical `[tower:state-corrupt]` wording.
7
+ // @ts-check
8
+ import { TowerToolError } from '../errors.js'
9
+ import { FINDING_STATUSES, MERGE_VERDICTS, MISSION_STATUSES, REVIEW_STATUS_RE } from '../mission.js'
10
+
11
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
12
+
13
+ /**
14
+ * @param {string} message
15
+ * @returns {never}
16
+ */
17
+ function fail(message) {
18
+ throw new TowerToolError('state-corrupt', message)
19
+ }
20
+
21
+ /**
22
+ * @param {unknown} value
23
+ * @returns {boolean}
24
+ */
25
+ function isPlainObject(value) {
26
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
27
+ }
28
+
29
+ /**
30
+ * A worktree record may omit legacy fields, but every field it carries must
31
+ * keep its documented type.
32
+ * @param {Record<string, any>} mission
33
+ * @param {string} where
34
+ * @returns {void}
35
+ */
36
+ function assertWorktreeRecord(mission, where) {
37
+ const worktree = mission.worktree
38
+ if (worktree === undefined || worktree === null) return
39
+ const invalid = !isPlainObject(worktree) || ['path', 'branch', 'baseCommit', 'realPath'].some(
40
+ (key) => worktree[key] !== undefined && typeof worktree[key] !== 'string',
41
+ ) || (worktree.createdAt !== undefined && typeof worktree.createdAt !== 'number')
42
+ if (invalid) fail(`mission ${mission.id} has an invalid worktree record${where}`)
43
+ }
44
+
45
+ /**
46
+ * @param {unknown} state
47
+ * @param {string} where
48
+ * @returns {TowerState}
49
+ */
50
+ export function assertStateSemantics(state, where) {
51
+ const record = /** @type {Record<string, any>} */ (state)
52
+ if (record.revision !== undefined && (!Number.isInteger(record.revision) || record.revision < 0)) {
53
+ fail(`tower state revision is not a non-negative integer${where}`)
54
+ }
55
+ const missionIds = new Set()
56
+ for (const mission of record.missions) {
57
+ if (missionIds.has(mission.id)) fail(`duplicate mission id ${mission.id}${where}`)
58
+ missionIds.add(mission.id)
59
+ if (!MISSION_STATUSES.includes(mission.status)) {
60
+ fail(`mission ${mission.id} has unknown status ${mission.status}${where}`)
61
+ }
62
+ if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
63
+ fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
64
+ }
65
+ if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
66
+ fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
67
+ }
68
+ assertWorktreeRecord(mission, where)
69
+ }
70
+ if (record.roster !== undefined) {
71
+ const names = new Set()
72
+ const agents = new Set()
73
+ for (const entry of record.roster) {
74
+ const reserved = entry?.state === 'reserved'
75
+ if (!entry || typeof entry.name !== 'string' || (!reserved && typeof entry.agentId !== 'string')) {
76
+ fail(`invalid roster entry${where}`)
77
+ }
78
+ if (names.has(entry.name)) fail(`duplicate roster name ${entry.name}${where}`)
79
+ if (typeof entry.agentId === 'string') {
80
+ if (agents.has(entry.agentId)) fail(`duplicate roster agent ${entry.agentId}${where}`)
81
+ agents.add(entry.agentId)
82
+ }
83
+ names.add(entry.name)
84
+ }
85
+ }
86
+ if (record.messages !== undefined) {
87
+ const ids = new Set()
88
+ for (const message of record.messages) {
89
+ if (ids.has(message.id)) fail(`duplicate message id ${message.id}${where}`)
90
+ ids.add(message.id)
91
+ }
92
+ }
93
+ if (record.findings !== undefined) {
94
+ const ids = new Set()
95
+ for (const finding of record.findings) {
96
+ if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
97
+ ids.add(finding.id)
98
+ if (finding.status !== undefined && !FINDING_STATUSES.includes(finding.status)) {
99
+ fail(`finding ${finding.id} has unknown status ${finding.status}${where}`)
100
+ }
101
+ }
102
+ }
103
+ if (record.reviews !== undefined) {
104
+ const ids = new Set()
105
+ for (const review of record.reviews) {
106
+ if (ids.has(review.id)) fail(`duplicate review id ${review.id}${where}`)
107
+ ids.add(review.id)
108
+ if (!REVIEW_STATUS_RE.test(String(review.status))) {
109
+ fail(`review ${review.id} has unknown status ${review.status}${where}`)
110
+ }
111
+ if (!MERGE_VERDICTS.includes(review.mergeVerdict)) {
112
+ fail(`review ${review.id} has unknown merge verdict ${review.mergeVerdict}${where}`)
113
+ }
114
+ }
115
+ }
116
+ return /** @type {TowerState} */ (record)
117
+ }
@@ -0,0 +1,33 @@
1
+ // Pure state-file validation (#56 P4).
2
+ //
3
+ // The structural contract lives in `state/schema.js` (TypeBox) and the policy
4
+ // checks in `state/semantics.js`. This module stays the public entry point so
5
+ // every app/tool import of `domain/state.js` is unchanged.
6
+ // @ts-check
7
+ import { TowerToolError } from './errors.js'
8
+ import { stateShapeError } from './state/schema.js'
9
+ import { assertStateSemantics } from './state/semantics.js'
10
+
11
+ /** @typedef {import('../types/index.js').TowerState} TowerState */
12
+
13
+ export { StateSchema, stateShapeError } from './state/schema.js'
14
+
15
+ /**
16
+ * @param {string} message
17
+ * @returns {never}
18
+ */
19
+ function fail(message) {
20
+ throw new TowerToolError('state-corrupt', message)
21
+ }
22
+
23
+ /**
24
+ * @param {unknown} state
25
+ * @param {string} [statePath]
26
+ * @returns {TowerState}
27
+ */
28
+ export function validateState(state, statePath) {
29
+ const where = statePath ? ` at ${statePath}` : ''
30
+ const shapeError = stateShapeError(state, where)
31
+ if (shapeError) fail(shapeError)
32
+ return assertStateSemantics(state, where)
33
+ }
@@ -0,0 +1,114 @@
1
+ // Pure mission/finding summaries.
2
+ // @ts-check
3
+ import { FINDING_STATUSES } from './mission.js'
4
+
5
+ /** @typedef {import('../types/index.js').Finding} Finding */
6
+ /** @typedef {import('../types/index.js').FindingSummary} FindingSummary */
7
+ /** @typedef {import('../types/index.js').Mission} Mission */
8
+ /** @typedef {import('../types/index.js').MissionDetail} MissionDetail */
9
+ /** @typedef {import('../types/index.js').MissionSummary} MissionSummary */
10
+ /** @typedef {import('../types/index.js').MissionTask} MissionTask */
11
+ /** @typedef {import('../types/index.js').TaskCounts} TaskCounts */
12
+
13
+ /**
14
+ * @param {MissionTask[]} tasks
15
+ * @returns {TaskCounts}
16
+ */
17
+ function taskCounts(tasks) {
18
+ /** @type {TaskCounts} */
19
+ const counts = { total: tasks.length, done: 0, open: 0, dropped: 0 }
20
+ for (const task of tasks) {
21
+ if (task.status === 'done') counts.done += 1
22
+ else if (task.status === 'dropped') counts.dropped += 1
23
+ else counts.open += 1
24
+ }
25
+ return counts
26
+ }
27
+
28
+ /**
29
+ * @param {Mission} mission
30
+ * @returns {MissionSummary}
31
+ */
32
+ export function missionSummary(mission) {
33
+ return {
34
+ id: mission.id,
35
+ title: mission.title,
36
+ kind: mission.kind,
37
+ status: mission.status,
38
+ scope: mission.scope,
39
+ deps: mission.deps,
40
+ owner: mission.owner ?? null,
41
+ worktree: mission.worktree
42
+ ? {
43
+ path: mission.worktree.path,
44
+ branch: mission.worktree.branch,
45
+ baseCommit: mission.worktree.baseCommit,
46
+ }
47
+ : null,
48
+ blockers: mission.blockers ?? [],
49
+ tasks: taskCounts(mission.tasks ?? []),
50
+ scopeGeneration: mission.scopeGeneration ?? 1,
51
+ ownerGeneration: mission.ownerGeneration ?? 0,
52
+ pauseCause: mission.pauseCause ?? null,
53
+ reviewPolicy: mission.reviewPolicy ?? 'required-clean',
54
+ mergedCommit: mission.mergedCommit ?? null,
55
+ mergedAt: mission.mergedAt ?? null,
56
+ updatedAt: mission.updatedAt,
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {Mission} mission
62
+ * @returns {MissionDetail}
63
+ */
64
+ function fullMission(mission) {
65
+ return {
66
+ ...missionSummary(mission),
67
+ tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
68
+ notes: mission.notes ?? [],
69
+ completionReport: mission.completionReport ?? null,
70
+ authorizationRef: mission.authorizationRef ?? null,
71
+ references: mission.references ?? [],
72
+ reviewPolicy: mission.reviewPolicy ?? 'required-clean',
73
+ reviewExemptionReason: mission.reviewExemptionReason ?? '',
74
+ testRuns: mission.testRuns ?? [],
75
+ }
76
+ }
77
+
78
+ /**
79
+ * @param {Finding} finding
80
+ * @returns {FindingSummary}
81
+ */
82
+ function findingSummary(finding) {
83
+ return {
84
+ id: finding.id,
85
+ seq: finding.seq ?? null,
86
+ type: finding.type,
87
+ title: finding.title,
88
+ severity: finding.severity ?? null,
89
+ status: finding.status ?? 'open',
90
+ missionId: finding.missionId ?? null,
91
+ branch: finding.branch ?? null,
92
+ reportedBy: finding.reportedBy ?? null,
93
+ at: finding.at ?? null,
94
+ updatedAt: finding.updatedAt ?? null,
95
+ closedAt: finding.closedAt ?? null,
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @param {Finding[]} findings
101
+ * @returns {Record<string, number>}
102
+ */
103
+ function countFindings(findings) {
104
+ /** @type {Record<string, number>} */
105
+ const counts = {}
106
+ for (const status of FINDING_STATUSES) counts[status] = 0
107
+ for (const finding of findings) {
108
+ const status = finding.status ?? 'open'
109
+ counts[status] = (counts[status] ?? 0) + 1
110
+ }
111
+ return counts
112
+ }
113
+
114
+ export { taskCounts, fullMission, findingSummary, countFindings }