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
@@ -0,0 +1,123 @@
1
+ # Mission shapes (generated)
2
+
3
+ > Generated by `npm run docs:generate:shapes` from `domain/shapes/index.js`. Do not edit by hand.
4
+
5
+ A mission shape is the single place its kind's contract lives (#148/#149). Generic
6
+ layers resolve `shapeFor(mission.kind)` and never branch on a kind literal; the
7
+ shape audit (`npm run shape:audit`) refuses kind and role comparisons outside
8
+ `domain/shapes/`, the global `TERMINAL_STATUSES`/`TRANSITIONS` vocabulary,
9
+ one-argument `isTerminal`/`canTransition` calls, and the dimension regressions
10
+ recorded in the inventory below (review tip, batch status, lease ledger, blocked
11
+ writes, evidence reads, legacy vocabulary, path permission and role/shape
12
+ conflation).
13
+
14
+ ## Kinds
15
+
16
+ | Kind | Statuses | Has terminal | Provisioning | Can merge | Can rebase | Reach (owns / fence) | Component | Accepts result |
17
+ |---|---|---|---|---|---|---|---|---|
18
+ | `build` | `planned`, `active`, `completed`, `blocked`, `paused`, `merged`, `abandoned` | yes | `branch-worktree` | true | true | true / `scope` | — | false |
19
+ | `survey` | `planned`, `active`, `completed`, `blocked`, `paused`, `abandoned` | yes | `shared` / `detached` | false | false | false / `read-only` | tower-survey | true |
20
+
21
+ ## Transitions
22
+
23
+ ### build
24
+
25
+ | From | To |
26
+ |---|---|
27
+ | `planned` | `active`, `blocked`, `abandoned` |
28
+ | `active` | `completed`, `blocked`, `paused`, `abandoned` |
29
+ | `completed` | `active`, `blocked`, `abandoned` |
30
+ | `blocked` | `active`, `abandoned` |
31
+ | `paused` | `active`, `blocked`, `abandoned` |
32
+ | `merged` | terminal |
33
+ | `abandoned` | terminal |
34
+
35
+ ### survey
36
+
37
+ | From | To |
38
+ |---|---|
39
+ | `planned` | `active`, `blocked`, `abandoned` |
40
+ | `active` | `completed`, `blocked`, `paused`, `abandoned` |
41
+ | `completed` | terminal |
42
+ | `blocked` | `active`, `abandoned` |
43
+ | `paused` | `active`, `blocked`, `abandoned` |
44
+ | `abandoned` | terminal |
45
+
46
+ ## Survey placement (#148 W2)
47
+
48
+ `survey.sandbox` is tuning, not a component: the `tower-survey` row stays the
49
+ switch. The shape resolves both the placement and the preset, so generic layers
50
+ never read the raw key or compare a kind.
51
+
52
+ The decision is made once, at provisioning time. A mission with a recorded
53
+ `mission.sandbox` keeps the detached placement and its completion invariants
54
+ even if `survey.sandbox` later changes — a config flip can neither skip the
55
+ sandbox HEAD/zero-commit checks nor strand the disposable checkout. A survey
56
+ that never got a sandbox keeps the shared invariants for the same reason.
57
+
58
+ | `survey.sandbox` | Provisioning | Preset | Completion invariants |
59
+ |---|---|---|---|
60
+ | `none` (default) | `shared`: no checkout, the agent reads the main checkout | `tower-survey` (strictly read-only: no write, edit, or shell tool) | HEAD equals `readCommit`, tracked-status fingerprint unchanged, zero commits |
61
+ | `detached` | `detached`: `git worktree add --detach <worktreeRoot>/<id>-sandbox <readCommit>` (no branch, no `baseCommit`, never merged) | `tower-survey-sandbox` (read/write/edit/pwsh plus the mission tools; spawn/merge/rebase/review/diff still absent) | sandbox HEAD equals `readCommit`, zero commits; disposable write/shell side effects are ignored; sandbox removed on completion, abandon or teardown with a bounded retry |
62
+
63
+ ## Completion invariants
64
+
65
+ | Kind | Invariants | Check |
66
+ |---|---|---|
67
+ | `build` | requiresWorktree=true, requiresCommits=true, requiresClean=true, requiresHeadMatch=false, requiresTrackedStatus=false | `checkCompletion` |
68
+ | `survey` | requiresWorktree=false, requiresSandbox=false, requiresCommits=false, requiresClean=false, requiresHeadMatch=true, requiresTrackedStatus=true | `checkCompletion` |
69
+
70
+ ## Role
71
+
72
+ | Contract | Owner |
73
+ |---|---|
74
+ | Status, transitions, terminality, merge/rebase, provisioning, reach, plan fields, completion, dependencies, briefing, summary | `domain/shapes/<kind>.js` |
75
+ | Kind resolution and the action guard | `domain/shapes/index.js` |
76
+ | Role vocabulary and the role contract (preset, model, placement, briefing, prerequisites) | `domain/roles.js` |
77
+ | Generated reference and the audit | `scripts/generate-shapes-reference.mjs`, `scripts/shape-audit.mjs` |
78
+
79
+ ## Dimension inventory (#149)
80
+
81
+ Every dimension the registry audit covers. A **shape-owned** verdict lives in
82
+ `domain/shapes/<kind>.js`; a **correctly generic** verdict has one owner module
83
+ outside the shapes; a **redesigned** verdict replaced an earlier ambiguous
84
+ contract. The fitness rule is the mechanical check the audit (or the generated
85
+ invariant) enforces.
86
+
87
+ | Dimension | Verdict | Owner module | Fitness rule |
88
+ |---|---|---|---|
89
+ | Mission lifecycle | **Shape-owned.** Statuses, transitions and terminality are per kind. | `domain/shapes/<kind>.js` | `kind-literal`, `global-status-vocabulary`, `shape-entry-point` |
90
+ | Role vocabulary | **Correctly generic.** Roles are actors, not mission kinds. | `domain/roles.js` | `role-literal` |
91
+ | Provisioning | **Shape-owned.** A build gets a branch worktree; a survey is shared or a detached sandbox. | `domain/shapes/<kind>.js` | Generated invariant: only a build creates a branch/worktree; a recorded `mission.sandbox` decides a survey's placement. |
92
+ | Read-only completion | **Shape-owned.** `resolveCompletion` derives invariants from the recorded placement. | `domain/shapes/survey.js` | Generated invariant: shared survey keeps HEAD and tracked status; detached survey keeps HEAD pinned; both make zero commits. |
93
+ | Dependency | **Shape-owned.** `dependencySatisfied` names the terminal statuses that satisfy a dependent. | `domain/shapes/<kind>.js` | Generated invariant: a completed survey satisfies a dependent build; a merged build does too. |
94
+ | Archive / reanchor / planning | **Shape-owned.** Terminality is the one archive/reanchor predicate. | `domain/shapes/<kind>.js` | Generated invariant: a terminal shape leaves open work and never blocks reanchor. |
95
+ | Completion result | **Shape-owned.** Only a survey may record a completion result. | `domain/shapes/<kind>.js` | Generated invariant: `shape.acceptsResult`. |
96
+ | Required component | **Shape-owned.** A survey needs the `tower-survey` row. | `domain/shapes/<kind>.js` | Generated invariant: planning refuses with an actionable code while the required component is off. |
97
+ | Review | **Redesigned.** Commit-stamped evidence: a reviewer reads a disposable checkout at the recorded review commit; only a shape with a branch is reviewable. | `app/review.js`, `app/reviewCheckout.js`, `domain/missions/review.js` | `review-tip-read` — A review never reads a live worktree HEAD; verdicts are stamped with a recorded commit. |
98
+ | Batch | **Correctly generic second lifecycle.** A batch is finite evidence over missions, not a mission kind; no shape knows about batches. | `domain/batch.js`, `domain/collab/batch.js`, `app/batch.js` | `batch-status-literal` — Batch status literals live in domain/batch.js; every other layer uses its constants. |
99
+ | Leases | **Correctly generic.** One fail-closed admission point; expiry is recovered only by an explicit `force` release. | `adapters/leases.js`, `app/spawn-prechecks.js` | `lease-ledger` — Only adapters/leases.js reads or writes the lease ledger. |
100
+ | Paused vs blocked | **Redesigned.** `blocked` is derived machine state; `paused` is a deliberate stop with a cause. | `domain/missions/lifecycle.js`, `domain/state/semantics.js` | `blocked-status-write` — blocked is derived from open blockers; only domain/missions/lifecycle.js writes it. |
101
+ | Test receipts / evidence | **Redesigned — one reader.** Completion checks and discovery receipts are one normalized evidence contract in `domain/evidence.js`; the shape declares whether a receipt is tip-bound. `tower_complete` no longer copies a synthetic `tests` check into the report. | `domain/evidence.js`; `domain/shapes/<kind>.js` declares `evidence` | `evidence-single-read` — Completion checks and test receipts have one reader: domain/evidence.js. |
102
+ | Legacy import | **Redesigned — strict adapter.** One payload is one declared format; status remaps, assumed kinds and non-carried fields are reported as `remapped`/`unresolved`/`dropped` instead of approximated. Import stays read-only and never resumes a paused mission. | `adapters/legacy.js`; `domain/batch.js` reads only the canonical `pausedMissionIds` | `legacy-vocabulary` — One live vocabulary: legacy formats/statuses are read only by adapters/legacy.js. |
103
+ | Scope ownership | **Redesigned — shape.reach.** The shape owns path permission (`reach.ownsPaths`, `reach.fence`); the gate, planner, lifecycle and auto-deps derive from it through one fence. | `domain/scope.js` + `shape.reach` in `domain/shapes/<kind>.js` | `scope-reach` — Path permission derives from shape.reach through domain/scope.js; no module owns or re-implements it. |
104
+ | Role vs kind | **Redesigned — orthogonal role contract.** Spawn, preset, model, placement, briefing, roster budget and gates resolve the role through `domain/roles.js`; the shape only supplies facts. | `domain/roles.js`; callers `tools/spawn.js`, `app/spawn-prechecks.js`, `app/spawn-support.js`, `domain/roster.js`, `host/presets.js` | `role-shape-conflation` — A role decision never combines with a shape decision; role facts live in domain/roles.js. |
105
+
106
+ ## Fitness rules
107
+
108
+ Generated from `scripts/shape-audit.mjs` `RULES`; the audit is wired into `npm run check`.
109
+
110
+ | Rule | Owner module | Contract |
111
+ |---|---|---|
112
+ | `kind-literal` | `domain/shapes/<kind>.js` | Generic layers never compare mission.kind with a literal. |
113
+ | `role-literal` | `domain/roles.js` | Generic layers use the vocabulary role predicates, never a role literal. |
114
+ | `global-status-vocabulary` | `domain/shapes/<kind>.js` | No module reaches for the global TERMINAL_STATUSES/TRANSITIONS tables. |
115
+ | `shape-entry-point` | `domain/shapes/index.js` | isTerminal(kind, status) and canTransition(kind, from, to) are the only entry points. |
116
+ | `review-tip-read` | `app/reviewCheckout.js` | A review never reads a live worktree HEAD; verdicts are stamped with a recorded commit. |
117
+ | `batch-status-literal` | `domain/batch.js` | Batch status literals live in domain/batch.js; every other layer uses its constants. |
118
+ | `lease-ledger` | `adapters/leases.js` | Only adapters/leases.js reads or writes the lease ledger. |
119
+ | `blocked-status-write` | `domain/missions/lifecycle.js` | blocked is derived from open blockers; only domain/missions/lifecycle.js writes it. |
120
+ | `evidence-single-read` | `domain/evidence.js` | Completion checks and test receipts have one reader: domain/evidence.js. |
121
+ | `legacy-vocabulary` | `adapters/legacy.js` | One live vocabulary: legacy formats/statuses are read only by adapters/legacy.js. |
122
+ | `scope-reach` | `domain/scope.js` | Path permission derives from shape.reach through domain/scope.js; no module owns or re-implements it. |
123
+ | `role-shape-conflation` | `domain/roles.js` | A role decision never combines with a shape decision; role facts live in domain/roles.js. |
@@ -84,6 +84,36 @@ plugin-page form, not a row and not one row per mode; the review gate in
84
84
  non-disableable. The component and config-key counts do not move (11 visible
85
85
  components, 54 keys).
86
86
 
87
+ #148/#149 grow the surface by exactly one key and one visible component,
88
+ recorded in [ADR 0006](../adr/0006-mission-shapes.md): config keys 54 → 55,
89
+ sections stay 14, components 11 → 12. A first cut of the shape work added three
90
+ keys and a `survey` section, then contracted 57 → 55 before release in a second
91
+ dated decision: a survey's preset is shape-owned
92
+ (`SHAPES.survey.spawn.defaultPreset`), so a `spawn.surveyPreset` key could only
93
+ disagree with the registry, and `survey.sandbox` advertised a placement W1 does
94
+ not implement. What remains is `spawn.surveyModel`, the optional model route for
95
+ survey workers (the survey row owns the capability, not the key name). The
96
+ `tower-survey` row is the switch: off means surveys cannot be planned and no
97
+ survey agent is dispatched; build missions are unaffected. No existing key
98
+ changed class.
99
+
100
+ #148 W2 implements the detached survey sandbox, so it restores exactly the
101
+ placement key that W1 correctly withheld: `survey.sandbox` (`none`|`detached`,
102
+ default `none`) returns as tuning, and `gates.requireFreshSurvey` (default
103
+ `false`) joins it as merge-gate strictness; config keys 55 → 57 and sections
104
+ 14 → 15. It
105
+ is not a capability: the `tower-survey` row stays the single switch and the
106
+ sandbox preset is an internal child of it, so the visible component count does
107
+ not move (12). `spawn.surveyPreset` stays removed: the shape resolves the preset
108
+ from `survey.sandbox`, so the key could still only disagree with the registry.
109
+
110
+ #149 W4 contracts the config surface by one: config keys 57 → 56. `tests.autoChecks`
111
+ only chose whether a discovery projection was copied into the completion report,
112
+ and that copy was the second evidence shape the W4 audit removed. The receipt in
113
+ `mission.testRuns` is the one evidence record now, so the switch had no meaning
114
+ left; it is deleted rather than kept as a dead tuning key. Sections and the
115
+ visible component count do not move.
116
+
87
117
  ## Config keys
88
118
 
89
119
  | Key | Class | Note |
@@ -116,6 +146,8 @@ components, 54 keys).
116
146
  | `git.timeoutMs` | tuning | Default Git command timeout outside merge. |
117
147
  | `worktrees.root` | tuning | Default worktree placement. |
118
148
  | `spawn.workerPreset` | core | Chooses the tool surface a worker receives. |
149
+ | `spawn.surveyModel` | tuning | Optional model route for survey agents. |
150
+ | `survey.sandbox` | tuning | Placement only: shared read-only checkout (default) or a disposable detached sandbox. The tower-survey row is still the switch. |
119
151
  | `spawn.reviewerPreset` | core | Chooses the read-only reviewer surface. |
120
152
  | `spawn.maxDepth` | tuning | Subagent depth requested at spawn. |
121
153
  | `spawn.provider` | tuning | Provider route for spawned agents. |
@@ -130,11 +162,11 @@ components, 54 keys).
130
162
  | `tests.admission` | core | Chooses when repository code is executed. |
131
163
  | `tests.timeoutMs` | tuning | Per-command discovery timeout. |
132
164
  | `tests.maxBytes` | tuning | Captured discovery output cap. |
133
- | `tests.autoChecks` | tuning | Attaches a tests check to completion. |
134
165
  | `tests.allowPackageDiscovery` | core | Allows repository code to execute; a security contract. |
135
166
  | `gates.requireChecks` | core | Requires completion evidence before merge. |
136
167
  | `gates.requireTests` | core | Requires a test receipt before merge. |
137
168
  | `gates.allowReviewExemption` | core | Allows a review exemption and its reason. |
169
+ | `gates.requireFreshSurvey` | core | Promotes the survey-stale dependency warning to a merge blocker; `false` keeps it advisory. |
138
170
  | `gates.maxReportBytes` | tuning | Report size cap; the refusal itself is not configurable. |
139
171
  | `docs.boardLocation` | tuning | Chooses where the board is written; whether it is written is the tower-board row. |
140
172
  | `archive.afterDays` | tuning | Archive age threshold; archival itself is the tower-archive row. |
@@ -48,8 +48,10 @@ overall `ok`. `state.missions[].testRuns` keeps the most recent 50 receipts. The
48
48
  discovery fails.
49
49
 
50
50
  - `tower_test({repoRoot,missionId})` runs discovery on demand and records a receipt.
51
- - `tower_complete` adds a `tests` check when `tests.autoChecks` is on.
52
- - `gates.requireChecks` requires any completion check; `gates.requireTests` requires a passing receipt
51
+ - The receipt in `testRuns` is the only evidence shape. `tower_complete` does not copy a `tests`
52
+ check into the completion report; completion checks, batch close and `gates.requireChecks` read the
53
+ same receipt.
54
+ - `gates.requireChecks` requires any recorded check or receipt; `gates.requireTests` requires a passing receipt
53
55
  for the current mission branch tip (`tests-missing`). Review is independent of this gate: with
54
56
  `review.mode:optional` (the default) or `off`, the tests gate still checks the branch tip.
55
57
 
@@ -1,13 +1,13 @@
1
1
  # Tools overview (generated)
2
2
 
3
3
  > Generated by `npm run docs:generate:tools` from the registered tool definitions. Do not edit by hand.
4
- > Surface: config keys: 54 · sections: 14 · tools: 29 · baseline 2026-10-23. Generated from `scripts/surface-baseline.json`; do not edit by hand.
4
+ > Surface: config keys: 56 · sections: 15 · tools: 29 · baseline 2026-10-23. Generated from `scripts/surface-baseline.json`; do not edit by hand.
5
5
 
6
6
  | Tool | Description |
7
7
  |---|---|
8
8
  | `tower_adopt` | Reconcile the roster with the durable subagent catalog after a restart or new session. Agents that are provably gone are marked retired (records are kept) or missing (retireMissing:false, so the tower can inspect/take over); agents whose parent catalog cannot be read stay unknown and are never changed. Only the tower may adopt. |
9
9
  | `tower_batch` | Manage finite evidence batches: create freezes missionIds/requiredChecks/receiptPaths, status summarizes members, close verifies every required check and receipt hash, abandon retires a stale open batch. Membership and exclusions are immutable after create; close/abandon never remove missions. |
10
- | `tower_complete` | 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. |
10
+ | `tower_complete` | 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. |
11
11
  | `tower_control` | Enter or leave Tower mode for the calling agent. Allowed only when the deployment enables allowAgentControl; use it when the user intent or task state clearly calls for Tower orchestration or a return to normal collaboration, and always state a concrete reason. |
12
12
  | `tower_diff` | Read-only view of a mission branch for review: merge base, changed files, commits, and a capped patch against the recorded base. Safe for reviewers; it never modifies the repository. |
13
13
  | `tower_doctor` | Read-only environment and repository health check: the concept-budget surface (config keys/sections/tools), git, state schema, case-sensitivity detection (caseInsensitive and the active scope comparison mode), config, presets/subagents/HMR, the state lock age/heartbeat freshness, the repo-scoped git lock holder (gitLock: {held,pid,phase,heldMs}), workspace lock, roster staleness, worktree ownership, test discovery, leases, and the terminal-mission archive (count, bytes, years, unreadable diagnostics and the configured afterDays/maxMissions policy). Returns checks with pass/warn/fail, state-lock and git-lock reports and an environment contract. Never writes state and never probes the filesystem. |
@@ -21,8 +21,8 @@
21
21
  | `tower_merge` | Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. If the merge commit already landed (manual merge, crashed or slow pre-merge hook), it detects the merge commit and records it as reconciled instead of merging again. A failed gate returns stable blocker codes without merging; a hook failure aborts cleanly and returns "merge-hook-failed" while a real content conflict returns "merge-conflict". |
22
22
  | `tower_mission` | Update one mission: lifecycle status, decision note, blocker, clear blockers, mark a task done, drop a task with a reason, or widen the scope (tower only). "merged" is never set here; only a future tower_merge records it. Merged and abandoned are terminal. |
23
23
  | `tower_mode` | Report whether Tower mode is active for the calling agent, who changed it last, whether the model may toggle it, any goal paused on entry, the workspace lock, HMR availability and the plugin version. |
24
- | `tower_plan` | 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. |
25
- | `tower_preflight` | 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. |
24
+ | `tower_plan` | 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. |
25
+ | `tower_preflight` | 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. |
26
26
  | `tower_rebase` | Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked. If the branch is already contained in the base, it returns alreadyMerged and leaves the branch untouched so tower_merge can record the landed merge. |
27
27
  | `tower_recover` | Read-only recovery diagnostics and an explicit stale-state-lock repair. status reports the state lock owner with its age, heartbeat age and staleness; clear-stale-lock requires confirm:"CLEAR_STALE_LOCK" and only moves a lock whose PID is provably dead to a backup file. force:true additionally clears a live-but-silent PID (a recycled PID) and still renames the record to a .recovered-<ts> backup. It never resets Tower state or worktrees. |
28
28
  | `tower_reload` | Dev-only: ask DSH HMR to reconcile this profile and reload the Tower Host plugin from disk. Registered only while the tower-hmr component is on (the row is shipped off). The reload is scheduled a few milliseconds after this call returns (so the result reaches the model first). Use after editing the plugin source in a linked profile; plugin_manager install/update already triggers reconciliation by itself. Fails with reload-unavailable when the HMR service is absent. |
@@ -30,7 +30,7 @@
30
30
  | `tower_review` | Submit an independent review verdict bound to the current branch tip: a completed mission starts a review round, and while the mission is active a later route may still record a verdict for that same reviewed tip, appending to the same round without changing the status (a moved tip is rejected with an explanation). status is "clean", "p1-Nitems", or "p2-Nitems"; merge is "merge", "fix-then-merge", or "hold" (only clean + merge is later approval). Non-clean returns the mission to active. Reviewers are capped at 5 rounds per branch; a tower-submitted review is break-glass and requires breakGlassReason. The tool is not registered when review.mode is off. |
31
31
  | `tower_roster` | List tower roster agents with authoritative live activity from the subagent catalog (running/inactive/missing/unknown). Read-only; never resumes children. Use before completion or merge decisions. |
32
32
  | `tower_send` | Send a durable inbox message to a tower participant: a roster agent name, "tower", or "all" (broadcast). The message is persisted first; when the recipient is a currently running roster agent the tower also steers it into that child's current turn. Returns delivery results. |
33
- | `tower_spawn` | 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. |
33
+ | `tower_spawn` | 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. |
34
34
  | `tower_status` | Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, paged mission summaries (scope/deps/tasks/worktree/owner), roster size, findings, archive count, config path and the activity tail. Also reports the repo-scoped git lock holder (`gitLock: {held,pid,phase,heldMs}`) so a merge holding the mutex is visible, and the resolved review policy (`review: {mode, available, required, detail}`; mode off means review is intentionally unavailable). Pass missionId for one full mission (tasks, notes, reviews, findings, completion report; archived missions are loaded from their year file); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; includeArchived appends compact archived summaries after the active page; format:"markdown" returns the human-readable mission board (active missions plus an archived count). Read-only. |
35
35
  | `tower_teardown` | Tear down eligible mission worktrees after their missions are merged or abandoned. A worktree whose roster agent is running is always kept (force cannot override); dirty worktrees need force; exclude keeps named missions. dry_run previews the remove/keep decisions without changing anything. Terminal missions whose worktree is gone and whose configured archive policy is due (archive.afterDays / archive.maxMissions) are moved to <git-common-dir>/dsh-tower/archive/ so state.json stops rewriting them; nothing is deleted. |
36
36
  | `tower_test` | Run the mission worktree's declared tower.testDiscovery (package.json) and record the receipt. Returns ok:false with diagnostics when discovery fails; does not change mission status. Use tower_complete for automatic admission checks. |
@@ -36,7 +36,7 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
36
36
  |---|---|---|
37
37
  | `tower_init` | `base?`, `autoGitInit?`, `snapshotDirty?`, `reanchor?` | Create `<git-common-dir>/dsh-tower/state.json`. `autoGitInit` runs `git init` + empty initial commit; `snapshotDirty` records a dangling snapshot commit of uncommitted tracked changes used as the mission baseline; `reanchor` changes base only when no non-terminal mission exists. Returns `dirty`, `snapshotCommit`, `reanchored`. |
38
38
  | `tower_plan` | `missions[]` (`title` — printable Unicode, NFC-normalized, ≤120 characters, `scope[]`, `tasks[]?`, `deps[]?`, `kind?`, `context?`, `references[]?` ≤20, `reviewPolicy?`, `reviewExemptionReason?`) | Create missions `M1..`; overlapping build scopes auto-add a dependency; rejects unknown deps and cycles. Mission ids stay ASCII (`M1`, `M2`); invisible characters and over-long titles are rejected with `invalid-argument`. `reviewPolicy:"none"` requires a non-empty reason; when the review gate is on, preflight also requires `gates.allowReviewExemption`. |
39
- | `tower_mission` | `id` (alias `missionId`), `status?`, `task_done?`, `task_drop?`+`task_drop_reason?`, `blocker?`, `clear_blockers?`, `note?`, `scope?`, `pause_cause?`, `authorization?` | Update one mission. Worker-safe fields are task/blocker/note; `merged` cannot be set here. Paused missions record `pause_cause` (user/failure/resource/dependency); resuming a user-paused mission honors `mode.requireResumeAuthorization`. |
39
+ | `tower_mission` | `id` (alias `missionId`), `status?`, `task_done?`, `task_drop?`+`task_drop_reason?`, `blocker?`, `clear_blockers?`, `note?`, `scope?`, `pause_cause?`, `authorization?` | Update one mission. Worker-safe fields are task/blocker/note; `merged` cannot be set here. `blocked` is derived from an open blocker (record `blocker`, never `status:"blocked"`); `clear_blockers` resumes it to `active`. Paused missions record `pause_cause` (user/failure/resource/dependency); resuming a user-paused mission honors `mode.requireResumeAuthorization`. |
40
40
  \|\ `tower_status`\ \|\ `missionId\?`,\ `offset\?`,\ `limit\?`,\ `includeGate\?`,\ `includeArchived\?`,\ `format\?`\ \|\ Dashboard\ by\ default\.\ Paged\ missions\ carry\ `reportBytes`\ only;\ `missionId`\ returns\ full\ detail\ \(tasks/notes/reviews/findings/completionReport\ body\),\ and\ an\ archived\ mission\ is\ loaded\ from\ its\ year\ file\.\ `offset`/`limit`\ page\ active\ missions\ \(default\ 20,\ max\ 100\),\ `includeGate`\ adds\ gate\ digests,\ `includeArchived`\ appends\ compact\ archived\ summaries\ \(flagged\ `archived:true`,\ no\ report\ body\)\ after\ the\ active\ page,\ and\ `format:"markdown"`\ returns\ the\ board\ \(active\ rows\ plus\ an\ archived\ count\)\.\ Includes\ `mode`\ with\ `lastChange`\ and\ recent\ `history`\ when\ the\ host\ mode\ hook\ is\ available\.\ `archive`\ reports\ `\{count,\ years,\ unreadable,\ message}`;\ a\ corrupt\ archive\ degrades\ to\ `unreadable:true`\ instead\ of\ failing\ the\ dashboard\.\ Also\ returns\ `gitLock`\ \(`\{held,\ pid,\ phase,\ heldMs}`\)\ for\ the\ repo-scoped\ git\ mutex,\ and\ `\{held:\ false}`\ when\ it\ is\ free\.\ \|
41
41
  | `tower_spawn` | `missionId` (alias `id`), `role` (`worker`/`reviewer`), `name?`, `instructions?`, `worktreeRoot?`, `wait?`, `waitMs?`, `takeover?`, `includeDirtyBase?` | Provision/reuse the worktree and start a subagent. Worker uses `tower-worker`; reviewer requires a `completed` mission and uses the read-only `tower-reviewer`; with `review.mode:off` the reviewer role is refused with `[tower:review-disabled]`. `takeover:true` replaces a live worker entry that is no longer running (increments ownerGeneration). Only one live worker per mission; `spawn.maxLiveWorkers` caps the repository. |
42
42
  | `tower_roster` | — | Roster with live activity (`running`/`inactive`/`missing`/`unknown`) and `stale` flags from the subagent catalog. Read-only. |
@@ -60,7 +60,7 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
60
60
  | `tower_recover` | `action` (`status`/`clear-stale-lock`), `confirm?`, `force?` | Recovery diagnostics plus an explicit stale state-lock repair. `clear-stale-lock` requires `confirm:"CLEAR_STALE_LOCK"` and only moves a lock whose PID is provably dead to a `.recovered-<ts>` backup. `force:true` additionally clears a live-but-silent PID (a recycled PID): the output names `{pid, startedAt, ageMs, heartbeatAgeMs}` and the record is still renamed, never unlinked. It never resets state/worktrees. |
61
61
  | `tower_test` | `missionId` (alias `id`) | Run the worktree's `package.json` `tower.testDiscovery` and record a receipt; `ok:false` on failure. Use `tower_complete` for automatic admission checks. |
62
62
  | `tower_batch` | `action` (`create`/`status`/`close`/`abandon`), `missionIds?`, `exclusions?`, `requiredChecks?`, `receiptPaths?`, `batchId?`, `checks?`, `treeTip?`, `receiptHashes?`, `reason?` | Finite evidence batch. Create freezes members/checks/receipts; close verifies every required check and receipt hash; merged members are eligible; abandon retires a stale open batch without deleting missions. Membership is immutable. |
63
- | `tower_resource` | `action` (`acquire`/`release`/`status`), `name?`, `holder?`, `token?`, `ttlMs?`, `force?` | Advisory per-repository lease in `<common>/dsh-tower/leases.json`. No OS CPU/memory hard cap; expired leases require explicit `force` and the correct token. |
63
+ | `tower_resource` | `action` (`acquire`/`release`/`status`), `name?`, `holder?`, `token?`, `ttlMs?`, `force?` | Advisory per-repository lease in `<common>/dsh-tower/leases.json`. No OS CPU/memory hard cap. A live lease is released only by its holder token; an **expired** lease is recovered by an explicit `force: true` release (never taken over automatically). |
64
64
  | `tower_import_legacy` | `sourcePath` | Read-only import of a Tower Codex `schemaVersion:1` or DSH legacy state. Full payload goes under `<common>/dsh-tower/legacy/<sha256>.json`; state stores a summary only. Never activates/adopts/resumes paused missions. |
65
65
  | `tower_legacy_map` | `action` (`map`/`status`), `sourceHash?`, `oldId?`, `newMissionId?` | Audit-only legacy-id mapping; mappings are append-only and never adopt a mission. |
66
66
 
@@ -20,10 +20,10 @@
20
20
  | `no-check-evidence` | 启用了 `gates.requireChecks` 且完成报告没有 `checks`。把 completed mission 退回 `active`,带 checks 重新调用 `tower_complete`,然后重新审查。 |
21
21
  | `branch-missing` | 还没有 worktree/分支:先 spawn worker。 |
22
22
  | `stale-base` | 分支不包含基线 tip:执行 `tower_rebase`。 |
23
- | `read-only-survey` | survey mission 修改了文件;survey mission 必须只读。 |
24
23
  | `out-of-scope` | 改动文件超出 mission scope;有意为之就扩大 scope,否则回退。在大小写敏感的检出上,仅大小写不同的路径仍会被拒绝,detail 会指出折叠大小写后本可匹配的 scope 条目。 |
25
24
  | `base-dirty` | 主检出存在未提交改动,合并会覆盖它们。 |
26
25
  | `deps-unmerged` | 依赖 mission 尚未满足:build 依赖须 `merged`/`abandoned`;survey 依赖在 `completed` 即视为满足(含归档记录)。 |
26
+ | `survey-stale` | build 依赖的 survey 的 `readCommit` 不是 build base 的祖先或同一提交。默认只是 `plan`/`preflight` 的 `warnings[]` 条目;`gates.requireFreshSurvey:true` 时同一 code 阻止合并。针对当前 base 重新规划 survey,或记录为何该读取仍然有效。 |
27
27
  | `no-review` | `review.mode:required` 且分支上没有审查:spawn reviewer(或关闭审查门禁)。 |
28
28
  | `review-mission-mismatch` | `review.mode:required` 且最新审查属于另一个 mission。 |
29
29
  | `not-clean` | `review.mode:required` 且最新审查是 `p1-Nitems`/`p2-Nitems`;需要再来一轮(同一轮的各 route 结论会追加记录,不会让状态反复翻转)。 |
@@ -63,4 +63,4 @@
63
63
  分支已包含在基线中时,`tower_rebase` 返回 `alreadyMerged: true`;此时调用 `tower_merge` 记录这次
64
64
  已落地的合并。
65
65
 
66
- 稳定的 blocker code 共 32 个(26 个门禁 + 4 个合并失败 + 2 个 rebase 专属;`worker-busy` 与门禁共用)。
66
+ 稳定的 blocker code 共 31 个(25 个门禁 + 4 个合并失败 + 2 个 rebase 专属;`worker-busy` 与门禁共用)。
@@ -99,9 +99,8 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
99
99
  | `tests.admission` | `off` | `spawn` 在启动 worker 前运行发现(fail-closed,不启动子代理);`complete` 在 `tower_complete` 时运行;`off` 只在显式 `tower_test` 时运行(默认;不会自动执行仓库代码)。 |
100
100
  | `tests.timeoutMs` | `60000` | 每条发现命令的超时。 |
101
101
  | `tests.maxBytes` | `16777216`(16 MiB) | 每条命令捕获的 stdout 上限。 |
102
- | `tests.autoChecks` | `true` | 向完成报告附加自动 `tests` 检查。 |
103
102
  | `tests.allowPackageDiscovery` | `true` | 允许仓库控制的 `tower.testDiscovery` 命令(会执行 worktree 代码;见安全页)。 |
104
- | `gates.requireChecks` | `false` | 合并前要求至少一条 `tower_complete.checks` 证据(`no-check-evidence`)。 |
103
+ | `gates.requireChecks` | `false` | 合并前要求至少一条已记录检查(`tower_complete.checks`)或测试回执(`no-check-evidence`)。 |
105
104
  | `gates.requireTests` | `false` | 要求针对当前 mission 分支 tip 的通过测试回执(`tests-missing`);与审查门禁独立。`tower-tests` 组件行关闭时,规划/进入模式会带解释拒绝该组合。 |
106
105
  | `gates.allowReviewExemption` | `false` | 在 `review.mode:required` 时,允许用 `reviewPolicy:"none"` + 非空理由跳过审查 blocker;默认故意关闭;`review.mode` 为 `off` 或 `optional` 时无影响(不会产生审查 blocker)。 |
107
106
  | `gates.maxReportBytes` | `32768`(32 KiB) | `tower_complete.report` 的 UTF-8 字节硬上限。超出时以 `invalid-argument` 拒绝并给出可执行的 `nextAction`(把全文写进 mission worktree 的文件并引用路径),绝不截断。已存在的超限报告仍可读取。 |
@@ -48,8 +48,9 @@ stdout/stderr 文件、SHA-256 哈希、退出码、计数、required 名称、w
48
48
  `tower_complete` 会抛出 `[tower:tests-failed]`。
49
49
 
50
50
  - `tower_test({repoRoot,missionId})` 按需运行发现并记录回执。
51
- - `tests.autoChecks` 开启时,`tower_complete` 会添加一条 `tests` 检查。
52
- - `gates.requireChecks` 要求任意完成检查;`gates.requireTests` 要求针对当前 mission 分支 tip 的
51
+ - `testRuns` 中的回执是唯一的证据形态;`tower_complete` 不再向完成报告复制 `tests` 检查,完成检查、批次关闭
52
+ 与 `gates.requireChecks` 读取同一份回执。
53
+ - `gates.requireChecks` 要求任意已记录检查或回执;`gates.requireTests` 要求针对当前 mission 分支 tip 的
53
54
  通过回执(`tests-missing`)。测试门禁与审查独立:即使策略为 `review.mode:optional`(默认)或
54
55
  `off`,测试门禁仍检查分支 tip。
55
56
 
package/domain/batch.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // Batch admission packets (2.1): pure create/close/summarize validation.
2
2
  //
3
+ // A batch is a second, generic lifecycle layered over missions (#149 W3): it is
4
+ // not a mission shape and never gets a kind. Its whole lifecycle — statuses,
5
+ // transition edges, terminality, legacy-status normalization and the status
6
+ // vocabulary — lives in this module (see `BATCH_STATUS*`), so no other module
7
+ // compares or writes `batch.status` to a string literal.
8
+ //
3
9
  // This module performs no filesystem, git, or subprocess I/O. The tower tool
4
10
  // layer reads the worktree, receipt files, and test results, then passes what
5
11
  // it observed into `validateClose`. Receipt hashes may be supplied by the
@@ -11,7 +17,7 @@
11
17
  // state.missions[] -> { id, status, mergedCommit?, completionReport?, testRuns? }
12
18
  // state.batches[] -> { id, status: 'open'|'closed'|'abandoned', missionIds,
13
19
  // exclusions, requiredChecks, receiptPaths, receiptHashes? }
14
- // state.legacySnapshots[] -> { hash, missions: [{ id, status }], pausedIds? }
20
+ // state.legacySnapshots[] -> { sourceHash, pausedMissionIds: string[] }
15
21
  // A batch record is the normalized validateCreate result plus `id`, `status`,
16
22
  // `createdAt`, and the optional `receiptHashes` expected-hash map.
17
23
  //
@@ -23,16 +29,64 @@
23
29
  // `input.treeTip` to bind recorded check `treeCommit` values to the current
24
30
  // HEAD.
25
31
  import path from 'node:path'
32
+ import { findEvidence } from './evidence.js'
33
+ import { shapeFor } from './shapes/index.js'
26
34
 
27
35
  const SHA256_PATTERN = /^[a-f0-9]{64}$/i
28
36
  const MAX_MISSIONS = 50
29
37
  const MAX_CHECKS = 50
30
38
  const MAX_RECEIPTS = 200
31
39
  const MAX_EXCLUSIONS = 50
32
- const PASS_STATUSES = new Set(['passed', 'pass', 'ok', 'success', 'succeeded', 'done', 'green'])
33
- const FAIL_STATUSES = new Set(['failed', 'fail', 'error', 'errored', 'timeout', 'timed-out', 'blocked'])
34
- const OPEN_BATCH_STATUSES = new Set(['open', 'created', 'active', 'pending'])
35
- const CLOSED_BATCH_STATUSES = new Set(['closed', 'abandoned'])
40
+
41
+ // The batch lifecycle contract (#149 W3). A batch is opened by tower_batch
42
+ // create and leaves `open` exactly once, to `closed` (every member verified) or
43
+ // `abandoned` (retired without verification). There is no third stop state and
44
+ // no per-shape variant.
45
+ export const BATCH_STATUS = Object.freeze({ OPEN: 'open', CLOSED: 'closed', ABANDONED: 'abandoned' })
46
+ /** @type {readonly string[]} */
47
+ export const BATCH_STATUSES = Object.freeze([BATCH_STATUS.OPEN, BATCH_STATUS.CLOSED, BATCH_STATUS.ABANDONED])
48
+ export const BATCH_TRANSITIONS = Object.freeze({
49
+ [BATCH_STATUS.OPEN]: Object.freeze([BATCH_STATUS.CLOSED, BATCH_STATUS.ABANDONED]),
50
+ [BATCH_STATUS.CLOSED]: Object.freeze([]),
51
+ [BATCH_STATUS.ABANDONED]: Object.freeze([]),
52
+ })
53
+ // Pre-v5 documents (and hand-written fixtures) used synonyms for `open`; v5
54
+ // normalizes them once in the state migration and nothing else accepts them.
55
+ /** @type {readonly string[]} */
56
+ const LEGACY_OPEN_BATCH_STATUSES = Object.freeze(['created', 'active', 'pending'])
57
+
58
+ /**
59
+ * @param {unknown} value
60
+ * @returns {boolean}
61
+ */
62
+ export function isBatchTerminal(value) {
63
+ return value === BATCH_STATUS.CLOSED || value === BATCH_STATUS.ABANDONED
64
+ }
65
+
66
+ /**
67
+ * @param {unknown} from
68
+ * @param {unknown} to
69
+ * @returns {boolean}
70
+ */
71
+ export function canTransitionBatch(from, to) {
72
+ return (BATCH_TRANSITIONS[/** @type {string} */ (from)] ?? []).includes(/** @type {string} */ (to))
73
+ }
74
+
75
+ /**
76
+ * Normalize a recorded batch status to the canonical vocabulary. An absent
77
+ * status means `open`; a legacy synonym means `open`; an unknown value is left
78
+ * as-is so validation can report it instead of silently rewriting it.
79
+ * @param {unknown} value
80
+ * @returns {string}
81
+ */
82
+ export function normalizeBatchStatus(value) {
83
+ if (value === undefined || value === null || value === '') return BATCH_STATUS.OPEN
84
+ const status = String(value)
85
+ if (BATCH_STATUSES.includes(status)) return status
86
+ if (LEGACY_OPEN_BATCH_STATUSES.includes(status)) return BATCH_STATUS.OPEN
87
+ return status
88
+ }
89
+
36
90
 
37
91
  /** Stable batch error; add `batch-invalid` to ERROR_CODES to map it directly. */
38
92
  export class BatchError extends Error {
@@ -123,28 +177,13 @@ function normalizeReceiptPathOrNull(value, where) {
123
177
  /** Legacy snapshots imported by adapters/legacy.js; only paused missions may be excluded. */
124
178
  function pausedLegacyIds(state) {
125
179
  const ids = new Set()
126
- const collectMission = (mission) => {
127
- if (isPlainObject(mission) && nonEmptyString(mission.id) && mission.status === 'paused') {
128
- ids.add(mission.id.trim())
129
- }
130
- }
131
- const collectList = (value) => {
132
- if (Array.isArray(value)) value.forEach(collectMission)
133
- else if (isPlainObject(value)) Object.values(value).forEach(collectMission)
134
- }
135
180
  const snapshots = Array.isArray(state.legacySnapshots) ? state.legacySnapshots : []
136
181
  for (const snapshot of snapshots) {
137
182
  if (!isPlainObject(snapshot)) continue
138
- collectList(snapshot.missions)
139
- if (isPlainObject(snapshot.snapshot)) collectList(snapshot.snapshot.missions)
140
- if (isPlainObject(snapshot.payload)) collectList(snapshot.payload.missions)
141
- // adapters/legacy.js stores imported snapshots with `pausedMissionIds`; older,
142
- // hand-written fixtures may use `pausedIds`.
143
- for (const key of ['pausedIds', 'pausedMissionIds']) {
144
- if (Array.isArray(snapshot[key])) {
145
- for (const id of snapshot[key]) if (nonEmptyString(id)) ids.add(id.trim())
146
- }
147
- }
183
+ // #149 W4: `pausedMissionIds` is the one canonical field the legacy adapter
184
+ // writes. Reading any other shape here would be a second live vocabulary.
185
+ if (!Array.isArray(snapshot.pausedMissionIds)) continue
186
+ for (const id of snapshot.pausedMissionIds) if (nonEmptyString(id)) ids.add(id.trim())
148
187
  }
149
188
  return ids
150
189
  }
@@ -153,82 +192,13 @@ function missionInOpenBatch(state, missionId) {
153
192
  const batches = Array.isArray(state.batches) ? state.batches : []
154
193
  for (const batch of batches) {
155
194
  if (!isPlainObject(batch)) continue
156
- const status = batch.status ?? 'open'
157
- if (!OPEN_BATCH_STATUSES.has(status)) continue
195
+ const status = normalizeBatchStatus(batch.status ?? BATCH_STATUS.OPEN)
196
+ if (isBatchTerminal(status)) continue
158
197
  if (Array.isArray(batch.missionIds) && batch.missionIds.includes(missionId)) return batch
159
198
  }
160
199
  return null
161
200
  }
162
201
 
163
- function checkNames(entry) {
164
- if (!isPlainObject(entry)) return []
165
- for (const candidate of [entry.name, entry.id, entry.check]) {
166
- if (nonEmptyString(candidate)) return [candidate.trim()]
167
- }
168
- // A nameless runDiscovery receipt stored in mission.testRuns is the built-in
169
- // test tool evidence. It answers to `tests` (the tool-layer auto check name)
170
- // and to `tower_test` (the synthetic name used by earlier integrations).
171
- if (Array.isArray(entry.commands) && Array.isArray(entry.tests)) return ['tests', 'tower_test']
172
- return []
173
- }
174
-
175
- function checkOutcome(entry) {
176
- if (!isPlainObject(entry)) return null
177
- if (typeof entry.status === 'string') {
178
- const status = entry.status.trim().toLowerCase()
179
- if (PASS_STATUSES.has(status)) return true
180
- if (FAIL_STATUSES.has(status)) return false
181
- }
182
- if (typeof entry.ok === 'boolean') return entry.ok
183
- if (typeof entry.pass === 'boolean') return entry.pass
184
- if (typeof entry.success === 'boolean') return entry.success
185
- if (entry.exitCode !== undefined && entry.exitCode !== null) {
186
- const exitCode = typeof entry.exitCode === 'number' ? entry.exitCode : Number(entry.exitCode)
187
- if (Number.isFinite(exitCode)) return exitCode === 0
188
- }
189
- if (entry.error !== undefined && entry.error !== null && entry.error !== '') return false
190
- return null
191
- }
192
-
193
- /**
194
- * Find the most authoritative recorded outcome for `name` on one mission:
195
- * completion report first, then testRuns newest-first, then caller-supplied
196
- * `extraChecks` (optionally scoped by `missionId`).
197
- */
198
- function findCheck(mission, name, extraChecks, missionId) {
199
- const sources = []
200
- const reportChecks = Array.isArray(mission?.completionReport?.checks) ? mission.completionReport.checks : []
201
- for (let index = reportChecks.length - 1; index >= 0; index -= 1) {
202
- sources.push({ source: 'completionReport', entry: reportChecks[index] })
203
- }
204
- const testRuns = Array.isArray(mission?.testRuns) ? mission.testRuns : []
205
- for (let index = testRuns.length - 1; index >= 0; index -= 1) {
206
- const run = testRuns[index]
207
- if (!isPlainObject(run)) continue
208
- if (Array.isArray(run.checks) && run.checks.length > 0) {
209
- for (let inner = run.checks.length - 1; inner >= 0; inner -= 1) {
210
- sources.push({ source: 'testRuns', entry: run.checks[inner] })
211
- }
212
- } else {
213
- sources.push({ source: 'testRuns', entry: run })
214
- }
215
- }
216
- for (let index = extraChecks.length - 1; index >= 0; index -= 1) {
217
- const entry = extraChecks[index]
218
- if (!isPlainObject(entry)) continue
219
- const scope = entry.missionId ?? entry.mission_id
220
- if (scope !== undefined && scope !== null && String(scope) !== missionId) continue
221
- sources.push({ source: 'input', entry })
222
- }
223
- for (const source of sources) {
224
- if (!checkNames(source.entry).includes(name)) continue
225
- const outcome = checkOutcome(source.entry)
226
- if (outcome === null) continue
227
- return { outcome, source: source.source, entry: source.entry }
228
- }
229
- return null
230
- }
231
-
232
202
  function blocker(message, code = 'batch-invalid') {
233
203
  return { code, message }
234
204
  }
@@ -414,9 +384,9 @@ export function validateClose(state, batch, input = {}) {
414
384
  const receiptPaths = receiptPathsRaw.map((value, index) => normalizeReceiptPath(value, `batch.receiptPaths[${index}]`))
415
385
 
416
386
  const blockers = []
417
- const status = batch.status ?? 'open'
418
- if (status === 'closed') blockers.push(blocker(`batch ${batch.id ?? ''} is already closed`.trim()))
419
- else if (status === 'abandoned') blockers.push(blocker(`batch ${batch.id ?? ''} is abandoned`.trim()))
387
+ const status = normalizeBatchStatus(batch.status ?? BATCH_STATUS.OPEN)
388
+ if (status === BATCH_STATUS.CLOSED) blockers.push(blocker(`batch ${batch.id ?? ''} is already closed`.trim()))
389
+ else if (status === BATCH_STATUS.ABANDONED) blockers.push(blocker(`batch ${batch.id ?? ''} is abandoned`.trim()))
420
390
 
421
391
  const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
422
392
  const byId = new Map()
@@ -447,13 +417,13 @@ export function validateClose(state, batch, input = {}) {
447
417
  results.push({ missionId, name, ok: true, outcome: 'skipped', source: 'abandoned' })
448
418
  continue
449
419
  }
450
- const found = findCheck(mission, name, extraChecks, missionId)
420
+ const found = findEvidence(shapeFor(mission.kind).evidence, mission, name, { extraChecks, missionId })
451
421
  if (!found) {
452
422
  results.push({ missionId, name, ok: false, outcome: 'missing', source: null })
453
423
  blockers.push(blocker(`mission ${missionId} has no recorded check "${name}"`, 'tests-missing'))
454
424
  continue
455
425
  }
456
- if (found.outcome !== true) {
426
+ if (found.ok !== true) {
457
427
  results.push({ missionId, name, ok: false, outcome: 'failed', source: found.source })
458
428
  blockers.push(blocker(`mission ${missionId} check "${name}" did not pass`, 'tests-failed'))
459
429
  continue
@@ -507,14 +477,14 @@ export function summarize(state, batch) {
507
477
  if (!Array.isArray(receiptPathsRaw)) fail('batch.receiptPaths must be an array')
508
478
  const receiptPaths = receiptPathsRaw.map((value, index) => normalizeReceiptPath(value, `batch.receiptPaths[${index}]`))
509
479
  const exclusions = Array.isArray(batch.exclusions) ? batch.exclusions : []
510
- const status = batch.status ?? 'open'
480
+ const status = normalizeBatchStatus(batch.status ?? BATCH_STATUS.OPEN)
511
481
 
512
482
  const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
513
483
  const byId = new Map()
514
484
  for (const mission of missions) if (isPlainObject(mission) && nonEmptyString(mission.id)) byId.set(mission.id, mission)
515
485
 
516
486
  const blockers = []
517
- if (CLOSED_BATCH_STATUSES.has(status)) blockers.push(blocker(`batch ${batch.id ?? ''} is already ${status}`.trim()))
487
+ if (isBatchTerminal(status)) blockers.push(blocker(`batch ${batch.id ?? ''} is already ${status}`.trim()))
518
488
  const members = []
519
489
  const checks = []
520
490
  for (const missionId of missionIds) {
@@ -539,7 +509,7 @@ export function summarize(state, batch) {
539
509
  checks.push({ missionId, name, ok: true, outcome: 'skipped', source: 'abandoned' })
540
510
  continue
541
511
  }
542
- const found = findCheck(mission, name, [], missionId)
512
+ const found = findEvidence(shapeFor(mission.kind).evidence, mission, name, { missionId })
543
513
  if (!found) {
544
514
  checks.push({ missionId, name, ok: false, outcome: 'missing', source: null })
545
515
  continue
@@ -547,8 +517,8 @@ export function summarize(state, batch) {
547
517
  checks.push({
548
518
  missionId,
549
519
  name,
550
- ok: found.outcome === true,
551
- outcome: found.outcome === true ? 'passed' : 'failed',
520
+ ok: found.ok === true,
521
+ outcome: found.ok === true ? 'passed' : 'failed',
552
522
  source: found.source,
553
523
  })
554
524
  }