pantheon-opencode 1.6.0-beta.6 → 1.6.0-beta.7

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 (58) hide show
  1. package/AGENTS.md +27 -820
  2. package/CHANGELOG.md +116 -0
  3. package/README.md +132 -39
  4. package/README.pt-BR.md +47 -23
  5. package/package.json +6 -1
  6. package/plugin.json +1 -1
  7. package/pyproject.toml +3 -1
  8. package/scripts/build-agents-md.mjs +162 -28
  9. package/scripts/doctor.mjs +104 -22
  10. package/scripts/install/agents-md.mjs +2 -2
  11. package/scripts/install/config-migration.mjs +154 -7
  12. package/scripts/install/opencode.mjs +202 -40
  13. package/scripts/install/shared.mjs +115 -7
  14. package/scripts/install/strings.mjs +8 -0
  15. package/scripts/probe-pantheon-cost.mjs +16 -3
  16. package/scripts/sync-artifacts.mjs +2 -2
  17. package/scripts/test-opencode-v2-sandbox.sh +820 -86
  18. package/skills-lock.json +6 -5
  19. package/src/agents/aphrodite.md +17 -28
  20. package/src/agents/demeter.md +8 -18
  21. package/src/agents/hephaestus.md +10 -11
  22. package/src/agents/hermes.md +15 -30
  23. package/src/agents/prometheus.md +16 -10
  24. package/src/agents/talos.md +4 -1
  25. package/src/agents/themis.md +4 -3
  26. package/src/agents/zeus.md +16 -280
  27. package/src/instructions/agent-return-format.instructions.md +4 -50
  28. package/src/instructions/backend-standards.instructions.md +2 -0
  29. package/src/instructions/frontend-standards.instructions.md +3 -1
  30. package/src/instructions/memory-protocol.instructions.md +12 -121
  31. package/src/instructions/zeus-anti-stall.instructions.md +8 -111
  32. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  33. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  34. package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
  35. package/src/pantheon/secret-scanner.ts +489 -0
  36. package/src/pantheon/v2-delegation-enforce.ts +86 -0
  37. package/src/pantheon/v2-execute-after.ts +186 -0
  38. package/src/pantheon/v2-unsupported.mjs +0 -23
  39. package/src/plugin-v2.ts +254 -102
  40. package/src/plugins/pantheon-hooks.ts +82 -35
  41. package/src/plugins/tui/README.md +14 -0
  42. package/src/plugins/tui/dist/tui.d.ts +11 -8
  43. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  44. package/src/plugins/tui/dist/tui.js +102 -62
  45. package/src/plugins/tui/package-lock.json +30 -33
  46. package/src/plugins/tui/package.json +5 -1
  47. package/src/plugins/tui/src/index.tsx +163 -107
  48. package/src/plugins/tui/tsdown.config.ts +17 -0
  49. package/src/routing.yml +12 -9
  50. package/src/skills/README.md +9 -4
  51. package/src/skills/agent-coordination/SKILL.md +13 -83
  52. package/src/skills/artifact-management/SKILL.md +12 -5
  53. package/src/skills/auto-continue/SKILL.md +37 -27
  54. package/src/skills/code-review-checklist/SKILL.md +5 -5
  55. package/src/skills/council-synthesis/SKILL.md +29 -0
  56. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  57. package/scripts/hooks/scan-secrets.sh +0 -97
  58. package/src/instructions/zeus-council-synthesis.instructions.md +0 -299
@@ -8,11 +8,11 @@
8
8
  * across both versions without duplication, this script consolidates:
9
9
  *
10
10
  * (a) a static header (project description + agent table + setup notes)
11
- * (b) every src/instructions/*.instructions.md body, embedded under a
12
- * `## <name>` section marker (name taken from frontmatter)
11
+ * (b) shared instruction bodies embedded under a `## <name>` section marker;
12
+ * agent-scoped bodies are rendered into only their matching agent prompt
13
13
  *
14
- * into one generated AGENTS.md. src/instructions/*.instructions.md remains
15
- * the source of truth — AGENTS.md is a committed build artifact, never
14
+ * into AGENTS.md plus selective agent prompt content. The instruction files
15
+ * remain the source of truth; AGENTS.md is a committed build artifact, never
16
16
  * hand-edited.
17
17
  *
18
18
  * Usage:
@@ -22,8 +22,8 @@
22
22
  * No runtime dependencies (Node >= 18, stdlib only). Idempotent: running it
23
23
  * twice produces byte-identical output.
24
24
  */
25
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
26
- import { dirname, join } from 'node:path'
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
26
+ import { dirname, join, resolve } from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
28
 
29
29
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -44,19 +44,19 @@ This project uses the Pantheon multi-agent framework with 14 specialized agents.
44
44
 
45
45
  | Agent | Role |
46
46
  |-------|------|
47
- | @aphrodite | Frontend specialist — React 19, TypeScript strict, WCAG accessibility, responsive design, TDD, modern API patterns, deprecated npm detection. Calls apollo for discovery, sends to themis for review. |
47
+ | @aphrodite | Frontend specialist — React 19, TypeScript strict, WCAG accessibility, responsive design, TDD, modern API patterns, deprecated npm detection. Uses discovery/review proportionally to scope and risk. |
48
48
  | @apollo | Read-only investigation scout — 3–10 parallel searches across codebase, external docs, and GitHub. Called by: athena, zeus, hermes, aphrodite, demeter. No edits, no commands. |
49
49
  | @athena | Strategic planner & architect — research-first, plan-only, never implements. Plans include quality gates (ruff/Biome, dep detection, LTS policy). Calls apollo for discovery. |
50
- | @demeter | Database specialist — SQLAlchemy 2.0, Alembic, query optimization, N+1 prevention, TDD migrations, modern DB libs. Calls apollo for discovery, sends to themis. |
50
+ | @demeter | Database specialist — SQLAlchemy 2.0, Alembic, query optimization, N+1 prevention, TDD migrations, modern DB libs. Preserves review gates for schema/data changes. |
51
51
  | @gaia | Remote sensing domain specialist — satellite image processing, spectral analysis, SAR, change detection, time series, ML/DL classification. Read-only analysis of geospatial data. |
52
52
  | @hephaestus | AI tooling & pipelines specialist — LangChain/LangGraph chains, RAG architecture, vector stores, embedding strategies. Forges AI infrastructure. Calls apollo, sends to themis. |
53
- | @hermes | Backend specialist — FastAPI, Python, async, TDD (RED→GREEN→REFACTOR), modern Python stdlib, obsolete lib detection. Calls apollo for discovery, sends to themis. |
53
+ | @hermes | Backend specialist — FastAPI, Python, async, TDD (RED→GREEN→REFACTOR), modern Python stdlib, obsolete lib detection. Uses discovery/review proportionally to scope and risk. |
54
54
  | @iris | GitHub operations specialist — branches, pull requests, issues, releases, tags. Called by zeus after review. Never pushes or merges without explicit human approval. Integrates with VS Code GitHub Pull Requests extension. |
55
55
  | @mnemosyne | Memory bank quality owner — initializes .pantheon/memory-bank/, writes ADRs and task records on explicit request. Called by zeus. Never invoked automatically after phases. |
56
56
  | @nyx | Observability & monitoring specialist — OpenTelemetry tracing, token/cost tracking, agent performance analytics, LangSmith integration. Calls apollo for discovery, sends to themis. |
57
57
  | @prometheus | Infrastructure + model provider specialist — Docker, CI/CD, multi-model routing, cost optimization, provider abstraction |
58
58
  | @talos | Hotfix express lane — direct fixes for small bugs, CSS, typos, minor logic. No TDD ceremony, no orchestration overhead. Standalone, no subagents. Escalates complex issues to zeus. |
59
- | @themis | Quality & security gate — ruff/Biome linting, dead/legacy code detection, OWASP Top 10, coverage >80%, correctness, deprecation audit. Called by implementers; escalates blockers to zeus. |
59
+ | @themis | Quality & security gate — ruff/Biome linting, dead/legacy code detection, OWASP Top 10, applicable coverage requirements, correctness, deprecation audit. Reviews material/sensitive changes. |
60
60
  | @zeus | Central orchestrator — never implements. Delegates to: athena, apollo, hermes, aphrodite, demeter, prometheus, themis, iris, mnemosyne, talos, hephaestus, nyx |
61
61
 
62
62
  ## OpenCode Setup
@@ -73,13 +73,14 @@ Para validar a instalação global do pacote pantheon-opencode COMO UM USUÁRIO
73
73
 
74
74
  - Rodar: \`bash ~/pantheon-sandbox/run-test.sh\` (opencode mcp list 5/5 connected + doctor 0 erros + abre TUI isolado)
75
75
  - Regra para agentes: ao validar instalação global (npm pack, \`init\`, MCPs, hooks), usar o sandbox — NUNCA testar no ambiente de dev
76
- - Descarte: \`rm -rf ~/pantheon-sandbox\`
77
- - Detalhes: ver \`~/pantheon-sandbox/README.md\`
76
+ - Prepare/atualize com \`bash scripts/test-opencode-v2-sandbox.sh --prepare\`; isso gera \`run-test.sh\`, \`start-pantheon.sh\` e o README do sandbox
77
+ - Descarte somente pelo reset com guardas: \`bash scripts/test-opencode-v2-sandbox.sh --reset\` (nunca use \`rm -rf\` manualmente)
78
+ - Detalhes: ver \`~/pantheon-sandbox/README.md\` após o prepare
78
79
 
79
80
  ## Conventions
80
81
 
81
- - TDD: Write failing test first, then implement
82
- - Coverage minimum: 80%
82
+ - TDD: RED→GREEN→REFACTOR for testable behavior; use focused checks for micro-edits
83
+ - Honor repository coverage thresholds when relevant; preserve stronger auth/security/data-integrity gates
83
84
  - Async/await on all I/O
84
85
  - Type hints on all functions
85
86
  - PRs always update the README — every pull request must document newly added features, behaviors, env vars, or commands in the README before being opened.
@@ -92,20 +93,41 @@ Para validar a instalação global do pacote pantheon-opencode COMO UM USUÁRIO
92
93
  /**
93
94
  * Parse YAML-ish frontmatter from an instruction file.
94
95
  *
95
- * Only the `name` field is extracted (single-line scalar). The frontmatter
96
- * block itself is stripped from the embedded body.
96
+ * Preserve the file-scope `applyTo` glob separately from the explicit
97
+ * `agents` selectors. In particular, a Python workspace glob is never
98
+ * interpreted as an agent name. The frontmatter block is stripped from
99
+ * the embedded body.
97
100
  *
98
101
  * @param {string} content - Raw file content
99
- * @returns {{ name: string, body: string } | null}
102
+ * @returns {{ name: string, body: string, applyTo: string | null,
103
+ * agents: string[], fallback: string | null, appliesToAgent: (name: string) => boolean } | null}
100
104
  */
101
- function parseInstruction(content) {
105
+ export function parseInstruction(content) {
102
106
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
103
107
  if (!match) return null
104
108
  const fm = match[1]
105
109
  const nameMatch = fm.match(/^name:\s*["']?([^"'\r\n]+)["']?\s*$/m)
106
110
  if (!nameMatch) return null
111
+ const applyToMatch = fm.match(/^applyTo:\s*["']?([^"'\r\n]+)["']?\s*$/m)
112
+ const agentsMatch = fm.match(/^agents:\s*\[([^\]\r\n]*)\]\s*$/m)
113
+ const agents = agentsMatch
114
+ ? agentsMatch[1]
115
+ .split(',')
116
+ .map((agent) => agent.trim().replace(/^(["'])(.*)\1$/, '$2'))
117
+ .filter(Boolean)
118
+ : fm.match(/^agents:/m)
119
+ ? [] // Malformed explicit selector: fail closed; never make it global.
120
+ : applyToMatch
121
+ ? [] // File-scoped instructions need an explicit agent association.
122
+ : ['*'] // Legacy instructions without selectors remain shared.
123
+ const fallbackMatch = fm.match(/^fallback:\s*["']?([^"'\r\n]+)["']?\s*$/m)
124
+ const appliesToAgent = (agent) => agents.includes('*') || agents.includes(agent)
107
125
  return {
108
126
  name: nameMatch[1].trim(),
127
+ applyTo: applyToMatch?.[1].trim() ?? null,
128
+ agents,
129
+ fallback: fallbackMatch?.[1].trim() ?? null,
130
+ appliesToAgent,
109
131
  body: `${content.slice(match[0].length).trimEnd()}\n`,
110
132
  }
111
133
  }
@@ -114,7 +136,8 @@ function parseInstruction(content) {
114
136
  * Read all src/instructions/*.instructions.md files, sorted by filename for
115
137
  * deterministic output.
116
138
  *
117
- * @returns {Array<{ file: string, name: string, body: string }>}
139
+ * @returns {Array<{ file: string, name: string, body: string, applyTo: string | null,
140
+ * agents: string[], fallback: string | null, appliesToAgent: (name: string) => boolean }>}
118
141
  */
119
142
  export function readInstructions() {
120
143
  if (!existsSync(INSTRUCTIONS_DIR)) return []
@@ -137,10 +160,13 @@ export function readInstructions() {
137
160
  /**
138
161
  * Generate the full AGENTS.md content.
139
162
  *
140
- * @param {Array<{ file: string, name: string, body: string }>} instructions
163
+ * @param {Array<{ file: string, name: string, body: string, agents?: string[] }>} instructions
141
164
  * @returns {string}
142
165
  */
143
166
  export function generateAgentsMd(instructions) {
167
+ const sharedInstructions = instructions.filter(
168
+ (instr) => !instr.agents || instr.agents.includes('*'),
169
+ )
144
170
  const sections = [
145
171
  '<!-- Generated by scripts/build-agents-md.mjs — do not edit directly. -->',
146
172
  '<!-- Source of truth: src/instructions/*.instructions.md (AGENTS.md is a committed build artifact). -->',
@@ -153,10 +179,11 @@ export function generateAgentsMd(instructions) {
153
179
  '',
154
180
  'The following sections are consolidated from `src/instructions/*.instructions.md`',
155
181
  'so both OpenCode V1 (auto-loads AGENTS.md) and V2 (loads AGENTS.md, ignores the',
156
- '`instructions` config key) receive identical instruction content.',
182
+ '`instructions` config key) receive identical shared rules. Agent-scoped rules',
183
+ 'are rendered into only the matching installed agent prompts.',
157
184
  '',
158
185
  ]
159
- for (const instr of instructions) {
186
+ for (const instr of sharedInstructions) {
160
187
  sections.push(
161
188
  `<!-- Source: src/instructions/${instr.file} -->`,
162
189
  `## ${instr.name}`,
@@ -168,6 +195,110 @@ export function generateAgentsMd(instructions) {
168
195
  return `${sections.join('\n').trimEnd()}\n`
169
196
  }
170
197
 
198
+ const GENERATED_AGENT_BLOCK =
199
+ /\n?<!-- Pantheon selective instructions: generated -->[\s\S]*?<!-- \/Pantheon selective instructions -->\n?/g
200
+
201
+ /**
202
+ * Render the agent-specific instruction set into the supported agent prompt.
203
+ * The shared rules remain in AGENTS.md; file globs are retained as scope
204
+ * guidance and are never used as agent selectors.
205
+ *
206
+ * @param {string} agentName - Canonical agent name from the prompt filename
207
+ * @param {string} prompt - Canonical source agent prompt
208
+ * @param {Array<{ file: string, name: string, body: string, applyTo?: string | null,
209
+ * agents?: string[], fallback?: string | null }>} instructions
210
+ * @returns {string}
211
+ */
212
+ export function generateAgentPrompt(agentName, prompt, instructions = readInstructions()) {
213
+ const basePrompt = prompt.replace(GENERATED_AGENT_BLOCK, '').trimEnd()
214
+ const selected = instructions.filter(
215
+ (instr) => instr.agents && !instr.agents.includes('*') && instr.agents.includes(agentName),
216
+ )
217
+ if (selected.length === 0) {
218
+ return [
219
+ basePrompt,
220
+ '',
221
+ '<!-- Pantheon selective instructions: generated -->',
222
+ 'No agent-specific instructions are assigned; follow the shared rules in AGENTS.md.',
223
+ '<!-- /Pantheon selective instructions -->',
224
+ '',
225
+ ].join('\n')
226
+ }
227
+
228
+ const sections = selected.map((instr) => {
229
+ const scope = instr.applyTo
230
+ ? `\n\n> Apply only when a target workspace file matches \`${instr.applyTo}\`.`
231
+ : ''
232
+ const fallback =
233
+ instr.fallback === 'shared'
234
+ ? '\n\n> For work outside this scope, use the shared rules in AGENTS.md.'
235
+ : ''
236
+ return [
237
+ `<!-- Source: src/instructions/${instr.file} -->`,
238
+ `## ${instr.name}${scope}`,
239
+ '',
240
+ `${instr.body.trimEnd()}${fallback}`,
241
+ ].join('\n')
242
+ })
243
+ return [
244
+ basePrompt,
245
+ '',
246
+ '<!-- Pantheon selective instructions: generated -->',
247
+ sections.join('\n\n'),
248
+ '<!-- /Pantheon selective instructions -->',
249
+ '',
250
+ ].join('\n')
251
+ }
252
+
253
+ /**
254
+ * Copy agent prompts with scoped instructions embedded in each actual prompt.
255
+ * Used by both init and postinstall sync so the installed runtime receives the
256
+ * same selection without a per-turn injection hook.
257
+ *
258
+ * @param {string} sourceDir - Canonical src/agents directory
259
+ * @param {string} targetDir - OpenCode agents directory
260
+ * @param {boolean} dryRun - Do not write when true
261
+ * @param {boolean} existingOnly - Refresh installed prompts without adding missing agents
262
+ * @returns {{ created: number, skipped: number }}
263
+ */
264
+ export function copyAgentPrompts(sourceDir, targetDir, dryRun = false, existingOnly = false) {
265
+ const instructions = readInstructions()
266
+ if (!dryRun && !existingOnly) mkdirSync(targetDir, { recursive: true })
267
+ let created = 0
268
+ let skipped = 0
269
+ for (const file of readdirSync(sourceDir)
270
+ .filter((entry) => entry.endsWith('.md'))
271
+ .sort()) {
272
+ if (file.toLowerCase() === 'readme.md') continue
273
+ const sourcePath = join(sourceDir, file)
274
+ if (!statSync(sourcePath).isFile()) continue
275
+ const agentName = file.replace(/\.md$/, '')
276
+ const output = generateAgentPrompt(agentName, readFileSync(sourcePath, 'utf8'), instructions)
277
+ const targetPath = join(targetDir, file)
278
+ if (existingOnly && !existsSync(targetPath)) continue
279
+ const existing = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null
280
+ if (existing === output) {
281
+ skipped++
282
+ } else {
283
+ if (!dryRun) writeFileSync(targetPath, output, 'utf8')
284
+ created++
285
+ }
286
+ }
287
+ return { created, skipped }
288
+ }
289
+
290
+ /**
291
+ * Count LF-delimited logical lines without assuming a trailing newline.
292
+ *
293
+ * @param {string} content - File content
294
+ * @returns {number}
295
+ */
296
+ export function logicalLineCount(content) {
297
+ if (content.length === 0) return 0
298
+ const separators = content.match(/\n/g)?.length ?? 0
299
+ return separators + (content.endsWith('\n') ? 0 : 1)
300
+ }
301
+
171
302
  // ---------------------------------------------------------------------------
172
303
  // CLI entry point
173
304
  // ---------------------------------------------------------------------------
@@ -175,6 +306,9 @@ export function generateAgentsMd(instructions) {
175
306
  function main() {
176
307
  const checkOnly = process.argv.includes('--check')
177
308
  const instructions = readInstructions()
309
+ const sharedCount = instructions.filter(
310
+ (instr) => !instr.agents || instr.agents.includes('*'),
311
+ ).length
178
312
  const content = generateAgentsMd(instructions)
179
313
 
180
314
  if (checkOnly) {
@@ -183,15 +317,15 @@ function main() {
183
317
  console.error(`❌ AGENTS.md is stale — run: node scripts/build-agents-md.mjs`)
184
318
  process.exit(1)
185
319
  }
186
- console.log(`✅ AGENTS.md is up to date (${instructions.length} instruction sections)`)
320
+ console.log(`✅ AGENTS.md is up to date (${sharedCount} shared instruction sections)`)
187
321
  return
188
322
  }
189
323
 
190
324
  writeFileSync(AGENTS_MD_PATH, content, 'utf8')
191
- const lines = content.split('\n').length
192
- console.log(
193
- `✅ Generated AGENTS.md (${instructions.length} instruction sections, ${lines} lines)`,
194
- )
325
+ const lines = logicalLineCount(content)
326
+ console.log(`✅ Generated AGENTS.md (${sharedCount} shared instruction sections, ${lines} lines)`)
195
327
  }
196
328
 
197
- main()
329
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
330
+ main()
331
+ }
@@ -1200,33 +1200,58 @@ export function resolveMcpCwd(entry, configPath = '') {
1200
1200
  // ---------------------------------------------------------------------------
1201
1201
 
1202
1202
  /**
1203
- * Prerelease-aware comparison for pantheon versions (X.Y.Z or X.Y.Z-beta.N).
1204
- * A stable release outranks any beta of the same core version. Returns
1203
+ * Prerelease-aware comparison for pantheon versions (X.Y.Z with an optional
1204
+ * semver prerelease suffix: `-beta.9`, `-rc.1`, ...). A stable release
1205
+ * outranks any prerelease of the same core version; two prereleases compare
1206
+ * identifier by identifier, numeric identifiers numerically. Returns
1205
1207
  * <0, 0, >0 (a - b). The shared compareVersions/parseVersion above ignores
1206
- * the `-beta.N` suffix (it only reads 4 numeric groups), which would make
1207
- * every beta pair compare equal.
1208
+ * the prerelease suffix (it only reads 4 numeric groups), which would make
1209
+ * every prerelease pair compare equal.
1208
1210
  */
1209
1211
  function comparePantheonVersions(a, b) {
1210
1212
  const parse = (v) => {
1211
1213
  const m = String(v)
1212
1214
  .trim()
1213
- .match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/)
1215
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/)
1214
1216
  if (!m) return null
1215
1217
  return {
1216
1218
  core: [+m[1], +m[2], +m[3]],
1217
- beta: m[4] === undefined ? Number.POSITIVE_INFINITY : +m[4],
1219
+ prerelease: m[4] === undefined ? null : m[4].split('.'),
1218
1220
  }
1219
1221
  }
1222
+ // Per semver precedence: numeric identifiers compare numerically and rank
1223
+ // below alphanumeric ones; a shorter identifier list ranks below a longer
1224
+ // one sharing its prefix; a version WITHOUT a prerelease outranks any
1225
+ // prerelease of the same core version.
1226
+ const comparePrerelease = (pa, pb) => {
1227
+ if (pa === null && pb === null) return 0
1228
+ if (pa === null) return 1
1229
+ if (pb === null) return -1
1230
+ const length = Math.max(pa.length, pb.length)
1231
+ for (let i = 0; i < length; i++) {
1232
+ const x = pa[i]
1233
+ const y = pb[i]
1234
+ if (x === undefined) return -1
1235
+ if (y === undefined) return 1
1236
+ const xNumeric = /^\d+$/.test(x)
1237
+ const yNumeric = /^\d+$/.test(y)
1238
+ if (xNumeric && yNumeric) {
1239
+ if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1
1240
+ } else if (xNumeric !== yNumeric) {
1241
+ return xNumeric ? -1 : 1
1242
+ } else if (x !== y) {
1243
+ return x < y ? -1 : 1
1244
+ }
1245
+ }
1246
+ return 0
1247
+ }
1220
1248
  const pa = parse(a)
1221
1249
  const pb = parse(b)
1222
1250
  if (!pa || !pb) return 0
1223
1251
  for (let i = 0; i < 3; i++) {
1224
1252
  if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]
1225
1253
  }
1226
- // Infinity - Infinity is NaN, which would make equal stable versions compare
1227
- // as drift for any caller using a strict === 0 check.
1228
- if (pa.beta === pb.beta) return 0
1229
- return pa.beta < pb.beta ? -1 : 1
1254
+ return comparePrerelease(pa.prerelease, pb.prerelease)
1230
1255
  }
1231
1256
 
1232
1257
  /**
@@ -1292,8 +1317,24 @@ export function checkInstallVersionDrift(args) {
1292
1317
  // registered plugin points into against this package's own version. Without
1293
1318
  // network access the "latest" version IS the running package's version.
1294
1319
 
1320
+ // Single source of truth: the package name is read from package.json, the same
1321
+ // way scripts/install/opencode.mjs derives the V2 pin spec. The fallback keeps
1322
+ // the doctor usable on a broken manifest.
1323
+ const PACKAGE_MANIFEST = readJson(join(ROOT, 'package.json'))
1324
+ const PANTHEON_PACKAGE_NAME =
1325
+ typeof PACKAGE_MANIFEST?.name === 'string' && PACKAGE_MANIFEST.name
1326
+ ? PACKAGE_MANIFEST.name
1327
+ : 'pantheon-opencode'
1328
+ // Matches the exact version-pinned V2 spec the installer writes
1329
+ // (`<name>@X.Y.Z` plus any semver prerelease suffix). The name is escaped
1330
+ // because package names may contain regex-significant characters (`.` in
1331
+ // unscoped names, `/` in scoped ones).
1332
+ const PINNED_PLUGIN_SPEC_PATTERN = new RegExp(
1333
+ `^${PANTHEON_PACKAGE_NAME.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)$`,
1334
+ )
1335
+
1295
1336
  /** Locates an installed copy of this package inside a dependency tree. */
1296
- const INSTALLED_PACKAGE_MARKER = '/node_modules/pantheon-opencode/'
1337
+ const INSTALLED_PACKAGE_MARKER = `/node_modules/${PANTHEON_PACKAGE_NAME}/`
1297
1338
 
1298
1339
  /**
1299
1340
  * Return the installed-package root containing a plugin path, or null when the
@@ -1362,8 +1403,10 @@ export function collectRegisteredPluginRefs(configs) {
1362
1403
  if (typeof ref === 'string') {
1363
1404
  refs.push({ path: ref, configPath: cfg.path, configLabel: cfg.label })
1364
1405
  } else if (ref && typeof ref === 'object') {
1365
- // Object-shaped entries carry their path under `path`/`source`.
1366
- const objectPath = ['path', 'source', 'id', 'name'].find(
1406
+ // npm plugin entries carry their identity under `package`; local
1407
+ // entries use `path`/`source`. Preserve either so H3 can inspect the
1408
+ // exact pinned npm version as well as installed filesystem copies.
1409
+ const objectPath = ['package', 'path', 'source', 'id', 'name'].find(
1367
1410
  (k) => typeof ref[k] === 'string',
1368
1411
  )
1369
1412
  if (objectPath) {
@@ -1376,6 +1419,20 @@ export function collectRegisteredPluginRefs(configs) {
1376
1419
  return refs
1377
1420
  }
1378
1421
 
1422
+ /**
1423
+ * Read an exact version from the package-root plugin syntax written by the
1424
+ * V2 installer (`pantheon-opencode@1.6.0`), including ANY semver prerelease
1425
+ * suffix (`-beta.11`, `-rc.1`, ...). Unpinned and unrelated packages
1426
+ * deliberately return null: H3 cannot infer their resolved npm cache version.
1427
+ * @param {string} pluginRef
1428
+ * @returns {string|null}
1429
+ */
1430
+ function readPinnedPantheonPluginVersion(pluginRef) {
1431
+ if (typeof pluginRef !== 'string') return null
1432
+ const match = pluginRef.match(PINNED_PLUGIN_SPEC_PATTERN)
1433
+ return match?.[1] ?? null
1434
+ }
1435
+
1379
1436
  /**
1380
1437
  * Compare the version of the package each registered plugin points into (when
1381
1438
  * it is an installed copy under node_modules/pantheon-opencode) against this
@@ -1404,6 +1461,21 @@ export function checkPluginVersionDrift(args) {
1404
1461
  let compared = 0
1405
1462
  let driftReported = false
1406
1463
  for (const ref of refs) {
1464
+ const pinnedNpmVersion = readPinnedPantheonPluginVersion(ref.path)
1465
+ if (pinnedNpmVersion) {
1466
+ const identity = `npm:${PANTHEON_PACKAGE_NAME}@${pinnedNpmVersion}`
1467
+ if (seen.has(identity)) continue
1468
+ seen.add(identity)
1469
+ compared++
1470
+
1471
+ if (classifyPluginVersionDrift(pinnedNpmVersion, packageVersion) !== 'drift') continue
1472
+ driftReported = true
1473
+ warn(
1474
+ `${ref.configLabel}: V2 plugin is pinned to ${PANTHEON_PACKAGE_NAME}@${pinnedNpmVersion}, but this package is v${packageVersion} — the registered tool surface may be stale. Run \`npx pantheon-opencode init\` to realign the pinned plugin version`,
1475
+ )
1476
+ continue
1477
+ }
1478
+
1407
1479
  const absolute = isAbsolute(ref.path) ? ref.path : resolve(dirname(ref.configPath), ref.path)
1408
1480
  const installed = readInstalledPluginVersion(absolute)
1409
1481
  if (!installed) continue
@@ -1442,21 +1514,29 @@ export function checkPluginVersionDrift(args) {
1442
1514
  * The V2 contract is the PLURAL `plugins` key; V1 is the singular `plugin` key.
1443
1515
  * So the key alone is not the test — a config can legitimately carry a
1444
1516
  * third-party entry in `plugins`. What identifies the generation is a Pantheon
1445
- * V2 entry inside it. Matched on the `plugin-v2` marker so all three real
1446
- * spellings are recognised: the canonical DIRECTORY (`src/plugin-v2`), an
1447
- * absolute path to it, and the package export (`pantheon-opencode/plugin-v2`).
1517
+ * V2 entry inside it. Recognises the canonical directory, an absolute path,
1518
+ * the package subpath export, and the version-pinned package-root entry emitted
1519
+ * by the installer. The exact package name is checked to avoid matching
1520
+ * similarly named third-party plugins.
1448
1521
  *
1449
1522
  * @param {unknown} ref
1450
1523
  * @returns {boolean}
1451
1524
  */
1452
1525
  function isV2PluginRef(ref) {
1453
- const candidate =
1526
+ const candidates =
1454
1527
  typeof ref === 'string'
1455
- ? ref
1528
+ ? [ref]
1456
1529
  : ref && typeof ref === 'object'
1457
- ? (ref.path ?? ref.source ?? ref.id ?? ref.name)
1458
- : undefined
1459
- return typeof candidate === 'string' && candidate.includes('plugin-v2')
1530
+ ? ['package', 'path', 'source', 'id', 'name']
1531
+ .map((key) => ref[key])
1532
+ .filter((value) => typeof value === 'string')
1533
+ : []
1534
+ return candidates.some(
1535
+ (candidate) =>
1536
+ candidate.includes('plugin-v2') ||
1537
+ candidate === PANTHEON_PACKAGE_NAME ||
1538
+ candidate.startsWith(`${PANTHEON_PACKAGE_NAME}@`),
1539
+ )
1460
1540
  }
1461
1541
 
1462
1542
  /**
@@ -1832,7 +1912,9 @@ export function classifyNodeSqliteProbe(probe) {
1832
1912
  if (probe.available) {
1833
1913
  return {
1834
1914
  status: 'ok',
1835
- message: `node:sqlite available on ${probe.version} — pantheon_cost read-only backend supported`,
1915
+ message:
1916
+ `node:sqlite available on ${probe.version} — read-only SQLite backend prerequisite satisfied; ` +
1917
+ 'doctor does not verify pantheon_cost tool registration',
1836
1918
  }
1837
1919
  }
1838
1920
  return {
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * agents-md.mjs — Dynamic AGENTS.md generator from canonical agent frontmatter
4
4
  *
5
- * Reads all agents/*.agent.md files, extracts name + description from frontmatter,
5
+ * Reads all canonical src/agents/*.md files, extracts name + description from frontmatter,
6
6
  * and generates AGENTS.md content dynamically. Eliminates hardcoded agent tables
7
7
  * in platform installers (claude.mjs, cursor.mjs, windsurf.mjs, cline.mjs, etc.).
8
8
  *
@@ -64,7 +64,7 @@ function parseAgentFrontmatter(content) {
64
64
  // ---------------------------------------------------------------------------
65
65
 
66
66
  /**
67
- * Read all canonical agents/*.agent.md files, parse frontmatter,
67
+ * Read all canonical src/agents/*.md files, parse frontmatter,
68
68
  * and return an array of agent metadata objects sorted alphabetically by name.
69
69
  *
70
70
  * Skips files that don't match the `*.agent.md` pattern, files without