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.
Files changed (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +9 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }