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
@@ -7,6 +7,8 @@
7
7
  // @ts-check
8
8
  import { TowerToolError } from '../errors.js'
9
9
  import { ROSTER_INACTIVE, countLiveWorkers, workerEntries } from '../roster.js'
10
+ import { isReviewerRole, isWorkerRole } from '../roles.js'
11
+ import { shapeFor } from '../shapes/index.js'
10
12
  import { clearStale } from './roster.js'
11
13
 
12
14
  /** @typedef {import('../../types/index.js').Mission} Mission */
@@ -14,19 +16,14 @@ import { clearStale } from './roster.js'
14
16
 
15
17
  /**
16
18
  * @param {string} message
19
+ * @param {string} [code]
17
20
  * @returns {never}
18
21
  */
19
- function fail(message) {
20
- throw new TowerToolError('invalid-argument', message)
22
+ function fail(message, code = 'invalid-argument') {
23
+ throw new TowerToolError(code, message)
21
24
  }
22
25
 
23
- /**
24
- * @param {unknown} role
25
- * @returns {boolean}
26
- */
27
- export function isWorkerRole(role) {
28
- return role === 'worker'
29
- }
26
+ export { isWorkerRole } from '../roles.js'
30
27
 
31
28
  /**
32
29
  * The configured live-worker ceiling, defaulting to four.
@@ -67,6 +64,26 @@ export function applyInterrupt(entry, input, now) {
67
64
  return entry
68
65
  }
69
66
 
67
+ /**
68
+ * A reviewer's evidence is the commit recorded here, never a live branch tip
69
+ * read later (#149 W3). The checkout is the disposable detached worktree the
70
+ * reviewer reads in; it is cleared when the verdict is recorded.
71
+ * @param {any} input
72
+ * @param {{ requireReviewCommit?: boolean }} [options]
73
+ * @returns {{ reviewCommit: string | null, reviewCheckout: string | null }}
74
+ */
75
+ function reviewEvidence(input, options = {}) {
76
+ const reviewCommit = typeof input.reviewCommit === 'string' && input.reviewCommit.length > 0 ? input.reviewCommit : null
77
+ const reviewCheckout = typeof input.reviewCheckout === 'string' && input.reviewCheckout.length > 0 ? input.reviewCheckout : null
78
+ if (options.requireReviewCommit && isReviewerRole(input.role) && !reviewCommit) {
79
+ throw new TowerToolError(
80
+ 'invalid-argument',
81
+ `reviewer ${input.name} has no recorded review commit; spawn reviewers through tower_spawn (a review verdict is stamped with a commit, not a live branch tip)`,
82
+ )
83
+ }
84
+ return { reviewCommit, reviewCheckout }
85
+ }
86
+
70
87
  /**
71
88
  * A reserved roster row: the name is claimed before activation so a failed
72
89
  * spawn remains recoverable.
@@ -75,6 +92,7 @@ export function applyInterrupt(entry, input, now) {
75
92
  * @returns {RosterEntry}
76
93
  */
77
94
  export function reservedRosterEntry(input, now) {
95
+ const evidence = reviewEvidence(input, { requireReviewCommit: true })
78
96
  return {
79
97
  name: input.name,
80
98
  role: input.role,
@@ -84,6 +102,8 @@ export function reservedRosterEntry(input, now) {
84
102
  missionId: input.missionId,
85
103
  cwd: input.cwd ?? null,
86
104
  branch: input.branch ?? null,
105
+ ...(evidence.reviewCommit ? { reviewCommit: evidence.reviewCommit } : {}),
106
+ ...(evidence.reviewCheckout ? { reviewCheckout: evidence.reviewCheckout } : {}),
87
107
  spawnTip: null,
88
108
  state: 'reserved',
89
109
  reservedAt: now,
@@ -104,10 +124,15 @@ export function reserveRosterEntry({ roster, input, now }) {
104
124
  /**
105
125
  * Validate and apply a spawn record. Mutates the roster (retiring prior worker
106
126
  * rows and inserting/reusing the entry) and the mission owner/status/gen.
107
- * @param {{ roster: RosterEntry[], mission: Mission, input: any, spawnTip: string | null, maxLiveWorkers: unknown, now: number }} params
127
+ *
128
+ * `missions` is the state's mission list, not just the spawned mission: the live
129
+ * worker count resolves every roster row's kind through the shape registry, so a
130
+ * survey row must not be counted as a build worker (#148). Callers that omit it
131
+ * (direct domain tests) keep the historical roster-only treatment.
132
+ * @param {{ roster: RosterEntry[], missions?: Mission[], mission: Mission, input: any, spawnTip: string | null, maxLiveWorkers: unknown, now: number }} params
108
133
  * @returns {RosterEntry}
109
134
  */
110
- export function recordSpawnEntry({ roster, mission, input, spawnTip, maxLiveWorkers, now }) {
135
+ export function recordSpawnEntry({ roster, missions = [], mission, input, spawnTip, maxLiveWorkers, now }) {
111
136
  const reserved = roster.find((entry) => entry.name === input.name && entry.state === 'reserved' && !entry.agentId)
112
137
  if (!reserved && roster.some((entry) => entry.name === input.name)) {
113
138
  fail(`roster name already used: ${input.name}`)
@@ -115,7 +140,9 @@ export function recordSpawnEntry({ roster, mission, input, spawnTip, maxLiveWork
115
140
  if (roster.some((entry) => entry.agentId && entry.agentId === input.agentId)) {
116
141
  fail(`agent ${input.agentId} is already in the roster`)
117
142
  }
118
- /** @type {RosterEntry} */
143
+ /** @type {{ reviewCommit?: string | null, reviewCheckout?: string | null }} */
144
+ const evidence = reviewEvidence(input, { requireReviewCommit: true })
145
+ /** @type {RosterEntry & { generation?: number }} */
119
146
  const entry = {
120
147
  name: input.name,
121
148
  role: input.role,
@@ -125,12 +152,14 @@ export function recordSpawnEntry({ roster, mission, input, spawnTip, maxLiveWork
125
152
  missionId: input.missionId,
126
153
  cwd: input.cwd ?? null,
127
154
  branch: input.branch ?? null,
155
+ ...(evidence.reviewCommit ? { reviewCommit: evidence.reviewCommit } : {}),
156
+ ...(evidence.reviewCheckout ? { reviewCheckout: evidence.reviewCheckout } : {}),
128
157
  spawnTip,
129
158
  state: 'active',
130
159
  spawnedAt: now,
131
160
  }
132
161
  if (isWorkerRole(input.role)) {
133
- const snapshot = /** @type {any} */ ({ roster })
162
+ const snapshot = /** @type {any} */ ({ roster, missions })
134
163
  const previous = workerEntries(snapshot, mission.id)
135
164
  const live = previous.find((row) => !ROSTER_INACTIVE.has(row.state) && row.state !== 'reserved')
136
165
  if (live && !input.takeover) {
@@ -148,9 +177,17 @@ export function recordSpawnEntry({ roster, mission, input, spawnTip, maxLiveWork
148
177
  old.outcomeAt = now
149
178
  clearStale(old)
150
179
  }
151
- const max = liveWorkerLimit(maxLiveWorkers)
152
- if (countLiveWorkers(snapshot) >= max) {
153
- fail(`live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`)
180
+ // A shape that does not count toward the live-worker budget (the read-only
181
+ // survey) must pass even when every build slot is busy; otherwise the
182
+ // precheck and the record-level check disagree (#148).
183
+ if (shapeFor(mission.kind).spawn.countsTowardWorkerBudget !== false) {
184
+ const max = liveWorkerLimit(maxLiveWorkers)
185
+ if (countLiveWorkers(snapshot) >= max) {
186
+ fail(
187
+ `live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`,
188
+ 'worker-limit',
189
+ )
190
+ }
154
191
  }
155
192
  const generation = (mission.ownerGeneration ?? 0) + 1
156
193
  entry.generation = generation
@@ -3,8 +3,11 @@
3
3
  // Principle 11 says optional capabilities are switches and the core is not. This
4
4
  // module is that policy as data plus the pure checks over it: the component rows
5
5
  // in `cordis.patch.yml`, the four-way classification of every config key, and
6
- // the forbidden-to-disable capabilities (host, health, recovery, review contract,
7
- // merge gates) that no group, row or boolean key may gate.
6
+ // the forbidden-to-disable capabilities (host, health, recovery, merge gates)
7
+ // that no group, row or boolean key may gate. The review policy is not one of
8
+ // those never-switches: `review.mode` is the one tri-state control, and only the
9
+ // gate it turns on in `required` mode (evidence, tip binding, exemption path) is
10
+ // non-disableable.
8
11
  //
9
12
  // The single source of truth is `scripts/component-surface.json`. The generated
10
13
  // reference `docs/reference/component-surface.md` is a rendering of it, so
@@ -700,8 +703,13 @@ ${componentRows}
700
703
 
701
704
  ## Forbidden to disable
702
705
 
703
- The host, the health contract, the recovery escape, the review contract and the
704
- merge gates are configuration or code, never switches. \`npm run
706
+ The host, the health contract, the recovery escape and the merge gates are
707
+ configuration or code, never switches. The review gate in \`required\` mode is
708
+ also non-disableable: a completed mission still needs the review evidence bound
709
+ to the branch tip, and the exemption path stays explicit. The policy itself is
710
+ not a never-switch — \`review.mode\` is the one tri-state control on the
711
+ plugin-page config form (\`off\`, \`optional\` or \`required\`) — and only the gate it
712
+ turns on in \`required\` mode is non-disableable. \`npm run
705
713
  docs:check:components\` derives the live disablers from \`host/tool-registry.js\`
706
714
  and \`cordis.patch.yml\`; a disabler that is not recorded here fails the gate.
707
715
 
@@ -16,6 +16,7 @@
16
16
  import Schema from '@deepseek-ai/schemastery'
17
17
  import { TowerToolError } from '../errors.js'
18
18
  import { DEFAULT_MAX_REPORT_BYTES } from '../missions/completion.js'
19
+ import { materializeVolatile, resolveReviewPolicy } from '../integration/review.js'
19
20
 
20
21
  export const DEFAULT_DENY_TOOLS = [
21
22
  'create_goal',
@@ -36,7 +37,7 @@ const LEGACY_KEYS = ['section', 'allowAgentControl']
36
37
  // #121: the `tools` group is gone. Every capability that used to be a
37
38
  // `tools.expose*` boolean is now a component row in `cordis.patch.yml`, and a
38
39
  // component row is the switch. What remains here is genuine tuning policy.
39
- const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev']
40
+ const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'survey', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev']
40
41
 
41
42
  const SECTION_KEYS = {
42
43
  init: ['autoGitInit', 'snapshotDirty', 'autoExclude'],
@@ -57,7 +58,7 @@ const SECTION_KEYS = {
57
58
  'maxEntries',
58
59
  'lockTimeoutMs',
59
60
  ],
60
- review: ['requireReview', 'maxRoundsPerReviewer'],
61
+ review: ['mode', 'requireReview', 'maxRoundsPerReviewer'],
61
62
  merge: ['timeoutMs', 'maxBufferBytes'],
62
63
  git: ['timeoutMs'],
63
64
  worktrees: ['root'],
@@ -68,40 +69,54 @@ const SECTION_KEYS = {
68
69
  'provider',
69
70
  'workerModel',
70
71
  'reviewerModel',
72
+ 'surveyModel',
71
73
  'maxLiveWorkers',
72
74
  'staleMs',
73
75
  'maxWaitMs',
74
76
  'requireLease',
75
77
  ],
76
78
  send: ['maxPerMinute', 'interruptDefault'],
77
- tests: ['admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
78
- gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
79
+ // #148 W2: where a survey reads. This is tuning, not a component: the
80
+ // tower-survey row is the switch; the placement decides whether write/shell
81
+ // side effects are rejected (shared) or absorbed by a disposable checkout.
82
+ survey: ['sandbox'],
83
+ tests: ['admission', 'timeoutMs', 'maxBytes', 'allowPackageDiscovery'],
84
+ gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'requireFreshSurvey', 'maxReportBytes'],
79
85
  docs: ['boardLocation'],
80
86
  archive: ['afterDays', 'maxMissions'],
81
87
  leases: ['ttlMs'],
82
88
  dev: ['reloadDelayMs', 'reloadRequiredIds'],
83
89
  }
84
90
 
85
- // #133/#135: `docs.board` and `archive.enabled` were component-shaped switches
86
- // hiding in the config form. They are component rows now, so a legacy config
87
- // keeps failing as an unknown key — with an upgrade note naming the row that
88
- // replaced it.
89
- /** @type {Record<string, string>} */
91
+ // #133/#135/#149: a removed key keeps failing as an unknown key — with an
92
+ // upgrade note naming what replaced it. `docs.board` and `archive.enabled`
93
+ // were component-shaped switches hiding in the config form, so their note
94
+ // names the component row. `tests.autoChecks` existed only to copy a discovery
95
+ // projection into the completion report; its replacement is the `testRuns`
96
+ // receipt (whose concrete record path belongs to domain/evidence.js), so the
97
+ // note names the receipt and the one reader instead of a row.
98
+ /** @type {Record<string, { component?: string, note?: string }>} */
90
99
  const REMOVED_KEYS = {
91
- 'docs.board': 'tower-board',
92
- 'archive.enabled': 'tower-archive',
100
+ 'docs.board': { component: 'tower-board' },
101
+ 'archive.enabled': { component: 'tower-archive' },
102
+ 'tests.autoChecks': {
103
+ note: 'tests.autoChecks was removed: test evidence is the testRuns receipt read by domain/evidence.js (see docs/adr/0006-mission-shapes.md)',
104
+ },
93
105
  }
94
106
 
95
107
  /**
96
- * Upgrade note for a removed component switch, appended to the unknown-key error.
108
+ * Upgrade note for a removed key, appended to the unknown-key error.
97
109
  * @param {string} group
98
110
  * @param {string} key
99
111
  * @returns {string}
100
112
  */
101
113
  function removedKeyNote(group, key) {
102
- const component = REMOVED_KEYS[`${group}.${key}`]
103
- if (!component) return ''
104
- return ` — ${group}.${key} was removed: the ${component} component row in cordis.patch.yml is the switch (see docs/adr/0005-component-surface.md)`
114
+ const entry = REMOVED_KEYS[`${group}.${key}`]
115
+ if (!entry) return ''
116
+ if (entry.component) {
117
+ return ` — ${group}.${key} was removed: the ${entry.component} component row in cordis.patch.yml is the switch (see docs/adr/0005-component-surface.md)`
118
+ }
119
+ return ` — ${entry.note}`
105
120
  }
106
121
 
107
122
  const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
@@ -169,13 +184,35 @@ export const Config = Schema.object({
169
184
  .default({})
170
185
  .description('Tower-mode behavior and persistence.'),
171
186
  review: Schema.object({
187
+ // #M39: deliberately *no* schema default here. Cordis resolves the schema
188
+ // before `apply()` on some paths, and a materialized `mode:'optional'` would
189
+ // sit next to a legacy `review.requireReview:true` and look like a user
190
+ // contradiction. The resolver (`resolveTowerConfig`) is the single place
191
+ // that applies the `optional` default, after alias resolution.
192
+ // #M40: `.volatile()` is what makes a field editable from the DSH
193
+ // Plugins-page settings form. The form projection keeps only fields under a
194
+ // volatile ancestor, and Cordis then hands the runtime a live reference
195
+ // that is updated in place on a settings write (see host/config.js).
196
+ //
197
+ // Both review keys are volatile on purpose: the page must show the
198
+ // *resolved* policy, and `resolveReviewPolicy` lets the deprecated alias
199
+ // decide when `mode` is absent. A non-volatile alias would be dropped by the
200
+ // projection, so a profile that sets only `review.requireReview:true` would
201
+ // render as Optional (and a save could persist a contradiction). The group
202
+ // itself stays non-volatile so the tuning keys remain ordinary config.
203
+ mode: Schema.union(['off', 'optional', 'required'])
204
+ .volatile()
205
+ .description(
206
+ 'Review policy. off = review is intentionally unavailable (recording a review is refused, reviewer spawn is refused, and the merge gate ignores review; a profile-level off also does not register tower_review); optional = review is available but never blocks a merge (default); required = a clean review bound to the branch tip is a merge blocker.',
207
+ ),
172
208
  requireReview: Schema.boolean()
173
209
  .default(false)
174
- .description('Require an independent clean review before merge. Default false: review is optional; set true to enforce the review gate globally.'),
210
+ .volatile()
211
+ .description('Deprecated alias for review.mode (true -> required, false -> optional); prefer review.mode. true next to a non-required mode is rejected; false never overrides an explicit mode (off, optional or required). The Plugins-page control shows the alias when it is the only key and always writes review.mode with review.requireReview:false.'),
175
212
  maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
176
213
  })
177
214
  .default({})
178
- .description('Review policy.'),
215
+ .description('Review policy: off, optional (default) or required.'),
179
216
  merge: Schema.object({
180
217
  timeoutMs: Schema.natural().min(1000).default(900000).description('Default tower_merge timeout, including pre-merge hooks.'),
181
218
  maxBufferBytes: Schema.natural().min(65536).default(67108864).description('Captured merge output buffer.'),
@@ -193,12 +230,13 @@ export const Config = Schema.object({
193
230
  .default({})
194
231
  .description('Worktree placement.'),
195
232
  spawn: Schema.object({
196
- workerPreset: Schema.string().default('tower-worker').description('Agent preset for worker missions.'),
233
+ workerPreset: Schema.string().default('tower-worker').description('Agent preset for build workers.'),
197
234
  reviewerPreset: Schema.string().default('tower-reviewer').description('Agent preset for reviewers.'),
198
235
  maxDepth: Schema.natural().min(1).default(1).description('Subagent depth requested when spawning tower agents.'),
199
236
  provider: Schema.string().description('Subagent provider id; empty picks spawn, then the first registered provider.'),
200
237
  workerModel: ModelSchema().description('Model route for workers; omitted inherits the parent session.'),
201
238
  reviewerModel: ModelSchema().description('Model route for reviewers; omitted inherits the parent session (recommended).'),
239
+ surveyModel: ModelSchema().description('Model route for survey workers; omitted inherits the parent session.'),
202
240
  maxLiveWorkers: Schema.natural()
203
241
  .min(1)
204
242
  .max(64)
@@ -228,6 +266,15 @@ export const Config = Schema.object({
228
266
  })
229
267
  .default({})
230
268
  .description('Message delivery policy.'),
269
+ survey: Schema.object({
270
+ sandbox: Schema.union(['none', 'detached'])
271
+ .default('none')
272
+ .description(
273
+ 'Where a survey reads. none = the shared checkout with a read-only preset (writes and shell commands are impossible); detached = a disposable detached git worktree created at the recorded commit, with a sandbox preset that may write and run commands inside it. A detached sandbox is removed when the survey completes or is abandoned; it has no branch, never participates in merge/stale-base, and cannot commit its evidence.',
274
+ ),
275
+ })
276
+ .default({})
277
+ .description('Survey placement. Tuning only: the tower-survey component row is the capability switch.'),
231
278
  tests: Schema.object({
232
279
  // #136: no `enabled` switch. The `tower-tests` component row *is* the
233
280
  // switch; a second boolean could only disagree with it, and a row-off
@@ -237,7 +284,6 @@ export const Config = Schema.object({
237
284
  .description('Run discovery before spawning a worker, when completing, or only on explicit tower_test (default off: repository code runs only on an explicit tower_test).'),
238
285
  timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
239
286
  maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
240
- autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
241
287
  allowPackageDiscovery: Schema.boolean()
242
288
  .default(true)
243
289
  .description('Allow package.json tower.testDiscovery commands from the mission worktree (executes repository code; see security docs).'),
@@ -247,13 +293,16 @@ export const Config = Schema.object({
247
293
  gates: Schema.object({
248
294
  requireChecks: Schema.boolean()
249
295
  .default(false)
250
- .description('Require at least one completion check (tower_complete.checks) before a mission can merge.'),
296
+ .description('Require at least one recorded check (tower_complete.checks) or test-discovery receipt before a mission can merge.'),
251
297
  requireTests: Schema.boolean()
252
298
  .default(false)
253
299
  .description('Require a passing test-discovery receipt for the reviewed worktree tip before merge.'),
254
300
  allowReviewExemption: Schema.boolean()
255
301
  .default(false)
256
302
  .description('Allow reviewPolicy:none missions with a non-empty reviewExemptionReason to skip review blockers.'),
303
+ requireFreshSurvey: Schema.boolean()
304
+ .default(false)
305
+ .description('Promote the advisory survey-stale dependency warning to a merge blocker: a build may only merge when every survey dependency read a commit that is an ancestor-or-equal of the build base. Default false (warning only).'),
257
306
  maxReportBytes: Schema.natural()
258
307
  .min(1024)
259
308
  .max(16 * 1024 * 1024)
@@ -406,6 +455,9 @@ export function assertKnownKeys(raw, label) {
406
455
  if ((group === 'spawn') && value.reviewerModel && typeof value.reviewerModel === 'object') {
407
456
  assertModelKeys(value.reviewerModel, `${label} spawn.reviewerModel`)
408
457
  }
458
+ if ((group === 'spawn') && value.surveyModel && typeof value.surveyModel === 'object') {
459
+ assertModelKeys(value.surveyModel, `${label} spawn.surveyModel`)
460
+ }
409
461
  }
410
462
  }
411
463
 
@@ -432,7 +484,10 @@ export function assertSafeKeys(value, label) {
432
484
 
433
485
  /** @param {any} [rawConfig] */
434
486
  export function resolveTowerConfig(rawConfig) {
435
- const raw = rawConfig ?? {}
487
+ // #M40: the profile config Cordis hands `apply()` carries `review.mode` as a
488
+ // live volatile reference. Materialize before validation so the value checked
489
+ // here is the current one; the reference itself stays live in host/config.js.
490
+ const raw = materializeVolatile(rawConfig ?? {})
436
491
  if (raw !== null && typeof raw !== 'object') throw new TowerToolError('config-invalid', 'Tower config must be an object')
437
492
  assertKnownKeys(raw, 'Tower config')
438
493
  let parsed
@@ -464,16 +519,26 @@ export function resolveTowerConfig(rawConfig) {
464
519
  // `resolve(raw)` for every valid config without changing any resolved value.
465
520
  if (mode.promptSection === undefined) mode.promptSection = ''
466
521
  if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
522
+ // #M39: resolve the review policy here so every consumer reads one shape.
523
+ // `review.mode` is canonical; the deprecated `review.requireReview` boolean is
524
+ // materialized from it (true only for `required`), which keeps older readers
525
+ // working and makes `resolve(resolve(raw))` a fixed point — the mirrored alias
526
+ // is compatible with the mode it came from (domain/integration/review.js).
527
+ const reviewPolicy = resolveReviewPolicy(raw.review)
528
+ const review = clone(parsed.review ?? {})
529
+ review.mode = reviewPolicy.mode
530
+ review.requireReview = reviewPolicy.mode === 'required'
467
531
  return {
468
532
  section: mode.promptSection,
469
533
  init: clone(parsed.init ?? {}),
470
534
  mode,
471
- review: clone(parsed.review ?? {}),
535
+ review,
472
536
  merge: clone(parsed.merge ?? {}),
473
537
  git: clone(parsed.git ?? {}),
474
538
  worktrees: clone(parsed.worktrees ?? {}),
475
539
  spawn: clone(parsed.spawn ?? {}),
476
540
  send: clone(parsed.send ?? {}),
541
+ survey: clone(parsed.survey ?? {}),
477
542
  tests: clone(parsed.tests ?? {}),
478
543
  gates: clone(parsed.gates ?? {}),
479
544
  docs: clone(parsed.docs ?? {}),
@@ -87,6 +87,7 @@ const SCHEMAS = {
87
87
  gitLock: S.obj({ held: S.bool, pid: S.any, phase: S.any, heldMs: S.any }),
88
88
  mode: S.any,
89
89
  spawn: S.any,
90
+ review: S.any,
90
91
  towerAgentId: S.any,
91
92
  ownerMissing: S.bool,
92
93
  },
@@ -259,6 +260,7 @@ const SCHEMAS = {
259
260
  }),
260
261
  reports: S.any,
261
262
  surface: S.any,
263
+ review: S.any,
262
264
  archive: S.obj({
263
265
  missions: S.any,
264
266
  archivedMissions: S.int,
@@ -287,7 +289,6 @@ const SCHEMAS = {
287
289
  commands: S.arr(S.obj()),
288
290
  error: S.str,
289
291
  receipt: S.obj(),
290
- checks: S.arr(S.obj()),
291
292
  },
292
293
  ['ok', 'configured'],
293
294
  ),
@@ -17,11 +17,11 @@ export const PlanInput = Type.Object({
17
17
  title: Type.String({ description: 'Printable Unicode mission title, NFC-normalized, at most 120 characters; used as the mission label.' }),
18
18
  scope: Type.Optional(Type.Array(Type.String(), { description: 'Relative file globs this mission may touch; empty only for survey.' })),
19
19
  tasks: Type.Optional(Type.Array(Type.String(), { description: 'Verifiable task items the worker ticks off.' })),
20
- deps: Type.Optional(Type.Array(Type.String(), { description: 'Mission ids that must merge before this one.' })),
20
+ deps: Type.Optional(Type.Array(Type.String(), { description: 'Mission ids that must be satisfied before this one: merged for a build, completed for a survey.' })),
21
21
  kind: Type.Optional(enumOf(MISSION_KINDS, { description: 'survey = read-only investigation that reserves no scope.' })),
22
22
  context: Type.Optional(Type.String({ description: "The user's own key sentences, verbatim (max 4096 chars)." })),
23
23
  references: Type.Optional(Type.Array(Type.String(), { maxItems: 20, description: 'Read-only pointers/files the worker should read (max 20); copied into the briefing.' })),
24
- reviewPolicy: Type.Optional(enumOf(REVIEW_POLICIES, { description: 'required-clean (mission default) means a clean review is required when review.requireReview is on; none needs reviewExemptionReason, and with the review gate on it also needs gates.allowReviewExemption.' })),
24
+ reviewPolicy: Type.Optional(enumOf(REVIEW_POLICIES, { description: 'required-clean (mission default) means a clean review is required when review.mode is required; none needs reviewExemptionReason, and with the review gate on it also needs gates.allowReviewExemption.' })),
25
25
  reviewExemptionReason: Type.Optional(Type.String({ description: 'Non-empty justification when reviewPolicy is "none".' })),
26
26
  }, { additionalProperties: false }),
27
27
  { description: 'Missions to create, in intended order.' },
@@ -63,6 +63,14 @@ export const CompleteInput = Type.Object({
63
63
  missionId: Type.Optional(Type.String({ description: 'Mission id, e.g. M1 (canonical; alias: id).' })),
64
64
  id: Type.Optional(Type.String({ description: 'Alias for missionId; both are accepted.' })),
65
65
  report: Type.String({ description: 'Full completion report for the tower and the next review round.' }),
66
+ result: Type.Optional(
67
+ Type.Object({
68
+ paths: Type.Optional(Type.Array(Type.String(), { maxItems: 200, description: 'Survey only: paths the read touched or found relevant.' })),
69
+ symbols: Type.Optional(Type.Array(Type.String(), { maxItems: 200, description: 'Survey only: symbols the read identified.' })),
70
+ plan: Type.Optional(Type.Array(Type.String(), { maxItems: 200, description: 'Survey only: the ordered plan or next steps the read recommends.' })),
71
+ notes: Type.Optional(Type.String({ description: 'Survey only: short free-form evidence (max 4000 chars).' })),
72
+ }, { additionalProperties: false, description: 'Survey evidence; a build completion may not carry one.' }),
73
+ ),
66
74
  checks: Type.Optional(
67
75
  Type.Array(
68
76
  Type.Object({
package/domain/errors.js CHANGED
@@ -181,6 +181,12 @@ export const ERROR_CODES = {
181
181
  retryable: false,
182
182
  nextAction: 'complete the mission first, then spawn the reviewer',
183
183
  },
184
+ 'review-disabled': {
185
+ description: 'Review is disabled by `review.mode:off`; no reviewer, verdict, or review blocker exists in this configuration.',
186
+ trigger: '`tower_spawn` with `role:"reviewer"`, or a review submission, while `review.mode` is `off`',
187
+ retryable: false,
188
+ nextAction: 'set review.mode to optional or required in the Tower config, then retry',
189
+ },
184
190
  'not-authorized': {
185
191
  description: 'The calling agent role may not perform this action.',
186
192
  trigger: 'a worker calling a tower-only tool or changing mission status/scope; a reviewer mutating findings',