Skip to content

Latest commit

 

History

History
862 lines (657 loc) · 50.4 KB

File metadata and controls

862 lines (657 loc) · 50.4 KB

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.10.0
GitHub https://github.com/ranxianglei/opencode-acp
License AGPL-3.0-or-later
Author ranxianglei

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) and runId for 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 after promotionThreshold survivals)
  • 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 compress tool calls (startId: "m00005", endId: "m00012")
  • Block IDs use format b0, b1, etc.
  • Protected messages get BLOCKED ref 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 nudges
  • stats — token accounting
  • messageIds — raw ↔ ref mapping
  • compressionTiming — tool execution duration tracking
  • toolParameters — 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 files field 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 persistence
  • messages/prune.ts — prune replacement logic
  • messages/sync.ts — block synchronization
  • messages/inject/inject.ts — nudge injection
  • commands/*.ts — slash command handlers
  • ui/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.ts has no internal dependencies (leaf node)
  • state/ depends only on config and SDK types
  • hooks.ts is the orchestrator — depends on most other modules
  • compress/ subsystem is self-contained; external code uses it through pipeline.ts or 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

  1. Run npm run typecheck to ensure no type errors
  2. Run npm run format:check to ensure formatting is consistent
  3. Understand the module dependency graph (Section 4.1)
  4. Check if the change affects backward compatibility (Section 2.6)

5.1.1 Development Workflow

All changes MUST follow this workflow:

  1. Create a feature branch from master (naming: YYYY-MM-DD_short-title)
  2. Create devlog entry: devlog/{YYYY-MM-DD_short-title}/ with REQ.md (see Section 5.1.2)
  3. Implement changes
  4. Ensure npm run build and npm run typecheck pass
  5. Ensure all tests pass: npm run test
  6. Commit with descriptive messages (include devlog files)
  7. Push branch and create a GitHub PR
  8. Obtain dual-agent review (Sections 5.3 + 5.4) on the PR
  9. 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:

  1. Prepare the PR (branch, commits, push, gh pr create).
  2. Verify CI passes.
  3. Report the PR URL to the human.
  4. 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 master without 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.md and WORKLOG.md are the required minimum
  • DESIGN.md is required for any change affecting architecture, data flow, or module boundaries
  • REQ.md should be filled BEFORE implementation (functions as a ticket)
  • WORKLOG.md should 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.

  1. 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.
  2. 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

  1. npm run build must pass
  2. npm run typecheck must pass
  3. Run relevant tests
  4. Deploy locally and test in opencode
  5. Update version in package.json before 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):

  1. Checks if the merge commit came from a release branch (YYYY-MM-DD_release-v*)
  2. If yes, reads package.json version and creates v{VERSION} tag
  3. Runs npm cinpm run check:packagenpm test
  4. Publishes to npm registry (uses NPM_TOKEN secret)
  5. 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:

  1. Push to master → release.yml detects release branch merge (YYYY-MM-DD_release-v*)
  2. 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_TOKEN secret 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 master must require pr-validation check 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.11.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 compat
  • chore: bump version to 1.0.1
  • fix: 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:

  1. All tests checked shouldInjectThisTurn but not lastPerMessageNudgeTokens → baseline reset was invisible
  2. All tests were single-turn → the feedback loop (baseline eaten each turn) was invisible
  3. All tests used preserveRecentMessages: 0 → the nothingToCompress path (which triggers the bug) was never exercised
  4. Docker E2E only verified blockCount → nudge state corruption was invisible
  5. 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).