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,141 @@
|
|
|
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
|
+
* @param {number | null} [input.archivedMissions] archived mission count, or null when unreadable
|
|
42
|
+
* @param {object} [input.archive] archive index view/diagnostic
|
|
43
|
+
* @returns {object}
|
|
44
|
+
*/
|
|
45
|
+
export function statusView({
|
|
46
|
+
state,
|
|
47
|
+
statePath,
|
|
48
|
+
activity,
|
|
49
|
+
unreadTower,
|
|
50
|
+
config = {},
|
|
51
|
+
missions,
|
|
52
|
+
archivedMissions = 0,
|
|
53
|
+
archive = null,
|
|
54
|
+
}) {
|
|
55
|
+
/** @type {Record<string, number>} */
|
|
56
|
+
const counts = {}
|
|
57
|
+
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
58
|
+
for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
|
|
59
|
+
const openTasks = state.missions.reduce((sum, mission) => sum + taskCounts(mission.tasks ?? []).open, 0)
|
|
60
|
+
/** @type {Record<string, number>} */
|
|
61
|
+
const findingsByStatus = {}
|
|
62
|
+
for (const findingStatus of FINDING_STATUSES) findingsByStatus[findingStatus] = 0
|
|
63
|
+
for (const finding of state.findings ?? []) {
|
|
64
|
+
const findingStatus = finding.status ?? 'open'
|
|
65
|
+
findingsByStatus[findingStatus] = (findingsByStatus[findingStatus] ?? 0) + 1
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
repoRoot: state.repoRoot,
|
|
69
|
+
commonDir: state.commonDir,
|
|
70
|
+
statePath,
|
|
71
|
+
base: state.base,
|
|
72
|
+
revision: state.revision,
|
|
73
|
+
towerAgentId: state.towerAgentId ?? null,
|
|
74
|
+
ownerMissing: !state.towerAgentId,
|
|
75
|
+
counts,
|
|
76
|
+
openTasks,
|
|
77
|
+
roster: (state.roster ?? []).length,
|
|
78
|
+
messages: (state.messages ?? []).length,
|
|
79
|
+
unreadTower,
|
|
80
|
+
findings: (state.findings ?? []).length,
|
|
81
|
+
findingsByStatus,
|
|
82
|
+
reviews: (state.reviews ?? []).length,
|
|
83
|
+
config: {
|
|
84
|
+
path: config.configPath ?? null,
|
|
85
|
+
repoConfigPresent: config.repoConfigPresent === true,
|
|
86
|
+
spawn: {
|
|
87
|
+
workerPreset: config.spawn?.workerPreset ?? null,
|
|
88
|
+
reviewerPreset: config.spawn?.reviewerPreset ?? null,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
|
|
92
|
+
id: finding.id,
|
|
93
|
+
type: finding.type,
|
|
94
|
+
severity: finding.severity,
|
|
95
|
+
status: finding.status ?? 'open',
|
|
96
|
+
title: finding.title,
|
|
97
|
+
missionId: finding.missionId,
|
|
98
|
+
reportedBy: finding.reportedBy,
|
|
99
|
+
at: finding.at,
|
|
100
|
+
})),
|
|
101
|
+
missions,
|
|
102
|
+
archivedMissions,
|
|
103
|
+
...(archive ? { archive } : {}),
|
|
104
|
+
activity,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {unknown} maxBytes
|
|
110
|
+
* @returns {number}
|
|
111
|
+
*/
|
|
112
|
+
export function patchLimit(maxBytes) {
|
|
113
|
+
return Number.isInteger(maxBytes) && /** @type {number} */ (maxBytes) > 0 ? /** @type {number} */ (maxBytes) : 64 * 1024
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {object} input
|
|
118
|
+
* @param {Mission} input.mission
|
|
119
|
+
* @param {string} input.baseTip
|
|
120
|
+
* @param {string} input.branchTip
|
|
121
|
+
* @param {string} input.mergeBase
|
|
122
|
+
* @param {string[]} input.files
|
|
123
|
+
* @param {string[]} input.commits
|
|
124
|
+
* @param {string} input.fullPatch
|
|
125
|
+
* @param {number} [input.maxBytes]
|
|
126
|
+
* @returns {object}
|
|
127
|
+
*/
|
|
128
|
+
export function diffView({ mission, baseTip, branchTip, mergeBase, files, commits, fullPatch, maxBytes }) {
|
|
129
|
+
const limit = patchLimit(maxBytes)
|
|
130
|
+
return {
|
|
131
|
+
missionId: mission.id,
|
|
132
|
+
branch: mission.worktree ? mission.worktree.branch : null,
|
|
133
|
+
baseTip,
|
|
134
|
+
branchTip,
|
|
135
|
+
mergeBase,
|
|
136
|
+
files,
|
|
137
|
+
commits,
|
|
138
|
+
truncated: fullPatch.length > limit,
|
|
139
|
+
patch: fullPatch.slice(0, limit),
|
|
140
|
+
}
|
|
141
|
+
}
|
package/domain/report.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
* @param {{ count?: number, unreadable?: boolean, message?: string } | null} [archive]
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function boardMarkdown(state, archive = null) {
|
|
14
|
+
const lines = []
|
|
15
|
+
lines.push(`# Tower missions — ${state.repoRoot}`)
|
|
16
|
+
lines.push('')
|
|
17
|
+
lines.push(`Base: \`${state.base}\` · revision ${state.revision} · updated ${new Date(state.updatedAt ?? Date.now()).toISOString()}`)
|
|
18
|
+
/** @type {Record<string, number>} */
|
|
19
|
+
const counts = {}
|
|
20
|
+
for (const status of MISSION_STATUSES) counts[status] = 0
|
|
21
|
+
for (const mission of state.missions) counts[mission.status] += 1
|
|
22
|
+
lines.push('')
|
|
23
|
+
lines.push(
|
|
24
|
+
`Missions: ${state.missions.length} · ` +
|
|
25
|
+
MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
|
|
26
|
+
)
|
|
27
|
+
if (archive) {
|
|
28
|
+
lines.push(
|
|
29
|
+
archive.unreadable === true
|
|
30
|
+
? `Archived: unreadable (${archive.message ?? 'archive index could not be read'})`
|
|
31
|
+
: `Archived: ${archive.count ?? 0} terminal mission(s) in archive/ (not listed; use tower_status includeArchived)`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
|
|
35
|
+
lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
|
|
36
|
+
lines.push('')
|
|
37
|
+
lines.push('| Mission | Status | Kind | Title | Owner | Tasks (done/open/dropped) | Latest review | Merged |')
|
|
38
|
+
lines.push('|---|---|---|---|---|---|---|---|')
|
|
39
|
+
for (const mission of state.missions) {
|
|
40
|
+
const tasks = taskCounts(mission.tasks ?? [])
|
|
41
|
+
const branch = mission.worktree ? mission.worktree.branch : null
|
|
42
|
+
const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
|
|
43
|
+
const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
|
|
44
|
+
const reviewText = latest ? `R${latest.round} ${latest.status}/${latest.mergeVerdict}` : '—'
|
|
45
|
+
lines.push(
|
|
46
|
+
`| ${mission.id} | ${mission.status} | ${mission.kind} | ${String(mission.title).replaceAll('|', '\\|')} | ${
|
|
47
|
+
mission.owner ?? '—'
|
|
48
|
+
} | ${tasks.done}/${tasks.open}/${tasks.dropped} | ${reviewText} | ${
|
|
49
|
+
mission.mergedCommit ? mission.mergedCommit.slice(0, 8) : '—'
|
|
50
|
+
} |`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
if ((state.roster ?? []).length > 0) {
|
|
54
|
+
lines.push('')
|
|
55
|
+
lines.push('## Roster')
|
|
56
|
+
lines.push('')
|
|
57
|
+
lines.push('| Name | Role | Mission | State | Agent |')
|
|
58
|
+
lines.push('|---|---|---|---|---|')
|
|
59
|
+
for (const entry of state.roster ?? []) {
|
|
60
|
+
lines.push(
|
|
61
|
+
`| ${entry.name} | ${entry.role} | ${entry.missionId} | ${entry.state} | \`${entry.agentId}\` |`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
lines.push('')
|
|
66
|
+
return lines.join('\n')
|
|
67
|
+
}
|
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
|
+
}
|
package/domain/scope.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { TowerToolError } from './errors.js'
|
|
2
|
+
// Pure scope helpers shared by planning and gates.
|
|
3
|
+
// @ts-check
|
|
4
|
+
const GLOB_META = /[*?[{]/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @returns {never}
|
|
9
|
+
*/
|
|
10
|
+
function fail(message) {
|
|
11
|
+
throw new TowerToolError('invalid-argument', message)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ caseInsensitive?: boolean }} MatchOptions */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Case folding is a comparison decision, not a normalisation: the stored scope
|
|
18
|
+
* text keeps the operator's casing, and callers supply the checkout's detected
|
|
19
|
+
* mode (#90).
|
|
20
|
+
* @param {string} value
|
|
21
|
+
* @param {boolean} caseInsensitive
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function foldCase(value, caseInsensitive) {
|
|
25
|
+
return caseInsensitive ? value.toLowerCase() : value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} pattern
|
|
30
|
+
* @param {MatchOptions} [options]
|
|
31
|
+
* @returns {RegExp}
|
|
32
|
+
*/
|
|
33
|
+
function globToRegExp(pattern, { caseInsensitive = false } = {}) {
|
|
34
|
+
const source = foldCase(pattern, caseInsensitive)
|
|
35
|
+
let out = '^'
|
|
36
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
37
|
+
const char = source[index]
|
|
38
|
+
if (char === '*') {
|
|
39
|
+
if (source[index + 1] === '*') {
|
|
40
|
+
out += '.*'
|
|
41
|
+
index += 1
|
|
42
|
+
} else {
|
|
43
|
+
out += '[^/]*'
|
|
44
|
+
}
|
|
45
|
+
} else if (char === '?') {
|
|
46
|
+
out += '[^/]'
|
|
47
|
+
} else {
|
|
48
|
+
out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return new RegExp(`${out}$`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string[] | null | undefined} scope
|
|
56
|
+
* @param {string} file
|
|
57
|
+
* @param {MatchOptions} [options]
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function fileInScope(scope, file, { caseInsensitive = false } = {}) {
|
|
61
|
+
const target = foldCase(file, caseInsensitive)
|
|
62
|
+
return (scope ?? []).some((pattern) => globToRegExp(pattern, { caseInsensitive }).test(target))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The first scope entry that would match `file` if case were folded, or null.
|
|
67
|
+
* Only used to explain a rejection on a case-sensitive checkout; it never
|
|
68
|
+
* widens the acceptance test (#90).
|
|
69
|
+
* @param {string[] | null | undefined} scope
|
|
70
|
+
* @param {string} file
|
|
71
|
+
* @returns {string | null}
|
|
72
|
+
*/
|
|
73
|
+
export function caseInsensitiveScopeMatch(scope, file) {
|
|
74
|
+
const target = file.toLowerCase()
|
|
75
|
+
return (scope ?? []).find((pattern) => globToRegExp(pattern, { caseInsensitive: true }).test(target)) ?? null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The comparison the helpers use for a detected mode. `null`/`undefined` (a
|
|
80
|
+
* legacy state that predates detection) keeps the safe, exact default.
|
|
81
|
+
* @param {unknown} caseInsensitive
|
|
82
|
+
* @returns {'case-insensitive' | 'case-sensitive'}
|
|
83
|
+
*/
|
|
84
|
+
export function comparisonMode(caseInsensitive) {
|
|
85
|
+
return caseInsensitive === true ? 'case-insensitive' : 'case-sensitive'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Mission titles are printable Unicode, not printable ASCII (#88). The hazards
|
|
89
|
+
// the old ASCII range stood in for are made explicit: control/format/surrogate
|
|
90
|
+
// code points, bidi overrides, zero-width characters, and unbounded length.
|
|
91
|
+
const TITLE_MAX_LENGTH = 120
|
|
92
|
+
// U+202A..U+202E (embedding/override) and U+2066..U+2069 (isolate/override).
|
|
93
|
+
const BIDI_OVERRIDE = /[\u202A-\u202E\u2066-\u2069]/
|
|
94
|
+
// U+200B..U+200D (zero-width space/non-joiner/joiner) and U+FEFF (BOM/ZWNBSP).
|
|
95
|
+
const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/
|
|
96
|
+
// Unicode "Other": controls (Cc), format (Cf), unassigned (Cn), private use
|
|
97
|
+
// (Co) and surrogates (Cs). With the `u` flag a lone surrogate matches Cs,
|
|
98
|
+
// while a valid surrogate pair is one printable code point.
|
|
99
|
+
const CONTROL_OR_FORMAT = /\p{C}/u
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {unknown} title
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
export function validateTitle(title) {
|
|
106
|
+
if (typeof title !== 'string' || title.trim().length === 0) fail('mission title is required')
|
|
107
|
+
if (BIDI_OVERRIDE.test(title)) {
|
|
108
|
+
fail(`mission title must not contain bidi override characters (U+202A-U+202E, U+2066-U+2069): ${JSON.stringify(title)}`)
|
|
109
|
+
}
|
|
110
|
+
if (ZERO_WIDTH.test(title)) {
|
|
111
|
+
fail(`mission title must not contain zero-width characters (U+200B-U+200D, U+FEFF): ${JSON.stringify(title)}`)
|
|
112
|
+
}
|
|
113
|
+
if (CONTROL_OR_FORMAT.test(title)) {
|
|
114
|
+
fail(`mission title must not contain control, format, or surrogate characters: ${JSON.stringify(title)}`)
|
|
115
|
+
}
|
|
116
|
+
const normalized = title.trim().normalize('NFC')
|
|
117
|
+
// Count Unicode code points, not UTF-16 units, so astral characters (emoji)
|
|
118
|
+
// count as one character and the bound is script-independent.
|
|
119
|
+
const length = [...normalized].length
|
|
120
|
+
if (length > TITLE_MAX_LENGTH) {
|
|
121
|
+
fail(`mission title must be at most ${TITLE_MAX_LENGTH} characters after NFC normalization (got ${length})`)
|
|
122
|
+
}
|
|
123
|
+
return normalized
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {unknown} scope
|
|
128
|
+
* @param {{ allowEmpty?: boolean }} [options]
|
|
129
|
+
* @returns {string[]}
|
|
130
|
+
*/
|
|
131
|
+
export function normalizeScope(scope, { allowEmpty = false } = {}) {
|
|
132
|
+
if (!Array.isArray(scope)) fail('mission scope must be an array of relative globs')
|
|
133
|
+
if (scope.length === 0 && !allowEmpty) fail('mission scope must not be empty for a build mission')
|
|
134
|
+
/** @type {string[]} */
|
|
135
|
+
const out = []
|
|
136
|
+
for (const raw of scope) {
|
|
137
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) fail('scope entries must be non-empty strings')
|
|
138
|
+
const value = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '').trim()
|
|
139
|
+
if (value.length === 0) fail('scope entries must be non-empty strings')
|
|
140
|
+
if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) fail(`scope must be relative: ${raw}`)
|
|
141
|
+
if (value.split('/').some((part) => part === '..')) fail(`scope must not contain "..": ${raw}`)
|
|
142
|
+
if (!out.includes(value)) out.push(value)
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} pattern
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function scopePrefix(pattern) {
|
|
152
|
+
const index = pattern.search(GLOB_META)
|
|
153
|
+
const literal = index < 0 ? pattern : pattern.slice(0, index)
|
|
154
|
+
return literal.replace(/\/+$/, '')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string[]} a
|
|
159
|
+
* @param {string[]} b
|
|
160
|
+
* @param {MatchOptions} [options]
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
export function scopesOverlap(a, b, { caseInsensitive = false } = {}) {
|
|
164
|
+
for (const rawA of a) {
|
|
165
|
+
for (const rawB of b) {
|
|
166
|
+
const pa = foldCase(rawA, caseInsensitive)
|
|
167
|
+
const pb = foldCase(rawB, caseInsensitive)
|
|
168
|
+
if (pa === pb) return true
|
|
169
|
+
const x = scopePrefix(pa)
|
|
170
|
+
const y = scopePrefix(pb)
|
|
171
|
+
if (!x || !y) return true
|
|
172
|
+
if (x === y) return true
|
|
173
|
+
if (x.startsWith(`${y}/`) || y.startsWith(`${x}/`)) return true
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Terminal-mission archive decisions (#73).
|
|
2
|
+
//
|
|
3
|
+
// A finished mission keeps its tasks, reviews, findings and completion report,
|
|
4
|
+
// but every mutation rewrites the whole state document. Archiving moves that
|
|
5
|
+
// history into `<git-common-dir>/dsh-tower/archive/` and leaves only an index
|
|
6
|
+
// entry behind, so state.json grows with active work rather than with the
|
|
7
|
+
// repository's total history.
|
|
8
|
+
//
|
|
9
|
+
// This module is pure: it declares the archive document contract, selects what
|
|
10
|
+
// is eligible under the configured policy, and builds the records. Reading and
|
|
11
|
+
// writing the files, and the lock that makes them atomic with state.json, live
|
|
12
|
+
// in `lib/archive.js` and `app/archive.js`.
|
|
13
|
+
// @ts-check
|
|
14
|
+
import { isTerminal } from '../mission.js'
|
|
15
|
+
|
|
16
|
+
/** @typedef {import('../../types/index.js').Finding} Finding */
|
|
17
|
+
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
18
|
+
/** @typedef {import('../../types/index.js').Review} Review */
|
|
19
|
+
/** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
|
|
20
|
+
|
|
21
|
+
/** The archive document version. It is independent of `SCHEMA_VERSION`. */
|
|
22
|
+
export const ARCHIVE_VERSION = 1
|
|
23
|
+
|
|
24
|
+
export const DAY_MS = 24 * 60 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
/** @typedef {{ archivedAt: number, archivedYear: number }} ArchiveMeta */
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A worktree is still live until teardown records its removal. Archiving a
|
|
38
|
+
* mission whose worktree exists would lose the only record of where it is.
|
|
39
|
+
* @param {Mission} mission
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function hasLiveWorktree(mission) {
|
|
43
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
44
|
+
return Boolean(record.worktree) && !record.worktreeRemovedAt
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* When the mission became terminal. Missing timestamps are treated as ancient
|
|
49
|
+
* so a legacy terminal mission does not stay hot forever; the threshold is
|
|
50
|
+
* still explicit in config.
|
|
51
|
+
* @param {Mission} mission
|
|
52
|
+
* @returns {number}
|
|
53
|
+
*/
|
|
54
|
+
export function terminalTimestamp(mission) {
|
|
55
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
56
|
+
for (const value of [record.mergedAt, record.abandonedAt, record.updatedAt]) {
|
|
57
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
58
|
+
}
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Select the terminal missions the policy wants moved out of state.json.
|
|
64
|
+
*
|
|
65
|
+
* Eligibility:
|
|
66
|
+
* - status is `merged` or `abandoned` (terminal);
|
|
67
|
+
* - the worktree is gone (never provisioned, or removed by teardown);
|
|
68
|
+
* - the caller did not protect it (open batch members, live agents).
|
|
69
|
+
*
|
|
70
|
+
* Triggers:
|
|
71
|
+
* - `force` archives every eligible mission (tower_teardown);
|
|
72
|
+
* - otherwise a mission older than `afterDays` is archived, and when the hot
|
|
73
|
+
* terminal count exceeds `maxMissions`, the oldest eligible missions are
|
|
74
|
+
* archived until the count fits the cap.
|
|
75
|
+
*
|
|
76
|
+
* @param {Mission[]} missions
|
|
77
|
+
* @param {Partial<TowerConfig['archive']>} [policy]
|
|
78
|
+
* @param {{ now?: number, force?: boolean, protectedIds?: Iterable<string> }} [options]
|
|
79
|
+
* @returns {Mission[]}
|
|
80
|
+
*/
|
|
81
|
+
export function selectForArchive(missions, policy = {}, options = {}) {
|
|
82
|
+
if (policy?.enabled === false) return []
|
|
83
|
+
const now = Number.isFinite(options.now) ? /** @type {number} */ (options.now) : Date.now()
|
|
84
|
+
const protectedIds = new Set(options.protectedIds ?? [])
|
|
85
|
+
const afterDays = Number.isFinite(policy?.afterDays) ? /** @type {number} */ (policy.afterDays) : 30
|
|
86
|
+
const maxMissions = Number.isFinite(policy?.maxMissions) ? /** @type {number} */ (policy.maxMissions) : 100
|
|
87
|
+
const eligible = missions.filter(
|
|
88
|
+
(mission) => isTerminal(mission.status) && !hasLiveWorktree(mission) && !protectedIds.has(mission.id),
|
|
89
|
+
)
|
|
90
|
+
if (eligible.length === 0) return []
|
|
91
|
+
if (options.force === true) return eligible
|
|
92
|
+
|
|
93
|
+
const selected = new Set()
|
|
94
|
+
const cutoff = now - Math.max(0, afterDays) * DAY_MS
|
|
95
|
+
for (const mission of eligible) {
|
|
96
|
+
if (terminalTimestamp(mission) <= cutoff) selected.add(mission.id)
|
|
97
|
+
}
|
|
98
|
+
if (eligible.length > maxMissions) {
|
|
99
|
+
const oldest = [...eligible].sort((left, right) => terminalTimestamp(left) - terminalTimestamp(right))
|
|
100
|
+
for (let index = 0; index < oldest.length - maxMissions; index += 1) selected.add(oldest[index].id)
|
|
101
|
+
}
|
|
102
|
+
return missions.filter((mission) => selected.has(mission.id))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Review records that belong to one mission, by id or by its branch.
|
|
107
|
+
* @param {Mission} mission
|
|
108
|
+
* @param {Review[] | null | undefined} reviews
|
|
109
|
+
* @returns {Review[]}
|
|
110
|
+
*/
|
|
111
|
+
export function linkedReviews(mission, reviews) {
|
|
112
|
+
const branch = /** @type {Record<string, any>} */ (mission).worktree?.branch ?? null
|
|
113
|
+
return (reviews ?? []).filter(
|
|
114
|
+
(review) => review.missionId === mission.id || (branch !== null && review.branch === branch),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {Mission} mission
|
|
120
|
+
* @param {Finding[] | null | undefined} findings
|
|
121
|
+
* @returns {Finding[]}
|
|
122
|
+
*/
|
|
123
|
+
export function linkedFindings(mission, findings) {
|
|
124
|
+
return (findings ?? []).filter((finding) => finding.missionId === mission.id)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Closed findings are historical; open/triaged findings stay in state.json so
|
|
129
|
+
* `tower_findings` keeps showing actionable work even after its mission is
|
|
130
|
+
* archived. The archive entry still carries a snapshot of every linked finding.
|
|
131
|
+
* @param {Finding} finding
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
export function isClosedFinding(finding) {
|
|
135
|
+
return finding.status === 'fixed' || finding.status === 'wontfix'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** UTC year used for `archive/<year>.json`. @param {number} now @returns {number} */
|
|
139
|
+
export function archiveYearFor(now) {
|
|
140
|
+
return new Date(now).getUTCFullYear()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The compact entry written to `archive/index.json` (and referenced by
|
|
145
|
+
* `tower_status`). It carries enough fields to keep ordering, dependency
|
|
146
|
+
* lookups and mission-id allocation working without loading a year file.
|
|
147
|
+
* @param {Mission} mission
|
|
148
|
+
* @param {ArchiveMeta} meta
|
|
149
|
+
* @returns {Record<string, any>}
|
|
150
|
+
*/
|
|
151
|
+
export function archiveIndexEntry(mission, meta) {
|
|
152
|
+
const record = /** @type {Record<string, any>} */ (mission)
|
|
153
|
+
const branch = record.worktree?.branch ?? record.branch ?? null
|
|
154
|
+
return {
|
|
155
|
+
id: mission.id,
|
|
156
|
+
title: String(record.title ?? mission.id),
|
|
157
|
+
status: mission.status,
|
|
158
|
+
kind: record.kind ?? 'build',
|
|
159
|
+
scope: Array.isArray(record.scope) ? record.scope : [],
|
|
160
|
+
deps: Array.isArray(record.deps) ? record.deps : [],
|
|
161
|
+
owner: record.owner ?? null,
|
|
162
|
+
branch,
|
|
163
|
+
mergedCommit: record.mergedCommit ?? null,
|
|
164
|
+
mergedAt: record.mergedAt ?? null,
|
|
165
|
+
updatedAt: record.updatedAt ?? null,
|
|
166
|
+
archivedAt: meta.archivedAt,
|
|
167
|
+
archivedYear: meta.archivedYear,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A full archived record. It nests the untouched mission and its linked
|
|
173
|
+
* reviews/findings so `tower_status missionId` can reproduce the detail view.
|
|
174
|
+
* @param {Mission} mission
|
|
175
|
+
* @param {ArchiveMeta & { reviews?: Review[], findings?: Finding[] }} meta
|
|
176
|
+
* @returns {Record<string, any>}
|
|
177
|
+
*/
|
|
178
|
+
export function archivedEntry(mission, meta) {
|
|
179
|
+
return {
|
|
180
|
+
...archiveIndexEntry(mission, meta),
|
|
181
|
+
mission,
|
|
182
|
+
reviews: meta.reviews ?? [],
|
|
183
|
+
findings: meta.findings ?? [],
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Strip the full payload before writing the index.
|
|
189
|
+
* @param {Record<string, any>} entry
|
|
190
|
+
* @returns {Record<string, any>}
|
|
191
|
+
*/
|
|
192
|
+
export function indexEntryFromArchived(entry) {
|
|
193
|
+
const { mission: _mission, reviews: _reviews, findings: _findings, ...index } = entry
|
|
194
|
+
return index
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Dedupe entries by id, keeping the first occurrence. Archiving is idempotent:
|
|
199
|
+
* a crash between writing a year file and writing state.json is retried on the
|
|
200
|
+
* next mutation and must not create a second record.
|
|
201
|
+
* @template {{ id?: unknown }} T
|
|
202
|
+
* @param {T[]} entries
|
|
203
|
+
* @returns {T[]}
|
|
204
|
+
*/
|
|
205
|
+
export function dedupeById(entries) {
|
|
206
|
+
const seen = new Set()
|
|
207
|
+
const out = []
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
const id = entry?.id
|
|
210
|
+
if (typeof id !== 'string' || seen.has(id)) continue
|
|
211
|
+
seen.add(id)
|
|
212
|
+
out.push(entry)
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Validate an archive document. Returns the diagnostic string, or null when the
|
|
219
|
+
* document is structurally usable. A truncated or foreign archive must degrade
|
|
220
|
+
* to a diagnostic in status/doctor, never to a thrown state-corrupt error.
|
|
221
|
+
* @param {unknown} document
|
|
222
|
+
* @param {'index'|'year'} kind
|
|
223
|
+
* @param {string} [where]
|
|
224
|
+
* @returns {string | null}
|
|
225
|
+
*/
|
|
226
|
+
export function archiveDocumentError(document, kind, where = '') {
|
|
227
|
+
if (!isPlainObject(document)) return `tower archive ${kind} is not an object${where}`
|
|
228
|
+
const record = /** @type {Record<string, any>} */ (document)
|
|
229
|
+
if (record.archiveVersion !== ARCHIVE_VERSION) {
|
|
230
|
+
return `tower archive ${kind} has unsupported archiveVersion ${JSON.stringify(record.archiveVersion)}${where}`
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(record.missions)) return `tower archive ${kind} has no missions array${where}`
|
|
233
|
+
const ids = new Set()
|
|
234
|
+
for (const entry of record.missions) {
|
|
235
|
+
if (!isPlainObject(entry) || typeof entry.id !== 'string') {
|
|
236
|
+
return `tower archive ${kind} has an entry without id${where}`
|
|
237
|
+
}
|
|
238
|
+
if (ids.has(entry.id)) return `tower archive ${kind} has duplicate mission ${entry.id}${where}`
|
|
239
|
+
ids.add(entry.id)
|
|
240
|
+
if (typeof entry.status !== 'string') return `tower archive ${kind} entry ${entry.id} has no status${where}`
|
|
241
|
+
if (kind === 'year') {
|
|
242
|
+
if (!isPlainObject(entry.mission) || entry.mission.id !== entry.id) {
|
|
243
|
+
return `tower archive ${kind} entry ${entry.id} has no full mission${where}`
|
|
244
|
+
}
|
|
245
|
+
if (entry.reviews !== undefined && !Array.isArray(entry.reviews)) {
|
|
246
|
+
return `tower archive ${kind} entry ${entry.id} has no reviews array${where}`
|
|
247
|
+
}
|
|
248
|
+
if (entry.findings !== undefined && !Array.isArray(entry.findings)) {
|
|
249
|
+
return `tower archive ${kind} entry ${entry.id} has no findings array${where}`
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (kind === 'index' && (!Number.isInteger(entry.archivedYear) || entry.archivedYear < 1970)) {
|
|
253
|
+
return `tower archive ${kind} entry ${entry.id} has no archivedYear${where}`
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|