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.
- package/CHANGELOG.md +44 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +20 -3
- package/PRINCIPLES.md +18 -0
- package/README.en.md +29 -16
- package/README.md +22 -15
- package/cordis.patch.yml +56 -28
- package/docs/README.md +2 -1
- package/docs/adr/0004-surface-budget.md +20 -0
- package/docs/adr/0005-component-surface.md +130 -0
- package/docs/adr/README.md +1 -0
- package/docs/agents/workflow.md +1 -1
- package/docs/architecture/overview.md +69 -7
- package/docs/guide/hot-reload.md +4 -4
- package/docs/guide/install-upgrade-migration.md +13 -0
- package/docs/operations/security.md +1 -1
- package/docs/reference/component-surface.md +135 -0
- package/docs/reference/config.generated.md +2 -10
- package/docs/reference/config.md +11 -9
- package/docs/reference/doctor.md +14 -9
- package/docs/reference/surface-budget.md +52 -23
- package/docs/reference/tools.generated.md +4 -2
- package/docs/reference/tools.md +6 -3
- package/docs/zh/README.md +1 -1
- package/docs/zh/guide/hot-reload.md +3 -3
- package/docs/zh/guide/install-upgrade-migration.md +10 -0
- package/docs/zh/operations/security.md +1 -1
- package/docs/zh/reference/config.md +9 -9
- package/docs/zh/reference/tools.md +6 -4
- package/domain/components.js +641 -0
- package/domain/config/schema.js +37 -27
- package/domain/contracts/outputs.js +10 -0
- package/domain/errors.js +11 -1
- package/domain/mission.js +12 -1
- package/domain/surface.js +154 -55
- 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/host/doctor.js +41 -9
- package/host/hmr.js +2 -4
- package/host/presets.js +51 -9
- package/host/tool-registry.js +150 -34
- package/index.js +32 -20
- package/locale/batch/en.json +6 -0
- package/locale/batch/zh.json +6 -0
- package/locale/en.json +31 -11
- package/locale/hmr/en.json +6 -0
- package/locale/hmr/zh.json +6 -0
- package/locale/import/en.json +6 -0
- package/locale/import/zh.json +6 -0
- package/locale/leases/en.json +6 -0
- package/locale/leases/zh.json +6 -0
- package/locale/skills/en.json +6 -0
- package/locale/skills/zh.json +6 -0
- package/locale/tests/en.json +6 -0
- package/locale/tests/zh.json +6 -0
- package/locale/zh.json +30 -10
- package/package.json +25 -4
- package/scripts/check-components.mjs +235 -0
- package/scripts/check-pack.mjs +4 -0
- package/scripts/component-surface.json +395 -0
- package/scripts/generate-capability-locale.mjs +148 -0
- package/scripts/generate-component-surface.mjs +59 -0
- package/scripts/generate-tools-reference.mjs +7 -25
- package/scripts/generate-types.mjs +127 -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 +30 -0
- package/scripts/surface-baseline.json +35 -4
- package/scripts/surface-baseline.mjs +32 -34
- 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/index.js +2 -1
- package/tools/reload.js +34 -35
- package/tools/status.js +8 -1
- package/types/index.d.ts +28 -12
|
@@ -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
|
+
}
|
package/host/doctor.js
CHANGED
|
@@ -17,6 +17,7 @@ import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/sur
|
|
|
17
17
|
import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
|
|
18
18
|
import { profileConfig } from './config.js'
|
|
19
19
|
import { inspectWorkspaceLock } from '../adapters/workspaceLock.js'
|
|
20
|
+
import { componentReport, componentSummary } from './capability-registry.js'
|
|
20
21
|
|
|
21
22
|
const execFileAsync = promisify(execFile)
|
|
22
23
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -287,16 +288,32 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
287
288
|
),
|
|
288
289
|
)
|
|
289
290
|
const presets = ctx.get?.('agentPresets')
|
|
291
|
+
const reviewerRequired = config.review?.requireReview === true
|
|
290
292
|
if (presets && typeof presets.list === 'function') {
|
|
291
293
|
try {
|
|
292
294
|
const available = (await presets.list()).map((entry) => String(entry.id))
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
295
|
+
const worker = config.spawn?.workerPreset ?? 'tower-worker'
|
|
296
|
+
const reviewer = config.spawn?.reviewerPreset ?? 'tower-reviewer'
|
|
297
|
+
const required = reviewerRequired ? [worker, reviewer] : [worker]
|
|
298
|
+
const missing = required.filter((id) => !available.includes(id))
|
|
299
|
+
// #121: `preset-tower-reviewer` is a conditional component. Its off state
|
|
300
|
+
// is supported while review.requireReview is false; with the gate on it is
|
|
301
|
+
// a fail, because no independent review can be dispatched.
|
|
302
|
+
if (missing.length === 0) {
|
|
303
|
+
const reviewerNote = available.includes(reviewer)
|
|
304
|
+
? `reviewer=${reviewer}`
|
|
305
|
+
: `reviewer=${reviewer} (disabled by component preset-tower-reviewer)`
|
|
306
|
+
checks.push(check('presets', 'pass', `worker=${worker} ${reviewerNote}`))
|
|
307
|
+
} else {
|
|
308
|
+
checks.push(
|
|
309
|
+
check(
|
|
310
|
+
'presets',
|
|
311
|
+
'fail',
|
|
312
|
+
`missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.requireReview is true, so preset-tower-reviewer may not be off)' : ''}`,
|
|
313
|
+
'enable the Tower preset component and retry',
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
}
|
|
300
317
|
} catch (error) {
|
|
301
318
|
checks.push(check('presets', 'warn', `preset registry unavailable: ${String(error?.message ?? error)}`))
|
|
302
319
|
}
|
|
@@ -304,6 +321,16 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
304
321
|
checks.push(check('presets', 'warn', 'agentPresets service is unavailable'))
|
|
305
322
|
}
|
|
306
323
|
|
|
324
|
+
// #121: the optional component rows are a diagnostic fact. A disabled row is
|
|
325
|
+
// reported with the exact "disabled by component <id>" reason rather than
|
|
326
|
+
// disappearing silently from the tool surface.
|
|
327
|
+
const components = await componentReport(ctx)
|
|
328
|
+
checks.push(
|
|
329
|
+
components.disabled.length > 0
|
|
330
|
+
? check('components', 'warn', componentSummary(components), 'enable the reported row in the plugin page and restart DSH')
|
|
331
|
+
: check('components', 'pass', componentSummary(components)),
|
|
332
|
+
)
|
|
333
|
+
|
|
307
334
|
const subagents = ctx.get?.('subagents')
|
|
308
335
|
checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
|
|
309
336
|
const hmr = ctx.get?.('hmr')
|
|
@@ -399,11 +426,15 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
399
426
|
)
|
|
400
427
|
|
|
401
428
|
const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
|
|
429
|
+
const testsDisabled =
|
|
430
|
+
config.tests?.enabled === false || components.disabled.some((entry) => entry.id === 'tower-tests')
|
|
402
431
|
checks.push(
|
|
403
432
|
check(
|
|
404
433
|
'tests',
|
|
405
|
-
|
|
406
|
-
|
|
434
|
+
testsDisabled ? 'warn' : 'pass',
|
|
435
|
+
testsDisabled
|
|
436
|
+
? `test discovery disabled${config.tests?.enabled === false ? ' (tests.enabled:false)' : ' (disabled by component tower-tests)'}`
|
|
437
|
+
: `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
|
|
407
438
|
),
|
|
408
439
|
)
|
|
409
440
|
checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
|
|
@@ -520,6 +551,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
520
551
|
// reports (from #89) and archive (from #73) are both kept
|
|
521
552
|
reports: reportsReport,
|
|
522
553
|
surface: surfaceReport,
|
|
554
|
+
components,
|
|
523
555
|
archive: {
|
|
524
556
|
missions: state ? state.missions.length : null,
|
|
525
557
|
archivedMissions: archiveInventory.missions,
|
package/host/hmr.js
CHANGED
|
@@ -20,13 +20,11 @@
|
|
|
20
20
|
// one, so a tool-triggered reload is safe.
|
|
21
21
|
import { TowerToolError } from '../domain/errors.js'
|
|
22
22
|
|
|
23
|
-
/** Entry ids Tower expects to be active after a reconciliation. */
|
|
23
|
+
/** Entry ids Tower expects to be active after a reconciliation (#121 rows). */
|
|
24
24
|
export const DEFAULT_REQUIRED_IDS = Object.freeze([
|
|
25
|
-
'tower-bundle',
|
|
26
25
|
'tower',
|
|
27
|
-
'tower-
|
|
26
|
+
'tower-host',
|
|
28
27
|
'preset-tower-worker',
|
|
29
|
-
'preset-tower-reviewer',
|
|
30
28
|
])
|
|
31
29
|
|
|
32
30
|
/** Debounce window for the dev hot-reload hook (milliseconds). */
|
package/host/presets.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
// Cross-field preset validation (#64): fail before work
|
|
1
|
+
// Cross-field preset and component validation (#64, #121): fail before work
|
|
2
|
+
// starts, not at spawn.
|
|
3
|
+
//
|
|
4
|
+
// Two invariants travel with the optional rows:
|
|
5
|
+
//
|
|
6
|
+
// 1. `preset-tower-worker` is a mandatory dependency: planning, mode entry and
|
|
7
|
+
// spawn must refuse when the worker preset is absent.
|
|
8
|
+
// 2. `preset-tower-reviewer` is a *conditional* component. It may be off while
|
|
9
|
+
// `review.requireReview` is false (merge is unaffected); with the review
|
|
10
|
+
// gate on it must be locked with an explanatory error, never silently
|
|
11
|
+
// allowed.
|
|
12
|
+
//
|
|
13
|
+
// `tower-tests` adds a third: with the component row off there is no
|
|
14
|
+
// `tower_test` tool and no receipt, so `gates.requireTests: true` is refused
|
|
15
|
+
// here rather than left to fail at merge time with a misleading blocker.
|
|
2
16
|
import { TowerToolError } from '../domain/errors.js'
|
|
17
|
+
import { componentEnabled, disabledReason } from './capability-registry.js'
|
|
3
18
|
|
|
4
19
|
export function presetIds(config = {}) {
|
|
5
20
|
return {
|
|
@@ -13,23 +28,50 @@ function rowId(preset) {
|
|
|
13
28
|
}
|
|
14
29
|
|
|
15
30
|
/**
|
|
16
|
-
* Resolve
|
|
17
|
-
* with preset-missing before mode entry/planning when a required preset is not
|
|
31
|
+
* Resolve the presets against the profile's agentPresets service. Fail closed
|
|
32
|
+
* with preset-missing before mode entry/planning when a *required* preset is not
|
|
18
33
|
* registered. If the service is unavailable, doctor reports that separately and
|
|
19
34
|
* this check cannot make a claim either way.
|
|
35
|
+
*
|
|
36
|
+
* The reviewer preset is required only when `review.requireReview` is true; the
|
|
37
|
+
* off state is otherwise supported by the component contract.
|
|
20
38
|
*/
|
|
21
39
|
export async function assertPresetsAvailable(ctx, config = {}) {
|
|
22
40
|
const service = ctx?.get?.('agentPresets')
|
|
23
41
|
if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
|
|
24
42
|
const presets = presetIds(config)
|
|
43
|
+
const required = [presets.worker]
|
|
44
|
+
if (config.review?.requireReview === true) required.push(presets.reviewer)
|
|
25
45
|
const available = new Set((await service.list()).map((entry) => String(entry.id)))
|
|
26
|
-
const missing =
|
|
27
|
-
if (missing.length
|
|
28
|
-
|
|
46
|
+
const missing = required.filter((id) => !available.has(id))
|
|
47
|
+
if (missing.length === 0) return { checked: true, missing: [] }
|
|
48
|
+
const rows = [...new Set(missing.map(rowId))]
|
|
49
|
+
const reviewLock =
|
|
50
|
+
config.review?.requireReview === true && missing.includes(presets.reviewer)
|
|
51
|
+
? ` review.requireReview is true, so the reviewer row may not be off; ${disabledReason(rowId(presets.reviewer))} is rejected`
|
|
52
|
+
: ''
|
|
53
|
+
throw new TowerToolError(
|
|
54
|
+
'preset-missing',
|
|
55
|
+
`required Tower agent preset(s) missing: ${missing.join(', ')}; re-enable ${rows.join(', ')} in the plugin page, then restart DSH.${reviewLock}`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every runtime invariant that depends on which component rows are mounted.
|
|
61
|
+
* Called before planning and before entering Tower mode, so a configuration
|
|
62
|
+
* that a component switch has invalidated is rejected with an explanation
|
|
63
|
+
* instead of failing later at merge time.
|
|
64
|
+
*
|
|
65
|
+
* @param {any} ctx
|
|
66
|
+
* @param {Record<string, any>} config
|
|
67
|
+
*/
|
|
68
|
+
export async function assertRuntimePreconditions(ctx, config = {}) {
|
|
69
|
+
const presets = await assertPresetsAvailable(ctx, config)
|
|
70
|
+
if (config.gates?.requireTests === true && !componentEnabled('tower-tests')) {
|
|
29
71
|
throw new TowerToolError(
|
|
30
|
-
'
|
|
31
|
-
`
|
|
72
|
+
'config-invalid',
|
|
73
|
+
`gates.requireTests is true but ${disabledReason('tower-tests')}; re-enable the tower-tests component or set gates.requireTests:false`,
|
|
32
74
|
)
|
|
33
75
|
}
|
|
34
|
-
return
|
|
76
|
+
return presets
|
|
35
77
|
}
|
package/host/tool-registry.js
CHANGED
|
@@ -18,7 +18,7 @@ import { git, resolveRepo, hasAnyCommit } from '../adapters/git.js'
|
|
|
18
18
|
import { runtimeConfig } from './config.js'
|
|
19
19
|
import { runDoctor } from './doctor.js'
|
|
20
20
|
import { createHmrBridge } from './hmr.js'
|
|
21
|
-
import {
|
|
21
|
+
import { assertRuntimePreconditions } from './presets.js'
|
|
22
22
|
import { readStateLock } from '../adapters/store.js'
|
|
23
23
|
import { lockTiming } from '../adapters/persistence.js'
|
|
24
24
|
import { acquireLease, releaseLease, inspectLeases } from '../adapters/leases.js'
|
|
@@ -53,6 +53,7 @@ import { registerFindingsTool } from '../tools/findings.js'
|
|
|
53
53
|
import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from '../adapters/tests.js'
|
|
54
54
|
import { summarize as summarizeBatch } from '../domain/batch.js'
|
|
55
55
|
import { assertPorts } from '../ports/index.js'
|
|
56
|
+
import { COMPONENT_TOOLS, componentEnabled, componentReport, holdComponent, disabledReason } from './capability-registry.js'
|
|
56
57
|
import path from 'node:path'
|
|
57
58
|
import { fsp } from '../adapters/store-fs.js'
|
|
58
59
|
import { createDefaultDeps } from '../adapters/default.js'
|
|
@@ -60,6 +61,17 @@ import crypto from 'node:crypto'
|
|
|
60
61
|
|
|
61
62
|
// Mutable composition state is grouped in one const object: the registration
|
|
62
63
|
// aggregator is the composition root, not an ambient module-level binding.
|
|
64
|
+
/**
|
|
65
|
+
* The workspace-lock hook the Host entry injects (`index.js`). Optional
|
|
66
|
+
* members are only present when the entry supplies them, so the registry and
|
|
67
|
+
* the tools that receive it declare the same shape.
|
|
68
|
+
* @typedef {object} WorkspaceHook
|
|
69
|
+
* @property {(repoRoot: string, exec: any, toolName: string) => Promise<void>} ensure
|
|
70
|
+
* @property {(exec: any) => string | null} [basePreference]
|
|
71
|
+
* @property {(exec: any) => Record<string, any> | null} [modeInfo]
|
|
72
|
+
* @property {(entry: any, key: string) => Promise<Record<string, any>>} [status]
|
|
73
|
+
*/
|
|
74
|
+
/** @type {{ workspaceHook: WorkspaceHook | null, hmrBridge: ReturnType<typeof createHmrBridge> | null, deps: Record<string, any> | null, definedTools: Set<string> }} */
|
|
63
75
|
const runtimeState = {
|
|
64
76
|
workspaceHook: null,
|
|
65
77
|
hmrBridge: null,
|
|
@@ -67,23 +79,54 @@ const runtimeState = {
|
|
|
67
79
|
// fallback is for tests/embedders that call registerTowerTools() directly;
|
|
68
80
|
// it is still built once per process.
|
|
69
81
|
deps: null,
|
|
70
|
-
// #84:
|
|
71
|
-
// so the concept budget measures the
|
|
72
|
-
//
|
|
73
|
-
//
|
|
82
|
+
// #84/#118/#121: registered tool names are recorded before any component row
|
|
83
|
+
// decides whether its capability mounts, so the concept budget measures the
|
|
84
|
+
// full build surface (29 tools) rather than one profile's mounted subset (28
|
|
85
|
+
// with the shipped `tower-hmr` row off). `recordToolRegistration` is the one
|
|
86
|
+
// recorder: define() calls it for every definition, the Host entry calls it
|
|
87
|
+
// for the directly registered tower_mode/tower_control pair, and the host
|
|
88
|
+
// records the declared capability tools (COMPONENT_TOOLS) because those entry
|
|
89
|
+
// points only run when their row is on. The number is reported by
|
|
90
|
+
// tower_doctor and compared with the committed scripts/surface-baseline.json.
|
|
74
91
|
definedTools: new Set(),
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Record one tool name in the concept-budget surface (#84, #118, #121).
|
|
96
|
+
*
|
|
97
|
+
* This is the single recorder for the *build* tool surface. `define()` calls it
|
|
98
|
+
* for every definition when a capability is mounted; the Host entry (`index.js`)
|
|
99
|
+
* calls it for the two tools it registers directly, `tower_mode` and
|
|
100
|
+
* `tower_control`; and `registerCoreTowerTools` records the declared capability
|
|
101
|
+
* tools up front so a row that ships off does not shrink the budget. One
|
|
102
|
+
* recorder means the count cannot silently miss a registration path again: #118
|
|
103
|
+
* found 27 of the 29 names because only the `define()` path was recorded.
|
|
104
|
+
*/
|
|
105
|
+
export function recordToolRegistration(name) {
|
|
106
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
107
|
+
throw new TypeError('recordToolRegistration requires a non-empty tool name')
|
|
108
|
+
}
|
|
109
|
+
runtimeState.definedTools.add(name.trim())
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The full shipped tool surface (names, sorted). It covers every tool this build
|
|
114
|
+
* can register, including capabilities whose component row is currently off.
|
|
115
|
+
*/
|
|
78
116
|
export function definedToolNames() {
|
|
79
117
|
return [...runtimeState.definedTools].sort()
|
|
80
118
|
}
|
|
81
119
|
|
|
82
|
-
/** The number of tools
|
|
120
|
+
/** The number of tools this build ships, used by the concept budget (#84). */
|
|
83
121
|
export function definedToolCount() {
|
|
84
122
|
return runtimeState.definedTools.size
|
|
85
123
|
}
|
|
86
124
|
|
|
125
|
+
/** Test hook: the recorded set is process state and must be reset per fixture. */
|
|
126
|
+
export function __testResetToolSurface() {
|
|
127
|
+
runtimeState.definedTools.clear()
|
|
128
|
+
}
|
|
129
|
+
|
|
87
130
|
/** The injected port set for the app layer (built once per process). */
|
|
88
131
|
function towerDeps() {
|
|
89
132
|
if (!runtimeState.deps) runtimeState.deps = createDefaultDeps()
|
|
@@ -335,15 +378,7 @@ async function ensureRepository(repoRootInput, autoGitInit) {
|
|
|
335
378
|
}
|
|
336
379
|
}
|
|
337
380
|
|
|
338
|
-
const OPERATIONAL_TOOLS = new Map(
|
|
339
|
-
['tower_batch', 'exposeBatch'],
|
|
340
|
-
['tower_resource', 'exposeLeases'],
|
|
341
|
-
['tower_import_legacy', 'exposeImport'],
|
|
342
|
-
['tower_legacy_map', 'exposeImport'],
|
|
343
|
-
['tower_recover', 'exposeRecovery'],
|
|
344
|
-
['tower_test', 'exposeTests'],
|
|
345
|
-
['tower_doctor', 'exposeDoctor'],
|
|
346
|
-
])
|
|
381
|
+
const OPERATIONAL_TOOLS = new Map()
|
|
347
382
|
|
|
348
383
|
function plainSchema(value) {
|
|
349
384
|
return JSON.parse(JSON.stringify(value))
|
|
@@ -368,12 +403,10 @@ function plainSchema(value) {
|
|
|
368
403
|
*/
|
|
369
404
|
function define(ctx, definition) {
|
|
370
405
|
const { aliases, requiresMissionId, board, execute, ...rest } = definition
|
|
371
|
-
// Record the definition
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const flag = OPERATIONAL_TOOLS.get(rest.name)
|
|
376
|
-
if (flag && (toolsConfig.exposeOperational === false || toolsConfig[flag] === false)) return
|
|
406
|
+
// Record the definition for the concept budget. The capability that owns the
|
|
407
|
+
// tool decides whether it is registered at all (#121): the component row is
|
|
408
|
+
// the switch, so `define()` no longer consults a `tools.expose*` boolean.
|
|
409
|
+
recordToolRegistration(rest.name)
|
|
377
410
|
ctx.effect(() =>
|
|
378
411
|
ctx.tools.register({
|
|
379
412
|
...rest,
|
|
@@ -433,6 +466,12 @@ function textOf(blocks) {
|
|
|
433
466
|
/** Read `package.json tower.testDiscovery` and run it for one mission worktree.
|
|
434
467
|
* Returns `{configured:false}` when the repository has no discovery block. */
|
|
435
468
|
async function runMissionDiscovery(tower, mission) {
|
|
469
|
+
// #121: test discovery is the tower-tests component. With the row off there is
|
|
470
|
+
// no tower_test tool and no receipt; never execute repository code silently
|
|
471
|
+
// because a config key still says admission complete/spawn.
|
|
472
|
+
if (!componentEnabled('tower-tests')) {
|
|
473
|
+
return { configured: false, disabledBy: 'tower-tests', reason: disabledReason('tower-tests') }
|
|
474
|
+
}
|
|
436
475
|
const testsConfig = tower.config.tests ?? {}
|
|
437
476
|
if (testsConfig.enabled === false || testsConfig.allowPackageDiscovery === false) {
|
|
438
477
|
return { configured: false }
|
|
@@ -495,9 +534,29 @@ async function observeActivity(ctx, tower) {
|
|
|
495
534
|
return { activity, parentStatus }
|
|
496
535
|
}
|
|
497
536
|
|
|
498
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Register the mandatory Tower host surface (#121).
|
|
539
|
+
*
|
|
540
|
+
* The host owns planning, mode integration, the health contract
|
|
541
|
+
* (`tower_doctor`) and the recovery escape (`tower_recover`); neither is a
|
|
542
|
+
* component and neither can be switched off. The optional capabilities
|
|
543
|
+
* (batch/import/leases/tests/reload) are registered by their own entry points
|
|
544
|
+
* (`registerBatchCapability`, ...), so switching a component row off removes
|
|
545
|
+
* exactly that capability while the host keeps working.
|
|
546
|
+
*/
|
|
547
|
+
export function registerCoreTowerTools(ctx, options = {}) {
|
|
499
548
|
runtimeState.workspaceHook = options.workspace ?? null
|
|
500
549
|
runtimeState.hmrBridge = options.hmrBridge ?? null
|
|
550
|
+
// #121/#122: capability tools are part of this build even while their
|
|
551
|
+
// component row is off. A row's entry point only runs when the row is on, so
|
|
552
|
+
// record the declared capability names here — before any row gates
|
|
553
|
+
// registration — and the concept budget reports the full 29-tool build
|
|
554
|
+
// surface in every profile. `componentReport` is what names the subset that
|
|
555
|
+
// is actually mounted; this count never shrinks because a user switched a
|
|
556
|
+
// component off.
|
|
557
|
+
for (const toolIds of Object.values(COMPONENT_TOOLS)) {
|
|
558
|
+
for (const toolId of toolIds) recordToolRegistration(toolId)
|
|
559
|
+
}
|
|
501
560
|
// #69: the host entry (index.js) is the composition root. When it supplies the
|
|
502
561
|
// port set, adopt it — validated once — instead of constructing a second set
|
|
503
562
|
// here. Tests/embedders that omit deps fall back to one lazily built set.
|
|
@@ -548,19 +607,21 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
548
607
|
ctx.effect(() => () => clearInterval(timer))
|
|
549
608
|
registerInitTool(ctx, { openTower, define, actorFor, requireTowerActor, workspaceHook: runtimeState.workspaceHook, ensureRepository, resolveRepo, runtimeConfig })
|
|
550
609
|
|
|
551
|
-
|
|
610
|
+
// #121: planning validates the mounted component rows as well as the presets,
|
|
611
|
+
// so a review gate without its reviewer row is refused before work starts.
|
|
612
|
+
registerPlanTool(ctx, { openTower, define, actorFor, requireTowerActor, assertPresetsAvailable: assertRuntimePreconditions })
|
|
552
613
|
|
|
553
614
|
registerMissionTool(ctx, { openTower, define, actorFor, requireKnownActor })
|
|
554
615
|
|
|
555
|
-
registerStatusTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity })
|
|
616
|
+
registerStatusTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity, componentReport })
|
|
556
617
|
|
|
557
|
-
registerSpawnTool(ctx, { openTower, define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState,
|
|
618
|
+
registerSpawnTool(ctx, { openTower, define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf, inspectLeases })
|
|
558
619
|
|
|
559
|
-
registerRosterTool(ctx, { openTower, define
|
|
620
|
+
registerRosterTool(ctx, { openTower, define })
|
|
560
621
|
|
|
561
622
|
registerInterruptTool(ctx, { openTower, define, actorFor, requireTowerActor, interruptRosterAgent })
|
|
562
623
|
|
|
563
|
-
registerDiffTool(ctx, { openTower, define
|
|
624
|
+
registerDiffTool(ctx, { openTower, define })
|
|
564
625
|
|
|
565
626
|
registerAdoptTool(ctx, { openTower, define, actorFor, requireTowerActor })
|
|
566
627
|
|
|
@@ -591,24 +652,79 @@ export function registerTowerTools(ctx, options = {}) {
|
|
|
591
652
|
resolveRepo,
|
|
592
653
|
runtimeConfig,
|
|
593
654
|
runDoctor,
|
|
594
|
-
// #84: the doctor reports the
|
|
595
|
-
//
|
|
655
|
+
// #84/#122: the doctor reports the full registered surface. It is always
|
|
656
|
+
// registered itself, and it is the reporter that names a disabled component.
|
|
596
657
|
surface: () => ({ tools: definedToolCount() }),
|
|
597
658
|
})
|
|
598
659
|
|
|
660
|
+
// #122: the recovery escape is registered for every configuration. The lock it
|
|
661
|
+
// repairs can be left behind by any crashed mutation, so an off state would be
|
|
662
|
+
// worse than uninstalling the plugin.
|
|
599
663
|
registerRecoverTool(ctx, { openTower, define, actorFor, requireTowerActor, readStateLock, lockTiming })
|
|
664
|
+
}
|
|
600
665
|
|
|
601
|
-
|
|
602
|
-
|
|
666
|
+
/** Evidence batches — the `tower-batch` component. */
|
|
667
|
+
export function registerBatchCapability(ctx) {
|
|
603
668
|
registerBatchTool(ctx, { openTower, define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles })
|
|
669
|
+
}
|
|
604
670
|
|
|
671
|
+
/** Advisory leases — the `tower-leases` component. */
|
|
672
|
+
export function registerLeasesCapability(ctx) {
|
|
605
673
|
registerResourceTool(ctx, { openTower, define, actorFor, requireTowerActor, acquireLease, releaseLease, inspectLeases })
|
|
674
|
+
}
|
|
606
675
|
|
|
676
|
+
/** Legacy import and its audit map — the `tower-import` component. */
|
|
677
|
+
export function registerImportCapability(ctx) {
|
|
607
678
|
registerImportLegacyTool(ctx, { openTower, define, actorFor, requireTowerActor, importLegacySnapshot })
|
|
608
|
-
|
|
609
679
|
registerLegacyMapTool(ctx, { openTower, define, actorFor, requireTowerActor, validateLegacyMap })
|
|
680
|
+
}
|
|
610
681
|
|
|
611
|
-
|
|
682
|
+
/** Declared test discovery — the `tower-tests` component. */
|
|
683
|
+
export function registerTestsCapability(ctx) {
|
|
684
|
+
registerTestTool(ctx, { openTower, define, actorFor, requireKnownActor, runMissionDiscovery })
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Dev hot reload — the `tower-hmr` component (shipped off).
|
|
689
|
+
* The returned bridge is the one `/tower reload` and `tower_mode` report.
|
|
690
|
+
*/
|
|
691
|
+
export function registerHmrCapability(ctx, options = {}) {
|
|
692
|
+
const dev = runtimeConfig().dev ?? {}
|
|
693
|
+
const bridge =
|
|
694
|
+
options.bridge ??
|
|
695
|
+
runtimeState.hmrBridge ??
|
|
696
|
+
createHmrBridge(ctx, {
|
|
697
|
+
requiredIds: dev.reloadRequiredIds,
|
|
698
|
+
delayMs: dev.reloadDelayMs,
|
|
699
|
+
logger: ctx.logger ?? console,
|
|
700
|
+
})
|
|
701
|
+
registerReloadTool(ctx, { openTower, define, actorFor, requireTowerActor, sharedHmrBridge: bridge, runtimeConfig, createHmrBridge })
|
|
702
|
+
return bridge
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Register every optional capability in-process. This is the aggregate used by
|
|
707
|
+
* tests and embedders; the shipped profile mounts the capability rows instead,
|
|
708
|
+
* so one row can be off without removing the rest.
|
|
709
|
+
*/
|
|
710
|
+
export function registerAllCapabilities(ctx, options = {}) {
|
|
711
|
+
for (const id of ['tower-batch', 'tower-import', 'tower-leases', 'tower-tests', 'tower-hmr']) holdComponent(ctx, id)
|
|
712
|
+
registerBatchCapability(ctx)
|
|
713
|
+
registerImportCapability(ctx)
|
|
714
|
+
registerLeasesCapability(ctx)
|
|
715
|
+
registerTestsCapability(ctx)
|
|
716
|
+
registerHmrCapability(ctx, options)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Backwards-compatible aggregate: the mandatory host surface plus every
|
|
721
|
+
* optional capability. Tests and embedders call this; the shipped composition
|
|
722
|
+
* calls `registerCoreTowerTools` and lets the component rows register their own
|
|
723
|
+
* capability.
|
|
724
|
+
*/
|
|
725
|
+
export function registerTowerTools(ctx, options = {}) {
|
|
726
|
+
registerCoreTowerTools(ctx, options)
|
|
727
|
+
registerAllCapabilities(ctx, options)
|
|
612
728
|
}
|
|
613
729
|
|
|
614
730
|
export const __testSpawnBackoff = {
|