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
@@ -7,6 +7,7 @@ import {
7
7
  copyFileSync,
8
8
  cpSync,
9
9
  existsSync,
10
+ lstatSync,
10
11
  mkdirSync,
11
12
  readdirSync,
12
13
  readFileSync,
@@ -257,17 +258,105 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
257
258
  return { created, skipped }
258
259
  }
259
260
 
260
- export function writeIfChanged(filePath, content, dryRun) {
261
- const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null
261
+ /**
262
+ * `lstat` that returns `null` instead of throwing for an absent path.
263
+ *
264
+ * Deliberately `lstat`, not `stat`/`existsSync`: both of those follow the link,
265
+ * so a DANGLING symlink reads as absent and the atomic rename below would then
266
+ * silently replace the link itself. `lstat` sees the link and lets the write
267
+ * guard refuse it.
268
+ */
269
+ function lstatOrNull(filePath) {
270
+ try {
271
+ return lstatSync(filePath)
272
+ } catch {
273
+ return null
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Read a path's raw text, or `null` when it cannot be read (absent, dangling
279
+ * symlink target, permission error). The caller decides what "unreadable"
280
+ * means; this never throws.
281
+ */
282
+ function readFileOrNull(filePath) {
283
+ try {
284
+ return readFileSync(filePath, 'utf8')
285
+ } catch {
286
+ return null
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Atomically create/overwrite a text file, skipping an unchanged write.
292
+ *
293
+ * @param {string} filePath - destination path
294
+ * @param {string} content - full file content
295
+ * @param {boolean} dryRun - when true, compute the outcome without writing
296
+ * @param {object} [options]
297
+ * @param {string|null} [options.expectedContent] - the raw content the caller
298
+ * read this path from, if any. When supplied, the write proceeds only if the
299
+ * on-disk content STILL matches it — compared once up front AND again
300
+ * immediately before the rename; otherwise it returns `'conflict'` and leaves
301
+ * the file alone. This is a best-effort COMPARE-BEFORE-WRITE guard, NOT a
302
+ * filesystem CAS: the final re-read and the rename are still two syscalls, so
303
+ * a writer that lands between them is not prevented. What it does close is
304
+ * the window the installer actually races with — a config the caller parsed
305
+ * going stale during the same run — and it keeps the target and the `.bak`
306
+ * consistent with the bytes that were checked.
307
+ * @param {(existing: string|null) => void} [options.beforeRecheck] - runs after
308
+ * the initial content check and before the last-moment re-read. Injection
309
+ * point so tests can simulate a concurrent writer landing in the window;
310
+ * production callers omit it (undefined is a no-op).
311
+ * @param {(existing: string|null) => void} [options.beforeSwap] - runs after the
312
+ * expected-content checks pass and immediately before the rename, so side
313
+ * effects that must not fire on a conflict (e.g. the `.bak` copy) live here.
314
+ * @returns {'created'|'skipped'|'conflict'}
315
+ */
316
+ export function writeIfChanged(filePath, content, dryRun, options = {}) {
317
+ const { expectedContent, beforeSwap, beforeRecheck } = options
318
+
319
+ // Refuse to write through/over a symlink. A dangling symlink is invisible to
320
+ // existsSync, and rename-over-link would replace the link rather than its
321
+ // target — both are silent redirections the installer must not perform.
322
+ const stat = lstatOrNull(filePath)
323
+ if (stat?.isSymbolicLink()) {
324
+ throw new Error(`Refusing to write through symlink: ${filePath}`)
325
+ }
326
+
327
+ const existing = stat === null ? null : readFileOrNull(filePath)
262
328
  if (existing === content) {
263
329
  return 'skipped'
264
330
  }
331
+ if (expectedContent !== undefined && existing !== expectedContent) {
332
+ // The file changed since the caller read it (or appeared/disappeared).
333
+ // Do not clobber the newer content; the caller decides how to react.
334
+ return 'conflict'
335
+ }
265
336
  if (!dryRun) {
337
+ // Seam for a concurrent writer to land between the read above and the
338
+ // re-read below. No production caller passes this.
339
+ if (typeof beforeRecheck === 'function') beforeRecheck(existing)
340
+
266
341
  // Atomic write: write a temp sibling and rename over the target so a
267
342
  // crash mid-write can never leave a truncated file behind (rename is
268
343
  // atomic within the same directory).
269
344
  const tmpPath = `${filePath}.tmp-${process.pid}`
270
345
  writeFileSync(tmpPath, content, 'utf8')
346
+
347
+ // Last-moment re-read. The initial compare and this rename are separate
348
+ // syscalls, so a concurrent writer can land in between; re-check the
349
+ // expected bytes immediately before the swap. On a mismatch, drop the temp
350
+ // and leave the target AND any existing `.bak` byte-identical.
351
+ if (expectedContent !== undefined) {
352
+ const current = readFileOrNull(filePath)
353
+ if (current !== expectedContent) {
354
+ rmSync(tmpPath, { force: true })
355
+ return 'conflict'
356
+ }
357
+ }
358
+
359
+ if (typeof beforeSwap === 'function') beforeSwap(existing)
271
360
  renameSync(tmpPath, filePath)
272
361
  }
273
362
  return 'created'
@@ -277,12 +366,31 @@ export function writeIfChanged(filePath, content, dryRun) {
277
366
  * beta.5: write the user's opencode.json with a same-directory backup of the
278
367
  * previous content. The installer rewrites the whole config; a `.bak` gives
279
368
  * the user a one-step recovery if a merge ever goes wrong.
369
+ *
370
+ * The backup is created INSIDE the `beforeSwap` hook, so it fires only after
371
+ * both expected-content checks pass (the initial compare and the last-moment
372
+ * re-read before the rename). A conflict observed at either point leaves the
373
+ * target AND any pre-existing `.bak` untouched, rather than overwriting the
374
+ * user's last known-good backup with a stale copy on a no-op write.
375
+ *
376
+ * @param {string} filePath
377
+ * @param {string} content
378
+ * @param {boolean} dryRun
379
+ * @param {object} [options] - forwarded to {@link writeIfChanged}
280
380
  */
281
- export function writeConfigWithBackup(filePath, content, dryRun) {
282
- if (!dryRun && existsSync(filePath)) {
283
- copyFileSync(filePath, `${filePath}.bak`)
284
- }
285
- return writeIfChanged(filePath, content, dryRun)
381
+ export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
382
+ return writeIfChanged(filePath, content, dryRun, {
383
+ expectedContent: options.expectedContent,
384
+ beforeRecheck: options.beforeRecheck,
385
+ beforeSwap: (existing) => {
386
+ if (existing === null) return
387
+ try {
388
+ copyFileSync(filePath, `${filePath}.bak`)
389
+ } catch {
390
+ // Best-effort: a backup failure must not block the install.
391
+ }
392
+ },
393
+ })
286
394
  }
287
395
 
288
396
  /**
@@ -47,6 +47,10 @@ const TABLE = {
47
47
  prereqMissing:
48
48
  'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
49
49
  v2Migrating: 'Migrating config to V2 native format...',
50
+ v2UnpublishedPin: (spec) =>
51
+ `V2 plugin is pinned to ${spec}, but this installer is running from a source checkout — ` +
52
+ 'that version may not be published to npm yet, so OpenCode cannot resolve the pin until it is. ' +
53
+ 'Run the installer from the published package (npx pantheon-opencode init) to avoid this.',
50
54
  // summary (printSummary)
51
55
  summaryInstalled: (target) => `OpenCode installed in ${target}`,
52
56
  summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
@@ -95,6 +99,10 @@ const TABLE = {
95
99
  prereqMissing:
96
100
  'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
97
101
  v2Migrating: 'Migrando config para o formato nativo V2...',
102
+ v2UnpublishedPin: (spec) =>
103
+ `Plugin V2 fixado em ${spec}, mas este installer está rodando de um checkout do código-fonte — ` +
104
+ 'essa versão pode ainda não estar publicada no npm, então o OpenCode não resolve o pin até a publicação. ' +
105
+ 'Rode o installer pelo pacote publicado (npx pantheon-opencode init) para evitar isso.',
98
106
  // summary (printSummary)
99
107
  summaryInstalled: (target) => `OpenCode instalado em ${target}`,
100
108
  summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
@@ -8,9 +8,10 @@
8
8
  * temporary synthetic opencode database. A missing fixture runtime is also
9
9
  * AMBIENTAL rather than a false PASS.
10
10
  */
11
- import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
11
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
12
12
  import { tmpdir } from 'node:os'
13
13
  import { join } from 'node:path'
14
+ import { fileURLToPath, pathToFileURL } from 'node:url'
14
15
 
15
16
  const requestedVersion = valueFor('--version')
16
17
  const jsonOutput = process.argv.includes('--json')
@@ -151,19 +152,27 @@ async function main() {
151
152
  return
152
153
  }
153
154
 
155
+ const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
154
156
  let DatabaseSync
155
157
  let createCostCommand
156
158
  try {
157
159
  ;({ DatabaseSync } = await import('node:sqlite'))
158
- ;({ createCostCommand } = await import(source.href))
160
+ // Node's built-in TS stripping deliberately refuses files under
161
+ // node_modules. Exercise the shipped command from an isolated temp copy
162
+ // while resolving its runtime dependencies through the installed package.
163
+ const runtimeSource = join(root, 'cost-command.ts')
164
+ const packageNodeModules = fileURLToPath(new URL('../node_modules/', import.meta.url))
165
+ copyFileSync(fileURLToPath(source), runtimeSource)
166
+ symlinkSync(packageNodeModules, join(root, 'node_modules'), 'dir')
167
+ ;({ createCostCommand } = await import(pathToFileURL(runtimeSource).href))
159
168
  } catch (error) {
169
+ rmSync(root, { recursive: true, force: true })
160
170
  ambiental(
161
171
  `pantheon_cost fixture runtime unavailable: ${error instanceof Error ? error.message : String(error)}`,
162
172
  )
163
173
  return
164
174
  }
165
175
 
166
- const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
167
176
  const dataHome = join(root, 'data')
168
177
  const opencodeDir = join(dataHome, 'opencode')
169
178
  // Every real installation writes `opencode.db`; the v1/v2 distinction is the
@@ -254,6 +263,7 @@ async function main() {
254
263
 
255
264
  const restore = setEnvironment({
256
265
  XDG_DATA_HOME: dataHome,
266
+ OPENCODE_DB: undefined,
257
267
  PANTHEON_OPENCODE_VERSION: requestedVersion,
258
268
  PANTHEON_COST_DB: undefined,
259
269
  })
@@ -284,6 +294,7 @@ async function main() {
284
294
 
285
295
  const envRestore = setEnvironment({
286
296
  XDG_DATA_HOME: dataHome,
297
+ OPENCODE_DB: undefined,
287
298
  PANTHEON_OPENCODE_VERSION: requestedVersion,
288
299
  PANTHEON_COST_DB: envDb,
289
300
  })
@@ -308,6 +319,7 @@ async function main() {
308
319
 
309
320
  const explicitRestore = setEnvironment({
310
321
  XDG_DATA_HOME: dataHome,
322
+ OPENCODE_DB: undefined,
311
323
  PANTHEON_OPENCODE_VERSION: requestedVersion,
312
324
  PANTHEON_COST_DB: envDb,
313
325
  })
@@ -329,6 +341,7 @@ async function main() {
329
341
 
330
342
  const missingRestore = setEnvironment({
331
343
  XDG_DATA_HOME: join(root, 'missing-data'),
344
+ OPENCODE_DB: undefined,
332
345
  PANTHEON_OPENCODE_VERSION: requestedVersion,
333
346
  PANTHEON_COST_DB: undefined,
334
347
  })
@@ -19,10 +19,10 @@
19
19
  */
20
20
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
21
21
  import { join } from 'node:path'
22
+ import { copyAgentPrompts } from './build-agents-md.mjs'
22
23
 
23
24
  import {
24
25
  collectSkillNames,
25
- copyFiles,
26
26
  installSkills,
27
27
  ROOT,
28
28
  syncDir,
@@ -59,7 +59,7 @@ export function syncCopyArtifacts(configDir) {
59
59
  const dstAgents = join(configDir, 'agents')
60
60
  if (existsSync(join(ROOT, 'src', 'agents'))) {
61
61
  mkdirSync(dstAgents, { recursive: true })
62
- bump(copyFiles(join(ROOT, 'src', 'agents'), dstAgents, false))
62
+ bump(copyAgentPrompts(join(ROOT, 'src', 'agents'), dstAgents, false))
63
63
  const routing = join(ROOT, 'src', 'routing.yml')
64
64
  if (existsSync(routing)) {
65
65
  if (