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
@@ -4,7 +4,7 @@
4
4
  "surfaces": {
5
5
  "configKeys": {
6
6
  "seed": 64,
7
- "value": 54,
7
+ "value": 56,
8
8
  "decisions": [
9
9
  {
10
10
  "from": 64,
@@ -19,13 +19,70 @@
19
19
  "date": "2026-11-03",
20
20
  "adr": "docs/adr/0005-component-surface.md",
21
21
  "note": "#M39: one new key, review.mode (off/optional/required), replaces the deprecated review.requireReview boolean as the review policy. It is not a fifth component row: the reviewer preset row stays a capability, and the mode decides whether it is required (required) or unused (off). review.requireReview remains accepted for one release as a normalized alias, so it is a deprecated key rather than a removed one and the surface grows by exactly one."
22
+ },
23
+ {
24
+ "from": 54,
25
+ "to": 57,
26
+ "date": "2026-11-10",
27
+ "adr": "docs/adr/0006-mission-shapes.md",
28
+ "note": "#148/#149: the shape redesign adds the survey routing keys spawn.surveyPreset (core) and spawn.surveyModel (tuning), plus survey.sandbox (tuning) in the new survey section. tower-survey is the capability row; the preset/model names only route the read-only agent."
29
+ },
30
+ {
31
+ "from": 57,
32
+ "to": 55,
33
+ "date": "2026-11-10",
34
+ "adr": "docs/adr/0006-mission-shapes.md",
35
+ "note": "Review round 2 contracts the shape surface before release: spawn.surveyPreset could never be read (a survey's preset is shape-owned, so the key could only disagree with the registry) and survey.sandbox advertised a placement W1 does not implement. Only spawn.surveyModel remains as the optional model route; both removals are recorded here rather than silently shrinking the two-way surface."
36
+ },
37
+ {
38
+ "from": 55,
39
+ "to": 56,
40
+ "date": "2026-11-10",
41
+ "adr": "docs/adr/0006-mission-shapes.md",
42
+ "note": "#148 W2 implements the detached survey sandbox, so survey.sandbox returns as one tuning key: none (read-only shared checkout, the default and unchanged W1 behavior) or detached (a disposable detached worktree with a write/shell-capable preset). It is placement tuning, not a capability row or a second routing key; spawn.surveyPreset stays removed because the shape resolves the preset."
43
+ },
44
+ {
45
+ "from": 56,
46
+ "to": 57,
47
+ "date": "2026-11-10",
48
+ "adr": "docs/adr/0006-mission-shapes.md",
49
+ "note": "#148 W2 D5 adds gates.requireFreshSurvey (default false): the survey-stale dependency warning is advisory by default and becomes a merge blocker only when this gate is true. It is a merge-gate strictness key like the other gates.* entries, not a component."
50
+ },
51
+ {
52
+ "from": 57,
53
+ "to": 56,
54
+ "date": "2026-11-10",
55
+ "adr": "docs/adr/0006-mission-shapes.md",
56
+ "note": "#149 W4 contracts the surface by one: `tests.autoChecks` existed only to copy a discovery projection into the completion report, which made a second evidence shape. The receipt in mission.testRuns is now the one evidence shape read by domain/evidence.js, so the key has no remaining meaning and is removed rather than kept as a dead tuning switch."
22
57
  }
23
58
  ]
24
59
  },
25
60
  "sections": {
26
61
  "seed": 15,
27
- "value": 14,
28
- "decisions": []
62
+ "value": 15,
63
+ "decisions": [
64
+ {
65
+ "from": 15,
66
+ "to": 15,
67
+ "date": "2026-11-10",
68
+ "adr": "docs/adr/0006-mission-shapes.md",
69
+ "note": "#148/#149 restore the section count to its seed: survey.sandbox needs a home, so the survey section returns the 15 sections that #121 shrank to 14. Net zero against the seed; no other section changed."
70
+ },
71
+ {
72
+ "from": 15,
73
+ "to": 14,
74
+ "date": "2026-11-10",
75
+ "adr": "docs/adr/0006-mission-shapes.md",
76
+ "note": "Review round 2 removes the survey section with its only key: survey.sandbox advertised a detached read-only checkout W1 does not implement. The section existed only to hold that key, so the count returns to 14."
77
+ },
78
+ {
79
+ "from": 14,
80
+ "to": 15,
81
+ "date": "2026-11-10",
82
+ "adr": "docs/adr/0006-mission-shapes.md",
83
+ "note": "#148 W2 restores the survey section for its one tuning key, survey.sandbox (placement: none|detached). The section count returns to the 15 of the earlier shape cut; no other section changed and no component row was added (the sandbox preset is an internal child of the existing tower-survey group)."
84
+ }
85
+ ]
29
86
  },
30
87
  "tools": {
31
88
  "seed": 27,
@@ -45,7 +102,7 @@
45
102
  },
46
103
  "components": {
47
104
  "seed": 4,
48
- "value": 11,
105
+ "value": 12,
49
106
  "decisions": [
50
107
  {
51
108
  "from": 4,
@@ -81,6 +138,13 @@
81
138
  "date": "2026-11-02",
82
139
  "adr": "docs/adr/0005-component-surface.md",
83
140
  "note": "#134: the gate reads every shipped cordis.patch.yml, so the tower-ui row in ui-bundle/cordis.patch.yml joins the counted surface. The row was always shipped and unclassified; counting it is not new surface, it is the existing surface becoming visible to the policy."
141
+ },
142
+ {
143
+ "from": 11,
144
+ "to": 12,
145
+ "date": "2026-11-10",
146
+ "adr": "docs/adr/0006-mission-shapes.md",
147
+ "note": "#148/#149: tower-survey is a visible optional row: off means surveys cannot be planned and no survey agent is dispatched; build missions are unaffected."
84
148
  }
85
149
  ]
86
150
  }
@@ -0,0 +1,48 @@
1
+ // tower-survey-sandbox preset policy (#148 W2): a survey that reads in a
2
+ // disposable detached sandbox may write and run commands *inside that sandbox*,
3
+ // but it still owns no branch, never commits, and may not merge, rebase, review
4
+ // or spawn. The allow-list is the structural guarantee: the denied tools do not
5
+ // exist in the session, so a prompt cannot talk the agent into using them.
6
+ //
7
+ // The shared-checkout survey preset (`dsh-tower/survey`) stays strictly
8
+ // read-only; this module is mounted only for a detached sandbox.
9
+ export const name = 'dsh-tower/survey-sandbox'
10
+ export const inject = ['tools', 'systemPrompt']
11
+
12
+ const ALLOW = [
13
+ 'read',
14
+ 'write',
15
+ 'edit',
16
+ 'glob',
17
+ 'grep',
18
+ 'pwsh',
19
+ 'tower_mission',
20
+ 'tower_status',
21
+ 'tower_finding',
22
+ 'tower_findings',
23
+ 'tower_inbox',
24
+ 'tower_complete',
25
+ 'skill',
26
+ ]
27
+
28
+ const PROTOCOL = [
29
+ 'You are a Tower survey worker reading one recorded commit in a disposable detached sandbox.',
30
+ 'Work only inside the sandbox path named in your briefing; never modify the main checkout.',
31
+ 'You may write files and run shell commands inside the sandbox, but do not commit: completion requires',
32
+ 'the sandbox HEAD to still be the recorded commit and zero new commits. The sandbox is removed when the mission completes.',
33
+ 'You cannot spawn, merge, rebase, review or diff. Acknowledge inbox mail with tower_inbox; unread messages block completion.',
34
+ 'Finish with tower_complete (report + checks evidence, optionally result:{paths,symbols,plan,notes}); the result is survey evidence only.',
35
+ ].join(' ')
36
+
37
+ export function apply(ctx) {
38
+ try {
39
+ ctx.tools.restrict({ allow: ALLOW })
40
+ } catch {
41
+ // a composition without the tool registry still gets the protocol text
42
+ }
43
+ try {
44
+ ctx.systemPrompt.section({ name: 'tower:survey-sandbox-protocol', order: 12000, text: PROTOCOL })
45
+ } catch {
46
+ // prompt assembly remains valid without the section
47
+ }
48
+ }
package/survey.js ADDED
@@ -0,0 +1,40 @@
1
+ // tower-survey preset policy: a read-only inspection surface. The allow-list is
2
+ // the structural read-only guarantee (#148): a survey cannot write, edit, run a
3
+ // shell, commit, or merge because the tools do not exist in its session.
4
+ export const name = 'dsh-tower/survey'
5
+ export const inject = ['tools', 'systemPrompt']
6
+
7
+ const ALLOW = [
8
+ 'read',
9
+ 'glob',
10
+ 'grep',
11
+ 'tower_mission',
12
+ 'tower_status',
13
+ 'tower_finding',
14
+ 'tower_findings',
15
+ 'tower_inbox',
16
+ 'tower_complete',
17
+ 'skill',
18
+ ]
19
+
20
+ const PROTOCOL = [
21
+ 'You are a Tower survey worker in a DeepSeek Harness Tower workspace.',
22
+ 'You read one recorded commit in the shared checkout and never modify it: do not write, edit, run a shell,',
23
+ 'commit, rebase, or merge. Your tools are read, glob, grep, skill, and the Tower mission/status/finding/inbox/complete tools.',
24
+ 'Acknowledge inbox mail with tower_inbox (it records Tower state, not a repository write): unread messages block completion.',
25
+ 'Finish with tower_complete (report + checks evidence, optionally result:{paths,symbols,notes}); completion requires',
26
+ 'HEAD to still be the recorded commit, the tracked-file status set to be unchanged, and zero commits.',
27
+ ].join(' ')
28
+
29
+ export function apply(ctx) {
30
+ try {
31
+ ctx.tools.restrict({ allow: ALLOW })
32
+ } catch {
33
+ // a composition without the tool registry still gets the protocol text
34
+ }
35
+ try {
36
+ ctx.systemPrompt.section({ name: 'tower:survey-protocol', order: 12000, text: PROTOCOL })
37
+ } catch {
38
+ // prompt assembly remains valid without the section
39
+ }
40
+ }
package/tools/complete.js CHANGED
@@ -13,7 +13,7 @@ export function registerCompleteTool(ctx, deps) {
13
13
  define(ctx, {
14
14
  name: 'tower_complete',
15
15
  description:
16
- 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report, capped at gates.maxReportBytes (default 32768 UTF-8 bytes); a larger report is rejected with invalid-argument and an actionable nextAction, never truncated. Persists reportBytes on the stored report. completion means ready for preflight and optional review, not merged.',
16
+ 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base (zero commits plus HEAD pinned to readCommit for a survey; zero commits in its detached sandbox with HEAD pinned for a sandboxed survey), and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. result is survey-only evidence ({paths?,symbols?,plan?,notes?}); a build completion that carries one is refused. Stores the full report, capped at gates.maxReportBytes (default 32768 UTF-8 bytes); a larger report is rejected with invalid-argument and an actionable nextAction, never truncated. Persists reportBytes on the stored report. completion means ready for preflight and optional review, not merged.',
17
17
  parameters: CompleteInput,
18
18
  aliases: {"missionId":["id"]},
19
19
  requiresMissionId: true,
@@ -38,15 +38,15 @@ export function registerCompleteTool(ctx, deps) {
38
38
  if (discovery.configured && discovery.receipt.ok !== true) {
39
39
  throw new TowerToolError('tests-failed', `test discovery failed for ${args.missionId}: ${discovery.receipt.error}`)
40
40
  }
41
- if (discovery.configured && testsConfig.autoChecks !== false && discovery.check) {
42
- if (checks.length >= 20) checks[19] = discovery.check
43
- else checks.push(discovery.check)
44
- }
45
41
  }
42
+ // #149 W4: the receipt already lives in mission.testRuns and is read by
43
+ // domain/evidence.js. Copying a `tests` projection into the completion
44
+ // report would be a second evidence shape, so it is not done here.
46
45
  return tower.complete({
47
46
  missionId: args.missionId,
48
47
  agentId: String(exec.agent.id),
49
48
  report: args.report,
49
+ result: args.result,
50
50
  checks,
51
51
  })
52
52
  },
package/tools/diff.js CHANGED
@@ -18,7 +18,7 @@ import { DiffInput } from '../domain/contracts/tool-inputs/index.js'
18
18
 
19
19
  /**
20
20
  * @param {any} ctx
21
- * @param {{ define: import('../types/index.js').DefineTool, openTower: any }} deps
21
+ * @param {{ define: import('../types/index.js').DefineTool, openTower: any, actorFor: any }} deps
22
22
  */
23
23
  export function registerDiffTool(ctx, deps) {
24
24
  // openTower is injected by the composition root (#69).
@@ -31,9 +31,11 @@ export function registerDiffTool(ctx, deps) {
31
31
  aliases: {"missionId":["id"]},
32
32
  requiresMissionId: true,
33
33
  output: output(SCHEMAS.diff),
34
- async execute(args) {
34
+ async execute(args, exec) {
35
35
  const tower = await deps.openTower(args.repoRoot)
36
- return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
36
+ // A reviewer's diff is bound to its recorded review commit (#149 W3).
37
+ const actor = await deps.actorFor(tower, exec)
38
+ return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes, actor })
37
39
  },
38
40
  })
39
41
  }
package/tools/plan.js CHANGED
@@ -25,7 +25,7 @@ export function registerPlanTool(ctx, deps) {
25
25
  define(ctx, {
26
26
  name: 'tower_plan',
27
27
  description:
28
- 'Plan one or more missions in an initialized repository. Each mission has a printable-Unicode title (NFC-normalized, at most 120 characters), relative scope globs, optional task checklist, optional mission-id dependencies, and kind build|survey. Assigns M1.. ids and auto-adds a dependency when build scopes overlap (serializes merges); rejects dependency cycles.',
28
+ 'Plan one or more missions in an initialized repository. Each mission has a printable-Unicode title (NFC-normalized, at most 120 characters), relative scope globs, optional task checklist, optional mission-id dependencies, and kind build|survey. Assigns M1.. ids and auto-adds a dependency when build scopes overlap (serializes merges); rejects dependency cycles. Returns survey-stale warnings for a build that depends on a survey whose recorded readCommit is not an ancestor of the base; those warnings are advisory and gates.requireFreshSurvey promotes them to merge blockers.',
29
29
  parameters: PlanInput,
30
30
  board: true,
31
31
  output: output(SCHEMAS.plan),
@@ -12,7 +12,7 @@ export function registerPreflightTool(ctx, deps) {
12
12
  define(ctx, {
13
13
  name: 'tower_preflight',
14
14
  description:
15
- 'Read-only merge gate for one mission: returns ok plus stable blocker codes (branch-missing, deps-unmerged, read-only-survey, stale-base, out-of-scope, base-dirty, branch-owned-by-closed-missions, and mission/worker guards). Review blockers (no-review, review-hold, not-clean, review-not-approved, tip-moved, review-mission-mismatch) apply only when review.mode is required; review.mode off and optional skip them. Never changes anything.',
15
+ 'Read-only merge gate for one mission: returns ok plus stable blocker codes (branch-missing, deps-unmerged, stale-base, out-of-scope, base-dirty, branch-owned-by-closed-missions, and mission/worker guards) and warnings (survey-stale). Review blockers (no-review, review-hold, not-clean, review-not-approved, tip-moved, review-mission-mismatch) apply only when review.mode is required; review.mode off and optional skip them. survey-stale reports a build dependency on a survey whose readCommit is not an ancestor-or-equal of the build base; it is a warning by default and a blocker only when gates.requireFreshSurvey is true. A survey has no merge gate: it is refused with invalid-transition (tower_complete checks HEAD/readCommit and zero commits). Never changes anything.',
16
16
  parameters: PreflightInput,
17
17
  aliases: {"missionId":["id"]},
18
18
  requiresMissionId: true,
@@ -20,10 +20,11 @@ export function registerPreflightTool(ctx, deps) {
20
20
  async execute(args) {
21
21
  const tower = await deps.openTower(args.repoRoot)
22
22
  const { activity, parentStatus } = await observeActivity(ctx, tower)
23
- const gate = await tower.evaluateGate({ missionId: args.missionId, activity, parentStatus })
23
+ const gate = await tower.evaluateGate({ missionId: args.missionId, activity, parentStatus, action: 'preflight' })
24
24
  return {
25
25
  ok: gate.ok,
26
26
  blockers: gate.blockers,
27
+ warnings: gate.warnings ?? [],
27
28
  missionId: args.missionId,
28
29
  branchTip: gate.branchTip ?? null,
29
30
  baseTip: gate.baseTip ?? null,
package/tools/spawn.js CHANGED
@@ -2,47 +2,27 @@
2
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
3
3
  import { TowerToolError, classifyError } from '../domain/errors.js'
4
4
  import { SpawnInput } from '../domain/contracts/tool-inputs/index.js'
5
- import { DEFAULT_ROLE, ROLES as ROLE_ENUM, SPAWN_OUTCOME, isCompletedStopReason, isReviewerRole, isSpawnAdmission, isWorkerRole } from '../domain/vocabulary.js'
5
+ import { defaultPresetFor, shapeFor } from '../domain/shapes/index.js'
6
+ import { DEFAULT_ROLE, ROLES as ROLE_ENUM, SPAWN_OUTCOME, isCompletedStopReason, isSpawnAdmission } from '../domain/vocabulary.js'
7
+ import { resolveRoleSpawn, roleSpawnDiscovery } from '../domain/roles.js'
6
8
  import {
7
9
  assertSpawnableMission,
8
10
  precheckSpawnServices,
9
11
  precheckWorkerOwnership,
10
12
  resolveRosterName,
11
13
  } from '../app/spawn-prechecks.js'
12
- import { buildAgentOptions, buildSpawnBriefing, waitForSpawnResult } from '../app/spawn-support.js'
13
-
14
- /**
15
- * @param {any} ctx
16
- * @param {{
17
- * define: import('../types/index.js').DefineTool,
18
- * openTower: any,
19
- * actorFor: any,
20
- * requireTowerActor: any,
21
- * enqueueSpawn: any,
22
- * observeActivity: any,
23
- * spawnBackoffState: any,
24
- * recordSpawnThrottle: any,
25
- * recordSpawnSuccess: any,
26
- * runMissionDiscovery: any,
27
- * textOf: any, inspectLeases: any,
28
- * leaseComponent?: () => { enabled: boolean, reason: string },
29
- * }} deps
30
- */
14
+ import { buildAgentOptions, provisionSpawnPlacement, waitForSpawnResult } from '../app/spawn-support.js'
15
+ import { cleanupReviewCheckout } from '../app/reviewCheckout.js'
16
+ /** @param {any} ctx
17
+ * @param {{ define: import('../types/index.js').DefineTool, [key: string]: any }} deps */
31
18
  export function registerSpawnTool(ctx, deps) {
32
- const {
33
- define, actorFor, requireTowerActor, enqueueSpawn, observeActivity,
34
- spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess,
35
- runMissionDiscovery, textOf,
36
- } = deps
19
+ const { define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf } = deps
37
20
  define(ctx, {
38
21
  name: 'tower_spawn',
39
22
  description:
40
- 'Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset, and the reviewer role is refused when review.mode is off. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true.',
41
- parameters: SpawnInput,
23
+ 'Spawn a worker or reviewer for a mission. Workers use the mission shape\'s preset (tower-worker for a build, tower-survey for a read-only shared survey, tower-survey-sandbox for a detached survey with survey.sandbox=detached); reviewers require a completed build mission and use the read-only tower-reviewer preset, and the reviewer role is refused when review.mode is off. A build provisions (or reuses) the mission git worktree and branch; a shared survey runs in the shared checkout because it reads one commit; a detached survey provisions (or reuses) a disposable detached worktree at its readCommit with no branch. Generates a self-contained briefing (including dependency survey result evidence) and starts a background subagent. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns get a disposable detached checkout at the recorded branch tip, and the verdict is stamped with that commit (never a live worktree HEAD). Returns immediately unless wait=true.', parameters: SpawnInput,
42
24
  aliases: {"missionId":["id"],"waitMs":["wait_ms"]},
43
- requiresMissionId: true,
44
- board: true,
45
- output: output(SCHEMAS.spawn),
25
+ requiresMissionId: true, board: true, output: output(SCHEMAS.spawn),
46
26
  async execute(args, exec) {
47
27
  const tower = await deps.openTower(args.repoRoot)
48
28
  const actor = await actorFor(tower, exec)
@@ -51,10 +31,18 @@ export function registerSpawnTool(ctx, deps) {
51
31
  if (!ROLE_ENUM.includes(role)) throw new TowerToolError('invalid-argument', `role must be one of ${ROLE_ENUM.join('|')}`)
52
32
  // Repository config (if any) already overrides the profile config here.
53
33
  const config = tower.config
54
- const preset = isReviewerRole(role) ? config.spawn.reviewerPreset : config.spawn.workerPreset
55
- const model = isReviewerRole(role) ? config.spawn.reviewerModel : config.spawn.workerModel
56
34
  const context = await tower.missionContext(args.missionId)
57
35
  assertSpawnableMission({ role, context, config })
36
+ const shape = shapeFor(context.mission.kind)
37
+ // #148 W2: the shape resolves its preset from tuning config, so a detached
38
+ // survey gets the sandbox preset and a shared survey stays read-only.
39
+ // #149 W4: role resolution (preset row, model route, placement, briefing)
40
+ // is one contract, orthogonal to the shape that supplies the facts.
41
+ const shapePreset = defaultPresetFor(context.mission.kind, config, context.mission)
42
+ const roleSpawn = resolveRoleSpawn(role, { config, shape, shapePreset })
43
+ if (!roleSpawn) throw new TowerToolError('invalid-argument', `role must be one of ${ROLE_ENUM.join('|')}`)
44
+ const preset = roleSpawn.preset
45
+ const model = roleSpawn.model
58
46
  const spawned = await enqueueSpawn(tower.root, async () => {
59
47
  const observed = await observeActivity(ctx, tower)
60
48
  await tower.reconcileObservedInactive(observed.activity)
@@ -65,26 +53,25 @@ export function registerSpawnTool(ctx, deps) {
65
53
  }
66
54
  const view = await tower.rosterView()
67
55
  const name = resolveRosterName({ role, missionId: args.missionId, requested: args.name, roster: view.roster })
68
- const takeover = await precheckWorkerOwnership({
69
- role, tower, ctx, view, missionId: args.missionId, takeover: args.takeover,
70
- maxLiveWorkers: config.spawn.maxLiveWorkers, budget: backoff.budget, observeActivity,
71
- })
56
+ const takeover = await precheckWorkerOwnership({ role, tower, ctx, view, missionId: args.missionId, takeover: args.takeover, maxLiveWorkers: config.spawn.maxLiveWorkers, budget: backoff.budget, observeActivity })
72
57
  const { subagents, provider } = await precheckSpawnServices({
73
58
  tower, ctx, config, preset, actor, exec, inspectLeases: deps.inspectLeases,
74
59
  // #131: the app layer only needs the row fact; the root owns the registry.
75
60
  leaseComponent: deps.leaseComponent,
76
61
  })
77
62
 
78
- const provisioned = await tower.provision({
79
- missionId: args.missionId,
63
+ const { mission, worktree, cwd, branch, review, briefing, provisioning, created, sandbox } = await provisionSpawnPlacement(tower, {
64
+ role,
65
+ context,
66
+ missionId: /** @type {string} */ (args.missionId),
67
+ name,
80
68
  worktreeRoot: args.worktreeRoot,
81
- includeDirtyBase: args.includeDirtyBase !== false,
69
+ includeDirtyBase: args.includeDirtyBase,
70
+ instructions: args.instructions,
82
71
  })
83
- const { mission, worktree } = provisioned
84
- const briefing = buildSpawnBriefing({ role, towerRoot: tower.root, context, worktree, instructions: args.instructions })
85
72
  const agentOptions = buildAgentOptions(model)
86
73
 
87
- if (isWorkerRole(role) && isSpawnAdmission(tower.config.tests?.admission)) {
74
+ if (roleSpawnDiscovery(role, shape) && isSpawnAdmission(tower.config.tests?.admission)) {
88
75
  const discovery = await runMissionDiscovery(tower, mission)
89
76
  if (discovery.configured && discovery.receipt.ok !== true) {
90
77
  throw new TowerToolError(
@@ -102,15 +89,17 @@ export function registerSpawnTool(ctx, deps) {
102
89
  name,
103
90
  role,
104
91
  parentSessionId: String(exec.agent.id),
105
- cwd: worktree.path,
106
- branch: worktree.branch,
92
+ cwd,
93
+ branch,
94
+ reviewCommit: review?.commit,
95
+ reviewCheckout: review?.path,
107
96
  })
108
97
  try {
109
98
  activation = await subagents.startActivation({
110
99
  provider,
111
100
  label: name,
112
101
  request: {
113
- cwd: worktree.path,
102
+ cwd,
114
103
  agentPreset: preset,
115
104
  prompt: [{ type: 'text', text: briefing }],
116
105
  parent: exec.agent,
@@ -127,8 +116,10 @@ export function registerSpawnTool(ctx, deps) {
127
116
  role,
128
117
  agentId,
129
118
  parentSessionId: String(exec.agent.id),
130
- cwd: worktree.path,
131
- branch: worktree.branch,
119
+ cwd,
120
+ branch,
121
+ reviewCommit: review?.commit,
122
+ reviewCheckout: review?.path,
132
123
  takeover,
133
124
  })
134
125
  recordSpawnSuccess(tower.root, config.spawn.maxLiveWorkers)
@@ -152,6 +143,10 @@ export function registerSpawnTool(ctx, deps) {
152
143
  detail: `spawn failed${agentId ? ` after activation ${agentId}` : ' before activation'}: ${String(error?.message ?? error).slice(0, 300)}`,
153
144
  })
154
145
  .catch(() => {})
146
+ // A failed review spawn must not leak its detached evidence checkout.
147
+ if (review) {
148
+ await cleanupReviewCheckout(tower, { name, missionId: mission.id }).catch(() => {})
149
+ }
155
150
  throw error
156
151
  }
157
152
 
@@ -167,8 +162,11 @@ export function registerSpawnTool(ctx, deps) {
167
162
  provider,
168
163
  maxDepth: config.spawn.maxDepth ?? 1,
169
164
  ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
170
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
171
- created: provisioned.created,
165
+ worktree: worktree ? { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit } : null,
166
+ sandbox,
167
+ review: review ? { commit: review.commit, checkout: review.path } : null,
168
+ provisioning,
169
+ created,
172
170
  briefingChars: briefing.length,
173
171
  takeover,
174
172
  rosterEntry: entry,
package/tools/test.js CHANGED
@@ -46,6 +46,8 @@ export function registerTestTool(ctx, deps) {
46
46
  return { ok: false, configured: false, error, tests: [], commands: [] }
47
47
  }
48
48
  const receipt = discovery.receipt
49
+ // #149 W4: the receipt is returned as recorded; there is no derived
50
+ // `checks` copy. Completion/batch read it through domain/evidence.js.
49
51
  return {
50
52
  ok: receipt.ok === true,
51
53
  configured: true,
@@ -54,7 +56,6 @@ export function registerTestTool(ctx, deps) {
54
56
  commands: receipt.commands ?? [],
55
57
  error: receipt.error ?? undefined,
56
58
  receipt,
57
- checks: discovery.check ? [discovery.check] : [],
58
59
  }
59
60
  },
60
61
  })
package/types/index.d.ts CHANGED
@@ -149,11 +149,25 @@ export interface RebaseRecord {
149
149
  baseTip?: string
150
150
  }
151
151
 
152
- export interface Mission {
152
+ /** Evidence a survey may record when it completes (optional). */
153
+ export interface SurveyResult {
154
+ paths?: string[]
155
+ symbols?: string[]
156
+ /** Ordered plan / next steps the read recommends (bounded array of strings). */
157
+ plan?: string[]
158
+ notes?: string
159
+ [key: string]: unknown
160
+ }
161
+
162
+ /**
163
+ * Fields every mission carries regardless of shape. The per-shape arms below
164
+ * declare which document dimensions a kind owns; the state schema v4 union and
165
+ * `domain/state/semantics.js` enforce the same split at runtime.
166
+ */
167
+ export interface MissionBase {
153
168
  id: string
154
169
  title: string
155
170
  status: MissionStatus
156
- kind: MissionKind
157
171
  scope: string[]
158
172
  deps: string[]
159
173
  /**
@@ -165,22 +179,59 @@ export interface Mission {
165
179
  blockers?: string[]
166
180
  reviewPolicy?: ReviewPolicy
167
181
  reviewExemptionReason?: string | null
168
- worktree?: MissionWorktree | null
169
182
  owner?: string | null
170
183
  notes?: string[]
171
184
  completionReport?: CompletionReport | null
172
185
  testRuns?: TestRun[]
173
186
  rebase?: RebaseRecord | null
174
- mergedCommit?: string | null
175
- mergedAt?: number | null
176
187
  scopeGeneration?: number
177
188
  ownerGeneration?: number
178
189
  pauseCause?: string | null
179
190
  updatedAt?: number
180
191
  references?: string[]
181
192
  authorizationRef?: string | null
193
+ /**
194
+ * Survey evidence; the completion transition refuses a result on a shape that
195
+ * does not accept one, so the type stays permissive for the shared draft.
196
+ */
197
+ result?: SurveyResult | null
182
198
  }
183
199
 
200
+ /** A build mission owns a private branch/worktree and lands by merging. */
201
+ export interface BuildMission extends MissionBase {
202
+ kind: 'build'
203
+ worktree?: MissionWorktree | null
204
+ mergedCommit?: string | null
205
+ mergedAt?: number | null
206
+ readCommit?: never
207
+ readDirty?: never
208
+ sandbox?: never
209
+ }
210
+
211
+ /** A survey reads one commit in the shared checkout; it never merges. */
212
+ export interface SurveyMission extends MissionBase {
213
+ kind: 'survey'
214
+ readCommit: string
215
+ readDirty?: string[]
216
+ result?: SurveyResult | null
217
+ /**
218
+ * #148 W2: the disposable detached sandbox path when `survey.sandbox` is
219
+ * `detached`. It has no branch and is removed on completion/abandon; a
220
+ * cleanup failure keeps the path plus `sandboxCleanupError` so the record and
221
+ * the filesystem do not silently disagree.
222
+ */
223
+ sandbox?: string
224
+ sandboxCleanupError?: string
225
+ sandboxRemovedAt?: number
226
+ worktree?: never
227
+ branch?: never
228
+ baseCommit?: never
229
+ mergedCommit?: never
230
+ mergedAt?: never
231
+ }
232
+
233
+ export type Mission = BuildMission | SurveyMission
234
+
184
235
  /**
185
236
  * Compact archived-mission entry (`archive/index.json`). It keeps enough of the
186
237
  * mission for ordering, dependency lookups and id allocation without loading the
@@ -255,6 +306,10 @@ export interface RosterEntry {
255
306
  missionId?: string | null
256
307
  parentSessionId?: string | null
257
308
  spawnTip?: string | null
309
+ /** The commit a reviewer's verdict is stamped with (#149 W3). */
310
+ reviewCommit?: string | null
311
+ /** The disposable detached checkout the reviewer reads, when recorded. */
312
+ reviewCheckout?: string | null
258
313
  stale?: boolean
259
314
  [key: string]: unknown
260
315
  }
@@ -326,6 +381,12 @@ export interface MissionSummary {
326
381
  reviewPolicy: ReviewPolicy
327
382
  mergedCommit: string | null
328
383
  mergedAt: number | null
384
+ /** Survey projection: the commit the survey read (null for a build). */
385
+ readCommit?: string | null
386
+ /** Survey projection: the tracked-status fingerprint captured at plan time. */
387
+ readDirty?: string[]
388
+ /** Survey projection: the disposable detached sandbox path (null otherwise). */
389
+ sandbox?: string | null
329
390
  /** Size of the completion report without loading its body; null when absent. */
330
391
  reportBytes: number | null
331
392
  updatedAt?: number