dsh-tower 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +18 -16
  7. package/README.md +18 -11
  8. package/adapters/leases.js +20 -6
  9. package/adapters/legacy.js +149 -37
  10. package/adapters/stateFile.js +11 -1
  11. package/app/archive.js +30 -5
  12. package/app/briefing.js +95 -12
  13. package/app/checkCompletion.js +107 -18
  14. package/app/enrichedMissions.js +1 -1
  15. package/app/freshness.js +77 -0
  16. package/app/gate.js +26 -12
  17. package/app/merge.js +7 -4
  18. package/app/mission.js +24 -1
  19. package/app/missionContext.js +2 -1
  20. package/app/plan.js +64 -1
  21. package/app/provision.js +133 -22
  22. package/app/read.js +38 -5
  23. package/app/rebase.js +10 -8
  24. package/app/review.js +104 -61
  25. package/app/reviewCheckout.js +133 -0
  26. package/app/sandbox.js +121 -0
  27. package/app/sharedCheckout.js +90 -0
  28. package/app/spawn-prechecks.js +31 -20
  29. package/app/spawn-support.js +59 -8
  30. package/app/spawn.js +3 -0
  31. package/app/teardown.js +119 -2
  32. package/app/tower.js +34 -6
  33. package/client.js +321 -0
  34. package/cordis.patch.yml +55 -3
  35. package/docs/adr/0005-component-surface.md +58 -13
  36. package/docs/adr/0006-mission-shapes.md +306 -0
  37. package/docs/architecture/overview.md +18 -10
  38. package/docs/examples/first-mission.md +3 -2
  39. package/docs/faq.md +9 -7
  40. package/docs/guide/install-upgrade-migration.md +58 -7
  41. package/docs/guide/quickstart.md +20 -10
  42. package/docs/guide/scenarios-and-philosophy.md +35 -28
  43. package/docs/operations/runbook.md +1 -1
  44. package/docs/operations/security.md +11 -10
  45. package/docs/reference/blockers.md +20 -11
  46. package/docs/reference/component-surface.md +20 -8
  47. package/docs/reference/config.generated.md +8 -5
  48. package/docs/reference/config.md +30 -5
  49. package/docs/reference/doctor.md +7 -7
  50. package/docs/reference/errors.generated.md +1 -0
  51. package/docs/reference/errors.md +2 -1
  52. package/docs/reference/operations.md +1 -1
  53. package/docs/reference/shapes.md +123 -0
  54. package/docs/reference/surface-budget.md +53 -4
  55. package/docs/reference/testing.md +5 -3
  56. package/docs/reference/tools.generated.md +7 -7
  57. package/docs/reference/tools.md +5 -5
  58. package/docs/zh/faq.md +7 -5
  59. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  60. package/docs/zh/guide/quickstart.md +17 -10
  61. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  62. package/docs/zh/operations/security.md +9 -8
  63. package/docs/zh/reference/blockers.md +13 -9
  64. package/docs/zh/reference/config.md +22 -5
  65. package/docs/zh/reference/errors.md +2 -1
  66. package/docs/zh/reference/testing.md +5 -4
  67. package/docs/zh/reference/tools.md +3 -3
  68. package/domain/batch.js +75 -105
  69. package/domain/collab/batch.js +30 -10
  70. package/domain/collab/spawn.js +53 -16
  71. package/domain/components.js +12 -4
  72. package/domain/config/schema.js +87 -22
  73. package/domain/contracts/outputs.js +2 -1
  74. package/domain/contracts/tool-inputs/missions.js +10 -2
  75. package/domain/errors.js +6 -0
  76. package/domain/evidence.js +283 -0
  77. package/domain/gate.js +17 -34
  78. package/domain/integration/gate.js +17 -43
  79. package/domain/integration/init.js +3 -2
  80. package/domain/integration/provision.js +51 -0
  81. package/domain/integration/rebase.js +16 -1
  82. package/domain/integration/review.js +203 -9
  83. package/domain/mission.js +16 -33
  84. package/domain/missions/completion.js +46 -8
  85. package/domain/missions/findings.js +2 -1
  86. package/domain/missions/lifecycle.js +131 -35
  87. package/domain/missions/plan.js +30 -6
  88. package/domain/missions/review.js +2 -18
  89. package/domain/planning.js +5 -5
  90. package/domain/read/missionContext.js +17 -3
  91. package/domain/read/views.js +5 -1
  92. package/domain/roles.js +222 -0
  93. package/domain/roster.js +9 -3
  94. package/domain/scope.js +51 -0
  95. package/domain/shapes/build.js +254 -0
  96. package/domain/shapes/index.js +171 -0
  97. package/domain/shapes/survey.js +445 -0
  98. package/domain/state/archive.js +28 -4
  99. package/domain/state/migrations.js +111 -4
  100. package/domain/state/schema.js +69 -7
  101. package/domain/state/semantics.js +93 -13
  102. package/domain/state.js +5 -1
  103. package/domain/summary.js +5 -11
  104. package/domain/vocabulary.js +15 -33
  105. package/host/capability-registry.js +25 -9
  106. package/host/config.js +40 -3
  107. package/host/doctor.js +20 -8
  108. package/host/preset-survey-sandbox.js +19 -0
  109. package/host/preset-survey.js +18 -0
  110. package/host/presets.js +17 -11
  111. package/host/tool-registry.js +23 -13
  112. package/locale/en.json +16 -1
  113. package/locale/preset-survey/en.json +6 -0
  114. package/locale/preset-survey/zh.json +6 -0
  115. package/locale/preset-survey-sandbox/en.json +6 -0
  116. package/locale/preset-survey-sandbox/zh.json +6 -0
  117. package/locale/zh.json +16 -1
  118. package/package.json +19 -2
  119. package/ports/state.js +8 -1
  120. package/scripts/architecture-waivers.json +3 -3
  121. package/scripts/check-components.mjs +120 -6
  122. package/scripts/check-docs.mjs +1 -1
  123. package/scripts/check-pack.mjs +5 -0
  124. package/scripts/component-surface.json +83 -29
  125. package/scripts/generate-capability-locale.mjs +5 -0
  126. package/scripts/generate-shapes-reference.mjs +171 -0
  127. package/scripts/mount-smoke/surface.mjs +3 -0
  128. package/scripts/pack-manifest.txt +22 -0
  129. package/scripts/shape-audit.mjs +361 -0
  130. package/scripts/surface-baseline.json +75 -4
  131. package/scripts/types-coverage.mjs +7 -0
  132. package/survey-sandbox.js +48 -0
  133. package/survey.js +40 -0
  134. package/tools/complete.js +5 -5
  135. package/tools/diff.js +5 -3
  136. package/tools/plan.js +1 -1
  137. package/tools/preflight.js +3 -2
  138. package/tools/review.js +1 -1
  139. package/tools/spawn.js +48 -50
  140. package/tools/status.js +8 -1
  141. package/tools/test.js +2 -1
  142. package/types/index.d.ts +76 -5
@@ -6,6 +6,7 @@
6
6
  import { TowerToolError } from '../errors.js'
7
7
  import { plannedEvents } from '../events.js'
8
8
  import { normalizeScope, validateTitle } from '../scope.js'
9
+ import { SHAPE_KINDS, shapeFor } from '../shapes/index.js'
9
10
 
10
11
  /** @typedef {import('../../types/index.js').Mission} Mission */
11
12
  /** @typedef {import('../../types/index.js').TowerState} TowerState */
@@ -28,15 +29,30 @@ function isPlainObject(value) {
28
29
 
29
30
  /**
30
31
  * Validate and normalize one `tower_plan` mission entry.
32
+ *
33
+ * The shape owns the kind-specific plan fields: a build normalizes its scope, a
34
+ * survey records the commit it reads (`read.commit`, resolved by app/plan from
35
+ * `state.base`) plus the tracked-status fingerprint captured at the same moment.
31
36
  * @param {any} input
32
37
  * @param {number} index
38
+ * @param {{ commit?: string, dirty?: string[] }} [read]
33
39
  * @returns {any}
34
40
  */
35
- export function normalizeMissionInput(input, index) {
41
+ export function normalizeMissionInput(input, index, read = {}) {
36
42
  if (!isPlainObject(input)) fail(`missions[${index}] must be an object`)
37
43
  const title = validateTitle(input.title)
38
- const kind = input.kind === 'survey' ? 'survey' : 'build'
39
- const scope = normalizeScope(input.scope ?? [], { allowEmpty: kind === 'survey' })
44
+ const kind = input.kind === undefined ? 'build' : input.kind
45
+ if (typeof kind !== 'string' || !SHAPE_KINDS.includes(kind)) {
46
+ fail(`missions[${index}].kind must be one of ${SHAPE_KINDS.join('|')}`)
47
+ }
48
+ const shape = shapeFor(kind)
49
+ /** @type {string[]} */
50
+ const scopeInput = input.scope ?? []
51
+ if (!Array.isArray(scopeInput)) fail(`missions[${index}].scope must be an array of globs`)
52
+ if (!shape.reach.ownsPaths && scopeInput.length > 0) {
53
+ fail(`missions[${index}] is a ${shape.kind}: it reads a commit and does not own a write scope`)
54
+ }
55
+ const scope = shape.reach.ownsPaths ? normalizeScope(scopeInput) : []
40
56
  /** @type {Array<{ text: string, status: string }>} */
41
57
  const tasks = []
42
58
  if (input.tasks !== undefined) {
@@ -80,19 +96,25 @@ export function normalizeMissionInput(input, index) {
80
96
  if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
81
97
  fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
82
98
  }
83
- return { title, kind, scope, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
99
+ const fields = /** @type {any} */ (/** @type {any} */ (shape).planFields)({
100
+ scope,
101
+ readCommit: read.commit,
102
+ readDirty: read.dirty,
103
+ })
104
+ return { title, kind, ...fields, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
84
105
  }
85
106
 
86
107
  /**
87
108
  * Validate the plan envelope and normalize every mission entry.
88
109
  * @param {any} input
110
+ * @param {{ commit?: string, dirty?: string[] }} [read]
89
111
  * @returns {any[]}
90
112
  */
91
- export function normalizePlanMissions(input) {
113
+ export function normalizePlanMissions(input, read = {}) {
92
114
  if (!Array.isArray(input.missions) || input.missions.length === 0) {
93
115
  fail('tower_plan needs at least one mission')
94
116
  }
95
- return input.missions.map((mission, index) => normalizeMissionInput(mission, index))
117
+ return input.missions.map((mission, index) => normalizeMissionInput(mission, index, read))
96
118
  }
97
119
 
98
120
  /**
@@ -147,6 +169,8 @@ export function newMission(id, input, now) {
147
169
  scopeGeneration: 1,
148
170
  createdAt: now,
149
171
  updatedAt: now,
172
+ ...(input.readCommit === undefined ? {} : { readCommit: input.readCommit }),
173
+ ...(input.readDirty === undefined ? {} : { readDirty: input.readDirty }),
150
174
  }))
151
175
  }
152
176
 
@@ -7,6 +7,7 @@
7
7
  import { TowerToolError } from '../errors.js'
8
8
  import { reviewedEvent } from '../events.js'
9
9
  import { MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
10
+ import { isReviewerRole } from '../roles.js'
10
11
 
11
12
  /** @typedef {import('../../types/index.js').Mission} Mission */
12
13
  /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
@@ -47,7 +48,7 @@ export function resolveReviewActor({ mission, caller, actorRole, agentId, breakG
47
48
  return { reviewerName: 'tower', reviewerRole: 'tower' }
48
49
  }
49
50
  if (caller) {
50
- if (caller.role !== 'reviewer') {
51
+ if (!isReviewerRole(caller.role)) {
51
52
  fail(`agent ${caller.name} is a ${caller.role}; only an assigned reviewer or the tower (break-glass) may review`)
52
53
  }
53
54
  if (caller.missionId !== mission.id) {
@@ -92,23 +93,6 @@ export function normalizeReviewVerdict(input) {
92
93
  return { status: input.status, merge: input.merge, findings, decision, checks }
93
94
  }
94
95
 
95
- /**
96
- * A reviewer may only review the tip it was spawned on.
97
- * @param {RosterEntry | undefined} caller
98
- * @param {string} tip
99
- * @returns {void}
100
- */
101
- export function assertReviewerSpawnTip(caller, tip) {
102
- if (caller && caller.role === 'reviewer' && !String(caller.spawnTip ?? '').trim()) {
103
- fail(`reviewer ${caller.name} has no recorded spawn tip; re-spawn the reviewer before reviewing`)
104
- }
105
- if (caller && caller.role === 'reviewer' && caller.spawnTip !== tip) {
106
- fail(
107
- `reviewer ${caller.name} modified the mission branch after it was spawned (${String(caller.spawnTip).slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
108
- )
109
- }
110
- }
111
-
112
96
  /**
113
97
  * The latest review of one branch. Higher rounds win; routes that share a round
114
98
  * are ordered by submission sequence, so "latest" is the most recently recorded
@@ -2,7 +2,7 @@
2
2
  // @ts-check
3
3
  import { TowerToolError } from './errors.js'
4
4
  import { scopesOverlap } from './scope.js'
5
- import { isTerminal } from './mission.js'
5
+ import { shapeFor } from './shapes/index.js'
6
6
 
7
7
  /** @typedef {import('../types/index.js').Mission} Mission */
8
8
 
@@ -93,7 +93,7 @@ function assertAcyclic(missions) {
93
93
  * @returns {void}
94
94
  */
95
95
  function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false } = {}) {
96
- const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
96
+ const openExisting = existingMissions.filter((mission) => !shapeFor(mission.kind).isTerminal(mission.status))
97
97
  const all = [...existingMissions, ...newMissions]
98
98
  /** @type {Map<string, Mission>} */
99
99
  const byId = new Map(all.map((mission) => [mission.id, mission]))
@@ -102,15 +102,15 @@ function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false }
102
102
  for (let j = i + 1; j < newMissions.length; j += 1) {
103
103
  const earlier = newMissions[i]
104
104
  const later = newMissions[j]
105
- if (earlier.kind === 'survey' || later.kind === 'survey') continue
105
+ if (!shapeFor(earlier.kind).reach.ownsPaths || !shapeFor(later.kind).reach.ownsPaths) continue
106
106
  if (!scopesOverlap(earlier.scope, later.scope, { caseInsensitive })) continue
107
107
  if (hasDependencyPath(byId, later.id, earlier.id)) continue
108
108
  addDependency(later, earlier.id)
109
109
  }
110
110
  const mission = newMissions[i]
111
- if (mission.kind === 'survey') continue
111
+ if (!shapeFor(mission.kind).reach.ownsPaths) continue
112
112
  for (const other of openExisting) {
113
- if (other.kind === 'survey') continue
113
+ if (!shapeFor(other.kind).reach.ownsPaths) continue
114
114
  if (!scopesOverlap(mission.scope, other.scope, { caseInsensitive })) continue
115
115
  if (hasDependencyPath(byId, mission.id, other.id)) continue
116
116
  addDependency(mission, other.id)
@@ -10,10 +10,13 @@ import { fullMission, findingSummary } from '../summary.js'
10
10
  * @param {object} input
11
11
  * @param {TowerState} input.state
12
12
  * @param {Mission} input.mission
13
+ * @param {Array<Record<string, any>> | Map<string, Record<string, any>>} [input.archived] archive index entries (or a by-id map) so a dependency that has already been archived keeps its evidence
13
14
  * @returns {object}
14
15
  */
15
- export function missionContextView({ state, mission }) {
16
+ export function missionContextView({ state, mission, archived = [] }) {
16
17
  const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
18
+ const archivedById =
19
+ archived instanceof Map ? archived : new Map(archived.map((entry) => [entry.id, entry]))
17
20
  const branch = mission.worktree ? mission.worktree.branch : null
18
21
  const reviews = branch
19
22
  ? (state.reviews ?? [])
@@ -46,8 +49,19 @@ export function missionContextView({ state, mission }) {
46
49
  })),
47
50
  findings: findings.map((finding) => findingSummary(finding)),
48
51
  deps: (mission.deps ?? []).map((id) => {
49
- const dep = byId.get(id)
50
- return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
52
+ const dep = byId.get(id) ?? archivedById.get(id)
53
+ if (!dep) return { id, title: null, status: 'unknown' }
54
+ return {
55
+ id,
56
+ title: dep.title,
57
+ kind: dep.kind,
58
+ status: dep.status,
59
+ readCommit: dep.readCommit ?? null,
60
+ // #148 D6: a survey's completion evidence travels with the dependency
61
+ // context so a dependent build worker sees what was found, not only that
62
+ // the survey finished. Formatting/clipping is the briefing's job.
63
+ result: dep.result ?? null,
64
+ }
51
65
  }),
52
66
  }
53
67
  }
@@ -125,16 +125,20 @@ export function patchLimit(maxBytes) {
125
125
  * @param {string[]} input.files
126
126
  * @param {string[]} input.commits
127
127
  * @param {string} input.fullPatch
128
+ * @param {string|null} [input.reviewedCommit]
128
129
  * @param {number} [input.maxBytes]
129
130
  * @returns {object}
130
131
  */
131
- export function diffView({ mission, baseTip, branchTip, mergeBase, files, commits, fullPatch, maxBytes }) {
132
+ export function diffView({ mission, baseTip, branchTip, mergeBase, files, commits, fullPatch, maxBytes, reviewedCommit = null }) {
132
133
  const limit = patchLimit(maxBytes)
133
134
  return {
134
135
  missionId: mission.id,
135
136
  branch: mission.worktree ? mission.worktree.branch : null,
136
137
  baseTip,
137
138
  branchTip,
139
+ // #149 W3: a reviewer sees (and is stamped with) one recorded commit; the
140
+ // field is null for every other caller reading the live branch.
141
+ reviewedCommit,
138
142
  mergeBase,
139
143
  files,
140
144
  commits,
@@ -0,0 +1,222 @@
1
+ // Role vocabulary (#149). Roles are decided from one place so generic layers do
2
+ // not compare against role strings: the shape audit refuses `role === 'worker'`
3
+ // outside this module. `domain/vocabulary.js` re-exports these constants for the
4
+ // public vocabulary surface; `domain/roster.js` imports them directly so the
5
+ // roster/vocabulary cycle does not exist.
6
+ //
7
+ // #149 W4: a role is not a shape kind. This module owns the role contract; a
8
+ // shape supplies facts (can it merge? does it own a worktree? which model slot?)
9
+ // and this contract decides what a role requires, where it runs, which preset
10
+ // row and model route it resolves, and whether it consumes a worker slot. Spawn,
11
+ // presets, roster and gates resolve role orthogonally to shape through these
12
+ // functions instead of combining `isReviewerRole` with `shape.*` at each site.
13
+ export const ROLE = Object.freeze({
14
+ tower: 'tower',
15
+ worker: 'worker',
16
+ reviewer: 'reviewer',
17
+ unknown: 'unknown',
18
+ })
19
+
20
+ /** Roles a spawned child may have. */
21
+ export const ROLES = Object.freeze([ROLE.worker, ROLE.reviewer])
22
+
23
+ /** Every actor role a tool may see. */
24
+ export const ACTOR_ROLES = Object.freeze([ROLE.tower, ROLE.worker, ROLE.reviewer, ROLE.unknown])
25
+
26
+ export const DEFAULT_ROLE = ROLE.worker
27
+
28
+ /** @param {unknown} role */
29
+ export function isTowerRole(role) {
30
+ return role === ROLE.tower
31
+ }
32
+
33
+ /** @param {unknown} role */
34
+ export function isWorkerRole(role) {
35
+ return role === ROLE.worker
36
+ }
37
+
38
+ /** @param {unknown} role */
39
+ export function isReviewerRole(role) {
40
+ return role === ROLE.reviewer
41
+ }
42
+
43
+ /** @param {unknown} role */
44
+ export function isUnknownRole(role) {
45
+ return role === ROLE.unknown
46
+ }
47
+
48
+ /**
49
+ * The one role contract. Every field is a role fact, never a shape fact:
50
+ *
51
+ * preset: 'shape' uses the shape's resolved default preset, otherwise the
52
+ * reviewer preset row; `model` names the config.spawn.<slot>Model
53
+ * route (a shape supplies its own slot through shape.spawn.model);
54
+ * placement: 'mission' provisions the mission placement (branch worktree,
55
+ * shared checkout or detached sandbox), 'review-checkout' creates
56
+ * the disposable detached review checkout;
57
+ * `tracksMission`/`countsTowardBudget` answer ownership and resource questions;
58
+ * `requires` lists what the mission must satisfy (`statuses: 'shape'` defers the
59
+ * status set to the shape's spawn contract).
60
+ * @type {Readonly<Record<string, {
61
+ * preset: 'shape' | 'reviewer',
62
+ * model: 'shape' | 'reviewer',
63
+ * placement: 'mission' | 'review-checkout',
64
+ * briefing: 'worker' | 'reviewer',
65
+ * tracksMission: boolean,
66
+ * countsTowardBudget: 'shape' | boolean,
67
+ * spawnDiscovery: 'worktree' | 'never',
68
+ * requires: { review: boolean, canMerge: boolean, worktree: boolean, statuses: 'shape' | readonly string[] },
69
+ * }>>}
70
+ */
71
+ export const ROLE_CONTRACT = Object.freeze({
72
+ [ROLE.worker]: Object.freeze({
73
+ preset: 'shape',
74
+ model: 'shape',
75
+ placement: 'mission',
76
+ briefing: 'worker',
77
+ tracksMission: true,
78
+ countsTowardBudget: 'shape',
79
+ spawnDiscovery: 'worktree',
80
+ requires: Object.freeze({ review: false, canMerge: false, worktree: false, statuses: 'shape' }),
81
+ }),
82
+ [ROLE.reviewer]: Object.freeze({
83
+ preset: 'reviewer',
84
+ model: 'reviewer',
85
+ placement: 'review-checkout',
86
+ briefing: 'reviewer',
87
+ tracksMission: false,
88
+ countsTowardBudget: false,
89
+ spawnDiscovery: 'never',
90
+ requires: Object.freeze({ review: true, canMerge: true, worktree: true, statuses: Object.freeze(['completed']) }),
91
+ }),
92
+ })
93
+
94
+ /** @param {unknown} role */
95
+ export function roleContract(role) {
96
+ return typeof role === 'string' && Object.prototype.hasOwnProperty.call(ROLE_CONTRACT, role)
97
+ ? ROLE_CONTRACT[role]
98
+ : null
99
+ }
100
+
101
+ /** @param {unknown} role */
102
+ export function roleTracksMission(role) {
103
+ return roleContract(role)?.tracksMission === true
104
+ }
105
+
106
+ /** @param {unknown} role */
107
+ export function roleBriefingKind(role) {
108
+ return roleContract(role)?.briefing ?? null
109
+ }
110
+
111
+ /** @param {unknown} role */
112
+ export function rolePlacement(role) {
113
+ return roleContract(role)?.placement ?? null
114
+ }
115
+
116
+ /** @param {unknown} role */
117
+ export function rolePresetSlot(role) {
118
+ return roleContract(role)?.preset ?? null
119
+ }
120
+
121
+ /**
122
+ * Which config key supplies a role's preset, and the schema fallback. A role
123
+ * whose `preset` is `shape` takes the shape-resolved preset when one exists and
124
+ * falls back to `spawn.workerPreset`; the reviewer uses its own row.
125
+ * @param {unknown} role
126
+ * @returns {{ slot: 'workerPreset' | 'reviewerPreset', fallback: string } | null}
127
+ */
128
+ export function rolePresetConfig(role) {
129
+ const spec = roleContract(role)
130
+ if (!spec) return null
131
+ return spec.preset === 'shape'
132
+ ? { slot: 'workerPreset', fallback: 'tower-worker' }
133
+ : { slot: 'reviewerPreset', fallback: 'tower-reviewer' }
134
+ }
135
+
136
+ /**
137
+ * Does this role consume a worker slot for a mission of this shape?
138
+ * @param {unknown} role
139
+ * @param {{ spawn?: { countsTowardWorkerBudget?: boolean } } | null | undefined} shape
140
+ */
141
+ export function roleCountsTowardBudget(role, shape) {
142
+ const spec = roleContract(role)
143
+ if (!spec) return false
144
+ if (spec.countsTowardBudget === 'shape') return shape?.spawn?.countsTowardWorkerBudget !== false
145
+ return spec.countsTowardBudget === true
146
+ }
147
+
148
+ /**
149
+ * Should this spawn run test discovery before activation? A worker on a shape
150
+ * with a worktree can; a reviewer never runs repository code.
151
+ * @param {unknown} role
152
+ * @param {{ fields?: { worktree?: boolean } } | null | undefined} shape
153
+ */
154
+ export function roleSpawnDiscovery(role, shape) {
155
+ const spec = roleContract(role)
156
+ if (!spec) return false
157
+ return spec.spawnDiscovery === 'worktree' && shape?.fields?.worktree === true
158
+ }
159
+
160
+ /**
161
+ * Resolve the role-controlled spawn facts: preset, model route, placement and
162
+ * briefing. The shape supplies `canMerge`/`worktree`/`spawn.model`; the role
163
+ * decides how they are used.
164
+ * @param {string} role
165
+ * @param {{ config: any, shape: any, shapePreset?: string | null }} input
166
+ */
167
+ export function resolveRoleSpawn(role, { config, shape, shapePreset = null }) {
168
+ const spec = roleContract(role)
169
+ const spawnConfig = config?.spawn ?? {}
170
+ if (!spec) return null
171
+ const preset = spec.preset === 'shape' ? (shapePreset ?? spawnConfig.workerPreset) : spawnConfig.reviewerPreset
172
+ const modelSlot = spec.model === 'shape' ? (shape?.spawn?.model ?? 'worker') : 'reviewer'
173
+ const model = spawnConfig[`${modelSlot}Model`]
174
+ return { preset, model, placement: spec.placement, briefing: spec.briefing }
175
+ }
176
+
177
+ /**
178
+ * Role prerequisites for one mission. The caller checks service-level facts
179
+ * (`reviewAvailable`); shape facts arrive on `shape`/`mission`. Returns
180
+ * blockers in the order a human would fix them; the caller throws the first.
181
+ * @param {string} role
182
+ * @param {{ shape: any, mission: any, reviewAvailable?: boolean }} input
183
+ * @returns {Array<{ code: string, message: string }>}
184
+ */
185
+ export function roleMissionBlockers(role, { shape, mission, reviewAvailable = true }) {
186
+ const spec = roleContract(role)
187
+ if (!spec) return [{ code: 'invalid-argument', message: `role must be one of ${ROLES.join('|')}` }]
188
+ const blockers = []
189
+ if (spec.requires.review && !reviewAvailable) {
190
+ blockers.push({
191
+ code: 'review-disabled',
192
+ message: 'review.mode is off, so review is unavailable and a reviewer cannot be spawned',
193
+ })
194
+ }
195
+ if (spec.requires.canMerge && !shape?.canMerge) {
196
+ blockers.push({
197
+ code: 'invalid-transition',
198
+ message: `mission ${mission?.id} is a ${shape?.kind}: it has no branch to review and is never reviewed`,
199
+ })
200
+ }
201
+ if (spec.requires.worktree && !mission?.worktree) {
202
+ blockers.push({
203
+ code: 'reviewer-prerequisite',
204
+ message: 'reviewer needs an existing mission worktree; spawn a worker first',
205
+ })
206
+ }
207
+ const statuses = spec.requires.statuses === 'shape' ? (shape?.spawn?.missionStatuses ?? []) : spec.requires.statuses
208
+ if (!statuses.includes(mission?.status)) {
209
+ blockers.push(
210
+ spec.requires.statuses === 'shape'
211
+ ? {
212
+ code: 'invalid-transition',
213
+ message: `mission ${mission?.id} is ${mission?.status}; a ${role} can only be spawned for a planned or active mission`,
214
+ }
215
+ : {
216
+ code: 'reviewer-prerequisite',
217
+ message: `reviewer requires a completed mission; ${mission?.id} is ${mission?.status}`,
218
+ },
219
+ )
220
+ }
221
+ return blockers
222
+ }
package/domain/roster.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Pure roster predicates.
2
2
  // @ts-check
3
+ import { isWorkerRole, roleCountsTowardBudget } from './roles.js'
4
+ import { shapeFor } from './shapes/index.js'
3
5
 
4
6
  /** @typedef {import('../types/index.js').RosterEntry} RosterEntry */
5
7
  /** @typedef {import('../types/index.js').TowerState} TowerState */
@@ -16,7 +18,7 @@ export const ROSTER_INACTIVE = new Set(ROSTER_INACTIVE_STATES)
16
18
  */
17
19
  export function liveWorkerEntry(state, missionId) {
18
20
  return (state.roster ?? []).find(
19
- (entry) => entry.missionId === missionId && entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state),
21
+ (entry) => entry.missionId === missionId && isWorkerRole(entry.role) && !ROSTER_INACTIVE.has(entry.state),
20
22
  )
21
23
  }
22
24
 
@@ -26,7 +28,7 @@ export function liveWorkerEntry(state, missionId) {
26
28
  * @returns {RosterEntry[]}
27
29
  */
28
30
  export function workerEntries(state, missionId) {
29
- return (state.roster ?? []).filter((entry) => entry.missionId === missionId && entry.role === 'worker')
31
+ return (state.roster ?? []).filter((entry) => entry.missionId === missionId && isWorkerRole(entry.role))
30
32
  }
31
33
 
32
34
  /**
@@ -34,7 +36,11 @@ export function workerEntries(state, missionId) {
34
36
  * @returns {number}
35
37
  */
36
38
  export function countLiveWorkers(state) {
39
+ const kinds = new Map((state.missions ?? []).map((mission) => [mission.id, mission.kind]))
37
40
  return (state.roster ?? []).filter(
38
- (entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state) && entry.state !== 'reserved',
41
+ (entry) =>
42
+ roleCountsTowardBudget(entry.role, shapeFor(kinds.get(entry.missionId ?? ''))) &&
43
+ !ROSTER_INACTIVE.has(entry.state) &&
44
+ entry.state !== 'reserved',
39
45
  ).length
40
46
  }
package/domain/scope.js CHANGED
@@ -85,6 +85,57 @@ export function comparisonMode(caseInsensitive) {
85
85
  return caseInsensitive === true ? 'case-insensitive' : 'case-sensitive'
86
86
  }
87
87
 
88
+ /** @typedef {{ code: string, detail: string }} ScopeBlocker */
89
+ /** @typedef {{ ownsPaths: boolean, fence: 'scope' | 'read-only' }} Reach */
90
+
91
+ /**
92
+ * The one path-permission fence (#149 W4). A shape's `reach` is the single
93
+ * definition of whether a mission may change paths at all:
94
+ *
95
+ * - `ownsPaths: true` + `fence: 'scope'`: every changed file must match the
96
+ * mission scope (case folding is a checkout fact, not a scope rewrite);
97
+ * - anything else (`fence: 'read-only'`): the mission permits no file change,
98
+ * so any changed path is a fence violation.
99
+ *
100
+ * The merge gate, the planning/lifecycle rules and the spawn briefing derive
101
+ * from the same declaration; none of them re-reads `mission.scope` to decide
102
+ * permission on its own.
103
+ *
104
+ * @param {{ mission: { id?: string, scope?: string[] | null }, reach: Reach | null | undefined, changedFiles?: string[], caseInsensitive?: boolean }} input
105
+ * @returns {ScopeBlocker | null}
106
+ */
107
+ export function scopeFenceBlocker({ mission, reach, changedFiles = [], caseInsensitive = false }) {
108
+ const files = Array.isArray(changedFiles) ? changedFiles : []
109
+ const ownsPaths = reach?.ownsPaths === true && reach?.fence !== 'read-only'
110
+ if (!ownsPaths) {
111
+ if (files.length === 0) return null
112
+ return {
113
+ code: 'out-of-scope',
114
+ detail: `mission ${mission?.id ?? '(unknown)'} is read-only and changed ${files.length} tracked path(s): ${files.slice(0, 8).join(', ')}`,
115
+ }
116
+ }
117
+ const scope = mission?.scope ?? []
118
+ const outOfScope = files.filter((file) => !fileInScope(scope, file, { caseInsensitive }))
119
+ if (outOfScope.length === 0) return null
120
+ const parts = [`changed files outside scope (${scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`]
121
+ // On a case-sensitive checkout a case-only mismatch is a human error worth
122
+ // naming, but it stays a rejection: folding here would make src/Api and
123
+ // src/api the same directory on a filesystem where they are not (#90).
124
+ if (!caseInsensitive) {
125
+ const hints = outOfScope
126
+ .map((file) => {
127
+ const pattern = caseInsensitiveScopeMatch(scope, file)
128
+ return pattern
129
+ ? `${file} matches "${pattern}" only when case is folded; this checkout is case-sensitive`
130
+ : null
131
+ })
132
+ .filter(Boolean)
133
+ .slice(0, 3)
134
+ if (hints.length > 0) parts.push(`case mismatch: ${hints.join('; ')}`)
135
+ }
136
+ return { code: 'out-of-scope', detail: parts.join('. ') }
137
+ }
138
+
88
139
  // Mission titles are printable Unicode, not printable ASCII (#88). The hazards
89
140
  // the old ASCII range stood in for are made explicit: control/format/surrogate
90
141
  // code points, bidi overrides, zero-width characters, and unbounded length.