dsh-tower 2.1.0-beta.1 → 2.3.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 (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -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 +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
@@ -4,6 +4,7 @@
4
4
  import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { EVENT_COUNT, LEGACY_TOOL_EVENT_NAMES } from '../domain/events.js'
7
8
 
8
9
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
10
  const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
@@ -15,7 +16,7 @@ const toolModuleSources = fs.existsSync(path.join(root, 'tools'))
15
16
  .map((name) => read(`tools/${name}`))
16
17
  .join('\n')
17
18
  : ''
18
- const toolsSource = read('tools/index.js') + toolModuleSources + read('lib/tool-registry.js') + read('index.js')
19
+ const toolsSource = read('tools/index.js') + toolModuleSources + read('host/tool-registry.js') + read('index.js')
19
20
  const documentedTools = read('docs/reference/tools.md')
20
21
  const toolNames = [...new Set([...toolsSource.matchAll(/name: '(tower_[a-z_]+)'/g)].map((match) => match[1]))]
21
22
  .filter((name) => name !== 'tower-control')
@@ -62,37 +63,9 @@ for (const code of [
62
63
  ]) blockerCodes.add(code)
63
64
  const missingBlockers = [...blockerCodes].filter((code) => !blockersSource.includes(`\`${code}\``)).sort()
64
65
 
65
- const eventNames = [
66
- 'init',
67
- 'plan',
68
- 'mission',
69
- 'spawn',
70
- 'complete',
71
- 'review',
72
- 'merge',
73
- 'rebase',
74
- 'teardown',
75
- 'finding',
76
- 'findings',
77
- 'send',
78
- 'inbox',
79
- 'interrupt',
80
- 'diff',
81
- 'adopt',
82
- 'roster',
83
- 'status',
84
- 'preflight',
85
- 'doctor',
86
- 'recover',
87
- 'test',
88
- 'batch',
89
- 'resource',
90
- 'import_legacy',
91
- 'legacy_map',
92
- 'mode',
93
- 'control',
94
- 'reload',
95
- ]
66
+ // #75: the legacy alias list comes from the same event table the generated
67
+ // reference and the runtime use, so a renamed alias fails here first.
68
+ const eventNames = LEGACY_TOOL_EVENT_NAMES.map((name) => name.replace(/^tower\//, ''))
96
69
  const documentedEvents = read('docs/reference/events.md')
97
70
  const missingEvents = eventNames.filter((name) => !documentedEvents.includes(`\`tower/${name}\``))
98
71
 
@@ -128,8 +101,8 @@ if (!blockersDoc.includes(`${blockerCodes.size} stable blocker/failure codes`))
128
101
  if (!errorsDoc.includes(`${errorCodes.length} tool error codes`)) {
129
102
  problems.push(`docs/reference/errors.md must state ${errorCodes.length} tool error codes`)
130
103
  }
131
- if (!eventsDoc.includes(`${eventNames.length} process-local events`)) {
132
- problems.push(`docs/reference/events.md must state ${eventNames.length} process-local events`)
104
+ if (!eventsDoc.includes(`${EVENT_COUNT} process-local events`)) {
105
+ problems.push(`docs/reference/events.md must state ${EVENT_COUNT} process-local events`)
133
106
  }
134
107
  for (const required of [
135
108
  '| `tests.admission` | `off` |',
@@ -172,5 +145,5 @@ if (problems.length > 0) {
172
145
  process.exit(1)
173
146
  }
174
147
  console.log(
175
- `docs ok: ${toolNames.length} tools, ${blockerCodes.size} blocker codes, ${errorCodes.length} error codes, ${eventNames.length} events, version ${packageJson.version}`,
148
+ `docs ok: ${toolNames.length} tools, ${blockerCodes.size} blocker codes, ${errorCodes.length} error codes, ${EVENT_COUNT} events, version ${packageJson.version}`,
176
149
  )
@@ -1,49 +1,78 @@
1
1
  // Fail when the published file list drifts from the reviewed snapshot.
2
- // Run: node scripts/check-pack.mjs [--write]
2
+ // Run: node scripts/check-pack.mjs [--write] [--root <dir>]
3
+ //
4
+ // #71: the optional Web client ships inside the main package, so the required
5
+ // list asserts the client package manifest and its bundle patch — not just the
6
+ // directory's index.js. `requiredMissing` is exported so a fixture can prove
7
+ // that removing one of them fails the check; `--root` lets that fixture run the
8
+ // real gate against a temporary package.
3
9
  import { execFileSync } from 'node:child_process'
4
10
  import fs from 'node:fs'
5
11
  import path from 'node:path'
6
12
  import { fileURLToPath } from 'node:url'
7
13
 
8
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
- const manifestFile = path.join(root, 'scripts', 'pack-manifest.txt')
10
- const write = process.argv.includes('--write')
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
15
+ const REQUIRED_FILES = [
16
+ 'cordis.patch.yml',
17
+ 'index.js',
18
+ 'locale/en.json',
19
+ 'locale/zh.json',
20
+ 'ui-bundle/index.js',
21
+ 'ui-bundle/package.json',
22
+ 'ui-bundle/cordis.patch.yml',
23
+ 'COMPATIBILITY.md',
24
+ ]
11
25
 
12
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
13
- let parsed
14
- try {
15
- const output = execFileSync(npm, ['pack', '--dry-run', '--json'], {
16
- cwd: root,
17
- encoding: 'utf8',
18
- shell: process.platform === 'win32',
19
- stdio: ['ignore', 'pipe', 'pipe'],
20
- })
21
- parsed = JSON.parse(output)
22
- } catch (error) {
23
- console.error(`pack check: npm pack failed: ${String(error?.message ?? error)}`)
24
- process.exit(1)
26
+ function requiredMissing(paths, required = REQUIRED_FILES) {
27
+ return required.filter((file) => !paths.includes(file))
25
28
  }
26
29
 
27
- const entry = Array.isArray(parsed) ? parsed[0] : parsed
28
- const paths = (entry?.files ?? []).map((file) => file.path).filter(Boolean).sort()
29
- if (write) {
30
- fs.writeFileSync(manifestFile, `${paths.join('\n')}\n`)
31
- console.log(`pack check: wrote ${paths.length} paths to ${path.relative(root, manifestFile)}`)
32
- process.exit(0)
33
- }
34
- const expected = fs.readFileSync(manifestFile, 'utf8').split('\n').filter(Boolean).sort()
35
- const added = paths.filter((file) => !expected.includes(file))
36
- const removed = expected.filter((file) => !paths.includes(file))
37
- if (added.length > 0 || removed.length > 0) {
38
- console.error('pack check: published file list changed; review and update scripts/pack-manifest.txt')
39
- if (added.length > 0) console.error(` added: ${added.join(', ')}`)
40
- if (removed.length > 0) console.error(` removed: ${removed.join(', ')}`)
41
- process.exit(1)
42
- }
43
- const required = ['cordis.patch.yml', 'index.js', 'locale/en.json', 'locale/zh.json', 'ui-bundle/index.js', 'COMPATIBILITY.md']
44
- const missing = required.filter((file) => !paths.includes(file))
45
- if (missing.length > 0) {
46
- console.error(`pack check: required runtime files missing: ${missing.join(', ')}`)
47
- process.exit(1)
30
+ function main() {
31
+ const rootArg = process.argv.indexOf('--root')
32
+ const root = rootArg > 0 ? path.resolve(process.argv[rootArg + 1]) : ROOT
33
+ const manifestFile = path.join(root, 'scripts', 'pack-manifest.txt')
34
+ const write = process.argv.includes('--write')
35
+
36
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
37
+ let parsed
38
+ try {
39
+ const output = execFileSync(npm, ['pack', '--dry-run', '--json'], {
40
+ cwd: root,
41
+ encoding: 'utf8',
42
+ shell: process.platform === 'win32',
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ })
45
+ parsed = JSON.parse(output)
46
+ } catch (error) {
47
+ console.error(`pack check: npm pack failed: ${String(error?.message ?? error)}`)
48
+ process.exit(1)
49
+ }
50
+
51
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed
52
+ const paths = (entry?.files ?? []).map((file) => file.path).filter(Boolean).sort()
53
+ if (write) {
54
+ fs.writeFileSync(manifestFile, `${paths.join('\n')}\n`)
55
+ console.log(`pack check: wrote ${paths.length} paths to ${path.relative(root, manifestFile)}`)
56
+ process.exit(0)
57
+ }
58
+ const expected = fs.readFileSync(manifestFile, 'utf8').split('\n').filter(Boolean).sort()
59
+ const added = paths.filter((file) => !expected.includes(file))
60
+ const removed = expected.filter((file) => !paths.includes(file))
61
+ if (added.length > 0 || removed.length > 0) {
62
+ console.error('pack check: published file list changed; review and update scripts/pack-manifest.txt')
63
+ if (added.length > 0) console.error(` added: ${added.join(', ')}`)
64
+ if (removed.length > 0) console.error(` removed: ${removed.join(', ')}`)
65
+ process.exit(1)
66
+ }
67
+ const missing = requiredMissing(paths)
68
+ if (missing.length > 0) {
69
+ console.error(`pack check: required runtime files missing: ${missing.join(', ')}`)
70
+ process.exit(1)
71
+ }
72
+ console.log(`pack check: ${paths.length} files match scripts/pack-manifest.txt`)
48
73
  }
49
- console.log(`pack check: ${paths.length} files match scripts/pack-manifest.txt`)
74
+
75
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
76
+ if (isMain) main()
77
+
78
+ export { REQUIRED_FILES, requiredMissing }
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ // Engineering-philosophy gate (#83).
3
+ //
4
+ // The repository writes down what it refuses to do and points every principle at
5
+ // the thing that enforces it. A pointer that does not resolve is a slogan, so
6
+ // this check fails on it. It also keeps the ADR set dated/structure-complete and
7
+ // makes every "Non-goals" heading in docs/ auditable: it must either name an
8
+ // existing enforcement file or be explicitly labelled `unowned`.
9
+ //
10
+ // Usage:
11
+ // node scripts/check-philosophy.mjs [--root <dir>] [--json]
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { parseArgs, runMain } from './lib/cli.mjs'
16
+
17
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
18
+ const PRINCIPLES_FILE = 'PRINCIPLES.md'
19
+ const ADR_DIR = 'docs/adr'
20
+ const MIN_PRINCIPLES = 8
21
+ const MIN_ADRS = 3
22
+ const ADR_REQUIRED_SECTIONS = ['Context', 'Decision', 'Consequences', 'Rejected alternatives']
23
+ const ADR_REQUIRED_TOPICS = ['ports', 'state.json', 'allowAgentControl']
24
+ const USAGE = `usage: node scripts/check-philosophy.mjs [--root <dir>] [--json]
25
+
26
+ --root repository root to check (default: this package)
27
+ --json print the report as JSON
28
+ --help print this text
29
+
30
+ Every principle needs "Refused because of this:" and "Enforced by:" where the
31
+ enforcement names an existing file and a test or gate. Every docs/ "Non-goals"
32
+ heading needs an existing enforcement pointer or the word "unowned". At least
33
+ ${MIN_ADRS} dated ADRs need Context, Decision, Consequences and Rejected alternatives.`
34
+
35
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
36
+
37
+ function finding(code, message) {
38
+ return { code, level: 'error', message }
39
+ }
40
+
41
+ function listMarkdown(dir) {
42
+ const files = []
43
+ if (!fs.existsSync(dir)) return files
44
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
45
+ const full = path.join(dir, entry.name)
46
+ if (entry.isDirectory()) files.push(...listMarkdown(full))
47
+ else if (entry.isFile() && entry.name.endsWith('.md')) files.push(full)
48
+ }
49
+ return files
50
+ }
51
+
52
+ /** Candidate repository paths: backticked tokens with a slash or a file suffix. */
53
+ function backtickedPaths(text) {
54
+ const paths = []
55
+ for (const match of String(text ?? '').matchAll(/`([^`]+)`/g)) {
56
+ const token = match[1].trim().replace(/\\/g, '/')
57
+ if (!token || token.startsWith('npm ') || token.startsWith('gh ') || token.includes(' ')) continue
58
+ if (!token.includes('/') && !/\.(?:mjs|js|cjs|ts|md|json|ya?ml)$/.test(token)) continue
59
+ paths.push(token)
60
+ }
61
+ return paths
62
+ }
63
+
64
+ function fieldText(body, label) {
65
+ const lines = String(body ?? '').split(/\r?\n/)
66
+ const start = lines.findIndex((line) => new RegExp(`^\\s*(?:[-*]\\s*)?\\**${label}:?\\**\\s*(.*)$`, 'i').test(line))
67
+ if (start < 0) return null
68
+ const match = new RegExp(`^\\s*(?:[-*]\\s*)?\\**${label}:?\\**\\s*(.*)$`, 'i').exec(lines[start])
69
+ const parts = [match?.[1] ?? '']
70
+ for (let index = start + 1; index < lines.length; index += 1) {
71
+ const line = lines[index]
72
+ if (!line.trim()) break
73
+ if (/^\s*(?:[-*]\s*)?\**Refused because of this:?/i.test(line)) break
74
+ if (/^\s*(?:[-*]\s*)?\**Enforced by:?/i.test(line)) break
75
+ if (/^#{1,6}\s+/.test(line)) break
76
+ parts.push(line.trim())
77
+ }
78
+ return parts.join(' ').replace(/\s+/g, ' ').trim() || null
79
+ }
80
+
81
+ /**
82
+ * Read PRINCIPLES.md into sections, then check each section's refusal and
83
+ * enforcement pointer. A section without both is not a principle.
84
+ * @param {string} text
85
+ * @param {(relative: string) => boolean} exists
86
+ */
87
+ export function checkPrinciplesText(text, exists) {
88
+ const findings = []
89
+ const headings = [...String(text ?? '').matchAll(/^##\s+(\d+)\.\s+(.+?)\s*$/gm)]
90
+ if (headings.length < MIN_PRINCIPLES) {
91
+ findings.push(finding('principles-count', `${PRINCIPLES_FILE} has ${headings.length} numbered principles; at least ${MIN_PRINCIPLES} are required`))
92
+ }
93
+ const principles = []
94
+ headings.forEach((heading, index) => {
95
+ const start = heading.index + heading[0].length
96
+ const end = index + 1 < headings.length ? headings[index + 1].index : text.length
97
+ const body = text.slice(start, end)
98
+ const number = Number(heading[1])
99
+ const title = heading[2].trim()
100
+ const refusal = fieldText(body, 'Refused because of this')
101
+ const enforcement = fieldText(body, 'Enforced by')
102
+ const record = { number, title, refusal, enforcement }
103
+ principles.push(record)
104
+ if (!record.refusal) {
105
+ findings.push(finding('principle-refusal', `principle ${number} (${title}) has no "Refused because of this:" line`))
106
+ }
107
+ if (!record.enforcement) {
108
+ findings.push(finding('principle-enforcement', `principle ${number} (${title}) has no "Enforced by:" line`))
109
+ return
110
+ }
111
+ const candidates = backtickedPaths(record.enforcement)
112
+ const missing = candidates.filter((candidate) => !exists(candidate))
113
+ if (candidates.length === 0) {
114
+ findings.push(finding('principle-pointer-missing', `principle ${number} (${title}) names no backticked file in "Enforced by:"`))
115
+ } else {
116
+ for (const candidate of missing) {
117
+ findings.push(
118
+ finding('principle-pointer-missing', `principle ${number} (${title}) points at a missing file: ${candidate}`),
119
+ )
120
+ }
121
+ }
122
+ if (!/(?:tests\/|\.test\.|npm run [a-z0-9:_-]+)/i.test(record.enforcement)) {
123
+ findings.push(
124
+ finding('principle-enforcement-weak', `principle ${number} (${title}) names no test or gate in "Enforced by:"`),
125
+ )
126
+ }
127
+ })
128
+ return { principles, findings }
129
+ }
130
+
131
+ /**
132
+ * Check that the ADR set is dated, structured and covers the three load-bearing
133
+ * decisions the issue asked to backfill.
134
+ * @param {string} root
135
+ */
136
+ export function checkAdrs(root) {
137
+ const findings = []
138
+ const dir = path.join(root, ADR_DIR)
139
+ const files = fs.existsSync(dir)
140
+ ? fs
141
+ .readdirSync(dir)
142
+ .filter((name) => /^\d{4}-.+\.md$/.test(name))
143
+ .sort()
144
+ : []
145
+ if (files.length < MIN_ADRS) {
146
+ findings.push(finding('adr-count', `${ADR_DIR} has ${files.length} numbered ADRs; at least ${MIN_ADRS} are required`))
147
+ }
148
+ const combined = []
149
+ for (const name of files) {
150
+ const relative = `${ADR_DIR}/${name}`
151
+ const text = fs.readFileSync(path.join(dir, name), 'utf8')
152
+ combined.push(text)
153
+ for (const section of ADR_REQUIRED_SECTIONS) {
154
+ const heading = new RegExp(`^#{1,6}\\s+${section}\\b`, 'im')
155
+ if (!heading.test(text)) findings.push(finding('adr-section', `${relative} misses the "${section}" section`))
156
+ }
157
+ if (!/^\s*(?:[-*]\s*)?Date:\s*(\d{4}-\d{2}-\d{2})\s*$/im.test(text)) {
158
+ findings.push(finding('adr-date', `${relative} needs a "Date: YYYY-MM-DD" line`))
159
+ }
160
+ if (!/^\s*(?:[-*]\s*)?Status:\s*\S+/im.test(text)) {
161
+ findings.push(finding('adr-status', `${relative} needs a "Status:" line`))
162
+ }
163
+ }
164
+ const all = combined.join('\n')
165
+ for (const topic of ADR_REQUIRED_TOPICS) {
166
+ if (!new RegExp(topic.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(all)) {
167
+ findings.push(finding('adr-topic', `no ADR explains the "${topic}" decision`))
168
+ }
169
+ }
170
+ return { adrDir: ADR_DIR, adrs: files, findings }
171
+ }
172
+
173
+ /**
174
+ * Every "Non-goals" heading in docs/ must carry an enforcement pointer that
175
+ * resolves or the explicit word `unowned`. No heading may be an orphan refusal.
176
+ * @param {string} root
177
+ */
178
+ export function checkNonGoals(root) {
179
+ const findings = []
180
+ const files = listMarkdown(path.join(root, 'docs'))
181
+ let headings = 0
182
+ for (const file of files) {
183
+ const relative = path.relative(root, file).replace(/\\/g, '/')
184
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/)
185
+ lines.forEach((line, index) => {
186
+ const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line)
187
+ if (!heading || !/\bnon[-\s]?goals?\b/i.test(heading[2])) return
188
+ headings += 1
189
+ const level = heading[1].length
190
+ const body = []
191
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
192
+ const next = /^(#{1,6})\s+/.exec(lines[cursor])
193
+ if (next && next[1].length <= level) break
194
+ body.push(lines[cursor])
195
+ }
196
+ const text = body.join('\n')
197
+ const enforcement = /^\s*(?:[-*]\s*)?\**Enforced by:?\**\s*(.+)$/im.exec(text)
198
+ const unowned = /\bunowned\b/i.test(text)
199
+ const enforced =
200
+ Boolean(unowned) ||
201
+ Boolean(enforcement && backtickedPaths(enforcement[1]).some((candidate) => fs.existsSync(path.join(root, candidate))))
202
+ if (!enforced) {
203
+ findings.push(
204
+ finding(
205
+ 'non-goal-unowned',
206
+ `${relative}:${index + 1} has a "Non-goals" heading with no existing enforcement pointer and no "unowned" label`,
207
+ ),
208
+ )
209
+ }
210
+ })
211
+ }
212
+ return { headings, findings }
213
+ }
214
+
215
+ /** Run all three checks against one repository root. */
216
+ export function checkPhilosophy(root) {
217
+ const findings = []
218
+ const principlesFile = path.join(root, PRINCIPLES_FILE)
219
+ let principles = []
220
+ if (!fs.existsSync(principlesFile)) {
221
+ findings.push(finding('principles-missing', `${PRINCIPLES_FILE} is missing`))
222
+ } else {
223
+ const result = checkPrinciplesText(fs.readFileSync(principlesFile, 'utf8'), (relative) =>
224
+ fs.existsSync(path.join(root, relative)),
225
+ )
226
+ principles = result.principles
227
+ findings.push(...result.findings)
228
+ }
229
+ const adr = checkAdrs(root)
230
+ findings.push(...adr.findings)
231
+ const nonGoals = checkNonGoals(root)
232
+ findings.push(...nonGoals.findings)
233
+ return {
234
+ ok: findings.length === 0,
235
+ principles,
236
+ adrs: adr.adrs,
237
+ nonGoals,
238
+ findings,
239
+ }
240
+ }
241
+
242
+ function main(argv) {
243
+ const options = parseArgs(argv, {
244
+ initial: { root: PACKAGE_ROOT, json: false, help: false },
245
+ values: { '--root': { key: 'root', parse: path.resolve } },
246
+ flags: { '--json': 'json', '--help': 'help', '-h': 'help' },
247
+ })
248
+ if (options.help) {
249
+ console.log(USAGE)
250
+ return 0
251
+ }
252
+ const report = checkPhilosophy(path.resolve(options.root))
253
+ if (options.json) {
254
+ console.log(JSON.stringify(report, null, 2))
255
+ return report.ok ? 0 : 1
256
+ }
257
+ console.log(
258
+ `philosophy: ${report.principles.length} principles, ${report.adrs.length} ADRs, ${report.nonGoals.headings} non-goal heading(s)`,
259
+ )
260
+ for (const item of report.findings) console.log(`philosophy: ${item.level} [${item.code}] ${item.message}`)
261
+ if (report.ok) console.log('philosophy: ok — every refusal points at an existing test or gate')
262
+ else console.log(`philosophy: ${report.findings.length} unenforced statement(s)`)
263
+ return report.ok ? 0 : 1
264
+ }
265
+
266
+ await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'philosophy', usage: USAGE })
@@ -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
  }
@@ -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
  }