feat: export core Hermes skills

This commit is contained in:
2026-07-15 02:45:56 +00:00
parent a028b63eda
commit 54711fee2a
308 changed files with 41310 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
skills/.curator_backups/
skills/.hub/
skills/.archive/
skills/.usage.json
skills/.usage.json.lock
skills/.bundled_manifest
skills/.curator_state
+8 -1
View File
@@ -1,3 +1,10 @@
# hermes-skills
Public export of Hermes skills used by 小Maggie
Public export of Hermes skills used by 小Maggie.
## Structure
- `skills/` — exported skill directories and linked reference/template/script files.
## Notes
- Exported from local Hermes profile.
- Skills may contain workflow-specific conventions and environment assumptions.
@@ -0,0 +1,3 @@
---
description: Skills for spawning and orchestrating autonomous AI coding agents and multi-agent workflows — running independent agent processes, delegating tasks, and coordinating parallel workstreams.
---
@@ -0,0 +1,91 @@
---
name: ai-coding-agents
description: "Delegate coding to external AI CLI agents (Claude Code, Codex, OpenCode). Shared orchestration patterns, tool selection, PTY handling."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [Coding-Agent, Claude, Codex, OpenAI, OpenCode, Autonomous, PTY, Delegation]
related_skills: [hermes-agent]
---
# AI Coding Agents — Orchestration Guide
Delegate coding tasks to external autonomous coding agent CLIs via the Hermes terminal. All three tools follow the same orchestration patterns; pick based on what's installed and the user's preference.
## Tool Selection
| Tool | Provider | Install | Best For |
|------|----------|---------|----------|
| **Claude Code** | Anthropic | `npm install -g @anthropic-ai/claude-code` | Complex multi-file refactoring, deep reasoning, MCP integration |
| **Codex** | OpenAI | `npm install -g @openai/codex` | Fast feature implementation, batch issue fixing |
| **OpenCode** | Multi-provider | `npm i -g opencode-ai@latest` | Provider-agnostic work, cost optimization |
## Shared Orchestration Patterns
### One-Shot (Preferred for most tasks)
All three tools support non-interactive one-shot execution — the cleanest integration:
```bash
# Claude Code
claude -p 'Add error handling to all API calls in src/' --allowedTools 'Read,Edit' --max-turns 10
# Codex
codex exec 'Add dark mode toggle to settings'
# OpenCode
opencode run 'Add retry logic to API calls and update tests'
```
### Interactive / Background (Multi-turn sessions)
For iterative work, run in background with PTY:
```bash
# All tools require pty=true for interactive mode
terminal(command="<tool> ...", workdir="~/project", background=true, pty=true)
# Monitor with process(action="poll"|"log")
# Send input with process(action="submit", data="...")
```
### PR Review
```bash
# Claude Code
claude -p 'Review this PR thoroughly' --from-pr 42 --max-turns 10
# Codex
codex exec 'Review PR #42. git diff origin/main...origin/pr/42'
# OpenCode
opencode pr 42
```
### Parallel Tasks
All three support running multiple instances in separate workdirs/worktrees:
```bash
terminal(command="<tool> 'Fix issue #78'", workdir="/tmp/issue-78", background=true, pty=true)
terminal(command="<tool> 'Fix issue #99'", workdir="/tmp/issue-99", background=true, pty=true)
```
## Critical Rules
1. **Always use `workdir`** — scope the agent to the right project
2. **Set turn limits** for one-shot mode (prevents runaway loops)
3. **Use `pty=true`** for interactive sessions — all three are TUI apps
4. **Monitor progress** with `process(action="poll"|"log")` — don't kill slow sessions
5. **Git repo required** — all three need a git directory. Use `mktemp -d && git init` for scratch work
6. **Clean up** background sessions when done
## Tool-Specific References
Detailed CLI flags, session management, and advanced features for each tool:
- `references/claude-code.md` — Claude Code: print mode deep dive, tmux orchestration, hooks, MCP, subagents, settings hierarchy
- `references/codex.md` — Codex: exec flags, full-auto vs yolo, worktree patterns, auth (OAuth vs API key)
- `references/opencode.md` — OpenCode: run vs interactive TUI, session resumption, provider selection, PR review
@@ -0,0 +1,745 @@
---
name: claude-code
description: "Delegate coding to Claude Code CLI (features, PRs)."
version: 2.2.0
author: Hermes Agent + Teknium
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [Coding-Agent, Claude, Anthropic, Code-Review, Refactoring, PTY, Automation]
related_skills: [codex, hermes-agent, opencode]
---
# Claude Code — Hermes Orchestration Guide
Delegate coding tasks to [Claude Code](https://code.claude.com/docs/en/cli-reference) (Anthropic's autonomous coding agent CLI) via the Hermes terminal. Claude Code v2.x can read files, write code, run shell commands, spawn subagents, and manage git workflows autonomously.
## Prerequisites
- **Install:** `npm install -g @anthropic-ai/claude-code`
- **Auth:** run `claude` once to log in (browser OAuth for Pro/Max, or set `ANTHROPIC_API_KEY`)
- **Console auth:** `claude auth login --console` for API key billing
- **SSO auth:** `claude auth login --sso` for Enterprise
- **Check status:** `claude auth status` (JSON) or `claude auth status --text` (human-readable)
- **Health check:** `claude doctor` — checks auto-updater and installation health
- **Version check:** `claude --version` (requires v2.x+)
- **Update:** `claude update` or `claude upgrade`
## Two Orchestration Modes
Hermes interacts with Claude Code in two fundamentally different ways. Choose based on the task.
### Mode 1: Print Mode (`-p`) — Non-Interactive (PREFERRED for most tasks)
Print mode runs a one-shot task, returns the result, and exits. No PTY needed. No interactive prompts. This is the cleanest integration path.
```
terminal(command="claude -p 'Add error handling to all API calls in src/' --allowedTools 'Read,Edit' --max-turns 10", workdir="/path/to/project", timeout=120)
```
**When to use print mode:**
- One-shot coding tasks (fix a bug, add a feature, refactor)
- CI/CD automation and scripting
- Structured data extraction with `--json-schema`
- Piped input processing (`cat file | claude -p "analyze this"`)
- Any task where you don't need multi-turn conversation
**Print mode skips ALL interactive dialogs** — no workspace trust prompt, no permission confirmations. This makes it ideal for automation.
### Mode 2: Interactive PTY via tmux — Multi-Turn Sessions
Interactive mode gives you a full conversational REPL where you can send follow-up prompts, use slash commands, and watch Claude work in real time. **Requires tmux orchestration.**
```
# Start a tmux session
terminal(command="tmux new-session -d -s claude-work -x 140 -y 40")
# Launch Claude Code inside it
terminal(command="tmux send-keys -t claude-work 'cd /path/to/project && claude' Enter")
# Wait for startup, then send your task
# (after ~3-5 seconds for the welcome screen)
terminal(command="sleep 5 && tmux send-keys -t claude-work 'Refactor the auth module to use JWT tokens' Enter")
# Monitor progress by capturing the pane
terminal(command="sleep 15 && tmux capture-pane -t claude-work -p -S -50")
# Send follow-up tasks
terminal(command="tmux send-keys -t claude-work 'Now add unit tests for the new JWT code' Enter")
# Exit when done
terminal(command="tmux send-keys -t claude-work '/exit' Enter")
```
**When to use interactive mode:**
- Multi-turn iterative work (refactor → review → fix → test cycle)
- Tasks requiring human-in-the-loop decisions
- Exploratory coding sessions
- When you need to use Claude's slash commands (`/compact`, `/review`, `/model`)
## PTY Dialog Handling (CRITICAL for Interactive Mode)
Claude Code presents up to two confirmation dialogs on first launch. You MUST handle these via tmux send-keys:
### Dialog 1: Workspace Trust (first visit to a directory)
```
❯ 1. Yes, I trust this folder ← DEFAULT (just press Enter)
2. No, exit
```
**Handling:** `tmux send-keys -t <session> Enter` — default selection is correct.
### Dialog 2: Bypass Permissions Warning (only with --dangerously-skip-permissions)
```
❯ 1. No, exit ← DEFAULT (WRONG choice!)
2. Yes, I accept
```
**Handling:** Must navigate DOWN first, then Enter:
```
tmux send-keys -t <session> Down && sleep 0.3 && tmux send-keys -t <session> Enter
```
### Robust Dialog Handling Pattern
```
# Launch with permissions bypass
terminal(command="tmux send-keys -t claude-work 'claude --dangerously-skip-permissions \"your task\"' Enter")
# Handle trust dialog (Enter for default "Yes")
terminal(command="sleep 4 && tmux send-keys -t claude-work Enter")
# Handle permissions dialog (Down then Enter for "Yes, I accept")
terminal(command="sleep 3 && tmux send-keys -t claude-work Down && sleep 0.3 && tmux send-keys -t claude-work Enter")
# Now wait for Claude to work
terminal(command="sleep 15 && tmux capture-pane -t claude-work -p -S -60")
```
**Note:** After the first trust acceptance for a directory, the trust dialog won't appear again. Only the permissions dialog recurs each time you use `--dangerously-skip-permissions`.
## CLI Subcommands
| Subcommand | Purpose |
|------------|---------|
| `claude` | Start interactive REPL |
| `claude "query"` | Start REPL with initial prompt |
| `claude -p "query"` | Print mode (non-interactive, exits when done) |
| `cat file \| claude -p "query"` | Pipe content as stdin context |
| `claude -c` | Continue the most recent conversation in this directory |
| `claude -r "id"` | Resume a specific session by ID or name |
| `claude auth login` | Sign in (add `--console` for API billing, `--sso` for Enterprise) |
| `claude auth status` | Check login status (returns JSON; `--text` for human-readable) |
| `claude mcp add <name> -- <cmd>` | Add an MCP server |
| `claude mcp list` | List configured MCP servers |
| `claude mcp remove <name>` | Remove an MCP server |
| `claude agents` | List configured agents |
| `claude doctor` | Run health checks on installation and auto-updater |
| `claude update` / `claude upgrade` | Update Claude Code to latest version |
| `claude remote-control` | Start server to control Claude from claude.ai or mobile app |
| `claude install [target]` | Install native build (stable, latest, or specific version) |
| `claude setup-token` | Set up long-lived auth token (requires subscription) |
| `claude plugin` / `claude plugins` | Manage Claude Code plugins |
| `claude auto-mode` | Inspect auto mode classifier configuration |
## Print Mode Deep Dive
### Structured JSON Output
```
terminal(command="claude -p 'Analyze auth.py for security issues' --output-format json --max-turns 5", workdir="/project", timeout=120)
```
Returns a JSON object with:
```json
{
"type": "result",
"subtype": "success",
"result": "The analysis text...",
"session_id": "75e2167f-...",
"num_turns": 3,
"total_cost_usd": 0.0787,
"duration_ms": 10276,
"stop_reason": "end_turn",
"terminal_reason": "completed",
"usage": { "input_tokens": 5, "output_tokens": 603, ... },
"modelUsage": { "claude-sonnet-4-6": { "costUSD": 0.078, "contextWindow": 200000 } }
}
```
**Key fields:** `session_id` for resumption, `num_turns` for agentic loop count, `total_cost_usd` for spend tracking, `subtype` for success/error detection (`success`, `error_max_turns`, `error_budget`).
### Streaming JSON Output
For real-time token streaming, use `stream-json` with `--verbose`:
```
terminal(command="claude -p 'Write a summary' --output-format stream-json --verbose --include-partial-messages", timeout=60)
```
Returns newline-delimited JSON events. Filter with jq for live text:
```
claude -p "Explain X" --output-format stream-json --verbose --include-partial-messages | \
jq -rj 'select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text'
```
Stream events include `system/api_retry` with `attempt`, `max_retries`, and `error` fields (e.g., `rate_limit`, `billing_error`).
### Bidirectional Streaming
For real-time input AND output streaming:
```
claude -p "task" --input-format stream-json --output-format stream-json --replay-user-messages
```
`--replay-user-messages` re-emits user messages on stdout for acknowledgment.
### Piped Input
```
# Pipe a file for analysis
terminal(command="cat src/auth.py | claude -p 'Review this code for bugs' --max-turns 1", timeout=60)
# Pipe multiple files
terminal(command="cat src/*.py | claude -p 'Find all TODO comments' --max-turns 1", timeout=60)
# Pipe command output
terminal(command="git diff HEAD~3 | claude -p 'Summarize these changes' --max-turns 1", timeout=60)
```
### JSON Schema for Structured Extraction
```
terminal(command="claude -p 'List all functions in src/' --output-format json --json-schema '{\"type\":\"object\",\"properties\":{\"functions\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"functions\"]}' --max-turns 5", workdir="/project", timeout=90)
```
Parse `structured_output` from the JSON result. Claude validates output against the schema before returning.
### Session Continuation
```
# Start a task
terminal(command="claude -p 'Start refactoring the database layer' --output-format json --max-turns 10 > /tmp/session.json", workdir="/project", timeout=180)
# Resume with session ID
terminal(command="claude -p 'Continue and add connection pooling' --resume $(cat /tmp/session.json | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"session_id\"])') --max-turns 5", workdir="/project", timeout=120)
# Or resume the most recent session in the same directory
terminal(command="claude -p 'What did you do last time?' --continue --max-turns 1", workdir="/project", timeout=30)
# Fork a session (new ID, keeps history)
terminal(command="claude -p 'Try a different approach' --resume <id> --fork-session --max-turns 10", workdir="/project", timeout=120)
```
### Bare Mode for CI/Scripting
```
terminal(command="claude --bare -p 'Run all tests and report failures' --allowedTools 'Read,Bash' --max-turns 10", workdir="/project", timeout=180)
```
`--bare` skips hooks, plugins, MCP discovery, and CLAUDE.md loading. Fastest startup. Requires `ANTHROPIC_API_KEY` (skips OAuth).
To selectively load context in bare mode:
| To load | Flag |
|---------|------|
| System prompt additions | `--append-system-prompt "text"` or `--append-system-prompt-file path` |
| Settings | `--settings <file-or-json>` |
| MCP servers | `--mcp-config <file-or-json>` |
| Custom agents | `--agents '<json>'` |
### Fallback Model for Overload
```
terminal(command="claude -p 'task' --fallback-model haiku --max-turns 5", timeout=90)
```
Automatically falls back to the specified model when the default is overloaded (print mode only).
## Complete CLI Flags Reference
### Session & Environment
| Flag | Effect |
|------|--------|
| `-p, --print` | Non-interactive one-shot mode (exits when done) |
| `-c, --continue` | Resume most recent conversation in current directory |
| `-r, --resume <id>` | Resume specific session by ID or name (interactive picker if no ID) |
| `--fork-session` | When resuming, create new session ID instead of reusing original |
| `--session-id <uuid>` | Use a specific UUID for the conversation |
| `--no-session-persistence` | Don't save session to disk (print mode only) |
| `--add-dir <paths...>` | Grant Claude access to additional working directories |
| `-w, --worktree [name]` | Run in an isolated git worktree at `.claude/worktrees/<name>` |
| `--tmux` | Create a tmux session for the worktree (requires `--worktree`) |
| `--ide` | Auto-connect to a valid IDE on startup |
| `--chrome` / `--no-chrome` | Enable/disable Chrome browser integration for web testing |
| `--from-pr [number]` | Resume session linked to a specific GitHub PR |
| `--file <specs...>` | File resources to download at startup (format: `file_id:relative_path`) |
### Model & Performance
| Flag | Effect |
|------|--------|
| `--model <alias>` | Model selection: `sonnet`, `opus`, `haiku`, or full name like `claude-sonnet-4-6` |
| `--effort <level>` | Reasoning depth: `low`, `medium`, `high`, `max`, `auto` | Both |
| `--max-turns <n>` | Limit agentic loops (print mode only; prevents runaway) |
| `--max-budget-usd <n>` | Cap API spend in dollars (print mode only) |
| `--fallback-model <model>` | Auto-fallback when default model is overloaded (print mode only) |
| `--betas <betas...>` | Beta headers to include in API requests (API key users only) |
### Permission & Safety
| Flag | Effect |
|------|--------|
| `--dangerously-skip-permissions` | Auto-approve ALL tool use (file writes, bash, network, etc.) |
| `--allow-dangerously-skip-permissions` | Enable bypass as an *option* without enabling it by default |
| `--permission-mode <mode>` | `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions` |
| `--allowedTools <tools...>` | Whitelist specific tools (comma or space-separated) |
| `--disallowedTools <tools...>` | Blacklist specific tools |
| `--tools <tools...>` | Override built-in tool set (`""` = none, `"default"` = all, or tool names) |
### Output & Input Format
| Flag | Effect |
|------|--------|
| `--output-format <fmt>` | `text` (default), `json` (single result object), `stream-json` (newline-delimited) |
| `--input-format <fmt>` | `text` (default) or `stream-json` (real-time streaming input) |
| `--json-schema <schema>` | Force structured JSON output matching a schema |
| `--verbose` | Full turn-by-turn output |
| `--include-partial-messages` | Include partial message chunks as they arrive (stream-json + print) |
| `--replay-user-messages` | Re-emit user messages on stdout (stream-json bidirectional) |
### System Prompt & Context
| Flag | Effect |
|------|--------|
| `--append-system-prompt <text>` | **Add** to the default system prompt (preserves built-in capabilities) |
| `--append-system-prompt-file <path>` | **Add** file contents to the default system prompt |
| `--system-prompt <text>` | **Replace** the entire system prompt (use --append instead usually) |
| `--system-prompt-file <path>` | **Replace** the system prompt with file contents |
| `--bare` | Skip hooks, plugins, MCP discovery, CLAUDE.md, OAuth (fastest startup) |
| `--agents '<json>'` | Define custom subagents dynamically as JSON |
| `--mcp-config <path>` | Load MCP servers from JSON file (repeatable) |
| `--strict-mcp-config` | Only use MCP servers from `--mcp-config`, ignoring all other MCP configs |
| `--settings <file-or-json>` | Load additional settings from a JSON file or inline JSON |
| `--setting-sources <sources>` | Comma-separated sources to load: `user`, `project`, `local` |
| `--plugin-dir <paths...>` | Load plugins from directories for this session only |
| `--disable-slash-commands` | Disable all skills/slash commands |
### Debugging
| Flag | Effect |
|------|--------|
| `-d, --debug [filter]` | Enable debug logging with optional category filter (e.g., `"api,hooks"`, `"!1p,!file"`) |
| `--debug-file <path>` | Write debug logs to file (implicitly enables debug mode) |
### Agent Teams
| Flag | Effect |
|------|--------|
| `--teammate-mode <mode>` | How agent teams display: `auto`, `in-process`, or `tmux` |
| `--brief` | Enable `SendUserMessage` tool for agent-to-user communication |
### Tool Name Syntax for --allowedTools / --disallowedTools
```
Read # All file reading
Edit # File editing (existing files)
Write # File creation (new files)
Bash # All shell commands
Bash(git *) # Only git commands
Bash(git commit *) # Only git commit commands
Bash(npm run lint:*) # Pattern matching with wildcards
WebSearch # Web search capability
WebFetch # Web page fetching
mcp__<server>__<tool> # Specific MCP tool
```
## Settings & Configuration
### Settings Hierarchy (highest to lowest priority)
1. **CLI flags** — override everything
2. **Local project:** `.claude/settings.local.json` (personal, gitignored)
3. **Project:** `.claude/settings.json` (shared, git-tracked)
4. **User:** `~/.claude/settings.json` (global)
### Permissions in Settings
```json
{
"permissions": {
"allow": ["Bash(npm run lint:*)", "WebSearch", "Read"],
"ask": ["Write(*.ts)", "Bash(git push*)"],
"deny": ["Read(.env)", "Bash(rm -rf *)"]
}
}
```
### Memory Files (CLAUDE.md) Hierarchy
1. **Global:** `~/.claude/CLAUDE.md` — applies to all projects
2. **Project:** `./CLAUDE.md` — project-specific context (git-tracked)
3. **Local:** `.claude/CLAUDE.local.md` — personal project overrides (gitignored)
Use the `#` prefix in interactive mode to quickly add to memory: `# Always use 2-space indentation`.
## Interactive Session: Slash Commands
### Session & Context
| Command | Purpose |
|---------|---------|
| `/help` | Show all commands (including custom and MCP commands) |
| `/compact [focus]` | Compress context to save tokens; CLAUDE.md survives compaction. E.g., `/compact focus on auth logic` |
| `/clear` | Wipe conversation history for a fresh start |
| `/context` | Visualize context usage as a colored grid with optimization tips |
| `/cost` | View token usage with per-model and cache-hit breakdowns |
| `/resume` | Switch to or resume a different session |
| `/rewind` | Revert to a previous checkpoint in conversation or code |
| `/btw <question>` | Ask a side question without adding to context cost |
| `/status` | Show version, connectivity, and session info |
| `/todos` | List tracked action items from the conversation |
| `/exit` or `Ctrl+D` | End session |
### Development & Review
| Command | Purpose |
|---------|---------|
| `/review` | Request code review of current changes |
| `/security-review` | Perform security analysis of current changes |
| `/plan [description]` | Enter Plan mode with auto-start for task planning |
| `/loop [interval]` | Schedule recurring tasks within the session |
| `/batch` | Auto-create worktrees for large parallel changes (5-30 worktrees) |
### Configuration & Tools
| Command | Purpose |
|---------|---------|
| `/model [model]` | Switch models mid-session (use arrow keys to adjust effort) |
| `/effort [level]` | Set reasoning effort: `low`, `medium`, `high`, `max`, or `auto` |
| `/init` | Create a CLAUDE.md file for project memory |
| `/memory` | Open CLAUDE.md for editing |
| `/config` | Open interactive settings configuration |
| `/permissions` | View/update tool permissions |
| `/agents` | Manage specialized subagents |
| `/mcp` | Interactive UI to manage MCP servers |
| `/add-dir` | Add additional working directories (useful for monorepos) |
| `/usage` | Show plan limits and rate limit status |
| `/voice` | Enable push-to-talk voice mode (20 languages; hold Space to record, release to send) |
| `/release-notes` | Interactive picker for version release notes |
### Custom Slash Commands
Create `.claude/commands/<name>.md` (project-shared) or `~/.claude/commands/<name>.md` (personal):
```markdown
# .claude/commands/deploy.md
Run the deploy pipeline:
1. Run all tests
2. Build the Docker image
3. Push to registry
4. Update the $ARGUMENTS environment (default: staging)
```
Usage: `/deploy production``$ARGUMENTS` is replaced with the user's input.
### Skills (Natural Language Invocation)
Unlike slash commands (manually invoked), skills in `.claude/skills/` are markdown guides that Claude invokes automatically via natural language when the task matches:
```markdown
# .claude/skills/database-migration.md
When asked to create or modify database migrations:
1. Use Alembic for migration generation
2. Always create a rollback function
3. Test migrations against a local database copy
```
## Interactive Session: Keyboard Shortcuts
### General Controls
| Key | Action |
|-----|--------|
| `Ctrl+C` | Cancel current input or generation |
| `Ctrl+D` | Exit session |
| `Ctrl+R` | Reverse search command history |
| `Ctrl+B` | Background a running task |
| `Ctrl+V` | Paste image into conversation |
| `Ctrl+O` | Transcript mode — see Claude's thinking process |
| `Ctrl+G` or `Ctrl+X Ctrl+E` | Open prompt in external editor |
| `Esc Esc` | Rewind conversation or code state / summarize |
### Mode Toggles
| Key | Action |
|-----|--------|
| `Shift+Tab` | Cycle permission modes (Normal → Auto-Accept → Plan) |
| `Alt+P` | Switch model |
| `Alt+T` | Toggle thinking mode |
| `Alt+O` | Toggle Fast Mode |
### Multiline Input
| Key | Action |
|-----|--------|
| `\` + `Enter` | Quick newline |
| `Shift+Enter` | Newline (alternative) |
| `Ctrl+J` | Newline (alternative) |
### Input Prefixes
| Prefix | Action |
|--------|--------|
| `!` | Execute bash directly, bypassing AI (e.g., `!npm test`). Use `!` alone to toggle shell mode. |
| `@` | Reference files/directories with autocomplete (e.g., `@./src/api/`) |
| `#` | Quick add to CLAUDE.md memory (e.g., `# Use 2-space indentation`) |
| `/` | Slash commands |
### Pro Tip: "ultrathink"
Use the keyword "ultrathink" in your prompt for maximum reasoning effort on a specific turn. This triggers the deepest thinking mode regardless of the current `/effort` setting.
## PR Review Pattern
### Quick Review (Print Mode)
```
terminal(command="cd /path/to/repo && git diff main...feature-branch | claude -p 'Review this diff for bugs, security issues, and style problems. Be thorough.' --max-turns 1", timeout=60)
```
### Deep Review (Interactive + Worktree)
```
terminal(command="tmux new-session -d -s review -x 140 -y 40")
terminal(command="tmux send-keys -t review 'cd /path/to/repo && claude -w pr-review' Enter")
terminal(command="sleep 5 && tmux send-keys -t review Enter") # Trust dialog
terminal(command="sleep 2 && tmux send-keys -t review 'Review all changes vs main. Check for bugs, security issues, race conditions, and missing tests.' Enter")
terminal(command="sleep 30 && tmux capture-pane -t review -p -S -60")
```
### PR Review from Number
```
terminal(command="claude -p 'Review this PR thoroughly' --from-pr 42 --max-turns 10", workdir="/path/to/repo", timeout=120)
```
### Claude Worktree with tmux
```
terminal(command="claude -w feature-x --tmux", workdir="/path/to/repo")
```
Creates an isolated git worktree at `.claude/worktrees/feature-x` AND a tmux session for it. Uses iTerm2 native panes when available; add `--tmux=classic` for traditional tmux.
## Parallel Claude Instances
Run multiple independent Claude tasks simultaneously:
```
# Task 1: Fix backend
terminal(command="tmux new-session -d -s task1 -x 140 -y 40 && tmux send-keys -t task1 'cd ~/project && claude -p \"Fix the auth bug in src/auth.py\" --allowedTools \"Read,Edit\" --max-turns 10' Enter")
# Task 2: Write tests
terminal(command="tmux new-session -d -s task2 -x 140 -y 40 && tmux send-keys -t task2 'cd ~/project && claude -p \"Write integration tests for the API endpoints\" --allowedTools \"Read,Write,Bash\" --max-turns 15' Enter")
# Task 3: Update docs
terminal(command="tmux new-session -d -s task3 -x 140 -y 40 && tmux send-keys -t task3 'cd ~/project && claude -p \"Update README.md with the new API endpoints\" --allowedTools \"Read,Edit\" --max-turns 5' Enter")
# Monitor all
terminal(command="sleep 30 && for s in task1 task2 task3; do echo '=== '$s' ==='; tmux capture-pane -t $s -p -S -5 2>/dev/null; done")
```
## CLAUDE.md — Project Context File
Claude Code auto-loads `CLAUDE.md` from the project root. Use it to persist project context:
```markdown
# Project: My API
## Architecture
- FastAPI backend with SQLAlchemy ORM
- PostgreSQL database, Redis cache
- pytest for testing with 90% coverage target
## Key Commands
- `make test` — run full test suite
- `make lint` — ruff + mypy
- `make dev` — start dev server on :8000
## Code Standards
- Type hints on all public functions
- Docstrings in Google style
- 2-space indentation for YAML, 4-space for Python
- No wildcard imports
```
**Be specific.** Instead of "Write good code", use "Use 2-space indentation for JS" or "Name test files with `.test.ts` suffix." Specific instructions save correction cycles.
### Rules Directory (Modular CLAUDE.md)
For projects with many rules, use the rules directory instead of one massive CLAUDE.md:
- **Project rules:** `.claude/rules/*.md` — team-shared, git-tracked
- **User rules:** `~/.claude/rules/*.md` — personal, global
Each `.md` file in the rules directory is loaded as additional context. This is cleaner than cramming everything into a single CLAUDE.md.
### Auto-Memory
Claude automatically stores learned project context in `~/.claude/projects/<project>/memory/`.
- **Limit:** 25KB or 200 lines per project
- This is separate from CLAUDE.md — it's Claude's own notes about the project, accumulated across sessions
## Custom Subagents
Define specialized agents in `.claude/agents/` (project), `~/.claude/agents/` (personal), or via `--agents` CLI flag (session):
### Agent Location Priority
1. `.claude/agents/` — project-level, team-shared
2. `--agents` CLI flag — session-specific, dynamic
3. `~/.claude/agents/` — user-level, personal
### Creating an Agent
```markdown
# .claude/agents/security-reviewer.md
---
name: security-reviewer
description: Security-focused code review
model: opus
tools: [Read, Bash]
---
You are a senior security engineer. Review code for:
- Injection vulnerabilities (SQL, XSS, command injection)
- Authentication/authorization flaws
- Secrets in code
- Unsafe deserialization
```
Invoke via: `@security-reviewer review the auth module`
### Dynamic Agents via CLI
```
terminal(command="claude --agents '{\"reviewer\": {\"description\": \"Reviews code\", \"prompt\": \"You are a code reviewer focused on performance\"}}' -p 'Use @reviewer to check auth.py'", timeout=120)
```
Claude can orchestrate multiple agents: "Use @db-expert to optimize queries, then @security to audit the changes."
## Hooks — Automation on Events
Configure in `.claude/settings.json` (project) or `~/.claude/settings.json` (global):
```json
{
"hooks": {
"PostToolUse": [{
"matcher": "Write(*.py)",
"hooks": [{"type": "command", "command": "ruff check --fix $CLAUDE_FILE_PATHS"}]
}],
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'rm -rf'; then echo 'Blocked!' && exit 2; fi"}]
}],
"Stop": [{
"hooks": [{"type": "command", "command": "echo 'Claude finished a response' >> /tmp/claude-activity.log"}]
}]
}
}
```
### All 8 Hook Types
| Hook | When it fires | Common use |
|------|--------------|------------|
| `UserPromptSubmit` | Before Claude processes a user prompt | Input validation, logging |
| `PreToolUse` | Before tool execution | Security gates, block dangerous commands (exit 2 = block) |
| `PostToolUse` | After a tool finishes | Auto-format code, run linters |
| `Notification` | On permission requests or input waits | Desktop notifications, alerts |
| `Stop` | When Claude finishes a response | Completion logging, status updates |
| `SubagentStop` | When a subagent completes | Agent orchestration |
| `PreCompact` | Before context memory is cleared | Backup session transcripts |
| `SessionStart` | When a session begins | Load dev context (e.g., `git status`) |
### Hook Environment Variables
| Variable | Content |
|----------|---------|
| `CLAUDE_PROJECT_DIR` | Current project path |
| `CLAUDE_FILE_PATHS` | Files being modified |
| `CLAUDE_TOOL_INPUT` | Tool parameters as JSON |
### Security Hook Examples
```json
{
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'rm -rf|git push.*--force|:(){ :|:& };:'; then echo 'Dangerous command blocked!' && exit 2; fi"}]
}]
}
```
## MCP Integration
Add external tool servers for databases, APIs, and services:
```
# GitHub integration
terminal(command="claude mcp add -s user github -- npx @modelcontextprotocol/server-github", timeout=30)
# PostgreSQL queries
terminal(command="claude mcp add -s local postgres -- npx @anthropic-ai/server-postgres --connection-string postgresql://localhost/mydb", timeout=30)
# Puppeteer for web testing
terminal(command="claude mcp add puppeteer -- npx @anthropic-ai/server-puppeteer", timeout=30)
```
### MCP Scopes
| Flag | Scope | Storage |
|------|-------|---------|
| `-s user` | Global (all projects) | `~/.claude.json` |
| `-s local` | This project (personal) | `.claude/settings.local.json` (gitignored) |
| `-s project` | This project (team-shared) | `.claude/settings.json` (git-tracked) |
### MCP in Print/CI Mode
```
terminal(command="claude --bare -p 'Query database' --mcp-config mcp-servers.json --strict-mcp-config", timeout=60)
```
`--strict-mcp-config` ignores all MCP servers except those from `--mcp-config`.
Reference MCP resources in chat: `@github:issue://123`
### MCP Limits & Tuning
- **Tool descriptions:** 2KB cap per server for tool descriptions and server instructions
- **Result size:** Default capped; use `maxResultSizeChars` annotation to allow up to **500K** characters for large outputs
- **Output tokens:** `export MAX_MCP_OUTPUT_TOKENS=50000` — cap output from MCP servers to prevent context flooding
- **Transports:** `stdio` (local process), `http` (remote), `sse` (server-sent events)
## Monitoring Interactive Sessions
### Reading the TUI Status
```
# Periodic capture to check if Claude is still working or waiting for input
terminal(command="tmux capture-pane -t dev -p -S -10")
```
Look for these indicators:
- `❯` at bottom = waiting for your input (Claude is done or asking a question)
- `●` lines = Claude is actively using tools (reading, writing, running commands)
- `⏵⏵ bypass permissions on` = status bar showing permissions mode
- `◐ medium · /effort` = current effort level in status bar
- `ctrl+o to expand` = tool output was truncated (can be expanded interactively)
### Context Window Health
Use `/context` in interactive mode to see a colored grid of context usage. Key thresholds:
- **< 70%** — Normal operation, full precision
- **70-85%** — Precision starts dropping, consider `/compact`
- **> 85%** — Hallucination risk spikes significantly, use `/compact` or `/clear`
## Environment Variables
| Variable | Effect |
|----------|--------|
| `ANTHROPIC_API_KEY` | API key for authentication (alternative to OAuth) |
| `CLAUDE_CODE_EFFORT_LEVEL` | Default effort: `low`, `medium`, `high`, `max`, or `auto` |
| `MAX_THINKING_TOKENS` | Cap thinking tokens (set to `0` to disable thinking entirely) |
| `MAX_MCP_OUTPUT_TOKENS` | Cap output from MCP servers (default varies; set e.g., `50000`) |
| `CLAUDE_CODE_NO_FLICKER=1` | Enable alt-screen rendering to eliminate terminal flicker |
| `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` | Strip credentials from sub-processes for security |
## Cost & Performance Tips
1. **Use `--max-turns`** in print mode to prevent runaway loops. Start with 5-10 for most tasks.
2. **Use `--max-budget-usd`** for cost caps. Note: minimum ~$0.05 for system prompt cache creation.
3. **Use `--effort low`** for simple tasks (faster, cheaper). `high` or `max` for complex reasoning.
4. **Use `--bare`** for CI/scripting to skip plugin/hook discovery overhead.
5. **Use `--allowedTools`** to restrict to only what's needed (e.g., `Read` only for reviews).
6. **Use `/compact`** in interactive sessions when context gets large.
7. **Pipe input** instead of having Claude read files when you just need analysis of known content.
8. **Use `--model haiku`** for simple tasks (cheaper) and `--model opus` for complex multi-step work.
9. **Use `--fallback-model haiku`** in print mode to gracefully handle model overload.
10. **Start new sessions for distinct tasks** — sessions last 5 hours; fresh context is more efficient.
11. **Use `--no-session-persistence`** in CI to avoid accumulating saved sessions on disk.
## Pitfalls & Gotchas
1. **Interactive mode REQUIRES tmux** — Claude Code is a full TUI app. Using `pty=true` alone in Hermes terminal works but tmux gives you `capture-pane` for monitoring and `send-keys` for input, which is essential for orchestration.
2. **`--dangerously-skip-permissions` dialog defaults to "No, exit"** — you must send Down then Enter to accept. Print mode (`-p`) skips this entirely.
3. **`--max-budget-usd` minimum is ~$0.05** — system prompt cache creation alone costs this much. Setting lower will error immediately.
4. **`--max-turns` is print-mode only** — ignored in interactive sessions.
5. **Claude may use `python` instead of `python3`** — on systems without a `python` symlink, Claude's bash commands will fail on first try but it self-corrects.
6. **Session resumption requires same directory**`--continue` finds the most recent session for the current working directory.
7. **`--json-schema` needs enough `--max-turns`** — Claude must read files before producing structured output, which takes multiple turns.
8. **Trust dialog only appears once per directory** — first-time only, then cached.
9. **Background tmux sessions persist** — always clean up with `tmux kill-session -t <name>` when done.
10. **Slash commands (like `/commit`) only work in interactive mode** — in `-p` mode, describe the task in natural language instead.
11. **`--bare` skips OAuth** — requires `ANTHROPIC_API_KEY` env var or an `apiKeyHelper` in settings.
12. **Context degradation is real** — AI output quality measurably degrades above 70% context window usage. Monitor with `/context` and proactively `/compact`.
## Rules for Hermes Agents
1. **Prefer print mode (`-p`) for single tasks** — cleaner, no dialog handling, structured output
2. **Use tmux for multi-turn interactive work** — the only reliable way to orchestrate the TUI
3. **Always set `workdir`** — keep Claude focused on the right project directory
4. **Set `--max-turns` in print mode** — prevents infinite loops and runaway costs
5. **Monitor tmux sessions** — use `tmux capture-pane -t <session> -p -S -50` to check progress
6. **Look for the `❯` prompt** — indicates Claude is waiting for input (done or asking a question)
7. **Clean up tmux sessions** — kill them when done to avoid resource leaks
8. **Report results to user** — after completion, summarize what Claude did and what changed
9. **Don't kill slow sessions** — Claude may be doing multi-step work; check progress instead
10. **Use `--allowedTools`** — restrict capabilities to what the task actually needs
@@ -0,0 +1,130 @@
---
name: codex
description: "Delegate coding to OpenAI Codex CLI (features, PRs)."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [Coding-Agent, Codex, OpenAI, Code-Review, Refactoring]
related_skills: [claude-code, hermes-agent]
---
# Codex CLI
Delegate coding tasks to [Codex](https://github.com/openai/codex) via the Hermes terminal. Codex is OpenAI's autonomous coding agent CLI.
## When to use
- Building features
- Refactoring
- PR reviews
- Batch issue fixing
Requires the codex CLI and a git repository.
## Prerequisites
- Codex installed: `npm install -g @openai/codex`
- OpenAI auth configured: either `OPENAI_API_KEY` or Codex OAuth credentials
from the Codex CLI login flow
- **Must run inside a git repository** — Codex refuses to run outside one
- Use `pty=true` in terminal calls — Codex is an interactive terminal app
For Hermes itself, `model.provider: openai-codex` uses Hermes-managed Codex
OAuth from `~/.hermes/auth.json` after `hermes auth add openai-codex`. For the
standalone Codex CLI, a valid CLI OAuth session may live under
`~/.codex/auth.json`; do not treat a missing `OPENAI_API_KEY` alone as proof
that Codex auth is missing.
## One-Shot Tasks
```
terminal(command="codex exec 'Add dark mode toggle to settings'", workdir="~/project", pty=true)
```
For scratch work (Codex needs a git repo):
```
terminal(command="cd $(mktemp -d) && git init && codex exec 'Build a snake game in Python'", pty=true)
```
## Background Mode (Long Tasks)
```
# Start in background with PTY
terminal(command="codex exec --full-auto 'Refactor the auth module'", workdir="~/project", background=true, pty=true)
# Returns session_id
# Monitor progress
process(action="poll", session_id="<id>")
process(action="log", session_id="<id>")
# Send input if Codex asks a question
process(action="submit", session_id="<id>", data="yes")
# Kill if needed
process(action="kill", session_id="<id>")
```
## Key Flags
| Flag | Effect |
|------|--------|
| `exec "prompt"` | One-shot execution, exits when done |
| `--full-auto` | Sandboxed but auto-approves file changes in workspace |
| `--yolo` | No sandbox, no approvals (fastest, most dangerous) |
## PR Reviews
Clone to a temp directory for safe review:
```
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && gh pr checkout 42 && codex review --base origin/main", pty=true)
```
## Parallel Issue Fixing with Worktrees
```
# Create worktrees
terminal(command="git worktree add -b fix/issue-78 /tmp/issue-78 main", workdir="~/project")
terminal(command="git worktree add -b fix/issue-99 /tmp/issue-99 main", workdir="~/project")
# Launch Codex in each
terminal(command="codex --yolo exec 'Fix issue #78: <description>. Commit when done.'", workdir="/tmp/issue-78", background=true, pty=true)
terminal(command="codex --yolo exec 'Fix issue #99: <description>. Commit when done.'", workdir="/tmp/issue-99", background=true, pty=true)
# Monitor
process(action="list")
# After completion, push and create PRs
terminal(command="cd /tmp/issue-78 && git push -u origin fix/issue-78")
terminal(command="gh pr create --repo user/repo --head fix/issue-78 --title 'fix: ...' --body '...'")
# Cleanup
terminal(command="git worktree remove /tmp/issue-78", workdir="~/project")
```
## Batch PR Reviews
```
# Fetch all PR refs
terminal(command="git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*'", workdir="~/project")
# Review multiple PRs in parallel
terminal(command="codex exec 'Review PR #86. git diff origin/main...origin/pr/86'", workdir="~/project", background=true, pty=true)
terminal(command="codex exec 'Review PR #87. git diff origin/main...origin/pr/87'", workdir="~/project", background=true, pty=true)
# Post results
terminal(command="gh pr comment 86 --body '<review>'", workdir="~/project")
```
## Rules
1. **Always use `pty=true`** — Codex is an interactive terminal app and hangs without a PTY
2. **Git repo required** — Codex won't run outside a git directory. Use `mktemp -d && git init` for scratch
3. **Use `exec` for one-shots**`codex exec "prompt"` runs and exits cleanly
4. **`--full-auto` for building** — auto-approves changes within the sandbox
5. **Background for long tasks** — use `background=true` and monitor with `process` tool
6. **Don't interfere** — monitor with `poll`/`log`, be patient with long-running tasks
7. **Parallel is fine** — run multiple Codex processes at once for batch work
@@ -0,0 +1,219 @@
---
name: opencode
description: "Delegate coding to OpenCode CLI (features, PR review)."
version: 1.2.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [Coding-Agent, OpenCode, Autonomous, Refactoring, Code-Review]
related_skills: [claude-code, codex, hermes-agent]
---
# OpenCode CLI
Use [OpenCode](https://opencode.ai) as an autonomous coding worker orchestrated by Hermes terminal/process tools. OpenCode is a provider-agnostic, open-source AI coding agent with a TUI and CLI.
## When to Use
- User explicitly asks to use OpenCode
- You want an external coding agent to implement/refactor/review code
- You need long-running coding sessions with progress checks
- You want parallel task execution in isolated workdirs/worktrees
## Prerequisites
- OpenCode installed: `npm i -g opencode-ai@latest` or `brew install anomalyco/tap/opencode`
- Auth configured: `opencode auth login` or set provider env vars (OPENROUTER_API_KEY, etc.)
- Verify: `opencode auth list` should show at least one provider
- Git repository for code tasks (recommended)
- `pty=true` for interactive TUI sessions
## Binary Resolution (Important)
Shell environments may resolve different OpenCode binaries. If behavior differs between your terminal and Hermes, check:
```
terminal(command="which -a opencode")
terminal(command="opencode --version")
```
If needed, pin an explicit binary path:
```
terminal(command="$HOME/.opencode/bin/opencode run '...'", workdir="~/project", pty=true)
```
## One-Shot Tasks
Use `opencode run` for bounded, non-interactive tasks:
```
terminal(command="opencode run 'Add retry logic to API calls and update tests'", workdir="~/project")
```
Attach context files with `-f`:
```
terminal(command="opencode run 'Review this config for security issues' -f config.yaml -f .env.example", workdir="~/project")
```
Show model thinking with `--thinking`:
```
terminal(command="opencode run 'Debug why tests fail in CI' --thinking", workdir="~/project")
```
Force a specific model:
```
terminal(command="opencode run 'Refactor auth module' --model openrouter/anthropic/claude-sonnet-4", workdir="~/project")
```
## Interactive Sessions (Background)
For iterative work requiring multiple exchanges, start the TUI in background:
```
terminal(command="opencode", workdir="~/project", background=true, pty=true)
# Returns session_id
# Send a prompt
process(action="submit", session_id="<id>", data="Implement OAuth refresh flow and add tests")
# Monitor progress
process(action="poll", session_id="<id>")
process(action="log", session_id="<id>")
# Send follow-up input
process(action="submit", session_id="<id>", data="Now add error handling for token expiry")
# Exit cleanly — Ctrl+C
process(action="write", session_id="<id>", data="\x03")
# Or just kill the process
process(action="kill", session_id="<id>")
```
**Important:** Do NOT use `/exit` — it is not a valid OpenCode command and will open an agent selector dialog instead. Use Ctrl+C (`\x03`) or `process(action="kill")` to exit.
### TUI Keybindings
| Key | Action |
|-----|--------|
| `Enter` | Submit message (press twice if needed) |
| `Tab` | Switch between agents (build/plan) |
| `Ctrl+P` | Open command palette |
| `Ctrl+X L` | Switch session |
| `Ctrl+X M` | Switch model |
| `Ctrl+X N` | New session |
| `Ctrl+X E` | Open editor |
| `Ctrl+C` | Exit OpenCode |
### Resuming Sessions
After exiting, OpenCode prints a session ID. Resume with:
```
terminal(command="opencode -c", workdir="~/project", background=true, pty=true) # Continue last session
terminal(command="opencode -s ses_abc123", workdir="~/project", background=true, pty=true) # Specific session
```
## Common Flags
| Flag | Use |
|------|-----|
| `run 'prompt'` | One-shot execution and exit |
| `--continue` / `-c` | Continue the last OpenCode session |
| `--session <id>` / `-s` | Continue a specific session |
| `--agent <name>` | Choose OpenCode agent (build or plan) |
| `--model provider/model` | Force specific model |
| `--format json` | Machine-readable output/events |
| `--file <path>` / `-f` | Attach file(s) to the message |
| `--thinking` | Show model thinking blocks |
| `--variant <level>` | Reasoning effort (high, max, minimal) |
| `--title <name>` | Name the session |
| `--attach <url>` | Connect to a running opencode server |
## Procedure
1. Verify tool readiness:
- `terminal(command="opencode --version")`
- `terminal(command="opencode auth list")`
2. For bounded tasks, use `opencode run '...'` (no pty needed).
3. For iterative tasks, start `opencode` with `background=true, pty=true`.
4. Monitor long tasks with `process(action="poll"|"log")`.
5. If OpenCode asks for input, respond via `process(action="submit", ...)`.
6. Exit with `process(action="write", data="\x03")` or `process(action="kill")`.
7. Summarize file changes, test results, and next steps back to user.
## PR Review Workflow
OpenCode has a built-in PR command:
```
terminal(command="opencode pr 42", workdir="~/project", pty=true)
```
Or review in a temporary clone for isolation:
```
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && opencode run 'Review this PR vs main. Report bugs, security risks, test gaps, and style issues.' -f $(git diff origin/main --name-only | head -20 | tr '\n' ' ')", pty=true)
```
## Parallel Work Pattern
Use separate workdirs/worktrees to avoid collisions:
```
terminal(command="opencode run 'Fix issue #101 and commit'", workdir="/tmp/issue-101", background=true, pty=true)
terminal(command="opencode run 'Add parser regression tests and commit'", workdir="/tmp/issue-102", background=true, pty=true)
process(action="list")
```
## Session & Cost Management
List past sessions:
```
terminal(command="opencode session list")
```
Check token usage and costs:
```
terminal(command="opencode stats")
terminal(command="opencode stats --days 7 --models anthropic/claude-sonnet-4")
```
## Pitfalls
- Interactive `opencode` (TUI) sessions require `pty=true`. The `opencode run` command does NOT need pty.
- `/exit` is NOT a valid command — it opens an agent selector. Use Ctrl+C to exit the TUI.
- PATH mismatch can select the wrong OpenCode binary/model config.
- If OpenCode appears stuck, inspect logs before killing:
- `process(action="log", session_id="<id>")`
- Avoid sharing one working directory across parallel OpenCode sessions.
- Enter may need to be pressed twice to submit in the TUI (once to finalize text, once to send).
## Verification
Smoke test:
```
terminal(command="opencode run 'Respond with exactly: OPENCODE_SMOKE_OK'")
```
Success criteria:
- Output includes `OPENCODE_SMOKE_OK`
- Command exits without provider/model errors
- For code tasks: expected files changed and tests pass
## Rules
1. Prefer `opencode run` for one-shot automation — it's simpler and doesn't need pty.
2. Use interactive background mode only when iteration is needed.
3. Always scope OpenCode sessions to a single repo/workdir.
4. For long tasks, provide progress updates from `process` logs.
5. Report concrete outcomes (files changed, tests, remaining risks).
6. Exit interactive sessions with Ctrl+C or kill, never `/exit`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
# Native MCP Client
Hermes Agent has a built-in MCP client that connects to MCP servers at startup, discovers their tools, and makes them available as first-class tools the agent can call directly. No bridge CLI needed -- tools from MCP servers appear alongside built-in tools like `terminal`, `read_file`, etc.
## When to Use
Use this whenever you want to:
- Connect to MCP servers and use their tools from within Hermes Agent
- Add external capabilities (filesystem access, GitHub, databases, APIs) via MCP
- Run local stdio-based MCP servers (npx, uvx, or any command)
- Connect to remote HTTP/StreamableHTTP MCP servers
- Have MCP tools auto-discovered and available in every conversation
For ad-hoc, one-off MCP tool calls from the terminal without configuring anything, see the `mcporter` skill instead.
## Prerequisites
- **mcp Python package** -- optional dependency; install with `pip install mcp`. If not installed, MCP support is silently disabled.
- **Node.js** -- required for `npx`-based MCP servers (most community servers)
- **uv** -- required for `uvx`-based MCP servers (Python-based servers)
Install the MCP SDK:
```bash
pip install mcp
# or, if using uv:
uv pip install mcp
```
## Quick Start
Add MCP servers to `~/.hermes/config.yaml` under the `mcp_servers` key:
```yaml
mcp_servers:
time:
command: "uvx"
args: ["mcp-server-time"]
```
Restart Hermes Agent. On startup it will:
1. Connect to the server
2. Discover available tools
3. Register them with the prefix `mcp_time_*`
4. Inject them into all platform toolsets
You can then use the tools naturally -- just ask the agent to get the current time.
## Configuration Reference
Each entry under `mcp_servers` is a server name mapped to its config. There are two transport types: **stdio** (command-based) and **HTTP** (url-based).
### Stdio Transport (command + args)
```yaml
mcp_servers:
server_name:
command: "npx" # (required) executable to run
args: ["-y", "pkg-name"] # (optional) command arguments, default: []
env: # (optional) environment variables for the subprocess
SOME_API_KEY: "value"
timeout: 120 # (optional) per-tool-call timeout in seconds, default: 120
connect_timeout: 60 # (optional) initial connection timeout in seconds, default: 60
```
### HTTP Transport (url)
```yaml
mcp_servers:
server_name:
url: "https://my-server.example.com/mcp" # (required) server URL
headers: # (optional) HTTP headers
Authorization: "Bearer sk-..."
timeout: 180 # (optional) per-tool-call timeout in seconds, default: 120
connect_timeout: 60 # (optional) initial connection timeout in seconds, default: 60
```
### All Config Options
| Option | Type | Default | Description |
|-------------------|--------|---------|---------------------------------------------------|
| `command` | string | -- | Executable to run (stdio transport, required) |
| `args` | list | `[]` | Arguments passed to the command |
| `env` | dict | `{}` | Extra environment variables for the subprocess |
| `url` | string | -- | Server URL (HTTP transport, required) |
| `headers` | dict | `{}` | HTTP headers sent with every request |
| `timeout` | int | `120` | Per-tool-call timeout in seconds |
| `connect_timeout` | int | `60` | Timeout for initial connection and discovery |
Note: A server config must have either `command` (stdio) or `url` (HTTP), not both.
## How It Works
### Startup Discovery
When Hermes Agent starts, `discover_mcp_tools()` is called during tool initialization:
1. Reads `mcp_servers` from `~/.hermes/config.yaml`
2. For each server, spawns a connection in a dedicated background event loop
3. Initializes the MCP session and calls `list_tools()` to discover available tools
4. Registers each tool in the Hermes tool registry
### Tool Naming Convention
MCP tools are registered with the naming pattern:
```
mcp_{server_name}_{tool_name}
```
Hyphens and dots in names are replaced with underscores for LLM API compatibility.
Examples:
- Server `filesystem`, tool `read_file``mcp_filesystem_read_file`
- Server `github`, tool `list-issues``mcp_github_list_issues`
- Server `my-api`, tool `fetch.data``mcp_my_api_fetch_data`
### Auto-Injection
After discovery, MCP tools are automatically injected into all `hermes-*` platform toolsets (CLI, Discord, Telegram, etc.). This means MCP tools are available in every conversation without any additional configuration.
### Connection Lifecycle
- Each server runs as a long-lived asyncio Task in a background daemon thread
- Connections persist for the lifetime of the agent process
- If a connection drops, automatic reconnection with exponential backoff kicks in (up to 5 retries, max 60s backoff)
- On agent shutdown, all connections are gracefully closed
### Idempotency
`discover_mcp_tools()` is idempotent -- calling it multiple times only connects to servers that aren't already connected. Failed servers are retried on subsequent calls.
## Transport Types
### Stdio Transport
The most common transport. Hermes launches the MCP server as a subprocess and communicates over stdin/stdout.
```yaml
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
```
The subprocess inherits a **filtered** environment (see Security section below) plus any variables you specify in `env`.
### HTTP / StreamableHTTP Transport
For remote or shared MCP servers. Requires the `mcp` package to include HTTP client support (`mcp.client.streamable_http`).
```yaml
mcp_servers:
remote_api:
url: "https://mcp.example.com/mcp"
headers:
Authorization: "Bearer sk-..."
```
If HTTP support is not available in your installed `mcp` version, the server will fail with an ImportError and other servers will continue normally.
## Security
### Environment Variable Filtering
For stdio servers, Hermes does NOT pass your full shell environment to MCP subprocesses. Only safe baseline variables are inherited:
- `PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `TERM`, `SHELL`, `TMPDIR`
- Any `XDG_*` variables
All other environment variables (API keys, tokens, secrets) are excluded unless you explicitly add them via the `env` config key. This prevents accidental credential leakage to untrusted MCP servers.
```yaml
mcp_servers:
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
# Only this token is passed to the subprocess
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..."
```
### Credential Stripping in Error Messages
If an MCP tool call fails, any credential-like patterns in the error message are automatically redacted before being shown to the LLM. This covers:
- GitHub PATs (`ghp_...`)
- OpenAI-style keys (`sk-...`)
- Bearer tokens
- Generic `token=`, `key=`, `API_KEY=`, `password=`, `secret=` patterns
## Troubleshooting
### "MCP SDK not available -- skipping MCP tool discovery"
The `mcp` Python package is not installed. Install it:
```bash
pip install mcp
```
### "No MCP servers configured"
No `mcp_servers` key in `~/.hermes/config.yaml`, or it's empty. Add at least one server.
### "Failed to connect to MCP server 'X'"
Common causes:
- **Command not found**: The `command` binary isn't on PATH. Ensure `npx`, `uvx`, or the relevant command is installed.
- **Package not found**: For npx servers, the npm package may not exist or may need `-y` in args to auto-install.
- **Timeout**: The server took too long to start. Increase `connect_timeout`.
- **Port conflict**: For HTTP servers, the URL may be unreachable.
### "MCP server 'X' requires HTTP transport but mcp.client.streamable_http is not available"
Your `mcp` package version doesn't include HTTP client support. Upgrade:
```bash
pip install --upgrade mcp
```
### Tools not appearing
- Check that the server is listed under `mcp_servers` (not `mcp` or `servers`)
- Ensure the YAML indentation is correct
- Look at Hermes Agent startup logs for connection messages
- Tool names are prefixed with `mcp_{server}_{tool}` -- look for that pattern
### Connection keeps dropping
The client retries up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s, capped at 60s). If the server is fundamentally unreachable, it gives up after 5 attempts. Check the server process and network connectivity.
## Examples
### Time Server (uvx)
```yaml
mcp_servers:
time:
command: "uvx"
args: ["mcp-server-time"]
```
Registers tools like `mcp_time_get_current_time`.
### Filesystem Server (npx)
```yaml
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/documents"]
timeout: 30
```
Registers tools like `mcp_filesystem_read_file`, `mcp_filesystem_write_file`, `mcp_filesystem_list_directory`.
### GitHub Server with Authentication
```yaml
mcp_servers:
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxxxxxxxxxx"
timeout: 60
```
Registers tools like `mcp_github_list_issues`, `mcp_github_create_pull_request`, etc.
### Remote HTTP Server
```yaml
mcp_servers:
company_api:
url: "https://mcp.mycompany.com/v1/mcp"
headers:
Authorization: "Bearer sk-xxxxxxxxxxxxxxxxxxxx"
X-Team-Id: "engineering"
timeout: 180
connect_timeout: 30
```
### Multiple Servers
```yaml
mcp_servers:
time:
command: "uvx"
args: ["mcp-server-time"]
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxxxxxxxxxx"
company_api:
url: "https://mcp.internal.company.com/mcp"
headers:
Authorization: "Bearer sk-xxxxxxxxxxxxxxxxxxxx"
timeout: 300
```
All tools from all servers are registered and available simultaneously. Each server's tools are prefixed with its name to avoid collisions.
## Sampling (Server-Initiated LLM Requests)
Hermes supports MCP's `sampling/createMessage` capability — MCP servers can request LLM completions through the agent during tool execution. This enables agent-in-the-loop workflows (data analysis, content generation, decision-making).
Sampling is **enabled by default**. Configure per server:
```yaml
mcp_servers:
my_server:
command: "npx"
args: ["-y", "my-mcp-server"]
sampling:
enabled: true # default: true
model: "gemini-3-flash" # model override (optional)
max_tokens_cap: 4096 # max tokens per request
timeout: 30 # LLM call timeout (seconds)
max_rpm: 10 # max requests per minute
allowed_models: [] # model whitelist (empty = all)
max_tool_rounds: 5 # tool loop limit (0 = disable)
log_level: "info" # audit verbosity
```
Servers can also include `tools` in sampling requests for multi-turn tool-augmented workflows. The `max_tool_rounds` config prevents infinite tool loops. Per-server audit metrics (requests, errors, tokens, tool use count) are tracked via `get_mcp_status()`.
Disable sampling for untrusted servers with `sampling: { enabled: false }`.
## Notes
- MCP tools are called synchronously from the agent's perspective but run asynchronously on a dedicated background event loop
- Tool results are returned as JSON with either `{"result": "..."}` or `{"error": "..."}`
- The native MCP client is independent of `mcporter` -- you can use both simultaneously
- Server connections are persistent and shared across all conversations in the same agent process
- Adding or removing servers requires restarting the agent (no hot-reload currently)
@@ -0,0 +1,165 @@
---
name: hermes-agent-skill-authoring
description: "Author in-repo SKILL.md: frontmatter, validator, structure."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [skills, authoring, hermes-agent, conventions, skill-md]
related_skills: [plan, requesting-code-review]
---
# Authoring Hermes-Agent Skills (in-repo)
## Overview
There are two places a SKILL.md can live:
1. **User-local:** `~/.hermes/skills/<maybe-category>/<name>/SKILL.md` — personal, not shared. Created via `skill_manage(action='create')`.
2. **In-repo (this skill is about this case):** `/home/bb/hermes-agent/skills/<category>/<name>/SKILL.md` — committed, shipped with the package. Use `write_file` + `git add`. `skill_manage(action='create')` does NOT target this tree.
## When to Use
- User asks you to add a skill "in this branch / repo / commit"
- You're committing a reusable workflow that should ship with hermes-agent
- You're editing an existing skill under `/home/bb/hermes-agent/skills/` (use `patch` for small edits, `write_file` for rewrites; `skill_manage` still works for patch on in-repo skills, but not for `create`)
## Required Frontmatter
Source of truth: `tools/skill_manager_tool.py::_validate_frontmatter`. Hard requirements:
- Starts with `---` as the first bytes (no leading blank line).
- Closes with `\n---\n` before the body.
- Parses as a YAML mapping.
- `name` field present.
- `description` field present, ≤ **1024 chars** (`MAX_DESCRIPTION_LENGTH`).
- Non-empty body after the closing `---`.
Peer-matched shape used by every skill under `skills/software-development/`:
```yaml
---
name: my-skill-name # lowercase, hyphens, ≤64 chars (MAX_NAME_LENGTH)
description: Use when <trigger>. <one-line behavior>.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [short, descriptive, tags]
related_skills: [other-skill, another-skill]
---
```
`version` / `author` / `license` / `metadata` are NOT enforced by the validator, but every peer has them — omit and your skill sticks out.
## Size Limits
- Description: ≤ 1024 chars (enforced).
- Full SKILL.md: ≤ 100,000 chars (enforced as `MAX_SKILL_CONTENT_CHARS`, ~36k tokens).
- Peer skills in `software-development/` sit at **8-14k chars**. Aim for that range. If you're pushing past 20k, split into `references/*.md` and reference them from SKILL.md.
## Peer-Matched Structure
Every in-repo skill follows roughly:
```
# <Title>
## Overview
One or two paragraphs: what and why.
## When to Use
- Bulleted triggers
- "Don't use for:" counter-triggers
## <Topic sections specific to the skill>
- Quick-reference tables are common
- Code blocks with exact commands
- Hermes-specific recipes (tests via scripts/run_tests.sh, ui-tui paths, etc.)
## Common Pitfalls
Numbered list of mistakes and their fixes.
## Verification Checklist
- [ ] Checkbox list of post-action verifications
## One-Shot Recipes (optional)
Named scenarios → concrete command sequences.
```
Not every section is mandatory, but `Overview` + `When to Use` + actionable body + pitfalls are the minimum for the skill to feel like a peer.
## Directory Placement
```
skills/<category>/<skill-name>/SKILL.md
```
Categories currently in repo (confirm with `ls skills/`): `autonomous-ai-agents`, `creative`, `data-science`, `devops`, `dogfood`, `email`, `gaming`, `github`, `leisure`, `mcp`, `media`, `mlops/*`, `note-taking`, `productivity`, `red-teaming`, `research`, `smart-home`, `social-media`, `software-development`.
Pick the closest existing category. Don't invent new top-level categories casually.
## Workflow
1. **Survey peers** in the target category:
```
ls skills/<category>/
```
Read 2-3 peer SKILL.md files to match tone and structure.
2. **Check validator constraints** in `tools/skill_manager_tool.py` if unsure.
3. **Draft** with `write_file` to `skills/<category>/<name>/SKILL.md`.
4. **Validate locally**:
```python
import yaml, re, pathlib
content = pathlib.Path("skills/<category>/<name>/SKILL.md").read_text()
assert content.startswith("---")
m = re.search(r'\n---\s*\n', content[3:])
fm = yaml.safe_load(content[3:m.start()+3])
assert "name" in fm and "description" in fm
assert len(fm["description"]) <= 1024
assert len(content) <= 100_000
```
5. **Git add + commit** on the active branch.
6. **Note:** the CURRENT session's skill loader is cached — `skill_view` / `skills_list` will not see the new skill until a new session. This is expected, not a bug.
## Cross-Referencing Other Skills
`metadata.hermes.related_skills` unions both trees (`skills/` in-repo and `~/.hermes/skills/`) at load time. You CAN reference a user-local skill from an in-repo skill, but it won't resolve for other users who clone the repo fresh. Prefer referencing only in-repo skills from in-repo skills. If a frequently-referenced skill lives only in `~/.hermes/skills/`, consider promoting it to the repo.
## Editing Existing In-Repo Skills
- **Small fix (typo, added pitfall, tightened trigger):** `skill_manage(action='patch', name=..., old_string=..., new_string=...)` works fine on in-repo skills.
- **Major rewrite:** `write_file` the whole SKILL.md. `skill_manage(action='edit')` also works but requires supplying the full new content.
- **Adding supporting files:** `write_file` to `skills/<category>/<name>/references/<file>.md`, `templates/<file>`, or `scripts/<file>`. `skill_manage(action='write_file')` also works and enforces the references/templates/scripts/assets subdir allowlist.
- **Always commit** the edit — in-repo skills are source, not runtime state.
## Common Pitfalls
1. **Using `skill_manage(action='create')` for an in-repo skill.** It writes to `~/.hermes/skills/`, not the repo tree. Use `write_file` for in-repo creation.
2. **Leading whitespace before `---`.** The validator checks `content.startswith("---")`; any leading blank line or BOM fails validation.
3. **Description too generic.** Peer descriptions start with "Use when ..." and describe the *trigger class*, not the one task. "Use when debugging X" > "Debug X".
4. **Forgetting the author/license/metadata block.** Not validator-enforced, but every peer has it; omitting makes the skill look half-finished.
5. **Writing a skill that duplicates a peer.** Before creating, `ls skills/<category>/` and open 2-3 peers. Prefer extending an existing skill to creating a narrow sibling.
6. **Expecting the current session to see the new skill.** It won't. The skill loader is initialized at session start. Verify in a fresh session or via `skill_view` using the exact path.
7. **Linking to skills that don't exist in-repo.** `related_skills: [some-user-local-skill]` works for you but breaks for other clones. Prefer only in-repo links.
## Verification Checklist
- [ ] File is at `skills/<category>/<name>/SKILL.md` (not in `~/.hermes/skills/`)
- [ ] Frontmatter starts at byte 0 with `---`, closes with `\n---\n`
- [ ] `name`, `description`, `version`, `author`, `license`, `metadata.hermes.{tags, related_skills}` all present
- [ ] Name ≤ 64 chars, lowercase + hyphens
- [ ] Description ≤ 1024 chars and starts with "Use when ..."
- [ ] Total file ≤ 100,000 chars (aim for 8-15k)
- [ ] Structure: `# Title` → `## Overview` → `## When to Use` → body → `## Common Pitfalls` → `## Verification Checklist`
- [ ] `related_skills` references resolve in-repo (or are explicitly OK to be user-local)
- [ ] `git add skills/<category>/<name>/ && git commit` completed on the intended branch
@@ -0,0 +1,194 @@
# Webhook Subscriptions
Create dynamic webhook subscriptions so external services (GitHub, GitLab, Stripe, CI/CD, IoT sensors, monitoring tools) can trigger Hermes agent runs by POSTing events to a URL.
## Setup (Required First)
The webhook platform must be enabled before subscriptions can be created. Check with:
```bash
hermes webhook list
```
If it says "Webhook platform is not enabled", set it up:
### Option 1: Setup wizard
```bash
hermes gateway setup
```
Follow the prompts to enable webhooks, set the port, and set a global HMAC secret.
### Option 2: Manual config
Add to `~/.hermes/config.yaml`:
```yaml
platforms:
webhook:
enabled: true
extra:
host: "0.0.0.0"
port: 8644
secret: "generate-a-strong-secret-here"
```
### Option 3: Environment variables
Add to `~/.hermes/.env`:
```bash
WEBHOOK_ENABLED=true
WEBHOOK_PORT=8644
WEBHOOK_SECRET=generate-a-strong-secret-here
```
After configuration, start (or restart) the gateway:
```bash
hermes gateway run
# Or if using systemd:
systemctl --user restart hermes-gateway
```
Verify it's running:
```bash
curl http://localhost:8644/health
```
## Commands
All management is via the `hermes webhook` CLI command:
### Create a subscription
```bash
hermes webhook subscribe <name> \
--prompt "Prompt template with {payload.fields}" \
--events "event1,event2" \
--description "What this does" \
--skills "skill1,skill2" \
--deliver telegram \
--deliver-chat-id "12345" \
--secret "optional-custom-secret"
```
Returns the webhook URL and HMAC secret. The user configures their service to POST to that URL.
### List subscriptions
```bash
hermes webhook list
```
### Remove a subscription
```bash
hermes webhook remove <name>
```
### Test a subscription
```bash
hermes webhook test <name>
hermes webhook test <name> --payload '{"key": "value"}'
```
## Prompt Templates
Prompts support `{dot.notation}` for accessing nested payload fields:
- `{issue.title}` — GitHub issue title
- `{pull_request.user.login}` — PR author
- `{data.object.amount}` — Stripe payment amount
- `{sensor.temperature}` — IoT sensor reading
If no prompt is specified, the full JSON payload is dumped into the agent prompt.
## Common Patterns
### GitHub: new issues
```bash
hermes webhook subscribe github-issues \
--events "issues" \
--prompt "New GitHub issue #{issue.number}: {issue.title}\n\nAction: {action}\nAuthor: {issue.user.login}\nBody:\n{issue.body}\n\nPlease triage this issue." \
--deliver telegram \
--deliver-chat-id "-100123456789"
```
Then in GitHub repo Settings → Webhooks → Add webhook:
- Payload URL: the returned webhook_url
- Content type: application/json
- Secret: the returned secret
- Events: "Issues"
### GitHub: PR reviews
```bash
hermes webhook subscribe github-prs \
--events "pull_request" \
--prompt "PR #{pull_request.number} {action}: {pull_request.title}\nBy: {pull_request.user.login}\nBranch: {pull_request.head.ref}\n\n{pull_request.body}" \
--skills "github-code-review" \
--deliver github_comment
```
### Stripe: payment events
```bash
hermes webhook subscribe stripe-payments \
--events "payment_intent.succeeded,payment_intent.payment_failed" \
--prompt "Payment {data.object.status}: {data.object.amount} cents from {data.object.receipt_email}" \
--deliver telegram \
--deliver-chat-id "-100123456789"
```
### CI/CD: build notifications
```bash
hermes webhook subscribe ci-builds \
--events "pipeline" \
--prompt "Build {object_attributes.status} on {project.name} branch {object_attributes.ref}\nCommit: {commit.message}" \
--deliver discord \
--deliver-chat-id "1234567890"
```
### Generic monitoring alert
```bash
hermes webhook subscribe alerts \
--prompt "Alert: {alert.name}\nSeverity: {alert.severity}\nMessage: {alert.message}\n\nPlease investigate and suggest remediation." \
--deliver origin
```
### Direct delivery (no agent, zero LLM cost)
For use cases where you just want to push a notification through to a user's chat — no reasoning, no agent loop — add `--deliver-only`. The rendered `--prompt` template becomes the literal message body and is dispatched directly to the target adapter.
Use this for:
- External service push notifications (Supabase/Firebase webhooks → Telegram)
- Monitoring alerts that should forward verbatim
- Inter-agent pings where one agent is telling another agent's user something
- Any webhook where an LLM round trip would be wasted effort
```bash
hermes webhook subscribe antenna-matches \
--deliver telegram \
--deliver-chat-id "123456789" \
--deliver-only \
--prompt "🎉 New match: {match.user_name} matched with you!" \
--description "Antenna match notifications"
```
The POST returns `200 OK` on successful delivery, `502` on target failure — so upstream services can retry intelligently. HMAC auth, rate limits, and idempotency still apply.
Requires `--deliver` to be a real target (telegram, discord, slack, github_comment, etc.) — `--deliver log` is rejected because log-only direct delivery is pointless.
## Security
- Each subscription gets an auto-generated HMAC-SHA256 secret (or provide your own with `--secret`)
- The webhook adapter validates signatures on every incoming POST
- Static routes from config.yaml cannot be overwritten by dynamic subscriptions
- Subscriptions persist to `~/.hermes/webhook_subscriptions.json`
## How It Works
1. `hermes webhook subscribe` writes to `~/.hermes/webhook_subscriptions.json`
2. The webhook adapter hot-reloads this file on each incoming request (mtime-gated, negligible overhead)
3. When a POST arrives matching a route, the adapter formats the prompt and triggers an agent run
4. The agent's response is delivered to the configured target (Telegram, Discord, GitHub comment, etc.)
## Troubleshooting
If webhooks aren't working:
1. **Is the gateway running?** Check with `systemctl --user status hermes-gateway` or `ps aux | grep gateway`
2. **Is the webhook server listening?** `curl http://localhost:8644/health` should return `{"status": "ok"}`
3. **Check gateway logs:** `grep webhook ~/.hermes/logs/gateway.log | tail -20`
4. **Signature mismatch?** Verify the secret in your service matches the one from `hermes webhook list`. GitHub sends `X-Hub-Signature-256`, GitLab sends `X-Gitlab-Token`.
5. **Firewall/NAT?** The webhook URL must be reachable from the service. For local development, use a tunnel (ngrok, cloudflared).
6. **Wrong event type?** Check `--events` filter matches what the service sends. Use `hermes webhook test <name>` to verify the route works.
@@ -0,0 +1,130 @@
---
name: multichannel-messaging-discipline
description: 跨渠道、多人环境下的通讯纪律——回复路由(在哪个渠道被找就回哪个渠道)、发送者身份核实、信息隔离、发送前核对。在向任何人或群发消息/回复、不确定对方是谁、或决定通知发往何处之前加载。覆盖企微 + 飞书的群聊与私聊。Load before sending any WeCom/Feishu message (group or DM), when unsure who the sender is, or when deciding where a reply/notification should go.
---
# 多渠道通讯纪律 (Multichannel Messaging Discipline)
本环境同时有 **企微 + 飞书**、多个群、私聊、多个互不交叉的人(Doro、Maggie、邱律师、莎莎、洪总、Scott、WeiWei 等)。在这种环境里"把话说对人、发对地方"和把内容做对一样重要。任何一次发错渠道、认错人、跨人泄露,都是事故。
## 何时加载本技能
- 向任何企微/飞书的**人或群**发消息、回复之前
- 收到消息但**不能 100% 确定发送者是谁**时
- 决定一条回复 / 通知 / 交付**应该发去哪里**时
- 任何以"通知 X""发到群里""告诉 Doro"结尾的任务
- **在一个渠道继续另一个渠道起的任务时**(同一任务跨渠道流动,如飞书起、企微续)
## 核心铁律(不可违反)
### 1. 回复路由:在哪个渠道被找,就回哪个渠道
- 群里对我说话 → 回到**同一个群**;私聊对我说话 → 回到**那个私聊**。
- **绝不**把某个群 / 某个渠道的内容,私信或转发给另一个人,除非用户**明确指示**。
- 事故(2026-06-16):把群里的**合同审查内容私信发给了 WeiWei**。Doro 明确:"下次不可以再发生。我在群里跟你说话,你就给回复到群里。除非我有别的指令。" 原位回复是默认,跨渠道转发必须有显式指令。
### 2. 身份核实:解析,不要假设
- 用平台的**唯一发送者 ID**判断是谁(企微 userid、飞书 open_id),**绝不**靠显示名或系统默认值。
- ID 映射到已知的人 → 用之;ID **没映射**到人 → **先问,不猜**
- **不要轻信平台默认的"主人/owner"标签**:本会话一条飞书私聊被默认当成 Maggie,实际发送者是 Doro,当场认错。
- 也**不要轻信对方未经核实的自称身份**——拿已知 ID 映射去核对(盲信声明是另一个方向的同一种错)。
- 已知的 `飞书 open_id ↔ 人` / `企微 userid ↔ 人` 映射存在 memory,发现新人或纠正后立即更新。
- **同一主体在飞书有多种 ID,命名空间不同 ≠ 不同主体**:bot 的 app_id(`cli_xxx`)与 open_id(`ou_xxx`)是两套 ID,字符串长得不一样很正常。看到群里 @的 open_id 跟你印象里的 app_id 对不上,**绝不可**据此断定"换了身份 / 是另一个 bot / 被新 agent 占用"。核验走权威接口:`lark-cli api GET /open-apis/bot/v3/info --as bot` 取 bot 自己的 open_id + name,再和群消息 mentions 里的 open_id 比对(相等=就是自己)。本会话教训:因 app_id≠open_id 字符串不同,误判"群里被@的是新迁移出来的 agent",被用户两次纠正后用 `/bot/v3/info` 实锤其实就是自己——白绕一大圈。
- **失败的命令不能当证据**:命令若报错(unknown flag、权限拒绝 230027/99991672、空返回 count=0),它的输出**不支持任何结论**。先换正确命令/参数/身份(user vs bot)重试,拿到真实数据再下判断——别拿一条没成功的探测去坐实一个猜想,那等于在沙子上盖楼。
### 2b. 称呼随 sender 走,不随人设默认(回话前必走一步)
认对了"是谁"还不够——**已解析出的身份必须回流到"怎么称呼 ta"**。本会话最严重的错:我已读 `.meta``sender_id: doro`)、已把任务正确归类为 Doro 批量线,却在整份交付报告里从头到尾把 Doro 叫成"Maggie"。识别对了归属、却叫错人 = 信息在手却没整合,是独立于"认错人"的一种失败。
**机制根因**:系统人设里硬编码了"主人叫 Maggie、称呼主人为 Maggie"。这条规则有个**隐含前提——当前对话者确实是主人本人**。把它当成无条件默认,就会用人设里的高频人名锚点覆盖掉会话事实。
**回话前的强制自检(每次生成称呼前走一遍)**
1. 当前这条消息的 **sender 是谁**?取 `.meta``sender_id`,或会话 `Source / User`,**不取人设默认名**。
2. sender **是主人本人吗**?是 → 才用主人称呼(Maggie);**否** → 用该 sender 的真实身份(`doro → Doro``qiuting → 邱律师` 等)。
3. 已核实的任务归属身份与称呼**必须一致**——不能归属判对了、抬头却写错。
口诀:**人设默认名是"当对方是主人时"的条件值,不是无条件抬头。回话前先认 sender,再决定称呼。**
### 3. 信息隔离
- 每个人 / 每个团队的文件和任务,只留在该人 / 该团队的上下文里。绝不把一方的内容带进另一方的渠道,也不在 A 的群里提 B 的任务。
- Maggie 团队 与 Doro/邱律师 团队、各客户之间,严格不交叉。
### 3b. 跨渠道任务连续性(同一任务在两个渠道间流动时)
同一个项目/任务可能在多个渠道接力推进——典型:飞书上确认了方法、更新了交付物,然后转到企微继续。**记忆是跨渠道通的(用户偏好、规则、文件位置都在),但每个渠道的对话上下文是隔离的**:飞书刚说的话不会自动进企微会话。所以\"接力续做\"前必须主动对齐,否则会拿旧认知在新渠道做事、重复已完成的工作、或漏掉刚确认的结论。
**续做铁律:先对齐,再动手。**
1. **用 `session_search` 把另一渠道的最新成果拉回来**——搜项目名/关键结论(如\"世茂 千分号 汇总表\"),读出对方渠道刚确认了什么、交付物更新到哪一版。不要凭这边的旧记忆假设进度。
2. **从权威源核实交付物现状**,不信任\"我记得\"——交付物(Nextcloud xlsx/docx)以实际文件为准,拉最新版打开看,确认版本/时间戳/内容与对方渠道的结论一致。
3. **回应用户关心的具体修改/结论时,先复述对齐结果**(\"飞书那边刚确认的 X、更新的 Y 我已接收\"),让用户确认我们站在同一起点,再往下做。
4. **渠道中断会留缺口**:若某渠道掉线过(如企微群订阅失效两小时),那段时间该渠道里别人发的内容这边是空白。续做前主动提示用户\"X 时段后若有人在该渠道发过东西,我没收到,请补给我\",避免任务断档——尤其客户对接渠道(隔离铁律下漏收=对接出窟窿)。
> 实证(2026-06-18 南通新东方/世茂):用户在飞书确认了 OCR 千分号/百分号识别法 + 更新了世茂汇总表,然后转企微说\"继续过世茂合同\"。正确做法是先 session_search 把飞书成果对齐、从 Nextcloud 拉最新版 xlsx 读出 4 份合同结构,再请用户给逐条指令——而不是在企微凭旧上下文直接开干。同期企微群因订阅失效(errcode 846609)静默两小时,须提示用户补回漏收的群消息。
### 4. 发送前三项核对(任何别人能看到的消息)
发出去前确认:
1. **收件人**——确切的人或群,解析出 chat_id / user_id(不要凭印象挑一个)
2. **内容**——要发的正文
3. **以谁的身份发**——bot 还是 user
工具支持时**先 dry-run**,确认 payload 无误再正式发;发完把回执(message_id + 北京时间)报给用户便于核对。
### 5. 平台内账号/身份管理请求:把它当成“账号操作”,不是“消息回复”
当用户让你**登录某个平台上的账号、修改密码、完成首次登录、维护个人账号资料**时,默认这是一个**账号操作任务**,不是跨人沟通任务。此时重点从“回哪个群/私聊”切换为:
1. **先区分是否是“当前 agent 自己要持有的账号”**
- 用户明确说“这是你的账号”“你自己管理好密码” → 可由 agent 自行设置并保管该账号密码。
- 如果是代表某个真人同事/用户持有的账号,且对方未授权 agent 自定密码 → 不能擅自决定长期密码。
2. **执行后要给结果,不要停在征求式废话**
- 用户已明确授权 agent 自主管理密码时,直接完成修改并回报“已完成”。
- 不要在已获授权的情况下继续追问“你想设什么密码”。
3. **账号管理与消息路由隔离**
- 账号是平台内身份,不等于消息发送对象;不要因为会话对方是谁,就把账号密码策略误当成需要对方逐项确认的沟通动作。
4. **汇报风格要结果导向**
- 这类任务完成后,优先回:是否登录成功、是否已改密、页面确认信息。
- 不展开无关解释,避免把简单账号操作说成审批流程。
### 5. 用户要求“重新查 / 不要用记忆 / 用文件夹里的问题件”时,立即切换到证据模式
这类指令不是语气提醒,而是**明确纠偏**:之前的回答被认为混入了记忆、推断或口径漂移。后续必须把“回复别人”切回“基于当前文件/当前目录/当前记录的实查结果”。
执行要求:
1. **停止沿用上一条摘要口径**——哪怕上一条是自己刚写的,也不能继续复述;
2. **以用户指定的载体为准重新取证**
- 说“用文件夹里的问题件” → 先看该文件夹实际有哪些文件;
- 说“打开文件查” → 必须打开文件或其内部结构(如 docx XML)再说;
- 说“不要用记忆” → 禁止用 memory / 旧会话印象补缀事实;
3. **结论按证据强弱分层表达**
- 能被当前文件直接坐实的,就说“可确认”;
- 只能证明“文件存在”但不能证明“属于该批问题件/pass件”的,就明确写“目前只能确认存在,不能据此归类”;
4. **不要把“在任务交付目录里存在”偷换成“就是问题件 / 已 pass / 属于同一批”**
5. **汇报时先交代证据来源**(查了哪个目录、哪份问题汇总、是否打开了文件),再给结论。
6. **用户说“我就要一个结果”时,停止过程化解释**:这类话表示对方当前只接受最终答案,不要再补背景、过程、严谨性铺垫。先给一句结论;如对方追问,再展开证据链。
一句话:**用户说“重新查”时,先把脑子里的版本清空,回到文件本身;用户说“只要结果”时,先把话收成结论。**
## 实操配方
- **飞书群里接收文件/图片**:飞书不允许文件和文字(@mention)在同一条消息里。解决方案:用户先发文件/图片,再对那条消息点「回复」并在回复里@小 Maggie。Gateway 的 `_fetch_parent_media` 方法会自动从被引用的 parent 消息中下载附件(2026-07-11 补丁)。文件缓存路径:`~/.hermes/cache/documents/`。图片同理——用户发图后回复+@即可
- 飞书群发消息 + @人(找群、@格式、dry-run、核对回执):见 `references/feishu-group-send-and-mention.md`
- **飞书 bot 在群里不回、私聊却正常**的诊断配方(gateway inbound vs 群真实历史对照、查 `mentions[].id` 的 open_id 识别同名 bot 撞名、`--as user` 读群历史):见 `references/feishu-bot-silent-in-group-diagnosis.md`
- 企微发文件 / 私信机制:见 `wecom-file-send-receive` 技能(MEDIA: 标签发文件;私信走独立 WS aibot_send_msg,chatid=userid+chat_type=1)
- **企微私信非 home 的人(Doro/邱律师/WeiWei 等)→ 用 `~/.hermes/scripts/wecom_dm.py`,不要用 `send_message` 工具**:
- `send_message(target='wecom:WeiWei')` 对非 home 的 wecom 用户会**静默回退到 home channel**(实测返回 `chat_id: JiaQian` + note `Sent to wecom home channel`),**不报错**——一条发给 WeiWei 的技术讨论就这样落到了 Maggie 渠道,同时违反信息隔离。
- 正确做法(agent 可直接 terminal 调用,自开 WS、永不退化成回复、目标唯一):
```bash
python3 ~/.hermes/scripts/wecom_dm.py --list # 先看白名单别名↔userid
python3 ~/.hermes/scripts/wecom_dm.py --to WeiWei --text "…" --dry-run # 演练
python3 ~/.hermes/scripts/wecom_dm.py --to WeiWei --text "…" # 真发,回执含 message_id
```
- 别名:`doro / jiaqian / qiuting / weiwei / shasha / yangayi / xiaonan`(`--list` 为准)。这是 `references/wecom-proactive-notify-misrouting.md` 修复方向 A 在脚本层的现成实现,**比 `_send_wecom(extra,…)` 更可用**(后者需 gateway 内部 `extra`,agent 会话里拿不到)。
- 企微主动通知**错投到错误的人**("给 Doro 的消息发给了 Maggie")的根因 + 只读诊断配方 + 修复方向:见 `references/wecom-proactive-notify-misrouting.md`
## Pitfalls
- **把群任务的处理结果私信给"相关的人"**——即使你觉得对方该知道,也不行。原位回群,要不要另外通知由用户决定。
- **靠会话默认值认人**——私聊默认 owner 不等于当前发送者,必须看 sender ID。
- **拿\"两个长得不一样的 ID 字符串\"推断成\"两个身份/新迁移出的 agent\"**——同一个实体在不同命名空间有多种 ID,长相不同是常态:飞书机器人的 `app_id`(`cli_xxx`)与它的 `open_id`(`ou_xxx`)本就不一样,**两者是同一个 bot**。事故(2026-06-22 与 WeiWei 排障):我把群里被 @ 的 `ou_20bd8…`(open_id)和 gateway 配置里的 `cli_aaa4e77d27789bed`(app_id)当成两个 bot,进而臆断\"有个新迁移出来的 agent 占用了身份\",被纠正\"迁移还没开始\"。下结论前用**权威身份接口**核对:`LARK_CLI_NO_PROXY=1 lark-cli api GET /open-apis/bot/v3/info --as bot` 返回的 `open_id`/`app_name` 才是 bot 真身——拿它去比对,再判断是不是同一个。
- **从一条 errored 的命令里读出\"结论\"**——同一次排障里,我那条查 bot open_id 的命令其实用错了 flag、根本没返回结果,我却继续往\"新身份\"上跳。**命令报错 = 没有证据,不是证据**;拿到真实返回再推理,别把工具失败当成支持自己假设的信号。
- **同名 bot 撞名 = @ 显示名 ≠ @ 到你这个 app**:群里可能存在两个同名「小Maggie」(典型触发:做过新 Agent 迁移 / provisioning,新身份被拉进群)。用户 @ 显示名时飞书解析到的是某个 open_id,若那不是当前 gateway 跑的 app_id,事件流根本收不到,bot"静默不回",但 DM 仍正常(DM 按会话路由、不按 @ 身份)。诊断时必须查群消息 `mentions[].id` 的 open_id 和当前 bot app_id 是否一致,别一看不回就报"掉线"。完整配方见 `references/feishu-bot-silent-in-group-diagnosis.md`。
- **认对了人却叫错称呼**——已解析出 sender 是 Doro,抬头却写"Maggie"。人设里"主人叫 Maggie"是"当对方是主人时"的条件值,不是无条件抬头;归属身份必须回流到称呼(见 2b)。
- **session 无 .meta / sender 信息时,从文件内容或"印象"推断发件人**——事故(2026-07-13):session JSON 的 user message 里无 sender_id metadata,我从合同内容(青浦区卫生机构)推断是"刘婷律师",实际 gateway 日志明确显示 `user=QiuTing`(邱律师)。**当 session 记录不含 sender 信息时,必须查 gateway 日志(`~/.hermes/logs/gateway.log`)确认 `inbound message: platform=wecom user=XXX` 才能定身份**,绝不可从文件内容、以往经验、或"谁经常发这类合同"去猜。`grep "时间段" ~/.hermes/logs/gateway.log | grep "inbound"` 是唯一权威来源。
- **猜 chat_id / group**——发前用 `lark-cli im +chat-list --as bot` 把 bot 实际所在的群列出来,挑出你和目标人共处的那个,别凭记忆。
- **企微 @通知**:aibot_send_msg 只支持 markdown,不支持 text+mentioned_list,无法真正 @人;需要提醒时另发一条私信利用其消息提醒(仅在用户允许、且不违反路由铁律的前提下)。
- **自动化脚本会替你发消息**:`auto_notify_new_file.sh` 等脚本会自动私信。处理任务前留意有没有自动化机制正在按旧规则推送,避免它替你违反路由 / 隔离铁律。
- **`wecom_group_notify.py` 和 `wecom_dm.py` 是两个完全不同的脚本,不可混用(2026-07-01教训)**:Doro说"私信邱律师",用了 `wecom_group_notify.py`(默认发到批量合同审查群 `wrbAFkXAAAiWC3styKqNj0bZyH6BbJ_Q`),消息发到了群里而不是邱律师私信。**发前看清脚本名**:`wecom_dm.py` = 私信;`wecom_group_notify.py` = 群通知。workflow YAML 中通知邱律师的脚本也必须用 `wecom_dm.py --to qiuting`,不是 `wecom_group_notify.py`。
- **`send_message` 工具私信企微人会静默投错**:`send_message(target='wecom:<user>')` 对非 home channel 的 wecom 用户**不报错、直接回退到 home channel**(实测发给 WeiWei 却落到 JiaQian/Maggie)。这是单点事故——既没到目标人、又跨团队泄露。私信非 home 的企微人一律走 `~/.hermes/scripts/wecom_dm.py --to <alias>`(见上"实操配方"),别用 `send_message`。发完核回执里的 userid 是不是目标人,发现是 home channel 立即用脚本补发。
- **"给 X 的消息错投给 Y"先查 adapter 回复兜底,别先怪 userid**:企微 `WeComAdapter.send()` 在主动 `aibot_send_msg` 前有一段 `_last_chat_req_ids[chat_id]` 回复兜底——主动私信可能**退化成"回复某条历史消息"**,在多人并发的长跑 gateway 里串到别人头上。userid 往往是对的(`doro/JiaQian/QiuTing` 都是独立真实 ID),错在路径。诊断配方 + 根因 + 三个修复方向见 `references/wecom-proactive-notify-misrouting.md`。注意 `workflow-watchdog.sh` 仍硬编码 `_send_wecom(extra,'doro',msg)`,是未拆的隐患。
@@ -0,0 +1,56 @@
# 飞书 bot 在群里静默、私聊却正常 —— 诊断配方
实证:2026-06-22 群「小Maggie工作群」(`oc_a927f86118216c36cb9394b0e95f2a11`)。WeiWei/颜伽艺在群里 @小Maggie 无反应,私聊正常。
## 症状
- 用户在飞书**群**里 @小Maggie,bot 不回。
- 但**私聊**(DM)发消息 bot 正常回复。
- gateway 进程活着,飞书连接日志显示 `[Feishu] Connected in websocket mode`
## 根因类别(按概率排序)
1. **身份不匹配(display-name 撞名)** — 群里被 @ 的「小Maggie」其实是**另一个 bot 身份**(不同 open_id),不是当前 gateway 跑的那个 app。常见触发:做过「新 Agent 迁移 / provisioning」,新身份被拉进群并占用了群里「小Maggie」的 @ 目标。群 @ 解析到新身份的 open_id,旧 gateway(旧 app_id)的事件流里根本收不到这条,所以"不回"。**DM 仍正常,因为 DM 按会话路由、不按 @ 身份。** ← 本会话确诊就是这一类。
2. 订阅/连接在空窗后失效(同类:企微 errcode 846609 静默两小时)。
3. 发送侧失败:历史上见过 `[99992402] field validation failed`(连 plain-text 兜底也失败)——这是**发**不出去,不是**收**不到,必须区分。
## 诊断步骤(命令均已实测可用)
所有 lark-cli 命令加 `LARK_CLI_NO_PROXY=1` 前缀,避免凭据走 HTTPS_PROXY。
**1. 确认 bot 在哪些群、拿群 chat_id:**
```bash
LARK_CLI_NO_PROXY=1 lark-cli im +chat-list --as bot
```
**2. 查 gateway 实际收到了这个群的哪些 inbound:**
```bash
grep "oc_<群id>" ~/.hermes/logs/gateway.log | grep -iE "Inbound|inbound message"
```
若最后一条 inbound 停在很久以前、之后空白 → gateway 根本没收到新群消息(排除"收到但没回")。
**3. 拉群的真实消息历史,和第 2 步对照:**
> bot 身份读群历史会因缺 scope 失败:`+chat-messages-list --as bot` → 230027 / 99991672,缺 `im:chat:readonly` `im:chat.members:read`。**改用 `--as user`**。
```bash
LARK_CLI_NO_PROXY=1 lark-cli im +chat-messages-list \
--chat-id oc_<群id> --as user --order desc --page-size 30 --no-reactions --format json
```
返回 JSON 字段:`data.messages[]`,每条含 `content`(直接是文本)、`create_time``sender.{id,sender_type,name}``mentions[].{id,name}`。**注意不是 `items`/`body`** —— 用错字段会得到 `count=0` 的假空,误判成"群里没人发"。
**4. 关键判定 —— 看 @ 到的是谁的 open_id:**
群消息的 `mentions[].id` 就是被 @ 对象的 open_id。和当前 gateway 的 bot 身份对比:
```bash
# 当前 gateway 用的飞书 app_id
python3 -c "import yaml;c=yaml.safe_load(open('/home/maggie/.hermes/config.yaml'));print(c['gateway']['platforms']['feishu'].get('extra',{}).get('app_id'))"
```
- 对照法:第 2 步里 gateway 正常工作时段,bot 在群里发言的 sender 是 `app cli_<app_id>`。拿这个和第 4 步 mentions 里「小Maggie」的 open_id 比。
- 若群历史里「小Maggie」被 @ 的 open_id ≠ 当前 gateway bot 的身份 → **确诊身份不匹配**:群里有两个同名「小Maggie」,大家 @ 错了。
**5. 旁证:DM 是否正常。** 私聊 inbound 在 gateway.log 里照常出现 → 证明连接/订阅活着,问题收敛到"群 @ 身份"这一层。
## 结论怎么报
- 这是技术/配置问题,按铁律修复决策交给技术负责人(WeiWei/Scott),不自作主张改。
- 给三个方向:①新 Agent 接管群(@ 目标对到新身份 / 把新身份订阅配通);②仍由旧 bot 管群(移除或换回群里的新「小Maggie」);③过渡期走私聊。
## Pitfalls
- **别一看 bot 没回就说"掉线了"** —— 先分清"没收到(群 @ 身份不对 / 订阅失效)" vs "收到但没发出去(send 失败 99992402)"。
- **lark-cli 读群历史/群成员要用 `--as user`**,bot 身份缺 scope。要让 bot 自己能读,得在开放平台给 `cli_xxx``im:chat:readonly` `im:chat.group_info:readonly` `im:chat.members:read`
- **JSON 字段名**:`+chat-messages-list` 返回 `data.messages[].content`,不是 `items[].body`。用错字段会得到误导性的 `count=0`
- **重启时 DNS 抽风是环境问题,不是代码问题** —— 重启日志里若有 `Temporary failure in name resolution`(open.feishu.cn / openws.work.weixin.qq.com),那是当时网络/DNS 短暂故障;飞书多半自己重连上了,企微可能没重连成功,单独核实企微在线状态即可,别当成代码 bug 去改。
@@ -0,0 +1,88 @@
# 飞书群发消息 + @人(已验证配方 2026-06-16)
目标:把消息发到**正确的飞书群**并 **@ 正确的人**,一次做对。依赖 `lark-cli`(lark-im 技能)。
## 步骤
### 1. 找出 bot 实际所在的群(不要猜 chat_id)
```bash
cd ~ && lark-cli im +chat-list --as bot
```
返回每个群的 `chat_id``name``description``owner_id`。挑出你和目标人**共处**的那个群。
- 本环境已知群:"Doro, Maggie, 魏玮"(描述"小Maggie工作群")= `oc_a927f86118216c36cb9394b0e95f2a11`
- 若有多个候选群,按成员和描述确认,仍不确定就问用户,别赌。
### 2. @人的格式(两种已验证写法,均触发真实可点击 @ + 通知)
- **text 模式**:`--msg-type text`,content 形如 `{"text":"<at>…</at> 正文"}`,@ 标签 `<at user_id="ou_xxx">显示名</at>`(用 **open_id**;嵌进 JSON 字符串时内层引号按 JSON 规则转义)。@所有人 `<at user_id="all"></at>`
- **post 模式**(多行/结构化汇报推荐,2026-06-16 已验证):post JSON 里 @ 用 at 元素 `{"tag":"at","user_id":"ou_xxx"}`,与文本元素 `{"tag":"text","text":"…"}` **同行**拼接,配 `--msg-type post`。结构:
`{"zh_cn":{"content":[[{"tag":"at","user_id":"ou_757f053c9d7aff6c73b18aa60c337756"},{"tag":"text","text":" 进展汇报…"}],[{"tag":"text","text":"第二行"}]]}}`
- **不要用 `--markdown`** 发 @:会强制转 post 且 @ 处理不可靠,用显式 post JSON 自己控制。
### 3. 先 dry-run 验证 payload
```bash
lark-cli im +messages-send --chat-id oc_xxx --as bot \
--content '{"text":"<at user_id=\"ou_757f053c9d7aff6c73b18aa60c337756\">Doro</at> 正文…"}' \
--msg-type text --dry-run
```
检查 body 里 chat_id、msg_type、@标签是否正确
### 4. 去掉 --dry-run 正式发送
成功返回 `message_id` + `create_time`。把 message_id 和**北京时间**报给用户便于核对。
## 发送前如何核实「这个 open_id 确实是目标本人」
@错人是红线。理想是查通讯录,但本 bot **常缺 contact / chat 读权限**,按可靠性从高到低:
1. **gateway.log 取地面真值(最可靠,无需任何 scope)**——平台事件原始数据,比 API、比记忆都硬:
```bash
grep "oc_<群id>" ~/.hermes/logs/gateway.log | grep -oE "ou_[a-z0-9]{20,}" | sort | uniq -c | sort -rn
```
再看「当前这条消息」的入站行确认发送者:
```bash
grep "inbound message: platform=feishu" ~/.hermes/logs/gateway.log | tail
# → user=ou_xxx chat=oc_xxx msg='…' 即是谁在这个群说了这句话
```
把刚收到那句话的 `user=ou_xxx` 与已知映射比对,一致才发。
2. **已知 open_id 映射**(下方表 + memory),ID 没映射到人 → 先问不猜。
3. **通讯录 API(常被 scope 挡)**:`lark-cli contact +get-user --user-id ou_xxx --user-id-type open_id --as user`。本环境 2026-06-16 报缺 `contact:user.basic_profile:readonly`;`im chat.members get` 也缺 `im:chat.members:read` 等。缺权限是**正常状态**,别卡在这里——退回方法 1。
## 已知 open_id(核对身份用,新增/纠正后同步到 memory)
- Doro = `ou_757f053c9d7aff6c73b18aa60c337756`(与 bot 私聊 chat=`oc_9292e11bd98ddb5a69ea2c2da10d4f12`)
- Scott(魏玮) = `ou_04fade9a9335c09ad09846da2051b3c0`
## 注意
- `--as bot`:消息以应用 bot 名义发出,bot 必须已在目标群里。
- lark-cli 是 API 工具,不是消息网关;bot 身份不代理用户(Scott 已纠正)。
- 终端里 lark-cli 输出常带 HTTPS_PROXY 的 WARN 和版本更新 notice,是噪音,不影响结果;可 `grep -v "WARN\|proxy"` 过滤。
## 发文件附件到群里(md/pdf/docx 报告,已验证)
要把一份**文件**(错误分析 md、合同 docx、报告 pdf)发到群里,用 `--file`。常见组合:**先发文件附件,再发一条 @某人 的 post 说明**(文件本身不能 @ 人,说明消息负责 @)。
```bash
# 先发文件(--file 接 cwd 相对路径),成功返回 message_id 并打印 "uploading file: xxx"
cd ~/lark_send_tmp && lark-cli im +messages-send \
--chat-id oc_a927f86118216c36cb9394b0e95f2a11 \
--as bot --file "./报告.md" \
--dry-run 2>&1 | grep -v "WARN\|proxy" # 先 dry-run,确认后去掉 --dry-run
# 再发 @某人 的 post 说明(见上「@人的格式」post 模式),把文件背景+要点写清楚
```
### ⚠️ 关键坑:`--file` 只接 cwd 相对路径,绝对路径被拒
lark-cli 安全限制:`--file`(及 `--image`/`--video`/`--audio`)**拒绝绝对路径**(如 `/tmp/x.md`),路径解析 `..`/symlink 后必须仍在 cwd 内。
**解法**:把文件复制到干净工作目录,`cd` 进去用 `./文件名` 发:
```bash
mkdir -p ~/lark_send_tmp && cp "/tmp/报告.md" ~/lark_send_tmp/
cd ~/lark_send_tmp && lark-cli im +messages-send --chat-id oc_xxx --as bot --file "./报告.md"
rm -rf ~/lark_send_tmp # 发完清理
```
- 本地文件 lark-cli 会**先自动上传**再发 file 消息,无需手动 `images.create`/拿 file_key。
- dry-run 时 file_key 显示占位符 `file_dryrun_upload` 是正常的,正式发送才真上传。
- 同理发图片用 `--image ./x.png`,视频 `--video ./x.mp4 --video-cover ./cover.png`。
## 已知群与人 ID(核对身份用)
| 对象 | ID |
|------|-----|
| 飞书工作群"Doro, Maggie, 魏玮"(小Maggie工作群) | `oc_a927f86118216c36cb9394b0e95f2a11` |
| Doro | `ou_757f053c9d7aff6c73b18aa60c337756`(私聊 chat `oc_9292e11bd98ddb5a69ea2c2da10d4f12`,别和群混) |
| Scott / 魏玮 | `ou_04fade9a9335c09ad09846da2051b3c0` |
| @所有人 | `all` |
@@ -0,0 +1,74 @@
# 企微主动通知错投到错误的人 — 根因与诊断
**症状**:一条本应私信给 A(如 Doro)的**主动通知**,落到了 B(如 JiaQian/Maggie)的企微私信里。
实证(2026-06-17,Maggie 飞书原话):私信收到"收到 JiaQian 发来的文件…请问如何处理?"——这条内容本应发给 Doro,却投到了 JiaQian 本人。
**关键澄清:不是 userid 传错。** 会话 DB 里 `doro / JiaQian / WeiWei / ShaSha / QiuTing` 都是**各自独立的真实企微 userid**,`chat_id="doro"` 本身指向的就是 Doro。错投来自下面两个机制叠加,而非地址写错。
## 根因 1:发送者归属靠"猜"(旧 `auto_notify_new_file.sh`)
旧版 `get_sender()` 查 session DB 取"最近 5 分钟最后一个会话":
```sql
SELECT user_id FROM sessions
WHERE source='wecom' AND started_at > (now-300)
ORDER BY started_at DESC LIMIT 1
```
多人**并发**时,这个"最近活跃会话"经常不是真正发文件的人 → 发件人张冠李戴。
**已修复**:企微 adapter 落盘缓存文件时写 `.meta` 边车文件(`~/.hermes/cache/documents/<file>.meta`,字段 `sender_id / chat_id / chat_type`)。脚本改读 `${filepath}.meta`,不再靠"最近会话"猜。验证:`.meta` 内容形如 `sender_id=doro chat_id=doro type=dm`
## 根因 2:主动私信会"退化成回复"导致串号(adapter 层)
`gateway/platforms/wecom.py` `WeComAdapter.send(chat_id, content)` 在做主动 `aibot_send_msg` 前有一段**回复优先兜底**(约 1430–1445 行):
```python
reply_req_id = self._reply_req_id_for_message(reply_to)
if not reply_req_id and chat_id in self._last_chat_req_ids:
reply_req_id = self._last_chat_req_ids[chat_id] # ← 退化点
if reply_req_id:
response = await self._send_reply_markdown(reply_req_id, content) # 回复某条历史消息,而非主动私信
else:
payload = {"chatid": chat_id, "msgtype": "markdown", ...}
if not chat_id.startswith("wr"): # 群 ID 以 "wr" 开头;非 "wr" 当私聊
payload["chat_type"] = 1 # 主动单聊
response = await self._send_request(APP_CMD_SEND, payload)
```
`_last_chat_req_ids[chat_id]` 由**入站流量**填充(`_remember_chat_req_id`,约 533 行)。在长跑的 gateway 常驻进程里、多人并发时,某个 `chat_id` key 上记住的 `req_id` 可能绑定到**归属于另一个人的会话上下文**——于是"发给 doro"退化成"回复那条 req_id",落到错误的人头上。
> 注意两个独立的 dict:`_reply_req_ids`(按 **message_id** 存,给显式 `reply_to` 用)和 `_last_chat_req_ids`(按 **chat_id** 存,作群聊无 `reply_to` 时的兜底)。错投走的是后者这条 chat_id 兜底路径。
## 谁还在踩这条路径(主动私信脚本)
任何脚本调用 `_send_wecom(extra, 'doro', msg)``adapter.send()` 都会经过上面的回复兜底,存在同样的串号风险。已知:
- `auto_notify_new_file.sh`:**主循环已不再调用** `notify_doro()`(非 QiuTing 文件只记日志、不通知任何人,符合信息隔离铁律;QiuTing 走静默 workflow 不发中间通知)。`notify_doro()` 函数仍**定义着**但无调用点 → 主路径安全。
- `workflow-watchdog.sh`:其 `notify()` **仍在用** `_send_wecom(extra, 'doro', msg)`,workflow 崩溃重启时会触发,**未拆除的隐患**(低频但路径有风险)。
## 修复方向(动手前须经用户确认,勿擅改代码/脚本)
- **A(最稳)**:给 adapter 加"强制主动私信"参数,让脚本类通知**绕过 `_last_chat_req_ids` 回复兜底**,永远走 `chat_type=1` proactive 直发。一次修复,所有脚本受益。涉及代码库,宜拉熟代码的人(WeiWei)评审。
- **B(最快)**:把 `workflow-watchdog.sh` 的通知目标改到本机日志/Scott,彻底不碰串号路径。改动小。
- **C(最保守)**:先出一页纸根因+修复评审文档,定了再动。
### ✅ 方向 A 已有现成实现:`~/.hermes/scripts/wecom_dm.py`(agent 可直接用)
不必等改 adapter——这个独立脚本已经实现了"强制主动私信、绕过回复兜底":自开 WebSocket,`aibot_subscribe` 认证后直接 `aibot_send_msg + chat_type=1`,**永不退化成回复**,一条消息=一次干净 proactive send、目标唯一。带 `--list` 白名单(别名↔userid 三方交叉验证)、`--dry-run`、回执 message_id。
```bash
python3 ~/.hermes/scripts/wecom_dm.py --list
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "…" --dry-run
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "…"
```
凡是 agent 会话里要私信某个企微人(绕开 home channel)的场景,**首选这个脚本**,而不是 `send_message(target='wecom:…')`(会静默落 home channel)或 `_send_wecom(extra,…)`(需 gateway 内部 `extra`,会话里拿不到)。`workflow-watchdog.sh` 等仍硬编码 `_send_wecom(extra,'doro',…)` 的脚本,理想改法就是切到这个干净发送器。
## 诊断配方(只读,安全)
```bash
# 1. 脚本实际发了什么、发给谁(看 chat_id 与回执)
tail -n 80 /tmp/auto_notify_new_file.log
grep -aiE "Sending response .* to|aibot_send|chat_type|私信" ~/.hermes/logs/agent.log | tail -30
# 2. 真实 userid ↔ 人 映射(确认不是地址写错)
cd ~/.hermes/hermes-agent && source venv/bin/activate
python3 -c "import sqlite3;d=sqlite3.connect('$HOME/.hermes/state.db');[print(r) for r in d.execute(\"SELECT user_id,COUNT(*),MAX(started_at) FROM sessions WHERE source='wecom' GROUP BY user_id ORDER BY 2 DESC\")]"
# 3. .meta 边车归属是否正确
find ~/.hermes/cache/documents -name '*.meta' -exec cat {} \;
# 4. 谁还在用 doro 硬编码主动发送
# search_files pattern: _send_wecom|DORO_ID="doro" 在 ~/.hermes/scripts
```
## 一句话结论
"给 X 的消息错投给 Y"在企微里**首查 adapter 的 `_last_chat_req_ids` 回复兜底**与**脚本的发件人归属逻辑**,不要一上来就怀疑 userid 写错——userid 往往是对的,错在"主动私信退化成回复历史消息"。
+3
View File
@@ -0,0 +1,3 @@
---
description: GitHub workflow skills for managing repositories, pull requests, code reviews, issues, and CI/CD pipelines using the gh CLI and git via terminal.
---
+96
View File
@@ -0,0 +1,96 @@
---
name: github
description: "GitHub workflow: auth setup, PR lifecycle, code review, issue management, repo operations. Covers gh CLI and curl fallbacks."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [GitHub, Git, Pull-Requests, Code-Review, Issues, Repositories, gh-cli]
---
# GitHub Workflow — Complete Guide
End-to-end GitHub operations: authentication, repository management, PR lifecycle, code review, and issue tracking. Every section shows `gh` CLI first, then `git` + `curl` fallback for machines without `gh`.
## Quick Auth Check
Run this at the start of any GitHub workflow:
```bash
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
echo "AUTH_METHOD=gh"
elif [ -n "$GITHUB_TOKEN" ]; then
echo "AUTH_METHOD=curl"
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
echo "AUTH_METHOD=curl"
else
echo "AUTH_METHOD=none — need to set up auth first"
fi
```
See `references/auth.md` for complete auth setup (HTTPS tokens, SSH keys, gh CLI login, troubleshooting).
## Operations Overview
| Task | Reference | Key Commands |
|------|-----------|-------------|
| **Auth setup** | `references/auth.md` | `gh auth login`, SSH keys, credential helpers |
| **Repo management** | `references/repo-management.md` | clone, create, fork, releases, secrets, Actions |
| **PR lifecycle** | `references/pr-workflow.md` | branch, commit, open, CI monitoring, merge |
| **Code review** | `references/code-review.md` | local diff review, PR comments, inline review, submit review |
| **Issue management** | `references/issues.md` | create, triage, label, assign, bulk operations |
| **Codebase inspection** | `references/codebase-inspection.md` | LOC counts, language breakdown, code-vs-comment ratios via pygount |
## Common Patterns
### Creating a Feature PR (end-to-end)
1. Branch: `git checkout -b feat/description`
2. Commit with conventional format: `feat(scope): description`
3. Push: `git push -u origin HEAD`
4. Open PR: `gh pr create --title "..." --body "..."`
5. Monitor CI: `gh pr checks --watch`
6. Merge: `gh pr merge --squash --delete-branch`
See `references/pr-workflow.md` for the complete lifecycle with CI auto-fix loops.
### Reviewing a PR
1. Get PR context: `gh pr view N` + `gh pr diff N --name-only`
2. Check out locally: `git fetch origin pull/N/head:pr-N && git checkout pr-N`
3. Read diff + full files with `read_file`
4. Apply review checklist (correctness, security, quality, testing, performance)
5. Submit: `gh pr review N --approve|--request-changes|--comment`
See `references/code-review.md` for inline comments, formal reviews, and the review checklist.
### Triaging Issues
1. List untriaged: `gh issue list --label "needs-triage"`
2. Categorize and apply labels
3. Assign if owner is clear
4. Comment with triage notes
See `references/issues.md` for templates, bulk operations, and linking issues to PRs.
## Support Files
### References
- `references/auth.md` — Full authentication setup and troubleshooting
- `references/repo-management.md` — Clone, create, fork, settings, releases, secrets, Actions, gists
- `references/pr-workflow.md` — Branch creation, commits, CI monitoring, auto-fix, merging
- `references/code-review.md` — Local review, PR review, inline comments, formal review submission
- `references/issues.md` — Create, manage, triage, bulk operations
- `references/conventional-commits.md` — Commit message format conventions
- `references/ci-troubleshooting.md` — Diagnosing and fixing CI failures
- `references/review-output-template.md` — Structured review output format
- `references/github-api-cheatsheet.md` — GitHub REST API endpoint quick reference
### Templates
- `templates/bug-report.md` — Bug report issue template
- `templates/feature-request.md` — Feature request issue template
- `templates/pr-body-bugfix.md` — PR body template for bug fixes
- `templates/pr-body-feature.md` — PR body template for features
### Scripts
- `scripts/gh-env.sh` — Source this to set up GitHub auth env vars in shell
+247
View File
@@ -0,0 +1,247 @@
---
name: github-auth
description: "GitHub auth setup: HTTPS tokens, SSH keys, gh CLI login."
version: 1.1.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [GitHub, Authentication, Git, gh-cli, SSH, Setup]
related_skills: [github-pr-workflow, github-code-review, github-issues, github-repo-management]
---
# GitHub Authentication Setup
This skill sets up authentication so the agent can work with GitHub repositories, PRs, issues, and CI. It covers two paths:
- **`git` (always available)** — uses HTTPS personal access tokens or SSH keys
- **`gh` CLI (if installed)** — richer GitHub API access with a simpler auth flow
## Detection Flow
When a user asks you to work with GitHub, run this check first:
```bash
# Check what's available
git --version
gh --version 2>/dev/null || echo "gh not installed"
# Check if already authenticated
gh auth status 2>/dev/null || echo "gh not authenticated"
git config --global credential.helper 2>/dev/null || echo "no git credential helper"
```
**Decision tree:**
1. If `gh auth status` shows authenticated → you're good, use `gh` for everything
2. If `gh` is installed but not authenticated → use "gh auth" method below
3. If `gh` is not installed → use "git-only" method below (no sudo needed)
---
## Method 1: Git-Only Authentication (No gh, No sudo)
This works on any machine with `git` installed. No root access needed.
### Option A: HTTPS with Personal Access Token (Recommended)
This is the most portable method — works everywhere, no SSH config needed.
**Step 1: Create a personal access token**
Tell the user to go to: **https://github.com/settings/tokens**
- Click "Generate new token (classic)"
- Give it a name like "hermes-agent"
- Select scopes:
- `repo` (full repository access — read, write, push, PRs)
- `workflow` (trigger and manage GitHub Actions)
- `read:org` (if working with organization repos)
- Set expiration (90 days is a good default)
- Copy the token — it won't be shown again
**Step 2: Configure git to store the token**
```bash
# Set up the credential helper to cache credentials
# "store" saves to ~/.git-credentials in plaintext (simple, persistent)
git config --global credential.helper store
# Now do a test operation that triggers auth — git will prompt for credentials
# Username: <their-github-username>
# Password: <paste the personal access token, NOT their GitHub password>
git ls-remote https://github.com/<their-username>/<any-repo>.git
```
After entering credentials once, they're saved and reused for all future operations.
**Alternative: cache helper (credentials expire from memory)**
```bash
# Cache in memory for 8 hours (28800 seconds) instead of saving to disk
git config --global credential.helper 'cache --timeout=28800'
```
**Alternative: set the token directly in the remote URL (per-repo)**
```bash
# Embed token in the remote URL (avoids credential prompts entirely)
git remote set-url origin https://<username>:<token>@github.com/<owner>/<repo>.git
```
**Step 3: Configure git identity**
```bash
# Required for commits — set name and email
git config --global user.name "Their Name"
git config --global user.email "their-email@example.com"
```
**Step 4: Verify**
```bash
# Test push access (this should work without any prompts now)
git ls-remote https://github.com/<their-username>/<any-repo>.git
# Verify identity
git config --global user.name
git config --global user.email
```
### Option B: SSH Key Authentication
Good for users who prefer SSH or already have keys set up.
**Step 1: Check for existing SSH keys**
```bash
ls -la ~/.ssh/id_*.pub 2>/dev/null || echo "No SSH keys found"
```
**Step 2: Generate a key if needed**
```bash
# Generate an ed25519 key (modern, secure, fast)
ssh-keygen -t ed25519 -C "their-email@example.com" -f ~/.ssh/id_ed25519 -N ""
# Display the public key for them to add to GitHub
cat ~/.ssh/id_ed25519.pub
```
Tell the user to add the public key at: **https://github.com/settings/keys**
- Click "New SSH key"
- Paste the public key content
- Give it a title like "hermes-agent-<machine-name>"
**Step 3: Test the connection**
```bash
ssh -T git@github.com
# Expected: "Hi <username>! You've successfully authenticated..."
```
**Step 4: Configure git to use SSH for GitHub**
```bash
# Rewrite HTTPS GitHub URLs to SSH automatically
git config --global url."git@github.com:".insteadOf "https://github.com/"
```
**Step 5: Configure git identity**
```bash
git config --global user.name "Their Name"
git config --global user.email "their-email@example.com"
```
---
## Method 2: gh CLI Authentication
If `gh` is installed, it handles both API access and git credentials in one step.
### Interactive Browser Login (Desktop)
```bash
gh auth login
# Select: GitHub.com
# Select: HTTPS
# Authenticate via browser
```
### Token-Based Login (Headless / SSH Servers)
```bash
echo "<THEIR_TOKEN>" | gh auth login --with-token
# Set up git credentials through gh
gh auth setup-git
```
### Verify
```bash
gh auth status
```
---
## Using the GitHub API Without gh
When `gh` is not available, you can still access the full GitHub API using `curl` with a personal access token. This is how the other GitHub skills implement their fallbacks.
### Setting the Token for API Calls
```bash
# Option 1: Export as env var (preferred — keeps it out of commands)
export GITHUB_TOKEN="<token>"
# Then use in curl calls:
curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/user
```
### Extracting the Token from Git Credentials
If git credentials are already configured (via credential.helper store), the token can be extracted:
```bash
# Read from git credential store
grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|'
```
### Helper: Detect Auth Method
Use this pattern at the start of any GitHub workflow:
```bash
# Try gh first, fall back to git + curl
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
echo "AUTH_METHOD=gh"
elif [ -n "$GITHUB_TOKEN" ]; then
echo "AUTH_METHOD=curl"
elif [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
echo "AUTH_METHOD=curl"
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
echo "AUTH_METHOD=curl"
else
echo "AUTH_METHOD=none"
echo "Need to set up authentication first"
fi
```
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `git push` asks for password | GitHub disabled password auth. Use a personal access token as the password, or switch to SSH |
| `remote: Permission to X denied` | Token may lack `repo` scope — regenerate with correct scopes |
| `fatal: Authentication failed` | Cached credentials may be stale — run `git credential reject` then re-authenticate |
| `ssh: connect to host github.com port 22: Connection refused` | Try SSH over HTTPS port: add `Host github.com` with `Port 443` and `Hostname ssh.github.com` to `~/.ssh/config` |
| Credentials not persisting | Check `git config --global credential.helper` — must be `store` or `cache` |
| Multiple GitHub accounts | Use SSH with different keys per host alias in `~/.ssh/config`, or per-repo credential URLs |
| `gh: command not found` + no sudo | Use git-only Method 1 above — no installation needed |
@@ -0,0 +1,183 @@
# CI Troubleshooting Quick Reference
Common CI failure patterns and how to diagnose them from the logs.
## Reading CI Logs
```bash
# With gh
gh run view <RUN_ID> --log-failed
# With curl — download and extract
curl -sL -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/actions/runs/<RUN_ID>/logs \
-o /tmp/ci-logs.zip && unzip -o /tmp/ci-logs.zip -d /tmp/ci-logs
```
## Common Failure Patterns
### Test Failures
**Signatures in logs:**
```
FAILED tests/test_foo.py::test_bar - AssertionError
E assert 42 == 43
ERROR tests/test_foo.py - ModuleNotFoundError
```
**Diagnosis:**
1. Find the test file and line number from the traceback
2. Use `read_file` to read the failing test
3. Check if it's a logic error in the code or a stale test assertion
4. Look for `ModuleNotFoundError` — usually a missing dependency in CI
**Common fixes:**
- Update assertion to match new expected behavior
- Add missing dependency to requirements.txt / pyproject.toml
- Fix flaky test (add retry, mock external service, fix race condition)
---
### Lint / Formatting Failures
**Signatures in logs:**
```
src/auth.py:45:1: E302 expected 2 blank lines, got 1
src/models.py:12:80: E501 line too long (95 > 88 characters)
error: would reformat src/utils.py
```
**Diagnosis:**
1. Read the specific file:line numbers mentioned
2. Check which linter is complaining (flake8, ruff, black, isort, mypy)
**Common fixes:**
- Run the formatter locally: `black .`, `isort .`, `ruff check --fix .`
- Fix the specific style violation by editing the file
- If using `patch`, make sure to match existing indentation style
---
### Type Check Failures (mypy / pyright)
**Signatures in logs:**
```
src/api.py:23: error: Argument 1 to "process" has incompatible type "str"; expected "int"
src/models.py:45: error: Missing return statement
```
**Diagnosis:**
1. Read the file at the mentioned line
2. Check the function signature and what's being passed
**Common fixes:**
- Add type cast or conversion
- Fix the function signature
- Add `# type: ignore` comment as last resort (with explanation)
---
### Build / Compilation Failures
**Signatures in logs:**
```
ModuleNotFoundError: No module named 'some_package'
ERROR: Could not find a version that satisfies the requirement foo==1.2.3
npm ERR! Could not resolve dependency
```
**Diagnosis:**
1. Check requirements.txt / package.json for the missing or incompatible dependency
2. Compare local vs CI Python/Node version
**Common fixes:**
- Add missing dependency to requirements file
- Pin compatible version
- Update lockfile (`pip freeze`, `npm install`)
---
### Permission / Auth Failures
**Signatures in logs:**
```
fatal: could not read Username for 'https://github.com': No such device or address
Error: Resource not accessible by integration
403 Forbidden
```
**Diagnosis:**
1. Check if the workflow needs special permissions (token scopes)
2. Check if secrets are configured (missing `GITHUB_TOKEN` or custom secrets)
**Common fixes:**
- Add `permissions:` block to workflow YAML
- Verify secrets exist: `gh secret list` or check repo settings
- For fork PRs: some secrets aren't available by design
---
### Timeout Failures
**Signatures in logs:**
```
Error: The operation was canceled.
The job running on runner ... has exceeded the maximum execution time
```
**Diagnosis:**
1. Check which step timed out
2. Look for infinite loops, hung processes, or slow network calls
**Common fixes:**
- Add timeout to the specific step: `timeout-minutes: 10`
- Fix the underlying performance issue
- Split into parallel jobs
---
### Docker / Container Failures
**Signatures in logs:**
```
docker: Error response from daemon
failed to solve: ... not found
COPY failed: file not found in build context
```
**Diagnosis:**
1. Check Dockerfile for the failing step
2. Verify the referenced files exist in the repo
**Common fixes:**
- Fix path in COPY/ADD command
- Update base image tag
- Add missing file to `.dockerignore` exclusion or remove from it
---
## Auto-Fix Decision Tree
```
CI Failed
├── Test failure
│ ├── Assertion mismatch → update test or fix logic
│ └── Import/module error → add dependency
├── Lint failure → run formatter, fix style
├── Type error → fix types
├── Build failure
│ ├── Missing dep → add to requirements
│ └── Version conflict → update pins
├── Permission error → update workflow permissions (needs user)
└── Timeout → investigate perf (may need user input)
```
## Re-running After Fix
```bash
git add <fixed_files> && git commit -m "fix: resolve CI failure" && git push
# Then monitor
gh pr checks --watch 2>/dev/null || \
echo "Poll with: curl -s -H 'Authorization: token ...' https://api.github.com/repos/.../commits/$(git rev-parse HEAD)/status"
```
@@ -0,0 +1,481 @@
---
name: github-code-review
description: "Review PRs: diffs, inline comments via gh or REST."
version: 1.1.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [GitHub, Code-Review, Pull-Requests, Git, Quality]
related_skills: [github-auth, github-pr-workflow]
---
# GitHub Code Review
Perform code reviews on local changes before pushing, or review open PRs on GitHub. Most of this skill uses plain `git` — the `gh`/`curl` split only matters for PR-level interactions.
## Prerequisites
- Authenticated with GitHub (see `github-auth` skill)
- Inside a git repository
### Setup (for PR interactions)
```bash
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
AUTH="gh"
else
AUTH="git"
if [ -z "$GITHUB_TOKEN" ]; then
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
fi
fi
fi
REMOTE_URL=$(git remote get-url origin)
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
```
---
## 1. Reviewing Local Changes (Pre-Push)
This is pure `git` — works everywhere, no API needed.
### Get the Diff
```bash
# Staged changes (what would be committed)
git diff --staged
# All changes vs main (what a PR would contain)
git diff main...HEAD
# File names only
git diff main...HEAD --name-only
# Stat summary (insertions/deletions per file)
git diff main...HEAD --stat
```
### Review Strategy
1. **Get the big picture first:**
```bash
git diff main...HEAD --stat
git log main..HEAD --oneline
```
2. **Review file by file** — use `read_file` on changed files for full context, and the diff to see what changed:
```bash
git diff main...HEAD -- src/auth/login.py
```
3. **Check for common issues:**
```bash
# Debug statements, TODOs, console.logs left behind
git diff main...HEAD | grep -n "print(\|console\.log\|TODO\|FIXME\|HACK\|XXX\|debugger"
# Large files accidentally staged
git diff main...HEAD --stat | sort -t'|' -k2 -rn | head -10
# Secrets or credential patterns
git diff main...HEAD | grep -in "password\|secret\|api_key\|token.*=\|private_key"
# Merge conflict markers
git diff main...HEAD | grep -n "<<<<<<\|>>>>>>\|======="
```
4. **Present structured feedback** to the user.
### Review Output Format
When reviewing local changes, present findings in this structure:
```
## Code Review Summary
### Critical
- **src/auth.py:45** — SQL injection: user input passed directly to query.
Suggestion: Use parameterized queries.
### Warnings
- **src/models/user.py:23** — Password stored in plaintext. Use bcrypt or argon2.
- **src/api/routes.py:112** — No rate limiting on login endpoint.
### Suggestions
- **src/utils/helpers.py:8** — Duplicates logic in `src/core/utils.py:34`. Consolidate.
- **tests/test_auth.py** — Missing edge case: expired token test.
### Looks Good
- Clean separation of concerns in the middleware layer
- Good test coverage for the happy path
```
---
## 2. Reviewing a Pull Request on GitHub
### View PR Details
**With gh:**
```bash
gh pr view 123
gh pr diff 123
gh pr diff 123 --name-only
```
**With git + curl:**
```bash
PR_NUMBER=123
# Get PR details
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
| python3 -c "
import sys, json
pr = json.load(sys.stdin)
print(f\"Title: {pr['title']}\")
print(f\"Author: {pr['user']['login']}\")
print(f\"Branch: {pr['head']['ref']} -> {pr['base']['ref']}\")
print(f\"State: {pr['state']}\")
print(f\"Body:\n{pr['body']}\")"
# List changed files
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/files \
| python3 -c "
import sys, json
for f in json.load(sys.stdin):
print(f\"{f['status']:10} +{f['additions']:-4} -{f['deletions']:-4} {f['filename']}\")"
```
### Check Out PR Locally for Full Review
This works with plain `git` — no `gh` needed:
```bash
# Fetch the PR branch and check it out
git fetch origin pull/123/head:pr-123
git checkout pr-123
# Now you can use read_file, search_files, run tests, etc.
# View diff against the base branch
git diff main...pr-123
```
**With gh (shortcut):**
```bash
gh pr checkout 123
```
### Leave Comments on a PR
**General PR comment — with gh:**
```bash
gh pr comment 123 --body "Overall looks good, a few suggestions below."
```
**General PR comment — with curl:**
```bash
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/$PR_NUMBER/comments \
-d '{"body": "Overall looks good, a few suggestions below."}'
```
### Leave Inline Review Comments
**Single inline comment — with gh (via API):**
```bash
HEAD_SHA=$(gh pr view 123 --json headRefOid --jq '.headRefOid')
gh api repos/$OWNER/$REPO/pulls/123/comments \
--method POST \
-f body="This could be simplified with a list comprehension." \
-f path="src/auth/login.py" \
-f commit_id="$HEAD_SHA" \
-f line=45 \
-f side="RIGHT"
```
**Single inline comment — with curl:**
```bash
# Get the head commit SHA
HEAD_SHA=$(curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments \
-d "{
\"body\": \"This could be simplified with a list comprehension.\",
\"path\": \"src/auth/login.py\",
\"commit_id\": \"$HEAD_SHA\",
\"line\": 45,
\"side\": \"RIGHT\"
}"
```
### Submit a Formal Review (Approve / Request Changes)
**With gh:**
```bash
gh pr review 123 --approve --body "LGTM!"
gh pr review 123 --request-changes --body "See inline comments."
gh pr review 123 --comment --body "Some suggestions, nothing blocking."
```
**With curl — multi-comment review submitted atomically:**
```bash
HEAD_SHA=$(curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews \
-d "{
\"commit_id\": \"$HEAD_SHA\",
\"event\": \"COMMENT\",
\"body\": \"Code review from Hermes Agent\",
\"comments\": [
{\"path\": \"src/auth.py\", \"line\": 45, \"body\": \"Use parameterized queries to prevent SQL injection.\"},
{\"path\": \"src/models/user.py\", \"line\": 23, \"body\": \"Hash passwords with bcrypt before storing.\"},
{\"path\": \"tests/test_auth.py\", \"line\": 1, \"body\": \"Add test for expired token edge case.\"}
]
}"
```
Event values: `"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`
The `line` field refers to the line number in the *new* version of the file. For deleted lines, use `"side": "LEFT"`.
---
## 3. Review Checklist
When performing a code review (local or PR), systematically check:
### Correctness
- Does the code do what it claims?
- Edge cases handled (empty inputs, nulls, large data, concurrent access)?
- Error paths handled gracefully?
### Security
- No hardcoded secrets, credentials, or API keys
- Input validation on user-facing inputs
- No SQL injection, XSS, or path traversal
- Auth/authz checks where needed
### Code Quality
- Clear naming (variables, functions, classes)
- No unnecessary complexity or premature abstraction
- DRY — no duplicated logic that should be extracted
- Functions are focused (single responsibility)
### Testing
- New code paths tested?
- Happy path and error cases covered?
- Tests readable and maintainable?
### Performance
- No N+1 queries or unnecessary loops
- Appropriate caching where beneficial
- No blocking operations in async code paths
### Documentation
- Public APIs documented
- Non-obvious logic has comments explaining "why"
- README updated if behavior changed
---
## 4. Pre-Push Review Workflow
When the user asks you to "review the code" or "check before pushing":
1. `git diff main...HEAD --stat` — see scope of changes
2. `git diff main...HEAD` — read the full diff
3. For each changed file, use `read_file` if you need more context
4. Apply the checklist above
5. Present findings in the structured format (Critical / Warnings / Suggestions / Looks Good)
6. If critical issues found, offer to fix them before the user pushes
---
## 5. PR Review Workflow (End-to-End)
When the user asks you to "review PR #N", "look at this PR", or gives you a PR URL, follow this recipe:
### Step 1: Set up environment
```bash
source "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/gh-env.sh"
# Or run the inline setup block from the top of this skill
```
### Step 2: Gather PR context
Get the PR metadata, description, and list of changed files to understand scope before diving into code.
**With gh:**
```bash
gh pr view 123
gh pr diff 123 --name-only
gh pr checks 123
```
**With curl:**
```bash
PR_NUMBER=123
# PR details (title, author, description, branch)
curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER
# Changed files with line counts
curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER/files
```
### Step 3: Check out the PR locally
This gives you full access to `read_file`, `search_files`, and the ability to run tests.
```bash
git fetch origin pull/$PR_NUMBER/head:pr-$PR_NUMBER
git checkout pr-$PR_NUMBER
```
### Step 4: Read the diff and understand changes
```bash
# Full diff against the base branch
git diff main...HEAD
# Or file-by-file for large PRs
git diff main...HEAD --name-only
# Then for each file:
git diff main...HEAD -- path/to/file.py
```
For each changed file, use `read_file` to see full context around the changes — diffs alone can miss issues visible only with surrounding code.
### Step 5: Run automated checks locally (if applicable)
```bash
# Run tests if there's a test suite
python -m pytest 2>&1 | tail -20
# or: npm test, cargo test, go test ./..., etc.
# Run linter if configured
ruff check . 2>&1 | head -30
# or: eslint, clippy, etc.
```
### Step 6: Apply the review checklist (Section 3)
Go through each category: Correctness, Security, Code Quality, Testing, Performance, Documentation.
### Step 7: Post the review to GitHub
Collect your findings and submit them as a formal review with inline comments.
**With gh:**
```bash
# If no issues — approve
gh pr review $PR_NUMBER --approve --body "Reviewed by Hermes Agent. Code looks clean — good test coverage, no security concerns."
# If issues found — request changes with inline comments
gh pr review $PR_NUMBER --request-changes --body "Found a few issues — see inline comments."
```
**With curl — atomic review with multiple inline comments:**
```bash
HEAD_SHA=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER \
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
# Build the review JSON — event is APPROVE, REQUEST_CHANGES, or COMMENT
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER/reviews \
-d "{
\"commit_id\": \"$HEAD_SHA\",
\"event\": \"REQUEST_CHANGES\",
\"body\": \"## Hermes Agent Review\n\nFound 2 issues, 1 suggestion. See inline comments.\",
\"comments\": [
{\"path\": \"src/auth.py\", \"line\": 45, \"body\": \"🔴 **Critical:** User input passed directly to SQL query — use parameterized queries.\"},
{\"path\": \"src/models.py\", \"line\": 23, \"body\": \"⚠️ **Warning:** Password stored without hashing.\"},
{\"path\": \"src/utils.py\", \"line\": 8, \"body\": \"💡 **Suggestion:** This duplicates logic in core/utils.py:34.\"}
]
}"
```
### Step 8: Also post a summary comment
In addition to inline comments, leave a top-level summary so the PR author gets the full picture at a glance. Use the review output format from `references/review-output-template.md`.
**With gh:**
```bash
gh pr comment $PR_NUMBER --body "$(cat <<'EOF'
## Code Review Summary
**Verdict: Changes Requested** (2 issues, 1 suggestion)
### 🔴 Critical
- **src/auth.py:45** — SQL injection vulnerability
### ⚠️ Warnings
- **src/models.py:23** — Plaintext password storage
### 💡 Suggestions
- **src/utils.py:8** — Duplicated logic, consider consolidating
### ✅ Looks Good
- Clean API design
- Good error handling in the middleware layer
---
*Reviewed by Hermes Agent*
EOF
)"
```
### Step 9: Clean up
```bash
git checkout main
git branch -D pr-$PR_NUMBER
```
### Decision: Approve vs Request Changes vs Comment
- **Approve** — no critical or warning-level issues, only minor suggestions or all clear
- **Request Changes** — any critical or warning-level issue that should be fixed before merge
- **Comment** — observations and suggestions, but nothing blocking (use when you're unsure or the PR is a draft)
@@ -0,0 +1,116 @@
---
name: codebase-inspection
description: "Inspect codebases w/ pygount: LOC, languages, ratios."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [LOC, Code Analysis, pygount, Codebase, Metrics, Repository]
related_skills: [github-repo-management]
prerequisites:
commands: [pygount]
---
# Codebase Inspection with pygount
Analyze repositories for lines of code, language breakdown, file counts, and code-vs-comment ratios using `pygount`.
## When to Use
- User asks for LOC (lines of code) count
- User wants a language breakdown of a repo
- User asks about codebase size or composition
- User wants code-vs-comment ratios
- General "how big is this repo" questions
## Prerequisites
```bash
pip install --break-system-packages pygount 2>/dev/null || pip install pygount
```
## 1. Basic Summary (Most Common)
Get a full language breakdown with file counts, code lines, and comment lines:
```bash
cd /path/to/repo
pygount --format=summary \
--folders-to-skip=".git,node_modules,venv,.venv,__pycache__,.cache,dist,build,.next,.tox,.eggs,*.egg-info" \
.
```
**IMPORTANT:** Always use `--folders-to-skip` to exclude dependency/build directories, otherwise pygount will crawl them and take a very long time or hang.
## 2. Common Folder Exclusions
Adjust based on the project type:
```bash
# Python projects
--folders-to-skip=".git,venv,.venv,__pycache__,.cache,dist,build,.tox,.eggs,.mypy_cache"
# JavaScript/TypeScript projects
--folders-to-skip=".git,node_modules,dist,build,.next,.cache,.turbo,coverage"
# General catch-all
--folders-to-skip=".git,node_modules,venv,.venv,__pycache__,.cache,dist,build,.next,.tox,vendor,third_party"
```
## 3. Filter by Specific Language
```bash
# Only count Python files
pygount --suffix=py --format=summary .
# Only count Python and YAML
pygount --suffix=py,yaml,yml --format=summary .
```
## 4. Detailed File-by-File Output
```bash
# Default format shows per-file breakdown
pygount --folders-to-skip=".git,node_modules,venv" .
# Sort by code lines (pipe through sort)
pygount --folders-to-skip=".git,node_modules,venv" . | sort -t$'\t' -k1 -nr | head -20
```
## 5. Output Formats
```bash
# Summary table (default recommendation)
pygount --format=summary .
# JSON output for programmatic use
pygount --format=json .
# Pipe-friendly: Language, file count, code, docs, empty, string
pygount --format=summary . 2>/dev/null
```
## 6. Interpreting Results
The summary table columns:
- **Language** — detected programming language
- **Files** — number of files of that language
- **Code** — lines of actual code (executable/declarative)
- **Comment** — lines that are comments or documentation
- **%** — percentage of total
Special pseudo-languages:
- `__empty__` — empty files
- `__binary__` — binary files (images, compiled, etc.)
- `__generated__` — auto-generated files (detected heuristically)
- `__duplicate__` — files with identical content
- `__unknown__` — unrecognized file types
## Pitfalls
1. **Always exclude .git, node_modules, venv** — without `--folders-to-skip`, pygount will crawl everything and may take minutes or hang on large dependency trees.
2. **Markdown shows 0 code lines** — pygount classifies all Markdown content as comments, not code. This is expected behavior.
3. **JSON files show low code counts** — pygount may count JSON lines conservatively. For accurate JSON line counts, use `wc -l` directly.
4. **Large monorepos** — for very large repos, consider using `--suffix` to target specific languages rather than scanning everything.
@@ -0,0 +1,71 @@
# Conventional Commits Quick Reference
Format: `type(scope): description`
## Types
| Type | When to use | Example |
|------|------------|---------|
| `feat` | New feature or capability | `feat(auth): add OAuth2 login flow` |
| `fix` | Bug fix | `fix(api): handle null response from /users endpoint` |
| `refactor` | Code restructuring, no behavior change | `refactor(db): extract query builder into separate module` |
| `docs` | Documentation only | `docs: update API usage examples in README` |
| `test` | Adding or updating tests | `test(auth): add integration tests for token refresh` |
| `ci` | CI/CD configuration | `ci: add Python 3.12 to test matrix` |
| `chore` | Maintenance, dependencies, tooling | `chore: upgrade pytest to 8.x` |
| `perf` | Performance improvement | `perf(search): add index on users.email column` |
| `style` | Formatting, whitespace, semicolons | `style: run black formatter on src/` |
| `build` | Build system or external deps | `build: switch from setuptools to hatch` |
| `revert` | Reverts a previous commit | `revert: revert "feat(auth): add OAuth2 login flow"` |
## Scope (optional)
Short identifier for the area of the codebase: `auth`, `api`, `db`, `ui`, `cli`, etc.
## Breaking Changes
Add `!` after type or `BREAKING CHANGE:` in footer:
```
feat(api)!: change authentication to use bearer tokens
BREAKING CHANGE: API endpoints now require Bearer token instead of API key header.
Migration guide: https://docs.example.com/migrate-auth
```
## Multi-line Body
Wrap at 72 characters. Use bullet points for multiple changes:
```
feat(auth): add JWT-based user authentication
- Add login/register endpoints with input validation
- Add User model with argon2 password hashing
- Add auth middleware for protected routes
- Add token refresh endpoint with rotation
Closes #42
```
## Linking Issues
In the commit body or footer:
```
Closes #42 ← closes the issue when merged
Fixes #42 ← same effect
Refs #42 ← references without closing
Co-authored-by: Name <email>
```
## Quick Decision Guide
- Added something new? → `feat`
- Something was broken and you fixed it? → `fix`
- Changed how code is organized but not what it does? → `refactor`
- Only touched tests? → `test`
- Only touched docs? → `docs`
- Updated CI/CD pipelines? → `ci`
- Updated dependencies or tooling? → `chore`
- Made something faster? → `perf`
@@ -0,0 +1,161 @@
# GitHub REST API Cheatsheet
Base URL: `https://api.github.com`
All requests need: `-H "Authorization: token $GITHUB_TOKEN"`
Use the `gh-env.sh` helper to set `$GITHUB_TOKEN`, `$GH_OWNER`, `$GH_REPO` automatically:
```bash
source "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/gh-env.sh"
```
## Repositories
| Action | Method | Endpoint |
|--------|--------|----------|
| Get repo info | GET | `/repos/{owner}/{repo}` |
| Create repo (user) | POST | `/user/repos` |
| Create repo (org) | POST | `/orgs/{org}/repos` |
| Update repo | PATCH | `/repos/{owner}/{repo}` |
| Delete repo | DELETE | `/repos/{owner}/{repo}` |
| List your repos | GET | `/user/repos?per_page=30&sort=updated` |
| List org repos | GET | `/orgs/{org}/repos` |
| Fork repo | POST | `/repos/{owner}/{repo}/forks` |
| Create from template | POST | `/repos/{owner}/{template}/generate` |
| Get topics | GET | `/repos/{owner}/{repo}/topics` |
| Set topics | PUT | `/repos/{owner}/{repo}/topics` |
## Pull Requests
| Action | Method | Endpoint |
|--------|--------|----------|
| List PRs | GET | `/repos/{owner}/{repo}/pulls?state=open` |
| Create PR | POST | `/repos/{owner}/{repo}/pulls` |
| Get PR | GET | `/repos/{owner}/{repo}/pulls/{number}` |
| Update PR | PATCH | `/repos/{owner}/{repo}/pulls/{number}` |
| List PR files | GET | `/repos/{owner}/{repo}/pulls/{number}/files` |
| Merge PR | PUT | `/repos/{owner}/{repo}/pulls/{number}/merge` |
| Request reviewers | POST | `/repos/{owner}/{repo}/pulls/{number}/requested_reviewers` |
| Create review | POST | `/repos/{owner}/{repo}/pulls/{number}/reviews` |
| Inline comment | POST | `/repos/{owner}/{repo}/pulls/{number}/comments` |
### PR Merge Body
```json
{"merge_method": "squash", "commit_title": "feat: description (#N)"}
```
Merge methods: `"merge"`, `"squash"`, `"rebase"`
### PR Review Events
`"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`
## Issues
| Action | Method | Endpoint |
|--------|--------|----------|
| List issues | GET | `/repos/{owner}/{repo}/issues?state=open` |
| Create issue | POST | `/repos/{owner}/{repo}/issues` |
| Get issue | GET | `/repos/{owner}/{repo}/issues/{number}` |
| Update issue | PATCH | `/repos/{owner}/{repo}/issues/{number}` |
| Add comment | POST | `/repos/{owner}/{repo}/issues/{number}/comments` |
| Add labels | POST | `/repos/{owner}/{repo}/issues/{number}/labels` |
| Remove label | DELETE | `/repos/{owner}/{repo}/issues/{number}/labels/{name}` |
| Add assignees | POST | `/repos/{owner}/{repo}/issues/{number}/assignees` |
| List labels | GET | `/repos/{owner}/{repo}/labels` |
| Search issues | GET | `/search/issues?q={query}+repo:{owner}/{repo}` |
Note: The Issues API also returns PRs. Filter with `"pull_request" not in item` when parsing.
## CI / GitHub Actions
| Action | Method | Endpoint |
|--------|--------|----------|
| List workflows | GET | `/repos/{owner}/{repo}/actions/workflows` |
| List runs | GET | `/repos/{owner}/{repo}/actions/runs?per_page=10` |
| List runs (branch) | GET | `/repos/{owner}/{repo}/actions/runs?branch={branch}` |
| Get run | GET | `/repos/{owner}/{repo}/actions/runs/{run_id}` |
| Download logs | GET | `/repos/{owner}/{repo}/actions/runs/{run_id}/logs` |
| Re-run | POST | `/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` |
| Re-run failed | POST | `/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` |
| Trigger dispatch | POST | `/repos/{owner}/{repo}/actions/workflows/{id}/dispatches` |
| Commit status | GET | `/repos/{owner}/{repo}/commits/{sha}/status` |
| Check runs | GET | `/repos/{owner}/{repo}/commits/{sha}/check-runs` |
## Releases
| Action | Method | Endpoint |
|--------|--------|----------|
| List releases | GET | `/repos/{owner}/{repo}/releases` |
| Create release | POST | `/repos/{owner}/{repo}/releases` |
| Get release | GET | `/repos/{owner}/{repo}/releases/{id}` |
| Delete release | DELETE | `/repos/{owner}/{repo}/releases/{id}` |
| Upload asset | POST | `https://uploads.github.com/repos/{owner}/{repo}/releases/{id}/assets?name={filename}` |
## Secrets
| Action | Method | Endpoint |
|--------|--------|----------|
| List secrets | GET | `/repos/{owner}/{repo}/actions/secrets` |
| Get public key | GET | `/repos/{owner}/{repo}/actions/secrets/public-key` |
| Set secret | PUT | `/repos/{owner}/{repo}/actions/secrets/{name}` |
| Delete secret | DELETE | `/repos/{owner}/{repo}/actions/secrets/{name}` |
## Branch Protection
| Action | Method | Endpoint |
|--------|--------|----------|
| Get protection | GET | `/repos/{owner}/{repo}/branches/{branch}/protection` |
| Set protection | PUT | `/repos/{owner}/{repo}/branches/{branch}/protection` |
| Delete protection | DELETE | `/repos/{owner}/{repo}/branches/{branch}/protection` |
## User / Auth
| Action | Method | Endpoint |
|--------|--------|----------|
| Get current user | GET | `/user` |
| List user repos | GET | `/user/repos` |
| List user gists | GET | `/gists` |
| Create gist | POST | `/gists` |
| Search repos | GET | `/search/repositories?q={query}` |
## Pagination
Most list endpoints support:
- `?per_page=100` (max 100)
- `?page=2` for next page
- Check `Link` header for `rel="next"` URL
## Rate Limits
- Authenticated: 5,000 requests/hour
- Check remaining: `curl -s -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/rate_limit`
## Common curl Patterns
```bash
# GET
curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO
# POST with JSON body
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/issues \
-d '{"title": "...", "body": "..."}'
# PATCH (update)
curl -s -X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/issues/42 \
-d '{"state": "closed"}'
# DELETE
curl -s -X DELETE \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/issues/42/labels/bug
# Parse JSON response with python3
curl -s ... | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['field'])"
```
+370
View File
@@ -0,0 +1,370 @@
---
name: github-issues
description: "Create, triage, label, assign GitHub issues via gh or REST."
version: 1.1.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [GitHub, Issues, Project-Management, Bug-Tracking, Triage]
related_skills: [github-auth, github-pr-workflow]
---
# GitHub Issues Management
Create, search, triage, and manage GitHub issues. Each section shows `gh` first, then the `curl` fallback.
## Prerequisites
- Authenticated with GitHub (see `github-auth` skill)
- Inside a git repo with a GitHub remote, or specify the repo explicitly
### Setup
```bash
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
AUTH="gh"
else
AUTH="git"
if [ -z "$GITHUB_TOKEN" ]; then
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
fi
fi
fi
REMOTE_URL=$(git remote get-url origin)
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
```
---
## 1. Viewing Issues
**With gh:**
```bash
gh issue list
gh issue list --state open --label "bug"
gh issue list --assignee @me
gh issue list --search "authentication error" --state all
gh issue view 42
```
**With curl:**
```bash
# List open issues
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues?state=open&per_page=20" \
| python3 -c "
import sys, json
for i in json.load(sys.stdin):
if 'pull_request' not in i: # GitHub API returns PRs in /issues too
labels = ', '.join(l['name'] for l in i['labels'])
print(f\"#{i['number']:5} {i['state']:6} {labels:30} {i['title']}\")"
# Filter by label
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues?state=open&labels=bug&per_page=20" \
| python3 -c "
import sys, json
for i in json.load(sys.stdin):
if 'pull_request' not in i:
print(f\"#{i['number']} {i['title']}\")"
# View a specific issue
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/42 \
| python3 -c "
import sys, json
i = json.load(sys.stdin)
labels = ', '.join(l['name'] for l in i['labels'])
assignees = ', '.join(a['login'] for a in i['assignees'])
print(f\"#{i['number']}: {i['title']}\")
print(f\"State: {i['state']} Labels: {labels} Assignees: {assignees}\")
print(f\"Author: {i['user']['login']} Created: {i['created_at']}\")
print(f\"\n{i['body']}\")"
# Search issues
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/search/issues?q=authentication+error+repo:$OWNER/$REPO" \
| python3 -c "
import sys, json
for i in json.load(sys.stdin)['items']:
print(f\"#{i['number']} {i['state']:6} {i['title']}\")"
```
## 2. Creating Issues
**With gh:**
```bash
gh issue create \
--title "Login redirect ignores ?next= parameter" \
--body "## Description
After logging in, users always land on /dashboard.
## Steps to Reproduce
1. Navigate to /settings while logged out
2. Get redirected to /login?next=/settings
3. Log in
4. Actual: redirected to /dashboard (should go to /settings)
## Expected Behavior
Respect the ?next= query parameter." \
--label "bug,backend" \
--assignee "username"
```
**With curl:**
```bash
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues \
-d '{
"title": "Login redirect ignores ?next= parameter",
"body": "## Description\nAfter logging in, users always land on /dashboard.\n\n## Steps to Reproduce\n1. Navigate to /settings while logged out\n2. Get redirected to /login?next=/settings\n3. Log in\n4. Actual: redirected to /dashboard\n\n## Expected Behavior\nRespect the ?next= query parameter.",
"labels": ["bug", "backend"],
"assignees": ["username"]
}'
```
### Bug Report Template
```
## Bug Description
<What's happening>
## Steps to Reproduce
1. <step>
2. <step>
## Expected Behavior
<What should happen>
## Actual Behavior
<What actually happens>
## Environment
- OS: <os>
- Version: <version>
```
### Feature Request Template
```
## Feature Description
<What you want>
## Motivation
<Why this would be useful>
## Proposed Solution
<How it could work>
## Alternatives Considered
<Other approaches>
```
## 3. Managing Issues
### Add/Remove Labels
**With gh:**
```bash
gh issue edit 42 --add-label "priority:high,bug"
gh issue edit 42 --remove-label "needs-triage"
```
**With curl:**
```bash
# Add labels
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/42/labels \
-d '{"labels": ["priority:high", "bug"]}'
# Remove a label
curl -s -X DELETE \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/42/labels/needs-triage
# List available labels in the repo
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/labels \
| python3 -c "
import sys, json
for l in json.load(sys.stdin):
print(f\" {l['name']:30} {l.get('description', '')}\")"
```
### Assignment
**With gh:**
```bash
gh issue edit 42 --add-assignee username
gh issue edit 42 --add-assignee @me
```
**With curl:**
```bash
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/42/assignees \
-d '{"assignees": ["username"]}'
```
### Commenting
**With gh:**
```bash
gh issue comment 42 --body "Investigated — root cause is in auth middleware. Working on a fix."
```
**With curl:**
```bash
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/42/comments \
-d '{"body": "Investigated — root cause is in auth middleware. Working on a fix."}'
```
### Closing and Reopening
**With gh:**
```bash
gh issue close 42
gh issue close 42 --reason "not planned"
gh issue reopen 42
```
**With curl:**
```bash
# Close
curl -s -X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/42 \
-d '{"state": "closed", "state_reason": "completed"}'
# Reopen
curl -s -X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/42 \
-d '{"state": "open"}'
```
### Linking Issues to PRs
Issues are automatically closed when a PR merges with the right keywords in the body:
```
Closes #42
Fixes #42
Resolves #42
```
To create a branch from an issue:
**With gh:**
```bash
gh issue develop 42 --checkout
```
**With git (manual equivalent):**
```bash
git checkout main && git pull origin main
git checkout -b fix/issue-42-login-redirect
```
## 4. Issue Triage Workflow
When asked to triage issues:
1. **List untriaged issues:**
```bash
# With gh
gh issue list --label "needs-triage" --state open
# With curl
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues?labels=needs-triage&state=open" \
| python3 -c "
import sys, json
for i in json.load(sys.stdin):
if 'pull_request' not in i:
print(f\"#{i['number']} {i['title']}\")"
```
2. **Read and categorize** each issue (view details, understand the bug/feature)
3. **Apply labels and priority** (see Managing Issues above)
4. **Assign** if the owner is clear
5. **Comment with triage notes** if needed
## 5. Bulk Operations
For batch operations, combine API calls with shell scripting:
**With gh:**
```bash
# Close all issues with a specific label
gh issue list --label "wontfix" --json number --jq '.[].number' | \
xargs -I {} gh issue close {} --reason "not planned"
```
**With curl:**
```bash
# List issue numbers with a label, then close each
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/issues?labels=wontfix&state=open" \
| python3 -c "import sys,json; [print(i['number']) for i in json.load(sys.stdin)]" \
| while read num; do
curl -s -X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/$num \
-d '{"state": "closed", "state_reason": "not_planned"}'
echo "Closed #$num"
done
```
## Quick Reference Table
| Action | gh | curl endpoint |
|--------|-----|--------------|
| List issues | `gh issue list` | `GET /repos/{o}/{r}/issues` |
| View issue | `gh issue view N` | `GET /repos/{o}/{r}/issues/N` |
| Create issue | `gh issue create ...` | `POST /repos/{o}/{r}/issues` |
| Add labels | `gh issue edit N --add-label ...` | `POST /repos/{o}/{r}/issues/N/labels` |
| Assign | `gh issue edit N --add-assignee ...` | `POST /repos/{o}/{r}/issues/N/assignees` |
| Comment | `gh issue comment N --body ...` | `POST /repos/{o}/{r}/issues/N/comments` |
| Close | `gh issue close N` | `PATCH /repos/{o}/{r}/issues/N` |
| Search | `gh issue list --search "..."` | `GET /search/issues?q=...` |
@@ -0,0 +1,367 @@
---
name: github-pr-workflow
description: "GitHub PR lifecycle: branch, commit, open, CI, merge."
version: 1.1.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [GitHub, Pull-Requests, CI/CD, Git, Automation, Merge]
related_skills: [github-auth, github-code-review]
---
# GitHub Pull Request Workflow
Complete guide for managing the PR lifecycle. Each section shows the `gh` way first, then the `git` + `curl` fallback for machines without `gh`.
## Prerequisites
- Authenticated with GitHub (see `github-auth` skill)
- Inside a git repository with a GitHub remote
### Quick Auth Detection
```bash
# Determine which method to use throughout this workflow
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
AUTH="gh"
else
AUTH="git"
# Ensure we have a token for API calls
if [ -z "$GITHUB_TOKEN" ]; then
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
fi
fi
fi
echo "Using: $AUTH"
```
### Extracting Owner/Repo from the Git Remote
Many `curl` commands need `owner/repo`. Extract it from the git remote:
```bash
# Works for both HTTPS and SSH remote URLs
REMOTE_URL=$(git remote get-url origin)
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
echo "Owner: $OWNER, Repo: $REPO"
```
---
## 1. Branch Creation
This part is pure `git` — identical either way:
```bash
# Make sure you're up to date
git fetch origin
git checkout main && git pull origin main
# Create and switch to a new branch
git checkout -b feat/add-user-authentication
```
Branch naming conventions:
- `feat/description` — new features
- `fix/description` — bug fixes
- `refactor/description` — code restructuring
- `docs/description` — documentation
- `ci/description` — CI/CD changes
## 2. Making Commits
Use the agent's file tools (`write_file`, `patch`) to make changes, then commit:
```bash
# Stage specific files
git add src/auth.py src/models/user.py tests/test_auth.py
# Commit with a conventional commit message
git commit -m "feat: add JWT-based user authentication
- Add login/register endpoints
- Add User model with password hashing
- Add auth middleware for protected routes
- Add unit tests for auth flow"
```
Commit message format (Conventional Commits):
```
type(scope): short description
Longer explanation if needed. Wrap at 72 characters.
```
Types: `feat`, `fix`, `refactor`, `docs`, `test`, `ci`, `chore`, `perf`
## 3. Pushing and Creating a PR
### Push the Branch (same either way)
```bash
git push -u origin HEAD
```
### Create the PR
**With gh:**
```bash
gh pr create \
--title "feat: add JWT-based user authentication" \
--body "## Summary
- Adds login and register API endpoints
- JWT token generation and validation
## Test Plan
- [ ] Unit tests pass
Closes #42"
```
Options: `--draft`, `--reviewer user1,user2`, `--label "enhancement"`, `--base develop`
**With git + curl:**
```bash
BRANCH=$(git branch --show-current)
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/$OWNER/$REPO/pulls \
-d "{
\"title\": \"feat: add JWT-based user authentication\",
\"body\": \"## Summary\nAdds login and register API endpoints.\n\nCloses #42\",
\"head\": \"$BRANCH\",
\"base\": \"main\"
}"
```
The response JSON includes the PR `number` — save it for later commands.
To create as a draft, add `"draft": true` to the JSON body.
## 4. Monitoring CI Status
### Check CI Status
**With gh:**
```bash
# One-shot check
gh pr checks
# Watch until all checks finish (polls every 10s)
gh pr checks --watch
```
**With git + curl:**
```bash
# Get the latest commit SHA on the current branch
SHA=$(git rev-parse HEAD)
# Query the combined status
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/commits/$SHA/status \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
print(f\"Overall: {data['state']}\")
for s in data.get('statuses', []):
print(f\" {s['context']}: {s['state']} - {s.get('description', '')}\")"
# Also check GitHub Actions check runs (separate endpoint)
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/commits/$SHA/check-runs \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for cr in data.get('check_runs', []):
print(f\" {cr['name']}: {cr['status']} / {cr['conclusion'] or 'pending'}\")"
```
### Poll Until Complete (git + curl)
```bash
# Simple polling loop — check every 30 seconds, up to 10 minutes
SHA=$(git rev-parse HEAD)
for i in $(seq 1 20); do
STATUS=$(curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/commits/$SHA/status \
| python3 -c "import sys,json; print(json.load(sys.stdin)['state'])")
echo "Check $i: $STATUS"
if [ "$STATUS" = "success" ] || [ "$STATUS" = "failure" ] || [ "$STATUS" = "error" ]; then
break
fi
sleep 30
done
```
## 5. Auto-Fixing CI Failures
When CI fails, diagnose and fix. This loop works with either auth method.
### Step 1: Get Failure Details
**With gh:**
```bash
# List recent workflow runs on this branch
gh run list --branch $(git branch --show-current) --limit 5
# View failed logs
gh run view <RUN_ID> --log-failed
```
**With git + curl:**
```bash
BRANCH=$(git branch --show-current)
# List workflow runs on this branch
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/actions/runs?branch=$BRANCH&per_page=5" \
| python3 -c "
import sys, json
runs = json.load(sys.stdin)['workflow_runs']
for r in runs:
print(f\"Run {r['id']}: {r['name']} - {r['conclusion'] or r['status']}\")"
# Get failed job logs (download as zip, extract, read)
RUN_ID=<run_id>
curl -s -L \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/runs/$RUN_ID/logs \
-o /tmp/ci-logs.zip
cd /tmp && unzip -o ci-logs.zip -d ci-logs && cat ci-logs/*.txt
```
### Step 2: Fix and Push
After identifying the issue, use file tools (`patch`, `write_file`) to fix it:
```bash
git add <fixed_files>
git commit -m "fix: resolve CI failure in <check_name>"
git push
```
### Step 3: Verify
Re-check CI status using the commands from Section 4 above.
### Auto-Fix Loop Pattern
When asked to auto-fix CI, follow this loop:
1. Check CI status → identify failures
2. Read failure logs → understand the error
3. Use `read_file` + `patch`/`write_file` → fix the code
4. `git add . && git commit -m "fix: ..." && git push`
5. Wait for CI → re-check status
6. Repeat if still failing (up to 3 attempts, then ask the user)
## 6. Merging
**With gh:**
```bash
# Squash merge + delete branch (cleanest for feature branches)
gh pr merge --squash --delete-branch
# Enable auto-merge (merges when all checks pass)
gh pr merge --auto --squash --delete-branch
```
**With git + curl:**
```bash
PR_NUMBER=<number>
# Merge the PR via API (squash)
curl -s -X PUT \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/merge \
-d "{
\"merge_method\": \"squash\",
\"commit_title\": \"feat: add user authentication (#$PR_NUMBER)\"
}"
# Delete the remote branch after merge
BRANCH=$(git branch --show-current)
git push origin --delete $BRANCH
# Switch back to main locally
git checkout main && git pull origin main
git branch -d $BRANCH
```
Merge methods: `"merge"` (merge commit), `"squash"`, `"rebase"`
### Enable Auto-Merge (curl)
```bash
# Auto-merge requires the repo to have it enabled in settings.
# This uses the GraphQL API since REST doesn't support auto-merge.
PR_NODE_ID=$(curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
| python3 -c "import sys,json; print(json.load(sys.stdin)['node_id'])")
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/graphql \
-d "{\"query\": \"mutation { enablePullRequestAutoMerge(input: {pullRequestId: \\\"$PR_NODE_ID\\\", mergeMethod: SQUASH}) { clientMutationId } }\"}"
```
## 7. Complete Workflow Example
```bash
# 1. Start from clean main
git checkout main && git pull origin main
# 2. Branch
git checkout -b fix/login-redirect-bug
# 3. (Agent makes code changes with file tools)
# 4. Commit
git add src/auth/login.py tests/test_login.py
git commit -m "fix: correct redirect URL after login
Preserves the ?next= parameter instead of always redirecting to /dashboard."
# 5. Push
git push -u origin HEAD
# 6. Create PR (picks gh or curl based on what's available)
# ... (see Section 3)
# 7. Monitor CI (see Section 4)
# 8. Merge when green (see Section 6)
```
## Useful PR Commands Reference
| Action | gh | git + curl |
|--------|-----|-----------|
| List my PRs | `gh pr list --author @me` | `curl -s -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$OWNER/$REPO/pulls?state=open"` |
| View PR diff | `gh pr diff` | `git diff main...HEAD` (local) or `curl -H "Accept: application/vnd.github.diff" ...` |
| Add comment | `gh pr comment N --body "..."` | `curl -X POST .../issues/N/comments -d '{"body":"..."}'` |
| Request review | `gh pr edit N --add-reviewer user` | `curl -X POST .../pulls/N/requested_reviewers -d '{"reviewers":["user"]}'` |
| Close PR | `gh pr close N` | `curl -X PATCH .../pulls/N -d '{"state":"closed"}'` |
| Check out someone's PR | `gh pr checkout N` | `git fetch origin pull/N/head:pr-N && git checkout pr-N` |
@@ -0,0 +1,516 @@
---
name: github-repo-management
description: "Clone/create/fork repos; manage remotes, releases."
version: 1.1.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [GitHub, Repositories, Git, Releases, Secrets, Configuration]
related_skills: [github-auth, github-pr-workflow, github-issues]
---
# GitHub Repository Management
Create, clone, fork, configure, and manage GitHub repositories. Each section shows `gh` first, then the `git` + `curl` fallback.
## Prerequisites
- Authenticated with GitHub (see `github-auth` skill)
### Setup
```bash
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
AUTH="gh"
else
AUTH="git"
if [ -z "$GITHUB_TOKEN" ]; then
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
fi
fi
fi
# Get your GitHub username (needed for several operations)
if [ "$AUTH" = "gh" ]; then
GH_USER=$(gh api user --jq '.login')
else
GH_USER=$(curl -s -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/user | python3 -c "import sys,json; print(json.load(sys.stdin)['login'])")
fi
```
If you're inside a repo already:
```bash
REMOTE_URL=$(git remote get-url origin)
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
```
---
## 1. Cloning Repositories
Cloning is pure `git` — works identically either way:
```bash
# Clone via HTTPS (works with credential helper or token-embedded URL)
git clone https://github.com/owner/repo-name.git
# Clone into a specific directory
git clone https://github.com/owner/repo-name.git ./my-local-dir
# Shallow clone (faster for large repos)
git clone --depth 1 https://github.com/owner/repo-name.git
# Clone a specific branch
git clone --branch develop https://github.com/owner/repo-name.git
# Clone via SSH (if SSH is configured)
git clone git@github.com:owner/repo-name.git
```
**With gh (shorthand):**
```bash
gh repo clone owner/repo-name
gh repo clone owner/repo-name -- --depth 1
```
## 2. Creating Repositories
**With gh:**
```bash
# Create a public repo and clone it
gh repo create my-new-project --public --clone
# Private, with description and license
gh repo create my-new-project --private --description "A useful tool" --license MIT --clone
# Under an organization
gh repo create my-org/my-new-project --public --clone
# From existing local directory
cd /path/to/existing/project
gh repo create my-project --source . --public --push
```
**With git + curl:**
```bash
# Create the remote repo via API
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/user/repos \
-d '{
"name": "my-new-project",
"description": "A useful tool",
"private": false,
"auto_init": true,
"license_template": "mit"
}'
# Clone it
git clone https://github.com/$GH_USER/my-new-project.git
cd my-new-project
# -- OR -- push an existing local directory to the new repo
cd /path/to/existing/project
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/$GH_USER/my-new-project.git
git push -u origin main
```
To create under an organization:
```bash
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/orgs/my-org/repos \
-d '{"name": "my-new-project", "private": false}'
```
### From a Template
**With gh:**
```bash
gh repo create my-new-app --template owner/template-repo --public --clone
```
**With curl:**
```bash
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/owner/template-repo/generate \
-d '{"owner": "'"$GH_USER"'", "name": "my-new-app", "private": false}'
```
## 3. Forking Repositories
**With gh:**
```bash
gh repo fork owner/repo-name --clone
```
**With git + curl:**
```bash
# Create the fork via API
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/owner/repo-name/forks
# Wait a moment for GitHub to create it, then clone
sleep 3
git clone https://github.com/$GH_USER/repo-name.git
cd repo-name
# Add the original repo as "upstream" remote
git remote add upstream https://github.com/owner/repo-name.git
```
### Keeping a Fork in Sync
```bash
# Pure git — works everywhere
git fetch upstream
git checkout main
git merge upstream/main
git push origin main
```
**With gh (shortcut):**
```bash
gh repo sync $GH_USER/repo-name
```
## 4. Repository Information
**With gh:**
```bash
gh repo view owner/repo-name
gh repo list --limit 20
gh search repos "machine learning" --language python --sort stars
```
**With curl:**
```bash
# View repo details
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO \
| python3 -c "
import sys, json
r = json.load(sys.stdin)
print(f\"Name: {r['full_name']}\")
print(f\"Description: {r['description']}\")
print(f\"Stars: {r['stargazers_count']} Forks: {r['forks_count']}\")
print(f\"Default branch: {r['default_branch']}\")
print(f\"Language: {r['language']}\")"
# List your repos
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/user/repos?per_page=20&sort=updated" \
| python3 -c "
import sys, json
for r in json.load(sys.stdin):
vis = 'private' if r['private'] else 'public'
print(f\" {r['full_name']:40} {vis:8} {r.get('language', ''):10} ★{r['stargazers_count']}\")"
# Search repos
curl -s \
"https://api.github.com/search/repositories?q=machine+learning+language:python&sort=stars&per_page=10" \
| python3 -c "
import sys, json
for r in json.load(sys.stdin)['items']:
print(f\" {r['full_name']:40} ★{r['stargazers_count']:6} {r['description'][:60] if r['description'] else ''}\")"
```
## 5. Repository Settings
**With gh:**
```bash
gh repo edit --description "Updated description" --visibility public
gh repo edit --enable-wiki=false --enable-issues=true
gh repo edit --default-branch main
gh repo edit --add-topic "machine-learning,python"
gh repo edit --enable-auto-merge
```
**With curl:**
```bash
curl -s -X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO \
-d '{
"description": "Updated description",
"has_wiki": false,
"has_issues": true,
"allow_auto_merge": true
}'
# Update topics
curl -s -X PUT \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.mercy-preview+json" \
https://api.github.com/repos/$OWNER/$REPO/topics \
-d '{"names": ["machine-learning", "python", "automation"]}'
```
## 6. Branch Protection
```bash
# View current protection
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/branches/main/protection
# Set up branch protection
curl -s -X PUT \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/branches/main/protection \
-d '{
"required_status_checks": {
"strict": true,
"contexts": ["ci/test", "ci/lint"]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"required_approving_review_count": 1
},
"restrictions": null
}'
```
## 7. Secrets Management (GitHub Actions)
**With gh:**
```bash
gh secret set API_KEY --body "your-secret-value"
gh secret set SSH_KEY < ~/.ssh/id_rsa
gh secret list
gh secret delete API_KEY
```
**With curl:**
Secrets require encryption with the repo's public key — more involved via API:
```bash
# Get the repo's public key for encrypting secrets
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/secrets/public-key
# Encrypt and set (requires Python with PyNaCl)
python3 -c "
from base64 import b64encode
from nacl import encoding, public
import json, sys
# Get the public key
key_id = '<key_id_from_above>'
public_key = '<base64_key_from_above>'
# Encrypt
sealed = public.SealedBox(
public.PublicKey(public_key.encode('utf-8'), encoding.Base64Encoder)
).encrypt('your-secret-value'.encode('utf-8'))
print(json.dumps({
'encrypted_value': b64encode(sealed).decode('utf-8'),
'key_id': key_id
}))"
# Then PUT the encrypted secret
curl -s -X PUT \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/secrets/API_KEY \
-d '<output from python script above>'
# List secrets (names only, values hidden)
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/secrets \
| python3 -c "
import sys, json
for s in json.load(sys.stdin)['secrets']:
print(f\" {s['name']:30} updated: {s['updated_at']}\")"
```
Note: For secrets, `gh secret set` is dramatically simpler. If setting secrets is needed and `gh` isn't available, recommend installing it for just that operation.
## 8. Releases
**With gh:**
```bash
gh release create v1.0.0 --title "v1.0.0" --generate-notes
gh release create v2.0.0-rc1 --draft --prerelease --generate-notes
gh release create v1.0.0 ./dist/binary --title "v1.0.0" --notes "Release notes"
gh release list
gh release download v1.0.0 --dir ./downloads
```
**With curl:**
```bash
# Create a release
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/releases \
-d '{
"tag_name": "v1.0.0",
"name": "v1.0.0",
"body": "## Changelog\n- Feature A\n- Bug fix B",
"draft": false,
"prerelease": false,
"generate_release_notes": true
}'
# List releases
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/releases \
| python3 -c "
import sys, json
for r in json.load(sys.stdin):
tag = r.get('tag_name', 'no tag')
print(f\" {tag:15} {r['name']:30} {'draft' if r['draft'] else 'published'}\")"
# Upload a release asset (binary file)
RELEASE_ID=<id_from_create_response>
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
"https://uploads.github.com/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=binary-amd64" \
--data-binary @./dist/binary-amd64
```
## 9. GitHub Actions Workflows
**With gh:**
```bash
gh workflow list
gh run list --limit 10
gh run view <RUN_ID>
gh run view <RUN_ID> --log-failed
gh run rerun <RUN_ID>
gh run rerun <RUN_ID> --failed
gh workflow run ci.yml --ref main
gh workflow run deploy.yml -f environment=staging
```
**With curl:**
```bash
# List workflows
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/workflows \
| python3 -c "
import sys, json
for w in json.load(sys.stdin)['workflows']:
print(f\" {w['id']:10} {w['name']:30} {w['state']}\")"
# List recent runs
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$OWNER/$REPO/actions/runs?per_page=10" \
| python3 -c "
import sys, json
for r in json.load(sys.stdin)['workflow_runs']:
print(f\" Run {r['id']} {r['name']:30} {r['conclusion'] or r['status']}\")"
# Download failed run logs
RUN_ID=<run_id>
curl -s -L \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/runs/$RUN_ID/logs \
-o /tmp/ci-logs.zip
cd /tmp && unzip -o ci-logs.zip -d ci-logs
# Re-run a failed workflow
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/runs/$RUN_ID/rerun
# Re-run only failed jobs
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/runs/$RUN_ID/rerun-failed-jobs
# Trigger a workflow manually (workflow_dispatch)
WORKFLOW_ID=<workflow_id_or_filename>
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/actions/workflows/$WORKFLOW_ID/dispatches \
-d '{"ref": "main", "inputs": {"environment": "staging"}}'
```
## 10. Gists
**With gh:**
```bash
gh gist create script.py --public --desc "Useful script"
gh gist list
```
**With curl:**
```bash
# Create a gist
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/gists \
-d '{
"description": "Useful script",
"public": true,
"files": {
"script.py": {"content": "print(\"hello\")"}
}
}'
# List your gists
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/gists \
| python3 -c "
import sys, json
for g in json.load(sys.stdin):
files = ', '.join(g['files'].keys())
print(f\" {g['id']} {g['description'] or '(no desc)':40} {files}\")"
```
## Quick Reference Table
| Action | gh | git + curl |
|--------|-----|-----------|
| Clone | `gh repo clone o/r` | `git clone https://github.com/o/r.git` |
| Create repo | `gh repo create name --public` | `curl POST /user/repos` |
| Fork | `gh repo fork o/r --clone` | `curl POST /repos/o/r/forks` + `git clone` |
| Repo info | `gh repo view o/r` | `curl GET /repos/o/r` |
| Edit settings | `gh repo edit --...` | `curl PATCH /repos/o/r` |
| Create release | `gh release create v1.0` | `curl POST /repos/o/r/releases` |
| List workflows | `gh workflow list` | `curl GET /repos/o/r/actions/workflows` |
| Rerun CI | `gh run rerun ID` | `curl POST /repos/o/r/actions/runs/ID/rerun` |
| Set secret | `gh secret set KEY` | `curl PUT /repos/o/r/actions/secrets/KEY` (+ encryption) |
@@ -0,0 +1,74 @@
# Review Output Template
Use this as the structure for PR review summary comments. Copy and fill in the sections.
## For PR Summary Comment
```markdown
## Code Review Summary
**Verdict: [Approved ✅ | Changes Requested 🔴 | Reviewed 💬]** ([N] issues, [N] suggestions)
**PR:** #[number] — [title]
**Author:** @[username]
**Files changed:** [N] (+[additions] -[deletions])
### 🔴 Critical
<!-- Issues that MUST be fixed before merge -->
- **file.py:line** — [description]. Suggestion: [fix].
### ⚠️ Warnings
<!-- Issues that SHOULD be fixed, but not strictly blocking -->
- **file.py:line** — [description].
### 💡 Suggestions
<!-- Non-blocking improvements, style preferences, future considerations -->
- **file.py:line** — [description].
### ✅ Looks Good
<!-- Call out things done well — positive reinforcement -->
- [aspect that was done well]
---
*Reviewed by Hermes Agent*
```
## Severity Guide
| Level | Icon | When to use | Blocks merge? |
|-------|------|-------------|---------------|
| Critical | 🔴 | Security vulnerabilities, data loss risk, crashes, broken core functionality | Yes |
| Warning | ⚠️ | Bugs in non-critical paths, missing error handling, missing tests for new code | Usually yes |
| Suggestion | 💡 | Style improvements, refactoring ideas, performance hints, documentation gaps | No |
| Looks Good | ✅ | Clean patterns, good test coverage, clear naming, smart design decisions | N/A |
## Verdict Decision
- **Approved ✅** — Zero critical/warning items. Only suggestions or all clear.
- **Changes Requested 🔴** — Any critical or warning item exists.
- **Reviewed 💬** — Observations only (draft PRs, uncertain findings, informational).
## For Inline Comments
Prefix inline comments with the severity icon so they're scannable:
```
🔴 **Critical:** User input passed directly to SQL query — use parameterized queries to prevent injection.
```
```
⚠️ **Warning:** This error is silently swallowed. At minimum, log it.
```
```
💡 **Suggestion:** This could be simplified with a dict comprehension:
`{k: v for k, v in items if v is not None}`
```
```
✅ **Nice:** Good use of context manager here — ensures cleanup on exceptions.
```
## For Local (Pre-Push) Review
When reviewing locally before push, use the same structure but present it as a message to the user instead of a PR comment. Skip the PR metadata header and just start with the severity sections.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# GitHub environment detection helper for Hermes Agent skills.
#
# Usage (via terminal tool):
# source skills/github/github-auth/scripts/gh-env.sh
#
# After sourcing, these variables are set:
# GH_AUTH_METHOD - "gh", "curl", or "none"
# GITHUB_TOKEN - personal access token (set if method is "curl")
# GH_USER - GitHub username
# GH_OWNER - repo owner (only if inside a git repo with a github remote)
# GH_REPO - repo name (only if inside a git repo with a github remote)
# GH_OWNER_REPO - owner/repo (only if inside a git repo with a github remote)
# --- Auth detection ---
GH_AUTH_METHOD="none"
GITHUB_TOKEN="${GITHUB_TOKEN:-}"
GH_USER=""
if command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1; then
GH_AUTH_METHOD="gh"
GH_USER=$(gh api user --jq '.login' 2>/dev/null)
elif [ -n "$GITHUB_TOKEN" ]; then
GH_AUTH_METHOD="curl"
elif [ -f "$HOME/.hermes/.env" ] && grep -q "^GITHUB_TOKEN=" "$HOME/.hermes/.env" 2>/dev/null; then
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$HOME/.hermes/.env" | head -1 | cut -d= -f2 | tr -d '\n\r')
if [ -n "$GITHUB_TOKEN" ]; then
GH_AUTH_METHOD="curl"
fi
elif [ -f "$HOME/.git-credentials" ] && grep -q "github.com" "$HOME/.git-credentials" 2>/dev/null; then
GITHUB_TOKEN=$(grep "github.com" "$HOME/.git-credentials" | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
if [ -n "$GITHUB_TOKEN" ]; then
GH_AUTH_METHOD="curl"
fi
fi
# Resolve username for curl method
if [ "$GH_AUTH_METHOD" = "curl" ] && [ -z "$GH_USER" ]; then
GH_USER=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/user 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('login',''))" 2>/dev/null)
fi
# --- Repo detection (if inside a git repo with a GitHub remote) ---
GH_OWNER=""
GH_REPO=""
GH_OWNER_REPO=""
_remote_url=$(git remote get-url origin 2>/dev/null)
if [ -n "$_remote_url" ] && echo "$_remote_url" | grep -q "github.com"; then
GH_OWNER_REPO=$(echo "$_remote_url" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
GH_OWNER=$(echo "$GH_OWNER_REPO" | cut -d/ -f1)
GH_REPO=$(echo "$GH_OWNER_REPO" | cut -d/ -f2)
fi
unset _remote_url
# --- Summary ---
echo "GitHub Auth: $GH_AUTH_METHOD"
[ -n "$GH_USER" ] && echo "User: $GH_USER"
[ -n "$GH_OWNER_REPO" ] && echo "Repo: $GH_OWNER_REPO"
[ "$GH_AUTH_METHOD" = "none" ] && echo "⚠ Not authenticated — see github-auth skill"
export GH_AUTH_METHOD GITHUB_TOKEN GH_USER GH_OWNER GH_REPO GH_OWNER_REPO
@@ -0,0 +1,35 @@
## Bug Description
<!-- Clear, concise description of the bug -->
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
<!-- What should happen -->
## Actual Behavior
<!-- What actually happens -->
## Environment
- OS:
- Version/Commit:
- Python version:
- Browser (if applicable):
## Error Output
<!-- Paste relevant error messages, stack traces, or logs -->
```
```
## Additional Context
<!-- Screenshots, related issues, workarounds discovered, etc. -->
@@ -0,0 +1,31 @@
## Feature Description
<!-- What do you want? -->
## Motivation
<!-- Why would this be useful? What problem does it solve? -->
## Proposed Solution
<!-- How could it work? Include API sketches, CLI examples, or mockups if helpful -->
```
# Example usage
```
## Alternatives Considered
<!-- Other approaches and why they're less ideal -->
-
## Scope / Effort Estimate
<!-- How big is this? What areas of the codebase would it touch? -->
Small / Medium / Large — <!-- explanation -->
## Additional Context
<!-- Links to similar features in other tools, relevant discussions, etc. -->
@@ -0,0 +1,35 @@
## Bug Description
<!-- What was happening? -->
Fixes #
## Root Cause
<!-- What was causing the bug? -->
## Fix
<!-- What does this PR change to fix it? -->
-
## How to Verify
<!-- Steps a reviewer can follow to confirm the fix -->
1.
2.
3.
## Test Plan
- [ ] Added regression test for this bug
- [ ] Existing tests still pass
- [ ] Manual verification of the fix
## Risk Assessment
<!-- Could this fix break anything else? What's the blast radius? -->
Low / Medium / High — <!-- explanation -->
@@ -0,0 +1,33 @@
## Summary
<!-- 1-3 bullet points describing what this PR does -->
-
## Motivation
<!-- Why is this change needed? Link to issue if applicable -->
Closes #
## Changes
<!-- Detailed list of changes made -->
-
## Test Plan
<!-- How was this tested? Checklist of verification steps -->
- [ ] Unit tests pass (`pytest`)
- [ ] Manual testing of new functionality
- [ ] No regressions in existing behavior
## Screenshots / Examples
<!-- If UI changes or new output, show before/after -->
## Notes for Reviewers
<!-- Anything reviewers should pay special attention to -->
@@ -0,0 +1,199 @@
---
name: case-analysis-nine-steps
description: 要件审判九步法——邹碧华法官提出的民事案件分析方法论。以请求权思维为基础,将案件分析分解为九个环环相扣的步骤。适用于案件分析、诉讼策略制定、庭审准备、裁判文书分析。
version: 1
tags: [legal, litigation, case-analysis, methodology]
triggers:
- 分析案件
- 案件分析
- 九步法
- 诉讼策略
- 请求权分析
- 争点整理
---
# 要件审判九步法 — 案件分析方法论
> 来源:邹碧华法官《要件审判九步法》
> 核心思维:先找法(大前提)→ 再认定事实(小前提)→ 归入裁判(结论)
## 案卷材料分析流程(九步法前置)
拿到案卷材料后,必须先完成以下步骤,再进入九步法分析:
### 一、精读全部材料
- **所有文件逐页阅读**,页眉、页脚、注释、手写内容、印章、批注一律不放过
- 标记关键信息:日期、金额、签名、盖章、手写备注
- 注意文件之间的交叉引用和矛盾之处
### 二、梳理相关方与时间线
- **找出所有相关方**:当事人、关联公司、代理人、第三方等
- **按时间顺序梳理全部事件**:合同签订、履行、违约、通知、催告、诉讼等
- 制作时间线表格,每个事件标注:时间、相关方、事件内容、对应文件
### 三、法律行为分析与法律关系提炼
- 对**每个相关方**逐一进行"法律行为"分析——做了什么、基于什么身份、产生什么法律效果
- 提炼**两两相对方之间的法律关系**(合同关系、侵权关系、担保关系、代理关系等)
- 总结每组法律关系中各方的**权利义务**
### 四、推理还原与缺失材料识别
- 基于已有材料**推理还原事件经过**
- **推测可能存在但未提供的文件和事实**(如:有催告函但没有送达凭证、有合同但没有付款凭证)
- 标注推测依据
### 五、制作补充材料清单
- 列出**需要客户补充的材料和文件**,说明每份材料的用途和重要性
- 要求客户提供,必要时说明不提供的风险
### 六、事实还原定稿
- 确认收集到所有能够搜集到的资料后,**尽可能还原案件事实全貌**
- 形成完整的事实陈述,区分"已证实事实"和"待确认事实"
完成以上步骤后,进入要件审判九步法进行法律分析。
---
## 适用场景
- 民事案件全面分析
- 诉讼策略制定(原告视角/被告视角)
- 庭审准备与争点预判
- 裁判文书逻辑审查
- 合同纠纷、侵权纠纷、物权纠纷等各类民商事案件
## 九步分析流程
### 第一步:固定权利请求
**任务**:明确当事人的诉讼请求是什么。
- 原告请求什么?(给付金钱、返还财产、确认权利、变更/解除合同等)
- 有无反诉?反诉请求是什么?
- 诉讼请求是否明确、具体、可执行?
- 注意区分:确认之诉、给付之诉、形成之诉
**输出**:列出全部诉讼请求(含反诉),逐条编号。
### 第二步:识别权利请求基础规范(法官找法)
**任务**:为每项诉讼请求找到对应的法律依据(请求权基础)。
- 该请求权属于什么性质?(合同请求权、侵权请求权、不当得利请求权、物权请求权等)
- 对应的具体法律条文是什么?(民法典哪一条、哪部特别法)
- 是否存在请求权竞合?如有,分析各请求权基础的利弊
- **必须查原文**:引用具体条文,不凭印象
**输出**:每项请求对应的法律条文及请求权性质。
### 第三步:识别抗辩权基础规范(对立规范)
**任务**:预判或识别对方可能/已经提出的抗辩。
- 权利障碍抗辩(合同无效、未成立等)
- 权利消灭抗辩(已清偿、已抵销、已免除等)
- 权利阻止抗辩(诉讼时效、同时履行抗辩权、不安抗辩权等)
- 每项抗辩对应的法律规范是什么?
- 抗辩的举证责任归谁?
**输出**:抗辩清单及对应法律依据,标注举证责任分配。
### 第四步:基础规范构成要件分析
**任务**:将请求权基础规范和抗辩规范的构成要件逐一拆解。
- 把法律条文分解为若干事实要件(要件事实)
- 对不完全法条,通过法律解释、司法解释、指导案例补充隐含要件
- 每个要件需要什么事实来满足?
**输出**:要件分解表——每项请求权/抗辩的构成要件列表。
**示例格式**
```
请求权基础:《民法典》第577条(违约责任)
构成要件:
1. 合同有效成立
2. 被告存在违约行为
3. 原告遭受损失
4. 违约行为与损失之间有因果关系
```
### 第五步:审查诉讼主张是否完备
**任务**:检查当事人的主张是否覆盖了全部构成要件。
- 有无遗漏的主张?(对照第四步的要件清单逐一检查)
- 有无矛盾的主张?
- 如代表一方,提示需要补充的主张
- 如分析裁判,检查法院是否进行了释明
**输出**:主张完备性检查表,标注缺漏项。
### 第六步:争点整理
**任务**:归纳案件的争议焦点。
- 哪些要件事实双方无争议?(可直接认定)
- 哪些要件事实双方有争议?(这就是争点)
- 是否存在法律适用争点?(法条理解分歧)
- 按重要性和逻辑顺序排列争点
**输出**:争议焦点清单,按优先级排序。
### 第七步:要件事实的证明
**任务**:围绕争点分析证据情况。
- 每个争点需要什么证据来证明?
- 现有证据是否充分?
- 举证责任如何分配?(谁主张谁举证,举证责任倒置情形)
- 有无证据缺口?如何补强?
- 对方证据的薄弱点在哪里?
**输出**:证据与争点对照表,标注证据充分性和风险点。
### 第八步:要件事实的认定
**任务**:基于证据认定事实。
- 依据构成要件"过滤"证据,排除无关联性证据
- 判断证据的真实性、合法性、关联性
- 对证据证明力大小作出判断
- 确认哪些要件事实可以认定、哪些不能
**输出**:事实认定结论,逐要件标注"已证明/未证明/证据不足"。
### 第九步:要件归入并得出结论
**任务**:将认定的事实归入法律要件,得出最终结论。
- 逐一比对:每个构成要件是否有事实支撑?
- 全部要件满足 → 请求权成立
- 任一要件不满足 → 请求权不成立
- 抗辩要件是否满足?
- 综合得出裁判/分析结论
**输出**:归入分析表 + 最终结论。
## 使用原则
1. **严格按顺序**:九步环环相扣,不跳步
2. **法条必须查原文**:涉及具体法律条文必须检索验证,不凭印象
3. **区分视角**:明确是站在原告、被告还是中立分析视角
4. **要件拆解是核心**:第四步做得越细,后续分析越精准
5. **争点是指挥棒**:第六步决定了后续证据分析的方向
6. **结论要有证据支撑**:每个判断都要指向具体证据
## 输出格式建议
分析报告按九步结构组织,每步包含:
- 分析过程
- 关键发现
- 风险提示(如有)
最后附总结:案件整体评估、胜诉概率判断(如适用)、策略建议。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
# add_clause after_search Fails After tracked_replace — Use Direct lxml Insertion
## Problem (2026-07-01 凤雅幼儿园劳务派遣协议)
After calling `ed.tracked_replace(old, new)` on multiple paragraphs, subsequent `ed.add_clause(text, after_search="...")` calls silently fail — the new paragraph doesn't appear in the output. The function returns without error but the clause is not inserted.
## Root Cause
`add_clause`'s `after_search` parameter searches paragraph text by concatenating all `<w:t>` elements. After `tracked_replace`, the paragraph's XML contains interleaved `<w:del>` and `<w:ins>` elements. The `after_search` text-matching logic may:
1. Include both old (del) and new (ins) text in the concatenation, so neither the old NOR new text matches cleanly
2. Match the wrong paragraph if the search string appears in unexpected combinations of del+ins text
## Solution: Direct lxml `addnext` Insertion
After all `tracked_replace` calls, insert new clauses directly using lxml:
```python
# Find reference paragraph by index or by scanning accepted-view text
paras = ed.body.findall(f'{WNS}p')
ref_para = paras[target_index] # e.g., P68
# Build INS paragraph
new_p = etree.Element(f'{WNS}p')
new_p.append(copy.deepcopy(ref_ppr)) # Clone paragraph formatting
ins = etree.SubElement(new_p, f'{WNS}ins')
ins.set(f'{WNS}id', next_rev_id())
ins.set(f'{WNS}author', 'WB')
ins.set(f'{WNS}date', rev_date)
r = etree.SubElement(ins, f'{WNS}r')
r.set(f'{WNS}rsidR', rsid)
r.insert(0, copy.deepcopy(ref_rpr))
t = etree.SubElement(r, f'{WNS}t')
t.set(XML_SPACE, 'preserve')
t.text = clause_text
# Mark paragraph itself as inserted (pPr/rPr/ins)
ppr = new_p.find(f'{WNS}pPr')
ppr_rpr = etree.SubElement(ppr, f'{WNS}rPr')
ppr_ins = etree.SubElement(ppr_rpr, f'{WNS}ins')
ppr_ins.set(f'{WNS}id', next_rev_id())
ppr_ins.set(f'{WNS}author', 'WB')
ppr_ins.set(f'{WNS}date', rev_date)
# Insert after reference
ref_para.addnext(new_p)
ref_para = new_p # Chain subsequent inserts
```
## When This Applies
- You need to both modify existing clauses (tracked_replace) AND add new clauses in the same editing session
- The `after_search` text has been altered by prior tracked_replace calls
## Correct Operation Order
1. All `ed.tracked_replace(...)` calls first
2. Then find target paragraphs by scanning the body with accepted-view text extraction
3. Insert new paragraphs directly via `addnext`
4. `ed.validate()` + `ed.save()`
## Verification
After save, scan paragraphs and confirm new clauses appear in accepted-view text at the expected positions.
@@ -0,0 +1,55 @@
# auto_notify_new_file.sh 架构:入队模式 vs 直接执行模式
## 根因(2026-06-30)
`auto_notify_new_file.sh` 原本自己启动 `uwf thread exec -c 5`(前台模式)来执行合同审查 workflow。
但 hermes ACP 适配器在前台模式下有 asyncio stdin 注册 bug(`KeyError: '0 is not registered'`),
导致每次 spawn `hermes acp` 子进程都立即失败,日志中全是 `agent command failed (uwf-hermes)`
**对比**`contract-queue-runner.sh` 使用 `uwf thread exec --background` 模式,正常工作。
## 当前架构(2026-06-30 修复后)
```
企微收到文件
→ auto_notify_new_file.sh (inotifywait 监控)
→ 识别 sender (邱律师=QiuTing)
→ 上传 Nextcloud 待审查/
→ 入队 /tmp/contract-queue/manifest.txt
→ 检查 contract-queue-runner.sh 是否在运行,不在则启动
→ contract-queue-runner.sh 串行执行(--background 模式)
→ uwf thread start + thread exec --background
→ uwf-hermes → hermes acp(正常)
```
## 关键文件
| 文件 | 路径 | 职责 |
|------|------|------|
| auto_notify | `~/.hermes/scripts/auto_notify_new_file.sh` | 监控文件到达、上传、入队 |
| queue runner | `~/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh` | 串行执行 workflow |
| watchdog | `~/.hermes/scripts/contract-queue-watchdog.sh` | 每20分钟检查卡住的 thread |
| auto_notify watchdog | `~/.hermes/scripts/auto_notify_watchdog.sh` | 每5分钟检查 auto_notify 进程 |
## 禁止回退
**绝不可把 auto_notify 改回自己启动 `uwf thread exec` 的模式**。前台模式有 ACP stdin bug,
只有 `--background` 模式能正常工作。如果未来需要修改 auto_notify 的 workflow 启动逻辑,
必须通过 contract-queue-runner 间接执行。
## 入队逻辑
```bash
# 入队到 contract-queue
cp "$filepath" "$QUEUE_DIR/${orig_name}"
# 追加到 manifest(去重)
if ! grep -qFx "$orig_name" "$QUEUE_DIR/manifest.txt" 2>/dev/null; then
echo "$orig_name" >> "$QUEUE_DIR/manifest.txt"
fi
# 检查 queue runner 是否在运行,不在则启动
if ! ps aux | grep -q "[c]ontract-queue-runner"; then
nohup bash "$HOME/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh" >> "$QUEUE_DIR/queue.log" 2>&1 &
fi
```
@@ -0,0 +1,98 @@
# 自动编号 → 手动固定编号修复(删段重排根治)
## 适用场景
他人(如屠佳青)用修订模式整段删除了一个**自动编号列表项**(段落 pPr 含 `<w:numPr>`,且段落标记 del=True),导致 OnlyOffice **markup 修订视图**把后续列表项渲染成"旧号新号"双编号:
```
(1)报名服务 ← 正常
(2)笔试服务[删除线] ← 被删,仍占编号位
(3)(2)面试服务 ← 双号!自动引擎按"接受后会变(2)"提前显示
(4)(3)项目管理 ← 双号!
```
Maggie/Doro 平时看 markup 视图,要求"修订视图下编号稳定显示 (1)(2)(3)(4)"。
根治办法:把这一组列表项从自动编号转成**手动文本编号**——文本是字面量,渲染器原样输出,不再经过自动编号引擎重排。
## 关键认知(动手前必须确认)
1. **被删项的删除是他人修订 → 绝不动其正文**(只在段首加编号 run,不碰 del 内容)。
2. **全文先确认没有对这些子项编号的交叉引用**(如"按上述第3项""见(4)")。本例"具体服务内容详见 附件一:服务报价单"是文字列举,非编号引用 → 安全。若存在引用,转手动编号后引用文字需同步核对。
3.`docker exec <nc容器> find ... ` 从 Nextcloud **拉当前交付版**作修复源,`md5sum` 比对确认本地副本没过时。
## 可复用代码(2026-06-16 验证通过)
```python
import zipfile, shutil, os
from lxml import etree
NS='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def q(t): return f'{{{NS}}}{t}'
def ln(el): return etree.QName(el).localname
src='交付版.docx'; out='FIXED.docx'
work='work.docx'; shutil.copy(src, work)
root=etree.fromstring(zipfile.ZipFile(work).read('word/document.xml'))
# 1. 按正文开头定位目标段(按你的合同改这些前缀)
segs={}
for p in root.iter(q('p')):
t=''.join((x.text or '') for x in p.iter() if ln(x) in ('t','delText'))
if t.startswith('报名服务:'): segs['报名']=p
elif t.startswith('笔试服务:提供'): segs['笔试']=p # 被删的那项
elif t.startswith('面试服务:'): segs['面试']=p
elif t.startswith('项目管理:整个项目'): segs['项目管理']=p
def make_rpr(): # 字体/字号照搬本段原文(本例宋体sz=24)。务必与目标段一致
rpr=etree.SubElement(etree.Element(q('tmp')), q('rPr'))
rf=etree.SubElement(rpr, q('rFonts'))
for a in ('ascii','hAnsi','cs'): rf.set(q(a),'宋体;SimSun')
etree.SubElement(rpr, q('sz')).set(q('val'),'24')
etree.SubElement(rpr, q('szCs')).set(q('val'),'24')
return rpr
def make_run(text):
r=etree.Element(q('r')); r.append(make_rpr())
t=etree.SubElement(r, q('t')); t.text=text
t.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
return r
def remove_numpr(p):
ppr=p.find(q('pPr'))
if ppr is not None:
np=ppr.find(q('numPr'))
if np is not None: ppr.remove(np)
def insert_first(p, node): # 插到 pPr 之后、第一个内容元素之前
idx=len(p)
for i,c in enumerate(p):
if ln(c) in ('r','ins','del','hyperlink'): idx=i; break
p.insert(idx, node)
# 2. 普通项:明文编号 run
for key,label in [('报名','(1)'),('面试','(3)'),('项目管理','(4)')]:
remove_numpr(segs[key]); insert_first(segs[key], make_run(label))
# 3. 被删项:编号 run 必须包进【他人的】<w:del>(复制其 author/date)
p=segs['笔试']; remove_numpr(p)
ex=p.find(q('del')) # 已有的他人 del(屠佳青)
nd=etree.Element(q('del'))
nd.set(q('author'), ex.get(q('author'))) # 照抄他人 author
nd.set(q('date'), ex.get(q('date')))
nd.set(q('id'),'99001') # 不冲突的大 id
r=etree.SubElement(nd, q('r')); r.append(make_rpr())
dt=etree.SubElement(r, q('delText')); dt.text='(2)'
dt.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
insert_first(p, nd)
# 4. 写回(只换 document.xml,其余条目原样复制)
new_doc=etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(work) as zin, zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as zout:
for it in zin.namelist():
zout.writestr(it, new_doc if it=='word/document.xml' else zin.read(it))
```
## 交付前必验(vision 不可用时降级四查,缺一不可)
1. **逐段 markup diff vs 交付源** → 只有目标 N 段不同,其余零改动(本例 346 段只动 4 段)。
2. **pdftotext 渲染层数编号链**`onlyoffice-render.sh out.docx && pdftotext -f1 -l1 out.pdf -` 确认 1.2 下严格 (1)(2)(3)(4) 无双号。
3. **编号 run rPr == 本段正文 run rPr**(字体宋体、sz=24 一致)。
4. **接受修订后视图编号链连续** + `python-docx Document(out)` 可打开(XML 合法)。验证被删项的编号确实在 `<del author=他人>` 里、他人原 del 内容(id 不变)一字未动。
## 上传
`docker cp` 覆盖 `任务交付/` 同名文件 → `chown www-data``occ files:scan --path` → 清 OnlyOffice 缓存(`rm -rf .../App_Data/cache/files/*`)让 Maggie 打开看到新版。`md5sum` 比对容器内==本地确认上传成功。
@@ -0,0 +1,113 @@
# 在「自动编号的顶层列表」中新增条款(保留numPr)
2026-06-18 华新慢病运维合同实战。一次返工换来的教训。
## 适用判定(动手前先分清两类合同)
合同的「条款」分两种承载方式,新增条款的手法完全不同:
| 类型 | 特征 | 新增条款手法 |
|------|------|-------------|
| **A. 第X条 文本标题** | 条款标题是 run 里的文字「第七条 …」/「7. …」,段落**无** numPr | `add_clause()`(库会剥 numPr,正确)|
| **B. 自动编号列表项** | 条款本身是自动编号列表项:段落 pPr 带 `<w:numPr>`,编号由 numbering.xml 的 `start`+`lvlText`(如 `一、`/`1.`/`(1)`)自动渲染,run 里**没有**编号文字 | ❌ 不能用 `add_clause()`;按下方「numbered-insert」手法 |
**判定脚本**:对要插入位置附近的条款段落跑 `numbering-diagnose.py`,或直接看锚点段 `pPr/numPr` 是否存在且其 numId 的 lvlText 是序号格式。本案锚点「五、违约责任」段 `pPr` = `pStyle=12 + numPr(numId=1,ilvl=0) + ind`,numId=1→abstractNum start=1 lvlText=`%1、`(japaneseCounting 一、二、三)。
## 为什么 add_clause 在 B 类会坏
`add_clause()`(contract_docx_lib.py 第408-411行)**无条件**剥离新段的 numPr:
```python
numpr = new_ppr.find(qn('numPr'))
if numpr is not None:
new_ppr.remove(numpr) # ← B类灾难
```
后果(本案实测):5 个新增条款全部**丢失自动编号**,且因 pPr 缺 numPr/缩进与列表项不一致,渲染时**堆到了文档最末尾**(签署页之前),既无编号又错位——违反「新增条款插在逻辑对应位置、不堆到最后」+「自动编号保留numPr」两条规则。
`add_clause` 第二个缺陷:它只把**文本** run 包进 w:ins,**没有把段落标记(¶)标记为插入**。B 类里 ¶ 承载着自动编号,¶ 不是 tracked-insert,则接受/拒绝修订时这一项的编号增减不随修订走。
## 正确手法:numbered tracked-insert 段落
克隆锚点段的 pPr(**保留** numPr,让新段成为同一自动编号序列的一员),并把**段落标记本身**也标成 w:ins:
```python
import sys, copy
sys.path.insert(0, '/home/maggie/contract-work')
from contract_docx_lib import ContractEditor, qn
from lxml import etree
ed = ContractEditor(src)
# 1) 定位锚点段(要插在它之后的那条原文条款)
anchor = None
for p in ed.body.findall(qn('p')):
if '违约责任:按照中华人民共和国民法典' in ed.get_para_text(p):
anchor = p; break
anchor_ppr = anchor.find(qn('pPr'))
assert anchor_ppr.find(qn('numPr')) is not None, "锚点不是自动编号项,确认是否B类"
def make_numbered_ins_para(text):
new_p = etree.Element(qn('p'))
new_ppr = copy.deepcopy(anchor_ppr) # 含 pStyle + numPr(同numId/ilvl) + ind → 入同一自动编号序列
# 关键:把段落标记(¶)标成插入,整段(含自动编号)作为 tracked insertion
rpr_mark = new_ppr.find(qn('rPr'))
if rpr_mark is None:
rpr_mark = etree.SubElement(new_ppr, qn('rPr'))
ins_mark = etree.SubElement(rpr_mark, qn('ins'))
ins_mark.set(qn('id'), ed._next_id())
ins_mark.set(qn('author'), 'WB')
ins_mark.set(qn('date'), ed._revision_date)
new_p.append(new_ppr)
# 文本作为 tracked-ins run,用规范化 _body_rpr(完整rFonts四属性+hint=eastAsia+显式sz)
new_p.append(ed._mk_ins(text, ed._body_rpr))
return new_p
clauses = [ # 期望的最终正序 六~十
"保密与数据:……",
"知识产权与系统交接:……",
"转包与分包:……",
"第三方侵权:……",
"违约赔偿:……",
]
# 2) 全部插在 anchor 之后;倒序 insert 使最终正序
parent = ed.body
anchor_idx = list(parent).index(anchor)
for txt in reversed(clauses):
parent.insert(anchor_idx + 1, make_numbered_ins_para(txt))
assert ed.validate() == []
ed.save(out)
```
要点:
- **倒序插入**:每条都插在 `anchor_idx+1`,倒序遍历 → 最终正序。
- **同一 numId/ilvl**:克隆锚点 pPr 即自动继承,新条款自动续编(本案锚点是五 → 新条款渲染为六、七、八、九、十,后续原文自动顺延为十一、十二…,**无需手动改任何原文编号**)。
- **¶ 标插入** + **文本 run 标插入**,两者都要,缺一不可。
- 文本 run 用 `ed._body_rpr`(库已规整:四属性 rFonts + hint=eastAsia + 显式 sz),不要手搓 rPr。
## 交付前验证(B 类专项)
1. **接受所有修订后**渲染(删 w:del + 删带 `pPr/rPr/del` 的整段 + 解包 w:ins)→ 确认新条款编号与锚点连续、原文顺延正确、无错位到末尾。
2. **字体核对走「同段原文」标准**:本案原文正文 run = `<w:rFonts hint="eastAsia"/><w:szCs val="21"/>`(**无**显式 eastAsia 名,继承 docDefaults 宋体)。新 INS run 与之等效即合格——`ea=None hint=eastAsia` 是**正确**的,`wb-ins-font-verify.py` 若按绝对属性报 `ea=None` 是假阳性(见 contract-reviewer 的 2026-06-17 培训合同条)。唯一差异是 INS 多了显式 `<w:sz val="21">`(w:ins 必需),渲染一致。
3. **LibreOffice 渲染假象**:用 `libreoffice→pdftotext` 自查时,被顺延的自动编号项会显示 `十二、[七、]` 这种**方括号叠加**(recomputed 新号 + cached 旧号),这是 LibreOffice markup 渲染产物,**不是错误**,XML 里没有字面方括号。判真实编号一律以「接受所有修订后」或 OnlyOffice 渲染为准(OnlyOffice 是 Maggie/Doro 实际所用引擎)。
## 锚点选择铁律:插在 body text 之后,不是 heading 之后
**这是一个极易犯的错误**(2026-06-26 朱家角环保袋合同实证):
当Reviewer要求"在违约责任条款之后、争议解决条款之前新增XX条款"时,合同结构通常是:
```
P74: 八.违约责任 ← heading(numId=1)
P75: 若乙方未按本合同... ← body text(无 numPr)
P76: 九.合同金额 ← 下一个 heading(numId=1)
```
**错误做法**:锚点 = P74(heading),插入后 → 新条款夹在 heading 和它的 body text 之间,结构错乱。
**正确做法**:锚点 = P75(body text),插入后 → 新条款在 body text 之后、下一个 heading 之前,结构正确。
**判据**`numbering-diagnose.py` 确认锚点段的 `numPr` 状态——heading 有 numPr,body text 无 numPr。新条款应克隆**下一个 heading**(如 P76 合同金额)的 pPr(含 numPr),插入在**前一个 body text**(如 P75)之后。
## 一句话
锚点是自动编号列表项(pPr 有 numPr)→ 别用 add_clause,克隆锚点 pPr(留 numPr)+ ¶ 标 w:ins + 文本标 w:ins,倒序插入,新条款自动续编、原文自动顺延。**插在 body text 之后,不是 heading 之后。**
@@ -0,0 +1,95 @@
# A类(手动文本编号)新增条款:克隆"真实邻居段落"而非信任库的 _title_rpr / _body_rpr
2026-06-18 赵巷镇 X线设备采购合同实战。终审字体核验抓出"新标题不加粗",根因是库提取的标题格式丢了 bold。
## 何时用这套手法
- 合同是 **A 类**:条款标题是 run 里的**手动文本编号**(如 `8.争端的解决``第七条 索赔`),段落**无** numPr。
- 要新增一个带标题的条款(标题段 + 正文段),并希望格式与兄弟条款 100% 一致。
- (B 类自动编号列表项见 `auto-numbered-list-clause-insert.md`,手法不同。)
## 为什么不直接用库的 `_title_rpr` / `_body_rpr`
`ContractEditor._extract_formats()`(contract_docx_lib.py ~第86-175行)用启发式认"条款标题":
```python
is_clause_title = (re.match(r'^\d+[..、]\s*\S', p_text) or
re.match(r'^第[一二三四五六七八九十百千\d]+条\s*\S', p_text)) and len(p_text) < 30
# 且 _is_title_style() 要 <w:b/> 或标题字体(黑体/SimHei…) 才算 title
```
**坑**:当标题就是"8.争端的解决"(宋体 + `<w:b/>`,无特殊标题字体),若该段在扫描中**没被 `_is_title_style` 命中**(例如 b 标记在 bCs 旁、或正则边界),`clause_title_rpr` / `first_bold_rpr` 取空 → `_title_rpr` **回退到 `_body_rpr`(不含 bold)**
实测后果:新标题 `8.转包与分包` 的 INS run `bold=False`,而原文兄弟标题 `9.争端的解决` `bold=True``validate()` 查不出(它不比 bold),只有逐段 WB INS 与"同段/同级原文"对比才抓得到。
## 稳健手法:克隆紧邻同级原文段落
不取库的 `_title_rpr`/`_body_rpr`,改为**直接深拷贝隔壁真实条款段**的 pPr 和首个 run 的 rPr:
```python
import sys, copy
sys.path.insert(0, '/home/maggie/contract-work')
from contract_docx_lib import ContractEditor, qn
from lxml import etree
ed = ContractEditor(src) # 已先做完所有 tracked_replace
def find(kw):
for p in ed.body.findall(qn('p')):
if kw in ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')):
return p
return None
# 克隆来源:插入点后面那条原文条款的【标题段】和它的【正文段】
title_src = find('8.争端的解决') # 兄弟条款标题(自带 <w:b/> + 宋体四属性 + 标题pPr缩进)
body_src = find('双方如在履行合同中发生纠纷') # 兄弟条款正文(无bold + firstLine=420 缩进)
def clone_as_ins(src_para, new_text):
"""深拷贝 src_para 的 pPr + 首run rPr,替换文本,整段(含¶)标 w:ins(author=WB)"""
np = etree.Element(qn('p'))
ppr = copy.deepcopy(src_para.find(qn('pPr')))
# ¶ 段落标记标插入
rprm = ppr.find(qn('rPr'))
if rprm is None:
rprm = etree.SubElement(ppr, qn('rPr'))
insm = etree.SubElement(rprm, qn('ins'))
insm.set(qn('id'), ed._next_id()); insm.set(qn('author'), 'WB'); insm.set(qn('date'), ed._revision_date)
np.append(ppr)
# run rPr 直接克隆兄弟段首 run(bold/字体/字号全继承,不碰库的默认值)
src_r = src_para.find(qn('r'))
src_rpr = copy.deepcopy(src_r.find(qn('rPr'))) if (src_r is not None and src_r.find(qn('rPr')) is not None) else None
np.append(ed._mk_ins(new_text, src_rpr))
return np
title_p = clone_as_ins(title_src, "8.转包与分包")
body_p = clone_as_ins(body_src, "未经甲方书面同意,乙方不得将本合同项下的…连带责任。")
idx = list(ed.body).index(title_src)
ed.body.insert(idx, title_p) # 标题插在兄弟条款标题之前 → 成为新的"8.",兄弟顺延为"9."
ed.body.insert(idx + 1, body_p)
```
## A类手动编号的顺延(与 B 类自动顺延不同!)
A 类编号是 run 里的字面文字,**不会自动顺延**。插入新"8."后,必须手动把后续所有手动编号 DEL 旧号+INS 新号(用 tracked_replace):
```python
for old, new in [("8.争端的解决","9.争端的解决"), ("9.合同生效","10.合同生效"),
("9.1 本合同在…","10.1 本合同在…"), ("10.合同附件","11.合同附件"),
("10.1 配置清单","11.1 配置清单"), ..., ("12.特别约定","13.特别约定")]:
ed.tracked_replace(old, new)
```
- **子编号一并顺延**(9.1/9.2→10.1/10.2,11.1-11.7→12.1-12.7)。
- 匹配串要够长以避免短串误命中(见 SKILL.md「tracked_replace 短字符串误命中」)。
## 交付前验证(必做)
1. **bold 对照**:新标题 INS run `bold==True` 且 ==兄弟标题;新正文 INS run `bold==False` 且有正确 `firstLine` 缩进。
```python
r = p.find('.//w:ins/w:r', ns); b = r.find('w:rPr/w:b', ns)
# 标题段 b is not None == 兄弟标题段 b is not None
```
2. **WB INS 字体逐段核验(相对同段/同级原文)**:异常应为 0。原文 run 有显式宋体四属性时,克隆来的 INS 也带四属性——与原文一致即合格。
3. **接受所有修订后渲染**,确认手动编号链连续(…7、**8.转包**、9、10、10.1、10.2、11…13),无重号/跳号。
4. python-docx 能打开(XML 合法)。
## 一句话
A 类手动编号合同新增带标题条款:**别用库的 `_title_rpr`/`_body_rpr`(启发式可能丢 bold)**,直接 `copy.deepcopy` 紧邻兄弟条款的【标题段】和【正文段】的 pPr+首run rPr,文本替换+整段标 w:ins;编号不会自动顺延,手动 tracked_replace 把后续主/子编号全部 +1。
@@ -0,0 +1,146 @@
# Comment Restoration from Original File
When comments are lost during docx editing (e.g., paragraph clear operations that remove commentRangeStart/End/Reference elements), restore them from the original file.
## Scenario
- Original file has N comments (e.g., Alice×2, 法务, 杜律 = 4 comments, ids 0-3)
- Edited file lost some/all original comments and may have added new ones (e.g., 华诚-Z comment id=0, Alice id=2)
- Goal: merge all comments — original ones preserved + new ones added, with non-conflicting IDs
## Recovery Technique
### Step 1: Extract original comments
```python
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
z_orig = zipfile.ZipFile('original.docx')
with z_orig.open('word/comments.xml') as f:
ctree_orig = etree.parse(f)
orig_comments = []
for c in ctree_orig.getroot().findall(f'{WNS}comment'):
orig_comments.append({
'id': c.get(f'{WNS}id'),
'author': c.get(f'{WNS}author'),
'date': c.get(f'{WNS}date'),
'text': ''.join(t.text for t in c.iter(f'{WNS}t') if t.text),
'element': copy.deepcopy(c)
})
z_orig.close()
```
### Step 2: Identify which comments survived in the edited file
```python
z_edit = zipfile.ZipFile('edited.docx')
with z_edit.open('word/comments.xml') as f:
ctree_edit = etree.parse(f)
edit_comment_ids = set()
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
edit_comment_ids.add(c.get(f'{WNS}id'))
```
### Step 3: Find new comments (non-original authors)
```python
new_comments = []
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
if c.get(f'{WNS}author') not in [oc['author'] for oc in orig_comments]:
new_comments.append({
'old_id': c.get(f'{WNS}id'),
'author': c.get(f'{WNS}author'),
'element': copy.deepcopy(c)
})
```
### Step 4: Rebuild comments.xml with all comments
Assign non-conflicting IDs:
- Original comments keep their original IDs (0, 1, 2, 3)
- New comments get IDs starting from max(original_ids) + 1
```python
new_comments_xml = etree.Element(f'{WNS}comments')
new_comments_xml.set('xmlns:w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
# ... add other namespaces as needed
max_id = max(int(oc['id']) for oc in orig_comments)
# Add original comments
for oc in orig_comments:
new_comments_xml.append(oc['element'])
# Add new comments with renumbered IDs
for nc in new_comments:
max_id += 1
nc['new_id'] = str(max_id)
nc['element'].set(f'{WNS}id', nc['new_id'])
new_comments_xml.append(nc['element'])
```
### Step 5: Update document.xml comment references
For each new comment, find its commentRangeStart, commentRangeEnd, and commentReference in document.xml and update the ID from old to new:
```python
for nc in new_comments:
old_id = nc['old_id']
new_id = nc['new_id']
# Update commentRangeStart
for elem in root.iter(f'{WNS}commentRangeStart'):
if elem.get(f'{WNS}id') == old_id:
elem.set(f'{WNS}id', new_id)
# Update commentRangeEnd
for elem in root.iter(f'{WNS}commentRangeEnd'):
if elem.get(f'{WNS}id') == old_id:
elem.set(f'{WNS}id', new_id)
# Update commentReference (inside w:r)
for elem in root.iter(f'{WNS}commentReference'):
if elem.get(f'{WNS}id') == old_id:
elem.set(f'{WNS}id', new_id)
```
### Step 6: Write back to docx
```python
z_out = zipfile.ZipFile('output.docx', 'w')
# Copy all files from edited.docx except comments.xml and document.xml
for item in z_edit.namelist():
if item not in ('word/comments.xml', 'word/document.xml'):
z_out.writestr(item, z_edit.read(item))
# Write updated comments.xml
z_out.writestr('word/comments.xml',
etree.tostring(new_comments_xml, encoding='UTF-8', xml_declaration=True, standalone=True))
# Write updated document.xml
z_out.writestr('word/document.xml',
etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))
z_edit.close()
z_out.close()
```
## Verification
```python
z = zipfile.ZipFile('output.docx')
with z.open('word/comments.xml') as f:
ctree = etree.parse(f)
for c in ctree.getroot().findall(f'{WNS}comment'):
print(f" id={c.get(f'{WNS}id')} author={c.get(f'{WNS}author')}: {text[:80]}")
# Check all IDs referenced in document.xml exist in comments.xml
content = z.read('word/document.xml').decode('utf-8')
doc_ids = set(re.findall(r'commentRangeStart[^>]*w:id="(\d+)"', content))
doc_ids |= set(re.findall(r'commentRangeEnd[^>]*w:id="(\d+)"', content))
doc_ids |= set(re.findall(r'commentReference[^>]*w:id="(\d+)"', content))
comment_ids = set(c.get(f'{WNS}id') for c in ctree.getroot().findall(f'{WNS}comment'))
assert doc_ids == comment_ids, f"ID mismatch: doc={doc_ids} comments={comment_ids}"
```
## Key Pitfall: Comment Text Extraction
When extracting comment text for comparison, comments may have nested `<w:p>` elements (multi-paragraph comments). Use `.iter()` not `.findall()` to get all text nodes.
## Empirical Case (2026-07-01 反委托代发工资协议)
- Original: 4 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3)
- v1_doro_updated: 2 comments (华诚-Z id=0, Alice id=2) — lost Alice id=0/1, 法务, 杜律
- Final: 5 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3, 华诚-Z id=4)
- 华诚-Z's comment was id=0 in v1_doro_updated, renumbered to id=4 in final
- All commentRangeStart/End/Reference IDs updated in document.xml accordingly
@@ -0,0 +1,94 @@
# 合同模板修订工作流(非workflow场景)
## 触发条件
用户要求参考一份新合同模板(保护甲方),将有利内容用修订模式改进原合同(乙方模板)。
## 与标准 review-contract workflow 的区别
- 不涉及 classifier/reviewer/editor/deliverer 角色链
- 不使用 review-rules.md
- 不需要 pass 流程(不写 tracker/xlsx)
- 直接用 ContractEditor 库手动修订
## 操作步骤
### 1. 读取两份合同
```python
from contract_docx_lib import ContractEditor
editor = ContractEditor('原合同.docx') # 乙方模板,作为修订基底
```
同时用 python-docx 或 zipfile+lxml 读取新合同全文,逐条对比差异。
### 2. 识别差异并分类
- **可直接移植**:新合同中明确有利于甲方的条款(如违约金降低、管辖权、解除权限制)
- **需要调整**:新合同有利但需适配原合同结构/编号的条款
- **需要补充**:新合同仍未覆盖的保护甲方的内容(根据法律法规判断)
### 3. 执行修订(最小化修改原则)
- 整体格式、编号逻辑按**原合同**来
-`tracked_replace` 修改既有条款
-`add_clause` 新增条款(插在合同逻辑对应位置)
- author=WB
### 4. 法律研究(严禁凭记忆)
每次修订前必须查证:
- 最新法律法规(民法典、劳动合同法、劳务派遣暂行规定等)
- 上海地区地方规定和司法实践
- 行业惯例
常见需要查证的点:
- 违约金比例上限(司法实践中过高会被调整)
- 管辖权约定(甲方所在地法院 vs 仲裁)
- 劳务派遣的法定退回情形(劳动合同法第65条)
- 雇主责任险要求(上海地区实务惯例)
- 经济补偿金的法定标准
### 5. 修订说明
完成后向用户汇报:
- 修订数量(insertions/deletions)
- 每项修订的法律依据
- 标注哪些是根据新合同移植、哪些是独立判断补充
## 违约后果公式(核心原则,2026-06-29 Doro纠正)
**"权利是法律给的,关键在违约后果"**——当法律已赋予甲方某项权利时,合同中简单写入"甲方有权XX"只是重复法律,没有实质保护价值。审查/修订的重点是**违约后果条款**:
### 标准违约后果公式
```
甲方因此支付的一切费用、承担的赔偿或补偿金、损失等由乙方全额赔偿,
乙方另向甲方支付违约金人民币 元。
如对甲方造成其他不良影响的,乙方还应当消除一切影响。
```
### 三要素
1. **赔偿范围**:一切费用、承担的赔偿或补偿金、损失等(括注具体类型如重新招聘费用、行政罚款、律师费、诉讼费等)
2. **违约金**:金额留空(6个空格),由甲方根据实际用工规模和风险自行填写
3. **消除影响**:兜底,覆盖名誉损害、商誉损失等非经济损失
### 适用场景
所有"乙方违反法定义务→甲方有权XX"类条款:
- 资质丧失 → 不止"甲方有权解除",要追加完整后果公式
- 克扣工资/欠缴社保 → 不止"暂停付款",要追加连带后果公式
- 一般违约追偿 → 不止"有权追偿",要写清赔偿范围+违约金+消除影响
### 劳务派遣协议实证(2026-06-29)
| 条款 | 原写法(弱) | 改后(含后果公式) |
|------|------------|-------------------|
| 资质丧失 | "甲方有权解除,乙方赔偿全部损失" | "乙方赔偿一切费用/赔偿或补偿金/损失(含重新招聘费、劳动者赔偿金、行政罚款、律师费等)+违约金___元+消除一切影响" |
| 审核权 | "暂停支付相关费用直至整改完成" | 追加:因乙方违法行为导致甲方承担连带责任的,一切费用由乙方赔偿+违约金+消除影响 |
| 一般追偿 | "甲方有权依法向乙方追偿" | "一切费用由乙方赔偿+违约金+消除一切影响" |
## 2026-06-29 劳务派遣协议案修订清单
| 修订 | 类型 | 法律依据 |
|------|------|----------|
| 乙方资质持续保证 | 新增 | 《劳务派遣暂行规定》第17条 |
| 甲方监督检查权扩展 | 修改 | 《劳动合同法》第62条 |
| 甲方调整岗位权 | 新增 | 《劳动合同法》第62条 |
| 甲方随时退回权 | 新增 | 《劳动合同法》第65条、《劳务派遣暂行规定》第12条 |
| 雇主责任险要求 | 新增 | 上海司法实践惯例 |
| 乙方解除权限制 | 修改 | 《民法典》第563条(催告程序) |
| 付款期限延长 | 修改 | 商业条款(甲方资金调度) |
| 甲方违约金降低 | 修改 | 上海法院对过高违约金的司法调整 |
| 乙方根本违约情形 | 新增 | 《民法典》第563条 |
| 争议解决管辖 | 新增 | 《民事诉讼法》第35条(协议管辖) |
| 附件和补充协议 | 新增 | 标准合同条款 |
@@ -0,0 +1,33 @@
# 跨境并购费用参考(5000万人民币交易规模)
> 来源:行业公开数据与市场实践,2026年6月。具体费用因交易复杂度、目标法域、各方谈判能力而异。
## 各角色费用区间
| 角色 | 费用(人民币) | 收费模式 |
|---|---|---|
| 财务顾问(FA) | 150万–250万 | 成功费,交易对价3%–5%;分期收取(签约10–20%,签约后40%,交割后40–50%) |
| 法律顾问(中国律所) | 50万–100万 | 固定费,含法律尽调15–30万、交易文件20–40万、监管审批10–20万、境外律师协调5–10万 |
| 境外律师 | 30万–80万 | 按小时(300–800美元/小时),目标法域决定 |
| 会计师(财务尽调) | 20万–40万 | 固定费 |
| 税务师 | 15万–35万 | 固定费,含税务尽调10–20万、结构优化5–15万 |
| **合计** | **265万–505万** | 占交易额约5%–10% |
## FA 费率惯例
- 中国市场:中端交易3%–5%,大型交易费率递减
- 海外莱曼公式(Lehman Formula):累退费率,5000万人民币≈680万欧元→约15万欧元(约118万人民币),但中国市场费率通常高于莱曼
- 中国FA实操中常用"一口价"或协商费率,少见纯莱曼公式
## 交易协调人(律师兼任)收费参考
- 固定项目管理费:5万–10万/月,或每项目15万–30万
- 从FA成功费分成:10%–15%
- 最优组合:固定费(保底)+ FA分成(激励)+ 法律费独立收取(不混)
## 第三方机构管理原则
- FA负责整体协调,但不代替第三方出具报告
- 第三方费用由客户直接支付
- 各机构独立承担专业责任
- 律师(作为交易协调人)可帮FA管理第三方机构,但不能替第三方机构的工作成果背书
@@ -0,0 +1,102 @@
# DOCX 批注(Word 原生 comment)插入 — 纯 zipfile+lxml
实战来源:南通新东方校外培训服务合同独立审查(2026-06-17)。Maggie 要求"用修订**和批注**的形式"。`ContractEditor` 没有批注方法(`dir()` 确认无 comment/annot/note),批注必须手写 OOXML。已验证可在 OnlyOffice 正常显示。
## 何时用 DOCX 批注 vs PDF 批注
- **docx 合同** → 用本文方法(Word 原生 comment,OnlyOffice 显示为右侧批注气泡)。
- **PDF 合同** → 用 pymupdf(fitz) 高亮+comment annotation(见 SKILL.md「PDF合同直接批注」)。两者不通用。
## 批注内容铁律(与 SKILL.md 一致,复述强调)
- 只写"建议……",给方案;**不写理由/原因/因为**;**不加【新增】【建议】等标签**。
- 批注仅限两类:①需客户确认(名称空白、标准未定义需明示);②建议增加条款且内容较长。**选择题/勾选项不处理(2026-07-08废止)。**
- 能直接修订的一律修订,批注是最后手段。
## 五个改动点(缺一不可,否则 Word 报"无法打开/需修复")
1. **新增 `word/comments.xml`**:定义每条批注的 id/author/date/initials + 内容。
2. **`word/document.xml`**:在锚点文本范围**前**插 `w:commentRangeStart`、**后**插 `w:commentRangeEnd` + 一个带 `w:commentReference` 的 run。
3. **`[Content_Types].xml`**:加 `Override` 声明 comments.xml 的 content-type。
4. **`word/_rels/document.xml.rels`**:加 `Relationship` 指向 comments.xml。
5. 三处 id(rangeStart/rangeEnd/commentReference)与 comments.xml 的 `w:comment/@w:id` **必须全部一致**
## 锚点定位(关键陷阱)
- 锚点文本要按**接受修订后**的文本匹配(遍历 w:t 时**跳过 w:del 内的**),否则被删字符会让匹配错位。
- commentRangeStart 必须插在段落第一个 `w:r` **或 `w:ins`** 之前(不能只找 w:r——修订后段首可能是 ins)。
- 先验证每个锚点在全文**唯一命中**(命中数==1)再插,多处命中会挂错段落。
## 可复用代码
```python
import zipfile, io
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
Wq = '{' + W + '}'
DATE = "2026-06-17T10:00:00Z"
comments = [ # 站顾问单位立场,需确认/建议增加内容
{"id":"201","anchor":"甲方扣除相应服务费后","text":"建议在合同或退费管理制度中明确“服务费”的扣费比例或计算方式,并在签约时向乙方明示。"},
{"id":"202","anchor":"向甲方住所地人民法院提起诉讼","text":"本条约定甲方住所地法院管辖,建议签约时以加粗或单独提示方式向乙方说明,尽到格式条款提示义务。"},
]
def build_comments_xml(comments):
p = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
f'<w:comments xmlns:w="{W}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">']
for c in comments:
p.append(f'<w:comment w:id="{c["id"]}" w:author="WB" w:date="{DATE}" w:initials="WB">')
# 批注文字字体随原文(本例宋体sz=18小一号),hint=eastAsia 必带
p.append('<w:p><w:r><w:rPr><w:rFonts w:ascii="宋体" w:hAnsi="宋体" w:eastAsia="宋体" w:cs="宋体" w:hint="eastAsia"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>')
p.append(f'<w:t xml:space="preserve">{c["text"]}</w:t></w:r></w:p></w:comment>')
p.append('</w:comments>')
return ''.join(p)
comments_xml = build_comments_xml(comments)
with open('IN.docx','rb') as f: data=f.read()
bi, bo = io.BytesIO(data), io.BytesIO()
inserted = {c["id"]: False for c in comments}
with zipfile.ZipFile(bi) as zin, zipfile.ZipFile(bo,'w',zipfile.ZIP_DEFLATED) as zout:
for it in zin.infolist():
raw = zin.read(it.filename)
if it.filename == 'word/document.xml':
tree = etree.fromstring(raw); body = tree.find(f'{Wq}body')
for para in body.findall(f'.//{Wq}p'):
ptext = '' # 接受修订后文本:跳过 del
for t in para.findall(f'.//{Wq}t'):
if not any(a.tag==f'{Wq}del' for a in t.iterancestors()):
ptext += (t.text or '')
for c in comments:
if not inserted[c["id"]] and c["anchor"] in ptext:
cid = c["id"]
first = next((ch for ch in para if ch.tag in (f'{Wq}r',f'{Wq}ins')), None)
if first is None: continue
crs = etree.Element(f'{Wq}commentRangeStart'); crs.set(f'{Wq}id',cid); first.addprevious(crs)
cre = etree.Element(f'{Wq}commentRangeEnd'); cre.set(f'{Wq}id',cid); para.append(cre)
rr = etree.SubElement(para,f'{Wq}r'); rp=etree.SubElement(rr,f'{Wq}rPr')
rs = etree.SubElement(rp,f'{Wq}rStyle'); rs.set(f'{Wq}val','CommentReference')
cref = etree.SubElement(rr,f'{Wq}commentReference'); cref.set(f'{Wq}id',cid)
inserted[cid] = True
raw = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
elif it.filename == '[Content_Types].xml':
ct = etree.fromstring(raw); NS='http://schemas.openxmlformats.org/package/2006/content-types'
ov = etree.SubElement(ct,f'{{{NS}}}Override')
ov.set('PartName','/word/comments.xml')
ov.set('ContentType','application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml')
raw = etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True)
elif it.filename == 'word/_rels/document.xml.rels':
rt = etree.fromstring(raw); RNS='http://schemas.openxmlformats.org/package/2006/relationships'
r = etree.SubElement(rt,f'{{{RNS}}}Relationship')
r.set('Id','rIdComments1')
r.set('Type','http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments')
r.set('Target','comments.xml')
raw = etree.tostring(rt, xml_declaration=True, encoding='UTF-8', standalone=True)
zout.writestr(it, raw)
zout.writestr('word/comments.xml', comments_xml.encode('utf-8'))
with open('OUT.docx','wb') as f: f.write(bo.getvalue())
assert all(inserted.values()), f"未全部挂靠: {inserted}"
```
## 交付前验证(缺一不可)
1. **id 四向一致**`commentRangeStart` / `commentRangeEnd` / `commentReference` 三组 id 集合 == comments.xml 的 `w:comment/@w:id` 集合。
2. **python-docx 能打开**(XML 合法)。
3. **接受修订后锚点存在**:批注挂靠的文本在去 del 后仍在。
4. **OnlyOffice 渲染**(onlyoffice-render.sh)确认批注气泡正常显示,不破坏修订标记。
## 修订与批注可共存
同一份 docx 先用 ContractEditor 做完 tracked_replace/add_clause 并 save,再在产物上跑本脚本加批注。批注的 commentRangeStart 会落在修订后的段落结构里(段首可能是 w:ins),代码已用 `(w:r, w:ins)` 兼容。
@@ -0,0 +1,42 @@
# 检测已审查文件重复处理(Workflow产出双版本问题)
## 2026-07-13 消防设施检测合同教训
### 现象
同一份合同在任务交付目录出现两个文件:
- v1: 有WB tracked changes(正确交付物)
- v2: 无tracked changes + 有WB批注(纯批注版)
### 诊断方法
```python
# 快速判断文件性质
with zipfile.ZipFile(filepath, 'r') as z:
doc_xml = z.read('word/document.xml')
# 检查tracked changes
ins_count = doc_xml.count(b'w:ins')
del_count = doc_xml.count(b'w:del')
# 检查批注
has_comments = 'word/comments.xml' in z.namelist()
if has_comments:
comments = z.read('word/comments.xml')
comment_count = comments.count(b'w:comment ')
print(f"INS: {ins_count}, DEL: {del_count}, Comments: {comment_count}")
```
### 判断标准
| 文件状态 | 性质 | 应否保留 |
|----------|------|----------|
| 有INS/DEL + 无comments | 标准修订版 | ✅ 正确交付物 |
| 有INS/DEL + 有comments | 修订+批注版 | ✅ 正确 |
| 无INS/DEL + 有comments | 纯批注版 | ⚠️ 需审查批注合规性 |
| 无INS/DEL + 无comments | 原文副本 | ❌ 不应在交付目录 |
### 纯批注版的审查要点
- 是否违反"能改就不批注"原则
- 批注立场是否正确(站甲方)
- 是否属于"提醒性批注"(禁止)
- **严重错误示例**:Comment 203建议"违约金偏高,建议设上限"——这是在帮乙方限制甲方的违约金权利,立场完全反了
### python-docx的.text陷阱
`paragraph.text`不反映批注内容。两份文件的`.text`可能100%相同但实际一份有6条批注。**判断文件是否相同必须检查comments.xml**。
@@ -0,0 +1,42 @@
# 文件版本管理纪律(2026-07-01 总结多次返工教训)
## 铁律:操作前备份,操作后验证,不覆盖不重做
### 1. 操作前必须备份
任何对 docx 文件的修改操作前,先 `cp` 一份到 `/tmp/contract-backup/` 并带时间戳:
```bash
cp /tmp/反委托_版本1.docx /tmp/contract-backup/反委托_版本1_$(date +%H%M).docx
```
2026-07-01教训:反委托代发工资协议做了7-8个版本,每次覆盖前一版,最终华诚-Z的修订痕迹差点不可恢复(在v1_doro_updated.docx中找到最后一份)。
### 2. 增量修复,不从头重做
出问题时修补当前版本,不从原文件重新做一遍。重做=覆盖=丢失中间状态。
### 3. 操作后验证完整性
每次修改 docx 后必须验证:
- comments.xml:批注数量、作者、ID 是否完整(与修改前对比)
- document.xml:tracked changes 的 author 集合是否正确
- 文件大小:是否合理(不应比修改前小太多)
### 4. 中间版本命名规范
```
反委托_版本1_v1.docx → 第一版
反委托_版本1_v2.docx → 第二版(不覆盖v1)
反委托_版本1_v3.docx → 第三版
反委托_版本1_final.docx → 确认后的最终版(覆盖上传到Nextcloud)
```
### 5. Subagent 输出必须验证
delegate_task 返回后:
- 检查 result.status 是否 "completed"
- 对文件类结果:用 zipfile 打开验证 comments/tracked changes 完整性
- 不能假设 subagent 正确——它可能丢批注、改错 author、漏条款
## 常见覆盖事故
| 事故 | 根因 | 预防 |
|------|------|------|
| 华诚-Z修订被全部改成WB | 多次重做时每次都"统一author=WB" | 备份原始含华诚-Z的版本 |
| 批注丢失(4条变2条) | 从头重建时没对比原文件的comments.xml | 修改后立即验证批注数量 |
| 字体覆盖(仿宋_GB2312→仿宋) | 重做时用了错误的字体名 | 从原文件克隆rPr,不手写 |
@@ -0,0 +1,144 @@
# Layering WB Revisions on High-Density Tracked Changes Documents
## Problem
When a document already has extensive tracked changes from another author (e.g., 华诚-Z with 170+ INS and 90+ DEL), ContractEditor's `tracked_replace` frequently fails with `ValueError: Element is not a child of this node` because the paragraph structure is heavily fragmented with interleaved `w:ins`/`w:del`/`w:r` elements.
## Solution: Direct lxml Operations
### Strategy
Use zipfile + lxml to directly manipulate the XML instead of ContractEditor library. Three operation types:
### 1. Append text to existing paragraph end
Find the paragraph, locate the last content element, and append a `w:ins` after it.
```python
# Find the last non-pPr child element in the paragraph
last_content = None
for child in p:
if child.tag != f'{WNS}pPr':
last_content = child
# Create INS element
ins = etree.SubElement(p, f'{WNS}ins')
ins.set(f'{WNS}id', str(next_id))
ins.set(f'{WNS}author', 'WB')
ins.set(f'{WNS}date', '2026-07-02T00:00:00Z')
r = etree.SubElement(ins, f'{WNS}r')
# Clone rPr from nearby run
rpr = get_reference_rpr(p) # see below
if rpr is not None:
r.insert(0, copy.deepcopy(rpr))
t = etree.SubElement(r, f'{WNS}t')
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
t.text = "追加的文字内容"
```
### 2. Insert new paragraph (全段INS)
Clone neighboring paragraph's pPr, create a new `w:p` with all content inside `w:ins`.
```python
# Clone pPr from reference paragraph
ref_p = paras[target_idx] # the paragraph after which to insert
new_p = etree.Element(f'{WNS}p')
# Clone pPr
ref_ppr = ref_p.find(f'{WNS}pPr')
if ref_ppr is not None:
new_p.append(copy.deepcopy(ref_ppr))
# Create INS wrapping all content
ins = etree.SubElement(new_p, f'{WNS}ins')
ins.set(f'{WNS}id', str(next_id))
ins.set(f'{WNS}author', 'WB')
ins.set(f'{WNS}date', '2026-07-02T00:00:00Z')
r = etree.SubElement(ins, f'{WNS}r')
rpr = get_reference_rpr(ref_p)
if rpr is not None:
r.insert(0, copy.deepcopy(rpr))
t = etree.SubElement(r, f'{WNS}t')
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
t.text = "新增条款全文"
# Insert after reference paragraph
ref_p.addnext(new_p)
```
### 3. Character-level replacement within high-density paragraph
When text to replace is inside an existing `w:ins` from another author (e.g., 华诚-Z), you need to split that ins element.
```python
# Find the ins element containing target text
for ins_elem in p.findall(f'{WNS}ins'):
for r in ins_elem.findall(f'{WNS}r'):
t = r.find(f'{WNS}t')
if t is not None and t.text and old_text in t.text:
# Split: keep text before, add WB del+ins for changed part, keep text after
pos = t.text.index(old_text)
before = t.text[:pos]
after = t.text[pos + len(old_text):]
# Modify existing t to keep only 'before'
t.text = before + after.replace(old_text, new_text) # simplified
# Or split into multiple elements...
```
### Getting reference rPr
```python
def get_reference_rpr(p):
"""Get rPr from first non-del run in paragraph, or from 华诚-Z ins"""
# Try plain runs first
for r in p.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is not None:
return rpr
# Try non-WB ins elements
for ins in p.findall(f'{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is not None:
return rpr
# Try previous paragraph
prev = p.getprevious()
if prev is not None:
return get_reference_rpr(prev)
return None
```
## Critical: Post-save sz fix
When INS runs clone rPr from paragraphs that lack explicit `w:sz` (relying on style inheritance), the INS will render at wrong size. **Always run a post-save sweep:**
```python
# Determine dominant body sz from neighboring paragraphs
# Then fix all WB INS runs missing sz
for ins in body.iter(f'{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
continue
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is not None:
sz = rpr.find(f'{WNS}sz')
if sz is None:
sz = etree.SubElement(rpr, f'{WNS}sz')
sz.set(f'{WNS}val', dominant_sz) # e.g., '24' for 12pt
szCs = etree.SubElement(rpr, f'{WNS}szCs')
szCs.set(f'{WNS}val', dominant_sz)
```
## Author Unification
After Doro reviews and confirms, unify all authors to WB:
```bash
python scripts/unify-author-wb.py input.docx [output.docx]
```
## Lesson Learned (2026-07-02)
- Doro will edit the files in OnlyOffice after upload. Always download Doro's version before doing further work.
- "你自己要满意再给我" = self-verify before delivery, don't ask user to check.
- "认真做" = thoroughness signal. Read full contract text, verify each modification landed correctly.
- When Doro says "看看是否还有需要调整的" = compare your version vs Doro's, identify what Doro changed, assess if further work needed.
- Unifying author is a standard final step — use the script, don't hand-code each time.
@@ -0,0 +1,27 @@
---
name: lawyer-letter-formatting
description: 律师函制作格式要点。基于Watson&Band模板,logo在正文段落anchor中而非header XML。
tags: [legal, lawyer-letter, docx, formatting]
---
# 律师函制作
## 关键格式(参考_律师函模板)
- **字体**:仿宋 12pt,西文Times New Roman
- **首行缩进**:304800 EMU
- **行距**:1.25倍
- **对齐**:两端对齐(JUSTIFY)
- **列表编号**:numbering.xml中japaneseCounting格式(第一、第二、第三、)
- **送达信息**:9pt
## 关键陷阱
- **Logo不在header XML中**!是作为浮动锚点(anchor drawing)嵌在正文第一段落的run中
- 用python-docx重建段落会丢失drawing元素,必须从模板段落提取保留
- 复制模板时要保留原始段落的XML结构,不能只复制文字
## 参考文件位置
- 模板:Doro诉讼案件任务/参考文件/_律师函
## 交付位置
- 放到 Doro其他任务/交付文件/(不是待处理任务)
- 交付后@doro通知
@@ -0,0 +1,68 @@
# 在「用户已自行修订过」的合同上叠加我方修订
实战来源:金信大厦5层东部租赁合同(2026-06-25)。Maggie 本人已用修订模式改了 6 处(author="maggie jia"),要求小Maggie 在此基础上**再补几处**(模版比对后补不可抗力对等、装修残值公式、抵押救济),**保留她的全部修订一字不动**。
## 何时用本配方
- 收到的 docx **已带 track changes**(settings.xml 有 `<w:trackRevisions/>`,文中有 author≠WB/小Maggie 的 w:ins/w:del)。
- 任务是**在用户既有修订之上追加几处**,不是重审、不是从干净稿做。
- **不重跑 workflow,也不用 ContractEditor 库**——库的字符级 diff 引擎会把用户既有 w:ins/w:del 卷进来重算,破坏其修订。一律 zipfile+lxml 直接追加节点。
## 五步配方
### 1. 新修订 id 从 `maxid+1000` 起,防撞 + 便于事后过滤
```python
maxid = 0
for el in root.iter():
if el.tag in (Wq+"ins", Wq+"del"):
v = el.get(Wq+"id")
if v and v.isdigit(): maxid = max(maxid, int(v))
nextid = [maxid + 1000] # 1000 间隔:本次新增 id 全 >1000,过滤/核验时一眼区分
def newid(): nextid[0]+=1; return str(nextid[0])
```
为什么 +1000 不是 +1:核验「我的修订」与「用户的修订」时,`int(id)>1000` 直接切分两批,不必记具体数字。
### 2. 作者:沿用文档既有修订线,不强行套 WB
金信大厦案文档既有修订 author="maggie jia",本次追加**沿用同一 author**(保持修订线一致、Maggie 看就是「她那条线的延续」)。
> 注意与「author 铁律=WB」的边界:WB 是 Doro 体系合同审查的署名;当**文档已有用户自己的修订线**、任务是「在她的修订上接着改」时,沿用她的 author 让修订归并到同一作者更自然。归属按文档既有线定,不是无脑套 WB。拿不准就问。
### 3. rPr:克隆「用户已渲染正确的 INS」当样板,预防中文字体回退坑
不要自己造 rPr。找一个用户已有的、**中文显示正常的** w:ins run,读它的 rPr 当模板:
```python
# 金信大厦案模板:<w:rFonts w:ascii="Times New Roman" w:eastAsiaTheme="minorEastAsia"
# w:hAnsi="Times New Roman" w:cs="Times New Roman" w:hint="eastAsia"/>
# <w:sz w:val="21"/><w:szCs w:val="21"/>
def make_rpr():
rpr = etree.Element(Wq+"rPr")
rf = etree.SubElement(rpr, Wq+"rFonts")
rf.set(Wq+"ascii","Times New Roman"); rf.set(Wq+"eastAsiaTheme","minorEastAsia")
rf.set(Wq+"hAnsi","Times New Roman"); rf.set(Wq+"cs","Times New Roman"); rf.set(Wq+"hint","eastAsia")
sz = etree.SubElement(rpr, Wq+"sz"); sz.set(Wq+"val","21")
etree.SubElement(rpr, Wq+"szCs").set(Wq+"val","21")
return rpr
```
`eastAsiaTheme="minorEastAsia"+hint="eastAsia"` 让中文走主题回退(金信大厦回退到宋体),西文 Times New Roman——这是这类合同 INS 中文正常显示的关键,详见 SKILL.md「INS 中文字体」节。
### 4. 三种插入机制(按改动类型选)
- **纯追加**(句末补一句救济/公式):定位段落最后一个 normal run / 最后一个 ins,`last.addnext(make_ins(text, rpr))`
- **删一段换对等表述**(单向条款改双向):split 原 run → 原 run 文本保留前半、`addnext(make_del(后半, 原rpr))` → 再 `del.addnext(make_ins(对等表述, rpr))`
- **替换数值/词**(6→12 个月、percent→百分之):字符级定位,DEL 旧 + INS 新。
`make_del``<w:del><w:r><w:delText>`、克隆原 run 的 rPr 加 `rsidDel``make_ins``<w:ins><w:r><w:t xml:space="preserve">`
### 5. 只换 document.xml(settings 已开 trackRevisions 就不动它)
```python
with zipfile.ZipFile(SRC) as zin, zipfile.ZipFile(tmp,"w",zipfile.ZIP_DEFLATED) as zout:
for it in zin.namelist():
zout.writestr(it, new_doc if it=="word/document.xml" else zin.read(it))
```
## 四查验证(缺一不可)
1. **python-docx 能打开**`Document(out)`)——XML 合法。
2. **接受所有修订后文本正确**——抽出「保留 ins 内容、丢弃 del 内容」的纯文本,逐处核我改的几段语句通顺、内容对。
3. **本次新增 INS(id>1000)含中文 run 字体非 Times New Roman**——`eastAsiaTheme=="minorEastAsia" or (ea and ea!="Times New Roman")` 应全真。
4. **🔴 用户原有修订逐 id 比对一字未动**——把源文件与产物里 `id<=maxid` 的所有 ins/del 提成 `(id, tag, author, 文本)` 排序比对,必须**完全相等**。这是本配方的核心安全验证:证明我只追加、没碰用户的任何一处。
## 收口(与库路径相同)
`scripts/accept-revisions-preview.py` 生成干净版 → OnlyOffice 渲染 → vision 视觉验收。
- **vision 报「页底某句截断」先分清 PDF 分页 vs 真丢数据**:金信大厦案 vision 报抵押救济句在 P7 底部截断,实为该句跨页接到 P8 开头——①数据层读该 INS 完整内容在;②P7+P8 拍平后 grep 完整句存在 → 确认是 PDF 分页跨页,OnlyOffice 滚动查看正常,**不返工**。判据同 contract-portfolio-analysis Pitfall:数据层完整+跨页搜得到=分页现象。
- vision 对字体/‰%/小符号的误判同样适用——回数据层核,别据像素返工(见 SKILL.md「交付前视觉验收的两个已知误判」)。
@@ -0,0 +1,62 @@
# 法律文书:脚注、同源模板成稿、Doro 编辑后字体修复
contract-editor 库(zipfile+lxml)在**新成稿文书**(非修订态)上的复用。2026-06-23-24 邹家《情况反映》制作中验证。配套 `litigation-doc-tracked-changes.md`(那篇讲修订态;本篇讲脚注+新建文书+字体规范化,都不是 tracked-changes)。
## 1. 法条原文脚注——从同案"姊妹文书"克隆脚注样式(铁律:脚注样式不要凭空造)
需求场景:Doro 把正文里的法条引用("《民事诉讼法》第七十一条之规定")要求改成**脚注呈现法条全文**,且"脚注格式和申请书一样"。
正确做法是从**同案已有带脚注的文书**(如同目录的《民事诉讼监督申请书》v9)克隆脚注体例,而不是自己拼 footnotes.xml:
**脚注的两个组成**(先从姊妹文书读出样式模板):
- 正文里的**引用标**:一个 `<w:r>`,rPr 带 `<w:rStyle w:val="affb"/>` + Times New Roman + 与正文同字号(sz24),内含 `<w:footnoteReference w:id="N"/>``affb` 是 Word 默认的 FootnoteReference 字符样式 id(不同文档可能不同,**从姊妹文书正文的 footnoteReference 承载 run 实测,别硬编码**)。
- footnotes.xml 里的**脚注正文**:separator/continuationSeparator 两个特殊脚注(id=-1/0,原样复制)+ 内容脚注(id≥1)。内容脚注段落 spacing line=240,run 字号是**脚注体例 sz18(9pt,小于正文)**,法名加粗(`<w:b/>`)、条号与原文不加粗。
```python
# 从姊妹文书 sqs_v9.docx 取模板
fn_sqs = etree.fromstring(z_sqs.read('word/footnotes.xml'))
special = {ft: deepcopy(f) for f in fn_sqs.iter(qn('footnote'))
for ft in [f.get(qn('type'))] if ft in ('separator','continuationSeparator')}
content_tmpl_p = deepcopy([f.find(qn('p')) for f in fn_sqs.iter(qn('footnote'))
if f.get(qn('id'))=='1'][0])
# 从模板段落抽三种 rPr:mark(带rStyle affb)、bold(法名)、plain(原文)
# 正文引用标 rPr 则从姊妹文书 document.xml 里 footnoteReference 承载 run 抓
```
**目标 docx 必须已支持脚注**`word/_rels/document.xml.rels` 有 footnotes 关系、`[Content_Types].xml``footnotes+xml`、styles.xml 有 `affb` 样式。若目标是从同源文书演化来的(本例情况反映以申请书为母版),这三样天然齐全;若从零新建则要补。
## 2. 脚注标定位的坑:锚点跨 run 时 footnoteReference 会插错位置(本会话实犯)
把脚注标插在"第五十一条第二款"之后时,第一版用"找锚点子串→定位锚点所在 run→run 后插标",结果 ³ 插到了下游的"承办部门"后面——因为锚点文字**跨多个 run**,按 run 粒度定位会落到错误的 run。
**正解:字符流定位 + 必要时拆 run**。把全段所有 `w:t` 拼成字符流,建立 `每个字符→(t元素, 字符在t内的索引)` 映射,找到锚点**结束字符**的精确位置;若结束字符在某 run 中间,**split 该 run**(head 留原 run,tail 进新 run),脚注引用 run 插在 head 和 tail 之间。这样标精确落在"…第二款【标】所定…"。
验证只能靠 OnlyOffice x2t 渲染后看页脚——vision 一眼就抓出"³ 标在承办部门后",肉眼读 XML 容易漏。体例统一:四个脚注一律"法条号正后方"挂注(不要有的挂句末有的挂条号后)。
## 3. 用同案文书做"母版"新建文书——保证两份同源同体例
新建《情况反映》时,以同案《民事诉讼监督申请书》v9 为母版克隆,确保字体/字号/页边距/样式完全一致(Doro/Maggie 两份并排看不会有体例差):
- 从母版抽各类段落模板:title(居中bold sz30)、body(首行缩进480 sz24)、recip(顶格bold 机关名)、sign(右对齐)、date、attachment-title、attachment-item。`mk(tmpl_key, text, bold=, no_indent=)` 克隆模板段→清空 run/numPr/ins/del→重设仿宋+Times→填文字。
- 用母版的整个 docx 做容器(保留 sectPr 页面设置、styles、numbering),只重写 body 的段落序列 + 清掉 `<w:trackRevisions/>`
- **清掉母版页眉**:母版页眉可能是另一种文书的抬头(本例申请书页眉"申请监督案号/受理法院"套在情况反映上不对)。清页眉要两步:①删页眉段所有 run 文字;②**删页眉段 pPr 的 `<w:pBdr>`**(页眉那条横线来自段落下边框,只删文字会留一条孤线)。OnlyOffice 渲染确认顶部纯白到标题。
## 4. ⚠️Doro 用编辑器改过的 docx 会丢显式 eastAsia 字体属性——每轮都要补(本会话两轮各犯一次)
**现象**:Doro 在他本机编辑器改过 docx 后回传,正文中文 run 的 `rFonts` **没有显式 eastAsia 字体名**(本会话两轮分别 1283、1243 个中文字符 `eastAsia=None`,docDefaults 也空)。OnlyOffice 靠底层回退仍渲染成仿宋、肉眼看正常,但**显式字体属性缺失不符合交付标准**(我们要求中文显式仿宋)。
**判别**:交付前扫一遍——
```python
for r in root.iter(qn('r')):
rf = r.find(qn('rPr/rFonts'))
ea = rf.get(qn('eastAsia')) if rf is not None else None
# 统计含中文 run 里 ea is None 的数量;>0 就要补
```
**修复(格式规范化,不改字形/文字/Doro 内容)**:每个含文字的 run,rFonts 显式设 `eastAsia=仿宋`,缺 ascii/hAnsi 的补 `Times New Roman`;再给 `docDefaults/rPrDefault/rPr/rFonts``eastAsia=仿宋` 兜底。改完目标:CJK 全仿宋、英数全 Times。**这是和合同字体规范化同类的操作,但要点在"每次 Doro 回传都要重做一遍"**——他的编辑器每改一次就再剥一次,不是一次性问题。改完必须 OnlyOffice 重渲染确认无字形回退(字体属性动过就要重验)。
## 5. 附件/正文 Doro 自己加的内容:修错别字但不擅改实质
Doro 自己在附件加了"检查监督申请书"——①错别字"检**查**"→"检**察**"院的监督,规范名应是与正式文件名一致的《民事诉讼监督申请书》(改);②但若附件项之间有实质区分缺失(如两份《质证通知书》一份标了"3日期限版"另一份没标"15日期限版"),那是 Doro 定的内容,**只提示不擅改**。附件清单常是自动编号(numId),Doro 删手敲序号是对的,自动会续 1-6。
## 一句话
脚注从同案姊妹文书克隆样式(rStyle affb + sz18 脚注体、法名加粗)、标位置用字符流+拆run精确落在条号后;新建文书拿同案文书做母版保同源(清页眉含删 pBdr);**Doro 编辑器回传的 docx 每轮都丢显式 eastAsia,每次交付前都要全局补仿宋再渲染**。
@@ -0,0 +1,73 @@
# 诉讼文书:法条原文脚注 + 母版克隆建新文书 + 字体规范化
contract-editor 库在诉讼文书上的三组技法,2026-06-23/24 邹家「情况反映」(给法院监督部门的程序违法反映材料)制作中验证,全部 OnlyOffice x2t 渲染逐页核对过。与 `litigation-doc-tracked-changes.md`(修订态技法)互补——本文是**脚注 + 新建文书 + 字体**层面。
---
## 一、法条原文脚注(Doro 偏好:引用法律规定一律用脚注呈现原文,不删改不概括)
Doro 对引用法条的文书要求:**法条原文(一字不改、不归纳)用脚注方式写进去**。监督申请书 v9 已是这个体例,情况反映照搬。这是可复用的整套做法。
### 1. 脚注格式从同族已有文书克隆,不自造
申请书 v9 的 `word/footnotes.xml` 是现成模板。提取三样:
- **两个特殊脚注** `type=separator` / `type=continuationSeparator`(id=-1/0)——分隔线,照搬。
- **一个内容脚注的段落骨架**(id=1 的 `<w:p>`)——拿它的 `pPr`(脚注段 `spacing line=240`)和三种 run 的 `rPr` 模板:
- **mark rPr**:带 `<w:rStyle w:val="affb"/>` + Times New Roman + **sz18**(9pt 脚注体,不是正文 sz24)——脚注区那个序号。
- **bold rPr**:`<w:b/>` + sz18——法名加粗用。
- **plain rPr**:sz18 无 rStyle 无 b——条号+原文用。
- 正文里的脚注引用标(`footnoteReference` 承载 run)的 rPr 另取:从 v9 **正文** 里找 `r/footnoteReference` 那个 run 的 rPr(`rStyle=affb` + Times + **sz24**,跟正文同号,上标由 affb 样式控制)。
每条脚注 `<w:footnote id=N>` 段落结构:`[footnoteRef(mark rPr)][空格(plain)][法名(bold rPr)][条号+原文(plain rPr)]`。条号与原文之间用**全角空格**(如「第七十一条 证据应当…」)。
### 2. 目标 docx 已支持脚注则零配置
情况反映是从 v9 编辑来的,本就带 `word/footnotes.xml`、rels 里有 footnotes 关系、`[Content_Types].xml``footnotes+xml`、styles.xml 有 `affb` 样式——直接覆盖 footnotes.xml + 在 document.xml 插引用标即可,**不用补 rels/CT/style**。动手前先 grep 确认这四样齐全;若是从无脚注的 docx 起步,才需要补全四处。
### 3. 插入引用标的位置铁律 + 多 run 锚点陷阱(本次踩坑)
脚注上标要紧贴**法条号正后方**(如「第七十一条¹之规定」「第五十一条第二款³所定」),不要落在句末或下游词上。统一体例:四个脚注全部「条号后挂注」最整齐。
**陷阱**`第五十一条第二款` 这种锚点在 docx XML 里常**跨多个 w:r**(编号、款号被拆在不同 run)。若按"找到 anchor 所在 run、在该 run 后插引用"的粗定位,会把上标插到 anchor **下游某个 run 后**——本次 ³ 错插到了「承办部门」后(隔了好几个词)。OnlyOffice 渲染出来才发现,vision 核对抓到的。
**正解:字符流 + split run 精确定位**
```python
# 1) 拼接段落所有 w:t 成 full,建 map: full每个字符 -> (t_element, idx_in_t)
# 2) end = full.find(anchor) + len(anchor) - 1 # 锚点最后一个字符
# 3) t_end, k_end = map[end];把 t_end 文本 split:head=s[:k_end+1], tail=s[k_end+1:]
# 4) t_end.text=head;在 t_end 所在 run 之后 addnext 一个新 run(脚注引用);
# 若 tail 非空,再 addnext 一个同 rPr 的 run 承载 tail
```
这样上标精确落在锚点最后一字之后,不受 run 边界影响。容错:`第五十一条第二款` 找不到时退化找 `第五十一条`
### 4. 款数存疑时,脚注放全条原文
Doro 引「第五十一条**第二款**」,但权威原文里"普通程序不少于十五日"实际在**第一款**。**不擅改他的款数**——脚注内容放该条**全文(含两款)**,无论款数对错,原文都完整覆盖、不断章;款数是否要改回原文里报给 Doro 定,不自己动。
---
## 二、母版克隆建新诉讼文书(保证与同案既有文书同源)
新建一份配套文书(情况反映 vs 已有的监督申请书),要让字体/字号/页边距/样式与同案既有文书**完全同源**——直接拿那份已交付的 docx 当母版。
- **段落模板克隆**:从母版 body 抓代表性段落各一份 deepcopy 当模板——title(居中 bold sz30)、body(首行缩进 fl480 sz24)、recip(机关名顶格 bold sz24)、sign(右对齐 sz24)、date(右对齐)、attt(附件标题顶格 bold)、att(附件项 fl480)。`mk(模板, 文本, bold, no_indent)`:克隆模板→清空其 run/ins/del→(按需删 numPr/ind)→`force_font`(eastAsia=仿宋, ascii/hAnsi=Times)→写新 run。
- **清空原 body 段落**,把新段落 insert 到 `sectPr` 之前(保页边距/分节设置不变)。
- **关 trackRevisions**:新建文书是全新成稿、非修订态,settings.xml 删 `<w:trackRevisions/>`
- **页眉错配必须清**(本次踩坑):母版(监督申请书)的 `header1.xml` 带"申请监督民事诉讼案号/受理法院"这种**本文书类型专属抬头**,套到情况反映上不对路。处理:清空 header 所有 run 的文字。
- **页眉横线 = pBdr,单清文字不够**:清了页眉文字后 OnlyOffice 仍渲出一条横线——来自页眉段落的 `<w:pBdr>`(段落下边框)。遍历 header 所有 `pPr``pBdr`(本例 2 段),并去掉可能带边框的 `pStyle` 引用。styles.xml 里的 Header 样式若也挂 pBdr 一并清。重渲确认顶部纯白到标题。
---
## 三、字体规范化:源文档丢了显式 eastAsia 字体
**症状**:用户编辑过的 docx,正文中文 run 的 `rFonts` **没有 eastAsia 属性**(eastAsia=None),docDefaults 也没设。OnlyOffice 靠底层回退仍渲成仿宋,但**显式字体属性缺失**不符合"中文必须显式仿宋"的交付标准。本次 Doro 改的情况反映 1283 个中文字符全是 eastAsia=None。
**判断边界(重要)**:先比对**用户原版**——若原版本就是 eastAsia=None(不是你的编辑引入的),补齐属于**格式规范化(不改字形、不改一个文字、不动他的内容编辑)**,与历史上的字体规范化同类,可做。若是你的操作把字体搞丢的,那是 bug 要修源头。
**修法**:遍历所有含文字的 run,`rFonts``eastAsia=仿宋`,缺 ascii/hAnsi 则补 Times New Roman;再给 `docDefaults/rPrDefault/rPr/rFonts` 补 eastAsia=仿宋 兜底。改完 OnlyOffice **重渲**确认无字形回退(字体改动必重渲,vision 核"全文仿宋、无方框、无回退乱码")。核验:zipfile 统计 CJK→仿宋、LATIN→Times New Roman 计数全覆盖。
---
## 验收三件套(脚注版)
1. **正文脚注引用数** == 预期(`sum(r.find(footnoteReference) for r in runs)`)。
2. **footnotes.xml 内容数** == 引用数,逐条 print 前 50 字核法名+条号+原文。
3. **OnlyOffice x2t 渲染逐页 vision 核**:每个上标在**正确法条号正后方**(重点查多 run 锚点那条没错位)、页脚脚注区原文完整无截断、脚注字号 < 正文、法名加粗、无乱码。脚注主要落在前两页,逐页都要看。
## 一句话
法条脚注:格式克隆同族文书的 footnotes.xml(separator+内容模板,mark/bold/plain 三 rPr),引用标用 split-run 精确插在条号后(多 run 锚点必踩坑),款数存疑放全条原文不擅改。建新文书:克隆母版段落模板保同源,清错配页眉+pBdr 横线。字体:源档丢 eastAsia 时补齐属于规范化(先确认是原档状态不是自己搞丢的),改完必重渲。
@@ -0,0 +1,74 @@
# 诉讼文书的修订态技法(contract-editor 库在合同以外文书上的复用)
ContractEditor 库不止用于合同——审/改诉讼文书(监督申请书、起诉状、答辩状等)同样适用。本文记录 2026-06-23 邹家民事诉讼监督申请书审改中验证过的几招,都用 OnlyOffice x2t(Doro/Maggie 实际引擎)渲染核对过。
## 1. 大段改写用「整块 del+ins」,不用字符级 diff(markup 可读性铁律)
`tracked_replace` 是字符级 diff(CJK 每字一 token + difflib)——**补字/小改**(错别字、补一个"在"字、称谓换词)用它,markup 干净。
但**大段改写**(整句重写、换论证)若用字符级 diff,新旧文本大量字符重合,markup 会交错成一团("未经~~及~~法庭审理""一百二十八条~~切~~国家机关"),Doro 在 OnlyOffice 看修订态根本读不下去。**接受修订后的最终文本虽正确,但修订态不可读 = 不合格交付**(Doro 有格式洁癖,看的就是 markup)。
- **正解**:对整句/整段改写,做「整块删 + 整块插」——`[<w:del>旧整句</w:del>][<w:ins>新整句</w:ins>]`,markup 显示为一条删除线旧句紧跟一条下划线新句,清清楚楚。
- 实现:复制 `tracked_replace` 的定位逻辑,但不跑 difflib,直接 `_mk_del(old_text)` + `_mk_ins(new_text)` 整块插。判据:**新旧文本相似度高、改动跨度大 → 整块;纯增删几个字 → 字符级**。
## 2. 称谓/词替换也要整词块替换,别让共享字符碎裂
把"法**庭**"改"莲都法**院**"时,"法"字共享,字符级 diff 会渲染成"莲都法~~庭~~院"(接受后对,markup 脏)。
- **正解**:整词 `tracked_block_replace("本案法庭向申请人送达", "莲都法院向申请人送达")` → markup 是干净的[删旧短语][插新短语]。
- 同理坑:替换前先分类全文每处目标词——actor 指代(要改)vs 法条/术语原文(如"法庭审理""在法庭上出示"=不能动)。grep 出所有命中,逐个判,别一刀切 replace_all。
## 3. 整段删除(让自动编号重排)——库没有,需自加 `tracked_delete_paragraph`
合并两个自动编号请求项(删一项、后项自动续号)时,要的是**段落级修订删除**:段内每个 run 包进 `<w:del>`,**且段落标记也要标删**——在 `pPr/rPr` 里插一个 `<w:del>`。这样接受修订后整段连段落标记一起消失,自动编号从 一二三四 重排成 一二三。
```python
def tracked_delete_paragraph(self, search_text):
p = self.find_para(search_text)
for r in list(p.findall(qn('r'))):
# 每个run的w:t搬进新建<w:del><w:r><w:delText>
...
ppr = p.find(qn('pPr')) or 新建
rpr = ppr.find(qn('rPr')) or 新建
rpr.insert(0, <w:del author=... date=...>) # 段落标记删除标记
```
缺了"段落标记删除"那一步,接受后会残留一个空的编号项。
## 4. 半角括号→全角:改 numbering.xml 的 lvlText,一次性根治
子标题 `(一)(二)…` 是自动编号时,半角括号来自 `word/numbering.xml``<w:lvlText w:val="(%1)">`。逐段改文档没用(那是渲染出来的)。
- **正解**:遍历 numbering.xml 所有 `<w:lvlText>``val` 里的 `(``(``)``)`,一次改全文所有同源编号。本例 37 处 lvlText 一次改完。
- 注意只动含 `()` 的 lvlText,`、`分隔的(如请求"一、二、三"用 `%1、`)不受影响。
## 5. 引号"统一为仿宋全角"——根因是引号 run 的字体不是中文字体
现象:正文中文是仿宋(继承样式),但弯引号 `“”`(U+201C/U+201D) 的 run `ascii=Times New Roman, eastAsia=None`。因为弯引号是**中西文模糊字符**,OnlyOffice 对没有 eastAsia 设定的字符按 ascii 字体渲染 → 引号显示成西文 Times 的粗重样式,和仿宋正文不协调。
- **正解(彻底版)**:对**纯引号/中文 run**,把 rFonts 的 `ascii/eastAsia/hAnsi/cs` 全设为「仿宋」+ `hint="eastAsia"`,消除歧义。对**引号+数字混排 run**(如 `“2026…`),按字符**拆 run**:引号段走仿宋、数字段保留 Times New Roman。
- 只设 eastAsia 不够稳——某些渲染下仍可能按 ascii 走 Times。纯引号 run 连 ascii 一起设仿宋最保险(数字 run 才需要保留 Times)。
- 核对:OnlyOffice x2t 渲染后裁剪含引号区域,确认引号纤细、与仿宋协调(不是又粗又重的衬线引号)。
## 6. 验证三件套(同合同终审,文书一样适用)
- `ed.validate()` 返回空。**注意**:诉讼文书的「请求项」原文常是加粗的(与合同正文不加粗体例不同),validate 的"不应加粗"规则会**误报**——先读原文该段普通 run 的 `<w:b>` 状态,若原文请求项本就加粗、INS 继承同样加粗=格式一致=误报,可放行。
- 逐个 `w:ins` 核 author 正确(诉讼文书署当前文书归属人,如本例 Doro 文书上署"小Maggie"修订;合同历史署"WB"——按文书归属定)、eastAsia 字体不缺。
- OnlyOffice x2t 渲两版:**修订态**(看 markup 干净)+ **接受态**(删 w:del、解包 w:ins、删段落标记被删的空段后重渲,看编号连续、全角括号生效、无乱码)。接受态自己生成:解包所有 ins、删所有 del、删 numPr 空段。
## 一句话
合同库的修订能力对所有 docx 文书通用;诉讼文书审改的差异点是:①大改写要整块 del+ins 保 markup 可读 ②引号/括号这类「字体/编号源」问题改 styles/numbering 层不改文档层 ③validate 加粗规则对加粗请求项会误报。
## 7. 接受所有修订 → 干净版 docx(反向操作,2026-06-24 徐函任务验证)
用户给一份**带修订痕迹+批注**的 docx,要「先接受现有修订、让我看干净版本」时——不是用 Word 手点"接受全部",用 zipfile+lxml 一次处理:
```python
W='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def w(t): return f'{{{W}}}'+t
# 1) w:del → 整个元素删掉(连 delText 一起没)
for d in root.findall('.//'+w('del')): d.getparent().remove(d)
# 2) w:ins → 解包:用其子元素替换它本身(保留插入内容,去掉ins包裹)
for ins in root.findall('.//'+w('ins')):
parent=ins.getparent(); idx=list(parent).index(ins)
for child in reversed(list(ins)): parent.insert(idx, child)
parent.remove(ins)
# 3) 属性变更追踪一并清:pPrChange/rPrChange/sectPrChange/tblPrChange/tcPrChange/trPrChange
for tag in ('pPrChange','rPrChange','sectPrChange','tblPrChange','tcPrChange','trPrChange'):
for el in root.findall('.//'+w(tag)): el.getparent().remove(el)
# 4) 批注三处一起拆(用户要"干净版"= 连批注也清):
# document.xml: 删 commentRangeStart/End,删含 commentReference 的整个 run
# settings.xml: 删 <w:trackRevisions/>(让文件退出跟踪模式)
# 打包时跳过 word/comments*.xml,并从 [Content_Types].xml 和 document.xml.rels 删 comments 的 Override/Relationship
```
要点:
- **w:del 删整块、w:ins 解包**——方向别搞反(del 是要丢弃的,ins 是要保留的)。
- **务必清 settings.xml 的 trackRevisions**,否则文件仍处于"跟踪修订"模式,用户继续编辑会又开始记修订。
- **批注要三处协同删**(comments.xml 本体 + document.xml 的 range/reference 锚点 + Content_Types/rels 注册),漏一处 OnlyOffice/Word 打开可能报损坏。
- 验证:解包后 `root.findall('.//w:ins')`/`w:del`/`w:commentReference` 全为 0;`'word/comments.xml' in zip.namelist()` 为 False;python-docx 能打开;OnlyOffice x2t 渲染核对无修订痕迹无批注无错位。
- vision 核干净版时顺带抓**残留内部标记**:黄色高亮(内部校对标记)、留白占位(编号"第 号"、日期" 日")、标题英文双连字符`--`应为中文破折号`——`——这些不是修订痕迹但属"未清的内部审核稿"特征,正式交付前要清。
@@ -0,0 +1,126 @@
# lxml XML Declaration Fix for docx Files
## Problem (2026-07-01, 劳务派遣协议案)
When lxml serializes XML (via `etree.tostring()` or python-docx's `Document.save()`), it outputs:
- **Single-quote** XML declaration: `<?xml version='1.0' encoding='UTF-8' standalone='yes'?>`
- **LF** line endings (`\n`)
Original docx files (created by Word/WPS/OnlyOffice) use:
- **Double-quote** XML declaration: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
- **CRLF** line endings (`\r\n`)
**OnlyOffice cannot open docx files with single-quote XML declarations.** The file appears structurally valid (ZIP ok, XML parses fine, python-docx loads it, even x2t can convert it to PDF), but the OnlyOffice web editor refuses to open it.
## Affected Files
Only XML files that were **re-serialized by lxml** are affected. In a typical ContractEditor workflow:
- `word/document.xml` — always re-serialized (main editing target)
- `word/settings.xml` — re-serialized if trackRevisions was added/modified
Other XML files (styles.xml, fontTable.xml, theme1.xml, etc.) that were read and written back unchanged via `zipfile` retain their original format.
## Diagnosis
```python
import zipfile
def check_docx_xml_format(docx_path):
"""Check if any XML files have problematic single-quote declarations."""
issues = []
with zipfile.ZipFile(docx_path) as z:
for name in z.namelist():
if name.endswith('.xml') or name.endswith('.rels'):
data = z.read(name).decode('utf-8')
first_line = data.split('\n')[0]
has_single_quotes = "version='1.0'" in first_line
has_lf_only = '\r\n' not in data[:200]
if has_single_quotes or has_lf_only:
issues.append((name, has_single_quotes, has_lf_only))
return issues
```
## Fix Script
```python
import zipfile
import re
import os
import tempfile
def fix_xml_declarations(docx_path, output_path=None):
"""
Fix lxml-serialized XML files inside a docx:
1. Single quotes -> double quotes in XML declaration
2. LF -> CRLF line endings (only if file has no CRLF)
If output_path is None, fixes in-place (via temp file + rename).
"""
if output_path is None:
output_path = docx_path
tmp_fd, tmp_path = tempfile.mkstemp(suffix='.docx')
os.close(tmp_fd)
try:
with zipfile.ZipFile(docx_path, 'r') as zin:
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename.endswith('.xml') or item.filename.endswith('.rels'):
text = data.decode('utf-8')
# Fix 1: Single quotes -> double quotes in XML declaration
text = re.sub(
r"<\?xml version='1\.0' encoding='UTF-8' standalone='yes'\?>",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
text
)
# Fix 2: LF -> CRLF (only if no CRLF present)
if '\r\n' not in text and '\n' in text:
text = text.replace('\n', '\r\n')
data = text.encode('utf-8')
zout.writestr(item, data)
os.replace(tmp_path, output_path)
except:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
# Usage after ContractEditor.save() or manual zipfile write:
# fix_xml_declarations('/tmp/【修】contract.docx')
```
## Integration Points
### After ContractEditor.save()
```python
ed = ContractEditor(src)
# ... edits ...
ed.save(output_path)
fix_xml_declarations(output_path) # Must run after every save
```
### After manual zipfile+lxml write
```python
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
# ... write files ...
pass
fix_xml_declarations(output_path) # Must run after ZIP is closed
```
## Key Insight
- `x2t` (OnlyOffice converter CLI) tolerates single-quote declarations — it can convert the "broken" file to PDF successfully
- The **OnlyOffice web editor** (WOPI-based document editing) does NOT tolerate single-quote declarations
- `python-docx Document()` opens the file fine (lxml parses both formats)
- Standard validation tools (zipfile.testzip(), etree.fromstring()) all pass
This makes the issue hard to diagnose — everything looks valid except OnlyOffice refuses to open it. The **only reliable test** is checking the raw bytes of the XML declaration in the ZIP.
@@ -0,0 +1,66 @@
# A类手动编号顺延 — 段落级 DEL/INS 模式
实战来源:CT维保合同-香花桥(2026-06-26)。新增"9. 第三方侵权"条款后,需将原9→10、10→11、11→12、12→13、13→14 顺延。所有条款编号均为手动文本(A类,run内w:t文字,无numPr)。
## 核心模式
对每个需顺延的段落,找到包含旧编号的 run,用**段落级** DEL/INS 替换:
```python
for old_num, new_num in renumber_map.items():
for r in p.findall(f'{{{W}}}r'):
t = r.find(f'{{{W}}}t')
if t is None or t.text is None: continue
if t.text.strip().startswith(str(old_num)):
# 1. DEL run: 旧编号
del_run = deepcopy(r)
del_run.set(f'{{{W}}}rsidDel', rsid)
del_t = del_run.find(f'{{{W}}}t')
del_t.tag = f'{{{W}}}delText'
del_t.text = str(old_num)
del_w = etree.Element(f'{{{W}}}del')
del_w.set(f'{{{W}}}id', str(nid)); nid += 1
del_w.set(f'{{{W}}}author', 'WB')
del_w.set(f'{{{W}}}date', rev_date)
del_w.append(del_run)
# 2. INS run: 新编号
ins_run = deepcopy(r)
ins_run.set(f'{{{W}}}rsidR', rsid)
ins_t = ins_run.find(f'{{{W}}}t')
ins_t.text = str(new_num)
ins_w = etree.Element(f'{{{W}}}ins')
ins_w.set(f'{{{W}}}id', str(nid)); nid += 1
ins_w.set(f'{{{W}}}author', 'WB')
ins_w.set(f'{{{W}}}date', rev_date)
ins_w.append(ins_run)
# 3. 原 run 去掉编号前缀
t.text = t.text[len(str(old_num)):]
# 4. DEL + INS 插入在原 run 之前
r.addprevious(ins_w)
r.addprevious(del_w)
break
break # 每个段落只改一个编号
```
## 关键点
1. **DEL/INS 在段落级**`w:p` 的直接子元素),不是 run 内
2. **从后往前处理**:如果用索引遍历,从后往前避免 offset 漂移
3. **只匹配run开头**`t.text.strip().startswith(str(old_num))` 确保只匹配编号前缀
4. **原 run 保留剩余文本**`t.text = t.text[len(str(old_num)):]` 去掉编号后保留标题文字
5. **ID 递增**:每个 DEL/INS 用独立 id,从 `max_id + 1`
## 与 add_clause 的区别
- `add_clause` / `add_clause_before`:创建**全新段落**(整段 w:ins)
- 本模式:修改**已有段落**的第一个 run 的编号,其余内容不动
## 适用场景
- 新增条款后,后续**手动编号**(A类)的条款需要顺延
- 不适用于自动编号(B类)——自动编号由 number.xml 引擎处理,修改 run 内文字无效
@@ -0,0 +1,60 @@
# 手动合同审查:具体修改规则(非角色约束)
> Doro 2026-07-02 明确:"我需要你遵守的是具体修改规则,不是角色。"
> 这些规则不因"手动操作"还是"workflow执行"而有任何区别。
## 十条硬规则
1. **修订精准到字,不整段 del+ins**
- 改一个字只标记一个字的 del+ins
- 不允许为了方便把整句/整段删掉重写
2. **INS run 字体/字号与原文同段落一致**
- 每个 INS run 的 rPr(sz/bold/rFonts)必须与同段落其他非INS run一致
- 签署页特别注意:"甲方:""乙方:"标签和名称可能原文字号不同,INS必须匹配标签字号
3. **格式、大小与原文保持一致**
- 段落缩进(firstLine)、行距(spacing)、段落样式(pStyle)全部与原文同级段落一致
- 新增条款标题必须继承原文条款标题的样式
4. **编号顺延要通读全文确认**
- 插入新条款后,后续条款编号必须顺延
- 必须通读全文确认编号链连续无跳号
5. **不擅自填写合同空白内容**
- 空白的商业条款(金额、期限、数量、质量标准等)不动
- 空白 = 留给签约双方自行填写,不是让审查人补充
6. **不做独立法律判断**
- 不在审查中做"这个条款合不合法"的独立判断
- 只按reviewer的issue清单执行修改
7. **不站自己的立场改客户的商业安排**
- 客户已经做出的商业决策不否定
- 例:客户选择"反委托代发工资",不能改成"乙方直接发"
- 只能在客户选择的框架内加保护条款
8. **批注只写修改方案,不写理由**
- ❌ "建议修改为……,因为……"
- ✅ "建议修改为……"
- 不加【新增】【修改】等标签前缀
9. **金额是商业条款不动**
- 无论金额看起来是否"合理",绝对不改
- 金额矛盾也只批注提示,不做修改
10. **原文批注/修订不动**
- 其他人(华诚-Z、法务、屠佳青等)的修订和批注保留原样
- 不删除、不修改、不合并他人的批注
- 除非Doro明确指示合并(如"华诚-Z的修订人改为WB")
## 核心原则
**遵守的是规则本身,不是"我现在扮演什么角色"。** 不管是workflow的editor角色执行、还是Doro直接让我手动改合同,这十条规则完全一样,不打折扣。
## 反面教材(2026-07-01)
- 反委托代发工资协议:站自己立场否定客户的反委托安排(版本1直接取消反委托)→ 违反第7条
- 填写空白的"质量保证期___个月" → 违反第5条
- 批注写理由 → 违反第8条
- 劳务派遣协议整段del+ins → 违反第1条
@@ -0,0 +1,125 @@
# Merge Layered Revisions with Priority (Accept Inner Author's Edits)
## Scenario (2026-07-03 模特合作协议案)
File has two layers of tracked changes:
- **Layer 1 (WB)**: Original review modifications
- **Layer 2 (华诚-Z)**: User edited on top of WB's tracked changes
Result: 华诚-Z's `w:del` elements are **nested inside** WB's `w:ins` elements — meaning 华诚-Z deleted portions of what WB had inserted.
User instruction: "以华诚-Z为准" (prioritize 华诚-Z), then unify all author names to WB.
## Three-Step Algorithm
### Step 1: Accept nested deletions (inner author wins)
Find all `w:del[author=华诚-Z]` nested inside `w:ins[author=WB]` and remove them (= accept the deletion):
```python
def accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB'):
for ins_elem in body.findall(f'.//{W}ins'):
if ins_elem.get(f'{W}author') != outer_author:
continue
for del_elem in ins_elem.findall(f'.//{W}del'):
if del_elem.get(f'{W}author') == inner_author:
parent = del_elem.getparent()
parent.remove(del_elem)
```
### Step 2: Remove empty outer elements
After accepting nested deletions, some WB ins elements may be empty (all their content was deleted by 华诚-Z):
```python
def remove_empty_ins(body):
for ins_elem in body.findall(f'.//{W}ins'):
has_text = False
for t in ins_elem.findall(f'.//{W}t'):
if t.text and t.text.strip():
has_text = True
break
if not has_text:
parent = ins_elem.getparent()
if parent is not None:
parent.remove(ins_elem)
```
### Step 3: Unify author names
```python
def rename_author(body, old_author, new_author):
count = 0
for elem in body.iter():
author = elem.get(f'{W}author')
if author == old_author:
elem.set(f'{W}author', new_author)
count += 1
return count
```
## Complete Flow
```python
from docx import Document
from lxml import etree
doc = Document('input.docx')
body = doc.element.body
# Step 1: Accept 华诚-Z deletions of WB content
accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB')
# Step 2: Clean up empty WB ins elements
remove_empty_ins(body)
# Step 3: Rename 华诚-Z → WB
rename_author(body, '华诚-Z', 'WB')
doc.save('output.docx')
```
## After Merge: Additional Modifications
After merging, you can continue adding new WB tracked changes on the unified file (e.g., reverting specific clauses to template wording). Use standard tracked change creation:
```python
def make_del(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
d = etree.Element(f'{W}del')
d.set(f'{W}id', str(abs(hash(text)) % 100000))
d.set(f'{W}author', author)
d.set(f'{W}date', date)
r = etree.SubElement(d, f'{W}r')
if rPr is not None:
r.append(deepcopy(rPr))
dt = etree.SubElement(r, f'{W}delText')
dt.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
dt.text = text
return d
def make_ins(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
ins = etree.Element(f'{W}ins')
ins.set(f'{W}id', str(abs(hash(text + 'ins')) % 100000))
ins.set(f'{W}author', author)
ins.set(f'{W}date', date)
r = etree.SubElement(ins, f'{W}r')
if rPr is not None:
r.append(deepcopy(rPr))
t = etree.SubElement(r, f'{W}t')
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
t.text = text
return ins
```
## Verification
After merge:
- `set(elem.get(W+'author') for elem in body.iter() if elem.get(W+'author'))` should return `{'WB'}` only
- Count ins/del elements to confirm reasonable numbers
- Verify key clauses read correctly in "accepted" view
## Key Distinction from `unify-author-wb.py`
The `scripts/unify-author-wb.py` script **only renames authors** — it does NOT handle nested deletions. If 华诚-Z has `w:del` inside WB's `w:ins`, just running unify will rename the del to WB but **leave the deleted content still marked as deleted inside the insertion** — creating a confusing state where WB appears to both insert and delete the same text.
**Always run the three-step algorithm** when inner author has modified outer author's tracked changes.
@@ -0,0 +1,118 @@
# Mixed Inherited/Explicit Font Size Fix (Document-Wide)
## Problem (2026-07-01 生育友好宣传阵地建设协议)
Source document has **mixed font sizing** in body text:
- Some runs have explicit `sz=24` (12pt) — e.g., section headings, specific clauses
- Other runs have **no explicit sz** — inherit from Normal style (`sz=21` / 10.5pt)
- WB INS runs mostly got `sz=24` correctly, but the mix of explicit + inherited in **original** runs creates visual inconsistency
Doro complaint: "文字大小不一致,修改" — the rendered result shows mixed sizes.
## Root Cause
- `docDefaults` / Normal style = 10.5pt (sz=21)
- Many body runs (P12+) have explicit sz=24 (from original author or conversion)
- ~72 original runs have NO explicit sz → inherit 10.5pt → render smaller
- OnlyOffice renders the mix faithfully → visible inconsistency
## Diagnosis
```python
from docx import Document
from collections import Counter
doc = Document('file.docx')
print(f'Normal style sz: {doc.styles["Normal"].font.size}') # If 133350 EMU = 10.5pt
sizes = Counter()
for p in doc.paragraphs[BODY_START:BODY_END]:
for run in p.runs:
if run.text.strip():
sizes[run.font.size.pt if run.font.size else 'inherited'] += 1
# If both 'inherited' and explicit size (e.g. 12.0) appear → mixed problem
print(sizes.most_common())
```
## Fix Pattern (Full Body Range)
Unlike the INS-only sweep, this fix targets ALL runs in the body text range:
```python
import zipfile, re
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
# 1. Identify body range (skip title/preamble and signature)
BODY_START = 12 # First body content paragraph index
BODY_END = 56 # Last body paragraph (exclusive)
TARGET_SZ = '24' # From explicit runs in body (majority value)
# 2. Fix ALL runs in body range
for pidx in range(BODY_START, min(BODY_END, len(paras))):
p = paras[pidx]
# Plain runs
for r in p.findall(f'{WNS}r'):
t_elem = r.find(f'{WNS}t')
if t_elem is None or not (t_elem.text or '').strip():
continue
rpr = r.find(f'{WNS}rPr')
if rpr is None:
rpr = etree.SubElement(r, f'{WNS}rPr')
r.insert(0, rpr)
sz = rpr.find(f'{WNS}sz')
if sz is None:
sz = etree.SubElement(rpr, f'{WNS}sz')
sz.set(f'{WNS}val', TARGET_SZ)
szCs = rpr.find(f'{WNS}szCs')
if szCs is None:
szCs = etree.SubElement(rpr, f'{WNS}szCs')
szCs.set(f'{WNS}val', TARGET_SZ)
# INS runs
for ins in p.findall(f'{WNS}ins'):
for r in ins.findall(f'{WNS}r'):
# same logic as above
...
# DEL runs (for visual consistency in markup view)
for d in p.findall(f'{WNS}del'):
for r in d.findall(f'{WNS}r'):
# same logic
...
```
## Key Distinctions from INS-Only Fix
| Aspect | INS-only sweep | Full body range fix |
|--------|---------------|---------------------|
| Scope | Only WB INS runs | ALL runs (plain + INS + DEL) |
| Trigger | INS runs missing sz | Doro reports "文字大小不一致" |
| Root cause | add_clause/tracked_replace gaps | Source document mixed inheritance |
| Target sz | From neighboring runs | From majority explicit sz in body |
## When to Apply
- Doro says "文字大小不一致" on a delivered file
- `wb-ins-font-verify.py` passes (INS runs OK) but rendered output still shows mixed sizes
- Diagnostic shows body runs split between `inherited` and explicit sz
## Important: Don't Change Preamble/Signature
- Title/header (e.g., P0-P2): larger sz by design (22pt/sz=44) — don't touch
- Party info (P3-P10): may use different sz — don't touch unless in body range
- Signature area (P56+): often sz=21 (10.5pt) — don't touch
- Only fix the **body text range** where sz should be uniform
## Relationship to 格式保留铁律
This fix does NOT violate "格式保留铁律" (don't change original formatting) because:
- The original document's **intent** is uniform 12pt body text (evidenced by majority explicit sz=24)
- The missing sz is a **formatting omission** (author forgot to set explicit sz on some runs)
- The fix makes the document render as the original author intended
- This is different from "changing 仿宋_GB2312 to 仿宋" (that changes the actual format choice)
BUT: if the original document intentionally uses different sizes in body (e.g., smaller text for notes, larger for headings), don't blindly unify. Check the pattern first.
@@ -0,0 +1,204 @@
# 修改已有tracked changes的作者和文本内容
## 场景
- 合并用户在OnlyOffice中的修订(author如"华诚-Z"改为"WB")
- 修改INS元素中的文本内容(如更新法律措辞)
- 修改批注作者(comments.xml中的w:comment author属性)
## 技术实现
### 修改tracked change作者
```python
from lxml import etree
import zipfile
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
# 打开docx,修改document.xml
z_in = zipfile.ZipFile('input.docx', 'r')
z_out = zipfile.ZipFile('output.docx', 'w')
# 复制非document.xml的文件
for item in z_in.namelist():
if item != 'word/document.xml':
z_out.writestr(item, z_in.read(item))
# 修改tracked change作者
with z_in.open('word/document.xml') as f:
tree = etree.parse(f)
root = tree.getroot()
for elem in root.iter():
tag = etree.QName(elem.tag).localname
if tag in ('ins', 'del'):
old_author = elem.get(f'{WNS}author', '')
if old_author == '旧作者名':
elem.set(f'{WNS}author', 'WB')
z_out.writestr('word/document.xml', etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))
z_in.close()
z_out.close()
```
### 修改INS文本内容
```python
from copy import deepcopy
from datetime import datetime
now = datetime.now().isoformat()
# 定位特定段落中的INS元素
body = root.find(f'{WNS}body')
paras = body.findall(f'{WNS}p')
target_para = paras[12] # 按索引定位
# 删除旧的INS元素(按作者筛选)
for ins in list(target_para.findall(f'{WNS}ins')):
author = ins.get(f'{WNS}author', '')
if author == '目标作者':
target_para.remove(ins)
# 添加新的INS元素
new_ins = etree.SubElement(target_para, f'{WNS}ins')
new_ins.set(f'{WNS}author', 'WB')
new_ins.set(f'{WNS}date', now)
new_r = etree.SubElement(new_ins, f'{WNS}r')
# 从同段落的原文run复制格式
orig_runs = target_para.findall(f'{WNS}r')
if orig_runs:
orig_rpr = orig_runs[0].find(f'{WNS}rPr')
if orig_rpr is not None:
new_r.append(deepcopy(orig_rpr))
new_t = etree.SubElement(new_r, f'{WNS}t')
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
new_t.text = '新的插入文本'
```
### 修改批注作者
```python
# 修改comments.xml中的作者
if 'word/comments.xml' in z_in.namelist():
with z_in.open('word/comments.xml') as f:
ctree = etree.parse(f)
croot = ctree.getroot()
for c in croot.findall(f'{WNS}comment'):
if c.get(f'{WNS}author', '') == '旧作者名':
c.set(f'{WNS}author', 'WB')
z_out.writestr('word/comments.xml', etree.tostring(ctree, encoding='UTF-8', xml_declaration=True, standalone=True))
```
### 在已有WB INS元素内修改部分文本(2026-07-01 反委托代发工资协议)
当段落文本全部是WB INS(无普通w:r),需要替换其中某一句时,**不能删除整个INS重建**(会丢失该INS中其他文本的修订标记)。正确手法:**trim原INS的w:t + addnext插入DEL/INS**。
```python
old_sentence = "退回派遣员工由乙方依法自行安置处理,与甲方无涉。"
new_sentence = "派遣员工退回后由乙方依法负责安置处理。因乙方安置不当导致甲方被追究责任的,乙方应赔偿甲方因此遭受的全部损失。"
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'ins' and child.get(f'{WNS}author') == 'WB':
for r in child.findall(f'{WNS}r'):
for t in r.findall(f'{WNS}t'):
if t.text and old_sentence in t.text:
rpr_copy = copy.deepcopy(r.find(f'{WNS}rPr')) if r.find(f'{WNS}rPr') is not None else None
# 1. Trim原INS文本(去掉被替换的句子)
t.text = t.text.replace(old_sentence, "")
# 2. 创建DEL
del_elem = etree.Element(f'{WNS}del')
del_elem.set(f'{WNS}id', next_id())
del_elem.set(f'{WNS}author', 'WB')
del_elem.set(f'{WNS}date', rev_date)
del_r = etree.SubElement(del_elem, f'{WNS}r')
if rpr_copy: del_r.insert(0, copy.deepcopy(rpr_copy))
del_r.set(f'{WNS}rsidDel', rsid)
del_t = etree.SubElement(del_r, f'{WNS}delText')
del_t.set(XML_SPACE, 'preserve')
del_t.text = old_sentence
# 3. 创建INS
ins_elem = etree.Element(f'{WNS}ins')
ins_elem.set(f'{WNS}id', next_id())
ins_elem.set(f'{WNS}author', 'WB')
ins_elem.set(f'{WNS}date', rev_date)
ins_r = etree.SubElement(ins_elem, f'{WNS}r')
if rpr_copy: ins_r.insert(0, copy.deepcopy(rpr_copy))
ins_r.set(f'{WNS}rsidR', rsid)
ins_t = etree.SubElement(ins_r, f'{WNS}t')
ins_t.set(XML_SPACE, 'preserve')
ins_t.text = new_sentence
# 4. 插入到原INS之后(addnext保证顺序)
child.addnext(ins_elem) # 后插的在后面
child.addnext(del_elem) # 后插的在前面 → 最终: [原INS] [DEL] [INS]
```
**关键点**
- `addnext` 两次:先插INS再插DEL,后插的排前面,最终顺序:`[原INS(trimmed)] [DEL旧句] [INS新句]`
- 绝不能 `p.remove(child)` 再重建——会丢失INS中其他未改动的文本
- rPr必须从原INS的run深拷贝,不要从全文body_rpr取(字号可能不同)
### 给全INS段落补充条款编号(2026-07-01)
段落所有文本都是WB INS时,编号INS插到pPr之后:
```python
ins_num = etree.Element(f'{WNS}ins')
ins_num.set(f'{WNS}id', next_id()); ins_num.set(f'{WNS}author', 'WB'); ins_num.set(f'{WNS}date', rev_date)
ins_r = etree.SubElement(ins_num, f'{WNS}r')
ins_r.insert(0, copy.deepcopy(existing_rpr)) # 从同段落INS run深拷贝
ins_r.set(f'{WNS}rsidR', rsid)
ins_t = etree.SubElement(ins_r, f'{WNS}t')
ins_t.set(XML_SPACE, 'preserve'); ins_t.text = "第X条 "
ppr = p.find(f'{WNS}pPr')
if ppr is not None: ppr.addnext(ins_num)
else: p.insert(0, ins_num)
```
### 新INS元素eastAsia字体显式补齐
原文WB INS run可能**没有显式eastAsia属性**(靠docDefaults回退),但新INS run**必须显式设置eastAsia=宋体**,否则修订上下文中可能丢失回退。post-save sweep:
```python
for p in body.findall(f'{WNS}p'):
for ins in p.findall(f'{WNS}ins'):
for r in ins.findall(f'{WNS}r'):
text = ''.join(t.text for t in r.findall(f'{WNS}t') if t.text)
if not any('\u4e00' <= c <= '\u9fff' for c in text): continue
rpr = r.find(f'{WNS}rPr')
if rpr is None:
rpr = etree.Element(f'{WNS}rPr'); r.insert(0, rpr)
rf = rpr.find(f'{WNS}rFonts')
if rf is None: rf = etree.SubElement(rpr, f'{WNS}rFonts')
if not rf.get(f'{WNS}eastAsia'): rf.set(f'{WNS}eastAsia', '宋体')
if not rf.get(f'{WNS}ascii'): rf.set(f'{WNS}ascii', '宋体')
```
## 验证方法
```python
# 验证所有作者已更改
content = z_out.read('word/document.xml').decode('utf-8', 'ignore')
authors = set(re.findall(r'w:author="([^"]+)"', content))
assert '旧作者名' not in authors, f"仍有旧作者: {authors}"
# 验证批注作者
if 'word/comments.xml' in z_out.namelist():
with z_out.open('word/comments.xml') as f:
ctree = etree.parse(f)
for c in ctree.getroot().findall(f'{WNS}comment'):
assert c.get(f'{WNS}author') != '旧作者名'
```
## ⚠️ 铁律
1. **修改前必须备份原文件**:覆盖含第三方修订的文件 = 不可逆丢失
2. **只改作者名,不改文本**:除非明确要求修改INS内容
3. **zipfile不能原地读写**:必须先读后写临时文件,再用os.replace
4. **保留comments.xml中的批注锚点**:只改author属性,不改id/content/anchor
## 实证(2026-07-01 反委托代发工资协议)
华诚-Z在OnlyOffice中做了3处修订(第六条去法条引用、第七条简化纠正流程、第八条加退回员工安置)。后续制作版本时覆盖了所有中间文件,导致华诚-Z修订痕迹丢失。最终通过系统化文件扫描在/tmp/v1_doro_updated.docx中找到仍含华诚-Z作者的文件,提取修订内容后在最终版本中恢复。
@@ -0,0 +1,93 @@
# Multi-Version Contract Comparison Table (三版对比表)
## When to Use
When Maggie/Doro asks to compare multiple versions of a contract (typically: template / counterparty revision / our revision), produce a structured docx comparison table.
## Pattern (2026-07-03 模特合作协议 session)
### Document Setup
- **Landscape orientation** for 4-5 columns: `section.orientation = 1; page_width=Cm(29.7); page_height=Cm(21.0)`
- Narrow margins: 1.2-1.5cm all sides
- Font size 8.5-9pt for table cells (fits more content)
### Table Structure
| 条款 | 【模版】 | 版本A(对方修订) | 版本B(我方修订) | 双方协商一致 |
|------|---------|------------------|-----------------|-------------|
### Red Font for Differences
- Column N is red when its content differs from other versions
- Use `RGBColor(0xFF, 0x00, 0x00)` on the run
- "协商一致" column: red = current text doesn't match consensus → needs modification
### Yellow Background for Consensus Column
```python
def set_cell_shading(cell, color):
tc = cell._element
tcPr = tc.find(qn('w:tcPr'))
if tcPr is None:
tcPr = OxmlElement('w:tcPr')
tc.insert(0, tcPr)
shading = OxmlElement('w:shd')
shading.set(qn('w:fill'), color) # e.g. 'FFF8E1' for light yellow
shading.set(qn('w:val'), 'clear')
tcPr.append(shading)
```
### Header Row Styling
- Blue background (`D9E2F3`)
- Bold, centered, font size 8.5pt
### Data Structure in Code
```python
# Each row: (clause_name, col1_text, col2_text, col3_text, col4_text, col2_red, col3_red, col4_red)
rows = [
('条款名',
'模版内容',
'对方修订内容',
'我方修订内容',
'协商一致内容',
True, # col2 red? (differs from others)
False, # col3 red?
True), # col4 red? (doesn't match consensus)
]
```
### Legend at Bottom
Include a legend explaining what red means in each column:
- 版本A列红色 = 与模版/我方版不一致(对方的修改)
- 版本B列红色 = 与模版不一致(我方的修改)
- 协商一致列红色 = 当前文本与协商一致不符,需要修改
## Key Lessons
1. **Read all three files from Nextcloud** using `sudo find ~/nextcloud/data/data/...` path
2. **Extract paragraph text** using python-docx: `[(i, p.text.strip()) for i, p in enumerate(doc.paragraphs) if p.text.strip()]`
3. **Check tables** separately: `doc.tables` — contracts often have signature blocks and SNS account tables
4. **Align comparison by clause semantics**, not paragraph index — different versions may have different paragraph counts
5. Also upload to Nextcloud for viewing in OnlyOffice
## Per-Run Precision for Tracked Changes (Maggie's correction)
When applying tracked changes based on comparison results, **never replace entire paragraphs**. Instead:
1. Identify the specific runs containing text to change
2. For each run: create w:del wrapping a deepcopy (converting w:t → w:delText), create w:ins with new text and cloned rPr, swap in place
3. All surrounding runs remain untouched
```python
# Find specific run by text content
for r in para_element.findall(qn('w:r')):
text = ''.join(t.text or '' for t in r.findall(qn('w:t')))
if text == '¥700,000': # exact match on this run
r_parent = r.getparent()
r_idx = list(r_parent).index(r)
# Create del wrapping copy of this run
del_elem = make_del_run_from_existing(r)
# Create ins with new value, same rPr
ins_elem = make_ins_run('¥600,000', r.find(qn('w:rPr')))
r_parent.remove(r)
r_parent.insert(r_idx, ins_elem)
r_parent.insert(r_idx, del_elem)
break
```
This produces clean tracked changes where Word/OnlyOffice shows exactly which characters changed (e.g., ~~700,000~~ → 600,000) rather than entire-paragraph replacements.
@@ -0,0 +1,232 @@
# Multi-Version Creation Pattern
## When to Use
When creating multiple versions of the same contract (e.g., 版本1法定安排 vs 版本2反委托保护) or when needing to redo a version from scratch.
## Critical Rule
**ALWAYS start from the original source file for each version. Never modify a previously modified version.**
## Step-by-Step Pattern
### 1. Preserve Original Source
```python
# First time: copy original to safe location
shutil.copy('/path/to/original.docx', '/tmp/original_backup.docx')
```
### 2. For Each Version, Start Fresh
```python
# Always reload from original
with zipfile.ZipFile('/tmp/original_backup.docx', 'r') as zin:
all_data = {n: zin.read(n) for n in zin.namelist()}
doc_xml = all_data['word/document.xml']
root = etree.fromstring(doc_xml)
body = root.find(f'{W}body')
paras = body.findall(f'{W}p')
# Get font template from original
rpr_template = None
for p in paras:
for r in p.findall(f'{W}r'):
t = r.find(f'{W}t')
if t is not None and t.text and t.text.strip():
rpr_elem = r.find(f'{W}rPr')
if rpr_elem is not None:
rpr_template = copy.deepcopy(rpr_elem)
break
if rpr_template:
break
```
### 3. Apply All Modifications in One Pass
```python
rev_id = 1000 # Start fresh revision ID counter
# Batch all replacements
replacements = [
(0, "old text", "new text"),
(5, "old text", "new text"),
# ... more replacements
]
for idx, old_text, new_text in replacements:
p = paras[idx]
# Clear runs (but NOT comment anchors!)
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r': # Only remove regular runs
p.remove(child)
d, i = make_tracked_replace(old_text, new_text, rpr_template, rev_id)
rev_id += 2
p.append(d)
p.append(i)
# Insert new clauses
insert_after = paras[10]
for clause_text in new_clauses:
new_p = make_ins_paragraph(clause_text, rpr_template, rev_id)
rev_id += 1
insert_after.addnext(new_p)
insert_after = new_p
# Mark deletions (e.g., 承诺书)
for idx in range(21, 30):
p = paras[idx]
text_parts = []
for r in p.findall(f'{W}r'):
t = r.find(f'{W}t')
if t is not None and t.text:
text_parts.append(t.text)
full_text = ''.join(text_parts)
if not full_text.strip():
continue
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
p.remove(child)
del_elem = make_tracked_delete(full_text, rpr_template, rev_id)
rev_id += 1
p.append(del_elem)
# Add comments LAST (after all structural changes)
# Comment anchors are fragile - add them at the end
```
### 4. Add Comments Carefully
```python
# Check if paragraph already has comment anchors
existing = p.find(f'{W}commentRangeStart')
if existing is None:
# Add new comment anchors
comment_start = etree.Element(f'{W}commentRangeStart')
comment_start.set(f'{W}id', str(comment_id))
p.insert(0, comment_start)
comment_end = etree.Element(f'{W}commentRangeEnd')
comment_end.set(f'{W}id', str(comment_id))
p.append(comment_end)
comment_ref_run = etree.SubElement(p, f'{W}r')
comment_ref = etree.SubElement(comment_ref_run, f'{W}commentReference')
comment_ref.set(f'{W}id', str(comment_id))
# Update comments.xml
if 'word/comments.xml' in all_data:
croot = etree.fromstring(all_data['word/comments.xml'])
else:
croot = etree.Element(f'{W}comments', nsmap={'w': W_NS})
# Add or update comment
new_comment = etree.SubElement(croot, f'{W}comment')
new_comment.set(f'{W}id', str(comment_id))
new_comment.set(f'{W}author', author)
new_comment.set(f'{W}date', datetime.now().isoformat())
p = etree.SubElement(new_comment, f'{W}p')
r = etree.SubElement(p, f'{W}r')
t = etree.SubElement(r, f'{W}t')
t.set(XML_SPACE, 'preserve')
t.text = comment_text
```
### 5. Save and Verify
```python
all_data['word/document.xml'] = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
all_data['word/comments.xml'] = etree.tostring(croot, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile('/tmp/version1.docx', 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in all_data.items():
zout.writestr(name, data)
# Verify immediately
doc = Document('/tmp/version1.docx')
print(f"OK: {len(doc.paragraphs)} paragraphs")
```
## Common Pitfalls
### ❌ Don't Do This
```python
# WRONG: Modifying v1 to create v2
shutil.copy('/tmp/v1.docx', '/tmp/v2.docx')
with zipfile.ZipFile('/tmp/v2.docx', 'r') as zin:
# ... load v1's modified structure
# This will have v1's tracked changes, comments, etc.
```
### ❌ Don't Clear Everything When Modifying
```python
# WRONG: Clears comment anchors too!
for child in list(p):
if child.tag not in (f'{W}pPr',):
p.remove(child) # Removes commentRangeStart/End!
```
### ✅ Do This Instead
```python
# RIGHT: Only clear regular runs, preserve comment anchors
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r': # Only regular runs
p.remove(child)
# commentRangeStart, commentRangeEnd are preserved
```
## Preserving Original Comments
When the original document has comments (e.g., Alice, 法务, 杜律), the workflow must:
1. Read original comments.xml to get all comment IDs and content
2. Check which paragraphs have comment anchors (commentRangeStart/End)
3. When clearing runs, preserve comment anchors (they're not `w:r` elements)
4. Add new comments with NEW IDs (don't reuse original IDs)
5. Original comments remain unchanged in comments.xml
## Preserving Third-Party Tracked Changes (2026-07-01 华诚-Z案)
When a contract file contains tracked changes from someone other than WB (e.g., 华诚-Z, Crystall, or any third-party reviewer), **those files must never be overwritten**. The tracked changes represent real editorial work that cannot be reconstructed from session notes alone.
### Backup Protocol
```python
import shutil
from datetime import datetime
# BEFORE any modification to a file with third-party tracked changes:
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f'/tmp/{filename}.bak_{ts}'
shutil.copy(source_path, backup_path)
print(f"Backed up to {backup_path}")
```
### Detection: Does This File Have Third-Party Changes?
```python
import zipfile, re
with zipfile.ZipFile(filepath) as z:
content = z.read('word/document.xml').decode('utf-8', errors='ignore')
authors = set(re.findall(r'w:author="([^"]+)"', content))
third_party = authors - {'WB'}
if third_party:
print(f"⚠️ Third-party authors found: {third_party} — BACKUP REQUIRED")
```
### Multi-Version with Third-Party Edits
When creating v1 and v2 from a file that has both original content AND third-party edits:
1. **Backup the file with third-party edits** (e.g., `华诚-Z版.bak_20260701`)
2. **Backup the pristine original** (no tracked changes at all)
3. **For each version**: start from the pristine original, then layer on:
- WB's own tracked changes
- Third-party's tracked changes (with author renamed to WB)
4. **Never modify the backup files** — they are your insurance
### What Was Lost (华诚-Z案)
- 华诚-Z made 3 tracked changes in OnlyOffice: 第六条 (removed specific legal citations), 第七条 (simplified correction process), 第八条 (added employee return placement clause)
- These intermediate files in /tmp/ were overwritten during v1/v2 creation
- Only session notes preserved the *content* of changes, not the actual tracked change markup (ids, timestamps, exact XML positions)
- **Recovery was impossible** — Doro had to accept reconstructed versions
## Real Example from This Session
- Original: 4 comments (Alice×2, 法务, 杜律)
- Version 1: 5 comments (original 4 + WB legal risk)
- Version 2: 5 comments (original 4 + WB legal risk)
Both versions created independently from original, each with their own WB comment (different content for each version).
@@ -0,0 +1,34 @@
# 新增条款pPr完整克隆铁律(2026-07-13 盈浦健康科普合同教训)
## 问题
新增条款(如"八、转包与分包"的正文P75)插入后,只考虑了numPr是否正确,但遗漏了其他pPr子元素(如`ind`首行缩进)。导致新增段落与原文同类段落格式不一致。
## 教训链(同一份合同被Doro纠正3次)
1. **第一次**:P75挂了错误的numId=11(不可抗力的序列)→ 渲染为"3."
2. **第二次**:去掉numId后,加了新的numId=14 → 单段正文不该有编号("有2才有1"规则延伸到numPr)
3. **第三次**:去掉numId后仍缺首行缩进ind=420 → 与原文"一、合作背景"(同为单段无编号正文)格式不一致
## 铁律:新增段落pPr必须完整比对原文参照段
动手前必须:
1. **找到原文中的参照段落**——格式相同的段落(同层级、同类型)
2. **逐子元素列出参照段的pPr**:spacing、ind、numPr、jc、每一个子元素
3. **逐一对比新增段落的pPr**:缺什么补什么,多什么删什么
4. 不能只看一个属性(如numPr)就认为"格式正确了"
## "有2才有1"规则在numPr上的延伸
原规则:新增条款如果下一级只有一条内容,不加子编号。
延伸到numPr:如果某章节下只有一个正文段落,且原文中同类单段正文没有numPr(如"一、合作背景"的P12无numPr),则新增的单段正文也不加numPr。
判断方法:看原文中有没有"章节标题+单段正文+无numPr"的先例。有→新增单段也不加。
## 检查清单(操作后必过)
- [ ] 新增段落的spacing与参照段一致
- [ ] 新增段落的ind与参照段一致(特别是firstLine/firstLineChars)
- [ ] 新增段落的numPr:单段→不加(参照原文同类);多段→加入对应序列
- [ ] 新增段落INS run的rPr与参照段原文run的rPr一致(无多余sz、无缺失属性)
@@ -0,0 +1,147 @@
# Strip numPr When Adding Manual Numbering to Auto-Numbered Paragraphs
## Problem (2026-07-01 反委托代发工资协议)
Original contract paragraphs have `<w:numPr>` with actual auto-numbering (e.g., `numId=3 → abstractNum decimal "%1." start=1`). When you insert manual "第X条" numbering as `w:ins` at paragraph start, OnlyOffice renders BOTH:
```
1. 第一条 乙方应严格按照... ← "1." is auto-numbering, "第一条" is your INS
```
This looks broken — two different numbering systems stacked.
## Root Cause
The paragraph's `pPr/numPr` tells the rendering engine to prepend an automatic decimal number. Your INS adds a second, manual number. They coexist independently.
Additionally, if the paragraph has `pPr/pPrChange` (tracking the old paragraph formatting), the old `numPr` inside `pPrChange` can ALSO render in markup view.
## Fix (two-step)
After inserting manual numbering INS elements, strip auto-numbering from ALL affected paragraphs:
```python
for idx in target_paragraph_indices:
p = paras[idx]
ppr = p.find(f'{WNS}pPr')
if ppr is not None:
# Step 1: Remove direct numPr
num_pr = ppr.find(f'{WNS}numPr')
if num_pr is not None:
ppr.remove(num_pr)
# Step 2: Remove numPr inside pPrChange (old formatting record)
ppr_change = ppr.find(f'{WNS}pPrChange')
if ppr_change is not None:
inner_ppr = ppr_change.find(f'{WNS}pPr')
if inner_ppr is not None:
inner_num = inner_ppr.find(f'{WNS}numPr')
if inner_num is not None:
inner_ppr.remove(inner_num)
```
## When This Applies
- You're converting a contract from auto-numbered clauses to manual "第X条" heading-style numbering
- The original .doc/.docx used Word's list numbering for clause structure
- You're adding "第一条 " etc. as INS at paragraph start
## Verification
After fix:
1. `pdftotext -layout` of OnlyOffice render should show NO stray "1." / "2." / "3." before your "第X条"
2. Accept-revisions preview should also be clean (no residual auto-numbers)
## Scenario B: Auto-Numbering Resets Across Tracked-Deleted Paragraphs (2026-07-01 反委托代发工资协议)
### Problem
When paragraphs with `numPr` auto-numbering are interspersed with **entirely deleted paragraphs** (all content in `w:del`), OnlyOffice's auto-number counter **resets to 1** after the deleted block. This makes continuous numbering impossible with `numPr` alone.
Example structure:
```
P6: numPr=1 INS content (clause 1) → renders "1."
P7: numPr=1 continuation → renders "2." (wrong if P7 shouldn't be numbered)
P8: numPr=1 ALL w:del → renders "3." with strikethrough
P9: numPr=1 ALL w:del → renders "4." with strikethrough
P10: numPr=1 INS content (clause 2) → renders "1." ← RESETS! Should be "2."
```
The auto-numbering engine counts visible (non-deleted) items in the `numId` sequence, but deleted paragraphs **break the continuity** in OnlyOffice's rendering.
### Solution: Convert to Manual Text Numbering
Strip `numPr` from ALL paragraphs and insert "N. " as `w:ins` text at paragraph start. This gives identical visual output ("1. 2. 3. ...") without depending on the broken auto-number counter.
```python
# Step 1: Strip ALL numPr (including inside pPrChange)
for i, p in enumerate(paragraphs):
pPr = p.find(f'{{{W}}}pPr')
if pPr is not None:
numPr = pPr.find(f'{{{W}}}numPr')
if numPr is not None:
pPr.remove(numPr)
for pPrChange in pPr.findall(f'{{{W}}}pPrChange'):
old_pPr = pPrChange.find(f'{{{W}}}pPr')
if old_pPr is not None:
old_numPr = old_pPr.find(f'{{{W}}}numPr')
if old_numPr is not None:
old_pPr.remove(old_numPr)
# Step 2: Insert "N. " as w:ins text for each clause paragraph
# Only number paragraphs that have VISIBLE content (not entirely w:del)
clause_map = {6: 1, 10: 2, 11: 3, ...} # para_index: clause_number
for para_idx, clause_num in clause_map.items():
p = paragraphs[para_idx]
# Build INS element with "N. " text
ins_elem = ET.Element(f'{{{W}}}ins')
ins_elem.set(f'{{{W}}}id', str(next_rev_id()))
ins_elem.set(f'{{{W}}}author', rev_author) # from existing INS in doc
ins_elem.set(f'{{{W}}}date', rev_date)
r_elem = ET.SubElement(ins_elem, f'{{{W}}}r')
# Clone rPr from existing runs for font consistency
rPr = get_run_rPr_from_paragraph(p)
if rPr is not None:
r_elem.append(copy.deepcopy(rPr))
t_elem = ET.SubElement(r_elem, f'{{{W}}}t')
t_elem.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
t_elem.text = f"{clause_num}. "
# Insert after pPr
pPr = p.find(f'{{{W}}}pPr')
if pPr is not None:
p.insert(list(p).index(pPr) + 1, ins_elem)
else:
p.insert(0, ins_elem)
```
### When to Use This (vs Scenario A)
- **Scenario A** (above): You're CHANGING the numbering scheme (auto "1." → manual "第一条")
- **Scenario B** (this): You're KEEPING the same format ("1. 2. 3.") but converting from auto to manual because auto-numbering resets across w:del paragraphs
- **Trigger**: Original uses numPr auto-numbering + your edits create entirely-deleted paragraphs between numbered items → auto counter resets → switch to manual text
### Key Decision: Which Paragraphs to Number
Only number paragraphs that will be visible after accepting revisions:
- Paragraphs with ONLY `w:del` content → skip (they're deleted)
- Paragraphs that are continuations of the previous clause (no independent number) → skip
- New INS-only paragraphs (new clauses) → number them
- Rewritten paragraphs (mixed INS+DEL, first clause in sequence) → number them
### Verification
After conversion:
1. OnlyOffice render (x2t → PDF) should show continuous "1. 2. 3. ... 11." without resets
2. No stray auto-numbers from numPr remnants
3. Deleted paragraphs (entirely w:del) should NOT show any number
## Distinction from Existing Rules
- Rule 5 (A類 vs B類) talks about NEW clauses inheriting/stripping numPr
- Scenario A is EXISTING paragraphs where you're REPLACING their numbering scheme with a different format via INS
- Scenario B is EXISTING paragraphs where auto-numbering BREAKS due to tracked-deleted paragraphs, requiring conversion to same-format manual text
- numId=0 trap (Rule 5 sub-note) is about fake auto-numbering; BOTH scenarios here are about REAL auto-numbering that renders visible numbers
@@ -0,0 +1,54 @@
# 一页纸 docx 排版压缩配方
适用场景:创建必须严格一页的 Word 文档(合作框架、报价单、一页摘要等),通过 OnlyOffice x2t 渲染验证。
## 迭代压缩流程
x2t 渲染的行高/间距比 python-docx 估算的略宽松,不能靠"调好参数直接交付"。必须走渲染验证循环。
### 第一轮:合理起点
| 参数 | 值 |
|---|---|
| 上下边距 | 1.5–2.0 cm |
| 左右边距 | 1.8–2.0 cm |
| 正文字号 | 9–10 pt |
| 表格字号 | 8–9 pt |
| 行距 | 1.05–1.15 |
### 验证循环
```bash
bash ~/.hermes/skills/legal/contract-editor/scripts/onlyoffice-render.sh <docx> <pdf>
python3 -c "
import subprocess
r=subprocess.run(['pdftotext','<pdf>','-'],capture_output=True,text=True)
pages=r.stdout.split('\f')
print(f'页数: {len(pages)}')
"
```
- 页数=1 → 交付
- 页数=2 且末页空白 → 内容刚好溢出,微调即可
- 页数=2 且有内容 → 需要大幅压缩或精简文字
### 逐级压缩(按优先级)
1. 底部边距:1.5→1.0→0.8→0.5 cm(先砍底部,顶部保阅读感)
2. 表格字号:8→7.5→7 pt
3. 行距:1.05→1.0
4. 左右边距:1.8→1.5 cm
5. 段落间距:Pt(2)→Pt(1)→Pt(0)
6. 精简文字(最后手段)
### 已实证的可用参数(5000字内一页A4)
| 参数 | 值 |
|---|---|
| 上下边距 | 1.2 / 0.5 cm |
| 左右边距 | 1.5 cm |
| 正文字号 | 8 pt |
| 表格字号 | 7 pt |
| 行距 | 1.0 |
| 段落间距 | 0 |
## 陷阱
- x2t 对表格行高估算偏大,表内文字多时尤其明显
- 分隔线(`—`*N)占用空间,一页紧张时去掉
- 表格 `Table Grid` 样式自带内边距,无法通过 python-docx 参数完全消除
- 第二页空白但无文字 = 内容刚好溢出几像素,再砍 0.2cm 底部边距或减 0.5pt 字号即可
@@ -0,0 +1,40 @@
# ContractEditor Operation Ordering: Add Clauses Before Renumbering
## 2026-07-13 练塘硬件购销合同教训
### Problem
When interleaving `add_clause_before()` and `tracked_replace()` for renumbering, lxml throws:
```
ValueError: Element is not a child of this node.
```
in `tracked_replace()``parent.remove(runs[idx])`.
### Root Cause
`add_clause_before()` / `add_clause()` mutate the XML tree (insert new `<w:p>` elements). After insertion, previously-found element references held by later `tracked_replace()` calls may point to nodes whose parent relationship has shifted. The `parent.remove()` call inside `tracked_replace` fails because the run's parent `<w:p>` is no longer the same object the code expects.
### Fix: Two-Phase Approach (铁律)
**Phase 1 — All structural additions:**
- `add_clause()` / `add_clause_before()` for new clauses
- Content-level `tracked_replace()` that don't touch clause titles being renumbered
**Phase 2 — Renumbering (after all additions are done):**
- `tracked_replace('第七条不可抗力', '第九条不可抗力')` etc.
### Example (correct order)
```python
# Phase 1: add new clauses
editor.add_clause_before('第七条 转包与分包\n...', before_search='第七条不可抗力')
editor.add_clause_before('第八条 第三方侵权\n...', before_search='第七条不可抗力')
# Phase 2: renumber old clauses (all additions done)
editor.tracked_replace('第七条不可抗力', '第九条不可抗力')
editor.tracked_replace('第八条争议解决', '第十条争议解决')
```
### WPS/DOC File Handling
WPS `.wps` and `.doc` files must be converted to `.docx` before ContractEditor can process them:
```bash
libreoffice --headless --convert-to docx input.wps --outdir /tmp/contract-review/
```
Then copy to a simple ASCII filename to avoid python-docx path issues with Chinese characters.
@@ -0,0 +1,60 @@
# Paragraph Deletion via Tracked Changes (WB)
When a reviewer instructs you to delete an entire clause/paragraph, use **paragraph-level deletion** — not just deleting the text but marking the entire paragraph as removed in tracked changes.
## Two-Part Deletion
### Part 1: Paragraph Mark Deletion
Add a `w:del` element inside the paragraph's `w:pPr/w:rPr`:
```xml
<w:pPr>
<w:rPr>
<w:del w:id="7777" w:author="WB" w:date="2026-06-26T00:00:00Z"/>
</w:rPr>
</w:pPr>
```
This marks the paragraph marker (¶) as deleted, so the paragraph doesn't leave an empty line.
### Part 2: Content Deletion
Wrap every text run in the paragraph inside `w:del` elements, converting `w:t` to `w:delText`:
```python
for child in list(paragraph):
tag = child.tag.split('}')[-1]
if tag in ('r', 'ins'):
paragraph.remove(child)
del_elem = etree.SubElement(paragraph, f'{{{W}}}del')
del_elem.set(f'{{{W}}}id', '7777')
del_elem.set(f'{{{W}}}author', 'WB')
del_elem.set(f'{{{W}}}date', '2026-06-26T00:00:00Z')
target_runs = child.findall(f'{{{W}}}r') if tag == 'ins' else [child]
for r in target_runs:
t = r.find(f'{{{W}}}t')
if t is not None:
r.remove(t)
dt = etree.SubElement(r, f'{{{W}}}delText')
dt.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
dt.text = t.text
r.set(f'{{{W}}}rsidDel', new_rsid())
del_elem.append(r)
```
### Key Points
- **Same del id** for both the paragraph mark and content dels (e.g., `7777`) — they're part of the same deletion operation
- **Handle existing INS runs**: If the paragraph has runs inside `w:ins` (from previous revisions), extract them and wrap in `w:del` too
- **Leave existing DEL runs untouched** — they're already deleted
- **Copy rPr**: If the original run had `rPr`, copy it into the del run so the strikethrough text renders with correct font/size
- **Use unique ids**: Pick an id that doesn't collide with existing del/ins ids in the document. Check `max(del_ids) + 1000` if unsure
### Verification
After deletion, render with OnlyOffice and check:
1. The deleted paragraph appears with strikethrough in markup view
2. Accepting all revisions removes the paragraph entirely (no empty line)
3. The paragraph mark (¶) is also deleted — no gap between surrounding paragraphs
@@ -0,0 +1,50 @@
# Per-Paragraph Font Matching(2026-07-13 消防设施检测合同教训)
## 问题
同一份合同中,不同段落的原文runs可能有完全不同的字体属性方案:
- P20 runs: `ascii=宋体, hAnsi=宋体, cs=宋体, sz=24, eastAsia=None, hint=None`
- P36/P41 runs: `rPr=None`(完全无字体属性,靠docDefaults/style继承)
如果对所有WB INS runs统一设置一种字体属性,必然导致某些段落mismatch。
## 错误做法
```python
# ❌ 全局统一设置
for ins in all_wb_ins:
rf.set('ascii', '宋体')
rf.set('sz', '24')
```
## 正确做法
```python
# ✅ 按段落匹配原文第一个非INS/非DEL run的rPr
for p in paras:
# 找到同段落的第一个orig run
orig_run = None
for child in p:
if child.tag == f'{WNS}r':
orig_run = child
break
if orig_run is None:
continue # 整段INS,无参照
orig_rpr = orig_run.find(f'{WNS}rPr')
orig_rf = orig_rpr.find(f'{WNS}rFonts') if orig_rpr is not None else None
# INS的rPr应该与orig_run的rPr完全匹配
# 如果orig没有rFonts → INS也不该有
# 如果orig有ascii=宋体但没有eastAsia → INS也是这样
```
## 整段INS段落(无同段原文可比)
- wb-ins-font-verify.py会报"MISSING HINT (无同段原文可比)"
- 这是**已知假阳性**,不算真问题
- 整段INS段落的字体应参照**相邻段落**(前后各2段)的原文run格式
- 如果相邻原文runs有explicit属性(ascii=宋体 sz=24),INS也设
- 如果相邻原文runs无rPr,INS也不设
## 2026-07-13 消防设施检测合同实证
- 第一次修复:全局strip eastAsia/hint → P20报ASCII MISMATCH(原文有ascii=宋体)
- 第二次修复:全局设ascii=宋体 → P36/P41报ASCII MISMATCH(原文无rFonts)
- 正确修复:per-paragraph检查orig_run是否有ascii → 有则INS也设,无则INS也不设
@@ -0,0 +1,46 @@
# 反委托代发工资法律风险
## 核心法律规定
| 法律依据 | 条文 | 效力 |
|----------|------|------|
| 《劳务派遣暂行规定》第8条第(三)项 | 派遣单位应当依法支付被派遣劳动者的劳动报酬 | 强制性规定 |
| 《劳动合同法》第58条 | 派遣单位是用人单位,应履行用人单位义务 | 法律 |
| 《劳动合同法》第92条第2款 | 用工单位给被派遣劳动者造成损害的,派遣单位与用工单位承担连带赔偿责任 | 法律 |
| 《劳务派遣暂行规定》第24条 | 用工单位违法退回的,按劳动合同法第92条第2款执行 | 部门规章 |
| 劳社部发〔2005〕12号第2条 | 工资支付凭证是认定事实劳动关系的首要证据 | 规范性文件 |
## 核心结论
1. **代发工资 = 事实劳动关系首要证据**:用工单位直接向派遣员工发工资,违反《劳务派遣暂行规定》第8条强制性规定
2. **协议不能免除法定责任**:甲乙之间的内部追偿条款不能对抗劳动者和行政机关
3. **退回条款限制**:用工单位只能在法定三种情形下退回(客观情况重大变化/经济性裁员、破产/解散、协议期满)
4. **"与甲方无涉"条款有法律风险**:可能因违反《劳动合同法》第26条第2款(免除法定责任)被认定无效
## 关键判例
- **广东高院(2022)粤民再30号**:汽车公司以咨询公司名义签劳动合同,工资由汽车公司直接发放。认定汽车公司与劳动者存在事实劳动关系。
- **(2019)沪0109民初12453号**:用工单位违法退回导致派遣公司违法解除的,用工单位承担连带赔偿责任。
- **(2022)鲁0322民初834号**:甲公司将工资计算后交乙公司发放,法院认定甲公司存在经济依附性,构成事实劳动关系。
## 审查建议
### 推荐方案(版本1:回归法定安排)
- 删除代发工资条款,由乙方(派遣公司)直接支付工资
- 添加甲方监督权、扣款权、违约金条款
- 添加乙方资质维持、用工管理义务、退回权、保密条款
### 替代方案(版本2:反委托保护)
如甲方坚持代发,最大化保护措施:
1. 鉴于条款定性为"委托代发",明确甲方仅为代理人
2. 三方签署要求(甲方、乙方、派遣员工)
3. 事实劳动关系兜底:乙方十日内赔偿甲方全部损失
4. 履约保证金(金额留空)或银行保函
5. 税务责任限定:甲方仅承担自身原因导致的差额
6. 社保义务对等
7. 劳动关系确认条款
8. 乙方资质维持 + 用工管理义务
### 退回条款措辞
❌ "退回派遣员工由乙方依法自行安置处理,与甲方无涉"(有法律风险)
✅ "派遣员工退回后由乙方依法负责安置处理。因乙方安置不当导致甲方被追究责任的,乙方应赔偿甲方因此遭受的全部损失。"
@@ -0,0 +1,120 @@
# 审查意见文档字体强制设置
## 背景(2026-07-02 肃言+恭兴合同返工)
review-rules.md 规定审查意见文档:中文统一仿宋体,英文Times New Roman。
**问题**:模板文件(`朱家角 审查意见【模板】.docx`)的表头行有显式eastAsia=仿宋,但新建的数据行字体设置不一致:
- 恭兴审查意见:editor给数据行设了 ascii=仿宋 hAnsi=仿宋(错:英文也变仿宋了)
- 肃言审查意见:editor压根没给数据行设ascii/hAnsi(只有eastAsia=仿宋)
**根因**:LLM每次独立session生成代码,字体设置逻辑不稳定。
## 强制修复代码(生成审查意见后必跑)
```python
from docx import Document
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def enforce_review_opinion_fonts(doc_path, save=True):
"""审查意见文档生成后强制设置所有run的字体。
中文=仿宋, 英文=Times New Roman
"""
doc = Document(doc_path)
fixed = 0
# Fix all table cells
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for p in cell.paragraphs:
for run in p.runs:
fixed += _fix_run_font(run._element)
# Fix all paragraphs outside tables
for p in doc.paragraphs:
for run in p.runs:
fixed += _fix_run_font(run._element)
if save:
doc.save(doc_path)
return fixed
def _fix_run_font(run_element):
"""Ensure run has eastAsia=仿宋, ascii/hAnsi=Times New Roman"""
rPr = run_element.find(qn('w:rPr'))
if rPr is None:
rPr = OxmlElement('w:rPr')
run_element.insert(0, rPr)
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.insert(0, rFonts)
changed = False
# eastAsia must be 仿宋
if rFonts.get(qn('w:eastAsia')) != '仿宋':
rFonts.set(qn('w:eastAsia'), '仿宋')
changed = True
# ascii must be Times New Roman (NOT 仿宋)
if rFonts.get(qn('w:ascii')) != 'Times New Roman':
rFonts.set(qn('w:ascii'), 'Times New Roman')
changed = True
# hAnsi must be Times New Roman (NOT 仿宋)
if rFonts.get(qn('w:hAnsi')) != 'Times New Roman':
rFonts.set(qn('w:hAnsi'), 'Times New Roman')
changed = True
return 1 if changed else 0
```
## 验证方法
```python
def verify_review_opinion_fonts(doc_path):
"""验证审查意见文档字体全部正确"""
from docx import Document
from docx.oxml.ns import qn
doc = Document(doc_path)
errors = []
for table in doc.tables:
for i, row in enumerate(table.rows):
for j, cell in enumerate(row.cells):
for p in cell.paragraphs:
for run in p.runs:
rpr = run._element.find(qn('w:rPr'))
if rpr is None:
errors.append(f'Row{i}Col{j}: no rPr')
continue
rf = rpr.find(qn('w:rFonts'))
if rf is None:
errors.append(f'Row{i}Col{j}: no rFonts')
continue
ea = rf.get(qn('w:eastAsia'))
ascii_f = rf.get(qn('w:ascii'))
hAnsi = rf.get(qn('w:hAnsi'))
if ea != '仿宋':
errors.append(f'Row{i}Col{j}: eastAsia={ea} (should be 仿宋)')
if ascii_f != 'Times New Roman':
errors.append(f'Row{i}Col{j}: ascii={ascii_f} (should be TNR)')
if hAnsi != 'Times New Roman':
errors.append(f'Row{i}Col{j}: hAnsi={hAnsi} (should be TNR)')
return errors
```
## 常见错误
| 错误 | 后果 | 根因 |
|------|------|------|
| ascii/hAnsi=仿宋 | 英文/数字渲染用仿宋(无西文字形,显示异常) | LLM把"统一仿宋"理解为所有属性都设仿宋 |
| 数据行无ascii/hAnsi | 英文回退系统默认字体(可能是宋体/黑体) | 只设了eastAsia,忘设西文字体 |
| 只有表头有字体 | 数据行全部回退默认 | 模板限制:只有表头行有显式字体 |
## 预防方案
最佳方案:修改模板文件的**Normal样式或Table Grid样式**定义,预设完整字体。但模板可能被多场景共用,最稳妥还是生成后强制设置。
@@ -0,0 +1,40 @@
# 审查意见文档格式检查清单 (2026-07-13 Doro纠正)
## 生成后必做三项格式修复
### 1. 删除表格中的空白行
- "空白行"指**表格中**三列全为空的row(模板占位行)
- 不是文档段落的空行
- 代码:遍历table rows,检查所有cells文本为空的row,删除
### 2. 页眉日期改为修订当日
- 读取 header*.xml,找到日期文本(如"2019/3")
- ⚠️ 日期经常被拆成多个run(如"201"+"9"+"/"+"3")
- 需逐run处理:拼出完整日期字符串 → 替换为当日(如"2026/7")
- 格式:YYYY/M(不补零)
### 3. 标题必须用合同正文全称
- 读合同docx正文P0(或前几段)获取合同标题全称
- 填入审查意见文档的《》内
- ❌ 不能用文件名(文件名可能是简称、带前缀、有(1)后缀)
- ✅ 必须是合同正文中出现的完整合同名称
## 内容规则(不变)
- 只写原文和修订后内容,不做理由说明
- 不写(注:...)
- 行顺序按条款号排列
- 有修改意见时删除"无法律修改意见。"段落
- 批注内容也要体现在表格中
## 字体硬规则
| 位置 | eastAsia | ascii | hAnsi | sz | bold | hint |
|------|----------|-------|-------|-----|------|------|
| 标题 | 仿宋 | - | - | 32(16pt) | True | eastAsia |
| 表头 | 仿宋 | TNR | TNR | 24(12pt) | True | eastAsia |
| 数据行 | 仿宋 | TNR | TNR | 24(12pt) | False | eastAsia |
| 签名 | 仿宋 | TNR | TNR | 24(12pt) | False | eastAsia |
## 同模板合同审查意见一致性
- 共有修订行内容完全一致
- 行顺序统一(按条款号)
- 个案差异行按各合同实际情况(如金额批注)
@@ -0,0 +1,44 @@
# 审查意见文档格式规则 (2026-07-13 Doro纠正)
## 规则来源
朱家角 review-rules.md "审查意见格式要求" 章节 (2026-07-13 更新)
## 规则内容
### 1. 删除表格中的空白行
- "空白行"指的是审查意见表格中**三列全空**的行(条文/原文/修订后都没有内容)
- 不是正文段落的空白行——正文段落空行是排版问题,表格空行才是Doro说的"删除空白行"
- 2026-07-13教训:Doro说"删除空白行",小Maggie误解为删正文段落空行,被纠正后才看到是表格Row1-Row4全空
### 2. 页眉日期改为修订当日
- 审查意见模板页眉中有日期(如 header2.xml 中 "2019/3")
- 生成审查意见时必须更新为**修订当日**的年/月(如"2026/7")
- 注意:页眉中的日期可能拆分在多个run中(如"201"+"9"+"/"+"3"),需逐run定位修改
### 3. 标题必须用合同正文全称
- 规则:"标题《》内填写所审查的合同名称"
- "合同名称" = 合同正文第一段的标题(如"医疗设备器械购销合同"),**不是文件名**
- 文件名可能是简写(如"医疗合同(2).doc"),但审查意见标题必须写全称
- 2026-07-13教训:文件名"医疗合同(2)",合同正文标题是"医疗设备器械购销合同",审查意见标题应为"关于《医疗设备器械购销合同》的审查意见"
## Workflow重复处理检测(2026-07-13 香花桥安全生产合同教训)
### 问题
待审查目录的文件可能**已含WB tracked changes**(上一轮workflow产出被放回了待审查)。workflow不做去重检测,会在已有修订上再跑一遍,导致:
- 文字重复(如"全部损失全部损失")
- 相同内容被双重标记为INS(冗余修订痕迹)
### 检测方法
修复/审查前**第一步**:检查待审查文件是否已有author=WB的tracked changes
```python
for ins in body.iter(f'{WNS}ins'):
if ins.get(f'{WNS}author') == 'WB':
# 文件已被处理过!
```
### 正确做法
如果待审查文件已有WB修订:
1. **以待审查版为基底**(它的第一轮修订是正确的)
2. 只在此基础上补充缺失的修订(如名称统一)
3. **不使用任务交付目录的二次处理版本**(它有重复)
4. 排查是否是auto_notify重复触发或手动误操作导致
@@ -0,0 +1,83 @@
# 审查意见文档生成模式(2026-07-02 确立)
## 核心原则
1. **只体现差异,不做理由说明** — 表格三列(条文|原文|修订后)只写文字差异
2. **字体必须显式设置** — 不依赖模板继承,每个run四属性齐全
3. **同模板合同内容必须一致** — 行顺序按条款号,模板级修订表述相同
## 字体规则
```python
def set_cell_font(cell, text, east_asia='仿宋', ascii_font='Times New Roman', h_ansi='Times New Roman', bold=False):
"""Set cell text with proper font - every run must have explicit rFonts"""
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# Clear existing content
for p in cell.paragraphs[1:]:
cell._element.remove(p._element)
p = cell.paragraphs[0]
for r in p._element.findall(qn('w:r')):
p._element.remove(r)
# Set paragraph alignment to justify
pPr = p._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
p._element.insert(0, pPr)
jc = pPr.find(qn('w:jc'))
if jc is None:
jc = OxmlElement('w:jc')
pPr.append(jc)
jc.set(qn('w:val'), 'both')
# Add run with explicit font settings
run = p.add_run(text)
rPr = run._element.find(qn('w:rPr'))
if rPr is None:
rPr = OxmlElement('w:rPr')
run._element.insert(0, rPr)
rFonts = OxmlElement('w:rFonts')
rFonts.set(qn('w:eastAsia'), east_asia)
rFonts.set(qn('w:ascii'), ascii_font)
rFonts.set(qn('w:hAnsi'), h_ansi)
rPr.insert(0, rFonts)
if bold:
b = OxmlElement('w:b')
rPr.append(b)
```
## 内容格式
### ✅ 正确(只体现差异)
| 条文 | 原文 | 修订后 |
|------|------|--------|
| 第1条 | 买方同意向卖方购买,同时卖方同意授予买方以下器械 | 甲方同意向乙方购买,同时乙方同意向甲方出售以下器械 |
| 第7.1.2条 | 按照器械的疵劣程度 | 按照器械的瑕疵程度 |
### ❌ 错误(带理由说明)
| 条文 | 原文 | 修订后 |
|------|------|--------|
| 第1条 | ... | 甲方同意向乙方购买……(注:统一称谓为甲方/乙方,"授予"修改为"出售"以准确反映买卖关系) |
## 同模板合同一致性保证
当同一顾问单位有多份同模板合同时:
1. 先确定模板级修订点列表(所有同模板合同共享的问题)
2. 每份合同的审查意见必须包含**全部**模板级修订点
3. 行顺序统一按条款号排列
4. 个案差异(如金额问题)在统一行之外单独加行
5. 修订后列的文字必须完全一致(逐字对比)
## 验证清单
生成完成后必须验证:
- [ ] 所有数据行的每个run都有eastAsia=仿宋 + ascii/hAnsi=Times New Roman
- [ ] 修订后列无(注:...)、无理由解释
- [ ] 行顺序按条款号排列
- [ ] 同模板合同的审查意见行数一致(除个案差异行外)
- [ ] 标题包含合同全称
@@ -0,0 +1,64 @@
# 审查意见文档生成规则(朱家角模板)
## 2026-07-02 Doro多次纠正后确立
### 模板结构
路径:`~/.hermes/shared/模版库/朱家角 审查意见【模板】.docx`
(新模板参考:`Doro合同审查任务/参考文件/朱家角 审查意见【新模板】.docx`
1. 空行(P0, 居中)
2. 标题:`关于《XX》的审查意见`(居中、仿宋 **16pt**(sz=32) **加粗**
3. `无法律修改意见。`(有审查意见时**删除此行**)
4. `审查意见:`(仿宋 12pt)
5. 表格(条文|原文|修订后)
6. 空行
7. 签名:`邱庭 律师`(右对齐、仿宋 12pt)
### 字体规格(从模板XML实际读取,非推测)
- 标题:eastAsia=仿宋, sz=32(16pt), bold=True, **ascii=None**(模板未设)
- 表头行:eastAsia=仿宋, sz=24(12pt), bold=True
- 数据行:eastAsia=仿宋, ascii=Times New Roman, hAnsi=Times New Roman, sz=24(12pt), bold=False, hint=eastAsia
- 正文段:eastAsia=仿宋, sz=24(12pt)
⚠️ 模板本身只设了`eastAsia=仿宋`没有设`ascii`。按review-rules.md要求英文用TNR,生成时应显式设ascii/hAnsi=Times New Roman。
### 文档格式处理(2026-07-12 Doro要求)
- **删除空白行**:文档中所有无内容的空段落必须删除(模板自带的空行也删)
- **页眉日期改为修订当日**:header*.xml中如有日期(如"2019/3"),改为当日日期(如"2026/7")。注意日期可能被拆分为多个run(如"201"+"9"+"/"+"3"),需逐run处理
- **标题用合同正文中的实际标题**:从合同docx正文提取合同全称(如"医疗设备器械购销合同"),填入《》内。绝不用文件名代替(文件名可能是简称如"医疗合同(2)")
- **标题格式**:`关于《XX合同全称》的审查意见`,确保无多余占位符残留
### 内容规则(2026-07-02 Doro明确)
- **只写原文和修订后的内容(包括批注内容),不做理由说明**
- ❌ 不写 (注:统一称谓…)
- ❌ 不写 (注:原引用法规…)
- ✅ 条文列:第X条 / 第X.X条 / 新增X.X条(主题)
- ✅ 原文列:合同原文
- ✅ 修订后列:修订后文字 / 批注内容(如"请注意确认金额")
- 行顺序按条款号排列
- 新增条款:条文栏写"新增X.X条(主题)",修订后栏直接写条文内容
### 同模板合同的审查意见统一规则
- 格式、字体、行顺序统一
- 共有修订行内容完全相同
- 个案差异行(如金额批注)按各合同实际情况处理
- 没有问题的合同不要强加批注行
### 生成代码要点
```python
# 字体设置(数据行)
def set_run_font(run_elem, east_asia='仿宋', ascii_font='Times New Roman', h_ansi='Times New Roman', sz_val=24):
rFonts.set(qn('w:eastAsia'), east_asia)
rFonts.set(qn('w:ascii'), ascii_font)
rFonts.set(qn('w:hAnsi'), h_ansi)
rFonts.set(qn('w:hint'), 'eastAsia')
sz.set(qn('w:val'), str(sz_val)) # 24 = 12pt
szCs.set(qn('w:val'), str(sz_val))
```
### 常见错误(本session犯过的)
1. 未设sz_val → 回退到默认字号
2. ascii设成仿宋 → 英文也变仿宋
3. 用bytes literal写中文 → unicode转义不解析显示乱码
4. 忘删"无法律修改意见。" → 矛盾
5. 写(注:...)理由 → 规则禁止
@@ -0,0 +1,32 @@
# 同模板合同审查一致性规则
## 2026-07-02 朱家角恭兴+肃言合同教训
### 问题
两份同模板购销合同(结构完全一致,仅乙方名称和设备清单不同),workflow串行审查后修订不一致:
- 恭兴发现了6.4条(药监局法规过时)但遗漏7.3条(侵权兜底)
- 肃言发现了7.3条但遗漏6.4条
### 根因
workflow是逐份串行处理(relay-runner),每份合同完全独立走reviewer→editor→deliverer,各session之间零状态共享。LLM每次独立推理,对同一段文字的优先级判断有随机性。
### 解决办法(已实施)
1. **review-rules.md增加同模板一致性规则**(已做)
2. **reviewer skill增加前置检查**:审查前检查同目录是否有同模板已审查的合同,对齐修订点
3. **手动审查时的铁律**:同批同模板合同必须先审完一份→确认修订点→后续合同按同样标准执行
### 判断"同模板"的方法
- 合同标题完全相同
- 正文条款结构一致(前20行匹配度>80%)
- 甲方相同,乙方不同
- 区别仅在商业条款(金额、设备清单、乙方信息等)
### 审查意见的统一要求
- 共有修订行:内容必须完全一致
- 行顺序:按条款号排列
- 个案差异:只有真实存在的问题才加行(如恭兴金额有误加批注行,肃言金额正确则不加)
- 格式:字体/字号/对齐统一
### 批注的统一原则
"统一"是审查逻辑统一,不是机械复制。金额有问题的合同加批注,没问题的不加——逻辑一致即可。不能给正确的合同强加问题批注。
@@ -0,0 +1,189 @@
# Same-Template Revision Transfer (同模板修订参照)
When Doro says "参照X合同的修订进行修订" — apply the same WB revisions from a reference contract to another contract using the same template.
## ⚠️ Doro 强制验证纪律(2026-07-08 明确要求)
Doro 明确要求做同模板修订参照时必须走完以下步骤,缺一不可:
1. **先确认模板一致性**:逐段对比两份合同原文(去掉修订后的文本),确认段落数一致、差异仅限业务内容(项目名称、单价等),其余结构完全相同。打印差异段数/总段数(如"9/62段有差异")。
2. **参照修订**:提取参照合同的WB修订→适配目标合同业务语境→应用
3. **格式/字体/编号全检**:所有INS的rPr必须与前后邻居run一致(逐个检查sz/rFonts/bold)。遵守workflow规则(author=WB、精准到字、不整段del+ins)
4. **全文阅读审查合理性**:渲染accept后全文,逐段通读确认修订逻辑合理、不破坏上下文语义
5. **交付前检查**:python-docx可打开、无异常字符、修订数与参照合同一致、他人修订保持不动
**不能跳步直接做修订然后上传。** Doro原话:"你先确认:两个合同是不是模板一样,内容一样;如果一样,参照修改;你修改的格式、字体、编号等,都要遵守workflow的规则;全文阅读,修订是否合理。最后检查交付。"
## Workflow
### Step 1: Extract revisions from reference contract
```python
import zipfile
from lxml import etree
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def extract_wb_revisions(filepath):
"""Extract all WB-authored INS and DEL from a contract."""
with zipfile.ZipFile(filepath, 'r') as z:
content = z.read('word/document.xml')
tree = etree.fromstring(content)
revisions = []
for ins in tree.iter(f'{{{ns}}}ins'):
if ins.get(f'{{{ns}}}author') != 'WB':
continue
texts = [t.text for t in ins.iter(f'{{{ns}}}t') if t.text]
# Get parent paragraph for context
parent_p = ins
while parent_p is not None and parent_p.tag != f'{{{ns}}}p':
parent_p = parent_p.getparent()
p_texts = [t.text for t in parent_p.iter(f'{{{ns}}}t') if t.text] if parent_p is not None else []
revisions.append({
'type': 'INS', 'text': ''.join(texts),
'para_context': ''.join(p_texts)[:120]
})
for d in tree.iter(f'{{{ns}}}del'):
if d.get(f'{{{ns}}}author') != 'WB':
continue
texts = [t.text for t in d.iter(f'{{{ns}}}delText') if t.text]
parent_p = d
while parent_p is not None and parent_p.tag != f'{{{ns}}}p':
parent_p = parent_p.getparent()
p_texts = [t.text for t in parent_p.iter(f'{{{ns}}}t') if t.text] if parent_p is not None else []
revisions.append({
'type': 'DEL', 'text': ''.join(texts),
'para_context': ''.join(p_texts)[:120]
})
return revisions
```
### Step 2: Identify modification patterns
Group INS/DEL pairs by paragraph context to understand what was changed:
- Simple text replacement: DEL "协议" + INS "合同" in same paragraph
- Text insertion: INS without corresponding DEL (e.g., data ownership sentence)
- Prefix insertion: INS "上海市" before existing text
### Step 3: Context adaptation
When the template is shared but service content differs, adapt context-specific terms:
- "体检服务" → "口腔检查服务"
- "学生个人信息、健康检查结果" → "个人信息、检查结果"
- Keep legal boilerplate identical (e.g., "归甲方所有", "合同期满")
### Step 4: Apply to target contract (zipfile+lxml)
Use three operations:
#### A. Tracked replace (DEL old + INS new)
```python
def do_tracked_replace(para, find_text, replace_text):
"""Find text in paragraph runs, create DEL + INS."""
# Build character map from runs (skip ins/del elements)
char_map = []
for elem in para:
if elem.tag == f'{{{ns}}}r':
t = elem.find(f'{{{ns}}}t')
if t is not None and t.text:
for ci in range(len(t.text)):
char_map.append((elem, t, ci))
full_text = ''.join(cm[1].text[cm[2]] for cm in char_map)
pos = full_text.find(find_text)
if pos == -1:
return False
# Verify single-run containment, then split into before/DEL/INS/after
# ... (see session code for full implementation)
```
#### B. Insert before text
```python
def do_tracked_insert_before(para, anchor_text, insert_text):
"""Insert INS element right before anchor_text."""
for elem in para:
if elem.tag == f'{{{ns}}}r':
t = elem.find(f'{{{ns}}}t')
if t is not None and t.text and anchor_text in t.text:
# Split run, insert INS before anchor portion
...
```
#### C. Insert after text
```python
def do_tracked_insert_after(para, anchor_text, insert_text):
"""Insert INS element right after anchor_text."""
for elem in para:
if elem.tag == f'{{{ns}}}r':
t = elem.find(f'{{{ns}}}t')
if t is not None and t.text and anchor_text in t.text:
# Split run, insert INS after anchor portion
...
```
## Data Attribution Rule (2026-07-08 Doro clarification)
When writing data ownership/attribution clauses across same-template contracts:
**LOCKED (identical across all contracts)**: 归属表述 = "归甲方或相关权利方所有"
- NOT "归甲方所有" (excludes data subjects' rights under PIPL)
- The phrase "甲方或相关权利方" covers both: data甲方 owns (aggregated stats, service outputs) AND personal info that belongs to data subjects
**NOT LOCKED (varies by contract)**: The descriptive content before the attribution phrase
- 体检合同: "乙方在提供体检服务过程中获取和产生的全部数据(包括但不限于学生个人信息、健康检查结果等)"
- 口腔检查合同: "乙方在提供口腔检查服务过程中获取和产生的全部数据(包括但不限于个人信息、检查结果等)"
- Other contracts: adapt to the specific service/data context
**Doro原话**: "我只需要涉及到数据权利的归属时,把归属谁改成'归甲方或相关权利方所有',其他的内容不同合同会不同,所以你不能写死。"
**Rule source**: `review-rules.md` §4 保密/数据 (updated 2026-07-08)
## Pitfalls
### 1. `<w:proofErr>` splits runs
Text like "青浦区练塘镇" may be split into multiple runs separated by `<w:proofErr>` elements:
```xml
<w:r><w:t>青浦区练塘</w:t></w:r>
<w:proofErr w:type="gramStart"/>
<w:r><w:t>镇社区卫生服务中心</w:t></w:r>
```
**Fix**: Search at individual run level (`for elem in para: if elem.tag == w:r`), not at full paragraph text level. Insert INS before the run containing the anchor, not at a text position within full paragraph text.
### 2. "达成如下协议" — not all "协议" should be replaced
In the reference contract, "达成如下协议:" was NOT changed (it's a formulaic expression meaning "reached the following agreement"). Only contextual uses of "协议" meaning "this agreement/contract" were changed to "合同"/"本合同".
**Rule**: Compare reference contract's accepted text to determine which instances were changed and which were left alone.
### 3. INS rPr must clone from target run (not reference)
The target contract's runs may have different formatting than the reference. Always clone rPr from the **target paragraph's existing run**, not from the reference contract.
### 4. Order of operations matters
Do replacements BEFORE insertions. Insertions change paragraph structure and character positions, which can break subsequent text searches.
Recommended order:
1. All `do_tracked_replace` calls (these only split existing runs)
2. All `do_tracked_insert_after` / `do_tracked_insert_before` calls (these add new elements)
### 5. Verify with accept-all view
After applying all revisions, verify by building accepted text (skip DEL, include INS) for key paragraphs and comparing against the reference contract's accepted text.
## 2026-07-08 实证:练塘口腔检查合同
Reference: 【修】2026年学生体检外包合同--练塘(1).docx
Target: 2026年口腔检查外包合同--练塘.docx
Modifications applied:
| # | Type | Content | Adaptation |
|---|------|---------|------------|
| 1 | INS before "青浦区" | "上海市" | None (identical) |
| 2a | INS after "保密义务。" | Data ownership sentence | "体检服务"→"口腔检查服务", "学生个人信息、健康检查结果"→"个人信息、检查结果" |
| 2b | DEL "协议" + INS "合同" | "协议期满"→"合同期满" | None |
| 2c | DEL "服务协议" + INS "本合同" | "服务协议解除"→"本合同解除" | None |
| 2d | DEL "本协议" + INS "本合同" | "本协议的履行"→"本合同的履行" | None |
| 3 | DEL "本协议" + INS "本合同" | "本协议一式"→"本合同一式" | None |
proofErr pitfall encountered: "青浦区练塘" split by `<w:proofErr>` — had to insert at run level rather than text-position level.
@@ -0,0 +1,32 @@
# 单段正文章节不加numPr(2026-07-12 盈浦健康科普合同)
## 规则
当新增的章节(如"八、转包与分包")下只有**一段**正文时,该段落**不设numPr**。
## 判断方法
1. 看原文中同样只有一段正文的章节是否有numPr
2. 如果原文单段章节无numPr(如"一、合作背景"P12无numPr),新增也不加
3. 多段正文章节有numPr(如"七、不可抗力"两段都有numId=11)
4. 这是"有2才有1"规则在numPr层面的体现
## 实证
盈浦健康科普服务合同:
- 原文"一、合作背景": 1段正文 → 无numPr
- 原文"七、不可抗力": 2段正文 → numId=11
- 新增"八、转包与分包": 1段正文 → 不应有numPr
错误地加了numId=14(新建abstractNum),导致OnlyOffice渲染出孤零零的"1."
## 同时要检查的段落格式
新增段落的pPr必须与原文同类型段落**完整匹配**:
- `w:ind`(firstLine/firstLineChars)—— 首行缩进
- `w:spacing`(line/lineRule)
- 不能只有spacing没有ind
实证:原文正文段有 `ind firstLine=420 firstLineChars=200`,workflow新增P75只有spacing缺ind → 渲染无首行缩进。
## heading run的sz继承陷阱
原文Heading 1样式定义sz=48(24pt),heading段落的plain run**没有显式sz**(靠样式继承)。
workflow/ContractEditor操作后可能给某个run添加spurious `sz=20`(从szCs误取),导致该run从24pt变成10pt。
检查:修订后Heading段落的所有plain run不应有新增的显式sz。
@@ -0,0 +1,175 @@
# 拆分合并的标题+正文段落为两个独立INS段落
## 场景
Reviewer发现新增条款的标题和正文被合并在一个`<w:p>`段落中(通过`<w:t>`内的换行符分隔),要求拆分为两个独立段落——标题段和正文段,各有独立的格式。
## 判别
- 目标段落是一个`<w:p>`,内含一个`<w:ins author="WB">``<w:ins>`内只有一个`<w:r>``<w:t>`文本包含换行符(`\n`)分隔标题和正文
- 标题格式要求:参照原文同级标题段落(如"第四条"或"第六条")
- 正文格式要求:参照原文同层级正文段落(如"一、施工期限")
## 操作步骤
### 1. 读取原文并定位目标段落
```python
import zipfile
from lxml import etree
import copy
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
with zipfile.ZipFile(docx_path, 'r') as zf:
doc_xml = etree.parse(zf.open('word/document.xml'))
all_files = {name: zf.read(name) for name in zf.namelist()}
body = doc_xml.getroot().find(f'{{{W}}}body')
paragraphs = list(body.findall(f'{{{W}}}p'))
# 找到目标段落
for i, p in enumerate(paragraphs):
texts = []
for elem in p.iter(f'{{{W}}}t'):
texts.append(elem.text or '')
full_text = ''.join(texts)
if '第五条' in full_text and '转包' in full_text:
target_idx = i
break
```
### 2. 提取标题和正文文本
```python
full_text = ''
for elem in target_p.iter(f'{{{W}}}t'):
full_text += elem.text or ''
lines = full_text.split('\n')
title_text = lines[0].strip() # "第五条 转包与分包"
body_text = '\n'.join(lines[1:]).strip() # 正文内容
```
### 3. 找到参照段落并克隆pPr
标题段pPr从紧邻的同级标题段落克隆(如"第六条"),正文段pPr从同层级正文段落克隆(如"一、施工期限")。
```python
# 标题参照段落(如"第六条")
ref_title_p = paragraphs[24] # 原文"第六条"的索引
ref_title_pPr = ref_title_p.find(f'{{{W}}}pPr')
title_pPr = copy.deepcopy(ref_title_pPr)
# 正文参照段落(如"一、施工期限")
ref_body_p = paragraphs[13] # 原文"一、施工期限"的索引
ref_body_pPr = ref_body_p.find(f'{{{W}}}pPr')
body_pPr = copy.deepcopy(ref_body_pPr)
```
### 4. 构建标题段落
```python
title_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
title_p.append(title_pPr)
# 标题rPr:黑体四属性 + hint=eastAsia + sz=24 + bold
title_rPr = etree.Element(f'{{{W}}}rPr')
rFonts = etree.SubElement(title_rPr, f'{{{W}}}rFonts')
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
rFonts.set(f'{{{W}}}{attr}', '黑体')
rFonts.set(f'{{{W}}}hint', 'eastAsia')
etree.SubElement(title_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-6')
etree.SubElement(title_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '24')
etree.SubElement(title_rPr, f'{{{W}}}szCs').set(f'{{{W}}}val', '24')
etree.SubElement(title_rPr, f'{{{W}}}b') # 加粗
title_ins = etree.SubElement(title_p, f'{{{W}}}ins')
title_ins.set(f'{{{W}}}id', str(new_ins_id))
title_ins.set(f'{{{W}}}author', 'WB')
title_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')
title_r = etree.SubElement(title_ins, f'{{{W}}}r')
title_r.set(f'{{{W}}}rsidR', '00AA0001')
title_r.append(copy.deepcopy(title_rPr))
title_t = etree.SubElement(title_r, f'{{{W}}}t')
title_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
title_t.text = title_text
```
### 5. 构建正文段落
```python
body_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
body_p.append(body_pPr)
# 正文rPr:宋体四属性 + hint=eastAsia + sz=21
body_rPr = etree.Element(f'{{{W}}}rPr')
body_rFonts = etree.SubElement(body_rPr, f'{{{W}}}rFonts')
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
body_rFonts.set(f'{{{W}}}{attr}', '宋体')
body_rFonts.set(f'{{{W}}}hint', 'eastAsia')
etree.SubElement(body_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-4')
etree.SubElement(body_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '21')
body_ins = etree.SubElement(body_p, f'{{{W}}}ins')
body_ins.set(f'{{{W}}}id', str(new_ins_id + 1))
body_ins.set(f'{{{W}}}author', 'WB')
body_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')
body_r = etree.SubElement(body_ins, f'{{{W}}}r')
body_r.set(f'{{{W}}}rsidR', '00AA0001')
body_r.append(copy.deepcopy(body_rPr))
body_t = etree.SubElement(body_r, f'{{{W}}}t')
body_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
body_t.text = body_text
```
### 6. 插入并删除原段落(⚠️ addprevious顺序陷阱)
**关键**`addprevious`将元素插入到目标元素的**紧邻前一个**位置。要得到 [title, body, old_p] 的顺序,必须:
```python
old_p = paragraphs[target_idx]
old_p.addprevious(body_p) # 先插入body → 顺序: body, old_p
body_p.addprevious(title_p) # 再在body前插入title → 顺序: title, body, old_p
body.remove(old_p) # 删除原段落 → 顺序: title, body, ...
```
**错误做法**(会导致顺序反转):
```python
# ❌ 错误:title在body之后
old_p.addprevious(title_p) # title, old_p
old_p.addprevious(body_p) # title, body, old_p ← 看起来对但实际是 body, title, old_p
```
原理:`addprevious`始终插入到目标元素的紧邻前一个位置。`old_p.addprevious(body_p)` 后 body_p 是 old_p 的前一个兄弟;`old_p.addprevious(title_p)` 后 title_p 成为 old_p 的前一个兄弟,body_p 被推到 title_p 之前。
### 7. 保存
```python
new_doc_xml = etree.tostring(doc_xml.getroot(), xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(docx_path, 'w', zipfile.ZIP_DEFLATED) as zf_out:
for name, data in all_files.items():
if name == 'word/document.xml':
zf_out.writestr(name, new_doc_xml)
else:
zf_out.writestr(name, data)
```
## 验证
1. **段落顺序**:确认 [title_idx] 是标题文本,[title_idx+1] 是正文文本
2. **字体属性**:标题 rPr 含 rFonts四属性(黑体) + hint=eastAsia + sz=24 + bold;正文 rPr 含 rFonts四属性(宋体) + hint=eastAsia + sz=21
3. **INS属性**:author=WB, 有 rsidR, 有唯一id
4. **OnlyOffice渲染**:x2t渲染为PDF,pdftotext确认标题和正文各占一行,正文有缩进
5. **validate()**:运行ContractEditor的validate(),区分预存误报和本轮新增问题
## 注意事项
- 标题和正文的pPr应从**紧邻的原文同级段落**克隆,而非从目标段落自身克隆
- rFonts必须设置四属性(ascii, hAnsi, eastAsia, cs),仅设hint=eastAsia是不够的
- 标题的bold属性按照reviewer的指令设置(注意:原文标题可能不加粗,但reviewer可能要求加粗)
- 两个INS段落使用不同的id(从文档中max_ins_id+1开始递增)
- 操作前先备份原文件
@@ -0,0 +1,60 @@
# Split-Run Numbering in docx XML
## Problem
Contract numbering like `(5)` is often split across multiple `<w:r>` runs in the XML:
```xml
<w:r><w:t></w:t></w:r>
<w:r><w:t>5</w:t></w:r>
<w:r><w:t>)委托方</w:t></w:r>
```
A naive `tracked_replace("(5)", "(6)")` searching for the complete string in a single `<w:t>` will **silently fail** — no match, no error, no renumbering.
## Solution: Multi-run concatenation + split
### Algorithm
```python
def tracked_replace_split_number(p, old_num, new_num):
"""Handle (old_num) spread across multiple runs."""
target = f'{old_num}'
new_target = f'{new_num}'
# 1. Collect all plain runs (not inside w:ins or w:del)
plain_runs = [(index, run, text) for each child of p]
# 2. Slide a window: concatenate adjacent run texts until target is found
for start in range(len(plain_runs)):
concat = ""
for end in range(start, start+4): # max 4 runs for a number
concat += plain_runs[end].text
if target in concat:
# Found! Extract before/after text around the number
runs_to_wrap = plain_runs[start:end+1]
# ...proceed to replace
# 3. Remove original runs, insert:
# - [before_run if text before number]
# - DEL element with delText=target
# - INS element with t=new_target
# - [after_run if text after number, e.g. "委托方"]
# 4. Set rsid attributes: rsidDel on DEL runs, rsidR on INS runs
```
### Critical: Process order
**Always renumber from bottom to top** (last paragraph first) to avoid index shifting:
```python
# CORRECT
renumber = [(P113, '10', '11'), (P112, '9', '10'), (P111, '7', '8')]
# WRONG - P112 was already renumbered when we get to it
renumber = [(P111, '7', '8'), (P112, '9', '10'), (P113, '10', '11')]
```
### Edge cases encountered (2026-06-08)
- `(` + `10)` (two runs, not three) — the closing `)` merged with the digit
- `(` + `5` + `)委托方` — closing `)` merged with following text, must split run to preserve "委托方"
- Copy `w:rPr` from original runs to all new DEL/INS runs to preserve font/size
## Lesson
This was the root cause of a terminal review failure where 3 new clauses were inserted without numbering, and subsequent numbering was not renumbered. The `tracked_replace` function matched nothing because it expected `(5)` as a single text node.
@@ -0,0 +1,87 @@
# Standalone Char-Level Tracked Changes + Comments (non-workflow)
When modifying contracts **outside** the Doro/邱律师 workflow (e.g. Maggie directly asks to revise a client's agreement), the full `ContractEditor` library + review-rules machinery is overkill. Use this lightweight pattern instead.
## When to use
- Maggie sends a contract and says "帮我改一下" / "修改这份协议"
- No workflow, no reviewer, no deliverer — just direct revision
- Still must produce Word-native tracked changes (del/ins) + comments
## Core technique: `difflib.SequenceMatcher` char-level diff
```python
import difflib
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def char_level_replace(para, new_text, author="WB", date="2026-07-07T10:00:00Z"):
"""Replace paragraph text with char-level tracked changes.
Unchanged chars → normal w:r (preserved).
Deleted chars → w:del + w:delText.
Inserted chars → w:ins + w:t.
"""
p = para._element
old_text = para.text
if old_text == new_text:
return
# Remove existing runs (preserve pPr)
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag in ('r', 'ins', 'del', 'hyperlink'):
p.remove(child)
sm = difflib.SequenceMatcher(None, old_text, new_text)
for op, i1, i2, j1, j2 in sm.get_opcodes():
if op == 'equal':
p.append(make_run(old_text[i1:i2]))
elif op == 'delete':
p.append(make_del_run(old_text[i1:i2], author, date))
elif op == 'insert':
p.append(make_ins_run(new_text[j1:j2], author, date))
elif op == 'replace':
p.append(make_del_run(old_text[i1:i2], author, date))
p.append(make_ins_run(new_text[j1:j2], author, date))
```
## Comments injection (bypassing python-docx limitations)
python-docx has no native comment support. Inject manually:
1. Add `commentRangeStart` + `commentRangeEnd` + `commentReference` run to target paragraph
2. Build `word/comments.xml` as a plain string (proper namespace, no lxml serialization quirks)
3. Inject into the docx ZIP: update `[Content_Types].xml` + `word/_rels/document.xml.rels`
### Critical: comments.xml namespace
**Wrong** (causes "reuse of xmlns" error):
```python
comments_xml = etree.Element(qn('w:comments'))
comments_xml.set(qn('xmlns:w'), WNS) # ❌ double declaration
```
**Right** (build as plain string):
```python
def build_comments_xml(comments_list):
lines = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
lines.append('<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">')
for cid, text in comments_list:
safe = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
lines.append(f' <w:comment w:id="{cid}" w:author="WB" w:date="..." w:initials="WB">')
lines.append(f' <w:p><w:r><w:t>{safe}</w:t></w:r></w:p>')
lines.append(f' </w:comment>')
lines.append('</w:comments>')
return "\n".join(lines)
```
## Pitfalls learned (2026-07-07 退休返聘案)
1. **lxml etree serialization breaks Word**: `etree.tostring()` produces `xmlns:ns0=...` prefix notation that Word/OnlyOffice cannot parse. Always build comments.xml as a plain string.
2. **Entire-paragraph del+ins is unacceptable**: Maggie and Doro both require char-level precision. "原文相同的部分保留,不一样的用修订" — this is non-negotiable.
3. **New paragraphs (fully inserted)**: Use `pPr/rPr/ins` mark to flag the ¶ itself as inserted, plus `w:ins` wrapping the text run. Both are needed for Word to show the full paragraph as tracked insertion.
4. **Verify files open correctly**: After save, always `Document(path)` to confirm no XML parse errors.
## Template (full working script structure)
See `/tmp/modify_v3_charlevel.py` from the 2026-07-07 session — processes two contracts (full-time + part-time) with char-level diff + comments injection. Pattern: `process_contract(input, output, is_fulltime=bool)`.
@@ -0,0 +1,93 @@
# Strip numPr Before Inserting Manual Numbering (2026-07-01)
## Problem
When adding manual numbering (e.g. "第一条 ") as `w:ins` at the beginning of a paragraph that already has `<w:numPr>` (automatic numbering like "%1." decimal format), OnlyOffice renders BOTH:
- The automatic number: "1."
- The manual INS text: "第一条"
Result: "1. 第一条 乙方应严格按照..."
## Root Cause
`<w:numPr>` in pPr tells the rendering engine to prepend an auto-generated number. The `w:ins` text is just another run in the paragraph — it doesn't suppress the auto-numbering.
Additionally, `<w:pPrChange>` records the pre-revision pPr state. If pPrChange still contains `<w:numPr>`, some renderers will show the old numbering in markup view.
## Affected Scenarios
1. **反委托代发工资协议 (2026-07-01)**: Original paragraphs P6-P12, P19 had `numId=1` or `numId=3` (decimal "%1." format). After adding "第一条" through "第十一条" as INS, OnlyOffice showed "1. 第一条", "2. 第二条", etc.
2. **Any contract where the original used auto-numbering**: Check `numbering.xml` for active numId references with `numFmt=decimal` or `numFmt=chineseCounting`.
## Fix Pattern
```python
import zipfile
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile(docx_path, 'r') as z:
all_files = {name: z.read(name) for name in z.namelist()}
doc = etree.fromstring(all_files['word/document.xml'])
body = doc.find(f'{WNS}body')
paras = body.findall(f'{WNS}p')
# Identify paragraphs where we added manual numbering INS
# These are paragraphs that have both:
# 1. A w:ins with author=WB containing "第X条" text
# 2. A pPr with numPr
for i, p in enumerate(paras):
ppr = p.find(f'{WNS}pPr')
if ppr is None:
continue
# Check if this paragraph has our manual numbering INS
has_manual_numbering = False
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'ins' and child.get(f'{WNS}author') == 'WB':
text = ''.join(t.text for t in child.iter(f'{WNS}t') if t.text)
if '' in text and '' in text:
has_manual_numbering = True
break
if not has_manual_numbering:
continue
# Strip numPr from pPr
num_pr = ppr.find(f'{WNS}numPr')
if num_pr is not None:
ppr.remove(num_pr)
print(f"P{i}: Stripped numPr from pPr")
# Strip numPr from pPrChange
ppc = ppr.find(f'{WNS}pPrChange')
if ppc is not None:
inner_ppr = ppc.find(f'{WNS}pPr')
if inner_ppr is not None:
inner_num = inner_ppr.find(f'{WNS}numPr')
if inner_num is not None:
inner_ppr.remove(inner_num)
print(f"P{i}: Stripped numPr from pPrChange")
# Save back
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
# Write to temp file then replace (never write to same zip you're reading)
```
## Verification
After stripping, render with OnlyOffice and confirm:
1. Markup view shows only the manual numbering (no "1." prefix)
2. Accepted-revisions view shows clean "第一条" through "第十一条"
3. python-docx can still open the file without errors
## Edge Cases
- **DEL-only empty paragraphs** (e.g. P8, P9 where all content is w:del): These may still have numPr. If they render a visible "3." or "4." in the gap, strip those too.
- **Cross-paragraph clauses** (P6+P7 = one clause): P7 may have its own independent numPr even though it's a continuation paragraph. Strip it.
- **numId=0 (disabled numbering)**: `numId=0` in OOXML means "numbering OFF" — it doesn't render anything. Only strip numPr where `numId > 0` and the corresponding abstractNum has a visible numFmt (decimal, chineseCounting, etc.).
@@ -0,0 +1,121 @@
# Systematic File Recovery for Lost Author Markers
When intermediate files have been overwritten during iterative editing (e.g., multiple versions of a contract revision), and you need to find a specific version that contains tracked changes by a particular author (e.g., "华诚-Z"), use this systematic scan approach.
## Scenario
- You made multiple intermediate files (v1, v2, v3...) in `/tmp/` during contract editing
- You overwrote files, losing the version with a specific author's tracked changes
- You need to find ANY surviving file that still has that author's `w:author` attribute
## Recovery Technique
### Step 1: List all candidate files
Find all `.docx` files in the working directory that are newer than the original source file:
```bash
find /tmp -name '*.docx' -newer /tmp/original_file.docx 2>/dev/null | sort
```
### Step 2: Check each file for the target author
```python
import zipfile, re, os
from datetime import datetime
target_author = '华诚-Z' # or whatever author you're looking for
files = [
"/tmp/v1_clean.docx",
"/tmp/v1_final.docx",
# ... list all candidate files from Step 1
]
for f in files:
if not os.path.exists(f):
continue
try:
z = zipfile.ZipFile(f)
content = z.read('word/document.xml').decode('utf-8', 'ignore')
authors = set(re.findall(r'w:author="([^"]+)"', content))
mt = datetime.fromtimestamp(os.path.getmtime(f)).strftime('%m-%d %H:%M')
has_target = target_author in authors
marker = '' if has_target else ' '
print(f"{marker} {os.path.basename(f):35s} {mt} authors={sorted(authors)}")
z.close()
except Exception as e:
print(f" ERROR {f}: {e}")
```
### Step 3: Extract the target author's changes
Once you find the file with the target author, extract their specific tracked changes:
```python
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
z = zipfile.ZipFile('/tmp/file_with_target_author.docx')
with z.open('word/document.xml') as f:
tree = etree.parse(f)
root = tree.getroot()
body = root.find(f'{WNS}body')
paras = body.findall(f'{WNS}p')
for i, p in enumerate(paras):
has_target = False
parts = []
for child in p:
tag = etree.QName(child.tag).localname
if tag == 'r':
t = child.find(f'{WNS}t')
if t is not None and t.text:
parts.append(('RUN', t.text, None))
elif tag == 'ins':
author = child.get(f'{WNS}author', '?')
if target_author in author:
has_target = True
ins_texts = []
for r in child.findall(f'{WNS}r'):
t = r.find(f'{WNS}t')
if t is not None and t.text:
ins_texts.append(t.text)
if ins_texts:
parts.append(('INS', ''.join(ins_texts), author))
elif tag == 'del':
author = child.get(f'{WNS}author', '?')
if target_author in author:
has_target = True
del_texts = []
for r in child.findall(f'{WNS}r'):
t = r.find(f'{WNS}delText')
if t is not None and t.text:
del_texts.append(t.text)
if del_texts:
parts.append(('DEL', ''.join(del_texts), author))
if has_target:
print(f"\n★ P{i}:")
for kind, text, author in parts:
if kind == 'RUN':
print(f" [原文] {repr(text)}")
else:
print(f" [{kind} by {author}] {repr(text)}")
z.close()
```
## Empirical Case (2026-07-01 反委托代发工资协议)
- Made ~15 intermediate files in `/tmp/` during iterative editing
- Overwrote all files, changing all `w:author` attributes to "WB"
- User (Doro) demanded recovery of 华诚-Z's tracked changes
- Systematic scan found `/tmp/v1_doro_updated.docx` with `authors=['WB', '华诚-Z']`
- Extracted 华诚-Z's 3 specific changes:
- P6: INS "等" (between WB's "《劳务派遣暂行规定》" and "规定,")
- P12: INS "退回派遣员工" + INS "由乙方依法自行安置处理,与甲方无涉。"
## Key Pitfalls
1. **Don't assume the file is gone** — check ALL intermediate files, not just the ones you expect
2. **Check timestamps** — the file you need might be an early intermediate, not the latest
3. **Use `w:author` attribute** — this is the definitive marker, not file content or naming
4. **Comments may also be lost** — the recovered file might have lost some original comments (see `references/comment-restoration-from-original.md`)
## Prevention (Better Than Recovery)
The existing skill already covers this, but worth repeating: **改前必备份** — before modifying any file with third-party tracked changes, save a timestamped backup:
```bash
cp file_with_third_party.docx file_with_third_party.bak_$(date +%Y%m%d_%H%M%S).docx
```
@@ -0,0 +1,69 @@
# 表格单元格编辑:保持格式不被破坏
## 问题
编辑 docx 表格单元格中的文字时,两种常见错误做法都会破坏格式:
1. **`cell.paragraphs[0].clear()` + `add_run()`**:把多段结构压成一段,丢失加粗、字号、字体
2. **XML 层全 cell 文字重分片**:把修改后的文字均匀分配到所有 `w:t` 元素,破坏段落边界和编号
## 正确做法:段落级精确定位 + 只改目标段
```python
from docx import Document
from docx.shared import Pt
doc = Document('file.docx')
table = doc.tables[0]
cell = table.rows[7].cells[1]
# 1. 定位目标段落(按索引)
paras = cell.paragraphs
target_p = paras[4] # 例如 P4 是你要改的段落
# 2. 保存首 run 格式
first_run = target_p.runs[0]
saved = {
'name': first_run.font.name,
'size': first_run.font.size,
'bold': first_run.font.bold,
'italic': first_run.font.italic,
}
# 3. 文字替换
old_text = target_p.text
new_text = old_text.replace('要被替换的文字', '新文字')
# 4. 清空该段 → 重写(保留格式)
target_p.clear()
run = target_p.add_run(new_text)
run.font.name = saved['name']
run.font.size = saved['size']
run.font.bold = saved['bold']
run.font.italic = saved['italic']
# 5. 合并单元格:同步更新同行其他 cell
for col_idx in [2, 3]:
cell2 = table.rows[7].cells[col_idx]
p = cell2.paragraphs[4]
p.clear()
run = p.add_run(new_text)
run.font.name = saved['name']
run.font.size = saved['size']
doc.save('output.docx')
```
## 关键原则
- **不碰其他段落**:只改目标索引的段落,其余段落原封不动
- **不压多段为一段**:每个段落独立处理,保持 `P0/P1/P2/P3/P4` 结构不变
- **合并单元格全同步**:`row[7].cells[1]` 改了什么,`cells[2]``cells[3]` 也要同步
- **先读后改**:改前用 `cell.paragraphs[i].text` 确认内容,用 `cell.paragraphs[i].runs[0].font` 确认格式
## 本次教训
2026-06-26 预算绩效分析表:两轮都搞坏格式。
- 第一轮:`clear()` + `add_run()` 把 Row 7 的 P0-P10 多段结构压成一段
- 第二轮:XML 全 cell 文字重分片把 "2.成本核算分析" 变成 ".成本核算分析"(编号丢失)
- 第三轮(正确):定位到 P4(成本优化段),只改它,保留 P0-P3 不动
@@ -0,0 +1,65 @@
# tracked_replace 被 DEL 元素打断(2026-06-26 CT维保合同-香花桥实证)
## 症状
`tracked_replace(old, new)` 对跨 DEL 元素的文本静默失败(不报错但也不修改)。
## 根因
当匹配文本被拆成多个 run,且中间夹着 `<w:del>` 元素时,`tracked_replace` 无法跨元素边界匹配完整字符串。
## 实证
**场景**:原文"一 年"(中间有空格),需改为"一年"。
实际 XML 结构:
```xml
<w:r><w:t></w:t></w:r>
<w:del><w:r><w:delText> </w:delText></w:r></w:del>
<w:r><w:t></w:t></w:r>
```
`tracked_replace("一 年", "一年")` 无法匹配("一 年" 不连续存在于任何单一 run 中)。
## 修法
直接 zipfile+lxml 操作,移除 DEL 元素:
```python
for elem in list(paragraph):
if elem.tag.split('}')[-1] == 'del':
for t in elem.iter():
if t.tag == f'{{{W}}}delText' and t.text == ' ':
paragraph.remove(elem)
break
```
## 同类变体:INS 需插入在 DEL 之后
**场景**:原文"与济损失"("与"为错字),上一轮已将"与"包进 DEL,但未补 INS "经"。
实际 XML 结构:
```xml
<w:del><w:r><w:delText></w:delText></w:r></w:del>
<w:r><w:t>济损失的...</w:t></w:r>
```
`tracked_replace("与济损失", "经济损失")` 静默失败("与"在 DEL 内)。
**修法**:zipfile+lxml 在 DEL 元素后插入 INS:
```python
ins = etree.SubElement(paragraph, f'{{{W}}}ins')
ins.set(f'{{{W}}}id', str(new_id))
ins.set(f'{{{W}}}author', 'WB')
ins.set(f'{{{W}}}date', date_str)
del_elem.addnext(ins) # INS 紧跟在 DEL 之后
r = etree.SubElement(ins, f'{{{W}}}r')
r.append(copy.deepcopy(ref_rPr)) # 从同级 run 克隆 rPr
t = etree.SubElement(r, f'{{{W}}}t')
t.text = ''
```
## 判别
改前先遍历目标段落子元素,看是否有 `w:del``w:ins` 元素分割了匹配文本。有则不用 `tracked_replace`,改用 zipfile+lxml 直接操作。
@@ -0,0 +1,127 @@
# tracked_replace 跨 w:ins 元素失败的处理
## 症状
`tracked_replace(old, new)` 抛出 `ValueError: Element is not a child of this node`
发生在 `contract_docx_lib.py` 第368行 `parent.remove(runs[idx])`
## 根因
合同已经过上一轮 workflow 修订,原文段落中插入了 `w:ins(author="WB")` 元素。
目标匹配文本跨越了 `w:r``w:ins` 边界:
```
w:r: "...若甲方在双"
w:ins(author="WB"): "方"
w:r: "核对消费金额时未提出异议的..."
```
`tracked_replace``w:r``w:ins` 下的 `w:r` 都收集到 `runs` 列表,
`parent.remove(runs[idx])` 时,`w:ins` 内的 `w:r` 的 parent 是 `w:ins`,不是 `w:p` → 报错。
## 判别
修改前先遍历目标段落的子元素,看是否有 `w:ins` 分割了匹配文本:
```python
for elem in paragraph:
tag = elem.tag.split('}')[-1]
if tag in ('r', 'ins', 'del'):
print(f" {tag}: '{''.join(t.text or '' for t in elem.findall('.//{W}t'))}'")
```
## 修法:zipfile+lxml 直接操作
不用 `tracked_replace`,改用 zipfile+lxml 四步操作:
### 步骤1:裁掉第一段 run 中跨越的部分
```python
# 如:w:r 末尾是 "...核对确认,若甲方在双" → 裁掉 "若甲方在双"
assert r1_t.text.endswith('若甲方在双')
r1_t.text = r1_t.text[:-6] # 移除6个字符
```
### 步骤2:创建 DEL 包裹被裁掉的文字
```python
del_elem = etree.Element(qn('del'))
del_elem.set(qn('id'), str(next_del_id))
del_elem.set(qn('author'), 'WB')
del_elem.set(qn('date'), revision_date)
del_run = etree.SubElement(del_elem, qn('r'))
del_rpr = etree.SubElement(del_run, qn('rPr'))
# 从被裁 run 复制 rPr
for child in r1_elem.find(qn('rPr')):
del_rpr.append(copy.deepcopy(child))
del_text = etree.SubElement(del_run, qn('delText'))
del_text.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
del_text.text = '若甲方在双方' # 被裁文字 + w:ins 内容
```
### 步骤3:移除原来的 w:ins 元素
```python
p.remove(ins_elem)
```
### 步骤4:DEL旧文字 + INS新文字
```python
# DEL 旧文字(第三段 run 的完整内容)
del_elem2 = ... # 同上模式,delText = r3_t.text
# INS 新文字
ins_elem = etree.Element(qn('ins'))
ins_elem.set(qn('id'), str(next_ins_id))
ins_elem.set(qn('author'), 'WB')
ins_elem.set(qn('date'), revision_date)
ins_run = etree.SubElement(ins_elem, qn('r'))
# 从原 run 复制 rPr
ins_rpr = etree.SubElement(ins_run, qn('rPr'))
for child in r3_elem.find(qn('rPr')):
ins_rpr.append(copy.deepcopy(child))
ins_t = etree.SubElement(ins_run, qn('t'))
ins_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
ins_t.text = new_text
# 替换:在 r3 位置前插入 DEL 和 INS,再删除 r3
r3_pos = list(p).index(r3_elem)
p.insert(r3_pos, del_elem2)
p.insert(r3_pos + 1, ins_elem)
p.remove(r3_elem)
```
### 步骤5:写回
```python
doc_xml_modified = etree.tostring(tree, encoding='UTF-8', xml_declaration=True)
with zipfile.ZipFile(src, 'r') as zf:
file_data = {f: zf.read(f) for f in zf.namelist()}
with zipfile.ZipFile(src, 'w', zipfile.ZIP_DEFLATED) as zf:
for f, data in file_data.items():
zf.writestr(f, doc_xml_modified if f == 'word/document.xml' else data)
```
## 易错点
### 1. 中文切片长度
`[:-3]` 移除3个**字符**(不是字节)。"但本" = 2个中文字符 → 用 `[:-2]`
`[:-3]` 会多切一个字(如把";"也切掉)。
**⚠️ 全角标点也是1个字符(2026-06-26 健康积分兑换协议教训)**:`(四)` 是3个字符——`(`(U+FF08全角左括号=1字)、`四`(1字)、`)`(U+FF09全角右括号=1字)。用 `[4:]` 切片会多切掉1个中文字符,导致 DEL 文本缺字("甲方"→"方")。**判别**:数切片偏移时,全角括号/标点(()、【】、《》、。,!?等)每符1字,不因"看起来宽"就计为多个。切片前 `print(repr(text[:10]))` 确认边界。
### 2. 旧文末尾与新文开头重复
原文 run 裁掉部分后,末尾可能与 INS 新文字开头重复。
如:原文末尾是"核对确认",新文字开头也是"核对确认" → 出现"核对确认核对确认"。
**修法**:从 INS 新文字中去掉重复前缀。
### 3. 标点符号归属
裁掉 run 末尾文字时,确保标点符号(;。等)留在正确位置。
如原文"费用;但本"裁掉"但本"后应为"费用;"(保留分号)。
## 验证
1. `python-docx` 能打开(`Document(out)` 不抛异常)
2. `wb-ins-font-verify.py` 所有 WB INS 字体一致
3. 用 `ContractEditor.get_para_text()` 读段落文本,确认无重复/缺字
@@ -0,0 +1,67 @@
# 移植workflow修订到同名不同版本合同 (2026-07-08)
## 场景
邱律师同日发了两份同名文件(如"2026年华新镇公立中小学生健康体检服务合同.docx"),内容有实质差异(不同版本/条款)。第一份被workflow正常审查交付,第二份因queue-runner同名跳过逻辑被遗漏。Doro要求"把workflow第1份的修订内容直接修订到第2份里,但要注意相关修订在第2份里是否合理"。
## 操作步骤
### 1. 提取第1份的WB修订清单
从已交付文件提取所有 `author=WB``w:ins``w:del`
```python
from zipfile import ZipFile
from lxml import etree
with ZipFile(delivered_path, 'r') as z:
content = z.read('word/document.xml').decode('utf-8')
root = etree.fromstring(content.encode('utf-8'))
# 遍历所有 WB INS/DEL,记录:段落索引、INS文本、DEL文本、上下文
```
### 2. 对比两份合同差异
`difflib` 对比两版全文,定位哪些段落内容不同。特别关注:
- 修订涉及的段落在第2份中是否存在
- 如果存在,文本是否与第1份中的"修订前"文本一致
### 3. 逐条判断修订是否适用于第2份
| 修订类型 | 判断方法 |
|---------|---------|
| 术语统一(如"协议"→"合同") | 在第2份中搜索同一术语,存在则同样修改 |
| 新增保护条款(如数据归属、转包连带责任) | 检查第2份对应位置是否缺同样的保护,缺则加 |
| 金额/支付相关修订 | 第2份的支付条款可能完全不同(如本案),需独立判断是否需要新的修订 |
| 合同期限相关修订 | 第2份期限可能不同,独立判断 |
### 4. 对第2份执行修订
使用ContractEditor库,与正常审查相同流程:
```python
ed = ContractEditor(second_file)
ed.tracked_replace(old, new) # 逐条适用的修订
errors = ed.validate()
ed.save(output)
```
### 5. 字体验证 + 上传
- `wb-ins-font-verify.py` 必须PASS
- 上传替换NC任务交付目录中的同名文件
- 同时确保第2份原始文件在待审查目录
## 2026-07-08 华新镇体检合同实证
**两版差异:**
| 条款 | 第1份 (11:05) | 第2份 (16:04) |
|------|--------------|--------------|
| 项目内容 | "公立中小学生健康检查工作" | "华新镇公立中小学生健康体检工作" |
| 支付方式 | 按实际人数结算,无金额上限 | 按实际完成人数+考核表结算,费用上限17万 |
| 合同期限 | 9月10日起 | 9月1日起 |
**移植的修订(全部适用):**
1. 保密条款:数据归属+合同期满扩大+协议→合同统一 ✅ 第2份保密条款内容相同
2. 转包限制:增加甲方书面同意+连带责任 ✅ 第2份P44文本相同
3. 效力条款:本协议→本合同 ✅ 第2份P51文本相同
**不需要额外修订的原因:**
- 第2份的支付条款已更完善(有上限、有考核、有一次性付清约定)
- 违约责任、争议解决条款相同且已足够
## 注意事项
- 第2份被上传后会**替换**第1份的交付文件(同名),tracker中seq=262的记录对应的实际内容变了
- hint mismatch 在 tracked_replace 生成的长INS文本中常见(库不自动加hint到多段INS),需post-fix
@@ -0,0 +1,59 @@
# 版本管理反模式(2026-07-01 反委托代发工资协议惨痛教训)
## 事件回顾
反委托代发工资协议需要制作两个版本(法定安排 vs 反委托保护),同时保留华诚-Z的修订痕迹。
### 灾难链条
1. 原始文件有华诚-Z的修订(author="华诚-Z")+ 批注
2. 我制作WB版本时,把所有author改成了WB
3. 又做了一版合并版本,再次覆盖
4. 之后Doro说"你把华诚-Z修订痕迹的版本放进去"
5. 发现/tmp里所有文件都只有WB作为author
6. Nextcloud版本历史也没有(只保留了一个.v文件,也是WB)
7. 最终在 `/tmp/v1_doro_updated.docx` 找到——这是一个中间版本,纯属侥幸
### 反模式清单
| 反模式 | 后果 |
|--------|------|
| 修改author前不备份 | 原始修订痕迹不可逆丢失 |
| 覆盖式保存(同文件名) | 中间版本消失 |
| 从头重做而非增量修补 | 每次重做都覆盖上一版 |
| 不验证就交付 | 批注丢了3条没发现 |
| 多轮操作共用/tmp目录 | 后续操作的文件名与前面冲突 |
### 正确做法
```python
import shutil
from datetime import datetime
# 操作前备份
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
shutil.copy(source, f"{source}.bak_{timestamp}")
# 操作后验证
import zipfile, re
z = zipfile.ZipFile(output)
content = z.read('word/document.xml').decode('utf-8')
authors = set(re.findall(r'w:author="([^"]+)"', content))
assert '华诚-Z' in authors, "华诚-Z author LOST!"
# 批注验证
if 'word/comments.xml' in z.namelist():
comments_xml = z.read('word/comments.xml').decode('utf-8')
comment_count = len(re.findall(r'<w:comment ', comments_xml))
assert comment_count >= expected_count, f"Comments lost: {comment_count} < {expected_count}"
```
### 文件命名规范(防覆盖)
不要用 `_v2.docx` `_v3.docx` 这种递增命名——容易忘记当前版本是几。用语义+时间戳:
```
反委托_华诚Z原版_20260701_0320.docx # 带华诚-Z修订的版本
反委托_WB合并版_20260701_0341.docx # WB+华诚-Z合并后
反委托_V1法定安排_FINAL_20260701.docx # 最终交付
```
@@ -0,0 +1,56 @@
# WB自加手动编号与前序自动编号撞号 — 诊断与修复
实战来源:端午节福利品采购合同(2026-06-16)。Maggie:"转包责任应该是7,上一个编号是6。手动修复上传。"
## 场景识别
- 我们(WB)用 `w:ins` 新增了若干尾部条款(转包/违约/争议…),编号是**手动文字**写在 run 文本开头("6、转包限制…")。
- 紧邻的前一条是**原文自带的自动编号**条款(pPr 有 `<w:numPr>`,编号由 numbering.xml 的 `<w:start>` 生成,文字里**没有**编号)。
- 二者渲染数字撞号:自动编号末值=6,我方手动也从6起 → 接受修订后出现两个6。
## 诊断步骤(顺序不可颠倒,OnlyOffice为准)
1. 取交付版(任务交付/【修】…docx)+原文(待审查/…doc),各用 `scripts/onlyoffice-render.sh` 渲染 PDF。
2. `pdftotext -layout x.pdf - | grep -E "^\s*\f?[0-9]+、"` 数出**完整可见编号链**(注意"5、结算"可能挤在第4条段内、自动编号条款文字里无编号——肉眼易漏)。
3. 读 numbering.xml 确认前序自动条款的 numId→abstractNumId→lvl0 的 `start` 值,得知它渲染成几(端午节:numId=3, start=6 →"6")。
4. 读 document.xml,确认我方各条是 `w:ins author=WB`,编号"6、""7、""8、"在 ins 首个 w:r 的 w:t 开头。
## 判定
前序自动编号末值 = N → 我方手动编号应从 **N+1** 起顺延。端午节:售后=6 → 转包=7、违约=8、争议=9。
**有Maggie明确指示 + 完整核对 → 执行。** 不要因"擅改编号"的旧教训而拒绝正确修复(区别在:当初错在没核对没确认,不在方向)。
## 修复(纯 zipfile+lxml,最干净)
只改 ins 首个 w:t 的编号前缀,不拆 run、不碰 rPr、不转 numbering:
```python
import zipfile, os
from lxml import etree
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
src='deliver.docx'; out='deliver_FIXED.docx'
root = etree.fromstring(zipfile.ZipFile(src).read('word/document.xml'))
paras = root.find(f'{W}body').findall(f'{W}p')
changes = {19:('6、','7、','转包'), 20:('7、','8、','违约'), 21:('8、','9、','争议')} # 段索引→(旧号,新号,关键词)
for idx,(old,new,kw) in changes.items():
ins = paras[idx].find(f'{W}ins')
assert ins is not None and ins.get(f'{W}author')=='WB', f"{idx}非WB的ins!" # 铁律:绝不改他人ins
t = ins.find(f'{W}r').find(f'{W}t')
assert t.text.startswith(old) and kw in t.text[:6]
t.text = new + t.text[len(old):]
new_doc = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
tmp=out+'.tmp'
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(tmp,'w',zipfile.ZIP_DEFLATED) as zout:
for it in zin.infolist():
zout.writestr(it, new_doc if it.filename=='word/document.xml' else zin.read(it.filename))
os.replace(tmp,out)
```
## 交付前四查(vision不可用时的强制验证,缺一不可)
1. **逐段markup diff vs交付源**:提取两版每个 w:p 的 markup 文本(含 delText),断言**只有目标N段不同**、其余全部零改动(端午节33段只动3段)。
2. **OnlyOffice渲染PDF编号链**:pdftotext 数出 1,2,3,4,(5),6,7,8,9 连续无双号。
3. **INS run rPr 改前==改后**`etree.tostring(rpr)` 逐段比对,确认字体/字号一字未动。
4. **python-docx 能打开** + 接受修订后(去 del、解包 ins)编号链连续,证明 XML 合法、WB 修订标记完整保留。
## 交付(Editor到此为止则交deliverer;本例Maggie直接要"上传"故一并做)
- 文件名**一字不动**:覆盖 `Doro合同审查任务/任务交付/【修】<原名>.docx`
- `docker cp` 进 nextcloud-nextcloud-1 → `chown www-data``occ files:scan --path=...`
- 落盘 md5 == 修复版 md5 才算成功。
- 清 OnlyOffice 缓存:`docker exec nextcloud-onlyoffice-1 rm -rf .../App_Data/cache/files/*`
- 已 pass 登记过的合同仅编号订正:tracker/xlsx 记录不变动。
@@ -0,0 +1,127 @@
# ContractEditor 原文Run属性污染诊断与修复
## 2026-07-13 洋励合同实证
### 问题描述
ContractEditor(contract_docx_lib.py)在处理文档时,不仅给WB INS runs添加多余属性,还会**修改原文runs**的rPr——给本来靠docDefaults/style继承的orig runs添加显式eastAsia/cs/sz。
### 典型污染模式
| 属性 | 原文(待审查) | 被污染后(交付物中的orig run) | WB INS run |
|------|--------------|-------------------------------|-----------|
| eastAsia | None (继承minorEastAsia) | **宋体** (被加) | None |
| cs | None | **宋体** (被加) | None |
| sz | None (继承docDefaults=22) | **21** (被加且值错) | None |
| ascii | 宋体 | 宋体 | None |
| hint | eastAsia (部分有) | eastAsia | None |
### 后果
1. 原文所有文字从11pt(docDefaults sz=22)变成10.5pt(显式sz=21) — 整体缩小0.5pt
2. INS文字没有任何属性 → 走docDefaults 11pt → 与被改小的原文不一致
3. 字体验证脚本(wb-ins-font-verify.py)报INS缺属性,但实际问题是orig被污染
### 诊断步骤
```bash
# 1. 读原文代表性段落run rPr
python3 -c "
import zipfile
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile('原文.docx', 'r') as z:
...
# 检查: eastAsia=None? sz=None?
# 如果是 → 原文靠继承
# 2. 读交付物同段落orig run rPr
# 检查: 是否多了eastAsia/cs/sz?
# 如果是 → 被污染
```
### 修复代码模板
```python
import zipfile, tempfile, shutil
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile(filepath, 'r') as z:
all_files = {n: z.read(n) for n in z.namelist()}
tree = etree.fromstring(all_files['word/document.xml'])
body = tree.find(f'{WNS}body')
# Step 1: Strip contaminated attributes from ALL orig runs
for p in body.findall(f'{WNS}p'):
for r in p.findall(f'{WNS}r'): # Only direct child runs (not inside ins/del)
rpr = r.find(f'{WNS}rPr')
if rpr is None:
continue
rf = rpr.find(f'{WNS}rFonts')
if rf is not None:
# Strip eastAsia (original didn't have it)
if f'{WNS}eastAsia' in rf.attrib:
del rf.attrib[f'{WNS}eastAsia']
# Strip cs (original didn't have it)
if f'{WNS}cs' in rf.attrib:
del rf.attrib[f'{WNS}cs']
# Strip sz (original relies on docDefaults)
sz = rpr.find(f'{WNS}sz')
if sz is not None:
rpr.remove(sz)
# Step 2: Fix INS runs to match REAL original format
for ins in body.findall(f'.//{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
continue
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is None:
continue
rf = rpr.find(f'{WNS}rFonts')
if rf is None:
rf = etree.SubElement(rpr, f'{WNS}rFonts')
# Match real original: ascii=宋体, hAnsi=宋体, NO eastAsia
rf.set(f'{WNS}ascii', '宋体')
rf.set(f'{WNS}hAnsi', '宋体')
if f'{WNS}eastAsia' in rf.attrib:
del rf.attrib[f'{WNS}eastAsia']
# Remove sz (let it inherit)
sz = rpr.find(f'{WNS}sz')
if sz is not None:
rpr.remove(sz)
# Step 3: Per-paragraph hint matching
for p in body.findall(f'{WNS}p'):
# Get orig run's hint
orig_hint = None
for r in p.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is not None:
rf = rpr.find(f'{WNS}rFonts')
orig_hint = rf.get(f'{WNS}hint') if rf is not None else None
break
# Apply to INS runs in same paragraph
for ins in p.findall(f'.//{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
continue
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is None: continue
rf = rpr.find(f'{WNS}rFonts')
if rf is None: continue
if orig_hint:
rf.set(f'{WNS}hint', orig_hint)
elif f'{WNS}hint' in rf.attrib:
del rf.attrib[f'{WNS}hint']
```
### 注意事项
1. **必须对比原文确定被污染了哪些属性** — 不同合同模板的原文属性不同
2. **不是所有合同都有此问题** — 取决于原文是否靠继承(有显式属性的不会被"污染",因为值相同)
3. **Step 1必须在Step 2之前** — 否则wb-ins-font-verify仍会报INS与(被污染的)orig不一致
4. **hint要逐段处理** — 同一文档不同段落的orig runs可能有的有hint有的没有
@@ -0,0 +1,70 @@
# Workflow INS Format Repair — ContractEditor Font Contamination Pattern
## 2026-07-13 洋励/安全生产/消防设施检测 连续验证
### 问题根因
ContractEditor库在处理文档时会**污染原文runs**——给原本没有显式属性的runs添加`eastAsia``cs``sz`
典型对比:
```
原文(待审查): rFonts={ascii=宋体, hAnsi=宋体, hint=eastAsia}, szCs=21, NO sz, NO eastAsia
v1中orig runs: rFonts={ascii=宋体, hAnsi=宋体, hint=eastAsia, cs=宋体, eastAsia=宋体}, szCs=21, sz=21
v1中WB INS: rPr=空 (什么属性都没有)
```
**后果**
1. 原文字号从继承docDefaults(如sz=22=11pt)变为显式sz=21(10.5pt)——整体缩小0.5pt
2. INS runs无属性→走docDefaults继承→11pt,与被改小的orig runs(10.5pt)不一致
3. wb-ins-font-verify报"orig=宋体/21 wb=None"——但这个"orig"已被污染,不是真实原文
### 诊断铁律
**永远对比待审查目录的原文,不信v1中的orig runs**
```python
# 对比同一段落的run属性
for label, path in [('待审查原文', orig_path), ('交付v1', v1_path)]:
# 读P4 first run rPr的所有子元素
# 如果v1比原文多了eastAsia/cs/sz → 被污染
```
### 修复方法(三步)
**Step 1:清除orig runs的污染属性**
```python
for r in p.findall(f'{WNS}r'): # 只处理原文runs(不在ins/del内的)
rpr = r.find(f'{WNS}rPr')
rf = rpr.find(f'{WNS}rFonts')
if rf is not None:
# 如果原文没有eastAsia,strip之
if f'{WNS}eastAsia' in rf.attrib:
del rf.attrib[f'{WNS}eastAsia']
if f'{WNS}cs' in rf.attrib:
del rf.attrib[f'{WNS}cs']
# 如果原文没有sz(靠继承),strip之
sz = rpr.find(f'{WNS}sz')
if sz is not None:
rpr.remove(sz)
```
**Step 2:设INS runs匹配真实原文**
```python
for ins in p.findall(f'.//{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB': continue
for r in ins.findall(f'{WNS}r'):
rf = rpr.find(f'{WNS}rFonts')
# 设置为原文实际有的属性(如ascii=宋体, hAnsi=宋体)
# 不设原文没有的(如eastAsia, cs)
# hint按同段orig run的值设
```
**Step 3:逐段匹配hint**
不同段落的orig runs hint状态不同(有的有hint=eastAsia,有的没有)。必须逐段检查并匹配。
### 注意事项
- **不能一刀切**:同一文档不同段落的orig run属性可能不同(P4有hint, P19没hint; P20有完整rFonts, P36完全没rFonts)
- **docDefaults是真正的参考基准**:检查`word/styles.xml``docDefaults/rPrDefault`了解继承值
- **"MISSING HINT (无同段原文可比)"是已知限制**:整段WB INS的新增段落没有orig run对比,脚本报MISSING不是真实错误
- **洋励案实证**:120处orig runs被污染,修复后INS只剩14个MISSING HINT(全是新增段落)
@@ -0,0 +1,92 @@
# Workflow交付件逐份审查检查清单 (2026-07-13 Doro要求)
## 触发条件
Doro说"逐一审查已交付合同的修订有哪些问题"或类似指令。
## 操作流程
1. 全文阅读通用review-rules.md + 对应顾问单位特殊规则
2. 列出今天交付的全部文件(`sudo find ... -newermt`
3. 一份一份审查,报告问题,等Doro说pass再做下一份
## 每份检查项
### A0. 文件完整性(先于内容)
- `zipfile``word/comments.xml`看有无批注
- 统计`w:ins`/`w:del`数量确认有修订痕迹
- 如果有多版本(v1/v2),每个都要独立检查性质
### A. 格式验证
- `wb-ins-font-verify.py` — 必须PASS
- 原文同段run属性 vs INS run属性逐一比对
- 新增段落pPr(ind/spacing/numPr)vs原文邻近段落
### B. 审查清单覆盖(10条逐条)
1. 主体条款
2. 违约责任(含赔偿上限删除、维权费用)
3. 争议解决/管辖(甲方所在地法院)
4. 保密/数据(归属+存续+泄露赔偿 三要素)
5. 知识产权/系统
6. 第三方侵权(全责+赔偿甲方损失)
7. 转包/分包(限制+连带)
8. 价款条款
9. 服务成果持续使用权(仅持续性服务适用)
10. 条款逻辑
### C. 同模板一致性
- 同批同模板合同的修订是否完全统一
- 特别检查:编号顺延方式、章节结构、措辞、天数
### D. 特殊交付物
- 读该顾问单位review-rules.md确认是否要求审查意见文档
- 缺失则标记
### E. 批注审查
- 每条WB批注逐一比对规则
- 立场是否正确(站甲方)
- 是否违反"能改就不批注"
- 是否属于提醒性批注(禁止)
## 报告格式
```
## 【修】合同名称
**字体验证:** PASS/FAIL
**修订内容:** 逐条列出WB INS/DEL
**问题:**
1. [严重/一般] 具体问题描述
2. ...
**结论:** pass建议/需修复
```
### F. 编号顺延完整性(2026-07-13 璞石合同教训)
- 章节标题编号顺延后(如七→八),**子编号也必须顺延**(7.1→8.1, 7.2→8.2...)
- Workflow常见遗漏:只改了章节标题的汉字编号(第七条→第八条),但内部子条款的阿拉伯数字编号(7.1/7.2/7.3/7.4)原封不动
- **检查方法**:accepted text中搜索所有"X.Y"格式编号,确认X与所属章节标题的序号一致
- 修复方法:子编号通常拆为两个run(如"7" + ".1 "),只需DEL第一个run("7")+INS新数字("8")
### G. 内容去重(2026-07-13 璞石合同教训)
- 新增的保密存续条款是否与原文已有的类似表述重复
- 典型:原文已有"乙方的保密义务不因合同解除或终止而免除",WB又插入"本条保密义务不因本合同的终止或解除而终止"——语义完全重复
### H. 赔偿上限全面检查(2026-07-13 璞石合同教训)
- 规则"赔偿上限能删就删"不仅适用于乙方赔偿甲方的上限
- **双向条款中的上限也要删**:如"任何一方违约,违约金额为合同总金额的20%"——此上限同时限制了甲方可获赔偿
- P43甲方自身违约金上限保留是正确的(保护甲方),但P49双向上限应删除
- **判断方法**:上限是否限制了对方向甲方赔偿?是→删;上限是否限制了甲方向对方赔偿?是→保留
### I. 新增标题段落样式(2026-07-13 璞石合同教训)
- WB新增的章节标题段(如"第七条 转包与分包")的pStyle必须与原文标题段一致
- 原文标题用Heading4→新增也用Heading4,不能用Style15(正文首行缩进)
- 标题文字中"第X条"与名称之间是否有空格?原文无空格("第六条违约责任")则新增也不加空格
### J. 原文已有修订保持不动
- 对照原文确认:WB-1/86187/杨丽/富强等原文修订人的INS/DEL/批注是否全部原样保留
- WB的修改不能意外覆盖或嵌套进原文修订
- P29金额"27900"的sz=22是原文86187的修订→不是workflow问题
## 铁律
- 先tool call读文件再下结论(验证指令铁律)
- 不凭上一份的印象判断下一份
- comments.xml必须检查(2026-07-13教训)
- **原文自带【修】前缀的文件**:按命名规则应为【修】【修】...,workflow通常不做双重前缀——记录为已知缺陷
- **Doro说"看清楚前后文再回复"**:意思是你漏了问题或误判了严重性,必须重新逐属性检查
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""生成"接受所有修订后"的干净 docx,用于 OnlyOffice 渲染做字体/排版的决定性视觉验证。
为什么需要:OnlyOffice 渲染修订态文字(w:ins,紫色+下划线)时视觉上常显示为类无衬线、
看起来字体/粗细与正文不同——这是 track-changes 的渲染特性,不是真实字体差异。vision 工具
会据此误报"字体不一致",导致无谓返工。把所有修订接受、批注去掉后再渲染,才能在无修订
颜色干扰下看到插入文字与正文的真实字体一致性。
用法: python accept-revisions-preview.py <in.docx> <out.docx>
处理: 解包所有 w:ins(保留内容)+ 删除所有 w:del(连内容)+ 移除批注锚点标记。
注意: 产物仅供"渲染核对",不是正式交付物(交付的是带修订痕迹的版本)。
"""
import sys, zipfile, io
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
Wq = '{' + W + '}'
def accept_revisions(in_path, out_path):
with open(in_path, 'rb') as f:
data = f.read()
bin_, bout = io.BytesIO(data), io.BytesIO()
with zipfile.ZipFile(bin_) as zin, zipfile.ZipFile(bout, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
raw = zin.read(item.filename)
if item.filename == 'word/document.xml':
root = etree.fromstring(raw)
# 删除所有 w:del(含内容)
for d in [e for e in root.iter(Wq + 'del')]:
d.getparent().remove(d)
# 解包所有 w:ins:把子元素提到 ins 的位置后删除 ins 壳
for ins in [e for e in root.iter(Wq + 'ins')]:
parent = ins.getparent()
idx = list(parent).index(ins)
for child in reversed(list(ins)):
parent.insert(idx, child)
parent.remove(ins)
# 移除批注锚点标记
for tag in ('commentRangeStart', 'commentRangeEnd'):
for e in [x for x in root.iter(Wq + tag)]:
e.getparent().remove(e)
for r in [x for x in root.iter(Wq + 'r')]:
if r.find(Wq + 'commentReference') is not None:
r.getparent().remove(r)
raw = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
zout.writestr(item, raw)
with open(out_path, 'wb') as f:
f.write(bout.getvalue())
print(f'接受修订版已生成: {out_path}')
if __name__ == '__main__':
if len(sys.argv) != 3:
print('用法: python accept-revisions-preview.py <in.docx> <out.docx>')
sys.exit(1)
accept_revisions(sys.argv[1], sys.argv[2])
@@ -0,0 +1,684 @@
#!/usr/bin/env python3
"""
contract_docx_lib.py — 合同修订核心库
固化验证通过的docx XML操作,不再每次重写。
用法:
from contract_docx_lib import ContractEditor
editor = ContractEditor("原文件.docx")
editor.tracked_replace("原文片段", "新文片段")
editor.add_clause("19.服务成果持续使用权", "条款内容...", after_clause=18)
editor.renumber(19, 20) # 原19→20
errors = editor.validate()
if not errors:
editor.save("【修】原文件.docx")
关键操作顺序(renumber和新增条款):
1. 先做所有 tracked_replace(文本修改)
2. 再做 add_clause(新增子条款,如15.4)
3. 再做 renumber_range(先腾出编号空间)
4. 最后做 add_clause_before(插入新主条款,用已腾出的编号)
5. validate() 验证
6. save() 保存
"""
import zipfile, io, copy, re, difflib
from lxml import etree
from datetime import datetime
from pathlib import Path
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
WNS = '{' + W + '}'
def qn(tag):
return f'{WNS}{tag}'
def cjk_tokenize(text):
"""CJK每字一token,ASCII连续一token,标点单独token。
经验证的分词策略,不要改。"""
tokens = []
i = 0
while i < len(text):
ch = text[i]
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or ch in ',。、;:!?""''()【】《》—…·[]%%':
tokens.append(ch)
i += 1
elif ch.isascii() and ch.isalnum():
j = i
while j < len(text) and text[j].isascii() and text[j].isalnum():
j += 1
tokens.append(text[i:j])
i = j
else:
tokens.append(ch)
i += 1
return tokens
class ContractEditor:
"""合同修订编辑器。一个实例对应一份合同文件。"""
def __init__(self, filepath):
self.filepath = Path(filepath)
with open(filepath, 'rb') as f:
self.original_bytes = f.read()
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
self.doc_xml = z.read('word/document.xml')
self.tree = etree.fromstring(self.doc_xml)
self.body = self.tree.find(qn('body'))
self._rev_id = 100
self._revision_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
self._author = 'WB'
self._rsid = '00AA0001'
# 提取原文格式(核心:避免每次猜错格式)
self._body_rpr = None # 正文格式(最常见的非加粗rPr)
self._title_rpr = None # 条款标题格式(加粗的rPr)
self._body_ppr = None
self._extract_formats()
def _extract_formats(self):
"""从原文提取正文和标题的rPr。
策略:
- 正文格式:统计所有run的rPr,取出现最多的非加粗rPr
- 标题格式:优先从条款编号标题段落(如"7.索赔条款")提取rPr,
而非简单取第一个加粗run(可能是合同大标题,字号不同)
- 如果条款标题不加粗,标题格式回退到正文格式"""
import re
rpr_map = {} # serialized_rpr -> (count, rpr_element)
clause_title_rpr = None # 从条款编号标题提取的格式
first_bold_rpr = None # 第一个加粗run的格式(fallback)
for p in self.body.findall(qn('p')):
# 获取段落全文,判断是否是条款编号标题(如 "7.索赔条款" "5.伴随服务")
p_text = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')).strip()
is_clause_title = bool(re.match(r'^\d+[..、]\s*\S', p_text)) and len(p_text) < 30
for r in p.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip() or len(txt) < 3:
continue
if rpr is not None:
is_bold = rpr.find(qn('b')) is not None
key = etree.tostring(rpr, encoding='unicode')
if is_bold and first_bold_rpr is None:
first_bold_rpr = rpr
# 优先从条款标题段落提取标题格式
if is_clause_title and clause_title_rpr is None:
clause_title_rpr = rpr
if not is_bold:
if key not in rpr_map:
rpr_map[key] = [0, rpr]
rpr_map[key][0] += 1
if self._body_ppr is None:
ppr = p.find(qn('pPr'))
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
if ppr is not None and len(txt) > 20:
self._body_ppr = ppr
if rpr_map:
best = max(rpr_map.values(), key=lambda x: x[0])
self._body_rpr = best[1]
# 标题格式优先级:条款编号标题 > 第一个加粗run > 正文格式
self._title_rpr = clause_title_rpr or first_bold_rpr or self._body_rpr
if self._title_rpr is None and self._body_rpr is not None:
self._title_rpr = copy.deepcopy(self._body_rpr)
etree.SubElement(self._title_rpr, qn('b'))
def _next_id(self):
self._rev_id += 1
return str(self._rev_id)
def _mk_del(self, text, rpr=None):
d = etree.Element(qn('del'))
d.set(qn('id'), self._next_id())
d.set(qn('author'), self._author)
d.set(qn('date'), self._revision_date)
r = etree.SubElement(d, qn('r'))
r.set(qn('rsidDel'), self._rsid)
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('delText'))
t.set(XML_SPACE, 'preserve')
t.text = text
return d
def _mk_ins(self, text, rpr=None):
i = etree.Element(qn('ins'))
i.set(qn('id'), self._next_id())
i.set(qn('author'), self._author)
i.set(qn('date'), self._revision_date)
r = etree.SubElement(i, qn('r'))
r.set(qn('rsidR'), self._rsid)
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
return i
def _mk_run(self, text, rpr=None):
r = etree.Element(qn('r'))
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
return r
def get_para_text(self, p):
"""获取段落的原始文本(不含删除标记中的文本)"""
return ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
def find_para(self, search_text):
"""查找包含指定文本的段落"""
for p in self.body.findall(qn('p')):
if search_text in self.get_para_text(p):
return p
return None
def tracked_replace(self, old_text, new_text):
"""在整个文档中查找old_text并用修订模式替换为new_text。
使用字符级tokenizer+difflib实现精准修订。
返回True如果成功。"""
for p in self.body.findall(qn('p')):
runs = p.findall(f'.//{qn("r")}')
if not runs:
continue
full = ''.join(
''.join(t.text or '' for t in r.findall(qn('t')))
for r in runs
)
if old_text not in full:
continue
start = full.index(old_text)
end = start + len(old_text)
# 获取匹配位置的rPr
rpr = None
pos = 0
for r in runs:
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
if pos + len(rt) > start:
rpr = r.find(qn('rPr'))
break
pos += len(rt)
# 生成diff元素
if new_text == '':
elems = [self._mk_del(old_text, rpr)]
else:
ot = cjk_tokenize(old_text)
nt = cjk_tokenize(new_text)
matcher = difflib.SequenceMatcher(None, ot, nt)
elems = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
elems.append(self._mk_run(''.join(ot[i1:i2]), rpr))
elif tag == 'delete':
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
elif tag == 'insert':
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
elif tag == 'replace':
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
# 定位受影响的runs并替换
pos = 0
first = last = None
prefix_text = suffix_text = ""
for idx, r in enumerate(runs):
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
run_end = pos + len(rt)
if run_end > start and pos < end:
if first is None:
first = idx
prefix_text = full[pos:start]
last = idx
suffix_text = full[end:run_end] if run_end > end else ""
pos = run_end
if first is None:
continue
ref = runs[first]
# Find the actual paragraph (w:p) element to insert into
para_elem = p
# Determine insert position: find ref or its ancestor that is a direct child of p
ref_ancestor = ref
while ref_ancestor.getparent() is not para_elem and ref_ancestor.getparent() is not None:
ref_ancestor = ref_ancestor.getparent()
insert_pos = list(para_elem).index(ref_ancestor)
# Remove runs (each from its own parent)
for idx in range(last, first - 1, -1):
r = runs[idx]
r_parent = r.getparent()
r_parent.remove(r)
# If parent (e.g. w:ins) is now empty, remove it too
if r_parent is not para_elem and len(r_parent) == 0:
gp = r_parent.getparent()
if gp is not None:
gp.remove(r_parent)
ip = insert_pos
if prefix_text:
para_elem.insert(ip, self._mk_run(prefix_text, rpr))
ip += 1
for e in elems:
para_elem.insert(ip, e)
ip += 1
if suffix_text:
para_elem.insert(ip, self._mk_run(suffix_text, rpr))
return True
return False
def _get_leading_whitespace(self, para):
"""从段落中提取前导空格/tab模式。
很多中文文档的缩进不是通过w:ind实现的,而是通过文本中的空格字符。"""
for r in para.findall(qn('r')):
# Skip deleted runs
if r.getparent().tag == qn('del'):
continue
for t in r.findall(qn('t')):
if t.text:
# Extract leading whitespace
stripped = t.text.lstrip()
if stripped: # Has actual content after whitespace
return t.text[:len(t.text) - len(stripped)]
elif t.text.isspace(): # Entire run is whitespace
return t.text
return ''
def add_clause(self, full_text, after_search, use_title_format=False):
"""在包含after_search的段落之后插入新条款段落。
full_text: 新条款全文
after_search: 在包含此文本的段落之后插入
use_title_format: True=标题格式(加粗),False=正文格式
"""
ref_para = self.find_para(after_search)
if ref_para is None:
return False
rpr = self._title_rpr if use_title_format else self._body_rpr
ppr = ref_para.find(qn('pPr')) or self._body_ppr
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws and not full_text.startswith(leading_ws):
full_text = leading_ws + full_text
new_p = etree.Element(qn('p'))
if ppr is not None:
new_p.append(copy.deepcopy(ppr))
new_p.append(self._mk_ins(full_text, rpr))
idx = list(self.body).index(ref_para)
self.body.insert(idx + 1, new_p)
return True
def add_clause_before(self, full_text, before_search, use_title_format=False):
"""在包含before_search的段落之前插入新条款段落。"""
ref_para = self.find_para(before_search)
if ref_para is None:
return False
rpr = self._title_rpr if use_title_format else self._body_rpr
ppr = ref_para.find(qn('pPr')) or self._body_ppr
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws and not full_text.startswith(leading_ws):
full_text = leading_ws + full_text
new_p = etree.Element(qn('p'))
if ppr is not None:
new_p.append(copy.deepcopy(ppr))
new_p.append(self._mk_ins(full_text, rpr))
idx = list(self.body).index(ref_para)
self.body.insert(idx, new_p)
return True
def add_mixed_clause(self, title_text, content_text, after_search):
"""插入标题加粗+内容不加粗的新条款(两个段落)。
用于原文标题和内容分行的合同格式。"""
ref_para = self.find_para(after_search)
if ref_para is None:
return False
ppr = ref_para.find(qn('pPr')) or self._body_ppr
idx = list(self.body).index(ref_para)
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws:
if not title_text.startswith(leading_ws):
title_text = leading_ws + title_text
if not content_text.startswith(leading_ws):
content_text = leading_ws + content_text
p_title = etree.Element(qn('p'))
if ppr: p_title.append(copy.deepcopy(ppr))
p_title.append(self._mk_ins(title_text, self._title_rpr))
self.body.insert(idx + 1, p_title)
p_content = etree.Element(qn('p'))
if ppr: p_content.append(copy.deepcopy(ppr))
p_content.append(self._mk_ins(content_text, self._body_rpr))
self.body.insert(idx + 2, p_content)
return True
def renumber_clause(self, old_num, new_num):
"""把条款编号从old_num改为new_num(修订模式)。
从后往前扫描,避免重复修改。"""
changed = 0
for p in reversed(self.body.findall(qn('p'))):
runs = p.findall(f'.//{qn("r")}')
for r in runs:
for t in r.findall(qn('t')):
if t.text and old_num in t.text:
rpr_e = r.find(qn('rPr'))
parent = r.getparent()
idx_r = list(parent).index(r)
pos = t.text.index(old_num)
prefix = t.text[:pos]
suffix = t.text[pos + len(old_num):]
parent.remove(r)
ip = idx_r
if prefix:
parent.insert(ip, self._mk_run(prefix, rpr_e))
ip += 1
parent.insert(ip, self._mk_del(old_num, rpr_e))
ip += 1
parent.insert(ip, self._mk_ins(new_num, rpr_e))
ip += 1
if suffix:
parent.insert(ip, self._mk_run(suffix, rpr_e))
changed += 1
break
return changed
def renumber_range(self, start, shift=1):
"""从start开始,所有现有条款编号+shift。从后往前处理。
注意:先调用此方法腾出编号空间,再插入新条款。
例:要在18后插入新19条:
editor.renumber_range(19, 1) # 19→20, 20→21, 21→22
editor.add_clause_before("19.新条款内容", before_search="20.合同生效")
"""
max_num = 0
for p in self.body.findall(qn('p')):
txt = self.get_para_text(p)
for m in re.finditer(r'(\d+)[..]', txt):
n = int(m.group(1))
if n > max_num:
max_num = n
for n in range(max_num, start - 1, -1):
self.renumber_clause(f'{n}', f'{n + shift}')
self.renumber_clause(f'{n}.', f'{n + shift}.')
def renumber_chinese(self, old_cn, new_cn):
"""中文编号顺延,如 "第十三条""第十四条""""
return self.renumber_clause(old_cn, new_cn)
def validate(self):
"""交付前验证。返回错误列表,空列表=通过。"""
errors = []
# 1. 编号连续性
clause_nums = []
for p in self.body.findall(qn('p')):
accepted = ''
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
elif tag == 'ins':
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
m = re.match(r'^(\d+)[..]', accepted.strip())
if m:
clause_nums.append(int(m.group(1)))
main_clauses = sorted(set(clause_nums))
for i in range(1, len(main_clauses)):
if main_clauses[i] - main_clauses[i-1] > 1:
errors.append(f"编号跳跃: {main_clauses[i-1]}{main_clauses[i]},缺少{main_clauses[i-1]+1}")
# 2. 字号一致性(WB的ins内容 vs 原文正文)
if self._body_rpr is not None:
body_sz = None
sz_elem = self._body_rpr.find(qn('sz'))
if sz_elem is not None:
body_sz = sz_elem.get(qn('val'))
if body_sz:
for ins in self.tree.findall(f'.//{qn("ins")}'):
if ins.get(qn('author')) != self._author:
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip():
continue
if rpr is not None:
ins_sz = rpr.find(qn('sz'))
if ins_sz is not None:
val = ins_sz.get(qn('val'))
is_bold = rpr.find(qn('b')) is not None
if val != body_sz and not is_bold:
errors.append(f"字号不一致: ins sz={val} vs 原文sz={body_sz}'{txt[:30]}'")
# 3. 加粗规则(内容不应加粗)
for ins in self.tree.findall(f'.//{qn("ins")}'):
if ins.get(qn('author')) != self._author:
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip() or len(txt.strip()) < 5:
continue
is_bold = rpr is not None and rpr.find(qn('b')) is not None
is_clause_title = bool(re.match(r'^\d+[..]\S', txt.strip())) or bool(re.match(r'^第.{1,3}条', txt.strip())) or bool(re.match(r'^[一二三四五六七八九十]{1,3}、', txt.strip()))
if is_bold and not is_clause_title:
errors.append(f"不应加粗: '{txt[:40]}'")
return errors
def dump_numbering(self):
"""输出accepted view的编号序列,用于人工确认"""
result = []
for p in self.body.findall(qn('p')):
accepted = ''
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
elif tag == 'ins':
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
m = re.match(r'^(\d+)[..]', accepted.strip())
if m:
result.append(f"{m.group(1)}. {accepted.strip()[:60]}")
return result
def save(self, output_path):
"""保存修订后的文件"""
new_doc_xml = etree.tostring(self.tree, xml_declaration=True,
encoding='UTF-8', standalone=True)
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
settings = z.read('word/settings.xml')
stree = etree.fromstring(settings)
if stree.find(f'.//{qn("trackRevisions")}') is None:
stree.append(etree.Element(qn('trackRevisions')))
new_settings = etree.tostring(stree, xml_declaration=True,
encoding='UTF-8', standalone=True)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item, new_doc_xml)
elif item.filename == 'word/settings.xml':
zout.writestr(item, new_settings)
else:
zout.writestr(item, zin.read(item.filename))
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
return output_path
class ZhujiajaoOpinion:
"""朱家角审查意见表格填写器。严格使用模板结构,不自创格式。"""
TEMPLATE_PATH = Path.home() / ".hermes/shared/模版库/朱家角 审查意见【模板】.docx"
def __init__(self, template_path=None):
tpath = Path(template_path) if template_path else self.TEMPLATE_PATH
with open(tpath, 'rb') as f:
self.tmpl_bytes = f.read()
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as z:
self.doc_xml = z.read('word/document.xml')
self.tree = etree.fromstring(self.doc_xml)
self.body = self.tree.find(qn('body'))
def fill(self, contract_name, items, has_modifications=True):
"""填写审查意见。
contract_name: 合同名称(填入标题《》中间)
items: [(条文位置, 原文, 修订后), ...]
has_modifications: False则保留"无法律修改意见"
"""
# 1. 填标题——找到空格run替换
for p in self.body.findall(qn('p')):
runs = p.findall(f'.//{qn("r")}')
for r in runs:
for t in r.findall(qn('t')):
if t.text and t.text.strip() == '' and len(t.text) >= 2:
parent_txt = ''.join(
tt.text or '' for rr in runs for tt in rr.findall(qn('t'))
)
if '关于《' in parent_txt:
t.text = contract_name
# 2. 处理"无法律修改意见"
if has_modifications:
for p in self.body.findall(qn('p')):
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
if '无法律修改意见' in txt:
for r in p.findall(f'.//{qn("r")}'):
for t in r.findall(qn('t')):
if '无法律修改意见' in (t.text or ''):
t.text = ''
# 3. 填表格
if not items:
return
tbl = self.body.find(qn('tbl'))
if tbl is None:
return
rows = tbl.findall(qn('tr'))
# Row 0 = header, Row 1+ = data rows
# 获取表头rPr
header_rpr = None
for hc in rows[0].findall(qn('tc')):
for hr in hc.findall(f'.//{qn("r")}'):
rr = hr.find(qn('rPr'))
if rr:
header_rpr = rr
break
if header_rpr:
break
# 确保有足够数据行
template_row = rows[1] if len(rows) > 1 else None
while len(tbl.findall(qn('tr'))) - 1 < len(items):
if template_row is not None:
tbl.append(copy.deepcopy(template_row))
rows = tbl.findall(qn('tr'))
# 填写数据
for i, (clause, orig_text, modified_text) in enumerate(items):
if i + 1 >= len(rows):
break
row = rows[i + 1]
cells = row.findall(qn('tc'))
if len(cells) < 3:
continue
for ci, text in enumerate([clause, orig_text, modified_text]):
cell = cells[ci]
p = cell.find(qn('p'))
if p is None:
p = etree.SubElement(cell, qn('p'))
for r in p.findall(qn('r')):
p.remove(r)
r = etree.SubElement(p, qn('r'))
if header_rpr:
new_rpr = copy.deepcopy(header_rpr)
b = new_rpr.find(qn('b'))
if b is not None:
new_rpr.remove(b)
if '注:' in text:
color = new_rpr.find(qn('color'))
if color is None:
color = etree.SubElement(new_rpr, qn('color'))
color.set(qn('val'), 'FF0000')
r.append(new_rpr)
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
# 删除多余空行
rows = tbl.findall(qn('tr'))
for i in range(len(rows) - 1, len(items), -1):
tbl.remove(rows[i])
def save(self, output_path):
new_doc = etree.tostring(self.tree, xml_declaration=True,
encoding='UTF-8', standalone=True)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item, new_doc)
else:
zout.writestr(item, zin.read(item.filename))
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
return output_path
@@ -0,0 +1,684 @@
#!/usr/bin/env python3
"""
contract_docx_lib.py — 合同修订核心库
固化验证通过的docx XML操作,不再每次重写。
用法:
from contract_docx_lib import ContractEditor
editor = ContractEditor("原文件.docx")
editor.tracked_replace("原文片段", "新文片段")
editor.add_clause("19.服务成果持续使用权", "条款内容...", after_clause=18)
editor.renumber(19, 20) # 原19→20
errors = editor.validate()
if not errors:
editor.save("【修】原文件.docx")
关键操作顺序(renumber和新增条款):
1. 先做所有 tracked_replace(文本修改)
2. 再做 add_clause(新增子条款,如15.4)
3. 再做 renumber_range(先腾出编号空间)
4. 最后做 add_clause_before(插入新主条款,用已腾出的编号)
5. validate() 验证
6. save() 保存
"""
import zipfile, io, copy, re, difflib
from lxml import etree
from datetime import datetime
from pathlib import Path
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
WNS = '{' + W + '}'
def qn(tag):
return f'{WNS}{tag}'
def cjk_tokenize(text):
"""CJK每字一token,ASCII连续一token,标点单独token。
经验证的分词策略,不要改。"""
tokens = []
i = 0
while i < len(text):
ch = text[i]
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or ch in ',。、;:!?""''()【】《》—…·[]%%':
tokens.append(ch)
i += 1
elif ch.isascii() and ch.isalnum():
j = i
while j < len(text) and text[j].isascii() and text[j].isalnum():
j += 1
tokens.append(text[i:j])
i = j
else:
tokens.append(ch)
i += 1
return tokens
class ContractEditor:
"""合同修订编辑器。一个实例对应一份合同文件。"""
def __init__(self, filepath):
self.filepath = Path(filepath)
with open(filepath, 'rb') as f:
self.original_bytes = f.read()
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
self.doc_xml = z.read('word/document.xml')
self.tree = etree.fromstring(self.doc_xml)
self.body = self.tree.find(qn('body'))
self._rev_id = 100
self._revision_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
self._author = 'WB'
self._rsid = '00AA0001'
# 提取原文格式(核心:避免每次猜错格式)
self._body_rpr = None # 正文格式(最常见的非加粗rPr)
self._title_rpr = None # 条款标题格式(加粗的rPr)
self._body_ppr = None
self._extract_formats()
def _extract_formats(self):
"""从原文提取正文和标题的rPr。
策略:
- 正文格式:统计所有run的rPr,取出现最多的非加粗rPr
- 标题格式:优先从条款编号标题段落(如"7.索赔条款")提取rPr,
而非简单取第一个加粗run(可能是合同大标题,字号不同)
- 如果条款标题不加粗,标题格式回退到正文格式"""
import re
rpr_map = {} # serialized_rpr -> (count, rpr_element)
clause_title_rpr = None # 从条款编号标题提取的格式
first_bold_rpr = None # 第一个加粗run的格式(fallback)
for p in self.body.findall(qn('p')):
# 获取段落全文,判断是否是条款编号标题(如 "7.索赔条款" "5.伴随服务")
p_text = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')).strip()
is_clause_title = bool(re.match(r'^\d+[..、]\s*\S', p_text)) and len(p_text) < 30
for r in p.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip() or len(txt) < 3:
continue
if rpr is not None:
is_bold = rpr.find(qn('b')) is not None
key = etree.tostring(rpr, encoding='unicode')
if is_bold and first_bold_rpr is None:
first_bold_rpr = rpr
# 优先从条款标题段落提取标题格式
if is_clause_title and clause_title_rpr is None:
clause_title_rpr = rpr
if not is_bold:
if key not in rpr_map:
rpr_map[key] = [0, rpr]
rpr_map[key][0] += 1
if self._body_ppr is None:
ppr = p.find(qn('pPr'))
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
if ppr is not None and len(txt) > 20:
self._body_ppr = ppr
if rpr_map:
best = max(rpr_map.values(), key=lambda x: x[0])
self._body_rpr = best[1]
# 标题格式优先级:条款编号标题 > 第一个加粗run > 正文格式
self._title_rpr = clause_title_rpr or first_bold_rpr or self._body_rpr
if self._title_rpr is None and self._body_rpr is not None:
self._title_rpr = copy.deepcopy(self._body_rpr)
etree.SubElement(self._title_rpr, qn('b'))
def _next_id(self):
self._rev_id += 1
return str(self._rev_id)
def _mk_del(self, text, rpr=None):
d = etree.Element(qn('del'))
d.set(qn('id'), self._next_id())
d.set(qn('author'), self._author)
d.set(qn('date'), self._revision_date)
r = etree.SubElement(d, qn('r'))
r.set(qn('rsidDel'), self._rsid)
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('delText'))
t.set(XML_SPACE, 'preserve')
t.text = text
return d
def _mk_ins(self, text, rpr=None):
i = etree.Element(qn('ins'))
i.set(qn('id'), self._next_id())
i.set(qn('author'), self._author)
i.set(qn('date'), self._revision_date)
r = etree.SubElement(i, qn('r'))
r.set(qn('rsidR'), self._rsid)
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
return i
def _mk_run(self, text, rpr=None):
r = etree.Element(qn('r'))
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
return r
def get_para_text(self, p):
"""获取段落的原始文本(不含删除标记中的文本)"""
return ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
def find_para(self, search_text):
"""查找包含指定文本的段落"""
for p in self.body.findall(qn('p')):
if search_text in self.get_para_text(p):
return p
return None
def tracked_replace(self, old_text, new_text):
"""在整个文档中查找old_text并用修订模式替换为new_text。
使用字符级tokenizer+difflib实现精准修订。
返回True如果成功。"""
for p in self.body.findall(qn('p')):
runs = p.findall(f'.//{qn("r")}')
if not runs:
continue
full = ''.join(
''.join(t.text or '' for t in r.findall(qn('t')))
for r in runs
)
if old_text not in full:
continue
start = full.index(old_text)
end = start + len(old_text)
# 获取匹配位置的rPr
rpr = None
pos = 0
for r in runs:
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
if pos + len(rt) > start:
rpr = r.find(qn('rPr'))
break
pos += len(rt)
# 生成diff元素
if new_text == '':
elems = [self._mk_del(old_text, rpr)]
else:
ot = cjk_tokenize(old_text)
nt = cjk_tokenize(new_text)
matcher = difflib.SequenceMatcher(None, ot, nt)
elems = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
elems.append(self._mk_run(''.join(ot[i1:i2]), rpr))
elif tag == 'delete':
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
elif tag == 'insert':
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
elif tag == 'replace':
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
# 定位受影响的runs并替换
pos = 0
first = last = None
prefix_text = suffix_text = ""
for idx, r in enumerate(runs):
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
run_end = pos + len(rt)
if run_end > start and pos < end:
if first is None:
first = idx
prefix_text = full[pos:start]
last = idx
suffix_text = full[end:run_end] if run_end > end else ""
pos = run_end
if first is None:
continue
ref = runs[first]
# Find the actual paragraph (w:p) element to insert into
para_elem = p
# Determine insert position: find ref or its ancestor that is a direct child of p
ref_ancestor = ref
while ref_ancestor.getparent() is not para_elem and ref_ancestor.getparent() is not None:
ref_ancestor = ref_ancestor.getparent()
insert_pos = list(para_elem).index(ref_ancestor)
# Remove runs (each from its own parent)
for idx in range(last, first - 1, -1):
r = runs[idx]
r_parent = r.getparent()
r_parent.remove(r)
# If parent (e.g. w:ins) is now empty, remove it too
if r_parent is not para_elem and len(r_parent) == 0:
gp = r_parent.getparent()
if gp is not None:
gp.remove(r_parent)
ip = insert_pos
if prefix_text:
para_elem.insert(ip, self._mk_run(prefix_text, rpr))
ip += 1
for e in elems:
para_elem.insert(ip, e)
ip += 1
if suffix_text:
para_elem.insert(ip, self._mk_run(suffix_text, rpr))
return True
return False
def _get_leading_whitespace(self, para):
"""从段落中提取前导空格/tab模式。
很多中文文档的缩进不是通过w:ind实现的,而是通过文本中的空格字符。"""
for r in para.findall(qn('r')):
# Skip deleted runs
if r.getparent().tag == qn('del'):
continue
for t in r.findall(qn('t')):
if t.text:
# Extract leading whitespace
stripped = t.text.lstrip()
if stripped: # Has actual content after whitespace
return t.text[:len(t.text) - len(stripped)]
elif t.text.isspace(): # Entire run is whitespace
return t.text
return ''
def add_clause(self, full_text, after_search, use_title_format=False):
"""在包含after_search的段落之后插入新条款段落。
full_text: 新条款全文
after_search: 在包含此文本的段落之后插入
use_title_format: True=标题格式(加粗),False=正文格式
"""
ref_para = self.find_para(after_search)
if ref_para is None:
return False
rpr = self._title_rpr if use_title_format else self._body_rpr
ppr = ref_para.find(qn('pPr')) or self._body_ppr
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws and not full_text.startswith(leading_ws):
full_text = leading_ws + full_text
new_p = etree.Element(qn('p'))
if ppr is not None:
new_p.append(copy.deepcopy(ppr))
new_p.append(self._mk_ins(full_text, rpr))
idx = list(self.body).index(ref_para)
self.body.insert(idx + 1, new_p)
return True
def add_clause_before(self, full_text, before_search, use_title_format=False):
"""在包含before_search的段落之前插入新条款段落。"""
ref_para = self.find_para(before_search)
if ref_para is None:
return False
rpr = self._title_rpr if use_title_format else self._body_rpr
ppr = ref_para.find(qn('pPr')) or self._body_ppr
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws and not full_text.startswith(leading_ws):
full_text = leading_ws + full_text
new_p = etree.Element(qn('p'))
if ppr is not None:
new_p.append(copy.deepcopy(ppr))
new_p.append(self._mk_ins(full_text, rpr))
idx = list(self.body).index(ref_para)
self.body.insert(idx, new_p)
return True
def add_mixed_clause(self, title_text, content_text, after_search):
"""插入标题加粗+内容不加粗的新条款(两个段落)。
用于原文标题和内容分行的合同格式。"""
ref_para = self.find_para(after_search)
if ref_para is None:
return False
ppr = ref_para.find(qn('pPr')) or self._body_ppr
idx = list(self.body).index(ref_para)
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws:
if not title_text.startswith(leading_ws):
title_text = leading_ws + title_text
if not content_text.startswith(leading_ws):
content_text = leading_ws + content_text
p_title = etree.Element(qn('p'))
if ppr: p_title.append(copy.deepcopy(ppr))
p_title.append(self._mk_ins(title_text, self._title_rpr))
self.body.insert(idx + 1, p_title)
p_content = etree.Element(qn('p'))
if ppr: p_content.append(copy.deepcopy(ppr))
p_content.append(self._mk_ins(content_text, self._body_rpr))
self.body.insert(idx + 2, p_content)
return True
def renumber_clause(self, old_num, new_num):
"""把条款编号从old_num改为new_num(修订模式)。
从后往前扫描,避免重复修改。"""
changed = 0
for p in reversed(self.body.findall(qn('p'))):
runs = p.findall(f'.//{qn("r")}')
for r in runs:
for t in r.findall(qn('t')):
if t.text and old_num in t.text:
rpr_e = r.find(qn('rPr'))
parent = r.getparent()
idx_r = list(parent).index(r)
pos = t.text.index(old_num)
prefix = t.text[:pos]
suffix = t.text[pos + len(old_num):]
parent.remove(r)
ip = idx_r
if prefix:
parent.insert(ip, self._mk_run(prefix, rpr_e))
ip += 1
parent.insert(ip, self._mk_del(old_num, rpr_e))
ip += 1
parent.insert(ip, self._mk_ins(new_num, rpr_e))
ip += 1
if suffix:
parent.insert(ip, self._mk_run(suffix, rpr_e))
changed += 1
break
return changed
def renumber_range(self, start, shift=1):
"""从start开始,所有现有条款编号+shift。从后往前处理。
注意:先调用此方法腾出编号空间,再插入新条款。
例:要在18后插入新19条:
editor.renumber_range(19, 1) # 19→20, 20→21, 21→22
editor.add_clause_before("19.新条款内容", before_search="20.合同生效")
"""
max_num = 0
for p in self.body.findall(qn('p')):
txt = self.get_para_text(p)
for m in re.finditer(r'(\d+)[..]', txt):
n = int(m.group(1))
if n > max_num:
max_num = n
for n in range(max_num, start - 1, -1):
self.renumber_clause(f'{n}.', f'{n + shift}.')
self.renumber_clause(f'{n}.', f'{n + shift}.')
def renumber_chinese(self, old_cn, new_cn):
"""中文编号顺延,如 "第十三条" → "第十四条"。"""
return self.renumber_clause(old_cn, new_cn)
def validate(self):
"""交付前验证。返回错误列表,空列表=通过。"""
errors = []
# 1. 编号连续性
clause_nums = []
for p in self.body.findall(qn('p')):
accepted = ''
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
elif tag == 'ins':
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
m = re.match(r'^(\d+)[..]', accepted.strip())
if m:
clause_nums.append(int(m.group(1)))
main_clauses = sorted(set(clause_nums))
for i in range(1, len(main_clauses)):
if main_clauses[i] - main_clauses[i-1] > 1:
errors.append(f"编号跳跃: {main_clauses[i-1]}→{main_clauses[i]},缺少{main_clauses[i-1]+1}")
# 2. 字号一致性(WB的ins内容 vs 原文正文)
if self._body_rpr is not None:
body_sz = None
sz_elem = self._body_rpr.find(qn('sz'))
if sz_elem is not None:
body_sz = sz_elem.get(qn('val'))
if body_sz:
for ins in self.tree.findall(f'.//{qn("ins")}'):
if ins.get(qn('author')) != self._author:
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip():
continue
if rpr is not None:
ins_sz = rpr.find(qn('sz'))
if ins_sz is not None:
val = ins_sz.get(qn('val'))
is_bold = rpr.find(qn('b')) is not None
if val != body_sz and not is_bold:
errors.append(f"字号不一致: ins sz={val} vs 原文sz={body_sz},'{txt[:30]}'")
# 3. 加粗规则(内容不应加粗)
for ins in self.tree.findall(f'.//{qn("ins")}'):
if ins.get(qn('author')) != self._author:
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip() or len(txt.strip()) < 5:
continue
is_bold = rpr is not None and rpr.find(qn('b')) is not None
is_clause_title = bool(re.match(r'^\d+[..]\S', txt.strip())) or bool(re.match(r'^第.{1,3}条', txt.strip()))
if is_bold and not is_clause_title:
errors.append(f"不应加粗: '{txt[:40]}'")
return errors
def dump_numbering(self):
"""输出accepted view的编号序列,用于人工确认"""
result = []
for p in self.body.findall(qn('p')):
accepted = ''
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
elif tag == 'ins':
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
m = re.match(r'^(\d+)[..]', accepted.strip())
if m:
result.append(f"{m.group(1)}. {accepted.strip()[:60]}")
return result
def save(self, output_path):
"""保存修订后的文件"""
new_doc_xml = etree.tostring(self.tree, xml_declaration=True,
encoding='UTF-8', standalone=True)
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
settings = z.read('word/settings.xml')
stree = etree.fromstring(settings)
if stree.find(f'.//{qn("trackRevisions")}') is None:
stree.append(etree.Element(qn('trackRevisions')))
new_settings = etree.tostring(stree, xml_declaration=True,
encoding='UTF-8', standalone=True)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item, new_doc_xml)
elif item.filename == 'word/settings.xml':
zout.writestr(item, new_settings)
else:
zout.writestr(item, zin.read(item.filename))
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
return output_path
class ZhujiajaoOpinion:
"""朱家角审查意见表格填写器。严格使用模板结构,不自创格式。"""
TEMPLATE_PATH = Path.home() / ".hermes/shared/模版库/朱家角 审查意见【模板】.docx"
def __init__(self, template_path=None):
tpath = Path(template_path) if template_path else self.TEMPLATE_PATH
with open(tpath, 'rb') as f:
self.tmpl_bytes = f.read()
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as z:
self.doc_xml = z.read('word/document.xml')
self.tree = etree.fromstring(self.doc_xml)
self.body = self.tree.find(qn('body'))
def fill(self, contract_name, items, has_modifications=True):
"""填写审查意见。
contract_name: 合同名称(填入标题《》中间)
items: [(条文位置, 原文, 修订后), ...]
has_modifications: False则保留"无法律修改意见"
"""
# 1. 填标题——找到空格run替换
for p in self.body.findall(qn('p')):
runs = p.findall(f'.//{qn("r")}')
for r in runs:
for t in r.findall(qn('t')):
if t.text and t.text.strip() == '' and len(t.text) >= 2:
parent_txt = ''.join(
tt.text or '' for rr in runs for tt in rr.findall(qn('t'))
)
if '关于《' in parent_txt:
t.text = contract_name
# 2. 处理"无法律修改意见"
if has_modifications:
for p in self.body.findall(qn('p')):
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
if '无法律修改意见' in txt:
for r in p.findall(f'.//{qn("r")}'):
for t in r.findall(qn('t')):
if '无法律修改意见' in (t.text or ''):
t.text = ''
# 3. 填表格
if not items:
return
tbl = self.body.find(qn('tbl'))
if tbl is None:
return
rows = tbl.findall(qn('tr'))
# Row 0 = header, Row 1+ = data rows
# 获取表头rPr
header_rpr = None
for hc in rows[0].findall(qn('tc')):
for hr in hc.findall(f'.//{qn("r")}'):
rr = hr.find(qn('rPr'))
if rr:
header_rpr = rr
break
if header_rpr:
break
# 确保有足够数据行
template_row = rows[1] if len(rows) > 1 else None
while len(tbl.findall(qn('tr'))) - 1 < len(items):
if template_row is not None:
tbl.append(copy.deepcopy(template_row))
rows = tbl.findall(qn('tr'))
# 填写数据
for i, (clause, orig_text, modified_text) in enumerate(items):
if i + 1 >= len(rows):
break
row = rows[i + 1]
cells = row.findall(qn('tc'))
if len(cells) < 3:
continue
for ci, text in enumerate([clause, orig_text, modified_text]):
cell = cells[ci]
p = cell.find(qn('p'))
if p is None:
p = etree.SubElement(cell, qn('p'))
for r in p.findall(qn('r')):
p.remove(r)
r = etree.SubElement(p, qn('r'))
if header_rpr:
new_rpr = copy.deepcopy(header_rpr)
b = new_rpr.find(qn('b'))
if b is not None:
new_rpr.remove(b)
if '注:' in text:
color = new_rpr.find(qn('color'))
if color is None:
color = etree.SubElement(new_rpr, qn('color'))
color.set(qn('val'), 'FF0000')
r.append(new_rpr)
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
# 删除多余空行
rows = tbl.findall(qn('tr'))
for i in range(len(rows) - 1, len(items), -1):
tbl.remove(rows[i])
def save(self, output_path):
new_doc = etree.tostring(self.tree, xml_declaration=True,
encoding='UTF-8', standalone=True)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item, new_doc)
else:
zout.writestr(item, zin.read(item.filename))
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
return output_path
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
contract_preprocess.py — 合同预处理:检测并切割非审查图片内容
用途:在workflow审查前,检测合同末尾的纯图片附件(如招标公告截图、中标通知书等),
切割出来保存,审查完后再还原。
判断逻辑:
1. 扫描文件结构:文字段落数 vs 图片段落数
2. 全文/大部分是图片(扫描件合同)→ 不切割,标记需OCR
3. 正文文字+末尾图片附件 → 切割末尾图片区域
4. 切割点:从最后一个"纯文字附件"结束后,到第一个"纯图片附件"开始
输出:
- {basename}_stripped.docx — 去掉图片附件的版本(供workflow处理)
- {basename}_cutdata.json — 切割信息(供还原用)
"""
import zipfile, json, os, sys, re
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
def analyze_contract(docx_path):
"""Analyze contract structure, return analysis dict"""
with zipfile.ZipFile(docx_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
media_files = {n: z.getinfo(n).file_size for n in z.namelist() if n.startswith('word/media/')}
body = doc.find(f'{{{W}}}body')
paras = body.findall(f'{{{W}}}p')
paragraphs = []
total_text_chars = 0
total_img_paras = 0
for i, p in enumerate(paras):
texts = p.findall(f'.//{{{W}}}t')
text = ''.join(t.text or '' for t in texts).strip()
has_img = any('drawing' in (e.tag if isinstance(e.tag, str) else '') for e in p.iter())
blips = list(p.iter(f'{{{A_NS}}}blip'))
img_rids = [b.get(f'{{{R_NS}}}embed', '') for b in blips]
total_text_chars += len(text)
if has_img:
total_img_paras += 1
paragraphs.append({
'idx': i,
'text': text,
'text_len': len(text),
'has_img': has_img,
'img_rids': img_rids,
'is_appendix_heading': bool(re.match(r'^附件[一二三四五六七八九十\d]+[::、]', text)),
})
return {
'total_paras': len(paras),
'total_text_chars': total_text_chars,
'total_img_paras': total_img_paras,
'media_files': media_files,
'total_media_bytes': sum(media_files.values()),
'paragraphs': paragraphs,
}
def detect_cut_zone(analysis):
"""Detect if there's a tail image zone to cut."""
paras = analysis['paragraphs']
total = analysis['total_paras']
text_paras = sum(1 for p in paras if p['text_len'] > 0 and not p['has_img'])
img_paras = analysis['total_img_paras']
if text_paras == 0 and img_paras > 0:
return {'action': 'ocr', 'reason': '全文无文字段落,疑似扫描件合同'}
if img_paras == 0:
return None
img_ratio = img_paras / max(1, text_paras + img_paras)
if img_ratio > 0.5:
return {'action': 'ocr', 'reason': f'图片段落占比{img_ratio:.0%},疑似扫描件合同'}
# Find tail image zones
image_zones = []
i = 0
while i < total:
p = paras[i]
if p['is_appendix_heading']:
zone_start = i
zone_has_images = False
zone_has_text_content = False
j = i + 1
while j < total:
next_p = paras[j]
if next_p['is_appendix_heading']:
break
if next_p['has_img']:
zone_has_images = True
if next_p['text_len'] > 20 and not next_p['has_img']:
zone_has_text_content = True
j += 1
image_zones.append({
'start_idx': zone_start,
'end_idx': j - 1,
'heading': p['text'],
'has_images': zone_has_images,
'has_text': zone_has_text_content,
'is_image_only': zone_has_images and not zone_has_text_content,
})
i = j
else:
i += 1
# Find consecutive image-only appendices at the tail
tail_cut_zones = []
for zone in reversed(image_zones):
if zone['is_image_only']:
tail_cut_zones.insert(0, zone)
else:
break
if not tail_cut_zones:
return None
cut_start = tail_cut_zones[0]['start_idx']
cut_headings = [z['heading'] for z in tail_cut_zones]
return {
'action': 'cut',
'cut_start_idx': cut_start,
'cut_end_idx': total - 1,
'cut_headings': cut_headings,
'reason': f'末尾{len(tail_cut_zones)}个附件为纯图片:{", ".join(cut_headings)}',
}
def preprocess_contract(docx_path, output_dir=None):
"""Main entry: analyze and optionally strip tail images."""
if output_dir is None:
output_dir = os.path.dirname(docx_path) or '.'
basename = os.path.splitext(os.path.basename(docx_path))[0]
analysis = analyze_contract(docx_path)
cut_info = detect_cut_zone(analysis)
print(f"\n=== 合同预处理分析 ===")
print(f"文件: {os.path.basename(docx_path)}")
print(f"段落数: {analysis['total_paras']}")
print(f"文字字符: {analysis['total_text_chars']}")
print(f"图片段落: {analysis['total_img_paras']}")
print(f"媒体文件: {len(analysis['media_files'])} ({analysis['total_media_bytes']:,} bytes)")
if cut_info is None:
print(f"结论: 无需切割")
return {'action': 'none', 'analysis': analysis}
if cut_info['action'] == 'ocr':
print(f"结论: {cut_info['reason']},需OCR处理")
return {'action': 'ocr', 'reason': cut_info['reason'], 'analysis': analysis}
cut_start = cut_info['cut_start_idx']
print(f"结论: 需切割 — {cut_info['reason']}")
print(f"切割点: 段落 #{cut_start}")
with zipfile.ZipFile(docx_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
all_files = {}
for name in z.namelist():
all_files[name] = z.read(name)
body = doc.find(f'{{{W}}}body')
paras = body.findall(f'{{{W}}}p')
cut_paras_xml = []
for i in range(cut_start, len(paras)):
cut_paras_xml.append(etree.tostring(paras[i], encoding='unicode'))
for i in range(len(paras) - 1, cut_start - 1, -1):
body.remove(paras[i])
cut_rids = set()
for p_info in analysis['paragraphs'][cut_start:]:
cut_rids.update(p_info['img_rids'])
rels_xml = all_files.get('word/_rels/document.xml.rels', b'')
if isinstance(rels_xml, bytes):
rels_xml = rels_xml.decode()
rid_to_media = {}
for m in re.finditer(r'Id="(rId\d+)"[^/]*Target="(media/[^"]+)"', rels_xml):
rid_to_media[m.group(1)] = f'word/{m.group(2)}'
cut_media = {}
for rid in cut_rids:
media_path = rid_to_media.get(rid)
if media_path and media_path in all_files:
cut_media[media_path] = len(all_files[media_path])
stripped_path = os.path.join(output_dir, f'{basename}_stripped.docx')
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(stripped_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in all_files.items():
zout.writestr(name, data)
cutdata = {
'original_file': os.path.basename(docx_path),
'cut_start_idx': cut_start,
'total_paras_original': len(paras) + len(cut_paras_xml),
'cut_paragraphs_xml': cut_paras_xml,
'cut_headings': cut_info['cut_headings'],
'cut_media_files': list(cut_media.keys()),
'reason': cut_info['reason'],
}
cutdata_path = os.path.join(output_dir, f'{basename}_cutdata.json')
with open(cutdata_path, 'w', encoding='utf-8') as f:
json.dump(cutdata, f, ensure_ascii=False, indent=2)
stripped_size = os.path.getsize(stripped_path)
original_size = os.path.getsize(docx_path)
print(f"\n输出:")
print(f" stripped: {stripped_path} ({stripped_size:,} bytes)")
print(f" cutdata: {cutdata_path}")
print(f" 大小变化: {original_size:,}{stripped_size:,} bytes ({stripped_size/original_size:.0%})")
return {
'action': 'cut',
'stripped_path': stripped_path,
'cutdata_path': cutdata_path,
'cut_info': cut_info,
'analysis': analysis,
}
def restore_contract(reviewed_path, cutdata_path, output_path):
"""Restore cut content back into the reviewed file."""
with open(cutdata_path, 'r', encoding='utf-8') as f:
cutdata = json.load(f)
with zipfile.ZipFile(reviewed_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
all_files = {}
for name in z.namelist():
all_files[name] = z.read(name)
body = doc.find(f'{{{W}}}body')
sect_pr = body.find(f'{{{W}}}sectPr')
for para_xml in cutdata['cut_paragraphs_xml']:
para_elem = etree.fromstring(para_xml)
if sect_pr is not None:
sect_pr.addprevious(para_elem)
else:
body.append(para_elem)
original_dir = os.path.dirname(cutdata_path)
original_name = cutdata['original_file']
original_path = os.path.join(original_dir, original_name)
if os.path.exists(original_path):
with zipfile.ZipFile(original_path) as z_orig:
for media_file in cutdata.get('cut_media_files', []):
if media_file not in all_files and media_file in z_orig.namelist():
all_files[media_file] = z_orig.read(media_file)
print(f" 还原媒体文件: {media_file}")
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in all_files.items():
zout.writestr(name, data)
restored_size = os.path.getsize(output_path)
print(f"\n=== 合同还原完成 ===")
print(f"还原文件: {output_path} ({restored_size:,} bytes)")
print(f"还原段落: {len(cutdata['cut_paragraphs_xml'])}")
print(f"还原附件: {', '.join(cutdata['cut_headings'])}")
return output_path
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage:")
print(" 预处理: python contract_preprocess.py preprocess <input.docx> [output_dir]")
print(" 还原: python contract_preprocess.py restore <reviewed.docx> <cutdata.json> <output.docx>")
sys.exit(1)
action = sys.argv[1]
if action == 'preprocess':
docx_path = sys.argv[2]
output_dir = sys.argv[3] if len(sys.argv) > 3 else None
result = preprocess_contract(docx_path, output_dir)
print(f"\nResult: {json.dumps({k: v for k, v in result.items() if k != 'analysis'}, ensure_ascii=False, indent=2)}")
elif action == 'restore':
reviewed_path = sys.argv[2]
cutdata_path = sys.argv[3]
output_path = sys.argv[4]
restore_contract(reviewed_path, cutdata_path, output_path)
else:
print(f"Unknown action: {action}")
sys.exit(1)
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""编号链诊断探针 — 一次性看清 docx 的自动编号/手动编号全貌。
用途:合同编号疑似错乱(重复/跳号/双号)时,动手改之前必跑此脚本。
它把三件事一次性摊开,让你判断「是我们WB改错的 / 他人修订重排的 / 还是源文件自带的潜伏自动编号」:
1. 每个段落:是否带 <w:numPr>(自动编号)、numId、ilvl、是否整段ins/del
2. numbering.xml 解析:numId→abstractNum→(numFmt, lvlText, start) ——
⚠️ start≠1 的 decimal 列表会渲染出「6、」之类的可见编号,但 run 里没有这个字!
这是最隐蔽的坑:源文件起草人给某段挂了 numId(start=6),OnlyOffice 自动显示「6、售后服务」,
而你在末尾新增条款时只数了手打的「1 2 3 4 5」,顺手编成「6」→ 与潜伏的自动6撞号。
3. 每段「接受所有修订后」的可见文本(去w:del、保w:ins),近似 OnlyOffice 接受后视图
用法: python numbering-diagnose.py <contract.docx>
.doc 先转换: soffice --headless --convert-to docx <file>.doc
"""
import sys, zipfile
from lxml import etree
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
def text_mode(p, mode):
"""mode='final': 接受所有修订后(去del,保ins). mode='orig': 修订前(去ins,保del)."""
parts = []
for node in p.iter():
if node.tag == W + 't':
anc, skip = node, False
while anc is not None:
if mode == 'final' and anc.tag == W + 'del':
skip = True; break
if mode == 'orig' and anc.tag == W + 'ins':
skip = True; break
anc = anc.getparent()
if not skip:
parts.append(node.text or '')
elif node.tag == W + 'delText' and mode == 'orig':
parts.append(node.text or '')
return ''.join(parts).strip()
def parse_numbering(z):
"""返回 numId -> (numFmt, lvlText, start) 仅 lvl0(够用于条款标题层)."""
out = {}
if 'word/numbering.xml' not in z.namelist():
return out
num = etree.fromstring(z.read('word/numbering.xml'))
n2a = {}
for n in num.findall(W + 'num'):
ab = n.find(W + 'abstractNumId')
if ab is not None:
n2a[n.get(W + 'numId')] = ab.get(W + 'val')
a2fmt = {}
for ab in num.findall(W + 'abstractNum'):
l0 = ab.find(W + 'lvl')
if l0 is not None:
fmt = l0.find(W + 'numFmt')
txt = l0.find(W + 'lvlText')
st = l0.find(W + 'start')
a2fmt[ab.get(W + 'abstractNumId')] = (
fmt.get(W + 'val') if fmt is not None else '?',
txt.get(W + 'val') if txt is not None else '',
st.get(W + 'val') if st is not None else '1',
)
for nid, aid in n2a.items():
out[nid] = a2fmt.get(aid, ('?', '', '1'))
return out
def main(path):
z = zipfile.ZipFile(path)
root = etree.fromstring(z.read('word/document.xml'))
numinfo = parse_numbering(z)
print(f"### {path}\n")
print("=== numbering.xml: numId -> (numFmt, lvlText, start) ===")
if not numinfo:
print(" (无 numbering.xml — 全文应为手动文本编号)")
for nid, (fmt, txt, st) in sorted(numinfo.items()):
warn = ' ⚠️start≠1 会渲染潜伏编号!' if (fmt == 'decimal' and st != '1') else ''
print(f" numId={nid}: fmt={fmt}, lvlText='{txt}', start={st}{warn}")
print()
print("idx | numPr(自动) | rendered | ins/del | 文本(接受修订后)")
print("-" * 92)
for i, p in enumerate(root.findall('.//' + W + 'p')):
tf = text_mode(p, 'final')
if not tf:
continue
npr = p.find('.//' + W + 'numPr')
npinfo, rendered = '', ''
if npr is not None:
nid_el = npr.find(W + 'numId')
il_el = npr.find(W + 'ilvl')
nid = nid_el.get(W + 'val') if nid_el is not None else '?'
il = il_el.get(W + 'val') if il_el is not None else '0'
npinfo = f"numId={nid},lvl={il}"
fmt, txt, st = numinfo.get(nid, ('?', '', '1'))
if fmt == 'decimal':
rendered = (txt or '%1、').replace('%1', st) # 该项首个渲染值(近似)
elif fmt == 'bullet':
rendered = ''
elif fmt == 'none':
rendered = '(无)'
has_ins = p.find('.//' + W + 'ins') is not None
has_del = p.find('.//' + W + 'del') is not None
mk = ('INS' if has_ins else '') + ('/' if has_ins and has_del else '') + ('DEL' if has_del else '')
print(f"{i:3d} | {npinfo:18s} | {rendered:8s} | {mk:7s} | {tf[:46]}")
print()
print("判读要点:")
print(" - rendered 列非空 = OnlyOffice 会自动加这个编号(run里没有这串字)")
print(" - 手动编号: rendered='' 且文本以「N、」开头 = 编号是写死的文字")
print(" - 若末尾新增条款(INS)的手打编号 与 上方某段 rendered 自动编号 相同 → 撞号")
print(" 正确做法: 新增手打编号应接续【rendered 自动值】往下编, 不是接续最后一个手打数字")
if __name__ == '__main__':
if len(sys.argv) < 2:
print(__doc__); sys.exit(1)
main(sys.argv[1])
@@ -0,0 +1,34 @@
#!/bin/bash
# OnlyOffice x2t 渲染 docx → PDF
# 用途:用Maggie/Doro实际使用的渲染引擎(OnlyOffice)把合同docx渲染成PDF,
# 核对编号/格式的真实显示效果(与LibreOffice/python模拟可能不同,核对一律以此为准)。
# 用法: ./onlyoffice-render.sh /path/to/合同.docx [输出PDF路径]
# 不给输出路径时,默认输出到 同目录/同名.pdf
# 依赖: OnlyOffice容器 nextcloud-onlyoffice-1 在运行;x2t在容器内
# /var/www/onlyoffice/documentserver/server/FileConverter/bin/x2t
# 之后用: pdftotext -layout out.pdf - | grep -nE "^\s*[0-9]+、" 逐条数编号链
# pdftoppm -png -r 140 -f 1 -l 1 out.pdf prefix 转图发给Maggie确认
set -e
SRC="$1"
[ -z "$SRC" ] && { echo "用法: $0 <docx路径> [输出PDF]"; exit 1; }
OUT="${2:-${SRC%.docx}.pdf}"
CONTAINER=nextcloud-onlyoffice-1
TS=$(date +%s%N)
INNAME="/tmp/render_${TS}.docx"
OUTNAME="/tmp/render_${TS}.pdf"
CONVXML="/tmp/conv_${TS}.xml"
docker cp "$SRC" "${CONTAINER}:${INNAME}"
docker exec "$CONTAINER" bash -c "cat > ${CONVXML} << 'EOF'
<?xml version=\"1.0\" encoding=\"utf-8\"?>
<TaskQueueDataConvert xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">
<m_sFileFrom>${INNAME}</m_sFileFrom>
<m_sFileTo>${OUTNAME}</m_sFileTo>
<m_bIsNoBase64>true</m_bIsNoBase64>
</TaskQueueDataConvert>
EOF
cd /var/www/onlyoffice/documentserver/server/FileConverter/bin && ./x2t ${CONVXML} > /dev/null 2>&1 && echo x2t_done"
docker cp "${CONTAINER}:${OUTNAME}" "$OUT"
docker exec "$CONTAINER" rm -f "$INNAME" "$OUTNAME" "$CONVXML" 2>/dev/null || true
echo "渲染完成: $OUT"
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Post-save sweep: strip explicit attributes from WB INS runs when
the same-paragraph original runs rely on inheritance (ea=None, hint=None, sz=None).
Usage: python3 strip-inherited-ins-attrs.py <docx_path>
Modifies the file in place. Run AFTER ContractEditor.save() and BEFORE
wb-ins-font-verify.py to fix the known "ContractEditor默认sz=21与docDefaults继承冲突".
The pattern: for each paragraph containing WB INS, find the first plain w:r
(non-INS, non-DEL) as reference. If that reference run has no explicit
eastAsia/hint/sz, strip those from all WB INS runs in the same paragraph.
"""
import sys
import zipfile
import tempfile
import shutil
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
def strip_inherited_attrs(filepath):
with zipfile.ZipFile(filepath, 'r') as z:
doc_xml = z.read('word/document.xml')
all_files = {n: z.read(n) for n in z.namelist()}
tree = etree.fromstring(doc_xml)
body = tree.find(f'{WNS}body')
paras = body.findall(f'{WNS}p')
fixed = 0
for p in paras:
# Find first plain run as reference
orig_run = None
for child in p:
if child.tag == f'{WNS}r':
orig_run = child
break
if orig_run is None:
continue
orig_rpr = orig_run.find(f'{WNS}rPr')
orig_rf = orig_rpr.find(f'{WNS}rFonts') if orig_rpr is not None else None
orig_sz = orig_rpr.find(f'{WNS}sz') if orig_rpr is not None else None
orig_ea = orig_rf.get(f'{WNS}eastAsia') if orig_rf is not None else None
orig_hint = orig_rf.get(f'{WNS}hint') if orig_rf is not None else None
orig_sz_val = orig_sz.get(f'{WNS}val') if orig_sz is not None else None
for ins in p.findall(f'.//{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
continue
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is None:
continue
rf = rpr.find(f'{WNS}rFonts')
sz = rpr.find(f'{WNS}sz')
if orig_ea is None and rf is not None:
for attr in ['eastAsia', 'ascii', 'hAnsi']:
key = f'{WNS}{attr}'
if key in rf.attrib:
if orig_rf is None or orig_rf.get(key) is None:
del rf.attrib[key]
fixed += 1
if orig_hint is None and rf is not None and f'{WNS}hint' in rf.attrib:
del rf.attrib[f'{WNS}hint']
fixed += 1
if orig_sz_val is None and sz is not None:
rpr.remove(sz)
fixed += 1
# Save
tmp = tempfile.mktemp(suffix='.docx')
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
for name in all_files:
if name == 'word/document.xml':
new_xml = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
new_str = new_xml.decode('utf-8')
new_str = new_str.replace(
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
new_str = new_str.replace('\n', '\r\n')
zout.writestr(name, new_str.encode('utf-8'))
else:
zout.writestr(name, all_files[name])
shutil.move(tmp, filepath)
return fixed
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <docx_path>")
sys.exit(1)
n = strip_inherited_attrs(sys.argv[1])
print(f"Fixed {n} inherited attribute issues in {sys.argv[1]}")
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Unify all tracked change authors in a docx to 'WB'.
Usage: python unify-author-wb.py <input.docx> [output.docx]
If output is omitted, overwrites input.
Covers: w:ins, w:del, rPrChange, pPrChange, sectPrChange,
tblPrChange, trPrChange, tcPrChange.
Also fixes XML declaration (single→double quotes) for OnlyOffice compatibility.
"""
import sys, os, zipfile, re
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
CHANGE_TAGS = ('ins', 'del', 'rPrChange', 'pPrChange',
'sectPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange')
def unify_author(src_path, out_path=None):
if out_path is None:
out_path = src_path
tmp_path = out_path + '.tmp'
zin = zipfile.ZipFile(src_path, 'r')
doc_xml = zin.read('word/document.xml')
tree = etree.fromstring(doc_xml)
body = tree.find(f'{WNS}body')
changed = 0
for tag_suffix in CHANGE_TAGS:
for elem in body.iter(f'{WNS}{tag_suffix}'):
author = elem.get(f'{WNS}author')
if author and author != 'WB':
elem.set(f'{WNS}author', 'WB')
changed += 1
# Serialize + fix XML declaration
doc_bytes = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
doc_str = doc_bytes.decode('utf-8')
doc_str = doc_str.replace(
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.namelist():
if item == 'word/document.xml':
zout.writestr(item, doc_str.encode('utf-8'))
else:
zout.writestr(item, zin.read(item))
zin.close()
os.replace(tmp_path, out_path)
# Verify
z = zipfile.ZipFile(out_path)
vdoc = z.read('word/document.xml')
vtree = etree.fromstring(vdoc)
vbody = vtree.find(f'{WNS}body')
remaining = set()
for tag_suffix in CHANGE_TAGS:
for elem in vbody.iter(f'{WNS}{tag_suffix}'):
a = elem.get(f'{WNS}author', '')
if a != 'WB':
remaining.add(a)
z.close()
print(f"{changed} author attributes → WB")
if remaining:
print(f"⚠️ Remaining non-WB authors: {remaining}")
else:
print(f" All authors = WB")
print(f" Output: {out_path} ({os.path.getsize(out_path):,} bytes)")
if __name__ == '__main__':
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
src = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else None
unify_author(src, out)
@@ -0,0 +1,698 @@
---
name: contract-pass-workflow
description: 合同审查pass后的标准操作——更新tracker、更新xlsx清单、上传Nextcloud、清缓存。Doro说pass后照此执行。
version: 1.0.0
tags: [合同审查, pass, tracker, xlsx]
---
# 合同审查 Pass 后标准操作
## 交付文件位置(铁律,2026-06-29 Doro纠正)
所有交付文件统一放在 **`Doro合同审查任务/任务交付/`(根目录)**,不放顾问单位子文件夹。
-`Doro合同审查任务/任务交付/【修】合同_朱家角.docx`
-`Doro合同审查任务/朱家角镇社区卫生服务中心/任务交付/【修】合同_朱家角.docx`
workflow YAML deliverer 步骤(第210行)明确写的上传路径是根目录 `任务交付/`。如果 deliverer 或 final_review 把文件放到了子文件夹,pass 步骤0核对时必须发现并移到根目录。
## 步骤0核对要点
pass 步骤0的核对流程:
1. 取原始文件名(去掉【修】/【审】前缀)
2.`Doro合同审查任务/待审查/` 确认源文件存在
3. **确认交付文件在根目录 `任务交付/`**(不是子文件夹)
4. 顾问单位核对(从合同正文读甲方名称)
5. tracker 查重
## 红线:验证指令 ≠ 回忆指令(2026-07-02 信任危机后确立)
### 根因排查铁律:抛弃旧判断,先查“发生了什么变化”,再谈原因(2026-07-14 Doro纠正)
当 Doro 明确说“去查原因”“不是让你猜测、推断”“完全抛弃此前的判断,重新核查情况”时,后续动作必须切换为**变化核查模式**,而不是继续打磨措辞、修补上一版归因。
**强制步骤:**
1. **旧判断全部作废**:停止沿用“状态漂移”“异常”“习惯变差”“元控制失效”等任何解释性语言,除非已经有直接证据支持。
2. **先限定时间窗**:如果用户已给出时间边界(如“发生在 7 月 10 日前”),所有核查必须围绕该边界展开;边界外的改动先排除,不得混入结论。
3. **优先查“可观察变化”而不是“解释”**
- 配置文件修改时间与具体改动;
- workflow/queue/watchdog/notify 脚本的修改时间与新增逻辑;
- skill 规则文本的修改时间;
- gateway / auto-notify / watchdog 的退出、重启、恢复日志;
- tracker / queue / done / manifest 等状态文件是否出现结构变化。
4. **汇报格式必须是“已查到的变化 / 没查到的变化”**,不能把“更可能”“像是”“说明了”写成原因结论。
5. **只有在“变化事实”查清后,才允许进入第二步根因分析**;若变化事实尚未闭环,明确说“目前只查到这些变化,原因尚未下结论”。
**禁止事项:**
- 不断修改上一版判断的措辞,假装自己在继续调查;
- 用“异常”“偶发”“状态不好”“坏习惯”去解释持续两天、批量失效的问题;
- 把证据和推断混写成同一层结论;
- 用户要求查原因时,实际只做语言收缩而不做新核查。
**本会话教训:** Doro连续纠正“不是让你猜测、推断”“不是让你不断修改措辞”“要你完全抛弃此前判断,重新核查 7 月 10 日前发生了什么变化”。以后凡是原因排查任务,第一步不是解释,而是建立时间窗并枚举已发生变化。
### 先查清时间窗,再查数据(2026-07-13 本会话再犯后补丁)
当 Doro/Maggie 说“上周五”“今天”“昨天”“本周一”这类**相对日期**时,**第一步不是直接查数据,而是先把自然语言时间词锚定成明确的北京时间起止窗口**。没先锚时间窗,就会出现:
- 把“上周五”理解成错误日期;
- UTC/BJT 边界算错;
- 用错窗口后得出“0 份”之类错误结论;
- 之后再补查 gateway.log 才发现用户是对的,严重伤信任。
**强制步骤:**
1. 先用北京时间确认当前日期和星期;
2. 把“上周五/今天”等词转换成**北京时间明确起止时间**;
3. 再换算成 UTC 时间戳/窗口;
4. 把这个时间窗写出来后,才开始查 `.meta` / gateway.log / tracker / xlsx。
**执行纪律:**
- 如果 `meta` 查不到,但 `gateway.log` 里已经出现该日文件消息证据,**不得继续说“0 份”或“没发”**;必须当场降级结论为“已证明确实发了,但文件清单仍在继续反查”。
- 对周五这类历史批次,**至少要交叉两源**:`gateway.log` 文件消息 + tracker / xlsx / queue 痕迹,不能只信一侧。
- 没闭环前,结论只能说“已查实部分”“尚未查实部分”,不能提前给“全部无遗漏”的总判断。
**本会话教训**:周五邱律师明明发了文件,但因先把时间窗算错、又只依赖 `.meta`,错误说成“0 份”;之后从 `gateway.log` 查到 `msg=''` 文件消息才纠正。以后凡是相对日期统计任务,**先定北京时间窗口,再查数据**。
## 红线:验证指令 ≠ 回忆指令(2026-07-02 信任危机后确立)
### 当前交付目录全量 pass 指令(2026-07-13 Doro明确授权)
当 Doro 明确下达类似指令:
- "任务交付文件夹里所有的合同及companion,都做pass"
- "现在这一刻,Nextcloud-任务交付里,所有的合同和companion,都做pass流程"
这属于**对当前任务交付目录的全量授权**,不再局限于单份合同或单个 companion。此时必须:
1. **先列出任务交付目录当前全部文件**,不要凭上一条对话里提到的那一份合同推断。
2. **合同与 companion 都要纳入核查范围**——先查清目录里到底有哪些主合同、有哪些 companion,不能只扫主合同关键词就下结论。
3. **主合同与 companion 的 pass 处理规则不同**
- **主合同**:做完整 pass——tracker 标记 `completed`,并登记到 excel。
- **companion**:属于主合同的附属交付物,**不单独登记到 excel,不单独占 seq**。只在核查结果中确认其存在与关联关系,必要时体现在主合同的 pass 备注/关联记录中。
4. **先查 tracker / xlsx 再写入**,但用户已经授权时,不要卡在"没有 tracker 记录所以我先不做"的保守口径上;对主合同应直接补建记录并完成 completed + excel 登记。
5. **汇报时先给出全量清单,再区分主合同与 companion 的处理结果**,不能把“全部做pass”误写成“所有文件都单独登记 excel”。
### 本次会话教训
- 错误做法:只围绕白鹤这一份合同回答"已pass",没有先把任务交付目录全量列出,遗漏了香花桥和朱家角积分项目的一整组合同及 companion。
- 第二层错误:把 companion 当成主合同一样单独写入 tracker/xlsx,导致 seq 冲突和错误登记。
- 正确做法:当 Doro 说"任务交付文件夹里所有的合同及companion,都做pass"时,必须把**当前任务交付目录的全部文件**作为审计范围,但**excel 只登记主合同,companion 不单独登记**。
**凡Doro说"查""核实""核对""是不是都""有没有遗漏"→ 回复中必须先有工具调用再有结论。context记忆/session summary ≠ 查证,不可直接输出。**
违反后果:Doro已明确说"到了无法信任你的程度"。这不是规则问题,是行为问题——规则早就写了(见已知坑),照样违反。
四个具体失败模式(2026-07-02 同一轮对话全犯):
1. **拿记忆当查证**:session context有信息→直接组织成答案→包装成"查实结果"→其实没跑工具
2. **渠道遗漏**:只查了QiuTing私信,漏了Doro私信、Doro直接Nextcloud上传、飞书等渠道
3. **不认识自己的输出**:之前汇报过的数据(seq 216-222含重固),被追问时说"找不到"——数据一直在,没看自己历史输出
4. **推责给用户**:找不到时直接问Doro"你记得叫什么名字吗"——把验证责任转嫁给用户。正确做法:穷尽搜索策略(换关键词、按时间批次找、按相邻seq推断、直接读xlsx逐行扫、session_search换多个query)。数据就在tracker里,是自己没认真遍历。
**第4条补充(2026-07-02追加)**:被追问"你真的查了吗"→ 如果上一条回复里没有tool call,直接承认"没查,现在查"。不辩解、不包装。
**唯一可接受的行为**
- 跑工具 → 看输出 → 写结论
- 不确定的说不确定
- 被追问时不防御不绕,直接承认没查到
- 对比自己之前输出过的表格/数据,确认是否已经有答案
## 恢复到workflow交付状态(2026-07-13 教训)
Doro可能要求"恢复到workflow完成的状态"——意思是撤销你的手动修改,让NC上的交付文件回到workflow原始交付版本。
**恢复方法(按优先级)**
1. **NC版本历史**`docker exec nextcloud-nextcloud-1 ls /var/www/html/data/doro/files_versions/Doro合同审查任务/任务交付/``.v<timestamp>` 文件,最早的版本 = workflow原始交付版
2. **`/tmp/pass_check_*` 副本**:pass流程核对时从NC拉取的副本,如果时间早于你的手动修改,就是workflow原版
3. **练塘镇子目录副本**:部分合同在 `练塘镇社区卫生服务中心/任务交付/` 也有一份
**操作**:docker cp 覆盖 → chown www-data → occ files:scan。
**场景**:你手动修复了workflow交付的合同(补编号、修字体等),但Doro认为应该重新走workflow而非手动修补。恢复后等Doro进一步指示。
## 前置审查(Doro要求"审查workflow修改"时触发)
Doro可能在pass之前要求逐份审查workflow交付物的质量。这不是pass流程,而是**前置质量审计**,只有审计通过+手动修复后Doro才会说pass。
### 审计方法论
1. **区分原文修订 vs workflow修订**:用zipfile读XML,按`w:ins/@author``w:del/@author`区分。`author=WB`是workflow的,其余(WB-1/86187/杨丽等)是原文自带的→保持不动。
2. **对照原文确认**:必须docker cp原文件(从NC待审查目录),转换后逐段对比,确认哪些修订是原文自带。
3. **逐项检查清单**
- INS rFonts:WB INS的rFonts属性必须与同段原文run一致(不多不少)。常见问题:多了hAnsi/cs/hint
- pStyle:新增条款标题的段落样式必须与原文条款标题一致(如Heading4),不能用Style15等其他样式
- 标题空格:原文"第六条违约责任"无空格→新增也不能有空格"第七条转包与分包"
- 子编号顺延:章节编号改了(七→八),内部子编号(7.1→8.1)也必须改
- 赔偿上限:双向条款的赔偿上限(限制甲方获赔)按规则"能删就删"
- 内容去重:新增保密存续等条款前,检查原文是否已有同义表述
- 脚注"法律顾问修订版":**必须用修订格式**(w:ins, author=WB),不能是普通文本
- 文件命名:【修】+原文件名一字不动(含扩展名变化.doc→.docx)
4. **通读全文**:接受修订后的全文必须通读,检查WB插入的内容是否与上下文语句通顺、有无重复编号(原文问题不改,但要识别)
5. **Doro的期望**
- "打开文件查清楚再回答我"→ 必须用工具完整检查后才能下结论,不能凭印象
- "看清楚前后文再回答我"→ 报告问题前必须理解修改点的上下文语境
- "有没有该加粗没加粗的"→ 格式检查要全面(bold/style/indent/spacing都要对比)
- "按照workflow的规则,手动修改"→ 发现问题后直接修复,不只是报告
### 修复操作要点
- 用zipfile+lxml直接操作XML(不用python-docx修改tracked changes)
- 修复后的INS rFonts只保留原文有的属性(通常eastAsia+ascii)
- 子编号顺延:原文数字可能分散在多个run中(如"7"+".1 "两个run),只需DEL+INS第一个数字run
- P49类大段文本需精确split run(保留前后文,只DEL中间要删的部分)
- 修复后必须python-docx打开验证
## 触发条件
Doro对已交付的合同**明确说"pass"**(可以是单份或批量)。
⚠️ **绝对前提:Doro必须亲口说"pass"才能启动此流程。** 合同交付后、workflow完成后、端午节合同end后——这些都**不是**pass。不要因为合同已交付就主动查xlsx/tracker或准备pass操作。Doro没说pass之前,对交付物的一切后续操作(更新tracker、更新xlsx、查重等)都不做。
⚠️ **"我满意了"≠Doro说pass(2026-07-13教训)**:即使你作为审查负责人完成了质检、修复了所有问题、对文件满意,也**绝不能自行执行pass流程**。必须等Doro明确说"pass"。2026-07-13实证:白鹤劳务派遣协议修复完毕后自行做了pass,被Doro纠正"我没说pass你做什么pass",紧急撤销(tracker回退delivered+xlsx删行)。自主质检和pass是两个完全独立的步骤——前者是你的职责,后者是Doro的权力。
2026-06-15教训:两次被Doro纠正("我都还没说pass呢"、"今天的合同我都没说pass呢")——agent在合同交付后主动查xlsx是否已更新、准备补写tracker,被视为越权操作。
2026-07-13教训:白鹤劳务派遣协议自行质检完后直接执行pass,被纠正"我没说pass你做什么pass",紧急撤销。
**手动审查交付物的完整检查清单**见 `references/manual-review-checklist-0713.md`——当Doro要求"审查workflow修改的情况"时按此执行。核心:自主质检→发现问题直接修→报告→等Doro说pass。不问"需要修复吗",不自行pass。
## 特殊判定:"终身不通过"
Doro可能对某些合同说"终身不通过"——这意味着合同审查质量太差,**永远不会pass**。
- **不是返工**:不是让你修了再交,而是直接否决
- **处理方式**:在tracker中标记`status: "permanently_rejected"`,不进入pass流程
- **反思**:必须分析为什么质量差到这个程度,是workflow哪个角色出了问题,把教训记入historical-failures
- **不要追问Doro**:已经定性了就不要再烦,自己复盘
## 操作步骤(严格按顺序)
### 0. 交付物核对(写入前必做)
⚠️ **PDF 批注交付件的独立核验** → 用 `scripts/verify_pdf_annotation_deliverable.py <原件> <NC交付件> [本地核验件]`:一键查 SHA256 字节一致、页数、原文未改动、批注数/author=WB、批注格式("建议"开头无【】)、高亮几何锚定。扫描件/PDF 合同走批注模式(非 docx 修订),docx 专项检查不适用,用此脚本替代。
Doro说pass后,在写tracker/xlsx之前,**必须先核对交付物与源文件的匹配关系**:
1. **取原始文件名**:从交付文件名去掉`【修】``【无修改意见】`前缀 → 得到原始文件名
2. **待审查目录核实**:去Nextcloud `Doro合同审查任务/待审查/` 确认该原始文件名存在。不存在则停下来排查,不继续写入
3. **顾问单位核对**:确认要写入xlsx的顾问单位名称正确。⚠️ 不能盲信classifier的`our_party_name`——已有多次classifier误判案例(如智慧医院云项目合同classifier设为"朱家角"实际甲方是"卫健事业发展中心")。**必须用python-docx打开合同原文,直接读取甲方名称**
- **⚠️ python-docx 的 `paragraph.text` 会吞掉 `w:ins`(修订插入)内容 → 读出的甲方可能是"补全前"的残缺名(2026-06-25 实证)**:当本次审查的改动之一就是"给甲方补全行政区前缀"(如 WB 修订插入「上海市青浦区」),交付件里甲方全称是「原稿可见文字 + w:ins 插入文字」拼起来的。`python-docx` 遍历 `doc.paragraphs[i].text` 时**未必包含 ins 的文字**,会让你误以为甲方还是缺前缀的简称。**核甲方全称必须把 `w:ins` 算进去**:用 zipfile 读 `word/document.xml`、对甲方那一段 `''.join(t.text for t in p.iter('{...}t'))``w:t` 不分 ins/非 ins,全收),或干脆遍历所有 `w:ins` 看 author=WB 插了什么。2026-06-25 项目终止协议书:`paragraph.text` 显示甲方「华新镇社区卫生服务中心」,实际交付件 w:ins 补了「上海市青浦区」,全称应是「上海市青浦区华新镇社区卫生服务中心」——只看 `.text` 就会把残缺简称写进 tracker/xlsx。**写 party 前先确认你读的是"接受修订后"的完整甲方名。**
- **顾问单位名称空白的特殊情况(按合同来源分两路,2026-06-25 补全)**:合同正文里顾问单位名称是空白下划线(待签时填)时,无法从正文确定。**先分清这份合同是谁的任务线**,再决定问谁——别默认是邱律师:
- **邱律师批量线**(health-centers):私信邱律师询问归属。私信用 `python3 ~/.hermes/scripts/wecom_dm.py --to qiuting --text "…"`(或 `_send_wecom(extra,'QiuTing',msg)`),**不用** `send_message(target='wecom:X')`(静默回退 home channel)。暂停 pass(不写 tracker/xlsx),邱律师回复后从步骤1继续;Doro 说过"邱律师回复了就直接补上 我不管了"——回复后自主补登记,不再找 Doro。
- **Doro 直接指派 / 非批量线**(如幼儿园保密协议、劳动合同等非卫生中心合同):**问发起 pass 的人本人**(通常就是正在对话的 Doro),不要问邱律师,也不要自己瞎填占位符。
- ⚠️ **绝不用泛指占位符当 party 写进 tracker/xlsx**(如"幼儿园(园方,名称空白待填)")——2026-06-25 教训:园名空白我写了这种占位符,Doro 直接纠正"顾问单位是平和学校"。空白就停下来问准确**法律主体全称**,确认后再写。
- **同名/近名主体消歧(2026-06-25 教训,写 party 前必做)**:拿到顾问单位名后,先 `openpyxl` 扫 xlsx 第 C 列看清单里**是否已有多个相似名**的主体——它们往往是**不同法律实体**,不能混用。本会话清单里同时有「上海青浦平和**幼儿园有限公司**」(seq13/15) 和「上海青浦平和**双语学校**」(seq44/210/211),一个园、一个校,是两个主体。判别靠**合同内容性质**:这份通篇"幼儿园/幼儿就读/保教费"→幼儿园主体;但既然清单里并存多个近名实体,**最终仍用 `clarify` 让发起人拍板一个准确全称**,并复用清单里既有的写法(保持前后一致,别造新写法)。
- **更正已写错的 party(两处同步)**:若 tracker/xlsx 已写入后才被纠正,两处都要改:tracker 用原子写回(tempfile+rename)改对应 seq 的 `party`;xlsx 改第 C 列后重新 `docker cp` 上传 + `occ files:scan` + 清 OnlyOffice 缓存重启;最后从线上重新拉 xlsx + 读 tracker **核对两处一致**才算完成。
4. **查重**:在tracker中检查是否已有相同 `original_filename` + `party` 的completed记录。有则为重复交付,不再写入
以上4步全部通过,才进入步骤1写tracker。任何一步不通过,停下来排查原因。
**注意**:Doro批量pass时可能列出审查意见文件(如"朱家角审查意见-xxx"、"采购协议-审查意见")。这些是主合同的附属交付物,**默认不单独写tracker/xlsx条目**。只需为主合同文件(【修】前缀的)写tracker和xlsx。审查意见文件如果不在交付目录中(可能已被清理或因classifier误判未生成),不影响主合同的pass流程——跳过即可,不需要报错或追问Doro。
### 例外:用户明确说“任务交付文件夹里所有的合同及companion,都做pass流程”时(2026-07-13 Doro明确)
当 Doro 用**当前任务交付目录全量授权**的口径下指示:
- “任务交付文件夹里所有的合同及companion,都做pass”
- “现在这一刻,Nextcloud-任务交付里,所有的合同和companion,都做pass流程”
此时必须把 companion **纳入 pass 核查范围**,但仍要遵守:
- **companion 不是独立合同,不单独登记 excel,不单独占 seq**;
- tracker/xlsx 的登记主体仍然是**主合同**;
- companion 的处理应当体现在“该主合同已连同 companion 一并核查/补做pass”的结果里,而不是把每个 companion 当主合同单独建台账。
**执行顺序**
1. 先把当前 `任务交付/` 目录全部文件列出来,不能只围绕当前对话那一份合同;
2. 再识别哪些是主合同、哪些是 companion;
3. 主合同逐份做 pass / tracker / xlsx;
4. companion 只做挂靠核查,不单独占 excel 行;
5. 回复时明确区分“主合同已登记几份、companion 已核查几份”,不要混成一类。
**2026-06-11教训**:安全测试合同因跳过核对,第一次把原始文件名写进xlsx文件名列(没有【修】前缀),发现后补写但未清理错误记录,导致tracker和xlsx各多一条重复数据。
**2026-06-12教训**:手动写xlsx时列顺序写反(日期和序号对调、文件名列写成原始文件名而非delivered_filename、顾问单位列写成"已完成"状态文字)。**写xlsx前必须先读上一行确认列顺序**,不要凭记忆。正确列顺序:A=序号, B=日期, C=顾问单位, D=合同名称, E=文件名(delivered_filename)。
### 1. 更新 Tracker JSON
路径:`~/.hermes/data/contract-tracker.json`
#### 状态流转(新增 `delivered` 中间态,2026-07-02 确立)
```
文件到达 → workflow审查 → deliverer交付成功 → queue-runner写入 status=delivered
Doro说pass → status=completed
24h后 → cleanup清理
```
**三道防线防重复:**
| 检查点 | 逻辑 |
|--------|------|
| queue-runner 启动前 | 查 tracker,`delivered``completed` → 直接 SKIP 并移入 done/ |
| watchdog resume 前 | 查 tracker,已交付的 thread 不恢复 |
| deliverer 写入时 | exists 检查,同文件不重复写入 |
#### Pass 时的写入逻辑
**如果 tracker 中已有该 `original_filename` 且 `status=delivered`** → 更新该记录为 `completed`,补全 `seq`/`party`/`contract_name`/`delivered_filename`/`converted_filename`/`xlsx_updated_at`/`cleaned=false`
**如果 tracker 中没有记录**(旧合同、手动审查等情况)→ 新建完整记录,直接 `status=completed`
完整记录示例:
```json
{
"original_filename": "消防设施检测服务合同(练塘卫生院).doc",
"delivered_filename": "【修】消防设施检测服务合同(练塘卫生院).docx",
"converted_filename": "消防设施检测服务合同(练塘卫生院).docx",
"party": "上海市青浦区练塘镇社区卫生服务中心",
"contract_name": "消防设施2026年度检测服务合同",
"seq": 175,
"status": "completed",
"delivered_at": "2026-06-09T18:30:00+08:00",
"xlsx_updated_at": "2026-06-09T20:09:00+08:00",
"cleaned": false
}
```
字段说明:
- `original_filename`:邱律师发来的原始文件名(可能是.doc)
- `delivered_filename`:交付到任务交付目录的文件名(【修】前缀)
- `converted_filename`:workflow转换后的.docx文件名(原始是.doc时有值,否则空字符串)。**来源**:workflow的`converted_filename`字段会从classifier一路传递到deliverer/final_review输出。pass流程必须从workflow输出中提取并写入tracker。如果workflow输出中没有此字段(旧workflow跑的合同),则根据original_filename判断:以`.doc`结尾的,converted_filename = 同名`.docx`;以`.docx`结尾的,converted_filename = 空字符串
- `seq`:合同审查清单中的序号
- `delivered_at`:workflow 交付完成时间(queue-runner 写入)
- `xlsx_updated_at`:pass 时写入,北京时间ISO格式,清理脚本据此计算24h
- `cleaned`:清理脚本执行后改为true
**写入方式**:先写临时文件再rename(原子操作),防止进程中断导致JSON损坏。
```python
import json, os, tempfile
from datetime import datetime, timezone, timedelta
BJT = timezone(timedelta(hours=8))
tracker_path = os.path.expanduser('~/.hermes/data/contract-tracker.json')
# 读取现有tracker
if os.path.exists(tracker_path):
with open(tracker_path, 'r') as f:
tracker = json.load(f)
else:
tracker = {"contracts": []}
now = datetime.now(BJT).isoformat()
# 查找是否已有 delivered 记录
existing = None
for c in tracker["contracts"]:
if c.get("original_filename") == original_filename:
existing = c
break
if existing and existing.get("status") == "delivered":
# 已有 delivered → 更新为 completed(补全字段)
existing["status"] = "completed"
existing["delivered_filename"] = delivered_filename
existing["converted_filename"] = converted_filename
existing["party"] = party
existing["contract_name"] = contract_name
existing["seq"] = seq
existing["xlsx_updated_at"] = now
existing["cleaned"] = False
elif existing and existing.get("status") == "completed":
# 已经 completed → 查重命中,不重复写入
pass
else:
# 无记录 → 新建(旧合同/手动审查走这条路)
tracker["contracts"].append({
"original_filename": original_filename,
"delivered_filename": delivered_filename,
"converted_filename": converted_filename,
"party": party,
"contract_name": contract_name,
"seq": seq,
"status": "completed",
"xlsx_updated_at": now,
"cleaned": False
})
# 原子写入
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(tracker_path), suffix='.json')
with os.fdopen(fd, 'w') as f:
json.dump(tracker, f, ensure_ascii=False, indent=2)
os.replace(tmp, tracker_path)
```
### 2. 更新合同审查清单 xlsx
路径(Nextcloud):`Doro合同审查任务/合同审查清单.xlsx`
⚠️ **铁律(2026-07-03覆盖事故后确立):写xlsx前必须从Nextcloud实时拉取最新版本,禁止使用/tmp或本地任何已有的xlsx文件。** 违反此规则会用旧版覆盖新版,导致其他session已登记的记录丢失(2026-07-03实证:用240行旧文件覆盖了252行最新版,丢失12条记录)。
**强制检查流程**
1. `docker cp` 从NC拉取 → 保存到 `/tmp/合同审查清单_LIVE.xlsx`(带LIVE后缀避免与残留文件混淆)
2. 打开后先读 `ws.max_row` 和最后一行的seq → 打印确认
3. 如果发现本地已有同名文件,**必须删除后重新拉取**,不得复用
4. 追加新行后上传
步骤:
1. **从Nextcloud下载最新xlsx(必须实时拉取,禁止复用本地文件)**
2. 用openpyxl追加行(序号、日期、顾问单位全称、合同名称、文件名)
3. 从上一行复制字体和对齐样式(用copy())
4. border用Side对象重建(不用ref_cell.border直接赋值,会报unhashable错误)
5. 保存后上传回Nextcloud
6. 执行files:scan + 清OnlyOffice缓存
```bash
# 下载(铁律:必须每次实时拉取,rm掉旧文件防止复用)
rm -f /tmp/合同审查清单_LIVE.xlsx
docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/合同审查清单_LIVE.xlsx
sudo chown maggie:maggie /tmp/合同审查清单_LIVE.xlsx
# 上传
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
# 上传
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
python3 -c "import openpyxl; ws=openpyxl.load_workbook('/tmp/合同审查清单_LIVE.xlsx').active; print(f'NC当前版本: {ws.max_row}行, 最后seq={ws.cell(ws.max_row,1).value}')"
# 上传
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
docker exec nextcloud-nextcloud-1 chown www-data:www-data /var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan --path="doro/files/Doro合同审查任务/合同审查清单.xlsx"
# 上传后验证(write-read-verify,防止覆盖事故)
rm -f /tmp/合同审查清单_VERIFY.xlsx
docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/合同审查清单_VERIFY.xlsx
python3 -c "import openpyxl; ws=openpyxl.load_workbook('/tmp/合同审查清单_VERIFY.xlsx').active; print(f'上传后验证: {ws.max_row}行, 最后seq={ws.cell(ws.max_row,1).value}')"
# 清OnlyOffice缓存
docker exec nextcloud-onlyoffice-1 bash -c 'rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*'
docker restart nextcloud-onlyoffice-1
```
### 3. 确认并汇报
向Doro确认已标记completed,告知清单已更新到第几条。
### 批量pass后的登记完整性审计(2026-06-15教训)
⚠️ **Doro说\"都pass了\"或\"看下是否都走了pass流程\"时,必须把每一份pass的合同逐一对照 tracker JSON 和 xlsx 两个存储,找出缺漏——不能假设\"workflow跑完了/交付了\"就等于\"登记完整\"。**
2026-06-15教训:6份合同队列全部跑完并交付,Doro说6份都pass。实际核对发现只有2份(端午节、医疗急救招聘)走了完整pass流程,另外4份(金泽蛋糕、赵巷消防、练塘健康积分、徐泾维保)tracker和xlsx**全都没登记**。Doro主动提醒\"excel登记是不够的\"。原因:交付由workflow的final_review完成,但pass流程(写tracker+xlsx)是独立的人工步骤,前面几份漏做了。
**审计脚本逻辑**(用openpyxl+json对照):
1. 列出本批次所有pass合同的 `delivered_filename`(从任务交付目录或Doro的pass消息提取)
2. 读 xlsx 第E列(文件名列)建一个 set
3. 读 tracker.json 的 `contracts[].delivered_filename` 建一个 set
4. 逐份合同检查:`in xlsx?` + `in tracker?`,打印缺失矩阵
5. 对缺失的合同,**完整补走步骤0(待审查核实+正文读甲方+查重)→ 步骤1(tracker)→ 步骤2(xlsx)**,不能只补一个存储
6. 补完后重新跑一遍审计脚本,确认6份全部 ✅ xlsx + ✅ tracker
**xlsx与tracker必须成对存在**:xlsx是给人看的登记,tracker是给cleanup cron用的。只有xlsx没tracker→文件永远不被自动清理;只有tracker没xlsx→Doro的清单缺条目。审计时两个都要查。
**openpyxl写xlsx的权限陷阱**:从Nextcloud用`sudo cp`下载的xlsx属主是root,openpyxl保存时报`PermissionError`。先`sudo chown maggie:maggie`改属主到当前用户的可写副本再操作。
## 触发模式
Doro会**回复引用**某条交付通知消息并说"pass"。可能是单份也可能批量("四份都pass")。
- 引用的消息中包含交付文件名,从中提取合同信息
- 批量pass时逐份处理,每份都写tracker+xlsx
### 审查意见文件的处理
Doro可能把审查意见文件(如"朱家角审查意见-XXX.docx"、"采购协议-审查意见.docx")也列入pass清单。这些是合同的伴随交付物,**不需要单独的tracker条目和xlsx行**——它们跟随主合同的tracker记录:
- 主合同"【修】XXX.docx" → 写tracker + 写xlsx
- 伴随审查意见文件 → 不写tracker,不写xlsx
- 清理时:审查意见文件跟随主合同一起清理
### Classifier甲方误判的pass处理(2026-06-12教训)
当workflow classifier误判了顾问单位(如把"卫健事业发展中心"误判为"朱家角"),pass流程写tracker/xlsx时必须用**合同正文中的真实甲方名称**,不能用classifier的`our_party_name`
- 步骤0核对时用python-docx读取合同正文前30段,找甲方全称
- tracker的`party`字段和xlsx的"顾问单位"列写真实甲方
## 清理cron配套信息
- Cron job name: `contract-cleanup`,job_id: `4636467b715d`
- 脚本路径: `~/.hermes/scripts/contract-cleanup.py`
- 调度: 每小时,no_agent静默模式
- 逻辑: 读tracker → 找completed + xlsx_updated_at超24h + cleaned=false → docker exec删Nextcloud待审查/和任务交付/中的文件 → 标记cleaned=true → files:scan
- 安全: 只删tracker中精确记录的文件名,不通配。xlsx在上一级目录碰不到。JSON损坏时静默不操作。原子写入(tempfile+rename)
- **.doc→.docx转换残留(2026-06-11发现+修复)**:原始文件为`.doc`时,workflow会用libreoffice转换为`.docx`副本放在待审查目录。cleanup脚本已有`converted_filename`字段的读取逻辑(第113-116行),但旧workflow跑的合同tracker中缺少此字段导致转换文件残留。**治本修复**(2026-06-11已实施):在review-contract.yaml的classifier procedure中添加`converted_filename`记录步骤,从classifier→reviewer→editor→deliverer→final_review全链路传递该字段。pass流程写tracker时从workflow输出中提取。cleanup脚本自动生效。
- **手动批量清理(Doro授权模式)**:Doro可能要求按日期批量清除旧文件(不走tracker逻辑),此时按修改时间(stat %Y)过滤,在Nextcloud容器内执行rm,完成后files:scan+清OO缓存。2026-06-11执行过一次:保留6月10日及之后的文件,删除之前的全部(待审查11个+任务交付179个)。
## 已知坑
- **时间窗不能先拍脑袋,必须先用工具确认当天与"上周五"的北京时间边界(2026-07-13 再犯后补强)**:用户要求"查看北京时间上周五及今天,邱律师(查sender id)发了多少合同"时,不能直接按自己理解硬算时间窗,更不能在没交叉验证前就下"周五=0份"结论。必须至少两步核对:①先用 `TZ='Asia/Shanghai' date` 确认当前北京时间与星期;②把北京时间窗口换算成 UTC 后,再同时查 `.meta``gateway.log`。**只要 gateway.log 已出现 QiuTing 的空消息(msg='')文件记录,就不能再说该时段 0 份。**
- **查"邱律师发了多少合同"必须双源交叉,不可只靠 cache meta(2026-07-13 教训)**:仅查 `~/.hermes/cache/documents/*.meta` 可能漏掉周五文件或误判为 0。标准做法:
1. `.meta`:按 `sender_id == QiuTing` + 北京时间窗口换算后的 UTC 区间统计
2. `gateway.log`:按同一时间窗 grep `platform=wecom user=QiuTing chat=QiuTing msg=''`,这是文件消息的一手证据
3. tracker + xlsx:核对这些文件是否都 workflow 完成、是否都 pass、是否都登记 excel
4. **没有把两源对齐前,不得下"都完成了/没有遗漏"结论**
- **被 Doro 说"胡说八道,重新查,查明情况再说"时,表示你刚才的结论缺乏证据链**:此时必须回退到原始数据源重查,不得只改措辞继续硬答。正确做法:先承认上一条没查明,再用不同数据源(如从 meta 转到 gateway.log)交叉验证。
- **审查workflow交付物时必须区分原文自带修订 vs WB修订(2026-07-13 Doro指示)**:Doro要求"对照原文件,WB-1如果是原文自带修订,保持不动。你只需要看workflow是不是准确遵守了workflow的规则,包括命名规则"。具体方法:①用zipfile读原文件(待审查目录)的revision authors,确认哪些author是原文已有的 ②读交付件,按author过滤只看WB的修订 ③逐条核对WB修订是否符合审查规则 ④原文自带的修订(其他author)不评价、不报告为问题。**常见误判**:把原文WB-1的修订当成workflow的WB修订来审查——必须先确认author再下结论。
- **核查汇报必须先用工具验证再说(2026-07-01+07-02 升级为顶部红线)**:详见本skill顶部「红线:验证指令 ≠ 回忆指令」章节 + `references/audit-methodology.md`。2026-07-02 同一轮对话中此规则被违反4次,导致Doro信任破裂("到了无法信任你的程度")。不再重复列举——执行时加载顶部红线即可。
- **"确定吗?再仔细查实"(2026-07-02 追加)**:Doro说"确定吗""再仔细认真查实"时,意思是**上一条回复的结论不够可信/不够深入**。正确做法:不要只是重复上一轮的输出,而是用不同角度/更深层的工具去交叉验证。具体:①查session_search看是否有其他session做过相同操作 ②查uwf step list确认每个thread走到了哪一步 ③确认通知确实被发出(session中有_send_wecom的tool call记录)而非假设。核心原则:**每一条"确认X发生了"的结论,都必须有对应的工具调用输出作为证据。**
- **文件"消失"的排查思路(2026-07-01 教训)**:Doro发现待审查/交付目录文件不在时,不要急着说"被误删"。先排查:①tracker 的 `cleaned` 字段是否已为true(cron清理了)→ 不可能不到24h;②auto_notify 是否正常工作(文件可能从来没上传到待审查目录,workflow直接从cache路径读文件完成审查,但Doro在Nextcloud看不到);③是否是手动操作中 `docker exec rm` 删了。根因很可能是"从来没上传到待审查"而不是"上传后被删了"。
- **交付件被 Doro 手动编辑后大小/内容会变 —— pass 时不覆盖、只登记(2026-06-23 实证)**:Doro 收到交付件后可能自己在 OnlyOffice 里编辑(删掉部分批注内容、改条款等),导致 Nextcloud 上的交付文件大小/字节/md5 与 workflow 原始交付件不同;且 OnlyOffice 后台处理期间文件**大小会持续变化**,docker cp 出来用 pymupdf/python-docx 读可能是 0 页/0 批注的中间态。**看到交付件与你核验时不一致,先确认是不是 Doro 自己改的,绝不要当成文件损坏去"修复"或用本地完整版覆盖。** Doro 说 pass 后:以 Doro 改后的版本为准,只写 tracker+xlsx 登记,**不触碰交付目录里的文件**。2026-06-23 教训:交付 PDF 从 20MB 变 3.5MB 且持续变化,我误判损坏、准备用本地版覆盖,Doro 说"是我删除了一些内容,你直接做 pass 就可以"。
- **PDF 扫描件合同走批注模式,不 OCR 转 docx(2026-06-23 Doro 明确)**:收到扫描件 PDF 合同(无文字层)时,**不要纠结"OCR 转 docx 才能修订"**——workflow 对 PDF 用**批注模式**审查(高亮 Highlight + 批注气泡 Text 配对,author=WB),原文零污染,这是正常流程(seq=201 朱家角.pdf、seq=207 阳澄湖团建.pdf 都是 PDF 批注交付)。Doro 原话"PDF 的修改使用批注,这是 workflow 的正常流程"。PDF 交付件**核验/pass 时**:核批注数/author/高亮锚定位置/原文页数不变,**不套用** INS/DEL/字号/numPr 那套 docx 专项检查(final_review 对 PDF 会自动判定这些不适用)。
- **交付文件位置:根目录 `任务交付/` 是正确的(2026-06-29 确认)**:workflow deliverer 明确写着上传到 `Doro合同审查任务/任务交付/`(根目录)。不要把文件移到顾问单位子文件夹——那里不是交付目的地。pass 流程步骤0核对时,交付文件应该在根目录 `任务交付/` 中。如果 deliverer 同时上传到了子文件夹(如 final_review 越权重复上传),子文件夹里的是多余的,应清理。
- **final_review 越权重复上传(2026-06-29 教训)**:workflow 中上传是 deliverer 的职责,final_review 只负责质量检查和通知 Doro。但 LLM 执行 final_review 时可能越权上传文件(到子文件夹而非根目录 `任务交付/`),且使用完全不同的命名格式。**判别**:`stat` 比较文件大小,同内容两份不同名=重复。**处理**:保留 deliverer 上传的(【修】/【审】+ 原始文件名),删除 final_review 额外上传的。**根因**:review-contract.yaml 的 final_review procedure 需修改,明确禁止上传动作(待 WeiWei 实施)。
- **审查意见标题空壳(2026-06-29 教训)**:workflow editor 生成的审查意见文档标题是"关于《合同》的审查意见"——"《合同》"是占位符,没有替换为实际合同名称。classifier 已正确提取了 `contract_title`,但 editor 生成审查意见时没有填入。**pass 流程必须检查审查意见标题**:如果标题是"关于《合同》的审查意见",需要手动修正为"关于《{合同实际名称}》的审查意见"。修正方法:用 zipfile + lxml 读 document.xml,找到标题段落的所有 `w:t` 节点,第一个设为完整标题,其余清空。
- **【审】审查意见文件缺失(2026-06-30 发现)**:deliverer 有时只上传【修】修订版而没有上传【审】审查意见文件。**排查**:`sudo find .../任务交付/ -name "【审】*"` 检查是否有对应的审查意见。**处理**:如果缺失,需要手动生成或报告Doro确认是否需要补做。审查意见是companion文件,不影响主合同的pass流程(不需要tracker/xlsx条目),但Doro可能期望看到。
- **Workflow常见格式缺陷清单(2026-07-13 审计总结,详见 `references/pre-pass-audit-checklist-20260713.md`)**:
1. WB INS rFonts多余属性(hAnsi/cs/hint)——每份都有,每次审计必查必清
2. 新增条款标题pStyle错误(用了Style15而非Heading4等)
3. 新增标题带空格("第七条 转包"应为"第七条转包")
4. 子编号未顺延(只改了章编号,没改内部X.Y编号)
5. 赔偿上限遗漏(双向条款的20%上限未删)
6. 保密存续重复插入(原文已有同义表述)
7. 脚注未用修订格式
- **同模板合同修订一致性(2026-07-03 Doro确认:手动调整)**:不同顾问单位提交审查的合同若为相同模板,修订需保持一致(规则9已有)。但不改workflow自动化逻辑——Doro指出不一致时手动调整即可,避免矫枉过正。不需要template_type标签或修订库。
- **非合同文件进入workflow审查(2026-07-01 香花桥招标需求实证)**:classifier识别出"招标需求文件"但仍走了完整review+edit流程并交付了修订版。Doro判定此类文件无需审查,直接通知邱律师。**已修auto_notify加L1文件名预筛**(关键词:招标需求/技术方案/报价单等)。但classifier兜底层未修——仍然会把非合同当合同审。根因:workflow的routing graph没有从classifier直接到$END的non-contract路径。**pass前排查**:如果交付物对应的原始文件明显不是合同(招标需求/投标文件/会议纪要等),直接删除交付物并通知邱律师,不做pass。
- **审查意见文件是错误交付物(2026-07-01 发现)**:workflow 有时会生成不该有的审查意见文件(如合同已被其他thread审查过、或classifier误判导致多生成)。**pass前排查**:检查任务交付目录中是否有不属于本次审查的【审】文件。如果是错误交付物(如旧版残留、重复审查产物),直接删除不pass。判断标准:审查意见的标题和内容是否对应本次审查的合同,标题是否是"关于《合同》的审查意见"(占位符)。
- **cleanup脚本不处理tracker顶层"ready"条目 + 不清理本地目录(2026-07-03 查明根因)**:`contract-cleanup.py` 只遍历 `tracker["contracts"]` 数组中 `status=completed` + `cleaned=False` 的条目。但以下两类文件不会被清理:
1. **Tracker顶层字典键**(status=ready的条目):这些是workflow跑完但尚未被Doro pass的合同,存储在tracker的顶层键而非contracts数组里。cleanup脚本看不到它们。Doro pass后,pass流程会把它们从顶层搬到contracts数组并标记completed——此时cleanup才能处理。**如果pass流程有bug没搬进contracts数组,文件永远不会被清理。**
2. **本地 `~/.hermes/shared/Doro合同审查任务/待审查/` 目录**:cleanup脚本只删Nextcloud容器内的文件(`docker exec rm`),本地shared目录的副本无人管理。这些是auto_notify上传NC后留下的本地副本——NC侧被cleanup清了,本地的永远残留。
- **诊断方法**:`ls ~/.hermes/shared/Doro合同审查任务/待审查/` 如果有文件,且对应的tracker条目已经completed/ready,就是此bug。
- **临时清理**:确认文件在tracker中已completed后,手动 `rm` 本地副本即可。
- **根治**:cleanup脚本需要增加两段逻辑:①遍历tracker顶层ready条目(Doro已pass但pass流程漏搬的)②清理本地shared/待审查/中已completed的文件。
- **沟通时间统一使用北京时间(2026-07-03 Doro多次纠正)**:所有对外沟通(包括向Doro汇报workflow状态、文件接收时间等)一律使用北京时间。服务器UTC时间仅在内部日志/脚本中使用,不对用户展示。
- **Doro说"查清楚"/"你去查原因"——必须用工具验证后再给结论**:Doro要求查证时,不能凭记忆或context summary回答。必须实际调用工具(session_search、terminal读文件/目录、tracker等)取得证据后再汇报。2026-07-13教训:被要求"查清楚我哪些说了pass"时,需要在本session对话记录中找到Doro的原话,不能凭印象列清单。
- **脚注"法律顾问修订版"必须用修订格式(2026-07-13 Doro纠正)**:添加页脚文字时,必须包裹在`w:ins`元素中(author=WB),不能作为普通文本直接写入。OnlyOffice/Word中显示为带修订标记的新增内容。实现方式:用python-docx添加footer文本后,再用zipfile+lxml找到footer XML中的对应run,包裹进`w:ins`元素。
- **交付件字体大小不一致(2026-07-01 Doro纠正,根因细化)**:有两种常见成因:
1. **段内混用**:同一段落内不同run使用不同字号(如sz=21和sz=24混用)。修复:找dominant size统一。
2. **INS-only新段落缺sz**(更隐蔽):`add_clause`插入的全新段落,原文docDefaults无sz定义或sz≠邻居段落的显式sz。新段落INS run不写sz→继承docDefaults→与显式sz=24的邻居段落不一致。**诊断**:找所有INS-only段落(整段只有w:ins),检查其run的rPr是否有sz,再对比前后段落的sz。缺sz且邻居有显式sz=补上。
3. **numPr叠加**:加了手动编号但没去掉原自动编号→显示"1. 第一条"。修法见contract-editor skill的"strip numPr"规则。
- **deliverer 重复上传(同内容两份不同名)(2026-06-29 教训)**:deliverer 可能对同一合同上传两次,文件大小完全一致说明内容相同。**判别**:`stat` 比较文件大小。**处理**:保留命名规范的那份(【修】/【审】+ 原始文件名),删除另一份。
- **原文件自带【修】前缀时命名错误(2026-07-13 璞石合同实证)**:邱律师发来的文件本身已有【修】前缀(如`【修】练塘-硬件购销合同-璞石医疗2026.7.13.wps`),workflow按规则应生成`【修】【修】练塘-...docx`(原文件名一字不动,前面再加【修】),但实际只生成了`【修】练塘-...docx`(吞掉了原文的【修】前缀)。**审查时判别**:原文件名含【修】时,交付文件名应出现两个【修】。只有一个=workflow命名错误。**根因**:workflow的命名逻辑strip了原文件名中已有的【修】前缀后再加自己的。
- **Cache hash前缀混入交付文件名(2026-06-30 实证)**:deliverer 有时把 cache 文件名直接当交付文件名上传,产生类似 `【修】doc_0ad4ee63bff5_印刷品制作合同2026.6(1).docx` 的文件名。**判别**:文件名含 `doc_[a-f0-9]{12}_` 模式。**处理**:删除带 hash 前缀的那份,保留正确命名的(`【修】印刷品制作合同2026.6(1).docx`)。**根因**:deliverer 从 cache 复制文件时没有去掉 cache hash 前缀重命名。
- **companion 文件(审查意见/流程单)不会被 cleanup 自动清理 → 永久残留孤儿(2026-06-21/29 实证)**:`contract-cleanup.py` 只删 tracker 里 `original_filename`/`converted_filename`/`delivered_filename` 三个字段精确匹配的文件。companion 从不进 tracker → 主合同被清理后 companion 永远残留。**排查**:运行 `references/cleanup-audit.py` 审计脚本,列出所有 not-in-tracker 的文件。**应急清理(Doro 授权后)**:
```bash
# 必须搜索所有任务交付路径(根目录+顾问单位子目录都可能有)
sudo docker exec nextcloud-nextcloud-1 find /var/www/html/data/doro/files/Doro合同审查任务/ -path "*任务交付*" -name "*审*意见*"
# 确认后逐个删除
sudo docker exec nextcloud-nextcloud-1 rm -f "<path1>" "<path2>" ...
# 扫描+清缓存
sudo docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan doro --path="/doro/files/Doro合同审查任务/"
docker exec nextcloud-onlyoffice-1 bash -c 'rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*'
docker restart nextcloud-onlyoffice-1
```
⚠️ Companion 可能同时存在于根目录 `任务交付/` 和顾问单位子目录(如 `朱家角镇社区卫生服务中心/任务交付/`)——find 命令用 `-path "*任务交付*"` 通配搜索确保不遗漏(2026-07-06 实证:肃言/恭兴审查意见各在两个位置共4份)。
**治本方案见 `references/companion-cleanup-proposal.md`**,需 WeiWei 决策后实施。
- **Companion 判定不能靠想当然命名(2026-07-13 白鹤劳务派遣协议教训)**:Doro说“交付文件夹里的合同及companion全部做pass”时,**先查清交付文件夹里到底有哪些相关文件,再决定有没有 companion**。不要因为很多合同通常会带审查意见,就默认本案也有;也不要只看主文件名一次就下结论。正确顺序:①先在 `任务交付/` 中做**宽匹配扫描**(合同名关键词、当事人名关键词、业务关键词都要扫)②再把整个 `Doro合同审查任务/` 目录补扫一遍,确认没有藏在别的子目录里的 companion ③最后再对 tracker+xlsx 核对是否已有 completed 记录。**如果实扫结果只有主合同一份,就要明确汇报“合同1份、companion 0份”,而不是笼统说“都pass了”。**
- **Companion 命名规则(2026-07-01 Doro确认)**:交付物=`【修】{原文件名}`,companion=`【审】{原文件名去扩展名} 审查意见.docx`。pass skill 扫描时按此规则拼确定性文件名+`nc_file_exists()`查询,查到就写入 tracker 的 `companion_files` 字段。cleanup 脚本读 `companion_files` 一并删除。两头(pass skill + cleanup script)必须同步改才有效果。workflow editor/deliverer 生成审查意见时也必须强制用此命名规则。
- **手动启动workflow前必须查实auto_notify是否已处理(2026-07-03 教训)**:发现cache中有新文件时,不要直接`uwf thread start`。必须先:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测到并启动了workflow ②`uwf thread list | grep running\|idle`看是否已有对应thread在跑。auto_notify正常工作时会自动上传NC+启动queue runner+排队执行,手动启动会导致重复审查。2026-07-03实证:邱律师发了文件,auto_notify 30秒内就启动了workflow(thread 06FJDADW),我没查就又手动启动了第三个重复thread,被Doro纠正。
- **不判断文件是否相同/重复(2026-07-03 Doro纠正)**:邱律师发了几次、发什么文件,不是我该判断的。不要说"同上""同名文件""是重复的"——即使md5一致也不做这个判断。auto_notify和workflow自己处理,我不干涉。待审查目录里有几份就是几份,workflow跑几个就是几个。
- **沟通必须使用北京时间(多次纠正)**:所有与Doro/Maggie的时间沟通一律用北京时间,包括表格、汇报、日志引用。服务器是UTC,必须+8转换后再输出。不写UTC时间。
- **"删掉批注"指令——先验证再操作(2026-07-08)**:Doro可能要求"删掉批注,提交后做pass"。操作前必须先用zipfile检查comments.xml是否存在+commentRangeStart数量。如果文件无批注(comments.xml不存在且无comment引用元素),直接汇报"文件无批注"然后继续后续操作(提交/pass),不需要强行"删除"不存在的东西。
- **邱律师同名文件不自行判断是否重复(2026-07-03+07-09 加强)**:邱律师发的多次同名文件,不要自己判断"重复发送"。同名文件可能:①不同顾问单位②修改版(字节不同=内容变了)③同一份发了两次。**只要字节大小不同,就必须视为不同合同独立审查**。2026-07-09教训:字节差407的两份合同有3处实质性条款差异(项目名称、支付方式、合同期限),不是"重复发送"。正确做法:检查字节是否相同→不同→独立审查或私信邱律师确认。
- **交付件大小/内容突变,先确认是不是用户手动编辑了,别当损坏(2026-06-22 实证)**:pass 前看到 Nextcloud 上的交付 docx/pdf 大小骤变(如 20MB→3.5MB 还在持续变、md5 对不上本地核验版),第一反应**别**判为"文件损坏/被进程写坏"。先确认是不是 Doro 自己在 OnlyOffice 里改了(删批注、调内容)。确认是用户编辑后:**不覆盖、不动交付目录的文件,以用户改后版本为准**,直接走 pass 登记(tracker+xlsx)。**铁律:pass 登记只动台账(tracker/xlsx),交付目录里的成品文件除非用户明确要求重做,否则只读不写。**
- **`.doc` 原始文件残留(2026-06-29 实证)**:tracker 的 `original_filename` 有时记录了 `.docx`(转换后文件名)而非 `.doc`(真正的原始文件),cleanup 按 `.docx` 去删找不到 `.doc` → 残留。**排查**:cleanup-audit.py 会标记为 NOT_IN_TRACKER。**预防**:pass 写 tracker 时确保 `original_filename` 是邱律师发来的真实文件名(含扩展名),不要写转换后的文件名。
- **编号"错误"误判——markup视图双编号是正常现象(2026-06-16教训)**:OnlyOffice/Word修订视图(markup)下,自动编号列表项被删除(含他人如屠佳青的删除)或新增条款插入后,后续项会显示"(3)(2)""(4)(3)"这类双编号——前一个是删除/插入前的旧编号,后一个是接受修订后的新编号。这是track changes的正常渲染,**不是编号错误**。Doro/Maggie说某份合同"编号修改错误"时,先别急着改:
1. 用execute_code生成"接受所有修订后"的版本:删除所有`w:del`元素 + 删除带段落标记删除(`pPr/rPr/del`)的整段 + 解包所有`w:ins`(把ins的子元素提到父级再删ins壳)
2. 用OnlyOffice x2t渲染accept版PDF,pdftotext看**最终编号**是否连续正确
3. 若accept后编号正确 → workflow没改错,markup双编号是正常的,**恢复workflow原版即可,不要擅改**
4. 若要改,必须先问清Doro/Maggie指的是accept后哪一处,他人(屠佳青等)的修订按铁律不能动
- 2026-06-16教训:把端午节合同(转包6误改成7)、医疗急救招聘合同的正常编号重排误判为错误并擅自改动,被Maggie两次纠正"workflow改得都没错,恢复成workflow修订版本"。x2t accept渲染命令见本skill的"用OnlyOffice渲染自查"或contract-editor skill。
- **但确有真编号重复时要修(2026-06-15端午节实测,与上条不冲突)**:自动编号列表项(numbering.xml中`numId`对应的`abstractNum`有`start=N`、`lvlText=%1、`)渲染出的编号,与后续**手动键入数字**的tracked插入条款(`w:ins`里`w:t`文本直接以"6、""7、"开头)会真冲突。本案"售后服务"是auto-number(numId=3, start=6 → 渲染为6),紧跟的WB插入条款手动写了"6、转包限制" → 出现两个6。这是**真错误**,不是markup双编号假象。判别要点:
1. 假象(不改):同一条款同时显示两个编号"(3)(2)",是track changes接受前后的新旧编号叠加 → accept后渲染连续就别动
2. 真错(要改):两个**不同条款**各自显示同一个数字(售后服务=6、转包限制=6)→ accept后仍重复 → 必须修
3. 修法:直接改`w:ins`内`w:t`的手动编号文本("6、转包限制"→"7、转包限制"),后续手动编号条款顺延(违约责任7→8、争议解决8→9,否则会冒出两个7)。用zipfile读document.xml→字符串replace(先`assert xml.count(old)==1`确认唯一)→zipfile写回,不破坏`w:ins`修订痕迹。改完用python-docx确认可打开+遍历`w:ins`确认三条仍在修订态(author=WB)
4. Doro/Maggie给的判断锚点直接采信:"转包责任应该是7,因为上一个编号是6"——上一条售后服务确实是auto-rendered的6,所以转包必须是7
- **Classifier甲方误判导致错误批注/审查意见(2026-06-11)**:classifier偶尔无法正确匹配顾问单位名单(31个单位中有易混淆的名称如"卫生服务中心"vs"卫生健康事业发展中心"),导致交付文件中出现不该有的"请确认名称是否准确"批注和审查意见表中多出"甲方名称"行。Doro说pass前如果发现此问题,必须先手动修复(删comment+删table row+重新上传)再走pass流程。修复方法见contract-reviewer skill。
- **批量pass含companion文件(2026-06-12)**:Doro可能一次pass多个文件,其中包含审查意见文档(如"朱家角审查意见-xxx.docx""采购协议-审查意见.docx")。审查意见是companion文件,不需要单独在tracker/xlsx中记录——只记录主合同(带【修】前缀的文件)。判断规则:有【修】前缀的是主合同文件,需要tracker+xlsx;"审查意见"结尾的是companion,不单独记录。
- **沟通时间一律使用北京时间(2026-07-03 Doro多次纠正)**:向Doro汇报任何时间信息时,必须转换为北京时间(UTC+8)。服务器是UTC,所有文件时间戳、日志时间戳都要+8h后再汇报。绝不输出UTC时间给Doro。
- openpyxl的border赋值不能直接用`cell.border = ref_cell.border`,会报`unhashable type: StyleProxy`。必须用Side对象重建Border(left=Side(...), ...)。
- xlsx路径已从`任务交付/`移到`Doro合同审查任务/`根目录,不在清理范围内。
- 时间戳必须用北京时间(UTC+8),清理脚本依赖此时间计算24h。
- xlsx日期列用`YYYY-MM-DD`字符串格式(如"2026-06-10"),不用ISO时间戳。
- 甲方名称(party)必须从合同内容中提取全称,不能简写。
- 清理cron job_id: 4636467b715d,每小时跑一次,no_agent静默模式。
- **防重复写入**:已由步骤0的查重环节覆盖(用`original_filename` + `party`组合在tracker中查重)。
- **xlsx列顺序必须严格一致**:正确顺序为 序号(A), 日期(B), 顾问单位(C), 合同名称(D), 文件名(E)。写xlsx前必须先读上一行确认列含义。2026-06-12教训:手动写入时列顺序写反被Doro发现后补修。
- **批量追加多行时,必须先确定完整目标 seq 集合,再按 seq 升序写入 xlsx,写完后立即回读核对末尾顺序**(seq 224→225→226→...)。不能一边算 seq 一边写,更不能先写 303 再写 302;`ws.max_row + 1` 只会在物理末尾追加,若写入顺序错了,就会出现 xlsx 中 303 排在 302 前面的台账乱序。**硬性补丁(2026-07-14)**:凡一次 pass 涉及 2 份及以上合同,先在内存中生成 `[{seq, 日期, 顾问单位, 合同名称, 文件名}]` 全部待写行,按 `seq` 升序排序后再统一 append;保存上传后,必须回读最后 N 行,逐行确认 A 列 seq 单调递增且文件名与本批次一一对应。若发现顺序错误,立即重拉 LIVE xlsx 重写,不得带错交付。
- **按“北京时间今天/昨天/上周五”统计邱律师发了多少合同时,先定时间窗,再查 meta,再对照审查状态(2026-07-14 再次实证)**:这类问题不能直接凭印象回答“几份、都做完了吗”。标准顺序必须是:①先用 `TZ='Asia/Shanghai' date` 锚定今天的北京时间窗口;②查 `~/.hermes/cache/documents/*.meta` 中 `sender_id=QiuTing` 且落在该窗口内的文件,得到**今天实际收到的文件清单**;③再去 tracker 中逐份核对这些文件对应的 `status/seq/delivered_filename/xlsx_updated_at`;④最后才汇总结论。**重点**:meta 统计的是“今天收到几份”,tracker 统计的是“这些文件审查到了哪一步”,两者不能互相替代。
- **同名不同来源/不同顾问单位的合同,统计和汇报时必须拆开,不得混成一句“劳务派遣协议已完成”(2026-07-14)**:如果今天收到的是 `劳务派遣协议.doc`,但 tracker/任务交付里同时还存在 `【修】白鹤--劳务派遣协议.docx` 等近名文件,汇报时必须明确区分“今天收到的这份”与“其他同类/同模板/同主题文件”。不能把别的合同的 completed 记录拿来替代今天这份的审查状态。标准动作:按 `original_filename` 精确对 tracker 命中,再补充说明是否另有近名已完成文件。
- **被用户要求“再核查一次”时,必须升级为四路交叉核查,不得只复述上一轮结论(2026-07-14)**:对“是不是同名合同但顾问单位不同”“今天收到的到底是哪几份”这类追问,重查时至少同时核:①Nextcloud 全目录文件扫描(不只根目录任务交付)②tracker 命中记录 ③xlsx 命中记录 ④cache meta/原件名称与正文中的甲方或项目编号。回复必须把四路结果并列写出,再下结论。不能只说“我刚才已经看过了,结论不变”。
- **自动 cleanup 依赖 tracker 文件名与 Nextcloud 实际文件名精确一致(2026-07-14)**:若发现“历史台账文件名”和“当前 NC 实际文件名”不一致,即使该条记录已经 completed,仍要同步修正 `tracker.original_filename` / `tracker.delivered_filename`,必要时同步修正 xlsx 第 E 列文件名,确保三者一致:①tracker ②xlsx ③NC 实际文件。否则 cleanup 按精确文件名匹配时会漏删或误判。修正后必须再次核对目标待审查文件与任务交付文件在 NC 中确实存在。
- **用户只要结果,不要过程解释时,汇报必须收敛成一句结论(2026-07-14 Doro纠正)**:当用户明确说“我就要一个结果”时,禁止继续附加过程说明、背景铺垫、风险解释或“补一句严谨的”。此类场景只回复最终结论,例如“能。”、“已完成。”、“不能,需要X条件。”;如确需补充,等用户追问后再展开。
- **统计“今天收到的合同”时,先答收到清单,再答审查状态,别把两件事揉成一句笼统结论(2026-07-14)**:推荐输出结构固定为:①今天收到几份;②逐份列出收到时间;③逐份列出审查状态(未启动 / 审查中 / delivered / completed);④如有同主题近名文件,单列“另有近名已完成文件,不等于今天这份”。这样可以避免把“收到数量”和“已完成数量”说串。
- **乙方空白时的处理**:合同乙方名称空白→不问Doro"该问谁",直接查文件来源(cache/documents时间戳+session_search找发送人),确定是邱律师发的就直接私信邱律师询问。Doro可能说"邱律师回复了就直接补上 我不管了"——意思是邱律师回复后自主完成tracker+xlsx更新,不再找Doro确认。
- **私信邱律师的send_message路由问题**(2026-06-12):`send_message(target='wecom:QiuTing')`会路由到home channel而非QiuTing私信。必须用`_send_wecom(extra, 'QiuTing', msg)`直接发送才能到私信。
- **私信任意企微用户的首选方法 = `wecom_dm.py` 脚本(2026-06-21 再犯后确立)**:`send_message(target='wecom:<user>')` 对企微用户名会**静默回退到 home channel**(JiaQian),既发不到目标、又可能违反信息隔离(把 Doro 团队内容发进 Maggie 渠道)。`_send_wecom(extra, ...)` 需要 gateway 的 `extra` 上下文,在 terminal/execute_code 里拿不到。**最稳的通用方法**是独立脚本:`python3 ~/.hermes/scripts/wecom_dm.py --to <别名> --text "内容"`——自己开 WebSocket + `aibot_send_msg` + `chat_type=1` 发主动私信,永不串号、目标唯一确定。白名单别名:doro / jiaqian(贾茜Maggie) / qiuting(邱律师) / weiwei(技术支持) / shasha(苌莎莎) / yangayi(颜伽艺) / xiaonan。先 `wecom_dm.py --list` 核对别名→userid 再发。**铁律:私信非 home 的企微用户,一律走 `wecom_dm.py`,绝不用 `send_message(target='wecom:X')`。** 2026-06-21 教训:给魏玮发技术讨论用了 `send_message(target='wecom:WeiWei')`,静默落到 JiaQian home channel,既没到魏玮又把 Doro 团队的脚本细节泄露进 Maggie 渠道,改用 `wecom_dm.py --to WeiWei` 才真正送达。
- **不干涉workflow铁律(2026-07-03 Doro纠正)**:发现邱律师发了新文件时,**禁止直接手动启动workflow**。必须先查 `/tmp/auto_notify_new_file.log` 和 `uwf thread list` 确认auto_notify是否已经处理。不判断文件是否重复("你也不要去判断是不是同一份文件")——让系统自己处理。2026-07-03教训:auto_notify已正常工作并启动了workflow,我没查就手动又启动了一个重复的。
- **手动启动workflow前必须查实auto_notify是否已处理(2026-07-03 Doro纠正)**:发现邱律师发了新合同时,**第一步不是手动启动workflow**,而是:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测并处理 ②查`uwf thread list | grep running\|idle`确认是否已有对应thread在跑。只有确认auto_notify没处理(日志无记录+无相关thread)才手动启动。2026-07-03教训:auto_notify已正常触发并启动了workflow,我没查就又手动启动了一个重复的。**"收到即启动"的前提是"确认没有被自动处理"。**
- **不判断邱律师发的文件是否重复/相同(2026-07-03 Doro纠正,2026-07-08 再次验证)**:邱律师多次发送同名文件时,不要自行判断"是同一份发重了"。存在同文件名但不同版本、不同条款内容的可能性。2026-07-08实证:同日发两份"2026年华新镇公立中小学生健康体检服务合同.docx"(11:05和16:04),文件大小不同(27889 vs 27482 bytes),实际条款差异显著(项目内容、付款方式、合同起始日均不同)——完全是两份实质不同的合同版本。queue-runner因同名文件已在done/直接SKIP导致第二份没审查。**核查方法**:用python比对两文件文本差异(zipfile提取全文+difflib对比),有实质差异则为不同合同/不同版本,须独立审查。**报告时绝不说"重复发送"**——Doro问"如何判断是重复发送"即提醒不该自行判断。
- **恢复文件到workflow原始交付状态(2026-07-13 实证)**:Doro要求撤销手动修改、恢复到workflow交付版本时,三个来源按优先级尝试:①NC版本历史(`files_versions/`目录下的`.vXXXXXXXXXX`文件,时间戳最早的=workflow首次上传版本)②`/tmp/pass_check_*`文件(之前做pass检查时从NC拉取的快照,如果当时还没手动修改则=workflow版本)③重新跑workflow(最后手段)。恢复后必须`docker cp`回NC + `chown www-data` + `occ files:scan`。
- **Tracker重复条目(同一合同不同文件名)**:workflow跑多轮或auto_notify重复触发时,tracker可能出现同一合同的多条delivered记录(文件名带版本后缀如`_v0113.doc`vs不带的)。批量pass时需注意:只pass实际在任务交付目录中的那份(【修】前缀的),旧的重复delivered条目如果文件名与NC上的交付物不匹配,不影响pass流程但会残留在tracker中(cleanup会因找不到文件而跳过)。
- **手动启动前必须先验证auto_notify是否已处理(2026-07-03铁律)**:发现邱律师新文件后,**禁止**直接手动启动workflow。必须先:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测并处理该文件 ②`uwf thread list | grep running`确认是否已有workflow在跑。只有确认auto_notify确实没工作(进程死亡或模式B静默失效)才手动介入。2026-07-03教训:auto_notify已正常触发workflow,但我没查就手动又启动了一个重复的,被Doro纠正"不要去干涉workflow"。**邱律师发多次同名文件不自行判断是否相同**——让系统处理,同时私信邱律师确认。
- **交付通知丢失的诊断与补发(2026-07-09 实证)**:Doro说"有几份合同我没有收到交付通知"时,按以下流程排查:①检查tracker中status=delivered的记录 ②查gateway.log在对应delivered_at时间前后是否有846609/Timeout/WS closed错误 ③确认是WS断连导致fire-and-forget通知丢失 ④用`wecom_dm.py --to doro`补发。**根因是企微WS凌晨不稳定+通知无重试机制,详见 `references/notification-ws-failure-pattern-20260709.md`。** 临时止血=手动补发;根治=watchdog增加notification_sent检查和补发逻辑(方案待实施)。
- **通知静默丢失——workflow完成但Doro没收到通知(2026-07-09 实证)**:workflow全流程跑完(thread status=end, tracker=delivered),但Doro说没收到交付通知。根因:`final_review`步骤通过`_send_wecom`发通知时,企微WebSocket已断连(errcode 846609: aibot websocket not subscribed),发送静默失败,无重试机制。**症状**:Doro说"没收到通知" + tracker有delivered记录 + `grep '846609' ~/.hermes/logs/gateway.log`在final_review执行时间段有报错。**诊断**:①查tracker找delivered_at时间 ②查gateway.log该时间±5分钟有无846609/WebSocket error ③确认final_review步骤确实跑完了(`uwf step list <thread>`有final_review且thread status=end)。**补救**:用`python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "合同审查完成通知(补发):..."`手动补发。**预防**:目前无自动重试机制。如果发现当天有WebSocket中断记录,主动检查该时段内完成的所有workflow是否通知成功——以gateway.log中有对应的"Sending response...to doro"记录(不含后续846609 error)为准。
- **"delivered但Doro没收到通知"诊断(2026-07-09 实证)**:Doro说"有几份没收到交付通知"时,根因通常是final_review发通知时WebSocket已断(846609错误)。Workflow全程跑完(classifier→reviewer→editor→reviewer→deliverer→final_review),queue-runner标记delivered,但final_review内的`_send_wecom`因WS断连静默失败。**诊断步骤**:①tracker找status=delivered的合同 ②gateway.log搜846609和"WebSocket error"确认断连时段 ③`uwf step list <thread_id>`确认final_review确实执行过(有时长=执行了) ④确认交付文件在任务交付目录存在。**补救**:用`wecom_dm.py --to doro --text "合同审查完成通知(补发):..."`补发。补发内容含:合同名、顾问单位、修订数(ins/del计数)、主要修订摘要。注明"因XX时段企微WebSocket中断未实时送达,现补发"。
- **Watchdog "suspended + already delivered" 死循环(2026-07-08 实证)**:当 workflow 在 `final_review` 阶段因 HTTP 500 suspended,但 deliverer 已经成功交付(tracker=delivered)时,watchdog 会无限循环 BLOCK resume 且不归档文件,同时阻止 runner 重启。表现:Doro 没收到通知但文件已在任务交付目录。**诊断**:`tail /tmp/contract-queue/watchdog.log | grep "BLOCK resume"`。**止血三步**:①`uwf thread cancel <thread_id>` 取消所有suspended thread ②`mv /tmp/contract-queue/<files> /tmp/contract-queue/done/` 清空queue ③对已delivered的合同直接做pass(tracker delivered→completed + xlsx追加)。**注意**:可能影响一批合同(2026-07-08是5份同时卡住),需全部处理。详见 `references/watchdog-suspended-delivered-deadlock-20260708.md`。
- **同名文件判重铁律(2026-07-08 华新镇体检合同教训,Doro明确要求)**:判断两份合同是否为"同一份",**绝不能只看文件名**。邱律师经常对同一份合同发送修改版(文件名不变但内容已改),也可能不同顾问单位使用相同文件名。**判断标准——必须比对合同实质内容**:
1. 甲方(顾问单位)名称
2. 金额/费用条款(总价、单价、费用上限等关键数字)
3. 合同期限(起止日期)
4. 项目内容描述
5. 字节大小
**以上任何一项不同 → 不同合同/新版本,必须独立审查。全部相同 → 重复发送,可跳过。**
此规则适用于所有环节:auto_notify入队、queue-runner启动前、手动操作、以及任何需要判断"是否已审查过"的场景。auto_notify已通过 `~/.hermes/scripts/contract_content_compare.py` 自动执行内容比对。
**2026-07-08实证**:华新镇体检合同同日发送两版(11:05和16:04),文件名完全相同,但第二版增加了费用上限17万元、项目名称加了"华新镇"、起始日期从9月10日改为9月1日——是实质不同的合同版本。因系统只按文件名判重,第二版被跳过未审查。详见 `references/queue-runner-same-name-different-content-20260708.md`。
- **auto_notify脚本依赖与失败处理**:如果邱律师的合同没有自动启动workflow,先检查`auto_notify_new_file.sh`是否还活着(`ps aux | grep inotifywait`)。该脚本没有守护机制,会静默死亡。**两种失败模式**:
- **模式A:进程死亡** — inotifywait进程不存在。watchdog cron (`63bb31d4f050`) 会自动重启。
- **模式B:进程活着但事件静默丢失(更隐蔽)** — 进程在跑但inotifywait没有捕获到任何事件(日志完全为空)。原因可能是:inotifywait的文件描述符失效、文件系统事件被内核丢弃、脚本启动时文件已经到达(race condition)。watchdog无法修复此模式,必须人工介入。
- **诊断方法**:`cat ~/.hermes/logs/auto_notify.log | grep 日期` — 如果日志为空但meta文件有当天文件,就是模式B。
**fallback流程**:
1. 检查 `~/.hermes/cache/documents/` 中是否有未处理的文件(按meta时间戳筛选今天邱律师发的)
2. 手动复制到 `Doro合同审查任务/待审查/`(用 `sudo cp` + `sudo chown www-data:www-data`)
3. 逐个启动 workflow(`uwf thread start review-contract -p "..."`)
**⚠️ 主动发现铁律(2026-07-03教训)**:任何操作过程中(查私信状态、查文件、核对数据等),如果发现cache/documents/中有未处理的邱律师文件(meta显示sender_id=QiuTing但对应文件不在待审查目录),必须**立即中断当前任务**,先上传+启动workflow,再继续原任务。"收到即启动"不只是auto_notify的职责——手动发现的文件也必须立即处理,不能"等会再说"。2026-07-03实证:邱律师14:27发了新合同,我在14:30+检查私信时看到了meta记录但没有立即启动workflow,直到Doro追问才处理。
4. **多份合同时串行执行**:写 relay 脚本(等当前 thread `status=end` 后再启动下一个),因为所有合同共用 `/tmp/contract-review/` 工作目录,并行会互相覆盖
5. **后台执行+通知**:`uwf thread exec <thread_id> --count 20 --background`,配合 `notify_on_complete=true`
6. 可选:设 cron job 每15分钟检查 relay 进程是否存活,挂了自动重启
**2026-06-29 实证**:邱律师发了12个文件,auto_notify 没运行,只有4个自动进了 workflow。手动把剩余4个从 cache 移到待审查,写 relay 脚本串行执行,成功完成。
**2026-06-30 实证(模式B)**:邱律师发了4份合同,auto_notify进程在跑但日志完全为空(inotifywait静默失效)。手动处理全部4份。
- **Subagent/delegate_task 的清理禁令**:给 subagent 的 context 中必须明确写入"禁止删除 Nextcloud 待审查/和任务交付/目录中的任何文件。只做被要求的操作(写tracker/写xlsx/上传),不做任何清理"。2026-07-01教训:subagent 在执行 pass 操作时可能做了多余的清理动作导致文件丢失。
- **xlsx文件名列必须统一用delivered_filename**(带【修】或【无修改意见】前缀),不能写原始文件名。
## 自动清理架构(2026-06-11确认,2026-07-01补充)
活跃 cron 列表(截至 2026-07-02):
- **`contract-cleanup`**(job_id: `4636467b715d`):每小时,no_agent,deliver=local。清理 pass 超 24h 的文件。
- **`contract-queue-watchdog`**(job_id: `3174518affda`):每20分钟,no_agent,deliver=local。巡检 queue-runner 是否存活,必要时重启。⚠️ 此 cron 有已知 bug:当 queue/ 中有文件未移入 done/ 时会反复重启 runner 导致重复审查(详见 `references/queue-runner-duplicate-bug-20260702.md`)。**修复前需确保 runner 加了 tracker 查重逻辑。**
- **`auto-notify-watchdog`**(job_id: `63bb31d4f050`):每5分钟,no_agent。守护 auto_notify_new_file.sh 的 inotifywait 进程。
- 旧版`清理已交付合同`(agent模式/每6h/deliver到wecom:doro)和`合同审查调度`(每15分钟轮询)已于2026-06-11删除,与现有机制功能重复
- 新文件监控由 `auto_notify_new_file.sh`(inotifywait实时事件驱动)独立承担,不再有cron轮询
### ⚠️ 文件删除权限铁律(2026-07-01 Doro纠正)
**只有 cleanup cron 有权删除待审查/和任务交付/目录中的文件。** 手动操作(包括小Maggie和subagent)不得直接 `docker exec ... rm` 删除这两个目录的文件。
唯一例外:Doro 明确指令删除特定文件(如"删掉这个招标需求")。
2026-07-01教训:小Maggie在执行替换/清理操作时,`docker exec rm` 误删了4份已pass但未满24小时的合同原始文件(徐泾北大居、印刷品、健康积分华新、银发健康包)。文件不在trashbin(docker exec rm绕过trashbin),cleanup cron全天silent确认没动,是手动操作误删。
### Companion 文件清理方案(2026-07-01 确认)
**Companion 类型因顾问单位而异**(不统一为"审查意见"):
- 朱家角:审查意见文档(从模板生成)
- 爱卫中心:合同流程单(xlsx)
- 练塘:脚注(加在合同里,非独立文件,不需清理)
- 其他:无
**方案**:deliverer 上传后写 manifest → pass skill 读取 manifest 写入 tracker `companion_files` → cleanup 读取并一并删除。
**manifest 位置**:Nextcloud 任务交付目录下 `.delivery-manifest-{原文件名去扩展名}.json`(隐藏文件)。
**待落地**:cleanup 脚本需增加读取 `companion_files` 字段的逻辑;deliverer YAML 需增加写 manifest 步骤。
### 清理时机明确定义
- **触发条件**:Doro 说 pass → 写入 tracker(`xlsx_updated_at` 记录当前北京时间)
- **清理时机**:cleanup cron 每小时检查 tracker,找 `status=completed` + `xlsx_updated_at` 超过24小时 + `cleaned=false` 的记录
- **即:pass 后 24 小时清理**,不是交付后24小时、不是workflow结束后24小时
- **清理范围**:精确删除 tracker 中记录的 `original_filename`(待审查/)+ `delivered_filename`(任务交付/)+ `converted_filename`(待审查/,.doc转.docx时有值)
- **不清理的**:companion 文件(审查意见)不在 tracker 中,不会被自动清理;xlsx 在上一级目录不受影响
## 防重复审查(2026-07-01 朱家角标识牌 + 2026-07-02 夏阳/家庭医生签约)
**已pass合同被重复审查交付的根因**:
1. **relay-runner/auto_notify 不查 tracker**(2026-07-01):启动 workflow 前不检查 tracker 是否已有 completed 记录。manifest 文件残留已 pass 合同的文件名,被重新捡起来跑了一遍。
2. **queue-runner + watchdog 交互 bug**(2026-07-02,详见 `references/queue-runner-duplicate-bug-20260702.md`):watchdog 每20分钟重启 runner(因 done/ 不满 manifest 行数),runner 的 SKIP 逻辑只查文件是否在 queue/ 目录,**不查 tracker**。一天内 watchdog 重启 runner 41次,导致夏阳和家庭医生签约被重复审查并重复通知 Doro。
**铁律**:任何触发 workflow 的流程(auto_notify / relay-runner / queue-runner / 手动启动),**启动前必须检查 contract-tracker.json**:
**查错后否认(2026-07-01)**:Doro问朱家角标识牌怎么重复了,回答说"你没pass"——实际查 tracker 发现 seq=229 早在6/27就pass了。**被问任何合同状态时,先查 tracker/xlsx 用工具验证再回答,不凭"印象"。**
**Queue-runner + Watchdog 重复审查(2026-07-02 实证,详见 `references/queue-runner-duplicate-review-20260702.md`)**:runner等worker时异常退出→worker独立完成(含通知)→文件没移入done/→watchdog重启runner→重新审查→重复通知。**止血**:杀重复进程→清queue→移文件到done/。**Doro说收到重复通知时,按reference文件中的止血SOP执行。**
```bash
# 在 uwf thread start 之前(精确版,匹配 original_filename + status)
if python3 -c "
import json, sys
t = json.load(open('$HOME/.hermes/data/contract-tracker.json'))
completed = [c['original_filename'] for c in t['contracts'] if c['status']=='completed']
sys.exit(0 if '${FILENAME}' in completed else 1)
" 2>/dev/null; then
echo "SKIP: already completed in tracker"
# 移入 done/ 防止 watchdog 下次重启时再次尝试
mv "$QUEUE_DIR/$FILENAME" "$QUEUE_DIR/done/" 2>/dev/null
exit 0
fi
```
**手动启动时的检查**:用 `python3 -c "import json; ..."` 精确检查 original_filename + status=completed 组合。
**queue-runner 止血方法**(重复通知正在发生时):
1. `kill` runner 进程和 background-worker 进程
2. 将已完成的文件全部移入 `done/`:确保 `done/` 数量 ≥ manifest 行数
3. 验证后 watchdog 自然停止重启(进度检查通过)
## 铁律
- **待审查目录原文件:Doro说pass之前严禁删除(2026-07-01 Doro纠正)**:无论审查了多少轮、出了多少个修订版本,原文件必须留在待审查目录,直到Doro明确说pass。违反此规则等于丢失原始文件。2026-07-01教训:重新审查反委托代发工资和生育友好合同时,把原文件从待审查删了(以为已经审查完),Doro发现后要求恢复。恢复方法:从cache/documents/复制原文件回到待审查目录。
- **Nextcloud 待审查/和任务交付/目录文件:只有 cleanup cron 有权删除(2026-07-01确立)**:手动操作(包括小Maggie本人、subagent/delegate_task)不得使用 docker exec rm 删除这两个目录的文件。唯一例外:Doro 明确指令删除特定文件(如"删掉这个招标需求")。2026-07-01教训:今天pass的4份合同(徐泾北大居、印刷品、健康积分、银发健康包)原始文件和交付文件在pass后不到24小时即被删除,tracker显示cleaned=False,cleanup cron全天silent——是手动操作误删。根因无法追溯。
- **手动操作时 workflow 规则同样适用(2026-07-01确立)**:手动执行合同审查相关操作(修订、交付、清理)时,必须遵守 workflow YAML 中各角色的职责边界和规则约束。不能因为"我知道怎么做"就跳过规则。
- **不主动生成审查意见文档(2026-07-01 Doro纠正)**:除非Doro明确要求,否则pass流程只处理【修】修订版,不主动生成【审】审查意见文档。审查意见是额外交付物。
- **方案不等于授权执行**
- 新方案提出后,Doro会问"会有什么影响吗"——必须主动分析潜在影响(token消耗、误判风险、时区问题、单点故障、竞态条件等),不能只说好处。被要求"再想一想不要有漏洞"时,逐一列出漏洞清单+解法,不能遗漏。复杂度过高时Doro会直接砍掉——接受并简化。
@@ -0,0 +1,91 @@
# 合同审查完整性审计方法 (Audit Methodology)
## 触发条件
Doro说"查一查""核查""核实""是不是都审查了/pass了/登记了"→ 这是**验证指令**。
## 铁律
**回复中必须先有工具调用再有结论。context记忆≠查证,不可直接输出。**
## 时区转换(铁律)
服务器时区UTC,Maggie/Doro/邱律师北京时间(UTC+8)。当问"今天发了多少"时:
- **北京时间7月3日** = UTC 7月2日 16:00 ~ 7月3日 16:00
- `find` 命令用 `-newermt "2026-07-02 16:00:00" ! -newermt "2026-07-03 16:00:00"`
-`TZ='Asia/Shanghai' date` 确认当前北京时间
**典型错误**:用UTC当天(00:00-24:00)筛选→会把北京时间前一天下午的文件算进来、漏掉当天上午的文件。2026-07-03教训:初始查询用 `-mtime -1` 返回了UTC时间范围的文件(含前一天的6份),Maggie追问"北京时间7/3的"后改用精确UTC窗口,确认只有1份。
## 必须覆盖的数据源(缺一不可)
### 1. Tracker JSON
```bash
cat ~/.hermes/data/contract-tracker.json
```
- 按seq范围筛选
- 逐条列出original_filename, party, status, xlsx_updated_at
### 2. xlsx(与tracker交叉比对)
```bash
sudo docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/
```
- openpyxl读取,逐行比对tracker
### 3. Gateway log - 全部接收渠道
```bash
# QiuTing私信文件(空消息=文件附件)
grep 'user=QiuTing.*chat=QiuTing' gateway.log | grep "msg=''"
# Doro私信文件
grep 'user=doro.*chat=doro' gateway.log | grep "msg=''"
# Doro群文件
grep 'user=doro.*chat=wrbAFkXAAAiWC3styKqNj0bZyH6BbJ_Q' gateway.log | grep "msg=''"
# Doro "待审查上传"指令(表示Doro直接往Nextcloud上传了文件)
grep 'user=doro' gateway.log | grep -i '待审查.*上传\|上传.*新.*合同'
# 飞书渠道
grep 'feishu.*ou_757f053c9d7aff6c73b18aa60c337756' gateway.log | grep 'media='
```
### 4. Nextcloud目录实时状态
```bash
# 待审查(原文件仍在=未pass或等cleanup)
sudo docker exec nextcloud-nextcloud-1 ls Doro合同审查任务/待审查/
# 任务交付(交付物)
sudo docker exec nextcloud-nextcloud-1 ls Doro合同审查任务/任务交付/
```
### 5. 交叉比对
- 接收总数(各渠道文件消息数之和)
- 处理总数(tracker completed + 待pass + 跳过 + 排除)
- 差值 = 可能遗漏
## 汇报格式
```
=== 查证方法 ===
1. 读了什么(tracker/xlsx/gateway log哪些渠道/Nextcloud哪些目录)
2. 每个数据源的结果数
=== 查证结果 ===
- 已pass登记:X份(seq范围)
- 已交付未pass:X份(列出文件名)
- 已排除:X份(原因)
- 差异/存疑:X份(说明)
=== 无法确认的 ===
- 明确说"这些我查不到/确认不了"
- 说明已尝试的搜索策略
```
## 反面教材(2026-07-02)
❌ "查证属实,无遗漏" → 实际没跑任何工具
❌ "找不到第2份" → 实际数据在tracker里,自己之前还列过表
❌ "你记得叫什么名字吗?" → 把验证责任转嫁用户
✅ 正确做法:跑完全部5个数据源 → 列出原始数据 → 标注不确定项 → 再给结论
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Audit 待审查 and 任务交付 directories against tracker.
Usage:
python3 ~/.hermes/skills/legal/contract-pass-workflow/references/cleanup-audit.py
Prints a matrix showing which files are:
- In tracker (and their status/age/cleaned flag)
- NOT in tracker (orphans that will never be auto-cleaned)
- Should be cleaned (>24h + completed + cleaned=false)
Does NOT delete anything. Pure diagnostic.
"""
import json, os, subprocess
from datetime import datetime, timezone, timedelta
BJT = timezone(timedelta(hours=8))
now = datetime.now(BJT)
TRACKER = os.path.expanduser('~/.hermes/data/contract-tracker.json')
BASE = os.path.expanduser('~/nextcloud/data/data/doro/files/Doro合同审查任务')
待审查 = os.path.join(BASE, '待审查')
任务交付 = os.path.join(BASE, '任务交付')
def nc_ls(path):
"""List files in a Nextcloud-managed directory (needs sudo)."""
r = subprocess.run(['sudo', 'ls', path], capture_output=True, text=True)
return [f for f in r.stdout.strip().split('\n') if f] if r.stdout.strip() else []
def file_age_hours(path):
"""Get file age in hours from mtime."""
r = subprocess.run(['sudo', 'stat', '-c', '%Y', path], capture_output=True, text=True)
if r.stdout.strip():
mtime = int(r.stdout.strip())
return (now - datetime.fromtimestamp(mtime, tz=BJT)).total_seconds() / 3600
return -1
def main():
with open(TRACKER) as f:
tracker = json.load(f)
# Build lookup: filename -> list of tracker records
lookup = {}
for c in tracker['contracts']:
for key in ['delivered_filename', 'original_filename', 'converted_filename']:
fn = c.get(key, '')
if fn:
lookup.setdefault(fn, []).append(c)
orphans = []
for label, directory in [('待审查', 待审查), ('任务交付', 任务交付)]:
print(f"\n{'='*70}")
print(f" {label} ({directory})")
print(f"{'='*70}")
files = nc_ls(directory)
if not files:
print(" (empty)")
continue
for fn in sorted(files):
records = lookup.get(fn, [])
age = file_age_hours(os.path.join(directory, fn))
is_companion = any(k in fn for k in ['审查意见', '合同流程单'])
if records:
for r in records:
ts = r.get('xlsx_updated_at', '')
h = (now - datetime.fromisoformat(ts)).total_seconds() / 3600 if ts else -1
should = r['status'] == 'completed' and h > 24
flag = '🔴 SHOULD_CLEAN' if should else '⏳ waiting'
print(f" {fn}")
print(f" seq={r['seq']} | {h:.0f}h | cleaned={r.get('cleaned')} | {flag}")
else:
tag = '📋 COMPANION_ORPHAN' if is_companion else '⚠️ NOT_IN_TRACKER'
print(f" {fn}")
print(f" {tag} | age={age:.0f}h")
orphans.append((label, fn, age))
if orphans:
print(f"\n{'='*70}")
print(f" ORPHANS SUMMARY: {len(orphans)} files not tracked")
print(f"{'='*70}")
for label, fn, age in orphans:
print(f" [{label}] {fn} ({age:.0f}h old)")
if __name__ == '__main__':
main()

Some files were not shown because too many files have changed in this diff Show More