diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md index c6cb9b6..bf23721 100644 --- a/.claude/commands/opsx/apply.md +++ b/.claude/commands/opsx/apply.md @@ -1,15 +1,12 @@ --- name: "OPSX: Apply" description: Implement tasks from an OpenSpec change (Experimental) -allowed-tools: Bash(openspec:*) 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 ` 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** @@ -29,7 +26,6 @@ Implement tasks from an OpenSpec change. ``` 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** @@ -39,7 +35,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Context file paths (varies by schema) - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state @@ -51,7 +47,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read every file path listed under `contextFiles` from the apply instructions output. + Read the files listed in `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 diff --git a/.claude/commands/opsx/archive.md b/.claude/commands/opsx/archive.md index df8a2f2..5e91608 100644 --- a/.claude/commands/opsx/archive.md +++ b/.claude/commands/opsx/archive.md @@ -1,15 +1,12 @@ --- name: "OPSX: Archive" description: Archive a completed change in the experimental workflow -allowed-tools: Bash(openspec:*) 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 ` 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** @@ -29,7 +26,6 @@ Archive a completed change in the experimental workflow. 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`:** @@ -52,7 +48,7 @@ Archive a completed change in the experimental workflow. 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. + Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` @@ -67,19 +63,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: + Create the archive directory if it doesn't exist: ```bash - mkdir -p "/archive" + mkdir -p openspec/changes/archive ``` Generate target name using current date: `YYYY-MM-DD-` **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 + - If no: Move the change directory to archive ```bash - mv "" "/archive/YYYY-MM-DD-" + mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- ``` 6. **Display summary** @@ -98,7 +94,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs All artifacts complete. All tasks complete. @@ -111,7 +107,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** No delta specs All artifacts complete. All tasks complete. @@ -124,7 +120,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** Sync skipped (user chose to skip) **Warnings:** @@ -141,7 +137,7 @@ Review the archive if this was not intentional. ## Archive Failed **Change:** -**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Target:** openspec/changes/archive/YYYY-MM-DD-/ Target archive directory already exists. diff --git a/.claude/commands/opsx/explore.md b/.claude/commands/opsx/explore.md index 7558a63..30d9c57 100644 --- a/.claude/commands/opsx/explore.md +++ b/.claude/commands/opsx/explore.md @@ -1,7 +1,6 @@ --- name: "OPSX: Explore" description: "Enter explore mode - think through ideas, investigate problems, clarify requirements" -allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, explore, experimental, thinking] --- @@ -12,8 +11,6 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **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 ` 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" @@ -62,10 +59,10 @@ Depending on what the user brings, you might: │ Use ASCII diagrams liberally │ ├─────────────────────────────────────────┤ │ │ -│ ┌────────┐ ┌────────┐ │ -│ │ State │────────▶│ State │ │ -│ │ A │ │ B │ │ -│ └────────┘ └────────┘ │ +│ ┌────────┐ ┌────────┐ │ +│ │ State │────────▶│ State │ │ +│ │ A │ │ B │ │ +│ └────────┘ └────────┘ │ │ │ │ System diagrams, state machines, │ │ data flows, architecture sketches, │ @@ -110,10 +107,11 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Resolve and read existing artifacts for context** - - Run `openspec status --change "" --json`. - - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. - - Read existing files from `artifactPaths..existingOutputPaths`. +1. **Read existing artifacts for context** + - `openspec/changes//proposal.md` + - `openspec/changes//design.md` + - `openspec/changes//tasks.md` + - etc. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -121,14 +119,14 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |----------------------------|--------------------------------| - | New requirement discovered | `specs//spec.md` | - | Requirement changed | `specs//spec.md` | - | Design decision made | `design.md` | - | Scope changed | `proposal.md` | - | New work identified | `tasks.md` | - | Assumption invalidated | Relevant artifact | + | Insight Type | Where to Capture | + |--------------|------------------| + | New requirement discovered | `specs//spec.md` | + | Requirement changed | `specs//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?" diff --git a/.claude/commands/opsx/propose.md b/.claude/commands/opsx/propose.md index 8d99588..05276f4 100644 --- a/.claude/commands/opsx/propose.md +++ b/.claude/commands/opsx/propose.md @@ -1,7 +1,6 @@ --- name: "OPSX: Propose" description: Propose a new change - create it and generate all artifacts in one step -allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, artifacts, experimental] --- @@ -17,8 +16,6 @@ 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 ` 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** @@ -36,7 +33,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. + This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -45,7 +42,6 @@ When ready to implement, run /opsx:apply 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** @@ -63,10 +59,10 @@ When ready to implement, run /opsx:apply - `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 + - `outputPath`: Where 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` + - Create the artifact file using `template` as the structure - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.claude/skills b/.claude/skills deleted file mode 120000 index 2b7a412..0000000 --- a/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../.agents/skills \ No newline at end of file diff --git a/.agents/skills/cmux-browser/SKILL.md b/.claude/skills/cmux-browser/SKILL.md similarity index 100% rename from .agents/skills/cmux-browser/SKILL.md rename to .claude/skills/cmux-browser/SKILL.md diff --git a/.agents/skills/cmux-browser/agents/openai.yaml b/.claude/skills/cmux-browser/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-browser/agents/openai.yaml rename to .claude/skills/cmux-browser/agents/openai.yaml diff --git a/.agents/skills/cmux-browser/references/authentication.md b/.claude/skills/cmux-browser/references/authentication.md similarity index 100% rename from .agents/skills/cmux-browser/references/authentication.md rename to .claude/skills/cmux-browser/references/authentication.md diff --git a/.agents/skills/cmux-browser/references/commands.md b/.claude/skills/cmux-browser/references/commands.md similarity index 100% rename from .agents/skills/cmux-browser/references/commands.md rename to .claude/skills/cmux-browser/references/commands.md diff --git a/.agents/skills/cmux-browser/references/proxy-support.md b/.claude/skills/cmux-browser/references/proxy-support.md similarity index 100% rename from .agents/skills/cmux-browser/references/proxy-support.md rename to .claude/skills/cmux-browser/references/proxy-support.md diff --git a/.agents/skills/cmux-browser/references/session-management.md b/.claude/skills/cmux-browser/references/session-management.md similarity index 100% rename from .agents/skills/cmux-browser/references/session-management.md rename to .claude/skills/cmux-browser/references/session-management.md diff --git a/.agents/skills/cmux-browser/references/snapshot-refs.md b/.claude/skills/cmux-browser/references/snapshot-refs.md similarity index 100% rename from .agents/skills/cmux-browser/references/snapshot-refs.md rename to .claude/skills/cmux-browser/references/snapshot-refs.md diff --git a/.agents/skills/cmux-browser/references/video-recording.md b/.claude/skills/cmux-browser/references/video-recording.md similarity index 100% rename from .agents/skills/cmux-browser/references/video-recording.md rename to .claude/skills/cmux-browser/references/video-recording.md diff --git a/.agents/skills/cmux-browser/templates/authenticated-session.sh b/.claude/skills/cmux-browser/templates/authenticated-session.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/authenticated-session.sh rename to .claude/skills/cmux-browser/templates/authenticated-session.sh diff --git a/.agents/skills/cmux-browser/templates/capture-workflow.sh b/.claude/skills/cmux-browser/templates/capture-workflow.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/capture-workflow.sh rename to .claude/skills/cmux-browser/templates/capture-workflow.sh diff --git a/.agents/skills/cmux-browser/templates/form-automation.sh b/.claude/skills/cmux-browser/templates/form-automation.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/form-automation.sh rename to .claude/skills/cmux-browser/templates/form-automation.sh diff --git a/.agents/skills/cmux-debug-windows/SKILL.md b/.claude/skills/cmux-debug-windows/SKILL.md similarity index 100% rename from .agents/skills/cmux-debug-windows/SKILL.md rename to .claude/skills/cmux-debug-windows/SKILL.md diff --git a/.agents/skills/cmux-debug-windows/agents/openai.yaml b/.claude/skills/cmux-debug-windows/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-debug-windows/agents/openai.yaml rename to .claude/skills/cmux-debug-windows/agents/openai.yaml diff --git a/.agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/.claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh similarity index 100% rename from .agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh rename to .claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh diff --git a/.agents/skills/cmux-markdown/SKILL.md b/.claude/skills/cmux-markdown/SKILL.md similarity index 100% rename from .agents/skills/cmux-markdown/SKILL.md rename to .claude/skills/cmux-markdown/SKILL.md diff --git a/.agents/skills/cmux-markdown/agents/openai.yaml b/.claude/skills/cmux-markdown/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-markdown/agents/openai.yaml rename to .claude/skills/cmux-markdown/agents/openai.yaml diff --git a/.agents/skills/cmux-markdown/references/commands.md b/.claude/skills/cmux-markdown/references/commands.md similarity index 100% rename from .agents/skills/cmux-markdown/references/commands.md rename to .claude/skills/cmux-markdown/references/commands.md diff --git a/.agents/skills/cmux-markdown/references/live-reload.md b/.claude/skills/cmux-markdown/references/live-reload.md similarity index 100% rename from .agents/skills/cmux-markdown/references/live-reload.md rename to .claude/skills/cmux-markdown/references/live-reload.md diff --git a/.agents/skills/cmux/SKILL.md b/.claude/skills/cmux/SKILL.md similarity index 100% rename from .agents/skills/cmux/SKILL.md rename to .claude/skills/cmux/SKILL.md diff --git a/.agents/skills/cmux/agents/openai.yaml b/.claude/skills/cmux/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux/agents/openai.yaml rename to .claude/skills/cmux/agents/openai.yaml diff --git a/.agents/skills/cmux/references/handles-and-identify.md b/.claude/skills/cmux/references/handles-and-identify.md similarity index 100% rename from .agents/skills/cmux/references/handles-and-identify.md rename to .claude/skills/cmux/references/handles-and-identify.md diff --git a/.agents/skills/cmux/references/panes-surfaces.md b/.claude/skills/cmux/references/panes-surfaces.md similarity index 100% rename from .agents/skills/cmux/references/panes-surfaces.md rename to .claude/skills/cmux/references/panes-surfaces.md diff --git a/.agents/skills/cmux/references/trigger-flash-and-health.md b/.claude/skills/cmux/references/trigger-flash-and-health.md similarity index 100% rename from .agents/skills/cmux/references/trigger-flash-and-health.md rename to .claude/skills/cmux/references/trigger-flash-and-health.md diff --git a/.agents/skills/cmux/references/windows-workspaces.md b/.claude/skills/cmux/references/windows-workspaces.md similarity index 100% rename from .agents/skills/cmux/references/windows-workspaces.md rename to .claude/skills/cmux/references/windows-workspaces.md diff --git a/.agents/skills/crit-cli/SKILL.md b/.claude/skills/crit-cli/SKILL.md similarity index 100% rename from .agents/skills/crit-cli/SKILL.md rename to .claude/skills/crit-cli/SKILL.md diff --git a/.agents/skills/ia-review/SKILL.md b/.claude/skills/ia-review/SKILL.md similarity index 100% rename from .agents/skills/ia-review/SKILL.md rename to .claude/skills/ia-review/SKILL.md diff --git a/.agents/skills/openspec-apply-change/SKILL.md b/.claude/skills/openspec-apply-change/SKILL.md similarity index 82% rename from .agents/skills/openspec-apply-change/SKILL.md rename to .claude/skills/openspec-apply-change/SKILL.md index 1375861..d474dc1 100644 --- a/.agents/skills/openspec-apply-change/SKILL.md +++ b/.claude/skills/openspec-apply-change/SKILL.md @@ -1,19 +1,16 @@ --- 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. -allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.2.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 ` 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** @@ -33,7 +30,6 @@ Implement tasks from an OpenSpec change. ``` 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** @@ -43,7 +39,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Context 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 @@ -55,7 +51,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read every file path listed under `contextFiles` from the apply instructions output. + Read the files listed in `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 diff --git a/.agents/skills/openspec-archive-change/SKILL.md b/.claude/skills/openspec-archive-change/SKILL.md similarity index 75% rename from .agents/skills/openspec-archive-change/SKILL.md rename to .claude/skills/openspec-archive-change/SKILL.md index c0c169d..9b1f851 100644 --- a/.agents/skills/openspec-archive-change/SKILL.md +++ b/.claude/skills/openspec-archive-change/SKILL.md @@ -1,19 +1,16 @@ --- 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. -allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.2.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 ` 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** @@ -33,7 +30,6 @@ Archive a completed change in the experimental workflow. 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`:** @@ -56,7 +52,7 @@ Archive a completed change in the experimental workflow. 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. + Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` @@ -71,19 +67,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: + Create the archive directory if it doesn't exist: ```bash - mkdir -p "/archive" + mkdir -p openspec/changes/archive ``` Generate target name using current date: `YYYY-MM-DD-` **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 + - If no: Move the change directory to archive ```bash - mv "" "/archive/YYYY-MM-DD-" + mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- ``` 6. **Display summary** @@ -102,7 +98,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") All artifacts complete. All tasks complete. diff --git a/.agents/skills/openspec-explore/SKILL.md b/.claude/skills/openspec-explore/SKILL.md similarity index 84% rename from .agents/skills/openspec-explore/SKILL.md rename to .claude/skills/openspec-explore/SKILL.md index 771271a..ffa10ca 100644 --- a/.agents/skills/openspec-explore/SKILL.md +++ b/.claude/skills/openspec-explore/SKILL.md @@ -1,13 +1,12 @@ --- 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. -allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.2.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. @@ -16,8 +15,6 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **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 ` 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 @@ -59,10 +56,10 @@ Depending on what the user brings, you might: │ Use ASCII diagrams liberally │ ├─────────────────────────────────────────┤ │ │ -│ ┌────────┐ ┌────────┐ │ -│ │ State │────────▶│ State │ │ -│ │ A │ │ B │ │ -│ └────────┘ └────────┘ │ +│ ┌────────┐ ┌────────┐ │ +│ │ State │────────▶│ State │ │ +│ │ A │ │ B │ │ +│ └────────┘ └────────┘ │ │ │ │ System diagrams, state machines, │ │ data flows, architecture sketches, │ @@ -105,10 +102,11 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Resolve and read existing artifacts for context** - - Run `openspec status --change "" --json`. - - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. - - Read existing files from `artifactPaths..existingOutputPaths`. +1. **Read existing artifacts for context** + - `openspec/changes//proposal.md` + - `openspec/changes//design.md` + - `openspec/changes//tasks.md` + - etc. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -116,14 +114,14 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |----------------------------|--------------------------------| - | New requirement discovered | `specs//spec.md` | - | Requirement changed | `specs//spec.md` | - | Design decision made | `design.md` | - | Scope changed | `proposal.md` | - | New work identified | `tasks.md` | - | Assumption invalidated | Relevant artifact | + | Insight Type | Where to Capture | + |--------------|------------------| + | New requirement discovered | `specs//spec.md` | + | Requirement changed | `specs//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?" @@ -229,7 +227,7 @@ User: A CLI tool that tracks local dev environments You: That changes everything. ┌─────────────────────────────────────────────────┐ - │ CLI TOOL DATA STORAGE │ + │ CLI TOOL DATA STORAGE │ └─────────────────────────────────────────────────┘ Key constraints: diff --git a/.agents/skills/openspec-propose/SKILL.md b/.claude/skills/openspec-propose/SKILL.md similarity index 81% rename from .agents/skills/openspec-propose/SKILL.md rename to .claude/skills/openspec-propose/SKILL.md index 716d2d3..d27bc53 100644 --- a/.agents/skills/openspec-propose/SKILL.md +++ b/.claude/skills/openspec-propose/SKILL.md @@ -1,13 +1,12 @@ --- 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. -allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.2.0" --- Propose a new change - create the change and generate all artifacts in one step. @@ -21,8 +20,6 @@ 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 ` 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** @@ -40,7 +37,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. + This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -49,7 +46,6 @@ When ready to implement, run /opsx:apply 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** @@ -67,10 +63,10 @@ When ready to implement, run /opsx:apply - `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 + - `outputPath`: Where 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` + - Create the artifact file using `template` as the structure - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.agents/skills/spec-drift-review/SKILL.md b/.claude/skills/spec-drift-review/SKILL.md similarity index 100% rename from .agents/skills/spec-drift-review/SKILL.md rename to .claude/skills/spec-drift-review/SKILL.md diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 0d2af30..0000000 --- a/.dockerignore +++ /dev/null @@ -1,14 +0,0 @@ -# Keep Docker build contexts small and hermetic. CI builds from a clean -# checkout so this mostly matters for local builds (e2e/federation -# harness), where node_modules would otherwise bloat the context to -# gigabytes — and worse, `COPY . .` would overlay host-installed -# node_modules over the image's own pnpm install. -**/node_modules -**/.turbo -**/build -**/.react-router -.git -e2e/results -playwright-report -test-results -**/*.log diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f2e30a4..21f282c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,22 +20,10 @@ updates: update-types: ["version-update:semver-major"] - dependency-name: "@types/node" update-types: ["version-update:semver-major"] - # These packages are version-pinned by the Expo SDK (see - # expo/bundledNativeModules.json) — upgrade them via an Expo SDK - # bump / `npx expo install --fix`, never on their own. Dependabot - # bumping them past the SDK's expected version broke the native - # build (expo-modules-core macro mismatch, June 2026) because CI - # never compiles native code. `react` stays unignored: the web - # apps own its version via the workspace catalog, and apps/mobile - # excludes it from expo version checks. + # react-native is pinned by the Expo SDK — upgrade it via an Expo + # SDK bump, not on its own. Community react-native-* libraries are + # intentionally NOT ignored here; they can be bumped independently. - dependency-name: "react-native" - - dependency-name: "react-native-gesture-handler" - - dependency-name: "react-native-reanimated" - - dependency-name: "react-native-safe-area-context" - - dependency-name: "react-native-screens" - - dependency-name: "react-native-worklets" - - dependency-name: "@sentry/react-native" - - dependency-name: "jest-expo" - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index a5a967e..701416c 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -13,15 +13,6 @@ concurrency: group: deploy-apps cancel-in-progress: true -# Public Sentry DSNs for the trails.cool flagship instance. Public by -# design — Sentry DSNs are transmitted unencrypted from the client JS -# bundle, embedding them in this workflow is no worse than embedding -# them in the runtime env. Self-hosted forks should either replace -# these with their own DSNs or remove the lines to ship without Sentry. -env: - SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728" - SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208" - jobs: build-images: name: Build & Push Docker Images @@ -34,7 +25,7 @@ jobs: matrix: app: [journal, planner] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: docker/login-action@v4 with: @@ -57,12 +48,8 @@ jobs: tags: | ghcr.io/trails-cool/${{ matrix.app }}:latest ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} - # VITE_SENTRY_DSN bakes the client-side DSN into the journal's - # built bundle. Only journal has a client Sentry init; planner - # ignores the build-arg if present. build-args: | SENTRY_RELEASE=${{ github.sha }} - VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} secrets: | SENTRY_AUTH_TOKEN=/tmp/sentry_token @@ -72,7 +59,7 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Decrypt secrets run: | @@ -83,16 +70,6 @@ jobs: echo "DOMAIN=trails.cool" >> infrastructure/app.env # Flagship marker — see cd-infra.yml for what this gates. echo "IS_FLAGSHIP=true" >> infrastructure/app.env - # Federation on (social-federation rollout 12.5, flipped - # 2026-06-07 after the staging + Mastodon soak). The - # FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env - # decrypted above. Rollback: delete these two lines, merge, - # rerun cd-apps — instant off, federation surfaces 404. - echo "FEDERATION_ENABLED=true" >> infrastructure/app.env - echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env - # Sentry DSNs (public — see workflow top-level env for context). - echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env - echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env - name: Copy files to server uses: appleboy/scp-action@v1 @@ -100,7 +77,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/caddy" + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" target: /opt/trails-cool strip_components: 1 @@ -121,10 +98,6 @@ jobs: username: root key: ${{ secrets.DEPLOY_SSH_KEY }} script: | - # Abort the deploy on the first failure. Without this, a failed - # schema push deploys new code against an old schema (the - # 2026-06-06 schema-drift incident). - set -euo pipefail cd /opt/trails-cool # Login to ghcr.io @@ -136,34 +109,11 @@ jobs: # Hand-written data migrations (idempotent) run BEFORE drizzle-kit # push so unique-key reshapes can collapse duplicate rows first. docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts - # drizzle-kit exits 0 even when it aborts on an interactive - # prompt it can't show (no TTY in CI) — that exact lie hid a - # month of staging schema drift. Treat any Error in its output - # as a failed deploy. - docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log - if grep -q "Error:" /tmp/drizzle-push.log; then - echo "drizzle-kit push reported an error — failing the deploy" - exit 1 - fi + docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force # --remove-orphans cleans up containers whose service was deleted # from the compose file, matching cd-infra's behaviour. docker compose --env-file app.env up -d --remove-orphans journal planner - # Gate on container health: a deploy that leaves journal or - # planner unhealthy must fail loudly, not report green. - for svc in journal planner; do - for i in $(seq 1 24); do - status=$(docker inspect -f '{{.State.Health.Status}}' "trails-cool-$svc-1" 2>/dev/null || echo missing) - [ "$status" = "healthy" ] && break - sleep 5 - done - if [ "$status" != "healthy" ]; then - echo "$svc did not become healthy (last status: $status)" - docker compose --env-file app.env logs "$svc" --tail 50 || true - exit 1 - fi - done - # Reload Caddy with the Caddyfile we just scp'd. cd-apps # ships infrastructure/Caddyfile alongside docker-compose.yml # (see scp step above), but containers don't auto-pick-up @@ -177,13 +127,8 @@ jobs: # deploy from failing if Caddy itself is unhealthy. docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true - # Clean up (best-effort). This runs AFTER the containers are - # swapped + Caddy reloaded, so the deploy already succeeded — - # a transient "a prune operation is already running" collision - # with a concurrent deploy/disk-maintenance prune must NOT fail - # an otherwise-green deploy. disk-maintenance.yml is the real - # image-prune safety net. - docker image prune -af || true + # Clean up + docker image prune -af docker compose ps # Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives diff --git a/.github/workflows/cd-brouter.yml b/.github/workflows/cd-brouter.yml index 5d28e86..907df2d 100644 --- a/.github/workflows/cd-brouter.yml +++ b/.github/workflows/cd-brouter.yml @@ -20,7 +20,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: docker/login-action@v4 with: @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest environment: infra steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Decrypt shared secret id: decrypt @@ -87,7 +87,6 @@ jobs: TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \ docker-compose.yml Caddyfile promtail-config.yml \ download-segments.sh .env \ - poi-extract \ | base64 -w0) # One SSH session: untar, (re)start containers, report status @@ -100,7 +99,7 @@ jobs: set -euo pipefail mkdir -p ~/brouter && cd ~/brouter echo "$TARBALL_B64" | base64 -d | tar -xzf - - chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py + chmod +x download-segments.sh # Segment seeding is a one-shot operator task (~10 GB, a few # minutes). CD must not rerun it on every deploy. diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index df41b93..d317ae1 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest environment: infra steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Decrypt secrets run: | @@ -43,7 +43,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards,infrastructure/scripts" + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards" target: /opt/trails-cool strip_components: 1 @@ -64,11 +64,6 @@ jobs: username: root key: ${{ secrets.DEPLOY_SSH_KEY }} script: | - # Abort on first failure. The 2026-06-06/07 outage: a network - # recreation stopped postgres, a later step failed, and the - # deploy left production down for ~9h while the job's partial - # progress looked plausible. Fail fast, verify health at the end. - set -euo pipefail cd /opt/trails-cool # .env was placed by the SCP step (decrypted app + infra secrets) @@ -84,77 +79,20 @@ jobs: docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true fi - # Capture whether prometheus was recreated. A fresh container already - # reads the new config on startup; sending it SIGHUP immediately can - # kill Prometheus 3.10 during early boot (exit 2 observed on - # 2026-06-09). - PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) - # Full restart: gh workflow run cd-infra.yml -f restart_all=true if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then docker compose --env-file .env up -d --remove-orphans else - # Restart infra services (config reloads handled below). + # Restart infra services (except Caddy — just reload its config). # --remove-orphans cleans up containers whose service was deleted # from the compose file (e.g., the flagship `brouter` removal in # PR #297 left an orphan that had to be removed by hand). docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile fi - PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) - - # Apply config-only changes that `up -d` skips — it recreates a - # container only when its compose *definition* changes, not when a - # mounted config file's content changes. The configs are mounted as - # directories (not single files), so a reload/restart re-reads the - # freshly scp'd file; a single-file mount would have pinned the old - # inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when - # the same container stayed up; a recreated container already loaded - # the new config on startup. Loki and Promtail reload their main - # config only on restart; Caddy reloads gracefully (validates, swaps - # live, no downtime). - if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then - docker compose --env-file .env kill -s SIGHUP prometheus - fi - docker compose --env-file .env restart loki promtail - docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile - docker compose ps - # Gate on the stack actually being up: postgres healthy, journal - # back to healthy after the DB bounce, and Prometheus answering - # /-/ready. A deploy that leaves any of them down must fail loudly - # (see the 2026-06-06/07 outage, plus the 2026-06-09 Prometheus - # startup/HUP regression). - for ctr in trails-cool-postgres-1 trails-cool-journal-1; do - for i in $(seq 1 36); do - status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing) - [ "$status" = "healthy" ] && break - sleep 5 - done - if [ "$status" != "healthy" ]; then - echo "$ctr did not become healthy (last status: $status)" - exit 1 - fi - done - - PROMETHEUS_READY= - for i in $(seq 1 36); do - prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing) - if [ "$prom_status" = "running" ]; then - prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1) - if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then - PROMETHEUS_READY=1 - break - fi - fi - sleep 5 - done - if [ -z "$PROMETHEUS_READY" ]; then - echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)" - exit 1 - fi - # Annotate deploy in Grafana GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) if [ -n "$GRAFANA_TOKEN" ]; then diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml index e69dec6..9f63ef0 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -17,9 +17,7 @@ on: - "infrastructure/docker-compose.staging.yml" - ".github/workflows/cd-staging.yml" pull_request: - # `labeled`/`unlabeled` so toggling the `preview` label on an existing PR - # starts / tears down its preview (see the opt-in gate on the jobs below). - types: [opened, synchronize, reopened, closed, labeled, unlabeled] + types: [opened, synchronize, reopened, closed] paths: - "apps/**" - "packages/**" @@ -32,12 +30,6 @@ concurrency: group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }} cancel-in-progress: true -# Public Sentry DSNs (same as cd-apps.yml). See that workflow for the -# "public by design" rationale. -env: - SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728" - SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208" - jobs: # ── Build ───────────────────────────────────────────────────────────── # Tags: @@ -46,15 +38,7 @@ jobs: # Skipped entirely on PR close (teardown doesn't need new images). build-images: name: Build & Push Docker Images - # PR previews are opt-in to keep flagship disk in check (each preview is a - # journal container + database): build a PR's images only when it carries - # the `preview` label or a `` marker in its description. - # Main-push / dispatch always build. - if: > - github.event_name != 'pull_request' || - (github.event.action != 'closed' && - (contains(github.event.pull_request.labels.*.name, 'preview') || - contains(github.event.pull_request.body, ''))) + if: github.event_name != 'pull_request' || github.event.action != 'closed' runs-on: ubuntu-latest environment: production permissions: @@ -67,7 +51,7 @@ jobs: tag_primary: ${{ steps.tags.outputs.primary }} tag_sha: ${{ steps.tags.outputs.sha }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - id: tags name: Compute image tags @@ -105,7 +89,6 @@ jobs: ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }} build-args: | SENTRY_RELEASE=${{ steps.tags.outputs.sha }} - VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} secrets: | SENTRY_AUTH_TOKEN=/tmp/sentry_token @@ -117,7 +100,7 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Decrypt secrets run: | @@ -135,15 +118,6 @@ jobs: echo "JOURNAL_IMAGE_TAG=staging" echo "PLANNER_IMAGE_TAG=staging" echo "SENTRY_RELEASE=${{ github.sha }}" - echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" - echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" - # Federation soak on persistent staging only (social-federation - # rollout 12.2). FEDERATION_KEY_ENCRYPTION_KEY comes from the - # SOPS env decrypted above; previews never set this flag. - echo "FEDERATION_ENABLED=true" - # Verbose Fedify logs during the soak (signature verification - # detail). Dial down to info once federation is proven out. - echo "FEDERATION_LOG_LEVEL=debug" } >> infrastructure/staging.env - name: Copy compose file + env to server @@ -191,48 +165,30 @@ jobs: # Pull and deploy staging containers (journal + planner via "persistent" profile) docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull - # drizzle-kit exits 0 even when it aborts on an interactive - # prompt (no TTY in CI) — set -e alone can't catch it. That lie - # hid a month of staging schema drift (2026-06-06 incident). - docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log - if grep -q "Error:" /tmp/drizzle-push.log; then - echo "drizzle-kit push reported an error — failing the deploy" - exit 1 - fi + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans # Reload Caddy so new staging routes (or Caddyfile changes shipped # via cd-infra) are live. Idempotent. docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true - # Reclaim superseded image layers. cd-apps prunes after its own - # deploys, but a day of staging/preview deploys while cd-apps is - # red can fill the disk on its own (2026-06-07: 100% full, - # postgres down). The 1h filter avoids racing layers another - # in-flight deploy just pulled. - docker image prune -af --filter "until=1h" || true - docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps # ── PR preview deploy ──────────────────────────────────────────────── deploy-preview: name: Deploy PR Preview - # Opt-in only (see build-images): the `preview` label or a `` - # marker in the PR body. Also skips GH-Actions-only Dependabot PRs — there - # is no app image to preview. + # Skip GH-Actions-only Dependabot PRs — there is no app image to preview. if: > github.event_name == 'pull_request' && github.event.action != 'closed' && - !startsWith(github.head_ref, 'dependabot/github_actions/') && - (contains(github.event.pull_request.labels.*.name, 'preview') || - contains(github.event.pull_request.body, '')) + !startsWith(github.head_ref, 'dependabot/github_actions/') needs: [build-images] runs-on: ubuntu-latest environment: production permissions: pull-requests: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - id: ports name: Compute preview ports + project name @@ -264,17 +220,6 @@ jobs: # PR-preview journals all share the persistent staging planner. echo "PLANNER_URL=https://planner.staging.trails.cool" echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}" - echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" - echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" - # Federation on previews too (social-federation rollout 12.4): - # makes any PR preview a second live trails instance that - # persistent staging can follow across — the trails-to-trails - # soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the - # SOPS env decrypted above. Preview teardown orphans remote - # follower rows on the other side; remotes handle dead - # instances via ordinary delivery-failure expiry. - echo "FEDERATION_ENABLED=true" - echo "FEDERATION_LOG_LEVEL=debug" } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env - name: Generate per-PR Caddyfile snippet @@ -374,22 +319,12 @@ jobs: # Pull, migrate, deploy (journal-only — no --profile means planner skipped) docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" pull journal - # Same drizzle-kit exit-code-0-on-error guard as the persistent - # staging deploy above. - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log - if grep -q "Error:" /tmp/drizzle-push.log; then - echo "drizzle-kit push reported an error — failing the preview deploy" - exit 1 - fi + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" up -d --remove-orphans journal # Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step) docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile - # Same disk-hygiene prune as the persistent staging deploy — - # preview pushes are the highest-volume image source. - docker image prune -af --filter "until=1h" || true - docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps # Find any prior preview comment so we can update it in place rather @@ -424,15 +359,7 @@ jobs: # ── PR preview teardown ────────────────────────────────────────────── teardown-preview: name: Tear Down PR Preview - # Tear down on close, or when the `preview` opt-in is removed (label pulled - # and no `` marker left) so a de-flagged PR doesn't orphan - # its preview stack on the flagship. - if: > - github.event_name == 'pull_request' && - (github.event.action == 'closed' || - (github.event.action == 'unlabeled' && - !(contains(github.event.pull_request.labels.*.name, 'preview') || - contains(github.event.pull_request.body, '')))) + if: github.event_name == 'pull_request' && github.event.action == 'closed' runs-on: ubuntu-latest environment: production permissions: @@ -446,7 +373,7 @@ jobs: echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Decrypt secrets (needed to satisfy compose env vars during down) run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 068ebc5..0494470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: contents: read pull-requests: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Gitleaks @@ -42,14 +42,14 @@ jobs: name: Dockerfile Package Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - run: bash scripts/check-dockerfiles.sh openspec: name: OpenSpec Validate runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -62,7 +62,7 @@ jobs: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -75,7 +75,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -88,7 +88,7 @@ jobs: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -101,7 +101,7 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -117,7 +117,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -127,7 +127,7 @@ jobs: - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} @@ -183,7 +183,7 @@ jobs: env: DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -193,7 +193,7 @@ jobs: - name: Cache BRouter segment id: segment-cache - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: /tmp/brouter-segments key: brouter-segment-E10_N50-v1.7.9 @@ -231,27 +231,9 @@ jobs: - name: Seed database run: pnpm db:seed - - name: Run integration tests - # These talk to real Postgres. The unit-test job has no DB so - # the `*.integration.test.ts` files skip there; this job has - # the DB up + schema pushed, so flip the gate env vars to "1" - # and let them run. Each gate is read by one file — see - # `runIntegration` in each test. - # - # --no-file-parallelism: integration tests share the journal - # schema and clean up by `DELETE FROM ... WHERE email LIKE - # '%@example.test'`. Parallel files step on each other's rows - # and trip FK constraints. Running sequentially is still <3s. - run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts - env: - EXPLORE_INTEGRATION: "1" - FOLLOW_INTEGRATION: "1" - DEMO_BOT_INTEGRATION: "1" - NOTIFICATIONS_INTEGRATION: "1" - - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} @@ -273,12 +255,7 @@ jobs: run: pnpm test:e2e env: BROUTER_URL: http://localhost:17777 - # E2E=true is the explicit opt-out from the fail-loud - # requireSecret() / getDatabaseUrl() guards — playwright boots - # the server via `react-router serve` (NODE_ENV=production) but - # against the local dev Postgres + local cookie secrets. E2E: "true" - INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }} - name: Playwright job summary if: ${{ !cancelled() }} @@ -316,87 +293,3 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 30 - - journal-image-smoke: - # Build the journal's *production* Docker image (the `runtime` stage) - # and actually boot it. Nothing else in CI does this: typecheck / - # lint / test / build all run against the source tree, and the e2e - # job boots the journal via `react-router-serve`, not the production - # `node server.ts` entrypoint. The runtime stage copies source files - # in by name (server.ts, app/lib, serve-static.ts, ...), so a refactor - # that adds a file `server.ts` imports — without a matching COPY — - # builds green everywhere and only crash-loops once deployed - # (ERR_MODULE_NOT_FOUND). That has taken prod down more than once - # (app/lib, app/jobs, serve-static.ts). Booting the real image and - # hitting /api/health closes that gap: a missing static OR dynamic - # import never reaches a healthy 200. - name: Journal Image Smoke Test - runs-on: ubuntu-latest - services: - postgres: - image: imresamu/postgis:16-3.4 - env: - POSTGRES_USER: trails - POSTGRES_PASSWORD: trails - POSTGRES_DB: trails - ports: - - 5432:5432 - # The postgis image restarts mid-init while it creates the - # extension; the health check only passes once the final server - # is up, so dependents don't race the init restart. - options: >- - --health-cmd "pg_isready -U trails" - --health-interval 5s - --health-timeout 5s - --health-retries 20 - env: - DATABASE_URL: postgres://trails:trails@localhost:5432/trails - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - # seedOAuthClient + the demo/notifications job worker run on boot - # and write to real tables, so the image needs a schema to come up - # healthy. The postgis extension is auto-created by the image. - - name: Push database schema - run: pnpm db:push - - - name: Build journal runtime image - run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke . - - - name: Boot image and wait for healthy - run: | - # --network host: reach the service Postgres at localhost:5432 - # and publish the server on localhost:3000 in one shot. - # E2E=true is the documented opt-out from the fail-loud - # getDatabaseUrl() prod guard (CI points at a local Postgres). - docker run -d --name journal-smoke --network host \ - -e NODE_ENV=production -e E2E=true \ - -e DATABASE_URL="$DATABASE_URL" \ - journal-smoke - code=000 - for i in $(seq 1 30); do - code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000) - echo "attempt $i: /api/health -> $code" - [ "$code" = "200" ] && break - if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then - echo "::error::journal container exited during boot" - break - fi - sleep 2 - done - if [ "$code" != "200" ]; then - echo "::error::journal production image failed to boot healthy (see logs below)" - docker logs journal-smoke 2>&1 || true - exit 1 - fi - echo "journal production image booted healthy" - - - name: Container logs - if: always() - run: docker logs journal-smoke 2>&1 | tail -40 || true diff --git a/.github/workflows/dependabot-auto-fix.yml b/.github/workflows/dependabot-auto-fix.yml deleted file mode 100644 index 85e110f..0000000 --- a/.github/workflows/dependabot-auto-fix.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Dependabot auto-fix - -# Post-processing that runs on every dependabot PR and pushes the result -# back to the PR branch. Two fixups, in one workflow so there is a single -# checkout / commit / push (two workflows racing to push the same branch -# would collide on a non-fast-forward): -# -# 1. pnpm dedupe — `pnpm install` alone doesn't dedupe peer copies of a -# package (e.g. two versions of i18next, each holding their own -# singleton state). That split caused a hydration mismatch on #272 -# until a manual `pnpm dedupe` collapsed them. -# -# 2. openspec update — the OpenSpec agent skills (.agents/skills/openspec-*) -# and opsx slash commands (.claude/commands/opsx/*) are generated files -# stamped with the CLI version that produced them. When dependabot bumps -# @fission-ai/openspec they go stale until regenerated (see PR #567, -# which did the 1.2.0 -> 1.6.0 regen by hand). `openspec update --force` -# rewrites them to match the freshly-installed CLI. -# -# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a -# fine-grained PAT (or GitHub App token) with `contents: write` on -# this repo. The default `GITHUB_TOKEN` would work for the push but -# would NOT trigger a subsequent CI run on that push (GitHub's -# anti-loop safeguard), leaving the PR with stale green CI from -# before the fixup commit. A PAT re-triggers CI so the reviewer -# sees test results for the state they'd actually be merging. - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - autofix: - if: github.actor == 'dependabot[bot]' - runs-on: ubuntu-latest - steps: - - name: Require DEPENDABOT_DEDUPE_TOKEN - env: - TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} - run: | - if [ -z "$TOKEN" ]; then - echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow" - echo "::error::requires a PAT so the fixup commit re-triggers CI." - echo "::error::See .github/workflows/dependabot-auto-fix.yml header." - exit 1 - fi - - uses: actions/checkout@v7 - with: - ref: ${{ github.head_ref }} - # With `persist-credentials: true`, this PAT is stashed by the - # action (under $RUNNER_TEMP in recent versions) and made - # available to subsequent git operations in this workspace — - # so the later `git push` authenticates as the PAT, which is - # what gets CI to re-trigger on the fixup commit. `true` is - # the checkout default; pinned explicitly here because it's - # load-bearing for this workflow. - token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} - persist-credentials: true - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile=false - - name: Dedupe lockfile - run: pnpm dedupe - - name: Regenerate OpenSpec tool files - # --force so the generated files always match the installed CLI, - # even if `update` would otherwise consider them up to date. No-op - # (no diff) when @fission-ai/openspec wasn't bumped in this PR. - run: pnpm exec openspec update --force - - name: Commit fixups - env: - GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} - run: | - git add pnpm-lock.yaml .agents/skills/openspec-* .claude/commands/opsx - if git diff --cached --quiet; then - echo "Nothing to fix up — lockfile deduped and OpenSpec files current." - exit 0 - fi - # Build a message naming only the parts that actually changed. - parts="" - git diff --cached --name-only | grep -q '^pnpm-lock.yaml$' && parts="pnpm dedupe" - if git diff --cached --name-only | grep -qE '^(\.agents/skills/openspec-|\.claude/commands/opsx)'; then - parts="${parts:+$parts + }openspec update" - fi - git config user.name "dependabot[bot]" - git config user.email "49699333+dependabot[bot]@users.noreply.github.com" - git commit -m "[github-actions] $parts" - git push - echo "Pushed fixups: $parts" diff --git a/.github/workflows/dependabot-dedupe.yml b/.github/workflows/dependabot-dedupe.yml new file mode 100644 index 0000000..09c90b1 --- /dev/null +++ b/.github/workflows/dependabot-dedupe.yml @@ -0,0 +1,74 @@ +name: Dependabot dedupe + +# Dependabot opens a PR after a bump, but `pnpm install` alone doesn't +# dedupe peer copies of packages (e.g. two versions of i18next, each +# holding their own singleton state). That split caused a hydration +# mismatch on #272 until a manual `pnpm dedupe` collapsed them. +# +# This workflow fires on every dependabot PR, runs `pnpm dedupe`, and +# pushes the resulting lockfile update back to the PR branch so the +# subsequent CI run tests the deduped tree. +# +# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a +# fine-grained PAT (or GitHub App token) with `contents: write` on +# this repo. The default `GITHUB_TOKEN` would work for the push but +# would NOT trigger a subsequent CI run on that push (GitHub's +# anti-loop safeguard), leaving the PR with stale green CI from +# before the dedupe commit. A PAT re-triggers CI so the reviewer +# sees test results for the state they'd actually be merging. + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + dedupe: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Require DEPENDABOT_DEDUPE_TOKEN + env: + TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow" + echo "::error::requires a PAT so the dedupe commit re-triggers CI." + echo "::error::See .github/workflows/dependabot-dedupe.yml header." + exit 1 + fi + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + # With `persist-credentials: true`, this PAT is stashed by the + # action (under $RUNNER_TEMP in recent versions) and made + # available to subsequent git operations in this workspace — + # so the later `git push` authenticates as the PAT, which is + # what gets CI to re-trigger on the dedupe commit. `true` is + # the checkout default; pinned explicitly here because it's + # load-bearing for this workflow. + token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + persist-credentials: true + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile=false + - run: pnpm dedupe + - name: Commit dedupe changes + env: + GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + run: | + if [ -n "$(git status --porcelain pnpm-lock.yaml)" ]; then + git config user.name "dependabot[bot]" + git config user.email "49699333+dependabot[bot]@users.noreply.github.com" + git add pnpm-lock.yaml + git commit -m "[github-actions] pnpm dedupe" + git push + echo "Deduped lockfile pushed." + else + echo "Lockfile already deduped." + fi diff --git a/.github/workflows/disk-maintenance.yml b/.github/workflows/disk-maintenance.yml deleted file mode 100644 index d8f6a09..0000000 --- a/.github/workflows/disk-maintenance.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Disk Maintenance - -# Daily safety net for flagship disk usage. The deploy workflows prune -# superseded image layers after their own runs, but that protection -# disappears exactly when it's needed most: a deploy that fails early -# never reaches its prune step, while other workflows keep pulling -# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging -# deploys, disk 100% full, postgres down on a Saturday morning). -# -# Also doubles as a redundant alert channel: the run FAILS when the -# disk is still above the threshold after pruning, so a scheduled-run -# failure email lands even if the Grafana disk alert drowns in other -# noise (which is what happened during the incident). - -on: - schedule: - # Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00) - - cron: "30 4 * * *" - workflow_dispatch: {} - -concurrency: - group: disk-maintenance - cancel-in-progress: false - -jobs: - prune-flagship: - name: Prune unused images (flagship) - runs-on: ubuntu-latest - environment: production - steps: - - name: Prune and check disk headroom - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - script: | - set -euo pipefail - echo "before: $(df -h / | tail -1)" - # 12h filter: never touch layers a same-day deploy may still - # be assembling; running containers' images are never pruned. - # `|| true`: a concurrent deploy's prune can make this collide - # with "a prune operation is already running" — that's benign - # (the other prune is freeing space too), and the disk-% gate - # below is the real assertion, so don't fail on the collision. - docker image prune -af --filter "until=12h" || true - echo "after: $(df -h / | tail -1)" - - PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9') - if [ "$PCT" -ge 85 ]; then - echo "Disk still at ${PCT}% after pruning — needs a human." - echo "Largest docker consumers:" - docker system df - exit 1 - fi - echo "Disk at ${PCT}% — healthy." diff --git a/.github/workflows/update-visual-snapshots.yml b/.github/workflows/update-visual-snapshots.yml index bcbfea1..f1c9e4c 100644 --- a/.github/workflows/update-visual-snapshots.yml +++ b/.github/workflows/update-visual-snapshots.yml @@ -62,7 +62,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.ref || github.event.inputs.branch || github.ref }} # Use a token with push rights so the commit-back step can push diff --git a/.gitignore b/.gitignore index 450df80..85f9417 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,3 @@ playwright-results.json .claude/worktrees/ .claude/settings.local.json .claude/scheduled_tasks.lock -docs/reviews/internal/ diff --git a/CLAUDE.md b/CLAUDE.md index 2109593..a1332cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,7 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with Full architecture: `docs/architecture.md` Philosophy: `docs/philosophy.md` -Roadmap: `docs/roadmap.md` -Ideas (pre-spec explorations): `docs/ideas/` -OpenSpec changes: `openspec/changes/` +OpenSpec change: `openspec/changes/phase-1-mvp/` ## Principles @@ -27,7 +25,7 @@ OpenSpec changes: `openspec/changes/` - **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack) - **Maps**: Leaflet + OpenStreetMap tiles - **CRDT**: Yjs + y-websocket (Planner only) -- **Federation**: Fedify (Journal only) +- **Federation**: Fedify (Journal only, Phase 2) - **Database**: PostgreSQL + PostGIS - **Media storage**: S3-compatible (Garage) - **Routing engine**: BRouter (Java, runs as separate Docker container) @@ -41,15 +39,11 @@ apps/ planner/ — Planner app (React Router 7) journal/ — Journal app (React Router 7 + Fedify) packages/ - types/ — Shared wire types both apps exchange (Waypoint) - map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side + types/ — Shared TypeScript interfaces (Route, Activity, Waypoint) + ui/ — Shared React components (Tailwind) + map/ — Leaflet map wrappers and tile layer configs gpx/ — GPX parsing, generation, validation - fit/ — FIT file generation (Wahoo route push) i18n/ — react-i18next config + translations - api/ — Shared API contracts (endpoints, pagination, error types, versioning) - db/ — Drizzle schema, database client, migration helpers - jobs/ — pg-boss setup, worker, and background job types - sentry-config/ — Shared Sentry configuration infrastructure/ — Terraform + Docker Compose openspec/ — OpenSpec specs and changes docs/ — Architecture, philosophy, tooling docs @@ -123,8 +117,8 @@ local config symmetric. - **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build. - All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings) -- Database row types are derived from the Drizzle schema (`@trails-cool/db`); API wire shapes are the Zod contracts in `@trails-cool/api`; only types both apps exchange (e.g. Waypoint) live in `@trails-cool/types` -- Map constants (colors, tiles, POI categories, z-indexes) go in `@trails-cool/map-core`; React/Leaflet map components live in the app that uses them +- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions +- Map components go in `@trails-cool/map`, not in individual apps - GPX parsing/generation goes in `@trails-cool/gpx` - Database schemas: `planner.*` for Planner data, `journal.*` for Journal data - Route geometry must be stored as PostGIS LineString (extracted from GPX on save) @@ -255,17 +249,6 @@ The shared file `infrastructure/docker-compose.staging.yml` covers both — env **Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr- logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up. -## Agent Skills & Config - -Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink). - -``` -.agents/skills/ ← canonical location -.claude/skills ← symlink → ../.agents/skills -``` - -Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup. - ## OpenSpec Workflow Specs live in `openspec/`. Use these slash commands: diff --git a/FEDERATION.md b/FEDERATION.md deleted file mode 100644 index aa4b88f..0000000 --- a/FEDERATION.md +++ /dev/null @@ -1,198 +0,0 @@ -# Federation protocol - -trails.cool's Journal federates over [ActivityPub](https://www.w3.org/TR/activitypub/), -implemented with [Fedify](https://fedify.dev). This document describes the -wire protocol precisely enough for another implementation to interoperate -deliberately — actor discovery, the object and activity types we emit and -accept, addressing, signatures, deduplication, delivery retry, and -moderation. Examples use `trails.example` for our instance and -`remote.example` for a peer. - -Federation is per-instance opt-in (`FEDERATION_ENABLED`). When it is off, -every federation surface returns 404 — a disabled instance is -indistinguishable from one without the feature. Only users with -`profile_visibility = 'public'` federate; a private user's actor, -WebFinger, inbox, and outbox all 404, so their existence never leaks. - -## Actor discovery - -### WebFinger - -`GET /.well-known/webfinger?resource=acct:alice@trails.example` resolves a -handle to an actor IRI: - -```json -{ - "subject": "acct:alice@trails.example", - "links": [ - { "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" } - ] -} -``` - -### Actor - -`GET https://trails.example/users/alice` with `Accept: application/activity+json` -returns a `Person`. The actor IRI and the human profile `url` are the same -by design (browsers get HTML at that URL via content negotiation): - -```json -{ - "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], - "id": "https://trails.example/users/alice", - "type": "Person", - "preferredUsername": "alice", - "name": "Alice", - "summary": "trail runner", - "url": "https://trails.example/users/alice", - "inbox": "https://trails.example/users/alice/inbox", - "outbox": "https://trails.example/users/alice/outbox", - "publicKey": { - "id": "https://trails.example/users/alice#main-key", - "owner": "https://trails.example/users/alice", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n" - }, - "assertionMethod": [ { "type": "Multikey", "…": "…" } ], - "attachment": [ - { "type": "PropertyValue", "name": "🥾 trails.cool", "value": "trails.example/users/alice" }, - { "type": "PropertyValue", "name": "Instance", "value": "trails.example" } - ] -} -``` - -`publicKey` is the RSA key Mastodon reads for HTTP-Signature verification; -`assertionMethod` carries the same keys as Multikeys for newer stacks. - -### NodeInfo (software discovery) - -`GET /.well-known/nodeinfo` links to `GET /nodeinfo/2.1`: - -```json -{ - "version": "2.1", - "software": { "name": "trails-cool", "version": "1.2.3", "homepage": "https://trails.cool/" }, - "protocols": ["activitypub"], - "usage": { "users": {}, "localPosts": 0, "localComments": 0 } -} -``` - -`software.name` is the machine-readable "this is a trails instance" marker -used by the trails-to-trails outbound check. Usage counts are deliberately -zeroed — publishing per-instance counts is a privacy decision we have not -made. - -## Objects and activities - -Activities correspond to a user's journal entries. The object model is -deliberately Mastodon-compatible: a `Create(Note)` whose HTML `content` -summarizes the activity and whose `url` links to the journal detail page. -(A first-class `trails:Route` object type is planned with route-federation; -today everything is a Note.) - -### Note - -```json -{ - "id": "https://trails.example/activities/01H…", - "type": "Note", - "attributedTo": "https://trails.example/users/alice", - "content": "

Morning trail run — 12.4 km, 480 m up

", - "url": "https://trails.example/activities/01H…", - "published": "2026-07-13T07:12:00Z", - "to": ["https://www.w3.org/ns/activitystreams#Public"] -} -``` - -The Note IRI (`/activities/`) is dereferenceable and serves -`application/activity+json` — Mastodon's search-fetch and strict re-fetch -of pushed objects both rely on this. - -### Create / Delete - -A publish is a `Create` wrapping the Note; the activity id is the object IRI -with a `#create` fragment. A retraction is a `Delete` wrapping a `Tombstone` -at the same object IRI: - -```json -{ "id": "https://trails.example/activities/01H…#create", "type": "Create", - "actor": "https://trails.example/users/alice", - "object": { "…": "the Note above" }, - "published": "2026-07-13T07:12:00Z", - "to": ["https://www.w3.org/ns/activitystreams#Public"] } -``` - -Note that a `Delete` poisons the object URI on the remote forever (remotes -tombstone it); re-publishing the same URI after a Delete is silently -refused by strict remotes. - -### Follow graph - -The inbox is **narrow** — only follow-graph activities are processed; -anything else is acknowledged (`202`) and dropped. - -| Inbound | Effect | -|---|---| -| `Follow` (remote → local public actor) | auto-accepted; we push back an `Accept(Follow)` | -| `Undo(Follow)` | removes the follow | -| `Accept(Follow)` | settles our outgoing Follow; triggers the first outbox poll | -| `Reject(Follow)` | drops our pending outgoing Follow | - -## Addressing - -Public activities are addressed to `https://www.w3.org/ns/activitystreams#Public` -and **push-delivered** to each accepted remote follower's inbox (fan-out, -one delivery per follower). We do not implement shared-inbox delivery. -Remotes do not backfill history — only pushed or individually-fetched -objects appear on a peer. - -## Signatures - -All inbound activities must carry a valid HTTP Signature; Fedify verifies it -against the sending actor's `publicKey` (fetched and cached). Unsigned or -badly-signed requests are rejected. Outbound deliveries are signed with the -sending user's key. An actor changing keys requires the remote to re-fetch -the actor document. - -## Deduplication - -Delivery is at-least-once, so receivers must be idempotent. trails dedups -inbound activities two ways: - -- **`Create(Note)`** — idempotent via a unique constraint on the activity's - origin IRI (`remote_origin_iri`); a redelivered Create is a no-op. -- **Follow-graph activities** (`Follow` / `Undo` / `Accept` / `Reject`) — the - activity IRI is recorded in `federation_processed_activities` on first - receipt (insert-or-drop before any side effect); a redelivery is dropped - and counted (`federation_inbox_dropped_total{reason="duplicate"}`). - Records are retained ≥ 30 days, which comfortably exceeds the - HTTP-Signature date-freshness window, then swept. - -## Delivery retry - -Delivery queueing and retry state are **durable** — they survive process -restarts and deploys (backed by PostgreSQL via pg-boss; Fedify owns the -retry policy). On a `5xx` or timeout, a delivery retries with exponential -backoff, giving up after a bounded budget (~8 attempts spanning roughly a -day) before a permanent failure is logged. Deliveries are paced to at most -1 request/second per remote host. Metrics: -`federation_delivery_total{outcome}` and `federation_queue_depth`. - -## Moderation - -An operator can block a federation instance by domain (exact-host match). -A blocked instance is **inert in both directions**: - -- its inbound activities are silently dropped (`202`, no error oracle) and - counted (`federation_inbox_dropped_total{reason="blocked"}`); -- it receives no deliveries (blocked recipients are filtered from fan-out); -- we never fetch its actors or outboxes. - -Blocking is effective immediately (checked per request / per job, no cache). -The operator procedure (a SQL insert/delete against -`journal.federation_blocked_instances`) is documented in the -[deployment runbook](docs/deployment.md#blocking-an-instance). - ---- - -*Kept current as federation capabilities change. Specs: -`openspec/specs/social-federation` and `openspec/specs/federation-operations`.* diff --git a/README.md b/README.md index 1bc4215..36f7493 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,6 @@ docker compose up -d See [docs/architecture.md](docs/architecture.md) for details on self-hosting configuration. -## Federation - -The Journal federates over ActivityPub. The wire protocol — actor -discovery, object/activity types with JSON examples, addressing, -signatures, deduplication, delivery retry, and moderation — is documented -in [FEDERATION.md](FEDERATION.md), which is precise enough for another -implementation to interoperate against. - ## Philosophy - **Privacy by design** — The Planner collects zero user data diff --git a/apps/journal/.env.example b/apps/journal/.env.example index 8a51ed0..ef8718a 100644 --- a/apps/journal/.env.example +++ b/apps/journal/.env.example @@ -49,14 +49,6 @@ # WAHOO_CLIENT_SECRET= # WAHOO_WEBHOOK_TOKEN= -# Garmin Connect Developer Program credentials (spec: garmin-import). -# Requires an approved program application; without these the Garmin -# provider is hidden on /settings/connections. The OAuth callback to -# register with Garmin is `/api/sync/callback/garmin`, the -# notification endpoint `/api/sync/webhook/garmin`. -# GARMIN_CLIENT_ID= -# GARMIN_CLIENT_SECRET= - # Integration test secret (only needed if running the integration # test suite that drives the API directly). Generate with # `openssl rand -hex 32`. diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 306948b..585803c 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-slim AS base +FROM node:25-slim AS base # curl is used by the docker-compose healthcheck (node:25-slim is Debian # trixie-slim and ships neither curl nor wget by default). RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* @@ -9,12 +9,13 @@ FROM base AS deps COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/journal/package.json apps/journal/ COPY packages/types/package.json packages/types/ +COPY packages/ui/package.json packages/ui/ +COPY packages/map/package.json packages/map/ COPY packages/gpx/package.json packages/gpx/ COPY packages/i18n/package.json packages/i18n/ COPY packages/sentry-config/package.json packages/sentry-config/ COPY packages/api/package.json packages/api/ COPY packages/map-core/package.json packages/map-core/ -COPY packages/ui/package.json packages/ui/ COPY packages/db/package.json packages/db/ COPY packages/jobs/package.json packages/jobs/ COPY packages/fit/package.json packages/fit/ @@ -22,16 +23,11 @@ RUN pnpm install --frozen-lockfile FROM base AS build ARG SENTRY_RELEASE -# Client-side Sentry DSN baked into the bundle at build time. Empty (or -# unset) produces a Sentry-free client. Public-by-design: the DSN -# appears in the shipped client JS regardless. -ARG VITE_SENTRY_DSN="" COPY --from=deps /app/ ./ COPY . . RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \ SENTRY_RELEASE="$SENTRY_RELEASE" \ - VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \ pnpm --filter @trails-cool/journal build FROM base AS runtime @@ -40,7 +36,6 @@ COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules COPY --from=build /app/apps/journal/build ./apps/journal/build COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts -COPY --from=build /app/apps/journal/serve-static.ts ./apps/journal/serve-static.ts COPY --from=build /app/apps/journal/app/lib ./apps/journal/app/lib COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json diff --git a/apps/journal/app/components/CollectionPage.tsx b/apps/journal/app/components/CollectionPage.tsx index 2632b44..08c8bab 100644 --- a/apps/journal/app/components/CollectionPage.tsx +++ b/apps/journal/app/components/CollectionPage.tsx @@ -4,9 +4,6 @@ interface Entry { username: string; displayName: string | null; domain: string; - /** Local path (`/users/x`) or, for federated entries, the remote profile URL. */ - profileUrl: string; - remote: boolean; } interface Props { @@ -45,10 +42,9 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) { ) : ( -
-

- Federation (ActivityPub) -

-

- Applies only when your profile visibility is public. - Private profiles do not federate at all — no actor object, no - WebFinger, no inbox. -

-
    -
  • - Your actor object (fetchable by any fediverse server) exposes: - username, display name, bio, profile link, and your public - signing key. Never your email, never private content. -
  • -
  • - Activities you mark public are pushed to the - servers of your accepted remote followers and listed in your - public outbox. Once delivered, copies live on those servers - under their policies — un-publishing sends a retraction, but - remote deletion cannot be guaranteed (and remote servers that - processed a retraction will not re-show a later re-publish). -
  • -
  • - Signing keys: one RSA keypair per user. The private key is - stored encrypted at rest and never leaves this server. -
  • -
  • - Remote actor cache: for accounts that interact with this - instance we store their public profile basics (handle, display - name, inbox/outbox URLs, public key) to render follower lists - and feeds without re-fetching. -
  • -
  • - Remote content: public (or followers-only) activities from - trails users you follow on other instances are cached here for - your feed — followers-only items are shown only to the - follower whose follow brought them in. -
  • -
  • - Inbox traffic (signed requests from other servers) appears in - the standard server logs (14-day retention, see section 4) and - is rate-limited per source instance. -
  • -
-
-

Sentry