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.
Files changed (142) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +18 -16
  7. package/README.md +18 -11
  8. package/adapters/leases.js +20 -6
  9. package/adapters/legacy.js +149 -37
  10. package/adapters/stateFile.js +11 -1
  11. package/app/archive.js +30 -5
  12. package/app/briefing.js +95 -12
  13. package/app/checkCompletion.js +107 -18
  14. package/app/enrichedMissions.js +1 -1
  15. package/app/freshness.js +77 -0
  16. package/app/gate.js +26 -12
  17. package/app/merge.js +7 -4
  18. package/app/mission.js +24 -1
  19. package/app/missionContext.js +2 -1
  20. package/app/plan.js +64 -1
  21. package/app/provision.js +133 -22
  22. package/app/read.js +38 -5
  23. package/app/rebase.js +10 -8
  24. package/app/review.js +104 -61
  25. package/app/reviewCheckout.js +133 -0
  26. package/app/sandbox.js +121 -0
  27. package/app/sharedCheckout.js +90 -0
  28. package/app/spawn-prechecks.js +31 -20
  29. package/app/spawn-support.js +59 -8
  30. package/app/spawn.js +3 -0
  31. package/app/teardown.js +119 -2
  32. package/app/tower.js +34 -6
  33. package/client.js +321 -0
  34. package/cordis.patch.yml +55 -3
  35. package/docs/adr/0005-component-surface.md +58 -13
  36. package/docs/adr/0006-mission-shapes.md +306 -0
  37. package/docs/architecture/overview.md +18 -10
  38. package/docs/examples/first-mission.md +3 -2
  39. package/docs/faq.md +9 -7
  40. package/docs/guide/install-upgrade-migration.md +58 -7
  41. package/docs/guide/quickstart.md +20 -10
  42. package/docs/guide/scenarios-and-philosophy.md +35 -28
  43. package/docs/operations/runbook.md +1 -1
  44. package/docs/operations/security.md +11 -10
  45. package/docs/reference/blockers.md +20 -11
  46. package/docs/reference/component-surface.md +20 -8
  47. package/docs/reference/config.generated.md +8 -5
  48. package/docs/reference/config.md +30 -5
  49. package/docs/reference/doctor.md +7 -7
  50. package/docs/reference/errors.generated.md +1 -0
  51. package/docs/reference/errors.md +2 -1
  52. package/docs/reference/operations.md +1 -1
  53. package/docs/reference/shapes.md +123 -0
  54. package/docs/reference/surface-budget.md +53 -4
  55. package/docs/reference/testing.md +5 -3
  56. package/docs/reference/tools.generated.md +7 -7
  57. package/docs/reference/tools.md +5 -5
  58. package/docs/zh/faq.md +7 -5
  59. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  60. package/docs/zh/guide/quickstart.md +17 -10
  61. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  62. package/docs/zh/operations/security.md +9 -8
  63. package/docs/zh/reference/blockers.md +13 -9
  64. package/docs/zh/reference/config.md +22 -5
  65. package/docs/zh/reference/errors.md +2 -1
  66. package/docs/zh/reference/testing.md +5 -4
  67. package/docs/zh/reference/tools.md +3 -3
  68. package/domain/batch.js +75 -105
  69. package/domain/collab/batch.js +30 -10
  70. package/domain/collab/spawn.js +53 -16
  71. package/domain/components.js +12 -4
  72. package/domain/config/schema.js +87 -22
  73. package/domain/contracts/outputs.js +2 -1
  74. package/domain/contracts/tool-inputs/missions.js +10 -2
  75. package/domain/errors.js +6 -0
  76. package/domain/evidence.js +283 -0
  77. package/domain/gate.js +17 -34
  78. package/domain/integration/gate.js +17 -43
  79. package/domain/integration/init.js +3 -2
  80. package/domain/integration/provision.js +51 -0
  81. package/domain/integration/rebase.js +16 -1
  82. package/domain/integration/review.js +203 -9
  83. package/domain/mission.js +16 -33
  84. package/domain/missions/completion.js +46 -8
  85. package/domain/missions/findings.js +2 -1
  86. package/domain/missions/lifecycle.js +131 -35
  87. package/domain/missions/plan.js +30 -6
  88. package/domain/missions/review.js +2 -18
  89. package/domain/planning.js +5 -5
  90. package/domain/read/missionContext.js +17 -3
  91. package/domain/read/views.js +5 -1
  92. package/domain/roles.js +222 -0
  93. package/domain/roster.js +9 -3
  94. package/domain/scope.js +51 -0
  95. package/domain/shapes/build.js +254 -0
  96. package/domain/shapes/index.js +171 -0
  97. package/domain/shapes/survey.js +445 -0
  98. package/domain/state/archive.js +28 -4
  99. package/domain/state/migrations.js +111 -4
  100. package/domain/state/schema.js +69 -7
  101. package/domain/state/semantics.js +93 -13
  102. package/domain/state.js +5 -1
  103. package/domain/summary.js +5 -11
  104. package/domain/vocabulary.js +15 -33
  105. package/host/capability-registry.js +25 -9
  106. package/host/config.js +40 -3
  107. package/host/doctor.js +20 -8
  108. package/host/preset-survey-sandbox.js +19 -0
  109. package/host/preset-survey.js +18 -0
  110. package/host/presets.js +17 -11
  111. package/host/tool-registry.js +23 -13
  112. package/locale/en.json +16 -1
  113. package/locale/preset-survey/en.json +6 -0
  114. package/locale/preset-survey/zh.json +6 -0
  115. package/locale/preset-survey-sandbox/en.json +6 -0
  116. package/locale/preset-survey-sandbox/zh.json +6 -0
  117. package/locale/zh.json +16 -1
  118. package/package.json +19 -2
  119. package/ports/state.js +8 -1
  120. package/scripts/architecture-waivers.json +3 -3
  121. package/scripts/check-components.mjs +120 -6
  122. package/scripts/check-docs.mjs +1 -1
  123. package/scripts/check-pack.mjs +5 -0
  124. package/scripts/component-surface.json +83 -29
  125. package/scripts/generate-capability-locale.mjs +5 -0
  126. package/scripts/generate-shapes-reference.mjs +171 -0
  127. package/scripts/mount-smoke/surface.mjs +3 -0
  128. package/scripts/pack-manifest.txt +22 -0
  129. package/scripts/shape-audit.mjs +361 -0
  130. package/scripts/surface-baseline.json +75 -4
  131. package/scripts/types-coverage.mjs +7 -0
  132. package/survey-sandbox.js +48 -0
  133. package/survey.js +40 -0
  134. package/tools/complete.js +5 -5
  135. package/tools/diff.js +5 -3
  136. package/tools/plan.js +1 -1
  137. package/tools/preflight.js +3 -2
  138. package/tools/review.js +1 -1
  139. package/tools/spawn.js +48 -50
  140. package/tools/status.js +8 -1
  141. package/tools/test.js +2 -1
  142. package/types/index.d.ts +76 -5
@@ -1,4 +1,5 @@
1
1
  // Worker completion preparation and commit use cases.
2
+ import path from 'node:path'
2
3
  import { TowerToolError } from '../domain/errors.js'
3
4
  import {
4
5
  normalizeChecks,
@@ -7,12 +8,79 @@ import {
7
8
  completionReport,
8
9
  applyCompletion,
9
10
  } from '../domain/missions/completion.js'
11
+ import { shapeFor } from '../domain/shapes/index.js'
10
12
  import { fullMission } from '../domain/summary.js'
11
13
  import { validateState } from '../domain/state.js'
12
14
  import { sweepArchive } from './archive.js'
15
+ import { cleanupSandbox } from './sandbox.js'
16
+ import { trackedFingerprint } from './sharedCheckout.js'
13
17
 
14
18
  export { normalizeChecks }
15
19
 
20
+ /**
21
+ * Read the primitives the shape's completion invariants need. Which reads happen
22
+ * is declared by the shape, so this layer never asks "is this a survey?"; a
23
+ * detached sandbox declares `requiresSandbox` and its HEAD/commit count is read
24
+ * from the recorded sandbox path instead of the shared checkout (#148 W2).
25
+ * @param {any} tower
26
+ * @param {import('../types/index.js').Mission} mission
27
+ * @param {any} invariants
28
+ * @returns {Promise<{ commits: number, clean: boolean, dirtyPaths: string[], head: string, trackedChanged: string[], readCommit?: string, sandbox?: string | null }>}
29
+ */
30
+ async function completionFacts(tower, mission, invariants) {
31
+ const worktree = /** @type {any} */ (mission.worktree)
32
+ const sandbox = invariants.requiresSandbox ? String(/** @type {any} */ (mission).sandbox ?? '') : ''
33
+ /** @type {{ commits: number, clean: boolean, dirtyPaths: string[], head: string, trackedChanged: string[], readCommit?: string, sandbox?: string | null }} */
34
+ const facts = { commits: 0, clean: true, dirtyPaths: [], head: '', trackedChanged: [], sandbox: sandbox || null }
35
+ if (invariants.requiresSandbox) {
36
+ if (!sandbox) {
37
+ throw new TowerToolError('invalid-argument', `mission ${mission.id} has no detached sandbox recorded; re-provision it or abandon the survey`)
38
+ }
39
+ const entries = await tower.deps.vcs.listWorktrees(tower.root)
40
+ if (!entries.some((entry) => entry.path && path.resolve(entry.path) === path.resolve(sandbox))) {
41
+ throw new TowerToolError(
42
+ 'invalid-argument',
43
+ `mission ${mission.id} detached sandbox ${sandbox} is not registered with git; the disposable checkout was removed — re-provision the survey`,
44
+ )
45
+ }
46
+ }
47
+ if (invariants.requiresWorktree) await tower.assertWorktree(worktree)
48
+ const cwd = sandbox || worktree?.path
49
+ if (invariants.requiresCommits) {
50
+ const base = invariants.requiresSandbox ? /** @type {string} */ (mission.readCommit) : worktree.baseCommit
51
+ facts.commits = await tower.deps.vcs.countCommits(cwd, base)
52
+ }
53
+ if (invariants.requiresClean) {
54
+ facts.clean = await tower.deps.vcs.isClean(cwd)
55
+ if (!facts.clean) {
56
+ facts.dirtyPaths = (await tower.git(cwd, ['status', '--porcelain']))
57
+ .split('\n')
58
+ .filter(Boolean)
59
+ .map((line) => line.slice(3).trim())
60
+ }
61
+ }
62
+ facts.readCommit = /** @type {string | undefined} */ (mission.readCommit)
63
+ if (invariants.requiresHeadMatch || invariants.requiresTrackedStatus) {
64
+ // A shared survey reads the shared checkout: HEAD and the tracked fingerprint
65
+ // must match the plan-time record exactly (a path that changed either way
66
+ // counts). A detached sandbox reads its own disposable checkout: only HEAD
67
+ // must still equal the recorded commit; writes there are expected.
68
+ facts.head = await tower.revParse(invariants.requiresSandbox ? sandbox : tower.root, 'HEAD')
69
+ if (invariants.requiresTrackedStatus) {
70
+ const current = trackedFingerprint(await tower.git(tower.root, ['status', '--porcelain']))
71
+ const planned = new Set(mission.readDirty ?? [])
72
+ const currentSet = new Set(current)
73
+ facts.trackedChanged = [
74
+ ...new Set([
75
+ ...current.filter((path) => !planned.has(path)),
76
+ ...(mission.readDirty ?? []).filter((path) => !currentSet.has(path)),
77
+ ]),
78
+ ].sort()
79
+ }
80
+ }
81
+ return facts
82
+ }
83
+
16
84
  export async function checkCompletion(tower, input) {
17
85
  const state = await tower.load()
18
86
  const mission = state.missions.find((entry) => entry.id === input.missionId)
@@ -25,24 +93,22 @@ export async function checkCompletion(tower, input) {
25
93
  input,
26
94
  maxReportBytes: tower.config.gates?.maxReportBytes,
27
95
  })
28
- await tower.assertWorktree(mission.worktree)
29
- const commits = await tower.deps.vcs.countCommits(mission.worktree.path, mission.worktree.baseCommit)
30
- if (commits < 1) {
31
- throw new TowerToolError(
32
- 'invalid-argument',
33
- `mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
34
- )
96
+ const shape = shapeFor(mission.kind)
97
+ const invariants = /** @type {{
98
+ * requiresWorktree: boolean, requiresSandbox: boolean, requiresCommits: boolean,
99
+ * requiresClean: boolean, requiresHeadMatch: boolean, requiresTrackedStatus: boolean,
100
+ * check: (ctx: any) => { commits: number },
101
+ * }} */ ('resolveCompletion' in shape && typeof shape.resolveCompletion === 'function'
102
+ ? shape.resolveCompletion(mission, tower.config)
103
+ : shape.completion)
104
+ if (invariants.requiresWorktree && !mission.worktree) {
105
+ throw new TowerToolError('invalid-argument', `mission ${mission.id} has no worktree`)
35
106
  }
36
- if (!(await tower.deps.vcs.isClean(mission.worktree.path))) {
37
- const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
38
- .split('\n')
39
- .filter(Boolean)
40
- .map((line) => line.slice(3).trim())
41
- throw new TowerToolError(
42
- 'worktree-dirty',
43
- `mission ${mission.id} worktree has uncommitted changes (${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ', …' : ''}); commit, remove, or record them before tower_complete`,
44
- )
107
+ if (invariants.requiresSandbox && !/** @type {any} */ (mission).sandbox) {
108
+ throw new TowerToolError('invalid-argument', `mission ${mission.id} has no detached sandbox; re-provision it or abandon the survey`)
45
109
  }
110
+ const facts = await completionFacts(tower, mission, invariants)
111
+ const { commits } = invariants.check({ mission, ...facts })
46
112
  const unread = tower.unreadFor(state, worker.name)
47
113
  assertCompletionInbox({ mission, worker, unread })
48
114
  return { state, mission, worker, commits }
@@ -52,7 +118,7 @@ export async function complete(tower, input) {
52
118
  // Two-phase pilot (#51): expensive git/state checks run before the lock;
53
119
  // the lock only revalidates the state revision and commits the transition.
54
120
  const prepared = await tower.checkCompletion(input)
55
- return tower.deps.store.withLock(tower.paths, async () => {
121
+ const result = await tower.deps.store.withLock(tower.paths, async () => {
56
122
  const state = await tower.load()
57
123
  if (state.revision !== prepared.state.revision) {
58
124
  throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
@@ -69,7 +135,7 @@ export async function complete(tower, input) {
69
135
  now,
70
136
  maxReportBytes: tower.config.gates?.maxReportBytes,
71
137
  })
72
- const applied = applyCompletion({ mission, report: storedReport, now })
138
+ const applied = applyCompletion({ mission, report: storedReport, now, result: input.result })
73
139
  await sweepArchive(tower, state, { now })
74
140
  state.revision += 1
75
141
  state.updatedAt = now
@@ -85,4 +151,27 @@ export async function complete(tower, input) {
85
151
  tower.emitEvents(applied.events, { revision: state.revision, at: now })
86
152
  return { mission: fullMission(mission), commits, unread: 0 }
87
153
  })
154
+ // The completion transition is durable at this point. Removing the disposable
155
+ // sandbox is advisory: a failed cleanup is reported, never rolled back into
156
+ // the mission state or allowed to fail the completion.
157
+ let sandboxCleanup = null
158
+ if (result.mission.sandbox) {
159
+ const sandbox = result.mission.sandbox
160
+ try {
161
+ sandboxCleanup = await cleanupSandbox(tower, input.missionId)
162
+ } catch (error) {
163
+ sandboxCleanup = {
164
+ ok: false,
165
+ removed: false,
166
+ attempts: 0,
167
+ path: sandbox,
168
+ error: String(error?.message ?? error).slice(0, 400),
169
+ }
170
+ }
171
+ if (sandboxCleanup?.ok) {
172
+ delete result.mission.sandbox
173
+ delete result.mission.sandboxCleanupError
174
+ }
175
+ }
176
+ return { ...result, ...(sandboxCleanup ? { sandboxCleanup } : {}) }
88
177
  }
@@ -12,7 +12,7 @@ export async function enrichedMissions(tower, state, onlyIds) {
12
12
  let tipMoved = false
13
13
  if (latest && mission.worktree && (await tower.deps.store.exists(mission.worktree.path))) {
14
14
  try {
15
- const tip = await tower.deps.vcs.revParse(mission.worktree.path, 'HEAD')
15
+ const tip = await tower.branchTip(mission)
16
16
  tipMoved = tip !== latest.reviewedCommit
17
17
  } catch {
18
18
  tipMoved = false
@@ -0,0 +1,77 @@
1
+ // Dependency freshness (#148 W2 / D5): a build that depends on a survey wants
2
+ // the survey's evidence to describe the code it starts from. A survey records
3
+ // the exact commit it read; when that commit is not an ancestor-or-equal of the
4
+ // build's base, the evidence may be stale. This is advisory by default and a
5
+ // blocker only when `gates.requireFreshSurvey` is true.
6
+ // @ts-check
7
+ import { shapeFor } from '../domain/shapes/index.js'
8
+
9
+ /**
10
+ * @typedef {import('../types/index.js').Mission} Mission
11
+ * @typedef {import('../types/index.js').TowerState} TowerState
12
+ */
13
+
14
+ /**
15
+ * One stable, self-describing freshness warning. The same shape is promoted to a
16
+ * blocker (with `detail` carrying the message) when the gate asks for it.
17
+ * @param {{ missionId: string, dep: { id: string }, readCommit: string, baseCommit: string }} input
18
+ * @returns {{ code: 'survey-stale', missionId: string, depId: string, readCommit: string, baseCommit: string, message: string }}
19
+ */
20
+ export function surveyStaleWarning({ missionId, dep, readCommit, baseCommit }) {
21
+ const read = String(readCommit).slice(0, 12)
22
+ const base = String(baseCommit).slice(0, 12)
23
+ return {
24
+ code: 'survey-stale',
25
+ missionId,
26
+ depId: dep.id,
27
+ readCommit,
28
+ baseCommit,
29
+ message: `survey ${dep.id} read ${read}, which is not an ancestor of ${missionId}'s base ${base}; the survey evidence may not describe the code this mission starts from — re-plan the survey against the current base, or record why the read is still valid`,
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Compute `survey-stale` warnings for one build mission's survey dependencies.
35
+ * A survey dependency is recognised through the shape contract (`fields.readCommit`),
36
+ * not a kind literal. The base is `mission.baseCommit` for a provisioned build,
37
+ * otherwise the current state base (resolved once by the caller or here).
38
+ * @param {any} tower
39
+ * @param {object} input
40
+ * @param {Mission} input.mission
41
+ * @param {TowerState} input.state
42
+ * @param {Mission[]} [input.missions]
43
+ * @param {Map<string, { id?: string, status: string, kind?: string, readCommit?: string }>} [input.archived]
44
+ * @param {string | null} [input.baseCommit] already-resolved build base
45
+ * @returns {Promise<Array<ReturnType<typeof surveyStaleWarning>>>}
46
+ */
47
+ export async function surveyStaleWarnings(tower, { mission, state, missions, archived = new Map(), baseCommit = null }) {
48
+ if (!shapeFor(mission.kind).fields.worktree) return []
49
+ const deps = mission.deps ?? []
50
+ if (deps.length === 0) return []
51
+ let base = baseCommit ?? /** @type {any} */ (mission).baseCommit ?? null
52
+ if (typeof base !== 'string' || base.length === 0) {
53
+ try {
54
+ base = await tower.revParse(tower.root, state.base)
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+ const byId = new Map((missions ?? state.missions).map((entry) => [entry.id, entry]))
60
+ /** @type {Array<ReturnType<typeof surveyStaleWarning>>} */
61
+ const warnings = []
62
+ for (const depId of deps) {
63
+ const dep = byId.get(depId) ?? archived.get(depId)
64
+ if (!dep || !shapeFor(dep.kind).fields.readCommit) continue
65
+ const readCommit = typeof /** @type {any} */ (dep).readCommit === 'string' ? /** @type {any} */ (dep).readCommit : null
66
+ if (!readCommit) continue
67
+ let fresh = false
68
+ try {
69
+ fresh = await tower.isAncestor(tower.root, readCommit, base)
70
+ } catch {
71
+ fresh = false
72
+ }
73
+ if (fresh) continue
74
+ warnings.push(surveyStaleWarning({ missionId: mission.id, dep: /** @type {{ id: string }} */ (dep), readCommit, baseCommit: base }))
75
+ }
76
+ return warnings
77
+ }
package/app/gate.js CHANGED
@@ -3,6 +3,7 @@ import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
3
3
  import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
4
4
  import { fullMission } from '../domain/summary.js'
5
5
  import { latestReview, reviewsForBranch } from '../domain/integration/review.js'
6
+ import { assertActionSupported } from '../domain/shapes/index.js'
6
7
  import {
7
8
  baseDirtyBlocker,
8
9
  baseWrongBranchBlocker,
@@ -14,8 +15,8 @@ import {
14
15
  outOfScopeBlocker,
15
16
  overwrittenPaths,
16
17
  staleBaseBlocker,
17
- surveyChangedFilesBlocker,
18
18
  } from '../domain/integration/gate.js'
19
+ import { surveyStaleWarnings } from './freshness.js'
19
20
 
20
21
  export async function evaluateGate(tower, input) {
21
22
  const state = await tower.load()
@@ -31,6 +32,9 @@ export async function evaluateGate(tower, input) {
31
32
  }
32
33
  }
33
34
  if (!mission) return missingMissionGateResult(input.missionId)
35
+ // A shape without a branch has no merge gate at all; refuse before doing
36
+ // any git work so the caller gets one actionable code (#148 D4).
37
+ assertActionSupported(mission, input.action === 'merge' ? 'merge' : 'preflight')
34
38
  const archiveIndex = await tower.archiveIndex()
35
39
  const archivedMissions = new Map(
36
40
  (archiveIndex.ok ? archiveIndex.missions : []).map((entry) => [entry.id, entry]),
@@ -56,7 +60,7 @@ export async function evaluateGate(tower, input) {
56
60
  } else {
57
61
  try {
58
62
  await tower.assertWorktree(worktree)
59
- branchTip = await tower.revParse(worktree.path, 'HEAD')
63
+ branchTip = await tower.branchTip(mission)
60
64
  baseTip = await tower.revParse(tower.root, state.base)
61
65
  const mergeBase = (await tower.git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
62
66
  changedFiles = (await tower.git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
@@ -66,20 +70,14 @@ export async function evaluateGate(tower, input) {
66
70
  const blocker = staleBaseBlocker({ branch: worktree.branch, base: state.base, baseTip })
67
71
  push(blocker.code, blocker.detail)
68
72
  }
69
- if (mission.kind === 'survey') {
70
- const blocker = surveyChangedFilesBlocker({ missionId: mission.id, changedFiles })
71
- if (blocker) push(blocker.code, blocker.detail)
72
- }
73
- if (mission.kind !== 'survey') {
74
- const blocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
75
- if (blocker) push(blocker.code, blocker.detail)
76
- }
73
+ const scopeBlocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
74
+ if (scopeBlocker) push(scopeBlocker.code, scopeBlocker.detail)
77
75
  const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
78
76
  .split('\n')
79
77
  .filter(Boolean)
80
78
  .map((line) => line.slice(3).trim())
81
- const blocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
82
- if (blocker) push(blocker.code, blocker.detail)
79
+ const dirtyBlocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
80
+ if (dirtyBlocker) push(dirtyBlocker.code, dirtyBlocker.detail)
83
81
  } catch (error) {
84
82
  push('branch-missing', String(error?.message ?? error))
85
83
  }
@@ -89,6 +87,21 @@ export async function evaluateGate(tower, input) {
89
87
  push(blocker.code, blocker.detail)
90
88
  }
91
89
 
90
+ // #148 W2: survey evidence freshness is advisory by default. The stable
91
+ // warning shape is what plan/preflight report; `gates.requireFreshSurvey`
92
+ // promotes the same code to a blocker for repositories that need the read
93
+ // to describe the exact base they start from.
94
+ const sandboxWarnings = await surveyStaleWarnings(tower, {
95
+ mission,
96
+ state,
97
+ missions: state.missions,
98
+ archived: archivedMissions,
99
+ baseCommit: mission.baseCommit ?? null,
100
+ })
101
+ if (tower.config.gates?.requireFreshSurvey === true) {
102
+ for (const warning of sandboxWarnings) blockers.push({ ...warning, detail: warning.message })
103
+ }
104
+
92
105
  const branch = worktree ? worktree.branch : null
93
106
  const latest = latestReview(reviewsForBranch(reviews, branch))
94
107
  // #140: `mergeGateBlockers` is pure and may not read the component registry, so
@@ -132,6 +145,7 @@ export async function evaluateGate(tower, input) {
132
145
  review: latest,
133
146
  blockers,
134
147
  }),
148
+ warnings: sandboxWarnings,
135
149
  ...(archivedEntry ? { archived: true } : {}),
136
150
  }
137
151
  }
package/app/merge.js CHANGED
@@ -18,6 +18,8 @@
18
18
  // prepare stale, so the whole prepare is retried, never partially applied.
19
19
  import path from 'node:path'
20
20
  import { validateState } from '../domain/state.js'
21
+ import { assertActionSupported } from '../domain/shapes/index.js'
22
+ import { markBlocked } from '../domain/missions/lifecycle.js'
21
23
  import {
22
24
  alreadyMergedResult,
23
25
  applyLandedMerge,
@@ -134,7 +136,7 @@ async function observeLandedMerge(tower, state, mission) {
134
136
  let branchTip
135
137
  try {
136
138
  baseTip = await tower.revParse(tower.root, state.base)
137
- branchTip = await tower.revParse(mission.worktree.path, 'HEAD')
139
+ branchTip = await tower.branchTip(mission)
138
140
  } catch {
139
141
  return null
140
142
  }
@@ -238,6 +240,8 @@ async function prepareFromState(tower, input, deps) {
238
240
  if (archived.ok && archived.entry) mission = archived.entry.mission
239
241
  }
240
242
  if (!mission) return { result: missionMissingResult(input.missionId) }
243
+ // A branchless shape has no merge to prepare; refuse before any git work.
244
+ assertActionSupported(mission, 'merge')
241
245
 
242
246
  // Reconcile a stale merge intent: Git runs here, outside every state lock.
243
247
  if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
@@ -272,7 +276,7 @@ async function prepareFromState(tower, input, deps) {
272
276
  // Capture the exact commits this merge is allowed to integrate. The merge
273
277
  // command below uses the captured branch SHA, never the mutable branch name.
274
278
  const baseTip = await tower.revParse(tower.root, 'HEAD')
275
- const branchTip = await tower.revParse(worktree.path, 'HEAD')
279
+ const branchTip = await tower.branchTip(mission)
276
280
  const drift = gateTipDrift({
277
281
  missionId: mission.id,
278
282
  gateBaseTip: gate.baseTip,
@@ -522,8 +526,7 @@ async function commitMerge(tower, plan, recorded, outcome) {
522
526
  const now = Date.now()
523
527
  if (intentActive) delete state.mergeIntent
524
528
  if (conflict) {
525
- mission.status = 'blocked'
526
- mission.blockers.push(mergeConflictNote(outcome.detail))
529
+ markBlocked(mission, mergeConflictNote(outcome.detail), now)
527
530
  } else {
528
531
  mission.notes.push(mergeHookFailureNote(outcome.detail, mission.status))
529
532
  }
package/app/mission.js CHANGED
@@ -4,6 +4,7 @@ import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
4
4
  import { validateState } from '../domain/state.js'
5
5
  import { fullMission } from '../domain/summary.js'
6
6
  import { sweepArchive } from './archive.js'
7
+ import { cleanupSandbox } from './sandbox.js'
7
8
 
8
9
  function fail(message, code = 'invalid-argument') {
9
10
  throw new TowerToolError(code, message)
@@ -12,7 +13,7 @@ function fail(message, code = 'invalid-argument') {
12
13
  export async function updateMission(tower, input = {}) {
13
14
  const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
14
15
  if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
15
- return tower.deps.store.withLock(tower.paths, async () => {
16
+ const result = await tower.deps.store.withLock(tower.paths, async () => {
16
17
  const state = await tower.load()
17
18
  const mission = state.missions.find((entry) => entry.id === missionId)
18
19
  if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
@@ -34,4 +35,26 @@ export async function updateMission(tower, input = {}) {
34
35
  tower.emitEvents(events, { revision: state.revision, at: now })
35
36
  return { revision: state.revision, mission: fullMission(mission) }
36
37
  })
38
+ // Abandoning a detached survey disposes its sandbox. The state transition is
39
+ // already durable; cleanup is advisory and reports rather than throws.
40
+ let sandboxCleanup = null
41
+ if (result.mission.status === 'abandoned' && result.mission.sandbox) {
42
+ const sandbox = result.mission.sandbox
43
+ try {
44
+ sandboxCleanup = await cleanupSandbox(tower, missionId)
45
+ } catch (error) {
46
+ sandboxCleanup = {
47
+ ok: false,
48
+ removed: false,
49
+ attempts: 0,
50
+ path: sandbox,
51
+ error: String(error?.message ?? error).slice(0, 400),
52
+ }
53
+ }
54
+ if (sandboxCleanup?.ok) {
55
+ delete result.mission.sandbox
56
+ delete result.mission.sandboxCleanupError
57
+ }
58
+ }
59
+ return { ...result, ...(sandboxCleanup ? { sandboxCleanup } : {}) }
37
60
  }
@@ -10,5 +10,6 @@ export async function missionContext(tower, missionId) {
10
10
  const state = await tower.load()
11
11
  const mission = state.missions.find((entry) => entry.id === missionId)
12
12
  if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
13
- return missionContextView({ state, mission })
13
+ const archived = await tower.archiveIndex()
14
+ return missionContextView({ state, mission, archived: archived.ok ? archived.missions : [] })
14
15
  }
package/app/plan.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Mission planning use case.
2
+ import { TowerToolError } from '../domain/errors.js'
2
3
  import { nextMissionNumber, addAutoDeps, assertAcyclic } from '../domain/planning.js'
3
4
  import {
4
5
  normalizePlanMissions,
@@ -7,15 +8,58 @@ import {
7
8
  newMission,
8
9
  applyPlan,
9
10
  } from '../domain/missions/plan.js'
11
+ import { shapeFor } from '../domain/shapes/index.js'
10
12
  import { validateState } from '../domain/state.js'
11
13
  import { fullMission } from '../domain/summary.js'
12
14
  import { sweepArchive } from './archive.js'
15
+ import { surveyStaleWarnings } from './freshness.js'
16
+ import { readFacts } from './sharedCheckout.js'
17
+
18
+ /**
19
+ * Planning a shape whose optional component is off must fail with an actionable
20
+ * message, not silently plan work nothing can execute. Preset rows are only
21
+ * visible through the async probe: the synchronous `enabled` switch cannot see
22
+ * a declarative agent-preset registration.
23
+ * @param {any} tower
24
+ * @param {any} input
25
+ * @returns {Promise<void>}
26
+ */
27
+ async function assertShapeComponents(tower, input) {
28
+ const components = tower.deps?.components
29
+ if (!components) return
30
+ for (const mission of input?.missions ?? []) {
31
+ const required = shapeFor(mission?.kind).requiredComponent
32
+ if (!required) continue
33
+ if (typeof components.available === 'function') {
34
+ // null means "cannot tell" (no agent-presets service): let the plan pass,
35
+ // exactly as a host without the component port did before shapes existed.
36
+ if ((await components.available(required)) !== false) continue
37
+ } else if (components.enabled(required) === true) continue
38
+ throw new TowerToolError(
39
+ 'invalid-argument',
40
+ `the ${required} component is off: surveys cannot be planned; build missions are unaffected`,
41
+ { nextAction: `enable the ${required} component and plan the survey again` },
42
+ )
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {any} input
48
+ * @returns {boolean}
49
+ */
50
+ function planNeedsRead(input) {
51
+ return (input?.missions ?? []).some((mission) => shapeFor(mission?.kind).fields.readCommit)
52
+ }
13
53
 
14
54
  export async function plan(tower, input = {}) {
15
- const prepared = normalizePlanMissions(input)
16
55
  return tower.deps.store.withLock(tower.paths, async () => {
17
56
  const state = await tower.load()
18
57
  const now = Date.now()
58
+ // A survey records the commit it reads and the tracked-status fingerprint at
59
+ // plan time; a build plan reads nothing extra.
60
+ await assertShapeComponents(tower, input)
61
+ const read = planNeedsRead(input) ? await readFacts(tower, state.base) : {}
62
+ const prepared = normalizePlanMissions(input, read)
19
63
  // Archived ids stay in the known set: a new mission must not reuse an id
20
64
  // and a merged dependency may live in the archive index (#73).
21
65
  const archive = await tower.archiveIndex()
@@ -29,6 +73,24 @@ export async function plan(tower, input = {}) {
29
73
 
30
74
  const created = assigned.map((mission) => newMission(mission.id, mission, now))
31
75
  const applied = applyPlan(state, created)
76
+ // #148 W2: report survey evidence freshness at plan time. New builds have no
77
+ // provisioned base yet, so the warning compares against the state base; it
78
+ // is advisory here and never blocks the plan. Archived survey dependencies
79
+ // keep their read commit on the archive index, so they are checked too.
80
+ const archivedMissions = new Map(
81
+ (archive.ok ? archive.missions : []).map((entry) => [entry.id, entry]),
82
+ )
83
+ const warnings = []
84
+ for (const mission of created) {
85
+ warnings.push(
86
+ ...(await surveyStaleWarnings(tower, {
87
+ mission,
88
+ state,
89
+ missions: state.missions,
90
+ archived: archivedMissions,
91
+ })),
92
+ )
93
+ }
32
94
  const swept = await sweepArchive(tower, state, { now })
33
95
  state.revision += 1
34
96
  state.updatedAt = now
@@ -46,6 +108,7 @@ export async function plan(tower, input = {}) {
46
108
  return {
47
109
  revision: state.revision,
48
110
  missions: created.map((mission) => fullMission(mission)),
111
+ ...(warnings.length > 0 ? { warnings } : {}),
49
112
  ...(swept.archived.length > 0 ? { archived: swept.archived } : {}),
50
113
  ...(swept.error ? { archiveError: swept.error } : {}),
51
114
  }