Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2233fd232a | ||
|
|
3985b9dae3 | ||
|
|
28224ccb9f | ||
|
|
668b64d594 | ||
|
|
16db5acb3a | ||
|
|
b013c68988 | ||
|
|
cb2d158689 | ||
|
|
1eb9147ba1 | ||
|
|
4145f7a50b | ||
|
|
c55a27d710 | ||
|
|
15e697ddf3 | ||
|
|
c63895f4da | ||
|
|
aebae1cea5 | ||
|
|
270b13834a | ||
|
|
4090a44524 | ||
|
|
540dbdc0e0 | ||
|
|
31b31c7635 | ||
|
|
1290e3b222 | ||
|
|
248a5bdbcd | ||
|
|
8be84cc396 |
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
name: implement-task
|
||||||
|
description: Phase 2 skill. Use when issue scope is locked by Product Owner (Phase 1 done). Plan, implement, create PR, and own it until merged as Developer.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implement Task
|
||||||
|
|
||||||
|
**Phase:** 2 — Requirement Implementation
|
||||||
|
**Role:** Developer
|
||||||
|
|
||||||
|
## When to invoke
|
||||||
|
|
||||||
|
- User says: "implement issue #X", "/implement-task #X"
|
||||||
|
- Phase 1 is complete (issue has a "Final Requirements" comment from PO).
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
- Issue must have a **Final Requirements** comment locked by PO. If not found, stop and tell the user to run `/validate-issue` first.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Load issue**
|
||||||
|
- Read issue: `mcp__gitea__.issue_read` (`method: "get"`).
|
||||||
|
- Read comments, find the **Final Requirements** comment: `mcp__gitea__.issue_read` (`method: "get_comments"`).
|
||||||
|
- If Final Requirements not found → stop, tell user to validate issue first.
|
||||||
|
- Read current Gitea user: `mcp__gitea__.get_me`.
|
||||||
|
- If current user is not already in the issue assignee list, update the issue with `mcp__gitea__.issue_write` (`method: "update"`) and set `assignees` to the existing assignees plus the current username.
|
||||||
|
|
||||||
|
2. **Read codebase**
|
||||||
|
- Use `mcp__gitea__.get_file_contents`, `mcp__gitea__.get_repository_tree` to understand current state.
|
||||||
|
- Identify files and functions that need to change.
|
||||||
|
|
||||||
|
3. **Write plan and post on issue**
|
||||||
|
- List implementation steps with verification criteria.
|
||||||
|
- List files to change.
|
||||||
|
- Flag risks or tradeoffs.
|
||||||
|
- Post plan as comment: `mcp__gitea__.issue_write` (`method: "add_comment"`).
|
||||||
|
- **Wait for "Approved" before coding.** Stop here.
|
||||||
|
|
||||||
|
4. **Implement** (only after plan is approved)
|
||||||
|
- Create feature branch from `main`:
|
||||||
|
```
|
||||||
|
git checkout main && git pull origin main
|
||||||
|
git checkout -b feat/<short-description>
|
||||||
|
```
|
||||||
|
- If currently on another branch, use worktree:
|
||||||
|
```
|
||||||
|
git worktree add .worktrees/<branch-name> main
|
||||||
|
```
|
||||||
|
- Implement following the plan (surgical changes only).
|
||||||
|
- Run validation:
|
||||||
|
```
|
||||||
|
gofmt -l .
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create PR**
|
||||||
|
- Use `mcp__gitea__.pull_request_write` (`method: "create"`).
|
||||||
|
- PR body must include:
|
||||||
|
- Summary of changes
|
||||||
|
- Test evidence (exact commands and output)
|
||||||
|
- `Fixes #<issue-id>`
|
||||||
|
- Add reviewer.
|
||||||
|
|
||||||
|
6. **Own the PR until merged**
|
||||||
|
- Monitor for review feedback: `mcp__gitea__.pull_request_read` (`method: "get_reviews"`, `get_review_comments`).
|
||||||
|
- When feedback arrives:
|
||||||
|
- Checkout the PR branch (or use worktree if on another branch).
|
||||||
|
- Fix code locally, commit, push.
|
||||||
|
- Reply on PR: `mcp__gitea__.pull_request_review_write` (`method: "create"`, `state: "COMMENT"`) explaining what was fixed.
|
||||||
|
- Never edit remote files directly via API.
|
||||||
|
- After reviewer approves (APPROVED), merge the PR if user confirms.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Use Gitea MCP tools for all issue/PR operations.
|
||||||
|
- Preserve existing assignees when adding yourself unless the user explicitly asks to replace them.
|
||||||
|
- Never edit remote files directly — always work on local branch.
|
||||||
|
- Never claim completion without fresh verification output.
|
||||||
|
- Keep changes strictly scoped to the approved plan.
|
||||||
|
- If the change needs durable documentation, create/update `docs/adr/**`. Do not commit `docs/superpowers/**` planning artifacts.
|
||||||
|
|
||||||
|
## Next skill
|
||||||
|
After PR is created, the user invokes `/review-pr <pr-number>` on the Reviewer agent.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
name: review-pr
|
||||||
|
description: Phase 2 skill. Use when a PR is created or updated. Review PR against locked issue requirements and post verdict as Reviewer. Do NOT edit code on the PR.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Review PR
|
||||||
|
|
||||||
|
**Phase:** 2 — Requirement Implementation
|
||||||
|
**Role:** Reviewer
|
||||||
|
|
||||||
|
## When to invoke
|
||||||
|
|
||||||
|
- User says: "review PR #X", "check PR #X", "/review-pr #X"
|
||||||
|
- A PR is created or updated and needs review.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
- PR exists on Gitea.
|
||||||
|
- The referenced issue has a **Final Requirements** comment from PO (Phase 1 done).
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Load PR and issue**
|
||||||
|
- Read PR: `mcp__gitea__.pull_request_read` (`method: "get"`).
|
||||||
|
- If PR is already merged or closed, say so and stop.
|
||||||
|
- Read current Gitea user: `mcp__gitea__.get_me`.
|
||||||
|
- Read existing reviews: `mcp__gitea__.pull_request_read` (`method: "get_reviews"`).
|
||||||
|
- If the current user is not already requested as a reviewer and has not already reviewed this PR, add them with `mcp__gitea__.pull_request_write` (`method: "add_reviewers"`).
|
||||||
|
- Identify the linked issue from PR body (`Fixes #<id>` or `Closes #<id>`).
|
||||||
|
- Read issue comments, find the **Final Requirements** comment: `mcp__gitea__.issue_read` (`method: "get_comments"`).
|
||||||
|
- Read PR discussion comments: `mcp__gitea__.issue_read` (`method: "get_comments"`), using the PR number as the index.
|
||||||
|
|
||||||
|
2. **Read review history** (for re-reviews)
|
||||||
|
- Get review comments: `mcp__gitea__.pull_request_read` (`method: "get_review_comments"`).
|
||||||
|
- Treat reviews, inline review comments, and PR discussion comments as separate context sources.
|
||||||
|
- If author addressed feedback in a follow-up commit, focus on new commits but still sanity-check the full diff.
|
||||||
|
|
||||||
|
3. **Get the diff**
|
||||||
|
- Use `mcp__gitea__.pull_request_read` (`method: "get_diff"`).
|
||||||
|
- Use `mcp__gitea__.get_file_contents` for surrounding context when needed.
|
||||||
|
|
||||||
|
4. **Validate locally** (when needed)
|
||||||
|
- Create worktree:
|
||||||
|
```
|
||||||
|
git worktree add .worktrees/pr-<number> origin/<head-branch>
|
||||||
|
```
|
||||||
|
- Run checks:
|
||||||
|
```
|
||||||
|
gofmt -l .
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
- Remove worktree when done:
|
||||||
|
```
|
||||||
|
git worktree remove .worktrees/pr-<number>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Evaluate**
|
||||||
|
- Does PR address **all** Final Requirements from the issue?
|
||||||
|
- Does PR contain unrelated changes or refactoring? → FAIL immediately.
|
||||||
|
- Code correctness, style, test coverage.
|
||||||
|
|
||||||
|
6. **Write verdict**
|
||||||
|
- Start with **PASS** or **FAIL**.
|
||||||
|
- Include file:line references for findings.
|
||||||
|
- Explain concrete impact, not style preferences.
|
||||||
|
|
||||||
|
7. **Post review**
|
||||||
|
- Post proactively if verdict is clear.
|
||||||
|
- Use `mcp__gitea__.pull_request_review_write` (`method: "create"`):
|
||||||
|
- `PASS` → `state: "APPROVED"`
|
||||||
|
- `FAIL` → `state: "REQUEST_CHANGES"`
|
||||||
|
- Non-blocking note → `state: "COMMENT"`
|
||||||
|
- For plain discussion (not changing review state): `mcp__gitea__.issue_write` (`method: "add_comment"`).
|
||||||
|
|
||||||
|
## Review Format
|
||||||
|
|
||||||
|
### PASS
|
||||||
|
```
|
||||||
|
PASS
|
||||||
|
|
||||||
|
No blocking issues found.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- gofmt -l .: OK
|
||||||
|
- go vet ./...: OK
|
||||||
|
- go test ./...: OK
|
||||||
|
|
||||||
|
Non-blocking:
|
||||||
|
- <optional note>
|
||||||
|
```
|
||||||
|
|
||||||
|
### FAIL
|
||||||
|
```
|
||||||
|
FAIL
|
||||||
|
|
||||||
|
1. [High] <issue with path:line and impact>
|
||||||
|
2. [Medium] <issue with path:line and impact>
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- go test ./...: FAIL (<details>)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Use Gitea MCP tools for all PR/review operations.
|
||||||
|
- Do not add yourself as reviewer twice; check requested reviewers and existing reviews first.
|
||||||
|
- Do NOT edit code on the PR.
|
||||||
|
- FAIL if PR contains unrelated changes or scope creep.
|
||||||
|
- Post review proactively if verdict is clear.
|
||||||
|
- Do not post a verdict until PR discussion comments have been checked.
|
||||||
|
- Mention validation commands you actually ran.
|
||||||
|
- No bracketed author/tool tags.
|
||||||
|
- Accept `docs/adr/**` when it records a direct, durable decision tied to the issue scope.
|
||||||
|
- Treat `docs/superpowers/**` and other agent execution artifacts as scope creep. When rejecting docs, explain whether the content should be deleted entirely or rewritten as an ADR.
|
||||||
|
|
||||||
|
## What happens next
|
||||||
|
- **PASS (APPROVED):** Developer merges the PR.
|
||||||
|
- **FAIL (REQUEST_CHANGES):** Developer fixes feedback, then user re-invokes `/review-pr` on this PR.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: validate-issue
|
||||||
|
description: Phase 1 skill. Use when a Gitea issue is created or updated and needs requirement validation. Analyze, clarify, and lock scope as Product Owner. Do NOT implement.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validate Issue
|
||||||
|
|
||||||
|
**Phase:** 1 — Requirement Refinement
|
||||||
|
**Role:** Product Owner
|
||||||
|
|
||||||
|
## When to invoke
|
||||||
|
|
||||||
|
- User says: "check issue #X", "validate issue #X", "/validate-issue #X"
|
||||||
|
- A new issue is created or an existing issue is updated with new information.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
- Issue exists on Gitea.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Load issue**
|
||||||
|
- Read issue: `mcp__gitea__.issue_read` (`method: "get"`).
|
||||||
|
- Read comments: `mcp__gitea__.issue_read` (`method: "get_comments"`).
|
||||||
|
|
||||||
|
2. **Analyze**
|
||||||
|
- Is the problem statement clear?
|
||||||
|
- Is the scope well-defined or too broad?
|
||||||
|
- Is it technically feasible?
|
||||||
|
- If needed, read relevant code: `mcp__gitea__.get_file_contents`.
|
||||||
|
|
||||||
|
3. **Decide**
|
||||||
|
- **Unclear or incomplete:**
|
||||||
|
- Post clarifying questions: `mcp__gitea__.issue_write` (`method: "add_comment"`).
|
||||||
|
- Stop. Wait for user to respond, then re-run this skill.
|
||||||
|
- **Clear and feasible:**
|
||||||
|
- Post Final Requirements (see format below).
|
||||||
|
- If the change likely needs a durable ADR, add a note: "ADR recommended: this change locks in a behavior/contract that future work must preserve."
|
||||||
|
- This locks the scope and signals Phase 2 can begin.
|
||||||
|
|
||||||
|
## Final Requirements format
|
||||||
|
|
||||||
|
```
|
||||||
|
## Final Requirements
|
||||||
|
|
||||||
|
**Summary:** <one-line summary>
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- <requirement 1>
|
||||||
|
- <requirement 2>
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- <explicitly excluded items>
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- <verifiable check 1>
|
||||||
|
- <verifiable check 2>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Use Gitea MCP tools for all operations.
|
||||||
|
- Do NOT write code, create branches, or create PRs.
|
||||||
|
- Do NOT proceed to Phase 2 — that is a separate skill.
|
||||||
|
|
||||||
|
## Next skill
|
||||||
|
After Final Requirements are posted, the user invokes `/implement-task <issue-id>` on the Developer agent.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## 1. Coding Principles
|
||||||
|
|
||||||
|
### 1.1 Simplicity First
|
||||||
|
- No out-of-scope features.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- If 50 lines work instead of 200, write 50.
|
||||||
|
|
||||||
|
### 1.2 Surgical Changes
|
||||||
|
- Only change lines directly related to the request.
|
||||||
|
- Never refactor adjacent code unless asked.
|
||||||
|
- Remove orphaned imports/variables/functions your changes created.
|
||||||
|
|
||||||
|
### 1.3 Goal-Driven Execution
|
||||||
|
- Define success criteria before coding.
|
||||||
|
- Verify each step before moving to the next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Workflow Overview
|
||||||
|
|
||||||
|
All work starts from a Gitea issue. Two phases, each with a dedicated role:
|
||||||
|
|
||||||
|
1. **Phase 1 — Requirement Refinement** (Product Owner): Clarify and lock scope.
|
||||||
|
2. **Phase 2 — Requirement Implementation** (Developer & Reviewer): Plan, code, review, merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phase 1: Requirement Refinement
|
||||||
|
|
||||||
|
**Role:** Product Owner (PO)
|
||||||
|
**Goal:** Turn a raw issue into a clear, scoped, feasible requirement.
|
||||||
|
**Constraint:** PO must NOT write code or create PRs.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Read and analyze the issue.
|
||||||
|
2. Is the requirement clear and well-scoped?
|
||||||
|
- **No:** Comment clarifying questions on the issue. Stop and wait for user response.
|
||||||
|
- **Yes:** Comment a summary and lock the scope (Final Requirements).
|
||||||
|
3. Locked issue moves to Phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase 2: Requirement Implementation
|
||||||
|
|
||||||
|
**Roles:** Developer (Dev) and Reviewer (Rev)
|
||||||
|
**Goal:** Implement the locked requirement from Phase 1.
|
||||||
|
|
||||||
|
### 4.1 Developer: Plan & Implement
|
||||||
|
|
||||||
|
1. Read the locked issue and any clarification comments.
|
||||||
|
2. Read relevant code to understand current state.
|
||||||
|
3. Write an implementation plan — list steps, files to change, risks.
|
||||||
|
4. **Comment the plan on the issue. Wait for "Approved" before coding.**
|
||||||
|
5. Once approved:
|
||||||
|
- Create a feature branch from `main`.
|
||||||
|
- Implement following the plan (surgical changes only).
|
||||||
|
- Run validation: `go test ./...`, `go vet ./...`, `gofmt -l .`.
|
||||||
|
- Create PR with `Fixes #<issue>` in the body so Gitea auto-closes the issue on merge.
|
||||||
|
- After merge, verify the issue is closed. If not (e.g. missing keyword), close it manually with a summary comment.
|
||||||
|
6. **Own the PR until merged:**
|
||||||
|
- Monitor for review feedback.
|
||||||
|
- Fix feedback on the correct branch (checkout or worktree, never edit remote via API).
|
||||||
|
- Always reply on PR after pushing a fix.
|
||||||
|
|
||||||
|
### 4.2 Reviewer: Review PR
|
||||||
|
|
||||||
|
**Constraint:** Reviewer must NOT edit code on the PR.
|
||||||
|
|
||||||
|
1. Re-read the original issue — verify PR addresses the locked requirement.
|
||||||
|
2. Read PR diff, review history, and PR discussion comments.
|
||||||
|
3. Validate in worktree if needed (`.worktrees/pr-<number>`, run `go test`, `go vet`, `gofmt`).
|
||||||
|
4. Check for scope creep — FAIL if PR contains unrelated changes or refactoring.
|
||||||
|
5. Write verdict — start with **PASS** or **FAIL**.
|
||||||
|
6. Post review proactively if verdict is clear:
|
||||||
|
- `PASS` → `APPROVED`
|
||||||
|
- `FAIL` → `REQUEST_CHANGES`
|
||||||
|
- Non-blocking note → `COMMENT`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Gitea Tools
|
||||||
|
|
||||||
|
- If git remote contains `git.thuanle.me`, ALWAYS use Gitea MCP tools.
|
||||||
|
- Scope: read PR/issue, list comments, post replies, create/edit PRs, reviews.
|
||||||
|
- Use `tea` CLI only as fallback when MCP is unavailable.
|
||||||
|
- Do not use `gh` for Gitea repositories.
|
||||||
|
- Local CLI tools (`git`, `go`, `rg`) are fine for local validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Review Format
|
||||||
|
|
||||||
|
```
|
||||||
|
PASS
|
||||||
|
|
||||||
|
No blocking issues found.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- go test ./...: OK
|
||||||
|
- go vet ./...: OK
|
||||||
|
|
||||||
|
Non-blocking:
|
||||||
|
- <optional note>
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
FAIL
|
||||||
|
|
||||||
|
1. [High] <issue with path:line and impact>
|
||||||
|
2. [Medium] <issue with path:line and impact>
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- go test ./...: OK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Documentation Rules
|
||||||
|
|
||||||
|
- Durable architecture or behavior decisions belong in `docs/adr/**`.
|
||||||
|
- `docs/superpowers/**` is for agent execution artifacts and must not be merged as part of product implementation PRs.
|
||||||
|
- If a PR needs a lasting technical rationale, author an ADR in `docs/adr/` instead.
|
||||||
|
- Reviewers should treat temporary plan/spec docs as scope creep, but accept ADRs that directly document the accepted behavior of the change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Comment Style
|
||||||
|
|
||||||
|
- Use readable multiline markdown.
|
||||||
|
- Use short sections (Summary, Changes, Test Plan).
|
||||||
|
- Concise bullet points and explicit line breaks.
|
||||||
|
- No bracketed author/tool tags.
|
||||||
@@ -1,70 +1,5 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
See [AGENT.md](AGENT.md) for full project instructions and workflow.
|
||||||
|
|
||||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
Agent skills are stored in `.codex/skills`
|
||||||
|
|
||||||
## 1. Think Before Coding
|
|
||||||
|
|
||||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
|
||||||
|
|
||||||
Before implementing:
|
|
||||||
|
|
||||||
- State your assumptions explicitly. If uncertain, ask.
|
|
||||||
- If multiple interpretations exist, present them - don't pick silently.
|
|
||||||
- If a simpler approach exists, say so. Push back when warranted.
|
|
||||||
- If something is unclear, stop. Name what's confusing. Ask.
|
|
||||||
|
|
||||||
## 2. Simplicity First
|
|
||||||
|
|
||||||
**Minimum code that solves the problem. Nothing speculative.**
|
|
||||||
|
|
||||||
- No features beyond what was asked.
|
|
||||||
- No abstractions for single-use code.
|
|
||||||
- No "flexibility" or "configurability" that wasn't requested.
|
|
||||||
- No error handling for impossible scenarios.
|
|
||||||
- If you write 200 lines and it could be 50, rewrite it.
|
|
||||||
|
|
||||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
|
||||||
|
|
||||||
## 3. Surgical Changes
|
|
||||||
|
|
||||||
**Touch only what you must. Clean up only your own mess.**
|
|
||||||
|
|
||||||
When editing existing code:
|
|
||||||
|
|
||||||
- Don't "improve" adjacent code, comments, or formatting.
|
|
||||||
- Don't refactor things that aren't broken.
|
|
||||||
- Match existing style, even if you'd do it differently.
|
|
||||||
- If you notice unrelated dead code, mention it - don't delete it.
|
|
||||||
|
|
||||||
When your changes create orphans:
|
|
||||||
|
|
||||||
- Remove imports/variables/functions that YOUR changes made unused.
|
|
||||||
- Don't remove pre-existing dead code unless asked.
|
|
||||||
|
|
||||||
The test: Every changed line should trace directly to the user's request.
|
|
||||||
|
|
||||||
## 4. Goal-Driven Execution
|
|
||||||
|
|
||||||
**Define success criteria. Loop until verified.**
|
|
||||||
|
|
||||||
Transform tasks into verifiable goals:
|
|
||||||
|
|
||||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
|
||||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
|
||||||
- "Refactor X" → "Ensure tests pass before and after"
|
|
||||||
|
|
||||||
For multi-step tasks, state a brief plan:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. [Step] → verify: [check]
|
|
||||||
2. [Step] → verify: [check]
|
|
||||||
3. [Step] → verify: [check]
|
|
||||||
```
|
|
||||||
|
|
||||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
|
||||||
|
|||||||
+17
-1
@@ -3,10 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"thuanle.me/claw-email-bridge/internal/ai_client"
|
"thuanle.me/claw-email-bridge/internal/ai_client"
|
||||||
"thuanle.me/claw-email-bridge/internal/api"
|
"thuanle.me/claw-email-bridge/internal/api"
|
||||||
@@ -62,16 +64,30 @@ func main() {
|
|||||||
// Start HTTP server with SMTP egress on callback.
|
// Start HTTP server with SMTP egress on callback.
|
||||||
smtpSender := mail.NewSMTPSender(cfg, db)
|
smtpSender := mail.NewSMTPSender(cfg, db)
|
||||||
router := api.NewRouter(db, cfg.BridgeCallbackToken, smtpSender.SendReplyFunc())
|
router := api.NewRouter(db, cfg.BridgeCallbackToken, smtpSender.SendReplyFunc())
|
||||||
|
srv := &http.Server{Addr: cfg.ListenAddr, Handler: router}
|
||||||
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
slog.Info("starting HTTP server", "addr", cfg.ListenAddr)
|
slog.Info("starting HTTP server", "addr", cfg.ListenAddr)
|
||||||
if err := router.Run(cfg.ListenAddr); err != nil {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
slog.Error("server exited with error", "error", err)
|
slog.Error("server exited with error", "error", err)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Graceful shutdown: wait for context cancel then shutdown HTTP server.
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
slog.Error("server shutdown error", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
slog.Info("bridge stopped")
|
slog.Info("bridge stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
type: ADR
|
||||||
|
id: "0001"
|
||||||
|
title: "IMAP thread resolution prefers In-Reply-To and falls back to References"
|
||||||
|
status: active
|
||||||
|
date: 2026-04-28
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The IMAP ingress flow stores a `thread_id` before dispatching work to OpenClaw. Previously the bridge only used `In-Reply-To` to find a parent task. Some valid email clients preserve thread ancestry through `References` even when `In-Reply-To` is absent. In those cases, the bridge created a new `thread_id`, which broke conversation continuity and downstream session reuse.
|
||||||
|
|
||||||
|
This decision needs to be durable because future refactors of mail parsing, dispatch, or storage must preserve the same thread-resolution behavior.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The bridge resolves parent thread linkage by trying `In-Reply-To` first. If no parent task is found, it falls back to `References`, scanning message IDs from last to first and using the first match found in the database. If neither header yields a known parent, the message's own `message_id` becomes the new `thread_id`.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
- **Use `In-Reply-To` first, then `References` last-to-first** (chosen): preserves existing behavior, matches common email ancestry ordering, and favors the most recent known parent.
|
||||||
|
- **Use only `In-Reply-To`**: simpler, but breaks valid threads when clients omit `In-Reply-To`.
|
||||||
|
- **Search `References` from first to last**: may attach to an older ancestor even when a more recent known parent exists.
|
||||||
|
- **Normalize all thread linkage into a more complex conversation graph**: more flexible, but out of scope for the current bridge behavior.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Thread continuity is improved for replies that rely on `References`.
|
||||||
|
Future contributors have an explicit behavioral contract for threading semantics.
|
||||||
|
Mail parsing changes must preserve the header precedence and reverse scan behavior unless a new ADR supersedes this one.
|
||||||
|
The current approach still depends on existing message IDs being present in the database; if upstream behavior changes significantly, this decision may need reevaluation.
|
||||||
|
|
||||||
|
## Advice
|
||||||
|
|
||||||
|
If a future change wants different precedence rules or deeper conversation reconstruction, create a new ADR rather than silently changing this behavior.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
This directory contains Architecture Decision Records (ADRs) for durable technical decisions in this repository.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
ADRs capture cross-cutting or behavior-defining decisions that future contributors need to preserve.
|
||||||
|
|
||||||
|
Use an ADR when a change introduces or locks in a decision such as:
|
||||||
|
|
||||||
|
- runtime behavior that future work must preserve
|
||||||
|
- repository-wide technical policy
|
||||||
|
- architecture or integration contracts
|
||||||
|
- storage or threading semantics
|
||||||
|
- quality gate or workflow policy
|
||||||
|
|
||||||
|
Do not use ADRs for:
|
||||||
|
|
||||||
|
- temporary implementation plans
|
||||||
|
- agent execution checklists
|
||||||
|
- one-off debugging notes
|
||||||
|
- PR-local task breakdowns
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Each ADR is a markdown file with YAML frontmatter.
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
type: ADR
|
||||||
|
id: "NNNN"
|
||||||
|
title: "Short decision title"
|
||||||
|
status: active
|
||||||
|
date: YYYY-MM-DD
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Why does this decision need to exist now?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**State the decision clearly in one or two sentences.**
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
- **Option A** (chosen): description with pros and cons
|
||||||
|
- **Option B**: description with pros and cons
|
||||||
|
- **Option C**: description with pros and cons
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
What becomes easier?
|
||||||
|
What becomes harder?
|
||||||
|
What risks were accepted?
|
||||||
|
What would trigger re-evaluation?
|
||||||
|
|
||||||
|
## Advice
|
||||||
|
|
||||||
|
Optional. Capture important external input or review notes.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- One decision per file.
|
||||||
|
- Name files `NNNN-short-kebab-title.md`.
|
||||||
|
- Once active, do not rewrite an ADR's meaning later. Supersede it with a new ADR instead.
|
||||||
|
- Keep the index below aligned with the current files in this directory.
|
||||||
|
- Do not keep placeholder or mock entries in the index.
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
| ID | Title | Status |
|
||||||
|
|----|-------|--------|
|
||||||
|
| 0001 | IMAP thread resolution prefers In-Reply-To and falls back to References | active |
|
||||||
+40
-7
@@ -283,6 +283,39 @@ func (w *IMAPWatcher) fetchUnseen(c *imapclient.Client) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveThreadID determines the thread_id for a message.
|
||||||
|
// It tries In-Reply-To first, then falls back to References (last-to-first).
|
||||||
|
// Returns the message's own ID if no parent is found.
|
||||||
|
func resolveThreadID(messageID, inReplyTo, references string, lookup func(string) (*database.Task, error)) string {
|
||||||
|
if inReplyTo != "" {
|
||||||
|
if parent, err := lookup(inReplyTo); err == nil && parent != nil {
|
||||||
|
return parent.ThreadID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, refID := range parseReferences(references) {
|
||||||
|
if parent, err := lookup(refID); err == nil && parent != nil {
|
||||||
|
return parent.ThreadID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReferences splits a References header value into individual message IDs,
|
||||||
|
// returned in reverse order (most recent first).
|
||||||
|
func parseReferences(header string) []string {
|
||||||
|
if header == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := strings.Fields(header)
|
||||||
|
// Reverse: last (most recent) first.
|
||||||
|
for i, j := 0, len(raw)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
raw[i], raw[j] = raw[j], raw[i]
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
// processMessage handles a single fetched email message.
|
// processMessage handles a single fetched email message.
|
||||||
func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.FetchMessageBuffer, bodySection *imap.FetchItemBodySection) {
|
func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.FetchMessageBuffer, bodySection *imap.FetchItemBodySection) {
|
||||||
env := buf.Envelope
|
env := buf.Envelope
|
||||||
@@ -333,6 +366,7 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
|||||||
// 4. Threading: determine thread_id.
|
// 4. Threading: determine thread_id.
|
||||||
threadID := messageID
|
threadID := messageID
|
||||||
inReplyTo := ""
|
inReplyTo := ""
|
||||||
|
references := ""
|
||||||
|
|
||||||
// Parse body to get In-Reply-To / References headers and plain text.
|
// Parse body to get In-Reply-To / References headers and plain text.
|
||||||
bodyPlain := ""
|
bodyPlain := ""
|
||||||
@@ -340,10 +374,12 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
|||||||
if rawBody != nil {
|
if rawBody != nil {
|
||||||
mr, err := gomessage.CreateReader(io.NopCloser(strings.NewReader(string(rawBody))))
|
mr, err := gomessage.CreateReader(io.NopCloser(strings.NewReader(string(rawBody))))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Extract In-Reply-To header.
|
|
||||||
if irt, err := mr.Header.Text("In-Reply-To"); err == nil && irt != "" {
|
if irt, err := mr.Header.Text("In-Reply-To"); err == nil && irt != "" {
|
||||||
inReplyTo = strings.TrimSpace(irt)
|
inReplyTo = strings.TrimSpace(irt)
|
||||||
}
|
}
|
||||||
|
if refs, err := mr.Header.Text("References"); err == nil && refs != "" {
|
||||||
|
references = refs
|
||||||
|
}
|
||||||
|
|
||||||
// Read body parts for plain text.
|
// Read body parts for plain text.
|
||||||
for {
|
for {
|
||||||
@@ -361,12 +397,9 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if inReplyTo != "" {
|
threadID = resolveThreadID(messageID, inReplyTo, references, func(mid string) (*database.Task, error) {
|
||||||
parent, err := database.FindByMessageID(w.db, inReplyTo)
|
return database.FindByMessageID(w.db, mid)
|
||||||
if err == nil && parent != nil {
|
})
|
||||||
threadID = parent.ThreadID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Save task with status RECEIVED.
|
// 5. Save task with status RECEIVED.
|
||||||
taskUUID := uuid.New().String()
|
taskUUID := uuid.New().String()
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"thuanle.me/claw-email-bridge/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveThreadID_NoHeaders_ReturnsMessageID(t *testing.T) {
|
||||||
|
got := resolveThreadID("msg-1", "", "", nilLookup)
|
||||||
|
if got != "msg-1" {
|
||||||
|
t.Fatalf("expected msg-1, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveThreadID_InReplyToMatches_ReturnsParentThreadID(t *testing.T) {
|
||||||
|
lookup := func(messageID string) (*database.Task, error) {
|
||||||
|
if messageID == "<parent>" {
|
||||||
|
return &database.Task{ThreadID: "thread-abc"}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
got := resolveThreadID("msg-2", "<parent>", "", lookup)
|
||||||
|
if got != "thread-abc" {
|
||||||
|
t.Fatalf("expected thread-abc, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveThreadID_ReferencesOnly_LastIDMatches(t *testing.T) {
|
||||||
|
lookup := func(messageID string) (*database.Task, error) {
|
||||||
|
if messageID == "<C>" {
|
||||||
|
return &database.Task{ThreadID: "thread-c"}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
got := resolveThreadID("msg-3", "", "<A> <B> <C>", lookup)
|
||||||
|
if got != "thread-c" {
|
||||||
|
t.Fatalf("expected thread-c, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveThreadID_ReferencesOnly_MiddleIDMatches(t *testing.T) {
|
||||||
|
lookup := func(messageID string) (*database.Task, error) {
|
||||||
|
if messageID == "<B>" {
|
||||||
|
return &database.Task{ThreadID: "thread-b"}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
got := resolveThreadID("msg-4", "", "<A> <B> <C>", lookup)
|
||||||
|
if got != "thread-b" {
|
||||||
|
t.Fatalf("expected thread-b, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveThreadID_ReferencesOnly_NoMatch_ReturnsMessageID(t *testing.T) {
|
||||||
|
got := resolveThreadID("msg-5", "", "<X> <Y>", nilLookup)
|
||||||
|
if got != "msg-5" {
|
||||||
|
t.Fatalf("expected msg-5, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveThreadID_InReplyToMiss_ReferencesMatches(t *testing.T) {
|
||||||
|
lookup := func(messageID string) (*database.Task, error) {
|
||||||
|
if messageID == "<ref>" {
|
||||||
|
return &database.Task{ThreadID: "thread-ref"}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
got := resolveThreadID("msg-6", "<unknown>", "<ref>", lookup)
|
||||||
|
if got != "thread-ref" {
|
||||||
|
t.Fatalf("expected thread-ref, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveThreadID_LookupError_ReturnsMessageID(t *testing.T) {
|
||||||
|
lookup := func(messageID string) (*database.Task, error) {
|
||||||
|
return nil, errors.New("db error")
|
||||||
|
}
|
||||||
|
got := resolveThreadID("msg-7", "<parent>", "", lookup)
|
||||||
|
if got != "msg-7" {
|
||||||
|
t.Fatalf("expected msg-7, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nilLookup = func(string) (*database.Task, error) { return nil, nil }
|
||||||
Reference in New Issue
Block a user