dsh-tower 2.1.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 (203) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +78 -5
  4. package/PRINCIPLES.md +163 -0
  5. package/README.en.md +35 -19
  6. package/README.md +28 -18
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/cordis.patch.yml +56 -28
  44. package/docs/README.md +7 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +70 -0
  49. package/docs/adr/0005-component-surface.md +130 -0
  50. package/docs/adr/README.md +52 -0
  51. package/docs/agents/workflow.md +51 -0
  52. package/docs/architecture/overview.md +192 -43
  53. package/docs/guide/hot-reload.md +5 -5
  54. package/docs/guide/install-upgrade-migration.md +13 -0
  55. package/docs/guide/release.md +1 -1
  56. package/docs/operations/incident.md +82 -0
  57. package/docs/operations/runbook.md +12 -3
  58. package/docs/operations/security.md +1 -1
  59. package/docs/reference/blockers.md +2 -2
  60. package/docs/reference/component-surface.md +135 -0
  61. package/docs/reference/config.generated.md +2 -9
  62. package/docs/reference/config.md +11 -9
  63. package/docs/reference/doctor.md +53 -2
  64. package/docs/reference/errors.generated.md +41 -37
  65. package/docs/reference/errors.md +53 -41
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +157 -0
  68. package/docs/reference/tools.generated.md +8 -5
  69. package/docs/reference/tools.md +10 -7
  70. package/docs/zh/README.md +1 -1
  71. package/docs/zh/guide/hot-reload.md +4 -4
  72. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  73. package/docs/zh/operations/security.md +1 -1
  74. package/docs/zh/reference/blockers.md +2 -2
  75. package/docs/zh/reference/config.md +9 -9
  76. package/docs/zh/reference/doctor.md +29 -1
  77. package/docs/zh/reference/tools.md +10 -8
  78. package/domain/batch.js +2 -2
  79. package/domain/components.js +641 -0
  80. package/domain/config/schema.js +62 -28
  81. package/domain/contracts/outputs.js +16 -2
  82. package/domain/contracts/tool-inputs/ops.js +1 -0
  83. package/domain/errors.js +308 -43
  84. package/domain/events.js +207 -0
  85. package/domain/integration/gate.js +17 -2
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +12 -1
  89. package/domain/missions/completion.js +14 -0
  90. package/domain/missions/lifecycle.js +9 -3
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/read/views.js +3 -0
  94. package/domain/state/archive.js +1 -1
  95. package/domain/state.js +1 -1
  96. package/domain/surface.js +347 -0
  97. package/host/capability-batch.js +15 -0
  98. package/host/capability-hmr.js +30 -0
  99. package/host/capability-import.js +11 -0
  100. package/host/capability-leases.js +11 -0
  101. package/host/capability-registry.js +149 -0
  102. package/host/capability-tests.js +16 -0
  103. package/{lib → host}/doctor.js +157 -18
  104. package/{lib → host}/hmr.js +2 -4
  105. package/{lib → host}/modeStore.js +1 -1
  106. package/host/presets.js +77 -0
  107. package/{lib → host}/tool-registry.js +235 -44
  108. package/host/tower.js +32 -0
  109. package/index.js +40 -25
  110. package/locale/en.json +31 -11
  111. package/locale/zh.json +30 -10
  112. package/package.json +24 -4
  113. package/ports/archive.js +18 -0
  114. package/ports/caseSensitivity.js +15 -0
  115. package/ports/clock.js +10 -2
  116. package/ports/config.js +19 -0
  117. package/ports/contract.js +38 -0
  118. package/ports/events.js +24 -0
  119. package/ports/ids.js +10 -2
  120. package/ports/index.js +54 -2
  121. package/ports/ownership.js +17 -0
  122. package/ports/state.js +16 -0
  123. package/ports/store.js +52 -3
  124. package/ports/vcs.js +25 -4
  125. package/scripts/architecture-waivers.json +19 -0
  126. package/scripts/architecture.mjs +634 -23
  127. package/scripts/check-components.mjs +235 -0
  128. package/scripts/check-docs.mjs +8 -35
  129. package/scripts/check-pack.mjs +68 -39
  130. package/scripts/check-philosophy.mjs +266 -0
  131. package/scripts/component-surface.json +395 -0
  132. package/scripts/domain-metrics.mjs +13 -4
  133. package/scripts/generate-component-surface.mjs +59 -0
  134. package/scripts/generate-config-reference.mjs +14 -1
  135. package/scripts/generate-error-reference.mjs +68 -14
  136. package/scripts/generate-events-reference.mjs +83 -0
  137. package/scripts/generate-tools-reference.mjs +18 -25
  138. package/scripts/generate-types.mjs +127 -0
  139. package/scripts/issue-audit.mjs +2 -2
  140. package/scripts/lib/surface.mjs +87 -0
  141. package/scripts/lib/tool-surface.mjs +85 -0
  142. package/scripts/mount-smoke/fixtures.mjs +3 -3
  143. package/scripts/mount-smoke/surface.mjs +96 -27
  144. package/scripts/mount-smoke.mjs +3 -1
  145. package/scripts/pack-manifest.txt +75 -22
  146. package/scripts/regression-audit.mjs +164 -0
  147. package/scripts/release-check.mjs +1 -1
  148. package/scripts/surface-baseline.json +52 -0
  149. package/scripts/surface-baseline.mjs +118 -0
  150. package/scripts/types-coverage.mjs +184 -0
  151. package/scripts/types-strict-manifest.txt +10 -0
  152. package/scripts/types-strict.mjs +141 -0
  153. package/skills.js +8 -1
  154. package/tools/adopt.js +2 -3
  155. package/tools/batch.js +2 -3
  156. package/tools/complete.js +2 -3
  157. package/tools/diff.js +3 -3
  158. package/tools/doctor.js +7 -10
  159. package/tools/finding.js +2 -3
  160. package/tools/findings.js +3 -3
  161. package/tools/import-legacy.js +3 -5
  162. package/tools/inbox.js +3 -3
  163. package/tools/index.js +9 -8
  164. package/tools/init.js +3 -6
  165. package/tools/interrupt.js +2 -3
  166. package/tools/legacy-map.js +3 -5
  167. package/tools/merge.js +3 -3
  168. package/tools/mission.js +2 -3
  169. package/tools/plan.js +6 -5
  170. package/tools/preflight.js +2 -3
  171. package/tools/rebase.js +3 -3
  172. package/tools/recover.js +38 -13
  173. package/tools/reload.js +36 -40
  174. package/tools/resource.js +3 -5
  175. package/tools/review.js +3 -4
  176. package/tools/roster.js +2 -3
  177. package/tools/send.js +2 -2
  178. package/tools/spawn.js +8 -5
  179. package/tools/status.js +11 -4
  180. package/tools/teardown.js +2 -3
  181. package/tools/test.js +2 -2
  182. package/types/index.d.ts +29 -13
  183. package/ui-bundle/README.md +37 -3
  184. package/ui-bundle/client.cards.js +356 -0
  185. package/ui-bundle/client.denied.js +142 -0
  186. package/ui-bundle/client.dock.js +333 -0
  187. package/ui-bundle/client.helpers.js +271 -0
  188. package/ui-bundle/client.js +79 -1747
  189. package/ui-bundle/client.locales.js +247 -0
  190. package/ui-bundle/client.mode.js +206 -0
  191. package/ui-bundle/client.overlay.js +64 -0
  192. package/ui-bundle/client.styles.js +151 -0
  193. package/ui-bundle/client.toolview.js +82 -0
  194. package/ui-bundle/package.json +1 -0
  195. package/lib/persistence.js +0 -356
  196. package/lib/presets.js +0 -35
  197. package/lib/store.js +0 -133
  198. /package/{lib → adapters}/stateFile.js +0 -0
  199. /package/{lib → app}/briefing.js +0 -0
  200. /package/{lib → app}/spawn-support.js +0 -0
  201. /package/{lib → host}/compatibility.js +0 -0
  202. /package/{lib → host}/config.js +0 -0
  203. /package/{lib → host}/modeState.js +0 -0
@@ -3,9 +3,13 @@ import path from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
5
5
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
6
- const SOURCE_ROOTS = ['domain', 'app', 'tools', 'adapters', 'lib']
6
+ const SOURCE_ROOTS = ['domain', 'app', 'tools', 'adapters', 'host']
7
7
  const ENTRY_FILES = ['index.js', 'worker.js', 'reviewer.js', 'skills.js']
8
8
  const PURE_ROOTS = ['domain']
9
+ // #71: the optional Web client is its own tier. It is printed on every run so
10
+ // its growth is visible in CI, but it stays out of the pure-domain denominator:
11
+ // the share measures the Host architecture, not the browser widget.
12
+ const CLIENT_ROOT = 'ui-bundle'
9
13
 
10
14
  function walk(dir, out = []) {
11
15
  if (!fs.existsSync(dir)) return out
@@ -34,15 +38,20 @@ for (const file of files) {
34
38
  const relative = path.relative(root, file).split(path.sep).join('/')
35
39
  if (PURE_ROOTS.some((prefix) => relative === prefix || relative.startsWith(`${prefix}/`))) pure += lines
36
40
  }
41
+ const clientLines = walk(path.join(root, CLIENT_ROOT)).reduce((sum, file) => sum + loc(file), 0)
37
42
  const share = total === 0 ? 0 : pure / total
38
- const result = { pure, total, share: Number(share.toFixed(4)) }
43
+ const result = { pure, total, share: Number(share.toFixed(4)), uiBundle: clientLines }
44
+ function printMetrics() {
45
+ console.log(`domain-metrics: pure domain share ${(share * 100).toFixed(1)}% (${pure}/${total})`)
46
+ console.log(`domain-metrics: ui-bundle ${clientLines} lines (client tier; excluded from the pure-domain share)`)
47
+ }
39
48
  if (process.argv.includes('--json')) console.log(JSON.stringify(result))
40
49
  else if (process.argv.includes('--check')) {
41
50
  if (share < 0.35) {
42
51
  console.error(`domain-metrics: pure domain share ${(share * 100).toFixed(1)}% < 35% (${pure}/${total})`)
43
52
  process.exit(1)
44
53
  }
45
- console.log(`domain-metrics: pure domain share ${(share * 100).toFixed(1)}% (${pure}/${total})`)
54
+ printMetrics()
46
55
  } else {
47
- console.log(`domain-metrics: pure domain share ${(share * 100).toFixed(1)}% (${pure}/${total})`)
56
+ printMetrics()
48
57
  }
@@ -0,0 +1,59 @@
1
+ // Generate the component-surface reference from the single source of truth
2
+ // (#120). The table is a rendering of `scripts/component-surface.json`; editing
3
+ // the markdown by hand fails `npm run docs:check:generated:components`.
4
+ //
5
+ // Usage:
6
+ // node scripts/generate-component-surface.mjs [--root <dir>] [--check]
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import {
11
+ COMPONENT_REFERENCE_FILE,
12
+ COMPONENT_SURFACE_FILE,
13
+ parseComponentSurface,
14
+ renderComponentSurface,
15
+ } from '../domain/components.js'
16
+ import { readBaseline } from './lib/surface.mjs'
17
+ import { parseArgs, runMain } from './lib/cli.mjs'
18
+
19
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
20
+ const USAGE = `usage: node scripts/generate-component-surface.mjs [--root <dir>] [--check]
21
+
22
+ --root repository root to read and write (default: this package)
23
+ --check fail when the committed reference differs instead of writing it
24
+ --help print this text
25
+
26
+ The table is generated from ${COMPONENT_SURFACE_FILE}; do not edit
27
+ ${COMPONENT_REFERENCE_FILE} by hand.`
28
+
29
+ function main(argv) {
30
+ const options = parseArgs(argv, {
31
+ initial: { root: PACKAGE_ROOT, check: false, help: false },
32
+ values: { '--root': { key: 'root', parse: path.resolve } },
33
+ flags: { '--check': 'check', '--help': 'help', '-h': 'help' },
34
+ })
35
+ if (options.help) {
36
+ console.log(USAGE)
37
+ return 0
38
+ }
39
+ const root = path.resolve(options.root)
40
+ const surface = parseComponentSurface(fs.readFileSync(path.join(root, COMPONENT_SURFACE_FILE), 'utf8'))
41
+ const baseline = readBaseline(root)
42
+ const expected = renderComponentSurface(surface, { baselineDate: baseline.date })
43
+ const target = path.join(root, COMPONENT_REFERENCE_FILE)
44
+ if (options.check) {
45
+ const actual = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
46
+ if (actual !== expected) {
47
+ console.error(`component reference is stale; run npm run docs:generate:components (${COMPONENT_REFERENCE_FILE})`)
48
+ return 1
49
+ }
50
+ console.log(`component reference: ${surface.keys.length} keys in sync (${COMPONENT_REFERENCE_FILE})`)
51
+ return 0
52
+ }
53
+ fs.mkdirSync(path.dirname(target), { recursive: true })
54
+ fs.writeFileSync(target, expected)
55
+ console.log(`wrote ${COMPONENT_REFERENCE_FILE} (${surface.keys.length} keys, ${surface.components.length} components)`)
56
+ return 0
57
+ }
58
+
59
+ await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'components', usage: USAGE })
@@ -2,11 +2,15 @@
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
- import { Config, resolveTowerConfig } from '../domain/config/schema.js'
5
+ import { Config, configSurface, resolveTowerConfig } from '../domain/config/schema.js'
6
+ import { SURFACE_BASELINE_FILE, surfaceLine } from '../domain/surface.js'
7
+ import { readBaseline } from './lib/surface.mjs'
6
8
 
7
9
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
8
10
  const target = path.join(root, 'docs', 'reference', 'config.generated.md')
9
11
  const defaults = resolveTowerConfig({})
12
+ const baseline = readBaseline(root)
13
+ const surface = configSurface()
10
14
  const rows = []
11
15
  for (const [key, schema] of Object.entries(Config.dict ?? {})) {
12
16
  const dict = schema?.dict
@@ -24,9 +28,18 @@ function format(key, schema, parent) {
24
28
  const description = String(schema?.meta?.description ?? parent?.meta?.description ?? '').replace(/\s+/g, ' ').trim()
25
29
  return `| \`${key}\` | \`${fallback}\` | ${description} |`
26
30
  }
31
+ // The counts are live for the config half and come from the committed baseline
32
+ // for the tools half; `npm run surface:check` makes the two agree, so the header
33
+ // is a generated decision record rather than hand-maintained prose.
34
+ const counts = {
35
+ configKeys: rows.length,
36
+ sections: surface.sections.length,
37
+ tools: baseline.surfaces.tools.value,
38
+ }
27
39
  const expected = `# Config reference (generated)
28
40
 
29
41
  > Generated by \`npm run docs:generate:config\` from \`domain/config/schema.js\`. Values are defaults before repo config.
42
+ > Surface: ${surfaceLine(counts)} · baseline ${baseline.date}. Generated from \`${SURFACE_BASELINE_FILE}\`; do not edit by hand.
30
43
 
31
44
  | Key | Default | Description |
32
45
  |---|---|---|
@@ -1,29 +1,83 @@
1
- // Generate the error-code reference from domain/errors.js so the catalog and docs
2
- // cannot disagree (#46). Usage: node scripts/generate-error-reference.mjs [--check]
1
+ // Generate the error-code reference from domain/errors.js so the catalog, the
2
+ // recovery metadata (`retryable` / `nextAction`) and the docs cannot disagree
3
+ // (#46, #74). Usage: node scripts/generate-error-reference.mjs [--check]
4
+ //
5
+ // Two artifacts come from the same catalog:
6
+ // - docs/reference/errors.generated.md — the compact generated catalog;
7
+ // - the table block inside docs/reference/errors.md, delimited by
8
+ // `generated:error-table:start/end` markers. The surrounding prose is
9
+ // hand-written; only the table is generated.
3
10
  import fs from 'node:fs'
4
11
  import path from 'node:path'
5
12
  import { fileURLToPath } from 'node:url'
6
13
  import { ERROR_CODES } from '../domain/errors.js'
7
14
 
8
15
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
- const target = path.join(root, 'docs', 'reference', 'errors.generated.md')
10
- const expected = `# Error code catalog (generated)
16
+ const generatedTarget = path.join(root, 'docs', 'reference', 'errors.generated.md')
17
+ const referenceTarget = path.join(root, 'docs', 'reference', 'errors.md')
18
+ const START = '<!-- generated:error-table:start -->'
19
+ const END = '<!-- generated:error-table:end -->'
20
+
21
+ const cell = (value) => String(value ?? '').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim()
22
+ const retryableCell = (entry) => (entry.retryable === true ? 'yes' : 'no')
23
+ const nextActionCell = (entry) => (entry.nextAction === null || entry.nextAction === undefined ? '—' : cell(entry.nextAction))
24
+
25
+ /** @param {{ withTrigger?: boolean }} options */
26
+ function table(options = {}) {
27
+ const withTrigger = options.withTrigger === true
28
+ const header = withTrigger
29
+ ? '| Code | Meaning | Retryable | Next action | Typical trigger |'
30
+ : '| Code | Meaning | Retryable | Next action |'
31
+ const divider = withTrigger ? '|---|---|---|---|---|' : '|---|---|---|---|'
32
+ const rows = Object.entries(ERROR_CODES).map(([code, entry]) => {
33
+ const base = `| \`${code}\` | ${cell(entry.description)} | ${retryableCell(entry)} | ${nextActionCell(entry)} |`
34
+ return withTrigger ? `${base} ${cell(entry.trigger)} |` : base
35
+ })
36
+ return [header, divider, ...rows].join('\n')
37
+ }
38
+
39
+ const expectedGenerated = `# Error code catalog (generated)
11
40
 
12
41
  > Generated by \`npm run docs:generate:errors\` from \`domain/errors.js\`. Do not edit by hand.
13
42
 
14
- | Code | Description |
15
- |---|---|
16
- ${Object.entries(ERROR_CODES).map(([code, description]) => `| \`${code}\` | ${description} |`).join('\n')}
43
+ The catalog is the source of truth for the stable codes and the recovery metadata the tool boundary
44
+ renders: \`retryable\` marks a failure that can succeed later unchanged, and \`nextAction\` is the
45
+ single recovery step appended to the message as \` — next: …\` (with \` (retryable)\` when retryable).
46
+
47
+ ${table()}
17
48
  `
49
+
50
+ /** Replace the marked block in the hand-written reference. @param {string} text */
51
+ function expectedReference(text) {
52
+ const start = text.indexOf(START)
53
+ const end = text.indexOf(END)
54
+ if (start === -1 || end === -1 || end < start) {
55
+ throw new Error(`docs/reference/errors.md is missing the ${START} / ${END} markers`)
56
+ }
57
+ return `${text.slice(0, start + START.length)}\n${table({ withTrigger: true })}\n${text.slice(end)}`
58
+ }
59
+
60
+ const referenceSource = fs.readFileSync(referenceTarget, 'utf8')
61
+ const expectedReferenceText = expectedReference(referenceSource)
62
+
18
63
  if (process.argv.includes('--check')) {
19
- const actual = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
20
- if (actual !== expected) {
21
- console.error(`error reference is stale; run npm run docs:generate:errors`)
64
+ const actualGenerated = fs.existsSync(generatedTarget) ? fs.readFileSync(generatedTarget, 'utf8') : ''
65
+ if (actualGenerated !== expectedGenerated) {
66
+ console.error('error reference is stale; run npm run docs:generate:errors')
67
+ process.exit(1)
68
+ }
69
+ if (referenceSource !== expectedReferenceText) {
70
+ console.error('docs/reference/errors.md generated table is stale; run npm run docs:generate:errors')
22
71
  process.exit(1)
23
72
  }
24
- console.log(`error reference: ${Object.keys(ERROR_CODES).length} codes in sync`)
73
+ const withRecovery = Object.values(ERROR_CODES).filter((entry) => entry.nextAction !== null).length
74
+ console.log(`error reference: ${Object.keys(ERROR_CODES).length} codes in sync (${withRecovery} with a next action)`)
25
75
  } else {
26
- fs.mkdirSync(path.dirname(target), { recursive: true })
27
- fs.writeFileSync(target, expected)
28
- console.log(`wrote ${path.relative(root, target)} (${Object.keys(ERROR_CODES).length} codes)`)
76
+ fs.mkdirSync(path.dirname(generatedTarget), { recursive: true })
77
+ fs.writeFileSync(generatedTarget, expectedGenerated)
78
+ fs.writeFileSync(referenceTarget, expectedReferenceText)
79
+ const withRecovery = Object.values(ERROR_CODES).filter((entry) => entry.nextAction !== null).length
80
+ console.log(
81
+ `wrote ${path.relative(root, generatedTarget)} and the generated table in ${path.relative(root, referenceTarget)} (${Object.keys(ERROR_CODES).length} codes, ${withRecovery} with a next action)`,
82
+ )
29
83
  }
@@ -0,0 +1,83 @@
1
+ // Generate docs/reference/events.md from the event table (#75) so event names
2
+ // and payloads cannot drift from the runtime. Usage:
3
+ // node scripts/generate-events-reference.mjs [--check]
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import {
8
+ EVENT_COUNT,
9
+ LEGACY_TOOL_EVENT_TABLE,
10
+ MISSION_EVENT_TABLE,
11
+ } from '../domain/events.js'
12
+
13
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
14
+ const target = path.join(root, 'docs', 'reference', 'events.md')
15
+
16
+ const payloadCell = (entry) => `\`{ ${entry.payload.join(', ')} }\``
17
+ const missionRows = MISSION_EVENT_TABLE.map(
18
+ (entry) => `| \`${entry.name}\` | ${payloadCell(entry)} | ${entry.description} |`,
19
+ ).join('\n')
20
+ const legacyRows = LEGACY_TOOL_EVENT_TABLE.map((entry) => `| \`${entry.name}\` | ${entry.description} |`).join('\n')
21
+
22
+ const expected = `# Tower events
23
+
24
+ > Generated by \`npm run docs:generate:events\` from \`domain/events.js\`. Do not edit by hand.
25
+
26
+ Tower emits process-local Cordis events after successful tool calls (mutations and read-only tools), so
27
+ other plugins can observe a repository without polling. This release defines ${EVENT_COUNT} process-local events:
28
+ ${MISSION_EVENT_TABLE.length} stable domain events and ${LEGACY_TOOL_EVENT_TABLE.length} deprecated tool-shaped
29
+ aliases. Events are **not** sent over the network and carry no telemetry.
30
+
31
+ ## Domain events (stable)
32
+
33
+ Domain events describe what changed, not which tool ran. They are emitted only after the state write
34
+ succeeds, so \`revision\` is the revision a reader can load back from \`state.json\`; a failed tool emits
35
+ no domain event. \`at\` is the transition timestamp in epoch milliseconds. \`state.json\` stays
36
+ authoritative: these events are an observation channel, not an event-sourced log, and are never
37
+ replayed.
38
+
39
+ | Event | Payload | Emitted after |
40
+ |---|---|---|
41
+ ${missionRows}
42
+
43
+ \`\`\`js
44
+ ctx.on('tower.mission.merged', ({ missionId, revision, at, commit }) => {
45
+ // the merge is already durable; commit is the merge commit that landed
46
+ })
47
+ \`\`\`
48
+
49
+ ## Tool-shaped aliases (deprecated)
50
+
51
+ The older \`tower/<tool>\` events are kept for one minor release as a **deprecated** alias for the
52
+ tool-shaped observation channel; prefer the domain events above and expect the alias to be removed in
53
+ the next minor. They carry \`{ tool, repoRoot, value }\`, fire after a successful tool call, and never
54
+ fail a tool when a listener throws. For \`tower_merge\` the alias also fires for a gate failure whose
55
+ \`value.ok\` is false.
56
+
57
+ | Event | Emitted after |
58
+ |---|---|
59
+ ${legacyRows}
60
+
61
+ \`\`\`js
62
+ ctx.on('tower/merge', (payload) => {
63
+ // deprecated: prefer tower.mission.merged
64
+ // payload.tool, payload.repoRoot, payload.value (the tool's JSON result)
65
+ })
66
+ \`\`\`
67
+
68
+ For a durable audit trail use \`<git-common-dir>/dsh-tower/activity.jsonl\` and \`MISSIONS.md\` instead;
69
+ events are process-local and are not replayed.
70
+ `
71
+
72
+ if (process.argv.includes('--check')) {
73
+ const actual = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
74
+ if (actual !== expected) {
75
+ console.error('events reference is stale; run npm run docs:generate:events')
76
+ process.exit(1)
77
+ }
78
+ console.log(`events reference: ${EVENT_COUNT} events in sync`)
79
+ } else {
80
+ fs.mkdirSync(path.dirname(target), { recursive: true })
81
+ fs.writeFileSync(target, expected)
82
+ console.log(`wrote ${path.relative(root, target)} (${EVENT_COUNT} events)`)
83
+ }
@@ -1,46 +1,39 @@
1
1
  // Generate the tools overview from the registered tool definitions (#46).
2
+ //
3
+ // #118: use the shipped Host entry, not only the define() path, so the table and
4
+ // its header count include tower_mode/tower_control as well.
2
5
  import fs from 'node:fs'
3
6
  import path from 'node:path'
4
7
  import { fileURLToPath } from 'node:url'
5
- import { registerTowerTools } from '../tools/index.js'
8
+ import { SURFACE_BASELINE_FILE, surfaceLine } from '../domain/surface.js'
9
+ import { loadShippedToolSurface } from './lib/tool-surface.mjs'
10
+ import { readBaseline } from './lib/surface.mjs'
6
11
 
7
12
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
8
13
  const target = path.join(root, 'docs', 'reference', 'tools.generated.md')
9
- const tools = new Map()
10
- const ctx = {
11
- get() {
12
- return undefined
13
- },
14
- effect(fn) {
15
- const dispose = fn()
16
- return typeof dispose === 'function' ? dispose : () => {}
17
- },
18
- systemPrompt: { section() {} },
19
- tools: {
20
- register(definition) {
21
- tools.set(definition.name, definition)
22
- return () => {}
23
- },
24
- guard() {
25
- return () => {}
26
- },
27
- restrict() {
28
- return () => {}
29
- },
30
- },
31
- }
32
- registerTowerTools(ctx)
14
+ const baseline = readBaseline(root)
15
+ const surface = loadShippedToolSurface()
16
+ const tools = surface.definitions
33
17
  const rows = [...tools.values()]
34
18
  .sort((a, b) => a.name.localeCompare(b.name))
35
19
  .map((tool) => `| \`${tool.name}\` | ${String(tool.description ?? '').replace(/\s+/g, ' ').trim()} |`)
20
+ // Live tools count, baseline config counts: `npm run surface:check` keeps the
21
+ // two halves equal, so the generated header carries one decision record.
22
+ const counts = {
23
+ configKeys: baseline.surfaces.configKeys.value,
24
+ sections: baseline.surfaces.sections.value,
25
+ tools: tools.size,
26
+ }
36
27
  const expected = `# Tools overview (generated)
37
28
 
38
29
  > Generated by \`npm run docs:generate:tools\` from the registered tool definitions. Do not edit by hand.
30
+ > Surface: ${surfaceLine(counts)} · baseline ${baseline.date}. Generated from \`${SURFACE_BASELINE_FILE}\`; do not edit by hand.
39
31
 
40
32
  | Tool | Description |
41
33
  |---|---|
42
34
  ${rows.join('\n')}
43
35
  `
36
+ surface.dispose()
44
37
  if (process.argv.includes('--check')) {
45
38
  const actual = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
46
39
  if (actual !== expected) {
@@ -0,0 +1,127 @@
1
+ // Generate the published type vocabulary from the runtime constants (#115).
2
+ //
3
+ // `types/index.d.ts` is the shipped type surface (`package.json` `types`). Its
4
+ // status/type/role/error unions used to be a hand-copied second declaration of
5
+ // constants that live in domain/; this generator makes the runtime the single
6
+ // source: the marked block is rewritten from ERROR_CODES, MISSION_STATUSES,
7
+ // REVIEW_STATUSES, MERGE_VERDICTS, MISSION_KINDS, REVIEW_POLICIES, FINDING_*,
8
+ // ROLES and PAUSE_CAUSES. It is the type-surface sibling of the four
9
+ // `docs:generate:*` generators and shares their `--check` contract.
10
+ //
11
+ // Usage: node scripts/generate-types.mjs [--check]
12
+ //
13
+ // The block is delimited by `// generated:vocabulary:start` / `:end` in
14
+ // types/index.d.ts; the prose around it is hand-written. A missing marker is a
15
+ // configuration error, not a silent no-op.
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { ERROR_CODES } from '../domain/errors.js'
20
+ import {
21
+ FINDING_SEVERITIES,
22
+ FINDING_STATUSES,
23
+ FINDING_TYPES,
24
+ MERGE_VERDICTS,
25
+ MISSION_STATUSES,
26
+ PAUSE_CAUSES,
27
+ REVIEW_STATUSES,
28
+ } from '../domain/mission.js'
29
+ import { MISSION_KINDS, REVIEW_POLICIES, ROLES } from '../domain/vocabulary.js'
30
+
31
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
32
+ const TARGET = path.join(root, 'types', 'index.d.ts')
33
+ export const START = '// generated:vocabulary:start'
34
+ export const END = '// generated:vocabulary:end'
35
+
36
+ /** One published alias per runtime constant family, in declaration order. */
37
+ const DECLARATIONS = [
38
+ { key: 'missionStatuses', name: 'MissionStatus' },
39
+ { key: 'missionKinds', name: 'MissionKind' },
40
+ { key: 'reviewStatuses', name: 'ReviewStatus', multiline: true },
41
+ { key: 'mergeVerdicts', name: 'MergeVerdict' },
42
+ { key: 'reviewPolicies', name: 'ReviewPolicy' },
43
+ { key: 'findingTypes', name: 'FindingType' },
44
+ { key: 'findingSeverities', name: 'FindingSeverity' },
45
+ { key: 'findingStatuses', name: 'FindingStatus' },
46
+ { key: 'roles', name: 'Role' },
47
+ { key: 'pauseCauses', name: 'PauseCause' },
48
+ { key: 'errorCodes', name: 'ErrorCode', multiline: true },
49
+ ]
50
+
51
+ /** The runtime data the block is generated from. `--check` compares this exact input. */
52
+ export function vocabularyData() {
53
+ return {
54
+ missionStatuses: [...MISSION_STATUSES],
55
+ missionKinds: [...MISSION_KINDS],
56
+ reviewStatuses: [...REVIEW_STATUSES],
57
+ mergeVerdicts: [...MERGE_VERDICTS],
58
+ reviewPolicies: [...REVIEW_POLICIES],
59
+ findingTypes: [...FINDING_TYPES],
60
+ findingSeverities: [...FINDING_SEVERITIES],
61
+ findingStatuses: [...FINDING_STATUSES],
62
+ roles: [...ROLES],
63
+ pauseCauses: [...PAUSE_CAUSES],
64
+ errorCodes: Object.keys(ERROR_CODES),
65
+ }
66
+ }
67
+
68
+ function renderDeclaration({ name, values, multiline = false }) {
69
+ const quoted = values.map((value) => `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`)
70
+ const inline = `export type ${name} = ${quoted.join(' | ')}`
71
+ if (!multiline && inline.length <= 120) return inline
72
+ return [`export type ${name} =`, ...quoted.map((value) => ` | ${value}`)].join('\n')
73
+ }
74
+
75
+ /**
76
+ * Render the marked block (markers excluded) for one vocabulary input.
77
+ * @param {Record<string, string[]>} data
78
+ * @returns {string}
79
+ */
80
+ export function vocabularyBlock(data) {
81
+ return DECLARATIONS.map((declaration) =>
82
+ renderDeclaration({ ...declaration, values: data[declaration.key] }),
83
+ ).join('\n')
84
+ }
85
+
86
+ /**
87
+ * Replace the marked block in a declaration file. Removing or reordering the
88
+ * markers is a hard error: a generated block that cannot be found would
89
+ * otherwise pass every check while drifting.
90
+ * @param {string} source
91
+ * @param {string} block
92
+ * @returns {string}
93
+ */
94
+ export function injectVocabulary(source, block) {
95
+ const start = source.indexOf(START)
96
+ const end = source.indexOf(END)
97
+ if (start === -1 || end === -1 || end < start) {
98
+ throw new Error(`types/index.d.ts is missing the ${START} / ${END} markers`)
99
+ }
100
+ return `${source.slice(0, start + START.length)}\n${block}\n${source.slice(end)}`
101
+ }
102
+
103
+ function main() {
104
+ const source = fs.readFileSync(TARGET, 'utf8')
105
+ const expected = injectVocabulary(source, vocabularyBlock(vocabularyData()))
106
+ const codes = Object.keys(ERROR_CODES).length
107
+ if (process.argv.includes('--check')) {
108
+ if (source !== expected) {
109
+ console.error('published type vocabulary is stale; run npm run types:generate')
110
+ process.exit(1)
111
+ }
112
+ console.log(`type vocabulary: ${DECLARATIONS.length} aliases (${codes} error codes) in sync`)
113
+ } else {
114
+ fs.writeFileSync(TARGET, expected)
115
+ console.log(`wrote ${path.relative(root, TARGET)} (${DECLARATIONS.length} aliases, ${codes} error codes)`)
116
+ }
117
+ }
118
+
119
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
120
+ if (isMain) {
121
+ try {
122
+ main()
123
+ } catch (error) {
124
+ console.error(`type vocabulary: ${String(error?.message ?? error)}`)
125
+ process.exit(1)
126
+ }
127
+ }
@@ -87,7 +87,7 @@ export function selectEvidence(issue) {
87
87
  return null
88
88
  }
89
89
 
90
- function labelsOf(issue) {
90
+ export function labelsOf(issue) {
91
91
  const labels = Array.isArray(issue.labels) ? issue.labels : []
92
92
  return labels.map((label) => (typeof label === 'string' ? label : label?.name ?? '')).filter(Boolean)
93
93
  }
@@ -116,7 +116,7 @@ function closedAtOf(issue) {
116
116
 
117
117
  // A pointer must stay inside the repository and name a real test file: an
118
118
  // existing script or doc is not evidence of a regression test.
119
- function inspectPointer(pointer, root) {
119
+ export function inspectPointer(pointer, root) {
120
120
  const normalized = String(pointer).replace(/\\/g, '/').trim()
121
121
  if (!normalized) return { code: 'invalid-pointer', detail: 'empty' }
122
122
  if (path.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('/')) {
@@ -0,0 +1,87 @@
1
+ // File-reading wrapper around the pure concept-budget surface (#84).
2
+ //
3
+ // `domain/surface.js` owns the rules; this module owns the two files they read
4
+ // (`scripts/surface-baseline.json` and `docs/reference/surface-budget.md`) and
5
+ // the combined check used by the CLI gate and the tests.
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import {
9
+ SURFACE_BASELINE_FILE,
10
+ SURFACE_CLASSIFICATION_FILE,
11
+ parseBaseline,
12
+ validateBaseline,
13
+ validateClassification,
14
+ } from '../../domain/surface.js'
15
+
16
+ /**
17
+ * @param {string} root repository root
18
+ * @returns {ReturnType<typeof parseBaseline>}
19
+ */
20
+ export function readBaseline(root) {
21
+ const file = path.join(root, SURFACE_BASELINE_FILE)
22
+ return parseBaseline(fs.readFileSync(file, 'utf8'))
23
+ }
24
+
25
+ /**
26
+ * @param {string} root repository root
27
+ */
28
+ export function readClassification(root) {
29
+ return fs.readFileSync(path.join(root, SURFACE_CLASSIFICATION_FILE), 'utf8')
30
+ }
31
+
32
+ /**
33
+ * Run both halves of the concept-budget gate against one repository root.
34
+ * `current` carries the live counts and the live key/tool names.
35
+ *
36
+ * @param {{ root: string, current: { configKeys: number, sections: number, tools: number, liveNames: { configKeys: string[], sections: string[], tools: string[] } } }} options
37
+ */
38
+ export function checkSurfaceBudget({ root, current }) {
39
+ let baseline
40
+ try {
41
+ baseline = readBaseline(root)
42
+ } catch (error) {
43
+ return {
44
+ ok: false,
45
+ baseline: null,
46
+ classification: null,
47
+ findings: [
48
+ {
49
+ code: 'baseline-unreadable',
50
+ level: 'error',
51
+ message: `${SURFACE_BASELINE_FILE} is unreadable: ${String(error?.message ?? error)}`,
52
+ },
53
+ ],
54
+ }
55
+ }
56
+ const findings = validateBaseline(baseline, current, {
57
+ exists: (relative) => fs.existsSync(path.join(root, relative)),
58
+ })
59
+ let classificationText
60
+ try {
61
+ classificationText = readClassification(root)
62
+ } catch (error) {
63
+ return {
64
+ ok: false,
65
+ baseline,
66
+ classification: null,
67
+ findings: [
68
+ ...findings,
69
+ {
70
+ code: 'classification-unreadable',
71
+ level: 'error',
72
+ message: `${SURFACE_CLASSIFICATION_FILE} is unreadable: ${String(error?.message ?? error)}`,
73
+ },
74
+ ],
75
+ }
76
+ }
77
+ const classification = validateClassification(classificationText, current.liveNames, {
78
+ baselineDate: baseline.date,
79
+ })
80
+ const all = [...findings, ...classification.findings]
81
+ return {
82
+ ok: all.length === 0,
83
+ baseline,
84
+ classification,
85
+ findings: all,
86
+ }
87
+ }