Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2011fdd740
|
||
|
|
694a9f4bef
|
||
|
|
d8ef7063f4
|
||
|
|
4f2fb5998a
|
||
|
|
b269cdf8ca
|
||
|
|
4a00c83392
|
@@ -0,0 +1,155 @@
|
||||
---
|
||||
name: "OPSX: Apply"
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/opsx:archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: "OPSX: Archive"
|
||||
description: Archive a completed change in the experimental workflow
|
||||
category: Workflow
|
||||
tags: [workflow, archive, experimental]
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Spec sync status (synced / sync skipped / no delta specs)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success (No Delta Specs)**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success With Warnings**
|
||||
|
||||
```
|
||||
## Archive Complete (with warnings)
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
- Archived with 2 incomplete artifacts
|
||||
- Archived with 3 incomplete tasks
|
||||
- Delta spec sync was skipped (user chose to skip)
|
||||
|
||||
Review the archive if this was not intentional.
|
||||
```
|
||||
|
||||
**Output On Error (Archive Exists)**
|
||||
|
||||
```
|
||||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
**Options:**
|
||||
1. Rename the existing archive
|
||||
2. Delete the existing archive if it's a duplicate
|
||||
3. Wait until a different date to archive
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
name: "OPSX: Explore"
|
||||
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
|
||||
category: Workflow
|
||||
tags: [workflow, explore, experimental, thinking]
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
- A comparison: "postgres vs sqlite for this"
|
||||
- Nothing (just enter explore mode)
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
If the user mentioned a specific change name, read its artifacts for context.
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: "OPSX: Propose"
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: "OPSX: Sync"
|
||||
description: Sync delta specs from a change to main specs
|
||||
category: Workflow
|
||||
tags: [workflow, specs, experimental]
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
name: openspec-sync-specs
|
||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
+24
-14
@@ -22,6 +22,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
"git.vakhrushev.me/av/jellybit/internal/naming"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -62,7 +63,27 @@ func runServe(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ingestor := ingest.New(st, qb, ingest.Config{
|
||||
// LLM-провайдер (опц.) — общий для вывода имени и распознавания.
|
||||
var llmProvider llm.Provider
|
||||
if cfg.LLM.Type != "" && cfg.LLM.BaseURL != "" {
|
||||
llmProvider, err = llm.New(llm.Config{
|
||||
Type: cfg.LLM.Type,
|
||||
BaseURL: cfg.LLM.BaseURL,
|
||||
APIKey: cfg.LLM.APIKey,
|
||||
Model: cfg.LLM.Model,
|
||||
Proxy: cfg.LLM.Proxy,
|
||||
Timeout: cfg.LLM.Timeout.Std(),
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("llm provider: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Вывод отображаемого имени торрента из контекста (best-effort). Без LLM
|
||||
// работает только алгоритмический фолбек.
|
||||
namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger)
|
||||
|
||||
ingestor := ingest.New(st, qb, namer, ingest.Config{
|
||||
Category: cfg.QBittorrent.Category,
|
||||
SavePath: cfg.QBittorrent.SavePath,
|
||||
}, logger)
|
||||
@@ -79,19 +100,8 @@ func runServe(args []string) error {
|
||||
// Ф2/Ф3: распознаватель и раскладчик. Если LLM не сконфигурирован,
|
||||
// сервис работает как в Ф1 (completed-задачи дальше не двигаются).
|
||||
var recognizer worker.Recognizer
|
||||
if cfg.LLM.Type != "" && cfg.LLM.BaseURL != "" {
|
||||
provider, perr := llm.New(llm.Config{
|
||||
Type: cfg.LLM.Type,
|
||||
BaseURL: cfg.LLM.BaseURL,
|
||||
APIKey: cfg.LLM.APIKey,
|
||||
Model: cfg.LLM.Model,
|
||||
Proxy: cfg.LLM.Proxy,
|
||||
Timeout: cfg.LLM.Timeout.Std(),
|
||||
}, logger)
|
||||
if perr != nil {
|
||||
return fmt.Errorf("llm provider: %w", perr)
|
||||
}
|
||||
recognizer = recognize.New(provider, providers, recognize.Config{
|
||||
if llmProvider != nil {
|
||||
recognizer = recognize.New(llmProvider, providers, recognize.Config{
|
||||
MaxRetries: cfg.LLM.MaxRetries,
|
||||
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
||||
}, logger)
|
||||
|
||||
+172
-10
@@ -25,16 +25,6 @@
|
||||
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
|
||||
сериала с провайдер-id).
|
||||
|
||||
### Название из контекста при добавлении в qBittorrent
|
||||
|
||||
При создании magnet-загрузки передавать в qBittorrent человекочитаемое имя
|
||||
из контекста (если оно есть), чтобы в списке qBit не было безликих
|
||||
`rutracker-topic-6852853`. Небольшая задача с заметной отдачей в
|
||||
повседневной эксплуатации.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Транспорты», пакет
|
||||
`ingest`/`qbt`.
|
||||
|
||||
### Рассинхрон состояния с реальностью (удалённый торрент / файлы)
|
||||
|
||||
Состояние jellybit может разойтись с тем, что реально лежит на диске.
|
||||
@@ -68,6 +58,57 @@
|
||||
[architecture.md](specs/architecture.md) → «Раскладка файлов» (undo,
|
||||
инвариант источника), [workflow.md](specs/workflow.md) (`done → reverted`).
|
||||
|
||||
### Наблюдаемость: метрики и учёт стоимости LLM
|
||||
|
||||
Сейчас единственное окно в систему — `slog`. Нет быстрых ответов на
|
||||
вопросы «сколько задач висит в review», «сколько токенов и денег съело
|
||||
распознавание», «какова медиана времени ingest → done». Нужны метрики:
|
||||
эндпоинт `/metrics` (Prometheus-формат) со счётчиками загрузок по
|
||||
состояниям, длительностями стадий и расходом LLM (токены/стоимость на
|
||||
задачу).
|
||||
|
||||
**Расход LLM уже снимается с провода** — `llm.openai` парсит `usage`
|
||||
(prompt/completion/total tokens и `cost`, который отдаёт шлюз) в
|
||||
`llm.Response.Usage` и пишет в лог. Не хватает только **персистентности и
|
||||
отображения**: сохранять `usage` у попытки распознавания (`recognition`) и
|
||||
показывать в карточке загрузки + агрегатом в `/metrics`. Где провайдер не
|
||||
шлёт `cost` — считать из токенов по таблице «модель → цена» в конфиге.
|
||||
|
||||
Отдельный LLM-прокси (LiteLLM и т.п.) для этого **не нужен** и противоречит
|
||||
принципам «один бинарь» / «минимум компонентов»: подсчёт токенов уже в коде,
|
||||
а роль мульти-модельного шлюза играет используемый OpenAI-совместимый
|
||||
эндпоинт (он и возвращает `cost`); разные модели подключаются сменой
|
||||
`[llm].model` или новым типом провайдера за интерфейсом `llm.Provider`.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Логирование»,
|
||||
[recognition.md](specs/recognition.md) (провайдер LLM, `[llm].type`),
|
||||
пакеты `worker`, `llm`, `httpapi`.
|
||||
|
||||
### Ретеншн и очистка БД
|
||||
|
||||
Терминальные задачи (`done`/`cancelled`/`failed`/`reverted`), их попытки
|
||||
`recognition` с сырыми ответами LLM и `metadata_candidate` копятся вечно —
|
||||
со временем БД и список загрузок распухают и становятся нечитаемыми. Нужна
|
||||
авточистка старше N дней (с настройкой в `[storage]` или `[worker]`) и/или
|
||||
ручное удаление. Маленькая задача, но без неё интерфейс деградирует по мере
|
||||
эксплуатации.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Хранилище» (таблицы
|
||||
`download`/`recognition`/`metadata_candidate`/`file_link`), пакет `store`.
|
||||
|
||||
### Eval-харнес распознавания (корпус кейсов + метрика точности)
|
||||
|
||||
Распознавание — ядро продукта, но смена модели или правка промпта сейчас
|
||||
вслепую: регрессий не видно. Нужен корпус размеченных кейсов (русские
|
||||
релизы, аниме, сезон-паки, репаки, спецвыпуски) и прогон распознавания по
|
||||
нему с метрикой точности (тип/название/год/нумерация). Тогда можно
|
||||
сравнивать LLM-провайдеры и версии промпта по числам, а не на ощупь.
|
||||
Прогон — отдельной командой (`jellybit eval` или тестом), на фикстурах, без
|
||||
реального qBittorrent.
|
||||
|
||||
Связано: [recognition.md](specs/recognition.md) (конвейер, модель
|
||||
уверенности), пакет `recognize`.
|
||||
|
||||
## Средний
|
||||
|
||||
### Машина состояний на go-библиотеке
|
||||
@@ -94,6 +135,33 @@
|
||||
веб = точные правки), [architecture.md](specs/architecture.md) →
|
||||
«Транспорты».
|
||||
|
||||
### Раздачи с докачиванием (слияние при повторном добавлении)
|
||||
|
||||
Свежий сериал часто раздают по мере выхода: торрент содержит 5 эпизодов из
|
||||
10. Позже его перезаливают целиком (или добавляют недостающие серии), и
|
||||
пользователь повторно добавляет тот же торрент. Нужно распознать, что это
|
||||
**та же** раздача/сезон, и повторить раскладку с **слиянием**: доложить
|
||||
недостающие хардлинки, не дублируя уже разложенное и не перезаписывая
|
||||
существующее (инвариант «существующее не трогаем»). Перекликается с
|
||||
«Проблемой второго сезона», но здесь доливаются эпизоды внутри одного
|
||||
сезона, а не новый сезон. Нужно продумать: как опознать повторное
|
||||
добавление (хеш торрента / провайдер-id + сезон), как сверять состав файлов
|
||||
и доливать только новые.
|
||||
|
||||
Связано: [«Проблема второго сезона»](#проблема-второго-сезона),
|
||||
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка, идемпотентность),
|
||||
[workflow.md](specs/workflow.md) (повторный прогон загрузки).
|
||||
|
||||
### Улучшения UI клиентов: показывать матч с записью метабазы
|
||||
|
||||
Во всех транспортах (веб, Telegram) показывать, **с какой именно записью**
|
||||
метабазы (TMDB/TVDB) сматчилась загрузка: название, год, провайдер-id,
|
||||
ссылку. Сейчас результат распознавания непрозрачен — пользователь не видит,
|
||||
к чему привязались, и не может быстро поймать ошибочный матч.
|
||||
|
||||
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
|
||||
(матч в базе), [architecture.md](specs/architecture.md) → «Транспорты».
|
||||
|
||||
### Добавление торрентов файлом/ссылкой — «единое окно»
|
||||
|
||||
Поддержать источники помимо magnet: `.torrent`-файл и URL (отдаём их в
|
||||
@@ -106,6 +174,61 @@ qBittorrent, без исходящих запросов на пользоват
|
||||
(`source_type = magnet|torrent|url` уже в схеме), пакет `ingest` (сейчас
|
||||
поддержан только magnet).
|
||||
|
||||
### Бэкап SQLite
|
||||
|
||||
`architecture.md` требует «бекапить data-том», но *как* — не описано. Без
|
||||
понятной стратегии сбой или редеплой стирают всё in-flight состояние.
|
||||
Зафиксировать решение и реализовать: периодический `VACUUM INTO` в
|
||||
`/data/backups` по расписанию (с ротацией) либо потоковая репликация
|
||||
(litestream). Лучше сделать, пока БД маленькая.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Деплой» (data-том,
|
||||
«бекапить-и-не-терять»), пакет `store`.
|
||||
|
||||
### Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
|
||||
|
||||
Фильм уже разложен, позже добавили раздачу лучшего качества — сейчас это
|
||||
просто новая задача, упирающаяся в «коллизию цели → review», без понятия
|
||||
«это та же вещь, заменить версию». Нужно осознанно обработать апгрейд
|
||||
качества: распознать тот же тайтл, предложить замену существующей раскладки
|
||||
либо сосуществование версий (Jellyfin поддерживает несколько версий одного
|
||||
фильма). Близко к «докачиванию», но про качество, а не про эпизоды.
|
||||
|
||||
Связано: [«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
|
||||
[jellyfin-layout.md](specs/jellyfin-layout.md) (never-overwrite, коллизия),
|
||||
[architecture.md](specs/architecture.md) → «Идентификация торрента»
|
||||
(репаки = разные infohash → разные задачи).
|
||||
|
||||
### Глубокий healthcheck и статус зависимостей
|
||||
|
||||
`/healthz` проверяет только сам сервис. Если qBittorrent, LLM или метабаза
|
||||
недоступны — узнаёшь лишь по застрявшим задачам. Нужна readiness-проверка
|
||||
ключевых зависимостей и отражение их состояния в UI (бейдж «qBittorrent
|
||||
недоступен»), чтобы причина простоя была видна сразу.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Деплой» (healthcheck),
|
||||
пакеты `qbt`, `llm`, `metadata`, `httpapi`.
|
||||
|
||||
### Обучение на правках человека (few-shot из прошлых ревью)
|
||||
|
||||
Когда человек поправил матч, тип или нумерацию — сохранять это как пример и
|
||||
подмешивать похожие в будущие промпты. Системно повышает точность на «твоих»
|
||||
трекерах и форматах имён без смены модели. Развитие идеи многоступенчатой
|
||||
верификации, но дешевле: учимся на уже собранных `hint`/`override`.
|
||||
|
||||
Связано: [recognition.md](specs/recognition.md) (конвейер, промпт),
|
||||
[«Многоступенчатая верификация»](#многоступенчатая-верификация-привязки-тема-для-размышления),
|
||||
[architecture.md](specs/architecture.md) → «Хранилище» (`hint`, `override`).
|
||||
|
||||
### Список загрузок: фильтр, поиск, пагинация
|
||||
|
||||
Прямое следствие роста БД (см. «Ретеншн»): плоский список загрузок со
|
||||
временем становится непригоден. Нужны фильтр по состоянию, поиск по
|
||||
названию и пагинация. Естественно ложится на экран расширенной информации.
|
||||
|
||||
Связано: [«Расширенная информация о загрузке в web-UI»](#расширенная-информация-о-загрузке-в-web-ui),
|
||||
пакет `httpapi`.
|
||||
|
||||
## Низкий
|
||||
|
||||
### Многоступенчатая верификация привязки (тема для размышления)
|
||||
@@ -119,6 +242,45 @@ qBittorrent, без исходящих запросов на пользоват
|
||||
Связано: [recognition.md](specs/recognition.md) (конвейер и модель
|
||||
уверенности).
|
||||
|
||||
### Расширенная информация о загрузке в web-UI
|
||||
|
||||
Экран просмотра деталей одной загрузки: исходный контекст и magnet, лог
|
||||
переходов состояний, распознанные данные и матч в метабазе (см. «показывать
|
||||
матч»), целевые пути и созданные хардлинки. Помогает разбираться, когда
|
||||
что-то пошло не так, без чтения логов сервера.
|
||||
|
||||
Связано: [review-ux.md](specs/review-ux.md), пакет `httpapi`.
|
||||
|
||||
### Выбор из нескольких находок метабазы в Telegram
|
||||
|
||||
Когда распознавание даёт несколько подходящих кандидатов в метабазе,
|
||||
предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча
|
||||
брать первый/лучший. Веб остаётся точкой точных правок, бот — быстрый выбор
|
||||
из готового короткого списка.
|
||||
|
||||
Связано: [review-ux.md](specs/review-ux.md) (боты — быстрые действия, веб —
|
||||
точные правки), [recognition.md](specs/recognition.md) (кандидаты матча).
|
||||
|
||||
### Проверка свободного места перед copy-fallback
|
||||
|
||||
Когда хардлинк невозможен (`EXDEV`/`ENOTSUP`/…), `layout` копирует файл,
|
||||
дублируя место на диске. На забитом диске это упрётся в полку посреди
|
||||
раскладки. Перед копированием проверять доступное место и при нехватке
|
||||
внятно уходить в `failed` с понятной причиной, а не падать на полпути.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Раскладка файлов»
|
||||
(фолбэк-копирование), пакет `layout`.
|
||||
|
||||
### Кэш метабаз (и опционально LLM)
|
||||
|
||||
Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и
|
||||
тем же запросом. Кэш ответов с TTL экономит лимиты API и ускоряет «Распознать
|
||||
заново»/«Уточнить». При желании — кэш ответов LLM по хешу входа (но он менее
|
||||
полезен, т.к. вход меняется подсказками).
|
||||
|
||||
Связано: [recognition.md](specs/recognition.md) (сверка с базой), пакеты
|
||||
`metadata`, `llm`.
|
||||
|
||||
### Современный Web-UI как PWA
|
||||
|
||||
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое,
|
||||
|
||||
@@ -26,6 +26,12 @@ type QBittorrent interface {
|
||||
Add(ctx context.Context, ar qbt.AddRequest) error
|
||||
}
|
||||
|
||||
// Namer выводит человекочитаемое отображаемое имя торрента из контекста.
|
||||
// Пустой результат → имя в qBittorrent не задаём. nil → шаг пропускается.
|
||||
type Namer interface {
|
||||
DeriveName(ctx context.Context, contextText, hint string) string
|
||||
}
|
||||
|
||||
// Config — параметры добавления в qBittorrent.
|
||||
type Config struct {
|
||||
Category string
|
||||
@@ -36,13 +42,15 @@ type Config struct {
|
||||
type Service struct {
|
||||
store Store
|
||||
qbt QBittorrent
|
||||
namer Namer
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает сервис приёма.
|
||||
func New(st Store, qb QBittorrent, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: st, qbt: qb, cfg: cfg, log: log}
|
||||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
||||
// выводится; qBittorrent оставит своё).
|
||||
func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
// Request — входной запрос приёма.
|
||||
@@ -82,6 +90,15 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
||||
// Выводится синхронно (param rename действует только при добавлении) и
|
||||
// ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно
|
||||
// «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит.
|
||||
var rename string
|
||||
if s.namer != nil {
|
||||
rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName)
|
||||
}
|
||||
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
@@ -99,6 +116,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
URLs: []string{source},
|
||||
Category: s.cfg.Category,
|
||||
SavePath: s.cfg.SavePath,
|
||||
Rename: rename,
|
||||
})
|
||||
if addErr != nil {
|
||||
s.log.Warn("ingest: qbittorrent add failed, marking download failed",
|
||||
|
||||
@@ -58,8 +58,27 @@ func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeNamer возвращает заранее заданное имя; фиксирует переданные аргументы.
|
||||
type fakeNamer struct {
|
||||
name string
|
||||
gotContext string
|
||||
gotHint string
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeNamer) DeriveName(_ context.Context, contextText, hint string) string {
|
||||
f.called = true
|
||||
f.gotContext = contextText
|
||||
f.gotHint = hint
|
||||
return f.name
|
||||
}
|
||||
|
||||
func newService(st Store, qb QBittorrent) *Service {
|
||||
return New(st, qb, Config{Category: "jellybit", SavePath: "/srv/media/downloads"},
|
||||
return newServiceWithNamer(st, qb, nil)
|
||||
}
|
||||
|
||||
func newServiceWithNamer(st Store, qb QBittorrent, nm Namer) *Service {
|
||||
return New(st, qb, nm, Config{Category: "jellybit", SavePath: "/srv/media/downloads"},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
@@ -94,6 +113,36 @@ func TestIngestHappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestSetsDisplayName(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
|
||||
_, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" {
|
||||
t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called)
|
||||
}
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: ""} // имя не получено
|
||||
if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "" {
|
||||
t.Errorf("rename = %q, want пусто", fq.added[0].Rename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestIdempotent(t *testing.T) {
|
||||
existing := &store.Download{ID: 7, State: store.StateDownloading}
|
||||
fs := &fakeStore{active: existing}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parenURL — markdown-хвост " (https://…)" в строках контекста.
|
||||
var parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`)
|
||||
|
||||
// parenSpecs — открывающая скобка тех. характеристик «(2024, …»: круглая
|
||||
// скобка, за которой (через необяз. пробелы) идёт цифра.
|
||||
var parenSpecs = regexp.MustCompile(`\(\s*\d`)
|
||||
|
||||
// fallbackName выводит имя без сети: берёт первую содержательную строку
|
||||
// контекста, отсекает технические характеристики и обрезает по длине. Если
|
||||
// контекст пуст/бесполезен — пробует hint (dn из magnet). Возвращает "",
|
||||
// если ничего пригодного нет.
|
||||
func fallbackName(contextText, hint string) string {
|
||||
if line := firstMeaningfulLine(contextText); line != "" {
|
||||
return truncate(stripTechSpecs(line), maxNameLen)
|
||||
}
|
||||
if h := sanitize(hint); h != "" {
|
||||
return truncate(stripTechSpecs(h), maxNameLen)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstMeaningfulLine возвращает первую строку контекста, не являющуюся
|
||||
// ссылкой, командой бота или UI-мусором. Контекст из tgbot уже вычищен, но
|
||||
// для HTTP/CLI вход может быть сырым — отсюда лёгкая фильтрация.
|
||||
func firstMeaningfulLine(text string) string {
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
line = strings.TrimSpace(parenURL.ReplaceAllString(line, ""))
|
||||
line = sanitize(line)
|
||||
if line == "" || isNoiseLine(line) {
|
||||
continue
|
||||
}
|
||||
return line
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNoiseLine отсекает строки-ссылки и команды бота (см. tgbot для исходной
|
||||
// эвристики; здесь — минимум, нужный фолбеку для сырого ввода). Проверяем
|
||||
// префиксы, а не вхождения: голый URL внутри осмысленной строки не должен
|
||||
// выкидывать всю строку с названием.
|
||||
func isNoiseLine(line string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "/"):
|
||||
return true // команда бота
|
||||
case strings.HasPrefix(line, "magnet:"),
|
||||
strings.HasPrefix(line, "http://"),
|
||||
strings.HasPrefix(line, "https://"):
|
||||
return true // строка-ссылка
|
||||
case strings.Contains(line, ": /"):
|
||||
return true // строка рейтинга/команд бота «👍: /g_… или 👎: /r_…»
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// stripTechSpecs отсекает хвост технических характеристик в скобках —
|
||||
// квадратных ("… [2024, WEB-DL 2160p …]") или круглых с годом
|
||||
// ("… (2024, WEB-DL …)") — и подчищает разделители на конце. Скобка с
|
||||
// текстом (например режиссёр «(Дени Вильнёв)») не режется.
|
||||
func stripTechSpecs(s string) string {
|
||||
cut := len(s)
|
||||
if i := strings.IndexByte(s, '['); i >= 0 && i < cut {
|
||||
cut = i
|
||||
}
|
||||
if loc := parenSpecs.FindStringIndex(s); loc != nil && loc[0] < cut {
|
||||
cut = loc[0]
|
||||
}
|
||||
s = strings.TrimRight(s[:cut], " -–—/.,")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFallbackName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
context string
|
||||
hint string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "release title with tech specs",
|
||||
context: "Дюна: Часть вторая / Dune: Part Two [2024, фантастика, WEB-DL 2160p]\nDub + MVO",
|
||||
want: "Дюна: Часть вторая / Dune: Part Two",
|
||||
},
|
||||
{
|
||||
name: "skips url and command lines",
|
||||
context: "magnet:?xt=urn:btih:abc\nhttps://example.com/x\n/help\nБрат [1997]",
|
||||
want: "Брат",
|
||||
},
|
||||
{
|
||||
name: "empty context falls back to magnet dn hint",
|
||||
context: "",
|
||||
hint: "rutracker-topic-6514485",
|
||||
want: "rutracker-topic-6514485",
|
||||
},
|
||||
{
|
||||
name: "useless context and no hint yields empty",
|
||||
context: "https://example.com/x\n/help",
|
||||
hint: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "strips markdown url tail",
|
||||
context: "Сёгун (https://hashurl.ru/abc)",
|
||||
want: "Сёгун",
|
||||
},
|
||||
{
|
||||
name: "strips tech specs in round brackets with year",
|
||||
context: "Дюна (2024, фантастика, WEB-DL 2160p)",
|
||||
want: "Дюна",
|
||||
},
|
||||
{
|
||||
name: "keeps non-spec parenthesis (director)",
|
||||
context: "Брат (Алексей Балабанов) [1997, криминал]",
|
||||
want: "Брат (Алексей Балабанов)",
|
||||
},
|
||||
{
|
||||
name: "inline url does not drop the title line",
|
||||
context: "Дюна 2024 подробнее http://tracker/x",
|
||||
want: "Дюна 2024 подробнее http://tracker/x",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := fallbackName(tc.context, tc.hint); got != tc.want {
|
||||
t.Errorf("fallbackName() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package naming выводит человекочитаемое отображаемое имя торрента из
|
||||
// текстового контекста загрузки. Имя нужно лишь как ярлык в списке
|
||||
// qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не
|
||||
// влияет на пути на диске или распознавание.
|
||||
//
|
||||
// Стратегия: сначала пробуем LLM (структурированный вывод названия/года/
|
||||
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
|
||||
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
|
||||
// вывода имени.
|
||||
package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
|
||||
const maxNameLen = 200
|
||||
|
||||
// mediaType — вид контента в извлечённой структуре.
|
||||
type mediaType string
|
||||
|
||||
const (
|
||||
typeMovie mediaType = "movie"
|
||||
typeSeries mediaType = "series"
|
||||
)
|
||||
|
||||
// extracted — структура имени, извлечённая из контекста (схема ответа LLM).
|
||||
// Year и Director опциональны (пустое значение → в ярлык не попадают).
|
||||
type extracted struct {
|
||||
Type mediaType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Year int `json:"year"`
|
||||
Director string `json:"director"`
|
||||
Season *int `json:"season"`
|
||||
IsRussian bool `json:"is_russian"`
|
||||
}
|
||||
|
||||
// Namer выводит отображаемое имя. provider может быть nil — тогда работает
|
||||
// только алгоритмический фолбек.
|
||||
type Namer struct {
|
||||
provider llm.Provider
|
||||
// attempts — число попыток получить валидный ответ LLM ([llm].max_retries).
|
||||
// Здесь это ровно столько вызовов модели (в отличие от recognize, где
|
||||
// max_retries — это число ПЕРЕразборов, т.е. max_retries+1 вызовов).
|
||||
attempts int
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает Namer. provider nil → только фолбек. attempts < 1 → 1.
|
||||
// logger nil → slog.Default().
|
||||
func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Namer{provider: provider, attempts: attempts, log: logger}
|
||||
}
|
||||
|
||||
// DeriveName выводит отображаемое имя из контекста. hint — подсказка из
|
||||
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
|
||||
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
|
||||
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
|
||||
if n.provider != nil {
|
||||
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
|
||||
if name := render(ex); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallbackName(contextText, hint)
|
||||
}
|
||||
|
||||
// render собирает из структуры короткий ярлык:
|
||||
// - movie: "Title (Director, Year)" — режиссёр и год опциональны;
|
||||
// - series: то же + ". Сезон N", если сезон задан.
|
||||
//
|
||||
// Имя очищается от управляющих символов/переводов строк и обрезается по
|
||||
// длине. Пустой Title → пустая строка.
|
||||
func render(ex extracted) string {
|
||||
title := sanitize(ex.Title)
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var paren []string
|
||||
if d := sanitize(ex.Director); d != "" {
|
||||
paren = append(paren, d)
|
||||
}
|
||||
if ex.Year > 0 {
|
||||
paren = append(paren, strconv.Itoa(ex.Year))
|
||||
}
|
||||
|
||||
name := title
|
||||
if len(paren) > 0 {
|
||||
name += " (" + strings.Join(paren, ", ") + ")"
|
||||
}
|
||||
if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 {
|
||||
name += ". Сезон " + strconv.Itoa(*ex.Season)
|
||||
}
|
||||
return truncate(name, maxNameLen)
|
||||
}
|
||||
|
||||
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
|
||||
func sanitize(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' || r == '\r' {
|
||||
return ' '
|
||||
}
|
||||
if r < 0x20 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// truncate обрезает строку до n рун (без разрыва символа), отбрасывая хвост.
|
||||
func truncate(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == n {
|
||||
return strings.TrimRight(s[:i], " ")
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// fakeProvider отдаёт заранее заданные ответы по очереди; считает вызовы.
|
||||
type fakeProvider struct {
|
||||
responses []string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Complete(_ context.Context, _ llm.Request) (llm.Response, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return llm.Response{}, f.err
|
||||
}
|
||||
idx := f.calls - 1
|
||||
if idx >= len(f.responses) {
|
||||
idx = len(f.responses) - 1
|
||||
}
|
||||
return llm.Response{Content: f.responses[idx]}, nil
|
||||
}
|
||||
|
||||
const duneContext = "Дюна: Часть вторая / Dune: Part Two [2024, фантастика, WEB-DL 2160p]\nDub + MVO"
|
||||
|
||||
func TestDeriveNameViaLLM(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{
|
||||
`{"type":"movie","title":"Дюна: Часть вторая","year":2024,"director":"Дени Вильнёв","is_russian":false}`,
|
||||
}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
want := "Дюна: Часть вторая (Дени Вильнёв, 2024)"
|
||||
if got != want {
|
||||
t.Errorf("DeriveName() = %q, want %q", got, want)
|
||||
}
|
||||
if fp.calls != 1 {
|
||||
t.Errorf("вызовов LLM = %d, want 1", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameRussianTitle(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{
|
||||
`{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`,
|
||||
}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), "Брат / Brat [1997]", "")
|
||||
if got != "Брат (1997)" {
|
||||
t.Errorf("DeriveName() = %q, want %q", got, "Брат (1997)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameRetriesThenSucceeds(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{
|
||||
"не json вовсе",
|
||||
`{"type":"movie","title":"Дюна: Часть вторая","year":2024}`,
|
||||
}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
if got != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("DeriveName() = %q", got)
|
||||
}
|
||||
if fp.calls != 2 {
|
||||
t.Errorf("вызовов LLM = %d, want 2", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameExhaustsThenFallback(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{"мусор"}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
// Бюджет исчерпан → алгоритмический фолбек: первая строка без тех. спецификаций.
|
||||
if got != "Дюна: Часть вторая / Dune: Part Two" {
|
||||
t.Errorf("DeriveName() = %q (ожидался фолбек)", got)
|
||||
}
|
||||
if fp.calls != 3 {
|
||||
t.Errorf("вызовов LLM = %d, want 3 (исчерпание бюджета)", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameProviderErrorFallsBack(t *testing.T) {
|
||||
fp := &fakeProvider{err: errors.New("connection refused")}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
if got != "Дюна: Часть вторая / Dune: Part Two" {
|
||||
t.Errorf("DeriveName() = %q (ожидался фолбек при ошибке)", got)
|
||||
}
|
||||
if fp.calls != 1 {
|
||||
t.Errorf("вызовов LLM = %d, want 1 (на транспортной ошибке не повторяем)", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameNilProviderUsesFallback(t *testing.T) {
|
||||
got := New(nil, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
if got != "Дюна: Часть вторая / Dune: Part Two" {
|
||||
t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
// systemPrompt инструктирует модель вытащить из вольного контекста короткое
|
||||
// имя. Язык названия выбирается по происхождению контента.
|
||||
const systemPrompt = `Ты извлекаешь из текста о торрент-раздаче данные для короткого названия.
|
||||
Верни СТРОГО один JSON-объект, без markdown-ограждений и пояснений.
|
||||
|
||||
Схема:
|
||||
{
|
||||
"type": "movie" | "series",
|
||||
"title": "название на нужном языке",
|
||||
"original_title": "оригинальное название или пустая строка",
|
||||
"year": число или 0,
|
||||
"director": "режиссёр или пустая строка",
|
||||
"season": число или null,
|
||||
"is_russian": true | false
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Определи, фильм это или сериал ("type").
|
||||
- "is_russian" = true, если это российский/советский фильм или сериал.
|
||||
- "title": для российского контента — русское название; иначе — английское
|
||||
(оригинальное). Без года, страны и технических характеристик.
|
||||
- "year" — год выпуска (0, если не ясен). "director" — режиссёр (пустая
|
||||
строка, если не указан). "season" — номер сезона для сериала (null для
|
||||
фильма или если сезон не указан).
|
||||
- Если данных не хватает, заполняй что можешь; "title" должен быть непустым.`
|
||||
|
||||
// extractViaLLM делает до n.attempts попыток получить валидную структуру.
|
||||
// Транспортные ошибки провайдера (сеть/429/5xx) гасятся внутри llm.Provider;
|
||||
// здесь повторяем только переразбор невалидного ответа. ok=false, если за
|
||||
// все попытки валидного результата не получено.
|
||||
func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (extracted, bool) {
|
||||
contextText = strings.TrimSpace(contextText)
|
||||
if contextText == "" {
|
||||
return extracted{}, false
|
||||
}
|
||||
|
||||
user := "Контекст раздачи:\n" + contextText
|
||||
if hint = strings.TrimSpace(hint); hint != "" {
|
||||
user += "\n\nИмя из magnet (подсказка, может быть мусором): " + hint
|
||||
}
|
||||
|
||||
temp := 0.0
|
||||
msgs := []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: systemPrompt},
|
||||
{Role: llm.RoleUser, Content: user},
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= n.attempts; attempt++ {
|
||||
resp, err := n.provider.Complete(ctx, llm.Request{
|
||||
Messages: msgs,
|
||||
JSONMode: true,
|
||||
Temperature: &temp,
|
||||
})
|
||||
if err != nil {
|
||||
// Транспортная ошибка/таймаут: дальше пробовать смысла нет —
|
||||
// уходим в фолбек, приём не валим.
|
||||
n.log.Warn("naming: llm complete failed, will fall back", "err", err)
|
||||
return extracted{}, false
|
||||
}
|
||||
|
||||
ex, perr := parseExtracted(resp.Content)
|
||||
if perr == nil {
|
||||
return ex, true
|
||||
}
|
||||
n.log.Warn("naming: unparsed llm response", "attempt", attempt, "err", perr)
|
||||
msgs = append(msgs,
|
||||
llm.Message{Role: llm.RoleAssistant, Content: resp.Content},
|
||||
llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() +
|
||||
". Верни строго один JSON-объект по схеме, без пояснений."})
|
||||
}
|
||||
return extracted{}, false
|
||||
}
|
||||
|
||||
// parseExtracted вытаскивает JSON-объект из ответа и валидирует минимум:
|
||||
// непустой title. Тип по умолчанию — movie.
|
||||
func parseExtracted(raw string) (extracted, error) {
|
||||
jsonStr, err := llm.ExtractJSONObject(raw)
|
||||
if err != nil {
|
||||
return extracted{}, err
|
||||
}
|
||||
var ex extracted
|
||||
if err := json.Unmarshal([]byte(jsonStr), &ex); err != nil {
|
||||
return extracted{}, fmt.Errorf("naming: unmarshal: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(ex.Title) == "" {
|
||||
return extracted{}, fmt.Errorf("naming: empty title")
|
||||
}
|
||||
if ex.Type != typeMovie && ex.Type != typeSeries {
|
||||
ex.Type = typeMovie
|
||||
}
|
||||
return ex, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ptr(i int) *int { return &i }
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in extracted
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "movie with director and year",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
|
||||
want: "Дюна: Часть вторая (Дени Вильнёв, 2024)",
|
||||
},
|
||||
{
|
||||
name: "movie with year only",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024},
|
||||
want: "Дюна: Часть вторая (2024)",
|
||||
},
|
||||
{
|
||||
name: "movie with director only",
|
||||
in: extracted{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"},
|
||||
want: "Брат (Алексей Балабанов)",
|
||||
},
|
||||
{
|
||||
name: "movie without director and year",
|
||||
in: extracted{Type: typeMovie, Title: "Брат"},
|
||||
want: "Брат",
|
||||
},
|
||||
{
|
||||
name: "series with season",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)},
|
||||
want: "Сёгун (2024). Сезон 2",
|
||||
},
|
||||
{
|
||||
name: "series without season",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024},
|
||||
want: "Сёгун (2024)",
|
||||
},
|
||||
{
|
||||
name: "series season zero is omitted",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Season: ptr(0)},
|
||||
want: "Сёгун",
|
||||
},
|
||||
{
|
||||
name: "empty title yields empty name",
|
||||
in: extracted{Type: typeMovie, Title: " ", Year: 2024},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "control chars and newlines are sanitized",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024},
|
||||
want: "Дюна Часть вторая (2024)",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := render(tc.in); got != tc.want {
|
||||
t.Errorf("render() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTruncates(t *testing.T) {
|
||||
long := strings.Repeat("я", maxNameLen+50)
|
||||
got := render(extracted{Type: typeMovie, Title: long})
|
||||
if n := len([]rune(got)); n > maxNameLen {
|
||||
t.Errorf("длина имени = %d рун, want <= %d", n, maxNameLen)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ type AddRequest struct {
|
||||
Torrents [][]byte // .torrent-файлы (Ф1 не использует)
|
||||
Category string
|
||||
SavePath string
|
||||
Rename string // отображаемое имя торрента (param rename); пустое — не задаём
|
||||
Paused bool
|
||||
}
|
||||
|
||||
@@ -173,6 +174,9 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
||||
if ar.SavePath != "" {
|
||||
_ = mw.WriteField("savepath", ar.SavePath)
|
||||
}
|
||||
if ar.Rename != "" {
|
||||
_ = mw.WriteField("rename", ar.Rename)
|
||||
}
|
||||
_ = mw.WriteField("paused", strconv.FormatBool(ar.Paused))
|
||||
for i, data := range ar.Torrents {
|
||||
fw, err := mw.CreateFormFile("torrents", fmt.Sprintf("file%d.torrent", i))
|
||||
|
||||
@@ -69,6 +69,51 @@ func newClient(t *testing.T, url string) *Client {
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAddSendsRename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rename string
|
||||
wantField string
|
||||
wantHasKey bool
|
||||
}{
|
||||
{name: "with rename", rename: "Дюна: Часть вторая (2024)", wantField: "Дюна: Часть вторая (2024)", wantHasKey: true},
|
||||
{name: "empty rename omits field", rename: "", wantHasKey: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotField string
|
||||
var hasKey bool
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, hasKey = r.MultipartForm.Value["rename"]
|
||||
gotField = r.FormValue("rename")
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := newClient(t, srv.URL)
|
||||
err := c.Add(context.Background(), AddRequest{
|
||||
URLs: []string{"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6"},
|
||||
Rename: tc.rename,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if hasKey != tc.wantHasKey {
|
||||
t.Errorf("наличие поля rename = %v, want %v", hasKey, tc.wantHasKey)
|
||||
}
|
||||
if gotField != tc.wantField {
|
||||
t.Errorf("rename = %q, want %q", gotField, tc.wantField)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPerformsLazyLogin(t *testing.T) {
|
||||
srv := fakeQBittorrent(t, "[]")
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-28
|
||||
@@ -0,0 +1,159 @@
|
||||
## Context
|
||||
|
||||
`ingest.Ingest()` принимает источник (magnet) и текстовый контекст
|
||||
(вычищенный заголовок релиза от торрент-бота), дедуплицирует по infohash,
|
||||
заводит задачу и отдаёт источник в qBittorrent через `qbt.Add()`. Сейчас
|
||||
`qbt.AddRequest` несёт `URLs/Category/SavePath/Paused`, но не имя — в списке
|
||||
qBit задача показывается своим `dn` из magnet, часто мусорным
|
||||
(`rutracker-topic-…`).
|
||||
|
||||
В проекте уже есть всё нужное: `llm.Provider` (один вызов модели, `JSONMode`,
|
||||
транспортные ретраи внутри; бюджет переразбора схемы — на стороне
|
||||
вызывающего, как в `recognize`), пред-парс имени через go-ptn, контекст с
|
||||
заголовком релиза. API qBittorrent `/torrents/add` принимает поле `rename`,
|
||||
задающее отображаемое имя торрента.
|
||||
|
||||
Ключевое ограничение: `rename` действует **только в момент добавления** —
|
||||
значит вывод имени должен случиться синхронно, до `qbt.Add()`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Из контекста загрузки получать короткое читаемое имя и класть его в qBit.
|
||||
- Имя строит LLM (структурированный вывод): название, год, режиссёр, тип,
|
||||
сезон; язык названия — русский для российского контента, иначе английский.
|
||||
- До трёх попыток получить валидное имя от LLM; иначе алгоритмический фолбек
|
||||
без сети.
|
||||
- Вывод имени — в ядре (`ingest`), общий для всех транспортов; деградирует
|
||||
штатно и **никогда не валит приём** загрузки.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Переименование уже добавленных задач; влияние на пути на диске и на
|
||||
`recognize`/`layout` (имя — только ярлык в qBit).
|
||||
- Сетевая сверка имени с метабазами (TMDB/TVDB) — это `recognize`, не здесь.
|
||||
- Источники кроме magnet.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Решение 1: вывод имени — синхронно в `ingest`, перед `qbt.Add()`
|
||||
|
||||
`rename` применяется лишь при добавлении, поэтому имя считается до отдачи
|
||||
источника в qBit. Вывод живёт в ядре `ingest` (принцип «единое ядро, тонкие
|
||||
транспорты»): транспорты по-прежнему передают только `Source` + `Context`.
|
||||
|
||||
- **Альтернатива** (отклонена): добавить торрент `paused`, переименовать
|
||||
отдельным вызовом API, снять с паузы. Сложнее, лишние запросы, гонка с
|
||||
поллингом — выгоды для ярлыка не оправдывают.
|
||||
- **Плата:** приём становится зависим от LLM по латентности. Гасится
|
||||
ограниченным таймаутом и быстрым фолбеком (см. Решение 4 и Риски).
|
||||
|
||||
### Решение 2: имя строит LLM со структурированным выводом
|
||||
|
||||
Отдельный узкий промпт (не трогаем схему `recognize`): на вход — контекст
|
||||
(и, как подсказка, имя/`dn` из magnet), на выход — строгий JSON:
|
||||
|
||||
```
|
||||
{
|
||||
"type": "movie" | "series",
|
||||
"title": "название на нужном языке",
|
||||
"original_title": "оригинальное название или пустая строка",
|
||||
"year": число или 0,
|
||||
"director": "режиссёр или пустая строка",
|
||||
"season": число или null,
|
||||
"is_russian": true | false
|
||||
}
|
||||
```
|
||||
|
||||
`title` модель отдаёт уже на нужном языке: для российского контента
|
||||
(`is_russian=true`) — русское название, иначе — английское/оригинальное.
|
||||
`director` и `year` — опциональные поля; если модель их извлекла, они
|
||||
попадают в ярлык (Решение 3).
|
||||
|
||||
- **Почему LLM, а не только go-ptn/регэкспы:** контекст — вольный
|
||||
человеческий текст с двойными названиями (`Рус / Eng`), годом внутри
|
||||
скобок и тех. характеристиками; алгоритмически чисто вытащить «красивое»
|
||||
имя ненадёжно. go-ptn остаётся фолбеком (Решение 4).
|
||||
- **Недоверенный вывод:** результат — только ярлык в qBit, на пути и
|
||||
инварианты не влияет; жёсткая валидация пути здесь не нужна, но имя
|
||||
очищается от управляющих символов и переводов строк и обрезается по длине.
|
||||
|
||||
### Решение 3: формат отображаемого имени
|
||||
|
||||
Рендер имени — чистая функция от структуры. Режиссёр и год — **опциональные**
|
||||
части скобки; внутри неё порядок «режиссёр, год»:
|
||||
|
||||
- оба: `Title (Director, Year)` → `Дюна: Часть вторая (Дени Вильнёв, 2024)`.
|
||||
- только год: `Title (Year)`; только режиссёр: `Title (Director)`; без обоих:
|
||||
`Title` (скобка опускается).
|
||||
- series: к любому из вариантов добавляется `. Сезон N`, если сезон есть.
|
||||
|
||||
Имя держим коротким и без тех. характеристик; `original_title` остаётся в
|
||||
структуре, но в строку не добавляется. Длина ограничивается (напр. 200
|
||||
символов).
|
||||
|
||||
### Решение 4: три попытки LLM, затем алгоритмический фолбек
|
||||
|
||||
«Попытка» — получить от модели валидный JSON с непустым `title`. Бюджет —
|
||||
существующий `[llm].max_retries` (по умолчанию 3; тот же, что у переразбора
|
||||
схемы в `recognize`); транспортные ретраи (сеть/429/5xx) остаются внутри
|
||||
`llm.Provider` и в этот счёт не входят. Исчерпали попытки или LLM
|
||||
недоступна → **алгоритмический фолбек**:
|
||||
первая содержательная строка контекста (та же логика, что в
|
||||
`tgbot.cleanContext`: без ссылок, команд и UI-мусора), срез до тех.
|
||||
характеристик (до `[`/`(` с годом), очистка и обрезка по длине. Фолбек —
|
||||
без сетевых запросов.
|
||||
|
||||
Если и фолбек пуст (контекста нет/он бесполезен) → `Rename` не задаём,
|
||||
qBittorrent оставляет своё имя. Поведение «без контекста» не меняется.
|
||||
|
||||
- **Бюджет попыток:** переиспользуем существующий `[llm].max_retries`
|
||||
(default 3). Семантика чуть иная, чем у переразбора схемы, но для проекта
|
||||
такого размера отдельный параметр избыточен — разделим при необходимости.
|
||||
|
||||
### Решение 5: интерфейс вывода имени и graceful-деградация
|
||||
|
||||
`ingest` зависит от узкого интерфейса (напр. `Namer`/функция
|
||||
`DeriveName(ctx, Context, magnetHint) string`), реализованного поверх
|
||||
`llm.Provider` + фолбек. Так вывод имени тестируется без сети, а при
|
||||
отсутствии настроенного LLM работает только фолбек. Любая ошибка вывода
|
||||
имени **логируется и не прерывает** `Ingest`: пустое имя → добавляем без
|
||||
`rename`.
|
||||
|
||||
### Решение 6: `qbt.AddRequest.Rename`
|
||||
|
||||
Добавляем поле `Rename string`; при непустом значении пишем form-field
|
||||
`rename`. Пустое — поле не отправляется (поведение не меняется).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Латентность приёма из-за вызова LLM** → вывод имени ограничен общим
|
||||
`[llm].timeout`; по таймауту/ошибке — фолбек. Приём не должен зависать на
|
||||
медленной модели.
|
||||
- **Стоимость токенов на каждую загрузку** → промпт узкий и короткий;
|
||||
расход уже снимается с провода (`llm.Response.Usage`). При желании в
|
||||
будущем — кэш/выключатель, вне объёма.
|
||||
- **`rename` затрагивает имя корневой папки многофайловой раздачи** →
|
||||
downstream безопасен: jellybit всегда читает реальные пути из qBit API
|
||||
(`Files`, `content_path`), а не выводит их из имени. Инвариант «источник
|
||||
неприкосновенен» цел — переименование делает сам qBittorrent при
|
||||
добавлении. Перепроверить руками на реальном qBit (Open Questions).
|
||||
- **Галлюцинация имени LLM** → последствия минимальны (всего лишь ярлык);
|
||||
имя очищается и обрезается; на распознавание/раскладку не влияет.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Чистое добавление, без миграций БД и слома API. Включается само (если LLM
|
||||
настроен — работает LLM-путь, иначе только фолбек). Откат — снять
|
||||
проброс `Rename` (старые задачи в qBit не затрагиваются).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Подтвердить на реальном qBittorrent, что `rename` меняет отображаемое имя
|
||||
(и поведение для многофайловой раздачи) ожидаемо. Решается верификацией на
|
||||
задаче 6.2; код от ответа не зависит (пути берутся из qBit API).
|
||||
|
||||
Решено: бюджет попыток — `[llm].max_retries` (default 3); таймаут вывода
|
||||
имени — общий `[llm].timeout`. Отдельные параметры не вводим: провайдер LLM
|
||||
один, паттерн обращения общий; разделим, если появится второй провайдер.
|
||||
@@ -0,0 +1,62 @@
|
||||
## Why
|
||||
|
||||
При добавлении magnet-загрузки jellybit не передаёт в qBittorrent
|
||||
человекочитаемое имя, поэтому в списке qBit задачи выглядят безлико:
|
||||
`rutracker-topic-6514485`. Контекст загрузки (заголовок релиза от
|
||||
торрент-бота) у нас уже есть — из него можно собрать аккуратное имя
|
||||
(«Дюна: Часть вторая (2024)») и сразу класть его в qBittorrent. Небольшая
|
||||
доработка с заметной отдачей в повседневной эксплуатации.
|
||||
|
||||
Это также первая capability, переносимая из `docs/specs` в OpenSpec
|
||||
(пилот формата): change засевает capability `ingest` дельтой `ADDED`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `ingest` выводит из контекста загрузки **отображаемое имя** и передаёт
|
||||
его в qBittorrent при добавлении.
|
||||
- Имя строится LLM: из контекста извлекаются название, год, режиссёр и (для
|
||||
сериала) номер сезона; название — на русском для российского контента,
|
||||
иначе на английском. Результат — короткая читаемая строка, а не кусок
|
||||
сырого контекста.
|
||||
- LLM даётся до **трёх попыток**; при неудаче — **алгоритмический фолбек**
|
||||
(первая содержательная строка контекста, очистка и обрезка по длине) без
|
||||
сетевых запросов.
|
||||
- Если ни LLM, ни фолбек не дали имени (контекст пуст/бесполезен) —
|
||||
отображаемое имя не передаётся: qBittorrent оставляет своё (`dn`/имя из
|
||||
торрента). Поведение при отсутствии контекста не меняется.
|
||||
- `qbt.AddRequest` получает поле для отображаемого имени, пробрасываемое в
|
||||
параметр `rename` API `/torrents/add`.
|
||||
|
||||
Не входит в объём (Non-goals):
|
||||
|
||||
- Переименование уже добавленных в qBittorrent задач.
|
||||
- Изменение логики распознавания (`recognize`) и раскладки — отображаемое
|
||||
имя нужно лишь для списка qBit и не влияет на пути на диске (jellybit
|
||||
по-прежнему читает реальные пути из qBit API).
|
||||
- Источники кроме magnet (`.torrent`/url) — отдельная задача.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `ingest`: приём загрузки (источник + контекст) — дедупликация по
|
||||
infohash, заведение задачи и передача источника в qBittorrent. В рамках
|
||||
этого change добавляется требование о выводе и передаче отображаемого
|
||||
имени. Базовое поведение приёма фиксируется как контекст существующего
|
||||
кода.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- OpenSpec-спеки пусты; существующих capability нет. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код:** `internal/ingest` (вывод имени, новая зависимость на LLM-провайдер
|
||||
и алгоритмический фолбек), `internal/qbt` (поле `Rename` в `AddRequest`,
|
||||
form-field `rename`). Возможен небольшой хелпер вывода имени (в `ingest`
|
||||
или соседнем пакете).
|
||||
- **Конфиг:** возможен бюджет попыток LLM (переиспользовать существующий
|
||||
`llm.max_retries` либо отдельный параметр) — уточняется в design.
|
||||
- **Внешние системы:** дополнительный вызов LLM на каждую новую загрузку
|
||||
(расход токенов); деградирует штатно — при недоступности LLM работает
|
||||
алгоритмический фолбек.
|
||||
- **Инварианты:** не затрагиваются. Имя влияет только на отображение в
|
||||
qBittorrent; пути на диске берутся из qBit API, источник не трогаем.
|
||||
@@ -0,0 +1,90 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отображаемое имя торрента из контекста
|
||||
|
||||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||||
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
||||
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
||||
показывалась безликим `dn` magnet-ссылки.
|
||||
|
||||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
||||
SHALL обрезаться по ограничению длины.
|
||||
|
||||
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
||||
qBittorrent (параметр `rename` действует только в момент добавления).
|
||||
|
||||
Отображаемое имя SHALL влиять только на отображение в qBittorrent и SHALL
|
||||
NOT влиять на пути файлов на диске, распознавание или раскладку — реальные
|
||||
пути система по-прежнему читает из qBit API.
|
||||
|
||||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||
|
||||
- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось
|
||||
получить имя
|
||||
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
||||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||
где режиссёр и год опциональны
|
||||
|
||||
#### Scenario: Контекст пуст или имя не получено
|
||||
|
||||
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||||
|
||||
### Requirement: Вывод имени через LLM со структурированным выводом
|
||||
|
||||
Система SHALL строить отображаемое имя с помощью LLM (структурированный
|
||||
JSON-вывод), извлекая из контекста тип (movie/series), название, год,
|
||||
режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля.
|
||||
|
||||
Название SHALL быть на русском языке для российского контента и на
|
||||
английском (оригинальном) — для остального.
|
||||
|
||||
Система SHALL предпринять ограниченное число попыток получить от LLM валидный
|
||||
результат (корректный JSON с непустым названием); бюджет попыток —
|
||||
`[llm].max_retries` (по умолчанию 3). Транспортные ретраи провайдера LLM
|
||||
(сетевые сбои, 429, 5xx) в этот счёт не входят.
|
||||
|
||||
Недоступность или ошибка LLM SHALL NOT прерывать приём загрузки: система
|
||||
переходит к алгоритмическому фолбеку.
|
||||
|
||||
#### Scenario: LLM возвращает структурированное имя
|
||||
|
||||
- **WHEN** LLM по контексту возвращает валидный JSON с непустым названием
|
||||
- **THEN** система формирует отображаемое имя из его полей (название, год,
|
||||
для сериала — сезон)
|
||||
|
||||
#### Scenario: Российский контент — название на русском
|
||||
|
||||
- **WHEN** контент распознан как российский
|
||||
- **THEN** в отображаемом имени используется русское название
|
||||
|
||||
#### Scenario: Исчерпан бюджет попыток LLM
|
||||
|
||||
- **WHEN** LLM за отведённые попытки (`[llm].max_retries`) не вернул валидный
|
||||
результат либо недоступен
|
||||
- **THEN** система не прерывает приём и переходит к алгоритмическому фолбеку
|
||||
|
||||
### Requirement: Алгоритмический фолбек вывода имени без сети
|
||||
|
||||
При неудаче LLM система SHALL выводить имя алгоритмически, без сетевых
|
||||
запросов: брать первую содержательную строку контекста (без ссылок, команд
|
||||
бота и UI-мусора), отсекать технические характеристики, очищать и обрезать
|
||||
по длине.
|
||||
|
||||
Если и фолбек не дал непустого имени, система SHALL добавить загрузку без
|
||||
параметра `rename`.
|
||||
|
||||
#### Scenario: Фолбек извлекает имя из контекста
|
||||
|
||||
- **WHEN** LLM недоступен или исчерпал попытки, а контекст содержательный
|
||||
- **THEN** система берёт первую содержательную строку контекста, отсекает
|
||||
технические характеристики и использует результат как отображаемое имя
|
||||
- **AND** при этом не делается ни одного сетевого запроса
|
||||
|
||||
#### Scenario: Фолбек тоже пуст
|
||||
|
||||
- **WHEN** ни LLM, ни алгоритмический фолбек не дали непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
@@ -0,0 +1,61 @@
|
||||
## 1. qBittorrent: проброс имени
|
||||
|
||||
- [x] 1.1 Добавить поле `Rename string` в `qbt.AddRequest`
|
||||
- [x] 1.2 В `Client.Add` писать form-field `rename` при непустом `Rename`
|
||||
- [x] 1.3 Тест: при заданном `Rename` form-data содержит `rename`, при
|
||||
пустом — поля нет
|
||||
|
||||
## 2. Вывод имени: структура и рендер
|
||||
|
||||
- [x] 2.1 Описать структуру извлечённого имени (type, title,
|
||||
original_title, year, director, season, is_russian)
|
||||
- [x] 2.2 Реализовать чистую функцию рендера структуры в короткий ярлык:
|
||||
`Title (Director, Year)` с опциональными режиссёром и годом (скобка
|
||||
опускается, если обоих нет), для сериала — суффикс `. Сезон N`;
|
||||
очистка управляющих символов/переводов строк и обрезка по длине
|
||||
- [x] 2.3 Тесты рендера: фильм с режиссёром+годом / только год / только
|
||||
режиссёр / без обоих, сериал с сезоном/без, обрезка длины, очистка
|
||||
|
||||
## 3. Вывод имени: LLM
|
||||
|
||||
- [x] 3.1 Узкий промпт извлечения имени (RU-название для российского
|
||||
контента, иначе EN/оригинал) + описание JSON-схемы ответа
|
||||
- [x] 3.2 Парсинг и валидация ответа (валидный JSON, непустой `title`);
|
||||
бюджет попыток — `[llm].max_retries` (переиспользуем существующий)
|
||||
- [x] 3.3 Тесты на фикстурах (без сети): успешный разбор, выбор языка
|
||||
названия, исчерпание попыток
|
||||
|
||||
## 4. Вывод имени: алгоритмический фолбек
|
||||
|
||||
- [x] 4.1 Реализовать фолбек без сети: первая содержательная строка
|
||||
контекста (без ссылок/команд/UI-мусора), отсечение тех.
|
||||
характеристик, очистка и обрезка
|
||||
- [x] 4.2 Фолбек самодостаточен (лёгкая фильтрация шума для сырого
|
||||
HTTP/CLI-ввода). `tgbot.cleanContext` НЕ рефакторил: он работает на
|
||||
слое транспорта (чистит UI-мусор бота), фолбек — на слое ядра (берёт
|
||||
заголовок из уже-контекста); преждевременная общая зависимость связала
|
||||
бы транспорт с util ядра. Дублирование минимально (две эвристики)
|
||||
- [x] 4.3 Тесты фолбека: заголовок релиза, пустой/бесполезный контекст → ""
|
||||
|
||||
## 5. Интеграция в ingest
|
||||
|
||||
- [x] 5.1 Ввести узкий интерфейс `ingest.Namer` (`DeriveName`), реализован
|
||||
пакетом `internal/naming` поверх LLM + фолбек; зависимость опциональна
|
||||
(nil-провайдер → только фолбек)
|
||||
- [x] 5.2 В `Ingest()` синхронно выводить имя из `req.Context` (подсказка —
|
||||
`magnet.DisplayName`) перед `qbt.Add`, класть в `AddRequest.Rename`
|
||||
- [x] 5.3 Graceful-деградация: ошибка/таймаут вывода имени логируется и не
|
||||
прерывает приём (пустое имя → без `rename`). Бюджет попыток и таймаут —
|
||||
общие `[llm].max_retries` / `[llm].timeout`, новых параметров не вводим
|
||||
- [x] 5.4 Прокинуть зависимость (LLM-провайдер) в сборке сервиса
|
||||
(`cmd/jellybit`): провайдер поднимается один раз, переиспользуется
|
||||
`naming` и `recognize`
|
||||
|
||||
## 6. Проверка
|
||||
|
||||
- [x] 6.1 `task test` и `task lint` зелёные
|
||||
- [ ] 6.2 Ручная проверка на реальном qBittorrent: имя видно в списке;
|
||||
подтвердить поведение `rename` для многофайловой раздачи
|
||||
- [x] 6.3 При архивации: дельта-спека синкнута в `openspec/specs/ingest`
|
||||
(новая capability), пункт «Название из контекста» убран из `docs/todo.md`
|
||||
(реализованное переехало в OpenSpec-спеки)
|
||||
@@ -0,0 +1,47 @@
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
Language: Russian
|
||||
Пиши на русском, но:
|
||||
- Структурные заголовки оставляй на английском:
|
||||
## ADDED/MODIFIED/REMOVED Requirements, ### Requirement:, #### Scenario:
|
||||
- Ключевые слова GIVEN/WHEN/THEN и RFC 2119 (SHALL/MUST/SHOULD) — на английском
|
||||
- Технические термины (API, REST, JWT), пути и код — на английском
|
||||
|
||||
Имена capabilities:
|
||||
- Capability — это ПОВЕДЕНИЕ/домен системы, а не пакет кода (совпадение с
|
||||
именем пакета допустимо, но не критерий).
|
||||
- Существительное, понятное без знания кода: ingest, recognition,
|
||||
file-layout, review, notifications. НЕ qbt/worker (это реализация).
|
||||
- Гранулярность по принципу «требования меняются вместе». Дробить, когда в
|
||||
одной спеке смешиваются разные заботы. Переименовать дёшево (RENAMED
|
||||
Requirements) — не дроби преждевременно в маленьком проекте.
|
||||
|
||||
RFC 2119 — это требование валидатора, не стиль:
|
||||
- Каждое ### Requirement ОБЯЗАНО содержать литерал SHALL или MUST, иначе
|
||||
`openspec validate` падает (проверено). Поэтому эти слова и WHEN/THEN не
|
||||
русифицируем — они несут точную нормативную/структурную семантику.
|
||||
|
||||
Ревью (процесс, не артефакт):
|
||||
- Нетривиальная/архитектурная задача — два чекпоинта: ревью дизайна (после
|
||||
design/specs, ДО кода — дешевле чинить направление) и ревью кода (после
|
||||
apply, до archive).
|
||||
- Тривиальная задача — достаточно одного прохода (код).
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
rules:
|
||||
proposal:
|
||||
- Capabilities называй по поведению/домену системы, не по пакету кода
|
||||
specs:
|
||||
- Каждое ### Requirement обязано содержать SHALL или MUST (иначе валидация падает)
|
||||
- Заголовки и WHEN/THEN/GIVEN — на английском, остальной текст на русском
|
||||
@@ -0,0 +1,98 @@
|
||||
# ingest Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Приём загрузки: использование контекста для отображаемого имени торрента в
|
||||
qBittorrent. Capability описывает вывод человекочитаемого имени из контекста
|
||||
(через LLM или алгоритмический фолбек) и его передачу в qBittorrent.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Отображаемое имя торрента из контекста
|
||||
|
||||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||||
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
||||
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
||||
показывалась безликим `dn` magnet-ссылки.
|
||||
|
||||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
||||
SHALL обрезаться по ограничению длины.
|
||||
|
||||
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
||||
qBittorrent (параметр `rename` действует только в момент добавления).
|
||||
|
||||
Отображаемое имя SHALL влиять только на отображение в qBittorrent и SHALL
|
||||
NOT влиять на пути файлов на диске, распознавание или раскладку — реальные
|
||||
пути система по-прежнему читает из qBit API.
|
||||
|
||||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||
|
||||
- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось
|
||||
получить имя
|
||||
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
||||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||
где режиссёр и год опциональны
|
||||
|
||||
#### Scenario: Контекст пуст или имя не получено
|
||||
|
||||
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||||
|
||||
### Requirement: Вывод имени через LLM со структурированным выводом
|
||||
|
||||
Система SHALL строить отображаемое имя с помощью LLM (структурированный
|
||||
JSON-вывод), извлекая из контекста тип (movie/series), название, год,
|
||||
режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля.
|
||||
|
||||
Название SHALL быть на русском языке для российского контента и на
|
||||
английском (оригинальном) — для остального.
|
||||
|
||||
Система SHALL предпринять ограниченное число попыток получить от LLM валидный
|
||||
результат (корректный JSON с непустым названием); бюджет попыток —
|
||||
`[llm].max_retries` (по умолчанию 3). Транспортные ретраи провайдера LLM
|
||||
(сетевые сбои, 429, 5xx) в этот счёт не входят.
|
||||
|
||||
Недоступность или ошибка LLM SHALL NOT прерывать приём загрузки: система
|
||||
переходит к алгоритмическому фолбеку.
|
||||
|
||||
#### Scenario: LLM возвращает структурированное имя
|
||||
|
||||
- **WHEN** LLM по контексту возвращает валидный JSON с непустым названием
|
||||
- **THEN** система формирует отображаемое имя из его полей (название, год,
|
||||
для сериала — сезон)
|
||||
|
||||
#### Scenario: Российский контент — название на русском
|
||||
|
||||
- **WHEN** контент распознан как российский
|
||||
- **THEN** в отображаемом имени используется русское название
|
||||
|
||||
#### Scenario: Исчерпан бюджет попыток LLM
|
||||
|
||||
- **WHEN** LLM за отведённые попытки (`[llm].max_retries`) не вернул валидный
|
||||
результат либо недоступен
|
||||
- **THEN** система не прерывает приём и переходит к алгоритмическому фолбеку
|
||||
|
||||
### Requirement: Алгоритмический фолбек вывода имени без сети
|
||||
|
||||
При неудаче LLM система SHALL выводить имя алгоритмически, без сетевых
|
||||
запросов: брать первую содержательную строку контекста (без ссылок, команд
|
||||
бота и UI-мусора), отсекать технические характеристики, очищать и обрезать
|
||||
по длине.
|
||||
|
||||
Если и фолбек не дал непустого имени, система SHALL добавить загрузку без
|
||||
параметра `rename`.
|
||||
|
||||
#### Scenario: Фолбек извлекает имя из контекста
|
||||
|
||||
- **WHEN** LLM недоступен или исчерпал попытки, а контекст содержательный
|
||||
- **THEN** система берёт первую содержательную строку контекста, отсекает
|
||||
технические характеристики и использует результат как отображаемое имя
|
||||
- **AND** при этом не делается ни одного сетевого запроса
|
||||
|
||||
#### Scenario: Фолбек тоже пуст
|
||||
|
||||
- **WHEN** ни LLM, ни алгоритмический фолбек не дали непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
Reference in New Issue
Block a user