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,259 @@
1
+ #!/usr/bin/env node
2
+ // Offline tracker audit (#96, #79): every closed P0/P1 must point at evidence.
3
+ //
4
+ // A closure is checkable when its closing comment carries the two-line contract:
5
+ //
6
+ // verified-by: tests/<file>.test.mjs
7
+ // commit: <sha>
8
+ //
9
+ // This script reads a *local tracker export* (no network, no credentials), finds
10
+ // every closed P0/P1, and requires `verified-by:` to name a test file that exists
11
+ // in the repository. A missing, nonexistent, non-test or repository-escaping
12
+ // pointer exits non-zero, so "closed" stops being an assertion by the author.
13
+ //
14
+ // Usage:
15
+ // node scripts/issue-audit.mjs [--tracker <file>] [--root <dir>]
16
+ // [--since <YYYY-MM-DD>] [--strict] [--json]
17
+ //
18
+ // CONTRIBUTING.md ("Commits, closures and evidence") documents the export
19
+ // command and the rule; `npm run audit:issues` is the short form.
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { findingRecorder, parseArgs, runMain, UsageError } from './lib/cli.mjs'
24
+
25
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
26
+ const DEFAULT_TRACKER = path.join('tracker', 'issues.json')
27
+ const TEST_PATTERN = /(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$/
28
+ const USAGE = `usage: node scripts/issue-audit.mjs [--tracker <file>] [--root <dir>] [--since <YYYY-MM-DD>] [--strict] [--json]
29
+
30
+ --tracker local tracker export (default: tracker/issues.json)
31
+ --root repository the verified-by paths resolve against (default: this package)
32
+ --since only audit issues closed on or after this UTC date
33
+ --strict treat a missing "commit:" field as an error
34
+ --json print the report as JSON
35
+ --help print this text
36
+
37
+ The export is {"issues": [...]} or a bare array; each issue needs number, title,
38
+ state (or closedAt), labels, body and comments. Produce it once with gh (see
39
+ CONTRIBUTING.md); the audit itself never touches the network.`
40
+
41
+ // `verified-by:` / `commit:` are line fields, not prose. Backticks, a leading
42
+ // bullet or blockquote, and Windows path separators are tolerated because the
43
+ // contract is about the pointer, not the formatting.
44
+ function cleanValue(value) {
45
+ return String(value)
46
+ .trim()
47
+ .replace(/^`+|`+$/g, '')
48
+ .trim()
49
+ .replace(/^["']+|["']+$/g, '')
50
+ .trim()
51
+ }
52
+
53
+ export function extractFields(text) {
54
+ const fields = { verifiedBy: [], commit: null }
55
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
56
+ const line = rawLine.replace(/^\s*(?:[-*+]\s+|>\s*)+/, '')
57
+ const match = /^(verified-by|commit)\s*:\s*(.+)$/i.exec(line)
58
+ if (!match) continue
59
+ const value = cleanValue(match[2])
60
+ if (!value) continue
61
+ if (match[1].toLowerCase() === 'verified-by') fields.verifiedBy.push(value)
62
+ else fields.commit = value
63
+ }
64
+ return fields
65
+ }
66
+
67
+ function textSources(issue) {
68
+ const sources = []
69
+ if (typeof issue.body === 'string' && issue.body.trim()) sources.push(issue.body)
70
+ const comments = Array.isArray(issue.comments) ? issue.comments : []
71
+ for (const comment of comments) {
72
+ const body = typeof comment === 'string' ? comment : comment?.body
73
+ if (typeof body === 'string' && body.trim()) sources.push(body)
74
+ }
75
+ return sources
76
+ }
77
+
78
+ // The newest text that carries a `verified-by:` line is the closure evidence;
79
+ // an older comment cannot keep an issue closed after an edit. Every verified-by
80
+ // line in that text is checked, so listing two tests cannot hide one gap.
81
+ export function selectEvidence(issue) {
82
+ const sources = textSources(issue)
83
+ for (let index = sources.length - 1; index >= 0; index -= 1) {
84
+ const fields = extractFields(sources[index])
85
+ if (fields.verifiedBy.length > 0) return fields
86
+ }
87
+ return null
88
+ }
89
+
90
+ function labelsOf(issue) {
91
+ const labels = Array.isArray(issue.labels) ? issue.labels : []
92
+ return labels.map((label) => (typeof label === 'string' ? label : label?.name ?? '')).filter(Boolean)
93
+ }
94
+
95
+ export function priorityOf(issue) {
96
+ const labels = labelsOf(issue).map((label) => label.toUpperCase())
97
+ if (labels.includes('P0')) return 'P0'
98
+ if (labels.includes('P1')) return 'P1'
99
+ // The historical record uses `[P0]`/`[P1]` title prefixes; a label that was
100
+ // never backfilled must not make a critical closure invisible to the audit.
101
+ const match = /(?:^|\s)\[(P[01])\]/i.exec(String(issue.title ?? ''))
102
+ return match ? match[1].toUpperCase() : null
103
+ }
104
+
105
+ export function isClosed(issue) {
106
+ const state = String(issue.state ?? '').toLowerCase()
107
+ return state === 'closed' || Boolean(issue.closedAt ?? issue.closed_at)
108
+ }
109
+
110
+ function closedAtOf(issue) {
111
+ const raw = issue.closedAt ?? issue.closed_at
112
+ if (!raw) return null
113
+ const date = new Date(raw)
114
+ return Number.isNaN(date.getTime()) ? null : date
115
+ }
116
+
117
+ // A pointer must stay inside the repository and name a real test file: an
118
+ // existing script or doc is not evidence of a regression test.
119
+ function inspectPointer(pointer, root) {
120
+ const normalized = String(pointer).replace(/\\/g, '/').trim()
121
+ if (!normalized) return { code: 'invalid-pointer', detail: 'empty' }
122
+ if (path.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('/')) {
123
+ return { code: 'invalid-pointer', detail: 'must be repository-relative' }
124
+ }
125
+ const resolved = path.resolve(root, normalized)
126
+ const relative = path.relative(root, resolved)
127
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
128
+ return { code: 'invalid-pointer', detail: 'escapes the repository' }
129
+ }
130
+ if (!fs.existsSync(resolved)) return { code: 'missing-test', detail: 'does not exist' }
131
+ if (!fs.statSync(resolved).isFile()) return { code: 'missing-test', detail: 'is not a file' }
132
+ const isTest = normalized.startsWith('tests/') || TEST_PATTERN.test(normalized)
133
+ if (!isTest) return { code: 'not-a-test', detail: 'not under tests/ and not named *.test.* / *.spec.*' }
134
+ return null
135
+ }
136
+
137
+ export function parseTracker(text) {
138
+ let parsed
139
+ try {
140
+ parsed = JSON.parse(text)
141
+ } catch (error) {
142
+ throw new Error(`tracker export is not valid JSON: ${error?.message ?? error}`)
143
+ }
144
+ const issues = Array.isArray(parsed) ? parsed : parsed?.issues
145
+ if (!Array.isArray(issues)) throw new Error('tracker export must be an array of issues or { "issues": [...] }')
146
+ for (const issue of issues) {
147
+ if (!issue || typeof issue !== 'object' || typeof issue.number !== 'number') {
148
+ throw new Error('tracker export must be an array of issues or { "issues": [...] }: every issue needs a numeric number')
149
+ }
150
+ }
151
+ return issues
152
+ }
153
+
154
+ export function auditIssues(issues, options = {}) {
155
+ const root = path.resolve(options.root ?? PACKAGE_ROOT)
156
+ const strict = Boolean(options.strict)
157
+ let since = null
158
+ if (options.since) {
159
+ since = new Date(`${String(options.since).slice(0, 10)}T00:00:00.000Z`)
160
+ if (Number.isNaN(since.getTime())) throw new UsageError(`--since is not a date: ${options.since}`)
161
+ }
162
+ const findings = []
163
+ let checked = 0
164
+ for (const issue of issues) {
165
+ if (!isClosed(issue)) continue
166
+ const priority = priorityOf(issue)
167
+ if (!priority) continue
168
+ const closedAt = closedAtOf(issue)
169
+ if (since && closedAt && closedAt < since) continue
170
+ checked += 1
171
+ const label = `#${issue.number} ${String(issue.title ?? '').trim()}`
172
+ const record = findingRecorder(findings, { number: issue.number, title: issue.title, priority })
173
+
174
+ const fields = selectEvidence(issue)
175
+ if (!fields) {
176
+ record('missing-evidence', 'error', `${label}: no verified-by: field in the body or comments`)
177
+ continue
178
+ }
179
+ for (const pointer of fields.verifiedBy) {
180
+ const problem = inspectPointer(pointer, root)
181
+ if (!problem) continue
182
+ if (problem.code === 'missing-test') {
183
+ record('missing-test', 'error', `${label}: verified-by names a missing test file: ${pointer}`)
184
+ } else if (problem.code === 'not-a-test') {
185
+ record('not-a-test', 'error', `${label}: verified-by is not a test file: ${pointer} (${problem.detail})`)
186
+ } else {
187
+ record('invalid-pointer', 'error', `${label}: verified-by must be a repository-relative path: ${pointer} (${problem.detail})`)
188
+ }
189
+ }
190
+ if (!fields.commit) {
191
+ record('missing-commit', strict ? 'error' : 'warning', `${label}: no commit: <sha> field (warning; --strict fails)`)
192
+ }
193
+ }
194
+ const errors = findings.filter((finding) => finding.level === 'error')
195
+ const warnings = findings.filter((finding) => finding.level === 'warning')
196
+ return {
197
+ ok: errors.length === 0,
198
+ checked,
199
+ findings,
200
+ errors: errors.length,
201
+ warnings: warnings.length,
202
+ strict,
203
+ since: options.since ?? null,
204
+ }
205
+ }
206
+
207
+ function parseOptions(argv) {
208
+ return parseArgs(argv, {
209
+ initial: { tracker: null, root: PACKAGE_ROOT, since: null, strict: false, json: false, help: false },
210
+ values: {
211
+ '--tracker': 'tracker',
212
+ '--root': { key: 'root', parse: path.resolve },
213
+ '--since': 'since',
214
+ },
215
+ flags: { '--strict': 'strict', '--json': 'json', '--help': 'help', '-h': 'help' },
216
+ })
217
+ }
218
+
219
+ function cli(argv) {
220
+ const options = parseOptions(argv)
221
+ if (options.help) {
222
+ console.log(USAGE)
223
+ return 0
224
+ }
225
+ const tracker = options.tracker ? path.resolve(process.cwd(), options.tracker) : path.join(PACKAGE_ROOT, DEFAULT_TRACKER)
226
+ let text
227
+ try {
228
+ text = fs.readFileSync(tracker, 'utf8')
229
+ } catch (error) {
230
+ console.error(`issue-audit: cannot read tracker export ${tracker}: ${error?.message ?? error}`)
231
+ console.error('issue-audit: produce one with the gh command in CONTRIBUTING.md ("Commits, closures and evidence")')
232
+ return 2
233
+ }
234
+ let issues
235
+ let result
236
+ try {
237
+ issues = parseTracker(text)
238
+ result = auditIssues(issues, { root: options.root, since: options.since, strict: options.strict })
239
+ } catch (error) {
240
+ console.error(`issue-audit: ${error.message}`)
241
+ return 2
242
+ }
243
+
244
+ if (options.json) {
245
+ console.log(JSON.stringify({ tracker, root: path.resolve(options.root), ...result }, null, 2))
246
+ return result.ok ? 0 : 1
247
+ }
248
+ console.log(`issue-audit: ${tracker}`)
249
+ console.log(`issue-audit: ${result.checked} closed P0/P1 checked, ${result.errors} errors, ${result.warnings} warnings`)
250
+ for (const finding of result.findings) console.log(`${finding.level} [${finding.code}] ${finding.message}`)
251
+ if (result.ok) {
252
+ console.log('issue-audit: every checked closure names an existing test file')
253
+ } else {
254
+ console.log(`issue-audit: ${result.errors} closure(s) are not verifiable; see CONTRIBUTING.md`)
255
+ }
256
+ return result.ok ? 0 : 1
257
+ }
258
+
259
+ await runMain(import.meta.url, () => cli(process.argv.slice(2)), { errorPrefix: 'issue-audit', usage: USAGE })
@@ -0,0 +1,76 @@
1
+ // Shared CLI plumbing for the maintenance entry points under scripts/ (#94).
2
+ //
3
+ // scripts/release-check.mjs, scripts/issue-audit.mjs, scripts/mount-smoke.mjs
4
+ // and scripts/mount-smoke/fixtures.mjs had near-identical `parseArgs`,
5
+ // `UsageError`, `isMain` and exit-code blocks. This is that block once: each
6
+ // script declares its flags and keeps its own usage text, messages and codes.
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ export class UsageError extends Error {}
11
+
12
+ function usageError(message) {
13
+ const error = new UsageError(message)
14
+ // Only argument-parsing failures print the usage block; a repository or file
15
+ // problem exits 2 with its message only.
16
+ error.showUsage = true
17
+ return error
18
+ }
19
+
20
+ /** True when this module is the program entry point, not an imported module. */
21
+ export function isMain(importMetaUrl) {
22
+ return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(importMetaUrl)
23
+ }
24
+
25
+ /**
26
+ * Parse `--name <value>` and boolean flags into a plain object.
27
+ *
28
+ * - `initial`: the object to start from (a copy is returned).
29
+ * - `flags`: flag -> property name for booleans.
30
+ * - `values`: flag -> property name, or `{ key, parse }`.
31
+ * - `error`: error factory for scripts whose argument errors are ordinary
32
+ * failures rather than usage errors (the mount smoke exits 1, not 2).
33
+ * - `optionLikeValues`: reject a value that itself starts with `--`.
34
+ */
35
+ export function parseArgs(
36
+ argv,
37
+ { initial = {}, flags = {}, values = {}, error = usageError, optionLikeValues = true } = {},
38
+ ) {
39
+ const parsed = { ...initial }
40
+ for (let index = 0; index < argv.length; index += 1) {
41
+ const arg = argv[index]
42
+ if (Object.prototype.hasOwnProperty.call(flags, arg)) {
43
+ parsed[flags[arg]] = true
44
+ continue
45
+ }
46
+ if (!Object.prototype.hasOwnProperty.call(values, arg)) throw error(`unknown argument ${JSON.stringify(arg)}`)
47
+ const next = argv[index + 1]
48
+ if (next === undefined || (optionLikeValues && next.startsWith('--'))) throw error(`${arg} needs a value`)
49
+ index += 1
50
+ const spec = values[arg]
51
+ if (typeof spec === 'string') parsed[spec] = next
52
+ else parsed[spec.key] = spec.parse ? spec.parse(next) : next
53
+ }
54
+ return parsed
55
+ }
56
+
57
+ /** The finding recorder both gates use, so every call is `(code, level, message)`. */
58
+ export function findingRecorder(findings, base = {}) {
59
+ return (code, level, message) => findings.push({ ...base, code, level, message })
60
+ }
61
+
62
+ /**
63
+ * Run a CLI as the program entry point: the returned exit code becomes
64
+ * `process.exitCode`. A UsageError exits 2 (printing the usage text when it
65
+ * came from argument parsing); any other error exits 1.
66
+ */
67
+ export async function runMain(importMetaUrl, run, { errorPrefix, usage = null } = {}) {
68
+ if (!isMain(importMetaUrl)) return
69
+ try {
70
+ process.exitCode = await run()
71
+ } catch (error) {
72
+ console.error(`${errorPrefix}: ${error?.message ?? error}`)
73
+ if (usage && error?.showUsage) console.error(usage)
74
+ process.exitCode = error instanceof UsageError ? 2 : 1
75
+ }
76
+ }
@@ -0,0 +1,20 @@
1
+ // Best-effort temp-tree cleanup shared by the mount smoke and its fixtures (#91).
2
+ //
3
+ // Windows can hold a handle on a just-written file after a child process exits
4
+ // (EPERM/EBUSY). Cleanup is never part of a proof, so retry briefly and give up:
5
+ // a locked temp directory must not turn a green run red or hide the real cause
6
+ // of a red one.
7
+ import fs from 'node:fs'
8
+
9
+ export function removeTreeQuietly(dir) {
10
+ if (!dir) return false
11
+ for (let attempt = 0; attempt < 3; attempt += 1) {
12
+ try {
13
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
14
+ return true
15
+ } catch {
16
+ // Still locked; try again, then leave the directory for the OS.
17
+ }
18
+ }
19
+ return false
20
+ }
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ // Seeded failure fixtures for the mount smoke (#95).
3
+ //
4
+ // npm run smoke:fixtures
5
+ // node scripts/mount-smoke/fixtures.mjs --host-dir <installed-host> --keep
6
+ //
7
+ // The real smoke proves the current tree mounts. These fixtures prove it can
8
+ // still fail for the right reason: each one copies the package into a temp
9
+ // directory, breaks exactly one claim the bundle makes, runs the smoke against
10
+ // the copy, and requires a red run that names the cause. A green control run
11
+ // comes first, so a broken environment is never mistaken for a caught bug.
12
+ //
13
+ // The fixtures run in CI as the non-required `mount-fixtures` job, with one
14
+ // cached host install shared by the control and both seeds through --host-dir;
15
+ // locally the short form is `npm run smoke:fixtures`.
16
+ import { spawnSync } from 'node:child_process'
17
+ import fs from 'node:fs'
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { parseArgs, runMain } from '../lib/cli.mjs'
22
+ import { removeTreeQuietly } from '../lib/tmp.mjs'
23
+
24
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
25
+ const repoRoot = path.resolve(scriptDir, '..', '..')
26
+ const smokePath = path.join(scriptDir, '..', 'mount-smoke.mjs')
27
+
28
+ function parseOptions(argv) {
29
+ return parseArgs(argv, {
30
+ initial: { root: repoRoot, hostDir: undefined, keep: false },
31
+ values: {
32
+ '--root': { key: 'root', parse: path.resolve },
33
+ '--host-dir': { key: 'hostDir', parse: path.resolve },
34
+ },
35
+ flags: { '--keep': 'keep' },
36
+ error: (message) => new Error(message),
37
+ optionLikeValues: false,
38
+ })
39
+ }
40
+
41
+ function copyPackage(from, to) {
42
+ fs.cpSync(from, to, {
43
+ recursive: true,
44
+ filter: (source) => {
45
+ const base = path.basename(source)
46
+ return base !== 'node_modules' && base !== '.git'
47
+ },
48
+ })
49
+ }
50
+
51
+ function replaceOnce(text, search, replacement, label) {
52
+ const next = text.replace(search, replacement)
53
+ if (next === text) throw new Error(`seed did not apply: ${label}`)
54
+ return next
55
+ }
56
+
57
+ const fixtures = [
58
+ {
59
+ name: 'renamed-tool-id',
60
+ what: 'one tool module registers an id the bundle does not claim',
61
+ seed(root) {
62
+ const file = path.join(root, 'tools', 'doctor.js')
63
+ const text = fs.readFileSync(file, 'utf8')
64
+ fs.writeFileSync(file, replaceOnce(text, "name: 'tower_doctor'", "name: 'tower_doctor_renamed'", 'tools/doctor.js tower_doctor'))
65
+ },
66
+ expect: ['tool: missing tower_doctor', 'tool: unexpected tower_doctor_renamed'],
67
+ },
68
+ {
69
+ name: 'dropped-skills-section',
70
+ what: 'the tower-skills row is removed from the bundle patch',
71
+ seed(root) {
72
+ const file = path.join(root, 'cordis.patch.yml')
73
+ const text = fs.readFileSync(file, 'utf8')
74
+ const next = replaceOnce(
75
+ text,
76
+ /\n {8}- id: tower-skills\n {10}name: 'dsh-tower\/skills'/,
77
+ '',
78
+ 'cordis.patch.yml tower-skills row',
79
+ )
80
+ fs.writeFileSync(file, next)
81
+ },
82
+ expect: ['skill: missing tower-worker-protocol', 'bundle: tower-bundle is missing child tower-skills'],
83
+ },
84
+ ]
85
+
86
+ function runSmoke(root, hostDir) {
87
+ const result = spawnSync(process.execPath, [smokePath, '--root', root, '--host-dir', hostDir], {
88
+ encoding: 'utf8',
89
+ timeout: 600_000,
90
+ maxBuffer: 64 * 1024 * 1024,
91
+ })
92
+ return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}${result.error ? String(result.error) : ''}` }
93
+ }
94
+
95
+ function selfDescribing(output) {
96
+ const missing = []
97
+ if (!/^ {2}profile: /m.test(output)) missing.push('the resolved profile path')
98
+ if (!/^ {2}host: {4}@deepseek-ai\/dsh@/m.test(output)) missing.push('the host build')
99
+ return missing
100
+ }
101
+
102
+ function main() {
103
+ const options = parseOptions(process.argv.slice(2))
104
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-tower-mount-fixtures-'))
105
+ const hostDir = options.hostDir ?? path.join(tmp, 'host')
106
+ let failures = 0
107
+
108
+ try {
109
+ process.stdout.write(`control: the unmodified package mounts green\n`)
110
+ const control = runSmoke(options.root, hostDir)
111
+ if (control.status !== 0) {
112
+ console.error('control: FAILED — the fixtures cannot distinguish a seed from a broken environment')
113
+ console.error(control.output.slice(-4000))
114
+ failures += 1
115
+ } else {
116
+ console.log('control: PASS')
117
+ }
118
+
119
+ for (const fixture of fixtures) {
120
+ const copyRoot = path.join(tmp, fixture.name)
121
+ copyPackage(options.root, copyRoot)
122
+ try {
123
+ fixture.seed(copyRoot)
124
+ } catch (error) {
125
+ console.error(`fixture ${fixture.name}: FAILED to seed — ${error.message}`)
126
+ failures += 1
127
+ continue
128
+ }
129
+ const run = runSmoke(copyRoot, hostDir)
130
+ const problems = []
131
+ if (run.status === 0) problems.push('the smoke stayed green; the seeded break was not detected')
132
+ for (const marker of fixture.expect) if (!run.output.includes(marker)) problems.push(`the red run did not say: ${marker}`)
133
+ for (const missing of selfDescribing(run.output)) problems.push(`the red run did not print ${missing}`)
134
+ if (run.status !== 0 && !run.output.includes('workspace kept:')) problems.push('the red run did not keep its workspace')
135
+
136
+ if (problems.length > 0) {
137
+ console.error(`fixture ${fixture.name}: FAILED (${fixture.what})`)
138
+ for (const problem of problems) console.error(` - ${problem}`)
139
+ console.error(run.output.slice(-4000))
140
+ failures += 1
141
+ } else {
142
+ const kept = /workspace kept: (.+)/.exec(run.output)?.[1]?.trim()
143
+ console.log(`fixture ${fixture.name}: PASS — the smoke failed and named the cause${kept ? ` (workspace ${kept})` : ''}`)
144
+ }
145
+ }
146
+ } finally {
147
+ if (options.keep) {
148
+ console.log(`fixtures workspace kept: ${tmp}`)
149
+ } else {
150
+ removeTreeQuietly(tmp)
151
+ }
152
+ }
153
+
154
+ if (failures > 0) {
155
+ console.error(`mount smoke fixtures: ${failures} of ${fixtures.length + 1} expectations failed`)
156
+ return 1
157
+ }
158
+ console.log(`mount smoke fixtures: control + ${fixtures.length} seeded failures behave as required`)
159
+ return 0
160
+ }
161
+
162
+ await runMain(import.meta.url, main, { errorPrefix: 'mount smoke fixtures failed' })
@@ -0,0 +1,4 @@
1
+ {
2
+ "dsh": "0.2.1-rc.2",
3
+ "pnpm": "11.28.5"
4
+ }
@@ -0,0 +1,125 @@
1
+ // Boot one throwaway profile through the real DSH loader and report its
2
+ // loaded surface as JSON (#95).
3
+ //
4
+ // This is the only file that touches host internals. It is spawned by
5
+ // scripts/mount-smoke.mjs against the pinned @deepseek-ai/dsh install, so the
6
+ // entry points below are the ones `dsh` itself calls: `runProfile` composes the
7
+ // profile's patch layers, mounts the tree, and hands back the root context.
8
+ // No model call, no browser, no session.
9
+ //
10
+ // Usage: node probe.mjs --home <DSH_HOME> --host <host-install> --profile <name> --out <json>
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import { pathToFileURL } from 'node:url'
14
+
15
+ function parseArgs(argv) {
16
+ const options = {}
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const key = argv[index]
19
+ const value = argv[index + 1]
20
+ if (!key.startsWith('--') || value === undefined) throw new Error(`probe: bad argument ${JSON.stringify(key)}`)
21
+ options[key.slice(2)] = value
22
+ index += 1
23
+ }
24
+ for (const required of ['home', 'host', 'profile', 'out']) {
25
+ if (!options[required]) throw new Error(`probe: --${required} is required`)
26
+ }
27
+ return options
28
+ }
29
+
30
+ const options = parseArgs(process.argv.slice(2))
31
+ const home = path.resolve(options.home)
32
+ const host = path.resolve(options.host)
33
+
34
+ // Read by dsh-home-paths through the host's own module graph, so this must be
35
+ // set before anything from the host is imported.
36
+ process.env.DSH_HOME = home
37
+ process.env.DSH_TELEMETRY_DISABLED = '1'
38
+
39
+ const hostPackage = path.join(host, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
40
+ const hostVersion = JSON.parse(fs.readFileSync(hostPackage, 'utf8')).version
41
+ const profileBoot = path.join(host, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'profile-boot.js')
42
+ const appBoot = path.join(host, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'lib', 'index.js')
43
+ for (const file of [profileBoot, appBoot]) {
44
+ if (!fs.existsSync(file)) throw new Error(`probe: ${file} is missing; host ${hostVersion} does not expose the loader entry point`)
45
+ }
46
+
47
+ const { runProfile } = await import(pathToFileURL(profileBoot).href)
48
+ const { loadLayeredEnv } = await import(pathToFileURL(appBoot).href)
49
+ if (typeof runProfile !== 'function') throw new Error(`probe: host ${hostVersion} does not export runProfile`)
50
+
51
+ /** Run one surface read, keeping a missing service a reportable value. */
52
+ async function read(run) {
53
+ try {
54
+ return { value: await run() }
55
+ } catch (error) {
56
+ return { error: String(error?.message ?? error) }
57
+ }
58
+ }
59
+
60
+ const { ctx, shutdown } = await runProfile({
61
+ environment: loadLayeredEnv('dsh'),
62
+ profile: options.profile,
63
+ patchFiles: [],
64
+ args: [],
65
+ })
66
+
67
+ const surface = { host: { version: hostVersion }, profile: {}, tools: null, skills: null, presets: null, commands: null, tree: null }
68
+ try {
69
+ const profileContext = ctx.get('profileContext')
70
+ surface.profile = {
71
+ name: options.profile,
72
+ dir: profileContext?.dir ?? path.join(home, 'profiles', options.profile),
73
+ patchPath: profileContext?.patchPath ?? null,
74
+ }
75
+
76
+ const tools = ctx.get('tools')
77
+ surface.tools = tools
78
+ ? await read(() => tools.schemas().map((tool) => tool.name))
79
+ : { error: 'the tools service is not mounted' }
80
+
81
+ const skills = ctx.get('skills')
82
+ surface.skills = skills
83
+ ? await read(async () => {
84
+ const all = await skills.list()
85
+ return { tower: all.filter((skill) => skill.provider === 'dsh-tower').map((skill) => skill.name), total: all.length }
86
+ })
87
+ : { error: 'the skills service is not mounted' }
88
+
89
+ const presets = ctx.get('agentPresets')
90
+ surface.presets = presets
91
+ ? await read(async () => (await presets.list()).map((preset) => preset.id))
92
+ : { error: 'the agentPresets service is not mounted (profile has no preset registry)' }
93
+
94
+ const commands = ctx.get('commands')
95
+ surface.commands = commands
96
+ ? await read(() => commands.list(undefined).map((command) => command.name))
97
+ : { error: 'the commands service is not mounted' }
98
+
99
+ const loader = ctx.get('loader')
100
+ surface.tree = loader
101
+ ? await read(() => {
102
+ // `boot` mounts the profile behind one root include plugin; the composed
103
+ // rows live in that include's subtree. Stay correct if the root ever
104
+ // carries the tree directly.
105
+ const includeRow = loader.root.data.find((row) => row && row.name === 'cordis:include')
106
+ const tree = (includeRow && loader.store[includeRow.id]?.subtree) || loader
107
+ const top = tree.root.data.map((row) => ({
108
+ id: row?.id ?? null,
109
+ name: row?.name ?? null,
110
+ group: row?.group === true,
111
+ disabled: row?.disabled === true,
112
+ childIds: Array.isArray(row?.config)
113
+ ? row.config.map((child) => (child && typeof child === 'object' && child.id !== undefined ? String(child.id) : null)).filter(Boolean)
114
+ : [],
115
+ }))
116
+ const states = Object.fromEntries(Object.entries(tree.store).map(([id, entry]) => [id, entry?.fiber?.state ?? null]))
117
+ return { top, states }
118
+ })
119
+ : { error: 'the loader service is not mounted' }
120
+ } finally {
121
+ await shutdown.shutdown(0)
122
+ }
123
+
124
+ fs.writeFileSync(options.out, `${JSON.stringify(surface, null, 2)}\n`)
125
+ process.exit(0)