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/state/schema.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
// Declarative state-file schema (#56 P4).
|
|
1
|
+
// Declarative state-file schema (#56 P4, #149).
|
|
2
2
|
//
|
|
3
3
|
// `StateSchema` describes the structural contract of
|
|
4
|
-
// `<git-common-dir>/dsh-tower/state.json`.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// `<git-common-dir>/dsh-tower/state.json`. Schema v4 splits a mission into a
|
|
5
|
+
// per-shape union: a build mission owns a branch/worktree/merge projection, a
|
|
6
|
+
// survey owns `readCommit`/`readDirty` and may *not* carry the branch-shaped
|
|
7
|
+
// fields. Object schemas allow additional properties on purpose: state files
|
|
8
|
+
// are forward/backward compatible and legacy rows carry extra fields, while the
|
|
9
|
+
// union's forbidden keys are declared `Type.Never()` so a survey carrying a
|
|
10
|
+
// worktree record fails the shape check. Semantic policy (uniqueness,
|
|
11
|
+
// status/verdict vocabularies, review exemption, worktree records and the
|
|
12
|
+
// revision counter) lives in `./semantics.js`, so this module remains a pure
|
|
13
|
+
// declaration.
|
|
9
14
|
// @ts-check
|
|
10
15
|
import { Type } from '@sinclair/typebox'
|
|
11
16
|
import { Value } from '@sinclair/typebox/value'
|
|
12
17
|
import { SCHEMA_VERSION } from '../mission.js'
|
|
18
|
+
import { isShapeKind, shapeFor } from '../shapes/index.js'
|
|
13
19
|
|
|
14
20
|
const optional = { additionalProperties: true }
|
|
15
21
|
|
|
@@ -17,9 +23,10 @@ const NonNegativeInteger = Type.Integer({ minimum: 0 })
|
|
|
17
23
|
|
|
18
24
|
const WorktreeSchema = Type.Object({}, optional)
|
|
19
25
|
|
|
20
|
-
const
|
|
26
|
+
const BuildMissionSchema = Type.Object(
|
|
21
27
|
{
|
|
22
28
|
id: Type.String(),
|
|
29
|
+
kind: Type.Literal('build'),
|
|
23
30
|
status: Type.Optional(Type.String()),
|
|
24
31
|
tasks: Type.Array(Type.Unknown()),
|
|
25
32
|
deps: Type.Array(Type.Unknown()),
|
|
@@ -27,10 +34,41 @@ const MissionSchema = Type.Object(
|
|
|
27
34
|
reviewPolicy: Type.Optional(Type.String()),
|
|
28
35
|
testRuns: Type.Optional(Type.Array(Type.Unknown())),
|
|
29
36
|
worktree: Type.Optional(Type.Union([WorktreeSchema, Type.Null()])),
|
|
37
|
+
// A build mission reads a base and writes a branch; it never carries the
|
|
38
|
+
// survey fingerprint or a detached survey sandbox.
|
|
39
|
+
readCommit: Type.Optional(Type.Never()),
|
|
40
|
+
readDirty: Type.Optional(Type.Never()),
|
|
41
|
+
sandbox: Type.Optional(Type.Never()),
|
|
30
42
|
},
|
|
31
43
|
optional,
|
|
32
44
|
)
|
|
33
45
|
|
|
46
|
+
const SurveyMissionSchema = Type.Object(
|
|
47
|
+
{
|
|
48
|
+
id: Type.String(),
|
|
49
|
+
kind: Type.Literal('survey'),
|
|
50
|
+
status: Type.Optional(Type.String()),
|
|
51
|
+
tasks: Type.Array(Type.Unknown()),
|
|
52
|
+
deps: Type.Array(Type.Unknown()),
|
|
53
|
+
references: Type.Optional(Type.Array(Type.Unknown())),
|
|
54
|
+
reviewPolicy: Type.Optional(Type.String()),
|
|
55
|
+
readCommit: Type.String(),
|
|
56
|
+
readDirty: Type.Optional(Type.Array(Type.String())),
|
|
57
|
+
// #148 W2: an optional path to the disposable detached sandbox. Absent for
|
|
58
|
+
// a shared-checkout survey and for every build.
|
|
59
|
+
sandbox: Type.Optional(Type.String()),
|
|
60
|
+
// A survey reads one commit in the shared checkout: no branch shape.
|
|
61
|
+
worktree: Type.Optional(Type.Never()),
|
|
62
|
+
branch: Type.Optional(Type.Never()),
|
|
63
|
+
baseCommit: Type.Optional(Type.Never()),
|
|
64
|
+
mergedCommit: Type.Optional(Type.Never()),
|
|
65
|
+
mergedAt: Type.Optional(Type.Never()),
|
|
66
|
+
},
|
|
67
|
+
optional,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const MissionSchema = Type.Union([BuildMissionSchema, SurveyMissionSchema])
|
|
71
|
+
|
|
34
72
|
const RosterEntrySchema = Type.Object(
|
|
35
73
|
{
|
|
36
74
|
name: Type.String(),
|
|
@@ -131,6 +169,9 @@ function firstShapeMessage(state, where) {
|
|
|
131
169
|
for (const mission of state.missions) {
|
|
132
170
|
if (!isPlainObject(mission) || typeof mission.id !== 'string') return `mission entry without id${where}`
|
|
133
171
|
const id = mission.id
|
|
172
|
+
if (!isShapeKind(mission.kind)) {
|
|
173
|
+
return `mission ${id} has unknown kind ${String(mission.kind)}${where}`
|
|
174
|
+
}
|
|
134
175
|
if (!Array.isArray(mission.tasks)) return `mission ${id} has no tasks array${where}`
|
|
135
176
|
if (!Array.isArray(mission.deps)) return `mission ${id} has no deps array${where}`
|
|
136
177
|
if (mission.references !== undefined && !Array.isArray(mission.references)) {
|
|
@@ -142,6 +183,27 @@ function firstShapeMessage(state, where) {
|
|
|
142
183
|
if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
|
|
143
184
|
return `mission ${id} has no testRuns array${where}`
|
|
144
185
|
}
|
|
186
|
+
if (isShapeKind(mission.kind)) {
|
|
187
|
+
const shape = shapeFor(mission.kind)
|
|
188
|
+
if (shape.fields.readCommit) {
|
|
189
|
+
if (typeof mission.readCommit !== 'string' || mission.readCommit.length === 0) {
|
|
190
|
+
return `mission ${id} is a survey and needs a readCommit${where}`
|
|
191
|
+
}
|
|
192
|
+
if (mission.readDirty !== undefined && !Array.isArray(mission.readDirty)) {
|
|
193
|
+
return `mission ${id} has an invalid readDirty fingerprint${where}`
|
|
194
|
+
}
|
|
195
|
+
if (mission.sandbox !== undefined && (typeof mission.sandbox !== 'string' || mission.sandbox.length === 0)) {
|
|
196
|
+
return `mission ${id} has an invalid sandbox path${where}`
|
|
197
|
+
}
|
|
198
|
+
if (mission.worktree !== undefined && mission.worktree !== null) {
|
|
199
|
+
return `mission ${id} is a survey and may not have a worktree record${where}`
|
|
200
|
+
}
|
|
201
|
+
} else if (mission.readCommit !== undefined || mission.readDirty !== undefined) {
|
|
202
|
+
return `mission ${id} is a build and may not have readCommit/readDirty${where}`
|
|
203
|
+
} else if (mission.sandbox !== undefined) {
|
|
204
|
+
return `mission ${id} is a build and may not have a sandbox${where}`
|
|
205
|
+
}
|
|
206
|
+
}
|
|
145
207
|
if (mission.worktree !== undefined && mission.worktree !== null && !isPlainObject(mission.worktree)) {
|
|
146
208
|
return `mission ${id} has an invalid worktree record${where}`
|
|
147
209
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// `stateShapeError` proves the structural contract; this module owns the policy
|
|
4
|
-
// the shape cannot express: unique ids, status/verdict vocabularies, the review
|
|
5
|
-
// exemption, roster/agent uniqueness, worktree records and the revision
|
|
6
|
-
// counter. Messages keep the historical `[tower:state-corrupt]` wording.
|
|
1
|
+
// State semantics that a structural schema cannot express: uniqueness,
|
|
2
|
+
// status vocabularies, review exemption and per-kind mission fields (#149).
|
|
7
3
|
// @ts-check
|
|
4
|
+
import { FINDING_STATUSES, MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
|
|
5
|
+
import { BATCH_STATUSES } from '../batch.js'
|
|
8
6
|
import { TowerToolError } from '../errors.js'
|
|
9
|
-
import {
|
|
7
|
+
import { isShapeKind, shapeFor } from '../shapes/index.js'
|
|
10
8
|
|
|
11
9
|
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
12
10
|
|
|
@@ -19,11 +17,52 @@ function fail(message) {
|
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* A build mission may carry a worktree/branch/merge projection; a survey may
|
|
21
|
+
* not, and carries the read fingerprint instead. The shape declares which
|
|
22
|
+
* dimensions its document owns, so this module never compares kinds.
|
|
23
|
+
* @param {Record<string, any>} mission
|
|
24
|
+
* @param {string} where
|
|
25
|
+
* @returns {void}
|
|
24
26
|
*/
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
+
function assertMissionShapeFields(mission, where) {
|
|
28
|
+
if (!isShapeKind(mission.kind)) {
|
|
29
|
+
fail(`mission ${mission.id} has unknown kind ${String(mission.kind)}${where}`)
|
|
30
|
+
}
|
|
31
|
+
const shape = shapeFor(mission.kind)
|
|
32
|
+
if (!shape.fields.worktree) {
|
|
33
|
+
if (mission.worktree !== undefined && mission.worktree !== null) {
|
|
34
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have a worktree record${where}`)
|
|
35
|
+
}
|
|
36
|
+
for (const key of ['branch', 'baseCommit', 'mergedCommit', 'mergedAt']) {
|
|
37
|
+
if (mission[key] !== undefined && mission[key] !== null) {
|
|
38
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have ${key}${where}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (shape.fields.worktree) {
|
|
43
|
+
if (mission.sandbox !== undefined) {
|
|
44
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have a sandbox${where}`)
|
|
45
|
+
}
|
|
46
|
+
} else if (shape.fields.sandbox) {
|
|
47
|
+
// A detached survey sandbox is a recorded path, never a worktree record: it
|
|
48
|
+
// has no branch, base commit or merge projection.
|
|
49
|
+
if (mission.sandbox !== undefined && (typeof mission.sandbox !== 'string' || mission.sandbox.length === 0)) {
|
|
50
|
+
fail(`mission ${mission.id} has an invalid sandbox path${where}`)
|
|
51
|
+
}
|
|
52
|
+
} else if (mission.sandbox !== undefined) {
|
|
53
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have a sandbox${where}`)
|
|
54
|
+
}
|
|
55
|
+
if (shape.fields.readCommit) {
|
|
56
|
+
if (typeof mission.readCommit !== 'string' || mission.readCommit.length === 0) {
|
|
57
|
+
fail(`mission ${mission.id} is a ${shape.kind} and needs a readCommit${where}`)
|
|
58
|
+
}
|
|
59
|
+
if (mission.readDirty === undefined) return
|
|
60
|
+
if (!Array.isArray(mission.readDirty) || mission.readDirty.some((entry) => typeof entry !== 'string')) {
|
|
61
|
+
fail(`mission ${mission.id} has an invalid readDirty fingerprint${where}`)
|
|
62
|
+
}
|
|
63
|
+
} else if (mission.readCommit !== undefined || mission.readDirty !== undefined) {
|
|
64
|
+
fail(`mission ${mission.id} is a ${shape.kind} and may not have readCommit/readDirty${where}`)
|
|
65
|
+
}
|
|
27
66
|
}
|
|
28
67
|
|
|
29
68
|
/**
|
|
@@ -42,6 +81,14 @@ function assertWorktreeRecord(mission, where) {
|
|
|
42
81
|
if (invalid) fail(`mission ${mission.id} has an invalid worktree record${where}`)
|
|
43
82
|
}
|
|
44
83
|
|
|
84
|
+
/**
|
|
85
|
+
* @param {unknown} value
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function isPlainObject(value) {
|
|
89
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
90
|
+
}
|
|
91
|
+
|
|
45
92
|
/**
|
|
46
93
|
* @param {unknown} state
|
|
47
94
|
* @param {string} where
|
|
@@ -56,8 +103,9 @@ export function assertStateSemantics(state, where) {
|
|
|
56
103
|
for (const mission of record.missions) {
|
|
57
104
|
if (missionIds.has(mission.id)) fail(`duplicate mission id ${mission.id}${where}`)
|
|
58
105
|
missionIds.add(mission.id)
|
|
59
|
-
|
|
60
|
-
|
|
106
|
+
const shape = shapeFor(mission.kind)
|
|
107
|
+
if (!shape.statuses.includes(mission.status)) {
|
|
108
|
+
fail(`mission ${mission.id} has unknown status ${mission.status} for a ${shape.kind} mission${where}`)
|
|
61
109
|
}
|
|
62
110
|
if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
|
|
63
111
|
fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
|
|
@@ -65,7 +113,24 @@ export function assertStateSemantics(state, where) {
|
|
|
65
113
|
if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
|
|
66
114
|
fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
|
|
67
115
|
}
|
|
116
|
+
assertMissionShapeFields(mission, where)
|
|
68
117
|
assertWorktreeRecord(mission, where)
|
|
118
|
+
// W3: `blocked` is derived from open blockers, never an independent stop
|
|
119
|
+
// state. A non-terminal mission is blocked exactly when it records a
|
|
120
|
+
// blocker, so the two can never disagree (a paused mission has no blockers;
|
|
121
|
+
// a blocker supersedes a pause). Terminal missions keep blockers as history.
|
|
122
|
+
const blockers = mission.blockers ?? []
|
|
123
|
+
if (!Array.isArray(blockers) || blockers.some((entry) => typeof entry !== 'string')) {
|
|
124
|
+
fail(`mission ${mission.id} has invalid blockers${where}`)
|
|
125
|
+
}
|
|
126
|
+
if (!shape.isTerminal(mission.status)) {
|
|
127
|
+
if (mission.status === 'blocked' && blockers.length === 0) {
|
|
128
|
+
fail(`mission ${mission.id} is blocked but records no blocker; blocked is derived from open blockers${where}`)
|
|
129
|
+
}
|
|
130
|
+
if (mission.status !== 'blocked' && blockers.length > 0) {
|
|
131
|
+
fail(`mission ${mission.id} has open blockers but is ${mission.status}; blocked is derived from open blockers${where}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
69
134
|
}
|
|
70
135
|
if (record.roster !== undefined) {
|
|
71
136
|
const names = new Set()
|
|
@@ -113,5 +178,20 @@ export function assertStateSemantics(state, where) {
|
|
|
113
178
|
}
|
|
114
179
|
}
|
|
115
180
|
}
|
|
181
|
+
// Batches have their own generic lifecycle (#149 W3), not a mission shape. The
|
|
182
|
+
// ledger may only carry canonical statuses; legacy synonyms are normalized by
|
|
183
|
+
// the v5 state migration, never accepted at rest.
|
|
184
|
+
if (record.batches !== undefined) {
|
|
185
|
+
const ids = new Set()
|
|
186
|
+
for (const batch of record.batches) {
|
|
187
|
+
if (!isPlainObject(batch)) fail(`invalid batch record${where}`)
|
|
188
|
+
if (ids.has(batch.id)) fail(`duplicate batch id ${batch.id}${where}`)
|
|
189
|
+
ids.add(batch.id)
|
|
190
|
+
const status = typeof batch.status === 'string' ? batch.status : ''
|
|
191
|
+
if (!BATCH_STATUSES.includes(status)) {
|
|
192
|
+
fail(`batch ${batch.id} has unknown status ${batch.status}${where}`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
116
196
|
return /** @type {TowerState} */ (record)
|
|
117
197
|
}
|
package/domain/state.js
CHANGED
|
@@ -150,7 +150,10 @@ export function validateState(state, statePath) {
|
|
|
150
150
|
*
|
|
151
151
|
* @param {unknown} state
|
|
152
152
|
* @param {string} [statePath]
|
|
153
|
-
* @param {{
|
|
153
|
+
* @param {{
|
|
154
|
+
* migrations?: readonly StateMigration[],
|
|
155
|
+
* legacySurveyRead?: import('./state/migrations.js').LegacySurveyRead | null,
|
|
156
|
+
* }} [options]
|
|
154
157
|
* @returns {{ state: TowerState, from: number, to: number, migrated: MigrationStep[], changed: boolean }}
|
|
155
158
|
*/
|
|
156
159
|
export function prepareState(state, statePath, options = {}) {
|
|
@@ -169,6 +172,7 @@ export function prepareState(state, statePath, options = {}) {
|
|
|
169
172
|
statePath,
|
|
170
173
|
to: info.supportedVersion,
|
|
171
174
|
migrations: options.migrations,
|
|
175
|
+
legacySurveyRead: options.legacySurveyRead ?? null,
|
|
172
176
|
})
|
|
173
177
|
// Validation runs on the *target* document: a shape that was invalid under
|
|
174
178
|
// the old schema (a missing `tasks` array, say) may be valid after a backfill,
|
package/domain/summary.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @ts-check
|
|
3
3
|
import { FINDING_STATUSES } from './mission.js'
|
|
4
4
|
import { completionReportBytes } from './missions/completion.js'
|
|
5
|
+
import { shapeFor } from './shapes/index.js'
|
|
5
6
|
|
|
6
7
|
/** @typedef {import('../types/index.js').Finding} Finding */
|
|
7
8
|
/** @typedef {import('../types/index.js').FindingSummary} FindingSummary */
|
|
@@ -31,7 +32,8 @@ function taskCounts(tasks) {
|
|
|
31
32
|
* @returns {MissionSummary}
|
|
32
33
|
*/
|
|
33
34
|
export function missionSummary(mission) {
|
|
34
|
-
|
|
35
|
+
const shape = shapeFor(mission.kind)
|
|
36
|
+
return /** @type {MissionSummary} */ ({
|
|
35
37
|
id: mission.id,
|
|
36
38
|
title: mission.title,
|
|
37
39
|
kind: mission.kind,
|
|
@@ -39,24 +41,16 @@ export function missionSummary(mission) {
|
|
|
39
41
|
scope: mission.scope,
|
|
40
42
|
deps: mission.deps,
|
|
41
43
|
owner: mission.owner ?? null,
|
|
42
|
-
|
|
43
|
-
? {
|
|
44
|
-
path: mission.worktree.path,
|
|
45
|
-
branch: mission.worktree.branch,
|
|
46
|
-
baseCommit: mission.worktree.baseCommit,
|
|
47
|
-
}
|
|
48
|
-
: null,
|
|
44
|
+
...shape.summary(mission),
|
|
49
45
|
blockers: mission.blockers ?? [],
|
|
50
46
|
tasks: taskCounts(mission.tasks ?? []),
|
|
51
47
|
scopeGeneration: mission.scopeGeneration ?? 1,
|
|
52
48
|
ownerGeneration: mission.ownerGeneration ?? 0,
|
|
53
49
|
pauseCause: mission.pauseCause ?? null,
|
|
54
50
|
reviewPolicy: mission.reviewPolicy ?? 'required-clean',
|
|
55
|
-
mergedCommit: mission.mergedCommit ?? null,
|
|
56
|
-
mergedAt: mission.mergedAt ?? null,
|
|
57
51
|
reportBytes: completionReportBytes(mission.completionReport),
|
|
58
52
|
updatedAt: mission.updatedAt,
|
|
59
|
-
}
|
|
53
|
+
})
|
|
60
54
|
}
|
|
61
55
|
|
|
62
56
|
/**
|
package/domain/vocabulary.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
PAUSE_CAUSES,
|
|
14
14
|
} from './mission.js'
|
|
15
15
|
import { ROSTER_INACTIVE_STATES } from './roster.js'
|
|
16
|
+
import { SHAPE_KINDS } from './shapes/index.js'
|
|
16
17
|
|
|
17
18
|
export {
|
|
18
19
|
FINDING_SEVERITIES,
|
|
@@ -24,44 +25,25 @@ export {
|
|
|
24
25
|
ROSTER_INACTIVE_STATES,
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
// Mission inputs accepted by tower_plan.
|
|
28
|
+
// Mission inputs accepted by tower_plan: the registry's kinds, in order.
|
|
28
29
|
/** @type {readonly string[]} */
|
|
29
|
-
export const MISSION_KINDS =
|
|
30
|
+
export const MISSION_KINDS = SHAPE_KINDS
|
|
30
31
|
/** @type {readonly string[]} */
|
|
31
32
|
export const REVIEW_POLICIES = ['required-clean', 'none']
|
|
32
33
|
|
|
33
34
|
// Roster actors (the caller resolved by actorFor) and createable agent roles.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** @param {string | null | undefined} role */
|
|
47
|
-
export function isTowerRole(role) {
|
|
48
|
-
return role === ROLE.tower
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** @param {string | null | undefined} role */
|
|
52
|
-
export function isWorkerRole(role) {
|
|
53
|
-
return role === ROLE.worker
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** @param {string | null | undefined} role */
|
|
57
|
-
export function isReviewerRole(role) {
|
|
58
|
-
return role === ROLE.reviewer
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** @param {string | null | undefined} role */
|
|
62
|
-
export function isUnknownRole(role) {
|
|
63
|
-
return role === ROLE.unknown
|
|
64
|
-
}
|
|
35
|
+
// One definition lives in domain/roles.js so roster/vocabulary have no cycle.
|
|
36
|
+
import { ROLE } from './roles.js'
|
|
37
|
+
export {
|
|
38
|
+
ACTOR_ROLES,
|
|
39
|
+
DEFAULT_ROLE,
|
|
40
|
+
ROLE,
|
|
41
|
+
ROLES,
|
|
42
|
+
isReviewerRole,
|
|
43
|
+
isTowerRole,
|
|
44
|
+
isUnknownRole,
|
|
45
|
+
isWorkerRole,
|
|
46
|
+
} from './roles.js'
|
|
65
47
|
|
|
66
48
|
// Inbox recipients: a roster name, the tower, or a broadcast.
|
|
67
49
|
export const RECIPIENT = Object.freeze({ tower: ROLE.tower, all: 'all' })
|
|
@@ -25,6 +25,7 @@ export const TOWER_COMPONENT_IDS = Object.freeze([
|
|
|
25
25
|
'tower-archive',
|
|
26
26
|
'tower-hmr',
|
|
27
27
|
'preset-tower-reviewer',
|
|
28
|
+
'tower-survey',
|
|
28
29
|
])
|
|
29
30
|
|
|
30
31
|
/** Tool ids each code component owns; the preset row owns no tool. */
|
|
@@ -38,6 +39,7 @@ export const COMPONENT_TOOLS = Object.freeze({
|
|
|
38
39
|
'tower-archive': Object.freeze([]),
|
|
39
40
|
'tower-hmr': Object.freeze(['tower_reload']),
|
|
40
41
|
'preset-tower-reviewer': Object.freeze([]),
|
|
42
|
+
'tower-survey': Object.freeze([]),
|
|
41
43
|
})
|
|
42
44
|
|
|
43
45
|
/** id -> number of live registrations (redelivery/HMR can overlap briefly). */
|
|
@@ -86,23 +88,37 @@ export function disabledReason(id) {
|
|
|
86
88
|
return `disabled by component ${id}`
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Availability of one declarative preset row from the optional agent-presets
|
|
93
|
+
* service. Preset rows (`preset-tower-reviewer`, `tower-survey`) mount an agent
|
|
94
|
+
* preset instead of a code entry point, so the process-local component registry
|
|
95
|
+
* cannot see them.
|
|
96
|
+
* @param {any} ctx
|
|
97
|
+
* @param {string} presetId the agent-preset id the row registers
|
|
98
|
+
* @returns {Promise<boolean | null>} null when the service cannot answer
|
|
99
|
+
*/
|
|
100
|
+
export async function presetAvailable(ctx, presetId) {
|
|
91
101
|
const service = ctx?.get?.('agentPresets')
|
|
92
102
|
if (!service || typeof service.list !== 'function') return null
|
|
93
103
|
try {
|
|
94
104
|
const presets = await service.list()
|
|
95
|
-
return Array.isArray(presets) && presets.some((entry) => String(entry?.id) ===
|
|
105
|
+
return Array.isArray(presets) && presets.some((entry) => String(entry?.id) === presetId)
|
|
96
106
|
} catch {
|
|
97
107
|
return null
|
|
98
108
|
}
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
/** Component rows whose switch is a declarative agent-preset registration. */
|
|
112
|
+
export const PRESET_ROW_COMPONENTS = Object.freeze({
|
|
113
|
+
'preset-tower-reviewer': 'tower-reviewer',
|
|
114
|
+
'tower-survey': 'tower-survey',
|
|
115
|
+
})
|
|
116
|
+
|
|
101
117
|
/**
|
|
102
|
-
* Report every optional component as enabled or disabled.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
118
|
+
* Report every optional component as enabled or disabled. A preset row is not a
|
|
119
|
+
* code entry point: its availability is read from `agentPresets` and reported as
|
|
120
|
+
* unknown (omitted) when that service is absent, exactly as the doctor's preset
|
|
121
|
+
* check does.
|
|
106
122
|
*
|
|
107
123
|
* @param {any} ctx
|
|
108
124
|
* @returns {Promise<{ enabled: string[], disabled: Array<{ id: string, reason: string }> }>}
|
|
@@ -111,8 +127,8 @@ export async function componentReport(ctx) {
|
|
|
111
127
|
const enabled = []
|
|
112
128
|
const disabled = []
|
|
113
129
|
for (const id of TOWER_COMPONENT_IDS) {
|
|
114
|
-
if (id
|
|
115
|
-
const available = await
|
|
130
|
+
if (PRESET_ROW_COMPONENTS[id]) {
|
|
131
|
+
const available = await presetAvailable(ctx, PRESET_ROW_COMPONENTS[id])
|
|
116
132
|
if (available === false) disabled.push({ id, reason: disabledReason(id) })
|
|
117
133
|
else if (available === true) enabled.push(id)
|
|
118
134
|
continue
|
package/host/config.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveTowerConfig,
|
|
25
25
|
} from '../domain/config/schema.js'
|
|
26
26
|
import { TowerToolError } from '../domain/errors.js'
|
|
27
|
+
import { materializeVolatile, resolveReviewPolicy, reviewUnavailable } from '../domain/integration/review.js'
|
|
27
28
|
|
|
28
29
|
export { Config, DEFAULT_DENY_TOOLS, resolveTowerConfig }
|
|
29
30
|
|
|
@@ -31,11 +32,16 @@ const REPO_CONFIG_MAX_BYTES = 1024 * 1024
|
|
|
31
32
|
|
|
32
33
|
// The last raw profile config `apply()` received; repository config is merged
|
|
33
34
|
// over it so a repo can tune spawn/merge/review without editing the profile.
|
|
35
|
+
//
|
|
36
|
+
// #M40: this must stay the *raw* object. A `.volatile()` field arrives as a
|
|
37
|
+
// live reference that Cordis updates in place on a settings write, and cloning
|
|
38
|
+
// (JSON) would freeze a copy and hide the new value from runtimeConfig(). Only
|
|
39
|
+
// readers that need plain data (profileConfig) materialize it.
|
|
34
40
|
let profileRaw = {}
|
|
35
41
|
|
|
36
42
|
export function configureRuntime(config) {
|
|
37
|
-
profileRaw =
|
|
38
|
-
return resolveTowerConfig(
|
|
43
|
+
profileRaw = config ?? {}
|
|
44
|
+
return resolveTowerConfig(profileRaw)
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
export function runtimeConfig() {
|
|
@@ -44,7 +50,7 @@ export function runtimeConfig() {
|
|
|
44
50
|
|
|
45
51
|
/** Raw profile config before defaults are materialized (for doctor warnings). */
|
|
46
52
|
export function profileConfig() {
|
|
47
|
-
return clone(profileRaw) ?? {}
|
|
53
|
+
return clone(materializeVolatile(profileRaw)) ?? {}
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export function repoConfigPath(commonDir) {
|
|
@@ -92,6 +98,10 @@ export async function readRepoConfig(commonDir) {
|
|
|
92
98
|
// Validate value types without letting the schema's defaults win over the
|
|
93
99
|
// profile layer: merge into an empty raw object and discard the result.
|
|
94
100
|
Config(parsed)
|
|
101
|
+
// #M39: the schema accepts both review keys; only the resolver knows that an
|
|
102
|
+
// explicit `requireReview:true` next to a conflicting `review.mode` is a
|
|
103
|
+
// contradiction. Reject it here, before the layer can be merged.
|
|
104
|
+
resolveReviewPolicy(parsed.review)
|
|
95
105
|
} catch (error) {
|
|
96
106
|
throw new TowerToolError('config-invalid', `repo config at ${file} is invalid: ${String(error?.message ?? error)}`)
|
|
97
107
|
}
|
|
@@ -130,6 +140,33 @@ export async function resolveRepoConfig(repo) {
|
|
|
130
140
|
if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
|
|
131
141
|
merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
|
|
132
142
|
}
|
|
143
|
+
// #M39: the review policy is resolved per layer instead of deep-merging two
|
|
144
|
+
// resolved shapes. A resolved base always carries both `review.mode` and its
|
|
145
|
+
// mirrored alias, so a repository layer that sets only one of the two would
|
|
146
|
+
// otherwise pair a stale inherited `mode` with the new alias. The repository
|
|
147
|
+
// layer wins as a whole when it names either key; a layer that names neither
|
|
148
|
+
// inherits the profile policy untouched.
|
|
149
|
+
const repoReview = repoLayer.review
|
|
150
|
+
if (repoReview && typeof repoReview === 'object' && !Array.isArray(repoReview)) {
|
|
151
|
+
const explicit = repoReview.mode !== undefined || repoReview.requireReview !== undefined
|
|
152
|
+
if (explicit) {
|
|
153
|
+
const repoPolicy = resolveReviewPolicy(repoReview)
|
|
154
|
+
if (reviewUnavailable(base) && repoPolicy.mode !== 'off') {
|
|
155
|
+
// The tool surface is fixed when the plugin applies: a profile with
|
|
156
|
+
// review off never registered tower_review, so a repository may relax
|
|
157
|
+
// review further but cannot re-enable a tool that does not exist.
|
|
158
|
+
throw new TowerToolError(
|
|
159
|
+
'config-invalid',
|
|
160
|
+
`review.mode is off in the profile, so tower_review is not registered; repository config cannot set review.mode:${repoPolicy.mode}. Set review.mode in the profile instead.`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
merged.review = {
|
|
164
|
+
...(merged.review ?? {}),
|
|
165
|
+
mode: repoPolicy.mode,
|
|
166
|
+
requireReview: repoPolicy.mode === 'required',
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
133
170
|
merged.section = merged.mode?.promptSection ?? merged.section ?? ''
|
|
134
171
|
return {
|
|
135
172
|
...merged,
|
package/host/doctor.js
CHANGED
|
@@ -12,7 +12,9 @@ import { inspectStateVersion } from '../domain/state.js'
|
|
|
12
12
|
import { SCHEMA_VERSION } from '../domain/mission.js'
|
|
13
13
|
import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
|
|
14
14
|
import { comparisonMode } from '../domain/scope.js'
|
|
15
|
+
import { hasTestReceipts } from '../domain/evidence.js'
|
|
15
16
|
import { configSurface } from '../domain/config/schema.js'
|
|
17
|
+
import { reviewPolicyView, reviewRequired } from '../domain/integration/review.js'
|
|
16
18
|
import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
|
|
17
19
|
import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
|
|
18
20
|
import { profileConfig } from './config.js'
|
|
@@ -242,8 +244,11 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
242
244
|
const config = tower.config ?? {}
|
|
243
245
|
const profile = profileConfig()
|
|
244
246
|
const staleDefaults = []
|
|
245
|
-
|
|
246
|
-
|
|
247
|
+
// #M39: `review.mode` replaces the deprecated boolean. A profile that names
|
|
248
|
+
// neither key relies on the default (optional), so say so rather than letting
|
|
249
|
+
// a reader assume review is enforced.
|
|
250
|
+
if (profile.review && profile.review.mode === undefined && profile.review.requireReview === undefined) {
|
|
251
|
+
staleDefaults.push('review.mode defaults to optional; review.requireReview is a deprecated alias')
|
|
247
252
|
}
|
|
248
253
|
if (profile.mode && profile.mode.allowAgentControl === undefined) {
|
|
249
254
|
staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
|
|
@@ -261,6 +266,11 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
261
266
|
'optional: add <git-common-dir>/dsh-tower/config.json',
|
|
262
267
|
),
|
|
263
268
|
)
|
|
269
|
+
// #M39: the resolved review mode is its own diagnostic fact. `off` is a
|
|
270
|
+
// supported, intentional posture (not a degradation), so it is a pass whose
|
|
271
|
+
// detail says exactly what is unavailable.
|
|
272
|
+
const reviewPolicy = reviewPolicyView(config)
|
|
273
|
+
checks.push(check('review', 'pass', reviewPolicy.detail))
|
|
264
274
|
// #84: report the concept-budget surface instead of implying it. The counts
|
|
265
275
|
// are the live schema/tool surface; the date is the committed baseline, so a
|
|
266
276
|
// reader sees both the number and the decision it was frozen at.
|
|
@@ -288,7 +298,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
288
298
|
),
|
|
289
299
|
)
|
|
290
300
|
const presets = ctx.get?.('agentPresets')
|
|
291
|
-
const reviewerRequired = config
|
|
301
|
+
const reviewerRequired = reviewRequired(config)
|
|
292
302
|
if (presets && typeof presets.list === 'function') {
|
|
293
303
|
try {
|
|
294
304
|
const available = (await presets.list()).map((entry) => String(entry.id))
|
|
@@ -296,9 +306,10 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
296
306
|
const reviewer = config.spawn?.reviewerPreset ?? 'tower-reviewer'
|
|
297
307
|
const required = reviewerRequired ? [worker, reviewer] : [worker]
|
|
298
308
|
const missing = required.filter((id) => !available.includes(id))
|
|
299
|
-
// #121: `preset-tower-reviewer` is a conditional component. Its off
|
|
300
|
-
// is supported while review
|
|
301
|
-
// a fail, because no independent review can
|
|
309
|
+
// #121/#M39: `preset-tower-reviewer` is a conditional component. Its off
|
|
310
|
+
// state is supported while review is off or optional; with
|
|
311
|
+
// `review.mode:required` it is a fail, because no independent review can
|
|
312
|
+
// be dispatched.
|
|
302
313
|
if (missing.length === 0) {
|
|
303
314
|
const reviewerNote = available.includes(reviewer)
|
|
304
315
|
? `reviewer=${reviewer}`
|
|
@@ -309,7 +320,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
309
320
|
check(
|
|
310
321
|
'presets',
|
|
311
322
|
'fail',
|
|
312
|
-
`missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.
|
|
323
|
+
`missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.mode is required, so preset-tower-reviewer may not be off)' : ''}`,
|
|
313
324
|
'enable the Tower preset component and retry',
|
|
314
325
|
),
|
|
315
326
|
)
|
|
@@ -444,7 +455,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
444
455
|
: check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
|
|
445
456
|
)
|
|
446
457
|
|
|
447
|
-
const testConfigured = state.missions?.some((mission) => mission
|
|
458
|
+
const testConfigured = state.missions?.some((mission) => hasTestReceipts(mission))
|
|
448
459
|
// #136: the `tower-tests` component row is the only switch (there is no
|
|
449
460
|
// tests.enabled), so this check reads the registry and nothing else.
|
|
450
461
|
const testsDisabled = components.disabled.some((entry) => entry.id === 'tower-tests')
|
|
@@ -596,6 +607,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
596
607
|
// reports (from #89) and archive (from #73) are both kept
|
|
597
608
|
reports: reportsReport,
|
|
598
609
|
surface: surfaceReport,
|
|
610
|
+
review: reviewPolicy,
|
|
599
611
|
components,
|
|
600
612
|
archive: {
|
|
601
613
|
missions: state ? state.missions.length : null,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Tower detached-survey preset declaration (#148 W2): the sandbox preset's own
|
|
2
|
+
// Plugins-page label. This is the writable twin of `host/preset-survey.js`: the
|
|
3
|
+
// row config stays in `cordis.patch.yml` and this module forwards it to the
|
|
4
|
+
// injected `agentPresets` service, which owns mount/unmount. A Tower-owned
|
|
5
|
+
// specifier gives the row a distinct locale
|
|
6
|
+
// (`dsh-tower/preset-survey-sandbox/locale/<lang>.json`).
|
|
7
|
+
export const name = 'dsh-tower/preset-survey-sandbox'
|
|
8
|
+
|
|
9
|
+
export const inject = ['agentPresets']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register the detached-survey preset with the profile's preset registry.
|
|
13
|
+
* @param {{ agentPresets: { register: (config: any) => Promise<() => Promise<void> | void> } }} ctx
|
|
14
|
+
* @param {any} config the `cordis.patch.yml` row config (id/name/description/order/plugins)
|
|
15
|
+
* @returns {Promise<() => Promise<void> | void>} the registry's unregister disposer
|
|
16
|
+
*/
|
|
17
|
+
export async function apply(ctx, config) {
|
|
18
|
+
return ctx.agentPresets.register(config)
|
|
19
|
+
}
|