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
package/lib/doctor.js CHANGED
@@ -5,6 +5,11 @@ import path from 'node:path'
5
5
  import { fsp } from '../adapters/store-fs.js'
6
6
  import { git } from './git.js'
7
7
  import { readStateLock } from './store.js'
8
+ import { inspectArchive } from './archive.js'
9
+ import { inspectStateVersion } from '../domain/state.js'
10
+ import { SCHEMA_VERSION } from '../domain/mission.js'
11
+ import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
12
+ import { comparisonMode } from '../domain/scope.js'
8
13
  import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
9
14
  import { profileConfig } from './config.js'
10
15
  import { inspectWorkspaceLock } from './workspaceLock.js'
@@ -33,9 +38,87 @@ function check(id, status, detail, action) {
33
38
  return { id, status, detail, ...(action ? { action } : {}) }
34
39
  }
35
40
 
41
+ /** Read the on-disk state version without loading, migrating, or locking. */
42
+ async function inspectStateSchema(paths) {
43
+ try {
44
+ const text = await fsp.readFile(paths.state, 'utf8')
45
+ return inspectStateVersion(JSON.parse(text), paths.state)
46
+ } catch (error) {
47
+ return {
48
+ status: 'invalid',
49
+ currentVersion: null,
50
+ supportedVersion: SCHEMA_VERSION,
51
+ migrations: [],
52
+ message: `tower state could not be inspected: ${String(error?.message ?? error)}`,
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Report the on-disk schema version and the migration that a write would apply.
59
+ * Doctor never migrates: a read-only call must leave the file byte-identical.
60
+ */
61
+ function stateSchemaReport(info) {
62
+ const stateSchema = {
63
+ currentVersion: info.currentVersion,
64
+ supportedVersion: info.supportedVersion,
65
+ migrations: info.migrations ?? [],
66
+ status: info.status,
67
+ }
68
+ if (info.status === 'current') {
69
+ return {
70
+ stateSchema,
71
+ check: check('state-schema', 'pass', `schemaVersion ${info.currentVersion} is current`),
72
+ }
73
+ }
74
+ if (info.status === 'migratable') {
75
+ return {
76
+ stateSchema,
77
+ check: check(
78
+ 'state-schema',
79
+ 'warn',
80
+ `schemaVersion ${info.currentVersion} is older than ${info.supportedVersion}; the next locked write migrates it and keeps state.json.v${info.currentVersion}.bak`,
81
+ 'a write tool applies the migration; a read-only tool only migrates in memory',
82
+ ),
83
+ }
84
+ }
85
+ if (info.status === 'newer') {
86
+ return {
87
+ stateSchema,
88
+ check: check(
89
+ 'state-schema',
90
+ 'fail',
91
+ `schemaVersion ${info.currentVersion} is newer than this build supports (${info.supportedVersion}); state was written by a newer dsh-tower`,
92
+ 'upgrade dsh-tower; this build refuses to downgrade state.json',
93
+ ),
94
+ }
95
+ }
96
+ if (info.status === 'unmigratable') {
97
+ return {
98
+ stateSchema,
99
+ check: check(
100
+ 'state-schema',
101
+ 'fail',
102
+ `schemaVersion ${info.currentVersion} has no migration to ${info.supportedVersion}`,
103
+ 'restore a state.json written by a supported dsh-tower; this build will not guess at an unknown schema',
104
+ ),
105
+ }
106
+ }
107
+ return {
108
+ stateSchema,
109
+ check: check(
110
+ 'state-schema',
111
+ 'fail',
112
+ info.message ?? 'state schemaVersion is unreadable',
113
+ 'restore a state.json this build can read, or run tower_init for a new repository',
114
+ ),
115
+ }
116
+ }
117
+
36
118
  /** Run every read-only diagnostic; never writes state or takes the state lock. */
37
119
  export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
38
120
  const checks = []
121
+ let reportsReport = null
39
122
  const version = await gitVersion()
40
123
  checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
41
124
 
@@ -84,6 +167,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
84
167
  }
85
168
 
86
169
  checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
170
+ const schemaInfo = await inspectStateSchema(tower.paths)
87
171
  let state = null
88
172
  try {
89
173
  state = await tower.load()
@@ -96,6 +180,29 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
96
180
  } catch (error) {
97
181
  checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
98
182
  }
183
+ const schemaReport = stateSchemaReport(schemaInfo)
184
+ checks.push(schemaReport.check)
185
+
186
+ // Scope matching is only as good as the detected filesystem mode (#90).
187
+ // Doctor never probes: a read-only call must not create files. A legacy state
188
+ // that has not been re-initialized therefore reports the safe exact default.
189
+ const caseInsensitive =
190
+ state && (state.caseInsensitive === true || state.caseInsensitive === false) ? state.caseInsensitive : null
191
+ const comparison = comparisonMode(caseInsensitive)
192
+ checks.push(
193
+ caseInsensitive === null
194
+ ? check(
195
+ 'case-mode',
196
+ 'warn',
197
+ `caseInsensitive: not detected; scope matching uses ${comparison} comparison`,
198
+ 'run tower_init on a writable checkout to probe and record the filesystem mode',
199
+ )
200
+ : check(
201
+ 'case-mode',
202
+ 'pass',
203
+ `caseInsensitive: ${caseInsensitive}; scope matching uses ${comparison} comparison`,
204
+ ),
205
+ )
99
206
 
100
207
  const config = tower.config ?? {}
101
208
  const profile = profileConfig()
@@ -208,6 +315,30 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
208
315
  )
209
316
  checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
210
317
  checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
318
+
319
+ // Completion reports are the largest model-supplied strings in state.json;
320
+ // report the biggest one so growth is visible before it becomes a problem.
321
+ const reportSizes = (state.missions ?? [])
322
+ .map((mission) => ({ missionId: mission.id, bytes: completionReportBytes(mission.completionReport) }))
323
+ .filter((entry) => entry.bytes !== null)
324
+ const largestReport = reportSizes.reduce(
325
+ (best, entry) => (best === null || entry.bytes > best.bytes ? entry : best),
326
+ null,
327
+ )
328
+ const reportCap = Number(tower.config?.gates?.maxReportBytes) || DEFAULT_MAX_REPORT_BYTES
329
+ reportsReport = { count: reportSizes.length, largest: largestReport, capBytes: reportCap }
330
+ checks.push(
331
+ largestReport
332
+ ? check(
333
+ 'reports',
334
+ largestReport.bytes > reportCap ? 'warn' : 'pass',
335
+ `${reportSizes.length} completion report(s); largest ${largestReport.bytes} B on ${largestReport.missionId} (cap ${reportCap} B)`,
336
+ largestReport.bytes > reportCap
337
+ ? 'existing reports predate the cap; reads are unchanged and no rewrite happens'
338
+ : undefined,
339
+ )
340
+ : check('reports', 'pass', `no completion reports (cap ${reportCap} B)`),
341
+ )
211
342
  }
212
343
 
213
344
  try {
@@ -248,16 +379,63 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
248
379
  checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
249
380
  }
250
381
 
382
+ // Archive inventory (#73): size, count and the configured trigger policy.
383
+ // A missing directory is a healthy empty archive; a corrupt file is a warn.
384
+ const archiveInventory = await inspectArchive(tower.paths)
385
+ const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
386
+ const archivePolicy = config.archive ?? {}
387
+ const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${archivePolicy.enabled === false ? ' disabled' : ''}`
388
+ if (!archiveInventory.exists) {
389
+ checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
390
+ } else if (archiveInventory.unreadable.length > 0) {
391
+ checks.push(
392
+ check(
393
+ 'archive',
394
+ 'warn',
395
+ `${archiveInventory.missions} archived mission(s), ${archiveInventory.bytes} bytes; unreadable: ${archiveInventory.unreadable[0]}`,
396
+ 'inspect <git-common-dir>/dsh-tower/archive/ and repair or move the corrupt file; archiving is paused until it is readable',
397
+ ),
398
+ )
399
+ } else {
400
+ checks.push(
401
+ check(
402
+ 'archive',
403
+ 'pass',
404
+ `${archiveInventory.missions} archived mission(s) in ${archiveInventory.years.length} year file(s), ${archiveInventory.bytes} bytes; ${policyText}`,
405
+ ),
406
+ )
407
+ }
408
+
251
409
  const failed = checks.filter((item) => item.status === 'fail').length
252
410
  return {
253
411
  ok: failed === 0,
254
412
  checks,
413
+ stateSchema: schemaReport.stateSchema,
414
+ // reports (from #89) and archive (from #73) are both kept
415
+ reports: reportsReport,
416
+ archive: {
417
+ missions: state ? state.missions.length : null,
418
+ archivedMissions: archiveInventory.missions,
419
+ stateBytes,
420
+ archiveBytes: archiveInventory.bytes,
421
+ dir: archiveInventory.dir,
422
+ years: archiveInventory.years,
423
+ files: archiveInventory.files.length,
424
+ unreadable: archiveInventory.unreadable,
425
+ policy: {
426
+ enabled: archivePolicy.enabled !== false,
427
+ afterDays: archivePolicy.afterDays ?? 30,
428
+ maxMissions: archivePolicy.maxMissions ?? 100,
429
+ },
430
+ },
255
431
  environmentContract: {
256
432
  platform: process.platform,
257
433
  gitVersion: version,
258
434
  repoRoot: tower.root,
259
435
  commonDir: tower.commonDir,
260
436
  stateDir: tower.paths.dir,
437
+ caseInsensitive,
438
+ comparisonMode: comparison,
261
439
  gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
262
440
  mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
263
441
  testsAdmission: tower.config.tests?.admission ?? 'off',
package/lib/leases.js CHANGED
@@ -27,6 +27,11 @@ const LOCK_STALE_MS = 10000
27
27
  const LOCK_POLL_MS = 20
28
28
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
29
29
 
30
+ // Exported with the retry loop so a test can assert the budget and the exact
31
+ // transient set instead of copying them (#86).
32
+ export const UNLINK_ATTEMPTS = 12
33
+ export const TRANSIENT_UNLINK_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
34
+
30
35
  function fail(code, message) {
31
36
  /** @type {Error & {code?: string}} */
32
37
  const error = new Error(message)
@@ -57,17 +62,21 @@ export function leasesLockPath(commonDir) {
57
62
  /**
58
63
  * Windows can reject unlink while an indexer, antivirus, or a concurrent
59
64
  * reader still holds the file; retry briefly instead of failing the mutation.
65
+ *
66
+ * `options.unlink` and `options.sleep` inject the syscall and the backoff so
67
+ * both outcomes are testable on any OS (#86); production callers omit them.
60
68
  */
61
- async function unlinkWithRetry(file) {
69
+ export async function unlinkWithRetry(file, options = {}) {
70
+ const unlink = options.unlink ?? fsp.unlink
71
+ const wait = options.sleep ?? sleep
62
72
  for (let attempt = 0; ; attempt += 1) {
63
73
  try {
64
- await fsp.unlink(file)
74
+ await unlink(file)
65
75
  return true
66
76
  } catch (error) {
67
77
  if (error?.code === 'ENOENT') return false
68
- const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
69
- if (!transient || attempt >= 12) throw error
70
- await sleep(10 + attempt * 15)
78
+ if (!TRANSIENT_UNLINK_ERRORS.has(error?.code) || attempt >= UNLINK_ATTEMPTS) throw error
79
+ await wait(10 + attempt * 15)
71
80
  }
72
81
  }
73
82
  }
package/lib/legacy.js CHANGED
@@ -41,13 +41,27 @@ function sha256(buffer) {
41
41
  return crypto.createHash('sha256').update(buffer).digest('hex')
42
42
  }
43
43
 
44
- function win32Lower(value) {
45
- return process.platform === 'win32' ? value.toLowerCase() : value
44
+ // Path semantics are selected from an injected platform so both branches are
45
+ // testable on any OS (#86). On the real platform this is exactly `path`.
46
+ function pathFor(platform) {
47
+ return platform === 'win32' ? path.win32 : path.posix
46
48
  }
47
49
 
48
- function isInside(parent, child) {
49
- const relative = path.relative(win32Lower(path.resolve(parent)), win32Lower(path.resolve(child)))
50
- return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
50
+ export function win32Lower(value, platform = process.platform) {
51
+ return platform === 'win32' ? value.toLowerCase() : value
52
+ }
53
+
54
+ /**
55
+ * Is `child` inside `parent` (or the same path)? Windows reconciles case
56
+ * because NTFS is case-insensitive; the POSIX branch stays case-sensitive.
57
+ */
58
+ export function isInside(parent, child, platform = process.platform) {
59
+ const pathImpl = pathFor(platform)
60
+ const relative = pathImpl.relative(
61
+ win32Lower(pathImpl.resolve(parent), platform),
62
+ win32Lower(pathImpl.resolve(child), platform),
63
+ )
64
+ return relative === '' || (!relative.startsWith('..') && !pathImpl.isAbsolute(relative))
51
65
  }
52
66
 
53
67
  async function realpathOrResolve(target) {
@@ -20,8 +20,10 @@ import { TowerToolError } from '../domain/errors.js'
20
20
  export const DEFAULT_LOCK_TIMEOUT_MS = 5000
21
21
  export const DEFAULT_LOCK_POLL_MS = 25
22
22
 
23
- const RENAME_ATTEMPTS = 12
24
- const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
23
+ // Exported with the retry loop so a test can assert the budget and the exact
24
+ // transient set instead of copying them (#86).
25
+ export const RENAME_ATTEMPTS = 12
26
+ export const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
25
27
  const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
26
28
 
27
29
  export function sleep(ms) {
@@ -74,18 +76,23 @@ export async function writeJsonAtomic(file, value) {
74
76
  * Windows can reject a rename while an indexer, antivirus, or a concurrent
75
77
  * reader still holds the destination; retry briefly instead of failing a
76
78
  * successful mutation. The temp file is removed when the rename never lands.
79
+ *
80
+ * `options.rename` and `options.sleep` inject the syscall and the backoff so
81
+ * both outcomes are testable on any OS (#86); production callers omit them.
77
82
  */
78
- export async function renameWithRetry(tmp, file) {
83
+ export async function renameWithRetry(tmp, file, options = {}) {
84
+ const rename = options.rename ?? fsp.rename
85
+ const wait = options.sleep ?? sleep
79
86
  for (let attempt = 0; ; attempt += 1) {
80
87
  try {
81
- await fsp.rename(tmp, file)
88
+ await rename(tmp, file)
82
89
  return
83
90
  } catch (error) {
84
91
  if (!TRANSIENT_RENAME_ERRORS.has(error?.code) || attempt >= RENAME_ATTEMPTS) {
85
92
  await fsp.unlink(tmp).catch(() => {})
86
93
  throw error
87
94
  }
88
- await sleep(10 + attempt * 15)
95
+ await wait(10 + attempt * 15)
89
96
  }
90
97
  }
91
98
  }
@@ -0,0 +1,99 @@
1
+ // State-file load path: schema-version inspection, migration, and the one
2
+ // persisted migration result (#72).
3
+ //
4
+ // The domain stays pure: `prepareState` classifies, transforms and validates the
5
+ // document. This module owns the disk effects, and it persists them only while
6
+ // the caller holds the state lock, so a reader can never rewrite a document a
7
+ // concurrent writer is about to change. A read-only load still migrates in
8
+ // memory; the next locked write persists that migration once.
9
+ //
10
+ // Ordering mirrors the protocol contract:
11
+ // 1. classify the version (a newer document is refused before any write);
12
+ // 2. write the pre-migration backup;
13
+ // 3. migrate in memory and validate the target schema;
14
+ // 4. atomically replace state.json, bump `revision` once, append
15
+ // `state.migrated {from,to}` to the activity log.
16
+ // A migration that throws therefore leaves state.json byte-identical.
17
+ import { inspectStateVersion, prepareState } from '../domain/state.js'
18
+ import { isStateLockHeld } from './store.js'
19
+
20
+ /**
21
+ * @typedef {object} StateLoadResult
22
+ * @property {import('../types/index.js').TowerState} state
23
+ * @property {ReturnType<typeof inspectStateVersion>} version
24
+ * @property {null | {
25
+ * from: number, to: number, steps: Array<{ from: number, to: number }>,
26
+ * persisted: boolean, backupPath: string|null,
27
+ * }} migration
28
+ */
29
+
30
+ /**
31
+ * Read `paths.state`, migrate it when it is older than this build, and (only
32
+ * while the state lock is held) persist that migration.
33
+ *
34
+ * @param {{
35
+ * store: any,
36
+ * paths: any,
37
+ * now?: number,
38
+ * persist?: boolean,
39
+ * migrations?: readonly import('../domain/state/migrations.js').StateMigration[],
40
+ * }} input
41
+ * @returns {Promise<StateLoadResult>}
42
+ */
43
+ export async function loadStateFile(input) {
44
+ const { store, paths, migrations } = input
45
+ const now = input.now ?? Date.now()
46
+ const raw = await store.readJson(paths.state)
47
+ const version = inspectStateVersion(raw, paths.state, migrations)
48
+ const persist = input.persist ?? isStateLockHeld(paths)
49
+ const backupPath = `${paths.state}.v${String(version.currentVersion ?? 'unknown')}.bak`
50
+
51
+ // Backup before the original can change. `prepareState` is pure, so a
52
+ // throwing migration cannot touch state.json whether or not this ran.
53
+ if (persist && version.status === 'migratable') {
54
+ await store.writeJsonAtomic(backupPath, raw)
55
+ }
56
+
57
+ const prepared = prepareState(raw, paths.state, { migrations })
58
+ if (!prepared.changed) {
59
+ return { state: prepared.state, version, migration: null }
60
+ }
61
+
62
+ if (!persist) {
63
+ return {
64
+ state: prepared.state,
65
+ version,
66
+ migration: {
67
+ from: prepared.from,
68
+ to: prepared.to,
69
+ steps: prepared.migrated,
70
+ persisted: false,
71
+ backupPath: null,
72
+ },
73
+ }
74
+ }
75
+
76
+ const baseRevision =
77
+ Number.isInteger(prepared.state.revision) && prepared.state.revision >= 0 ? prepared.state.revision : 0
78
+ const migrated = { ...prepared.state, revision: baseRevision + 1, updatedAt: now }
79
+ await store.writeJsonAtomic(paths.state, migrated)
80
+ await store.appendJsonl(paths.activity, {
81
+ at: now,
82
+ type: 'state.migrated',
83
+ from: prepared.from,
84
+ to: prepared.to,
85
+ revision: migrated.revision,
86
+ text: `schema v${prepared.from} -> v${prepared.to}`,
87
+ })
88
+ return {
89
+ state: migrated,
90
+ version,
91
+ migration: {
92
+ from: prepared.from,
93
+ to: prepared.to,
94
+ steps: prepared.migrated,
95
+ persisted: true,
96
+ backupPath,
97
+ },
98
+ }
99
+ }
package/lib/store.js CHANGED
@@ -66,6 +66,19 @@ export async function readStateLock(paths) {
66
66
  return readLock(paths.lock)
67
67
  }
68
68
 
69
+ /**
70
+ * Paths objects whose state lock this process currently holds. A state
71
+ * migration may be persisted only inside `withLock`: the loader reads this to
72
+ * tell a locked mutation (which may write) from a read-only load (which must
73
+ * migrate in memory and leave the file alone).
74
+ */
75
+ const HELD_STATE_LOCKS = new WeakSet()
76
+
77
+ /** @param {object} [paths] @returns {boolean} */
78
+ export function isStateLockHeld(paths) {
79
+ return Boolean(paths) && HELD_STATE_LOCKS.has(paths)
80
+ }
81
+
69
82
  /**
70
83
  * Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
71
84
  * provably dead is reclaimed atomically; a live lock is reported, never stolen.
@@ -86,9 +99,11 @@ export async function withLock(paths, fn, options = {}) {
86
99
  )
87
100
  }
88
101
  const startedAt = Date.now()
102
+ HELD_STATE_LOCKS.add(paths)
89
103
  try {
90
104
  return await fn(paths)
91
105
  } finally {
106
+ HELD_STATE_LOCKS.delete(paths)
92
107
  await held.release()
93
108
  await fsp
94
109
  .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
package/lib/tests.js CHANGED
@@ -288,8 +288,12 @@ export function testProcessEnv(source = process.env) {
288
288
  return env
289
289
  }
290
290
 
291
- /** Platform shell for discovery commands (PowerShell 7 on Windows, bash on POSIX). */
292
- function shellSpec(platform = process.platform) {
291
+ /**
292
+ * Platform shell for discovery commands (PowerShell 7 on Windows with a
293
+ * `powershell` fallback, bash on POSIX). Exported so both branches are asserted
294
+ * on every OS instead of only on the platform that happens to run CI (#86).
295
+ */
296
+ export function shellSpec(platform = process.platform) {
293
297
  if (platform === 'win32') {
294
298
  const args = (command) => ['-NoProfile', '-NonInteractive', '-Command', command]
295
299
  return {
@@ -6,7 +6,7 @@
6
6
  // and must still be the Git worktree of the recorded branch in the recorded
7
7
  // repository. Every mismatch is rejected with an actionable message; nothing
8
8
  // is repaired or recreated here.
9
- import { fsp } from '../adapters/store-fs.js'
9
+ import { fs, fsp } from '../adapters/store-fs.js'
10
10
  import path from 'node:path'
11
11
 
12
12
  import { git } from './git.js'
@@ -22,18 +22,29 @@ function isNonEmptyString(value) {
22
22
  return typeof value === 'string' && value.trim().length > 0
23
23
  }
24
24
 
25
- function compareKey(target) {
26
- const resolved = path.resolve(target)
27
- return process.platform === 'win32' ? resolved.toLowerCase() : resolved
25
+ // Path identity follows the injected platform's semantics so both branches are
26
+ // testable on any OS (#86); on the real platform this is exactly `path`.
27
+ export function compareKey(target, platform = process.platform) {
28
+ const pathImpl = platform === 'win32' ? path.win32 : path.posix
29
+ const resolved = pathImpl.resolve(target)
30
+ return platform === 'win32' ? resolved.toLowerCase() : resolved
31
+ }
32
+
33
+ function nativeRealpath(target) {
34
+ try {
35
+ return fs.realpathSync.native(target)
36
+ } catch {
37
+ return path.resolve(target)
38
+ }
28
39
  }
29
40
 
30
41
  function samePath(a, b) {
31
- return compareKey(a) === compareKey(b)
42
+ return compareKey(nativeRealpath(a)) === compareKey(nativeRealpath(b))
32
43
  }
33
44
 
34
45
  async function realpathOrResolve(target) {
35
46
  try {
36
- return await fsp.realpath(target)
47
+ return fs.realpathSync.native(target)
37
48
  } catch (error) {
38
49
  if (error?.code === 'ENOENT') return path.resolve(target)
39
50
  throw error
@@ -48,7 +59,7 @@ export async function canonicalPath(target) {
48
59
  if (!isNonEmptyString(target)) fail('invalid-argument', 'path is required')
49
60
  if (!path.isAbsolute(target)) fail('invalid-argument', `path must be an absolute path: ${target}`)
50
61
  try {
51
- return await fsp.realpath(target)
62
+ return fs.realpathSync.native(target)
52
63
  } catch (error) {
53
64
  if (error?.code === 'ENOENT') fail('invalid-transition', `path does not exist: ${path.resolve(target)}`)
54
65
  throw error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "2.1.0-beta.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -8,6 +8,7 @@ SECURITY.md
8
8
  adapters/default.js
9
9
  adapters/store-fs.js
10
10
  adapters/vcs-git.js
11
+ app/archive.js
11
12
  app/batch.js
12
13
  app/board.js
13
14
  app/checkCompletion.js
@@ -31,6 +32,9 @@ app/teardown.js
31
32
  app/tower.js
32
33
  cordis.patch.yml
33
34
  docs/README.md
35
+ docs/agents/domain.md
36
+ docs/agents/issue-tracker.md
37
+ docs/agents/triage-labels.md
34
38
  docs/architecture/overview.md
35
39
  docs/board.md
36
40
  docs/examples/first-mission.md
@@ -106,13 +110,17 @@ domain/report.js
106
110
  domain/roster.js
107
111
  domain/scope.js
108
112
  domain/state.js
113
+ domain/state/archive.js
114
+ domain/state/migrations.js
109
115
  domain/state/schema.js
110
116
  domain/state/semantics.js
111
117
  domain/summary.js
112
118
  domain/vocabulary.js
113
119
  icon.svg
114
120
  index.js
121
+ lib/archive.js
115
122
  lib/briefing.js
123
+ lib/caseSensitivity.js
116
124
  lib/compatibility.js
117
125
  lib/config.js
118
126
  lib/doctor.js
@@ -126,6 +134,7 @@ lib/persistence.js
126
134
  lib/presets.js
127
135
  lib/spawn-prechecks.js
128
136
  lib/spawn-support.js
137
+ lib/stateFile.js
129
138
  lib/store.js
130
139
  lib/tests.js
131
140
  lib/tool-registry.js
package/tools/complete.js CHANGED
@@ -14,7 +14,7 @@ export function registerCompleteTool(ctx, deps) {
14
14
  define(ctx, {
15
15
  name: 'tower_complete',
16
16
  description:
17
- 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means ready for preflight and optional review, not merged.',
17
+ 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report, capped at gates.maxReportBytes (default 32768 UTF-8 bytes); a larger report is rejected with invalid-argument and an actionable nextAction, never truncated. Persists reportBytes on the stored report. completion means ready for preflight and optional review, not merged.',
18
18
  parameters: CompleteInput,
19
19
  aliases: {"missionId":["id"]},
20
20
  requiresMissionId: true,
package/tools/doctor.js CHANGED
@@ -15,7 +15,7 @@ export function registerDoctorTool(ctx, define) {
15
15
  define(ctx, {
16
16
  name: 'tower_doctor',
17
17
  description:
18
- 'Read-only environment and repository health check: git, state schema, config, presets/subagents/HMR, workspace lock, roster staleness, worktree ownership, test discovery, leases. Returns checks with pass/warn/fail and an environment contract. Never writes state.',
18
+ 'Read-only\ environment\ and\ repository\ health\ check:\ git,\ state\ schema,\ case-sensitivity\ detection\ \(caseInsensitive\ and\ the\ active\ scope\ comparison\ mode\),\ config,\ presets/subagents/HMR,\ workspace\ lock,\ roster\ staleness,\ worktree\ ownership,\ test\ discovery,\ leases,\ and\ the\ terminal-mission\ archive\ \(count,\ bytes,\ years,\ unreadable\ diagnostics\ and\ the\ configured\ afterDays/maxMissions\ policy\)\.\ Returns\ checks\ with\ pass/warn/fail\ and\ an\ environment\ contract\.\ Never\ writes\ state\ and\ never\ probes\ the\ filesystem\.',
19
19
  parameters: DoctorInput,
20
20
  output: output(SCHEMAS.doctor),
21
21
  async execute(args, exec) {
package/tools/init.js CHANGED
@@ -16,7 +16,7 @@ export function registerInitTool(ctx, deps) {
16
16
  define(ctx, {
17
17
  name: 'tower_init',
18
18
  description:
19
- 'Initialize Tower mission state for a repository (absolute repoRoot). Creates <git-common-dir>/dsh-tower/state.json and an activity log; idempotent on an existing workspace. Options: autoGitInit (git init + empty initial commit when the directory is not a repository), snapshotDirty (snapshot uncommitted work into a dangling commit used as the mission baseline), reanchor (change base when every mission is merged/abandoned). Only the tower may initialize.',
19
+ 'Initialize Tower mission state for a repository (absolute repoRoot). Creates <git-common-dir>/dsh-tower/state.json and an activity log; idempotent on an existing workspace. Probes whether the checkout filesystem folds case and records caseInsensitive once, so scope matching follows the real filesystem (a skipped probe records null and keeps the safe case-sensitive comparison). Options: autoGitInit (git init + empty initial commit when the directory is not a repository), snapshotDirty (snapshot uncommitted work into a dangling commit used as the mission baseline), reanchor (change base when every mission is merged/abandoned). Only the tower may initialize.',
20
20
  parameters: InitInput,
21
21
  aliases: { auto_git_init: ['autoGitInit'], snapshot_dirty: ['snapshotDirty'] },
22
22
  board: true,
package/tools/plan.js CHANGED
@@ -24,7 +24,7 @@ export function registerPlanTool(ctx, deps) {
24
24
  define(ctx, {
25
25
  name: 'tower_plan',
26
26
  description:
27
- 'Plan one or more missions in an initialized repository. Each mission has a printable-ASCII title, relative scope globs, optional task checklist, optional mission-id dependencies, and kind build|survey. Assigns M1.. ids and auto-adds a dependency when build scopes overlap (serializes merges); rejects dependency cycles.',
27
+ 'Plan one or more missions in an initialized repository. Each mission has a printable-Unicode title (NFC-normalized, at most 120 characters), relative scope globs, optional task checklist, optional mission-id dependencies, and kind build|survey. Assigns M1.. ids and auto-adds a dependency when build scopes overlap (serializes merges); rejects dependency cycles.',
28
28
  parameters: PlanInput,
29
29
  board: true,
30
30
  output: output(SCHEMAS.plan),
@@ -38,6 +38,7 @@ export function registerPreflightTool(ctx, deps) {
38
38
  reviewedCommit: gate.review.reviewedCommit,
39
39
  }
40
40
  : null,
41
+ ...(gate.archived ? { archived: true } : {}),
41
42
  }
42
43
  },
43
44
  })