dsh-tower 2.0.4 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- 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 +427 -0
- package/domain/contracts/outputs.js +305 -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 +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -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 +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -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 +9 -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 +212 -0
- package/scripts/release-check.mjs +303 -0
- 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 +45 -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 +102 -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 +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Pure mission planning input normalization and projections.
|
|
2
|
+
//
|
|
3
|
+
// The use case in app/plan.js owns the state lock, load, persistence, and the
|
|
4
|
+
// activity entry; everything here is deterministic and repository-free.
|
|
5
|
+
// @ts-check
|
|
6
|
+
import { TowerToolError } from '../errors.js'
|
|
7
|
+
import { normalizeScope, validateTitle } from '../scope.js'
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} message
|
|
13
|
+
* @returns {never}
|
|
14
|
+
*/
|
|
15
|
+
function fail(message) {
|
|
16
|
+
throw new TowerToolError('invalid-argument', message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
function isPlainObject(value) {
|
|
24
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate and normalize one `tower_plan` mission entry.
|
|
29
|
+
* @param {any} input
|
|
30
|
+
* @param {number} index
|
|
31
|
+
* @returns {any}
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeMissionInput(input, index) {
|
|
34
|
+
if (!isPlainObject(input)) fail(`missions[${index}] must be an object`)
|
|
35
|
+
const title = validateTitle(input.title)
|
|
36
|
+
const kind = input.kind === 'survey' ? 'survey' : 'build'
|
|
37
|
+
const scope = normalizeScope(input.scope ?? [], { allowEmpty: kind === 'survey' })
|
|
38
|
+
/** @type {Array<{ text: string, status: string }>} */
|
|
39
|
+
const tasks = []
|
|
40
|
+
if (input.tasks !== undefined) {
|
|
41
|
+
if (!Array.isArray(input.tasks)) fail(`missions[${index}].tasks must be an array of strings`)
|
|
42
|
+
for (const task of input.tasks) {
|
|
43
|
+
if (typeof task !== 'string' || task.trim().length === 0) {
|
|
44
|
+
fail(`missions[${index}].tasks entries must be non-empty strings`)
|
|
45
|
+
}
|
|
46
|
+
tasks.push({ text: task.trim(), status: 'open' })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** @type {string[]} */
|
|
50
|
+
const deps = []
|
|
51
|
+
if (input.deps !== undefined) {
|
|
52
|
+
if (!Array.isArray(input.deps)) fail(`missions[${index}].deps must be an array of mission ids`)
|
|
53
|
+
for (const dep of input.deps) {
|
|
54
|
+
if (typeof dep !== 'string' || dep.trim().length === 0) {
|
|
55
|
+
fail(`missions[${index}].deps entries must be non-empty mission ids`)
|
|
56
|
+
}
|
|
57
|
+
if (!deps.includes(dep.trim())) deps.push(dep.trim())
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
|
|
61
|
+
/** @type {string[]} */
|
|
62
|
+
const references = []
|
|
63
|
+
if (input.references !== undefined) {
|
|
64
|
+
if (!Array.isArray(input.references)) fail(`missions[${index}].references must be an array of pointers`)
|
|
65
|
+
if (input.references.length > 20) fail(`missions[${index}].references supports at most 20 pointers`)
|
|
66
|
+
for (const reference of input.references) {
|
|
67
|
+
if (typeof reference !== 'string' || reference.trim().length === 0) {
|
|
68
|
+
fail(`missions[${index}].references entries must be non-empty strings`)
|
|
69
|
+
}
|
|
70
|
+
if (!references.includes(reference.trim())) references.push(reference.trim())
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const reviewPolicy = input.reviewPolicy === undefined ? 'required-clean' : input.reviewPolicy
|
|
74
|
+
if (!['required-clean', 'none'].includes(reviewPolicy)) {
|
|
75
|
+
fail(`missions[${index}].reviewPolicy must be "required-clean" or "none"`)
|
|
76
|
+
}
|
|
77
|
+
const reviewExemptionReason = typeof input.reviewExemptionReason === 'string' ? input.reviewExemptionReason.trim() : ''
|
|
78
|
+
if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
|
|
79
|
+
fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
|
|
80
|
+
}
|
|
81
|
+
return { title, kind, scope, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate the plan envelope and normalize every mission entry.
|
|
86
|
+
* @param {any} input
|
|
87
|
+
* @returns {any[]}
|
|
88
|
+
*/
|
|
89
|
+
export function normalizePlanMissions(input) {
|
|
90
|
+
if (!Array.isArray(input.missions) || input.missions.length === 0) {
|
|
91
|
+
fail('tower_plan needs at least one mission')
|
|
92
|
+
}
|
|
93
|
+
return input.missions.map((mission, index) => normalizeMissionInput(mission, index))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Assign the next contiguous mission ids without mutating the input.
|
|
98
|
+
* @param {any[]} prepared
|
|
99
|
+
* @param {number} start
|
|
100
|
+
* @returns {any[]}
|
|
101
|
+
*/
|
|
102
|
+
export function assignMissionIds(prepared, start) {
|
|
103
|
+
return prepared.map((mission, index) => ({ ...mission, id: `M${start + index}` }))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every explicit dependency must name an existing or newly assigned mission.
|
|
108
|
+
* @param {any[]} assigned
|
|
109
|
+
* @param {Mission[]} existingMissions
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
112
|
+
export function assertKnownDependencies(assigned, existingMissions) {
|
|
113
|
+
const known = new Set([...existingMissions.map((mission) => mission.id), ...assigned.map((mission) => mission.id)])
|
|
114
|
+
for (const mission of assigned) {
|
|
115
|
+
for (const dep of mission.deps) {
|
|
116
|
+
if (!known.has(dep)) fail(`mission ${mission.id} depends on unknown mission "${dep}"`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Project a normalized, id-assigned input into a fresh mission record.
|
|
123
|
+
* @param {string} id
|
|
124
|
+
* @param {any} input
|
|
125
|
+
* @param {number} now
|
|
126
|
+
* @returns {Mission}
|
|
127
|
+
*/
|
|
128
|
+
export function newMission(id, input, now) {
|
|
129
|
+
return /** @type {Mission} */ (/** @type {any} */ ({
|
|
130
|
+
id,
|
|
131
|
+
title: input.title,
|
|
132
|
+
kind: input.kind,
|
|
133
|
+
scope: input.scope,
|
|
134
|
+
tasks: input.tasks,
|
|
135
|
+
deps: input.deps,
|
|
136
|
+
context: input.context,
|
|
137
|
+
references: input.references ?? [],
|
|
138
|
+
reviewPolicy: input.reviewPolicy ?? 'required-clean',
|
|
139
|
+
reviewExemptionReason: input.reviewExemptionReason ?? '',
|
|
140
|
+
status: 'planned',
|
|
141
|
+
owner: null,
|
|
142
|
+
blockers: [],
|
|
143
|
+
notes: [],
|
|
144
|
+
reviews: [],
|
|
145
|
+
scopeGeneration: 1,
|
|
146
|
+
createdAt: now,
|
|
147
|
+
updatedAt: now,
|
|
148
|
+
}))
|
|
149
|
+
}
|
|
@@ -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,121 @@
|
|
|
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
|
+
* `caseInsensitive` is the checkout's detected mode (#90); on such a filesystem
|
|
89
|
+
* `src/Api/**` and `src/api/**` are the same real files and must serialise.
|
|
90
|
+
* @param {Mission[]} newMissions
|
|
91
|
+
* @param {Mission[]} existingMissions
|
|
92
|
+
* @param {{ caseInsensitive?: boolean }} [options]
|
|
93
|
+
* @returns {void}
|
|
94
|
+
*/
|
|
95
|
+
function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false } = {}) {
|
|
96
|
+
const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
|
|
97
|
+
const all = [...existingMissions, ...newMissions]
|
|
98
|
+
/** @type {Map<string, Mission>} */
|
|
99
|
+
const byId = new Map(all.map((mission) => [mission.id, mission]))
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < newMissions.length; i += 1) {
|
|
102
|
+
for (let j = i + 1; j < newMissions.length; j += 1) {
|
|
103
|
+
const earlier = newMissions[i]
|
|
104
|
+
const later = newMissions[j]
|
|
105
|
+
if (earlier.kind === 'survey' || later.kind === 'survey') continue
|
|
106
|
+
if (!scopesOverlap(earlier.scope, later.scope, { caseInsensitive })) continue
|
|
107
|
+
if (hasDependencyPath(byId, later.id, earlier.id)) continue
|
|
108
|
+
addDependency(later, earlier.id)
|
|
109
|
+
}
|
|
110
|
+
const mission = newMissions[i]
|
|
111
|
+
if (mission.kind === 'survey') continue
|
|
112
|
+
for (const other of openExisting) {
|
|
113
|
+
if (other.kind === 'survey') continue
|
|
114
|
+
if (!scopesOverlap(mission.scope, other.scope, { caseInsensitive })) continue
|
|
115
|
+
if (hasDependencyPath(byId, mission.id, other.id)) continue
|
|
116
|
+
addDependency(mission, other.id)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
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
|
+
}
|