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.
- package/CHANGELOG.md +72 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +5 -3
- package/GLOSSARY.md +73 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +16 -15
- package/README.md +18 -10
- package/app/archive.js +16 -1
- package/app/board.js +16 -1
- package/app/gate.js +14 -0
- package/app/review.js +7 -0
- package/app/spawn-prechecks.js +30 -5
- package/app/tower.js +5 -5
- package/client.js +321 -0
- package/cordis.patch.yml +26 -9
- package/docs/adr/0004-surface-budget.md +10 -4
- package/docs/adr/0005-component-surface.md +222 -11
- package/docs/architecture/overview.md +12 -6
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +53 -7
- package/docs/guide/quickstart.md +91 -10
- package/docs/guide/release.md +60 -19
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +12 -7
- package/docs/reference/component-surface.md +40 -21
- package/docs/reference/config.generated.md +6 -8
- package/docs/reference/config.md +36 -8
- package/docs/reference/doctor.md +11 -5
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/surface-budget.md +47 -12
- package/docs/reference/testing.md +1 -1
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +26 -7
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +11 -7
- package/docs/zh/reference/config.md +28 -8
- package/docs/zh/reference/doctor.md +7 -2
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +2 -2
- package/docs/zh/reference/tools.md +6 -5
- package/domain/component-rules.js +216 -0
- package/domain/components.js +181 -53
- package/domain/config/schema.js +86 -28
- package/domain/contracts/outputs.js +2 -0
- package/domain/contracts/tool-inputs/missions.js +1 -1
- package/domain/errors.js +6 -0
- package/domain/gate.js +23 -5
- package/domain/integration/review.js +203 -9
- package/domain/scope.js +176 -5
- package/domain/surface.js +21 -7
- package/host/capability-archive.js +18 -0
- package/host/capability-board.js +18 -0
- package/host/capability-registry.js +4 -0
- package/host/config.js +40 -3
- package/host/doctor.js +68 -12
- package/host/presets.js +26 -8
- package/host/tool-registry.js +30 -5
- package/index.js +17 -6
- package/locale/archive/en.json +6 -0
- package/locale/archive/zh.json +6 -0
- package/locale/batch/en.json +1 -1
- package/locale/batch/zh.json +1 -1
- package/locale/board/en.json +6 -0
- package/locale/board/zh.json +6 -0
- package/locale/en.json +37 -22
- package/locale/hmr/en.json +2 -2
- package/locale/hmr/zh.json +1 -1
- package/locale/import/en.json +1 -1
- package/locale/import/zh.json +1 -1
- package/locale/leases/en.json +2 -2
- package/locale/leases/zh.json +2 -2
- package/locale/preset-reviewer/en.json +1 -1
- package/locale/preset-reviewer/zh.json +1 -1
- package/locale/preset-worker/en.json +1 -1
- package/locale/preset-worker/zh.json +1 -1
- package/locale/skills/en.json +1 -1
- package/locale/skills/zh.json +1 -1
- package/locale/tests/en.json +2 -2
- package/locale/tests/zh.json +2 -2
- package/locale/zh.json +36 -21
- package/package.json +17 -2
- package/scripts/check-components.mjs +698 -44
- package/scripts/check-pack.mjs +3 -0
- package/scripts/component-surface.json +101 -37
- package/scripts/generate-capability-locale.mjs +3 -0
- package/scripts/mount-smoke/surface.mjs +2 -0
- package/scripts/pack-manifest.txt +10 -0
- package/scripts/release-notes.mjs +256 -0
- package/scripts/surface-baseline.json +39 -3
- package/scripts/surface-baseline.mjs +15 -5
- package/scripts/types-coverage.mjs +7 -0
- package/skills.js +4 -1
- package/tools/complete.js +3 -1
- package/tools/preflight.js +1 -1
- package/tools/review.js +1 -1
- package/tools/spawn.js +7 -7
- package/tools/status.js +8 -1
- package/tools/test.js +7 -7
- package/types/index.d.ts +10 -0
- package/ui-bundle/locale/en.json +1 -1
- package/ui-bundle/locale/zh.json +1 -1
package/domain/config/schema.js
CHANGED
|
@@ -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: ['
|
|
78
|
+
tests: ['admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
|
|
78
79
|
gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
|
|
79
|
-
docs: ['
|
|
80
|
-
archive: ['
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
2
|
-
// projections. No repository
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
51
|
-
|
|
241
|
+
return (
|
|
242
|
+
reviewRequired(config) &&
|
|
243
|
+
mission.reviewPolicy === 'none' &&
|
|
244
|
+
config.gates?.allowReviewExemption !== true
|
|
245
|
+
)
|
|
52
246
|
}
|