dsh-tower 2.0.4 → 2.1.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 (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +9 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,75 @@
1
+ // tower_init / tower_doctor / tower_recover / tower_test / tower_batch /
2
+ // tower_resource / tower_import_legacy / tower_legacy_map / tower_reload input
3
+ // contracts. Pure TypeBox only; no I/O.
4
+ import {
5
+ BATCH_ACTIONS,
6
+ LEASE_ACTIONS,
7
+ LEGACY_MAP_ACTIONS,
8
+ RECOVERY_ACTIONS,
9
+ } from '../../vocabulary.js'
10
+ import { Type, enumOf, untypedObject } from './shared.js'
11
+
12
+ export const InitInput = Type.Object({
13
+ repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
14
+ base: Type.Optional(Type.String({ description: 'Optional local base branch (defaults to the checked-out branch).' })),
15
+ autoGitInit: Type.Optional(Type.Boolean({ description: 'Run git init (and an empty initial commit) when repoRoot is not a repository yet.' })),
16
+ snapshotDirty: Type.Optional(Type.Boolean({ description: 'Snapshot uncommitted tracked changes into a dangling commit used as the mission baseline (untracked files are not included).' })),
17
+ reanchor: Type.Optional(Type.Boolean({ description: 'Update the recorded base when no mission is active.' })),
18
+ }, { additionalProperties: false })
19
+
20
+ export const DoctorInput = Type.Object({
21
+ repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
22
+ }, { additionalProperties: false })
23
+
24
+ export const RecoverInput = Type.Object({
25
+ repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
26
+ action: enumOf(RECOVERY_ACTIONS, { description: 'Recovery action.' }),
27
+ confirm: Type.Optional(Type.String({ description: 'Required literal CLEAR_STALE_LOCK for clear-stale-lock.' })),
28
+ }, { additionalProperties: false })
29
+
30
+ export const TestInput = Type.Object({
31
+ repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
32
+ missionId: Type.String({ description: 'Mission id, e.g. M1 (alias: id).' }),
33
+ id: Type.Optional(Type.String({ description: 'Alias for missionId.' })),
34
+ }, { additionalProperties: false })
35
+
36
+ export const BatchInput = Type.Object({
37
+ repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
38
+ action: enumOf(BATCH_ACTIONS, { description: 'Batch operation.' }),
39
+ missionIds: Type.Optional(Type.Array(Type.String(), { description: 'Exact mission ids in this batch (create).' })),
40
+ exclusions: Type.Optional(Type.Array(Type.String(), { description: 'Paused legacy mission ids excluded from the batch.' })),
41
+ requiredChecks: Type.Optional(Type.Array(Type.String(), { description: 'Check names every build member must record (default ["check"]).' })),
42
+ receiptPaths: Type.Optional(Type.Array(Type.String(), { description: 'Exact relative receipt files checked at close.' })),
43
+ batchId: Type.Optional(Type.String({ description: 'Batch id (status/close/abandon).' })),
44
+ reason: Type.Optional(Type.String({ description: 'Reason recorded when abandoning a batch.' })),
45
+ checks: Type.Optional(Type.Array(untypedObject(), { description: 'Close-time extra check evidence.' })),
46
+ treeTip: Type.Optional(Type.String({ description: 'Expected reviewed/tested commit for close evidence.' })),
47
+ receiptHashes: Type.Optional(untypedObject('Relative receipt path -> sha256 observed by the caller.')),
48
+ }, { additionalProperties: false })
49
+
50
+ export const ResourceInput = Type.Object({
51
+ repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
52
+ action: enumOf(LEASE_ACTIONS, { description: 'Lease operation.' }),
53
+ name: Type.Optional(Type.String({ description: 'Lease name, e.g. plugin-load or heavy-ci.' })),
54
+ holder: Type.Optional(Type.String({ description: 'Holder label (defaults to the caller).' })),
55
+ token: Type.Optional(Type.String({ description: 'Release token; generated on acquire when omitted.' })),
56
+ ttlMs: Type.Optional(Type.Integer({ minimum: 1000, description: 'Lease lifetime (default leases.ttlMs).' })),
57
+ force: Type.Optional(Type.Boolean({ description: 'Explicitly release an expired lease (token still required).' })),
58
+ }, { additionalProperties: false })
59
+
60
+ export const ImportLegacyInput = Type.Object({
61
+ repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
62
+ sourcePath: Type.String({ description: 'Absolute path to the legacy JSON state file.' }),
63
+ }, { additionalProperties: false })
64
+
65
+ export const LegacyMapInput = Type.Object({
66
+ repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
67
+ action: enumOf(LEGACY_MAP_ACTIONS, { description: 'Mapping operation.' }),
68
+ sourceHash: Type.Optional(Type.String({ description: 'Legacy snapshot sha256 (map).' })),
69
+ oldId: Type.Optional(Type.String({ description: 'Legacy mission id (map).' })),
70
+ newMissionId: Type.Optional(Type.String({ description: 'Current mission id; optional audit pointer.' })),
71
+ }, { additionalProperties: false })
72
+
73
+ export const ReloadInput = Type.Object({
74
+ repoRoot: Type.Optional(Type.String({ description: 'Optional repository root; when present the caller must be the tower.' })),
75
+ }, { additionalProperties: false, required: [] })
@@ -0,0 +1,25 @@
1
+ // Shared TypeBox helpers for tool input schemas (#45, #56 P3c).
2
+ import { Type } from '@sinclair/typebox'
3
+
4
+ /**
5
+ * A string enum that serializes to the same JSON Schema shape as the old
6
+ * inline schemas (type/enum) rather than TypeBox's anyOf union form.
7
+ * @template {string} T
8
+ * @param {readonly T[]} values
9
+ * @param {Record<string, unknown>} [options]
10
+ * @returns {import('@sinclair/typebox').TUnsafe<T>}
11
+ */
12
+ export function enumOf(values, options = {}) {
13
+ return Type.Unsafe(/** @type {any} */ ({ type: 'string', enum: [...values], ...options }))
14
+ }
15
+
16
+ /**
17
+ * Untyped JSON object entries (checks, receiptHashes) must keep their exact
18
+ * `{ type: 'object' }` shape; Type.Object({}) would add a `properties: {}` key.
19
+ * @param {string} [description]
20
+ */
21
+ export function untypedObject(description) {
22
+ return Type.Unsafe(/** @type {any} */ (description === undefined ? { type: 'object' } : { type: 'object', description }))
23
+ }
24
+
25
+ export { Type }
@@ -0,0 +1,98 @@
1
+ // Stable tool error codes.
2
+ //
3
+ // Everything a Tower tool throws reaches the model as
4
+ // [tower:<code>] <original message>
5
+ // so callers (and tests) can branch on the code instead of matching prose.
6
+ // The catalog is documented in docs/reference/errors.md; scripts/check-docs.mjs
7
+ // keeps the two in sync.
8
+
9
+ /** Neutral error type shared by the git adapter and the domain classifier. */
10
+ export class GitError extends Error {
11
+ constructor(args, detail, code, meta = {}) {
12
+ super(`git ${args.join(' ')} failed: ${detail}`)
13
+ this.name = 'GitError'
14
+ this.code = code
15
+ this.timedOut = meta.timedOut === true
16
+ this.bufferOverflow = meta.bufferOverflow === true
17
+ this.stdout = meta.stdout ?? ''
18
+ }
19
+ }
20
+
21
+ export const ERROR_CODES = {
22
+ 'invalid-argument': 'A tool argument is missing, malformed, or not allowed.',
23
+ 'repo-invalid': 'repoRoot is missing, relative, or not inside a Git repository.',
24
+ 'not-initialized': 'Tower is not initialized for this repository; run tower_init.',
25
+ 'mission-not-found': 'No mission with that id exists.',
26
+ 'finding-not-found': 'No finding with that id exists.',
27
+ 'invalid-transition': 'The requested state transition is not allowed.',
28
+ 'stale-base': 'The repository state changed while the operation was being prepared; retry.',
29
+ 'state-locked': 'Another Tower writer holds the state lock.',
30
+ 'state-corrupt': 'The Tower state file is unreadable or invalid.',
31
+ 'state-version-unsupported': 'The Tower state file uses a schema version this build cannot read or downgrade.',
32
+ 'state-migration-failed': 'A Tower state migration failed; the original state file was not modified.',
33
+ 'config-invalid': 'Tower configuration is invalid.',
34
+ 'config-unknown-key': 'Tower configuration has an unknown top-level key.',
35
+ 'git-failed': 'A git command failed.',
36
+ 'git-timeout': 'A git command timed out or exceeded its output buffer.',
37
+ 'worktree-dirty': 'The mission worktree has uncommitted changes and cannot complete.',
38
+ 'spawn-unavailable': 'The subagents service is unavailable.',
39
+ 'provider-missing': 'No subagent provider is registered.',
40
+ 'provider-cooldown': 'The provider is throttled; spawn is in a short cooldown.',
41
+ 'preset-missing': 'The configured agent preset is not registered.',
42
+ 'reviewer-prerequisite': 'The reviewer spawn prerequisite is not met.',
43
+ 'not-authorized': 'The calling agent role may not perform this action.',
44
+ 'workspace-busy': 'Another Tower session owns this repository workspace lock.',
45
+ 'worker-limit': 'The repository reached spawn.maxLiveWorkers live workers.',
46
+ 'rate-limited': 'The tower_send per-minute rate limit was reached.',
47
+ 'takeover-refused': 'The mission already has a live worker that was not stopped first.',
48
+ 'reload-unavailable': 'The DSH HMR service is not available in this profile.',
49
+ 'reload-failed': 'The HMR reload failed; check the DSH log and restart if needed.',
50
+ 'tests-failed': 'Declared test discovery failed or did not meet its admission requirements.',
51
+ 'tests-timeout': 'Test discovery exceeded its time or output budget.',
52
+ 'lease-busy': 'Another holder owns the requested advisory lease.',
53
+ 'lease-token': 'The lease release token does not match the current holder.',
54
+ 'batch-invalid': 'The batch request or its close evidence is invalid.',
55
+ 'legacy-invalid': 'The legacy snapshot or mapping is invalid.',
56
+ 'tool-internal': 'An unexpected failure; read the message and the activity log.',
57
+ }
58
+
59
+ export const ERROR_CODE_VALUES = Object.freeze(Object.keys(ERROR_CODES))
60
+ export function isErrorCode(value) {
61
+ return typeof value === 'string' && Object.hasOwn(ERROR_CODES, value)
62
+ }
63
+
64
+ const RETRYABLE_CODES = new Set(['state-locked', 'rate-limited', 'provider-cooldown', 'git-timeout', 'workspace-busy', 'merge-intent-pending', 'stale-base'])
65
+
66
+ export class TowerToolError extends Error {
67
+ constructor(code, message, options = {}) {
68
+ super(`[tower:${code}] ${message}`, options.cause ? { cause: options.cause } : undefined)
69
+ this.name = 'TowerToolError'
70
+ this.code = code
71
+ this.retryable = options.retryable ?? RETRYABLE_CODES.has(code)
72
+ this.nextAction = options.nextAction ?? null
73
+ this.details = options.details ?? null
74
+ }
75
+ }
76
+
77
+ function originalMessage(error) {
78
+ return String(error?.message ?? error)
79
+ }
80
+
81
+ /** Wrap any thrown value into a TowerToolError with a stable code. */
82
+ export function classifyError(error) {
83
+ if (error instanceof TowerToolError) return error
84
+ if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
85
+ return new TowerToolError(error.code, originalMessage(error), {
86
+ cause: error,
87
+ retryable: error.retryable === true,
88
+ nextAction: error.nextAction ?? null,
89
+ details: error.details ?? null,
90
+ })
91
+ }
92
+ if (error instanceof GitError || error?.name === 'GitError') {
93
+ const message = originalMessage(error)
94
+ const code = error?.timedOut === true || error?.bufferOverflow === true ? 'git-timeout' : 'git-failed'
95
+ return new TowerToolError(code, message, { cause: error })
96
+ }
97
+ return new TowerToolError('tool-internal', originalMessage(error), { cause: error })
98
+ }
package/domain/gate.js ADDED
@@ -0,0 +1,125 @@
1
+ // Pure completion gate checks (#49). No repository I/O.
2
+ // @ts-check
3
+ import { isTerminal } from './mission.js'
4
+ import { isReviewExempt, reviewExemptionInvalid } from './integration/review.js'
5
+
6
+ /** @typedef {import('../types/index.js').Blocker} Blocker */
7
+ /** @typedef {import('../types/index.js').MergeGateOptions} MergeGateOptions */
8
+ /** @typedef {import('../types/index.js').Mission} Mission */
9
+ /** @typedef {import('../types/index.js').Review} Review */
10
+ /** @typedef {import('../types/index.js').TowerConfig} TowerConfig */
11
+
12
+ /**
13
+ * @param {Mission} mission
14
+ * @param {TowerConfig} [config]
15
+ * @returns {Blocker[]}
16
+ */
17
+ export function completionBlockers(mission, config = {}) {
18
+ /** @type {Blocker[]} */
19
+ const blockers = []
20
+ /** @type {(code: string, detail: string) => void} */
21
+ const push = (code, detail) => {
22
+ blockers.push({ code, detail })
23
+ }
24
+ if (isTerminal(mission.status)) {
25
+ push(
26
+ 'branch-owned-by-closed-missions',
27
+ `mission ${mission.id} is ${mission.status}; a merge never flips a closed mission — re-plan the work under a new title`,
28
+ )
29
+ }
30
+ if (mission.status !== 'completed') {
31
+ push('mission-not-completed', `mission ${mission.id} is ${mission.status}; completion is required`)
32
+ }
33
+ const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
34
+ if (openTasks.length > 0) {
35
+ push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
36
+ }
37
+ const presentBlockers = mission.blockers ?? []
38
+ if (presentBlockers.length > 0) push('blockers-present', presentBlockers.join('; '))
39
+ if (config.gates?.requireChecks === true) {
40
+ const checks = mission.completionReport?.checks ?? []
41
+ if (checks.length === 0) {
42
+ push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
43
+ }
44
+ }
45
+ return blockers
46
+ }
47
+
48
+ /**
49
+ * Review, roster, and test-evidence blockers; all inputs are snapshots.
50
+ * @param {MergeGateOptions} options
51
+ * @returns {Blocker[]}
52
+ */
53
+ export function mergeGateBlockers({
54
+ mission,
55
+ roster = [],
56
+ branch = null,
57
+ branchTip = null,
58
+ review = null,
59
+ config = {},
60
+ activity = {},
61
+ parentStatus = null,
62
+ inactiveStates = [],
63
+ rebaseWaived = false,
64
+ }) {
65
+ /** @type {Blocker[]} */
66
+ const blockers = []
67
+ /** @type {(code: string, detail: string) => void} */
68
+ const push = (code, detail) => {
69
+ blockers.push({ code, detail })
70
+ }
71
+ const inactive = new Set(inactiveStates)
72
+ const requireReview = config.review?.requireReview !== false
73
+ const exempt = isReviewExempt(mission, config)
74
+ if (reviewExemptionInvalid(mission, config)) {
75
+ push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
76
+ }
77
+ if (requireReview && !exempt && !review) {
78
+ push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.requireReview:false)`)
79
+ } else if (requireReview && !exempt) {
80
+ const latest = /** @type {Review} */ (review)
81
+ if (latest.missionId !== mission.id) push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
82
+ if (latest.status !== 'clean') push('not-clean', `latest review round ${latest.round} is "${latest.status}"`)
83
+ if (latest.mergeVerdict === 'hold') {
84
+ push('review-hold', 'latest review recommends hold — a later clean review must supersede it')
85
+ } else if (latest.status === 'clean' && latest.mergeVerdict !== 'merge') {
86
+ push('review-not-approved', `latest clean review recommends "${latest.mergeVerdict}" — only "merge" approves`)
87
+ }
88
+ if (branchTip && latest.reviewedCommit !== branchTip && !rebaseWaived) {
89
+ push('tip-moved', `reviewed ${latest.reviewedCommit.slice(0, 7)} but tip is ${branchTip.slice(0, 7)}; re-review required`)
90
+ }
91
+ }
92
+ for (const entry of (roster ?? []).filter((row) => row.missionId === mission.id && !inactive.has(row.state))) {
93
+ // Reserved rows can lack an agent id; indexing with undefined is a miss.
94
+ const agentId = /** @type {string} */ (entry.agentId)
95
+ if (activity[agentId] === 'running') push('worker-busy', `agent ${entry.name} is still running`)
96
+ else if (parentStatus && entry.parentSessionId && parentStatus[entry.parentSessionId] !== 'ok') {
97
+ push('worker-status-unknown', `cannot prove agent ${entry.name} is stopped: its parent catalog is unavailable`)
98
+ }
99
+ }
100
+ const staleWorkers = (roster ?? []).filter(
101
+ (row) => row.missionId === mission.id && (row.state === 'stale' || (row.stale === true && !inactive.has(row.state))),
102
+ )
103
+ if (staleWorkers.length > 0) {
104
+ push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
105
+ }
106
+ if (config.gates?.requireTests === true) {
107
+ const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
108
+ if (runs.length === 0) {
109
+ push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
110
+ }
111
+ }
112
+ return blockers
113
+ }
114
+
115
+ /**
116
+ * A conflict-free rebase after the reviewed tip waives the stale-tip blocker.
117
+ * @param {Mission} mission
118
+ * @param {Review} review
119
+ * @returns {boolean}
120
+ */
121
+ export function rebaseWaived(mission, review) {
122
+ const rebase = mission.rebase
123
+ if (!rebase) return false
124
+ return review.at <= rebase.at && review.reviewedCommit === rebase.oldTip
125
+ }
@@ -0,0 +1,190 @@
1
+ // Pure preflight/gate blocker computation from state snapshots plus the Git
2
+ // facts the app already read (tips, changed files, checkout branch). The app
3
+ // performs every read and owns the result assembly.
4
+ // @ts-check
5
+ import { caseInsensitiveScopeMatch, fileInScope } from '../scope.js'
6
+
7
+ /** @typedef {import('../../types/index.js').Blocker} Blocker */
8
+ /** @typedef {import('../../types/index.js').Mission} Mission */
9
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
10
+
11
+ /**
12
+ * @param {string} missionId
13
+ * @returns {object}
14
+ */
15
+ export function missingMissionGateResult(missionId) {
16
+ return {
17
+ ok: false,
18
+ blockers: [{ code: 'mission-missing', detail: `unknown mission "${missionId}"` }],
19
+ missionId,
20
+ branchTip: null,
21
+ baseTip: null,
22
+ changedFiles: [],
23
+ review: null,
24
+ }
25
+ }
26
+
27
+ /**
28
+ * @param {{ missionId: string, startedAt?: number }} mergeIntent
29
+ * @returns {Blocker}
30
+ */
31
+ export function mergeIntentPendingBlocker(mergeIntent) {
32
+ return {
33
+ code: 'merge-intent-pending',
34
+ detail: `a merge intent for ${mergeIntent.missionId} was recorded at ${new Date(Number(mergeIntent.startedAt) || 0).toISOString()}; run tower_merge to reconcile it`,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {string} missionId
40
+ * @returns {Blocker}
41
+ */
42
+ export function noWorktreeBlocker(missionId) {
43
+ return { code: 'branch-missing', detail: `mission ${missionId} has no worktree or branch` }
44
+ }
45
+
46
+ /**
47
+ * @param {object} input
48
+ * @param {string} input.branch
49
+ * @param {string} input.base
50
+ * @param {string} input.baseTip
51
+ * @returns {Blocker}
52
+ */
53
+ export function staleBaseBlocker({ branch, base, baseTip }) {
54
+ return {
55
+ code: 'stale-base',
56
+ detail: `${branch} does not contain base "${base}" tip ${baseTip.slice(0, 12)}; run tower_rebase first`,
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {object} input
62
+ * @param {string} input.missionId
63
+ * @param {string[]} input.changedFiles
64
+ * @returns {Blocker | null}
65
+ */
66
+ export function surveyChangedFilesBlocker({ missionId, changedFiles }) {
67
+ return changedFiles.length > 0
68
+ ? {
69
+ code: 'read-only-survey',
70
+ detail: `survey ${missionId} is read-only but ${changedFiles.length} file(s) changed: ${changedFiles.slice(0, 5).join(', ')}`,
71
+ }
72
+ : null
73
+ }
74
+
75
+ /**
76
+ * @param {object} input
77
+ * @param {Mission} input.mission
78
+ * @param {string[]} input.changedFiles
79
+ * @param {boolean} [input.caseInsensitive] the checkout's detected comparison mode
80
+ * @returns {Blocker | null}
81
+ */
82
+ export function outOfScopeBlocker({ mission, changedFiles, caseInsensitive = false }) {
83
+ const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file, { caseInsensitive }))
84
+ if (outOfScope.length === 0) return null
85
+ const parts = [`changed files outside scope (${mission.scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`]
86
+ // On a case-sensitive checkout a case-only mismatch is a human error worth
87
+ // naming, but it stays a rejection: folding here would make src/Api and
88
+ // src/api the same directory on a filesystem where they are not (#90).
89
+ if (!caseInsensitive) {
90
+ const hints = outOfScope
91
+ .map((file) => {
92
+ const pattern = caseInsensitiveScopeMatch(mission.scope, file)
93
+ return pattern
94
+ ? `${file} matches "${pattern}" only when case is folded; this checkout is case-sensitive`
95
+ : null
96
+ })
97
+ .filter(Boolean)
98
+ .slice(0, 3)
99
+ if (hints.length > 0) parts.push(`case mismatch: ${hints.join('; ')}`)
100
+ }
101
+ return { code: 'out-of-scope', detail: parts.join('. ') }
102
+ }
103
+
104
+ /**
105
+ * @param {string[]} changedFiles
106
+ * @param {string[]} dirty
107
+ * @returns {string[]}
108
+ */
109
+ export function overwrittenPaths(changedFiles, dirty) {
110
+ return changedFiles.filter((file) => dirty.includes(file))
111
+ }
112
+
113
+ /**
114
+ * @param {string[]} wouldOverwrite
115
+ * @returns {Blocker | null}
116
+ */
117
+ export function baseDirtyBlocker(wouldOverwrite) {
118
+ return wouldOverwrite.length > 0
119
+ ? {
120
+ code: 'base-dirty',
121
+ detail: `main checkout has uncommitted changes this merge would overwrite: ${wouldOverwrite.slice(0, 8).join(', ')}`,
122
+ }
123
+ : null
124
+ }
125
+
126
+ /**
127
+ * @param {object} input
128
+ * @param {string[]} input.deps
129
+ * @param {TowerState} input.state
130
+ * @param {Map<string, { status: string }>} [input.archived] compact archived missions (#73)
131
+ * @returns {Blocker[]}
132
+ */
133
+ export function dependencyBlockers({ deps = [], state, archived = new Map() }) {
134
+ /** @type {Blocker[]} */
135
+ const blockers = []
136
+ for (const depId of deps) {
137
+ const dep = state.missions.find((entry) => entry.id === depId) ?? archived.get(depId)
138
+ if (!dep || !['merged', 'abandoned'].includes(dep.status)) {
139
+ blockers.push({ code: 'deps-unmerged', detail: `${depId} is ${dep ? dep.status : 'missing'}` })
140
+ }
141
+ }
142
+ return blockers
143
+ }
144
+
145
+ /**
146
+ * @param {object} input
147
+ * @param {string | null} input.checkoutBranch
148
+ * @param {string} input.base
149
+ * @returns {Blocker | null}
150
+ */
151
+ export function baseWrongBranchBlocker({ checkoutBranch, base }) {
152
+ return checkoutBranch !== base
153
+ ? { code: 'base-wrong-branch', detail: `main checkout is on ${checkoutBranch ?? 'detached HEAD'}, expected "${base}"` }
154
+ : null
155
+ }
156
+
157
+ /**
158
+ * Append every blocker produced by a decision helper.
159
+ * @param {Blocker[]} blockers
160
+ * @param {Blocker | null} blocker
161
+ * @returns {Blocker[]}
162
+ */
163
+ export function pushBlocker(blockers, blocker) {
164
+ if (blocker) blockers.push(blocker)
165
+ return blockers
166
+ }
167
+
168
+ /**
169
+ * @param {object} input
170
+ * @param {object} input.mission full mission projection
171
+ * @param {object | null} input.worktree
172
+ * @param {string | null} input.branchTip
173
+ * @param {string | null} input.baseTip
174
+ * @param {string[]} input.changedFiles
175
+ * @param {object | null} input.review
176
+ * @param {Blocker[]} input.blockers
177
+ * @returns {object}
178
+ */
179
+ export function gateResult({ mission, worktree, branchTip, baseTip, changedFiles, review, blockers }) {
180
+ return {
181
+ ok: blockers.length === 0,
182
+ blockers,
183
+ mission,
184
+ worktree,
185
+ branchTip,
186
+ baseTip,
187
+ changedFiles,
188
+ review,
189
+ }
190
+ }