dsh-tower 2.3.0 → 2.4.1

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 (83) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +20 -3
  4. package/PRINCIPLES.md +18 -0
  5. package/README.en.md +29 -16
  6. package/README.md +22 -15
  7. package/cordis.patch.yml +56 -28
  8. package/docs/README.md +2 -1
  9. package/docs/adr/0004-surface-budget.md +20 -0
  10. package/docs/adr/0005-component-surface.md +130 -0
  11. package/docs/adr/README.md +1 -0
  12. package/docs/agents/workflow.md +1 -1
  13. package/docs/architecture/overview.md +69 -7
  14. package/docs/guide/hot-reload.md +4 -4
  15. package/docs/guide/install-upgrade-migration.md +13 -0
  16. package/docs/operations/security.md +1 -1
  17. package/docs/reference/component-surface.md +135 -0
  18. package/docs/reference/config.generated.md +2 -10
  19. package/docs/reference/config.md +11 -9
  20. package/docs/reference/doctor.md +14 -9
  21. package/docs/reference/surface-budget.md +52 -23
  22. package/docs/reference/tools.generated.md +4 -2
  23. package/docs/reference/tools.md +6 -3
  24. package/docs/zh/README.md +1 -1
  25. package/docs/zh/guide/hot-reload.md +3 -3
  26. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  27. package/docs/zh/operations/security.md +1 -1
  28. package/docs/zh/reference/config.md +9 -9
  29. package/docs/zh/reference/tools.md +6 -4
  30. package/domain/components.js +641 -0
  31. package/domain/config/schema.js +37 -27
  32. package/domain/contracts/outputs.js +10 -0
  33. package/domain/errors.js +11 -1
  34. package/domain/mission.js +12 -1
  35. package/domain/surface.js +154 -55
  36. package/host/capability-batch.js +15 -0
  37. package/host/capability-hmr.js +30 -0
  38. package/host/capability-import.js +11 -0
  39. package/host/capability-leases.js +11 -0
  40. package/host/capability-registry.js +149 -0
  41. package/host/capability-tests.js +16 -0
  42. package/host/doctor.js +41 -9
  43. package/host/hmr.js +2 -4
  44. package/host/presets.js +51 -9
  45. package/host/tool-registry.js +150 -34
  46. package/index.js +32 -20
  47. package/locale/batch/en.json +6 -0
  48. package/locale/batch/zh.json +6 -0
  49. package/locale/en.json +31 -11
  50. package/locale/hmr/en.json +6 -0
  51. package/locale/hmr/zh.json +6 -0
  52. package/locale/import/en.json +6 -0
  53. package/locale/import/zh.json +6 -0
  54. package/locale/leases/en.json +6 -0
  55. package/locale/leases/zh.json +6 -0
  56. package/locale/skills/en.json +6 -0
  57. package/locale/skills/zh.json +6 -0
  58. package/locale/tests/en.json +6 -0
  59. package/locale/tests/zh.json +6 -0
  60. package/locale/zh.json +30 -10
  61. package/package.json +25 -4
  62. package/scripts/check-components.mjs +235 -0
  63. package/scripts/check-pack.mjs +4 -0
  64. package/scripts/component-surface.json +395 -0
  65. package/scripts/generate-capability-locale.mjs +148 -0
  66. package/scripts/generate-component-surface.mjs +59 -0
  67. package/scripts/generate-tools-reference.mjs +7 -25
  68. package/scripts/generate-types.mjs +127 -0
  69. package/scripts/lib/tool-surface.mjs +85 -0
  70. package/scripts/mount-smoke/fixtures.mjs +3 -3
  71. package/scripts/mount-smoke/surface.mjs +96 -27
  72. package/scripts/mount-smoke.mjs +3 -1
  73. package/scripts/pack-manifest.txt +30 -0
  74. package/scripts/surface-baseline.json +35 -4
  75. package/scripts/surface-baseline.mjs +32 -34
  76. package/scripts/types-coverage.mjs +184 -0
  77. package/scripts/types-strict-manifest.txt +10 -0
  78. package/scripts/types-strict.mjs +141 -0
  79. package/skills.js +8 -1
  80. package/tools/index.js +2 -1
  81. package/tools/reload.js +34 -35
  82. package/tools/status.js +8 -1
  83. package/types/index.d.ts +28 -12
@@ -33,7 +33,10 @@ export const DEFAULT_DENY_TOOLS = [
33
33
  ]
34
34
 
35
35
  const LEGACY_KEYS = ['section', 'allowAgentControl']
36
- const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev', 'tools']
36
+ // #121: the `tools` group is gone. Every capability that used to be a
37
+ // `tools.expose*` boolean is now a component row in `cordis.patch.yml`, and a
38
+ // component row is the switch. What remains here is genuine tuning policy.
39
+ const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev']
37
40
 
38
41
  const SECTION_KEYS = {
39
42
  init: ['autoGitInit', 'snapshotDirty', 'autoExclude'],
@@ -75,9 +78,8 @@ const SECTION_KEYS = {
75
78
  gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
76
79
  docs: ['board', 'boardLocation'],
77
80
  archive: ['enabled', 'afterDays', 'maxMissions'],
78
- tools: ['exposeOperational', 'exposeBatch', 'exposeImport', 'exposeLeases', 'exposeTests', 'exposeRecovery', 'exposeDoctor'],
79
81
  leases: ['ttlMs'],
80
- dev: ['hotReload', 'reloadDelayMs', 'reloadRequiredIds'],
82
+ dev: ['reloadDelayMs', 'reloadRequiredIds'],
81
83
  }
82
84
 
83
85
  const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
@@ -261,30 +263,16 @@ export const Config = Schema.object({
261
263
  })
262
264
  .default({})
263
265
  .description('Terminal-mission archival policy. Archived missions are moved, never deleted.'),
264
- tools: Schema.object({
265
- exposeOperational: Schema.boolean()
266
- .default(true)
267
- .description('Master switch for batch/resource/import/legacy/recover/test/doctor tools.'),
268
- exposeBatch: Schema.boolean().default(true).description('Register tower_batch.'),
269
- exposeImport: Schema.boolean().default(true).description('Register tower_import_legacy/tower_legacy_map.'),
270
- exposeLeases: Schema.boolean().default(true).description('Register tower_resource advisory leases.'),
271
- exposeTests: Schema.boolean().default(true).description('Register tower_test; gates.requireTests requires it.'),
272
- exposeRecovery: Schema.boolean().default(true).description('Register tower_recover (stale state-lock escape).'),
273
- exposeDoctor: Schema.boolean().default(true).description('Register tower_doctor (read-only health contract).'),
274
- })
275
- .default({})
276
- .description('Tool-surface policy.'),
277
266
  leases: Schema.object({
278
267
  ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
279
268
  })
280
269
  .default({})
281
270
  .description('Advisory resource leases.'),
282
271
  dev: Schema.object({
283
- hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
284
272
  reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
285
273
  reloadRequiredIds: Schema.array(Schema.string())
286
- .default(['tower-bundle', 'tower', 'tower-skills', 'preset-tower-worker', 'preset-tower-reviewer'])
287
- .description('Loader entry ids passed to reconcileProfile as required.'),
274
+ .default(['tower', 'tower-host', 'preset-tower-worker'])
275
+ .description('Loader entry ids passed to reconcileProfile as required. Optional component rows are deliberately not required, so a row switched off is not a reload failure.'),
288
276
  })
289
277
  .default({})
290
278
  .description('Development and hot-reload behavior.'),
@@ -314,14 +302,23 @@ export function configSurface(config = Config) {
314
302
  return { keys, sections, count: keys.length }
315
303
  }
316
304
 
305
+ /** @param {any} value */
317
306
  export function clone(value) {
318
307
  return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
319
308
  }
320
309
 
310
+ /**
311
+ * @param {any} value
312
+ * @returns {boolean}
313
+ */
321
314
  export function isPlainObject(value) {
322
315
  return value !== null && typeof value === 'object' && !Array.isArray(value)
323
316
  }
324
317
 
318
+ /**
319
+ * @param {any} base
320
+ * @param {any} override
321
+ */
325
322
  export function deepMerge(base, override) {
326
323
  if (override === undefined) return clone(base) ?? {}
327
324
  if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
@@ -350,6 +347,10 @@ export function normalizeLegacy(raw = {}) {
350
347
  return out
351
348
  }
352
349
 
350
+ /**
351
+ * @param {any} value
352
+ * @param {string} label
353
+ */
353
354
  function assertModelKeys(value, label) {
354
355
  const bad = Object.keys(value).filter((key) => !MODEL_KEYS.includes(key))
355
356
  if (bad.length > 0) {
@@ -357,12 +358,16 @@ function assertModelKeys(value, label) {
357
358
  }
358
359
  }
359
360
 
361
+ /**
362
+ * @param {any} raw
363
+ * @param {string} label
364
+ */
360
365
  export function assertKnownKeys(raw, label) {
361
366
  const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
362
367
  if (unknown.length > 0) {
363
368
  throw new TowerToolError(
364
369
  'config-unknown-key',
365
- `${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev?, tools? }`,
370
+ `${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev? }`,
366
371
  )
367
372
  }
368
373
  for (const [group, allowed] of Object.entries(SECTION_KEYS)) {
@@ -387,7 +392,11 @@ export function assertKnownKeys(raw, label) {
387
392
 
388
393
  const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
389
394
 
390
- /** Reject prototype-pollution style keys anywhere in a repository config. */
395
+ /**
396
+ * Reject prototype-pollution style keys anywhere in a repository config.
397
+ * @param {any} value
398
+ * @param {string} label
399
+ */
391
400
  export function assertSafeKeys(value, label) {
392
401
  if (Array.isArray(value)) {
393
402
  for (const item of value) assertSafeKeys(item, label)
@@ -402,6 +411,7 @@ export function assertSafeKeys(value, label) {
402
411
  }
403
412
  }
404
413
 
414
+ /** @param {any} [rawConfig] */
405
415
  export function resolveTowerConfig(rawConfig) {
406
416
  const raw = rawConfig ?? {}
407
417
  if (raw !== null && typeof raw !== 'object') throw new TowerToolError('config-invalid', 'Tower config must be an object')
@@ -413,11 +423,12 @@ export function resolveTowerConfig(rawConfig) {
413
423
  throw new TowerToolError('config-invalid', `Tower config is invalid: ${String(error?.message ?? error)}`)
414
424
  }
415
425
  const mode = clone(parsed.mode ?? {})
416
- if (parsed.gates?.requireTests === true) {
417
- const testsAvailable = parsed.tests?.enabled !== false && parsed.tools?.exposeOperational !== false && parsed.tools?.exposeTests !== false
418
- if (!testsAvailable) {
419
- throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled/tools.exposeTests)')
420
- }
426
+ if (parsed.gates?.requireTests === true && parsed.tests?.enabled === false) {
427
+ // #121: the tower-tests *component row* is the capability switch now; a
428
+ // repository that turns the row off cannot satisfy gates.requireTests. The
429
+ // component-level half of this invariant is enforced at plan/mode entry,
430
+ // where the mounted rows are known (host/presets.js).
431
+ throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled:false)')
421
432
  }
422
433
  // Canonical keys win; the legacy top-level aliases only fill gaps.
423
434
  const rawMode = raw.mode ?? {}
@@ -444,7 +455,6 @@ export function resolveTowerConfig(rawConfig) {
444
455
  gates: clone(parsed.gates ?? {}),
445
456
  docs: clone(parsed.docs ?? {}),
446
457
  archive: clone(parsed.archive ?? {}),
447
- tools: clone(parsed.tools ?? {}),
448
458
  leases: clone(parsed.leases ?? {}),
449
459
  dev: clone(parsed.dev ?? {}),
450
460
  }
@@ -4,7 +4,12 @@ const S = {
4
4
  str: { type: 'string' },
5
5
  int: { type: 'integer' },
6
6
  bool: { type: 'boolean' },
7
+ /** @param {any} items */
7
8
  arr: (items) => ({ type: 'array', items }),
9
+ /**
10
+ * @param {Record<string, any>} [properties]
11
+ * @param {string[]} [required]
12
+ */
8
13
  obj: (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: true }),
9
14
  any: {},
10
15
  }
@@ -298,10 +303,15 @@ const SCHEMAS = {
298
303
  legacyMap: S.obj({ ok: S.bool, map: S.obj(), maps: S.arr(S.obj()) }, ['ok']),
299
304
  }
300
305
 
306
+ /**
307
+ * @param {any} _args
308
+ * @param {any} value
309
+ */
301
310
  function renderValue(_args, value) {
302
311
  return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
303
312
  }
304
313
 
314
+ /** @param {any} schema */
305
315
  function output(schema) {
306
316
  return { schema, render: renderValue }
307
317
  }
package/domain/errors.js CHANGED
@@ -24,6 +24,12 @@
24
24
 
25
25
  /** Neutral error type shared by the git adapter and the domain classifier. */
26
26
  export class GitError extends Error {
27
+ /**
28
+ * @param {string[]} args
29
+ * @param {string} detail
30
+ * @param {number | undefined} code the child-process errno, not a Tower code
31
+ * @param {{ timedOut?: boolean, bufferOverflow?: boolean, stdout?: string }} [meta]
32
+ */
27
33
  constructor(args, detail, code, meta = {}) {
28
34
  super(`git ${args.join(' ')} failed: ${detail}`)
29
35
  this.name = 'GitError'
@@ -327,11 +333,15 @@ export class TowerToolError extends Error {
327
333
  }
328
334
  }
329
335
 
336
+ /** @param {any} error */
330
337
  function originalMessage(error) {
331
338
  return String(error?.message ?? error)
332
339
  }
333
340
 
334
- /** Wrap any thrown value into a TowerToolError with a stable code. */
341
+ /**
342
+ * Wrap any thrown value into a TowerToolError with a stable code.
343
+ * @param {any} error
344
+ */
335
345
  export function classifyError(error) {
336
346
  if (error instanceof TowerToolError) return error
337
347
  if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
package/domain/mission.js CHANGED
@@ -12,7 +12,18 @@ export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'p
12
12
  export const SCHEMA_VERSION = 3
13
13
  /** @type {readonly MergeVerdict[]} */
14
14
  export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
15
- export const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
15
+ // #115: the review status vocabulary is one runtime source. The validator
16
+ // accepts any positive item count (`p1-7items`), while the published
17
+ // `ReviewStatus` type is generated from REVIEW_STATUS_ITEMS, which fixes the
18
+ // documented 1..5 range so a consumer sees a closed union.
19
+ export const REVIEW_STATUS_PRIORITIES = ['p1', 'p2']
20
+ export const REVIEW_STATUS_ITEMS = [1, 2, 3, 4, 5]
21
+ /** @type {readonly string[]} */
22
+ export const REVIEW_STATUSES = [
23
+ 'clean',
24
+ ...REVIEW_STATUS_PRIORITIES.flatMap((priority) => REVIEW_STATUS_ITEMS.map((count) => `${priority}-${count}items`)),
25
+ ]
26
+ export const REVIEW_STATUS_RE = new RegExp(`^(?:clean|(?:${REVIEW_STATUS_PRIORITIES.join('|')})-\\d+items)$`)
16
27
  /** @type {ReadonlySet<string>} */
17
28
  export const TERMINAL_STATUSES = new Set(['merged', 'abandoned'])
18
29
  /** @type {readonly FindingType[]} */
package/domain/surface.js CHANGED
@@ -9,9 +9,19 @@
9
9
  //
10
10
  // This module is pure: no fs, no git. `scripts/surface-baseline.mjs` supplies
11
11
  // the live counts and the file reads; `scripts/lib/surface.mjs` wraps them.
12
+ //
13
+ // Since #120 `components` is a fourth surface: the number of user-visible
14
+ // component rows in `cordis.patch.yml`. That is what makes a contraction (the
15
+ // 2.3.0 4 -> 1 group row) and the coming #121 growth require a written decision
16
+ // instead of arriving quietly.
12
17
  export const SURFACE_BASELINE_FILE = 'scripts/surface-baseline.json'
13
18
  export const SURFACE_CLASSIFICATION_FILE = 'docs/reference/surface-budget.md'
14
- export const SURFACE_KEYS = ['configKeys', 'sections', 'tools']
19
+ export const SURFACE_KEYS = ['configKeys', 'sections', 'tools', 'components']
20
+ // A count in this list is two-way: a contraction needs a decision record just
21
+ // like growth does. The component surface is where the failure actually
22
+ // happened (4 top-level rows -> 1 group row), so "a reduction is cheap" is
23
+ // exactly the policy hole #120 closes.
24
+ export const SURFACE_TWO_WAY_KEYS = ['components']
15
25
  export const CONFIG_CLASSES = ['core', 'tuning', 'niche']
16
26
  export const TOOL_CLASSES = ['core', 'optional']
17
27
  export const SURFACE_SCHEMA_VERSION = 1
@@ -178,71 +188,160 @@ export function validateClassification(markdown, liveNames, options = {}) {
178
188
  }
179
189
 
180
190
  /**
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.
191
+ * Validate an explicit measurement-correction decision (#118).
185
192
  *
186
- * @param {ReturnType<typeof parseBaseline>} baseline
187
- * @param {{ configKeys: number, sections: number, tools: number, liveNames: { configKeys: string[], sections: string[], tools: string[] } }} current
193
+ * A correction says the live surface did not grow: the earlier measurement
194
+ * missed a registration path. It is deliberately narrower than an ADR — it only
195
+ * authorizes the written `from`/`to` step it carries, needs a reason and a date,
196
+ * and (when it names a `ref`) that reference must resolve. A later count change
197
+ * still needs its own decision, so the brake stays strict.
198
+ *
199
+ * @returns {boolean} true when the correction is valid evidence for the growth
200
+ */
201
+ function correctionEvidence(decision, where, record, exists) {
202
+ if (decision.correction === undefined) return false
203
+ if (!isPlainObject(decision.correction)) {
204
+ record('correction-invalid', `${where}.correction must be an object with a reason and a date`)
205
+ return false
206
+ }
207
+ const correction = decision.correction
208
+ const reason = typeof correction.reason === 'string' ? correction.reason.trim() : ''
209
+ if (!reason) {
210
+ record('correction-invalid', `${where}.correction needs a non-empty reason explaining why the measurement changed`)
211
+ }
212
+ const date = correction.date ?? decision.date
213
+ const dateOk = typeof date === 'string' && ISO_DATE.test(date)
214
+ if (!dateOk) {
215
+ record('correction-invalid', `${where}.correction needs a date (YYYY-MM-DD)`)
216
+ } else if (typeof decision.date === 'string' && date !== decision.date) {
217
+ record('correction-invalid', `${where}.correction.date ${date} does not match the decision date ${decision.date}`)
218
+ }
219
+ let refOk = true
220
+ if (correction.ref !== undefined) {
221
+ const ref = typeof correction.ref === 'string' ? correction.ref.trim().replace(/\\/g, '/') : ''
222
+ if (!ref) {
223
+ record('correction-invalid', `${where}.correction.ref must be a repository path when present`)
224
+ refOk = false
225
+ } else if (!ref.startsWith('docs/') || !ref.endsWith('.md')) {
226
+ record('correction-invalid', `${where}.correction.ref must name a docs/*.md record, got ${ref}`)
227
+ refOk = false
228
+ } else if (!exists(ref)) {
229
+ record('correction-invalid', `${where}.correction.ref ${ref} does not exist`)
230
+ refOk = false
231
+ }
232
+ }
233
+ return reason.length > 0 && dateOk && refOk
234
+ }
235
+
236
+ /**
237
+ * Validate one surface entry against its live count and its decision chain. This
238
+ * is the brake: `live !== entry.value` fails, and every decision that grows a
239
+ * count must name an existing ADR, deletions that are provably absent from the
240
+ * live surface, or a valid measurement-correction record.
241
+ *
242
+ * A two-way surface (see SURFACE_TWO_WAY_KEYS) also refuses a contraction whose
243
+ * new count is not reached by a decision record; the other surfaces keep the
244
+ * "a reduction is cheap" rule from ADR 0004.
245
+ *
246
+ * Extracted from `validateBaseline` so a narrower gate (`docs:check:components`,
247
+ * #120) can validate just the component count without re-measuring the others.
248
+ *
249
+ * @param {string} key one of SURFACE_KEYS
250
+ * @param {{ seed: number, value: number, decisions: any[] }} entry
251
+ * @param {number} live
252
+ * @param {string[]} liveNames
188
253
  * @param {{ exists?: (relativePath: string) => boolean }} [options]
189
254
  * @returns {Array<{ code: string, level: string, message: string }>}
190
255
  */
191
- export function validateBaseline(baseline, current, options = {}) {
256
+ export function validateSurfaceEntry(key, entry, live, liveNames, options = {}) {
192
257
  const exists = options.exists ?? (() => false)
193
258
  const findings = []
194
259
  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
260
+ if (!Number.isInteger(live)) {
261
+ record('surface-missing', `live ${key} count is unavailable`)
262
+ return findings
263
+ }
264
+ if (live !== entry.value) {
265
+ const direction = live > entry.value ? 'grew' : 'shrank'
266
+ record(
267
+ 'stale-baseline',
268
+ `${key} ${direction} from the committed baseline ${entry.value} to ${live}; update ${SURFACE_BASELINE_FILE} with an ADR or a deletion record before landing`,
269
+ )
270
+ }
271
+ let running = entry.seed
272
+ entry.decisions.forEach((decision, index) => {
273
+ const where = `${key}.decisions[${index}]`
274
+ if (decision.from !== running) {
275
+ record('broken-chain', `${where}.from ${decision.from} does not continue from ${running}`)
202
276
  }
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
- )
277
+ if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
278
+ record('broken-chain', `${where}.date is not YYYY-MM-DD`)
209
279
  }
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
- }
280
+ const growth = decision.to - decision.from
281
+ if (growth > 0) {
282
+ const deletions = Array.isArray(decision.deletions)
283
+ ? decision.deletions.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim())
284
+ : []
285
+ const adr = typeof decision.adr === 'string' && decision.adr.trim() ? decision.adr.trim().replace(/\\/g, '/') : null
286
+ const adrExists = Boolean(adr) && adr.startsWith('docs/adr/') && adr.endsWith('.md') && exists(adr)
287
+ const liveSet = new Set(liveNames ?? [])
288
+ const stillLive = deletions.filter((name) => liveSet.has(name))
289
+ const deletionEvidence = deletions.length >= growth && stillLive.length === 0
290
+ const correction = correctionEvidence(decision, where, record, exists)
291
+ if (!adrExists && !deletionEvidence && !correction) {
292
+ const reasons = []
293
+ if (adr && !adrExists) reasons.push(`ADR ${adr} does not exist under docs/adr/`)
294
+ if (deletions.length === 0) reasons.push('no deletions recorded')
295
+ else if (stillLive.length > 0) reasons.push(`deleted name(s) are still in the live surface: ${stillLive.join(', ')}`)
296
+ else if (deletions.length < growth) reasons.push(`only ${deletions.length} deletion(s) for a growth of ${growth}`)
297
+ if (decision.correction !== undefined && !correction) reasons.push('the correction record is invalid')
298
+ record('growth-without-evidence', `${where} grows ${key} by ${growth} without an ADR, a matching deletion or a valid correction (${reasons.join('; ')})`)
237
299
  }
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
300
  }
301
+ running = decision.to
302
+ })
303
+ const twoWay = SURFACE_TWO_WAY_KEYS.includes(key)
304
+ if (running !== entry.value && !(entry.decisions.length === 0 && entry.value < running && !twoWay)) {
305
+ record(
306
+ 'broken-chain',
307
+ `${key} value ${entry.value} does not match the last decision (${running}); every count change needs a decision record`,
308
+ )
246
309
  }
247
310
  return findings
248
311
  }
312
+
313
+ /**
314
+ * Validate the whole committed baseline. Every surface in SURFACE_KEYS is
315
+ * checked, so a missing `current` entry is a `surface-missing` finding.
316
+ *
317
+ * @param {ReturnType<typeof parseBaseline>} baseline
318
+ * @param {{ configKeys: number, sections: number, tools: number, components: number, liveNames: { configKeys: string[], sections: string[], tools: string[], components: string[] } }} current
319
+ * @param {{ exists?: (relativePath: string) => boolean }} [options]
320
+ * @returns {Array<{ code: string, level: string, message: string }>}
321
+ */
322
+ export function validateBaseline(baseline, current, options = {}) {
323
+ const findings = []
324
+ for (const key of SURFACE_KEYS) {
325
+ findings.push(...validateSurfaceEntry(key, baseline.surfaces[key], current[key], current.liveNames?.[key], options))
326
+ }
327
+ return findings
328
+ }
329
+
330
+ /**
331
+ * Validate one surface entry of the committed baseline (the narrower form used
332
+ * by `docs:check:components`).
333
+ *
334
+ * @param {ReturnType<typeof parseBaseline>} baseline
335
+ * @param {string} key
336
+ * @param {Record<string, any>} current
337
+ * @param {{ exists?: (relativePath: string) => boolean }} [options]
338
+ * @returns {Array<{ code: string, level: string, message: string }>}
339
+ */
340
+ export function validateSurfaceBaselineKey(baseline, key, current, options = {}) {
341
+ if (!SURFACE_KEYS.includes(key)) {
342
+ throw surfaceError(`surface ${key} is not one of ${SURFACE_KEYS.join('/')}`)
343
+ }
344
+ const entry = baseline.surfaces[key]
345
+ if (!entry) throw surfaceError(`surface baseline has no "${key}" entry`)
346
+ return validateSurfaceEntry(key, entry, current[key], current.liveNames?.[key], options)
347
+ }
@@ -0,0 +1,15 @@
1
+ // tower-batch component entry (#121): evidence batches only.
2
+ //
3
+ // Each optional capability is its own loader row, so switching this component
4
+ // off removes exactly `tower_batch` and leaves the rest of Tower working. The
5
+ // row is declared in cordis.patch.yml; the package export is `./batch`.
6
+ import { holdComponent } from './capability-registry.js'
7
+ import { registerBatchCapability } from './tool-registry.js'
8
+
9
+ export const name = 'dsh-tower/batch'
10
+ export const inject = ['tools', 'tower']
11
+
12
+ export function apply(ctx) {
13
+ holdComponent(ctx, 'tower-batch')
14
+ registerBatchCapability(ctx)
15
+ }
@@ -0,0 +1,30 @@
1
+ // tower-hmr component entry (#121): dev hot reload, shipped off.
2
+ //
3
+ // The row defaults to `disabled: true` in cordis.patch.yml, because a profile
4
+ // without HMR is the normal case and the capability only makes sense for linked
5
+ // development installs. Switching the row on registers `tower_reload` and the
6
+ // `/tower reload` branch; switching it off leaves both absent while
7
+ // `tower_status` reports "disabled by component tower-hmr".
8
+ import { createHmrBridge } from './hmr.js'
9
+ import { runtimeConfig } from './config.js'
10
+ import { clearHmrBridge, holdComponent, setHmrBridge } from './capability-registry.js'
11
+ import { registerHmrCapability } from './tool-registry.js'
12
+
13
+ export const name = 'dsh-tower/hmr'
14
+ export const inject = ['tools', 'tower']
15
+
16
+ export function apply(ctx) {
17
+ holdComponent(ctx, 'tower-hmr')
18
+ const dev = runtimeConfig().dev ?? {}
19
+ const bridge = createHmrBridge(ctx, {
20
+ requiredIds: dev.reloadRequiredIds,
21
+ delayMs: dev.reloadDelayMs,
22
+ logger: ctx.logger ?? console,
23
+ })
24
+ setHmrBridge(bridge)
25
+ ctx.effect(() => () => {
26
+ clearHmrBridge(bridge)
27
+ bridge.dispose()
28
+ })
29
+ registerHmrCapability(ctx, { bridge })
30
+ }
@@ -0,0 +1,11 @@
1
+ // tower-import component entry (#121): legacy import and its audit map only.
2
+ import { holdComponent } from './capability-registry.js'
3
+ import { registerImportCapability } from './tool-registry.js'
4
+
5
+ export const name = 'dsh-tower/import'
6
+ export const inject = ['tools', 'tower']
7
+
8
+ export function apply(ctx) {
9
+ holdComponent(ctx, 'tower-import')
10
+ registerImportCapability(ctx)
11
+ }
@@ -0,0 +1,11 @@
1
+ // tower-leases component entry (#121): advisory resource leases only.
2
+ import { holdComponent } from './capability-registry.js'
3
+ import { registerLeasesCapability } from './tool-registry.js'
4
+
5
+ export const name = 'dsh-tower/leases'
6
+ export const inject = ['tools', 'tower']
7
+
8
+ export function apply(ctx) {
9
+ holdComponent(ctx, 'tower-leases')
10
+ registerLeasesCapability(ctx)
11
+ }