dsh-tower 2.5.0 → 2.7.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 (142) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +18 -16
  7. package/README.md +18 -11
  8. package/adapters/leases.js +20 -6
  9. package/adapters/legacy.js +149 -37
  10. package/adapters/stateFile.js +11 -1
  11. package/app/archive.js +30 -5
  12. package/app/briefing.js +95 -12
  13. package/app/checkCompletion.js +107 -18
  14. package/app/enrichedMissions.js +1 -1
  15. package/app/freshness.js +77 -0
  16. package/app/gate.js +26 -12
  17. package/app/merge.js +7 -4
  18. package/app/mission.js +24 -1
  19. package/app/missionContext.js +2 -1
  20. package/app/plan.js +64 -1
  21. package/app/provision.js +133 -22
  22. package/app/read.js +38 -5
  23. package/app/rebase.js +10 -8
  24. package/app/review.js +104 -61
  25. package/app/reviewCheckout.js +133 -0
  26. package/app/sandbox.js +121 -0
  27. package/app/sharedCheckout.js +90 -0
  28. package/app/spawn-prechecks.js +31 -20
  29. package/app/spawn-support.js +59 -8
  30. package/app/spawn.js +3 -0
  31. package/app/teardown.js +119 -2
  32. package/app/tower.js +34 -6
  33. package/client.js +321 -0
  34. package/cordis.patch.yml +55 -3
  35. package/docs/adr/0005-component-surface.md +58 -13
  36. package/docs/adr/0006-mission-shapes.md +306 -0
  37. package/docs/architecture/overview.md +18 -10
  38. package/docs/examples/first-mission.md +3 -2
  39. package/docs/faq.md +9 -7
  40. package/docs/guide/install-upgrade-migration.md +58 -7
  41. package/docs/guide/quickstart.md +20 -10
  42. package/docs/guide/scenarios-and-philosophy.md +35 -28
  43. package/docs/operations/runbook.md +1 -1
  44. package/docs/operations/security.md +11 -10
  45. package/docs/reference/blockers.md +20 -11
  46. package/docs/reference/component-surface.md +20 -8
  47. package/docs/reference/config.generated.md +8 -5
  48. package/docs/reference/config.md +30 -5
  49. package/docs/reference/doctor.md +7 -7
  50. package/docs/reference/errors.generated.md +1 -0
  51. package/docs/reference/errors.md +2 -1
  52. package/docs/reference/operations.md +1 -1
  53. package/docs/reference/shapes.md +123 -0
  54. package/docs/reference/surface-budget.md +53 -4
  55. package/docs/reference/testing.md +5 -3
  56. package/docs/reference/tools.generated.md +7 -7
  57. package/docs/reference/tools.md +5 -5
  58. package/docs/zh/faq.md +7 -5
  59. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  60. package/docs/zh/guide/quickstart.md +17 -10
  61. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  62. package/docs/zh/operations/security.md +9 -8
  63. package/docs/zh/reference/blockers.md +13 -9
  64. package/docs/zh/reference/config.md +22 -5
  65. package/docs/zh/reference/errors.md +2 -1
  66. package/docs/zh/reference/testing.md +5 -4
  67. package/docs/zh/reference/tools.md +3 -3
  68. package/domain/batch.js +75 -105
  69. package/domain/collab/batch.js +30 -10
  70. package/domain/collab/spawn.js +53 -16
  71. package/domain/components.js +12 -4
  72. package/domain/config/schema.js +87 -22
  73. package/domain/contracts/outputs.js +2 -1
  74. package/domain/contracts/tool-inputs/missions.js +10 -2
  75. package/domain/errors.js +6 -0
  76. package/domain/evidence.js +283 -0
  77. package/domain/gate.js +17 -34
  78. package/domain/integration/gate.js +17 -43
  79. package/domain/integration/init.js +3 -2
  80. package/domain/integration/provision.js +51 -0
  81. package/domain/integration/rebase.js +16 -1
  82. package/domain/integration/review.js +203 -9
  83. package/domain/mission.js +16 -33
  84. package/domain/missions/completion.js +46 -8
  85. package/domain/missions/findings.js +2 -1
  86. package/domain/missions/lifecycle.js +131 -35
  87. package/domain/missions/plan.js +30 -6
  88. package/domain/missions/review.js +2 -18
  89. package/domain/planning.js +5 -5
  90. package/domain/read/missionContext.js +17 -3
  91. package/domain/read/views.js +5 -1
  92. package/domain/roles.js +222 -0
  93. package/domain/roster.js +9 -3
  94. package/domain/scope.js +51 -0
  95. package/domain/shapes/build.js +254 -0
  96. package/domain/shapes/index.js +171 -0
  97. package/domain/shapes/survey.js +445 -0
  98. package/domain/state/archive.js +28 -4
  99. package/domain/state/migrations.js +111 -4
  100. package/domain/state/schema.js +69 -7
  101. package/domain/state/semantics.js +93 -13
  102. package/domain/state.js +5 -1
  103. package/domain/summary.js +5 -11
  104. package/domain/vocabulary.js +15 -33
  105. package/host/capability-registry.js +25 -9
  106. package/host/config.js +40 -3
  107. package/host/doctor.js +20 -8
  108. package/host/preset-survey-sandbox.js +19 -0
  109. package/host/preset-survey.js +18 -0
  110. package/host/presets.js +17 -11
  111. package/host/tool-registry.js +23 -13
  112. package/locale/en.json +16 -1
  113. package/locale/preset-survey/en.json +6 -0
  114. package/locale/preset-survey/zh.json +6 -0
  115. package/locale/preset-survey-sandbox/en.json +6 -0
  116. package/locale/preset-survey-sandbox/zh.json +6 -0
  117. package/locale/zh.json +16 -1
  118. package/package.json +19 -2
  119. package/ports/state.js +8 -1
  120. package/scripts/architecture-waivers.json +3 -3
  121. package/scripts/check-components.mjs +120 -6
  122. package/scripts/check-docs.mjs +1 -1
  123. package/scripts/check-pack.mjs +5 -0
  124. package/scripts/component-surface.json +83 -29
  125. package/scripts/generate-capability-locale.mjs +5 -0
  126. package/scripts/generate-shapes-reference.mjs +171 -0
  127. package/scripts/mount-smoke/surface.mjs +3 -0
  128. package/scripts/pack-manifest.txt +22 -0
  129. package/scripts/shape-audit.mjs +361 -0
  130. package/scripts/surface-baseline.json +75 -4
  131. package/scripts/types-coverage.mjs +7 -0
  132. package/survey-sandbox.js +48 -0
  133. package/survey.js +40 -0
  134. package/tools/complete.js +5 -5
  135. package/tools/diff.js +5 -3
  136. package/tools/plan.js +1 -1
  137. package/tools/preflight.js +3 -2
  138. package/tools/review.js +1 -1
  139. package/tools/spawn.js +48 -50
  140. package/tools/status.js +8 -1
  141. package/tools/test.js +2 -1
  142. package/types/index.d.ts +76 -5
@@ -0,0 +1,18 @@
1
+ // Tower survey preset declaration (#148 D7): the read-only survey preset's own
2
+ // Plugins-page label. This is the survey twin of `host/preset-worker.js`: the row
3
+ // config stays in `cordis.patch.yml` and this module forwards it to the injected
4
+ // `agentPresets` service, which owns mount/unmount. A Tower-owned specifier gives
5
+ // the row a distinct locale (`dsh-tower/preset-survey/locale/<lang>.json`).
6
+ export const name = 'dsh-tower/preset-survey'
7
+
8
+ export const inject = ['agentPresets']
9
+
10
+ /**
11
+ * Register the survey preset with the profile's preset registry.
12
+ * @param {{ agentPresets: { register: (config: any) => Promise<() => Promise<void> | void> } }} ctx
13
+ * @param {any} config the `cordis.patch.yml` row config (id/name/description/order/plugins)
14
+ * @returns {Promise<() => Promise<void> | void>} the registry's unregister disposer
15
+ */
16
+ export async function apply(ctx, config) {
17
+ return ctx.agentPresets.register(config)
18
+ }
package/host/presets.js CHANGED
@@ -6,9 +6,9 @@
6
6
  // 1. `preset-tower-worker` is a mandatory dependency: planning, mode entry and
7
7
  // spawn must refuse when the worker preset is absent.
8
8
  // 2. `preset-tower-reviewer` is a *conditional* component. It may be off while
9
- // `review.requireReview` is false (merge is unaffected); with the review
10
- // gate on it must be locked with an explanatory error, never silently
11
- // allowed.
9
+ // `review.mode` is `off` or `optional` (merge is unaffected; `off` does not
10
+ // use the preset at all); with `review.mode:required` it must be locked with
11
+ // an explanatory error, never silently allowed.
12
12
  //
13
13
  // `tower-tests` adds a third: with the component row off there is no
14
14
  // `tower_test` tool and no receipt, so `gates.requireTests: true` is refused
@@ -19,13 +19,19 @@
19
19
  // spawn would fail at `lease-busy` advising a tool that is not registered, so the
20
20
  // contradiction is refused up front instead.
21
21
  import { TowerToolError } from '../domain/errors.js'
22
+ import { reviewRequired } from '../domain/integration/review.js'
23
+ import { ROLES, rolePresetConfig } from '../domain/roles.js'
22
24
  import { componentEnabled, disabledReason } from './capability-registry.js'
23
25
 
24
26
  export function presetIds(config = {}) {
25
- return {
26
- worker: String(config.spawn?.workerPreset ?? 'tower-worker'),
27
- reviewer: String(config.spawn?.reviewerPreset ?? 'tower-reviewer'),
27
+ const ids = {}
28
+ const spawn = config.spawn ?? {}
29
+ for (const role of ROLES) {
30
+ const spec = rolePresetConfig(role)
31
+ if (!spec) continue
32
+ ids[role] = String(spawn[spec.slot] ?? spec.fallback)
28
33
  }
34
+ return ids
29
35
  }
30
36
 
31
37
  function rowId(preset) {
@@ -38,22 +44,22 @@ function rowId(preset) {
38
44
  * registered. If the service is unavailable, doctor reports that separately and
39
45
  * this check cannot make a claim either way.
40
46
  *
41
- * The reviewer preset is required only when `review.requireReview` is true; the
42
- * off state is otherwise supported by the component contract.
47
+ * The reviewer preset is required only when `review.mode` is `required`; with
48
+ * `off`/`optional` the off state is supported by the component contract.
43
49
  */
44
50
  export async function assertPresetsAvailable(ctx, config = {}) {
45
51
  const service = ctx?.get?.('agentPresets')
46
52
  if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
47
53
  const presets = presetIds(config)
48
54
  const required = [presets.worker]
49
- if (config.review?.requireReview === true) required.push(presets.reviewer)
55
+ if (reviewRequired(config)) required.push(presets.reviewer)
50
56
  const available = new Set((await service.list()).map((entry) => String(entry.id)))
51
57
  const missing = required.filter((id) => !available.has(id))
52
58
  if (missing.length === 0) return { checked: true, missing: [] }
53
59
  const rows = [...new Set(missing.map(rowId))]
54
60
  const reviewLock =
55
- config.review?.requireReview === true && missing.includes(presets.reviewer)
56
- ? ` review.requireReview is true, so the reviewer row may not be off; ${disabledReason(rowId(presets.reviewer))} is rejected`
61
+ reviewRequired(config) && missing.includes(presets.reviewer)
62
+ ? ` review.mode is required, so the reviewer row may not be off; ${disabledReason(rowId(presets.reviewer))} is rejected`
57
63
  : ''
58
64
  throw new TowerToolError(
59
65
  'preset-missing',
@@ -16,6 +16,7 @@ import {
16
16
  } from '../domain/vocabulary.js'
17
17
  import { git, resolveRepo, hasAnyCommit } from '../adapters/git.js'
18
18
  import { runtimeConfig } from './config.js'
19
+ import { reviewUnavailable } from '../domain/integration/review.js'
19
20
  import { runDoctor } from './doctor.js'
20
21
  import { createHmrBridge } from './hmr.js'
21
22
  import { assertRuntimePreconditions } from './presets.js'
@@ -53,7 +54,7 @@ import { registerFindingsTool } from '../tools/findings.js'
53
54
  import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from '../adapters/tests.js'
54
55
  import { summarize as summarizeBatch } from '../domain/batch.js'
55
56
  import { assertPorts } from '../ports/index.js'
56
- import { COMPONENT_TOOLS, componentEnabled, componentReport, holdComponent, disabledReason } from './capability-registry.js'
57
+ import { COMPONENT_TOOLS, componentEnabled, componentReport, holdComponent, disabledReason, presetAvailable, PRESET_ROW_COMPONENTS } from './capability-registry.js'
57
58
  import path from 'node:path'
58
59
  import { fsp } from '../adapters/store-fs.js'
59
60
  import { createDefaultDeps } from '../adapters/default.js'
@@ -510,16 +511,9 @@ async function runMissionDiscovery(tower, mission) {
510
511
  diagnosticsDir: path.join(tower.paths.dir, 'test-runs', `${mission.id}-${Date.now()}`),
511
512
  })
512
513
  const stored = await tower.recordTestRun({ missionId: mission.id, receipt })
513
- const commands = Array.isArray(stored.commands) ? stored.commands : []
514
- const check = {
515
- name: 'tests',
516
- command: commands.map((command) => `${command.id}: ${command.command}`).join('; ') || 'tower.testDiscovery',
517
- exitCode: commands.every((command) => command.exitCode === 0) ? 0 : (commands.find((command) => command.exitCode !== 0)?.exitCode ?? 1),
518
- evidence: stored.ok
519
- ? `${stored.tests?.length ?? 0} test(s) passed at ${String(stored.treeTip ?? '').slice(0, 12)}; receipt ${stored.receiptPath ?? stored.diagnosticsPath}`
520
- : `test discovery failed: ${String(stored.error ?? 'unknown').slice(0, 300)}`,
521
- }
522
- return { configured: true, receipt: stored, check }
514
+ // #149 W4: the recorded receipt is the only evidence shape. Consumers read it
515
+ // through domain/evidence.js; there is no separate `check` projection.
516
+ return { configured: true, receipt: stored }
523
517
  }
524
518
 
525
519
  async function observeActivity(ctx, tower) {
@@ -579,6 +573,15 @@ export function registerCoreTowerTools(ctx, options = {}) {
579
573
  components: {
580
574
  enabled: (id) => componentEnabled(id),
581
575
  reason: (id) => disabledReason(id),
576
+ // Preset rows are declarative and invisible to the code registry, so the
577
+ // synchronous `enabled` probe cannot answer for them. Callers that gate a
578
+ // shape read this async probe, which returns null when `agentPresets` is
579
+ // absent (unknown, not disabled).
580
+ available: async (id) => {
581
+ const presetId = PRESET_ROW_COMPONENTS[id]
582
+ if (presetId) return presetAvailable(ctx, presetId)
583
+ return componentEnabled(id)
584
+ },
582
585
  },
583
586
  events: {
584
587
  emit(name, payload) {
@@ -638,7 +641,7 @@ export function registerCoreTowerTools(ctx, options = {}) {
638
641
 
639
642
  registerInterruptTool(ctx, { openTower, define, actorFor, requireTowerActor, interruptRosterAgent })
640
643
 
641
- registerDiffTool(ctx, { openTower, define })
644
+ registerDiffTool(ctx, { openTower, define, actorFor })
642
645
 
643
646
  registerAdoptTool(ctx, { openTower, define, actorFor, requireTowerActor })
644
647
 
@@ -652,7 +655,14 @@ export function registerCoreTowerTools(ctx, options = {}) {
652
655
 
653
656
  registerFindingsTool(ctx, { openTower, define, actorFor, requireKnownActor })
654
657
 
655
- registerReviewTool(ctx, { openTower, define, actorFor })
658
+ // #M39: `review.mode:off` is the one configuration that removes tower_review
659
+ // from the mounted surface. The name is still recorded before the decision so
660
+ // the concept budget keeps measuring the build surface (the same rule the
661
+ // shipped-off component rows follow), and status/doctor report the mode as
662
+ // intentionally unavailable. A repository layer cannot re-enable the tool
663
+ // (host/config.js refuses that); the profile owns the mounted surface.
664
+ recordToolRegistration('tower_review')
665
+ if (!reviewUnavailable(runtimeConfig())) registerReviewTool(ctx, { openTower, define, actorFor })
656
666
 
657
667
  registerPreflightTool(ctx, { openTower, define, observeActivity })
658
668
 
package/locale/en.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Tower",
4
- "description": "Plan and track missions, isolate each one in its own worktree, hand the work to worker and reviewer agents, and merge after review when review before merging is required. Off equals uninstalling Tower: no tools, presets or skills remain."
4
+ "description": "Plan and track missions, isolate each one in its own worktree, hand the work to worker and reviewer agents, and merge after review when review before merging is required. Off equals uninstalling Tower: no tools, presets or skills remain. Turning off the survey capability removes both survey presets: surveys cannot be planned (planning one is refused with an actionable code) and no survey agent can be dispatched. Build missions and every other capability keep working."
5
5
  },
6
6
  "components": {
7
7
  "tower": {
@@ -59,6 +59,21 @@
59
59
  "summary": "The independent Tower Reviewer preset: a read-only reviewer agent that reads the mission diff, checks scope and acceptance, and returns a verdict. Usable while review before merging is optional; a precondition for merging once review before merging is required.",
60
60
  "disables": "The reviewer cannot be started. That is supported while review before merging is optional; once review before merging is required, planning and entering Tower mode are refused with an explanation."
61
61
  },
62
+ "tower-survey": {
63
+ "title": "Tower Survey",
64
+ "summary": "Inspect one recorded commit either read-only in the shared checkout or in a disposable detached sandbox that may write and run commands inside it. A survey has no branch, no commit of its own, and never merges; the sandbox is removed when the survey completes.",
65
+ "disables": "Turning off the survey capability removes both survey presets: surveys cannot be planned (planning one is refused with an actionable code) and no survey agent can be dispatched. Build missions and every other capability keep working."
66
+ },
67
+ "preset-tower-survey": {
68
+ "title": "Tower Survey preset",
69
+ "summary": "The shared-checkout survey dependency: read, glob, grep, skill, and the Tower mission/status/finding/inbox/complete tools, with no write, edit, or shell tool.",
70
+ "disables": "A shared survey cannot be dispatched; build missions and every other capability keep working."
71
+ },
72
+ "preset-tower-survey-sandbox": {
73
+ "title": "Tower Survey Sandbox preset",
74
+ "summary": "The detached-survey dependency: a disposable sandbox that may write and run commands inside its checkout while reading one recorded commit, with no branch and no commit of its own.",
75
+ "disables": "A detached survey cannot be dispatched; shared surveys and build missions keep working."
76
+ },
62
77
  "tower-ui": {
63
78
  "title": "Tower UI",
64
79
  "summary": "Adds Tower cards for tool results, a compact status summary above the message box, and a short status strip, in the web client.",
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower Survey preset",
4
+ "description": "The shared-checkout survey dependency: read, glob, grep, skill, and the Tower mission/status/finding/inbox/complete tools, with no write, edit, or shell tool. Mandatory dependency, not a switch: A shared survey cannot be dispatched; build missions and every other capability keep working."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower Survey 预设",
4
+ "description": "共享检出勘察的依赖项:read、glob、grep、skill,以及 Tower 的任务线、状态、发现、收件箱与完成工具,不包含写入、编辑或命令行工具。强制依赖,不是开关:无法派发共享检出勘察;普通构建任务线与其他能力继续工作。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower Survey Sandbox preset",
4
+ "description": "The detached-survey dependency: a disposable sandbox that may write and run commands inside its checkout while reading one recorded commit, with no branch and no commit of its own. Mandatory dependency, not a switch: A detached survey cannot be dispatched; shared surveys and build missions keep working."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower Survey Sandbox 预设",
4
+ "description": "分离勘察的依赖项:一个一次性沙箱,可在读取一个已记录提交时于检出中写入并运行命令,没有分支,也没有自己的提交。强制依赖,不是开关:无法派发分离勘察;共享检出勘察与普通构建任务线继续工作。"
5
+ }
6
+ }
package/locale/zh.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Tower",
4
- "description": "规划并跟踪任务线,让每条任务线在独立工作树中完成,交给执行代理与审查代理,并且在把合并前审查设为必须时,审查通过后再合并。关闭这一项等同于卸载 Tower:不再保留任何工具、预设或技能。"
4
+ "description": "规划并跟踪任务线,让每条任务线在独立工作树中完成,交给执行代理与审查代理,并且在把合并前审查设为必须时,审查通过后再合并。关闭这一项等同于卸载 Tower:不再保留任何工具、预设或技能。关闭勘察能力会移除两个勘察预设:无法规划勘察任务(规划勘察会被可操作的错误码拒绝),也无法派发勘察代理。普通构建任务线与其他能力继续工作。"
5
5
  },
6
6
  "components": {
7
7
  "tower": {
@@ -59,6 +59,21 @@
59
59
  "summary": "独立的 Tower Reviewer 审查代理预设:一个只读代理,阅读任务线差异、核对范围与验收,并给出结论。在合并前审查仍为可选时可以使用;一旦把合并前审查设为必须,它就是合并的前置条件。",
60
60
  "disables": "无法启动审查代理。在合并前审查仍为可选时,允许关闭这一项;一旦要求合并前必须审查,规划或进入 Tower 模式会被拒绝并给出解释。"
61
61
  },
62
+ "tower-survey": {
63
+ "title": "Tower 勘察",
64
+ "summary": "勘察一个已记录的提交:可在共享检出中只读查看,也可在一次性分离沙箱中写入并运行命令。勘察没有分支、没有自己的提交,也从不合并;勘察完成时沙箱会被移除。",
65
+ "disables": "关闭勘察能力会移除两个勘察预设:无法规划勘察任务(规划勘察会被可操作的错误码拒绝),也无法派发勘察代理。普通构建任务线与其他能力继续工作。"
66
+ },
67
+ "preset-tower-survey": {
68
+ "title": "Tower Survey 预设",
69
+ "summary": "共享检出勘察的依赖项:read、glob、grep、skill,以及 Tower 的任务线、状态、发现、收件箱与完成工具,不包含写入、编辑或命令行工具。",
70
+ "disables": "无法派发共享检出勘察;普通构建任务线与其他能力继续工作。"
71
+ },
72
+ "preset-tower-survey-sandbox": {
73
+ "title": "Tower Survey Sandbox 预设",
74
+ "summary": "分离勘察的依赖项:一个一次性沙箱,可在读取一个已记录提交时于检出中写入并运行命令,没有分支,也没有自己的提交。",
75
+ "disables": "无法派发分离勘察;共享检出勘察与普通构建任务线继续工作。"
76
+ },
62
77
  "tower-ui": {
63
78
  "title": "Tower 界面",
64
79
  "summary": "在 Web 客户端中显示 Tower 工具结果卡片、输入框上方的简要状态摘要,以及简短的状态提示条。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -28,8 +28,11 @@
28
28
  },
29
29
  "exports": {
30
30
  ".": "./index.js",
31
+ "./client": "./client.js",
31
32
  "./worker": "./worker.js",
32
33
  "./reviewer": "./reviewer.js",
34
+ "./survey": "./survey.js",
35
+ "./survey-sandbox": "./survey-sandbox.js",
33
36
  "./skills": "./skills.js",
34
37
  "./batch": "./host/capability-batch.js",
35
38
  "./import": "./host/capability-import.js",
@@ -40,6 +43,8 @@
40
43
  "./hmr": "./host/capability-hmr.js",
41
44
  "./preset-worker": "./host/preset-worker.js",
42
45
  "./preset-reviewer": "./host/preset-reviewer.js",
46
+ "./preset-survey": "./host/preset-survey.js",
47
+ "./preset-survey-sandbox": "./host/preset-survey-sandbox.js",
43
48
  "./skills/locale/*.json": "./locale/skills/*.json",
44
49
  "./batch/locale/*.json": "./locale/batch/*.json",
45
50
  "./import/locale/*.json": "./locale/import/*.json",
@@ -50,14 +55,19 @@
50
55
  "./hmr/locale/*.json": "./locale/hmr/*.json",
51
56
  "./preset-worker/locale/*.json": "./locale/preset-worker/*.json",
52
57
  "./preset-reviewer/locale/*.json": "./locale/preset-reviewer/*.json",
58
+ "./preset-survey/locale/*.json": "./locale/preset-survey/*.json",
59
+ "./preset-survey-sandbox/locale/*.json": "./locale/preset-survey-sandbox/*.json",
53
60
  "./locale/*.json": "./locale/*.json",
54
61
  "./icon": "./icon.svg",
55
62
  "./package.json": "./package.json"
56
63
  },
57
64
  "files": [
65
+ "client.js",
58
66
  "index.js",
59
67
  "worker.js",
60
68
  "reviewer.js",
69
+ "survey.js",
70
+ "survey-sandbox.js",
61
71
  "skills.js",
62
72
  "skills/",
63
73
  "host/*.js",
@@ -86,6 +96,10 @@
86
96
  "dsh": {
87
97
  "bundle": {
88
98
  "patch": "./cordis.patch.yml"
99
+ },
100
+ "client": {
101
+ "platform": "web",
102
+ "immediately": true
89
103
  }
90
104
  },
91
105
  "scripts": {
@@ -119,6 +133,9 @@
119
133
  "docs:check:generated:components": "node scripts/generate-component-surface.mjs --check",
120
134
  "docs:generate:locale": "node scripts/generate-capability-locale.mjs",
121
135
  "docs:check:generated:locale": "node scripts/generate-capability-locale.mjs --check",
136
+ "docs:generate:shapes": "node scripts/generate-shapes-reference.mjs",
137
+ "docs:check:generated:shapes": "node scripts/generate-shapes-reference.mjs --check",
138
+ "shape:audit": "node scripts/shape-audit.mjs",
122
139
  "docs:check:components": "node scripts/check-components.mjs",
123
140
  "pack:check": "node scripts/check-pack.mjs",
124
141
  "architecture:check": "node scripts/architecture.mjs",
@@ -126,7 +143,7 @@
126
143
  "types:coverage": "node scripts/types-coverage.mjs",
127
144
  "types:strict:check": "node scripts/types-strict.mjs",
128
145
  "types:strict:write": "node scripts/types-strict.mjs --write",
129
- "check": "npm test && npm run test:property && 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",
146
+ "check": "npm test && npm run test:property && 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:generated:shapes && npm run shape:audit && 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",
130
147
  "domain:metrics": "node scripts/domain-metrics.mjs",
131
148
  "domain:check": "node scripts/domain-metrics.mjs --check"
132
149
  },
package/ports/state.js CHANGED
@@ -5,7 +5,14 @@ import { assertPort, definePort } from './contract.js'
5
5
 
6
6
  /**
7
7
  * @typedef {object} StatePort
8
- * @property {(input: { store: unknown, paths: unknown, now?: number, persist?: boolean, migrations?: readonly unknown[] }) => Promise<any>} loadStateFile
8
+ * @property {(input: {
9
+ * store: unknown,
10
+ * paths: unknown,
11
+ * now?: number,
12
+ * persist?: boolean,
13
+ * migrations?: readonly unknown[],
14
+ * resolveLegacySurvey?: (rawState: Record<string, any>) => Promise<{ readCommit: string, readDirty: string[] } | null>,
15
+ * }) => Promise<any>} loadStateFile
9
16
  */
10
17
 
11
18
  export const STATE_PORT = definePort('StatePort', ['loadStateFile'])
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "$comment": "Justified architecture-gate exemptions (#80). Every entry needs rule, path, reason, and owner or ref. Scripts/architecture.mjs fails with waiver-contract when an entry is not justified. domain-pure, host-scope and no-lib-from-core cannot be waived at all, and domain/** accepts no waiver. Since #70 the retired lib/ layer has no waivers: host/** owns ambient state by rule, and only the two I/O adapters below are exempt.",
3
- "waivers": [
4
- {
3
+ "waivers": [ {
5
4
  "rule": "child-process-surface",
6
5
  "path": "adapters/vcs-git.js",
7
6
  "reason": "The git adapter is the single process-spawning boundary; no other module implements VcsPort with a child process.",
@@ -15,5 +14,6 @@
15
14
  "owner": "louifinn",
16
15
  "ref": "#50"
17
16
  }
18
- ]
17
+ ],
18
+ "shapeWaivers": []
19
19
  }
@@ -8,14 +8,19 @@
8
8
  // locale/en.json and locale/zh.json — and the same three strings for every
9
9
  // component that ships a generated row label, including the internal
10
10
  // worker preset (#121);
11
- // 2. a forbidden-to-disable capability (host, health, recovery, review
12
- // contract, merge gates) that any group, row or boolean key can disable;
11
+ // 2. a forbidden-to-disable capability (host, health, recovery, merge gates)
12
+ // that any group, row or boolean key can disable;
13
13
  // 3. a component count that moved without a decision in
14
14
  // scripts/surface-baseline.json (the same brake `surface:check` applies);
15
15
  // 4. #137: user-facing label copy that leaks a config key, an internal tool id,
16
16
  // a slash command or a module specifier, leaves a term untranslated in
17
17
  // Chinese, drifts from the recorded glossary, or diverges between zh and en
18
- // in the root locale, the generated row locales and the ui-bundle locale.
18
+ // in the root locale, the generated row locales and the ui-bundle locale;
19
+ // 5. #M42: the review policy presented as a row. `review.mode` is one
20
+ // tri-state configuration control (off/optional/required) on the plugin
21
+ // page, not a component row and not three mutually exclusive rows; no row
22
+ // may own the policy, and the reviewer preset row may not be made
23
+ // incompatible with the off/optional modes.
19
24
  //
20
25
  // It also refuses the quiet forms of the same drift: a shipped component row
21
26
  // missing from the single source of truth, a live config key without a
@@ -72,9 +77,10 @@ a visible row with no label source at all, a group row whose plugin-page
72
77
  description omits its off consequence, a forbidden-to-disable capability disabled
73
78
  by a group, row or boolean key, a component count that moved without a
74
79
  scripts/surface-baseline.json decision, an unclassified config key or component
75
- row, a stale generated reference, and (#137) any label copy that names a config
76
- key or internal tool id, leaves a glossary term untranslated, or diverges between
77
- zh and en.
80
+ row, a stale generated reference, (#137) any label copy that names a config key or
81
+ internal tool id, leaves a glossary term untranslated, or diverges between zh and
82
+ en, and (#M42) the review policy exposed as a row instead of one tri-state
83
+ configuration control.
78
84
  Regenerate the table with \`npm run docs:generate:components\` and the row labels
79
85
  with \`npm run docs:generate:locale\`.`
80
86
 
@@ -650,6 +656,112 @@ export function checkRowLabelSources(patch, rowLocales) {
650
656
  return findings
651
657
  }
652
658
 
659
+ // #M42: the review policy is one tri-state configuration control on the plugin
660
+ // page. These names are the policy-shaped rows the gate refuses to see: a row
661
+ // per mode (or one "review policy" row) is the shape the product replaced.
662
+ export const REVIEW_POLICY_KEY = 'review.mode'
663
+ export const REVIEW_ALIAS_KEY = 'review.requireReview'
664
+ export const REVIEW_REVIEWER_ROW = 'preset-tower-reviewer'
665
+ const REVIEW_POLICY_ROW_IDS = Object.freeze([
666
+ 'review',
667
+ 'review-policy',
668
+ 'review-mode',
669
+ 'review-modes',
670
+ 'tower-review',
671
+ 'tower-review-policy',
672
+ ])
673
+
674
+ /**
675
+ * #M42: hold the review control to its declared shape. The policy is one
676
+ * tri-state control (`off`/`optional`/`required`) on the plugin-page
677
+ * configuration form, not a component row and not three mutually exclusive rows.
678
+ *
679
+ * The gate fails when a review-policy-shaped row exists, when a component
680
+ * `allowedOverrides` or a shipped row's `config:` block owns `review.mode` (or
681
+ * the deprecated alias), when the non-disableable required-mode gate is not
682
+ * recorded on the forbidden list, and when a forbidden capability claims the
683
+ * reviewer preset row as a disabler. The reviewer row is an optional capability:
684
+ * it may be off while the policy is off or optional, and is only a precondition
685
+ * (with an explanatory refusal) under required.
686
+ *
687
+ * @param {ReturnType<typeof parseComponentSurface>} surface
688
+ * @param {{ patch?: ReturnType<typeof parseCordisPatch> }} live
689
+ */
690
+ export function checkReviewPolicySurface(surface, live) {
691
+ const findings = []
692
+ const record = (code, message) => findings.push(finding(code, 'error', message))
693
+ const components = surface?.components ?? []
694
+ const byName = new Map((surface?.keys ?? []).map((key) => [key.name, key]))
695
+
696
+ for (const name of [REVIEW_POLICY_KEY, REVIEW_ALIAS_KEY]) {
697
+ const key = byName.get(name)
698
+ if (!key) {
699
+ record('review-policy-key', `${name} is missing from the component surface; the review policy needs an explicit class`)
700
+ continue
701
+ }
702
+ if (key.class !== 'core') {
703
+ record(
704
+ 'review-policy-class',
705
+ `${name} is classified "${key.class}"; the review policy is one tri-state configuration control and its keys must be core, never a switch a row can turn off`,
706
+ )
707
+ }
708
+ }
709
+
710
+ const policyRow = components.find((component) => REVIEW_POLICY_ROW_IDS.includes(component.id))
711
+ if (policyRow) {
712
+ record(
713
+ 'review-policy-row',
714
+ `component "${policyRow.id}" is a review-policy row; the review policy is one tri-state control on the plugin-page configuration form, not a component row and never one row per mode`,
715
+ )
716
+ }
717
+
718
+ const reviewerRow = components.find((component) => component.id === REVIEW_REVIEWER_ROW)
719
+ if (!reviewerRow) {
720
+ record(
721
+ 'review-policy-reviewer-row',
722
+ `${REVIEW_REVIEWER_ROW} is missing from the component surface; the reviewer preset must stay an optional capability the review policy may leave unused`,
723
+ )
724
+ }
725
+
726
+ for (const component of components) {
727
+ for (const override of component.allowedOverrides ?? []) {
728
+ if (override !== REVIEW_POLICY_KEY && override !== REVIEW_ALIAS_KEY) continue
729
+ record(
730
+ 'review-policy-switch',
731
+ `component "${component.id}" lists ${override} in allowedOverrides; the review policy is one tri-state configuration control and no component row may own it`,
732
+ )
733
+ }
734
+ }
735
+
736
+ for (const row of live?.patch?.rows ?? []) {
737
+ for (const override of row.configOverrides ?? []) {
738
+ if (override.key !== REVIEW_POLICY_KEY && override.key !== REVIEW_ALIAS_KEY) continue
739
+ record(
740
+ 'review-policy-switch',
741
+ `row "${row.id}" sets ${override.key} in cordis.patch.yml; the review policy is one tri-state configuration control, not a per-row switch`,
742
+ )
743
+ }
744
+ }
745
+
746
+ const gate = (surface?.forbidden ?? []).find((capability) => (capability.gatedBy ?? []).includes(REVIEW_POLICY_KEY))
747
+ if (!gate) {
748
+ record(
749
+ 'review-policy-gate',
750
+ `no forbidden capability records the required-mode review gate via gatedBy ${REVIEW_POLICY_KEY}; required mode must keep its review evidence and exemption path non-disableable`,
751
+ )
752
+ }
753
+
754
+ for (const capability of surface?.forbidden ?? []) {
755
+ if (!(capability.rowIds ?? []).includes(REVIEW_REVIEWER_ROW)) continue
756
+ record(
757
+ 'review-policy-exclusive',
758
+ `forbidden capability "${capability.id}" lists ${REVIEW_REVIEWER_ROW} as a row it may disable; a component may not be mutually exclusive with the review policy, which is one control, not a row`,
759
+ )
760
+ }
761
+
762
+ return findings
763
+ }
764
+
653
765
  /**
654
766
  * Run every component-surface rule against one repository root.
655
767
  * @param {string} root
@@ -735,6 +847,8 @@ export async function checkComponents(root) {
735
847
  findings.push(finding('registry-unreadable', 'error', live.registryError))
736
848
  }
737
849
  findings.push(...checkForbidden(surface, { booleans: live.booleans, registry: live.registry, patch: live.patch }))
850
+ // #M42: the review policy is one tri-state configuration control, not a row.
851
+ findings.push(...checkReviewPolicySurface(surface, live))
738
852
 
739
853
  // The decision record must exist: the ADR is where the policy is argued.
740
854
  if (!fs.existsSync(path.join(root, surface.adr))) {
@@ -34,10 +34,10 @@ for (const code of [
34
34
  'blockers-present',
35
35
  'branch-missing',
36
36
  'stale-base',
37
- 'read-only-survey',
38
37
  'out-of-scope',
39
38
  'base-dirty',
40
39
  'deps-unmerged',
40
+ 'survey-stale',
41
41
  'no-review',
42
42
  'review-mission-mismatch',
43
43
  'not-clean',
@@ -16,6 +16,9 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
16
16
  const REQUIRED_FILES = [
17
17
  'cordis.patch.yml',
18
18
  'index.js',
19
+ // #M40: the core Web Client half carries the Plugins-page review policy
20
+ // control; it is part of the published bundle like ui-bundle/.
21
+ 'client.js',
19
22
  'locale/en.json',
20
23
  'locale/zh.json',
21
24
  // #121: every optional capability row and both preset-declaring rows explain
@@ -25,6 +28,8 @@ const REQUIRED_FILES = [
25
28
  ...presetLocalePaths(),
26
29
  'host/preset-worker.js',
27
30
  'host/preset-reviewer.js',
31
+ 'host/preset-survey.js',
32
+ 'survey.js',
28
33
  'ui-bundle/index.js',
29
34
  'ui-bundle/package.json',
30
35
  'ui-bundle/cordis.patch.yml',