Imported from ranxianglei/opencode-acp (
AGENTS.md). Install upstream withnpx skills add ranxianglei/opencode-acp. Copyright stays with the author.
opencode-acp Development Specification
This document is the highest-priority specification for this project. All developers (including AI Agents) MUST comply unconditionally.
1. Project Overview
1.1 What Is ACP
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.
1.2 Tech Stack
| 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 |
1.3 Repository Info
| Field | Value |
|---|---|
| npm package | opencode-acp |
| Current version | 1.18.2 |
| GitHub | https://github.com/ranxianglei/opencode-acp |
| License | AGPL-3.0-or-later |
| Author | ranxianglei |
1.4 OpenCode Version Support Scope (V2 migrated to billion-context)
opencode-acp targets OpenCode 1.x only. It uses the OpenCode V1 plugin API and does not load on OpenCode 2.x. OpenCode 2.x context management is provided by the sibling package billion-context — install via bili opencode (built-in V2 plugin / launcher) or bili plugin install opencode (self-spawning native plugin). This is the single supported V2 path; the in-repo experimental V2 native port (lib/v2/*, on the retired v2-base branch) is not maintained. Decision recorded in #442 (context: #395, #434); V2 capability delivered in billion-context#754. The README already points 2.x users to billion-context.
Agent behavior rule (MANDATORY): When a new issue/PR/request asks for OpenCode 2.x / V2 plugin API support in this repo, do NOT implement it here. Instead:
- Politely decline and explain that OpenCode 2.x is served by billion-context (link + the two install commands above).
- Point any still-valid V2 findings to https://github.com/ranxianglei/billion-context/issues.
- Close the issue with a brief note referencing this policy and #442.
Exception — do not over-apply: A report may mention OpenCode 2.x but actually be a genuine OpenCode 1.x bug against live master code (e.g., permission handling in index.ts / lib/host-permissions.ts, or shared code under lib/messages/). Verify which tree the reported path lives on before redirecting. Genuine 1.x bugs stay in opencode-acp and are handled normally — never dismiss them just because the thread references V2.
2. Architecture
2.1 Module Map
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
2.2 Core Data Flow
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
2.3 Key Concepts
Compression Blocks
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
Message IDs
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
Session State
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.
2.4 Configuration System
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
Default Configuration
{
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
},
}
2.5 Storage Paths
| 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/
2.6 Internal vs External Naming
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.
3. Development Standards
3.1 Build Commands
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 + verify
3.2 Build Output
dist/— bundled JavaScript (ESM)dist/*.d.ts— TypeScript declaration files- Published files (per
filesfield in package.json):dist/,README.md,LICENSE
3.3 Testing
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
3.4 Deployment (Local Testing)
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/ only
opencode resolves opencode-acp@latest to:
~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/
⚠️ Restart opencode after deploying — the running process caches the module in memory. To pick up changes, kill the opencode process and restart.
Verify the deployed bundle has your changes:
grep -c 'your-feature-name' ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist/index.js
Common 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
3.5 npm Publishing
# Pre-publish checks (runs build + verify)
npm run check:package
# Publish (uses Automation token for 2FA bypass)
npm publish
Important: 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).
4. Code Change Guidelines
4.1 Module Dependencies
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
4.2 Key File Sizes (Complexity Indicators)
| 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 |
4.3 Common Patterns
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.
4.4 Bug Fix History (Key Fixes)
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 |
5. Contributing
5.1 Before Making Changes
- 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)
5.1.1 Development Workflow
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".
5.1.1.1 Git Safety Rules (MANDATORY)
| 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. |
5.1.1.2 PR Merge — Absolute Prohibition
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.
5.1.2 Devlog Requirement (MANDATORY)
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.
5.1.3 Problem Discovery & Fix Reporting (MANDATORY)
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.
5.2 After Making Changes
npm run buildmust passnpm run typecheckmust pass- Run relevant tests
- Deploy locally and test in opencode
- Update version in
package.jsonbefore publishing
5.3 Code Review (MANDATORY)
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 |
5.4 Release Workflow (Automated via CI)
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.
5.4.1 CI Workflows
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.
5.4.2 Release Process (Step-by-Step)
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.md
Changelog 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-acp
5.4.3 Prerequisites
NPM_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.
5.4.4 Manual Publish (Legacy Fallback)
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 version
Only use this as a fallback. The automated workflow (Section 5.4.2) is the standard release process.
5.4.5 Dev / Prerelease Publishing
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 Release
Installing a dev prerelease:
{
"plugin": {
"opencode-acp": "dev"
}
}
Or via CLI:
opencode plugin opencode-acp@dev --global
Key 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.
5.5 Commit Convention
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
5.6 Test Review (MANDATORY)
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
5.7 Nudge & Growth Testing Requirements (MANDATORY)
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.
5.7.1 Unit Test Requirements
| 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 |
5.7.2 Docker E2E Requirements
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.
5.7.3 Why These Requirements Exist
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).
