docs(04): create phase plan

Phase 04: Tags & Organization
- 3 plan(s) in 2 wave(s)
- Wave 1: 04-01 (tags schema), 04-02 (pin/due date) - parallel
- Wave 2: 04-03 (tag UI) - depends on 04-01
- Ready for execution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Richter
2026-01-31 12:49:53 +01:00
parent 307e4eff92
commit 4fe198eb0a
4 changed files with 818 additions and 4 deletions

View File

@@ -88,12 +88,12 @@ Plans:
5. User can pin/favorite an entry for quick access 5. User can pin/favorite an entry for quick access
6. User can set a due date on a task 6. User can set a due date on a task
7. Pinned entries appear in a dedicated section at top of list 7. Pinned entries appear in a dedicated section at top of list
**Plans**: TBD **Plans**: 3 plans
Plans: Plans:
- [ ] 04-01: TBD - [ ] 04-01-PLAN.md — Tags schema with case-insensitive index and tagRepository
- [ ] 04-02: TBD - [ ] 04-02-PLAN.md — Pin/favorite and due date UI (uses existing schema columns)
- [ ] 04-03: TBD - [ ] 04-03-PLAN.md — Tag input component with Svelecte autocomplete
### Phase 5: Search ### Phase 5: Search
**Goal**: Users can find entries through search and filtering **Goal**: Users can find entries through search and filtering

View File

@@ -0,0 +1,183 @@
---
phase: 04-tags
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/lib/server/db/schema.ts
- src/lib/server/db/repository.ts
autonomous: true
must_haves:
truths:
- "Tags table exists with case-insensitive unique constraint"
- "Entry-tag associations stored in junction table"
- "Finding tag by name is case-insensitive"
- "Updating entry tags replaces entire tag set atomically"
artifacts:
- path: "src/lib/server/db/schema.ts"
provides: "tags and entryTags table definitions with relations"
contains: "tags = sqliteTable"
- path: "src/lib/server/db/repository.ts"
provides: "tagRepository singleton with CRUD operations"
contains: "tagRepository"
key_links:
- from: "entryTags.entryId"
to: "entries.id"
via: "foreign key with cascade delete"
pattern: "onDelete.*cascade"
- from: "entryTags.tagId"
to: "tags.id"
via: "foreign key with cascade delete"
pattern: "onDelete.*cascade"
---
<objective>
Add database schema and repository layer for many-to-many tag relationships between entries and tags.
Purpose: Foundation for TAG-01, TAG-02, TAG-03, TAG-04 requirements - enables tag storage and querying
Output: tags table, entry_tags junction table, tagRepository with findOrCreate/getAll/updateEntryTags methods
</objective>
<execution_context>
@/home/tho/.claude/get-shit-done/workflows/execute-plan.md
@/home/tho/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-tags/04-RESEARCH.md
@src/lib/server/db/schema.ts
@src/lib/server/db/repository.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Add tags schema with case-insensitive unique index</name>
<files>src/lib/server/db/schema.ts</files>
<action>
Add tags table and entry_tags junction table to schema.ts:
1. Create `lower()` SQL helper function for case-insensitive indexing:
```typescript
import { sql, SQL } from 'drizzle-orm';
import { AnySQLiteColumn } from 'drizzle-orm/sqlite-core';
export function lower(column: AnySQLiteColumn): SQL {
return sql`lower(${column})`;
}
```
2. Add tags table:
```typescript
export const tags = sqliteTable('tags', {
id: text('id').primaryKey(),
name: text('name').notNull(),
createdAt: text('created_at').notNull().$defaultFn(() => new Date().toISOString()),
}, (table) => [
uniqueIndex('tagNameUniqueIndex').on(lower(table.name)),
]);
```
3. Add entry_tags junction table with composite primary key:
```typescript
export const entryTags = sqliteTable('entry_tags', {
entryId: text('entry_id').notNull().references(() => entries.id, { onDelete: 'cascade' }),
tagId: text('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }),
}, (t) => [
primaryKey({ columns: [t.entryId, t.tagId] }),
]);
```
4. Add type exports:
```typescript
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export type EntryTag = typeof entryTags.$inferSelect;
```
Import requirements: Add `uniqueIndex`, `primaryKey` to drizzle-orm/sqlite-core imports.
</action>
<verify>Run `npm run check` - TypeScript compiles without errors</verify>
<done>Schema file contains tags table with case-insensitive unique index and entry_tags junction table with cascade deletes</done>
</task>
<task type="auto">
<name>Task 2: Create tagRepository with tag operations</name>
<files>src/lib/server/db/repository.ts</files>
<action>
Add TagRepository interface and implementation after ImageRepository:
1. Import new schema tables:
```typescript
import { entries, images, tags, entryTags, lower, ... } from './schema';
```
2. Define TagRepository interface:
```typescript
export interface TagRepository {
findOrCreate(name: string): Tag;
getAll(): Tag[];
getById(id: string): Tag | undefined;
getByEntryId(entryId: string): Tag[];
updateEntryTags(entryId: string, tagNames: string[]): void;
}
```
3. Implement SQLiteTagRepository:
- `findOrCreate(name)`:
- Normalize: `name.trim()`
- Query with `sql\`lower(${tags.name}) = lower(${normalizedName})\``
- If exists, return it
- If not, insert with nanoid() and return
- `getAll()`:
- Select all from tags ordered by name ASC
- `getById(id)`:
- Simple select where eq(tags.id, id)
- `getByEntryId(entryId)`:
- Join entry_tags with tags where entryId matches
- Return Tag[] (the tag objects, not junction records)
- `updateEntryTags(entryId, tagNames)`:
- Delete all from entry_tags where entryId matches
- For each tagName: findOrCreate tag, then insert into entry_tags
4. Export singleton:
```typescript
export const tagRepository: TagRepository = new SQLiteTagRepository();
```
Import `sql` from 'drizzle-orm' for case-insensitive queries.
</action>
<verify>Run `npm run check` - TypeScript compiles. Manually test in dev console or add temporary test route if needed.</verify>
<done>tagRepository exported with findOrCreate, getAll, getById, getByEntryId, and updateEntryTags methods working correctly</done>
</task>
</tasks>
<verification>
1. `npm run check` passes
2. Schema includes tags table with unique case-insensitive index on name
3. Schema includes entry_tags with composite PK and cascade deletes
4. tagRepository singleton exported with all five methods
5. Run `npm run db:push` to sync schema to database
</verification>
<success_criteria>
- Tags table created with case-insensitive unique constraint
- Entry-tag junction table links entries to tags with cascade delete
- tagRepository.findOrCreate returns same tag for "work", "Work", "WORK"
- tagRepository.updateEntryTags atomically replaces all tags for an entry
</success_criteria>
<output>
After completion, create `.planning/phases/04-tags/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,284 @@
---
phase: 04-tags
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- src/routes/+page.server.ts
- src/routes/+page.svelte
- src/lib/components/EntryCard.svelte
- src/lib/components/EntryList.svelte
autonomous: true
must_haves:
truths:
- "User can click a pin button to toggle pinned state"
- "User can set a due date on any entry"
- "Pinned entries appear in a dedicated section at top of list"
- "Due date shows on entry card when set"
artifacts:
- path: "src/routes/+page.server.ts"
provides: "togglePin and updateDueDate form actions"
contains: "togglePin"
- path: "src/lib/components/EntryCard.svelte"
provides: "Pin button and due date picker in expanded view"
contains: "togglePin"
- path: "src/lib/components/EntryList.svelte"
provides: "Pinned section above regular entries"
contains: "pinned"
key_links:
- from: "EntryCard pin button"
to: "?/togglePin action"
via: "form submit or fetch"
pattern: "togglePin"
- from: "EntryCard due date input"
to: "?/updateDueDate action"
via: "form submit or fetch"
pattern: "updateDueDate"
---
<objective>
Add pin/favorite and due date features using existing schema columns (pinned, dueDate already in entries table).
Purpose: Enables ORG-01, ORG-02, ORG-03 requirements - quick access to important entries and task scheduling
Output: Toggle pin button, due date picker, pinned entries section at top of list
</objective>
<execution_context>
@/home/tho/.claude/get-shit-done/workflows/execute-plan.md
@/home/tho/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@src/routes/+page.server.ts
@src/routes/+page.svelte
@src/lib/components/EntryCard.svelte
@src/lib/components/EntryList.svelte
@src/lib/server/db/schema.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Add togglePin and updateDueDate form actions</name>
<files>src/routes/+page.server.ts</files>
<action>
Add two new form actions to the existing actions object:
1. `togglePin` action:
```typescript
togglePin: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) {
return fail(400, { error: 'Entry ID is required' });
}
const existing = entryRepository.getById(id);
if (!existing) {
return fail(404, { error: 'Entry not found' });
}
// Toggle pinned state
entryRepository.update(id, { pinned: !existing.pinned });
return { success: true };
}
```
2. `updateDueDate` action:
```typescript
updateDueDate: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id')?.toString();
const dueDate = formData.get('dueDate')?.toString() || null;
if (!id) {
return fail(400, { error: 'Entry ID is required' });
}
const existing = entryRepository.getById(id);
if (!existing) {
return fail(404, { error: 'Entry not found' });
}
// Update due date (empty string becomes null)
entryRepository.update(id, { dueDate: dueDate || null });
return { success: true };
}
```
Note: The dueDate is stored as ISO date string (YYYY-MM-DD format from HTML5 date input).
</action>
<verify>Run `npm run check` - TypeScript compiles. Start dev server and test actions via form submission.</verify>
<done>togglePin and updateDueDate form actions work correctly, entries.pinned toggles and entries.dueDate updates</done>
</task>
<task type="auto">
<name>Task 2: Add pin button, due date picker, and pinned section UI</name>
<files>src/lib/components/EntryCard.svelte, src/lib/components/EntryList.svelte</files>
<action>
**EntryCard.svelte changes:**
1. Add state for due date editing:
```typescript
let editDueDate = $state(entry.dueDate || '');
```
2. Sync dueDate with entry on collapse (add to existing $effect):
```typescript
$effect(() => {
if (!expanded) {
// ... existing syncs
editDueDate = entry.dueDate || '';
}
});
```
3. Add async function for pin toggle (similar to debouncedSave pattern):
```typescript
async function handleTogglePin() {
const formData = new FormData();
formData.append('id', entry.id);
await fetch('?/togglePin', {
method: 'POST',
body: formData
});
await invalidateAll();
}
```
4. Add async function for due date change:
```typescript
async function handleDueDateChange(e: Event) {
const input = e.target as HTMLInputElement;
editDueDate = input.value;
const formData = new FormData();
formData.append('id', entry.id);
formData.append('dueDate', input.value);
await fetch('?/updateDueDate', {
method: 'POST',
body: formData
});
await invalidateAll();
}
```
5. In collapsed view header (after the expand arrow), add pin indicator:
```svelte
{#if entry.pinned}
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v3.586l1.707 1.707A1 1 0 0117 10v1a1 1 0 01-1 1h-4v5a1 1 0 11-2 0v-5H6a1 1 0 01-1-1v-1a1 1 0 01.293-.707L7 7.586V4z" />
</svg>
{/if}
```
6. In collapsed view, show due date if set (near type badge):
```svelte
{#if entry.dueDate}
<span class="text-xs text-gray-500">{entry.dueDate}</span>
{/if}
```
7. In expanded view, add pin button and due date picker (in the flex row with type and delete):
```svelte
<div class="flex items-center gap-3">
<button
type="button"
onclick={handleTogglePin}
class="p-2 rounded-lg hover:bg-gray-100 {entry.pinned ? 'text-yellow-500' : 'text-gray-400'}"
aria-label={entry.pinned ? 'Unpin entry' : 'Pin entry'}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v3.586l1.707 1.707A1 1 0 0117 10v1a1 1 0 01-1 1h-4v5a1 1 0 11-2 0v-5H6a1 1 0 01-1-1v-1a1 1 0 01.293-.707L7 7.586V4z" />
</svg>
</button>
<div>
<label for="due-date-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
<input
id="due-date-{entry.id}"
type="date"
value={editDueDate}
onchange={handleDueDateChange}
class="px-3 py-2 border border-gray-200 rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
```
**EntryList.svelte changes:**
1. Separate entries into pinned and unpinned:
```typescript
let pinnedEntries = $derived(entries.filter(e => e.pinned));
let unpinnedEntries = $derived(entries.filter(e => !e.pinned));
```
2. Render pinned section first if there are any:
```svelte
{#if pinnedEntries.length > 0}
<div class="mb-4">
<h2 class="text-sm font-medium text-gray-500 uppercase tracking-wide mb-2 px-4 md:px-0">Pinned</h2>
<div class="space-y-0 md:space-y-3">
{#each pinnedEntries as entry (entry.id)}
<EntryCard {entry} />
{/each}
</div>
</div>
{/if}
{#if unpinnedEntries.length > 0}
<div class="space-y-0 md:space-y-3">
{#each unpinnedEntries as entry (entry.id)}
<EntryCard {entry} />
{/each}
</div>
{/if}
{#if entries.length === 0}
<!-- existing empty state -->
{/if}
```
</action>
<verify>Run `npm run dev`, test:
1. Click pin button in expanded view - entry moves to pinned section
2. Click again - entry moves back to regular list
3. Set due date - shows on collapsed card
4. Clear due date - disappears from card</verify>
<done>Pin toggle works, due date picker works, pinned entries appear in dedicated section at top</done>
</task>
</tasks>
<verification>
1. `npm run check` passes
2. Pin button toggles entry.pinned state
3. Due date picker updates entry.dueDate
4. Pinned section appears at top of list with pinned entries
5. Pin icon visible on collapsed pinned entries
6. Due date visible on collapsed entries when set
</verification>
<success_criteria>
- User can toggle pin status with visual feedback (ORG-01)
- User can set/clear due date using native date picker (ORG-02)
- Pinned entries appear in labeled section at top of list (ORG-03)
- Due date displays on entry card in collapsed view
</success_criteria>
<output>
After completion, create `.planning/phases/04-tags/04-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,347 @@
---
phase: 04-tags
plan: 03
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- package.json
- src/lib/components/TagInput.svelte
- src/lib/components/EntryCard.svelte
- src/routes/+page.server.ts
- src/routes/+page.svelte
autonomous: true
must_haves:
truths:
- "User can add multiple tags to an entry"
- "User can remove tags from an entry"
- "Tag input shows autocomplete suggestions from existing tags"
- "Tags are case-insensitive (work = Work = WORK)"
- "New tags can be created inline during entry editing"
artifacts:
- path: "src/lib/components/TagInput.svelte"
provides: "Multi-select tag input with autocomplete and creation"
contains: "Svelecte"
- path: "src/lib/components/EntryCard.svelte"
provides: "Tag display in collapsed view, TagInput in expanded view"
contains: "TagInput"
- path: "src/routes/+page.server.ts"
provides: "updateTags form action and tags in load data"
contains: "updateTags"
key_links:
- from: "TagInput component"
to: "availableTags prop"
via: "passed from page data"
pattern: "availableTags"
- from: "EntryCard save"
to: "?/updateTags action"
via: "fetch call"
pattern: "updateTags"
- from: "+page.server.ts load"
to: "tagRepository.getByEntryId"
via: "attach tags to entries"
pattern: "getByEntryId"
---
<objective>
Add tag input UI with Svelecte for autocomplete and inline tag creation, integrated into EntryCard.
Purpose: Enables TAG-01, TAG-02, TAG-03, TAG-04 requirements - full tag management with user-friendly input
Output: TagInput component with Svelecte, tag display on entries, updateTags form action
</objective>
<execution_context>
@/home/tho/.claude/get-shit-done/workflows/execute-plan.md
@/home/tho/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-tags/04-RESEARCH.md
@.planning/phases/04-tags/04-01-SUMMARY.md
@src/lib/server/db/schema.ts
@src/lib/server/db/repository.ts
@src/lib/components/EntryCard.svelte
@src/routes/+page.server.ts
@src/routes/+page.svelte
</context>
<tasks>
<task type="auto">
<name>Task 1: Install Svelecte and update load/actions for tags</name>
<files>package.json, src/routes/+page.server.ts</files>
<action>
**Install Svelecte:**
```bash
npm install svelecte
```
**Update +page.server.ts:**
1. Import tagRepository:
```typescript
import { entryRepository, imageRepository, tagRepository } from '$lib/server/db/repository';
```
2. Update load function to include tags on entries and all available tags:
```typescript
export const load: PageServerLoad = async ({ url }) => {
const showCompleted = url.searchParams.get('showCompleted') === 'true';
const entries = entryRepository.getOrdered({ showCompleted });
// Attach images AND tags to each entry
const entriesWithData = entries.map((entry) => ({
...entry,
images: imageRepository.getByEntryId(entry.id),
tags: tagRepository.getByEntryId(entry.id)
}));
// Get all tags for autocomplete
const allTags = tagRepository.getAll();
return {
entries: entriesWithData,
allTags,
showCompleted
};
};
```
3. Add updateTags action:
```typescript
updateTags: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id')?.toString();
const tagsJson = formData.get('tags')?.toString() || '[]';
if (!id) {
return fail(400, { error: 'Entry ID is required' });
}
const existing = entryRepository.getById(id);
if (!existing) {
return fail(404, { error: 'Entry not found' });
}
try {
const tagNames = JSON.parse(tagsJson) as string[];
tagRepository.updateEntryTags(id, tagNames);
return { success: true };
} catch (e) {
return fail(400, { error: 'Invalid tags format' });
}
}
```
</action>
<verify>Run `npm run check` - TypeScript compiles. Run `npm run dev` and check console for load data including allTags.</verify>
<done>Svelecte installed, load returns entries with tags and allTags array, updateTags action processes tag changes</done>
</task>
<task type="auto">
<name>Task 2: Create TagInput component and integrate into EntryCard</name>
<files>src/lib/components/TagInput.svelte, src/lib/components/EntryCard.svelte, src/routes/+page.svelte</files>
<action>
**Create TagInput.svelte:**
```svelte
<script lang="ts">
import Svelecte from 'svelecte';
import type { Tag } from '$lib/server/db/schema';
interface Props {
availableTags: Tag[];
selectedTags: Tag[];
onchange: (tags: Tag[]) => void;
}
let { availableTags, selectedTags, onchange }: Props = $props();
// Transform tags to Svelecte format
let options = $derived(availableTags.map(t => ({ value: t.id, label: t.name })));
let value = $derived(selectedTags.map(t => ({ value: t.id, label: t.name })));
function handleChange(event: CustomEvent) {
// event.detail is array of {value, label} objects
// For new tags (creatable), value === label (the text)
const selected = event.detail || [];
// Map back to Tag format, handling new tags (where value === label means new)
const tags: Tag[] = selected.map((item: { value: string; label: string }) => {
// If value matches an existing tag id, use that tag
const existing = availableTags.find(t => t.id === item.value);
if (existing) return existing;
// Otherwise it's a new tag - create placeholder (will be created on save)
return {
id: item.label, // Use label as temp id for new tags
name: item.label,
createdAt: new Date().toISOString()
};
});
onchange(tags);
}
</script>
<Svelecte
{options}
{value}
multiple
creatable
valueField="value"
labelField="label"
placeholder="Add tags..."
on:change={handleChange}
class="tag-input"
/>
<style>
:global(.tag-input) {
--sv-bg: white;
--sv-border: 1px solid #e5e7eb;
--sv-border-radius: 0.5rem;
--sv-min-height: 42px;
}
</style>
```
**Update EntryCard.svelte:**
1. Import TagInput and Tag type:
```typescript
import TagInput from './TagInput.svelte';
import type { Tag } from '$lib/server/db/schema';
```
2. Add availableTags prop to Props interface:
```typescript
interface Props {
entry: EntryWithImages & { tags: Tag[] };
availableTags: Tag[];
}
let { entry, availableTags }: Props = $props();
```
3. Add state for tag editing:
```typescript
let editTags = $state<Tag[]>(entry.tags || []);
```
4. Sync tags with entry on collapse:
```typescript
$effect(() => {
if (!expanded) {
// ... existing syncs
editTags = entry.tags || [];
}
});
```
5. Add function to handle tag changes:
```typescript
async function handleTagsChange(newTags: Tag[]) {
editTags = newTags;
// Save immediately (no debounce for tags)
const formData = new FormData();
formData.append('id', entry.id);
formData.append('tags', JSON.stringify(newTags.map(t => t.name)));
await fetch('?/updateTags', {
method: 'POST',
body: formData
});
await invalidateAll();
}
```
6. Display tags in collapsed view (after type badge):
```svelte
{#if entry.tags?.length > 0}
<div class="flex flex-wrap gap-1 mt-1">
{#each entry.tags.slice(0, 3) as tag}
<span class="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full">{tag.name}</span>
{/each}
{#if entry.tags.length > 3}
<span class="text-xs text-gray-400">+{entry.tags.length - 3}</span>
{/if}
</div>
{/if}
```
7. Add TagInput in expanded view (after type selector, before delete button):
```svelte
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tags</label>
<TagInput
{availableTags}
selectedTags={editTags}
onchange={handleTagsChange}
/>
</div>
```
**Update +page.svelte:**
Pass allTags to EntryList, then from EntryList to EntryCard. Or simpler - pass directly:
1. Update EntryList.svelte to accept and pass availableTags:
```typescript
interface Props {
entries: EntryWithImages[];
availableTags: Tag[];
}
let { entries, availableTags }: Props = $props();
```
And pass to each EntryCard:
```svelte
<EntryCard {entry} {availableTags} />
```
2. Update +page.svelte to pass allTags:
```svelte
<EntryList entries={data.entries} availableTags={data.allTags} />
```
Import Tag type if needed for type checking.
</action>
<verify>Run `npm run dev`, test:
1. Expand an entry - TagInput appears
2. Type a tag name - autocomplete shows matching existing tags
3. Select existing tag - appears as chip
4. Type new tag and press Enter - tag created
5. Click X on tag chip - tag removed
6. Collapse and expand - tags persist</verify>
<done>TagInput component with Svelecte works, shows autocomplete, allows creation, displays tags on collapsed entries</done>
</task>
</tasks>
<verification>
1. `npm run check` passes
2. Svelecte package installed and loads correctly
3. Tag autocomplete shows all existing tags
4. User can add tags (existing or new) to entries
5. User can remove tags by clicking X
6. Tags display on collapsed entry cards
7. Case-insensitive: typing "Work" matches existing "work" tag
</verification>
<success_criteria>
- User can add multiple tags to entry via Svelecte input (TAG-01)
- User can remove tags by clicking X on chips (TAG-02)
- Autocomplete shows matching existing tags as user types (TAG-03)
- Existing tags matched case-insensitively (TAG-04)
- Tags visible on collapsed entry cards (max 3 with +N indicator)
</success_criteria>
<output>
After completion, create `.planning/phases/04-tags/04-03-SUMMARY.md`
</output>