From 9eb208a23b7d41aca30b719718ea0fc8d2d0fd69 Mon Sep 17 00:00:00 2001 From: Jose Ernesto Suarez Date: Wed, 25 Mar 2026 15:11:56 +0100 Subject: [PATCH] Add week command for Obsidian weekly task organization via Claude New `week` command that fetches the current week's Google Tasks, groups by category (emoji-based), uses Claude CLI in parallel to suggest project groupings enriched with Obsidian vault context, and writes a structured Week file with category/project hierarchy and TaskNotes creation. Key changes: - Extract TaskClassification module from InteractiveMode for reuse - Add due_min/due_max date filtering to Google Tasks API client - Add vault_path configuration to settings - Create WeekPlanner class with parallel Claude CLI integration - Wire up `week` command in interactive and console modes Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/tasker | 10 + .../specs/2026-03-24-week-command-design.md | 331 ++++++++++++++ lib/google_tasks_client.rb | 9 +- lib/interactive_mode.rb | 119 +++-- lib/settings_manager.rb | 18 +- lib/task_classification.rb | 58 +++ lib/week_planner.rb | 414 ++++++++++++++++++ 7 files changed, 895 insertions(+), 64 deletions(-) create mode 100644 docs/superpowers/specs/2026-03-24-week-command-design.md create mode 100644 lib/task_classification.rb create mode 100644 lib/week_planner.rb diff --git a/bin/tasker b/bin/tasker index a45cb45..10fd5bf 100755 --- a/bin/tasker +++ b/bin/tasker @@ -194,6 +194,16 @@ class TaskerCLI < Thor puts "Error: #{e.message}" end + desc 'week LIST_ID', 'Generate Obsidian week plan with category/project grouping via Claude' + def week(list_id) + require_relative '../lib/week_planner' + require_relative '../lib/settings_manager' + planner = WeekPlanner.new(client, SettingsManager.new) + planner.generate_week(list_id) + rescue => e + puts "Error: #{e.message}" + end + private def client diff --git a/docs/superpowers/specs/2026-03-24-week-command-design.md b/docs/superpowers/specs/2026-03-24-week-command-design.md new file mode 100644 index 0000000..da667ed --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-week-command-design.md @@ -0,0 +1,331 @@ +# Week Command: Obsidian Weekly Task Organization + +## Problem + +Weekly task planning requires manually grouping ~80 groomed Google Tasks into categories and projects inside Obsidian Week files. This is tedious and time-consuming. The data (categories, priorities) already exists in Google Tasks notes but the organizational structure (projects) must be inferred. + +## Solution + +New `week` command that fetches the current week's tasks, groups by category, uses Claude CLI to suggest project groupings (with vault context), and writes a structured Obsidian Week file. + +## Output Format + +```markdown +→23 to 29 March 2026 ← preserved from existing file +[[existing wikilinks]] ← preserved from existing file + +# 🧩 Product + +## [[Fix the billing]] +- [ ] 2026-03-24 🔥 User facing UI billing %%encoded_id%% +- [ ] 2026-03-24 🟢 Fix problem with visibility %%encoded_id%% + +## [[Play with Shells POC]] +- [ ] 2026-03-24 🟢 prepare self-demo button %%encoded_id%% + +# 📈 Business + +## [[Capacity planning]] +- [ ] 2026-03-24 🔥 review RI purchases %%encoded_id%% +- [ ] 2026-03-24 🟠 Generate a doc with RI purchases %%encoded_id%% + +# No Category + +## [[Misc Admin]] +- [ ] 2026-03-24 some uncategorized task %%encoded_id%% +``` + +## Data Flow + +``` +Google Tasks API (fetch week's uncompleted tasks) + │ + ▼ +Group by category (emoji extraction — existing method) + │ + ▼ +Scan Obsidian vault (all .md files → extract note titles) + │ + ▼ +Parallel: spawn `claude -p` per category (Ruby threads) + │ Context: task titles + all vault note names + │ Returns JSON: {"Project Name": ["task title", ...]} + ▼ +Present suggestions per category + │ + ▼ +Interactive: accept / move individual tasks / create new projects + │ + ▼ +Generate markdown (sorted by priority within each project) + │ + ▼ +Write Week N.md (preserve header, replace groomed section) + │ + ▼ +Create missing TaskNotes (backlink to [[Week N]]) +``` + +## Claude CLI Integration + +Shell out to `claude -p` (user's existing OAuth auth). One call per category, all calls run in parallel via Ruby threads. + +### Prompt Template + +``` +You are organizing tasks for weekly planning. Given these tasks in the "{category}" +category, group them into logical projects. + +Existing notes in the Obsidian vault (prefer reusing these names when tasks match): +{newline-separated list of all vault note titles} + +Tasks to organize: +1. {task title} +2. {task title} +... + +Return ONLY valid JSON, no markdown fences: +{"Project Name": ["task title exactly as given", ...]} + +Rules: +- Project names: concise (2-5 words), suitable as Obsidian note titles +- Group related tasks (same initiative, system, or goal) +- Single-task projects are fine if the task doesn't belong elsewhere +- Reuse existing vault note names when tasks clearly relate to them +``` + +### Response Parsing + +1. Run `claude -p "..." --output-format json` +2. Parse outer JSON — Claude CLI returns `{"type":"result", "result":"", ...}` +3. Extract `result` field (string containing the JSON project groupings) +4. Parse inner JSON (the `{"Project Name": ["task", ...]}` structure) +5. Fallback on parse failure: one project per task using task title + +### Error Handling for Parallel Calls + +- Each thread rescues exceptions independently; failed categories fall back to one-project-per-task +- 30-second timeout per `claude -p` call via `Timeout.timeout(30)` +- If all calls fail, print warning and proceed with manual grouping mode + +### Thread Implementation + +```ruby +threads = categories.map do |category, tasks| + Thread.new { [category, claude_suggest(category, tasks, vault_notes)] } +end +results = threads.map(&:value).to_h +``` + +## Interactive Confirmation + +Per category, show suggested groupings: + +``` +============================================================ +🧩 Product (8 tasks → 3 projects) +============================================================ + + 1. [[Fix the billing]] (3 tasks) + a. 🔥 User facing UI billing + b. 🟢 Fix problem with visibility + c. 🟢 Add the analysis suite to backoffice + + 2. [[Play with Shells POC]] (2 tasks) + d. 🟢 prepare self-demo button + e. 🟠 mimic the login page from cloudzero + + 3. [[Infrastructure Maintenance]] (3 tasks) + f. 🔥 Set storageclaim default + g. 🟢 Onboarding verification check s3 + h. 🟠 review how onboarding uses KEDA + +Accept? (Y/move/skip) + Y = accept all projects in this category + move = move individual tasks between projects + skip = exclude this entire category from the Week file output +``` + +### Move Flow + +``` +Which task? (letter): f +Current project: Infrastructure Maintenance + +Available projects: + 1. Fix the billing + 2. Play with Shells POC + 3. Infrastructure Maintenance + 4. (new project) + +Move to (1-4): 4 +New project name: AWS Operations +✅ Moved "Set storageclaim default" → [[AWS Operations]] + +Move another? (y/N): +``` + +## Priority Handling + +### Sorting + +Tasks sorted within each project by priority weight: +1. 🔥 Hot (weight 4) +2. 🟢 Must (weight 3) +3. 🟠 Nice (weight 2) +4. 🔴 NotNow (weight 1) +5. No priority (weight 0) + +### Display + +Priority emoji shown on each task line in the output: +``` +- [ ] 2026-03-24 🔥 Task title %%id%% +``` + +### TaskNotes Frontmatter Mapping + +| Emoji | Priority | Frontmatter value | +|-------|----------|-------------------| +| 🔥 | Hot | high | +| 🟢 | Must | normal | +| 🟠 | Nice | low | +| 🔴 | NotNow | none | + +## File Writing + +### Week File + +- Path: `{vault_path}/Week {N}.md` +- Week number: `Date#cweek` (ISO week numbering) +- Week boundaries: Monday 00:00 to Sunday 23:59, local timezone +- **Header preservation**: scan file line by line; everything before a line matching `^# ` (H1 heading) is the header. Preserve header, replace everything from first H1 onward. +- **Sentinel**: if file contains `# All tasks groomed`, that line and everything below is replaced. +- If no file: create fresh with date range header + grouped structure. +- **Idempotency**: re-running `week` in the same week regenerates the grouped section while preserving the header. User is warned before overwrite. + +### TaskNotes + +Created only for projects that don't exist as notes anywhere in the vault. + +Path: `{vault_path}/{ProjectName}.md` + +```yaml +--- +title: {ProjectName} +status: open +priority: normal +scheduled: {monday of the week, YYYY-MM-DD} +dateCreated: {ISO8601 with timezone} +dateModified: {ISO8601 with timezone} +tags: + - task +--- + +[[Week {N}]] +``` + +For existing notes: append `[[Week N]]` to the end of the file body (after frontmatter) if the string `[[Week N]]` is not already present anywhere in the file. No other modifications to existing files. + +## Vault Context Scanning + +Glob `{vault_path}/**/*.md` for all markdown files. Extract titles from filenames (strip path and `.md`). Pass full list to Claude prompt so it can match tasks to existing notes. + +## Settings + +Add `vault_path` to `~/.tasker/settings.json`: + +```json +{ + "vault_path": "~/home", + "categories": [...], + "preferred_days": {...} +} +``` + +Default: `~/home/`. Configurable via `settings` command. + +## Files to Create/Modify + +| File | Action | Purpose | +|------|--------|---------| +| `lib/week_planner.rb` | CREATE | Core logic class | +| `lib/task_classification.rb` | CREATE | Shared module extracting `extract_category_from_notes`, `extract_priority_from_notes`, `get_priority_weight` from `interactive_mode.rb` | +| `lib/google_tasks_client.rb` | MODIFY | Add `due_min:`, `due_max:` params to `list_tasks` (Google Tasks API supports these) | +| `lib/settings_manager.rb` | MODIFY | Add `vault_path` config + settings menu option | +| `lib/interactive_mode.rb` | MODIFY | Add `week` command routing; include `TaskClassification` module | +| `bin/tasker` | MODIFY | Add Thor `week LIST_ID` command | + +### WeekPlanner Class API + +```ruby +class WeekPlanner + def initialize(client, settings_manager) + def generate_week(list_id) + def fetch_week_tasks(list_id) + def group_by_category(tasks) + def scan_vault_notes + def suggest_projects_parallel(categories, vault_notes) + def claude_suggest(category, tasks, vault_notes) + def confirm_assignments(category, suggestions) + def generate_markdown(week_num, all_groups) + def write_week_file(week_num, markdown) + def ensure_project_notes(projects, week_num) +end +``` + +### TaskClassification Module + +Extract from `interactive_mode.rb` into `lib/task_classification.rb`: +- `extract_category_from_notes(notes)` (line 1054) — returns `'🧩Product'` (no space) +- `extract_priority_from_notes(notes)` (line 1037) — returns `'🔥Hot'` +- `get_priority_weight` — priority sorting weights + +**Category display format**: `extract_category_from_notes` returns `'🧩Product'`. For display in headings, split on first non-emoji char: `emoji = '🧩'`, `name = 'Product'` → `# 🧩 Product`. + +Both `WeekPlanner` and `InteractiveMode` include this module. + +## Verification + +1. `ruby bin/tasker interactive` → select list → `week` +2. Verify Claude CLI spawns per category (check terminal output) +3. Confirm interactive flow: accept, move tasks, create new project +4. Check `~/home/Week {N}.md`: header preserved, grouped structure correct +5. Check new TaskNotes created with correct frontmatter +6. Check existing notes get `[[Week N]]` backlink +7. `ruby bin/tasker week LIST_ID` for console mode +8. Test with `DEBUG=1` to see API calls and Claude prompts + +## Task ID Format + +Task IDs in `%%...%%` markers are the raw Google Tasks API IDs (already base64-encoded by Google). Example: `%%LXRrQ1Nqc2pHMTZ5MXpzdw%%`. These are passed through as-is from the API response `task.id`. + +## Google Tasks API Changes + +Add `due_min:` and `due_max:` keyword args to `GoogleTasksClient#list_tasks`: + +```ruby +def list_tasks(list_id, show_completed: false, max_results: nil, due_min: nil, due_max: nil) + # ... existing code ... + response = @service.list_tasks( + list_id, + show_completed: show_completed, + max_results: max_results || 100, + page_token: next_page_token, + due_min: due_min&.rfc3339, # Google Tasks API supports dueMin + due_max: due_max&.rfc3339 # Google Tasks API supports dueMax + ) +``` + +This enables `fetch_week_tasks` to query only the relevant date range rather than fetching all tasks and filtering client-side. + +## Edge Cases + +- **No tasks for the week:** Print message, exit gracefully +- **Claude CLI not installed/no auth:** Print error with setup instructions +- **Claude returns invalid JSON:** Fallback to one project per task, print warning +- **Partial thread failures:** Failed categories fall back individually; successful ones proceed +- **Empty category (all tasks uncategorized):** Group under "# No Category" heading +- **Existing Week file with different format:** Detect header boundary (first `# ` line), preserve above +- **Very large vault (hundreds of notes):** Truncate vault note list in prompt to 200 entries +- **Re-running same week:** Warn user, overwrite grouped section, preserve header diff --git a/lib/google_tasks_client.rb b/lib/google_tasks_client.rb index 421c34e..566041d 100644 --- a/lib/google_tasks_client.rb +++ b/lib/google_tasks_client.rb @@ -1,3 +1,4 @@ +require 'time' require 'google/apis/tasks_v1' require 'googleauth' require 'googleauth/stores/file_token_store' @@ -448,18 +449,20 @@ def delete_task_list(list_id) end end - def list_tasks(list_id, show_completed: false, max_results: nil) + def list_tasks(list_id, show_completed: false, max_results: nil, due_min: nil, due_max: nil) ensure_authenticated handle_api_error do all_tasks = [] next_page_token = nil - + loop do response = @service.list_tasks( list_id, show_completed: show_completed, max_results: max_results || 100, # Default page size - page_token: next_page_token + page_token: next_page_token, + due_min: due_min&.xmlschema, + due_max: due_max&.xmlschema ) # Add tasks from this page diff --git a/lib/interactive_mode.rb b/lib/interactive_mode.rb index 3446f21..c3acd6a 100644 --- a/lib/interactive_mode.rb +++ b/lib/interactive_mode.rb @@ -1,9 +1,12 @@ require_relative 'google_tasks_client' require_relative 'google_calendar_client' require_relative 'settings_manager' +require_relative 'task_classification' require 'readline' class InteractiveMode + include TaskClassification + def initialize(client, calendar_client = nil) @client = client @calendar_client = calendar_client || GoogleCalendarClient.new @@ -869,6 +872,16 @@ def recap_workflow(list_id = nil, date_arg = nil) end end + def week_workflow(list_id = nil) + require_relative 'week_planner' + working_list_id = list_id || @current_list[:id] + planner = WeekPlanner.new(@client, @settings_manager) + planner.generate_week(working_list_id) + rescue => e + puts "Error during week workflow: #{e.message}" + puts e.backtrace.first(3).join("\n") if ENV['DEBUG'] + end + def debug_task_in_current_list(args) return puts "Usage: debug " if args.nil? || args.empty? @@ -1034,47 +1047,6 @@ def get_duration_choice end end - def extract_priority_from_notes(notes) - return nil unless notes - - case notes - when /🔥/ - '🔥Hot' - when /🟢/ - '🟢Must' - when /🟠/ - '🟠Nice' - when /🔴/ - '🔴NotNow' - else - nil - end - end - - def extract_category_from_notes(notes) - return 'No Category' unless notes - - case notes - when /🧩/ - '🧩Product' - when /📈/ - '📈Business' - when /📢/ - '📢Marketing' - when /🔒/ - '🔒Security' - when /💰/ - '💰Finance' - when /💳/ - '💳Sales' - when /🎧/ - '🎧Support' - when /👩‍💼/ - '👩‍💼Others' - else - 'No Category' - end - end def group_tasks_by_category_and_priority(tasks) # Group tasks by category first @@ -1099,20 +1071,6 @@ def group_tasks_by_category_and_priority(tasks) sorted_grouped end - def get_priority_weight_from_text(priority_text) - case priority_text - when '🔥Hot' - 4 - when '🟢Must' - 3 - when '🟠Nice' - 2 - when '🔴NotNow' - 1 - else - 0 # No priority - end - end def build_category_event_description(tasks, category) description = "🏷️ Category: #{category}\n" @@ -1554,6 +1512,12 @@ def handle_command(input) else puts "Error: No list context set. Use 'use ' first." end + when 'week' + if @current_context == :list + week_workflow(@current_list[:id]) + else + puts "Error: No list context set. Use 'use ' first." + end when 'settings' settings_menu else @@ -1591,6 +1555,7 @@ def show_help puts " agenda - Category-based time-blocking with 2-minute rule filtering" puts " grooming - GTD workflow: review unclassified tasks then schedule all overdue/unscheduled tasks" puts " recap [date] - Review day's tasks (completed/incomplete), select tasks by number for follow-ups (today/yesterday/YYYY-MM-DD)" + puts " week - Generate Obsidian week plan with category/project grouping via Claude" else puts "List context commands (available when in a list context):" puts " tasks, list [--completed] [--limit N] - Show tasks in current list" @@ -2005,10 +1970,11 @@ def settings_menu puts "Settings options:" puts " 1. Manage categories" puts " 2. Configure preferred days" - puts " 3. View current settings" - puts " 4. Back to main menu" + puts " 3. Configure vault path" + puts " 4. View current settings" + puts " 5. Back to main menu" puts - print "Select option (1-4): " + print "Select option (1-5): " choice = $stdin.gets.chomp.strip @@ -2018,12 +1984,14 @@ def settings_menu when '2' configure_preferred_days_menu when '3' - view_current_settings + configure_vault_path when '4' + view_current_settings + when '5' puts "Returning to main menu..." break else - puts "Invalid option. Please choose 1-4." + puts "Invalid option. Please choose 1-5." end puts end @@ -2412,10 +2380,41 @@ def view_current_settings puts "-" * 60 display_preferred_days + puts "\n📁 Vault Path:" + puts "-" * 60 + puts " #{@settings_manager.get_vault_path}" + puts "\n💾 Settings file: #{SettingsManager::SETTINGS_FILE}" puts end + def configure_vault_path + puts "\n📁 CONFIGURE VAULT PATH" + puts "=" * 60 + + current_path = @settings_manager.get_vault_path + puts "\nCurrent vault path: #{current_path}" + puts + + print "Enter new vault path (or press Enter to keep current): " + input = $stdin.gets.chomp.strip + + if input.empty? + puts "Vault path unchanged." + return + end + + expanded_path = File.expand_path(input) + + unless Dir.exist?(expanded_path) + puts "❌ Path does not exist: #{expanded_path}" + return + end + + @settings_manager.set_vault_path(expanded_path) + puts "✅ Vault path updated to: #{expanded_path}" + end + def import_categories_from_tasks puts "\n📥 IMPORT CATEGORIES FROM TASKS" puts "=" * 60 diff --git a/lib/settings_manager.rb b/lib/settings_manager.rb index 32d260b..a1d3c1c 100644 --- a/lib/settings_manager.rb +++ b/lib/settings_manager.rb @@ -16,6 +16,8 @@ class SettingsManager { name: 'Others', emoji: '👩‍💼' } ] + DEFAULT_VAULT_PATH = File.expand_path('~/home') + WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] def initialize @@ -98,6 +100,18 @@ def get_preferred_day_for_category(category_name) nil end + # Get the configured vault path + def get_vault_path + @settings['vault_path'] + end + + # Set the vault path + def set_vault_path(path) + @settings['vault_path'] = path + save_settings + true + end + # Get category by emoji def get_category_by_emoji(emoji) @settings['categories'].find { |c| c['emoji'] == emoji } @@ -152,6 +166,7 @@ def load_settings # Ensure all required keys exist @settings['categories'] ||= default_categories_hash @settings['preferred_days'] ||= default_preferred_days_hash + @settings['vault_path'] ||= DEFAULT_VAULT_PATH else @settings = default_settings save_settings @@ -165,7 +180,8 @@ def save_settings def default_settings { 'categories' => default_categories_hash, - 'preferred_days' => default_preferred_days_hash + 'preferred_days' => default_preferred_days_hash, + 'vault_path' => DEFAULT_VAULT_PATH } end diff --git a/lib/task_classification.rb b/lib/task_classification.rb new file mode 100644 index 0000000..751ebd9 --- /dev/null +++ b/lib/task_classification.rb @@ -0,0 +1,58 @@ +module TaskClassification + def extract_priority_from_notes(notes) + return nil unless notes + + case notes + when /🔥/ + '🔥Hot' + when /🟢/ + '🟢Must' + when /🟠/ + '🟠Nice' + when /🔴/ + '🔴NotNow' + else + nil + end + end + + def extract_category_from_notes(notes) + return 'No Category' unless notes + + case notes + when /🧩/ + '🧩Product' + when /📈/ + '📈Business' + when /📢/ + '📢Marketing' + when /🔒/ + '🔒Security' + when /💰/ + '💰Finance' + when /💳/ + '💳Sales' + when /🎧/ + '🎧Support' + when /👩‍💼/ + '👩‍💼Others' + else + 'No Category' + end + end + + def get_priority_weight_from_text(priority_text) + case priority_text + when '🔥Hot' + 4 + when '🟢Must' + 3 + when '🟠Nice' + 2 + when '🔴NotNow' + 1 + else + 0 # No priority + end + end +end diff --git a/lib/week_planner.rb b/lib/week_planner.rb new file mode 100644 index 0000000..9f7f2a4 --- /dev/null +++ b/lib/week_planner.rb @@ -0,0 +1,414 @@ +require 'date' +require 'json' +require 'timeout' +require_relative 'task_classification' + +class WeekPlanner + include TaskClassification + + PRIORITY_EMOJI_MAP = { + '🔥Hot' => { emoji: '🔥', frontmatter: 'high' }, + '🟢Must' => { emoji: '🟢', frontmatter: 'normal' }, + '🟠Nice' => { emoji: '🟠', frontmatter: 'low' }, + '🔴NotNow' => { emoji: '🔴', frontmatter: 'none' } + }.freeze + + def initialize(client, settings_manager) + @client = client + @settings_manager = settings_manager + @vault_path = settings_manager.get_vault_path + end + + def generate_week(list_id) + today = Date.today + @monday = today - (today.cwday - 1) + @sunday = @monday + 6 + @week_number = today.cweek + + puts "📅 Week #{@week_number} Planning (#{@monday.strftime('%Y-%m-%d')} to #{@sunday.strftime('%Y-%m-%d')})" + puts "=" * 60 + puts + + # Fetch tasks + puts "📋 Fetching tasks for this week..." + tasks = fetch_week_tasks(list_id) + if tasks.empty? + puts "No tasks found for this week." + return + end + puts "Found #{tasks.length} tasks." + puts + + # Group by category + categories = group_by_category(tasks) + puts "📊 #{categories.keys.length} categories: #{categories.keys.join(', ')}" + puts + + # Scan vault for existing notes + puts "🔍 Scanning vault for existing notes..." + vault_notes = scan_vault_notes + puts "Found #{vault_notes.length} notes in vault." + puts + + # Suggest projects via Claude (parallel) + puts "🤖 Analyzing tasks with Claude..." + puts + all_groups = suggest_projects_parallel(categories, vault_notes) + + # Interactive confirmation + confirmed_groups = {} + all_groups.each do |category, suggestions| + result = confirm_assignments(category, suggestions) + confirmed_groups[category] = result if result + end + + if confirmed_groups.empty? + puts "No categories confirmed. Exiting." + return + end + + # Generate and write + markdown = generate_markdown(@week_number, confirmed_groups) + write_week_file(@week_number, markdown) + + # Create missing project notes + project_names = confirmed_groups.values.flat_map(&:keys).uniq + ensure_project_notes(project_names, @week_number) + + puts + puts "🎉 Week #{@week_number} plan written to #{File.join(@vault_path, "Week #{@week_number}.md")}" + puts "📝 #{project_names.length} projects referenced." + end + + def fetch_week_tasks(list_id) + monday_time = Time.new(@monday.year, @monday.month, @monday.day, 0, 0, 0) + sunday_time = Time.new(@sunday.year, @sunday.month, @sunday.day, 23, 59, 59) + + tasks = @client.list_tasks( + list_id, + show_completed: false, + due_min: monday_time, + due_max: sunday_time + ) + + tasks.select { |t| t.due } + end + + def group_by_category(tasks) + groups = {} + tasks.each do |task| + category = extract_category_from_notes(task.notes) + groups[category] ||= [] + groups[category] << task + end + # Sort: named categories first, "No Category" last + groups.sort_by { |k, _| k == 'No Category' ? 1 : 0 }.to_h + end + + def scan_vault_notes + pattern = File.join(@vault_path, '**', '*.md') + files = Dir.glob(pattern) + titles = files.map { |f| File.basename(f, '.md') }.uniq.sort + # Truncate to 200 for prompt size + titles.first(200) + end + + def suggest_projects_parallel(categories, vault_notes) + threads = categories.map do |category, tasks| + Thread.new do + begin + result = claude_suggest(category, tasks, vault_notes) + [category, result] + rescue => e + puts "⚠️ Claude failed for #{category}: #{e.message}" if ENV['DEBUG'] + # Fallback: one project per task + fallback = {} + tasks.each { |t| (fallback[t.title] ||= []) << t } + [category, fallback] + end + end + end + + results = {} + threads.each do |thread| + category, suggestions = thread.value + results[category] = suggestions + end + results + end + + def claude_suggest(category, tasks, vault_notes) + task_titles = tasks.map(&:title) + vault_context = vault_notes.join("\n") + + prompt = <<~PROMPT + You are organizing tasks for weekly planning. Given these tasks in the "#{category}" category, group them into logical projects. + + Existing notes in the Obsidian vault (prefer reusing these names when tasks match): + #{vault_context} + + Tasks to organize: + #{task_titles.each_with_index.map { |t, i| "#{i + 1}. #{t}" }.join("\n")} + + Return ONLY valid JSON, no markdown fences: + {"Project Name": ["task title exactly as given", ...]} + + Rules: + - Project names: concise (2-5 words), suitable as Obsidian note titles + - Group related tasks (same initiative, system, or goal) + - Single-task projects are fine if the task doesn't belong elsewhere + - Reuse existing vault note names when tasks clearly relate to them + PROMPT + + # Shell out to claude CLI (use IO.popen to avoid shell escaping issues) + result = nil + Timeout.timeout(60) do + result = IO.popen(['claude', '-p', prompt, '--output-format', 'json'], err: '/dev/null', &:read) + end + + raise "Empty response from Claude" if result.nil? || result.strip.empty? + + # Parse 2-layer JSON: outer CLI wrapper, inner project groupings + outer = JSON.parse(result) + inner_text = outer['result'] + raise "No result field in Claude response" unless inner_text + + # Claude might wrap JSON in markdown fences, strip them + inner_text = inner_text.gsub(/```json\s*/, '').gsub(/```\s*/, '').strip + project_groupings = JSON.parse(inner_text) + + # Map task titles back to task objects + title_to_task = {} + tasks.each { |t| title_to_task[t.title] = t } + + mapped = {} + project_groupings.each do |project_name, titles| + mapped[project_name] = titles.filter_map { |title| title_to_task[title] } + end + + # Add any tasks that weren't assigned + assigned_titles = mapped.values.flatten.map(&:title) + unassigned = tasks.reject { |t| assigned_titles.include?(t.title) } + if unassigned.any? + mapped['Unassigned'] ||= [] + mapped['Unassigned'].concat(unassigned) + end + + mapped + end + + def confirm_assignments(category, suggestions) + # Split category into emoji and name for display + display_category = format_category_display(category) + total_tasks = suggestions.values.flatten.length + + puts "=" * 60 + puts "#{display_category} (#{total_tasks} tasks → #{suggestions.keys.length} projects)" + puts "=" * 60 + puts + + # Assign letters to tasks for move flow + task_letters = {} + letter = 'a' + + suggestions.each_with_index do |(project, tasks), idx| + priority_sorted = tasks.sort_by { |t| -get_priority_weight_from_text(extract_priority_from_notes(t.notes).to_s) } + puts " #{idx + 1}. [[#{project}]] (#{tasks.length} tasks)" + priority_sorted.each do |task| + priority = extract_priority_from_notes(task.notes) + priority_display = priority ? PRIORITY_EMOJI_MAP.dig(priority, :emoji) || '' : '' + puts " #{letter}. #{priority_display} #{task.title}" + task_letters[letter] = { task: task, project: project } + letter = letter.next + end + puts + end + + print "Accept? (Y/move/skip): " + input = $stdin.gets&.strip&.downcase + + case input + when 'y', '' + suggestions + when 'skip' + puts "⏭️ Skipped #{display_category}" + nil + when 'move' + move_tasks(suggestions, task_letters) + else + suggestions + end + end + + def generate_markdown(week_num, all_groups) + lines = [] + + all_groups.each do |category, projects| + display_category = format_category_display(category) + lines << "# #{display_category}" + lines << "" + + projects.each do |project_name, tasks| + lines << "## [[#{project_name}]]" + + # Sort by priority (highest first) + sorted_tasks = tasks.sort_by do |t| + -get_priority_weight_from_text(extract_priority_from_notes(t.notes).to_s) + end + + sorted_tasks.each do |task| + due_date = task.due ? Date.parse(task.due.to_s).strftime('%Y-%m-%d') : 'no-date' + priority = extract_priority_from_notes(task.notes) + priority_display = priority ? "#{PRIORITY_EMOJI_MAP.dig(priority, :emoji)} " : '' + lines << "- [ ] #{due_date} #{priority_display}#{task.title} %%#{task.id}%%" + end + lines << "" + end + end + + lines.join("\n") + end + + def write_week_file(week_num, markdown) + file_path = File.join(@vault_path, "Week #{week_num}.md") + + if File.exist?(file_path) + existing = File.read(file_path) + # Find header: everything before first H1 line + header_lines = [] + content_started = false + + existing.each_line do |line| + if !content_started && line.match?(/^# /) + content_started = true + end + header_lines << line unless content_started + end + + header = header_lines.join + # Ensure blank line between header and content + header = header.rstrip + "\n\n" unless header.strip.empty? + + puts "📝 Updating existing Week #{week_num}.md (preserving header)" + File.write(file_path, header + markdown) + else + # Create new file with date header + header = "→#{@monday.strftime('%d')} to #{@sunday.strftime('%d')} #{@monday.strftime('%B %Y')}\n\n" + File.write(file_path, header + markdown) + puts "📝 Created Week #{week_num}.md" + end + end + + def ensure_project_notes(project_names, week_num) + project_names.each do |name| + # Check if note exists anywhere in vault + pattern = File.join(@vault_path, '**', "#{name}.md") + existing = Dir.glob(pattern) + + if existing.any? + # Add backlink if missing + file = existing.first + content = File.read(file) + backlink = "[[Week #{week_num}]]" + unless content.include?(backlink) + File.write(file, content.rstrip + "\n#{backlink}\n") + puts "🔗 Added #{backlink} to #{File.basename(file)}" if ENV['DEBUG'] + end + else + # Create new TaskNote + create_project_note(name, week_num) + end + end + end + + private + + def format_category_display(category) + # '🧩Product' → '🧩 Product', 'No Category' stays as-is + if category =~ /^(\p{So}|\p{Emoji_Presentation})(.+)$/ + "#{$1} #{$2}" + else + category + end + end + + def move_tasks(suggestions, task_letters) + loop do + print "Which task to move? (letter, or done): " + letter = $stdin.gets&.strip&.downcase + + break if letter == 'done' || letter.nil? || letter.empty? + + unless task_letters[letter] + puts "Invalid letter '#{letter}'" + next + end + + task_info = task_letters[letter] + task = task_info[:task] + old_project = task_info[:project] + + puts "Current project: #{old_project}" + puts + puts "Available projects:" + project_names = suggestions.keys + project_names.each_with_index do |name, idx| + puts " #{idx + 1}. #{name}" + end + puts " #{project_names.length + 1}. (new project)" + puts + + print "Move to (1-#{project_names.length + 1}): " + choice = $stdin.gets&.strip&.to_i + + if choice == project_names.length + 1 + print "New project name: " + new_name = $stdin.gets&.strip + if new_name && !new_name.empty? + suggestions[new_name] ||= [] + suggestions[new_name] << task + suggestions[old_project]&.delete(task) + suggestions.delete(old_project) if suggestions[old_project]&.empty? + task_letters[letter][:project] = new_name + puts "✅ Moved \"#{task.title}\" → [[#{new_name}]]" + end + elsif choice.between?(1, project_names.length) + target = project_names[choice - 1] + suggestions[target] << task + suggestions[old_project]&.delete(task) + suggestions.delete(old_project) if suggestions[old_project]&.empty? + task_letters[letter][:project] = target + puts "✅ Moved \"#{task.title}\" → [[#{target}]]" + else + puts "Invalid choice" + end + + puts + end + + suggestions + end + + def create_project_note(name, week_num) + now = Time.now + file_path = File.join(@vault_path, "#{name}.md") + + content = <<~YAML + --- + title: #{name} + status: open + priority: normal + scheduled: #{@monday.strftime('%Y-%m-%d')} + dateCreated: #{now.strftime('%Y-%m-%dT%H:%M:%S.%L%:z')} + dateModified: #{now.strftime('%Y-%m-%dT%H:%M:%S.%L%:z')} + tags: + - task + --- + + [[Week #{week_num}]] + YAML + + File.write(file_path, content.gsub(/^ /, '')) + puts "📄 Created project note: #{name}.md" + end +end