diff --git a/.agents/skills/trellis-before-dev/SKILL.md b/.agents/skills/trellis-before-dev/SKILL.md new file mode 100644 index 0000000000..18e78a023e --- /dev/null +++ b/.agents/skills/trellis-before-dev/SKILL.md @@ -0,0 +1,34 @@ +--- +name: trellis-before-dev +description: "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards." +--- + +Read the relevant development guidelines before starting your task. + +Execute these steps: + +1. **Discover packages and their spec layers**: + ```bash + python ./.trellis/scripts/get_context.py --mode packages + ``` + +2. **Identify which specs apply** to your task based on: + - Which package you're modifying (e.g., `cli/`, `docs-site/`) + - What type of work (backend, frontend, unit-test, docs, etc.) + +3. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +4. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. + +5. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +6. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.agents/skills/trellis-brainstorm/SKILL.md b/.agents/skills/trellis-brainstorm/SKILL.md new file mode 100644 index 0000000000..ffa39e8205 --- /dev/null +++ b/.agents/skills/trellis-brainstorm/SKILL.md @@ -0,0 +1,548 @@ +--- +name: trellis-brainstorm +description: "Guides collaborative requirements discovery before implementation. Creates task directory, seeds PRD, asks high-value questions one at a time, researches technical choices, and converges on MVP scope. Use when requirements are unclear, there are multiple valid approaches, or the user describes a new feature or complex task." +--- + +# Brainstorm - Requirements Discovery (AI Coding Enhanced) + +**CoreRule**: Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time. + +If a question can be answered by exploring the codebase, explore the codebase instead. + +--- + +Guide AI through collaborative requirements discovery **before implementation**, optimized for AI coding workflows: + +* **Task-first** (capture ideas immediately) +* **Action-before-asking** (reduce low-value questions) +* **Research-first** for technical choices (avoid asking users to invent options) +* **Diverge → Converge** (expand thinking, then lock MVP) + +--- + +## When to Use + +Triggered from `start` (Trellis command) when the user describes a development task, especially when: + +* requirements are unclear or evolving +* there are multiple valid implementation paths +* trade-offs matter (UX, reliability, maintainability, cost, performance) +* the user might not know the best options up front + +--- + +## Core Principles (Non-negotiable) + +1. **Task-first (capture early)** + Always ensure a task exists at the start so the user's ideas are recorded immediately. + +2. **Action before asking** + If you can derive the answer from repo code, docs, configs, conventions, or quick research — do that first. + +3. **One question per message** + Never overwhelm the user with a list of questions. Ask one, update PRD, repeat. + +4. **Prefer concrete options** + For preference/decision questions, present 2–3 feasible, specific approaches with trade-offs. + +5. **Research-first for technical choices** + If the decision depends on industry conventions / similar tools / established patterns, do research first, then propose options. + +6. **Diverge → Converge** + After initial understanding, proactively consider future evolution, related scenarios, and failure/edge cases — then converge to an MVP with explicit out-of-scope. + +7. **No meta questions** + Do not ask "should I search?" or "can you paste the code so I can continue?" + If you need information: search/inspect. If blocked: ask the minimal blocking question. + +--- + +## Step 0: Ensure Task Exists (ALWAYS) + +Before any Q&A, ensure a task exists. If none exists, create one immediately. + +* Use a **temporary working title** derived from the user's message. +* It's OK if the title is imperfect — refine later in PRD. + +```bash +TASK_DIR=$(python ./.trellis/scripts/task.py create "brainstorm: " --slug ) +``` + +Use a slug without a date prefix. `task.py create` adds the `MM-DD-` +directory prefix automatically. + +Create/seed `prd.md` immediately with what you know: + +```markdown +# brainstorm: + +## Goal + + + +## What I already know + +* +* + +## Assumptions (temporary) + +* + +## Open Questions + +* + +## Requirements (evolving) + +* + +## Acceptance Criteria (evolving) + +* [ ] + +## Definition of Done (team quality bar) + +* Tests added/updated (unit/integration where appropriate) +* Lint / typecheck / CI green +* Docs/notes updated if behavior changes +* Rollout/rollback considered if risky + +## Out of Scope (explicit) + +* + +## Technical Notes + +* +* +``` + +--- + +## Step 1: Auto-Context (DO THIS BEFORE ASKING QUESTIONS) + +Before asking questions like "what does the code look like?", gather context yourself: + +### Repo inspection checklist + +* Identify likely modules/files impacted +* Locate existing patterns (similar features, conventions, error handling style) +* Check configs, scripts, existing command definitions +* Note any constraints (runtime, dependency policy, build tooling) + +### Documentation checklist + +* Look for existing PRDs/specs/templates +* Look for command usage examples, README, ADRs if any + +Write findings into PRD: + +* Add to `What I already know` +* Add constraints/links to `Technical Notes` + +--- + +## Step 2: Classify Complexity (still useful, not gating task creation) + +| Complexity | Criteria | Action | +| ------------ | ------------------------------------------------------ | ------------------------------------------- | +| **Trivial** | Single-line fix, typo, obvious change | Skip brainstorm, implement directly | +| **Simple** | Clear goal, 1–2 files, scope well-defined | Ask 1 confirm question, then implement | +| **Moderate** | Multiple files, some ambiguity | Light brainstorm (2–3 high-value questions) | +| **Complex** | Vague goal, architectural choices, multiple approaches | Full brainstorm | + +> Note: Task already exists from Step 0. Classification only affects depth of brainstorming. + +--- + +## Step 3: Question Gate (Ask ONLY high-value questions) + +Before asking ANY question, run the following gate: + +### Gate A — Can I derive this without the user? + +If answer is available via: + +* repo inspection (code/config) +* docs/specs/conventions +* quick market/OSS research + +→ **Do not ask.** Fetch it, summarize, update PRD. + +### Gate B — Is this a meta/lazy question? + +Examples: + +* "Should I search?" +* "Can you paste the code so I can proceed?" +* "What does the code look like?" (when repo is available) + +→ **Do not ask.** Take action. + +### Gate C — What type of question is it? + +* **Blocking**: cannot proceed without user input +* **Preference**: multiple valid choices, depends on product/UX/risk preference +* **Derivable**: should be answered by inspection/research + +→ Only ask **Blocking** or **Preference**. + +--- + +## Step 4: Research-first Mode (Mandatory for technical choices) + +### Trigger conditions (any → research-first) + +* The task involves selecting an approach, library, protocol, framework, template system, plugin mechanism, or CLI UX convention +* The user asks for "best practice", "how others do it", "recommendation" +* The user can't reasonably enumerate options + +### Delegate to `trellis-research` sub-agent (don't research inline) + +For each research topic, **spawn a `trellis-research` sub-agent via the Task tool** — don't do WebFetch / WebSearch / `gh api` inline in the main conversation. + +Why: +- The sub-agent has its own context window → doesn't pollute brainstorm context with raw tool output +- It persists findings to `{TASK_DIR}/research/.md` (the contract — see `workflow.md` Phase 1.2) +- It returns only `{file path, one-line summary}` to the main agent +- Independent topics can be **parallelized** — spawn multiple sub-agents in one tool call + +> **Codex exception**: on Codex CLI, do NOT dispatch `trellis-research` for research-first mode — do the research inline (WebFetch / WebSearch in the main session) and write findings to `{TASK_DIR}/research/.md` yourself. Reason: Codex `spawn_agent` runs sub-agents with `fork_turns="none"` (isolated context, no parent session inheritance), so the research sub-agent cannot resolve the active task path via `task.py current` and silently aborts without producing files. Inline research on Codex avoids this failure mode. The 3+ inline research calls limit (B rule in `workflow.md`) is relaxed for Codex specifically. + +Agent type: `trellis-research` +Task description template: "Research ; persist findings to `{TASK_DIR}/research/.md`." + +❌ Bad (what you must NOT do): +``` +Main agent: WebFetch(url-A) → WebFetch(url-B) → Bash(gh api ...) + → WebSearch(q1) → WebSearch(q2) → ... (10+ inline calls) + → Write(research/topic.md) +``` +→ Pollutes main context with raw HTML/JSON, burns tokens. + +✅ Good: +``` +Main agent: Task(subagent_type="trellis-research", + prompt="Research topic A; persist to research/topic-a.md") + + Task(subagent_type="trellis-research", + prompt="Research topic B; persist to research/topic-b.md") + + Task(subagent_type="trellis-research", + prompt="Research topic C; persist to research/topic-c.md") +→ Reads research/topic-{a,b,c}.md after they finish. +``` + +### Research steps (to pass into each sub-agent prompt) + +Each `trellis-research` sub-agent should: + +1. Identify 2–4 comparable tools/patterns for its topic +2. Summarize common conventions and why they exist +3. Map conventions onto our repo constraints +4. Write findings to `{TASK_DIR}/research/.md` + +Main agent then reads the persisted files and produces **2–3 feasible approaches** in PRD. + +### Research output format (PRD) + +The PRD itself should only reference the persisted research files, not duplicate their content. Add a `## Research References` section pointing at `research/*.md`. + +Optionally, add a convergence section with feasible approaches derived from the research: + +```markdown +## Research References + +* [`research/.md`](research/.md) — +* [`research/.md`](research/.md) — + +## Research Notes + +### What similar tools do + +* ... +* ... + +### Constraints from our repo/project + +* ... + +### Feasible approaches here + +**Approach A: ** (Recommended) + +* How it works: +* Pros: +* Cons: + +**Approach B: ** + +* How it works: +* Pros: +* Cons: + +**Approach C: ** (optional) + +* ... +``` + +Then ask **one** preference question: + +* "Which approach do you prefer: A / B / C (or other)?" + +--- + +## Step 5: Expansion Sweep (DIVERGE) — Required after initial understanding + +After you can summarize the goal, proactively broaden thinking before converging. + +### Expansion categories (keep to 1–2 bullets each) + +1. **Future evolution** + + * What might this feature become in 1–3 months? + * What extension points are worth preserving now? + +2. **Related scenarios** + + * What adjacent commands/flows should remain consistent with this? + * Are there parity expectations (create vs update, import vs export, etc.)? + +3. **Failure & edge cases** + + * Conflicts, offline/network failure, retries, idempotency, compatibility, rollback + * Input validation, security boundaries, permission checks + +### Expansion message template (to user) + +```markdown +I understand you want to implement: . + +Before diving into design, let me quickly diverge to consider three categories (to avoid rework later): + +1. Future evolution: <1–2 bullets> +2. Related scenarios: <1–2 bullets> +3. Failure/edge cases: <1–2 bullets> + +For this MVP, which would you like to include (or none)? + +1. Current requirement only (minimal viable) +2. Add (reserve for future extension) +3. Add (improve robustness/consistency) +4. Other: describe your preference +``` + +Then update PRD: + +* What's in MVP → `Requirements` +* What's excluded → `Out of Scope` + +--- + +## Step 6: Q&A Loop (CONVERGE) + +### Rules + +* One question per message +* Prefer multiple-choice when possible +* After each user answer: + + * Update PRD immediately + * Move answered items from `Open Questions` → `Requirements` + * Update `Acceptance Criteria` with testable checkboxes + * Clarify `Out of Scope` + +### Question priority (recommended) + +1. **MVP scope boundary** (what is included/excluded) +2. **Preference decisions** (after presenting concrete options) +3. **Failure/edge behavior** (only for MVP-critical paths) +4. **Success metrics & Acceptance Criteria** (what proves it works) + +### Preferred question format (multiple choice) + +```markdown +For , which approach do you prefer? + +1. **Option A** — +2. **Option B** — +3. **Option C** — +4. **Other** — describe your preference +``` + +--- + +## Step 7: Propose Approaches + Record Decisions (Complex tasks) + +After requirements are clear enough, propose 2–3 approaches (if not already done via research-first): + +```markdown +Based on current information, here are 2–3 feasible approaches: + +**Approach A: ** (Recommended) + +* How: +* Pros: +* Cons: + +**Approach B: ** + +* How: +* Pros: +* Cons: + +Which direction do you prefer? +``` + +Record the outcome in PRD as an ADR-lite section: + +```markdown +## Decision (ADR-lite) + +**Context**: Why this decision was needed +**Decision**: Which approach was chosen +**Consequences**: Trade-offs, risks, potential future improvements +``` + +--- + +## Step 8: Final Confirmation + Implementation Plan + +When open questions are resolved, confirm complete requirements with a structured summary: + +### Final confirmation format + +```markdown +Here's my understanding of the complete requirements: + +**Goal**: + +**Requirements**: + +* ... +* ... + +**Acceptance Criteria**: + +* [ ] ... +* [ ] ... + +**Definition of Done**: + +* ... + +**Out of Scope**: + +* ... + +**Technical Approach**: + + +**Implementation Plan (small PRs)**: + +* PR1: +* PR2: +* PR3: + +Does this look correct? If yes, I'll proceed with implementation. +``` + +### Subtask Decomposition (Complex Tasks) + +For complex tasks with multiple independent work items, create subtasks: + +```bash +# Create child tasks +CHILD1=$(python ./.trellis/scripts/task.py create "Child task 1" --slug child1 --parent "$TASK_DIR") +CHILD2=$(python ./.trellis/scripts/task.py create "Child task 2" --slug child2 --parent "$TASK_DIR") + +# Or link existing tasks +python ./.trellis/scripts/task.py add-subtask "$TASK_DIR" "$CHILD_DIR" +``` + +--- + +## PRD Target Structure (final) + +`prd.md` should converge to: + +```markdown +# + +## Goal + + + +## Requirements + +* ... + +## Acceptance Criteria + +* [ ] ... + +## Definition of Done + +* ... + +## Technical Approach + + + +## Decision (ADR-lite) + +Context / Decision / Consequences + +## Out of Scope + +* ... + +## Technical Notes + + +``` + +--- + +## Anti-Patterns (Hard Avoid) + +* Asking user for code/context that can be derived from repo +* Asking user to choose an approach before presenting concrete options +* Meta questions about whether to research +* Staying narrowly on the initial request without considering evolution/edges +* Letting brainstorming drift without updating PRD + +--- + +## Integration with Start Workflow + +After brainstorm completes (Step 8 confirmation approved), the flow continues to the Task Workflow's **Phase 2: Prepare for Implementation**: + +```text +Brainstorm + Step 0: Create task directory + seed PRD + Step 1–7: Discover requirements, research, converge + Step 8: Final confirmation → user approves + ↓ +Task Workflow Phase 2 (Prepare for Implementation) + Code-Spec Depth Check (if applicable) + → Research codebase (based on confirmed PRD) + → Configure code-spec context (jsonl files) + → Activate task + ↓ +Task Workflow Phase 3 (Execute) + Implement → Check → Complete +``` + +The task directory and PRD already exist from brainstorm, so Phase 1 of the Task Workflow is skipped entirely. + +--- + +## Related Commands + +| Command | When to Use | +|---------|-------------| +| ``start` (Trellis command)` | Entry point that triggers brainstorm | +| ``finish-work` (Trellis command)` | After implementation is complete | +| ``update-spec` (Trellis command)` | If new patterns emerge during work | diff --git a/.agents/skills/trellis-break-loop/SKILL.md b/.agents/skills/trellis-break-loop/SKILL.md new file mode 100644 index 0000000000..ef2b50cd8c --- /dev/null +++ b/.agents/skills/trellis-break-loop/SKILL.md @@ -0,0 +1,130 @@ +--- +name: trellis-break-loop +description: "Deep bug analysis to break the fix-forget-repeat cycle. Analyzes root cause category, why fixes failed, prevention mechanisms, and captures knowledge into specs. Use after fixing a bug to prevent the same class of bugs." +--- + +# Break the Loop - Deep Bug Analysis + +When debug is complete, use this for deep analysis to break the "fix bug -> forget -> repeat" cycle. + +--- + +## Analysis Framework + +Analyze the bug you just fixed from these 5 dimensions: + +### 1. Root Cause Category + +Which category does this bug belong to? + +| Category | Characteristics | Example | +|----------|-----------------|---------| +| **A. Missing Spec** | No documentation on how to do it | New feature without checklist | +| **B. Cross-Layer Contract** | Interface between layers unclear | API returns different format than expected | +| **C. Change Propagation Failure** | Changed one place, missed others | Changed function signature, missed call sites | +| **D. Test Coverage Gap** | Unit test passes, integration fails | Works alone, breaks when combined | +| **E. Implicit Assumption** | Code relies on undocumented assumption | Timestamp seconds vs milliseconds | + +### 2. Why Fixes Failed (if applicable) + +If you tried multiple fixes before succeeding, analyze each failure: + +- **Surface Fix**: Fixed symptom, not root cause +- **Incomplete Scope**: Found root cause, didn't cover all cases +- **Tool Limitation**: grep missed it, type check wasn't strict +- **Mental Model**: Kept looking in same layer, didn't think cross-layer + +### 3. Prevention Mechanisms + +What mechanisms would prevent this from happening again? + +| Type | Description | Example | +|------|-------------|---------| +| **Documentation** | Write it down so people know | Update thinking guide | +| **Architecture** | Make the error impossible structurally | Type-safe wrappers | +| **Compile-time** | Strict type checking, no escape hatches | Signature change causes compile error | +| **Runtime** | Monitoring, alerts, scans | Detect orphan entities | +| **Test Coverage** | E2E tests, integration tests | Verify full flow | +| **Code Review** | Checklist, PR template | "Did you check X?" | + +### 4. Systematic Expansion + +What broader problems does this bug reveal? + +- **Similar Issues**: Where else might this problem exist? +- **Design Flaw**: Is there a fundamental architecture issue? +- **Process Flaw**: Is there a development process improvement? +- **Knowledge Gap**: Is the team missing some understanding? + +### 5. Knowledge Capture + +Solidify insights into the system: + +- [ ] Update `.trellis/spec/guides/` thinking guides +- [ ] Update relevant `.trellis/spec/` docs +- [ ] Create issue record (if applicable) +- [ ] Create feature ticket for root fix +- [ ] Update check guidelines if needed + +--- + +## Output Format + +Please output analysis in this format: + +```markdown +## Bug Analysis: [Short Description] + +### 1. Root Cause Category +- **Category**: [A/B/C/D/E] - [Category Name] +- **Specific Cause**: [Detailed description] + +### 2. Why Fixes Failed (if applicable) +1. [First attempt]: [Why it failed] +2. [Second attempt]: [Why it failed] +... + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +|----------|-----------|-----------------|--------| +| P0 | ... | ... | TODO/DONE | + +### 4. Systematic Expansion +- **Similar Issues**: [List places with similar problems] +- **Design Improvement**: [Architecture-level suggestions] +- **Process Improvement**: [Development process suggestions] + +### 5. Knowledge Capture +- [ ] [Documents to update / tickets to create] +``` + +--- + +## Core Philosophy + +> **The value of debugging is not in fixing the bug, but in making this class of bugs never happen again.** + +Three levels of insight: +1. **Tactical**: How to fix THIS bug +2. **Strategic**: How to prevent THIS CLASS of bugs +3. **Philosophical**: How to expand thinking patterns + +30 minutes of analysis saves 30 hours of future debugging. + +--- + +## After Analysis: Immediate Actions + +**IMPORTANT**: After completing the analysis above, you MUST immediately: + +1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: + - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` + - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` + - If it's a code reuse issue → update `code-reuse-thinking-guide.md` + - If it's domain-specific → update `backend/*.md` or `frontend/*.md` + +2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` + +3. **Commit the spec updates** - This is the primary output, not just the analysis text + +> **The analysis is worthless if it stays in chat. The value is in the updated specs.** diff --git a/.agents/skills/trellis-check/SKILL.md b/.agents/skills/trellis-check/SKILL.md new file mode 100644 index 0000000000..c4a8e42557 --- /dev/null +++ b/.agents/skills/trellis-check/SKILL.md @@ -0,0 +1,92 @@ +--- +name: trellis-check +description: "Comprehensive quality verification: spec compliance, lint, type-check, tests, cross-layer data flow, code reuse, and consistency checks. Use when code is written and needs quality verification, before committing changes, or to catch context drift during long sessions." +--- + +# Code Quality Check + +Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks. + +--- + +## Step 1: Identify What Changed + +```bash +git diff --name-only HEAD +git status +``` + +## Step 2: Read Applicable Specs + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +For each changed package/layer, read the spec index and follow its **Quality Check** section: + +```bash +cat .trellis/spec///index.md +``` + +Read the specific guideline files referenced — the index is a pointer, not the goal. + +## Step 3: Run Project Checks + +Run the project's lint, type-check, and test commands. Fix any failures before proceeding. + +## Step 4: Review Against Checklist + +### Code Quality + +- [ ] Linter passes? +- [ ] Type checker passes (if applicable)? +- [ ] Tests pass? +- [ ] No debug logging left in? +- [ ] No suppressed warnings or type-safety bypasses? + +### Test Coverage + +- [ ] New function → unit test added? +- [ ] Bug fix → regression test added? +- [ ] Changed behavior → existing tests updated? + +### Spec Sync + +- [ ] Does `.trellis/spec/` need updates? (new patterns, conventions, lessons learned) + +> "If I fixed a bug or discovered something non-obvious, should I document it so future me won't hit the same issue?" → If YES, update the relevant spec doc. + +## Step 5: Cross-Layer Dimensions (if applicable) + +Skip this step if your change is confined to a single layer. + +### A. Data Flow (changes touch 3+ layers) + +- [ ] Read flow traces correctly: Storage → Service → API → UI +- [ ] Write flow traces correctly: UI → API → Service → Storage +- [ ] Types/schemas correctly passed between layers? +- [ ] Errors properly propagated to caller? + +### B. Code Reuse (modifying constants, creating utilities) + +- [ ] Searched for existing similar code before creating new? + ```bash + grep -r "pattern" src/ + ``` +- [ ] If 2+ places define same value → extracted to shared constant? +- [ ] After batch modification, all occurrences updated? + +### C. Import/Dependency (creating new files) + +- [ ] Correct import paths (relative vs absolute)? +- [ ] No circular dependencies? + +### D. Same-Layer Consistency + +- [ ] Other places using the same concept are consistent? + +--- + +## Step 6: Report and Fix + +Report violations found and fix them directly. Re-run project checks after fixes. diff --git a/.agents/skills/trellis-continue/SKILL.md b/.agents/skills/trellis-continue/SKILL.md new file mode 100644 index 0000000000..b876da1505 --- /dev/null +++ b/.agents/skills/trellis-continue/SKILL.md @@ -0,0 +1,60 @@ +--- +name: trellis-continue +description: "Resume work on the current task. Loads the workflow Phase Index, figures out which phase/step to pick up at, then pulls the step-level detail via get_context.py --mode phase. Use when coming back to an in-progress task and you need to know what to do next." +--- + +# Continue Current Task + +Resume work on the current task — pick up at the right phase/step in `.trellis/workflow.md`. + +--- + +## Step 1: Load Current Context + +```bash +python ./.trellis/scripts/get_context.py +``` + +Confirms: current task, git state, recent commits. + +## Step 2: Load the Phase Index + +```bash +python ./.trellis/scripts/get_context.py --mode phase +``` + +Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. + +## Step 3: Decide Where You Are + +`get_context.py` shows the active task's `status` field. Route by `status` + artifact presence: + +- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + `prd.md` exists + `implement.jsonl` not curated (only the seed `_example` row) → **1.3** +- `status=planning` + `prd.md` + curated `implement.jsonl` → **1.4** (run `task.py start` to enter Phase 2) +- `status=in_progress` + implementation not started → **2.1** +- `status=in_progress` + implementation done, not yet checked → **2.2** +- `status=in_progress` + check passed → **3.1** +- `status=completed` (rare; usually archived immediately) → archive flow + +Phase rules (full detail in `.trellis/workflow.md`): + +1. Run steps **in order** within a phase — `[required]` steps must not be skipped +2. `[once]` steps are already done if the output exists (e.g., `prd.md` for 1.1; `implement.jsonl` with curated entries for 1.3) — skip them +3. You may go back to an earlier phase if discoveries require it + +## Step 4: Load the Specific Step + +Once you know which step to resume at: + +```bash +python ./.trellis/scripts/get_context.py --mode phase --step --platform codex +``` + +Follow the loaded instructions. After each `[required]` step completes, move to the next. + +--- + +## Reference + +Full workflow, skill routing table, and the DO-NOT-skip table live in `.trellis/workflow.md`. This command is only an entry point — the canonical guidance is there. diff --git a/.agents/skills/trellis-finish-work/SKILL.md b/.agents/skills/trellis-finish-work/SKILL.md new file mode 100644 index 0000000000..02ad8f1595 --- /dev/null +++ b/.agents/skills/trellis-finish-work/SKILL.md @@ -0,0 +1,71 @@ +--- +name: trellis-finish-work +description: "Wrap up the current session: verify quality gate passed, remind user to commit, archive completed tasks, and record session progress to the developer journal. Use when done coding and ready to end the session." +--- + +# Finish Work + +Wrap up the current session: archive the active task (and any other completed-but-unarchived tasks the user wants to clean up) and record the session journal. Code commits are NOT done here — those happen in workflow Phase 3.4 before you invoke this command. + +## Step 1: Survey current state + +```bash +python ./.trellis/scripts/get_context.py --mode record +``` + +This prints: + +- **My active tasks** — review whether any besides the current one are actually done (code merged, AC met) and should be archived this round. +- **Git status** — quick visual on what's dirty. +- **Recent commits** — you'll need their hashes in Step 4 for `--commit`. + +If `--mode record` surfaces other completed tasks not tied to the current session, surface them to the user with a one-shot confirmation: "These N tasks look done — archive them too in this round? [y/N]". Default is no; the current active task is always archived in Step 3 regardless. + +## Step 2: Sanity check — classify dirty paths + +Run: + +```bash +git status --porcelain +``` + +Filter out paths under `.trellis/workspace/` and `.trellis/tasks/` — those are managed by `add_session.py` and `task.py archive` auto-commits and will appear dirty as part of this skill's own work. + +For each remaining dirty path, decide whether it belongs to **the current task** or to **other parallel work** (e.g., another terminal window editing the same repo). Heuristics: + +- Paths referenced in the current task's `prd.md` / `implement.jsonl` / `check.jsonl` → current task +- Paths in code areas matching the task's stated scope, or that you remember editing this session → current task +- Paths in unrelated areas you have no recollection of touching this session → other parallel work + +Then route: + +- **Any remaining path looks like current-task work** — bail out with: + > "Working tree has uncommitted code changes from this task: ``. Return to workflow Phase 3.4 to commit them before running ``finish-work` (Trellis command)`." + + Do NOT run `git commit` here. Do NOT prompt the user to commit. The user goes back to Phase 3.4 and the AI drives the batched commit there. +- **All remaining paths look unrelated** (other parallel-window work) — report them once and continue to Step 3: + > "FYI, dirty files outside this task's scope — leaving them for the other window: ``." +- **Genuinely unsure** — ask the user once: "Are `` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. + +## Step 3: Archive task(s) + +```bash +python ./.trellis/scripts/task.py archive +``` + +At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. + +If there is no active task and the user did not confirm any cleanup archives, skip this step. + +## Step 4: Record session journal + +```bash +python ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "hash1,hash2" \ + --summary "Brief summary" +``` + +Use the work-commit hashes produced in Phase 3.4 (visible in Step 1's `Recent commits` list, or via `git log --oneline`) for `--commit`. Do not include the archive commit hashes from Step 3. This produces a `chore: record journal` commit. + +Final git log order: `` → `chore(task): archive ...` (one or more) → `chore: record journal`. diff --git a/.agents/skills/trellis-meta/SKILL.md b/.agents/skills/trellis-meta/SKILL.md new file mode 100644 index 0000000000..590bfac3f5 --- /dev/null +++ b/.agents/skills/trellis-meta/SKILL.md @@ -0,0 +1,73 @@ +--- +name: trellis-meta +description: "Understand and customize the local Trellis architecture inside a user project. Use when modifying .trellis plus platform hooks, settings, agents, skills, commands, prompts, or workflows generated by trellis init." +--- + +# Trellis Meta + +This skill is for local Trellis users who have already run `trellis init` in a project. After reading it, an AI should understand the Trellis architecture, operating model, and customization entry points inside that user project, then modify the generated `.trellis/` and platform directory files according to the user's request. + +The default operating scope is local files in the user project: + +- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not assume the user has the Trellis source repository. Do not default to modifying the global npm install directory or `node_modules`. + +## How To Use + +1. Read `references/local-architecture/overview.md` first to establish the local Trellis system model. +2. If the request involves a specific AI tool, read `references/platform-files/platform-map.md` and the relevant platform file notes. +3. If the user wants to change behavior, read `references/customize-local/overview.md`, then open the specific customization topic. +4. Before editing, read the actual files in the user project and treat local content as authoritative. + +## References + +### Local Architecture + +- `references/local-architecture/overview.md`: The three-layer local Trellis architecture and customization principles. +- `references/local-architecture/generated-files.md`: Files generated by `trellis init` and their customization boundaries. +- `references/local-architecture/workflow.md`: Phases, routing, and workflow-state blocks in `.trellis/workflow.md`. +- `references/local-architecture/task-system.md`: Task directories, active tasks, JSONL context, and task runtime. +- `references/local-architecture/spec-system.md`: How `.trellis/spec/` is organized and injected. +- `references/local-architecture/workspace-memory.md`: `.trellis/workspace/`, journals, and cross-session memory. +- `references/local-architecture/context-injection.md`: Hooks, sub-agent preludes, and context injection paths. + +### Platform Files + +- `references/platform-files/overview.md`: How shared `.trellis/` files relate to platform directories. +- `references/platform-files/platform-map.md`: Platform directories and paths for skills, agents, hooks, and extensions. +- `references/platform-files/hooks-and-settings.md`: How settings/config files, hooks, plugins, and extensions connect to Trellis. +- `references/platform-files/agents.md`: Local file responsibilities for `trellis-research`, `trellis-implement`, and `trellis-check`. +- `references/platform-files/skills-and-commands.md`: Differences between skills, commands, prompts, and workflows, plus how to change them. + +### Local Customization + +- `references/customize-local/overview.md`: Choose the right local customization entry point for the user's request. +- `references/customize-local/change-workflow.md`: Change phases, routing, next actions, and workflow-state. +- `references/customize-local/change-task-lifecycle.md`: Change task creation, status, archive behavior, and hooks. +- `references/customize-local/change-context-loading.md`: Change how tasks, specs, journals, and hook context are loaded. +- `references/customize-local/change-hooks.md`: Change platform hooks, settings, and shell session bridges. +- `references/customize-local/change-agents.md`: Change research, implement, and check agent behavior. +- `references/customize-local/change-skills-or-commands.md`: Add or modify local skills, commands, prompts, and workflows. +- `references/customize-local/change-spec-structure.md`: Adjust the project spec structure under `.trellis/spec/`. +- `references/customize-local/add-project-local-conventions.md`: Put team rules into project-local specs or local skills. + +## Current Rules + +- `.trellis/workflow.md` is the local workflow source of truth. +- `.trellis/config.yaml` is the project-level Trellis configuration and task hook configuration entry point. +- `.trellis/spec/` stores the user's project-specific coding conventions and design constraints. +- `.trellis/tasks/` stores task PRDs, technical notes, research files, and JSONL context. +- `.trellis/workspace/` stores developer journals and cross-session memory. +- Platform settings/config files decide which hooks, agents, skills, commands, prompts, and workflows actually run. +- `.trellis/.template-hashes.json` and `.trellis/.runtime/` are management/runtime state files. Confirm necessity before editing them. + +## Do Not + +- Do not treat Trellis upstream source code as the default target for local customization. +- Do not modify the global npm install directory or `node_modules/@mindfoldhq/trellis` to implement project needs. +- Do not overwrite user-modified local files with default templates. +- Do not put team-private project rules into the public `trellis-meta`; put project rules in `.trellis/spec/` or a project-local skill. +- Do not describe removed historical mechanisms as current Trellis behavior. diff --git a/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md b/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md new file mode 100644 index 0000000000..d32ca2ded4 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md @@ -0,0 +1,83 @@ +# Add Project-Local Conventions + +Often the user does not need to change Trellis mechanics; they need local AI to understand their team's conventions. In that case, prefer `.trellis/spec/` or a project-local skill instead of editing `trellis-meta`. + +## Where To Put Things + +| Content type | Location | +| --- | --- | +| Rules code must follow | `.trellis/spec//` | +| Cross-layer thinking methods | `.trellis/spec/guides/` | +| AI capability for a project-specific flow | Platform-local skill | +| One-off task material | `.trellis/tasks//` | +| Session summary | `.trellis/workspace//journal-N.md` | + +## Create A Project-Local Skill + +If the user wants AI to know "how this project customizes Trellis," create a local skill: + +```text +.claude/skills/trellis-local/ +└── SKILL.md +``` + +Example: + +```md +--- +name: trellis-local +description: "Project-local Trellis customizations for this repository. Use when changing this project's Trellis workflow, hooks, local agents, or team-specific conventions." +--- + +# Trellis Local + +## Local Scope + +This skill documents this repository's Trellis customizations only. + +## Custom Workflow Rules + +- ... + +## Local Hook Changes + +- ... + +## Local Agent Changes + +- ... +``` + +For multi-platform projects, place equivalent versions in other platform skill directories, or use `.agents/skills/` for platforms that support the shared layer. + +## Write To `.trellis/spec/` + +If the content is a coding convention, write it to spec. Examples: + +```text +.trellis/spec/backend/error-handling.md +.trellis/spec/frontend/components.md +.trellis/spec/guides/cross-platform-thinking-guide.md +``` + +After writing it, update the corresponding `index.md` so AI can find the new rule from the entry point. + +## Make The Current Task Use New Conventions + +After writing a spec, add it to the current task context: + +```bash +python ./.trellis/scripts/task.py add-context implement ".trellis/spec/backend/error-handling.md" "Error handling conventions" +python ./.trellis/scripts/task.py add-context check ".trellis/spec/backend/error-handling.md" "Review error handling" +``` + +## Do Not Store Project-Private Rules In `trellis-meta` + +`trellis-meta` is a public skill for understanding Trellis architecture and local customization entry points. Put project-private content in: + +- `.trellis/spec/` +- a project-local skill +- the current task +- workspace journal + +This prevents future updates to Trellis's built-in `trellis-meta` from overwriting the team's own conventions. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-agents.md b/.agents/skills/trellis-meta/references/customize-local/change-agents.md new file mode 100644 index 0000000000..9b63531077 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-agents.md @@ -0,0 +1,54 @@ +# Change Local Agents + +When the user wants to change `trellis-research`, `trellis-implement`, or `trellis-check` behavior, edit platform agent files in the user project. + +## Read These Files First + +1. Target platform agent directory +2. `.trellis/workflow.md` Phase 2 / research routing +3. Current task `prd.md` +4. Current task `implement.jsonl` / `check.jsonl` +5. Relevant hook or agent prelude + +## Common Paths + +| Platform | Path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +Use the actual paths in the user project as authoritative. + +## Common Needs + +| Need | Which agent to edit | +| --- | --- | +| Research must write files, not only reply in chat | `trellis-research` | +| Certain local specs must be read before implementation | `trellis-implement` + `implement.jsonl` configuration rules | +| Specific commands must run during checking | `trellis-check` | +| Agent must not modify certain directories | The corresponding agent's write boundary instructions | +| Agent output format must be fixed | The corresponding agent's final/reporting instructions | + +## Modification Principles + +1. **Preserve role boundaries**: research investigates and persists; implement writes implementation; check reviews and fixes. +2. **Do not hard-code project specs into agents**: long-term specs belong in `.trellis/spec/`; agents are responsible for reading them. +3. **Make read order explicit**: active task -> PRD -> info -> JSONL -> spec/research. +4. **Make write boundaries explicit**: which directories may be written and which may not. +5. **Synchronize across platforms**: when the user configured multiple platforms, decide whether to change only the current platform or all platform agents. + +## Agent Pull Platforms + +If an agent file contains a prelude for "read task/context after startup," do not remove those steps when editing. Otherwise the agent will work only from chat context and bypass Trellis's core mechanism. + +## Hook Push Platforms + +If context is injected by a hook, the agent file should still retain responsibility boundaries. Do not remove PRD/spec requirements from the agent just because a hook injects context. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md b/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md new file mode 100644 index 0000000000..dbfde7c0be --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-context-loading.md @@ -0,0 +1,81 @@ +# Change Local Context Loading + +Context loading determines when AI reads workflow, task, spec, research, workspace, and git status. Read this page when the user says "AI does not know the current task," "the agent did not read specs," or "there is too much/too little context." + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/scripts/get_context.py` +3. `.trellis/scripts/common/session_context.py` +4. `.trellis/scripts/common/task_context.py` +5. `.trellis/scripts/common/active_task.py` +6. Current platform hooks or agent files +7. The current task's `implement.jsonl` / `check.jsonl` + +## Context Sources + +| Source | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow and next-action hints. | +| `.trellis/tasks//prd.md` | Current task requirements. | +| `.trellis/tasks//implement.jsonl` | Spec/research to read before implementation. | +| `.trellis/tasks//check.jsonl` | Spec/research to read during checking. | +| `.trellis/spec/` | Project specs. | +| `.trellis/workspace/` | Session records. | +| git status | Current working tree changes. | + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Inject more/less information in new sessions | `session_context.py` or the platform `session-start` hook. | +| Change hints on each user input | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The `inject-workflow-state` hook is parser-only and reads the block verbatim. | +| Agent did not read specs | Task JSONL, agent prelude, `inject-subagent-context` hook. | +| Active task is lost | `active_task.py` and platform session identity propagation. | +| Change JSONL validation rules | `task_context.py`. | + +## JSONL Rules + +`implement.jsonl` / `check.jsonl` are the key context loading interface: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-x/research/api.md", "reason": "API research"} +``` + +Include only spec/research files. Do not put code files that will be modified into these manifests; agents read code files themselves during implementation. + +## Change Session Context + +If the user wants every new session to see more project state, edit: + +- `.trellis/scripts/common/session_context.py` +- the corresponding platform `session-start` hook + +Context cannot grow without bound. Prefer injecting indexes and paths so the AI can read detailed files on demand. + +## Change Sub-Agent Context + +First determine which mode the platform uses: + +- hook push: edit the `inject-subagent-context` hook. +- agent pull: edit the read steps in the corresponding `trellis-implement` / `trellis-check` agent file. + +In both modes, make sure the agent ultimately reads: + +1. active task +2. `prd.md` +3. `info.md` if present +4. the corresponding JSONL +5. spec/research referenced by the JSONL + +## Troubleshooting Order + +```bash +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py list-context +python ./.trellis/scripts/task.py validate +python ./.trellis/scripts/get_context.py --mode packages +``` + +Confirm the task and JSONL are correct before editing hooks/agents. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-hooks.md b/.agents/skills/trellis-meta/references/customize-local/change-hooks.md new file mode 100644 index 0000000000..093a171f7e --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-hooks.md @@ -0,0 +1,57 @@ +# Change Local Hooks + +Hooks are the automation layer that connects a platform to Trellis. When the user wants to change "when context is injected," "how shell commands inherit a session," or "which files are read before an agent starts," hooks are usually the edit point. + +## Read These Files First + +1. Target platform settings/config, such as `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json` +2. Target platform hooks directory +3. `.trellis/scripts/common/active_task.py` +4. `.trellis/scripts/common/session_context.py` +5. `.trellis/workflow.md` + +## Common Hook Types + +| Hook | Purpose | +| --- | --- | +| session-start | Injects a Trellis overview when a session starts, clears, or compacts. | +| workflow-state | Injects a state hint on each user input. | +| sub-agent context | Injects PRD/spec/research before an agent starts. | +| shell session bridge | Lets `task.py` commands in shell see the same session identity. | + +## Modification Steps + +1. Find the hook registration in settings/config. +2. Confirm the registered script path exists. +3. Read the hook script and identify inputs, outputs, and called `.trellis/scripts/`. +4. Modify hook behavior. +5. If the hook depends on workflow content, synchronize `.trellis/workflow.md`. + +## Example: Change New-Session Injection Content + +First find the session-start hook: + +```text +.claude/settings.json +.claude/hooks/session-start.py +``` + +If the hook ultimately calls `.trellis/scripts/get_context.py` or `session_context.py`, editing the local script is usually more robust than hard-coding content in the hook. + +## Example: Agent Did Not Read JSONL + +First confirm: + +```bash +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py validate +``` + +If the task and JSONL are correct, determine whether the platform uses hook push or agent pull. For hook push, edit `inject-subagent-context`; for agent pull, edit the agent file. + +## Notes + +- Settings handle registration, hook scripts handle behavior; inspect both together. +- Different platforms support different hook events. Do not directly copy another platform's settings. +- Hooks should read project-local `.trellis/`; they should not depend on Trellis upstream source paths. +- Hook failures should produce visible errors so AI does not silently lose context. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md b/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md new file mode 100644 index 0000000000..84590a118f --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md @@ -0,0 +1,78 @@ +# Change Local Skills, Commands, Prompts, And Workflows + +When the user wants to change AI entry points, auto-trigger rules, or explicit command behavior, edit skills, commands, prompts, or workflows in local platform directories. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Target platform skill/command/prompt/workflow directory +3. Related agent or hook files +4. Whether project rules already exist in `.trellis/spec/` + +## Which Entry Type To Choose + +| Goal | Recommendation | +| --- | --- | +| AI should automatically know a capability | Add or modify a skill. | +| User wants to trigger manually with a command | Add or modify a command/prompt/workflow. | +| Team project conventions | Prefer `.trellis/spec/` or a project-local skill. | +| Change Trellis flow semantics | Synchronize `.trellis/workflow.md`. | + +## Modify A Skill + +A skill is usually: + +```text +/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should be short and responsible for triggering/routing. Put long content in `references/` so AI can read it on demand. + +The frontmatter description should specify when to use the skill. Example: + +```yaml +description: "Use when customizing this project's deployment workflow and release checklist." +``` + +Do not write vague descriptions such as "helpful project skill"; they can trigger incorrectly. + +## Modify A Command/Prompt/Workflow + +Explicit entry points should state: + +- How the user triggers it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +If a command only repeats workflow rules, prefer making it reference/read `.trellis/workflow.md` instead of maintaining a second copy of the flow. + +## Common Paths + +| Platform | Entry directories | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Kilo / Antigravity / Windsurf | workflows + skills | + +## Add A Project-Local Skill + +If the user wants to document team-private customizations, create a project-local skill, for example: + +```text +.claude/skills/project-trellis-local/ +└── SKILL.md +``` + +For multi-platform projects, add equivalent versions in each platform skill directory, or use `.agents/skills/` on platforms that support the shared layer. + +## Notes + +- Do not mix every platform's syntax into one file. +- Do not change only one platform entry point while claiming all platforms are supported. +- Do not hide long-term engineering conventions inside a command; write them to `.trellis/spec/`. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md b/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md new file mode 100644 index 0000000000..2dea28301d --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-spec-structure.md @@ -0,0 +1,83 @@ +# Change Local Spec Structure + +When the user wants to change the engineering conventions AI follows, add new spec layers, or adjust monorepo package mapping, edit `.trellis/spec/` and `.trellis/config.yaml`. + +## Read These Files First + +1. `.trellis/config.yaml` +2. `.trellis/spec/` +3. `.trellis/workflow.md` Phase 1.3 and Phase 3.3 +4. Current task `implement.jsonl` / `check.jsonl` + +## Common Needs + +| Need | Edit location | +| --- | --- | +| Add backend/frontend/docs/test spec layer | `.trellis/spec//` or `.trellis/spec///` | +| Add shared thinking guides | `.trellis/spec/guides/` | +| Adjust monorepo packages | `packages` in `.trellis/config.yaml` | +| Change default package | `default_package` in `.trellis/config.yaml` | +| Control spec scanning scope | `spec_scope` in `.trellis/config.yaml` | +| Make a task read a new spec | Task `implement.jsonl` / `check.jsonl` | + +## Add A Spec Layer + +Single-repository example: + +```text +.trellis/spec/security/ +├── index.md +└── auth.md +``` + +Monorepo example: + +```text +.trellis/spec/webapp/security/ +├── index.md +└── auth.md +``` + +`index.md` should include: + +- What code this layer applies to. +- Pre-Development Checklist. +- Quality Check. +- Links to specific guideline files. + +## Update Context + +Adding a spec does not mean every task automatically reads it. The current task must reference it in JSONL: + +```bash +python ./.trellis/scripts/task.py add-context implement ".trellis/spec/webapp/security/index.md" "Security conventions" +python ./.trellis/scripts/task.py add-context check ".trellis/spec/webapp/security/index.md" "Security review rules" +``` + +## Change Monorepo Packages + +Example `.trellis/config.yaml`: + +```yaml +packages: + webapp: + path: apps/web + api: + path: apps/api +default_package: webapp +``` + +After editing, run: + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +Use this output to confirm AI can see the correct packages and spec layers. + +## Notes + +- Specs are user project conventions and can be changed according to project needs. +- Do not put temporary task information into specs; put temporary information in the task. +- Do not put long-term conventions only in agents or commands; preserve them in specs. +- After changing spec structure, check whether existing task JSONL files still point to files that exist. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md b/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md new file mode 100644 index 0000000000..208e0da110 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md @@ -0,0 +1,90 @@ +# Change Local Task Lifecycle + +Task lifecycle includes creation, start, context configuration, finish, archive, parent/child tasks, and lifecycle hooks. The default customization targets are `.trellis/tasks/`, `.trellis/config.yaml`, and `.trellis/scripts/`. + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/config.yaml` +3. `.trellis/scripts/task.py` +4. `.trellis/scripts/common/task_store.py` +5. `.trellis/scripts/common/task_utils.py` +6. The current task's `.trellis/tasks//task.json` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Automatically sync an external system after task creation | `hooks.after_create` in `.trellis/config.yaml`. | +| Automatically update status after task start | `hooks.after_start` in `.trellis/config.yaml`. | +| Run a script after task finish | `hooks.after_finish` in `.trellis/config.yaml`. | +| Clean external resources after archive | `hooks.after_archive` in `.trellis/config.yaml`. | +| Change default task fields | `.trellis/scripts/common/task_store.py`. | +| Change task parsing/search | `.trellis/scripts/common/task_utils.py`. | +| Change active task behavior | `.trellis/scripts/common/active_task.py`. | + +## lifecycle hooks + +`.trellis/config.yaml` supports: + +```yaml +hooks: + after_create: + - "python .trellis/scripts/hooks/my_sync.py create" + after_start: + - "python .trellis/scripts/hooks/my_sync.py start" + after_finish: + - "python .trellis/scripts/hooks/my_sync.py finish" + after_archive: + - "python .trellis/scripts/hooks/my_sync.py archive" +``` + +Hook commands receive the `TASK_JSON_PATH` environment variable, pointing to the current task's `task.json`. Hook failures should usually warn, but not block the main task operation. + +## Change Task Fields + +If the user wants to add project-local fields, prefer putting them under `meta` in `task.json` to avoid breaking existing scripts' assumptions about standard fields. + +Example: + +```json +"meta": { + "linearIssue": "ENG-123", + "risk": "high" +} +``` + +If standard fields really need to change, inspect every local script that reads `task.json`. + +## Change Active Task + +Active task is session-level state stored in `.trellis/.runtime/sessions/`. Do not fall back to a global `.current-task` model. If the user wants to change active task behavior, edit: + +- `.trellis/scripts/common/active_task.py` +- platform hooks or shell session bridges +- active task descriptions in `.trellis/workflow.md` + +### `task.py create` Sets the Active Pointer + +`cmd_create` in `.trellis/scripts/common/task_store.py` calls `set_active_task` best-effort right after writing the new task directory. The behavior: + +- When the calling shell carries session identity (`TRELLIS_CONTEXT_ID` env var, or any platform-specific session env that `resolve_context_key` recognizes — see `active_task.py:_ENV_SESSION_KEYS`), the per-session pointer at `.trellis/.runtime/sessions/.json` is rewritten to point at the new task. The task's `status=planning` and `[workflow-state:planning]` fires on the very next `UserPromptSubmit`. +- When session identity is unavailable (raw CLI invocation outside an AI session, or a platform that doesn't propagate identity to shell), the task directory is still created and `status=planning` is still written, but the active pointer is left untouched. The user can attach the task later with `task.py start ` once they're back in an AI session. + +This makes `[workflow-state:planning]` the live breadcrumb during the brainstorm and JSONL curation work that follows `task.py create`. The pre-R7 behavior left the breadcrumb stuck on `no_task` until `task.py start`, so the planning block was effectively dead text. + +If you fork `task.py` to add a new creation path (e.g. an external import that bypasses `cmd_create`), audit whether your path also calls `set_active_task`. Without that call, your created tasks will not surface as active. The full status writer table is in `.trellis/spec/cli/backend/workflow-state-contract.md`. + +## Modification Steps + +1. Confirm the current task with `python ./.trellis/scripts/task.py current --source`. +2. Read the current task's `task.json` and confirm status and fields. +3. For configuration needs, edit `.trellis/config.yaml` first. +4. For script behavior needs, then edit `.trellis/scripts/`. +5. If the AI flow changed, synchronize `.trellis/workflow.md`. + +## Do Not + +- Do not directly edit `.trellis/.runtime/sessions/` to "fix" business state. +- Do not hard-code project-private fields into scripts; prefer `meta`. +- Do not default to asking the user to fork Trellis CLI. diff --git a/.agents/skills/trellis-meta/references/customize-local/change-workflow.md b/.agents/skills/trellis-meta/references/customize-local/change-workflow.md new file mode 100644 index 0000000000..4231845adc --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/change-workflow.md @@ -0,0 +1,64 @@ +# Change Local Workflow + +When the user wants to change Trellis phases, next-action hints, whether to create tasks, whether to use sub-agents, or when to check/wrap up, edit `.trellis/workflow.md` first. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Entry files for the current platform, such as skills/commands/prompts/workflows +3. The current task's `task.json` and `prd.md` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Change phase names or phase order | `Phase Index` and the corresponding Phase sections. | +| Change whether to create a task when there is no task | `[workflow-state:no_task]` state block. | +| Change the next step during planning | Phase 1 and `[workflow-state:planning]`. | +| Change whether an agent is required during in_progress | Phase 2 and `[workflow-state:in_progress]`. | +| Change wrap-up after completion | Phase 3 and `[workflow-state:completed]`. | +| Change which skill a user intent triggers | `Skill Routing` table. | + +## Modification Steps + +1. Find the relevant section in `.trellis/workflow.md`. +2. When changing rules, keep explicit trigger conditions and next actions. +3. If adding or renaming a skill/agent, synchronize the corresponding files in platform directories. +4. Workflow-state changes only need an edit to the `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook is parser-only — it reads whatever you put in the block. Keep the opening and closing tags' STATUS strings identical (`[workflow-state:foo]…[/workflow-state:foo]`); mismatched STATUS pairs are silently dropped. +5. Make the AI reread `.trellis/workflow.md`; do not keep using rules from the old conversation. + +## Example: Relax Task Creation Requirements + +To change when task creation can be skipped, usually edit `[workflow-state:no_task]`: + +```md +[workflow-state:no_task] +Task is not required when the answer is a one-reply explanation, no files are changed, and no research is needed. +[/workflow-state:no_task] +``` + +If the formal Phase 1 flow also needs to change, synchronize the Phase 1 section. + +## Example: One Platform Does Not Use Sub-Agents + +If the user wants only one platform to avoid sub-agents, first confirm whether that platform has a separate group in the workflow. Then change Phase 2 routing for that platform group instead of deleting all `trellis-implement` / `trellis-check` instructions across platforms. + +## `/trellis:continue` Route Table + +`/trellis:continue` resumes a task by deciding which phase step to load next. The decision combines `task.json.status` with the presence of artifacts inside the task directory. The mapping is fixed in the command itself; forks that add custom statuses must extend both the workflow.md tag block and this table. + +| `status` | Artifact state | Resume at | +| --- | --- | --- | +| `planning` | `prd.md` missing | Phase 1.1 (load `trellis-brainstorm`) | +| `planning` | `prd.md` exists, `implement.jsonl` only has the seed `_example` row | Phase 1.3 (curate JSONL context) | +| `planning` | `prd.md` exists, `implement.jsonl` curated | Phase 1.4 (run `task.py start`) | +| `in_progress` | no implementation in conversation history | Phase 2.1 (`trellis-implement`) | +| `in_progress` | implementation done, no `trellis-check` run | Phase 2.2 (`trellis-check`) | +| `in_progress` | check passed | Phase 3.1 (verify quality + spec update) | +| `completed` | task is still in active tree | Phase 3.5 (run `/trellis:finish-work` to archive) | + +When you add a custom status (e.g. `in-review`), add a `[workflow-state:in-review]` block in `.trellis/workflow.md` for the per-turn breadcrumb AND extend this route table — usually by editing the `/trellis:continue` command file (`.{platform}/commands/trellis/continue.md` or equivalent) to add a row that decides where to resume from. Without the route entry, `/trellis:continue` will fall through to a default branch and the user will not land on the step you intended. + +## Notes + +`.trellis/workflow.md` is the local project workflow, not an immutable template. The user can adapt it to team habits. After editing it, platform entry files may still contain old descriptions, so inspect them too. diff --git a/.agents/skills/trellis-meta/references/customize-local/overview.md b/.agents/skills/trellis-meta/references/customize-local/overview.md new file mode 100644 index 0000000000..ac16a4c953 --- /dev/null +++ b/.agents/skills/trellis-meta/references/customize-local/overview.md @@ -0,0 +1,55 @@ +# Local Customization Overview + +This directory is for local AI working in a user project where Trellis was installed through npm and `trellis init` has already been run. The AI should modify generated `.trellis/` and platform directories inside the project, not Trellis CLI upstream source code. + +## First Determine What The User Actually Wants To Change + +| User wording | Read first | +| --- | --- | +| "Change the Trellis flow / phases / next prompt" | `change-workflow.md` | +| "Change task creation, status, archive, or hooks" | `change-task-lifecycle.md` | +| "AI did not read context / change injected content" | `change-context-loading.md` | +| "A platform hook is not behaving as expected" | `change-hooks.md` | +| "Change implement/check/research agent behavior" | `change-agents.md` | +| "Add a skill/command/workflow/prompt" | `change-skills-or-commands.md` | +| "Adjust the project spec structure" | `change-spec-structure.md` | +| "Add team conventions and local notes" | `add-project-local-conventions.md` | + +## General Operation Order + +1. **Confirm platform and directories**: inspect which directories exist, such as `.claude/`, `.codex/`, `.cursor/`. +2. **Confirm the current active task**: run `python ./.trellis/scripts/task.py current --source`. +3. **Read the local source of truth**: prefer `.trellis/workflow.md`, `.trellis/config.yaml`, and relevant platform files. +4. **Modify narrowly**: edit only files related to the user's request. +5. **Synchronize semantics**: if a shared flow changes, check whether platform entry points also need changes; if a platform entry changes, check whether `.trellis/workflow.md` still agrees. + +## Local File Priority + +| Layer | Files | +| --- | --- | +| Workflow | `.trellis/workflow.md` | +| Project configuration | `.trellis/config.yaml` | +| Task material | `.trellis/tasks//` | +| Project specs | `.trellis/spec/` | +| Runtime scripts | `.trellis/scripts/` | +| Platform integration | `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, and similar directories | +| Shared skill | `.agents/skills/` | + +## Things Not To Do By Default + +- Do not edit the global npm install directory. +- Do not edit `node_modules/@mindfoldhq/trellis`. +- Do not assume the user has the Trellis GitHub repository. +- Do not overwrite local files already modified by the user with default templates. +- Do not put team project rules into public `trellis-meta`; project rules belong in `.trellis/spec/` or a local skill. + +## When To Inspect Upstream Source + +Switch to an upstream source-code perspective only when the user explicitly expresses one of these goals: + +- "I want to open a PR to Trellis" +- "I want to change npm package publish contents" +- "I want to fork Trellis" +- "I want to modify the generation logic for `trellis init/update`" + +Otherwise, default to modifying local Trellis files inside the user project. diff --git a/.agents/skills/trellis-meta/references/local-architecture/context-injection.md b/.agents/skills/trellis-meta/references/local-architecture/context-injection.md new file mode 100644 index 0000000000..fae6fa581a --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/context-injection.md @@ -0,0 +1,68 @@ +# Local Context Injection System + +Trellis context injection aims to make AI read the right files at the right time instead of relying on model memory. In a user project, injection is implemented by `.trellis/` scripts together with platform hooks, agents, and skills. + +## Injected Context Types + +| Type | Source | Purpose | +| --- | --- | --- | +| session context | `.trellis/scripts/get_context.py` | Current developer, git status, active task, active tasks, journal, packages. | +| workflow context | `.trellis/workflow.md` | Current Trellis flow and next action. | +| spec context | `.trellis/spec/` + task JSONL | Specs that must be followed during implementation/checking. | +| task context | `.trellis/tasks//prd.md`, `info.md`, `research/` | Current task requirements, design, and research. | +| platform context | Platform hooks/settings/agents | Lets different AI tools read the files above through their own mechanisms. | + +## session-start + +Platforms with session-start support inject a Trellis overview when a session starts, clears, compacts, or receives a similar event. Injected content usually includes: + +- workflow summary. +- current task status. +- active tasks. +- spec index paths. +- developer identity and git status. + +If the user feels the AI does not know the current task in a new session, first check whether the platform's session-start hook or equivalent mechanism is installed and running. + +## workflow-state + +workflow-state is a lightweight hint injected around each user turn. Based on current task status, it selects a block from `.trellis/workflow.md`, such as `no_task`, `planning`, `in_progress`, or `completed`. + +If the user wants to change "what the AI should do next in a given state," edit the corresponding state block in `.trellis/workflow.md` first. + +## sub-agent context + +Implement and check agents need task context. Trellis has two loading modes: + +1. **hook push**: a platform hook injects `prd.md` and the files referenced by `implement.jsonl` / `check.jsonl` before the agent starts. +2. **agent pull**: the agent definition instructs the agent to read the active task, PRD, and JSONL context after startup. + +In both modes, JSONL files in the task directory are the key interface. + +## JSONL Reading Rules + +`implement.jsonl` and `check.jsonl` contain one JSON object per line: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend rules"} +``` + +Readers should skip seed rows without a `file` field. When configuring JSONL, the AI should include only spec/research files, not pre-register code files that will be modified. + +## Active Task And Context Key + +Active task state lives in `.trellis/.runtime/sessions/` and is isolated per session. Hooks try to resolve the context key from platform events, environment variables, transcript paths, or `TRELLIS_CONTEXT_ID`. + +If shell commands cannot see the same context key, `task.py current --source` may report no active task. In that case, check whether the platform passes session identity into the shell instead of hand-writing a global current-task file. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change session-start injected content | The platform's `session-start` hook or plugin file. | +| Change per-turn workflow-state rules | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The platform workflow-state hook parses these blocks verbatim and embeds no fallback text. | +| Change how sub-agents read context | Platform agent definitions, the `inject-subagent-context` hook, or agent preludes. | +| Change JSONL validation/display | `.trellis/scripts/common/task_context.py`. | +| Change active task resolution | `.trellis/scripts/common/active_task.py`. | + +When modifying context injection, verify two things: new sessions can see the correct task, and sub-agents can see the correct PRD/spec/research. diff --git a/.agents/skills/trellis-meta/references/local-architecture/generated-files.md b/.agents/skills/trellis-meta/references/local-architecture/generated-files.md new file mode 100644 index 0000000000..66f832d7de --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/generated-files.md @@ -0,0 +1,80 @@ +# Local Files Generated After Init + +`trellis init` writes the Trellis runtime into the user project. Later, `trellis update` tries to update Trellis-managed template files, but it uses `.trellis/.template-hashes.json` to determine which files have already been modified by the user. + +This page only describes files that are visible and editable inside the user project. + +## `.trellis/` + +```text +.trellis/ +├── workflow.md +├── config.yaml +├── .developer +├── .version +├── .template-hashes.json +├── .runtime/ +├── scripts/ +├── spec/ +├── tasks/ +└── workspace/ +``` + +| Path | Usually editable? | Notes | +| --- | --- | --- | +| `.trellis/workflow.md` | Yes | Local workflow documentation and AI routing rules. | +| `.trellis/config.yaml` | Yes | Project configuration, hooks, packages, journal line limits, and related settings. | +| `.trellis/spec/` | Yes | Project specs, intended to be updated regularly by users and AI. | +| `.trellis/tasks/` | Yes | Task material and research artifacts, maintained by the task workflow. | +| `.trellis/workspace/` | Yes | Session records, usually written by `add_session.py`. | +| `.trellis/scripts/` | Carefully | Local runtime. It can be customized, but only after understanding the call chain. | +| `.trellis/.runtime/` | No | Runtime state, usually written automatically by hooks/scripts. | +| `.trellis/.developer` | Carefully | Current developer identity. | +| `.trellis/.version` | No | Trellis version record used by update/migration logic. | +| `.trellis/.template-hashes.json` | No | Template hash record. Do not hand-write business rules here. | + +## Platform Directories + +Different platforms generate different directories. Common categories: + +| Category | Example paths | Purpose | +| --- | --- | --- | +| hooks | `.claude/hooks/`, `.codex/hooks/`, `.cursor/hooks/` | Inject session context, workflow-state, and sub-agent context. | +| settings | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Tell the platform when to run hooks or plugins. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define agents such as `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Skills that auto-trigger or can be read by AI. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Explicit user-invoked command or workflow entry points. | + +When modifying a platform directory, also confirm whether `.trellis/workflow.md` still describes the same flow. + +## Meaning Of Template Hashes + +`.trellis/.template-hashes.json` records the content hash from the last time Trellis wrote a template file. `trellis update` uses it to distinguish three cases: + +| Case | Update behavior | +| --- | --- | +| File was not modified by the user | It can be updated automatically. | +| File was modified by the user | Prompt the user to overwrite, keep, or generate `.new`. | +| File is no longer a current template | It may be deleted, renamed, or preserved according to migration rules. | + +When an AI customizes local Trellis files, it does not need to maintain hashes manually. It is normal for Trellis update to recognize the result as "modified by the user." + +## Local Customization Boundaries + +Editable by default: + +- `.trellis/workflow.md` +- `.trellis/config.yaml` +- `.trellis/spec/**` +- `.trellis/scripts/**` +- Platform hooks, settings, agents, skills, commands, prompts, and workflows + +Do not edit by default: + +- Global npm install directory +- `node_modules/@mindfoldhq/trellis` +- Trellis GitHub repository source code +- Concrete state files under `.trellis/.runtime/**` +- Hash contents inside `.trellis/.template-hashes.json` + +Switch to the Trellis CLI source-code perspective only when the user explicitly wants to contribute upstream. diff --git a/.agents/skills/trellis-meta/references/local-architecture/overview.md b/.agents/skills/trellis-meta/references/local-architecture/overview.md new file mode 100644 index 0000000000..99c7f73687 --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/overview.md @@ -0,0 +1,51 @@ +# Local Trellis Architecture Overview + +`trellis-meta` is for user projects that have already run `trellis init`. The user's machine usually has only the npm-installed `trellis` command plus the Trellis files generated inside the project; it may not have the Trellis CLI source code. + +Therefore, when an AI uses this skill, the default customization target is local files inside the user project: + +- `.trellis/`: workflow, tasks, specs, memory, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not default to guiding the user to fork the Trellis CLI repository. Treat upstream source code as the operating target only when the user explicitly says they want to change Trellis upstream source, publish an npm package, or contribute a PR. + +## Local System Model + +Trellis provides three layers inside a user project: + +1. **Workflow layer**: `.trellis/workflow.md` defines phases, routing, next actions, and prompt blocks. +2. **Persistence layer**: `.trellis/tasks/`, `.trellis/spec/`, and `.trellis/workspace/` store tasks, specs, and session memory. +3. **Platform integration layer**: hooks, settings, agents, skills, commands, prompts, and workflows in platform directories connect the Trellis workflow to different AI tools. + +All three layers live inside the user project, so an AI can read and modify them directly. + +## Core Paths + +| Path | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow phases, skill routing, and workflow-state prompt blocks. | +| `.trellis/config.yaml` | Project configuration, task lifecycle hooks, monorepo package configuration, and journal configuration. | +| `.trellis/spec/` | The user's project-specific coding conventions and thinking guides. | +| `.trellis/tasks/` | Each task's PRD, technical notes, research files, and JSONL context. | +| `.trellis/workspace/` | Per-developer journals and cross-session memory. | +| `.trellis/scripts/` | Local Python runtime used by commands, hooks, and context injection. | +| `.trellis/.runtime/` | Session-level runtime state, such as the current task pointer. | +| `.trellis/.template-hashes.json` | Template hashes for Trellis-managed files, used by update to determine whether local files were modified by the user. | + +## AI Customization Principles + +1. **Find the local source of truth first**: Do not edit from memory. Read `.trellis/workflow.md`, `.trellis/config.yaml`, the relevant platform directory, and related task files first. +2. **Edit the user project, not the npm package cache**: Modify generated files inside the project, not `node_modules` or the global npm install directory. +3. **Keep platform files aligned with `.trellis/`**: If workflow routing changes, also check whether platform skills or commands still describe the same flow. +4. **Put project-specific rules in `.trellis/spec/` or a local skill**: Do not put team conventions into `trellis-meta`. +5. **Preserve user changes**: If a file was already modified locally, work from the current content instead of overwriting it with a default template. + +## How To Use This Directory + +- To understand which files exist after init, read `generated-files.md`. +- To change phases, routing, or next actions, read `workflow.md`. +- To change the task model, JSONL context, or active task behavior, read `task-system.md`. +- To change coding convention injection, read `spec-system.md`. +- To understand journals and cross-session memory, read `workspace-memory.md`. +- To change hooks or sub-agent context loading, read `context-injection.md`. diff --git a/.agents/skills/trellis-meta/references/local-architecture/spec-system.md b/.agents/skills/trellis-meta/references/local-architecture/spec-system.md new file mode 100644 index 0000000000..40d6560058 --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/spec-system.md @@ -0,0 +1,102 @@ +# Local Spec System + +`.trellis/spec/` is the user's project-specific engineering spec library. Trellis is not about making AI memorize conventions; it injects relevant specs or requires the AI to read them at the right time. + +## Directory Model + +A common single-repository structure: + +```text +.trellis/spec/ +├── backend/ +│ ├── index.md +│ └── ... +├── frontend/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +A common monorepo structure: + +```text +.trellis/spec/ +├── cli/ +│ ├── backend/ +│ │ ├── index.md +│ │ └── ... +│ └── unit-test/ +│ ├── index.md +│ └── ... +├── docs-site/ +│ └── docs/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +`index.md` is the entry point for each layer. It should list the Pre-Development Checklist and Quality Check. Specific guidelines live in other Markdown files in the same directory. + +## Package Configuration + +`.trellis/config.yaml` can declare packages: + +```yaml +packages: + cli: + path: packages/cli + docs-site: + path: docs-site + type: submodule +default_package: cli +``` + +The AI can run: + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +This command lists packages and spec layers for the current project. Use this output as the reference when configuring context JSONL. + +## How Specs Enter Tasks + +Before a task enters implementation, Phase 1.3 should write relevant specs into `implement.jsonl` / `check.jsonl`: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "CLI backend conventions"} +{"file": ".trellis/spec/cli/unit-test/conventions.md", "reason": "Test expectations"} +``` + +Sub-agents or platform preludes read these JSONL files and load the referenced specs. On platforms without sub-agent support, the AI should read the relevant specs directly according to the workflow. + +## What Specs Should Contain + +Specs should contain executable engineering conventions for the project, not generic best practices: + +- Where files should live. +- How error handling should be expressed. +- Input/output contracts for APIs, hooks, and commands. +- Patterns that are forbidden. +- Cases that require tests. +- Project-specific pitfalls and how to avoid them. + +When the AI learns a new rule during implementation or debugging, it should update `.trellis/spec/` rather than only summarizing it in chat. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Add a new spec layer | `.trellis/spec///index.md` and corresponding guideline files. | +| Change monorepo spec mapping | `packages` / `default_package` / `spec_scope` in `.trellis/config.yaml`. | +| Change which specs AI reads before implementation | The task's `implement.jsonl`. | +| Change which specs AI reads during checking | The task's `check.jsonl`. | +| Change when specs should be updated | Phase 3.3 in `.trellis/workflow.md` and the `trellis-update-spec` skill. | + +## Boundaries + +`.trellis/spec/` is the user's project specification, not a permanent copy of Trellis built-in templates. The AI should encourage the user to update it according to the actual project code instead of treating Trellis default templates as immutable documents. diff --git a/.agents/skills/trellis-meta/references/local-architecture/task-system.md b/.agents/skills/trellis-meta/references/local-architecture/task-system.md new file mode 100644 index 0000000000..40e6de8a15 --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/task-system.md @@ -0,0 +1,101 @@ +# Local Task System + +The Trellis task system is stored entirely under `.trellis/tasks/` in the user project. Each task is a directory containing requirements, context, research, state, and relationship information. + +## Task Directory Structure + +```text +.trellis/tasks/ +├── 04-28-example-task/ +│ ├── task.json +│ ├── prd.md +│ ├── info.md +│ ├── implement.jsonl +│ ├── check.jsonl +│ └── research/ +└── archive/ + └── 2026-04/ +``` + +| File | Purpose | +| --- | --- | +| `task.json` | Task metadata: status, assignee, priority, branch, parent/child tasks, and similar fields. | +| `prd.md` | Requirements document; the most important business context during implementation. | +| `info.md` | Optional technical design. | +| `implement.jsonl` | List of spec/research files the implement agent must read first. | +| `check.jsonl` | List of spec/research files the check agent must read first. | +| `research/` | Research artifacts. Complex findings should not live only in chat. | + +## `task.json` + +`task.json` records task status and metadata. Common fields: + +| Field | Meaning | +| --- | --- | +| `id` / `name` / `title` | Task identity and title. | +| `status` | Status such as `planning`, `in_progress`, `review`, or `completed`. | +| `priority` | `P0`, `P1`, `P2`, `P3`. | +| `creator` / `assignee` | Creator and assignee. | +| `package` | Target package in a monorepo; may be empty. | +| `branch` / `base_branch` | Working branch and PR target branch. | +| `children` / `parent` | Parent/child task relationships. | +| `commit` / `pr_url` | Commit and PR information after completion. | +| `meta` | Extension fields. | + +The AI should not treat phase numbers as task status. Task progress is mainly determined by `status`, `prd.md`, whether JSONL context is configured, and the phase descriptions in `workflow.md`. + +## Active Task + +The user sees a "current task," but Trellis stores active task state per session. + +```text +.trellis/.runtime/sessions/.json +``` + +`task.py start` writes the task path into the runtime session file for the current session. `task.py current --source` shows the current task and where it came from. Different AI windows can point to different tasks without overwriting each other. + +If the platform or shell environment has no stable session identity, `task.py start` may be unable to set the active task. The AI should read the error, inspect the platform hook/session environment, and not fall back to a shared global pointer. + +## JSONL Context + +`implement.jsonl` and `check.jsonl` are context manifests for sub-agents to read first. + +Format: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-example/research/api.md", "reason": "API research"} +``` + +Rules: + +- Include spec and research files. +- Do not include code files that are about to be modified. +- Do not treat temporary conclusions in chat as the only context. +- Seed rows have no `file` field; they only prompt the AI to fill in real entries. + +## Common Commands + +```bash +python ./.trellis/scripts/task.py create "" --slug <slug> +python ./.trellis/scripts/task.py start <task> +python ./.trellis/scripts/task.py current --source +python ./.trellis/scripts/task.py add-context <task> implement <file> <reason> +python ./.trellis/scripts/task.py validate <task> +python ./.trellis/scripts/task.py finish +python ./.trellis/scripts/task.py archive <task> +``` + +When modifying the task system, the AI should prefer script commands to maintain structure. Edit JSON/Markdown directly only when scripts do not cover the need. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change the default task template | `.trellis/scripts/common/task_store.py` and task creation instructions. | +| Change status semantics | `.trellis/workflow.md`, workflow-state hook logic, and task usage conventions. | +| Add task lifecycle actions | `hooks.after_*` in `.trellis/config.yaml`. | +| Change context rules | Phase 1.3 in `.trellis/workflow.md` and related platform agent/hook instructions. | +| Change archive policy | `.trellis/scripts/common/task_store.py` / `task_utils.py`. | + +These are local files in the user project. Do not default to editing Trellis CLI source code unless the user wants to contribute upstream. diff --git a/.agents/skills/trellis-meta/references/local-architecture/workflow.md b/.agents/skills/trellis-meta/references/local-architecture/workflow.md new file mode 100644 index 0000000000..f0659ff15d --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/workflow.md @@ -0,0 +1,75 @@ +# Local Workflow System + +`.trellis/workflow.md` is the Trellis workflow source of truth inside the user project. An AI does not need Trellis source code to understand how the current project should move tasks forward; this file is enough. + +## File Responsibilities + +`.trellis/workflow.md` has three responsibilities: + +1. **Explain workflow phases**: Plan, Execute, Finish. +2. **Define skill routing**: which skill or agent the AI should use when the user expresses a certain intent. +3. **Provide workflow-state prompt blocks**: hooks can inject the prompt block for the current state into the conversation. + +## Current Phase Model + +```text +Phase 1: Plan -> clarify what to build, produce prd.md and required research +Phase 2: Execute -> implement against the PRD and specs, then check +Phase 3: Finish -> final verification, preserve lessons, and wrap up +``` + +Each phase contains numbered steps, such as `1.3 Configure context`. These numbers are not runtime fields in `task.json`; they are workflow structure for AI and humans to read. + +## Skill Routing + +`workflow.md` separates routing by platform capability: + +- Platforms with sub-agent support: dispatch `trellis-implement` by default for implementation and `trellis-check` for checking. +- Platforms without sub-agent support: the main session reads skills such as `trellis-before-dev`, then executes directly. + +When changing local AI behavior, update the routing descriptions in `workflow.md` first, then check whether the corresponding platform skill, command, or agent files need to stay in sync. + +## Workflow-State Prompt Blocks + +The bottom of `workflow.md` can contain state blocks like this: + +```text +[workflow-state:no_task] +... +[/workflow-state:no_task] +``` + +Hooks choose the right block based on current task status and inject it into the conversation. Common states include: + +| State | Meaning | +| --- | --- | +| `no_task` | The current session has no active task. | +| `planning` | The task is still in requirements, research, or context configuration. | +| `in_progress` | The task has entered implementation and checking. | +| `completed` | The task is complete and waiting for wrap-up or archive. | + +If the user wants to change policies such as "whether to create a task when there is no task," "when task creation may be skipped," or "whether sub-agents are required," edit these state blocks and the routing table above them. + +## Local Modification Patterns + +Common changes: + +| Goal | Edit point | +| --- | --- | +| Add a phase | Update the Phase Index, phase body, routing, and state blocks. | +| Change task creation policy | Update the `no_task` state block and Phase 1 description. | +| Change the default implementation/check path | Update Phase 2 and skill routing. | +| Change the wrap-up flow | Update Phase 3 and `finish-work` related descriptions. Note the current split: Phase 3.4 = AI-driven code commits (batched, user-confirmed), Phase 3.5 = `/finish-work` (archive + record session). `/finish-work` refuses to run if the working tree is dirty. | +| Change platform differences | Update routing descriptions grouped by platform. | + +After editing, make the AI reread `.trellis/workflow.md`; do not assume the flow from the old conversation is still valid. + +## Relationship To Platform Files + +`workflow.md` is the semantic center of the local workflow, but each platform can also have its own entry files: + +- skills, such as `trellis-brainstorm` and `trellis-check`. +- commands/prompts/workflows, such as continue and finish-work. +- hooks, such as session-start or workflow-state injection. + +If only `workflow.md` changes, platform entry files may still contain old language. When the user wants to change "what the AI actually does," also inspect the relevant platform directory. diff --git a/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md b/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md new file mode 100644 index 0000000000..92d29f49bc --- /dev/null +++ b/.agents/skills/trellis-meta/references/local-architecture/workspace-memory.md @@ -0,0 +1,71 @@ +# Local Workspace Memory System + +`.trellis/workspace/` stores cross-session memory. Its purpose is to let AI and humans understand what happened before across different windows and different days. + +## Directory Structure + +```text +.trellis/workspace/ +├── index.md +└── <developer>/ + ├── index.md + ├── journal-1.md + └── journal-2.md +``` + +| File | Purpose | +| --- | --- | +| `.trellis/.developer` | Current developer identity. | +| `.trellis/workspace/index.md` | Global workspace overview. | +| `.trellis/workspace/<developer>/index.md` | Session index for a developer. | +| `.trellis/workspace/<developer>/journal-N.md` | Session journal. | + +## Developer Identity + +Run this the first time: + +```bash +python ./.trellis/scripts/init_developer.py <name> +``` + +This creates `.trellis/.developer` and the corresponding workspace directory. The AI should not change developer identity casually; if the identity is wrong, first confirm who is using the current project. + +## Journal + +`journal-N.md` records completed or partially completed work from each session. By default, each journal holds about 2000 lines; after that it rotates to the next file. + +Common command for recording a session: + +```bash +python ./.trellis/scripts/add_session.py \ + --title "Session title" \ + --summary "What changed" \ + --commit "abc1234" +``` + +Planning or review work without a commit can also be recorded by using `--no-commit` or an empty commit value. + +## Relationship Between Workspace Memory And Tasks + +| System | What it stores | +| --- | --- | +| `.trellis/tasks/` | Requirements, design, research, and state for a specific task. | +| `.trellis/workspace/` | Work records across tasks and sessions. | +| `.trellis/spec/` | Engineering knowledge preserved as long-term conventions. | + +If information is only useful for the current task, put it in the task directory. +If information describes what happened in the current session, put it in the workspace journal. +If information should be followed every time code is written in the future, put it in spec. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change maximum journal lines | `max_journal_lines` in `.trellis/config.yaml`. | +| Change session auto-commit message | `session_commit_message` in `.trellis/config.yaml`. | +| Change session content format | `.trellis/scripts/add_session.py`. | +| Change how workspace is displayed in context | `.trellis/scripts/common/session_context.py`. | + +## AI Usage Rules + +The AI should not treat workspace as the only source of truth. When resuming a task, read the current task first, then use workspace for background. After a task is complete, record important process notes in workspace; if long-term rules emerged, update spec. diff --git a/.agents/skills/trellis-meta/references/platform-files/agents.md b/.agents/skills/trellis-meta/references/platform-files/agents.md new file mode 100644 index 0000000000..a624a66104 --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/agents.md @@ -0,0 +1,79 @@ +# Agents + +Trellis agent files define specialized roles. Common Trellis agents in a user project are: + +- `trellis-research` +- `trellis-implement` +- `trellis-check` + +File locations and formats differ by platform, but responsibility boundaries should stay consistent. + +## Agent Responsibilities + +| Agent | Responsibility | +| --- | --- | +| `trellis-research` | Investigate the question and write findings into the current task's `research/`. | +| `trellis-implement` | Implement against `prd.md`, `info.md`, `implement.jsonl`, and related spec/research. | +| `trellis-check` | Review changes, fix discovered issues, and run necessary checks. | + +Agent files should not become generic chat prompts. They should define input sources, write boundaries, whether code may be changed, and how results are reported. + +## Common Paths + +| Platform | Agent path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +GitHub Copilot agent/prompt support is provided by a combination of directories such as `.github/agents/`, `.github/prompts/`, and `.github/skills/`; inspect the files actually generated in the user project. + +Main-session workflow platforms such as Kilo, Antigravity, and Windsurf may not have Trellis sub-agent files. They usually rely on workflows/skills to guide the main session. + +## Two Context Loading Modes + +### hook push + +The platform hook injects task context before the agent starts. The agent file itself can focus more on responsibilities and boundaries. + +Common on platforms that support agent hooks. + +### agent pull + +The agent file instructs the agent to read after startup: + +- `python ./.trellis/scripts/task.py current --source` +- current task `prd.md` +- `info.md` +- `implement.jsonl` or `check.jsonl` +- spec/research files referenced by JSONL + +This mode fits platforms whose hooks cannot reliably rewrite sub-agent prompts. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Implement agent must follow extra restrictions | The platform's `trellis-implement` agent file. | +| Check agent must run project-specific commands | `trellis-check` agent file, and `.trellis/spec/` if needed. | +| Research agent must output a fixed format | `trellis-research` agent file. | +| Agent cannot read task context | Agent prelude or `inject-subagent-context` hook. | +| Add a project-specific agent | Platform agent directory + related workflow/command/skill entry point. | + +## Modification Principles + +1. **Keep responsibilities single-purpose**. Do not mix research, implement, and check responsibilities into one agent. +2. **Specify the read order**. Agents must know to start from the active task and then find the PRD and JSONL. +3. **Specify write boundaries**. Research usually only writes `research/`; implement can write code; check can fix issues. +4. **Keep semantics synchronized in multi-platform projects**. If the user configured Claude, Codex, and Cursor together, decide whether changes to one platform's agent also need to be applied to others. + +## Do Not Default To Editing Upstream Templates + +Local AI should default to modifying platform agent files inside the user project. Discuss upstream template source only when the user explicitly wants to contribute the change back to Trellis. diff --git a/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md new file mode 100644 index 0000000000..94156a88df --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -0,0 +1,69 @@ +# Hooks And Settings + +Hooks/settings are the entry layer that connects a platform to Trellis. They decide which scripts, plugins, or extensions a platform runs for which events. + +## Settings Responsibilities + +settings/config files usually register: + +- session-start hook: injects a Trellis overview when a new session starts or context resets. +- workflow-state hook: parses `[workflow-state:STATUS]` blocks from `.trellis/workflow.md` and emits the body matching the current task `status` on each user input. Parser-only; the script does not embed fallback content. +- sub-agent context hook: injects task context when implementation/check/research agents start. +- shell/session bridge: lets shell commands see the same Trellis session identity. +- platform plugin or extension entry points. + +Common files: + +| Platform | settings/config | +| --- | --- | +| Claude Code | `.claude/settings.json` | +| Cursor | `.cursor/hooks.json` | +| Codex | `.codex/hooks.json`, `.codex/config.toml` | +| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` | +| Kiro | `.kiro/hooks/` + platform config | +| Gemini CLI | `.gemini/settings.json` | +| Qoder | `.qoder/settings.json` | +| CodeBuddy | `.codebuddy/settings.json` | +| GitHub Copilot | `.github/copilot/hooks.json` | +| Factory Droid | `.factory/settings.json` | +| Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` | + +Whether these files exist in a project depends on which `trellis init --<platform>` flags the user ran. + +## Hook Script Types + +| Script | Purpose | +| --- | --- | +| `session-start.py` | Generates session-start context. | +| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. | +| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. | +| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. | + +Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| AI should see more/less context in a new session | Platform `session-start` hook. | +| Per-turn hint policy should change | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook parses workflow.md verbatim — no script edit required. | +| Sub-agent cannot read PRD/spec | `inject-subagent-context` hook or agent prelude. | +| `task.py current` in shell has no active task | Shell/session bridge hook or platform environment variable configuration. | +| Disable an automatic injection | The corresponding hook registration in settings/config. | + +## Modification Principles + +1. **Settings wire things up; hooks define behavior**. If only the hook changes, the platform may never call it. If only settings change, behavior may not change. +2. **Confirm platform event names first**. Different platforms use different names for SessionStart, UserPromptSubmit, AgentSpawn, shell execution, and similar events. +3. **Hooks read local `.trellis/`, not upstream source**. `.trellis/scripts/` and `.trellis/workflow.md` in the user project are the default targets. +4. **Errors must be visible**. Hook failures should tell the user what was not injected instead of silently leaving the AI without context. + +## Troubleshooting Path + +If the user says "AI did not read Trellis state": + +1. Check whether the platform settings register the hook. +2. Check whether the hook file exists. +3. Manually run the `.trellis/scripts/get_context.py` or `task.py current --source` command that the hook depends on. +4. Check whether active task state exists in `.trellis/.runtime/sessions/`. +5. Check whether the platform shell passes session identity. diff --git a/.agents/skills/trellis-meta/references/platform-files/overview.md b/.agents/skills/trellis-meta/references/platform-files/overview.md new file mode 100644 index 0000000000..60ae1dfb8d --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/overview.md @@ -0,0 +1,59 @@ +# Platform Files Overview + +Trellis connects the same local architecture to different AI tools. `.trellis/` stores the shared runtime; platform directories store adapter files that define how each AI tool enters Trellis. + +When a local AI modifies Trellis, it should distinguish two file categories first: + +- **Shared files**: `.trellis/workflow.md`, `.trellis/tasks/`, `.trellis/spec/`, `.trellis/scripts/`. +- **Platform files**: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. + +Platform files do not store business state. They let the corresponding AI tool read Trellis state, call Trellis scripts, and load Trellis skills/agents/hooks. + +## Platform File Categories + +| Category | Common paths | Purpose | +| --- | --- | --- | +| settings/config | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Register hooks, plugins, extensions, or platform behavior. | +| hooks/plugins/extensions | `.claude/hooks/`, `.opencode/plugins/`, `.pi/extensions/` | Inject context at session start, user input, agent startup, shell execution, and similar events. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Capability descriptions that auto-trigger or can be read on demand. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Entry points explicitly invoked by the user. | + +## Three Platform Integration Modes + +### 1. Hook / Extension Driven + +These platforms can trigger scripts or plugins on specific events and actively inject Trellis context into AI. + +Common capabilities: + +- session-start injection of a `.trellis/` overview. +- workflow-state hints for each user turn. +- PRD/spec/research injection when sub-agents start. +- Shell commands inheriting session identity. + +To change "when the AI knows what," inspect hooks/plugins/extensions and settings first. + +### 2. Agent Prelude / Pull-Based + +Some platforms cannot reliably let hooks rewrite sub-agent prompts, so the agent file itself instructs the agent to read the active task, PRD, and JSONL context after startup. + +To change how sub-agents load context, inspect the agent files themselves. + +### 3. Main-Session Workflow + +Some platforms do not have Trellis sub-agent or hook capabilities. They rely on workflows/skills/commands to guide the main-session AI to read files, run scripts, and move tasks forward. + +To change behavior, inspect platform workflows/skills/commands and `.trellis/workflow.md`. + +## Local Modification Order + +When the user asks to customize behavior for a platform, the AI should inspect files in this order: + +1. Read `.trellis/workflow.md` to confirm the shared flow. +2. Read the target platform's settings/config to see which hooks/agents/skills/commands are registered. +3. Read the target platform's agents/skills/commands/hooks. +4. Modify the local file closest to the user's need. +5. If the change affects the shared flow, synchronize `.trellis/workflow.md` or `.trellis/spec/`. + +Do not modify only platform files and forget the shared workflow. Do not modify only `.trellis/workflow.md` and forget that platform entry points may still contain old descriptions. diff --git a/.agents/skills/trellis-meta/references/platform-files/platform-map.md b/.agents/skills/trellis-meta/references/platform-files/platform-map.md new file mode 100644 index 0000000000..b5576f42cd --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/platform-map.md @@ -0,0 +1,74 @@ +# Platform File Map + +This page lists common Trellis file locations in a user project by platform. Whether a platform directory exists in an actual project depends on which `trellis init --<platform>` commands the user ran. + +## Matrix + +| Platform | CLI flag | Main directory | Skill directory | Agent directory | Hooks/extensions | +| --- | --- | --- | --- | --- | --- | +| Claude Code | `--claude` | `.claude/` | `.claude/skills/` | `.claude/agents/` | `.claude/hooks/` + `.claude/settings.json` | +| Cursor | `--cursor` | `.cursor/` | `.cursor/skills/` | `.cursor/agents/` | `.cursor/hooks.json` + `.cursor/hooks/` | +| OpenCode | `--opencode` | `.opencode/` | `.opencode/skills/` | `.opencode/agents/` | `.opencode/plugins/` | +| Codex | `--codex` | `.codex/` | `.agents/skills/` | `.codex/agents/` | `.codex/hooks/` + `.codex/hooks.json` | +| Kilo | `--kilo` | `.kilocode/` | `.kilocode/skills/` | Usually none | `.kilocode/workflows/` | +| Kiro | `--kiro` | `.kiro/` | `.kiro/skills/` | `.kiro/agents/` | `.kiro/hooks/` | +| Gemini CLI | `--gemini` | `.gemini/` | `.agents/skills/` | `.gemini/agents/` | `.gemini/settings.json` + `.gemini/hooks/` | +| Antigravity | `--antigravity` | `.agent/` | `.agent/skills/` | Usually none | `.agent/workflows/` | +| Windsurf | `--windsurf` | `.windsurf/` | `.windsurf/skills/` | Usually none | `.windsurf/workflows/` | +| Qoder | `--qoder` | `.qoder/` | `.qoder/skills/` | `.qoder/agents/` | `.qoder/hooks/` + `.qoder/settings.json` | +| CodeBuddy | `--codebuddy` | `.codebuddy/` | `.codebuddy/skills/` | `.codebuddy/agents/` | `.codebuddy/hooks/` + `.codebuddy/settings.json` | +| GitHub Copilot | `--copilot` | `.github/` | `.github/skills/` | `.github/agents/` | `.github/copilot/hooks/` + prompts | +| Factory Droid | `--droid` | `.factory/` | `.factory/skills/` | `.factory/droids/` | `.factory/hooks/` + settings | +| Pi Agent | `--pi` | `.pi/` | `.pi/skills/` | `.pi/agents/` | `.pi/extensions/trellis/` + `.pi/settings.json` | + +## Capability Groups + +### Trellis Sub-Agent Support + +These platforms usually have `trellis-research`, `trellis-implement`, and `trellis-check` files: + +- Claude Code +- Cursor +- OpenCode +- Codex +- Kiro +- Gemini CLI +- Qoder +- CodeBuddy +- GitHub Copilot +- Factory Droid +- Pi Agent + +When changing implementation/check/research behavior, look for the corresponding platform agent files first. + +### Main-Session Workflow Platforms + +These platforms rely more on workflows/skills to guide the main session: + +- Kilo +- Antigravity +- Windsurf + +When changing behavior, inspect workflows and skills first. Do not assume Trellis sub-agents exist. + +### Shared `.agents/skills/` + +Codex writes the shared `.agents/skills/` layer. Some tools that support agentskills.io can also read this directory. If the user wants multiple compatible tools to share one skill, consider `.agents/skills/` first, but do not assume every platform reads it. + +## Decision Rules When Modifying Platform Files + +1. User specified a platform: modify only that platform directory unless shared workflow/spec files must also change. +2. User says "all platforms should do this": synchronize equivalent entry points platform by platform; do not modify only one directory. +3. User only says "my AI": inspect the configuration directories that actually exist in the project and infer the current AI platform. +4. User wants project rules: prefer `.trellis/spec/` or a project-local skill. +5. User wants Trellis behavior: edit `.trellis/workflow.md` plus platform hooks/agents/skills/commands. + +## When Paths Differ + +Platform ecosystems change, and user projects may already be customized. If this table disagrees with local files, use the actual settings/config in the user project as authoritative: + +- Check the hook that settings registers. +- Check the script that a command/prompt/workflow points to. +- Judge behavior by the read rules currently written in the agent file. + +Do not delete a custom file just because it is not listed in this path table. diff --git a/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md b/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md new file mode 100644 index 0000000000..816c666a88 --- /dev/null +++ b/.agents/skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -0,0 +1,83 @@ +# Skills, Commands, Prompts, And Workflows + +Skills and commands are textual entry points for user interaction with Trellis. Different platforms use different names, but their core purpose is the same: tell the AI how to enter the Trellis flow when the user expresses a certain intent. + +## Conceptual Differences + +| Type | Trigger mode | Best for | +| --- | --- | --- | +| skill | AI auto-match or explicit user mention | Long-term capabilities, workflow rules, modification guides. | +| command | Explicit user invocation | Clear operation entry points such as continue and finish-work. | +| prompt | Explicit user invocation or platform selection | Similar to command, but in a platform prompt format. | +| workflow | Explicit user selection or platform auto-match | Guides the main session when no sub-agent/hook exists. | + +Trellis workflow skills usually share one semantic set: brainstorm, before-dev, check, update-spec, break-loop. Multi-file built-in skills such as `trellis-meta` use layered references. + +## Common Paths + +| Platform | Common entries | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| Kilo | `.kilocode/skills/`, `.kilocode/workflows/` | +| Kiro | `.kiro/skills/` | +| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | +| Antigravity | `.agent/skills/`, `.agent/workflows/` | +| Windsurf | `.windsurf/skills/`, `.windsurf/workflows/` | +| Qoder | `.qoder/skills/`, `.qoder/commands/` | +| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Factory Droid | `.factory/skills/`, `.factory/commands/` | +| Pi Agent | `.pi/skills/` | + +In a user project, use the files actually generated by init as authoritative. + +## Skill Structure + +A common skill is a directory: + +```text +trellis-meta/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should tell the AI: + +- When to use this skill. +- Which reference to read first for the current task. +- What not to do. + +References hold longer explanations so the entry file does not contain everything. + +## Command/Prompt/Workflow Structure + +Commands, prompts, and workflows are usually single files. Their content should include: + +- When to use it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +They should not store task state; task state belongs in `.trellis/tasks/` and `.trellis/.runtime/`. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Change AI auto-trigger rules | The corresponding skill's frontmatter description. | +| Change user command behavior | The corresponding command/prompt/workflow file. | +| Add a project-local skill | Platform skill directory, or shared `.agents/skills/`. | +| Let multiple platforms share one capability | Write equivalent skills in each platform skill directory, or use the `.agents/skills/` shared layer on platforms that support it. | +| Change finish/continue entry points | Platform commands/prompts/workflows. | + +## Modification Principles + +1. **Keep entry files short; references carry long content**. This matters especially for multi-file skills like `trellis-meta`. +2. **Make trigger descriptions specific**. A description that is too broad can mis-trigger; one that is too narrow may not trigger. +3. **Keep the same semantics consistent across platforms**. File formats can differ, but behavior descriptions should match. +4. **Put project-specific capabilities in local skills**. Do not put team-private flows into public `trellis-meta`. + +If the user only wants local AI to know one more project rule, usually create a project-local skill or update `.trellis/spec/` instead of changing a Trellis built-in workflow skill. diff --git a/.agents/skills/trellis-spec-bootstarp/SKILL.md b/.agents/skills/trellis-spec-bootstarp/SKILL.md new file mode 100644 index 0000000000..2f7c7ad8ff --- /dev/null +++ b/.agents/skills/trellis-spec-bootstarp/SKILL.md @@ -0,0 +1,41 @@ +--- +name: trellis-spec-bootstarp +description: "Bootstrap project-specific Trellis coding specs with a platform-neutral single-agent workflow. Use when creating or refreshing .trellis/spec guidelines, analyzing a codebase with GitNexus, ABCoder, or source inspection, decomposing package/layer spec work, and writing real codebase-backed spec docs without placeholder text." +--- + +# Trellis Spec Bootstarp + +Use this skill to create or refresh `.trellis/spec/` guidelines from the real codebase. One capable agent owns the full loop: analyze the repository, choose the spec boundaries, write the docs, and verify the result. The workflow does not depend on a specific host, CLI, or agent brand. + +## Workflow + +1. Confirm Trellis is initialized and inspect the current `.trellis/spec/` tree. +2. Analyze the repository architecture with the best available tools: GitNexus, ABCoder, language tooling, and direct source reads. +3. Decompose the spec work by package and layer only when that reflects the actual codebase. +4. Fill or reshape the spec files with concrete patterns, file paths, examples, and anti-patterns from the project. +5. Verify that the final specs are internally consistent and contain no template placeholders. + +## Reference Routing + +| Need | Read | +|------|------| +| Repository architecture analysis | [references/repository-analysis.md](references/repository-analysis.md) | +| Spec work decomposition and task planning | [references/spec-task-planning.md](references/spec-task-planning.md) | +| Writing high-signal Trellis spec files | [references/spec-writing.md](references/spec-writing.md) | +| GitNexus and ABCoder MCP setup | [references/mcp-setup.md](references/mcp-setup.md) | + +## Operating Rules + +- Treat templates as starting points, not contracts. Delete, rename, split, or add spec files when the repository calls for it. +- Prefer source-backed rules over generic advice. Every important recommendation should point at a real file or repeated local pattern. +- Keep execution single-owner by default. Optional helper agents are an implementation detail, not a requirement or user-visible dependency. +- Do not write platform-specific instructions unless the target project already standardizes on that platform. +- Do not leave placeholder text, empty headings, or copied boilerplate in `.trellis/spec/`. + +## Done Criteria + +- `.trellis/spec/` describes the project as it exists now. +- Each relevant package or layer has practical coding guidance with real examples. +- Non-applicable template sections are removed. +- `index.md` files match the final spec file set. +- Any required setup or analysis assumptions are documented in the relevant spec or task notes. diff --git a/.agents/skills/trellis-spec-bootstarp/references/mcp-setup.md b/.agents/skills/trellis-spec-bootstarp/references/mcp-setup.md new file mode 100644 index 0000000000..629fcbda87 --- /dev/null +++ b/.agents/skills/trellis-spec-bootstarp/references/mcp-setup.md @@ -0,0 +1,90 @@ +# MCP Setup + +GitNexus and ABCoder are recommended when bootstrapping Trellis specs because they expose architecture and AST context to the agent. They are tool choices, not platform requirements. Configure them through whatever MCP mechanism your agent host provides. + +## GitNexus + +GitNexus builds a code knowledge graph from the repository. Use it for module boundaries, execution flows, dependency relationships, blast radius, and graph queries. + +### Install and Index + +```bash +# Run from the repository root. +npx gitnexus analyze + +# Check index status. +npx gitnexus status + +# Re-index after code changes when the analysis is stale. +npx gitnexus analyze +``` + +The index is written to `.gitnexus/`. Keep embeddings only if the project already uses them; otherwise a normal index is enough for spec bootstrapping. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +npx -y gitnexus mcp +``` + +### Useful Tools + +| Tool | Purpose | +|------|---------| +| `gitnexus_query` | Find execution flows and functional areas by concept | +| `gitnexus_context` | Inspect callers, callees, references, and process participation for a symbol | +| `gitnexus_impact` | Understand blast radius before changing a symbol | +| `gitnexus_detect_changes` | Check changed symbols and affected flows before finishing | +| `gitnexus_cypher` | Run direct graph queries | +| `gitnexus_list_repos` | List indexed repositories | + +## ABCoder + +ABCoder parses code into UniAST and gives precise package, file, and node-level structure. Use it for signatures, type shapes, implementations, dependencies, and reverse references. + +### Install + +```bash +go install github.com/cloudwego/abcoder@latest +abcoder --help +``` + +### Parse Repositories + +```bash +abcoder parse /absolute/path/to/package \ + --lang typescript \ + --name package-name \ + --output ~/abcoder-asts +``` + +For monorepos, parse each package with a stable `--name` so task notes can reference the same repository names. + +### MCP Server Command + +Use this server command in the host's MCP configuration: + +```bash +abcoder mcp ~/abcoder-asts +``` + +### Useful Tools + +| Tool | Layer | Purpose | +|------|-------|---------| +| `list_repos` | 1 | List parsed repositories | +| `get_repo_structure` | 2 | Inspect packages and files | +| `get_package_structure` | 3 | Inspect nodes within a package | +| `get_file_structure` | 3 | Inspect functions, classes, types, and signatures in a file | +| `get_ast_node` | 4 | Retrieve code, dependencies, references, and implementations | + +## Verification + +After configuration, verify from the agent host that both MCP servers are visible. Then run one simple query against each server before starting the spec writing pass. + +```bash +ls .gitnexus/meta.json +ls ~/abcoder-asts/*.json +``` diff --git a/.agents/skills/trellis-spec-bootstarp/references/repository-analysis.md b/.agents/skills/trellis-spec-bootstarp/references/repository-analysis.md new file mode 100644 index 0000000000..1309d293df --- /dev/null +++ b/.agents/skills/trellis-spec-bootstarp/references/repository-analysis.md @@ -0,0 +1,59 @@ +# Repository Analysis + +The goal is to discover the project's real architecture before writing rules. Do not start from generic spec templates and fill blanks. Start from the code, then let the spec structure follow. + +## Analysis Order + +1. Read the existing `.trellis/spec/` tree and note which files are templates, outdated, or already project-specific. +2. Inspect package manifests, build scripts, workspace config, and top-level documentation to identify packages and runtime layers. +3. Use GitNexus for execution flows, module clusters, dependency hubs, and impact-sensitive areas. +4. Use ABCoder or language-native tooling for exact signatures, types, class boundaries, and implementation examples. +5. Read representative source and test files directly before turning any finding into a spec rule. + +## What To Capture + +| Area | Questions | +|------|-----------| +| Package boundaries | What does each package own? What imports cross boundaries? | +| Runtime layers | Which code is CLI, backend, frontend, worker, shared library, test-only, or tooling? | +| Core abstractions | Which types, services, stores, commands, routes, or adapters define the system shape? | +| Data flow | Where does user input enter, how is it validated, and where does state persist? | +| Error handling | How are failures represented, logged, surfaced, and tested? | +| Configuration | Where do defaults, environment config, generated files, and templates live? | +| Tests | Which test styles are trusted examples for new work? | + +## GitNexus Usage + +Start broad, then inspect specific symbols: + +```text +gitnexus_query({query: "CLI command execution flow"}) +gitnexus_query({query: "template generation and migration"}) +gitnexus_context({name: "SymbolName"}) +gitnexus_cypher({query: "MATCH (n)-[r]->(m) RETURN n.name, type(r), m.name LIMIT 30"}) +``` + +Use GitNexus results to find important files and flows. Do not quote graph output as the final authority until you have checked the relevant source files. + +## ABCoder Usage + +Use ABCoder when the spec needs exact code shapes: + +```text +list_repos() +get_repo_structure({repo_name: "package-name"}) +get_file_structure({repo_name: "package-name", file_path: "src/example.ts"}) +get_ast_node({repo_name: "package-name", node_ids: [{mod_path: "...", pkg_path: "...", name: "SymbolName"}]}) +``` + +ABCoder is most valuable for documenting constructor patterns, function signatures, type contracts, and reference chains. + +## Analysis Notes + +Keep short notes while analyzing. The notes should include: + +- Package or layer name. +- Files that define the local pattern. +- Rules the spec should teach. +- Anti-patterns found in old code, comments, tests, or migration paths. +- Spec files that should be created, deleted, renamed, or merged. diff --git a/.agents/skills/trellis-spec-bootstarp/references/spec-task-planning.md b/.agents/skills/trellis-spec-bootstarp/references/spec-task-planning.md new file mode 100644 index 0000000000..dca26871a1 --- /dev/null +++ b/.agents/skills/trellis-spec-bootstarp/references/spec-task-planning.md @@ -0,0 +1,61 @@ +# Spec Task Planning + +Use a single agent as the default execution model. The agent may create Trellis tasks for traceability, but the skill should not require a specific platform, CLI, or parallel worker model. + +## Decomposition + +Create spec work units around real ownership boundaries: + +- One package when a package has its own conventions. +- One layer when the same package has distinct frontend, backend, CLI, worker, or shared-library rules. +- One cross-cutting guide when a pattern spans packages and is not owned by one layer. + +Avoid artificial decomposition. A small library usually needs one focused spec pass, not several tasks. + +## Task Shape + +When a Trellis task is useful, write a concise PRD with these sections: + +```markdown +# Fill <package-or-layer> Trellis Specs + +## Goal +Write project-specific `.trellis/spec/` guidance for <scope>. + +## Scope +- Spec directory: +- Source directories to inspect: +- Tests to inspect: +- Out of scope: + +## Architecture Context +Summarize the concrete findings from repository analysis. + +## Files To Create Or Update +- `.trellis/spec/.../index.md` +- `.trellis/spec/.../<topic>.md` + +## Rules +- Adapt the spec file set to the real codebase. +- Use real source examples with file paths. +- Remove template-only sections that do not apply. +- Do not modify product source code unless the task explicitly asks for it. + +## Acceptance Criteria +- [ ] Specs contain concrete examples and anti-patterns from the repository. +- [ ] No placeholder text remains. +- [ ] Index files match the final spec files. +- [ ] Claims are backed by source files, tests, or project docs. +``` + +## Optional Helper Agents + +If the host supports subagents, helpers can inspect independent packages or run verification. They are optional. The main agent still owns integration and final quality. + +Helper tasks must have clear ownership: + +- Read-only research tasks may inspect any source needed for the assigned scope. +- Write tasks should own disjoint spec directories. +- Verification tasks should check placeholder removal, broken links, and consistency. + +Do not encode helper-agent names, vendor-specific commands, or platform-specific routing in the skill. Put only the required work and acceptance criteria in the task. diff --git a/.agents/skills/trellis-spec-bootstarp/references/spec-writing.md b/.agents/skills/trellis-spec-bootstarp/references/spec-writing.md new file mode 100644 index 0000000000..6bc7dec821 --- /dev/null +++ b/.agents/skills/trellis-spec-bootstarp/references/spec-writing.md @@ -0,0 +1,70 @@ +# Spec Writing + +Trellis specs are coding guidance for future agents. They should explain how to work in this repository, not how a generic project might be organized. + +## Write From Evidence + +Each important rule should be backed by one of these: + +- A source file that demonstrates the preferred pattern. +- A test file that shows expected behavior. +- A project document that defines the convention. +- A repeated pattern across multiple files. + +Use short snippets only when they make the rule clearer. Prefer linking to the file path and naming the symbol or behavior. + +## File Structure + +Keep the spec tree aligned with the project: + +- Keep `index.md` as the navigation file for the spec directory. +- Split topics when developers would look for them independently. +- Merge topics when separate files would repeat the same rule. +- Delete template files that do not apply. +- Add new files for important local patterns the template missed. + +## Content Standards + +Good spec sections include: + +- When the rule applies. +- The local pattern to follow. +- The source or test files that prove the pattern. +- Common mistakes or anti-patterns. +- Verification commands or checks when they are specific and reliable. + +Avoid: + +- Placeholder prose. +- Generic framework advice. +- Tool instructions that only work in one agent host. +- Long copied code blocks. +- Rules based on a single accidental implementation detail. + +## Example Shape + +```markdown +## Command Handlers + +Command handlers should keep argument parsing, validation, and side effects separate. The local pattern is: + +- Parse CLI flags at the command boundary. +- Convert raw inputs into typed task options before invoking core logic. +- Keep filesystem writes in the command or service layer, not in template helpers. + +Reference files: +- `packages/cli/src/commands/example.ts` +- `packages/cli/test/commands/example.test.ts` + +Avoid passing raw `process.argv` or unvalidated config objects into shared helpers. +``` + +## Final Pass + +Before finishing: + +```bash +grep -R "To be filled\\|TODO: fill\\|placeholder" .trellis/spec +``` + +Also check links, index files, and whether any spec still describes a template rather than this repository. diff --git a/.agents/skills/trellis-start/SKILL.md b/.agents/skills/trellis-start/SKILL.md new file mode 100644 index 0000000000..64f11b6b14 --- /dev/null +++ b/.agents/skills/trellis-start/SKILL.md @@ -0,0 +1,63 @@ +--- +name: trellis-start +description: "Initializes an AI development session by reading workflow guides, developer identity, git status, active tasks, and project guidelines from .trellis/. Classifies incoming tasks and routes to brainstorm, direct edit, or task workflow. Use when beginning a new coding session, resuming work, starting a new task, or re-establishing project context." +--- + +# Start Session + +Initialize a Trellis-managed development session. This platform has no session-start hook, so manually load the equivalent context by following these steps (each one mirrors a section the hook would otherwise inject). + +--- + +## Step 1: Current state +Identity, git status, current task, active tasks, journal location. + +```bash +python ./.trellis/scripts/get_context.py +``` + +If this output includes a line beginning `Trellis update available:`, copy the full line verbatim when summarizing session context. Do not shorten operational command hints. + +## Step 2: Workflow overview +Phase Index + skill routing table + DO-NOT-skip rules. + +```bash +python ./.trellis/scripts/get_context.py --mode phase +``` + +Full guide in `.trellis/workflow.md` (read on demand). + +## Step 3: Guideline indexes +Discover packages + spec layers, then read each relevant index file. + +```bash +python ./.trellis/scripts/get_context.py --mode packages +cat .trellis/spec/guides/index.md +cat .trellis/spec/<package>/<layer>/index.md # for each relevant layer +``` + +Index files list the specific guideline docs to read when you actually start coding. + +## Step 4: Decide next action +From Step 1 you know the current task. Check the task directory: + +- **Active task + `prd.md` exists** → Phase 2 step 2.1. Load the step detail: + ```bash + python ./.trellis/scripts/get_context.py --mode phase --step 2.1 --platform codex + ``` +- **Active task + no `prd.md`** → Phase 1.1. Load the `trellis-brainstorm` skill. +- **No active task** → when the user describes multi-step work, load the `trellis-brainstorm` skill to clarify requirements, then create a task via `task.py create`. For simple one-off questions or trivial edits, skip this and just answer directly — no task needed. + +--- + +## Skill routing (quick reference) + +| User intent | Skill | +|---|---| +| New feature / unclear requirements | `trellis-brainstorm` | +| About to write code | `trellis-before-dev` | +| Done coding / quality check | `trellis-check` | +| Stuck / fixed same bug multiple times | `trellis-break-loop` | +| Learned something worth capturing | `trellis-update-spec` | + +Full rules + anti-rationalization table in `.trellis/workflow.md`. diff --git a/.agents/skills/trellis-update-spec/SKILL.md b/.agents/skills/trellis-update-spec/SKILL.md new file mode 100644 index 0000000000..81bad087ed --- /dev/null +++ b/.agents/skills/trellis-update-spec/SKILL.md @@ -0,0 +1,356 @@ +--- +name: trellis-update-spec +description: "Captures executable contracts and coding conventions into .trellis/spec/ documents. Use when learning something valuable from debugging, implementing, or discussion that should be preserved for future sessions." +--- + +# Update Code-Spec - Capture Executable Contracts + +When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. + +**Timing**: After completing a task, fixing a bug, or discovering a new pattern + +--- + +## Code-Spec First Rule (CRITICAL) + +In this project, "spec" for implementation work means **code-spec**: +- Executable contracts (not principle-only text) +- Concrete signatures, payload fields, env keys, and boundary behavior +- Testable validation/error behavior + +If the change touches infra or cross-layer contracts, code-spec depth is mandatory. + +### Mandatory Triggers + +Apply code-spec depth when the change includes any of: +- New/changed command or API signature +- Cross-layer request/response contract change +- Database schema/migration change +- Infra integration (storage, queue, cache, secrets, env wiring) + +### Mandatory Output (7 Sections) + +For triggered tasks, include all sections below: +1. Scope / Trigger +2. Signatures (command/API/DB) +3. Contracts (request/response/env) +4. Validation & Error Matrix +5. Good/Base/Bad Cases +6. Tests Required (with assertion points) +7. Wrong vs Correct (at least one pair) + +--- + +## When to Update Code-Specs + +| Trigger | Example | Target Spec | +|---------|---------|-------------| +| **Implemented a feature** | Added a new integration or module | Relevant spec file | +| **Made a design decision** | Chose extensibility pattern over simplicity | Relevant spec + "Design Decisions" section | +| **Fixed a bug** | Found a subtle issue with error handling | Relevant spec (e.g., error-handling docs) | +| **Discovered a pattern** | Found a better way to structure code | Relevant spec file | +| **Hit a gotcha** | Learned that X must be done before Y | Relevant spec + "Common Mistakes" section | +| **Established a convention** | Team agreed on naming pattern | Quality guidelines | +| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item) | + +**Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. + +--- + +## Spec Structure Overview + +``` +.trellis/spec/ +├── <layer>/ # Per-layer coding standards (e.g., backend/, frontend/, api/) +│ ├── index.md # Overview and links +│ └── *.md # Topic-specific guidelines +└── guides/ # Thinking checklists (NOT coding specs!) + ├── index.md # Guide index + └── *.md # Topic-specific guides +``` + +### CRITICAL: Code-Spec vs Guide - Know the Difference + +| Type | Location | Purpose | Content Style | +|------|----------|---------|---------------| +| **Code-Spec** | `<layer>/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | +| **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | + +**Decision Rule**: Ask yourself: + +- "This is **how to write** the code" → Put in a spec layer directory +- "This is **what to consider** before writing" → Put in `guides/` + +**Example**: + +| Learning | Wrong Location | Correct Location | +|----------|----------------|------------------| +| "Use API X not API Y for this task" | ❌ `guides/` (too specific for a thinking guide) | ✅ Relevant spec file (concrete convention) | +| "Remember to check X when doing Y" | ❌ Spec file (too abstract for a spec) | ✅ `guides/` (thinking checklist) | + +**Guides should be short checklists that point to specs**, not duplicate the detailed rules. + +--- + +## Update Process + +### Step 1: Identify What You Learned + +Answer these questions: + +1. **What did you learn?** (Be specific) +2. **Why is it important?** (What problem does it prevent?) +3. **Where does it belong?** (Which spec file?) + +### Step 2: Classify the Update Type + +| Type | Description | Action | +|------|-------------|--------| +| **Design Decision** | Why we chose approach X over Y | Add to "Design Decisions" section | +| **Project Convention** | How we do X in this project | Add to relevant section with examples | +| **New Pattern** | A reusable approach discovered | Add to "Patterns" section | +| **Forbidden Pattern** | Something that causes problems | Add to "Anti-patterns" or "Don't" section | +| **Common Mistake** | Easy-to-make error | Add to "Common Mistakes" section | +| **Convention** | Agreed-upon standard | Add to relevant section | +| **Gotcha** | Non-obvious behavior | Add warning callout | + +### Step 3: Read the Target Code-Spec + +Before editing, read the current code-spec to: +- Understand existing structure +- Avoid duplicating content +- Find the right section for your update + +```bash +cat .trellis/spec/<category>/<file>.md +``` + +### Step 4: Make the Update + +Follow these principles: + +1. **Be Specific**: Include concrete examples, not just abstract rules +2. **Explain Why**: State the problem this prevents +3. **Show Contracts**: Add signatures, payload fields, and error behavior +4. **Show Code**: Add code snippets for key patterns +5. **Keep it Short**: One concept per section + +### Step 5: Update the Index (if needed) + +If you added a new section or the code-spec status changed, update the category's `index.md`. + +--- + +## Update Templates + +### Mandatory Template for Infra/Cross-Layer Work + +```markdown +## Scenario: <name> + +### 1. Scope / Trigger +- Trigger: <why this requires code-spec depth> + +### 2. Signatures +- Backend command/API/DB signature(s) + +### 3. Contracts +- Request fields (name, type, constraints) +- Response fields (name, type, constraints) +- Environment keys (required/optional) + +### 4. Validation & Error Matrix +- <condition> -> <error> + +### 5. Good/Base/Bad Cases +- Good: ... +- Base: ... +- Bad: ... + +### 6. Tests Required +- Unit/Integration/E2E with assertion points + +### 7. Wrong vs Correct +#### Wrong +... +#### Correct +... +``` + +### Adding a Design Decision + +```markdown +### Design Decision: [Decision Name] + +**Context**: What problem were we solving? + +**Options Considered**: +1. Option A - brief description +2. Option B - brief description + +**Decision**: We chose Option X because... + +**Example**: +\`\`\`typescript +// How it's implemented +code example +\`\`\` + +**Extensibility**: How to extend this in the future... +``` + +### Adding a Project Convention + +```markdown +### Convention: [Convention Name] + +**What**: Brief description of the convention. + +**Why**: Why we do it this way in this project. + +**Example**: +\`\`\`typescript +// How to follow this convention +code example +\`\`\` + +**Related**: Links to related conventions or specs. +``` + +### Adding a New Pattern + +```markdown +### Pattern Name + +**Problem**: What problem does this solve? + +**Solution**: Brief description of the approach. + +**Example**: +\`\`\` +// Good +code example + +// Bad +code example +\`\`\` + +**Why**: Explanation of why this works better. +``` + +### Adding a Forbidden Pattern + +```markdown +### Don't: Pattern Name + +**Problem**: +\`\`\` +// Don't do this +bad code example +\`\`\` + +**Why it's bad**: Explanation of the issue. + +**Instead**: +\`\`\` +// Do this instead +good code example +\`\`\` +``` + +### Adding a Common Mistake + +```markdown +### Common Mistake: Description + +**Symptom**: What goes wrong + +**Cause**: Why this happens + +**Fix**: How to correct it + +**Prevention**: How to avoid it in the future +``` + +### Adding a Gotcha + +```markdown +> **Warning**: Brief description of the non-obvious behavior. +> +> Details about when this happens and how to handle it. +``` + +--- + +## Interactive Mode + +If you're unsure what to update, answer these prompts: + +1. **What did you just finish?** + - [ ] Fixed a bug + - [ ] Implemented a feature + - [ ] Refactored code + - [ ] Had a discussion about approach + +2. **What did you learn or decide?** + - Design decision (why X over Y) + - Project convention (how we do X) + - Non-obvious behavior (gotcha) + - Better approach (pattern) + +3. **Would future AI/developers need to know this?** + - To understand how the code works → Yes, update spec + - To maintain or extend the feature → Yes, update spec + - To avoid repeating mistakes → Yes, update spec + - Purely one-off implementation detail → Maybe skip + +4. **Which area does it relate to?** + - [ ] Backend code + - [ ] Frontend code + - [ ] Cross-layer data flow + - [ ] Code organization/reuse + - [ ] Quality/testing + +--- + +## Quality Checklist + +Before finishing your code-spec update: + +- [ ] Is the content specific and actionable? +- [ ] Did you include a code example? +- [ ] Did you explain WHY, not just WHAT? +- [ ] Did you include executable signatures/contracts? +- [ ] Did you include validation and error matrix? +- [ ] Did you include Good/Base/Bad cases? +- [ ] Did you include required tests with assertion points? +- [ ] Is it in the right code-spec file? +- [ ] Does it duplicate existing content? +- [ ] Would a new team member understand it? + +--- + +## Relationship to Other Commands + +``` +Development Flow: + Learn something → `update-spec` (Trellis command) → Knowledge captured + ↑ ↓ + `break-loop` (Trellis command) ←──────────────────── Future sessions benefit + (deep bug analysis) +``` + +- ``break-loop` (Trellis command)` - Analyzes bugs deeply, often reveals spec updates needed +- ``update-spec` (Trellis command)` - Actually makes the updates +- ``finish-work` (Trellis command)` - Reminds you to check if specs need updates + +--- + +## Core Philosophy + +> **Code-specs are living documents. Every debugging session, every "aha moment" is an opportunity to make the implementation contract clearer.** + +The goal is **institutional memory**: +- What one person learns, everyone benefits from +- What AI learns in one session, persists to future sessions +- Mistakes become documented guardrails diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5fdb4d839..28dc74d6b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,14 +3,25 @@ on: pull_request: types: [opened, synchronize, reopened] paths: - - '**/*.kt' - - '**/*.kts' + - "**/*.kt" + - "**/*.kts" + - "**/*.yml" + - "gradle.properties" + - "gradle/**" + - "gradlew" + - ".github/workflows/build.yml" push: branches: - master + - advanced paths: - - '**/*.kt' - - '**/*.kts' + - "**/*.kt" + - "**/*.kts" + - "**/*.yml" + - "gradle.properties" + - "gradle/**" + - "gradlew" + - ".github/workflows/build.yml" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -19,16 +30,69 @@ jobs: build: runs-on: ubuntu-latest permissions: - contents: read + contents: write steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 25 uses: actions/setup-java@v4 with: - distribution: 'temurin' - java-version: '25' + distribution: "temurin" + java-version: "25" - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Build run: ./gradlew build --full-stacktrace + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: EcoEnchants-${{ github.sha }} + path: | + build/libs/*-obfuscated.jar + if-no-files-found: error + retention-days: 14 + - name: Prepare release metadata + id: release_metadata + if: ${{ success() && github.event_name == 'push' && github.ref == 'refs/heads/advanced' }} + shell: bash + run: | + VERSION_NAME="$(sed -n 's/^version=//p' gradle.properties | head -n 1)" + if [ -z "$VERSION_NAME" ]; then + echo "version is missing from gradle.properties" >&2 + exit 1 + fi + + SHORT_HASH="${GITHUB_SHA::7}" + echo "VERSION_NAME=$VERSION_NAME" >> "$GITHUB_ENV" + echo "SHORT_HASH=$SHORT_HASH" >> "$GITHUB_ENV" + echo "version_name=$VERSION_NAME" >> "$GITHUB_OUTPUT" + echo "short_hash=$SHORT_HASH" >> "$GITHUB_OUTPUT" + + if [ ! -f RELEASE_NOTES.md ]; then + { + echo "EcoEnchants $VERSION_NAME-$SHORT_HASH" + echo + echo "Commit: $GITHUB_SHA" + } > RELEASE_NOTES.md + fi + + mkdir -p build/release + find build/libs -maxdepth 1 -type f -name "*-obfuscated.jar" -exec cp {} build/release/ \; + + if ! ls build/release/*.jar >/dev/null 2>&1; then + echo "No obfuscated release JARs found in build/libs" >&2 + exit 1 + fi + - name: Automatic release + if: ${{ success() && github.event_name == 'push' && github.ref == 'refs/heads/advanced' }} + uses: softprops/action-gh-release@master + with: + token: ${{ secrets.GITHUB_TOKEN }} + draft: false + make_latest: true + body_path: RELEASE_NOTES.md + tag_name: "v${{ steps.release_metadata.outputs.version_name }}-${{ steps.release_metadata.outputs.short_hash }}" + prerelease: false + name: "EcoEnchants ${{ steps.release_metadata.outputs.version_name }}-${{ steps.release_metadata.outputs.short_hash }}" + files: | + build/release/*.jar diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..337a37196c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,52 @@ +name: Docs + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "documentation/**" + - "package.json" + - "vercel.json" + - ".github/workflows/docs.yml" + push: + branches: + - master + - advanced + paths: + - "documentation/**" + - "package.json" + - "vercel.json" + - ".github/workflows/docs.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-docs: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install docs dependencies + run: npm install + + - name: Build VitePress docs + run: npm run build + + - name: Upload docs artifact + uses: actions/upload-artifact@v4 + with: + name: ecoenchants-vitepress-docs-${{ github.sha }} + path: documentation/.vitepress/dist + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 62e2d2defb..6ff6355cda 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ gradle-app.setting .kotlin .worktrees +.vercel +documentation/github-issues-fix-todo-2026-05-31.md +documentation/github-issues-research-2026-05-31.md +.codex/ diff --git a/.trellis/.gitignore b/.trellis/.gitignore new file mode 100644 index 0000000000..5a991ea50f --- /dev/null +++ b/.trellis/.gitignore @@ -0,0 +1,32 @@ +# Developer identity (local only) +.developer + +# Current task pointer (each dev works on different task) +.current-task + +# Session/window scoped runtime state +.runtime/ + +# Ralph Loop state file +.ralph-state.json + +# Agent runtime files +.agents/ +.agent-log +.session-id + +# Task directory runtime files +.plan-log + +# Atomic update temp files +*.tmp + +# Update backup directories +.backup-* + +# Conflict resolution temp files +*.new + +# Python cache +**/__pycache__/ +**/*.pyc diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json new file mode 100644 index 0000000000..650a6915a4 --- /dev/null +++ b/.trellis/.template-hashes.json @@ -0,0 +1,35 @@ +{ + "__version": 2, + "hashes": { + ".trellis/config.yaml": "5c9207418cecc390e9d86d589b4183b831f76697d92fb42fefd5221cd8772e51", + ".trellis/scripts/add_session.py": "f26b66a539d160c739d4b88fd926b3d7f6745be326cd57131e5ef17a7b011fbe", + ".trellis/scripts/common/active_task.py": "6c88ed40ef7289bca0f6d2ecba0f8b8aef46cd58788080fbeeea88de138a431f", + ".trellis/scripts/common/cli_adapter.py": "cd844d1e84b1a09b373b3a7609e4d5606ee9d4825154c002cc9bb3f54c8e2fb9", + ".trellis/scripts/common/config.py": "25c5a53ad20d6909be5209222e4208a84528805316a4d78350529459a364edb1", + ".trellis/scripts/common/developer.py": "b2141b0145a41f8cedb4f9a24c925796edb2f0f6fde7c86b559513ec30499368", + ".trellis/scripts/common/git.py": "e14817be7de122d3a106f509c2825aeb9669d962ba73ba241642d2931cfdf1d6", + ".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d", + ".trellis/scripts/common/io.py": "6480b181f2bc505323b28ed7a66963d7b7edc96251e83b4c8e7a45907cc721c8", + ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", + ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", + ".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee", + ".trellis/scripts/common/safe_commit.py": "8789bff4b30a9065469210f2efab3f59f03dddd77bef4e4b6a5bb641f93539f4", + ".trellis/scripts/common/session_context.py": "d669b96fd7a608808695b9e82e9bfd1693a9ae98ade03cc8dce6c24487696793", + ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", + ".trellis/scripts/common/task_context.py": "1c16a7fa82d363010d0d0ebdc038296ae1552bf6e90214787d707f49567bc159", + ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", + ".trellis/scripts/common/task_store.py": "4a6ad7f15fd6fdca0da174804ee2d750919d42e46066a891c7525aa8d8a2c592", + ".trellis/scripts/common/task_utils.py": "f5ef4af87ba3e11d8b19630c0c96d009de1811fc9be56c2027a9c96e21ed103e", + ".trellis/scripts/common/trellis_config.py": "0839dcf90ebbd77712c276930a89335b3313927051650c91d220fb51ca2a6a3c", + ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", + ".trellis/scripts/common/workflow_phase.py": "2b260f4a7770e9c3223129836716bd8e2c0f0568acd682224a57415bc1dc726b", + ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", + ".trellis/scripts/get_context.py": "af3ea7cd563a453227cf2cb4ab04d667390046b7febfac2217348d0892781f4b", + ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", + ".trellis/scripts/hooks/linear_sync.py": "cfc270b7ff775caa5b2434823c45414a3b37f9ba2aa1e293a26daef9fd2e577a", + ".trellis/scripts/init_developer.py": "0943f1c240993649ab89b91a2c5b379e84daa8c53b35f0490774bff05a552873", + ".trellis/scripts/task.py": "e2614fbfc1308c90c0708a11475ca6684ea0a1e2a845140300192229589a2f1f", + ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", + ".trellis/workflow.md": "94810f640dcfe6fdaebcf6e9d0d6cec554610192c4f953b288029570ba8bc89d" + } +} \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version new file mode 100644 index 0000000000..aa49d36aab --- /dev/null +++ b/.trellis/.version @@ -0,0 +1 @@ +0.5.19 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml new file mode 100644 index 0000000000..f1e99eb16b --- /dev/null +++ b/.trellis/config.yaml @@ -0,0 +1,90 @@ +# Trellis Configuration +# Project-level settings for the Trellis workflow system +# +# All values have sensible defaults. Only override what you need. + +#------------------------------------------------------------------------------- +# Session Recording +#------------------------------------------------------------------------------- + +# Commit message used when auto-committing journal/index changes +# after running add_session.py +session_commit_message: "chore: record journal" + +# Maximum lines per journal file before rotating to a new one +max_journal_lines: 2000 + +#------------------------------------------------------------------------------- +# Session Auto-Commit +#------------------------------------------------------------------------------- + +# Auto-commit behavior for session journal + task archive operations. +# - true (default): scripts auto-stage and auto-commit journal / task changes +# after add_session.py / task.py archive runs. +# - false: scripts do not touch git. Files (journal-*.md, task archive moves) +# are still written to disk; you decide whether to git add / commit. +# +# Use `false` if your project's .gitignore intentionally excludes `.trellis/` +# and you want session data kept local-only, or if you prefer to review +# staged changes manually before each commit. +# +# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive). +# +# session_auto_commit: true + +#------------------------------------------------------------------------------- +# Task Lifecycle Hooks +#------------------------------------------------------------------------------- + +# Shell commands to run after task lifecycle events. +# Each hook receives TASK_JSON_PATH environment variable pointing to task.json. +# Hook failures print a warning but do not block the main operation. +# +# hooks: +# after_create: +# - "echo 'Task created'" +# after_start: +# - "echo 'Task started'" +# after_finish: +# - "echo 'Task finished'" +# after_archive: +# - "echo 'Task archived'" + +#------------------------------------------------------------------------------- +# Monorepo / Packages +#------------------------------------------------------------------------------- + +# Declare packages for monorepo projects. +# Trellis auto-detects workspaces during `trellis init`, but you can also +# configure them manually here. +# +# packages: +# frontend: +# path: packages/frontend +# backend: +# path: packages/backend +# docs: +# path: docs-site +# type: submodule +# # For polyrepo / meta-repo layouts (independent .git in each subdir), +# # mark the package with `git: true`. The runtime treats it as an +# # independent repository for things like git-context display. +# webapp: +# path: ./webapp +# git: true + +# Default package used when --package is not specified. +# default_package: frontend + +#------------------------------------------------------------------------------- +# Codex (dispatch behavior) +#------------------------------------------------------------------------------- +# Codex-only knob; other platforms ignore it. Default ("inline") makes the +# main Codex agent edit code directly because Codex sub-agents run with +# `fork_turns="none"` isolation and can't inherit the parent session's +# task context. Set to "sub-agent" to opt into the legacy dispatch model +# (main agent spawns trellis-implement / trellis-check / trellis-research +# sub-agents). +# +# codex: +# dispatch_mode: inline # or "sub-agent" to dispatch trellis-* sub-agents diff --git a/.trellis/scripts/__init__.py b/.trellis/scripts/__init__.py new file mode 100644 index 0000000000..815a137435 --- /dev/null +++ b/.trellis/scripts/__init__.py @@ -0,0 +1,5 @@ +""" +Trellis Python Scripts + +This module provides Python implementations of Trellis workflow scripts. +""" diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py new file mode 100644 index 0000000000..7149739715 --- /dev/null +++ b/.trellis/scripts/add_session.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Add a new session to journal file and update index.md. + +Usage: + python add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] + python add_session.py --title "Title" --branch "feat/my-branch" + + # Pipe detailed content via stdin (use --stdin to opt in): + cat << 'EOF' | python add_session.py --stdin --title "Title" --summary "Summary" + <session content here> + EOF + +Branch resolution order: + 1. --branch CLI arg (explicit) + 2. task.json branch field (from active task) + 3. git branch --show-current (auto-detect) + 4. None (omitted gracefully) +""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import datetime +from pathlib import Path + +from common.paths import ( + FILE_JOURNAL_PREFIX, + get_repo_root, + get_current_task, + get_developer, + get_workspace_dir, +) +from common.developer import ensure_developer +from common.git import run_git +from common.safe_commit import ( + print_gitignore_warning, + safe_git_add, + safe_trellis_paths_to_add, +) +from common.tasks import load_task +from common.config import ( + get_packages, + get_session_auto_commit, + get_session_commit_message, + get_max_journal_lines, + is_monorepo, + resolve_package, + validate_package, +) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]: + """Get latest journal file info. + + Returns: + Tuple of (file_path, file_number, line_count). + """ + latest_file: Path | None = None + latest_num = -1 + + for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + match = re.search(r"(\d+)$", f.stem) + if match: + num = int(match.group(1)) + if num > latest_num: + latest_num = num + latest_file = f + + if latest_file: + lines = len(latest_file.read_text(encoding="utf-8").splitlines()) + return latest_file, latest_num, lines + + return None, 0, 0 + + +def get_current_session(index_file: Path) -> int: + """Get current session number from index.md.""" + if not index_file.is_file(): + return 0 + + content = index_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if "Total Sessions" in line: + match = re.search(r":\s*(\d+)", line) + if match: + return int(match.group(1)) + return 0 + + +def _extract_journal_num(filename: str) -> int: + """Extract journal number from filename for sorting.""" + match = re.search(r"(\d+)", filename) + return int(match.group(1)) if match else 0 + + +def count_journal_files(dev_dir: Path, active_num: int) -> str: + """Count journal files and return table rows.""" + active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md" + result_lines = [] + + files = sorted( + [f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()], + key=lambda f: _extract_journal_num(f.stem), + reverse=True + ) + + for f in files: + filename = f.name + lines = len(f.read_text(encoding="utf-8").splitlines()) + status = "Active" if filename == active_file else "Archived" + result_lines.append(f"| `{filename}` | ~{lines} | {status} |") + + return "\n".join(result_lines) + + +def create_new_journal_file( + dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000, +) -> Path: + """Create a new journal file.""" + prev_num = num - 1 + new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md" + + content = f"""# Journal - {developer} (Part {num}) + +> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines) +> Started: {today} + +--- + +""" + new_file.write_text(content, encoding="utf-8") + return new_file + + +def generate_session_content( + session_num: int, + title: str, + commit: str, + summary: str, + extra_content: str, + today: str, + package: str | None = None, + branch: str | None = None, +) -> str: + """Generate session content.""" + if commit and commit != "-": + commit_table = """| Hash | Message | +|------|---------|""" + for c in commit.split(","): + c = c.strip() + commit_table += f"\n| `{c}` | (see git log) |" + else: + commit_table = "(No commits - planning session)" + + package_line = f"\n**Package**: {package}" if package else "" + branch_line = f"\n**Branch**: `{branch}`" if branch else "" + + return f""" + +## Session {session_num}: {title} + +**Date**: {today} +**Task**: {title}{package_line}{branch_line} + +### Summary + +{summary} + +### Main Changes + +{extra_content} + +### Git Commits + +{commit_table} + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete +""" + + +def update_index( + index_file: Path, + dev_dir: Path, + title: str, + commit: str, + new_session: int, + active_file: str, + today: str, + branch: str | None = None, +) -> bool: + """Update index.md with new session info.""" + # Format commit for display + commit_display = "-" + if commit and commit != "-": + commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", ")) + + # Get file number from active_file name + match = re.search(r"(\d+)", active_file) + active_num = int(match.group(1)) if match else 0 + files_table = count_journal_files(dev_dir, active_num) + + print(f"Updating index.md for session {new_session}...") + print(f" Title: {title}") + print(f" Commit: {commit_display}") + print(f" Active File: {active_file}") + print() + + content = index_file.read_text(encoding="utf-8") + + if "@@@auto:current-status" not in content: + print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr) + return False + + # Process sections + lines = content.splitlines() + new_lines = [] + + in_current_status = False + in_active_documents = False + in_session_history = False + header_written = False + + for line in lines: + if "@@@auto:current-status" in line: + new_lines.append(line) + in_current_status = True + new_lines.append(f"- **Active File**: `{active_file}`") + new_lines.append(f"- **Total Sessions**: {new_session}") + new_lines.append(f"- **Last Active**: {today}") + continue + + if "@@@/auto:current-status" in line: + in_current_status = False + new_lines.append(line) + continue + + if "@@@auto:active-documents" in line: + new_lines.append(line) + in_active_documents = True + new_lines.append("| File | Lines | Status |") + new_lines.append("|------|-------|--------|") + new_lines.append(files_table) + continue + + if "@@@/auto:active-documents" in line: + in_active_documents = False + new_lines.append(line) + continue + + if "@@@auto:session-history" in line: + new_lines.append(line) + in_session_history = True + header_written = False + continue + + if "@@@/auto:session-history" in line: + in_session_history = False + new_lines.append(line) + continue + + if in_current_status: + continue + + if in_active_documents: + continue + + if in_session_history: + # Migrate old 4/6-column headers to 5-column Branch-only history. + if re.match( + r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$", + line, + ): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written: + new_lines.append("|---|------|-------|---------|--------|") + new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |") + header_written = True + continue + new_lines.append(line) + continue + + new_lines.append(line) + + index_file.write_text("\n".join(new_lines), encoding="utf-8") + print("[OK] Updated index.md successfully!") + return True + + +# ============================================================================= +# Main Function +# ============================================================================= + +def _auto_commit_workspace(repo_root: Path) -> None: + """Stage Trellis-owned workspace + task paths and commit. + + Path scope is restricted to specific products (journal files, index.md, + active task dirs, the archive subtree). We never `git add` the whole + `.trellis/` tree, and if `.gitignore` blocks the specific paths we + warn + skip — never retry with ``-f``. + + Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to + ``false``, this function returns immediately without touching git + (journal/index files are still written to disk by the caller). + """ + if not get_session_auto_commit(repo_root): + print( + "[OK] session_auto_commit: false — skipping git stage/commit.", + file=sys.stderr, + ) + return + + commit_msg = get_session_commit_message(repo_root) + paths = safe_trellis_paths_to_add(repo_root) + if not paths: + print("[OK] No workspace changes to commit.", file=sys.stderr) + return + + success, _, err = safe_git_add(paths, repo_root) + if not success: + if err and "ignored by" in err.lower(): + print_gitignore_warning(paths) + else: + print( + f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", + file=sys.stderr, + ) + return + + # Check if there are staged changes for the paths we just staged. + rc, _, _ = run_git( + ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root + ) + if rc == 0: + print("[OK] No workspace changes to commit.", file=sys.stderr) + return + + rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root) + if rc == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + else: + print( + f"[WARN] Auto-commit failed: {commit_err.strip()}", + file=sys.stderr, + ) + + +def add_session( + title: str, + commit: str = "-", + summary: str = "(Add summary)", + extra_content: str = "(Add details)", + auto_commit: bool = True, + package: str | None = None, + branch: str | None = None, +) -> int: + """Add a new session.""" + repo_root = get_repo_root() + ensure_developer(repo_root) + + developer = get_developer(repo_root) + if not developer: + print("Error: Developer not initialized", file=sys.stderr) + return 1 + + dev_dir = get_workspace_dir(repo_root) + if not dev_dir: + print("Error: Workspace directory not found", file=sys.stderr) + return 1 + + max_lines = get_max_journal_lines(repo_root) + + index_file = dev_dir / "index.md" + today = datetime.now().strftime("%Y-%m-%d") + + journal_file, current_num, current_lines = get_latest_journal_info(dev_dir) + current_session = get_current_session(index_file) + new_session = current_session + 1 + + session_content = generate_session_content( + new_session, title, commit, summary, extra_content, today, package, + branch, + ) + content_lines = len(session_content.splitlines()) + + print("========================================", file=sys.stderr) + print("ADD SESSION", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print(f"Session: {new_session}", file=sys.stderr) + print(f"Title: {title}", file=sys.stderr) + print(f"Commit: {commit}", file=sys.stderr) + print("", file=sys.stderr) + print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr) + print(f"Current lines: {current_lines}", file=sys.stderr) + print(f"New content lines: {content_lines}", file=sys.stderr) + print(f"Total after append: {current_lines + content_lines}", file=sys.stderr) + print("", file=sys.stderr) + + target_file = journal_file + target_num = current_num + + if current_lines + content_lines > max_lines: + target_num = current_num + 1 + print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr) + target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines) + print(f"Created: {target_file}", file=sys.stderr) + + # Append session content + if target_file: + with target_file.open("a", encoding="utf-8") as f: + f.write(session_content) + print(f"[OK] Appended session to {target_file.name}", file=sys.stderr) + + print("", file=sys.stderr) + + # Update index.md + active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" + if not update_index( + index_file, + dev_dir, + title, + commit, + new_session, + active_file, + today, + branch, + ): + return 1 + + print("", file=sys.stderr) + print("========================================", file=sys.stderr) + print(f"[OK] Session {new_session} added successfully!", file=sys.stderr) + print("========================================", file=sys.stderr) + print("", file=sys.stderr) + print("Files updated:", file=sys.stderr) + print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr) + print(" - index.md", file=sys.stderr) + + # Auto-commit workspace changes + if auto_commit: + print("", file=sys.stderr) + _auto_commit_workspace(repo_root) + + return 0 + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Add a new session to journal file and update index.md" + ) + parser.add_argument("--title", required=True, help="Session title") + parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") + parser.add_argument("--summary", default="(Add summary)", help="Brief summary") + parser.add_argument("--content-file", help="Path to file with detailed content") + parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)") + parser.add_argument("--branch", help="Branch name (auto-detected if omitted)") + parser.add_argument("--no-commit", action="store_true", + help="Skip auto-commit of workspace changes") + parser.add_argument("--stdin", action="store_true", + help="Read extra content from stdin (explicit opt-in)") + + args = parser.parse_args() + + extra_content = "(Add details)" + if args.content_file: + content_path = Path(args.content_file) + if content_path.is_file(): + extra_content = content_path.read_text(encoding="utf-8") + elif args.stdin: + extra_content = sys.stdin.read() + + # Load active task once — shared by package and branch resolution + repo_root = get_repo_root() + current = get_current_task(repo_root) + task_data = load_task(repo_root / current) if current else None + + package = args.package + if package: + # CLI source: fail-fast in monorepo, ignore in single-repo + if not is_monorepo(repo_root): + print("Warning: --package ignored in single-repo project", file=sys.stderr) + package = None + elif not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr) + return 1 + else: + # Inferred: active task's task.json.package → default_package → None + task_package = task_data.package if task_data else None + package = resolve_package(task_package, repo_root) + + # Resolve branch: CLI → task.json → git auto-detect → None + branch = args.branch + + if not branch: + if task_data and task_data.raw.get("branch"): + branch = task_data.raw["branch"] + else: + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + detected = branch_out.strip() + if detected: + branch = detected + + return add_session( + args.title, args.commit, args.summary, extra_content, + auto_commit=not args.no_commit, + package=package, + branch=branch, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py new file mode 100644 index 0000000000..6d7236034b --- /dev/null +++ b/.trellis/scripts/common/__init__.py @@ -0,0 +1,92 @@ +""" +Common utilities for Trellis workflow scripts. + +This module provides shared functionality used by other Trellis scripts. +""" + +import io +import sys + +# ============================================================================= +# Windows Encoding Fix (MUST be at top, before any other output) +# ============================================================================= +# On Windows, stdout defaults to the system code page (often GBK/CP936). +# This causes UnicodeEncodeError when printing non-ASCII characters. +# +# Any script that imports from common will automatically get this fix. +# ============================================================================= + + +def _configure_stream(stream: object) -> object: + """Configure a stream for UTF-8 encoding on Windows.""" + # Try reconfigure() first (Python 3.7+, more reliable) + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + return stream + # Fallback: detach and rewrap with TextIOWrapper + elif hasattr(stream, "detach"): + return io.TextIOWrapper( + stream.detach(), # type: ignore[union-attr] + encoding="utf-8", + errors="replace", + ) + return stream + + +if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +def configure_encoding() -> None: + """ + Configure stdout/stderr/stdin for UTF-8 encoding on Windows. + + This is automatically called when importing from common, + but can be called manually for scripts that don't import common. + + Safe to call multiple times. + """ + global sys + if sys.platform == "win32": + sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment] + sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment] + sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment] + + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + DIR_ARCHIVE, + DIR_SPEC, + DIR_SCRIPTS, + FILE_DEVELOPER, + FILE_CURRENT_TASK, + FILE_TASK_JSON, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, + get_tasks_dir, + get_workspace_dir, + get_active_journal_file, + count_lines, + get_current_task, + get_current_task_abs, + normalize_task_ref, + resolve_task_ref, + set_current_task, + clear_current_task, + has_current_task, + generate_task_date_prefix, +) + +from .active_task import ( + ActiveTask, + clear_active_task, + resolve_active_task, + resolve_context_key, + set_active_task, +) diff --git a/.trellis/scripts/common/active_task.py b/.trellis/scripts/common/active_task.py new file mode 100644 index 0000000000..e6597e85fb --- /dev/null +++ b/.trellis/scripts/common/active_task.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +"""Session-scoped active task resolution. + +The user-facing concept is a single "active task". Trellis stores that pointer +per AI session/window under `.trellis/.runtime/sessions/`; without a stable +session key there is no active task. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DIR_WORKFLOW = ".trellis" +DIR_TASKS = "tasks" +DIR_RUNTIME = ".runtime" +DIR_SESSIONS = "sessions" +DIR_CURSOR_SHELL = "cursor-shell" +CURSOR_SHELL_TICKET_TTL_SECONDS = 30 +TASK_SESSION_COMMANDS = {"start", "current", "finish"} + +_SESSION_KEYS = ("session_id", "sessionId", "sessionID") +_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID") +_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript") +_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput") +_KNOWN_PLATFORMS = { + "claude", + "codex", + "cursor", + "opencode", + "gemini", + "droid", + "qoder", + "codebuddy", + "kiro", + "copilot", + "pi", +} + +_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")), + ("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")), + ("cursor", ("CURSOR_SESSION_ID",)), + ("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")), + ("gemini", ("GEMINI_SESSION_ID",)), + ("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")), + ("qoder", ("QODER_SESSION_ID",)), + ("codebuddy", ("CODEBUDDY_SESSION_ID",)), + ("kiro", ("KIRO_SESSION_ID",)), + ("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")), + ("pi", ("PI_SESSION_ID", "PI_SESSIONID")), +) +_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")), +) +_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude", ("CLAUDE_TRANSCRIPT_PATH",)), + ("codex", ("CODEX_TRANSCRIPT_PATH",)), + ("cursor", ("CURSOR_TRANSCRIPT_PATH",)), + ("gemini", ("GEMINI_TRANSCRIPT_PATH",)), + ("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")), + ("qoder", ("QODER_TRANSCRIPT_PATH",)), + ("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)), +) +_ENV_PLATFORM_ALIASES = { + "claude-code": "claude", + "factory": "droid", + "factory-ai": "droid", + "github-copilot": "copilot", +} + + +@dataclass(frozen=True) +class ActiveTask: + """Resolved active task state.""" + + task_path: str | None + source_type: str + context_key: str | None = None + stale: bool = False + + @property + def source(self) -> str: + """Human-readable source label.""" + if self.source_type == "session" and self.context_key: + return f"session:{self.context_key}" + if self.source_type == "session-fallback" and self.context_key: + return f"session-fallback:{self.context_key}" + return self.source_type + + +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable storage and comparison.""" + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None: + """Resolve a task ref to an absolute task directory.""" + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj + + +def _runtime_sessions_dir(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS + + +def _sanitize_key(raw: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip()) + safe = safe.strip("._-") + return safe[:160] if safe else "" + + +def _hash_value(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + + +def _as_dict(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + +def _string_value(value: Any) -> str | None: + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return None + + +def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + value = _string_value(data.get(key)) + if value: + return value + + for nested_key in _NESTED_KEYS: + nested = _as_dict(data.get(nested_key)) + if not nested: + continue + value = _lookup_string(nested, keys) + if value: + return value + + return None + + +def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str: + if platform: + return _sanitize_key(platform) or "session" + if platform_input: + for key in ("_trellis_platform", "trellis_platform", "platform", "source"): + value = _string_value(platform_input.get(key)) + if value: + return _sanitize_key(value) or "session" + if _string_value(platform_input.get("cursor_version")): + return "cursor" + return "session" + + +def _context_key(platform_name: str, kind: str, value: str) -> str: + if kind == "transcript": + return f"{platform_name}_transcript_{_hash_value(value)}" + safe_value = _sanitize_key(value) + if safe_value: + return f"{platform_name}_{safe_value}" + return f"{platform_name}_{_hash_value(value)}" + + +def _iter_env_keys( + env_keys: tuple[tuple[str, tuple[str, ...]], ...], + platform_name: str | None, +) -> tuple[tuple[str, tuple[str, ...]], ...]: + if not platform_name: + return env_keys + matched = tuple((name, keys) for name, keys in env_keys if name == platform_name) + return matched + + +def _env_platform_name(platform_name: str | None) -> str | None: + if not platform_name or platform_name == "session": + return None + return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name) + + +def _lookup_env_context_key(platform_name: str | None) -> str | None: + """Resolve a context key from platform-provided environment variables. + + Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run + shell command can only see session identity if the host platform exports it + in the command environment. These names are best-effort adapters; if none + are present, there is no session-scoped active task. + """ + env_platform_name = _env_platform_name(platform_name) + + for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "session", value) + + for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "conversation", value) + + for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "transcript", value) + + return None + + +def _find_repo_root_from_cwd() -> Path | None: + current = Path.cwd().resolve() + while True: + if (current / DIR_WORKFLOW).is_dir(): + return current + if current == current.parent: + return None + current = current.parent + + +def _cursor_shell_ticket_dir(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL + + +def _remove_file(path: Path) -> bool: + try: + path.unlink() + return True + except OSError: + return False + + +def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool: + if not left or not right: + return False + left_path = resolve_task_ref(left, repo_root) + right_path = resolve_task_ref(right, repo_root) + if left_path is not None and right_path is not None: + return left_path == right_path + return normalize_task_ref(left) == normalize_task_ref(right) + + +def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool: + if Path(sys.argv[0]).name != "task.py": + return False + args = tuple(sys.argv[1:]) + if not args: + return False + + command_name = args[0] + if command_name not in TASK_SESSION_COMMANDS: + return False + + subcommands = ticket.get("subcommands") + if not isinstance(subcommands, list): + return False + + for subcommand in subcommands: + if not isinstance(subcommand, dict): + continue + if _string_value(subcommand.get("name")) != command_name: + continue + if command_name != "start": + return True + task_ref = args[1] if len(args) > 1 else None + if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root): + return True + + return False + + +def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool: + expires_at = ticket.get("expires_at_epoch") + if isinstance(expires_at, (int, float)) and expires_at < now: + _remove_file(ticket_path) + return False + + created_at = ticket.get("created_at_epoch") + if isinstance(created_at, (int, float)): + if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS: + return True + _remove_file(ticket_path) + return False + return True + + +def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool: + cwd = _string_value(ticket.get("cwd")) + if not cwd: + return True + try: + Path(cwd).resolve().relative_to(repo_root) + except ValueError: + return False + return True + + +def _matching_cursor_ticket_context_key( + ticket_path: Path, + repo_root: Path, + now: float, +) -> str | None: + ticket = _read_json(ticket_path) + if ticket is None or ticket.get("platform") != "cursor": + return None + if not _ticket_is_fresh(ticket, ticket_path, now): + return None + if not _ticket_cwd_matches_repo(ticket, repo_root): + return None + if not _pending_ticket_matches_args(ticket, repo_root): + return None + return _string_value(ticket.get("context_key")) + + +def _lookup_cursor_shell_ticket_context_key() -> str | None: + """Resolve Cursor conversation identity from a short-lived shell ticket. + + Cursor exposes `conversation_id` to `beforeShellExecution`, but does not + export it into the shell command environment. The Cursor hook writes a + short-lived ticket just before `task.py` runs. We accept a ticket only when + the current `task.py` subcommand matches and exactly one fresh context key + matches, which avoids cross-window pointer contamination. + """ + repo_root = _find_repo_root_from_cwd() + if repo_root is None: + return None + + ticket_dir = _cursor_shell_ticket_dir(repo_root) + if not ticket_dir.is_dir(): + return None + + now = time.time() + candidates: set[str] = set() + for ticket_path in ticket_dir.glob("*.json"): + context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now) + if context_key: + candidates.add(context_key) + + if len(candidates) == 1: + return next(iter(candidates)) + return None + + +def resolve_context_key( + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> str | None: + """Resolve a stable session/window context key, if one is available. + + `TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI + scripts and subprocesses. It does not store the task itself. + """ + override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID")) + if override: + return _sanitize_key(override) or _hash_value(override) + + data = _as_dict(platform_input) + platform_name = _detect_platform(data, platform) if data or platform else None + + if data: + session_id = _lookup_string(data, _SESSION_KEYS) + if session_id: + return _context_key(platform_name or "session", "session", session_id) + + conversation_id = _lookup_string(data, _CONVERSATION_KEYS) + if conversation_id: + return _context_key(platform_name or "session", "conversation", conversation_id) + + transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS) + if transcript_path: + return _context_key(platform_name or "session", "transcript", transcript_path) + + env_context_key = _lookup_env_context_key(platform_name) + if env_context_key: + return env_context_key + + if platform_name in (None, "session", "cursor"): + return _lookup_cursor_shell_ticket_context_key() + return None + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None + + +def _write_json(path: Path, data: dict[str, Any]) -> bool: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return True + except OSError: + return False + + +def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None: + normalized = normalize_task_ref(task_path) + if not normalized: + return None + full_path = resolve_task_ref(normalized, repo_root) + if full_path is None or not full_path.is_dir(): + return None + try: + return full_path.relative_to(repo_root).as_posix() + except ValueError: + return str(full_path) + + +def _active_from_ref( + task_ref: str | None, + repo_root: Path, + source_type: str, + context_key: str | None = None, +) -> ActiveTask | None: + if not task_ref: + return None + resolved = resolve_task_ref(task_ref, repo_root) + stale = resolved is None or not resolved.is_dir() + return ActiveTask(task_ref, source_type, context_key, stale) + + +def _context_path(repo_root: Path, context_key: str) -> Path: + return _runtime_sessions_dir(repo_root) / f"{context_key}.json" + + +def resolve_active_task( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask: + """Resolve the active task from session runtime state only. + + A stale session task is returned as stale. Missing context identity or a + missing/empty session context falls back to single-session inference: if + exactly one session file exists in the runtime, return its task with + source_type="session-fallback" — covers class-2 platform sub-agents (codex, + copilot, gemini, qoder) that don't inherit the parent's session id. ≥2 + files or 0 files yield ActiveTask(None) — refuses to guess across windows. + """ + context_key = resolve_context_key(platform_input, platform) + if context_key: + context = _read_json(_context_path(repo_root, context_key)) or {} + task_ref = _string_value(context.get("current_task")) + active = _active_from_ref(task_ref, repo_root, "session", context_key) + if active: + return active + + fallback = _resolve_single_session_fallback(repo_root) + if fallback is not None: + return fallback + + return ActiveTask(None, "none", context_key) + + +def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None: + """Return the task pointed at by the sole session file, if exactly one exists. + + Used when context-key resolution fails (typical for class-2 platform + sub-agents). Returns None if 0 or ≥2 session files are present — refuses + to pick across windows so 04-21's multi-session isolation contract holds. + """ + sessions_dir = _runtime_sessions_dir(repo_root) + if not sessions_dir.is_dir(): + return None + + session_files = sorted(sessions_dir.glob("*.json")) + if len(session_files) != 1: + return None + + session_file = session_files[0] + context = _read_json(session_file) or {} + task_ref = _string_value(context.get("current_task")) + if not task_ref: + return None + + fallback_key = session_file.stem + return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _context_metadata( + platform_input: dict[str, Any] | None, + platform: str | None, + context_key: str | None = None, +) -> dict[str, Any]: + data = _as_dict(platform_input) or {} + platform_name = _detect_platform(data, platform) + if platform_name == "session" and context_key: + prefix = context_key.split("_", 1)[0] + if prefix in _KNOWN_PLATFORMS: + platform_name = prefix + metadata: dict[str, Any] = { + "platform": platform_name, + "last_seen_at": _utc_now(), + } + for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS): + value = _lookup_string(data, (key,)) + if value: + metadata[key] = value + return metadata + + +def set_active_task( + task_path: str, + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask | None: + """Set the active task in session scope. + + Returns None when no context key is available; callers should surface a + user-facing error that explains how to provide session identity. + """ + canonical = _canonical_task_ref(task_path, repo_root) + if canonical is None: + return None + + context_key = resolve_context_key(platform_input, platform) + if not context_key: + return None + + context_path = _context_path(repo_root, context_key) + context = _read_json(context_path) or {} + context.update(_context_metadata(platform_input, platform, context_key)) + context["current_task"] = canonical + context.setdefault("current_run", None) + if not _write_json(context_path, context): + return None + return ActiveTask(canonical, "session", context_key) + + +def clear_active_task( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask: + """Clear the active task by deleting the current session context file.""" + context_key = resolve_context_key(platform_input, platform) + if not context_key: + return ActiveTask(None, "none") + + previous = resolve_active_task(repo_root, platform_input, platform) + context_path = _context_path(repo_root, context_key) + if context_path.is_file(): + _remove_file(context_path) + return previous + + +def clear_task_from_sessions(task_path: str, repo_root: Path) -> int: + """Delete all session runtime files that point at a task.""" + target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path) + if not target: + return 0 + + cleared = 0 + sessions_dir = _runtime_sessions_dir(repo_root) + if not sessions_dir.is_dir(): + return cleared + + for session_path in sessions_dir.glob("*.json"): + context = _read_json(session_path) or {} + current = _string_value(context.get("current_task")) + if not current: + continue + current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current) + if current_ref != target: + continue + if session_path.is_file() and _remove_file(session_path): + cleared += 1 + + return cleared + + +def get_current_task_source( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> tuple[str, str | None, str | None]: + """Return (`source_type`, `context_key`, `task_path`) for compatibility.""" + active = resolve_active_task(repo_root, platform_input, platform) + return active.source_type, active.context_key, active.task_path diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py new file mode 100644 index 0000000000..b65f61a26f --- /dev/null +++ b/.trellis/scripts/common/cli_adapter.py @@ -0,0 +1,811 @@ +""" +CLI Adapter for Multi-Platform Support. + +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces. + +Supported platforms: +- claude: Claude Code (default) +- opencode: OpenCode +- cursor: Cursor IDE +- iflow: iFlow CLI +- codex: Codex CLI (skills-based) +- kilo: Kilo CLI +- kiro: Kiro Code (skills-based) +- gemini: Gemini CLI +- antigravity: Antigravity (workflow-based) +- windsurf: Windsurf (workflow-based) +- qoder: Qoder +- codebuddy: CodeBuddy +- copilot: GitHub Copilot (VS Code) +- droid: Factory Droid (commands-based) +- pi: Pi Agent (extension-backed) + +Usage: + from common.cli_adapter import CLIAdapter + + adapter = CLIAdapter("opencode") + cmd = adapter.build_run_command( + agent="dispatch", + session_id="abc123", + prompt="Start the pipeline" + ) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar, Literal + +Platform = Literal[ + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", +] + + +@dataclass +class CLIAdapter: + """Adapter for different AI coding CLI tools.""" + + platform: Platform + + # ========================================================================= + # Agent Name Mapping + # ========================================================================= + + # OpenCode has built-in agents that cannot be overridden + # See: https://github.com/sst/opencode/issues/4271 + # Note: Class-level constant, not a dataclass field + _AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = { + "claude": {}, # No mapping needed + "opencode": { + "plan": "trellis-plan", # 'plan' is built-in in OpenCode + }, + } + + def get_agent_name(self, agent: str) -> str: + """Get platform-specific agent name. + + Args: + agent: Original agent name (e.g., 'plan', 'dispatch') + + Returns: + Platform-specific agent name (e.g., 'trellis-plan' for OpenCode) + """ + mapping = self._AGENT_NAME_MAP.get(self.platform, {}) + return mapping.get(agent, agent) + + # ========================================================================= + # Agent Path + # ========================================================================= + + @property + def config_dir_name(self) -> str: + """Get platform-specific config directory name. + + Returns: + Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.windsurf', '.qoder', '.codebuddy', '.github/copilot', '.factory', or '.pi') + """ + if self.platform == "opencode": + return ".opencode" + elif self.platform == "cursor": + return ".cursor" + elif self.platform == "iflow": + return ".iflow" + elif self.platform == "codex": + return ".codex" + elif self.platform == "kilo": + return ".kilocode" + elif self.platform == "kiro": + return ".kiro" + elif self.platform == "gemini": + return ".gemini" + elif self.platform == "antigravity": + return ".agent" + elif self.platform == "windsurf": + return ".windsurf" + elif self.platform == "qoder": + return ".qoder" + elif self.platform == "codebuddy": + return ".codebuddy" + elif self.platform == "copilot": + return ".github/copilot" + elif self.platform == "droid": + return ".factory" + elif self.platform == "pi": + return ".pi" + else: + return ".claude" + + def get_config_dir(self, project_root: Path) -> Path: + """Get platform-specific config directory. + + Args: + project_root: Project root directory + + Returns: + Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .windsurf, .qoder, .codebuddy, .github/copilot, .factory, or .pi) + """ + return project_root / self.config_dir_name + + def get_agent_path(self, agent: str, project_root: Path) -> Path: + """Get path to agent definition file. + + Args: + agent: Agent name (original, before mapping) + project_root: Project root directory + + Returns: + Path to agent definition file (.md for most platforms, .toml for Codex) + """ + mapped_name = self.get_agent_name(agent) + if self.platform == "codex": + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml" + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" + + def get_commands_path(self, project_root: Path, *parts: str) -> Path: + """Get path to commands directory or specific command file. + + Args: + project_root: Project root directory + *parts: Additional path parts (e.g., 'trellis', 'finish-work.md') + + Returns: + Path to commands directory or file + + Note: + Cursor uses prefix naming: .cursor/commands/trellis-<name>.md + Antigravity uses workflow directory: .agent/workflows/<name>.md + Windsurf uses workflow directory: .windsurf/workflows/trellis-<name>.md + Copilot uses prompt files: .github/prompts/<name>.prompt.md + Pi uses prompt templates: .pi/prompts/trellis-<name>.md + Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md + """ + if self.platform == "pi": + prompts_dir = self.get_config_dir(project_root) / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"trellis-{filename}.md" + return prompts_dir / Path(*parts) + + if self.platform == "windsurf": + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / f"trellis-{filename}" + return workflow_dir / Path(*parts) + + if self.platform in ("antigravity", "kilo"): + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / filename + return workflow_dir / Path(*parts) + + if self.platform == "copilot": + prompts_dir = project_root / ".github" / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"{filename}.prompt.md" + return prompts_dir / Path(*parts) + + if not parts: + return self.get_config_dir(project_root) / "commands" + + # Cursor uses prefix naming instead of subdirectory + if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis": + # Convert trellis/<name>.md to trellis-<name>.md + filename = parts[-1] + return ( + self.get_config_dir(project_root) / "commands" / f"trellis-{filename}" + ) + + return self.get_config_dir(project_root) / "commands" / Path(*parts) + + def get_trellis_command_path(self, name: str) -> str: + """Get relative path to a trellis command file. + + Args: + name: Command name without extension (e.g., 'finish-work', 'check') + + Returns: + Relative path string for use in JSONL entries + + Note: + Cursor: .cursor/commands/trellis-<name>.md + Codex: .agents/skills/trellis-<name>/SKILL.md + Kiro: .kiro/skills/trellis-<name>/SKILL.md + Gemini: .gemini/commands/trellis/<name>.toml + Antigravity: .agent/workflows/<name>.md + Windsurf: .windsurf/workflows/trellis-<name>.md + Pi: .pi/prompts/trellis-<name>.md + Others: .{platform}/commands/trellis/<name>.md + """ + if self.platform == "cursor": + return f".cursor/commands/trellis-{name}.md" + elif self.platform == "codex": + # 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix + # (see that release's manifest for the 60+ rename entries). + return f".agents/skills/trellis-{name}/SKILL.md" + elif self.platform == "kiro": + return f".kiro/skills/trellis-{name}/SKILL.md" + elif self.platform == "gemini": + return f".gemini/commands/trellis/{name}.toml" + elif self.platform == "antigravity": + return f".agent/workflows/{name}.md" + elif self.platform == "windsurf": + return f".windsurf/workflows/trellis-{name}.md" + elif self.platform == "kilo": + return f".kilocode/workflows/{name}.md" + elif self.platform == "copilot": + return f".github/prompts/{name}.prompt.md" + elif self.platform == "droid": + return f".factory/commands/trellis/{name}.md" + elif self.platform == "pi": + return f".pi/prompts/trellis-{name}.md" + else: + return f"{self.config_dir_name}/commands/trellis/{name}.md" + + # ========================================================================= + # Environment Variables + # ========================================================================= + + def get_non_interactive_env(self) -> dict[str, str]: + """Get environment variables for non-interactive mode. + + Returns: + Dict of environment variables to set + """ + if self.platform == "opencode": + return {"OPENCODE_NON_INTERACTIVE": "1"} + elif self.platform == "iflow": + return {"IFLOW_NON_INTERACTIVE": "1"} + elif self.platform == "codex": + return {"CODEX_NON_INTERACTIVE": "1"} + elif self.platform == "kiro": + return {"KIRO_NON_INTERACTIVE": "1"} + elif self.platform == "gemini": + return {} # Gemini CLI doesn't have a non-interactive env var + elif self.platform == "antigravity": + return {} + elif self.platform == "windsurf": + return {} + elif self.platform == "qoder": + return {} + elif self.platform == "codebuddy": + return {} + elif self.platform == "copilot": + return {} + elif self.platform == "droid": + return {} + elif self.platform == "pi": + return {} + else: + return {"CLAUDE_NON_INTERACTIVE": "1"} + + # ========================================================================= + # CLI Command Building + # ========================================================================= + + def build_run_command( + self, + agent: str, + prompt: str, + session_id: str | None = None, + skip_permissions: bool = True, + verbose: bool = True, + json_output: bool = True, + ) -> list[str]: + """Build CLI command for running an agent. + + Args: + agent: Agent name (will be mapped if needed) + prompt: Prompt to send to the agent + session_id: Optional session ID (Claude Code only for creation) + skip_permissions: Whether to skip permission prompts + verbose: Whether to enable verbose output + json_output: Whether to use JSON output format + + Returns: + List of command arguments + """ + mapped_agent = self.get_agent_name(agent) + + if self.platform == "opencode": + cmd = ["opencode", "run"] + cmd.extend(["--agent", mapped_agent]) + + # Note: OpenCode 'run' mode is non-interactive by default + # No equivalent to Claude Code's --dangerously-skip-permissions + # See: https://github.com/anomalyco/opencode/issues/9070 + + if json_output: + cmd.extend(["--format", "json"]) + + if verbose: + cmd.extend(["--log-level", "DEBUG", "--print-logs"]) + + # Note: OpenCode doesn't support --session-id on creation + # Session ID must be extracted from logs after startup + + cmd.append(prompt) + + elif self.platform == "iflow": + cmd = ["iflow", "-y", "-p"] + cmd.append(f"${mapped_agent} {prompt}") + elif self.platform == "codex": + cmd = ["codex", "exec"] + cmd.append(prompt) + elif self.platform == "kiro": + cmd = ["kiro", "run", prompt] + elif self.platform == "gemini": + cmd = ["gemini"] + cmd.append(prompt) + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI agent run is not supported." + ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI agent run is not supported." + ) + elif self.platform == "qoder": + cmd = ["qodercli", "-p", prompt] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI agent run is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI agent run is not yet supported." + ) + elif self.platform == "pi": + cmd = ["pi", "-p", prompt] + + else: # claude + cmd = ["claude", "-p"] + cmd.extend(["--agent", mapped_agent]) + + if session_id: + cmd.extend(["--session-id", session_id]) + + if skip_permissions: + cmd.append("--dangerously-skip-permissions") + + if json_output: + cmd.extend(["--output-format", "stream-json"]) + + if verbose: + cmd.append("--verbose") + + cmd.append(prompt) + + return cmd + + def build_resume_command(self, session_id: str) -> list[str]: + """Build CLI command for resuming a session. + + Args: + session_id: Session ID to resume (ignored for iFlow) + + Returns: + List of command arguments + """ + if self.platform == "opencode": + return ["opencode", "run", "--session", session_id] + elif self.platform == "iflow": + # iFlow uses -c to continue most recent conversation + # session_id is ignored as iFlow doesn't support session IDs + return ["iflow", "-c"] + elif self.platform == "codex": + return ["codex", "resume", session_id] + elif self.platform == "kiro": + return ["kiro", "resume", session_id] + elif self.platform == "gemini": + return ["gemini", "--resume", session_id] + elif self.platform == "antigravity": + raise ValueError( + "Antigravity workflows are UI slash commands; CLI resume is not supported." + ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI resume is not supported." + ) + elif self.platform == "qoder": + return ["qodercli", "--resume", session_id] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI resume is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI resume is not yet supported." + ) + elif self.platform == "pi": + return ["pi", "-c", session_id] + else: + return ["claude", "--resume", session_id] + + def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str: + """Get human-readable resume command string. + + Args: + session_id: Session ID to resume + cwd: Optional working directory to cd into + + Returns: + Command string for display + """ + cmd = self.build_resume_command(session_id) + cmd_str = " ".join(cmd) + + if cwd: + return f"cd {cwd} && {cmd_str}" + return cmd_str + + # ========================================================================= + # Platform Detection Helpers + # ========================================================================= + + @property + def is_opencode(self) -> bool: + """Check if platform is OpenCode.""" + return self.platform == "opencode" + + @property + def is_claude(self) -> bool: + """Check if platform is Claude Code.""" + return self.platform == "claude" + + @property + def is_cursor(self) -> bool: + """Check if platform is Cursor.""" + return self.platform == "cursor" + + @property + def is_iflow(self) -> bool: + """Check if platform is iFlow CLI.""" + return self.platform == "iflow" + + @property + def cli_name(self) -> str: + """Get CLI executable name. + + Note: Cursor doesn't have a CLI tool, returns None-like value. + """ + if self.is_opencode: + return "opencode" + elif self.is_cursor: + return "cursor" # Note: Cursor is IDE-only, no CLI + elif self.platform == "iflow": + return "iflow" + elif self.platform == "kiro": + return "kiro" + elif self.platform == "gemini": + return "gemini" + elif self.platform == "antigravity": + return "agy" + elif self.platform == "windsurf": + return "windsurf" + elif self.platform == "qoder": + return "qodercli" + elif self.platform == "codebuddy": + return "codebuddy" + elif self.platform == "copilot": + return "copilot" + elif self.platform == "droid": + return "droid" + elif self.platform == "pi": + return "pi" + else: + return "claude" + + @property + def supports_cli_agents(self) -> bool: + """Check if platform supports running agents via CLI. + + Claude Code, OpenCode, iFlow, and Codex support CLI agent execution. + Cursor is IDE-only and doesn't support CLI agents. + """ + return self.platform in ("claude", "opencode", "iflow", "codex", "pi") + + @property + def requires_agent_definition_file(self) -> bool: + """Check if platform requires an agent definition file (.md/.toml) to run. + + Claude Code, OpenCode, iFlow: require agent .md files (--agent flag). + Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag. + """ + return self.platform in ("claude", "opencode", "iflow") + + # ========================================================================= + # Session ID Handling + # ========================================================================= + + @property + def supports_session_id_on_create(self) -> bool: + """Check if platform supports specifying session ID on creation. + + Claude Code: Yes (--session-id) + OpenCode: No (auto-generated, extract from logs) + iFlow: No (no session ID support) + """ + return self.platform == "claude" + + def extract_session_id_from_log(self, log_content: str) -> str | None: + """Extract session ID from log output (OpenCode only). + + OpenCode generates session IDs in format: ses_xxx + + Args: + log_content: Log file content + + Returns: + Session ID if found, None otherwise + """ + import re + + # OpenCode session ID pattern + match = re.search(r"ses_[a-zA-Z0-9]+", log_content) + if match: + return match.group(0) + return None + + +# ============================================================================= +# Factory Function +# ============================================================================= + + +def get_cli_adapter(platform: str = "claude") -> CLIAdapter: + """Get CLI adapter for the specified platform. + + Args: + platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi') + + Returns: + CLIAdapter instance + + Raises: + ValueError: If platform is not supported + """ + if platform not in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", + ): + raise ValueError( + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')" + ) + + return CLIAdapter(platform=platform) # type: ignore + + +_ALL_PLATFORM_CONFIG_DIRS = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".codex", + ".kilocode", + ".kiro", + ".gemini", + ".agent", + ".windsurf", + ".qoder", + ".codebuddy", + ".github/copilot", + ".factory", + ".pi", +) +"""Platform-specific config directory names used by detect_platform exclusion +checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform +layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the +agentskills.io standard), not a single-platform signal. Its presence must not +block detection of Kiro, Antigravity, Windsurf, or other platforms.""" + + +def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool: + """Check if any platform config dir exists besides those in *exclude*.""" + return any( + (project_root / d).is_dir() + for d in _ALL_PLATFORM_CONFIG_DIRS + if d not in exclude + ) + + +def detect_platform(project_root: Path) -> Platform: + """Auto-detect platform based on existing config directories. + + Detection order: + 1. TRELLIS_PLATFORM environment variable (if set) + 2. .opencode directory exists → opencode + 3. .iflow directory exists → iflow + 4. .cursor directory exists (without .claude) → cursor + 5. .codex exists and no other platform dirs → codex + 6. .kilocode directory exists → kilo + 7. .kiro/skills exists and no other platform dirs → kiro + 8. .gemini directory exists → gemini + 9. .agent/workflows exists and no other platform dirs → antigravity + 10. .windsurf/workflows exists and no other platform dirs → windsurf + 11. .codebuddy directory exists → codebuddy + 12. .qoder directory exists → qoder + 13. .pi directory exists → pi + 14. Default → claude + + Args: + project_root: Project root directory + + Returns: + Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude') + """ + import os + + # Check environment variable first + env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower() + if env_platform in ( + "claude", + "opencode", + "cursor", + "iflow", + "codex", + "kilo", + "kiro", + "gemini", + "antigravity", + "windsurf", + "qoder", + "codebuddy", + "copilot", + "droid", + "pi", + ): + return env_platform # type: ignore + + # Check for .opencode directory (OpenCode-specific) + if (project_root / ".opencode").is_dir(): + return "opencode" + + # Check for .iflow directory (iFlow-specific) + if (project_root / ".iflow").is_dir(): + return "iflow" + + # Check for .cursor directory (Cursor-specific) + # Only detect as cursor if .claude doesn't exist (to avoid confusion) + if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir(): + return "cursor" + + # Check for .gemini directory (Gemini CLI-specific) + if (project_root / ".gemini").is_dir(): + return "gemini" + + # Check for .codex directory (Codex-specific) + # .agents/skills/ alone does NOT trigger codex detection (it's a shared standard) + if (project_root / ".codex").is_dir() and not _has_other_platform_dir( + project_root, {".codex", ".agents"} + ): + return "codex" + + # Check for .kilocode directory (Kilo-specific) + if (project_root / ".kilocode").is_dir(): + return "kilo" + + # Check for Kiro skills directory only when no other platform config exists + if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir( + project_root, {".kiro"} + ): + return "kiro" + + # Check for Antigravity workflow directory only when no other platform config exists + if ( + project_root / ".agent" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".agent", ".gemini"} + ): + return "antigravity" + + # Check for Windsurf workflow directory only when no other platform config exists + if ( + project_root / ".windsurf" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".windsurf"} + ): + return "windsurf" + + # Check for .codebuddy directory (CodeBuddy-specific) + if (project_root / ".codebuddy").is_dir(): + return "codebuddy" + + # Check for .qoder directory (Qoder-specific) + if (project_root / ".qoder").is_dir(): + return "qoder" + + # Check for .github/copilot directory (GitHub Copilot-specific) + if (project_root / ".github" / "copilot").is_dir(): + return "copilot" + + # Check for .factory directory (Factory Droid-specific) + if (project_root / ".factory").is_dir(): + return "droid" + + # Check for .pi directory (Pi Agent-specific) + if (project_root / ".pi").is_dir(): + return "pi" + + # Fallback: checkout only has the Codex shared-skills layer + # (.agents/skills/trellis-* dirs) and no explicit platform config dir. + # Happens on fresh clones where .codex/ is gitignored/absent but the + # shared skills were committed to git. Must guard against the case + # where .claude/ or any other platform dir also exists — .agents/skills/ + # can legitimately coexist with any platform as a shared consumption + # layer for Amp/Cline/Warp/etc. + agents_skills = project_root / ".agents" / "skills" + if agents_skills.is_dir() and not _has_other_platform_dir( + project_root, set() + ): + try: + for entry in agents_skills.iterdir(): + if entry.is_dir() and entry.name.startswith("trellis-"): + return "codex" + except OSError: + pass + + return "claude" + + +def get_cli_adapter_auto(project_root: Path) -> CLIAdapter: + """Get CLI adapter with auto-detected platform. + + Args: + project_root: Project root directory + + Returns: + CLIAdapter instance for detected platform + """ + platform = detect_platform(project_root) + return CLIAdapter(platform=platform) diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py new file mode 100644 index 0000000000..93df643fc9 --- /dev/null +++ b/.trellis/scripts/common/config.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +""" +Trellis configuration reader. + +Reads settings from .trellis/config.yaml with sensible defaults. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .paths import DIR_WORKFLOW, get_repo_root + + +# ============================================================================= +# YAML Simple Parser (no dependencies) +# ============================================================================= + + +def _unquote(s: str) -> str: + """Remove exactly one layer of matching surrounding quotes. + + Unlike str.strip('"'), this only removes the outermost pair, + preserving any nested quotes inside the value. + + Examples: + _unquote('"hello"') -> 'hello' + _unquote("'hello'") -> 'hello' + _unquote('"echo \\'hi\\'"') -> "echo 'hi'" + _unquote('hello') -> 'hello' + _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) + """ + if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): + return s[1:-1] + return s + + +def _strip_inline_comment(value: str) -> str: + """Strip ` # …` inline comments while preserving `#` inside quoted strings. + + YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token + is part of the value. Quoted strings are immune. + + Mirrors :func:`common.trellis_config._strip_inline_comment` so both + parsers handle ``key: value # comment`` identically. + """ + in_quote: str | None = None + for idx, ch in enumerate(value): + if in_quote: + if ch == in_quote: + in_quote = None + continue + if ch in ('"', "'"): + in_quote = ch + continue + if ch == "#" and (idx == 0 or value[idx - 1].isspace()): + return value[:idx] + return value + + +def parse_simple_yaml(content: str) -> dict: + """Parse simple YAML with nested dict support (no dependencies). + + Supports: + - key: value (string) + - key: (followed by list items) + - item1 + - item2 + - key: (followed by nested dict) + nested_key: value + nested_key2: + - item + + Uses indentation to detect nesting (2+ spaces deeper = child). + + Args: + content: YAML content string. + + Returns: + Parsed dict (values can be str, list[str], or dict). + """ + lines = content.splitlines() + result: dict = {} + _parse_yaml_block(lines, 0, 0, result) + return result + + +def _parse_yaml_block( + lines: list[str], start: int, min_indent: int, target: dict +) -> int: + """Parse a YAML block into target dict, returning next line index.""" + i = start + current_list: list | None = None + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith("#"): + i += 1 + continue + + # Calculate indentation + indent = len(line) - len(line.lstrip()) + + # If dedented past our block, we're done + if indent < min_indent: + break + + if stripped.startswith("- "): + if current_list is not None: + current_list.append(_unquote(stripped[2:].strip())) + i += 1 + elif ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = _strip_inline_comment(value).strip() + value = _unquote(value) + current_list = None + + if value: + # key: value + target[key] = value + i += 1 + else: + # key: (no value) — peek ahead to determine list vs nested dict + next_i, next_line = _next_content_line(lines, i + 1) + if next_i >= len(lines): + target[key] = {} + i = next_i + elif next_line.strip().startswith("- "): + # It's a list + current_list = [] + target[key] = current_list + i += 1 + else: + next_indent = len(next_line) - len(next_line.lstrip()) + if next_indent > indent: + # It's a nested dict + nested: dict = {} + target[key] = nested + i = _parse_yaml_block(lines, i + 1, next_indent, nested) + else: + # Empty value, same or less indent follows + target[key] = {} + i += 1 + else: + i += 1 + + return i + + +def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: + """Find the next non-empty, non-comment line.""" + i = start + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i, lines[i] + i += 1 + return i, "" + + +# Defaults +DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" +DEFAULT_MAX_JOURNAL_LINES = 2000 +DEFAULT_SESSION_AUTO_COMMIT = True + +CONFIG_FILE = "config.yaml" + + +def _is_true_config_value(value: object) -> bool: + """Return True when a config value represents an enabled flag.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + +def _get_config_path(repo_root: Path | None = None) -> Path: + """Get path to config.yaml.""" + root = repo_root or get_repo_root() + return root / DIR_WORKFLOW / CONFIG_FILE + + +def _load_config(repo_root: Path | None = None) -> dict: + """Load and parse config.yaml. Returns empty dict on any error.""" + config_file = _get_config_path(repo_root) + try: + content = config_file.read_text(encoding="utf-8") + return parse_simple_yaml(content) + except (OSError, IOError): + return {} + + +def get_session_commit_message(repo_root: Path | None = None) -> str: + """Get the commit message for auto-committing session records.""" + config = _load_config(repo_root) + return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE) + + +def get_max_journal_lines(repo_root: Path | None = None) -> int: + """Get the maximum lines per journal file.""" + config = _load_config(repo_root) + value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES) + try: + return int(value) + except (ValueError, TypeError): + return DEFAULT_MAX_JOURNAL_LINES + + +def get_session_auto_commit(repo_root: Path | None = None) -> bool: + """Whether scripts should auto-stage + auto-commit session/task changes. + + Governs both ``add_session.py:_auto_commit_workspace`` and + ``task_store.py:_auto_commit_archive``. + + Default: ``True`` (existing behavior — auto-stage + auto-commit). + Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip + auto-staging entirely; the journal/archive files are still written to + disk, but the user manages ``git add`` / ``git commit`` themselves. + + Accepts native YAML booleans (``true`` / ``false``) and the string + aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive). + Invalid values fall back to ``True`` with a stderr warning. + """ + config = _load_config(repo_root) + raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT) + if isinstance(raw, bool): + return raw + s = str(raw).strip().lower() + if s in ("true", "yes", "1", "on"): + return True + if s in ("false", "no", "0", "off"): + return False + print( + f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)", + file=sys.stderr, + ) + return DEFAULT_SESSION_AUTO_COMMIT + + +def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: + """Get hook commands for a lifecycle event. + + Args: + event: Event name (e.g. "after_create", "after_archive"). + repo_root: Repository root path. + + Returns: + List of shell commands to execute, empty if none configured. + """ + config = _load_config(repo_root) + hooks = config.get("hooks") + if not isinstance(hooks, dict): + return [] + commands = hooks.get(event) + if isinstance(commands, list): + return [str(c) for c in commands] + return [] + + +# ============================================================================= +# Monorepo / Packages +# ============================================================================= + + +def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: + """Get monorepo package declarations. + + Returns: + Dict mapping package name to its config (path, type, etc.), + or None if not configured (single-repo mode). + + Example return: + {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} + """ + config = _load_config(repo_root) + packages = config.get("packages") + if not isinstance(packages, dict): + return None + # Ensure each value is a dict (filter out scalar entries) + filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} + if not filtered: + return None + return filtered + + +def get_default_package(repo_root: Path | None = None) -> str | None: + """Get the default package name from config. + + Returns: + Package name string, or None if not configured. + """ + config = _load_config(repo_root) + value = config.get("default_package") + return str(value) if value else None + + +def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that are git submodules. + + Returns: + Dict mapping package name to its path for submodule-type packages. + Empty dict if none configured. + + Example return: + {"docs-site": "docs-site"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if cfg.get("type") == "submodule" + } + + +def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that have their own independent git repository. + + These are sub-directories with their own .git (not submodules), + marked with ``git: true`` in config.yaml. + + Returns: + Dict mapping package name to its path for git-repo packages. + Empty dict if none configured. + + Example config:: + + packages: + backend: + path: iqs + git: true + + Example return:: + + {"backend": "iqs"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if _is_true_config_value(cfg.get("git")) + } + + +def is_monorepo(repo_root: Path | None = None) -> bool: + """Check if the project is configured as a monorepo (has packages in config).""" + return get_packages(repo_root) is not None + + +def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: + """Get the spec directory base path relative to .trellis/. + + Single-repo: returns "spec" + Monorepo with package: returns "spec/<package>" + Monorepo without package: returns "spec" (caller should specify package) + """ + if package and is_monorepo(repo_root): + return f"spec/{package}" + return "spec" + + +def validate_package(package: str, repo_root: Path | None = None) -> bool: + """Check if a package name is valid in this project. + + Single-repo (no packages configured): always returns True. + Monorepo: returns True only if package exists in config.yaml packages. + """ + packages = get_packages(repo_root) + if packages is None: + return True # Single-repo, no validation needed + return package in packages + + +def resolve_package( + task_package: str | None = None, + repo_root: Path | None = None, +) -> str | None: + """Resolve package from inferred sources with validation. + + Checks in order: task_package → default_package. + Invalid inferred values print a warning to stderr and are skipped. + + Returns: + Resolved package name, or None if no valid package found. + + Note: + CLI --package should be validated separately by the caller + (fail-fast with available packages list on error). + """ + packages = get_packages(repo_root) + if packages is None: + return None # Single-repo, no package needed + + # Try task_package (guard against non-string values from malformed JSON) + if task_package and isinstance(task_package, str): + if task_package in packages: + return task_package + print( + f"Warning: task.json package '{task_package}' not found in config, skipping", + file=sys.stderr, + ) + + # Try default_package + default = get_default_package(repo_root) + if default: + if default in packages: + return default + print( + f"Warning: default_package '{default}' not found in config, skipping", + file=sys.stderr, + ) + + return None + + +def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: + """Get session.spec_scope configuration. + + Returns: + list[str]: Package names to include in spec scanning. + str: "active_task" to use current task's package. + None: No scope configured (scan all packages). + """ + config = _load_config(repo_root) + session = config.get("session") + if not isinstance(session, dict): + return None + + scope = session.get("spec_scope") + if scope is None: + return None + if isinstance(scope, str): + return scope # e.g. "active_task" + if isinstance(scope, list): + return [str(s) for s in scope] + return None diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py new file mode 100644 index 0000000000..f4227783a3 --- /dev/null +++ b/.trellis/scripts/common/developer.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Developer management utilities. + +Provides: + init_developer - Initialize developer + ensure_developer - Ensure developer is initialized (exit if not) + show_developer_info - Show developer information +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +from .paths import ( + DIR_WORKFLOW, + DIR_WORKSPACE, + DIR_TASKS, + FILE_DEVELOPER, + FILE_JOURNAL_PREFIX, + get_repo_root, + get_developer, + check_developer, +) + + +# ============================================================================= +# Developer Initialization +# ============================================================================= + +def init_developer(name: str, repo_root: Path | None = None) -> bool: + """Initialize developer. + + Creates: + - .trellis/.developer file with developer info + - .trellis/workspace/<name>/ directory structure + - Initial journal file and index.md + + Args: + name: Developer name. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success, False on error. + """ + if not name: + print("Error: developer name is required", file=sys.stderr) + return False + + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name + + # Create .developer file + initialized_at = datetime.now().isoformat() + try: + dev_file.write_text( + f"name={name}\ninitialized_at={initialized_at}\n", + encoding="utf-8" + ) + except (OSError, IOError) as e: + print(f"Error: Failed to create .developer file: {e}", file=sys.stderr) + return False + + # Create workspace directory structure + try: + workspace_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr) + return False + + # Create initial journal file + journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md" + if not journal_file.exists(): + today = datetime.now().strftime("%Y-%m-%d") + journal_content = f"""# Journal - {name} (Part 1) + +> AI development session journal +> Started: {today} + +--- + +""" + try: + journal_file.write_text(journal_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create journal file: {e}", file=sys.stderr) + return False + + # Create index.md with markers for auto-update + index_file = workspace_dir / "index.md" + if not index_file.exists(): + index_content = f"""# Workspace Index - {name} + +> Journal tracking for AI development sessions. + +--- + +## Current Status + +<!-- @@@auto:current-status --> +- **Active File**: `journal-1.md` +- **Total Sessions**: 0 +- **Last Active**: - +<!-- @@@/auto:current-status --> + +--- + +## Active Documents + +<!-- @@@auto:active-documents --> +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~0 | Active | +<!-- @@@/auto:active-documents --> + +--- + +## Session History + +<!-- @@@auto:session-history --> +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| +<!-- @@@/auto:session-history --> + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions +""" + try: + index_file.write_text(index_content, encoding="utf-8") + except (OSError, IOError) as e: + print(f"Error: Failed to create index.md: {e}", file=sys.stderr) + return False + + print(f"Developer initialized: {name}") + print(f" .developer file: {dev_file}") + print(f" Workspace dir: {workspace_dir}") + + return True + + +def ensure_developer(repo_root: Path | None = None) -> None: + """Ensure developer is initialized, exit if not. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + if not check_developer(repo_root): + print("Error: Developer not initialized.", file=sys.stderr) + print(f"Run: python ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr) + sys.exit(1) + + +def show_developer_info(repo_root: Path | None = None) -> None: + """Show developer information. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + + if not developer: + print("Developer: (not initialized)") + else: + print(f"Developer: {developer}") + print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + show_developer_info() diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py new file mode 100644 index 0000000000..c4bf29f587 --- /dev/null +++ b/.trellis/scripts/common/git.py @@ -0,0 +1,31 @@ +""" +Git command execution utility. + +Single source of truth for running git commands across all Trellis scripts. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr). + + Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure + consistent output across all platforms (Windows, macOS, Linux). + """ + try: + git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args + result = subprocess.run( + git_args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return result.returncode, result.stdout, result.stderr + except Exception as e: + return 1, "", str(e) diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py new file mode 100644 index 0000000000..23fc6eceec --- /dev/null +++ b/.trellis/scripts/common/git_context.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Git and Session Context utilities. + +Entry shim — delegates to session_context and packages_context. + +Provides: + output_json - Output context in JSON format + output_text - Output context in text format +""" + +from __future__ import annotations + +import json + +from .git import run_git +from .session_context import ( + get_context_json, + get_context_text, + get_context_record_json, + get_context_text_record, + output_json, + output_text, +) +from .packages_context import ( + get_context_packages_text, + get_context_packages_json, +) +from .trellis_config import read_trellis_config +from .workflow_phase import ( + filter_platform, + get_phase_index, + get_step, + resolve_effective_platform, +) + +# Backward-compatible alias — external modules import this name +_run_git_command = run_git + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> None: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Get Session Context for AI Agent") + parser.add_argument( + "--json", + "-j", + action="store_true", + help="Output in JSON format (works with any --mode)", + ) + parser.add_argument( + "--mode", + "-m", + choices=["default", "record", "packages", "phase"], + default="default", + help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)", + ) + parser.add_argument( + "--step", + help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.", + ) + parser.add_argument( + "--platform", + help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.", + ) + + args = parser.parse_args() + + if args.mode == "record": + if args.json: + print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_text_record()) + elif args.mode == "packages": + if args.json: + print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_packages_text()) + elif args.mode == "phase": + content = get_step(args.step) if args.step else get_phase_index() + if not content.strip(): + if args.step: + parser.exit(2, f"Step not found: {args.step}\n") + else: + parser.exit(2, "Phase Index section not found in workflow.md\n") + if args.platform: + effective = resolve_effective_platform( + args.platform, read_trellis_config() + ) + content = filter_platform(content, effective) + print(content, end="") + else: + if args.json: + output_json() + else: + output_text() + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py new file mode 100644 index 0000000000..44288f4163 --- /dev/null +++ b/.trellis/scripts/common/io.py @@ -0,0 +1,37 @@ +""" +JSON file I/O utilities. + +Provides read_json and write_json as the single source of truth +for JSON file operations across all Trellis scripts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def read_json(path: Path) -> dict | None: + """Read and parse a JSON file. + + Returns None if the file doesn't exist, is invalid JSON, or can't be read. + """ + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def write_json(path: Path, data: dict) -> bool: + """Write dict to JSON file with pretty formatting. + + Returns True on success, False on error. + """ + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return True + except (OSError, IOError): + return False diff --git a/.trellis/scripts/common/log.py b/.trellis/scripts/common/log.py new file mode 100644 index 0000000000..839c643bbf --- /dev/null +++ b/.trellis/scripts/common/log.py @@ -0,0 +1,45 @@ +""" +Terminal output utilities: colors and structured logging. + +Single source of truth for Colors and log_* functions +used across all Trellis scripts. +""" + +from __future__ import annotations + + +class Colors: + """ANSI color codes for terminal output.""" + + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + DIM = "\033[2m" + NC = "\033[0m" # No Color / Reset + + +def colored(text: str, color: str) -> str: + """Apply ANSI color to text.""" + return f"{color}{text}{Colors.NC}" + + +def log_info(msg: str) -> None: + """Print info-level message with [INFO] prefix.""" + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + """Print success message with [SUCCESS] prefix.""" + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + """Print warning message with [WARN] prefix.""" + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + """Print error message with [ERROR] prefix.""" + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") diff --git a/.trellis/scripts/common/packages_context.py b/.trellis/scripts/common/packages_context.py new file mode 100644 index 0000000000..e7d4e8c158 --- /dev/null +++ b/.trellis/scripts/common/packages_context.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Package discovery and context output. + +Provides: + get_packages_info - Get structured package info + get_packages_section - Build PACKAGES text section + get_context_packages_text - Full packages text output (--mode packages) + get_context_packages_json - Full packages JSON output (--mode packages --json) +""" + +from __future__ import annotations + +from pathlib import Path + +from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope +from .paths import ( + DIR_SPEC, + DIR_WORKFLOW, + get_current_task, + get_repo_root, +) +from .tasks import load_task + + +# ============================================================================= +# Internal Helpers +# ============================================================================= + +def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]: + """Scan spec directory for available layers (subdirectories). + + For monorepo: scans spec/<package>/ + For single-repo: scans spec/ + """ + target = spec_dir / package if package else spec_dir + if not target.is_dir(): + return [] + return sorted( + d.name for d in target.iterdir() if d.is_dir() and d.name != "guides" + ) + + +def _get_active_task_package(repo_root: Path) -> str | None: + """Get the package field from the active task's task.json.""" + current = get_current_task(repo_root) + if not current: + return None + ct = load_task(repo_root / current) + return ct.package if ct and ct.package else None + + +def _resolve_scope_set( + packages: dict, + spec_scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve spec_scope to a set of allowed package names, or None for full scan.""" + if not packages: + return None + + if spec_scope is None: + return None + + if isinstance(spec_scope, str) and spec_scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + if isinstance(spec_scope, list): + valid = {e for e in spec_scope if e in packages} + if valid: + return valid + # All invalid: fallback + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + return None + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def get_packages_info(repo_root: Path) -> list[dict]: + """Get structured package info for monorepo projects. + + Returns list of dicts with keys: name, path, type, default, specLayers, + isSubmodule, isGitRepo. + Returns empty list for single-repo projects. + """ + packages = get_packages(repo_root) + if not packages: + return [] + + default_pkg = get_default_package(repo_root) + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + result = [] + + for pkg_name, pkg_config in packages.items(): + pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config) + pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local" + pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False + layers = _scan_spec_layers(spec_dir, pkg_name) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "type": pkg_type, + "default": pkg_name == default_pkg, + "specLayers": layers, + "isSubmodule": pkg_type == "submodule", + "isGitRepo": _is_true_config_value(pkg_git), + }) + + return result + + +def get_packages_section(repo_root: Path) -> str: + """Build the PACKAGES section for text output.""" + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + pkg_info = get_packages_info(repo_root) + + lines: list[str] = [] + lines.append("## PACKAGES") + + if not pkg_info: + lines.append("(single-repo mode)") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + default_pkg = get_default_package(repo_root) + + for pkg in pkg_info: + layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else "" + submodule_tag = " (submodule)" if pkg["isSubmodule"] else "" + git_repo_tag = " (git repo)" if pkg["isGitRepo"] else "" + default_tag = " *" if pkg["default"] else "" + lines.append( + f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}" + ) + + if default_pkg: + lines.append(f"Default package: {default_pkg}") + + return "\n".join(lines) + + +def get_context_packages_text(repo_root: Path | None = None) -> str: + """Get packages context as formatted text (for --mode packages).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + lines: list[str] = [] + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + lines.append("Single-repo project (no packages configured)") + lines.append("") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + # Resolve scope for annotations + packages_dict = get_packages(repo_root) or {} + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg) + + lines.append("## PACKAGES") + lines.append("") + for pkg in pkg_info: + default_tag = " (default)" if pkg["default"] else "" + type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else "" + git_tag = " [git repo]" if pkg["isGitRepo"] else "" + + # Scope annotation + scope_tag = "" + if scope_set is not None and pkg["name"] not in scope_set: + scope_tag = " (out of scope)" + + lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}") + lines.append(f"Path: {pkg['path']}") + if pkg["specLayers"]: + lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}") + for layer in pkg["specLayers"]: + lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md") + else: + lines.append("Spec: not configured") + lines.append("") + + # Also show shared guides + guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides" + if guides_dir.is_dir(): + lines.append("### Shared Guides (always included)") + lines.append("Path: .trellis/spec/guides/index.md") + lines.append("") + + return "\n".join(lines) + + +def get_context_packages_json(repo_root: Path | None = None) -> dict: + """Get packages context as a dictionary (for --mode packages --json).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + layers = _scan_spec_layers(spec_dir) + return { + "mode": "single-repo", + "specLayers": layers, + } + + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + + return { + "mode": "monorepo", + "packages": pkg_info, + "defaultPackage": default_pkg, + "specScope": spec_scope, + "activeTaskPackage": task_pkg, + } diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py new file mode 100644 index 0000000000..1c5a58e46d --- /dev/null +++ b/.trellis/scripts/common/paths.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +""" +Common path utilities for Trellis workflow. + +Provides: + get_repo_root - Get repository root directory + get_developer - Get developer name + get_workspace_dir - Get developer workspace directory + get_tasks_dir - Get tasks directory + get_active_journal_file - Get current journal file +""" + +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path + + +# ============================================================================= +# Path Constants (change here to rename directories) +# ============================================================================= + +# Directory names +DIR_WORKFLOW = ".trellis" +DIR_WORKSPACE = "workspace" +DIR_TASKS = "tasks" +DIR_ARCHIVE = "archive" +DIR_SPEC = "spec" +DIR_SCRIPTS = "scripts" + +# File names +FILE_DEVELOPER = ".developer" +FILE_CURRENT_TASK = ".current-task" +FILE_TASK_JSON = "task.json" +FILE_JOURNAL_PREFIX = "journal-" + + +# ============================================================================= +# Repository Root +# ============================================================================= + +def get_repo_root(start_path: Path | None = None) -> Path: + """Find the nearest directory containing .trellis/ folder. + + This handles nested git repos correctly (e.g., test project inside another repo). + + Args: + start_path: Starting directory to search from. Defaults to current directory. + + Returns: + Path to repository root, or current directory if no .trellis/ found. + """ + current = (start_path or Path.cwd()).resolve() + + while current != current.parent: + if (current / DIR_WORKFLOW).is_dir(): + return current + current = current.parent + + # Fallback to current directory if no .trellis/ found + return Path.cwd().resolve() + + +# ============================================================================= +# Developer +# ============================================================================= + +def get_developer(repo_root: Path | None = None) -> str | None: + """Get developer name from .developer file. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Developer name or None if not initialized. + """ + if repo_root is None: + repo_root = get_repo_root() + + dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER + + if not dev_file.is_file(): + return None + + try: + content = dev_file.read_text(encoding="utf-8") + for line in content.splitlines(): + if line.startswith("name="): + return line.split("=", 1)[1].strip() + except (OSError, IOError): + pass + + return None + + +def check_developer(repo_root: Path | None = None) -> bool: + """Check if developer is initialized. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if developer is initialized. + """ + return get_developer(repo_root) is not None + + +# ============================================================================= +# Tasks Directory +# ============================================================================= + +def get_tasks_dir(repo_root: Path | None = None) -> Path: + """Get tasks directory path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to tasks directory. + """ + if repo_root is None: + repo_root = get_repo_root() + return repo_root / DIR_WORKFLOW / DIR_TASKS + + +# ============================================================================= +# Workspace Directory +# ============================================================================= + +def get_workspace_dir(repo_root: Path | None = None) -> Path | None: + """Get developer workspace directory. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to workspace directory or None if developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if developer: + return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer + return None + + +# ============================================================================= +# Journal File +# ============================================================================= + +def get_active_journal_file(repo_root: Path | None = None) -> Path | None: + """Get the current active journal file. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to active journal file or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + workspace_dir = get_workspace_dir(repo_root) + if workspace_dir is None or not workspace_dir.is_dir(): + return None + + latest: Path | None = None + highest = 0 + + for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"): + if not f.is_file(): + continue + + # Extract number from filename + name = f.stem # e.g., "journal-1" + match = re.search(r"(\d+)$", name) + if match: + num = int(match.group(1)) + if num > highest: + highest = num + latest = f + + return latest + + +def count_lines(file_path: Path) -> int: + """Count lines in a file. + + Args: + file_path: Path to file. + + Returns: + Number of lines, or 0 if file doesn't exist. + """ + if not file_path.is_file(): + return 0 + + try: + return len(file_path.read_text(encoding="utf-8").splitlines()) + except (OSError, IOError): + return 0 + + +# ============================================================================= +# Current Task Management +# ============================================================================= + +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable runtime storage. + + Stored refs should prefer repo-relative POSIX paths like + `.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved + unless they can later be converted back to repo-relative form by callers. + """ + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None: + """Resolve a task ref to an absolute task directory path.""" + if repo_root is None: + repo_root = get_repo_root() + + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj + + +def get_current_task( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> str | None: + """Get current task directory path (relative to repo_root). + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Relative path to current task directory or None. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import resolve_active_task + + return resolve_active_task(repo_root, platform_input, platform).task_path + + +def get_current_task_abs( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> Path | None: + """Get current task directory absolute path. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Absolute path to current task directory or None. + """ + if repo_root is None: + repo_root = get_repo_root() + + relative = get_current_task(repo_root, platform_input, platform) + if relative: + return resolve_task_ref(relative, repo_root) + return None + + +def get_current_task_source( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> tuple[str, str | None, str | None]: + """Get active task source as (`source`, `context_key`, `task_path`).""" + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import get_current_task_source as _get_source + + return _get_source(repo_root, platform_input, platform) + + +def set_current_task( + task_path: str, + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> bool: + """Set current task in session scope. + + Args: + task_path: Task directory path (relative to repo_root). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success, False on error. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import set_active_task + + return set_active_task( + task_path, + repo_root, + platform_input=platform_input, + platform=platform, + ) is not None + + +def clear_current_task( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> bool: + """Clear current task in session scope. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True on success. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import clear_active_task + + clear_active_task( + repo_root, + platform_input=platform_input, + platform=platform, + ) + return True + + +def has_current_task(repo_root: Path | None = None) -> bool: + """Check if has current task. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if current task is set. + """ + return get_current_task(repo_root) is not None + + +# ============================================================================= +# Task ID Generation +# ============================================================================= + +def generate_task_date_prefix() -> str: + """Generate task ID based on date (MM-DD format). + + Returns: + Date prefix string (e.g., "01-21"). + """ + return datetime.now().strftime("%m-%d") + + +# ============================================================================= +# Monorepo / Package Paths +# ============================================================================= + + +def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path: + """Get the spec directory path. + + Single-repo: .trellis/spec + Monorepo with package: .trellis/spec/<package> + + Uses lazy import to avoid circular dependency with config.py. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_spec_base + + base = get_spec_base(package, repo_root) + return repo_root / DIR_WORKFLOW / base + + +def get_package_path(package: str, repo_root: Path | None = None) -> Path | None: + """Get a package's source directory absolute path from config. + + Returns: + Absolute path to the package directory, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_packages + + packages = get_packages(repo_root) + if not packages or package not in packages: + return None + + info = packages[package] + if isinstance(info, dict): + rel_path = info.get("path", package) + else: + rel_path = str(info) + + return repo_root / rel_path + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + print(f"Repository root: {repo}") + print(f"Developer: {get_developer(repo)}") + print(f"Tasks dir: {get_tasks_dir(repo)}") + print(f"Workspace dir: {get_workspace_dir(repo)}") + print(f"Journal file: {get_active_journal_file(repo)}") + print(f"Current task: {get_current_task(repo)}") diff --git a/.trellis/scripts/common/safe_commit.py b/.trellis/scripts/common/safe_commit.py new file mode 100644 index 0000000000..4174191b9e --- /dev/null +++ b/.trellis/scripts/common/safe_commit.py @@ -0,0 +1,285 @@ +""" +Safe git-add helpers for Trellis-owned paths. + +Why this module exists +---------------------- +A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide +template / personal habit). When `add_session.py` and `task.py archive` ran +their auto-commit and `git add` failed with `ignored by .gitignore`, the AI +agent driving the workflow "fixed" it by retrying with +`git add -f .trellis/` — which fan-out-included every ignored subtree +(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`, +`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups. + +Design +------ +- Scripts only stage SPECIFIC product paths (journal files, index.md, the + current task dir, the archive dir). Never the whole `.trellis/` tree. +- If plain `git add <specific>` fails with "ignored by", DO NOT retry with + ``-f``. The presence of `.trellis/` in `.gitignore` is treated as user + intent ("keep .trellis/ local-only"). The script warns and skips the + auto-commit; users who want auto-staging can either fix their `.gitignore` + or set ``session_auto_commit: false`` and manage git themselves. +- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...`` + so any AI rereading the log doesn't reinvent the bug. + +History note: 0.5.10 introduced an automatic ``git add -f`` retry on the +specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the +user had gitignored violates user intent even when the path list is narrow. +The wider-grain forbidden command stays forbidden, and the narrow-grain auto +``-f`` is gone too. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .git import run_git +from .paths import ( + DIR_ARCHIVE, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + FILE_JOURNAL_PREFIX, + get_developer, +) + + +# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the +# warning to the user can show concrete subpaths to ignore individually +# instead of ignoring the whole `.trellis/` tree. +TRELLIS_IGNORED_SUBPATHS = ( + ".trellis/.backup-*", + ".trellis/worktrees/", + ".trellis/.template-hashes.json", + ".trellis/.runtime/", + ".trellis/.cache/", +) + + +def safe_trellis_paths_to_add(repo_root: Path) -> list[str]: + """Return the list of repo-relative paths the auto-commit should stage. + + Only includes paths that exist on disk so callers don't pass non-existent + arguments to git. The caller is responsible for `git diff --cached` + checking afterwards. + + Included: + - .trellis/workspace/<developer>/journal-*.md + - .trellis/workspace/<developer>/index.md + - .trellis/tasks/<task-dir>/ (every active task directory) + - .trellis/tasks/archive/ (whole archive subtree, if present) + + Excluded (intentionally — these must not be staged): + - .trellis/.backup-*, .trellis/worktrees/, + .trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/ + """ + paths: list[str] = [] + + # Workspace journal files + index.md + developer = get_developer(repo_root) + if developer: + ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer + if ws.is_dir(): + for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")): + if f.is_file(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}" + ) + index_md = ws / "index.md" + if index_md.is_file(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md" + ) + + # Active tasks: each direct child of tasks/ that is a directory and not + # the archive root. The archive subtree is added as a single path below. + tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS + if tasks_dir.is_dir(): + for child in sorted(tasks_dir.iterdir()): + if not child.is_dir(): + continue + if child.name == DIR_ARCHIVE: + continue + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") + + archive_dir = tasks_dir / DIR_ARCHIVE + if archive_dir.is_dir(): + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") + + return paths + + +def safe_archive_paths_to_add( + repo_root: Path, + task_name: str | None = None, + modified_children: list[str] | None = None, +) -> list[str]: + """Return paths to stage after `task.py archive`. + + Scoped to ONLY the paths the archive operation actually touched: + + - the archive subtree (where the freshly-moved task lives) + - the source task directory (for source-side deletes; caller pairs + this with `git rm --cached` since `git add` won't stage deletes + for a path that no longer exists in the working tree) + - any child task directories whose `task.json` was edited to drop + the archived parent (parent-children relationship update) + + This narrow scope avoids "scope creep" — dirty changes in OTHER + active task dirs (parallel-window edits) are NOT bundled into the + archive commit. Callers handle each kind of change in its own + commit boundary. + + Backwards-compat: with no arguments, the function walks the whole + `.trellis/tasks/` subtree the old way (active tasks + archive). New + callers should always pass `task_name`. + """ + paths: list[str] = [] + tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS + if not tasks_dir.is_dir(): + return paths + + archive_dir = tasks_dir / DIR_ARCHIVE + + if task_name is not None: + # Narrow scope — only paths that still exist on disk (so + # `git add` doesn't choke on the moved-away source). The caller + # handles the source-side deletes via `git rm --cached` + # explicitly. + if archive_dir.is_dir(): + paths.append( + f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}" + ) + for child_name in modified_children or []: + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}") + return paths + + # Legacy wide scope (no task_name): preserve old behavior so callers + # that have not been updated keep working. + if archive_dir.is_dir(): + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}") + for child in sorted(tasks_dir.iterdir()): + if not child.is_dir(): + continue + if child.name == DIR_ARCHIVE: + continue + paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}") + return paths + + +def _stderr_indicates_ignored(stderr: str) -> bool: + """git add error indicates the path is excluded by .gitignore.""" + if not stderr: + return False + lowered = stderr.lower() + return "ignored by" in lowered + + +def safe_git_add( + paths: list[str], repo_root: Path +) -> tuple[bool, bool, str]: + """Run `git add` on specific paths; never retry with -f. + + Returns ``(success, used_force, stderr)``. The ``used_force`` field is + kept for signature compatibility with the 0.5.10 implementation but is + always ``False`` — we never auto-force. + + Behavior: + - No paths passed → success, no force, empty stderr. + - Plain ``git add -- <paths>`` succeeds → return success. + - Plain fails (any reason — ignored or otherwise) → return failure with + the stderr. Callers should inspect the stderr (see + :func:`print_gitignore_warning`) and skip the auto-commit. + """ + if not paths: + return True, False, "" + + rc, _, err = run_git(["add", "--", *paths], cwd=repo_root) + if rc == 0: + return True, False, "" + return False, False, err + + +def print_gitignore_warning(paths: list[str]) -> None: + """Explain to the user (and any AI reading the log) what to do. + + CRITICAL: includes the negative example + ``Do NOT use `git add -f .trellis/``` — agents reading the warning are + known to invent that command, which fans out to ignored caches/backups. + """ + print( + "[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.", + file=sys.stderr, + ) + print( + "[WARN] Skipping auto-commit. The journal/task files were still written to disk;", + file=sys.stderr, + ) + print( + "[WARN] git was not touched.", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Trellis manages these specific paths and they should be tracked:", + file=sys.stderr, + ) + if paths: + for p in paths: + print(f"[WARN] {p}", file=sys.stderr) + else: + print( + "[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}", + file=sys.stderr, + ) + print( + "[WARN] .trellis/tasks/<task-dir>/", + file=sys.stderr, + ) + print( + "[WARN] .trellis/tasks/archive/", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Recommended: change your .gitignore from `.trellis/` to specific", + file=sys.stderr, + ) + print( + "[WARN] subpaths that should remain ignored, e.g.:", + file=sys.stderr, + ) + for sub in TRELLIS_IGNORED_SUBPATHS: + print(f"[WARN] {sub}", file=sys.stderr) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Or, if you intentionally keep .trellis/ local-only, set in", + file=sys.stderr, + ) + print( + "[WARN] .trellis/config.yaml:", + file=sys.stderr, + ) + print( + "[WARN] session_auto_commit: false", + file=sys.stderr, + ) + print( + "[WARN] so the scripts skip git entirely and you can review / commit", + file=sys.stderr, + ) + print( + "[WARN] manually with `git status` / `git add` / `git commit`.", + file=sys.stderr, + ) + print("[WARN]", file=sys.stderr) + print( + "[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,", + file=sys.stderr, + ) + print( + "[WARN] and runtime caches that should never be committed.", + file=sys.stderr, + ) diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py new file mode 100644 index 0000000000..5039f68bdd --- /dev/null +++ b/.trellis/scripts/common/session_context.py @@ -0,0 +1,821 @@ +#!/usr/bin/env python3 +""" +Session context generation (default + record modes). + +Provides: + get_context_json - JSON output for default mode + get_context_text - Text output for default mode + get_context_record_json - JSON for record mode + get_context_text_record - Text for record mode + output_json - Print JSON + output_text - Print text +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from pathlib import Path + +from .active_task import resolve_context_key +from .config import get_git_packages +from .git import run_git +from .packages_context import get_packages_section +from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress +from .paths import ( + DIR_SCRIPTS, + DIR_SPEC, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + count_lines, + get_active_journal_file, + get_current_task, + get_current_task_source, + get_developer, + get_repo_root, + get_tasks_dir, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + +_PACKAGE_NAME = "@mindfoldhq/trellis" +_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0 +_VERSION_RE = re.compile( + r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$" +) +_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b") +_POLYREPO_IGNORED_DIRS = { + "node_modules", + "target", + "dist", + "build", + "out", + "bin", + "obj", + "vendor", + "coverage", + "tmp", + "__pycache__", +} +_POLYREPO_SCAN_MAX_DEPTH = 2 + + +def _is_git_worktree(path: Path) -> bool: + """Return True when path is inside a Git worktree.""" + rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path) + return rc == 0 and out.strip().lower() == "true" + + +def _parse_recent_commits(log_output: str) -> list[dict]: + """Parse `git log --oneline` output into structured commit entries.""" + commits = [] + for line in log_output.splitlines(): + if not line.strip(): + continue + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + elif len(parts) == 1: + commits.append({"hash": parts[0], "message": ""}) + return commits + + +def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None: + """Collect Git status for one known repository directory.""" + if not (repo_dir / ".git").exists(): + return None + + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir) + changes = len([l for l in status_out.splitlines() if l.strip()]) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir) + + return { + "name": name, + "path": rel_path, + "branch": branch, + "isClean": changes == 0, + "uncommittedChanges": changes, + "recentCommits": _parse_recent_commits(log_out), + } + + +def _collect_root_git_info(repo_root: Path) -> dict: + """Collect root Git info without pretending a non-Git root is clean.""" + if not _is_git_worktree(repo_root): + return { + "isRepo": False, + "branch": "", + "isClean": False, + "uncommittedChanges": 0, + "recentCommits": [], + } + + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + + _, short_out, _ = run_git(["status", "--short"], cwd=repo_root) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + + return { + "isRepo": True, + "branch": branch, + "isClean": len(status_lines) == 0, + "uncommittedChanges": len(status_lines), + "statusShort": short_out.splitlines(), + "recentCommits": _parse_recent_commits(log_out), + } + + +def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]: + """Discover child Git repositories using the init-time polyrepo heuristic.""" + found: list[str] = [] + + def is_candidate_dir(path: Path) -> bool: + name = path.name + return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS + + def scan(rel_dir: Path, depth: int) -> None: + if depth >= _POLYREPO_SCAN_MAX_DEPTH: + return + abs_dir = repo_root / rel_dir + try: + children = sorted(abs_dir.iterdir(), key=lambda p: p.name) + except OSError: + return + + for child in children: + if not child.is_dir() or not is_candidate_dir(child): + continue + + child_rel = ( + rel_dir / child.name if rel_dir != Path(".") else Path(child.name) + ) + if (child / ".git").exists(): + found.append(child_rel.as_posix()) + continue + scan(child_rel, depth + 1) + + scan(Path("."), 0) + if len(found) < 2: + return [] + return [(path.replace("/", "_"), path) for path in sorted(found)] + + +def _collect_package_git_info( + repo_root: Path, + discover_unconfigured: bool = False, +) -> list[dict]: + """Collect Git status for independent package repositories. + + Packages marked with ``git: true`` in config.yaml are authoritative. + When the Trellis root is not a Git repo and no configured package repos are + available, optionally fall back to the bounded polyrepo child scan. + + Returns: + List of dicts with keys: name, path, branch, isClean, + uncommittedChanges, recentCommits. + Empty list if no git-repo packages are configured. + """ + git_pkgs = get_git_packages(repo_root) + result = [] + for pkg_name, pkg_path in git_pkgs.items(): + pkg_dir = repo_root / pkg_path + info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir) + if info is not None: + result.append(info) + + if result or not discover_unconfigured: + return result + + discovered = [] + for pkg_name, pkg_path in _discover_child_git_repos(repo_root): + info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path) + if info is not None: + discovered.append(info) + return discovered + + +def _append_root_git_context(lines: list[str], root_git_info: dict) -> None: + """Append root Git status without misleading non-Git roots.""" + lines.append("## GIT STATUS") + if not root_git_info["isRepo"]: + lines.append("Root is not a Git repository.") + lines.append("Run Git commands from the package repository paths listed below.") + else: + lines.append(f"Branch: {root_git_info['branch']}") + if root_git_info["isClean"]: + lines.append("Working directory: Clean") + else: + lines.append( + f"Working directory: {root_git_info['uncommittedChanges']} " + "uncommitted change(s)" + ) + lines.append("") + lines.append("Changes:") + for line in root_git_info.get("statusShort", [])[:10]: + lines.append(line) + lines.append("") + + lines.append("## RECENT COMMITS") + if not root_git_info["isRepo"]: + lines.append( + "Root has no Git commit history because it is not a Git repository." + ) + elif root_git_info["recentCommits"]: + for commit in root_git_info["recentCommits"]: + lines.append(f"{commit['hash']} {commit['message']}") + else: + lines.append("(no commits)") + lines.append("") + + +def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None: + """Append Git status and recent commits for package repositories.""" + for pkg in package_git_info: + lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})") + lines.append(f"Branch: {pkg['branch']}") + if pkg["isClean"]: + lines.append("Working directory: Clean") + else: + lines.append( + f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)" + ) + lines.append("") + lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})") + if pkg["recentCommits"]: + for commit in pkg["recentCommits"]: + lines.append(f"{commit['hash']} {commit['message']}") + else: + lines.append("(no commits)") + lines.append("") + + +def _read_project_version(repo_root: Path) -> str | None: + try: + version = (repo_root / DIR_WORKFLOW / ".version").read_text( + encoding="utf-8" + ).strip() + except OSError: + return None + return version or None + + +def _fetch_trellis_version_output() -> str | None: + try: + result = subprocess.run( + ["trellis", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_UPDATE_CHECK_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.SubprocessError, TimeoutError): + return None + + if result.returncode != 0: + return None + output = f"{result.stdout}\n{result.stderr}".strip() + return output or None + + +def _extract_available_update_version(output: str) -> str | None: + update_match = re.search( + r"Trellis update available:\s*" + r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)", + output, + ) + if update_match: + return update_match.group("latest").strip() + candidates = _VERSION_TOKEN_RE.findall(output) + return candidates[-1] if candidates else None + + +def _resolve_available_update_version() -> str | None: + output = _fetch_trellis_version_output() + if not output: + return None + return _extract_available_update_version(output) + + +def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None: + match = _VERSION_RE.match(version) + if not match: + return None + major, minor, patch, prerelease = match.groups() + numbers = (int(major), int(minor or "0"), int(patch or "0")) + prerelease_parts = tuple(prerelease.split(".")) if prerelease else None + return numbers, prerelease_parts + + +def _compare_prerelease( + left: tuple[str, ...] | None, + right: tuple[str, ...] | None, +) -> int: + if left is None and right is None: + return 0 + if left is None: + return 1 + if right is None: + return -1 + + for left_part, right_part in zip(left, right): + if left_part == right_part: + continue + left_numeric = left_part.isdigit() + right_numeric = right_part.isdigit() + if left_numeric and right_numeric: + left_int = int(left_part) + right_int = int(right_part) + return (left_int > right_int) - (left_int < right_int) + if left_numeric: + return -1 + if right_numeric: + return 1 + return (left_part > right_part) - (left_part < right_part) + + return (len(left) > len(right)) - (len(left) < len(right)) + + +def _compare_versions(left: str, right: str) -> int | None: + parsed_left = _parse_version(left) + parsed_right = _parse_version(right) + if parsed_left is None or parsed_right is None: + return None + + left_numbers, left_prerelease = parsed_left + right_numbers, right_prerelease = parsed_right + if left_numbers != right_numbers: + return (left_numbers > right_numbers) - (left_numbers < right_numbers) + return _compare_prerelease(left_prerelease, right_prerelease) + + +def _update_marker_path(repo_root: Path) -> Path: + context_key = resolve_context_key() + if not context_key: + terminal_key = os.environ.get("TERM_SESSION_ID", "").strip() + context_key = terminal_key or f"ppid-{os.getppid()}" + safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-") + if not safe_key: + safe_key = "session" + return ( + repo_root + / DIR_WORKFLOW + / ".runtime" + / f"update-check-{safe_key[:160]}.marker" + ) + + +def _mark_update_check_attempted(repo_root: Path) -> bool: + marker_path = _update_marker_path(repo_root) + if marker_path.exists(): + return False + try: + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.write_text("checked\n", encoding="utf-8") + except OSError: + pass + return True + + +def _get_update_hint(repo_root: Path) -> str | None: + marker_path = _update_marker_path(repo_root) + if marker_path.exists(): + return None + + current_version = _read_project_version(repo_root) + if not current_version: + return None + + latest_version = _resolve_available_update_version() + if not latest_version: + return None + + _mark_update_check_attempted(repo_root) + comparison = _compare_versions(current_version, latest_version) + if comparison is None or comparison >= 0: + return None + + return ( + f"Trellis update available: {current_version} -> {latest_version}, " + f"run npm install -g {_PACKAGE_NAME}@latest" + ) + + +# ============================================================================= +# JSON Output +# ============================================================================= + +def get_context_json(repo_root: Path | None = None) -> dict: + """Get context as a dictionary. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Context dictionary. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + journal_file = get_active_journal_file(repo_root) + + journal_lines = 0 + journal_relative = "" + if journal_file and developer: + journal_lines = count_lines(journal_file) + journal_relative = ( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + ) + + root_git_info = _collect_root_git_info(repo_root) + + # Tasks + tasks = [ + { + "dir": t.dir_name, + "name": t.name, + "status": t.status, + "children": list(t.children), + "parent": t.parent, + } + for t in iter_active_tasks(tasks_dir) + ] + + # Package git repos (independent sub-repositories) + pkg_git_info = _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ) + + result = { + "developer": developer or "", + "git": { + "isRepo": root_git_info["isRepo"], + "branch": root_git_info["branch"], + "isClean": root_git_info["isClean"], + "uncommittedChanges": root_git_info["uncommittedChanges"], + "recentCommits": root_git_info["recentCommits"], + }, + "tasks": { + "active": tasks, + "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", + }, + "journal": { + "file": journal_relative, + "lines": journal_lines, + "nearLimit": journal_lines > 1800, + }, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def output_json(repo_root: Path | None = None) -> None: + """Output context in JSON format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + context = get_context_json(repo_root) + print(json.dumps(context, indent=2, ensure_ascii=False)) + + +# ============================================================================= +# Text Output +# ============================================================================= + +def get_context_text(repo_root: Path | None = None) -> str: + """Get context as formatted text. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Formatted text output. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines = [] + lines.append("========================================") + lines.append("SESSION CONTEXT") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + + # Developer section + lines.append("## DEVELOPER") + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + lines.append(f"Name: {developer}") + lines.append("") + + root_git_info = _collect_root_git_info(repo_root) + _append_root_git_context(lines, root_git_info) + + # Package git repos — independent sub-repositories + _append_package_git_context( + lines, + _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ), + ) + + # Current task + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + source_type, context_key, _ = get_current_task_source(repo_root) + lines.append(f"Path: {current_task}") + lines.append( + f"Source: {source_type}" + (f":{context_key}" if context_key else "") + ) + + ct = load_task(current_task_dir) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}") + if ct.description: + lines.append(f"Description: {ct.description}") + + # Check for prd.md + prd_file = current_task_dir / "prd.md" + if prd_file.is_file(): + lines.append("") + lines.append("[!] This task has prd.md - read it for task details") + else: + lines.append("(none)") + lines.append("") + + # Active tasks + lines.append("## ACTIVE TASKS") + tasks_dir = get_tasks_dir(repo_root) + task_count = 0 + + # Collect all task data for hierarchy display + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + def _print_task_tree(name: str, indent: int = 0) -> None: + nonlocal task_count + t = all_tasks[name] + progress = children_progress(t.children, all_statuses) + prefix = " " * indent + lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}") + task_count += 1 + for child in t.children: + if child in all_tasks: + _print_task_tree(child, indent + 1) + + for dir_name in sorted(all_tasks.keys()): + if not all_tasks[dir_name].parent: + _print_task_tree(dir_name) + + if task_count == 0: + lines.append("(no active tasks)") + lines.append(f"Total: {task_count} active task(s)") + lines.append("") + + # My tasks + lines.append("## MY TASKS (Assigned to me)") + my_task_count = 0 + + for t in all_tasks.values(): + if t.assignee == developer and t.status != "done": + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no tasks assigned to you)") + lines.append("") + + # Journal file + lines.append("## JOURNAL FILE") + journal_file = get_active_journal_file(repo_root) + if journal_file: + journal_lines = count_lines(journal_file) + relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + lines.append(f"Active file: {relative}") + lines.append(f"Line count: {journal_lines} / 2000") + if journal_lines > 1800: + lines.append("[!] WARNING: Approaching 2000 line limit!") + else: + lines.append("No journal file found") + lines.append("") + + # Packages + packages_text = get_packages_section(repo_root) + if packages_text: + lines.append(packages_text) + lines.append("") + + # Paths + lines.append("## PATHS") + lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +# ============================================================================= +# Record Mode +# ============================================================================= + +def get_context_record_json(repo_root: Path | None = None) -> dict: + """Get record-mode context as a dictionary. + + Focused on: my active tasks, git status, current task. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + + root_git_info = _collect_root_git_info(repo_root) + + # My tasks (single pass — collect statuses and filter by assignee) + all_tasks_list = list(iter_active_tasks(tasks_dir)) + all_statuses = {t.dir_name: t.status for t in all_tasks_list} + + my_tasks = [] + for t in all_tasks_list: + if t.assignee == developer: + done = sum( + 1 for c in t.children + if all_statuses.get(c) in ("completed", "done") + ) + my_tasks.append({ + "dir": t.dir_name, + "title": t.title, + "status": t.status, + "priority": t.priority, + "children": list(t.children), + "childrenDone": done, + "parent": t.parent, + "meta": t.meta, + }) + + # Current task + current_task_info = None + current_task = get_current_task(repo_root) + if current_task: + source_type, context_key, _ = get_current_task_source(repo_root) + ct = load_task(repo_root / current_task) + if ct: + current_task_info = { + "path": current_task, + "name": ct.name, + "status": ct.status, + "source": source_type, + "contextKey": context_key, + } + + # Package git repos + pkg_git_info = _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ) + + result = { + "developer": developer or "", + "git": { + "isRepo": root_git_info["isRepo"], + "branch": root_git_info["branch"], + "isClean": root_git_info["isClean"], + "uncommittedChanges": root_git_info["uncommittedChanges"], + "recentCommits": root_git_info["recentCommits"], + }, + "myTasks": my_tasks, + "currentTask": current_task_info, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def get_context_text_record(repo_root: Path | None = None) -> str: + """Get context as formatted text for record-session mode. + + Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), + then GIT STATUS, RECENT COMMITS, CURRENT TASK. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines: list[str] = [] + lines.append("========================================") + lines.append("SESSION CONTEXT (RECORD MODE)") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + # MY ACTIVE TASKS — first and prominent + lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") + lines.append("[!] Review whether any should be archived before recording this session.") + lines.append("") + + tasks_dir = get_tasks_dir(repo_root) + my_task_count = 0 + + # Single pass — collect all tasks and filter by assignee + all_statuses = get_all_statuses(tasks_dir) + + for t in iter_active_tasks(tasks_dir): + if t.assignee == developer: + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no active tasks assigned to you)") + lines.append("") + + root_git_info = _collect_root_git_info(repo_root) + _append_root_git_context(lines, root_git_info) + + # Package git repos — independent sub-repositories + _append_package_git_context( + lines, + _collect_package_git_info( + repo_root, + discover_unconfigured=not root_git_info["isRepo"], + ), + ) + + # CURRENT TASK + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + source_type, context_key, _ = get_current_task_source(repo_root) + lines.append(f"Path: {current_task}") + lines.append( + f"Source: {source_type}" + (f":{context_key}" if context_key else "") + ) + ct = load_task(repo_root / current_task) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + else: + lines.append("(none)") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +def output_text(repo_root: Path | None = None) -> None: + """Output context in text format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + if repo_root is None: + repo_root = get_repo_root() + update_hint = _get_update_hint(repo_root) + if update_hint: + print(update_hint) + print("") + print(get_context_text(repo_root)) diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py new file mode 100644 index 0000000000..fa8841201a --- /dev/null +++ b/.trellis/scripts/common/task_context.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Task JSONL context management. + +Provides: + cmd_add_context - Add entry to JSONL context file + cmd_validate - Validate JSONL context files + cmd_list_context - List JSONL context entries + +Note: + ``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files + are now seeded at ``task.py create`` time with a self-describing + ``_example`` line; the AI agent curates real entries during Phase 1.3 of + the workflow. See ``.trellis/workflow.md`` Phase 1.3 for the current + instructions. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .log import Colors, colored +from .paths import get_repo_root +from .task_utils import resolve_task_dir + + +# ============================================================================= +# Command: add-context +# ============================================================================= + +def cmd_add_context(args: argparse.Namespace) -> int: + """Add entry to JSONL context file.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + jsonl_name = args.file + path = args.path + reason = args.reason or "Added manually" + + if not target_dir.is_dir(): + print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) + return 1 + + # Support shorthand + if not jsonl_name.endswith(".jsonl"): + jsonl_name = f"{jsonl_name}.jsonl" + + jsonl_file = target_dir / jsonl_name + full_path = repo_root / path + + entry_type = "file" + if full_path.is_dir(): + entry_type = "directory" + if not path.endswith("/"): + path = f"{path}/" + elif not full_path.is_file(): + print(colored(f"Error: Path not found: {path}", Colors.RED)) + return 1 + + # Check if already exists + if jsonl_file.is_file(): + content = jsonl_file.read_text(encoding="utf-8") + if f'"{path}"' in content: + print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) + return 0 + + # Add entry + entry: dict + if entry_type == "directory": + entry = {"file": path, "type": "directory", "reason": reason} + else: + entry = {"file": path, "reason": reason} + + with jsonl_file.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: validate +# ============================================================================= + +def cmd_validate(args: argparse.Namespace) -> int: + """Validate JSONL context files.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Validating Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print() + + total_errors = 0 + for jsonl_name in ["implement.jsonl", "check.jsonl"]: + jsonl_file = target_dir / jsonl_name + errors = _validate_jsonl(jsonl_file, repo_root) + total_errors += errors + + print() + if total_errors == 0: + print(colored("✓ All validations passed", Colors.GREEN)) + return 0 + else: + print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) + return 1 + + +def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: + """Validate a single JSONL file. + + Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are + skipped silently; they are self-describing comments, not real entries. + """ + file_name = jsonl_file.name + errors = 0 + + if not jsonl_file.is_file(): + print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") + return 0 + + line_num = 0 + real_entries = 0 + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + line_num += 1 + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") + errors += 1 + continue + + file_path = data.get("file") + entry_type = data.get("type", "file") + + if not file_path: + # Seed / comment row — skip silently + continue + + real_entries += 1 + full_path = repo_root / file_path + if entry_type == "directory": + if not full_path.is_dir(): + print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") + errors += 1 + else: + if not full_path.is_file(): + print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") + errors += 1 + + if errors == 0: + print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}") + else: + print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") + + return errors + + +# ============================================================================= +# Command: list-context +# ============================================================================= + +def cmd_list_context(args: argparse.Namespace) -> int: + """List JSONL context entries.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Context Files ===", Colors.BLUE)) + print() + + for jsonl_name in ["implement.jsonl", "check.jsonl"]: + jsonl_file = target_dir / jsonl_name + if not jsonl_file.is_file(): + continue + + print(colored(f"[{jsonl_name}]", Colors.CYAN)) + + count = 0 + seed_only = True + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + file_path = data.get("file") + if not file_path: + # Seed / comment row — don't count as a real entry + continue + seed_only = False + + count += 1 + entry_type = data.get("type", "file") + reason = data.get("reason", "-") + + if entry_type == "directory": + print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") + else: + print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") + print(f" {colored('→', Colors.YELLOW)} {reason}") + + if seed_only: + print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}") + + print() + + return 0 diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py new file mode 100644 index 0000000000..f7485e2e5e --- /dev/null +++ b/.trellis/scripts/common/task_queue.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Task queue utility functions. + +Provides: + list_tasks_by_status - List tasks by status + list_pending_tasks - List tasks with pending status + list_tasks_by_assignee - List tasks by assignee + list_my_tasks - List tasks assigned to current developer + get_task_stats - Get P0/P1/P2/P3 counts +""" + +from __future__ import annotations + +from pathlib import Path + +from .paths import ( + get_repo_root, + get_developer, + get_tasks_dir, +) +from .tasks import iter_active_tasks + + +# ============================================================================= +# Internal helper +# ============================================================================= + +def _task_to_dict(t) -> dict: + """Convert TaskInfo to the dict format callers expect.""" + return { + "priority": t.priority, + "id": t.raw.get("id", ""), + "title": t.title, + "status": t.status, + "assignee": t.assignee or "-", + "dir": t.dir_name, + "children": list(t.children), + "parent": t.parent, + } + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def list_tasks_by_status( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks by status. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts with keys: priority, id, title, status, assignee. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + for t in iter_active_tasks(tasks_dir): + if filter_status and t.status != filter_status: + continue + results.append(_task_to_dict(t)) + + return results + + +def list_pending_tasks(repo_root: Path | None = None) -> list[dict]: + """List pending tasks. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + return list_tasks_by_status("planning", repo_root) + + +def list_tasks_by_assignee( + assignee: str, + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to a specific developer. + + Args: + assignee: Developer name. + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + results = [] + + for t in iter_active_tasks(tasks_dir): + if (t.assignee or "-") != assignee: + continue + if filter_status and t.status != filter_status: + continue + results.append(_task_to_dict(t)) + + return results + + +def list_my_tasks( + filter_status: str | None = None, + repo_root: Path | None = None +) -> list[dict]: + """List tasks assigned to current developer. + + Args: + filter_status: Optional status filter. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + List of task info dicts. + + Raises: + ValueError: If developer not set. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + if not developer: + raise ValueError("Developer not set") + + return list_tasks_by_assignee(developer, filter_status, repo_root) + + +def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: + """Get task statistics. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with keys: P0, P1, P2, P3, Total. + """ + if repo_root is None: + repo_root = get_repo_root() + + tasks_dir = get_tasks_dir(repo_root) + stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} + + for t in iter_active_tasks(tasks_dir): + if t.priority in stats: + stats[t.priority] += 1 + stats["Total"] += 1 + + return stats + + +def format_task_stats(stats: dict[str, int]) -> str: + """Format task stats as string. + + Args: + stats: Stats dict from get_task_stats. + + Returns: + Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3". + """ + return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}" + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + stats = get_task_stats() + print(format_task_stats(stats)) + print() + print("Pending tasks:") + for task in list_pending_tasks(): + print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}") diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py new file mode 100644 index 0000000000..71d1bf12f7 --- /dev/null +++ b/.trellis/scripts/common/task_store.py @@ -0,0 +1,697 @@ +#!/usr/bin/env python3 +""" +Task CRUD operations. + +Provides: + ensure_tasks_dir - Ensure tasks directory exists + cmd_create - Create a new task + cmd_archive - Archive completed task + cmd_set_branch - Set git branch for task + cmd_set_base_branch - Set PR target branch + cmd_set_scope - Set scope for PR title + cmd_add_subtask - Link child task to parent + cmd_remove_subtask - Unlink child task from parent +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime +from pathlib import Path + +from .config import ( + get_packages, + get_session_auto_commit, + is_monorepo, + resolve_package, + validate_package, +) +from .git import run_git +from .io import read_json, write_json +from .log import Colors, colored +from .paths import ( + DIR_ARCHIVE, + DIR_TASKS, + DIR_WORKFLOW, + FILE_TASK_JSON, + generate_task_date_prefix, + get_developer, + get_repo_root, + get_tasks_dir, +) +from .safe_commit import ( + print_gitignore_warning, + safe_archive_paths_to_add, + safe_git_add, +) +from .task_utils import ( + archive_task_complete, + find_task_by_name, + resolve_task_dir, + run_task_hooks, +) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _slugify(title: str) -> str: + """Convert title to slug (only works with ASCII).""" + result = title.lower() + result = re.sub(r"[^a-z0-9]", "-", result) + result = re.sub(r"-+", "-", result) + result = result.strip("-") + return result + + +def ensure_tasks_dir(repo_root: Path) -> Path: + """Ensure tasks directory exists.""" + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + + if not tasks_dir.exists(): + tasks_dir.mkdir(parents=True) + print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) + + if not archive_dir.exists(): + archive_dir.mkdir(parents=True) + + return tasks_dir + + +def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None: + """Find an archived task directory with the exact active-task dir name.""" + archive_dir = tasks_dir / DIR_ARCHIVE + if not archive_dir.is_dir(): + return None + + for month_dir in sorted(archive_dir.iterdir()): + if not month_dir.is_dir(): + continue + candidate = month_dir / dir_name + if candidate.is_dir(): + return candidate + + return None + + +def _repo_relative_path(path: Path, repo_root: Path) -> str: + """Format a path relative to the repo root when possible.""" + try: + return path.relative_to(repo_root).as_posix() + except ValueError: + return str(path) + + +# ============================================================================= +# Sub-agent platform detection + JSONL seeding +# ============================================================================= + +# Config directories of platforms that consume implement.jsonl / check.jsonl. +# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the +# platforms listed in workflow.md's "agent-capable" Skill Routing block +# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity / +# Windsurf are NOT in this list: they do not consume JSONL. +_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = ( + ".claude", + ".cursor", + ".codex", + ".kiro", + ".gemini", + ".opencode", + ".qoder", + ".codebuddy", + ".factory", # Factory Droid + ".github/copilot", + ".pi", # Pi Agent +) + +_SEED_EXAMPLE = ( + "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. " + "Put spec/research files only — no code paths. " + "Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. " + "Delete this line once real entries are added." +) + + +def _has_subagent_platform(repo_root: Path) -> bool: + """Return True if any sub-agent-capable platform is configured. + + Detected by probing well-known config directories at the repo root. Used + only to decide whether ``task.py create`` should seed empty + ``implement.jsonl`` / ``check.jsonl`` files. + """ + for config_dir in _SUBAGENT_CONFIG_DIRS: + if (repo_root / config_dir).is_dir(): + return True + return False + + +def _write_seed_jsonl(path: Path) -> None: + """Write a one-line seed JSONL file with a self-describing ``_example``. + + The seed row has no ``file`` field, so downstream consumers (hooks + + preludes) that iterate entries via ``item.get("file")`` naturally skip + it. The row exists purely as an in-file prompt for the AI curator. + """ + seed = {"_example": _SEED_EXAMPLE} + path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8") + + +# ============================================================================= +# Command: create +# ============================================================================= + +def cmd_create(args: argparse.Namespace) -> int: + """Create a new task.""" + repo_root = get_repo_root() + + if not args.title: + print(colored("Error: title is required", Colors.RED), file=sys.stderr) + return 1 + + # Validate --package (CLI source: fail-fast) + package: str | None = getattr(args, "package", None) + if not is_monorepo(repo_root): + # Single-repo: ignore --package, no package prefix + if package: + print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) + package = None + elif package: + if not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr) + return 1 + else: + # Inferred: default_package → None (no task.json yet for create) + package = resolve_package(repo_root=repo_root) + + # Default assignee to current developer + assignee = args.assignee + if not assignee: + assignee = get_developer(repo_root) + if not assignee: + print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) + return 1 + + ensure_tasks_dir(repo_root) + + # Get current developer as creator + creator = get_developer(repo_root) or assignee + + # Generate slug if not provided + slug = args.slug or _slugify(args.title) + if not slug: + print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) + return 1 + + # Create task directory with MM-DD-slug format + tasks_dir = get_tasks_dir(repo_root) + date_prefix = generate_task_date_prefix() + dir_name = f"{date_prefix}-{slug}" + task_dir = tasks_dir / dir_name + task_json_path = task_dir / FILE_TASK_JSON + + archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name) + if archived_task_dir: + print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr) + print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr) + print("Use a new slug if you intend to create a new task.", file=sys.stderr) + return 1 + + if task_dir.exists(): + print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) + else: + task_dir.mkdir(parents=True) + + today = datetime.now().strftime("%Y-%m-%d") + + # Record current branch as base_branch (PR target) + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + current_branch = branch_out.strip() or "main" + + task_data = { + "id": slug, + "name": slug, + "title": args.title, + "description": args.description or "", + "status": "planning", + "dev_type": None, + "scope": None, + "package": package, + "priority": args.priority, + "creator": creator, + "assignee": assignee, + "createdAt": today, + "completedAt": None, + "branch": None, + "base_branch": current_branch, + "worktree_path": None, + "commit": None, + "pr_url": None, + "subtasks": [], + "children": [], + "parent": None, + "relatedFiles": [], + "notes": "", + "meta": {}, + } + + write_json(task_json_path, task_data) + + # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms. + # Agent curates real entries in Phase 1.3 (see .trellis/workflow.md). + # Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they + # load specs via the trellis-before-dev skill instead of JSONL. + seeded_jsonl = False + if _has_subagent_platform(repo_root): + for jsonl_name in ("implement.jsonl", "check.jsonl"): + jsonl_path = task_dir / jsonl_name + if not jsonl_path.exists(): + _write_seed_jsonl(jsonl_path) + seeded_jsonl = True + + # Handle --parent: establish bidirectional link + if args.parent: + parent_dir = resolve_task_dir(args.parent, repo_root) + parent_json_path = parent_dir / FILE_TASK_JSON + if not parent_json_path.is_file(): + print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) + else: + parent_data = read_json(parent_json_path) + if parent_data: + # Add child to parent's children list + parent_children = parent_data.get("children", []) + if dir_name not in parent_children: + parent_children.append(dir_name) + parent_data["children"] = parent_children + write_json(parent_json_path, parent_data) + + # Set parent in child's task.json + task_data["parent"] = parent_dir.name + write_json(task_json_path, task_data) + + print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) + + # Auto-activate the new task so the per-turn breadcrumb fires planning + # state. Best-effort: gracefully degrade if no session identity (CLI run + # outside an AI session) — the task is still created, the user can run + # task.py start later. Pointer is session-scoped so this never affects + # other AI sessions. + try: + from .active_task import resolve_context_key, set_active_task + if resolve_context_key(): + try: + rel_dir = task_dir.relative_to(repo_root).as_posix() + except ValueError: + rel_dir = str(task_dir) + set_active_task(rel_dir, repo_root) + except Exception: + pass + + print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) + print("", file=sys.stderr) + print(colored("Next steps:", Colors.BLUE), file=sys.stderr) + print(" 1. Create prd.md with requirements", file=sys.stderr) + if seeded_jsonl: + print( + " 2. Curate implement.jsonl / check.jsonl (spec + research files only — " + "see .trellis/workflow.md Phase 1.3)", + file=sys.stderr, + ) + print(" 3. Run: python task.py start <dir>", file=sys.stderr) + else: + print(" 2. Run: python task.py start <dir>", file=sys.stderr) + print("", file=sys.stderr) + + # Output relative path for script chaining + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") + + run_task_hooks("after_create", task_json_path, repo_root) + return 0 + + +# ============================================================================= +# Command: archive +# ============================================================================= + +def cmd_archive(args: argparse.Namespace) -> int: + """Archive completed task.""" + repo_root = get_repo_root() + task_name = args.name + + if not task_name: + print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) + return 1 + + tasks_dir = get_tasks_dir(repo_root) + + # Resolve task directory (supports task name, relative path, or absolute path) + task_dir = resolve_task_dir(task_name, repo_root) + + if not task_dir or not task_dir.is_dir(): + print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) + print("Active tasks:", file=sys.stderr) + # Import lazily to avoid circular dependency + from .tasks import iter_active_tasks + for t in iter_active_tasks(tasks_dir): + print(f" - {t.dir_name}/", file=sys.stderr) + return 1 + + dir_name = task_dir.name + task_json_path = task_dir / FILE_TASK_JSON + + # Update status before archiving + today = datetime.now().strftime("%Y-%m-%d") + # Names of child task dirs whose task.json gets modified below; passed + # into safe_archive_paths_to_add so they're staged in this commit. + modified_children: list[str] = [] + if task_json_path.is_file(): + data = read_json(task_json_path) + if data: + data["status"] = "completed" + data["completedAt"] = today + write_json(task_json_path, data) + + # Handle subtask relationships on archive. + # Keep this task in its parent's children list so progress + # counters (children_progress) stay consistent — children + # missing from the active set are treated as completed. + task_children = data.get("children", []) + + # If this is a parent, clear parent field in all children + if task_children: + for child_name in task_children: + child_dir_path = find_task_by_name(child_name, tasks_dir) + if child_dir_path: + child_json = child_dir_path / FILE_TASK_JSON + if child_json.is_file(): + child_data = read_json(child_json) + if child_data: + child_data["parent"] = None + write_json(child_json, child_data) + modified_children.append(child_dir_path.name) + + # Clear any session that still points at this task before the path moves. + from .active_task import clear_task_from_sessions + clear_task_from_sessions(str(task_dir), repo_root) + + # Archive + result = archive_task_complete(task_dir, repo_root) + if "archived_to" in result: + archive_dest = Path(result["archived_to"]) + year_month = archive_dest.parent.name + print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) + + # Auto-commit unless --no-commit + if not getattr(args, "no_commit", False): + _auto_commit_archive(dir_name, repo_root, modified_children) + + # Return the archive path + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") + + # Run hooks with the archived path + archived_json = archive_dest / FILE_TASK_JSON + run_task_hooks("after_archive", archived_json, repo_root) + return 0 + + return 1 + + +def _auto_commit_archive( + task_name: str, + repo_root: Path, + modified_children: list[str] | None = None, +) -> None: + """Stage Trellis-owned task paths and commit after archive. + + Scoped narrowly to the archived task's source + destination paths + plus any child task dirs whose ``task.json`` was edited (parent → + children relationship update). Dirty changes in OTHER active task + dirs are NOT bundled into the archive commit. + + If ``.gitignore`` blocks the paths, we warn + skip — we do NOT + retry with ``git add -f``. The warning explicitly forbids + ``git add -f .trellis/`` (which would fan out to caches/backups) + and points users at ``session_auto_commit: false``. + + Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when + set to ``false``, this function returns immediately without + touching git (the archive directory move on disk is unaffected). + """ + if not get_session_auto_commit(repo_root): + print( + "[OK] session_auto_commit: false — skipping git stage/commit.", + file=sys.stderr, + ) + return + + paths = safe_archive_paths_to_add( + repo_root, task_name=task_name, modified_children=modified_children + ) + if not paths: + print("[OK] No task changes to commit.", file=sys.stderr) + return + + success, _, err = safe_git_add(paths, repo_root) + if not success: + if err and "ignored by" in err.lower(): + print_gitignore_warning(paths) + else: + print( + f"[WARN] git add failed: {err.strip() if err else 'unknown error'}", + file=sys.stderr, + ) + return + + # Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses + # `git add` (no -A) which only stages additions/modifications. The + # source task directory was moved away by `shutil.move`, so its files + # need an explicit `git rm --cached` to stage the deletions in this + # same commit — otherwise they sit as uncommitted "phantom deletes" + # against HEAD until something later picks them up. + # + # `--ignore-unmatch` makes this a no-op when the task was never tracked + # (e.g. archiving a task that lived only in working tree). + source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}" + run_git( + ["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel], + cwd=repo_root, + ) + + rc, _, _ = run_git( + ["diff", "--cached", "--quiet", "--", *paths, source_rel], + cwd=repo_root, + ) + if rc == 0: + print("[OK] No task changes to commit.", file=sys.stderr) + return + + commit_msg = f"chore(task): archive {task_name}" + rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root) + if rc == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + else: + print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) + + +# ============================================================================= +# Command: add-subtask +# ============================================================================= + +def cmd_add_subtask(args: argparse.Namespace) -> int: + """Link a child task to a parent task.""" + repo_root = get_repo_root() + + parent_dir = resolve_task_dir(args.parent_dir, repo_root) + child_dir = resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = read_json(parent_json_path) + child_data = read_json(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Check if child already has a parent + existing_parent = child_data.get("parent") + if existing_parent: + print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) + return 1 + + # Add child to parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name not in parent_children: + parent_children.append(child_dir_name) + parent_data["children"] = parent_children + + # Set parent in child's task.json + child_data["parent"] = parent_dir.name + + # Write both + write_json(parent_json_path, parent_data) + write_json(child_json_path, child_data) + + print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: remove-subtask +# ============================================================================= + +def cmd_remove_subtask(args: argparse.Namespace) -> int: + """Unlink a child task from a parent task.""" + repo_root = get_repo_root() + + parent_dir = resolve_task_dir(args.parent_dir, repo_root) + child_dir = resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = read_json(parent_json_path) + child_data = read_json(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Remove child from parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name in parent_children: + parent_children.remove(child_dir_name) + parent_data["children"] = parent_children + + # Clear parent in child's task.json + child_data["parent"] = None + + # Write both + write_json(parent_json_path, parent_data) + write_json(child_json_path, child_data) + + print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: set-branch +# ============================================================================= + +def cmd_set_branch(args: argparse.Namespace) -> int: + """Set git branch for task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + branch = args.branch + + if not branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python task.py set-branch <task-dir> <branch-name>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["branch"] = branch + write_json(task_json, data) + + print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: set-base-branch +# ============================================================================= + +def cmd_set_base_branch(args: argparse.Namespace) -> int: + """Set the base branch (PR target) for task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + base_branch = args.base_branch + + if not base_branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python task.py set-base-branch <task-dir> <base-branch>") + print("Example: python task.py set-base-branch <dir> develop") + print() + print("This sets the target branch for PR (the branch your feature will merge into).") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["base_branch"] = base_branch + write_json(task_json, data) + + print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) + print(f" PR will target: {base_branch}") + return 0 + + +# ============================================================================= +# Command: set-scope +# ============================================================================= + +def cmd_set_scope(args: argparse.Namespace) -> int: + """Set scope for PR title.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + scope = args.scope + + if not scope: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python task.py set-scope <task-dir> <scope>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["scope"] = scope + write_json(task_json, data) + + print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) + return 0 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py new file mode 100644 index 0000000000..62c215e3d8 --- /dev/null +++ b/.trellis/scripts/common/task_utils.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +Task utility functions. + +Provides: + is_safe_task_path - Validate task path is safe to operate on + find_task_by_name - Find task directory by name + resolve_task_dir - Resolve task directory from name, relative, or absolute path + archive_task_dir - Archive task to monthly directory + run_task_hooks - Run lifecycle hooks for task events +""" + +from __future__ import annotations + +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from .paths import get_repo_root, get_tasks_dir + + +# ============================================================================= +# Path Safety +# ============================================================================= + +def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: + """Check if a relative task path is safe to operate on. + + Args: + task_path: Task path (relative to repo_root). + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + True if safe, False if dangerous. + """ + if repo_root is None: + repo_root = get_repo_root() + + normalized = task_path.replace("\\", "/") + + # Check empty or null + if not normalized or normalized == "null": + print("Error: empty or null task path", file=sys.stderr) + return False + + # Reject absolute paths + if Path(task_path).is_absolute(): + print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) + return False + + # Reject ".", "..", paths starting with "./" or "../", or containing ".." + if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized: + print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) + return False + + # Final check: ensure resolved path is not the repo root + abs_path = repo_root / Path(normalized) + if abs_path.exists(): + try: + resolved = abs_path.resolve() + root_resolved = repo_root.resolve() + if resolved == root_resolved: + print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) + return False + except (OSError, IOError): + pass + + return True + + +# ============================================================================= +# Task Lookup +# ============================================================================= + +def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: + """Find task directory by name (exact or suffix match). + + Args: + task_name: Task name to find. + tasks_dir: Tasks directory path. + + Returns: + Absolute path to task directory, or None if not found. + """ + if not task_name or not tasks_dir or not tasks_dir.is_dir(): + return None + + # Try exact match first + exact_match = tasks_dir / task_name + if exact_match.is_dir(): + return exact_match + + # Try suffix match (e.g., "my-task" matches "01-21-my-task") + for d in tasks_dir.iterdir(): + if d.is_dir() and d.name.endswith(f"-{task_name}"): + return d + + return None + + +# ============================================================================= +# Archive Operations +# ============================================================================= + +def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None: + """Archive a task directory to archive/{YYYY-MM}/. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Path to archived directory, or None on error. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return None + + # Get tasks directory (parent of the task) + tasks_dir = task_dir_abs.parent + archive_dir = tasks_dir / "archive" + year_month = datetime.now().strftime("%Y-%m") + month_dir = archive_dir / year_month + + # Create archive directory + try: + month_dir.mkdir(parents=True, exist_ok=True) + except (OSError, IOError) as e: + print(f"Error: Failed to create archive directory: {e}", file=sys.stderr) + return None + + # Move task to archive + task_name = task_dir_abs.name + dest = month_dir / task_name + + try: + shutil.move(str(task_dir_abs), str(dest)) + except (OSError, IOError, shutil.Error) as e: + print(f"Error: Failed to move task to archive: {e}", file=sys.stderr) + return None + + return dest + + +def archive_task_complete( + task_dir_abs: Path, + repo_root: Path | None = None +) -> dict[str, str]: + """Complete archive workflow: archive directory. + + Args: + task_dir_abs: Absolute path to task directory. + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Dict with archive result info. + """ + if not task_dir_abs.is_dir(): + print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) + return {} + + archive_dest = archive_task_dir(task_dir_abs, repo_root) + if archive_dest: + return {"archived_to": str(archive_dest)} + + return {} + + +# ============================================================================= +# Task Directory Resolution +# ============================================================================= + +def resolve_task_dir(target_dir: str, repo_root: Path) -> Path: + """Resolve task directory to absolute path. + + Supports: + - Absolute path: /path/to/task + - Relative path: .trellis/tasks/01-31-my-task + - Task name: my-task (uses find_task_by_name for lookup) + + Args: + target_dir: Task directory specification. + repo_root: Repository root path. + + Returns: + Resolved absolute path. + """ + if not target_dir: + return Path() + + normalized = target_dir.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + # Absolute path + if Path(target_dir).is_absolute(): + return Path(target_dir) + + # Relative path (contains path separator or starts with .trellis) + if "/" in normalized or normalized.startswith(".trellis"): + return repo_root / Path(normalized) + + # Task name - try to find in tasks directory + tasks_dir = get_tasks_dir(repo_root) + found = find_task_by_name(target_dir, tasks_dir) + if found: + return found + + # Fallback to treating as relative path + return repo_root / Path(normalized) + + +# ============================================================================= +# Lifecycle Hooks +# ============================================================================= + +def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: + """Run lifecycle hooks for a task event. + + Args: + event: Event name (e.g. "after_create"). + task_json_path: Absolute path to the task's task.json. + repo_root: Repository root for cwd and config lookup. + """ + import os + import subprocess + + from .config import get_hooks + from .log import Colors, colored + + commands = get_hooks(event, repo_root) + if not commands: + return + + env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} + + for cmd in commands: + try: + result = subprocess.run( + cmd, + shell=True, + cwd=repo_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print( + colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), + file=sys.stderr, + ) + if result.stderr.strip(): + print(f" {result.stderr.strip()}", file=sys.stderr) + except Exception as e: + print( + colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), + file=sys.stderr, + ) + + +# ============================================================================= +# Main Entry (for testing) +# ============================================================================= + +if __name__ == "__main__": + repo = get_repo_root() + tasks = get_tasks_dir(repo) + + print(f"Tasks dir: {tasks}") + print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") + print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}") diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py new file mode 100644 index 0000000000..7b44094ca0 --- /dev/null +++ b/.trellis/scripts/common/tasks.py @@ -0,0 +1,112 @@ +""" +Task data access layer. + +Single source of truth for loading and iterating task directories. +Replaces scattered task.json parsing across 9+ files. + +Provides: + load_task — Load a single task by directory path + iter_active_tasks — Iterate all non-archived tasks (sorted) + get_all_statuses — Get {dir_name: status} map for children progress +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +from .io import read_json +from .paths import FILE_TASK_JSON +from .types import TaskInfo + + +def load_task(task_dir: Path) -> TaskInfo | None: + """Load task from a directory containing task.json. + + Args: + task_dir: Absolute path to the task directory. + + Returns: + TaskInfo if task.json exists and is valid, None otherwise. + """ + task_json = task_dir / FILE_TASK_JSON + if not task_json.is_file(): + return None + + data = read_json(task_json) + if not data: + return None + + return TaskInfo( + dir_name=task_dir.name, + directory=task_dir, + title=data.get("title") or data.get("name") or "unknown", + status=data.get("status", "unknown"), + assignee=data.get("assignee", ""), + priority=data.get("priority", "P2"), + children=tuple(data.get("children", [])), + parent=data.get("parent"), + package=data.get("package"), + raw=data, + ) + + +def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]: + """Iterate all active (non-archived) tasks, sorted by directory name. + + Skips the "archive" directory and directories without valid task.json. + + Args: + tasks_dir: Path to the tasks directory. + + Yields: + TaskInfo for each valid task. + """ + if not tasks_dir.is_dir(): + return + + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + info = load_task(d) + if info is not None: + yield info + + +def get_all_statuses(tasks_dir: Path) -> dict[str, str]: + """Get a {dir_name: status} mapping for all active tasks. + + Useful for computing children progress without loading full TaskInfo. + + Args: + tasks_dir: Path to the tasks directory. + + Returns: + Dict mapping directory names to status strings. + """ + return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)} + + +def children_progress( + children: tuple[str, ...] | list[str], + all_statuses: dict[str, str], +) -> str: + """Format children progress string like " [2/3 done]". + + Args: + children: List of child directory names. + all_statuses: Status map from get_all_statuses(). + + Returns: + Formatted string, or "" if no children. + """ + if not children: + return "" + # A child missing from active statuses has been archived (cmd_archive + # sets status=completed before moving the dir). Count it as done so + # parent progress doesn't regress when children are archived. + done = sum( + 1 for c in children + if c not in all_statuses or all_statuses.get(c) in ("completed", "done") + ) + return f" [{done}/{len(children)} done]" diff --git a/.trellis/scripts/common/trellis_config.py b/.trellis/scripts/common/trellis_config.py new file mode 100644 index 0000000000..5dbec7a0dd --- /dev/null +++ b/.trellis/scripts/common/trellis_config.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Standalone reader for .trellis/config.yaml. + +Mirrors a minimal subset of common.config so callers (hooks, workflow_phase) +can read configuration without importing the full task/repo helpers. Returns +an empty dict on missing/malformed files so callers stay simple. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + + +CONFIG_REL_PATH = ".trellis/config.yaml" + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + return value[1:-1] + return value + + +def _strip_inline_comment(value: str) -> str: + """Strip ` # …` inline comments while preserving `#` inside quoted strings. + + YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token + is part of the value. Quoted strings are immune. + """ + in_quote: str | None = None + for idx, ch in enumerate(value): + if in_quote: + if ch == in_quote: + in_quote = None + continue + if ch in ('"', "'"): + in_quote = ch + continue + if ch == "#" and (idx == 0 or value[idx - 1].isspace()): + return value[:idx] + return value + + +def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: + i = start + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i, lines[i] + i += 1 + return i, "" + + +def _parse_yaml_block( + lines: list[str], start: int, min_indent: int, target: dict +) -> int: + i = start + current_list: list | None = None + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + i += 1 + continue + + indent = len(line) - len(line.lstrip()) + if indent < min_indent: + break + + if stripped.startswith("- "): + if current_list is not None: + current_list.append(_unquote(stripped[2:].strip())) + i += 1 + elif ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = _strip_inline_comment(value).strip() + value = _unquote(value) + current_list = None + + if value: + target[key] = value + i += 1 + else: + next_i, next_line = _next_content_line(lines, i + 1) + if next_i >= len(lines): + target[key] = {} + i = next_i + elif next_line.strip().startswith("- "): + current_list = [] + target[key] = current_list + i += 1 + else: + next_indent = len(next_line) - len(next_line.lstrip()) + if next_indent > indent: + nested: dict = {} + target[key] = nested + i = _parse_yaml_block(lines, i + 1, next_indent, nested) + else: + target[key] = {} + i += 1 + else: + i += 1 + + return i + + +def parse_simple_yaml(content: str) -> dict: + """Parse a small subset of YAML. See common.config for full doc.""" + lines = content.splitlines() + result: dict = {} + _parse_yaml_block(lines, 0, 0, result) + return result + + +def read_trellis_config(repo_root: Optional[Path] = None) -> dict: + """Read .trellis/config.yaml. Returns {} on missing or malformed file.""" + root = repo_root or Path.cwd() + config_file = root / CONFIG_REL_PATH + try: + content = config_file.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + return {} + try: + parsed = parse_simple_yaml(content) + except Exception: + return {} + return parsed if isinstance(parsed, dict) else {} diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py new file mode 100644 index 0000000000..5802e10122 --- /dev/null +++ b/.trellis/scripts/common/types.py @@ -0,0 +1,110 @@ +""" +Core type definitions for Trellis task data. + +Provides: + TaskData — TypedDict for task.json shape (read-path type hints only) + TaskInfo — Frozen dataclass for loaded task (the public API type) + AgentRecord — TypedDict for registry.json agent entries +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TypedDict + + +# ============================================================================= +# task.json shape (TypedDict — used only for read-path type hints) +# ============================================================================= + +class TaskData(TypedDict, total=False): + """Shape of task.json on disk. + + Used only for type annotations when reading task.json. + Writes must use the original dict to avoid losing unknown fields. + """ + + id: str + name: str + title: str + description: str + status: str + dev_type: str + scope: str | None + package: str | None + priority: str + creator: str + assignee: str + createdAt: str + completedAt: str | None + branch: str | None + base_branch: str | None + worktree_path: str | None + commit: str | None + pr_url: str | None + subtasks: list[str] + children: list[str] + parent: str | None + relatedFiles: list[str] + notes: str + meta: dict + + +# ============================================================================= +# Loaded task object (frozen dataclass — the public API type) +# ============================================================================= + +@dataclass(frozen=True) +class TaskInfo: + """Immutable view of a loaded task. + + Created by load_task() / iter_active_tasks(). + Contains the commonly accessed fields; the original dict + is preserved in `raw` for write-back and uncommon field access. + """ + + dir_name: str + directory: Path + title: str + status: str + assignee: str + priority: str + children: tuple[str, ...] + parent: str | None + package: str | None + raw: dict # original dict — use for writes and uncommon fields + + @property + def name(self) -> str: + """Task name (id or name field).""" + return self.raw.get("name") or self.raw.get("id") or self.dir_name + + @property + def description(self) -> str: + return self.raw.get("description", "") + + @property + def branch(self) -> str | None: + return self.raw.get("branch") + + @property + def meta(self) -> dict: + return self.raw.get("meta", {}) + + +# ============================================================================= +# registry.json agent entry +# ============================================================================= + +class AgentRecord(TypedDict, total=False): + """Shape of an agent entry in registry.json.""" + + id: str + pid: int + task_dir: str + worktree_path: str + branch: str + platform: str + started_at: str + status: str diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py new file mode 100644 index 0000000000..2b4acd0f7c --- /dev/null +++ b/.trellis/scripts/common/workflow_phase.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Workflow Phase Extraction. + +Extracts step-level content from .trellis/workflow.md and optionally filters +platform-specific blocks. + +Platform marker syntax in workflow.md: + + [Claude Code, Cursor, ...] + agent-capable content + [/Claude Code, Cursor, ...] + +Provides: + get_phase_index - Extract the Phase Index section (no --step) + get_step - Extract a single step (#### X.X) section + filter_platform - Strip platform blocks that don't include the given name +""" + +from __future__ import annotations + +import re + +from .paths import DIR_WORKFLOW, get_repo_root + + +def _workflow_md_path(): + return get_repo_root() / DIR_WORKFLOW / "workflow.md" + +# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" +_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") + +# Step heading: "#### 1.0 Title" or "#### 1.0 ..." +_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$") + +# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs. +_PHASE_INDEX_HEADING = "## Phase Index" + + +def _read_workflow() -> str: + path = _workflow_md_path() + if not path.exists(): + raise FileNotFoundError(f"workflow.md not found: {path}") + return path.read_text(encoding="utf-8") + + +def _parse_marker(line: str) -> tuple[bool, list[str]] | None: + """Parse a platform marker line. + + Returns: + (is_closing, [platform_names]) if line is a marker, else None. + """ + m = _MARKER_RE.match(line) + if not m: + return None + is_closing = m.group(1) == "/" + names = [p.strip() for p in m.group(2).split(",") if p.strip()] + return is_closing, names + + +def get_phase_index() -> str: + """Return Phase Index + Phase 1/2/3 step bodies from workflow.md. + + Matches what the SessionStart hook injects into the `<workflow>` block: + starts at `## Phase Index`, continues through `## Phase 1: Plan`, + `## Phase 2: Execute`, `## Phase 3: Finish`, stops at + `## Customizing Trellis (for forks)` (the docs-for-forks footer). + `[workflow-state:STATUS]` tag blocks (now embedded in Phase Index since + v0.5.0-rc.0) are consumed by the UserPromptSubmit hook so they're + stripped from this output. + """ + text = _read_workflow() + lines = text.splitlines() + + start: int | None = None + end: int | None = None + for i, line in enumerate(lines): + stripped = line.strip() + if start is None and stripped == _PHASE_INDEX_HEADING: + start = i + continue + if start is not None and stripped == "## Customizing Trellis (for forks)": + end = i + break + + if start is None: + return "" + if end is None: + end = len(lines) + + section = "\n".join(lines[start:end]).rstrip() + # Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since + # they're injected separately by inject-workflow-state.py per-turn. + import re as _re + tag_re = _re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?", + _re.DOTALL, + ) + return tag_re.sub("", section).rstrip() + "\n" + + +def get_step(step_id: str) -> str: + """Return the `#### X.X` section matching step_id (header + body). + + Body ends at the next `####` or `---` or `##` heading (whichever comes first). + """ + text = _read_workflow() + lines = text.splitlines() + + start: int | None = None + for i, line in enumerate(lines): + m = _STEP_HEADING_RE.match(line) + if m and m.group(1) == step_id: + start = i + break + if start is None: + return "" + + end: int = len(lines) + for j in range(start + 1, len(lines)): + line = lines[j] + if line.startswith("#### "): + end = j + break + if line.startswith("## "): + end = j + break + # Horizontal rule at column 0 + if line.strip() == "---": + end = j + break + + return "\n".join(lines[start:end]).rstrip() + "\n" + + +def _platform_matches(platform: str, block_names: list[str]) -> bool: + """Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'.""" + needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "") + for name in block_names: + hay = name.lower().replace("-", "").replace("_", "").replace(" ", "") + if needle == hay: + return True + return False + + +def resolve_effective_platform(platform: str, config: dict) -> str: + """Map ``codex`` to a dispatch-mode-namespaced virtual platform name. + + When ``--platform codex`` is passed, return ``"codex-inline"`` (default) + or ``"codex-sub-agent"`` based on ``.trellis/config.yaml`` ``codex.dispatch_mode``. + ``filter_platform`` then surfaces blocks whose marker lists include the + namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo, + Antigravity, Windsurf]``). + + Default is ``inline`` because Codex sub-agents run with ``fork_turns="none"`` + isolation and can't inherit the parent session's task context — inline + keeps the main agent in charge so context isn't lost. Invalid / missing + values also fall back to inline. + + Other platforms are returned unchanged. + """ + if platform == "codex": + mode = "inline" + codex_cfg = config.get("codex") if isinstance(config, dict) else None + if isinstance(codex_cfg, dict): + cfg_mode = codex_cfg.get("dispatch_mode") + if cfg_mode in ("inline", "sub-agent"): + mode = cfg_mode + return f"codex-{mode}" + return platform + + +def filter_platform(content: str, platform: str) -> str: + """Keep lines outside any `[...]` block + lines inside blocks that include platform. + + Marker lines themselves are dropped from the output. + """ + lines = content.splitlines() + out: list[str] = [] + + in_block = False + keep_block = False + + for line in lines: + marker = _parse_marker(line) + if marker is not None: + is_closing, names = marker + if not is_closing: + in_block = True + keep_block = _platform_matches(platform, names) + else: + in_block = False + keep_block = False + continue # drop the marker line itself + + if in_block: + if keep_block: + out.append(line) + continue + out.append(line) + + # Collapse runs of 3+ blank lines that may arise from dropped markers + collapsed: list[str] = [] + blank_run = 0 + for line in out: + if line.strip() == "": + blank_run += 1 + if blank_run <= 2: + collapsed.append(line) + else: + blank_run = 0 + collapsed.append(line) + + return "\n".join(collapsed).rstrip() + "\n" diff --git a/.trellis/scripts/get_context.py b/.trellis/scripts/get_context.py new file mode 100644 index 0000000000..0bde5bfa6b --- /dev/null +++ b/.trellis/scripts/get_context.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +""" +Get Session Context for AI Agent. + +Usage: + python get_context.py Output context in text format + python get_context.py --json Output context in JSON format +""" + +from __future__ import annotations + +from common.git_context import main + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/get_developer.py b/.trellis/scripts/get_developer.py new file mode 100644 index 0000000000..f8a89ebf66 --- /dev/null +++ b/.trellis/scripts/get_developer.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Get current developer name. + +This is a wrapper that uses common/paths.py +""" + +from __future__ import annotations + +import sys + +from common.paths import get_developer + + +def main() -> None: + """CLI entry point.""" + developer = get_developer() + if developer: + print(developer) + else: + print("Developer not initialized", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/hooks/linear_sync.py b/.trellis/scripts/hooks/linear_sync.py new file mode 100644 index 0000000000..1fdce68172 --- /dev/null +++ b/.trellis/scripts/hooks/linear_sync.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Linear sync hook for Trellis task lifecycle. + +Syncs task events to Linear via the `linearis` CLI. + +Usage (called automatically by task.py hooks): + python .trellis/scripts/hooks/linear_sync.py create + python .trellis/scripts/hooks/linear_sync.py start + python .trellis/scripts/hooks/linear_sync.py archive + +Manual usage: + TASK_JSON_PATH=.trellis/tasks/<name>/task.json python .trellis/scripts/hooks/linear_sync.py sync + +Environment: + TASK_JSON_PATH - Absolute path to task.json (set by task.py) + +Configuration: + .trellis/hooks.local.json - Local config (gitignored), example: + { + "linear": { + "team": "TEAM_KEY", + "project": "Project Name", + "assignees": { + "dev-name": "linear-user-id" + } + } + } +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +# ─── Configuration ──────────────────────────────────────────────────────────── + +# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low) +PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4} + +# Linear status names (must match your team's workflow) +STATUS_IN_PROGRESS = "In Progress" +STATUS_DONE = "Done" + + +def _load_config() -> dict: + """Load local hook config from .trellis/hooks.local.json.""" + task_json_path = os.environ.get("TASK_JSON_PATH", "") + if task_json_path: + # Walk up from task.json to find .trellis/ + trellis_dir = Path(task_json_path).parent.parent.parent + else: + trellis_dir = Path(".trellis") + + config_path = trellis_dir / "hooks.local.json" + try: + with open(config_path, encoding="utf-8") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +CONFIG = _load_config() +LINEAR_CFG = CONFIG.get("linear", {}) + +TEAM = LINEAR_CFG.get("team", "") +PROJECT = LINEAR_CFG.get("project", "") +ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {}) + +# ─── Helpers ────────────────────────────────────────────────────────────────── + + +def _read_task() -> tuple[dict, str]: + path = os.environ.get("TASK_JSON_PATH", "") + if not path: + print("TASK_JSON_PATH not set", file=sys.stderr) + sys.exit(1) + with open(path, encoding="utf-8") as f: + return json.load(f), path + + +def _write_task(data: dict, path: str) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def _linearis(*args: str) -> dict | None: + result = subprocess.run( + ["linearis", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print(f"linearis error: {result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + stdout = result.stdout.strip() + if stdout: + return json.loads(stdout) + return None + + +def _get_linear_issue(task: dict) -> str | None: + meta = task.get("meta") + if isinstance(meta, dict): + return meta.get("linear_issue") + return None + + +# ─── Actions ────────────────────────────────────────────────────────────────── + + +def cmd_create() -> None: + if not TEAM: + print("No linear.team configured in hooks.local.json", file=sys.stderr) + sys.exit(1) + + task, path = _read_task() + + # Skip if already linked + if _get_linear_issue(task): + print(f"Already linked: {_get_linear_issue(task)}") + return + + title = task.get("title") or task.get("name") or "Untitled" + args = ["issues", "create", title, "--team", TEAM] + + # Map priority + priority = PRIORITY_MAP.get(task.get("priority", ""), 0) + if priority: + args.extend(["-p", str(priority)]) + + # Set project + if PROJECT: + args.extend(["--project", PROJECT]) + + # Assign to Linear user + assignee = task.get("assignee", "") + linear_user_id = ASSIGNEE_MAP.get(assignee) + if linear_user_id: + args.extend(["--assignee", linear_user_id]) + + # Link to parent's Linear issue if available + parent_issue = _resolve_parent_linear_issue(task) + if parent_issue: + args.extend(["--parent-ticket", parent_issue]) + + result = _linearis(*args) + if result and "identifier" in result: + if not isinstance(task.get("meta"), dict): + task["meta"] = {} + task["meta"]["linear_issue"] = result["identifier"] + _write_task(task, path) + print(f"Created Linear issue: {result['identifier']}") + + +def cmd_start() -> None: + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + return + _linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS) + print(f"Updated {issue} -> {STATUS_IN_PROGRESS}") + cmd_sync() + + +def cmd_archive() -> None: + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + return + _linearis("issues", "update", issue, "-s", STATUS_DONE) + print(f"Updated {issue} -> {STATUS_DONE}") + + +def cmd_sync() -> None: + """Sync prd.md content to Linear issue description.""" + task, _ = _read_task() + issue = _get_linear_issue(task) + if not issue: + print("No linear_issue in meta, run create first", file=sys.stderr) + sys.exit(1) + + # Find prd.md next to task.json + task_json_path = os.environ.get("TASK_JSON_PATH", "") + prd_path = Path(task_json_path).parent / "prd.md" + if not prd_path.is_file(): + print(f"No prd.md found at {prd_path}", file=sys.stderr) + sys.exit(1) + + description = prd_path.read_text(encoding="utf-8").strip() + _linearis("issues", "update", issue, "-d", description) + print(f"Synced prd.md to {issue} description") + + +# ─── Parent Issue Resolution ───────────────────────────────────────────────── + + +def _resolve_parent_linear_issue(task: dict) -> str | None: + """Find parent task's Linear issue identifier.""" + parent_name = task.get("parent") + if not parent_name: + return None + + task_json_path = os.environ.get("TASK_JSON_PATH", "") + if not task_json_path: + return None + + current_task_dir = Path(task_json_path).parent + tasks_dir = current_task_dir.parent + parent_json = tasks_dir / parent_name / "task.json" + + if parent_json.exists(): + try: + with open(parent_json, encoding="utf-8") as f: + parent_task = json.load(f) + return _get_linear_issue(parent_task) + except (json.JSONDecodeError, OSError): + pass + return None + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + action = sys.argv[1] if len(sys.argv) > 1 else "" + actions = { + "create": cmd_create, + "start": cmd_start, + "archive": cmd_archive, + "sync": cmd_sync, + } + fn = actions.get(action) + if fn: + fn() + else: + print(f"Unknown action: {action}", file=sys.stderr) + print(f"Valid actions: {', '.join(actions)}", file=sys.stderr) + sys.exit(1) diff --git a/.trellis/scripts/init_developer.py b/.trellis/scripts/init_developer.py new file mode 100644 index 0000000000..557b289914 --- /dev/null +++ b/.trellis/scripts/init_developer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Initialize developer for workflow. + +Usage: + python init_developer.py <developer-name> + +This creates: + - .trellis/.developer file with developer info + - .trellis/workspace/<name>/ directory structure +""" + +from __future__ import annotations + +import sys + +from common.paths import ( + DIR_WORKFLOW, + FILE_DEVELOPER, + get_developer, +) +from common.developer import init_developer + + +def main() -> None: + """CLI entry point.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} <developer-name>") + print() + print("Example:") + print(f" {sys.argv[0]} john") + sys.exit(1) + + name = sys.argv[1] + + # Check if already initialized + existing = get_developer() + if existing: + print(f"Developer already initialized: {existing}") + print() + print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first") + sys.exit(0) + + if init_developer(name): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py new file mode 100644 index 0000000000..81e4da8d28 --- /dev/null +++ b/.trellis/scripts/task.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Task Management Script. + +Usage: + python task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] + python task.py add-context <dir> <file> <path> [reason] # Add jsonl entry + python task.py validate <dir> # Validate jsonl files + python task.py list-context <dir> # List jsonl entries + python task.py start <dir> # Set active task + python task.py current [--source] # Show active task + python task.py finish # Clear active task + python task.py set-branch <dir> <branch> # Set git branch + python task.py set-base-branch <dir> <branch> # Set PR target branch + python task.py set-scope <dir> <scope> # Set scope for PR title + python task.py archive <task-dir> # Archive completed task + python task.py list # List active tasks + python task.py list-archive [month] # List archived tasks + python task.py add-subtask <parent-dir> <child-dir> # Link child to parent + python task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent +""" + +from __future__ import annotations + +import argparse +import sys + +from common.log import Colors, colored +from common.paths import ( + DIR_WORKFLOW, + DIR_TASKS, + FILE_TASK_JSON, + get_repo_root, + get_developer, + get_tasks_dir, + get_current_task, +) +from common.active_task import ( + clear_active_task, + resolve_active_task, + resolve_context_key, + set_active_task, +) +from common.io import read_json, write_json +from common.task_utils import resolve_task_dir, run_task_hooks +from common.tasks import iter_active_tasks, children_progress + +# Import command handlers from split modules (also re-exports for plan.py compatibility) +from common.task_store import ( + cmd_create, + cmd_archive, + cmd_set_branch, + cmd_set_base_branch, + cmd_set_scope, + cmd_add_subtask, + cmd_remove_subtask, +) +from common.task_context import ( + cmd_add_context, + cmd_validate, + cmd_list_context, +) + + +# ============================================================================= +# Command: start / finish +# ============================================================================= + +def cmd_start(args: argparse.Namespace) -> int: + """Set active task.""" + repo_root = get_repo_root() + task_input = args.dir + + if not task_input: + print(colored("Error: task directory or name required", Colors.RED)) + return 1 + + # Resolve task directory (supports task name, relative path, or absolute path) + full_path = resolve_task_dir(task_input, repo_root) + + if not full_path.is_dir(): + print(colored(f"Error: Task not found: {task_input}", Colors.RED)) + print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')") + return 1 + + # Convert to relative path for storage + try: + task_dir = full_path.relative_to(repo_root).as_posix() + except ValueError: + task_dir = str(full_path) + + task_json_path = full_path / FILE_TASK_JSON + + if not resolve_context_key(): + # Degraded mode: no session identity available. + # Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code, + # --continue resume path, fork distribution, hooks disabled, etc.). Skip + # per-session pointer write; AI continues based on conversation context. + print(colored( + "ℹ Session identity not available; active-task pointer not persisted " + "this session (degraded mode). AI continues based on conversation context.", + Colors.YELLOW, + )) + print(colored( + "Hint: run inside an AI IDE/session that exposes session identity, " + "or set TRELLIS_CONTEXT_ID before running task.py start.", + Colors.YELLOW, + )) + + # Still flip task.json status: planning → in_progress so downstream phases proceed. + if task_json_path.is_file(): + data = read_json(task_json_path) + if data and data.get("status") == "planning": + data["status"] = "in_progress" + if write_json(task_json_path, data): + print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN)) + run_task_hooks("after_start", task_json_path, repo_root) + return 0 + + active = set_active_task(task_dir, repo_root) + if active: + print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) + print(f"Source: {active.source}") + + if task_json_path.is_file(): + data = read_json(task_json_path) + if data and data.get("status") == "planning": + data["status"] = "in_progress" + if write_json(task_json_path, data): + print(colored("✓ Status: planning → in_progress", Colors.GREEN)) + + print() + print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) + + run_task_hooks("after_start", task_json_path, repo_root) + return 0 + else: + print(colored("Error: Failed to set current task", Colors.RED)) + return 1 + + +def cmd_finish(args: argparse.Namespace) -> int: + """Clear active task.""" + repo_root = get_repo_root() + active = clear_active_task(repo_root) + current = active.task_path + + if not current: + print(colored("No current task set", Colors.YELLOW)) + return 0 + + # Resolve task.json path before clearing + task_json_path = repo_root / current / FILE_TASK_JSON + + print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) + print(f"Source: {active.source}") + + if task_json_path.is_file(): + run_task_hooks("after_finish", task_json_path, repo_root) + return 0 + + +def cmd_current(args: argparse.Namespace) -> int: + """Show active task.""" + repo_root = get_repo_root() + active = resolve_active_task(repo_root) + + if args.source: + print(f"Current task: {active.task_path or '(none)'}") + print(f"Source: {active.source}") + if active.stale: + print("State: stale") + return 0 if active.task_path else 1 + + if active.task_path: + print(active.task_path) + return 0 + + return 1 + + +# ============================================================================= +# Command: list +# ============================================================================= + +def cmd_list(args: argparse.Namespace) -> int: + """List active tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + current_task = get_current_task(repo_root) + developer = get_developer(repo_root) + filter_mine = args.mine + filter_status = args.status + + if filter_mine: + if not developer: + print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr) + return 1 + print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE)) + else: + print(colored("All active tasks:", Colors.BLUE)) + print() + + # Single pass: collect all tasks via shared iterator + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + # Display tasks hierarchically + count = 0 + + def _print_task(dir_name: str, indent: int = 0) -> None: + nonlocal count + t = all_tasks[dir_name] + + # Apply --mine filter + if filter_mine and (t.assignee or "-") != developer: + return + + # Apply --status filter + if filter_status and t.status != filter_status: + return + + relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" + marker = "" + if relative_path == current_task: + marker = f" {colored('<- current', Colors.GREEN)}" + + # Children progress + progress = children_progress(t.children, all_statuses) + + # Package tag + pkg_tag = f" @{t.package}" if t.package else "" + + prefix = " " * indent + " - " + + if filter_mine: + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}") + else: + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") + count += 1 + + # Print children indented + for child_name in t.children: + if child_name in all_tasks: + _print_task(child_name, indent + 1) + + # Display only top-level tasks (those without a parent) + for dir_name in sorted(all_tasks.keys()): + if not all_tasks[dir_name].parent: + _print_task(dir_name) + + if count == 0: + if filter_mine: + print(" (no tasks assigned to you)") + else: + print(" (no active tasks)") + + print() + print(f"Total: {count} task(s)") + return 0 + + +# ============================================================================= +# Command: list-archive +# ============================================================================= + +def cmd_list_archive(args: argparse.Namespace) -> int: + """List archived tasks.""" + repo_root = get_repo_root() + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + month = args.month + + print(colored("Archived tasks:", Colors.BLUE)) + print() + + if month: + month_dir = archive_dir / month + if month_dir.is_dir(): + print(f"[{month}]") + for d in sorted(month_dir.iterdir()): + if d.is_dir(): + print(f" - {d.name}/") + else: + print(f" No archives for {month}") + else: + if archive_dir.is_dir(): + for month_dir in sorted(archive_dir.iterdir()): + if month_dir.is_dir(): + month_name = month_dir.name + count = sum(1 for d in month_dir.iterdir() if d.is_dir()) + print(f"[{month_name}] - {count} task(s)") + + return 0 + + +# ============================================================================= +# Help +# ============================================================================= + +def show_usage() -> None: + """Show usage help.""" + print("""Task Management Script + +Usage: + python task.py create <title> Create new task directory + python task.py create <title> --package <pkg> Create task for a specific package + python task.py create <title> --parent <dir> Create task as child of parent + python task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl + python task.py validate <dir> Validate jsonl files + python task.py list-context <dir> List jsonl entries + python task.py start <dir> Set active task + python task.py current [--source] Show active task + python task.py finish Clear active task + python task.py set-branch <dir> <branch> Set git branch + python task.py set-base-branch <dir> <branch> Set PR target branch + python task.py set-scope <dir> <scope> Set scope for PR title + python task.py archive <task-dir> Archive completed task + python task.py add-subtask <parent> <child> Link child task to parent + python task.py remove-subtask <parent> <child> Unlink child from parent + python task.py list [--mine] [--status <status>] List tasks + python task.py list-archive [YYYY-MM] List archived tasks + +Monorepo options: + --package <pkg> Package name (validated against config.yaml packages) + +List options: + --mine, -m Show only tasks assigned to current developer + --status, -s <s> Filter by status (planning, in_progress, review, completed) + +Examples: + python task.py create "Add login feature" --slug add-login + python task.py create "Add login feature" --slug add-login --package cli + python task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent + python task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines" + python task.py set-branch <dir> task/add-login + python task.py start .trellis/tasks/01-21-add-login + python task.py current --source + python task.py finish + python task.py archive add-login + python task.py add-subtask parent-task child-task # Link existing tasks + python task.py remove-subtask parent-task child-task + python task.py list # List all active tasks + python task.py list --mine # List my tasks only + python task.py list --mine --status in_progress # List my in-progress tasks +""") + + +# ============================================================================= +# Main Entry +# ============================================================================= + +def main() -> int: + """CLI entry point.""" + # Deprecation guard: `init-context` was removed in v0.5.0-beta.12. + # Detect early so argparse doesn't mask the real reason with a generic + # "invalid choice" error. + if len(sys.argv) >= 2 and sys.argv[1] == "init-context": + print( + colored( + "Error: `task.py init-context` was removed in v0.5.0-beta.12.", + Colors.RED, + ), + file=sys.stderr, + ) + print( + "implement.jsonl / check.jsonl are now seeded on `task.py create` for", + file=sys.stderr, + ) + print( + "sub-agent-capable platforms and curated by the AI during Phase 1.3.", + file=sys.stderr, + ) + print("See .trellis/workflow.md Phase 1.3 or run:", file=sys.stderr) + print( + " python ./.trellis/scripts/get_context.py --mode phase --step 1.3", + file=sys.stderr, + ) + print( + "Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.", + file=sys.stderr, + ) + return 2 + + parser = argparse.ArgumentParser( + description="Task Management Script", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # create + p_create = subparsers.add_parser("create", help="Create new task") + p_create.add_argument("title", help="Task title") + p_create.add_argument("--slug", "-s", help="Task slug") + p_create.add_argument("--assignee", "-a", help="Assignee developer") + p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") + p_create.add_argument("--description", "-d", help="Task description") + p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") + p_create.add_argument("--package", help="Package name for monorepo projects") + + # add-context + p_add = subparsers.add_parser("add-context", help="Add context entry") + p_add.add_argument("dir", help="Task directory") + p_add.add_argument("file", help="JSONL file (implement|check)") + p_add.add_argument("path", help="File path to add") + p_add.add_argument("reason", nargs="?", help="Reason for adding") + + # validate + p_validate = subparsers.add_parser("validate", help="Validate context files") + p_validate.add_argument("dir", help="Task directory") + + # list-context + p_listctx = subparsers.add_parser("list-context", help="List context entries") + p_listctx.add_argument("dir", help="Task directory") + + # start + p_start = subparsers.add_parser("start", help="Set active task") + p_start.add_argument("dir", help="Task directory") + + # current + p_current = subparsers.add_parser("current", help="Show active task") + p_current.add_argument("--source", action="store_true", + help="Show active task source") + + # finish + subparsers.add_parser("finish", help="Clear active task") + + # set-branch + p_branch = subparsers.add_parser("set-branch", help="Set git branch") + p_branch.add_argument("dir", help="Task directory") + p_branch.add_argument("branch", help="Branch name") + + # set-base-branch + p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch") + p_base.add_argument("dir", help="Task directory") + p_base.add_argument("base_branch", help="Base branch name (PR target)") + + # set-scope + p_scope = subparsers.add_parser("set-scope", help="Set scope") + p_scope.add_argument("dir", help="Task directory") + p_scope.add_argument("scope", help="Scope name") + + # archive + p_archive = subparsers.add_parser("archive", help="Archive task") + p_archive.add_argument("name", help="Task directory or name") + p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive") + + # list + p_list = subparsers.add_parser("list", help="List tasks") + p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only") + p_list.add_argument("--status", "-s", help="Filter by status") + + # add-subtask + p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent") + p_addsub.add_argument("parent_dir", help="Parent task directory") + p_addsub.add_argument("child_dir", help="Child task directory") + + # remove-subtask + p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent") + p_rmsub.add_argument("parent_dir", help="Parent task directory") + p_rmsub.add_argument("child_dir", help="Child task directory") + + # list-archive + p_listarch = subparsers.add_parser("list-archive", help="List archived tasks") + p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)") + + args = parser.parse_args() + + if not args.command: + show_usage() + return 1 + + commands = { + "create": cmd_create, + "add-context": cmd_add_context, + "validate": cmd_validate, + "list-context": cmd_list_context, + "start": cmd_start, + "current": cmd_current, + "finish": cmd_finish, + "set-branch": cmd_set_branch, + "set-base-branch": cmd_set_base_branch, + "set-scope": cmd_set_scope, + "archive": cmd_archive, + "add-subtask": cmd_add_subtask, + "remove-subtask": cmd_remove_subtask, + "list": cmd_list, + "list-archive": cmd_list_archive, + } + + if args.command in commands: + return commands[args.command](args) + else: + show_usage() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md new file mode 100644 index 0000000000..dc506f8bdf --- /dev/null +++ b/.trellis/spec/backend/database-guidelines.md @@ -0,0 +1,44 @@ +# Backend Database Guidelines + +> DB patterns and conventions. Status: active for runtime-selected Janus control-plane stores. + +--- + +## 1. Stack + +- **PostgreSQL (release mode)** — owner profile / project / session / message / event metadata (doc 03 §7). +- **SQLite (dev mode)** — local control-plane store for UI/API testing without Docker or Postgres. +- **Driver: `postgres` for release, `bun:sqlite` for dev**. Janus stores stable lookup columns plus schema-validated JSON bodies for MVP control-plane records. +- **Object store (S3-compatible)** — session logs, artifacts, large files (not in Postgres). +- **Redis** — cache, session-token↔key mapping, PubSub/queue (not the system of record). + +> Single-user / personal deployment: **no multi-tenant model, no `tenant_id` column, no row-level tenancy**. Model entities for one owner. + +--- + +## 2. Architecture placement (hard rule) + +- **All DB access lives in `contexts/_shared/adapters/store/postgres/`, `contexts/_shared/adapters/store/sqlite/`, or another context-owned `adapters/` folder**, implementing a `contracts/ports` interface. +- `usecases/` depend on the **port**, never on `postgres` or a connection directly. This keeps the one-way dependency rule and makes usecases testable with a mock store. +- Schema/migration definitions live alongside the db adapter, referencing types from `contracts/` where they cross layers. + +--- + +## 3. Conventions + +- **Migrations/initialization**: schema changes are forward-only SQL. Dev SQLite and local release adapters may create missing tables during startup, but production release migrations must be reviewed SQL, not hidden in usecases. +- **Naming**: `snake_case` tables/columns; plural table names; string UUID primary keys for runtime records. +- **Transactions**: own the transaction boundary in the **usecase** (it knows the unit of work); the adapter exposes a transactional port method, not ad-hoc `BEGIN` scattered around. +- **Queries**: keep SQL parameterized, typed at the adapter boundary, and indexed for hot access paths such as activity stream session/sequence and model-session token expiry. +- **JSON bodies**: records stored in Postgres `jsonb` or SQLite text JSON must be decoded with shared Zod schemas on read. Do not trust database JSON as already-valid typed data. +- **Runtime mode selection**: `JANUS_RUNTIME_MODE=dev` selects SQLite and local dev runtime adapters; `JANUS_RUNTIME_MODE=release` selects Postgres and Docker-backed runtime adapters. The selection belongs in `entry/composition`, not in usecases. + +--- + +## 4. Common mistakes to avoid + +- Importing the db client into a usecase (breaks layering — inject a port). +- Adding a runtime-mode branch inside a usecase (mode selection belongs in entry composition). +- Storing secrets (BYOK keys, Git tokens) in Postgres in plaintext — they belong in the **encrypted key vault** (doc 03 §7, doc 05 §3), never a plain column. +- Putting large blobs (logs, artifacts) in Postgres instead of the object store. +- Adding a tenancy column "just in case" — out of scope for the personal edition (needless complexity). diff --git a/.trellis/spec/backend/directory-structure.md b/.trellis/spec/backend/directory-structure.md new file mode 100644 index 0000000000..68c4e97108 --- /dev/null +++ b/.trellis/spec/backend/directory-structure.md @@ -0,0 +1,198 @@ +# Backend Directory Structure — Context-First Atomic Architecture (4+2) + +> **Authoritative, normative.** All backend code under `apps/server/` and `packages/*` follows this. This is the *code-organization* architecture (how source is layered), orthogonal to the *runtime-component* architecture in `docs/design/03-system-architecture.md` (which services exist at runtime). Each runtime component is built *from* these layers. +> +> Status: active. The codebase uses top-level `entry/`, `api/`, `contracts/`, and `errors/` plus bounded contexts under `contexts/`. Keep this file in sync as real modules land. + +--- + +## 1. The model: bounded contexts + 4-layer dependency chain + 2 horizontal zones + +``` + L1 entry/ ← single startup entry + composition root (assembly only) + │ + L2 api/ ← protocol adapters (REST / WS / CLI / Webhook) + │ + L3 usecases/ ← public application entrypoints + L3b workflows/ ← private, context-local multi-step orchestration + L3c services/ ← private, context-local application helpers + │ + L4 atoms/ ← pure policies/parsers/decisions (no I/O) + + X1 contracts/ ← shared language: DTOs, error codes, ports, events, constants (declarations only) + X2 adapters/ ← the ONLY place with I/O: DB / cache / MQ / HTTP / FS / SDK / model-service / git +``` + +Main chain compile-time dependency (one-way): **`api → context/usecases|workflows|services → context/atoms`**. +`contracts/` is depended on by everyone (declarations only). `adapters/` is wired in by `entry/` and reaches `usecases` **only through `contracts/ports`** -- never imported directly by `usecases`/`api`. + +Use 4+2 as the dependency model, not as a mandatory horizontal-only directory shape. Janus groups backend code by bounded context to avoid rigidity from scattered edits, while preserving the same one-way dependency and I/O rules inside each context. + +**First principle**: context ownership is the stable boundary; layers are the dependency safety rails inside that boundary. A private helper that belongs to one context should stay in that context, not be promoted into `_shared` or `contracts` just to satisfy a folder rule. + +--- + +## 2. Directory layout (`apps/server/src/`) + +``` +apps/server/src/ +├─ entry/ +│ ├─ main.ts # the single process entry +│ └─ composition-root.ts # DI wiring: build adapters, inject ports into usecases +├─ api/ +│ ├─ http/ # REST routes (Elysia/Hono) +│ ├─ ws/ # WebSocket / SSE activity-stream endpoints +│ └─ error-map.ts # internal error code → protocol error +├─ contracts/ +│ ├─ ports/ # CliAdapterPort, ModelGatewayPort, RepoStorePort, GitCredsPort, ... +│ ├─ dto/ # request/response/event types (shared with packages/shared where cross-cutting) +│ ├─ errors.ts # error codes +│ └─ events.ts # event definitions +├─ errors/ +│ └─ janus-error.ts # executable error class; not declarations-only contract code +└─ contexts/ + ├─ orchestrator/ + │ ├─ usecases/ # run/session lifecycle public entrypoints + │ ├─ workflows/ # sandbox startup, run queueing, delivery/cancellation coordination + │ ├─ services/ # lifecycle helpers with injected ports + │ └─ atoms/ # pure queue/state/lifecycle decisions + ├─ supervisor/ + │ ├─ usecases/ # supervisor-engine public entrypoints when needed + │ ├─ workflows/ # private model/tool loop orchestration + │ ├─ services/ # private tool execution helpers with injected ports + │ └─ atoms/ # pure prompt, tool, CLI job, and discussion policy + ├─ model-gateway/ + │ ├─ usecases/ # public provider/proxy entrypoints + │ ├─ services/ # private provider health/failover helpers + │ ├─ adapters/llm/ # upstream Anthropic/OpenAI-compatible HTTP I/O + │ └─ atoms/ # pure provider ordering/model rewrite/token parsing + ├─ sandbox/ + │ ├─ usecases/ # sandbox lifecycle policy + │ ├─ adapters/docker/ # Docker I/O + │ ├─ adapters/cli/ # Claude Code, Codex, tmux command execution I/O + │ └─ atoms/ # pure sandbox policy and command/config construction + ├─ git-broker/ + │ ├─ usecases/ # repository authorization policy + │ ├─ adapters/git/ # git workspace/worktree/publish I/O + │ ├─ adapters/github/ # GitHub API I/O + │ └─ atoms/ # pure repo slug, branch name, diff parsing + ├─ sessions/ + │ └─ usecases/ # session lifecycle and activity/diff flows + ├─ projects/ + │ ├─ usecases/ # project connection/listing flows + │ └─ atoms/ # public response shaping + ├─ credentials/ + │ ├─ usecases/ # credential metadata flows + │ └─ adapters/key-vault/ + └─ _shared/ + ├─ usecases/ # generic shared sub-usecases only + └─ adapters/ # shared infrastructure adapters only +``` + +Do not recreate top-level `usecases/`, `atoms/`, or `adapters/` for new backend work. Add code to the owning `contexts/<context>/...` folder or create a new bounded context when the responsibility does not fit an existing one. + +--- + +## 3. The layers in detail + +### L1 — `entry/` (Entry Layer) +- The **single startup entry** + composition root. Process-level governance: config loading, **dependency assembly (DI)**, lifecycle, global exception capture, log/tracing init and routing. +- **Forbidden**: any business logic. Startup and runtime governance only. +- May depend on **everything** (for assembly only). + +### L2 — `api/` (API / Transport Layer) +- The system's outward protocol adapter (HTTP / WS / CLI / Webhook): protocol codec, auth/gate, parameter validation, **error mapping** (internal error code → protocol error). +- **Forbidden**: any business rule / domain decision (branch decisions, state transitions, rule computation). +- May only call context usecase factories/functions; **must not orchestrate atoms directly**. + +### L3 — `contexts/<context>/usecases/` (Public Application Entrypoints) +- Public application entrypoints called by `api/`, `entry/`, tests, or context composition. +- Owns: request-level orchestration, authorization/precondition checks, idempotency boundary, transaction boundary, and handoff into private workflows/services. +- Dependencies allowed: same-context `atoms/`, `workflows/`, `services/`, server `contracts/`, `errors/`, and carefully controlled `contexts/_shared/usecases/`. +- A public usecase may call same-context private workflows/services. It must not import another context's usecase/workflow/service directly. +- Public usecase→public usecase calls are discouraged because they blur ownership; prefer extracting the shared behavior into a same-context service/workflow, a pure atom, or a deliberately stable `_shared` helper. +- **Must not import `adapters/` directly** — external capability is injected via `contracts/ports`. + +### L3b — `contexts/<context>/workflows/` (Private Workflow Helpers) +- Optional for large context-local flows that would otherwise make one usecase obscure or rigid. +- Workflow helpers are still usecase-layer code: they may compose atoms and injected ports, but they must not do I/O directly or import adapters. +- Workflows are private to their owning context. A usecase in another context must not import them; extract shared policy downward to atoms or a stable `_shared` usecase instead. + +### L3c — `contexts/<context>/services/` (Private Application Services) +- Optional for context-local helper logic that is more than a pure atom but smaller than a workflow. +- Use services for repeated application behavior that needs injected ports, timestamps, id factories, or persistence updates, but does not define a public application entrypoint. +- Services are private to their owning context. Do not import another context's services; promote a cross-context need into a port/event/shared contract instead. +- Services must not import adapters directly and must not become a dumping ground for unrelated helpers. + +### L4 — `contexts/<context>/atoms/` (Atomic Layer, pure only) +- Each atom is a **single-responsibility pure unit** (function / pure class / stateless). +- Atoms must: have **no I/O**, **no shared mutable state**, be unit-testable, be reusable. +- Atoms **may call atoms** (as long as they stay pure). +- A single atom's logic should usually be small enough to read in one screen. The old 80-line rule is a heuristic, not a correctness property. Cohesive policy modules may be larger when they remain pure, table-driven, and easier to read than scattered one-function files. + +### X1 — `contracts/` (Contracts zone) +- The system's "shared language": DTOs / request-response types, error codes, **port interfaces (ports)**, event definitions, constants. +- **Declarations only, no implementation**; no business flow or I/O code. +- All layers may depend on `contracts/`, but only use its declarations. (Cross-cutting types shared with the frontend live in `packages/shared`; server-only contracts live here.) +- Executable classes/functions do not belong in `contracts/`. Put executable cross-cutting error helpers in `errors/` or a context-local atom, depending on scope. + +### X2 — `contexts/<context>/adapters/` (Adapters / Infrastructure zone) +- The **only** place I/O may appear: DB / cache / MQ / HTTP / FS / third-party SDK / model-service / git. +- Provides external capability by **implementing `contracts/ports`**; assembled by `entry/` and injected into `usecases/`. +- May depend on `contracts/`, `errors/`, and optionally pure atoms. +- **Must not depend on `usecases/` or `api/`**, and must not carry business rules. + +### Shared zones +- `contexts/_shared/usecases/` is for stable, protocol-free shared sub-usecases. Keep it small; if it starts carrying domain meaning, move that behavior into an owning context. +- `contexts/_shared/adapters/` is for infrastructure helpers reused by adapters, such as process runners or stores. Non-adapter code must not import it. +- `packages/shared` is only for cross-process/frontend DTOs, Zod schemas, constants, and inferred types. Server-only ports/events/errors stay in `apps/server/src/contracts`. + +--- + +## 4. Hard rules (enforced) + +1. **Compile-time deps are one-way**: `api → contexts/*/usecases|workflows|services → contexts/*/atoms`. `adapters` must not be imported by `usecases`/`workflows`/`services`/`api`; `entry` may depend on all (assembly only). +2. **No cross-layer calls**: except `entry`, no layer calls a more-outer layer's implementation. +3. **I/O isolation**: any network / DB / file / cache / queue access **must** be in `adapters/`. I/O appearing in `atoms` / `services` / `workflows` / `usecases` / `api` is a violation. +4. **Extension method**: prefer adding new atoms/usecases/workflows/services when behavior is genuinely new; refactor existing internals when it removes duplication, obscurity, or the wrong owner. +5. **Testing**: `atoms` must have unit tests; `usecases` must have use-case tests (mock ports); `api` does contract/route tests; `adapters` do integration tests (optional). See `quality-guidelines.md`. +6. **Context boundaries**: context-local workflows/services stay in their context. Cross-context reuse belongs in pure atoms, server contracts, ports/events, or carefully controlled `_shared` usecases. +7. **Contracts are declarations**: error code unions, port interfaces, event types, DTO declarations, and constants only. + +--- + +## 5. "Where does my new code go?" — decision flow + +``` +Does it do I/O (DB/HTTP/FS/cache/subprocess/git)? + └─ yes → contexts/<owner>/adapters/ (behind a contracts/ports interface) + └─ no → Is it a multi-step flow / orchestration / has a transaction or retry boundary? + └─ yes → contexts/<owner>/usecases/ or workflows/ (compose atoms; inject ports) + └─ no → Does it need injected ports/time/id/state but is not a public entrypoint? + └─ yes → contexts/<owner>/services/ + └─ no → Is it pure, single-responsibility, reusable logic? + └─ yes → contexts/<owner>/atoms/ +Is it a type / error code / port interface / event / constant? → contracts/ +Is it a route / WS handler / request validation / error mapping? → api/ +Is it DI wiring / startup / lifecycle? → entry/ +Is it an executable cross-cutting error helper/class? → errors/ +``` + +> Two atoms always passed together as parameters? That's a **data clump** — group them into a `contracts/dto` type. About to import an adapter from a usecase? Stop — define/inject a `contracts/ports` interface instead. + +--- + +## 6. Mapping to runtime components (doc 03) + +Runtime components (Orchestrator, Supervisor Engine, Model Gateway, Sandbox Manager, Git Credential Broker) map to bounded contexts under `contexts/`. See the mapping table in `docs/design/05-tech-stack-and-conventions.md` §5.1. Rule of thumb: a component's public application entrypoints are `usecases/`, private orchestration is `workflows/`, repeated injected helpers are `services/`, pure policy/decision logic is `atoms/`, I/O is `adapters/` behind `contracts/ports`, and transport is `api/`. + +Orchestrator owns run/session lifecycle, sandbox startup/cleanup coordination, run queueing, queued-run delivery, cancellation registration, and lifecycle persistence. Supervisor owns the trusted agent loop surface: model turns, tool calls/results, prompt policy, tool input parsing, CLI job prompt policy, and group-discussion output policy. Do not put Orchestrator lifecycle code under `contexts/supervisor`. + +--- + +## 7. Naming conventions + +- Folders/files: `kebab-case`. Types/classes: `PascalCase`. Functions/vars: `camelCase`. +- Port interfaces end in `Port` (`CliAdapterPort`); their adapters are named by technology (`DockerSandboxAdapter`). +- One atom = one responsibility = one file (or a tight folder of closely-related pure functions). +- Context names use product/runtime language (`supervisor`, `model-gateway`, `sandbox`, `git-broker`, `sessions`, `projects`, `credentials`), not roadmap labels. diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md new file mode 100644 index 0000000000..25a29f57f3 --- /dev/null +++ b/.trellis/spec/backend/error-handling.md @@ -0,0 +1,46 @@ +# Backend Error Handling + +> How errors are defined, propagated, and returned. Aligns with the atomic 4+2 architecture (`directory-structure.md`). Status: greenfield — conventions to follow; ⏳ marks choices to finalize with real code. + +--- + +## 1. Where errors live per layer + +| Layer | Responsibility | +|---|---| +| `contracts/errors.ts` | **Declare** error codes / typed error shapes (the shared vocabulary). Declarations only. | +| `atoms/` | Return typed results or throw pure domain errors; never log, never do I/O. | +| `usecases/` | Map low-level/port errors into domain outcomes; decide retry/iterate vs fail (the supervisor's iterate-on-verify-failure is a domain decision here). | +| `adapters/` | Catch raw external errors (DB driver, fetch, dockerode, GitHub SDK) and **wrap them into the port's declared error type** — callers never see a raw driver error. | +| `api/error-map.ts` | Map internal error codes → protocol errors (HTTP status / WS error frame). The **only** place internal→protocol translation happens. | +| `entry/` | Global last-resort handler: capture uncaught errors, log, exit/restart cleanly. | + +--- + +## 2. Patterns + +- **Typed over stringly**: domain errors carry a stable code from `contracts/errors.ts`, not just a message. +- **Wrap at the boundary**: adapters convert vendor errors to port errors so usecases stay vendor-agnostic (and the dependency direction holds). +- **No empty catch**: never swallow. Either handle (retry/fallback/translate) or rethrow with context. +- **No secrets in errors**: error messages/contexts must not include LLM keys, Git tokens, or request auth headers (see `logging-guidelines.md`). The Model Gateway / Git Broker must redact upstream errors before they propagate. +- **Result vs throw**: ⏳ pick one convention for expected/recoverable outcomes (e.g. a `Result<T, E>` type in `contracts/`) vs `throw` for truly exceptional cases — decide before the first usecases land, then keep it consistent. + +--- + +## 3. API error response shape (⏳ to finalize) + +A single envelope, validated by a shared Zod schema in `packages/shared`: + +```jsonc +{ "error": { "code": "SANDBOX_START_FAILED", "message": "human-readable", "details": {} } } +``` + +WS/stream errors use the same `code` so the frontend can branch consistently. Finalize the field set when the API surface stabilizes. + +--- + +## 4. Common mistakes to avoid + +- Leaking a raw Postgres / fetch error to the client (skips error mapping, exposes internals). +- Doing error logging inside `atoms/`/`usecases/` (logging is I/O — keep it at adapters/entry edges or inject a logger port). +- Returning HTTP status codes from `usecases/` (that's `api/`'s job — keeps business layer protocol-free). diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md new file mode 100644 index 0000000000..99730262fb --- /dev/null +++ b/.trellis/spec/backend/index.md @@ -0,0 +1,57 @@ +# Backend Development Guidelines + +> Conventions for backend development in this project (Bun + TypeScript). Read before writing any backend code. + +--- + +## ⭐ Start here: context-first atomic architecture (4+2) + +All backend code under `apps/server/` and `packages/*` follows the **context-first atomic architecture: bounded contexts using a 4-layer dependency model + 2 horizontal zones**: + +``` +api → context/usecases|workflows|services → context/atoms +contracts (declarations) · adapters (the only I/O) +``` + +→ **Read [`directory-structure.md`](./directory-structure.md) first** — it is the authoritative, normative spec (bounded context ownership, layer responsibilities, dependency rules, I/O isolation, "where does my code go?" decision flow). + +--- + +## Pre-Development Checklist + +Before writing backend code, confirm: + +- [ ] I know which **layer** my code belongs to (`directory-structure.md` §5 decision flow). +- [ ] Any I/O I need goes in `adapters/` behind a `contracts/ports` interface — not in usecases/api/atoms. +- [ ] I know the owning **bounded context** (`contexts/<context>`), and I am not importing private workflow/service code across contexts. +- [ ] I'm adding new behavior where that is clearer, or refactoring existing internals when that removes real duplication/obscurity. +- [ ] Shared orchestration stays in same-context `workflows/` or `services/`, not in cross-context usecase calls. +- [ ] I know the test I owe (atom = high-signal invariant/contract test when useful; usecase = mock-port). +- [ ] No secret (LLM key / Git token) will touch logs, the sandbox, or plaintext storage. + +## Quality Check + +Before marking work done, verify against [`quality-guidelines.md`](./quality-guidelines.md): + +- [ ] Architecture invariants intact (one-way deps, I/O only in adapters). +- [ ] Scanned for the 7 code smells; none introduced. +- [ ] Required tests pass; lint + type-check green. + +--- + +## Guidelines Index + +| Guide | Description | Status | +|-------|-------------|--------| +| [Repository Session Flow Contracts](./repository-session-flow-contracts.md) | Concrete API/env/secret/workspace/session/activity/diff contracts | Filled | +| [Directory Structure](./directory-structure.md) | **Context-first atomic 4+2 architecture** — bounded contexts, layers, deps, I/O isolation | ✅ Filled | +| [Quality Guidelines](./quality-guidelines.md) | Invariants, 7 code smells, testing, forbidden patterns | ✅ Filled | +| [Error Handling](./error-handling.md) | Error vocabulary, wrapping at boundaries, API error shape | ✅ Filled | +| [Database Guidelines](./database-guidelines.md) | Postgres store, typed SQL, access via ports | ✅ Filled | +| [Logging Guidelines](./logging-guidelines.md) | Structured logging, levels, **secret redaction** | ✅ Filled (⏳ lib to confirm) | + +> ⏳ items are recommendations from `docs/design/05-tech-stack-and-conventions.md` to finalize before the relevant code lands. They are decisions, not blanks. + +--- + +**Language**: all documentation and code identifiers are in **English** (project language policy; conversational replies may be Chinese). diff --git a/.trellis/spec/backend/logging-guidelines.md b/.trellis/spec/backend/logging-guidelines.md new file mode 100644 index 0000000000..59e2314b2b --- /dev/null +++ b/.trellis/spec/backend/logging-guidelines.md @@ -0,0 +1,48 @@ +# Backend Logging Guidelines + +> Structured logging conventions. Status: greenfield — ⏳ marks choices to finalize. **The "what NOT to log" section is security-critical** (doc 03 §6 threat model). + +--- + +## 1. Placement (hard rule) + +- Logging is **I/O** → it belongs at the edges: `adapters/` and `entry/`. +- `atoms/` must not log (they are pure). `usecases/` should not log directly — inject a `LoggerPort` from `contracts/ports` if a flow genuinely needs to emit an event, so the layer stays testable and pure-ish. +- The **activity stream** (supervisor↔CLI events shown in the UI) is a product event channel, not the same as ops logging — it flows through `contracts/events.ts` → Orchestrator → WS, and is persisted via the store adapter. Keep the two concerns separate. + +--- + +## 2. Structured logging + +- ⏳ Library: a structured JSON logger (e.g. `pino`); confirm Bun compatibility before adopting it. +- Every log line is structured with at least: `level`, `msg`, `sessionId`, `projectId`, `component` (e.g. `supervisor`, `model-gateway`, `sandbox-mgr`), and a correlation/`traceId`. +- Initialize the logger and tracing in `entry/` (composition root), inject downward as a port. + +## 3. Levels + +| Level | Use | +|---|---| +| `debug` | Local dev detail; off in normal runs | +| `info` | Lifecycle events: session start/stop, dispatch, verify result, branch/PR created | +| `warn` | Recoverable: CLI retry, failover triggered, context compaction | +| `error` | Failed operation needing attention: sandbox crash, upstream LLM error, broker failure | + +--- + +## 4. What NOT to log (security-critical) + +**Never** write any of these to logs (or to the activity stream, or to error contexts): + +- ❌ Real **LLM API keys** / `Authorization` / `x-api-key` headers. +- ❌ **GitHub OAuth / PAT / Git App tokens** or any remote Git credential. +- ❌ Session-level tokens that map to real keys. +- ❌ Full request bodies/responses that may carry the above. +- ❌ Owner private source code beyond what's needed for a diff summary. + +The Model Gateway and Git Credential Broker **must redact** before logging upstream errors. Treat any leak of the above as a security incident, not a cosmetic bug. This rule is also enforced in `quality-guidelines.md` §4. + +--- + +## 5. What to log (useful events) + +Session lifecycle, supervisor phase transitions (plan/dispatch/verify/iterate), CLI dispatch + outcome, verification pass/fail, best-of-N fan-out/adjudication, branch/PR creation, sandbox start/stop, quota/egress-policy hits. diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md new file mode 100644 index 0000000000..bba7435eeb --- /dev/null +++ b/.trellis/spec/backend/quality-guidelines.md @@ -0,0 +1,131 @@ +# Backend Quality Guidelines + +> Code-review standards and the quality bar for `apps/server/` and `packages/*`. +> Pairs with `directory-structure.md` (the context-first atomic 4+2 architecture). Status: active — these are the conventions every change is held to. + +--- + +## 1. Architecture invariants (must hold on every change) + +Restated from `directory-structure.md` §4 because a reviewer checks these first: + +1. **One-way deps**: `api → context usecases/workflows/services → atoms`. `adapters` not imported by `usecases`/`workflows`/`services`/`api`; only `entry` may import across all for assembly. +2. **I/O only in `adapters/`**. Any DB/HTTP/FS/cache/queue/subprocess/git call elsewhere is a defect. +3. **Application-layer code reaches external capability only via `contracts/ports`** (constructor/factory injection), never a direct adapter import. +4. **`api` holds no business rules**; `entry` holds no business logic. +5. **Context privacy holds**: same-context private workflows/services may support public usecases; cross-context usecase/workflow/service imports are forbidden. +6. **Atoms are pure** (no I/O, no shared mutable state) and small enough to read/review as one responsibility. +7. **Extend or refactor deliberately**: add new atoms/usecases/workflows/services for new behavior; refactor existing internals when it removes duplication, obscurity, or the wrong owner. + +A change breaking any invariant is fixed or explicitly justified in review — never merged silently. + +--- + +## 2. Code-smell watch-list (raise immediately when spotted) + +Whenever you write or review code, actively watch for these seven smells. **The moment you spot one, surface it and propose a fix** — don't let it merge. + +| # | Smell | What it looks like here | Typical fix | +|---|---|---|---| +| 1 | **Rigidity** | A small change forces a cascade of edits across layers | Re-check dependency direction; push volatile logic behind a `contracts/ports` interface | +| 2 | **Redundancy** | The same logic duplicated across usecases/workflows/services/atoms | Sink to a same-context service/workflow, pure `atom/`, or carefully controlled `_shared/` helper | +| 3 | **Circular dependency** | Two modules import each other, or private context helpers leak across contexts | Extract the shared part down to `atoms/`, a port/event, or an owning-context service | +| 4 | **Fragility** | Editing one spot breaks unrelated features | Tighten layer boundaries; add tests at the seam | +| 5 | **Obscurity** | Intent unclear, tangled structure | Rename to intent; split; add a one-line "why" where non-obvious | +| 6 | **Data clump** | The same group of params travels together across signatures | Group into a `contracts/dto` type | +| 7 | **Needless complexity** | A sledgehammer for a nut; over-engineering | Delete speculative generality; solve the problem in front of you | + +> Catching these early is cheaper than a refactor later. + +--- + +## 3. Testing requirements per layer + +| Layer | Required test | How | +|---|---|---| +| `atoms/` | High-signal unit/property tests for meaningful contracts | Pure in/out; no mocks needed (no I/O) | +| `usecases/` / `workflows/` / `services/` | **Application-layer tests (required for behavior)** | Mock the injected `contracts/ports`; assert orchestration/flow, not I/O | +| `api/` | Contract / route tests | Validate codec, validation, error mapping | +| `adapters/` | Integration tests (optional) | Hit a real/embedded dependency or `msw`-style mock (cf. cc-switch) | + +Test runner: `bun test` / Vitest (see doc 05 §6). + +### Higher-signal atom tests + +For pure atoms with broad input space (parsers, normalizers, path policies, +security allow/deny policy, text transforms), prefer bounded property-based, +metamorphic, or differential tests over example-only tables. Do not keep a +unit test just because a pure function exists: if the test only snapshots a +stable implementation detail, static prompt wording, presentation label, or +one-line pass-through, delete it or cover the user-visible contract at a more +useful boundary. + +**Contract**: +- Use `fast-check` with `bun:test` for generated checks. +- Keep generated tests deterministic and bounded: set a fixed `seed` and a + small `numRuns` that is cheap in normal `bun test`. +- Preserve explicit regression examples for security boundaries, protocol + examples, and bugs that need a readable named case. +- Keep low-volatility security defaults covered when a wrong value would be + dangerous, for example sandbox hardening, egress policy, or secret handling. +- Use metamorphic assertions when an input transformation should preserve the + result, for example normalization idempotence or line-order independence. +- Use differential assertions only when there is a real second oracle or shared + contract, for example a server wrapper matching a shared policy. + +**Good/base/bad cases**: +- Good: generated safe and unsafe shell command paths prove allow/deny + invariants across many command lines. +- Base: one named example remains for a concrete path escape or secret path. +- Bad: a property that reimplements the production algorithm line-for-line and + only proves the same code twice. + +**Example**: + +```typescript +const propertyOptions = { numRuns: 150, seed: 20260627 }; + +test("keeps normalization idempotent for generated input", () => { + fc.assert( + fc.property(fc.string({ maxLength: 120 }), (input) => { + const normalized = normalizeInput(input); + + expect(normalizeInput(normalized)).toBe(normalized); + }), + propertyOptions, + ); +}); +``` + +--- + +## 4. Naming and comment hygiene + +- File names, test names, comments, and docs should describe the behavior or responsibility they cover. Avoid internal roadmap labels, task names, implementation phases, or temporary shorthand when a product/domain name is available. +- Use comments only to explain a non-obvious why or boundary. Do not add comments that restate the code or memorialize planning context. + +--- + +## 5. Forbidden patterns + +- ❌ Importing `adapters/*` from `usecases/*` or `api/*`. +- ❌ Any I/O (fetch, db client, fs, child_process, redis, git) outside `adapters/`. +- ❌ Business branching / state transitions in `api/` or `entry/`. +- ❌ Importing another context's usecase/workflow/service directly. +- ❌ Promoting context-private helper logic into `_shared` before it is stable, generic, and protocol-free. +- ❌ Shared mutable module-level state in `atoms/`. +- ❌ **Any real LLM key / Git token written into the sandbox image, default env, or logs** (security-critical; see doc 03 §6 and `logging-guidelines.md`). Plaintext key storage anywhere is forbidden — use the encrypted key vault. +- ❌ Swallowing errors (empty catch) — see `error-handling.md`. + +--- + +## 6. Review checklist (before approving a change) + +- [ ] Dependency direction intact; no adapter leaks into usecases/api. +- [ ] All I/O lives behind a `contracts/ports` interface in `adapters/`. +- [ ] New behavior arrived in the correct context owner; refactors remove real duplication/obscurity rather than moving code around. +- [ ] Atoms are pure and small; application-layer code orchestrates via injected ports. +- [ ] Required tests present (atom unit / usecase mock-port). +- [ ] No secret touches logs, the sandbox, or plaintext storage. +- [ ] User-facing text, comments, file names, and test names use product/domain terms rather than roadmap shorthand. +- [ ] Scanned for the 7 smells; none introduced (or each flagged with a follow-up). diff --git a/.trellis/spec/backend/repository-session-flow-contracts.md b/.trellis/spec/backend/repository-session-flow-contracts.md new file mode 100644 index 0000000000..6f54ff9895 --- /dev/null +++ b/.trellis/spec/backend/repository-session-flow-contracts.md @@ -0,0 +1,1861 @@ +# Repository Session Flow Contracts + +> Concrete implementation contract for the GitHub -> workspace -> Claude Code session -> activity stream -> diff flow. + +## Scenario: Repository Session Flow + +### 1. Scope / Trigger + +- Trigger: this flow introduces cross-layer API contracts, local encrypted secret storage, Git workspace I/O, Docker sandbox I/O, model-gateway proxying, SSE activity streaming, and frontend/server shared DTOs. +- Applies to `apps/server/`, `apps/web/`, and `packages/shared` when extending credential, project, session, activity, diff, sandbox, or model-gateway behavior. + +### 2. Signatures + +- `POST /api/credentials` stores one secret alias. +- `GET /api/credentials` returns credential metadata only. +- `POST /api/repositories/authorize` stores one repository authorization record through `RepoAuthorizationPort`. +- `POST /api/projects` clones or fetches one GitHub repo into a persistent workspace. +- `GET /api/projects` returns project metadata. +- `POST /api/sessions` starts one Claude Code sandbox session. +- `POST /api/sessions/:sessionId/instructions` dispatches one instruction and records a diff. +- `GET /api/sessions/:sessionId/activity` returns persisted events. +- `GET /api/sessions/:sessionId/activity-stream` streams activity events as SSE. +- `GET /api/sessions/:sessionId/diff` returns the recorded diff. +- `GET /api/projects/:projectId/threads` lists session summaries for the sidebar. It must query supervisor runs through session-scoped store reads for the project's sessions rather than decoding every persisted run row. +- `ALL /api/model-gateway/anthropic/*` proxies Anthropic-compatible requests using a session-scoped model gateway token. +- Docker Claude Code dispatch runs `claude --print --output-format stream-json --session-id <sessionId> ... -- <instruction>` inside the existing session sandbox, with typed launch options mapped by the adapter. +- Docker session startup passes the session-scoped model gateway token through the Docker process environment and `-e ANTHROPIC_API_KEY`, not as a literal command argument. + +### 3. Contracts + +- Credential request fields: `alias`, `kind` (`github_pat` or `llm_api_key`), `secret`. +- Credential response fields: `alias`, `kind`, `status`, `updatedAt`; never return `secret`. +- Repository authorization fields: `id`, `provider`, `owner`, `repo`, normalized `repoSlug`, `mode`, `status`, `authorizedAt`, optional `tokenAlias`. Default composition must use the durable Janus store, not a process-local in-memory adapter. +- Project request fields: `provider: "github"`, `owner`, `repo`, `gitCredentialAlias`. +- GitHub `owner` and `repo` must be path-safe GitHub identifiers. The normalized repo slug is lower-case `owner/repo` and must never contain traversal segments. +- Session request fields: `projectId`, `llmCredentialAlias`, optional `image`. +- Session IDs generated for real runtime sessions must be UUIDs because Claude Code's `--session-id` flag requires a UUID. +- The sandbox receives only the model session token produced by `issueModelSessionToken(deps, sessionId)`; it must never receive the real LLM credential value. +- The model session token is a random, short-lived capability. Persist only its SHA-256 hash plus `sessionId`, `issuedAt`, `expiresAt`, and optional `revokedAt`; never persist the plaintext token or derive the token from `sessionId`. +- The model session token maps to a real control-plane credential, so treat it as sensitive: it may be injected into the sandbox environment, but it must not appear in Docker command arrays, API responses, logs, activity events, or error contexts. +- The model-gateway adapter must resolve upstream URLs only from origin-relative request paths. Legacy fallback pins the upstream base URL to `https://api.anthropic.com`; configured provider routing may use only HTTP(S) upstream base URLs, preserves any configured path prefix, and rejects absolute URLs, protocol-relative paths, backslashes, control characters, or path-prefix escapes before attaching real provider auth. +- Activity event fields: `id`, `sessionId`, `sequence`, `type`, `level`, `message`, `timestamp`. +- Claude Code `stream-json` / Codex JSON stdout must be parsed into structured `cli_output` activity messages; do not persist one raw unbounded stdout blob when line-level JSON can be decoded. +- Diff fields: `sessionId`, `files[]`, `patch`, `updatedAt`. +- Thread summary fields: `sessionId`, `projectId`, `cli`, `title`, `status`, `runCount`, `updatedAt`. Sessions without runs use title `"New session"`, status `"idle"`, `runCount: 0`, and the session `startedAt`. +- Untracked files must be represented both in `files[]` and in `patch`; newly created files should not produce an empty diff body. +- Env keys: `JANUS_RUNTIME_MODE`, `JANUS_DATA_DIR`, `JANUS_DATABASE_URL`, `JANUS_SQLITE_PATH`, `JANUS_VAULT_KEY`, `JANUS_MODEL_GATEWAY_URL`, `JANUS_ACCESS_TOKEN`, `HOST`, `PORT`. +- Runtime data crossing frontend/backend must be parsed with schemas from `packages/shared`. + +### 4. Validation & Error Matrix + +- Missing or malformed JSON -> `VALIDATION_FAILED` / HTTP 400. +- Missing single-user token on protected `/api/*` route -> `UNAUTHORIZED` / HTTP 401. +- Missing model session token on `/api/model-gateway/anthropic/*` or `/api/model-gateway/openai/*` -> `UNAUTHORIZED` / HTTP 401. +- Invalid, missing, revoked, or expired model session token capability -> `UNAUTHORIZED` / HTTP 401. +- Absolute/protocol-relative/invalid Anthropic proxy path -> `MODEL_GATEWAY_FAILED` / HTTP 502, before any upstream fetch or real key header is created. +- Invalid GitHub owner/repo or workspace path escaping the workspace root -> `VALIDATION_FAILED` / HTTP 400. +- Missing credential alias -> `CREDENTIAL_NOT_FOUND` / HTTP 404. +- Missing project -> `PROJECT_NOT_FOUND` / HTTP 404. +- Missing session or diff -> `SESSION_NOT_FOUND` / HTTP 404. +- Schema-incompatible supervisor run rows for the requested session -> fail that run/thread response through the shared schema parser. Unrelated run rows must not be decoded for session-scoped run or thread listing. +- Store failures while listing project threads, such as database or connection errors -> propagate normally. +- Missing or invalid `JANUS_VAULT_KEY` during secret read/write -> `CONFIGURATION_REQUIRED` / HTTP 500. +- Invalid `JANUS_RUNTIME_MODE` -> `CONFIGURATION_REQUIRED` / HTTP 500 before `Bun.serve`. +- Git clone/fetch/diff failure -> `WORKSPACE_SYNC_FAILED` / HTTP 500. +- Docker start failure -> persist the session as `failed`, emit `session_failed`, then return `SANDBOX_START_FAILED` / HTTP 500. +- Claude Code dispatch failure -> `CLI_DISPATCH_FAILED` / HTTP 500. +- Anthropic upstream failure -> `MODEL_GATEWAY_FAILED` / HTTP 502. + +### 5. Good/Base/Bad Cases + +- Good: store GitHub PAT and LLM key aliases, connect a repo, start a session, stream events, dispatch one instruction, and fetch a diff without exposing real secrets to responses or sandbox config. +- Good: repository authorization survives process restart because the default port is wired to the Janus store. +- Good: `JANUS_RUNTIME_MODE=dev` starts the API with SQLite and local dev runtime adapters without requiring Docker or Postgres. +- Good: opening a newly created session calls the session-scoped run listing path and is not affected by unrelated orphan or historical supervisor run rows. +- Base: service starts without `JANUS_VAULT_KEY`; health/access still work, but credential read/write fails with `CONFIGURATION_REQUIRED`. +- Base: omitting `JANUS_RUNTIME_MODE` uses `release` only when `NODE_ENV=production`; otherwise it uses `dev`. +- Bad: passing a real LLM key or Git token into Docker env, Docker command args, API responses, logs, activity events, or frontend storage. +- Bad: requiring Docker or Postgres for basic local UI/API testing in dev mode. +- Bad: accepting `janus_session_<sessionId>` as authentication. The token prefix is only a namespace; the capability must match a stored hash and be within TTL. + +### 6. Tests Required + +- Atom tests: sandbox policy, session-scoped model gateway token shape/hash, git diff parsing. +- Shared usecase tests: issue a model session token, persist only the hash, resolve the token before TTL, and reject expired or session-id-derived tokens. +- API/usecase tests: repository authorization normalizes `owner/repo`, returns no token value, and default composition writes through the durable store port. +- Usecase tests: project thread listing queries runs only for project sessions, session run listing uses the session-scoped store method, and store failures for requested sessions still propagate. +- Entry tests: startup config validates `JANUS_RUNTIME_MODE` and composition selects SQLite/local dev adapters for `dev`, Postgres/Docker adapters for `release`. +- Usecase tests with mock ports: project connect uses Git credential alias, session start passes only a session-scoped gateway token to sandbox, dispatch records activity and diff. +- API smoke: credential -> project -> session -> dispatch -> activity -> diff using fake Git/Docker/CLI/model ports. +- Adapter tests: Claude Code Docker dispatch includes `--session-id <sessionId>` and separates instruction text after `--` so prompt text cannot be parsed as CLI flags. +- Adapter tests: model gateway rejects attacker-controlled absolute/protocol-relative paths without calling fetch; Docker startup command output does not include the model session token; Git workspace refuses paths outside the workspace root and includes untracked patches. +- Quality scans: no adapter imports from `api/`, `usecases/`, or `atoms`; no real secret literals in committed code. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +await docker.start({ + env: { + ANTHROPIC_API_KEY: realLlmKey, + }, +}); +``` + +```ts +new URL(request.path, "https://api.anthropic.com"); +headers.set("x-api-key", realLlmKey); +``` + +```ts +["docker", "run", "-e", `ANTHROPIC_API_KEY=${modelSessionToken}`]; +``` + +#### Correct + +```ts +await sandboxSessionPort.startSessionSandbox({ + modelGatewayUrl, + modelSessionToken: await issueModelSessionToken(deps, sessionId), + hardening: buildSessionSandboxPolicy(), +}); +``` + +```ts +const upstream = resolveAnthropicUrl(provider.upstreamBaseUrl, request.path); +headers.set(provider.authMode === "bearer" ? "authorization" : "x-api-key", realLlmKey); +``` + +```ts +await runProcess({ + command: ["docker", "run", "-e", "ANTHROPIC_API_KEY"], + env: { ANTHROPIC_API_KEY: modelSessionToken }, +}); +``` + +The correct path gives the sandbox only a session-scoped gateway token. The model gateway maps that capability to the real key inside the trusted control plane. + +## Scenario: Session Titles and Rename + +### 1. Scope / Trigger + +- Trigger: session titles are a cross-layer shared DTO/API/UI contract and are persisted as part of `SessionRecord`. +- Applies when changing session creation, supervisor-run session creation, project thread summaries, session rename API, or sidebar session list UI. + +### 2. Signatures + +- `SessionRecord.title?: string` stores the user-facing session title. The field is optional for compatibility with older stored sessions. +- `POST /api/sessions` creates an explicit empty session with `title: "New session"`. +- `POST /api/supervisor-runs` creates a new session when `sessionId` is omitted with `title: "New session"`; the first running supervisor workflow then calls `SupervisorModelPort.generateSessionTitle({ task, modelOverride?, signal? })` once before normal supervisor work and saves the generated title. +- `PATCH /api/sessions/:sessionId` accepts `{ title }` and returns `{ session }`. +- `GET /api/projects/:projectId/threads` returns each thread title from the session record, not from the latest supervisor run. +- Manual rename and first-run automatic naming both append a durable `session_renamed` activity event for the affected session. + +### 3. Contracts + +- Rename request fields: `title: string`, trimmed, non-empty, max 120 characters. +- Rename response fields: `session: SessionRecord`. +- Thread summary fields remain `sessionId`, `projectId`, `cli`, `title`, `status`, `runCount`, `updatedAt`. +- Session title is the source of truth for the sidebar label. Supervisor run task text must not overwrite it after the first supervisor-model title generation. +- Existing sessions without `title` display `"New session"` until explicitly renamed or recreated. +- Supervisor title generation belongs to first-run startup application logic behind `SupervisorModelPort`, not a supervisor tool and not a deterministic local prompt trimmer. The model prompt must require a concise few-word title and a title-only response. +- `session_renamed` activity events use the standard activity event fields (`id`, `sessionId`, `sequence`, `type`, `level`, `message`, `timestamp`) and are the realtime invalidation signal for thread/sidebar title refreshes. The session record remains the source of truth for the title. + +### 4. Validation & Error Matrix + +- Missing or malformed JSON on `PATCH /api/sessions/:sessionId` -> `VALIDATION_FAILED` / HTTP 400. +- Empty, whitespace-only, or overlong `title` -> `VALIDATION_FAILED` / HTTP 400 before the usecase persists. +- Missing session on rename -> `SESSION_NOT_FOUND` / HTTP 404. +- Store failure while saving a renamed session -> propagate normally through API error mapping; do not silently keep a stale frontend title. +- Activity event append failure after a saved rename -> propagate normally rather than silently losing the realtime invalidation contract. + +### 5. Good/Base/Bad Cases + +- Good: first supervisor run creates or claims a `New session`, asks the supervisor model for a concise title, saves it before normal supervisor work, and later runs in the same session leave that title unchanged. +- Good: sidebar Rename switches only the selected row label into an input, persists through `PATCH /api/sessions/:sessionId`, and refreshes TanStack Query state. +- Good: manual rename and automatic first-run naming both emit `session_renamed`, so an open session activity stream can invalidate project thread data without inventing frontend-only title state. +- Base: old stored sessions with no title render as `"New session"` and remain renameable. +- Bad: deriving `ThreadSummary.title` from the latest run task; this couples session identity to run history. +- Bad: adding a supervisor rename tool or letting model output repeatedly rename a session. +- Bad: duplicating session title state in frontend stores instead of reading it from project thread query data. + +### 6. Tests Required + +- Shared schema tests or usecase coverage: rename rejects empty/invalid titles through the shared schema/API boundary. +- Usecase tests: rename persists a new title through `SessionStorePort` and missing sessions fail with `SESSION_NOT_FOUND`. +- Usecase tests: manual rename appends a `session_renamed` activity event through `ActivityEventPort`. +- Orchestrator workflow tests: new supervisor-created sessions and user-created placeholder sessions receive a supervisor-model-generated title at first run startup, and same-session follow-up runs do not change it. +- Orchestrator workflow tests: first-run automatic naming appends `session_renamed`. +- Session thread tests: `buildThreadSummary` uses `session.title` even when the latest run has a different task. +- Frontend checks: rename mutation invalidates or updates the project thread query and does not mirror server state into a long-lived local store. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const title = + latest === undefined ? "New session" : deriveThreadTitle(latest.task); +``` + +```ts +supervisorTools.push({ + name: "rename_session", + inputSchema: { title: "string" }, +}); +``` + +#### Correct + +```ts +const session: SessionRecord = { + id: sessionId, + projectId: project.id, + title: "New session", + cli: "claude-code", + status: "starting", + modelGatewayUrl, + startedAt, +}; +``` + +```ts +return { + ...thread, + title: session.title ?? "New session", +}; +``` + +The correct path makes session naming a session-owned source of truth, keeps one-time automatic naming outside the repeatable tool surface, and reserves later title changes for explicit user rename requests. + +## Scenario: Supervisor Model Streaming and Run Live Updates + +### 1. Scope / Trigger + +- Trigger: supervisor model calls can stream provider output, persist incremental transcript state, publish ephemeral run-live events, and fall back to non-streaming calls when a model does not support streaming. +- Applies when changing `SupervisorModelPort`, supervisor model adapters, supervisor run transcript persistence, run live SSE, retry behavior, or frontend run query freshness. + +### 2. Signatures + +- `SupervisorModelPort.completeTurn(request)` accepts `onStreamEvent?: (event) => Promise<void> | void`. +- `SupervisorModelPort.generateSessionTitle({ task, modelOverride?, signal? })` returns a short session title generated by the supervisor model. +- `GET /api/sessions/:sessionId/runs-stream` streams `SupervisorRunLiveEvent` SSE frames with `event: run`. +- `SupervisorRunLivePort.publish({ type: "run_updated", sessionId, run })` broadcasts latest run state in-process; it is not a durable store. + +### 3. Contracts + +- Streaming requests must send provider-native `stream: true` when `onStreamEvent` is present and the selected model has no `streamingDisabledAt` marker. +- The adapter must aggregate streamed provider deltas back into the existing `CompleteSupervisorTurnResult` shape so the tool loop remains provider-agnostic. +- Supported normalized stream events are text delta/done and thought delta/done. Thought content may only come from provider-exposed reasoning/thinking summary fields. +- Incremental assistant/thought transcript entries use `status: "streaming"` while active and `status: "completed"` with `completedAt` when finalized. +- Unsupported streaming is model-scoped: save `streamingDisabledAt` and optional `streamingDisabledReason` on the exact provider model alias, then retry the same turn through the non-streaming path. +- Updating an unchanged model preserves its streaming-disabled marker; changing that model clears the marker. Updating other models must not clear unrelated markers. +- Retry attempts for supervisor model calls publish persisted `supervisor_model_retry` activity events before waiting for the next attempt. + +### 4. Validation & Error Matrix + +- Missing supervisor model port -> `CONFIGURATION_REQUIRED` / run failure. +- Missing supervisor model credential -> `CREDENTIAL_NOT_FOUND`. +- Upstream non-retryable streaming unsupported status (`400`, `404`, `405`, `415`, `422`) -> mark model streaming disabled and fall back to non-streaming. +- Upstream retryable failures (`429`, `5xx`, network before response) -> retry the model call up to 5 total attempts, with visible `supervisor_model_retry` activity events. +- Abort/cancellation -> do not retry; cancellation must stop promptly. +- Invalid non-streaming JSON or invalid streaming JSON -> `MODEL_GATEWAY_FAILED`, secret-redacted. + +### 5. Good/Base/Bad Cases + +- Good: OpenAI Chat Completions request includes `stream: true`, emits text/thought deltas into the run transcript, and returns final text/tool calls to the tool loop. +- Good: a model returning `400 stream not supported` is marked streaming-disabled once and later runs skip streaming for that model. +- Good: the frontend subscribes to `runs-stream` and patches TanStack Query run data directly from shared-schema-validated events. +- Base: providers without `onStreamEvent` callers use the existing non-streaming behavior. +- Bad: adding UI-only "streaming" status while the adapter still waits for `response.json()`. +- Bad: writing every token delta as a persisted activity event; run-live events are ephemeral, while retry/status activity remains durable. +- Bad: exposing private chain-of-thought not present in provider-safe summary fields. + +### 6. Tests Required + +- Adapter tests: streaming request body includes `stream: true`; SSE text/thought/tool-call deltas aggregate to the final `CompleteSupervisorTurnResult`. +- Adapter tests: unsupported streaming status records a model-scoped disabled marker and falls back to non-streaming. +- Orchestrator tests: streamed deltas persist one in-progress transcript entry and finalization does not duplicate the assistant message. +- Orchestrator tests: transient model failures emit `supervisor_model_retry` activity and retry up to 5 total attempts; aborts do not retry. +- Frontend checks: run SSE events validate with `supervisorRunLiveEventSchema` before patching the session-runs query. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const payload = await response.json(); +return extractSupervisorResult(provider, payload); +``` + +```ts +await appendSessionEvent(deps, { + type: "cli_output", + message: tokenDelta, +}); +``` + +#### Correct + +```ts +await supervisorFetch(url, { + method: "POST", + body: JSON.stringify({ ...body, stream: true }), +}); +``` + +```ts +deps.supervisorRunLivePort.publish({ + type: "run_updated", + sessionId: run.sessionId, + run, +}); +``` + +The correct path keeps provider streaming in the model adapter, normalized run state in the orchestrator, durable user-visible retry status in activity events, and live token updates out of the persisted activity log. + +## Scenario: Workspace Git Source Control + +### 1. Scope / Trigger + +- Trigger: project workspaces expose local Git source-control state and actions through cross-layer HTTP/shared DTO contracts. +- Applies when changing project Git status, staging, unstaging, local commits, frontend Source Control UI, `GitSourceControlPort`, or `GitWorkspaceAdapter` local Git operations. + +### 2. Signatures + +- `GET /api/projects/:projectId/git/status` returns current local source-control state. +- `POST /api/projects/:projectId/git/stage` with `{ path }` stages one workspace-relative path. +- `POST /api/projects/:projectId/git/unstage` with `{ path }` unstages one workspace-relative path. +- `POST /api/projects/:projectId/git/stage-all` stages all workspace changes. +- `POST /api/projects/:projectId/git/commit` with `{ message }` commits currently staged changes and returns the new commit plus updated status. +- `GitSourceControlPort` owns these operations behind injected project records; subprocess Git I/O remains in `contexts/git-broker/adapters/git/`. + +### 3. Contracts + +- Status response fields: + - `branch: string` + - `stagedChanges: GitFileChange[]` + - `changes: GitFileChange[]` + - `clean: boolean` +- `GitFileChange` fields: `path`, optional `originalPath`, and `status` from the existing diff file status vocabulary (`added`, `modified`, `deleted`, `renamed`, `untracked`). +- Path request fields: `path: string`, non-empty, workspace-relative after normalization. +- Commit request fields: `message: string`, trimmed non-empty. +- Commit response fields: `commit: GitCommit` and `status: GitStatusResponse`. +- `GitCommit` may include `parentShas: string[]` when history is read through the Source Control panel. The Git adapter reads it from `%P`, shortens each parent to the UI/API short SHA length, and omits the field for root commits or legacy rows without parent data. +- Local commits use Janus identity: `user.name=Janus`, `user.email=janus@users.noreply.github.com`. +- Git command arguments must be passed as argv arrays, never shell-composed command strings. +- File operations must validate the normalized path stays inside `project.workspacePath` before invoking Git. +- No GitHub PAT is needed for these local operations, and no real Git token may be returned to the frontend or included in errors. + +### 4. Validation & Error Matrix + +- Missing project -> `PROJECT_NOT_FOUND` / HTTP 404 before any Git port call. +- Empty commit message -> `VALIDATION_FAILED` / HTTP 400 before any commit command. +- Empty or workspace-escaping path -> `VALIDATION_FAILED` / HTTP 400 before any Git command. +- Git status/stage/unstage/commit command failure -> `WORKSPACE_SYNC_FAILED` / HTTP 500 with a generic, secret-free message. +- Commit with no staged changes -> Git command failure mapped to `WORKSPACE_SYNC_FAILED`; the frontend should normally disable commit before this path. + +### 5. Good/Base/Bad Cases + +- Good: the Source Control sidebar loads status, stages one file, commits with a non-empty message, refreshes status, and shows the new commit without exposing any credentials. +- Good: the commit tree displays parent commit edges from `GitCommit.parentShas` and marks merge commits when more than one parent is present. +- Good: `git status --porcelain=v1 -z --untracked-files=all` parsing is pure atom logic; adapter code only performs Git subprocess I/O and path confinement. +- Base: a clean workspace returns empty staged/unstaged arrays and `clean: true`. +- Base: renamed porcelain entries preserve `originalPath` for future UI display even if the current UI only shows the new path. +- Bad: running `git add ${path}` through a shell string; this creates command-injection and path-parsing risk. +- Bad: implementing Git subprocess calls in API routes, usecases, or frontend hooks. +- Bad: adding destructive actions such as discard/reset without an explicit contract and confirmation policy. + +### 6. Tests Required + +- Atom tests: porcelain status parser splits staged, unstaged, untracked, and renamed records. +- Usecase tests with mock ports: missing project and empty commit message fail before the Git port is called. +- Adapter tests: status reads current branch plus porcelain output; stage/unstage/stage-all emit argv arrays; commit uses Janus identity; file paths cannot escape the workspace. +- Adapter tests: history parsing includes parent SHAs from `git log --format=%H%x1f%P...`. +- Frontend checks: Source Control mutations invalidate Git status, Git history, workspace tree, and workspace file queries after successful actions. +- Quality scans: architecture check must confirm Git I/O remains behind `GitSourceControlPort` in adapters. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +app.post("/api/projects/:projectId/git/stage", async (context) => { + await Bun.spawn(["sh", "-c", `git add ${context.req.query("path")}`]); +}); +``` + +```tsx +const [status, setStatus] = useState(await fetch("/git/status")); +``` + +#### Correct + +```ts +const response = await manageGitSourceControl.stageFile(projectId, body.path); +``` + +```ts +await runGit(["add", "--", cleanPath], project.workspacePath, processRunner); +``` + +```tsx +const statusQuery = useGitStatusQuery(projectId); +const stageFile = useStageGitFileMutation(projectId); +``` + +The correct path keeps protocol parsing in `api/`, project lookup and validation in a usecase, Git subprocess I/O in the adapter, pure porcelain parsing in an atom, and frontend state in TanStack Query. + +## Scenario: Release Blocker Safety Contracts + +### 1. Scope / Trigger + +- Trigger: release blockers around model-gateway capability lifetime, Docker/session teardown, Docker session egress, frontend access-token forwarding, subprocess hangs, and GitHub PR base branches. +- Applies when changing model session token issuance/resolution/revocation, sandbox hardening policy, sandbox teardown, worktree cleanup, process execution adapters, protected web/API requests, verification command execution, or GitHub pull-request creation. + +### 2. Signatures + +- `ModelSessionTokenPort.saveModelSessionToken(record)` stores `{ tokenHash, sessionId, issuedAt, expiresAt, revokedAt? }`. +- `ModelSessionTokenPort.getModelSessionTokenByHash(tokenHash)` returns a token record or `undefined`. +- `ModelSessionTokenPort.revokeModelSessionTokensForSession(sessionId, revokedAt)` marks all non-revoked capabilities for a session as revoked and returns the count. +- `issueModelSessionToken(deps, sessionId)` returns a plaintext session capability token once and stores only the hash. +- `issueSupervisorRunModelSessionToken(deps, sessionId)` issues the same hash-only session capability token with a run-scoped TTL. +- `resolveModelSessionToken(deps, token)` returns `sessionId` only for a stored, non-revoked, non-expired token hash. +- `SandboxSessionPort.stopSessionSandbox({ sandboxId })` removes a running session sandbox. +- `RunProcessRequest` supports optional `timeoutMs` and `signal`; `runProcess` returns exit code `124` on timeout and `130` on cancellation. +- `runProcess` inherits only the explicit process environment allowlist required for shell/Docker operation plus caller-provided `env` overrides; Janus control-plane env values are not inherited by default. +- `VerificationPort.runCommand({ sessionId, sandboxId, command })` passes a finite timeout to the process runner. +- `PullRequestPort.createPullRequest(...)` must read GitHub repository metadata and use `default_branch` as the PR base. +- `buildSessionSandboxPolicy()` returns the effective session sandbox policy, including `networkMode: "bridge"` and `egressAllowlist: ["host.docker.internal"]`. +- `SandboxEgressGuardPort.applyEgressGuard({ sandboxId, destinations })` returns runtime egress status `{ mode: "enforced" | "dev_noop", allowedDestinations[], detail, warning? }`. +- `SandboxEgressGuardPort.teardownEgressGuard({ sandboxId })` removes egress enforcement rules before the sandbox container is removed. +- `resolveServerStartupConfig(env)` computes `{ hostname, port, createServerOptions }`; no-token startup binds to loopback, and public binds require `JANUS_ACCESS_TOKEN`. +- Docker session startup adds `--add-host host.docker.internal:host-gateway` and passes the session-scoped model gateway token through process env, not command args. +- On Linux, Docker session startup reconciles workspace write access: it resolves the workspace dir's numeric owner and runs the container as that `uid:gid` (overriding the policy default) so the sandbox process and the host control-plane git operations share write access without hitting git "dubious ownership". If the workspace is owned by root (Janus runs as root), it instead `chown -R`s the workspace to the policy sandbox user and keeps `--user 10001:10001`. Non-Linux hosts keep the policy default because bind-mount permissions are not enforced there. +- `JANUS_REQUIRE_EGRESS_ENFORCEMENT` (bool, default off) makes `startSessionSandbox` fail closed: if the started container's runtime egress mode is not `enforced`, the partially-started container is torn down and startup fails with `SANDBOX_START_FAILED`. Wired from `resolveServerStartupConfig` -> `createServer({ requireEgressEnforcement })` -> `DockerSandboxSessionAdapter({ requireEnforcedEgress })`. + +### 3. Contracts + +- Default model session token TTL is short-lived: 15 minutes unless a test or composition explicitly overrides it. Supervisor runs use a longer run-scoped default TTL and must revoke the capability in `finally` when the run/candidate session ends. +- Token persistence is hash-only. Plaintext model session tokens must not be stored in the Janus store, API responses, activity events, logs, or error contexts. +- Direct supervisor runs must stop the session sandbox in `finally`; best-of-N candidate sessions must stop their candidate sandbox in candidate-level `finally`. +- Cleanup failures must not overwrite the already-persisted terminal run/session state. They should be isolated to the cleanup boundary and must not expose secrets. +- Empty sandbox hardening defaults to `networkMode: "none"` and no `egressAllowlist`. +- Session sandbox hardening defaults to `networkMode: "bridge"` plus `egressAllowlist: ["host.docker.internal"]` so Claude Code and Codex can reach the trusted control-plane Model Gateway through the session-token boundary. +- The session `egressAllowlist` is enforced on Linux by host firewall rules scoped to the sandbox container and the Model Gateway host:port. Non-Linux dev hosts must return runtime egress mode `dev_noop` with warning code `egress_enforcement_dev_noop`. +- Runtime snapshots include optional `egress` status. `mode: "enforced"` should not emit an egress warning; `mode: "dev_noop"` must emit a warning; missing egress status with an allowlist falls back to legacy `egress_allowlist_label_only`. +- Docker session startup must reject policies that cannot reach the local Model Gateway, including `networkMode: "none"` or a missing `host.docker.internal` allowlist. +- Protected API token comparison uses `crypto.timingSafeEqual` over fixed-length hashes. Empty/missing access token fails closed unless `allowDevWithoutToken` is explicitly enabled by loopback startup/test wiring. +- Frontend protected API/SSE calls use `VITE_JANUS_ACCESS_TOKEN` only as an in-memory build-time env value and send it as `x-janus-access-token`; do not write it to client storage or logs. +- CLI dispatch and verification commands must have finite process timeouts so a stuck CLI or shell command cannot hang the supervisor loop indefinitely. +- GitHub PR creation uses repository `default_branch`, not a hard-coded branch name. + +### 4. Validation & Error Matrix + +- Model session token missing on gateway route -> `UNAUTHORIZED` / HTTP 401. +- Model session token hash absent, revoked, or expired -> `UNAUTHORIZED` / HTTP 401. +- Docker session policy cannot reach `host.docker.internal` on the expected bridge path -> `SANDBOX_START_FAILED` / HTTP 500. +- Linux egress guard cannot inspect the container or apply firewall rules -> `SANDBOX_START_FAILED` / HTTP 500 and the partially-started container is removed. +- Egress guard teardown failure -> `SANDBOX_STOP_FAILED` / HTTP 500 at the adapter boundary after Docker removal is attempted. +- Docker session stop failure -> `SANDBOX_STOP_FAILED` / HTTP 500 at the adapter boundary; supervisor cleanup ignores it after persisting terminal state. +- Missing access token with public `HOST` binding -> `CONFIGURATION_REQUIRED` before `Bun.serve`. +- Verification process timeout -> result `exitCode: 124`; supervisor treats the command as failed verification output, not a hung loop. +- CLI process cancellation -> result `exitCode: 130`. +- GitHub repository metadata lookup fails before PR creation -> `PULL_REQUEST_FAILED` / HTTP 502. +- GitHub PR response missing `html_url` or `number` -> `PULL_REQUEST_FAILED` / HTTP 502. + +### 5. Good/Base/Bad Cases + +- Good: session startup issues a random `janus_session_*` token, stores only its hash with a 15-minute expiry, injects it through process env, and resolves it at the gateway before upstream auth is attached. +- Good: a supervisor run issues a run-scoped session capability token, completes/fails/max-exhausts, revokes all session capabilities, and removes the session sandbox. +- Good: session sandbox starts with `--network bridge`, `--add-host host.docker.internal:host-gateway`, and only session-scoped model gateway tokens in process env so the CLI can reach the local Model Gateway without receiving real keys. +- Good: on Linux, the Docker session adapter applies `DOCKER-USER` allow/reject rules so only the Model Gateway host:port is reachable, and removes those rules before `docker rm -f`. +- Good: on non-Linux dev, runtime snapshots expose `egress.mode: "dev_noop"` and warning `egress_enforcement_dev_noop`. +- Good: no access token starts only on loopback; public binds require `JANUS_ACCESS_TOKEN`, and token checks use timing-safe fixed-length comparison. +- Good: child processes inherit `PATH`/Docker client env only; `JANUS_ACCESS_TOKEN`, `JANUS_VAULT_KEY`, and other control-plane secrets are not inherited unless explicitly passed. +- Good: CLI JSON stdout creates multiple structured `cli_output` events instead of one raw blob. +- Good: empty sandbox starts with `--network none`; it remains the no-network inspection/template path. +- Good: a verification command that never exits is killed by the process runner and recorded as a failed verification result. +- Good: a repo with default branch `trunk` gets a PR body with `base: "trunk"`. +- Good: on Linux, a workspace cloned by a non-root host user (e.g. uid 1000) is mounted into a container started with `--user 1000:1000`, so the CLI worker can edit files and produce a non-empty diff. +- Good: with `JANUS_REQUIRE_EGRESS_ENFORCEMENT=1` on a non-Linux host, `startSessionSandbox` tears down the container and fails with `SANDBOX_START_FAILED` instead of running with `dev_noop` egress. +- Bad: `janus_session_${sessionId}` authenticates gateway traffic. +- Bad: leaving `sleep infinity` session containers or non-revoked model-session tokens after a supervisor terminal state. +- Bad: treating `egressAllowlist: ["host.docker.internal"]` alone as real network enforcement, omitting egress runtime status, or leaving firewall rules behind after sandbox teardown. +- Bad: comparing access tokens with `===` or allowing a no-token public bind by default. +- Bad: mounting a host-cloned workspace into a container hardcoded to a uid that does not own the workspace, so the CLI worker cannot write it and the run produces an empty diff. +- Bad: spreading `Bun.env` into every child process. +- Bad: storing raw `stream-json` stdout as one activity event blob when structured message lines are available. +- Bad: waiting on `process.exited` without timeout/cancel for CLI dispatch or verification. +- Bad: hard-coding `base: "main"` for every GitHub PR. + +### 6. Tests Required + +- Token usecase tests: hash-only persistence, short TTL, successful resolve before expiry, expired-token rejection, session-id-derived token rejection, and revoke-by-session rejection. +- Supervisor usecase tests: direct run calls token revoke and sandbox stop after success/failure; best-of-N candidates revoke and stop each candidate sandbox. +- Sandbox atom tests: session policy uses `bridge` with `host.docker.internal`; empty sandbox policy uses `none`. +- Sandbox adapter tests: session command uses `--network bridge` and `--add-host host.docker.internal:host-gateway`, command output does not contain the model token, policies that cannot reach the local gateway are rejected, Linux egress rules are applied/removed, non-Linux returns `dev_noop`, and stop removes the container with a bounded process timeout. +- Runtime snapshot tests: enforced egress has no egress warning; dev-noop egress includes `egress_enforcement_dev_noop`; stronger isolation warning remains. +- Process runner tests: timeout returns `124`; abort signal returns `130`; stdout/stderr are still drained; control-plane env is not inherited; explicit env overrides are passed. +- API/access tests: missing configured token fails closed, explicit dev mode allows loopback/test use, header/cookie tokens pass, wrong-length tokens reject without compare errors, and public no-token startup is refused. +- Activity tests: stream-json stdout lines produce separate structured `cli_output` messages; plain stdout remains a single bounded message. +- Verification adapter tests: Docker command shape and timeout propagation. +- Frontend tests or review checks: API and SSE paths both use `buildApiHeaders` so `VITE_JANUS_ACCESS_TOKEN` reaches protected routes. +- GitHub adapter tests: mock metadata `default_branch` and assert PR create payload uses it. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const token = `janus_session_${sessionId}`; +``` + +```ts +command.push("--network", "bridge", "--label", "janus.egress-allowlist=github.com"); +``` + +```ts +const [exitCode] = await Promise.all([process.exited]); +``` + +```ts +body: JSON.stringify({ head: branchName, base: "main" }); +``` + +#### Correct + +```ts +const token = await issueModelSessionToken(deps, sessionId); +``` + +```ts +try { + await runSupervisorLoop(...); +} finally { + await revokeModelSessionTokensForSession(deps, sessionId); + await sandboxSessionPort.stopSessionSandbox({ sandboxId }); +} +``` + +```ts +if (!canReachSessionModelGateway(request.hardening)) { + throw new JanusError("SANDBOX_START_FAILED", "Docker session sandbox must allow access to the local Janus Model Gateway.", 500); +} +``` + +```ts +await runProcess({ command, timeoutMs: 10 * 60 * 1000 }); +``` + +```ts +const base = await readDefaultBranch(request); +body: JSON.stringify({ head: branchName, base }); +``` + +The correct path treats session gateway tokens as real capabilities, enforces session egress on Linux with an honest dev-noop status elsewhere, keeps long-running subprocesses bounded, and adapts PR creation to each repository. + +## Scenario: Dev and Release Runtime Modes + +### 1. Scope / Trigger + +- Trigger: Janus has two runtime modes so contributors can test the UI/API locally without Docker or Postgres while release deployments keep the durable Postgres and Docker sandbox boundaries. +- Applies when changing server startup config, entry composition, store adapter selection, sandbox/CLI/verification adapters, env docs, or mode-sensitive tests. + +### 2. Signatures + +- `readServerStartupEnv(env)` reads `JANUS_RUNTIME_MODE`, `JANUS_DATA_DIR`, `JANUS_DATABASE_URL`, `JANUS_SQLITE_PATH`, `JANUS_ACCESS_TOKEN`, `JANUS_VAULT_KEY`, `JANUS_MODEL_GATEWAY_URL`, `JANUS_REQUIRE_EGRESS_ENFORCEMENT`, `HOST`, `PORT`, and `NODE_ENV`. +- `resolveServerStartupConfig(env)` returns `{ hostname, port, createServerOptions }` and sets `createServerOptions.runtimeMode` to `"dev"` or `"release"`. +- `CreateServerOptions.runtimeMode?: "dev" | "release"` controls the default adapter graph in `createServerPorts(options)`. +- `CreateServerOptions.sqlitePath?: string` configures the dev SQLite database path. +- `CreateServerOptions.databaseUrl?: string` configures the release Postgres connection string. +- `createServerPorts(options)` wires one store adapter to all Janus store ports and chooses runtime adapters by `runtimeMode`. + +### 3. Contracts + +- `JANUS_RUNTIME_MODE=dev` selects `SqliteJanusStoreAdapter(options.sqlitePath ?? join(dataDir, "janus.sqlite"))` plus local dev sandbox, CLI, interactive CLI, and verification adapters. +- `JANUS_RUNTIME_MODE=release` selects `PostgresJanusStoreAdapter(options.databaseUrl ?? "postgres://postgres:postgres@127.0.0.1:5432/janus")` plus Docker sandbox/session, Docker CLI, tmux, and Docker verification adapters. +- When `JANUS_RUNTIME_MODE` is omitted, `NODE_ENV=production` selects `release`; every other environment selects `dev`. +- `JANUS_DATA_DIR` defaults to `.janus-dev` and owns local workspaces, helpers, vault storage, and the default dev SQLite file path. +- `JANUS_SQLITE_PATH` is dev-mode storage configuration. Release mode must not use it as a fallback for Postgres. +- `JANUS_DATABASE_URL` is release-mode storage configuration. Dev mode must not require it for health, project listing, or basic UI/API testing. +- Explicit port overrides in `CreateServerOptions` still win over mode defaults. Tests may inject fake ports without changing runtime mode. +- Mode selection belongs in `entry/startup-config.ts` and `entry/composition/ports.ts`. Usecases, workflows, services, atoms, and API handlers must depend on ports only. + +### 4. Validation & Error Matrix + +- `JANUS_RUNTIME_MODE` is neither `dev` nor `release` -> `CONFIGURATION_REQUIRED` / HTTP 500 before `Bun.serve`. +- Public `HOST` binding without `JANUS_ACCESS_TOKEN` -> `CONFIGURATION_REQUIRED` / HTTP 500 before `Bun.serve`. +- Dev mode with no Docker or Postgres installed -> startup and store-backed UI/API flows use SQLite/local dev adapters, not Docker/Postgres. +- Release mode with missing or unreachable Postgres -> store-backed routes fail at the Postgres adapter boundary; do not silently fall back to SQLite or in-memory persistence. +- Release mode with Docker sandbox startup failure -> persist the session as `failed`, emit `session_failed`, then return `SANDBOX_START_FAILED` / HTTP 500. +- Dev mode sandbox/CLI/verification execution -> return local dev results with runtime `local_dev` / egress `dev_noop`, not fake Docker command output. + +### 5. Good/Base/Bad Cases + +- Good: a contributor runs the server without `JANUS_RUNTIME_MODE`, Docker, or Postgres; startup defaults to dev, stores data in `.janus-dev/janus.sqlite`, and local dev adapters make session flows observable. +- Good: a release deployment sets `JANUS_RUNTIME_MODE=release` and `JANUS_DATABASE_URL`; all Janus store ports share the Postgres adapter and sandbox work uses Docker boundaries. +- Good: a composition test passes `sqlitePath: ":memory:"` with `runtimeMode: "dev"` and verifies the durable store port behavior without starting Docker. +- Base: `NODE_ENV=production` with no explicit runtime mode selects release and therefore expects release dependencies. +- Bad: a usecase checks `process.env.JANUS_RUNTIME_MODE` to skip work or choose a persistence implementation. +- Bad: release startup catches a Postgres error and silently creates a SQLite store. +- Bad: dev mode returns Docker-shaped command arrays that imply real sandbox isolation when execution was skipped. + +### 6. Tests Required + +- Startup config tests: explicit `dev`, explicit `release`, omitted mode with and without `NODE_ENV=production`, invalid mode error, and public bind token enforcement. +- Composition tests: dev mode uses SQLite and local dev runtime adapters; release mode uses Postgres and Docker runtime adapters; explicit port overrides still win. +- Store adapter tests: SQLite supports the same Janus store port surface needed by dev UI/API flows, including project/session persistence, activity sequence allocation, model gateway routing, model-session tokens, runtime snapshots, approval requests, supervisor runs, and repository authorization. +- Regression tests: dev mode session startup emits `local_dev` runtime snapshots with `dev_noop` egress and no Docker dependency. +- Quality gates: `bun run lint`, `bunx tsc -b`, `bun run --cwd apps/server test`, `bun run --cwd apps/web typecheck`, and `bun run --cwd apps/web build`. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const storeAdapter = new PostgresJanusStoreAdapter(databaseUrl); +``` + +```ts +if (process.env.JANUS_RUNTIME_MODE === "dev") { + return skippedSessionResult; +} +``` + +#### Correct + +```ts +const storeAdapter = + runtimeMode === "release" + ? new PostgresJanusStoreAdapter(databaseUrl) + : new SqliteJanusStoreAdapter(sqlitePath); +``` + +```ts +await createSessionUsecase({ + sessionStorePort, + sandboxSessionPort, + cliSessionPort, +}); +``` + +The correct path keeps runtime-mode branching in entry composition, preserves the same usecase contracts in both modes, and makes dev convenience explicit instead of weakening release boundaries. + +## Scenario: Janus Store Release Postgres Persistence + +### 1. Scope / Trigger + +- Trigger: release mode uses Postgres persistence for projects, sessions, activity, supervisor runs, model gateway routing, runtime state, model-session tokens, and repository authorization. +- Applies when changing `contexts/_shared/adapters/store/postgres/*`, store port signatures, default composition wiring, schema initialization/migrations, or data retention behavior. + +### 2. Signatures + +- Release composition creates `PostgresJanusStoreAdapter(databaseUrl)` and injects that same adapter for all Janus store ports unless a test or caller overrides a specific port. +- `JANUS_DATABASE_URL` configures the release control-plane Postgres connection string. When omitted in release mode, Janus defaults to `postgres://postgres:postgres@127.0.0.1:5432/janus`. +- `PostgresJanusStoreAdapter(databaseUrl, now?)` implements `ProjectStorePort`, `SessionStorePort`, `ActivityEventPort`, `SupervisorRunStorePort`, `ModelGatewayStorePort`, `ModelSessionTokenPort`, `RuntimeStatePort`, and `RepoAuthorizationPort`. +- Tables: `projects`, `sessions`, `session_diffs`, `runtime_snapshots`, `approval_requests`, `activity_events`, `activity_event_sequences`, `supervisor_runs`, `model_providers`, `active_model_gateway_routes`, `model_provider_health`, `model_session_tokens`, and `repo_authorizations`. + +### 3. Contracts + +- The adapter lazily initializes the schema on first store use, so `/api/health` and access checks do not require an immediate database connection. +- Each aggregate has its own table. Stable lookup/order fields are scalar columns; nested records are stored as `jsonb` in `body` and decoded with shared Zod schemas on read. +- Indexed access paths include project repo slug, session start time, approval request session/request time, activity event session/sequence, model provider priority/name, model session token session/expiry, and repository authorization id. +- `activity_events` must enforce unique `(session_id, sequence)` values. `nextSequence(sessionId)` allocates sequence numbers through the `activity_event_sequences` table with one Postgres upsert/returning statement, not `MAX(sequence)` read-then-return. +- Model-session tokens store only `token_hash`, `session_id`, `issued_at`, `expires_at`, and nullable `revoked_at`. Expired tokens are pruned during token save/read paths. +- Repository authorization persists in Postgres in release mode. Do not reintroduce a process-local default authorization adapter. +- The legacy single-file `store.json` adapter is not a supported default persistence path. +- SQLite is supported only for dev-mode local testing and must not become the release fallback when Postgres is missing or unreachable. + +### 4. Validation & Error Matrix + +- Malformed JSON `body` or schema-incompatible records -> the adapter read fails through the shared schema parser; callers must treat this as store corruption, not silently drop records. +- Duplicate activity event `session_id` + `sequence` -> Postgres constraint failure; fix the caller/sequence allocation rather than overwriting an event. +- Expired model-session token lookup -> return `undefined` after pruning expired token rows. +- Revoked model-session token lookup -> return the stored row with `revokedAt`; gateway resolution rejects it at the token usecase boundary. +- Missing/unreachable Postgres service in release mode -> store-backed routes fail at the adapter boundary; fix deployment configuration rather than falling back to SQLite or a process-local store. + +### 5. Good/Base/Bad Cases + +- Good: restart a release-mode server with the same `JANUS_DATABASE_URL`; projects, sessions, model gateway route, repo authorization, runtime state, and supervisor runs survive in Postgres. +- Good: background activity appends and HTTP reads operate against per-record Postgres writes rather than a whole-file read-modify-write cycle. +- Good: concurrent `nextSequence("session-1")` calls allocate `0..n` without duplicates. +- Base: an expired model-session token may remain until the next save/read sweep; the next token access prunes it. +- Bad: a store adapter reads the full persisted state into memory, mutates arrays, and rewrites one `store.json` file. +- Bad: `nextSequence` calculates `MAX(sequence) + 1` outside a database write; two callers can allocate the same sequence. +- Bad: release mode falls back to `.janus-dev/janus.sqlite` because Postgres was not reachable. +- Bad: a usecase imports `postgres`, `drizzle`, or any database client directly. + +### 6. Tests Required + +- Adapter tests: records persist across two `PostgresJanusStoreAdapter` instances using the same database. +- Adapter tests: multiple record writes keep all records and do not rely on whole-store rewrite semantics. +- Adapter tests: `nextSequence` allocates unique contiguous sequences for the same session through multiple adapter instances. +- Adapter tests: expired model-session tokens are pruned and revoke-by-session returns the changed row count. +- Adapter tests: repository authorization is stored in the `repo_authorizations` table and validates through the shared schema. +- Quality gates: `bun run lint`, `bun run arch:check`, `bun run typecheck`, `bun test`, and `bun run build`. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const latest = await store.listEvents(sessionId); +return latest.length; +``` + +```ts +await writeFile("store.json", JSON.stringify({ ...wholeStore, events })); +``` + +#### Correct + +```ts +await sql` + INSERT INTO activity_event_sequences (session_id, next_sequence) + VALUES (${sessionId}, 1) + ON CONFLICT (session_id) DO UPDATE + SET next_sequence = activity_event_sequences.next_sequence + 1 + RETURNING next_sequence - 1 AS sequence +`; +``` + +```ts +const storeAdapter = new PostgresJanusStoreAdapter(databaseUrl); +``` + +The correct path keeps persistence I/O inside `_shared/adapters/store/postgres`, preserves existing store ports for usecases, and makes Postgres the MVP system of record. + +## Scenario: Runtime Safety and Approval Observability + +### 1. Scope / Trigger + +- Trigger: M5 adds runtime observability and approval state across shared DTOs, HTTP routes, runtime-state persistence, sandbox/session/supervisor usecases, and frontend server state. +- Applies when changing sandbox hardening snapshots, session runtime health, approval request lifecycle, or the runtime safety UI. + +### 2. Signatures + +- `GET /api/sessions/:sessionId/runtime` returns session runtime health, optional runtime snapshot, and approval requests for one session. +- `GET /api/sessions/:sessionId/approval-requests` lists approval requests for one session. +- `POST /api/sessions/:sessionId/approval-requests` creates one pending approval request. +- `POST /api/sessions/:sessionId/approval-requests/:approvalRequestId/approve` approves one pending request. +- `POST /api/sessions/:sessionId/approval-requests/:approvalRequestId/deny` denies one pending request. +- `RuntimeStatePort` owns `saveRuntimeSnapshot`, `getRuntimeSnapshot`, `saveApprovalRequest`, `getApprovalRequest`, and `listApprovalRequests`. +- Sandbox startup usecases record a runtime snapshot immediately after the sandbox starts and before emitting `sandbox_started`. + +### 3. Contracts + +- Runtime snapshot fields: `sessionId`, `sandboxId`, `runtime: "docker"`, `hardening`, `isolation[]`, `warnings[]`, `recordedAt`. +- Isolation entries must report hardened Docker as `active`; gVisor and Firecracker must be reported as `not_configured` or `unavailable` until a real runtime integration exists. +- Runtime warnings are policy metadata only. They must not include real LLM keys, Git tokens, model session token values, raw Docker output, request headers, or private source. +- Runtime health fields: `sessionId`, `sessionStatus`, `status`, `failureCount`, optional `latestFailure`, `policyWarningCount`, `pendingApprovalCount`, `updatedAt`. +- Runtime health status values are `not_started`, `healthy`, `warning`, `failed`, and `completed`. A completed session reports `completed` even when policy warnings remain; clients should read `policyWarningCount` and `snapshot.warnings` for warning detail. +- Approval request fields: `id`, `sessionId`, `source`, `actionKind`, `riskLevel`, `title`, `description`, `status`, `requestedAt`, optional `decidedAt`, optional `decisionNote`. +- Approval lifecycle is `pending -> approved` or `pending -> denied`. Terminal states are immutable. +- Approval decisions record `decidedAt`; `decisionNote` is optional and must be user/operator text, not raw command output or secrets. +- Frontend runtime data must flow through TanStack Query and parse responses with `packages/shared` runtime schemas. + +### 4. Validation & Error Matrix + +- Missing or malformed approval payload -> `VALIDATION_FAILED` / HTTP 400. +- Missing session on runtime or approval routes -> `SESSION_NOT_FOUND` / HTTP 404. +- Missing approval request or request belonging to another session -> `APPROVAL_REQUEST_NOT_FOUND` / HTTP 404. +- Approving or denying a non-pending request -> `APPROVAL_REQUEST_ALREADY_RESOLVED` / HTTP 409. +- Sandbox startup failure -> no runtime snapshot is recorded; persist the session as `failed`, emit `session_failed`, then return `SANDBOX_START_FAILED` / HTTP 500. + +### 5. Good/Base/Bad Cases + +- Good: a session starts, stores one runtime snapshot with effective hardening policy, returns runtime health, creates an approval request, approves it once, and rejects a second terminal decision. +- Base: a session exists before sandbox startup completes; runtime health returns `not_started` with no snapshot. +- Bad: claiming gVisor or Firecracker is active when local Docker is the only configured runtime, or storing raw CLI/Docker output in approval descriptions. + +### 6. Tests Required + +- Atom tests: runtime snapshot serialization, honest isolation status, warning derivation, approval state transition immutability, and health status derivation. +- Usecase tests with mock ports: create/list/approve/deny approval requests, missing session/request errors, and runtime response aggregation from session, events, snapshots, and approvals. +- API smoke tests: runtime and approval routes parse shared schemas, terminal approval decisions return `APPROVAL_REQUEST_ALREADY_RESOLVED`, and runtime responses never expose secrets. +- Frontend checks: runtime panel consumes `sessionRuntimeResponseSchema`, invalidates the runtime query after session/run start and approval decisions, and does not duplicate server state in local state. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +await runtimeStatePort.saveRuntimeSnapshot({ + runtime: "firecracker", + warnings: [], +}); +``` + +```ts +await runtimeStatePort.saveApprovalRequest({ + status: "approved", + description: rawCliOutput, +}); +``` + +#### Correct + +```ts +await runtimeStatePort.saveRuntimeSnapshot( + buildSessionRuntimeSnapshot({ + sessionId, + sandboxId, + hardening: buildSessionSandboxPolicy(), + recordedAt, + }), +); +``` + +```ts +await runtimeStatePort.saveApprovalRequest( + transitionRuntimeApprovalRequest({ + approvalRequest, + status: "approved", + decidedAt, + note, + }), +); +``` + +The correct path records the effective Docker hardening policy honestly, keeps terminal approval decisions immutable, and exposes runtime state through shared schemas without leaking control-plane credentials. + +## Scenario: Model Gateway Provider Routing + +### 1. Scope / Trigger + +- Trigger: M3 adds control-plane provider routing for Supervisor, Claude Code, and Codex model traffic across shared DTOs, HTTP routes, Janus store persistence, model-gateway usecases, upstream HTTP adapter behavior, and frontend server state. +- Applies when changing provider config, provider test calls, active model route selection, Anthropic/OpenAI proxy routing, failover policy, provider health, supervisor model wire APIs, or model-gateway UI behavior. + +### 2. Signatures + +- `GET /api/model-gateway/providers` returns configured provider records. +- `POST /api/model-gateway/providers` creates or updates one provider record. +- `POST /api/model-gateway/providers/test` verifies submitted provider settings by making one minimal upstream model call without saving a new provider or returning upstream response content. +- `DELETE /api/model-gateway/providers/:providerId` deletes one provider record and associated provider state. +- `POST /api/model-gateway/active-route` sets the active provider/model alias route for one app (`supervisor`, `claude-code`, or `codex`). +- `GET /api/model-gateway/status` returns `activeRoutes` keyed by app plus provider health. It may also return legacy `activeRoute` for older clients, but new UI and routing logic must use the app-keyed map. +- `ALL /api/model-gateway/anthropic/*` resolves the session token, reads the `claude-code` active route on every request, rewrites the Anthropic `model`, and forwards to the selected upstream. +- `ALL /api/model-gateway/openai/*` resolves the session token, reads the `codex` active route on every request, rewrites the OpenAI-compatible `model`, and forwards to the selected upstream. +- `ModelGatewayStorePort` persists provider records, one active route per app, and redacted provider health records. +- `SupervisorModelPort.completeTurn(...)` may call a supervisor provider through Anthropic Messages, OpenAI Chat Completions, or OpenAI Responses based on the provider `wireApi`. + +### 3. Contracts + +- Provider request fields: optional `id`, `client` (`supervisor`, `claude-code`, or `codex`), `name`, `upstreamBaseUrl`, optional `apiKey`, `authMode` (`x-api-key` or `bearer`), optional `wireApi` (`responses` or `chat`; undefined means Anthropic Messages), non-empty `models[alias]`, `enabled`, and `priority`. +- Provider records store `hasApiKey` and may store a masked `apiKeyPreview`; real provider keys remain in the encrypted key vault and must not appear in provider records, API responses, frontend storage, logs, activity events, or sandbox env. +- Provider test request uses the provider request fields plus optional `modelAlias`; when `apiKey` is omitted for an existing `id`, the usecase reads the stored key from the key vault. The success response is `{ "success": true }`; failure responses use the normal error envelope and must not include upstream response bodies or secrets. +- Provider delete removes provider metadata, provider health, only that provider's app active route when it points at the deleted provider, and the encrypted `llm_api_key` vault entry when the key vault adapter supports deletion. +- `upstreamBaseUrl` must be a valid HTTP(S) URL. If it includes a path prefix such as `/anthropic`, proxied request paths remain under that prefix. +- Model map rules: at least one model alias is required, but `default` is not globally required. Claude Code and Codex should normally use `default`; Supervisor may expose arbitrary user-facing aliases without synthesizing `default`. Route model aliases are free-form non-empty strings and must reference an alias exposed by the selected provider. +- Active route fields: `app` (`supervisor`, `claude-code`, or `codex`), `providerId`, `modelAlias`, `updatedAt`. Persistence is keyed by `app`; selecting a Supervisor provider must not overwrite the Claude Code or Codex active route. +- Active route writes must reject a provider whose `client` does not match `app`, a disabled provider, or a `modelAlias` missing from the provider's model map. +- Provider health fields: `providerId`, `status` (`unknown`, `healthy`, `degraded`), `failureCount`, optional `lastCheckedAt`, optional redacted `lastError`. +- Proxy routing reads the active route for its own app on every request. Switching one app's route affects that app's next Claude Code, Codex, or Supervisor model request without restarting the sandbox or changing the session gateway token, and must not clear other apps' selections. +- If no active route exists for the requested app, proxy routing falls back to the session's `llmCredentialAlias` and the app's default upstream (`https://api.anthropic.com` for Claude Code, `https://api.openai.com` for Codex) for compatibility. +- Failover tries the active enabled provider first, then enabled providers sorted by `priority` and `name`; retryable statuses are `429`, `500`, `502`, `503`, and `504`. +- OpenAI-compatible provider base URLs must include the version path, for example `https://api.openai.com/v1`; provider test calls and supervisor OpenAI requests append only `/chat/completions` or `/responses`. Supervisor Anthropic requests use `/v1/messages`. Tool calls and tool results must be translated at the supervisor model adapter boundary. + +### 4. Validation & Error Matrix + +- Missing or malformed provider payload -> `VALIDATION_FAILED` / HTTP 400. +- Empty provider model map -> `VALIDATION_FAILED` / HTTP 400. +- Provider create/test without an API key or existing stored key -> `CREDENTIAL_REQUIRED` / HTTP 400. +- Provider test selected alias is not configured -> `VALIDATION_FAILED` / HTTP 400. +- Provider test upstream non-2xx or fetch failure -> `MODEL_GATEWAY_FAILED` / HTTP 502 with HTTP status and a redacted, bounded upstream response/error detail; saved provider health becomes `degraded` when the request includes an existing provider `id`. +- Supervisor model upstream non-2xx or fetch failure -> `MODEL_GATEWAY_FAILED` / HTTP 502 with HTTP status and a redacted, bounded upstream response/error detail; persisted run/session `lastError` should be specific enough for the UI to show the provider's actual rejection reason. +- Provider delete for an unknown `providerId` is idempotent and returns success. +- Active route references a missing, disabled, or wrong-client provider -> `MODEL_PROVIDER_NOT_FOUND` / HTTP 404. +- Active route references an alias missing from the provider's model map -> `VALIDATION_FAILED` / HTTP 400. +- Active route exists but no enabled providers are available -> `MODEL_PROVIDER_NOT_FOUND` / HTTP 404. +- Invalid model session token on proxy route -> `UNAUTHORIZED` / HTTP 401. +- Missing session for a valid model session token -> `SESSION_NOT_FOUND` / HTTP 404. +- Invalid proxy path or invalid upstream base URL -> `MODEL_GATEWAY_FAILED` / HTTP 502 before upstream fetch or real provider auth headers are created. +- Retryable upstream response -> mark provider `degraded`, increment `failureCount`, then try the next enabled provider. +- First non-retryable upstream response -> mark provider `healthy` and return it. + +### 5. Good/Base/Bad Cases + +- Good: Claude Code, Codex, and Supervisor providers are configured with encrypted keys; changing `activeRoutes[app]` to one app's provider/alias affects that app's next gateway or supervisor model request without clearing the other app selections or exposing real keys. +- Good: two Anthropic-compatible providers are configured with encrypted keys, the Claude Code active route points to provider A/`sonnet`, a running Claude Code session sends the next Messages request, Janus rewrites `model` to provider A's `sonnet` model, and a retryable failure fails over to provider B without exposing real keys. +- Good: a Supervisor provider stores multiple aliases such as `default`, `planner`, and `reviewer`; the composer displays them as `Provider/default`, `Provider/planner`, and `Provider/reviewer`, and the selected route writes `providerId + modelAlias`. +- Good: a Supervisor provider stores a single custom alias such as `claude-opus-4-8` without adding a synthetic `default` alias. +- Good: the Provider form can test unsaved settings with the submitted `apiKey` without persisting it, or test an edited provider with the stored key when `apiKey` is omitted. +- Good: the Provider form displays only a masked key preview such as `sk-r********-key`; replacing the key requires an explicit edit action and never reveals the full stored key. +- Good: deleting a provider clears its health and stale active route state for that provider's app so later routing cannot point at a deleted provider while other app selections stay intact. +- Good: an OpenAI-backed Supervisor provider chooses either Chat Completions or Responses, and the supervisor model adapter translates Janus tool calls/tool results to that wire API. +- Base: no active route exists for an app; existing sessions still proxy through that app's default upstream using `session.llmCredentialAlias`. +- Bad: persisting a real LLM key in a provider record, returning it in `/api/model-gateway/status`, forwarding an absolute proxy path, letting `../` escape a configured upstream path prefix, or caching the active route in a running sandbox. +- Bad: accepting an active route for `Provider/missingAlias`, testing a provider by sending the real API key from the frontend directly to an upstream URL, or logging the upstream provider test error body. + +### 6. Tests Required + +- Atom tests: model alias inference/rewrite, provider ordering, retryable status classification. +- Usecase tests with mock ports: provider upsert/list/status redaction and masked key preview, provider delete cleanup, provider test calls for submitted and stored keys, OpenAI test paths with versioned base URLs, redacted upstream test-call failures, active route alias/client validation, independent active routes per app, active route switching per request, model rewrite, retryable failover, missing credential/provider errors, and no-route fallback. +- Adapter tests: absolute/protocol-relative paths are rejected before fetch, non-HTTP(S) upstream bases are rejected before auth headers, configured path prefixes are preserved, and path-prefix escapes do not reach fetch. +- Supervisor adapter tests: Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses request/response translators preserve model aliases, tool calls, tool results, and stop reasons without exposing provider keys. +- Supervisor adapter tests: upstream non-2xx failures include provider response details while redacting the configured API key. +- API smoke tests: provider config and status endpoints never return real secrets and M1/M2 repository-session and supervisor flows still pass. +- Frontend checks: provider/status/test server state flows through TanStack Query, all three provider forms expose test call status, supervisor aliases render as `provider/alias`, and optional model aliases are omitted when blank instead of submitted as empty strings. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +await modelGatewayStorePort.saveModelProvider({ + credentialAlias: "sk-ant-real-key", +}); +``` + +```ts +const activeRoute = cachedAtSessionStartup; +``` + +```ts +new URL(request.path, provider.upstreamBaseUrl); +headers.set("x-api-key", realLlmKey); +``` + +```ts +await fetch(provider.upstreamBaseUrl, { + headers: { authorization: `Bearer ${apiKey}` }, +}); +``` + +#### Correct + +```ts +await modelGatewayStorePort.saveModelProvider({ + hasApiKey: true, +}); +``` + +```ts +const activeRoute = await modelGatewayStorePort.getActiveRoute("claude-code"); +``` + +```ts +const upstream = resolveAnthropicUrl(provider.upstreamBaseUrl, request.path); +headers.set(provider.authMode === "bearer" ? "authorization" : "x-api-key", realLlmKey); +``` + +```ts +await testModelProvider.execute({ + client: "supervisor", + upstreamBaseUrl, + apiKey, + authMode, + models, +}); +``` + +The correct path keeps secrets in the control plane, reads routing state per proxy request, validates provider aliases, and attaches real provider auth only inside the model-gateway/supervisor adapter boundary after URL validation succeeds. + +## Scenario: Supervisor Reasoning Effort and Visible Thought Output + +### 1. Scope / Trigger + +- Trigger: supervisor model configuration now carries per-alias reasoning effort across shared DTOs, provider storage, model adapter request payloads, supervisor run transcripts, and frontend conversation rendering. +- Applies when changing `ModelProviderRecord.models`, supervisor model wire APIs, `CompleteSupervisorTurnResult`, `SupervisorRunRecord.transcript`, provider settings UI, or conversation mapping for model-returned reasoning/thinking summaries. + +### 2. Signatures + +- `ModelMap` accepts legacy string entries and object entries: `{ [alias: string]: string | { model: string; reasoningEffort?: string } }`. +- Reasoning effort presets are `none`, `low`, `medium`, `high`, `xhigh`, and `max`; custom non-empty values are allowed when they pass the shared schema. +- `CompleteSupervisorTurnResult` may include `thoughts?: { type: "thought"; title?: string; text: string }[]`. +- `SupervisorRunRecord.transcript[]` may include `{ id, kind: "thought", title?, text, at }`. + +### 3. Contracts + +- `none` means "do not request provider thinking" and must omit reasoning/thinking request fields upstream. +- Model-map consumers must resolve entries through shared helpers such as `resolveModelConfig`, `modelConfigModelId`, and `modelConfigReasoningEffort`; do not assume `provider.models[alias]` is a string. +- OpenAI Chat Completions uses top-level `reasoning_effort` for non-`none` configured effort. `reasoningEffort: "max"` maps to `reasoning_effort: "xhigh"` because mainstream OpenAI-compatible Chat gateways do not accept `max` as the highest effort label. +- OpenAI Responses uses `reasoning: { effort, summary: "auto" }` for non-`none` configured effort and may return visible reasoning summaries in output reasoning items. +- Anthropic Messages uses the provider-appropriate summarized thinking request shape for non-`none` configured effort and may return visible `thinking` content blocks. +- OpenAI Chat Completions visible thought output may arrive in `message.reasoning_content`, string or object `message.reasoning`, or `message.reasoning_details`. The supervisor adapter must extract those fields as `thoughts`; plain `message.content` remains assistant answer text. +- Chat Completions `usage.completion_tokens_details.reasoning_tokens` is hidden usage accounting only; it must not create transcript thought entries. +- The UI may display only provider-returned visible thought/reasoning summary text. It must not infer, fabricate, or expose hidden chain-of-thought. + +### 4. Validation & Error Matrix + +- Empty model alias or model id -> `VALIDATION_FAILED` / HTTP 400 through shared provider schemas. +- Empty or schema-invalid custom reasoning effort -> `VALIDATION_FAILED` / HTTP 400. +- Active route references an alias missing from the provider's model map -> `VALIDATION_FAILED` / HTTP 400. +- Upstream rejects an unsupported reasoning effort value -> `MODEL_GATEWAY_FAILED` / HTTP 502 with redacted bounded provider detail. +- Provider returns no visible thought summary -> no `thought` transcript entry is persisted. + +### 5. Good/Base/Bad Cases + +- Good: an existing provider with `models: { default: "claude-3-5-sonnet-latest" }` still parses, routes, and sends no thinking field. +- Good: a supervisor provider stores `models: { planner: { model: "gpt-5", reasoningEffort: "high" } }`; selecting `planner` sends Chat `reasoning_effort: "high"` or Responses `reasoning.effort: "high"` depending on the provider wire API. +- Good: an OpenAI Chat `reasoning_content`, OpenAI Responses reasoning summary, or Anthropic thinking block is persisted as a `thought` transcript entry and renders as an always-expanded informational conversation row. +- Base: a custom effort such as `minimal` can be stored and sent for compatible gateways even when it is not one of the Janus preset labels. +- Bad: reading `provider.models.default` as a string and serializing it directly into upstream `model`. +- Bad: displaying `reasoning_tokens` counts, provider-hidden reasoning, or locally generated explanations as a thought row. + +### 6. Tests Required + +- Shared schema tests: legacy string model entries, object model entries, custom reasoning effort, and `none` omission behavior. +- Adapter tests: Chat sends top-level `reasoning_effort`, maps `max` to `xhigh`, extracts visible reasoning fields, and does not synthesize thoughts from usage tokens; Responses sends `reasoning` and extracts summaries; Anthropic sends summarized thinking config and extracts thinking blocks. +- Workflow tests or focused coverage: returned `thoughts` are persisted as `kind: "thought"` transcript entries and blank thought text is ignored. +- Frontend mapper/render tests: `thought` transcript entries map to conversation items with `Thinking` fallback title and visible body text. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const model = provider.models[alias] ?? provider.models.default; +body.model = model; +``` + +```ts +const tokens = response.usage.completion_tokens_details.reasoning_tokens; +transcript.push({ kind: "thought", text: `Reasoning tokens: ${tokens}` }); +``` + +#### Correct + +```ts +const config = resolveModelConfig(provider.models, alias); +body.model = modelConfigModelId(config); +if (shouldUseReasoningEffort(modelConfigReasoningEffort(config))) { + body.reasoning_effort = modelConfigReasoningEffort(config); +} +``` + +```ts +for (const thought of response.thoughts ?? []) { + transcript.push({ kind: "thought", text: thought.text, at: now }); +} +``` + +The correct path keeps model alias configuration backward-compatible, centralizes model-config normalization, and limits visible reasoning UI to provider-returned summary/thinking text. + +## Scenario: Supervisor Run Loop + +### 1. Scope / Trigger + +- Trigger: M2 adds a cross-layer supervisor run contract spanning shared DTOs, HTTP routes, run persistence, sandbox CLI dispatch, verification command execution, Git branch publishing, GitHub PR creation, activity events, and frontend state. +- Applies when extending plan/dispatch/verify, objective checks, repair iteration, branch/PR output, task-run request fields, supervisor bash validation, or the task-run UI. + +### 2. Signatures + +- `POST /api/supervisor-runs` starts one control-plane task run. +- `POST /api/supervisor-runs/:runId/cancel` requests cancellation for one non-terminal supervisor run. +- `GET /api/supervisor-runs/:runId` returns the persisted run state. +- `BackgroundTaskPort.run(task)` schedules the long-running supervisor loop after the initial run/session records are persisted. +- `SupervisorRunStorePort.saveSupervisorRun(run)`, `getSupervisorRun(runId)`, `listSupervisorRuns()`, and `listSupervisorRunsForSession(sessionId)` persist and read run records. Session-scoped UI/API paths must use `listSupervisorRunsForSession(sessionId)` so unrelated persisted rows are not decoded. +- `SupervisorRunCancellationPort.registerRun(runId, sessionId)` returns `{ signal, unregister }`; `cancelRun(runId, reason?)` aborts one active run; `cancelSession(sessionId, reason?)` aborts all active runs for a deleted session or requirement replacement. `reason` is one of `run_canceled` or `requirements_changed`. +- `SupervisorModelPort.completeTurn(...)`, `SandboxCommandPort.runCommand(...)`, and `CliSessionPort.dispatchInstruction(...)` accept an optional `signal`. +- `CliSessionPort.dispatchInstruction({ cli, sessionId, sandboxId, instruction, launch?, workspacePath?, signal?, onOutputLine? })` remains the subprocess boundary. `launch` is a typed allowlisted object, not raw argv. +- `VerificationPort.runCommand({ sessionId, sandboxId, command })` executes one objective check inside the session sandbox. +- `SandboxCommandPort.runCommand({ sessionId, sandboxId, command, workspacePath?, signal? })` executes one shell command. Docker adapters run inside `/workspace`; local-dev adapters use `workspacePath` as the host `cwd` when provided. +- Supervisor workspace tools are exposed to the model as `read_file`, `write_file`, `edit_file`, `bash`, `dispatch_claude_code`, `dispatch_codex`, and `read_cli_job`. +- `dispatch_claude_code({ instruction, description?, candidateCount?, launch? })` and `dispatch_codex({ instruction, description?, candidateCount?, launch? })` start asynchronous non-interactive CLI jobs and return job ids immediately. `description` is a short human-facing label for the job purpose. `candidateCount` defaults to `1` and may start a bounded parallel candidate set. `read_cli_job({ jobId })` reads the current same-session job snapshot; if the job is still running, it returns `status: running` without waiting for completion. +- `edit_file({ path, oldText, newText, replaceAll? })` applies an exact text replacement to one workspace file. `replaceAll` defaults to `false`. +- `GitPublisherPort.publishBranch({ project, branchName, gitToken })` publishes the current workspace branch using a control-plane Git token. +- `PullRequestPort.createPullRequest({ project, branchName, title, body, gitToken })` creates the PR through the control-plane GitHub boundary. + +### 3. Contracts + +- Start request fields: `projectId`, `task`, optional `sessionId`, optional `image`, and optional `supervisorModel`. The schema is strict; removed fields such as `permissionMode` must fail validation instead of being silently ignored. +- Start response fields: `run` and optional `diff`. The initial response should return the persisted `planning` run with `sessionId` before sandbox startup, CLI dispatch, verification, branch publishing, or PR creation completes. +- Run record fields: `id`, `projectId`, `sessionId`, `task`, `status`, `plan[]`, `verificationCommands[]`, `verificationResults[]`, `maxIterations`, `iteration`, optional `branchName`, optional `pullRequest`, timestamps, optional `lastError`. +- Async CLI job fields live on `SupervisorRunRecord.cliJobs[]`: `id`, `toolUseId`, `cli`, optional `description`, `instruction`, `launch`, `status` (`running`, `completed`, `failed`, `canceled`), `startedAt`, optional `completedAt`, optional `exitCode`, optional bounded `stdout`, optional bounded `stderr`, and optional `canceledReason` (`run_canceled` or `requirements_changed`). +- CLI launch option fields: optional `model` matching `^[A-Za-z0-9._:/+-]+$`, optional `effort`, and `access: "read-only" | "full-access"` defaulting to `full-access`. Claude Code accepts `effort: "low" | "medium" | "high" | "xhigh" | "max" | "ultracode"`; Codex accepts `effort: "low" | "medium" | "high" | "xhigh"`. The supervisor model never receives or supplies raw argv, shell syntax, secrets, host paths, `writePolicy`, or a generic `cli` field as launch configuration. +- Codex approval policy is a top-level Codex CLI flag, not an `exec` subcommand flag. Docker and local-dev Codex dispatch must build `codex -a never exec --json ... -s <sandbox> -- <instruction>` (or the Docker `docker exec <sandbox> codex -a never exec ...` equivalent). Placing `-a never` after `exec` makes current Codex fail before the job can run. `launch.access: "read-only"` maps to `-s read-only`; `full-access` maps to `-s danger-full-access`. +- Plan step fields: `id`, `index`, `title`, `instruction`, `status`, optional timestamps, optional `lastError`. +- Verification result fields: `id`, `commandId`, `command`, `status`, `exitCode`, bounded `stdout`, bounded `stderr`, timestamps. +- Successful finalization fields: `branchName`, `pullRequest.status: "created"`, `pullRequest.url`, `pullRequest.number`. +- Direct workspace file tools must use `WorkspaceReaderPort` / `WorkspaceWriterPort` with the opened `ProjectRecord.workspacePath`; they must not read or write through the sandbox container path. +- `read_file.path`, `write_file.path`, and `edit_file.path` are workspace-relative paths. Path traversal protection belongs to the workspace reader/writer adapter. +- `write_file` replaces the full file content. `edit_file` is for focused edits: `oldText` must be non-empty, must exist, and must be unique unless `replaceAll: true`. +- Supervisor `bash` tool commands are not a raw unrestricted terminal. They must pass a pure hard-deny validator before reaching `SandboxCommandPort`: no command substitution, variable expansion, host absolute paths (including Windows drive-qualified paths), backslash paths, `~`, parent-directory traversal, credential paths such as `.env` / `.ssh/*`, working-directory/git-dir overrides such as `-C`, `--cwd`, `--git-dir`, `--work-tree`, or `--prefix`, destructive `git` subcommands, `rm` / `rmdir`, background/heredoc syntax, container-control commands, or direct env/credential inspection commands. Non-hard-denied commands are allowed by default. +- Prompt text must not reveal the host workspace path to the model. Model-facing tool instructions and tool inputs use workspace-relative paths only; hard safety is enforced by validators/adapters, not by prompt wording alone. +- Process output returned to the supervisor model or persisted in supervisor tool transcript entries must virtualize exact project `workspacePath` occurrences as `/workspace`, including Windows backslash, Windows slash, and Git Bash/MSYS drive path variants such as `/c/Users/...`. This is a display/model boundary only; adapters still receive the real path for process execution. +- CLI job stdout/stderr returned through `read_cli_job`, automatic completion feedback, or run records must use the same workspace-path virtualization and bounded output policy. Incremental CLI stdout may create `cli_output` activity events through `onOutputLine`. +- The supervisor loop must not finalize while async CLI jobs are pending. If the model produces no tool calls while jobs are pending, the workflow waits for one completion, persists the completed job, feeds a user-message summary back to the model, and continues. +- Model-facing dispatch results and tool descriptions must steer the model away from polling. After dispatching CLI jobs, the model should stop its turn unless it has useful independent work; Janus resumes automatically when a job completes. If the model still calls `read_cli_job` for a running job, the workflow returns the current bounded snapshot once. +- Requirement changes are cancel-and-restart: when a new supervisor run reuses an active `sessionId`, the old active run is canceled with reason `requirements_changed`; its pending CLI jobs are recorded as `canceled` and stale results must not be fed back into the replacement run. +- Model-facing tool instructions must prefer `read_file` for file contents instead of advertising `cat`/`head`/`tail` as the normal file-read path. Final responses should read like a person talking to the user, not a report: no titles, no section headings at any level (`#`, `##`, `###`), no bold heading labels, and no heading-like lines such as `Summary:`, `Changes:`, `Verification:`, or `Next steps:`. Use concise paragraphs and only a few flat bullets when useful. +- Cancelling a supervisor run aborts the registered `AbortSignal`, propagates to in-flight model calls, sandbox shell commands, and dispatched coding CLI processes, preserves existing transcript entries, and records the run/session as `canceled` with a `session_canceled` activity event. Deleting a session must call `cancelSession(sessionId)` before deleting session records. +- Workspace tree listing is a working-tree view: it starts from `HEAD`, overlays `git status --porcelain=v1 -z --untracked-files=all`, includes untracked files/directories, and marks changed entries with the shared diff status values. +- Successful `write_file` and `edit_file` tool calls should attach the current workspace diff snapshot to the run record so the UI can show a patch before final run completion; the final session diff is still recorded through the normal `diff_recorded` event. +- The frontend may display friendly tool names (`Read`, `Write`, `Edit`, `Run`, `CLI`, `Best of N`), but transcript records keep the stable tool ids from `packages/shared`. +- Activity event types for this loop include `supervisor_planned`, `verification_started`, `verification_passed`, `verification_failed`, `supervisor_iterating`, `branch_published`, and `pull_request_created`. +- Branch publishing must commit current workspace changes before pushing. The adapter owns `git checkout -B <branch>`, `git status --porcelain`, `git add -A`, `git -c user.name=Janus -c user.email=janus@users.noreply.github.com commit -m <message>`, and `git push --set-upstream origin <branch>`. +- Real Git tokens and LLM keys stay in the control plane. They must not appear in run records, activity event messages, API responses, command arrays, frontend state, or error messages. + +### 4. Validation & Error Matrix + +- Missing or malformed run request -> `VALIDATION_FAILED` / HTTP 400. +- Missing project -> `PROJECT_NOT_FOUND` / HTTP 404. +- Missing LLM or Git credential alias -> `CREDENTIAL_NOT_FOUND` / HTTP 404. +- Missing run on `GET /api/supervisor-runs/:runId` -> `SUPERVISOR_RUN_NOT_FOUND` / HTTP 404. +- Missing run on `POST /api/supervisor-runs/:runId/cancel` -> `SUPERVISOR_RUN_NOT_FOUND` / HTTP 404. +- Cancelling a completed, canceled, or failed supervisor run -> no-op success. +- Cancelling an active supervisor run -> abort signal propagates; run/session reach terminal `canceled` state without a failure `lastError`. +- Reusing `sessionId` for a changed requirement -> cancel active runs for that session with `requirements_changed`, then start a replacement run with the new task. +- Invalid CLI launch option, including model strings with spaces or raw argv fragments -> tool input validation failure before subprocess dispatch. +- `read_cli_job` for an unknown `jobId` -> tool failure; the supervisor run may continue if the model repairs the job id. +- CLI job process returns non-zero -> job status `failed`; `dispatch_claude_code` / `dispatch_codex` itself still succeeds if the asynchronous job was started. +- CLI job cancellation -> job status `canceled`, `exitCode: 130`, and `canceledReason` set to the propagated cancellation reason. +- Run request containing removed fields such as `permissionMode` -> `VALIDATION_FAILED` / HTTP 400. +- Supervisor `bash` command with hard-denied shell syntax, credential path, host absolute path, `~`, parent traversal, cwd/git-dir override, destructive command, or direct env inspection -> tool failure with `VALIDATION_FAILED`; the run may continue if the model repairs the tool input. +- Sandbox startup failure after run/session persistence -> run `failed`, session `failed`, emit `session_failed`; do not leak raw Docker output. +- CLI dispatch startup failure -> async job status `failed` or tool failure depending on whether a job record was created; record bounded status, not raw secrets. +- Verification command non-zero exit -> verification result `failed`; this is an expected run outcome, not an HTTP error. +- Verification failure within budget -> run `iterating`; dispatch a bounded repair instruction. +- Verification failure after budget -> run `max_iterations_exhausted`, session `failed`. +- `edit_file.oldText` missing from the file -> tool failure with `VALIDATION_FAILED`; the run may continue if the model repairs the tool input. +- `edit_file.oldText` matches multiple locations and `replaceAll` is false -> tool failure with `VALIDATION_FAILED`; the model must include more context or set `replaceAll`. +- No workspace changes when branch publishing starts -> `WORKSPACE_SYNC_FAILED`; the run becomes `failed` and no PR is created. +- Branch publish or PR creation failure -> run `failed`; adapter errors must be redacted before propagation. + +### 5. Good/Base/Bad Cases + +- Good: a task run creates a plan, dispatches the persistent Claude Code session, runs checks in the sandbox, repairs once after a failed check, captures a diff, publishes a branch, and returns PR metadata without exposing secrets. +- Good: `POST /api/supervisor-runs` returns a `planning` run quickly, then the UI uses `GET /api/supervisor-runs/:runId`, session activity stream, and session diff polling to observe progress. +- Good: in local development mode, supervisor `bash` commands run in the opened project workspace via the injected `workspacePath` instead of returning a skipped-command placeholder. +- Good: a model request such as `echo $(cat ../AGENTS.md)`, `echo $PWD`, `cat C:/Users/...`, `cat ~/.ssh/config`, or `git -C .. status` is rejected before a subprocess starts. +- Good: the UI calls `POST /api/supervisor-runs/:runId/cancel`; the registered signal aborts an in-flight model or CLI process, the existing transcript remains visible, terminal state is `canceled`, and the background task unregisters the run in `finally`. +- Good: `dispatch_claude_code` or `dispatch_codex` returns `cli_job_started: <id>` before the CLI process exits; the supervisor can keep reasoning, stop and let automatic completion feedback resume it, or call `read_cli_job` once and receive the terminal result when that job completes. +- Good: local development mode dispatches real non-interactive CLIs from the project workspace using `claude --print ...` for Claude Code and `codex -a never exec --json ...` for Codex. Typed `launch.model`, `launch.effort`, and `launch.access` are mapped to each CLI's own flags by the adapter. +- Good: a changed user requirement reuses the session id, cancels stale pending CLI jobs as `requirements_changed`, and starts a replacement run rather than trying to mutate stdin for a non-interactive process. +- Good: the default supervisor bash path allows broader non-hard-denied commands such as `python -c "print(1)"`, while still blocking `cat .env`, `echo $JANUS_ACCESS_TOKEN`, `git clean -fd`, and `ls && rm -rf dist`. +- Good: deleting a running session first cancels all active supervisor runs for that session, then deletes the session-scoped rows. +- Good: a focused code change uses `edit_file` with enough `oldText` context to replace exactly one occurrence. +- Good: after `write_file` creates `generated/file.ts`, the run record carries a current diff snapshot and the workspace tree lists `generated/` plus `generated/file.ts` from the current filesystem before the final publish step. +- Good: workspace tree listing uses the current filesystem as the source of truth and uses `git status --porcelain=v1 -z --untracked-files=all` only to annotate changed entries. Deleted tracked files that no longer exist on disk must not remain visible just because they exist in `HEAD`. +- Base: checks fail and `maxIterations` is `0`; the run ends as `max_iterations_exhausted` without creating a branch or PR. +- Bad: using `write_file` for a tiny replacement when `edit_file` can express the change safely. +- Bad: relying on the system prompt to keep the model inside the workspace while still executing unvalidated `bash -lc` text. +- Bad: only changing the send button UI to "stop" without aborting the server-side model call and child process. +- Bad: awaiting `CliSessionPort.dispatchInstruction()` inside a dispatch tool before returning a tool result; this blocks the supervisor on long CLI work. +- Bad: exposing a raw `argv` or `args` string/object to the supervisor model for CLI launch control. +- Bad: building `codex exec ... -a never ...`; `-a` belongs before `exec`. +- Bad: trying to send changed requirements into an already-running non-interactive CLI process; cancel the stale job and start a new one instead. +- Bad: a usecase runs `Bun.spawn`, imports `adapters/*`, sends a Git token into the sandbox, stores raw unbounded verification logs, or returns the Git token in the run response. +- Bad: publishing a branch from dirty workspace state without committing first; this pushes the old HEAD and produces an empty or failing PR. + +### 6. Tests Required + +- Atom tests: plan derivation, branch-name derivation, verification pass/fail aggregation, iteration decision, repair-instruction construction. +- Atom tests: exact file edit replacement succeeds, rejects ambiguous matches by default, and supports explicit replace-all. +- Usecase tests with mock ports: pass path creates PR, failed verification dispatches a repair instruction, max-iteration stop does not publish. +- Usecase/workflow tests with mock ports: supervisor tool loop executes `read_file`, `write_file`, `edit_file`, and `bash` through injected ports and records bounded tool transcript entries. +- Usecase/workflow tests with mock ports: successful `write_file` / `edit_file` tool calls attach a non-empty diff snapshot to the running supervisor run record. +- Usecase/workflow tests with mock ports: `dispatch_claude_code` and `dispatch_codex` persist `running` CLI job records and return job ids before the mocked CLI promise resolves, including multi-candidate `candidateCount`. +- Usecase/workflow tests with mock ports: if the model tries to finish while CLI jobs are pending, the workflow waits for a job completion, persists the completed result, feeds it back to the model, and only then allows finalization. +- Usecase/workflow tests with mock ports: `read_cli_job` returns same-run and prior-run running snapshots, returns completed output, and updates persisted job state without exposing host workspace paths. +- Usecase/workflow tests with mock ports: changed requirements cancel active session runs with `requirements_changed` and mark pending CLI jobs `canceled`. +- Usecase tests with mock ports: starting a run returns before the captured background task executes; running the captured task advances persisted state to the terminal outcome. +- Atom/tool-input tests: CLI launch options default `access` to `full-access`, accept safe model ids, enforce per-CLI effort ranges, reject spaces/raw argv fragments, and reject removed/unknown launch fields such as `writePolicy`. +- Atom tests: supervisor bash validation allows non-hard-denied commands by default and rejects exactly hard-denied shell expansion/control syntax, credential paths, absolute/drive/home paths, parent traversal, cwd/git-dir overrides, destructive commands, and direct env inspection. +- Usecase/workflow tests with mock ports: cancelling a registered queued/running run aborts the workflow before the next model/tool operation, preserves transcript entries, persists run/session `canceled`, emits `session_canceled`, and unregisters the run. +- Usecase tests with mock ports: cancel-supervisor-run no-ops for terminal runs including `canceled`, aborts non-terminal registered runs, and delete-session calls `cancelSession(sessionId)` before deleting. +- Adapter tests: model, sandbox command, and coding CLI adapters pass `AbortSignal` to the underlying fetch/process runner so cancellation reaches the actual external work. +- Adapter tests: Docker Claude Code, Docker Codex, and local-dev `claude`/`codex` translate typed `launch.model`, `launch.effort`, and `launch.access` to each CLI's correct argv flags while keeping instruction text after `--`; Codex tests must assert `-a never` appears before `exec` and that `access: "read-only"` maps to `-s read-only`. +- Adapter tests: verification adapter command shape executes through the session sandbox; Git publish commits dirty workspace changes before push, rejects empty workspace changes, uses askpass/env rather than command-argument tokens; GitHub PR adapter parses `html_url` and `number` and redacts upstream failures. +- Adapter tests: workspace tree listing reads current filesystem entries, hides `.git`, excludes deleted tracked files that are absent on disk, includes untracked root files and nested untracked directories, and overlays `git status --porcelain=v1 -z --untracked-files=all` only for changed markers. +- Adapter tests: local-dev sandbox command adapter uses `workspacePath` as `cwd` when present and preserves the old skipped-command behavior when absent. +- API/contract tests: route payloads parse with `packages/shared` schemas; removed start-supervisor-run fields such as `permissionMode` fail validation; start-supervisor-run returns the initial `planning` run before the background loop completes; get-missing-run returns `SUPERVISOR_RUN_NOT_FOUND`. +- Quality scans: no adapter imports from `api/` or `usecases/`; no real key/token in run records, activity event messages, API responses, or frontend storage. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +await Bun.spawn(["bun", "test"], { cwd: project.workspacePath }); +``` + +```ts +await cliSessionPort.dispatchInstruction({ + instruction: `Fix this with token ${gitToken}`, +}); +``` + +```ts +const result = await cliSessionPort.dispatchInstruction({ + cli, + sessionId, + sandboxId, + instruction, + launch: { argv: ["--model", model, "--dangerously-bypass"] }, +}); +return result.stdout; +``` + +```ts +await sandboxCommandPort.runCommand({ + sessionId, + sandboxId, + command: "echo $(cat ../AGENTS.md)", +}); +``` + +```ts +await startSupervisorRun({ + projectId, + task, + permissionMode: "guarded", +}); +``` + +#### Correct + +```ts +await startSupervisorRun({ + projectId, + task, +}); +``` + +```ts +await verificationPort.runCommand({ + sessionId, + sandboxId, + command: "bun test", +}); +``` + +```ts +const edited = applyWorkspaceFileEdit({ + content: file.content, + oldText, + newText, + replaceAll: false, +}); +``` + +```ts +await gitPublisherPort.publishBranch({ + project, + branchName, + gitToken, +}); +``` + +```ts +const cancellation = supervisorRunCancellationPort.registerRun(run.id, session.id); +backgroundTaskPort.run(async () => { + try { + await runSupervisorLoop({ signal: cancellation.signal }); + } finally { + cancellation.unregister(); + } +}); +``` + +```ts +validateSupervisorBashCommand(command); +await sandboxCommandPort.runCommand({ + sessionId, + sandboxId, + command, + signal, +}); +``` + +```ts +const job = startCliJob({ + cli: "codex", + instruction, + launch: { model, effort: "high", access: "full-access" }, +}); +return `cli_job_started: ${job.id}`; +``` + +The correct path keeps objective checks, long-running scheduling, cancellation, bash validation, and Git publishing behind injected ports. The supervisor usecase owns the retry/cancel decision and stores only bounded, user-visible run state. + +## Scenario: Supervisor Run Attachments, Best-of-N, Ask, and Browser Tools + +### 1. Scope / Trigger + +- Trigger: supervisor runs now accept uploaded files and Best_of_N options, expose a blocking Ask tool, and expose headless browser automation. This is a cross-layer contract spanning `packages/shared`, HTTP, orchestrator usecases/workflows, workspace I/O ports, run-live SSE, and workspace conversation UI. +- Applies when changing `StartSupervisorRunRequest`, `SupervisorRunRecord`, supervisor tool definitions/inputs, Ask resolution routes, browser automation ports/adapters, session-run SSE, or conversation composer/pending Ask UI. + +### 2. Signatures + +- `POST /api/supervisor-runs` accepts optional `attachments?: SupervisorRunAttachmentInput[]` and `bestOfN?: { candidateCount?: 1..5 }`. +- `POST /api/supervisor-runs/:runId/asks/:askId/answer` accepts `{ answer }` and returns `{ run }`. +- `WorkspaceWriterPort.writeBinaryFile({ project, path, content })` writes uploaded binary content inside the session workspace. +- `SupervisorAskPort.waitForAnswer({ runId, askId, signal? })` blocks the tool loop until an answer arrives; `resolveAnswer({ runId, askId, answer, answeredAt })` resumes it. +- `BrowserAutomationPort.run({ workspacePath, url, actions, screenshotPath, signal? })` loads a page, applies bounded DOM actions, extracts text/structure, and writes a screenshot artifact. +- Supervisor tools include `ask_user({ question, context?, options? })` and `browser_action({ url, actions?, screenshotPath? })`. + +### 3. Contracts + +- Attachment input fields: `name` trimmed 1..240, optional `mediaType` 1..160, `sizeBytes` 0..`supervisorRunAttachmentMaxBytes`, and strict base64 `contentBase64`. Max attachment count per run is 10. +- Attachment record fields: `id`, original `name`, optional `mediaType`, `sizeBytes`, workspace-relative `path`, and `uploadedAt`. Do not store base64 content in run records. +- Uploaded files are written under `.janus/uploads/<runId>/<index>-<sanitizedName>` through `WorkspaceWriterPort.writeBinaryFile`; filenames must be sanitized and path-contained by the adapter. +- Initial supervisor model context must mention attached file metadata and workspace-relative paths so the model can read them through normal workspace tools. +- Best_of_N is a supervisor-run policy. `bestOfN.candidateCount` generates 1..5 candidate model turns, scores them deterministically in an atom, saves candidate records, and continues with the selected candidate before tool execution or final response handling. +- Ask requests are persisted in `SupervisorRunRecord.askRequests[]` with `pending`, `answered`, or `cancelled` status. Pending requests are delivered to the UI through the existing run-live event/query path, not a separate frontend-only state store. +- Ask answers must update the run record before resolving the in-process waiter so refreshed clients see answered state and the tool loop receives the same answer as tool output. +- Browser action inputs must be HTTP(S) URLs. Actions are bounded to click, type, scroll, wait, and snapshot. Screenshot paths are workspace-relative and must stay inside the workspace. +- Browser automation may fail with `CONFIGURATION_REQUIRED` when the runtime image lacks Playwright; do not add install commands or silently skip the tool. +- Real LLM keys, Git tokens, auth headers, and model session tokens must not appear in attachment metadata, browser output, Ask records, transcript output, errors, or frontend storage. + +### 4. Validation & Error Matrix + +- More than 10 attachments, invalid base64, oversized attachment, empty name, or size/content mismatch -> `VALIDATION_FAILED` / HTTP 400 before the run proceeds. +- Binary write path escaping the workspace or targeting a directory -> `VALIDATION_FAILED` / HTTP 400 from the workspace adapter. +- Missing run on Ask answer -> `SUPERVISOR_RUN_NOT_FOUND` / HTTP 404. +- Unknown ask id -> `VALIDATION_FAILED` / HTTP 404. +- Answering an already answered or cancelled Ask -> `VALIDATION_FAILED` / HTTP 409. +- Ask wait aborted by run cancellation -> request status becomes `cancelled`, and the run follows the normal cancellation path. +- Invalid browser URL, action shape, selector, timeout, or screenshot path -> tool failure with validation output; the run may continue if the model repairs the input. +- Browser runtime missing or invalid -> tool failure with `CONFIGURATION_REQUIRED`, without attempting an installation command. + +### 5. Good/Base/Bad Cases + +- Good: user attaches `notes.txt`, the file is stored in the session workspace under `.janus/uploads/...`, the run stores only metadata, and the first model message names the workspace-relative path. +- Good: Best_of_N generates three candidates, picks the one with valid tool use, executes only that candidate's tool calls, then applies the same policy to the next model turn. +- Good: `ask_user` persists a pending question, the UI renders an answer form from `SupervisorRunRecord.askRequests`, and answering resumes the original tool loop with a normal tool result. +- Good: `browser_action` loads an HTTPS page, clicks or types through bounded actions, writes `.janus/browser/<runId>/<toolUseId>.png`, and returns text plus structure to the model. +- Base: Playwright is absent in a local/dev runtime; the browser tool returns a clear configuration failure and the rest of the system remains usable. +- Bad: accepting multipart uploads by adding an unplanned dependency when shared JSON/base64 schemas cover the current single-user scope. +- Bad: storing uploaded file base64 in `SupervisorRunRecord` or frontend state. +- Bad: keeping pending Ask prompts only in React local state; refreshes and other tabs would lose the blocking question. +- Bad: importing a browser adapter directly into an orchestrator service instead of calling `BrowserAutomationPort`. + +### 6. Tests Required + +- Shared schema tests: attachments, Best_of_N options, Ask request/response records, and tool names parse and reject invalid payloads. +- Atom tests: attachment path/content preparation and deterministic Best_of_N scoring. +- Tool-input tests: `ask_user` trims/defaults fields; `browser_action` defaults snapshot actions, normalizes workspace screenshot paths, and rejects invalid actions. +- Usecase/workflow tests with mock ports: start-supervisor-run writes attachment bytes, stores metadata, includes attachment paths in the initial model context, and records Best_of_N candidates. +- Usecase tests with mock ports: resolving Ask updates the run and calls `SupervisorAskPort.resolveAnswer`; duplicate or missing Ask resolution fails. +- Tool tests/workflow coverage: Ask cancellation marks pending requests cancelled; browser tool calls only `BrowserAutomationPort` and records a bounded transcript entry. +- Frontend checks: composer sends shared attachment/Best_of_N payloads, conversation rows show attachment metadata, pending Ask cards derive from run query data, and answering invalidates `sessionRuns(sessionId)`. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +run.attachments = [{ name, contentBase64 }]; +``` + +```tsx +const [pendingAsk, setPendingAsk] = useState(questionFromSse); +``` + +```ts +import { PlaywrightBrowserAutomationAdapter } from "../adapters/browser"; +await new PlaywrightBrowserAutomationAdapter().run(request); +``` + +#### Correct + +```ts +await workspaceWriterPort.writeBinaryFile({ + project: projectForSessionWorkspace(project, session), + path: attachment.record.path, + content: attachment.content, +}); +``` + +```tsx +const pendingAsks = runs.flatMap((run) => + (run.askRequests ?? []).filter((ask) => ask.status === "pending"), +); +``` + +```ts +const result = await browserAutomationPort.run({ + workspacePath: project.workspacePath, + url, + actions, + screenshotPath, + signal, +}); +``` + +The correct path keeps uploaded bytes in the workspace, keeps live Ask state in durable run records, applies Best_of_N as orchestrator policy, and keeps browser I/O behind a declared port. + +## Scenario: Supervisor Autonomous Routing and Candidate Execution + +### 1. Scope / Trigger + +- Trigger: M4/MVP hardening extends the supervisor loop across shared DTOs, model-gateway routing, supervisor model planning, sandbox startup, CLI dispatch, git worktrees, tmux interactive sessions, candidate verification, and frontend run display. +- Applies when changing supervisor model planning/review/adjudication, supervisor routing decisions, dual CLI execution, Codex/OpenAI gateway behavior, best-of-N candidates, worktree isolation/cleanup, or tmux attach/control behavior. + +### 2. Signatures + +- `POST /api/supervisor-runs` still accepts task intent fields (`projectId`, `llmCredentialAlias`, `task`, `verificationCommands[]`, `maxIterations`, optional `image`, optional `supervisorModel` override); it must not require per-run CLI strategy or candidate-count selectors. +- `GET /api/supervisor-runs/:runId` returns optional `routingDecision`, `interactiveAttach`, `candidates[]`, and `selectedCandidateId` on the run record. +- `SupervisorModelPort.planAndRoute({ task, availableCliKinds, verificationCommands, maxIterations, maxCandidateCount, decidedAt, modelOverride? })` returns `{ plan, routing }`. +- `SupervisorModelPort.adjudicateCandidates({ task, candidates })` returns `{ selectedId, rationale }`. +- `SupervisorModelPort.reviewImplementation({ task, plan, diff, verificationResults })` returns `{ pass, rationale, repairHint? }`. +- `ALL /api/model-gateway/openai/*` proxies OpenAI-compatible Codex requests using the same session-scoped model gateway token pattern as Anthropic routing. +- `CliSessionPort.dispatchInstruction({ cli, sessionId, sandboxId, instruction })` dispatches a non-interactive CLI instruction for either `claude-code` or `codex`. +- `InteractiveCliPort.startInteractive/sendInput/captureOutput` controls tmux-backed interactive sessions inside the sandbox. +- `GitWorktreePort.createWorktree({ project, runId, candidateId })` creates an isolated candidate worktree without creating a session/candidate-owned branch and returns `{ workspacePath, branchName }`, where `branchName` is the repository's current/global branch metadata. +- `GitWorktreePort.removeWorktree({ project, workspacePath, branchName? })` removes the isolated candidate worktree only. It must not delete `branchName`, because sessions follow the shared repository branch rather than owning disposable branches. +- `SandboxSessionPort.startSessionSandbox(...)` may receive `openAiModelGatewayUrl`; sandbox startup writes Codex gateway config files. + +### 3. Contracts + +- Routing decision fields: `source` (`model` or `policy`), `cliKinds[]`, `entryMode` (`persistent`, `one-shot`, `interactive-tmux`), `executionShape` (`direct`, `best-of-n`), `candidateCount`, `rationale`, `decidedAt`. +- A configured supervisor model plans and routes first. Missing provider/key, invalid structured output, or model call failure falls back to `derivePlanSteps` + `chooseSupervisorRoute` with `source: "policy"`. +- Model-driven best-of-N adjudication selects the winner by `selectedId`; if adjudication fails or selects an unknown id, fallback selection is first passing candidate, then first candidate. +- Model review runs only after objective verification passes. A failed review becomes a bounded verification failure (`command: "supervisor model review"`) so the existing repair/max-iteration policy remains the single control path. +- Candidate fields: `id`, `sessionId`, `cli`, `entryMode`, `workspacePath`, optional `worktreeBranchName`, optional `sandboxId`, `status`, `verificationResults[]`, optional `diff`, optional `lastError`, optional `interactiveAttach`. `worktreeBranchName`, when present, is display/trace metadata for the shared current branch, not an ownership or cleanup token. +- Candidate worktrees must be removed in a workflow-level `finally` after publish/failure; candidate sandboxes and session tokens must be cleaned up in a candidate-level `finally`. +- Direct interactive runs store optional run-level `interactiveAttach`; best-of-N interactive runs store attach entries on the candidate that owns the tmux session. +- Interactive attach fields: `sandboxId`, `tmuxSessionName`, `attachCommand[]`. The command may identify a sandbox/tmux session, but must not include LLM keys, Git tokens, or model session token values. +- Provider records may include `client` (`claude-code` or `codex`) and `wireApi` (`responses` or `chat`). Missing `client` means legacy `claude-code`. +- OpenAI-compatible gateway fallback uses `session.llmCredentialAlias`, `https://api.openai.com`, and bearer auth. Configured Codex providers use provider credentials and rewrite the requested model through the selected alias, then `default`, then the first configured model. +- Sandbox startup may expose session gateway tokens as `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` to the sandbox. It must not write real keys into env, Docker args, `~/.codex/auth.json`, `~/.codex/config.toml`, logs, activity events, or API responses. +- Supervisor routing is autonomous. Prompt text can express constraints, but UI/API should display the decision rather than require manual per-run CLI/entry/fan-out selection. + +### 4. Validation & Error Matrix + +- Missing or malformed run request -> `VALIDATION_FAILED` / HTTP 400. +- Missing worktree or interactive port when the routing decision requires it -> `CONFIGURATION_REQUIRED` / HTTP 500. +- Supervisor model provider/key missing or model request fails -> policy fallback, not run failure. +- Supervisor model adjudication fails or references an unknown candidate -> policy fallback selection, not run failure. +- OpenAI proxy missing/invalid model session token -> `UNAUTHORIZED` / HTTP 401. +- OpenAI proxy valid token but missing session -> `SESSION_NOT_FOUND` / HTTP 404. +- OpenAI proxy unsafe path or invalid upstream base URL -> `MODEL_GATEWAY_FAILED` / HTTP 502 before upstream fetch or real provider auth headers are created. +- Candidate verification failure -> candidate `failed`; this is expected run state, not an HTTP route failure. +- No candidate produces a publishable diff -> run `failed`. +- No candidate passes verification -> run `max_iterations_exhausted`; no branch/PR is created. +- Tmux interactive start/send/capture command failure -> `CLI_DISPATCH_FAILED` / HTTP 500; do not synthesize an attach command as if the interactive entry succeeded. + +### 5. Good/Base/Bad Cases + +- Good: with a configured Claude-compatible provider, a normal task records an LLM-derived plan/routing decision with `source: "model"` and follows that route. +- Good: with no provider/key, a normal task records a policy fallback route and still completes through the legacy path. +- Good: a model chooses Codex/best-of-N; the supervisor creates isolated worktrees that follow the repository's current branch metadata, runs candidates, verifies each, asks the model adjudicator for a winner, publishes only that worktree, then removes all candidate worktrees without deleting the shared branch. +- Good: an interactive route starts tmux inside the Linux sandbox and records an attach command without exposing credentials. +- Base: no Codex provider route exists; OpenAI proxy falls back to the run/session LLM credential alias. +- Bad: adding `cliStrategy`, `entryMode`, or `candidateCount` as required user-facing run-start fields; this turns Janus into manual orchestration instead of supervisor-owned routing. +- Bad: treating the first passing candidate as the winner when a model adjudicator is configured and returns a valid candidate id. +- Bad: leaving `_worktrees/<project>/<candidate>` after a best-of-N run reaches a terminal state, or creating/deleting `janus/<run>/<candidate>` branches as if sessions owned their own branches. +- Bad: writing a real OpenAI key into `~/.codex/auth.json` in the sandbox; only the session gateway token may appear there. +- Bad: swallowing a failed `tmux new-session`, `send-keys`, or `capture-pane` command and still returning an attach command; this makes the UI display a dead interactive entry. + +### 6. Tests Required + +- Atom tests: routing policy chooses direct Claude default, Codex/best-of-N from prompt constraints, and interactive tmux from prompt constraints. +- Adapter tests: Codex command shape uses `codex exec --json`; tmux adapter starts/sends/captures via `docker exec tmux`; sandbox startup writes Codex config using env-provided session tokens without token literals in command args. +- Adapter tests: tmux command failures throw `CLI_DISPATCH_FAILED` without exposing raw process output. +- Adapter tests: git worktree paths stay inside the configured workspace root, use git worktree commands without `-B janus/<run>/<candidate>`, return the current/global branch metadata, and remove worktrees without deleting shared branches or accepting paths outside the workspace root. +- Usecase tests: model plan/route uses a fake `SupervisorModelPort`; model failures fall back to policy; legacy Claude run still passes; best-of-N creates separate candidate worktrees/sandboxes, stores candidate verification results, uses model adjudication when available, publishes only the selected workspace, and cleans up all candidate worktrees. +- Gateway tests: OpenAI fallback uses session credential; configured Codex provider rewrites `model`; unsafe paths are rejected before auth headers. +- Frontend checks: run display reads `routingDecision`, run-level `interactiveAttach`, and `candidates[]` from shared schemas and does not add required manual strategy selectors. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +await startSupervisorRun({ + task, + cliStrategy: "codex", + candidateCount: 3, +}); +``` + +```ts +const routingDecision = chooseSupervisorRoute(...); +const selected = candidates.find((candidate) => passed(candidate)); +``` + +```ts +await writeFile("/home/janus/.codex/auth.json", JSON.stringify({ + OPENAI_API_KEY: realOpenAiKey, +})); +``` + +```ts +await Bun.spawn(["tmux", "send-keys", instruction]); +``` + +#### Correct + +```ts +const routingDecision = chooseSupervisorRoute({ + task, + availableCliKinds: ["claude-code", "codex"], + decidedAt, +}); +``` + +```ts +const { plan, routing } = + (await supervisorModelPort.planAndRoute(...).catch(() => undefined)) ?? + fallbackPlanAndRoute(task, decidedAt); +const selected = + (await supervisorModelPort.adjudicateCandidates(...).catch(() => undefined)) ?? + selectCandidateByPolicy(candidates); +``` + +```ts +try { + await runBestOfNCandidates(...); +} finally { + await gitWorktreePort.removeWorktree({ project, workspacePath }); +} +``` + +```ts +await sandboxSessionPort.startSessionSandbox({ + modelSessionToken: await issueModelSessionToken(deps, sessionId), + modelGatewayUrl, + openAiModelGatewayUrl, +}); +``` + +```ts +await interactiveCliPort.sendInput({ + sandboxId, + tmuxSessionName, + input: instruction, +}); +``` + +The correct path keeps routing policy in pure supervisor atoms, keeps Docker/git/tmux I/O behind ports, and gives the sandbox only session-scoped gateway credentials. + +## Scenario: Workspace Project Terminal and Safe Shell Policy + +### 1. Scope / Trigger + +- Trigger: workspace terminal support adds a WebSocket protocol boundary, project-workspace subprocess I/O, cross-platform Bash resolution, and a shared safe-command policy consumed by frontend display compression. +- Applies when changing `GET /api/projects/:projectId/terminal`, `ProjectTerminalPort`, project terminal adapters, terminal UI connection handling, supervisor bash display compression, or safe shell command classification. + +### 2. Signatures + +- `GET /api/projects/:projectId/terminal?terminalId=<id>` upgrades to a WebSocket attachment for one project terminal. Missing or blank `terminalId` defaults to `default`. +- WebSocket client -> server messages are raw terminal input strings. +- WebSocket server -> client messages are raw terminal output chunks. +- `ProjectTerminalPort.openTerminal({ projectId, terminalId, workspacePath, onOutput })` returns a `ProjectTerminalSession` attachment. +- `ProjectTerminalSession.write(input)` forwards user input to the terminal session. +- `ProjectTerminalSession.dispose()` detaches the current output subscriber. It must not kill the underlying project terminal when the UI tab closes. +- `classifyShellCommand(command)` returns `{ compressible }` for the shared safe-command policy used by UI tool-call grouping. +- `SandboxCommandPort.runCommand({ sessionId, sandboxId, command, workspacePath? })` executes supervisor `bash` tool commands through an adapter-local workspace containment wrapper. + +### 3. Contracts + +- The terminal command language is Bash/Linux-style commands. +- Linux/macOS hosts use native `bash` from `PATH`. +- Windows hosts use Git Bash (`GIT_BASH`, common Git for Windows install paths, or `bash.exe`/`bash` on `PATH`); do not fall back to PowerShell or CMD. +- Commands run with `cwd` rooted in the connected project's `workspacePath`. +- Terminal identity is scoped by `projectId + terminalId`. Reopening the same id should reuse the same cwd, input buffer, running command state, and bounded output history for the current server process. +- Terminal persistence is in-process only; terminal sessions are not durable across server restarts unless a future contract explicitly adds storage. +- Closing a frontend tab or WebSocket must detach that client only. It must not dispose the terminal process/state. +- `cd` updates the session cwd only when the target stays inside the project workspace and exists. +- Terminal subprocesses inherit only the process environment allowlist from `runProcess` plus terminal display keys such as `TERM` and `COLORTERM`; Janus control-plane secrets must not be inherited implicitly. +- Supervisor sandbox commands run from the project workspace root. Docker adapters set `docker exec -w /workspace`; local dev adapters set `RunProcessRequest.cwd` to `project.workspacePath`. +- Supervisor sandbox command wrappers must pass the user command and workspace root as argv values to `bash -lc`; do not interpolate either value into the wrapper source. Local dev wrappers pass `.` as the workspace root so Git Bash does not need to parse a Windows host path. +- Supervisor sandbox command wrappers resolve the physical workspace root, install `DEBUG` and `EXIT` traps, and fail if the physical current directory leaves that root. Runtime containment is defense-in-depth on top of lexical command validation. +- Shared safe command families define which shell transcript entries may be visually grouped as low-risk/repetitive UI actions: `pwd`, `ls`, `cat`, `head`, `tail`, `dirname`, `basename`, `which`, `file`, `stat`, `du`, `df`, `grep`, `wc`, `sort`, `uniq`, `cut`, `rg`, `echo`, `printf`, `date`, `whoami`, `id`, `uname`, `find`, `git status`, `git diff`, `git log`, `git show`, `bun test`, `bun run`, package runner `run`/`test`, `node`, `tsc`, and `vitest`. +- Terminal-only or broader supervisor bash commands such as `sleep`, `npm install`, and `python -c "print(1)"` may be valid non-hard-denied runtime commands, but must not be marked `compressible` unless they fit the shared safe display policy. +- Safe command classification is conservative for UI grouping: command lines must tokenize cleanly, each command segment must be in the safe family, and hard-denied syntax/targets remain non-compressible. Pipes, control operators, and redirection may be compressible only when every segment/target remains inside the safe project-local policy. +- UI compression may use the shared `compressible` flag. Supervisor bash runtime validation is separate and uses the hard-deny policy, not the UI compression classifier. +- Supervisor bash hard-deny validation must reject commands that can weaken the containment wrapper or spawn a new uncontrolled shell, including nested shell executables and shell meta builtins such as `exec`, `trap`, `eval`, `command`, `builtin`, `enable`, `alias`, `unalias`, and `source`. + +### 4. Validation & Error Matrix + +- Missing project -> `PROJECT_NOT_FOUND` and the WebSocket closes after an error chunk. +- Missing project workspace directory -> `PROJECT_NOT_FOUND` and the WebSocket closes after an error chunk. +- Missing native Bash on Linux/macOS -> `CONFIGURATION_REQUIRED` and the WebSocket closes after an error chunk. +- Missing Git Bash on Windows -> `CONFIGURATION_REQUIRED` and the WebSocket closes after an error chunk. +- `cd` target outside the workspace -> terminal prints a bounded error and keeps the previous cwd. +- Supervisor sandbox command leaves the physical workspace during or after execution -> command returns non-zero with `Janus sandbox policy: command left the workspace.` on stderr. +- Active command cancellation -> command process is killed and the session remains usable unless the WebSocket closes. +- WebSocket close before terminal startup finishes -> detach the attachment after startup resolves; do not leave a listener writing to a closed socket. + +### 5. Good/Base/Bad Cases + +- Good: a Windows developer opens a terminal and runs `pwd`, `ls`, `git status`, and `docker compose ps` through Git Bash while the UI presents Linux command semantics. +- Good: Linux/macOS runs the same commands through native `bash` without platform-specific UI branches. +- Good: closing a Terminal editor tab removes only the tab; selecting the same terminal from the sidebar reattaches to the same terminal id. +- Good: UI tool-call compression groups safe repetitive commands such as `pwd`, `ls`, and `git status`, while supervisor bash validation still allows broader non-hard-denied commands through the default runtime path. +- Good: supervisor `bash` can run `cd apps/server && bun test` because the physical cwd remains under the workspace root. +- Base: WebSocket input sent before terminal startup is buffered and replayed once the terminal session is ready. +- Base: terminal output history is bounded and replayed on reattach; unbounded terminal logs are not retained in memory. +- Base: supervisor `bash` command output is formatted normally when the user command fails for project reasons but the cwd remains inside the workspace. +- Bad: falling back to PowerShell/CMD on Windows; this changes command semantics behind the user's back. +- Bad: marking `ls && rm -rf dist`, `echo secret > .env`, or `rg token | xargs rm` as safe because the first token is harmless. +- Bad: passing a host absolute `workspacePath` inside the shell source text, especially on Windows where Git Bash path parsing differs from Node's path model. +- Bad: allowing `trap`, `eval`, or nested `bash -lc` through supervisor command validation; those commands can weaken the wrapper's runtime checks. +- Bad: making React tab unmount the owner of the terminal process; tab lifecycle and terminal lifecycle must stay separate. +- Bad: running terminal subprocesses from `api/` or `usecases/`; subprocess I/O must stay in an adapter behind `ProjectTerminalPort`. + +### 6. Tests Required + +- Shared policy tests: every safe display command family returns `{ compressible: true }`; hard-denied, credential, host-path, traversal, variable-expansion, and unsafe target examples return `{ compressible: false }`. +- Supervisor atom tests: `validateSupervisorBashCommand` allows non-hard-denied commands by default and rejects exactly the hard-denied command set. +- Sandbox atom tests: command wrapper argv shape, physical cwd checks, and trap installation. +- Usecase tests with mock ports: opening a project terminal resolves `workspacePath` from `ProjectStorePort`, calls `ProjectTerminalPort`, and rejects missing projects. +- Adapter tests: Bash path resolution for Unix and Windows, cwd confinement, `cd` behavior, cancellation, detach/reattach without terminal disposal, and no implicit secret environment inheritance. +- Adapter tests: Docker and local dev sandbox command adapters execute through the workspace containment wrapper while preserving the adapter-owned cwd. +- API/web integration tests when available: WebSocket route upgrades only for authenticated clients, streams startup errors, and detaches the terminal attachment on close. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +app.get("/api/projects/:projectId/terminal", async () => { + const child = Bun.spawn(["powershell.exe"]); +}); +``` + +```ts +const safe = command.startsWith("ls"); +``` + +```ts +if (classifyShellCommand(command).compressible) { + validateSupervisorBashCommand(command); +} +``` + +```ts +await runProcess({ + command: ["bash", "-lc", `cd "${workspacePath}" && ${command}`], +}); +``` + +#### Correct + +```ts +const terminal = await openProjectTerminal.execute({ + projectId, + terminalId, + onOutput: (chunk) => ws.send(chunk), +}); +``` + +```ts +const policy = classifyShellCommand(command); +if (policy.compressible) { + // UI may group this low-risk shell transcript entry with adjacent actions. +} +``` + +```ts +validateSupervisorBashCommand(command); +await sandboxCommandPort.runCommand({ sessionId, sandboxId, command }); +``` + +```ts +await runProcess({ + command: buildWorkspaceContainedShellCommand({ + shellExecutable: bashPath, + workspaceRoot: ".", + command, + }), + cwd: project.workspacePath, +}); +``` + +The correct path keeps WebSocket protocol handling in `api/`, project lookup in a usecase, subprocess I/O in the terminal adapter, UI compression in the shared classifier, and supervisor runtime safety in the hard-deny validator. + +## Scenario: Supervisor CLI Job Snapshot, Resume, and Terminal Control + +### 1. Scope / Trigger + +- Trigger: supervisor-dispatched Claude Code and Codex jobs are shown as terminal-like UI entries, can be inspected while running, and can be canceled from the terminal tab. +- Applies when changing supervisor CLI dispatch tools, `SupervisorRunRecord.cliJobs`, CLI adapter output streaming, same-session run context, run cancellation, or frontend terminal/job display. + +### 2. Signatures + +- `dispatch_claude_code({ instruction, description?, candidateCount?, launch? })` starts asynchronous Claude Code job(s). +- `dispatch_codex({ instruction, description?, candidateCount?, launch? })` starts asynchronous Codex job(s). +- `read_cli_job({ jobId })` returns the current stored `SupervisorCliJobRecord` snapshot for a running or completed job in the same session without waiting for completion. +- CLI adapters receive `DispatchCliInstructionRequest.onOutputLine?: (line: string) => void` and should call it for each stdout line as it arrives when the underlying CLI streams. +- `POST /api/supervisor-runs/:runId/cancel` is the cancellation boundary used by a running CLI terminal's kill action. +- `POST /api/supervisor-runs/:runId/deliver` requests delivery of one queued follow-up run into the currently running run for the same session. This is not a forceful interrupt and must not cancel the current run or pending CLI jobs. +- Starting a run with an existing `sessionId` loads previous runs for that session and passes bounded prior context into the supervisor model's initial messages. +- Same-session supervisor context must replay prior run transcript as role-preserving model messages: `user` text stays a user message, `assistant` text stays an assistant message, and prior tool entries replay as an assistant `tool_use` followed by the corresponding user `tool_result`. Do not collapse prior runs into a single synthetic "previous context" text block as the primary continuity mechanism. +- Tool replay is bounded: truncate model-facing prior tool outputs and transcript text, and select complete message groups so a `tool_result` is never replayed without its matching `tool_use`. +- Todo replay uses parsed/defaulted todo input and the corresponding todo tool output so later runs can continue item wording and statuses instead of recreating the checklist from memory. + +### 3. Contracts + +- `SupervisorCliJobRecord.description` is optional storage metadata used for terminal/status labels; when omitted, UI may fall back to a one-line instruction summary. +- Running CLI output is persisted to `SupervisorRunRecord.cliJobs[].stdout` from `onOutputLine`; completed job output replaces the final `stdout`/`stderr` with the CLI process result. +- CLI job storage and model tool output have separate bounded limits. `SupervisorRunRecord.cliJobs[].stdout/stderr` is the larger terminal snapshot and may set `stdoutTruncated/stderrTruncated`. Tool transcript output returned to the supervisor model remains smaller and may be shortened even when the stored terminal snapshot is complete. +- The supervisor tool loop has two distinct async paths: + - `read_cli_job` is an immediate snapshot read for inspection. + - When the model emits no tool calls while CLI jobs are pending, the loop waits for the next CLI completion and resumes the model with that completed result. +- `read_cli_job` lookup is session-scoped. It must check the current workflow's in-memory pending/completed jobs first, then persisted `SupervisorRunRecord.cliJobs` for the current run and prior runs in the same session. A follow-up run must be able to read a running job that an earlier run in the same session started. +- CLI terminal entries are derived from `SupervisorRunRecord.cliJobs`, include the owning `runId` and `sessionId`, and must not maintain a second frontend-only source of job truth. +- Killing a CLI terminal cancels the owning supervisor run. It must not be presented as a tab-close-only operation or as per-process cancellation unless a per-job backend port exists. +- `SupervisorRunRecord.deliveryRequestedAt` marks that a queued run should be delivered into the active run at the next safe workflow boundary. `deliveredToRunId` and `deliveredAt` record the completed delivery target and time. +- Delivery must wake a supervisor workflow that is waiting for asynchronous CLI or group-discussion completion, then inject the queued task as a user message before the CLI/discussion completes. It must not wait for `read_cli_job`, CLI completion, or model polling. +- Delivery cannot mutate an in-flight model request. If the model request is already in progress, delivery is applied at the next workflow checkpoint after the model returns. +- Codex non-interactive dispatch uses `codex exec --json ...`; `launch.access: "read-only"` maps to `-s read-only`, and full access maps to the configured full-access Codex sandbox mode. Do not pass the removed `-a never` flag. +- Same-session context is bounded by count and character limits and must not include hidden control-plane credentials or raw unbounded CLI logs. + +### 4. Validation & Error Matrix + +- Unknown CLI job id in `read_cli_job` -> tool failure output `CLI job was not found: <jobId>`. +- Running CLI job in `read_cli_job` -> successful tool result containing `status: running` and any currently stored output. +- Completed failed CLI job in `read_cli_job` -> failed tool result containing the job snapshot and failure status. +- Delivering an unknown run id -> `SUPERVISOR_RUN_NOT_FOUND` / HTTP 404. +- Delivering a run that is not queued, or was already delivered -> no-op response with the current run record. +- Run cancel from a CLI terminal -> supervisor run transitions through the existing cancellation path; pending CLI jobs are stored as `canceled` with bounded retained stdout and cancellation reason. +- CLI executable start failure in dev mode -> CLI result `exitCode: 127` with actionable stderr; no secret values in output. + +### 5. Good/Base/Bad Cases + +- Good: dispatching Codex creates a running `cliJobs[]` entry, opens a terminal tab, stores streamed stdout lines, and later replaces the job with completed output. +- Good: the supervisor calls `read_cli_job` once to inspect a running job and receives the current snapshot, then stops; Janus resumes automatically when the job completes. +- Good: a long completed Codex result appears fully in the CLI terminal snapshot while the model-facing `read_cli_job` transcript is bounded. +- Good: a second user message in the same session receives bounded role-preserving context from previous supervisor runs, including prior todo/tool results, without changing the visible user transcript. +- Good: a queued follow-up marked via "Send now" is delivered into the active run while a CLI job is still running, and the model can inspect that running job with `read_cli_job`. +- Base: Codex emits no stdout until process completion; the terminal shows a first-output placeholder until final stdout arrives. +- Base: delivery requested while a model request is in flight waits for that model response, then appends the queued user message at the next workflow checkpoint. +- Bad: implementing `read_cli_job` by awaiting the pending process; this makes it unusable for inspecting running jobs and duplicates the automatic resume path. +- Bad: polling `read_cli_job` repeatedly instead of letting the no-tool-call resume path wait for completion. +- Bad: naming queued-message delivery "interrupt" or presenting it as a forceful abort; true cancellation remains `/cancel` or `stop_cli_job`. +- Bad: waiting for async CLI completion before delivering a queued follow-up after `/deliver` has been requested. +- Bad: adding frontend-only job state that can drift from `SupervisorRunRecord.cliJobs`. +- Bad: flattening prior runs into one synthetic summary string; this loses tool-call structure and makes same-session prompts feel disconnected. +- Bad: replaying unbounded file reads, CLI output, or other tool results into every later run. + +### 6. Tests Required + +- Supervisor workflow tests: dispatch starts without waiting; `read_cli_job` returns same-run and prior-run running snapshots; no-tool-call turns auto-resume on completion; same-session runs include role-preserving prior user/assistant context and replay prior todo tool history. +- Delivery tests: `/deliver`/usecase sets `deliveryRequestedAt`; a workflow waiting on async CLI/group completion wakes immediately, delivers the queued message, and leaves pending CLI jobs running. +- Adapter tests: Codex command shape is `codex exec --json ...` and read-only launch access maps to `-s read-only`. +- Frontend mapper tests: CLI job views include `runId`, `sessionId`, status, description, and output fields from shared run records. +- Cancellation tests: canceling a run with pending CLI jobs stores canceled job state and does not leave terminal jobs running in UI data. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +const completed = await pendingJob.promise; +return formatCliJobForModel(completed); +``` + +```tsx +setCliJobs((jobs) => [...jobs, frontendOnlyJob]); +``` + +```ts +await cancelSupervisorRun(queuedRun.id); +``` + +#### Correct + +```ts +const job = pendingJobs.get(jobId)?.job ?? completedJobs.get(jobId); +return formatCliJobForModel(job); +``` + +```tsx +const cliJobs = toCliJobViews(supervisorRuns); +``` + +```ts +await deliverQueuedSupervisorRun(queuedRun.id); +``` + +The correct path keeps job state in the supervisor run store, reserves blocking waits for the automatic no-tool-call resume path, and lets the terminal UI cancel through the existing run cancellation API. + +## Scenario: Session Worktree Checkpoints and Branching + +### 1. Scope / Trigger + +- Trigger: session rewind/branching changes session workspace ownership, Git worktree I/O, checkpoint metadata storage, cross-layer shared DTOs, HTTP endpoints, and frontend conversation controls. +- Applies when changing session workspace creation, checkpoint recording, checkpoint listing, session branch/rewind APIs, conversation version navigation, or future merge/apply flows. + +### 2. Signatures + +- `SessionRecord.workspacePath?: string` stores the isolated session workspace path when a session owns one. +- `SessionRecord.workspaceBaseRef?: string` stores the Git ref or branch used to create the session workspace. +- `SessionCheckpointRecord` fields: `id`, `projectId`, `sessionId`, optional `runId`, optional `parentCheckpointId`, `ref`, `commitSha`, `title`, `origin`, `createdAt`. +- `GET /api/sessions/:sessionId/checkpoints` returns `{ checkpoints: SessionCheckpointRecord[] }`. +- `POST /api/sessions/:sessionId/branches` accepts `{ checkpointId?, runId?, origin? }` and returns `{ session, checkpoint?, copiedRunCount }`. +- `GitWorktreePort.createSessionWorktree({ project, sessionId, startRef? })` creates a persistent detached session worktree. +- `GitCheckpointPort.createCheckpoint({ project, session, runId, message })` records a Git-native checkpoint ref for a completed run. +- `SessionCheckpointStorePort` owns checkpoint metadata persistence. + +### 3. Contracts + +- Session runs must use `session.workspacePath` when present; the project main workspace remains the integration target and must not be mutated by ordinary session runs. +- Session worktrees live under the configured Janus workspace root, such as `_sessions/<projectId>/<sessionId>`, and must be path-contained by the Git adapter. +- A completed run creates a checkpoint ref under `refs/janus/checkpoints/<sessionId>/<runId>` and a matching `SessionCheckpointRecord`. +- Checkpoint Git commits use Janus identity and may create a commit only when the session worktree has staged or unstaged changes; a no-change checkpoint still updates the internal ref to `HEAD`. +- Checkpoint parentage is metadata, not inferred from visible transcript rows. Rewind/branch checkpoints use `origin: "rewind"` or `"branch"`; ordinary completed-run checkpoints use `origin: "run"`. +- Branch/rewind creates a new session worktree from the selected checkpoint ref, copies supervisor runs only up to the selected checkpoint, and rewrites copied run/session identifiers so frontend conversation mapping does not duplicate the generated initial user transcript entry. +- Frontend branch/version navigation is derived from checkpoint metadata and TanStack Query data; it must not maintain a second server-state source of truth. +- Merge/apply into the main workspace is a separate explicit workflow. Conflict resolution must happen in an isolated integration worktree before the main workspace is updated. + +### 4. Validation & Error Matrix + +- Missing source session -> `SESSION_NOT_FOUND` / HTTP 404. +- Missing source project -> `PROJECT_NOT_FOUND` / HTTP 404. +- Missing selected checkpoint -> `SESSION_CHECKPOINT_NOT_FOUND` / HTTP 404. +- Branch/rewind without configured `GitWorktreePort` -> `CONFIGURATION_REQUIRED` / HTTP 500. +- Git worktree/checkpoint subprocess failure -> `WORKSPACE_SYNC_FAILED` / HTTP 500 with a generic, secret-free message. +- Worktree path escaping the configured workspace root -> `VALIDATION_FAILED` / HTTP 400 before any Git subprocess call. + +### 5. Good/Base/Bad Cases + +- Good: a new session starts in its own worktree and completed runs produce retained Git refs plus checkpoint metadata. +- Good: editing a user message branches from that run checkpoint with `origin: "rewind"`, starts a new run in the new session, and shows version navigation at the rewind point. +- Good: branching copies the conversation into a new session without carrying a live sandbox id, completed timestamp, or last error from the source session. +- Base: an existing legacy session without `workspacePath` may continue to use the project workspace until migrated, but new sessions should receive session worktrees when the port is configured. +- Base: a no-op completed run records a checkpoint at the current `HEAD` so the conversation graph remains durable. +- Bad: running a session agent directly in `project.workspacePath` when a session worktree exists. +- Bad: storing checkpoint code state as Janus-only patch blobs while also relying on Git for merge/conflict semantics. +- Bad: copying supervisor runs without rewriting the generated initial user transcript id; the frontend will display duplicate user messages. + +### 6. Tests Required + +- Adapter tests: `createSessionWorktree` uses `git worktree add --detach <path> <startRef>` and keeps the path inside the workspace root. +- Adapter tests: `createCheckpoint` stages/commits only the session workspace when changes exist, updates `refs/janus/checkpoints/...`, and does not leak credentials into command arrays. +- Usecase tests: branch/rewind selects checkpoints by `checkpointId` or `runId`, creates a session worktree from the checkpoint ref, copies runs only through the checkpoint, rewrites copied run transcript ids, and saves branch/rewind checkpoint metadata. +- Store tests or smoke coverage: checkpoint rows survive process-local store round trips and are deleted with their owning session. +- Frontend checks: user message actions expose copy, branch, and edit; checkpoint version controls derive from checkpoint query data and do not duplicate server state. + +### 7. Wrong vs Correct + +#### Wrong + +```ts +await runSupervisorRunWorkflow(deps, { + project, + session, + workspacePath: project.workspacePath, +}); +``` + +```ts +const copiedRun = { ...run, id: newRunId, sessionId: newSessionId }; +``` + +#### Correct + +```ts +const workspaceProject = + session.workspacePath === undefined + ? project + : { ...project, workspacePath: session.workspacePath }; +``` + +```ts +const copiedTranscript = run.transcript.map((entry) => + entry.kind === "user" && entry.id === `${run.id}-user-1` + ? { ...entry, id: `${newRunId}-user-1` } + : entry, +); +``` + +The correct path keeps session code state isolated in Git-native worktrees/checkpoints, preserves one source of truth for conversation history, and leaves main-workspace mutation to an explicit future merge/apply workflow. diff --git a/.trellis/spec/frontend/component-guidelines.md b/.trellis/spec/frontend/component-guidelines.md new file mode 100644 index 0000000000..146a34b96c --- /dev/null +++ b/.trellis/spec/frontend/component-guidelines.md @@ -0,0 +1,78 @@ +# Frontend Component Guidelines + +> Component conventions for `apps/web/`. Status: greenfield — conventions to follow. + +--- + +## 1. Building blocks + +- Use **shadcn/ui + Radix** primitives (accessible by default) and style with **Tailwind**; wrap them in `components/` rather than scattering raw Radix across features. +- Function components only; no class components. + +## 2. Component structure + +```tsx +type Props = { sessionId: string; onPause: () => void } // explicit, typed + +export function ActivityStream({ sessionId, onPause }: Props) { + // 1. hooks (data, state) 2. derived values 3. handlers 4. JSX +} +``` + +## 3. Props conventions + +- Define an explicit `Props` type; **no `any`**. Reuse cross-cutting shapes from `packages/shared`. +- Keep components presentational where possible; push data fetching into hooks (see `hook-guidelines.md`) and orchestration into the feature. +- Prefer composition over boolean-flag explosions (a row of `isX` booleans is a smell → split the component). + +## 4. Styling + +- Tailwind utility classes; extract repeated class sets into a component, not a copy-paste (redundancy smell). +- **Theme tokens only — never ad-hoc hex or literal Tailwind palette colors.** The token system (`src/styles.css`) is the single source of truth: `:root` holds HSL channel triplets, `@theme inline` maps them onto Tailwind utilities. Use `bg-card`, `text-muted-foreground`, `border-border-accent`, `bg-info-soft`, etc. — not `#xxxxxx`, not `bg-cyan-500`/`text-slate-400`/`bg-emerald-500`. Literal palette families (`slate`/`amber`/`emerald`/`cyan`/`sky`/`blue`) drift from the accent and break the single-accent discipline. The only acceptable hex is an xterm/CodeMirror fallback behind `??` that resolves from a token at runtime. + +### Radius scale (use the semantic class, not a pixel guess) + +| Class | px | Use on | +|-------|----|--------| +| `rounded-xs` | 6 | chips · badges · icon squares (e.g. 9×9 icon tile) · inline `<code>` · tooltip-tail | +| `rounded-sm` | 8 | buttons · inputs · textareas · select-triggers · pill list rows · 7×7 icon buttons | +| `rounded-md` | 12 | cards · dialogs · menus · popovers · sheets · section containers · code blocks · tabs lists | +| `rounded-lg` | 16 | workspace shell · full-width app frame · top nav bar | +| `rounded-full` | ∞ | status dots · avatars · progress bars · pills | + +Forbidden: bare `rounded` (4px default), `rounded-xl`/`rounded-2xl`/`rounded-3xl` (untokenized defaults), `rounded-[var(--radius)]` (use the semantic class instead). The old `tailwind.config` `borderRadius` extension that mapped `rounded-md/lg` to ~22/24px is gone — `rounded-{sm,md,lg}` now resolve to 8/12/16 via `@theme inline`. + +### Nested-radius rule — inner = outer − padding + +When a rounded container holds a rounded child, the inner radius must equal the outer radius minus the padding between them, or the corners look uncoordinated. Concretely: +- Card `rounded-md` (12) + `p-3` (12px) → inner element `rounded-full` or flush. With `p-2` (8px) → inner `rounded-sm` (≈4–8). +- `ModeToggle` container `rounded-md` (12) + `p-0.5` (2px) → inner buttons `rounded-sm` (8) ≈ 12−2−2. +- A 9×9 icon tile is a chip, not a card → `rounded-xs`, never `rounded-lg` (the old code put 16px on a 36px square — inner bigger than outer). + +### Motion — tiered, not killed + +`prefers-reduced-motion` is handled centrally in `styles.css`, tiered: transform/scale animations and infinite loops are disabled, but opacity/color transitions are kept at ~150ms so reduced-motion users still see gentle state changes (not a 0.01ms full-kill). Do not duplicate this per-component. For interactive elements, add `transition-colors duration-150` on hover-highlighted rows and `active:scale-[0.98]`-style press feedback where appropriate. Defined keyframes (`fade-in-up`, `route-fade`, `panel-in`, `live-pulse`, `collapsible-*`) live once in `styles.css` — never redefine a keyframe in `tailwind.config.ts` (the old `fade-in-up` duplicate caused a name collision). + +### Theme-ready token architecture + +`@theme inline` (not bare `@theme`) maps `:root` runtime variables onto Tailwind utilities, so a future `.dark` / `[data-theme]` override of the `:root` variables flows through every utility. Don't reintroduce a static `@theme` block with literal HSL values — that creates a dual-track system where `@theme` and `:root` drift apart. To add a token: declare the HSL triplet in `:root`, then map it under `@theme inline`. + +## 5. User-facing copy + +- UI copy must describe the product capability or user workflow directly. Do not show internal roadmap labels, task names, implementation phases, or temporary project shorthand in visible text. +- The same rule applies to aria labels, tooltips, empty states, and status text: use language a user can act on, not planning terminology. + +## 6. Accessibility + +- Lean on Radix's a11y; preserve labels, focus order, and keyboard handling. Don't strip ARIA that primitives provide. + +## 7. Real-time surfaces + +The activity stream / run panel update live. Keep render cost bounded: virtualize long streams, memoize list rows, and derive from server state (TanStack Query / WS) rather than duplicating it into local state. + +## 8. Common mistakes to avoid + +- Putting fetch calls directly in components (use hooks). +- Duplicating server state into `useState` (causes drift — see `state-management.md`). +- Letting roadmap shorthand leak into UI strings, component labels, or placeholder text. +- **Computing a right-edge drag width as `window.innerWidth - pointerX`.** This assumes the dragged panel's right edge is flush with the viewport — it almost never is (app padding, borders, a left sidebar all inset it). The handle lags the pointer and the panel snaps to max width early. Instead, capture the container's `getBoundingClientRect().right` at drag start (via the handle's `parentElement`) into a ref, and compute `railRightRef.current - pointerX` on move. See `useResizableRail`. diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md new file mode 100644 index 0000000000..0c025b4be3 --- /dev/null +++ b/.trellis/spec/frontend/directory-structure.md @@ -0,0 +1,44 @@ +# Frontend Directory Structure + +> How `apps/web/` is organized. Status: greenfield — feature-based convention to follow; ⏳ marks details to confirm with real code. + +--- + +## 1. Stack (✅ decided, doc 05 §2 / D13) + +**Vite + React + TypeScript**; **Tailwind + shadcn/ui + Radix**. Real-time via a WebSocket client. Shared types/schema come from `packages/shared` (never redeclared locally). + +## 2. Layout (⏳ feature-based) + +``` +apps/web/src/ +├─ app/ # app shell, router, providers (Query/WS/theme) +├─ features/ # one folder per Code-mode surface (doc 02 §2.2) +│ ├─ session/ # conversation + supervisor plan/decisions +│ ├─ activity-stream/ # real-time supervisor↔CLI event stream (signature surface) +│ ├─ plan/ # plan / task-decomposition view +│ ├─ diff/ # diff view + file tree +│ ├─ run-panel/ # test/build/verify results +│ ├─ best-of-n/ # candidate comparison + adjudication +│ ├─ repo-connect/ # GitHub OAuth / PAT / repo picker +│ └─ model-config/ # cc/cx provider + model config (cc-switch-style) +├─ components/ # shared presentational components (shadcn/ui wrappers) +├─ hooks/ # cross-feature reusable hooks +├─ lib/ # pure client utilities (api client, ws client, formatters) +└─ types/ # local-only UI types (cross-cutting types live in packages/shared) +``` + +## 3. Module organization + +- A feature owns its components, hooks, and local state; it imports shared UI from `components/` and shared types from `packages/shared`. +- Promote a component/hook to the top-level `components/`/`hooks/` only when a **second** feature needs it (avoid premature sharing → needless complexity). + +## 4. Naming conventions + +- Components: `PascalCase.tsx`. Hooks: `useCamelCase.ts`. Other files: `kebab-case.ts`. +- One component per file; co-locate its styles/tests beside it. + +## 5. Common mistakes to avoid + +- Redeclaring server DTOs locally instead of importing from `packages/shared` (causes front/back type drift). +- A "utils dumping ground" — keep `lib/` purpose-named. diff --git a/.trellis/spec/frontend/hook-guidelines.md b/.trellis/spec/frontend/hook-guidelines.md new file mode 100644 index 0000000000..1d35005deb --- /dev/null +++ b/.trellis/spec/frontend/hook-guidelines.md @@ -0,0 +1,34 @@ +# Frontend Hook Guidelines + +> Custom-hook and data-fetching conventions for `apps/web/`. Status: greenfield. + +--- + +## 1. Data fetching: TanStack Query (✅ doc 05 §2) + +- Server data is fetched/cached via **TanStack Query** — never hand-rolled `useEffect` + `fetch` for server state. +- Wrap each endpoint in a typed hook: `useSession(id)`, `useProjects()`. Query keys are stable, structured arrays (`['session', id]`). +- The fetcher uses the shared API client (`lib/`) and shared types (`packages/shared`). + +## 2. Real-time: WebSocket activity stream + +- A `useActivityStream(sessionId)` hook subscribes to the WS channel and feeds the live event stream. +- Reconcile WS events with Query cache where appropriate (e.g. invalidate/patch on completion) so there is one source of truth. +- Session activity SSE/WS handlers must validate frames with the shared `activityEventSchema`, upsert durable events into the `sessionActivity(sessionId)` query, and invalidate related Query keys from the event type rather than copying server state into component state: + - `session_renamed`, `session_created`, `session_completed`, `session_canceled`, `session_failed` -> project threads. + - `checkpoint_recorded` -> session checkpoints, session diff, and workspace content. + - `diff_recorded` -> session diff and workspace content. + - `sandbox_started` / `session_failed` -> session runtime. +- Run-live SSE handlers must validate frames with `supervisorRunLiveEventSchema`, patch the `sessionRuns(sessionId)` query, and limit sidebar/thread invalidation to run status/delivery/error boundaries so token-level streaming does not cause refetch storms. + +## 3. Custom-hook patterns + +- Name `useXxx`; one responsibility per hook; return a typed object. +- Hooks compose other hooks; keep side-effect logic inside hooks, not components. +- Extract a hook only when logic is reused or a component gets too busy (avoid premature abstraction). + +## 4. Common mistakes to avoid + +- Using `useEffect`+`fetch` for server state instead of TanStack Query. +- Putting non-stateful pure helpers in a hook (they belong in `lib/`). +- Unstable query keys (causes refetch storms). diff --git a/.trellis/spec/frontend/index.md b/.trellis/spec/frontend/index.md new file mode 100644 index 0000000000..c9f1c476f3 --- /dev/null +++ b/.trellis/spec/frontend/index.md @@ -0,0 +1,46 @@ +# Frontend Development Guidelines + +> Conventions for frontend development (Vite + React + TypeScript). Read before writing any frontend code. + +--- + +## Stack (✅ decided, doc 05 §2 / D13) + +**Vite + React + TS** · **Tailwind + shadcn/ui + Radix** · **TanStack Query** (server state) + **Zustand** (UI state) · WebSocket client (activity stream) · shared types/Zod from **`packages/shared`**. + +## Pre-Development Checklist + +Before writing frontend code, confirm: + +- [ ] Cross-cutting types come from `packages/shared` (not redeclared locally). +- [ ] Server data goes through TanStack Query; live data through the WS hook — not duplicated into local state. +- [ ] New shared component/hook is justified (a second consumer exists), else keep it feature-local. +- [ ] Network/WS data is validated (Zod) before being treated as typed. +- [ ] No secret/token touches client storage or logs. + +## Quality Check + +Before marking work done (see [`quality-guidelines.md`](./quality-guidelines.md)): + +- [ ] Lint + type-check green; strict TS, no `any`/unsafe casts. +- [ ] Scanned for the 7 code smells; none introduced. +- [ ] Tests pass (Vitest + RTL where applicable). + +--- + +## Guidelines Index + +| Guide | Description | Status | +|-------|-------------|--------| +| [Directory Structure](./directory-structure.md) | Feature-based layout, naming | ✅ Filled | +| [Component Guidelines](./component-guidelines.md) | shadcn/Radix + Tailwind, props, a11y, real-time surfaces | ✅ Filled | +| [Hook Guidelines](./hook-guidelines.md) | TanStack Query, WS activity-stream hook | ✅ Filled | +| [State Management](./state-management.md) | Server vs live vs UI state | ✅ Filled | +| [Type Safety](./type-safety.md) | Shared types, Zod validation, forbidden casts | ✅ Filled | +| [Quality Guidelines](./quality-guidelines.md) | Forbidden/required patterns, 7 smells, tests | ✅ Filled (⏳ test setup to confirm) | + +> ⏳ items are recommendations from `docs/design/05-tech-stack-and-conventions.md` to finalize before the relevant code lands. + +--- + +**Language**: all documentation and code identifiers are in **English** (project language policy; conversational replies may be Chinese). diff --git a/.trellis/spec/frontend/quality-guidelines.md b/.trellis/spec/frontend/quality-guidelines.md new file mode 100644 index 0000000000..967694e484 --- /dev/null +++ b/.trellis/spec/frontend/quality-guidelines.md @@ -0,0 +1,38 @@ +# Frontend Quality Guidelines + +> Quality bar for `apps/web/`. Status: greenfield. Shares the project-wide 7-code-smell watch-list (see `../backend/quality-guidelines.md` §2). + +--- + +## 1. Required patterns + +- Server state via TanStack Query; live data via the WS hook — one source of truth (`state-management.md`). +- Shared types/Zod from `packages/shared`; validate network data at the boundary (`type-safety.md`). +- Accessible primitives (shadcn/ui + Radix); explicit typed `Props`. + +## 2. Forbidden patterns + +- ❌ `any`, unchecked `as`, `@ts-ignore` without justification. +- ❌ `useEffect`+`fetch` for server state. +- ❌ Server state duplicated into local state. +- ❌ Secrets/tokens in client logs or `localStorage` (auth handled via the backend; see doc 03 §6). +- ❌ Raw colors/spacing instead of theme tokens. + +## 3. Code-smell watch-list + +The same seven smells apply (Rigidity, Redundancy, Circular dependency, Fragility, Obscurity, Data clump, Needless complexity). Frontend-flavored examples: prop-drilling chains (rigidity), copy-pasted Tailwind class sets (redundancy), god-store/god-component (needless complexity), boolean-flag prop explosions (data clump). **Raise and fix on sight.** + +## 4. Testing requirements + +- ⏳ Component tests with **Vitest + React Testing Library**; cover stateful behavior and a11y-critical interactions. +- Test hooks' logic; mock the API/WS client. +- Prefer tests at interactive/user-visible boundaries. Delete pure mapper or + label-format snapshots when they only restate stable implementation details. + +## 5. Review checklist + +- [ ] Lint + type-check green (Biome or ESLint+Prettier, doc 05 §6); strict TS. +- [ ] No `any`/unsafe casts; network data validated. +- [ ] Server vs UI state in the right home; no duplication. +- [ ] No secret in client storage/logs. +- [ ] Scanned for the 7 smells. diff --git a/.trellis/spec/frontend/state-management.md b/.trellis/spec/frontend/state-management.md new file mode 100644 index 0000000000..1d73066997 --- /dev/null +++ b/.trellis/spec/frontend/state-management.md @@ -0,0 +1,26 @@ +# Frontend State Management + +> State conventions for `apps/web/`. Status: greenfield (✅ stack decided, doc 05 §2). + +--- + +## 1. Three state categories — pick the right home + +| Category | Tool | Examples | +|---|---|---| +| **Server state** | **TanStack Query** | sessions, projects, messages, diffs, verify results | +| **Live event state** | WS subscription (reconciled into Query) | activity stream, run-panel updates | +| **Local UI state** | **Zustand** (global UI) / `useState` (component-local) | selected candidate, panel open/closed, theme, draft input | + +## 2. Rules + +- **Do not copy server state into Zustand/`useState`.** Server data lives in the Query cache; components read it there. Duplicating it causes drift (a fragility smell). +- Promote local → global (Zustand) only when **multiple distant components** need it. Default to component-local. +- Derived state is computed at render (or `useMemo`), not stored. +- URL owns navigational state (selected session/project) where it should be shareable/bookmarkable. + +## 3. Common mistakes to avoid + +- A giant global store holding everything (needless complexity + re-render churn). +- Mirroring `useQuery` data into `useState` in an effect. +- Putting ephemeral UI flags into the server round-trip. diff --git a/.trellis/spec/frontend/type-safety.md b/.trellis/spec/frontend/type-safety.md new file mode 100644 index 0000000000..5dd3fe9209 --- /dev/null +++ b/.trellis/spec/frontend/type-safety.md @@ -0,0 +1,29 @@ +# Frontend Type Safety + +> TypeScript conventions for `apps/web/`. Status: greenfield. + +--- + +## 1. Shared types are the source of truth + +- Cross-cutting types (DTOs, events, error codes) come from **`packages/shared`** — front and back import the same definitions, so the contract can't drift. +- Validate external/runtime data (API responses, WS frames) with **Zod** schemas from `packages/shared`; infer TS types from the schema (`z.infer`), don't write the type twice. +- `types/` in the web app holds **UI-only** types that never cross the wire. + +## 2. Conventions + +- `strict` tsconfig (with `noUncheckedIndexedAccess` recommended). +- Prefer inference; annotate public function signatures and component `Props`. +- Use discriminated unions for event/state variants (e.g. activity-stream event kinds) and exhaustive `switch`. + +## 3. Forbidden patterns + +- ❌ `any` (use `unknown` + narrowing). +- ❌ Non-null `!` and unchecked `as` casts to silence the compiler — validate or narrow instead. +- ❌ Redeclaring a server DTO locally. +- ❌ `@ts-ignore` without a one-line justification. + +## 4. Common mistakes to avoid + +- Trusting unvalidated JSON from the network as a typed object (parse with Zod first). +- Casting WS payloads with `as SomeType` instead of validating the discriminant. diff --git a/.trellis/spec/guides/architecture-thinking-guide.md b/.trellis/spec/guides/architecture-thinking-guide.md new file mode 100644 index 0000000000..87cf241b30 --- /dev/null +++ b/.trellis/spec/guides/architecture-thinking-guide.md @@ -0,0 +1,73 @@ +# Architecture Thinking Guide + +> **Purpose**: catch architecture erosion *before* it lands, and keep the context-first atomic 4+2 design honest. +> Companion to [Code Reuse](./code-reuse-thinking-guide.md) and [Cross-Layer](./cross-layer-thinking-guide.md) guides. + +--- + +## Why this guide + +Most architecture decay isn't one bad decision — it's many small "I'll just import this here" moments. This guide gives you the questions to ask before each one, so the codebase stays **clear, maintainable, high-cohesion, low-coupling**. + +The backing rules are normative in `../backend/directory-structure.md` (the context-first atomic 4+2 layering) and `../backend/quality-guidelines.md` (invariants + the smell list). This guide is the *mindset* behind them. + +--- + +## How to work here (collaboration style) + +When proposing or reviewing a design: + +1. **Offer options, not edicts** — for a non-trivial choice, sketch 2–3 approaches with trade-offs (cost, maintainability, risk, fit), then recommend one *with reasons*. +2. **Explain the principle**, not just the rule — say *why* I/O belongs in adapters, not only that it does. +3. **Watch the seven smells proactively** — the moment one appears, name it and propose the fix; don't wait to be asked. +4. **Right-size the solution** — match the tool to the problem; a greenfield personal app doesn't need a sledgehammer. + +--- + +## The 7 code smells — questions to ask + +For each, ask the question *before* writing the code: + +| Smell | Ask yourself | If "yes" → | +|---|---|---| +| **Rigidity** | "Will a likely future change force edits in many places?" | Put the volatile part behind a `contracts/ports` interface | +| **Redundancy** | "Have I written this logic somewhere already?" | Search first; sink to a same-context service/workflow, pure `atom/`, or stable `_shared/` helper | +| **Circular dependency** | "Do these two modules need each other?" | Extract the shared part to the correct owner: atom, same-context service, port, or event | +| **Fragility** | "Could editing this break something unrelated?" | Find the hidden coupling; add a test at the seam | +| **Obscurity** | "Would a new reader understand the intent in 30s?" | Rename to intent; split; add a one-line *why* | +| **Data clump** | "Do these params always travel together?" | Make them a `contracts/dto` type | +| **Needless complexity** | "Am I building for a requirement that exists?" | Delete the speculative generality | + +> Rule of thumb: **30 minutes of this thinking saves 3 hours of debugging.** + +--- + +## How the atomic 4+2 architecture pre-empts the smells + +The layering isn't bureaucracy — each rule kills a specific smell: + +- **One-way deps (`api→context usecases/workflows/services→atoms`)** → prevents *circular dependency* and *rigidity*. +- **I/O only in `adapters/`** → prevents *fragility* (vendor changes stay at the edge) and keeps decision logic pure & testable. +- **Ports in `contracts/`** → decouples policy from infrastructure (kills *rigidity*). +- **Context-private workflows/services** → prevents giant public usecases without leaking private APIs across contexts. +- **Pure, small atoms** → fights *obscurity* and *needless complexity*. +- **Deliberate add-or-refactor rule** → contains *fragility* while still allowing cleanup when existing ownership is wrong. +- **No cross-context private helper imports** → keeps bounded contexts honest and prevents hidden coupling. + +When a rule feels inconvenient, pause and identify the owner. The answer may be "push it down to an atom", "make it a same-context service/workflow", or "expose a real port/event". Do not use `_shared` as a pressure valve for unclear ownership. + +--- + +## Pre-design questions (before a new feature) + +- [ ] Which layer(s) does this touch? (Use the decision flow in `directory-structure.md` §5.) +- [ ] Which bounded context owns this behavior? +- [ ] What's the **port** boundary for any external capability? +- [ ] Can the core logic be a **pure atom** (so it's trivially testable)? +- [ ] Am I adding new behavior, or refactoring existing internals to remove a real smell? +- [ ] Did I consider 2–3 options for the non-obvious part and pick with reasons? +- [ ] Any of the 7 smells lurking? Name and address them now. + +--- + +**Core principle**: clear boundaries are cheaper than clever code. Keep I/O at the edges, keep decisions pure, and make the next change easy. diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md new file mode 100644 index 0000000000..f9d5f99bb3 --- /dev/null +++ b/.trellis/spec/guides/code-reuse-thinking-guide.md @@ -0,0 +1,105 @@ +# Code Reuse Thinking Guide + +> **Purpose**: Stop and think before creating new code - does it already exist? + +--- + +## The Problem + +**Duplicated code is the #1 source of inconsistency bugs.** + +When you copy-paste or rewrite existing logic: +- Bug fixes don't propagate +- Behavior diverges over time +- Codebase becomes harder to understand + +--- + +## Before Writing New Code + +### Step 1: Search First + +```bash +# Search for similar function names +grep -r "functionName" . + +# Search for similar logic +grep -r "keyword" . +``` + +### Step 2: Ask These Questions + +| Question | If Yes... | +|----------|-----------| +| Does a similar function exist? | Use or extend it | +| Is this pattern used elsewhere? | Follow the existing pattern | +| Could this be a shared utility? | Create it in the right place | +| Am I copying code from another file? | **STOP** - extract to shared | + +--- + +## Common Duplication Patterns + +### Pattern 1: Copy-Paste Functions + +**Bad**: Copying a validation function to another file + +**Good**: Extract to shared utilities, import where needed + +### Pattern 2: Similar Components + +**Bad**: Creating a new component that's 80% similar to existing + +**Good**: Extend existing component with props/variants + +### Pattern 3: Repeated Constants + +**Bad**: Defining the same constant in multiple files + +**Good**: Single source of truth, import everywhere + +--- + +## When to Abstract + +**Abstract when**: +- Same code appears 3+ times +- Logic is complex enough to have bugs +- Multiple people might need this + +**Don't abstract when**: +- Only used once +- Trivial one-liner +- Abstraction would be more complex than duplication + +--- + +## After Batch Modifications + +When you've made similar changes to multiple files: + +1. **Review**: Did you catch all instances? +2. **Search**: Run grep to find any missed +3. **Consider**: Should this be abstracted? + +--- + +## Gotcha: Asymmetric Mechanisms Producing Same Output + +**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts. + +**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely. + +**Prevention checklist**: +- [ ] When migrating directory structures, search for ALL code paths that reference the old structure +- [ ] If one path is auto-derived (glob/copy) and another is manually listed, the manual one needs updating +- [ ] Add a regression test that compares outputs from both mechanisms + +--- + +## Checklist Before Commit + +- [ ] Searched for existing similar code +- [ ] No copy-pasted logic that should be shared +- [ ] Constants defined in one place +- [ ] Similar patterns follow same structure diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md new file mode 100644 index 0000000000..0a91f11aa3 --- /dev/null +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -0,0 +1,162 @@ +# Cross-Layer Thinking Guide + +> **Purpose**: Think through data flow across layers before implementing. + +--- + +## The Problem + +**Most bugs happen at layer boundaries**, not within layers. + +Common cross-layer bugs: +- API returns format A, frontend expects format B +- Database stores X, service transforms to Y, but loses data +- Multiple layers implement the same logic differently + +--- + +## Before Implementing Cross-Layer Features + +### Step 1: Map the Data Flow + +Draw out how data moves: + +``` +Source → Transform → Store → Retrieve → Transform → Display +``` + +For each arrow, ask: +- What format is the data in? +- What could go wrong? +- Who is responsible for validation? + +### Step 2: Identify Boundaries + +| Boundary | Common Issues | +|----------|---------------| +| API ↔ Service | Type mismatches, missing fields | +| Service ↔ Database | Format conversions, null handling | +| Backend ↔ Frontend | Serialization, date formats | +| Component ↔ Component | Props shape changes | + +### Step 3: Define Contracts + +For each boundary: +- What is the exact input format? +- What is the exact output format? +- What errors can occur? + +--- + +## Common Cross-Layer Mistakes + +### Mistake 1: Implicit Format Assumptions + +**Bad**: Assuming date format without checking + +**Good**: Explicit format conversion at boundaries + +### Mistake 2: Scattered Validation + +**Bad**: Validating the same thing in multiple layers + +**Good**: Validate once at the entry point + +### Mistake 3: Leaky Abstractions + +**Bad**: Component knows about database schema + +**Good**: Each layer only knows its neighbors + +--- + +## Checklist for Cross-Layer Features + +Before implementation: +- [ ] Mapped the complete data flow +- [ ] Identified all layer boundaries +- [ ] Defined format at each boundary +- [ ] Decided where validation happens + +After implementation: +- [ ] Tested with edge cases (null, empty, invalid) +- [ ] Verified error handling at each boundary +- [ ] Checked data survives round-trip + +--- + +## Cross-Platform Template Consistency + +In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary. + +### Checklist: After Modifying Any Command Template + +- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"` +- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`) +- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings +- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed + +**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check. + +--- + +## Generated Runtime Template Upgrade Consistency + +Some generated files are both documentation and runtime input. In Trellis, +`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`, +SessionStart filters, and per-turn hooks. Template changes must be validated +against both fresh init and upgrade paths. + +### Checklist: After Modifying A Runtime-Parsed Template + +- [ ] Identify every runtime parser that reads the template, not just the file + writer that installs it +- [ ] Check whether relevant syntax lives outside obvious managed regions + such as tag blocks +- [ ] Verify fresh `init` output and a versioned `update` scenario that writes + the older `.trellis/.version` +- [ ] Add an upgrade regression using an older pristine template fixture, then + assert the installed file reaches the current packaged shape +- [ ] Update the backend spec that owns the runtime contract + +**Real-world example**: Codex inline mode changed workflow platform markers from +`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` / +`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but +`trellis update` only merged `[workflow-state:*]` blocks and preserved stale +markers outside those blocks. Result: upgraded projects got new hook scripts +but old workflow routing, so `get_context.py --mode phase --platform codex` +could return empty Phase 2.1 detail. + +--- + +## Mode-Detection Probe Checklist + +When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download): + +### Before implementing: +- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos) +- [ ] 404 vs transient error are distinguished — don't treat both as "not found" +- [ ] Transient errors **abort or retry**, never silently switch modes +- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source) +- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers + +### After implementing: +- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough +- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments +- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON +- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`) +- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters + +**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found". + +**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail. + +--- + +## When to Create Flow Documentation + +Create detailed flow docs when: +- Feature spans 3+ layers +- Multiple teams are involved +- Data format is complex +- Feature has caused bugs before diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md new file mode 100644 index 0000000000..d2d07454c4 --- /dev/null +++ b/.trellis/spec/guides/index.md @@ -0,0 +1,90 @@ +# Thinking Guides + +> **Purpose**: Expand your thinking to catch things you might not have considered. + +--- + +## Why Thinking Guides? + +**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill: + +- Didn't think about what happens at layer boundaries → cross-layer bugs +- Didn't think about code patterns repeating → duplicated code everywhere +- Didn't think about edge cases → runtime errors +- Didn't think about future maintainers → unreadable code + +These guides help you **ask the right questions before coding**. + +--- + +## Available Guides + +| Guide | Purpose | When to Use | +|-------|---------|-------------| +| [Architecture Thinking Guide](./architecture-thinking-guide.md) | Keep the atomic 4+2 design honest; catch the 7 code smells | Any new feature, module, or design decision | +| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns | +| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers | + +--- + +## Quick Reference: Thinking Triggers + +### When to Think About Architecture + +- [ ] You're adding a new feature, module, or runtime component +- [ ] You're about to import something across layers +- [ ] You're unsure which layer (entry/api/usecases/atoms/contracts/adapters) code belongs in +- [ ] Any I/O (DB, HTTP, FS, subprocess, git) is involved +- [ ] A design choice has more than one reasonable approach + +→ Read [Architecture Thinking Guide](./architecture-thinking-guide.md) + +### When to Think About Cross-Layer Issues + +- [ ] Feature touches 3+ layers (API, Service, Component, Database) +- [ ] Data format changes between layers +- [ ] Multiple consumers need the same data +- [ ] You're not sure where to put some logic + +→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) + +### When to Think About Code Reuse + +- [ ] You're writing similar code to something that exists +- [ ] You see the same pattern repeated 3+ times +- [ ] You're adding a new field to multiple places +- [ ] **You're modifying any constant or config** +- [ ] **You're creating a new utility/helper function** ← Search first! + +→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) + +--- + +## Pre-Modification Rule (CRITICAL) + +> **Before changing ANY value, ALWAYS search first!** + +```bash +# Search for the value you're about to change +grep -r "value_to_change" . +``` + +This single habit prevents most "forgot to update X" bugs. + +--- + +## How to Use This Directory + +1. **Before coding**: Skim the relevant thinking guide +2. **During coding**: If something feels repetitive or complex, check the guides +3. **After bugs**: Add new insights to the relevant guide (learn from mistakes) + +--- + +## Contributing + +Found a new "didn't think of that" moment? Add it to the relevant guide. + +--- + +**Core Principle**: 30 minutes of thinking saves 3 hours of debugging. diff --git a/.trellis/tasks/07-03-beautify-vitepress-docs/check.jsonl b/.trellis/tasks/07-03-beautify-vitepress-docs/check.jsonl new file mode 100644 index 0000000000..9cd59d4faf --- /dev/null +++ b/.trellis/tasks/07-03-beautify-vitepress-docs/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-03-beautify-vitepress-docs/implement.jsonl b/.trellis/tasks/07-03-beautify-vitepress-docs/implement.jsonl new file mode 100644 index 0000000000..9cd59d4faf --- /dev/null +++ b/.trellis/tasks/07-03-beautify-vitepress-docs/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/07-03-beautify-vitepress-docs/prd.md b/.trellis/tasks/07-03-beautify-vitepress-docs/prd.md new file mode 100644 index 0000000000..f8d15b9d74 --- /dev/null +++ b/.trellis/tasks/07-03-beautify-vitepress-docs/prd.md @@ -0,0 +1,74 @@ +# Beautify VitePress documentation site + +## Goal + +Refresh the VitePress documentation site so it feels polished, coherent, and useful for Minecraft server owners evaluating EcoEnchants. The homepage should become a stronger entry point, and there must be a dedicated documentation page that explains the homepage structure and intent. + +## What I already know + +* The documentation root is `documentation/`. +* VitePress is configured in `documentation/.vitepress/config.mts`. +* The custom theme imports `documentation/.vitepress/theme/custom.css`. +* The current homepage is `documentation/index.md` and already uses VitePress `layout: home`. +* Existing public assets include `hero-ecoenchants.png`, `brand-mark.svg`, and `favicon.svg`. +* The repository policy forbids local build/test commands and installation commands. Verification must avoid local build/test execution. +* Existing unrecognized dirty files before this task: `.agents/`, `.trellis/`, `Install-CodexTrellis.ps1`. + +## Requirements + +* Beautify the full VitePress documentation experience without adding new dependencies. +* Improve homepage layout, content density, visual rhythm, and navigation paths. +* Add a standalone documentation page introducing the homepage. +* Wire the new homepage-introduction page into VitePress navigation/sidebar. +* Keep the design responsive for desktop and mobile. +* Preserve existing Chinese documentation structure and existing EcoEnchants report links. +* Keep edits scoped to documentation and task bookkeeping files. + +## Acceptance Criteria + +* [x] `documentation/index.md` presents a richer, more polished homepage. +* [x] `documentation/.vitepress/theme/custom.css` improves global page, navigation, homepage, and documentation reading styles. +* [x] A standalone page under `documentation/guide/` introduces the homepage. +* [x] `documentation/.vitepress/config.mts` links the new page from navigation/sidebar. +* [x] No installation command is run. +* [x] No local build or test command is run. + +## Definition of Done + +* Code/docs edited according to the requirements. +* Changes reviewed manually against repo conventions. +* Local build/test is not run because repository instructions require build/test in GitHub workflow. +* Commit message is generated and submitted; push attempted as requested by repository instructions. + +## Technical Approach + +Use VitePress native home layout plus HTML sections in Markdown. Extend the existing CSS variables and component classes in `custom.css` instead of adding a new framework. Add one guide page dedicated to the homepage and link it from the guide navigation group. + +## Decision (ADR-lite) + +**Context**: The site already has a VitePress theme file, assets, and Chinese navigation. Adding dependencies or a custom app shell would increase maintenance and conflict with the no-install constraint. + +**Decision**: Enhance the existing VitePress default theme with scoped CSS and Markdown/HTML sections. + +**Consequences**: The implementation remains deployable through the existing VitePress pipeline. Visual verification is limited to static review in this environment because local build/test commands are prohibited. + +## Out of Scope + +* Adding npm dependencies. +* Running local VitePress build/test commands. +* Rewriting all existing report content. +* Changing backend/plugin behavior. + +## Technical Notes + +* Relevant files inspected: + * `package.json` + * `documentation/.vitepress/config.mts` + * `documentation/.vitepress/theme/custom.css` + * `documentation/index.md` + * `documentation/ecoenchants/index.md` + * `documentation/report/index.md` +* Static verification performed: + * `git diff --check -- documentation/.vitepress/config.mts documentation/.vitepress/theme/custom.css documentation/index.md documentation/guide/homepage.md` + * Manual link target existence check for homepage and guide links. +* Local VitePress build/test was intentionally not run because repository instructions require build/test execution in GitHub workflow only. diff --git a/.trellis/tasks/07-03-beautify-vitepress-docs/task.json b/.trellis/tasks/07-03-beautify-vitepress-docs/task.json new file mode 100644 index 0000000000..a85ce02f70 --- /dev/null +++ b/.trellis/tasks/07-03-beautify-vitepress-docs/task.json @@ -0,0 +1,26 @@ +{ + "id": "beautify-vitepress-docs", + "name": "beautify-vitepress-docs", + "title": "Beautify VitePress documentation site", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "Akiraph", + "assignee": "Akiraph", + "createdAt": "2026-07-03", + "completedAt": null, + "branch": null, + "base_branch": "advanced", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md new file mode 100644 index 0000000000..e273e2baf8 --- /dev/null +++ b/.trellis/workflow.md @@ -0,0 +1,690 @@ +# Development Workflow + +--- + +## Core Principles + +1. **Plan before code** — figure out what to do before you start +2. **Specs injected, not remembered** — guidelines are injected via hook/skill, not recalled from memory +3. **Persist everything** — research, decisions, and lessons all go to files; conversations get compacted, files don't +4. **Incremental development** — one task at a time +5. **Capture learnings** — after each task, review and write new knowledge back to spec + +--- + +## Trellis System + +### Developer Identity + +On first use, initialize your identity: + +```bash +python ./.trellis/scripts/init_developer.py <your-name> +``` + +Creates `.trellis/.developer` (gitignored) + `.trellis/workspace/<your-name>/`. + +### Spec System + +`.trellis/spec/` holds coding guidelines organized by package and layer. + +- `.trellis/spec/<package>/<layer>/index.md` — entry point with **Pre-Development Checklist** + **Quality Check**. Actual guidelines live in the `.md` files it points to. +- `.trellis/spec/guides/index.md` — cross-package thinking guides. + +```bash +python ./.trellis/scripts/get_context.py --mode packages # list packages / layers +``` + +**When to update spec**: new pattern/convention found · bug-fix prevention to codify · new technical decision. + +### Task System + +Every task has its own directory under `.trellis/tasks/{MM-DD-name}/` holding `prd.md`, `implement.jsonl`, `check.jsonl`, `task.json`, optional `research/`, `info.md`. + +```bash +# Task lifecycle +python ./.trellis/scripts/task.py create "<title>" [--slug <name>] [--parent <dir>] +python ./.trellis/scripts/task.py start <name> # set active task (session-scoped when available) +python ./.trellis/scripts/task.py current --source # show active task and source +python ./.trellis/scripts/task.py finish # clear active task (triggers after_finish hooks) +python ./.trellis/scripts/task.py archive <name> # move to archive/{year-month}/ +python ./.trellis/scripts/task.py list [--mine] [--status <s>] +python ./.trellis/scripts/task.py list-archive + +# Code-spec context (injected into implement/check agents via JSONL). +# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable +# platforms; the AI curates real spec + research entries during Phase 1.3. +python ./.trellis/scripts/task.py add-context <name> <action> <file> <reason> +python ./.trellis/scripts/task.py list-context <name> [action] +python ./.trellis/scripts/task.py validate <name> + +# Task metadata +python ./.trellis/scripts/task.py set-branch <name> <branch> +python ./.trellis/scripts/task.py set-base-branch <name> <branch> # PR target +python ./.trellis/scripts/task.py set-scope <name> <scope> + +# Hierarchy (parent/child) +python ./.trellis/scripts/task.py add-subtask <parent> <child> +python ./.trellis/scripts/task.py remove-subtask <parent> <child> + +# PR creation +python ./.trellis/scripts/task.py create-pr [name] [--dry-run] +``` + +> Run `python ./.trellis/scripts/task.py --help` to see the authoritative, up-to-date list. + +**Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, there is no active task and `task.py start` fails with a session identity hint. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task. + +### Workspace System + +Records every AI session for cross-session tracking under `.trellis/workspace/<developer>/`. + +- `journal-N.md` — session log. **Max 2000 lines per file**; a new `journal-(N+1).md` is auto-created when exceeded. +- `index.md` — personal index (total sessions, last active). + +```bash +python ./.trellis/scripts/add_session.py --title "Title" --commit "hash" --summary "Summary" +``` + +### Context Script + +```bash +python ./.trellis/scripts/get_context.py # full session runtime +python ./.trellis/scripts/get_context.py --mode packages # available packages + spec layers +python ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed guide for a workflow step +``` + +--- + +<!-- + WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below) + + The 4 [workflow-state:STATUS] blocks embedded in the ## Phase Index section + below are the SINGLE source of truth for the per-turn `<workflow-state>` + breadcrumb that every supported AI platform's UserPromptSubmit hook + reads. inject-workflow-state.py (Python platforms) and + inject-workflow-state.js (OpenCode plugin) only parse them — there is no + fallback dict baked into the scripts after v0.5.0-rc.0. + + STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it + degrades to a generic "Refer to workflow.md for current step." line — + intentionally visible so users notice and fix a broken workflow.md. + + INVARIANT (test/regression.test.ts): + Every workflow-walkthrough step marked `[required · once]` must have a + matching enforcement line in its phase's [workflow-state:*] block. The + breadcrumb is the only per-turn channel; if a mandatory step isn't + mentioned there, the AI silently skips it (Phase 1.3 jsonl curation + skip and Phase 3.4 commit skip both manifested via this gap). + + TAG ↔ PHASE scoping: + [workflow-state:no_task] → no active task; before Phase 1 + [workflow-state:planning] → all of Phase 1 (status='planning') + [workflow-state:in_progress] → Phase 2 + Phase 3.1-3.4 + (status stays 'in_progress' from + task.py start until task.py archive) + [workflow-state:completed] → currently DEAD: cmd_archive flips + status and moves the dir in the same + call, so the resolver loses the + pointer (block kept for a future + explicit in_progress→completed + transition) + + Editing checklist: + - When you change a [workflow-state:STATUS] block, also check the + matching phase's `[required · once]` walkthrough steps for sync + - Run `trellis update` after editing to push the new bodies to + downstream user projects (block-level managed replacement) + - Full runtime contract: + .trellis/spec/cli/backend/workflow-state-contract.md +--> + +## Phase Index + +``` +Phase 1: Plan → figure out what to do (brainstorm + research → prd.md) +Phase 2: Execute → write code and pass quality checks +Phase 3: Finish → distill lessons + wrap-up +``` + +<!-- Per-turn breadcrumb: shown when there is no active task (before Phase 1) --> + +[workflow-state:no_task] +No active task. **A Direct answer** — pure Q&A / explanation / lookup / chat; no file writes + one-line answer + repo reads ≤ 2 files → AI judges, no override needed. +**B Create a task** — any implementation / code change / build / refactor work. Entry sequence: (1) `python ./.trellis/scripts/task.py create "<title>"` to create the task (status=planning, breadcrumb switches to [workflow-state:planning] for brainstorm + jsonl phase guidance) → (2) load `trellis-brainstorm` skill to discuss requirements with the user and iterate on prd.md → (3) once prd is done and jsonl is curated, run `task.py start <task-dir>` to enter [workflow-state:in_progress] for the implementation skeleton. **"It looks small" is NOT grounds for downgrading B to A or C**. +**C Inline change** (per-turn only, escape hatch for B) — the user's CURRENT message MUST contain one of: "skip trellis" / "no task" / "just do it" / "don't create a task" / "跳过 trellis" / "别走流程" / "小修一下" / "直接改" / "先别建任务" → briefly acknowledge ("ok, skipping trellis flow this turn"), then inline. **Without seeing one of these phrases you must NOT inline on your own**; do not invent an override the user never said. +[/workflow-state:no_task] + +### Phase 1: Plan +- 1.0 Create task `[required · once]` (just `task.py create`; status enters planning) +- 1.1 Requirement exploration `[required · repeatable]` +- 1.2 Research `[optional · repeatable]` +- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi +- 1.4 Activate task `[required · once]` (run `task.py start`; status → in_progress) +- 1.5 Completion criteria + +<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') --> + +[workflow-state:planning] +Load the `trellis-brainstorm` skill and iterate on prd.md with the user. +Phase 1.3 (required, once): before `task.py start`, you MUST curate `implement.jsonl` and `check.jsonl` — list the spec / research files sub-agents need so they get the right context injected. You may skip only if the jsonl already has agent-curated entries (the seed `_example` row alone doesn't count). +Then run `task.py start <task-dir>` to flip status to in_progress. +[/workflow-state:planning] + +<!-- Per-turn breadcrumb: shown throughout Phase 1 when codex.dispatch_mode=inline. + Codex-only opt-in alternate to [workflow-state:planning]. The main agent + edits code directly in Phase 2, so Phase 1.3 jsonl curation is skipped — + the inline workflow loads `trellis-before-dev` instead of injecting JSONL + into a sub-agent. --> + +[workflow-state:planning-inline] +Load the `trellis-brainstorm` skill and iterate on prd.md with the user. +Phase 1.3 jsonl curation is **skipped** in inline dispatch mode — the main session loads `trellis-before-dev` directly in Phase 2 and reads spec context itself, so there is no sub-agent to inject jsonl into. +Then run `task.py start <task-dir>` to flip status to in_progress. +[/workflow-state:planning-inline] + +### Phase 2: Execute +- 2.1 Implement `[required · repeatable]` +- 2.2 Quality check `[required · repeatable]` +- 2.3 Rollback `[on demand]` + +<!-- Per-turn breadcrumb: shown while status='in_progress'. + Scope: all of Phase 2 + Phase 3.1-3.4 (status stays 'in_progress' from + task.py start until task.py archive; only archive flips it). The body + therefore must cover every required step from implementation through + commit, including Phase 3.3 spec update and Phase 3.4 commit. --> + +[workflow-state:in_progress] +**Tools**: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill — there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes. +**Flow**: trellis-implement → trellis-check → trellis-update-spec → commit (Phase 3.4) → `/trellis:finish-work`. +**Main-session default (no override)**: dispatch the `trellis-implement` / `trellis-check` sub-agents — the main agent does NOT edit code by default. Phase 3.4 commit (required, once): after trellis-update-spec, or whenever implementation is verifiably complete, the main agent **drives the commit** — state the commit plan in user-facing text, then run `git commit` — BEFORE suggesting `/trellis:finish-work`. `/finish-work` refuses to run on a dirty working tree (paths outside `.trellis/workspace/` and `.trellis/tasks/`). +**Sub-agent self-exemption**: if you are already running as `trellis-implement`, implement directly from the loaded task context and do NOT spawn another `trellis-implement`; if you are already running as `trellis-check`, review/fix directly and do NOT spawn another `trellis-check`. The default dispatch rule applies to the main session only. +**Sub-agent dispatch protocol (all platforms, all sub-agents)**: When you spawn `trellis-implement` / `trellis-check` / `trellis-research`, your dispatch prompt **MUST** start with one line: `Active task: <task path from \`task.py current\`>`. No exceptions. On class-2 platforms (codex / copilot / gemini / qoder) the sub-agent depends on this line because there is no hook to inject task context. On class-1 platforms (claude / cursor / opencode / kiro / codebuddy / droid) the line is normally redundant — the hook injects context directly — but it serves as a critical fallback when the hook fails (Windows + Claude Code PreToolUse silent skip, `--continue` resume, fork distribution, hooks disabled, etc.). For `trellis-research`, the line tells the sub-agent which `{task_dir}/research/` to write into. +**Inline override** (per-turn only, escape hatch for sub-agent dispatch): the user's CURRENT message MUST explicitly contain one of: "do it inline" / "no sub-agent" / "你直接改" / "别派 sub-agent" / "main session 写就行" / "不用 sub-agent". **Without seeing one of these phrases you must NOT inline on your own**; do not invent an override the user never said. +[/workflow-state:in_progress] + +<!-- Per-turn breadcrumb: shown while status='in_progress' when + codex.dispatch_mode=inline. Codex-only opt-in alternate to + [workflow-state:in_progress]. The main session edits code directly + instead of dispatching sub-agents. --> + +[workflow-state:in_progress-inline] +**Flow** (inline mode): main session loads `trellis-before-dev` → main session edits code → main session loads `trellis-check` → run lint / type-check / tests → fix → `trellis-update-spec` → commit (Phase 3.4) → `/trellis:finish-work`. +**Main-session default (inline dispatch_mode)**: the main agent edits code directly. Do NOT dispatch `trellis-implement` / `trellis-check` sub-agents. Load the `trellis-before-dev` skill before writing code; load the `trellis-check` skill before reporting completion. +Phase 3.4 commit (required, once): after `trellis-update-spec`, or whenever implementation is verifiably complete, the main agent **drives the commit** — state the commit plan in user-facing text, then run `git commit` — BEFORE suggesting `/trellis:finish-work`. `/finish-work` refuses to run on a dirty working tree (paths outside `.trellis/workspace/` and `.trellis/tasks/`). +[/workflow-state:in_progress-inline] + +### Phase 3: Finish +- 3.1 Quality verification `[required · repeatable]` +- 3.2 Debug retrospective `[on demand]` +- 3.3 Spec update `[required · once]` +- 3.4 Commit changes `[required · once]` +- 3.5 Wrap-up reminder + +<!-- Per-turn breadcrumb: shown while status='completed'. + Currently DEAD in normal flow: cmd_archive writes status='completed' in + the same call that moves the task dir to archive/, so the active-task + resolver loses the pointer and the hook never fires on archived tasks. + Block preserved for a future status-transition redesign (e.g. an + explicit in_progress→completed command). Edit through the same spec + channel as the live blocks. --> + +[workflow-state:completed] +Code committed via Phase 3.4; run `/trellis:finish-work` to wrap up (archive the task + record session). +If you reach this state with uncommitted code, return to Phase 3.4 first — `/finish-work` refuses to run on a dirty working tree. +`task.py archive` deletes any runtime session files that still point at the archived task. +[/workflow-state:completed] + +### Rules + +1. Identify which Phase you're in, then continue from the next step there +2. Run steps in order inside each Phase; `[required]` steps can't be skipped +3. Phases can roll back (e.g., Execute reveals a prd defect → return to Plan to fix, then re-enter Execute) +4. Steps tagged `[once]` are skipped if the output already exists; don't re-run + +### Skill Routing + +When a user request matches one of these intents, load the corresponding skill (or dispatch the corresponding sub-agent) first — do not skip skills. + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +| User intent | Route | +|---|---| +| Wants a new feature / requirement unclear | `trellis-brainstorm` | +| About to write code / start implementing | Dispatch the `trellis-implement` sub-agent per Phase 2.1 | +| Finished writing / want to verify | Dispatch the `trellis-check` sub-agent per Phase 2.2 | +| Stuck / fixed same bug several times | `trellis-break-loop` | +| Spec needs update | `trellis-update-spec` | + +**Why `trellis-before-dev` is NOT in this table:** you are not the one writing code — the `trellis-implement` sub-agent is. Sub-agent platforms get spec context via `implement.jsonl` injection / prelude, not via the main thread loading `trellis-before-dev`. + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +| User intent | Skill | +|---|---| +| Wants a new feature / requirement unclear | `trellis-brainstorm` | +| About to write code / start implementing | `trellis-before-dev` (then implement directly in the main session) | +| Finished writing / want to verify | `trellis-check` | +| Stuck / fixed same bug several times | `trellis-break-loop` | +| Spec needs update | `trellis-update-spec` | + +[/codex-inline, Kilo, Antigravity, Windsurf] + +### DO NOT skip skills + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +| What you're thinking | Why it's wrong | +|---|---| +| "This is simple, I'll just code it in the main thread" | Dispatching `trellis-implement` is the cheap path; skipping it tempts you to write code in the main thread and lose spec context — sub-agents get `implement.jsonl` injected, you don't | +| "I already thought it through in plan mode" | Plan-mode output lives in memory — sub-agents can't see it; must be persisted to prd.md | +| "I already know the spec" | The spec may have been updated since you last read it; the sub-agent gets the fresh copy, you may not | +| "Code first, check later" | `trellis-check` surfaces issues you won't notice yourself; earlier is cheaper | + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +| What you're thinking | Why it's wrong | +|---|---| +| "This is simple, just code it" | Simple tasks often grow complex; `trellis-before-dev` takes under a minute and loads the spec context you'll need | +| "I already thought it through in plan mode" | Plan-mode output lives in memory — must be persisted to prd.md before code | +| "I already know the spec" | The spec may have been updated since you last read it; read again | +| "Code first, check later" | `trellis-check` surfaces issues you won't notice yourself; earlier is cheaper | + +[/codex-inline, Kilo, Antigravity, Windsurf] + +### Loading Step Detail + +At each step, run this to fetch detailed guidance: + +```bash +python ./.trellis/scripts/get_context.py --mode phase --step <step> +# e.g. python ./.trellis/scripts/get_context.py --mode phase --step 1.1 +``` + +--- + +## Phase 1: Plan + +Goal: figure out what to build, produce a clear requirements doc and the context needed to implement it. + +#### 1.0 Create task `[required · once]` + +Create the task directory (status enters `planning`, the session active-task pointer auto-targets the new task when session identity is available): + +```bash +python ./.trellis/scripts/task.py create "<task title>" --slug <name> +``` + +`--slug` is the human-readable name only. Do **not** include the `MM-DD-` date prefix; `task.py create` adds that prefix automatically. + +After this command succeeds, the per-turn breadcrumb auto-switches to `[workflow-state:planning]`, telling the AI to enter the brainstorm + jsonl curation phase. + +⚠️ **Run only `create` here — do not also run `start`**. `start` flips status to `in_progress`, which switches the breadcrumb to the implementation phase before brainstorm + jsonl are done — the AI will silently skip them. Save `start` for step 1.4, after jsonl curation is complete. + +Skip when `python ./.trellis/scripts/task.py current --source` already points to a task. + +#### 1.1 Requirement exploration `[required · repeatable]` + +Load the `trellis-brainstorm` skill and explore requirements interactively with the user per the skill's guidance. + +The brainstorm skill will guide you to: +- Ask one question at a time +- Prefer researching over asking the user +- Prefer offering options over open-ended questions +- Update `prd.md` immediately after each user answer + +Return to this step whenever requirements change and revise `prd.md`. + +#### 1.2 Research `[optional · repeatable]` + +Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc. + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +Spawn the research sub-agent: + +- **Agent type**: `trellis-research` +- **Task description**: Research <specific question> +- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/` + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. (For `codex-inline` this avoids the `fork_turns="none"` isolation that prevents `trellis-research` sub-agents from resolving the active task path.) + +[/codex-inline, Kilo, Antigravity, Windsurf] + +**Research artifact conventions**: +- One file per research topic (e.g. `research/auth-library-comparison.md`) +- Record third-party library usage examples, API references, version constraints in files +- Note relevant spec file paths you discovered for later reference + +Brainstorm and research can interleave freely — pause to research a technical question, then return to talk with the user. + +**Key principle**: Research output must be written to files, not left only in the chat. Conversations get compacted; files don't. + +#### 1.3 Configure context `[required · once]` + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries. + +**Location**: `{TASK_DIR}/implement.jsonl` and `{TASK_DIR}/check.jsonl` (already exist). + +**Format**: one JSON object per line — `{"file": "<path>", "reason": "<why>"}`. Paths are repo-root relative. + +**What to put in**: +- **Spec files** — `.trellis/spec/<package>/<layer>/index.md` and any specific guideline files (`error-handling.md`, `conventions.md`, etc.) relevant to this task +- **Research files** — `{TASK_DIR}/research/*.md` that the sub-agent will need to consult + +**What NOT to put in**: +- Code files (`src/**`, `packages/**/*.ts`, etc.) — those are read by the sub-agent during implementation, not pre-registered here +- Files you're about to modify — same reason + +**Split between the two files**: +- `implement.jsonl` → specs + research the implement sub-agent needs to write code correctly +- `check.jsonl` → specs for the check sub-agent (quality guidelines, check conventions, same research if needed) + +**How to discover relevant specs**: + +```bash +python ./.trellis/scripts/get_context.py --mode packages +``` + +Lists every package + its spec layers with paths. Pick the entries that match this task's domain. + +**How to append entries**: + +Either edit the jsonl file directly in your editor, or use: + +```bash +python ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" +python ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" +``` + +Delete the seed `_example` line once real entries exist (optional — it's skipped automatically by consumers). + +Skip when: `implement.jsonl` has agent-curated entries (the seed row alone doesn't count). + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2. + +[/codex-inline, Kilo, Antigravity, Windsurf] + +#### 1.4 Activate task `[required · once]` + +Once prd.md is complete and 1.3 jsonl curation is done, flip the task status to `in_progress`: + +```bash +python ./.trellis/scripts/task.py start <task-dir> +``` + +After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows. + +If `task.py start` errors with a session-identity message (no context key from hook input, `TRELLIS_CONTEXT_ID`, or platform-native session env), follow the hint in the error to set up session identity, then retry. + +#### 1.5 Completion criteria + +| Condition | Required | +|------|:---:| +| `prd.md` exists | ✅ | +| User confirms requirements | ✅ | +| `task.py start` has been run (status = in_progress) | ✅ | +| `research/` has artifacts (complex tasks) | recommended | +| `info.md` technical design (complex tasks) | optional | + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +| `implement.jsonl` has agent-curated entries (not just the seed row) | ✅ | + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +--- + +## Phase 2: Execute + +Goal: turn the prd into code that passes quality checks. + +#### 2.1 Implement `[required · repeatable]` + +[Claude Code, Cursor, OpenCode, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the requirements per prd.md, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`. + +The platform hook/plugin auto-handles: +- Reads `implement.jsonl` and injects the referenced spec files into the agent prompt +- Injects prd.md content + +[/Claude Code, Cursor, OpenCode, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-sub-agent] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the requirements per prd.md, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then explicitly say the spawned agent is already `trellis-implement` and must implement directly without spawning another `trellis-implement` / `trellis-check`. + +The Codex sub-agent definition auto-handles the context load requirement: +- Resolves the active task with `task.py current --source`, then reads `prd.md` and `info.md` if present +- Reads `implement.jsonl` and requires the agent to load each referenced spec file before coding + +[/codex-sub-agent] + +[Kiro] + +Spawn the implement sub-agent: + +- **Agent type**: `trellis-implement` +- **Task description**: Implement the requirements per prd.md, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check +- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`. + +The platform prelude auto-handles the context load requirement: +- Reads `implement.jsonl` and injects the referenced spec files into the agent prompt +- Injects prd.md content + +[/Kiro] + +[codex-inline, Kilo, Antigravity, Windsurf] + +1. Load the `trellis-before-dev` skill to read project guidelines +2. Read `{TASK_DIR}/prd.md` for requirements +3. Consult materials under `{TASK_DIR}/research/` +4. Implement the code per requirements +5. Run project lint and type-check + +[/codex-inline, Kilo, Antigravity, Windsurf] + +#### 2.2 Quality check `[required · repeatable]` + +[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +Spawn the check sub-agent: + +- **Agent type**: `trellis-check` +- **Task description**: Review all code changes against spec and prd; fix any findings directly; ensure lint and type-check pass +- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`. + +The check agent's job: +- Review code changes against specs +- Auto-fix issues it finds +- Run lint and typecheck to verify + +[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi] + +[codex-inline, Kilo, Antigravity, Windsurf] + +Load the `trellis-check` skill and verify the code per its guidance: +- Spec compliance +- lint / type-check / tests +- Cross-layer consistency (when changes span layers) + +If issues are found → fix → re-check, until green. + +[/codex-inline, Kilo, Antigravity, Windsurf] + +#### 2.3 Rollback `[on demand]` + +- `check` reveals a prd defect → return to Phase 1, fix `prd.md`, then redo 2.1 +- Implementation went wrong → revert code, redo 2.1 +- Need more research → research (same as Phase 1.2), write findings into `research/` + +--- + +## Phase 3: Finish + +Goal: ensure code quality, capture lessons, record the work. + +#### 3.1 Quality verification `[required · repeatable]` + +Load the `trellis-check` skill and do a final verification: +- Spec compliance +- lint / type-check / tests +- Cross-layer consistency (when changes span layers) + +If issues are found → fix → re-check, until green. + +#### 3.2 Debug retrospective `[on demand]` + +If this task involved repeated debugging (the same issue was fixed multiple times), load the `trellis-break-loop` skill to: +- Classify the root cause +- Explain why earlier fixes failed +- Propose prevention + +The goal is to capture debugging lessons so the same class of issue doesn't recur. + +#### 3.3 Spec update `[required · once]` + +Load the `trellis-update-spec` skill and review whether this task produced new knowledge worth recording: +- Newly discovered patterns or conventions +- Pitfalls you hit +- New technical decisions + +Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "nothing to update", walk through the judgment. + +#### 3.4 Commit changes `[required · once]` + +The AI drives a batched commit of this task's code changes so `/finish-work` can run cleanly afterwards. Goal: produce work commits FIRST, then bookkeeping (archive + journal) commits land after — never interleaved. + +**Step-by-step**: + +1. **Inspect dirty state**: + ```bash + git status --porcelain + ``` + Snapshot every dirty path. If the working tree is clean, skip to 3.5. + +2. **Learn commit style** from recent history (so drafted messages blend in): + ```bash + git log --oneline -5 + ``` + Note the prefix convention (`feat:` / `fix:` / `chore:` / `docs:` ...), language (中文/English), and length style. + +3. **Classify dirty files into two groups**: + - **AI-edited this session** — files you wrote/edited via Edit/Write/Bash tool calls in this session. You know what changed and why. + - **Unrecognized** — dirty files you did NOT touch this session (could be the user's manual edits, leftover WIP from a previous session, or unrelated work). Do NOT silently include these. + +4. **Draft a commit plan**. Group AI-edited files into logical commits (1 commit per coherent change unit, not 1 commit per file). Each entry: `<commit message>` + file list. List unrecognized files separately at the bottom. + +5. **Present the plan once, ask for one-shot confirmation**. Format: + ``` + Proposed commits (in order): + 1. <message> + - <file> + - <file> + 2. <message> + - <file> + + Unrecognized dirty files (NOT in any commit — confirm include/exclude): + - <file> + - <file> + + Reply 'ok' / '行' to execute. Reply with edits, or '我自己来' / 'manual' to abort. + ``` + +6. **On confirmation**: run `git add <files>` + `git commit -m "<msg>"` for each batch in order. Do not amend. Do not push. + +7. **On rejection** (user replies "不行" / "我自己来" / "manual" / any pushback on the plan): stop. Do not attempt a second plan. The user will commit by hand; you skip ahead to 3.5 once they confirm. + +**Rules**: +- No `git commit --amend` anywhere — three-stage three-commit flow (work commits → archive commit → journal commit). +- Never push to remote in this step. +- If the user wants different message wording but accepts the file grouping, edit the message and re-confirm once — but if they reject the grouping, exit to manual mode. +- The batched plan is one prompt; do not prompt per commit. + +#### 3.5 Wrap-up reminder + +After the above, remind the user they can run `/finish-work` to wrap up (archive the task, record the session). + +--- + +## Customizing Trellis (for forks) + +This section is for developers who want to modify the Trellis workflow itself. All customization is done by editing this file; the scripts are parsers only. + +### Changing what a step means + +Edit the corresponding step's walkthrough body in the Phase 1 / 2 / 3 sections above. **Critical constraint**: if you change a step's `[required · once]` marker or add a new `[required · once]` step, you MUST also add a matching enforcement line to that phase's `[workflow-state:STATUS]` tag block — otherwise the per-turn breadcrumb omits the reinforcement, and the AI silently skips the step. The regression tests assert this. + +All 4 tag blocks live in the `## Phase Index` section above, immediately after each phase summary: + +| Scope | Corresponding tag | +|---|---| +| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) | +| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) | +| Phase 2 + Phase 3.1–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) | +| After Phase 3.5 (archived) | `[workflow-state:completed]` (after Phase 3 summary; **currently DEAD**) | + +### Changing the per-turn prompt text + +Directly edit the body of the corresponding `[workflow-state:STATUS]` block. After editing, run `trellis update` (if you're a template maintainer) or restart your AI session (if you're customizing your own project) — no script changes required. + +### Adding a custom status + +Add a new block: + +``` +[workflow-state:my-status] +your per-turn prompt text +[/workflow-state:my-status] +``` + +Constraints: +- STATUS charset: `[A-Za-z0-9_-]+` (underscores and hyphens allowed, e.g. `in-review`, `blocked-by-team`) +- A lifecycle hook must write `task.json.status` to your custom value, otherwise the tag is never read +- Lifecycle hooks live in `task.json.hooks.after_*` and bind to one of `after_create / after_start / after_finish / after_archive` + +### Adding a lifecycle hook + +Add a `hooks` field to your `task.json`: + +```json +{ + "hooks": { + "after_finish": [ + "your-script-or-command-here" + ] + } +} +``` + +Supported events: `after_create / after_start / after_finish / after_archive`. Note that `after_finish` ≠ a status change (it only clears the active-task pointer); use `after_archive` for "task is done" notifications. + +### Full contract + +For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see: + +- `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants +- `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..a12223bc09 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +Repository Guidelines + +Do not write to a super file!!!! Do not write to a super file!!!! Do not write to a super file!!!! +All actual build and test commands must be executed within the GitHub workflow; running them on your local machine is prohibited—local device performance is insufficient. + +Do not execute any installation commands; simply modify the code. + +Regarding the garbled text issue you mentioned, it has been confirmed that it is not caused by file corruption. The file can be read correctly in PowerShell using the following method: +powershell +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 +Get-Content -Encoding UTF8 file-path +Each time you complete the addition or modification of a feature according to my requirements, a commit message should be automatically generated and submitted and pushed after you finish modifying the code. When submitting a GPG key, you can temporarily omit the signature. diff --git a/Install-CodexTrellis.ps1 b/Install-CodexTrellis.ps1 new file mode 100644 index 0000000000..b4e39edb8f --- /dev/null +++ b/Install-CodexTrellis.ps1 @@ -0,0 +1,333 @@ +#requires -Version 5.1 +<# +.SYNOPSIS +Deploy the Janus Codex + Trellis workflow files into a project root. + +.DESCRIPTION +Copies the reusable workflow layer only: +- .codex/ +- .trellis/scripts/ +- .trellis/spec/ +- .trellis/workflow.md and .trellis/config.yaml +- .agents/skills/trellis-* + +It deliberately does not copy .trellis/tasks, .trellis/.runtime, or +.trellis/workspace from the source project. + +.EXAMPLE +.\Install-CodexTrellis.ps1 + +.EXAMPLE +.\Install-CodexTrellis.ps1 -TargetRoot F:\Repositories\GitHub\NewProject -Force + +.EXAMPLE +.\Install-CodexTrellis.ps1 -ConfigureUserConfig +#> + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [string]$SourceRoot = 'F:\Repositories\GitHub\jans\Janus', + [string]$TargetRoot = $PSScriptRoot, + [switch]$Force, + [switch]$ConfigureUserConfig +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +function Resolve-ExistingDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "$Name cannot be empty." + } + + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.ProviderPath -PathType Container)) { + throw "$Name is not a directory: $Path" + } + + return [System.IO.Path]::GetFullPath($resolved.ProviderPath) +} + +function Join-RootPath { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + [Parameter(Mandatory = $true)] + [string]$RelativePath + ) + + return Join-Path -Path $Root -ChildPath $RelativePath +} + +function Assert-SourcePath { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Missing required source $Label`: $Path" + } +} + +function Ensure-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (Test-Path -LiteralPath $Path -PathType Container) { + return + } + + if ($PSCmdlet.ShouldProcess($Path, 'Create directory')) { + New-Item -ItemType Directory -Path $Path -Force | Out-Null + } +} + +function Copy-FileItem { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + Assert-SourcePath -Path $SourcePath -Label 'file' + + if ((Test-Path -LiteralPath $DestinationPath) -and -not $Force) { + throw "Destination file already exists: $DestinationPath. Re-run with -Force to overwrite." + } + + $parent = Split-Path -Parent $DestinationPath + Ensure-Directory -Path $parent + + if ($PSCmdlet.ShouldProcess($DestinationPath, "Copy file from $SourcePath")) { + Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Force:$Force + } +} + +function Copy-DirectoryTree { + param( + [Parameter(Mandatory = $true)] + [string]$SourceDirectory, + [Parameter(Mandatory = $true)] + [string]$DestinationDirectory + ) + + Assert-SourcePath -Path $SourceDirectory -Label 'directory' + + if ((Test-Path -LiteralPath $DestinationDirectory) -and -not $Force) { + throw "Destination directory already exists: $DestinationDirectory. Re-run with -Force to merge and overwrite files." + } + + Ensure-Directory -Path $DestinationDirectory + + $sourceRoot = [System.IO.Path]::GetFullPath($SourceDirectory).TrimEnd('\', '/') + $items = Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse + + foreach ($item in $items) { + $relative = $item.FullName.Substring($sourceRoot.Length).TrimStart('\', '/') + $destination = Join-Path -Path $DestinationDirectory -ChildPath $relative + + if ($item.PSIsContainer) { + Ensure-Directory -Path $destination + continue + } + + if ((Test-Path -LiteralPath $destination) -and -not $Force) { + throw "Destination file already exists: $destination. Re-run with -Force to overwrite." + } + + $parent = Split-Path -Parent $destination + Ensure-Directory -Path $parent + + if ($PSCmdlet.ShouldProcess($destination, "Copy file from $($item.FullName)")) { + Copy-Item -LiteralPath $item.FullName -Destination $destination -Force:$Force + } + } +} + +function Copy-TrellisSkills { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedSourceRoot, + [Parameter(Mandatory = $true)] + [string]$ResolvedTargetRoot + ) + + $sourceSkills = Join-RootPath -Root $ResolvedSourceRoot -RelativePath '.agents\skills' + Assert-SourcePath -Path $sourceSkills -Label 'skills directory' + + $skills = Get-ChildItem -LiteralPath $sourceSkills -Directory -Force | + Where-Object { $_.Name -like 'trellis-*' } + + if (-not $skills) { + throw "No trellis-* skills found under $sourceSkills" + } + + foreach ($skill in $skills) { + $destination = Join-RootPath -Root $ResolvedTargetRoot -RelativePath ".agents\skills\$($skill.Name)" + Copy-DirectoryTree -SourceDirectory $skill.FullName -DestinationDirectory $destination + } +} + +function Set-HooksFeature { + param( + [Parameter(Mandatory = $true)] + [string]$Content + ) + + $normalized = $Content -replace "`r`n", "`n" + $lines = [System.Collections.Generic.List[string]]::new() + foreach ($line in ($normalized -split "`n", -1)) { + $lines.Add($line) + } + + if ($lines.Count -eq 1 -and $lines[0] -eq '') { + $lines.Clear() + } + + $featuresStart = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^\s*\[features\]\s*$') { + $featuresStart = $i + break + } + } + + if ($featuresStart -lt 0) { + if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -ne '') { + $lines.Add('') + } + $lines.Add('[features]') + $lines.Add('hooks = true') + return ($lines -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine + } + + $featuresEnd = $lines.Count + for ($i = $featuresStart + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^\s*\[.+\]\s*$') { + $featuresEnd = $i + break + } + } + + for ($i = $featuresStart + 1; $i -lt $featuresEnd; $i++) { + if ($lines[$i] -match '^\s*hooks\s*=') { + $lines[$i] = 'hooks = true' + return ($lines -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine + } + } + + $lines.Insert($featuresStart + 1, 'hooks = true') + return ($lines -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine +} + +function Update-CodexUserConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedTargetRoot + ) + + $codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path -Path $HOME -ChildPath '.codex' } + $configPath = Join-Path -Path $codexHome -ChildPath 'config.toml' + $targetForToml = $ResolvedTargetRoot.Replace('\', '/') + $projectHeader = "[projects.`"$targetForToml`"]" + + Ensure-Directory -Path $codexHome + + $content = '' + if (Test-Path -LiteralPath $configPath) { + $content = Get-Content -LiteralPath $configPath -Encoding UTF8 -Raw + } + + $updated = Set-HooksFeature -Content $content + + if (-not $updated.Contains($projectHeader)) { + if ($updated.Trim().Length -gt 0) { + $updated = $updated.TrimEnd() + [Environment]::NewLine + [Environment]::NewLine + } + $updated += "$projectHeader" + [Environment]::NewLine + $updated += 'trust_level = "trusted"' + [Environment]::NewLine + } + + if ($PSCmdlet.ShouldProcess($configPath, 'Update Codex user config')) { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($configPath, $updated, $utf8NoBom) + } +} + +$resolvedSourceRoot = Resolve-ExistingDirectory -Path $SourceRoot -Name 'SourceRoot' +$resolvedTargetRoot = Resolve-ExistingDirectory -Path $TargetRoot -Name 'TargetRoot' + +if ($resolvedSourceRoot -eq $resolvedTargetRoot) { + throw 'SourceRoot and TargetRoot must be different directories.' +} + +$requiredDirectories = @( + '.codex', + '.trellis\scripts', + '.trellis\spec' +) + +$requiredFiles = @( + '.trellis\workflow.md', + '.trellis\config.yaml' +) + +$optionalFiles = @( + '.trellis\.version', + '.trellis\.developer', + '.trellis\.gitignore', + '.trellis\.template-hashes.json' +) + +foreach ($relativePath in $requiredDirectories) { + Copy-DirectoryTree ` + -SourceDirectory (Join-RootPath -Root $resolvedSourceRoot -RelativePath $relativePath) ` + -DestinationDirectory (Join-RootPath -Root $resolvedTargetRoot -RelativePath $relativePath) +} + +foreach ($relativePath in $requiredFiles) { + Copy-FileItem ` + -SourcePath (Join-RootPath -Root $resolvedSourceRoot -RelativePath $relativePath) ` + -DestinationPath (Join-RootPath -Root $resolvedTargetRoot -RelativePath $relativePath) +} + +foreach ($relativePath in $optionalFiles) { + $sourcePath = Join-RootPath -Root $resolvedSourceRoot -RelativePath $relativePath + if (Test-Path -LiteralPath $sourcePath) { + Copy-FileItem ` + -SourcePath $sourcePath ` + -DestinationPath (Join-RootPath -Root $resolvedTargetRoot -RelativePath $relativePath) + } +} + +Copy-TrellisSkills -ResolvedSourceRoot $resolvedSourceRoot -ResolvedTargetRoot $resolvedTargetRoot + +Ensure-Directory -Path (Join-RootPath -Root $resolvedTargetRoot -RelativePath '.trellis\tasks') +Ensure-Directory -Path (Join-RootPath -Root $resolvedTargetRoot -RelativePath '.trellis\workspace') + +if ($ConfigureUserConfig) { + Update-CodexUserConfig -ResolvedTargetRoot $resolvedTargetRoot +} + +Write-Host "Codex + Trellis workflow deployed to: $resolvedTargetRoot" +Write-Host 'Skipped source task/runtime data: .trellis/tasks, .trellis/.runtime, .trellis/workspace' + +if (-not $ConfigureUserConfig) { + Write-Host 'Next: add this project to ~/.codex/config.toml as trusted, enable hooks, then run /hooks in Codex.' +} diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 6b111d165d..0000000000 --- a/LICENSE.md +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> -Everyone is permitted to copy and distribute verbatim copies -of this license document, but changing it is not allowed. - - Preamble - -The GNU General Public License is a free, copyleft license for -software and other kinds of works. - -The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - -When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - -To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - -For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - -Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - -For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - -Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - -Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - -The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - -0. Definitions. - -"This License" refers to version 3 of the GNU General Public License. - -"Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based -on the Program. - -To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - -To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - -1. Source Code. - -The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - -A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - -The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - -The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - -The Corresponding Source for a work in source code form is that -same work. - -2. Basic Permissions. - -All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. - -No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - -When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - -4. Conveying Verbatim Copies. - -You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. - -You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - -A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - -6. Conveying Non-Source Forms. - -You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - -"Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - -If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - -The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - -7. Additional Terms. - -"Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - -All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - -8. Termination. - -You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - -However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - -Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - -9. Acceptance Not Required for Having Copies. - -You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. - -Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - -11. Patents. - -A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - -If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - -A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. - -If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - -13. Use with the GNU Affero General Public License. - -Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - -14. Revised Versions of this License. - -The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - -Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - -15. Disclaimer of Warranty. - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. - -If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - <one line to give the program's name and a brief idea of what it does.> - Copyright (C) <year> <name of author> - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. - -Also add information on how to contact you by electronic and paper mail. - -If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - <program> Copyright (C) <year> <name of author> - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - -You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -<https://www.gnu.org/licenses/>. - -The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/README.md b/README.md index 8e0295f99c..97babea135 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ <br> </h1> -<h4 align="center">Source code for EcoEnchants, a premium spigot plugin.</h4> +<h4 align="center">EcoEnchants, a premium Spigot plugin.</h4> <p align="center"> <a href="https://polymart.org/resource/1-16-1-17-ecoenchants.490"> @@ -30,9 +30,9 @@ [![Docs](https://i.imgur.com/uS2O3ll.png)](https://plugins.auxilor.io/ecoenchants/all-enchantments) [![Compatibility](https://i.imgur.com/MxiF57Z.png)]() -## License +## Commercial License -*Click here to read [the entire license](https://github.com/Auxilor/EcoEnchants/blob/master/LICENSE.md).* +EcoEnchants is distributed as a closed-source commercial plugin. Commercial builds require online license verification during startup. <h1 align="center"> <br> diff --git a/build.gradle.kts b/build.gradle.kts index 51aca7897a..ab75064101 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,8 +11,38 @@ plugins { group = "com.willfp" version = findProperty("version")!! -val libreforgeVersion = findProperty("libreforge-version") + +// useGradleVersions=true (set by release workflows) pins dependencies to the +// versions in gradle.properties; otherwise dev builds track the latest master snapshot. +val useGradleVersions = findProperty("useGradleVersions") == "true" +val libreforgeVersion = if (useGradleVersions) findProperty("libreforge-version") else "dev-SNAPSHOT" val ecoVersion = findProperty("eco-version") +val proguardVersion = findProperty("proguard-version") ?: "7.9.1" +val vineflowerVersion = findProperty("vineflower-version") ?: "1.12.0" + +val embeddedLibreforge by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + isTransitive = false +} + +val decompiler by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + isTransitive = true +} + +val obfuscator by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + isTransitive = true +} + +val obfuscationLibraries by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + isTransitive = false +} base { archivesName.set(project.name) @@ -20,12 +50,175 @@ base { dependencies { implementation(project(":eco-core:core-plugin")) - implementation(project(":eco-core:core-nms:v1_21_8", configuration = "reobf")) - implementation(project(":eco-core:core-nms:v1_21_10", configuration = "reobf")) - implementation(project(":eco-core:core-nms:v1_21_11", configuration = "reobf")) - implementation(project(":eco-core:core-nms:v26_1_1", configuration = "shadow")) - implementation(project(":eco-core:core-nms:v26_1_2", configuration = "shadow")) - implementation(project(":eco-core:core-nms:v26_2", configuration = "shadow")) + implementation(project(":eco-core:core-nms:v1_21_8", "reobf")) + implementation(project(":eco-core:core-nms:v1_21_10", "reobf")) + implementation(project(":eco-core:core-nms:v1_21_11", "reobf")) + implementation(project(":eco-core:core-nms:v26_1_1", "shadow")) + implementation(project(":eco-core:core-nms:v26_1_2", "shadow")) + implementation(project(":eco-core:core-nms:v26_2", "shadow")) + + embeddedLibreforge("com.willfp:libreforge:${libreforgeVersion!!}:shadow@jar") + decompiler("org.vineflower:vineflower:$vineflowerVersion") + obfuscator("com.guardsquare:proguard-base:$proguardVersion") + + obfuscationLibraries(fileTree("lib") { + include("*.jar") + }) + obfuscationLibraries("com.willfp:eco:$ecoVersion") + obfuscationLibraries("com.willfp:libreforge:${libreforgeVersion!!}:shadow@jar") + obfuscationLibraries("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + obfuscationLibraries("net.essentialsx:EssentialsX:2.19.7") { + exclude("*", "*") + } + obfuscationLibraries("org.jetbrains:annotations:26.0.2") + obfuscationLibraries("org.jetbrains.kotlin:kotlin-stdlib:2.3.0") + obfuscationLibraries("com.github.ben-manes.caffeine:caffeine:3.2.3") +} + +tasks { + shadowJar { + from(embeddedLibreforge) { + rename { "libreforge-$libreforgeVersion-shadow.jar" } + } + } + + val nativeServerClassGlobs = listOf( + "com/destroystokyo/**", + "com/mojang/**", + "io/papermc/**", + "net/minecraft/**", + "org/bukkit/**", + "org/spigotmc/**" + ) + + val pluginDecompileClasses = layout.buildDirectory.dir("decompile/input/plugin-classes") + val proguardRules = layout.projectDirectory.file("proguard-rules.pro") + val proguardConfig = layout.buildDirectory.file("tmp/proguard/ecoenchants.pro") + val obfuscatedPluginJar = layout.buildDirectory.file("libs/${base.archivesName.get()}-${project.version}-obfuscated.jar") + + val preparePluginDecompileClasses by registering(Sync::class) { + group = "decompilation" + description = "Copies only EcoEnchants plugin classes from the shaded jar for isolated decompilation." + + dependsOn(shadowJar) + + from(shadowJar.flatMap { it.archiveFile }.map { zipTree(it) }) { + include("com/willfp/ecoenchants/**") + exclude("com/willfp/ecoenchants/libreforge/loader/**") + exclude(nativeServerClassGlobs) + includeEmptyDirs = false + } + + into(pluginDecompileClasses) + + doLast { + val nativeClasses = fileTree(pluginDecompileClasses.get().asFile).matching { + include(nativeServerClassGlobs) + }.files + + check(nativeClasses.isEmpty()) { + "Native server classes were copied into the decompile input." + } + } + } + + register<JavaExec>("decompilePlugin") { + group = "decompilation" + description = "Decompiles EcoEnchants plugin classes into build/decompiled/plugin without touching source files." + + dependsOn(preparePluginDecompileClasses) + + classpath = decompiler + mainClass.set("org.jetbrains.java.decompiler.main.decompiler.ConsoleDecompiler") + jvmArgs("-Xmx1g") + + val outputDir = layout.buildDirectory.dir("decompiled/plugin") + + inputs.dir(pluginDecompileClasses) + outputs.dir(outputDir) + + doFirst { + delete(outputDir) + outputDir.get().asFile.mkdirs() + args( + "-dgs=1", + "-asc=1", + "-rsy=1", + "-log=WARN", + pluginDecompileClasses.get().asFile.absolutePath, + outputDir.get().asFile.absolutePath + ) + } + } + + val obfuscatePlugin by registering(JavaExec::class) { + group = "obfuscation" + description = "Obfuscates the final plugin jar into build/libs without rewriting source files." + + dependsOn(shadowJar) + + classpath = obfuscator + mainClass.set("proguard.ProGuard") + jvmArgs("-Xmx2g") + + inputs.file(shadowJar.flatMap { it.archiveFile }) + inputs.file(proguardRules) + inputs.files(obfuscationLibraries) + outputs.file(obfuscatedPluginJar) + + doFirst { + fun File.proguardPath(): String = "'${absolutePath.replace("\\", "/")}'" + + val inputJar = shadowJar.get().archiveFile.get().asFile + val outputJar = obfuscatedPluginJar.get().asFile + val configFile = proguardConfig.get().asFile + val nativeFilter = nativeServerClassGlobs.joinToString(",") { "!$it" } + val jmods = File(System.getProperty("java.home"), "jmods") + val javaLibraries = jmods + .listFiles { file -> file.extension == "jmod" } + ?.sortedBy { it.name } + .orEmpty() + .joinToString(System.lineSeparator()) { + "-libraryjars ${it.proguardPath()}(!**.jar;!module-info.class;!classes/module-info.class)" + } + val dependencyLibraries = obfuscationLibraries.files + .filter { it.isFile } + .distinctBy { it.absolutePath } + .sortedBy { it.name } + .joinToString(System.lineSeparator()) { + "-libraryjars ${it.proguardPath()}(!META-INF/versions/**;!module-info.class)" + } + + delete(outputJar) + outputJar.parentFile.mkdirs() + configFile.parentFile.mkdirs() + configFile.writeText( + """ + -injars ${inputJar.proguardPath()}($nativeFilter) + -outjars ${outputJar.proguardPath()} + $javaLibraries + $dependencyLibraries + -include ${proguardRules.asFile.proguardPath()} + """.trimIndent() + ) + + setArgs(listOf("@${configFile.absolutePath}")) + } + + doLast { + val nativeClasses = zipTree(obfuscatedPluginJar.get().asFile).matching { + include(nativeServerClassGlobs) + }.files + + check(nativeClasses.isEmpty()) { + "Native server classes were copied into the obfuscated plugin jar." + } + } + } + + build { + dependsOn(obfuscatePlugin) + } } publishing { @@ -37,6 +230,9 @@ publishing { // maven-releases (served publicly via the maven-public group): the API jar create<MavenPublication>("release") { artifactId = rootProject.name + // Keep the Java component so the generated POM retains the dependency + // metadata downstream consumers need when compiling against the API. + from(components["java"]) } } repositories { @@ -59,8 +255,10 @@ publishing { } } -// Neither publication is attached to a software component, so only the single jar -// and its pom are published - no sources, javadoc, or classified variants. +// The release publication carries the Java component's dependency metadata into the +// POM, but its main artifact is swapped below for the core-plugin API jar - so only +// that single jar (plus the POM) is published, no sources, javadoc, or classified +// variants. afterEvaluate { publishing.publications.named<MavenPublication>("private") { artifact(tasks.named("libreforgeJar")) @@ -71,6 +269,8 @@ afterEvaluate { // relocates kotlin.* into com.willfp.eco.libs.kotlin, which rewrites @kotlin.Metadata // and makes the whole API read as Java. eco publishes its API the same way. publishing.publications.named<MavenPublication>("release") { + // Drop the component's default (root) jar; publish the core-plugin API jar instead. + artifacts.removeIf { it.classifier.isNullOrEmpty() && it.extension == "jar" } artifact(project(":eco-core:core-plugin").tasks.named<Jar>("jar")) { classifier = "" } diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts new file mode 100644 index 0000000000..0ad8369a60 --- /dev/null +++ b/documentation/.vitepress/config.mts @@ -0,0 +1,124 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'EcoEnchants', + titleTemplate: ':title | 服主报告', + description: '面向 Minecraft 服务器主的 EcoEnchants 功能调研与使用指南', + lang: 'zh-CN', + cleanUrls: true, + appearance: true, + markdown: { + lineNumbers: true + }, + head: [ + ['link', { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }], + ['meta', { name: 'theme-color', content: '#16a34a' }], + ['meta', { property: 'og:title', content: 'EcoEnchants 服主报告' }], + ['meta', { property: 'og:description', content: '面向 Minecraft 服务器主的 EcoEnchants 功能调研、advanced 分支能力说明与运维指南。' }], + ['meta', { property: 'og:type', content: 'website' }] + ], + themeConfig: { + logo: '/brand-mark.svg', + siteTitle: 'EcoEnchants', + nav: [ + { text: '主页说明', link: '/guide/homepage', activeMatch: '^/guide/homepage' }, + { text: '报告', link: '/report/', activeMatch: '^/report/(?!advanced|chloemlla-advanced)' }, + { text: 'Advanced', link: '/report/chloemlla-advanced', activeMatch: '^/report/(advanced|chloemlla-advanced)' }, + { text: '部署', link: '/guide/vercel', activeMatch: '^/guide/vercel' }, + { text: '原文档', link: '/ecoenchants/', activeMatch: '^/ecoenchants/' } + ], + sidebar: [ + { + text: '文档站部署', + items: [ + { text: '主页说明', link: '/guide/homepage' }, + { text: 'Vercel 部署', link: '/guide/vercel' } + ] + }, + { + text: '服主调研报告', + items: [ + { text: '总览', link: '/report/' }, + { text: '部署与运行边界', link: '/report/deployment-runtime' }, + { text: '玩法与平衡模型', link: '/report/gameplay-balance' }, + { text: '附魔库与自定义附魔', link: '/report/enchantments' }, + { text: '命令、权限与 GUI', link: '/report/commands-gui' }, + { text: '配置运营手册', link: '/report/configuration' }, + { text: '兼容性与集成', link: '/report/integrations' } + ] + }, + { + text: 'Chloemlla advanced 新功能', + items: [ + { text: '功能总览', link: '/report/chloemlla-advanced' }, + { text: '授权与服务状态', link: '/report/advanced-license-services' }, + { text: '远程运维与备份', link: '/report/advanced-remote-operations' }, + { text: '遥测与环境探针', link: '/report/advanced-telemetry-probe' }, + { text: 'GUI 与玩家体验', link: '/report/advanced-gui-experience' }, + { text: '排障与上线清单', link: '/report/advanced-troubleshooting' } + ] + }, + { + text: '现有 EcoEnchants 文档', + collapsed: true, + items: [ + { text: 'EcoEnchants', link: '/ecoenchants/' }, + { text: 'Gameplay', link: '/ecoenchants/the-gameplay' }, + { text: 'Commands and Permissions', link: '/ecoenchants/commands-and-permissions' }, + { text: 'Plugin Config', link: '/ecoenchants/plugin-config' }, + { text: 'Player Experience Optimizations', link: '/ecoenchants/player-experience-optimizations' }, + { text: 'Runtime Telemetry API', link: '/ecoenchants/runtime-telemetry-api' }, + { text: 'Secure RPC Operations API', link: '/ecoenchants/secure-rpc-operations-api' } + ] + } + ], + search: { + provider: 'local', + options: { + translations: { + button: { + buttonText: '搜索文档', + buttonAriaLabel: '搜索文档' + }, + modal: { + noResultsText: '没有找到结果', + resetButtonTitle: '清除搜索', + footer: { + selectText: '选择', + navigateText: '切换', + closeText: '关闭' + } + } + } + } + }, + outline: { + level: [2, 3], + label: '本页目录' + }, + editLink: { + pattern: 'https://github.com/Chloemlla/EcoEnchants/edit/advanced/documentation/:path', + text: '在 GitHub 编辑此页' + }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/Chloemlla/EcoEnchants' } + ], + docFooter: { + prev: '上一页', + next: '下一页' + }, + lastUpdated: { + text: '最后更新' + }, + footer: { + message: 'EcoEnchants advanced 分支文档站', + copyright: 'Released with repository documentation updates.' + }, + darkModeSwitchLabel: '外观', + lightModeSwitchTitle: '切换到浅色模式', + darkModeSwitchTitle: '切换到深色模式', + sidebarMenuLabel: '菜单', + returnToTopLabel: '返回顶部' + }, + lastUpdated: true +}) diff --git a/documentation/.vitepress/theme/custom.css b/documentation/.vitepress/theme/custom.css new file mode 100644 index 0000000000..a0de6c4eb0 --- /dev/null +++ b/documentation/.vitepress/theme/custom.css @@ -0,0 +1,840 @@ +:root { + --vp-c-brand-1: #15803d; + --vp-c-brand-2: #16a34a; + --vp-c-brand-3: #22c55e; + --vp-c-brand-soft: rgba(34, 197, 94, 0.14); + --vp-c-bg: #fbfcf8; + --vp-c-bg-alt: #f1f5ef; + --vp-c-bg-soft: #eef7ef; + --vp-c-border: rgba(34, 83, 59, 0.18); + --vp-c-divider: rgba(34, 83, 59, 0.14); + --vp-c-text-1: #17211b; + --vp-c-text-2: #4b5b51; + --vp-c-text-3: #6c786e; + --vp-font-family-base: "Inter", "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; + --vp-font-family-mono: "JetBrains Mono", "Cascadia Code", "SFMono-Regular", Consolas, monospace; + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: linear-gradient(120deg, #16a34a 0%, #d97706 48%, #0284c7 100%); + --ee-accent-gold: #d97706; + --ee-accent-sky: #0284c7; + --ee-surface: rgba(255, 255, 255, 0.76); + --ee-surface-strong: rgba(255, 255, 255, 0.94); + --ee-surface-solid: #ffffff; + --ee-shadow: 0 24px 70px rgba(27, 45, 31, 0.14); + --ee-shadow-soft: 0 14px 38px rgba(27, 45, 31, 0.1); +} + +.dark { + --vp-c-brand-1: #86efac; + --vp-c-brand-2: #4ade80; + --vp-c-brand-3: #22c55e; + --vp-c-brand-soft: rgba(74, 222, 128, 0.14); + --vp-c-bg: #0e1713; + --vp-c-bg-alt: #121f19; + --vp-c-bg-soft: #16271f; + --vp-c-border: rgba(155, 229, 187, 0.18); + --vp-c-divider: rgba(155, 229, 187, 0.13); + --vp-c-text-1: #f2f8f0; + --vp-c-text-2: #c7d6ca; + --vp-c-text-3: #94a99a; + --vp-home-hero-name-background: linear-gradient(120deg, #86efac 0%, #facc15 48%, #38bdf8 100%); + --ee-surface: rgba(18, 31, 25, 0.78); + --ee-surface-strong: rgba(20, 34, 28, 0.94); + --ee-surface-solid: #14221c; + --ee-shadow: 0 24px 70px rgba(0, 0, 0, 0.36); + --ee-shadow-soft: 0 14px 38px rgba(0, 0, 0, 0.24); +} + +* { + letter-spacing: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + min-width: 320px; + background-color: var(--vp-c-bg); + background-image: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent 360px), + linear-gradient(rgba(21, 128, 61, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(2, 132, 199, 0.035) 1px, transparent 1px); + background-size: 100% 100%, 42px 42px, 42px 42px; + text-rendering: optimizeLegibility; +} + +.dark body { + background-image: + linear-gradient(180deg, rgba(14, 23, 19, 0.72), transparent 360px), + linear-gradient(rgba(134, 239, 172, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(56, 189, 248, 0.035) 1px, transparent 1px); + background-size: 100% 100%, 42px 42px, 42px 42px; +} + +::selection { + color: #092016; + background: rgba(134, 239, 172, 0.72); +} + +.VPNav { + border-bottom: 1px solid var(--vp-c-divider); +} + +.VPNavBar { + background: rgba(251, 252, 248, 0.86); + box-shadow: 0 10px 30px rgba(27, 45, 31, 0.06); +} + +.dark .VPNavBar { + background: rgba(14, 23, 19, 0.84); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24); +} + +@supports (backdrop-filter: blur(16px)) { + .VPNavBar { + backdrop-filter: blur(16px); + } +} + +.VPNavBarTitle .title { + font-weight: 800; + letter-spacing: 0; +} + +.VPNavBarTitle .logo { + border-radius: 8px; + box-shadow: 0 0 0 1px var(--vp-c-divider); +} + +.VPNavBarMenuLink, +.VPNavBarMenuGroup .button { + font-weight: 650; +} + +.VPNavBarMenuLink.active, +.VPNavBarMenuGroup.active .button { + color: var(--vp-c-brand-1); +} + +.VPNavBarSearch .DocSearch-Button { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--ee-surface); + transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease; +} + +.VPNavBarSearch .DocSearch-Button:hover { + border-color: rgba(2, 132, 199, 0.36); + background: var(--ee-surface-strong); + box-shadow: var(--ee-shadow-soft); +} + +.VPNavScreen { + background: var(--vp-c-bg); +} + +.VPNavScreenMenuLink, +.VPNavScreenMenuGroup .button { + border-radius: 8px; +} + +.VPHome { + padding-bottom: 84px; +} + +.VPHomeHero { + position: relative; + overflow: hidden; + padding: 96px 24px 54px; +} + +.VPHomeHero::before { + position: absolute; + inset: 0; + content: ""; + background: + linear-gradient(105deg, rgba(34, 197, 94, 0.15) 0%, transparent 34%), + linear-gradient(165deg, transparent 0%, rgba(217, 119, 6, 0.12) 54%, transparent 82%), + repeating-linear-gradient(135deg, transparent 0 18px, rgba(2, 132, 199, 0.045) 18px 19px); + border-bottom: 1px solid var(--vp-c-divider); +} + +.VPHomeHero .container { + position: relative; + z-index: 1; + max-width: 1184px; + gap: 52px; +} + +.VPHomeHero .main { + max-width: 650px; +} + +.VPHomeHero .name { + max-width: 650px; + font-size: 68px; + line-height: 1; + font-weight: 850; +} + +.VPHomeHero .text { + max-width: 650px; + margin-top: 14px; + font-size: 44px; + line-height: 1.12; + font-weight: 800; + color: var(--vp-c-text-1); +} + +.VPHomeHero .tagline { + max-width: 640px; + padding-top: 22px; + font-size: 19px; + line-height: 1.75; + color: var(--vp-c-text-2); +} + +.VPHomeHero .actions { + gap: 12px; + padding-top: 30px; +} + +.VPHomeHero .image-bg { + display: none; +} + +.VPHomeHero .image { + display: flex; + align-items: center; + justify-content: center; +} + +.VPHomeHero .image-container { + position: relative; + width: 540px; + height: auto; + transform: none; +} + +.VPHomeHero .image-src { + position: relative; + top: auto; + left: auto; + width: 100%; + max-width: 540px; + aspect-ratio: 16 / 10; + object-fit: cover; + transform: none; + border: 1px solid rgba(167, 243, 208, 0.38); + border-radius: 8px; + box-shadow: var(--ee-shadow); +} + +.VPButton { + min-height: 44px; + border-radius: 8px !important; + font-weight: 750; +} + +.VPButton.brand { + border-color: #15803d; + background: #15803d; + box-shadow: 0 12px 28px rgba(21, 128, 61, 0.22); +} + +.VPButton.brand:hover { + border-color: #166534; + background: #166534; +} + +.VPButton.alt { + border: 1px solid var(--vp-c-divider); + background: var(--ee-surface-strong); + color: var(--vp-c-text-1); +} + +.VPButton.alt:hover { + border-color: rgba(21, 128, 61, 0.42); + color: var(--vp-c-brand-1); +} + +.VPFeatures { + padding: 28px 24px 0; +} + +.VPFeatures .container { + max-width: 1184px; +} + +.VPFeatures .items { + gap: 16px; +} + +.VPFeature { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: + linear-gradient(180deg, var(--ee-surface-strong), var(--ee-surface)), + var(--vp-c-bg-soft); + box-shadow: 0 10px 36px rgba(29, 49, 36, 0.07); + transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease; +} + +.VPFeature:hover { + border-color: rgba(21, 128, 61, 0.35); + box-shadow: var(--ee-shadow); + transform: translateY(-2px); +} + +.VPFeature .title { + font-size: 18px; + font-weight: 800; +} + +.VPFeature .details { + line-height: 1.72; + color: var(--vp-c-text-2); +} + +.VPHomeContent { + padding: 0 24px; +} + +.VPHomeContent .vp-doc { + max-width: 1184px; + margin: 0 auto; +} + +.home-section { + margin-top: 48px; +} + +.home-section-header { + max-width: 780px; + margin-bottom: 18px; +} + +.home-eyebrow { + margin: 0 0 8px; + color: var(--ee-accent-gold); + font-size: 13px; + font-weight: 800; + text-transform: uppercase; +} + +.home-section h2 { + margin: 0; + border: 0; + font-size: 28px; + line-height: 1.25; +} + +.home-section p { + color: var(--vp-c-text-2); +} + +.home-command { + display: grid; + grid-template-columns: minmax(0, 0.92fr) minmax(320px, 1.08fr); + gap: 18px; + align-items: stretch; + padding: 18px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(21, 128, 61, 0.1), rgba(2, 132, 199, 0.08)), + var(--ee-surface); + box-shadow: var(--ee-shadow-soft); +} + +.home-command-copy { + padding: 12px 6px 12px 10px; +} + +.home-command-copy h2 { + max-width: 620px; +} + +.home-command-copy p:last-child { + max-width: 660px; + margin-bottom: 0; +} + +.home-command-panel { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--ee-surface-strong); +} + +.home-command-panel div { + padding: 16px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--ee-surface-solid); +} + +.home-command-panel span { + display: block; + margin-bottom: 6px; + color: var(--ee-accent-sky); + font-size: 13px; + font-weight: 800; +} + +.home-command-panel strong { + display: block; + color: var(--vp-c-text-1); + font-size: 17px; + line-height: 1.55; +} + +.home-route-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.route-card { + display: block; + min-height: 206px; + padding: 22px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--ee-surface-strong); + color: var(--vp-c-text-1); + text-decoration: none; + box-shadow: 0 10px 32px rgba(27, 45, 31, 0.08); + transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease; +} + +.route-card:hover { + border-color: rgba(2, 132, 199, 0.34); + box-shadow: var(--ee-shadow); + transform: translateY(-2px); +} + +.route-card strong { + display: block; + margin: 12px 0 8px; + font-size: 19px; +} + +.route-card span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: var(--vp-c-brand-soft); + color: var(--vp-c-brand-1); + font-weight: 850; +} + +.route-card small { + color: var(--vp-c-text-2); + font-size: 14px; + line-height: 1.75; +} + +.home-capability-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.home-capability-grid a { + display: block; + min-height: 190px; + padding: 20px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: + linear-gradient(180deg, var(--ee-surface-strong), var(--ee-surface)), + var(--vp-c-bg-soft); + color: var(--vp-c-text-1); + text-decoration: none; + box-shadow: 0 10px 32px rgba(27, 45, 31, 0.07); + transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease; +} + +.home-capability-grid a:hover { + border-color: rgba(217, 119, 6, 0.4); + box-shadow: var(--ee-shadow); + transform: translateY(-2px); +} + +.home-capability-grid span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: rgba(217, 119, 6, 0.1); + color: var(--ee-accent-gold); + font-size: 12px; + font-weight: 850; +} + +.home-capability-grid strong { + display: block; + margin: 14px 0 8px; + font-size: 18px; + line-height: 1.35; +} + +.home-capability-grid small { + color: var(--vp-c-text-2); + font-size: 14px; + line-height: 1.72; +} + +.home-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 16px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(21, 128, 61, 0.08), rgba(2, 132, 199, 0.07)), + var(--ee-surface); + box-shadow: var(--ee-shadow-soft); +} + +.metric-item { + min-height: 112px; + padding: 18px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--ee-surface-strong); +} + +.metric-item strong { + display: block; + color: var(--vp-c-text-1); + font-size: 24px; + line-height: 1.2; +} + +.metric-item span { + display: block; + margin-top: 8px; + color: var(--vp-c-text-2); + font-size: 14px; + line-height: 1.55; +} + +.home-note { + padding: 20px 22px; + border-left: 4px solid var(--ee-accent-sky); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(2, 132, 199, 0.1), transparent), + var(--ee-surface-strong); + box-shadow: var(--ee-shadow-soft); +} + +.home-note p { + margin: 0; +} + +.VPDoc { + background: transparent; +} + +.VPDoc .content { + padding-top: 40px; +} + +.vp-doc { + font-size: 16px; + line-height: 1.85; +} + +.vp-doc h1 { + font-size: 40px; + line-height: 1.18; + font-weight: 850; + max-width: 820px; +} + +.vp-doc h2 { + position: relative; + margin-top: 48px; + border-top: 1px solid var(--vp-c-divider); + padding-top: 28px; + font-size: 26px; + line-height: 1.35; +} + +.vp-doc h2::before { + position: absolute; + top: -1px; + left: 0; + width: 72px; + height: 3px; + content: ""; + background: linear-gradient(90deg, var(--vp-c-brand-2), var(--ee-accent-gold), var(--ee-accent-sky)); +} + +.vp-doc h3 { + margin-top: 32px; + font-size: 21px; +} + +.vp-doc p, +.vp-doc li { + color: var(--vp-c-text-2); +} + +.vp-doc ul, +.vp-doc ol { + padding-left: 1.35rem; +} + +.vp-doc li::marker { + color: var(--vp-c-brand-1); + font-weight: 800; +} + +.vp-doc strong { + color: var(--vp-c-text-1); +} + +.vp-doc a { + font-weight: 650; + text-decoration-thickness: 1px; + text-underline-offset: 4px; +} + +.vp-doc blockquote { + border-left: 4px solid var(--vp-c-brand-2); + border-radius: 0 8px 8px 0; + background: var(--vp-c-brand-soft); +} + +.vp-doc hr { + height: 1px; + margin: 36px 0; + border: 0; + background: linear-gradient(90deg, transparent, var(--vp-c-divider), transparent); +} + +.vp-doc table { + display: table; + width: 100%; + border-collapse: separate; + border-spacing: 0; + overflow: hidden; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + font-size: 15px; + box-shadow: var(--ee-shadow-soft); +} + +.vp-doc tr:nth-child(2n) { + background: rgba(21, 128, 61, 0.045); +} + +.dark .vp-doc tr:nth-child(2n) { + background: rgba(134, 239, 172, 0.055); +} + +.vp-doc th { + background: rgba(21, 128, 61, 0.1); + color: var(--vp-c-text-1); + font-weight: 800; +} + +.dark .vp-doc th { + background: rgba(134, 239, 172, 0.11); +} + +.vp-doc td, +.vp-doc th { + border-color: var(--vp-c-divider); + vertical-align: top; +} + +.vp-doc :not(pre) > code { + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + padding: 2px 6px; + background: rgba(21, 128, 61, 0.08); + color: #166534; + font-weight: 650; +} + +.dark .vp-doc :not(pre) > code { + color: #bbf7d0; + background: rgba(134, 239, 172, 0.12); +} + +.vp-doc div[class*="language-"] { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + box-shadow: 0 12px 36px rgba(27, 45, 31, 0.08); +} + +.vp-doc div[class*="language-"] pre { + line-height: 1.75; +} + +.vp-doc .custom-block { + border-radius: 8px; + border: 1px solid var(--vp-c-divider); +} + +.VPSidebar { + background: rgba(241, 245, 239, 0.86); + border-right: 1px solid var(--vp-c-divider); +} + +.dark .VPSidebar { + background: rgba(18, 31, 25, 0.88); +} + +.VPSidebarItem .text { + line-height: 1.45; +} + +.VPSidebarItem .link { + border-radius: 8px; +} + +.VPSidebarItem.level-0 > .item > .link > .text, +.VPSidebarItem.level-0 > .item > .text { + font-weight: 850; +} + +.VPSidebarItem.is-active > .item .text { + color: var(--vp-c-brand-1); + font-weight: 800; +} + +.VPDocAsideOutline .outline-link { + border-radius: 6px; +} + +.VPDocAsideOutline .content { + border-left-color: var(--vp-c-divider); +} + +.VPDocFooter { + border-top-color: var(--vp-c-divider); +} + +@media (max-width: 959px) { + .VPHomeHero { + padding-top: 58px; + } + + .VPHomeHero .container { + gap: 28px; + } + + .VPHomeHero .name { + font-size: 46px; + } + + .VPHomeHero .text { + font-size: 32px; + } + + .VPHomeHero .tagline { + font-size: 17px; + } + + .VPHomeHero .image-container { + width: 100%; + } + + .VPHomeHero .image-src { + max-width: 100%; + } + + .home-command { + grid-template-columns: 1fr; + } + + .home-route-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .home-capability-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .home-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 639px) { + .VPHomeHero { + padding: 42px 20px 34px; + } + + .VPHomeHero .name { + font-size: 40px; + } + + .VPHomeHero .text { + font-size: 28px; + } + + .VPHomeHero .actions { + align-items: stretch; + } + + .VPHomeHero .actions .action, + .VPHomeHero .actions .VPButton { + width: 100%; + } + + .home-command { + padding: 14px; + } + + .home-command-copy { + padding: 6px 2px; + } + + .VPFeatures, + .VPHomeContent { + padding-right: 20px; + padding-left: 20px; + } + + .home-section h2 { + font-size: 24px; + } + + .home-route-grid, + .home-capability-grid { + grid-template-columns: 1fr; + } + + .home-metrics { + grid-template-columns: 1fr; + } + + .vp-doc h1 { + font-size: 32px; + } + + .vp-doc h2 { + font-size: 23px; + } + + .vp-doc table { + display: block; + overflow-x: auto; + } +} diff --git a/documentation/.vitepress/theme/index.ts b/documentation/.vitepress/theme/index.ts new file mode 100644 index 0000000000..42fe9a9361 --- /dev/null +++ b/documentation/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from 'vitepress/theme' +import './custom.css' + +export default DefaultTheme diff --git a/documentation/ecoenchants/commands-and-permissions.md b/documentation/ecoenchants/commands-and-permissions.md index a5a9b470c4..ff8fc94be1 100644 --- a/documentation/ecoenchants/commands-and-permissions.md +++ b/documentation/ecoenchants/commands-and-permissions.md @@ -3,17 +3,22 @@ title: "Commands and Permissions" sidebar_position: 4 --- -Every command and its permission node is listed below. Permissions follow the `ecoenchants.command.<name>` pattern and are granted to operators by default. +Every command and its permission node is listed below. Permissions follow the `ecoenchants.command.<name>` pattern and are granted to operators by default. `/ecoenchants` can also be run as `/ee`. | Command | Description | Permission | |------------------------------------------------------------------|-------------------------------------------------------------------------|------------------------------------------| +| `/ecoenchants` or `/ecoenchants help` | Show the commands available to you | `ecoenchants.command.ecoenchants` | | `/ecoenchants reload` | Reload the plugin configs (adding new enchantments requires re-logging) | `ecoenchants.command.reload` | -| `/enchant <enchant> <level>` | Enchant the held item | `ecoenchants.command.enchant` | -| `/enchantinfo <enchant> [level]` | Open the enchant info GUI for the specified enchantment at an optional level | `ecoenchants.command.enchantinfo` | +| `/enchant <enchant> [level]` | Add or remove an enchantment from your held item; use level `0` to remove | `ecoenchants.command.enchant` | +| `/enchant <player> <enchant> [level]` | Console form of `/enchant` | `ecoenchants.command.enchant` | +| `/enchantinfo [enchant] [level]` | Open the enchant info GUI; with no arguments, lists the enchantments on your held item as clickable chat lines | `ecoenchants.command.enchantinfo` | +| `/ecoenchants search <query>` | Search enchantments by name and return clickable results that open their info | `ecoenchants.command.search` | +| `/ecoenchants favorites` | List your bookmarked enchantments as clickable chat lines | `ecoenchants.command.favorites` | | `/ecoenchants gui` | Open the enchantment GUI | `ecoenchants.command.gui` | +| `/ecoenchants services` | Show license, `/api/ecoenchants/v1`, remote operations, and local service status | `ecoenchants.command.services` | +| `/ecoenchants guide [book]` | Show the player guide, or give the player an in-game guide book | `ecoenchants.command.guide` | +| `/ecoenchants experience` | Show player guidance settings and empty-result hint statistics | `ecoenchants.command.experience` | | `/ecoenchants giverandombook <player> [type/rarity] [min] [max]` | Give a player a random enchanted book | `ecoenchants.command.giverandombook` | -| `/ecoenchants import <id>` | Import an enchant from [lrcdb](https://lrcdb.auxilor.io/) | `ecoenchants.command.import` | -| `/ecoenchants export <id>` | Export an enchant to [lrcdb](https://lrcdb.auxilor.io/) | `ecoenchants.command.export` | | `/ecoenchants toggledescriptions` | Let players toggle enchantment descriptions | `ecoenchants.command.toggledescriptions` | ### PlaceholderAPI @@ -34,3 +39,4 @@ Every command and its permission node is listed below. Permissions follow the `e - **Make an enchantment to use these on:** the [How to Make an Enchantment](how-to-make-a-custom-enchant) guide. - **Configure the plugin:** every option in the [Plugin Config](plugin-config). +- **Improve player guidance:** use [Player Experience Optimizations](player-experience-optimizations) to place helpful hints in the GUI and chat flow. diff --git a/documentation/ecoenchants/commercialization-and-license-api.md b/documentation/ecoenchants/commercialization-and-license-api.md new file mode 100644 index 0000000000..995fca37db --- /dev/null +++ b/documentation/ecoenchants/commercialization-and-license-api.md @@ -0,0 +1,1189 @@ +--- +title: "商业化与授权 API 方案" +sidebar_position: 9 +--- + +# EcoEnchants 商业化与授权 API 方案 + +本文档面向 EcoEnchants 这类 Minecraft Paper/Spigot 插件的商业化落地,重点说明: + +- 当前仓库许可证与商业化边界。 +- 合理的收费模式、产品分层与运营策略。 +- 插件侧接入授权系统时应遵守的工程要求。 +- 后端授权 API 需要实现的接口、数据模型、安全要求与验收标准。 + +> 说明:当前仓库 `LICENSE.md` 是 GPLv3。GPLv3 允许收费分发,但不能禁止接收者复制、修改、再分发 GPL 代码;如果你不是全部版权持有人,也不能把现有 GPL 代码直接改成闭源商业授权。商业化设计必须围绕这一事实展开。 + +## 1. 总体结论 + +EcoEnchants 可以商业化,但不建议把“强 DRM 防破解”作为核心价值。 + +更合理的商业化核心应是: + +1. 官方构建与持续兼容更新。 +2. 高质量默认配置、平衡性调优与扩展附魔包。 +3. 私有支持、故障排查、迁移服务、服务器定制。 +4. 官方下载、版本更新、变更日志、兼容性矩阵。 +5. 可选云服务,例如授权管理、配置同步、附魔市场、远程备份。 +6. 品牌、商标、官方渠道可信度。 + +授权 API 的定位应是: + +- 管理官方购买权益、下载资格、支持资格和云服务权益。 +- 给官方构建提供温和的激活、租约和状态校验。 +- 在网络异常时提供离线宽限,避免影响服务器正常开服。 +- 记录必要审计信息,辅助客服处理盗用、退款、超额激活。 + +授权 API 不应承诺: + +- 永久阻止绕过。GPLv3 代码接收者有权修改源码,包括移除授权检查。 +- 通过隐藏算法或硬编码密钥保证安全。插件客户端内的密钥都应视为可被读取。 +- 采集玩家数据或服务器隐私信息来做强绑定。 + +## 2. 法务与许可证边界 + +### 2.1 GPLv3 下可以做的事 + +- 可以收费出售插件二进制包。 +- 可以只向购买者提供官方下载入口。 +- 可以向购买者提供源码或源码获取方式。 +- 可以出售技术支持、更新服务、安装服务、配置服务。 +- 可以出售独立的配置包、素材包、文档、云服务,但需要注意它们和 GPL 代码是否构成派生作品。 +- 可以使用商标、官方渠道、签名构建区分“官方版本”和第三方再分发版本。 + +### 2.2 GPLv3 下不能依赖的事 + +- 不能阻止购买者按 GPLv3 再分发他们收到的 GPL 版本。 +- 不能在 GPL 版本中加入额外条款禁止修改或绕过授权检查。 +- 不能只发二进制而拒绝提供对应源码。 +- 如果有外部贡献者或上游版权,不能未经所有版权人同意改成闭源。 + +### 2.3 推荐许可证策略 + +如果你拥有全部版权: + +- 可采用“双许可证”: + - 社区版继续 GPLv3。 + - 商业版使用单独商业许可证,包含官方支持、额外功能、私有模块。 +- 新增商业模块尽量保持明确边界,避免和 GPL 核心强耦合到难以区分。 + +如果你基于他人 GPL 项目开发: + +- 保持代码 GPLv3 合规。 +- 重点销售官方服务、构建、支持、配置内容、云端能力。 +- 不把授权系统描述成“禁止使用 GPL 程序”,而是描述成“验证官方权益和云服务资格”。 + +## 3. 产品与收费建议 + +### 3.1 产品分层 + +建议分为 3 个层级: + +| 层级 | 适合用户 | 权益 | +| --- | --- | --- | +| Community | 小型服、开发者 | GPL 源码、自行构建、社区支持、基础文档 | +| Pro | 商业服务器 | 官方构建、稳定更新、授权下载、优先问题修复、基础支持、附魔配置包 | +| Network / Enterprise | 多服网络、托管商 | 多实例授权、SLA、迁移支持、定制附魔、私有兼容修复、批量部署 | + +### 3.2 计费方式 + +推荐组合: + +- 一次性购买 + 12 个月更新资格。 +- 订阅制支持服务。 +- 多服务器席位包,例如 1、3、10、无限网络席位。 +- 定制开发按工时报价。 +- 配置包、平衡包、赛季包作为可选内容。 + +不建议: + +- 对服务器玩家数量做强制实时计费。玩家峰值波动大,争议多,隐私风险高。 +- 每次启动都强依赖授权服务器。授权服务故障会直接变成客户事故。 +- 把正常 bugfix 全部锁在高价套餐里。会损害插件口碑和安全性。 + +### 3.3 可售卖能力 + +EcoEnchants 这类插件适合售卖: + +- 版本兼容:Paper/Folia、Minecraft 新版本、NMS 变更适配。 +- 附魔平衡:PVP、RPG、生存、空岛、监狱服等预设包。 +- 迁移工具:从其他附魔插件迁移 lore、物品和配置。 +- 托管服务:在线配置编辑器、配置校验、附魔库导入导出。 +- 开发者服务:稳定 Maven API、示例插件、私有集成支持。 +- 运维服务:性能分析、异常附魔定位、配置审计。 + +## 4. 授权系统设计原则 + +### 4.1 授权对象 + +建议授权对象为“安装实例”,而不是硬件指纹。 + +插件首次启动时生成并持久化: + +```yaml +license: + key: "" + installation-id: "uuid-generated-on-first-run" + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + channel: "stable" + timeout-ms: 3000 + offline-grace-hours: 72 +``` + +绑定字段建议: + +- `productId`: 产品 ID,例如 `ecoenchants`。 +- `licenseKey`: 用户输入的授权码。 +- `installationId`: 插件本地生成的随机 UUID。 +- `serverName`: 可选,仅用于客户后台识别。 +- `serverVersion`: Paper/Spigot/Folia 版本。 +- `pluginVersion`: 插件版本。 +- `javaVersion`: Java 版本。 + +不建议强绑定: + +- 机器硬件序列号。 +- 全量 IP 地址历史。 +- 玩家 UUID、玩家 IP、聊天或经济数据。 +- world 文件散列。容器化、迁服、备份恢复都会造成误封。 + +### 4.2 授权状态 + +后端应统一返回以下状态: + +| 状态 | 含义 | 插件建议行为 | +| --- | --- | --- | +| `valid` | 授权有效 | 正常启用 | +| `trial` | 试用有效 | 正常启用,日志显示试用到期时间 | +| `expired` | 更新或订阅过期 | 允许已安装版本运行,禁止下载新版本或云服务;如果合同要求也可关闭高级功能 | +| `suspended` | 风控暂停 | 进入宽限或限制云服务,提示联系支持 | +| `revoked` | 退款、欺诈、手动吊销 | 禁用商业权益,保留清晰日志 | +| `activation_limit_exceeded` | 激活数量超额 | 本实例不激活,提示到客户后台释放旧实例 | +| `invalid` | 授权码不存在或格式错误 | 首次安装时不启用商业构建,提示配置授权码 | +| `server_error` | 后端异常 | 使用本地缓存租约和离线宽限 | + +### 4.3 离线宽限 + +必须支持离线宽限: + +- 最近一次有效校验后,默认允许离线运行 72 小时。 +- Enterprise 可配置 7-30 天。 +- 宽限期只依赖后端签名过的本地租约,不依赖本地时间完全可信。 +- 宽限期内日志降噪,例如每 6 小时警告一次。 +- 宽限过期后不要崩溃服务器,应禁用插件或禁用商业功能,并输出明确原因。 + +### 4.4 签名租约 + +后端每次激活或校验返回一个签名租约: + +- 格式建议使用 JWS/JWT。 +- 算法建议 Ed25519。 +- 插件内只内置公钥,用于验证响应签名。 +- 私钥只保存在后端 KMS 或密钥管理服务。 +- 租约包含授权状态、权益、过期时间、离线宽限时间。 + +示例租约载荷: + +```json +{ + "iss": "EcoEnchants License Service", + "aud": "ecoenchants", + "productId": "ecoenchants", + "licenseId": "lic_01JZ0000000000000000000000", + "activationId": "act_01JZ0000000000000000000000", + "installationIdHash": "sha256:...", + "status": "valid", + "entitlements": ["official-build", "updates", "support", "config-pack-pro"], + "maxActivations": 3, + "issuedAt": "2026-06-05T08:00:00Z", + "expiresAt": "2026-06-08T08:00:00Z", + "offlineGraceUntil": "2026-06-11T08:00:00Z", + "latestVersion": "13.0.0", + "minimumSupportedVersion": "12.5.0" +} +``` + +## 5. 插件侧接入要求 + +虽然本文主要定义后端接口,但后端设计必须假设插件会这样接入。 + +### 5.1 生命周期建议 + +- 插件启动时先读取本地签名租约。 +- 本地租约有效时立即启用,避免阻塞服务器启动。 +- 远程授权校验异步执行,不在 Bukkit 主线程阻塞 HTTP 请求。 +- 首次安装且没有本地租约时,可进入“未授权限制状态”,提供控制台提示和授权命令。 +- 远程返回 `revoked`、`invalid` 且不在宽限时,按策略禁用插件或禁用商业功能。 + +### 5.2 建议命令与权限 + +新增管理命令: + +| 命令 | 权限 | 说明 | +| --- | --- | --- | +| `/ecoenchants license status` | `ecoenchants.command.license` | 查看授权状态、到期时间、激活 ID | +| `/ecoenchants license activate <key>` | `ecoenchants.command.license` | 激活授权并写入配置 | +| `/ecoenchants license refresh` | `ecoenchants.command.license` | 手动刷新授权租约 | +| `/ecoenchants license deactivate` | `ecoenchants.command.license` | 释放当前实例激活 | + +注意: + +- 命令输出不要打印完整 license key。 +- 日志中只展示授权码后 4 位,例如 `****-****-ABCD`。 +- HTTP 错误只输出可操作信息,不泄露内部堆栈或后端密钥。 + +### 5.3 配置要求 + +建议新增: + +```yaml +license: + enabled: true + key: "" + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + channel: "stable" + timeout-ms: 3000 + offline-grace-hours: 72 + strict-mode: false +``` + +字段说明: + +| 字段 | 要求 | +| --- | --- | +| `enabled` | 官方商业构建默认启用;社区构建可关闭 | +| `key` | 用户授权码,支持命令写入 | +| `api-url` | 默认官方地址;测试构建可改 | +| `channel` | `stable`、`beta`、`dev` | +| `timeout-ms` | 默认 3000,不建议超过 5000 | +| `offline-grace-hours` | 本地偏好值,最终以签名租约为准 | +| `strict-mode` | true 时无有效授权直接禁用;默认 false 更利于客户运维 | + +## 6. 后端 API 总览 + +### 6.1 基础约定 + +- Base URL: `https://tts.chloemlla.com/api/ecoenchants` +- API version: `/v1` +- Content-Type: `application/json; charset=utf-8` +- 所有接口必须使用 HTTPS。 +- 所有时间使用 ISO-8601 UTC,例如 `2026-06-05T08:00:00Z`。 +- 所有写接口支持 `Idempotency-Key` 请求头。 +- 所有响应包含 `requestId`,便于排查。 + +通用请求头: + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `User-Agent` | 是 | 例如 `EcoEnchants/13.0.0 Paper/1.21.11 Java/21` | +| `X-Request-Id` | 否 | 客户端生成,后端也可生成 | +| `Idempotency-Key` | 写接口必填 | 避免重试导致重复激活 | +| `Authorization` | 部分接口必填 | 激活后使用 `Bearer <activationToken>` | +| `X-Signature` | Webhook 必填 | 第三方平台或内部服务签名 | + +通用错误响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "error": { + "code": "activation_limit_exceeded", + "message": "Activation limit exceeded for this license.", + "docsUrl": "https://docs.example.com/license/errors#activation_limit_exceeded", + "retryAfterSeconds": null + } +} +``` + +HTTP 状态建议: + +| 状态码 | 场景 | +| --- | --- | +| `200` | 查询或校验成功 | +| `201` | 激活创建成功 | +| `202` | Webhook 已接收,异步处理 | +| `400` | 请求字段错误 | +| `401` | 未认证或 token 无效 | +| `403` | 授权无权限、被吊销、超额 | +| `404` | 对象不存在 | +| `409` | 幂等冲突或重复激活冲突 | +| `422` | 授权状态不允许当前操作 | +| `429` | 频率限制 | +| `500` | 后端未知错误 | +| `503` | 依赖服务不可用 | + +## 7. 插件授权接口 + +### 7.1 获取服务状态 + +`GET /v1/health` + +用途: + +- 监控授权服务是否可用。 +- 不参与授权决策。 + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "status": "ok", + "time": "2026-06-05T08:00:00Z" +} +``` + +### 7.2 获取产品策略 + +`GET /v1/products/{productId}/policy` + +用途: + +- 获取产品当前支持版本、最新版本、默认校验间隔、公告。 +- 可以公开,但不要返回客户敏感信息。 + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "productId": "ecoenchants", + "latestVersion": "13.0.0", + "minimumSupportedVersion": "12.5.0", + "recommendedJava": 21, + "supportedPlatforms": ["Paper", "Folia"], + "defaultCheckIntervalSeconds": 21600, + "defaultLeaseSeconds": 259200, + "defaultOfflineGraceSeconds": 259200, + "notices": [ + { + "level": "warning", + "message": "Minecraft 1.21.7 is no longer supported.", + "startsAt": "2026-06-01T00:00:00Z", + "endsAt": "2026-07-01T00:00:00Z" + } + ] +} +``` + +### 7.3 获取签名公钥 + +`GET /.well-known/license-public-keys` + +用途: + +- 支持公钥轮换。 +- 插件仍应内置至少一个当前公钥,避免首次启动完全依赖网络。 + +响应: + +```json +{ + "keys": [ + { + "kid": "ed25519-2026-01", + "alg": "EdDSA", + "kty": "OKP", + "crv": "Ed25519", + "x": "base64url-public-key", + "status": "active", + "notBefore": "2026-01-01T00:00:00Z" + } + ] +} +``` + +### 7.4 激活授权 + +`POST /v1/licenses/activate` + +用途: + +- 授权码首次绑定到当前安装实例。 +- 返回 `activationId`、短期 `activationToken` 和签名租约。 + +请求: + +```json +{ + "productId": "ecoenchants", + "licenseKey": "ECOE-XXXX-XXXX-XXXX", + "installationId": "7f29607c-3e30-4b6b-8476-b0b286d9fb10", + "server": { + "name": "Survival Network", + "platform": "Paper", + "platformVersion": "1.21.11-R0.1-SNAPSHOT", + "minecraftVersion": "1.21.11", + "onlineMode": true, + "javaVersion": "21.0.7", + "timezone": "Asia/Shanghai" + }, + "plugin": { + "version": "13.0.0", + "channel": "stable", + "buildHash": "sha256:..." + }, + "capabilities": { + "foliaSupported": true, + "offlineLeaseSupported": true, + "signatureAlgorithms": ["EdDSA"] + } +} +``` + +响应 `201`: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "license": { + "licenseId": "lic_01JZ0000000000000000000000", + "status": "valid", + "plan": "pro", + "expiresAt": "2027-06-05T08:00:00Z", + "supportUntil": "2027-06-05T08:00:00Z", + "maxActivations": 3 + }, + "activation": { + "activationId": "act_01JZ0000000000000000000000", + "name": "Survival Network", + "createdAt": "2026-06-05T08:00:00Z", + "lastSeenAt": "2026-06-05T08:00:00Z" + }, + "entitlements": [ + "official-build", + "updates", + "support", + "config-pack-pro" + ], + "activationToken": "jwt-or-random-token", + "activationTokenExpiresAt": "2026-07-05T08:00:00Z", + "signedLease": "eyJhbGciOiJFZERTQSIsImtpZCI6ImVkMjU1MTktMjAyNi0wMSJ9..." +} +``` + +错误: + +| code | HTTP | 说明 | +| --- | --- | --- | +| `invalid_license_key` | 401 | 授权码不存在或格式错误 | +| `product_mismatch` | 403 | 授权码不属于该产品 | +| `activation_limit_exceeded` | 403 | 超过激活数量 | +| `license_revoked` | 403 | 授权已吊销 | +| `license_expired` | 422 | 订阅已过期且策略不允许激活 | +| `rate_limited` | 429 | 尝试过于频繁 | + +### 7.5 校验授权 + +`POST /v1/licenses/verify` + +认证: + +`Authorization: Bearer <activationToken>` + +用途: + +- 插件启动后异步校验。 +- 定期刷新签名租约。 +- 同步授权状态、版本策略、权益变化。 + +请求: + +```json +{ + "productId": "ecoenchants", + "activationId": "act_01JZ0000000000000000000000", + "installationId": "7f29607c-3e30-4b6b-8476-b0b286d9fb10", + "nonce": "base64url-random-32-bytes", + "server": { + "platform": "Paper", + "platformVersion": "1.21.11-R0.1-SNAPSHOT", + "minecraftVersion": "1.21.11", + "onlineMode": true, + "javaVersion": "21.0.7" + }, + "plugin": { + "version": "13.0.0", + "channel": "stable", + "buildHash": "sha256:..." + }, + "lastLeaseId": "lease_01JZ0000000000000000000000" +} +``` + +响应 `200`: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "status": "valid", + "lease": { + "leaseId": "lease_01JZ0000000000000000000001", + "expiresAt": "2026-06-08T08:00:00Z", + "offlineGraceUntil": "2026-06-11T08:00:00Z", + "nextCheckAfter": "2026-06-05T14:00:00Z" + }, + "license": { + "licenseId": "lic_01JZ0000000000000000000000", + "plan": "pro", + "supportUntil": "2027-06-05T08:00:00Z", + "maxActivations": 3 + }, + "entitlements": [ + "official-build", + "updates", + "support", + "config-pack-pro" + ], + "policy": { + "latestVersion": "13.0.0", + "minimumSupportedVersion": "12.5.0", + "message": null + }, + "signedLease": "eyJhbGciOiJFZERTQSIsImtpZCI6ImVkMjU1MTktMjAyNi0wMSJ9..." +} +``` + +特殊响应: + +- 授权被吊销时仍可返回 `200`,但 `status` 为 `revoked`,同时返回签名租约。这样插件可以验证“吊销状态”确实来自官方后端。 +- 网络错误、`500`、`503` 不应立即导致插件禁用,应让插件使用本地租约和宽限策略。 + +### 7.6 心跳 + +`POST /v1/licenses/heartbeat` + +认证: + +`Authorization: Bearer <activationToken>` + +用途: + +- 轻量更新 `lastSeenAt`。 +- 检测同一授权超额并发使用。 +- 不一定每次都返回新租约。 + +请求: + +```json +{ + "productId": "ecoenchants", + "activationId": "act_01JZ0000000000000000000000", + "installationId": "7f29607c-3e30-4b6b-8476-b0b286d9fb10", + "uptimeSeconds": 86400, + "pluginVersion": "13.0.0" +} +``` + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "status": "valid", + "serverTime": "2026-06-05T08:00:00Z", + "nextHeartbeatAfter": "2026-06-05T09:00:00Z", + "shouldVerify": false +} +``` + +频率建议: + +- 默认每 1 小时。 +- 后端可通过响应要求下次心跳时间。 +- 失败后指数退避,避免服务故障时大量重试。 + +### 7.7 释放激活 + +`POST /v1/licenses/deactivate` + +认证: + +`Authorization: Bearer <activationToken>` + +用途: + +- 用户迁服或停用时释放当前安装实例。 + +请求: + +```json +{ + "productId": "ecoenchants", + "activationId": "act_01JZ0000000000000000000000", + "installationId": "7f29607c-3e30-4b6b-8476-b0b286d9fb10", + "reason": "server_migration" +} +``` + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "deactivated": true, + "deactivatedAt": "2026-06-05T08:00:00Z" +} +``` + +### 7.8 下载最新构建 + +`GET /v1/downloads/latest?productId=ecoenchants&channel=stable` + +认证: + +客户 Portal token 或短期下载 token,不建议直接使用插件内的 activation token 下载完整 JAR。 + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "productId": "ecoenchants", + "version": "13.0.0", + "channel": "stable", + "fileName": "EcoEnchants-13.0.0.jar", + "sha256": "hex-sha256", + "signature": "minisign-or-sigstore-signature", + "downloadUrl": "https://cdn.example.com/signed-url", + "expiresAt": "2026-06-05T08:15:00Z", + "sourceArchiveUrl": "https://cdn.example.com/source/EcoEnchants-13.0.0-src.zip" +} +``` + +要求: + +- 下载链接必须短期有效。 +- 返回 JAR 校验和与签名。 +- GPL 构建必须提供对应源码获取方式。 +- 插件不应自动热加载远程 JAR,避免远程代码执行风险。 + +## 8. 客户后台接口 + +客户后台用于购买者管理授权、下载和支持资格。 + +### 8.1 查询我的授权 + +`GET /v1/me/licenses` + +认证: + +用户登录 token。 + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "licenses": [ + { + "licenseId": "lic_01JZ0000000000000000000000", + "productId": "ecoenchants", + "plan": "pro", + "status": "valid", + "licenseKeyLast4": "ABCD", + "supportUntil": "2027-06-05T08:00:00Z", + "maxActivations": 3, + "activeActivations": 1 + } + ] +} +``` + +### 8.2 查询授权详情 + +`GET /v1/me/licenses/{licenseId}` + +响应应包含: + +- 授权基础信息。 +- 激活实例列表。 +- 最近校验时间。 +- 当前权益。 +- 可下载版本。 +- 发票或订单引用。 + +### 8.3 释放某个激活 + +`POST /v1/me/licenses/{licenseId}/activations/{activationId}/revoke` + +用途: + +- 客户在后台释放旧服务器。 +- 后端应限制频率,避免频繁换绑滥用。 + +请求: + +```json +{ + "reason": "customer_requested" +} +``` + +### 8.4 轮换授权码 + +`POST /v1/me/licenses/{licenseId}/key/rotate` + +用途: + +- 授权码泄露时重置。 +- 旧 key 立即失效或进入短暂迁移期。 + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "licenseKey": "ECOE-NEWX-XXXX-XXXX", + "rotatedAt": "2026-06-05T08:00:00Z" +} +``` + +### 8.5 查询下载列表 + +`GET /v1/me/downloads?productId=ecoenchants` + +响应应包含: + +- 可下载版本。 +- 支持的 Minecraft/Paper 版本。 +- changelog。 +- JAR sha256。 +- 源码包地址。 +- 是否需要续费才可下载。 + +## 9. 管理后台接口 + +管理后台仅限内部使用,必须有强认证、审计日志和 RBAC。 + +### 9.1 产品管理 + +| 接口 | 说明 | +| --- | --- | +| `POST /v1/admin/products` | 创建产品 | +| `GET /v1/admin/products` | 查询产品 | +| `PATCH /v1/admin/products/{productId}` | 修改产品策略 | +| `POST /v1/admin/products/{productId}/versions` | 发布版本元数据 | + +产品字段: + +- `productId` +- `name` +- `currentVersion` +- `minimumSupportedVersion` +- `channels` +- `defaultLeaseSeconds` +- `defaultOfflineGraceSeconds` +- `publicKeys` + +### 9.2 套餐管理 + +| 接口 | 说明 | +| --- | --- | +| `POST /v1/admin/plans` | 创建套餐 | +| `PATCH /v1/admin/plans/{planId}` | 修改套餐 | +| `GET /v1/admin/plans` | 查询套餐 | + +套餐字段: + +- `planId` +- `productId` +- `name` +- `maxActivations` +- `entitlements` +- `supportDurationDays` +- `updateDurationDays` +- `price` +- `currency` + +### 9.3 授权管理 + +| 接口 | 说明 | +| --- | --- | +| `POST /v1/admin/licenses` | 手动创建授权 | +| `GET /v1/admin/licenses` | 查询授权 | +| `GET /v1/admin/licenses/{licenseId}` | 查询详情 | +| `PATCH /v1/admin/licenses/{licenseId}` | 修改状态、席位、到期时间 | +| `POST /v1/admin/licenses/{licenseId}/revoke` | 吊销授权 | +| `POST /v1/admin/licenses/{licenseId}/extend` | 延长授权 | +| `POST /v1/admin/licenses/{licenseId}/notes` | 添加客服备注 | + +### 9.4 激活管理 + +| 接口 | 说明 | +| --- | --- | +| `GET /v1/admin/activations` | 按授权、客户、IP、版本查询 | +| `POST /v1/admin/activations/{activationId}/revoke` | 吊销单个激活 | +| `POST /v1/admin/activations/{activationId}/rename` | 重命名实例 | + +### 9.5 审计与事件 + +| 接口 | 说明 | +| --- | --- | +| `GET /v1/admin/audit-logs` | 查询管理员操作 | +| `GET /v1/admin/license-events` | 查询授权事件 | +| `GET /v1/admin/risk-events` | 查询风控事件 | + +必须记录: + +- 谁在什么时间修改了授权。 +- 修改前后的关键字段。 +- Webhook 来源与处理结果。 +- 授权激活、校验、吊销、退款事件。 + +## 10. 支付与市场 Webhook + +### 10.1 Polymart Webhook + +`POST /v1/webhooks/polymart` + +用途: + +- 接收购买、退款、争议、用户信息变更。 +- 自动创建或更新 license。 + +要求: + +- 验证 Polymart 签名或 shared secret。 +- 使用事件 ID 做幂等。 +- 原始 payload 入库,便于重放和排查。 + +事件处理: + +| 事件 | 后端动作 | +| --- | --- | +| purchase_created | 创建 license,发送邮件 | +| purchase_refunded | 设置 `revoked` 或 `suspended` | +| purchase_chargeback | 设置 `suspended`,标记风险 | +| user_email_changed | 更新客户资料 | + +### 10.2 Stripe / PayPal Webhook + +`POST /v1/webhooks/stripe` + +`POST /v1/webhooks/paypal` + +要求: + +- 必须验证官方 webhook 签名。 +- 订单状态和授权状态要有明确映射。 +- 退款和拒付必须自动同步到授权状态。 +- 重复事件不得重复创建 license。 + +## 11. 数据模型 + +建议核心表: + +### 11.1 `products` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 产品 ID,例如 `ecoenchants` | +| `name` | string | 产品名 | +| `status` | enum | `active`、`archived` | +| `latest_version` | string | 最新版本 | +| `minimum_supported_version` | string | 最低支持版本 | +| `default_lease_seconds` | int | 默认租约时间 | +| `default_grace_seconds` | int | 默认离线宽限 | + +### 11.2 `plans` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 套餐 ID | +| `product_id` | string | 产品 ID | +| `name` | string | 套餐名 | +| `max_activations` | int | 最大激活数 | +| `entitlements` | json | 权益列表 | +| `support_duration_days` | int | 支持期限 | +| `update_duration_days` | int | 更新期限 | + +### 11.3 `customers` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 客户 ID | +| `email` | string | 邮箱 | +| `marketplace_user_id` | string | 市场账号 ID | +| `created_at` | timestamp | 创建时间 | + +### 11.4 `licenses` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 授权 ID | +| `product_id` | string | 产品 ID | +| `customer_id` | string | 客户 ID | +| `plan_id` | string | 套餐 ID | +| `key_hash` | string | 授权码哈希 | +| `key_last4` | string | 授权码后 4 位 | +| `status` | enum | `valid`、`trial`、`expired`、`suspended`、`revoked` | +| `max_activations` | int | 最大激活数 | +| `expires_at` | timestamp | 授权到期 | +| `support_until` | timestamp | 支持到期 | +| `created_at` | timestamp | 创建时间 | + +授权码存储要求: + +- 不存明文授权码。 +- 使用 HMAC-SHA256 或 Argon2id 哈希。 +- pepper 存在 KMS/环境变量,不进数据库。 + +### 11.5 `activations` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 激活 ID | +| `license_id` | string | 授权 ID | +| `installation_id_hash` | string | 安装 ID 哈希 | +| `name` | string | 客户自定义服务器名 | +| `status` | enum | `active`、`deactivated`、`revoked` | +| `first_seen_at` | timestamp | 首次激活 | +| `last_seen_at` | timestamp | 最近心跳 | +| `last_ip_hash` | string | IP 哈希,可选 | +| `platform` | string | Paper/Folia/Spigot | +| `minecraft_version` | string | MC 版本 | +| `plugin_version` | string | 插件版本 | + +### 11.6 `leases` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 租约 ID | +| `activation_id` | string | 激活 ID | +| `status` | enum | 返回给插件的授权状态 | +| `signed_payload` | text | 签名租约 | +| `expires_at` | timestamp | 租约到期 | +| `offline_grace_until` | timestamp | 离线宽限到期 | +| `created_at` | timestamp | 创建时间 | + +### 11.7 `orders` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 内部订单 ID | +| `provider` | string | Polymart/Stripe/PayPal | +| `provider_order_id` | string | 平台订单 ID | +| `customer_id` | string | 客户 ID | +| `status` | enum | `paid`、`refunded`、`chargeback` | +| `raw_payload` | json | 原始事件 | + +### 11.8 `audit_logs` + +记录管理员操作、自动化任务、Webhook 处理和风控决策。 + +字段至少包括: + +- `actor_type`: `admin`、`system`、`webhook`。 +- `actor_id` +- `action` +- `resource_type` +- `resource_id` +- `before` +- `after` +- `request_id` +- `created_at` + +## 12. 安全要求 + +### 12.1 服务端安全 + +- 全站 HTTPS,禁用明文 HTTP。 +- 管理后台启用 MFA。 +- 管理 API 使用 RBAC,不同角色权限隔离。 +- 私钥放 KMS,不写入代码仓库。 +- 授权码只存哈希,不可逆。 +- Webhook 必须验签。 +- 下载 URL 使用短期签名。 +- 所有关键操作写审计日志。 +- 管理员导出数据需要额外权限和审计。 + +### 12.2 插件通信安全 + +- 首次激活使用 license key。 +- 激活后使用 activation token。 +- activation token 可轮换、可吊销、有过期时间。 +- 后端响应的授权结论必须带签名租约。 +- 插件验证签名后才更新本地租约。 +- 请求包含 nonce,防止简单重放。 +- 客户端请求失败时指数退避。 + +### 12.3 频率限制 + +建议限制: + +| 接口 | 限制 | +| --- | --- | +| activate | 同一 key 每 10 分钟 10 次 | +| verify | 同一 activation 每分钟 6 次 | +| heartbeat | 同一 activation 每分钟 2 次 | +| customer login | 按账号和 IP 限制 | +| admin API | 按账号、IP、权限限制 | + +### 12.4 隐私要求 + +- 不采集玩家列表、玩家 IP、聊天内容、经济数据。 +- 服务器 IP 如需风控,建议哈希或只保存最近一次。 +- 客户后台必须提供隐私政策。 +- 提供数据删除或匿名化流程。 +- 日志保留期限明确,例如 180 天。 + +## 13. 运维要求 + +### 13.1 可用性 + +授权服务目标: + +- 核心 verify/activate API 月可用性不低于 99.9%。 +- API P95 响应时间小于 300ms。 +- 单区域故障时,已激活客户可通过本地宽限继续运行。 + +### 13.2 监控 + +必须监控: + +- API 错误率。 +- verify 延迟。 +- activate 失败原因分布。 +- Webhook 积压。 +- 数据库连接池。 +- 签名租约生成失败。 +- 下载 CDN 错误率。 + +### 13.3 告警 + +建议告警: + +- `5xx` 超过 1% 持续 5 分钟。 +- verify P95 超过 1 秒持续 10 分钟。 +- Webhook 处理延迟超过 5 分钟。 +- 签名密钥不可用。 +- 数据库主从延迟异常。 + +### 13.4 备份 + +- 数据库每日全量备份。 +- 关键表启用 PITR。 +- Webhook 原始事件至少保留 90 天。 +- 签名私钥有轮换和灾备方案。 + +## 14. 风控策略 + +建议只做低误伤风控: + +- 同一授权短时间大量新激活,标记风险而不是立即永久封禁。 +- 同一授权在多个国家或机房并发心跳,进入人工审核。 +- 退款、拒付自动暂停。 +- 客户后台允许自助释放旧激活,但设置每日或每周上限。 +- Enterprise 可配置更宽松迁移策略。 + +不建议: + +- 仅凭 IP 变化封禁。服务器迁移和动态 IP 很常见。 +- 频繁强制联网校验。 +- 插件内做复杂反调试或混淆作为主要防线。 + +## 15. 文档与客户体验要求 + +必须提供以下文档: + +- 购买后如何下载。 +- 如何填写授权码。 +- 迁服时如何释放激活。 +- 授权服务器不可达时插件如何处理。 +- 常见错误码说明。 +- 退款、续费、支持范围说明。 +- GPL 源码获取方式。 +- 隐私政策和数据收集说明。 + +错误提示应可操作: + +| 错误 | 面向用户的提示 | +| --- | --- | +| `invalid_license_key` | 授权码无效,请检查是否复制完整 | +| `activation_limit_exceeded` | 激活数量已达上限,请在客户后台释放旧服务器 | +| `license_revoked` | 授权已被吊销,请联系支持 | +| `network_error` | 无法连接授权服务器,正在使用离线宽限 | +| `lease_expired` | 离线宽限已过期,请恢复网络或重新激活 | + +## 16. 开发里程碑 + +### Phase 1: 最小可用授权服务 + +范围: + +- license key 生成与哈希存储。 +- `activate`、`verify`、`heartbeat`、`deactivate`。 +- 签名租约。 +- 本地离线宽限。 +- 管理后台基础查询。 + +验收: + +- 新授权可激活。 +- 超出激活数会被拒绝。 +- 后端断网时插件可使用本地有效租约启动。 +- 吊销授权后,下一次 verify 能返回签名吊销状态。 +- 日志不泄露完整授权码。 + +### Phase 2: 支付与客户后台 + +范围: + +- Polymart/Stripe/PayPal webhook。 +- 客户登录。 +- 授权列表、激活管理、下载列表。 +- 授权码轮换。 + +验收: + +- 购买后自动生成授权。 +- 退款后自动吊销或暂停。 +- 客户可以自助释放旧激活。 +- 重复 webhook 不会重复创建授权。 + +### Phase 3: 商业运营能力 + +范围: + +- 版本发布管理。 +- CDN 下载与文件签名。 +- 支持工单关联授权。 +- 风控事件与人工审核。 +- 管理后台 RBAC 与审计。 + +验收: + +- 管理员操作可追踪。 +- 支持按授权快速定位客户环境。 +- 发布新版本时客户后台能显示兼容性和 changelog。 +- 风控误伤有可回滚流程。 + +## 17. 最低验收清单 + +上线前必须满足: + +- [ ] GPL 源码提供方式明确。 +- [ ] 商业条款不与 GPLv3 冲突。 +- [ ] 授权 API 全部 HTTPS。 +- [ ] 授权码不明文入库。 +- [ ] 响应授权结论有后端签名。 +- [ ] 插件 HTTP 请求不阻塞主线程。 +- [ ] 支持离线宽限。 +- [ ] 支持激活释放。 +- [ ] 支持退款/拒付吊销。 +- [ ] 客户后台能查看激活实例。 +- [ ] 管理员操作有审计日志。 +- [ ] 错误码文档完整。 +- [ ] 隐私政策说明采集字段。 +- [ ] 监控和告警已配置。 + +## 18. 不建议投入的方向 + +- 把大量时间投入不可维护的混淆和反篡改。 +- 强制每次开服联网且无宽限。 +- 采集大量服务器或玩家隐私来识别盗版。 +- 用授权 API 控制开源 GPL 用户的基本运行权利。 +- 将商业价值全部押在“别人不能复制 JAR”上。 + +最稳妥的商业化路线是:GPL 合规 + 官方构建 + 高质量配置内容 + 稳定更新 + 支持服务 + 可选云端权益。授权 API 只负责管理官方权益和服务资格,而不是作为唯一商业壁垒。 diff --git a/documentation/ecoenchants/index.md b/documentation/ecoenchants/index.md index 64f10abeee..3f6c6bed74 100644 --- a/documentation/ecoenchants/index.md +++ b/documentation/ecoenchants/index.md @@ -12,7 +12,7 @@ There's a long list of reasons why, but chief among them is how the enchants are On top of that, **you can create your own custom enchantments with zero coding knowledge.** Just make them as you want, to give your server the ultimate unique feel and customize everything to be absolutely perfect. And if you don't like creating them yourself, **you can download community-made enchantments from the [online config explorer](https://lrcdb.auxilor.io).** -EcoEnchants is also completely open-source. Many *other* enchantment plugins have extremely poor code and tend to obfuscate their plugins and try to prevent you from actually owning the plugin, instead treating you like you're borrowing it from them, with license checkers and enterprise licenses that restrict your freedom to use things how you want. **EcoEnchants will never have licenses, obfuscation, or anything like that.** The source code is public and open, and you can find it on [GitHub](https://github.com/Auxilor/EcoEnchants). +EcoEnchants is distributed as a closed-source commercial plugin. Commercial builds require an online license verification during startup before the core runtime is enabled. The license check is intentionally narrow: it verifies the license key, installation ID, plugin/server version, Java version, channel, online-mode, and optionally server name and build fingerprint. It does not collect player UUIDs, player IPs, chat, economy data, inventories, coordinates, permissions, or world file fingerprints. EcoEnchants also doesn't fill your server with random clutter that you don't want. It was built out of frustration at the state of the most popular plugins at the time, filled with meaningless features and built around 1.8 PvP servers, which leads to bad performance, a bad user experience, and a bad developer experience too. Because it's built to feel like an extension of vanilla rather than a whole new system with the same name, your players will immediately understand it. @@ -23,4 +23,6 @@ EcoEnchants also doesn't fill your server with random clutter that you don't wan - **The Gameplay:** how [types, rarity, obtaining, and targets](the-gameplay) fit together. - **Make your own:** the [How to Make an Enchantment](how-to-make-a-custom-enchant) guide. - **Configure the plugin:** every option in the [Plugin Config](plugin-config). -- **Browse community enchantments:** the [online config explorer](https://lrcdb.auxilor.io). \ No newline at end of file +- **Improve player guidance:** [Player Experience Optimizations](player-experience-optimizations), automatic tips, guide commands, and GUI shortcuts. +- **Backend integration:** the [Runtime Telemetry API](runtime-telemetry-api) contract for scheduled audit event reporting. +- **Browse community enchantments:** the [online config explorer](https://lrcdb.auxilor.io). diff --git a/documentation/ecoenchants/player-experience-optimizations.md b/documentation/ecoenchants/player-experience-optimizations.md new file mode 100644 index 0000000000..5ca723aee4 --- /dev/null +++ b/documentation/ecoenchants/player-experience-optimizations.md @@ -0,0 +1,92 @@ +--- +title: "Player Experience Optimizations" +sidebar_position: 6 +--- + +This page lists player experience features that make EcoEnchants easier for players to discover, understand, and use. Most player-facing text lives in `lang.yml`, and most behavior can be tuned in `config.yml`. + +## Implemented Features + +| Feature | Where | Notes | +|---------|-------|-------| +| First-join hint | `player-experience.auto-hints.on-first-join` | Introduces `/ecoenchants gui` and `/ecoenchants guide` once per player | +| First browser-open hint | `player-experience.auto-hints.on-browser-open` | Explains the top-middle item slot and compatible filtering | +| Empty-result hints | `player-experience.auto-hints.on-empty-results` | Sends a contextual tip for no loaded enchants, active filters, item filters, or group filters | +| Filter-change hints | `player-experience.auto-hints.on-filter-change` | Confirms the changed filter and suggests the next action | +| Hold-item action-bar tip | `player-experience.auto-hints.on-hold-enchantable` | When a player holds an enchantable item (or enchanted book), shows an action-bar prompt, plus a one-time clickable chat prompt that opens the browser | +| Hint cooldown | `player-experience.auto-hints.cooldown-seconds` | Prevents repeated chat noise; also throttles the hold-item tip | +| Enchant search | `/ecoenchants search <query>` (`player-experience.search.max-results`) | Finds enchants by name and returns clickable chat results that open their info GUI | +| Held-item enchant list | `/enchantinfo` with no arguments | Lists the EcoEnchants on the player's held item as clickable chat lines | +| Enchant favorites | `/ecoenchants favorites`, info-GUI star button, browser "favorites only" filter | Bookmark enchants (stored per player) and revisit or filter to them quickly | +| Browser sort toggle | `enchant-gui.sort` button | Each player can cycle the browser between name, rarity, and max-level order for their own view | +| Permission-aware help | `/ecoenchants` and `/ecoenchants help` | Only lists commands the sender can use | +| Player guide | `/ecoenchants guide` | Sends a short chat guide | +| Guide book | `/ecoenchants guide book` | Gives players an in-game written book | +| Experience stats | `/ecoenchants experience` | Shows hint settings and empty-result counts for staff | +| GUI filters | Top row of `/ecoenchants gui` | Cycle type, rarity, target, compatible-only, and favorites-only filters | +| Conflict view | Top row of `/ecoenchants gui` | Shows which enchantments are blocked by the current item | +| Invalid-click feedback | `player-experience.sounds.invalid-click` | Plays a sound when a convenience action needs an item or enchants | + +## Already Supported Entry Points + +Use these player-facing places for convenience hints: + +| Place | What to show | Why it helps | +|-------|--------------|--------------| +| `/ecoenchants gui` info item | How to place an item, browse all enchants, and toggle descriptions | Gives first-time players a clear next action | +| Empty enchant results | Explain whether nothing is loaded, the group is empty, or the item has no compatible enchants | Prevents players from thinking the GUI is broken | +| Group buttons | Summarize what normal, spell, special, and curse enchantments are for | Helps players choose a category quickly | +| Enchant info GUI | Show max level, rarity, targets, conflicts, requirements, discovery sources, and the `/enchantinfo` command | Turns the GUI into a reusable reference | +| Page buttons | Show current page and next/previous direction | Makes large enchant lists easier to scan | +| Returned GUI item messages | Tell players when items are returned or dropped because the inventory is full | Reduces item-loss anxiety | +| `/ecoenchants toggledescriptions` result | Tell players they can run the command again to reverse the setting | Makes the toggle self-explanatory | +| Search / favorites / held-item results | Clickable chat lines that reopen an enchant's info GUI | Lets players jump straight to details without paging the GUI | +| Holding an enchantable item | An action-bar prompt pointing to `/ecoenchants gui` and `/enchantinfo` | Surfaces the feature exactly when it is relevant | +| Admin tools GUI | Explain reload and random-book testing actions | Makes live testing faster for staff | + +## Recommended Additions + +### Low-Risk Configuration Improvements + +1. Add concise bilingual lore to all GUI controls. +2. Keep the info item visible in the same slot across flat and grouped browsing. +3. Use different empty-result messages for no item, filtered group, item-only filter, and item plus group filter. +4. Put the most useful command in failure messages, such as `/enchantinfo <name>` after a failed lookup. +5. Keep all hint text short enough for Minecraft lore lines. +6. Prefer actionable hints over feature descriptions, for example "Place gear to filter results" instead of "This GUI filters results." +7. Add custom decorative or shortcut slots through `custom-slots` only when they reduce clicks. + +### Future Feature Ideas + +1. Add per-player controls for disabling automatic tips. +2. Add persistent analytics export for empty-result cases across restarts. +3. Add optional MiniMessage hover text with richer formatting on clickable lines. + +> Previously listed ideas now shipped: clickable chat results (search, favorites, held-item lists), +> a text search flow (`/ecoenchants search`), and optional action-bar hints +> (`player-experience.auto-hints.on-hold-enchantable`). + +## Suggested Player Tips + +These short hints fit well in GUI lore and chat messages: + +| Situation | Suggested hint | +|-----------|----------------| +| Player opens the browser | "Place gear in the top slot to filter compatible enchantments." | +| Player browses without an item | "Browsing all enchantments. Add an item to narrow the list." | +| No compatible enchantments appear | "Try another item, remove conflicts, or browse without an item." | +| Player views an enchant | "Use `/enchantinfo <name> [level]` to reopen this later." | +| Player wants a specific enchant | "Use `/ecoenchants search <name>` to jump straight to it." | +| Player holds an enchantable item | "This item can be enchanted — open `/ecoenchants gui` to browse." | +| Player revisits favorites | "Star an enchant on its info screen, then use `/ecoenchants favorites`." | +| Player toggles descriptions | "Run the command again to switch back." | +| Player changes groups | "Groups are filters; return to groups to choose another type." | +| Player inventory is full | "Returned items that do not fit are dropped nearby." | +| Staff opens admin tools | "Reload after config edits; players should relog after new enchants are added." | + +## Implementation Notes + +- Keep player text in `lang.yml` wherever possible. +- Keep GUI structure in `config.yml` so server owners can move controls without code changes. +- Use `custom-slots` for server-specific help links, guide items, or shortcut buttons. +- Avoid long lore paragraphs; Minecraft inventory UI is best with short, scannable lines. diff --git a/documentation/ecoenchants/plugin-config.md b/documentation/ecoenchants/plugin-config.md index 09d24c647c..933404a110 100644 --- a/documentation/ecoenchants/plugin-config.md +++ b/documentation/ecoenchants/plugin-config.md @@ -14,6 +14,145 @@ A few options note that they require a **server restart** rather than a reload, ## Default config.yml ```yaml +# Required online license check for closed-source commercial builds. +# The plugin will disable itself during startup unless this check returns status "valid" or "trial". +license: + key: "" + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + channel: stable + timeout-ms: 3000 + installation-id: "" + send-server-name: false + send-build-fingerprint: true + # Privacy boundary: + # - The startup check sends license key, installation ID, plugin/server version, Java version, + # online-mode, channel, and optionally server name / build fingerprint. +# - It does not collect player UUIDs, player IPs, chat, economy data, inventories, +# coordinates, permissions, or world file fingerprints. + +# Developer-facing backend API communication tracing. +# Keep disabled on production unless you are actively diagnosing backend issues. +# Payload logging is separately gated and redacts known tokens, license keys, signatures, +# secrets, and passwords before writing to the console. +backend-api: + logging: + verbose: false + include-payloads: false + max-payload-chars: 2048 + +# Secure remote operations client for /api/ecoenchants/v1. +# The plugin connects outbound to the licensed backend after startup verification succeeds. +# It never exposes arbitrary shell execution; managed commands are hardcoded allowlist actions. +remote-operations: + enabled: true + reconnect-min-seconds: 5 + reconnect-max-seconds: 300 + + security: + # Reject remote operations over plain http/ws when enabled. + require-secure-transport: true + + # HMAC protects registration, WebSocket handshakes, and inbound RPC messages + # from replayed or unsigned control traffic. If secret is blank, the plugin + # uses the activation/session token as the shared signing secret. + hmac: + enabled: true + require-signed-rpc: true + key-id: "" + secret: "" + max-clock-skew-seconds: 300 + + # Optional client certificate authentication for mTLS deployments. + # key-store should point to a PKCS12/JKS file readable by the server process. + mtls: + enabled: false + key-store: "" + key-store-password: "" + key-store-type: "PKCS12" + + audit-log: + enabled: true + file: security-audit.log + + # File operations are disabled by default because they can change server data. + # Enable only for servers that should be maintained from the backend console. + file-ops: + enabled: false + # Leave blank to infer the Minecraft server root from plugins/EcoEnchants. + server-root: "" + max-read-bytes: 1048576 + max-write-bytes: 10485760 + allow-permanent-delete: false + + # Backup creation writes zip archives into plugins/EcoEnchants/backups. + # Restore defaults to staged mode; use apply mode only after reviewing the staged contents. + backups: + enabled: false + max-total-size-mb: 256 + +# Server-side runtime telemetry and transparent compliance probes. +# This records operational audit metadata for administrators. Raw IP addresses, full chat text, +# and full inventory contents are not written unless explicitly enabled below. +runtime-telemetry: + enabled: true + + audit-log: + enabled: true + file: telemetry/audit.jsonl + max-file-size-mb: 10 + + remote-reporting: + enabled: true + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + endpoint: "/telemetry/events" + interval-ticks: 1200 + batch-size: 100 + max-queued-events: 5000 + timeout-ms: 3000 + require-activation-token: true + + privacy: + hash-salt: "" + include-raw-network-addresses: false + + identity: + enabled: true + + movement: + enabled: true + sample-interval-ms: 1000 + max-distance-per-sample: 24.0 + max-blocks-per-second: 30.0 + log-samples: false + + state-delta: + enabled: true + include-inventory-summary: true + + text: + enabled: true + capture-raw: false + log-all-metadata: false + log-command-root: true + log-matched-terms: true + risk-terms: + - dupe + - crash + - lag machine + - xray + - kill aura + + environment-probe: + enabled: true + interval-ticks: 1200 + redline-action: disable-plugin + denied-jvm-args: + - "-agentlib:jdwp" + - "-Xdebug" + block-java-agents: false + denied-env-vars: [] + denied-system-properties: [] + # Options for enchanting items in the enchanting table enchanting-table: enabled: true # If custom enchantments should be available from enchanting tables diff --git a/documentation/ecoenchants/runtime-telemetry-api.md b/documentation/ecoenchants/runtime-telemetry-api.md new file mode 100644 index 0000000000..2f2b545ac9 --- /dev/null +++ b/documentation/ecoenchants/runtime-telemetry-api.md @@ -0,0 +1,228 @@ +--- +title: "运行时遥测上报 API" +sidebar_position: 11 +--- + +# EcoEnchants 运行时遥测上报 API + +本文档定义插件端运行时遥测向后端上报时,后端需要实现的接口、鉴权、幂等、响应语义和数据字段。 + +当前插件默认 API Base URL: + +```text +https://tts.chloemlla.com/api/ecoenchants/v1 +``` + +插件会对误配置的重复前缀做容错,例如 `https://tts.chloemlla.com/https://tts.chloemlla.com/api/ecoenchants/v1` 会被规范化为上面的 Base URL。 + +## 1. 插件端行为 + +插件本地先将运行时事件写入统一审计事件结构,再同时进入本地 JSONL 审计日志和远端上报队列。 + +远端上报不是心跳。只有出现新事件时,插件才会启动定时上报任务;队列清空后,插件会取消该任务并停止请求后端。没有新变更时不会发送空批次。 + +默认配置: + +```yaml +runtime-telemetry: + remote-reporting: + enabled: true + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + endpoint: "/telemetry/events" + interval-ticks: 1200 + batch-size: 100 + max-queued-events: 5000 + timeout-ms: 3000 + require-activation-token: true +``` + +上报周期为 `interval-ticks`,默认 1200 tick,约 60 秒。每批最多 `batch-size` 条事件。队列超过 `max-queued-events` 时,插件会丢弃超限事件并在 `/ecoenchants services` 中展示 dropped 计数。 + +## 2. 接口 + +### `POST /telemetry/events` + +完整 URL: + +```text +POST https://tts.chloemlla.com/api/ecoenchants/v1/telemetry/events +``` + +插件端将任意 `2xx` 响应视为成功。非 `2xx`、连接失败、超时都会将该批事件重新放回队列,等待下一次周期重试。 + +## 3. 请求头 + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `Content-Type` | 是 | 固定 `application/json; charset=utf-8` | +| `Accept` | 是 | 固定 `application/json` | +| `User-Agent` | 是 | 例如 `EcoEnchants/13.0.0 Paper/1.21.11 Java/21` | +| `Authorization` | 默认必填 | `Bearer <activationToken>`,来自授权校验响应 | +| `X-Request-Id` | 是 | 插件每次请求生成的 UUID | +| `Idempotency-Key` | 是 | 本次 HTTP 批次 UUID | +| `X-Eco-Product-Id` | 是 | 固定 `ecoenchants` | +| `X-Eco-Installation-Id` | 是 | 插件本地安装实例 UUID | +| `X-Eco-Plugin-Version` | 是 | 插件版本 | + +后端授权接口 `POST /licenses/verify` 应在 `valid` 或 `trial` 响应中返回: + +```json +{ + "status": "valid", + "activationToken": "short-lived-token", + "activationId": "act_01JZ0000000000000000000000" +} +``` + +如果 `runtime-telemetry.remote-reporting.require-activation-token` 为 `true`,而授权响应没有 `activationToken`,插件只保留本地审计日志,不会远端上报。 + +## 4. 请求体 + +```json +{ + "productId": "ecoenchants", + "installationId": "7f29607c-3e30-4b6b-8476-b0b286d9fb10", + "activationId": "act_01JZ0000000000000000000000", + "plugin": { + "version": "13.0.0", + "channel": "stable" + }, + "server": { + "platform": "Paper", + "platformVersion": "1.21.11-R0.1-SNAPSHOT", + "minecraftVersion": "1.21.11", + "onlineMode": true, + "javaVersion": "21.0.7" + }, + "batch": { + "id": "9a8c4d8f-9f14-4413-b4c7-6b8c8a6fba38", + "sequence": 42, + "createdAt": "2026-06-06T08:00:00Z", + "eventCount": 2 + }, + "events": [ + { + "eventId": "c880f80b-f7e7-45d6-8d06-50534bb2b0fe", + "timestamp": "2026-06-06T08:00:00Z", + "category": "identity_anchor", + "payload": {} + } + ] +} +``` + +### 幂等要求 + +后端必须按 `eventId` 去重。同一事件在网络超时、连接中断或后端返回非 `2xx` 后可能被再次发送。`Idempotency-Key` 只代表本次 HTTP 批次,不能替代事件级去重。 + +建议唯一键: + +```text +productId + installationId + eventId +``` + +## 5. 事件结构 + +每个事件固定包含: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `eventId` | string | 插件生成的 UUID,事件级幂等键 | +| `timestamp` | string | ISO-8601 UTC | +| `category` | string | 事件类别 | +| `payload` | object | 类别相关字段 | + +当前插件可能发送的 `category`: + +| Category | 说明 | +| --- | --- | +| `telemetry_lifecycle` | 插件遥测启动、重载、停止 | +| `environment_probe` | JVM 参数、系统属性、环境变量等透明合规探针结果 | +| `identity_anchor` | 玩家 UUID、名称、online-mode、网络路由哈希 | +| `client_context` | 协议版本、客户端品牌、语言、视距、ping | +| `session_end` | 玩家会话结束 | +| `trajectory_sample` | 可配置的移动轨迹采样 | +| `trajectory_anomaly` | 超阈值位移或速度异常 | +| `trajectory_transition` | 传送、跨世界移动等空间上下文切换 | +| `state_transition` | 飞行等玩家状态切换 | +| `state_baseline` | 玩家背包状态基线哈希 | +| `state_delta` | 背包状态哈希变化 | +| `economy_delta` | 经验、等级、附魔消耗等经济流转 | +| `behavioral_text` | 聊天或命令文本风险元数据 | + +## 6. 隐私边界 + +默认情况下,插件不会上传明文玩家 IP、完整背包内容或完整聊天文本。 + +默认上传的是: + +- 玩家 UUID 与名称。 +- 网络地址、hostname、virtual host 的 SHA-256 本地盐哈希。 +- 坐标采样和世界 UUID,世界名称为哈希。 +- 背包整体哈希和按材料汇总的数量。 +- 聊天或命令的长度、哈希、风险词命中结果。 + +只有管理员显式开启以下配置时,才会扩大数据面: + +```yaml +runtime-telemetry: + privacy: + include-raw-network-addresses: true + text: + capture-raw: true +``` + +后端应将这些字段按敏感数据处理,并支持按 `installationId` 和时间范围删除遥测记录。 + +## 7. 响应 + +推荐成功响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "status": "accepted", + "acceptedEvents": 100, + "duplicateEvents": 3, + "rejectedEvents": [], + "serverTime": "2026-06-06T08:00:01Z" +} +``` + +如果单条事件字段不符合后端业务规则,但请求整体已被处理,建议仍返回 `200` 或 `202`,并在 `rejectedEvents` 中说明。插件只按 HTTP 状态判断是否重试;如果后端用 `4xx` 拒绝整批,插件会保留并重试整批。 + +推荐错误响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "error": { + "code": "invalid_activation_token", + "message": "Activation token is missing, expired, or revoked.", + "retryAfterSeconds": 300 + } +} +``` + +## 8. 状态码语义 + +| 状态码 | 插件行为 | 后端语义 | +| --- | --- | --- | +| `200` / `202` / 其他 `2xx` | 认为成功,移除该批事件 | 批次已接收或已入队 | +| `400` / `422` | 重试整批 | 请求结构不被接受,后端应谨慎使用 | +| `401` / `403` | 重试整批 | token 无效或授权不足 | +| `409` | 重试整批 | 幂等冲突,建议改为 `2xx` 并报告 duplicate | +| `429` | 重试整批 | 限流 | +| `500` / `503` | 重试整批 | 后端暂不可用 | + +## 9. 后端验收清单 + +- 实现 `POST /api/ecoenchants/v1/licenses/verify`,并在成功响应中返回 `activationToken` 和 `activationId`。 +- 实现 `POST /api/ecoenchants/v1/telemetry/events`。 +- 验证 `Authorization: Bearer <activationToken>`。 +- 按 `productId + installationId + eventId` 做事件级幂等去重。 +- 接收重复事件时返回 `2xx`,不要让插件无限重试已入库数据。 +- 对 `events` 设置合理上限,至少支持默认批量 100 条。 +- 所有时间按 ISO-8601 UTC 存储。 +- 对明文网络地址和 raw text 字段设置更高权限和更短保留周期。 +- 对 `401`、`403`、`429`、`5xx` 做监控;这些状态会导致插件保留队列并周期重试。 diff --git a/documentation/ecoenchants/secure-rpc-operations-api.md b/documentation/ecoenchants/secure-rpc-operations-api.md new file mode 100644 index 0000000000..9ca550bbf4 --- /dev/null +++ b/documentation/ecoenchants/secure-rpc-operations-api.md @@ -0,0 +1,651 @@ +--- +title: "安全 RPC 运维与灾备 API" +sidebar_position: 10 +--- + +# `/api/ecoenchants/v1` 安全 RPC 运维与灾备技术需求 + +本文定义 EcoEnchants 后端与已授权 Minecraft 服务器实例之间的远程运维、受控文件管理、敏感数据脱敏导出、灾备归档与版本回滚能力。目标是在传输安全、身份可信、权限最小化、操作可审计的前提下,为多实例服务器提供集中化调试和应急恢复能力。 + +本设计不提供裸露的远程代码执行能力。所有远程操作必须映射为后端登记的受控任务,经过 RBAC、实例授权、参数校验、风险确认和审计后才能下发。 + +## 1. 范围与边界 + +### 1.1 系统目标 + +- 支持多实例服务器集中接入后端控制台。 +- 支持安全下发受控管理任务,并异步回传执行结果。 +- 支持在指定 Minecraft 服务器根目录内进行文件读取、上传、覆盖和安全删除。 +- 支持敏感配置、日志片段、玩家相关调试数据的脱敏导出。 +- 支持持久化数据和核心配置文件的自动压缩归档、远程触发备份和受控回滚。 +- 支持本地安全审计日志与后端审计日志双写。 + +### 1.2 明确禁止 + +- 禁止通过 API 暴露任意系统 shell、任意 JVM 代码执行、动态加载外部 JAR 或隐式 RCE。 +- 禁止后端接受匿名指令或未签名指令。 +- 禁止文件接口访问服务器根目录以外的路径。 +- 禁止通过路径穿越、符号链接、Windows 盘符、UNC 路径或绝对路径绕过受控目录。 +- 禁止在日志、审计事件、任务结果中回传明文密钥、完整授权码、玩家 IP、聊天内容等非必要敏感数据。 + +## 2. 基础约定 + +- Base URL: `https://tts.chloemlla.com/api/ecoenchants` +- API version: `/v1` +- WebSocket RPC: `wss://tts.chloemlla.com/api/ecoenchants/v1/rpc/connect` +- Content-Type: `application/json; charset=utf-8` +- 所有时间使用 ISO-8601 UTC,例如 `2026-06-05T08:00:00Z`。 +- 所有写入接口必须支持 `Idempotency-Key`。 +- 所有响应必须包含 `requestId`。 +- 插件必须主动向后端建立出站连接,后端不得要求客户暴露公网入站端口。 + +## 3. 通信与鉴权 + +### 3.1 传输安全 + +- 所有 HTTP 和 WebSocket 连接必须使用 TLS 1.3 或更高安全等级配置。 +- 企业版或高权限运维能力必须支持 mTLS,插件实例使用短期客户端证书接入。 +- 非 mTLS 场景必须使用短期访问令牌加 HMAC-SHA256 请求签名。 +- 证书、令牌和签名密钥必须支持轮换、吊销和过期。 + +### 3.2 请求签名 + +使用 HMAC-SHA256 时,请求必须包含: + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `Authorization` | 是 | `Bearer <activationToken>` 或运维会话令牌 | +| `X-Eco-Key-Id` | 是 | 签名密钥 ID | +| `X-Eco-Timestamp` | 是 | UTC 秒级时间戳 | +| `X-Eco-Nonce` | 是 | 至少 128 bit 随机值 | +| `X-Eco-Signature` | 是 | HMAC-SHA256 签名 | +| `X-Request-Id` | 否 | 客户端生成或后端生成 | + +签名串必须包含 HTTP method、path、query、timestamp、nonce、body SHA-256。后端必须拒绝: + +- 时间偏差超过 300 秒的请求。 +- 已使用过的 nonce。 +- 签名不匹配的请求。 +- token scope 与接口不匹配的请求。 + +插件端 HTTP / WebSocket 握手签名串为: + +```text +<METHOD> +<rawPath> +<rawQuery> +<unixTimestampSeconds> +<nonce> +<sha256(body)> +``` + +WebSocket 建立后的 RPC 消息必须在 JSON 信封中携带 `timestamp`、`nonce`、`signature`;如果插件配置了 `remote-operations.security.hmac.key-id`,还必须携带匹配的 `keyId`。插件端按以下字段顺序校验 HMAC,密钥优先使用 `remote-operations.security.hmac.secret`,为空时使用当前 activation/session token: + +```text +RPC +<unixTimestampSeconds> +<nonce> +<type> +<requestId> +<jobId> +<method> +<commandId> +<mount> +<path> +<mode> +<contentSha256> +<backupId> +<archiveSha256> +<redactionPolicy> +<format> +<offset> +<limitBytes> +<mounts comma-list> +<paths comma-list> +<restorePaths comma-list> +``` + +### 3.3 权限模型 + +后端必须实现 RBAC 和实例级授权: + +| 能力 | 推荐权限 | +| --- | --- | +| 查看实例状态 | `ops.instance.read` | +| 下发诊断任务 | `ops.job.diagnostics` | +| 读取文件 | `ops.file.read` | +| 上传或覆盖文件 | `ops.file.write` | +| 删除文件 | `ops.file.delete` | +| 创建备份 | `ops.backup.create` | +| 执行回滚 | `ops.backup.restore` | +| 修改任务白名单 | `ops.policy.write` | + +高风险操作,例如文件删除、配置覆盖、备份回滚,必须支持二次确认。生产实例建议要求双人审批。 + +## 4. RPC 连接模型 + +### 4.1 实例注册 + +实例必须先完成授权激活,再申请运维 RPC 能力。后端只向具备运维权益和启用配置的实例开放 RPC。 + +`POST /v1/ops/instances/register` + +请求: + +```json +{ + "productId": "ecoenchants", + "activationId": "act_01JZ0000000000000000000000", + "installationId": "7f29607c-3e30-4b6b-8476-b0b286d9fb10", + "server": { + "name": "Survival Network", + "platform": "Paper", + "minecraftVersion": "1.21.11", + "javaVersion": "21.0.7" + }, + "capabilities": { + "mtls": true, + "fileOps": true, + "backupArchive": true, + "redactedExport": true + } +} +``` + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "instanceId": "ins_01JZ0000000000000000000000", + "rpcUrl": "wss://tts.chloemlla.com/api/ecoenchants/v1/rpc/connect", + "sessionToken": "short-lived-token", + "sessionExpiresAt": "2026-06-05T09:00:00Z", + "policyVersion": "pol_2026_06_05" +} +``` + +### 4.2 WebSocket 握手 + +插件连接 `GET /v1/rpc/connect` 时必须携带: + +- `Authorization: Bearer <sessionToken>` +- `X-Eco-Timestamp` +- `X-Eco-Nonce` +- `X-Eco-Signature` +- 可选 mTLS 客户端证书 + +连接建立后,插件必须发送 `rpc.hello`: + +```json +{ + "type": "rpc.hello", + "requestId": "req_01JZ0000000000000000000000", + "instanceId": "ins_01JZ0000000000000000000000", + "policyVersion": "pol_2026_06_05", + "supportedMethods": [ + "ops.diagnostics.snapshot", + "ops.command.runManaged", + "ops.file.read", + "ops.file.write", + "ops.file.delete", + "ops.backup.create", + "ops.backup.restore" + ] +} +``` + +### 4.3 RPC 消息信封 + +所有 RPC 消息使用统一信封: + +```json +{ + "type": "rpc.request", + "requestId": "req_01JZ0000000000000000000000", + "jobId": "job_01JZ0000000000000000000000", + "method": "ops.file.read", + "issuedAt": "2026-06-05T08:00:00Z", + "expiresAt": "2026-06-05T08:05:00Z", + "params": {} +} +``` + +插件必须返回接收确认、进度事件和最终结果: + +```json +{ + "type": "rpc.result", + "requestId": "req_01JZ0000000000000000000000", + "jobId": "job_01JZ0000000000000000000000", + "status": "succeeded", + "result": {}, + "completedAt": "2026-06-05T08:00:10Z" +} +``` + +## 5. 受控指令下发 + +### 5.1 任务创建接口 + +控制台通过后端创建任务,后端再通过 RPC 下发给目标实例。 + +`POST /v1/ops/instances/{instanceId}/jobs` + +请求: + +```json +{ + "method": "ops.command.runManaged", + "reason": "Reload EcoEnchants configuration after approved change.", + "riskLevel": "medium", + "params": { + "commandId": "ecoenchants.reload", + "arguments": { + "scope": "plugin-config" + } + } +} +``` + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "jobId": "job_01JZ0000000000000000000000", + "status": "queued", + "createdAt": "2026-06-05T08:00:00Z" +} +``` + +### 5.2 指令白名单 + +`ops.command.runManaged` 只能执行后端策略中登记的 `commandId`。每个指令必须声明: + +- `commandId` +- `description` +- `riskLevel` +- `allowedRoles` +- `argumentSchema` +- `timeoutSeconds` +- `maxOutputBytes` +- `requiresApproval` +- `minecraftConsoleTemplate` + +示例策略: + +```json +{ + "commandId": "ecoenchants.reload", + "riskLevel": "medium", + "allowedRoles": ["ops-admin"], + "argumentSchema": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "enum": ["plugin-config"] + } + }, + "required": ["scope"] + }, + "timeoutSeconds": 10, + "maxOutputBytes": 65536, + "requiresApproval": false, + "minecraftConsoleTemplate": "ecoenchants reload" +} +``` + +插件不得拼接任意 shell 命令。若确需执行 Minecraft 控制台命令,也只能由固定模板生成,并且参数必须通过 schema 校验和字符集限制。 + +### 5.3 任务状态查询 + +`GET /v1/ops/jobs/{jobId}` + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "jobId": "job_01JZ0000000000000000000000", + "instanceId": "ins_01JZ0000000000000000000000", + "method": "ops.command.runManaged", + "status": "succeeded", + "createdAt": "2026-06-05T08:00:00Z", + "startedAt": "2026-06-05T08:00:02Z", + "completedAt": "2026-06-05T08:00:05Z", + "output": { + "truncated": false, + "text": "Reload complete." + } +} +``` + +## 6. 受控文件管理 + +### 6.1 目录边界 + +插件必须维护可访问目录清单: + +| mount | 说明 | +| --- | --- | +| `server-root` | 指定 Minecraft 服务器运行根目录 | +| `plugin-data` | EcoEnchants 插件数据目录 | +| `config` | 允许维护的配置目录 | +| `logs` | 允许读取的日志目录 | +| `backups` | 备份归档目录 | + +所有文件路径必须是相对路径。插件处理路径时必须: + +- URL decode 后再规范化。 +- 拒绝空路径、绝对路径、`..`、NUL 字符、控制字符。 +- 拒绝 Windows 盘符、UNC 路径和跨盘符访问。 +- 使用受控根目录解析真实路径,拒绝符号链接逃逸。 +- 在写入前再次校验父目录真实路径仍位于受控根目录内。 + +### 6.2 文件读取 + +`POST /v1/ops/instances/{instanceId}/files/read` + +请求: + +```json +{ + "mount": "logs", + "path": "latest.log", + "offset": 0, + "limitBytes": 131072, + "redactionPolicy": "logs-default" +} +``` + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "jobId": "job_01JZ0000000000000000000000", + "status": "queued" +} +``` + +读取结果必须通过 RPC 异步回传。超过大小限制的文件必须分页或拒绝。 + +### 6.3 文件上传与覆盖 + +`POST /v1/ops/instances/{instanceId}/files/write` + +请求: + +```json +{ + "mount": "config", + "path": "enchants/custom.yml", + "mode": "overwrite", + "contentSha256": "hex-sha256", + "contentBase64": "base64-content", + "reason": "Approved configuration update." +} +``` + +写入要求: + +- 默认禁止覆盖 `.jar`、`.class`、`.exe`、`.dll`、`.so`、脚本文件和启动参数文件。 +- 写入必须先落到同目录临时文件,校验 hash 后原子替换。 +- 覆盖前必须保存变更前 hash,必要时生成本地回滚副本。 +- 配置文件写入后可触发受控 reload 任务,但不得自动执行任意命令。 + +### 6.4 安全删除 + +`POST /v1/ops/instances/{instanceId}/files/delete` + +请求: + +```json +{ + "mount": "server-root", + "path": "cache/tmp-12345.bin", + "mode": "quarantine", + "reason": "Clean temporary cache after incident." +} +``` + +删除要求: + +- 默认使用 quarantine 或 recycle 模式,直接永久删除必须是高风险操作。 +- 禁止删除受保护目录,例如 `world` 根、`plugins` 根、`backups` 根。 +- 批量删除必须限制最大文件数和总大小。 +- 每次删除必须记录审计事件和删除前 hash。 + +## 7. 数据脱敏导出 + +### 7.1 脱敏策略 + +后端必须支持命名脱敏策略: + +| 策略 | 用途 | +| --- | --- | +| `logs-default` | 日志导出,遮蔽 IP、token、授权码、邮箱 | +| `config-default` | 配置导出,遮蔽 secret、password、key、token | +| `players-debug` | 玩家调试数据,哈希玩家标识并删除非必要字段 | + +脱敏方式包括: + +- 固定格式遮蔽,例如 `192.168.1.10` 变为 `192.168.x.x`。 +- 不可逆哈希,例如 `sha256:<digest>`。 +- 稳定伪匿名化,例如使用实例级 salt 的 HMAC-SHA256。 +- 字段级删除,例如移除聊天内容、完整 IP、会话 token。 + +### 7.2 导出接口 + +`POST /v1/ops/instances/{instanceId}/exports` + +请求: + +```json +{ + "source": { + "mount": "plugin-data", + "paths": [ + "config.yml", + "enchants/" + ] + }, + "redactionPolicy": "config-default", + "archiveFormat": "tar.gz", + "reason": "Support debugging without exposing secrets." +} +``` + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "jobId": "job_01JZ0000000000000000000000", + "status": "queued" +} +``` + +导出产物必须记录: + +- 源路径清单。 +- 每个源文件 hash。 +- 使用的脱敏策略版本。 +- 导出包 hash。 +- 生成时间、操作者、审批记录。 + +## 8. 灾备归档与回滚 + +### 8.1 创建备份 + +`POST /v1/ops/instances/{instanceId}/backups` + +请求: + +```json +{ + "scope": { + "mounts": ["plugin-data", "config"], + "paths": ["config.yml", "enchants/", "types.yml"] + }, + "format": "tar.gz", + "retentionDays": 30, + "reason": "Backup before configuration migration." +} +``` + +备份要求: + +- 只允许备份受控根目录下的文件。 +- 归档必须包含 manifest,包括文件路径、大小、mtime、sha256、插件版本、服务器版本。 +- 归档文件必须写入受控 backups 目录或上传到后端对象存储。 +- 归档 hash 必须回传后端。 +- 大型备份必须分片上传并支持续传。 + +### 8.2 查询备份 + +`GET /v1/ops/instances/{instanceId}/backups` + +响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "backups": [ + { + "backupId": "bak_01JZ0000000000000000000000", + "createdAt": "2026-06-05T08:00:00Z", + "format": "tar.gz", + "sizeBytes": 1048576, + "sha256": "hex-sha256", + "scope": ["plugin-data", "config"], + "status": "available" + } + ] +} +``` + +### 8.3 版本回滚 + +`POST /v1/ops/instances/{instanceId}/backups/{backupId}/restore` + +请求: + +```json +{ + "mode": "staged", + "restorePaths": ["config.yml", "enchants/"], + "preRestoreBackup": true, + "reason": "Rollback after failed configuration migration." +} +``` + +回滚要求: + +- 必须验证归档 hash 和 manifest。 +- 必须先创建回滚前备份,除非实例不可写且审批明确豁免。 +- 默认使用 staged 模式,先解压到临时目录并比对目标变更。 +- 高风险回滚必须要求二次确认。 +- 回滚完成后必须回传变更摘要和审计事件。 + +## 9. 审计日志 + +### 9.1 本地 Security Audit Log + +插件必须在本地写入 append-only JSONL 审计日志。建议路径: + +`plugins/EcoEnchants/security-audit.log` + +事件示例: + +```json +{ + "auditId": "aud_01JZ0000000000000000000000", + "requestId": "req_01JZ0000000000000000000000", + "jobId": "job_01JZ0000000000000000000000", + "createdAt": "2026-06-05T08:00:00Z", + "actor": { + "type": "admin", + "id": "usr_01JZ0000000000000000000000" + }, + "action": "ops.file.write", + "resource": { + "mount": "config", + "path": "enchants/custom.yml" + }, + "decision": "allowed", + "beforeSha256": "hex-before", + "afterSha256": "hex-after", + "policyVersion": "pol_2026_06_05", + "previousEntryHash": "hex-previous", + "entryHash": "hex-current" +} +``` + +审计要求: + +- 本地和后端都必须记录关键操作。 +- 审计日志不得包含完整 secret、token、license key、玩家 IP 或聊天正文。 +- 本地日志建议使用 hash chain 降低篡改风险。 +- 后端审计日志必须可按实例、操作者、任务、时间范围查询。 + +### 9.2 审计查询接口 + +`GET /v1/ops/audit-logs?instanceId={instanceId}&from={time}&to={time}` + +响应必须分页,并支持按 `action`、`actorId`、`jobId` 过滤。 + +## 10. 错误模型 + +通用错误响应: + +```json +{ + "requestId": "req_01JZ0000000000000000000000", + "error": { + "code": "path_outside_allowed_root", + "message": "The requested path is outside the allowed root.", + "retryAfterSeconds": null + } +} +``` + +常见错误码: + +| code | HTTP | 说明 | +| --- | --- | --- | +| `unauthorized` | 401 | token 缺失或无效 | +| `signature_invalid` | 401 | 签名校验失败 | +| `permission_denied` | 403 | 权限不足 | +| `approval_required` | 403 | 需要审批 | +| `instance_offline` | 409 | 实例未连接 RPC | +| `policy_rejected` | 422 | 任务不符合策略 | +| `path_outside_allowed_root` | 422 | 路径越界 | +| `file_type_blocked` | 422 | 文件类型禁止写入 | +| `backup_integrity_failed` | 422 | 备份 hash 或 manifest 校验失败 | +| `rate_limited` | 429 | 请求过于频繁 | + +## 11. 运行限制 + +- 单实例并发任务数默认不超过 2。 +- 文件读取默认单次不超过 1 MiB,必须支持分页或分片。 +- 文件写入默认单文件不超过 10 MiB,超过需走分片上传。 +- 任务输出默认最多保留 64 KiB,超出必须截断并标记。 +- WebSocket 断线必须指数退避重连。 +- 后端不得因为 RPC 不可用影响插件核心游戏逻辑,除非客户明确启用严格运维模式。 + +## 12. 最低验收清单 + +- [ ] 所有控制链路使用 TLS,企业高权限能力支持 mTLS。 +- [ ] 所有写操作通过 HMAC 时间戳签名或 mTLS 身份校验。 +- [ ] 后端拒绝匿名、过期、重放和 scope 不匹配的请求。 +- [ ] 远程指令只能执行白名单受控任务,不能执行任意 shell。 +- [ ] 文件路径规范化和真实路径校验覆盖 Linux 与 Windows。 +- [ ] 文件写入使用原子替换,删除默认进入隔离区。 +- [ ] 脱敏导出覆盖 license key、token、password、IP、email 等敏感模式。 +- [ ] 备份归档包含 manifest 和 sha256,回滚前默认创建预备份。 +- [ ] 本地 Security Audit Log 与后端审计日志都能追踪关键操作。 +- [ ] 高风险任务支持审批、二次确认、限流和可取消。 +- [ ] 断网或后端故障不会触发未授权操作,也不会破坏服务器数据。 diff --git a/documentation/guide/homepage.md b/documentation/guide/homepage.md new file mode 100644 index 0000000000..c35f2ca6f1 --- /dev/null +++ b/documentation/guide/homepage.md @@ -0,0 +1,67 @@ +--- +title: 主页说明 +description: EcoEnchants 文档站主页结构、入口职责与维护规则。 +--- + +# 主页说明 + +EcoEnchants 文档站主页是服主进入文档的总控入口。它不替代细分页,也不承载完整配置说明;它负责把“我现在该看哪里”这件事说清楚。 + +## 主页定位 + +主页面向四类常见场景: + +| 场景 | 主页入口 | 适合解决的问题 | +| --- | --- | --- | +| 首次评估插件 | 阅读调研报告 | 判断 EcoEnchants 的玩法定位、默认附魔规模、部署边界和基础风险。 | +| 准备启用 advanced 分支 | Advanced 功能 | 理解授权、远程运维、备份、遥测和 GUI 改造的启用顺序。 | +| 对照原始能力 | 原始文档 | 查阅 EcoEnchants 原文档中的功能描述、配置字段和玩法说明。 | +| 维护文档站 | 主页说明 | 理解首页每个区域的用途,避免后续更新时把首页变成内容堆叠页。 | + +## 页面区域 + +### 首屏 Hero + +首屏只保留品牌、定位和高优先级入口。它的目标是让服主在几秒内确认这是一个围绕 EcoEnchants 运营、配置、授权和排障建立的中文文档站。 + +### Control Center + +`CONTROL CENTER` 区域把当前文档站最重要的运维判断压缩成三条信号: + +* 当前重点:提示主要阅读对象。 +* 关键风险:提醒 advanced 分支启用前必须关注的边界。 +* 推荐顺序:给出部署、调参、权限和审计的阅读链路。 + +### Reading Path + +`READING PATH` 是主页最重要的导航区。它按照实际上线顺序组织,而不是按照文件目录排序: + +1. 先看总览。 +2. 再调玩法。 +3. 接入 advanced。 +4. 上线前复核。 + +### Site Signals + +`SITE SIGNALS` 用短指标解释文档站覆盖范围,例如默认附魔数量、advanced 能力类别、本地搜索和中文入口。这里适合放“帮助服主快速建立判断”的信息,不适合放长段配置说明。 + +### Advanced Map + +`ADVANCED MAP` 把 advanced 分支能力拆成授权、运维、遥测和 GUI 体验四组。每组都链接到独立章节,避免把高风险后端能力混在同一段说明里。 + +## 内容维护规则 + +主页更新时遵守以下规则: + +* 首页只放入口、判断信号和阅读路线;长篇解释放到独立文档页。 +* 新增 advanced 能力时,优先补充对应细分页,再考虑是否需要在主页增加入口。 +* 涉及远程文件操作、备份恢复、授权校验、遥测上报的内容必须明确风险边界。 +* 首页卡片数量应保持可扫描,避免把所有文档链接都堆到首页。 +* 文案面向服主,不使用开发流程、任务编号或临时实现术语。 + +## 相关页面 + +* [服主功能调研报告](/report/) +* [Chloemlla advanced 新功能](/report/chloemlla-advanced) +* [排障与上线清单](/report/advanced-troubleshooting) +* [Vercel 部署](/guide/vercel) diff --git a/documentation/guide/vercel.md b/documentation/guide/vercel.md new file mode 100644 index 0000000000..b4d5ce8029 --- /dev/null +++ b/documentation/guide/vercel.md @@ -0,0 +1,33 @@ +# Vercel 部署 + +本仓库已经在根目录提供 `vercel.json`,用于让 Vercel 直接构建 VitePress 文档站。 + +## 项目设置 + +在 Vercel 导入 GitHub 仓库后,保持项目根目录为仓库根目录。部署配置会从 `vercel.json` 读取: + +| 设置项 | 值 | +| --- | --- | +| Application Preset | `VitePress` | +| Root Directory | `./` | +| Install Command | `npm install` | +| Build Command | `npm run build` | +| Output Directory | `documentation/.vitepress/dist` | + +Vercel 的 VitePress 默认示例通常使用 `docs/.vitepress/dist`,但本仓库的文档目录是 `documentation`,因此输出目录需要使用 `documentation/.vitepress/dist`。这些值与 `.github/workflows/docs.yml` 中的文档构建流程保持一致。 + +## Node.js 版本 + +GitHub Actions 文档构建使用 Node.js 22。为了保持 Vercel 与 CI 一致,请在 Vercel 项目的 Node.js Version 设置中选择 `22.x`。 + +## 自动部署 + +Vercel 连接仓库后,会在推送到启用的生产分支时自动部署生产环境,并为 Pull Request 创建预览部署。文档源码位于 `documentation`,构建产物由 VitePress 写入 `documentation/.vitepress/dist`。 + +## 验证路径 + +部署完成后,访问站点首页确认以下入口可用: + +- `/report/` +- `/ecoenchants/` +- `/guide/vercel` diff --git a/documentation/index.md b/documentation/index.md new file mode 100644 index 0000000000..d7ecc2fe10 --- /dev/null +++ b/documentation/index.md @@ -0,0 +1,147 @@ +--- +layout: home +title: EcoEnchants 服主报告 +titleTemplate: false +hero: + name: EcoEnchants + text: 附魔玩法、运营边界与 advanced 能力总控台 + tagline: 面向 Minecraft 服务器主,把部署判断、玩法调参、授权接入、远程运维、遥测审计和 GUI 体验整理成一套可落地的中文文档。 + image: + src: /hero-ecoenchants.png + alt: EcoEnchants operations overview + actions: + - theme: brand + text: 阅读调研报告 + link: /report/ + - theme: alt + text: Advanced 功能 + link: /report/chloemlla-advanced + - theme: alt + text: 原始文档 + link: /ecoenchants/ + - theme: alt + text: 主页说明 + link: /guide/homepage +features: + - title: 面向服主决策 + details: 从部署、玩法、附魔池、权限、GUI 和配置风险出发,帮助开服前先看清上线边界。 + - title: 覆盖 advanced 能力 + details: 单独整理授权门禁、后端 API、远程运维、备份恢复、遥测探针和上线排障清单。 + - title: 更适合检索 + details: 本地搜索、中文导航、代码行号、清晰表格和目录结构让日常排查更快定位。 + - title: 首页单独说明 + details: 新增主页导览文档,解释每个入口、指标和阅读路线适合什么场景。 +--- + +<section class="home-section home-command"> + <div class="home-command-copy"> + <p class="home-eyebrow">CONTROL CENTER</p> + <h2>一屏判断:先开服,后接入,再排障</h2> + <p>首页按服主真实工作流组织:先判断插件是否适合当前服,再进入玩法与权限调参,最后按需启用 advanced 后端能力。每个入口都指向可执行的检查清单或配置说明。</p> + </div> + <div class="home-command-panel" aria-label="EcoEnchants 文档重点"> + <div> + <span>当前重点</span> + <strong>advanced 分支上线前评估</strong> + </div> + <div> + <span>关键风险</span> + <strong>授权、远程文件操作、备份恢复、遥测边界</strong> + </div> + <div> + <span>推荐顺序</span> + <strong>部署校验 → 玩法调参 → 权限收口 → 运维审计</strong> + </div> + </div> +</section> + +<section class="home-section"> + <div class="home-section-header"> + <p class="home-eyebrow">READING PATH</p> + <h2>从上线前判断到生产排障</h2> + <p>首页只放核心入口,具体判断和配置建议拆到独立章节,避免把授权、远程运维、遥测和 GUI 说明混在同一页。</p> + </div> + <div class="home-route-grid"> + <a class="route-card" href="/report/"> + <span>1</span> + <strong>先看总览</strong> + <small>确认插件定位、默认附魔规模、advanced 分支能力和生产服启用顺序。</small> + </a> + <a class="route-card" href="/report/gameplay-balance"> + <span>2</span> + <strong>再调玩法</strong> + <small>围绕附魔台、村民、战利品、铁砧、展示规则和经济流通做平衡。</small> + </a> + <a class="route-card" href="/report/chloemlla-advanced"> + <span>3</span> + <strong>接入 advanced</strong> + <small>按最小权限启用授权、远程运维、备份、遥测探针和管理员工具。</small> + </a> + <a class="route-card" href="/report/advanced-troubleshooting"> + <span>4</span> + <strong>上线前复核</strong> + <small>对照排障与上线清单检查启动日志、权限边界、回滚方案和审计记录。</small> + </a> + </div> +</section> + +<section class="home-section"> + <div class="home-section-header"> + <p class="home-eyebrow">SITE SIGNALS</p> + <h2>把分散信息压缩成可扫描信号</h2> + </div> + <div class="home-metrics"> + <div class="metric-item"> + <strong>102</strong> + <span>默认可用附魔配置,覆盖 normal、spell、special、curse 等类型。</span> + </div> + <div class="metric-item"> + <strong>7 类</strong> + <span>advanced 新能力:授权、API、运维、备份、遥测、玩家体验、GUI 管理。</span> + </div> + <div class="metric-item"> + <strong>本地搜索</strong> + <span>文档站内置 VitePress local search,部署后无需额外搜索服务。</span> + </div> + <div class="metric-item"> + <strong>中文入口</strong> + <span>服主报告、部署说明和原始文档统一在同一个导航层级里。</span> + </div> + </div> +</section> + +<section class="home-section"> + <div class="home-section-header"> + <p class="home-eyebrow">ADVANCED MAP</p> + <h2>高级能力按风险等级拆开阅读</h2> + <p>advanced 分支不是单个开关,而是一组服务端能力。首页把它们分成授权、运维、遥测、体验与排障,便于按权限、审计和回滚要求逐项启用。</p> + </div> + <div class="home-capability-grid"> + <a href="/report/advanced-license-services"> + <span>Auth</span> + <strong>授权与服务状态</strong> + <small>启动门禁、后端 API、在线校验和服务健康检查。</small> + </a> + <a href="/report/advanced-remote-operations"> + <span>Ops</span> + <strong>远程运维与备份</strong> + <small>文件操作、备份恢复、审批边界和最小权限策略。</small> + </a> + <a href="/report/advanced-telemetry-probe"> + <span>Probe</span> + <strong>遥测与环境探针</strong> + <small>运行时审计、环境采样、上报字段与隐私边界。</small> + </a> + <a href="/report/advanced-gui-experience"> + <span>GUI</span> + <strong>GUI 与玩家体验</strong> + <small>浏览入口、提示文案、管理员工具和玩家引导。</small> + </a> + </div> +</section> + +<section class="home-section"> + <div class="home-note"> + <p><strong>生产建议:</strong>先完成基础玩法和权限配置,再逐步启用 advanced 后端能力。远程文件操作与备份恢复默认保持关闭,只有明确的审批、审计和回滚流程后再开放。主页结构说明见 <a href="/guide/homepage">主页说明</a>。</p> + </div> +</section> diff --git a/documentation/public/brand-mark.svg b/documentation/public/brand-mark.svg new file mode 100644 index 0000000000..e9943ad482 --- /dev/null +++ b/documentation/public/brand-mark.svg @@ -0,0 +1,7 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect width="32" height="32" rx="8" fill="#10231C"/> + <path d="M8 8.7C8 7.8 8.8 7.1 9.7 7.4L16 9.6L22.3 7.4C23.2 7.1 24 7.8 24 8.7V22.4C24 23 23.6 23.6 23 23.8L16 26.2L9 23.8C8.4 23.6 8 23 8 22.4V8.7Z" fill="#F5E6B8"/> + <path d="M16 9.6V26.2" stroke="#7C4A2E" stroke-width="1.8" stroke-linecap="round"/> + <path d="M11 13.2L14 14.2M11 17L14 18M21 13.2L18 14.2M21 17L18 18" stroke="#5F745F" stroke-width="1.6" stroke-linecap="round"/> + <path d="M6.4 14.8H3.8M28.2 14.8H25.6M16 3.8V6.4M16 25.6V28.2" stroke="#34D399" stroke-width="1.8" stroke-linecap="round"/> +</svg> diff --git a/documentation/public/favicon.svg b/documentation/public/favicon.svg new file mode 100644 index 0000000000..6e17d8a6b3 --- /dev/null +++ b/documentation/public/favicon.svg @@ -0,0 +1,7 @@ +<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect width="64" height="64" rx="14" fill="#10231C"/> + <path d="M15 16.5C15 14.8 16.6 13.5 18.2 14.1L32 18.7L45.8 14.1C47.4 13.5 49 14.8 49 16.5V44.2C49 45.4 48.3 46.5 47.1 46.9L32 52L16.9 46.9C15.7 46.5 15 45.4 15 44.2V16.5Z" fill="#F5E6B8"/> + <path d="M32 18.7V52" stroke="#7C4A2E" stroke-width="3.4" stroke-linecap="round"/> + <path d="M22 26.5L28 28.4M22 34L28 35.8M42 26.5L36 28.4M42 34L36 35.8" stroke="#5F745F" stroke-width="3" stroke-linecap="round"/> + <path d="M11 31H5M59 31H53M32 5V11M32 53V59" stroke="#34D399" stroke-width="3.4" stroke-linecap="round"/> +</svg> diff --git a/documentation/public/hero-ecoenchants.png b/documentation/public/hero-ecoenchants.png new file mode 100644 index 0000000000..734231b5c4 Binary files /dev/null and b/documentation/public/hero-ecoenchants.png differ diff --git a/documentation/report/advanced-gui-experience.md b/documentation/report/advanced-gui-experience.md new file mode 100644 index 0000000000..3cb2b855e2 --- /dev/null +++ b/documentation/report/advanced-gui-experience.md @@ -0,0 +1,174 @@ +# advanced:GUI 与玩家体验 + +advanced 分支把 `/ecoenchants gui` 从简单浏览器扩展为玩家可用的附魔工作台,也新增了自动提示、指南书和管理员工具。目标是减少玩家不知道怎么找附魔、为什么没结果、哪些附魔冲突的问题。 + +## 玩家主流程 + +推荐告诉玩家: + +1. 执行 `/ecoenchants gui`。 +2. 不放物品时可以浏览全部附魔。 +3. 把装备放到顶部中间槽后,只看适合该物品的附魔。 +4. 点击类型、稀有度、目标筛选按钮缩小范围。 +5. 点击冲突查看按钮,了解已有附魔阻止了什么。 +6. 用 `/enchantinfo <name> [level]` 随时查看详情。 +7. 用 `/ecoenchants toggledescriptions` 控制物品 lore 里的描述显示。 + +## 主 GUI 控件 + +默认控件: + +| 控件 | 默认物品 | 配置段 | 用途 | +| --- | --- | --- | --- | +| 信息按钮 | player head | `enchant-gui.info` | 告诉玩家怎么放物品和浏览。 | +| 放入物品槽 | 第 1 行第 5 列 | `item-row` / `item-column` | 玩家放装备后触发兼容过滤。 | +| 类型筛选 | compass | `enchant-gui.filters.type` | 在 `types.yml` 的类型之间循环。 | +| 稀有度筛选 | emerald | `enchant-gui.filters.rarity` | 在 `rarity.yml` 的稀有度之间循环。 | +| 目标筛选 | anvil | `enchant-gui.filters.target` | 在 `targets.yml` 的目标之间循环。 | +| 仅兼容 | lime dye | `enchant-gui.filters.compatible-only` | 只显示当前物品可添加附魔。 | +| 冲突查看 | knowledge book | `enchant-gui.conflict-view` | 分析当前物品已有附魔造成的冲突。 | +| 管理员工具 | command block | `enchant-gui.admin-tools` | 有权限时打开管理员 GUI。 | +| 关闭按钮 | barrier | `enchant-gui.close-button` | 关闭界面。 | +| 翻页按钮 | arrow | `enchant-gui.page-change` | 浏览大附魔池。 | + +修改按钮位置时,不要占用同一个 `row` / `column`。尤其不要占用放入物品槽。 + +## 空结果提示 + +advanced 分支会区分空结果原因: + +| 原因 | 玩家看到的含义 | 常见处理 | +| --- | --- | --- | +| `none-loaded` | 当前没有可浏览附魔 | 管理员检查配置和 reload。 | +| `filter` | 当前筛选没有结果 | 继续点击筛选或回到全部。 | +| `item-compatible` | 当前物品没有可添加附魔 | 换装备、关闭仅兼容、查看冲突。 | +| `item-and-group` | 当前分组对该物品无结果 | 返回分组或取出物品。 | +| `item-and-filter` | 当前筛选对该物品无结果 | 切换类型、稀有度或目标。 | + +这些提示由 `lang.yml` 的 `hints.empty-results` 和 `gui.enchant.empty-results` 控制。 + +## 分组浏览 + +如果默认附魔池太大,建议开启分组: + +```yaml +enchant-gui: + grouped: true + group-by: type +``` + +`group-by` 可选: + +| 值 | 分组来源 | 使用场景 | +| --- | --- | --- | +| `type` | `types.yml` | 普通、法术、特殊、诅咒。最适合新手。 | +| `rarity` | `rarity.yml` | 按价值层级浏览。适合经济服。 | +| `target` | `targets.yml` | 按装备类型浏览。适合大附魔池。 | + +注意:`group-gui.groups[].id` 必须匹配对应来源文件里的 ID。比如 `group-by: rarity` 时,组 ID 应该是 `common`、`rare`、`legendary` 等,而不是 `normal`、`spell`。 + +## 管理员 GUI + +默认开启: + +```yaml +admin-gui: + enabled: true + tools: + reload: + enabled: true + random-book: + enabled: true +``` + +显示条件: + +- 玩家有 `ecoenchants.command.reload` 或 `ecoenchants.command.giverandombook`。 +- `enchant-gui.admin-tools.enabled: true`。 +- `admin-gui.enabled: true`。 + +工具: + +| 工具 | 权限 | 行为 | +| --- | --- | --- | +| reload | `ecoenchants.command.reload` | 调用插件 reload。 | +| random-book | `ecoenchants.command.giverandombook` | 给自己一本随机附魔书。 | + +生产服建议只给可信管理员这两个权限。 + +## 自动提示 + +默认: + +```yaml +player-experience: + auto-hints: + enabled: true + cooldown-seconds: 90 + once-per-player: true + on-first-join: true + on-browser-open: true + on-empty-results: true + on-filter-change: true +``` + +触发点: + +| 触发 | 说明 | +| --- | --- | +| 首次进服 | 提示 `/ecoenchants gui` 和 `/ecoenchants guide`。 | +| 首次打开浏览器 | 告诉玩家放物品会自动筛选。 | +| 空结果 | 根据原因给下一步建议。 | +| 筛选变化 | 告诉玩家当前筛选值。 | + +如果服务器已有教程系统,可以关闭: + +```yaml +player-experience: + auto-hints: + on-first-join: false + on-filter-change: false +``` + +如果玩家反馈提示太频繁,提高冷却: + +```yaml +player-experience: + auto-hints: + cooldown-seconds: 180 +``` + +## 指南命令 + +| 命令 | 用途 | +| --- | --- | +| `/ecoenchants guide` | 发送聊天版简短指南。 | +| `/ecoenchants guide book` | 给玩家一本写好的指南书。 | +| `/ecoenchants experience` | 给管理员查看提示设置和空结果统计。 | + +`/ecoenchants experience` 的空结果统计只统计当前运行会话,重载会清理部分内存状态,重启后不会保留长期趋势。 + +## 语言与文案 + +当前默认 `lang.yml` 已包含中英双语文案。服主可以按服务器风格改: + +- `gui.enchant.info` +- `gui.enchant.empty-results` +- `gui.enchant.filters` +- `gui.enchant.conflict-view` +- `gui.group` +- `gui.admin` +- `hints` +- `commands.guide` + +建议保持每行 lore 短句,不要写长段落。Minecraft 物品 lore 太长会降低可读性。 + +## 上线验收 + +- 普通玩家能打开 `/ecoenchants gui`。 +- 放入剑、镐、护甲时结果会变化。 +- 类型、稀有度、目标筛选能循环。 +- 没有结果时显示 barrier 空结果提示。 +- 冲突查看在没放物品时播放无效点击音效并提示。 +- 管理员能看到工具入口,普通玩家看不到。 +- `/ecoenchants guide book` 能给出指南书。 diff --git a/documentation/report/advanced-license-services.md b/documentation/report/advanced-license-services.md new file mode 100644 index 0000000000..a787e9a80d --- /dev/null +++ b/documentation/report/advanced-license-services.md @@ -0,0 +1,165 @@ +# advanced:授权与服务状态 + +本页教服主完成 advanced 分支的启动授权,并读懂 `/ecoenchants services` 输出。授权是 advanced 分支的第一道门:授权失败时,核心运行时不会继续启用。 + +## 授权启动流程 + +启动时插件会读取: + +```yaml +license: + key: "" + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + channel: stable + timeout-ms: 3000 + installation-id: "" + send-server-name: false + send-build-fingerprint: true +``` + +然后向后端发送 `POST /licenses/verify`。后端返回 `valid` 或 `trial` 才算通过。成功响应中如果包含 `activationToken` 和 `activationId`,后续远程运维和遥测远程上报才有凭据。 + +## 第一次部署 + +1. 在 `license.key` 填入授权 key。 +2. 保持 `api-url` 为后端给出的地址。默认地址已经包含 `/api/ecoenchants/v1`。 +3. 多台服务器共用同一授权体系时,为每个实例设置不同 `installation-id`;留空时插件会生成并保存本地安装 ID。 +4. 如果不希望后端看到服务器名,保持 `send-server-name: false`。 +5. 启动服务器后,用管理员账号执行 `/ecoenchants services`。 + +## URL 容错规则 + +插件会规范化后端地址: + +| 输入 | 实际用途 | +| --- | --- | +| `https://tts.chloemlla.com` | 补成 `https://tts.chloemlla.com/api/ecoenchants/v1` | +| `https://tts.chloemlla.com/api/ecoenchants` | 补成 versioned API | +| `https://tts.chloemlla.com/api/ecoenchants/v1` | 原样作为 versioned API | +| 重复粘贴的绝对 URL | 尝试折叠成最后一个有效绝对 URL | + +仍然建议直接填写完整默认格式,减少排查成本。 + +## `/ecoenchants services` 怎么看 + +这个命令会输出多个区块。第一段来自授权策略: + +| 行 | 含义 | 正常判断 | +| --- | --- | --- | +| `EcoEnchants license gate` | 授权门禁区块开始 | 只是标题。 | +| `Mode: required-online` | 当前为强制在线授权模式 | advanced 分支预期如此。 | +| `API URL` | 配置中填写的后端地址 | 应指向你的授权后端。 | +| `Contract URL` | 规范化后的契约根地址 | 应以 `/api/ecoenchants/v1` 结尾。 | +| `Product ID` | 固定 `ecoenchants` | 用于后端区分产品。 | +| `Channel` | `stable` 等发布频道 | 应与你购买或部署的频道一致。 | +| `Timeout` | 授权请求超时 | 代码会限制在 500-5000ms。 | +| `Send server name` | 是否发送服务器名 | 生产服通常保持 false。 | +| `Send build fingerprint` | 是否发送构建指纹 | 默认 true,便于后端判断构建。 | +| `Backend verbose logging` | 是否打印后端 API 通信追踪 | 排障时临时开启,生产默认关闭。 | +| `Backend payload logging` | 是否打印脱敏后的请求/响应内容 | 只在深度排障时开启。 | +| `Backend max payload chars` | payload 日志最大字符数 | 超出会截断。 | +| `Last check` | 最近授权结果 | 应显示 valid/trial 相关摘要。 | + +如果 `Last check` 显示失败,先排查 key、网络、后端地址和后端响应。 + +## 与远程运维的关系 + +远程运维不直接使用 `license.key` 建立长期连接,而是依赖授权响应中的 `activationToken`。因此可能出现: + +| 现象 | 原因 | 处理 | +| --- | --- | --- | +| 插件启动成功,但远程运维等待 token | 授权通过但后端未返回 `activationToken` | 修后端授权响应,或关闭 `remote-operations.enabled`。 | +| 遥测本地有日志,但不上报 | `require-activation-token: true` 且没有 token | 修后端响应,或关闭远程上报。 | +| `/ecoenchants services` 显示 remote enabled,但 Instance ID 是 `unregistered` | RPC 注册未成功 | 查看远程运维状态和后端 `/ops/instances/register`。 | + +## 推荐配置模板 + +只启用授权和本地功能: + +```yaml +license: + key: "替换为授权 key" + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + channel: stable + timeout-ms: 3000 + send-server-name: false + send-build-fingerprint: true + +remote-operations: + enabled: false + +runtime-telemetry: + remote-reporting: + enabled: false +``` + +启用后端状态联动,但不允许远程改文件: + +```yaml +license: + key: "替换为授权 key" + +remote-operations: + enabled: true + file-ops: + enabled: false + backups: + enabled: false +``` + +## 开启后端通信详细日志 + +新增的开发者追踪配置位于 `backend-api.logging`: + +```yaml +backend-api: + logging: + verbose: true + include-payloads: false + max-payload-chars: 2048 +``` + +开启 `verbose` 后,控制台会打印: + +- 授权校验请求:`license.verify` +- 远程运维注册:`ops.register` +- WebSocket 连接:`ops.websocket` +- RPC 请求与结果:`ops.rpc` +- 远程重连原因:`ops.reconnect` +- 遥测上报:`telemetry.events` +- 遥测队列调度和丢弃:`telemetry.queue` + +每条日志会尽量包含 `requestId`、HTTP 方法、URI、状态码、耗时、body 字节数、RPC `jobId`、RPC `method` 等信息,方便开发者从服务端日志反查后端请求。 + +`include-payloads` 默认应保持 false。只有需要核对后端请求/响应结构时才临时开启。开启后会打印经过脱敏和截断的 payload。 + +已知会脱敏: + +- `licenseKey` +- `activationToken` +- `sessionToken` +- `token` +- `secret` +- `password` +- `key-store-password` +- `X-Eco-Signature` +- `Authorization: Bearer ...` + +仍然建议不要长期打开 payload 日志,因为第三方后端响应中可能包含插件未知的敏感字段名。 + +## 常见失败 + +| 失败 | 判断 | 处理 | +| --- | --- | --- | +| `No license key is configured at license.key.` | 未填写 key | 填写 key 后重启。 | +| 授权请求超时 | 后端不可达或 `timeout-ms` 太短 | 检查防火墙、DNS、TLS,必要时调到 5000。 | +| HTTP 非 2xx | 后端拒绝或接口路径不对 | 检查 `api-url` 和 license 后端日志。 | +| 返回状态不是 `valid` / `trial` | 授权无效、过期或后端响应格式不符 | 修授权或后端响应。 | +| 插件启动后远程功能不可用 | 缺少 `activationToken` | 后端 `valid`/`trial` 响应需包含 token。 | + +## 服主验收 + +- `/ecoenchants services` 能显示授权最近检查成功。 +- `Contract URL` 与后端实际接口一致。 +- 如果开启远程运维,`Remote operations` 不应长期停在 `waiting for activation token from license verification`。 +- 如果只想本地运行,`remote-operations.enabled` 和 `runtime-telemetry.remote-reporting.enabled` 已按策略关闭。 diff --git a/documentation/report/advanced-remote-operations.md b/documentation/report/advanced-remote-operations.md new file mode 100644 index 0000000000..1dcf5700a6 --- /dev/null +++ b/documentation/report/advanced-remote-operations.md @@ -0,0 +1,222 @@ +# advanced:远程运维与备份 + +本页说明 advanced 分支的后端远程运维能力。它适合多实例集中维护,但也属于高权限能力:文件操作和备份恢复开启前,必须有明确的权限、审批和审计流程。 + +## 能做什么 + +当前源码支持的 RPC 方法分三层: + +| 层级 | 方法 | 默认可用条件 | +| --- | --- | --- | +| 诊断 | `ops.diagnostics.snapshot` | `remote-operations.enabled: true` 且注册成功 | +| 受控命令 | `ops.command.runManaged` | 同上 | +| 文件操作 | `ops.file.read`、`ops.file.write`、`ops.file.delete` | `remote-operations.file-ops.enabled: true` | +| 备份恢复 | `ops.backup.create`、`ops.backup.restore` | `remote-operations.backups.enabled: true` | + +受控命令不是任意控制台命令。当前只允许: + +| commandId | 行为 | +| --- | --- | +| `ecoenchants.reload` | 在服务器线程调用插件重载,并返回耗时与附魔数量。 | +| `ecoenchants.services.status` | 返回授权和远程运维状态行。 | + +## 连接流程 + +1. 授权通过,后端返回 `activationToken`。 +2. 插件向 `POST /ops/instances/register` 注册实例。 +3. 后端返回 `instanceId`、`sessionToken`、`rpcUrl`、`policyVersion`。 +4. 插件连接 WebSocket,默认路径为 `/api/ecoenchants/v1/rpc/connect`。 +5. 插件发送 `rpc.hello`,列出当前支持的方法。 +6. 后端通过 RPC 下发任务,插件回 `rpc.ack` 和最终 `rpc.result`。 + +## 安全开关 + +推荐保持: + +```yaml +remote-operations: + enabled: true + security: + require-secure-transport: true + hmac: + enabled: true + require-signed-rpc: true + max-clock-skew-seconds: 300 + mtls: + enabled: false +``` + +含义: + +| 配置 | 说明 | +| --- | --- | +| `require-secure-transport` | 拒绝普通 `http` / `ws`,要求 HTTPS/WSS。 | +| `hmac.enabled` | 给注册、握手和 RPC 做签名。 | +| `hmac.require-signed-rpc` | RPC 消息必须带签名。 | +| `hmac.key-id` | 后端需要区分签名密钥时填写。 | +| `hmac.secret` | 留空时使用 activation/session token 作为共享密钥。 | +| `mtls.enabled` | 企业或高权限环境可启用客户端证书。 | + +## 三种启用档位 + +### 只允许状态与 reload + +```yaml +remote-operations: + enabled: true + file-ops: + enabled: false + backups: + enabled: false +``` + +适合先上线验证。后端只能拿诊断快照、查询状态、触发 EcoEnchants reload。 + +### 允许备份,不允许改文件 + +```yaml +remote-operations: + enabled: true + file-ops: + enabled: false + backups: + enabled: true + max-total-size-mb: 256 +``` + +适合需要远程触发灾备归档,但不希望控制台读写服务器文件的服群。 + +### 允许文件维护 + +```yaml +remote-operations: + enabled: true + file-ops: + enabled: true + server-root: "" + max-read-bytes: 1048576 + max-write-bytes: 10485760 + allow-permanent-delete: false + backups: + enabled: true +``` + +只有后端 RBAC、审批、审计、备份恢复演练都完成后再用。`allow-permanent-delete` 建议长期保持 false。 + +## 文件操作边界 + +远程文件能力支持受控 mount: + +| mount | 说明 | +| --- | --- | +| `server-root` | Minecraft 服务器根目录,默认从 `plugins/EcoEnchants` 推断。 | +| `plugin-data` | EcoEnchants 插件数据目录。 | +| `config` | 配置维护目录。 | +| `logs` | 日志读取目录。 | +| `backups` | 备份归档目录。 | + +路径必须是相对路径。插件会拒绝绝对路径、`..`、NUL、控制字符、Windows 盘符、UNC 路径和符号链接逃逸。 + +## 文件读取 + +读取会返回 base64 内容,并带上文件大小、offset、limit、sha256 和是否截断。 + +服主侧关注: + +- 默认单次最多读 `max-read-bytes`。 +- 大文件需要分页读。 +- 带 `redactionPolicy` 时会做简单脱敏。 +- 每次读取都会写入远程运维审计日志。 + +适合读取: + +- `logs/latest.log` 的片段。 +- `plugins/EcoEnchants/config.yml`。 +- 单个附魔配置文件。 + +不适合读取: + +- 世界文件。 +- 大型数据库。 +- 含大量玩家隐私的完整日志。 + +## 文件写入 + +写入要求: + +- 请求必须带 `contentBase64`。 +- 请求必须带 `contentSha256`。 +- 插件会校验内容 hash,不匹配直接拒绝。 +- 单文件不能超过 `max-write-bytes`。 +- 写入先落临时文件,再原子替换。 +- `mode` 只支持 `create` 和 `overwrite`。 + +插件会拒绝写入高风险文件类型和启动脚本,例如 `.jar`、`.class`、`.exe`、`.dll`、`.so`、脚本文件、启动参数文件等。远程维护应该只用于配置和文本文件。 + +## 删除策略 + +默认删除模式应使用 `quarantine`。文件会移动到隔离目录,而不是直接消失。 + +永久删除只有在同时满足下面条件时才可能执行: + +- RPC 请求使用 `mode: permanent`。 +- `remote-operations.file-ops.allow-permanent-delete: true`。 +- 目标不是受保护目录。 + +插件会保护 `plugins`、`world`、`world_nether`、`world_the_end`、`backups` 等顶层目录,避免远程误删核心数据。 + +## 备份与恢复 + +备份归档写入: + +```text +plugins/EcoEnchants/backups +``` + +恢复相关临时目录: + +```text +plugins/EcoEnchants/ops-restore-staging +``` + +建议流程: + +1. 开启 `remote-operations.backups.enabled`。 +2. 先从后端触发小范围备份,例如 `plugin-data/config.yml`。 +3. 下载或读取备份 manifest,确认 sha256、路径和条目数。 +4. 在测试服验证 restore。 +5. 生产服恢复前,先创建恢复前备份。 +6. 恢复后执行受控 `ecoenchants.reload` 或安排重启。 + +## 审计日志 + +远程运维审计默认开启: + +```yaml +remote-operations: + audit-log: + enabled: true + file: security-audit.log +``` + +审计会记录 RPC 请求、文件读写删、备份创建和恢复。服主应把该文件纳入运维留存,并限制普通玩家和低权限管理员读取。 + +## 后端需要实现的接口 + +最低需要: + +- `POST /api/ecoenchants/v1/licenses/verify` +- `POST /api/ecoenchants/v1/ops/instances/register` +- `GET /api/ecoenchants/v1/rpc/connect` WebSocket + +如果启用遥测,还需要: + +- `POST /api/ecoenchants/v1/telemetry/events` + +## 上线建议 + +- 第一阶段只开 `remote-operations.enabled`,关闭 file ops 和 backups。 +- 第二阶段开 backups,只做备份,不做恢复。 +- 第三阶段在测试服演练 restore。 +- 第四阶段才考虑打开 file ops。 +- 永久删除保持关闭,除非后端有双人审批和可追溯工单。 diff --git a/documentation/report/advanced-telemetry-probe.md b/documentation/report/advanced-telemetry-probe.md new file mode 100644 index 0000000000..3f70429ef8 --- /dev/null +++ b/documentation/report/advanced-telemetry-probe.md @@ -0,0 +1,227 @@ +# advanced:遥测与环境探针 + +advanced 分支新增运行时遥测和环境探针。它们用于审计、风控和排障,但也涉及玩家行为数据,服主必须根据服务器隐私政策决定开启范围。 + +## 两条数据路径 + +| 路径 | 配置 | 说明 | +| --- | --- | --- | +| 本地审计 | `runtime-telemetry.audit-log` | 写入服务器本地 JSONL 文件。 | +| 远程上报 | `runtime-telemetry.remote-reporting` | 批量 POST 到后端 `/telemetry/events`。 | + +如果你只想本地留痕: + +```yaml +runtime-telemetry: + enabled: true + audit-log: + enabled: true + remote-reporting: + enabled: false +``` + +如果你连本地遥测也不想要: + +```yaml +runtime-telemetry: + enabled: false +``` + +## 本地审计日志 + +默认: + +```yaml +runtime-telemetry: + audit-log: + enabled: true + file: telemetry/audit.jsonl + max-file-size-mb: 10 +``` + +日志是 JSONL,每行一个事件。达到大小上限后会按实现策略轮转或限制写入。建议把该目录从公开下载、网页日志和低权限面板中排除。 + +## 远程上报 + +默认: + +```yaml +runtime-telemetry: + remote-reporting: + enabled: true + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + endpoint: "/telemetry/events" + interval-ticks: 1200 + batch-size: 100 + max-queued-events: 5000 + timeout-ms: 3000 + require-activation-token: true +``` + +工作方式: + +1. 事件先进入本地统一结构。 +2. 本地审计按配置写入。 +3. 如果远程上报开启且有 activation token,事件进入队列。 +4. 队列有内容时才启动周期上报。 +5. `2xx` 响应视为成功。 +6. 非 `2xx`、超时或网络失败会把批次放回队列等待重试。 +7. 超过 `max-queued-events` 会丢弃超限事件,并在 `/ecoenchants services` 显示 dropped 计数。 + +## 事件类别 + +| 类别 | 记录内容 | +| --- | --- | +| `telemetry_lifecycle` | 插件遥测启动、重载、停止。 | +| `environment_probe` | JVM 参数、Java agent、环境变量和系统属性探针结果。 | +| `identity_anchor` | 玩家 UUID、名称、online-mode、网络路由哈希。 | +| `client_context` | 协议版本、客户端品牌、语言、视距、ping。 | +| `session_end` | 玩家离线和会话结束。 | +| `trajectory_sample` | 可选移动采样,默认 `log-samples: false`。 | +| `trajectory_anomaly` | 超距离或超速度移动异常。 | +| `trajectory_transition` | 传送、跨世界等空间切换。 | +| `state_transition` | 飞行状态等玩家状态变化。 | +| `state_baseline` | 背包状态基线 hash。 | +| `state_delta` | 背包状态 hash 变化。 | +| `economy_delta` | 经验、等级、附魔消耗等变化。 | +| `behavioral_text` | 聊天/命令文本风险元数据。 | + +## 隐私边界 + +默认不会写入或上传: + +- 明文玩家 IP。 +- 完整聊天文本。 +- 完整背包内容。 + +默认会记录: + +- 玩家 UUID 与名称。 +- 网络地址、hostname、virtual host 的 hash。 +- 坐标、世界 UUID、世界名 hash。 +- 背包整体 hash 和按材料汇总的数量。 +- 聊天/命令文本长度、hash、命中风险词。 + +会扩大数据面的配置: + +```yaml +runtime-telemetry: + privacy: + include-raw-network-addresses: true + text: + capture-raw: true +``` + +除非你已经在规则、隐私政策和管理制度里明确说明,否则不要开启。 + +## 常用配置方案 + +### 保守生产服 + +```yaml +runtime-telemetry: + enabled: true + remote-reporting: + enabled: false + privacy: + include-raw-network-addresses: false + text: + capture-raw: false + log-all-metadata: false +``` + +### 风控服群 + +```yaml +runtime-telemetry: + enabled: true + remote-reporting: + enabled: true + require-activation-token: true + movement: + enabled: true + log-samples: false + text: + enabled: true + log-command-root: true + log-matched-terms: true +``` + +### 排查移动异常 + +```yaml +runtime-telemetry: + movement: + enabled: true + sample-interval-ms: 1000 + max-distance-per-sample: 24.0 + max-blocks-per-second: 30.0 + log-samples: false +``` + +不要长期打开 `log-samples: true`,否则事件量会明显增加。 + +## 环境探针 + +默认: + +```yaml +runtime-telemetry: + environment-probe: + enabled: true + interval-ticks: 1200 + redline-action: disable-plugin + denied-jvm-args: + - "-agentlib:jdwp" + - "-Xdebug" + block-java-agents: false + denied-env-vars: [] + denied-system-properties: [] +``` + +它会检查: + +- JVM 启动参数。 +- Java agent。 +- 指定环境变量。 +- 指定系统属性。 + +`redline-action` 可选: + +| 值 | 行为 | +| --- | --- | +| `disable-plugin` | 命中红线时禁用 EcoEnchants。生产服默认推荐。 | +| `log-only` | 只写日志和审计,不禁用。测试服、调试服推荐。 | + +## `/ecoenchants services` 中怎么看遥测 + +会出现 `Runtime telemetry` 和 `Telemetry remote reporting` 两段: + +| 行 | 含义 | +| --- | --- | +| `Enabled` | 总开关。 | +| `Audit log enabled` | 是否写本地 JSONL。 | +| `Remote reporting enabled` | 是否远程上报。 | +| `Remote reporting URL` | 实际上报地址。 | +| `Queued events` | 等待发送事件数。 | +| `Dropped events` | 队列溢出被丢弃事件数。 | +| `Last result` | 最近一次发送结果。 | +| `Environment redline action` | 环境探针命中后的动作。 | + +## 后端接收建议 + +后端应: + +- 按 `productId + installationId + eventId` 去重。 +- 对重复事件返回 `2xx`。 +- 支持默认批量 100 条。 +- 对 raw IP、raw text 设置更短保留周期。 +- 不要用 `4xx` 拒绝单条坏事件导致插件整批重试。 + +## 服主验收 + +- 本地 `plugins/EcoEnchants/telemetry/audit.jsonl` 能看到生命周期事件。 +- `/ecoenchants services` 中 queued 不长期增长。 +- dropped 长期为 0。 +- 隐私扩大项保持关闭,除非已经完成告知。 +- 调试服已把 `redline-action` 改成 `log-only`。 diff --git a/documentation/report/advanced-troubleshooting.md b/documentation/report/advanced-troubleshooting.md new file mode 100644 index 0000000000..d649899a78 --- /dev/null +++ b/documentation/report/advanced-troubleshooting.md @@ -0,0 +1,139 @@ +# advanced:排障与上线清单 + +本页把 advanced 分支常见问题按症状整理。排障时先执行 `/ecoenchants services`,再按对应区块处理。 + +## 上线前总清单 + +| 项目 | 必须确认 | +| --- | --- | +| 授权 | `license.key` 已填写,后端能返回 `valid` 或 `trial`。 | +| 后端地址 | `Contract URL` 指向正确 `/api/ecoenchants/v1`。 | +| 远程运维 | 不需要就关闭 `remote-operations.enabled`。 | +| 文件操作 | 默认保持 `file-ops.enabled: false`。 | +| 备份 | 开启前先在测试服验证创建和恢复。 | +| 遥测 | 明确本地审计和远程上报是否符合隐私政策。 | +| 环境探针 | 调试服使用 `log-only`,生产服可保持 `disable-plugin`。 | +| GUI | 检查按钮槽位没有重叠。 | +| 权限 | 普通玩家只给 GUI、guide、enchantinfo、toggledescriptions。 | +| 回滚 | 修改 advanced 后端配置前保留 `config.yml` 备份。 | + +## 开发者追踪日志 + +排查后端通信问题时,先临时开启: + +```yaml +backend-api: + logging: + verbose: true + include-payloads: false + max-payload-chars: 2048 +``` + +如果只靠状态码和 requestId 还不够,再短时间开启: + +```yaml +backend-api: + logging: + include-payloads: true +``` + +日志格式大致如下: + +```text +[EcoEnchants API] -> license.verify requestId=... method=POST uri=https://... bodyBytes=... +[EcoEnchants API] <- license.verify requestId=... status=200 durationMs=... bodyBytes=... +[EcoEnchants API] ** ops.reconnect attempt=1 delaySeconds=5 reason=register failed +``` + +把 `requestId`、`jobId`、`method`、状态码和时间戳交给后端开发者,可以快速定位同一条请求在服务端、代理和后端之间的流转。 + +排障结束后关闭: + +```yaml +backend-api: + logging: + verbose: false + include-payloads: false +``` + +## 插件启动失败 + +| 现象 | 可能原因 | 处理 | +| --- | --- | --- | +| 日志提示没有 license key | `license.key` 为空 | 填写 key 后重启。 | +| 授权请求超时 | 后端不可达、DNS/TLS、防火墙、超时太短 | 检查网络,`timeout-ms` 调到 5000。 | +| 授权返回 invalid | key 错误、过期或后端绑定不匹配 | 检查后端授权记录。 | +| 命中环境红线后禁用 | `environment-probe.redline-action: disable-plugin` | 调试服改 `log-only`,生产服移除调试 JVM 参数。 | +| registry / proxy 相关错误 | 服务端版本或 NMS 代理不匹配 | 换支持版本或等待对应代理模块。 | + +## 远程运维不连接 + +看 `/ecoenchants services` 的 `Remote operations` 区块。 + +| 状态 | 含义 | 处理 | +| --- | --- | --- | +| `disabled by config` | 配置关闭 | 需要远程运维时改 `remote-operations.enabled: true` 并重载/重启。 | +| `waiting for activation token from license verification` | 授权响应没有 token | 后端 `licenses/verify` 成功响应需要返回 `activationToken`。 | +| `register failed: HTTP ...` | `/ops/instances/register` 失败 | 查后端注册接口、token 权限、实例额度。 | +| `websocket failed` | RPC URL、TLS、HMAC 或网络失败 | 查 `rpcUrl`、证书、WSS、防火墙。 | +| `reconnecting in ...` | 退避重连中 | 看括号里的原因,不要反复重启。 | +| `Instance ID: unregistered` | 尚未完成注册 | 先解决注册或 token 问题。 | + +## 远程文件操作被拒绝 + +| 错误码 | 含义 | 处理 | +| --- | --- | --- | +| `file_ops_disabled` | 文件操作未启用 | 确认是否真的要开 `remote-operations.file-ops.enabled`。 | +| `missing_mount` / `missing_path` | 后端请求缺字段 | 修后端 RPC 参数。 | +| `path_outside_allowed_root` | 路径越界 | 使用相对路径,不要 `..`、绝对路径、跨盘符。 | +| `not_a_regular_file` | 读取目标不是普通文件 | 不要读目录或特殊文件。 | +| `write_limit_exceeded` | 超过写入大小限制 | 提高 `max-write-bytes` 或拆分。 | +| `sha256_mismatch` | 内容 hash 不匹配 | 后端重算 `contentSha256`。 | +| `file_type_blocked` | 文件类型被禁止写入 | 不要远程写 jar、脚本、启动参数等高风险文件。 | +| `unsupported_delete_mode` | 删除模式不允许 | 默认用 `quarantine`;永久删除需显式开启。 | + +## 备份或恢复失败 + +| 错误码 | 含义 | 处理 | +| --- | --- | --- | +| `backups_disabled` | 备份恢复未启用 | 开启 `remote-operations.backups.enabled`。 | +| `backup_limit_exceeded` | 超过备份大小上限 | 调高 `max-total-size-mb` 或缩小 scope。 | +| `backup_not_found` | 找不到归档 | 检查 backupId 和 backups 目录。 | +| `backup_integrity_failed` | manifest 或 zip 条目不可信 | 不要恢复该归档,重新创建备份。 | + +恢复生产配置前建议: + +1. 先在测试服恢复同一个归档。 +2. 生产服先创建恢复前备份。 +3. 只恢复必要路径。 +4. 恢复后执行受控 reload 或安排重启。 + +## 遥测队列增长 + +| 现象 | 原因 | 处理 | +| --- | --- | --- | +| `Queued events` 持续增长 | 后端不可达或非 2xx | 查 `/telemetry/events`。 | +| `Dropped events` 增长 | 队列超过 `max-queued-events` | 修后端,临时增大队列或关闭远程上报。 | +| `Last result` 是 401/403 | token 无效或后端鉴权失败 | 检查 activation token 验证。 | +| 没有远程上报 | `require-activation-token` 且无 token | 修授权响应或关闭该要求。 | +| 事件量过大 | 开了 movement samples 或 raw text | 关闭 `movement.log-samples`,减少文本采集。 | + +## 玩家 GUI 问题 + +| 现象 | 可能原因 | 处理 | +| --- | --- | --- | +| 普通玩家打不开 GUI | 缺 `ecoenchants.command.gui` | 给普通组权限。 | +| 放入物品无结果 | 物品没有可用目标、已有冲突、兼容过滤开启 | 关闭仅兼容、查看冲突、检查 `targets.yml`。 | +| 分组页没有某类 | `group-gui.groups[].id` 和 `group-by` 不匹配 | 按 `type`、`rarity` 或 `target` 的真实 ID 修改。 | +| 管理员工具不显示 | 缺 reload/random-book 权限或配置关闭 | 检查权限和 `admin-gui.enabled`。 | +| 按钮重叠 | 多个控件同 row/column | 调整 `config.yml` 槽位。 | +| 提示太吵 | 冷却太短或筛选提示开启 | 提高 `cooldown-seconds` 或关闭 `on-filter-change`。 | + +## 推荐的排障顺序 + +1. 先看服务器启动日志,确认授权和环境探针结果。 +2. 执行 `/ecoenchants services`,记录 license、remote、telemetry、probe 状态。 +3. 如果是玩家体验问题,执行 `/ecoenchants experience`。 +4. 对配置做最小改动,不要同时改授权、远程运维和遥测。 +5. 每次改完先 `/ecoenchants reload`,涉及启动授权、注册表、NMS 或显示总开关时安排重启。 +6. 问题解决后恢复最小权限:关闭不需要的 file ops、raw text、raw IP 和 permanent delete。 diff --git a/documentation/report/chloemlla-advanced.md b/documentation/report/chloemlla-advanced.md new file mode 100644 index 0000000000..16ed5dd93e --- /dev/null +++ b/documentation/report/chloemlla-advanced.md @@ -0,0 +1,114 @@ +# Chloemlla advanced 新功能总览 + +本章是 Chloemlla `advanced` 分支的功能入口。它先解释新增能力的定位,再把具体使用步骤拆到独立页面,避免把授权、远程运维、遥测和 GUI 说明塞进一个超长文件。 + +## 新增能力总览 + +`advanced` 分支新增能力主要分为 7 类: + +| 类别 | 功能 | 服主价值 | +| --- | --- | --- | +| 商业授权 | 启动时在线 license 验证 | 统一控制商业构建授权,阻止未授权运行。 | +| 后端接入 | `/api/ecoenchants/v1` URL 规范化、状态查询、activation token | 为授权、遥测和远程运维提供统一后端入口。 | +| 开发者溯源 | 可配置后端 API 通信追踪、requestId、耗时、状态码、脱敏 payload | 帮助开发者从插件日志反查后端请求链路。 | +| 远程运维 | WebSocket RPC、HMAC、mTLS、审计、文件操作、备份、回滚 | 支持集中维护多实例,但需要严格审批和审计。 | +| 运行遥测 | 本地 JSONL 审计、远程批量上报、身份/移动/状态/文本风险事件 | 帮助服主分析异常行为和运行风险。 | +| 玩家体验 | 双语提示、指南命令、指南书、空结果提示、经验统计 | 降低玩家学习成本,减少“GUI 坏了”的误解。 | +| GUI 管理 | 筛选按钮、兼容过滤、冲突查看、分组、管理员工具 | 让玩家更快找到可用附魔,让管理员更快测试配置。 | + +## 先读哪一页 + +| 你要做什么 | 阅读 | +| --- | --- | +| 让插件通过商业授权并启动 | [授权与服务状态](./advanced-license-services) | +| 查看 `/ecoenchants services` 每行是什么意思 | [授权与服务状态](./advanced-license-services) | +| 接入后端控制台、RPC、文件维护、备份回滚 | [远程运维与备份](./advanced-remote-operations) | +| 只想本地审计,不想上报数据 | [遥测与环境探针](./advanced-telemetry-probe) | +| 向玩家开放新版 GUI 和自动提示 | [GUI 与玩家体验](./advanced-gui-experience) | +| 上线前检查或遇到启动/连接失败 | [排障与上线清单](./advanced-troubleshooting) | + +## 默认状态 + +advanced 分支默认更偏“商业版 + 后端可接入”的形态: + +| 配置 | 默认 | 影响 | +| --- | --- | --- | +| `license.key` | 空 | 不填 key 时授权校验失败,核心运行时不会启用。 | +| `license.api-url` | `https://tts.chloemlla.com/api/ecoenchants/v1` | 授权、远程运维和遥测默认后端。 | +| `backend-api.logging.verbose` | false | 默认不打印后端 API 通信追踪,排障时临时开启。 | +| `backend-api.logging.include-payloads` | false | 默认不打印 payload,深度排障时才短期开启。 | +| `remote-operations.enabled` | true | 授权成功且后端返回 token 后尝试注册远程运维实例。 | +| `remote-operations.file-ops.enabled` | false | 默认不允许远程读写删文件。 | +| `remote-operations.backups.enabled` | false | 默认不允许远程备份/恢复。 | +| `runtime-telemetry.enabled` | true | 本地记录运行审计事件。 | +| `runtime-telemetry.remote-reporting.enabled` | true | 有 activation token 时尝试远程批量上报。 | +| `runtime-telemetry.environment-probe.redline-action` | `disable-plugin` | 命中红线时禁用插件。 | +| `player-experience.auto-hints.enabled` | true | 向玩家发送低频引导提示。 | +| `enchant-gui.filters.*.enabled` | true | GUI 启用类型、稀有度、目标和兼容筛选。 | +| `admin-gui.enabled` | true | 有权限的管理员可从 GUI 打开工具页。 | + +## 最小可用配置 + +如果你只想让插件启动并暂时不接入高风险运维能力,可以使用这个思路: + +```yaml +license: + key: "你的授权 key" + +backend-api: + logging: + verbose: false + include-payloads: false + +remote-operations: + enabled: false + +runtime-telemetry: + enabled: true + remote-reporting: + enabled: false +``` + +这样做的结果是: + +- 插件仍执行启动授权。 +- 不建立远程运维 WebSocket。 +- 本地保留遥测审计日志。 +- 不向后端发送遥测事件。 +- 玩家 GUI、提示、指南和管理员工具仍可使用。 + +## 集中运维配置 + +```yaml +license: + key: "你的授权 key" + +remote-operations: + enabled: true + security: + require-secure-transport: true + hmac: + enabled: true + require-signed-rpc: true + file-ops: + enabled: false + backups: + enabled: true +``` + +此配置允许后端注册实例、建立 RPC、触发备份和恢复。文件读写删仍然关闭,适合先验证后端控制台、状态查询、诊断快照和受控 reload。 + +## 必须告知管理团队的风险 + +- 授权后端不可用会影响启动,应准备维护窗口和沟通方案。 +- 远程文件操作一旦启用,必须按高风险权限管理。 +- 运行时遥测可能涉及玩家行为数据,应配合服务器隐私政策。 +- 环境探针默认可能禁用插件,调试服要改成 `log-only`。 +- GUI 管理员工具只应给可信管理员,因为可触发重载和随机书生成。 + +## 下一步 + +- 先完成 [授权与服务状态](./advanced-license-services),确保插件能启动。 +- 再按需要启用 [远程运维与备份](./advanced-remote-operations)。 +- 同步确认 [遥测与环境探针](./advanced-telemetry-probe) 是否符合你的隐私公告。 +- 面向玩家开放前阅读 [GUI 与玩家体验](./advanced-gui-experience)。 diff --git a/documentation/report/commands-gui.md b/documentation/report/commands-gui.md new file mode 100644 index 0000000000..44e43bc0bb --- /dev/null +++ b/documentation/report/commands-gui.md @@ -0,0 +1,88 @@ +# 命令、权限与 GUI + +本章整理服主、管理员和普通玩家常用入口。权限来自 `plugin.yml` 和命令源码。 + +## 命令总表 + +| 命令 | 用途 | 权限 | 默认 | +| --- | --- | --- | --- | +| `/ecoenchants` | 显示权限可见的帮助列表 | `ecoenchants.command.ecoenchants` | true | +| `/ecoenchants help` | 显示帮助列表 | `ecoenchants.command.ecoenchants` | true | +| `/ecoenchants gui` | 打开附魔浏览 GUI | `ecoenchants.command.gui` | true | +| `/ecoenchants guide` | 查看聊天版玩家指南 | `ecoenchants.command.guide` | true | +| `/ecoenchants guide book` | 给玩家一本指南书 | `ecoenchants.command.guide` | true | +| `/ecoenchants toggledescriptions` | 玩家切换物品 lore 中的附魔描述 | `ecoenchants.command.toggledescriptions` | true | +| `/enchantinfo <name> [level]` | 打开指定附魔的信息 GUI | `ecoenchants.command.enchantinfo` | true | +| `/enchant <id> [level]` | 管理员给自己手持物品添加或移除附魔,等级 `0` 为移除 | `ecoenchants.command.enchant` | op | +| `/enchant <player> <id> [level]` | 控制台或管理员给目标玩家手持物品操作附魔 | `ecoenchants.command.enchant` | op | +| `/ecoenchants giverandombook <player> [type/rarity] [min] [max]` | 给玩家随机附魔书,可按类型或稀有度过滤 | `ecoenchants.command.giverandombook` | op | +| `/ecoenchants reload` | 重载配置 | `ecoenchants.command.reload` | op | +| `/ecoenchants services` | 查看授权、后端、远程运维、遥测和环境探针状态 | `ecoenchants.command.services` | op | +| `/ecoenchants experience` | 查看玩家提示设置和空结果统计 | `ecoenchants.command.experience` | op | + +## 权限组建议 + +普通玩家建议开放: + +```text +ecoenchants.command.ecoenchants +ecoenchants.command.gui +ecoenchants.command.guide +ecoenchants.command.toggledescriptions +ecoenchants.command.enchantinfo +ecoenchants.fromtable.* +``` + +管理员建议开放: + +```text +ecoenchants.command.* +ecoenchants.anvil.color +``` + +如果你希望某些玩家不能从附魔台获得指定附魔,可以按单个附魔控制: + +```text +ecoenchants.fromtable.<enchant_id> +``` + +例如不给某组 `ecoenchants.fromtable.lifesteal`,他们就不会从附魔台自然获得 `lifesteal`。这不一定阻止战利品、村民或管理员命令来源,其他来源要通过附魔文件和全局配置控制。 + +## 附魔浏览 GUI + +`/ecoenchants gui` 是玩家理解插件的主入口。当前分支的 GUI 支持: + +- 顶部中间槽放入物品后自动筛选兼容附魔。 +- 类型筛选,按 `types.yml` 循环。 +- 稀有度筛选,按 `rarity.yml` 循环。 +- 目标筛选,按 `targets.yml` 循环。 +- 仅兼容当前物品开关。 +- 冲突查看按钮,提示当前物品已有附魔会阻止哪些附魔。 +- 分页按钮和页数显示。 +- 空结果提示,会区分“无附魔加载”“分组为空”“物品无可用附魔”“筛选无结果”等情况。 +- 可选分组 GUI,按类型、稀有度或目标先进入分组,再浏览附魔。 +- 管理员工具入口,具备权限的玩家可以在 GUI 中快速重载或给自己随机书。 + +## GUI 配置位置 + +主要配置位于: + +| 配置段 | 用途 | +| --- | --- | +| `enchantinfo` | `/enchantinfo` 信息 GUI 的行数、背景、展示物品、lore key。 | +| `enchant-gui` | 主附魔浏览器的行数、标题、槽位、分页、筛选、冲突查看、兼容过滤、管理员入口。 | +| `group-gui` | 分组浏览器,只有 `enchant-gui.grouped: true` 时使用。 | +| `admin-gui` | 管理员工具 GUI,包含重载和随机书按钮。 | +| `lang.yml -> gui` | GUI 按钮名称、lore、提示和双语文本。 | + +如果要改按钮位置,优先调整 `row` 和 `column`。保持一个格子只放一个按钮,尤其要避开 `item-row` / `item-column` 的放入物品槽。 + +## 玩家引导建议 + +服主可以把 `/ecoenchants gui` 放入菜单、出生点 NPC 或教程书。新玩家最需要知道三件事: + +1. 可以不放物品直接浏览全部附魔。 +2. 放入装备后会只显示可添加附魔。 +3. 用 `/enchantinfo <name> [level]` 查看最大等级、目标、冲突和来源。 + +`advanced` 分支已经内置自动提示,默认会在首次进服、首次打开浏览器、切换筛选和空结果时发送短提示。若服务器聊天较繁忙,可以提高 `player-experience.auto-hints.cooldown-seconds` 或关闭部分触发点。 diff --git a/documentation/report/configuration.md b/documentation/report/configuration.md new file mode 100644 index 0000000000..ca47b14d97 --- /dev/null +++ b/documentation/report/configuration.md @@ -0,0 +1,178 @@ +# 配置运营手册 + +本章按服主常见目标整理 `config.yml` 的配置方法。完整默认配置可参考原文档中的 `Plugin Config`,这里重点解释上线运营时该怎么取舍。 + +## 授权与后端 + +| 配置段 | 默认状态 | 建议 | +| --- | --- | --- | +| `license` | 启用,必须通过后端验证 | 生产服必须填写 key,并确认后端地址稳定。 | +| `backend-api.logging` | 关闭 | 仅排查授权、RPC、遥测通信时临时开启。 | +| `remote-operations.enabled` | true | 如果后端未部署或不使用远程运维,建议改为 false。 | +| `remote-operations.file-ops.enabled` | false | 保持 false,只有需要后端维护文件时启用。 | +| `remote-operations.backups.enabled` | false | 保持 false,除非要用后端触发备份/回滚。 | +| `runtime-telemetry.enabled` | true | 根据隐私政策决定是否开启。 | +| `runtime-telemetry.remote-reporting.enabled` | true | 若只想本地审计,改为 false。 | + +最低风险的后端配置: + +```yaml +backend-api: + logging: + verbose: false + include-payloads: false + +remote-operations: + enabled: false + +runtime-telemetry: + enabled: true + remote-reporting: + enabled: false +``` + +这样保留本地审计和运行观察能力,但不建立远程运维连接,也不上报遥测。 + +## 附魔获取渠道 + +### 偏原版生存 + +```yaml +enchanting-table: + enabled: true + book-multiplier: 0.5 + cap: 5 + reduction: 2.2 + +villager: + enabled: true + pass-through-chance: 25 + +loot: + enabled: true +``` + +适合普通生存服。玩家能从常规玩法获取附魔,但高稀有度仍受稀有度表控制。 + +### 偏硬核经济 + +```yaml +enchanting-table: + cap: 3 + reduction: 3.0 + +villager: + pass-through-chance: 45 + book-multiplier: 0.08 + +loot: + reduction: 9.0 +``` + +适合担心附魔泛滥的服务器。降低村民和连带附魔产出,保留稀有战利品价值。 + +### 偏活动赛季 + +```yaml +enchanting-table: + cap: 6 + reduction: 1.8 + +anvil: + max-repair-cost: 60 + clamp-repair-cost: true +``` + +适合短赛季、RPG 或活动服,装备成型更快,但要注意 PvP 和 Boss 平衡。 + +## 铁砧成本 + +想减少玩家抱怨“太贵”,优先调: + +```yaml +anvil: + cost-exponent: 0.85 + max-repair-cost: 60 + clamp-repair-cost: true +``` + +想限制毕业装备,优先调: + +```yaml +anvil: + enchant-limit: 8 + clamp-repair-cost: false +``` + +`clamp-repair-cost: false` 时,超过 `max-repair-cost` 的结果会被阻止,更适合竞技服。 + +## 物品 lore 展示 + +大附魔池推荐: + +```yaml +display: + collapse: + enabled: true + threshold: 9 + per-line: 2 + descriptions: + enabled: true + threshold: 5 + word-wrap: 27 +``` + +这样少量附魔时能看描述,多附魔毕业装备则折叠显示,避免物品说明过长。 + +如果你有其他插件接管物品展示,才考虑关闭: + +```yaml +display: + enabled: false +``` + +此项需要重启,不适合热改。 + +## GUI 与提示 + +要让玩家更容易上手,保持这些功能开启: + +```yaml +player-experience: + auto-hints: + enabled: true + on-first-join: true + on-browser-open: true + on-empty-results: true + on-filter-change: true +``` + +如果聊天太吵: + +```yaml +player-experience: + auto-hints: + cooldown-seconds: 180 + on-filter-change: false +``` + +如果玩家更喜欢分类浏览: + +```yaml +enchant-gui: + grouped: true + group-by: type +``` + +`group-by` 可选 `type`、`rarity`、`target`。修改后要检查 `group-gui.groups` 的 `id` 是否来自对应文件。 + +## 变更发布流程 + +推荐生产服配置变更流程: + +1. 在测试服修改配置。 +2. 用 `/ecoenchants reload` 验证常规变更。 +3. 用 `/ecoenchants gui` 检查浏览、筛选、空结果、冲突查看。 +4. 用 `/enchantinfo` 检查关键附魔的目标、冲突和来源。 +5. 如果新增附魔或改注册相关内容,安排重启窗口。 +6. 上线后用 `/ecoenchants services` 和 `/ecoenchants experience` 查看运行状态。 diff --git a/documentation/report/deployment-runtime.md b/documentation/report/deployment-runtime.md new file mode 100644 index 0000000000..6443445422 --- /dev/null +++ b/documentation/report/deployment-runtime.md @@ -0,0 +1,83 @@ +# 部署与运行边界 + +本章用于服主上线前确认 EcoEnchants 的硬性依赖、启动流程和运维边界。 + +## 插件依赖 + +`plugin.yml` 中声明的运行关系如下: + +| 类型 | 插件 | 说明 | +| --- | --- | --- | +| 必需依赖 | `eco` | EcoEnchants 基础运行库,缺失时插件不能正常启动。 | +| 软依赖 | `libreforge` | 当前构建会嵌入 libreforge shadow jar,同时仍声明软依赖以兼容生态。 | +| 软依赖 | `CMI` | 用于附魔注册和显示相关兼容。 | +| 软依赖 | `EssentialsX` | 用于附魔注册和显示相关兼容。 | + +插件 `load: STARTUP`,会在服务端启动早期注册附魔。`folia-supported: true` 已声明,但生产环境仍建议先在测试服检查与其他插件的交互。 + +## 支持的服务端版本形态 + +源码包含多个 NMS 模块,当前分支覆盖 `v1_21_8`、`v1_21_10`、`v1_21_11`、`v26_1_1`、`v26_1_2`、`v26_2` 等代理模块。服主不需要手动选择模块,插件会通过代理层加载对应实现。 + +如果代理加载失败,插件会记录服务端版本、Bukkit 版本和异常类型,并阻止继续运行,避免半注册状态污染附魔注册表。 + +## advanced 分支授权门禁 + +当前 `advanced` 分支包含在线授权校验。默认配置位于 `license`: + +```yaml +license: + key: "" + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + channel: stable + timeout-ms: 3000 + installation-id: "" + send-server-name: false + send-build-fingerprint: true +``` + +启动时后端返回 `valid` 或 `trial` 才会继续启用核心运行时。授权请求默认发送授权 key、安装 ID、插件/服务端版本、Java 版本、online-mode、频道,并可选发送服务器名和构建指纹。源码注释明确不收集玩家 UUID、玩家 IP、聊天、经济、背包、坐标、权限或世界文件指纹。 + +服主上线前应确认: + +1. 已填写合法 `license.key`。 +2. 服务器能访问 `license.api-url`。 +3. `timeout-ms` 符合本机到后端的网络质量。 +4. 多实例网络应固定或明确区分 `installation-id`。 + +## 重载与重启 + +常规配置可以通过 `/ecoenchants reload` 重载。命令反馈会提示重载耗时和附魔数量。 + +以下情况建议重启: + +| 情况 | 原因 | +| --- | --- | +| 新增或删除附魔文件 | 附魔注册和客户端/插件缓存可能需要完整生命周期刷新。 | +| 修改 `display.enabled` | 配置注释明确此项需要服务器重启。 | +| 调整 NMS、依赖或构建产物 | 代理和注册表在启动阶段确定。 | +| 授权/远程运维安全策略大改 | 确保会话 token、HMAC、mTLS、审计状态全部重新初始化。 | + +## 文件位置 + +常用配置文件在插件数据目录中生成,仓库默认资源来源如下: + +| 文件 | 用途 | +| --- | --- | +| `config.yml` | 总配置,包含授权、远程运维、遥测、获取来源、显示、GUI、玩家体验。 | +| `lang.yml` | 消息与 GUI 文案,当前默认包含中英双语。 | +| `enchants/*.yml` | 默认附魔与自定义附魔配置。 | +| `types.yml` | 附魔类型、类型限制、高等级偏置、砂轮规则。 | +| `rarity.yml` | 稀有度、附魔台概率、最低等级、村民概率、战利品概率。 | +| `targets.yml` | 目标装备、槽位、额外可附魔物品。 | +| `vanillaenchants.yml` | 原版附魔相关配置。 | + +## 上线前检查清单 + +- 确认 `eco` 已安装且版本与构建匹配。 +- 确认服务端版本在当前 NMS 模块覆盖范围内。 +- 填写授权 key,并在测试服验证启动结果。 +- 保留默认 `remote-operations.file-ops.enabled: false`,除非已经有后端审批与审计流程。 +- 检查 `runtime-telemetry` 是否符合你的隐私公告与玩家告知要求。 +- 修改 GUI 行列位置后,确认所有按钮没有占用同一格。 +- 大型生存服先降低高稀有度获取概率,再逐步观察经济流通。 diff --git a/documentation/report/enchantments.md b/documentation/report/enchantments.md new file mode 100644 index 0000000000..097f3e8d7f --- /dev/null +++ b/documentation/report/enchantments.md @@ -0,0 +1,100 @@ +# 附魔库与自定义附魔 + +当前默认资源包含 102 个实际附魔配置文件,另有 `_example.yml` 用于说明配置格式。附魔文件可以放在 `enchants` 根目录,也可以按集成插件拆分子目录,例如 `enchants/ecoskills`、`enchants/ecojobs`、`enchants/ecopets`。 + +## 默认附魔统计 + +按类型统计: + +| 类型 | 数量 | 运营含义 | +| --- | ---: | --- | +| `normal` | 79 | 主体附魔池,适合附魔台、村民和战利品。 | +| `spell` | 9 | 通常更像主动或特殊触发效果,默认同类最多 1 个。 | +| `special` | 8 | 高价值能力,默认同类最多 1 个。 | +| `curse` | 5 | 负面或限制型附魔,默认不可砂轮移除。 | +| `common` | 1 | 当前类型表未定义该类型,服主排查展示或分组异常时应优先检查。 | + +按稀有度统计: + +| 稀有度 | 数量 | +| --- | ---: | +| `legendary` | 31 | +| `rare` | 22 | +| `epic` | 18 | +| `uncommon` | 15 | +| `special` | 6 | +| `common` | 5 | +| `veryspecial` | 5 | + +## 附魔文件结构 + +`_example.yml` 展示了一个附魔常见字段: + +```yaml +display-name: "Example" +description: "Gives a &a%placeholder%%&r and a &a+%damage%&r bonus to damage" +placeholder: "%level%" +type: normal +targets: + - sword +conflicts: + - sharpness +maximum-level: 5 +tradeable: true +discoverable: true +enchantable: true +effects: [] +conditions: [] +``` + +服主最常改的字段: + +| 字段 | 作用 | +| --- | --- | +| `display-name` | 游戏内显示名,改名不会改变附魔 ID。 | +| `description` | 描述文本,可使用占位符。 | +| `placeholder` | 描述中 `%placeholder%` 的值。 | +| `type` | 对应 `types.yml`,影响颜色、类型限制和砂轮行为。 | +| `targets` | 对应 `targets.yml`,决定可用装备和生效槽位。 | +| `conflicts` | 与指定附魔冲突,防止强力组合。 | +| `requirements` | 前置附魔要求。 | +| `maximum-level` | 最大等级。 | +| `tradeable` | 是否进入村民交易池。 | +| `discoverable` | 是否进入发现类来源。 | +| `enchantable` | 是否能从附魔台获得。 | +| `effects` | libreforge 效果逻辑。 | +| `conditions` | 生效条件。 | + +## 获取来源细分 + +当前分支的信息 GUI 会显示更细的发现来源,例如: + +- `discoverable_chests` +- `discoverable_fishing` +- `discoverable_mob_drops` +- `discoverable_raids` + +如果单个附魔文件使用了分来源配置,服主可以让某个附魔只在宝箱、钓鱼、怪物掉落或袭击奖励中出现。这样比全局开关更适合做地图探索或活动奖励。 + +## 自定义附魔建议 + +新增自定义附魔时建议按以下顺序做: + +1. 从 `_example.yml` 复制成新的小写 ID 文件,例如 `shadow_edge.yml`。 +2. 先设置 `display-name`、`description`、`type`、`targets`、`maximum-level`。 +3. 先只允许管理员命令测试,暂时关闭 `tradeable`、`discoverable`、`enchantable`。 +4. 用 `/enchant <id> [level]` 在测试服验证效果、描述和冲突。 +5. 再决定是否进入附魔台、村民或战利品。 +6. 新增附魔后安排玩家重新登录,生产服更稳妥的做法是重启。 + +## 平衡检查问题 + +发布新附魔前至少回答这些问题: + +- 这个附魔应该在哪些装备上生效? +- 是否能与原版强力附魔叠加? +- 是否应该和同类插件附魔冲突? +- 最高等级是否会破坏 PvP 或 Boss 战? +- 是否允许村民量产? +- 是否应该只通过活动或稀有战利品出现? +- lore 描述是否足够短,玩家能否理解触发条件? diff --git a/documentation/report/gameplay-balance.md b/documentation/report/gameplay-balance.md new file mode 100644 index 0000000000..8305401710 --- /dev/null +++ b/documentation/report/gameplay-balance.md @@ -0,0 +1,98 @@ +# 玩法与平衡模型 + +EcoEnchants 的平衡由“附魔定义、类型、稀有度、目标、获取来源、铁砧规则、展示规则”共同决定。服主调参时不要只改单个概率,否则很容易造成某类装备过强或市场价格失真。 + +## 附魔生命周期 + +一个附魔通常从 `enchants/*.yml` 加载,经过注册后成为真实服务端附魔。玩家可以通过以下路径接触它: + +| 路径 | 相关配置 | 服主关注点 | +| --- | --- | --- | +| 附魔台 | `enchanting-table`、附魔 `enchantable`、稀有度 `table-chance`、`minimum-level` | 控制普通玩家自然获取速度。 | +| 村民交易 | `villager`、附魔 `tradeable`、稀有度 `villager-chance` | 控制图书管理员经济和刷交易价值。 | +| 自然战利品 | `loot`、附魔 `discoverable`、分来源开关 | 控制探索奖励和地牢价值。 | +| 铁砧合并 | `anvil`、类型限制、冲突和前置 | 控制最终装备成型成本。 | +| 管理命令 | `/enchant`、`/ecoenchants giverandombook` | 用于活动、补偿、测试和管理员干预。 | +| GUI 浏览 | `/ecoenchants gui`、`/enchantinfo` | 帮助玩家理解附魔、冲突和获取来源。 | + +## 类型模型 + +默认 `types.yml` 定义了 4 类: + +| 类型 | 默认显示 | 默认限制 | 高等级偏置 | 砂轮 | +| --- | --- | --- | --- | --- | +| `normal` | 灰色 | 不限制 | 0 | 可移除 | +| `curse` | 红色 | 不限制 | 0 | 不可移除 | +| `spell` | 蓝色渐变 | 同类最多 1 个 | 0.5 | 可移除 | +| `special` | 粉色渐变 | 同类最多 1 个 | 0.7 | 可移除 | + +`limit` 是装备成型上限的重要工具。想让强力主动类或特殊类附魔不会堆满装备,应保持 `spell` 和 `special` 的 `limit: 1`。`high-level-bias` 会降低高等级出现频率,适合给强力类型增加长期追求。 + +## 稀有度模型 + +默认稀有度决定附魔台、村民和战利品概率: + +| 稀有度 | 附魔台概率 | 最低等级 | 村民概率 | 战利品概率 | +| --- | ---: | ---: | ---: | ---: | +| `common` | 30 | 1 | 10.5 | 12 | +| `uncommon` | 20 | 5 | 9 | 16 | +| `rare` | 20 | 15 | 7.5 | 18 | +| `epic` | 10 | 16 | 6 | 20 | +| `legendary` | 8 | 20 | 4.5 | 15 | +| `special` | 2 | 30 | 3 | 5 | +| `veryspecial` | 1 | 30 | 1.5 | 2 | + +这些概率不是孤立结果,还会被全局 multiplier 和 reduction 影响。比如附魔台有 `book-multiplier: 0.5`、`cap: 5`、`reduction: 2.2`,表示同一次附魔中越靠后的附魔越难出现。 + +## 获取渠道建议 + +生存服推荐: + +- `common` 到 `rare` 允许附魔台和村民自然流通。 +- `epic` 可保留自然战利品,提高探索价值。 +- `legendary` 建议降低村民权重,避免刷交易量产。 +- `special` 和 `veryspecial` 只保留极低概率,或改为活动、任务、宝箱奖励。 +- 诅咒类不要完全关闭,保留少量能让战利品更有风险,但不要让它们进入所有主流获取渠道。 + +小游戏或赛季服推荐: + +- 提高 `enchanting-table.cap` 加快装备成型。 +- 降低 `anvil.cost-exponent` 或开启 `vanilla-costs`,减少铁砧成本门槛。 +- 用 `/ecoenchants giverandombook` 做赛季奖励,但限制等级范围。 + +## 铁砧与砂轮 + +`anvil` 配置决定玩家能否把多个自定义附魔合在一起: + +```yaml +anvil: + vanilla-costs: false + cost-exponent: 0.95 + enchant-limit: -1 + use-rework-penalty: true + max-repair-cost: 40 + clamp-repair-cost: true +``` + +关键含义: + +| 配置 | 建议 | +| --- | --- | +| `vanilla-costs` | 想完全贴近原版成本时开启;想让自定义附魔更可控时保持关闭。 | +| `cost-exponent` | 越低越能缓解“过于昂贵”。 | +| `enchant-limit` | 公平竞技服建议设置上限;RPG 服可保持 `-1`。 | +| `max-repair-cost` | 控制最终成本天花板。 | +| `clamp-repair-cost` | 开启时会把成本压到上限;关闭时超过上限会阻止结果。 | + +砂轮行为还受类型的 `no-grindstone` 控制。默认诅咒不可通过砂轮移除,这符合原版直觉。 + +## 展示规则 + +`display` 控制物品 lore 中如何显示附魔: + +- `collapse.enabled` 可在附魔数量超过阈值时折叠显示,减少 lore 爆炸。 +- `descriptions.enabled` 可显示附魔描述,但超过阈值不显示,避免装备说明过长。 +- `sort.type`、`sort.rarity`、`sort.length` 可改变排序方式。 +- `require-enchantable` 可避免非可附魔物品显示 EcoEnchants 信息。 + +大型服务器建议开启折叠显示,并允许玩家用 `/ecoenchants toggledescriptions` 自己控制描述显示。 diff --git a/documentation/report/index.md b/documentation/report/index.md new file mode 100644 index 0000000000..e681122eab --- /dev/null +++ b/documentation/report/index.md @@ -0,0 +1,29 @@ +# EcoEnchants 服主功能调研报告 + +本报告面向 Minecraft 服务器主,目标是把当前插件内可用功能、配置边界、运营建议和 `advanced` 分支新增能力整理成可直接用于开服、调参、授权接入和日常维护的文档。 + +调研依据来自当前仓库源码与资源文件,包括 `config.yml`、`lang.yml`、`plugin.yml`、`types.yml`、`rarity.yml`、`targets.yml`、默认附魔配置、命令源码、GUI 源码、后端授权/远程运维源码和运行时遥测源码。本文档不假设未在当前分支出现的功能。 + +## 快速结论 + +EcoEnchants 的核心定位是“像原版附魔一样存在”的自定义附魔插件。它不是只在物品 lore 上写几行文字,而是将附魔注册进服务端生态,使附魔台、村民交易、战利品、铁砧、砂轮、命令和兼容插件能够围绕真实附魔工作。 + +当前默认资源包含 102 个可用附魔文件,另有一个 `_example.yml` 模板。按类型统计:`normal` 79 个、`spell` 9 个、`special` 8 个、`curse` 5 个,另有 1 个配置中写作 `common` 的附魔类型需要服主在排查时留意。按稀有度统计:`legendary` 31 个、`rare` 22 个、`epic` 18 个、`uncommon` 15 个、`special` 6 个、`common` 5 个、`veryspecial` 5 个。 + +`advanced` 分支新增了大量服主侧运营能力:在线授权启动门禁、后端 `/api/ecoenchants/v1` 接入、安全远程运维客户端、可选文件操作与备份、运行时遥测、环境探针、双语提示、改进后的附魔浏览 GUI、管理员工具、经验提示统计和更细的命令反馈。这些内容已经单独放在 [Chloemlla advanced 新功能](./chloemlla-advanced) 章节。 + +## 服主阅读路线 + +1. 首次部署先看 [部署与运行边界](./deployment-runtime),确认依赖、授权、版本和启动要求。 +2. 想调经济和平衡看 [玩法与平衡模型](./gameplay-balance),重点是获取来源、稀有度和铁砧成本。 +3. 想了解默认附魔与自定义附魔看 [附魔库与自定义附魔](./enchantments)。 +4. 给玩家和管理组配置权限看 [命令、权限与 GUI](./commands-gui)。 +5. 修改 `config.yml` 前看 [配置运营手册](./configuration)。 +6. 使用 CMI、EssentialsX、Folia 或新版本 Paper 时看 [兼容性与集成](./integrations)。 +7. 使用 Chloemlla `advanced` 分支时先看 [新增功能总览](./chloemlla-advanced),再按需要阅读授权、远程运维、遥测、GUI 和排障细分页。 + +## 运维原则 + +生产服建议先把附魔获取渠道分层开启:附魔台负责普通获取,战利品负责探索奖励,村民交易负责经济流通,命令和 GUI 管理入口只给管理员。改动附魔文件后可先执行 `/ecoenchants reload`,但新增附魔或涉及注册行为时,应安排玩家重新登录,必要时重启服务器。 + +对 `advanced` 分支的后端能力要按最小权限启用。授权校验是启动前置;远程文件操作和备份默认应保持关闭,只有明确需要后端维护时再启用,并同步审计日志与审批流程。 diff --git a/documentation/report/integrations.md b/documentation/report/integrations.md new file mode 100644 index 0000000000..5ee113efb2 --- /dev/null +++ b/documentation/report/integrations.md @@ -0,0 +1,76 @@ +# 兼容性与集成 + +EcoEnchants 的优势是把自定义附魔注册成服务端能识别的真实附魔,因此兼容面比纯 lore 插件更广。服主要关注的是版本、依赖、注册时机和其他插件对物品 lore 的处理。 + +## 原版系统集成 + +| 系统 | 支持情况 | 服主注意点 | +| --- | --- | --- | +| 附魔台 | 支持 | 受 `enchanting-table`、稀有度和 `ecoenchants.fromtable.<id>` 权限控制。 | +| 村民交易 | 支持 | 受 `villager`、稀有度和单个附魔 `tradeable` 控制。 | +| 自然战利品 | 支持 | 受 `loot`、稀有度和单个附魔 `discoverable` 控制。 | +| 铁砧 | 支持 | 受 `anvil`、冲突、前置、类型限制控制。 | +| 砂轮 | 支持 | 受附魔类型 `no-grindstone` 控制。 | +| 物品 lore | 支持 | 由 `display` 配置控制,可折叠和显示描述。 | + +## CMI 与 EssentialsX + +`plugin.yml` 声明了 `CMI` 和 `EssentialsX` 软依赖。源码中有对应集成加载器,会在插件存在时注册兼容逻辑。服主应注意: + +- 不需要为了 EcoEnchants 强制安装 CMI 或 EssentialsX。 +- 如果服务器已经使用它们,建议在测试服确认附魔书、物品命令、修复命令和 lore 显示没有冲突。 +- 若其他插件直接重写 item meta 或 lore,可能覆盖 EcoEnchants 展示,但真实附魔数据仍应保留。 + +## libreforge 与生态附魔 + +EcoEnchants 使用 libreforge 效果系统表达大量附魔逻辑。当前构建会把 libreforge shadow jar 嵌入最终产物,同时也能读取依赖型附魔配置。 + +部分附魔位于子目录: + +| 目录 | 说明 | +| --- | --- | +| `enchants/ecoskills` | 与 EcoSkills 经验或技能相关的附魔。 | +| `enchants/ecojobs` | 与 EcoJobs 相关的附魔。 | +| `enchants/ecopets` | 与 EcoPets 相关的附魔。 | + +这些附魔如果声明了依赖插件,缺少依赖时会被跳过或不可用。服主不要把生态附魔当作必定生效的基础池,先确认对应插件存在。 + +## Folia + +插件声明 `folia-supported: true`。这表示插件作者已经声明 Folia 支持,但服主仍需要对以下内容做测试: + +- GUI 点击与物品归还。 +- 附魔触发效果。 +- 遥测和远程运维的调度任务。 +- 与其他非 Folia 插件的组合。 + +Folia 的问题往往不是单个插件,而是插件组合中的线程上下文不一致。 + +## 版本与注册表 + +当前分支包含多个现代版本代理模块,插件会在启动阶段替换、注册和冻结附魔注册表。advanced 分支还改进了代理加载失败时的保护逻辑,失败时会禁用插件而不是继续运行。 + +服主遇到“附魔不存在”“GUI 能看但物品无效果”“启动阶段报 registry 错误”时,优先检查: + +1. 服务端版本是否落在当前构建支持范围。 +2. 是否使用了非 Paper 兼容分支或深度修改核心。 +3. 是否有其他插件也在启动阶段修改附魔注册表。 +4. 是否新增了无效 `type`、`rarity` 或 `target`。 +5. 是否需要玩家重新登录或重启。 + +## 从其他附魔插件迁移 + +`lore-conversion` 用于将其他插件的 lore 型附魔转换为同名 EcoEnchants 附魔: + +```yaml +lore-conversion: + enabled: false + aggressive: false +``` + +迁移建议: + +- 先在备份服开启并测试,不要直接在生产服全量转换。 +- 保持 `aggressive: false`,只在玩家交互时逐步转换。 +- 只有确认库存、箱子、离线玩家物品都需要扫描时,才考虑 aggressive 模式。 +- 转换前保留完整备份,尤其是玩家数据和世界容器。 diff --git a/eco-core/core-nms/v1_21_11/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_11/ModernEnchantmentRegisterer.kt b/eco-core/core-nms/v1_21_11/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_11/ModernEnchantmentRegisterer.kt index c7ba71f40d..6904e62e65 100644 --- a/eco-core/core-nms/v1_21_11/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_11/ModernEnchantmentRegisterer.kt +++ b/eco-core/core-nms/v1_21_11/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_11/ModernEnchantmentRegisterer.kt @@ -13,7 +13,6 @@ import net.minecraft.core.Holder import net.minecraft.core.MappedRegistry import net.minecraft.core.Registry import net.minecraft.core.registries.Registries -import net.minecraft.resources.Identifier import org.bukkit.Bukkit import org.bukkit.NamespacedKey import org.bukkit.craftbukkit.CraftRegistry @@ -130,7 +129,7 @@ class ModernEnchantmentRegisterer : ModernEnchantmentRegistererProxy { Registry.register( enchantmentRegistry, - Identifier.withDefaultNamespace(enchant.id), + CraftNamespacedKey.toMinecraft(enchant.enchantmentKey), vanillaEnchantment ) diff --git a/eco-core/core-nms/v1_21_11/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_11/registration/VanillaEcoEnchantsEnchantment.kt b/eco-core/core-nms/v1_21_11/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_11/registration/VanillaEcoEnchantsEnchantment.kt index 5d06c0a762..2a56248761 100644 --- a/eco-core/core-nms/v1_21_11/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_11/registration/VanillaEcoEnchantsEnchantment.kt +++ b/eco-core/core-nms/v1_21_11/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_11/registration/VanillaEcoEnchantsEnchantment.kt @@ -2,8 +2,8 @@ package com.willfp.ecoenchants.proxy.v1_21_11.registration import com.willfp.ecoenchants.enchant.EcoEnchant import net.minecraft.core.HolderSet -import net.minecraft.resources.Identifier import net.minecraft.world.item.enchantment.Enchantment +import org.bukkit.craftbukkit.util.CraftNamespacedKey fun vanillaEcoEnchantsEnchantment(enchant: EcoEnchant): Enchantment { val enchantment = Enchantment.enchantment( @@ -17,5 +17,5 @@ fun vanillaEcoEnchantsEnchantment(enchant: EcoEnchant): Enchantment { ) ) - return enchantment.build(Identifier.withDefaultNamespace(enchant.id)) + return enchantment.build(CraftNamespacedKey.toMinecraft(enchant.enchantmentKey)) } diff --git a/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/ModernEnchantmentRegisterer.kt b/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/ModernEnchantmentRegisterer.kt index 32d750c193..f966c3e54d 100644 --- a/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/ModernEnchantmentRegisterer.kt +++ b/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/ModernEnchantmentRegisterer.kt @@ -13,7 +13,6 @@ import net.minecraft.core.Holder import net.minecraft.core.MappedRegistry import net.minecraft.core.Registry import net.minecraft.core.registries.Registries -import net.minecraft.resources.ResourceLocation import org.bukkit.Bukkit import org.bukkit.NamespacedKey import org.bukkit.craftbukkit.CraftRegistry @@ -131,7 +130,7 @@ class ModernEnchantmentRegisterer : ModernEnchantmentRegistererProxy { Registry.register( enchantmentRegistry, - ResourceLocation.withDefaultNamespace(enchant.id), + CraftNamespacedKey.toMinecraft(enchant.enchantmentKey), vanillaEnchantment ) diff --git a/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/EcoEnchantsCraftEnchantment.kt b/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/EcoEnchantsCraftEnchantment.kt index db58c3ba41..62901a790c 100644 --- a/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/EcoEnchantsCraftEnchantment.kt +++ b/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/EcoEnchantsCraftEnchantment.kt @@ -8,7 +8,6 @@ import net.kyori.adventure.text.Component import net.minecraft.core.Holder import net.minecraft.world.item.enchantment.Enchantment import org.bukkit.craftbukkit.enchantments.CraftEnchantment -import org.bukkit.enchantments.EnchantmentTarget import org.bukkit.inventory.ItemStack class EcoEnchantsCraftEnchantment( @@ -60,7 +59,8 @@ class EcoEnchantsCraftEnchantment( replaceWith = ReplaceWith("this.targets") ) @Suppress("DEPRECATION") - override fun getItemTarget(): EnchantmentTarget = EnchantmentTarget.ALL + override fun getItemTarget(): org.bukkit.enchantments.EnchantmentTarget = + org.bukkit.enchantments.EnchantmentTarget.ALL @Deprecated( message = "Treasure enchantments do not exist in EcoEnchants", diff --git a/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/ModifiedVanillaCraftEnchantment.kt b/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/ModifiedVanillaCraftEnchantment.kt index ea885d109f..209770b438 100644 --- a/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/ModifiedVanillaCraftEnchantment.kt +++ b/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/ModifiedVanillaCraftEnchantment.kt @@ -11,15 +11,18 @@ class ModifiedVanillaCraftEnchantment( target: Enchantment, holder: Holder<Enchantment> ) : CraftEnchantment(holder) { - override fun getMaxLevel(): Int = this.vanillaEnchantmentData?.maxLevel ?: super.getMaxLevel() + private val vanillaData = key.vanillaEnchantmentData + + override fun getMaxLevel(): Int = vanillaData?.maxLevel ?: super.getMaxLevel() override fun conflictsWith(other: org.bukkit.enchantments.Enchantment): Boolean { val otherConflicts = when (other) { - is ModifiedVanillaCraftEnchantment -> other.vanillaEnchantmentData?.conflicts?.contains(this.key) == true + is ModifiedVanillaCraftEnchantment -> other.vanillaData?.conflicts?.contains(this.key) == true else -> other.conflictsWith(this) } - return this.vanillaEnchantmentData?.conflicts?.contains(other.key) ?: super.conflictsWith(other) - || otherConflicts + val conflicts = vanillaData?.conflicts?.contains(other.key) ?: super.conflictsWith(other) + + return conflicts || otherConflicts } } diff --git a/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/VanillaEcoEnchantsEnchantment.kt b/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/VanillaEcoEnchantsEnchantment.kt index 7afc84780e..f206f93e48 100644 --- a/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/VanillaEcoEnchantsEnchantment.kt +++ b/eco-core/core-nms/v1_21_8/src/main/kotlin/com/willfp/ecoenchants/proxy/v1_21_8/registration/VanillaEcoEnchantsEnchantment.kt @@ -2,8 +2,8 @@ package com.willfp.ecoenchants.proxy.v1_21_8.registration import com.willfp.ecoenchants.enchant.EcoEnchant import net.minecraft.core.HolderSet -import net.minecraft.resources.ResourceLocation import net.minecraft.world.item.enchantment.Enchantment +import org.bukkit.craftbukkit.util.CraftNamespacedKey fun vanillaEcoEnchantsEnchantment(enchant: EcoEnchant): Enchantment { val enchantment = Enchantment.enchantment( @@ -17,5 +17,5 @@ fun vanillaEcoEnchantsEnchantment(enchant: EcoEnchant): Enchantment { ) ) - return enchantment.build(ResourceLocation.withDefaultNamespace(enchant.id)) + return enchantment.build(CraftNamespacedKey.toMinecraft(enchant.enchantmentKey)) } diff --git a/eco-core/core-nms/v26_1_1/src/main/kotlin/com/willfp/ecoenchants/proxy/v26_1_1/ModernEnchantmentRegisterer.kt b/eco-core/core-nms/v26_1_1/src/main/kotlin/com/willfp/ecoenchants/proxy/v26_1_1/ModernEnchantmentRegisterer.kt index 234a891208..5ba40c4b5e 100644 --- a/eco-core/core-nms/v26_1_1/src/main/kotlin/com/willfp/ecoenchants/proxy/v26_1_1/ModernEnchantmentRegisterer.kt +++ b/eco-core/core-nms/v26_1_1/src/main/kotlin/com/willfp/ecoenchants/proxy/v26_1_1/ModernEnchantmentRegisterer.kt @@ -13,7 +13,6 @@ import net.minecraft.core.Holder import net.minecraft.core.MappedRegistry import net.minecraft.core.Registry import net.minecraft.core.registries.Registries -import net.minecraft.resources.Identifier import org.bukkit.Bukkit import org.bukkit.NamespacedKey import org.bukkit.craftbukkit.CraftRegistry @@ -135,7 +134,7 @@ class ModernEnchantmentRegisterer : ModernEnchantmentRegistererProxy { Registry.register( enchantmentRegistry, - Identifier.withDefaultNamespace(enchant.id), + CraftNamespacedKey.toMinecraft(enchant.enchantmentKey), vanillaEnchantment ) diff --git a/eco-core/core-nms/v26_1_1/src/main/kotlin/com/willfp/ecoenchants/proxy/v26_1_1/registration/VanillaEcoEnchantsEnchantment.kt b/eco-core/core-nms/v26_1_1/src/main/kotlin/com/willfp/ecoenchants/proxy/v26_1_1/registration/VanillaEcoEnchantsEnchantment.kt index 074a1e9a9a..8d15d6b34d 100644 --- a/eco-core/core-nms/v26_1_1/src/main/kotlin/com/willfp/ecoenchants/proxy/v26_1_1/registration/VanillaEcoEnchantsEnchantment.kt +++ b/eco-core/core-nms/v26_1_1/src/main/kotlin/com/willfp/ecoenchants/proxy/v26_1_1/registration/VanillaEcoEnchantsEnchantment.kt @@ -2,8 +2,8 @@ package com.willfp.ecoenchants.proxy.v26_1_1.registration import com.willfp.ecoenchants.enchant.EcoEnchant import net.minecraft.core.HolderSet -import net.minecraft.resources.Identifier import net.minecraft.world.item.enchantment.Enchantment +import org.bukkit.craftbukkit.util.CraftNamespacedKey fun vanillaEcoEnchantsEnchantment(enchant: EcoEnchant): Enchantment { val enchantment = Enchantment.enchantment( @@ -17,5 +17,5 @@ fun vanillaEcoEnchantsEnchantment(enchant: EcoEnchant): Enchantment { ) ) - return enchantment.build(Identifier.withDefaultNamespace(enchant.id)) -} \ No newline at end of file + return enchantment.build(CraftNamespacedKey.toMinecraft(enchant.enchantmentKey)) +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/ChatUtils.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/ChatUtils.kt new file mode 100644 index 0000000000..568ddde0ac --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/ChatUtils.kt @@ -0,0 +1,42 @@ +package com.willfp.ecoenchants + +import com.willfp.eco.util.formatEco +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.event.HoverEvent +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer +import org.bukkit.entity.Player + +// EcoEnchants formats text into legacy section (§) strings, including the +// §x§r§r§g§g§b§b hex sequences Spigot/eco emits for gradients and hex colors. +// This serializer converts those legacy strings back into Adventure components +// so we can attach click/hover events while keeping the original colours. +private val legacySerializer = LegacyComponentSerializer.builder() + .character('§') + .hexColors() + .useUnusualXRepeatedCharacterHexFormat() + .build() + +/** Format an eco source string (& codes, MiniMessage tags) into an Adventure [Component]. */ +internal fun String.toEnchantComponent(): Component = + legacySerializer.deserialize(this.formatEco()) + +/** + * Send [line] as a clickable chat message that runs [command] when clicked. + * [line] is an eco source string; [hover], if given, is shown as the tooltip. + */ +internal fun Player.sendClickableLine(line: String, command: String, hover: String? = null) { + var component = line.toEnchantComponent() + .clickEvent(ClickEvent.runCommand(command)) + + if (hover != null) { + component = component.hoverEvent(HoverEvent.showText(hover.toEnchantComponent())) + } + + this.sendMessage(component) +} + +/** Send [text] (an eco source string) on the player's action bar. */ +internal fun Player.sendActionBarHint(text: String) { + this.sendActionBar(text.toEnchantComponent()) +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/EcoEnchantsPlugin.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/EcoEnchantsPlugin.kt index 3272bf286f..97aefba73a 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/EcoEnchantsPlugin.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/EcoEnchantsPlugin.kt @@ -7,6 +7,8 @@ import com.willfp.eco.core.command.impl.PluginCommand import com.willfp.eco.core.display.DisplayModule import com.willfp.eco.core.dragdrop.DragAndDropHandlers import com.willfp.eco.core.integrations.IntegrationLoader +import com.willfp.ecoenchants.backend.OnlineLicenseGate +import com.willfp.ecoenchants.backend.RemoteOperationsClient import com.willfp.ecoenchants.commands.CommandEcoEnchants import com.willfp.ecoenchants.commands.CommandEnchant import com.willfp.ecoenchants.commands.CommandEnchantInfo @@ -20,25 +22,31 @@ import com.willfp.ecoenchants.display.EnchantDisplay import com.willfp.ecoenchants.display.EnchantSorter import com.willfp.ecoenchants.dragdrop.EcoEnchantBookDragAndDropHandler import com.willfp.ecoenchants.enchant.EcoEnchantLevel +import com.willfp.ecoenchants.enchant.EcoEnchant import com.willfp.ecoenchants.enchant.EcoEnchants import com.willfp.ecoenchants.enchant.EnchantGUI import com.willfp.ecoenchants.enchant.LoreConversion -import com.willfp.ecoenchants.enchant.registration.EnchantmentRegisterer import com.willfp.ecoenchants.enchant.registration.ModernEnchantmentRegistererProxy +import com.willfp.ecoenchants.enchant.impl.EcoEnchantBase +import com.willfp.ecoenchants.experience.PlayerExperience import com.willfp.ecoenchants.integrations.EnchantRegistrations import com.willfp.ecoenchants.integrations.plugins.CMIIntegration import com.willfp.ecoenchants.integrations.plugins.EssentialsIntegration import com.willfp.ecoenchants.libreforge.EffectApplyRandomEnchant import com.willfp.ecoenchants.mechanics.EcoEnchantsAnvilHandler +import com.willfp.ecoenchants.mechanics.EnchantmentSourceCache import com.willfp.ecoenchants.mechanics.EnchantingTableSupport import com.willfp.ecoenchants.mechanics.ExtraItemSupport import com.willfp.ecoenchants.mechanics.GrindstoneSupport +import com.willfp.ecoenchants.mechanics.HeldInteractionRefreshSupport import com.willfp.ecoenchants.mechanics.LootSupport import com.willfp.ecoenchants.mechanics.VillagerSupport import com.willfp.ecoenchants.rarity.EnchantmentRarities import com.willfp.ecoenchants.target.EnchantFinder import com.willfp.ecoenchants.target.EnchantFinder.clearEnchantmentCache import com.willfp.ecoenchants.target.EnchantmentTargets +import com.willfp.ecoenchants.telemetry.EnvironmentRiskProbe +import com.willfp.ecoenchants.telemetry.RuntimeTelemetry import com.willfp.ecoenchants.type.EnchantmentTypes import com.willfp.libreforge.NamedValue import com.willfp.libreforge.effects.Effects @@ -47,7 +55,9 @@ import com.willfp.libreforge.loader.configs.ConfigCategory import com.willfp.libreforge.registerHolderPlaceholderProvider import com.willfp.libreforge.registerHolderProvider import com.willfp.libreforge.registerSpecificRefreshFunction +import net.kyori.adventure.text.format.NamedTextColor import org.bukkit.entity.LivingEntity +import org.bukkit.enchantments.Enchantment import org.bukkit.event.Listener internal lateinit var plugin: EcoEnchantsPlugin @@ -60,22 +70,52 @@ class EcoEnchantsPlugin : LibreforgePlugin() { val vanillaEnchantsYml = VanillaEnchantsYml(this) var isLoaded = false private set + private var proxyLoadFailure: Throwable? = null - val enchantmentRegisterer: EnchantmentRegisterer = this.getProxy(ModernEnchantmentRegistererProxy::class.java) + val enchantmentRegisterer: ModernEnchantmentRegistererProxy init { plugin = this - plugin.getProxy(ModernEnchantmentRegistererProxy::class.java).replaceRegistry() + enchantmentRegisterer = runCatching { + this.getProxy(ModernEnchantmentRegistererProxy::class.java) + }.onFailure { + proxyLoadFailure = it + logProxyFailure("initialization", it) + }.getOrElse { + FailingModernEnchantmentRegistererProxy(it) + } + + replaceRegistrySafely("initialization") } override fun loadConfigCategories(): List<ConfigCategory> { + if (proxyLoadFailure != null) { + return emptyList() + } + return listOf( EcoEnchants ) } override fun handleEnable() { + if (disableIfProxyFailed()) { + return + } + + if (disableIfLicenseFailed()) { + return + } + + if (disableIfEnvironmentProbeFailed()) { + return + } + + sanitizeScoreboardTeamColors() + RuntimeTelemetry.start() + RemoteOperationsClient.start() + Effects.register(EffectApplyRandomEnchant) registerHolderProvider(EnchantFinder.toHolderProvider()) @@ -98,21 +138,37 @@ class EcoEnchantsPlugin : LibreforgePlugin() { } override fun handleAfterLoad() { + if (disableIfProxyFailed()) { + return + } + isLoaded = true - plugin.getProxy(ModernEnchantmentRegistererProxy::class.java).replaceRegistry() + replaceRegistrySafely("after-load") } override fun handleReload() { + if (disableIfProxyFailed()) { + return + } + DisplayCache.reload() EnchantSorter.reload() + CommandEnchant.reload() + CommandEnchantInfo.reload() ExtraItemSupport.reload() + EnchantmentSourceCache.reload() EnchantGUI.reload() + PlayerExperience.reload() + RuntimeTelemetry.reload() + RemoteOperationsClient.reload() registerAnvilHandler() } override fun handleDisable() { + RemoteOperationsClient.stop() + RuntimeTelemetry.stop() DragAndDropHandlers.unregisterAll("ecoenchants") } @@ -131,12 +187,20 @@ class EcoEnchantsPlugin : LibreforgePlugin() { } override fun loadListeners(): List<Listener> { + if (proxyLoadFailure != null) { + return emptyList() + } + return listOf( VillagerSupport, EnchantingTableSupport, LootSupport, LoreConversion, - GrindstoneSupport + GrindstoneSupport, + HeldInteractionRefreshSupport, + EnchantGUI, + PlayerExperience, + RuntimeTelemetry ) } @@ -156,7 +220,7 @@ class EcoEnchantsPlugin : LibreforgePlugin() { } override fun loadDisplayModules(): List<DisplayModule> { - if (!this.configYml.getBool("display.enabled")) { + if (proxyLoadFailure != null || !this.configYml.getBool("display.enabled")) { return emptyList() } @@ -174,4 +238,86 @@ class EcoEnchantsPlugin : LibreforgePlugin() { if (configYml.getBool("display.enabled")) "enabled" else "disabled" } ) + + private fun replaceRegistrySafely(stage: String) { + if (proxyLoadFailure != null) { + return + } + + runCatching { + enchantmentRegisterer.replaceRegistry() + }.onFailure { + proxyLoadFailure = it + logProxyFailure(stage, it) + } + } + + private fun disableIfProxyFailed(): Boolean { + val failure = proxyLoadFailure ?: return false + + logProxyFailure("enable", failure) + server.pluginManager.disablePlugin(this) + return true + } + + private fun disableIfLicenseFailed(): Boolean { + if (OnlineLicenseGate.verifyStartup()) { + return false + } + + server.pluginManager.disablePlugin(this) + return true + } + + private fun disableIfEnvironmentProbeFailed(): Boolean { + if (EnvironmentRiskProbe.verifyStartup()) { + return false + } + + logger.severe("EcoEnchants environment risk probe failed startup policy; disabling plugin.") + server.pluginManager.disablePlugin(this) + return true + } + + private fun logProxyFailure(stage: String, failure: Throwable) { + val serverVersion = runCatching { + "${server.name} ${server.bukkitVersion}" + }.getOrDefault("the current server version") + + logger.severe( + "Could not initialize EcoEnchants NMS proxy during $stage on " + + "$serverVersion. EcoEnchants will disable itself." + ) + logger.severe("${failure::class.java.name}: ${failure.message}") + } + + private fun sanitizeScoreboardTeamColors() { + runCatching { + val scoreboard = server.scoreboardManager.mainScoreboard + + for (team in scoreboard.teams) { + if (team.hasColor()) { + continue + } + + team.color(NamedTextColor.WHITE) + } + }.onFailure { + logger.warning("Could not sanitize scoreboard team colors: ${it.message}") + } + } +} + +private class FailingModernEnchantmentRegistererProxy( + private val failure: Throwable +) : ModernEnchantmentRegistererProxy { + override fun replaceRegistry() = Unit + + override fun freezeRegistry() = Unit + + override fun register(enchant: EcoEnchantBase): Enchantment { + throw IllegalStateException("EcoEnchants NMS proxy is not available", failure) + } + + override fun unregister(enchant: EcoEnchant) = Unit } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/TextUtils.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/TextUtils.kt new file mode 100644 index 0000000000..540712a246 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/TextUtils.kt @@ -0,0 +1,6 @@ +package com.willfp.ecoenchants + +private val legacyFormattingPattern = Regex("(?i)\\u00A7[0-9A-FK-ORX]") + +internal fun String.stripLegacyFormatting(): String = + legacyFormattingPattern.replace(this, "") diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendApiPolicy.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendApiPolicy.kt new file mode 100644 index 0000000000..c0114873b9 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendApiPolicy.kt @@ -0,0 +1,186 @@ +package com.willfp.ecoenchants.backend + +import com.willfp.ecoenchants.plugin + +object BackendApiPolicy { + const val PRODUCT_ID = "ecoenchants" + const val API_ROOT_PATH = "/api/ecoenchants" + const val API_VERSION = "v1" + + const val CORE_RUNTIME_GATING_ALLOWED = true + const val REQUIRED_STARTUP_NETWORK_ALLOWED = true + const val PLAYER_PRIVACY_COLLECTION_ALLOWED = false + + val apiUrl: String + get() = plugin.configYml.getString("license.api-url").trimEnd('/') + + val contractUrl: String + get() = normalizeContractUrl(apiUrl) + + val versionedApiUrl: String + get() = "$contractUrl/$API_VERSION" + + val defaultRpcUrl: String + get() = "${toWebSocketUrl(contractUrl)}/$API_VERSION/rpc/connect" + + val channel: String + get() = plugin.configYml.getString("license.channel") + + val timeoutMillis: Int + get() = plugin.configYml.getInt("license.timeout-ms").coerceIn(500, 5000) + + val sendServerName: Boolean + get() = plugin.configYml.getBool("license.send-server-name") + + val sendBuildFingerprint: Boolean + get() = plugin.configYml.getBool("license.send-build-fingerprint") + + val backendVerboseLogging: Boolean + get() = plugin.configYml.getBool("backend-api.logging.verbose") + + val backendPayloadLogging: Boolean + get() = plugin.configYml.getBool("backend-api.logging.include-payloads") + + val backendMaxPayloadChars: Int + get() = plugin.configYml.getInt("backend-api.logging.max-payload-chars").coerceIn(128, 16384) + + val licenseKey: String + get() = plugin.configYml.getString("license.key").trim() + + val remoteOperationsEnabled: Boolean + get() = plugin.configYml.getBool("remote-operations.enabled") + + val remoteOperationsReconnectMinSeconds: Long + get() = plugin.configYml.getInt("remote-operations.reconnect-min-seconds").coerceAtLeast(1).toLong() + + val remoteOperationsReconnectMaxSeconds: Long + get() = plugin.configYml.getInt("remote-operations.reconnect-max-seconds").coerceAtLeast(5).toLong() + + val remoteOpsRequireSecureTransport: Boolean + get() = plugin.configYml.getBool("remote-operations.security.require-secure-transport") + + val remoteOpsHmacEnabled: Boolean + get() = plugin.configYml.getBool("remote-operations.security.hmac.enabled") + + val remoteOpsRequireSignedRpc: Boolean + get() = plugin.configYml.getBool("remote-operations.security.hmac.require-signed-rpc") + + val remoteOpsHmacKeyId: String + get() = plugin.configYml.getString("remote-operations.security.hmac.key-id").trim() + + val remoteOpsHmacSecret: String + get() = plugin.configYml.getString("remote-operations.security.hmac.secret").trim() + + val remoteOpsHmacMaxClockSkewSeconds: Long + get() = plugin.configYml.getInt("remote-operations.security.hmac.max-clock-skew-seconds") + .coerceAtLeast(30) + .toLong() + + val remoteOpsMtlsEnabled: Boolean + get() = plugin.configYml.getBool("remote-operations.security.mtls.enabled") + + val remoteOpsMtlsKeyStore: String + get() = plugin.configYml.getString("remote-operations.security.mtls.key-store").trim() + + val remoteOpsMtlsKeyStorePassword: String + get() = plugin.configYml.getString("remote-operations.security.mtls.key-store-password") + + val remoteOpsMtlsKeyStoreType: String + get() = plugin.configYml.getString("remote-operations.security.mtls.key-store-type") + .ifBlank { "PKCS12" } + + val remoteFileOpsEnabled: Boolean + get() = plugin.configYml.getBool("remote-operations.file-ops.enabled") + + val remoteBackupsEnabled: Boolean + get() = plugin.configYml.getBool("remote-operations.backups.enabled") + + val remoteAuditLogEnabled: Boolean + get() = plugin.configYml.getBool("remote-operations.audit-log.enabled") + + val remoteAuditLogFile: String + get() = plugin.configYml.getString("remote-operations.audit-log.file") + + val remoteOpsServerRoot: String + get() = plugin.configYml.getString("remote-operations.file-ops.server-root").trim() + + val remoteOpsMaxReadBytes: Long + get() = plugin.configYml.getInt("remote-operations.file-ops.max-read-bytes").coerceAtLeast(1).toLong() + + val remoteOpsMaxWriteBytes: Long + get() = plugin.configYml.getInt("remote-operations.file-ops.max-write-bytes").coerceAtLeast(1).toLong() + + val remoteOpsAllowPermanentDelete: Boolean + get() = plugin.configYml.getBool("remote-operations.file-ops.allow-permanent-delete") + + val remoteOpsBackupMaxBytes: Long + get() = (plugin.configYml.getDouble("remote-operations.backups.max-total-size-mb") * 1024 * 1024) + .toLong() + .coerceAtLeast(1024L) + + val contractBasePath: String + get() = "$API_ROOT_PATH/$API_VERSION" + + fun statusLines(): List<String> { + val result = OnlineLicenseGate.lastResult + + return listOf( + "EcoEnchants license gate", + "Mode: required-online", + "API URL: $apiUrl", + "Contract path: $contractBasePath", + "Contract URL: $versionedApiUrl", + "Product ID: $PRODUCT_ID", + "Channel: $channel", + "Timeout: ${timeoutMillis}ms", + "Send server name: $sendServerName", + "Send build fingerprint: $sendBuildFingerprint", + "Core runtime gating allowed: $CORE_RUNTIME_GATING_ALLOWED", + "Required startup network allowed: $REQUIRED_STARTUP_NETWORK_ALLOWED", + "Player privacy collection allowed: $PLAYER_PRIVACY_COLLECTION_ALLOWED", + "Backend verbose logging: $backendVerboseLogging", + "Backend payload logging: $backendPayloadLogging", + "Backend max payload chars: $backendMaxPayloadChars", + "Last check: ${result.summary}", + "Remote operations enabled: $remoteOperationsEnabled", + "Remote secure transport required: $remoteOpsRequireSecureTransport", + "Remote HMAC enabled: $remoteOpsHmacEnabled", + "Remote signed RPC required: $remoteOpsRequireSignedRpc", + "Remote mTLS enabled: $remoteOpsMtlsEnabled", + "Remote file ops enabled: $remoteFileOpsEnabled", + "Remote backups enabled: $remoteBackupsEnabled" + ) + } + + fun normalizeVersionedApiUrl(rawUrl: String): String = + "${normalizeContractUrl(rawUrl)}/$API_VERSION" + + fun normalizeContractUrl(rawUrl: String): String { + val cleaned = collapseDuplicatedAbsoluteUrl(rawUrl).trim().trimEnd('/') + if (cleaned.endsWith("/$API_VERSION")) { + return cleaned.removeSuffix("/$API_VERSION") + } + if (cleaned.endsWith(API_ROOT_PATH)) { + return cleaned + } + return "$cleaned$API_ROOT_PATH" + } + + private fun collapseDuplicatedAbsoluteUrl(rawUrl: String): String { + val trimmed = rawUrl.trim() + val httpsIndex = trimmed.indexOf("https://", startIndex = "https://".length) + val httpIndex = trimmed.indexOf("http://", startIndex = "http://".length) + val index = listOf(httpsIndex, httpIndex) + .filter { it > 0 } + .minOrNull() + ?: return trimmed + + return trimmed.substring(index) + } + + private fun toWebSocketUrl(url: String): String = when { + url.startsWith("https://", ignoreCase = true) -> "wss://${url.substringAfter("://")}" + url.startsWith("http://", ignoreCase = true) -> "ws://${url.substringAfter("://")}" + else -> url + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendApiTrace.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendApiTrace.kt new file mode 100644 index 0000000000..a827b3784b --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendApiTrace.kt @@ -0,0 +1,105 @@ +package com.willfp.ecoenchants.backend + +import com.willfp.ecoenchants.plugin +import java.net.URI +import java.time.Duration +import java.time.Instant +import java.util.Locale + +object BackendApiTrace { + val enabled: Boolean + get() = plugin.configYml.getBool("backend-api.logging.verbose") + + private val includePayloads: Boolean + get() = plugin.configYml.getBool("backend-api.logging.include-payloads") + + private val maxPayloadChars: Int + get() = plugin.configYml.getInt("backend-api.logging.max-payload-chars").coerceIn(128, 16384) + + fun mark(): Instant = Instant.now() + + fun request( + area: String, + requestId: String, + method: String, + uri: URI, + body: String? = null + ) { + if (!enabled) { + return + } + + plugin.logger.info( + "[EcoEnchants API] -> $area requestId=$requestId method=${method.uppercase(Locale.ROOT)} " + + "uri=${sanitizeUri(uri)} bodyBytes=${body?.toByteArray(Charsets.UTF_8)?.size ?: 0}" + ) + + if (includePayloads && body != null) { + plugin.logger.info("[EcoEnchants API] -> $area requestId=$requestId body=${sanitizePayload(body)}") + } + } + + fun response( + area: String, + requestId: String, + statusCode: Int, + startedAt: Instant, + body: String? = null + ) { + if (!enabled) { + return + } + + plugin.logger.info( + "[EcoEnchants API] <- $area requestId=$requestId status=$statusCode " + + "durationMs=${durationMillis(startedAt)} bodyBytes=${body?.toByteArray(Charsets.UTF_8)?.size ?: 0}" + ) + + if (includePayloads && body != null) { + plugin.logger.info("[EcoEnchants API] <- $area requestId=$requestId body=${sanitizePayload(body)}") + } + } + + fun failure(area: String, requestId: String, startedAt: Instant? = null, message: String) { + if (!enabled) { + return + } + + val duration = if (startedAt != null) { + " durationMs=${durationMillis(startedAt)}" + } else { + "" + } + + plugin.logger.info("[EcoEnchants API] !! $area requestId=$requestId$duration error=${sanitizePayload(message)}") + } + + fun event(area: String, message: String) { + if (!enabled) { + return + } + + plugin.logger.info("[EcoEnchants API] ** $area ${sanitizePayload(message)}") + } + + private fun durationMillis(startedAt: Instant): Long = + Duration.between(startedAt, Instant.now()).toMillis().coerceAtLeast(0) + + private fun sanitizeUri(uri: URI): String { + val query = uri.rawQuery?.let { "?${sanitizePayload(it)}" } ?: "" + return "${uri.scheme}://${uri.authority}${uri.rawPath ?: ""}$query" + } + + private fun sanitizePayload(value: String): String { + var result = value + .replace(Regex("""(?i)(authorization\s*[:=]\s*bearer\s+)[A-Za-z0-9._~+/=-]+"""), "\$1[redacted]") + .replace(Regex("""(?i)("?(?:licenseKey|activationToken|sessionToken|token|secret|password|key-store-password|X-Eco-Signature)"?\s*[:=]\s*"?)[^",\s}]+("?|)"""), "\$1[redacted]\$2") + .replace(Regex("""(?i)(Bearer\s+)[A-Za-z0-9._~+/=-]+"""), "\$1[redacted]") + + if (result.length > maxPayloadChars) { + result = result.take(maxPayloadChars) + "...[truncated ${result.length - maxPayloadChars} chars]" + } + + return result + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendJson.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendJson.kt new file mode 100644 index 0000000000..a4aeb578a7 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/BackendJson.kt @@ -0,0 +1,101 @@ +package com.willfp.ecoenchants.backend + +import java.util.regex.Pattern + +object BackendJson { + fun toJson(value: Any?): String = when (value) { + null -> "null" + is Boolean -> value.toString() + is Number -> value.toString() + is Map<*, *> -> value.entries.joinToString(prefix = "{", postfix = "}") { (key, item) -> + "${toJson(key.toString())}:${toJson(item)}" + } + is Iterable<*> -> value.joinToString(prefix = "[", postfix = "]") { toJson(it) } + is Array<*> -> value.joinToString(prefix = "[", postfix = "]") { toJson(it) } + else -> "\"${escape(value.toString())}\"" + } + + fun stringField(json: String, name: String): String? { + val pattern = Regex(""""${Pattern.quote(name)}"\s*:\s*"((?:\\.|[^"\\])*)"""") + return pattern.find(json)?.groupValues?.get(1)?.let(::unescape) + } + + fun longField(json: String, name: String): Long? { + val pattern = Regex(""""${Pattern.quote(name)}"\s*:\s*(-?\d+)""") + return pattern.find(json)?.groupValues?.get(1)?.toLongOrNull() + } + + fun booleanField(json: String, name: String): Boolean? { + val pattern = Regex(""""${Pattern.quote(name)}"\s*:\s*(true|false)""", RegexOption.IGNORE_CASE) + return pattern.find(json)?.groupValues?.get(1)?.lowercase()?.toBooleanStrictOrNull() + } + + fun stringArrayField(json: String, name: String): List<String> { + val pattern = Regex(""""${Pattern.quote(name)}"\s*:\s*\[(.*?)]""", RegexOption.DOT_MATCHES_ALL) + val body = pattern.find(json)?.groupValues?.get(1) ?: return emptyList() + return Regex(""""((?:\\.|[^"\\])*)"""") + .findAll(body) + .map { unescape(it.groupValues[1]) } + .toList() + } + + fun escape(value: String): String = buildString { + for (char in value) { + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u") + append(char.code.toString(16).padStart(4, '0')) + } else { + append(char) + } + } + } + } + } + + private fun unescape(value: String): String { + val result = StringBuilder() + var index = 0 + while (index < value.length) { + val char = value[index] + if (char != '\\' || index == value.lastIndex) { + result.append(char) + index++ + continue + } + + val escaped = value[index + 1] + when (escaped) { + '"' -> result.append('"') + '\\' -> result.append('\\') + '/' -> result.append('/') + 'b' -> result.append('\b') + 'f' -> result.append('\u000C') + 'n' -> result.append('\n') + 'r' -> result.append('\r') + 't' -> result.append('\t') + 'u' -> { + val hex = value.substring(index + 2, (index + 6).coerceAtMost(value.length)) + val code = hex.toIntOrNull(16) + if (code != null && hex.length == 4) { + result.append(code.toChar()) + index += 4 + } else { + result.append("\\u") + } + } + else -> result.append(escaped) + } + index += 2 + } + return result.toString() + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/OnlineLicenseGate.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/OnlineLicenseGate.kt new file mode 100644 index 0000000000..a8056e934c --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/OnlineLicenseGate.kt @@ -0,0 +1,207 @@ +package com.willfp.ecoenchants.backend + +import com.willfp.ecoenchants.plugin +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.security.MessageDigest +import java.time.Duration +import java.util.UUID + +object OnlineLicenseGate { + @Volatile + var lastResult: LicenseCheckResult = LicenseCheckResult.NotChecked + private set + + fun verifyStartup(): Boolean { + val key = BackendApiPolicy.licenseKey + if (key.isBlank()) { + return fail("No license key is configured at license.key.") + } + + val requestId = UUID.randomUUID().toString() + val uri = URI.create("${BackendApiPolicy.versionedApiUrl}/licenses/verify") + val payload = buildPayload(key) + val request = runCatching { + HttpRequest.newBuilder() + .uri(uri) + .timeout(Duration.ofMillis(BackendApiPolicy.timeoutMillis.toLong())) + .header("Content-Type", "application/json; charset=utf-8") + .header("User-Agent", userAgent()) + .header("X-Request-Id", requestId) + .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)) + .build() + }.getOrElse { + BackendApiTrace.failure("license.verify", requestId, message = "Could not build request: ${it.message}") + return fail("Could not build license verification request: ${it.message}") + } + + BackendApiTrace.request("license.verify", requestId, "POST", uri, payload) + val startedAt = BackendApiTrace.mark() + val response = runCatching { + HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(BackendApiPolicy.timeoutMillis.toLong())) + .build() + .send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) + }.getOrElse { + BackendApiTrace.failure("license.verify", requestId, startedAt, "License server is unreachable: ${it.message}") + return fail("License server is unreachable: ${it.message}") + } + + BackendApiTrace.response("license.verify", requestId, response.statusCode(), startedAt, response.body()) + if (response.statusCode() != 200) { + return fail("License server returned HTTP ${response.statusCode()}.") + } + + val body = response.body() + val status = extractStatus(body) + if (status == "valid" || status == "trial") { + val activationToken = BackendJson.stringField(body, "activationToken") + lastResult = LicenseCheckResult.Valid( + status = status, + activationToken = activationToken, + activationId = BackendJson.stringField(body, "activationId") + ) + BackendApiTrace.event( + "license.verify", + "accepted status=$status activationTokenPresent=${!activationToken.isNullOrBlank()}" + ) + plugin.logger.info("EcoEnchants license verified online with status '$status'.") + return true + } + + return fail("License server returned non-runnable status '${status ?: "missing"}'.") + } + + private fun fail(message: String): Boolean { + lastResult = LicenseCheckResult.Failed(message) + plugin.logger.severe("EcoEnchants license verification failed: $message") + plugin.logger.severe("EcoEnchants requires a successful online license check before core runtime is enabled.") + return false + } + + private fun buildPayload(licenseKey: String): String { + val serverNameField = if (BackendApiPolicy.sendServerName) { + ",\n \"name\":\"${json(plugin.server.name)}\"" + } else { + "" + } + + val fingerprintField = if (BackendApiPolicy.sendBuildFingerprint) { + ",\n \"buildFingerprint\":\"${json(buildFingerprint())}\"" + } else { + "" + } + + return """ + { + "productId":"${BackendApiPolicy.PRODUCT_ID}", + "licenseKey":"${json(licenseKey)}", + "installationId":"${json(installationId())}", + "server":{ + "platform":"${json(plugin.server.name)}", + "platformVersion":"${json(plugin.server.bukkitVersion)}", + "minecraftVersion":"${json(plugin.server.minecraftVersion)}", + "onlineMode":${plugin.server.onlineMode}, + "javaVersion":"${json(System.getProperty("java.version"))}" + $serverNameField + }, + "plugin":{ + "version":"${json(plugin.pluginMeta.version)}", + "channel":"${json(BackendApiPolicy.channel)}" + $fingerprintField + } + } + """.trimIndent() + } + + fun installationId(): String { + val configured = plugin.configYml.getString("license.installation-id").trim() + if (configured.isNotBlank()) { + return configured + } + + val file = plugin.dataFolder.toPath().resolve("license-installation-id.txt") + if (Files.isRegularFile(file)) { + return Files.readString(file, StandardCharsets.UTF_8).trim() + } + + Files.createDirectories(plugin.dataFolder.toPath()) + val generated = UUID.randomUUID().toString() + Files.writeString(file, generated, StandardCharsets.UTF_8) + return generated + } + + private fun buildFingerprint(): String { + val location = runCatching { + plugin.javaClass.protectionDomain.codeSource.location.toURI() + }.getOrNull() ?: return "unavailable" + + val path = Paths.get(location) + if (!Files.isRegularFile(path)) { + return "development-directory" + } + + return "sha256:${sha256(path)}" + } + + private fun sha256(path: Path): String { + val digest = MessageDigest.getInstance("SHA-256") + Files.newInputStream(path).use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) { + break + } + digest.update(buffer, 0, read) + } + } + + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun userAgent(): String { + return "EcoEnchants/${plugin.pluginMeta.version} ${plugin.server.name}/${plugin.server.bukkitVersion} " + + "Java/${System.getProperty("java.version")}" + } + + private fun extractStatus(body: String): String? { + return STATUS_REGEX.find(body)?.groupValues?.get(1)?.lowercase() + } + + private fun json(value: String): String = BackendJson.escape(value) + + private val STATUS_REGEX = Regex(""""status"\s*:\s*"([^"]+)"""") +} + +sealed class LicenseCheckResult { + abstract val summary: String + + data object NotChecked : LicenseCheckResult() { + override val summary = "not checked" + } + + data class Valid( + val status: String, + val activationToken: String? = null, + val activationId: String? = null + ) : LicenseCheckResult() { + override val summary = if (activationToken.isNullOrBlank()) { + "$status (no activation token)" + } else { + "$status (activation token available)" + } + } + + data class Failed( + val reason: String + ) : LicenseCheckResult() { + override val summary = "failed - $reason" + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteFileOperations.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteFileOperations.kt new file mode 100644 index 0000000000..45b8be4faf --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteFileOperations.kt @@ -0,0 +1,936 @@ +package com.willfp.ecoenchants.backend + +import com.willfp.ecoenchants.plugin +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.security.MessageDigest +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.Base64 +import java.util.Locale +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream +import kotlin.io.path.name + +object RemoteFileOperations { + fun supportedMethods(): List<String> = buildList { + if (BackendApiPolicy.remoteFileOpsEnabled) { + add("ops.file.read") + add("ops.file.write") + add("ops.file.delete") + } + if (BackendApiPolicy.remoteBackupsEnabled) { + add("ops.backup.create") + add("ops.backup.restore") + } + } + + fun read(message: String, jobId: String?): Map<String, Any?> { + ensureFileOpsEnabled() + + val mount = BackendJson.stringField(message, "mount") ?: throw RemoteOperationException("missing_mount") + val path = BackendJson.stringField(message, "path") ?: throw RemoteOperationException("missing_path") + val offset = BackendJson.longField(message, "offset") ?: 0L + val limit = (BackendJson.longField(message, "limitBytes") ?: BackendApiPolicy.remoteOpsMaxReadBytes) + .coerceIn(1L, BackendApiPolicy.remoteOpsMaxReadBytes) + val redactionPolicy = BackendJson.stringField(message, "redactionPolicy") + + val resolved = resolveManagedPath(mount, path, ExistingPathMode.MUST_EXIST) + if (!Files.isRegularFile(resolved.realPath)) { + throw RemoteOperationException("not_a_regular_file") + } + + val size = Files.size(resolved.realPath) + val content = Files.newInputStream(resolved.realPath).use { input -> + if (offset > 0) { + input.skip(offset) + } + input.readNBytes(limit.toInt()) + } + + val output = if (redactionPolicy.isNullOrBlank()) { + content + } else { + redact(content.toString(StandardCharsets.UTF_8), redactionPolicy).toByteArray(StandardCharsets.UTF_8) + } + + RemoteOperationsAuditLog.write( + "ops.file.read", + mapOf("jobId" to jobId, "mount" to mount, "path" to path, "sizeBytes" to size) + ) + + return mapOf( + "mount" to mount, + "path" to path, + "sizeBytes" to size, + "offset" to offset, + "limitBytes" to limit, + "truncated" to (offset + content.size < size), + "sha256" to sha256(resolved.realPath), + "redactionPolicy" to redactionPolicy, + "contentBase64" to Base64.getEncoder().encodeToString(output) + ) + } + + fun write(message: String, jobId: String?): Map<String, Any?> { + ensureFileOpsEnabled() + + val mount = BackendJson.stringField(message, "mount") ?: throw RemoteOperationException("missing_mount") + val path = BackendJson.stringField(message, "path") ?: throw RemoteOperationException("missing_path") + val mode = BackendJson.stringField(message, "mode") ?: "overwrite" + val expectedSha256 = BackendJson.stringField(message, "contentSha256") ?: throw RemoteOperationException("missing_sha256") + val contentBase64 = BackendJson.stringField(message, "contentBase64") ?: throw RemoteOperationException("missing_content") + val content = Base64.getDecoder().decode(contentBase64) + + if (content.size.toLong() > BackendApiPolicy.remoteOpsMaxWriteBytes) { + throw RemoteOperationException("write_limit_exceeded") + } + + val actualSha256 = sha256(content) + if (!actualSha256.equals(expectedSha256, ignoreCase = true)) { + throw RemoteOperationException("sha256_mismatch") + } + + val decodedPath = decodeRelativePath(path) + rejectBlockedWrite(decodedPath) + + val resolved = resolveManagedPath(mount, path, ExistingPathMode.PARENT_MUST_EXIST) + rejectExistingTargetOutsideRoot(mount, resolved.normalizedPath) + val exists = Files.exists(resolved.normalizedPath) + if (mode == "create" && exists) { + throw RemoteOperationException("file_already_exists") + } + if (mode != "create" && mode != "overwrite") { + throw RemoteOperationException("unsupported_write_mode") + } + + val beforeSha256 = if (exists && Files.isRegularFile(resolved.normalizedPath)) { + sha256(resolved.normalizedPath) + } else { + null + } + + val temp = resolved.normalizedPath.resolveSibling(".${resolved.normalizedPath.name}.${UUID.randomUUID()}.tmp") + Files.write( + temp, + content, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE + ) + Files.move( + temp, + resolved.normalizedPath, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + + RemoteOperationsAuditLog.write( + "ops.file.write", + mapOf( + "jobId" to jobId, + "mount" to mount, + "path" to path, + "mode" to mode, + "beforeSha256" to beforeSha256, + "afterSha256" to actualSha256 + ) + ) + + return mapOf( + "mount" to mount, + "path" to path, + "mode" to mode, + "sizeBytes" to content.size, + "beforeSha256" to beforeSha256, + "afterSha256" to actualSha256 + ) + } + + fun delete(message: String, jobId: String?): Map<String, Any?> { + ensureFileOpsEnabled() + + val mount = BackendJson.stringField(message, "mount") ?: throw RemoteOperationException("missing_mount") + val path = BackendJson.stringField(message, "path") ?: throw RemoteOperationException("missing_path") + val mode = BackendJson.stringField(message, "mode") ?: "quarantine" + + val resolved = resolveManagedPath(mount, path, ExistingPathMode.MUST_EXIST) + if (!Files.isRegularFile(resolved.realPath)) { + throw RemoteOperationException("delete_requires_regular_file") + } + rejectProtectedDelete(mount, path) + + val beforeSha256 = sha256(resolved.realPath) + val size = Files.size(resolved.realPath) + val quarantinePath = if (mode == "quarantine") { + val target = quarantineRoot().resolve("${timestamp()}-${UUID.randomUUID()}-${resolved.realPath.name}") + Files.createDirectories(target.parent) + Files.move(resolved.realPath, target, StandardCopyOption.REPLACE_EXISTING) + target + } else if (mode == "permanent" && BackendApiPolicy.remoteOpsAllowPermanentDelete) { + Files.delete(resolved.realPath) + null + } else { + throw RemoteOperationException("unsupported_delete_mode") + } + + RemoteOperationsAuditLog.write( + "ops.file.delete", + mapOf( + "jobId" to jobId, + "mount" to mount, + "path" to path, + "mode" to mode, + "beforeSha256" to beforeSha256, + "sizeBytes" to size + ) + ) + + return mapOf( + "mount" to mount, + "path" to path, + "mode" to mode, + "sizeBytes" to size, + "beforeSha256" to beforeSha256, + "quarantinePath" to quarantinePath?.fileName?.toString() + ) + } + + fun createBackup(message: String, jobId: String?): Map<String, Any?> { + if (!BackendApiPolicy.remoteBackupsEnabled) { + throw RemoteOperationException("backups_disabled") + } + + val mounts = BackendJson.stringArrayField(message, "mounts").ifEmpty { listOf("plugin-data") } + val paths = BackendJson.stringArrayField(message, "paths").ifEmpty { listOf(".") } + val requestedFormat = BackendJson.stringField(message, "format") ?: "zip" + val result = createBackupArchive( + backupId = newBackupId("bak"), + mounts = mounts, + paths = paths, + requestedFormat = requestedFormat, + manifestType = "backup" + ) + + RemoteOperationsAuditLog.write( + "ops.backup.create", + mapOf( + "jobId" to jobId, + "backupId" to result.backupId, + "mounts" to mounts, + "paths" to paths, + "sizeBytes" to result.sizeBytes, + "sha256" to result.sha256 + ) + ) + + return result.toResponse(requestedFormat) + } + + fun restoreBackup(message: String, jobId: String?): Map<String, Any?> { + if (!BackendApiPolicy.remoteBackupsEnabled) { + throw RemoteOperationException("backups_disabled") + } + + val backupId = BackendJson.stringField(message, "backupId") ?: throw RemoteOperationException("missing_backup_id") + val mode = (BackendJson.stringField(message, "mode") ?: "staged").lowercase(Locale.ROOT) + val expectedSha256 = BackendJson.stringField(message, "archiveSha256") + val restorePaths = restorePathFilters(BackendJson.stringArrayField(message, "restorePaths")) + val restoreMounts = BackendJson.stringArrayField(message, "mounts").toSet() + val preRestoreBackup = BackendJson.booleanField(message, "preRestoreBackup") ?: true + + val archive = resolveBackupArchive(backupId) + val archiveSha256 = sha256(archive) + if (!expectedSha256.isNullOrBlank() && !archiveSha256.equals(expectedSha256, ignoreCase = true)) { + throw RemoteOperationException("backup_integrity_failed") + } + + val entries = restoreEntries(archive, backupId, restorePaths, restoreMounts) + if (entries.isEmpty()) { + throw RemoteOperationException("no_restore_entries") + } + + return when (mode) { + "staged" -> stageRestore(archive, backupId, archiveSha256, entries, jobId) + "apply", "restore" -> applyRestore( + archive = archive, + backupId = backupId, + archiveSha256 = archiveSha256, + entries = entries, + jobId = jobId, + preRestoreBackup = preRestoreBackup + ) + else -> throw RemoteOperationException("unsupported_restore_mode") + } + } + + private fun ensureFileOpsEnabled() { + if (!BackendApiPolicy.remoteFileOpsEnabled) { + throw RemoteOperationException("file_ops_disabled") + } + } + + private fun createBackupArchive( + backupId: String, + mounts: List<String>, + paths: List<String>, + requestedFormat: String, + manifestType: String + ): BackupArchiveResult { + val archive = backupRoot().resolve("$backupId.zip").normalize() + Files.createDirectories(archive.parent) + + val entries = mutableListOf<Map<String, Any?>>() + var totalSize = 0L + + try { + ZipOutputStream(Files.newOutputStream(archive, StandardOpenOption.CREATE_NEW)).use { zip -> + for (mount in mounts) { + for (path in paths) { + val root = resolveManagedPath(mount, path, ExistingPathMode.MUST_EXIST) + val start = root.realPath + if (Files.isRegularFile(start) && !isExcludedFromBackup(start, archive)) { + totalSize = addBackupFileWithLimit(zip, mount, root.relativePath, start, entries, totalSize) + } else if (Files.isDirectory(start)) { + Files.walk(start).use { walk -> + for (file in walk.filter { Files.isRegularFile(it) && !isExcludedFromBackup(it, archive) }) { + val relative = start.relativize(file).toString().replace('\\', '/') + val entryPath = root.relativePath.trimEnd('/').let { + if (it == "." || it.isBlank()) relative else "$it/$relative" + } + totalSize = addBackupFileWithLimit(zip, mount, entryPath, file, entries, totalSize) + } + } + } + } + } + + val manifest = mapOf( + "backupId" to backupId, + "type" to manifestType, + "createdAt" to Instant.now().toString(), + "requestedFormat" to requestedFormat, + "actualFormat" to "zip", + "productId" to BackendApiPolicy.PRODUCT_ID, + "pluginVersion" to plugin.pluginMeta.version, + "server" to mapOf( + "platform" to plugin.server.name, + "bukkitVersion" to plugin.server.bukkitVersion, + "minecraftVersion" to plugin.server.minecraftVersion + ), + "entries" to entries + ) + zip.putNextEntry(ZipEntry("manifest.json")) + zip.write(BackendJson.toJson(manifest).toByteArray(StandardCharsets.UTF_8)) + zip.closeEntry() + } + } catch (failure: Throwable) { + Files.deleteIfExists(archive) + throw failure + } + + return BackupArchiveResult( + backupId = backupId, + fileName = archive.fileName.toString(), + sizeBytes = Files.size(archive), + sha256 = sha256(archive), + entryCount = entries.size + ) + } + + private fun stageRestore( + archive: Path, + backupId: String, + archiveSha256: String, + entries: List<RestoreEntry>, + jobId: String? + ): Map<String, Any?> { + val stageRoot = restoreStagingRoot().resolve("${backupId}-${UUID.randomUUID()}").normalize() + Files.createDirectories(stageRoot) + + ZipFile(archive.toFile()).use { zip -> + for (entry in entries) { + val zipEntry = zip.getEntry(entry.zipEntryName) ?: throw RemoteOperationException("backup_integrity_failed") + val target = stageRoot.resolve(entry.zipEntryName).normalize() + if (!target.startsWith(stageRoot)) { + throw RemoteOperationException("backup_integrity_failed") + } + + Files.createDirectories(target.parent) + zip.getInputStream(zipEntry).use { input -> + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING) + } + } + } + + RemoteOperationsAuditLog.write( + "ops.backup.restore.staged", + mapOf( + "jobId" to jobId, + "backupId" to backupId, + "archiveSha256" to archiveSha256, + "entryCount" to entries.size, + "stagingDirectory" to stageRoot.fileName.toString() + ) + ) + + return mapOf( + "backupId" to backupId, + "mode" to "staged", + "archiveSha256" to archiveSha256, + "entryCount" to entries.size, + "stagingDirectory" to stageRoot.fileName.toString() + ) + } + + private fun applyRestore( + archive: Path, + backupId: String, + archiveSha256: String, + entries: List<RestoreEntry>, + jobId: String?, + preRestoreBackup: Boolean + ): Map<String, Any?> { + entries.forEach { rejectBlockedWrite(it.relativePath) } + + val preRestore = if (preRestoreBackup) { + createPreRestoreBackup(entries, backupId) + } else { + null + } + + val changes = mutableListOf<Map<String, Any?>>() + + ZipFile(archive.toFile()).use { zip -> + for (entry in entries) { + val resolved = resolveManagedPath(entry.mount, entry.relativePath, ExistingPathMode.ROOT_MUST_EXIST) + ensureWritableParent(entry.mount, resolved.normalizedPath) + rejectExistingTargetOutsideRoot(entry.mount, resolved.normalizedPath) + + if (Files.exists(resolved.normalizedPath) && !Files.isRegularFile(resolved.normalizedPath)) { + throw RemoteOperationException("restore_requires_regular_file_target") + } + + val beforeSha256 = if (Files.isRegularFile(resolved.normalizedPath)) { + sha256(resolved.normalizedPath) + } else { + null + } + + val temp = resolved.normalizedPath.resolveSibling(".${resolved.normalizedPath.name}.${UUID.randomUUID()}.restore") + val zipEntry = zip.getEntry(entry.zipEntryName) ?: throw RemoteOperationException("backup_integrity_failed") + zip.getInputStream(zipEntry).use { input -> + Files.copy(input, temp, StandardCopyOption.REPLACE_EXISTING) + } + Files.move(temp, resolved.normalizedPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + + changes += mapOf( + "mount" to entry.mount, + "path" to entry.relativePath, + "beforeSha256" to beforeSha256, + "afterSha256" to sha256(resolved.normalizedPath) + ) + } + } + + RemoteOperationsAuditLog.write( + "ops.backup.restore", + mapOf( + "jobId" to jobId, + "backupId" to backupId, + "archiveSha256" to archiveSha256, + "entryCount" to entries.size, + "preRestoreBackupId" to preRestore?.backupId + ) + ) + + return mapOf( + "backupId" to backupId, + "mode" to "apply", + "archiveSha256" to archiveSha256, + "entryCount" to entries.size, + "preRestoreBackup" to preRestore?.toResponse("zip"), + "changes" to changes + ) + } + + private fun createPreRestoreBackup(entries: List<RestoreEntry>, sourceBackupId: String): BackupArchiveResult? { + val existingTargets = entries + .distinctBy { "${it.mount}:${it.relativePath}" } + .mapNotNull { entry -> + val resolved = resolveManagedPath(entry.mount, entry.relativePath, ExistingPathMode.ROOT_MUST_EXIST) + rejectExistingTargetOutsideRoot(entry.mount, resolved.normalizedPath) + if (Files.isRegularFile(resolved.normalizedPath)) { + entry to resolved.normalizedPath + } else { + null + } + } + + if (existingTargets.isEmpty()) { + return null + } + + val backupId = newBackupId("pre") + val archive = backupRoot().resolve("$backupId.zip").normalize() + Files.createDirectories(archive.parent) + + val manifestEntries = mutableListOf<Map<String, Any?>>() + var totalSize = 0L + + try { + ZipOutputStream(Files.newOutputStream(archive, StandardOpenOption.CREATE_NEW)).use { zip -> + for ((entry, file) in existingTargets) { + totalSize = addBackupFileWithLimit(zip, entry.mount, entry.relativePath, file, manifestEntries, totalSize) + } + + val manifest = mapOf( + "backupId" to backupId, + "type" to "pre-restore", + "sourceBackupId" to sourceBackupId, + "createdAt" to Instant.now().toString(), + "actualFormat" to "zip", + "productId" to BackendApiPolicy.PRODUCT_ID, + "pluginVersion" to plugin.pluginMeta.version, + "entries" to manifestEntries + ) + zip.putNextEntry(ZipEntry("manifest.json")) + zip.write(BackendJson.toJson(manifest).toByteArray(StandardCharsets.UTF_8)) + zip.closeEntry() + } + } catch (failure: Throwable) { + Files.deleteIfExists(archive) + throw failure + } + + return BackupArchiveResult( + backupId = backupId, + fileName = archive.fileName.toString(), + sizeBytes = Files.size(archive), + sha256 = sha256(archive), + entryCount = manifestEntries.size + ) + } + + private fun restoreEntries( + archive: Path, + backupId: String, + restorePaths: List<String>, + restoreMounts: Set<String> + ): List<RestoreEntry> { + val entries = mutableListOf<RestoreEntry>() + + ZipFile(archive.toFile()).use { zip -> + val manifestEntry = zip.getEntry("manifest.json") ?: throw RemoteOperationException("backup_integrity_failed") + val manifest = zip.getInputStream(manifestEntry).use { input -> + input.readBytes().toString(StandardCharsets.UTF_8) + } + val manifestBackupId = BackendJson.stringField(manifest, "backupId") + if (manifestBackupId != backupId) { + throw RemoteOperationException("backup_integrity_failed") + } + + val zipEntries = zip.entries() + while (zipEntries.hasMoreElements()) { + val zipEntry = zipEntries.nextElement() + if (zipEntry.isDirectory || zipEntry.name == "manifest.json") { + continue + } + + val entryName = normalizeZipEntryName(zipEntry.name) + val mount = entryName.substringBefore('/') + val relativePath = entryName.substringAfter('/') + val decodedRelativePath = decodeRelativePath(relativePath) + if (decodedRelativePath.isBlank() || restoreMounts.isNotEmpty() && mount !in restoreMounts) { + continue + } + if (!matchesRestoreFilter(mount, decodedRelativePath, restorePaths)) { + continue + } + + mountRoot(mount) + entries += RestoreEntry( + zipEntryName = entryName, + mount = mount, + relativePath = decodedRelativePath, + sizeBytes = zipEntry.size + ) + } + } + + return entries + } + + private fun resolveBackupArchive(backupId: String): Path { + if (!Regex("""^[A-Za-z0-9_-]+$""").matches(backupId)) { + throw RemoteOperationException("invalid_backup_id") + } + + val root = backupRoot() + val archive = root.resolve("$backupId.zip").normalize() + if (!Files.isRegularFile(archive)) { + throw RemoteOperationException("backup_not_found") + } + + val realRoot = root.toRealPath() + val realArchive = archive.toRealPath() + if (!realArchive.startsWith(realRoot) || !Files.isRegularFile(realArchive)) { + throw RemoteOperationException("backup_not_found") + } + return realArchive + } + + private fun restorePathFilters(paths: List<String>): List<String> { + val filters = paths.map { decodeRelativePath(it).trimEnd('/') } + return if (filters.any { it == "." }) { + emptyList() + } else { + filters + } + } + + private fun matchesRestoreFilter(mount: String, relativePath: String, filters: List<String>): Boolean { + if (filters.isEmpty()) { + return true + } + + val entryPath = "$mount/$relativePath" + return filters.any { filter -> + relativePath == filter || + relativePath.startsWith("$filter/") || + entryPath == filter || + entryPath.startsWith("$filter/") + } + } + + private fun normalizeZipEntryName(name: String): String { + val normalized = name.replace('\\', '/').trim() + if ( + normalized.isBlank() || + normalized.startsWith("/") || + normalized.startsWith("//") || + normalized.any { it.code < 0x20 } + ) { + throw RemoteOperationException("backup_integrity_failed") + } + + val parts = normalized.split("/") + if (parts.size < 2 || parts.any { it.isBlank() || it == ".." }) { + throw RemoteOperationException("backup_integrity_failed") + } + + return normalized + } + + private fun ensureWritableParent(mount: String, target: Path) { + val parent = target.parent ?: throw RemoteOperationException("missing_parent") + Files.createDirectories(parent) + + val realRoot = mountRoot(mount).toRealPath() + val realParent = parent.toRealPath() + if (!realParent.startsWith(realRoot)) { + throw RemoteOperationException("path_outside_allowed_root") + } + } + + private fun rejectExistingTargetOutsideRoot(mount: String, target: Path) { + if (!Files.exists(target)) { + return + } + + val realRoot = mountRoot(mount).toRealPath() + val realTarget = target.toRealPath() + if (!realTarget.startsWith(realRoot)) { + throw RemoteOperationException("path_outside_allowed_root") + } + } + + private fun addBackupFileWithLimit( + zip: ZipOutputStream, + mount: String, + relativePath: String, + file: Path, + entries: MutableList<Map<String, Any?>>, + currentSize: Long + ): Long { + val size = Files.size(file) + if (currentSize + size > BackendApiPolicy.remoteOpsBackupMaxBytes) { + throw RemoteOperationException("backup_limit_exceeded") + } + + addBackupFile(zip, mount, relativePath, file, entries) + return currentSize + size + } + + private fun addBackupFile( + zip: ZipOutputStream, + mount: String, + relativePath: String, + file: Path, + entries: MutableList<Map<String, Any?>> + ): Long { + val size = Files.size(file) + val digest = sha256(file) + val entryName = "$mount/${relativePath.replace('\\', '/')}".replace("//", "/") + + zip.putNextEntry(ZipEntry(entryName)) + Files.newInputStream(file).use { input -> input.copyTo(zip) } + zip.closeEntry() + + entries += mapOf( + "mount" to mount, + "path" to relativePath, + "sizeBytes" to size, + "sha256" to digest + ) + return size + } + + private fun isExcludedFromBackup(file: Path, archive: Path): Boolean { + val normalized = file.toAbsolutePath().normalize() + val excludedRoots = listOf( + backupRoot(), + quarantineRoot(), + restoreStagingRoot() + ).map { it.toAbsolutePath().normalize() } + + return normalized == archive.toAbsolutePath().normalize() || + excludedRoots.any { normalized.startsWith(it) } + } + + private fun resolveManagedPath( + mount: String, + rawPath: String, + mode: ExistingPathMode + ): ManagedPath { + val root = mountRoot(mount) + val realRoot = root.toRealPath() + val relative = decodeRelativePath(rawPath) + val candidate = realRoot.resolve(relative).normalize() + + if (!candidate.startsWith(realRoot)) { + throw RemoteOperationException("path_outside_allowed_root") + } + + val realPath = when (mode) { + ExistingPathMode.MUST_EXIST -> { + val real = candidate.toRealPath() + if (!real.startsWith(realRoot)) { + throw RemoteOperationException("path_outside_allowed_root") + } + real + } + ExistingPathMode.PARENT_MUST_EXIST -> { + val parent = candidate.parent ?: throw RemoteOperationException("missing_parent") + val realParent = parent.toRealPath() + if (!realParent.startsWith(realRoot)) { + throw RemoteOperationException("path_outside_allowed_root") + } + candidate + } + ExistingPathMode.ROOT_MUST_EXIST -> candidate + } + + return ManagedPath( + normalizedPath = candidate, + realPath = realPath, + relativePath = relative + ) + } + + private fun mountRoot(mount: String): Path { + val serverRoot = BackendApiPolicy.remoteOpsServerRoot.ifBlank { + plugin.dataFolder.parentFile?.parentFile?.absolutePath + ?: plugin.server.worldContainer.absolutePath + } + + return when (mount) { + "server-root" -> Paths.get(serverRoot) + "plugin-data", "config" -> plugin.dataFolder.toPath() + "logs" -> Paths.get(serverRoot).resolve("logs") + "backups" -> backupRoot() + else -> throw RemoteOperationException("unknown_mount") + }.normalize() + } + + private fun decodeRelativePath(rawPath: String): String { + val decoded = URLDecoder.decode(rawPath, StandardCharsets.UTF_8) + .replace('\\', '/') + .trim() + + if ( + decoded.isBlank() || + decoded.startsWith("/") || + decoded.startsWith("//") || + Regex("^[A-Za-z]:").containsMatchIn(decoded) || + decoded.any { it.code < 0x20 } + ) { + throw RemoteOperationException("invalid_path") + } + + val parts = decoded.split("/") + if (parts.any { it == ".." || it.isBlank() }) { + throw RemoteOperationException("invalid_path") + } + + return decoded + } + + private fun rejectBlockedWrite(path: String) { + val lower = path.lowercase(Locale.ROOT) + val fileName = lower.substringAfterLast('/') + val blockedExtensions = listOf( + ".jar", + ".class", + ".exe", + ".dll", + ".so", + ".dylib", + ".bat", + ".cmd", + ".ps1", + ".sh", + ".bash", + ".zsh", + ".fish", + ".vbs", + ".js", + ".jse", + ".wsf", + ".py", + ".rb", + ".pl", + ".php" + ) + val blockedNames = setOf( + "user_jvm_args.txt", + "start.sh", + "start.bat", + "run.sh", + "run.bat", + "server.jar", + "paper.jar", + "spigot.jar", + "bukkit.jar" + ) + + if (blockedExtensions.any { fileName.endsWith(it) } || fileName in blockedNames) { + throw RemoteOperationException("file_type_blocked") + } + } + + private fun newBackupId(prefix: String): String = + "${prefix}_${timestamp()}_${UUID.randomUUID()}" + + private fun BackupArchiveResult.toResponse(requestedFormat: String): Map<String, Any?> = + mapOf( + "backupId" to backupId, + "requestedFormat" to requestedFormat, + "actualFormat" to "zip", + "fileName" to fileName, + "sizeBytes" to sizeBytes, + "sha256" to sha256, + "entryCount" to entryCount + ) + + private fun rejectProtectedDelete(mount: String, path: String) { + if (mount != "server-root") { + return + } + + val normalized = decodeRelativePath(path) + val topLevel = normalized.substringBefore('/') + if (topLevel in setOf("plugins", "world", "world_nether", "world_the_end", "backups")) { + throw RemoteOperationException("protected_path") + } + } + + private fun redact(text: String, policy: String): String { + var result = text + result = result.replace(Regex("""\b(?:\d{1,3}\.){3}\d{1,3}\b"""), "x.x.x.x") + result = result.replace(Regex("""[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"""), "[redacted-email]") + result = result.replace(Regex("""(?i)\b(ECOE-[A-Z0-9-]+)\b"""), "ECOE-****") + result = result.replace( + Regex("""(?i)\b(password|secret|token|api[-_]?key|license[-_]?key)\s*[:=]\s*["']?[^"'\s]+""") + ) { + "${it.groupValues[1]}=***" + } + return if (policy == "players-debug") { + result.replace(Regex("""(?i)\b(uuid|player)\s*[:=]\s*["']?[^"'\s,}]+""")) { + "${it.groupValues[1]}=sha256:${sha256(it.value.toByteArray(StandardCharsets.UTF_8))}" + } + } else { + result + } + } + + private fun quarantineRoot(): Path = + plugin.dataFolder.toPath().resolve("ops-quarantine").normalize() + + private fun backupRoot(): Path = + plugin.dataFolder.toPath().resolve("backups").normalize() + + private fun restoreStagingRoot(): Path = + plugin.dataFolder.toPath().resolve("ops-restore-staging").normalize() + + private fun timestamp(): String = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(java.time.LocalDateTime.now()) + + private fun sha256(path: Path): String { + val digest = MessageDigest.getInstance("SHA-256") + Files.newInputStream(path).use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) { + break + } + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } +} + +class RemoteOperationException( + val code: String, + message: String = code +) : RuntimeException(message) + +private enum class ExistingPathMode { + MUST_EXIST, + PARENT_MUST_EXIST, + ROOT_MUST_EXIST +} + +private data class ManagedPath( + val normalizedPath: Path, + val realPath: Path, + val relativePath: String +) + +private data class BackupArchiveResult( + val backupId: String, + val fileName: String, + val sizeBytes: Long, + val sha256: String, + val entryCount: Int +) + +private data class RestoreEntry( + val zipEntryName: String, + val mount: String, + val relativePath: String, + val sizeBytes: Long +) diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationSecurity.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationSecurity.kt new file mode 100644 index 0000000000..32c60f82d0 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationSecurity.kt @@ -0,0 +1,288 @@ +package com.willfp.ecoenchants.backend + +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.WebSocket +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths +import java.security.KeyStore +import java.security.MessageDigest +import java.security.SecureRandom +import java.time.Instant +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext + +object RemoteOperationSecurity { + private const val HMAC_ALGORITHM = "HmacSHA256" + private val random = SecureRandom() + private val seenNonces = ConcurrentHashMap<String, Long>() + + fun requireSecureUri(uri: URI) { + if (!BackendApiPolicy.remoteOpsRequireSecureTransport) { + return + } + + val scheme = uri.scheme?.lowercase(Locale.ROOT) + if (scheme != "https" && scheme != "wss") { + throw RemoteOperationException("insecure_transport") + } + } + + fun configureClient(builder: HttpClient.Builder): HttpClient.Builder { + val context = mtlsSslContext() ?: return builder + return builder.sslContext(context) + } + + fun signHttpRequest( + builder: HttpRequest.Builder, + method: String, + uri: URI, + body: String, + fallbackSecret: String, + fallbackKeyId: String + ): HttpRequest.Builder { + if (!BackendApiPolicy.remoteOpsHmacEnabled) { + return builder + } + + val signature = hmacSignature( + secret = signingSecret(fallbackSecret), + canonical = canonicalHttpRequest(method, uri, body) + ) + + return builder + .header("X-Eco-Key-Id", keyId(fallbackKeyId)) + .header("X-Eco-Timestamp", signature.timestamp) + .header("X-Eco-Nonce", signature.nonce) + .header("X-Eco-Signature", signature.value) + } + + fun signWebSocket( + builder: WebSocket.Builder, + uri: URI, + fallbackSecret: String, + fallbackKeyId: String + ): WebSocket.Builder { + if (!BackendApiPolicy.remoteOpsHmacEnabled) { + return builder + } + + val signature = hmacSignature( + secret = signingSecret(fallbackSecret), + canonical = canonicalHttpRequest("GET", uri, "") + ) + + return builder + .header("X-Eco-Key-Id", keyId(fallbackKeyId)) + .header("X-Eco-Timestamp", signature.timestamp) + .header("X-Eco-Nonce", signature.nonce) + .header("X-Eco-Signature", signature.value) + } + + fun verifyRpcMessage(message: String, fallbackSecret: String?) { + if (!BackendApiPolicy.remoteOpsHmacEnabled || !BackendApiPolicy.remoteOpsRequireSignedRpc) { + return + } + + val signature = stringOrNumber(message, "signature") + ?: BackendJson.stringField(message, "X-Eco-Signature") + ?: throw RemoteOperationException("signature_missing") + val timestamp = stringOrNumber(message, "timestamp") + ?: BackendJson.stringField(message, "X-Eco-Timestamp") + ?: throw RemoteOperationException("timestamp_missing") + val nonce = stringOrNumber(message, "nonce") + ?: BackendJson.stringField(message, "X-Eco-Nonce") + ?: throw RemoteOperationException("nonce_missing") + val providedKeyId = BackendJson.stringField(message, "keyId") + ?: BackendJson.stringField(message, "X-Eco-Key-Id") + val requiredKeyId = BackendApiPolicy.remoteOpsHmacKeyId + if (requiredKeyId.isNotBlank()) { + if (providedKeyId.isNullOrBlank()) { + throw RemoteOperationException("key_id_missing") + } + if (providedKeyId != requiredKeyId) { + throw RemoteOperationException("key_id_invalid") + } + } + + val timestampSeconds = timestamp.toLongOrNull() + ?: throw RemoteOperationException("timestamp_invalid") + val nowSeconds = Instant.now().epochSecond + val skew = BackendApiPolicy.remoteOpsHmacMaxClockSkewSeconds + if (kotlin.math.abs(nowSeconds - timestampSeconds) > skew) { + throw RemoteOperationException("timestamp_out_of_range") + } + + val issuedAt = BackendJson.stringField(message, "issuedAt") + if (issuedAt != null && parseInstantOrNull(issuedAt)?.isAfter(Instant.now().plusSeconds(skew)) == true) { + throw RemoteOperationException("issued_at_invalid") + } + + val expiresAt = BackendJson.stringField(message, "expiresAt") + if (expiresAt != null && parseInstantOrNull(expiresAt)?.isBefore(Instant.now()) == true) { + throw RemoteOperationException("request_expired") + } + + val secret = verifyingSecret(fallbackSecret) + val expected = hmacHex(secret, canonicalRpcMessage(message, timestamp, nonce)) + if (!constantTimeEquals(expected, signature)) { + throw RemoteOperationException("signature_invalid") + } + + rememberNonce(nonce, nowSeconds) + } + + private fun canonicalHttpRequest(method: String, uri: URI, body: String): String { + val timestamp = currentTimestamp() + val nonce = nonce() + return listOf( + method.uppercase(Locale.ROOT), + uri.rawPath ?: "", + uri.rawQuery ?: "", + timestamp, + nonce, + sha256(body.toByteArray(StandardCharsets.UTF_8)) + ).joinToString("\n") + } + + private fun hmacSignature(secret: String, canonical: String): HmacSignature { + val parts = canonical.split('\n') + val timestamp = parts.getOrNull(3) ?: currentTimestamp() + val nonce = parts.getOrNull(4) ?: nonce() + return HmacSignature( + timestamp = timestamp, + nonce = nonce, + value = hmacHex(secret, canonical) + ) + } + + private fun canonicalRpcMessage(message: String, timestamp: String, nonce: String): String { + val scalarFields = listOf( + "type", + "requestId", + "jobId", + "method", + "commandId", + "mount", + "path", + "mode", + "contentSha256", + "backupId", + "archiveSha256", + "redactionPolicy", + "format", + "offset", + "limitBytes" + ).joinToString("\n") { field -> + stringOrNumber(message, field) ?: "" + } + + val arrayFields = listOf( + "mounts", + "paths", + "restorePaths" + ).joinToString("\n") { field -> + BackendJson.stringArrayField(message, field).joinToString(",") + } + + return listOf( + "RPC", + timestamp, + nonce, + scalarFields, + arrayFields + ).joinToString("\n") + } + + private fun mtlsSslContext(): SSLContext? { + if (!BackendApiPolicy.remoteOpsMtlsEnabled) { + return null + } + + val keyStorePath = BackendApiPolicy.remoteOpsMtlsKeyStore + if (keyStorePath.isBlank()) { + throw RemoteOperationException("mtls_keystore_missing") + } + + val password = BackendApiPolicy.remoteOpsMtlsKeyStorePassword.toCharArray() + val keyStore = KeyStore.getInstance(BackendApiPolicy.remoteOpsMtlsKeyStoreType) + Files.newInputStream(Paths.get(keyStorePath)).use { input -> + keyStore.load(input, password) + } + + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(keyStore, password) + + return SSLContext.getInstance("TLS").apply { + init(keyManagerFactory.keyManagers, null, random) + } + } + + private fun signingSecret(fallback: String): String = + BackendApiPolicy.remoteOpsHmacSecret.ifBlank { fallback } + + private fun verifyingSecret(fallback: String?): String = + BackendApiPolicy.remoteOpsHmacSecret.ifBlank { + fallback ?: throw RemoteOperationException("hmac_secret_missing") + } + + private fun keyId(fallback: String): String = + BackendApiPolicy.remoteOpsHmacKeyId.ifBlank { fallback } + + private fun currentTimestamp(): String = + Instant.now().epochSecond.toString() + + private fun nonce(): String { + val bytes = ByteArray(16) + random.nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun rememberNonce(nonce: String, nowSeconds: Long) { + if (seenNonces.size > 4096) { + val cutoff = nowSeconds - BackendApiPolicy.remoteOpsHmacMaxClockSkewSeconds + seenNonces.entries.removeIf { it.value < cutoff } + } + + if (seenNonces.putIfAbsent(nonce, nowSeconds) != null) { + throw RemoteOperationException("replay_detected") + } + } + + private fun stringOrNumber(json: String, name: String): String? = + BackendJson.stringField(json, name) + ?: BackendJson.longField(json, name)?.toString() + + private fun hmacHex(secret: String, canonical: String): String { + val mac = Mac.getInstance(HMAC_ALGORITHM) + mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), HMAC_ALGORITHM)) + return mac.doFinal(canonical.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } + + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + + private fun constantTimeEquals(expected: String, actual: String): Boolean = + MessageDigest.isEqual( + expected.lowercase(Locale.ROOT).toByteArray(StandardCharsets.US_ASCII), + actual.lowercase(Locale.ROOT).toByteArray(StandardCharsets.US_ASCII) + ) + + private fun parseInstantOrNull(value: String): Instant? = + runCatching { Instant.parse(value) }.getOrNull() + + private data class HmacSignature( + val timestamp: String, + val nonce: String, + val value: String + ) +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationsAuditLog.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationsAuditLog.kt new file mode 100644 index 0000000000..afcb906c57 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationsAuditLog.kt @@ -0,0 +1,99 @@ +package com.willfp.ecoenchants.backend + +import com.willfp.ecoenchants.plugin +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.security.MessageDigest +import java.time.Instant +import java.util.UUID + +object RemoteOperationsAuditLog { + private val lock = Any() + + fun write(action: String, payload: Map<String, Any?> = emptyMap()) { + if (!BackendApiPolicy.remoteAuditLogEnabled) { + return + } + + synchronized(lock) { + runCatching { + val path = auditPath() + Files.createDirectories(path.parent) + + val previousHash = previousEntryHash(path) + val event = linkedMapOf<String, Any?>( + "auditId" to "aud_${UUID.randomUUID()}", + "createdAt" to Instant.now().toString(), + "action" to action, + "decision" to (payload["decision"] ?: "recorded"), + "payload" to payload, + "previousEntryHash" to previousHash + ) + val entryHash = sha256(BackendJson.toJson(event).toByteArray(StandardCharsets.UTF_8)) + event["entryHash"] = entryHash + + Files.writeString( + path, + "${BackendJson.toJson(event)}\n", + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND + ) + Files.writeString( + hashPath(path), + entryHash, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + }.onFailure { + plugin.logger.warning("Could not write EcoEnchants remote operations audit log: ${it.message}") + } + } + } + + private fun auditPath(): Path { + val root = plugin.dataFolder.toPath().toAbsolutePath().normalize() + Files.createDirectories(root) + + val path = root.resolve(BackendApiPolicy.remoteAuditLogFile).normalize() + if (!path.startsWith(root)) { + throw IllegalArgumentException("Remote operations audit log path must stay inside plugin data folder.") + } + + return path + } + + private fun previousEntryHash(path: Path): String? { + val sidecar = hashPath(path) + if (Files.isRegularFile(sidecar)) { + return Files.readString(sidecar, StandardCharsets.UTF_8).trim().ifBlank { null } + } + + if (!Files.isRegularFile(path)) { + return null + } + + var lastLine: String? = null + Files.newBufferedReader(path, StandardCharsets.UTF_8).use { reader -> + while (true) { + val line = reader.readLine() ?: break + if (line.isNotBlank()) { + lastLine = line + } + } + } + + return lastLine?.let { BackendJson.stringField(it, "entryHash") } + } + + private fun hashPath(path: Path): Path = + path.resolveSibling("${path.fileName}.sha256") + + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationsClient.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationsClient.kt new file mode 100644 index 0000000000..c31cc4c87a --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/backend/RemoteOperationsClient.kt @@ -0,0 +1,505 @@ +package com.willfp.ecoenchants.backend + +import com.willfp.eco.util.toNiceString +import com.willfp.ecoenchants.enchant.EcoEnchants +import com.willfp.ecoenchants.plugin +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.net.http.WebSocket +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +object RemoteOperationsClient { + private val stopping = AtomicBoolean(false) + private val reconnectAttempt = AtomicInteger(0) + + @Volatile + private var status: String = "not started" + + @Volatile + private var instanceId: String? = null + + @Volatile + private var policyVersion: String? = null + + @Volatile + private var webSocket: WebSocket? = null + + @Volatile + private var currentSessionToken: String? = null + + fun start() { + stop() + + if (!BackendApiPolicy.remoteOperationsEnabled) { + status = "disabled by config" + return + } + + stopping.set(false) + reconnectAttempt.set(0) + connectAsync() + } + + fun reload() { + stop() + start() + } + + fun stop() { + stopping.set(true) + webSocket?.abort() + webSocket = null + currentSessionToken = null + instanceId = null + policyVersion = null + status = "stopped" + } + + fun statusLines(): List<String> = listOf( + "Remote operations", + "Enabled: ${BackendApiPolicy.remoteOperationsEnabled}", + "Status: $status", + "Instance ID: ${instanceId ?: "unregistered"}", + "Policy version: ${policyVersion ?: "unknown"}", + "RPC URL: ${BackendApiPolicy.defaultRpcUrl}", + "Supported methods: ${supportedMethods().joinToString(", ")}" + ) + + private fun connectAsync() { + CompletableFuture.runAsync { + if (stopping.get()) { + return@runAsync + } + + val license = OnlineLicenseGate.lastResult as? LicenseCheckResult.Valid + val activationToken = license?.activationToken + if (activationToken.isNullOrBlank()) { + status = "waiting for activation token from license verification" + plugin.logger.warning( + "EcoEnchants remote operations are enabled, but the license response did not include activationToken." + ) + return@runAsync + } + + status = "registering" + val client = runCatching { + httpClient() + }.getOrElse { + status = "register failed: ${it.message}" + BackendApiTrace.failure("ops.register", "client-setup", message = "HTTP client setup failed: ${it.message}") + scheduleReconnect("HTTP client setup failed") + return@runAsync + } + + val registerRequestId = UUID.randomUUID().toString() + val registerPayload = registrationPayload() + val registerUri = URI.create("${BackendApiPolicy.versionedApiUrl}/ops/instances/register") + BackendApiTrace.request("ops.register", registerRequestId, "POST", registerUri, registerPayload) + val registerStartedAt = BackendApiTrace.mark() + val response = runCatching { + client.send( + registerRequest(activationToken, registerRequestId, registerUri, registerPayload), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8) + ) + }.getOrElse { + status = "register failed: ${it.message}" + BackendApiTrace.failure("ops.register", registerRequestId, registerStartedAt, "register failed: ${it.message}") + scheduleReconnect("register failed") + return@runAsync + } + + BackendApiTrace.response("ops.register", registerRequestId, response.statusCode(), registerStartedAt, response.body()) + if (response.statusCode() !in 200..299) { + status = "register failed: HTTP ${response.statusCode()}" + scheduleReconnect("register HTTP ${response.statusCode()}") + return@runAsync + } + + val body = response.body() + val registeredInstanceId = BackendJson.stringField(body, "instanceId") + val sessionToken = BackendJson.stringField(body, "sessionToken") + val rpcUrl = BackendJson.stringField(body, "rpcUrl") ?: BackendApiPolicy.defaultRpcUrl + + if (registeredInstanceId.isNullOrBlank() || sessionToken.isNullOrBlank()) { + status = "register failed: missing instanceId or sessionToken" + scheduleReconnect("register payload incomplete") + return@runAsync + } + + instanceId = registeredInstanceId + policyVersion = BackendJson.stringField(body, "policyVersion") + currentSessionToken = sessionToken + BackendApiTrace.event( + "ops.register", + "registered instanceId=$registeredInstanceId policyVersion=${policyVersion ?: "unknown"} rpcUrl=$rpcUrl" + ) + connectWebSocket(client, normalizeWebSocketUrl(rpcUrl), sessionToken) + } + } + + private fun connectWebSocket(client: HttpClient, rpcUrl: String, sessionToken: String) { + if (stopping.get()) { + return + } + + status = "connecting websocket" + val uri = runCatching { + URI.create(rpcUrl).also(RemoteOperationSecurity::requireSecureUri) + }.getOrElse { + status = "websocket failed: ${it.message}" + BackendApiTrace.failure("ops.websocket", "uri", message = "websocket URI rejected: ${it.message}") + scheduleReconnect("websocket URI rejected") + return + } + + val requestId = UUID.randomUUID().toString() + val builder = client.newWebSocketBuilder() + .connectTimeout(Duration.ofMillis(BackendApiPolicy.timeoutMillis.toLong())) + .header("Authorization", "Bearer $sessionToken") + .header("User-Agent", userAgent()) + .header("X-Request-Id", requestId) + + BackendApiTrace.request("ops.websocket", requestId, "GET", uri) + val startedAt = BackendApiTrace.mark() + RemoteOperationSecurity.signWebSocket( + builder, + uri, + sessionToken, + instanceId ?: OnlineLicenseGate.installationId() + ) + .buildAsync(uri, RpcListener()) + .whenComplete { socket, error -> + if (error != null) { + status = "websocket failed: ${error.message}" + BackendApiTrace.failure( + "ops.websocket", + requestId, + startedAt, + "websocket connect failed: ${error.message}" + ) + scheduleReconnect("websocket connect failed") + return@whenComplete + } + + webSocket = socket + BackendApiTrace.event("ops.websocket", "connected requestId=$requestId durationMs=${Duration.between(startedAt, Instant.now()).toMillis().coerceAtLeast(0)}") + } + } + + private fun registerRequest( + activationToken: String, + requestId: String, + uri: URI, + payload: String + ): HttpRequest { + RemoteOperationSecurity.requireSecureUri(uri) + + val builder = HttpRequest.newBuilder() + .uri(uri) + .timeout(Duration.ofMillis(BackendApiPolicy.timeoutMillis.toLong())) + .header("Authorization", "Bearer $activationToken") + .header("Content-Type", "application/json; charset=utf-8") + .header("User-Agent", userAgent()) + .header("X-Request-Id", requestId) + + RemoteOperationSecurity.signHttpRequest( + builder, + "POST", + uri, + payload, + activationToken, + (OnlineLicenseGate.lastResult as? LicenseCheckResult.Valid)?.activationId ?: OnlineLicenseGate.installationId() + ) + + return builder + .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)) + .build() + } + + private fun registrationPayload(): String = BackendJson.toJson( + mapOf( + "productId" to BackendApiPolicy.PRODUCT_ID, + "activationId" to ((OnlineLicenseGate.lastResult as? LicenseCheckResult.Valid)?.activationId ?: ""), + "installationId" to OnlineLicenseGate.installationId(), + "server" to mapOf( + "name" to if (BackendApiPolicy.sendServerName) plugin.server.name else null, + "platform" to plugin.server.name, + "platformVersion" to plugin.server.bukkitVersion, + "minecraftVersion" to plugin.server.minecraftVersion, + "onlineMode" to plugin.server.onlineMode, + "javaVersion" to System.getProperty("java.version") + ), + "plugin" to mapOf( + "version" to plugin.pluginMeta.version, + "channel" to BackendApiPolicy.channel + ), + "capabilities" to mapOf( + "fileOps" to BackendApiPolicy.remoteFileOpsEnabled, + "backupArchive" to BackendApiPolicy.remoteBackupsEnabled, + "redactedExport" to BackendApiPolicy.remoteFileOpsEnabled, + "supportedMethods" to supportedMethods() + ) + ) + ) + + private fun supportedMethods(): List<String> = listOf( + "ops.diagnostics.snapshot", + "ops.command.runManaged" + ) + RemoteFileOperations.supportedMethods() + + private fun handleMessage(socket: WebSocket, message: String) { + val method = BackendJson.stringField(message, "method") + val requestId = BackendJson.stringField(message, "requestId") ?: UUID.randomUUID().toString() + val jobId = BackendJson.stringField(message, "jobId") + + if (BackendJson.stringField(message, "type") == "rpc.ping") { + BackendApiTrace.event("ops.rpc", "received ping requestId=$requestId") + send(socket, mapOf("type" to "rpc.pong", "requestId" to requestId, "serverTime" to Instant.now().toString())) + return + } + + BackendApiTrace.event( + "ops.rpc", + "received requestId=$requestId jobId=${jobId ?: "none"} method=${method ?: "missing"} bytes=${message.toByteArray(StandardCharsets.UTF_8).size}" + ) + runCatching { + RemoteOperationSecurity.verifyRpcMessage(message, currentSessionToken) + }.onFailure { + val code = (it as? RemoteOperationException)?.code ?: "signature_invalid" + BackendApiTrace.failure("ops.rpc", requestId, message = "signature verification failed code=$code message=${it.message ?: code}") + sendFailure(socket, requestId, jobId, code, it.message ?: code) + return + } + + if (method.isNullOrBlank()) { + sendFailure(socket, requestId, jobId, "missing_method", "RPC method is required.") + return + } + + send( + socket, + mapOf( + "type" to "rpc.ack", + "requestId" to requestId, + "jobId" to jobId, + "status" to "accepted", + "acceptedAt" to Instant.now().toString() + ) + ) + + CompletableFuture.runAsync { + val result = runCatching { + execute(method, message, jobId) + } + + result.onSuccess { + BackendApiTrace.event( + "ops.rpc", + "succeeded requestId=$requestId jobId=${jobId ?: "none"} method=$method" + ) + send( + socket, + mapOf( + "type" to "rpc.result", + "requestId" to requestId, + "jobId" to jobId, + "status" to "succeeded", + "result" to it, + "completedAt" to Instant.now().toString() + ) + ) + }.onFailure { + val code = (it as? RemoteOperationException)?.code ?: "operation_failed" + BackendApiTrace.failure("ops.rpc", requestId, message = "failed jobId=${jobId ?: "none"} method=$method code=$code message=${it.message ?: code}") + sendFailure(socket, requestId, jobId, code, it.message ?: code) + } + } + } + + private fun execute(method: String, message: String, jobId: String?): Map<String, Any?> { + RemoteOperationsAuditLog.write( + "rpc.request", + mapOf("jobId" to jobId, "method" to method) + ) + + return when (method) { + "ops.diagnostics.snapshot" -> diagnosticsSnapshot() + "ops.command.runManaged" -> runManagedCommand(message) + "ops.file.read" -> RemoteFileOperations.read(message, jobId) + "ops.file.write" -> RemoteFileOperations.write(message, jobId) + "ops.file.delete" -> RemoteFileOperations.delete(message, jobId) + "ops.backup.create" -> RemoteFileOperations.createBackup(message, jobId) + "ops.backup.restore" -> RemoteFileOperations.restoreBackup(message, jobId) + else -> throw RemoteOperationException("unsupported_method") + } + } + + private fun runManagedCommand(message: String): Map<String, Any?> { + val commandId = BackendJson.stringField(message, "commandId") ?: throw RemoteOperationException("missing_command_id") + + return when (commandId) { + "ecoenchants.reload" -> runReload() + "ecoenchants.services.status" -> mapOf( + "commandId" to commandId, + "lines" to (BackendApiPolicy.statusLines() + statusLines()) + ) + else -> throw RemoteOperationException("command_not_allowed") + } + } + + private fun runReload(): Map<String, Any?> { + val future = CompletableFuture<Map<String, Any?>>() + plugin.scheduler.run { + runCatching { + val time = plugin.reloadWithTime() + mapOf( + "commandId" to "ecoenchants.reload", + "time" to time.toNiceString(), + "enchantCount" to EcoEnchants.values().size + ) + }.onSuccess { + future.complete(it) + }.onFailure { + future.completeExceptionally(it) + } + } + return future.get(30, TimeUnit.SECONDS) + } + + private fun diagnosticsSnapshot(): Map<String, Any?> = mapOf( + "productId" to BackendApiPolicy.PRODUCT_ID, + "plugin" to mapOf( + "version" to plugin.pluginMeta.version, + "loaded" to plugin.isLoaded, + "enchantCount" to EcoEnchants.values().size + ), + "server" to mapOf( + "platform" to plugin.server.name, + "bukkitVersion" to plugin.server.bukkitVersion, + "minecraftVersion" to plugin.server.minecraftVersion, + "onlineMode" to plugin.server.onlineMode, + "onlinePlayers" to plugin.server.onlinePlayers.size, + "maxPlayers" to plugin.server.maxPlayers + ), + "license" to OnlineLicenseGate.lastResult.summary, + "remoteOperations" to status + ) + + private fun sendHello(socket: WebSocket) { + send( + socket, + mapOf( + "type" to "rpc.hello", + "requestId" to UUID.randomUUID().toString(), + "instanceId" to instanceId, + "policyVersion" to policyVersion, + "supportedMethods" to supportedMethods() + ) + ) + } + + private fun sendFailure( + socket: WebSocket, + requestId: String, + jobId: String?, + code: String, + message: String + ) { + send( + socket, + mapOf( + "type" to "rpc.result", + "requestId" to requestId, + "jobId" to jobId, + "status" to "failed", + "error" to mapOf( + "code" to code, + "message" to message + ), + "completedAt" to Instant.now().toString() + ) + ) + } + + private fun send(socket: WebSocket, payload: Map<String, Any?>) { + socket.sendText(BackendJson.toJson(payload), true) + } + + private fun scheduleReconnect(reason: String) { + if (stopping.get()) { + return + } + + val attempt = reconnectAttempt.getAndIncrement().coerceAtMost(6) + val min = BackendApiPolicy.remoteOperationsReconnectMinSeconds + val max = BackendApiPolicy.remoteOperationsReconnectMaxSeconds.coerceAtLeast(min) + val delay = (min * (1L shl attempt)).coerceAtMost(max) + status = "reconnecting in ${delay}s ($reason)" + BackendApiTrace.event("ops.reconnect", "attempt=${attempt + 1} delaySeconds=$delay reason=$reason") + + CompletableFuture.delayedExecutor(delay, TimeUnit.SECONDS).execute { + if (!stopping.get()) { + connectAsync() + } + } + } + + private fun normalizeWebSocketUrl(url: String): String = when { + url.startsWith("https://", ignoreCase = true) -> "wss://${url.substringAfter("://")}" + url.startsWith("http://", ignoreCase = true) -> "ws://${url.substringAfter("://")}" + else -> url + } + + private fun httpClient(): HttpClient = + RemoteOperationSecurity.configureClient(HttpClient.newBuilder()) + .connectTimeout(Duration.ofMillis(BackendApiPolicy.timeoutMillis.toLong())) + .build() + + private fun userAgent(): String { + return "EcoEnchants/${plugin.pluginMeta.version} ${plugin.server.name}/${plugin.server.bukkitVersion} " + + "Java/${System.getProperty("java.version")}" + } + + private class RpcListener : WebSocket.Listener { + private val buffer = StringBuilder() + + override fun onOpen(webSocket: WebSocket) { + status = "connected" + reconnectAttempt.set(0) + webSocket.request(1) + sendHello(webSocket) + } + + override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*>? { + buffer.append(data) + if (last) { + val message = buffer.toString() + buffer.setLength(0) + handleMessage(webSocket, message) + } + webSocket.request(1) + return null + } + + override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String): CompletionStage<*>? { + status = "closed: $statusCode $reason" + scheduleReconnect("websocket closed") + return null + } + + override fun onError(webSocket: WebSocket, error: Throwable) { + status = "websocket error: ${error.message}" + scheduleReconnect("websocket error") + } + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEcoEnchants.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEcoEnchants.kt index 07f3ddc693..a9c1d513bc 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEcoEnchants.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEcoEnchants.kt @@ -1,6 +1,7 @@ package com.willfp.ecoenchants.commands import com.willfp.eco.core.command.impl.PluginCommand +import com.willfp.ecoenchants.experience.PlayerExperience import com.willfp.ecoenchants.plugin import org.bukkit.command.CommandSender @@ -11,15 +12,23 @@ object CommandEcoEnchants : PluginCommand( false ) { override fun onExecute(sender: CommandSender, args: List<String>) { - sender.sendMessage( - plugin.langYml.getMessage("invalid-command") - ) + if (args.isNotEmpty()) { + sender.sendMessage(plugin.langYml.getMessage("invalid-command")) + } + + PlayerExperience.sendHelp(sender) } init { - addSubcommand(CommandReload) + addSubcommand(CommandHelp) + .addSubcommand(CommandGuide) + .addSubcommand(CommandSearch) + .addSubcommand(CommandFavorites) + .addSubcommand(CommandExperience) + .addSubcommand(CommandReload) .addSubcommand(CommandToggleDescriptions) .addSubcommand(CommandGiveRandomBook) .addSubcommand(CommandGUI) + .addSubcommand(CommandServices) } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEnchant.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEnchant.kt index e7bea1bd27..5cdef6a114 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEnchant.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEnchant.kt @@ -1,12 +1,14 @@ package com.willfp.ecoenchants.commands import com.willfp.eco.core.command.impl.PluginCommand +import com.willfp.eco.core.items.isEcoEmpty import com.willfp.eco.util.StringUtils import com.willfp.eco.util.savedDisplayName import com.willfp.ecoenchants.display.getFormattedName +import com.willfp.ecoenchants.enchant.getEnchantmentByID import com.willfp.ecoenchants.enchant.wrap import com.willfp.ecoenchants.plugin -import org.bukkit.NamespacedKey +import org.bukkit.Bukkit import org.bukkit.command.CommandSender import org.bukkit.enchantments.Enchantment import org.bukkit.entity.Player @@ -19,28 +21,78 @@ object CommandEnchant : PluginCommand( "ecoenchants.command.enchant", false ) { + private var enchantmentCompletions: List<String> = emptyList() + private var levelCompletionsByEnchant = emptyMap<String, List<String>>() + private val defaultLevelCompletions = (0..5).map { it.toString() } + + internal fun reload() { + @Suppress("DEPRECATION") + val enchantments = Enchantment.values() + + enchantmentCompletions = enchantments.map { it.key.key } + levelCompletionsByEnchant = enchantments.associate { + it.key.key to (0..it.maxLevel).map { level -> level.toString() } + } + } + override fun onExecute(sender: CommandSender, rawArgs: List<String>) { - var args = rawArgs - var player = sender as? Player + val usageMessage = if (sender is Player) { + "enchant-usage" + } else { + "enchant-usage-console" + } - if (sender !is Player) { - player = notifyPlayerRequired(args.getOrNull(0), "invalid-player") - args = rawArgs.subList(1, rawArgs.size) + val (player, args) = if (sender is Player) { + sender to rawArgs + } else { + val playerName = rawArgs.getOrNull(0) + if (playerName == null) { + sender.sendMessage(plugin.langYml.getMessage(usageMessage)) + return + } + + val target = Bukkit.getPlayer(playerName) + if (target == null) { + sender.sendMessage(plugin.langYml.getMessage("invalid-player")) + return + } + + target to rawArgs.drop(1) } - player!! // Unbelievable jank + val enchantName = args.getOrNull(0) + if (enchantName == null) { + sender.sendMessage(plugin.langYml.getMessage(usageMessage)) + return + } - val enchant = notifyNull( - @Suppress("DEPRECATION") - args.getOrNull(0)?.lowercase()?.let { Enchantment.getByKey(NamespacedKey.minecraft(it)) }, - "invalid-enchantment" - ) + val enchant = getEnchantmentByID(enchantName.lowercase()) + if (enchant == null) { + sender.sendMessage(plugin.langYml.getMessage("invalid-enchantment")) + sender.sendMessage(plugin.langYml.getMessage(usageMessage)) + return + } - val level = args.getOrNull(1)?.toIntOrNull() ?: 1 + val levelArg = args.getOrNull(1) + val level = if (levelArg == null) { + 1 + } else { + levelArg.toIntOrNull() ?: run { + sender.sendMessage(plugin.langYml.getMessage("invalid-level")) + sender.sendMessage(plugin.langYml.getMessage(usageMessage)) + return + } + } val item = player.inventory.itemInMainHand - val meta = item.itemMeta + if (item.isEcoEmpty || meta == null) { + sender.sendMessage( + plugin.langYml.getMessage("requires-held-item", StringUtils.FormatOption.WITHOUT_PLACEHOLDERS) + .replace("%player%", player.savedDisplayName) + ) + return + } if (level > 0) { if (meta is EnchantmentStorageMeta) { @@ -70,41 +122,53 @@ object CommandEnchant : PluginCommand( } override fun tabComplete(sender: CommandSender, rawArgs: List<String>): List<String> { + if (enchantmentCompletions.isEmpty()) { + reload() + } + val completions = mutableListOf<String>() - var args = rawArgs + val args = if (sender !is Player) { + if (rawArgs.size <= 1) { + StringUtil.copyPartialMatches( + rawArgs.getOrNull(0) ?: "", + Bukkit.getOnlinePlayers().map { it.name }, + completions + ) + completions.sort() + return completions + } - if (sender !is Player) { - args = rawArgs.subList(1, rawArgs.size) + rawArgs.drop(1) + } else { + rawArgs } - if (args.size == 1) { + if (args.isEmpty()) { + completions.addAll(enchantmentCompletions) + } else if (args.size == 1) { StringUtil.copyPartialMatches( args[0], - @Suppress("DEPRECATION") - Enchantment.values().map { it.key.key }, + enchantmentCompletions, completions ) - } - - if (args.size == 2) { - @Suppress("DEPRECATION") - val enchant = Enchantment.getByKey(NamespacedKey.minecraft(args[0].lowercase())) + } else if (args.size == 2) { + val enchant = getEnchantmentByID(args[0].lowercase()) val levels = if (enchant != null) { - val maxLevel = enchant.maxLevel - (0..maxLevel).toList() + levelCompletionsByEnchant[enchant.key.key] ?: defaultLevelCompletions } else { - (0..5).toList() + defaultLevelCompletions } StringUtil.copyPartialMatches( args[1], - levels.map { it.toString() }, + levels, completions ) } + completions.sort() return completions } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEnchantInfo.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEnchantInfo.kt index 60cbbc8baa..a12cd31afc 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEnchantInfo.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandEnchantInfo.kt @@ -1,10 +1,14 @@ package com.willfp.ecoenchants.commands import com.willfp.eco.core.command.impl.PluginCommand +import com.willfp.eco.core.fast.fast import com.willfp.ecoenchants.display.getFormattedName import com.willfp.ecoenchants.enchant.EcoEnchants import com.willfp.ecoenchants.enchant.EnchantGUI +import com.willfp.ecoenchants.experience.PlayerExperience import com.willfp.ecoenchants.plugin +import com.willfp.ecoenchants.sendClickableLine +import com.willfp.ecoenchants.stripLegacyFormatting import org.bukkit.command.CommandSender import org.bukkit.entity.Player import org.bukkit.util.StringUtil @@ -15,11 +19,35 @@ object CommandEnchantInfo : PluginCommand( "ecoenchants.command.enchantinfo", true ) { + private var enchantmentCompletions: List<String> = emptyList() + private var levelCompletionsByName = emptyMap<String, List<String>>() + private var hiddenEnchantNames = emptySet<String>() + + internal fun reload() { + val namesWithEnchantments = EcoEnchants.values().map { enchantment -> + enchantment.getFormattedName(0).stripLegacyFormatting() to enchantment + } + + enchantmentCompletions = namesWithEnchantments.map { it.first } + levelCompletionsByName = namesWithEnchantments.associate { (name, enchantment) -> + name.lowercase() to (1..enchantment.maximumLevel).map { it.toString() } + } + hiddenEnchantNames = namesWithEnchantments + .filter { it.second.isHiddenFromGui } + .map { it.first.lowercase() } + .toSet() + } + override fun onExecute(sender: CommandSender, args: List<String>) { sender as Player if (args.isEmpty()) { + if (showHeldItemEnchants(sender)) { + return + } + sender.sendMessage(this.plugin.langYml.getMessage("missing-enchant")) + sender.sendMessage(this.plugin.langYml.getMessage("enchantinfo-usage")) return } @@ -32,36 +60,86 @@ object CommandEnchantInfo : PluginCommand( if (enchantment == null || (enchantment.isHiddenFromGui && !sender.hasPermission("ecoenchants.seehidden"))) { val message = plugin.langYml.getMessage("not-found").replace("%name%", searchName) sender.sendMessage(message) + sender.sendMessage(plugin.langYml.getMessage("enchantinfo-browse-hint")) return } EnchantGUI.openInfoGUI(sender, enchantment, level ?: -1) } + /** + * Lists the EcoEnchants on the player's main-hand item as clickable chat lines + * (each reopens the info GUI via /enchantinfo). Returns false when the held item + * has no EcoEnchants enchantments so the caller can fall back to the usage message. + */ + private fun showHeldItemEnchants(player: Player): Boolean { + val item = player.inventory.itemInMainHand + if (item.type.isAir) { + return false + } + + val canSeeHidden = player.hasPermission("ecoenchants.seehidden") + val ecoByEnchantment = EcoEnchants.values().associateBy { it.enchantment } + val onItem = item.fast().getEnchants(true) + .mapNotNull { (enchantment, level) -> ecoByEnchantment[enchantment]?.let { it to level } } + .filter { (enchant, _) -> !enchant.isHiddenFromGui || canSeeHidden } + .sortedBy { it.first.getFormattedName(0).stripLegacyFormatting().lowercase() } + + if (onItem.isEmpty()) { + return false + } + + PlayerExperience.sendLangLines( + player, + "commands.enchantinfo.held-header", + "count" to onItem.size.toString() + ) + + val line = plugin.langYml.getStrings("commands.enchantinfo.held-line").firstOrNull() ?: "&7- %enchant%" + val hover = plugin.langYml.getStrings("commands.enchantinfo.held-hover").firstOrNull() + + for ((enchant, level) in onItem) { + val plainName = enchant.getFormattedName(0).stripLegacyFormatting() + player.sendClickableLine( + line.replace("%enchant%", enchant.getFormattedName(level)), + "/enchantinfo $plainName $level", + hover?.replace("%enchant%", enchant.getFormattedName(level)) + ) + } + + return true + } + override fun tabComplete(sender: CommandSender, args: List<String>): List<String> { - val completions = mutableListOf<String>() + if (enchantmentCompletions.isEmpty()) { + reload() + } + val completions = mutableListOf<String>() val canSeeHidden = sender.hasPermission("ecoenchants.seehidden") - @Suppress("DEPRECATION") - val names = EcoEnchants.values().filter { !it.isHiddenFromGui || canSeeHidden }.mapNotNull { org.bukkit.ChatColor.stripColor(it.getFormattedName(0)) } + val visibleCompletions = if (canSeeHidden) { + enchantmentCompletions + } else { + enchantmentCompletions.filterNot { it.lowercase() in hiddenEnchantNames } + } if (args.isEmpty()) { // Currently, this case is not ever reached - return names + return visibleCompletions } // If all args except the last form a complete enchant name, suggest level numbers if (args.size > 1) { val namePrefix = args.dropLast(1).joinToString(" ") + val levels = levelCompletionsByName[namePrefix.lowercase()] val matched = EcoEnchants.getByName(namePrefix) - if (matched != null && (!matched.isHiddenFromGui || canSeeHidden)) { - val levels = (1..matched.maximumLevel).map { it.toString() } + if (levels != null && matched != null && (!matched.isHiddenFromGui || canSeeHidden)) { StringUtil.copyPartialMatches(args.last(), levels, completions) return completions } } - StringUtil.copyPartialMatches(args.joinToString(" "), names, completions) + StringUtil.copyPartialMatches(args.joinToString(" "), visibleCompletions, completions) if (args.size > 1) { val prefix = args.dropLast(1).joinToString(" ") + " " diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandExperience.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandExperience.kt new file mode 100644 index 0000000000..e39ed3c815 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandExperience.kt @@ -0,0 +1,19 @@ +package com.willfp.ecoenchants.commands + +import com.willfp.eco.core.command.impl.Subcommand +import com.willfp.ecoenchants.experience.PlayerExperience +import com.willfp.ecoenchants.plugin +import org.bukkit.command.CommandSender + +object CommandExperience : Subcommand( + plugin, + "experience", + "ecoenchants.command.experience", + false +) { + override fun onExecute(sender: CommandSender, args: List<String>) { + for (line in PlayerExperience.statusLines()) { + sender.sendMessage(line) + } + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandFavorites.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandFavorites.kt new file mode 100644 index 0000000000..100635e804 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandFavorites.kt @@ -0,0 +1,49 @@ +package com.willfp.ecoenchants.commands + +import com.willfp.eco.core.command.impl.Subcommand +import com.willfp.ecoenchants.display.getFormattedName +import com.willfp.ecoenchants.experience.Favorites +import com.willfp.ecoenchants.experience.PlayerExperience +import com.willfp.ecoenchants.plugin +import com.willfp.ecoenchants.sendClickableLine +import com.willfp.ecoenchants.stripLegacyFormatting +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +object CommandFavorites : Subcommand( + plugin, + "favorites", + "ecoenchants.command.favorites", + true +) { + override fun onExecute(sender: CommandSender, args: List<String>) { + sender as Player + + val favorites = Favorites.list(sender) + .sortedBy { it.getFormattedName(0).stripLegacyFormatting().lowercase() } + + if (favorites.isEmpty()) { + PlayerExperience.sendLangLines(sender, "commands.favorites.empty") + return + } + + PlayerExperience.sendLangLines( + sender, + "commands.favorites.header", + "count" to favorites.size.toString() + ) + + val line = plugin.langYml.getStrings("commands.favorites.line").firstOrNull() ?: "&7- %enchant%" + val hover = plugin.langYml.getStrings("commands.favorites.hover").firstOrNull() + + for (enchant in favorites) { + val level = enchant.maximumLevel + val plainName = enchant.getFormattedName(0).stripLegacyFormatting() + sender.sendClickableLine( + line.replace("%enchant%", enchant.getFormattedName(level)), + "/enchantinfo $plainName $level", + hover?.replace("%enchant%", enchant.getFormattedName(level)) + ) + } + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandGiveRandomBook.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandGiveRandomBook.kt index 77567cec68..00a8b95ca4 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandGiveRandomBook.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandGiveRandomBook.kt @@ -14,6 +14,7 @@ import com.willfp.ecoenchants.type.EnchantmentType import com.willfp.ecoenchants.type.EnchantmentTypes import org.bukkit.Bukkit import org.bukkit.command.CommandSender +import org.bukkit.util.StringUtil object CommandGiveRandomBook : PluginCommand( plugin, @@ -26,6 +27,7 @@ object CommandGiveRandomBook : PluginCommand( if (playerName == null) { sender.sendMessage(plugin.langYml.getMessage("requires-player")) + sender.sendMessage(plugin.langYml.getMessage("giverandombook-usage")) return } @@ -39,14 +41,42 @@ object CommandGiveRandomBook : PluginCommand( val filterName = args.getOrNull(1) val filter = if (filterName != null) { - EnchantmentTypes[filterName] ?: EnchantmentRarities[filterName] + val normalizedFilterName = filterName.lowercase() + EnchantmentTypes[normalizedFilterName] ?: EnchantmentRarities[normalizedFilterName] ?: run { + sender.sendMessage(plugin.langYml.getMessage("invalid-filter")) + sender.sendMessage(plugin.langYml.getMessage("giverandombook-usage")) + return + } } else null - val minLevel = args.getOrNull(2)?.toIntOrNull() ?: 1 - val maxLevel = args.getOrNull(3)?.toIntOrNull() ?: Int.MAX_VALUE + val minLevel = args.getOrNull(2)?.toIntOrNull() ?: run { + if (args.size > 2) { + sender.sendMessage(plugin.langYml.getMessage("invalid-level")) + sender.sendMessage(plugin.langYml.getMessage("giverandombook-usage")) + return + } + + 1 + } + val maxLevel = args.getOrNull(3)?.toIntOrNull() ?: run { + if (args.size > 3) { + sender.sendMessage(plugin.langYml.getMessage("invalid-level")) + sender.sendMessage(plugin.langYml.getMessage("giverandombook-usage")) + return + } + + Int.MAX_VALUE + } if (minLevel > maxLevel) { sender.sendMessage(plugin.langYml.getMessage("invalid-levels")) + sender.sendMessage(plugin.langYml.getMessage("giverandombook-usage")) + return + } + + if (minLevel < 1 || maxLevel < 1) { + sender.sendMessage(plugin.langYml.getMessage("invalid-book-levels")) + sender.sendMessage(plugin.langYml.getMessage("giverandombook-usage")) return } @@ -82,8 +112,8 @@ object CommandGiveRandomBook : PluginCommand( } override fun tabComplete(sender: CommandSender, args: List<String>): List<String> { - // OfTeN wrote this - it's cursed, and I am *not* going to try refactor this. - return when (args.size) { + val completions = mutableListOf<String>() + val options = when (args.size) { 1 -> Bukkit.getOnlinePlayers().map { it.name } 2 -> (EnchantmentRarities.values().map { it.id } + EnchantmentTypes.values().map { it.id }) 3 -> (1..10).map { it.toString() } @@ -95,5 +125,9 @@ object CommandGiveRandomBook : PluginCommand( else -> emptyList() } + + StringUtil.copyPartialMatches(args.lastOrNull() ?: "", options, completions) + completions.sort() + return completions } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandGuide.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandGuide.kt new file mode 100644 index 0000000000..f722f64c9e --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandGuide.kt @@ -0,0 +1,31 @@ +package com.willfp.ecoenchants.commands + +import com.willfp.eco.core.command.impl.Subcommand +import com.willfp.ecoenchants.experience.PlayerExperience +import com.willfp.ecoenchants.plugin +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +object CommandGuide : Subcommand( + plugin, + "guide", + "ecoenchants.command.guide", + false +) { + override fun onExecute(sender: CommandSender, args: List<String>) { + if (args.firstOrNull().equals("book", ignoreCase = true) && sender is Player) { + PlayerExperience.giveGuideBook(sender) + return + } + + PlayerExperience.sendGuide(sender) + } + + override fun tabComplete(sender: CommandSender, args: List<String>): List<String> { + return if (args.size == 1 && sender is Player) { + listOf("book").filter { it.startsWith(args[0], ignoreCase = true) } + } else { + emptyList() + } + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandHelp.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandHelp.kt new file mode 100644 index 0000000000..74894bb62c --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandHelp.kt @@ -0,0 +1,17 @@ +package com.willfp.ecoenchants.commands + +import com.willfp.eco.core.command.impl.Subcommand +import com.willfp.ecoenchants.experience.PlayerExperience +import com.willfp.ecoenchants.plugin +import org.bukkit.command.CommandSender + +object CommandHelp : Subcommand( + plugin, + "help", + "ecoenchants.command.ecoenchants", + false +) { + override fun onExecute(sender: CommandSender, args: List<String>) { + PlayerExperience.sendHelp(sender) + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandSearch.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandSearch.kt new file mode 100644 index 0000000000..1fe977888b --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandSearch.kt @@ -0,0 +1,77 @@ +package com.willfp.ecoenchants.commands + +import com.willfp.eco.core.command.impl.Subcommand +import com.willfp.ecoenchants.display.getFormattedName +import com.willfp.ecoenchants.enchant.EcoEnchants +import com.willfp.ecoenchants.experience.PlayerExperience +import com.willfp.ecoenchants.plugin +import com.willfp.ecoenchants.sendClickableLine +import com.willfp.ecoenchants.stripLegacyFormatting +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player +import org.bukkit.util.StringUtil + +object CommandSearch : Subcommand( + plugin, + "search", + "ecoenchants.command.search", + false +) { + override fun onExecute(sender: CommandSender, args: List<String>) { + if (args.isEmpty()) { + PlayerExperience.sendLangLines(sender, "commands.search.usage") + return + } + + val query = args.joinToString(" ").trim() + + val matches = EcoEnchants.values() + .map { it to it.getFormattedName(0).stripLegacyFormatting() } + .filter { it.second.contains(query, ignoreCase = true) } + .sortedBy { it.second.lowercase() } + .take(plugin.configYml.getInt("player-experience.search.max-results").coerceAtLeast(1)) + + if (matches.isEmpty()) { + PlayerExperience.sendLangLines(sender, "commands.search.no-results", "query" to query) + return + } + + PlayerExperience.sendLangLines( + sender, + "commands.search.header", + "query" to query, + "count" to matches.size.toString() + ) + + val resultLine = plugin.langYml.getStrings("commands.search.result").firstOrNull() + + for ((enchant, plainName) in matches) { + val level = enchant.maximumLevel + val line = (resultLine ?: "&7- %enchant%").replace("%enchant%", enchant.getFormattedName(level)) + + if (sender is Player) { + sender.sendClickableLine( + line, + "/enchantinfo $plainName $level", + plugin.langYml.getStrings("commands.search.result-hover") + .firstOrNull() + ?.replace("%enchant%", enchant.getFormattedName(level)) + ) + } else { + PlayerExperience.sendLangLines(sender, "commands.search.result", "enchant" to enchant.getFormattedName(level)) + } + } + } + + override fun tabComplete(sender: CommandSender, args: List<String>): List<String> { + if (args.isEmpty()) { + return emptyList() + } + + val completions = mutableListOf<String>() + val names = EcoEnchants.values().map { it.getFormattedName(0).stripLegacyFormatting() } + StringUtil.copyPartialMatches(args.joinToString(" "), names, completions) + completions.sort() + return completions + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandServices.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandServices.kt new file mode 100644 index 0000000000..e7a9e83abb --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/commands/CommandServices.kt @@ -0,0 +1,35 @@ +package com.willfp.ecoenchants.commands + +import com.willfp.eco.core.command.impl.Subcommand +import com.willfp.ecoenchants.backend.BackendApiPolicy +import com.willfp.ecoenchants.backend.RemoteOperationsClient +import com.willfp.ecoenchants.plugin +import com.willfp.ecoenchants.telemetry.EnvironmentRiskProbe +import com.willfp.ecoenchants.telemetry.RuntimeTelemetryPolicy +import com.willfp.ecoenchants.telemetry.TelemetryReporter +import org.bukkit.command.CommandSender + +object CommandServices : Subcommand( + plugin, + "services", + "ecoenchants.command.services", + false +) { + override fun onExecute(sender: CommandSender, args: List<String>) { + for (line in BackendApiPolicy.statusLines()) { + sender.sendMessage(line) + } + for (line in RemoteOperationsClient.statusLines()) { + sender.sendMessage(line) + } + for (line in RuntimeTelemetryPolicy.statusLines()) { + sender.sendMessage(line) + } + for (line in TelemetryReporter.statusLines()) { + sender.sendMessage(line) + } + for (line in EnvironmentRiskProbe.statusLines()) { + sender.sendMessage(line) + } + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/EnchantDisplay.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/EnchantDisplay.kt index 2458ee0a7a..87cb90651c 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/EnchantDisplay.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/EnchantDisplay.kt @@ -6,13 +6,20 @@ import com.willfp.eco.core.display.DisplayPriority import com.willfp.eco.core.display.DisplayProperties import com.willfp.eco.core.fast.FastItemStack import com.willfp.eco.core.fast.fast +import com.willfp.eco.util.formatEco import com.willfp.ecoenchants.commands.CommandToggleDescriptions.seesEnchantmentDescriptions import com.willfp.ecoenchants.display.EnchantSorter.sortForDisplay import com.willfp.ecoenchants.enchant.EcoEnchant import com.willfp.ecoenchants.enchant.wrap import com.willfp.ecoenchants.plugin import com.willfp.ecoenchants.target.EnchantmentTargets.isEnchantable +import com.willfp.libreforge.Dispatcher import com.willfp.libreforge.ItemProvidedHolder +import com.willfp.libreforge.ProvidedHolder +import com.willfp.libreforge.applyHolder +import com.willfp.libreforge.conditions.ConditionList +import com.willfp.libreforge.toDispatcher +import com.willfp.libreforge.toPlaceholderContext import org.bukkit.Material import org.bukkit.entity.Player import org.bukkit.inventory.ItemFlag @@ -32,6 +39,18 @@ object EnchantDisplay : DisplayModule(plugin, DisplayPriority.HIGH) { private val hideStateKey = plugin.namespacedKeyFactory.create("ecoenchantlore-skip") // Same for backwards compatibility + private val originalHideEnchantsKey = + plugin.namespacedKeyFactory.create("ecoenchantlore-original-hide-enchants") + + private val originalHideStoredEnchantsKey = + plugin.namespacedKeyFactory.create("ecoenchantlore-original-hide-stored-enchants") + + private val addedHideEnchantsKey = + plugin.namespacedKeyFactory.create("ecoenchantlore-added-hide-enchants") + + private val addedHideStoredEnchantsKey = + plugin.namespacedKeyFactory.create("ecoenchantlore-added-hide-stored-enchants") + private val hse = plugin.getProxy(HideStoredEnchantsProxy::class.java) override fun display( @@ -40,18 +59,47 @@ object EnchantDisplay : DisplayModule(plugin, DisplayPriority.HIGH) { props: DisplayProperties, vararg args: Any ) { - if (!itemStack.isEnchantable && plugin.configYml.getBool("display.require-enchantable")) { + val config = plugin.configYml + val requireEnchantable = config.getBool("display.require-enchantable") + + if (!itemStack.isEnchantable && requireEnchantable) { return } val fast = itemStack.fast() val pdc = fast.persistentDataContainer + // Get enchants mapped to EcoEnchantLike + val unsorted = fast.getEnchants(true) + if (unsorted.isEmpty()) { + return + } + + val originalHideState = fast.getOriginalHideState(args, itemStack.type == Material.ENCHANTED_BOOK) + + pdc.set(originalHideEnchantsKey, PersistentDataType.INTEGER, originalHideState.hidesEnchants.toStoredInt()) + pdc.set( + originalHideStoredEnchantsKey, + PersistentDataType.INTEGER, + originalHideState.hidesStoredEnchants.toStoredInt() + ) + // Args represent hide enchants - if (args[0] == true) { - fast.addItemFlags(ItemFlag.HIDE_ENCHANTS) + if (originalHideState.hidesEnchants || originalHideState.hidesStoredEnchants) { + if (!originalHideState.hidesEnchants) { + fast.addItemFlags(ItemFlag.HIDE_ENCHANTS) + pdc.set(addedHideEnchantsKey, PersistentDataType.INTEGER, 1) + } else { + pdc.set(addedHideEnchantsKey, PersistentDataType.INTEGER, 0) + } + if (itemStack.type == Material.ENCHANTED_BOOK) { - hse.hideStoredEnchants(fast) + if (!originalHideState.hidesStoredEnchants) { + hse.hideStoredEnchants(fast) + pdc.set(addedHideStoredEnchantsKey, PersistentDataType.INTEGER, 1) + } else { + pdc.set(addedHideStoredEnchantsKey, PersistentDataType.INTEGER, 0) + } } pdc.set(hideStateKey, PersistentDataType.INTEGER, 1) return @@ -61,18 +109,23 @@ object EnchantDisplay : DisplayModule(plugin, DisplayPriority.HIGH) { val lore = fast.lore val enchantLore = mutableListOf<String>() - - // Get enchants mapped to EcoEnchantLike - val unsorted = fast.getEnchants(true) val enchants = unsorted.keys.sortForDisplay() .associateWith { unsorted[it]!! } - val shouldCollapse = plugin.configYml.getBool("display.collapse.enabled") && - enchants.size > plugin.configYml.getInt("display.collapse.threshold") + val collapseEnabled = config.getBool("display.collapse.enabled") + val collapseThreshold = config.getInt("display.collapse.threshold") + val collapsePerLine = config.getInt("display.collapse.per-line") + val collapseDelimiter = config.getFormattedString("display.collapse.delimiter") + val descriptionsEnabled = config.getBool("display.descriptions.enabled") + val descriptionsThreshold = config.getInt("display.descriptions.threshold") + val enchantmentsBelowLore = config.getBool("display.enchantments-below-lore") + val playerDispatcher = player?.toDispatcher() - val shouldDescribe = (plugin.configYml.getBool("display.descriptions.enabled") && - enchants.size <= plugin.configYml.getInt("display.descriptions.threshold") - && player?.seesEnchantmentDescriptions ?: true) + val shouldCollapse = collapseEnabled && enchants.size > collapseThreshold + + val shouldDescribe = descriptionsEnabled && + enchants.size <= descriptionsThreshold && + (player?.seesEnchantmentDescriptions ?: true) val formattedNames = mutableMapOf<DisplayableEnchant, String>() @@ -80,28 +133,25 @@ object EnchantDisplay : DisplayModule(plugin, DisplayPriority.HIGH) { for ((enchant, level) in enchants) { var showNotMet = false - if (player != null && enchant is EcoEnchant) { + if (playerDispatcher != null && enchant is EcoEnchant) { val enchantLevel = enchant.getLevel(level) val holder = ItemProvidedHolder(enchantLevel, itemStack) - val enchantNotMetLines = holder.getNotMetLines(player).map { Display.PREFIX + it } - notMetLines.addAll(enchantNotMetLines) - - if (enchantNotMetLines.isNotEmpty() || holder.isShowingAnyNotMet(player)) { - showNotMet = true - } + val notMetDisplay = holder.getNotMetDisplay(playerDispatcher) + notMetLines.addAll(notMetDisplay.lines.map { Display.PREFIX + it }) + showNotMet = notMetDisplay.showNameAsNotMet } - formattedNames[DisplayableEnchant(enchant.wrap(), level)] = - enchant.wrap().getFormattedName(level, showNotMet = showNotMet) + val wrapped = enchant.wrap() + formattedNames[DisplayableEnchant(wrapped, level)] = + wrapped.getFormattedName(level, showNotMet = showNotMet) } if (shouldCollapse) { - val perLine = plugin.configYml.getInt("display.collapse.per-line") - for (names in formattedNames.values.chunked(perLine)) { + for (names in formattedNames.values.chunked(collapsePerLine)) { enchantLore.add( Display.PREFIX + names.joinToString( - plugin.configYml.getFormattedString("display.collapse.delimiter") + collapseDelimiter ) ) } @@ -120,11 +170,13 @@ object EnchantDisplay : DisplayModule(plugin, DisplayPriority.HIGH) { } fast.addItemFlags(ItemFlag.HIDE_ENCHANTS) + pdc.set(addedHideEnchantsKey, PersistentDataType.INTEGER, 1) if (itemStack.type == Material.ENCHANTED_BOOK) { hse.hideStoredEnchants(fast) + pdc.set(addedHideStoredEnchantsKey, PersistentDataType.INTEGER, 1) } - if (plugin.configYml.getBool("display.enchantments-below-lore")) { + if (enchantmentsBelowLore) { fast.lore = lore + enchantLore + notMetLines } else { fast.lore = enchantLore + lore + notMetLines @@ -139,26 +191,63 @@ object EnchantDisplay : DisplayModule(plugin, DisplayPriority.HIGH) { val fast = itemStack.fast() val pdc = fast.persistentDataContainer - if (pdc.hideState != 1) { + val originallyHidEnchants = pdc.get(originalHideEnchantsKey, PersistentDataType.INTEGER)?.toBool() + ?: (pdc.hideState == 1) + val originallyHidStoredEnchants = pdc.get(originalHideStoredEnchantsKey, PersistentDataType.INTEGER)?.toBool() + ?: (pdc.hideState == 1) + val addedHideEnchants = pdc.get(addedHideEnchantsKey, PersistentDataType.INTEGER)?.toBool() + ?: (!originallyHidEnchants && pdc.hideState != -1) + val addedHideStoredEnchants = pdc.get(addedHideStoredEnchantsKey, PersistentDataType.INTEGER)?.toBool() + ?: (!originallyHidStoredEnchants && pdc.hideState != -1) + + if (originallyHidEnchants) { + fast.addItemFlags(ItemFlag.HIDE_ENCHANTS) + } else if (addedHideEnchants) { fast.removeItemFlags(ItemFlag.HIDE_ENCHANTS) + } - if (itemStack.type == Material.ENCHANTED_BOOK) { + if (itemStack.type == Material.ENCHANTED_BOOK) { + if (originallyHidStoredEnchants) { + hse.hideStoredEnchants(fast) + } else if (addedHideStoredEnchants) { hse.showStoredEnchants(fast) } } pdc.remove(hideStateKey) + pdc.remove(originalHideEnchantsKey) + pdc.remove(originalHideStoredEnchantsKey) + pdc.remove(addedHideEnchantsKey) + pdc.remove(addedHideStoredEnchantsKey) } override fun generateVarArgs(itemStack: ItemStack): Array<Any> { val fast = itemStack.fast() + val pdc = fast.persistentDataContainer + + val originallyHidEnchants = pdc.get(originalHideEnchantsKey, PersistentDataType.INTEGER)?.toBool() + val originallyHidStoredEnchants = pdc.get(originalHideStoredEnchantsKey, PersistentDataType.INTEGER)?.toBool() + + if (originallyHidEnchants != null || originallyHidStoredEnchants != null) { + return arrayOf( + originallyHidEnchants ?: false, + originallyHidStoredEnchants ?: false + ) + } + + val hidesEnchants = fast.hasItemFlag(ItemFlag.HIDE_ENCHANTS) + val hidesStoredEnchants = if (itemStack.type == Material.ENCHANTED_BOOK) { + hse.areStoredEnchantsHidden(fast) + } else { + false + } return when (fast.hideState) { - 1 -> arrayOf(true) - 0 -> arrayOf(false) + 1 -> arrayOf(true, hidesStoredEnchants) + 0 -> arrayOf(false, hidesStoredEnchants) else -> arrayOf( - fast.hasItemFlag(ItemFlag.HIDE_ENCHANTS) - || hse.areStoredEnchantsHidden(fast) + hidesEnchants, + hidesStoredEnchants ) } } @@ -168,4 +257,71 @@ object EnchantDisplay : DisplayModule(plugin, DisplayPriority.HIGH) { private val PersistentDataContainer.hideState: Int get() = this.get(hideStateKey, PersistentDataType.INTEGER) ?: -1 + + private fun Boolean.toStoredInt(): Int = if (this) 1 else 0 + + private fun Int.toBool(): Boolean = this == 1 + + private fun FastItemStack.getOriginalHideState(args: Array<out Any>, isEnchantedBook: Boolean): HideState { + val pdc = this.persistentDataContainer + + val hidesEnchants = pdc.get(originalHideEnchantsKey, PersistentDataType.INTEGER)?.toBool() + ?: (args.getOrNull(0) as? Boolean) + ?: this.hasItemFlag(ItemFlag.HIDE_ENCHANTS) + + val hidesStoredEnchants = if (isEnchantedBook) { + pdc.get(originalHideStoredEnchantsKey, PersistentDataType.INTEGER)?.toBool() + ?: (args.getOrNull(1) as? Boolean) + ?: hse.areStoredEnchantsHidden(this) + } else { + false + } + + return HideState(hidesEnchants, hidesStoredEnchants) + } + + private data class HideState( + val hidesEnchants: Boolean, + val hidesStoredEnchants: Boolean + ) +} + +private data class NotMetDisplay( + val lines: List<String>, + val showNameAsNotMet: Boolean +) + +private fun ProvidedHolder.getNotMetDisplay(dispatcher: Dispatcher<*>): NotMetDisplay { + val lines = mutableListOf<String>() + var showNameAsNotMet = false + + fun collect(conditionList: ConditionList) { + for (block in conditionList) { + if (!block.showNotMet) { + continue + } + + if (block.isMet(dispatcher, this@getNotMetDisplay)) { + continue + } + + showNameAsNotMet = true + + if (block.notMetLines.isEmpty()) { + continue + } + + val context = block.config.applyHolder(this@getNotMetDisplay, dispatcher).toPlaceholderContext() + lines += block.notMetLines.map { + it.formatEco(context) + } + } + } + + collect(holder.conditions) + for (effect in holder.effects) { + collect(effect.conditions) + } + + return NotMetDisplay(lines, showNameAsNotMet) } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/EnchantmentFormatting.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/EnchantmentFormatting.kt index 6166912c92..c6d4e15450 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/EnchantmentFormatting.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/EnchantmentFormatting.kt @@ -83,12 +83,161 @@ fun EcoEnchantLike.getFormattedDescription(level: Int, player: Player? = null): formattedLine = formattedLine.replace(tag, tag + descriptionFormat) } - // Apply word wrapping after all formatting - StringUtils.lineWrap(formattedLine.formatEco(placeholderContext( - injectable = this.config)), wrap) + // Apply word wrapping after all formatting, without counting color codes. + formattedLine.formatEco(placeholderContext( + injectable = this.config + )).lineWrapIgnoringFormatting(wrap) } } } // Java backwards compatibility fun EcoEnchantLike.getFormattedDescription(level: Int): List<String> = getFormattedDescription(level, null) + +private val wrapTokenPattern = Regex("""\s+|\S+""") +private const val LEGACY_COLOR_CHAR = '§' +private const val AMPERSAND_COLOR_CHAR = '&' +private const val HEX_FORMAT_LENGTH = 14 +private const val LEGACY_FORMAT_LENGTH = 2 +private val colorCodes = setOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f' +) +private val formatCodes = setOf('k', 'l', 'm', 'n', 'o') +private val resetCode = 'r' + +private fun String.lineWrapIgnoringFormatting(width: Int): List<String> { + if (width <= 0) { + return listOf(this) + } + + val lines = mutableListOf<String>() + var current = StringBuilder() + var currentWidth = 0 + var activeFormatting = "" + var pendingWhitespace = "" + + fun emitLine() { + lines += current.toString().trimEnd() + current = StringBuilder(activeFormatting) + currentWidth = 0 + } + + fun appendText(text: String, splitLongToken: Boolean) { + var index = 0 + while (index < text.length) { + val formatLength = text.formatLengthAt(index) + if (formatLength > 0) { + val format = text.substring(index, index + formatLength) + current.append(format) + activeFormatting = activeFormatting.applyFormat(format) + index += formatLength + continue + } + + if (splitLongToken && currentWidth >= width && currentWidth > 0) { + emitLine() + } + + current.append(text[index]) + currentWidth++ + index++ + } + } + + for (token in wrapTokenPattern.findAll(this).map { it.value }) { + if (token.isBlank()) { + if (currentWidth > 0) { + pendingWhitespace = token + } + continue + } + + val leadingWhitespace = if (currentWidth > 0) pendingWhitespace else "" + val tokenWithWhitespace = leadingWhitespace + token + val visibleLength = tokenWithWhitespace.visibleLength() + + if (currentWidth > 0 && currentWidth + visibleLength > width) { + emitLine() + appendText(token, token.visibleLength() > width) + } else { + appendText(tokenWithWhitespace, visibleLength > width) + } + + pendingWhitespace = "" + } + + if (current.isNotEmpty()) { + lines += current.toString().trimEnd() + } + + return lines +} + +private fun String.visibleLength(): Int { + var length = 0 + var index = 0 + + while (index < this.length) { + val formatLength = this.formatLengthAt(index) + if (formatLength > 0) { + index += formatLength + continue + } + + length++ + index++ + } + + return length +} + +private fun String.formatLengthAt(index: Int): Int { + if (index + 1 >= this.length) { + return 0 + } + + val marker = this[index] + if (marker != LEGACY_COLOR_CHAR && marker != AMPERSAND_COLOR_CHAR) { + return 0 + } + + val code = this[index + 1].lowercaseChar() + if (code == 'x' && this.hasHexFormatAt(index, marker)) { + return HEX_FORMAT_LENGTH + } + + if (code in colorCodes || code in formatCodes || code == resetCode) { + return LEGACY_FORMAT_LENGTH + } + + return 0 +} + +private fun String.hasHexFormatAt(index: Int, marker: Char): Boolean { + if (index + HEX_FORMAT_LENGTH > this.length) { + return false + } + + for (offset in 2 until HEX_FORMAT_LENGTH step 2) { + if (this[index + offset] != marker || !this[index + offset + 1].isHexDigit()) { + return false + } + } + + return true +} + +private fun Char.isHexDigit(): Boolean = + this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' + +private fun String.applyFormat(format: String): String { + val code = format.getOrNull(1)?.lowercaseChar() ?: return this + + return when { + code == resetCode -> "" + code == 'x' || code in colorCodes -> format + code in formatCodes -> if (this.contains(format)) this else this + format + else -> this + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/Sorters.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/Sorters.kt index 43dd257a78..553b2d11aa 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/Sorters.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/display/Sorters.kt @@ -4,90 +4,103 @@ import com.willfp.ecoenchants.enchant.wrap import com.willfp.ecoenchants.plugin import com.willfp.ecoenchants.rarity.EnchantmentRarities import com.willfp.ecoenchants.rarity.EnchantmentRarity +import com.willfp.ecoenchants.stripLegacyFormatting import com.willfp.ecoenchants.type.EnchantmentType import com.willfp.ecoenchants.type.EnchantmentTypes import org.bukkit.enchantments.Enchantment -interface EnchantmentSorter { - fun sort(enchantments: Collection<Enchantment>, children: List<EnchantmentSorter>): List<Enchantment> -} - object EnchantSorter { - private val sorters = mutableListOf<EnchantmentSorter>() + private var sortByRarity = false + private var sortByType = false + private var sortByLength = false + private var comparator: Comparator<DisplaySortEntry> = compareBy<DisplaySortEntry> { it.name } internal fun reload() { - sorters.clear() + sortByRarity = plugin.configYml.getBool("display.sort.rarity") + sortByType = plugin.configYml.getBool("display.sort.type") + sortByLength = plugin.configYml.getBool("display.sort.length") - if (plugin.configYml.getBool("display.sort.rarity")) { - sorters.add(RaritySorter) + val comparators = mutableListOf<Comparator<DisplaySortEntry>>() + + if (sortByRarity) { + comparators += compareBy<DisplaySortEntry> { it.rarityOrder } } - if (plugin.configYml.getBool("display.sort.type")) { - sorters.add(TypeSorter) + if (sortByType) { + comparators += compareBy<DisplaySortEntry> { it.typeOrder } } - if (plugin.configYml.getBool("display.sort.length")) { - sorters.add(LengthSorter) + if (sortByLength) { + comparators += compareBy<DisplaySortEntry> { it.nameLength } + } else { + comparators += compareBy<DisplaySortEntry> { it.name } } + + comparator = comparators.reduce { current, next -> current.then(next) } } fun Collection<Enchantment>.sortForDisplay(): List<Enchantment> = - sorters.getSafely(0).sort(this, sorters.drop(1)) -} + this.mapNotNull { it.toDisplaySortEntry() } + .sortedWith(comparator) + .map { it.enchantment } + + private fun Enchantment.toDisplaySortEntry(): DisplaySortEntry? { + val wrapped = this.wrap() + val rarityOrder = if (sortByRarity) { + RaritySorter.orderOf(wrapped.enchantmentRarity) ?: return null + } else { + 0 + } + val typeOrder = if (sortByType) { + TypeSorter.orderOf(wrapped.type) ?: return null + } else { + 0 + } -fun List<EnchantmentSorter>.getSafely(index: Int) = - this.getOrNull(index) ?: AlphabeticSorter + val name = wrapped.getFormattedName(0).stripLegacyFormatting() -object AlphabeticSorter : EnchantmentSorter { - override fun sort(enchantments: Collection<Enchantment>, children: List<EnchantmentSorter>): List<Enchantment> { - @Suppress("DEPRECATION") - return enchantments.sortedBy { org.bukkit.ChatColor.stripColor(it.wrap().getFormattedName(0)) } + return DisplaySortEntry( + enchantment = this, + rarityOrder = rarityOrder, + typeOrder = typeOrder, + nameLength = name.length, + name = name + ) } } -object LengthSorter : EnchantmentSorter { - override fun sort(enchantments: Collection<Enchantment>, children: List<EnchantmentSorter>): List<Enchantment> { - @Suppress("DEPRECATION") - return enchantments.sortedBy { org.bukkit.ChatColor.stripColor(it.wrap().getFormattedName(0))?.length ?: 0 } - } -} +private data class DisplaySortEntry( + val enchantment: Enchantment, + val rarityOrder: Int, + val typeOrder: Int, + val nameLength: Int, + val name: String +) -object TypeSorter : EnchantmentSorter { - private val types = mutableListOf<EnchantmentType>() +object TypeSorter { + private var typeOrder = emptyMap<EnchantmentType, Int>() fun update() { - types.clear() - types.addAll(plugin.configYml.getStrings("display.sort.type-order").mapNotNull { - EnchantmentTypes[it] - }) + typeOrder = plugin.configYml.getStrings("display.sort.type-order") + .mapIndexedNotNull { index, id -> + EnchantmentTypes[id]?.let { it to index } + } + .toMap() } - override fun sort(enchantments: Collection<Enchantment>, children: List<EnchantmentSorter>): List<Enchantment> { - val sorted = children.getSafely(0).sort(enchantments, children.drop(1)) - val enchants = mutableListOf<Enchantment>() - for (type in types) { - enchants.addAll(sorted.filter { it.wrap().type == type }) - } - return enchants - } + internal fun orderOf(type: EnchantmentType): Int? = typeOrder[type] } -object RaritySorter : EnchantmentSorter { - private val rarities = mutableListOf<EnchantmentRarity>() +object RaritySorter { + private var rarityOrder = emptyMap<EnchantmentRarity, Int>() fun update() { - rarities.clear() - rarities.addAll(plugin.configYml.getStrings("display.sort.rarity-order").mapNotNull { - EnchantmentRarities[it] - }) + rarityOrder = plugin.configYml.getStrings("display.sort.rarity-order") + .mapIndexedNotNull { index, id -> + EnchantmentRarities[id]?.let { it to index } + } + .toMap() } - override fun sort(enchantments: Collection<Enchantment>, children: List<EnchantmentSorter>): List<Enchantment> { - val sorted = children.getSafely(0).sort(enchantments, children.drop(1)) - val enchants = mutableListOf<Enchantment>() - for (rarity in rarities) { - enchants.addAll(sorted.filter { it.wrap().enchantmentRarity == rarity }) - } - return enchants - } + internal fun orderOf(rarity: EnchantmentRarity): Int? = rarityOrder[rarity] } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EcoEnchantLike.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EcoEnchantLike.kt index d882292938..c3d02ca6de 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EcoEnchantLike.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EcoEnchantLike.kt @@ -64,8 +64,22 @@ interface EcoEnchantLike { item: ItemStack, additionalEnchantments: Collection<Enchantment> = emptyList() ): Boolean { + val enchantLimit = plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative() val enchants = item.fast().getEnchants(true).keys + additionalEnchantments + return canEnchantItemConsidering(item, enchants, enchantLimit) + } + + /** + * Get if this enchantment can be applied to [item], using a precomputed current enchantment set. + */ + fun canEnchantItemConsidering( + item: ItemStack, + currentEnchantments: Collection<Enchantment>, + enchantLimit: Int = plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative() + ): Boolean { + val enchants = currentEnchantments + if (enchants.count { it.wrap().type == this.type } >= this.type.limit) { return false } @@ -78,7 +92,7 @@ interface EcoEnchantLike { return false } - if (enchants.size >= plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative()) { + if (enchants.size >= enchantLimit) { return false } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EcoEnchants.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EcoEnchants.kt index 66bde7e149..153994f628 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EcoEnchants.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EcoEnchants.kt @@ -5,20 +5,20 @@ import com.willfp.eco.core.config.interfaces.Config import com.willfp.ecoenchants.EcoEnchantsPlugin import com.willfp.ecoenchants.display.getFormattedName import com.willfp.ecoenchants.enchant.impl.EcoEnchantBase +import com.willfp.ecoenchants.enchant.impl.HardcodedEcoEnchant import com.willfp.ecoenchants.enchant.impl.LibreforgeEcoEnchant import com.willfp.ecoenchants.enchant.impl.hardcoded.EnchantmentPermanenceCurse import com.willfp.ecoenchants.enchant.impl.hardcoded.EnchantmentRepairing import com.willfp.ecoenchants.enchant.impl.hardcoded.EnchantmentReplenish import com.willfp.ecoenchants.enchant.impl.hardcoded.EnchantmentSoulbound -import com.willfp.ecoenchants.enchant.registration.ModernEnchantmentRegistererProxy import com.willfp.ecoenchants.integrations.EnchantRegistrations import com.willfp.ecoenchants.plugin import com.willfp.ecoenchants.rarity.EnchantmentRarities +import com.willfp.ecoenchants.stripLegacyFormatting import com.willfp.ecoenchants.target.EnchantmentTargets import com.willfp.ecoenchants.type.EnchantmentTypes import com.willfp.libreforge.loader.LibreforgePlugin import com.willfp.libreforge.loader.configs.RegistrableCategory -import org.bukkit.ChatColor @Suppress("UNUSED") object EcoEnchants : RegistrableCategory<EcoEnchant>("enchant", "enchants") { @@ -32,14 +32,16 @@ object EcoEnchants : RegistrableCategory<EcoEnchant>("enchant", "enchants") { for (enchant in registry.values()) { plugin.enchantmentRegisterer.unregister(enchant) EnchantRegistrations.removeEnchant(enchant) - BY_NAME.remove(ChatColor.stripColor(enchant.getFormattedName(0))?.lowercase()) + BY_NAME.remove(enchant.getFormattedName(0).stripLegacyFormatting().lowercase()) } registry.clear() } override fun beforeReload(plugin: LibreforgePlugin) { - plugin.getProxy(ModernEnchantmentRegistererProxy::class.java).replaceRegistry() + plugin as EcoEnchantsPlugin + + plugin.enchantmentRegisterer.replaceRegistry() EnchantmentRarities.update() EnchantmentTargets.update() @@ -48,9 +50,13 @@ object EcoEnchants : RegistrableCategory<EcoEnchant>("enchant", "enchants") { override fun afterReload(plugin: LibreforgePlugin) { sendPrompts() + HardcodedEcoEnchant.reload() registerHardcodedEnchantments() + EnchantRegistrations.registerEnchantments() - plugin.getProxy(ModernEnchantmentRegistererProxy::class.java).freezeRegistry() + plugin as EcoEnchantsPlugin + + plugin.enchantmentRegisterer.freezeRegistry() } override fun acceptPreloadConfig(plugin: LibreforgePlugin, id: String, config: Config) { @@ -95,9 +101,7 @@ object EcoEnchants : RegistrableCategory<EcoEnchant>("enchant", "enchants") { val enchantment = plugin.enchantmentRegisterer.register(enchant) // Register delegated versions registry.register(enchantment as EcoEnchant) - @Suppress("DEPRECATION") - BY_NAME[ChatColor.stripColor(enchant.getFormattedName(0))?.lowercase()] = enchantment as EcoEnchant - EnchantRegistrations.registerEnchantments() + BY_NAME[enchant.getFormattedName(0).stripLegacyFormatting().lowercase()] = enchantment as EcoEnchant } private fun registerHardcodedEnchantments() { diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EnchantGUI.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EnchantGUI.kt index 5f3a9ff1bb..0e20af8141 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EnchantGUI.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/EnchantGUI.kt @@ -1,57 +1,71 @@ package com.willfp.ecoenchants.enchant import com.willfp.eco.core.cache.EcoCache +import com.willfp.eco.core.config.interfaces.Config import com.willfp.eco.core.drops.DropQueue import com.willfp.eco.core.fast.fast import com.willfp.eco.core.gui.GUIComponent -import com.willfp.eco.core.gui.addPageChanger import com.willfp.eco.core.gui.menu import com.willfp.eco.core.gui.menu.Menu +import com.willfp.eco.core.gui.menu.MenuLayer import com.willfp.eco.core.gui.page.Page -import com.willfp.eco.core.gui.page.PageChanger import com.willfp.eco.core.gui.slot import com.willfp.eco.core.gui.slot.ConfigSlot import com.willfp.eco.core.gui.slot.FillerMask import com.willfp.eco.core.gui.slot.MaskItems import com.willfp.eco.core.gui.slot.Slot +import com.willfp.eco.core.items.HashedItem import com.willfp.eco.core.items.Items import com.willfp.eco.core.items.builder.EnchantedBookBuilder import com.willfp.eco.core.items.builder.ItemStackBuilder import com.willfp.eco.core.items.isEcoEmpty -import com.willfp.eco.core.sound.PlayableSound +import com.willfp.eco.util.NumberUtils +import com.willfp.eco.util.StringUtils import com.willfp.eco.util.formatEco import com.willfp.eco.util.lineWrap +import com.willfp.eco.util.toNiceString import com.willfp.ecoenchants.display.EnchantSorter.sortForDisplay import com.willfp.ecoenchants.display.HideStoredEnchantsProxy +import com.willfp.ecoenchants.display.RaritySorter import com.willfp.ecoenchants.display.getFormattedDescription import com.willfp.ecoenchants.display.getFormattedName import com.willfp.ecoenchants.dragdrop.isDragAndDropEnabled import com.willfp.ecoenchants.enchant.DiscoveryType +import com.willfp.ecoenchants.experience.Favorites +import com.willfp.ecoenchants.experience.PlayerExperience import com.willfp.ecoenchants.plugin +import com.willfp.ecoenchants.rarity.EnchantmentRarities import com.willfp.ecoenchants.target.EnchantmentTargets.applicableEnchantments +import com.willfp.ecoenchants.target.EnchantmentTargets +import com.willfp.ecoenchants.type.EnchantmentTypes import org.bukkit.Material -import org.bukkit.enchantments.Enchantment import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerKickEvent +import org.bukkit.event.player.PlayerQuitEvent import org.bukkit.inventory.ItemFlag import org.bukkit.inventory.ItemStack -import com.willfp.ecoenchants.rarity.EnchantmentRarities -import com.willfp.ecoenchants.type.EnchantmentTypes -import com.willfp.ecoenchants.target.EnchantmentTargets +import java.util.Locale +import java.util.UUID import kotlin.math.ceil -object EnchantGUI { +object EnchantGUI : Listener { private lateinit var menu: Menu private var groupMenu: Menu? = null + private var adminMenu: Menu? = null private val enchantInfoMenus = EcoCache.builder<Pair<EcoEnchant, Int>, Menu>().build() - private var allEnchantsSorted: List<Enchantment> = emptyList() + private var allEnchantsSorted: List<EcoEnchant> = emptyList() + private val returnedOnDisconnect = mutableSetOf<UUID>() internal fun reload() { cachedEnchantmentSlots.invalidateAll() + applicableEnchantmentsSorted.invalidateAll() enchantInfoMenus.invalidateAll() - allEnchantsSorted = EcoEnchants.values().map { it.enchantment }.sortForDisplay() + allEnchantsSorted = EcoEnchants.values().sortForGui() menu = menu(plugin.configYml.getInt("enchant-gui.rows")) { - title = plugin.configYml.getFormattedString("enchant-gui.title") + title = getConfiguredGuiTitle("enchant-gui") allowChangingHeldItem() @@ -67,11 +81,7 @@ object EnchantGUI { setSlot( plugin.configYml.getInt("enchant-gui.info.row"), plugin.configYml.getInt("enchant-gui.info.column"), - slot( - ItemStackBuilder(Items.lookup(plugin.configYml.getString("enchant-gui.info.item"))) - .addLoreLines(plugin.configYml.getStrings("enchant-gui.info.lore")) - .build() - ) + slot(buildGuiItem("enchant-gui.info")) ) val captiveRow = plugin.configYml.getInt("enchant-gui.item-row") @@ -86,41 +96,39 @@ object EnchantGUI { onRender { player, menu -> val atCaptive = menu.getCaptiveItem(player, captiveRow, captiveColumn) val hasItem = !atCaptive.isEcoEmpty && atCaptive != null && atCaptive.type != Material.BOOK + val compatibleOnly = menu.getState<Boolean>(player, "compatibleOnly") + ?: plugin.configYml.getBool("enchant-gui.filters.compatible-only.default-enabled") val canSeeHidden = player.hasPermission("ecoenchants.seehidden") - val baseEnchants = if (!hasItem) { - EcoEnchants.values().filter { !it.isHiddenFromGui || canSeeHidden }.map { it.enchantment }.sortForDisplay() + val baseEnchants = if (hasItem && compatibleOnly) { + val currentEnchants = atCaptive.fast().enchants.keys + applicableEnchantmentsSorted.get(HashedItem.of(atCaptive)) { + atCaptive.applicableEnchantments + .filter { !it.isHiddenFromGui || canSeeHidden } + .sortForGui() + }.filterNot { it.enchantment in currentEnchants } } else { - atCaptive.applicableEnchantments.filter { !it.isHiddenFromGui || canSeeHidden }.map { it.enchantment }.sortForDisplay() - .subtract(atCaptive.fast().enchants.keys) - .toList() + allEnchantsSorted.filter { !it.isHiddenFromGui || canSeeHidden } } // Apply group filter if a groupId is set in menu state val groupId = menu.getState<String>(player, "groupId") - val filteredEnchants = if (groupId != null) { - val groupBy = plugin.configYml.getString("enchant-gui.group-by") - baseEnchants.filter { enchantment -> - val wrapped = enchantment.wrap() - when (groupBy) { - "type" -> wrapped.type.id == groupId - "rarity" -> wrapped.enchantmentRarity.id == groupId - "target" -> wrapped is EcoEnchant && wrapped.targets.any { it.id == groupId } - else -> true - } - } - } else { - baseEnchants - } - - menu.setState(player, "enchants", filteredEnchants) + val favoritesOnly = menu.getState<Boolean>(player, "favoritesOnly") ?: false + val sortOrder = menu.getState<String>(player, "sortOrder").orEmpty() + val filteredEnchants = baseEnchants + .filter { it.matchesGroupFilter(groupId) } + .filter { it.matchesCycleFilters(player, menu) } + .filter { !favoritesOnly || Favorites.contains(player, it) } + .applyGuiSort(sortOrder) // Reset to page 1 when an item is placed or removed from the captive slot val previousHasItem = menu.getState<Boolean>(player, "hasItem") ?: false if (hasItem != previousHasItem) { menu.setState(player, Page.PAGE_KEY, 1) } + menu.setState(player, "enchants", filteredEnchants) menu.setState(player, "hasItem", hasItem) + menu.setState(player, "compatibleOnly", compatibleOnly) // Safety net: also reset if the current page now exceeds the new max. // Compute directly from filteredEnchants to avoid a stale getMaxPage() value @@ -134,6 +142,15 @@ object EnchantGUI { if (menu.getPage(player) > maxPage) { menu.setState(player, Page.PAGE_KEY, 1) } + + if (filteredEnchants.isEmpty()) { + PlayerExperience.handleEmptyResults( + player, + getEmptyResultReason(hasItem, groupId, menu.hasCycleFilters(player) || favoritesOnly, compatibleOnly) + ) + } else { + PlayerExperience.clearEmptyResults(player) + } } val pane = EnchantmentScrollPane() @@ -144,31 +161,66 @@ object EnchantGUI { pane ) - val pageChangeSound = PlayableSound.create( - plugin.configYml.getSubsection("enchant-gui.page-change.sound") - ) + for (direction in GuiPageDirection.entries) { + val directionName = direction.name.lowercase(Locale.ROOT) - for (direction in PageChanger.Direction.entries) { - val directionName = direction.name.lowercase() + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("enchant-gui.page-change.$directionName.row"), + plugin.configYml.getInt("enchant-gui.page-change.$directionName.column"), + EnchantPageChanger(direction) + ) + } + + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("enchant-gui.admin-tools.row"), + plugin.configYml.getInt("enchant-gui.admin-tools.column"), + AdminToolsButton() + ) - addPageChanger( - plugin.configYml, - "enchant-gui.page-change.$directionName", - direction, - pageChangeSound + for (axis in GuiFilterAxis.entries) { + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("${axis.configPath}.row"), + plugin.configYml.getInt("${axis.configPath}.column"), + FilterCycleButton(axis) ) } + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("enchant-gui.filters.compatible-only.row"), + plugin.configYml.getInt("enchant-gui.filters.compatible-only.column"), + CompatibleOnlyButton() + ) + + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("enchant-gui.conflict-view.row"), + plugin.configYml.getInt("enchant-gui.conflict-view.column"), + ConflictViewButton() + ) + + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("enchant-gui.filters.favorites-only.row"), + plugin.configYml.getInt("enchant-gui.filters.favorites-only.column"), + FavoritesOnlyButton() + ) + + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("enchant-gui.sort.row"), + plugin.configYml.getInt("enchant-gui.sort.column"), + SortCycleButton() + ) + if (plugin.configYml.getBool("enchant-gui.close-button.enabled")) { setSlot( plugin.configYml.getInt("enchant-gui.close-button.row"), plugin.configYml.getInt("enchant-gui.close-button.column"), - slot( - ItemStackBuilder(Items.lookup(plugin.configYml.getString("enchant-gui.close-button.item"))) - .setDisplayName(plugin.configYml.getFormattedString("enchant-gui.close-button.name")) - .addLoreLines(plugin.configYml.getStrings("enchant-gui.close-button.lore")) - .build() - ) { + slot(buildGuiItem("enchant-gui.close-button")) { onLeftClick { event, _ -> event.whoClicked.closeInventory() } } ) @@ -180,22 +232,11 @@ object EnchantGUI { setSlot( plugin.configYml.getInt("enchant-gui.back-button.row"), plugin.configYml.getInt("enchant-gui.back-button.column"), - slot( - ItemStackBuilder(Items.lookup(plugin.configYml.getString("enchant-gui.back-button.item"))) - .addLoreLines(plugin.configYml.getStrings("enchant-gui.back-button.lore")) - .build() - ) { + slot(buildGuiItem("enchant-gui.back-button")) { onLeftClick { event, _ -> val groupGui = groupMenu ?: return@onLeftClick val player = event.whoClicked as Player - // Return captive items to the player before navigating back - val captiveItems = menu.getCaptiveItems(player) - if (captiveItems.isNotEmpty()) { - DropQueue(player) - .addItems(captiveItems) - .forceTelekinesis() - .push() - } + returnCaptiveItems(player) groupGui.open(player) } } @@ -217,10 +258,11 @@ object EnchantGUI { onClose { event, menu -> val player = event.player as Player - DropQueue(player) - .addItems(menu.getCaptiveItems(player)) - .forceTelekinesis() - .push() + if (returnedOnDisconnect.remove(player.uniqueId)) { + return@onClose + } + + returnCaptiveItems(player, menu) } for (config in plugin.configYml.getSubsections("enchant-gui.custom-slots")) { @@ -235,7 +277,7 @@ object EnchantGUI { // Build the group selection menu (only when grouped mode is enabled) if (plugin.configYml.getBool("enchant-gui.grouped")) { groupMenu = menu(plugin.configYml.getInt("group-gui.rows")) { - title = plugin.configYml.getFormattedString("group-gui.title") + title = getConfiguredGuiTitle("group-gui") setMask( FillerMask( @@ -246,6 +288,35 @@ object EnchantGUI { ) ) + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("group-gui.admin-tools.row"), + plugin.configYml.getInt("group-gui.admin-tools.column"), + AdminToolsButton("group-gui.admin-tools") + ) + + if (plugin.configYml.getBool("group-gui.all-enchants.enabled")) { + setSlot( + plugin.configYml.getInt("group-gui.all-enchants.row"), + plugin.configYml.getInt("group-gui.all-enchants.column"), + slot(buildGuiItem("group-gui.all-enchants")) { + onLeftClick { event, _ -> + openAllEnchantsGUI(event.whoClicked as Player) + } + } + ) + } + + if (plugin.configYml.getBool("group-gui.close-button.enabled")) { + setSlot( + plugin.configYml.getInt("group-gui.close-button.row"), + plugin.configYml.getInt("group-gui.close-button.column"), + slot(buildGuiItem("group-gui.close-button")) { + onLeftClick { event, _ -> event.whoClicked.closeInventory() } + } + ) + } + // Add a clickable slot for each configured group for (config in plugin.configYml.getSubsections("group-gui.groups")) { val groupId = config.getString("id") @@ -266,11 +337,7 @@ object EnchantGUI { setSlot( config.getInt("row"), config.getInt("column"), - slot( - ItemStackBuilder(Items.lookup(config.getString("item"))) - .addLoreLines(config.getStrings("lore")) - .build() - ) { + slot(buildGuiItem(config)) { onLeftClick { event, _ -> openGroupGUI(event.whoClicked as Player, groupId) } @@ -290,9 +357,71 @@ object EnchantGUI { } else { groupMenu = null } + + adminMenu = if (plugin.configYml.getBool("admin-gui.enabled")) { + menu(plugin.configYml.getInt("admin-gui.rows")) { + title = getConfiguredGuiTitle("admin-gui") + + setMask( + FillerMask( + MaskItems.fromItemNames( + plugin.configYml.getStrings("admin-gui.mask.items") + ), + *plugin.configYml.getStrings("admin-gui.mask.pattern").toTypedArray() + ) + ) + + for (tool in AdminTool.entries) { + if (!plugin.configYml.getBool("${tool.configPath}.enabled")) { + continue + } + + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("${tool.configPath}.row"), + plugin.configYml.getInt("${tool.configPath}.column"), + AdminToolButton(tool) + ) + } + + if (plugin.configYml.getBool("admin-gui.back-button.enabled")) { + setSlot( + plugin.configYml.getInt("admin-gui.back-button.row"), + plugin.configYml.getInt("admin-gui.back-button.column"), + slot(buildGuiItem("admin-gui.back-button")) { + onLeftClick { event, _ -> + openGUI(event.whoClicked as Player) + } + } + ) + } + + if (plugin.configYml.getBool("admin-gui.close-button.enabled")) { + setSlot( + plugin.configYml.getInt("admin-gui.close-button.row"), + plugin.configYml.getInt("admin-gui.close-button.column"), + slot(buildGuiItem("admin-gui.close-button")) { + onLeftClick { event, _ -> event.whoClicked.closeInventory() } + } + ) + } + + for (config in plugin.configYml.getSubsections("admin-gui.custom-slots")) { + setSlot( + config.getInt("row"), + config.getInt("column"), + ConfigSlot(config) + ) + } + } + } else { + null + } } fun openGUI(player: Player) { + PlayerExperience.handleBrowserOpen(player) + if (plugin.configYml.getBool("enchant-gui.grouped") && groupMenu != null) { groupMenu!!.open(player) } else { @@ -324,6 +453,15 @@ object EnchantGUI { ) ) + if (plugin.configYml.getBool("enchantinfo.favorite.enabled")) { + addComponent( + MenuLayer.TOP, + plugin.configYml.getInt("enchantinfo.favorite.row"), + plugin.configYml.getInt("enchantinfo.favorite.column"), + FavoriteButton(enchant, effectiveLevel) + ) + } + for (config in plugin.configYml.getSubsections("enchantinfo.custom-slots")) { setSlot( config.getInt("row"), @@ -335,10 +473,450 @@ object EnchantGUI { }.open(player) } + private fun openAdminGUI(player: Player) { + val targetMenu = adminMenu ?: run { + player.sendLangMessage("admin-gui-disabled") + return + } + + if (!player.hasAdminToolsPermission()) { + player.sendMessage(plugin.langYml.getMessage("no-permission")) + return + } + + targetMenu.open(player) + player.sendLangMessage("opened-admin-gui") + } + + private fun runAdminTool(player: Player, tool: AdminTool) { + if (!player.hasPermission(tool.permission)) { + player.sendMessage(plugin.langYml.getMessage("no-permission")) + return + } + + player.playConfiguredSound("${tool.configPath}.sound") + + when (tool) { + AdminTool.RELOAD -> reloadFromAdmin(player) + AdminTool.RANDOM_BOOK -> giveRandomBookToSelf(player) + } + } + + private fun reloadFromAdmin(player: Player) { + player.closeInventory() + + val message = plugin.langYml.getMessage("reloaded", StringUtils.FormatOption.WITHOUT_PLACEHOLDERS) + val time = plugin.reloadWithTime().toNiceString() + + player.sendMessage( + message + .replace("%time%", time) + .replace("%count%", EcoEnchants.values().size.toString()) + ) + } + + private fun giveRandomBookToSelf(player: Player) { + val enchantment = EcoEnchants.values().randomOrNull() ?: run { + player.sendMessage(plugin.langYml.getMessage("no-enchantments-found")) + return + } + + val level = NumberUtils.randInt(1, enchantment.maximumLevel) + + val item = EnchantedBookBuilder() + .addStoredEnchantment(enchantment.enchantment, level) + .build() + + DropQueue(player) + .addItem(item) + .forceTelekinesis() + .push() + + player.sendMessage( + plugin.langYml.getMessage("gave-random-book", StringUtils.FormatOption.WITHOUT_PLACEHOLDERS) + .replace("%player%", player.name) + .replace("%enchantment%", enchantment.getFormattedName(level)) + ) + } + private fun openGroupGUI(player: Player, groupId: String) { - menu.open(player) menu.setState(player, "groupId", groupId) menu.setState(player, Page.PAGE_KEY, 1) + menu.open(player) + player.sendLangMessage("opened-enchant-group", "group" to getGroupDisplayName(groupId)) + } + + private fun openAllEnchantsGUI(player: Player) { + menu.clearState(player) + menu.setState(player, Page.PAGE_KEY, 1) + menu.open(player) + player.sendLangMessage("opened-enchant-group", "group" to plugin.langYml.getFormattedString("all")) + } + + @EventHandler + fun handleQuit(event: PlayerQuitEvent) { + returnCaptiveItemsOnDisconnect(event.player) + } + + @EventHandler + fun handleKick(event: PlayerKickEvent) { + returnCaptiveItemsOnDisconnect(event.player) + } + + private fun returnCaptiveItemsOnDisconnect(player: Player) { + if (returnCaptiveItems(player, notify = false)) { + returnedOnDisconnect.add(player.uniqueId) + } + } + + private fun returnCaptiveItems(player: Player, sourceMenu: Menu? = null, notify: Boolean = true): Boolean { + if (!::menu.isInitialized) { + return false + } + + val activeMenu = sourceMenu ?: menu + val captiveItems = activeMenu.getCaptiveItems(player) + .filterNot { it.isEcoEmpty || it.type == Material.AIR } + + if (captiveItems.isEmpty()) { + activeMenu.clearState(player) + return false + } + + val overflow = player.inventory.addItem(*captiveItems.map { it.clone() }.toTypedArray()) + + for (item in overflow.values) { + player.world.dropItemNaturally(player.location, item) + } + + activeMenu.clearState(player) + + if (notify) { + val returnedAmount = captiveItems.sumOf { it.amount } + val droppedAmount = overflow.values.sumOf { it.amount } + + if (droppedAmount > 0) { + player.sendLangMessage( + "returned-gui-items-with-overflow", + "amount" to returnedAmount.toString(), + "dropped" to droppedAmount.toString() + ) + } else { + player.sendLangMessage( + "returned-gui-items", + "amount" to returnedAmount.toString() + ) + } + } + + return true + } + + private class AdminToolsButton( + private val configPath: String = "enchant-gui.admin-tools" + ) : GUIComponent { + private val emptySlot = slot(ItemStack(Material.AIR)) + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + if (!plugin.configYml.getBool("$configPath.enabled") + || !player.hasAdminToolsPermission()) { + return emptySlot + } + + return slot( + buildGuiItem(configPath) + ) { + onLeftClick { event, _ -> + openAdminGUI(event.whoClicked as Player) + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + } + + private class FilterCycleButton( + private val axis: GuiFilterAxis + ) : GUIComponent { + private val emptySlot = slot(ItemStack(Material.AIR)) + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + if (!plugin.configYml.getBool("${axis.configPath}.enabled")) { + return emptySlot + } + + return slot( + buildGuiItem( + axis.configPath, + mapOf("current" to axis.currentDisplay(player, menu)) + ) + ) { + onLeftClick { event, _ -> + val clickedPlayer = event.whoClicked as Player + val next = axis.nextValue(clickedPlayer, menu) + + if (next == null) { + menu.setState(clickedPlayer, axis.stateKey, "") + } else { + menu.setState(clickedPlayer, axis.stateKey, next.id) + } + + menu.setState(clickedPlayer, Page.PAGE_KEY, 1) + clickedPlayer.playConfiguredSound("${axis.configPath}.sound") + PlayerExperience.handleFilterChanged( + clickedPlayer, + axis.label, + next?.displayName ?: plugin.langYml.getFormattedString("all") + ) + menu.open(clickedPlayer) + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + } + + private class CompatibleOnlyButton : GUIComponent { + private val emptySlot = slot(ItemStack(Material.AIR)) + private val configPath = "enchant-gui.filters.compatible-only" + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + if (!plugin.configYml.getBool("$configPath.enabled")) { + return emptySlot + } + + val enabled = menu.getState<Boolean>(player, "compatibleOnly") + ?: plugin.configYml.getBool("$configPath.default-enabled") + + return slot( + buildGuiItem( + configPath, + mapOf("state" to enabled.parseEnabledDisabled()) + ) + ) { + onLeftClick { event, _ -> + val clickedPlayer = event.whoClicked as Player + val current = menu.getState<Boolean>(clickedPlayer, "compatibleOnly") + ?: plugin.configYml.getBool("$configPath.default-enabled") + + menu.setState(clickedPlayer, "compatibleOnly", !current) + menu.setState(clickedPlayer, Page.PAGE_KEY, 1) + clickedPlayer.playConfiguredSound("$configPath.sound") + PlayerExperience.handleFilterChanged( + clickedPlayer, + "compatible-only", + (!current).parseEnabledDisabled() + ) + menu.open(clickedPlayer) + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + } + + private class ConflictViewButton : GUIComponent { + private val emptySlot = slot(ItemStack(Material.AIR)) + private val configPath = "enchant-gui.conflict-view" + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + if (!plugin.configYml.getBool("$configPath.enabled")) { + return emptySlot + } + + return slot(buildGuiItem(configPath)) { + onLeftClick { event, _ -> + val clickedPlayer = event.whoClicked as Player + val captiveRow = plugin.configYml.getInt("enchant-gui.item-row") + val captiveColumn = plugin.configYml.getInt("enchant-gui.item-column") + val item = menu.getCaptiveItem(clickedPlayer, captiveRow, captiveColumn) + + if (item.isEcoEmpty || item == null || item.type == Material.AIR) { + clickedPlayer.playConfiguredSound("player-experience.sounds.invalid-click.sound") + PlayerExperience.sendLangLines(clickedPlayer, "hints.conflict-view.no-item") + return@onLeftClick + } + + val current = item.fast().getEnchants(true).keys + if (current.isEmpty()) { + clickedPlayer.playConfiguredSound("player-experience.sounds.invalid-click.sound") + PlayerExperience.sendLangLines(clickedPlayer, "hints.conflict-view.no-enchants") + return@onLeftClick + } + + val conflicts = EcoEnchants.values() + .filter { enchantment -> + current.any { it.conflictsWithDeep(enchantment.enchantment) } + } + .map { enchantment -> + val level = if (plugin.configYml.getBool("enchantinfo.item.show-max-level")) { + enchantment.maximumLevel + } else { + 1 + } + enchantment.getFormattedName(level) + } + .distinct() + .take(plugin.configYml.getInt("$configPath.max-lines").coerceAtLeast(1)) + + clickedPlayer.playConfiguredSound("$configPath.sound") + if (conflicts.isEmpty()) { + PlayerExperience.sendLangLines(clickedPlayer, "hints.conflict-view.none") + } else { + PlayerExperience.sendLangLines(clickedPlayer, "hints.conflict-view.header") + for (conflict in conflicts) { + PlayerExperience.sendLangLines(clickedPlayer, "hints.conflict-view.line", "enchant" to conflict) + } + } + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + } + + private class FavoritesOnlyButton : GUIComponent { + private val emptySlot = slot(ItemStack(Material.AIR)) + private val configPath = "enchant-gui.filters.favorites-only" + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + if (!plugin.configYml.getBool("$configPath.enabled")) { + return emptySlot + } + + val enabled = menu.getState<Boolean>(player, "favoritesOnly") ?: false + + return slot( + buildGuiItem( + configPath, + mapOf("state" to enabled.parseEnabledDisabled()) + ) + ) { + onLeftClick { event, _ -> + val clickedPlayer = event.whoClicked as Player + val current = menu.getState<Boolean>(clickedPlayer, "favoritesOnly") ?: false + + menu.setState(clickedPlayer, "favoritesOnly", !current) + menu.setState(clickedPlayer, Page.PAGE_KEY, 1) + clickedPlayer.playConfiguredSound("$configPath.sound") + PlayerExperience.handleFilterChanged( + clickedPlayer, + "favorites-only", + (!current).parseEnabledDisabled() + ) + menu.open(clickedPlayer) + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + } + + private class SortCycleButton : GUIComponent { + private val emptySlot = slot(ItemStack(Material.AIR)) + private val configPath = "enchant-gui.sort" + private val order = listOf("", "name", "rarity", "level") + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + if (!plugin.configYml.getBool("$configPath.enabled")) { + return emptySlot + } + + val current = menu.getState<String>(player, "sortOrder").orEmpty() + + return slot( + buildGuiItem( + configPath, + mapOf("current" to sortDisplayName(current)) + ) + ) { + onLeftClick { event, _ -> + val clickedPlayer = event.whoClicked as Player + val cur = menu.getState<String>(clickedPlayer, "sortOrder").orEmpty() + val next = order[(order.indexOf(cur).coerceAtLeast(0) + 1) % order.size] + + menu.setState(clickedPlayer, "sortOrder", next) + menu.setState(clickedPlayer, Page.PAGE_KEY, 1) + clickedPlayer.playConfiguredSound("$configPath.sound") + menu.open(clickedPlayer) + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + } + + private class FavoriteButton( + private val enchant: EcoEnchant, + private val level: Int + ) : GUIComponent { + private val emptySlot = slot(ItemStack(Material.AIR)) + private val configPath = "enchantinfo.favorite" + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + if (!plugin.configYml.getBool("$configPath.enabled")) { + return emptySlot + } + + val isFavorite = Favorites.contains(player, enchant) + val itemPath = if (isFavorite) "$configPath.remove" else "$configPath.add" + + return slot(buildGuiItem(itemPath)) { + onLeftClick { event, _ -> + val clickedPlayer = event.whoClicked as Player + val nowFavorite = Favorites.toggle(clickedPlayer, enchant) + + clickedPlayer.playConfiguredSound("$configPath.sound") + clickedPlayer.sendLangMessage( + if (nowFavorite) "favorite-added" else "favorite-removed", + "enchant" to enchant.getFormattedName(level) + ) + menu.open(clickedPlayer) + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + } + + private class AdminToolButton( + private val tool: AdminTool + ) : GUIComponent { + private val emptySlot = slot(ItemStack(Material.AIR)) + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + if (!player.hasPermission(tool.permission)) { + return emptySlot + } + + return slot( + buildGuiItem(tool.configPath) + ) { + onLeftClick { event, _ -> + runAdminTool(event.whoClicked as Player, tool) + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + } + + private enum class AdminTool( + val configKey: String, + val permission: String + ) { + RELOAD("reload", "ecoenchants.command.reload"), + RANDOM_BOOK("random-book", "ecoenchants.command.giverandombook"); + + val configPath = "admin-gui.tools.$configKey" } } @@ -351,7 +929,11 @@ private class EnchantmentScrollPane : GUIComponent { val enchants = menu.getState<List<EcoEnchant>>(player, "enchants") ?: return defaultSlot if (enchants.isEmpty()) { - return defaultSlot + return if (row == (rows + 1) / 2 && column == (columns + 1) / 2) { + getEmptyResultsSlot(player, menu) + } else { + defaultSlot + } } val enchant = enchants.getOrNull(index + size * (page - 1)) ?: return defaultSlot @@ -360,15 +942,431 @@ private class EnchantmentScrollPane : GUIComponent { return enchant.getInformationSlot(player, displayLevel) } + private fun getEmptyResultsSlot(player: Player, menu: Menu): Slot { + val configPath = "enchant-gui.empty-results" + if (!plugin.configYml.has("$configPath.item")) { + return defaultSlot + } + + val hasItem = menu.getState<Boolean>(player, "hasItem") ?: false + val groupId = menu.getState<String>(player, "groupId") + val placeholders = mapOf( + "group" to (groupId?.let { getGroupDisplayName(it) } ?: plugin.langYml.getFormattedString("all")) + ) + + val loreKeyPath = when { + hasItem && groupId != null && plugin.configYml.has("$configPath.with-item-and-group-lore-key") -> + "$configPath.with-item-and-group-lore-key" + hasItem && plugin.configYml.has("$configPath.with-item-lore-key") -> + "$configPath.with-item-lore-key" + groupId != null && plugin.configYml.has("$configPath.group-lore-key") -> + "$configPath.group-lore-key" + else -> + "$configPath.lore-key" + } + + val loreKeyOverride = if (plugin.configYml.has(loreKeyPath)) { + plugin.configYml.getString(loreKeyPath) + } else { + null + } + + return slot( + buildGuiItem( + configPath, + placeholders, + loreKeyOverride + ) + ) + } + override fun getRows() = plugin.configYml.getInt("enchant-gui.enchant-area.height") override fun getColumns() = plugin.configYml.getInt("enchant-gui.enchant-area.width") val size = rows * columns } +private enum class GuiFilterAxis( + val label: String, + val stateKey: String, + val configPath: String +) { + TYPE("type", "filterType", "enchant-gui.filters.type"), + RARITY("rarity", "filterRarity", "enchant-gui.filters.rarity"), + TARGET("target", "filterTarget", "enchant-gui.filters.target"); + + fun options(): List<GuiFilterValue> { + return when (this) { + TYPE -> EnchantmentTypes.values() + .map { GuiFilterValue(it.id, it.id.toDisplayName()) } + RARITY -> EnchantmentRarities.values() + .map { GuiFilterValue(it.id, it.displayName) } + TARGET -> EnchantmentTargets.values() + .filterNot { it.id.equals("all", ignoreCase = true) } + .map { GuiFilterValue(it.id, it.displayName) } + }.sortedBy { it.displayName.stripLegacyFormattingForSort() } + } + + fun currentValue(player: Player, menu: Menu): GuiFilterValue? { + val currentId = menu.getState<String>(player, stateKey).orEmpty() + if (currentId.isBlank()) { + return null + } + + return options().firstOrNull { it.id.equals(currentId, ignoreCase = true) } + } + + fun currentDisplay(player: Player, menu: Menu): String { + return currentValue(player, menu)?.displayName ?: plugin.langYml.getFormattedString("all") + } + + fun nextValue(player: Player, menu: Menu): GuiFilterValue? { + val availableOptions = options() + if (availableOptions.isEmpty()) { + return null + } + + val current = currentValue(player, menu) ?: return availableOptions.first() + val currentIndex = availableOptions.indexOfFirst { it.id == current.id } + val nextIndex = currentIndex + 1 + + return if (currentIndex == -1 || nextIndex >= availableOptions.size) { + null + } else { + availableOptions[nextIndex] + } + } +} + +private data class GuiFilterValue( + val id: String, + val displayName: String +) + private val cachedEnchantmentSlots = EcoCache.builder<Pair<EcoEnchant, Int>, Slot>() .build() +private val applicableEnchantmentsSorted = EcoCache.builder<HashedItem, List<EcoEnchant>>() + .build() + +private enum class GuiPageDirection { + FORWARDS, + BACKWARDS +} + +private class EnchantPageChanger( + private val direction: GuiPageDirection +) : GUIComponent { + private val configPath = "enchant-gui.page-change.${direction.name.lowercase(Locale.ROOT)}" + private val emptySlot = slot(ItemStack(Material.AIR)) + + override fun getSlotAt(row: Int, column: Int, player: Player, menu: Menu): Slot { + val maxPage = getMaxPage(player, menu) + val currentPage = menu.getPage(player).coerceAtLeast(1) + + if (!canChangePage(currentPage, maxPage)) { + return emptySlot + } + + val item = buildGuiItem( + configPath, + mapOf( + "page" to currentPage.toString(), + "max_page" to maxPage.toString() + ) + ) + + return slot(item) { + onLeftClick { event, _ -> + val clickedPlayer = event.whoClicked as Player + val clickedMaxPage = getMaxPage(clickedPlayer, menu) + val clickedPage = menu.getPage(clickedPlayer).coerceAtLeast(1) + + if (!canChangePage(clickedPage, clickedMaxPage)) { + return@onLeftClick + } + + val nextPage = when (direction) { + GuiPageDirection.FORWARDS -> clickedPage + 1 + GuiPageDirection.BACKWARDS -> clickedPage - 1 + } + + menu.setState(clickedPlayer, Page.PAGE_KEY, nextPage.coerceIn(1, clickedMaxPage)) + clickedPlayer.playPageChangeSound(configPath) + clickedPlayer.sendLangMessage( + "changed-enchant-page", + "page" to nextPage.toString(), + "max_page" to clickedMaxPage.toString() + ) + } + } + } + + override fun getRows() = 1 + override fun getColumns() = 1 + + private fun canChangePage(currentPage: Int, maxPage: Int): Boolean { + return when (direction) { + GuiPageDirection.FORWARDS -> currentPage < maxPage + GuiPageDirection.BACKWARDS -> currentPage > 1 && maxPage > 1 + } + } + + private fun getMaxPage(player: Player, menu: Menu): Int { + val enchants = menu.getState<List<EcoEnchant>>(player, "enchants") ?: emptyList() + val perPage = plugin.configYml.getInt("enchant-gui.enchant-area.width") * + plugin.configYml.getInt("enchant-gui.enchant-area.height") + + if (enchants.isEmpty() || perPage <= 0) { + return 0 + } + + return ceil(enchants.size.toDouble() / perPage).toInt() + } +} + +private fun Collection<EcoEnchant>.sortForGui(): List<EcoEnchant> { + val byEnchantment = this.associateBy { it.enchantment } + return this.map { it.enchantment }.sortForDisplay() + .mapNotNull { byEnchantment[it] } +} + +// Per-player, per-session sort override for the enchant browser. Blank keeps the +// configured global display order (already applied to the base lists). +private fun List<EcoEnchant>.applyGuiSort(order: String): List<EcoEnchant> = when (order) { + "name" -> this.sortedBy { it.getFormattedName(0).stripLegacyFormattingForSort().lowercase(Locale.ROOT) } + "rarity" -> this.sortedWith( + compareBy<EcoEnchant> { RaritySorter.orderOf(it.enchantmentRarity) ?: Int.MAX_VALUE } + .thenBy { it.getFormattedName(0).stripLegacyFormattingForSort().lowercase(Locale.ROOT) } + ) + "level" -> this.sortedWith( + compareByDescending<EcoEnchant> { it.maximumLevel } + .thenBy { it.getFormattedName(0).stripLegacyFormattingForSort().lowercase(Locale.ROOT) } + ) + else -> this +} + +private fun sortDisplayName(order: String): String { + val key = order.ifBlank { "default" } + return plugin.langYml.getFormattedString("gui.enchant.sort.values.$key") +} + +private fun getConfiguredGuiTitle(configPath: String): String { + return if (plugin.configYml.has("$configPath.title-key")) { + plugin.langYml.getFormattedString(plugin.configYml.getString("$configPath.title-key")) + } else { + plugin.configYml.getFormattedString("$configPath.title") + } +} + +private fun buildGuiItem( + configPath: String, + placeholders: Map<String, String> = emptyMap(), + loreKeyOverride: String? = null +): ItemStack { + return buildGuiItem(plugin.configYml.getSubsection(configPath), placeholders, loreKeyOverride) +} + +private fun buildGuiItem( + config: Config, + placeholders: Map<String, String> = emptyMap(), + loreKeyOverride: String? = null +): ItemStack { + val builder = ItemStackBuilder( + Items.lookup(config.getString("item").replacePlaceholders(placeholders)) + ) + + if (config.has("name-key")) { + builder.setDisplayName( + plugin.langYml.getFormattedString(config.getString("name-key")) + .replacePlaceholders(placeholders) + ) + } else if (config.has("name")) { + builder.setDisplayName(config.getString("name").replacePlaceholders(placeholders).formatEco()) + } + + if (loreKeyOverride != null) { + builder.addLoreLines(plugin.langYml.getStrings(loreKeyOverride).map { + it.replacePlaceholders(placeholders) + }.formatEco()) + } else if (config.has("lore-key")) { + builder.addLoreLines(getConfiguredGuiLore(config, placeholders).formatEco()) + } else if (config.has("lore")) { + builder.addLoreLines(getConfiguredGuiLore(config, placeholders).formatEco()) + } + + return builder.build() +} + +private fun getConfiguredGuiLore(configPath: String, placeholders: Map<String, String> = emptyMap()): List<String> { + return getConfiguredGuiLore(plugin.configYml.getSubsection(configPath), placeholders) +} + +private fun getConfiguredGuiLore(config: Config, placeholders: Map<String, String> = emptyMap()): List<String> { + val lore = if (config.has("lore-key")) { + plugin.langYml.getStrings(config.getString("lore-key")) + } else if (config.has("lore")) { + config.getStrings("lore") + } else { + emptyList() + } + + return lore.map { it.replacePlaceholders(placeholders) } +} + +private fun String.replacePlaceholders(placeholders: Map<String, String>): String { + var result = this + for ((key, value) in placeholders) { + result = result.replace("%$key%", value) + } + return result +} + +private fun Player.sendLangMessage(key: String, vararg replacements: Pair<String, String>) { + var message = plugin.langYml.getMessage(key, StringUtils.FormatOption.WITHOUT_PLACEHOLDERS) + + for ((placeholder, value) in replacements) { + message = message.replace("%$placeholder%", value) + } + + this.sendMessage(message) +} + +private fun getGroupDisplayName(groupId: String): String { + val groupBy = plugin.configYml.getString("enchant-gui.group-by") + + return getConfiguredGroupDisplayName(groupId) ?: when (groupBy) { + "type" -> EnchantmentTypes[groupId]?.id?.toDisplayName() + "rarity" -> EnchantmentRarities[groupId]?.displayName + "target" -> EnchantmentTargets[groupId]?.displayName + else -> null + } ?: groupId +} + +private fun getConfiguredGroupDisplayName(groupId: String): String? { + val config = plugin.configYml.getSubsections("group-gui.groups") + .firstOrNull { it.getString("id") == groupId } + ?: return null + + return when { + config.has("name-key") -> plugin.langYml.getFormattedString(config.getString("name-key")) + config.has("name") -> config.getString("name").formatEco() + else -> null + } +} + +private fun EcoEnchant.matchesGroupFilter(groupId: String?): Boolean { + if (groupId.isNullOrBlank()) { + return true + } + + return when (plugin.configYml.getString("enchant-gui.group-by")) { + "type" -> this.type.id == groupId + "rarity" -> this.enchantmentRarity.id == groupId + "target" -> this.targets.any { it.id == groupId } + else -> true + } +} + +private fun EcoEnchant.matchesCycleFilters(player: Player, menu: Menu): Boolean { + val typeId = menu.getState<String>(player, GuiFilterAxis.TYPE.stateKey).orEmpty() + val rarityId = menu.getState<String>(player, GuiFilterAxis.RARITY.stateKey).orEmpty() + val targetId = menu.getState<String>(player, GuiFilterAxis.TARGET.stateKey).orEmpty() + + if (typeId.isNotBlank() && this.type.id != typeId) { + return false + } + + if (rarityId.isNotBlank() && this.enchantmentRarity.id != rarityId) { + return false + } + + if (targetId.isNotBlank() && this.targets.none { it.id == targetId }) { + return false + } + + return true +} + +private fun Menu.hasCycleFilters(player: Player): Boolean { + return GuiFilterAxis.entries.any { + this.getState<String>(player, it.stateKey).orEmpty().isNotBlank() + } +} + +private fun getEmptyResultReason( + hasItem: Boolean, + groupId: String?, + hasCycleFilters: Boolean, + compatibleOnly: Boolean +): String { + return when { + hasItem && groupId != null -> "item-and-group" + hasItem && hasCycleFilters -> "item-and-filter" + hasItem && compatibleOnly -> "item-compatible" + groupId != null || hasCycleFilters -> "filter" + else -> "none-loaded" + } +} + +private fun String.toDisplayName(): String { + return this.split('_', '-') + .filter { it.isNotBlank() } + .joinToString(" ") { part -> + part.replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase(Locale.ROOT) else char.toString() + } + } +} + +private fun String.stripLegacyFormattingForSort(): String { + return this.replace(Regex("&[0-9a-fk-orA-FK-OR]"), "") + .replace(Regex("<[^>]+>"), "") +} + +private fun Boolean.parseEnabledDisabled(): String { + return if (this) { + plugin.langYml.getFormattedString("enabled") + } else { + plugin.langYml.getFormattedString("disabled") + } +} + +private fun Player.hasAdminToolsPermission(): Boolean { + return this.hasPermission("ecoenchants.command.reload") + || this.hasPermission("ecoenchants.command.giverandombook") +} + +private fun Player.playPageChangeSound(configPath: String) { + this.playConfiguredSound("$configPath.sound") +} + +private fun Player.playConfiguredSound(soundPath: String) { + if (!plugin.configYml.has(soundPath)) { + return + } + + val sound = plugin.configYml.getString(soundPath) + if (sound.isBlank()) { + return + } + + val configPath = soundPath.removeSuffix(".sound") + val volume = if (plugin.configYml.has("$configPath.sound-volume")) { + plugin.configYml.getDouble("$configPath.sound-volume").toFloat() + } else { + 1.0f + } + + val pitch = if (plugin.configYml.has("$configPath.sound-pitch")) { + plugin.configYml.getDouble("$configPath.sound-pitch").toFloat() + } else { + 1.0f + } + + this.playSound(this.location, sound, volume, pitch) +} + private fun EcoEnchant.getInformationSlot(player: Player, level: Int): Slot { return cachedEnchantmentSlots.get(this to level) { slot( @@ -378,39 +1376,32 @@ private fun EcoEnchant.getInformationSlot(player: Player, level: Int): Slot { .setDisplayName(this.getFormattedName(level)) .addLoreLines(this.getFormattedDescription(level, player)) .addLoreLines { - plugin.configYml.getStrings("enchantinfo.item.lore") - .map { - it.replace("%max_level%", enchantment.maxLevel.toString()) - .replace("%rarity%", this.enchantmentRarity.displayName) - .replace( - "%targets%", - this.targets.joinToString(", ") { target -> target.displayName } - ) - .replace( - "%conflicts%", - if (this.conflictsWithEverything) { - plugin.langYml.getFormattedString("all-conflicts") - } else { - this.conflicts.joinToString(", ") { conflict -> - conflict.wrap().getFormattedName(0) - }.ifEmpty { plugin.langYml.getFormattedString("no-conflicts") } - } - ) - .replace( - "%required%", - this.required.joinToString(", ") { required -> - required.wrap().getFormattedName(0) - }.ifEmpty { plugin.langYml.getFormattedString("no-required") } - ) - .replace("%tradeable%", this.isObtainableThroughTrading.parseLangOption("tradeable")) - .replace("%discoverable%", this.isObtainableThroughDiscovery.parseDiscoverable()) - .replace("%discoverable_chests%", this.isObtainableThrough(DiscoveryType.CHESTS).parseDiscoverable(DiscoveryType.CHESTS)) - .replace("%discoverable_fishing%", this.isObtainableThrough(DiscoveryType.FISHING).parseDiscoverable(DiscoveryType.FISHING)) - .replace("%discoverable_mob_drops%", this.isObtainableThrough(DiscoveryType.MOB_DROPS).parseDiscoverable(DiscoveryType.MOB_DROPS)) - .replace("%discoverable_raids%", this.isObtainableThrough(DiscoveryType.RAIDS).parseDiscoverable(DiscoveryType.RAIDS)) - .replace("%enchantable%", this.isObtainableThroughEnchanting.parseLangOption("enchantable")) - .replace("%drag_and_drop%", this.isDragAndDropEnabled().parseLangOption("drag-and-drop")) - } + getConfiguredGuiLore( + "enchantinfo.item", + mapOf( + "max_level" to enchantment.maxLevel.toString(), + "rarity" to this.enchantmentRarity.displayName, + "targets" to this.targets.joinToString(", ") { target -> target.displayName }, + "conflicts" to if (this.conflictsWithEverything) { + plugin.langYml.getFormattedString("all-conflicts") + } else { + this.conflicts.joinToString(", ") { conflict -> + conflict.wrap().getFormattedName(0) + }.ifEmpty { plugin.langYml.getFormattedString("no-conflicts") } + }, + "required" to this.required.joinToString(", ") { required -> + required.wrap().getFormattedName(0) + }.ifEmpty { plugin.langYml.getFormattedString("no-required") }, + "tradeable" to this.isObtainableThroughTrading.parseYesOrNo(), + "discoverable" to this.isObtainableThroughDiscovery.parseDiscoverable(), + "discoverable_chests" to this.isObtainableThrough(DiscoveryType.CHESTS).parseDiscoverable(DiscoveryType.CHESTS), + "discoverable_fishing" to this.isObtainableThrough(DiscoveryType.FISHING).parseDiscoverable(DiscoveryType.FISHING), + "discoverable_mob_drops" to this.isObtainableThrough(DiscoveryType.MOB_DROPS).parseDiscoverable(DiscoveryType.MOB_DROPS), + "discoverable_raids" to this.isObtainableThrough(DiscoveryType.RAIDS).parseDiscoverable(DiscoveryType.RAIDS), + "enchantable" to this.isObtainableThroughEnchanting.parseYesOrNo(), + "drag_and_drop" to this.isDragAndDropEnabled().parseYesOrNo() + ) + ) .formatEco() .flatMap { it.lineWrap(32, true) diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/LoreConversion.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/LoreConversion.kt index bb3d169b8b..aff648c861 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/LoreConversion.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/LoreConversion.kt @@ -3,6 +3,7 @@ package com.willfp.ecoenchants.enchant import com.willfp.eco.core.fast.fast import com.willfp.eco.util.NumberUtils import com.willfp.ecoenchants.plugin +import com.willfp.ecoenchants.stripLegacyFormatting import org.bukkit.enchantments.Enchantment import org.bukkit.event.EventHandler import org.bukkit.event.Listener @@ -47,16 +48,20 @@ object LoreConversion : Listener { return } - val meta = itemStack.itemMeta ?: return + val fast = itemStack.fast() + val lore = fast.lore + if (lore.isEmpty()) { + return + } + val meta = itemStack.itemMeta ?: return val toAdd = mutableMapOf<Enchantment, Int>() val matchedLines = mutableListOf<String>() - val lore = itemStack.fast().lore.toMutableList() + val updatedLore = lore.toMutableList() - for (line in lore.toList()) { - @Suppress("DEPRECATION") - val uncolored = org.bukkit.ChatColor.stripColor(line) ?: continue + for (line in lore) { + val uncolored = line.stripLegacyFormatting() var enchant: EcoEnchant? var level: Int @@ -97,19 +102,23 @@ object LoreConversion : Listener { } + if (toAdd.isEmpty()) { + return + } + if (meta is EnchantmentStorageMeta) { - lore.removeAll(matchedLines) + updatedLore.removeAll(matchedLines) for ((enchant, level) in toAdd) { meta.addStoredEnchant(enchant, level, true) } } else { - lore.removeAll(matchedLines) + updatedLore.removeAll(matchedLines) for ((enchant, level) in toAdd) { meta.addEnchant(enchant, level, true) } } itemStack.itemMeta = meta - itemStack.fast().lore = lore + itemStack.fast().lore = updatedLore } -} \ No newline at end of file +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/Util.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/Util.kt index 6b915fdc00..a2cc3a3319 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/Util.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/Util.kt @@ -19,6 +19,18 @@ fun Enchantment.wrap(): EcoEnchantLike { } } +@Suppress("DEPRECATION") +fun getEnchantmentByID(id: String): Enchantment? { + val normalized = id.lowercase() + + if (":" in normalized) { + return NamespacedKey.fromString(normalized)?.let { Enchantment.getByKey(it) } + } + + return Enchantment.getByKey(plugin.createNamespacedKey(normalized)) + ?: Enchantment.getByKey(NamespacedKey.minecraft(normalized)) +} + fun Enchantment.conflictsWithDeep(other: Enchantment): Boolean { return this.conflictsWith(other) || other.conflictsWith(this) } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/VanillaEnchantments.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/VanillaEnchantments.kt index b625d6cc2a..ef25198b7a 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/VanillaEnchantments.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/VanillaEnchantments.kt @@ -5,8 +5,11 @@ import org.bukkit.NamespacedKey import org.bukkit.enchantments.Enchantment val Enchantment.vanillaEnchantmentData: VanillaEnchantmentData? + get() = key.vanillaEnchantmentData + +val NamespacedKey.vanillaEnchantmentData: VanillaEnchantmentData? get() { - val vanilla = plugin.vanillaEnchantsYml.getSubsectionOrNull(key.key) ?: return null + val vanilla = plugin.vanillaEnchantsYml.getSubsectionOrNull(key) ?: return null return VanillaEnchantmentData( vanilla.getIntOrNull("max-level"), diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/EcoEnchantBase.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/EcoEnchantBase.kt index 7dee59b113..305c150e5d 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/EcoEnchantBase.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/EcoEnchantBase.kt @@ -8,6 +8,7 @@ import com.willfp.ecoenchants.display.getFormattedName import com.willfp.ecoenchants.enchant.DiscoveryType import com.willfp.ecoenchants.enchant.EcoEnchant import com.willfp.ecoenchants.enchant.EcoEnchantLevel +import com.willfp.ecoenchants.enchant.getEnchantmentByID import com.willfp.ecoenchants.rarity.EnchantmentRarities import com.willfp.ecoenchants.rarity.EnchantmentRarity import com.willfp.ecoenchants.target.EnchantmentTargets @@ -16,8 +17,8 @@ import com.willfp.ecoenchants.type.EnchantmentTypes import com.willfp.libreforge.SilentViolationContext import com.willfp.libreforge.ViolationContext import com.willfp.libreforge.conditions.Conditions +import com.willfp.libreforge.slot.SlotType import org.bukkit.Bukkit -import org.bukkit.NamespacedKey import org.bukkit.enchantments.Enchantment import org.bukkit.permissions.Permission import org.bukkit.permissions.PermissionDefault @@ -41,7 +42,7 @@ abstract class EcoEnchantBase( private val requiredIds = config.getStrings("required").toSet() - override val enchantmentKey = NamespacedKey.minecraft(id) + override val enchantmentKey = plugin.createNamespacedKey(id) override val rawDisplayName = config.getString("display-name") @@ -55,15 +56,13 @@ abstract class EcoEnchantBase( override val conflicts = config.getStrings("conflicts") .mapNotNull { - @Suppress("DEPRECATION") - Enchantment.getByKey(NamespacedKey.minecraft(it)) + getEnchantmentByID(it) } .toSet() override val required = config.getStrings("required") .mapNotNull { - @Suppress("DEPRECATION") - Enchantment.getByKey(NamespacedKey.minecraft(it)) + getEnchantmentByID(it) } .toSet() @@ -71,6 +70,8 @@ abstract class EcoEnchantBase( .mapNotNull { EnchantmentTargets[it] } .toSet() + override val slots: Set<SlotType> = targets.map { it.slot }.toSet() + override val type: EnchantmentType = config.getString("type") .let { EnchantmentTypes[it] } ?: EnchantmentTypes.values().first() diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/HardcodedEcoEnchant.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/HardcodedEcoEnchant.kt index 8f06216725..3a464a3ccb 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/HardcodedEcoEnchant.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/HardcodedEcoEnchant.kt @@ -12,17 +12,57 @@ abstract class HardcodedEcoEnchant( id: String, ) : EcoEnchantBase(id, plugin) { private val file: File? - get() = File(plugin.dataFolder, "enchants") - .walk() - .firstOrNull { file -> file.nameWithoutExtension == id } + get() = filesById[id] - val isPresent = file != null + val isPresent: Boolean + get() = file != null final override fun loadConfig(): Config { - return file.readConfig(ConfigType.YAML) + return requireNotNull(file) { + "Could not find hardcoded enchant config for $id" + }.readConfig(ConfigType.YAML) } override fun createLevel(level: Int): EcoEnchantLevel { return EcoEnchantLevel(this, level, emptyEffectList(), conditions) } + + companion object { + private val hardcodedDefaults = setOf( + "permanence_curse", + "repairing", + "replenish", + "soulbound" + ) + + private var filesById = emptyMap<String, File>() + + internal fun reload() { + ensureDefaultConfigs() + + val enchantsFolder = File(plugin.dataFolder, "enchants") + filesById = if (enchantsFolder.exists()) { + enchantsFolder.walk() + .filter { it.isFile } + .associateBy { it.nameWithoutExtension } + } else { + emptyMap() + } + } + + private fun ensureDefaultConfigs() { + for (id in hardcodedDefaults) { + val path = "enchants/$id.yml" + val file = File(plugin.dataFolder, path) + + if (file.exists()) { + continue + } + + runCatching { + plugin.saveResource(path, false) + } + } + } + } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/LibreforgeEcoEnchant.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/LibreforgeEcoEnchant.kt index 8b48454c1c..a16d6f8fe6 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/LibreforgeEcoEnchant.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/LibreforgeEcoEnchant.kt @@ -1,7 +1,6 @@ package com.willfp.ecoenchants.enchant.impl import com.willfp.eco.core.config.interfaces.Config -import com.willfp.eco.util.containsIgnoreCase import com.willfp.ecoenchants.enchant.EcoEnchantLevel import com.willfp.ecoenchants.enchant.MissingDependencyException import com.willfp.ecoenchants.plugin @@ -22,9 +21,11 @@ class LibreforgeEcoEnchant( init { val missingPlugins = mutableSetOf<String>() + val loadedPluginNames = Bukkit.getPluginManager().plugins + .mapTo(mutableSetOf()) { it.name.lowercase() } for (dependency in config.getStrings("dependencies")) { - if (!Bukkit.getPluginManager().plugins.map { it.name }.containsIgnoreCase(dependency)) { + if (dependency.lowercase() !in loadedPluginNames) { missingPlugins += dependency } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/hardcoded/EnchantmentRepairing.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/hardcoded/EnchantmentRepairing.kt index fb20f13fdd..07cd560fde 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/hardcoded/EnchantmentRepairing.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/hardcoded/EnchantmentRepairing.kt @@ -3,10 +3,10 @@ package com.willfp.ecoenchants.enchant.impl.hardcoded import com.willfp.eco.util.DurabilityUtils import com.willfp.ecoenchants.enchant.impl.HardcodedEcoEnchant import com.willfp.ecoenchants.target.EnchantFinder.getItemsWithEnchantActive -import com.willfp.ecoenchants.target.EnchantFinder.hasEnchantActive -import com.willfp.libreforge.slot.impl.SlotTypeArmor import com.willfp.libreforge.slot.impl.SlotTypeHands import org.bukkit.Bukkit +import org.bukkit.entity.Player +import java.util.function.Consumer object EnchantmentRepairing : HardcodedEcoEnchant( "repairing" @@ -15,30 +15,35 @@ object EnchantmentRepairing : HardcodedEcoEnchant( val frequency = config.getInt("frequency").toLong() plugin.scheduler.runTimer(frequency, frequency) { - handleRepairing() + for (player in Bukkit.getOnlinePlayers()) { + player.scheduler.run(plugin, Consumer { + handleRepairing(player) + }, null) + } } } - private fun handleRepairing() { + private fun handleRepairing(player: Player) { val notWhileHolding = config.getBool("not-while-holding") - for (player in Bukkit.getOnlinePlayers()) { - if (player.hasEnchantActive(this)) { - val repairPerLevel = config.getIntFromExpression("repair-per-level", player) - - for ((item, level) in player.getItemsWithEnchantActive(this)) { - if (notWhileHolding) { - val isHolding = item in SlotTypeHands.getItems(player) - val isEquipped = item in SlotTypeArmor.getItems(player) + val activeItems = player.getItemsWithEnchantActive(this) + if (activeItems.isEmpty()) { + return + } - if (isHolding || isEquipped) { - continue - } - } + val repairPerLevel = config.getIntFromExpression("repair-per-level", player) + val excludedItems = if (notWhileHolding) { + SlotTypeHands.getItems(player).toSet() + } else { + emptySet() + } - DurabilityUtils.repairItem(item, level * repairPerLevel) - } + for ((item, level) in activeItems) { + if (item in excludedItems) { + continue } + + DurabilityUtils.repairItem(item, level * repairPerLevel) } } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/hardcoded/EnchantmentReplenish.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/hardcoded/EnchantmentReplenish.kt index 10fc4d33a5..262f538587 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/hardcoded/EnchantmentReplenish.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/enchant/impl/hardcoded/EnchantmentReplenish.kt @@ -13,11 +13,32 @@ import org.bukkit.event.block.BlockBreakEvent import org.bukkit.event.block.BlockPlaceEvent import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.ItemStack +import java.util.EnumSet object EnchantmentReplenish : HardcodedEcoEnchant( "replenish" ) { private var handler = ReplenishHandler(this) + private val ignoredCropTypes = EnumSet.of( + Material.GLOW_BERRIES, + Material.SWEET_BERRY_BUSH, + Material.CACTUS, + Material.BAMBOO, + Material.CHORUS_FLOWER, + Material.SUGAR_CANE + ) + private val seedTypes = mapOf( + Material.WHEAT to Material.WHEAT_SEEDS, + Material.POTATOES to Material.POTATO, + Material.CARROTS to Material.CARROT, + Material.BEETROOTS to Material.BEETROOT_SEEDS, + Material.COCOA to Material.COCOA_BEANS, + Material.NETHER_WART to Material.NETHER_WART, + Material.TORCHFLOWER_CROP to Material.TORCHFLOWER_SEEDS, + Material.PITCHER_CROP to Material.PITCHER_POD, + Material.MELON_STEM to Material.MELON_SEEDS, + Material.PUMPKIN_STEM to Material.PUMPKIN_SEEDS + ) override fun onRegister() { plugin.eventManager.registerListener(handler) @@ -34,24 +55,10 @@ object EnchantmentReplenish : HardcodedEcoEnchant( ignoreCancelled = true ) fun handle(event: BlockBreakEvent) { - val player = event.player - - if (!player.hasEnchantActive(enchant)) { - return - } - val block = event.block val type = block.type - if (type in arrayOf( - Material.GLOW_BERRIES, - Material.SWEET_BERRY_BUSH, - Material.CACTUS, - Material.BAMBOO, - Material.CHORUS_FLOWER, - Material.SUGAR_CANE - ) - ) { + if (type in ignoredCropTypes) { return } @@ -61,17 +68,24 @@ object EnchantmentReplenish : HardcodedEcoEnchant( return } + val player = event.player + + if (!player.hasEnchantActive(enchant)) { + return + } + + val wasFullyGrown = data.age == data.maximumAge + if (!wasFullyGrown && enchant.config.getBool("only-fully-grown")) { + return + } + if (enchant.config.getBool("consume-seeds")) { - val item = ItemStack( - when (type) { - Material.WHEAT -> Material.WHEAT_SEEDS - Material.POTATOES -> Material.POTATO - Material.CARROTS -> Material.CARROT - Material.BEETROOTS -> Material.BEETROOT_SEEDS - Material.COCOA -> Material.COCOA_BEANS - else -> type - } - ) + val seedType = seedTypes[type] ?: type + if (!seedType.isItem) { + return + } + + val item = ItemStack(seedType) val hasSeeds = player.inventory.removeItem(item).isEmpty() @@ -80,34 +94,39 @@ object EnchantmentReplenish : HardcodedEcoEnchant( } } - if (data.age != data.maximumAge) { - if (enchant.config.getBool("only-fully-grown")) { - return - } - + if (!wasFullyGrown) { event.isDropItems = false event.expToDrop = 0 } data.age = 0 + val itemInHand = player.inventory.itemInMainHand.clone() plugin.scheduler.run { + if (!block.type.isAir) { + return@run + } + + val replacedState = block.state block.type = type block.blockData = data // Improves compatibility with other plugins. @Suppress("UnstableApiUsage") - Bukkit.getPluginManager().callEvent( - BlockPlaceEvent( - block, - block.state, - block.getRelative(BlockFace.DOWN), - player.inventory.itemInMainHand, - player, - true, - EquipmentSlot.HAND - ) + val placeEvent = BlockPlaceEvent( + block, + replacedState, + block.getRelative(BlockFace.DOWN), + itemInHand, + player, + true, + EquipmentSlot.HAND ) + Bukkit.getPluginManager().callEvent(placeEvent) + + if (placeEvent.isCancelled || !placeEvent.canBuild()) { + replacedState.update(true, false) + } } } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/experience/Favorites.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/experience/Favorites.kt new file mode 100644 index 0000000000..fd0f7964e1 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/experience/Favorites.kt @@ -0,0 +1,44 @@ +package com.willfp.ecoenchants.experience + +import com.willfp.eco.core.data.keys.PersistentDataKey +import com.willfp.eco.core.data.keys.PersistentDataKeyType +import com.willfp.eco.core.data.profile +import com.willfp.ecoenchants.enchant.EcoEnchant +import com.willfp.ecoenchants.enchant.EcoEnchants +import com.willfp.ecoenchants.plugin +import org.bukkit.entity.Player + +/** + * Per-player enchantment bookmarks. Stores enchant IDs in a STRING_LIST profile key, + * mirroring the persistence pattern used by [com.willfp.ecoenchants.enchant.impl.hardcoded.EnchantmentSoulbound]. + */ +object Favorites { + private val favoritesKey = PersistentDataKey( + plugin.namespacedKeyFactory.create("favorite_enchants"), + PersistentDataKeyType.STRING_LIST, + emptyList() + ) + + /** The player's favorited enchants, dropping any IDs that no longer resolve. */ + fun list(player: Player): List<EcoEnchant> = + player.profile.read(favoritesKey).mapNotNull { EcoEnchants.getByID(it) } + + fun contains(player: Player, enchant: EcoEnchant): Boolean = + player.profile.read(favoritesKey).contains(enchant.id) + + /** Toggles [enchant] in the player's favorites. Returns true if it is now a favorite. */ + fun toggle(player: Player, enchant: EcoEnchant): Boolean { + val current = player.profile.read(favoritesKey).toMutableList() + + val nowFavorite = if (current.contains(enchant.id)) { + current.remove(enchant.id) + false + } else { + current.add(enchant.id) + true + } + + player.profile.write(favoritesKey, current) + return nowFavorite + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/experience/PlayerExperience.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/experience/PlayerExperience.kt new file mode 100644 index 0000000000..82b7c71d97 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/experience/PlayerExperience.kt @@ -0,0 +1,293 @@ +package com.willfp.ecoenchants.experience + +import com.willfp.eco.core.data.keys.PersistentDataKey +import com.willfp.eco.core.data.keys.PersistentDataKeyType +import com.willfp.eco.core.data.profile +import com.willfp.eco.util.NamespacedKeyUtils +import com.willfp.eco.util.formatEco +import com.willfp.ecoenchants.enchant.EcoEnchants +import com.willfp.ecoenchants.plugin +import com.willfp.ecoenchants.sendActionBarHint +import com.willfp.ecoenchants.sendClickableLine +import com.willfp.ecoenchants.target.EnchantmentTargets.applicableEnchantments +import org.bukkit.Material +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerItemHeldEvent +import org.bukkit.event.player.PlayerJoinEvent +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.BookMeta +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +object PlayerExperience : Listener { + private val seenJoinHintKey = PersistentDataKey( + NamespacedKeyUtils.create("ecoenchants", "seen_join_hint"), + PersistentDataKeyType.BOOLEAN, + false + ) + + private val seenBrowserHintKey = PersistentDataKey( + NamespacedKeyUtils.create("ecoenchants", "seen_browser_hint"), + PersistentDataKeyType.BOOLEAN, + false + ) + + private val seenHoldHintKey = PersistentDataKey( + NamespacedKeyUtils.create("ecoenchants", "seen_hold_hint"), + PersistentDataKeyType.BOOLEAN, + false + ) + + private val lastHintByPlayer = ConcurrentHashMap<UUID, MutableMap<String, Long>>() + private val lastEmptyReasonByPlayer = ConcurrentHashMap<UUID, String>() + private val emptyResultCounts = ConcurrentHashMap<String, Int>() + + fun reload() { + lastHintByPlayer.clear() + lastEmptyReasonByPlayer.clear() + } + + @EventHandler + fun handleJoin(event: PlayerJoinEvent) { + if (!plugin.configYml.getBool("player-experience.auto-hints.enabled") + || !plugin.configYml.getBool("player-experience.auto-hints.on-first-join")) { + return + } + + val player = event.player + if (player.profile.read(seenJoinHintKey)) { + return + } + + player.profile.write(seenJoinHintKey, true) + sendLangLines(player, "hints.join") + } + + @EventHandler + fun handleItemHeld(event: PlayerItemHeldEvent) { + if (!plugin.configYml.getBool("player-experience.auto-hints.enabled") + || !plugin.configYml.getBool("player-experience.auto-hints.on-hold-enchantable")) { + return + } + + val player = event.player + val item = player.inventory.getItem(event.newSlot) ?: return + if (item.type == Material.AIR) { + return + } + + val enchantable = item.type == Material.ENCHANTED_BOOK || item.applicableEnchantments.isNotEmpty() + if (!enchantable) { + return + } + + if (!canSendHint(player, "hold-enchantable")) { + return + } + + val actionBar = plugin.langYml.getStrings("hints.hold-enchantable.actionbar").firstOrNull() + if (!actionBar.isNullOrBlank()) { + player.sendActionBarHint(actionBar) + } + + // The first time only, also send a clickable chat prompt that opens the browser. + if (!player.profile.read(seenHoldHintKey)) { + player.profile.write(seenHoldHintKey, true) + val chat = plugin.langYml.getStrings("hints.hold-enchantable.chat").firstOrNull() + if (!chat.isNullOrBlank()) { + player.sendClickableLine(chat, "/ecoenchants gui") + } + } + } + + fun handleBrowserOpen(player: Player) { + if (!plugin.configYml.getBool("player-experience.auto-hints.enabled") + || !plugin.configYml.getBool("player-experience.auto-hints.on-browser-open")) { + return + } + + if (plugin.configYml.getBool("player-experience.auto-hints.once-per-player") + && player.profile.read(seenBrowserHintKey)) { + return + } + + if (!canSendHint(player, "browser-open")) { + return + } + + player.profile.write(seenBrowserHintKey, true) + sendLangLines(player, "hints.browser-open") + } + + fun handleEmptyResults(player: Player, reason: String) { + if (lastEmptyReasonByPlayer[player.uniqueId] == reason) { + return + } + + lastEmptyReasonByPlayer[player.uniqueId] = reason + emptyResultCounts.merge(reason, 1, Int::plus) + + if (!plugin.configYml.getBool("player-experience.auto-hints.enabled") + || !plugin.configYml.getBool("player-experience.auto-hints.on-empty-results") + || !canSendHint(player, "empty-results")) { + return + } + + sendLangLines( + player, + "hints.empty-results.$reason", + "reason" to reason.toDisplayName() + ) + } + + fun clearEmptyResults(player: Player) { + lastEmptyReasonByPlayer.remove(player.uniqueId) + } + + fun handleFilterChanged(player: Player, filter: String, value: String) { + if (!plugin.configYml.getBool("player-experience.auto-hints.enabled") + || !plugin.configYml.getBool("player-experience.auto-hints.on-filter-change") + || !canSendHint(player, "filter-change")) { + return + } + + sendLangLines( + player, + "hints.filter-changed", + "filter" to filter.toDisplayName(), + "value" to value + ) + } + + fun sendGuide(sender: CommandSender) { + sendLangLines( + sender, + "commands.guide.lines", + "enchant_count" to EcoEnchants.values().size.toString() + ) + } + + fun giveGuideBook(player: Player) { + val item = ItemStack(Material.WRITTEN_BOOK) + val meta = item.itemMeta as? BookMeta ?: return + + meta.setTitle(stripFormatting(plugin.langYml.getFormattedString("commands.guide.book-title"))) + meta.setAuthor(stripFormatting(plugin.langYml.getFormattedString("commands.guide.book-author"))) + meta.setPages( + plugin.langYml.getStrings("commands.guide.book-pages") + .map { stripFormatting(it.replace("%enchant_count%", EcoEnchants.values().size.toString())) } + ) + item.itemMeta = meta + + player.inventory.addItem(item).values.forEach { + player.world.dropItemNaturally(player.location, it) + } + sendLangLines(player, "commands.guide.book-given") + } + + fun sendHelp(sender: CommandSender) { + sendLangLines(sender, "commands.help.header") + + for (entry in helpEntries) { + if (!sender.hasPermission(entry.permission)) { + continue + } + + sendLangLines(sender, entry.langKey) + } + } + + fun statusLines(): List<String> { + val lines = mutableListOf( + "&aEcoEnchants Player Experience", + "&7Auto hints: &f${plugin.configYml.getBool("player-experience.auto-hints.enabled")}", + "&7Hint cooldown: &f${plugin.configYml.getInt("player-experience.auto-hints.cooldown-seconds")}s", + "&7Empty result reasons tracked: &f${emptyResultCounts.size}" + ) + + if (emptyResultCounts.isEmpty()) { + lines += "&8No empty result cases have been tracked this session." + } else { + emptyResultCounts.entries + .sortedByDescending { it.value } + .take(8) + .forEach { (reason, count) -> + lines += "&7- &f${reason.toDisplayName()}&7: &a$count" + } + } + + return lines.formatEco() + } + + private fun canSendHint(player: Player, key: String): Boolean { + val cooldownMillis = plugin.configYml.getInt("player-experience.auto-hints.cooldown-seconds") + .coerceAtLeast(0) * 1000L + val now = System.currentTimeMillis() + val playerHints = lastHintByPlayer.computeIfAbsent(player.uniqueId) { mutableMapOf() } + val previous = playerHints[key] ?: 0L + + if (now - previous < cooldownMillis) { + return false + } + + playerHints[key] = now + return true + } + + fun sendLangLines( + sender: CommandSender, + key: String, + vararg replacements: Pair<String, String> + ) { + val lines = plugin.langYml.getStrings(key) + if (lines.isEmpty()) { + return + } + + for (line in lines) { + var message = line + for ((placeholder, value) in replacements) { + message = message.replace("%$placeholder%", value) + } + sender.sendMessage(message.formatEco()) + } + } + + private fun stripFormatting(value: String): String { + return value + .replace(Regex("&[0-9a-fk-orA-FK-OR]"), "") + .replace(Regex("<[^>]+>"), "") + } + + private fun String.toDisplayName(): String { + return this.split('-', '_') + .filter { it.isNotBlank() } + .joinToString(" ") { part -> + part.replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase() else char.toString() + } + } + } + + private data class HelpEntry( + val permission: String, + val langKey: String + ) + + private val helpEntries = listOf( + HelpEntry("ecoenchants.command.gui", "commands.help.gui"), + HelpEntry("ecoenchants.command.search", "commands.help.search"), + HelpEntry("ecoenchants.command.enchantinfo", "commands.help.enchantinfo"), + HelpEntry("ecoenchants.command.favorites", "commands.help.favorites"), + HelpEntry("ecoenchants.command.toggledescriptions", "commands.help.toggledescriptions"), + HelpEntry("ecoenchants.command.guide", "commands.help.guide"), + HelpEntry("ecoenchants.command.enchant", "commands.help.enchant"), + HelpEntry("ecoenchants.command.giverandombook", "commands.help.giverandombook"), + HelpEntry("ecoenchants.command.reload", "commands.help.reload"), + HelpEntry("ecoenchants.command.services", "commands.help.services"), + HelpEntry("ecoenchants.command.experience", "commands.help.experience") + ) +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/EnchantRegistrationIntegration.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/EnchantRegistrationIntegration.kt index 2da0bda5ce..8abe5ad3dd 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/EnchantRegistrationIntegration.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/EnchantRegistrationIntegration.kt @@ -15,7 +15,9 @@ object EnchantRegistrations { private val registered = mutableSetOf<EnchantRegistrationIntegration>() fun register(integration: EnchantRegistrationIntegration) { - registered.add(integration) + if (registered.add(integration)) { + runCatching { integration.registerEnchants() } + } } internal fun registerEnchantments() { diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/plugins/CMIIntegration.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/plugins/CMIIntegration.kt index 41838e7aaf..5b39727d41 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/plugins/CMIIntegration.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/plugins/CMIIntegration.kt @@ -1,13 +1,92 @@ package com.willfp.ecoenchants.integrations.plugins +import com.willfp.ecoenchants.display.getFormattedName +import com.willfp.ecoenchants.enchant.EcoEnchant +import com.willfp.ecoenchants.enchant.EcoEnchants import com.willfp.ecoenchants.integrations.EnchantRegistrationIntegration +import com.willfp.ecoenchants.stripLegacyFormatting import net.Zrips.CMILib.Enchants.CMIEnchantment +import org.bukkit.enchantments.Enchantment +@Suppress("UNCHECKED_CAST") object CMIIntegration: EnchantRegistrationIntegration { + private val byName by lazy { + CMIEnchantment::class.java.getDeclaredField("byName") + .apply { isAccessible = true } + .get(null) as MutableMap<String, Enchantment> + } + + private val enchantList by lazy { + CMIEnchantment::class.java.getDeclaredField("enchantList") + .apply { isAccessible = true } + .get(null) as MutableMap<String, String> + } + + private val translatedEnchantList by lazy { + CMIEnchantment::class.java.getDeclaredField("transaltedEnchantList") + .apply { isAccessible = true } + .get(null) as MutableMap<String, String> + } + override fun registerEnchants() { CMIEnchantment.initialize() CMIEnchantment.saveEnchants() + + for (enchantment in EcoEnchants.values()) { + registerEnchant(enchantment) + } + } + + override fun removeEnchant(enchantment: EcoEnchant) { + val aliases = enchantment.aliases().map { it.normalize() }.toSet() + val canonical = enchantment.canonicalName() + + for (alias in aliases) { + byName.remove(alias) + enchantList.remove(alias) + translatedEnchantList.remove(alias) + } + + byName.entries.removeIf { it.value == enchantment.enchantment } + enchantList.entries.removeIf { it.value == canonical } + translatedEnchantList.remove(canonical) } override fun getPluginName() = "CMI" + + private fun registerEnchant(enchantment: EcoEnchant) { + val canonical = enchantment.canonicalName() + val displayName = enchantment.getFormattedName(0).stripLegacyFormatting() + + byName[canonical] = enchantment.enchantment + translatedEnchantList[canonical] = displayName + + for (alias in enchantment.aliases()) { + val normalized = alias.normalize() + if (normalized.isBlank()) { + continue + } + + byName[normalized] = enchantment.enchantment + enchantList[normalized] = canonical + } + } + + private fun EcoEnchant.canonicalName(): String = + this.id.normalize() + + private fun EcoEnchant.aliases(): Set<String> { + val displayName = this.getFormattedName(0).stripLegacyFormatting() + + return setOf( + this.id, + this.enchantment.key.key, + displayName + ) + } + + private fun String.normalize(): String = + this.replace("_", "") + .replace(" ", "") + .lowercase() } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/plugins/EssentialsIntegration.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/plugins/EssentialsIntegration.kt index 938bc04f66..952cd8d171 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/plugins/EssentialsIntegration.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/integrations/plugins/EssentialsIntegration.kt @@ -1,42 +1,57 @@ package com.willfp.ecoenchants.integrations.plugins import com.earth2me.essentials.Enchantments +import com.willfp.ecoenchants.display.getFormattedName import com.willfp.ecoenchants.enchant.EcoEnchant import com.willfp.ecoenchants.enchant.EcoEnchants import com.willfp.ecoenchants.integrations.EnchantRegistrationIntegration +import com.willfp.ecoenchants.stripLegacyFormatting import org.bukkit.enchantments.Enchantment @Suppress("UNCHECKED_CAST") object EssentialsIntegration : EnchantRegistrationIntegration { + private val enchantmentMaps by lazy { + arrayOf("ENCHANTMENTS", "ALIASENCHANTMENTS").map { field -> + Enchantments::class.java.getDeclaredField(field) + .apply { isAccessible = true } + .get(null) as MutableMap<String, Enchantment> + } + } + override fun registerEnchants() { for (enchantment in EcoEnchants.values()) { // why aren't you using the api you PRd in // because essentials named mending to repairing etc - for (field in arrayOf("ENCHANTMENTS", "ALIASENCHANTMENTS")) { - Enchantments::class.java.getDeclaredField(field) - .apply { - isAccessible = true - (get(null) as MutableMap<String, Enchantment>).apply { - put(enchantment.id, enchantment.enchantment) - put(enchantment.id.replace("_", ""), enchantment.enchantment) - } - } + for (map in enchantmentMaps) { + for (alias in enchantment.aliases()) { + map[alias] = enchantment.enchantment + } } } } override fun removeEnchant(enchantment: EcoEnchant) { - for (field in arrayOf("ENCHANTMENTS", "ALIASENCHANTMENTS")) { - Enchantments::class.java.getDeclaredField(field) - .apply { - isAccessible = true - (get(null) as MutableMap<String, Enchantment>).apply { - remove(enchantment.id) - remove(enchantment.id.replace("_", "")) - } - } + for (map in enchantmentMaps) { + for (alias in enchantment.aliases()) { + map.remove(alias) + } } } override fun getPluginName() = "Essentials" + + private fun EcoEnchant.aliases(): Set<String> { + val displayName = this.getFormattedName(0).stripLegacyFormatting() + + return setOf( + this.id, + this.id.replace("_", ""), + this.enchantment.key.key, + displayName, + displayName.replace(" ", ""), + displayName.replace("_", "") + ).map { it.lowercase() } + .filter { it.isNotBlank() } + .toSet() + } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/EnchantingTableSupport.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/EnchantingTableSupport.kt index 63be313e64..9322a439ae 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/EnchantingTableSupport.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/EnchantingTableSupport.kt @@ -1,11 +1,11 @@ package com.willfp.ecoenchants.mechanics +import com.willfp.eco.core.fast.fast import com.willfp.eco.core.items.Items import com.willfp.eco.core.items.TestableItem import com.willfp.eco.core.recipe.parts.EmptyTestableItem import com.willfp.eco.util.NumberUtils import com.willfp.eco.util.randDouble -import com.willfp.ecoenchants.enchant.EcoEnchants import com.willfp.ecoenchants.enchant.infiniteIfNegative import com.willfp.ecoenchants.plugin import org.bukkit.Bukkit @@ -75,14 +75,20 @@ object EnchantingTableSupport : Listener { multiplier *= plugin.configYml.getDouble("enchanting-table.book-multiplier") } - val enchantments = EcoEnchants.values().shuffled() + val currentEnchantments = item.fast().getEnchants(true).toMutableMap() + currentEnchantments.putAll(toAdd) - for (enchantment in enchantments) { - if (!enchantment.isObtainableThroughEnchanting) { - continue + val tableCap = plugin.configYml.getInt("enchanting-table.cap") + val enchantLimit = plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative() + val maxObtainableLevel = plugin.configYml.getInt("enchanting-table.maximum-obtainable-level") + val reduction = plugin.configYml.getDouble("enchanting-table.reduction") + + for (enchantment in EnchantmentSourceCache.enchanting.randomizedIteration()) { + if (toAdd.size >= tableCap || currentEnchantments.size >= enchantLimit) { + break } - if (!enchantment.canEnchantItem(item, toAdd.keys)) { + if (!enchantment.canEnchantItemConsidering(item, currentEnchantments.keys, enchantLimit)) { continue } @@ -103,17 +109,7 @@ object EnchantingTableSupport : Listener { continue } - if (toAdd.size >= plugin.configYml.getInt("enchanting-table.cap")) { - break - } - - if (toAdd.size > plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative()) { - break - } - - val maxLevel = enchantment.maximumLevel - val maxObtainableLevel = plugin.configYml.getInt("enchanting-table.maximum-obtainable-level") val levelPart1 = if (enchantment.type.highLevelBias > 0) { randDouble(0.0, 1.0) @@ -125,9 +121,10 @@ object EnchantingTableSupport : Listener { val levelPart3 = NumberUtils.bias(levelPart2, enchantment.type.highLevelBias) val level = ceil(levelPart3 * maxLevel).coerceIn(1.0..maxLevel.toDouble()).toInt() - multiplier /= plugin.configYml.getDouble("enchanting-table.reduction") + multiplier /= reduction toAdd[enchantment.enchantment] = level + currentEnchantments[enchantment.enchantment] = level } toAdd.forEach(event.enchantsToAdd::putIfAbsent) diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/EnchantmentSourceCache.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/EnchantmentSourceCache.kt new file mode 100644 index 0000000000..640209838f --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/EnchantmentSourceCache.kt @@ -0,0 +1,98 @@ +package com.willfp.ecoenchants.mechanics + +import com.willfp.ecoenchants.enchant.EcoEnchant +import com.willfp.ecoenchants.enchant.EcoEnchants +import java.util.concurrent.ThreadLocalRandom + +internal object EnchantmentSourceCache { + private var loaded = false + private var enchantingCache = emptyList<EcoEnchant>() + private var tradingCache = emptyList<EcoEnchant>() + private var discoveryCache = emptyList<EcoEnchant>() + + val enchanting: List<EcoEnchant> + get() { + ensureLoaded() + return enchantingCache + } + + val trading: List<EcoEnchant> + get() { + ensureLoaded() + return tradingCache + } + + val discovery: List<EcoEnchant> + get() { + ensureLoaded() + return discoveryCache + } + + fun reload() { + val enchantments = EcoEnchants.values().toList() + enchantingCache = enchantments.filter { it.isObtainableThroughEnchanting } + tradingCache = enchantments.filter { it.isObtainableThroughTrading } + discoveryCache = enchantments.filter { it.isObtainableThroughDiscovery } + loaded = true + } + + private fun ensureLoaded() { + if (!loaded) { + reload() + } + } +} + +internal fun List<EcoEnchant>.randomizedIteration(): Iterable<EcoEnchant> { + if (this.size < 2) { + return this + } + + val source = this + + return Iterable { + val size = source.size + val random = ThreadLocalRandom.current() + val start = random.nextInt(size) + val step = randomCoprimeStep(size, random) + + object : Iterator<EcoEnchant> { + private var visited = 0 + + override fun hasNext(): Boolean = visited < size + + override fun next(): EcoEnchant { + if (!hasNext()) { + throw NoSuchElementException() + } + + val index = (start + visited * step) % size + visited++ + return source[index] + } + } + } +} + +private fun randomCoprimeStep(size: Int, random: ThreadLocalRandom): Int { + var step = random.nextInt(1, size) + + while (gcd(step, size) != 1) { + step = random.nextInt(1, size) + } + + return step +} + +private fun gcd(a: Int, b: Int): Int { + var x = a + var y = b + + while (y != 0) { + val next = x % y + x = y + y = next + } + + return x +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/GrindstoneSupport.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/GrindstoneSupport.kt index 90ae21c494..5d54dc13e9 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/GrindstoneSupport.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/GrindstoneSupport.kt @@ -3,102 +3,102 @@ package com.willfp.ecoenchants.mechanics import com.willfp.eco.core.fast.fast import com.willfp.ecoenchants.enchant.wrap import com.willfp.ecoenchants.plugin +import org.bukkit.Material import org.bukkit.enchantments.Enchantment import org.bukkit.entity.ExperienceOrb import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority import org.bukkit.event.Listener import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.PrepareGrindstoneEvent import org.bukkit.inventory.GrindstoneInventory +import org.bukkit.inventory.ItemStack import org.bukkit.inventory.meta.EnchantmentStorageMeta import kotlin.math.max @Suppress("DEPRECATION") object GrindstoneSupport : Listener { - @EventHandler - fun preGrindstone(event: InventoryClickEvent) { - val inventory = event.view.topInventory as? GrindstoneInventory ?: return + @EventHandler(priority = EventPriority.HIGH) + fun prepareGrindstone(event: PrepareGrindstoneEvent) { + val inventory = event.inventory + val result = event.result ?: return + val inputEnchants = inventory.getInputEnchants() - // Run everything later to await event completion - plugin.scheduler.run { - val topEnchants = inventory.getItem(0)?.fast()?.getEnchants(true) ?: emptyMap() - val bottomEnchants = inventory.getItem(1)?.fast()?.getEnchants(true) ?: emptyMap() - - if (topEnchants.isEmpty() && bottomEnchants.isEmpty()) { - return@run - } - - val toKeep = mutableMapOf<Enchantment, Int>() - - for ((enchant, level) in topEnchants) { - if (enchant.wrap().type.noGrindstone) { - toKeep[enchant] = level - } - } + if (inputEnchants.isEmpty()) { + return + } - for ((enchant, level) in bottomEnchants) { - if (enchant.wrap().type.noGrindstone) { - val current = toKeep[enchant] ?: 0 - toKeep[enchant] = max(level, current) - } - } + event.result = result.withGrindstoneResultEnchants(inputEnchants.getNoGrindstoneEnchants()) + } - val result = inventory.getItem(2) + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun postGrindstone(event: InventoryClickEvent) { + val inventory = event.clickedInventory as? GrindstoneInventory ?: return - if (result == null || event.isCancelled) { - return@run - } + if (event.slot != 2) { + return + } - val meta = result.itemMeta ?: return@run + val item = inventory.result ?: return - if (toKeep.isEmpty()) { - return@run - } + if (item.fast().getEnchants(true).keys.none { it.wrap().type.noGrindstone }) { + return + } - if (meta is EnchantmentStorageMeta) { - for ((enchant, _) in meta.storedEnchants.toMap()) { - meta.removeStoredEnchant(enchant) + for (delay in 1L..3L) { + plugin.scheduler.runLater(delay) { + val loc = inventory.location ?: return@runLater + val orbs = loc.getNearbyEntitiesByType(ExperienceOrb::class.java, 3.0, 3.0, 3.0) + .filter { it.spawnReason == ExperienceOrb.SpawnReason.GRINDSTONE } + for (orb in orbs) { + orb.remove() } + } + } + } - for ((enchant, level) in toKeep) { - meta.addStoredEnchant(enchant, level, true) - } - } else { - for ((enchant, _) in meta.enchants.toMap()) { - meta.removeEnchant(enchant) - } + private fun GrindstoneInventory.getInputEnchants(): Map<Enchantment, Int> { + val enchants = mutableMapOf<Enchantment, Int>() - for ((enchant, level) in toKeep) { - meta.addEnchant(enchant, level, true) - } + for (item in listOf(this.upperItem, this.lowerItem)) { + for ((enchant, level) in item?.fast()?.getEnchants(true) ?: emptyMap()) { + enchants[enchant] = max(enchants[enchant] ?: 0, level) } - - result.itemMeta = meta } + + return enchants } - @EventHandler - fun postGrindstone(event: InventoryClickEvent) { - val inventory = event.clickedInventory as? GrindstoneInventory ?: return + private fun Map<Enchantment, Int>.getNoGrindstoneEnchants(): Map<Enchantment, Int> = + this.filterKeys { it.wrap().type.noGrindstone } - if (event.slot != 2) { - return + private fun ItemStack.withGrindstoneResultEnchants(toKeep: Map<Enchantment, Int>): ItemStack { + if (this.type == Material.ENCHANTED_BOOK && toKeep.isEmpty()) { + return ItemStack(Material.BOOK, this.amount) } - val item = inventory.result ?: return + val result = this.clone() + val meta = result.itemMeta ?: return result - if (item.fast().getEnchants(true).isEmpty()) { - return - } + if (meta is EnchantmentStorageMeta) { + for (enchant in meta.storedEnchants.keys.toSet()) { + meta.removeStoredEnchant(enchant) + } - // Force remove XP - plugin.scheduler.runLater(1) { - val loc = inventory.location ?: return@runLater - val orbs = loc.getNearbyEntities(3.0, 3.0, 3.0) - .filterIsInstance<ExperienceOrb>() - .filter { it.spawnReason == ExperienceOrb.SpawnReason.GRINDSTONE } - for (orb in orbs) { - orb.remove() + for ((enchant, level) in toKeep) { + meta.addStoredEnchant(enchant, level, true) + } + } else { + for (enchant in meta.enchants.keys.toSet()) { + meta.removeEnchant(enchant) + } + + for ((enchant, level) in toKeep) { + meta.addEnchant(enchant, level, true) } } + + result.itemMeta = meta + return result } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/HeldInteractionRefreshSupport.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/HeldInteractionRefreshSupport.kt new file mode 100644 index 0000000000..b8c3da1712 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/HeldInteractionRefreshSupport.kt @@ -0,0 +1,101 @@ +package com.willfp.ecoenchants.mechanics + +import com.willfp.ecoenchants.enchant.EcoEnchantLevel +import com.willfp.ecoenchants.plugin +import com.willfp.ecoenchants.target.EnchantFinder.clearEnchantmentCache +import com.willfp.libreforge.forceRefreshHolders +import com.willfp.libreforge.providedActiveEffects +import com.willfp.libreforge.slot.SlotItemProvidedHolder +import com.willfp.libreforge.toDispatcher +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.block.Action +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.event.player.PlayerInteractEvent +import org.bukkit.event.player.PlayerItemHeldEvent +import org.bukkit.event.player.PlayerJoinEvent +import org.bukkit.event.player.PlayerDropItemEvent +import org.bukkit.event.player.PlayerSwapHandItemsEvent +import org.bukkit.inventory.EquipmentSlot +import java.util.function.Consumer + +object HeldInteractionRefreshSupport : Listener { + private val interactionTriggers = setOf("alt_click", "click_block") + + @EventHandler(priority = EventPriority.LOWEST) + fun handle(event: PlayerInteractEvent) { + if (event.hand != EquipmentSlot.HAND) { + return + } + + if (event.action == Action.PHYSICAL) { + return + } + + val dispatcher = event.player.toDispatcher() + + if (!dispatcher.hasCachedMainHandInteractionEnchant()) { + return + } + + event.player.refreshEnchantHoldersNow() + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handle(event: PlayerItemHeldEvent) { + event.player.refreshEnchantHoldersLater() + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handle(event: PlayerSwapHandItemsEvent) { + event.player.refreshEnchantHoldersLater() + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handle(event: PlayerDropItemEvent) { + event.player.refreshEnchantHoldersLater() + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handle(event: InventoryClickEvent) { + (event.whoClicked as? Player)?.refreshEnchantHoldersLater() + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handle(event: InventoryDragEvent) { + (event.whoClicked as? Player)?.refreshEnchantHoldersLater() + } + + @EventHandler(priority = EventPriority.MONITOR) + fun handle(event: PlayerJoinEvent) { + event.player.refreshEnchantHoldersLater() + } + + private fun Player.refreshEnchantHoldersNow() { + this.clearEnchantmentCache() + this.toDispatcher().forceRefreshHolders() + } + + private fun Player.refreshEnchantHoldersLater() { + this.scheduler.run(plugin, Consumer { + if (this.isOnline) { + this.refreshEnchantHoldersNow() + } + }, null) + } + + private fun com.willfp.libreforge.Dispatcher<*>.hasCachedMainHandInteractionEnchant(): Boolean = + this.providedActiveEffects.any { provided -> + if (provided.holder.holder !is EcoEnchantLevel) { + return@any false + } + + val slotHolder = provided.holder as? SlotItemProvidedHolder<*> ?: return@any false + + slotHolder.slotType.id == "mainhand" && + provided.effect.triggers.any { it.id in interactionTriggers } + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/LootSupport.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/LootSupport.kt index 18c032e027..61ff640206 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/LootSupport.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/LootSupport.kt @@ -3,7 +3,6 @@ package com.willfp.ecoenchants.mechanics import com.willfp.eco.core.fast.fast import com.willfp.eco.util.NumberUtils import com.willfp.ecoenchants.enchant.DiscoveryType -import com.willfp.ecoenchants.enchant.EcoEnchants import com.willfp.ecoenchants.enchant.infiniteIfNegative import com.willfp.ecoenchants.plugin import com.willfp.ecoenchants.target.EnchantmentTargets.isEnchantable @@ -59,14 +58,19 @@ object LootSupport : Listener { multiplier *= plugin.configYml.getDouble("loot.book-multiplier") } - val enchantments = EcoEnchants.values().shuffled() + val enchantLimit = plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative() + val reduction = plugin.configYml.getDouble("loot.reduction") + + for (enchantment in EnchantmentSourceCache.discovery.randomizedIteration()) { + if (enchants.size >= enchantLimit) { + break + } - for (enchantment in enchantments) { if (!enchantment.isObtainableThrough(discoveryType)) { continue } - if (!enchantment.canEnchantItem(item, enchants.keys)) { + if (!enchantment.canEnchantItemConsidering(item, enchants.keys, enchantLimit)) { continue } @@ -74,17 +78,13 @@ object LootSupport : Listener { continue } - if (enchants.size > plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative()) { - break - } - val maxLevel = enchantment.maximumLevel val levelPart1 = NumberUtils.bias(NumberUtils.randFloat(0.7, 1.0), enchantment.type.highLevelBias) val levelPart2 = NumberUtils.triangularDistribution(0.0, 1.0, levelPart1) val level = ceil(levelPart2 * maxLevel).coerceIn(1.0..maxLevel.toDouble()).toInt() - multiplier /= plugin.configYml.getDouble("loot.reduction") + multiplier /= reduction enchants[enchantment.enchantment] = level } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/VillagerSupport.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/VillagerSupport.kt index 0d907313f3..657834d3d9 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/VillagerSupport.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/mechanics/VillagerSupport.kt @@ -2,7 +2,6 @@ package com.willfp.ecoenchants.mechanics import com.willfp.eco.core.fast.fast import com.willfp.eco.util.NumberUtils -import com.willfp.ecoenchants.enchant.EcoEnchants import com.willfp.ecoenchants.enchant.infiniteIfNegative import com.willfp.ecoenchants.plugin import com.willfp.ecoenchants.target.EnchantmentTargets.isEnchantable @@ -39,14 +38,15 @@ object VillagerSupport : Listener { multiplier *= plugin.configYml.getDouble("villager.book-multiplier") } - val enchantments = EcoEnchants.values().shuffled() + val enchantLimit = plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative() + val reduction = plugin.configYml.getDouble("villager.reduction") - for (enchantment in enchantments) { - if (!enchantment.isObtainableThroughTrading) { - continue + for (enchantment in EnchantmentSourceCache.trading.randomizedIteration()) { + if (enchants.size >= enchantLimit) { + break } - if (!enchantment.canEnchantItem(result, enchants.keys)) { + if (!enchantment.canEnchantItemConsidering(result, enchants.keys, enchantLimit)) { continue } @@ -54,11 +54,6 @@ object VillagerSupport : Listener { continue } - if (enchants.size > plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative()) { - break - } - - val maxLevel = enchantment.maximumLevel val levelPart1 = event.recipe.ingredients[0].amount / 64.0 @@ -66,7 +61,7 @@ object VillagerSupport : Listener { val levelPart3 = NumberUtils.bias(levelPart2, enchantment.type.highLevelBias) val level = ceil(levelPart3 * maxLevel).coerceIn(1.0..maxLevel.toDouble()).toInt() - multiplier /= plugin.configYml.getDouble("villager.reduction") + multiplier /= reduction if (result.type == Material.ENCHANTED_BOOK) { // Only allow one enchantment diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/target/EnchantFinder.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/target/EnchantFinder.kt index 876f872f01..b79b80860e 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/target/EnchantFinder.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/target/EnchantFinder.kt @@ -34,7 +34,7 @@ object EnchantFinder : ItemHolderFinder<EcoEnchantLevel>() { } override fun isValidInSlot(holder: EcoEnchantLevel, slot: SlotType): Boolean { - return holder.enchant.targets.map { it.slot }.any { it.isOrContains(slot) } + return holder.enchant.slots.any { it.isOrContains(slot) } } internal fun LivingEntity.clearEnchantmentCache() = levelCache.invalidate(this.uniqueId) @@ -43,7 +43,7 @@ object EnchantFinder : ItemHolderFinder<EcoEnchantLevel>() { get() = levelCache.get(this.uniqueId) { toHolderProvider().provide(this.toDispatcher()) .mapNotNull { - val level = it.holder as? EcoEnchantLevel ?: return@mapNotNull null + val level = it.holder val item = it.provider as? ItemStack ?: return@mapNotNull null ProvidedLevel(level, item, it) @@ -51,15 +51,20 @@ object EnchantFinder : ItemHolderFinder<EcoEnchantLevel>() { } fun LivingEntity.hasEnchantActive(enchant: EcoEnchant): Boolean { - return this.cachedLevels - .filter { it.level.enchant == enchant } - .any { it.level.conditions.areMet(this.toDispatcher(), it.holder) } + val dispatcher = this.toDispatcher() + + return this.cachedLevels.any { + it.level.enchant == enchant && it.level.conditions.areMet(dispatcher, it.holder) + } } fun LivingEntity.getItemsWithEnchantActive(enchant: EcoEnchant): Map<ItemStack, Int> { - return this.cachedLevels - .filter { it.level.enchant == enchant } - .filter { it.level.conditions.areMet(this.toDispatcher(), it.holder) } + val dispatcher = this.toDispatcher() + + return this.cachedLevels.asSequence() + .filter { + it.level.enchant == enchant && it.level.conditions.areMet(dispatcher, it.holder) + } .associate { it.item to it.level.level } } diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/target/EnchantmentTargets.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/target/EnchantmentTargets.kt index 364e4630a4..d1de6d28e9 100644 --- a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/target/EnchantmentTargets.kt +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/target/EnchantmentTargets.kt @@ -1,10 +1,12 @@ package com.willfp.ecoenchants.target import com.willfp.eco.core.cache.EcoCache +import com.willfp.eco.core.fast.fast import com.willfp.eco.core.items.HashedItem import com.willfp.eco.core.registry.Registry import com.willfp.ecoenchants.enchant.EcoEnchant import com.willfp.ecoenchants.enchant.EcoEnchants +import com.willfp.ecoenchants.enchant.infiniteIfNegative import com.willfp.ecoenchants.plugin import org.bukkit.Material import org.bukkit.inventory.ItemStack @@ -29,7 +31,10 @@ object EnchantmentTargets : Registry<EnchantmentTarget>() { val ItemStack.applicableEnchantments: List<EcoEnchant> get() = canEnchantCache.get(HashedItem.of(this)) { - EcoEnchants.values().filter { it.canEnchantItem(this) } + val currentEnchantments = this.fast().getEnchants(true).keys + val enchantLimit = plugin.configYml.getInt("anvil.enchant-limit").infiniteIfNegative() + + EcoEnchants.values().filter { it.canEnchantItemConsidering(this, currentEnchantments, enchantLimit) } } @JvmStatic diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/EnvironmentRiskProbe.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/EnvironmentRiskProbe.kt new file mode 100644 index 0000000000..809530cc0d --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/EnvironmentRiskProbe.kt @@ -0,0 +1,155 @@ +package com.willfp.ecoenchants.telemetry + +import com.willfp.ecoenchants.plugin +import java.lang.management.ManagementFactory +import org.bukkit.scheduler.BukkitTask + +object EnvironmentRiskProbe { + @Volatile + private var lastFindings: List<EnvironmentRiskFinding> = emptyList() + + private var task: BukkitTask? = null + + fun verifyStartup(): Boolean { + if (!RuntimeTelemetryPolicy.environmentProbeEnabled) { + lastFindings = emptyList() + return true + } + + val findings = probe() + lastFindings = findings + logFindings(findings) + + return !findings.any { it.redline } || RuntimeTelemetryPolicy.environmentRedlineAction != "disable-plugin" + } + + fun start() { + stop() + + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.environmentProbeEnabled) { + return + } + + task = plugin.scheduler.runAsyncTimer( + RuntimeTelemetryPolicy.environmentProbeIntervalTicks, + RuntimeTelemetryPolicy.environmentProbeIntervalTicks + ) { + val findings = probe() + lastFindings = findings + logFindings(findings) + + if (findings.any { it.redline } && RuntimeTelemetryPolicy.environmentRedlineAction == "disable-plugin") { + plugin.scheduler.run { + plugin.logger.severe("EcoEnchants environment risk redline reached; disabling plugin.") + plugin.server.pluginManager.disablePlugin(plugin) + } + } + } + } + + fun stop() { + task?.cancel() + task = null + } + + fun statusLines(): List<String> { + val findings = lastFindings + return buildList { + add("Environment risk probe") + add("Enabled: ${RuntimeTelemetryPolicy.environmentProbeEnabled}") + add("Last finding count: ${findings.size}") + add("Redline findings: ${findings.count { it.redline }}") + for (finding in findings.take(5)) { + add("${finding.severity}: ${finding.signal} - ${finding.detail}") + } + } + } + + private fun probe(): List<EnvironmentRiskFinding> { + val findings = mutableListOf<EnvironmentRiskFinding>() + val jvmArgs = ManagementFactory.getRuntimeMXBean().inputArguments + + for (deniedArg in RuntimeTelemetryPolicy.deniedJvmArgs) { + val matchedArg = jvmArgs.firstOrNull { it.contains(deniedArg, ignoreCase = true) } + if (matchedArg != null) { + findings += EnvironmentRiskFinding( + severity = "redline", + signal = "denied-jvm-arg", + detail = deniedArg, + redline = true + ) + } + } + + if (RuntimeTelemetryPolicy.blockJavaAgents) { + for (arg in jvmArgs.filter { it.startsWith("-javaagent", ignoreCase = true) }) { + findings += EnvironmentRiskFinding( + severity = "redline", + signal = "java-agent", + detail = arg.substringBefore('='), + redline = true + ) + } + } + + for (name in RuntimeTelemetryPolicy.deniedEnvironmentVariables) { + if (System.getenv(name) != null) { + findings += EnvironmentRiskFinding( + severity = "redline", + signal = "denied-env-var", + detail = name, + redline = true + ) + } + } + + for (name in RuntimeTelemetryPolicy.deniedSystemProperties) { + if (System.getProperty(name) != null) { + findings += EnvironmentRiskFinding( + severity = "redline", + signal = "denied-system-property", + detail = name, + redline = true + ) + } + } + + if (!plugin.server.onlineMode) { + findings += EnvironmentRiskFinding( + severity = "notice", + signal = "server-offline-mode", + detail = "identity anchors are weaker when online-mode is disabled", + redline = false + ) + } + + return findings + } + + private fun logFindings(findings: List<EnvironmentRiskFinding>) { + if (findings.isEmpty()) { + TelemetryAuditLog.write("environment_probe", mapOf("status" to "clear")) + return + } + + for (finding in findings) { + TelemetryAuditLog.write( + "environment_probe", + mapOf( + "severity" to finding.severity, + "signal" to finding.signal, + "detail" to finding.detail, + "redline" to finding.redline, + "action" to RuntimeTelemetryPolicy.environmentRedlineAction + ) + ) + } + } +} + +data class EnvironmentRiskFinding( + val severity: String, + val signal: String, + val detail: String, + val redline: Boolean +) diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/RuntimeTelemetry.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/RuntimeTelemetry.kt new file mode 100644 index 0000000000..ff52ab4422 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/RuntimeTelemetry.kt @@ -0,0 +1,586 @@ +package com.willfp.ecoenchants.telemetry + +import com.willfp.ecoenchants.plugin +import io.papermc.paper.event.player.AsyncChatEvent +import java.net.InetAddress +import java.util.TreeMap +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.bukkit.Location +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.enchantment.EnchantItemEvent +import org.bukkit.event.entity.EntityPickupItemEvent +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.event.player.AsyncPlayerPreLoginEvent +import org.bukkit.event.player.PlayerCommandPreprocessEvent +import org.bukkit.event.player.PlayerDropItemEvent +import org.bukkit.event.player.PlayerExpChangeEvent +import org.bukkit.event.player.PlayerJoinEvent +import org.bukkit.event.player.PlayerLevelChangeEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.event.player.PlayerTeleportEvent +import org.bukkit.event.player.PlayerToggleFlightEvent +import org.bukkit.inventory.ItemStack +import kotlin.math.pow +import kotlin.math.sqrt + +object RuntimeTelemetry : Listener { + private val routeVectors = ConcurrentHashMap<UUID, RouteVector>() + private val movementSamples = ConcurrentHashMap<UUID, MovementSample>() + private val inventoryHashes = ConcurrentHashMap<UUID, String>() + + fun start() { + TelemetryReporter.start() + TelemetryAuditLog.start() + EnvironmentRiskProbe.start() + } + + fun reload() { + routeVectors.clear() + movementSamples.clear() + inventoryHashes.clear() + TelemetryAuditLog.write("telemetry_lifecycle", mapOf("state" to "reloaded")) + TelemetryReporter.reload() + EnvironmentRiskProbe.start() + } + + fun stop() { + EnvironmentRiskProbe.stop() + routeVectors.clear() + movementSamples.clear() + inventoryHashes.clear() + TelemetryAuditLog.stop() + TelemetryReporter.stop() + } + + @EventHandler(priority = EventPriority.MONITOR) + fun handleLogin(event: AsyncPlayerPreLoginEvent) { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.identityEnabled) { + return + } + + val route = toRouteVector(event.address, event.rawAddress, event.hostname) + routeVectors[event.uniqueId] = route + + TelemetryAuditLog.write( + "identity_anchor", + mapOf( + "uuid" to event.uniqueId.toString(), + "name" to event.name, + "onlineMode" to plugin.server.onlineMode, + "network" to route.toLogMap() + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR) + fun handleJoin(event: PlayerJoinEvent) { + if (!RuntimeTelemetryPolicy.enabled) { + return + } + + movementSamples[event.player.uniqueId] = event.player.location.toMovementSample() + seedInventoryBaseline(event.player, "join") + + plugin.scheduler.runLater(20L) { + if (!event.player.isOnline || !RuntimeTelemetryPolicy.identityEnabled) { + return@runLater + } + + TelemetryAuditLog.write( + "client_context", + mapOf( + "uuid" to event.player.uniqueId.toString(), + "protocol" to runCatching { event.player.protocolVersion }.getOrNull(), + "clientBrand" to runCatching { event.player.clientBrandName }.getOrNull(), + "locale" to runCatching { event.player.locale().toLanguageTag() }.getOrNull(), + "viewDistance" to runCatching { event.player.clientViewDistance }.getOrNull(), + "ping" to runCatching { event.player.ping }.getOrNull() + ) + ) + } + } + + @EventHandler(priority = EventPriority.MONITOR) + fun handleQuit(event: PlayerQuitEvent) { + if (RuntimeTelemetryPolicy.enabled) { + TelemetryAuditLog.write( + "session_end", + mapOf("uuid" to event.player.uniqueId.toString()) + ) + } + + routeVectors.remove(event.player.uniqueId) + movementSamples.remove(event.player.uniqueId) + inventoryHashes.remove(event.player.uniqueId) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleMove(event: org.bukkit.event.player.PlayerMoveEvent) { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.movementEnabled) { + return + } + + if (!event.hasExplicitlyChangedPosition()) { + return + } + + val current = event.to.toMovementSample() + val previous = movementSamples[event.player.uniqueId] + val now = current.timestampMillis + + if (previous == null) { + movementSamples[event.player.uniqueId] = current + return + } + + if (now - previous.timestampMillis < RuntimeTelemetryPolicy.movementSampleIntervalMillis) { + return + } + + movementSamples[event.player.uniqueId] = current + + val distance = previous.distanceTo(current) + val elapsedSeconds = ((now - previous.timestampMillis) / 1000.0).coerceAtLeast(0.001) + val velocity = distance / elapsedSeconds + val changedWorld = previous.worldId != current.worldId + + if (changedWorld || RuntimeTelemetryPolicy.logMovementSamples) { + TelemetryAuditLog.write( + "trajectory_sample", + event.player.trajectoryPayload(previous, current, distance, velocity, changedWorld) + ) + } + + if (!changedWorld && + (distance > RuntimeTelemetryPolicy.maxDistancePerMovementSample || + velocity > RuntimeTelemetryPolicy.maxBlocksPerSecond) + ) { + TelemetryAuditLog.write( + "trajectory_anomaly", + event.player.trajectoryPayload(previous, current, distance, velocity, false) + ) + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleTeleport(event: PlayerTeleportEvent) { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.movementEnabled) { + return + } + + movementSamples[event.player.uniqueId] = event.to.toMovementSample() + TelemetryAuditLog.write( + "trajectory_transition", + mapOf( + "uuid" to event.player.uniqueId.toString(), + "cause" to event.cause.name, + "from" to event.from.toWorldContext(), + "to" to event.to.toWorldContext() + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleFlightToggle(event: PlayerToggleFlightEvent) { + TelemetryAuditLog.write( + "state_transition", + mapOf( + "uuid" to event.player.uniqueId.toString(), + "state" to "flight-toggle", + "flying" to event.isFlying + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleInventoryClick(event: InventoryClickEvent) { + val player = event.whoClicked as? Player ?: return + scheduleInventoryDelta( + player, + mapOf( + "event" to "inventory-click", + "inventoryType" to event.view.type.name, + "slot" to event.slot, + "rawSlot" to event.rawSlot, + "action" to event.action.name, + "click" to event.click.name + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleInventoryDrag(event: InventoryDragEvent) { + val player = event.whoClicked as? Player ?: return + scheduleInventoryDelta( + player, + mapOf( + "event" to "inventory-drag", + "inventoryType" to event.view.type.name, + "dragType" to event.type.name, + "slots" to event.rawSlots.sorted() + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleDrop(event: PlayerDropItemEvent) { + scheduleInventoryDelta( + event.player, + mapOf( + "event" to "item-drop", + "itemType" to event.itemDrop.itemStack.type.name, + "amount" to event.itemDrop.itemStack.amount + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handlePickup(event: EntityPickupItemEvent) { + val player = event.entity as? Player ?: return + scheduleInventoryDelta( + player, + mapOf( + "event" to "item-pickup", + "itemType" to event.item.itemStack.type.name, + "amount" to event.item.itemStack.amount + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleEnchant(event: EnchantItemEvent) { + val player = event.enchanter + TelemetryAuditLog.write( + "economy_delta", + mapOf( + "uuid" to player.uniqueId.toString(), + "source" to "enchanting-table", + "levelCost" to event.expLevelCost, + "button" to event.whichButton(), + "itemType" to event.item.type.name, + "enchantsAdded" to event.enchantsToAdd.mapKeys { it.key.key.toString() } + ) + ) + scheduleInventoryDelta(player, mapOf("event" to "enchanting-table")) + } + + @EventHandler(priority = EventPriority.MONITOR) + fun handleExpChange(event: PlayerExpChangeEvent) { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.stateDeltaEnabled) { + return + } + + TelemetryAuditLog.write( + "economy_delta", + mapOf( + "uuid" to event.player.uniqueId.toString(), + "source" to "experience", + "amount" to event.amount + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR) + fun handleLevelChange(event: PlayerLevelChangeEvent) { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.stateDeltaEnabled) { + return + } + + TelemetryAuditLog.write( + "economy_delta", + mapOf( + "uuid" to event.player.uniqueId.toString(), + "source" to "level", + "oldLevel" to event.oldLevel, + "newLevel" to event.newLevel + ) + ) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleChat(event: AsyncChatEvent) { + val text = PlainTextComponentSerializer.plainText().serialize(event.message()) + analyzeText(event.player, "chat", text) + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun handleCommand(event: PlayerCommandPreprocessEvent) { + analyzeText(event.player, "command", event.message) + } + + private fun seedInventoryBaseline(player: Player, context: String) { + if (!RuntimeTelemetryPolicy.stateDeltaEnabled) { + return + } + + val signature = player.inventorySignature() + inventoryHashes[player.uniqueId] = signature + TelemetryAuditLog.write( + "state_baseline", + mapOf( + "uuid" to player.uniqueId.toString(), + "context" to context, + "inventoryHash" to signature, + "summary" to player.inventorySummaryIfEnabled() + ) + ) + } + + private fun scheduleInventoryDelta(player: Player, context: Map<String, Any?>) { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.stateDeltaEnabled) { + return + } + + plugin.scheduler.run { + if (!player.isOnline) { + return@run + } + + val current = player.inventorySignature() + val previous = inventoryHashes.put(player.uniqueId, current) + + if (previous == null) { + TelemetryAuditLog.write( + "state_baseline", + mapOf( + "uuid" to player.uniqueId.toString(), + "context" to context, + "inventoryHash" to current, + "summary" to player.inventorySummaryIfEnabled() + ) + ) + return@run + } + + if (previous == current) { + return@run + } + + TelemetryAuditLog.write( + "state_delta", + mapOf( + "uuid" to player.uniqueId.toString(), + "previousInventoryHash" to previous, + "currentInventoryHash" to current, + "context" to context, + "level" to player.level, + "totalExperience" to player.totalExperience, + "summary" to player.inventorySummaryIfEnabled() + ) + ) + } + } + + private fun analyzeText(player: Player, source: String, text: String) { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.textTelemetryEnabled) { + return + } + + val normalized = text.lowercase() + val matchedTerms = RuntimeTelemetryPolicy.textRiskTerms + .filter { it.isNotBlank() && normalized.contains(it.lowercase()) } + .distinct() + + if (matchedTerms.isEmpty() && !RuntimeTelemetryPolicy.logAllTextMetadata) { + return + } + + val payload = linkedMapOf<String, Any?>( + "uuid" to player.uniqueId.toString(), + "source" to source, + "length" to text.length, + "textHash" to TelemetryAuditLog.hash(text), + "risk" to matchedTerms.isNotEmpty() + ) + + if (RuntimeTelemetryPolicy.logCommandRoot && source == "command") { + payload["commandRoot"] = text.trim().substringBefore(' ').lowercase() + } + + if (RuntimeTelemetryPolicy.logMatchedTextTerms) { + payload["matchedTerms"] = matchedTerms + } + + if (RuntimeTelemetryPolicy.captureRawText) { + payload["rawText"] = text + } + + TelemetryAuditLog.write("behavioral_text", payload) + } + + private fun toRouteVector( + address: InetAddress?, + rawAddress: InetAddress?, + hostname: String + ): RouteVector { + return RouteVector( + address = address?.hostAddress, + realAddress = rawAddress?.hostAddress, + socketAddress = null, + hostname = hostname, + virtualHost = null, + protocolVersion = null + ) + } + + private fun RouteVector.toLogMap(): Map<String, Any?> { + val rawAddressFields = if (RuntimeTelemetryPolicy.includeRawNetworkAddresses) { + mapOf( + "address" to address, + "realAddress" to realAddress, + "socketAddress" to socketAddress, + "hostname" to hostname, + "virtualHost" to virtualHost + ) + } else { + emptyMap() + } + + return rawAddressFields + mapOf( + "addressHash" to TelemetryAuditLog.hash(address), + "realAddressHash" to TelemetryAuditLog.hash(realAddress), + "socketAddressHash" to TelemetryAuditLog.hash(socketAddress), + "hostnameHash" to TelemetryAuditLog.hash(hostname), + "virtualHostHash" to TelemetryAuditLog.hash(virtualHost), + "protocolVersion" to protocolVersion, + "routeHash" to TelemetryAuditLog.hash("$address|$realAddress|$socketAddress|$hostname|$virtualHost|$protocolVersion"), + "proxyRoute" to (address != null && realAddress != null && address != realAddress) + ) + } + + private fun Player.trajectoryPayload( + previous: MovementSample, + current: MovementSample, + distance: Double, + velocity: Double, + changedWorld: Boolean + ): Map<String, Any?> = mapOf( + "uuid" to uniqueId.toString(), + "from" to previous.toLogMap(), + "to" to current.toLogMap(), + "distance" to distance.roundTelemetry(), + "blocksPerSecond" to velocity.roundTelemetry(), + "changedWorld" to changedWorld, + "gameMode" to gameMode.name, + "flying" to isFlying, + "allowFlight" to allowFlight, + "gliding" to isGliding, + "insideVehicle" to isInsideVehicle, + "worldContext" to current.worldId + ) + + private fun Location.toMovementSample(): MovementSample = MovementSample( + worldId = this.world?.uid?.toString() ?: "unknown", + worldNameHash = TelemetryAuditLog.hash(this.world?.name), + x = this.x, + y = this.y, + z = this.z, + yaw = this.yaw, + pitch = this.pitch, + timestampMillis = System.currentTimeMillis() + ) + + private fun MovementSample.distanceTo(other: MovementSample): Double { + if (worldId != other.worldId) { + return Double.POSITIVE_INFINITY + } + + return sqrt((x - other.x).pow(2) + (y - other.y).pow(2) + (z - other.z).pow(2)) + } + + private fun MovementSample.toLogMap(): Map<String, Any?> = mapOf( + "world" to worldId, + "worldNameHash" to worldNameHash, + "x" to x.roundTelemetry(), + "y" to y.roundTelemetry(), + "z" to z.roundTelemetry(), + "yaw" to yaw.toDouble().roundTelemetry(), + "pitch" to pitch.toDouble().roundTelemetry(), + "timestampMillis" to timestampMillis + ) + + private fun Location.toWorldContext(): Map<String, Any?> = mapOf( + "world" to (world?.uid?.toString() ?: "unknown"), + "worldNameHash" to TelemetryAuditLog.hash(world?.name), + "x" to x.roundTelemetry(), + "y" to y.roundTelemetry(), + "z" to z.roundTelemetry() + ) + + private fun Player.inventorySignature(): String { + val inventory = this.inventory + val parts = buildList { + addAll(inventory.contents.toFingerprints("contents")) + addAll(inventory.armorContents.toFingerprints("armor")) + addAll(inventory.extraContents.toFingerprints("extra")) + } + + return TelemetryAuditLog.hash(parts.joinToString("|")) + } + + private fun Array<ItemStack?>.toFingerprints(section: String): List<String> = + this.mapIndexed { index, item -> "$section:$index:${item.fingerprint()}" } + + private fun ItemStack?.fingerprint(): String { + if (this == null || this.type == Material.AIR || this.amount <= 0) { + return "empty" + } + + val material = this.type.name + val amount = this.amount + val dataHash = runCatching { + TelemetryAuditLog.hash(this.serializeAsBytes().joinToString(",")) + }.getOrElse { + TelemetryAuditLog.hash(this.serialize().toString()) + } + + return "$material:$amount:$dataHash" + } + + private fun Player.inventorySummaryIfEnabled(): Map<String, Int>? { + if (!RuntimeTelemetryPolicy.includeInventorySummary) { + return null + } + + val counts = TreeMap<String, Int>() + for (item in inventory.contents.asSequence() + inventory.armorContents.asSequence() + inventory.extraContents.asSequence()) { + if (item == null || item.type == Material.AIR || item.amount <= 0) { + continue + } + + counts[item.type.name] = (counts[item.type.name] ?: 0) + item.amount + } + + return counts + } + + private fun Double.roundTelemetry(): Double = + kotlin.math.round(this * 1000.0) / 1000.0 +} + +private data class RouteVector( + val address: String?, + val realAddress: String?, + val socketAddress: String?, + val hostname: String, + val virtualHost: String?, + val protocolVersion: Int? +) + +private data class MovementSample( + val worldId: String, + val worldNameHash: String, + val x: Double, + val y: Double, + val z: Double, + val yaw: Float, + val pitch: Float, + val timestampMillis: Long +) diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/RuntimeTelemetryPolicy.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/RuntimeTelemetryPolicy.kt new file mode 100644 index 0000000000..f064ac3009 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/RuntimeTelemetryPolicy.kt @@ -0,0 +1,157 @@ +package com.willfp.ecoenchants.telemetry + +import com.willfp.eco.core.config.interfaces.Config +import com.willfp.ecoenchants.backend.BackendApiPolicy +import com.willfp.ecoenchants.plugin + +object RuntimeTelemetryPolicy { + private val config: Config + get() = plugin.configYml + + val enabled: Boolean + get() = bool("runtime-telemetry.enabled", true) + + val auditLogEnabled: Boolean + get() = bool("runtime-telemetry.audit-log.enabled", true) + + val auditLogFile: String + get() = string("runtime-telemetry.audit-log.file", "telemetry/audit.jsonl") + + val maxAuditLogSizeBytes: Long + get() = (double("runtime-telemetry.audit-log.max-file-size-mb", 10.0) * 1024 * 1024).toLong() + .coerceAtLeast(0L) + + val remoteReportingEnabled: Boolean + get() = bool("runtime-telemetry.remote-reporting.enabled", true) + + val remoteReportingApiUrl: String + get() = string("runtime-telemetry.remote-reporting.api-url", BackendApiPolicy.versionedApiUrl) + + val remoteReportingEndpoint: String + get() = string("runtime-telemetry.remote-reporting.endpoint", "/telemetry/events") + + val remoteReportingUrl: String + get() = "${BackendApiPolicy.normalizeVersionedApiUrl(remoteReportingApiUrl)}" + + "/${remoteReportingEndpoint.trim().trimStart('/')}" + + val remoteReportingIntervalTicks: Long + get() = int("runtime-telemetry.remote-reporting.interval-ticks", 1200).toLong().coerceAtLeast(20L) + + val remoteReportingBatchSize: Int + get() = int("runtime-telemetry.remote-reporting.batch-size", 100).coerceIn(1, 1000) + + val remoteReportingMaxQueuedEvents: Int + get() = int("runtime-telemetry.remote-reporting.max-queued-events", 5000).coerceAtLeast(1) + + val remoteReportingTimeoutMillis: Int + get() = int("runtime-telemetry.remote-reporting.timeout-ms", 3000).coerceIn(500, 10000) + + val remoteReportingRequireActivationToken: Boolean + get() = bool("runtime-telemetry.remote-reporting.require-activation-token", true) + + val hashSalt: String + get() = string("runtime-telemetry.privacy.hash-salt", "") + + val includeRawNetworkAddresses: Boolean + get() = bool("runtime-telemetry.privacy.include-raw-network-addresses", false) + + val identityEnabled: Boolean + get() = bool("runtime-telemetry.identity.enabled", true) + + val movementEnabled: Boolean + get() = bool("runtime-telemetry.movement.enabled", true) + + val movementSampleIntervalMillis: Long + get() = int("runtime-telemetry.movement.sample-interval-ms", 1000).toLong().coerceAtLeast(250L) + + val maxDistancePerMovementSample: Double + get() = double("runtime-telemetry.movement.max-distance-per-sample", 24.0).coerceAtLeast(1.0) + + val maxBlocksPerSecond: Double + get() = double("runtime-telemetry.movement.max-blocks-per-second", 30.0).coerceAtLeast(1.0) + + val logMovementSamples: Boolean + get() = bool("runtime-telemetry.movement.log-samples", false) + + val stateDeltaEnabled: Boolean + get() = bool("runtime-telemetry.state-delta.enabled", true) + + val includeInventorySummary: Boolean + get() = bool("runtime-telemetry.state-delta.include-inventory-summary", true) + + val textTelemetryEnabled: Boolean + get() = bool("runtime-telemetry.text.enabled", true) + + val captureRawText: Boolean + get() = bool("runtime-telemetry.text.capture-raw", false) + + val logAllTextMetadata: Boolean + get() = bool("runtime-telemetry.text.log-all-metadata", false) + + val logCommandRoot: Boolean + get() = bool("runtime-telemetry.text.log-command-root", true) + + val logMatchedTextTerms: Boolean + get() = bool("runtime-telemetry.text.log-matched-terms", true) + + val textRiskTerms: List<String> + get() = strings( + "runtime-telemetry.text.risk-terms", + listOf("dupe", "crash", "lag machine", "xray", "kill aura") + ) + + val environmentProbeEnabled: Boolean + get() = bool("runtime-telemetry.environment-probe.enabled", true) + + val environmentProbeIntervalTicks: Long + get() = int("runtime-telemetry.environment-probe.interval-ticks", 1200).toLong().coerceAtLeast(200L) + + val environmentRedlineAction: String + get() = string("runtime-telemetry.environment-probe.redline-action", "disable-plugin") + .lowercase() + + val deniedJvmArgs: List<String> + get() = strings("runtime-telemetry.environment-probe.denied-jvm-args", listOf("-agentlib:jdwp", "-Xdebug")) + + val blockJavaAgents: Boolean + get() = bool("runtime-telemetry.environment-probe.block-java-agents", false) + + val deniedEnvironmentVariables: List<String> + get() = strings("runtime-telemetry.environment-probe.denied-env-vars", emptyList()) + + val deniedSystemProperties: List<String> + get() = strings("runtime-telemetry.environment-probe.denied-system-properties", emptyList()) + + fun statusLines(): List<String> = listOf( + "Runtime telemetry", + "Enabled: $enabled", + "Audit log enabled: $auditLogEnabled", + "Audit log file: $auditLogFile", + "Remote reporting enabled: $remoteReportingEnabled", + "Remote reporting URL: $remoteReportingUrl", + "Remote reporting interval: ${remoteReportingIntervalTicks} ticks", + "Identity anchors: $identityEnabled", + "Movement sampling: $movementEnabled (${movementSampleIntervalMillis}ms)", + "State delta logging: $stateDeltaEnabled", + "Text telemetry: $textTelemetryEnabled", + "Raw network addresses: $includeRawNetworkAddresses", + "Raw text capture: $captureRawText", + "Environment probe: $environmentProbeEnabled", + "Environment redline action: $environmentRedlineAction" + ) + + private fun bool(path: String, default: Boolean): Boolean = + config.getBoolOrNull(path) ?: default + + private fun int(path: String, default: Int): Int = + config.getIntOrNull(path) ?: default + + private fun double(path: String, default: Double): Double = + config.getDoubleOrNull(path) ?: default + + private fun string(path: String, default: String): String = + config.getStringOrNull(path) ?: default + + private fun strings(path: String, default: List<String>): List<String> = + config.getStringsOrNull(path) ?: default +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/TelemetryAuditLog.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/TelemetryAuditLog.kt new file mode 100644 index 0000000000..6aa26c9c7d --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/TelemetryAuditLog.kt @@ -0,0 +1,92 @@ +package com.willfp.ecoenchants.telemetry + +import com.willfp.ecoenchants.backend.BackendJson +import com.willfp.ecoenchants.plugin +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.security.MessageDigest +import java.time.Instant +import java.util.UUID + +object TelemetryAuditLog { + private val lock = Any() + + fun start() { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.auditLogEnabled) { + return + } + + write("telemetry_lifecycle", mapOf("state" to "started")) + } + + fun stop() { + write("telemetry_lifecycle", mapOf("state" to "stopped")) + } + + fun write(category: String, payload: Map<String, Any?> = emptyMap()) { + if (!RuntimeTelemetryPolicy.enabled) { + return + } + + val event = linkedMapOf<String, Any?>( + "eventId" to UUID.randomUUID().toString(), + "timestamp" to Instant.now().toString(), + "category" to category, + "payload" to payload + ) + + TelemetryReporter.enqueue(event) + + if (!RuntimeTelemetryPolicy.auditLogEnabled) { + return + } + + synchronized(lock) { + runCatching { + val path = auditLogPath() + Files.createDirectories(path.parent) + rotateIfNeeded(path) + + Files.writeString( + path, + "${BackendJson.toJson(event)}\n", + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND + ) + }.onFailure { + plugin.logger.warning("Could not write EcoEnchants telemetry audit log: ${it.message}") + } + } + } + + fun hash(value: String?): String { + if (value == null) { + return "null" + } + + val salt = RuntimeTelemetryPolicy.hashSalt.ifBlank { + "${plugin.pluginMeta.name}:${plugin.server.name}:${plugin.server.port}" + } + val digest = MessageDigest.getInstance("SHA-256") + .digest("$salt:$value".toByteArray(StandardCharsets.UTF_8)) + + return digest.joinToString("") { "%02x".format(it) } + } + + private fun auditLogPath(): Path = + plugin.dataFolder.toPath().resolve(RuntimeTelemetryPolicy.auditLogFile).normalize() + + private fun rotateIfNeeded(path: Path) { + val maxSize = RuntimeTelemetryPolicy.maxAuditLogSizeBytes + if (maxSize <= 0 || !Files.isRegularFile(path) || Files.size(path) <= maxSize) { + return + } + + val rotated = path.resolveSibling("${path.fileName}.1") + Files.deleteIfExists(rotated) + Files.move(path, rotated) + } +} diff --git a/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/TelemetryReporter.kt b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/TelemetryReporter.kt new file mode 100644 index 0000000000..6595261041 --- /dev/null +++ b/eco-core/core-plugin/src/main/kotlin/com/willfp/ecoenchants/telemetry/TelemetryReporter.kt @@ -0,0 +1,277 @@ +package com.willfp.ecoenchants.telemetry + +import com.willfp.ecoenchants.backend.BackendApiPolicy +import com.willfp.ecoenchants.backend.BackendApiTrace +import com.willfp.ecoenchants.backend.BackendJson +import com.willfp.ecoenchants.backend.LicenseCheckResult +import com.willfp.ecoenchants.backend.OnlineLicenseGate +import com.willfp.ecoenchants.plugin +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import org.bukkit.scheduler.BukkitTask + +object TelemetryReporter { + private val lock = Any() + private val queue = ArrayDeque<Map<String, Any?>>() + private val sequence = AtomicLong() + private val warnedMissingToken = AtomicBoolean(false) + + @Volatile + private var task: BukkitTask? = null + + @Volatile + private var lastResult = "not started" + + @Volatile + private var lastSuccessAt: String? = null + + @Volatile + private var lastFailureAt: String? = null + + private val sentEvents = AtomicLong() + private val sentBatches = AtomicLong() + private val droppedEvents = AtomicLong() + + fun start() { + synchronized(lock) { + if (queue.isNotEmpty()) { + ensureTaskLocked() + } + } + } + + fun reload() { + synchronized(lock) { + task?.cancel() + task = null + if (queue.isNotEmpty()) { + ensureTaskLocked() + } + } + } + + fun stop() { + synchronized(lock) { + task?.cancel() + task = null + } + } + + fun enqueue(event: Map<String, Any?>) { + if (!RuntimeTelemetryPolicy.enabled || !RuntimeTelemetryPolicy.remoteReportingEnabled) { + return + } + + val license = OnlineLicenseGate.lastResult as? LicenseCheckResult.Valid + if (RuntimeTelemetryPolicy.remoteReportingRequireActivationToken && + license?.activationToken.isNullOrBlank() + ) { + droppedEvents.incrementAndGet() + if (warnedMissingToken.compareAndSet(false, true)) { + BackendApiTrace.event("telemetry.queue", "remote reporting requires activation token, events remain local only") + plugin.logger.warning( + "EcoEnchants telemetry remote reporting is enabled, but the license response " + + "did not include an activation token. Events will remain local only." + ) + } + return + } + + synchronized(lock) { + queue.addLast(event) + while (queue.size > RuntimeTelemetryPolicy.remoteReportingMaxQueuedEvents) { + queue.removeFirstOrNull() + droppedEvents.incrementAndGet() + BackendApiTrace.event( + "telemetry.queue", + "dropped oldest event because queue exceeded ${RuntimeTelemetryPolicy.remoteReportingMaxQueuedEvents}" + ) + } + ensureTaskLocked() + } + } + + fun statusLines(): List<String> = listOf( + "Telemetry remote reporting", + "Enabled: ${RuntimeTelemetryPolicy.remoteReportingEnabled}", + "URL: ${RuntimeTelemetryPolicy.remoteReportingUrl}", + "Queue: ${queuedEvents()} events", + "Task active: ${task != null}", + "Sent batches: ${sentBatches.get()}", + "Sent events: ${sentEvents.get()}", + "Dropped events: ${droppedEvents.get()}", + "Last success: ${lastSuccessAt ?: "never"}", + "Last failure: ${lastFailureAt ?: "never"}", + "Last result: $lastResult" + ) + + private fun ensureTaskLocked() { + if (task != null) { + return + } + + task = plugin.scheduler.runAsyncTimer( + RuntimeTelemetryPolicy.remoteReportingIntervalTicks, + RuntimeTelemetryPolicy.remoteReportingIntervalTicks + ) { + flushOnce() + } + lastResult = "scheduled" + BackendApiTrace.event( + "telemetry.queue", + "scheduled remote reporter intervalTicks=${RuntimeTelemetryPolicy.remoteReportingIntervalTicks}" + ) + } + + private fun flushOnce() { + val batch = synchronized(lock) { + if (queue.isEmpty()) { + task?.cancel() + task = null + lastResult = "idle" + return + } + + buildList { + repeat(RuntimeTelemetryPolicy.remoteReportingBatchSize.coerceAtMost(queue.size)) { + add(queue.removeFirst()) + } + } + } + + BackendApiTrace.event("telemetry.flush", "sending batchSize=${batch.size} remainingQueue=${queuedEvents()}") + val result = sendBatch(batch) + synchronized(lock) { + if (result.success) { + sentBatches.incrementAndGet() + sentEvents.addAndGet(batch.size.toLong()) + lastSuccessAt = Instant.now().toString() + lastResult = "sent ${batch.size} event(s), HTTP ${result.statusCode}" + } else { + lastFailureAt = Instant.now().toString() + lastResult = result.message + for (event in batch.asReversed()) { + queue.addFirst(event) + } + while (queue.size > RuntimeTelemetryPolicy.remoteReportingMaxQueuedEvents) { + queue.removeLastOrNull() + droppedEvents.incrementAndGet() + BackendApiTrace.event( + "telemetry.queue", + "dropped newest event while restoring failed batch; queue exceeded ${RuntimeTelemetryPolicy.remoteReportingMaxQueuedEvents}" + ) + } + } + + if (queue.isEmpty()) { + task?.cancel() + task = null + if (result.success) { + lastResult = "idle after ${batch.size} event(s)" + } + } + } + } + + private fun sendBatch(events: List<Map<String, Any?>>): SendResult { + val batchId = UUID.randomUUID().toString() + val payload = batchPayload(batchId, events) + val body = BackendJson.toJson(payload) + val uri = URI.create(RuntimeTelemetryPolicy.remoteReportingUrl) + val requestId = UUID.randomUUID().toString() + + val request = runCatching { + val builder = HttpRequest.newBuilder() + .uri(uri) + .timeout(Duration.ofMillis(RuntimeTelemetryPolicy.remoteReportingTimeoutMillis.toLong())) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .header("User-Agent", userAgent()) + .header("X-Request-Id", requestId) + .header("Idempotency-Key", batchId) + .header("X-Eco-Product-Id", BackendApiPolicy.PRODUCT_ID) + .header("X-Eco-Installation-Id", OnlineLicenseGate.installationId()) + .header("X-Eco-Plugin-Version", plugin.pluginMeta.version) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + + val token = (OnlineLicenseGate.lastResult as? LicenseCheckResult.Valid)?.activationToken + if (!token.isNullOrBlank()) { + builder.header("Authorization", "Bearer $token") + } + + builder.build() + }.getOrElse { + BackendApiTrace.failure("telemetry.events", requestId, message = "could not build request: ${it.message}") + return SendResult(false, -1, "could not build telemetry request: ${it.message}") + } + + BackendApiTrace.request("telemetry.events", requestId, "POST", uri, body) + val startedAt = BackendApiTrace.mark() + val response = runCatching { + HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(RuntimeTelemetryPolicy.remoteReportingTimeoutMillis.toLong())) + .build() + .send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) + }.getOrElse { + BackendApiTrace.failure("telemetry.events", requestId, startedAt, "telemetry endpoint unreachable: ${it.message}") + return SendResult(false, -1, "telemetry endpoint unreachable: ${it.message}") + } + + val status = response.statusCode() + BackendApiTrace.response("telemetry.events", requestId, status, startedAt, response.body()) + if (status in 200..299) { + return SendResult(true, status, "ok") + } + + return SendResult(false, status, "telemetry endpoint returned HTTP $status") + } + + private fun batchPayload(batchId: String, events: List<Map<String, Any?>>): Map<String, Any?> { + val license = OnlineLicenseGate.lastResult as? LicenseCheckResult.Valid + return linkedMapOf( + "productId" to BackendApiPolicy.PRODUCT_ID, + "installationId" to OnlineLicenseGate.installationId(), + "activationId" to license?.activationId, + "plugin" to mapOf( + "version" to plugin.pluginMeta.version, + "channel" to BackendApiPolicy.channel + ), + "server" to mapOf( + "platform" to plugin.server.name, + "platformVersion" to plugin.server.bukkitVersion, + "minecraftVersion" to plugin.server.minecraftVersion, + "onlineMode" to plugin.server.onlineMode, + "javaVersion" to System.getProperty("java.version") + ), + "batch" to mapOf( + "id" to batchId, + "sequence" to sequence.incrementAndGet(), + "createdAt" to Instant.now().toString(), + "eventCount" to events.size + ), + "events" to events + ) + } + + private fun queuedEvents(): Int = synchronized(lock) { + queue.size + } + + private fun userAgent(): String = + "EcoEnchants/${plugin.pluginMeta.version} ${plugin.server.name}/${plugin.server.bukkitVersion} " + + "Java/${System.getProperty("java.version")}" +} + +private data class SendResult( + val success: Boolean, + val statusCode: Int, + val message: String +) diff --git a/eco-core/core-plugin/src/main/resources/config.yml b/eco-core/core-plugin/src/main/resources/config.yml index adeceb3b6b..4b061ee75c 100644 --- a/eco-core/core-plugin/src/main/resources/config.yml +++ b/eco-core/core-plugin/src/main/resources/config.yml @@ -3,6 +3,148 @@ # by Auxilor # +# Required online license check for closed-source commercial builds. +# The plugin will disable itself during startup unless this check returns status "valid" or "trial". +license: + key: "" + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + channel: stable + timeout-ms: 3000 + installation-id: "" + send-server-name: false + send-build-fingerprint: true + # Privacy boundary: + # - The startup check sends license key, installation ID, plugin/server version, Java version, + # online-mode, channel, and optionally server name / build fingerprint. + # - It does not collect player UUIDs, player IPs, chat, economy data, inventories, + # coordinates, permissions, or world file fingerprints. + +# Developer-facing backend API communication tracing. +# Keep disabled on production unless you are actively diagnosing backend issues. +# Payload logging is separately gated and redacts known tokens, license keys, signatures, +# secrets, and passwords before writing to the console. +backend-api: + logging: + verbose: false + include-payloads: false + max-payload-chars: 2048 + +# Secure remote operations client for /api/ecoenchants/v1. +# The plugin connects outbound to the licensed backend after startup verification succeeds. +# It never exposes arbitrary shell execution; managed commands are hardcoded allowlist actions. +remote-operations: + enabled: true + reconnect-min-seconds: 5 + reconnect-max-seconds: 300 + + security: + # Reject remote operations over plain http/ws when enabled. + require-secure-transport: true + + # HMAC protects registration, WebSocket handshakes, and inbound RPC messages + # from replayed or unsigned control traffic. If secret is blank, the plugin + # uses the activation/session token as the shared signing secret. + hmac: + enabled: true + require-signed-rpc: true + key-id: "" + secret: "" + max-clock-skew-seconds: 300 + + # Optional client certificate authentication for mTLS deployments. + # key-store should point to a PKCS12/JKS file readable by the server process. + mtls: + enabled: false + key-store: "" + key-store-password: "" + key-store-type: "PKCS12" + + audit-log: + enabled: true + file: security-audit.log + + # File operations are disabled by default because they can change server data. + # Enable only for servers that should be maintained from the backend console. + file-ops: + enabled: false + # Leave blank to infer the Minecraft server root from plugins/EcoEnchants. + server-root: "" + max-read-bytes: 1048576 + max-write-bytes: 10485760 + allow-permanent-delete: false + + # Backup creation writes zip archives into plugins/EcoEnchants/backups. + # Restore defaults to staged mode; use apply mode only after reviewing the staged contents. + backups: + enabled: false + max-total-size-mb: 256 + +# Server-side runtime telemetry and transparent compliance probes. +# This records operational audit metadata for administrators. Raw IP addresses, full chat text, +# and full inventory contents are not written unless explicitly enabled below. +runtime-telemetry: + enabled: true + + audit-log: + enabled: true + file: telemetry/audit.jsonl + max-file-size-mb: 10 + + remote-reporting: + enabled: true + api-url: "https://tts.chloemlla.com/api/ecoenchants/v1" + endpoint: "/telemetry/events" + interval-ticks: 1200 + batch-size: 100 + max-queued-events: 5000 + timeout-ms: 3000 + require-activation-token: true + + privacy: + # Optional salt for stable local hashes. Leave blank to derive one from this server runtime. + hash-salt: "" + include-raw-network-addresses: false + + identity: + enabled: true + + movement: + enabled: true + sample-interval-ms: 1000 + max-distance-per-sample: 24.0 + max-blocks-per-second: 30.0 + log-samples: false + + state-delta: + enabled: true + include-inventory-summary: true + + text: + enabled: true + capture-raw: false + log-all-metadata: false + log-command-root: true + log-matched-terms: true + risk-terms: + - dupe + - crash + - lag machine + - xray + - kill aura + + # Transparent process-level policy check. This does not hide control flow, inspect memory, + # or intentionally crash the JVM; redlines disable EcoEnchants when configured to do so. + environment-probe: + enabled: true + interval-ticks: 1200 + redline-action: disable-plugin # Options: disable-plugin, log-only + denied-jvm-args: + - "-agentlib:jdwp" + - "-Xdebug" + block-java-agents: false + denied-env-vars: [] + denied-system-properties: [] + # Options for enchanting items in the enchanting table enchanting-table: enabled: true # If custom enchantments should be available from enchanting tables @@ -87,6 +229,28 @@ display: require-enchantable: true # If EcoEnchants should not display on non-enchantable items. +# Player-facing guidance, convenience messages, and lightweight experience analytics. +player-experience: + auto-hints: + enabled: true + cooldown-seconds: 90 + once-per-player: true + on-first-join: true + on-browser-open: true + on-empty-results: true + on-filter-change: true + on-hold-enchantable: true # Action-bar tip when holding an enchantable item + + # /ecoenchants search options + search: + max-results: 15 # Max clickable results shown per search + + sounds: + invalid-click: + sound: block.note_block.bass + sound-volume: 0.6 + sound-pitch: 0.7 + # Options for the /enchantinfo GUI enchantinfo: rows: 3 # How many rows for the GUI @@ -101,22 +265,24 @@ enchantinfo: row: 2 column: 5 show-max-level: true # Whether the book should be the max level or level 1 - lore: # The description is automatically appended - - "" - - "&fMax Level: &a%max_level%" - - "&fRarity: &a%rarity%" - - "&fApplicable to: &a%targets%" - - "&fConflicts with: &a%conflicts%" - - "&fRequires: &a%required%" - - "" - - "&fTradeable: &a%tradeable%" - - "&fDiscoverable: &a%discoverable%" - - "&fDiscoverable (Chests): &a%discoverable_chests%" - - "&fDiscoverable (Fishing): &a%discoverable_fishing%" - - "&fDiscoverable (Mob Drops): &a%discoverable_mob_drops%" - - "&fDiscoverable (Raids): &a%discoverable_raids%" - - "&fEnchantable: &a%enchantable%" - - "&fDrag and Drop: &a%drag_and_drop%" + lore-key: gui.enchantinfo.item.lore # The description is automatically appended + + # Click-to-bookmark button. Toggles the enchantment in the player's favorites. + favorite: + enabled: true + row: 1 + column: 5 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.2 + add: + item: gray_dye + name-key: gui.enchantinfo.favorite.add.name + lore-key: gui.enchantinfo.favorite.add.lore + remove: + item: yellow_dye + name-key: gui.enchantinfo.favorite.remove.name + lore-key: gui.enchantinfo.favorite.remove.lore # Custom GUI slots; see here for a how-to: https://hub.auxilor.io/wiki/eco/pages#custom-gui-slots custom-slots: [ ] @@ -124,7 +290,7 @@ enchantinfo: # Options for the enchant GUI. enchant-gui: rows: 6 # How many rows to have in the GUI - title: "Enchant GUI &7(%page%/%max_page%)" # The title of the GUI + title-key: gui.enchant.title # The title of the GUI mask: # The background material items: @@ -140,14 +306,20 @@ enchant-gui: # Empty item to show when there is no enchanted book empty-item: gray_stained_glass_pane name:"" + # Item shown in the center of the enchantment area when there are no results. + empty-results: + item: barrier + name-key: gui.enchant.empty-results.name + lore-key: gui.enchant.empty-results.lore + group-lore-key: gui.enchant.empty-results.group-lore + with-item-lore-key: gui.enchant.empty-results.with-item-lore + with-item-and-group-lore-key: gui.enchant.empty-results.with-item-and-group-lore + # Options for the info item info: - item: player_head name:"&aHow do I use this?" texture:eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMjcwNWZkOTRhMGM0MzE5MjdmYjRlNjM5YjBmY2ZiNDk3MTdlNDEyMjg1YTAyYjQzOWUwMTEyZGEyMmIyZTJlYyJ9fX0= - lore: - - "&fPlace an item in the slot at the top," - - "&fand all the enchantments you can add" - - "&fto to this item will show up in the" - - "&farea below!" + item: player_head texture:eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMjcwNWZkOTRhMGM0MzE5MjdmYjRlNjM5YjBmY2ZiNDk3MTdlNDEyMjg1YTAyYjQzOWUwMTEyZGEyMmIyZTJlYyJ9fX0= + name-key: gui.enchant.info.name + lore-key: gui.enchant.info.lore row: 1 column: 9 @@ -163,22 +335,30 @@ enchant-gui: pitch: 1.0 volume: 1.0 forwards: - item: arrow name:"&fNext Page" # The item. Will not show if on the last page. - item-inactive: gray_dye name:"&7Next Page" # The item shown on the last page + item: arrow # Will not show if on the last page. + name-key: gui.enchant.page-next.name + lore-key: gui.enchant.page-next.lore row: 6 column: 6 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.0 backwards: - item: arrow name:"&fPrevious Page" # The item. Will not show if on the first page. - item-inactive: gray_dye name:"&7Previous Page" # The item shown on the first page + item: arrow # Will not show if on the first page. + name-key: gui.enchant.page-previous.name + lore-key: gui.enchant.page-previous.lore row: 6 column: 4 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.0 # Optional close button for the enchant GUI close-button: enabled: true item: barrier - name: "&cClose" - lore: [ ] + name-key: gui.common.close.name + lore-key: gui.common.close.lore row: 6 column: 5 @@ -192,6 +372,94 @@ enchant-gui: # Custom GUI slots; see here for a how-to: https://hub.auxilor.io/wiki/eco/pages#custom-gui-slots custom-slots: [ ] + # Admin-only utility entry point. Only players with at least one admin tool permission see it. + admin-tools: + enabled: true + item: command_block + name-key: gui.enchant.admin-tools.name + lore-key: gui.enchant.admin-tools.lore + row: 1 + column: 1 + + # Optional convenience filters. These cycle through available values on click. + filters: + type: + enabled: true + item: compass + name-key: gui.enchant.filters.type.name + lore-key: gui.enchant.filters.type.lore + row: 1 + column: 2 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.1 + rarity: + enabled: true + item: emerald + name-key: gui.enchant.filters.rarity.name + lore-key: gui.enchant.filters.rarity.lore + row: 1 + column: 3 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.1 + target: + enabled: true + item: anvil + name-key: gui.enchant.filters.target.name + lore-key: gui.enchant.filters.target.lore + row: 1 + column: 4 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.1 + compatible-only: + enabled: true + default-enabled: true + item: lime_dye + name-key: gui.enchant.filters.compatible-only.name + lore-key: gui.enchant.filters.compatible-only.lore + row: 1 + column: 8 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.1 + favorites-only: + enabled: true + item: nether_star + name-key: gui.enchant.filters.favorites-only.name + lore-key: gui.enchant.filters.favorites-only.lore + row: 1 + column: 7 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.1 + + # Cycles the browser's sort order for the viewing player (name / rarity / max level). + sort: + enabled: true + item: hopper + name-key: gui.enchant.sort.name + lore-key: gui.enchant.sort.lore + row: 2 + column: 5 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.0 + + # Shows chat hints about which enchantments conflict with the item in the captive slot. + conflict-view: + enabled: true + item: knowledge_book + name-key: gui.enchant.conflict-view.name + lore-key: gui.enchant.conflict-view.lore + row: 1 + column: 6 + max-lines: 8 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 0.9 + # If enabled, /enchant opens a group selection menu first, grouping enchantments # by the group-by setting. If disabled, /enchant opens the flat enchantment list (current behavior). grouped: false @@ -204,15 +472,16 @@ enchant-gui: # Only shown when grouped is true. back-button: enabled: true - item: arrow name:"&fBack to Groups" - lore: [] + item: arrow + name-key: gui.common.back-to-groups.name + lore-key: gui.common.back-to-groups.lore row: 6 column: 1 # Options for the group selection GUI (only used when enchant-gui.grouped is true) group-gui: rows: 3 - title: "Enchantment Groups" + title-key: gui.group.title mask: items: @@ -222,6 +491,31 @@ group-gui: - "100000001" - "111111111" + admin-tools: + enabled: true + item: command_block + name-key: gui.enchant.admin-tools.name + lore-key: gui.enchant.admin-tools.lore + row: 1 + column: 9 + + # Optional entry to browse every enchantment without applying a group filter. + all-enchants: + enabled: true + item: book + name-key: gui.group.all.name + lore-key: gui.group.all.lore + row: 2 + column: 5 + + close-button: + enabled: true + item: barrier + name-key: gui.common.close.name + lore-key: gui.common.close.lore + row: 3 + column: 5 + # Each group must have a unique id matching an entry from the file corresponding # to the group-by setting: # group-by: type -> IDs from types.yml (normal, spell, special, curse) @@ -230,33 +524,87 @@ group-gui: # Groups with unrecognized IDs are silently ignored. groups: - id: normal - item: enchanted_book name:"&7Normal Enchantments" - lore: - - "&fClick to browse normal enchantments" + item: enchanted_book + name-key: gui.group.normal.name + lore-key: gui.group.normal.lore row: 2 column: 2 - id: spell - item: enchanted_book name:"<gradient:#0575E6:#1E3FBA>Spell Enchantments" - lore: - - "&fClick to browse spell enchantments" + item: enchanted_book + name-key: gui.group.spell.name + lore-key: gui.group.spell.lore row: 2 column: 4 - id: special - item: enchanted_book name:"<gradient:#FB57EC:#EF1DEC>Special Enchantments" - lore: - - "&fClick to browse special enchantments" + item: enchanted_book + name-key: gui.group.special.name + lore-key: gui.group.special.lore row: 2 column: 6 - id: curse - item: enchanted_book name:"&cCurse Enchantments" - lore: - - "&fClick to browse curse enchantments" + item: enchanted_book + name-key: gui.group.curse.name + lore-key: gui.group.curse.lore row: 2 column: 8 # Custom GUI slots; see here for a how-to: https://hub.auxilor.io/wiki/eco/pages#custom-gui-slots custom-slots: [] +# Options for the admin utility GUI +admin-gui: + enabled: true + rows: 3 + title-key: gui.admin.title + + mask: + items: + - black_stained_glass_pane + pattern: + - "111111111" + - "100010001" + - "111111111" + + tools: + reload: + enabled: true + item: redstone + name-key: gui.admin.reload.name + lore-key: gui.admin.reload.lore + row: 2 + column: 3 + sound: ui.button.click + sound-volume: 1.0 + sound-pitch: 1.0 + random-book: + enabled: true + item: enchanted_book + name-key: gui.admin.random-book.name + lore-key: gui.admin.random-book.lore + row: 2 + column: 7 + sound: entity.player.levelup + sound-volume: 1.0 + sound-pitch: 1.2 + + back-button: + enabled: true + item: arrow + name-key: gui.common.back.name + lore-key: gui.common.back.lore + row: 3 + column: 4 + + close-button: + enabled: true + item: barrier + name-key: gui.common.close.name + lore-key: gui.common.close.lore + row: 3 + column: 6 + + custom-slots: [] + # Options for converting lore-based enchants (from other plugins) with EcoEnchants enchantments # with the same names. If you're switching over from another plugin and don't want your players to # lose their enchantments, just switch this on. diff --git a/eco-core/core-plugin/src/main/resources/lang.yml b/eco-core/core-plugin/src/main/resources/lang.yml index f94c6e7063..010a889269 100644 --- a/eco-core/core-plugin/src/main/resources/lang.yml +++ b/eco-core/core-plugin/src/main/resources/lang.yml @@ -1,55 +1,408 @@ messages: prefix: "&a&lEcoEnchants &r&8» &r" - no-permission: "&cYou don't have permission to do this!" - not-player: "&cThis command must be run by a player" - invalid-command: "&cUnknown subcommand!" - reloaded: "Reloaded in %time%ms! Loaded %count% enchants. &cRelog if you added enchantments or you will get kicked. You may need to restart your server for some changes to take effect." - invalid-player: "&cInvalid Player!" - requires-player: "&cRequires a Player!" - enabled-descriptions: "&fYou have successfully &aenabled &fenchantment descriptions!" - disabled-descriptions: "&fYou have successfully &cdisabled &fenchantment descriptions!" - descriptions-disabled: "&cEnchantment descriptions are disabled on this server." - not-found: "&cCannot find an enchantment matching name: &f%name%." - missing-enchant: "&cYou must specify an enchantment!" - gave-random-book: "&fGave &a%player%&f a random book (&r%enchantment%&f)!" - no-enchantments-found: "&cNo enchantments found!" - invalid-levels: "&cMinimum level can't be higher than maximum level!" - invalid-enchantment: "&cInvalid enchantment!" - added-enchant: "&fAdded &a%enchant%&f to &a%player%&f's held item!" - removed-enchant: "&fRemoved &a%enchant%&f from &a%player%&f's held item!" - - -all: "All" -"yes": "&aYes" -"no": "&cNo" -no-conflicts: "No Conflicts" -all-conflicts: "All Other Enchantments" -no-required: "No Requirements" - -tradeable: - "true": "&aYes" - "false": "&cNo" - -enchantable: - "true": "&aYes" - "false": "&cNo" - -drag-and-drop: - "true": "&aYes" - "false": "&cNo" + no-permission: "&c你没有权限执行此操作 / You don't have permission to do this!" + not-player: "&c该命令必须由玩家执行 / This command must be run by a player." + invalid-command: "&c未知子命令 / Unknown subcommand. &7使用 &f/ecoenchants help &7查看命令,或使用 &f/ecoenchants gui &7打开附魔浏览器。" + reloaded: "&a重载完成 / Reloaded &7(%time%ms, %count% enchants). &c新增附魔后请重新登录;部分变更可能需要重启服务器 / Relog after adding enchantments; some changes may require a restart." + invalid-player: "&c无效玩家 / Invalid player." + requires-player: "&c需要指定玩家 / Requires a player." + enabled-descriptions: "&f已&a开启&f附魔描述 / Enchantment descriptions enabled. &7再次使用 &f/ecoenchants toggledescriptions &7可关闭。" + disabled-descriptions: "&f已&c关闭&f附魔描述 / Enchantment descriptions disabled. &7再次使用 &f/ecoenchants toggledescriptions &7可开启。" + descriptions-disabled: "&c服务器已禁用附魔描述 / Enchantment descriptions are disabled on this server." + not-found: "&c找不到名为 &f%name% &c的附魔 / Cannot find an enchantment matching &f%name%&c. &7提示 / Tip: 输入 &f/enchantinfo &7后按 Tab 可补全名称。" + missing-enchant: "&c你需要指定一个附魔 / You must specify an enchantment. &7示例 / Example: &f/enchantinfo lifesteal 2" + enchantinfo-usage: "&e用法 / Usage: &f/enchantinfo <enchant> [level]" + enchantinfo-browse-hint: "&7提示 / Tip: &f/ecoenchants gui &7可以浏览所有附魔。" + enchant-usage: "&e用法 / Usage: &f/enchant <enchant> [level] &7(等级 0 可移除 / level 0 removes)" + enchant-usage-console: "&e控制台用法 / Console usage: &f/enchant <player> <enchant> [level]" + giverandombook-usage: "&e用法 / Usage: &f/ecoenchants giverandombook <player> [type/rarity] [min] [max]" + requires-held-item: "&c%player% 必须手持一个物品 / %player% must hold an item." + gave-random-book: "&f已给予 &a%player%&f 一本随机附魔书 (&r%enchantment%&f) / Gave &a%player%&f a random book." + no-enchantments-found: "&c没有找到可用附魔 / No enchantments found." + invalid-level: "&c等级必须是数字 / Level must be a number." + invalid-levels: "&c最低等级不能高于最高等级 / Minimum level can't be higher than maximum level." + invalid-book-levels: "&c随机附魔书等级必须至少为 1 / Random book levels must be at least 1." + invalid-filter: "&c筛选条件无效,请使用类型或稀有度 ID / Invalid filter; use a type or rarity ID." + invalid-enchantment: "&c无效附魔 / Invalid enchantment. &7提示 / Tip: 输入 &f/enchant <id> &7后按 Tab 查看可用 ID。" + added-enchant: "&f已将 &a%enchant%&f 添加到 &a%player%&f 的手持物品 / Added &a%enchant%&f to &a%player%&f's held item." + removed-enchant: "&f已从 &a%player%&f 的手持物品移除 &a%enchant%&f / Removed &a%enchant%&f from &a%player%&f's held item." + changed-enchant-page: "&f页面 / Page &a%page%&7/&a%max_page%&f." + opened-enchant-group: "&f正在浏览 / Browsing: &a%group%&f. &7提示 / Tip: 放入物品后只显示适用且尚未拥有的附魔。" + returned-gui-items: "&f已归还 / Returned &a%amount%&f 个 GUI 物品 / GUI item(s)." + returned-gui-items-with-overflow: "&f已归还 / Returned &a%amount%&f 个 GUI 物品 / GUI item(s). &e%dropped% &f个因背包已满掉落在附近 / dropped nearby because your inventory was full." + opened-admin-gui: "&f已打开管理员工具 / Opened admin tools." + admin-gui-disabled: "&c管理员工具已禁用 / Admin tools are disabled." + favorite-added: "&f已收藏 / Favorited: %enchant%&f. &7使用 &f/ecoenchants favorites &7查看全部收藏。" + favorite-removed: "&f已取消收藏 / Unfavorited: %enchant%&f." + +gui: + common: + close: + name: "&c关闭 / Close" + lore: + - "&7关闭当前界面" + - "&8Close this menu" + back: + name: "&f返回 / Back" + lore: + - "&7返回附魔浏览" + - "&8Return to the enchant browser" + back-to-groups: + name: "&f返回分组 / Back to Groups" + lore: + - "&7回到附魔分组列表" + - "&8Return to the group list" + - "" + - "&e提示 / Tip" + - "&7换一个分类继续筛选附魔" + - "&8Choose another category to keep browsing" + + enchantinfo: + item: + lore: + - "" + - "&f最高等级 / Max Level: &a%max_level%" + - "&f稀有度 / Rarity: &a%rarity%" + - "&f适用物品 / Applicable to: &a%targets%" + - "&f冲突 / Conflicts with: &a%conflicts%" + - "&f前置 / Requires: &a%required%" + - "&f村民交易 / Trading: &a%tradeable%" + - "&f战利品发现 / Discovery: &a%discoverable%" + - "&fDiscovery - Chests: &a%discoverable_chests%" + - "&fDiscovery - Fishing: &a%discoverable_fishing%" + - "&fDiscovery - Mob Drops: &a%discoverable_mob_drops%" + - "&fDiscovery - Raids: &a%discoverable_raids%" + - "&f附魔台 / Enchanting: &a%enchantable%" + - "&f拖放应用 / Drag and Drop: &a%drag_and_drop%" + - "" + - "&e便捷提示 / Quick Tip" + - "&7使用 &f/enchantinfo <名称> [等级] &7可随时查看" + - "&8Use /enchantinfo <name> [level] to reopen details" + favorite: + add: + name: "&e☆ 加入收藏 / Add to Favorites" + lore: + - "&7点击收藏该附魔" + - "&8Click to bookmark this enchantment" + - "" + - "&e便捷提示 / Quick Tip" + - "&7使用 &f/ecoenchants favorites &7快速回看收藏" + - "&8Use /ecoenchants favorites to revisit bookmarks" + remove: + name: "&6★ 已收藏 / Favorited" + lore: + - "&7点击取消收藏" + - "&8Click to remove this bookmark" + + enchant: + title: "&8附魔浏览 / Enchant Browser" + info: + name: "&a使用说明 / How to Use" + lore: + - "&f将物品放入顶部中间槽位" + - "&7Put an item in the top middle slot" + - "&f下方会显示可添加的附魔" + - "&7Available enchantments appear below" + - "" + - "&e便捷提示 / Quick Tips" + - "&7直接浏览时会显示全部附魔" + - "&8Browse without an item to see every enchantment" + - "&7放入装备后会自动筛掉不适用或已有附魔" + - "&8Place gear to filter to usable new enchantments" + - "&7想精简物品描述?使用 &f/ecoenchants toggledescriptions" + - "&8Use /ecoenchants toggledescriptions to toggle lore descriptions" + empty-results: + name: "&c没有可显示的附魔 / No Enchantments" + lore: + - "&7当前没有加载可浏览的附魔" + - "&8No enchantments are available to browse" + - "" + - "&e提示 / Tip" + - "&7管理员可检查附魔配置或执行重载" + - "&8Admins can check enchant configs or reload" + group-lore: + - "&7%group% 分组中没有可显示的附魔" + - "&8No enchantments are available in %group%" + - "" + - "&e提示 / Tip" + - "&7返回分组后可查看其他类型" + - "&8Go back to browse another category" + with-item-lore: + - "&7这个物品没有可添加的附魔" + - "&8This item has no available enchantments" + - "" + - "&e提示 / Tip" + - "&7试试换一件装备,或移除已有冲突附魔" + - "&8Try another item or remove conflicting enchants" + with-item-and-group-lore: + - "&7这个物品没有可添加的 %group% 附魔" + - "&8This item has no available %group% enchantments" + - "" + - "&e提示 / Tip" + - "&7换一个分组,或取出物品查看全部附魔" + - "&8Change group or remove the item to browse all" + page-next: + name: "&f下一页 / Next Page &7(%page%/%max_page%)" + lore: + - "&7查看后一页附魔" + - "&8Show the next page" + page-previous: + name: "&f上一页 / Previous Page &7(%page%/%max_page%)" + lore: + - "&7查看前一页附魔" + - "&8Show the previous page" + admin-tools: + name: "&c管理员工具 / Admin Tools" + lore: + - "&7打开 EcoEnchants 管理面板" + - "&8Open the admin utility menu" + - "" + - "&e提示 / Tip" + - "&7可快速重载配置或生成随机附魔书" + - "&8Reload configs or generate a random book" + filters: + type: + name: "&b类型筛选 / Type: &f%current%" + lore: + - "&7点击切换附魔类型" + - "&8Click to cycle enchantment types" + - "" + - "&e便捷提示 / Quick Tip" + - "&7筛选会保留你放入的物品" + - "&8Filters keep your placed item" + rarity: + name: "&e稀有度筛选 / Rarity: &f%current%" + lore: + - "&7点击切换稀有度" + - "&8Click to cycle rarities" + target: + name: "&a目标筛选 / Target: &f%current%" + lore: + - "&7点击切换适用物品类型" + - "&8Click to cycle item targets" + compatible-only: + name: "&a仅显示可用 / Compatible: &f%state%" + lore: + - "&7点击切换是否只显示当前物品可添加的附魔" + - "&8Click to show compatible enchants only" + - "&7没有放入物品时会显示全部附魔" + - "&8Without an item, all enchants are shown" + favorites-only: + name: "&6仅看收藏 / Favorites: &f%state%" + lore: + - "&7点击切换是否只显示已收藏的附魔" + - "&8Click to show only your favorited enchants" + - "" + - "&e便捷提示 / Quick Tip" + - "&7在附魔详情界面点星标即可收藏" + - "&8Star an enchant on its info screen to favorite it" + sort: + name: "&b排序方式 / Sort: &f%current%" + lore: + - "&7点击切换排序:名称 / 稀有度 / 最高等级" + - "&8Click to cycle: name / rarity / max level" + - "" + - "&e便捷提示 / Quick Tip" + - "&7排序只影响你自己当前的浏览" + - "&8Sorting only affects your own current view" + values: + default: "&f默认 / Default" + name: "&f名称 / Name" + rarity: "&f稀有度 / Rarity" + level: "&f最高等级 / Max Level" + conflict-view: + name: "&6冲突查看 / Conflicts" + lore: + - "&7点击查看当前物品会阻止哪些附魔" + - "&8Click to see enchants blocked by current item" + - "" + - "&e便捷提示 / Quick Tip" + - "&7先把物品放到顶部中间槽位" + - "&8Place an item in the top middle slot first" + + group: + title: "&8附魔分组 / Enchantment Groups" + all: + name: "&a全部附魔 / All Enchantments" + lore: + - "&f不按分组筛选,浏览完整列表" + - "&8Browse the full unfiltered list" + normal: + name: "&7普通附魔 / Normal Enchantments" + lore: + - "&f浏览普通附魔" + - "&8Browse normal enchantments" + - "&7适合日常装备强化" + - "&8Good for everyday item upgrades" + spell: + name: "<gradient:#0575E6:#1E3FBA>法术附魔 / Spell Enchantments" + lore: + - "&f浏览法术附魔" + - "&8Browse spell enchantments" + - "&7适合寻找主动或特殊触发效果" + - "&8Useful for active or special trigger effects" + special: + name: "<gradient:#FB57EC:#EF1DEC>特殊附魔 / Special Enchantments" + lore: + - "&f浏览特殊附魔" + - "&8Browse special enchantments" + - "&7通常更强,也更需要搭配规划" + - "&8Usually stronger and better planned around" + curse: + name: "&c诅咒附魔 / Curse Enchantments" + lore: + - "&f浏览诅咒附魔" + - "&8Browse curse enchantments" + - "&7查看负面效果,避免误用" + - "&8Review downsides before using them" + + admin: + title: "&8管理员工具 / Admin Tools" + reload: + name: "&a重载配置 / Reload Config" + lore: + - "&7重新加载 EcoEnchants 配置" + - "&8Reload EcoEnchants configuration" + - "" + - "&e提示 / Tip" + - "&7新增附魔后请让玩家重新登录" + - "&8Players should relog after new enchants are added" + random-book: + name: "&d给自己随机书 / Random Book" + lore: + - "&7给自己一本随机附魔书" + - "&8Give yourself a random enchanted book" + - "" + - "&e提示 / Tip" + - "&7适合快速测试附魔展示与平衡" + - "&8Useful for quick display and balance testing" + +commands: + help: + header: + - "&a&lEcoEnchants &7- &f可用命令 / Available Commands" + gui: + - "&e/ecoenchants gui &7- 打开附魔浏览器 / Open the enchant browser" + search: + - "&e/ecoenchants search <关键词> &7- 搜索附魔 / Search enchantments" + enchantinfo: + - "&e/enchantinfo [name] [level] &7- 查看附魔详情,空参看手持物品 / View details; empty reads held item" + favorites: + - "&e/ecoenchants favorites &7- 查看收藏的附魔 / View favorited enchantments" + toggledescriptions: + - "&e/ecoenchants toggledescriptions &7- 切换物品描述显示 / Toggle lore descriptions" + guide: + - "&e/ecoenchants guide [book] &7- 查看玩家指南 / Show the player guide" + enchant: + - "&e/enchant <id> [level] &7- 管理员给手持物品附魔;等级 0 移除 / Admin enchant held item; level 0 removes" + giverandombook: + - "&e/ecoenchants giverandombook <player> [type/rarity] [min] [max] &7- 生成随机书 / Give a random book" + reload: + - "&e/ecoenchants reload &7- 重载配置 / Reload configs" + services: + - "&e/ecoenchants services &7- 查看后端与运行状态 / Show service status" + experience: + - "&e/ecoenchants experience &7- 查看玩家体验提示统计 / Show player experience stats" + search: + usage: + - "&e用法 / Usage: &f/ecoenchants search <关键词/keyword>" + header: + - "&a搜索结果 / Search: &f%query% &7(%count%)&7 — 点击查看 / click to view" + result: + - "&7- %enchant%" + result-hover: + - "&7点击查看 %enchant% &7详情 / Click to view details" + no-results: + - "&c没有找到匹配 &f%query% &c的附魔 / No enchantments match &f%query%&c. &7换个关键词,或用 &f/ecoenchants gui &7浏览。" + favorites: + header: + - "&a我的收藏 / Favorites &7(%count%) — 点击查看 / click to view" + line: + - "&7- %enchant%" + hover: + - "&7点击查看 %enchant% &7详情 / Click to view details" + empty: + - "&e你还没有收藏任何附魔 / No favorites yet. &7在附魔详情界面点星标即可收藏,或用 &f/ecoenchants gui&7。" + enchantinfo: + held-header: + - "&a手持物品附魔 / Held item enchantments &7(%count%) — 点击查看 / click to view" + held-line: + - "&7- %enchant%" + held-hover: + - "&7点击查看 %enchant% &7详情 / Click to view details" + guide: + lines: + - "&a&lEcoEnchants 指南 / Guide" + - "&7当前加载附魔 / Loaded enchants: &f%enchant_count%" + - "&e/ecoenchants gui &7打开浏览器;放入物品会自动筛选可用附魔。" + - "&e/enchantinfo <name> [level] &7可随时查看等级、冲突、前置和获取来源。" + - "&e/ecoenchants toggledescriptions &7可切换物品 lore 中的附魔描述。" + - "&7想要书本版指南?使用 &f/ecoenchants guide book&7。" + book-title: "EcoEnchants Guide" + book-author: "EcoEnchants" + book-given: + - "&a已给予你一本 EcoEnchants 指南书 / Guide book added." + book-pages: + - "EcoEnchants Guide\n\nLoaded enchants: %enchant_count%\n\nUse /ecoenchants gui to browse enchants." + - "Place an item in the top middle slot to filter compatible enchants.\n\nUse the filter buttons to narrow the list." + - "Use /enchantinfo <name> [level] to inspect max level, rarity, targets, conflicts, requirements, and sources." + +hints: + join: + - "&aEcoEnchants 提示 / Tip: &7使用 &f/ecoenchants gui &7浏览附魔,使用 &f/ecoenchants guide &7查看指南。" + hold-enchantable: + actionbar: + - "&a附魔提示 / Tip: &7/ecoenchants gui 浏览可用附魔 · /enchantinfo 查看手持附魔" + chat: + - "&a便捷提示 / Tip: &7手上的物品可以附魔!点此打开附魔浏览器 &f[/ecoenchants gui]&7。" + browser-open: + - "&a便捷提示 / Tip: &7把装备放到顶部中间槽位,会自动只显示可添加的附魔。" + filter-changed: + - "&a筛选已更新 / Filter: &f%filter% &7= &f%value%&7。没有结果时可切换筛选或取出物品查看全部。" + empty-results: + none-loaded: + - "&e提示 / Tip: &7当前没有可浏览附魔。管理员可检查配置并执行 &f/ecoenchants reload&7。" + filter: + - "&e提示 / Tip: &7当前筛选没有结果。继续点击筛选按钮可切换到下一个选项或回到全部。" + item-compatible: + - "&e提示 / Tip: &7这个物品暂时没有可添加附魔。试试换装备、关闭仅兼容,或查看冲突。" + item-and-group: + - "&e提示 / Tip: &7当前分组对这个物品没有可添加附魔。返回分组或取出物品查看全部。" + item-and-filter: + - "&e提示 / Tip: &7当前筛选对这个物品没有结果。切换类型、稀有度或目标筛选试试。" + conflict-view: + no-item: + - "&e提示 / Tip: &7先把物品放到顶部中间槽位,再查看冲突。" + no-enchants: + - "&e提示 / Tip: &7这个物品还没有附魔,因此没有可分析的冲突。" + none: + - "&a冲突查看 / Conflicts: &7当前物品没有阻止其他 EcoEnchants 附魔。" + header: + - "&6冲突查看 / Conflicts: &7当前物品会阻止以下附魔:" + line: + - "&7- &f%enchant%" + +all: "全部 / All" +enabled: "&a开启 / Enabled" +disabled: "&c关闭 / Disabled" +"yes": "&a是 / Yes" +"no": "&c否 / No" +no-conflicts: "无冲突 / No Conflicts" +all-conflicts: "与所有其他附魔冲突 / All Other Enchantments" +no-required: "无前置 / No Requirements" discoverable: - "true": "&aYes" - "false": "&cNo" + "true": "&a是 / Yes" + "false": "&c否 / No" chests: - "true": "&aYes" - "false": "&cNo" + "true": "&a是 / Yes" + "false": "&c否 / No" fishing: - "true": "&aYes" - "false": "&cNo" + "true": "&a是 / Yes" + "false": "&c否 / No" mob-drops: - "true": "&aYes" - "false": "&cNo" + "true": "&a是 / Yes" + "false": "&c否 / No" raids: - "true": "&aYes" - "false": "&cNo" + "true": "&a是 / Yes" + "false": "&c否 / No" diff --git a/eco-core/core-plugin/src/main/resources/plugin.yml b/eco-core/core-plugin/src/main/resources/plugin.yml index 4ffdf5dbf7..f6d854cdb4 100644 --- a/eco-core/core-plugin/src/main/resources/plugin.yml +++ b/eco-core/core-plugin/src/main/resources/plugin.yml @@ -5,6 +5,7 @@ api-version: 1.21.8 authors: [ Auxilor ] website: willfp.com load: STARTUP +folia-supported: true depend: - eco softdepend: @@ -14,13 +15,14 @@ softdepend: commands: ecoenchants: - description: Base Command + description: Main EcoEnchants command and help permission: ecoenchants.command.ecoenchants + aliases: [ ee ] enchantinfo: - description: Show info about an enchant + description: Show detailed information about an enchantment permission: ecoenchants.command.enchantinfo enchant: - description: Enchants an item + description: Add or remove enchantments on a held item permission: ecoenchants.command.enchant permissions: @@ -38,10 +40,15 @@ permissions: ecoenchants.command.reload: true ecoenchants.command.ecoenchants: true ecoenchants.command.toggledescriptions: true + ecoenchants.command.guide: true + ecoenchants.command.experience: true ecoenchants.command.giverandombook: true ecoenchants.command.enchantinfo: true ecoenchants.command.gui: true ecoenchants.command.enchant: true + ecoenchants.command.services: true + ecoenchants.command.search: true + ecoenchants.command.favorites: true ecoenchants.anvil.*: description: All anvil perks default: op @@ -63,6 +70,12 @@ permissions: ecoenchants.command.toggledescriptions: description: Allows the use of /ecoenchants toggledescriptions. default: true + ecoenchants.command.guide: + description: Allows the use of /ecoenchants guide. + default: true + ecoenchants.command.experience: + description: Allows checking player experience hint status and empty-result stats. + default: op ecoenchants.command.enchantinfo: description: Allows the use of /enchantinfo. default: true @@ -71,4 +84,13 @@ permissions: default: true ecoenchants.command.enchant: description: Allows the use of /enchant. - default: op \ No newline at end of file + default: op + ecoenchants.command.services: + description: Allows checking the EcoEnchants online license gate status. + default: op + ecoenchants.command.search: + description: Allows the use of /ecoenchants search. + default: true + ecoenchants.command.favorites: + description: Allows the use of /ecoenchants favorites. + default: true diff --git a/gradle.properties b/gradle.properties index dbd501b1de..6591c48802 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,10 @@ -#libreforge-updater +#libreforge-updater #Tue Jun 23 15:48:31 BST 2026 eco-version=2026.33 kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx2g -XX\:+UseG1GC -XX\:MaxMetaspaceSize\=512m libreforge-version=2026.33 org.gradle.parallel=true +proguard-version=7.9.1 +vineflower-version=1.12.0 version=2026.33 diff --git a/package.json b/package.json new file mode 100644 index 0000000000..7ba8aff10d --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "ecoenchants-docs", + "private": true, + "type": "module", + "scripts": { + "build": "vitepress build documentation", + "docs:dev": "vitepress dev documentation", + "docs:build": "npm run build", + "docs:preview": "vitepress preview documentation" + }, + "devDependencies": { + "vitepress": "^1.6.4" + } +} diff --git a/proguard-rules.pro b/proguard-rules.pro new file mode 100644 index 0000000000..1df6703073 --- /dev/null +++ b/proguard-rules.pro @@ -0,0 +1,56 @@ +# Keep obfuscation conservative: rename implementation code, but do not shrink or optimize +# plugin bytecode that depends on Paper, eco, libreforge, and version-specific NMS bridges. +-dontshrink +-dontoptimize +-dontpreverify +-ignorewarnings +-dontwarn ** +-dontnote net.kyori.ansi.** +-dontnote com.willfp.ecoenchants.ReflectionUtilKt +-dontnote com.willfp.ecoenchants.proxy.** + +-allowaccessmodification +-overloadaggressively +-useuniqueclassmembernames +-adaptclassstrings + +-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions + +# Bukkit loads this class by the literal name in plugin.yml. +-keep class com.willfp.ecoenchants.EcoEnchantsPlugin { *; } + +# eco.yml resolves version-specific proxies from this package by name. +-keep,includedescriptorclasses class com.willfp.ecoenchants.proxy.** { *; } +-keep,includedescriptorclasses interface com.willfp.ecoenchants.**Proxy { *; } + +# The relocated libreforge loader uses its own config/category model. +-keep,includedescriptorclasses class com.willfp.ecoenchants.libreforge.loader.** { *; } + +# Public API used by plugins that compileOnly depend on EcoEnchants. +-keep,includedescriptorclasses class com.willfp.ecoenchants.enchant.EcoEnchants { *; } +-keep,includedescriptorclasses interface com.willfp.ecoenchants.enchant.EcoEnchant { *; } +-keep,includedescriptorclasses interface com.willfp.ecoenchants.enchant.EcoEnchantLike { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.enchant.EcoEnchantLevel { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.enchant.VanillaEnchantmentsKt { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.enchant.VanillaEnchantmentData { *; } +-keep,includedescriptorclasses interface com.willfp.ecoenchants.enchant.EcoCraftEnchantmentManagerProxy { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.display.EnchantmentFormattingKt { public *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.target.EnchantFinder { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.target.EnchantmentTargets { *; } +-keep,includedescriptorclasses interface com.willfp.ecoenchants.target.EnchantmentTarget { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.type.EnchantmentTypes { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.type.EnchantmentType { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.rarity.EnchantmentRarities { *; } +-keep,includedescriptorclasses class com.willfp.ecoenchants.rarity.EnchantmentRarity { *; } + +# Bukkit event discovery is annotation based. +-keepclassmembers class * { + @org.bukkit.event.EventHandler <methods>; +} + +# Kotlin/JVM interop points that can be called reflectively by Java plugins. +-keepclassmembers class * { + @kotlin.jvm.JvmField <fields>; + @kotlin.jvm.JvmStatic <methods>; + @kotlin.jvm.JvmOverloads <methods>; +} diff --git a/roots.sst b/roots.sst new file mode 100644 index 0000000000..f1b8a34b36 Binary files /dev/null and b/roots.sst differ diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000000..1d57fc706f --- /dev/null +++ b/vercel.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vitepress", + "installCommand": "npm install", + "buildCommand": "npm run build", + "outputDirectory": "documentation/.vitepress/dist" +}