pantheon-opencode 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/README.md +53 -15
- package/bin/pantheon-init.mjs +14 -0
- package/opencode.json +17 -1
- package/package.json +13 -4
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +303 -13
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +42 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +201 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +419 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +97 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +50 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/opencode.mjs +58 -29
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install-mcp.mjs +104 -56
- package/scripts/mcp_persistence_server.py +1 -0
- package/scripts/prune.mjs +359 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/zeus.md +33 -5
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -3
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +1503 -0
- package/src/plugins/tui/dist/tui.d.ts +8 -0
- package/src/plugins/tui/dist/tui.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui.js +1198 -0
- package/src/plugins/tui/dist/tui.js.map +1 -0
- package/src/plugins/tui/dist/tui.tsx +1204 -0
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +16 -1
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* prune.mjs — Pantheon legacy cleanup CLI (issue #22)
|
|
4
|
+
*
|
|
5
|
+
* Finds and (with explicit flags) removes legacy artifacts left by previous
|
|
6
|
+
* Pantheon versions — old global installs, stale config backups, dead
|
|
7
|
+
* legacy config directories — following the XDG Base Directory spec.
|
|
8
|
+
*
|
|
9
|
+
* SAFETY: prune NEVER touches the active config, the active venv, the package
|
|
10
|
+
* repo, or the installed package. It only:
|
|
11
|
+
* - lists old global `pantheon` installs (suggests `npm uninstall -g pantheon`)
|
|
12
|
+
* - removes stale `opencode.json.bak*` backups (age > threshold) with --apply
|
|
13
|
+
* - lists legacy directories whose opencode.json references dead/relative
|
|
14
|
+
* paths (removal requires --apply --remove-dirs as explicit confirmation)
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node scripts/prune.mjs # dry-run (default): list only
|
|
18
|
+
* node scripts/prune.mjs --apply # remove stale backups
|
|
19
|
+
* node scripts/prune.mjs --apply --remove-dirs # also remove legacy dirs
|
|
20
|
+
* node scripts/prune.mjs --target <dir> # override config dir (tests)
|
|
21
|
+
* node scripts/prune.mjs --age 30 # backup age threshold (days)
|
|
22
|
+
* node scripts/prune.mjs --help
|
|
23
|
+
*
|
|
24
|
+
* Exit codes:
|
|
25
|
+
* 0 = nothing to clean (or everything cleaned)
|
|
26
|
+
* 1 = findings remain (dry-run listed items, or items kept)
|
|
27
|
+
* 2 = usage error
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execSync } from 'node:child_process'
|
|
31
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
32
|
+
import { homedir } from 'node:os'
|
|
33
|
+
import { dirname, join, resolve } from 'node:path'
|
|
34
|
+
import { fileURLToPath } from 'node:url'
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
37
|
+
const ROOT = join(__dirname, '..')
|
|
38
|
+
const DEFAULT_BACKUP_AGE_DAYS = 30
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// XDG config resolution — parity with _pantheon_paths.py and bin/pantheon-init
|
|
42
|
+
// PANTHEON_HOME → $XDG_CONFIG_HOME/opencode → ~/.config/opencode
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export function resolveConfigDir(target) {
|
|
46
|
+
if (target) return resolve(target)
|
|
47
|
+
if (process.env.PANTHEON_HOME) return resolve(process.env.PANTHEON_HOME)
|
|
48
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
49
|
+
return join(base, 'opencode')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Finding model
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {{ type: string, target: string, detail: string, action: 'suggest'|'remove'|'list'|'keep' }} Finding
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** @type {Finding[]} */
|
|
61
|
+
const findings = []
|
|
62
|
+
|
|
63
|
+
function add(type, target, detail, action) {
|
|
64
|
+
findings.push({ type, target, detail, action })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// (a) Old global `pantheon` install (private package, never published)
|
|
69
|
+
// → suggestion only: NEVER remove node_modules directly (npm-managed).
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export function findLegacyGlobalInstalls() {
|
|
73
|
+
const nodeModulesDirs = new Set()
|
|
74
|
+
const knownLegacy = join(homedir(), '.npm-global', 'lib', 'node_modules')
|
|
75
|
+
if (existsSync(knownLegacy)) nodeModulesDirs.add(knownLegacy)
|
|
76
|
+
try {
|
|
77
|
+
const npmPrefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim()
|
|
78
|
+
if (npmPrefix) nodeModulesDirs.add(join(npmPrefix, 'lib', 'node_modules'))
|
|
79
|
+
} catch {
|
|
80
|
+
// npm unavailable — fall back to the known legacy location only
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const dir of nodeModulesDirs) {
|
|
84
|
+
// `pantheon` (legacy name) — NOT `pantheon-opencode` (current package)
|
|
85
|
+
const pkgDir = join(dir, 'pantheon')
|
|
86
|
+
const pkgJsonPath = join(pkgDir, 'package.json')
|
|
87
|
+
if (!existsSync(pkgJsonPath)) continue
|
|
88
|
+
try {
|
|
89
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
90
|
+
if (pkg.name === 'pantheon' && pkg.private === true) {
|
|
91
|
+
add(
|
|
92
|
+
'global-install',
|
|
93
|
+
pkgDir,
|
|
94
|
+
`legacy private package pantheon@${pkg.version || '?'} — bin 'pantheon' conflicts with pantheon-opencode`,
|
|
95
|
+
'suggest',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// unreadable package.json — skip
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// (b) Stale opencode.json.bak* backups (age > threshold) in the config dir
|
|
106
|
+
// → removable with --apply. Fresh backups are listed as kept.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export function findStaleBackups(configDir, maxAgeDays = DEFAULT_BACKUP_AGE_DAYS) {
|
|
110
|
+
if (!existsSync(configDir)) return
|
|
111
|
+
const cutoffMs = Date.now() - maxAgeDays * 86_400_000
|
|
112
|
+
for (const name of readdirSync(configDir)) {
|
|
113
|
+
if (!name.startsWith('opencode.json.bak')) continue
|
|
114
|
+
const full = join(configDir, name)
|
|
115
|
+
let st
|
|
116
|
+
try {
|
|
117
|
+
st = statSync(full)
|
|
118
|
+
} catch {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
const ageDays = Math.floor((Date.now() - st.mtimeMs) / 86_400_000)
|
|
122
|
+
if (st.mtimeMs < cutoffMs) {
|
|
123
|
+
add('backup', full, `age ~${ageDays}d > ${maxAgeDays}d threshold`, 'remove')
|
|
124
|
+
} else {
|
|
125
|
+
add('backup-fresh', full, `age ~${ageDays}d ≤ ${maxAgeDays}d — kept`, 'keep')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// (c) Legacy directories with dead config (relative/nonexistent paths)
|
|
132
|
+
// → listed only; removal needs --apply --remove-dirs (explicit confirm).
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
const LEGACY_DIR_NAMES = ['pantheon-legacy', 'legacy', 'old', 'backup', 'backups']
|
|
136
|
+
|
|
137
|
+
/** Collect opencode.json candidates inside a dir (depth ≤ 2). */
|
|
138
|
+
function collectConfigCandidates(dir) {
|
|
139
|
+
const candidates = []
|
|
140
|
+
const walk = (d, depth) => {
|
|
141
|
+
if (depth > 2) return
|
|
142
|
+
let entries
|
|
143
|
+
try {
|
|
144
|
+
entries = readdirSync(d, { withFileTypes: true })
|
|
145
|
+
} catch {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const full = join(d, entry.name)
|
|
150
|
+
if (entry.isDirectory()) walk(full, depth + 1)
|
|
151
|
+
else if (entry.isFile() && entry.name === 'opencode.json') candidates.push(full)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
walk(dir, 0)
|
|
155
|
+
return candidates
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** True when an opencode.json references relative or non-existent paths. */
|
|
159
|
+
export function configHasDeadPaths(configPath) {
|
|
160
|
+
let cfg
|
|
161
|
+
try {
|
|
162
|
+
cfg = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
163
|
+
} catch {
|
|
164
|
+
return true // unparseable config in a legacy dir counts as dead
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const check = (value, where) => {
|
|
168
|
+
if (typeof value !== 'string' || value === '') return
|
|
169
|
+
if (value.startsWith('file://')) return
|
|
170
|
+
if (!value.startsWith('/')) return true // relative path in a config
|
|
171
|
+
if (!existsSync(value)) return true // absolute but missing
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// agent sources
|
|
176
|
+
for (const agent of Object.values(cfg.agent || {})) {
|
|
177
|
+
if (agent && typeof agent === 'object' && check(agent.source, 'agent.source')) return true
|
|
178
|
+
}
|
|
179
|
+
// instructions
|
|
180
|
+
for (const instr of cfg.instructions || []) {
|
|
181
|
+
if (check(instr, 'instructions')) return true
|
|
182
|
+
}
|
|
183
|
+
// mcp cwd + command[0] (when not a bare command name like python3)
|
|
184
|
+
for (const [key, mcp] of Object.entries(cfg.mcp || {})) {
|
|
185
|
+
if (!mcp || typeof mcp !== 'object') continue
|
|
186
|
+
if (mcp.cwd && check(mcp.cwd, `mcp.${key}.cwd`)) return true
|
|
187
|
+
const cmd = Array.isArray(mcp.command) ? mcp.command[0] : undefined
|
|
188
|
+
if (cmd && typeof cmd === 'string' && cmd.includes('/') && check(cmd, `mcp.${key}.command`)) {
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function findLegacyDirs(configDir) {
|
|
196
|
+
if (!existsSync(configDir)) return
|
|
197
|
+
for (const entry of readdirSync(configDir, { withFileTypes: true })) {
|
|
198
|
+
if (!entry.isDirectory() || !LEGACY_DIR_NAMES.includes(entry.name)) continue
|
|
199
|
+
const dir = join(configDir, entry.name)
|
|
200
|
+
const deadConfigs = collectConfigCandidates(dir).filter(configHasDeadPaths)
|
|
201
|
+
if (deadConfigs.length > 0) {
|
|
202
|
+
add(
|
|
203
|
+
'legacy-dir',
|
|
204
|
+
dir,
|
|
205
|
+
`dead config(s): ${deadConfigs.map((c) => c.replace(dir + '/', '')).join(', ')}`,
|
|
206
|
+
'list',
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Extra: legacy `~/.opencode` install dir (pre-XDG location)
|
|
214
|
+
// → listed only, manual review.
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
export function findLegacyInstallDir(configDir) {
|
|
218
|
+
const legacyDir = join(homedir(), '.opencode')
|
|
219
|
+
if (legacyDir === configDir) return
|
|
220
|
+
if (!existsSync(join(legacyDir, 'opencode.json'))) return
|
|
221
|
+
add(
|
|
222
|
+
'legacy-install',
|
|
223
|
+
legacyDir,
|
|
224
|
+
'pre-XDG install location — review manually; not removed automatically',
|
|
225
|
+
'list',
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Actions
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
function format(f) {
|
|
234
|
+
const icon =
|
|
235
|
+
f.action === 'remove' ? '🗑️' : f.action === 'suggest' ? '💡' : f.action === 'keep' ? '✅' : '🔎'
|
|
236
|
+
return `${icon} [${f.type}] ${f.target} — ${f.detail}`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function runPrune({ configDir, maxAgeDays, apply, removeDirs }) {
|
|
240
|
+
findLegacyGlobalInstalls()
|
|
241
|
+
findStaleBackups(configDir, maxAgeDays)
|
|
242
|
+
findLegacyDirs(configDir)
|
|
243
|
+
findLegacyInstallDir(configDir)
|
|
244
|
+
|
|
245
|
+
console.log(`Pantheon prune — config dir: ${configDir}`)
|
|
246
|
+
console.log(` mode: ${apply ? 'APPLY' : 'DRY-RUN'}${removeDirs ? ' + REMOVE-DIRS' : ''}`)
|
|
247
|
+
console.log('')
|
|
248
|
+
|
|
249
|
+
if (findings.length === 0) {
|
|
250
|
+
console.log(' ✅ Nothing to clean.')
|
|
251
|
+
return { removed: 0, remain: 0 }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let removed = 0
|
|
255
|
+
let remain = 0
|
|
256
|
+
for (const f of findings) {
|
|
257
|
+
console.log(` ${format(f)}`)
|
|
258
|
+
if (f.action === 'remove' && apply) {
|
|
259
|
+
try {
|
|
260
|
+
rmSync(f.target, { force: true })
|
|
261
|
+
console.log(` → removed`)
|
|
262
|
+
removed++
|
|
263
|
+
continue
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.log(` → ⚠️ failed: ${err.message}`)
|
|
266
|
+
remain++
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (f.action === 'remove' && !apply) {
|
|
271
|
+
// dry-run: count as "would remove"
|
|
272
|
+
removed++
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
if (f.action === 'list' && apply && removeDirs) {
|
|
276
|
+
try {
|
|
277
|
+
rmSync(f.target, { recursive: true, force: true })
|
|
278
|
+
console.log(` → removed (--remove-dirs)`)
|
|
279
|
+
removed++
|
|
280
|
+
continue
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.log(` → ⚠️ failed: ${err.message}`)
|
|
283
|
+
remain++
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (f.action === 'list' && apply && !removeDirs) {
|
|
288
|
+
console.log(' → kept: directory removal requires --apply --remove-dirs')
|
|
289
|
+
remain++
|
|
290
|
+
} else if (f.action === 'suggest') {
|
|
291
|
+
console.log(' → action: npm uninstall -g pantheon (not run automatically)')
|
|
292
|
+
remain++
|
|
293
|
+
} else if (f.action === 'keep' || f.action === 'list') {
|
|
294
|
+
remain++
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
console.log('')
|
|
299
|
+
const hint = apply ? '' : ' — run with --apply to remove stale backups'
|
|
300
|
+
console.log(
|
|
301
|
+
` ${apply ? 'Removed' : 'Would remove'}: ${removed} item(s); remaining: ${remain}${hint}`,
|
|
302
|
+
)
|
|
303
|
+
return { removed, remain }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// CLI
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
function printUsage() {
|
|
311
|
+
console.log(`Pantheon prune — legacy cleanup (issue #22)
|
|
312
|
+
|
|
313
|
+
Usage:
|
|
314
|
+
node scripts/prune.mjs [options]
|
|
315
|
+
|
|
316
|
+
Options:
|
|
317
|
+
--apply Remove stale backups (default: dry-run, list only)
|
|
318
|
+
--remove-dirs With --apply, also remove legacy directories
|
|
319
|
+
(explicit confirmation; default: list only)
|
|
320
|
+
--target <dir> Override the config directory (XDG-aware by default)
|
|
321
|
+
--age <days> Backup age threshold in days (default: ${DEFAULT_BACKUP_AGE_DAYS})
|
|
322
|
+
--help Show this help
|
|
323
|
+
|
|
324
|
+
Safety:
|
|
325
|
+
Never removes: active opencode.json, active .venv, package repo, or the
|
|
326
|
+
installed pantheon-opencode package. Old global 'pantheon' installs are
|
|
327
|
+
only suggested for manual 'npm uninstall -g pantheon'.
|
|
328
|
+
|
|
329
|
+
Exit codes: 0 = clean, 1 = findings remain, 2 = usage error`)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function main() {
|
|
333
|
+
const args = process.argv.slice(2)
|
|
334
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
335
|
+
printUsage()
|
|
336
|
+
process.exit(0)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const apply = args.includes('--apply') || args.includes('-y')
|
|
340
|
+
const removeDirs = args.includes('--remove-dirs')
|
|
341
|
+
const targetIdx = args.indexOf('--target')
|
|
342
|
+
const target = targetIdx >= 0 ? args[targetIdx + 1] : undefined
|
|
343
|
+
const ageIdx = args.indexOf('--age')
|
|
344
|
+
const age = ageIdx >= 0 ? Number(args[ageIdx + 1]) : DEFAULT_BACKUP_AGE_DAYS
|
|
345
|
+
if (!Number.isFinite(age) || age < 0) {
|
|
346
|
+
console.error('❌ --age must be a non-negative number of days')
|
|
347
|
+
process.exit(2)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const configDir = resolveConfigDir(target)
|
|
351
|
+
const result = runPrune({ configDir, maxAgeDays: age, apply, removeDirs })
|
|
352
|
+
|
|
353
|
+
// 0 = clean/cleaned; 1 = findings remain; 2 = usage error (handled above)
|
|
354
|
+
process.exit(result.remain > 0 ? 1 : 0)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
358
|
+
main()
|
|
359
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* version-check.mjs — Single source of truth enforcement
|
|
4
|
+
*
|
|
5
|
+
* package.json is the SOURCE OF TRUTH for the Pantheon version.
|
|
6
|
+
* Every other manifest (pyproject.toml, plugin.json, src/plugins/tui/package.json)
|
|
7
|
+
* MUST carry the same version. This script:
|
|
8
|
+
*
|
|
9
|
+
* - compares every manifest against package.json and prints a divergence
|
|
10
|
+
* table when they differ (exit code 1),
|
|
11
|
+
* - with --fix, rewrites each divergent manifest to the package.json version.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors the MANIFEST_FILES list in scripts/versioning.mjs so `apply`
|
|
14
|
+
* and `check` can never drift apart.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node scripts/version-check.mjs # verify (exit 0 = in sync)
|
|
18
|
+
* node scripts/version-check.mjs --fix # sync all manifests
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
24
|
+
|
|
25
|
+
/** Same set of manifests as versioning.mjs (minus package.json itself). */
|
|
26
|
+
export const MANIFESTS = [
|
|
27
|
+
{ name: 'pyproject.toml', file: 'pyproject.toml', kind: 'toml' },
|
|
28
|
+
{ name: 'plugin.json', file: 'plugin.json', kind: 'json' },
|
|
29
|
+
{ name: 'src/plugins/tui/package.json', file: join('src', 'plugins', 'tui', 'package.json'), kind: 'json' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
function readTomlVersion(filePath) {
|
|
33
|
+
const text = readFileSync(filePath, 'utf-8')
|
|
34
|
+
const match = text.match(/^version\s*=\s*"([^"]+)"/m)
|
|
35
|
+
if (!match) throw new Error(`no version field in ${filePath}`)
|
|
36
|
+
return match[1]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJsonVersion(filePath) {
|
|
40
|
+
return JSON.parse(readFileSync(filePath, 'utf-8')).version
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readManifestVersion(filePath, kind) {
|
|
44
|
+
try {
|
|
45
|
+
return kind === 'toml' ? readTomlVersion(filePath) : readJsonVersion(filePath)
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeManifestVersion(filePath, kind, version) {
|
|
52
|
+
if (kind === 'toml') {
|
|
53
|
+
const text = readFileSync(filePath, 'utf-8')
|
|
54
|
+
const updated = text.replace(/^(version\s*=\s*")[^"]+(")/m, `$1${version}$2`)
|
|
55
|
+
writeFileSync(filePath, updated)
|
|
56
|
+
} else {
|
|
57
|
+
const content = JSON.parse(readFileSync(filePath, 'utf-8'))
|
|
58
|
+
content.version = version
|
|
59
|
+
writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compare every manifest version against package.json (source of truth).
|
|
65
|
+
*
|
|
66
|
+
* @param {string} root - repository root containing package.json + manifests
|
|
67
|
+
* @returns {{source: string, manifests: Array<{name: string, version: string|null, ok: boolean}>, ok: boolean}}
|
|
68
|
+
* - source: version read from package.json
|
|
69
|
+
* - manifests: per-manifest {name, version (null when unreadable), ok}
|
|
70
|
+
* - ok: true when every manifest matches the source version
|
|
71
|
+
*/
|
|
72
|
+
export function compareVersions(root) {
|
|
73
|
+
const source = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version
|
|
74
|
+
const manifests = MANIFESTS.map(({ name, file, kind }) => {
|
|
75
|
+
const version = readManifestVersion(join(root, file), kind)
|
|
76
|
+
return { name, version, ok: version === source }
|
|
77
|
+
})
|
|
78
|
+
return { source, manifests, ok: manifests.every((m) => m.ok) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rewrite every divergent manifest to the package.json version.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} root - repository root
|
|
85
|
+
* @returns {number} count of manifests rewritten
|
|
86
|
+
*/
|
|
87
|
+
export function syncToSource(root) {
|
|
88
|
+
const { source, manifests } = compareVersions(root)
|
|
89
|
+
let changed = 0
|
|
90
|
+
for (const manifest of manifests) {
|
|
91
|
+
if (manifest.ok) continue
|
|
92
|
+
const def = MANIFESTS.find((m) => m.name === manifest.name)
|
|
93
|
+
writeManifestVersion(join(root, def.file), def.kind, source)
|
|
94
|
+
changed += 1
|
|
95
|
+
}
|
|
96
|
+
return changed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function printDivergenceTable(result) {
|
|
100
|
+
const rows = [
|
|
101
|
+
['manifest', 'version', 'status'],
|
|
102
|
+
...result.manifests.map((m) => [m.name, m.version ?? 'MISSING', m.ok ? 'ok' : 'DIVERGENT']),
|
|
103
|
+
]
|
|
104
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)))
|
|
105
|
+
console.log(`Source of truth: package.json → v${result.source}\n`)
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '))
|
|
108
|
+
}
|
|
109
|
+
console.log('')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isDirectRun =
|
|
113
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
114
|
+
|
|
115
|
+
if (isDirectRun) {
|
|
116
|
+
// npm scripts run from the package root, so the working directory is the
|
|
117
|
+
// repository root to check. Explicit cwd keeps the CLI testable.
|
|
118
|
+
const root = process.cwd()
|
|
119
|
+
const fix = process.argv.includes('--fix')
|
|
120
|
+
let result
|
|
121
|
+
try {
|
|
122
|
+
result = compareVersions(root)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error(`version-check: cannot read package.json — ${error.message}`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (result.ok) {
|
|
129
|
+
console.log(`✓ All manifests match package.json v${result.source}`)
|
|
130
|
+
process.exit(0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
printDivergenceTable(result)
|
|
134
|
+
if (fix) {
|
|
135
|
+
const changed = syncToSource(root)
|
|
136
|
+
const after = compareVersions(root)
|
|
137
|
+
if (after.ok) {
|
|
138
|
+
console.log(`✓ Synced ${changed} manifest(s) to v${after.source}`)
|
|
139
|
+
process.exit(0)
|
|
140
|
+
}
|
|
141
|
+
console.error(`version-check: sync incomplete — ${after.manifests.filter((m) => !m.ok).length} manifest(s) still divergent`)
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
console.error('version-check: manifests out of sync (run with --fix to sync)')
|
|
145
|
+
process.exit(1)
|
|
146
|
+
}
|
package/scripts/versioning.mjs
CHANGED
|
@@ -29,7 +29,7 @@ const MANIFEST_FILES = [
|
|
|
29
29
|
'pyproject.toml',
|
|
30
30
|
'package.json',
|
|
31
31
|
'plugin.json',
|
|
32
|
-
'
|
|
32
|
+
'src/plugins/tui/package.json',
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
const CHANGELOG_PATH = join(ROOT, 'CHANGELOG.md')
|
|
@@ -47,8 +47,11 @@ function run(cmd) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function getLatestTag() {
|
|
50
|
-
//
|
|
51
|
-
|
|
50
|
+
// Stable tags only: strict vX.Y.Z with NO pre-release suffix.
|
|
51
|
+
// The loose glob `v[0-9]*.[0-9]*.[0-9]*` ALSO matches v1.2.0-beta.9.*
|
|
52
|
+
// (the trailing `*` swallows the -beta suffix), which would poison the
|
|
53
|
+
// version gate — so filter strictly before sorting.
|
|
54
|
+
const tag = run("git tag -l 'v*' | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$' | sort -V | tail -1")
|
|
52
55
|
return tag || 'v0.0.0'
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -207,13 +210,15 @@ switch (command) {
|
|
|
207
210
|
const latestVer = latestTag.replace(/^v/, '')
|
|
208
211
|
|
|
209
212
|
// If package.json is already ahead of the latest tag, someone bumped
|
|
210
|
-
// without tagging — warn and use the current version as-is.
|
|
213
|
+
// without tagging — warn and use the current version as-is. Tags are
|
|
214
|
+
// workflow-owned, so there is nothing to create here.
|
|
211
215
|
if (current !== latestVer) {
|
|
212
216
|
console.log(`⚠ package.json (${current}) already ahead of latest tag (${latestTag}).`)
|
|
213
217
|
console.log(` Syncing all manifests to ${current} and promoting CHANGELOG.`)
|
|
214
218
|
updateManifests(current)
|
|
215
219
|
const date = new Date().toISOString().slice(0, 10)
|
|
216
220
|
promoteUnreleased(current, date)
|
|
221
|
+
console.log(`Tag v${current} will be created by the release workflow after merge to main.`)
|
|
217
222
|
break
|
|
218
223
|
}
|
|
219
224
|
|
|
@@ -226,16 +231,10 @@ switch (command) {
|
|
|
226
231
|
promoteUnreleased(newVersion, date)
|
|
227
232
|
console.log(`\nDone. Commit with: git add -A && git commit -m "chore(release): v${newVersion}"`)
|
|
228
233
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
console.log(`Tag ${tag} created locally. Push: git push origin ${tag}`)
|
|
234
|
-
console.log(`Auto-release will trigger on main after merge.`)
|
|
235
|
-
} catch (e) {
|
|
236
|
-
console.warn(`Tag creation failed: ${e.message}`)
|
|
237
|
-
console.log('Create manually: git tag -a v' + newVersion + ' -m "chore(release): v' + newVersion + '"')
|
|
238
|
-
}
|
|
234
|
+
// Tags are owned by the release workflow — never create them locally.
|
|
235
|
+
// A local tag on a pre-merge commit drifts from the merged main state
|
|
236
|
+
// (the untagged-v1.2.1 root cause) and would desync the version gate.
|
|
237
|
+
console.log(`Tag v${newVersion} will be created by the release workflow after merge to main.`)
|
|
239
238
|
break
|
|
240
239
|
}
|
|
241
240
|
|
package/src/agents/zeus.md
CHANGED
|
@@ -95,6 +95,13 @@ kv_store("delegation:<pattern>", "{agent: ..., background: true/false}")
|
|
|
95
95
|
kv_get("delegation:<pattern>") → reusa decisao sem memory_search
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
### Telemetria de delegacao (Nyx P1-3)
|
|
99
|
+
|
|
100
|
+
Toda decisao de delegacao grava UM registro `DelegationCacheDecision` no
|
|
101
|
+
namespace `delegation-telemetry` via kv_store: {hit|miss|writeback|corrected,
|
|
102
|
+
pattern, agent, source: cache|routing|user}. Em re-roteamento (recusa P0-3),
|
|
103
|
+
adicione {reroute_from, reroute_to, delegation_id, source}. Uma linha por delegacao.
|
|
104
|
+
|
|
98
105
|
## REGRA DE OURO: NUNCA USE general
|
|
99
106
|
|
|
100
107
|
**`subagent_type: general` e `subagent_type: explore` sao PROIBIDOS.** Nao existem no Pantheon.
|
|
@@ -113,13 +120,16 @@ Tarefa envolve:
|
|
|
113
120
|
AI, RAG, LangChain, embeddings, vetores -> @hephaestus
|
|
114
121
|
observabilidade, tracing, monitoramento -> @nyx
|
|
115
122
|
GitHub, PR, issues, releases, branches -> @iris
|
|
116
|
-
documentacao
|
|
123
|
+
documentacao de PROJETO (README, docs/, changelog) -> @talos (trivial) | implementador (tecnica) | @iris (changelog/release)
|
|
124
|
+
documentacao de SISTEMA (.pantheon/memory-bank/, ADRs, task records) -> @mnemosyne
|
|
117
125
|
hotfix rapido, bug pequeno, typo, CSS -> @talos
|
|
118
126
|
|
|
119
127
|
NENHUMA das acima? -> E descoberta? @apollo. E planejamento? @athena.
|
|
120
128
|
Ainda assim sem match? -> Pergunte ao usuario qual agente usar. NUNCA use general.
|
|
121
129
|
```
|
|
122
130
|
|
|
131
|
+
REGRA: "fora de .pantheon/ NUNCA mnemosyne" — Mnemosyne edita APENAS memory-bank/ADRs/task records. Docs de projeto (README, docs/) vão para talos/implementador/iris.
|
|
132
|
+
|
|
123
133
|
## Background Delegation (PADRAO: background=true)
|
|
124
134
|
|
|
125
135
|
**Requer:** `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` (env var)
|
|
@@ -199,6 +209,15 @@ Zeus (nivel 0) -> Apollo/Hermes (nivel 1) -> sub-subagente (nivel 2 max).
|
|
|
199
209
|
- Env var: `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`
|
|
200
210
|
- Guards: `instructions/zeus-timeout-retry.instructions.md`
|
|
201
211
|
|
|
212
|
+
## Taxonomia de Recusa (P0-3)
|
|
213
|
+
|
|
214
|
+
Quando um subagente RECUSA ou retorna "escopo fora do meu dominio" (ex: edit: deny, scope boundary):
|
|
215
|
+
|
|
216
|
+
- **Causa provavel:** agente errado selecionado no roteamento (match por palavra-chave, nao por capacidade).
|
|
217
|
+
- **ACAO:** RE-ROTEAR imediatamente para o agente correto — NAO retry com prompt reformulado.
|
|
218
|
+
- Registre o caso no cache de delegacao (`kv_store("deleg:<pattern>", ...)` para aprendizado futuro).
|
|
219
|
+
- Recusa legitima do agente = comportamento correto do guard; o defeito esta na selecao (zeus), nao no agente.
|
|
220
|
+
|
|
202
221
|
## TODO Enforcer (Auto-Retry)
|
|
203
222
|
|
|
204
223
|
**Se um agente delegado falhar ou travar, recupere automaticamente.**
|
|
@@ -207,13 +226,22 @@ Zeus (nivel 0) -> Apollo/Hermes (nivel 1) -> sub-subagente (nivel 2 max).
|
|
|
207
226
|
```
|
|
208
227
|
Apos task_status(wait=true), verifique:
|
|
209
228
|
result.state == "error" ou timeout?
|
|
210
|
-
SIM → retry
|
|
211
|
-
|
|
229
|
+
SIM → ANTES de retry: detecte a CAUSA na mensagem de erro
|
|
230
|
+
|
|
231
|
+
RECUSA / scope-boundary (result contem "edit: deny", "escopo fora do meu dominio",
|
|
232
|
+
"reviewer-only", "fora do meu dominio", ou similar negativa de dominio)?
|
|
233
|
+
SIM → NAO retry com prompt rephrased.
|
|
234
|
+
RE-ROTEAR para o agente correto (ver "Taxonomia de Recusa (P0-3)").
|
|
235
|
+
Registre o caso: kv_store("deleg:<pattern>", "{agente_correto, ...}").
|
|
236
|
+
|
|
237
|
+
FALHA REAL (timeout, crash, resposta vazia, context exceeded)?
|
|
238
|
+
SIM → retry 1x com prompt rephrased (diferente, mais especifico)
|
|
239
|
+
Ainda erro → escalate: "Agente X falhou 2x. Opcoes: (a) tentar outro, (b) simplificar, (c) pular"
|
|
212
240
|
```
|
|
213
241
|
|
|
214
242
|
### Regras
|
|
215
|
-
- **1 retry automatico** —
|
|
216
|
-
- **Rephrase o prompt** —
|
|
243
|
+
- **1 retry automatico APENAS para falha real** — recusa/scope-boundary NUNCA gera retry; gera re-roteamento
|
|
244
|
+
- **Rephrase o prompt** — so em falha real (timeout/crash/vazio), nao em recusa de dominio
|
|
217
245
|
- **Timeout** — sempre `timeout_ms=120000` em task_status(). Pesquisa leva tempo
|
|
218
246
|
- **Stall** — 3+ turns sem progresso util? Troque de agente ou abordagem
|
|
219
247
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
pyyaml
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
# Pantheon MCP runtime dependencies — EXACT pins (issue #21)
|
|
2
|
+
# Pinned to the versions validated in the sandbox venv (2026-08-05).
|
|
3
|
+
# Update deliberately via: pip install -U <pkg> && pip freeze | grep -iE '^(mcp|fastmcp|pyyaml|sqlite-vec|fastembed)'
|
|
4
|
+
mcp==1.29.0
|
|
5
|
+
fastmcp==3.4.6
|
|
6
|
+
pyyaml==6.0.3
|
|
7
|
+
sqlite-vec==0.1.9
|
|
8
|
+
fastembed==0.8.0
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
mcp
|
|
2
|
-
fastmcp
|
|
3
|
-
httpx
|
|
1
|
+
mcp==1.29.0
|
|
2
|
+
fastmcp==3.4.6
|
|
3
|
+
httpx==0.28.1
|