This document is the highest-priority specification for this project. All developers (including AI Agents) MUST comply unconditionally.
Active Context Pruning (ACP) is an OpenCode plugin that implements model-driven context management. Instead of passively truncating context at a hard limit, ACP exposes a compress tool to the AI model, letting it decide when and what to compress into high-fidelity summaries.
ACP is a hardened fork of DCP with 39 bug fixes, including state persistence, token reporting, GC deactivation, 268x logger speedup, auto-recovery for reversed boundaries, and hard-exclusion of protected tools from compression ranges.
| Category | Technology |
|---|---|
| Language | TypeScript (strict, ESM) |
| Runtime | Node.js |
| Build | tsup (bundling) + tsc --emitDeclarationOnly (types) |
| Test Runner | Node.js built-in: node --import tsx --test tests/*.test.ts |
| Package Manager | npm |
| Linting/Formatting | Prettier |
| Plugin SDK | @opencode-ai/plugin >=1.4.3, @opencode-ai/sdk >=1.4.3 |
| Tokenizer | @anthropic-ai/tokenizer |
| Config Parsing | jsonc-parser |
| Validation | zod |
| Field | Value |
|---|---|
| npm package | opencode-acp |
| Current version | 1.10.0 |
| GitHub | https://github.com/ranxianglei/opencode-acp |
| License | AGPL-3.0-or-later |
| Author | ranxianglei |
opencode-acp/
├── index.ts # Plugin entry point — wires hooks, tools, commands, config
├── lib/
│ ├── hooks.ts # Plugin hook handlers (system prompt, message transform, command, event, text-complete)
│ ├── config.ts # Three-layer config: global → config-dir → project
│ ├── logger.ts # Structured logging (logs/acp/)
│ ├── auth.ts # Plugin authentication
│ ├── token-utils.ts # Token counting utilities
│ ├── message-ids.ts # Message ID mapping (raw ↔ mNNNNNN refs)
│ ├── compress-permission.ts # Permission management for compress tool
│ ├── protected-patterns.ts # File pattern protection logic
│ ├── host-permissions.ts # Host-based permission system
│ │
│ ├── compress/ # Compression subsystem
│ │ ├── pipeline.ts # Shared prepare/finalize pipeline for both modes
│ │ ├── range.ts # Range-mode compress tool (contiguous spans → block summaries)
│ │ ├── message.ts # Message-mode compress tool (individual message summaries)
│ │ ├── search.ts # Boundary resolution: maps IDs → message indices
│ │ ├── state.ts # Block allocation, state mutation, wrapping
│ │ ├── message-utils.ts # Message-level utilities for compression
│ │ ├── protected-content.ts # Protected content injection into summaries
│ │ ├── range-utils.ts # Range-level utility functions
│ │ ├── timing.ts # Compression timing tracking
│ │ ├── types.ts # Shared type definitions (ToolContext, BoundaryReference, etc.)
│ │ ├── quality-gate/ # Post-compression quality evaluation (non-blocking, pluggable)
│ │ │ ├── types.ts # QualityGate interface, QualityGateContext, QualityReport
│ │ │ ├── registry.ts # Singleton Map; registerQualityGate / getQualityGate / list
│ │ │ ├── tokenizer.ts # Hand-rolled word-level tokenizer (EN keywords + ZH uni/bigrams)
│ │ │ ├── evaluate.ts # Orchestrator: evaluateBlockQuality + evaluateBatchQuality
│ │ │ ├── algorithms/
│ │ │ │ ├── rouge-recall-v1.ts # Default gate: L1 length floor + L2 ROUGE-1 F1 AND top-20 recall
│ │ │ │ └── index.ts # ensureBuiltinGatesRegistered() idempotent initializer
│ │ │ └── index.ts # Barrel export
│ │ └── index.ts # Barrel export
│ │
│ ├── messages/ # Message processing pipeline
│ │ ├── inject/
│ │ │ ├── inject.ts # Nudge injection (context-limit, turn, iteration) + message ID injection
│ │ │ └── utils.ts # Anchor management, context usage calculation, budget computation
│ │ ├── prune.ts # Replace compressed ranges with summaries, strip tool outputs
│ │ ├── sync.ts # Sync compression blocks with actual messages (deactivate orphans)
│ │ ├── priority.ts # Message priority computation
│ │ ├── query.ts # Message query utilities
│ │ ├── shape.ts # Message shape analysis
│ │ ├── reasoning-strip.ts # Strip reasoning tokens from messages
│ │ ├── utils.ts # General message utilities
│ │ └── index.ts # Barrel export
│ │
│ ├── prompts/ # Prompt system
│ │ ├── index.ts # System prompt renderer (base + extensions)
│ │ ├── store.ts # 6 editable prompts, file-based overrides at 3 levels
│ │ ├── system.ts # Base system prompt template
│ │ ├── compress-message.ts # Message-mode compress prompt
│ │ ├── compress-range.ts # Range-mode compress prompt
│ │ ├── context-limit-nudge.ts # Context limit nudge template
│ │ ├── turn-nudge.ts # Turn nudge template
│ │ ├── iteration-nudge.ts # Iteration nudge template
│ │ └── extensions/
│ │ └── nudge.ts # Block aging warnings + message priority guidance
│ │
│ ├── state/ # State management
│ │ ├── state.ts # SessionState creation, session change detection
│ │ ├── persistence.ts # File persistence (plugin/acp/{sessionId}.json)
│ │ ├── tool-cache.ts # Tool result caching
│ │ ├── types.ts # Core types (SessionState, CompressionBlock, Prune, etc.)
│ │ ├── utils.ts # State utility functions
│ │ └── index.ts # Barrel export
│ │
│ ├── gc/
│ │ └── truncate.ts # Age-based deactivation + old-gen summary truncation
│ │
│ ├── commands/ # /acp slash commands
│ │ ├── index.ts # Command barrel (context, stats, export)
│ │ ├── context.ts # /acp context — show current context usage
│ │ ├── stats.ts # /acp stats — show compression statistics
│ │ ├── export.ts # /acp export — export compression blocks to markdown
│ │ └── compression-targets.ts # Target selection for manual compression
│ │
│ ├── ui/
│ │ ├── notification.ts # Compression notification builder (chat/toast, minimal/detailed)
│ │ └── utils.ts # UI formatting utilities
│ │
│ └── update.ts # Auto-update check and notification
│
├── devlog/ # Development iteration logs (templates + per-iteration entries)
│ ├── README.md # Usage guide and naming conventions
│ ├── REQ.template.md # Requirement template
│ ├── WORKLOG.template.md # Worklog template
│ ├── DESIGN.template.md # Design document template
│ └── YYYY-MM-DD_short-title/ # One folder per iteration (REQ.md + WORKLOG.md minimum)
│
├── scripts/ # Utility scripts
│ ├── print.ts # Print DCP info
│ ├── verify-package.mjs # Package verification before publish
│ ├── README.md # Scripts documentation
│ └── ... # CLI tools for session inspection
│
├── tests/ # Test files — 591 tests across 45 files
├── lib/config-validation.ts # Pure validation logic (extracted from config.ts for testability)
├── dcp.schema.json # JSON schema for config validation
├── tsconfig.json # TypeScript config
├── tsup.config.ts # Build config
└── package.json # Package manifest
OpenCode Session
│
▼
index.ts (Plugin Entry — registers hooks + tools)
│
├─► System Prompt Hook (experimental.chat.system.transform)
│ └─► prompts/index.ts → renderSystemPrompt()
│ base prompt + extensions (protected tools, manual mode, subagent mode)
│
├─► Message Transform Hook (experimental.chat.messages.transform) ← runs EVERY LLM call
│ │
│ ├─► registry.getOrCreate() → resolve per-session state (init + load persisted)
│ ├─► updatePerTurnState() → compaction detection + turn count
│ ├─► stripHallucinations() → remove stale mNNNNN refs from model output
│ ├─► assignMessageRefs() → bidirectional map: raw message IDs ↔ mNNNNN refs
│ ├─► syncCompressionBlocks() → deactivate orphaned blocks (messages deleted externally)
│ ├─► runMajorGC() → age-based block deactivation + truncate oversized summaries
│ ├─► prune() → replace compressed ranges with summary blocks in messages
│ ├─► injectCompressNudges() → add context-limit / turn / iteration nudges
│ │ └─► includes block aging guidance (only when context usage > 50%)
│ ├─► injectMessageIds() → tag every message with mNNNNN ref (or BLOCKED)
│ ├─► applyAnchoredNudges() → render nudge text into actual messages
│ └─► stripStaleMetadata() → clean up removed messages' metadata
│
├─► Command Hook (command.execute.before)
│ └─► /acp {help|context|stats|export}
│ (also accepts /dcp for backward compatibility)
│
├─► Event Hook (event)
│ └─► Track compress tool start/complete → attach duration to blocks
│
├─► Text Complete Hook (experimental.text.complete)
│ └─► Strip hallucinated mNNNNN/bN refs from completions
│
└─► Compress Tool (registered as "compress")
│
├─► prepareSession() → permission check, fetch messages, init state
│
├─► [range mode] resolve ranges → map startId/endId to message indices
│ ├─► Auto-swap reversed boundaries (Bug 34 fix)
│ ├─► Inject nested block placeholders into summaries
│ └─► Append protected content (user msgs, tags, tool outputs)
│
├─► [message mode] resolve individual messages
│
├─► applyCompressionState() → allocate block/run IDs, deactivate consumed blocks
│ ├─► Create CompressionBlock (generation: young → old)
│ ├─► Update byMessageId index
│ └─→ Track newly compressed tokens
│
└─► finalizeSession() → save state, evaluate quality gate (non-blocking), send notification
When the model calls compress, one or more CompressionBlock objects are created:
- Each block has a
blockId(bN) andrunIdfor tracking - Blocks track which messages/tools they cover (
directMessageIds,effectiveMessageIds) - Blocks can nest (newer compressions can consume older blocks)
- Blocks have a generation:
young(newly created) →old(promoted afterpromotionThresholdsurvivals) - Old-gen blocks can be truncated by GC if their summaries exceed
maxOldGenSummaryLength - Blocks track
survivedCount— incremented each message-transform hook run
ACP maintains a bidirectional mapping:
- Raw IDs: OpenCode's internal message IDs (UUIDs)
- Refs: Short human-readable IDs (
m00001,m00002, ...) shown to the model (5-digit zero-padded, max 99999) - The model uses refs in
compresstool calls (startId: "m00005",endId: "m00012") - Block IDs use format
b0,b1, etc. - Protected messages get
BLOCKEDref to prevent compression - Backward compat: Old 4-digit refs (pre-1.1.0) are auto-migrated to 5-digit on state load
SessionState holds per-session runtime data:
prune— compression state (blocks, message pruning map, active blocks)nudges— anchor tracking for context-limit, turn, and iteration nudgesstats— token accountingmessageIds— raw ↔ ref mappingcompressionTiming— tool execution duration trackingtoolParameters— tool call parameter cache
State is persisted to ~/.local/share/opencode/storage/plugin/acp/{sessionId}.json.
Three-layer config merging (later layers override earlier):
1. Global: ~/.config/opencode/acp.jsonc
2. Config dir: $OPENCODE_CONFIG_DIR/acp.jsonc
3. Project: .opencode/acp.jsonc
{
enabled: true,
autoUpdate: true,
debug: false,
pruneNotification: "detailed",
pruneNotificationType: "toast",
commands: { enabled: true, protectedTools: ["task", "skill", "todowrite", "todoread", "compress", "batch", "plan_enter", "plan_exit", "write", "edit"] },
allowSubAgents: true,
experimental: { customPrompts: false },
protectedFilePatterns: [],
compress: {
mode: "range",
permission: "allow",
showCompression: true,
summaryBuffer: true,
maxContextLimit: "55%", // percentage of model context limit
minContextLimit: "45%", // percentage of model context limit
nudgeFrequency: 5, // nudges every N turns
iterationNudgeThreshold: 15, // nudge after N messages since last user message
nudgeForce: "soft", // "strong" | "soft"
protectedTools: ["skill"], // root default; an explicit array replaces inherited policy (use [] to protect nothing)
protectTags: false,
protectUserMessages: false,
},
gc: {
algorithm: "truncate",
promotionThreshold: 5, // young → old after this many survivals
maxBlockAge: 15, // deactivate block after this many survivals
maxOldGenSummaryLength: 3000, // truncate old-gen summaries exceeding this (chars)
majorGcThresholdPercent: "100%", // run major GC when usage exceeds this
},
}| What | ACP Path | Notes |
|---|---|---|
| State persistence | plugin/acp/{sessionId}.json |
JSON file I/O |
| Config | ~/.config/opencode/acp.jsonc |
JSONC |
| Prompt overrides | ~/.config/opencode/acp-prompts/ |
File-based |
| Debug logs | logs/acp/ |
Per-request |
Base storage: ~/.local/share/opencode/storage/
ACP maintains backward compatibility with DCP in internal code:
| Scope | Naming Convention |
|---|---|
| User-visible (commands, UI, notifications, docs, config files, storage paths) | ACP, acp |
| Internal code (XML tags, regex variables, schema URLs) | dcp — kept for backward compat |
Examples: dcp-message-id tag, dcp-system-reminder tag, DCP_BLOCK_ID_TAG_REGEX, dcp.schema.json schema URL |
Rule: Never change internal dcp naming without a migration plan. These tags appear in persisted state and LLM interactions.
npm run clean # Remove dist/
npm run build # Clean + tsup + tsc --emitDeclarationOnly
npm run typecheck # TypeScript type checking (no emit)
npm run test # Run tests: node --import tsx --test tests/*.test.ts
npm run format # Format with Prettier
npm run format:check # Check formatting
npm run verify:package # Verify package contents before publish
npm run check:package # Build + verifydist/— bundled JavaScript (ESM)dist/*.d.ts— TypeScript declaration files- Published files (per
filesfield in package.json):dist/,README.md,LICENSE
Test runner: node --import tsx --test tests/*.test.ts
Test directory: Flat tests/ structure — all test files in tests/*.test.ts. No subdirectories.
The project has ~70 source files under lib/ and 45 test files; flat structure is sufficient.
CI is configured via GitHub Actions (PR #2): typecheck + test + build on Node 22/24 matrix.
Baseline: Tag v1.0.1-test-baseline — 95 tests, initial state before ACP test fixes.
Test categories (by naming convention, all in tests/):
| Category | Files | Tests | Description |
|---|---|---|---|
| Baseline | hooks-permission.test.ts, compress-message.test.ts, compress-range.test.ts, message-priority.test.ts, token-counting.test.ts, context-limits.test.ts, update.test.ts |
95 | Original DCP tests, adapted for ACP |
| Tier 1 (pure) | config-validation.test.ts, priority-classify.test.ts, shape.test.ts, query-pure.test.ts, gc-truncate-pure.test.ts, state-utils-pure.test.ts |
83 | Pure function tests, no side effects |
| Tier 2 (mock) | query-mock.test.ts, gc-truncate-mock.test.ts |
68 | Mock-data unit tests |
| Functional | compress-search.test.ts, compress-state.test.ts, message-ids.test.ts |
77 | Compress pipeline with mock data |
| E2E | e2e-message-transform.test.ts, e2e-blocks-nudges.test.ts |
21 | Full message-transform pipeline |
Total: 591 tests, 0 failures (as of v1.10.0)
Test review requirement: All new and modified test files MUST undergo independent review by at least 2 separate agents before commit. See Section 5.4.
Coverage gaps (modules still without dedicated tests):
state/persistence.ts— state persistencemessages/prune.ts— prune replacement logicmessages/sync.ts— block synchronizationmessages/inject/inject.ts— nudge injectioncommands/*.ts— slash command handlersui/notification.ts— notification builder
One command — build + deploy to the local opencode plugin cache:
./scripts/dev-deploy.sh # Type check + build + deploy
./scripts/dev-deploy.sh --check # Tests + type check + build + deploy
./scripts/dev-deploy.sh --no-build # Deploy existing dist/ onlyopencode resolves opencode-acp@latest to:
~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/
Verify the deployed bundle has your changes:
grep -c 'your-feature-name' ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist/index.jsCommon mistake: Deploying to ~/.cache/opencode/node_modules/opencode-acp/ (wrong path — that's the old resolution path, not where @latest resolves).
ACP debug logs (for verifying injection behavior):
~/.config/opencode/logs/acp/context/<session_id>/<timestamp>.json # per-request message snapshots
~/.config/opencode/logs/acp/daily/<date>.log # WARN/ERROR always; INFO/DEBUG when debug: true
# Pre-publish checks (runs build + verify)
npm run check:package
# Publish (uses Automation token for 2FA bypass)
npm publishImportant: The .git/config contains a GitHub OAuth token in the remote URL. Ensure it's not included in the npm package (the files field prevents this).
Dependency graph (simplified):
config.ts ← (consumed by everything)
↑
state/state.ts ← state/persistence.ts
↑
hooks.ts ← messages/inject, messages/prune, messages/sync, gc, prompts, state
↑
compress/pipeline.ts ← state, config
↑
compress/range.ts ← compress/search, compress/state, compress/pipeline
compress/message.ts ← compress/search, compress/state, compress/pipeline
Rules:
config.tshas no internal dependencies (leaf node)state/depends only onconfigand SDK typeshooks.tsis the orchestrator — depends on most other modulescompress/subsystem is self-contained; external code uses it throughpipeline.tsor the tool functions
| File | Lines | Notes |
|---|---|---|
lib/config.ts |
~1125 | Largest file — validation, merging, migration, defaults |
lib/hooks.ts |
~700 | Core pipeline orchestration |
lib/compress/range.ts |
~600 | Range-mode compression logic |
lib/messages/inject/inject.ts |
~500 | Nudge system brain |
lib/prompts/store.ts |
~478 | Prompt management |
lib/compress/search.ts |
~450 | Boundary resolution |
State access pattern: All modules receive PluginConfig, SessionState, and Logger through function parameters or a ToolContext object. No global singletons.
Message transform pipeline: Sequential steps in hooks.ts. Order matters — each step depends on the output of previous steps. Do NOT reorder without understanding dependencies.
ID resolution: The model uses short refs (m0, b3). These must be resolved to raw UUIDs via messageIds.byRef before any operation. Search (compress/search.ts) handles boundary resolution.
Protected content: Tools in protectedTools arrays and files matching protectedFilePatterns are never pruned. Their content is injected into compression summaries.
For reference when modifying code — these bugs were real and the fixes are load-bearing:
| Bug | Fix Location | What It Fixed |
|---|---|---|
| Bug 39 | compress/range.ts, compress/message.ts |
Hard-exclude protected tool messages (skill/task/todowrite) from compression ranges — they survive intact in visible context instead of being soft-appended to summaries (which GC could truncate) |
| Bug 35 | nudge.ts |
Aging warning only shows when context usage > 50% (was showing at 20-30%) |
| Bug 34 | search.ts |
Auto-swap reversed compress boundaries (model gave endId < startId) |
| State persistence | persistence.ts |
State survives restart (was lost before) |
| Token reporting | token-utils.ts |
Returns actual token counts (was returning 0) |
| GC deactivation | gc/truncate.ts |
Age-based block deactivation (blocks were never deactivated) |
| Logger speedup | logger.ts |
268x faster tokenization (was using sync API) |
| Summary resolution | compress/range.ts |
Block placeholder injection for nested compressions |
- Run
npm run typecheckto ensure no type errors - Run
npm run format:checkto ensure formatting is consistent - Understand the module dependency graph (Section 4.1)
- Check if the change affects backward compatibility (Section 2.6)
All changes MUST follow this workflow:
- Create a feature branch from
master(naming:YYYY-MM-DD_short-title) - Create devlog entry:
devlog/{YYYY-MM-DD_short-title}/withREQ.md(see Section 5.1.2) - Implement changes
- Ensure
npm run buildandnpm run typecheckpass - Ensure all tests pass:
npm run test - Commit with descriptive messages (include devlog files)
- Push branch and create a GitHub PR
- Obtain dual-agent review (Sections 5.3 + 5.4) on the PR
- PR merge is a human-only operation — AI agents MUST NEVER merge PRs, even when explicitly instructed or forced by a human. See §5.1.1.2 for the absolute policy. The Agent prepares the PR; the human clicks "Merge".
| Rule | Enforcement |
|---|---|
NEVER force-push to master |
Under no circumstances. Not for reverts, not for fixes, not for "quick corrections". If master needs changing, create a PR. |
| NEVER merge PRs — ABSOLUTE PROHIBITION, no exceptions | PR merges are a human-only operation. The Agent MUST NEVER merge any PR, under ANY circumstances. See §5.1.1.2 for the full policy. |
| NEVER remove and re-apply GitHub branch protection to force changes | This is a circumvention of the merge policy. If protection blocks a push, the correct response is to create a PR. |
| NEVER delete branches or tags without human confirmation | Preserve work for review. |
NEVER modify version field in package.json on non-release branches |
Version bumps happen ONLY on YYYY-MM-DD_release-v* branches (see §5.4.2). Regular feature/fix PRs MUST NOT touch the version field. The CI changelog check (§5.4.1) enforces this indirectly: if version changes, CHANGELOG.md and CHANGELOG.zh-CN.md MUST also be modified with a ### v{VERSION} header. Violating this rule causes version-number drift across non-release PRs (e.g., v1.13.0 → v1.13.1 in a feature PR) which makes release bookkeeping unpredictable and can lead to skipped or duplicated npm publishes. |
PR merges are a human-only operation. The Agent MUST NEVER merge any PR.
This is an absolute rule with no exceptions. It applies to:
| Situation | Agent Action |
|---|---|
| No human instruction to merge | Do not merge. End of story. |
| Human implicitly suggests merging (e.g., "提交一下代码", "ship it", "looks good") | Do not merge. Treat as commit/push only. If ambiguous, ASK; do not assume merge authorization. |
| Human explicitly authorizes merge (e.g., "you may merge") | Do not merge. Reply that PR merges are a human-only operation and the human must perform it. |
| Human directly instructs/orders merge (e.g., "merge this now") | Do not merge. Reply that PR merges are a human-only operation and the human must perform it. |
| Human forces or demands auto-merge (e.g., "I order you to merge", ultimatums) | Explicitly refuse. State that this rule cannot be overridden by any instruction, including this one. |
| Human claims this rule does not apply to a specific case | Do not merge. This rule has no case-by-case exceptions. |
| The PR is a revert, fix-up, or "obvious" merge | Do not merge. Reverts and fixes follow the same rule. |
| CI checks all pass and reviews are complete | Do not merge. Green CI is necessary but not sufficient — human action is still required. |
| Hotfix / urgent situation | Do not merge. Urgency does not override this rule. |
What the Agent MUST do instead:
- Prepare the PR (branch, commits, push,
gh pr create). - Verify CI passes.
- Report the PR URL to the human.
- Stop. Wait for the human to click "Merge" themselves.
What the Agent MUST NOT do:
- Call
gh pr merge,gh api .../merge, or any command that merges a PR. - Toggle GitHub branch protection to enable a merge (also forbidden by §5.1.1.1).
- Use admin overrides, force-push, or any workaround to land changes on
masterwithout going through human-initiated PR merge. - Re-interpret human words ("commit", "ship", "land", "deploy", "提交", "上线") as merge authorization. These mean commit/push, not merge.
How to respond when a human instructs the Agent to merge:
I can't merge PRs — AGENTS.md §5.1.1.2 forbids Agents from merging PRs under any circumstances, including when explicitly instructed. Please merge the PR yourself: [PR URL].
This rule exists because PR merges are irreversible, land code on the protected master branch, and may trigger automated releases. Human-only execution ensures a human is always in the loop for these irreversible operations. The rule is intentionally designed so that no instruction — not even an explicit override from the user — can relax it. If a human wants this rule changed, they must edit this section of AGENTS.md themselves; the Agent will continue to follow the written rule until then.
Every PR MUST have a corresponding devlog entry in devlog/{YYYY-MM-DD_short-title}/.
Rules:
- The folder name MUST match the branch name
REQ.mdandWORKLOG.mdare the required minimumDESIGN.mdis required for any change affecting architecture, data flow, or module boundariesREQ.mdshould be filled BEFORE implementation (functions as a ticket)WORKLOG.mdshould be updated DURING and AFTER implementation- Devlog files are committed alongside code changes — not as a separate afterthought
See devlog/README.md for templates and naming conventions.
Problems discovered or fixed while working MUST leave a trace in the issue tracker — never fixed silently and moved on.
- Discovered a problem (bug, defect, wrong behavior, spec violation) — whether while working on this project or any sibling project — file an issue in the project the problem belongs to: repro/steps, impact, root cause (if known), suggested fix.
- Fixed a problem — after the fix, submit an issue to the owning project recording the problem and how it was fixed. For problems in this project: https://github.com/ranxianglei/opencode-acp/issues . If the fix ships as a PR, the PR MUST reference its issue (
Fixes #N); a bare PR without an issue is not acceptable — file the issue first, then link it. An existing PR for the fix counts, but it should carry an accompanying issue.
npm run buildmust passnpm run typecheckmust pass- Run relevant tests
- Deploy locally and test in opencode
- Update version in
package.jsonbefore publishing
All source code changes (files under lib/) MUST undergo independent review by at least 2 separate agents before merge. This applies to:
- New modules added to
lib/ - Modified source files
- Changes to shared types, interfaces, or exports
Review checklist:
| Category | What to Check |
|---|---|
| Correctness | Logic matches intent, no off-by-one errors, edge cases handled |
| Backward compatibility | No breaking changes to persisted state format, exported APIs, or internal tags (Section 2.6) |
| Performance | No unnecessary CPU/memory overhead, no O(n²) where O(n) suffices |
| Type safety | No as any, no @ts-ignore, no type assertion hacks |
| State integrity | State mutations are safe, no lost data on save/load cycle |
Releases are fully automated through GitHub Actions. The workflow is: create a release PR → merge → CI auto-tags, builds, tests, and publishes to npm. No manual npm publish or git tag needed.
Two GitHub Actions workflows enforce AGENTS.md standards and automate releases:
pr-checks.yml — runs on every PR to master:
| Check | What it validates | Script |
|---|---|---|
| Branch name | Matches YYYY-MM-DD_short-title (regex: ^\d{4}-\d{2}-\d{2}_[a-z0-9.-]+$) |
scripts/ci/check-pr.sh |
| Devlog | devlog/{branch-name}/REQ.md and WORKLOG.md exist |
same |
| Changelog | If package.json version changed, CHANGELOG.md and CHANGELOG.zh-CN.md must be modified and contain ### v{VERSION} |
same |
release.yml — triggers on push to master (PR merge):
- Checks if the merge commit came from a release branch (
YYYY-MM-DD_release-v*) - If yes, reads
package.jsonversion and createsv{VERSION}tag - Runs
npm ci→npm run check:package→npm test - Publishes to npm registry (uses
NPM_TOKENsecret) - Creates GitHub Release with auto-generated notes
Why not separate tag-triggered publish? GitHub Actions does not allow workflows pushed by GITHUB_TOKEN to trigger other workflows. A separate auto-tag.yml + tag-triggered release.yml chain does not work — the tag push from auto-tag.yml won't fire release.yml. The unified workflow solves this by doing everything in one job.
Can also be triggered manually via workflow_dispatch with force: true to publish outside a release branch merge.
Step 1: Create a release branch
git checkout master
git pull origin master
git checkout -b YYYY-MM-DD_release-v{VERSION}The branch name MUST match YYYY-MM-DD_release-v{VERSION} for auto-tagging to work (e.g., 2026-07-11_release-v1.11.2).
Step 2: Bump version + update changelog + devlog
# Edit package.json — bump version
# Edit CHANGELOG.md — add changelog entry at the top (under "# Changelog")
# Edit CHANGELOG.zh-CN.md — add changelog entry at the top (under "# 更新日志")
# Create devlog/YYYY-MM-DD_release-v{VERSION}/REQ.md + WORKLOG.mdChangelog format:
### v{VERSION} — Title (PR #NNN)
**Problem**: What was wrong.
**Fix**: What changed.
Files: `path/to/file.ts`. Tests: `tests/file.test.ts`.Step 3: Verify locally, commit, push, create PR
# Verify CI checks pass locally
./scripts/ci/check-pr.sh YYYY-MM-DD_release-v{VERSION} origin/master
# Commit
git add -A
git commit -m "release: v{VERSION} — title"
git push origin YYYY-MM-DD_release-v{VERSION}
# Create PR (CI will run pr-checks.yml + ci.yml)
gh pr create --title "release: v{VERSION} — title" --body "..."Step 4: Merge PR (human-only operation — Agent MUST NOT merge)
Wait for CI to pass (pr-validation, test, build), then a human merges the PR. The Agent MUST NEVER merge the PR itself, even if explicitly instructed — see §5.1.1.2.
Step 5: Auto-publish (fully automated)
Merging the PR triggers release.yml automatically — no manual action needed:
- Push to master →
release.ymldetects release branch merge (YYYY-MM-DD_release-v*) - Creates
v{VERSION}tag, builds, tests, publishes to npm, creates GitHub Release
All in one workflow — no chained workflows (GitHub Actions limitation: GITHUB_TOKEN cannot trigger other workflows).
Step 6: Verify
# Check npm registry
npm view opencode-acp version
# Check GitHub Release
gh release view v{VERSION} --repo ranxianglei/opencode-acpNPM_TOKENsecret must be set in GitHub repo settings (Settings → Secrets → Actions). Create an "Automation" type token at https://www.npmjs.com/settings/ranxianglei/tokens.- GitHub branch protection on
mastermust requirepr-validationcheck to pass before merge. - Release branch naming must follow
YYYY-MM-DD_release-v{VERSION}for auto-tagging to trigger.
If CI is down or NPM_TOKEN is misconfigured, publish manually as a fallback:
# 0. Ensure clean state on master
git checkout master && git pull origin master
git status --porcelain # MUST be empty
# 1. Build + verify
npm run check:package
# 2. Privacy audit
npm pack --dry-run 2>&1
npm pack && tar -tf opencode-acp-*.tgz | grep -iE '\.env|secret|credential|token|key|\.pem|\.key'
rm opencode-acp-*.tgz
# 3. Tag + publish
git tag -a "v{VERSION}" -m "release v{VERSION}"
git push origin "v{VERSION}"
npm publish
# 4. Verify
npm view opencode-acp versionOnly use this as a fallback. The automated workflow (Section 5.4.2) is the standard release process.
For testing changes before a stable release, publish a dev prerelease to npm's dev tag (not latest). This lets users opt in via opencode-acp@dev without affecting stable users on @latest.
How CI detects prereleases: The release.yml workflow checks if the version string contains - (e.g., 1.13.0-dev.1, 1.12.7-beta.2). If it does, it publishes with --tag dev and marks the GitHub Release as prerelease: true. If not, it publishes with --tag latest (normal stable release).
Step-by-step:
# 1. Create a release branch (same naming convention as stable releases)
git checkout master && git pull origin master
git checkout -b YYYY-MM-DD_release-v{VERSION}-dev
# 2. Set a prerelease version in package.json (MUST contain a hyphen)
# e.g., "1.12.7-dev.1", "1.13.0-beta.1", "2.0.0-rc.1"
# 3. Add changelog entries to CHANGELOG.md and CHANGELOG.zh-CN.md
# (header must contain ### v{VERSION} including the suffix, e.g. ### v1.12.7-dev.1)
# 4. Create devlog entry
# 5. Verify, commit, push, create PR
./scripts/ci/check-pr.sh YYYY-MM-DD_release-v{VERSION}-dev origin/master
git add -A && git commit -m "release: v{VERSION}-dev.1 — title"
git push origin YYYY-MM-DD_release-v{VERSION}-dev
gh pr create --title "release: v{VERSION}-dev.1 — title" --body "..."
# 6. Merge PR (human-only operation — Agent MUST NOT merge, see §5.1.1.2)
# 7. CI auto-publishes to npm dev tag + creates prerelease GitHub ReleaseInstalling a dev prerelease:
{
"plugin": {
"opencode-acp": "dev"
}
}Or via CLI:
opencode plugin opencode-acp@dev --globalKey differences from stable releases:
| Aspect | Stable | Dev/Prerelease |
|---|---|---|
| Version format | 1.12.7 |
1.12.7-dev.1 (contains -) |
| npm tag | latest |
dev |
| GitHub Release | stable | prerelease |
| Install | opencode-acp@latest |
opencode-acp@dev |
| Branch naming | YYYY-MM-DD_release-v{VERSION} |
same convention |
Promoting dev → stable: When ready, create a new release branch with the stable version (remove the -suffix), e.g., 1.12.7-dev.1 → 1.12.7. CI will publish to latest.
Use descriptive commit messages. Historical examples:
fix: aging warning only shows when context usage > 50%feat: /dcp → /acp command rename with backward compatchore: bump version to 1.0.1fix: config migration moved to getConfig() entry point
All new and modified test files MUST undergo independent review by at least 2 separate agents before merge (same requirement as Section 5.3 code review). This requirement applies to:
- New test files added to
tests/ - Modified test files (changed test logic, not just test names)
- Changes to test utilities or factories that affect test correctness
Review checklist:
| Category | What to Check |
|---|---|
| Import correctness | Tests import from actual source files, not local reimplementations. If a source module has untestable runtime dependencies, extract pure logic into a separate importable module. |
| Test name fidelity | Test name accurately describes what the test asserts. A test named "returns true" must assert true, not false. |
| Config completeness | buildConfig() factory includes ALL required config fields (including gc), matching the PluginConfig type. |
| Input validity | Test inputs actually exercise the code path described in the test name. A "dcp tag stripping" test must contain actual dcp tags. |
| No tautological tests | Tests must assert meaningful behavior, not trivially true conditions (e.g., assert.equal(x, x)). |
Anti-patterns to flag:
- Tests that reimplement source logic locally instead of importing from source
buildConfig()missing fields that other test files include- Test names that contradict their assertions
- Tests whose inputs don't match what the test name describes
Changes to lib/messages/inject/ or nudge-related logic MUST include tests that satisfy ALL of the following. These requirements were added after the baseline-reset bug (PR #207) — a production bug where lastPerMessageNudgeTokens was silently reset on nothingToCompress, creating a feedback loop that prevented nudges from ever firing in short/subagent sessions. The existing test suite (900+ tests) failed to catch this bug due to five structural gaps.
| Requirement | What | Why |
|---|---|---|
| Multi-turn | At least 2 consecutive injectCompressNudges calls in the same test, sharing SessionState |
Single-turn tests cannot catch cross-turn state bugs (baseline accumulation, feedback loops, proportional adjustment) |
| Side-effect assertions | Assert BOTH shouldInjectThisTurn AND lastPerMessageNudgeTokens (and/or lastNudgeShownTokens) after each call |
Checking only shouldInject misses baseline mutations that are invisible until the next turn |
| Production config | At least one test per PR MUST use preserveRecentMessages > 0 (production default: 20) |
All existing tests use preserveRecentMessages: 0, which disables protection — the exact scenario that triggers nothingToCompress in production is never tested |
| Growth cycle | At least one test covers the full cycle: baseline → growth → nudge → compress → new baseline → growth → nudge | Verifies that the nudge system self-resets correctly after compression and can fire again |
Docker E2E tests (scripts/e2e/) MUST cover:
| Requirement | What | Why |
|---|---|---|
| Nudge-triggered compression | At least one scenario using "respond": "nudge-compress" — the fake LLM detects ACP's nudge injection via detectNudge() (scans user-role messages for nudge-unique phrases) and emits a compress call in response |
Tests the real nudge→compress flow, not just scripted compress calls |
| Nudge state verification | verify.ts MUST check nudge state fields (lastPerMessageNudgeTokens) not just blockCount |
Block count alone cannot detect baseline corruption or nudge suppression bugs |
| Growth accumulation | At least one scenario where context grows across multiple turns past the nudge threshold | Tests that all-compress-in-one-turn don't exercise the growth-gating logic |
The fake-llm-server.ts reports prompt_tokens from actual input message sizes (via computeInputTokens), so ACP sees realistic token counts for threshold evaluation.
The baseline-reset bug (PR #207) was a 1-line production bug that survived 900+ tests because:
- All tests checked
shouldInjectThisTurnbut notlastPerMessageNudgeTokens→ baseline reset was invisible - All tests were single-turn → the feedback loop (baseline eaten each turn) was invisible
- All tests used
preserveRecentMessages: 0→ thenothingToCompresspath (which triggers the bug) was never exercised - Docker E2E only verified
blockCount→ nudge state corruption was invisible - Docker E2E scenarios only used explicit compress calls → the nudge→compress flow was untested
Lesson: Tests that pass against buggy code are worse than no tests — they create false confidence. Every nudge/growth test MUST be verified to FAIL when the bug is present (temporarily revert the fix, run the test, confirm it fails, then re-apply the fix).