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.
Files changed (116) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.en.md +2 -1
  5. package/README.md +6 -1
  6. package/adapters/leases.js +20 -6
  7. package/adapters/legacy.js +149 -37
  8. package/adapters/stateFile.js +11 -1
  9. package/app/archive.js +30 -5
  10. package/app/briefing.js +95 -12
  11. package/app/checkCompletion.js +107 -18
  12. package/app/enrichedMissions.js +1 -1
  13. package/app/freshness.js +77 -0
  14. package/app/gate.js +26 -12
  15. package/app/merge.js +7 -4
  16. package/app/mission.js +24 -1
  17. package/app/missionContext.js +2 -1
  18. package/app/plan.js +64 -1
  19. package/app/provision.js +133 -22
  20. package/app/read.js +38 -5
  21. package/app/rebase.js +10 -8
  22. package/app/review.js +97 -61
  23. package/app/reviewCheckout.js +133 -0
  24. package/app/sandbox.js +121 -0
  25. package/app/sharedCheckout.js +90 -0
  26. package/app/spawn-prechecks.js +25 -24
  27. package/app/spawn-support.js +59 -8
  28. package/app/spawn.js +3 -0
  29. package/app/teardown.js +119 -2
  30. package/app/tower.js +29 -1
  31. package/cordis.patch.yml +50 -0
  32. package/docs/adr/0006-mission-shapes.md +306 -0
  33. package/docs/architecture/overview.md +6 -4
  34. package/docs/guide/install-upgrade-migration.md +26 -0
  35. package/docs/operations/runbook.md +1 -1
  36. package/docs/reference/blockers.md +8 -4
  37. package/docs/reference/component-surface.md +7 -2
  38. package/docs/reference/config.generated.md +6 -4
  39. package/docs/reference/config.md +1 -2
  40. package/docs/reference/doctor.md +7 -7
  41. package/docs/reference/operations.md +1 -1
  42. package/docs/reference/shapes.md +123 -0
  43. package/docs/reference/surface-budget.md +33 -1
  44. package/docs/reference/testing.md +4 -2
  45. package/docs/reference/tools.generated.md +5 -5
  46. package/docs/reference/tools.md +2 -2
  47. package/docs/zh/reference/blockers.md +2 -2
  48. package/docs/zh/reference/config.md +1 -2
  49. package/docs/zh/reference/testing.md +3 -2
  50. package/domain/batch.js +75 -105
  51. package/domain/collab/batch.js +30 -10
  52. package/domain/collab/spawn.js +53 -16
  53. package/domain/config/schema.js +47 -17
  54. package/domain/contracts/outputs.js +0 -1
  55. package/domain/contracts/tool-inputs/missions.js +9 -1
  56. package/domain/evidence.js +283 -0
  57. package/domain/gate.js +11 -31
  58. package/domain/integration/gate.js +17 -43
  59. package/domain/integration/init.js +3 -2
  60. package/domain/integration/provision.js +51 -0
  61. package/domain/integration/rebase.js +16 -1
  62. package/domain/mission.js +16 -33
  63. package/domain/missions/completion.js +46 -8
  64. package/domain/missions/findings.js +2 -1
  65. package/domain/missions/lifecycle.js +131 -35
  66. package/domain/missions/plan.js +30 -6
  67. package/domain/missions/review.js +2 -18
  68. package/domain/planning.js +5 -5
  69. package/domain/read/missionContext.js +17 -3
  70. package/domain/read/views.js +5 -1
  71. package/domain/roles.js +222 -0
  72. package/domain/roster.js +9 -3
  73. package/domain/scope.js +51 -0
  74. package/domain/shapes/build.js +254 -0
  75. package/domain/shapes/index.js +171 -0
  76. package/domain/shapes/survey.js +445 -0
  77. package/domain/state/archive.js +28 -4
  78. package/domain/state/migrations.js +111 -4
  79. package/domain/state/schema.js +69 -7
  80. package/domain/state/semantics.js +93 -13
  81. package/domain/state.js +5 -1
  82. package/domain/summary.js +5 -11
  83. package/domain/vocabulary.js +15 -33
  84. package/host/capability-registry.js +25 -9
  85. package/host/doctor.js +2 -1
  86. package/host/preset-survey-sandbox.js +19 -0
  87. package/host/preset-survey.js +18 -0
  88. package/host/presets.js +8 -3
  89. package/host/tool-registry.js +14 -12
  90. package/locale/en.json +16 -1
  91. package/locale/preset-survey/en.json +6 -0
  92. package/locale/preset-survey/zh.json +6 -0
  93. package/locale/preset-survey-sandbox/en.json +6 -0
  94. package/locale/preset-survey-sandbox/zh.json +6 -0
  95. package/locale/zh.json +16 -1
  96. package/package.json +13 -2
  97. package/ports/state.js +8 -1
  98. package/scripts/architecture-waivers.json +3 -3
  99. package/scripts/check-docs.mjs +1 -1
  100. package/scripts/check-pack.mjs +2 -0
  101. package/scripts/component-surface.json +68 -20
  102. package/scripts/generate-capability-locale.mjs +5 -0
  103. package/scripts/generate-shapes-reference.mjs +171 -0
  104. package/scripts/mount-smoke/surface.mjs +3 -0
  105. package/scripts/pack-manifest.txt +21 -0
  106. package/scripts/shape-audit.mjs +361 -0
  107. package/scripts/surface-baseline.json +68 -4
  108. package/survey-sandbox.js +48 -0
  109. package/survey.js +40 -0
  110. package/tools/complete.js +5 -5
  111. package/tools/diff.js +5 -3
  112. package/tools/plan.js +1 -1
  113. package/tools/preflight.js +3 -2
  114. package/tools/spawn.js +47 -49
  115. package/tools/test.js +2 -1
  116. package/types/index.d.ts +66 -5
@@ -1,7 +1,55 @@
1
1
  // tower_spawn support (#56 P3c): briefing assembly, agent options, and the
2
2
  // wait/result settling path. Split out of tools/spawn.js; no I/O of its own.
3
+ import { shapeFor } from '../domain/shapes/index.js'
4
+ import { roleBriefingKind, rolePlacement } from '../domain/roles.js'
3
5
  import { workerBriefing, reviewerBriefing } from './briefing.js'
4
6
 
7
+ /**
8
+ * Resolve where a spawn runs (#149 W3). A worker gets the shape's provisioning
9
+ * (branch worktree, shared checkout, or detached survey sandbox); a reviewer
10
+ * gets a disposable detached checkout at the recorded branch tip, so the
11
+ * verdict is stamped with a commit instead of a live worktree HEAD.
12
+ * @param {any} tower
13
+ * @param {{ role: string, context: any, missionId: string, name: string, worktreeRoot?: string, includeDirtyBase?: boolean, instructions?: string }} input
14
+ */
15
+ export async function provisionSpawnPlacement(tower, { role, context, missionId, name, worktreeRoot, includeDirtyBase, instructions }) {
16
+ // #149 W4: the role contract decides the placement; the shape supplies the
17
+ // mission provisioning facts it does not own.
18
+ const reviewCheckout = rolePlacement(role) === 'review-checkout'
19
+ const shape = shapeFor(context.mission.kind)
20
+ const provisioned = reviewCheckout
21
+ ? null
22
+ : await tower.provision({ missionId, worktreeRoot, includeDirtyBase: includeDirtyBase !== false })
23
+ const review = reviewCheckout
24
+ ? await tower.provisionReviewCheckout({ missionId, name, worktreeRoot })
25
+ : null
26
+ const mission = review ? review.mission : provisioned.mission
27
+ const worktree = review ? mission.worktree : provisioned.worktree
28
+ const cwd = review ? review.path : (provisioned.cwd ?? worktree.path)
29
+ const branch = worktree?.branch ?? null
30
+ // The briefing must describe the checkout that actually exists: a detached
31
+ // sandbox is recorded only after provision, so use the provisioned mission.
32
+ const briefing = buildSpawnBriefing({
33
+ role,
34
+ towerRoot: tower.root,
35
+ context: /** @type {any} */ ({ ...context, mission }),
36
+ worktree,
37
+ review,
38
+ instructions,
39
+ })
40
+ return {
41
+ mission,
42
+ worktree,
43
+ cwd,
44
+ branch,
45
+ review,
46
+ briefing,
47
+ provisioning: review ? 'review-checkout' : (provisioned.provisioning ?? shape.provisioning),
48
+ created: review ? true : provisioned.created,
49
+ sandbox: provisioned?.sandbox ?? null,
50
+ }
51
+ }
52
+
5
53
  /**
6
54
  * @param {any} model
7
55
  */
@@ -14,25 +62,28 @@ export function buildAgentOptions(model) {
14
62
  }
15
63
 
16
64
  /**
17
- * @param {{ role: string, towerRoot: string, context: any, worktree: any, instructions: string | undefined }} input
65
+ * @param {{ role: string, towerRoot: string, context: any, worktree: any, review?: any, instructions: string | undefined }} input
18
66
  */
19
- export function buildSpawnBriefing({ role, towerRoot, context, worktree, instructions }) {
20
- const mission = {
21
- ...context.mission,
22
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
23
- }
67
+ export function buildSpawnBriefing({ role, towerRoot, context, worktree, review = null, instructions }) {
68
+ // Only a shape that owns a worktree gets the git checkout projection; a survey
69
+ // reads the shared checkout and must not see a worktree record it does not own.
70
+ const shape = shapeFor(context.mission.kind)
71
+ const mission = shape.fields.worktree
72
+ ? { ...context.mission, worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit } }
73
+ : { ...context.mission }
24
74
  const briefing = {
25
75
  repoRoot: towerRoot,
26
76
  base: context.base,
27
77
  mission,
28
78
  deps: context.deps,
29
- worktree,
79
+ worktree: worktree ?? null,
80
+ review,
30
81
  instructions,
31
82
  reviews: context.reviews,
32
83
  findings: context.findings,
33
84
  completionReport: context.completionReport,
34
85
  }
35
- return role === 'worker' ? workerBriefing(briefing) : reviewerBriefing(briefing)
86
+ return roleBriefingKind(role) === 'reviewer' ? reviewerBriefing(briefing) : workerBriefing(briefing)
36
87
  }
37
88
 
38
89
  /**
package/app/spawn.js CHANGED
@@ -96,6 +96,9 @@ export async function recordSpawn(tower, input) {
96
96
  const now = Date.now()
97
97
  const entry = recordSpawnEntry({
98
98
  roster: state.roster,
99
+ // The live-worker count resolves each roster row's kind, so the whole
100
+ // mission list is required: a survey row is not a build worker (#148).
101
+ missions: state.missions,
99
102
  mission,
100
103
  input,
101
104
  spawnTip,
package/app/teardown.js CHANGED
@@ -21,6 +21,106 @@ import {
21
21
  teardownRemovedDecision,
22
22
  } from '../domain/integration/teardown.js'
23
23
  import { sweepArchive } from './archive.js'
24
+ import { cleanupSandbox } from './sandbox.js'
25
+ import { cleanupReviewCheckout } from './reviewCheckout.js'
26
+
27
+ /**
28
+ * Disposable review checkouts (#149 W3). A reviewer works in a detached checkout
29
+ * at its recorded review commit and the checkout is removed when the verdict is
30
+ * recorded; a failed spawn or an interrupted reviewer leaves it behind, so
31
+ * teardown retries the bounded removal and clears the recorded path. A checkout
32
+ * whose reviewer is still active is kept. This runs before the worktree phase so
33
+ * its state revisions never invalidate the worktree claims.
34
+ * @param {any} tower
35
+ * @param {any} input
36
+ * @returns {Promise<{ decisions: any[], removed: string[] }>}
37
+ */
38
+ async function teardownReviewCheckouts(tower, input) {
39
+ const state = await tower.load()
40
+ const exclude = new Set(input.exclude ?? [])
41
+ const decisions = []
42
+ const removed = []
43
+ for (const entry of state.roster ?? []) {
44
+ const checkout = typeof entry.reviewCheckout === 'string' ? entry.reviewCheckout : null
45
+ if (!checkout) continue
46
+ const row = { name: entry.name, missionId: entry.missionId ?? null, checkout }
47
+ if (exclude.has(entry.missionId)) {
48
+ decisions.push({ ...row, action: 'keep', reason: 'excluded' })
49
+ continue
50
+ }
51
+ if (entry.state === 'active') {
52
+ decisions.push({ ...row, action: 'keep', reason: 'reviewer is active' })
53
+ continue
54
+ }
55
+ if (input.dryRun === true) {
56
+ decisions.push({ ...row, action: 'remove', reason: 'eligible' })
57
+ continue
58
+ }
59
+ const outcome = await cleanupReviewCheckout(tower, { name: entry.name, missionId: entry.missionId })
60
+ if (!outcome || outcome.removal.ok) {
61
+ decisions.push({ ...row, action: 'removed', reason: outcome ? 'checkout was already gone' : 'checkout and record were already gone' })
62
+ removed.push(entry.name)
63
+ continue
64
+ }
65
+ decisions.push({
66
+ ...row,
67
+ action: 'keep',
68
+ reason: `checkout cleanup failed after ${outcome.removal.attempts} attempt(s): ${outcome.removal.error}`,
69
+ })
70
+ }
71
+ return { decisions, removed }
72
+ }
73
+
74
+ /**
75
+ * Disposable detached survey sandboxes (#148 W2). A terminal survey may still
76
+ * carry a sandbox after a failed cleanup; teardown retries with the same bounded
77
+ * removal as completion. Run before the worktree phase so its state revisions
78
+ * never invalidate the worktree claims.
79
+ * @param {any} tower
80
+ * @param {any} input
81
+ * @returns {Promise<{ decisions: any[], removed: string[] }>}
82
+ */
83
+ async function teardownSandboxes(tower, input) {
84
+ const state = await tower.load()
85
+ const activity = input.activity ?? {}
86
+ const exclude = new Set(input.exclude ?? [])
87
+ const decisions = []
88
+ const removed = []
89
+ for (const mission of state.missions) {
90
+ const sandbox = typeof /** @type {any} */ (mission).sandbox === 'string' ? /** @type {any} */ (mission).sandbox : null
91
+ if (!sandbox) continue
92
+ if (exclude.has(mission.id)) {
93
+ decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: 'excluded' })
94
+ continue
95
+ }
96
+ if (hasLiveAgent(state, mission.id, activity)) {
97
+ decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: 'live agent' })
98
+ continue
99
+ }
100
+ if (!['completed', 'abandoned'].includes(mission.status)) {
101
+ decisions.push({ missionId: mission.id, action: 'keep', sandbox, reason: `mission is ${mission.status}` })
102
+ continue
103
+ }
104
+ if (input.dryRun === true) {
105
+ decisions.push({ missionId: mission.id, action: 'remove', sandbox, reason: 'eligible' })
106
+ continue
107
+ }
108
+ const outcome = await cleanupSandbox(tower, mission.id)
109
+ if (!outcome) {
110
+ decisions.push({ missionId: mission.id, action: 'removed', sandbox, reason: 'sandbox was already gone' })
111
+ removed.push(mission.id)
112
+ continue
113
+ }
114
+ if (outcome.ok) removed.push(mission.id)
115
+ decisions.push({
116
+ missionId: mission.id,
117
+ action: outcome.ok ? 'removed' : 'keep',
118
+ sandbox,
119
+ ...(outcome.ok ? {} : { reason: `sandbox cleanup failed after ${outcome.attempts} attempt(s): ${outcome.error}` }),
120
+ })
121
+ }
122
+ return { decisions, removed }
123
+ }
24
124
 
25
125
  /**
26
126
  * Phase 1 (#51): decide every keep/remove outcome without holding the state
@@ -87,8 +187,23 @@ async function claimRemoval(tower, candidate, expectedRevision, input) {
87
187
  }
88
188
 
89
189
  export async function teardown(tower, input) {
190
+ // Sandbox cleanup runs first: it owns its own short locks and revisions, so
191
+ // the worktree prepare/act/commit phases below see a stable revision.
192
+ // Review checkout cleanup first: it owns its own short locks and revisions, so
193
+ // the worktree prepare/act/commit phases below see a stable revision.
194
+ const reviews = await teardownReviewCheckouts(tower, input)
195
+ const sandboxes = await teardownSandboxes(tower, input)
90
196
  const prepared = await prepareTeardown(tower, input)
91
- if (prepared.dryRun) return { ok: true, dryRun: true, removed: [], decisions: prepared.decisions }
197
+ if (prepared.dryRun) {
198
+ return {
199
+ ok: true,
200
+ dryRun: true,
201
+ removed: [],
202
+ decisions: [...reviews.decisions, ...sandboxes.decisions, ...prepared.decisions],
203
+ reviewCheckoutsRemoved: [],
204
+ sandboxesRemoved: [],
205
+ }
206
+ }
92
207
 
93
208
  const removed = []
94
209
  let expectedRevision = prepared.stateRevision
@@ -197,7 +312,9 @@ export async function teardown(tower, input) {
197
312
  ok: true,
198
313
  dryRun: false,
199
314
  removed,
200
- decisions: prepared.decisions,
315
+ decisions: [...reviews.decisions, ...sandboxes.decisions, ...prepared.decisions],
316
+ reviewCheckoutsRemoved: reviews.removed,
317
+ sandboxesRemoved: sandboxes.removed,
201
318
  archived: archive.archived,
202
319
  ...(archive.error ? { archiveError: archive.error } : {}),
203
320
  }
package/app/tower.js CHANGED
@@ -28,6 +28,7 @@ import { writeBoard, boardFile } from './board.js'
28
28
  import { archiveIndex, archivedMission } from './archive.js'
29
29
  import { recordSpawnOutcome, recordInterrupt, reserveRoster, recordSpawn, rosterView } from './spawn.js'
30
30
  import { recordTestRun, recordLegacySnapshot, recordLegacyMap } from './recording.js'
31
+ import { resolveLegacySurveyRead } from './sharedCheckout.js'
31
32
 
32
33
  export const ROLES = ['worker', 'reviewer']
33
34
  const MAX_REVIEW_ROUNDS = 5
@@ -99,6 +100,7 @@ import { rebase } from './rebase.js'
99
100
  import { merge } from './merge.js'
100
101
  import { init } from './init.js'
101
102
  import { provision } from './provision.js'
103
+ import { provisionReviewCheckout } from './reviewCheckout.js'
102
104
  import { addFinding, readFindings, findings } from './findings.js'
103
105
  import { recordMessage, recordDelivery, inbox } from './messages.js'
104
106
  import { createBatch, closeBatch, abandonBatch, batchStatus } from './batch.js'
@@ -170,6 +172,20 @@ export class Tower {
170
172
  return (await this.git(cwd, ['rev-parse', ref])).trim()
171
173
  }
172
174
 
175
+ /**
176
+ * The commit a mission branch points at, resolved from the branch ref. Never
177
+ * reads a live worktree checkout: a checkout is a view, the ref is the fact
178
+ * (#149 W3 review evidence and every tip comparison use this).
179
+ * @param {{ id?: string, worktree?: { branch?: string } | null }} mission
180
+ */
181
+ async branchTip(mission) {
182
+ const branch = mission?.worktree?.branch
183
+ if (typeof branch !== 'string' || branch.length === 0) {
184
+ fail(`mission ${mission?.id ?? ''} has no branch to resolve`.replace(' ', ' '), 'invalid-argument')
185
+ }
186
+ return this.revParse(this.root, `refs/heads/${branch}`)
187
+ }
188
+
173
189
  async isClean(cwd) {
174
190
  return (await this.git(cwd, ['status', '--porcelain'])).trim().length === 0
175
191
  }
@@ -193,6 +209,9 @@ export class Tower {
193
209
  paths: this.paths,
194
210
  now: this.deps.clock?.now ? this.deps.clock.now() : Date.now(),
195
211
  persist: options.persist,
212
+ // A legacy survey planned but never provisioned recorded no base; the
213
+ // loader resolves it once, from this checkout, before the pure migration.
214
+ resolveLegacySurvey: (rawState) => resolveLegacySurveyRead(this, rawState),
196
215
  })
197
216
  this.stateLoad = { ...loaded.version, migration: loaded.migration }
198
217
  return loaded.state
@@ -279,6 +298,15 @@ export class Tower {
279
298
  return provision(this, input)
280
299
  }
281
300
 
301
+ /**
302
+ * Provision the disposable detached review checkout for a reviewer (#149 W3).
303
+ * The commit it returns is the reviewer's evidence for every later read and
304
+ * the verdict stamp.
305
+ */
306
+ async provisionReviewCheckout(input = {}) {
307
+ return provisionReviewCheckout(this, input)
308
+ }
309
+
282
310
  async missionContext(missionId) {
283
311
  return missionContext(this, missionId)
284
312
  }
@@ -521,7 +549,7 @@ export class Tower {
521
549
  let branchTip
522
550
  try {
523
551
  baseTip = await this.revParse(this.root, state.base)
524
- branchTip = await this.revParse(mission.worktree.path, 'HEAD')
552
+ branchTip = await this.branchTip(mission)
525
553
  } catch {
526
554
  return null
527
555
  }
package/cordis.patch.yml CHANGED
@@ -110,3 +110,53 @@
110
110
  name: '@deepseek-ai/dsh-tool-fs-search'
111
111
  config:
112
112
  sampleOverCapGlobResults: false
113
+ # #148 D7/W2: one visible survey capability row. The group is the switch:
114
+ # both the read-only preset and the detached-sandbox preset are nested
115
+ # dependencies of it, so the sandbox placement is tuning and adds no second
116
+ # visible component. Off means a survey cannot be planned (tower_plan refuses
117
+ # with an actionable code) and no survey agent can be dispatched; build
118
+ # missions are unaffected. Read-only remains structural for the shared
119
+ # placement (the preset allow-list has no write, edit, or shell tool); the
120
+ # sandbox preset exists only for `survey.sandbox: 'detached'`.
121
+ - id: tower-survey
122
+ name: cordis:group
123
+ group: true
124
+ config:
125
+ - id: preset-tower-survey
126
+ name: 'dsh-tower/preset-survey'
127
+ config:
128
+ id: tower-survey
129
+ name: Tower Survey
130
+ description: Read-only inspection of one recorded commit in the shared checkout.
131
+ order: 62
132
+ plugins:
133
+ - id: tower-survey-policy
134
+ name: 'dsh-tower/survey'
135
+ - id: tower-survey-fs
136
+ name: '@deepseek-ai/dsh-tool-fs'
137
+ config:
138
+ write: false
139
+ edit: false
140
+ readImage: false
141
+ - id: tower-survey-fs-search
142
+ name: '@deepseek-ai/dsh-tool-fs-search'
143
+ config:
144
+ sampleOverCapGlobResults: false
145
+ - id: preset-tower-survey-sandbox
146
+ name: 'dsh-tower/preset-survey-sandbox'
147
+ config:
148
+ id: tower-survey-sandbox
149
+ name: Tower Survey Sandbox
150
+ description: Disposable detached checkout for a survey that may write or run commands while reading one recorded commit.
151
+ order: 63
152
+ plugins:
153
+ - id: tower-survey-sandbox-policy
154
+ name: 'dsh-tower/survey-sandbox'
155
+ - id: tower-survey-sandbox-fs
156
+ name: '@deepseek-ai/dsh-tool-fs'
157
+ - id: tower-survey-sandbox-fs-search
158
+ name: '@deepseek-ai/dsh-tool-fs-search'
159
+ config:
160
+ sampleOverCapGlobResults: false
161
+ - id: tower-survey-sandbox-pwsh
162
+ name: '@deepseek-ai/dsh-tool-pwsh'