dsh-tower 2.1.0-beta.1 → 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 (75) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +8 -1
  4. package/README.en.md +8 -2
  5. package/README.md +8 -2
  6. package/adapters/default.js +2 -0
  7. package/app/archive.js +190 -0
  8. package/app/board.js +3 -1
  9. package/app/checkCompletion.js +15 -2
  10. package/app/gate.js +31 -13
  11. package/app/init.js +45 -3
  12. package/app/merge.js +6 -0
  13. package/app/mission.js +2 -0
  14. package/app/plan.js +12 -4
  15. package/app/read.js +59 -3
  16. package/app/review.js +2 -0
  17. package/app/teardown.js +22 -1
  18. package/app/tower.js +34 -6
  19. package/docs/agents/domain.md +38 -0
  20. package/docs/agents/issue-tracker.md +46 -0
  21. package/docs/agents/triage-labels.md +15 -0
  22. package/docs/architecture/overview.md +3 -1
  23. package/docs/guide/install-upgrade-migration.md +18 -1
  24. package/docs/reference/blockers.md +1 -1
  25. package/docs/reference/config.generated.md +4 -0
  26. package/docs/reference/config.md +4 -0
  27. package/docs/reference/doctor.md +58 -8
  28. package/docs/reference/errors.generated.md +2 -0
  29. package/docs/reference/errors.md +4 -2
  30. package/docs/reference/tools.generated.md +6 -6
  31. package/docs/reference/tools.md +7 -6
  32. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  33. package/docs/zh/reference/blockers.md +1 -1
  34. package/docs/zh/reference/config.md +4 -0
  35. package/docs/zh/reference/doctor.md +35 -2
  36. package/docs/zh/reference/errors.md +4 -2
  37. package/docs/zh/reference/tools.md +3 -3
  38. package/domain/config/schema.js +26 -3
  39. package/domain/contracts/outputs.js +27 -1
  40. package/domain/contracts/tool-inputs/missions.js +2 -1
  41. package/domain/errors.js +2 -0
  42. package/domain/integration/gate.js +25 -11
  43. package/domain/integration/init.js +40 -4
  44. package/domain/mission.js +1 -1
  45. package/domain/missions/completion.js +58 -5
  46. package/domain/missions/lifecycle.js +2 -1
  47. package/domain/planning.js +6 -3
  48. package/domain/read/views.js +14 -1
  49. package/domain/report.js +9 -1
  50. package/domain/scope.js +83 -11
  51. package/domain/state/archive.js +257 -0
  52. package/domain/state/migrations.js +145 -0
  53. package/domain/state/schema.js +6 -0
  54. package/domain/state.js +154 -4
  55. package/domain/summary.js +2 -0
  56. package/lib/archive.js +279 -0
  57. package/lib/caseSensitivity.js +66 -0
  58. package/lib/doctor.js +178 -0
  59. package/lib/leases.js +14 -5
  60. package/lib/legacy.js +19 -5
  61. package/lib/persistence.js +12 -5
  62. package/lib/stateFile.js +99 -0
  63. package/lib/store.js +15 -0
  64. package/lib/tests.js +6 -2
  65. package/lib/worktreeOwnership.js +18 -7
  66. package/package.json +1 -1
  67. package/scripts/pack-manifest.txt +9 -0
  68. package/tools/complete.js +1 -1
  69. package/tools/doctor.js +1 -1
  70. package/tools/init.js +1 -1
  71. package/tools/plan.js +1 -1
  72. package/tools/preflight.js +1 -0
  73. package/tools/status.js +14 -6
  74. package/tools/teardown.js +1 -1
  75. package/types/index.d.ts +34 -0
@@ -169,8 +169,9 @@ export function applyMissionUpdate({ mission, state, input, config }) {
169
169
  const others = state.missions.filter(
170
170
  (entry) => entry.id !== mission.id && !TERMINAL.has(entry.status) && entry.kind !== 'survey',
171
171
  )
172
+ const caseInsensitive = state.caseInsensitive === true
172
173
  for (const other of others) {
173
- if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope)) {
174
+ if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope, { caseInsensitive })) {
174
175
  fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
175
176
  }
176
177
  }
@@ -85,11 +85,14 @@ function assertAcyclic(missions) {
85
85
  /**
86
86
  * Kimi-style heuristic: overlapping build scopes are serialized by an
87
87
  * auto-added dependency. It is conservative, not a proof of disjointness.
88
+ * `caseInsensitive` is the checkout's detected mode (#90); on such a filesystem
89
+ * `src/Api/**` and `src/api/**` are the same real files and must serialise.
88
90
  * @param {Mission[]} newMissions
89
91
  * @param {Mission[]} existingMissions
92
+ * @param {{ caseInsensitive?: boolean }} [options]
90
93
  * @returns {void}
91
94
  */
92
- function addAutoDeps(newMissions, existingMissions) {
95
+ function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false } = {}) {
93
96
  const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
94
97
  const all = [...existingMissions, ...newMissions]
95
98
  /** @type {Map<string, Mission>} */
@@ -100,7 +103,7 @@ function addAutoDeps(newMissions, existingMissions) {
100
103
  const earlier = newMissions[i]
101
104
  const later = newMissions[j]
102
105
  if (earlier.kind === 'survey' || later.kind === 'survey') continue
103
- if (!scopesOverlap(earlier.scope, later.scope)) continue
106
+ if (!scopesOverlap(earlier.scope, later.scope, { caseInsensitive })) continue
104
107
  if (hasDependencyPath(byId, later.id, earlier.id)) continue
105
108
  addDependency(later, earlier.id)
106
109
  }
@@ -108,7 +111,7 @@ function addAutoDeps(newMissions, existingMissions) {
108
111
  if (mission.kind === 'survey') continue
109
112
  for (const other of openExisting) {
110
113
  if (other.kind === 'survey') continue
111
- if (!scopesOverlap(mission.scope, other.scope)) continue
114
+ if (!scopesOverlap(mission.scope, other.scope, { caseInsensitive })) continue
112
115
  if (hasDependencyPath(byId, mission.id, other.id)) continue
113
116
  addDependency(mission, other.id)
114
117
  }
@@ -38,9 +38,20 @@ export function missionDetailView({ state, mission, activity }) {
38
38
  * @param {number} input.unreadTower
39
39
  * @param {object} input.config
40
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
41
43
  * @returns {object}
42
44
  */
43
- export function statusView({ state, statePath, activity, unreadTower, config = {}, missions }) {
45
+ export function statusView({
46
+ state,
47
+ statePath,
48
+ activity,
49
+ unreadTower,
50
+ config = {},
51
+ missions,
52
+ archivedMissions = 0,
53
+ archive = null,
54
+ }) {
44
55
  /** @type {Record<string, number>} */
45
56
  const counts = {}
46
57
  for (const status of MISSION_STATUSES) counts[status] = 0
@@ -88,6 +99,8 @@ export function statusView({ state, statePath, activity, unreadTower, config = {
88
99
  at: finding.at,
89
100
  })),
90
101
  missions,
102
+ archivedMissions,
103
+ ...(archive ? { archive } : {}),
91
104
  activity,
92
105
  }
93
106
  }
package/domain/report.js CHANGED
@@ -7,9 +7,10 @@ import { taskCounts } from './summary.js'
7
7
 
8
8
  /**
9
9
  * @param {TowerState} state
10
+ * @param {{ count?: number, unreadable?: boolean, message?: string } | null} [archive]
10
11
  * @returns {string}
11
12
  */
12
- export function boardMarkdown(state) {
13
+ export function boardMarkdown(state, archive = null) {
13
14
  const lines = []
14
15
  lines.push(`# Tower missions — ${state.repoRoot}`)
15
16
  lines.push('')
@@ -23,6 +24,13 @@ export function boardMarkdown(state) {
23
24
  `Missions: ${state.missions.length} · ` +
24
25
  MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
25
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
+ }
26
34
  const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
27
35
  lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
28
36
  lines.push('')
package/domain/scope.js CHANGED
@@ -11,16 +11,32 @@ function fail(message) {
11
11
  throw new TowerToolError('invalid-argument', message)
12
12
  }
13
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
+
14
28
  /**
15
29
  * @param {string} pattern
30
+ * @param {MatchOptions} [options]
16
31
  * @returns {RegExp}
17
32
  */
18
- function globToRegExp(pattern) {
33
+ function globToRegExp(pattern, { caseInsensitive = false } = {}) {
34
+ const source = foldCase(pattern, caseInsensitive)
19
35
  let out = '^'
20
- for (let index = 0; index < pattern.length; index += 1) {
21
- const char = pattern[index]
36
+ for (let index = 0; index < source.length; index += 1) {
37
+ const char = source[index]
22
38
  if (char === '*') {
23
- if (pattern[index + 1] === '*') {
39
+ if (source[index + 1] === '*') {
24
40
  out += '.*'
25
41
  index += 1
26
42
  } else {
@@ -38,20 +54,73 @@ function globToRegExp(pattern) {
38
54
  /**
39
55
  * @param {string[] | null | undefined} scope
40
56
  * @param {string} file
57
+ * @param {MatchOptions} [options]
41
58
  * @returns {boolean}
42
59
  */
43
- export function fileInScope(scope, file) {
44
- return (scope ?? []).some((pattern) => globToRegExp(pattern).test(file))
60
+ export function fileInScope(scope, file, { caseInsensitive = false } = {}) {
61
+ const target = foldCase(file, caseInsensitive)
62
+ return (scope ?? []).some((pattern) => globToRegExp(pattern, { caseInsensitive }).test(target))
45
63
  }
46
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
+
47
101
  /**
48
102
  * @param {unknown} title
49
103
  * @returns {string}
50
104
  */
51
105
  export function validateTitle(title) {
52
106
  if (typeof title !== 'string' || title.trim().length === 0) fail('mission title is required')
53
- if (!/^[\x20-\x7E]+$/.test(title)) fail(`mission title must be printable ASCII: ${JSON.stringify(title)}`)
54
- return title.trim()
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
55
124
  }
56
125
 
57
126
  /**
@@ -88,11 +157,14 @@ function scopePrefix(pattern) {
88
157
  /**
89
158
  * @param {string[]} a
90
159
  * @param {string[]} b
160
+ * @param {MatchOptions} [options]
91
161
  * @returns {boolean}
92
162
  */
93
- export function scopesOverlap(a, b) {
94
- for (const pa of a) {
95
- for (const pb of b) {
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)
96
168
  if (pa === pb) return true
97
169
  const x = scopePrefix(pa)
98
170
  const y = scopePrefix(pb)
@@ -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
+ }
@@ -0,0 +1,145 @@
1
+ // State-schema migrations (#72).
2
+ //
3
+ // The state file is a durable contract, so a version move is a migration, never
4
+ // a corruption. This module is the single place a future version is added: bump
5
+ // `SCHEMA_VERSION` in `../mission.js`, append one `{ from, to, migrate }` entry
6
+ // here, and every load path picks it up.
7
+ //
8
+ // Each `migrate` is a pure function: it receives a parsed document and returns a
9
+ // new one. It must not read the filesystem, mutate its argument, read the clock,
10
+ // or throw for data it can normalise. A step that throws becomes
11
+ // `[tower:state-migration-failed]` and the original file is left byte-identical.
12
+ // @ts-check
13
+ import { TowerToolError } from '../errors.js'
14
+ import { SCHEMA_VERSION } from '../mission.js'
15
+
16
+ /** @typedef {{ from: number, to: number, migrate: (state: Record<string, any>) => Record<string, any> }} StateMigration */
17
+
18
+ /**
19
+ * @param {unknown} value
20
+ * @returns {boolean}
21
+ */
22
+ function isPlainObject(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
24
+ }
25
+
26
+ /**
27
+ * v1 -> v2. Documents written before the current schema can omit `revision` and
28
+ * a mission's `tasks`/`deps` arrays; v2's structural contract requires them.
29
+ * The step only backfills absent values and never drops data.
30
+ * @param {Record<string, any>} state
31
+ * @returns {Record<string, any>}
32
+ */
33
+ export function migrateV1ToV2(state) {
34
+ const next = { ...state }
35
+ if (!Number.isInteger(next.revision) || next.revision < 0) next.revision = 0
36
+ next.missions = (Array.isArray(next.missions) ? next.missions : []).map((mission) => {
37
+ if (!isPlainObject(mission)) return mission
38
+ return {
39
+ ...mission,
40
+ tasks: Array.isArray(mission.tasks) ? mission.tasks : [],
41
+ deps: Array.isArray(mission.deps) ? mission.deps : [],
42
+ }
43
+ })
44
+ return next
45
+ }
46
+
47
+ /**
48
+ * v2 -> v3. Scope matching gained a detected case mode (#90). Older documents
49
+ * never recorded one, and a migration must not read the filesystem, so the
50
+ * field is backfilled as `null` ("not detected"). Comparison then keeps the
51
+ * safe exact default until the next `tower_init` probes the checkout.
52
+ * @param {Record<string, any>} state
53
+ * @returns {Record<string, any>}
54
+ */
55
+ export function migrateV2ToV3(state) {
56
+ if (state.caseInsensitive !== undefined) return state
57
+ return { ...state, caseInsensitive: null }
58
+ }
59
+
60
+ /** Ordered registry: entry 0 moves 1 -> 2, entry 1 moves 2 -> 3, and so on. */
61
+ export const STATE_MIGRATIONS = Object.freeze([
62
+ Object.freeze({ from: 1, to: 2, migrate: migrateV1ToV2 }),
63
+ Object.freeze({ from: 2, to: 3, migrate: migrateV2ToV3 }),
64
+ ])
65
+
66
+ /**
67
+ * The ordered steps needed to move `from` to `to`, or null when the gap cannot
68
+ * be crossed. `[]` means the document is already at `to`; a downgrade is never
69
+ * expressed as a chain.
70
+ *
71
+ * @param {unknown} from
72
+ * @param {number} [to]
73
+ * @param {readonly StateMigration[]} [migrations]
74
+ * @returns {StateMigration[] | null}
75
+ */
76
+ export function migrationChain(from, to = SCHEMA_VERSION, migrations = STATE_MIGRATIONS) {
77
+ if (!Number.isInteger(from) || /** @type {number} */ (from) < 1) return null
78
+ if (!Number.isInteger(to) || to < 1) return null
79
+ if (from === to) return []
80
+ if (/** @type {number} */ (from) > to) return null
81
+ const chain = []
82
+ let version = /** @type {number} */ (from)
83
+ while (version < to) {
84
+ const step = migrations.find((entry) => entry.from === version)
85
+ if (!step || !Number.isInteger(step.to) || step.to <= version) return null
86
+ chain.push(step)
87
+ version = step.to
88
+ }
89
+ return version === to ? chain : null
90
+ }
91
+
92
+ /**
93
+ * Apply the registry to a parsed document. Pure: the input object is never
94
+ * touched and the returned document has the target `schemaVersion`.
95
+ *
96
+ * @param {Record<string, any>} state
97
+ * @param {{ statePath?: string, to?: number, migrations?: readonly StateMigration[] }} [options]
98
+ * @returns {{ state: Record<string, any>, from: number, to: number, steps: Array<{ from: number, to: number }> }}
99
+ */
100
+ export function migrateState(state, options = {}) {
101
+ const where = options.statePath ? ` at ${options.statePath}` : ''
102
+ const to = options.to ?? SCHEMA_VERSION
103
+ const migrations = options.migrations ?? STATE_MIGRATIONS
104
+ const from = state?.schemaVersion
105
+ const chain = migrationChain(from, to, migrations)
106
+ if (!chain || chain.length === 0) {
107
+ throw new TowerToolError(
108
+ 'state-version-unsupported',
109
+ `tower state schemaVersion ${String(from)}${where} cannot be migrated to ${to}`,
110
+ {
111
+ nextAction: `upgrade dsh-tower when the state was written by a newer release; otherwise restore a state.json this build can read (schemaVersion ${SCHEMA_VERSION})`,
112
+ },
113
+ )
114
+ }
115
+ /** @type {Record<string, any>} */
116
+ let current = state
117
+ /** @type {Array<{ from: number, to: number }>} */
118
+ const steps = []
119
+ for (const step of chain) {
120
+ /** @type {Record<string, any>} */
121
+ let next
122
+ try {
123
+ next = step.migrate(current)
124
+ } catch (error) {
125
+ throw new TowerToolError(
126
+ 'state-migration-failed',
127
+ `state migration v${step.from} -> v${step.to} failed${where}: ${String(error?.message ?? error)}`,
128
+ {
129
+ cause: error,
130
+ nextAction: 'the original state.json was not modified; inspect the pre-migration backup state.json.v<N>.bak and report this failure',
131
+ },
132
+ )
133
+ }
134
+ if (!isPlainObject(next)) {
135
+ throw new TowerToolError(
136
+ 'state-migration-failed',
137
+ `state migration v${step.from} -> v${step.to} returned ${next === null ? 'null' : typeof next}${where}`,
138
+ { nextAction: 'the original state.json was not modified; report this migration bug' },
139
+ )
140
+ }
141
+ current = { ...next, schemaVersion: step.to }
142
+ steps.push({ from: step.from, to: step.to })
143
+ }
144
+ return { state: current, from: /** @type {number} */ (from), to, steps }
145
+ }
@@ -76,6 +76,9 @@ export const StateSchema = Type.Object(
76
76
  createdAt: Type.Optional(Type.Number()),
77
77
  updatedAt: Type.Optional(Type.Number()),
78
78
  dirtyBase: Type.Optional(Type.Boolean()),
79
+ // Detected once at tower_init (#90). `null` means "not detected yet": the
80
+ // v2 -> v3 migration backfills it without touching the filesystem.
81
+ caseInsensitive: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])),
79
82
  towerAgentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
80
83
  snapshotCommit: Type.Optional(Type.Union([Type.String(), Type.Null()])),
81
84
  snapshotRef: Type.Optional(Type.Union([Type.String(), Type.Null()])),
@@ -154,6 +157,9 @@ function firstShapeMessage(state, where) {
154
157
  if (state.dirtyBase !== undefined && typeof state.dirtyBase !== 'boolean') {
155
158
  return `tower state dirtyBase is not a boolean${where}`
156
159
  }
160
+ if (state.caseInsensitive !== undefined && state.caseInsensitive !== null && typeof state.caseInsensitive !== 'boolean') {
161
+ return `tower state caseInsensitive is not a boolean${where}`
162
+ }
157
163
  for (const key of ['towerAgentId', 'snapshotCommit', 'snapshotRef']) {
158
164
  if (state[key] !== undefined && state[key] !== null && typeof state[key] !== 'string') {
159
165
  return `tower state ${key} is not a string or null${where}`