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
package/domain/state.js CHANGED
@@ -1,17 +1,123 @@
1
- // Pure state-file validation (#56 P4).
1
+ // Pure state-file validation and migration decisions (#56 P4, #72).
2
2
  //
3
- // The structural contract lives in `state/schema.js` (TypeBox) and the policy
4
- // checks in `state/semantics.js`. This module stays the public entry point so
5
- // every app/tool import of `domain/state.js` is unchanged.
3
+ // The structural contract lives in `state/schema.js` (TypeBox), the policy
4
+ // checks in `state/semantics.js`, and the version registry in
5
+ // `state/migrations.js`. This module stays the public entry point so every
6
+ // app/tool import of `domain/state.js` is unchanged.
7
+ //
8
+ // `validateState` proves a document matches the *current* schema.
9
+ // `prepareState` is the load-path decision: it inspects the version, applies the
10
+ // ordered migration registry, then validates the target schema. Both are pure;
11
+ // the side effects (backup, write, activity) live in `adapters/stateFile.js`.
6
12
  // @ts-check
7
13
  import { TowerToolError } from './errors.js'
14
+ import { SCHEMA_VERSION } from './mission.js'
15
+ import { migrationChain, migrateState } from './state/migrations.js'
8
16
  import { stateShapeError } from './state/schema.js'
9
17
  import { assertStateSemantics } from './state/semantics.js'
10
18
 
11
19
  /** @typedef {import('../types/index.js').TowerState} TowerState */
20
+ /** @typedef {{ from: number, to: number }} MigrationStep */
21
+ /** @typedef {import('./state/migrations.js').StateMigration} StateMigration */
12
22
 
13
23
  export { StateSchema, stateShapeError } from './state/schema.js'
14
24
 
25
+ /**
26
+ * @param {unknown} value
27
+ * @returns {boolean}
28
+ */
29
+ function isPlainObject(value) {
30
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
31
+ }
32
+
33
+ /**
34
+ * Classify a document's `schemaVersion` without throwing. This is the read path
35
+ * doctor uses, so it must stay usable on a repository that cannot load.
36
+ * @param {unknown} state
37
+ * @param {string} [statePath]
38
+ * @param {readonly StateMigration[]} [migrations] registry to classify against
39
+ * @returns {{
40
+ * status: 'current'|'migratable'|'newer'|'unmigratable'|'invalid',
41
+ * currentVersion: number|null,
42
+ * supportedVersion: number,
43
+ * migrations: MigrationStep[],
44
+ * message?: string,
45
+ * }}
46
+ */
47
+ export function inspectStateVersion(state, statePath, migrations) {
48
+ const where = statePath ? ` at ${statePath}` : ''
49
+ if (!isPlainObject(state)) {
50
+ return {
51
+ status: 'invalid',
52
+ currentVersion: null,
53
+ supportedVersion: SCHEMA_VERSION,
54
+ migrations: [],
55
+ message: `tower state is not an object${where}`,
56
+ }
57
+ }
58
+ const version = /** @type {Record<string, any>} */ (state).schemaVersion
59
+ if (!Number.isInteger(version) || version < 1) {
60
+ return {
61
+ status: 'invalid',
62
+ currentVersion: null,
63
+ supportedVersion: SCHEMA_VERSION,
64
+ migrations: [],
65
+ message: `tower state has no usable schemaVersion (${JSON.stringify(version)})${where}`,
66
+ }
67
+ }
68
+ if (version === SCHEMA_VERSION) {
69
+ return { status: 'current', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
70
+ }
71
+ if (version > SCHEMA_VERSION) {
72
+ return { status: 'newer', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
73
+ }
74
+ const chain = migrationChain(version, SCHEMA_VERSION, migrations)
75
+ if (!chain) {
76
+ return { status: 'unmigratable', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
77
+ }
78
+ return {
79
+ status: 'migratable',
80
+ currentVersion: version,
81
+ supportedVersion: SCHEMA_VERSION,
82
+ migrations: chain.map((step) => ({ from: step.from, to: step.to })),
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @param {{ status: string, currentVersion: number|null, supportedVersion: number, message?: string }} info
88
+ * @param {string} [statePath]
89
+ * @returns {TowerToolError}
90
+ */
91
+ function versionError(info, statePath) {
92
+ const where = statePath ? ` at ${statePath}` : ''
93
+ if (info.status === 'newer') {
94
+ return new TowerToolError(
95
+ 'state-version-unsupported',
96
+ `tower state${where} has schemaVersion ${info.currentVersion}, but this build supports ${info.supportedVersion}: it was written by a newer dsh-tower and will not be downgraded`,
97
+ {
98
+ nextAction: `upgrade dsh-tower to a release that supports state schemaVersion ${info.currentVersion}; the file was left untouched`,
99
+ },
100
+ )
101
+ }
102
+ if (info.status === 'migratable') {
103
+ return new TowerToolError(
104
+ 'state-version-unsupported',
105
+ `tower state${where} has schemaVersion ${info.currentVersion}; load it through the state loader to migrate it to ${info.supportedVersion}`,
106
+ { nextAction: 'call any tower tool; mutations apply the migration under the state lock' },
107
+ )
108
+ }
109
+ if (info.status === 'unmigratable') {
110
+ return new TowerToolError(
111
+ 'state-version-unsupported',
112
+ `tower state${where} has schemaVersion ${info.currentVersion}, which has no migration to ${info.supportedVersion}`,
113
+ {
114
+ nextAction: `restore a state.json written by dsh-tower ${info.supportedVersion}; this build will not guess at an unknown schema`,
115
+ },
116
+ )
117
+ }
118
+ return new TowerToolError('state-corrupt', info.message ?? `tower state is not usable${where}`)
119
+ }
120
+
15
121
  /**
16
122
  * @param {string} message
17
123
  * @returns {never}
@@ -21,13 +127,57 @@ function fail(message) {
21
127
  }
22
128
 
23
129
  /**
130
+ * Prove a document matches the current schema. Any other version is rejected
131
+ * with a version error rather than corruption; the load path migrates first.
24
132
  * @param {unknown} state
25
133
  * @param {string} [statePath]
26
134
  * @returns {TowerState}
27
135
  */
28
136
  export function validateState(state, statePath) {
137
+ const info = inspectStateVersion(state, statePath)
138
+ if (info.status !== 'current') throw versionError(info, statePath)
29
139
  const where = statePath ? ` at ${statePath}` : ''
30
140
  const shapeError = stateShapeError(state, where)
31
141
  if (shapeError) fail(shapeError)
32
142
  return assertStateSemantics(state, where)
33
143
  }
144
+
145
+ /**
146
+ * The pure load-path decision: migrate an older document through the registry,
147
+ * validate the result against the target schema, then report what happened. The
148
+ * returned state is the validated migrated document; the caller decides whether
149
+ * to persist it (only while holding the state lock).
150
+ *
151
+ * @param {unknown} state
152
+ * @param {string} [statePath]
153
+ * @param {{ migrations?: readonly StateMigration[] }} [options]
154
+ * @returns {{ state: TowerState, from: number, to: number, migrated: MigrationStep[], changed: boolean }}
155
+ */
156
+ export function prepareState(state, statePath, options = {}) {
157
+ const info = inspectStateVersion(state, statePath, options.migrations)
158
+ if (info.status === 'current') {
159
+ return {
160
+ state: validateState(state, statePath),
161
+ from: SCHEMA_VERSION,
162
+ to: SCHEMA_VERSION,
163
+ migrated: [],
164
+ changed: false,
165
+ }
166
+ }
167
+ if (info.status !== 'migratable') throw versionError(info, statePath)
168
+ const outcome = migrateState(/** @type {Record<string, any>} */ (state), {
169
+ statePath,
170
+ to: info.supportedVersion,
171
+ migrations: options.migrations,
172
+ })
173
+ // Validation runs on the *target* document: a shape that was invalid under
174
+ // the old schema (a missing `tasks` array, say) may be valid after a backfill,
175
+ // and validating the target is what proves the migration did its job.
176
+ return {
177
+ state: validateState(outcome.state, statePath),
178
+ from: outcome.from,
179
+ to: outcome.to,
180
+ migrated: outcome.steps,
181
+ changed: true,
182
+ }
183
+ }
package/domain/summary.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Pure mission/finding summaries.
2
2
  // @ts-check
3
3
  import { FINDING_STATUSES } from './mission.js'
4
+ import { completionReportBytes } from './missions/completion.js'
4
5
 
5
6
  /** @typedef {import('../types/index.js').Finding} Finding */
6
7
  /** @typedef {import('../types/index.js').FindingSummary} FindingSummary */
@@ -53,6 +54,7 @@ export function missionSummary(mission) {
53
54
  reviewPolicy: mission.reviewPolicy ?? 'required-clean',
54
55
  mergedCommit: mission.mergedCommit ?? null,
55
56
  mergedAt: mission.mergedAt ?? null,
57
+ reportBytes: completionReportBytes(mission.completionReport),
56
58
  updatedAt: mission.updatedAt,
57
59
  }
58
60
  }
@@ -0,0 +1,248 @@
1
+ // Concept budget: surface metrics, the committed baseline and its classifications (#84).
2
+ //
3
+ // The repository does not measure "how many concepts a user has to hold" by
4
+ // counting lines. It measures the surface — config leaf keys, config sections and
5
+ // registered tools — and keeps a committed baseline. Growth is refused by
6
+ // default: a baseline decision that raises a count must name either an ADR (why
7
+ // the surface has to grow) or deletions that are provably gone from the live
8
+ // surface (the added concept replaced a removed one).
9
+ //
10
+ // This module is pure: no fs, no git. `scripts/surface-baseline.mjs` supplies
11
+ // the live counts and the file reads; `scripts/lib/surface.mjs` wraps them.
12
+ export const SURFACE_BASELINE_FILE = 'scripts/surface-baseline.json'
13
+ export const SURFACE_CLASSIFICATION_FILE = 'docs/reference/surface-budget.md'
14
+ export const SURFACE_KEYS = ['configKeys', 'sections', 'tools']
15
+ export const CONFIG_CLASSES = ['core', 'tuning', 'niche']
16
+ export const TOOL_CLASSES = ['core', 'optional']
17
+ export const SURFACE_SCHEMA_VERSION = 1
18
+
19
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
20
+
21
+ /**
22
+ * The human-readable count line shared by `tower_doctor` and the generated
23
+ * references: `config keys: N · sections: N · tools: N`.
24
+ * @param {{ configKeys?: number, sections?: number, tools?: number }} counts
25
+ */
26
+ export function surfaceLine(counts = {}) {
27
+ const value = (key) => (Number.isInteger(counts[key]) ? String(counts[key]) : '?')
28
+ return `config keys: ${value('configKeys')} · sections: ${value('sections')} · tools: ${value('tools')}`
29
+ }
30
+
31
+ function isPlainObject(value) {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
33
+ }
34
+
35
+ function surfaceError(message) {
36
+ return new Error(message)
37
+ }
38
+
39
+ /**
40
+ * Parse and validate `scripts/surface-baseline.json`. Throws with an actionable
41
+ * message when the file is malformed; returns a normalized copy otherwise.
42
+ * @param {string | object} source
43
+ */
44
+ export function parseBaseline(source) {
45
+ let value
46
+ if (typeof source === 'string') {
47
+ try {
48
+ value = JSON.parse(source)
49
+ } catch (error) {
50
+ throw surfaceError(`surface baseline is not valid JSON: ${error?.message ?? error}`)
51
+ }
52
+ } else {
53
+ value = source
54
+ }
55
+ if (!isPlainObject(value)) throw surfaceError('surface baseline must be a JSON object')
56
+ if (value.schema !== undefined && value.schema !== SURFACE_SCHEMA_VERSION) {
57
+ throw surfaceError(`surface baseline schema ${value.schema} is not supported (expected ${SURFACE_SCHEMA_VERSION})`)
58
+ }
59
+ if (typeof value.date !== 'string' || !ISO_DATE.test(value.date)) {
60
+ throw surfaceError(`surface baseline needs a date (YYYY-MM-DD), got ${JSON.stringify(value.date)}`)
61
+ }
62
+ if (!isPlainObject(value.surfaces)) throw surfaceError('surface baseline needs a "surfaces" object')
63
+ const surfaces = {}
64
+ for (const key of SURFACE_KEYS) {
65
+ const entry = value.surfaces[key]
66
+ if (!isPlainObject(entry)) throw surfaceError(`surface baseline misses the "${key}" entry`)
67
+ if (!Number.isInteger(entry.seed) || entry.seed < 0) {
68
+ throw surfaceError(`surface baseline ${key}.seed must be a non-negative integer`)
69
+ }
70
+ if (!Number.isInteger(entry.value) || entry.value < 0) {
71
+ throw surfaceError(`surface baseline ${key}.value must be a non-negative integer`)
72
+ }
73
+ if (!Array.isArray(entry.decisions)) throw surfaceError(`surface baseline ${key}.decisions must be an array`)
74
+ surfaces[key] = {
75
+ seed: entry.seed,
76
+ value: entry.value,
77
+ decisions: entry.decisions.map((decision, index) => {
78
+ if (!isPlainObject(decision)) {
79
+ throw surfaceError(`surface baseline ${key}.decisions[${index}] must be an object`)
80
+ }
81
+ if (!Number.isInteger(decision.from) || decision.from < 0) {
82
+ throw surfaceError(`surface baseline ${key}.decisions[${index}].from must be a non-negative integer`)
83
+ }
84
+ if (!Number.isInteger(decision.to) || decision.to < 0) {
85
+ throw surfaceError(`surface baseline ${key}.decisions[${index}].to must be a non-negative integer`)
86
+ }
87
+ return { ...decision }
88
+ }),
89
+ }
90
+ }
91
+ return { schema: SURFACE_SCHEMA_VERSION, date: value.date, surfaces }
92
+ }
93
+
94
+ /**
95
+ * The rows of the classification table, already parsed. `kind` is `config` or
96
+ * `tools`, `name` is the key/tool name, `className` is the declared class.
97
+ * @param {string} markdown
98
+ */
99
+ export function parseClassification(markdown) {
100
+ const text = String(markdown ?? '')
101
+ const dateMatch = /^\s*(?:[-*]\s*)?Decision date:\s*(\d{4}-\d{2}-\d{2})\s*$/im.exec(text)
102
+ const entries = []
103
+ let kind = null
104
+ for (const rawLine of text.split(/\r?\n/)) {
105
+ const heading = /^#{1,6}\s+(.+?)\s*$/.exec(rawLine)
106
+ if (heading) {
107
+ const title = heading[1].toLowerCase()
108
+ kind = title.includes('config') ? 'config' : title.includes('tool') ? 'tools' : null
109
+ continue
110
+ }
111
+ if (!kind) continue
112
+ const match = /^\|\s*`([^`]+)`\s*\|\s*([a-z-]+)\s*\|\s*(.*?)\s*\|\s*$/.exec(rawLine)
113
+ if (!match) continue
114
+ entries.push({ kind, name: match[1].trim(), className: match[2].trim().toLowerCase(), note: match[3].trim() })
115
+ }
116
+ return { date: dateMatch ? dateMatch[1] : null, entries }
117
+ }
118
+
119
+ /**
120
+ * Validate the classification table against the live surface. Every live key and
121
+ * tool must appear exactly once with a legal class; every `niche` row must carry
122
+ * a removal note, so "we will remove it later" is a written promise rather than
123
+ * an implication.
124
+ * @param {string} markdown
125
+ * @param {{ configKeys: string[], sections: string[], tools: string[] }} liveNames
126
+ * @param {{ baselineDate?: string }} [options]
127
+ * @returns {{ date: string | null, entries: Array<{ kind: string, name: string, className: string, note: string }>, findings: Array<{ code: string, level: string, message: string }> }}
128
+ */
129
+ export function validateClassification(markdown, liveNames, options = {}) {
130
+ const parsed = parseClassification(markdown)
131
+ const findings = []
132
+ const text = String(markdown ?? '')
133
+ const record = (code, message) => findings.push({ code, level: 'error', message })
134
+ if (!parsed.date) {
135
+ record('classification-date', 'classification table needs a "Decision date: YYYY-MM-DD" line')
136
+ } else if (options.baselineDate && parsed.date !== options.baselineDate) {
137
+ record(
138
+ 'classification-date',
139
+ `classification date ${parsed.date} does not match the baseline date ${options.baselineDate}`,
140
+ )
141
+ }
142
+
143
+ const checkRows = (kind, expected, legal) => {
144
+ const rows = parsed.entries.filter((entry) => entry.kind === kind)
145
+ const seen = new Map()
146
+ for (const row of rows) {
147
+ if (seen.has(row.name)) record('classification-duplicate', `${kind === 'config' ? 'config key' : 'tool'} ${row.name} is classified twice`)
148
+ seen.set(row.name, row)
149
+ if (!legal.includes(row.className)) {
150
+ record(
151
+ 'classification-class',
152
+ `${kind === 'config' ? 'config key' : 'tool'} ${row.name} has class "${row.className}" (expected ${legal.join('/')})`,
153
+ )
154
+ }
155
+ }
156
+ for (const name of expected) {
157
+ if (!seen.has(name)) record('classification-missing', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not classified`)
158
+ }
159
+ const expectedSet = new Set(expected)
160
+ for (const name of seen.keys()) {
161
+ if (!expectedSet.has(name)) record('classification-unknown', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not in the live surface`)
162
+ }
163
+ return rows
164
+ }
165
+
166
+ const configRows = checkRows('config', liveNames.configKeys, CONFIG_CLASSES)
167
+ checkRows('tools', liveNames.tools, TOOL_CLASSES)
168
+ const nicheRows = configRows.filter((row) => row.className === 'niche')
169
+ for (const row of nicheRows) {
170
+ if (!/removal note:/i.test(row.note)) {
171
+ record('niche-removal-note', `niche config key ${row.name} needs a "Removal note:" in its table row`)
172
+ }
173
+ }
174
+ if (nicheRows.length === 0 && !/no key qualifies as niche/i.test(text)) {
175
+ record('niche-removal-note', 'no niche config key has a removal note and the table does not say "no key qualifies as niche"')
176
+ }
177
+ return { date: parsed.date, entries: parsed.entries, findings }
178
+ }
179
+
180
+ /**
181
+ * Validate a committed baseline against the live counts and its own decision
182
+ * chain. This is the brake: `current[surface] !== entry.value` fails, and every
183
+ * decision that grows a count must name an existing ADR or deletions that are
184
+ * provably absent from the live surface.
185
+ *
186
+ * @param {ReturnType<typeof parseBaseline>} baseline
187
+ * @param {{ configKeys: number, sections: number, tools: number, liveNames: { configKeys: string[], sections: string[], tools: string[] } }} current
188
+ * @param {{ exists?: (relativePath: string) => boolean }} [options]
189
+ * @returns {Array<{ code: string, level: string, message: string }>}
190
+ */
191
+ export function validateBaseline(baseline, current, options = {}) {
192
+ const exists = options.exists ?? (() => false)
193
+ const findings = []
194
+ const record = (code, message) => findings.push({ code, level: 'error', message })
195
+
196
+ for (const key of SURFACE_KEYS) {
197
+ const entry = baseline.surfaces[key]
198
+ const live = current[key]
199
+ if (!Number.isInteger(live)) {
200
+ record('surface-missing', `live ${key} count is unavailable`)
201
+ continue
202
+ }
203
+ if (live !== entry.value) {
204
+ const direction = live > entry.value ? 'grew' : 'shrank'
205
+ record(
206
+ 'stale-baseline',
207
+ `${key} ${direction} from the committed baseline ${entry.value} to ${live}; update ${SURFACE_BASELINE_FILE} with an ADR or a deletion record before landing`,
208
+ )
209
+ }
210
+ let running = entry.seed
211
+ entry.decisions.forEach((decision, index) => {
212
+ const where = `${key}.decisions[${index}]`
213
+ if (decision.from !== running) {
214
+ record('broken-chain', `${where}.from ${decision.from} does not continue from ${running}`)
215
+ }
216
+ if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
217
+ record('broken-chain', `${where}.date is not YYYY-MM-DD`)
218
+ }
219
+ const growth = decision.to - decision.from
220
+ if (growth > 0) {
221
+ const deletions = Array.isArray(decision.deletions)
222
+ ? decision.deletions.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim())
223
+ : []
224
+ const adr = typeof decision.adr === 'string' && decision.adr.trim() ? decision.adr.trim().replace(/\\/g, '/') : null
225
+ const adrExists = Boolean(adr) && adr.startsWith('docs/adr/') && adr.endsWith('.md') && exists(adr)
226
+ const liveNames = new Set(current.liveNames?.[key] ?? [])
227
+ const stillLive = deletions.filter((name) => liveNames.has(name))
228
+ const deletionEvidence = deletions.length >= growth && stillLive.length === 0
229
+ if (!adrExists && !deletionEvidence) {
230
+ const reasons = []
231
+ if (adr && !adrExists) reasons.push(`ADR ${adr} does not exist under docs/adr/`)
232
+ if (deletions.length === 0) reasons.push('no deletions recorded')
233
+ else if (stillLive.length > 0) reasons.push(`deleted name(s) are still in the live surface: ${stillLive.join(', ')}`)
234
+ else if (deletions.length < growth) reasons.push(`only ${deletions.length} deletion(s) for a growth of ${growth}`)
235
+ record('growth-without-evidence', `${where} grows ${key} by ${growth} without an ADR or a matching deletion (${reasons.join('; ')})`)
236
+ }
237
+ }
238
+ running = decision.to
239
+ })
240
+ if (running !== entry.value && !(entry.decisions.length === 0 && entry.value < running)) {
241
+ record(
242
+ 'broken-chain',
243
+ `${key} value ${entry.value} does not match the last decision (${running}); every count change needs a decision record`,
244
+ )
245
+ }
246
+ }
247
+ return findings
248
+ }