dsh-tower 2.5.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 (142) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +18 -16
  7. package/README.md +18 -11
  8. package/adapters/leases.js +20 -6
  9. package/adapters/legacy.js +149 -37
  10. package/adapters/stateFile.js +11 -1
  11. package/app/archive.js +30 -5
  12. package/app/briefing.js +95 -12
  13. package/app/checkCompletion.js +107 -18
  14. package/app/enrichedMissions.js +1 -1
  15. package/app/freshness.js +77 -0
  16. package/app/gate.js +26 -12
  17. package/app/merge.js +7 -4
  18. package/app/mission.js +24 -1
  19. package/app/missionContext.js +2 -1
  20. package/app/plan.js +64 -1
  21. package/app/provision.js +133 -22
  22. package/app/read.js +38 -5
  23. package/app/rebase.js +10 -8
  24. package/app/review.js +104 -61
  25. package/app/reviewCheckout.js +133 -0
  26. package/app/sandbox.js +121 -0
  27. package/app/sharedCheckout.js +90 -0
  28. package/app/spawn-prechecks.js +31 -20
  29. package/app/spawn-support.js +59 -8
  30. package/app/spawn.js +3 -0
  31. package/app/teardown.js +119 -2
  32. package/app/tower.js +34 -6
  33. package/client.js +321 -0
  34. package/cordis.patch.yml +55 -3
  35. package/docs/adr/0005-component-surface.md +58 -13
  36. package/docs/adr/0006-mission-shapes.md +306 -0
  37. package/docs/architecture/overview.md +18 -10
  38. package/docs/examples/first-mission.md +3 -2
  39. package/docs/faq.md +9 -7
  40. package/docs/guide/install-upgrade-migration.md +58 -7
  41. package/docs/guide/quickstart.md +20 -10
  42. package/docs/guide/scenarios-and-philosophy.md +35 -28
  43. package/docs/operations/runbook.md +1 -1
  44. package/docs/operations/security.md +11 -10
  45. package/docs/reference/blockers.md +20 -11
  46. package/docs/reference/component-surface.md +20 -8
  47. package/docs/reference/config.generated.md +8 -5
  48. package/docs/reference/config.md +30 -5
  49. package/docs/reference/doctor.md +7 -7
  50. package/docs/reference/errors.generated.md +1 -0
  51. package/docs/reference/errors.md +2 -1
  52. package/docs/reference/operations.md +1 -1
  53. package/docs/reference/shapes.md +123 -0
  54. package/docs/reference/surface-budget.md +53 -4
  55. package/docs/reference/testing.md +5 -3
  56. package/docs/reference/tools.generated.md +7 -7
  57. package/docs/reference/tools.md +5 -5
  58. package/docs/zh/faq.md +7 -5
  59. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  60. package/docs/zh/guide/quickstart.md +17 -10
  61. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  62. package/docs/zh/operations/security.md +9 -8
  63. package/docs/zh/reference/blockers.md +13 -9
  64. package/docs/zh/reference/config.md +22 -5
  65. package/docs/zh/reference/errors.md +2 -1
  66. package/docs/zh/reference/testing.md +5 -4
  67. package/docs/zh/reference/tools.md +3 -3
  68. package/domain/batch.js +75 -105
  69. package/domain/collab/batch.js +30 -10
  70. package/domain/collab/spawn.js +53 -16
  71. package/domain/components.js +12 -4
  72. package/domain/config/schema.js +87 -22
  73. package/domain/contracts/outputs.js +2 -1
  74. package/domain/contracts/tool-inputs/missions.js +10 -2
  75. package/domain/errors.js +6 -0
  76. package/domain/evidence.js +283 -0
  77. package/domain/gate.js +17 -34
  78. package/domain/integration/gate.js +17 -43
  79. package/domain/integration/init.js +3 -2
  80. package/domain/integration/provision.js +51 -0
  81. package/domain/integration/rebase.js +16 -1
  82. package/domain/integration/review.js +203 -9
  83. package/domain/mission.js +16 -33
  84. package/domain/missions/completion.js +46 -8
  85. package/domain/missions/findings.js +2 -1
  86. package/domain/missions/lifecycle.js +131 -35
  87. package/domain/missions/plan.js +30 -6
  88. package/domain/missions/review.js +2 -18
  89. package/domain/planning.js +5 -5
  90. package/domain/read/missionContext.js +17 -3
  91. package/domain/read/views.js +5 -1
  92. package/domain/roles.js +222 -0
  93. package/domain/roster.js +9 -3
  94. package/domain/scope.js +51 -0
  95. package/domain/shapes/build.js +254 -0
  96. package/domain/shapes/index.js +171 -0
  97. package/domain/shapes/survey.js +445 -0
  98. package/domain/state/archive.js +28 -4
  99. package/domain/state/migrations.js +111 -4
  100. package/domain/state/schema.js +69 -7
  101. package/domain/state/semantics.js +93 -13
  102. package/domain/state.js +5 -1
  103. package/domain/summary.js +5 -11
  104. package/domain/vocabulary.js +15 -33
  105. package/host/capability-registry.js +25 -9
  106. package/host/config.js +40 -3
  107. package/host/doctor.js +20 -8
  108. package/host/preset-survey-sandbox.js +19 -0
  109. package/host/preset-survey.js +18 -0
  110. package/host/presets.js +17 -11
  111. package/host/tool-registry.js +23 -13
  112. package/locale/en.json +16 -1
  113. package/locale/preset-survey/en.json +6 -0
  114. package/locale/preset-survey/zh.json +6 -0
  115. package/locale/preset-survey-sandbox/en.json +6 -0
  116. package/locale/preset-survey-sandbox/zh.json +6 -0
  117. package/locale/zh.json +16 -1
  118. package/package.json +19 -2
  119. package/ports/state.js +8 -1
  120. package/scripts/architecture-waivers.json +3 -3
  121. package/scripts/check-components.mjs +120 -6
  122. package/scripts/check-docs.mjs +1 -1
  123. package/scripts/check-pack.mjs +5 -0
  124. package/scripts/component-surface.json +83 -29
  125. package/scripts/generate-capability-locale.mjs +5 -0
  126. package/scripts/generate-shapes-reference.mjs +171 -0
  127. package/scripts/mount-smoke/surface.mjs +3 -0
  128. package/scripts/pack-manifest.txt +22 -0
  129. package/scripts/shape-audit.mjs +361 -0
  130. package/scripts/surface-baseline.json +75 -4
  131. package/scripts/types-coverage.mjs +7 -0
  132. package/survey-sandbox.js +48 -0
  133. package/survey.js +40 -0
  134. package/tools/complete.js +5 -5
  135. package/tools/diff.js +5 -3
  136. package/tools/plan.js +1 -1
  137. package/tools/preflight.js +3 -2
  138. package/tools/review.js +1 -1
  139. package/tools/spawn.js +48 -50
  140. package/tools/status.js +8 -1
  141. package/tools/test.js +2 -1
  142. package/types/index.d.ts +76 -5
@@ -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/config.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  resolveTowerConfig,
25
25
  } from '../domain/config/schema.js'
26
26
  import { TowerToolError } from '../domain/errors.js'
27
+ import { materializeVolatile, resolveReviewPolicy, reviewUnavailable } from '../domain/integration/review.js'
27
28
 
28
29
  export { Config, DEFAULT_DENY_TOOLS, resolveTowerConfig }
29
30
 
@@ -31,11 +32,16 @@ const REPO_CONFIG_MAX_BYTES = 1024 * 1024
31
32
 
32
33
  // The last raw profile config `apply()` received; repository config is merged
33
34
  // over it so a repo can tune spawn/merge/review without editing the profile.
35
+ //
36
+ // #M40: this must stay the *raw* object. A `.volatile()` field arrives as a
37
+ // live reference that Cordis updates in place on a settings write, and cloning
38
+ // (JSON) would freeze a copy and hide the new value from runtimeConfig(). Only
39
+ // readers that need plain data (profileConfig) materialize it.
34
40
  let profileRaw = {}
35
41
 
36
42
  export function configureRuntime(config) {
37
- profileRaw = clone(config ?? {}) ?? {}
38
- return resolveTowerConfig(config)
43
+ profileRaw = config ?? {}
44
+ return resolveTowerConfig(profileRaw)
39
45
  }
40
46
 
41
47
  export function runtimeConfig() {
@@ -44,7 +50,7 @@ export function runtimeConfig() {
44
50
 
45
51
  /** Raw profile config before defaults are materialized (for doctor warnings). */
46
52
  export function profileConfig() {
47
- return clone(profileRaw) ?? {}
53
+ return clone(materializeVolatile(profileRaw)) ?? {}
48
54
  }
49
55
 
50
56
  export function repoConfigPath(commonDir) {
@@ -92,6 +98,10 @@ export async function readRepoConfig(commonDir) {
92
98
  // Validate value types without letting the schema's defaults win over the
93
99
  // profile layer: merge into an empty raw object and discard the result.
94
100
  Config(parsed)
101
+ // #M39: the schema accepts both review keys; only the resolver knows that an
102
+ // explicit `requireReview:true` next to a conflicting `review.mode` is a
103
+ // contradiction. Reject it here, before the layer can be merged.
104
+ resolveReviewPolicy(parsed.review)
95
105
  } catch (error) {
96
106
  throw new TowerToolError('config-invalid', `repo config at ${file} is invalid: ${String(error?.message ?? error)}`)
97
107
  }
@@ -130,6 +140,33 @@ export async function resolveRepoConfig(repo) {
130
140
  if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
131
141
  merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
132
142
  }
143
+ // #M39: the review policy is resolved per layer instead of deep-merging two
144
+ // resolved shapes. A resolved base always carries both `review.mode` and its
145
+ // mirrored alias, so a repository layer that sets only one of the two would
146
+ // otherwise pair a stale inherited `mode` with the new alias. The repository
147
+ // layer wins as a whole when it names either key; a layer that names neither
148
+ // inherits the profile policy untouched.
149
+ const repoReview = repoLayer.review
150
+ if (repoReview && typeof repoReview === 'object' && !Array.isArray(repoReview)) {
151
+ const explicit = repoReview.mode !== undefined || repoReview.requireReview !== undefined
152
+ if (explicit) {
153
+ const repoPolicy = resolveReviewPolicy(repoReview)
154
+ if (reviewUnavailable(base) && repoPolicy.mode !== 'off') {
155
+ // The tool surface is fixed when the plugin applies: a profile with
156
+ // review off never registered tower_review, so a repository may relax
157
+ // review further but cannot re-enable a tool that does not exist.
158
+ throw new TowerToolError(
159
+ 'config-invalid',
160
+ `review.mode is off in the profile, so tower_review is not registered; repository config cannot set review.mode:${repoPolicy.mode}. Set review.mode in the profile instead.`,
161
+ )
162
+ }
163
+ merged.review = {
164
+ ...(merged.review ?? {}),
165
+ mode: repoPolicy.mode,
166
+ requireReview: repoPolicy.mode === 'required',
167
+ }
168
+ }
169
+ }
133
170
  merged.section = merged.mode?.promptSection ?? merged.section ?? ''
134
171
  return {
135
172
  ...merged,
package/host/doctor.js CHANGED
@@ -12,7 +12,9 @@ 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'
17
+ import { reviewPolicyView, reviewRequired } from '../domain/integration/review.js'
16
18
  import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
17
19
  import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
18
20
  import { profileConfig } from './config.js'
@@ -242,8 +244,11 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
242
244
  const config = tower.config ?? {}
243
245
  const profile = profileConfig()
244
246
  const staleDefaults = []
245
- if (profile.review && profile.review.requireReview === undefined) {
246
- staleDefaults.push('review.requireReview now defaults to false (2.0.3)')
247
+ // #M39: `review.mode` replaces the deprecated boolean. A profile that names
248
+ // neither key relies on the default (optional), so say so rather than letting
249
+ // a reader assume review is enforced.
250
+ if (profile.review && profile.review.mode === undefined && profile.review.requireReview === undefined) {
251
+ staleDefaults.push('review.mode defaults to optional; review.requireReview is a deprecated alias')
247
252
  }
248
253
  if (profile.mode && profile.mode.allowAgentControl === undefined) {
249
254
  staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
@@ -261,6 +266,11 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
261
266
  'optional: add <git-common-dir>/dsh-tower/config.json',
262
267
  ),
263
268
  )
269
+ // #M39: the resolved review mode is its own diagnostic fact. `off` is a
270
+ // supported, intentional posture (not a degradation), so it is a pass whose
271
+ // detail says exactly what is unavailable.
272
+ const reviewPolicy = reviewPolicyView(config)
273
+ checks.push(check('review', 'pass', reviewPolicy.detail))
264
274
  // #84: report the concept-budget surface instead of implying it. The counts
265
275
  // are the live schema/tool surface; the date is the committed baseline, so a
266
276
  // reader sees both the number and the decision it was frozen at.
@@ -288,7 +298,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
288
298
  ),
289
299
  )
290
300
  const presets = ctx.get?.('agentPresets')
291
- const reviewerRequired = config.review?.requireReview === true
301
+ const reviewerRequired = reviewRequired(config)
292
302
  if (presets && typeof presets.list === 'function') {
293
303
  try {
294
304
  const available = (await presets.list()).map((entry) => String(entry.id))
@@ -296,9 +306,10 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
296
306
  const reviewer = config.spawn?.reviewerPreset ?? 'tower-reviewer'
297
307
  const required = reviewerRequired ? [worker, reviewer] : [worker]
298
308
  const missing = required.filter((id) => !available.includes(id))
299
- // #121: `preset-tower-reviewer` is a conditional component. Its off state
300
- // is supported while review.requireReview is false; with the gate on it is
301
- // a fail, because no independent review can be dispatched.
309
+ // #121/#M39: `preset-tower-reviewer` is a conditional component. Its off
310
+ // state is supported while review is off or optional; with
311
+ // `review.mode:required` it is a fail, because no independent review can
312
+ // be dispatched.
302
313
  if (missing.length === 0) {
303
314
  const reviewerNote = available.includes(reviewer)
304
315
  ? `reviewer=${reviewer}`
@@ -309,7 +320,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
309
320
  check(
310
321
  'presets',
311
322
  'fail',
312
- `missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.requireReview is true, so preset-tower-reviewer may not be off)' : ''}`,
323
+ `missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.mode is required, so preset-tower-reviewer may not be off)' : ''}`,
313
324
  'enable the Tower preset component and retry',
314
325
  ),
315
326
  )
@@ -444,7 +455,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
444
455
  : check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
445
456
  )
446
457
 
447
- const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
458
+ const testConfigured = state.missions?.some((mission) => hasTestReceipts(mission))
448
459
  // #136: the `tower-tests` component row is the only switch (there is no
449
460
  // tests.enabled), so this check reads the registry and nothing else.
450
461
  const testsDisabled = components.disabled.some((entry) => entry.id === 'tower-tests')
@@ -596,6 +607,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
596
607
  // reports (from #89) and archive (from #73) are both kept
597
608
  reports: reportsReport,
598
609
  surface: surfaceReport,
610
+ review: reviewPolicy,
599
611
  components,
600
612
  archive: {
601
613
  missions: state ? state.missions.length : null,
@@ -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
+ }