dsh-tower 2.0.3 → 2.1.0-beta.1
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 +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +44 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Pure review submission policy: actor resolution, verdict validation, round
|
|
2
|
+
// accounting, and the resulting mission/record projections.
|
|
3
|
+
//
|
|
4
|
+
// app/review.js owns loading state, the worktree/tip Git reads, the lock, and
|
|
5
|
+
// persistence. Everything here is deterministic and repository-free.
|
|
6
|
+
// @ts-check
|
|
7
|
+
import { TowerToolError } from '../errors.js'
|
|
8
|
+
import { MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
11
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
12
|
+
/** @typedef {import('../../types/index.js').Review} Review */
|
|
13
|
+
|
|
14
|
+
export const MAX_REVIEW_ROUNDS = 5
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} message
|
|
18
|
+
* @param {string} [code]
|
|
19
|
+
* @returns {never}
|
|
20
|
+
*/
|
|
21
|
+
function fail(message, code = 'invalid-argument') {
|
|
22
|
+
throw new TowerToolError(code, message)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A mission with a worktree is the only reviewable subject; the caller checks
|
|
27
|
+
* that its status is `completed`.
|
|
28
|
+
* @param {Mission} mission
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
export function assertReviewableMission(mission) {
|
|
32
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to review`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve who is submitting the review. An unregistered caller fails closed;
|
|
37
|
+
* only an assigned reviewer or the tower (break-glass) may review.
|
|
38
|
+
* @param {{ mission: Mission, caller: RosterEntry | undefined, actorRole: string | undefined, agentId: string | undefined, breakGlassReason: unknown }} params
|
|
39
|
+
* @returns {{ reviewerName: string, reviewerRole: string }}
|
|
40
|
+
*/
|
|
41
|
+
export function resolveReviewActor({ mission, caller, actorRole, agentId, breakGlassReason }) {
|
|
42
|
+
if (actorRole === 'tower') {
|
|
43
|
+
if (typeof breakGlassReason !== 'string' || breakGlassReason.trim().length === 0) {
|
|
44
|
+
fail('a tower-submitted review is break-glass and requires a non-empty breakGlassReason', 'not-authorized')
|
|
45
|
+
}
|
|
46
|
+
return { reviewerName: 'tower', reviewerRole: 'tower' }
|
|
47
|
+
}
|
|
48
|
+
if (caller) {
|
|
49
|
+
if (caller.role !== 'reviewer') {
|
|
50
|
+
fail(`agent ${caller.name} is a ${caller.role}; only an assigned reviewer or the tower (break-glass) may review`)
|
|
51
|
+
}
|
|
52
|
+
if (caller.missionId !== mission.id) {
|
|
53
|
+
fail(`reviewer ${caller.name} was not assigned to mission ${mission.id}`)
|
|
54
|
+
}
|
|
55
|
+
if (mission.owner && caller.name === mission.owner) fail('the reviewer must differ from the worker', 'not-authorized')
|
|
56
|
+
return { reviewerName: caller.name, reviewerRole: 'reviewer' }
|
|
57
|
+
}
|
|
58
|
+
// Fail closed: an unknown caller is not the tower, and a missing roster row
|
|
59
|
+
// is not evidence of tower identity.
|
|
60
|
+
fail(`agent ${agentId ?? '(unknown)'} is not a registered reviewer or the tower`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate the verdict payload fields that are independent of Git.
|
|
65
|
+
* @param {any} input
|
|
66
|
+
* @returns {{ status: string, merge: string, findings: string, decision: string, checks: string[] }}
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeReviewVerdict(input) {
|
|
69
|
+
if (!REVIEW_STATUS_RE.test(String(input.status ?? ''))) {
|
|
70
|
+
fail('status must be "clean", "p1-Nitems", or "p2-Nitems"')
|
|
71
|
+
}
|
|
72
|
+
if (!MERGE_VERDICTS.includes(input.merge)) {
|
|
73
|
+
fail(`merge verdict must be one of ${MERGE_VERDICTS.join('|')}`)
|
|
74
|
+
}
|
|
75
|
+
const findings = String(input.findings ?? '').trim()
|
|
76
|
+
if (!findings) fail('findings text is required; write "none" for a clean verdict')
|
|
77
|
+
if (input.status === 'clean' && findings.toLowerCase() !== 'none') {
|
|
78
|
+
fail('a clean verdict requires findings text "none"')
|
|
79
|
+
}
|
|
80
|
+
if (input.status !== 'clean' && findings.toLowerCase() === 'none') {
|
|
81
|
+
fail('a non-clean verdict must describe its findings')
|
|
82
|
+
}
|
|
83
|
+
const decision = String(input.decision ?? '').trim()
|
|
84
|
+
if (!decision) fail('decision (the reasoning behind the verdict) is required')
|
|
85
|
+
/** @type {string[]} */
|
|
86
|
+
let checks = []
|
|
87
|
+
if (input.checks !== undefined) {
|
|
88
|
+
if (!Array.isArray(input.checks)) fail('checks must be an array of strings')
|
|
89
|
+
checks = input.checks.map((entry) => String(entry))
|
|
90
|
+
}
|
|
91
|
+
return { status: input.status, merge: input.merge, findings, decision, checks }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A reviewer may only review the tip it was spawned on.
|
|
96
|
+
* @param {RosterEntry | undefined} caller
|
|
97
|
+
* @param {string} tip
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
export function assertReviewerSpawnTip(caller, tip) {
|
|
101
|
+
if (caller && caller.role === 'reviewer' && !String(caller.spawnTip ?? '').trim()) {
|
|
102
|
+
fail(`reviewer ${caller.name} has no recorded spawn tip; re-spawn the reviewer before reviewing`)
|
|
103
|
+
}
|
|
104
|
+
if (caller && caller.role === 'reviewer' && caller.spawnTip !== tip) {
|
|
105
|
+
fail(
|
|
106
|
+
`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`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Compute the review round and enforce the per-reviewer cap.
|
|
113
|
+
* @param {{ reviews: Review[] | undefined, branch: string, reviewerName: string, maxRoundsPerReviewer: unknown }} params
|
|
114
|
+
* @returns {{ reviewerRounds: number, round: number }}
|
|
115
|
+
*/
|
|
116
|
+
export function reviewRound({ reviews, branch, reviewerName, maxRoundsPerReviewer }) {
|
|
117
|
+
const prior = (reviews ?? []).filter((review) => review.branch === branch)
|
|
118
|
+
const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
|
|
119
|
+
const maxReviewRounds = Number(maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
|
|
120
|
+
if (reviewerRounds >= maxReviewRounds) {
|
|
121
|
+
fail(
|
|
122
|
+
`reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
|
|
126
|
+
return { reviewerRounds, round }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A non-clean verdict returns the mission to active and records the note; a
|
|
131
|
+
* clean verdict leaves the status untouched.
|
|
132
|
+
* @param {{ status: string, merge: string, round: number, reviewerName: string }} params
|
|
133
|
+
* @returns {{ returnToActive: boolean, note: string }}
|
|
134
|
+
*/
|
|
135
|
+
export function reviewOutcome({ status, merge, round, reviewerName }) {
|
|
136
|
+
if (status !== 'clean') {
|
|
137
|
+
return {
|
|
138
|
+
returnToActive: true,
|
|
139
|
+
note: `review round ${round} by ${reviewerName}: ${status} (${merge}) — returned to active`,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { returnToActive: false, note: `review round ${round} by ${reviewerName}: clean (${merge})` }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Project the persisted review record.
|
|
147
|
+
* @param {{ seq: number, missionId: string, branch: string, tip: string, verdict: { status: string, merge: string, findings: string, checks: string[], decision: string }, reviewerName: string, reviewerRole: string, round: number, now: number, breakGlassReason?: string }} params
|
|
148
|
+
* @returns {Review}
|
|
149
|
+
*/
|
|
150
|
+
export function reviewRecord({
|
|
151
|
+
seq,
|
|
152
|
+
missionId,
|
|
153
|
+
branch,
|
|
154
|
+
tip,
|
|
155
|
+
verdict,
|
|
156
|
+
reviewerName,
|
|
157
|
+
reviewerRole,
|
|
158
|
+
round,
|
|
159
|
+
now,
|
|
160
|
+
breakGlassReason,
|
|
161
|
+
}) {
|
|
162
|
+
return {
|
|
163
|
+
id: `R${seq}`,
|
|
164
|
+
seq,
|
|
165
|
+
missionId,
|
|
166
|
+
branch,
|
|
167
|
+
reviewedCommit: tip,
|
|
168
|
+
status: verdict.status,
|
|
169
|
+
mergeVerdict: /** @type {import('../../types/index.js').MergeVerdict} */ (verdict.merge),
|
|
170
|
+
findings: verdict.findings,
|
|
171
|
+
checks: verdict.checks,
|
|
172
|
+
decision: verdict.decision,
|
|
173
|
+
reviewer: reviewerName,
|
|
174
|
+
reviewerRole,
|
|
175
|
+
round,
|
|
176
|
+
at: now,
|
|
177
|
+
...(reviewerRole === 'tower' ? { breakGlassReason } : {}),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Pure mission planning helpers.
|
|
2
|
+
// @ts-check
|
|
3
|
+
import { TowerToolError } from './errors.js'
|
|
4
|
+
import { scopesOverlap } from './scope.js'
|
|
5
|
+
import { isTerminal } from './mission.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../types/index.js').Mission} Mission */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} message
|
|
11
|
+
* @returns {never}
|
|
12
|
+
*/
|
|
13
|
+
function fail(message) {
|
|
14
|
+
throw new TowerToolError('invalid-argument', message)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Mission[]} missions
|
|
19
|
+
* @returns {number}
|
|
20
|
+
*/
|
|
21
|
+
function nextMissionNumber(missions) {
|
|
22
|
+
let max = 0
|
|
23
|
+
for (const mission of missions) {
|
|
24
|
+
const match = /^M(\d+)$/.exec(mission.id)
|
|
25
|
+
if (match) max = Math.max(max, Number(match[1]))
|
|
26
|
+
}
|
|
27
|
+
return max + 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Mission} mission
|
|
32
|
+
* @param {string} dep
|
|
33
|
+
* @returns {void}
|
|
34
|
+
*/
|
|
35
|
+
function addDependency(mission, dep) {
|
|
36
|
+
if (mission.id === dep) return
|
|
37
|
+
if (!mission.deps.includes(dep)) mission.deps.push(dep)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {Map<string, Mission>} missionsById
|
|
42
|
+
* @param {string} fromId
|
|
43
|
+
* @param {string} toId
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function hasDependencyPath(missionsById, fromId, toId) {
|
|
47
|
+
/** @type {Set<string>} */
|
|
48
|
+
const seen = new Set()
|
|
49
|
+
/** @type {string[]} */
|
|
50
|
+
const stack = [fromId]
|
|
51
|
+
while (stack.length > 0) {
|
|
52
|
+
const current = /** @type {string} */ (stack.pop())
|
|
53
|
+
if (current === toId) return true
|
|
54
|
+
if (seen.has(current)) continue
|
|
55
|
+
seen.add(current)
|
|
56
|
+
const mission = missionsById.get(current)
|
|
57
|
+
if (!mission) continue
|
|
58
|
+
for (const dep of mission.deps) stack.push(dep)
|
|
59
|
+
}
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {Mission[]} missions
|
|
65
|
+
* @returns {void}
|
|
66
|
+
*/
|
|
67
|
+
function assertAcyclic(missions) {
|
|
68
|
+
/** @type {Map<string, Mission>} */
|
|
69
|
+
const byId = new Map(missions.map((mission) => [mission.id, mission]))
|
|
70
|
+
/** @type {Map<string, 'visiting' | 'done'>} */
|
|
71
|
+
const state = new Map()
|
|
72
|
+
/** @type {(id: string, trail: string[]) => void} */
|
|
73
|
+
const visit = (id, trail) => {
|
|
74
|
+
const mark = state.get(id)
|
|
75
|
+
if (mark === 'done') return
|
|
76
|
+
if (mark === 'visiting') fail(`mission dependencies have a cycle: ${[...trail, id].join(' -> ')}`)
|
|
77
|
+
state.set(id, 'visiting')
|
|
78
|
+
const mission = byId.get(id)
|
|
79
|
+
if (mission) for (const dep of mission.deps) visit(dep, [...trail, id])
|
|
80
|
+
state.set(id, 'done')
|
|
81
|
+
}
|
|
82
|
+
for (const mission of missions) visit(mission.id, [])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Kimi-style heuristic: overlapping build scopes are serialized by an
|
|
87
|
+
* auto-added dependency. It is conservative, not a proof of disjointness.
|
|
88
|
+
* @param {Mission[]} newMissions
|
|
89
|
+
* @param {Mission[]} existingMissions
|
|
90
|
+
* @returns {void}
|
|
91
|
+
*/
|
|
92
|
+
function addAutoDeps(newMissions, existingMissions) {
|
|
93
|
+
const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
|
|
94
|
+
const all = [...existingMissions, ...newMissions]
|
|
95
|
+
/** @type {Map<string, Mission>} */
|
|
96
|
+
const byId = new Map(all.map((mission) => [mission.id, mission]))
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < newMissions.length; i += 1) {
|
|
99
|
+
for (let j = i + 1; j < newMissions.length; j += 1) {
|
|
100
|
+
const earlier = newMissions[i]
|
|
101
|
+
const later = newMissions[j]
|
|
102
|
+
if (earlier.kind === 'survey' || later.kind === 'survey') continue
|
|
103
|
+
if (!scopesOverlap(earlier.scope, later.scope)) continue
|
|
104
|
+
if (hasDependencyPath(byId, later.id, earlier.id)) continue
|
|
105
|
+
addDependency(later, earlier.id)
|
|
106
|
+
}
|
|
107
|
+
const mission = newMissions[i]
|
|
108
|
+
if (mission.kind === 'survey') continue
|
|
109
|
+
for (const other of openExisting) {
|
|
110
|
+
if (other.kind === 'survey') continue
|
|
111
|
+
if (!scopesOverlap(mission.scope, other.scope)) continue
|
|
112
|
+
if (hasDependencyPath(byId, mission.id, other.id)) continue
|
|
113
|
+
addDependency(mission, other.id)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { nextMissionNumber, addDependency, hasDependencyPath, assertAcyclic, addAutoDeps }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Pure dashboard mission enrichment: select the latest review, project the
|
|
2
|
+
// review view, and merge it into the mission summary. The worktree existence
|
|
3
|
+
// check and `rev-parse HEAD` that detect a moved tip stay in
|
|
4
|
+
// app/enrichedMissions.js.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { missionSummary } from '../summary.js'
|
|
7
|
+
import { latestReview, reviewsForBranch } from '../integration/review.js'
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
10
|
+
/** @typedef {import('../../types/index.js').Review} Review */
|
|
11
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Filter the missions the caller asked for, preserving state order.
|
|
15
|
+
* @param {Mission[]} missions
|
|
16
|
+
* @param {string[] | null | undefined} onlyIds
|
|
17
|
+
* @returns {Mission[]}
|
|
18
|
+
*/
|
|
19
|
+
export function missionsToEnrich(missions, onlyIds) {
|
|
20
|
+
const wanted = onlyIds ? new Set(onlyIds) : null
|
|
21
|
+
return wanted ? missions.filter((mission) => wanted.has(mission.id)) : missions
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {TowerState} state
|
|
26
|
+
* @param {Mission} mission
|
|
27
|
+
* @returns {Review | null}
|
|
28
|
+
*/
|
|
29
|
+
export function latestReviewForMission(state, mission) {
|
|
30
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
31
|
+
return latestReview(reviewsForBranch(state.reviews, branch))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} input
|
|
36
|
+
* @param {Mission} input.mission
|
|
37
|
+
* @param {Review | null} input.latest
|
|
38
|
+
* @param {boolean} input.tipMoved
|
|
39
|
+
* @returns {object}
|
|
40
|
+
*/
|
|
41
|
+
export function enrichedMissionView({ mission, latest, tipMoved }) {
|
|
42
|
+
return {
|
|
43
|
+
...missionSummary(mission),
|
|
44
|
+
review: latest
|
|
45
|
+
? {
|
|
46
|
+
id: latest.id,
|
|
47
|
+
round: latest.round,
|
|
48
|
+
status: latest.status,
|
|
49
|
+
mergeVerdict: latest.mergeVerdict,
|
|
50
|
+
reviewer: latest.reviewer,
|
|
51
|
+
reviewerRole: latest.reviewerRole,
|
|
52
|
+
reviewedCommit: latest.reviewedCommit,
|
|
53
|
+
at: latest.at,
|
|
54
|
+
}
|
|
55
|
+
: null,
|
|
56
|
+
tipMoved,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Pure mission-context projection: reviews, findings, and dependency statuses
|
|
2
|
+
// for one mission. Loading state and activity stays in app/missionContext.js.
|
|
3
|
+
// @ts-check
|
|
4
|
+
import { fullMission, findingSummary } from '../summary.js'
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
7
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} input
|
|
11
|
+
* @param {TowerState} input.state
|
|
12
|
+
* @param {Mission} input.mission
|
|
13
|
+
* @returns {object}
|
|
14
|
+
*/
|
|
15
|
+
export function missionContextView({ state, mission }) {
|
|
16
|
+
const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
|
|
17
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
18
|
+
const reviews = branch
|
|
19
|
+
? (state.reviews ?? [])
|
|
20
|
+
.filter((review) => review.branch === branch)
|
|
21
|
+
.slice()
|
|
22
|
+
.sort((left, right) => right.round - left.round)
|
|
23
|
+
.slice(0, 3)
|
|
24
|
+
: []
|
|
25
|
+
const findings = (state.findings ?? [])
|
|
26
|
+
.filter((finding) => finding.missionId === mission.id)
|
|
27
|
+
.slice()
|
|
28
|
+
.sort((left, right) => (right.seq ?? 0) - (left.seq ?? 0))
|
|
29
|
+
.slice(0, 10)
|
|
30
|
+
return {
|
|
31
|
+
base: state.base,
|
|
32
|
+
mission: fullMission(mission),
|
|
33
|
+
snapshotCommit: state.snapshotCommit ?? null,
|
|
34
|
+
completionReport: mission.completionReport ?? null,
|
|
35
|
+
reviews: reviews.map((review) => ({
|
|
36
|
+
id: review.id,
|
|
37
|
+
round: review.round,
|
|
38
|
+
status: review.status,
|
|
39
|
+
mergeVerdict: review.mergeVerdict,
|
|
40
|
+
reviewer: review.reviewer,
|
|
41
|
+
findings: review.findings,
|
|
42
|
+
checks: review.checks ?? [],
|
|
43
|
+
decision: review.decision,
|
|
44
|
+
reviewedCommit: review.reviewedCommit,
|
|
45
|
+
at: review.at,
|
|
46
|
+
})),
|
|
47
|
+
findings: findings.map((finding) => findingSummary(finding)),
|
|
48
|
+
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' }
|
|
51
|
+
}),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Pure read-model projections for the status/detail/diff use cases. Loading
|
|
2
|
+
// state, reading the activity tail, and running Git stay in app/read.js.
|
|
3
|
+
// @ts-check
|
|
4
|
+
import { FINDING_STATUSES, MISSION_STATUSES } from '../mission.js'
|
|
5
|
+
import { fullMission, findingSummary, taskCounts } from '../summary.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
8
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} input
|
|
12
|
+
* @param {TowerState} input.state
|
|
13
|
+
* @param {Mission} input.mission
|
|
14
|
+
* @param {Array<Record<string, unknown>>} input.activity
|
|
15
|
+
* @returns {object}
|
|
16
|
+
*/
|
|
17
|
+
export function missionDetailView({ state, mission, activity }) {
|
|
18
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
19
|
+
const reviews = (state.reviews ?? [])
|
|
20
|
+
.filter((review) => review.missionId === mission.id || (branch && review.branch === branch))
|
|
21
|
+
.slice()
|
|
22
|
+
.sort((left, right) => right.round - left.round)
|
|
23
|
+
const findings = (state.findings ?? []).filter((finding) => finding.missionId === mission.id)
|
|
24
|
+
const missionActivity = activity.filter((entry) => entry.missionId === mission.id)
|
|
25
|
+
return {
|
|
26
|
+
mission: fullMission(mission),
|
|
27
|
+
reviews: reviews.slice(0, 10),
|
|
28
|
+
findings: findings.slice(-10).map((finding) => findingSummary(finding)),
|
|
29
|
+
activity: missionActivity.slice(-20),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} input
|
|
35
|
+
* @param {TowerState} input.state
|
|
36
|
+
* @param {string} input.statePath
|
|
37
|
+
* @param {Array<Record<string, unknown>>} input.activity
|
|
38
|
+
* @param {number} input.unreadTower
|
|
39
|
+
* @param {object} input.config
|
|
40
|
+
* @param {object[]} input.missions enriched mission views
|
|
41
|
+
* @returns {object}
|
|
42
|
+
*/
|
|
43
|
+
export function statusView({ state, statePath, activity, unreadTower, config = {}, missions }) {
|
|
44
|
+
/** @type {Record<string, number>} */
|
|
45
|
+
const counts = {}
|
|
46
|
+
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
47
|
+
for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
|
|
48
|
+
const openTasks = state.missions.reduce((sum, mission) => sum + taskCounts(mission.tasks ?? []).open, 0)
|
|
49
|
+
/** @type {Record<string, number>} */
|
|
50
|
+
const findingsByStatus = {}
|
|
51
|
+
for (const findingStatus of FINDING_STATUSES) findingsByStatus[findingStatus] = 0
|
|
52
|
+
for (const finding of state.findings ?? []) {
|
|
53
|
+
const findingStatus = finding.status ?? 'open'
|
|
54
|
+
findingsByStatus[findingStatus] = (findingsByStatus[findingStatus] ?? 0) + 1
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
repoRoot: state.repoRoot,
|
|
58
|
+
commonDir: state.commonDir,
|
|
59
|
+
statePath,
|
|
60
|
+
base: state.base,
|
|
61
|
+
revision: state.revision,
|
|
62
|
+
towerAgentId: state.towerAgentId ?? null,
|
|
63
|
+
ownerMissing: !state.towerAgentId,
|
|
64
|
+
counts,
|
|
65
|
+
openTasks,
|
|
66
|
+
roster: (state.roster ?? []).length,
|
|
67
|
+
messages: (state.messages ?? []).length,
|
|
68
|
+
unreadTower,
|
|
69
|
+
findings: (state.findings ?? []).length,
|
|
70
|
+
findingsByStatus,
|
|
71
|
+
reviews: (state.reviews ?? []).length,
|
|
72
|
+
config: {
|
|
73
|
+
path: config.configPath ?? null,
|
|
74
|
+
repoConfigPresent: config.repoConfigPresent === true,
|
|
75
|
+
spawn: {
|
|
76
|
+
workerPreset: config.spawn?.workerPreset ?? null,
|
|
77
|
+
reviewerPreset: config.spawn?.reviewerPreset ?? null,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
|
|
81
|
+
id: finding.id,
|
|
82
|
+
type: finding.type,
|
|
83
|
+
severity: finding.severity,
|
|
84
|
+
status: finding.status ?? 'open',
|
|
85
|
+
title: finding.title,
|
|
86
|
+
missionId: finding.missionId,
|
|
87
|
+
reportedBy: finding.reportedBy,
|
|
88
|
+
at: finding.at,
|
|
89
|
+
})),
|
|
90
|
+
missions,
|
|
91
|
+
activity,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {unknown} maxBytes
|
|
97
|
+
* @returns {number}
|
|
98
|
+
*/
|
|
99
|
+
export function patchLimit(maxBytes) {
|
|
100
|
+
return Number.isInteger(maxBytes) && /** @type {number} */ (maxBytes) > 0 ? /** @type {number} */ (maxBytes) : 64 * 1024
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {object} input
|
|
105
|
+
* @param {Mission} input.mission
|
|
106
|
+
* @param {string} input.baseTip
|
|
107
|
+
* @param {string} input.branchTip
|
|
108
|
+
* @param {string} input.mergeBase
|
|
109
|
+
* @param {string[]} input.files
|
|
110
|
+
* @param {string[]} input.commits
|
|
111
|
+
* @param {string} input.fullPatch
|
|
112
|
+
* @param {number} [input.maxBytes]
|
|
113
|
+
* @returns {object}
|
|
114
|
+
*/
|
|
115
|
+
export function diffView({ mission, baseTip, branchTip, mergeBase, files, commits, fullPatch, maxBytes }) {
|
|
116
|
+
const limit = patchLimit(maxBytes)
|
|
117
|
+
return {
|
|
118
|
+
missionId: mission.id,
|
|
119
|
+
branch: mission.worktree ? mission.worktree.branch : null,
|
|
120
|
+
baseTip,
|
|
121
|
+
branchTip,
|
|
122
|
+
mergeBase,
|
|
123
|
+
files,
|
|
124
|
+
commits,
|
|
125
|
+
truncated: fullPatch.length > limit,
|
|
126
|
+
patch: fullPatch.slice(0, limit),
|
|
127
|
+
}
|
|
128
|
+
}
|
package/domain/report.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Pure human-readable mission board.
|
|
2
|
+
// @ts-check
|
|
3
|
+
import { MISSION_STATUSES } from './mission.js'
|
|
4
|
+
import { taskCounts } from './summary.js'
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../types/index.js').TowerState} TowerState */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {TowerState} state
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function boardMarkdown(state) {
|
|
13
|
+
const lines = []
|
|
14
|
+
lines.push(`# Tower missions — ${state.repoRoot}`)
|
|
15
|
+
lines.push('')
|
|
16
|
+
lines.push(`Base: \`${state.base}\` · revision ${state.revision} · updated ${new Date(state.updatedAt ?? Date.now()).toISOString()}`)
|
|
17
|
+
/** @type {Record<string, number>} */
|
|
18
|
+
const counts = {}
|
|
19
|
+
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
20
|
+
for (const mission of state.missions) counts[mission.status] += 1
|
|
21
|
+
lines.push('')
|
|
22
|
+
lines.push(
|
|
23
|
+
`Missions: ${state.missions.length} · ` +
|
|
24
|
+
MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
|
|
25
|
+
)
|
|
26
|
+
const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
27
|
+
lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
|
|
28
|
+
lines.push('')
|
|
29
|
+
lines.push('| Mission | Status | Kind | Title | Owner | Tasks (done/open/dropped) | Latest review | Merged |')
|
|
30
|
+
lines.push('|---|---|---|---|---|---|---|---|')
|
|
31
|
+
for (const mission of state.missions) {
|
|
32
|
+
const tasks = taskCounts(mission.tasks ?? [])
|
|
33
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
34
|
+
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
35
|
+
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
36
|
+
const reviewText = latest ? `R${latest.round} ${latest.status}/${latest.mergeVerdict}` : '—'
|
|
37
|
+
lines.push(
|
|
38
|
+
`| ${mission.id} | ${mission.status} | ${mission.kind} | ${String(mission.title).replaceAll('|', '\\|')} | ${
|
|
39
|
+
mission.owner ?? '—'
|
|
40
|
+
} | ${tasks.done}/${tasks.open}/${tasks.dropped} | ${reviewText} | ${
|
|
41
|
+
mission.mergedCommit ? mission.mergedCommit.slice(0, 8) : '—'
|
|
42
|
+
} |`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
if ((state.roster ?? []).length > 0) {
|
|
46
|
+
lines.push('')
|
|
47
|
+
lines.push('## Roster')
|
|
48
|
+
lines.push('')
|
|
49
|
+
lines.push('| Name | Role | Mission | State | Agent |')
|
|
50
|
+
lines.push('|---|---|---|---|---|')
|
|
51
|
+
for (const entry of state.roster ?? []) {
|
|
52
|
+
lines.push(
|
|
53
|
+
`| ${entry.name} | ${entry.role} | ${entry.missionId} | ${entry.state} | \`${entry.agentId}\` |`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
lines.push('')
|
|
58
|
+
return lines.join('\n')
|
|
59
|
+
}
|
package/domain/roster.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pure roster predicates.
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('../types/index.js').RosterEntry} RosterEntry */
|
|
5
|
+
/** @typedef {import('../types/index.js').TowerState} TowerState */
|
|
6
|
+
|
|
7
|
+
/** @type {readonly string[]} */
|
|
8
|
+
export const ROSTER_INACTIVE_STATES = ['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive']
|
|
9
|
+
/** @type {ReadonlySet<string>} */
|
|
10
|
+
export const ROSTER_INACTIVE = new Set(ROSTER_INACTIVE_STATES)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {TowerState} state
|
|
14
|
+
* @param {string} missionId
|
|
15
|
+
* @returns {RosterEntry | undefined}
|
|
16
|
+
*/
|
|
17
|
+
export function liveWorkerEntry(state, missionId) {
|
|
18
|
+
return (state.roster ?? []).find(
|
|
19
|
+
(entry) => entry.missionId === missionId && entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {TowerState} state
|
|
25
|
+
* @param {string} missionId
|
|
26
|
+
* @returns {RosterEntry[]}
|
|
27
|
+
*/
|
|
28
|
+
export function workerEntries(state, missionId) {
|
|
29
|
+
return (state.roster ?? []).filter((entry) => entry.missionId === missionId && entry.role === 'worker')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {TowerState} state
|
|
34
|
+
* @returns {number}
|
|
35
|
+
*/
|
|
36
|
+
export function countLiveWorkers(state) {
|
|
37
|
+
return (state.roster ?? []).filter(
|
|
38
|
+
(entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state) && entry.state !== 'reserved',
|
|
39
|
+
).length
|
|
40
|
+
}
|