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.
Files changed (37) hide show
  1. package/AGENTS.md +10 -0
  2. package/README.md +53 -15
  3. package/bin/pantheon-init.mjs +14 -0
  4. package/opencode.json +17 -1
  5. package/package.json +13 -4
  6. package/scripts/changelog-extract.mjs +112 -0
  7. package/scripts/doctor.mjs +303 -13
  8. package/scripts/hooks/audit-imports.sh +43 -0
  9. package/scripts/hooks/format-multi-language.sh +96 -0
  10. package/scripts/hooks/log-session-start.sh +42 -0
  11. package/scripts/hooks/on-subagent-delegation-start.sh +201 -0
  12. package/scripts/hooks/on-subagent-delegation-stop.sh +419 -0
  13. package/scripts/hooks/run-type-check.sh +21 -0
  14. package/scripts/hooks/scan-secrets.sh +97 -0
  15. package/scripts/hooks/validate-post-conditions.sh +21 -0
  16. package/scripts/hooks/validate-talos-scope.sh +50 -0
  17. package/scripts/hooks/validate-tool-safety.sh +46 -0
  18. package/scripts/install/opencode.mjs +58 -29
  19. package/scripts/install/plugin.mjs +4 -2
  20. package/scripts/install-mcp.mjs +104 -56
  21. package/scripts/mcp_persistence_server.py +1 -0
  22. package/scripts/prune.mjs +359 -0
  23. package/scripts/version-check.mjs +146 -0
  24. package/scripts/versioning.mjs +13 -14
  25. package/src/agents/zeus.md +33 -5
  26. package/src/mcp/requirements-mcp.txt +8 -5
  27. package/src/mcp/requirements-vision.txt +3 -3
  28. package/src/plugins/hook-runner.ts +186 -0
  29. package/src/plugins/pantheon-hooks.ts +1503 -0
  30. package/src/plugins/tui/dist/tui.d.ts +8 -0
  31. package/src/plugins/tui/dist/tui.d.ts.map +1 -0
  32. package/src/plugins/tui/dist/tui.js +1198 -0
  33. package/src/plugins/tui/dist/tui.js.map +1 -0
  34. package/src/plugins/tui/dist/tui.tsx +1204 -0
  35. package/src/plugins/tui/package.json +2 -2
  36. package/src/plugins/tui/src/index.tsx +16 -1
  37. 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
+ }
@@ -29,7 +29,7 @@ const MANIFEST_FILES = [
29
29
  'pyproject.toml',
30
30
  'package.json',
31
31
  'plugin.json',
32
- '.github/plugin/plugin.json',
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
- // Use numerically highest tag, not just nearest git ancestor
51
- const tag = run("git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -1")
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
- // Create git tag
230
- try {
231
- const tag = `v${newVersion}`
232
- execSync(`git tag -a ${tag} -m "chore(release): ${tag}"`, { stdio: 'inherit' })
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
 
@@ -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, memory-bank, ADRs -> @mnemosyne
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 1x com prompt rephrased (diferente, mais especifico)
211
- Ainda erro → escalate: "Agente X falhou 2x. Opcoes: (a) tentar outro, (b) simplificar, (c) pular"
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** — 2a falha = escalate, nao loop infinito
216
- - **Rephrase o prompt** — nao mande o mesmo texto. Mais especifico, menos escopo
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
- mcp>=1.28.0
2
- fastmcp>=3.4.0
3
- pyyaml>=6.0
4
- sqlite-vec>=0.1.7
5
- fastembed>=0.5.0
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>=1.28.0
2
- fastmcp>=3.4.0
3
- httpx>=0.27.0
1
+ mcp==1.29.0
2
+ fastmcp==3.4.6
3
+ httpx==0.28.1