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,124 @@
|
|
|
1
|
+
// Pure provision decisions: branch slug, base-commit choice, worktree root and
|
|
2
|
+
// path resolution, and result/activity projections. Git worktree creation and
|
|
3
|
+
// state persistence stay in app/provision.js.
|
|
4
|
+
// @ts-check
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
8
|
+
/** @typedef {import('../../types/index.js').MissionWorktree} MissionWorktree */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {unknown} title
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function slugify(title) {
|
|
15
|
+
const slug = String(title)
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '')
|
|
19
|
+
.slice(0, 24)
|
|
20
|
+
return slug || 'mission'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {Mission} mission
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function worktreeBranch(mission) {
|
|
28
|
+
return `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pick the base commit from the explicit argument, the requested base ref, or
|
|
33
|
+
* the recorded dirty snapshot. `source: 'base'` means the caller must rev-parse
|
|
34
|
+
* the configured base (the only I/O in the choice).
|
|
35
|
+
* @param {object} input
|
|
36
|
+
* @param {unknown} [input.baseCommit]
|
|
37
|
+
* @param {unknown} [input.includeDirtyBase]
|
|
38
|
+
* @param {string | null} [input.snapshotCommit]
|
|
39
|
+
* @param {boolean} [input.snapshotCommitValid]
|
|
40
|
+
* @returns {{ source: 'explicit' | 'snapshot', commit: string } | { source: 'base' }}
|
|
41
|
+
*/
|
|
42
|
+
export function baseCommitChoice({ baseCommit, includeDirtyBase, snapshotCommit = null, snapshotCommitValid = false }) {
|
|
43
|
+
if (baseCommit) return { source: 'explicit', commit: String(baseCommit) }
|
|
44
|
+
if (includeDirtyBase === false) return { source: 'base' }
|
|
45
|
+
if (snapshotCommit && snapshotCommitValid) return { source: 'snapshot', commit: snapshotCommit }
|
|
46
|
+
return { source: 'base' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} input
|
|
51
|
+
* @param {string | null} [input.worktreeRoot]
|
|
52
|
+
* @param {string | null} [input.configuredRoot]
|
|
53
|
+
* @param {string} input.repoRoot
|
|
54
|
+
* @param {string} input.commonDirHash
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function worktreeRoot({ worktreeRoot = null, configuredRoot = null, repoRoot, commonDirHash }) {
|
|
58
|
+
if (worktreeRoot) return path.resolve(worktreeRoot)
|
|
59
|
+
if (configuredRoot) return path.resolve(configuredRoot)
|
|
60
|
+
return path.join(path.dirname(repoRoot), '.dsh-tower-worktrees', commonDirHash)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} root
|
|
65
|
+
* @param {string} missionId
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function worktreePath(root, missionId) {
|
|
69
|
+
return path.join(root, missionId)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {unknown} missionId
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function hasMissionId(missionId) {
|
|
77
|
+
return typeof missionId === 'string' && missionId.trim().length > 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {object} input
|
|
82
|
+
* @param {string} input.path
|
|
83
|
+
* @param {string} input.branch
|
|
84
|
+
* @param {string} input.baseCommit
|
|
85
|
+
* @param {number} input.createdAt
|
|
86
|
+
* @param {string | null} [input.realPath]
|
|
87
|
+
* @returns {MissionWorktree}
|
|
88
|
+
*/
|
|
89
|
+
export function worktreeRecord({ path: wtPath, branch, baseCommit, createdAt, realPath = null }) {
|
|
90
|
+
return /** @type {MissionWorktree} */ ({
|
|
91
|
+
path: wtPath,
|
|
92
|
+
branch,
|
|
93
|
+
baseCommit,
|
|
94
|
+
createdAt,
|
|
95
|
+
...(realPath === null ? {} : { realPath }),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {Mission} mission
|
|
101
|
+
* @param {MissionWorktree} worktree
|
|
102
|
+
* @returns {{ mission: Mission, worktree: MissionWorktree, created: false }}
|
|
103
|
+
*/
|
|
104
|
+
export function existingWorktreeResult(mission, worktree) {
|
|
105
|
+
return { mission, worktree, created: false }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {Mission} mission
|
|
110
|
+
* @param {MissionWorktree} worktree
|
|
111
|
+
* @returns {{ mission: Mission, worktree: MissionWorktree, created: true }}
|
|
112
|
+
*/
|
|
113
|
+
export function createdWorktreeResult(mission, worktree) {
|
|
114
|
+
return { mission, worktree, created: true }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} branch
|
|
119
|
+
* @param {string} wtPath
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
export function provisionActivityText(branch, wtPath) {
|
|
123
|
+
return `${branch} at ${wtPath}`
|
|
124
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Pure rebase decisions: the prepared snapshot, durable-intent classification,
|
|
2
|
+
// replay/conflict outcomes, and the strings recorded for reconciliation.
|
|
3
|
+
// Git reads/writes and persistence stay in app/rebase.js.
|
|
4
|
+
// @ts-check
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
7
|
+
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
8
|
+
|
|
9
|
+
export const STALE_PREPARE = 'tower state changed while the rebase was being prepared; retry'
|
|
10
|
+
export const STALE_ACT =
|
|
11
|
+
'tower state changed while the rebase was running; the rebase intent is retained so the next tower_rebase can reconcile it; retry'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string | null | undefined} tip
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function shortTip(tip) {
|
|
18
|
+
return String(tip ?? '').slice(0, 7)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {RosterEntry} agent
|
|
23
|
+
* @returns {{ ok: false, blockers: Array<{ code: string, detail: string }> }}
|
|
24
|
+
*/
|
|
25
|
+
export function workerBusyResult(agent) {
|
|
26
|
+
return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${agent.name} is running; rebase refused` }] }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} path
|
|
31
|
+
* @returns {{ ok: false, blockers: Array<{ code: string, detail: string }> }}
|
|
32
|
+
*/
|
|
33
|
+
export function dirtyWorktreeResult(path) {
|
|
34
|
+
return { ok: false, blockers: [{ code: 'worktree-dirty', detail: `${path} has uncommitted changes` }] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rewriting an already-landed branch would destroy the reviewed tip.
|
|
39
|
+
* @param {object} input
|
|
40
|
+
* @param {string} input.oldTip
|
|
41
|
+
* @param {string} input.baseTip
|
|
42
|
+
* @param {string} input.base
|
|
43
|
+
* @returns {object}
|
|
44
|
+
*/
|
|
45
|
+
export function alreadyMergedRebaseResult({ oldTip, baseTip, base }) {
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
alreadyMerged: true,
|
|
49
|
+
skipped: true,
|
|
50
|
+
oldTip,
|
|
51
|
+
newTip: oldTip,
|
|
52
|
+
baseTip,
|
|
53
|
+
note: `${oldTip.slice(0, 7)} is already contained in ${base} ${baseTip.slice(0, 7)}; branch left untouched — call tower_merge to record the landed merge`,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} input
|
|
59
|
+
* @param {string} input.oldTip
|
|
60
|
+
* @param {string} input.baseTip
|
|
61
|
+
* @returns {object}
|
|
62
|
+
*/
|
|
63
|
+
export function alreadyUpToDateResult({ oldTip, baseTip }) {
|
|
64
|
+
return { ok: true, alreadyUpToDate: true, oldTip, newTip: oldTip, baseTip }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Both tips must still match the prepared snapshot before the branch rewrite.
|
|
69
|
+
* @param {object} input
|
|
70
|
+
* @param {string} input.oldTip
|
|
71
|
+
* @param {string} input.baseTip
|
|
72
|
+
* @param {string} input.currentTip
|
|
73
|
+
* @param {string} input.currentBase
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function rebaseTipsMoved({ oldTip, baseTip, currentTip, currentBase }) {
|
|
77
|
+
return currentTip !== oldTip || currentBase !== baseTip
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {object} input
|
|
82
|
+
* @param {string} input.oldTip
|
|
83
|
+
* @param {string} input.baseTip
|
|
84
|
+
* @param {string} input.currentTip
|
|
85
|
+
* @param {string} input.currentBase
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function rebaseTipsMovedDetail({ oldTip, baseTip, currentTip, currentBase }) {
|
|
89
|
+
return `tips moved after preparation (${shortTip(oldTip)}/${shortTip(baseTip)} -> ${shortTip(currentTip)}/${shortTip(currentBase)}), so nothing was rebased`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Malformed durable intents are refused rather than guessed.
|
|
94
|
+
* @param {unknown} intent
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
export function malformedRebaseIntent(intent) {
|
|
98
|
+
const record = /** @type {Record<string, unknown> | null} */ (intent)
|
|
99
|
+
return (
|
|
100
|
+
typeof record !== 'object' ||
|
|
101
|
+
record === null ||
|
|
102
|
+
typeof record.missionId !== 'string' ||
|
|
103
|
+
typeof record.token !== 'string'
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Classify the durable intent from the observed worktree tip. Only the two
|
|
109
|
+
* unambiguous outcomes are recordable; anything else stays pending.
|
|
110
|
+
* @param {object} input
|
|
111
|
+
* @param {{ oldTip: string, baseTip: string, commits?: number | null }} input.intent
|
|
112
|
+
* @param {Mission | undefined} input.mission
|
|
113
|
+
* @param {string} [input.tip]
|
|
114
|
+
* @param {boolean} input.tipReadable
|
|
115
|
+
* @param {boolean} input.baseIsAncestor
|
|
116
|
+
* @param {number | null} [input.actualCommits]
|
|
117
|
+
* @returns {{ action: 'clear' | 'ambiguous' | 'record', reason?: string, tip?: string }}
|
|
118
|
+
*/
|
|
119
|
+
export function classifyRebaseIntent({ intent, mission, tip, tipReadable, baseIsAncestor, actualCommits = null }) {
|
|
120
|
+
if (!mission || !mission.worktree || typeof mission.worktree.path !== 'string') {
|
|
121
|
+
return { action: 'clear', reason: 'the mission has no worktree' }
|
|
122
|
+
}
|
|
123
|
+
if (!tipReadable) return { action: 'clear', reason: 'the worktree is not readable' }
|
|
124
|
+
if (tip === intent.oldTip) {
|
|
125
|
+
return { action: 'clear', reason: 'the branch tip is unchanged (the rebase never ran)' }
|
|
126
|
+
}
|
|
127
|
+
if (!baseIsAncestor) return { action: 'ambiguous', tip }
|
|
128
|
+
if (intent.commits !== null && intent.commits !== undefined) {
|
|
129
|
+
if (actualCommits !== Number(intent.commits)) return { action: 'ambiguous', tip }
|
|
130
|
+
}
|
|
131
|
+
return { action: 'record', tip }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {{ missionId: string, startedAt?: number }} intent
|
|
136
|
+
* @param {string | undefined} tip
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function ambiguousRebaseIntentMessage(intent, tip) {
|
|
140
|
+
return `a rebase intent for ${intent.missionId} from ${new Date(Number(intent.startedAt) || 0).toISOString()} is pending and cannot be reconciled automatically (tip ${shortTip(tip)} does not match the prepared snapshot); finish or abort the rebase in that worktree, then retry`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {object} input
|
|
145
|
+
* @param {string} input.token
|
|
146
|
+
* @param {string} input.missionId
|
|
147
|
+
* @param {string} input.base
|
|
148
|
+
* @param {string} input.oldTip
|
|
149
|
+
* @param {string} input.baseTip
|
|
150
|
+
* @param {number | null} input.commits
|
|
151
|
+
* @param {number} input.startedAt
|
|
152
|
+
* @returns {object}
|
|
153
|
+
*/
|
|
154
|
+
export function buildRebaseIntent({ token, missionId, base, oldTip, baseTip, commits, startedAt }) {
|
|
155
|
+
return { token, missionId, base, oldTip, baseTip, commits, startedAt }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} base
|
|
160
|
+
* @param {string} baseTip
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
export function rebaseConflictNote(base, baseTip) {
|
|
164
|
+
return `rebase conflict onto ${base} @ ${baseTip.slice(0, 12)}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string} detail
|
|
169
|
+
* @returns {{ ok: false, blockers: Array<{ code: string, detail: string }> }}
|
|
170
|
+
*/
|
|
171
|
+
export function rebaseConflictResult(detail) {
|
|
172
|
+
return { ok: false, blockers: [{ code: 'rebase-conflict', detail }] }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {object} input
|
|
177
|
+
* @param {string} input.oldTip
|
|
178
|
+
* @param {string} input.newTip
|
|
179
|
+
* @param {string} input.baseTip
|
|
180
|
+
* @returns {{ ok: true, oldTip: string, newTip: string, baseTip: string, reviewWaived: true }}
|
|
181
|
+
*/
|
|
182
|
+
export function rebaseSuccessResult({ oldTip, newTip, baseTip }) {
|
|
183
|
+
return { ok: true, oldTip, newTip, baseTip, reviewWaived: true }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {object} input
|
|
188
|
+
* @param {string} input.oldTip
|
|
189
|
+
* @param {string} input.newTip
|
|
190
|
+
* @param {string} input.baseTip
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function rebaseSuccessNote({ oldTip, newTip, baseTip }) {
|
|
194
|
+
return `rebased ${shortTip(oldTip)} -> ${shortTip(newTip)} onto ${shortTip(baseTip)}; conflict-free rebase waives re-review`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {object} input
|
|
199
|
+
* @param {string} input.oldTip
|
|
200
|
+
* @param {string} input.newTip
|
|
201
|
+
* @param {string} input.baseTip
|
|
202
|
+
* @returns {string}
|
|
203
|
+
*/
|
|
204
|
+
export function rebaseReconcileNote({ oldTip, newTip, baseTip }) {
|
|
205
|
+
return `reconciled interrupted rebase ${shortTip(oldTip)} -> ${shortTip(newTip)} onto ${shortTip(baseTip)}; the clean rebase waiver is preserved`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {object} input
|
|
210
|
+
* @param {string} input.oldTip
|
|
211
|
+
* @param {string} input.newTip
|
|
212
|
+
* @param {string} input.baseTip
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function rebaseActivityText({ oldTip, newTip, baseTip }) {
|
|
216
|
+
return `${shortTip(oldTip)} -> ${shortTip(newTip)} onto ${shortTip(baseTip)}`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {object} input
|
|
221
|
+
* @param {string} input.oldTip
|
|
222
|
+
* @param {string} input.newTip
|
|
223
|
+
* @param {string} input.baseTip
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
export function rebaseReconcileActivityText({ oldTip, newTip, baseTip }) {
|
|
227
|
+
return `reconciled ${shortTip(oldTip)} -> ${shortTip(newTip)} onto ${shortTip(baseTip)}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {string | undefined} reason
|
|
232
|
+
* @returns {string}
|
|
233
|
+
*/
|
|
234
|
+
export function clearedRebaseIntentActivityText(reason) {
|
|
235
|
+
return `cleared a stale rebase intent (${reason})`
|
|
236
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Pure review-selection and exemption decisions shared by the merge/gate/read
|
|
2
|
+
// projections. No repository I/O.
|
|
3
|
+
// @ts-check
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
6
|
+
/** @typedef {import('../../types/index.js').Review} Review */
|
|
7
|
+
/** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reviews are branch-scoped; a missing branch has no reviews.
|
|
11
|
+
* @param {Review[] | null | undefined} reviews
|
|
12
|
+
* @param {string | null} branch
|
|
13
|
+
* @returns {Review[]}
|
|
14
|
+
*/
|
|
15
|
+
export function reviewsForBranch(reviews, branch) {
|
|
16
|
+
return branch ? (reviews ?? []).filter((review) => review.branch === branch) : []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The latest review round, or null when the branch has no reviews.
|
|
21
|
+
* @param {Review[]} reviews
|
|
22
|
+
* @returns {Review | null}
|
|
23
|
+
*/
|
|
24
|
+
export function latestReview(reviews) {
|
|
25
|
+
return reviews.length > 0 ? reviews.reduce((left, right) => (left.round >= right.round ? left : right)) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A mission may skip review only with both the exemption gate and a reason.
|
|
30
|
+
* @param {Mission} mission
|
|
31
|
+
* @param {TowerConfig} [config]
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isReviewExempt(mission, config = {}) {
|
|
35
|
+
return (
|
|
36
|
+
config.review?.requireReview === false ||
|
|
37
|
+
(mission.reviewPolicy === 'none' &&
|
|
38
|
+
config.gates?.allowReviewExemption === true &&
|
|
39
|
+
String(mission.reviewExemptionReason ?? '').trim().length > 0)
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `reviewPolicy: none` is invalid when the reviewer gate does not allow it.
|
|
45
|
+
* @param {Mission} mission
|
|
46
|
+
* @param {TowerConfig} [config]
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function reviewExemptionInvalid(mission, config = {}) {
|
|
50
|
+
const requireReview = config.review?.requireReview !== false
|
|
51
|
+
return requireReview && mission.reviewPolicy === 'none' && config.gates?.allowReviewExemption !== true
|
|
52
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Pure teardown decisions: keep/remove eligibility and the revalidation of a
|
|
2
|
+
// prepared candidate. Worktree ownership checks and git removals stay in
|
|
3
|
+
// app/teardown.js.
|
|
4
|
+
// @ts-check
|
|
5
|
+
import { hasLiveAgent } from './liveness.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
8
|
+
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
9
|
+
|
|
10
|
+
export const STALE_TEARDOWN = 'tower state changed while teardown was being prepared; retry'
|
|
11
|
+
|
|
12
|
+
/** @typedef {{ missionId: string, action: string, reason?: string, forced?: boolean }} Decision */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Replace a decision object in place so mission order is preserved.
|
|
16
|
+
* @param {Decision} decision
|
|
17
|
+
* @param {Record<string, unknown>} value
|
|
18
|
+
* @returns {void}
|
|
19
|
+
*/
|
|
20
|
+
export function replaceDecision(decision, value) {
|
|
21
|
+
const missionId = decision.missionId
|
|
22
|
+
for (const key of Object.keys(decision)) delete /** @type {Record<string, unknown>} */ (decision)[key]
|
|
23
|
+
Object.assign(decision, { missionId, ...value })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Snapshot-only eligibility before ownership/dirty diagnostics run.
|
|
28
|
+
* `skip` means the mission has no removable worktree; `decision` is a keep
|
|
29
|
+
* outcome; `proceed` means the caller must run the ownership/dirty checks.
|
|
30
|
+
* @param {Mission} mission
|
|
31
|
+
* @param {object} input
|
|
32
|
+
* @param {boolean} [input.excluded]
|
|
33
|
+
* @param {boolean} [input.hasLiveAgent]
|
|
34
|
+
* @returns {{ skip: true } | { decision: Decision } | { proceed: true }}
|
|
35
|
+
*/
|
|
36
|
+
export function teardownPrecheck(mission, { excluded = false, hasLiveAgent = false } = {}) {
|
|
37
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
38
|
+
if (!record.worktree || record.worktreeRemovedAt) return { skip: true }
|
|
39
|
+
if (excluded) return { decision: { missionId: mission.id, action: 'keep', reason: 'excluded' } }
|
|
40
|
+
if (hasLiveAgent) return { decision: { missionId: mission.id, action: 'keep', reason: 'live agent' } }
|
|
41
|
+
if (!['merged', 'abandoned'].includes(mission.status)) {
|
|
42
|
+
return { decision: { missionId: mission.id, action: 'keep', reason: `mission is ${mission.status}` } }
|
|
43
|
+
}
|
|
44
|
+
return { proceed: true }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} missionId
|
|
49
|
+
* @param {unknown} error
|
|
50
|
+
* @returns {Decision}
|
|
51
|
+
*/
|
|
52
|
+
export function ownershipUnverifiedDecision(missionId, error) {
|
|
53
|
+
return {
|
|
54
|
+
missionId,
|
|
55
|
+
action: 'keep',
|
|
56
|
+
reason: `worktree ownership/registration unverified: ${String(/** @type {{ message?: unknown }} */ (error)?.message ?? error).slice(0, 240)}`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {boolean} dirty
|
|
62
|
+
* @param {boolean} [force]
|
|
63
|
+
* @returns {{ action: 'keep', reason: string } | null}
|
|
64
|
+
*/
|
|
65
|
+
export function teardownDirtyDecision(dirty, force) {
|
|
66
|
+
return dirty && force !== true ? { action: 'keep', reason: 'uncommitted changes' } : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {boolean} dirty
|
|
71
|
+
* @returns {{ action: 'remove', reason: 'eligible', forced: boolean }}
|
|
72
|
+
*/
|
|
73
|
+
export function teardownRemoveDecision(dirty) {
|
|
74
|
+
return { action: 'remove', reason: 'eligible', forced: dirty }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Revalidate a prepared candidate against the current state. Used both before
|
|
79
|
+
* the git lock and again after re-reading state inside it.
|
|
80
|
+
* @param {TowerState} state
|
|
81
|
+
* @param {{ missionId: string, worktree: { path: string } }} candidate
|
|
82
|
+
* @param {Record<string, string | undefined>} [activity]
|
|
83
|
+
* @returns {{ ok: true, mission: Mission } | { ok: false, reason: string }}
|
|
84
|
+
*/
|
|
85
|
+
export function revalidateTeardownCandidate(state, candidate, activity = {}) {
|
|
86
|
+
const mission = state.missions.find((entry) => entry.id === candidate.missionId)
|
|
87
|
+
const record = mission ? /** @type {Record<string, any>} */ (mission) : null
|
|
88
|
+
if (!record || !record.worktree || record.worktreeRemovedAt) return { ok: false, reason: 'worktree is already gone' }
|
|
89
|
+
if (record.worktree.path !== candidate.worktree.path) {
|
|
90
|
+
return { ok: false, reason: 'worktree path changed during teardown' }
|
|
91
|
+
}
|
|
92
|
+
if (!['merged', 'abandoned'].includes(record.status)) return { ok: false, reason: `mission is ${record.status}` }
|
|
93
|
+
if (hasLiveAgent(state, record.id, activity)) return { ok: false, reason: 'live agent' }
|
|
94
|
+
return { ok: true, mission: /** @type {Mission} */ (mission) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {object} input
|
|
99
|
+
* @param {string} input.branch
|
|
100
|
+
* @param {string | null} [input.branchDeleteError]
|
|
101
|
+
* @returns {{ action: 'removed', branch: string, branchRetained?: true, branchDeleteError?: string }}
|
|
102
|
+
*/
|
|
103
|
+
export function teardownRemovedDecision({ branch, branchDeleteError = null }) {
|
|
104
|
+
return {
|
|
105
|
+
action: 'removed',
|
|
106
|
+
branch,
|
|
107
|
+
...(branchDeleteError ? { branchRetained: true, branchDeleteError } : {}),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} branch
|
|
113
|
+
* @param {string | null} [branchDeleteError]
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
export function teardownActivityText(branch, branchDeleteError = null) {
|
|
117
|
+
return `${branch}${branchDeleteError ? ' (branch retained)' : ''}`
|
|
118
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Pure mission vocabulary and transition rules (#49). No fs, git, or config.
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('../types/index.js').FindingSeverity} FindingSeverity */
|
|
5
|
+
/** @typedef {import('../types/index.js').FindingStatus} FindingStatus */
|
|
6
|
+
/** @typedef {import('../types/index.js').FindingType} FindingType */
|
|
7
|
+
/** @typedef {import('../types/index.js').MergeVerdict} MergeVerdict */
|
|
8
|
+
/** @typedef {import('../types/index.js').MissionStatus} MissionStatus */
|
|
9
|
+
|
|
10
|
+
/** @type {readonly MissionStatus[]} */
|
|
11
|
+
export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
|
|
12
|
+
export const SCHEMA_VERSION = 3
|
|
13
|
+
/** @type {readonly MergeVerdict[]} */
|
|
14
|
+
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
15
|
+
export const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
|
|
16
|
+
/** @type {ReadonlySet<string>} */
|
|
17
|
+
export const TERMINAL_STATUSES = new Set(['merged', 'abandoned'])
|
|
18
|
+
/** @type {readonly FindingType[]} */
|
|
19
|
+
export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
|
|
20
|
+
/** @type {readonly FindingSeverity[]} */
|
|
21
|
+
export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
|
|
22
|
+
/** @type {readonly FindingStatus[]} */
|
|
23
|
+
export const FINDING_STATUSES = ['open', 'triaged', 'fixed', 'wontfix']
|
|
24
|
+
/** @type {Record<string, readonly string[]>} */
|
|
25
|
+
export const FINDING_TRANSITIONS = {
|
|
26
|
+
open: ['triaged', 'fixed', 'wontfix'],
|
|
27
|
+
triaged: ['open', 'fixed', 'wontfix'],
|
|
28
|
+
fixed: ['open'],
|
|
29
|
+
wontfix: ['open'],
|
|
30
|
+
}
|
|
31
|
+
/** @type {readonly string[]} */
|
|
32
|
+
export const PAUSE_CAUSES = ['user', 'failure', 'resource', 'dependency']
|
|
33
|
+
/** @type {Record<string, readonly string[] | undefined>} */
|
|
34
|
+
export const TRANSITIONS = {
|
|
35
|
+
planned: ['active', 'abandoned'],
|
|
36
|
+
active: ['completed', 'blocked', 'paused', 'abandoned'],
|
|
37
|
+
blocked: ['active', 'paused', 'completed', 'abandoned'],
|
|
38
|
+
paused: ['active', 'abandoned'],
|
|
39
|
+
completed: ['active', 'abandoned'],
|
|
40
|
+
merged: [],
|
|
41
|
+
abandoned: [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} status
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isTerminal(status) {
|
|
49
|
+
return TERMINAL_STATUSES.has(status)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} from
|
|
54
|
+
* @param {string} to
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function canTransition(from, to) {
|
|
58
|
+
return (TRANSITIONS[from] ?? []).includes(to)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string | null | undefined} [value]
|
|
63
|
+
* @returns {string | null}
|
|
64
|
+
*/
|
|
65
|
+
export function pauseCauseError(value) {
|
|
66
|
+
if (value === undefined || value === null || value === '') return null
|
|
67
|
+
return PAUSE_CAUSES.includes(value) ? null : `pause_cause must be one of ${PAUSE_CAUSES.join('|')}`
|
|
68
|
+
}
|