dsh-tower 2.6.0 → 2.7.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 (116) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.en.md +2 -1
  5. package/README.md +6 -1
  6. package/adapters/leases.js +20 -6
  7. package/adapters/legacy.js +149 -37
  8. package/adapters/stateFile.js +11 -1
  9. package/app/archive.js +30 -5
  10. package/app/briefing.js +95 -12
  11. package/app/checkCompletion.js +107 -18
  12. package/app/enrichedMissions.js +1 -1
  13. package/app/freshness.js +77 -0
  14. package/app/gate.js +26 -12
  15. package/app/merge.js +7 -4
  16. package/app/mission.js +24 -1
  17. package/app/missionContext.js +2 -1
  18. package/app/plan.js +64 -1
  19. package/app/provision.js +133 -22
  20. package/app/read.js +38 -5
  21. package/app/rebase.js +10 -8
  22. package/app/review.js +97 -61
  23. package/app/reviewCheckout.js +133 -0
  24. package/app/sandbox.js +121 -0
  25. package/app/sharedCheckout.js +90 -0
  26. package/app/spawn-prechecks.js +25 -24
  27. package/app/spawn-support.js +59 -8
  28. package/app/spawn.js +3 -0
  29. package/app/teardown.js +119 -2
  30. package/app/tower.js +29 -1
  31. package/cordis.patch.yml +50 -0
  32. package/docs/adr/0006-mission-shapes.md +306 -0
  33. package/docs/architecture/overview.md +6 -4
  34. package/docs/guide/install-upgrade-migration.md +26 -0
  35. package/docs/operations/runbook.md +1 -1
  36. package/docs/reference/blockers.md +8 -4
  37. package/docs/reference/component-surface.md +7 -2
  38. package/docs/reference/config.generated.md +6 -4
  39. package/docs/reference/config.md +1 -2
  40. package/docs/reference/doctor.md +7 -7
  41. package/docs/reference/operations.md +1 -1
  42. package/docs/reference/shapes.md +123 -0
  43. package/docs/reference/surface-budget.md +33 -1
  44. package/docs/reference/testing.md +4 -2
  45. package/docs/reference/tools.generated.md +5 -5
  46. package/docs/reference/tools.md +2 -2
  47. package/docs/zh/reference/blockers.md +2 -2
  48. package/docs/zh/reference/config.md +1 -2
  49. package/docs/zh/reference/testing.md +3 -2
  50. package/domain/batch.js +75 -105
  51. package/domain/collab/batch.js +30 -10
  52. package/domain/collab/spawn.js +53 -16
  53. package/domain/config/schema.js +47 -17
  54. package/domain/contracts/outputs.js +0 -1
  55. package/domain/contracts/tool-inputs/missions.js +9 -1
  56. package/domain/evidence.js +283 -0
  57. package/domain/gate.js +11 -31
  58. package/domain/integration/gate.js +17 -43
  59. package/domain/integration/init.js +3 -2
  60. package/domain/integration/provision.js +51 -0
  61. package/domain/integration/rebase.js +16 -1
  62. package/domain/mission.js +16 -33
  63. package/domain/missions/completion.js +46 -8
  64. package/domain/missions/findings.js +2 -1
  65. package/domain/missions/lifecycle.js +131 -35
  66. package/domain/missions/plan.js +30 -6
  67. package/domain/missions/review.js +2 -18
  68. package/domain/planning.js +5 -5
  69. package/domain/read/missionContext.js +17 -3
  70. package/domain/read/views.js +5 -1
  71. package/domain/roles.js +222 -0
  72. package/domain/roster.js +9 -3
  73. package/domain/scope.js +51 -0
  74. package/domain/shapes/build.js +254 -0
  75. package/domain/shapes/index.js +171 -0
  76. package/domain/shapes/survey.js +445 -0
  77. package/domain/state/archive.js +28 -4
  78. package/domain/state/migrations.js +111 -4
  79. package/domain/state/schema.js +69 -7
  80. package/domain/state/semantics.js +93 -13
  81. package/domain/state.js +5 -1
  82. package/domain/summary.js +5 -11
  83. package/domain/vocabulary.js +15 -33
  84. package/host/capability-registry.js +25 -9
  85. package/host/doctor.js +2 -1
  86. package/host/preset-survey-sandbox.js +19 -0
  87. package/host/preset-survey.js +18 -0
  88. package/host/presets.js +8 -3
  89. package/host/tool-registry.js +14 -12
  90. package/locale/en.json +16 -1
  91. package/locale/preset-survey/en.json +6 -0
  92. package/locale/preset-survey/zh.json +6 -0
  93. package/locale/preset-survey-sandbox/en.json +6 -0
  94. package/locale/preset-survey-sandbox/zh.json +6 -0
  95. package/locale/zh.json +16 -1
  96. package/package.json +13 -2
  97. package/ports/state.js +8 -1
  98. package/scripts/architecture-waivers.json +3 -3
  99. package/scripts/check-docs.mjs +1 -1
  100. package/scripts/check-pack.mjs +2 -0
  101. package/scripts/component-surface.json +68 -20
  102. package/scripts/generate-capability-locale.mjs +5 -0
  103. package/scripts/generate-shapes-reference.mjs +171 -0
  104. package/scripts/mount-smoke/surface.mjs +3 -0
  105. package/scripts/pack-manifest.txt +21 -0
  106. package/scripts/shape-audit.mjs +361 -0
  107. package/scripts/surface-baseline.json +68 -4
  108. package/survey-sandbox.js +48 -0
  109. package/survey.js +40 -0
  110. package/tools/complete.js +5 -5
  111. package/tools/diff.js +5 -3
  112. package/tools/plan.js +1 -1
  113. package/tools/preflight.js +3 -2
  114. package/tools/spawn.js +47 -49
  115. package/tools/test.js +2 -1
  116. package/types/index.d.ts +66 -5
@@ -12,8 +12,12 @@
12
12
  // @ts-check
13
13
  import { TowerToolError } from '../errors.js'
14
14
  import { SCHEMA_VERSION } from '../mission.js'
15
+ import { SHAPE_KINDS, shapeFor } from '../shapes/index.js'
16
+ import { normalizeBatchStatus } from '../batch.js'
15
17
 
16
- /** @typedef {{ from: number, to: number, migrate: (state: Record<string, any>) => Record<string, any> }} StateMigration */
18
+ /** @typedef {{ readCommit: string, readDirty: string[] }} LegacySurveyRead */
19
+ /** @typedef {{ legacySurveyRead?: LegacySurveyRead | null }} MigrationContext */
20
+ /** @typedef {{ from: number, to: number, migrate: (state: Record<string, any>, context?: MigrationContext) => Record<string, any> }} StateMigration */
17
21
 
18
22
  /**
19
23
  * @param {unknown} value
@@ -57,10 +61,109 @@ export function migrateV2ToV3(state) {
57
61
  return { ...state, caseInsensitive: null }
58
62
  }
59
63
 
64
+ /**
65
+ * v3 -> v4. Mission state becomes per-shape (#149). Every mission is stamped with
66
+ * its kind; a legacy branch-shaped survey keeps the commit it read (the
67
+ * worktree's base commit) and drops the worktree/branch/merge fields it no
68
+ * longer owns. Build missions keep their document unchanged.
69
+ *
70
+ * The step is pure and one-way. Re-running it on a v4 document is a fixed point:
71
+ * every mission already has a kind, so the same shape migration rewrites it to
72
+ * the same values.
73
+ *
74
+ * A legacy survey planned but never provisioned records no base commit at all
75
+ * (2.6.0 wrote neither `baseCommit` nor a worktree at plan time). The loader
76
+ * resolves the state base once and passes it as `context.legacySurveyRead`; the
77
+ * survey shape consumes it only when it has no recorded base of its own.
78
+ * @param {Record<string, any>} state
79
+ * @param {MigrationContext} [context]
80
+ * @returns {Record<string, any>}
81
+ */
82
+ export function migrateV3ToV4(state, context = {}) {
83
+ const next = { ...state }
84
+ next.missions = (Array.isArray(next.missions) ? next.missions : []).map((mission) => {
85
+ if (!isPlainObject(mission)) return mission
86
+ const shape = SHAPE_KINDS.map((kind) => shapeFor(kind)).find((candidate) => candidate.isLegacy(mission))
87
+ return shape ? shape.migrateLegacy(mission, context) : mission
88
+ })
89
+ return next
90
+ }
91
+
92
+ /**
93
+ * v4 -> v5. Two contracts that used to overlap are made exact (#149 W3):
94
+ *
95
+ * - `blocked` is derived from open blockers, not an independent stop state. A
96
+ * non-terminal mission that says `blocked` without a blocker gets one explicit
97
+ * legacy blocker (it stays blocked; `clear_blockers` resumes it); a
98
+ * non-terminal mission that carries blockers under any other status becomes
99
+ * `blocked` because a blocker wins over a pause or a fresh status. Terminal
100
+ * missions keep their blockers as history and are never reopened.
101
+ * - A batch's status vocabulary is normalized to `open|closed|abandoned`;
102
+ * pre-v5 documents used `created|active|pending` as synonyms for `open`.
103
+ *
104
+ * The step is pure and one-way. Re-running it on a v5 document is a fixed point.
105
+ * @param {Record<string, any>} state
106
+ * @returns {Record<string, any>}
107
+ */
108
+ export function migrateV4ToV5(state) {
109
+ const next = { ...state }
110
+ next.missions = (Array.isArray(next.missions) ? next.missions : []).map((mission) => {
111
+ if (!isPlainObject(mission)) return mission
112
+ const shape = shapeFor(mission.kind)
113
+ const blockers = Array.isArray(mission.blockers) ? [...mission.blockers] : []
114
+ if (shape.isTerminal(mission.status)) return { ...mission, blockers }
115
+ if (mission.status === 'blocked' && blockers.length === 0) {
116
+ return {
117
+ ...mission,
118
+ blockers: ['(legacy blocked state: no blocker was recorded; clear_blockers resumes the mission)'],
119
+ }
120
+ }
121
+ if (mission.status !== 'blocked' && blockers.length > 0) {
122
+ const migrated = { ...mission, status: 'blocked', blockers }
123
+ // A blocker supersedes a pause: the pause cause no longer describes why
124
+ // the mission is stopped.
125
+ delete migrated.pauseCause
126
+ return migrated
127
+ }
128
+ return mission
129
+ })
130
+ if (Array.isArray(next.batches)) {
131
+ next.batches = next.batches.map((batch) => {
132
+ if (!isPlainObject(batch)) return batch
133
+ const status = normalizeBatchStatus(batch.status)
134
+ return status === batch.status ? batch : { ...batch, status }
135
+ })
136
+ }
137
+ return next
138
+ }
139
+
140
+ /**
141
+ * True when a pre-v4 document contains at least one legacy mission whose shape
142
+ * cannot anchor its read from the document alone (a survey planned but never
143
+ * provisioned). The load path must resolve the repository base before migration
144
+ * runs; a document that recorded its own commit needs no resolver, and a
145
+ * current-version document is never re-migrated.
146
+ * @param {unknown} state
147
+ * @returns {boolean}
148
+ */export function legacySurveyNeedsRead(state) {
149
+ if (!isPlainObject(state)) return false
150
+ const document = /** @type {Record<string, any>} */ (state)
151
+ const version = document.schemaVersion
152
+ if (!Number.isInteger(version) || version < 1 || version >= SCHEMA_VERSION) return false
153
+ const missions = Array.isArray(document.missions) ? document.missions : []
154
+ return missions.some((mission) => {
155
+ if (!isPlainObject(mission)) return false
156
+ const shape = SHAPE_KINDS.map((kind) => shapeFor(kind)).find((candidate) => candidate.isLegacy(mission))
157
+ return typeof shape?.needsLegacyRead === 'function' && shape.needsLegacyRead(mission)
158
+ })
159
+ }
160
+
60
161
  /** Ordered registry: entry 0 moves 1 -> 2, entry 1 moves 2 -> 3, and so on. */
61
162
  export const STATE_MIGRATIONS = Object.freeze([
62
163
  Object.freeze({ from: 1, to: 2, migrate: migrateV1ToV2 }),
63
164
  Object.freeze({ from: 2, to: 3, migrate: migrateV2ToV3 }),
165
+ Object.freeze({ from: 3, to: 4, migrate: migrateV3ToV4 }),
166
+ Object.freeze({ from: 4, to: 5, migrate: migrateV4ToV5 }),
64
167
  ])
65
168
 
66
169
  /**
@@ -91,16 +194,20 @@ export function migrationChain(from, to = SCHEMA_VERSION, migrations = STATE_MIG
91
194
 
92
195
  /**
93
196
  * Apply the registry to a parsed document. Pure: the input object is never
94
- * touched and the returned document has the target `schemaVersion`.
197
+ * touched and the returned document has the target `schemaVersion`. The same
198
+ * `MigrationContext` object reaches every step, so a step may consume a value
199
+ * the loader resolved once (the legacy survey read).
95
200
  *
96
201
  * @param {Record<string, any>} state
97
- * @param {{ statePath?: string, to?: number, migrations?: readonly StateMigration[] }} [options]
202
+ * @param {{ statePath?: string, to?: number, migrations?: readonly StateMigration[] } & MigrationContext} [options]
98
203
  * @returns {{ state: Record<string, any>, from: number, to: number, steps: Array<{ from: number, to: number }> }}
99
204
  */
100
205
  export function migrateState(state, options = {}) {
101
206
  const where = options.statePath ? ` at ${options.statePath}` : ''
102
207
  const to = options.to ?? SCHEMA_VERSION
103
208
  const migrations = options.migrations ?? STATE_MIGRATIONS
209
+ /** @type {MigrationContext} */
210
+ const context = { legacySurveyRead: options.legacySurveyRead }
104
211
  const from = state?.schemaVersion
105
212
  const chain = migrationChain(from, to, migrations)
106
213
  if (!chain || chain.length === 0) {
@@ -120,7 +227,7 @@ export function migrateState(state, options = {}) {
120
227
  /** @type {Record<string, any>} */
121
228
  let next
122
229
  try {
123
- next = step.migrate(current)
230
+ next = step.migrate(current, context)
124
231
  } catch (error) {
125
232
  throw new TowerToolError(
126
233
  'state-migration-failed',
@@ -1,15 +1,21 @@
1
- // Declarative state-file schema (#56 P4).
1
+ // Declarative state-file schema (#56 P4, #149).
2
2
  //
3
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.
4
+ // `<git-common-dir>/dsh-tower/state.json`. Schema v4 splits a mission into a
5
+ // per-shape union: a build mission owns a branch/worktree/merge projection, a
6
+ // survey owns `readCommit`/`readDirty` and may *not* carry the branch-shaped
7
+ // fields. Object schemas allow additional properties on purpose: state files
8
+ // are forward/backward compatible and legacy rows carry extra fields, while the
9
+ // union's forbidden keys are declared `Type.Never()` so a survey carrying a
10
+ // worktree record fails the shape check. Semantic policy (uniqueness,
11
+ // status/verdict vocabularies, review exemption, worktree records and the
12
+ // revision counter) lives in `./semantics.js`, so this module remains a pure
13
+ // declaration.
9
14
  // @ts-check
10
15
  import { Type } from '@sinclair/typebox'
11
16
  import { Value } from '@sinclair/typebox/value'
12
17
  import { SCHEMA_VERSION } from '../mission.js'
18
+ import { isShapeKind, shapeFor } from '../shapes/index.js'
13
19
 
14
20
  const optional = { additionalProperties: true }
15
21
 
@@ -17,9 +23,10 @@ const NonNegativeInteger = Type.Integer({ minimum: 0 })
17
23
 
18
24
  const WorktreeSchema = Type.Object({}, optional)
19
25
 
20
- const MissionSchema = Type.Object(
26
+ const BuildMissionSchema = Type.Object(
21
27
  {
22
28
  id: Type.String(),
29
+ kind: Type.Literal('build'),
23
30
  status: Type.Optional(Type.String()),
24
31
  tasks: Type.Array(Type.Unknown()),
25
32
  deps: Type.Array(Type.Unknown()),
@@ -27,10 +34,41 @@ const MissionSchema = Type.Object(
27
34
  reviewPolicy: Type.Optional(Type.String()),
28
35
  testRuns: Type.Optional(Type.Array(Type.Unknown())),
29
36
  worktree: Type.Optional(Type.Union([WorktreeSchema, Type.Null()])),
37
+ // A build mission reads a base and writes a branch; it never carries the
38
+ // survey fingerprint or a detached survey sandbox.
39
+ readCommit: Type.Optional(Type.Never()),
40
+ readDirty: Type.Optional(Type.Never()),
41
+ sandbox: Type.Optional(Type.Never()),
30
42
  },
31
43
  optional,
32
44
  )
33
45
 
46
+ const SurveyMissionSchema = Type.Object(
47
+ {
48
+ id: Type.String(),
49
+ kind: Type.Literal('survey'),
50
+ status: Type.Optional(Type.String()),
51
+ tasks: Type.Array(Type.Unknown()),
52
+ deps: Type.Array(Type.Unknown()),
53
+ references: Type.Optional(Type.Array(Type.Unknown())),
54
+ reviewPolicy: Type.Optional(Type.String()),
55
+ readCommit: Type.String(),
56
+ readDirty: Type.Optional(Type.Array(Type.String())),
57
+ // #148 W2: an optional path to the disposable detached sandbox. Absent for
58
+ // a shared-checkout survey and for every build.
59
+ sandbox: Type.Optional(Type.String()),
60
+ // A survey reads one commit in the shared checkout: no branch shape.
61
+ worktree: Type.Optional(Type.Never()),
62
+ branch: Type.Optional(Type.Never()),
63
+ baseCommit: Type.Optional(Type.Never()),
64
+ mergedCommit: Type.Optional(Type.Never()),
65
+ mergedAt: Type.Optional(Type.Never()),
66
+ },
67
+ optional,
68
+ )
69
+
70
+ const MissionSchema = Type.Union([BuildMissionSchema, SurveyMissionSchema])
71
+
34
72
  const RosterEntrySchema = Type.Object(
35
73
  {
36
74
  name: Type.String(),
@@ -131,6 +169,9 @@ function firstShapeMessage(state, where) {
131
169
  for (const mission of state.missions) {
132
170
  if (!isPlainObject(mission) || typeof mission.id !== 'string') return `mission entry without id${where}`
133
171
  const id = mission.id
172
+ if (!isShapeKind(mission.kind)) {
173
+ return `mission ${id} has unknown kind ${String(mission.kind)}${where}`
174
+ }
134
175
  if (!Array.isArray(mission.tasks)) return `mission ${id} has no tasks array${where}`
135
176
  if (!Array.isArray(mission.deps)) return `mission ${id} has no deps array${where}`
136
177
  if (mission.references !== undefined && !Array.isArray(mission.references)) {
@@ -142,6 +183,27 @@ function firstShapeMessage(state, where) {
142
183
  if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
143
184
  return `mission ${id} has no testRuns array${where}`
144
185
  }
186
+ if (isShapeKind(mission.kind)) {
187
+ const shape = shapeFor(mission.kind)
188
+ if (shape.fields.readCommit) {
189
+ if (typeof mission.readCommit !== 'string' || mission.readCommit.length === 0) {
190
+ return `mission ${id} is a survey and needs a readCommit${where}`
191
+ }
192
+ if (mission.readDirty !== undefined && !Array.isArray(mission.readDirty)) {
193
+ return `mission ${id} has an invalid readDirty fingerprint${where}`
194
+ }
195
+ if (mission.sandbox !== undefined && (typeof mission.sandbox !== 'string' || mission.sandbox.length === 0)) {
196
+ return `mission ${id} has an invalid sandbox path${where}`
197
+ }
198
+ if (mission.worktree !== undefined && mission.worktree !== null) {
199
+ return `mission ${id} is a survey and may not have a worktree record${where}`
200
+ }
201
+ } else if (mission.readCommit !== undefined || mission.readDirty !== undefined) {
202
+ return `mission ${id} is a build and may not have readCommit/readDirty${where}`
203
+ } else if (mission.sandbox !== undefined) {
204
+ return `mission ${id} is a build and may not have a sandbox${where}`
205
+ }
206
+ }
145
207
  if (mission.worktree !== undefined && mission.worktree !== null && !isPlainObject(mission.worktree)) {
146
208
  return `mission ${id} has an invalid worktree record${where}`
147
209
  }
@@ -1,12 +1,10 @@
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.
1
+ // State semantics that a structural schema cannot express: uniqueness,
2
+ // status vocabularies, review exemption and per-kind mission fields (#149).
7
3
  // @ts-check
4
+ import { FINDING_STATUSES, MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
5
+ import { BATCH_STATUSES } from '../batch.js'
8
6
  import { TowerToolError } from '../errors.js'
9
- import { FINDING_STATUSES, MERGE_VERDICTS, MISSION_STATUSES, REVIEW_STATUS_RE } from '../mission.js'
7
+ import { isShapeKind, shapeFor } from '../shapes/index.js'
10
8
 
11
9
  /** @typedef {import('../../types/index.js').TowerState} TowerState */
12
10
 
@@ -19,11 +17,52 @@ function fail(message) {
19
17
  }
20
18
 
21
19
  /**
22
- * @param {unknown} value
23
- * @returns {boolean}
20
+ * A build mission may carry a worktree/branch/merge projection; a survey may
21
+ * not, and carries the read fingerprint instead. The shape declares which
22
+ * dimensions its document owns, so this module never compares kinds.
23
+ * @param {Record<string, any>} mission
24
+ * @param {string} where
25
+ * @returns {void}
24
26
  */
25
- function isPlainObject(value) {
26
- return value !== null && typeof value === 'object' && !Array.isArray(value)
27
+ function assertMissionShapeFields(mission, where) {
28
+ if (!isShapeKind(mission.kind)) {
29
+ fail(`mission ${mission.id} has unknown kind ${String(mission.kind)}${where}`)
30
+ }
31
+ const shape = shapeFor(mission.kind)
32
+ if (!shape.fields.worktree) {
33
+ if (mission.worktree !== undefined && mission.worktree !== null) {
34
+ fail(`mission ${mission.id} is a ${shape.kind} and may not have a worktree record${where}`)
35
+ }
36
+ for (const key of ['branch', 'baseCommit', 'mergedCommit', 'mergedAt']) {
37
+ if (mission[key] !== undefined && mission[key] !== null) {
38
+ fail(`mission ${mission.id} is a ${shape.kind} and may not have ${key}${where}`)
39
+ }
40
+ }
41
+ }
42
+ if (shape.fields.worktree) {
43
+ if (mission.sandbox !== undefined) {
44
+ fail(`mission ${mission.id} is a ${shape.kind} and may not have a sandbox${where}`)
45
+ }
46
+ } else if (shape.fields.sandbox) {
47
+ // A detached survey sandbox is a recorded path, never a worktree record: it
48
+ // has no branch, base commit or merge projection.
49
+ if (mission.sandbox !== undefined && (typeof mission.sandbox !== 'string' || mission.sandbox.length === 0)) {
50
+ fail(`mission ${mission.id} has an invalid sandbox path${where}`)
51
+ }
52
+ } else if (mission.sandbox !== undefined) {
53
+ fail(`mission ${mission.id} is a ${shape.kind} and may not have a sandbox${where}`)
54
+ }
55
+ if (shape.fields.readCommit) {
56
+ if (typeof mission.readCommit !== 'string' || mission.readCommit.length === 0) {
57
+ fail(`mission ${mission.id} is a ${shape.kind} and needs a readCommit${where}`)
58
+ }
59
+ if (mission.readDirty === undefined) return
60
+ if (!Array.isArray(mission.readDirty) || mission.readDirty.some((entry) => typeof entry !== 'string')) {
61
+ fail(`mission ${mission.id} has an invalid readDirty fingerprint${where}`)
62
+ }
63
+ } else if (mission.readCommit !== undefined || mission.readDirty !== undefined) {
64
+ fail(`mission ${mission.id} is a ${shape.kind} and may not have readCommit/readDirty${where}`)
65
+ }
27
66
  }
28
67
 
29
68
  /**
@@ -42,6 +81,14 @@ function assertWorktreeRecord(mission, where) {
42
81
  if (invalid) fail(`mission ${mission.id} has an invalid worktree record${where}`)
43
82
  }
44
83
 
84
+ /**
85
+ * @param {unknown} value
86
+ * @returns {boolean}
87
+ */
88
+ function isPlainObject(value) {
89
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
90
+ }
91
+
45
92
  /**
46
93
  * @param {unknown} state
47
94
  * @param {string} where
@@ -56,8 +103,9 @@ export function assertStateSemantics(state, where) {
56
103
  for (const mission of record.missions) {
57
104
  if (missionIds.has(mission.id)) fail(`duplicate mission id ${mission.id}${where}`)
58
105
  missionIds.add(mission.id)
59
- if (!MISSION_STATUSES.includes(mission.status)) {
60
- fail(`mission ${mission.id} has unknown status ${mission.status}${where}`)
106
+ const shape = shapeFor(mission.kind)
107
+ if (!shape.statuses.includes(mission.status)) {
108
+ fail(`mission ${mission.id} has unknown status ${mission.status} for a ${shape.kind} mission${where}`)
61
109
  }
62
110
  if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
63
111
  fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
@@ -65,7 +113,24 @@ export function assertStateSemantics(state, where) {
65
113
  if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
66
114
  fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
67
115
  }
116
+ assertMissionShapeFields(mission, where)
68
117
  assertWorktreeRecord(mission, where)
118
+ // W3: `blocked` is derived from open blockers, never an independent stop
119
+ // state. A non-terminal mission is blocked exactly when it records a
120
+ // blocker, so the two can never disagree (a paused mission has no blockers;
121
+ // a blocker supersedes a pause). Terminal missions keep blockers as history.
122
+ const blockers = mission.blockers ?? []
123
+ if (!Array.isArray(blockers) || blockers.some((entry) => typeof entry !== 'string')) {
124
+ fail(`mission ${mission.id} has invalid blockers${where}`)
125
+ }
126
+ if (!shape.isTerminal(mission.status)) {
127
+ if (mission.status === 'blocked' && blockers.length === 0) {
128
+ fail(`mission ${mission.id} is blocked but records no blocker; blocked is derived from open blockers${where}`)
129
+ }
130
+ if (mission.status !== 'blocked' && blockers.length > 0) {
131
+ fail(`mission ${mission.id} has open blockers but is ${mission.status}; blocked is derived from open blockers${where}`)
132
+ }
133
+ }
69
134
  }
70
135
  if (record.roster !== undefined) {
71
136
  const names = new Set()
@@ -113,5 +178,20 @@ export function assertStateSemantics(state, where) {
113
178
  }
114
179
  }
115
180
  }
181
+ // Batches have their own generic lifecycle (#149 W3), not a mission shape. The
182
+ // ledger may only carry canonical statuses; legacy synonyms are normalized by
183
+ // the v5 state migration, never accepted at rest.
184
+ if (record.batches !== undefined) {
185
+ const ids = new Set()
186
+ for (const batch of record.batches) {
187
+ if (!isPlainObject(batch)) fail(`invalid batch record${where}`)
188
+ if (ids.has(batch.id)) fail(`duplicate batch id ${batch.id}${where}`)
189
+ ids.add(batch.id)
190
+ const status = typeof batch.status === 'string' ? batch.status : ''
191
+ if (!BATCH_STATUSES.includes(status)) {
192
+ fail(`batch ${batch.id} has unknown status ${batch.status}${where}`)
193
+ }
194
+ }
195
+ }
116
196
  return /** @type {TowerState} */ (record)
117
197
  }
package/domain/state.js CHANGED
@@ -150,7 +150,10 @@ export function validateState(state, statePath) {
150
150
  *
151
151
  * @param {unknown} state
152
152
  * @param {string} [statePath]
153
- * @param {{ migrations?: readonly StateMigration[] }} [options]
153
+ * @param {{
154
+ * migrations?: readonly StateMigration[],
155
+ * legacySurveyRead?: import('./state/migrations.js').LegacySurveyRead | null,
156
+ * }} [options]
154
157
  * @returns {{ state: TowerState, from: number, to: number, migrated: MigrationStep[], changed: boolean }}
155
158
  */
156
159
  export function prepareState(state, statePath, options = {}) {
@@ -169,6 +172,7 @@ export function prepareState(state, statePath, options = {}) {
169
172
  statePath,
170
173
  to: info.supportedVersion,
171
174
  migrations: options.migrations,
175
+ legacySurveyRead: options.legacySurveyRead ?? null,
172
176
  })
173
177
  // Validation runs on the *target* document: a shape that was invalid under
174
178
  // the old schema (a missing `tasks` array, say) may be valid after a backfill,
package/domain/summary.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // @ts-check
3
3
  import { FINDING_STATUSES } from './mission.js'
4
4
  import { completionReportBytes } from './missions/completion.js'
5
+ import { shapeFor } from './shapes/index.js'
5
6
 
6
7
  /** @typedef {import('../types/index.js').Finding} Finding */
7
8
  /** @typedef {import('../types/index.js').FindingSummary} FindingSummary */
@@ -31,7 +32,8 @@ function taskCounts(tasks) {
31
32
  * @returns {MissionSummary}
32
33
  */
33
34
  export function missionSummary(mission) {
34
- return {
35
+ const shape = shapeFor(mission.kind)
36
+ return /** @type {MissionSummary} */ ({
35
37
  id: mission.id,
36
38
  title: mission.title,
37
39
  kind: mission.kind,
@@ -39,24 +41,16 @@ export function missionSummary(mission) {
39
41
  scope: mission.scope,
40
42
  deps: mission.deps,
41
43
  owner: mission.owner ?? null,
42
- worktree: mission.worktree
43
- ? {
44
- path: mission.worktree.path,
45
- branch: mission.worktree.branch,
46
- baseCommit: mission.worktree.baseCommit,
47
- }
48
- : null,
44
+ ...shape.summary(mission),
49
45
  blockers: mission.blockers ?? [],
50
46
  tasks: taskCounts(mission.tasks ?? []),
51
47
  scopeGeneration: mission.scopeGeneration ?? 1,
52
48
  ownerGeneration: mission.ownerGeneration ?? 0,
53
49
  pauseCause: mission.pauseCause ?? null,
54
50
  reviewPolicy: mission.reviewPolicy ?? 'required-clean',
55
- mergedCommit: mission.mergedCommit ?? null,
56
- mergedAt: mission.mergedAt ?? null,
57
51
  reportBytes: completionReportBytes(mission.completionReport),
58
52
  updatedAt: mission.updatedAt,
59
- }
53
+ })
60
54
  }
61
55
 
62
56
  /**
@@ -13,6 +13,7 @@ import {
13
13
  PAUSE_CAUSES,
14
14
  } from './mission.js'
15
15
  import { ROSTER_INACTIVE_STATES } from './roster.js'
16
+ import { SHAPE_KINDS } from './shapes/index.js'
16
17
 
17
18
  export {
18
19
  FINDING_SEVERITIES,
@@ -24,44 +25,25 @@ export {
24
25
  ROSTER_INACTIVE_STATES,
25
26
  }
26
27
 
27
- // Mission inputs accepted by tower_plan.
28
+ // Mission inputs accepted by tower_plan: the registry's kinds, in order.
28
29
  /** @type {readonly string[]} */
29
- export const MISSION_KINDS = ['build', 'survey']
30
+ export const MISSION_KINDS = SHAPE_KINDS
30
31
  /** @type {readonly string[]} */
31
32
  export const REVIEW_POLICIES = ['required-clean', 'none']
32
33
 
33
34
  // Roster actors (the caller resolved by actorFor) and createable agent roles.
34
- export const ROLE = Object.freeze({
35
- tower: 'tower',
36
- worker: 'worker',
37
- reviewer: 'reviewer',
38
- unknown: 'unknown',
39
- })
40
- /** @type {readonly string[]} */
41
- export const ROLES = Object.freeze([ROLE.worker, ROLE.reviewer])
42
- /** @type {readonly string[]} */
43
- export const ACTOR_ROLES = Object.freeze([ROLE.tower, ROLE.worker, ROLE.reviewer, ROLE.unknown])
44
- export const DEFAULT_ROLE = ROLE.worker
45
-
46
- /** @param {string | null | undefined} role */
47
- export function isTowerRole(role) {
48
- return role === ROLE.tower
49
- }
50
-
51
- /** @param {string | null | undefined} role */
52
- export function isWorkerRole(role) {
53
- return role === ROLE.worker
54
- }
55
-
56
- /** @param {string | null | undefined} role */
57
- export function isReviewerRole(role) {
58
- return role === ROLE.reviewer
59
- }
60
-
61
- /** @param {string | null | undefined} role */
62
- export function isUnknownRole(role) {
63
- return role === ROLE.unknown
64
- }
35
+ // One definition lives in domain/roles.js so roster/vocabulary have no cycle.
36
+ import { ROLE } from './roles.js'
37
+ export {
38
+ ACTOR_ROLES,
39
+ DEFAULT_ROLE,
40
+ ROLE,
41
+ ROLES,
42
+ isReviewerRole,
43
+ isTowerRole,
44
+ isUnknownRole,
45
+ isWorkerRole,
46
+ } from './roles.js'
65
47
 
66
48
  // Inbox recipients: a roster name, the tower, or a broadcast.
67
49
  export const RECIPIENT = Object.freeze({ tower: ROLE.tower, all: 'all' })
@@ -25,6 +25,7 @@ export const TOWER_COMPONENT_IDS = Object.freeze([
25
25
  'tower-archive',
26
26
  'tower-hmr',
27
27
  'preset-tower-reviewer',
28
+ 'tower-survey',
28
29
  ])
29
30
 
30
31
  /** Tool ids each code component owns; the preset row owns no tool. */
@@ -38,6 +39,7 @@ export const COMPONENT_TOOLS = Object.freeze({
38
39
  'tower-archive': Object.freeze([]),
39
40
  'tower-hmr': Object.freeze(['tower_reload']),
40
41
  'preset-tower-reviewer': Object.freeze([]),
42
+ 'tower-survey': Object.freeze([]),
41
43
  })
42
44
 
43
45
  /** id -> number of live registrations (redelivery/HMR can overlap briefly). */
@@ -86,23 +88,37 @@ export function disabledReason(id) {
86
88
  return `disabled by component ${id}`
87
89
  }
88
90
 
89
- /** Reviewer-preset availability from the optional agent-presets service. */
90
- async function reviewerPresetAvailable(ctx) {
91
+ /**
92
+ * Availability of one declarative preset row from the optional agent-presets
93
+ * service. Preset rows (`preset-tower-reviewer`, `tower-survey`) mount an agent
94
+ * preset instead of a code entry point, so the process-local component registry
95
+ * cannot see them.
96
+ * @param {any} ctx
97
+ * @param {string} presetId the agent-preset id the row registers
98
+ * @returns {Promise<boolean | null>} null when the service cannot answer
99
+ */
100
+ export async function presetAvailable(ctx, presetId) {
91
101
  const service = ctx?.get?.('agentPresets')
92
102
  if (!service || typeof service.list !== 'function') return null
93
103
  try {
94
104
  const presets = await service.list()
95
- return Array.isArray(presets) && presets.some((entry) => String(entry?.id) === 'tower-reviewer')
105
+ return Array.isArray(presets) && presets.some((entry) => String(entry?.id) === presetId)
96
106
  } catch {
97
107
  return null
98
108
  }
99
109
  }
100
110
 
111
+ /** Component rows whose switch is a declarative agent-preset registration. */
112
+ export const PRESET_ROW_COMPONENTS = Object.freeze({
113
+ 'preset-tower-reviewer': 'tower-reviewer',
114
+ 'tower-survey': 'tower-survey',
115
+ })
116
+
101
117
  /**
102
- * Report every optional component as enabled or disabled. The reviewer preset
103
- * is not a code entry point: its row mounts an agent preset, so availability is
104
- * read from `agentPresets` and reported as unknown (omitted) when that service
105
- * is absent, exactly as the doctor's preset check does.
118
+ * Report every optional component as enabled or disabled. A preset row is not a
119
+ * code entry point: its availability is read from `agentPresets` and reported as
120
+ * unknown (omitted) when that service is absent, exactly as the doctor's preset
121
+ * check does.
106
122
  *
107
123
  * @param {any} ctx
108
124
  * @returns {Promise<{ enabled: string[], disabled: Array<{ id: string, reason: string }> }>}
@@ -111,8 +127,8 @@ export async function componentReport(ctx) {
111
127
  const enabled = []
112
128
  const disabled = []
113
129
  for (const id of TOWER_COMPONENT_IDS) {
114
- if (id === 'preset-tower-reviewer') {
115
- const available = await reviewerPresetAvailable(ctx)
130
+ if (PRESET_ROW_COMPONENTS[id]) {
131
+ const available = await presetAvailable(ctx, PRESET_ROW_COMPONENTS[id])
116
132
  if (available === false) disabled.push({ id, reason: disabledReason(id) })
117
133
  else if (available === true) enabled.push(id)
118
134
  continue
package/host/doctor.js CHANGED
@@ -12,6 +12,7 @@ import { inspectStateVersion } from '../domain/state.js'
12
12
  import { SCHEMA_VERSION } from '../domain/mission.js'
13
13
  import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
14
14
  import { comparisonMode } from '../domain/scope.js'
15
+ import { hasTestReceipts } from '../domain/evidence.js'
15
16
  import { configSurface } from '../domain/config/schema.js'
16
17
  import { reviewPolicyView, reviewRequired } from '../domain/integration/review.js'
17
18
  import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
@@ -454,7 +455,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
454
455
  : check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
455
456
  )
456
457
 
457
- const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
458
+ const testConfigured = state.missions?.some((mission) => hasTestReceipts(mission))
458
459
  // #136: the `tower-tests` component row is the only switch (there is no
459
460
  // tests.enabled), so this check reads the registry and nothing else.
460
461
  const testsDisabled = components.disabled.some((entry) => entry.id === 'tower-tests')
@@ -0,0 +1,19 @@
1
+ // Tower detached-survey preset declaration (#148 W2): the sandbox preset's own
2
+ // Plugins-page label. This is the writable twin of `host/preset-survey.js`: the
3
+ // row config stays in `cordis.patch.yml` and this module forwards it to the
4
+ // injected `agentPresets` service, which owns mount/unmount. A Tower-owned
5
+ // specifier gives the row a distinct locale
6
+ // (`dsh-tower/preset-survey-sandbox/locale/<lang>.json`).
7
+ export const name = 'dsh-tower/preset-survey-sandbox'
8
+
9
+ export const inject = ['agentPresets']
10
+
11
+ /**
12
+ * Register the detached-survey preset with the profile's preset registry.
13
+ * @param {{ agentPresets: { register: (config: any) => Promise<() => Promise<void> | void> } }} ctx
14
+ * @param {any} config the `cordis.patch.yml` row config (id/name/description/order/plugins)
15
+ * @returns {Promise<() => Promise<void> | void>} the registry's unregister disposer
16
+ */
17
+ export async function apply(ctx, config) {
18
+ return ctx.agentPresets.register(config)
19
+ }