pantheon-opencode 1.5.2 → 1.6.0-beta.10

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 (146) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/AGENTS.global.md +182 -0
  6. package/AGENTS.md +10 -891
  7. package/CHANGELOG.md +343 -0
  8. package/README.md +577 -79
  9. package/README.pt-BR.md +345 -60
  10. package/bin/pantheon-init.mjs +26 -3
  11. package/commands/pantheon-audit.md +7 -1
  12. package/commands/pantheon-consolidate.md +1 -1
  13. package/commands/pantheon-deepwork.md +14 -3
  14. package/commands/pantheon-focus.md +17 -0
  15. package/commands/pantheon-optimize.md +2 -2
  16. package/commands/pantheon-vision.md +61 -12
  17. package/package.json +30 -11
  18. package/plugin.json +2 -2
  19. package/pyproject.toml +11 -16
  20. package/scripts/build-agents-md.mjs +376 -58
  21. package/scripts/changelog-merge.mjs +161 -0
  22. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  23. package/scripts/ci-validate-yaml.py +7 -5
  24. package/scripts/{code_mode_server.py → code_mode.py} +119 -27
  25. package/scripts/cost.mjs +244 -41
  26. package/scripts/doctor.mjs +236 -29
  27. package/scripts/install/agents-md.mjs +4 -4
  28. package/scripts/install/cli-ui.mjs +1 -1
  29. package/scripts/install/config-migration.mjs +319 -6
  30. package/scripts/install/health-check.mjs +26 -37
  31. package/scripts/install/interactive.mjs +7 -7
  32. package/scripts/install/migrate.mjs +1 -1
  33. package/scripts/install/opencode-version.mjs +246 -4
  34. package/scripts/install/opencode.mjs +627 -179
  35. package/scripts/install/plugin.mjs +43 -9
  36. package/scripts/install/shared.mjs +120 -12
  37. package/scripts/install/vision-prompt.mjs +161 -0
  38. package/scripts/install-mcp.mjs +28 -9
  39. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  40. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  41. package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
  42. package/scripts/postinstall.mjs +0 -7
  43. package/scripts/probe-context-rehydrate.mjs +36 -13
  44. package/scripts/probe-pantheon-cost.mjs +71 -15
  45. package/scripts/prune.mjs +2 -2
  46. package/scripts/run-node-tests.mjs +189 -0
  47. package/scripts/sync-artifacts.mjs +26 -13
  48. package/scripts/sync-tui.mjs +24 -3
  49. package/scripts/test-opencode-v2-sandbox.sh +1913 -0
  50. package/scripts/validate-routing.mjs +43 -3
  51. package/scripts/validate-sandbox-install.mjs +138 -0
  52. package/scripts/versioning.mjs +13 -1
  53. package/scripts/vision-status.mjs +682 -0
  54. package/skills-lock.json +15 -16
  55. package/src/agents/aphrodite.md +20 -37
  56. package/src/agents/apollo.md +7 -3
  57. package/src/agents/athena.md +63 -35
  58. package/src/agents/demeter.md +8 -28
  59. package/src/agents/gaia.md +1 -1
  60. package/src/agents/hephaestus.md +10 -21
  61. package/src/agents/hermes.md +15 -38
  62. package/src/agents/iris.md +1 -1
  63. package/src/agents/mnemosyne.md +23 -60
  64. package/src/agents/nyx.md +1 -1
  65. package/src/agents/prometheus.md +16 -20
  66. package/src/agents/talos.md +10 -2
  67. package/src/agents/themis.md +5 -5
  68. package/src/agents/zeus.md +99 -130
  69. package/src/instructions/agent-return-format.instructions.md +18 -3
  70. package/src/instructions/backend-standards.instructions.md +2 -0
  71. package/src/instructions/frontend-standards.instructions.md +3 -1
  72. package/src/instructions/memory-protocol.instructions.md +11 -110
  73. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  74. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  75. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  76. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  77. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  78. package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
  79. package/src/mcp/eval_store.py +2 -2
  80. package/src/mcp/mcp_codemap_module.py +559 -129
  81. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  82. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  83. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
  84. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
  85. package/src/mcp/requirements-mcp.txt +10 -6
  86. package/src/mcp/requirements-vision.txt +4 -2
  87. package/src/pantheon/cost-command.ts +399 -101
  88. package/src/pantheon/delegation-enforce.ts +10 -1
  89. package/src/pantheon/model-command.ts +18 -7
  90. package/src/pantheon/presets.d.mts +0 -8
  91. package/src/pantheon/presets.mjs +5 -32
  92. package/src/pantheon/todo-enforcer.ts +1 -1
  93. package/src/pantheon/v2-bounded-read.ts +640 -0
  94. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  95. package/src/pantheon/v2-execute-after.ts +186 -0
  96. package/src/pantheon/v2-tools.ts +429 -0
  97. package/src/pantheon/v2-unsupported.d.mts +11 -0
  98. package/src/pantheon/v2-unsupported.mjs +90 -0
  99. package/src/pantheon/v2-vision.ts +622 -0
  100. package/src/pantheon/vision-local.ts +531 -0
  101. package/src/pantheon/vision.ts +1161 -116
  102. package/src/plugin-v2.ts +1244 -288
  103. package/src/plugin.ts +12 -9
  104. package/src/plugins/tui/dist/server.d.ts +1 -2
  105. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  106. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  107. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  108. package/src/plugins/tui/dist/tui-v2.js +659 -0
  109. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  110. package/src/plugins/tui/dist/tui.d.ts +85 -82
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +72 -59
  113. package/src/plugins/tui/index-v2.ts +6 -0
  114. package/src/plugins/tui/package-lock.json +5504 -1341
  115. package/src/plugins/tui/package.json +25 -15
  116. package/src/plugins/tui/src/index-v2.tsx +734 -0
  117. package/src/plugins/tui/src/index.tsx +106 -85
  118. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  119. package/src/plugins/tui/tsconfig.build.json +5 -0
  120. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  121. package/src/plugins/tui/tsdown.config.ts +22 -2
  122. package/src/plugins/tui/tui-v2.ts +2 -0
  123. package/src/plugins/tui/tui.ts +3 -0
  124. package/src/routing.yml +11 -22
  125. package/src/skills/README.md +34 -39
  126. package/src/skills/agent-coordination/SKILL.md +7 -2
  127. package/src/skills/artifact-management/SKILL.md +12 -5
  128. package/src/skills/auto-continue/SKILL.md +54 -95
  129. package/src/skills/code-review-checklist/SKILL.md +5 -5
  130. package/src/skills/context-compression/SKILL.md +26 -879
  131. package/src/skills/session-goal/SKILL.md +14 -5
  132. package/src/skills/spec-driven-development/SKILL.md +3 -1
  133. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  134. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  135. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  136. package/.pantheon/code-mode/eval-run.py +0 -197
  137. package/.pantheon/code-mode/eval-static.py +0 -321
  138. package/.pantheon/code-mode/session-end-save.py +0 -186
  139. package/.pantheon/code-mode/session-end-save.sh +0 -18
  140. package/opencode.json +0 -58
  141. package/scripts/recover-zenodo-deposition.mjs +0 -146
  142. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  143. package/scripts/validate-zenodo-release.mjs +0 -154
  144. package/scripts/zenodo-artifact.mjs +0 -94
  145. package/scripts/zenodo-release-state.mjs +0 -53
  146. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -5,13 +5,6 @@
5
5
  * Runs after `npm install` completes. Validates environment and prints
6
6
  * setup instructions. Does NOT install Pantheon platform components.
7
7
  */
8
- import { existsSync } from 'node:fs'
9
- import { join } from 'node:path'
10
- import { fileURLToPath } from 'node:url'
11
-
12
- const __dirname = fileURLToPath(new URL('.', import.meta.url))
13
- const _ROOT = join(__dirname, '..')
14
-
15
8
  const REQUIRED_NODE_MAJOR = 18
16
9
  const nodeMajor = parseInt(process.versions.node.split('.')[0], 10)
17
10
 
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'
15
15
 
16
16
  const requestedVersion = valueFor('--version')
17
17
  const jsonOutput = process.argv.includes('--json')
18
- const runtime = fileURLToPath(new URL('./mcp_persistence_server.py', import.meta.url))
18
+ const runtime = fileURLToPath(new URL('./mcp_persistence.py', import.meta.url))
19
19
 
20
20
  function valueFor(name) {
21
21
  const index = process.argv.indexOf(name)
@@ -63,6 +63,12 @@ spec.loader.exec_module(module)
63
63
 
64
64
  async def call(name, arguments):
65
65
  result = await module.mcp.call_tool(name, arguments)
66
+ structured = getattr(result, "structured_content", None)
67
+ if structured is not None:
68
+ metadata = getattr(result, "meta", None) or {}
69
+ if metadata.get("fastmcp", {}).get("wrap_result"):
70
+ return structured.get("result") if isinstance(structured, dict) else structured
71
+ return structured
66
72
  if isinstance(result, tuple):
67
73
  structured = result[1]
68
74
  if structured is not None:
@@ -113,8 +119,10 @@ async def main():
113
119
 
114
120
  os.environ.pop("PANTHEON_COMPACTION", None)
115
121
  os.environ.pop("PANTHEON_SESSION_END_SUMMARY", None)
116
- blocks = await call("context_rehydrate", {"slug": slug})
117
- summary = await call("context_session_summary", {"slug": slug})
122
+ blocks = await call("context_rehydrate", {"slug": slug,
123
+ "session_id": session_id})
124
+ summary = await call("context_session_summary", {"slug": slug,
125
+ "session_id": session_id})
118
126
  if not isinstance(blocks, list) or not blocks:
119
127
  raise AssertionError("context_rehydrate returned a null/empty payload")
120
128
  if not isinstance(summary, str) or not summary:
@@ -126,18 +134,25 @@ async def main():
126
134
  checks.append("context_rehydrate non-null payload: PASS")
127
135
  checks.append("context_session_summary non-null payload: PASS")
128
136
 
129
- missing_blocks = await call("context_rehydrate", {"slug": "probe-absent"})
130
- missing_summary = await call("context_session_summary", {"slug": "probe-absent"})
137
+ missing_blocks = await call("context_rehydrate", {"slug": "probe-absent",
138
+ "session_id": session_id})
139
+ missing_summary = await call("context_session_summary", {"slug": "probe-absent",
140
+ "session_id": session_id})
131
141
  if missing_blocks is not None or missing_summary is not None:
132
142
  raise AssertionError("absent checkpoint must return null payloads")
133
143
  checks.append("absence returns null: PASS")
134
144
 
135
145
  expired_slug = "context-expired-probe"
136
146
  await call("context_save", {"slug": expired_slug, "key": "phase:1",
137
- "content": json.dumps(checkpoint), "ttl": -1,
147
+ "content": json.dumps(checkpoint), "ttl": 1,
138
148
  "session_id": session_id})
139
- expired_blocks = await call("context_rehydrate", {"slug": expired_slug})
140
- expired_summary = await call("context_session_summary", {"slug": expired_slug})
149
+ # Use the public TTL contract (positive seconds) and let the entry expire;
150
+ # negative TTLs are rejected by validation and must not be special-cased.
151
+ await asyncio.sleep(1.2)
152
+ expired_blocks = await call("context_rehydrate", {"slug": expired_slug,
153
+ "session_id": session_id})
154
+ expired_summary = await call("context_session_summary", {"slug": expired_slug,
155
+ "session_id": session_id})
141
156
  if expired_blocks is not None or expired_summary is not None:
142
157
  raise AssertionError("expired checkpoint must return null payloads")
143
158
  checks.append("expiration returns null: PASS")
@@ -145,19 +160,23 @@ async def main():
145
160
  invalid_slug = "context-invalid-probe"
146
161
  await call("context_save", {"slug": invalid_slug, "key": "phase:1",
147
162
  "content": "not-json", "session_id": session_id})
148
- invalid_blocks = await call("context_rehydrate", {"slug": invalid_slug})
149
- invalid_summary = await call("context_session_summary", {"slug": invalid_slug})
163
+ invalid_blocks = await call("context_rehydrate", {"slug": invalid_slug,
164
+ "session_id": session_id})
165
+ invalid_summary = await call("context_session_summary", {"slug": invalid_slug,
166
+ "session_id": session_id})
150
167
  if invalid_blocks is not None or invalid_summary is not None:
151
168
  raise AssertionError("invalid checkpoint must return null payloads")
152
169
  checks.append("invalid payload returns null: PASS")
153
170
 
154
171
  os.environ["PANTHEON_COMPACTION"] = "off"
155
- switched_blocks = await call("context_rehydrate", {"slug": slug})
172
+ switched_blocks = await call("context_rehydrate", {"slug": slug,
173
+ "session_id": session_id})
156
174
  if switched_blocks is not None:
157
175
  raise AssertionError("PANTHEON_COMPACTION=off must return null")
158
176
  os.environ.pop("PANTHEON_COMPACTION", None)
159
177
  os.environ["PANTHEON_SESSION_END_SUMMARY"] = "off"
160
- switched_summary = await call("context_session_summary", {"slug": slug})
178
+ switched_summary = await call("context_session_summary", {"slug": slug,
179
+ "session_id": session_id})
161
180
  if switched_summary is not None:
162
181
  raise AssertionError("PANTHEON_SESSION_END_SUMMARY=off must return null")
163
182
  checks.append("kill-switches return null: PASS")
@@ -173,7 +192,7 @@ async function main() {
173
192
  return
174
193
  }
175
194
  if (!existsSync(runtime)) {
176
- unavailable('installed package does not include mcp_persistence_server.py')
195
+ unavailable('installed package does not include mcp_persistence.py')
177
196
  return
178
197
  }
179
198
 
@@ -207,6 +226,10 @@ async function main() {
207
226
  emit('AMBIENTAL', 'persistence probe timed out after 30s')
208
227
  return
209
228
  }
229
+ if (/IncompleteFieldDefinitionWarning|pydantic_settings/i.test(child.stderr)) {
230
+ emit('FAIL', 'persistence probe emitted an incomplete pydantic_settings field warning')
231
+ return
232
+ }
210
233
  if (child.status !== 0) {
211
234
  const detail = `${child.stderr || child.stdout || 'persistence probe failed'}`.trim()
212
235
  if (
@@ -56,11 +56,48 @@ function setEnvironment(values) {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Write the rows in the family the requested host generation actually uses.
61
+ *
62
+ * Call sites describe an assistant turn the way they always have
63
+ * (`role: 'assistant'`, plus a `phase`). This function translates that
64
+ * descriptor into what is on disk: for V2, `role` becomes the `type` COLUMN and
65
+ * `phase` is dropped, because a real V2 payload carries neither — measured, the
66
+ * `role` key is absent from 40052 of 40052 rows on a host 2.0.22 database.
67
+ *
68
+ * Keeping that translation in ONE place is the point: a fixture that wrote
69
+ * `role` into a session_message row asserts the V1 format under a V2 name, which
70
+ * is exactly how the stale V1 predicate in the CLI mirror stayed green — the
71
+ * synthetic database agreed with the bug instead of contradicting it.
72
+ */
59
73
  function createSyntheticDb(DatabaseSync, path, rows) {
60
74
  const db = new DatabaseSync(path)
61
- db.exec('CREATE TABLE message (data TEXT NOT NULL, time_created INTEGER NOT NULL)')
62
- const insert = db.prepare('INSERT INTO message (data, time_created) VALUES (?, ?)')
63
- for (const data of rows) insert.run(JSON.stringify(data), Date.now())
75
+ if (requestedVersion === 'v2') {
76
+ db.exec(
77
+ 'CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, time_created INTEGER, time_updated INTEGER, data TEXT)',
78
+ )
79
+ const insert = db.prepare(
80
+ 'INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?,?,?,?,?,?,?)',
81
+ )
82
+ rows.forEach((row, index) => {
83
+ const payload = { ...row }
84
+ delete payload.role
85
+ delete payload.phase
86
+ insert.run(
87
+ `msg_${index + 1}`,
88
+ 'ses_probe',
89
+ row.role,
90
+ index + 1,
91
+ Date.now(),
92
+ Date.now(),
93
+ JSON.stringify(payload),
94
+ )
95
+ })
96
+ } else {
97
+ db.exec('CREATE TABLE message (data TEXT NOT NULL, time_created INTEGER NOT NULL)')
98
+ const insert = db.prepare('INSERT INTO message (data, time_created) VALUES (?, ?)')
99
+ for (const data of rows) insert.run(JSON.stringify(data), Date.now())
100
+ }
64
101
  db.close()
65
102
  }
66
103
 
@@ -129,7 +166,10 @@ async function main() {
129
166
  const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
130
167
  const dataHome = join(root, 'data')
131
168
  const opencodeDir = join(dataHome, 'opencode')
132
- const selectedDb = join(opencodeDir, requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db')
169
+ // Every real installation writes `opencode.db`; the v1/v2 distinction is the
170
+ // SCHEMA of that file, never its name. Deriving `opencode-v2.db` here made the
171
+ // probe assert a path that exists on no real machine.
172
+ const selectedDb = join(opencodeDir, 'opencode.db')
133
173
  const otherDb = join(root, 'other.db')
134
174
  const envDb = join(root, 'env.db')
135
175
  const explicitDb = join(root, 'explicit.db')
@@ -139,7 +179,9 @@ async function main() {
139
179
  try {
140
180
  mkdirSync(opencodeDir, { recursive: true })
141
181
  // These rows intentionally include a monetary-looking field to prove it is
142
- // ignored; only input/output/total tokens may appear in the report.
182
+ // ignored; only input/output/total tokens may appear in the report. Every
183
+ // V2 phase reads back as `unknown` because the V2 reader emits that literal:
184
+ // a V2 payload carries no phase field for a reader to recover.
143
185
  const selectedRows =
144
186
  requestedVersion === 'v2'
145
187
  ? [
@@ -224,8 +266,8 @@ async function main() {
224
266
  selectedOutput,
225
267
  requestedVersion === 'v2'
226
268
  ? [
227
- { agent: 'aphrodite', phase: 'plan', input: 8, output: 10, total: 18 },
228
- { agent: 'zeus', phase: 'audit', input: 4, output: 6, total: 10 },
269
+ { agent: 'aphrodite', phase: 'unknown', input: 8, output: 10, total: 18 },
270
+ { agent: 'zeus', phase: 'unknown', input: 4, output: 6, total: 10 },
229
271
  ]
230
272
  : [
231
273
  { agent: 'hermes', phase: 'plan', input: 13, output: 24, total: 37 },
@@ -250,7 +292,12 @@ async function main() {
250
292
  {},
251
293
  { sessionID: 'cost-probe' },
252
294
  )
253
- requireOutput(output, '| env-agent | override | 1 | 2 | 3 |', 'PANTHEON_COST_DB')
295
+ // V2 has no phase to report, so the same row reads back as 'unknown'.
296
+ requireOutput(
297
+ output,
298
+ `| env-agent | ${requestedVersion === 'v2' ? 'unknown' : 'override'} | 1 | 2 | 3 |`,
299
+ 'PANTHEON_COST_DB',
300
+ )
254
301
  if (output.includes('wrong-version') || output.includes('env-agent') === false) {
255
302
  throw new Error('PANTHEON_COST_DB did not override the version-selected database')
256
303
  }
@@ -269,7 +316,11 @@ async function main() {
269
316
  {},
270
317
  { sessionID: 'cost-probe' },
271
318
  )
272
- requireOutput(output, '| explicit-agent | explicit | 9 | 1 | 10 |', 'explicit dbPath')
319
+ requireOutput(
320
+ output,
321
+ `| explicit-agent | ${requestedVersion === 'v2' ? 'unknown' : 'explicit'} | 9 | 1 | 10 |`,
322
+ 'explicit dbPath',
323
+ )
273
324
  if (output.includes('env-agent')) throw new Error('explicit dbPath did not take precedence')
274
325
  checks.push('explicit dbPath precedence: PASS')
275
326
  } finally {
@@ -287,11 +338,7 @@ async function main() {
287
338
  { sessionID: 'cost-probe' },
288
339
  )
289
340
  requireOutput(output, 'not found', 'missing DB')
290
- requireOutput(
291
- output,
292
- requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db',
293
- 'selected missing DB path',
294
- )
341
+ requireOutput(output, 'opencode.db', 'selected missing DB path')
295
342
  checks.push('missing DB: PASS')
296
343
  } finally {
297
344
  missingRestore()
@@ -302,7 +349,16 @@ async function main() {
302
349
  }).pantheon_cost.execute({}, { sessionID: 'cost-probe' })
303
350
  requireOutput(incompatibleOutput, 'incompatible opencode.db schema', 'incompatible schema')
304
351
  checks.push('incompatible schema: PASS')
305
- emit('PASS', 'offline synthetic DB checks passed; no real DB was read', checks)
352
+ // Names the synthetic fixture that was built, because the previous wording
353
+ // ("no real DB was read") was true of every run and therefore true of the run
354
+ // in which the V2 ledger was read as V1 and silently dropped. A verdict that
355
+ // cannot fail is not a verdict: it stayed green across the exact defect it
356
+ // was meant to cover.
357
+ emit(
358
+ 'PASS',
359
+ `${checks.length} synthetic ${requestedVersion} checks passed against a temporary opencode.db (no user database was read)`,
360
+ checks,
361
+ )
306
362
  } catch (error) {
307
363
  emit('FAIL', diagnostic(error), checks)
308
364
  } finally {
package/scripts/prune.mjs CHANGED
@@ -164,7 +164,7 @@ export function configHasDeadPaths(configPath) {
164
164
  return true // unparseable config in a legacy dir counts as dead
165
165
  }
166
166
 
167
- const check = (value, where) => {
167
+ const check = (value, _where) => {
168
168
  if (typeof value !== 'string' || value === '') return
169
169
  if (value.startsWith('file://')) return
170
170
  if (!value.startsWith('/')) return true // relative path in a config
@@ -202,7 +202,7 @@ export function findLegacyDirs(configDir) {
202
202
  add(
203
203
  'legacy-dir',
204
204
  dir,
205
- `dead config(s): ${deadConfigs.map((c) => c.replace(dir + '/', '')).join(', ')}`,
205
+ `dead config(s): ${deadConfigs.map((c) => c.replace(`${dir}/`, '')).join(', ')}`,
206
206
  'list',
207
207
  )
208
208
  }
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run `node --test` over an explicit file list, and FAIL when zero tests are collected.
4
+ *
5
+ * Why this exists: `node --test <glob>` exits 0 when the glob matches nothing (Node >= 22).
6
+ * A test script whose file set is absent from the checkout -- or absent from the published
7
+ * package, because the `files` allow-list omits it -- therefore reports success while
8
+ * collecting nothing. That is the worst possible failure mode for a release gate: it
9
+ * manufactures confidence.
10
+ *
11
+ * This runner closes that hole from two independent sides:
12
+ * 1. It expands the globs ITSELF and refuses to start the runner with zero files, so the
13
+ * runner is never handed an unexpanded pattern that silently matches nothing.
14
+ * 2. After a run that exited 0, it re-reads the collected count from the machine-readable
15
+ * TAP summary and fails closed when that count is 0, unparseable, or missing.
16
+ *
17
+ * Side 2 is deliberately independent of side 1: a future Node change to `--test` glob
18
+ * handling, a glob that expands to a directory of empty files, or an emptied test file all
19
+ * still get caught.
20
+ *
21
+ * Human-readable output is unchanged: the default `spec` reporter still goes to stdout, and
22
+ * the TAP reporter is diverted to a scratch file that is read once and deleted.
23
+ *
24
+ * Usage:
25
+ * node scripts/run-node-tests.mjs [--label <name>] [-- <node flags...>] <glob>...
26
+ *
27
+ * Examples:
28
+ * node scripts/run-node-tests.mjs 'tests/*.mjs' 'tests/pantheon/*.mjs'
29
+ * node scripts/run-node-tests.mjs -- --experimental-test-coverage 'tests/*.mjs'
30
+ */
31
+ import { spawnSync } from 'node:child_process'
32
+ import { globSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
33
+ import { tmpdir } from 'node:os'
34
+ import { join } from 'node:path'
35
+ import { fileURLToPath } from 'node:url'
36
+
37
+ const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..')
38
+
39
+ function usage(message) {
40
+ console.error(`zero-test-guard: ${message}`)
41
+ console.error(
42
+ 'usage: node scripts/run-node-tests.mjs [--label <name>] [-- <node flags...>] <glob>...',
43
+ )
44
+ process.exit(1)
45
+ }
46
+
47
+ /** Parse `[--label <name>] [-- <node flags...>] <glob>...`. */
48
+ function parseArgs(argv) {
49
+ const separator = argv.indexOf('--')
50
+ const own = separator === -1 ? argv : argv.slice(0, separator)
51
+ const nodeFlags = separator === -1 ? [] : argv.slice(separator + 1)
52
+
53
+ let label = 'node tests'
54
+ const globs = []
55
+ for (let i = 0; i < own.length; i++) {
56
+ if (own[i] === '--label') {
57
+ const value = own[i + 1]
58
+ if (value === undefined) usage('--label requires a value')
59
+ label = value
60
+ i += 1
61
+ continue
62
+ }
63
+ if (own[i].startsWith('-')) usage(`unknown option ${own[i]}`)
64
+ globs.push(own[i])
65
+ }
66
+ if (globs.length === 0) usage('at least one glob is required')
67
+ return { label, globs, nodeFlags }
68
+ }
69
+
70
+ /**
71
+ * Expand globs to a deduped, ordered file list.
72
+ * Order follows the positional order of the globs so that the suite runs in the same order
73
+ * as the shell used to; within a glob, matches are sorted for determinism.
74
+ */
75
+ function expand(globs) {
76
+ const files = []
77
+ const seen = new Set()
78
+ for (const pattern of globs) {
79
+ const matches = globSync(pattern, { cwd: ROOT })
80
+ .filter((match) => !match.includes('node_modules') && !match.includes('__pycache__'))
81
+ .sort()
82
+ for (const match of matches) {
83
+ if (seen.has(match)) continue
84
+ seen.add(match)
85
+ files.push(match)
86
+ }
87
+ }
88
+ return files
89
+ }
90
+
91
+ function reportMissingFiles(label, globs) {
92
+ console.error(`zero-test-guard: ${label} matched 0 test files — refusing to report success.`)
93
+ console.error(` globs: ${globs.join(' ')}`)
94
+ console.error(' This is the "test suite silently passes when it collects nothing" failure.')
95
+ console.error(' Either the test files are missing from this checkout, or they are missing')
96
+ console.error(' from the published package (check the "files" allow-list in package.json).')
97
+ }
98
+
99
+ function reportZeroCollected(label, files) {
100
+ console.error(`zero-test-guard: ${label} collected 0 tests — refusing to report success.`)
101
+ console.error(` ${files.length} file(s) matched, but not one test case was collected:`)
102
+ for (const file of files) console.error(` ${file}`)
103
+ }
104
+
105
+ /**
106
+ * Count the real test cases collected in a TAP summary.
107
+ *
108
+ * Node counts a test FILE that declares no test cases as one passing "test"
109
+ * (`ok 1 - tests/foo.test.mjs`), so the summary's `# tests N` is >= the file count and
110
+ * can never reach 0 once any file matched. Relying on it alone would let a suite whose
111
+ * every test file was emptied out report success.
112
+ *
113
+ * The reliable discriminator is the subtest NAME: a file-level placeholder is named with
114
+ * the file path that was passed to the runner, a real test case is named with its test
115
+ * name. So a top-level subtest whose name is one of our matched paths is a placeholder
116
+ * and is not counted as a collected case.
117
+ *
118
+ * Returns null when the summary cannot be read or parsed -- the caller fails closed.
119
+ */
120
+ function countCollectedTests(tapPath, files) {
121
+ let tap = ''
122
+ try {
123
+ tap = readFileSync(tapPath, 'utf8')
124
+ } catch {
125
+ return null
126
+ }
127
+ // The summary count is still asserted to exist: without it the stream is not TAP.
128
+ if (!/^#\s*tests\s+\d+\s*$/m.test(tap)) return null
129
+
130
+ const placeholders = new Set(files)
131
+ let collected = 0
132
+ for (const match of tap.matchAll(/^# Subtest: (.+)$/gm)) {
133
+ if (placeholders.has(match[1].trim())) continue
134
+ collected += 1
135
+ }
136
+ return collected
137
+ }
138
+
139
+ const { label, globs, nodeFlags } = parseArgs(process.argv.slice(2))
140
+ const files = expand(globs)
141
+
142
+ if (files.length === 0) {
143
+ reportMissingFiles(label, globs)
144
+ process.exit(1)
145
+ }
146
+
147
+ const scratch = mkdtempSync(join(tmpdir(), 'pantheon-node-tests-'))
148
+ const tapPath = join(scratch, 'summary.tap')
149
+
150
+ try {
151
+ const run = spawnSync(
152
+ process.execPath,
153
+ [
154
+ '--test',
155
+ '--test-reporter=spec',
156
+ '--test-reporter-destination=stdout',
157
+ '--test-reporter=tap',
158
+ `--test-reporter-destination=${tapPath}`,
159
+ ...nodeFlags,
160
+ ...files,
161
+ ],
162
+ { cwd: ROOT, stdio: 'inherit' },
163
+ )
164
+
165
+ if (run.error) {
166
+ console.error(`zero-test-guard: could not start the test runner: ${run.error.message}`)
167
+ process.exit(1)
168
+ }
169
+ if (run.signal) {
170
+ console.error(`zero-test-guard: the test runner was killed by ${run.signal}`)
171
+ process.exit(1)
172
+ }
173
+ // Real failures keep the runner's own exit code; the guard only speaks up when the
174
+ // runner claimed success it cannot justify.
175
+ if (run.status !== 0) process.exit(run.status)
176
+
177
+ const collected = countCollectedTests(tapPath, files)
178
+ if (collected === null) {
179
+ console.error('zero-test-guard: could not read the collected test count from the TAP summary.')
180
+ console.error(' Failing closed: an unreadable summary cannot certify that any test ran.')
181
+ process.exit(1)
182
+ }
183
+ if (collected === 0) {
184
+ reportZeroCollected(label, files)
185
+ process.exit(1)
186
+ }
187
+ } finally {
188
+ rmSync(scratch, { recursive: true, force: true })
189
+ }
@@ -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, writeAgentsMdManaged } from './build-agents-md.mjs'
22
23
 
23
24
  import {
24
25
  collectSkillNames,
25
- copyFiles,
26
26
  installSkills,
27
27
  ROOT,
28
28
  syncDir,
@@ -31,15 +31,16 @@ import {
31
31
 
32
32
  /** Canonical MCP script sources — mirrors installOpenCode phase 2.10. */
33
33
  const MCP_SCRIPTS = {
34
- 'mcp_resources_server.py': () => join(ROOT, 'src', 'mcp', 'mcp_resources_server.py'),
35
- 'code_mode_server.py': () => join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
36
- 'memory_mcp_server.py': () => join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
37
- 'mcp_persistence_server.py': () => join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
34
+ 'mcp_resources.py': () => join(ROOT, 'src', 'mcp', 'mcp_resources.py'),
35
+ 'code_mode.py': () => join(ROOT, 'src', 'mcp', 'code_mode.py'),
36
+ 'memory_mcp.py': () => join(ROOT, 'src', 'mcp', 'memory_mcp.py'),
37
+ 'mcp_persistence.py': () => join(ROOT, 'src', 'mcp', 'mcp_persistence.py'),
38
38
  '_pantheon_paths.py': () => join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
39
39
  'mcp_codemap_module.py': () => join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
40
- 'pantheon_vision_server.py': () => join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
40
+ 'pantheon_vision.py': () => join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
41
41
  'eval_store.py': () => join(ROOT, 'src', 'mcp', 'eval_store.py'),
42
42
  'scrub-secrets.py': () => join(ROOT, 'scripts', 'scrub-secrets.py'),
43
+ 'themis_heuristic_scan.py': () => join(ROOT, 'scripts', 'themis_heuristic_scan.py'),
43
44
  }
44
45
 
45
46
  /**
@@ -59,7 +60,7 @@ export function syncCopyArtifacts(configDir) {
59
60
  const dstAgents = join(configDir, 'agents')
60
61
  if (existsSync(join(ROOT, 'src', 'agents'))) {
61
62
  mkdirSync(dstAgents, { recursive: true })
62
- bump(copyFiles(join(ROOT, 'src', 'agents'), dstAgents, false))
63
+ bump(copyAgentPrompts(join(ROOT, 'src', 'agents'), dstAgents, false))
63
64
  const routing = join(ROOT, 'src', 'routing.yml')
64
65
  if (existsSync(routing)) {
65
66
  if (
@@ -82,15 +83,27 @@ export function syncCopyArtifacts(configDir) {
82
83
  result.errors.push(`skills: ${err.message}`)
83
84
  }
84
85
 
85
- // ── instructions (AGENTS.md) ──
86
+ // ── instructions (global AGENTS.md artifact) ──
87
+ // The config dir is GLOBAL context: it receives the repo-agnostic global
88
+ // artifact, never the repository artifact. Managed-region writer keeps any
89
+ // user content around the markers intact.
86
90
  try {
87
- const src = join(ROOT, 'AGENTS.md')
91
+ const src = join(ROOT, 'AGENTS.global.md')
88
92
  if (existsSync(src)) {
89
- if (
90
- writeIfChanged(join(configDir, 'AGENTS.md'), readFileSync(src, 'utf8'), false) === 'created'
91
- ) {
93
+ const status = writeAgentsMdManaged(
94
+ join(configDir, 'AGENTS.md'),
95
+ readFileSync(src, 'utf8'),
96
+ false,
97
+ )
98
+ if (status === 'skipped') result.skipped++
99
+ else {
92
100
  result.created++
93
- } else result.skipped++
101
+ if (status === 'merged') {
102
+ result.errors.push(
103
+ 'instructions: non-generated content found in AGENTS.md — preserved around the Pantheon managed region',
104
+ )
105
+ }
106
+ }
94
107
  }
95
108
  } catch (err) {
96
109
  result.errors.push(`instructions: ${err.message}`)
@@ -14,6 +14,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
14
14
  import { homedir } from 'node:os'
15
15
  import { join } from 'node:path'
16
16
  import { fileURLToPath, pathToFileURL } from 'node:url'
17
+ import { resolveOpenCodeVersion } from './install/opencode-version.mjs'
17
18
 
18
19
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
19
20
  const ROOT = join(__dirname, '..')
@@ -63,7 +64,7 @@ function resolveConfigDir() {
63
64
  * The raw TSX source is never copied — the loader consumes only the bundle.
64
65
  * @returns {{ created: number, skipped: number }}
65
66
  */
66
- function copyPluginFiles(srcDir, dstDir) {
67
+ function copyPluginFiles(srcDir, dstDir, version) {
67
68
  const result = { created: 0, skipped: 0 }
68
69
 
69
70
  mkdirSync(dstDir, { recursive: true })
@@ -87,11 +88,30 @@ function copyPluginFiles(srcDir, dstDir) {
87
88
  // package.json
88
89
  const pkgSrc = join(srcDir, 'package.json')
89
90
  if (existsSync(pkgSrc)) {
90
- const content = readFileSync(pkgSrc, 'utf8')
91
+ const packageJson = JSON.parse(readFileSync(pkgSrc, 'utf8'))
92
+ if (version === 'v2' && packageJson.exports?.['./tui-v2']) {
93
+ packageJson.exports['./tui'] = packageJson.exports['./tui-v2']
94
+ }
95
+ const content = `${JSON.stringify(packageJson, null, 2)}\n`
91
96
  if (writeIfChanged(join(dstDir, 'package.json'), content)) result.created++
92
97
  else result.skipped++
93
98
  }
94
99
 
100
+ const tuiEntry = join(srcDir, version === 'v2' ? 'tui-v2.ts' : 'tui.ts')
101
+ if (existsSync(tuiEntry)) {
102
+ if (writeIfChanged(join(dstDir, 'tui.ts'), readFileSync(tuiEntry, 'utf8'))) result.created++
103
+ else result.skipped++
104
+ }
105
+
106
+ const v2ServerEntry = join(srcDir, 'index-v2.ts')
107
+ const v2ServerDestination = join(dstDir, 'index.ts')
108
+ if (version === 'v2' && existsSync(v2ServerEntry)) {
109
+ if (writeIfChanged(v2ServerDestination, readFileSync(v2ServerEntry, 'utf8'))) result.created++
110
+ else result.skipped++
111
+ } else if (existsSync(v2ServerDestination)) {
112
+ rmSync(v2ServerDestination, { force: true })
113
+ }
114
+
95
115
  // Keep the copied plugin installable with deterministic npm ci.
96
116
  const lockSrc = join(srcDir, 'package-lock.json')
97
117
  if (existsSync(lockSrc)) {
@@ -169,7 +189,8 @@ async function main() {
169
189
  const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
170
190
 
171
191
  // Copy fresh files
172
- const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
192
+ const version = resolveOpenCodeVersion('auto', { warn: (message) => console.warn(message) })
193
+ const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir, version)
173
194
 
174
195
  // Lockfile installation is the only accepted dependency path. Strip
175
196
  // global-context npm vars so a global postinstall can't trigger ECIGLOBAL.