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