Канон документов, каталог задач и OpenSpec

docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего
устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью.
Конвенции перенесены из jellybit; места, где код им не следует, помечены
строкой «Расхождение» как объявленный долг.

tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и
многопользовательский режим), два направления (все форматы, долгие
записи) и пять задач в беклоге.

openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет.

CLAUDE.md переписан по форме канона: инварианты с severity, семантика
гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
av
2026-08-10 21:19:07 +03:00
parent a4646c0930
commit 4d1c2bf44c
40 changed files with 3656 additions and 71 deletions
+155
View File
@@ -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.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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
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
+160
View File
@@ -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.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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 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
+174
View File
@@ -0,0 +1,174 @@
---
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.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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
+109
View File
@@ -0,0 +1,109 @@
---
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
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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
+143
View File
@@ -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).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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
```
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.5.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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
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.5.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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 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
+289
View File
@@ -0,0 +1,289 @@
---
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.5.0"
---
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.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## 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
+113
View File
@@ -0,0 +1,113 @@
---
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.5.0"
---
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
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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
+147
View File
@@ -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.5.0"
---
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).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**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
```
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
+122 -71
View File
@@ -1,99 +1,150 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Памятка для работы над transcriber. Перед задачей прочитай также
[docs/passport.md](docs/passport.md),
[docs/architecture.md](docs/architecture.md) и
[docs/conventions/](docs/conventions/README.md).
Проект и общение по нему ведём по-русски.
Проект ведём по-русски.
## Что это
Сервис расшифровки аудио. Два входа — Telegram-бот и HTTP API, один общий конвейер
обработки. Распознавание асинхронное, через Yandex SpeechKit; файл едет в Yandex
Object Storage, оттуда его забирает SpeechKit.
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач
и метаданные файлов лежат в SQLite, файлы — на диске.
Чего **не** делает: не редактирует и не пересказывает текст, не хранит записи как
архив, не распознаёт речь сам, не заводит учётные записи и не работает с живым
потоком. Границу домена целиком держит [docs/passport.md](docs/passport.md).
## Стек
Go 1.24 (нужен CGO из-за `mattn/go-sqlite3`), gin, goqu, goose, SQLite,
`go-telegram-bot-api`, `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex
SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Docker,
выкладка — Ansible из `pet-project-server`.
## Инварианты
Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и в колонку
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во
всех местах выкладки. **critical**
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
**critical**
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
задачу пригодной к повтору, либо переводит её в `failed` и сообщает
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает.
**major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом. Необратимо: goose считает применённую версию по номеру. **critical**
- **Новая колонка правится во всех четырёх местах** репозитория SQLite —
`Create`, `Save`, `GetByID`, `FindAndAcquire`. Компилятор расхождение не
поймает, а проявится оно как потерянное при сохранении поле. **major**
## Команды
```bash
go build ./... # нужен CGO: mattn/go-sqlite3
go build ./... # нужен CGO
go test ./...
go vet ./...
gofmt -l .
golangci-lint run
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml
task image # docker-образ, тег из $BUILD_ID (по умолчанию dev)
task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом
```
`golangci-lint run` на чистом `master` даёт 4 замечания в существующем коде (два
непроверенных `Close`, два сравнения ошибок через приведение типа вместо
`errors.As`). Их пока не чинили — новые замечания отличай от этих.
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml`
скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в
[docs/conventions/config.md](docs/conventions/config.md) строками
«*Расхождение:*».
Деплой запускается не отсюда, а из `pet-project-server`: `inv pl -- transcriber`.
Плейбук сам зовёт `task image` по контракту роли `app_image`, образ едет на сервер
через `docker save`/`load`, реестр не участвует.
## Гейт
`lefthook` на pre-commit гоняет `gitleaks git --staged`.
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
`origin/master`; переопределяется `task gate BASE=<rev>`.
- **Где логи шагов:** вывод команды, отдельного файла нет.
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
`docs.py check` коды свои: 0 сошлось, 1 дрейф раскладки, 2 ошибка
употребления, 3 не корень проекта, 4 внутренний сбой.
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов.
Всё перечисленное проверяется машиной и потому не обсуждается.
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
коммита. Полную историю никто не проверяет;
- согласованность документов между собой и с кодом — её судят агенты, зовёт
их скилл `av-dev-docs:healthcheck`, и звать его надо руками;
- проверка каталога задач и формы `openspec/config.yaml` — соответствующих
плагинов в проекте нет, шагов в гейте нет, и не проверяет их **никто**;
- покрытие изменённых строк не считается ничем.
## Конфиг
**Гейт на `master` сегодня красный, и это объявленный долг, а не поломка дня.**
Два известных отказа:
`config.toml` в gitignore, образец — `config.dist.toml`. Разбирается в
`internal/config`; значения по умолчанию заданы в `defaultConfig()`, файл их
перекрывает. Правишь поле — правь оба места.
- `go test ./...` падает в `internal/controller/http`: тесты требуют
`testdata/sample.m4a`, которого в репозитории нет и не было (`*.m4a` стоит в
`.gitignore`), а остальные скармливают строку `test audio content` реальному
`ffprobe` и ждут 201. Заведено задачей `http-handler-tests-never-green`;
- `golangci-lint run` даёт 4 замечания в существующем коде: два непроверенных
`Close` (`adapter/recognizer/yandex/speechkit.go:55`, `main.go:124`) и два
сравнения ошибок приведением типа (`controller/worker/worker.go:51`,
`service/transcribe.go:394`). Долг записан в
[docs/conventions/errors.md](docs/conventions/errors.md), заведён задачей
`errors-as-instead-of-typecast`.
Два подвоха:
Новые отказы отличай от этих. Пока они живы, «зелёный гейт» в определении
сделанного означает «не добавилось ничего сверх перечисленного».
- Ключ белого списка пользователей Telegram называется `users_while_list` (опечатка
в теге toml), лежит в секции `[server]`, и в `config.dist.toml` его нет вообще.
Без него бот отвечает отказом всем.
- Список сверяется с `update.Message.From.String()` из tgbotapi — это `@username`
либо имя с фамилией, а не числовой id.
## Запреты
## Конвейер задач
- **Рабочую БД не трогать.** `data/transcriber.db` на сервере и его копии.
Локальная база в `./data/` — своя, её ронять и пересоздавать можно свободно.
- **Боевой каталог записей не трогать.** `data/files` на сервере: там лежат
голосовые сообщения живых людей.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
перехватывает обновления у работающего, и пользователь теряет ответы.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`.
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
запускает человек.
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
временном каталоге и убирают за собой.
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
писать: этот каталог смонтирован на сервере.
Состояния `TranscribeJob`: `created``converted``transcribe``done` / `failed`
(константы в `internal/entity/job.go`).
## Работа
Три воркера в `main.go` крутят по одному шагу каждый, опрашивая базу раз в секунду:
`conversion_worker` (created → converted, ffmpeg в ogg), `transcribe_worker`
(converted → transcribe, заливка в S3 и старт распознавания), `check_worker`
(transcribe → done/failed, опрос операции Yandex).
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
файла на диске и раскладка `data/files`, публичный контракт HTTP API, имя
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга
из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли
задачами.
- **Ориентир по размеру порции:** не замерялся.
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
поимённо.
Задача захватывается через `FindAndAcquire`: один `UPDATE` проставляет
`acquisition_id`, дальше строка читается по нему. Задача с истёкшим `acquire_time`
достаётся заново — час для конвертации и распознавания, сутки для проверки.
`delay_time` откладывает следующую попытку.
## Язык
`contract.NoopJobError` — не ошибка, а «задач в этом состоянии нет». Воркер такой
случай не логирует и не считает в метрику. Возвращая ошибку из шага конвейера, не
теряй эту семантику.
Ошибку, после которой задачу нет смысла повторять, оформляй через `failJob`: он
переводит задачу в `failed` и сам шлёт человеку понятный текст в Telegram. Возврат
обычной ошибки оставляет задачу в текущем состоянии на повтор.
## Слои
`internal/entity` — модели, `internal/contract` — интерфейсы и типы ошибок,
`internal/service` — конвейер, `internal/controller/{http,tg,worker}` — входы,
`internal/adapter/*` — реализации contract (ffmpeg, yandex, telegram, sqlite).
Сервис зависит только от интерфейсов `contract`; конкретные адаптеры собираются
в `main.go`.
## База
Миграции goose в `migrations/`, вшиты в бинарник через `//go:embed migrations/*.sql`
в `main.go` и накатываются при старте. Новый файл достаточно положить в каталог,
регистрировать нигде не надо. Диалект — `sqlite3`.
Добавляя колонку, правь четыре места: миграцию, структуру в `internal/entity`,
и в `internal/adapter/repo/sqlite` — списки колонок в `Create`, `Save`, `GetByID` и
`FindAndAcquire`. Списки продублированы, компилятор расхождение не поймает.
## Конвенции
- Коммиты прямо в `master`, без веток и PR.
- В сообщении коммита не ставить трейлер `Co-Authored-By`.
- Логи — `log/slog`, структурные пары ключ-значение, логгер прокидывается
конструктором. `log.Printf` в `internal/controller/http/transcribe.go` — остаток,
на него не равняться.
- Текст, который увидит пользователь Telegram, — по-русски. Логи и комментарии в
коде — как в соседних файлах.
- Метрики Prometheus объявляются в `internal/metrics` с префиксом `transcriber_`.
- Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский.
- Текст, который видит пользователь Telegram, — русский.
+63
View File
@@ -4,9 +4,72 @@ version: '3'
vars:
PROJECT: "transcriber"
# База диффа для шагов, которым нужна разница с основной веткой.
BASE: '{{.BASE | default "origin/master"}}'
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
# дрейф своего каталога, и выпадение одного не подменяется другим.
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}'
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}'
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}'
tasks:
gate:
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
cmds:
- go build ./...
- go vet ./...
- |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "gofmt: файлы выше не отформатированы"
exit 1
fi
- go test ./...
- golangci-lint run
- task: docs
- task: tasks
- task: openspec
docs:
desc: 'Раскладка docs/ против канона'
cmds:
# Шаг обязан краснеть внятно, если скрипта нет, а не пропускаться молча.
- |
py=$(eval echo {{.DOCS_PY}})
if [ ! -f "$py" ]; then
echo "docs.py не найден: $py"
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
exit 1
fi
python3 "$py" check --base {{.BASE}}
tasks:
desc: 'Согласованность каталога задач'
cmds:
- |
py=$(eval echo {{.TASKS_PY}})
if [ ! -f "$py" ]; then
echo "tasks.py не найден: $py"
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
exit 1
fi
python3 "$py" check --dir tasks
openspec:
desc: 'Форма openspec/config.yaml'
cmds:
- |
py=$(eval echo {{.OPENSPEC_PY}})
if [ ! -f "$py" ]; then
echo "openspec.py не найден: $py"
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
exit 1
fi
python3 "$py" check --dir .
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
image:
+4
View File
@@ -0,0 +1,4 @@
{
"canon": 12,
"migrations": "migrations"
}
+39
View File
@@ -0,0 +1,39 @@
# Журнал решений
Одна запись — одно решение. **ADR переносит решение из архивного `design.md`**, а
не сочиняет его заново: запись цитирует решение и ссылается на
`openspec/changes/archive/<id>/design.md`.
## Когда заводить
Верно одно из трёх:
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
«заменено на».
Не заводить для рутины и для того, что видно из кода и `git log`.
## Соглашения
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
источником, а не абзацем в теле.
## Записи
Новые сверху.
| Дата | Запись | Статус |
| --- | --- | --- |
Записей нет: канон заведён 2026-08-10, а решения, принятые до него, источника в
архиве изменений не имеют — сочинять их задним числом правило запрещает.
Ближайшие кандидаты назовёт первое же изменение, которое тронет хранилище или
вход: замена SQLite на PocketBase и вход через OIDC оба проходят триггер
«дорогой откат».
+22
View File
@@ -0,0 +1,22 @@
# Краткий заголовок решения
- **Дата:** ГГГГ-ММ-ДД
- **Источник:** openspec/changes/archive/<id>/design.md
Статус ставится тем же полем и только при пересмотре:
`- **Статус:** заменено на ADR-…` либо `- **Статус:** устарело`.
У активной записи поля нет.
## Решение
Что именно решено — одной фразой.
## Почему
Намерение и причина. Цитата из источника, а не пересказ. Пиши так, чтобы через
год было понятно без чтения переписки.
## Последствия
- `+` что стало лучше.
- `` чем платим: ограничения, риски, нагрузка на сопровождение.
+131
View File
@@ -0,0 +1,131 @@
# Архитектура
Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
— нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано,
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
этого ещё не решено — в разделе «Открытые вопросы».
Спеки ещё не заведены: capability ни одной, поведение живёт только в коде.
Первая задача, которая трогает поведение, заводит спеку — до тех пор у темы
`requirements` нормативного документа нет.
## Принципы
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу
запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в
день (оценка владельца, не замер: `research/` пуст).
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
достаётся снова по истечении срока захвата и проходит шаг заново.
- **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и SQLite подставляются в
`main.go`.
## Компоненты
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
<!-- канон: поведение → openspec/specs/intake, delivery -->
| Компонент | Где | Что делает |
| --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/sqlite` | Задачи и файлы, запросы через goqu |
<!-- канон: поведение → openspec/specs/pipeline -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Три
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду.
## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
## Эксплуатация
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»:
<!-- канон: поведение → openspec/specs/conversion, recognition -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| SQLite (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
или по сообщению об ошибке. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
Отдельного оповещения нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
три воркера опрашивают базу раз в секунду вхолостую.
## Единые точки проекта
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает
сам.
## Деплой
Образ собирается по контракту роли `app_image`: `task image` даёт
`transcriber:$BUILD_ID`, по умолчанию `transcriber:dev`. Реестр не участвует —
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
`inv pl -- transcriber` из `pet-project-server`.
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
процесс работает под непривилегированным пользователем `transcriber`.
## Открытые вопросы
- **Хранилище.** Пробуем PocketBase взамен SQLite с goqu и goose. Не решено, чем
становится конвейер задач: таблицей PocketBase с тем же захватом или чем-то
другим. Данные не переносим — начинаем с чистого листа.
- **Учётные записи.** Вход через OIDC, провайдер — Authelia. Не решено, где
живёт сессия и как связываются пользователь Telegram и пользователь веба.
- **Веб-интерфейс.** Формы нет вовсе, есть только API. Конвенция веб-UI на htmx
описана в [conventions/web-ui.md](conventions/web-ui.md) заранее.
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
+66
View File
@@ -0,0 +1,66 @@
# Конвенции кода
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
система делает, и от [../architecture.md](../architecture.md), который описывает,
как она сложена.
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
размазывает внимание по тривиальному — и модель, и человек добросовестно
проверят именование и не дойдут до формы решения.
Обоснование «почему именно так» живёт в [../adr/](../adr/README.md); инварианты с
severity — в [CLAUDE.md](../../CLAUDE.md).
## Откуда взяты и что с расхождениями
Все пять записей перенесены из проекта jellybit — тот же Go, тот же автор, те же
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
шаге и дублируется воркером, доменные ошибки проверяются приведением типа.
Каждое такое место названо в своей записи строкой «*Расхождение:*». Читается оно
как **долг, а не как нарушение**: правила действуют на новый код, переписывание
существующего — отдельная работа. Проходу ревью строка «Расхождение» говорит, что
находка на этом месте уже известна и новой не считается.
## Записи
- [logging.md](logging.md) — логирование: уровень по адресату, единая логирующая
точка на доменной границе, словарь полей, `ext.*`, что не логируем.
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is` и
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
типизированной.
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
`0600`, самодокументируемый `config.dist.toml`, проверка на старте.
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
ULID, разбор на входной границе, естественные ключи у деталей.
- [web-ui.md](web-ui.md) — веб-UI на htmx: партиал равен странице равен
фрагменту, ветвление по `HX-Request`, деградация без JS, ошибка на htmx-пути
как 200 плюс фрагмент, самозавершающийся опрос, вендоринг статики. **Записана
наперёд: веб-UI ещё нет.**
## Механизировано
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
промптах ревью не пересказывается.
| Правило | Где механизировано |
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck` (кроме `defer Close` и `send`) |
| Форматирование исходников | `.golangci.yml``gofmt` |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
оставшееся прозой, проверяет человек на каждом ревью заново.
+124
View File
@@ -0,0 +1,124 @@
# Конфигурация
Конвенция: *как* устроена и грузится конфигурация transcriber (TOML).
Правила оформления кода (How), не спецификация поведения.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: образец называется `config.dist.toml`, а не `config.example.toml`;
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
проверки пустых ключей внутри адаптеров.
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас
участвует.
## Принципы
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
используем**: окружение наследуется дочерними процессами и видно через
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
*Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла.
- Грузим **один раз при старте** в одну типизированную структуру `Config`
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
прикладном коде нет, только загрузчик `internal/config`.
- Конфиг **неизменяем** после старта; смена параметров — перезапуск процесса.
## Файл и поиск
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
процесса.
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный
`config.toml` не коммитится.
## config.dist.toml — самодокументируемый образец
`config.dist.toml` коммитим как единый справочник по конфигу: все секции и все
поля. **Каждое поле снабжаем комментарием**, из которого ясно:
- **зачем** поле — что оно меняет в поведении;
- **допустимые значения** — перечисление или границы;
- **единицы измерения**, если применимы — секунды, байты, доля `01`.
```toml
[server]
port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
одним домом — таблица «Настройки с числовым значением» в
[../database.md](../database.md); `config.dist.toml` — источник истины по составу
полей.
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
бекендов или внешних сервисов), обязательность и опциональность полей определяет
значение `type`, а не фиксированный список секции.
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
обязательных полей; поля других значений не требуются. Неизвестное значение —
ошибка на старте с перечислением поддерживаемых.
- **Образец — по `type`.** В `config.dist.toml`:
- основное (умолчательное) значение **предзаполнено** рабочими значениями;
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
полей (зачем, границы, единицы — как у обычных полей);
- так из примера видны все варианты и поля каждого, не открывая код.
Дискриминатора в transcriber пока нет; правило записано на случай второго
распознавателя.
## Секреты
Секреты доставляет **выкладка**, рендеря их прямо в `config.toml` (transcriber:
Ansible из `pet-project-server`). Приложение просто читает TOML — отдельного слоя
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение.
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`).
- В `config.dist.toml` секретные поля — пустые строки.
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
строки, и загрузчик их не отличает от настоящего значения.
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
отрендеренный файл) — см. «Проверка и остановка на старте».
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
## Проверка и остановка на старте
Конфиг проверяем **на старте, до приёма трафика**. Негодный конфиг — лог `ERROR`
и выход с ненулевым кодом, не стартуем наполовину.
Что проверяем:
- обязательные поля заданы;
- каталоги хранилища существуют и доступны на запись;
- границы числовых полей соблюдены;
- ключи внешних сервисов не пусты.
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и
приложение продолжает работу без бота; пустые ключи Yandex ловятся в
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет.
## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям (`Server`,
`Database`, `Storage`, `Yandex`, `Telegram`).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
требует правки обоих мест.
+56
View File
@@ -0,0 +1,56 @@
# Конвенция: база данных и идентификаторы
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации
и разбора нет. Правила действуют на новый код; переписывание существующего —
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты
в transcriber нет.
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах.
- **Точка генерации и разбора одна**: создание — при вставке записи в
репозитории, разбор — на входных границах. Самодельных генераторов по месту
вызова не заводим.
## Канонический вид — lowercase
- Генерим и храним id в **нижнем регистре**. Сравнение строк в SQLite
побайтовое, поэтому любой внешний id (URL, форма, поле запроса) обязательно
проходит разбор до запроса к БД — разбор проверяет формат и нормализует
регистр (base32 ULID нечувствителен к регистру при декодировании).
- Синтаксически неверный id считаем несуществующей сущностью (404), без похода
в БД.
## Естественные и составные ключи — для деталей
- У таблиц-деталей и связей допустим естественный или составной ключ вместо
ULID, когда он есть по природе данных. Отдельный ULID там — мёртвый вес.
- Прочие генерируемые идентификаторы — тем же способом, что и ключи сущностей:
единый формат, сортируемость, корреляция в логах.
## Прочее
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
- Миграции — goose (`migrations/`): SQL-файлы для DDL; Go-миграции
(`goose.AddMigrationContext`) — когда нужен код (генерация id, заполнение
задним числом). При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением.
- Добавляя колонку, соблюдай инвариант «Новая колонка правится во всех четырёх
местах» — [CLAUDE.md](../../CLAUDE.md), «Инварианты».
+145
View File
@@ -0,0 +1,145 @@
# Ошибки
Конвенция: *как* устроены и передаются ошибки в transcriber. Правила оформления
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки
строятся, оборачиваются и проверяются.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
решают сами, а доменные ошибки проверяются приведением типа, а не `errors.As`.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
пакетов ошибок в проекте и так нет.
## Базовая идиома: stdlib
- Только стандартный `errors` плюс `fmt.Errorf`: контекст ошибки несёт `slog`, а
не стек — стек-трейсы и внешний сборщик избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
пересмотреть, а не умолчание.
## Обёртка и контекст
transcriber — **приложение, а не библиотека**: внешнего Go-API нет, весь код наш.
Поэтому внутри приложения обёртка `%w`**умолчание**, чтобы `errors.Is` и
`errors.As` работали сквозь слои.
- Добавляем контекст обёрткой: `fmt.Errorf("convert audio: %w", err)`.
- `%w` — когда вызывающий может смотреть причину (наш обычный случай). `%v`
когда причину сознательно **не** раскрываем.
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке, а
трансляцией на внешней границе (см. ниже).
Стиль сообщения:
- со строчной, без точки в конце, без «failed to» и «error» — обёртка и так
читается как «контекст: причина»;
- контекст — операция или субъект: `"acquire job: %w"`, а не
`"something failed"`;
- без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний.
*Расхождение:* в коде преобладает форма `"failed to <действие>: %w"`.
## Проверка ошибок
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
`sql.ErrNoRows` превращается в доменную ошибку в слое репозитория, чтобы выше
по коду не торчал `database/sql`.
- Проверяем `errors.Is` и `errors.As`, а не сравнением и не приведением типа.
*Расхождение, и оно опасно:* `NoopJobError` и `JobNotFoundError` проверяются
приведением типа — `err.(*contract.NoopJobError)` в
`internal/controller/worker/worker.go` и `err.(*contract.JobNotFoundError)` в
`internal/service/transcribe.go`. Работает это только потому, что на этом пути
ошибку никто не оборачивает. Первый же `fmt.Errorf("…: %w")` между ними сломает
проверку молча: воркер перестанет отличать «задач нет» от отказа и начнёт
считать пустой прогон ошибкой раз в секунду.
## Sentinel и типизированные
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, на
которые ветвится код. Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями плюс метод `Error()`) — когда
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
где хватает sentinel.
Сегодня в проекте три типизированные ошибки, и данные несёт только одна:
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel).
## Граница и трансляция: приватный и публичный канал
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**, и
форма зависит от канала и от того, кто его видит:
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
API). Сюда отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
найти полную ошибку в логах. «При обработке задачи произошла ошибка, job_id
= …», а не «произошла ошибка» и не сырой текст.
**Ключ есть не у всякого транспорта, и это называется вслух.** Отказ приёма
случается до заведения задачи, и ключа у него нет вовсе — тогда сообщение
остаётся без якоря, а диагностика ищется по записи доменной границы.
Заводить транспорту собственный идентификатор запроса ради ключа — решение
уровня спеки, а не умолчание;
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
HTTP и веба:
| Доменная ошибка | Статус | Сообщение |
| --- | --- | --- |
| задача не найдена | 404 | «задача не найдена» |
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» |
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go`
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на
любую ошибку заведения задачи.
### Разовый ответ и сохранённая диагностика
У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок
авторизации. Ошибка транспорта может нести URL с токеном внутри, и её
вычищают на границе клиента;
- это **не** канал для разовых отказов — те остаются нейтральными;
- **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
пользователь Telegram видит отдельный человекочитаемый текст — это часть
правила соблюдена.
## panic
- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
границы **нет**: паника в шаге конвейера роняет процесс целиком.
## Несколько ошибок
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
+248
View File
@@ -0,0 +1,248 @@
# Логирование
Конвенция: *как* и *когда* писать логи в transcriber. Это правила оформления
кода (How), а не спецификация поведения — наблюдаемые требования к логам (что
система обязана залогировать как часть контракта capability) живут в спеках
OpenSpec.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: обработчик текстовый, а не JSON; уровень зашит `INFO` и не
настраивается; `msg` — предложение с заглавной буквы, а не константная
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова.
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не
включены, поэтому правилами не выражено ни одно из перечисленного ниже.
## Принципы
- Структурированный JSON (`slog.JSONHandler`), один формат для разработки и для
продакшена.
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
отдельный ключ с типизированным значением: это даёт отбор и сведение через
`jq` без регулярных выражений.
```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
## Сообщение
- `msg` — короткая константа в нижнем регистре: `job accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет.
*Расхождение:* сегодня `msg` — предложение вида `Starting conversion job`,
поля `capability` нет, отдельной категории перехода нет.
## Уровни
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько громко
сломалось». `slog` даёт четыре уровня; их и используем.
| Уровень | Кому и когда | Примеры в transcriber |
| --- | --- | --- |
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
Правила:
- Уровень **не зависит от capability**: `ERROR` в приёме и в распознавании
одинаково серьёзны.
- `WARN` не значит «ничего страшного». `WARN` значит «может стать проблемой».
Если это не «может» — это `INFO`.
- Меняется адресат — меняется уровень. Негодный ввод от пользователя — это
`DEBUG` (норма, владельцу разбирать нечего), а не `ERROR`.
- **Событийное — `INFO`, рутинно-частое — `DEBUG`.** Операция по реальному
действию (приём записи, запуск распознавания, отправка текста) идёт на `INFO`.
Повторяющаяся служебная операция, которую запускает таймер или опрос и которая
сама по себе события не несёт (проверка здоровья, пустой прогон воркера,
опрос готовности операции), — на `DEBUG`: на `INFO` она зашумляет разбор.
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
завершаем процесс с ненулевым кодом.
*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
## Время
- Поле — `time` (ключ `slog` по умолчанию).
- UTC, RFC 3339 с долями секунды, суффикс `Z`.
- Логи — **в UTC**, как и хранение в БД: это даёт однозначный порядок событий и
лексикографическую сортировку. Часовой пояс есть только у **отображения**.
## Поля: словарь имён
Главное условие — **единый словарь**: одно поле, одно имя по всему коду.
- Доменные поля — плоский `snake_case`.
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
`ext.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`; пока их нет, поле не заполняется), `job_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично.
## Корреляция по id сущности
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
осмысленные ключи: идентификаторы задачи и файла, они лежат в базе.
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
Для задачи — логгер с уже подставленным ключом, протаскиваемый сквозь стадии,
чтобы ключ дописывался на каждую запись сам:
```go
log := log.With("job_id", job.Id, "capability", "conversion")
```
- Все записи одной задачи собираются одним отбором:
`jq 'select(.job_id=="…")' app.jsonl`.
## Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
накапливается в цепочке `%w`.
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`).
- Транспорты переводят возвращённую ошибку в свой ответ и **не логируют** её
повторно — иначе один сбой даёт дубли.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой доменной
ошибки ровно один логирующий; уровень выбирает он.
| Класс отказа | Кому | Уровень |
| --- | --- | --- |
| негодный ввод, задача не найдена, действие сейчас недопустимо | пользователю (он уже получил ответ на поверхности) | `DEBUG` |
| запись распознана пустой, задача досталась повторно | владельцу, «может стать проблемой» | `WARN` |
| сбой БД, диска, недоступность внешнего сервиса | владельцу, в разбор | `ERROR` |
- **Повторяющийся сбой фонового цикла — `WARN`, а не `ERROR`.** Одиночный
промах шага временный: задача останется в своём состоянии, и следующий тик
повторит. Тот же класс сбоя в синхронной операции приёма — `ERROR`, потому что
операция провалилась целиком и повтора нет. Уровень задаёт не текст ошибки, а
наличие штатного повтора.
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о поведении
зависимости, а не дубль доменной ошибки.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf`
мимо `slog` целиком.
## Внешние сервисы: логируем все вызовы
**Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``telegram`, `speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были.
Уровни вызова:
- `INFO` — успешный **событийный** вызов (заливка объекта, запуск распознавания,
отправка сообщения, конвертация);
- `DEBUG` — успешный **рутинно-частый** вызов (опрос готовности операции,
длинный опрос обновлений);
- `WARN` — попытка не удалась, делаем повтор;
- `ERROR` — повторы исчерпаны либо сервис недоступен. Завершённый ответ с 4xx —
это успех на транспортном уровне; решение «это ошибка» принимает доменный
вызывающий.
Тело запроса и ответа — только на `DEBUG` и **после** вычистки секретов.
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются
никак.
## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением,
линтером не выражается.
- **`GET /health` и `GET /metrics` логируем на `DEBUG`** — их дёргают
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся.
*Расхождение:* `sloggin` пишет все запросы одинаково, `/health` и `/metrics`
попадают в лог наравне с остальными.
## Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
переписки. Логируем длину текста, а не текст.
Дополнительно:
- Тела запросов и ответов внешних сервисов — только на `DEBUG`, с вычисткой
секретов и обрезкой по длине.
- При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
строки лога.
## Куда пишем и уровень
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
раскладываем по файлам.
- Базовый уровень в продакшене — `INFO`; `DEBUG` включается конфигом при
необходимости. При разработке — `DEBUG`.
*Расхождение:* поля конфигурации под уровень лога нет.
## Анализ
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+146
View File
@@ -0,0 +1,146 @@
# Веб-UI (htmx)
Конвенция: *как* мы пишем код веб-UI — частичная замена фрагментов, опрос живых
обновлений, обработчики действий, деградация без JS, ошибки. Это правила
оформления кода (How), а не спецификация поведения — что именно UI показывает и
какие действия обязан поддерживать, живёт в спеке OpenSpec.
**Взято из проекта jellybit и записано наперёд: веб-UI в transcriber нет вовсе.**
Есть только HTTP API на gin. Ни одного расхождения назвать нельзя — нечему
расходиться; правила действуют с первой страницы, которую заведём.
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
наружу — [errors.md](errors.md). Здесь — только особенности htmx-транспорта, без
повторения.
## Стек и границы
htmx-first: `gin` плюс `html/template` (рендер на сервере) плюс htmx. Ничего
сверх этого: **без шага сборки, без Node и сборщика, без реактивных
фреймворков**. htmx вендорится и раздаётся с нашего же хоста (`go:embed`,
`/static/vendor/`), без CDN.
- Свой JS сведён к минимуму: только то, чего серверу знать не нужно.
**Клиентского пересчёта доменного состояния нет** — состояние считает сервер,
клиент лишь подменяет присланную разметку.
- Alpine.js и SPA сознательно **не вводим**. Понадобится реактивный клиентский
виджет — вводим отдельным изменением и записываем решение, не раньше.
## Единый источник разметки: партиал равен странице равен фрагменту
Переиспользуемый кусок — это `{{define "name"}}` в каталоге партиалов. Тот же
`{{define}}` рендерится **и** внутри страницы (`{{template "name" .}}`), **и**
как ответ-фрагмент того же обработчика. Отдельной разметки под фрагмент не
заводим — иначе она разъедется со страницей.
**Инвариант: корень `{{define}}` — это элемент с целевым `id`**, например
`#job-{id}`. `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный
фрагмент не несёт тот же корневой `id`, следующее действие или опрос не найдёт
цель. Разметку и `id` держим в одном партиале.
Сборку данных для шаблона выносим в отдельную функцию и зовём её и на полной
странице, и во фрагменте — чтобы htmx-ветка не копировала сборку.
## Обработчик действия: ветвление htmx и редирект
htmx-запрос определяем по заголовку `HX-Request: true`.
Обработчик действия зовёт доменную операцию **одинаково** в обеих ветках, а
дальше ветвится: без htmx — привычный редирект после POST (303); с htmx —
перечитать актуальное состояние, собрать данные тем же сборщиком и отдать
фрагмент.
Рендер именованного шаблона идёт **в буфер** и только затем пишется в ответ: при
ошибке шаблона клиент не получит полстраницы.
## Деградация без JS обязательна
Формы действий остаются обычными `<form method="post" action="...">`, а
`hx-post`, `hx-target` и `hx-swap` лишь **накладываются сверху** на ту же форму.
Без JS всё работает через POST и редирект. Атрибут `action` — рабочий запасной
путь, а не украшение.
Фильтр, поиск и разбиение списка на страницы — **серверные**, параметрами
запроса, тоже без JS. Клиентской фильтрации нет намеренно.
## Ошибки на htmx-пути: HTTP 200 и фрагмент
htmx по умолчанию **не подменяет DOM на ответы 4xx и 5xx**. Поэтому при ошибке
действия обработчик отвечает **200 с фрагментом**, несущим сообщение. Доменную
ошибку на htmx-пути **не** транслируем в HTTP-статус — в отличие от API и от
пути без JS.
- Сообщение — нейтральный текст публичного канала (см. [errors.md](errors.md));
сырой `err.Error()` наружу не идёт.
- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая доменные
поля: непустое доменное поле перекрыло бы сообщение.
- **При ошибке активное состояние не меняем** — перечитанные данные показывают
прежний выбор плюс сообщение.
## Живой опрос
Приём живого обновления: эндпоинт фрагмента плюс в разметке `hx-get`,
`hx-trigger="every Ns"` и `hx-swap="outerHTML"`. Прямой предмет опроса в
transcriber — карточка задачи, пока та не дошла до `done` или `failed`.
- **Один опросчик на обновляемый корень.** Опрашивает себя корень поверхности, а
вложенные живые области своего `hx-get` **не несут**: подмена корня уносит их
вместе с таймером, и два опроса подменяли бы разметку друг друга.
- **Опросчик самозавершается.** Опрос идёт, пока предмет может измениться без
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
больше не опрашивает. Для задачи это значит: `created`, `converted` и
`transcribe` наблюдаемы, `done` и `failed` — нет.
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
`200` и фрагментом с объяснением **без `hx-*`**: htmx не подменяет DOM на
`4xx` и `5xx`, поэтому статус ошибки оставил бы поверхность навсегда прежней, а
опрос — бесконечным. Фрагмент отказа обязан нести корневой `id` того узла,
который он собой заменяет.
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный повтор;
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
- **Подмена всего фрагмента через `outerHTML`** удаляет старый узел вместе с его
опросчиком, и htmx заново размечает новый — двойного опроса нет **при условии
совпадения корневого `id`**. Эфемерное состояние разметки подмену не
переживает: то, что должно пережить тик (раскрытый `<details>`), помечается
`hx-preserve`.
- **Частота — по цене тика, и она называется числом** в таблице настроек
[../database.md](../database.md) в тот же момент, когда заводится первый
опрашиваемый экран; сегодня такой настройки нет. Опрашивать чаще, чем меняется
источник, бессмысленно: задачу двигает воркер с шагом в секунду.
- **Тик ходит в БД, и это цена решения.** Читать состояние задачи дешевле, чем
держать снимок в памяти, но каждый открытый браузер добавляет запросов.
## Подмена сохраняет контекст; выход — навигация
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные фильтр,
поиск и страницу (они в параметрах запроса). Действие **не должно уводить**
пользователя со страницы, если предмет остаётся на ней.
Действие, после которого предмет **покидает** страницу (удаление записи),
остаётся **обычной POST-формой без `hx-*`**, то есть полной навигацией. Признак
«это выход» — форма без htmx-атрибутов; так не нужен `HX-Redirect`, а «уйти с
экрана» выражено самой навигацией.
**Асинхронные действия.** Доменное действие асинхронно почти всегда: загрузка
записи только заводит задачу, работу доделывают воркеры. Подмена отдаёт
**промежуточное** состояние, а не мнимый результат; готовый итог догоняем
самозавершающимся опросом. Мгновенный итог в UI не обещаем.
## Различение поверхности одного действия
Один и тот же роут действия, вызванный с разных страниц, отдаёт разные фрагменты.
Различаем **явным скрытым полем формы** `surface=list|detail`, а не догадкой по
`HX-Target` или `Referer`: поле самодокументируемо и не зависит от разрешения
цели.
## Статика, вендоринг, кэш
- Ресурсы встроены через `go:embed`, отдаются под `/static/` с длинным
неизменяемым кэшем (`Cache-Control: public, max-age=31536000, immutable`).
- Меняемые ресурсы (css, js) версионируются параметром `?v=<версия>` — коротким
sha256 их содержимого, URL строит помощник шаблона. Свежая выкладка не отдаёт
устаревший файл.
- Вендор (htmx, шрифты) адресуется по **неизменному имени файла**, и параметр
версии ему не нужен. В git его **не коммитим**; задача сборки идемпотентно
добывает его по манифесту со сверкой sha256.
- Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен,
внешних ресурсов времени выполнения нет.
+101
View File
@@ -0,0 +1,101 @@
# Схема хранилища
СУБД, миграции, правило времени и идентификаторов.
СУБД — SQLite, драйвер `mattn/go-sqlite3` (нужен CGO). Запросы строит
`doug-martin/goqu` с диалектом `sqlite3`. Миграции — `pressly/goose`, каталог
`migrations/`, вшит в бинарник через `//go:embed migrations/*.sql` в `main.go` и
накатывается при старте. Новый файл достаточно положить в каталог.
**Идентификаторы** — UUID v4 строкой.
**Время** — локальная зона процесса, UTC не навязан. Колонки `created_at` и
`updated_at` проставляет приложение, а не СУБД; умолчание `CURRENT_TIMESTAMP`
стоит только у `files.created_at`.
Того, что единой точки генерации идентификатора и времени нет, здесь не
повторяем: перечень единых точек и их отсутствий держит
[architecture.md](architecture.md), «Единые точки проекта».
Переход на PocketBase запланирован, но не начат — здесь описано сегодняшнее
состояние. Открытые вопросы перехода — в
[architecture.md](architecture.md), раздел «Открытые вопросы».
## Таблицы
### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — три разные записи.
| Колонка | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | UUID файла |
| `storage` | TEXT | `local` или `s3` |
| `file_name` | TEXT | Имя в хранилище: UUID с расширением |
| `size` | INTEGER | Размер в байтах |
| `created_at` | DATETIME | Умолчание `CURRENT_TIMESTAMP` |
### `transcribe_jobs`
Задача расшифровки и она же очередь.
| Колонка | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | UUID задачи |
| `state` | TEXT | `created`, `converted`, `transcribe`, `done`, `failed` |
| `source` | TEXT | `api`, `telegram`, `unknown`; умолчание `unknown` |
| `file_id` | TEXT FK → `files.id` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
| `acquisition_id` | TEXT | Кто захватил задачу |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
| `transcription_text` | TEXT | Результат распознавания |
| `is_error` | BOOLEAN | Задача с `1` из выборки исключена навсегда |
| `error_text` | TEXT | Текст ошибки, машинный |
| `tg_chat_id` | INTEGER | Куда отправить результат |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
| `created_at`, `updated_at` | DATETIME | Проставляет приложение |
Индексов, кроме первичных ключей, нет. Выборка воркера идёт полным перебором по
`state`, `is_error`, `delay_time` и `acquire_time`.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в колонке `transcription_text`** одной строкой.
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
целиком при каждом `GetByID` и при каждом захвате задачи воркером.
- **Аудио в базе не лежит.** На диске — каталог `data/files`, плоский, имя файла
равно UUID с расширением. Ни файлы, ни объекты в Object Storage не удаляются
после завершения задачи: каталог и бакет растут неограниченно.
- **Захват задачи — два запроса подряд, не транзакция.** Сперва `UPDATE …
WHERE id = (SELECT … LIMIT 1)` проставляет `acquisition_id`, затем отдельный
`SELECT … WHERE acquisition_id = ?` читает строку. Репозиторий сверяет число
затронутых строк с ожидаемым, но между запросами задачу может перехватить
другой воркер с тем же значением — на одном процессе это не наблюдалось.
- **Список колонок задан не одним местом** — четырьмя запросами файла
`internal/adapter/repo/sqlite/transcript_job_repo.go`. Правило правки всех
четырёх и его severity — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
## Настройки с числовым значением
| Настройка | Значение | Где |
| --- | --- | --- |
| Срок захвата, конвертация и распознавание | 1 час | `service/transcribe.go`, вызовы `findJob` |
| Срок захвата, проверка операции | 24 часа | там же |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` |
| Задержка между проверками операции | 5 секунд | там же |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` |
| Память под multipart-загрузку | 32 МиБ | `main.go`, `router.MaxMultipartMemory` |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` |
Чего среди настроек **нет**: режим журналирования SQLite не задан (значение по
умолчанию, не WAL), таймаут занятости не задан, размер пула соединений не задан,
срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и
SpeechKit тоже нет — ни одного.
+73
View File
@@ -0,0 +1,73 @@
# Паспорт проекта
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «в каком порядке», паспорт —
«зачем и для кого».
## Цель
Превращать записанную речь в текст, который можно читать и искать.
**Потребители** — список закрытый: он определяет, что считать нужным, а что
интересным.
| Кто | Что ему нужно от нас |
| --- | --- |
| Владелец сервиса | Отправить голосовое сообщение из Telegram и получить текст ответом. Работает сегодня |
| Приглашённый пользователь | Войти в веб через свою учётную запись, загрузить запись, забрать текст. Каждый видит только свои записи |
| Внешняя программа | Отдать файл по HTTP и опросить готовность. Работает сегодня, без разграничения доступа |
Цель достигнута, когда:
- запись любого распространённого формата принимается без предварительной
подготовки, включая дорожку из видео;
- запись длиной в несколько часов доходит до текста, а не прерывается ошибкой при
достижении предела;
- сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали, — в вебе и в Telegram.
## Что целью не является
Граница домена. По ней в теме `architecture` судят, не перенесено ли понятие
через границу.
- **Редактор текста.** Расшифровку отдаём как есть; правка, разметка, экспорт в
форматы документов — не наша работа.
- **Хранилище записей.** Отдаём текст и на этом заканчиваем: библиотекой, архивом
и поиском по прошлым записям сервис не становится. Сколько запись лежит на
диске после обработки — вопрос срока хранения, а его нет вовсе:
[database.md](database.md), «Представление данных».
- **Понимание сказанного.** Пересказ, выжимка, ответы на вопросы по записи, поиск
по смыслу — за границей: мы отдаём текст, а не выводы из него.
- **Собственное распознавание.** Модель не обучаем и не держим у себя, речь
распознаёт внешний сервис.
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
провайдер, свою регистрацию и свои пароли не делаем.
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем.
## Типовые сценарии
1. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
отвечает «обрабатываю», через минуту приходит текст ответом на то же
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
несколькими частями.
2. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
отличает их по MIME-типу и расширению.
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio`, получает идентификатор
задачи и опрашивает `GET /api/status/:id`, пока не увидит `done` и текст.
4. **Отказ на середине.** Конвертация или распознавание не удались — задача
переходит в `failed`, а пользователь Telegram получает сообщение о том, что
именно не вышло, и предложение повторить.
## Референсы
Где смотреть prior art, когда упёрлись.
- **Yandex SpeechKit, отложенное распознавание** — модель `deferred-general`,
которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи.
- **PocketBase** — кандидат в хранилище взамен сегодняшнего SQLite. Источником
учётных записей его не рассматриваем: вход решено делать через OIDC у Authelia
([architecture.md](architecture.md), «Открытые вопросы»).
+22
View File
@@ -0,0 +1,22 @@
# Разведка
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
расходится с практикой. Источник истины — этот каталог, а не чужая документация.
**Каждый вывод — с числами и командой, которой получен**, чтобы его можно было
перепроверить.
## Как снималось
Ничего не снималось. Записей нет: замеров на живом потоке не делали, поведение
внешних сервисов на границах не проверяли.
Внешних источников, о которых разведка нужна, четыре — Telegram Bot API, Yandex
SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, что стоит
открытыми вопросами в [../architecture.md](../architecture.md) — «Долгие
записи», «Формат для распознавания», «Видео». Первый же ответ на любой из них
заводит здесь запись с командой и условиями замера.
## Записи
Записей нет.
+203
View File
@@ -0,0 +1,203 @@
# Ревью: настройка и журнал
## Как настроен конвейер
Конвейера ревью в проекте пока нет: плагин не подключён, ни одного прогона не
было. Раздел заполнен наперёд по коду — он и служит настройкой первому прогону.
### Типовые узлы
Рода узлов проекта и проверяемые свойства к каждому.
**Шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`):
- отличает «задач нет» от отказа и не считает первое ошибкой;
- при отказе на середине оставляет задачу в состоянии, из которого повтор
корректен, либо переводит в `failed` осознанно;
- не теряет ссылку на файл: `job.FileID` переставляется только после того, как
запись о новом файле создана;
- повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
- проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
- различает «сервис ответил отказом» и «сервис недоступен»;
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча.
**Репозиторий SQLite** (`adapter/repo/sqlite`):
- список колонок совпадает во всех четырёх запросах файла;
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`;
- захват задачи не выдаёт одну строку двум вызывающим;
- ошибка драйвера транслируется в доменную у источника.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`):
- отсутствие программы в `PATH` отличается от отказа обработки;
- вход, пришедший от пользователя, не попадает в аргументы командной строки
неразобранным;
- пустой или частично записанный выходной файл считается отказом;
- процесс не висит вечно.
**Любой узел** — сверх свойств своего рода:
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
становится неотличим от привычного шума (журнал, запись 2026-08-10).
### Типовые ложноположительные
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
логирует его и не считает в метрику. Настоящий дефект рядом другой — проверка
идёт приведением типа и сломается при первой же обёртке; он уже записан в
[conventions/errors.md](conventions/errors.md).
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.
Находка становится настоящей ровно тогда, когда появится второй экземпляр
процесса или второй воркер на то же состояние.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой
[security.md](security.md). Находкой считается только новая поверхность,
выставленная наружу, а не повторение этого факта.
### Вопросы по темам
Форма: `<тема>: <вопрос> (<провенанс>)`.
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
`transcribe.go`, 2026-08-10).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
2026-08-10).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10).
- `security`: не попал ли в лог текст расшифровки, имя файла пользователя или
URL с токеном бота (запрет в [security.md](security.md) и
[conventions/logging.md](conventions/logging.md)).
- `security`: не строится ли путь на диске или ключ объекта из значения,
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
(чтение `service/transcribe.go`, 2026-08-10).
- `architecture`: не появился ли второй путь приёма мимо
`createTranscribeJob` — сегодня через него идут оба входа
([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
спек ещё нет, и соблазн описать поведение в обзоре максимальный.
- `conventions`: новая колонка правится во всех четырёх местах репозитория
(CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
тестов два файла, и оба мимо конвейера.
### Триггеры метки
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
**Крупное здесь** (поднимает до `large`, ось объёма):
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
колонку разом;
- замена хранилища или переход на PocketBase — любой её кусок;
- введение веб-UI: транспорт, шаблоны и статика одновременно;
- изменение, трогающее оба входа сразу — Telegram и HTTP.
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
пользователь веба, до начала работы назвать нельзя;
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
форма решения зависит от замера;
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
- всё, что требует записи в `research/` прежде, чем начать.
**Мелкое здесь** (опускает до `small`):
- правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`;
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона.
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
ни были, они не `small`.
### Недоступно проверке
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно:**
Ничего не отключали — проверять пока и не начинали.
## Журнал дефектов
Первая запись найдена прогоном гейта при заведении канона 2026-08-10, две
нижние восстановлены по истории git тогда же. Все три помечены `проскочил`:
ревью тогда не было, и поймать их было некому. У восстановленных нет поля «Чем
воспроизведён», и выдумывать его задним числом нельзя.
## 2026-08-10 — тесты http-обработчика ни разу не были зелёными [проскочил]
- **Где:** `internal/controller/http/transcribe_test.go`
- **Симптом:** `go test ./...` падает четырьмя случаями; обнаружено первым же
прогоном гейта при заведении канона
- **Причина:** тест требует `testdata/sample.m4a`, которого в репозитории нет и
не могло быть — `.gitignore` содержит `*.m4a`. Остальные случаи записывают в
файл строку `test audio content` и ждут `201`, а обработчик зовёт настоящий
`ffprobe`, который такой вход отвергает
- **Чем воспроизведён:** `go test ./internal/controller/http/` — четыре отказа,
из них один по отсутствию файла и три по коду `500` вместо `201`
- **Почему не поймали:** гейта не было вовсе, а `go test` руками, судя по
результату, не гоняли ни разу с коммита `87d8b05`
- **Что меняем:** заведена задача `http-handler-tests-never-green`; в гейт
добавлен шаг `go test ./...`, и красный тест теперь виден. Настоящий остаток
шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в
типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в
вопросы темы `autotests` — вопрос про изменённый шаг конвейера
## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]
- **Где:** `internal/service/transcribe.go`, ветка завершения задачи
- **Симптом:** пользователь Telegram получал пустое сообщение вместо текста
- **Причина:** SpeechKit возвращал операцию успешной, но с пустым текстом, и
задача завершалась этим пустым значением
- **Чем воспроизведён:** восстановлено по коммиту `ec637c0`, оракула нет
- **Почему не поймали:** конвейера ревью не существовало
- **Что меняем:** уже сделано — пустой текст подменяется фразой «на записи нет
текста». Настоящий остаток в другом: свойство «вырожденный ответ внешнего
сервиса не превращается в успех молча» вынесено в типовой узел «клиент
внешнего сервиса» выше
## 2025-08-17 — длинная расшифровка не доходила до пользователя [проскочил]
- **Где:** `internal/adapter/telegram/sender.go`
- **Симптом:** отправка текста длиннее предела сообщения Telegram завершалась ошибкой
целиком, пользователь не получал ничего
- **Причина:** предел длины сообщения на стороне Telegram не учитывался
- **Чем воспроизведён:** восстановлено по коммиту `822e168`, который тем же
заходом завёл `internal/adapter/telegram/split_test.go`
- **Почему не поймали:** конвейера ревью не существовало
- **Что меняем:** уже сделано — деление по словам с пределом 4000 символов,
число записано в [database.md](database.md)
+91
View File
@@ -0,0 +1,91 @@
# Модель угроз
## Периметр
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против
этого — сегодняшнего — периметра.
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, и каждый
пользователь видит только свои записи. Он **не** развёрнут; описанное ниже
разграничение доступа относится только к Telegram.
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio`
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу
запросов и не ограничен по размеру файла.
## Недоверенный вход
Что приходит извне и каким каналом.
| Вход | Канал | Кто может слать |
| --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета |
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
## Из чего строятся пути и ключи
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался.
- **Путь на диске** — `filepath.Join(cfg.Storage.Path, fileId + ext)`, где
`fileId` наш UUID, а **`ext` берётся из имени файла отправителя** через
`filepath.Ext`. Расширение в путь попадает без проверки списком; `filepath.Ext`
режет по последней точке и не пропускает разделитель каталогов, но это
единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет.
- **Каталог** один и плоский: `data/files` целиком, вложенности нет.
- **Идентификатор задачи** — UUID v4. Он же единственное, что защищает
`GET /api/status/:id`.
## Что разграничивает доступ
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу.
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с
остальным.
Владения записью в модели данных нет: у задачи нет пользователя. Пока API
анонимен, знание UUID задачи и есть право её читать.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть.
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
4. **Белый список пользователей** — сам по себе перечень имён.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
Тексты расшифровок и имена файлов в логи не пишутся — логируется длина текста и
идентификаторы. Токен бота попадает в URL скачивания файла (`file.Link(token)`),
и этот URL нигде не логируется.
## Что вне модели
Перечислить явно.
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
SpeechKit не рассматривается.
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
внешней программе, своей песочницы вокруг неё нет.
- **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются
вовсе; забвение не реализовано и в задачах не стоит.
+66
View File
@@ -0,0 +1,66 @@
schema: spec-driven
context: |
Language: Russian
Пиши на русском, но:
- Структурные заголовки оставляй на английском:
## ADDED/MODIFIED/REMOVED Requirements, ### Requirement:, #### Scenario:
- Ключевые слова GIVEN/WHEN/THEN и RFC 2119 (SHALL/MUST/SHOULD) — на английском
- Технические термины, пути и код — на английском
Имена capabilities:
- Capability — это ПОВЕДЕНИЕ или домен системы, а не пакет кода (совпадение с
именем пакета допустимо, но не критерий).
- Существительное, понятное без знания кода: intake, recognition, delivery —
это примеры формы, а не список проекта. НЕ service/httpapi/tg: это
реализация.
- Гранулярность по принципу «требования меняются вместе». Дробить, когда в
одной спеке смешиваются разные заботы. Переименовать дёшево (RENAMED
Requirements) — не дроби преждевременно в маленьком проекте.
RFC 2119 — требование валидатора, не стиль:
- Каждое ### Requirement ОБЯЗАНО содержать литерал SHALL или MUST, иначе
`openspec validate` падает. Поэтому эти слова и WHEN/THEN не русифицируем.
Состояние спек и правило «первая задача, трогающая поведение, заводит спеку
своей capability» — docs/architecture.md, преамбула.
Что это за проект — читай перед предложением, а не отсюда:
- docs/passport.md — цель, её граница (чем проект НЕ является), потребители,
типовые сценарии, референсы;
- CLAUDE.md — инварианты с severity и семантика гейта;
- docs/architecture.md — устройство; docs/security.md — периметр;
docs/database.md — схема и настройки с числами; docs/adr/ — почему решено
так; docs/research/ — что уже измерено;
- tasks/ROADMAP.md — что приложение уже умеет и чего ещё не умеет.
Пересказа этих документов здесь нет намеренно: второй дом факта расходится с
первым молча, и заметно это становится в предложении, которое уже написано.
Ревью: правило выбора метки и состав проходов здесь не пересказываем — их дом
скилл av-dev-code:review, проектная настройка — docs/review.md.
Конвенции кода: механизированное проверяет гейт, прозой остаётся
docs/conventions/. Ни состав шагов гейта, ни перечень конвенций здесь не
пересказываем: и то и другое растёт по ходу задач.
Развилка или блокер — сперва prior art. Готовые решения смотрим в референсах
паспорта, отвергаем — с названной причиной, и причина идёт в design.md этого
же изменения.
rules:
proposal:
- Capabilities называй по поведению или домену системы, не по пакету кода
- "Why и What Changes — языком домена из docs/passport.md: без SHALL, без имён модулей и функций там, где вещь называется по-русски"
design:
- "Назови рассмотренные варианты и причину отказа от каждого — из них потом пишется ADR"
- "Решение объясняется через то, что человек увидит иначе, а не через устройство кода"
specs:
# Кавычки обязательны: без них YAML обрежет строку на первом '#'.
- "Каждое ### Requirement обязано содержать SHALL или MUST (иначе валидация падает)"
- "Сценарий — ровно #### (четыре решётки); три или список молча теряются"
- "SHALL/MUST должно стоять в ПЕРВОМ абзаце требования: валидатор смотрит только его"
- "Заголовки и WHEN/THEN/GIVEN — на английском, остальной текст на русском"
tasks:
- "Критерии приёмки задачи — отдельным блоком и дословно: файл задачи закрытие удалит, критерии обязаны его пережить"
- "Рубрика ревью дизайна, если оно её дало, идёт в тот же блок: приёмка судится по одному списку, а не по двум"
- "Шаг плана формулируется проверяемо — по нему видно «сделано / не сделано» без суждения"
+27
View File
@@ -0,0 +1,27 @@
# Беклог
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
+ строка здесь. Целей тут нет — они в [ROADMAP.md](ROADMAP.md): беклог — то, что берут,
роадмап — то, подо что берут. **Порядок строк внутри секции значим:**
это очередь, и первая строка — то, что делают следующим. Порядок
назначает человек на груминге, машина его не выводит. Одно исключение
производно от типа — сырьё (`research` без раздела «Вопрос»)
стоит в конце секции: его не берут. Ведётся скиллом `tasks`.
Тип записи стоит первым полем меты и решает, что у неё может быть:
`feature` 🐞 `fix` 🧹 `chore` 🔬 `research`
Секции «блокеры» здесь нет и не заводится: блокер — это состояние
(работа не может продолжаться ни одной задачей), оно живёт до ответа
человека, а его следы — вопросами в файлах задач.
## Ядро
- [🧹 Сравнивать доменные ошибки через errors.As](items/errors-as-instead-of-typecast.md) — NoopJobError и JobNotFoundError проверяются приведением типа: первая же обёртка %w между слоями сломает проверку молча.
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
- [🐞 Починить тесты http-обработчика, ни разу не бывшие зелёными](items/http-handler-tests-never-green.md) — go test ./... падает на master: тесты требуют файла, которого нет в репозитории, и ждут 201 от ffprobe, которому скормили строку.
## Инфра
- [🧹 Перевести хранилище на встроенный PocketBase](items/pocketbase-storage.md) — Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего.
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
+7
View File
@@ -0,0 +1,7 @@
# Ушедшее без реализации
Задачи, покинувшие беклог **без реализации**, с причиной и датой.
Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них
есть коммит. Это первое место, куда смотрит дедупликация при заведении.
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
+38
View File
@@ -0,0 +1,38 @@
# Роадмап
Состояние проекта: что приложение **уже умеет** и чего ещё не умеет.
Цель — возможность приложения: файл типа `goal` (🎯) в
`items/`. Её задачи здесь **не перечисляются** — перечень даёт
`tasks.py list --goal <слаг>`.
- **Запланировано** — очередь значима и обосновывается прозой;
- **Направления** — очереди нет, тянутся долго;
- **Сопровождение** — чем держат проект: инструмент,
процесс, эксплуатация. Не возможности приложения, и отдельно —
чтобы не читаться как обещание продукта;
- **Готово** — достигнутое: строку пишет
`tasks.py close <цель> --implemented`, ссылки на файл в ней нет —
файл удаляется, поведение живёт в спеках. Стоит последней: копится.
Секции **канонические** и переименованию проектом не подлежат:
у каждой свой смысл, и в достигнутое пишет сам `close`. Порядок
тоже канонический. Английский
вариант — Planned | Directions | Operations | Done, один язык на весь
индекс.
## Запланировано
- [🎯 Записи загружаются и читаются в браузере](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
- [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
## Направления
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
- [🎯 Запись длиной в несколько часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны.
## Сопровождение
## Готово
- 2025-08-14 `telegram-transcription` — Голосовое сообщение из Telegram возвращается текстом. Основной вход сервиса: бот принимает голосовое, аудиофайл и документ с аудио и отвечает расшифровкой.
- 2025-08-08 `api-transcription` — Запись, отданная по HTTP, возвращается текстом. Программный вход: файл отдаётся формой, готовность и текст забираются опросом статуса задачи.
+19
View File
@@ -0,0 +1,19 @@
# 🎯 Принимается запись любого формата, включая дорожку из видео
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
контейнера или видео, из которого нужна только речь. Подготовка на стороне
пользователя не требуется.
## Завершение
1. Перечень принимаемых форматов замерен и записан в `research/`, а не выведен
из документации ffmpeg.
2. Видеофайл принимается, и из него берётся звуковая дорожка.
3. Формат, который принять нельзя, отклоняется на приёме — с текстом, из
которого понятно почему, а не отказом на конвертации через минуту.
4. Расхождение ogg/vorbis против заявленного SpeechKit `OGG_OPUS` разобрано:
либо устранено, либо записано как проверенно безвредное.
@@ -0,0 +1,35 @@
# 🧹 Сравнивать доменные ошибки через errors.As
- **Тип:** chore
- **Категория:** Ядро
- **Зачем:** NoopJobError и JobNotFoundError проверяются приведением типа: первая же обёртка %w между слоями сломает проверку молча.
Сегодня это работает только потому, что ошибку на этом пути никто не
оборачивает. Сломается тихо: воркер перестанет отличать «задач нет» от отказа и
начнёт писать `ERROR` раз в секунду на каждый из трёх воркеров, а `NoopJobError`
попадёт в метрику отказов.
Правило записано в [conventions/errors.md](../../docs/conventions/errors.md),
линтер на этих двух местах уже красный.
## Затрагивает
- `internal/controller/worker/worker.go`, проверка `NoopJobError`;
- `internal/service/transcribe.go`, метод `findJob`, проверка
`JobNotFoundError`;
- `internal/contract/error.go` — оба типа полей не несут и годятся в sentinel;
- `.golangci.yml` — после правки `errorlint` на этих местах должен молчать.
## Критерии приёмки
- Обе проверки идут через `errors.As` либо через `errors.Is` по sentinel.
Оракул — `golangci-lint run` не даёт замечаний `errorlint`.
- Обёртка `fmt.Errorf("…: %w", err)` в середине пути не ломает распознавание.
Оракул — тест: обёрнутый `NoopJobError` воркер по-прежнему считает пустым
прогоном и не пишет ни лога, ни метрики.
- Метрика `transcriber_worker_job_count` на пустом прогоне не растёт. Оракул —
тот же тест, проверка значения счётчика до и после.
## Рамки
Поведение снаружи не меняется; конвейер не трогаем.
+46
View File
@@ -0,0 +1,46 @@
# 🧹 Задать таймауты обращениям к внешним сервисам
- **Тип:** chore
- **Категория:** Инфра
- **Зачем:** Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
Все обращения наружу идут с `context.Background()` либо через `http.Get` без
клиента со своим таймаутом. Отказ такого рода в
[architecture.md](../../docs/architecture.md) назван условием: собеседник,
который «отвечает медленно», сегодня останавливает воркер целиком, и заметить
это можно только по тому, что задачи перестали двигаться.
Сюда же контекст: воркеры получают `ctx` и отменяют его при остановке, но ни
один шаг конвейера внутрь его не передаёт — мягкая остановка ждёт таймаута
вместо того, чтобы прервать вызов.
## Затрагивает
- `internal/adapter/recognizer/yandex/s3.go` — заливка объекта;
- `internal/adapter/recognizer/yandex/speechkit.go` — запуск распознавания,
чтение потока, опрос операции;
- `internal/controller/tg/tg.go` — скачивание файла через `http.Get`;
- `internal/adapter/telegram/sender.go` — отправка сообщения;
- `internal/contract/contract.go` — интерфейсам нужен `context.Context` первым
аргументом;
- `internal/controller/worker/worker.go` и `internal/service/transcribe.go`
протаскивание контекста в шаг;
- `config.dist.toml` и `internal/config` — числа таймаутов;
- `docs/database.md`, таблица настроек с числовым значением.
## Критерии приёмки
- У каждого обращения наружу есть таймаут, и его значение задаётся конфигом.
Оракул — тест на подставном сервере, который не отвечает: вызов
возвращается с ошибкой за назначенное время, а не висит.
- Отмена контекста при остановке приложения прерывает шаг конвейера. Оракул —
тест: отменённый контекст возвращает управление из шага, задача остаётся в
прежнем состоянии.
- Прерванная по таймауту задача достаётся повторно и доходит до текста. Оракул
— тест на повторный прогон шага после отказа по таймауту.
- Числа таймаутов записаны в `docs/database.md`. Оракул — `task gate`.
## Рамки
Повторов с нарастающей паузой не заводим — это отдельная работа; здесь только
таймаут и отмена.
@@ -0,0 +1,57 @@
# 🐞 Починить тесты http-обработчика, ни разу не бывшие зелёными
- **Тип:** fix
- **Категория:** Ядро
- **Зачем:** go test ./... падает на master: тесты требуют файла, которого нет в репозитории, и ждут 201 от ffprobe, которому скормили строку.
Тесты заведены коммитом `87d8b05` и с тех пор ни разу не проходили: файл
`internal/controller/http/testdata/sample.m4a` в git не попадал никогда, а
`.gitignore` строкой `*.m4a` и не даст его закоммитить. Остальные случаи
записывают в файл строку `test audio content` и ждут `201`, тогда как обработчик
зовёт настоящий `ffprobe`, который такой вход отвергает, — получается `500`.
Отсюда следствие важнее самих тестов: **красный `go test` перестал что-либо
значить**, и любой настоящий отказ в этом пакете теперь неотличим от привычного
шума.
Заодно тесты зовут `os.Chdir`, то есть меняют состояние всего процесса: гонять
их параллельно нельзя, а `errcheck` на этих вызовах молчит только потому, что
`_test.go` вынесен в исключения `.golangci.yml`.
## Воспроизведение
```
go test ./internal/controller/http/
```
Отказов четыре: `TestCreateTranscribeJob_Success` не находит
`testdata/sample.m4a`; `TestCreateTranscribeJob_EmptyFile` и три случая
`TestCreateTranscribeJob_DifferentFileExtensions` получают `500` вместо `201` с
`ffprobe execution failed: exit status 1` в логе.
## Затрагивает
- `internal/controller/http/transcribe_test.go` целиком;
- `.gitignore`, строка `*.m4a` — она же мешает положить настоящую запись в
`testdata`;
- `internal/contract`, `AudioMetaViewer` — подставной вместо настоящего
`ffprobe` в тестах;
- `.golangci.yml`, исключение `errcheck` для `_test.go`, если `os.Chdir` уйдёт.
## Критерии приёмки
- `go test ./...` зелёный на чистом клоне без ручной подготовки файлов. Оракул —
`git clone` во временный каталог и `go test ./...`.
- Тест приёма не зависит от установленного `ffprobe`: метаданные даёт подставной
`AudioMetaViewer`. Оракул — прогон с временно переименованным `ffprobe` в
`PATH`.
- Отказ разбора метаданных проверяется отдельным случаем и ожидает `500`, а не
`201`. Оракул — тот же тест на подставном, возвращающем ошибку.
- Тесты не меняют рабочий каталог процесса. Оракул — `grep -n 'os.Chdir'
internal/controller/http/transcribe_test.go` пуст.
## Рамки
Поведение обработчика не меняем: задача про тесты. Если по ходу выяснится, что
`500` на негодный файл — неверный ответ, это отдельная задача про трансляцию
доменной ошибки.
+23
View File
@@ -0,0 +1,23 @@
# 🎯 Запись длиной в несколько часов доходит до текста
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны.
- **Теги:** decomposed
Лекция, созвон и интервью целиком превращаются в текст. Сегодня неизвестно даже,
на каком звене такая запись отваливается, — цель начинается с замера, а не с
переделки.
## Завершение
1. Потолки каждого звена замерены и записаны в `research/` с командой замера:
приём из Telegram, приём по HTTP, конвертация, заливка в Object Storage,
модель `deferred-general`.
2. Запись, превышающая потолок, отклоняется на приёме понятным текстом, а не
висит в конвейере до истечения захвата.
3. Запись в пределах потолка доходит до текста и не теряет его хвост.
4. Текст в несколько сотен килобайт доходит до получателя: и в браузере, и в
Telegram, где предел сообщения — 4000 символов.
5. Долгая задача не блокирует короткие: запись на три часа не останавливает
конвейер для голосового на десять секунд.
+21
View File
@@ -0,0 +1,21 @@
# 🎯 Сервисом пользуются несколько человек, и записи одного не видны другому
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
Приложение узнаёт, кто к нему пришёл, и показывает каждому только его записи.
Учётные записи заводит и проверяет внешний провайдер — Authelia по OIDC; своей
регистрации и своих паролей не делаем, это граница из
[паспорта](../../docs/passport.md).
## Завершение
1. Неаутентифицированный запрос к записям не проходит: ни к странице, ни к API.
2. У задачи и файла есть владелец, и выборка чужой записи по её
идентификатору возвращает «не найдено», а не содержимое.
3. Вход идёт через OIDC у Authelia; выход из сессии работает.
4. Пользователь Telegram сопоставлен с учётной записью, и записи, пришедшие
ботом, видны ему же в браузере.
5. Белый список Telegram перестаёт быть отдельным механизмом: право писать боту
выводится из учётной записи.
+44
View File
@@ -0,0 +1,44 @@
# 🧹 Перевести хранилище на встроенный PocketBase
- **Тип:** chore
- **Категория:** Инфра
- **Зачем:** Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего.
PocketBase встраивается библиотекой в тот же бинарник и приносит хранилище,
учётные записи и панель администратора разом. Наблюдаемое поведение сервиса
после перевода не меняется: те же два входа, тот же конвейер, тот же текст на
выходе.
Данные не переносим — база заводится с чистого листа, и это решение принято
сознательно.
## Затрагивает
- таблицы `files` и `transcribe_jobs`, каталог `migrations/` и весь механизм
goose;
- `internal/adapter/repo/sqlite` целиком, включая захват задачи через
`FindAndAcquire`;
- `internal/contract`, интерфейсы `FileRepository` и `TranscriptJobRepository`;
- ключ конфигурации `[database] path` и раскладка каталога `data/`;
- сборка образа: PocketBase тянет свой набор зависимостей, а `mattn/go-sqlite3`
с его требованием CGO может уйти;
- `docs/database.md` — схема, представление данных и таблица настроек.
## Критерии приёмки
- Сервис поднимается на чистом каталоге данных, накатывает свою схему сам и
принимает запись обоими входами. Оракул — запуск на пустом `data/` и прогон
записи из Telegram и через `POST /api/audio` до состояния `done`.
- Захват задачи воркером не выдаёт одну запись двум вызывающим. Оракул — тест
на трёх параллельных вызовах захвата по одному состоянию: ровно один
получает запись.
- Задача, брошенная на середине, достаётся снова по истечении срока захвата.
Оракул — тест с проставленным задним числом `acquire_time`.
- `docs/database.md` описывает новую схему, а старые упоминания goose и goqu из
документов канона убраны. Оракул — `task gate`, шаг `docs.py check`.
## Рамки
Данные прежней базы не переносим и не пытаемся сохранить; выкладку не запускаем;
смена формата хранения на сервере необратима, и момент перехода назначает
человек.
+32
View File
@@ -0,0 +1,32 @@
# 🔬 Потолки SpeechKit по длине записи и по формату
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
- **Теги:** goal:long-recordings
Цель «запись длиной в несколько часов доходит до текста» упирается в то, что
неизвестно, на каком звене такая запись отваливается. Начинать с переделки
приёма или с деления записи на куски — разные работы, и выбор между ними
определяет замер, а не рассуждение.
Отдельно висит расхождение: конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает заявку `ContainerAudio_OGG_OPUS`. Работает ли это и что
происходит с записью — не разбиралось.
## Вопрос
Какую самую длинную запись модель `deferred-general` доводит до текста, что
происходит при превышении и какие контейнеры она принимает на самом деле.
## Куда ляжет ответ
`docs/research/speechkit.md` — числами и с командой замера. Ответ на вопрос про
`OGG_OPUS` уходит туда же и снимает открытый вопрос из
[architecture.md](../../docs/architecture.md).
## Рамки
Замер идёт за деньги: распознавание и хранение в Object Storage оплачиваются по
факту. Число прогонов и длину пробных записей назначает человек. Боевые записи
пользователей для замера не берём.
+19
View File
@@ -0,0 +1,19 @@
# 🎯 Записи загружаются и читаются в браузере
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
Приложение получает поверхность, на которой запись загружают и забирают текст,
не открывая Telegram и не вызывая API руками. Конвейер обработки при этом
остаётся прежним — меняется только вход и способ показать результат.
## Завершение
1. Страница принимает файл формой и заводит задачу — ту же, что заводит бот.
2. Состояние задачи видно на странице и обновляется само, пока задача не дошла
до `done` или `failed`; отказ показывается человекочитаемым текстом.
3. Готовый текст читается и копируется со страницы целиком, без деления на
части.
4. Список своих записей открывается и листается.
5. Всё перечисленное работает без JavaScript — формой и переходом по ссылке.