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,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Engineering-philosophy gate (#83).
|
|
3
|
+
//
|
|
4
|
+
// The repository writes down what it refuses to do and points every principle at
|
|
5
|
+
// the thing that enforces it. A pointer that does not resolve is a slogan, so
|
|
6
|
+
// this check fails on it. It also keeps the ADR set dated/structure-complete and
|
|
7
|
+
// makes every "Non-goals" heading in docs/ auditable: it must either name an
|
|
8
|
+
// existing enforcement file or be explicitly labelled `unowned`.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// node scripts/check-philosophy.mjs [--root <dir>] [--json]
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { parseArgs, runMain } from './lib/cli.mjs'
|
|
16
|
+
|
|
17
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
const PRINCIPLES_FILE = 'PRINCIPLES.md'
|
|
19
|
+
const ADR_DIR = 'docs/adr'
|
|
20
|
+
const MIN_PRINCIPLES = 8
|
|
21
|
+
const MIN_ADRS = 3
|
|
22
|
+
const ADR_REQUIRED_SECTIONS = ['Context', 'Decision', 'Consequences', 'Rejected alternatives']
|
|
23
|
+
const ADR_REQUIRED_TOPICS = ['ports', 'state.json', 'allowAgentControl']
|
|
24
|
+
const USAGE = `usage: node scripts/check-philosophy.mjs [--root <dir>] [--json]
|
|
25
|
+
|
|
26
|
+
--root repository root to check (default: this package)
|
|
27
|
+
--json print the report as JSON
|
|
28
|
+
--help print this text
|
|
29
|
+
|
|
30
|
+
Every principle needs "Refused because of this:" and "Enforced by:" where the
|
|
31
|
+
enforcement names an existing file and a test or gate. Every docs/ "Non-goals"
|
|
32
|
+
heading needs an existing enforcement pointer or the word "unowned". At least
|
|
33
|
+
${MIN_ADRS} dated ADRs need Context, Decision, Consequences and Rejected alternatives.`
|
|
34
|
+
|
|
35
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
36
|
+
|
|
37
|
+
function finding(code, message) {
|
|
38
|
+
return { code, level: 'error', message }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listMarkdown(dir) {
|
|
42
|
+
const files = []
|
|
43
|
+
if (!fs.existsSync(dir)) return files
|
|
44
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const full = path.join(dir, entry.name)
|
|
46
|
+
if (entry.isDirectory()) files.push(...listMarkdown(full))
|
|
47
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) files.push(full)
|
|
48
|
+
}
|
|
49
|
+
return files
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Candidate repository paths: backticked tokens with a slash or a file suffix. */
|
|
53
|
+
function backtickedPaths(text) {
|
|
54
|
+
const paths = []
|
|
55
|
+
for (const match of String(text ?? '').matchAll(/`([^`]+)`/g)) {
|
|
56
|
+
const token = match[1].trim().replace(/\\/g, '/')
|
|
57
|
+
if (!token || token.startsWith('npm ') || token.startsWith('gh ') || token.includes(' ')) continue
|
|
58
|
+
if (!token.includes('/') && !/\.(?:mjs|js|cjs|ts|md|json|ya?ml)$/.test(token)) continue
|
|
59
|
+
paths.push(token)
|
|
60
|
+
}
|
|
61
|
+
return paths
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fieldText(body, label) {
|
|
65
|
+
const lines = String(body ?? '').split(/\r?\n/)
|
|
66
|
+
const start = lines.findIndex((line) => new RegExp(`^\\s*(?:[-*]\\s*)?\\**${label}:?\\**\\s*(.*)$`, 'i').test(line))
|
|
67
|
+
if (start < 0) return null
|
|
68
|
+
const match = new RegExp(`^\\s*(?:[-*]\\s*)?\\**${label}:?\\**\\s*(.*)$`, 'i').exec(lines[start])
|
|
69
|
+
const parts = [match?.[1] ?? '']
|
|
70
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
71
|
+
const line = lines[index]
|
|
72
|
+
if (!line.trim()) break
|
|
73
|
+
if (/^\s*(?:[-*]\s*)?\**Refused because of this:?/i.test(line)) break
|
|
74
|
+
if (/^\s*(?:[-*]\s*)?\**Enforced by:?/i.test(line)) break
|
|
75
|
+
if (/^#{1,6}\s+/.test(line)) break
|
|
76
|
+
parts.push(line.trim())
|
|
77
|
+
}
|
|
78
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim() || null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read PRINCIPLES.md into sections, then check each section's refusal and
|
|
83
|
+
* enforcement pointer. A section without both is not a principle.
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @param {(relative: string) => boolean} exists
|
|
86
|
+
*/
|
|
87
|
+
export function checkPrinciplesText(text, exists) {
|
|
88
|
+
const findings = []
|
|
89
|
+
const headings = [...String(text ?? '').matchAll(/^##\s+(\d+)\.\s+(.+?)\s*$/gm)]
|
|
90
|
+
if (headings.length < MIN_PRINCIPLES) {
|
|
91
|
+
findings.push(finding('principles-count', `${PRINCIPLES_FILE} has ${headings.length} numbered principles; at least ${MIN_PRINCIPLES} are required`))
|
|
92
|
+
}
|
|
93
|
+
const principles = []
|
|
94
|
+
headings.forEach((heading, index) => {
|
|
95
|
+
const start = heading.index + heading[0].length
|
|
96
|
+
const end = index + 1 < headings.length ? headings[index + 1].index : text.length
|
|
97
|
+
const body = text.slice(start, end)
|
|
98
|
+
const number = Number(heading[1])
|
|
99
|
+
const title = heading[2].trim()
|
|
100
|
+
const refusal = fieldText(body, 'Refused because of this')
|
|
101
|
+
const enforcement = fieldText(body, 'Enforced by')
|
|
102
|
+
const record = { number, title, refusal, enforcement }
|
|
103
|
+
principles.push(record)
|
|
104
|
+
if (!record.refusal) {
|
|
105
|
+
findings.push(finding('principle-refusal', `principle ${number} (${title}) has no "Refused because of this:" line`))
|
|
106
|
+
}
|
|
107
|
+
if (!record.enforcement) {
|
|
108
|
+
findings.push(finding('principle-enforcement', `principle ${number} (${title}) has no "Enforced by:" line`))
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const candidates = backtickedPaths(record.enforcement)
|
|
112
|
+
const missing = candidates.filter((candidate) => !exists(candidate))
|
|
113
|
+
if (candidates.length === 0) {
|
|
114
|
+
findings.push(finding('principle-pointer-missing', `principle ${number} (${title}) names no backticked file in "Enforced by:"`))
|
|
115
|
+
} else {
|
|
116
|
+
for (const candidate of missing) {
|
|
117
|
+
findings.push(
|
|
118
|
+
finding('principle-pointer-missing', `principle ${number} (${title}) points at a missing file: ${candidate}`),
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!/(?:tests\/|\.test\.|npm run [a-z0-9:_-]+)/i.test(record.enforcement)) {
|
|
123
|
+
findings.push(
|
|
124
|
+
finding('principle-enforcement-weak', `principle ${number} (${title}) names no test or gate in "Enforced by:"`),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
return { principles, findings }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check that the ADR set is dated, structured and covers the three load-bearing
|
|
133
|
+
* decisions the issue asked to backfill.
|
|
134
|
+
* @param {string} root
|
|
135
|
+
*/
|
|
136
|
+
export function checkAdrs(root) {
|
|
137
|
+
const findings = []
|
|
138
|
+
const dir = path.join(root, ADR_DIR)
|
|
139
|
+
const files = fs.existsSync(dir)
|
|
140
|
+
? fs
|
|
141
|
+
.readdirSync(dir)
|
|
142
|
+
.filter((name) => /^\d{4}-.+\.md$/.test(name))
|
|
143
|
+
.sort()
|
|
144
|
+
: []
|
|
145
|
+
if (files.length < MIN_ADRS) {
|
|
146
|
+
findings.push(finding('adr-count', `${ADR_DIR} has ${files.length} numbered ADRs; at least ${MIN_ADRS} are required`))
|
|
147
|
+
}
|
|
148
|
+
const combined = []
|
|
149
|
+
for (const name of files) {
|
|
150
|
+
const relative = `${ADR_DIR}/${name}`
|
|
151
|
+
const text = fs.readFileSync(path.join(dir, name), 'utf8')
|
|
152
|
+
combined.push(text)
|
|
153
|
+
for (const section of ADR_REQUIRED_SECTIONS) {
|
|
154
|
+
const heading = new RegExp(`^#{1,6}\\s+${section}\\b`, 'im')
|
|
155
|
+
if (!heading.test(text)) findings.push(finding('adr-section', `${relative} misses the "${section}" section`))
|
|
156
|
+
}
|
|
157
|
+
if (!/^\s*(?:[-*]\s*)?Date:\s*(\d{4}-\d{2}-\d{2})\s*$/im.test(text)) {
|
|
158
|
+
findings.push(finding('adr-date', `${relative} needs a "Date: YYYY-MM-DD" line`))
|
|
159
|
+
}
|
|
160
|
+
if (!/^\s*(?:[-*]\s*)?Status:\s*\S+/im.test(text)) {
|
|
161
|
+
findings.push(finding('adr-status', `${relative} needs a "Status:" line`))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const all = combined.join('\n')
|
|
165
|
+
for (const topic of ADR_REQUIRED_TOPICS) {
|
|
166
|
+
if (!new RegExp(topic.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(all)) {
|
|
167
|
+
findings.push(finding('adr-topic', `no ADR explains the "${topic}" decision`))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { adrDir: ADR_DIR, adrs: files, findings }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Every "Non-goals" heading in docs/ must carry an enforcement pointer that
|
|
175
|
+
* resolves or the explicit word `unowned`. No heading may be an orphan refusal.
|
|
176
|
+
* @param {string} root
|
|
177
|
+
*/
|
|
178
|
+
export function checkNonGoals(root) {
|
|
179
|
+
const findings = []
|
|
180
|
+
const files = listMarkdown(path.join(root, 'docs'))
|
|
181
|
+
let headings = 0
|
|
182
|
+
for (const file of files) {
|
|
183
|
+
const relative = path.relative(root, file).replace(/\\/g, '/')
|
|
184
|
+
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/)
|
|
185
|
+
lines.forEach((line, index) => {
|
|
186
|
+
const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line)
|
|
187
|
+
if (!heading || !/\bnon[-\s]?goals?\b/i.test(heading[2])) return
|
|
188
|
+
headings += 1
|
|
189
|
+
const level = heading[1].length
|
|
190
|
+
const body = []
|
|
191
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
192
|
+
const next = /^(#{1,6})\s+/.exec(lines[cursor])
|
|
193
|
+
if (next && next[1].length <= level) break
|
|
194
|
+
body.push(lines[cursor])
|
|
195
|
+
}
|
|
196
|
+
const text = body.join('\n')
|
|
197
|
+
const enforcement = /^\s*(?:[-*]\s*)?\**Enforced by:?\**\s*(.+)$/im.exec(text)
|
|
198
|
+
const unowned = /\bunowned\b/i.test(text)
|
|
199
|
+
const enforced =
|
|
200
|
+
Boolean(unowned) ||
|
|
201
|
+
Boolean(enforcement && backtickedPaths(enforcement[1]).some((candidate) => fs.existsSync(path.join(root, candidate))))
|
|
202
|
+
if (!enforced) {
|
|
203
|
+
findings.push(
|
|
204
|
+
finding(
|
|
205
|
+
'non-goal-unowned',
|
|
206
|
+
`${relative}:${index + 1} has a "Non-goals" heading with no existing enforcement pointer and no "unowned" label`,
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
return { headings, findings }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Run all three checks against one repository root. */
|
|
216
|
+
export function checkPhilosophy(root) {
|
|
217
|
+
const findings = []
|
|
218
|
+
const principlesFile = path.join(root, PRINCIPLES_FILE)
|
|
219
|
+
let principles = []
|
|
220
|
+
if (!fs.existsSync(principlesFile)) {
|
|
221
|
+
findings.push(finding('principles-missing', `${PRINCIPLES_FILE} is missing`))
|
|
222
|
+
} else {
|
|
223
|
+
const result = checkPrinciplesText(fs.readFileSync(principlesFile, 'utf8'), (relative) =>
|
|
224
|
+
fs.existsSync(path.join(root, relative)),
|
|
225
|
+
)
|
|
226
|
+
principles = result.principles
|
|
227
|
+
findings.push(...result.findings)
|
|
228
|
+
}
|
|
229
|
+
const adr = checkAdrs(root)
|
|
230
|
+
findings.push(...adr.findings)
|
|
231
|
+
const nonGoals = checkNonGoals(root)
|
|
232
|
+
findings.push(...nonGoals.findings)
|
|
233
|
+
return {
|
|
234
|
+
ok: findings.length === 0,
|
|
235
|
+
principles,
|
|
236
|
+
adrs: adr.adrs,
|
|
237
|
+
nonGoals,
|
|
238
|
+
findings,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function main(argv) {
|
|
243
|
+
const options = parseArgs(argv, {
|
|
244
|
+
initial: { root: PACKAGE_ROOT, json: false, help: false },
|
|
245
|
+
values: { '--root': { key: 'root', parse: path.resolve } },
|
|
246
|
+
flags: { '--json': 'json', '--help': 'help', '-h': 'help' },
|
|
247
|
+
})
|
|
248
|
+
if (options.help) {
|
|
249
|
+
console.log(USAGE)
|
|
250
|
+
return 0
|
|
251
|
+
}
|
|
252
|
+
const report = checkPhilosophy(path.resolve(options.root))
|
|
253
|
+
if (options.json) {
|
|
254
|
+
console.log(JSON.stringify(report, null, 2))
|
|
255
|
+
return report.ok ? 0 : 1
|
|
256
|
+
}
|
|
257
|
+
console.log(
|
|
258
|
+
`philosophy: ${report.principles.length} principles, ${report.adrs.length} ADRs, ${report.nonGoals.headings} non-goal heading(s)`,
|
|
259
|
+
)
|
|
260
|
+
for (const item of report.findings) console.log(`philosophy: ${item.level} [${item.code}] ${item.message}`)
|
|
261
|
+
if (report.ok) console.log('philosophy: ok — every refusal points at an existing test or gate')
|
|
262
|
+
else console.log(`philosophy: ${report.findings.length} unenforced statement(s)`)
|
|
263
|
+
return report.ok ? 0 : 1
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'philosophy', usage: USAGE })
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"date": "2026-10-23",
|
|
4
|
+
"adr": "docs/adr/0005-component-surface.md",
|
|
5
|
+
"classes": {
|
|
6
|
+
"core": "Removing it breaks the Tower contract. It is never a switch.",
|
|
7
|
+
"optional-component": "A capability whose off state leaves the rest of Tower intact. It must be a user-visible switch with a documented fallback.",
|
|
8
|
+
"optional-config": "A tuning knob, not a capability. It stays in the config form and never hides a component.",
|
|
9
|
+
"forbidden-to-disable": "The host, the health contract, the recovery escape, the review contract and the merge gates. No group, row or boolean key may turn it off."
|
|
10
|
+
},
|
|
11
|
+
"components": [
|
|
12
|
+
{
|
|
13
|
+
"id": "tower",
|
|
14
|
+
"scope": "visible",
|
|
15
|
+
"kind": "group",
|
|
16
|
+
"fallback": "Off equals uninstalling Tower: no tools, presets or skills remain. That is the accepted all-off boundary of the mandatory host anchor, not an optional capability."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "tower-host",
|
|
20
|
+
"scope": "internal",
|
|
21
|
+
"kind": "row",
|
|
22
|
+
"fallback": "The Host plugin itself, nested under the mandatory tower anchor. A mandatory dependency, never its own switch."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "tower-skills",
|
|
26
|
+
"scope": "visible",
|
|
27
|
+
"kind": "row",
|
|
28
|
+
"fallback": "The 3 skills leave the catalog; the worker/reviewer PROTOCOL sections still ship through systemPrompt.section, so only the tower-recovery guidance is lost."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "tower-batch",
|
|
32
|
+
"scope": "visible",
|
|
33
|
+
"kind": "row",
|
|
34
|
+
"fallback": "tower_batch is not registered; missions, gates and merges are unaffected because no state depends on a batch."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "tower-import",
|
|
38
|
+
"scope": "visible",
|
|
39
|
+
"kind": "row",
|
|
40
|
+
"fallback": "tower_import_legacy and tower_legacy_map are not registered; new repository state is unaffected."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "tower-leases",
|
|
44
|
+
"scope": "visible",
|
|
45
|
+
"kind": "row",
|
|
46
|
+
"fallback": "tower_resource is not registered; advisory leases are coordination only, so nothing else depends on them."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "tower-tests",
|
|
50
|
+
"scope": "visible",
|
|
51
|
+
"kind": "row",
|
|
52
|
+
"fallback": "tower_test is not registered and no test receipt can be recorded. With gates.requireTests:true planning/mode entry is rejected with an explanation, and the merge gate still reports tests-missing."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "tower-hmr",
|
|
56
|
+
"scope": "visible",
|
|
57
|
+
"kind": "row",
|
|
58
|
+
"fallback": "tower_reload and /tower reload are unavailable; a profile without HMR behaves exactly as before. Shipped disabled: enable the row for a linked dev profile."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "preset-tower-worker",
|
|
62
|
+
"scope": "internal",
|
|
63
|
+
"kind": "row",
|
|
64
|
+
"fallback": "tower_spawn fails without the worker preset. A mandatory dependency, never a switch."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "preset-tower-reviewer",
|
|
68
|
+
"scope": "visible",
|
|
69
|
+
"kind": "row",
|
|
70
|
+
"fallback": "The reviewer cannot be dispatched. Supported while review.requireReview is false (merge is unaffected); with the gate on planning/mode entry is locked with an explanatory error."
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"gateableTools": {},
|
|
74
|
+
"keys": [
|
|
75
|
+
{
|
|
76
|
+
"name": "section",
|
|
77
|
+
"class": "core",
|
|
78
|
+
"note": "Legacy alias for mode.promptSection: the Tower-mode prompt contract, not a capability."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "allowAgentControl",
|
|
82
|
+
"class": "core",
|
|
83
|
+
"note": "Legacy alias for mode.allowAgentControl: the human-owned mode rule (ADR 0003), not a component."
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "init.autoGitInit",
|
|
87
|
+
"class": "optional-config",
|
|
88
|
+
"note": "Bootstrap convenience; off only means tower_init will not run git init."
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "init.snapshotDirty",
|
|
92
|
+
"class": "optional-config",
|
|
93
|
+
"note": "Dirty-work baseline convenience; no capability is removed."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "init.autoExclude",
|
|
97
|
+
"class": "optional-config",
|
|
98
|
+
"note": "Keeps Tower artifacts out of .git/info/exclude; safe either way."
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "mode.promptSection",
|
|
102
|
+
"class": "core",
|
|
103
|
+
"note": "Replaces the whole Tower-mode prompt text; contract text, not a capability."
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "mode.sectionAppend",
|
|
107
|
+
"class": "optional-config",
|
|
108
|
+
"note": "Appends team conventions to the prompt."
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "mode.allowAgentControl",
|
|
112
|
+
"class": "core",
|
|
113
|
+
"note": "Decides whether the model may toggle the mode (ADR 0003). A governance rule, never a component."
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "mode.autoPauseGoal",
|
|
117
|
+
"class": "core",
|
|
118
|
+
"note": "Entering the mode pauses an active goal; exit never resumes it."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "mode.requireResumeAuthorization",
|
|
122
|
+
"class": "core",
|
|
123
|
+
"note": "Guards resuming a user-paused mission."
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "mode.requireHumanAckOnFirstEntry",
|
|
127
|
+
"class": "core",
|
|
128
|
+
"note": "First entry can require an explicit human ack."
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "mode.workspaceLock",
|
|
132
|
+
"class": "core",
|
|
133
|
+
"note": "One Tower session per repository is a safety contract; a row may not turn it off."
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"name": "mode.workspaceLockScope",
|
|
137
|
+
"class": "optional-config",
|
|
138
|
+
"note": "Chooses whether read-only tools take the workspace lock."
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "mode.planModePolicy",
|
|
142
|
+
"class": "core",
|
|
143
|
+
"note": "Plan mode and Tower mode stay mutually exclusive."
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"name": "mode.workspaceStaleMs",
|
|
147
|
+
"class": "optional-config",
|
|
148
|
+
"note": "Stale window for the advisory workspace lock."
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "mode.heartbeatMs",
|
|
152
|
+
"class": "optional-config",
|
|
153
|
+
"note": "Heartbeat interval while the mode is on."
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"name": "mode.denyTools",
|
|
157
|
+
"class": "core",
|
|
158
|
+
"note": "The denied-tool list is part of the mode guard."
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"name": "mode.storePath",
|
|
162
|
+
"class": "optional-config",
|
|
163
|
+
"note": "Custom mode-store location; no capability is removed."
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "mode.maxEntries",
|
|
167
|
+
"class": "optional-config",
|
|
168
|
+
"note": "Bounds the persisted mode store."
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"name": "mode.lockTimeoutMs",
|
|
172
|
+
"class": "optional-config",
|
|
173
|
+
"note": "Bounded wait for the cross-process mode lock."
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"name": "review.requireReview",
|
|
177
|
+
"class": "forbidden-to-disable",
|
|
178
|
+
"note": "Turns independent review into a merge blocker; no component may switch it off. With it on, the preset-tower-reviewer row may not be off (planning/mode entry refuse the combination)."
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"name": "review.maxRoundsPerReviewer",
|
|
182
|
+
"class": "forbidden-to-disable",
|
|
183
|
+
"note": "Caps review rounds; part of the review contract."
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "merge.timeoutMs",
|
|
187
|
+
"class": "optional-config",
|
|
188
|
+
"note": "Default merge timeout; a per-call value wins."
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"name": "merge.maxBufferBytes",
|
|
192
|
+
"class": "optional-config",
|
|
193
|
+
"note": "Captured merge output buffer."
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"name": "git.timeoutMs",
|
|
197
|
+
"class": "optional-config",
|
|
198
|
+
"note": "Default Git command timeout outside merge."
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"name": "worktrees.root",
|
|
202
|
+
"class": "optional-config",
|
|
203
|
+
"note": "Default worktree placement."
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"name": "spawn.workerPreset",
|
|
207
|
+
"class": "core",
|
|
208
|
+
"note": "Chooses the worker tool surface; a dependency, never a switch."
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"name": "spawn.reviewerPreset",
|
|
212
|
+
"class": "core",
|
|
213
|
+
"note": "Chooses the read-only reviewer surface; a dependency, never a switch."
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"name": "spawn.maxDepth",
|
|
217
|
+
"class": "optional-config",
|
|
218
|
+
"note": "Subagent depth requested at spawn."
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "spawn.provider",
|
|
222
|
+
"class": "optional-config",
|
|
223
|
+
"note": "Provider route for spawned agents."
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"name": "spawn.workerModel",
|
|
227
|
+
"class": "optional-config",
|
|
228
|
+
"note": "Optional model route for workers."
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"name": "spawn.reviewerModel",
|
|
232
|
+
"class": "optional-config",
|
|
233
|
+
"note": "Optional independent model route for reviewers."
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"name": "spawn.maxLiveWorkers",
|
|
237
|
+
"class": "core",
|
|
238
|
+
"note": "Resource-safety cap on live workers."
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"name": "spawn.staleMs",
|
|
242
|
+
"class": "optional-config",
|
|
243
|
+
"note": "Roster staleness window before merge is blocked."
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"name": "spawn.maxWaitMs",
|
|
247
|
+
"class": "optional-config",
|
|
248
|
+
"note": "Upper bound for a blocking spawn wait."
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"name": "spawn.requireLease",
|
|
252
|
+
"class": "optional-config",
|
|
253
|
+
"note": "Advisory-lease gate before a worker starts."
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"name": "send.maxPerMinute",
|
|
257
|
+
"class": "optional-config",
|
|
258
|
+
"note": "Message rate limit; zero disables it."
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"name": "send.interruptDefault",
|
|
262
|
+
"class": "optional-config",
|
|
263
|
+
"note": "Default for the interrupt-before-deliver flag."
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"name": "tests.enabled",
|
|
267
|
+
"class": "optional-component",
|
|
268
|
+
"note": "Tuning inside the optional tower-tests component: it decides whether discovery runs when the row is on. Off with gates.requireTests:true is rejected at config resolution."
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"name": "tests.admission",
|
|
272
|
+
"class": "optional-config",
|
|
273
|
+
"note": "Chooses when discovery runs inside the optional tests capability."
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"name": "tests.timeoutMs",
|
|
277
|
+
"class": "optional-config",
|
|
278
|
+
"note": "Per-command discovery timeout."
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
"name": "tests.maxBytes",
|
|
282
|
+
"class": "optional-config",
|
|
283
|
+
"note": "Captured discovery output cap."
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
"name": "tests.autoChecks",
|
|
287
|
+
"class": "optional-config",
|
|
288
|
+
"note": "Attaches a tests check to completion when discovery runs."
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"name": "tests.allowPackageDiscovery",
|
|
292
|
+
"class": "core",
|
|
293
|
+
"note": "Allows repository code to execute; a security contract, not a component."
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"name": "gates.requireChecks",
|
|
297
|
+
"class": "forbidden-to-disable",
|
|
298
|
+
"note": "Requires completion evidence before merge; a merge gate."
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"name": "gates.requireTests",
|
|
302
|
+
"class": "forbidden-to-disable",
|
|
303
|
+
"note": "Requires a test receipt before merge; a merge gate. With the tower-tests row off, planning/mode entry refuse the combination."
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"name": "gates.allowReviewExemption",
|
|
307
|
+
"class": "forbidden-to-disable",
|
|
308
|
+
"note": "The review-exemption path is part of the review and merge contract."
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"name": "gates.maxReportBytes",
|
|
312
|
+
"class": "forbidden-to-disable",
|
|
313
|
+
"note": "The report cap is part of the completion contract; the refusal itself is never configurable away."
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
"name": "docs.board",
|
|
317
|
+
"class": "optional-config",
|
|
318
|
+
"note": "Writes the human-readable board; no capability is removed."
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"name": "docs.boardLocation",
|
|
322
|
+
"class": "optional-config",
|
|
323
|
+
"note": "Chooses the board location."
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"name": "archive.enabled",
|
|
327
|
+
"class": "optional-config",
|
|
328
|
+
"note": "Archival is maintenance, not a tool capability; off only leaves terminal missions hot."
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
"name": "archive.afterDays",
|
|
332
|
+
"class": "optional-config",
|
|
333
|
+
"note": "Archive age threshold."
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"name": "archive.maxMissions",
|
|
337
|
+
"class": "optional-config",
|
|
338
|
+
"note": "Hot terminal-mission cap."
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"name": "leases.ttlMs",
|
|
342
|
+
"class": "optional-config",
|
|
343
|
+
"note": "Tuning inside the optional tower-leases component."
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
"name": "dev.reloadDelayMs",
|
|
347
|
+
"class": "optional-config",
|
|
348
|
+
"note": "Delay so a tool result is delivered before a scheduled reload inside the optional tower-hmr component."
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
"name": "dev.reloadRequiredIds",
|
|
352
|
+
"class": "optional-config",
|
|
353
|
+
"note": "Loader ids passed to reconcileProfile as required by the tower-hmr component."
|
|
354
|
+
}
|
|
355
|
+
],
|
|
356
|
+
"forbidden": [
|
|
357
|
+
{
|
|
358
|
+
"id": "host",
|
|
359
|
+
"capability": "the Tower host",
|
|
360
|
+
"reason": "Without the host the plugin registers nothing; there is no off state that leaves the rest working, and no config boolean may remove it. The visible `tower` anchor is the all-off boundary, and the host section nested under it may never become its own switch.",
|
|
361
|
+
"tools": [],
|
|
362
|
+
"rowIds": ["tower-host"]
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"id": "health",
|
|
366
|
+
"capability": "tower_doctor (read-only health contract)",
|
|
367
|
+
"reason": "Diagnosis must always exist so a failure message can point at it. #122 removed exposeDoctor and the exposeOperational master switch; the tool is registered for every configuration.",
|
|
368
|
+
"tools": ["tower_doctor"],
|
|
369
|
+
"rowIds": ["tower-doctor"]
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
"id": "recovery",
|
|
373
|
+
"capability": "tower_recover (stale state-lock escape)",
|
|
374
|
+
"reason": "The escape hatch must exist whenever the lock it repairs can be left behind. #122 removed exposeRecovery and the exposeOperational master switch; the tool is registered for every configuration.",
|
|
375
|
+
"tools": ["tower_recover"],
|
|
376
|
+
"rowIds": ["tower-recover"]
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
"id": "review",
|
|
380
|
+
"capability": "the review contract",
|
|
381
|
+
"reason": "review.requireReview is a merge blocker, not a component; a row may not switch it off. The reviewer row is conditional, not a way to weaken the gate.",
|
|
382
|
+
"tools": ["tower_diff", "tower_preflight", "tower_review"],
|
|
383
|
+
"rowIds": ["tower-review"],
|
|
384
|
+
"gatedBy": ["review.requireReview", "review.maxRoundsPerReviewer"]
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
"id": "merge-gates",
|
|
388
|
+
"capability": "the merge gates",
|
|
389
|
+
"reason": "gates.* decides delivery; a component may not bypass it.",
|
|
390
|
+
"tools": ["tower_complete", "tower_merge"],
|
|
391
|
+
"rowIds": ["tower-gates", "tower-merge-gates"],
|
|
392
|
+
"gatedBy": ["gates.requireChecks", "gates.requireTests", "gates.allowReviewExemption", "gates.maxReportBytes"]
|
|
393
|
+
}
|
|
394
|
+
]
|
|
395
|
+
}
|