dsh-tower 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +2 -1
- package/README.md +6 -1
- 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 +97 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +25 -24
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +29 -1
- package/cordis.patch.yml +50 -0
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +6 -4
- package/docs/guide/install-upgrade-migration.md +26 -0
- package/docs/operations/runbook.md +1 -1
- package/docs/reference/blockers.md +8 -4
- package/docs/reference/component-surface.md +7 -2
- package/docs/reference/config.generated.md +6 -4
- package/docs/reference/config.md +1 -2
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +33 -1
- package/docs/reference/testing.md +4 -2
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +2 -2
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +1 -2
- package/docs/zh/reference/testing.md +3 -2
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/config/schema.js +47 -17
- package/domain/contracts/outputs.js +0 -1
- package/domain/contracts/tool-inputs/missions.js +9 -1
- package/domain/evidence.js +283 -0
- package/domain/gate.js +11 -31
- 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/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/doctor.js +2 -1
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +8 -3
- package/host/tool-registry.js +14 -12
- 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 +13 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +2 -0
- package/scripts/component-surface.json +68 -20
- 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 +21 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +68 -4
- 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/spawn.js +47 -49
- package/tools/test.js +2 -1
- package/types/index.d.ts +66 -5
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The one shape registry (#149). A *shape* is the contract a mission kind
|
|
2
|
+
// carries: its status vocabulary and transitions, what it provisions, how it
|
|
3
|
+
// completes, how dependencies are satisfied, and what its briefing and summary
|
|
4
|
+
// say. Generic layers resolve a shape once through `shapeFor()` and never
|
|
5
|
+
// compare `mission.kind`.
|
|
6
|
+
//
|
|
7
|
+
// `isTerminal(kind, status)` and `canTransition(kind, from, to)` are the only
|
|
8
|
+
// entry points for status policy; the old global `TERMINAL_STATUSES` and
|
|
9
|
+
// `TRANSITIONS` tables are gone.
|
|
10
|
+
// @ts-check
|
|
11
|
+
import { TowerToolError } from '../errors.js'
|
|
12
|
+
import { buildShape } from './build.js'
|
|
13
|
+
import { surveyShape } from './survey.js'
|
|
14
|
+
|
|
15
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
16
|
+
|
|
17
|
+
/** @type {Readonly<Record<string, typeof buildShape | typeof surveyShape>>} */
|
|
18
|
+
export const SHAPES = Object.freeze({
|
|
19
|
+
build: buildShape,
|
|
20
|
+
survey: surveyShape,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/** Ordered kind vocabulary; the first kind is the default for absent kinds. */
|
|
24
|
+
/** @type {readonly string[]} */
|
|
25
|
+
export const SHAPE_KINDS = Object.freeze(Object.keys(SHAPES))
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a mission kind to its shape.
|
|
29
|
+
*
|
|
30
|
+
* A document written before schema v4 may omit `kind`; the v3 -> v4 migration
|
|
31
|
+
* stamps `build`, and pure helpers that tolerate partial mission snapshots still
|
|
32
|
+
* resolve an absent kind as the build shape. An unknown non-empty kind is never
|
|
33
|
+
* guessed.
|
|
34
|
+
* @param {string | null | undefined} kind
|
|
35
|
+
* @returns {typeof buildShape | typeof surveyShape}
|
|
36
|
+
*/
|
|
37
|
+
export function shapeFor(kind) {
|
|
38
|
+
if (kind === undefined || kind === null || kind === '') return SHAPES.build
|
|
39
|
+
const shape = SHAPES[kind]
|
|
40
|
+
if (!shape) {
|
|
41
|
+
throw new TowerToolError('invalid-argument', `unknown mission kind "${kind}"; expected one of ${SHAPE_KINDS.join('|')}`)
|
|
42
|
+
}
|
|
43
|
+
return shape
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {unknown} kind
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function isShapeKind(kind) {
|
|
51
|
+
return typeof kind === 'string' && Object.hasOwn(SHAPES, kind)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The only terminality entry point.
|
|
56
|
+
* @param {string | null | undefined} kind
|
|
57
|
+
* @param {string} status
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function isTerminal(kind, status) {
|
|
61
|
+
return shapeFor(kind).isTerminal(status)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The only transition entry point.
|
|
66
|
+
* @param {string | null | undefined} kind
|
|
67
|
+
* @param {string} from
|
|
68
|
+
* @param {string} to
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function canTransition(kind, from, to) {
|
|
72
|
+
return shapeFor(kind).canTransition(from, to)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string | null | undefined} kind
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
export function canMerge(kind) {
|
|
80
|
+
return shapeFor(kind).canMerge
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string | null | undefined} kind
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
export function canRebase(kind) {
|
|
88
|
+
return shapeFor(kind).canRebase
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve how a mission of this kind is provisioned. A shape may resolve the
|
|
93
|
+
* placement from tuning config (#148 W2: a survey is `shared` by default and
|
|
94
|
+
* `detached` with `survey.sandbox: 'detached'`); a shape without a resolver keeps
|
|
95
|
+
* its static placement. When a mission is supplied, a placement already recorded
|
|
96
|
+
* on it (a provisioned detached sandbox) wins over config.
|
|
97
|
+
* @param {string | null | undefined} kind
|
|
98
|
+
* @param {import('../../types/index.js').TowerConfig} [config]
|
|
99
|
+
* @param {import('../../types/index.js').Mission | null | undefined} [mission]
|
|
100
|
+
* @returns {'branch-worktree' | 'shared' | 'detached'}
|
|
101
|
+
*/
|
|
102
|
+
export function provisioningFor(kind, config = {}, mission = null) {
|
|
103
|
+
const shape = shapeFor(kind)
|
|
104
|
+
if ('resolveProvisioning' in shape && typeof shape.resolveProvisioning === 'function') {
|
|
105
|
+
return shape.resolveProvisioning(mission, config)
|
|
106
|
+
}
|
|
107
|
+
return shape.provisioning
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the agent preset that executes this kind in the configured placement.
|
|
112
|
+
* A build keeps its `spawn.defaultPreset`; a survey switches between the
|
|
113
|
+
* read-only preset and the sandbox preset so the allow-list follows the
|
|
114
|
+
* resolved placement rather than a hard-coded kind.
|
|
115
|
+
* @param {string | null | undefined} kind
|
|
116
|
+
* @param {import('../../types/index.js').TowerConfig} [config]
|
|
117
|
+
* @param {import('../../types/index.js').Mission | null | undefined} [mission]
|
|
118
|
+
* @returns {string | null}
|
|
119
|
+
*/
|
|
120
|
+
export function defaultPresetFor(kind, config = {}, mission = null) {
|
|
121
|
+
const shape = shapeFor(kind)
|
|
122
|
+
if ('resolvePreset' in shape && typeof shape.resolvePreset === 'function') {
|
|
123
|
+
return shape.resolvePreset(mission, config)
|
|
124
|
+
}
|
|
125
|
+
return 'defaultPreset' in shape.spawn ? shape.spawn.defaultPreset : null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Refuse branch-shaped actions for a shape that has no branch. The message tells
|
|
130
|
+
* the caller what to do instead, so the refusal is actionable rather than a bare
|
|
131
|
+
* "invalid transition".
|
|
132
|
+
* @param {Mission} mission
|
|
133
|
+
* @param {'merge' | 'rebase' | 'preflight' | 'diff'} action
|
|
134
|
+
* @returns {void}
|
|
135
|
+
*/
|
|
136
|
+
export function assertActionSupported(mission, action) {
|
|
137
|
+
const shape = shapeFor(mission.kind)
|
|
138
|
+
// Derived from the shape, never from a hard-coded kind: a new shape that has
|
|
139
|
+
// no branch gets the same refusals without touching this function.
|
|
140
|
+
const evidence = shape.fields.readCommit
|
|
141
|
+
? `readCommit ${String(mission.readCommit ?? '').slice(0, 12)}`
|
|
142
|
+
: 'recorded evidence'
|
|
143
|
+
if (action === 'merge' && !shape.canMerge) {
|
|
144
|
+
throw new TowerToolError(
|
|
145
|
+
'invalid-transition',
|
|
146
|
+
`mission ${mission.id} is a ${shape.kind}: it has no branch to merge and is never merged; a completed ${shape.kind} already satisfies its dependents`,
|
|
147
|
+
{ nextAction: `use tower_complete to finish the ${shape.kind}; its ${evidence} is the evidence` },
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
if (action === 'rebase' && !shape.canRebase) {
|
|
151
|
+
throw new TowerToolError(
|
|
152
|
+
'invalid-transition',
|
|
153
|
+
`mission ${mission.id} is a ${shape.kind}: it has no branch to rebase; a ${shape.kind} does not move`,
|
|
154
|
+
{ nextAction: `re-plan the ${shape.kind} if the base moved; the recorded ${evidence} is immutable` },
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
if (action === 'preflight' && !shape.canMerge) {
|
|
158
|
+
throw new TowerToolError(
|
|
159
|
+
'invalid-transition',
|
|
160
|
+
`mission ${mission.id} is a ${shape.kind}: it has no merge gate; tower_complete checks HEAD, tracked files and zero commits`,
|
|
161
|
+
{ nextAction: `use tower_complete to finish the ${shape.kind}; ${shape.kind}s are not preflighted or merged` },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
if (action === 'diff' && !shape.canMerge) {
|
|
165
|
+
throw new TowerToolError(
|
|
166
|
+
'invalid-transition',
|
|
167
|
+
`mission ${mission.id} is a ${shape.kind}: it has no branch diff; its ${evidence} is the evidence`,
|
|
168
|
+
{ nextAction: `inspect the recorded ${evidence} directly, or the ${shape.kind} result recorded at completion` },
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// The survey shape (#148/#149): a read of one commit, not a write of a branch.
|
|
2
|
+
//
|
|
3
|
+
// A survey has no worktree, no branch and no commit of its own. By default it
|
|
4
|
+
// runs in the shared checkout with a read-only preset allow-list. With
|
|
5
|
+
// `survey.sandbox: 'detached'` the read moves into a disposable detached git
|
|
6
|
+
// worktree at the recorded commit (#148 W2): the sandbox preset may write and
|
|
7
|
+
// run commands there, but the sandbox has no branch, never merges, is removed
|
|
8
|
+
// when the mission completes or is abandoned, and must still end at the
|
|
9
|
+
// recorded commit with zero commits. Both placements record the commit read
|
|
10
|
+
// plus the tracked-status fingerprint captured at plan time; only the shared
|
|
11
|
+
// placement enforces the fingerprint as a completion invariant.
|
|
12
|
+
// @ts-check
|
|
13
|
+
import { TowerToolError } from '../errors.js'
|
|
14
|
+
import { hasCheckEvidence } from '../evidence.js'
|
|
15
|
+
|
|
16
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
17
|
+
/** @typedef {import('../../types/index.js').Blocker} Blocker */
|
|
18
|
+
/** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
|
|
19
|
+
|
|
20
|
+
/** @type {readonly string[]} */
|
|
21
|
+
export const SURVEY_STATUSES = Object.freeze(['planned', 'active', 'completed', 'blocked', 'paused', 'abandoned'])
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The evidence this shape accepts (#149 W4). A survey has no branch and no
|
|
25
|
+
* tree tip, so a discovery receipt cannot appertain; its completion checks are
|
|
26
|
+
* still accepted through the same one reader.
|
|
27
|
+
* @type {import('../evidence.js').EvidenceContract}
|
|
28
|
+
*/
|
|
29
|
+
export const EVIDENCE = Object.freeze({
|
|
30
|
+
completionChecks: true,
|
|
31
|
+
testReceipts: /** @type {const} */ (false),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The survey shape's path reach (#149 W4): a survey owns no paths. Its scope is
|
|
36
|
+
* empty by construction and any changed path is a fence violation; the
|
|
37
|
+
* placement (shared read-only checkout or detached disposable sandbox) changes
|
|
38
|
+
* where it runs, not what it may change.
|
|
39
|
+
* @type {{ ownsPaths: boolean, fence: 'scope' | 'read-only' }}
|
|
40
|
+
*/
|
|
41
|
+
export const REACH = Object.freeze({
|
|
42
|
+
ownsPaths: false,
|
|
43
|
+
fence: /** @type {const} */ ('read-only'),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A survey has no `merged` row or column: it is never merged, only completed.
|
|
48
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
49
|
+
*/
|
|
50
|
+
export const SURVEY_TRANSITIONS = Object.freeze({
|
|
51
|
+
planned: Object.freeze(['active', 'blocked', 'abandoned']),
|
|
52
|
+
active: Object.freeze(['completed', 'blocked', 'paused', 'abandoned']),
|
|
53
|
+
// W3: `blocked` is derived from open blockers; clearing them is the only way
|
|
54
|
+
// out, and a blocker always wins over a pause (hence paused -> blocked).
|
|
55
|
+
blocked: Object.freeze(['active', 'abandoned']),
|
|
56
|
+
paused: Object.freeze(['active', 'blocked', 'abandoned']),
|
|
57
|
+
completed: Object.freeze([]),
|
|
58
|
+
abandoned: Object.freeze([]),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} status
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function isTerminal(status) {
|
|
66
|
+
return status === 'completed' || status === 'abandoned'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} from
|
|
71
|
+
* @param {string} to
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function canTransition(from, to) {
|
|
75
|
+
return (SURVEY_TRANSITIONS[from] ?? []).includes(to)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Survey planning records what is being read, not what will be written: the
|
|
80
|
+
* commit and the tracked-status fingerprint at plan time. Scope is empty.
|
|
81
|
+
* @param {{ readCommit?: unknown, readDirty?: unknown }} context
|
|
82
|
+
* @returns {{ scope: string[], readCommit: string, readDirty: string[] }}
|
|
83
|
+
*/
|
|
84
|
+
export function planFields(context) {
|
|
85
|
+
const readCommit = context.readCommit
|
|
86
|
+
if (typeof readCommit !== 'string' || readCommit.length === 0) {
|
|
87
|
+
throw new TowerToolError('invalid-argument', 'a survey needs the commit it reads (state.base did not resolve)', {
|
|
88
|
+
nextAction: 'run tower_init so the base branch resolves to a commit, then plan the survey again',
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
const readDirty = Array.isArray(context.readDirty)
|
|
92
|
+
? context.readDirty.filter((entry) => typeof entry === 'string')
|
|
93
|
+
: []
|
|
94
|
+
return { scope: [], readCommit, readDirty }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Completion invariants for a survey that reads the shared checkout: zero
|
|
99
|
+
* commits, same HEAD, same tracked files. The app layer supplies the observed
|
|
100
|
+
* primitives.
|
|
101
|
+
* @param {{ mission: Mission, head: string, readCommit: string, trackedChanged: string[] }} context
|
|
102
|
+
* @returns {{ commits: number }}
|
|
103
|
+
*/
|
|
104
|
+
export function checkCompletion({ mission, head, readCommit, trackedChanged }) {
|
|
105
|
+
if (head !== readCommit) {
|
|
106
|
+
throw new TowerToolError(
|
|
107
|
+
'invalid-argument',
|
|
108
|
+
`survey ${mission.id} moved HEAD from ${String(readCommit).slice(0, 12)} to ${String(head).slice(0, 12)}; a survey reads one commit and makes no commits`,
|
|
109
|
+
{ nextAction: 'reset the shared checkout to the recorded readCommit; do not commit survey work' },
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
if (trackedChanged.length > 0) {
|
|
113
|
+
throw new TowerToolError(
|
|
114
|
+
'invalid-argument',
|
|
115
|
+
`survey ${mission.id} changed tracked files: ${trackedChanged.join(', ')}; a survey is read-only`,
|
|
116
|
+
{ nextAction: 'restore the shared checkout tracked files to the status captured at plan time' },
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return { commits: 0 }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Completion invariants for a detached survey sandbox: the disposable checkout
|
|
124
|
+
* is allowed to contain write/shell side effects, but it may not commit and it
|
|
125
|
+
* must still be at the recorded commit. The sandbox itself is removed after a
|
|
126
|
+
* successful completion.
|
|
127
|
+
* @param {{ mission: Mission, head: string, readCommit: string, commits: number, sandbox: string }} context
|
|
128
|
+
* @returns {{ commits: number }}
|
|
129
|
+
*/
|
|
130
|
+
export function checkSandboxCompletion({ mission, head, readCommit, commits, sandbox }) {
|
|
131
|
+
if (!sandbox || sandbox !== mission.sandbox) {
|
|
132
|
+
throw new TowerToolError(
|
|
133
|
+
'invalid-argument',
|
|
134
|
+
`detached survey ${mission.id} has no recorded sandbox; re-provision the survey or abandon it`,
|
|
135
|
+
{ nextAction: 'run tower_spawn to create the detached sandbox again, then complete' },
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
if (commits !== 0) {
|
|
139
|
+
throw new TowerToolError(
|
|
140
|
+
'invalid-argument',
|
|
141
|
+
`survey ${mission.id} made ${commits} commit(s) in its detached sandbox; a survey reads one commit and records no commit of its own`,
|
|
142
|
+
{ nextAction: `reset --hard ${String(readCommit).slice(0, 12)} in ${sandbox}; the sandbox is disposable but its HEAD must stay at the recorded commit` },
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if (head !== readCommit) {
|
|
146
|
+
throw new TowerToolError(
|
|
147
|
+
'invalid-argument',
|
|
148
|
+
`survey ${mission.id} moved the detached sandbox HEAD from ${String(readCommit).slice(0, 12)} to ${String(head).slice(0, 12)}; a survey reads one commit and makes no commits`,
|
|
149
|
+
{ nextAction: `reset --hard ${String(readCommit).slice(0, 12)} in ${sandbox}; the sandbox is removed on completion` },
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
return { commits: 0 }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve the survey placement (#148 W2). The shape owns the decision so
|
|
157
|
+
* generic layers never compare `mission.kind` or read the raw key.
|
|
158
|
+
*
|
|
159
|
+
* A sandbox already recorded on the mission is authoritative: provisioned
|
|
160
|
+
* state must win over current config, otherwise flipping `survey.sandbox`
|
|
161
|
+
* between provision and completion would silently move the mission to the
|
|
162
|
+
* shared checkout (or make the completion invariants disagree with the
|
|
163
|
+
* checkout that actually exists). Config only decides initial provisioning.
|
|
164
|
+
* @param {Mission | null | undefined} [mission]
|
|
165
|
+
* @param {TowerConfig} [config]
|
|
166
|
+
* @returns {'shared' | 'detached'}
|
|
167
|
+
*/
|
|
168
|
+
export function resolveProvisioning(mission, config = /** @type {TowerConfig} */ ({})) {
|
|
169
|
+
const recorded = mission && typeof mission.sandbox === 'string' ? mission.sandbox.trim() : ''
|
|
170
|
+
if (recorded.length > 0) return 'detached'
|
|
171
|
+
return config.survey?.sandbox === 'detached' ? 'detached' : 'shared'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The preset that executes a survey in the resolved placement. Shared surveys
|
|
176
|
+
* stay strictly read-only; a detached sandbox gets a preset that may write and
|
|
177
|
+
* run commands inside the disposable checkout.
|
|
178
|
+
* @param {Mission | null | undefined} [mission]
|
|
179
|
+
* @param {TowerConfig} [config]
|
|
180
|
+
* @returns {'tower-survey' | 'tower-survey-sandbox'}
|
|
181
|
+
*/
|
|
182
|
+
export function resolvePreset(mission, config = /** @type {TowerConfig} */ ({})) {
|
|
183
|
+
return resolveProvisioning(mission, config) === 'detached' ? 'tower-survey-sandbox' : 'tower-survey'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Shared-checkout completion invariants (the default placement). */
|
|
187
|
+
const SHARED_COMPLETION = Object.freeze({
|
|
188
|
+
requiresWorktree: false,
|
|
189
|
+
requiresSandbox: false,
|
|
190
|
+
requiresCommits: false,
|
|
191
|
+
requiresClean: false,
|
|
192
|
+
requiresHeadMatch: true,
|
|
193
|
+
requiresTrackedStatus: true,
|
|
194
|
+
check: /** @type {(ctx: any) => { commits: number }} */ (checkCompletion),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
/** Detached-sandbox completion invariants. */
|
|
198
|
+
const SANDBOX_COMPLETION = Object.freeze({
|
|
199
|
+
requiresWorktree: false,
|
|
200
|
+
requiresSandbox: true,
|
|
201
|
+
requiresCommits: true,
|
|
202
|
+
requiresClean: false,
|
|
203
|
+
requiresHeadMatch: true,
|
|
204
|
+
requiresTrackedStatus: false,
|
|
205
|
+
check: /** @type {(ctx: any) => { commits: number }} */ (checkSandboxCompletion),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The completion invariants for the checkout the mission was provisioned in.
|
|
210
|
+
* Placement is read from the recorded `mission.sandbox`, never from current
|
|
211
|
+
* config: a detached sandbox still enforces its HEAD/zero-commit invariants and
|
|
212
|
+
* is removed after a config flip to `none`, and a survey that never got a
|
|
213
|
+
* sandbox keeps the shared invariants even if config now asks for `detached`.
|
|
214
|
+
* The config parameter stays in the shape contract for symmetry with other
|
|
215
|
+
* hooks, but is intentionally not consulted here.
|
|
216
|
+
* @param {Mission} mission
|
|
217
|
+
* @param {TowerConfig} [_config]
|
|
218
|
+
* @returns {typeof SHARED_COMPLETION | typeof SANDBOX_COMPLETION}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveCompletion(mission, _config = /** @type {TowerConfig} */ ({})) {
|
|
221
|
+
return resolveProvisioning(mission) === 'detached' ? SANDBOX_COMPLETION : SHARED_COMPLETION
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Completion blockers for a survey. A survey is never merged, so the build-only
|
|
226
|
+
* "closed mission owns a branch" blocker does not exist for it.
|
|
227
|
+
* @param {Mission} mission
|
|
228
|
+
* @param {TowerConfig} [config]
|
|
229
|
+
* @returns {Blocker[]}
|
|
230
|
+
*/
|
|
231
|
+
export function completionBlockers(mission, config = /** @type {TowerConfig} */ ({})) {
|
|
232
|
+
/** @type {Blocker[]} */
|
|
233
|
+
const blockers = []
|
|
234
|
+
if (mission.status !== 'completed') {
|
|
235
|
+
blockers.push({
|
|
236
|
+
code: 'mission-not-completed',
|
|
237
|
+
detail:
|
|
238
|
+
mission.status === 'blocked'
|
|
239
|
+
? `mission ${mission.id} is blocked: ${(mission.blockers ?? []).join('; ')}; clear the blockers to resume it`
|
|
240
|
+
: `mission ${mission.id} is ${mission.status}; completion is required`,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
const open = (mission.tasks ?? []).filter((task) => task.status === 'open')
|
|
244
|
+
if (open.length > 0) {
|
|
245
|
+
blockers.push({ code: 'tasks-open', detail: `${open.length} open task(s): ${open.map((task) => task.text).join('; ')}` })
|
|
246
|
+
}
|
|
247
|
+
if ((mission.blockers ?? []).length > 0) {
|
|
248
|
+
blockers.push({ code: 'blockers-present', detail: (mission.blockers ?? []).join('; ') })
|
|
249
|
+
}
|
|
250
|
+
if (config.gates?.requireChecks && !hasCheckEvidence(EVIDENCE, mission)) {
|
|
251
|
+
blockers.push({
|
|
252
|
+
code: 'no-check-evidence',
|
|
253
|
+
detail: `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
return blockers
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A completed survey satisfies its dependents; its read commit is part of the
|
|
261
|
+
* evidence, but the shape contract only needs the terminal status.
|
|
262
|
+
* @param {Pick<Mission, 'status'>} dep
|
|
263
|
+
* @returns {boolean}
|
|
264
|
+
*/
|
|
265
|
+
export function dependencySatisfied(dep) {
|
|
266
|
+
return dep.status === 'completed' || dep.status === 'abandoned'
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Worker spawn constraints: a survey worker runs in the shared checkout (or,
|
|
271
|
+
* with `survey.sandbox: 'detached'`, in a disposable detached worktree) and
|
|
272
|
+
* never provisions a branch. The concrete placement/preset is resolved by
|
|
273
|
+
* `resolveProvisioning`/`resolvePreset`.
|
|
274
|
+
* @type {{ provisioning: 'shared', missionStatuses: readonly string[], worktree: false, defaultPreset: string, countsTowardWorkerBudget: boolean, model: string }}
|
|
275
|
+
*/
|
|
276
|
+
export const spawn = Object.freeze({
|
|
277
|
+
provisioning: /** @type {const} */ ('shared'),
|
|
278
|
+
missionStatuses: Object.freeze(['planned', 'active', 'blocked', 'paused']),
|
|
279
|
+
worktree: /** @type {const} */ (false),
|
|
280
|
+
defaultPreset: 'tower-survey',
|
|
281
|
+
countsTowardWorkerBudget: false,
|
|
282
|
+
model: 'survey',
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Mission briefing lines that are specific to the survey shape. A detached
|
|
287
|
+
* sandbox is named explicitly so the worker never edits the main checkout and
|
|
288
|
+
* knows the checkout is disposable but the recorded commit is pinned.
|
|
289
|
+
* @param {Mission} mission
|
|
290
|
+
* @param {{ repoRoot: string, worktree?: unknown }} context
|
|
291
|
+
* @returns {string[]}
|
|
292
|
+
*/
|
|
293
|
+
export function briefing(mission, context) {
|
|
294
|
+
const short = String(mission.readCommit ?? '').slice(0, 12)
|
|
295
|
+
const fingerprint = (mission.readDirty ?? []).length
|
|
296
|
+
if (mission.sandbox) {
|
|
297
|
+
return [
|
|
298
|
+
`Read and work in the disposable detached sandbox: ${mission.sandbox} (no branch, no commit of your own; the sandbox is removed when the mission completes)`,
|
|
299
|
+
`The sandbox HEAD is pinned to ${short}. Write and shell side effects stay inside the sandbox; completion requires HEAD to still be ${short} and zero new commits.`,
|
|
300
|
+
`Pass workdir="${mission.sandbox}" on every shell command; never modify the main checkout ${context.repoRoot}.`,
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
return [
|
|
304
|
+
`Read only: commit ${short} in the shared checkout ${context.repoRoot} (no branch, no worktree, no commits)`,
|
|
305
|
+
`Completion invariants: HEAD must still be ${short} and the tracked-status set captured at plan time (${fingerprint} path(s)) must be unchanged. Complete with zero commits.`,
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Projection of the survey-specific mission fields for summaries/board rows.
|
|
311
|
+
* @param {Mission} mission
|
|
312
|
+
* @returns {Record<string, any>}
|
|
313
|
+
*/
|
|
314
|
+
export function summary(mission) {
|
|
315
|
+
return {
|
|
316
|
+
worktree: null,
|
|
317
|
+
sandbox: mission.sandbox ?? null,
|
|
318
|
+
readCommit: mission.readCommit ?? null,
|
|
319
|
+
readDirty: [...(mission.readDirty ?? [])],
|
|
320
|
+
mergedCommit: null,
|
|
321
|
+
mergedAt: null,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* One-line human description of where the mission reads, used by reports.
|
|
327
|
+
* @param {Mission} mission
|
|
328
|
+
* @returns {string | null}
|
|
329
|
+
*/
|
|
330
|
+
export function describe(mission) {
|
|
331
|
+
return mission.readCommit ? `read at ${String(mission.readCommit).slice(0, 12)}` : null
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* v3 -> v4 migration hook (#148). A legacy survey reads the commit its
|
|
336
|
+
* branch-shaped worktree was based on, keeps its completion report, and loses
|
|
337
|
+
* the worktree/branch/merge projection. A legacy survey that was merged becomes
|
|
338
|
+
* completed: a survey is never merged in v4.
|
|
339
|
+
* @param {Record<string, any>} mission
|
|
340
|
+
* @returns {boolean}
|
|
341
|
+
*/
|
|
342
|
+
export function isLegacy(mission) {
|
|
343
|
+
return mission.kind === 'survey'
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The commit a legacy (v3, branch-shaped) survey recorded, in fallback order:
|
|
348
|
+
* the v4 field, the worktree it was based on, then the mission-level base
|
|
349
|
+
* commit. A survey planned and never provisioned (or whose spawn failed before
|
|
350
|
+
* provisioning) has none of the three.
|
|
351
|
+
* @param {Record<string, any>} mission
|
|
352
|
+
* @returns {string | null}
|
|
353
|
+
*/
|
|
354
|
+
export function legacyReadCommit(mission) {
|
|
355
|
+
const recorded = mission.readCommit ?? mission.worktree?.baseCommit ?? mission.baseCommit
|
|
356
|
+
return typeof recorded === 'string' && recorded.length > 0 ? recorded : null
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* True when a legacy survey has no base of its own: the state loader must
|
|
361
|
+
* resolve one from the repository before migration can anchor the survey.
|
|
362
|
+
* @param {Record<string, any>} mission
|
|
363
|
+
* @returns {boolean}
|
|
364
|
+
*/
|
|
365
|
+
export function needsLegacyRead(mission) {
|
|
366
|
+
return legacyReadCommit(mission) === null
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @param {Record<string, any>} mission
|
|
371
|
+
* @param {{ legacySurveyRead?: { readCommit?: unknown, readDirty?: unknown } | null }} [options]
|
|
372
|
+
* @returns {Record<string, any>}
|
|
373
|
+
*/
|
|
374
|
+
export function migrateLegacy(mission, options = {}) {
|
|
375
|
+
const recorded = legacyReadCommit(mission)
|
|
376
|
+
// The loader resolves the state base once for the whole document; only a
|
|
377
|
+
// survey that recorded no base of its own may consume that resolution. A
|
|
378
|
+
// recorded commit always wins, so re-running the step is a fixed point.
|
|
379
|
+
const resolved = recorded === null ? options.legacySurveyRead : null
|
|
380
|
+
const readCommit = recorded ?? resolved?.readCommit
|
|
381
|
+
if (typeof readCommit !== 'string' || readCommit.length === 0) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`survey ${String(mission.id)} has no recorded base commit to read; the state loader must resolve the base ref for a planned-but-never-provisioned survey`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
const readDirty =
|
|
387
|
+
recorded === null
|
|
388
|
+
? Array.isArray(resolved?.readDirty)
|
|
389
|
+
? resolved.readDirty.filter((entry) => typeof entry === 'string')
|
|
390
|
+
: []
|
|
391
|
+
: Array.isArray(mission.readDirty)
|
|
392
|
+
? [...mission.readDirty]
|
|
393
|
+
: []
|
|
394
|
+
const next = /** @type {Record<string, any>} */ ({
|
|
395
|
+
...mission,
|
|
396
|
+
kind: 'survey',
|
|
397
|
+
scope: [],
|
|
398
|
+
readCommit,
|
|
399
|
+
readDirty,
|
|
400
|
+
})
|
|
401
|
+
if (next.status === 'merged') next.status = 'completed'
|
|
402
|
+
delete next.worktree
|
|
403
|
+
delete next.worktreeRemovedAt
|
|
404
|
+
delete next.sandbox
|
|
405
|
+
delete next.sandboxCleanupError
|
|
406
|
+
delete next.sandboxRemovedAt
|
|
407
|
+
delete next.branch
|
|
408
|
+
delete next.baseCommit
|
|
409
|
+
delete next.mergedCommit
|
|
410
|
+
delete next.mergedAt
|
|
411
|
+
return next
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export const surveyShape = Object.freeze({
|
|
415
|
+
kind: /** @type {const} */ ('survey'),
|
|
416
|
+
statuses: SURVEY_STATUSES,
|
|
417
|
+
transitions: SURVEY_TRANSITIONS,
|
|
418
|
+
isTerminal,
|
|
419
|
+
canTransition,
|
|
420
|
+
isLegacy,
|
|
421
|
+
migrateLegacy,
|
|
422
|
+
needsLegacyRead,
|
|
423
|
+
canMerge: false,
|
|
424
|
+
canRebase: false,
|
|
425
|
+
provisioning: /** @type {const} */ ('shared'),
|
|
426
|
+
reach: REACH,
|
|
427
|
+
// Document dimensions this shape owns; the state validator reads these.
|
|
428
|
+
fields: Object.freeze({ worktree: false, readCommit: true, merge: false, sandbox: true }),
|
|
429
|
+
// A survey needs its optional component row; a build does not.
|
|
430
|
+
requiredComponent: 'tower-survey',
|
|
431
|
+
// Only a survey records `result` evidence on completion.
|
|
432
|
+
acceptsResult: true,
|
|
433
|
+
evidence: EVIDENCE,
|
|
434
|
+
planFields,
|
|
435
|
+
resolveProvisioning,
|
|
436
|
+
resolvePreset,
|
|
437
|
+
resolveCompletion,
|
|
438
|
+
completion: SHARED_COMPLETION,
|
|
439
|
+
completionBlockers,
|
|
440
|
+
dependencySatisfied,
|
|
441
|
+
spawn,
|
|
442
|
+
briefing,
|
|
443
|
+
summary,
|
|
444
|
+
describe,
|
|
445
|
+
})
|
package/domain/state/archive.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// writing the files, and the lock that makes them atomic with state.json, live
|
|
12
12
|
// in `adapters/archive.js` and `app/archive.js`.
|
|
13
13
|
// @ts-check
|
|
14
|
-
import {
|
|
14
|
+
import { normalizeResult } from '../missions/completion.js'
|
|
15
|
+
import { shapeFor } from '../shapes/index.js'
|
|
15
16
|
|
|
16
17
|
/** @typedef {import('../../types/index.js').Finding} Finding */
|
|
17
18
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
@@ -63,7 +64,8 @@ export function terminalTimestamp(mission) {
|
|
|
63
64
|
* Select the terminal missions the policy wants moved out of state.json.
|
|
64
65
|
*
|
|
65
66
|
* Eligibility:
|
|
66
|
-
* - status
|
|
67
|
+
* - the mission's shape reports its status as terminal (`merged`/`abandoned`
|
|
68
|
+
* for a build, `completed`/`abandoned` for a survey);
|
|
67
69
|
* - the worktree is gone (never provisioned, or removed by teardown);
|
|
68
70
|
* - the caller did not protect it (open batch members, live agents).
|
|
69
71
|
*
|
|
@@ -85,7 +87,7 @@ export function selectForArchive(missions, policy = {}, options = {}) {
|
|
|
85
87
|
const afterDays = Number.isFinite(policy?.afterDays) ? /** @type {number} */ (policy.afterDays) : 30
|
|
86
88
|
const maxMissions = Number.isFinite(policy?.maxMissions) ? /** @type {number} */ (policy.maxMissions) : 100
|
|
87
89
|
const eligible = missions.filter(
|
|
88
|
-
(mission) => isTerminal(mission.status) && !hasLiveWorktree(mission) && !protectedIds.has(mission.id),
|
|
90
|
+
(mission) => shapeFor(mission.kind).isTerminal(mission.status) && !hasLiveWorktree(mission) && !protectedIds.has(mission.id),
|
|
89
91
|
)
|
|
90
92
|
if (eligible.length === 0) return []
|
|
91
93
|
if (options.force === true) return eligible
|
|
@@ -140,10 +142,30 @@ export function archiveYearFor(now) {
|
|
|
140
142
|
return new Date(now).getUTCFullYear()
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Keep the bounded survey evidence on the index entry. The value was already
|
|
147
|
+
* normalized when the survey completed, but re-normalizing is cheap and makes
|
|
148
|
+
* a hand-edited or legacy document safe to archive.
|
|
149
|
+
* @param {Mission} mission
|
|
150
|
+
* @returns {Record<string, any> | null}
|
|
151
|
+
*/
|
|
152
|
+
function boundedResult(mission) {
|
|
153
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
154
|
+
if (record.result === undefined || record.result === null) return null
|
|
155
|
+
try {
|
|
156
|
+
return normalizeResult(record.result)
|
|
157
|
+
} catch {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
143
162
|
/**
|
|
144
163
|
* The compact entry written to `archive/index.json` (and referenced by
|
|
145
164
|
* `tower_status`). It carries enough fields to keep ordering, dependency
|
|
146
|
-
* lookups and mission-id allocation working without loading a
|
|
165
|
+
* lookups, freshness checks and mission-id allocation working without loading a
|
|
166
|
+
* year file: `readCommit` and the bounded survey `result` stay on the index so
|
|
167
|
+
* an archived survey dependency keeps its evidence and cannot fail the
|
|
168
|
+
* `gates.requireFreshSurvey` check open.
|
|
147
169
|
* @param {Mission} mission
|
|
148
170
|
* @param {ArchiveMeta} meta
|
|
149
171
|
* @returns {Record<string, any>}
|
|
@@ -158,6 +180,8 @@ export function archiveIndexEntry(mission, meta) {
|
|
|
158
180
|
kind: record.kind ?? 'build',
|
|
159
181
|
scope: Array.isArray(record.scope) ? record.scope : [],
|
|
160
182
|
deps: Array.isArray(record.deps) ? record.deps : [],
|
|
183
|
+
readCommit: record.readCommit ?? null,
|
|
184
|
+
result: boundedResult(mission),
|
|
161
185
|
owner: record.owner ?? null,
|
|
162
186
|
branch,
|
|
163
187
|
mergedCommit: record.mergedCommit ?? null,
|