dsh-tower 2.3.0 → 2.4.1

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 (83) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +20 -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 +69 -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/batch/en.json +6 -0
  48. package/locale/batch/zh.json +6 -0
  49. package/locale/en.json +31 -11
  50. package/locale/hmr/en.json +6 -0
  51. package/locale/hmr/zh.json +6 -0
  52. package/locale/import/en.json +6 -0
  53. package/locale/import/zh.json +6 -0
  54. package/locale/leases/en.json +6 -0
  55. package/locale/leases/zh.json +6 -0
  56. package/locale/skills/en.json +6 -0
  57. package/locale/skills/zh.json +6 -0
  58. package/locale/tests/en.json +6 -0
  59. package/locale/tests/zh.json +6 -0
  60. package/locale/zh.json +30 -10
  61. package/package.json +25 -4
  62. package/scripts/check-components.mjs +235 -0
  63. package/scripts/check-pack.mjs +4 -0
  64. package/scripts/component-surface.json +395 -0
  65. package/scripts/generate-capability-locale.mjs +148 -0
  66. package/scripts/generate-component-surface.mjs +59 -0
  67. package/scripts/generate-tools-reference.mjs +7 -25
  68. package/scripts/generate-types.mjs +127 -0
  69. package/scripts/lib/tool-surface.mjs +85 -0
  70. package/scripts/mount-smoke/fixtures.mjs +3 -3
  71. package/scripts/mount-smoke/surface.mjs +96 -27
  72. package/scripts/mount-smoke.mjs +3 -1
  73. package/scripts/pack-manifest.txt +30 -0
  74. package/scripts/surface-baseline.json +35 -4
  75. package/scripts/surface-baseline.mjs +32 -34
  76. package/scripts/types-coverage.mjs +184 -0
  77. package/scripts/types-strict-manifest.txt +10 -0
  78. package/scripts/types-strict.mjs +141 -0
  79. package/skills.js +8 -1
  80. package/tools/index.js +2 -1
  81. package/tools/reload.js +34 -35
  82. package/tools/status.js +8 -1
  83. package/types/index.d.ts +28 -12
package/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // plugin from disk when the HMR service is present (dev/linked installs).
13
13
  import fs from 'node:fs'
14
14
  import { randomUUID } from 'node:crypto'
15
- import { registerTowerTools } from './host/tool-registry.js'
15
+ import { registerCoreTowerTools, recordToolRegistration } from './host/tool-registry.js'
16
16
  import { createDefaultDeps } from './adapters/default.js'
17
17
  import { loadModes, saveModes, storePath } from './host/modeStore.js'
18
18
  import { ModeState } from './host/modeState.js'
@@ -26,8 +26,8 @@ import {
26
26
  touchWorkspaceLock,
27
27
  workspaceLockToken,
28
28
  } from './adapters/workspaceLock.js'
29
- import { createHmrBridge } from './host/hmr.js'
30
- import { assertPresetsAvailable } from './host/presets.js'
29
+ import { assertRuntimePreconditions } from './host/presets.js'
30
+ import { currentHmrBridge } from './host/capability-registry.js'
31
31
 
32
32
  export const name = 'dsh-tower'
33
33
  export const inject = ['tools', 'systemPrompt']
@@ -256,14 +256,6 @@ export function apply(ctx, rawConfig) {
256
256
  modeState.replace(loadModes(config.mode.storePath || undefined))
257
257
  if (typeof ctx.provide === 'function') ctx.provide('tower', modeState)
258
258
  denyTools = new Set(config.mode.denyTools)
259
- const hotReloadEnabled = config.dev?.hotReload !== false
260
- const hmrBridge = hotReloadEnabled
261
- ? createHmrBridge(ctx, {
262
- requiredIds: config.dev?.reloadRequiredIds,
263
- delayMs: config.dev?.reloadDelayMs,
264
- logger: ctx.logger ?? console,
265
- })
266
- : null
267
259
 
268
260
  const workspace = {
269
261
  /** Lazily acquire the advisory workspace lock on the first repo-scoped tool. */
@@ -346,12 +338,24 @@ export function apply(ctx, rawConfig) {
346
338
 
347
339
  // #69: the host entry is the composition root — the adapters are constructed
348
340
  // once here and injected into the tool layer and every Tower instance.
349
- registerTowerTools(ctx, { workspace, hmrBridge, deps: createDefaultDeps() })
341
+ // #121: only the mandatory host surface is registered here. The optional
342
+ // capability rows (batch/import/leases/tests/hmr) are separate loader entries
343
+ // and register their own tools, so a disabled row removes exactly one
344
+ // capability.
345
+ registerCoreTowerTools(ctx, { workspace, deps: createDefaultDeps() })
346
+
347
+ // #118/#121: tower_mode/tower_control are registered directly here instead of
348
+ // through define(), so record them through the same recorder the registry
349
+ // uses. tower_control is recorded before the allowAgentControl gate: the
350
+ // concept budget measures the surface this build can register, not only the
351
+ // tools one profile exposes (#84). Together with the capability-tool names the
352
+ // registry records before row gating, these are the shipped 29.
353
+ recordToolRegistration('tower_mode')
354
+ recordToolRegistration('tower_control')
350
355
 
351
356
  // On dispose (including an HMR reload) release advisory workspace locks this
352
357
  // process still owns; a reloaded plugin restores the entries from modes.json.
353
358
  ctx.effect(() => () => {
354
- if (hmrBridge) hmrBridge.dispose?.()
355
359
  for (const entry of modeState.values()) {
356
360
  if (entry?.lock) {
357
361
  releaseWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
@@ -451,7 +455,12 @@ export function apply(ctx, rawConfig) {
451
455
  deniedWhileActive: [...denyTools],
452
456
  persistedAt: storePath(config.mode.storePath || undefined),
453
457
  pluginVersion: readPluginVersion(),
454
- hotReload: hmrBridge ? hmrBridge.status() : { available: false, disabled: true },
458
+ hotReload: (() => {
459
+ // #121/#122: the tower-hmr component row owns the bridge. When the
460
+ // row is off the capability is reported, never silently absent.
461
+ const bridge = currentHmrBridge()
462
+ return bridge ? bridge.status() : { available: false, disabled: true, component: 'tower-hmr' }
463
+ })(),
455
464
  planMode: (() => {
456
465
  const state = planModeState(ctx, exec.agent)
457
466
  return { available: state.available, active: state.active, pending: state.pending }
@@ -507,7 +516,7 @@ export function apply(ctx, rawConfig) {
507
516
  }
508
517
  const reason = args.reason.trim()
509
518
  if (args.action === 'enter' && !modeOn(key)) {
510
- await assertPresetsAvailable(ctx, config)
519
+ await assertRuntimePreconditions(ctx, config)
511
520
  }
512
521
  const outcome =
513
522
  args.action === 'enter'
@@ -558,20 +567,23 @@ export function apply(ctx, rawConfig) {
558
567
  }
559
568
  const parsed = parseTowerInput(rawInput)
560
569
  if (parsed.action === 'reload') {
561
- if (!hotReloadEnabled) {
562
- return { kind: 'error', text: 'Tower reload is disabled by dev.hotReload:false; restart DSH instead.' }
570
+ // #121: /tower reload is the tower-hmr component. The row is shipped
571
+ // off, so the error names the component instead of an invisible key.
572
+ const bridge = currentHmrBridge()
573
+ if (!bridge) {
574
+ return { kind: 'error', text: 'Tower reload is disabled by component tower-hmr; enable the tower-hmr row in the plugin page and restart DSH.' }
563
575
  }
564
- if (!hmrBridge || !hmrBridge.available) {
576
+ if (!bridge.available) {
565
577
  return { kind: 'error', text: 'Tower reload is unavailable: this DSH profile has no HMR service. Restart DSH instead.' }
566
578
  }
567
- const outcome = hmrBridge.schedule()
579
+ const outcome = bridge.schedule()
568
580
  return {
569
581
  kind: 'success',
570
582
  text: `Tower reload scheduled in ${outcome.delayMs}ms (required entries: ${outcome.requiredIds.join(', ')}). If the new code does not appear, restart DSH.`,
571
583
  }
572
584
  }
573
585
  const { active, message } = parsed
574
- if (active) await assertPresetsAvailable(ctx, config)
586
+ if (active) await assertRuntimePreconditions(ctx, config)
575
587
  const outcome = active
576
588
  ? enterMode(ctx, key, agent, 'user', message || null)
577
589
  : await exitMode(key, 'user', message || null)
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Evidence batches",
4
+ "description": "Group missions into finite evidence batches and close them against required checks and receipts. When off: tower_batch is not registered; missions, gates and merges are unaffected."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "证据批次",
4
+ "description": "把 mission 归入有限证据批次,并按必需检查与回执关闭批次。关闭后:不再注册 tower_batch;mission、门禁与合并不受影响。"
5
+ }
6
+ }
package/locale/en.json CHANGED
@@ -4,25 +4,45 @@
4
4
  "description": "Mission planning, isolated worktrees, worker/reviewer agents, and review-gated merges."
5
5
  },
6
6
  "components": {
7
- "tower-bundle": {
8
- "title": "Tower",
9
- "summary": "Mission planning, isolated worktrees, worker/reviewer agents, and merge gates.",
10
- "disables": "Turns off the whole Tower bundle; no orphan preset or skill is left behind."
7
+ "tower": {
8
+ "title": "Tower host",
9
+ "summary": "Mission planning, worktree isolation, the mode guard, the health contract and the recovery escape.",
10
+ "disables": "Turns off the mandatory host; this equals uninstalling Tower: no tools, presets or skills remain."
11
11
  },
12
12
  "tower-skills": {
13
13
  "title": "Tower skills",
14
14
  "summary": "Playbooks for worker/reviewer agents and recovery after a blocked merge.",
15
- "disables": "Internal section; toggled with the Tower bundle."
15
+ "disables": "The three Tower skills leave the catalog; the worker/reviewer protocol sections and every mission tool keep working."
16
16
  },
17
- "preset-tower-worker": {
18
- "title": "Tower worker",
19
- "summary": "Writes code, runs verification, and commits only the files assigned to its mission.",
20
- "disables": "Internal section; a missing preset is rejected before planning/mode entry."
17
+ "tower-batch": {
18
+ "title": "Evidence batches",
19
+ "summary": "Group missions into finite evidence batches and close them against required checks and receipts.",
20
+ "disables": "tower_batch is not registered; missions, gates and merges are unaffected."
21
+ },
22
+ "tower-import": {
23
+ "title": "Legacy import",
24
+ "summary": "Import a Tower Codex legacy state file and audit its snapshot-to-mission mapping.",
25
+ "disables": "tower_import_legacy and tower_legacy_map are not registered; new state is unaffected."
26
+ },
27
+ "tower-leases": {
28
+ "title": "Advisory leases",
29
+ "summary": "Coordinate heavy per-repository work with advisory resource leases.",
30
+ "disables": "tower_resource is not registered; nothing else depends on a lease."
31
+ },
32
+ "tower-tests": {
33
+ "title": "Test discovery",
34
+ "summary": "Run the worktree's declared test discovery and record a receipt for the mission tip.",
35
+ "disables": "tower_test is not registered and no receipt can be recorded; with gates.requireTests:true planning/mode entry is rejected with an explanation."
36
+ },
37
+ "tower-hmr": {
38
+ "title": "Dev hot reload",
39
+ "summary": "Register tower_reload and /tower reload for linked-profile development.",
40
+ "disables": "tower_reload and /tower reload are unavailable; a non-HMR profile behaves exactly as before. Shipped off."
21
41
  },
22
42
  "preset-tower-reviewer": {
23
43
  "title": "Tower reviewer",
24
- "summary": "Read-only checks for scope and acceptance, returning a clean verdict or findings.",
25
- "disables": "Internal section; a missing preset is rejected before planning/mode entry when review is required."
44
+ "summary": "Read-only independent review: inspect the mission diff, verify scope and acceptance, return a verdict.",
45
+ "disables": "The reviewer cannot be dispatched. Supported while review.requireReview is false; with the gate on planning/mode entry is locked with an explanatory error."
26
46
  }
27
47
  }
28
48
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Dev hot reload",
4
+ "description": "Register tower_reload and /tower reload for linked-profile development. When off: tower_reload and /tower reload are unavailable; a non-HMR profile behaves exactly as before. Shipped off."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "开发热重载",
4
+ "description": "为联动开发 profile 注册 tower_reload 与 /tower reload。关闭后:tower_reload 与 /tower reload 不可用;非 HMR profile 的行为与之前完全相同。随包默认关闭。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Legacy import",
4
+ "description": "Import a Tower Codex legacy state file and audit its snapshot-to-mission mapping. When off: tower_import_legacy and tower_legacy_map are not registered; new state is unaffected."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "旧版导入",
4
+ "description": "导入 Tower Codex 旧状态文件,并审计快照到 mission 的映射。关闭后:不再注册 tower_import_legacy 与 tower_legacy_map;新状态不受影响。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Advisory leases",
4
+ "description": "Coordinate heavy per-repository work with advisory resource leases. When off: tower_resource is not registered; nothing else depends on a lease."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "咨询式租约",
4
+ "description": "用咨询式资源租约协调每个仓库中的重型工作。关闭后:不再注册 tower_resource;其他能力不依赖租约。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower skills",
4
+ "description": "Playbooks for worker/reviewer agents and recovery after a blocked merge. When off: The three Tower skills leave the catalog; the worker/reviewer protocol sections and every mission tool keep working."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower 技能包",
4
+ "description": "执行与审查代理的操作手册,以及失败后的恢复指南。关闭后:三个 Tower 技能从目录中移除;执行/审查协议段落与所有 mission 工具继续工作。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Test discovery",
4
+ "description": "Run the worktree's declared test discovery and record a receipt for the mission tip. When off: tower_test is not registered and no receipt can be recorded; with gates.requireTests:true planning/mode entry is rejected with an explanation."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "测试发现",
4
+ "description": "运行 worktree 声明的测试发现,并为 mission tip 记录回执。关闭后:不再注册 tower_test,也无法记录回执;若 gates.requireTests:true,规划/进入模式会以解释性错误拒绝。"
5
+ }
6
+ }
package/locale/zh.json CHANGED
@@ -4,25 +4,45 @@
4
4
  "description": "多代理任务规划、隔离工作树与审查门禁合并。"
5
5
  },
6
6
  "components": {
7
- "tower-bundle": {
7
+ "tower": {
8
8
  "title": "Tower 控制塔",
9
- "summary": "任务规划、隔离工作树、执行/审查代理与合并门禁。",
10
- "disables": "关闭后整个 Tower 一起停用;不会留下孤立的代理预设或技能。"
9
+ "summary": "任务规划、隔离工作树、模式守卫、健康契约与恢复逃生口。",
10
+ "disables": "关闭强制主组件等同于卸载 Tower:不再保留任何工具、preset 或技能。"
11
11
  },
12
12
  "tower-skills": {
13
13
  "title": "Tower 技能包",
14
14
  "summary": "执行与审查代理的操作手册,以及失败后的恢复指南。",
15
- "disables": "内部 section;随 Tower 整体启停。"
15
+ "disables": "三个 Tower 技能从目录中移除;执行/审查协议段落与所有 mission 工具继续工作。"
16
16
  },
17
- "preset-tower-worker": {
18
- "title": "Tower 执行代理",
19
- "summary": "在独立分支里写代码、跑验证并提交,只改分给它的文件。",
20
- "disables": "内部 section;缺失时会在规划/进入模式前被拒绝。"
17
+ "tower-batch": {
18
+ "title": "证据批次",
19
+ "summary": "把 mission 归入有限证据批次,并按必需检查与回执关闭批次。",
20
+ "disables": "不再注册 tower_batch;mission、门禁与合并不受影响。"
21
+ },
22
+ "tower-import": {
23
+ "title": "旧版导入",
24
+ "summary": "导入 Tower Codex 旧状态文件,并审计快照到 mission 的映射。",
25
+ "disables": "不再注册 tower_import_legacy 与 tower_legacy_map;新状态不受影响。"
26
+ },
27
+ "tower-leases": {
28
+ "title": "咨询式租约",
29
+ "summary": "用咨询式资源租约协调每个仓库中的重型工作。",
30
+ "disables": "不再注册 tower_resource;其他能力不依赖租约。"
31
+ },
32
+ "tower-tests": {
33
+ "title": "测试发现",
34
+ "summary": "运行 worktree 声明的测试发现,并为 mission tip 记录回执。",
35
+ "disables": "不再注册 tower_test,也无法记录回执;若 gates.requireTests:true,规划/进入模式会以解释性错误拒绝。"
36
+ },
37
+ "tower-hmr": {
38
+ "title": "开发热重载",
39
+ "summary": "为联动开发 profile 注册 tower_reload 与 /tower reload。",
40
+ "disables": "tower_reload 与 /tower reload 不可用;非 HMR profile 的行为与之前完全相同。随包默认关闭。"
21
41
  },
22
42
  "preset-tower-reviewer": {
23
43
  "title": "Tower 审查代理",
24
- "summary": "只读检查改动是否越界、是否达标,给出通过或问题清单。",
25
- "disables": "内部 section;缺失且强制审查时会在规划/进入模式前被拒绝。"
44
+ "summary": "只读独立审查:检查 mission diff、核对范围与验收,给出结论。",
45
+ "disables": "无法派发审查代理。review.requireReview 为 false 时允许关闭;强制审查开启时规划/进入模式会被锁定并给出解释。"
26
46
  }
27
47
  }
28
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
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",
@@ -31,6 +31,17 @@
31
31
  "./worker": "./worker.js",
32
32
  "./reviewer": "./reviewer.js",
33
33
  "./skills": "./skills.js",
34
+ "./batch": "./host/capability-batch.js",
35
+ "./import": "./host/capability-import.js",
36
+ "./leases": "./host/capability-leases.js",
37
+ "./tests": "./host/capability-tests.js",
38
+ "./hmr": "./host/capability-hmr.js",
39
+ "./skills/locale/*.json": "./locale/skills/*.json",
40
+ "./batch/locale/*.json": "./locale/batch/*.json",
41
+ "./import/locale/*.json": "./locale/import/*.json",
42
+ "./leases/locale/*.json": "./locale/leases/*.json",
43
+ "./tests/locale/*.json": "./locale/tests/*.json",
44
+ "./hmr/locale/*.json": "./locale/hmr/*.json",
34
45
  "./locale/*.json": "./locale/*.json",
35
46
  "./icon": "./icon.svg",
36
47
  "./package.json": "./package.json"
@@ -49,7 +60,7 @@
49
60
  "types/",
50
61
  "cordis.patch.yml",
51
62
  "scripts/",
52
- "locale/*.json",
63
+ "locale/",
53
64
  "icon.svg",
54
65
  "README.md",
55
66
  "README.en.md",
@@ -90,10 +101,20 @@
90
101
  "docs:check:generated:config": "node scripts/generate-config-reference.mjs --check",
91
102
  "docs:generate:events": "node scripts/generate-events-reference.mjs",
92
103
  "docs:check:generated:events": "node scripts/generate-events-reference.mjs --check",
104
+ "types:generate": "node scripts/generate-types.mjs",
105
+ "docs:check:generated:types": "node scripts/generate-types.mjs --check",
106
+ "docs:generate:components": "node scripts/generate-component-surface.mjs",
107
+ "docs:check:generated:components": "node scripts/generate-component-surface.mjs --check",
108
+ "docs:generate:locale": "node scripts/generate-capability-locale.mjs",
109
+ "docs:check:generated:locale": "node scripts/generate-capability-locale.mjs --check",
110
+ "docs:check:components": "node scripts/check-components.mjs",
93
111
  "pack:check": "node scripts/check-pack.mjs",
94
112
  "architecture:check": "node scripts/architecture.mjs",
95
- "types:check": "tsc --noEmit",
96
- "check": "npm test && npm run docs:check && npm run docs:check:philosophy && npm run docs:check:generated && npm run docs:check:generated:tools && npm run docs:check:generated:config && npm run docs:check:generated:events && npm run surface:check && npm run pack:check && npm run architecture:check && npm run types:check && npm run domain:check",
113
+ "types:check": "tsc --noEmit && tsc -p tsconfig.strict.json",
114
+ "types:coverage": "node scripts/types-coverage.mjs",
115
+ "types:strict:check": "node scripts/types-strict.mjs",
116
+ "types:strict:write": "node scripts/types-strict.mjs --write",
117
+ "check": "npm test && npm run docs:check && npm run docs:check:philosophy && npm run docs:check:generated && npm run docs:check:generated:tools && npm run docs:check:generated:config && npm run docs:check:generated:events && npm run docs:check:generated:types && npm run docs:check:generated:components && npm run docs:check:generated:locale && npm run docs:check:components && npm run surface:check && npm run pack:check && npm run architecture:check && npm run types:coverage && npm run types:strict:check && npm run types:check && npm run domain:check",
97
118
  "domain:metrics": "node scripts/domain-metrics.mjs",
98
119
  "domain:check": "node scripts/domain-metrics.mjs --check"
99
120
  },
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ // Component-surface policy gate (#120).
3
+ //
4
+ // Principle 11 says optional capabilities are switches and the core is not. This
5
+ // gate is what makes that a rule instead of a slogan. It fails on:
6
+ //
7
+ // 1. a visible component without `title`, `summary` and `disables` in both
8
+ // locale/en.json and locale/zh.json;
9
+ // 2. a forbidden-to-disable capability (host, health, recovery, review
10
+ // contract, merge gates) that any group, row or boolean key can disable;
11
+ // 3. a component count that moved without a decision in
12
+ // scripts/surface-baseline.json (the same brake `surface:check` applies).
13
+ //
14
+ // It also refuses the quiet forms of the same drift: a shipped component row
15
+ // missing from the single source of truth, a live config key without a
16
+ // component-axis class, and a generated reference that is out of date.
17
+ //
18
+ // The single source of truth is `scripts/component-surface.json`; the generated
19
+ // table is `docs/reference/component-surface.md`; the policy is recorded in
20
+ // `docs/adr/0005-component-surface.md`.
21
+ //
22
+ // Usage:
23
+ // node scripts/check-components.mjs [--root <dir>] [--json]
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { Config, configSurface } from '../domain/config/schema.js'
28
+ import {
29
+ COMPONENT_REFERENCE_FILE,
30
+ COMPONENT_SURFACE_FILE,
31
+ LOCALE_FILES,
32
+ checkForbidden,
33
+ checkLocaleCoverage,
34
+ parseComponentSurface,
35
+ parseCordisPatch,
36
+ parseOperationalRegistry,
37
+ renderComponentSurface,
38
+ validateComponentSurface,
39
+ } from '../domain/components.js'
40
+ import { validateSurfaceBaselineKey } from '../domain/surface.js'
41
+ import { readBaseline } from './lib/surface.mjs'
42
+ import { parseArgs, runMain } from './lib/cli.mjs'
43
+
44
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
45
+ const REGISTRY_FILE = 'host/tool-registry.js'
46
+ const PATCH_FILE = 'cordis.patch.yml'
47
+ const USAGE = `usage: node scripts/check-components.mjs [--root <dir>] [--json]
48
+
49
+ --root repository root to check (default: this package)
50
+ --json print the report as JSON
51
+ --help print this text
52
+
53
+ Fails on a visible component without title/summary/disables in both locale files,
54
+ a forbidden-to-disable capability disabled by a group, row or boolean key, a
55
+ component count that moved without a scripts/surface-baseline.json decision,
56
+ an unclassified config key or component row, and a stale generated reference.
57
+ Regenerate the table with \`npm run docs:generate:components\`.`
58
+
59
+ function finding(code, level, message) {
60
+ return { code, level, message }
61
+ }
62
+
63
+ /** The live boolean config keys, derived from the schemastery schema. */
64
+ function liveBooleanKeys() {
65
+ const keys = []
66
+ for (const [key, schema] of Object.entries(Config.dict ?? {})) {
67
+ const dict = schema?.dict
68
+ if (dict && typeof dict === 'object') {
69
+ for (const [child, childSchema] of Object.entries(dict)) {
70
+ if (childSchema?.type === 'boolean') keys.push(`${key}.${child}`)
71
+ }
72
+ } else if (schema?.type === 'boolean') {
73
+ keys.push(key)
74
+ }
75
+ }
76
+ return keys
77
+ }
78
+
79
+ /**
80
+ * The static wiring that lets a boolean key remove a tool: the OPERATIONAL_TOOLS
81
+ * table in host/tool-registry.js. Parsing it (rather than importing the runtime)
82
+ * keeps the denylist check offline and free of a booted Host. The parser accepts
83
+ * both the tuple and the `Map.set(...)` forms, and a trailing comma (F11), so a
84
+ * reformatted table cannot fall out of the allowlist.
85
+ */
86
+ function operationalRegistry(root) {
87
+ const file = path.join(root, REGISTRY_FILE)
88
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
89
+ return parseOperationalRegistry(text)
90
+ }
91
+
92
+ /** Read the live inputs the pure checks need: schema, patch, locales, registry. */
93
+ export function componentCheckData(root) {
94
+ const config = configSurface()
95
+ const patchFile = path.join(root, PATCH_FILE)
96
+ const patch = fs.existsSync(patchFile)
97
+ ? parseCordisPatch(fs.readFileSync(patchFile, 'utf8'))
98
+ : { rows: [], visible: [] }
99
+ const locales = {}
100
+ for (const file of LOCALE_FILES) {
101
+ const localeFile = path.join(root, file)
102
+ locales[file] = fs.existsSync(localeFile) ? JSON.parse(fs.readFileSync(localeFile, 'utf8')) : null
103
+ }
104
+ return {
105
+ configKeys: config.keys,
106
+ sections: config.sections,
107
+ booleans: liveBooleanKeys(),
108
+ registry: operationalRegistry(root),
109
+ patch,
110
+ locales,
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Run every component-surface rule against one repository root.
116
+ * @param {string} root
117
+ * @returns {{ ok: boolean, surface: any, live: any, findings: Array<{ code: string, level: string, message: string }> }}
118
+ */
119
+ export function checkComponents(root) {
120
+ const findings = []
121
+ const live = componentCheckData(root)
122
+ const surfaceFile = path.join(root, COMPONENT_SURFACE_FILE)
123
+ if (!fs.existsSync(surfaceFile)) {
124
+ return {
125
+ ok: false,
126
+ surface: null,
127
+ live,
128
+ findings: [finding('surface-missing', 'error', `${COMPONENT_SURFACE_FILE} is missing`)],
129
+ }
130
+ }
131
+ let surface
132
+ try {
133
+ surface = parseComponentSurface(fs.readFileSync(surfaceFile, 'utf8'))
134
+ } catch (error) {
135
+ return {
136
+ ok: false,
137
+ surface: null,
138
+ live,
139
+ findings: [finding('surface-invalid', 'error', `${COMPONENT_SURFACE_FILE}: ${String(error?.message ?? error)}`)],
140
+ }
141
+ }
142
+ findings.push(...validateComponentSurface(surface, { configKeys: live.configKeys, patch: live.patch }))
143
+ findings.push(...checkLocaleCoverage(surface, live.locales))
144
+ findings.push(...checkForbidden(surface, { booleans: live.booleans, registry: live.registry, patch: live.patch }))
145
+
146
+ // #122: an empty OPERATIONAL_TOOLS table is the expected state. Every route
147
+ // that does exist must still match the `gateableTools` allowlist, which
148
+ // checkForbidden enforces above; there is no "unreadable registry" failure to
149
+ // raise when the correct count is zero.
150
+
151
+ // The decision record must exist: the ADR is where the policy is argued.
152
+ if (!fs.existsSync(path.join(root, surface.adr))) {
153
+ findings.push(finding('adr-missing', 'error', `component surface names ${surface.adr}, which does not exist`))
154
+ }
155
+ for (const capability of surface.forbidden) {
156
+ const decision = capability.acceptedSwitches?.decision
157
+ if (!decision) continue
158
+ if (!fs.existsSync(path.join(root, decision.ref))) {
159
+ findings.push(
160
+ finding(
161
+ 'forbidden-decision',
162
+ 'error',
163
+ `forbidden capability ${capability.id} records a decision in ${decision.ref}, which does not exist`,
164
+ ),
165
+ )
166
+ }
167
+ }
168
+
169
+ // Check 3: the component count is a baselined surface. `surface:check` runs
170
+ // the full baseline; this narrow call keeps the component brake available in
171
+ // this gate too, so a moved count fails here without re-measuring the rest.
172
+ const visible = live.patch.visible
173
+ let baseline = null
174
+ try {
175
+ baseline = readBaseline(root)
176
+ findings.push(
177
+ ...validateSurfaceBaselineKey(
178
+ baseline,
179
+ 'components',
180
+ { components: visible.length, liveNames: { components: visible } },
181
+ { exists: (relative) => fs.existsSync(path.join(root, relative)) },
182
+ ),
183
+ )
184
+ } catch (error) {
185
+ findings.push(finding('baseline-unreadable', 'error', `scripts/surface-baseline.json is unreadable: ${String(error?.message ?? error)}`))
186
+ }
187
+
188
+ // The reference is a rendering, not prose. There is exactly one way to update
189
+ // it: edit the source of truth and run the generator.
190
+ try {
191
+ const expected = renderComponentSurface(surface, { baselineDate: baseline?.date })
192
+ const referenceFile = path.join(root, COMPONENT_REFERENCE_FILE)
193
+ const actual = fs.existsSync(referenceFile) ? fs.readFileSync(referenceFile, 'utf8') : ''
194
+ if (actual !== expected) {
195
+ findings.push(
196
+ finding('reference-stale', 'error', `${COMPONENT_REFERENCE_FILE} is stale; run npm run docs:generate:components`),
197
+ )
198
+ }
199
+ } catch (error) {
200
+ findings.push(finding('reference-unreadable', 'error', `${COMPONENT_REFERENCE_FILE} cannot be rendered: ${String(error?.message ?? error)}`))
201
+ }
202
+
203
+ return { ok: findings.every((entry) => entry.level !== 'error'), surface, live, findings }
204
+ }
205
+
206
+ function main(argv) {
207
+ const options = parseArgs(argv, {
208
+ initial: { root: PACKAGE_ROOT, json: false, help: false },
209
+ values: { '--root': { key: 'root', parse: path.resolve } },
210
+ flags: { '--json': 'json', '--help': 'help', '-h': 'help' },
211
+ })
212
+ if (options.help) {
213
+ console.log(USAGE)
214
+ return 0
215
+ }
216
+ const root = path.resolve(options.root)
217
+ const report = checkComponents(root)
218
+ if (options.json) {
219
+ console.log(JSON.stringify({ root, ...report }, null, 2))
220
+ return report.ok ? 0 : 1
221
+ }
222
+ if (report.surface) {
223
+ const visible = report.surface.components.filter((component) => component.scope === 'visible').length
224
+ const internal = report.surface.components.length - visible
225
+ console.log(
226
+ `components: ${visible} visible, ${internal} internal, ${report.surface.keys.length} config keys classified (${COMPONENT_SURFACE_FILE})`,
227
+ )
228
+ }
229
+ for (const item of report.findings) console.log(`components: ${item.level} [${item.code}] ${item.message}`)
230
+ if (report.ok) console.log('components: ok — locale coverage, the forbidden denylist and the component baseline hold')
231
+ else console.log(`components: ${report.findings.length} problem(s); see docs/adr/0005-component-surface.md`)
232
+ return report.ok ? 0 : 1
233
+ }
234
+
235
+ await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'components', usage: USAGE })
@@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process'
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
+ import { capabilityLocalePaths } from './generate-capability-locale.mjs'
13
14
 
14
15
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
15
16
  const REQUIRED_FILES = [
@@ -17,6 +18,9 @@ const REQUIRED_FILES = [
17
18
  'index.js',
18
19
  'locale/en.json',
19
20
  'locale/zh.json',
21
+ // #121: every optional capability row explains itself on the Plugins page
22
+ // through a subpath locale resource, so those generated files are required.
23
+ ...capabilityLocalePaths(),
20
24
  'ui-bundle/index.js',
21
25
  'ui-bundle/package.json',
22
26
  'ui-bundle/cordis.patch.yml',