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
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // Regression closure audit (#85): a closed `regression` issue must name a
3
+ // `Regression test:` path that exists.
4
+ //
5
+ // A regression is "worked in a released version, then stopped". The project's
6
+ // own closure standard is a pointer, not prose: the incident template
7
+ // (docs/operations/incident.md) carries six fields and the regression test path
8
+ // is the one that can be checked. This script reads a local tracker export, the
9
+ // same format as `npm run audit:issues`, so it never touches the network:
10
+ //
11
+ // { "issues": [ { number, title, state, labels, closedAt, body, comments } ] }
12
+ //
13
+ // Usage:
14
+ // node scripts/regression-audit.mjs [--tracker <file>] [--root <dir>] [--json]
15
+ //
16
+ // Exit codes: 0 all checked regressions point at an existing test; 1 at least
17
+ // one is missing or points at a nonexistent/non-test path; 2 the export is
18
+ // missing or malformed.
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { inspectPointer, isClosed, labelsOf, parseTracker } from './issue-audit.mjs'
23
+ import { findingRecorder, parseArgs, runMain } from './lib/cli.mjs'
24
+
25
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
26
+ const DEFAULT_TRACKER = path.join('tracker', 'issues.json')
27
+ const USAGE = `usage: node scripts/regression-audit.mjs [--tracker <file>] [--root <dir>] [--json]
28
+
29
+ --tracker local tracker export (default: tracker/issues.json)
30
+ --root repository the Regression test paths resolve against (default: this package)
31
+ --json print the report as JSON
32
+ --help print this text
33
+
34
+ A regression issue is closed when state is closed (or closedAt is set) and its
35
+ labels include "regression". Its body or newest comment must carry:
36
+
37
+ Regression test: tests/<file>.test.mjs
38
+
39
+ The path must exist under --root and must be a test file. Produce the export once
40
+ with gh (see CONTRIBUTING.md, "Regressions and incidents"); the audit itself is
41
+ offline.`
42
+
43
+ function cleanValue(value) {
44
+ return String(value)
45
+ .trim()
46
+ .replace(/^`+|`+$/g, '')
47
+ .trim()
48
+ .replace(/^["']+|["']+$/g, '')
49
+ .trim()
50
+ }
51
+
52
+ /**
53
+ * The newest text (body first, then comments oldest to newest) that carries a
54
+ * `Regression test:` line is the closure evidence. Returns `{ pointer }` or
55
+ * `{ error: 'missing' }`.
56
+ * @param {any} issue
57
+ */
58
+ export function selectRegressionField(issue) {
59
+ const sources = []
60
+ if (typeof issue.body === 'string' && issue.body.trim()) sources.push(issue.body)
61
+ const comments = Array.isArray(issue.comments) ? issue.comments : []
62
+ for (const comment of comments) {
63
+ const body = typeof comment === 'string' ? comment : comment?.body
64
+ if (typeof body === 'string' && body.trim()) sources.push(body)
65
+ }
66
+ for (let index = sources.length - 1; index >= 0; index -= 1) {
67
+ for (const rawLine of sources[index].split(/\r?\n/)) {
68
+ const line = rawLine.replace(/^\s*(?:[-*+]\s+|>\s*)+/, '')
69
+ const match = /^Regression test\s*:\s*(.+)$/i.exec(line)
70
+ if (!match) continue
71
+ const pointer = cleanValue(match[1])
72
+ if (pointer) return { pointer }
73
+ }
74
+ }
75
+ return { error: 'missing' }
76
+ }
77
+
78
+ /**
79
+ * Audit closed regression issues against the repository test tree.
80
+ * @param {any[]} issues parsed tracker issues
81
+ * @param {{ root?: string }} [options]
82
+ */
83
+ export function auditRegressions(issues, options = {}) {
84
+ const root = path.resolve(options.root ?? PACKAGE_ROOT)
85
+ const findings = []
86
+ let checked = 0
87
+ for (const issue of issues) {
88
+ if (!isClosed(issue)) continue
89
+ const labels = labelsOf(issue).map((label) => label.toLowerCase())
90
+ if (!labels.includes('regression')) continue
91
+ checked += 1
92
+ const label = `#${issue.number} ${String(issue.title ?? '').trim()}`
93
+ const record = findingRecorder(findings, { number: issue.number, title: issue.title })
94
+ const field = selectRegressionField(issue)
95
+ if (field.error) {
96
+ record(
97
+ 'missing-regression-test',
98
+ 'error',
99
+ `${label}: closed regression has no "Regression test:" field (see docs/operations/incident.md)`,
100
+ )
101
+ continue
102
+ }
103
+ const problem = inspectPointer(field.pointer, root)
104
+ if (!problem) continue
105
+ if (problem.code === 'missing-test') {
106
+ record('missing-regression-test', 'error', `${label}: Regression test names a missing file: ${field.pointer}`)
107
+ } else if (problem.code === 'not-a-test') {
108
+ record('not-a-test', 'error', `${label}: Regression test is not a test file: ${field.pointer} (${problem.detail})`)
109
+ } else {
110
+ record(
111
+ 'invalid-regression-test',
112
+ 'error',
113
+ `${label}: Regression test must be a repository-relative path: ${field.pointer} (${problem.detail})`,
114
+ )
115
+ }
116
+ }
117
+ const errors = findings.filter((item) => item.level === 'error')
118
+ return { ok: errors.length === 0, checked, findings, errors: errors.length }
119
+ }
120
+
121
+ function main(argv) {
122
+ const options = parseArgs(argv, {
123
+ initial: { tracker: null, root: PACKAGE_ROOT, json: false, help: false },
124
+ values: {
125
+ '--tracker': 'tracker',
126
+ '--root': { key: 'root', parse: path.resolve },
127
+ },
128
+ flags: { '--json': 'json', '--help': 'help', '-h': 'help' },
129
+ })
130
+ if (options.help) {
131
+ console.log(USAGE)
132
+ return 0
133
+ }
134
+ const tracker = options.tracker ? path.resolve(process.cwd(), options.tracker) : path.join(PACKAGE_ROOT, DEFAULT_TRACKER)
135
+ let text
136
+ try {
137
+ text = fs.readFileSync(tracker, 'utf8')
138
+ } catch (error) {
139
+ console.error(`regression-audit: cannot read tracker export ${tracker}: ${error?.message ?? error}`)
140
+ console.error('regression-audit: produce one with the gh command in CONTRIBUTING.md ("Regressions and incidents")')
141
+ return 2
142
+ }
143
+ let issues
144
+ let result
145
+ try {
146
+ issues = parseTracker(text)
147
+ result = auditRegressions(issues, { root: options.root })
148
+ } catch (error) {
149
+ console.error(`regression-audit: ${error.message}`)
150
+ return 2
151
+ }
152
+ if (options.json) {
153
+ console.log(JSON.stringify({ tracker, root: path.resolve(options.root), ...result }, null, 2))
154
+ return result.ok ? 0 : 1
155
+ }
156
+ console.log(`regression-audit: ${tracker}`)
157
+ console.log(`regression-audit: ${result.checked} closed regression(s) checked, ${result.errors} error(s)`)
158
+ for (const item of result.findings) console.log(`${item.level} [${item.code}] ${item.message}`)
159
+ if (result.ok) console.log('regression-audit: every closed regression names an existing test file')
160
+ else console.log('regression-audit: a closed regression has no verifiable test pointer; see docs/operations/incident.md')
161
+ return result.ok ? 0 : 1
162
+ }
163
+
164
+ await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'regression-audit', usage: USAGE })
@@ -27,7 +27,7 @@
27
27
  import fs from 'node:fs'
28
28
  import path from 'node:path'
29
29
  import { spawnSync } from 'node:child_process'
30
- import { compareVersions } from '../lib/compatibility.js'
30
+ import { compareVersions } from '../host/compatibility.js'
31
31
  import { findingRecorder, parseArgs, runMain, UsageError } from './lib/cli.mjs'
32
32
 
33
33
  const USAGE = `usage: node scripts/release-check.mjs [--mode pr|tag] [--tag <name>] [--json] [--help]
@@ -0,0 +1,52 @@
1
+ {
2
+ "schema": 1,
3
+ "date": "2026-10-23",
4
+ "surfaces": {
5
+ "configKeys": {
6
+ "seed": 64,
7
+ "value": 56,
8
+ "decisions": []
9
+ },
10
+ "sections": {
11
+ "seed": 15,
12
+ "value": 14,
13
+ "decisions": []
14
+ },
15
+ "tools": {
16
+ "seed": 27,
17
+ "value": 29,
18
+ "decisions": [
19
+ {
20
+ "from": 27,
21
+ "to": 29,
22
+ "date": "2026-10-23",
23
+ "correction": {
24
+ "reason": "Measurement correction, not surface growth: the 2.3.0 baseline recorded only define()-registered tools. tower_mode and tower_control are registered directly by index.js, so the shipped surface was always 29 (#118). The recorder in host/tool-registry.js now covers both paths.",
25
+ "date": "2026-10-23",
26
+ "ref": "docs/adr/0004-surface-budget.md"
27
+ }
28
+ }
29
+ ]
30
+ },
31
+ "components": {
32
+ "seed": 4,
33
+ "value": 8,
34
+ "decisions": [
35
+ {
36
+ "from": 4,
37
+ "to": 1,
38
+ "date": "2026-10-23",
39
+ "adr": "docs/adr/0005-component-surface.md",
40
+ "note": "The component surface joins the budget with its history. 2.0.4 shipped four top-level rows (tower, tower-skills, preset-tower-worker, preset-tower-reviewer); 2.3.0 replaced them with one tower-bundle group row that still mounts the same four sections. This is a recorded contraction, not a free ratchet: the seed stays at 4 so any further move -- including the #121 growth back to individually toggleable optional rows -- must extend this chain with a dated decision."
41
+ },
42
+ {
43
+ "from": 1,
44
+ "to": 8,
45
+ "date": "2026-10-23",
46
+ "adr": "docs/adr/0005-component-surface.md",
47
+ "note": "#121 restores the #65 policy from ADR 0005: the one tower-bundle group becomes a mandatory tower host anchor plus seven individually switchable optional rows (tower-skills, tower-batch, tower-import, tower-leases, tower-tests, tower-hmr shipped off, preset-tower-reviewer conditional). The host anchor and preset-tower-worker stay mandatory; tower_doctor and tower_recover stay mandatory with the host (#122), so they are not rows. configKeys and sections shrank in the same wave because the seven tools.expose* keys and dev.hotReload became rows."
48
+ }
49
+ ]
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // Concept-budget gate (#84): compare the live surface with the committed
3
+ // baseline and require evidence for growth.
4
+ //
5
+ // The surface is four counts the user has to hold at once:
6
+ // config keys leaf keys in domain/config/schema.js (one level deep)
7
+ // sections object groups in that schema
8
+ // tools every tool the Host entry registers, including the
9
+ // tower_mode/tower_control pair that index.js registers
10
+ // directly rather than through define() (#118)
11
+ // components user-visible rows in cordis.patch.yml (#120), so the 2.3.0
12
+ // 4 -> 1 contraction and any future move need a written decision
13
+ //
14
+ // `scripts/surface-baseline.json` freezes those counts. A PR that grows a count
15
+ // fails here until the baseline carries a decision with either an ADR under
16
+ // docs/adr/, deletions that no longer exist in the live surface, or an explicit
17
+ // measurement-correction record (reason + date). The same file supplies the date
18
+ // and counts printed in the generated config/tools reference headers, so growth
19
+ // is visible in a diff.
20
+ //
21
+ // Usage:
22
+ // node scripts/surface-baseline.mjs [--root <dir>] [--json]
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { configSurface } from '../domain/config/schema.js'
27
+ import { parseCordisPatch } from '../domain/components.js'
28
+ import { SURFACE_CLASSIFICATION_FILE, SURFACE_BASELINE_FILE, surfaceLine } from '../domain/surface.js'
29
+ import { loadShippedToolSurface } from './lib/tool-surface.mjs'
30
+ import { checkSurfaceBudget } from './lib/surface.mjs'
31
+ import { parseArgs, runMain } from './lib/cli.mjs'
32
+
33
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
34
+ const USAGE = `usage: node scripts/surface-baseline.mjs [--root <dir>] [--json]
35
+
36
+ --root repository root to check (default: this package)
37
+ --json print the report as JSON
38
+ --help print this text
39
+
40
+ Growth is refused by default. To grow a count, update ${SURFACE_BASELINE_FILE}
41
+ with a decision that names an existing docs/adr/ ADR, deletions that are no
42
+ longer in the live surface, or a measurement-correction record with a reason and
43
+ date. The classification table in
44
+ ${SURFACE_CLASSIFICATION_FILE} must cover every key and tool exactly once, and
45
+ the component count comes from the visible rows in cordis.patch.yml.`
46
+
47
+ /**
48
+ * The live tool names: apply the shipped Host entry so every registration path
49
+ * is included, then release the mock context's timers.
50
+ */
51
+ function liveToolNames() {
52
+ const surface = loadShippedToolSurface()
53
+ try {
54
+ return surface.names
55
+ } finally {
56
+ surface.dispose()
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The live surface used by the gate. Exported so tests can compare the counts
62
+ * with the generated references without spawning the CLI.
63
+ * @param {string} [root] repository root (defaults to this package)
64
+ */
65
+ export function liveSurface(root = PACKAGE_ROOT) {
66
+ const config = configSurface()
67
+ const tools = liveToolNames()
68
+ const patch = parseCordisPatch(fs.readFileSync(path.join(root, 'cordis.patch.yml'), 'utf8'))
69
+ return {
70
+ configKeys: config.count,
71
+ sections: config.sections.length,
72
+ tools: tools.length,
73
+ components: patch.visible.length,
74
+ liveNames: {
75
+ configKeys: config.keys,
76
+ sections: config.sections,
77
+ tools,
78
+ components: patch.visible,
79
+ },
80
+ }
81
+ }
82
+
83
+ function main(argv) {
84
+ const options = parseArgs(argv, {
85
+ initial: { root: PACKAGE_ROOT, json: false, help: false },
86
+ values: { '--root': { key: 'root', parse: path.resolve } },
87
+ flags: { '--json': 'json', '--help': 'help', '-h': 'help' },
88
+ })
89
+ if (options.help) {
90
+ console.log(USAGE)
91
+ return 0
92
+ }
93
+ const root = path.resolve(options.root)
94
+ if (!fs.existsSync(path.join(root, SURFACE_BASELINE_FILE))) {
95
+ console.error(`surface: ${SURFACE_BASELINE_FILE} is missing under ${root}`)
96
+ return 2
97
+ }
98
+ const current = liveSurface()
99
+ const result = checkSurfaceBudget({ root, current })
100
+ if (options.json) {
101
+ console.log(JSON.stringify({ root, current, ...result }, null, 2))
102
+ return result.ok ? 0 : 1
103
+ }
104
+ console.log(`surface: ${surfaceLine(current)}`)
105
+ console.log(`surface: components: ${current.components} visible (${current.liveNames.components.join(', ')})`)
106
+ if (result.baseline) console.log(`surface: baseline ${result.baseline.date} (${SURFACE_BASELINE_FILE})`)
107
+ if (result.classification) {
108
+ const configRows = result.classification.entries.filter((entry) => entry.kind === 'config').length
109
+ const toolRows = result.classification.entries.filter((entry) => entry.kind === 'tools').length
110
+ console.log(`surface: classified ${configRows} config keys and ${toolRows} tools (${SURFACE_CLASSIFICATION_FILE})`)
111
+ }
112
+ for (const finding of result.findings) console.log(`surface: ${finding.level} [${finding.code}] ${finding.message}`)
113
+ if (result.ok) console.log('surface: ok — counts match the committed baseline and every entry is classified')
114
+ else console.log(`surface: ${result.findings.length} problem(s); growth needs an ADR or a matching deletion`)
115
+ return result.ok ? 0 : 1
116
+ }
117
+
118
+ await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'surface', usage: USAGE })
@@ -0,0 +1,184 @@
1
+ // Type-coverage fitness rule (#115, step 1).
2
+ //
3
+ // The checked program must be derived from the shipped program: every runtime
4
+ // `.js` file that `package.json` publishes has to appear in the `tsc` program,
5
+ // otherwise "this file is unchecked" cannot even be reported. Today the
6
+ // tsconfig `include` list is hand-written, so a new module outside it is
7
+ // invisible by construction; this gate makes that gap a red check.
8
+ //
9
+ // Run: node scripts/types-coverage.mjs [--root <dir>] [--json]
10
+ //
11
+ // The shipped set comes from `package.json` `files` (the same list `npm pack`
12
+ // uses; scripts/pack-manifest.txt pins the reviewed snapshot). The checked set
13
+ // comes from `tsc --noEmit --listFiles`, so no second enumeration is involved:
14
+ // changing the tsconfig is enough and this gate follows.
15
+ //
16
+ // Excluded tiers are data with a reason, not silent gaps:
17
+ // - `ui-bundle/**` is the optional Web client: a browser artifact with DOM
18
+ // types and no Node runtime, checked by tests/ui-bundle.test.mjs and the
19
+ // architecture client rules instead of the Node tsc program;
20
+ // - `scripts/**` is build/dev tooling (ESM `.mjs`, not plugin runtime).
21
+ import { execFileSync } from 'node:child_process'
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url))
27
+ const SELF_ROOT = path.resolve(MODULE_DIR, '..')
28
+ const TSC_BIN = path.join(SELF_ROOT, 'node_modules', 'typescript', 'bin', 'tsc')
29
+ const WALK_SKIP = new Set(['node_modules', '.git', '.dsh-tower-worktrees', 'coverage', '.nyc_output'])
30
+
31
+ /** Shipped `.js` tiers that are deliberately outside the Node tsc program. */
32
+ export const NOT_CHECKED_TIERS = [
33
+ {
34
+ pattern: 'ui-bundle/**',
35
+ reason: 'optional Web client: browser artifact, checked by tests/ui-bundle.test.mjs and the client architecture rules',
36
+ },
37
+ {
38
+ pattern: 'scripts/**',
39
+ reason: 'build/dev tooling, not plugin runtime',
40
+ },
41
+ ]
42
+
43
+ /** Expand one package.json `files` pattern into repo-relative paths. */
44
+ export function globToRegExp(pattern) {
45
+ const source = String(pattern)
46
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
47
+ .replace(/\*\*/g, '\u0000')
48
+ .replace(/\*/g, '[^/]*')
49
+ .replace(/\?/g, '[^/]')
50
+ .replace(/\u0000\//g, '(?:[^/]+/)*')
51
+ .replace(/\u0000/g, '.*')
52
+ return new RegExp(`^${source}$`)
53
+ }
54
+
55
+ function walkFiles(root, relative = '') {
56
+ const dir = path.join(root, relative)
57
+ if (!fs.existsSync(dir)) return []
58
+ const out = []
59
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
60
+ if (WALK_SKIP.has(entry.name)) continue
61
+ const next = relative ? `${relative}/${entry.name}` : entry.name
62
+ if (entry.isDirectory()) out.push(...walkFiles(root, next))
63
+ else out.push(next)
64
+ }
65
+ return out
66
+ }
67
+
68
+ /**
69
+ * Resolve `package.json` `files` into the repo-relative file list.
70
+ * A trailing slash (or a bare directory name) means the whole directory, the
71
+ * same reading `npm pack` gives it.
72
+ * @param {string[]} patterns
73
+ * @param {string[]} files every repo-relative file
74
+ * @returns {string[]}
75
+ */
76
+ export function expandShipped(patterns, files) {
77
+ const out = new Set()
78
+ for (const pattern of patterns) {
79
+ if (typeof pattern !== 'string' || pattern.length === 0) continue
80
+ const normalized = pattern.endsWith('/') ? `${pattern}**` : pattern
81
+ const matcher = globToRegExp(normalized)
82
+ const directory = !/\*|\?/.test(normalized) && files.some((file) => file.startsWith(`${normalized}/`))
83
+ const hit = (file) => (directory ? file === normalized || file.startsWith(`${normalized}/`) : matcher.test(file))
84
+ for (const file of files) if (hit(file)) out.add(file)
85
+ }
86
+ return [...out].sort()
87
+ }
88
+
89
+ /**
90
+ * The Node-runtime slice of the shipped set: `.js` files minus the documented
91
+ * non-Node tiers.
92
+ * @param {string[]} shipped
93
+ * @returns {{ runtime: string[], excluded: Array<{ file: string, reason: string }> }}
94
+ */
95
+ export function runtimeJavaScript(shipped) {
96
+ const excluded = []
97
+ const runtime = []
98
+ for (const file of shipped) {
99
+ if (!file.endsWith('.js')) continue
100
+ const tier = NOT_CHECKED_TIERS.find((entry) => globToRegExp(entry.pattern).test(file))
101
+ if (tier) excluded.push({ file, reason: tier.reason })
102
+ else runtime.push(file)
103
+ }
104
+ return { runtime: runtime.sort(), excluded }
105
+ }
106
+
107
+ /**
108
+ * Compare the shipped runtime set with the checked program. One direction only:
109
+ * a shipped file outside the program fails. Extra program files (tests/*.ts,
110
+ * the declaration files, fixture helpers) are expected and allowed.
111
+ * @param {{ runtime: string[], program: string[] }} input
112
+ * @returns {{ ok: boolean, missing: string[], checked: number, total: number }}
113
+ */
114
+ export function evaluateCoverage({ runtime, program }) {
115
+ const checked = new Set(program)
116
+ const missing = runtime.filter((file) => !checked.has(file))
117
+ return { ok: missing.length === 0, missing, checked: runtime.length - missing.length, total: runtime.length }
118
+ }
119
+
120
+ /** List the project files (never node_modules) in the `tsc` program. */
121
+ function programFiles(root) {
122
+ const config = path.join(root, 'tsconfig.json')
123
+ if (!fs.existsSync(config)) throw new Error(`no tsconfig.json under ${root}`)
124
+ if (!fs.existsSync(TSC_BIN)) throw new Error('typescript is not installed; run npm ci first')
125
+ let output
126
+ try {
127
+ output = execFileSync(process.execPath, [TSC_BIN, '--noEmit', '--listFiles', '-p', config], {
128
+ cwd: root,
129
+ encoding: 'utf8',
130
+ stdio: ['ignore', 'pipe', 'pipe'],
131
+ })
132
+ } catch (error) {
133
+ // A program with type errors still lists its files; only a config failure
134
+ // (no input at all) is fatal for this gate. Keep diagnostics out of the
135
+ // parse by only accepting existing absolute paths.
136
+ output = String(error?.stdout ?? '')
137
+ if (!output.trim()) throw new Error(`tsc --listFiles failed: ${String(error?.stderr ?? error?.message ?? error)}`)
138
+ }
139
+ const files = new Set()
140
+ for (const raw of output.split('\n')) {
141
+ const line = raw.trim()
142
+ if (!line || !path.isAbsolute(line)) continue
143
+ if (!fs.existsSync(line)) continue
144
+ const relative = path.relative(root, line).split(path.sep).join('/')
145
+ if (!relative || relative.startsWith('..') || relative.startsWith('node_modules/')) continue
146
+ if (relative.includes('/node_modules/')) continue
147
+ files.add(relative)
148
+ }
149
+ return [...files].sort()
150
+ }
151
+
152
+ function main() {
153
+ const rootArg = process.argv.indexOf('--root')
154
+ const root = rootArg > 0 ? path.resolve(process.argv[rootArg + 1]) : SELF_ROOT
155
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
156
+ const shipped = expandShipped(Array.isArray(pkg.files) ? pkg.files : [], walkFiles(root))
157
+ const { runtime, excluded } = runtimeJavaScript(shipped)
158
+ let program
159
+ try {
160
+ program = programFiles(root)
161
+ } catch (error) {
162
+ console.error(`types coverage: ${String(error?.message ?? error)}`)
163
+ process.exit(1)
164
+ }
165
+ const result = evaluateCoverage({ runtime, program })
166
+ if (process.argv.includes('--json')) {
167
+ console.log(JSON.stringify({ ...result, program: program.length, excluded }, null, 2))
168
+ } else if (result.ok) {
169
+ console.log(
170
+ `types coverage: ${result.checked}/${result.total} shipped runtime .js files are in the tsc program` +
171
+ ` (${program.length} program files, ${excluded.length} excluded: ${[...new Set(excluded.map((entry) => entry.file.split('/')[0]))].join(', ') || 'none'})`,
172
+ )
173
+ } else {
174
+ console.error(
175
+ `types coverage: ${result.missing.length} shipped runtime .js file(s) are outside the tsc program:`,
176
+ )
177
+ for (const file of result.missing) console.error(` - ${file}`)
178
+ console.error('add the file (or its directory glob) to tsconfig.json `include`')
179
+ }
180
+ process.exit(result.ok ? 0 : 1)
181
+ }
182
+
183
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
184
+ if (isMain) main()
@@ -0,0 +1,10 @@
1
+ domain/contracts/outputs.js
2
+ domain/contracts/tool-inputs/collaboration.js
3
+ domain/contracts/tool-inputs/findings.js
4
+ domain/contracts/tool-inputs/index.js
5
+ domain/contracts/tool-inputs/integration.js
6
+ domain/contracts/tool-inputs/missions.js
7
+ domain/contracts/tool-inputs/ops.js
8
+ domain/contracts/tool-inputs/shared.js
9
+ types/cordis.d.ts
10
+ types/index.d.ts
@@ -0,0 +1,141 @@
1
+ // Strict type ratchet (#115, step 4).
2
+ //
3
+ // `tsconfig.strict.json` turns `noImplicitAny: true` on for an explicit,
4
+ // growing file list. The list is the ratchet: CI runs the strict config, and
5
+ // this gate makes the list a reviewed artifact instead of a file that can
6
+ // silently shrink.
7
+ //
8
+ // Run: node scripts/types-strict.mjs [--write] [--allow-shrink] [--root <dir>]
9
+ //
10
+ // * no flag — check: every listed file exists, every file in the
11
+ // seed tier (`domain/contracts/**`) is listed, and the
12
+ // reviewed snapshot (scripts/types-strict-manifest.txt)
13
+ // matches the list exactly.
14
+ // * --write — record the current list in the snapshot. Removing an
15
+ // entry additionally needs `--allow-shrink`, so shrinking
16
+ // the ratchet is a deliberate, visible decision.
17
+ //
18
+ // The list must stay explicit files: a glob would let a new file enter the
19
+ // strict program without a reviewed one-line diff, which is the opposite of a
20
+ // ratchet. `domain/contracts/**` is the seed tier: adding a file there fails
21
+ // the check until the list and the snapshot name it.
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
27
+ export const MANIFEST = 'scripts/types-strict-manifest.txt'
28
+ const SEED_DIRECTORIES = ['domain/contracts']
29
+
30
+ function walk(root, relative) {
31
+ const dir = path.join(root, relative)
32
+ if (!fs.existsSync(dir)) return []
33
+ const out = []
34
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
35
+ const next = `${relative}/${entry.name}`
36
+ if (entry.isDirectory()) out.push(...walk(root, next))
37
+ else out.push(next)
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Entries that are not repo-relative explicit files. @param {unknown} include */
43
+ export function invalidEntries(include) {
44
+ if (!Array.isArray(include) || include.length === 0) return ['include must be a non-empty array of files']
45
+ const problems = []
46
+ for (const entry of include) {
47
+ if (typeof entry !== 'string' || entry.length === 0) {
48
+ problems.push(`${JSON.stringify(entry)} is not a non-empty path`)
49
+ continue
50
+ }
51
+ if (path.isAbsolute(entry) || entry.includes('\\') || entry.split('/').includes('..')) {
52
+ problems.push(`${entry}: use a repo-relative path with forward slashes`)
53
+ } else if (/[*?]/.test(entry)) {
54
+ problems.push(`${entry}: the strict list must name explicit files, not a glob`)
55
+ }
56
+ }
57
+ return problems
58
+ }
59
+
60
+ /** @param {{ include: string[], manifest: string[] }} input */
61
+ export function compareLists({ include, manifest }) {
62
+ const listed = new Set(include)
63
+ const pinned = new Set(manifest)
64
+ return {
65
+ lost: manifest.filter((file) => !listed.has(file)).sort(),
66
+ grew: include.filter((file) => !pinned.has(file)).sort(),
67
+ }
68
+ }
69
+
70
+ /** Every seed-tier file the strict list must name. */
71
+ function seedFiles(root) {
72
+ return SEED_DIRECTORIES.flatMap((directory) => walk(root, directory)).filter((file) => file.endsWith('.js')).sort()
73
+ }
74
+
75
+ function readManifest(root) {
76
+ const file = path.join(root, MANIFEST)
77
+ if (!fs.existsSync(file)) return []
78
+ return fs.readFileSync(file, 'utf8').split('\n').map((line) => line.trim()).filter(Boolean)
79
+ }
80
+
81
+ function fail(lines) {
82
+ console.error('types strict: the ratchet would shrink or drift:')
83
+ for (const line of lines) console.error(` - ${line}`)
84
+ process.exit(1)
85
+ }
86
+
87
+ function main() {
88
+ const rootArg = process.argv.indexOf('--root')
89
+ const root = rootArg > 0 ? path.resolve(process.argv[rootArg + 1]) : SELF_ROOT
90
+ const write = process.argv.includes('--write')
91
+ const allowShrink = process.argv.includes('--allow-shrink')
92
+ const configFile = path.join(root, 'tsconfig.strict.json')
93
+
94
+ if (!fs.existsSync(configFile)) {
95
+ console.error(`types strict: ${path.relative(process.cwd(), configFile)} is missing`)
96
+ process.exit(1)
97
+ }
98
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'))
99
+ if (config?.compilerOptions?.noImplicitAny !== true) {
100
+ console.error('types strict: tsconfig.strict.json must set compilerOptions.noImplicitAny: true explicitly')
101
+ process.exit(1)
102
+ }
103
+ const include = Array.isArray(config.include) ? config.include : []
104
+ const invalid = invalidEntries(include)
105
+ if (invalid.length > 0) fail(invalid)
106
+
107
+ const missingFiles = include.filter((file) => !fs.existsSync(path.join(root, file)))
108
+ if (missingFiles.length > 0) fail(missingFiles.map((file) => `${file}: listed in tsconfig.strict.json but does not exist`))
109
+
110
+ const seeds = seedFiles(root)
111
+ const unlistedSeeds = seeds.filter((file) => !include.includes(file))
112
+ if (unlistedSeeds.length > 0) {
113
+ fail(unlistedSeeds.map((file) => `${file}: in the seed tier but not in tsconfig.strict.json include`))
114
+ }
115
+
116
+ const manifest = readManifest(root)
117
+ const { lost, grew } = compareLists({ include, manifest })
118
+ if (write) {
119
+ if (lost.length > 0 && !allowShrink) {
120
+ fail([
121
+ ...lost.map((file) => `${file}: pinned in ${MANIFEST} but missing from tsconfig.strict.json include`),
122
+ 'pass --allow-shrink to record a deliberate removal',
123
+ ])
124
+ }
125
+ fs.mkdirSync(path.join(root, 'scripts'), { recursive: true })
126
+ fs.writeFileSync(path.join(root, MANIFEST), `${[...include].sort().join('\n')}\n`)
127
+ console.log(`types strict: wrote ${include.length} files to ${MANIFEST} (${lost.length} removed, ${grew.length} added)`)
128
+ return
129
+ }
130
+
131
+ if (lost.length > 0 || grew.length > 0) {
132
+ fail([
133
+ ...lost.map((file) => `${file}: pinned in ${MANIFEST} but no longer checked (the ratchet must not shrink)`),
134
+ ...grew.map((file) => `${file}: newly checked but not pinned; run npm run types:strict:write to record it`),
135
+ ])
136
+ }
137
+ console.log(`types strict: ${include.length} files pinned, noImplicitAny over ${SEED_DIRECTORIES.join(', ')}/**`)
138
+ }
139
+
140
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
141
+ if (isMain) main()