pantheon-opencode 1.6.0-beta.1 → 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 (120) hide show
  1. package/AGENTS.global.md +182 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +326 -0
  4. package/README.md +496 -32
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +26 -3
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +21 -14
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +374 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/config-migration.mjs +317 -4
  22. package/scripts/install/health-check.mjs +21 -19
  23. package/scripts/install/interactive.mjs +1 -1
  24. package/scripts/install/opencode-version.mjs +246 -4
  25. package/scripts/install/opencode.mjs +627 -179
  26. package/scripts/install/plugin.mjs +43 -9
  27. package/scripts/install/shared.mjs +120 -12
  28. package/scripts/install/vision-prompt.mjs +161 -0
  29. package/scripts/install-mcp.mjs +28 -9
  30. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  31. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  32. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  33. package/scripts/probe-context-rehydrate.mjs +8 -2
  34. package/scripts/probe-pantheon-cost.mjs +71 -15
  35. package/scripts/run-node-tests.mjs +189 -0
  36. package/scripts/sync-artifacts.mjs +26 -13
  37. package/scripts/sync-tui.mjs +24 -3
  38. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  39. package/scripts/validate-routing.mjs +3 -3
  40. package/scripts/validate-sandbox-install.mjs +138 -0
  41. package/scripts/versioning.mjs +13 -1
  42. package/scripts/vision-status.mjs +682 -0
  43. package/skills-lock.json +8 -8
  44. package/src/agents/aphrodite.md +20 -37
  45. package/src/agents/apollo.md +7 -3
  46. package/src/agents/athena.md +63 -35
  47. package/src/agents/demeter.md +8 -28
  48. package/src/agents/gaia.md +1 -1
  49. package/src/agents/hephaestus.md +10 -21
  50. package/src/agents/hermes.md +15 -38
  51. package/src/agents/iris.md +1 -1
  52. package/src/agents/mnemosyne.md +18 -54
  53. package/src/agents/nyx.md +1 -1
  54. package/src/agents/prometheus.md +16 -20
  55. package/src/agents/talos.md +10 -2
  56. package/src/agents/themis.md +5 -5
  57. package/src/agents/zeus.md +99 -129
  58. package/src/instructions/agent-return-format.instructions.md +18 -3
  59. package/src/instructions/backend-standards.instructions.md +2 -0
  60. package/src/instructions/frontend-standards.instructions.md +3 -1
  61. package/src/instructions/memory-protocol.instructions.md +11 -110
  62. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  63. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  64. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  65. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  66. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  67. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  68. package/src/mcp/eval_store.py +2 -2
  69. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  70. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  71. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  72. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  73. package/src/mcp/requirements-mcp.txt +6 -4
  74. package/src/mcp/requirements-vision.txt +2 -2
  75. package/src/pantheon/cost-command.ts +399 -101
  76. package/src/pantheon/delegation-enforce.ts +10 -1
  77. package/src/pantheon/presets.d.mts +0 -8
  78. package/src/pantheon/presets.mjs +5 -32
  79. package/src/pantheon/v2-bounded-read.ts +640 -0
  80. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  81. package/src/pantheon/v2-execute-after.ts +186 -0
  82. package/src/pantheon/v2-tools.ts +429 -0
  83. package/src/pantheon/v2-unsupported.d.mts +11 -0
  84. package/src/pantheon/v2-unsupported.mjs +90 -0
  85. package/src/pantheon/v2-vision.ts +622 -0
  86. package/src/pantheon/vision-local.ts +531 -0
  87. package/src/pantheon/vision.ts +1160 -115
  88. package/src/plugin-v2.ts +1145 -219
  89. package/src/plugin.ts +12 -9
  90. package/src/plugins/tui/dist/server.d.ts +1 -2
  91. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  93. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  94. package/src/plugins/tui/dist/tui-v2.js +659 -0
  95. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  96. package/src/plugins/tui/dist/tui.d.ts +85 -82
  97. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  98. package/src/plugins/tui/dist/tui.js +72 -59
  99. package/src/plugins/tui/index-v2.ts +6 -0
  100. package/src/plugins/tui/package-lock.json +5516 -1353
  101. package/src/plugins/tui/package.json +22 -12
  102. package/src/plugins/tui/src/index-v2.tsx +734 -0
  103. package/src/plugins/tui/src/index.tsx +106 -85
  104. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  105. package/src/plugins/tui/tsconfig.build.json +5 -0
  106. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  107. package/src/plugins/tui/tsdown.config.ts +22 -2
  108. package/src/plugins/tui/tui-v2.ts +2 -0
  109. package/src/plugins/tui/tui.ts +3 -0
  110. package/src/routing.yml +11 -21
  111. package/src/skills/README.md +3 -3
  112. package/src/skills/agent-coordination/SKILL.md +7 -2
  113. package/src/skills/artifact-management/SKILL.md +12 -5
  114. package/src/skills/auto-continue/SKILL.md +39 -30
  115. package/src/skills/code-review-checklist/SKILL.md +5 -5
  116. package/src/skills/context-compression/SKILL.md +26 -879
  117. package/src/skills/session-goal/SKILL.md +14 -5
  118. package/src/skills/spec-driven-development/SKILL.md +3 -1
  119. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  120. package/opencode.json +0 -58
@@ -34,7 +34,7 @@
34
34
  # Env overrides:
35
35
  # PANTHEON_SANDBOX_ROOT Sandbox root (default: ~/pantheon-sandbox)
36
36
  # OPENCODE_V2_SPEC npm spec providing the V2 binary
37
- # (default: @opencode-ai/cli@beta). NOTE: this is an
37
+ # (default: @opencode/cli@latest). NOTE: this is an
38
38
  # install spec only — it is not a dependency of this
39
39
  # package, and no code imports it.
40
40
  # PANTHEON_REPO Repository the sandbox is built from (default: the
@@ -63,7 +63,7 @@ SANDBOX_ROOT="${PANTHEON_SANDBOX_ROOT:-$HOME/pantheon-sandbox}"
63
63
  SANDBOX_HOME="$SANDBOX_ROOT/home"
64
64
  NPM_PREFIX="$SANDBOX_HOME/.npm-global"
65
65
 
66
- OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode-ai/cli@beta}"
66
+ OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode/cli@latest}"
67
67
  PANTHEON_SANDBOX_MODEL="${PANTHEON_SANDBOX_MODEL:-opencode-go/mimo-v2.5}"
68
68
  PANTHEON_PROMPT_TIMEOUT="${PANTHEON_PROMPT_TIMEOUT:-300}"
69
69
 
@@ -241,11 +241,11 @@ try:
241
241
  except OSError:
242
242
  raise SystemExit(1)
243
243
  expected = {
244
- "code_mode_server.py",
245
- "memory_mcp_server.py",
246
- "mcp_persistence_server.py",
247
- "mcp_resources_server.py",
248
- "pantheon_vision_server.py",
244
+ "code_mode.py",
245
+ "memory_mcp.py",
246
+ "mcp_persistence.py",
247
+ "mcp_resources.py",
248
+ "pantheon_vision.py",
249
249
  }
250
250
  pattern = re.compile(
251
251
  r"spawning process \{\s*"
@@ -265,7 +265,10 @@ for record in records:
265
265
  if command != python_bin or cwd != runtime_dir or script not in expected:
266
266
  raise SystemExit(1)
267
267
  registered.add(script)
268
- if len(records) != len(expected) or registered != expected:
268
+ # OpenCode can initialize the same configured workspace more than once during
269
+ # service startup and `mcp list`. Count the logical server set here; the later
270
+ # `mcp list` assertion still requires exactly five connected MCP names.
271
+ if registered != expected:
269
272
  raise SystemExit(1)
270
273
  PY
271
274
  then
@@ -418,6 +421,7 @@ V2_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
418
421
  V2_PORT="${PANTHEON_V2_PORT:-49376}"
419
422
  V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
420
423
  V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
424
+ V2_MCP_LIST_TIMEOUT="${PANTHEON_V2_MCP_LIST_TIMEOUT:-15}"
421
425
  V2_SERVER_LOG="$SANDBOX_DIR/v2-server.log"
422
426
  V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
423
427
  V2_SERVER_PID=""
@@ -515,11 +519,11 @@ try:
515
519
  except OSError:
516
520
  raise SystemExit(1)
517
521
  expected = {
518
- "code_mode_server.py",
519
- "memory_mcp_server.py",
520
- "mcp_persistence_server.py",
521
- "mcp_resources_server.py",
522
- "pantheon_vision_server.py",
522
+ "code_mode.py",
523
+ "memory_mcp.py",
524
+ "mcp_persistence.py",
525
+ "mcp_resources.py",
526
+ "pantheon_vision.py",
523
527
  }
524
528
  pattern = re.compile(
525
529
  r"spawning process \{\s*"
@@ -642,11 +646,11 @@ config_path = Path(os.environ["CONFIG_PATH"])
642
646
  runtime_dir = os.environ["RUNTIME_DIR"]
643
647
  python_bin = os.environ["MCP_PYTHON"]
644
648
  managed = {
645
- "pantheon-code-mode": "code_mode_server.py",
646
- "pantheon-memory": "memory_mcp_server.py",
647
- "pantheon-persistence": "mcp_persistence_server.py",
648
- "pantheon-resources": "mcp_resources_server.py",
649
- "pantheon-vision": "pantheon_vision_server.py",
649
+ "pantheon-code-mode": "code_mode.py",
650
+ "pantheon-memory": "memory_mcp.py",
651
+ "pantheon-persistence": "mcp_persistence.py",
652
+ "pantheon-resources": "mcp_resources.py",
653
+ "pantheon-vision": "pantheon_vision.py",
650
654
  }
651
655
  try:
652
656
  config = json.loads(config_path.read_text(encoding="utf-8"))
@@ -735,7 +739,8 @@ check_mcp() { # label binary project
735
739
  if ! wait_for_v2_mcp_registrations "$project/.opencode" "$project/.venv/bin/python3"; then
736
740
  kill "$list_pid" 2>/dev/null || true
737
741
  wait "$list_pid" 2>/dev/null || true
738
- rm -f "$list_file"
742
+ echo "--- mcp list output (preserved at $list_file) ---" >&2
743
+ cat "$list_file" >&2 || true
739
744
  record_status "$label MCP" "FAIL" "MCP registrations did not match the active sandbox"
740
745
  return 0
741
746
  fi
@@ -744,7 +749,8 @@ check_mcp() { # label binary project
744
749
  else
745
750
  kill "$list_pid" 2>/dev/null || true
746
751
  wait "$list_pid" 2>/dev/null || true
747
- rm -f "$list_file"
752
+ echo "--- mcp list output (preserved at $list_file) ---" >&2
753
+ cat "$list_file" >&2 || true
748
754
  record_status "$label MCP" "FAIL" "MCP handshakes did not become ready after mcp list started"
749
755
  return 0
750
756
  fi
@@ -809,6 +815,11 @@ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$TGZ" >/dev/null 2>&1; then
809
815
  echo "--- Init (headless) ---"
810
816
  pantheon-opencode init --headless -y
811
817
  rewrite_v2_mcp_config
818
+ node "$REPO_DIR/scripts/validate-sandbox-install.mjs" \
819
+ --repo "$REPO_DIR" \
820
+ --install "$NPM_PREFIX/lib/node_modules/pantheon-opencode" \
821
+ --project-config "$V2_CONFIG" \
822
+ --config "$PANTHEON_GLOBAL/opencode.json"
812
823
 
813
824
  echo "--- MCP Validation ---"
814
825
  check_mcp "V2" opencode2 "$SANDBOX_DIR/project-v2"
@@ -829,7 +840,7 @@ else
829
840
  fi
830
841
 
831
842
  echo "--- Gate (b): pantheon://agents CONTENT check ---"
832
- RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/mcp_resources_server.py"
843
+ RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/mcp_resources.py"
833
844
  if [ ! -f "$RESOURCES_SRV" ]; then
834
845
  record_status "pantheon://agents resource" "FAIL" "resources server not found at $RESOURCES_SRV"
835
846
  else
@@ -871,6 +882,139 @@ fi
871
882
  echo "Next: open the isolated TUI with: $SANDBOX_DIR/start-pantheon.sh"
872
883
  RUNTEST
873
884
  chmod +x "$SANDBOX_ROOT/run-test.sh"
885
+
886
+ cat > "$SANDBOX_ROOT/start-pantheon.sh" <<'STARTPANTHEON'
887
+ #!/usr/bin/env bash
888
+ # Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
889
+ # Launches the installed OpenCode beta with only this sandbox's HOME/config/db.
890
+ set -euo pipefail
891
+
892
+ SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
893
+ SANDBOX_HOME="$SANDBOX_DIR/home"
894
+ NPM_PREFIX="$SANDBOX_HOME/.npm-global"
895
+ SANDBOX_VENV="$SANDBOX_HOME/.config/opencode/.venv"
896
+ V2_PORT="${PANTHEON_V2_PORT:-49376}"
897
+ if [[ ! "$V2_PORT" =~ ^[0-9]{1,5}$ ]]; then
898
+ echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
899
+ exit 1
900
+ fi
901
+ V2_PORT=$((10#$V2_PORT))
902
+ if [ "$V2_PORT" -lt 1 ] || [ "$V2_PORT" -gt 65535 ]; then
903
+ echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
904
+ exit 1
905
+ fi
906
+ if ! python3 - "$V2_PORT" "$NPM_PREFIX" <<'CHECKPORT'
907
+ import errno
908
+ import os
909
+ import socket
910
+ import sys
911
+ from urllib.request import urlopen
912
+
913
+ port = int(sys.argv[1])
914
+ cli_prefix = os.path.realpath(
915
+ os.path.join(sys.argv[2], "lib", "node_modules", "@opencode", "cli")
916
+ )
917
+ try:
918
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
919
+ # A just-stopped service can leave a harmless TIME_WAIT socket behind.
920
+ # SO_REUSEADDR permits the new service to bind that port without
921
+ # masking a live listener or an unrelated server.
922
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
923
+ probe.bind(("127.0.0.1", port))
924
+ except OSError as exc:
925
+ if exc.errno != errno.EADDRINUSE:
926
+ print(f"ERROR: cannot reserve dedicated V2 port 127.0.0.1:{port}: {exc}", file=sys.stderr)
927
+ raise SystemExit(1)
928
+
929
+ # OpenCode intentionally keeps its managed service alive after the TUI exits.
930
+ # Reuse it only when /proc proves the listener is this sandbox's CLI binary.
931
+ inodes = set()
932
+ for table in ("/proc/net/tcp", "/proc/net/tcp6"):
933
+ try:
934
+ with open(table, encoding="ascii") as entries:
935
+ next(entries, None)
936
+ for line in entries:
937
+ fields = line.split()
938
+ if len(fields) >= 10 and int(fields[1].rsplit(":", 1)[1], 16) == port and fields[3] == "0A":
939
+ inodes.add(fields[9])
940
+ except OSError:
941
+ pass
942
+
943
+ listener_pids = set()
944
+ for pid in (entry for entry in os.listdir("/proc") if entry.isdigit()):
945
+ fd_dir = f"/proc/{pid}/fd"
946
+ try:
947
+ for fd in os.listdir(fd_dir):
948
+ try:
949
+ target = os.readlink(os.path.join(fd_dir, fd))
950
+ except OSError:
951
+ continue
952
+ if target.startswith("socket:[") and target[8:-1] in inodes:
953
+ listener_pids.add(pid)
954
+ break
955
+ except OSError:
956
+ continue
957
+
958
+ owns_listener = False
959
+ for pid in listener_pids:
960
+ try:
961
+ with open(f"/proc/{pid}/cmdline", "rb") as command_line:
962
+ args = [arg.decode(errors="replace") for arg in command_line.read().split(b"\0") if arg]
963
+ except OSError:
964
+ continue
965
+ executable = os.path.realpath(args[0]) if args else ""
966
+ if (
967
+ executable.startswith(cli_prefix + os.sep)
968
+ and "serve" in args
969
+ and "--service" in args
970
+ and len(listener_pids) == 1
971
+ ):
972
+ owns_listener = True
973
+ break
974
+
975
+ healthy = False
976
+ if owns_listener:
977
+ try:
978
+ scheme = "http"
979
+ loopback = ".".join(("127", "0", "0", "1"))
980
+ with urlopen(f"{scheme}://{loopback}:{port}/global/health", timeout=2) as response:
981
+ healthy = response.status == 200
982
+ except Exception:
983
+ pass
984
+
985
+ if not healthy:
986
+ print(
987
+ f"ERROR: dedicated V2 port 127.0.0.1:{port} is occupied by a different or unverifiable service; no service was stopped",
988
+ file=sys.stderr,
989
+ )
990
+ raise SystemExit(1)
991
+ print(f"Reusing the existing beta sandbox OpenCode service on 127.0.0.1:{port} (PID {next(iter(listener_pids))}).")
992
+ CHECKPORT
993
+ then
994
+ exit 1
995
+ fi
996
+
997
+ export HOME="$SANDBOX_HOME"
998
+ export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
999
+ export npm_config_prefix="$NPM_PREFIX"
1000
+ export XDG_CONFIG_HOME="$SANDBOX_HOME/.config"
1001
+ export XDG_CONFIG_DIRS="$SANDBOX_HOME/.config/xdg"
1002
+ export XDG_DATA_HOME="$SANDBOX_HOME/.local/share"
1003
+ export XDG_DATA_DIRS="$SANDBOX_HOME/.local/share/xdg"
1004
+ export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
1005
+ export XDG_CACHE_HOME="$SANDBOX_HOME/.cache"
1006
+ export OPENCODE_CONFIG_DIR="$SANDBOX_DIR/project-v2"
1007
+ export OPENCODE_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
1008
+ export PANTHEON_V2_PORT="$V2_PORT"
1009
+ export PORT="$PANTHEON_V2_PORT"
1010
+ unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE \
1011
+ OPENCODE_DATA_DIR OPENCODE_STATE_DIR OPENCODE_CACHE_DIR OPENCODE_STORAGE_PATH
1012
+
1013
+ "$NPM_PREFIX/bin/opencode2" service set port "$V2_PORT"
1014
+ cd "$SANDBOX_DIR/project-v2"
1015
+ exec "$NPM_PREFIX/bin/opencode2" "$@"
1016
+ STARTPANTHEON
1017
+ chmod +x "$SANDBOX_ROOT/start-pantheon.sh"
874
1018
  }
875
1019
 
876
1020
  # ── Reset / Prepare ───────────────────────────────────────────────────────────
@@ -990,6 +1134,7 @@ prepare_project() {
990
1134
  (cd "$dir" && "$pan" init --project --version "$TARGET_VERSION" --model "$PANTHEON_SANDBOX_MODEL" -y) \
991
1135
  || die "init --project --version $TARGET_VERSION failed in $dir"
992
1136
  rewrite_v2_mcp_config
1137
+ validate_sandbox_install
993
1138
  }
994
1139
 
995
1140
  # The installer merges an existing project config. A reused sandbox can
@@ -1013,11 +1158,11 @@ config_path = Path(os.environ["CONFIG_PATH"])
1013
1158
  runtime_dir = os.environ["RUNTIME_DIR"]
1014
1159
  python_bin = os.environ["MCP_PYTHON"]
1015
1160
  managed = {
1016
- "pantheon-code-mode": "code_mode_server.py",
1017
- "pantheon-memory": "memory_mcp_server.py",
1018
- "pantheon-persistence": "mcp_persistence_server.py",
1019
- "pantheon-resources": "mcp_resources_server.py",
1020
- "pantheon-vision": "pantheon_vision_server.py",
1161
+ "pantheon-code-mode": "code_mode.py",
1162
+ "pantheon-memory": "memory_mcp.py",
1163
+ "pantheon-persistence": "mcp_persistence.py",
1164
+ "pantheon-resources": "mcp_resources.py",
1165
+ "pantheon-vision": "pantheon_vision.py",
1021
1166
  }
1022
1167
 
1023
1168
  try:
@@ -1077,6 +1222,15 @@ for name, script in managed.items():
1077
1222
  PY
1078
1223
  }
1079
1224
 
1225
+ validate_sandbox_install() {
1226
+ node "$REPO_DIR/scripts/validate-sandbox-install.mjs" \
1227
+ --repo "$REPO_DIR" \
1228
+ --install "$NPM_PREFIX/lib/node_modules/pantheon-opencode" \
1229
+ --project-config "$V2_CONFIG" \
1230
+ --config "$SANDBOX_HOME/.config/opencode/opencode.json" \
1231
+ || die "sandbox package/config validation failed"
1232
+ }
1233
+
1080
1234
  cmd_prepare() {
1081
1235
  mkdir -p "$SANDBOX_HOME" "$NPM_PREFIX"
1082
1236
  write_run_test_sh
@@ -19,7 +19,7 @@ import { createHash } from 'node:crypto'
19
19
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
20
20
  import { dirname, join } from 'node:path'
21
21
  import { fileURLToPath } from 'node:url'
22
- import yaml from 'js-yaml'
22
+ import { load } from 'js-yaml'
23
23
  import { validatePresetDefs } from '../src/pantheon/presets.mjs'
24
24
 
25
25
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -52,7 +52,7 @@ if (!existsSync(routingPath)) {
52
52
  console.error('❌ routing.yml not found')
53
53
  process.exit(1)
54
54
  }
55
- const routing = yaml.load(readFileSync(routingPath, 'utf8'))
55
+ const routing = load(readFileSync(routingPath, 'utf8'))
56
56
 
57
57
  console.log('🔍 Validating routing.yml...\n')
58
58
 
@@ -253,7 +253,7 @@ function parseFrontmatter(filePath) {
253
253
  const match = content.match(FRONTMATTER_RE)
254
254
  if (!match) return null
255
255
  try {
256
- return yaml.load(match[1])
256
+ return load(match[1])
257
257
  } catch {
258
258
  return null
259
259
  }
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync } from 'node:fs'
4
+ import { join, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const PACKAGE_REF = /^(?:npm:)?pantheon-opencode(?:@[^/]+)?(?:\/(?:plugin|plugin-v2))?$/
8
+ export const SANDBOX_SOURCE_FILES = [
9
+ 'bin/pantheon-init.mjs',
10
+ 'scripts/install/config-migration.mjs',
11
+ 'scripts/install/opencode.mjs',
12
+ 'scripts/validate-sandbox-install.mjs',
13
+ 'src/plugin-v2.ts',
14
+ ]
15
+
16
+ function readJson(path, label) {
17
+ try {
18
+ return JSON.parse(readFileSync(path, 'utf8'))
19
+ } catch (error) {
20
+ throw new Error(`cannot read ${label} at ${path}: ${error.message}`)
21
+ }
22
+ }
23
+
24
+ function pluginReferences(config) {
25
+ const refs = []
26
+ for (const key of ['plugin', 'plugins']) {
27
+ const value = config?.[key]
28
+ const entries = Array.isArray(value) ? value : value === undefined ? [] : [value]
29
+ for (const entry of entries) {
30
+ if (typeof entry === 'string') refs.push(entry)
31
+ else if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
32
+ for (const field of ['package', 'id', 'name', 'path', 'source']) {
33
+ if (typeof entry[field] === 'string') refs.push(entry[field])
34
+ }
35
+ }
36
+ }
37
+ }
38
+ return refs
39
+ }
40
+
41
+ /**
42
+ * Prove that the sandbox is executing the package built from this checkout and
43
+ * that the project config cannot silently load an older npm-cached Pantheon.
44
+ */
45
+ export function validateSandboxInstall({ repoDir, installDir, projectConfig, configPaths = [] }) {
46
+ const sourcePackagePath = join(resolve(repoDir), 'package.json')
47
+ const installedPackagePath = join(resolve(installDir), 'package.json')
48
+ const sourcePackage = readJson(sourcePackagePath, 'checkout package manifest')
49
+ const installedPackage = readJson(installedPackagePath, 'sandbox package manifest')
50
+ if (sourcePackage.version !== installedPackage.version) {
51
+ throw new Error(
52
+ `sandbox package version ${installedPackage.version} does not match checkout ${sourcePackage.version}`,
53
+ )
54
+ }
55
+
56
+ const sourceHash = createHash('sha256')
57
+ for (const relativePath of SANDBOX_SOURCE_FILES) {
58
+ let source
59
+ let installed
60
+ try {
61
+ source = readFileSync(join(resolve(repoDir), relativePath))
62
+ installed = readFileSync(join(resolve(installDir), relativePath))
63
+ } catch (error) {
64
+ throw new Error(`cannot compare package source ${relativePath}: ${error.message}`)
65
+ }
66
+ if (!source.equals(installed)) {
67
+ throw new Error(`installed package source differs from checkout: ${relativePath}`)
68
+ }
69
+ sourceHash.update(relativePath).update('\0').update(installed).update('\0')
70
+ }
71
+
72
+ const expectedPluginPath = resolve(installDir, 'src', 'plugin-v2')
73
+ const allConfigPaths = [...new Set([projectConfig, ...configPaths].map((path) => resolve(path)))]
74
+ let activeProjectPluginCount = 0
75
+ for (const configPath of allConfigPaths) {
76
+ const config = readJson(configPath, 'active OpenCode config')
77
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
78
+ throw new Error(`active OpenCode config must be an object: ${configPath}`)
79
+ }
80
+ for (const ref of pluginReferences(config)) {
81
+ const normalized = ref.replaceAll('\\', '/').replace(/\/$/, '')
82
+ if (PACKAGE_REF.test(normalized)) {
83
+ throw new Error(`stale Pantheon package reference in ${configPath}: ${ref}`)
84
+ }
85
+ if (resolve(ref) === expectedPluginPath) {
86
+ if (configPath === resolve(projectConfig)) activeProjectPluginCount += 1
87
+ }
88
+ }
89
+ }
90
+ if (activeProjectPluginCount !== 1) {
91
+ throw new Error(
92
+ `project config must reference the sandbox V2 plugin exactly once; found ${activeProjectPluginCount}: ${resolve(projectConfig)}`,
93
+ )
94
+ }
95
+
96
+ return {
97
+ status: 'PASS',
98
+ version: installedPackage.version,
99
+ sourceSha256: sourceHash.digest('hex'),
100
+ }
101
+ }
102
+
103
+ function parseArgs(args) {
104
+ const options = { configPaths: [] }
105
+ for (let i = 0; i < args.length; i += 1) {
106
+ const arg = args[i]
107
+ if (arg === '--config') options.configPaths.push(args[++i])
108
+ else if (['--repo', '--install', '--project-config'].includes(arg))
109
+ options[arg.slice(2).replace('-', '')] = args[++i]
110
+ else throw new Error(`unknown argument: ${arg}`)
111
+ }
112
+ for (const required of ['repo', 'install', 'projectconfig']) {
113
+ if (!options[required])
114
+ throw new Error(
115
+ `missing required option --${required.replace('projectconfig', 'project-config')}`,
116
+ )
117
+ }
118
+ return {
119
+ repoDir: options.repo,
120
+ installDir: options.install,
121
+ projectConfig: options.projectconfig,
122
+ configPaths: options.configPaths,
123
+ }
124
+ }
125
+
126
+ function main() {
127
+ try {
128
+ const result = validateSandboxInstall(parseArgs(process.argv.slice(2)))
129
+ process.stdout.write(
130
+ `Sandbox install proof: ${result.status} (version ${result.version}, source sha256 ${result.sourceSha256})\n`,
131
+ )
132
+ } catch (error) {
133
+ process.stderr.write(`Sandbox install proof: FAIL — ${error.message}\n`)
134
+ process.exitCode = 1
135
+ }
136
+ }
137
+
138
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
@@ -24,6 +24,7 @@ import { execFileSync } from 'node:child_process'
24
24
  import { existsSync, readFileSync } from 'node:fs'
25
25
  import { basename, dirname, join } from 'node:path'
26
26
  import { fileURLToPath } from 'node:url'
27
+ import { mergeUnreleasedIntoVersion } from './changelog-merge.mjs'
27
28
  import {
28
29
  commitPreparedUpdates,
29
30
  prepareInventoryVersion,
@@ -471,6 +472,17 @@ switch (command) {
471
472
  case 'changelog': {
472
473
  const version = arg || getCurrentVersion()
473
474
  const date = new Date().toISOString().slice(0, 10)
475
+ const currentChangelog = readFileSync(CHANGELOG_PATH, 'utf-8')
476
+ const mergePlan = mergeUnreleasedIntoVersion(currentChangelog, version)
477
+ if (mergePlan) {
478
+ if (mergePlan.changed) {
479
+ commitPreparedUpdates([{ path: CHANGELOG_PATH, content: mergePlan.content }])
480
+ console.log(` ✓ CHANGELOG: merged [Unreleased] into existing [v${version}]`)
481
+ } else {
482
+ console.log(` ✓ CHANGELOG: [Unreleased] is empty; [v${version}] unchanged`)
483
+ }
484
+ break
485
+ }
474
486
  const changelogPlan = prepareUnreleased(version, date)
475
487
  if (changelogPlan.changed) {
476
488
  commitPreparedUpdates([{ path: CHANGELOG_PATH, content: changelogPlan.content }])
@@ -492,7 +504,7 @@ Commands:
492
504
  --beta: advance the committed X.Y.Z-beta.N line and
493
505
  promote [Unreleased] → [vX.Y.Z-beta.N] in CHANGELOG
494
506
  beta Alias for 'apply --beta'
495
- changelog [version] Promote [Unreleased] → [vX.Y.Z] without bumping
507
+ changelog [version] Promote [Unreleased], or merge into an existing version section
496
508
 
497
509
  Release flow (stable):
498
510
  1. node scripts/versioning.mjs apply [minor]