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.
- package/CHANGELOG.md +114 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +1 -1
- package/GLOSSARY.md +6 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +18 -16
- package/README.md +18 -11
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +104 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +31 -20
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +34 -6
- package/client.js +321 -0
- package/cordis.patch.yml +55 -3
- package/docs/adr/0005-component-surface.md +58 -13
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +18 -10
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +58 -7
- package/docs/guide/quickstart.md +20 -10
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/runbook.md +1 -1
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +20 -11
- package/docs/reference/component-surface.md +20 -8
- package/docs/reference/config.generated.md +8 -5
- package/docs/reference/config.md +30 -5
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +53 -4
- package/docs/reference/testing.md +5 -3
- package/docs/reference/tools.generated.md +7 -7
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +25 -6
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +13 -9
- package/docs/zh/reference/config.md +22 -5
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +5 -4
- package/docs/zh/reference/tools.md +3 -3
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/components.js +12 -4
- package/domain/config/schema.js +87 -22
- package/domain/contracts/outputs.js +2 -1
- package/domain/contracts/tool-inputs/missions.js +10 -2
- package/domain/errors.js +6 -0
- package/domain/evidence.js +283 -0
- package/domain/gate.js +17 -34
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/integration/review.js +203 -9
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/config.js +40 -3
- package/host/doctor.js +20 -8
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +17 -11
- package/host/tool-registry.js +23 -13
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +19 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-components.mjs +120 -6
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +5 -0
- package/scripts/component-surface.json +83 -29
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +22 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +75 -4
- package/scripts/types-coverage.mjs +7 -0
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/review.js +1 -1
- package/tools/spawn.js +48 -50
- package/tools/status.js +8 -1
- package/tools/test.js +2 -1
- package/types/index.d.ts +76 -5
package/domain/missions/plan.js
CHANGED
|
@@ -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 ===
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/domain/planning.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @ts-check
|
|
3
3
|
import { TowerToolError } from './errors.js'
|
|
4
4
|
import { scopesOverlap } from './scope.js'
|
|
5
|
-
import {
|
|
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
|
|
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
|
|
111
|
+
if (!shapeFor(mission.kind).reach.ownsPaths) continue
|
|
112
112
|
for (const other of openExisting) {
|
|
113
|
-
if (other.kind
|
|
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
|
-
|
|
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
|
}
|
package/domain/read/views.js
CHANGED
|
@@ -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,
|
package/domain/roles.js
ADDED
|
@@ -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
|
|
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
|
|
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) =>
|
|
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.
|