dsh-tower 2.4.2 → 2.6.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 (108) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +5 -3
  4. package/GLOSSARY.md +73 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +16 -15
  7. package/README.md +18 -10
  8. package/app/archive.js +16 -1
  9. package/app/board.js +16 -1
  10. package/app/gate.js +14 -0
  11. package/app/review.js +7 -0
  12. package/app/spawn-prechecks.js +30 -5
  13. package/app/tower.js +5 -5
  14. package/client.js +321 -0
  15. package/cordis.patch.yml +26 -9
  16. package/docs/adr/0004-surface-budget.md +10 -4
  17. package/docs/adr/0005-component-surface.md +222 -11
  18. package/docs/architecture/overview.md +12 -6
  19. package/docs/examples/first-mission.md +3 -2
  20. package/docs/faq.md +9 -7
  21. package/docs/guide/install-upgrade-migration.md +53 -7
  22. package/docs/guide/quickstart.md +91 -10
  23. package/docs/guide/release.md +60 -19
  24. package/docs/guide/scenarios-and-philosophy.md +35 -28
  25. package/docs/operations/security.md +11 -10
  26. package/docs/reference/blockers.md +12 -7
  27. package/docs/reference/component-surface.md +40 -21
  28. package/docs/reference/config.generated.md +6 -8
  29. package/docs/reference/config.md +36 -8
  30. package/docs/reference/doctor.md +11 -5
  31. package/docs/reference/errors.generated.md +1 -0
  32. package/docs/reference/errors.md +2 -1
  33. package/docs/reference/surface-budget.md +47 -12
  34. package/docs/reference/testing.md +1 -1
  35. package/docs/reference/tools.generated.md +5 -5
  36. package/docs/reference/tools.md +5 -5
  37. package/docs/zh/faq.md +7 -5
  38. package/docs/zh/guide/install-upgrade-migration.md +26 -7
  39. package/docs/zh/guide/quickstart.md +17 -10
  40. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  41. package/docs/zh/operations/security.md +9 -8
  42. package/docs/zh/reference/blockers.md +11 -7
  43. package/docs/zh/reference/config.md +28 -8
  44. package/docs/zh/reference/doctor.md +7 -2
  45. package/docs/zh/reference/errors.md +2 -1
  46. package/docs/zh/reference/testing.md +2 -2
  47. package/docs/zh/reference/tools.md +6 -5
  48. package/domain/component-rules.js +216 -0
  49. package/domain/components.js +181 -53
  50. package/domain/config/schema.js +86 -28
  51. package/domain/contracts/outputs.js +2 -0
  52. package/domain/contracts/tool-inputs/missions.js +1 -1
  53. package/domain/errors.js +6 -0
  54. package/domain/gate.js +23 -5
  55. package/domain/integration/review.js +203 -9
  56. package/domain/scope.js +176 -5
  57. package/domain/surface.js +21 -7
  58. package/host/capability-archive.js +18 -0
  59. package/host/capability-board.js +18 -0
  60. package/host/capability-registry.js +4 -0
  61. package/host/config.js +40 -3
  62. package/host/doctor.js +68 -12
  63. package/host/presets.js +26 -8
  64. package/host/tool-registry.js +30 -5
  65. package/index.js +17 -6
  66. package/locale/archive/en.json +6 -0
  67. package/locale/archive/zh.json +6 -0
  68. package/locale/batch/en.json +1 -1
  69. package/locale/batch/zh.json +1 -1
  70. package/locale/board/en.json +6 -0
  71. package/locale/board/zh.json +6 -0
  72. package/locale/en.json +37 -22
  73. package/locale/hmr/en.json +2 -2
  74. package/locale/hmr/zh.json +1 -1
  75. package/locale/import/en.json +1 -1
  76. package/locale/import/zh.json +1 -1
  77. package/locale/leases/en.json +2 -2
  78. package/locale/leases/zh.json +2 -2
  79. package/locale/preset-reviewer/en.json +1 -1
  80. package/locale/preset-reviewer/zh.json +1 -1
  81. package/locale/preset-worker/en.json +1 -1
  82. package/locale/preset-worker/zh.json +1 -1
  83. package/locale/skills/en.json +1 -1
  84. package/locale/skills/zh.json +1 -1
  85. package/locale/tests/en.json +2 -2
  86. package/locale/tests/zh.json +2 -2
  87. package/locale/zh.json +36 -21
  88. package/package.json +17 -2
  89. package/scripts/check-components.mjs +698 -44
  90. package/scripts/check-pack.mjs +3 -0
  91. package/scripts/component-surface.json +101 -37
  92. package/scripts/generate-capability-locale.mjs +3 -0
  93. package/scripts/mount-smoke/surface.mjs +2 -0
  94. package/scripts/pack-manifest.txt +10 -0
  95. package/scripts/release-notes.mjs +256 -0
  96. package/scripts/surface-baseline.json +39 -3
  97. package/scripts/surface-baseline.mjs +15 -5
  98. package/scripts/types-coverage.mjs +7 -0
  99. package/skills.js +4 -1
  100. package/tools/complete.js +3 -1
  101. package/tools/preflight.js +1 -1
  102. package/tools/review.js +1 -1
  103. package/tools/spawn.js +7 -7
  104. package/tools/status.js +8 -1
  105. package/tools/test.js +7 -7
  106. package/types/index.d.ts +10 -0
  107. package/ui-bundle/locale/en.json +1 -1
  108. package/ui-bundle/locale/zh.json +1 -1
@@ -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',
@@ -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'],
@@ -74,14 +75,36 @@ const SECTION_KEYS = {
74
75
  'requireLease',
75
76
  ],
76
77
  send: ['maxPerMinute', 'interruptDefault'],
77
- tests: ['enabled', 'admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
78
+ tests: ['admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
78
79
  gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
79
- docs: ['board', 'boardLocation'],
80
- archive: ['enabled', 'afterDays', 'maxMissions'],
80
+ docs: ['boardLocation'],
81
+ archive: ['afterDays', 'maxMissions'],
81
82
  leases: ['ttlMs'],
82
83
  dev: ['reloadDelayMs', 'reloadRequiredIds'],
83
84
  }
84
85
 
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
+ const REMOVED_KEYS = {
92
+ 'docs.board': 'tower-board',
93
+ 'archive.enabled': 'tower-archive',
94
+ }
95
+
96
+ /**
97
+ * Upgrade note for a removed component switch, appended to the unknown-key error.
98
+ * @param {string} group
99
+ * @param {string} key
100
+ * @returns {string}
101
+ */
102
+ 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)`
106
+ }
107
+
85
108
  const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
86
109
 
87
110
  const ModelSchema = () =>
@@ -147,13 +170,35 @@ export const Config = Schema.object({
147
170
  .default({})
148
171
  .description('Tower-mode behavior and persistence.'),
149
172
  review: Schema.object({
173
+ // #M39: deliberately *no* schema default here. Cordis resolves the schema
174
+ // before `apply()` on some paths, and a materialized `mode:'optional'` would
175
+ // sit next to a legacy `review.requireReview:true` and look like a user
176
+ // contradiction. The resolver (`resolveTowerConfig`) is the single place
177
+ // that applies the `optional` default, after alias resolution.
178
+ // #M40: `.volatile()` is what makes a field editable from the DSH
179
+ // Plugins-page settings form. The form projection keeps only fields under a
180
+ // volatile ancestor, and Cordis then hands the runtime a live reference
181
+ // that is updated in place on a settings write (see host/config.js).
182
+ //
183
+ // Both review keys are volatile on purpose: the page must show the
184
+ // *resolved* policy, and `resolveReviewPolicy` lets the deprecated alias
185
+ // decide when `mode` is absent. A non-volatile alias would be dropped by the
186
+ // projection, so a profile that sets only `review.requireReview:true` would
187
+ // render as Optional (and a save could persist a contradiction). The group
188
+ // itself stays non-volatile so the tuning keys remain ordinary config.
189
+ mode: Schema.union(['off', 'optional', 'required'])
190
+ .volatile()
191
+ .description(
192
+ '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.',
193
+ ),
150
194
  requireReview: Schema.boolean()
151
195
  .default(false)
152
- .description('Require an independent clean review before merge. Default false: review is optional; set true to enforce the review gate globally.'),
196
+ .volatile()
197
+ .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.'),
153
198
  maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
154
199
  })
155
200
  .default({})
156
- .description('Review policy.'),
201
+ .description('Review policy: off, optional (default) or required.'),
157
202
  merge: Schema.object({
158
203
  timeoutMs: Schema.natural().min(1000).default(900000).description('Default tower_merge timeout, including pre-merge hooks.'),
159
204
  maxBufferBytes: Schema.natural().min(65536).default(67108864).description('Captured merge output buffer.'),
@@ -207,7 +252,9 @@ export const Config = Schema.object({
207
252
  .default({})
208
253
  .description('Message delivery policy.'),
209
254
  tests: Schema.object({
210
- enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
255
+ // #136: no `enabled` switch. The `tower-tests` component row *is* the
256
+ // switch; a second boolean could only disagree with it, and a row-off
257
+ // profile has no tower_test tool and no receipt to gate on.
211
258
  admission: Schema.union(['spawn', 'complete', 'off'])
212
259
  .default('off')
213
260
  .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).'),
@@ -239,30 +286,24 @@ export const Config = Schema.object({
239
286
  .default({})
240
287
  .description('Merge-gate strictness.'),
241
288
  docs: Schema.object({
242
- board: Schema.boolean()
243
- .default(true)
244
- .description('Write a human-readable MISSIONS.md after mission changes.'),
245
289
  boardLocation: Schema.union(['common', 'repo'])
246
290
  .default('common')
247
- .description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md.'),
291
+ .description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md. Whether the board is written at all is the tower-board component row (#133).'),
248
292
  })
249
293
  .default({})
250
- .description('Human-readable artifacts.'),
294
+ .description('Human-readable artifact location. Turning the board off is a component row, not a tuning key.'),
251
295
  archive: Schema.object({
252
- enabled: Schema.boolean()
253
- .default(true)
254
- .description('Move terminal (merged/abandoned) missions whose worktree is gone into <git-common-dir>/dsh-tower/archive/ at the next mutation, so state.json stops carrying their full history.'),
255
296
  afterDays: Schema.natural()
256
297
  .min(0)
257
298
  .default(30)
258
- .description('Terminal missions untouched for at least this many days are archived on the next mutation (0 archives them immediately).'),
299
+ .description('Terminal missions untouched for at least this many days are archived on the next mutation (0 archives them immediately). Archival itself is the tower-archive component row (#135).'),
259
300
  maxMissions: Schema.natural()
260
301
  .min(0)
261
302
  .default(100)
262
303
  .description('Maximum terminal missions kept hot; once exceeded, the oldest eligible terminal missions are archived until the cap holds (0 archives every eligible terminal mission).'),
263
304
  })
264
305
  .default({})
265
- .description('Terminal-mission archival policy. Archived missions are moved, never deleted.'),
306
+ .description('Terminal-mission archival tuning. Whether archival runs at all is the tower-archive component row; archived missions are moved, never deleted.'),
266
307
  leases: Schema.object({
267
308
  ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
268
309
  })
@@ -376,9 +417,10 @@ export function assertKnownKeys(raw, label) {
376
417
  if (!value || typeof value !== 'object' || Array.isArray(value)) continue
377
418
  const bad = Object.keys(value).filter((key) => !allowed.includes(key))
378
419
  if (bad.length > 0) {
420
+ const notes = bad.map((key) => removedKeyNote(group, key)).join('')
379
421
  throw new TowerToolError(
380
422
  'config-unknown-key',
381
- `${label} has unknown key(s) ${bad.map((key) => `${group}.${key}`).join(', ')}`,
423
+ `${label} has unknown key(s) ${bad.map((key) => `${group}.${key}`).join(', ')}${notes}`,
382
424
  )
383
425
  }
384
426
  if ((group === 'spawn') && value.workerModel && typeof value.workerModel === 'object') {
@@ -413,7 +455,10 @@ export function assertSafeKeys(value, label) {
413
455
 
414
456
  /** @param {any} [rawConfig] */
415
457
  export function resolveTowerConfig(rawConfig) {
416
- const raw = rawConfig ?? {}
458
+ // #M40: the profile config Cordis hands `apply()` carries `review.mode` as a
459
+ // live volatile reference. Materialize before validation so the value checked
460
+ // here is the current one; the reference itself stays live in host/config.js.
461
+ const raw = materializeVolatile(rawConfig ?? {})
417
462
  if (raw !== null && typeof raw !== 'object') throw new TowerToolError('config-invalid', 'Tower config must be an object')
418
463
  assertKnownKeys(raw, 'Tower config')
419
464
  let parsed
@@ -423,13 +468,10 @@ export function resolveTowerConfig(rawConfig) {
423
468
  throw new TowerToolError('config-invalid', `Tower config is invalid: ${String(error?.message ?? error)}`)
424
469
  }
425
470
  const mode = clone(parsed.mode ?? {})
426
- if (parsed.gates?.requireTests === true && parsed.tests?.enabled === false) {
427
- // #121: the tower-tests *component row* is the capability switch now; a
428
- // repository that turns the row off cannot satisfy gates.requireTests. The
429
- // component-level half of this invariant is enforced at plan/mode entry,
430
- // where the mounted rows are known (host/presets.js).
431
- throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled:false)')
432
- }
471
+ // #136: `tests.enabled` is gone, so this resolver can no longer disagree with
472
+ // the mounted rows. The component-level invariant (`gates.requireTests:true`
473
+ // with the `tower-tests` row off) is enforced where the rows are known:
474
+ // host/presets.js at plan/mode entry, and the merge gate reports its cause.
433
475
  // Canonical keys win; the legacy top-level aliases only fill gaps.
434
476
  const rawMode = raw.mode ?? {}
435
477
  if (rawMode.promptSection === undefined && raw.section !== undefined) mode.promptSection = raw.section
@@ -440,12 +482,28 @@ export function resolveTowerConfig(rawConfig) {
440
482
  // Cordis config may omit this key, and an explicit legacy top-level
441
483
  // `allowAgentControl:true` must not be shadowed by a schema default.
442
484
  if (mode.allowAgentControl === undefined) mode.allowAgentControl = true
485
+ // F17/#130: materialize the resolved prompt section as well. `section` always
486
+ // mirrors `mode.promptSection`, so leaving the canonical field absent for a
487
+ // config with neither key would make a *second* pass copy the just-added
488
+ // `section:''` back into `mode` (`promptSection:''`): one pass would not be a
489
+ // fixed point. Filling it here makes `resolve(resolve(raw))` equal
490
+ // `resolve(raw)` for every valid config without changing any resolved value.
491
+ if (mode.promptSection === undefined) mode.promptSection = ''
443
492
  if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
493
+ // #M39: resolve the review policy here so every consumer reads one shape.
494
+ // `review.mode` is canonical; the deprecated `review.requireReview` boolean is
495
+ // materialized from it (true only for `required`), which keeps older readers
496
+ // working and makes `resolve(resolve(raw))` a fixed point — the mirrored alias
497
+ // is compatible with the mode it came from (domain/integration/review.js).
498
+ const reviewPolicy = resolveReviewPolicy(raw.review)
499
+ const review = clone(parsed.review ?? {})
500
+ review.mode = reviewPolicy.mode
501
+ review.requireReview = reviewPolicy.mode === 'required'
444
502
  return {
445
- section: mode.promptSection ?? '',
503
+ section: mode.promptSection,
446
504
  init: clone(parsed.init ?? {}),
447
505
  mode,
448
- review: clone(parsed.review ?? {}),
506
+ review,
449
507
  merge: clone(parsed.merge ?? {}),
450
508
  git: clone(parsed.git ?? {}),
451
509
  worktrees: clone(parsed.worktrees ?? {}),
@@ -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,
@@ -21,7 +21,7 @@ export const PlanInput = Type.Object({
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.' },
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',
package/domain/gate.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Pure completion gate checks (#49). No repository I/O.
2
2
  // @ts-check
3
3
  import { isTerminal } from './mission.js'
4
- import { isReviewExempt, reviewExemptionInvalid } from './integration/review.js'
4
+ import { isReviewExempt, reviewExemptionInvalid, reviewRequired } from './integration/review.js'
5
5
 
6
6
  /** @typedef {import('../types/index.js').Blocker} Blocker */
7
7
  /** @typedef {import('../types/index.js').MergeGateOptions} MergeGateOptions */
@@ -47,7 +47,11 @@ export function completionBlockers(mission, config = {}) {
47
47
 
48
48
  /**
49
49
  * Review, roster, and test-evidence blockers; all inputs are snapshots.
50
- * @param {MergeGateOptions} options
50
+ * `testsComponent` is the optional `tower-tests` row state (#140). It is passed
51
+ * through the options bag rather than declared on `MergeGateOptions` because the
52
+ * domain may not read the component registry itself: the composition root
53
+ * publishes the probe on the port bag and `app/gate.js` forwards the snapshot.
54
+ * @param {MergeGateOptions & { testsComponent?: ({ enabled?: boolean, reason?: string } | null) }} options
51
55
  * @returns {Blocker[]}
52
56
  */
53
57
  export function mergeGateBlockers({
@@ -61,6 +65,7 @@ export function mergeGateBlockers({
61
65
  parentStatus = null,
62
66
  inactiveStates = [],
63
67
  rebaseWaived = false,
68
+ testsComponent = null,
64
69
  }) {
65
70
  /** @type {Blocker[]} */
66
71
  const blockers = []
@@ -69,13 +74,16 @@ export function mergeGateBlockers({
69
74
  blockers.push({ code, detail })
70
75
  }
71
76
  const inactive = new Set(inactiveStates)
72
- const requireReview = config.review?.requireReview !== false
77
+ // #M39: only `review.mode:required` produces review blockers. `optional`
78
+ // records reviews without gating, and `off` disables review entirely (no
79
+ // tower_review, no reviewer spawn), so both skip this whole block.
80
+ const requireReview = reviewRequired(config)
73
81
  const exempt = isReviewExempt(mission, config)
74
82
  if (reviewExemptionInvalid(mission, config)) {
75
83
  push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
76
84
  }
77
85
  if (requireReview && !exempt && !review) {
78
- push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.requireReview:false)`)
86
+ push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.mode:optional)`)
79
87
  } else if (requireReview && !exempt) {
80
88
  const latest = /** @type {Review} */ (review)
81
89
  if (latest.missionId !== mission.id) push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
@@ -106,7 +114,17 @@ export function mergeGateBlockers({
106
114
  if (config.gates?.requireTests === true) {
107
115
  const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
108
116
  if (runs.length === 0) {
109
- push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
117
+ // #140: name the component cause when the row is off. Without this the
118
+ // blocker reads as a repository mistake ("no passing discovery") when the
119
+ // real cause is a disabled row and no tower_test tool exists to produce a
120
+ // receipt. The component state is a snapshot on the options bag.
121
+ const tip = branchTip ? branchTip.slice(0, 7) : '(none)'
122
+ const cause =
123
+ testsComponent && testsComponent.enabled !== true
124
+ ? `; ${testsComponent.reason ?? 'disabled by component tower-tests'} so no receipt can exist: ` +
125
+ 're-enable the tower-tests component (or set gates.requireTests:false) and re-record discovery'
126
+ : ''
127
+ push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${tip}${cause}`)
110
128
  }
111
129
  }
112
130
  return blockers
@@ -1,11 +1,197 @@
1
- // Pure review-selection and exemption decisions shared by the merge/gate/read
2
- // projections. No repository I/O.
1
+ // Pure review-selection, review-policy resolution and exemption decisions
2
+ // shared by the merge/gate/read projections and the config layer. No repository
3
+ // I/O and no process state.
4
+ //
5
+ // REVIEW POLICY (#M39)
6
+ // --------------------
7
+ // `review.mode` is the single policy with exactly three independent values:
8
+ //
9
+ // off review is intentionally unavailable: recording a review is
10
+ // refused, `tower_spawn` refuses the reviewer role, and the merge
11
+ // gate ignores review blockers. Registration is profile-time: a
12
+ // profile-level off does not mount `tower_review`, while a
13
+ // repository-level off over a profile that mounted it refuses the
14
+ // submission with `review-disabled`;
15
+ // optional review is available but never blocks a merge (the default, and the
16
+ // 2.0.3+ behavior of `review.requireReview:false`);
17
+ // required a clean review bound to the branch tip is a merge blocker (the
18
+ // old `review.requireReview:true` behavior).
19
+ //
20
+ // `review.requireReview` stays accepted for one release as a normalized alias:
21
+ // true -> required, false -> optional. Precedence for one raw config object is
22
+ // deterministic:
23
+ //
24
+ // * only the alias set: the alias decides;
25
+ // * only the mode set: the mode decides;
26
+ // * both set: an alias of `true` next to any mode other than `required` is a
27
+ // contradiction and is rejected naming both keys. An alias of `false` stays
28
+ // compatible with `off` because both mean "not required by merge" and the
29
+ // mode is the more specific policy. That asymmetry is also what keeps the
30
+ // resolver idempotent: it materializes the alias from the mode it resolved,
31
+ // and `required` is the only mode that mirrors back to `true`.
3
32
  // @ts-check
33
+ import { TowerToolError } from '../errors.js'
4
34
 
5
35
  /** @typedef {import('../../types/index.js').Mission} Mission */
6
36
  /** @typedef {import('../../types/index.js').Review} Review */
7
37
  /** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
8
38
 
39
+ export const REVIEW_MODES = Object.freeze(['off', 'optional', 'required'])
40
+ export const DEFAULT_REVIEW_MODE = 'optional'
41
+
42
+ // Cordis materializes a `.volatile()` schema field as a live reference object
43
+ // (`{ get() }` plus a non-enumerable `Symbol.for('cosmokit.volatile.write')`).
44
+ // Settings forms hand the runtime these references, not plain values, so every
45
+ // consumer below unwraps them before validating or comparing. Keeping the
46
+ // helpers here (instead of a new config module) lets the schema import them
47
+ // without a cycle: review.js has no dependency on schema.js.
48
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
49
+
50
+ /**
51
+ * True for the live reference Cordis/Schemastery creates for a volatile field.
52
+ * @param {unknown} value
53
+ * @returns {value is { get: () => unknown }}
54
+ */
55
+ export function isVolatileRef(value) {
56
+ return (
57
+ value !== null &&
58
+ typeof value === 'object' &&
59
+ typeof /** @type {{ get?: unknown }} */ (value).get === 'function' &&
60
+ Object.getOwnPropertySymbols(value).includes(VOLATILE_WRITE)
61
+ )
62
+ }
63
+
64
+ /**
65
+ * Read a volatile reference once; other values pass through unchanged.
66
+ * @template T
67
+ * @param {T | { get: () => T }} value
68
+ * @returns {T}
69
+ */
70
+ export function unwrapVolatile(value) {
71
+ return /** @type {any} */ (isVolatileRef(value) ? value.get() : value)
72
+ }
73
+
74
+ /**
75
+ * Deep-copy a raw config object, replacing every volatile reference with its
76
+ * current value. Non-plain objects (for example a class instance) are returned
77
+ * as-is; Tower config is JSON-shaped plain data.
78
+ * @param {unknown} value
79
+ * @returns {any}
80
+ */
81
+ export function materializeVolatile(value) {
82
+ const plain = unwrapVolatile(value)
83
+ if (Array.isArray(plain)) return plain.map((entry) => materializeVolatile(entry))
84
+ if (plain === null || typeof plain !== 'object') return plain
85
+ const proto = Object.getPrototypeOf(plain)
86
+ if (proto !== Object.prototype && proto !== null) return plain
87
+ /** @type {Record<string, unknown>} */
88
+ const copy = {}
89
+ for (const [key, entry] of Object.entries(plain)) copy[key] = materializeVolatile(entry)
90
+ return copy
91
+ }
92
+
93
+ /**
94
+ * @param {unknown} value
95
+ * @returns {value is 'off' | 'optional' | 'required'}
96
+ */
97
+ export function isReviewMode(value) {
98
+ return typeof value === 'string' && REVIEW_MODES.includes(value)
99
+ }
100
+
101
+ /**
102
+ * The mode the deprecated boolean alias means.
103
+ * @param {boolean} requireReview
104
+ */
105
+ export function legacyReviewMode(requireReview) {
106
+ return requireReview === true ? 'required' : DEFAULT_REVIEW_MODE
107
+ }
108
+
109
+ /**
110
+ * The resolved mode for any config object the runtime may hold: a resolved
111
+ * config, a raw profile/repo layer, or a hand-built test fixture. An explicit
112
+ * `mode` always wins; otherwise the deprecated alias decides; otherwise the
113
+ * documented default applies.
114
+ * @param {{ review?: any } | null | undefined} [config]
115
+ * @returns {'off' | 'optional' | 'required'}
116
+ */
117
+ export function reviewModeOf(config) {
118
+ const review = config?.review ?? {}
119
+ const mode = unwrapVolatile(review.mode)
120
+ if (isReviewMode(mode)) return mode
121
+ const alias = unwrapVolatile(review.requireReview)
122
+ if (typeof alias === 'boolean') return legacyReviewMode(alias)
123
+ return DEFAULT_REVIEW_MODE
124
+ }
125
+
126
+ /**
127
+ * The review gate is enforced only in `required`. `off` and `optional` both
128
+ * ignore review at the merge gate; they differ in availability, not strictness.
129
+ * @param {{ review?: any } | null | undefined} [config]
130
+ */
131
+ export function reviewRequired(config) {
132
+ return reviewModeOf(config) === 'required'
133
+ }
134
+
135
+ /**
136
+ * True when review is intentionally switched off (`tower_review` absent,
137
+ * reviewer spawn refused, merge gate ignores review).
138
+ * @param {{ review?: any } | null | undefined} [config]
139
+ */
140
+ export function reviewUnavailable(config) {
141
+ return reviewModeOf(config) === 'off'
142
+ }
143
+
144
+ /**
145
+ * Resolve one raw `review` object (a profile, a repository layer, or a single
146
+ * written config) into its mode. Rejects contradictory explicit values and an
147
+ * unknown mode with an actionable `config-invalid` error.
148
+ *
149
+ * @param {any} rawReview
150
+ * @returns {{ mode: 'off' | 'optional' | 'required', explicit: boolean, legacy: boolean }}
151
+ */
152
+ export function resolveReviewPolicy(rawReview) {
153
+ const review = rawReview && typeof rawReview === 'object' && !Array.isArray(rawReview) ? rawReview : {}
154
+ const mode = unwrapVolatile(review.mode)
155
+ const alias = unwrapVolatile(review.requireReview)
156
+ if (mode !== undefined && !isReviewMode(mode)) {
157
+ throw new TowerToolError(
158
+ 'config-invalid',
159
+ `review.mode must be one of ${REVIEW_MODES.join('|')}, got ${JSON.stringify(mode)}`,
160
+ )
161
+ }
162
+ if (alias !== undefined && typeof alias !== 'boolean') {
163
+ throw new TowerToolError(
164
+ 'config-invalid',
165
+ 'review.requireReview must be a boolean (deprecated alias: true -> required, false -> optional)',
166
+ )
167
+ }
168
+ if (mode !== undefined && alias === true && mode !== 'required') {
169
+ throw new TowerToolError(
170
+ 'config-invalid',
171
+ `review.mode is ${JSON.stringify(mode)} but review.requireReview is true; set only review.mode (off|optional|required) — review.requireReview is a deprecated alias (true -> required, false -> optional) and may not contradict it`,
172
+ )
173
+ }
174
+ if (mode !== undefined) return { mode, explicit: true, legacy: alias !== undefined }
175
+ if (alias !== undefined) return { mode: legacyReviewMode(alias), explicit: true, legacy: true }
176
+ return { mode: DEFAULT_REVIEW_MODE, explicit: false, legacy: false }
177
+ }
178
+
179
+ /**
180
+ * The small read-only projection reported by `tower_status` and the doctor's
181
+ * `review` check. `detail` is the one sentence both surfaces share.
182
+ * @param {{ review?: any } | null | undefined} [config]
183
+ */
184
+ export function reviewPolicyView(config) {
185
+ const mode = reviewModeOf(config)
186
+ const detail =
187
+ mode === 'off'
188
+ ? 'review.mode:off — review is intentionally unavailable: recording a review is refused, reviewer spawn is refused, and the merge gate ignores review; a profile-level off does not register tower_review'
189
+ : mode === 'required'
190
+ ? 'review.mode:required — a clean review bound to the branch tip is a merge blocker'
191
+ : 'review.mode:optional — review is available but never blocks a merge'
192
+ return { mode, available: mode !== 'off', required: mode === 'required', detail }
193
+ }
194
+
9
195
  /**
10
196
  * Reviews are branch-scoped; a missing branch has no reviews.
11
197
  * @param {Review[] | null | undefined} reviews
@@ -26,27 +212,35 @@ export function latestReview(reviews) {
26
212
  }
27
213
 
28
214
  /**
29
- * A mission may skip review only with both the exemption gate and a reason.
215
+ * A mission skips the review gate unless `review.mode` is `required`. In
216
+ * `required`, it may still skip with both the exemption gate and a reason.
217
+ * `off` and `optional` both bypass the gate; they differ only in whether the
218
+ * review tools/spawn role exist.
30
219
  * @param {Mission} mission
31
220
  * @param {TowerConfig} [config]
32
221
  * @returns {boolean}
33
222
  */
34
223
  export function isReviewExempt(mission, config = {}) {
224
+ if (!reviewRequired(config)) return true
35
225
  return (
36
- config.review?.requireReview === false ||
37
- (mission.reviewPolicy === 'none' &&
38
- config.gates?.allowReviewExemption === true &&
39
- String(mission.reviewExemptionReason ?? '').trim().length > 0)
226
+ mission.reviewPolicy === 'none' &&
227
+ config.gates?.allowReviewExemption === true &&
228
+ String(mission.reviewExemptionReason ?? '').trim().length > 0
40
229
  )
41
230
  }
42
231
 
43
232
  /**
44
233
  * `reviewPolicy: none` is invalid when the reviewer gate does not allow it.
234
+ * Only `review.mode:required` can make the request invalid; with review off or
235
+ * optional no review blocker is produced at all.
45
236
  * @param {Mission} mission
46
237
  * @param {TowerConfig} [config]
47
238
  * @returns {boolean}
48
239
  */
49
240
  export function reviewExemptionInvalid(mission, config = {}) {
50
- const requireReview = config.review?.requireReview !== false
51
- return requireReview && mission.reviewPolicy === 'none' && config.gates?.allowReviewExemption !== true
241
+ return (
242
+ reviewRequired(config) &&
243
+ mission.reviewPolicy === 'none' &&
244
+ config.gates?.allowReviewExemption !== true
245
+ )
52
246
  }