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
@@ -1,12 +1,14 @@
1
1
  // Pure batch lifecycle decisions and status projection.
2
2
  //
3
- // `domain/batch.js` owns the admission/close evidence validation; this module
4
- // owns the id assignment, close/abandon record transitions, and status payload
5
- // that app/batch.js used to keep inline. Receipt file reads stay in the tool
6
- // layer.
3
+ // `domain/batch.js` owns the lifecycle vocabulary, admission/close evidence
4
+ // validation and normalization; this module owns the id assignment, close/abandon
5
+ // record transitions, and status payload that app/batch.js used to keep inline.
6
+ // Receipt file reads stay in the tool layer. Every status move goes through the
7
+ // lifecycle module's `canTransitionBatch`, so this file never names a status
8
+ // literal of its own (#149 W3).
7
9
  // @ts-check
8
10
  import { TowerToolError } from '../errors.js'
9
- import { summarize } from '../batch.js'
11
+ import { BATCH_STATUS, canTransitionBatch, isBatchTerminal, normalizeBatchStatus, summarize } from '../batch.js'
10
12
 
11
13
  /** @typedef {import('../../types/index.js').TowerState} TowerState */
12
14
 
@@ -19,6 +21,21 @@ function fail(message, code = 'batch-invalid') {
19
21
  throw new TowerToolError(code, message)
20
22
  }
21
23
 
24
+ /**
25
+ * Move a draft batch to `to`, refusing a transition the lifecycle does not
26
+ * allow. A no-op (already at the target) is handled by the callers, not here.
27
+ * @param {any} batch
28
+ * @param {string} to
29
+ * @param {string} batchId
30
+ * @returns {void}
31
+ */
32
+ function assertBatchMove(batch, to, batchId) {
33
+ const from = normalizeBatchStatus(batch.status)
34
+ if (!canTransitionBatch(from, to)) {
35
+ fail(`batch ${batchId} is ${from}; it cannot move to ${to}${isBatchTerminal(from) ? ' (a batch is closed or abandoned exactly once)' : ''}`)
36
+ }
37
+ }
38
+
22
39
  /**
23
40
  * Next free `B<n>` id.
24
41
  * @param {any[]} batches
@@ -43,7 +60,7 @@ export function nextBatchId(batches) {
43
60
  export function createBatchRecord(id, normalized, actor, now) {
44
61
  return {
45
62
  id,
46
- status: 'open',
63
+ status: BATCH_STATUS.OPEN,
47
64
  ...normalized,
48
65
  createdBy: actor,
49
66
  createdAt: now,
@@ -59,7 +76,8 @@ export function createBatchRecord(id, normalized, actor, now) {
59
76
  * @returns {any}
60
77
  */
61
78
  export function closeBatchRecord(batch, options, actor, now) {
62
- batch.status = 'closed'
79
+ assertBatchMove(batch, BATCH_STATUS.CLOSED, String(batch.id ?? ''))
80
+ batch.status = BATCH_STATUS.CLOSED
63
81
  batch.closedAt = now
64
82
  batch.closedBy = actor
65
83
  batch.close = {
@@ -78,8 +96,9 @@ export function closeBatchRecord(batch, options, actor, now) {
78
96
  * @returns {{ alreadyAbandoned: boolean }}
79
97
  */
80
98
  export function abandonBatchCheck(batch, batchId) {
81
- if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
82
- return { alreadyAbandoned: batch.status === 'abandoned' }
99
+ const status = normalizeBatchStatus(batch.status)
100
+ if (status === BATCH_STATUS.CLOSED) fail(`batch ${batchId} is already closed`)
101
+ return { alreadyAbandoned: status === BATCH_STATUS.ABANDONED }
83
102
  }
84
103
 
85
104
  /**
@@ -88,7 +107,8 @@ export function abandonBatchCheck(batch, batchId) {
88
107
  * @returns {any}
89
108
  */
90
109
  export function applyBatchAbandon({ batch, actor, reason, now }) {
91
- batch.status = 'abandoned'
110
+ assertBatchMove(batch, BATCH_STATUS.ABANDONED, String(batch.id ?? ''))
111
+ batch.status = BATCH_STATUS.ABANDONED
92
112
  batch.abandonedAt = now
93
113
  batch.abandonedBy = actor
94
114
  batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
@@ -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
@@ -37,7 +37,7 @@ const LEGACY_KEYS = ['section', 'allowAgentControl']
37
37
  // #121: the `tools` group is gone. Every capability that used to be a
38
38
  // `tools.expose*` boolean is now a component row in `cordis.patch.yml`, and a
39
39
  // component row is the switch. What remains here is genuine tuning policy.
40
- 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']
41
41
 
42
42
  const SECTION_KEYS = {
43
43
  init: ['autoGitInit', 'snapshotDirty', 'autoExclude'],
@@ -69,40 +69,54 @@ const SECTION_KEYS = {
69
69
  'provider',
70
70
  'workerModel',
71
71
  'reviewerModel',
72
+ 'surveyModel',
72
73
  'maxLiveWorkers',
73
74
  'staleMs',
74
75
  'maxWaitMs',
75
76
  'requireLease',
76
77
  ],
77
78
  send: ['maxPerMinute', 'interruptDefault'],
78
- tests: ['admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
79
- 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'],
80
85
  docs: ['boardLocation'],
81
86
  archive: ['afterDays', 'maxMissions'],
82
87
  leases: ['ttlMs'],
83
88
  dev: ['reloadDelayMs', 'reloadRequiredIds'],
84
89
  }
85
90
 
86
- // #133/#135: `docs.board` and `archive.enabled` were component-shaped switches
87
- // hiding in the config form. They are component rows now, so a legacy config
88
- // keeps failing as an unknown key — with an upgrade note naming the row that
89
- // replaced it.
90
- /** @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 }>} */
91
99
  const REMOVED_KEYS = {
92
- 'docs.board': 'tower-board',
93
- '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
+ },
94
105
  }
95
106
 
96
107
  /**
97
- * 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.
98
109
  * @param {string} group
99
110
  * @param {string} key
100
111
  * @returns {string}
101
112
  */
102
113
  function removedKeyNote(group, key) {
103
- const component = REMOVED_KEYS[`${group}.${key}`]
104
- if (!component) return ''
105
- 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}`
106
120
  }
107
121
 
108
122
  const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
@@ -216,12 +230,13 @@ export const Config = Schema.object({
216
230
  .default({})
217
231
  .description('Worktree placement.'),
218
232
  spawn: Schema.object({
219
- 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.'),
220
234
  reviewerPreset: Schema.string().default('tower-reviewer').description('Agent preset for reviewers.'),
221
235
  maxDepth: Schema.natural().min(1).default(1).description('Subagent depth requested when spawning tower agents.'),
222
236
  provider: Schema.string().description('Subagent provider id; empty picks spawn, then the first registered provider.'),
223
237
  workerModel: ModelSchema().description('Model route for workers; omitted inherits the parent session.'),
224
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.'),
225
240
  maxLiveWorkers: Schema.natural()
226
241
  .min(1)
227
242
  .max(64)
@@ -251,6 +266,15 @@ export const Config = Schema.object({
251
266
  })
252
267
  .default({})
253
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.'),
254
278
  tests: Schema.object({
255
279
  // #136: no `enabled` switch. The `tower-tests` component row *is* the
256
280
  // switch; a second boolean could only disagree with it, and a row-off
@@ -260,7 +284,6 @@ export const Config = Schema.object({
260
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).'),
261
285
  timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
262
286
  maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
263
- autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
264
287
  allowPackageDiscovery: Schema.boolean()
265
288
  .default(true)
266
289
  .description('Allow package.json tower.testDiscovery commands from the mission worktree (executes repository code; see security docs).'),
@@ -270,13 +293,16 @@ export const Config = Schema.object({
270
293
  gates: Schema.object({
271
294
  requireChecks: Schema.boolean()
272
295
  .default(false)
273
- .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.'),
274
297
  requireTests: Schema.boolean()
275
298
  .default(false)
276
299
  .description('Require a passing test-discovery receipt for the reviewed worktree tip before merge.'),
277
300
  allowReviewExemption: Schema.boolean()
278
301
  .default(false)
279
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).'),
280
306
  maxReportBytes: Schema.natural()
281
307
  .min(1024)
282
308
  .max(16 * 1024 * 1024)
@@ -429,6 +455,9 @@ export function assertKnownKeys(raw, label) {
429
455
  if ((group === 'spawn') && value.reviewerModel && typeof value.reviewerModel === 'object') {
430
456
  assertModelKeys(value.reviewerModel, `${label} spawn.reviewerModel`)
431
457
  }
458
+ if ((group === 'spawn') && value.surveyModel && typeof value.surveyModel === 'object') {
459
+ assertModelKeys(value.surveyModel, `${label} spawn.surveyModel`)
460
+ }
432
461
  }
433
462
  }
434
463
 
@@ -509,6 +538,7 @@ export function resolveTowerConfig(rawConfig) {
509
538
  worktrees: clone(parsed.worktrees ?? {}),
510
539
  spawn: clone(parsed.spawn ?? {}),
511
540
  send: clone(parsed.send ?? {}),
541
+ survey: clone(parsed.survey ?? {}),
512
542
  tests: clone(parsed.tests ?? {}),
513
543
  gates: clone(parsed.gates ?? {}),
514
544
  docs: clone(parsed.docs ?? {}),
@@ -289,7 +289,6 @@ const SCHEMAS = {
289
289
  commands: S.arr(S.obj()),
290
290
  error: S.str,
291
291
  receipt: S.obj(),
292
- checks: S.arr(S.obj()),
293
292
  },
294
293
  ['ok', 'configured'],
295
294
  ),
@@ -17,7 +17,7 @@ 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.' })),
@@ -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({