dsh-tower 2.1.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +78 -5
- package/PRINCIPLES.md +163 -0
- package/README.en.md +35 -19
- package/README.md +28 -18
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +8 -9
- package/app/tower.js +89 -35
- package/cordis.patch.yml +56 -28
- package/docs/README.md +7 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +70 -0
- package/docs/adr/0005-component-surface.md +130 -0
- package/docs/adr/README.md +52 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +192 -43
- package/docs/guide/hot-reload.md +5 -5
- package/docs/guide/install-upgrade-migration.md +13 -0
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/operations/security.md +1 -1
- package/docs/reference/blockers.md +2 -2
- package/docs/reference/component-surface.md +135 -0
- package/docs/reference/config.generated.md +2 -9
- package/docs/reference/config.md +11 -9
- package/docs/reference/doctor.md +53 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +157 -0
- package/docs/reference/tools.generated.md +8 -5
- package/docs/reference/tools.md +10 -7
- package/docs/zh/README.md +1 -1
- package/docs/zh/guide/hot-reload.md +4 -4
- package/docs/zh/guide/install-upgrade-migration.md +10 -0
- package/docs/zh/operations/security.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +9 -9
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +10 -8
- package/domain/batch.js +2 -2
- package/domain/components.js +641 -0
- package/domain/config/schema.js +62 -28
- package/domain/contracts/outputs.js +16 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +308 -43
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +12 -1
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +347 -0
- package/host/capability-batch.js +15 -0
- package/host/capability-hmr.js +30 -0
- package/host/capability-import.js +11 -0
- package/host/capability-leases.js +11 -0
- package/host/capability-registry.js +149 -0
- package/host/capability-tests.js +16 -0
- package/{lib → host}/doctor.js +157 -18
- package/{lib → host}/hmr.js +2 -4
- package/{lib → host}/modeStore.js +1 -1
- package/host/presets.js +77 -0
- package/{lib → host}/tool-registry.js +235 -44
- package/host/tower.js +32 -0
- package/index.js +40 -25
- package/locale/en.json +31 -11
- package/locale/zh.json +30 -10
- package/package.json +24 -4
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-components.mjs +235 -0
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/component-surface.json +395 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-component-surface.mjs +59 -0
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +18 -25
- package/scripts/generate-types.mjs +127 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/lib/tool-surface.mjs +85 -0
- package/scripts/mount-smoke/fixtures.mjs +3 -3
- package/scripts/mount-smoke/surface.mjs +96 -27
- package/scripts/mount-smoke.mjs +3 -1
- package/scripts/pack-manifest.txt +75 -22
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +52 -0
- package/scripts/surface-baseline.mjs +118 -0
- package/scripts/types-coverage.mjs +184 -0
- package/scripts/types-strict-manifest.txt +10 -0
- package/scripts/types-strict.mjs +141 -0
- package/skills.js +8 -1
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +2 -3
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +9 -8
- package/tools/init.js +3 -6
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +6 -5
- package/tools/preflight.js +2 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +36 -40
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +11 -4
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +29 -13
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/persistence.js +0 -356
- package/lib/presets.js +0 -35
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
|
@@ -0,0 +1,347 @@
|
|
|
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
|
+
//
|
|
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.
|
|
17
|
+
export const SURFACE_BASELINE_FILE = 'scripts/surface-baseline.json'
|
|
18
|
+
export const SURFACE_CLASSIFICATION_FILE = 'docs/reference/surface-budget.md'
|
|
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']
|
|
25
|
+
export const CONFIG_CLASSES = ['core', 'tuning', 'niche']
|
|
26
|
+
export const TOOL_CLASSES = ['core', 'optional']
|
|
27
|
+
export const SURFACE_SCHEMA_VERSION = 1
|
|
28
|
+
|
|
29
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The human-readable count line shared by `tower_doctor` and the generated
|
|
33
|
+
* references: `config keys: N · sections: N · tools: N`.
|
|
34
|
+
* @param {{ configKeys?: number, sections?: number, tools?: number }} counts
|
|
35
|
+
*/
|
|
36
|
+
export function surfaceLine(counts = {}) {
|
|
37
|
+
const value = (key) => (Number.isInteger(counts[key]) ? String(counts[key]) : '?')
|
|
38
|
+
return `config keys: ${value('configKeys')} · sections: ${value('sections')} · tools: ${value('tools')}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isPlainObject(value) {
|
|
42
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function surfaceError(message) {
|
|
46
|
+
return new Error(message)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse and validate `scripts/surface-baseline.json`. Throws with an actionable
|
|
51
|
+
* message when the file is malformed; returns a normalized copy otherwise.
|
|
52
|
+
* @param {string | object} source
|
|
53
|
+
*/
|
|
54
|
+
export function parseBaseline(source) {
|
|
55
|
+
let value
|
|
56
|
+
if (typeof source === 'string') {
|
|
57
|
+
try {
|
|
58
|
+
value = JSON.parse(source)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw surfaceError(`surface baseline is not valid JSON: ${error?.message ?? error}`)
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
value = source
|
|
64
|
+
}
|
|
65
|
+
if (!isPlainObject(value)) throw surfaceError('surface baseline must be a JSON object')
|
|
66
|
+
if (value.schema !== undefined && value.schema !== SURFACE_SCHEMA_VERSION) {
|
|
67
|
+
throw surfaceError(`surface baseline schema ${value.schema} is not supported (expected ${SURFACE_SCHEMA_VERSION})`)
|
|
68
|
+
}
|
|
69
|
+
if (typeof value.date !== 'string' || !ISO_DATE.test(value.date)) {
|
|
70
|
+
throw surfaceError(`surface baseline needs a date (YYYY-MM-DD), got ${JSON.stringify(value.date)}`)
|
|
71
|
+
}
|
|
72
|
+
if (!isPlainObject(value.surfaces)) throw surfaceError('surface baseline needs a "surfaces" object')
|
|
73
|
+
const surfaces = {}
|
|
74
|
+
for (const key of SURFACE_KEYS) {
|
|
75
|
+
const entry = value.surfaces[key]
|
|
76
|
+
if (!isPlainObject(entry)) throw surfaceError(`surface baseline misses the "${key}" entry`)
|
|
77
|
+
if (!Number.isInteger(entry.seed) || entry.seed < 0) {
|
|
78
|
+
throw surfaceError(`surface baseline ${key}.seed must be a non-negative integer`)
|
|
79
|
+
}
|
|
80
|
+
if (!Number.isInteger(entry.value) || entry.value < 0) {
|
|
81
|
+
throw surfaceError(`surface baseline ${key}.value must be a non-negative integer`)
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(entry.decisions)) throw surfaceError(`surface baseline ${key}.decisions must be an array`)
|
|
84
|
+
surfaces[key] = {
|
|
85
|
+
seed: entry.seed,
|
|
86
|
+
value: entry.value,
|
|
87
|
+
decisions: entry.decisions.map((decision, index) => {
|
|
88
|
+
if (!isPlainObject(decision)) {
|
|
89
|
+
throw surfaceError(`surface baseline ${key}.decisions[${index}] must be an object`)
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(decision.from) || decision.from < 0) {
|
|
92
|
+
throw surfaceError(`surface baseline ${key}.decisions[${index}].from must be a non-negative integer`)
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isInteger(decision.to) || decision.to < 0) {
|
|
95
|
+
throw surfaceError(`surface baseline ${key}.decisions[${index}].to must be a non-negative integer`)
|
|
96
|
+
}
|
|
97
|
+
return { ...decision }
|
|
98
|
+
}),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { schema: SURFACE_SCHEMA_VERSION, date: value.date, surfaces }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The rows of the classification table, already parsed. `kind` is `config` or
|
|
106
|
+
* `tools`, `name` is the key/tool name, `className` is the declared class.
|
|
107
|
+
* @param {string} markdown
|
|
108
|
+
*/
|
|
109
|
+
export function parseClassification(markdown) {
|
|
110
|
+
const text = String(markdown ?? '')
|
|
111
|
+
const dateMatch = /^\s*(?:[-*]\s*)?Decision date:\s*(\d{4}-\d{2}-\d{2})\s*$/im.exec(text)
|
|
112
|
+
const entries = []
|
|
113
|
+
let kind = null
|
|
114
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
115
|
+
const heading = /^#{1,6}\s+(.+?)\s*$/.exec(rawLine)
|
|
116
|
+
if (heading) {
|
|
117
|
+
const title = heading[1].toLowerCase()
|
|
118
|
+
kind = title.includes('config') ? 'config' : title.includes('tool') ? 'tools' : null
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (!kind) continue
|
|
122
|
+
const match = /^\|\s*`([^`]+)`\s*\|\s*([a-z-]+)\s*\|\s*(.*?)\s*\|\s*$/.exec(rawLine)
|
|
123
|
+
if (!match) continue
|
|
124
|
+
entries.push({ kind, name: match[1].trim(), className: match[2].trim().toLowerCase(), note: match[3].trim() })
|
|
125
|
+
}
|
|
126
|
+
return { date: dateMatch ? dateMatch[1] : null, entries }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate the classification table against the live surface. Every live key and
|
|
131
|
+
* tool must appear exactly once with a legal class; every `niche` row must carry
|
|
132
|
+
* a removal note, so "we will remove it later" is a written promise rather than
|
|
133
|
+
* an implication.
|
|
134
|
+
* @param {string} markdown
|
|
135
|
+
* @param {{ configKeys: string[], sections: string[], tools: string[] }} liveNames
|
|
136
|
+
* @param {{ baselineDate?: string }} [options]
|
|
137
|
+
* @returns {{ date: string | null, entries: Array<{ kind: string, name: string, className: string, note: string }>, findings: Array<{ code: string, level: string, message: string }> }}
|
|
138
|
+
*/
|
|
139
|
+
export function validateClassification(markdown, liveNames, options = {}) {
|
|
140
|
+
const parsed = parseClassification(markdown)
|
|
141
|
+
const findings = []
|
|
142
|
+
const text = String(markdown ?? '')
|
|
143
|
+
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
144
|
+
if (!parsed.date) {
|
|
145
|
+
record('classification-date', 'classification table needs a "Decision date: YYYY-MM-DD" line')
|
|
146
|
+
} else if (options.baselineDate && parsed.date !== options.baselineDate) {
|
|
147
|
+
record(
|
|
148
|
+
'classification-date',
|
|
149
|
+
`classification date ${parsed.date} does not match the baseline date ${options.baselineDate}`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const checkRows = (kind, expected, legal) => {
|
|
154
|
+
const rows = parsed.entries.filter((entry) => entry.kind === kind)
|
|
155
|
+
const seen = new Map()
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
if (seen.has(row.name)) record('classification-duplicate', `${kind === 'config' ? 'config key' : 'tool'} ${row.name} is classified twice`)
|
|
158
|
+
seen.set(row.name, row)
|
|
159
|
+
if (!legal.includes(row.className)) {
|
|
160
|
+
record(
|
|
161
|
+
'classification-class',
|
|
162
|
+
`${kind === 'config' ? 'config key' : 'tool'} ${row.name} has class "${row.className}" (expected ${legal.join('/')})`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const name of expected) {
|
|
167
|
+
if (!seen.has(name)) record('classification-missing', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not classified`)
|
|
168
|
+
}
|
|
169
|
+
const expectedSet = new Set(expected)
|
|
170
|
+
for (const name of seen.keys()) {
|
|
171
|
+
if (!expectedSet.has(name)) record('classification-unknown', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not in the live surface`)
|
|
172
|
+
}
|
|
173
|
+
return rows
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const configRows = checkRows('config', liveNames.configKeys, CONFIG_CLASSES)
|
|
177
|
+
checkRows('tools', liveNames.tools, TOOL_CLASSES)
|
|
178
|
+
const nicheRows = configRows.filter((row) => row.className === 'niche')
|
|
179
|
+
for (const row of nicheRows) {
|
|
180
|
+
if (!/removal note:/i.test(row.note)) {
|
|
181
|
+
record('niche-removal-note', `niche config key ${row.name} needs a "Removal note:" in its table row`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (nicheRows.length === 0 && !/no key qualifies as niche/i.test(text)) {
|
|
185
|
+
record('niche-removal-note', 'no niche config key has a removal note and the table does not say "no key qualifies as niche"')
|
|
186
|
+
}
|
|
187
|
+
return { date: parsed.date, entries: parsed.entries, findings }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validate an explicit measurement-correction decision (#118).
|
|
192
|
+
*
|
|
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
|
|
253
|
+
* @param {{ exists?: (relativePath: string) => boolean }} [options]
|
|
254
|
+
* @returns {Array<{ code: string, level: string, message: string }>}
|
|
255
|
+
*/
|
|
256
|
+
export function validateSurfaceEntry(key, entry, live, liveNames, options = {}) {
|
|
257
|
+
const exists = options.exists ?? (() => false)
|
|
258
|
+
const findings = []
|
|
259
|
+
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
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}`)
|
|
276
|
+
}
|
|
277
|
+
if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
|
|
278
|
+
record('broken-chain', `${where}.date is not YYYY-MM-DD`)
|
|
279
|
+
}
|
|
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('; ')})`)
|
|
299
|
+
}
|
|
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
|
+
)
|
|
309
|
+
}
|
|
310
|
+
return findings
|
|
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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Runtime view of the optional component rows (#121).
|
|
2
|
+
//
|
|
3
|
+
// The component rows in `cordis.patch.yml` are separate loader entries. A
|
|
4
|
+
// capability row that is off simply never runs `apply()`, so the host cannot
|
|
5
|
+
// ask a disabled plugin to report itself. This module is the small shared
|
|
6
|
+
// registry the capability entry points write into and the read-only reporters
|
|
7
|
+
// (`tower_status`, `tower_doctor`) read back. An absent entry is a disabled
|
|
8
|
+
// component, reported with the exact "disabled by component <id>" wording the
|
|
9
|
+
// acceptance contract asks for, never a silent absence.
|
|
10
|
+
//
|
|
11
|
+
// The registry is process-local and reference-counted: a capability entry point
|
|
12
|
+
// registers on apply and unregisters through its `ctx.effect` disposer, so an
|
|
13
|
+
// HMR reload cannot leave a stale "enabled" claim behind. It is coordination,
|
|
14
|
+
// not state: no file is written and no tool result depends on it except the
|
|
15
|
+
// capability report itself.
|
|
16
|
+
|
|
17
|
+
/** The visible optional component rows, in `cordis.patch.yml` order. */
|
|
18
|
+
export const TOWER_COMPONENT_IDS = Object.freeze([
|
|
19
|
+
'tower-skills',
|
|
20
|
+
'tower-batch',
|
|
21
|
+
'tower-import',
|
|
22
|
+
'tower-leases',
|
|
23
|
+
'tower-tests',
|
|
24
|
+
'tower-hmr',
|
|
25
|
+
'preset-tower-reviewer',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
/** Tool ids each code component owns; the preset row owns no tool. */
|
|
29
|
+
export const COMPONENT_TOOLS = Object.freeze({
|
|
30
|
+
'tower-skills': Object.freeze([]),
|
|
31
|
+
'tower-batch': Object.freeze(['tower_batch']),
|
|
32
|
+
'tower-import': Object.freeze(['tower_import_legacy', 'tower_legacy_map']),
|
|
33
|
+
'tower-leases': Object.freeze(['tower_resource']),
|
|
34
|
+
'tower-tests': Object.freeze(['tower_test']),
|
|
35
|
+
'tower-hmr': Object.freeze(['tower_reload']),
|
|
36
|
+
'preset-tower-reviewer': Object.freeze([]),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** id -> number of live registrations (redelivery/HMR can overlap briefly). */
|
|
40
|
+
const active = new Map()
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mark one component as mounted. IDEMPOTENT per call site: the reference count
|
|
44
|
+
* lets a brief double-mount during HMR stay "enabled" until the last disposer
|
|
45
|
+
* runs.
|
|
46
|
+
* @param {string} id
|
|
47
|
+
*/
|
|
48
|
+
export function registerComponent(id) {
|
|
49
|
+
if (!TOWER_COMPONENT_IDS.includes(id)) throw new TypeError(`unknown Tower component: ${id}`)
|
|
50
|
+
active.set(id, (active.get(id) ?? 0) + 1)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Drop one registration; a component is disabled when its count reaches 0. */
|
|
54
|
+
export function unregisterComponent(id) {
|
|
55
|
+
const count = active.get(id) ?? 0
|
|
56
|
+
if (count <= 1) active.delete(id)
|
|
57
|
+
else active.set(id, count - 1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when a code component entry point is currently mounted. */
|
|
61
|
+
export function componentEnabled(id) {
|
|
62
|
+
return (active.get(id) ?? 0) > 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Register/unregister a component around one plugin's lifetime. */
|
|
66
|
+
export function holdComponent(ctx, id) {
|
|
67
|
+
registerComponent(id)
|
|
68
|
+
if (typeof ctx?.effect === 'function') {
|
|
69
|
+
ctx.effect(() => () => unregisterComponent(id))
|
|
70
|
+
}
|
|
71
|
+
return () => unregisterComponent(id)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Test hook: process-local registry state must be reset per fixture. */
|
|
75
|
+
export function __testResetComponentRegistry() {
|
|
76
|
+
active.clear()
|
|
77
|
+
sharedHmrBridge = null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The exact wording the acceptance contract uses for a missing capability. */
|
|
81
|
+
export function disabledReason(id) {
|
|
82
|
+
return `disabled by component ${id}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Reviewer-preset availability from the optional agent-presets service. */
|
|
86
|
+
async function reviewerPresetAvailable(ctx) {
|
|
87
|
+
const service = ctx?.get?.('agentPresets')
|
|
88
|
+
if (!service || typeof service.list !== 'function') return null
|
|
89
|
+
try {
|
|
90
|
+
const presets = await service.list()
|
|
91
|
+
return Array.isArray(presets) && presets.some((entry) => String(entry?.id) === 'tower-reviewer')
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Report every optional component as enabled or disabled. The reviewer preset
|
|
99
|
+
* is not a code entry point: its row mounts an agent preset, so availability is
|
|
100
|
+
* read from `agentPresets` and reported as unknown (omitted) when that service
|
|
101
|
+
* is absent, exactly as the doctor's preset check does.
|
|
102
|
+
*
|
|
103
|
+
* @param {any} ctx
|
|
104
|
+
* @returns {Promise<{ enabled: string[], disabled: Array<{ id: string, reason: string }> }>}
|
|
105
|
+
*/
|
|
106
|
+
export async function componentReport(ctx) {
|
|
107
|
+
const enabled = []
|
|
108
|
+
const disabled = []
|
|
109
|
+
for (const id of TOWER_COMPONENT_IDS) {
|
|
110
|
+
if (id === 'preset-tower-reviewer') {
|
|
111
|
+
const available = await reviewerPresetAvailable(ctx)
|
|
112
|
+
if (available === false) disabled.push({ id, reason: disabledReason(id) })
|
|
113
|
+
else if (available === true) enabled.push(id)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (componentEnabled(id)) enabled.push(id)
|
|
117
|
+
else disabled.push({ id, reason: disabledReason(id) })
|
|
118
|
+
}
|
|
119
|
+
return { enabled, disabled }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A capability plugin's `ctx.provide()` is visible to its descendants, not to
|
|
123
|
+
// the parent host context that owns `/tower` and `tower_mode`. The hot-reload
|
|
124
|
+
// bridge is a process singleton, so it is shared here rather than through a
|
|
125
|
+
// service lookup the host could not see.
|
|
126
|
+
let sharedHmrBridge = null
|
|
127
|
+
|
|
128
|
+
/** Publish the mounted tower-hmr bridge for the host entry to read. */
|
|
129
|
+
export function setHmrBridge(bridge) {
|
|
130
|
+
sharedHmrBridge = bridge
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Clear the publication when the component unloads; never clear another bridge. */
|
|
134
|
+
export function clearHmrBridge(bridge) {
|
|
135
|
+
if (sharedHmrBridge === bridge) sharedHmrBridge = null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The mounted tower-hmr bridge, or null when the component row is off. */
|
|
139
|
+
export function currentHmrBridge() {
|
|
140
|
+
return sharedHmrBridge
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One-line form used by `tower_status` and the doctor's `components` check. */
|
|
144
|
+
export function componentSummary(report) {
|
|
145
|
+
if (!report || report.disabled.length === 0) {
|
|
146
|
+
return `${report?.enabled?.length ?? 0} optional component(s) enabled`
|
|
147
|
+
}
|
|
148
|
+
return report.disabled.map((entry) => entry.reason).join('; ')
|
|
149
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// tower-tests component entry (#121): declared test discovery only.
|
|
2
|
+
//
|
|
3
|
+
// With this row off there is no `tower_test` tool and no receipt to satisfy
|
|
4
|
+
// `gates.requireTests`; planning/mode entry refuse that combination with an
|
|
5
|
+
// explanatory error (host/presets.js), and the merge gate still reports
|
|
6
|
+
// `tests-missing` because no receipt can exist.
|
|
7
|
+
import { holdComponent } from './capability-registry.js'
|
|
8
|
+
import { registerTestsCapability } from './tool-registry.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'dsh-tower/tests'
|
|
11
|
+
export const inject = ['tools', 'tower']
|
|
12
|
+
|
|
13
|
+
export function apply(ctx) {
|
|
14
|
+
holdComponent(ctx, 'tower-tests')
|
|
15
|
+
registerTestsCapability(ctx)
|
|
16
|
+
}
|