dsh-tower 2.3.0 → 2.4.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 (69) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +17 -3
  4. package/PRINCIPLES.md +18 -0
  5. package/README.en.md +29 -16
  6. package/README.md +22 -15
  7. package/cordis.patch.yml +56 -28
  8. package/docs/README.md +2 -1
  9. package/docs/adr/0004-surface-budget.md +20 -0
  10. package/docs/adr/0005-component-surface.md +130 -0
  11. package/docs/adr/README.md +1 -0
  12. package/docs/agents/workflow.md +1 -1
  13. package/docs/architecture/overview.md +25 -7
  14. package/docs/guide/hot-reload.md +4 -4
  15. package/docs/guide/install-upgrade-migration.md +13 -0
  16. package/docs/operations/security.md +1 -1
  17. package/docs/reference/component-surface.md +135 -0
  18. package/docs/reference/config.generated.md +2 -10
  19. package/docs/reference/config.md +11 -9
  20. package/docs/reference/doctor.md +14 -9
  21. package/docs/reference/surface-budget.md +52 -23
  22. package/docs/reference/tools.generated.md +4 -2
  23. package/docs/reference/tools.md +6 -3
  24. package/docs/zh/README.md +1 -1
  25. package/docs/zh/guide/hot-reload.md +3 -3
  26. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  27. package/docs/zh/operations/security.md +1 -1
  28. package/docs/zh/reference/config.md +9 -9
  29. package/docs/zh/reference/tools.md +6 -4
  30. package/domain/components.js +641 -0
  31. package/domain/config/schema.js +37 -27
  32. package/domain/contracts/outputs.js +10 -0
  33. package/domain/errors.js +11 -1
  34. package/domain/mission.js +12 -1
  35. package/domain/surface.js +154 -55
  36. package/host/capability-batch.js +15 -0
  37. package/host/capability-hmr.js +30 -0
  38. package/host/capability-import.js +11 -0
  39. package/host/capability-leases.js +11 -0
  40. package/host/capability-registry.js +149 -0
  41. package/host/capability-tests.js +16 -0
  42. package/host/doctor.js +41 -9
  43. package/host/hmr.js +2 -4
  44. package/host/presets.js +51 -9
  45. package/host/tool-registry.js +150 -34
  46. package/index.js +32 -20
  47. package/locale/en.json +31 -11
  48. package/locale/zh.json +30 -10
  49. package/package.json +16 -3
  50. package/scripts/check-components.mjs +235 -0
  51. package/scripts/component-surface.json +395 -0
  52. package/scripts/generate-component-surface.mjs +59 -0
  53. package/scripts/generate-tools-reference.mjs +7 -25
  54. package/scripts/generate-types.mjs +127 -0
  55. package/scripts/lib/tool-surface.mjs +85 -0
  56. package/scripts/mount-smoke/fixtures.mjs +3 -3
  57. package/scripts/mount-smoke/surface.mjs +96 -27
  58. package/scripts/mount-smoke.mjs +3 -1
  59. package/scripts/pack-manifest.txt +17 -0
  60. package/scripts/surface-baseline.json +35 -4
  61. package/scripts/surface-baseline.mjs +32 -34
  62. package/scripts/types-coverage.mjs +184 -0
  63. package/scripts/types-strict-manifest.txt +10 -0
  64. package/scripts/types-strict.mjs +141 -0
  65. package/skills.js +8 -1
  66. package/tools/index.js +2 -1
  67. package/tools/reload.js +34 -35
  68. package/tools/status.js +8 -1
  69. package/types/index.d.ts +28 -12
@@ -0,0 +1,184 @@
1
+ // Type-coverage fitness rule (#115, step 1).
2
+ //
3
+ // The checked program must be derived from the shipped program: every runtime
4
+ // `.js` file that `package.json` publishes has to appear in the `tsc` program,
5
+ // otherwise "this file is unchecked" cannot even be reported. Today the
6
+ // tsconfig `include` list is hand-written, so a new module outside it is
7
+ // invisible by construction; this gate makes that gap a red check.
8
+ //
9
+ // Run: node scripts/types-coverage.mjs [--root <dir>] [--json]
10
+ //
11
+ // The shipped set comes from `package.json` `files` (the same list `npm pack`
12
+ // uses; scripts/pack-manifest.txt pins the reviewed snapshot). The checked set
13
+ // comes from `tsc --noEmit --listFiles`, so no second enumeration is involved:
14
+ // changing the tsconfig is enough and this gate follows.
15
+ //
16
+ // Excluded tiers are data with a reason, not silent gaps:
17
+ // - `ui-bundle/**` is the optional Web client: a browser artifact with DOM
18
+ // types and no Node runtime, checked by tests/ui-bundle.test.mjs and the
19
+ // architecture client rules instead of the Node tsc program;
20
+ // - `scripts/**` is build/dev tooling (ESM `.mjs`, not plugin runtime).
21
+ import { execFileSync } from 'node:child_process'
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url))
27
+ const SELF_ROOT = path.resolve(MODULE_DIR, '..')
28
+ const TSC_BIN = path.join(SELF_ROOT, 'node_modules', 'typescript', 'bin', 'tsc')
29
+ const WALK_SKIP = new Set(['node_modules', '.git', '.dsh-tower-worktrees', 'coverage', '.nyc_output'])
30
+
31
+ /** Shipped `.js` tiers that are deliberately outside the Node tsc program. */
32
+ export const NOT_CHECKED_TIERS = [
33
+ {
34
+ pattern: 'ui-bundle/**',
35
+ reason: 'optional Web client: browser artifact, checked by tests/ui-bundle.test.mjs and the client architecture rules',
36
+ },
37
+ {
38
+ pattern: 'scripts/**',
39
+ reason: 'build/dev tooling, not plugin runtime',
40
+ },
41
+ ]
42
+
43
+ /** Expand one package.json `files` pattern into repo-relative paths. */
44
+ export function globToRegExp(pattern) {
45
+ const source = String(pattern)
46
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
47
+ .replace(/\*\*/g, '\u0000')
48
+ .replace(/\*/g, '[^/]*')
49
+ .replace(/\?/g, '[^/]')
50
+ .replace(/\u0000\//g, '(?:[^/]+/)*')
51
+ .replace(/\u0000/g, '.*')
52
+ return new RegExp(`^${source}$`)
53
+ }
54
+
55
+ function walkFiles(root, relative = '') {
56
+ const dir = path.join(root, relative)
57
+ if (!fs.existsSync(dir)) return []
58
+ const out = []
59
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
60
+ if (WALK_SKIP.has(entry.name)) continue
61
+ const next = relative ? `${relative}/${entry.name}` : entry.name
62
+ if (entry.isDirectory()) out.push(...walkFiles(root, next))
63
+ else out.push(next)
64
+ }
65
+ return out
66
+ }
67
+
68
+ /**
69
+ * Resolve `package.json` `files` into the repo-relative file list.
70
+ * A trailing slash (or a bare directory name) means the whole directory, the
71
+ * same reading `npm pack` gives it.
72
+ * @param {string[]} patterns
73
+ * @param {string[]} files every repo-relative file
74
+ * @returns {string[]}
75
+ */
76
+ export function expandShipped(patterns, files) {
77
+ const out = new Set()
78
+ for (const pattern of patterns) {
79
+ if (typeof pattern !== 'string' || pattern.length === 0) continue
80
+ const normalized = pattern.endsWith('/') ? `${pattern}**` : pattern
81
+ const matcher = globToRegExp(normalized)
82
+ const directory = !/\*|\?/.test(normalized) && files.some((file) => file.startsWith(`${normalized}/`))
83
+ const hit = (file) => (directory ? file === normalized || file.startsWith(`${normalized}/`) : matcher.test(file))
84
+ for (const file of files) if (hit(file)) out.add(file)
85
+ }
86
+ return [...out].sort()
87
+ }
88
+
89
+ /**
90
+ * The Node-runtime slice of the shipped set: `.js` files minus the documented
91
+ * non-Node tiers.
92
+ * @param {string[]} shipped
93
+ * @returns {{ runtime: string[], excluded: Array<{ file: string, reason: string }> }}
94
+ */
95
+ export function runtimeJavaScript(shipped) {
96
+ const excluded = []
97
+ const runtime = []
98
+ for (const file of shipped) {
99
+ if (!file.endsWith('.js')) continue
100
+ const tier = NOT_CHECKED_TIERS.find((entry) => globToRegExp(entry.pattern).test(file))
101
+ if (tier) excluded.push({ file, reason: tier.reason })
102
+ else runtime.push(file)
103
+ }
104
+ return { runtime: runtime.sort(), excluded }
105
+ }
106
+
107
+ /**
108
+ * Compare the shipped runtime set with the checked program. One direction only:
109
+ * a shipped file outside the program fails. Extra program files (tests/*.ts,
110
+ * the declaration files, fixture helpers) are expected and allowed.
111
+ * @param {{ runtime: string[], program: string[] }} input
112
+ * @returns {{ ok: boolean, missing: string[], checked: number, total: number }}
113
+ */
114
+ export function evaluateCoverage({ runtime, program }) {
115
+ const checked = new Set(program)
116
+ const missing = runtime.filter((file) => !checked.has(file))
117
+ return { ok: missing.length === 0, missing, checked: runtime.length - missing.length, total: runtime.length }
118
+ }
119
+
120
+ /** List the project files (never node_modules) in the `tsc` program. */
121
+ function programFiles(root) {
122
+ const config = path.join(root, 'tsconfig.json')
123
+ if (!fs.existsSync(config)) throw new Error(`no tsconfig.json under ${root}`)
124
+ if (!fs.existsSync(TSC_BIN)) throw new Error('typescript is not installed; run npm ci first')
125
+ let output
126
+ try {
127
+ output = execFileSync(process.execPath, [TSC_BIN, '--noEmit', '--listFiles', '-p', config], {
128
+ cwd: root,
129
+ encoding: 'utf8',
130
+ stdio: ['ignore', 'pipe', 'pipe'],
131
+ })
132
+ } catch (error) {
133
+ // A program with type errors still lists its files; only a config failure
134
+ // (no input at all) is fatal for this gate. Keep diagnostics out of the
135
+ // parse by only accepting existing absolute paths.
136
+ output = String(error?.stdout ?? '')
137
+ if (!output.trim()) throw new Error(`tsc --listFiles failed: ${String(error?.stderr ?? error?.message ?? error)}`)
138
+ }
139
+ const files = new Set()
140
+ for (const raw of output.split('\n')) {
141
+ const line = raw.trim()
142
+ if (!line || !path.isAbsolute(line)) continue
143
+ if (!fs.existsSync(line)) continue
144
+ const relative = path.relative(root, line).split(path.sep).join('/')
145
+ if (!relative || relative.startsWith('..') || relative.startsWith('node_modules/')) continue
146
+ if (relative.includes('/node_modules/')) continue
147
+ files.add(relative)
148
+ }
149
+ return [...files].sort()
150
+ }
151
+
152
+ function main() {
153
+ const rootArg = process.argv.indexOf('--root')
154
+ const root = rootArg > 0 ? path.resolve(process.argv[rootArg + 1]) : SELF_ROOT
155
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
156
+ const shipped = expandShipped(Array.isArray(pkg.files) ? pkg.files : [], walkFiles(root))
157
+ const { runtime, excluded } = runtimeJavaScript(shipped)
158
+ let program
159
+ try {
160
+ program = programFiles(root)
161
+ } catch (error) {
162
+ console.error(`types coverage: ${String(error?.message ?? error)}`)
163
+ process.exit(1)
164
+ }
165
+ const result = evaluateCoverage({ runtime, program })
166
+ if (process.argv.includes('--json')) {
167
+ console.log(JSON.stringify({ ...result, program: program.length, excluded }, null, 2))
168
+ } else if (result.ok) {
169
+ console.log(
170
+ `types coverage: ${result.checked}/${result.total} shipped runtime .js files are in the tsc program` +
171
+ ` (${program.length} program files, ${excluded.length} excluded: ${[...new Set(excluded.map((entry) => entry.file.split('/')[0]))].join(', ') || 'none'})`,
172
+ )
173
+ } else {
174
+ console.error(
175
+ `types coverage: ${result.missing.length} shipped runtime .js file(s) are outside the tsc program:`,
176
+ )
177
+ for (const file of result.missing) console.error(` - ${file}`)
178
+ console.error('add the file (or its directory glob) to tsconfig.json `include`')
179
+ }
180
+ process.exit(result.ok ? 0 : 1)
181
+ }
182
+
183
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
184
+ if (isMain) main()
@@ -0,0 +1,10 @@
1
+ domain/contracts/outputs.js
2
+ domain/contracts/tool-inputs/collaboration.js
3
+ domain/contracts/tool-inputs/findings.js
4
+ domain/contracts/tool-inputs/index.js
5
+ domain/contracts/tool-inputs/integration.js
6
+ domain/contracts/tool-inputs/missions.js
7
+ domain/contracts/tool-inputs/ops.js
8
+ domain/contracts/tool-inputs/shared.js
9
+ types/cordis.d.ts
10
+ types/index.d.ts
@@ -0,0 +1,141 @@
1
+ // Strict type ratchet (#115, step 4).
2
+ //
3
+ // `tsconfig.strict.json` turns `noImplicitAny: true` on for an explicit,
4
+ // growing file list. The list is the ratchet: CI runs the strict config, and
5
+ // this gate makes the list a reviewed artifact instead of a file that can
6
+ // silently shrink.
7
+ //
8
+ // Run: node scripts/types-strict.mjs [--write] [--allow-shrink] [--root <dir>]
9
+ //
10
+ // * no flag — check: every listed file exists, every file in the
11
+ // seed tier (`domain/contracts/**`) is listed, and the
12
+ // reviewed snapshot (scripts/types-strict-manifest.txt)
13
+ // matches the list exactly.
14
+ // * --write — record the current list in the snapshot. Removing an
15
+ // entry additionally needs `--allow-shrink`, so shrinking
16
+ // the ratchet is a deliberate, visible decision.
17
+ //
18
+ // The list must stay explicit files: a glob would let a new file enter the
19
+ // strict program without a reviewed one-line diff, which is the opposite of a
20
+ // ratchet. `domain/contracts/**` is the seed tier: adding a file there fails
21
+ // the check until the list and the snapshot name it.
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
27
+ export const MANIFEST = 'scripts/types-strict-manifest.txt'
28
+ const SEED_DIRECTORIES = ['domain/contracts']
29
+
30
+ function walk(root, relative) {
31
+ const dir = path.join(root, relative)
32
+ if (!fs.existsSync(dir)) return []
33
+ const out = []
34
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
35
+ const next = `${relative}/${entry.name}`
36
+ if (entry.isDirectory()) out.push(...walk(root, next))
37
+ else out.push(next)
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Entries that are not repo-relative explicit files. @param {unknown} include */
43
+ export function invalidEntries(include) {
44
+ if (!Array.isArray(include) || include.length === 0) return ['include must be a non-empty array of files']
45
+ const problems = []
46
+ for (const entry of include) {
47
+ if (typeof entry !== 'string' || entry.length === 0) {
48
+ problems.push(`${JSON.stringify(entry)} is not a non-empty path`)
49
+ continue
50
+ }
51
+ if (path.isAbsolute(entry) || entry.includes('\\') || entry.split('/').includes('..')) {
52
+ problems.push(`${entry}: use a repo-relative path with forward slashes`)
53
+ } else if (/[*?]/.test(entry)) {
54
+ problems.push(`${entry}: the strict list must name explicit files, not a glob`)
55
+ }
56
+ }
57
+ return problems
58
+ }
59
+
60
+ /** @param {{ include: string[], manifest: string[] }} input */
61
+ export function compareLists({ include, manifest }) {
62
+ const listed = new Set(include)
63
+ const pinned = new Set(manifest)
64
+ return {
65
+ lost: manifest.filter((file) => !listed.has(file)).sort(),
66
+ grew: include.filter((file) => !pinned.has(file)).sort(),
67
+ }
68
+ }
69
+
70
+ /** Every seed-tier file the strict list must name. */
71
+ function seedFiles(root) {
72
+ return SEED_DIRECTORIES.flatMap((directory) => walk(root, directory)).filter((file) => file.endsWith('.js')).sort()
73
+ }
74
+
75
+ function readManifest(root) {
76
+ const file = path.join(root, MANIFEST)
77
+ if (!fs.existsSync(file)) return []
78
+ return fs.readFileSync(file, 'utf8').split('\n').map((line) => line.trim()).filter(Boolean)
79
+ }
80
+
81
+ function fail(lines) {
82
+ console.error('types strict: the ratchet would shrink or drift:')
83
+ for (const line of lines) console.error(` - ${line}`)
84
+ process.exit(1)
85
+ }
86
+
87
+ function main() {
88
+ const rootArg = process.argv.indexOf('--root')
89
+ const root = rootArg > 0 ? path.resolve(process.argv[rootArg + 1]) : SELF_ROOT
90
+ const write = process.argv.includes('--write')
91
+ const allowShrink = process.argv.includes('--allow-shrink')
92
+ const configFile = path.join(root, 'tsconfig.strict.json')
93
+
94
+ if (!fs.existsSync(configFile)) {
95
+ console.error(`types strict: ${path.relative(process.cwd(), configFile)} is missing`)
96
+ process.exit(1)
97
+ }
98
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'))
99
+ if (config?.compilerOptions?.noImplicitAny !== true) {
100
+ console.error('types strict: tsconfig.strict.json must set compilerOptions.noImplicitAny: true explicitly')
101
+ process.exit(1)
102
+ }
103
+ const include = Array.isArray(config.include) ? config.include : []
104
+ const invalid = invalidEntries(include)
105
+ if (invalid.length > 0) fail(invalid)
106
+
107
+ const missingFiles = include.filter((file) => !fs.existsSync(path.join(root, file)))
108
+ if (missingFiles.length > 0) fail(missingFiles.map((file) => `${file}: listed in tsconfig.strict.json but does not exist`))
109
+
110
+ const seeds = seedFiles(root)
111
+ const unlistedSeeds = seeds.filter((file) => !include.includes(file))
112
+ if (unlistedSeeds.length > 0) {
113
+ fail(unlistedSeeds.map((file) => `${file}: in the seed tier but not in tsconfig.strict.json include`))
114
+ }
115
+
116
+ const manifest = readManifest(root)
117
+ const { lost, grew } = compareLists({ include, manifest })
118
+ if (write) {
119
+ if (lost.length > 0 && !allowShrink) {
120
+ fail([
121
+ ...lost.map((file) => `${file}: pinned in ${MANIFEST} but missing from tsconfig.strict.json include`),
122
+ 'pass --allow-shrink to record a deliberate removal',
123
+ ])
124
+ }
125
+ fs.mkdirSync(path.join(root, 'scripts'), { recursive: true })
126
+ fs.writeFileSync(path.join(root, MANIFEST), `${[...include].sort().join('\n')}\n`)
127
+ console.log(`types strict: wrote ${include.length} files to ${MANIFEST} (${lost.length} removed, ${grew.length} added)`)
128
+ return
129
+ }
130
+
131
+ if (lost.length > 0 || grew.length > 0) {
132
+ fail([
133
+ ...lost.map((file) => `${file}: pinned in ${MANIFEST} but no longer checked (the ratchet must not shrink)`),
134
+ ...grew.map((file) => `${file}: newly checked but not pinned; run npm run types:strict:write to record it`),
135
+ ])
136
+ }
137
+ console.log(`types strict: ${include.length} files pinned, noImplicitAny over ${SEED_DIRECTORIES.join(', ')}/**`)
138
+ }
139
+
140
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
141
+ if (isMain) main()
package/skills.js CHANGED
@@ -3,9 +3,12 @@
3
3
  // the short system-prompt sections stay authoritative for safety rules.
4
4
  import { readFile } from 'node:fs/promises'
5
5
  import { fileURLToPath } from 'node:url'
6
+ import { holdComponent } from './host/capability-registry.js'
6
7
 
7
8
  export const name = 'dsh-tower/skills'
8
- export const inject = ['skills']
9
+ // #121: an optional capability row still depends on the host service, so a
10
+ // composition with the host group off cannot leave an orphan skill provider.
11
+ export const inject = ['skills', 'tower']
9
12
 
10
13
  const ASSETS = fileURLToPath(new URL('./skills/', import.meta.url))
11
14
 
@@ -59,5 +62,9 @@ const provider = {
59
62
  }
60
63
 
61
64
  export function apply(ctx) {
65
+ // #121: tower-skills is an optional component row. Registering here is what
66
+ // lets tower_status/tower_doctor report "disabled by component tower-skills"
67
+ // when the row is off instead of leaving the missing catalog unexplained.
68
+ holdComponent(ctx, 'tower-skills')
62
69
  ctx.skills.registerProvider(() => provider)
63
70
  }
package/tools/index.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // - define(ctx, definition) accepts a TypeBox parameters schema;
5
5
  // - execute(args) is typed from that schema by Static<>;
6
6
  // - aliases and requiresMissionId are honored by the registry;
7
- // - operational switches decide whether a tool is registered;
7
+ // - capability ownership decides whether a tool is registered; the component
8
+ // rows in cordis.patch.yml are the switches (#121);
8
9
  // - errors are classified to stable [tower:code] values;
9
10
  // - tools must not import node:fs or node:child_process directly.
10
11
  //
package/tools/reload.js CHANGED
@@ -1,4 +1,8 @@
1
1
  // tower_reload: dev-only HMR reload request.
2
+ //
3
+ // #121: this tool is the `tower-hmr` component. It is registered only by that
4
+ // component's entry point, so the row is the switch; there is no second
5
+ // `dev.hotReload` boolean to disagree with it.
2
6
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
3
7
  import { TowerToolError } from '../domain/errors.js'
4
8
  import { ReloadInput } from '../domain/contracts/tool-inputs/index.js'
@@ -9,41 +13,36 @@ import { ReloadInput } from '../domain/contracts/tool-inputs/index.js'
9
13
  */
10
14
  export function registerReloadTool(ctx, deps) {
11
15
  const { define, actorFor, requireTowerActor, sharedHmrBridge, runtimeConfig, createHmrBridge } = deps
12
- // Dev-only hot reload. The tool is registered whenever dev.hotReload is on;
13
- // it reports reload-unavailable when the profile has no HMR service. When the
14
- // Host passed a shared bridge, its status() reflects the command's reloads too.
15
16
  const dev = runtimeConfig().dev ?? {}
16
- if (dev.hotReload !== false) {
17
- const bridge =
18
- sharedHmrBridge ??
19
- createHmrBridge(ctx, {
20
- requiredIds: dev.reloadRequiredIds,
21
- delayMs: dev.reloadDelayMs,
22
- logger: ctx.logger ?? console,
23
- })
24
- define(ctx, {
25
- name: 'tower_reload',
26
- description:
27
- 'Dev-only: ask DSH HMR to reconcile this profile and reload the Tower Host plugin from disk. The reload is scheduled a few milliseconds after this call returns (so the result reaches the model first). Use after editing the plugin source in a linked profile; plugin_manager install/update already triggers reconciliation by itself. Registered while dev.hotReload is on; fails with reload-unavailable when the HMR service is absent.',
28
- parameters: ReloadInput,
29
- output: output(SCHEMAS.reload),
30
- async execute(args, exec) {
31
- if (typeof args.repoRoot === 'string') {
32
- const tower = await deps.openTower(args.repoRoot)
33
- const actor = await actorFor(tower, exec)
34
- requireTowerActor(actor, 'reload the Tower plugin')
35
- }
36
- if (!bridge.available) {
37
- throw new TowerToolError('reload-unavailable', 'this DSH profile has no HMR service; restart DSH instead')
38
- }
39
- const outcome = bridge.schedule()
40
- return {
41
- ok: true,
42
- ...outcome,
43
- ...(outcome.lastError ? { error: outcome.lastError } : {}),
44
- restartHint: 'If the new code does not appear, restart DSH or check the profile hmr entry.',
45
- }
46
- },
17
+ const bridge =
18
+ sharedHmrBridge ??
19
+ createHmrBridge(ctx, {
20
+ requiredIds: dev.reloadRequiredIds,
21
+ delayMs: dev.reloadDelayMs,
22
+ logger: ctx.logger ?? console,
47
23
  })
48
- }
24
+ define(ctx, {
25
+ name: 'tower_reload',
26
+ description:
27
+ 'Dev-only: ask DSH HMR to reconcile this profile and reload the Tower Host plugin from disk. Registered only while the tower-hmr component is on (the row is shipped off). The reload is scheduled a few milliseconds after this call returns (so the result reaches the model first). Use after editing the plugin source in a linked profile; plugin_manager install/update already triggers reconciliation by itself. Fails with reload-unavailable when the HMR service is absent.',
28
+ parameters: ReloadInput,
29
+ output: output(SCHEMAS.reload),
30
+ async execute(args, exec) {
31
+ if (typeof args.repoRoot === 'string') {
32
+ const tower = await deps.openTower(args.repoRoot)
33
+ const actor = await actorFor(tower, exec)
34
+ requireTowerActor(actor, 'reload the Tower plugin')
35
+ }
36
+ if (!bridge.available) {
37
+ throw new TowerToolError('reload-unavailable', 'this DSH profile has no HMR service; restart DSH instead')
38
+ }
39
+ const outcome = bridge.schedule()
40
+ return {
41
+ ok: true,
42
+ ...outcome,
43
+ ...(outcome.lastError ? { error: outcome.lastError } : {}),
44
+ restartHint: 'If the new code does not appear, restart DSH or check the profile hmr entry.',
45
+ }
46
+ },
47
+ })
49
48
  }
package/tools/status.js CHANGED
@@ -13,10 +13,11 @@ import { archiveView } from '../app/archive.js'
13
13
  * workspaceHook: any,
14
14
  * spawnBackoffView: any,
15
15
  * observeActivity: any,
16
+ * componentReport: (ctx: any) => Promise<{ enabled: string[], disabled: Array<{ id: string, reason: string }> }>,
16
17
  * }} deps
17
18
  */
18
19
  export function registerStatusTool(ctx, deps) {
19
- const { define, workspaceHook, spawnBackoffView, observeActivity } = deps
20
+ const { define, workspaceHook, spawnBackoffView, observeActivity, componentReport } = deps
20
21
  define(ctx, {
21
22
  name: 'tower_status',
22
23
  description:
@@ -26,6 +27,10 @@ export function registerStatusTool(ctx, deps) {
26
27
  output: output(SCHEMAS.status),
27
28
  async execute(args, exec) {
28
29
  const tower = await deps.openTower(args.repoRoot)
30
+ // #121: report the mounted optional components, including the exact
31
+ // "disabled by component <id>" reason. A capability that is off must be a
32
+ // visible fact of the dashboard, never a silent absence from the tools.
33
+ const components = await componentReport(ctx)
29
34
  const boardPath = tower.boardFile()
30
35
  const mode = workspaceHook?.modeInfo?.(exec) ?? null
31
36
  const statusState = await tower.load()
@@ -46,6 +51,7 @@ export function registerStatusTool(ctx, deps) {
46
51
  boardPath,
47
52
  mode,
48
53
  spawn,
54
+ components,
49
55
  }
50
56
  }
51
57
  const state = await tower.load()
@@ -90,6 +96,7 @@ export function registerStatusTool(ctx, deps) {
90
96
  boardPath,
91
97
  mode,
92
98
  spawn,
99
+ components,
93
100
  }
94
101
  if (isMarkdownFormat(args.format)) {
95
102
  const state = await tower.load()
package/types/index.d.ts CHANGED
@@ -6,16 +6,35 @@
6
6
  // `tsc --noEmit` (checkJs). Keep this file free of value imports so consumers
7
7
  // can load it without `allowJs`.
8
8
 
9
+ // ---------------------------------------------------------------------------
10
+ // Generated vocabulary (#115): one declaration per runtime constant family.
11
+ // `npm run types:generate` rewrites the marked block from ERROR_CODES,
12
+ // MISSION_STATUSES, REVIEW_STATUSES, MERGE_VERDICTS, MISSION_KINDS,
13
+ // REVIEW_POLICIES, FINDING_*, ROLES and PAUSE_CAUSES; the generated-types gate
14
+ // (`npm run docs:check:generated:types`) fails when the block drifts. Do not
15
+ // edit the block by hand.
16
+ // generated:vocabulary:start
9
17
  export type MissionStatus = 'planned' | 'active' | 'completed' | 'blocked' | 'paused' | 'merged' | 'abandoned'
10
18
  export type MissionKind = 'build' | 'survey'
11
- export type ReviewStatus = 'clean' | 'p1-1items' | 'p1-2items' | 'p1-3items' | 'p1-4items' | 'p1-5items' | 'p2-1items' | 'p2-2items' | 'p2-3items' | 'p2-4items' | 'p2-5items'
19
+ export type ReviewStatus =
20
+ | 'clean'
21
+ | 'p1-1items'
22
+ | 'p1-2items'
23
+ | 'p1-3items'
24
+ | 'p1-4items'
25
+ | 'p1-5items'
26
+ | 'p2-1items'
27
+ | 'p2-2items'
28
+ | 'p2-3items'
29
+ | 'p2-4items'
30
+ | 'p2-5items'
12
31
  export type MergeVerdict = 'merge' | 'fix-then-merge' | 'hold'
13
32
  export type ReviewPolicy = 'required-clean' | 'none'
14
33
  export type FindingType = 'bug' | 'improve' | 'vuln' | 'idea'
15
34
  export type FindingSeverity = 'low' | 'medium' | 'high' | 'critical'
16
35
  export type FindingStatus = 'open' | 'triaged' | 'fixed' | 'wontfix'
17
36
  export type Role = 'worker' | 'reviewer'
18
-
37
+ export type PauseCause = 'user' | 'failure' | 'resource' | 'dependency'
19
38
  export type ErrorCode =
20
39
  | 'invalid-argument'
21
40
  | 'repo-invalid'
@@ -23,26 +42,22 @@ export type ErrorCode =
23
42
  | 'mission-not-found'
24
43
  | 'finding-not-found'
25
44
  | 'invalid-transition'
45
+ | 'stale-base'
26
46
  | 'state-locked'
27
47
  | 'state-corrupt'
48
+ | 'state-version-unsupported'
49
+ | 'state-migration-failed'
28
50
  | 'config-invalid'
29
51
  | 'config-unknown-key'
52
+ | 'git-failed'
30
53
  | 'git-timeout'
31
- | 'merge-conflict'
32
- | 'merge-hook-failed'
33
- | 'merge-intent-pending'
34
- | 'base-dirty'
35
- | 'stale-base'
36
- | 'tip-moved'
37
- | 'out-of-scope'
38
- | 'worker-busy'
39
- | 'worker-stale'
54
+ | 'worktree-dirty'
40
55
  | 'spawn-unavailable'
41
56
  | 'provider-missing'
42
57
  | 'provider-cooldown'
43
58
  | 'preset-missing'
44
59
  | 'reviewer-prerequisite'
45
- | 'worktree-dirty'
60
+ | 'not-authorized'
46
61
  | 'workspace-busy'
47
62
  | 'worker-limit'
48
63
  | 'rate-limited'
@@ -56,6 +71,7 @@ export type ErrorCode =
56
71
  | 'batch-invalid'
57
72
  | 'legacy-invalid'
58
73
  | 'tool-internal'
74
+ // generated:vocabulary:end
59
75
 
60
76
  /** One machine-readable gate/completion blocker: a stable code plus a detail. */
61
77
  export interface Blocker {