Skill: bparticle/natural-language-planner (version 0.1.0)

--- slug: natural-language-planner displayName: Natural Language Planner name: natural-language-planner description: > Natural language task and project management. Use when the user talks about things they need to do, projects they're working on, tasks, deadlines, or asks for a project overview / dashboard. Captures tasks from conversation, organises them into projects, tracks progress, and serves a local Kanban dashboard. license: Complete terms in LICENSE.txt --- # Natural Language Planner You are an intelligent task and project manager. You capture tasks from natural conversation, organise them into projects, and help the user stay on top of their work — all stored as simple Markdown files on their local machine. --- ## 1. First-Time Setup If the workspace has **not** been initialised yet (no `.nlplanner/config.json` exists in the workspace path), walk the user through setup: 1. Ask where they'd like to store their planner data. Suggest a sensible default: - **Windows**: `~/nlplanner` - **macOS / Linux**: `~/nlplanner` 2. Run the initialisation script: ```python import sys, os sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath("__file__")), "scripts")) # ── OR, if the skill is installed at a known path: ── # sys.path.insert(0, "<SKILL_DIR>/scripts") from scripts.file_manager import init_workspace init_workspace("<WORKSPACE_PATH>") ``` 3. Confirm success: > "Your planner workspace is ready at `<path>`. Just tell me about anything > you need to do and I'll keep track of it for you." ### Re-initialisation If the workspace directory is missing or corrupted, offer to re-create it. Existing files are never deleted — `init_workspace` only creates what's missing. --- ## 2. Listening for Tasks & Projects During **every** conversation turn, look for signals that the user is talking about work they need to do, are doing, or have finished. ### Intent detection patterns | User says (examples) | Detected intent | Action | |---|---|---| | "I need to…", "I should…", "Remind me to…", "Don't forget to…" | **New task** | `create_task(...)` | | "I'm working on…", "Started the…", "Currently doing…" | **Status → in-progress** | `update_task(id, {"status": "in-progress"})` | | "Finished the…", "Done with…", "Completed…" | **Status → done** | `update_task(id, {"status": "done"})` | | "Let me start a project for…", "I have a big project…" | **New project** | `create_project(...)` | | "This is related to…", "Part of the… project" | **Link / move** | `move_task(...)` or `link_tasks(...)` | | "Cancel…", "Nevermind about…", "Drop the…" | **Archive** | `archive_task(...)` | | "Show me what I'm working on", "What's on my plate?" | **Overview** | List tasks / offer dashboard | ### Extracting structured data When creating or updating tasks, extract as much structured information as you can from the conversation. Fill in reasonable defaults for anything missing. - **Title**: Short, action-oriented phrase. - **Priority**: Look for words like *urgent*, *important*, *critical* → high; *whenever*, *low priority*, *nice to have* → low; otherwise medium. - **Due date**: Parse natural language dates ("next Tuesday", "end of month", "by Friday"). Convert to ISO format (`YYYY-MM-DD`). - **Tags**: Intelligently infer tags from context. Follow these rules: 1. **Reuse existing tags first** — before inventing a new tag, check what tags already exist across the workspace (via `search_tasks` or `list_tasks`). Consistent tagging makes filtering useful. 2. **Infer from domain** — if the user says "fix the login bug", add `bug` and `auth`. If they say "design the landing page", add `design` and `frontend`. 3. **Infer from history** — if the user has been working on a series of tasks tagged `backend`, and they add a new API-related task, carry `backend` forward without being asked. 4. **Cross-reference projects** — tasks in a project should generally inherit the project's tags, plus task-specific ones. 5. **Keep tags short and lowercase** — single words or hyphenated phrases (e.g., `ui`, `bug-fix`, `q1-planning`). 6. **Suggest but don't over-tag** — 2–4 tags per task is ideal. Don't add tags that add no filtering value (e.g., don't tag everything `task`). - **Dependencies**: "Before I do X, I need Y" → link Y as dependency of X. - **Context**: Save a brief summary of the conversation that led to the task. ### Avoiding duplicates Before creating a new task, search existing tasks (by title similarity) to check whether the user is referring to something already tracked. If a likely match exists, update it instead of creating a duplicate. ```python from scripts.index_manager import search_tasks matches = search_tasks("deploy to staging") # If matches[0] looks like the same task → update instead of create ``` --- ## 3. Automatic Organisation - When **3 or more tasks** share a common theme and aren't already in a project, suggest creating a project: > "I notice you have several tasks related to the website redesign. > Want me to group them into a project?" - When the user confirms, create the project and move the tasks into it. - New tasks that clearly belong to an existing project should be placed there automatically (tell the user which project you chose). - Tasks without a clear project go to **inbox**. --- ## 4. Proactive Check-ins Track the `last_checkin` date on each active task. Based on the configured check-in frequency (default: 24 hours), proactively ask about stale tasks. ### Check-in flow 1. At the **start of a conversation** (or when there's a natural pause), check for tasks needing a check-in: ```python from scripts.index_manager import get_tasks_needing_checkin, get_overdue_tasks stale = get_tasks_needing_checkin() overdue = get_overdue_tasks() ``` 2. If there are overdue tasks, mention them first: > "Heads up — **Deploy to staging** was due 2 days ago. How's that going?" 3. For other stale tasks, ask casually: > "How's **Set up CI pipeline** coming along?" 4. Based on the response, update the task status and `last_checkin` date: ```python from scripts.file_manager import update_task from scripts.utils import today_str update_task("task-001", {"last_checkin": today_str(), "status": "in-progress"}) ``` ### Check-in etiquette - **Don't be annoying.** Limit to 1–2 check-ins per conversation. - If the user seems busy or dismissive, back off. - Prioritise overdue and high-priority tasks. - Never check in on tasks marked as `done` or `archived`. ### Refining metadata during check-ins Check-ins are a good opportunity to improve task metadata based on what you've learned: - **Refine tags** — if a task was tagged `research` but the user describes implementation work, update the tags to reflect reality. - **Add missing tags** — if you notice a pattern (e.g., several tasks are clearly `frontend` work but weren't tagged), add the tag. - **Update priority** — if the user signals urgency ("I really need to finish this"), bump the priority. - **Enrich context** — add any new context from the conversation to the task's `## Context` section so it's visible on the dashboard. --- ## 5. Agent Tips & Ideas (Collaborative Intelligence) You are a **collaborative partner**, not just a task recorder. For every task you create or update, consider adding helpful tips, ideas, and inspiration to the `## Agent Tips` section. This content is yours — it represents your expertise and initiative — and is visually separated from the user's own notes in the dashboard. ### When to add Agent Tips Add tips **proactively** when: - **Creating a task**: Think about what would help the user succeed. Add 2–4 initial tips covering approach, tools, pitfalls, or inspiration. - **During check-ins**: If you learn something relevant, add a new tip. - **When the user shares context**: If they mention constraints, preferences, or goals, add tips that address those specifically. - **When you have domain knowledge**: Share what you know — frameworks, best practices, common mistakes, useful resources. ### What makes a good Agent Tip Tips should be **actionable, specific, and genuinely helpful**: | Good tip | Bad tip | |---|---| | "Consider using CSS Grid for the layout — it handles responsive columns without media queries" | "Make sure to write good code" | | "The Lighthouse CI GitHub Action can automate performance checks on every PR" | "Test your code" | | "Beach destinations in Feb: Tybee Island (3h), Myrtle Beach (4h), St. Simons (4h) — all within budget" | "Look at some beaches" | | "Watch out for N+1 queries when loading project tasks — use eager loading" | "Be careful with the database" | ### Tone and character - Be a **helpful colleague**, not a lecturing professor. - Be **specific** — name tools, techniques, URLs where relevant. - Include **creative ideas** and lateral thinking, not just obvious advice. - Match the user's domain — if they're a designer, suggest design tools; if a developer, suggest libraries and patterns. - Keep each tip to **1–2 sentences**. Concise is better. - **Write tips in plain text only** — do NOT use markdown formatting such as `**bold**`, `*italic*`, `__underline__`, backtick code spans, or markdown links. The dashboard displays tips as plain text, so markdown syntax would show up as raw characters. Just write naturally without any formatting. ### How to add tips ```python from scripts.file_manager import update_task_agent_tips # Add tips to an existing task (appends by default) update_task_agent_tips("task-001", [ "Consider using Tailwind CSS for rapid prototyping — it pairs well with React", "Stripe.com and Linear.app are great references for clean SaaS landing pages", "Run a Lighthouse audit before starting so you have a performance baseline", ]) # Or include them when creating a task from scripts.file_manager import create_task create_task("Design homepage", project_id="website", details={ "description": "Create wireframes and mockups for the new homepage", "priority": "high", "agent_tips": [ "Start mobile-first — 60% of traffic is from phones", "The brand guidelines doc is in the shared drive (ask user for link)", "Figma has a free tier that works well for collaborative wireframing", ], }) # Replace all tips (useful when context changes significantly) update_task_agent_tips("task-001", [ "Updated tip based on new information", ], replace=True) # Read existing tips from scripts.file_manager import get_task_agent_tips tips = get_task_agent_tips("task-001") ``` ### How they appear in the dashboard - In the task detail **modal**: A collapsible purple panel labelled "Agent Tips & Ideas" with a lightbulb icon. Expanded by default so users see your suggestions immediately. - In **focus cards** (This Week view): A small purple "tips" badge indicates the task has agent suggestions. - Tips are **never mixed** with user content — they live in their own `## Agent Tips` markdown section. ### Important rules 1. **Never edit user sections** (Description, Context, Notes) when adding tips. Only write to `## Agent Tips`. 2. **Don't repeat** what's already in the task description. 3. **Update tips** when context changes — remove outdated ones with `replace=True`. 4. **Quality over quantity** — 3 great tips beat 10 mediocre ones. 5. Tips are **suggestions**, not commands. The user decides what to act on. --- ## 6. Weekly Focus When the user tells you what they're working on this week, or you detect weekly planning intent: 1. Mark the relevant tasks as **in-progress** (or create them). 2. Set due dates within the current week if the user mentions deadlines. 3. Set priority to **high** for tasks the user emphasises. The dashboard's **This Week** tab (the default view) automatically shows: - All tasks with status `in-progress` - Tasks with due dates in the current Monday–Sunday window - High-priority `todo` tasks - Any overdue tasks (highlighted) ### Intent patterns for weekly focus | User says | Action | |---|---| | "This week I'm focusing on…" | Mark those tasks as in-progress, set due dates | | "My priorities this week are…" | Create/update tasks, set priority high | | "I want to get X and Y done by Friday" | Create tasks with Friday due date | | "What should I work on this week?" | Show This Week summary from dashboard data | ### Example **User**: "This week I need to finish the homepage design and start the API work." **Action**: ```python from scripts.file_manager import update_task from scripts.utils import today_str # Mark homepage design as in-progress, set due to Friday update_task("task-001", {"status": "in-progress", "due": "2026-02-13", "last_checkin": today_str()}) # Mark API work as in-progress update_task("task-002", {"status": "in-progress", "last_checkin": today_str()}) ``` **Response**: > "Updated your week — **Homepage design** is in progress (due Friday) and > **API work** is started. Check the This Week view on your dashboard for > the full picture." --- ## 7. Handling Images & Attachments When the user shares an image or references a file in conversation: 1. Save it to the project's `attachments/` directory: ```python from scripts.file_manager import add_attachment rel_path = add_attachment("website-redesign", "/path/to/screenshot.png") ``` 2. Update the relevant task's `## Attachments` section to include a markdown image link: ```python from scripts.file_manager import get_task, update_task task = get_task("task-001") body = task["body"] # Append the link to the Attachments section new_attachment_line = f"- [{filename}]({rel_path})" body = body.replace("## Attachments\n", f"## Attachments\n{new_attachment_line}\n") update_task("task-001", {"body": body}) ``` 3. The dashboard modal will automatically detect image attachments and display them in a gallery grid. Users can click any thumbnail to open a full-size lightbox view. 4. Confirm: > "Saved the screenshot to **Website Redesign** and linked it to > **Design homepage layout**. You can see it in the task details on > the dashboard." ### Attachment storage locations Attachments can be stored in **either** of two locations — both are served via the same `/api/attachment/<project_id>/<filename>` endpoint: | Location | Notes | |---|---| | `projects/<project_id>/attachments/` | Original / backwards-compatible path | | `media/<project_id>/` | New preferred location for media files | The server checks the `attachments/` directory first, then falls back to `media/`. You don't need to move existing files — both paths work transparently. ### Supported image formats PNG, JPG, JPEG, GIF, WebP, SVG, BMP — all displayed inline in the gallery. --- ## 8. Dashboard The skill includes a local web dashboard for a visual overview. ### Dashboard lifecycle The dashboard should be **always on** and **always current** when the agent is working with tasks. Use `ensure_dashboard()` — never `start_dashboard()` directly — so the agent handles start, health-check, and port recovery automatically. **Rules for the agent:** 1. **Auto-start** — Call `ensure_dashboard()` whenever you create, update, list, or search tasks. The user should never need to ask for the dashboard; it should just be there. 2. **Always current** — Call `rebuild_index()` after any write operation (create / update / archive / move) so the next dashboard poll picks up changes immediately. 3. **Proactive URL reminder** — After the first task operation in a conversation, mention the dashboard URL once (e.g. "Your dashboard is live at http://localhost:8080"). Do not repeat it on every operation. 4. **Port recovery** — If the configured port is occupied (e.g. from a previous session), `ensure_dashboard()` automatically tries the next ports and persists the one it finds. 5. **LAN / network access** — When the user is accessing the assistant from a different device than the one running the planner (e.g. a Raspberry Pi, home server, remote machine, or any headless setup), enable network access so the dashboard is reachable from the local network. Either pass `allow_network=True` to `ensure_dashboard()`, or set the config once with `set_setting("dashboard_allow_network", True)`. When network mode is active, `ensure_dashboard()` returns a URL with the machine's LAN IP (e.g. `http://192.168.0.172:8080`) instead of `localhost`. **When to enable network access automatically:** - The agent is running on a device the user accesses remotely (Pi, server, NAS, etc.) - The user mentions wanting to open the dashboard on their phone, tablet, or another computer on the same network - The user shares a LAN IP or hostname rather than `localhost` **Security note:** The dashboard has no authentication. When network access is enabled, anyone on the same network can view the tasks. Mention this once when first enabling it. ```python from scripts.dashboard_server import ensure_dashboard from scripts.index_manager import rebuild_index # Always use ensure_dashboard() — safe to call repeatedly url = ensure_dashboard() # Returns "http://localhost:8080" # On a headless / remote device, enable network access: url = ensure_dashboard(allow_network=True) # Returns "http://192.168.0.172:8080" # After any write operation, rebuild the index rebuild_index() ``` ### Dashboard features (for user reference) - **This Week** (default view): Focus cards showing what's active this week, with descriptions, context, dependencies, and status badges - **Kanban board**: Columns for To Do, In Progress, Done - **Project cards**: Shows each project with task counts, colour-coded left border, and colour-matched tags - **Colour-coded projects**: Each project is auto-assigned an accent colour from a curated palette. The colour appears as a left border on project and task cards, and tints the tag badges. Users can request a different colour at any time. - **Timeline**: Visual list of upcoming due dates - **Search**: Find tasks by keyword - **Task detail modal**: Click any task to see full details, context, and notes - **Image gallery**: Attachments appear as thumbnails; click for full-size lightbox - **Dark mode**: Toggle via the moon/sun icon in the header (persists across sessions) - **Auto-refresh**: Updates every 5 seconds ### Stopping the dashboard ```python from scripts.dashboard_server import stop_dashboard stop_dashboard() ``` ### Remote access (tunnels) When the user wants to access their dashboard from another device or share a link, use the built-in tunnel integration. ```python from scripts.tunnel import start_tunnel, stop_tunnel, detect_tunnel_tool, get_install_instructions from scripts.dashboard_server import ensure_dashboard, get_dashboard_port # Ensure dashboard is running first url = ensure_dashboard() port = get_dashboard_port() # Check for a tunnel tool tool = detect_tunnel_tool() # Returns "cloudflared", "ngrok", "lt", or None if tool: public_url = start_tunnel(port, tool=tool) # Tell the user: "Your dashboard is now available at <public_url>" else: # Give the user install instructions instructions = get_install_instructions() ``` **Rules for the agent:** 1. Only start a tunnel when the user explicitly asks for remote/domain access — never automatically. 2. Warn the user that the dashboard has no authentication. Anyone with the URL can see their tasks. 3. Cloudflare Tunnel (`cloudflared`) is recommended because it's free and requires no account for quick tunnels. 4. When the user is done, call `stop_tunnel()`. ### Export / static hosting For users who want to host a read-only snapshot of their dashboard on a custom domain (GitHub Pages, Netlify, Vercel, etc.), provide a static export. ```python from scripts.export import export_dashboard # Export with default output directory (<workspace>/.nlplanner/export/) path = export_dashboard() # Export to a custom directory (e.g. a git-managed docs/ folder) path = export_dashboard(output_dir="./docs") ``` **Rules for the agent:** 1. Only export when the user asks for it. 2. Explain that the export is a **point-in-time snapshot** — it will not auto-update. The user needs to re-export after changes. 3. Suggest free hosting options: - **GitHub Pages**: push the export to a `docs/` folder and enable Pages - **Netlify / Vercel**: drag-and-drop the exported folder 4. For automated freshness, suggest a git hook or cron job that re-runs the export. ### Handling skill updates (hot-reload & restart) When the skill's source files are updated — UI templates, Python scripts, or configuration — the running dashboard must pick up the changes. Follow these rules to decide what action is needed. #### What changed → what to do | Changed files | Action required | Why | |---|---|---| | **Dashboard templates** (`templates/dashboard/*.html`, `*.css`, `*.js`) | **Usually nothing** — the server reads static files from disk on every request, so the browser picks up changes on the next page load. If the browser cached an old version, a **hard refresh** (Ctrl+Shift+R / Cmd+Shift+R) is enough. | `SimpleHTTPRequestHandler` serves files straight from the filesystem. | | **Python scripts** (`scripts/*.py`) | **Restart the dashboard.** Python modules are loaded once into memory; a running server thread will not see updated code until it is restarted. | Module code is cached by the Python interpreter. | | **Configuration defaults** (`config_manager.py` default values) | **Restart the dashboard**, then call `load_config()` to merge new defaults. | The config is read once at startup and cached. | | **Skill instructions** (`SKILL.md`) only | **No server action needed.** The SKILL.md is read by the AI agent, not by the running server. | The file is an agent prompt, not runtime code. | #### How to restart safely Always use `restart_dashboard()` — it preserves the current port and network-access setting, properly closes the server socket so the port is freed immediately, and starts a fresh server instance. ```python from scripts.dashboard_server import restart_dashboard # Restart after a skill update (preserves port & network settings) url = restart_dashboard() ``` If you need to force a specific configuration: ```python from scripts.dashboard_server import restart_dashboard url = restart_dashboard(allow_network=True) # re-open on LAN ``` Under the hood this calls `stop_dashboard()` (which closes the socket) → `ensure_dashboard()`. It is safe to call even if the dashboard is not currently running (it simply starts a new one). #### Dealing with externally-started dashboards If the dashboard was started **outside the agent's process** — for example via `python -m scripts dashboard` in a terminal — the agent's `restart_dashboard()` cannot stop it because the server lives in a different Python process. In this case: 1. **Ask the user to stop the terminal process** (Ctrl+C in the terminal where `python -m scripts dashboard` is running). 2. **Then** call `ensure_dashboard()` or `restart_dashboard()` to start a fresh instance under the agent's control. 3. If the user can't or won't stop the external process, the agent's `ensure_dashboard()` will automatically find the next available port — but mention that the original instance is still running and the user should eventually stop it to avoid confusion. #### Rules for the agent 1. **After pulling / syncing skill updates**, check whether any Python scripts changed. If so, call `restart_dashboard()` once. 2. **After UI-only template changes**, mention to the user that a hard refresh in the browser may be needed if they don't see the update. 3. **Never restart mid-operation** — finish any in-flight task writes and `rebuild_index()` calls first, then restart. 4. **Confirm the restart** to the user, and verify the port is unchanged: > "The dashboard has been restarted to pick up the latest changes. > It's live at http://localhost:8080." 5. **Watch for port drift** — if `restart_dashboard()` returns a URL with a different port than expected, it likely means an external process is holding the original port. Alert the user. ### Persistent service (systemd) On headless devices like a Raspberry Pi or home server, the user will typically want the dashboard to start on boot and stay running independently of any terminal session or agent conversation. The recommended approach is a **systemd service**. #### Creating the service When the user asks to make the dashboard persistent, create a systemd unit file. Adapt the paths to the actual system: ```ini # /etc/systemd/system/nlplanner-dashboard.service [Unit] Description=Natural Language Planner Dashboard After=network.target [Service] Type=simple User=<USERNAME> WorkingDirectory=<SKILL_INSTALL_DIR> ExecStart=/usr/bin/python3 -m scripts dashboard --network <WORKSPACE_PATH> Restart=always RestartSec=10 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target ``` Replace the placeholders: | Placeholder | Example | How to find it | |---|---|---| | `<USERNAME>` | `sirius` | The OS user that owns the workspace files | | `<SKILL_INSTALL_DIR>` | `/home/sirius/.openclaw/skills/natural-language-planner` | The directory containing `scripts/` and `templates/` | | `<WORKSPACE_PATH>` | `/mnt/ClawFiles/nlplanner` | The `workspace_path` value from `.nlplanner/config.json` | Omit `--network` if the dashboard should only be accessible on `localhost`. #### Enabling and starting ```bash sudo systemctl daemon-reload sudo systemctl enable nlplanner-dashboard.service sudo systemctl start nlplanner-dashboard.service ``` Verify with `systemctl status nlplanner-dashboard.service` — the log should show the dashboard URL and the directory it is serving from. #### Viewing logs ```bash # Follow live logs journalctl -u nlplanner-dashboard.service -f # Last 50 lines journalctl -u nlplanner-dashboard.service -n 50 ``` #### Restarting after skill updates When Python scripts change, the systemd service must be restarted for the running process to pick up the new code: ```bash sudo systemctl restart nlplanner-dashboard.service ``` #### Common pitfalls 1. **Port conflicts** — If another process is already bound to the configured port, the dashboard will silently bump to the next available port and persist that in the config (`dashboard_port`). This causes "port drift." Before starting the service, verify the port is free: `sudo ss -tlnp | grep <PORT>`. If something unexpected is listening, identify and stop it first. 2. **Stale services from earlier setups** — A previous attempt at a dashboard (e.g. a generic `python3 -m http.server` service) may still be active and holding the port. Check for conflicting services: `systemctl list-units --type=service | grep -i dashboard`. Stop and remove any stale ones: ```bash sudo systemctl stop <old-service> sudo systemctl disable <old-service> sudo rm /etc/systemd/system/<old-service>.service sudo systemctl daemon-reload ``` 3. **Config edits not taking effect** — The dashboard reads the config at startup and the port-drift code can overwrite manual edits. Always stop the service *before* editing `config.json`, then start it again. If the workspace is on a network share (NFS/SMB), edit the config from the machine running the service to avoid caching issues. 4. **Agent vs service restarts** — The agent's `restart_dashboard()` only controls dashboard instances it started itself (in-process threads). It **cannot** restart a systemd-managed process. When a systemd service is running, the agent should tell the user to run `sudo systemctl restart nlplanner-dashboard.service` instead. #### Removing the service entirely ```bash sudo systemctl stop nlplanner-dashboard.service sudo systemctl disable nlplanner-dashboard.service sudo rm /etc/systemd/system/nlplanner-dashboard.service sudo systemctl daemon-reload ``` #### Rules for the agent 1. **Suggest a systemd service** when the user is on a headless device (Pi, server, NAS) and asks for the dashboard to run persistently or survive reboots. 2. **Check for existing services** before creating a new one — stale or conflicting services are a common source of port conflicts and directory-listing bugs. 3. **Never create the service silently** — always show the user the unit file contents and the commands, and let them run the `sudo` commands themselves. 4. **After creating the service**, verify it is running on the expected port and serving the actual dashboard (not a directory listing). --- ## 9. Common Operations Reference ### Create a project ```python from scripts.file_manager import create_project project_id = create_project( "Website Redesign", description="Modernise the company website with new branding", tags=["design", "frontend"], goals=["New landing page", "Mobile-responsive", "Improved performance"], # color is auto-assigned from a curated palette — omit it unless # the user specifically asks for a colour. To set one explicitly: # color="#3b82f6", ) ``` ### Change a project's colour The agent picks a colour automatically when creating a project. If the user asks to change it, use `update_project`: ```python from scripts.file_manager import update_project update_project("website-redesign", {"color": "#ec4899"}) # pink ``` The colour is used throughout the dashboard: left border on project and task cards, and as the tint for tag badges. Any valid CSS hex colour (e.g. `#ef4444`, `#84cc16`) works. ### Create a task ```python from scripts.file_manager import create_task task_id = create_task( "Design new homepage layout", project_id="website-redesign", details={ "description": "Create wireframes and mockups for the new homepage", "priority": "high", "due": "2026-02-15", "tags": ["design"], "context": "User mentioned wanting a modern, clean look", } ) ``` ### Update a task ```python from scripts.file_manager import update_task update_task("task-001", {"status": "in-progress"}) update_task("task-001", {"priority": "high", "due": "2026-02-20"}) ``` ### List and filter tasks ```python from scripts.file_manager import list_tasks all_tasks = list_tasks() high_priority = list_tasks(filter_by={"priority": "high"}) project_tasks = list_tasks(project_id="website-redesign") todo_items = list_tasks(filter_by={"status": "todo"}) ``` ### Search tasks ```python from scripts.index_manager import rebuild_index, search_tasks rebuild_index() results = search_tasks("homepage") ``` ### Get upcoming deadlines ```python from scripts.index_manager import get_tasks_due_soon upcoming = get_tasks_due_soon(days=7) ``` ### Move a task between projects ```python from scripts.file_manager import move_task move_task("task-005", "website-redesign") ``` ### Link dependent tasks ```python from scripts.file_manager import link_tasks link_tasks("task-002", "task-001") # task-002 depends on task-001 ``` ### Archive completed work ```python from scripts.file_manager import archive_task, archive_project archive_task("task-003") archive_project("old-project") ``` --- ## 10. Configuration Settings are stored in `.nlplanner/config.json`. The user can adjust: | Setting | Default | Description | |---|---|---| | `checkin_frequency_hours` | 24 | Hours between proactive check-ins | | `auto_archive_completed_days` | 30 | Auto-archive tasks done for N days | | `default_priority` | `"medium"` | Priority for tasks without explicit priority | | `dashboard_port` | 8080 | Port for the local dashboard server | | `dashboard_allow_network` | `false` | Bind to `0.0.0.0` instead of `localhost` so the dashboard is reachable from other devices on the LAN. Enable this on headless / remote setups (Pi, server, etc.) | ```python from scripts.config_manager import set_setting, get_setting set_setting("checkin_frequency_hours", 48) current = get_setting("dashboard_port") # 8080 ``` --- ## 11. Communication Style Follow these guidelines when talking to the user about their tasks: - **Be concise.** Don't narrate every file operation. Summarise: > "Created project 'Website Redesign' with 3 tasks." - **Confirm major actions** but don't ask permission for obvious ones. - **Use natural language**, not technical jargon. - **Ask for clarification** only when truly ambiguous (e.g., unclear which project a task belongs to). - **Be encouraging** but not patronising. > "Nice — 'Deploy to staging' is done! You've got 4 tasks left this week." --- ## 12. Error Handling - If the workspace isn't set up, offer to initialise it. - If a file operation fails, tell the user plainly and suggest a fix. - If a task isn't found by ID, try searching by title before giving up. - Never crash silently — always inform the user if something went wrong. --- ## 13. Data Principles - **Local-first**: All data lives on the user's machine. No cloud services. - **Human-readable**: Everything is Markdown + YAML. Users can edit files directly in any text editor. - **Git-friendly**: The workspace can be version-controlled for history. - **Non-destructive**: Archive instead of delete. The user's data is sacred. --- ## 14. Example Conversation **User**: "I need to redesign the company website. The homepage needs a fresh look, the about page needs updating, and we should improve mobile responsiveness. The homepage is the most urgent — I want it done by next Friday." **Assistant action plan**: 1. Create project "Website Redesign" with tags `[design, frontend]` 2. Create task "Redesign homepage layout" — priority high, due next Friday 3. Create task "Update about page content" — priority medium, no due date 4. Create task "Improve mobile responsiveness" — priority medium, no due date **Assistant response**: > "I've set up a **Website Redesign** project with 3 tasks: > - **Redesign homepage layout** (high priority, due Feb 14) > - **Update about page content** > - **Improve mobile responsiveness** > > I'll check in on the homepage task since it has a deadline coming up." --- ## 15. Technical Notes ### Dependencies The Python scripts require: - **Python 3.9+** - **PyYAML** (`pip install pyyaml`) No other external dependencies are needed for core functionality. ### File locations All scripts are in the `scripts/` directory relative to this SKILL.md file. The dashboard HTML/CSS/JS are in `templates/dashboard/`. ### Cross-platform All file paths use `pathlib` for cross-platform compatibility. The skill works on Windows, macOS, and Linux.

path: ARCHITECTURE.md
content: # Architecture Overview

Technical reference for contributors and maintainers.

## System Diagram

```
┌────────────────────────────────────────────────┐
│                  SKILL.md                       │
│  (AI instructions — intent detection, routing)  │
└──────────────────┬─────────────────────────────┘
                   │ calls
                   ▼
┌──────────────────────────────────────────┐
│            Python Scripts                │
│                                          │
│  ┌──────────────┐  ┌─────────────────┐   │
│  │ file_manager │  │ config_manager  │   │
│  │  (CRUD ops)  │  │  (settings)     │   │
│  └──────┬───────┘  └────────┬────────┘   │
│         │                   │            │
│  ┌──────┴───────┐  ┌───────┴────────┐   │
│  │index_manager │  │dashboard_server│   │
│  │  (search)    │  │  (HTTP + API)  │   │
│  └──────┬───────┘  └───────┬────────┘   │
│         │                   │            │
│  ┌──────┴───────────────────┴────────┐   │
│  │            utils.py               │   │
│  │  (slugs, YAML, file I/O, IDs)    │   │
│  └───────────────────────────────────┘   │
└──────────────────┬───────────────────────┘
                   │ reads/writes
                   ▼
┌──────────────────────────────────────────┐
│           Workspace (filesystem)         │
│                                          │
│  .nlplanner/config.json                  │
│  projects/*/README.md                    │
│  projects/*/tasks/task-NNN.md            │
│  projects/*/attachments/*                │
│  archive/...                             │
└──────────────────────────────────────────┘
                   ▲
                   │ serves via HTTP
┌──────────────────┴───────────────────────┐
│         Dashboard (browser)              │
│                                          │
│  index.html + app.js + styles.css        │
│  ┌─────────────────────────────┐         │
│  │ Kanban │ Projects │ Timeline│         │
│  └─────────────────────────────┘         │
│  Polls /api/* endpoints for data         │
└──────────────────────────────────────────┘
```

## Module Responsibilities

### `utils.py` — Foundation Layer

Shared utilities with zero internal dependencies.

| Function | Purpose |
|---|---|
| `generate_slug()` | Filesystem-safe names from text |
| `generate_task_id()` | Sequential `task-NNN` IDs |
| `parse_frontmatter()` | YAML frontmatter → dict + body |
| `serialize_frontmatter()` | dict + body → Markdown string |
| `safe_read_file()` / `safe_write_file()` | Error-handled file I/O |
| `ensure_directory()` | Recursive mkdir |
| `validate_status()` / `validate_priority()` | Input validation |

### `config_manager.py` — Settings

Manages `.nlplanner/config.json`. Provides typed getters/setters for all
user-configurable settings. Merges with defaults so new settings are always
available after upgrades.

**Key design decision**: The config path is set once via `set_config_path()`
and cached at module level. This avoids threading the workspace path through
every function call.

### `file_manager.py` — Core CRUD

All create/read/update/list/archive operations for projects and tasks.

**Key functions**:
- `init_workspace()` — creates the full directory tree
- `create_project()` / `create_task()` — write new Markdown files
- `get_project()` / `get_task()` — read and parse files
- `list_tasks()` — glob-based listing with filter support
- `update_task()` — merge updates into existing frontmatter
- `archive_task()` / `archive_project()` — move to `archive/`
- `move_task()` — relocate between projects
- `link_tasks()` — add dependency relationships (with circular check)

**Task ID strategy**: Sequential counters (`task-001`, `task-002`, ...) scanned
from all existing files. This is simple, human-readable, and avoids conflicts
as long as there's a single writer (the AI assistant).

### `index_manager.py` — Search & Analytics

In-memory index rebuilt from disk on demand. Provides:
- Full-text search across titles, bodies, tags
- Due-soon / overdue queries
- Check-in staleness detection
- Summary statistics

The index is also persisted to `.nlplanner/index.json` (without body text)
for potential future use by external tools.

### `dashboard_server.py` — Web Interface

Lightweight HTTP server using Python's `http.server` stdlib module.

**Static files**: Served from `templates/dashboard/` (or workspace copy).

**API routes**:

| Endpoint | Returns |
|---|---|
| `GET /api/stats` | Summary statistics |
| `GET /api/projects` | All projects |
| `GET /api/tasks` | All tasks (filterable via query params) |
| `GET /api/task/<id>` | Single task detail |
| `GET /api/project/<id>` | Single project detail |
| `GET /api/search?q=...` | Search results |
| `GET /api/due-soon?days=N` | Tasks due within N days |
| `GET /api/overdue` | Overdue tasks |

**Threading**: The server runs in a daemon thread so it doesn't block the
AI assistant. `start_dashboard()` / `stop_dashboard()` control the lifecycle.

## Data Format

### Frontmatter Schema — Tasks

```yaml
id: task-001          # Unique, sequential
title: string         # Short description
project: string       # Parent project slug
status: todo | in-progress | done | archived
priority: low | medium | high
created: YYYY-MM-DD
due: YYYY-MM-DD | ''
last_checkin: YYYY-MM-DD
tags: [string]
dependencies: [task-id]
```

### Frontmatter Schema — Projects

```yaml
id: project-slug      # URL/filesystem-safe
title: string
created: YYYY-MM-DD
status: active | archived
tags: [string]
color: '#hex'         # Accent colour — auto-assigned from palette, user-overridable
```

## Error Handling Strategy

1. All file I/O goes through `safe_read_file()` / `safe_write_file()` which
   catch OS errors and return `None` / `False`.
2. Functions that can fail return `Optional` types — callers check for `None`.
3. The `logging` module is used throughout (never `print()`).
4. Validation functions (`validate_status`, etc.) reject bad input before
   it reaches the filesystem.

## Cross-Platform Notes

- All paths use `pathlib.Path` — forward/backward slashes handled automatically.
- `~` expansion via `Path.expanduser()`.
- File encoding is always UTF-8.
- The dashboard binds to `127.0.0.1` by default. Set
  `dashboard_allow_network` to `true` to bind to `0.0.0.0` (all
  interfaces) for LAN access on headless / remote devices.

## Extension Points

The architecture is designed to be extended:

- **New file types**: Add a new `create_*` / `get_*` pattern in `file_manager.py`
- **New API endpoints**: Add routes in `DashboardHandler._handle_api()`
- **New dashboard views**: Add HTML section + JS render function + CSS
- **Import/export**: Write converters that read/write the Markdown format
- **CLI**: Import `file_manager` functions directly from a CLI script
- **Sync**: The Markdown-on-filesystem design is naturally git-friendly

## Performance Considerations

- **< 100 tasks**: No optimisation needed; glob scanning is fast.
- **100–1000 tasks**: The in-memory index helps; `rebuild_index()` scans once.
- **1000+ tasks**: Consider adding SQLite as an optional index backend
  (the Markdown files remain the source of truth).

## Security

- Dashboard runs on `127.0.0.1` by default (not accessible from network).
  When `dashboard_allow_network` is enabled it binds to `0.0.0.0`.
- No authentication — when network access is enabled, anyone on the LAN
  can view the dashboard.
- No data leaves the machine.
- File operations are confined to the workspace directory.


path: LICENSE.txt
content: MIT License

Copyright (c) 2026 Natural Language Planner Contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


path: README.md
content: # Natural Language Planner

An OpenClaw skill that turns natural conversation into organised tasks and projects — stored as simple Markdown files on your local machine.

## What It Does

- **Talk naturally** — just mention what you need to do and the AI captures it as a task
- **Automatic organisation** — tasks are grouped into projects, priorities set, due dates parsed
- **Visual dashboard** — a local Kanban board at `localhost:8080` shows everything at a glance
- **Proactive check-ins** — the AI asks about stale tasks so nothing falls through the cracks
- **Local-first** — all data is Markdown + YAML on your filesystem. No accounts, no cloud, no lock-in

## Quick Start

### 1. Install the dependency

```bash
pip install pyyaml
```

### 2. Add the skill to your AI assistant

Copy or clone this directory into your skills folder. The AI will detect the
`SKILL.md` and activate the planner automatically.

### 3. Start using it

Just talk to your AI assistant:

> "I need to redesign the homepage by next Friday — it's the most urgent thing right now."

The assistant will:
- Create a task titled **Redesign homepage** with high priority and a due date
- Place it in the **inbox** (or an existing project if one fits)
- Confirm what it did

### 4. Open the dashboard (optional)

The AI assistant automatically starts the dashboard when you work with tasks.
You can also start it manually:

```bash
python -m scripts dashboard ~/nlplanner
```

## Workspace Structure

All your data lives in one directory (default `~/nlplanner`):

```
~/nlplanner/
├── .nlplanner/          # Config and dashboard files
│   └── config.json
├── projects/
│   ├── inbox/           # Uncategorised tasks
│   │   └── tasks/
│   ├── website-redesign/
│   │   ├── README.md    # Project metadata
│   │   ├── tasks/       # Task markdown files
│   │   └── attachments/ # Images, documents
│   └── ...
└── archive/             # Archived projects and tasks
```

Every task and project is a Markdown file with YAML frontmatter — readable
and editable in any text editor.

## Configuration

Settings live in `.nlplanner/config.json`:

| Setting | Default | Description |
|---|---|---|
| `checkin_frequency_hours` | 24 | How often to ask about stale tasks |
| `auto_archive_completed_days` | 30 | Days before done tasks auto-archive |
| `default_priority` | `"medium"` | Default priority for new tasks |
| `dashboard_port` | 8080 | Port for the local dashboard |
| `dashboard_auto_start` | `true` | Agent auto-starts the dashboard on first task operation |
| `dashboard_allow_network` | `false` | Bind to all interfaces (0.0.0.0) instead of localhost |

## Dashboard Features

The browser-based dashboard includes:

- **This Week view** — focus cards for what you're working on this week
- **Kanban board** — drag-free columns for To Do, In Progress, Done
- **Project overview** — cards showing each project with task counts
- **Timeline** — upcoming deadlines sorted by date
- **Search** — find any task instantly
- **Task detail modal** — click to see full task info, attachments, and AI tips
- **Image gallery** — attachments displayed as thumbnails with lightbox
- **Dark mode** — toggle via the header icon (persists across sessions)
- **Auto-refresh** — updates every 5 seconds

## Remote Access (Tunnels)

Want to check your dashboard from your phone or share a link with someone?
The planner can create a secure tunnel to expose your local dashboard.

```bash
# Auto-detects cloudflared, ngrok, or localtunnel
python -m scripts tunnel ~/nlplanner
```

Or ask the AI: *"Make my dashboard accessible from my phone."*

**Supported tools** (install one):

| Tool | Install | Notes |
|---|---|---|
| Cloudflare Tunnel | `winget install cloudflare.cloudflared` / `brew install cloudflared` | Free, no account needed for quick tunnels |
| ngrok | [ngrok.com/download](https://ngrok.com/download) | Free tier, requires account |
| localtunnel | `npm install -g localtunnel` | Free, no account |

> **Security note:** The dashboard has no authentication. Anyone with the
> tunnel URL can view your tasks. Share the link carefully.

## Persistent Service (Raspberry Pi / Servers)

On a headless device (Raspberry Pi, home server, NAS), you can run the
dashboard as a systemd service so it starts on boot and stays running:

1. Create the service file:

```bash
sudo tee /etc/systemd/system/nlplanner-dashboard.service << 'EOF'
[Unit]
Description=Natural Language Planner Dashboard
After=network.target

[Service]
Type=simple
User=YOUR_USERNAME
WorkingDirectory=/path/to/natural-language-planner
ExecStart=/usr/bin/python3 -m scripts dashboard --network /path/to/workspace
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF
```

2. Enable and start:

```bash
sudo systemctl daemon-reload
sudo systemctl enable nlplanner-dashboard.service
sudo systemctl start nlplanner-dashboard.service
```

3. Check status and logs:

```bash
systemctl status nlplanner-dashboard.service
journalctl -u nlplanner-dashboard.service -f
```

> **Tip:** Before starting, make sure nothing else is using port 8080
> (`sudo ss -tlnp | grep 8080`). Stale services from earlier setups are
> a common cause of port conflicts. Check with
> `systemctl list-units --type=service | grep dashboard`.

Omit `--network` if the dashboard should only be accessible on localhost.

## Static Export

Export a self-contained HTML snapshot of your dashboard for hosting on any
static platform (GitHub Pages, Netlify, Vercel).

```bash
# Default: exports to <workspace>/.nlplanner/export/
python -m scripts export ~/nlplanner

# Export to a specific directory (e.g. for GitHub Pages)
python -m scripts export ~/nlplanner --output ./docs
```

The export inlines all CSS, JS, and data into a single `index.html` that
works without a server. Note that exports are snapshots — re-export after
changes for an updated version.

## Requirements

- **Python 3.9+**
- **PyYAML** (`pip install pyyaml`)
- No other external dependencies for core functionality
- For remote access: `cloudflared`, `ngrok`, or `localtunnel` (optional)

## Project Structure

```
natural-language-planner/
├── SKILL.md              # AI skill instructions
├── scripts/
│   ├── __init__.py       # Package init
│   ├── __main__.py       # CLI entry point
│   ├── file_manager.py   # CRUD for projects and tasks
│   ├── config_manager.py # Settings management
│   ├── index_manager.py  # Search and lookup
│   ├── dashboard_server.py # Local web server + auto-start
│   ├── tunnel.py         # Remote access via tunnels
│   ├── export.py         # Static HTML snapshot export
│   └── utils.py          # Shared utilities
├── templates/
│   ├── dashboard/        # HTML/CSS/JS for the dashboard
│   ├── project_template.md
│   └── task_template.md
├── tests/                # Unit tests
└── examples/             # Sample data and conversations
```

## Contributing

Contributions are welcome! Here's how:

1. **Fork** this repository
2. Create a **feature branch** (`git checkout -b feature/my-idea`)
3. Make your changes and add tests
4. Submit a **pull request** with a clear description

### Development

```bash
# Run tests
python -m pytest tests/ -v

# Start dashboard in development
python -m scripts dashboard ~/nlplanner

# Start a tunnel for remote access
python -m scripts tunnel ~/nlplanner

# Export a static snapshot
python -m scripts export ~/nlplanner --output ./docs
```

### Areas for contribution

- Better natural date parsing
- Drag-and-drop in the Kanban board
- Import from Todoist / Notion / other tools
- Authentication for tunneled dashboards
- Time tracking

## Design Principles

1. **Local-first** — your data never leaves your machine
2. **Human-readable** — everything is Markdown you can edit by hand
3. **Non-destructive** — archive, never delete
4. **Minimal dependencies** — stdlib where possible
5. **Cross-platform** — works on Windows, macOS, and Linux

## License

MIT — see [LICENSE.txt](LICENSE.txt)


path: examples/conversation_examples.md
content: # Conversation Examples

Real-world examples of how the Natural Language Planner captures tasks from
natural conversation.

---

## Example 1: Creating tasks from a casual mention

**User**:
> I need to update the landing page copy — the current version is outdated.
> Oh, and I should probably fix that broken contact form too. The landing page
> is more urgent though.

**What the AI does**:
1. Creates task "Update landing page copy" — priority **high**
2. Creates task "Fix broken contact form" — priority **medium**
3. Both go to **inbox** (no project specified)

**AI response**:
> Got it — I've added two tasks:
> - **Update landing page copy** (high priority)
> - **Fix broken contact form** (medium priority)
>
> Want me to group these into a "Website" project?

---

## Example 2: Starting a new project

**User**:
> I'm starting a new project — we're building a mobile app for our customers.
> I need to do user research first, then design the wireframes, then start on
> the React Native setup. User research should be done by end of next week.

**What the AI does**:
1. Creates project "Mobile App"
2. Creates task "Conduct user research" — priority high, due next Friday, tags: [research]
3. Creates task "Design wireframes" — priority medium, tags: [design]
   - Dependency: "Conduct user research"
4. Creates task "Set up React Native project" — priority medium, tags: [development]
   - Dependency: "Design wireframes"

**AI response**:
> I've set up the **Mobile App** project with 3 tasks:
> - **Conduct user research** (high priority, due Feb 14) — first step
> - **Design wireframes** — after research is done
> - **Set up React Native project** — after wireframes
>
> I'll check in on the user research as the deadline approaches.

---

## Example 3: Updating task status

**User**:
> I just finished the user research for the mobile app. Got some great
> insights — turns out users really want offline mode.

**What the AI does**:
1. Finds task "Conduct user research" in the Mobile App project
2. Updates status to **done**
3. Adds note about offline mode to the task
4. Updates `last_checkin` to today

**AI response**:
> Nice work! Marked **Conduct user research** as done. I've noted the offline
> mode insight.
>
> Next up: **Design wireframes** — ready to start on that?

---

## Example 4: Proactive check-in

*24 hours have passed since the last check-in on "Design wireframes"*

**AI initiates**:
> How's the wireframe design going for the Mobile App? You've got
> **Set up React Native project** waiting on it.

**User**:
> Making progress — should be done by tomorrow.

**What the AI does**:
1. Updates `last_checkin` on "Design wireframes" to today
2. Keeps status as **in-progress**

**AI response**:
> Sounds good — I'll check back tomorrow.

---

## Example 5: Asking for an overview

**User**:
> What's on my plate right now?

**What the AI does**:
1. Lists all active tasks grouped by project
2. Highlights overdue items
3. Offers the dashboard

**AI response**:
> Here's your current workload:
>
> **Mobile App** (2 tasks remaining)
> - Design wireframes — in progress
> - Set up React Native project — waiting on wireframes
>
> **Inbox** (2 tasks)
> - Update landing page copy — high priority
> - Fix broken contact form
>
> Want me to open the dashboard for a visual view?

---

## Example 6: Handling ambiguity

**User**:
> I need to fix the login bug.

**What the AI does**:
1. Searches existing tasks for "login bug"
2. No match found — this is a new task
3. Checks projects — could belong to multiple

**AI response**:
> I'll add **Fix login bug** as a task. Which project does this belong to?
> - Mobile App
> - Inbox (general)
>
> Or should I create a new project for it?

---

## Example 7: Using the dashboard

**User**:
> Can you show me the dashboard?

**AI response**:
> Your dashboard is running at **http://localhost:8080** — open it in your
> browser. You'll see:
> - A Kanban board with your tasks
> - Project overview with task counts
> - Timeline of upcoming deadlines
>
> It auto-refreshes every 5 seconds, so any changes I make will appear
> right away.


path: examples/sample_project/README.md
content: ---
id: website-redesign
title: Website Redesign
created: '2026-02-09'
status: active
tags:
- design
- frontend
---

## Description
Modernise the company website with new branding, improved UX, and mobile-first responsive design.

## Goals
- Redesign homepage with new brand identity
- Make all pages mobile-responsive
- Improve page load performance by 50%
- Update content across all pages

## Notes
- Stakeholder meeting scheduled for Feb 12
- Brand guidelines document available in shared drive


path: examples/sample_project/tasks/task-001.md
content: ---
id: task-001
title: Design new homepage layout
project: website-redesign
status: in-progress
priority: high
created: '2026-02-09'
due: '2026-02-14'
last_checkin: '2026-02-09'
tags:
- design
- homepage
dependencies: []
---

## Description
Create wireframes and high-fidelity mockups for the new homepage. Should incorporate the updated brand colours and typography.

## Context
User mentioned wanting a modern, clean look. Prefers minimal design with bold typography. Cited Stripe and Linear as design inspiration.

## Notes
- Start with mobile wireframe first
- Include hero section, features grid, testimonials, and CTA
- Use Figma for mockups

## Attachments


path: examples/sample_project/tasks/task-002.md
content: ---
id: task-002
title: Improve mobile responsiveness
project: website-redesign
status: todo
priority: medium
created: '2026-02-09'
due: '2026-02-28'
last_checkin: '2026-02-09'
tags:
- frontend
- mobile
dependencies:
- task-001
---

## Description
Audit all pages for mobile breakpoints and fix layout issues. Target devices: iPhone 14, Pixel 7, iPad.

## Context
Analytics show 60% of traffic is mobile but the current site has poor mobile UX.

## Notes
- Run Lighthouse mobile audit first
- Fix hamburger menu on smaller screens
- Test touch targets are at least 44px

## Attachments


path: examples/sample_project/tasks/task-003.md
content: ---
id: task-003
title: Update about page content
project: website-redesign
status: todo
priority: low
created: '2026-02-09'
due: ''
last_checkin: '2026-02-09'
tags:
- content
dependencies: []
---

## Description
Rewrite the about page with updated team bios, company mission, and new photos.

## Context
The current about page hasn't been updated in over a year. Several team members have joined since.

## Notes
- Collect new headshots from the team
- Draft updated mission statement
- Add company timeline section

## Attachments


path: scripts/__init__.py
content: """
Natural Language Planner - Python utilities for task and project management.

This package provides core functionality for the Natural Language Planner skill,
including file operations, configuration management, search indexing, and a
local dashboard server.

Modules:
    utils           - Shared utilities (slug generation, YAML/Markdown parsing, etc.)
    config_manager  - User settings and configuration management
    file_manager    - Core CRUD operations for projects and tasks
    index_manager   - Fast search and lookup optimization
    dashboard_server - Local web server for the visual dashboard
    tunnel          - Cloudflare/ngrok tunnel integration for remote access
    export          - Static HTML snapshot export for deployment
"""

__version__ = "1.0.0"


path: scripts/__main__.py
content: """
CLI entry point for the Natural Language Planner.

Usage:
    python -m scripts <command> [arguments]

Commands:
    init <workspace_path>       Initialise a new workspace
    dashboard [--port PORT]     Start the dashboard server
    list-tasks [--project ID]   List all tasks
    list-projects               List all projects
    stats                       Show summary statistics
    search <query>              Search tasks
    rebuild-index               Rebuild the search index
    tunnel [workspace_path]     Start a tunnel for remote dashboard access
    export [--output DIR]       Export dashboard as a static HTML snapshot
"""

import sys
import argparse
import json

from .utils import setup_logging
from .file_manager import init_workspace, list_tasks, list_projects
from .config_manager import load_config, set_config_path
from .index_manager import rebuild_index, search_tasks, get_stats
from .dashboard_server import start_dashboard, ensure_dashboard, get_dashboard_url, get_dashboard_port
from .tunnel import start_tunnel, stop_tunnel, get_tunnel_url, detect_tunnel_tool, get_install_instructions
from .export import export_dashboard


def main() -> None:
    setup_logging()

    parser = argparse.ArgumentParser(
        prog="python -m scripts",
        description="Natural Language Planner — CLI",
    )
    sub = parser.add_subparsers(dest="command", help="Available commands")

    # init
    p_init = sub.add_parser("init", help="Initialise a new workspace")
    p_init.add_argument("workspace_path", help="Directory for planner data")

    # dashboard
    p_dash = sub.add_parser("dashboard", help="Start the dashboard server")
    p_dash.add_argument("--port", type=int, default=None, help="Port (default: 8080)")
    p_dash.add_argument("--network", action="store_true", help="Bind to all interfaces (0.0.0.0)")
    p_dash.add_argument("workspace_path", nargs="?", default=None, help="Workspace directory")

    # list-tasks
    p_lt = sub.add_parser("list-tasks", help="List tasks")
    p_lt.add_argument("--project", default=None, help="Filter by project ID")
    p_lt.add_argument("--status", default=None, help="Filter by status")
    p_lt.add_argument("workspace_path", nargs="?", default=None, help="Workspace directory")

    # list-projects
    p_lp = sub.add_parser("list-projects", help="List projects")
    p_lp.add_argument("workspace_path", nargs="?", default=None, help="Workspace directory")

    # stats
    p_st = sub.add_parser("stats", help="Show summary statistics")
    p_st.add_argument("workspace_path", nargs="?", default=None, help="Workspace directory")

    # search
    p_se = sub.add_parser("search", help="Search tasks")
    p_se.add_argument("query", help="Search query")
    p_se.add_argument("workspace_path", nargs="?", default=None, help="Workspace directory")

    # rebuild-index
    p_ri = sub.add_parser("rebuild-index", help="Rebuild the search index")
    p_ri.add_argument("workspace_path", nargs="?", default=None, help="Workspace directory")

    # tunnel
    p_tn = sub.add_parser("tunnel", help="Start a tunnel for remote dashboard access")
    p_tn.add_argument("--tool", choices=["cloudflared", "ngrok", "lt"], default=None,
                       help="Tunnel tool to use (default: auto-detect)")
    p_tn.add_argument("workspace_path", nargs="?", default=None, help="Workspace directory")

    # export
    p_ex = sub.add_parser("export", help="Export dashboard as static HTML snapshot")
    p_ex.add_argument("--output", default=None, help="Output directory (default: <workspace>/.nlplanner/export/)")
    p_ex.add_argument("workspace_path", nargs="?", default=None, help="Workspace directory")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    # ── Dispatch ────────────────────────────────────────────

    if args.command == "init":
        path = args.workspace_path
        ok = init_workspace(path)
        if ok:
            print(f"Workspace initialised at: {path}")
        else:
            print("Failed to initialise workspace.", file=sys.stderr)
            sys.exit(1)

    elif args.command == "dashboard":
        _ensure_workspace(args)
        allow_network = getattr(args, "network", False)
        url = start_dashboard(port=args.port, allow_network=allow_network or None)
        if url:
            print(f"Dashboard running at {url}")
            print("Press Ctrl+C to stop.")
            try:
                import time
                while True:
                    time.sleep(1)
            except KeyboardInterrupt:
                print("\nStopping dashboard.")
        else:
            print("Failed to start dashboard.", file=sys.stderr)
            sys.exit(1)

    elif args.command == "list-tasks":
        _ensure_workspace(args)
        filters = {}
        if args.status:
            filters["status"] = args.status
        tasks = list_tasks(filter_by=filters or None, project_id=args.project)
        if not tasks:
            print("No tasks found.")
        for t in tasks:
            status = t.get("status", "?")
            priority = t.get("priority", "?")
            due = t.get("due", "")
            due_str = f"  due {due}" if due else ""
            print(f"  [{status}] {t.get('id', '?')}  {t.get('title', '?')}  ({priority}){due_str}")

    elif args.command == "list-projects":
        _ensure_workspace(args)
        projects = list_projects()
        if not projects:
            print("No projects found.")
        for p in projects:
            print(f"  {p.get('id', '?')}  —  {p.get('title', '?')}  [{p.get('status', '?')}]")

    elif args.command == "stats":
        _ensure_workspace(args)
        rebuild_index()
        s = get_stats()
        print(json.dumps(s, indent=2))

    elif args.command == "search":
        _ensure_workspace(args)
        rebuild_index()
        results = search_tasks(args.query)
        if not results:
            print("No results.")
        for t in results:
            print(f"  {t.get('id', '?')}  {t.get('title', '?')}  [{t.get('status', '?')}]")

    elif args.command == "rebuild-index":
        _ensure_workspace(args)
        rebuild_index()
        print("Index rebuilt.")

    elif args.command == "tunnel":
        _ensure_workspace(args)

        # Ensure the dashboard is running first
        dash_url = ensure_dashboard()
        if not dash_url:
            print("Failed to start dashboard. Cannot create tunnel.", file=sys.stderr)
            sys.exit(1)

        port = get_dashboard_port()
        print(f"Dashboard running at {dash_url}")

        # Check if a tool is available
        tool = getattr(args, "tool", None) or detect_tunnel_tool()
        if not tool:
            print("\nNo tunnel tool found on your system.\n")
            print(get_install_instructions())
            sys.exit(1)

        print(f"Starting {tool} tunnel on port {port}...")
        tunnel_url = start_tunnel(port, tool=tool)
        if tunnel_url:
            print(f"\nPublic URL: {tunnel_url}")
            print("\nAnyone with this URL can access your dashboard.")
            print("WARNING: The dashboard has no authentication — share this URL carefully.")
            print("Press Ctrl+C to stop the tunnel.")
            try:
                import time
                while True:
                    time.sleep(1)
            except KeyboardInterrupt:
                print("\nStopping tunnel...")
                stop_tunnel()
                print("Tunnel stopped.")
        else:
            print("Failed to start tunnel.", file=sys.stderr)
            sys.exit(1)

    elif args.command == "export":
        _ensure_workspace(args)
        output_dir = getattr(args, "output", None)
        path = export_dashboard(output_dir=output_dir)
        if path:
            print(f"Dashboard exported to: {path}")
            print("Open this file in a browser, or deploy it to any static hosting service.")
        else:
            print("Failed to export dashboard.", file=sys.stderr)
            sys.exit(1)


def _ensure_workspace(args: argparse.Namespace) -> None:
    """Point the config manager at the workspace, if provided."""
    ws = getattr(args, "workspace_path", None)
    if ws:
        set_config_path(ws)
    else:
        # Try loading existing config to find workspace path
        config = load_config()
        if not config.get("workspace_path"):
            print(
                "No workspace specified. Either pass a path or run 'init' first.",
                file=sys.stderr,
            )
            sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/config_manager.py
content: """
Configuration management for the Natural Language Planner.

Handles loading, saving, and accessing user settings stored in
.nlplanner/config.json within the workspace directory.
"""

import json
import logging
from pathlib import Path
from typing import Any, Optional

from .utils import ensure_directory, safe_read_file, safe_write_file

logger = logging.getLogger("nlplanner.config")

# Default configuration values
DEFAULT_CONFIG: dict[str, Any] = {
    "version": "1.0.0",
    "workspace_path": "",
    "settings": {
        "checkin_frequency_hours": 24,
        "auto_archive_completed_days": 30,
        "default_priority": "medium",
        "dashboard_port": 8080,
        "dashboard_auto_start": True,
        "dashboard_allow_network": False,
        "enable_notifications": False,
    },
    "preferences": {
        "default_project": "inbox",
        "task_id_format": "task-{counter:03d}",
        "project_id_format": "{slug}",
    },
}

# Module-level cached config path
_config_path: Optional[Path] = None


def _get_config_path(workspace_path: Optional[str] = None) -> Path:
    """
    Resolve the path to config.json.

    Args:
        workspace_path: Explicit workspace path. If None, uses cached or default.

    Returns:
        Path to the config.json file.
    """
    global _config_path

    if workspace_path:
        p = Path(workspace_path).expanduser().resolve()
        _config_path = p / ".nlplanner" / "config.json"
    elif _config_path is None:
        # Fallback: look for a config in the current directory or home
        _config_path = Path.home() / "nlplanner" / ".nlplanner" / "config.json"

    return _config_path


def set_config_path(workspace_path: str) -> None:
    """
    Set the workspace path used by the config manager.

    Call this early — before any other config operations — to point
    the planner at the correct workspace directory.

    Args:
        workspace_path: Absolute or ~-relative path to the workspace root.
    """
    _get_config_path(workspace_path)
    logger.info("Config path set to %s", _config_path)


def load_config(workspace_path: Optional[str] = None) -> dict[str, Any]:
    """
    Load the configuration file.

    If the file doesn't exist, returns a copy of DEFAULT_CONFIG.

    Args:
        workspace_path: Optional explicit workspace path.

    Returns:
        Configuration dictionary.

    Example:
        >>> config = load_config("/home/user/nlplanner")
        >>> config["settings"]["dashboard_port"]
        8080
    """
    path = _get_config_path(workspace_path)

    if not path.exists():
        logger.info("No config file found at %s — using defaults.", path)
        return _deep_copy_config(DEFAULT_CONFIG)

    raw = safe_read_file(path)
    if raw is None:
        logger.warning("Could not read config — using defaults.")
        return _deep_copy_config(DEFAULT_CONFIG)

    try:
        config = json.loads(raw)
        # Merge with defaults so new keys are always present
        merged = _merge_config(DEFAULT_CONFIG, config)
        return merged
    except json.JSONDecodeError as e:
        logger.error("Invalid JSON in config file: %s", e)
        return _deep_copy_config(DEFAULT_CONFIG)


def save_config(config: dict[str, Any], workspace_path: Optional[str] = None) -> bool:
    """
    Persist the configuration dictionary to disk.

    Args:
        config: The configuration dictionary to save.
        workspace_path: Optional explicit workspace path.

    Returns:
        True if saved successfully, False otherwise.
    """
    path = _get_config_path(workspace_path)
    ensure_directory(path.parent)

    try:
        content = json.dumps(config, indent=2, ensure_ascii=False)
        return safe_write_file(path, content)
    except (TypeError, ValueError) as e:
        logger.error("Failed to serialize config: %s", e)
        return False


def get_workspace_path() -> str:
    """
    Return the configured workspace path.

    Returns:
        The workspace root directory path as a string.
    """
    config = load_config()
    return config.get("workspace_path", "")


def set_workspace_path(path: str) -> bool:
    """
    Update the workspace path in the configuration.

    Also updates the internal config path pointer so subsequent
    operations use the new workspace.

    Args:
        path: New workspace root directory path.

    Returns:
        True if the config was saved successfully.
    """
    resolved = str(Path(path).expanduser().resolve())
    set_config_path(resolved)
    config = load_config(resolved)
    config["workspace_path"] = resolved
    return save_config(config, resolved)


def get_setting(key: str) -> Any:
    """
    Get a single setting value by key.

    Args:
        key: The setting name (e.g., 'dashboard_port').

    Returns:
        The setting value, or None if not found.
    """
    config = load_config()
    return config.get("settings", {}).get(key)


def set_setting(key: str, value: Any) -> bool:
    """
    Update a single setting value.

    Args:
        key: The setting name.
        value: The new value.

    Returns:
        True if saved successfully.
    """
    config = load_config()
    config.setdefault("settings", {})[key] = value
    return save_config(config)


def get_checkin_frequency() -> int:
    """
    Get the check-in frequency in hours.

    Returns:
        Number of hours between proactive check-ins.
    """
    return get_setting("checkin_frequency_hours") or 24


def set_checkin_frequency(hours: int) -> bool:
    """
    Set the check-in frequency.

    Args:
        hours: Number of hours between check-ins (minimum 1).

    Returns:
        True if saved successfully.
    """
    hours = max(1, hours)
    return set_setting("checkin_frequency_hours", hours)


def get_preference(key: str) -> Any:
    """
    Get a single preference value by key.

    Args:
        key: The preference name (e.g., 'default_project').

    Returns:
        The preference value, or None if not found.
    """
    config = load_config()
    return config.get("preferences", {}).get(key)


# ── Internal helpers ────────────────────────────────────────────────

def _deep_copy_config(source: dict) -> dict:
    """Return a deep copy of a config dict via JSON round-trip."""
    return json.loads(json.dumps(source))


def _merge_config(defaults: dict, overrides: dict) -> dict:
    """
    Recursively merge *overrides* into *defaults*.

    Keys present in overrides take precedence; keys only in defaults
    are preserved so that newly added settings always appear.
    """
    merged = _deep_copy_config(defaults)
    for key, value in overrides.items():
        if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
            merged[key] = _merge_config(merged[key], value)
        else:
            merged[key] = value
    return merged


path: scripts/dashboard_server.py
content: """
Local web server for the Natural Language Planner dashboard.

Serves the dashboard single-page app and provides a JSON API for
reading task/project data from the workspace.

Uses only the Python standard library (http.server) so there are no
external dependencies.  For production-style usage consider replacing
with FastAPI or similar, but this works great for local use.
"""

import json
import logging
import mimetypes
import socket
import threading
import time
from functools import partial
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse, parse_qs, unquote

from .config_manager import load_config, set_setting, get_setting
from .file_manager import list_projects, list_tasks, get_project, get_task
from .index_manager import rebuild_index, get_stats, search_tasks, get_tasks_due_soon, get_overdue_tasks

logger = logging.getLogger("nlplanner.dashboard")

_server: Optional[HTTPServer] = None
_thread: Optional[threading.Thread] = None
_started_at: float = 0.0


class DashboardHandler(SimpleHTTPRequestHandler):
    """
    HTTP request handler for the dashboard.

    Serves static files from the dashboard directory and handles
    /api/* routes for JSON data.
    """

    def __init__(self, *args, dashboard_dir: str = "", **kwargs):
        self._dashboard_dir = dashboard_dir
        super().__init__(*args, directory=dashboard_dir, **kwargs)

    def do_GET(self) -> None:
        """Route GET requests to the appropriate handler."""
        parsed = urlparse(self.path)
        path = parsed.path

        if path.startswith("/api/"):
            self._handle_api(path, parsed.query)
        else:
            # Serve static files from the dashboard directory
            super().do_GET()

    def _handle_api(self, path: str, query_string: str) -> None:
        """Dispatch API requests."""
        params = parse_qs(query_string)

        routes: dict[str, Any] = {
            "/api/stats": self._api_stats,
            "/api/projects": self._api_projects,
            "/api/tasks": self._api_tasks,
            "/api/search": self._api_search,
            "/api/due-soon": self._api_due_soon,
            "/api/overdue": self._api_overdue,
            "/api/health": self._api_health,
        }

        # Dynamic routes: /api/attachment/<project>/<file>, /api/project/<id>, /api/task/<id>
        if path.startswith("/api/attachment/"):
            parts = path.split("/api/attachment/")[1].strip("/").split("/", 1)
            if len(parts) == 2:
                self._api_serve_attachment(unquote(parts[0]), unquote(parts[1]))
            else:
                self._json_response({"error": "Bad attachment path"}, status=400)
            return
        if path.startswith("/api/project/"):
            project_id = path.split("/api/project/")[1].strip("/")
            self._api_single_project(project_id)
            return
        if path.startswith("/api/task/"):
            task_id = path.split("/api/task/")[1].strip("/")
            self._api_single_task(task_id)
            return

        handler = routes.get(path)
        if handler:
            handler(params)
        else:
            self._json_response({"error": "Not found"}, status=404)

    # ── API handlers ───────────────────────────────────────────

    def _api_health(self, params: dict) -> None:
        """Return server health status and uptime."""
        uptime = round(time.time() - _started_at, 1) if _started_at else 0
        self._json_response({
            "status": "ok",
            "uptime_seconds": uptime,
            "port": _server.server_address[1] if _server else None,
        })

    def _api_stats(self, params: dict) -> None:
        rebuild_index()
        self._json_response(get_stats())

    def _api_projects(self, params: dict) -> None:
        projects = list_projects()
        self._json_response(projects)

    def _api_tasks(self, params: dict) -> None:
        project = params.get("project", [None])[0]
        status = params.get("status", [None])[0]
        priority = params.get("priority", [None])[0]
        include_archived = params.get("include_archived", [""])[0].lower() in ("1", "true", "yes")

        filters: dict[str, Any] = {}
        if status:
            filters["status"] = status
        if priority:
            filters["priority"] = priority

        tasks = list_tasks(filter_by=filters if filters else None, project_id=project,
                           include_archived=include_archived)
        self._json_response(tasks)

    def _api_search(self, params: dict) -> None:
        query = params.get("q", [""])[0]
        if not query:
            self._json_response([])
            return
        rebuild_index()
        results = search_tasks(query)
        self._json_response(results)

    def _api_due_soon(self, params: dict) -> None:
        try:
            days = max(1, min(365, int(params.get("days", ["7"])[0])))
        except (ValueError, TypeError):
            days = 7
        rebuild_index()
        self._json_response(get_tasks_due_soon(days))

    def _api_overdue(self, params: dict) -> None:
        rebuild_index()
        self._json_response(get_overdue_tasks())

    def _api_single_project(self, project_id: str) -> None:
        project = get_project(project_id)
        if project:
            self._json_response(project)
        else:
            self._json_response({"error": "Project not found"}, status=404)

    def _api_single_task(self, task_id: str) -> None:
        task = get_task(task_id)
        if task:
            self._json_response(task)
        else:
            self._json_response({"error": "Task not found"}, status=404)

    def _api_serve_attachment(self, project_id: str, filename: str) -> None:
        """Serve a file from project attachments or media directory."""
        config = load_config()
        ws = config.get("workspace_path", "")
        if not ws:
            self._json_response({"error": "Workspace not configured"}, status=500)
            return

        # Security: prevent path traversal in both project_id and filename
        safe_project = Path(project_id).name
        safe_name = Path(filename).name
        ws_root = Path(ws).resolve()

        # Check both locations (backwards compat + new media dir)
        paths_to_try = [
            (ws_root / "projects" / safe_project / "attachments" / safe_name).resolve(),
            (ws_root / "media" / safe_project / safe_name).resolve(),
        ]

        file_path = None
        for candidate in paths_to_try:
            # Ensure the resolved path is still within the workspace
            if not candidate.is_relative_to(ws_root):
                continue
            if candidate.is_file():
                file_path = candidate
                break

        if not file_path:
            self._json_response({"error": "Attachment not found"}, status=404)
            return

        content_type, _ = mimetypes.guess_type(str(file_path))
        content_type = content_type or "application/octet-stream"

        try:
            data = file_path.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", content_type)
            self.send_header("Content-Length", str(len(data)))
            self.send_header("Cache-Control", "public, max-age=10")
            self.end_headers()
            self.wfile.write(data)
        except OSError as e:
            logger.error("Failed to serve attachment %s: %s", file_path, e)
            self._json_response({"error": "Failed to read file"}, status=500)

    # ── Response helpers ───────────────────────────────────────

    def _json_response(self, data: Any, status: int = 200) -> None:
        """Send a JSON response."""
        body = json.dumps(data, default=str, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format: str, *args: Any) -> None:
        """Redirect access logs to our logger instead of stderr."""
        logger.debug(format, *args)


# ── Internal helpers ──────────────────────────────────────────────

def _resolve_dashboard_dir() -> str:
    """
    Find the dashboard static files directory.

    Looks in the workspace .nlplanner/dashboard first, then falls back to
    the templates/dashboard shipped with the skill.
    """
    config = load_config()
    ws = config.get("workspace_path", "")

    if ws:
        ws_dashboard = Path(ws) / ".nlplanner" / "dashboard"
        if (ws_dashboard / "index.html").exists():
            return str(ws_dashboard)

    # Fallback: templates directory relative to this script
    templates = Path(__file__).parent.parent / "templates" / "dashboard"
    if (templates / "index.html").exists():
        return str(templates)

    raise FileNotFoundError(
        "Dashboard static files not found. Ensure the templates/dashboard/ "
        "directory exists or run init_workspace() first."
    )


def _is_port_available(host: str, port: int) -> bool:
    """Check if a TCP port is available for binding."""
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind((host, port))
            return True
    except OSError:
        return False


def _find_available_port(host: str, start_port: int, max_attempts: int = 10) -> int:
    """
    Find an available port starting from start_port.

    Tries consecutive ports until one is available.

    Args:
        host: The host to bind to.
        start_port: Port to try first.
        max_attempts: How many consecutive ports to try.

    Returns:
        An available port number.

    Raises:
        OSError: If no port is available within the range.
    """
    for offset in range(max_attempts):
        port = start_port + offset
        if _is_port_available(host, port):
            return port
    raise OSError(
        f"No available port found in range {start_port}-{start_port + max_attempts - 1}"
    )


def _resolve_host() -> str:
    """Return the bind host based on configuration."""
    config = load_config()
    allow_network = config.get("settings", {}).get("dashboard_allow_network", False)
    return "0.0.0.0" if allow_network else "127.0.0.1"


def _get_lan_ip() -> str:
    """Best-effort detection of the machine's LAN IP address.

    Returns ``"127.0.0.1"`` if the address cannot be determined (e.g.
    no network interfaces are up).
    """
    try:
        # Open a UDP socket to an external address (nothing is actually sent)
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect(("8.8.8.8", 80))
            return s.getsockname()[0]
    except OSError:
        return "127.0.0.1"


# ── Public functions ──────────────────────────────────────────────

def start_dashboard(port: Optional[int] = None, allow_network: Optional[bool] = None) -> str:
    """
    Start the dashboard web server in a background thread.

    If the requested port is occupied, automatically tries the next
    consecutive ports until one is available.

    Args:
        port: Port number (default: from config or 8080).
        allow_network: If True, bind to 0.0.0.0 (all interfaces).
                       If None, reads from config.

    Returns:
        The dashboard URL, or empty string on failure.

    Example:
        >>> url = start_dashboard()
        >>> print(url)
        http://localhost:8080
    """
    global _server, _thread, _started_at

    if _server is not None:
        logger.info("Dashboard is already running.")
        return get_dashboard_url()

    config = load_config()

    if port is None:
        port = config.get("settings", {}).get("dashboard_port", 8080)

    if allow_network is None:
        allow_network = config.get("settings", {}).get("dashboard_allow_network", False)

    host = "0.0.0.0" if allow_network else "127.0.0.1"
    try:
        dashboard_dir = _resolve_dashboard_dir()
    except FileNotFoundError as e:
        logger.error("%s", e)
        return ""
    handler = partial(DashboardHandler, dashboard_dir=dashboard_dir)

    # Try the configured port first, then search for an available one
    try:
        actual_port = _find_available_port(host, port)
        if actual_port != port:
            logger.info(
                "Port %d is occupied, using port %d instead.", port, actual_port
            )
    except OSError as e:
        logger.error("Could not find an available port: %s", e)
        return ""

    try:
        _server = HTTPServer((host, actual_port), handler)
    except OSError as e:
        logger.error("Could not start dashboard on port %d: %s", actual_port, e)
        return ""

    _started_at = time.time()
    _thread = threading.Thread(target=_server.serve_forever, daemon=True)
    _thread.start()

    if allow_network:
        lan_ip = _get_lan_ip()
        url = f"http://{lan_ip}:{actual_port}"
    else:
        url = f"http://localhost:{actual_port}"
    logger.info("Dashboard started at %s (serving from %s)", url, dashboard_dir)

    # Persist the actual port so subsequent calls know where it is
    if actual_port != port:
        set_setting("dashboard_port", actual_port)

    return url


def ensure_dashboard(port: Optional[int] = None, allow_network: Optional[bool] = None) -> str:
    """
    Ensure the dashboard is running and healthy.

    This is the recommended function for the agent to call. It:
    1. Returns the existing URL if the server is already running.
    2. Starts a new server if not running, with automatic port recovery.
    3. Verifies the server is responsive.

    Args:
        port: Optional port override.
        allow_network: If True, bind to all interfaces (``0.0.0.0``)
                       so the dashboard is reachable from other devices
                       on the LAN.  If None, reads from config.

    Returns:
        The dashboard URL, or empty string on failure.

    Example:
        >>> url = ensure_dashboard()
        >>> print(url)
        http://localhost:8080
    """
    if is_running():
        return get_dashboard_url()

    url = start_dashboard(port=port, allow_network=allow_network)
    if not url:
        return ""

    # Brief pause to let the server thread start accepting connections
    time.sleep(0.1)

    if is_running():
        logger.info("Dashboard ensured at %s", url)
        return url

    logger.warning("Dashboard started but may not be healthy.")
    return url


def stop_dashboard() -> None:
    """Stop the running dashboard server and release its port."""
    global _server, _thread, _started_at

    if _server is None:
        logger.info("Dashboard is not running.")
        return

    _server.shutdown()       # Stop the serve_forever() loop
    _server.server_close()   # Close the socket — releases the port immediately
    _server = None
    _thread = None
    _started_at = 0.0
    logger.info("Dashboard stopped.")


def restart_dashboard(allow_network: Optional[bool] = None) -> str:
    """
    Stop and restart the dashboard server on the **same port**.

    Use this after skill updates that change Python scripts, dashboard
    templates, or any other server-side code.  It fully tears down the
    existing server (including closing the socket so the port is freed
    immediately) and starts a fresh one, re-resolving the dashboard
    directory and reloading configuration.

    Args:
        allow_network: If True, bind to all interfaces (``0.0.0.0``).
                       If None, reads from config.

    Returns:
        The new dashboard URL, or empty string on failure.

    Example:
        >>> url = restart_dashboard()
        >>> print(url)
        http://localhost:8080
    """
    port = get_dashboard_port() or None
    stop_dashboard()
    return ensure_dashboard(port=port, allow_network=allow_network)


def get_dashboard_url() -> str:
    """
    Get the URL of the running dashboard.

    When the server is bound to all interfaces (``0.0.0.0``), returns
    the LAN IP so the URL is directly usable from other devices on the
    network.  When bound to ``127.0.0.1``, returns ``localhost``.

    Returns:
        The dashboard URL, or an empty string if not running.
    """
    if _server is None:
        return ""
    host, port = _server.server_address
    if host == "0.0.0.0":
        return f"http://{_get_lan_ip()}:{port}"
    return f"http://localhost:{port}"


def get_dashboard_port() -> int:
    """
    Get the port of the running dashboard.

    Returns:
        The port number, or 0 if not running.
    """
    if _server is None:
        return 0
    return _server.server_address[1]


def is_running() -> bool:
    """Check whether the dashboard server is currently running."""
    return _server is not None


def get_uptime() -> float:
    """
    Get dashboard uptime in seconds.

    Returns:
        Uptime in seconds, or 0.0 if not running.
    """
    if not is_running() or _started_at == 0.0:
        return 0.0
    return time.time() - _started_at


path: scripts/export.py
content: """
Static HTML export for the Natural Language Planner dashboard.

Generates a single self-contained index.html file with all task/project
data inlined as JSON, plus embedded CSS and JS. The exported file can
be opened directly in a browser or deployed to any static hosting service
(GitHub Pages, Netlify, Vercel, etc.).

Note: The export is a point-in-time snapshot — it does not auto-update.
"""

import json
import logging
from pathlib import Path
from typing import Optional

from .config_manager import load_config
from .file_manager import list_projects, list_tasks
from .index_manager import rebuild_index, get_stats
from .utils import ensure_directory

logger = logging.getLogger("nlplanner.export")


def export_dashboard(output_dir: Optional[str] = None) -> Optional[str]:
    """
    Export the dashboard as a self-contained static HTML file.

    Reads all current task and project data, embeds it as JSON in the
    dashboard HTML, and writes a single file that works without a server.

    Args:
        output_dir: Directory to write the export to. Defaults to
                    <workspace>/.nlplanner/export/

    Returns:
        Path to the exported index.html, or None on failure.

    Example:
        >>> path = export_dashboard()
        >>> print(path)
        '/home/user/nlplanner/.nlplanner/export/index.html'
    """
    config = load_config()
    ws = config.get("workspace_path", "")

    if not ws:
        logger.error("Workspace not configured. Run init first.")
        return None

    # Resolve output directory
    if output_dir:
        out = Path(output_dir).expanduser().resolve()
    else:
        out = Path(ws) / ".nlplanner" / "export"

    ensure_directory(out)

    # Gather data
    rebuild_index()
    projects = list_projects()
    tasks = list_tasks()
    stats = get_stats()

    data_payload = {
        "projects": projects,
        "tasks": tasks,
        "stats": stats,
        "exported_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
    }

    # Read the template files
    templates_dir = Path(__file__).parent.parent / "templates" / "dashboard"
    html_content = _read_file(templates_dir / "index.html")
    css_content = _read_file(templates_dir / "styles.css")
    js_content = _read_file(templates_dir / "app.js")

    if not all([html_content, css_content, js_content]):
        logger.error("Could not read dashboard template files.")
        return None

    # Build the static JS that injects data instead of fetching from API
    static_js = _build_static_js(js_content, data_payload)

    # Inline CSS and JS into the HTML
    export_html = _inline_assets(html_content, css_content, static_js)

    # Write the export
    output_path = out / "index.html"
    try:
        output_path.write_text(export_html, encoding="utf-8")
    except OSError as e:
        logger.error("Failed to write export: %s", e)
        return None

    logger.info("Dashboard exported to %s", output_path)
    return str(output_path)


def _read_file(path: Path) -> Optional[str]:
    """Read a text file, returning None on error."""
    try:
        return path.read_text(encoding="utf-8")
    except OSError as e:
        logger.error("Could not read %s: %s", path, e)
        return None


def _escape_json_for_html(json_str: str) -> str:
    """Escape a JSON string so it is safe to embed inside an HTML <script> block.

    Prevents ``</script>`` and ``<!--`` sequences in the data from breaking
    out of the surrounding ``<script>`` tag.  These replacements produce
    strings that are still valid JavaScript string literals.
    """
    return json_str.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")


def _build_static_js(original_js: str, data: dict) -> str:
    """
    Transform the dashboard JS for static/offline use.

    Replaces the API fetch logic with a pre-loaded data blob so the
    dashboard works as a standalone file without a running server.
    """
    data_json = _escape_json_for_html(
        json.dumps(data, default=str, ensure_ascii=False)
    )

    # Prepend the inlined data and override the api() and loadAll() functions
    static_preamble = f"""
// ── STATIC EXPORT: Data is inlined, no server needed ──────────
window.__NLP_STATIC_DATA__ = {data_json};
window.__NLP_STATIC_MODE__ = true;
"""

    # Patch: replace the api() function to return inlined data
    # and replace loadAll() to use the static data directly
    static_patch = """
// ── STATIC EXPORT: Patched loader ─────────────────────────────
(function() {
  var _origInit = null;
  var _checkInterval = setInterval(function() {
    // Wait for the app IIFE to set up, then patch
    if (window.__NLP_STATIC_MODE__) {
      clearInterval(_checkInterval);
    }
  }, 50);
})();
"""

    # Instead of patching inline, we'll wrap: let the original JS load,
    # but override the fetch-based `api` function at the window level
    # so it returns static data.  The app.js uses a local `api()` inside
    # the IIFE, so we need a different approach: inject a <script> block
    # before app.js that sets window.fetch to return our data.

    fetch_override = f"""
// ── STATIC EXPORT: Override fetch for offline use ─────────────
(function() {{
  var DATA = {data_json};
  var originalFetch = window.fetch;
  window.fetch = function(url, opts) {{
    if (typeof url !== 'string') return originalFetch(url, opts);
    // Parse the pathname
    var a = document.createElement('a');
    a.href = url;
    var path = a.pathname;
    if (path === '/api/stats') return fakeResponse(DATA.stats);
    if (path === '/api/projects') return fakeResponse(DATA.projects);
    if (path === '/api/tasks') return fakeResponse(DATA.tasks);
    if (path === '/api/health') return fakeResponse({{status:'ok',static:true}});
    if (path.indexOf('/api/search') === 0) {{
      var q = (new URL(url, location.origin)).searchParams.get('q') || '';
      q = q.toLowerCase();
      var results = DATA.tasks.filter(function(t) {{
        var s = ((t.title||'')+(t.project||'')+((t.tags||[]).join(' '))).toLowerCase();
        return s.indexOf(q) !== -1;
      }});
      return fakeResponse(results);
    }}
    if (path.indexOf('/api/task/') === 0) {{
      var tid = path.split('/api/task/')[1];
      var task = null;
      for (var i=0;i<DATA.tasks.length;i++) {{
        if (DATA.tasks[i].id===tid) {{ task = DATA.tasks[i]; break; }}
      }}
      return fakeResponse(task || {{error:'Not found'}});
    }}
    if (path.indexOf('/api/project/') === 0) {{
      var pid = path.split('/api/project/')[1];
      var proj = null;
      for (var j=0;j<DATA.projects.length;j++) {{
        if (DATA.projects[j].id===pid) {{ proj = DATA.projects[j]; break; }}
      }}
      return fakeResponse(proj || {{error:'Not found'}});
    }}
    // Fallback for other requests (attachments etc)
    return originalFetch(url, opts);
  }};
  function fakeResponse(data) {{
    return Promise.resolve(new Response(JSON.stringify(data), {{
      status: 200,
      headers: {{'Content-Type':'application/json'}}
    }}));
  }}
}})();
"""
    return fetch_override + "\n" + original_js


def _inline_assets(html: str, css: str, js: str) -> str:
    """
    Replace external CSS/JS references with inline <style> and <script> blocks.
    """
    # Replace the <link rel="stylesheet" href="styles.css" /> with inline CSS
    html = html.replace(
        '<link rel="stylesheet" href="styles.css" />',
        f"<style>\n{css}\n</style>",
    )

    # Replace <script src="app.js"></script> with inline JS
    html = html.replace(
        '<script src="app.js"></script>',
        f"<script>\n{js}\n</script>",
    )

    # Add a banner comment
    html = html.replace(
        "<title>NL Planner",
        "<!-- Static export — generated by Natural Language Planner -->\n  <title>NL Planner",
    )

    return html


path: scripts/file_manager.py
content: """
Core file operations for the Natural Language Planner.

Handles creation, reading, updating, listing, and archiving of projects
and tasks stored as Markdown files with YAML frontmatter.
"""

import re
import shutil
import logging
from pathlib import Path
from typing import Any, Optional

from .utils import (
    generate_slug,
    generate_task_id,
    parse_frontmatter,
    serialize_frontmatter,
    today_str,
    ensure_directory,
    safe_read_file,
    safe_write_file,
    safe_child_path,
    validate_status,
    validate_priority,
)
from .config_manager import load_config, save_config, set_config_path

logger = logging.getLogger("nlplanner.files")

# ── Project colour palette ─────────────────────────────────────────
# A curated set of accent colours that work well in both light and
# dark mode.  When a new project is created, the next unused colour
# is assigned automatically.  The user can override it later.

PROJECT_COLOUR_PALETTE = [
    "#84cc16",  # lime
    "#ef4444",  # red
    "#38bdf8",  # sky
    "#a78bfa",  # purple
    "#eab308",  # yellow
    "#ec4899",  # pink
    "#14b8a6",  # teal
    "#f97316",  # orange
    "#6366f1",  # indigo
    "#06b6d4",  # cyan
    "#f43f5e",  # rose
    "#10b981",  # emerald
]


def _next_project_colour(root: Path) -> str:
    """Pick the next unused colour from the palette.

    Scans existing projects to see which colours are already taken and
    returns the first unused one.  If all colours have been used, it
    cycles back to the beginning.
    """
    used: set[str] = set()
    projects_dir = root / "projects"
    if projects_dir.exists():
        for readme in projects_dir.glob("*/README.md"):
            raw = safe_read_file(readme)
            if raw:
                meta, _ = parse_frontmatter(raw)
                c = meta.get("color", "")
                if c:
                    used.add(c.lower())

    for colour in PROJECT_COLOUR_PALETTE:
        if colour.lower() not in used:
            return colour

    # All taken — cycle based on total project count
    return PROJECT_COLOUR_PALETTE[len(used) % len(PROJECT_COLOUR_PALETTE)]


# ── Workspace initialisation ───────────────────────────────────────

def init_workspace(workspace_path: str) -> bool:
    """
    Initialise the workspace directory structure.

    Creates the full directory tree expected by the planner:
        workspace/
        ├── .nlplanner/
        │   ├── config.json
        │   └── dashboard/
        ├── projects/
        │   └── inbox/
        │       └── tasks/
        └── archive/

    Args:
        workspace_path: Root directory for the planner workspace.

    Returns:
        True if initialisation succeeded, False otherwise.

    Example:
        >>> init_workspace("~/nlplanner")
        True
    """
    root = Path(workspace_path).expanduser().resolve()

    directories = [
        root / ".nlplanner",
        root / ".nlplanner" / "dashboard",
        root / "projects",
        root / "projects" / "inbox" / "tasks",
        root / "archive",
    ]

    for d in directories:
        if not ensure_directory(d):
            return False

    # Write initial config
    set_config_path(str(root))
    config = load_config(str(root))
    config["workspace_path"] = str(root)
    if not save_config(config, str(root)):
        return False

    # Create inbox README if it doesn't exist
    inbox_readme = root / "projects" / "inbox" / "README.md"
    if not inbox_readme.exists():
        meta = {
            "id": "inbox",
            "title": "Inbox",
            "created": today_str(),
            "status": "active",
            "tags": [],
            "color": _next_project_colour(root),
        }
        body = (
            "## Description\n"
            "Default project for uncategorized tasks.\n\n"
            "## Notes\n"
            "Tasks here haven't been assigned to a specific project yet."
        )
        safe_write_file(inbox_readme, serialize_frontmatter(meta, body))

    logger.info("Workspace initialised at %s", root)
    return True


# ── Project operations ─────────────────────────────────────────────

def create_project(
    name: str,
    description: str = "",
    tags: Optional[list[str]] = None,
    goals: Optional[list[str]] = None,
    color: Optional[str] = None,
) -> Optional[str]:
    """
    Create a new project.

    Args:
        name: Human-readable project name.
        description: Optional longer description.
        tags: Optional list of tag strings.
        goals: Optional list of project goals.
        color: Optional hex colour for the project accent (e.g. ``"#84cc16"``).
               If not provided, one is picked automatically from the palette.

    Returns:
        The project ID (slug) on success, or None on failure.

    Example:
        >>> create_project("Website Redesign", description="Modernise the company website")
        'website-redesign'
    """
    root = _workspace_root()
    if root is None:
        return None

    project_id = generate_slug(name)
    project_dir = safe_child_path(root, "projects", project_id)
    if project_dir is None:
        logger.error("Generated project slug '%s' is invalid.", project_id)
        return None

    if project_dir.exists():
        logger.warning("Project directory '%s' already exists.", project_id)
        # Return existing ID so callers can still reference it
        return project_id

    ensure_directory(project_dir / "tasks")
    ensure_directory(project_dir / "attachments")

    # Auto-assign a colour from the palette if none was given
    if not color:
        color = _next_project_colour(root)

    meta: dict[str, Any] = {
        "id": project_id,
        "title": name,
        "created": today_str(),
        "status": "active",
        "tags": tags or [],
        "color": color,
    }

    body_parts = []
    body_parts.append(f"## Description\n{description or 'No description yet.'}")
    if goals:
        body_parts.append("## Goals\n" + "\n".join(f"- {g}" for g in goals))
    body_parts.append("## Notes\n")

    content = serialize_frontmatter(meta, "\n\n".join(body_parts))
    if not safe_write_file(project_dir / "README.md", content):
        return None

    logger.info("Created project '%s' (%s)", name, project_id)
    return project_id


def get_project(project_id: str) -> Optional[dict[str, Any]]:
    """
    Read a project's metadata and body.

    Args:
        project_id: The project slug / ID.

    Returns:
        Dictionary with keys 'meta' and 'body', or None if not found.
    """
    root = _workspace_root()
    if root is None:
        return None

    readme = safe_child_path(root, "projects", project_id, "README.md")
    if readme is None:
        return None
    raw = safe_read_file(readme)
    if raw is None:
        return None

    meta, body = parse_frontmatter(raw)
    return {"meta": meta, "body": body, "path": str(readme)}


def list_projects(include_archived: bool = False) -> list[dict[str, Any]]:
    """
    List all projects with their metadata.

    Args:
        include_archived: If True, also include archived projects.

    Returns:
        List of project metadata dictionaries.
    """
    root = _workspace_root()
    if root is None:
        return []

    projects = []
    projects_dir = root / "projects"

    if projects_dir.exists():
        for readme in sorted(projects_dir.glob("*/README.md")):
            raw = safe_read_file(readme)
            if raw:
                meta, _ = parse_frontmatter(raw)
                meta["_path"] = str(readme.parent)
                projects.append(meta)

    if include_archived:
        archive_dir = root / "archive"
        if archive_dir.exists():
            for readme in sorted(archive_dir.glob("*/README.md")):
                raw = safe_read_file(readme)
                if raw:
                    meta, _ = parse_frontmatter(raw)
                    meta["_path"] = str(readme.parent)
                    meta["_archived"] = True
                    projects.append(meta)

    return projects


def update_project(project_id: str, updates: dict[str, Any]) -> bool:
    """
    Update a project's metadata and/or body.

    Args:
        project_id: The project slug / ID.
        updates: Dictionary of fields to update. Supports keys in the
                 frontmatter (e.g. 'title', 'tags', 'status') as well
                 as 'body' to replace the markdown body.

    Returns:
        True if the update was saved, False otherwise.
    """
    root = _workspace_root()
    if root is None:
        return False

    readme = safe_child_path(root, "projects", project_id, "README.md")
    if readme is None:
        logger.error("Invalid project ID '%s'.", project_id)
        return False
    raw = safe_read_file(readme)
    if raw is None:
        logger.error("Project '%s' not found.", project_id)
        return False

    meta, body = parse_frontmatter(raw)
    new_body = updates.pop("body", None)
    meta.update(updates)

    content = serialize_frontmatter(meta, new_body if new_body is not None else body)
    return safe_write_file(readme, content)


def archive_project(project_id: str) -> bool:
    """
    Move a project (and its tasks) to the archive directory.

    Args:
        project_id: The project slug / ID.

    Returns:
        True if archived successfully, False otherwise.
    """
    root = _workspace_root()
    if root is None:
        return False

    src = safe_child_path(root, "projects", project_id)
    dst = safe_child_path(root, "archive", project_id)
    if src is None or dst is None:
        logger.error("Invalid project ID '%s'.", project_id)
        return False

    if not src.exists():
        logger.error("Project '%s' not found for archiving.", project_id)
        return False

    try:
        ensure_directory(root / "archive")
        shutil.move(str(src), str(dst))
        # Update status in README
        readme = dst / "README.md"
        raw = safe_read_file(readme)
        if raw:
            meta, body = parse_frontmatter(raw)
            meta["status"] = "archived"
            safe_write_file(readme, serialize_frontmatter(meta, body))
        logger.info("Archived project '%s'.", project_id)
        return True
    except OSError as e:
        logger.error("Failed to archive project '%s': %s", project_id, e)
        return False


# ── Task operations ────────────────────────────────────────────────

def create_task(
    title: str,
    project_id: str = "inbox",
    details: Optional[dict[str, Any]] = None,
) -> Optional[str]:
    """
    Create a new task within a project.

    Args:
        title: Short task title.
        project_id: The project slug to add the task to (default: inbox).
        details: Optional dict of additional fields:
            - description (str)
            - context (str)
            - priority ('low' | 'medium' | 'high')
            - status ('todo' | 'in-progress' | 'done')
            - due (str, ISO date)
            - tags (list[str])
            - dependencies (list[str], task IDs)
            - notes (list[str])

    Returns:
        The generated task ID on success, or None on failure.

    Example:
        >>> create_task("Set up CI pipeline", project_id="website-redesign",
        ...             details={"priority": "high", "due": "2026-02-15"})
        'task-001'
    """
    root = _workspace_root()
    if root is None:
        return None

    details = details or {}

    # Resolve the task directory
    task_dir = safe_child_path(root, "projects", project_id, "tasks")
    if task_dir is None:
        logger.error("Invalid project ID '%s'.", project_id)
        return None
    if not task_dir.exists():
        logger.error("Project '%s' not found.", project_id)
        return None

    # Determine next task counter
    counter = _next_task_counter(root)
    task_id = generate_task_id(counter)

    priority = details.get("priority", "medium")
    if not validate_priority(priority):
        priority = "medium"

    status = details.get("status", "todo")
    if not validate_status(status):
        status = "todo"

    meta: dict[str, Any] = {
        "id": task_id,
        "title": title,
        "project": project_id,
        "status": status,
        "priority": priority,
        "created": today_str(),
        "due": details.get("due", ""),
        "last_checkin": today_str(),
        "tags": details.get("tags", []),
        "dependencies": details.get("dependencies", []),
    }

    body_parts = []
    body_parts.append(
        f"## Description\n{details.get('description', 'No description yet.')}"
    )
    if details.get("context"):
        body_parts.append(f"## Context\n{details['context']}")
    notes = details.get("notes", [])
    if notes:
        body_parts.append("## Notes\n" + "\n".join(f"- {n}" for n in notes))
    else:
        body_parts.append("## Notes\n")
    body_parts.append("## Attachments\n")

    # Agent Tips — AI-generated suggestions, kept separate from user content
    agent_tips = details.get("agent_tips", [])
    if agent_tips:
        body_parts.append(
            "## Agent Tips\n" + "\n".join(f"- {t}" for t in agent_tips)
        )
    else:
        body_parts.append("## Agent Tips\n")

    content = serialize_frontmatter(meta, "\n\n".join(body_parts))
    task_file = task_dir / f"{task_id}.md"

    if not safe_write_file(task_file, content):
        return None

    logger.info("Created task '%s' (%s) in project '%s'.", title, task_id, project_id)
    return task_id


def get_task(task_id: str) -> Optional[dict[str, Any]]:
    """
    Find and read a task by its ID, searching across all projects.

    Args:
        task_id: The task identifier (e.g., 'task-001').

    Returns:
        Dictionary with 'meta' and 'body' keys, or None if not found.
    """
    path = _find_task_file(task_id)
    if path is None:
        return None

    raw = safe_read_file(path)
    if raw is None:
        return None

    meta, body = parse_frontmatter(raw)
    return {"meta": meta, "body": body, "path": str(path)}


def update_task(task_id: str, updates: dict[str, Any]) -> bool:
    """
    Update a task's metadata and/or body.

    Args:
        task_id: The task identifier.
        updates: Fields to update. Supports frontmatter keys and 'body'.

    Returns:
        True if the update was saved.

    Example:
        >>> update_task("task-001", {"status": "in-progress"})
        True
    """
    path = _find_task_file(task_id)
    if path is None:
        logger.error("Task '%s' not found.", task_id)
        return False

    raw = safe_read_file(path)
    if raw is None:
        return False

    meta, body = parse_frontmatter(raw)
    new_body = updates.pop("body", None)

    # Validate controlled fields
    if "status" in updates and not validate_status(updates["status"]):
        logger.warning("Invalid status '%s'; ignoring.", updates["status"])
        del updates["status"]
    if "priority" in updates and not validate_priority(updates["priority"]):
        logger.warning("Invalid priority '%s'; ignoring.", updates["priority"])
        del updates["priority"]

    meta.update(updates)
    content = serialize_frontmatter(meta, new_body if new_body is not None else body)
    return safe_write_file(path, content)


def list_tasks(
    filter_by: Optional[dict[str, Any]] = None,
    project_id: Optional[str] = None,
    include_archived: bool = False,
) -> list[dict[str, Any]]:
    """
    List tasks, optionally filtered.

    Args:
        filter_by: Optional dict of field->value filters applied to metadata.
                   Supports: status, priority, tags (matches if any tag present).
        project_id: If provided, only list tasks in this project.
        include_archived: If True, also scan the archive directory.

    Returns:
        List of task metadata dictionaries.

    Example:
        >>> list_tasks(filter_by={"status": "todo", "priority": "high"})
        [{'id': 'task-001', 'title': '...', ...}, ...]
    """
    root = _workspace_root()
    if root is None:
        return []

    filter_by = filter_by or {}
    tasks: list[dict[str, Any]] = []

    # Determine which project directories to scan
    projects_dir = root / "projects"
    if project_id:
        search_dirs = [projects_dir / project_id / "tasks"]
    else:
        search_dirs = list(projects_dir.glob("*/tasks"))

    # Also scan the archive directory when requested
    if include_archived:
        archive_dir = root / "archive"
        if archive_dir.is_dir():
            if project_id:
                archive_tasks = archive_dir / project_id / "tasks"
                if archive_tasks.is_dir():
                    search_dirs.append(archive_tasks)
            else:
                search_dirs.extend(archive_dir.glob("*/tasks"))

    for task_dir in search_dirs:
        if not task_dir.is_dir():
            continue
        for task_file in sorted(task_dir.glob("task-*.md")):
            raw = safe_read_file(task_file)
            if raw is None:
                continue
            meta, body = parse_frontmatter(raw)
            meta["_path"] = str(task_file)

            # Extract first image attachment as thumbnail
            thumb = _extract_first_image(body)
            if thumb:
                meta["thumbnail"] = thumb

            if _matches_filter(meta, filter_by):
                tasks.append(meta)

    return tasks


def archive_task(task_id: str) -> bool:
    """
    Archive a task by moving it to the archive directory and setting
    its status to 'archived'.

    Args:
        task_id: The task identifier.

    Returns:
        True if archived successfully.
    """
    root = _workspace_root()
    if root is None:
        return False

    path = _find_task_file(task_id)
    if path is None:
        logger.error("Task '%s' not found for archiving.", task_id)
        return False

    # Read and update status
    raw = safe_read_file(path)
    if raw is None:
        return False
    meta, body = parse_frontmatter(raw)
    meta["status"] = "archived"

    # Determine archive location
    project_id = meta.get("project", "inbox")
    archive_dir = safe_child_path(root, "archive", project_id, "tasks")
    if archive_dir is None:
        logger.error("Invalid project ID '%s' in task metadata.", project_id)
        return False
    ensure_directory(archive_dir)

    archive_path = archive_dir / path.name
    if not safe_write_file(archive_path, serialize_frontmatter(meta, body)):
        return False

    # Remove original
    try:
        path.unlink()
    except OSError as e:
        logger.warning("Could not remove original task file: %s", e)

    logger.info("Archived task '%s'.", task_id)
    return True


def add_attachment(project_id: str, file_path: str, new_name: Optional[str] = None) -> Optional[str]:
    """
    Copy a file into a project's attachments directory.

    Args:
        project_id: Target project slug.
        file_path: Path to the source file.
        new_name: Optional rename for the copied file.

    Returns:
        Relative path to the attachment (from the tasks dir), or None on error.
    """
    root = _workspace_root()
    if root is None:
        return None

    src = Path(file_path).expanduser().resolve()
    if not src.is_file():
        logger.error("Attachment source '%s' not found.", file_path)
        return None

    attachments_dir = safe_child_path(root, "projects", project_id, "attachments")
    if attachments_dir is None:
        logger.error("Invalid project ID '%s'.", project_id)
        return None
    ensure_directory(attachments_dir)

    dest_name = new_name or src.name
    dest = attachments_dir / dest_name

    try:
        shutil.copy2(str(src), str(dest))
    except OSError as e:
        logger.error("Failed to copy attachment: %s", e)
        return None

    relative = f"../attachments/{dest_name}"
    logger.info("Added attachment '%s' to project '%s'.", dest_name, project_id)
    return relative


def link_tasks(task_a: str, task_b: str, relationship: str = "depends-on") -> bool:
    """
    Create a dependency link between two tasks.

    Adds task_b to task_a's dependencies list.

    Args:
        task_a: The dependent task ID.
        task_b: The dependency task ID.
        relationship: Description of the relationship (currently unused
                      beyond logging, reserved for future use).

    Returns:
        True if the link was created successfully.
    """
    task_data = get_task(task_a)
    if task_data is None:
        logger.error("Task '%s' not found.", task_a)
        return False

    deps = task_data["meta"].get("dependencies", [])
    if task_b in deps:
        logger.info("Link already exists: %s -> %s", task_a, task_b)
        return True

    # Check for circular dependency (simple direct check)
    other = get_task(task_b)
    if other and task_a in other["meta"].get("dependencies", []):
        logger.warning(
            "Circular dependency detected: %s and %s depend on each other.", task_a, task_b
        )
        return False

    deps.append(task_b)
    return update_task(task_a, {"dependencies": deps})


def move_task(task_id: str, target_project_id: str) -> bool:
    """
    Move a task from its current project to another.

    Args:
        task_id: The task identifier.
        target_project_id: The destination project slug.

    Returns:
        True if moved successfully.
    """
    root = _workspace_root()
    if root is None:
        return False

    src_path = _find_task_file(task_id)
    if src_path is None:
        logger.error("Task '%s' not found.", task_id)
        return False

    target_dir = safe_child_path(root, "projects", target_project_id, "tasks")
    if target_dir is None:
        logger.error("Invalid target project ID '%s'.", target_project_id)
        return False
    if not target_dir.exists():
        logger.error("Target project '%s' not found.", target_project_id)
        return False

    # Update the project field in metadata
    raw = safe_read_file(src_path)
    if raw is None:
        return False

    meta, body = parse_frontmatter(raw)
    meta["project"] = target_project_id
    content = serialize_frontmatter(meta, body)

    dest_path = target_dir / src_path.name
    if not safe_write_file(dest_path, content):
        return False

    try:
        src_path.unlink()
    except OSError as e:
        logger.warning("Could not remove original task file after move: %s", e)

    logger.info("Moved task '%s' to project '%s'.", task_id, target_project_id)
    return True


# ── Agent Tips ─────────────────────────────────────────────────────

def update_task_agent_tips(task_id: str, tips: list[str], replace: bool = False) -> bool:
    """
    Add AI-generated tips and suggestions to a task.

    Tips are stored in the '## Agent Tips' section of the task markdown,
    clearly separated from user-authored content.

    Args:
        task_id: The task identifier.
        tips: List of tip/suggestion strings to add.
        replace: If True, replace all existing tips. If False (default),
                 append to existing tips.

    Returns:
        True if the task was updated successfully.

    Example:
        >>> update_task_agent_tips("task-001", [
        ...     "Consider using a CSS framework like Tailwind for rapid prototyping",
        ...     "Look at competitor sites for layout inspiration: Stripe, Linear, Vercel",
        ...     "Run Lighthouse audit before and after to measure improvement",
        ... ])
        True
    """
    path = _find_task_file(task_id)
    if path is None:
        logger.error("Task '%s' not found.", task_id)
        return False

    raw = safe_read_file(path)
    if raw is None:
        return False

    meta, body = parse_frontmatter(raw)

    # Parse existing agent tips from body
    existing_tips: list[str] = []
    if not replace and "## Agent Tips" in body:
        parts = body.split("## Agent Tips")
        if len(parts) > 1:
            tips_section = parts[1].split("\n## ")[0]  # Stop at next section
            for line in tips_section.strip().splitlines():
                stripped = line.strip()
                if stripped.startswith("- "):
                    existing_tips.append(stripped[2:])

    # Combine tips
    all_tips = existing_tips + tips if not replace else tips

    # Rebuild the Agent Tips section
    tips_block = "\n".join(f"- {t}" for t in all_tips) if all_tips else ""

    if "## Agent Tips" in body:
        # Replace the section content
        before_tips = body.split("## Agent Tips")[0]
        after_parts = body.split("## Agent Tips")[1].split("\n## ", 1)
        after_section = "\n## " + after_parts[1] if len(after_parts) > 1 else ""
        body = f"{before_tips}## Agent Tips\n{tips_block}{after_section}"
    else:
        # Append the section
        body = f"{body.rstrip()}\n\n## Agent Tips\n{tips_block}"

    content = serialize_frontmatter(meta, body)
    return safe_write_file(path, content)


def get_task_agent_tips(task_id: str) -> list[str]:
    """
    Read the AI-generated tips for a task.

    Args:
        task_id: The task identifier.

    Returns:
        List of tip strings, or empty list if none.
    """
    task = get_task(task_id)
    if task is None:
        return []

    body = task.get("body", "")
    if "## Agent Tips" not in body:
        return []

    tips_section = body.split("## Agent Tips")[1].split("\n## ")[0]
    tips = []
    for line in tips_section.strip().splitlines():
        stripped = line.strip()
        if stripped.startswith("- "):
            tips.append(stripped[2:])
    return tips


# ── Internal helpers ───────────────────────────────────────────────

_IMG_LINK_RE = re.compile(
    r"\[([^\]]*)\]\(([^)]+\.(?:png|jpe?g|gif|webp|svg|bmp))\)",
    re.IGNORECASE,
)


def _extract_first_image(body: str) -> Optional[str]:
    """
    Extract the filename of the first image attachment from a task body.

    Scans the markdown body for image links (``[name](path.ext)``) where
    the extension is a known image format.  Returns just the filename
    (not the full path) so the dashboard can build the API URL.

    Returns:
        The image filename, or None if no images found.
    """
    match = _IMG_LINK_RE.search(body)
    if match:
        # Return just the filename portion (security: no paths)
        return Path(match.group(2)).name
    return None


def _workspace_root() -> Optional[Path]:
    """
    Resolve and return the workspace root from config.

    The returned path is always fully resolved so that security checks
    using ``Path.is_relative_to()`` work correctly.

    Returns:
        Resolved path to workspace root, or None if not configured.
    """
    config = load_config()
    ws = config.get("workspace_path", "")
    if not ws:
        logger.error(
            "Workspace path not configured. Run init_workspace() first."
        )
        return None
    return Path(ws).resolve()


def _find_task_file(task_id: str) -> Optional[Path]:
    """
    Locate a task file by ID across all projects.

    Args:
        task_id: The task identifier.

    Returns:
        Path to the task markdown file, or None if not found.
    """
    root = _workspace_root()
    if root is None:
        return None

    # Search active projects
    for task_file in (root / "projects").glob(f"*/tasks/{task_id}.md"):
        return task_file

    # Search archive
    for task_file in (root / "archive").glob(f"*/tasks/{task_id}.md"):
        return task_file

    logger.debug("Task file for '%s' not found.", task_id)
    return None


def _next_task_counter(root: Path) -> int:
    """
    Determine the next sequential task counter by scanning existing tasks.

    Scans all projects and the archive to find the highest existing counter.
    """
    import re as _re

    max_counter = 0
    pattern = _re.compile(r"task-(\d+)\.md$")

    for md_file in root.glob("**/tasks/task-*.md"):
        m = pattern.search(md_file.name)
        if m:
            max_counter = max(max_counter, int(m.group(1)))

    return max_counter + 1


def _matches_filter(meta: dict[str, Any], filter_by: dict[str, Any]) -> bool:
    """
    Check whether a task's metadata matches all filter conditions.
    """
    for key, value in filter_by.items():
        if key == "tags":
            # Match if any requested tag is present
            task_tags = meta.get("tags", [])
            if not any(t in task_tags for t in value):
                return False
        else:
            if meta.get(key) != value:
                return False
    return True


path: scripts/index_manager.py
content: """
Search and fast-lookup optimisation for the Natural Language Planner.

Builds an in-memory index from the markdown files so that queries,
due-date checks, and check-in lookups are fast even with many tasks.
"""

import json
import logging
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Optional

from .utils import parse_frontmatter, safe_read_file, safe_write_file, ensure_directory
from .config_manager import load_config

logger = logging.getLogger("nlplanner.index")

# In-memory index
_index: dict[str, Any] = {"tasks": {}, "projects": {}, "built_at": ""}


# ── Public API ─────────────────────────────────────────────────────

def rebuild_index() -> bool:
    """
    Rebuild the in-memory index by scanning all markdown files.

    Also persists the index to .nlplanner/index.json for faster cold starts.

    Returns:
        True if the index was built successfully.

    Example:
        >>> rebuild_index()
        True
        >>> len(search_tasks("backend"))
        3
    """
    root = _workspace_root()
    if root is None:
        return False

    global _index
    _index = {"tasks": {}, "projects": {}, "built_at": datetime.now().isoformat()}

    # Index projects
    projects_dir = root / "projects"
    if projects_dir.exists():
        for readme in projects_dir.glob("*/README.md"):
            raw = safe_read_file(readme)
            if raw is None:
                continue
            meta, body = parse_frontmatter(raw)
            pid = meta.get("id", readme.parent.name)
            _index["projects"][pid] = {
                **meta,
                "_body": body,
                "_path": str(readme),
            }

    # Index tasks
    for task_file in projects_dir.glob("*/tasks/task-*.md"):
        raw = safe_read_file(task_file)
        if raw is None:
            continue
        meta, body = parse_frontmatter(raw)
        tid = meta.get("id", task_file.stem)
        _index["tasks"][tid] = {
            **meta,
            "_body": body,
            "_path": str(task_file),
        }

    # Also index archived tasks for search
    archive_dir = root / "archive"
    if archive_dir.exists():
        for task_file in archive_dir.glob("*/tasks/task-*.md"):
            raw = safe_read_file(task_file)
            if raw is None:
                continue
            meta, body = parse_frontmatter(raw)
            tid = meta.get("id", task_file.stem)
            _index["tasks"][tid] = {
                **meta,
                "_body": body,
                "_path": str(task_file),
                "_archived": True,
            }

    # Persist to disk
    _persist_index(root)

    task_count = len(_index["tasks"])
    project_count = len(_index["projects"])
    logger.info("Index rebuilt: %d tasks, %d projects.", task_count, project_count)
    return True


def search_tasks(query: str, include_archived: bool = False) -> list[dict[str, Any]]:
    """
    Search tasks by a text query.

    Matches against title, description body, tags, and project name.
    Case-insensitive substring matching.

    Args:
        query: Search string.
        include_archived: Whether to include archived tasks in results.

    Returns:
        List of matching task metadata dicts, sorted by relevance (title
        matches first, then body matches).

    Example:
        >>> results = search_tasks("deploy")
        >>> [r["title"] for r in results]
        ['Deploy to staging', 'Set up deploy pipeline']
    """
    _ensure_index()
    query_lower = query.lower()
    title_matches = []
    other_matches = []

    for tid, task in _index["tasks"].items():
        if not include_archived and task.get("_archived"):
            continue
        if task.get("status") == "archived" and not include_archived:
            continue

        title = (task.get("title") or "").lower()
        body = (task.get("_body") or "").lower()
        tags = " ".join(task.get("tags") or []).lower()
        project = (task.get("project") or "").lower()

        searchable = f"{title} {body} {tags} {project}"

        if query_lower in title:
            title_matches.append(_clean_task(task))
        elif query_lower in searchable:
            other_matches.append(_clean_task(task))

    return title_matches + other_matches


def get_tasks_due_soon(days: int = 7) -> list[dict[str, Any]]:
    """
    Return active tasks with due dates within the next N days.

    Args:
        days: Number of days to look ahead.

    Returns:
        List of task metadata dicts sorted by due date (soonest first).

    Example:
        >>> upcoming = get_tasks_due_soon(3)
        >>> [t["title"] for t in upcoming]
        ['Submit report', 'Review PR']
    """
    _ensure_index()
    today = date.today()
    horizon = today + timedelta(days=days)
    results = []

    for tid, task in _index["tasks"].items():
        if task.get("status") in ("done", "archived"):
            continue
        due_str = task.get("due", "")
        if not due_str:
            continue
        try:
            due_date = date.fromisoformat(str(due_str))
        except (ValueError, TypeError):
            continue

        if due_date <= horizon:
            entry = _clean_task(task)
            entry["_due_date"] = due_date.isoformat()
            entry["_overdue"] = due_date < today
            results.append(entry)

    results.sort(key=lambda t: t.get("_due_date", "9999"))
    return results


def get_tasks_needing_checkin() -> list[dict[str, Any]]:
    """
    Return active tasks that haven't been checked on recently.

    Uses the configured check-in frequency from config.json.

    Returns:
        List of task metadata dicts that need a check-in.

    Example:
        >>> stale = get_tasks_needing_checkin()
        >>> len(stale)
        2
    """
    _ensure_index()
    config = load_config()
    freq_hours = config.get("settings", {}).get("checkin_frequency_hours", 24)
    cutoff = datetime.now() - timedelta(hours=freq_hours)

    results = []
    for tid, task in _index["tasks"].items():
        status = task.get("status", "")
        if status in ("done", "archived"):
            continue

        last_checkin_str = task.get("last_checkin", "")
        if not last_checkin_str:
            results.append(_clean_task(task))
            continue

        try:
            last_checkin = datetime.fromisoformat(str(last_checkin_str))
        except (ValueError, TypeError):
            # If it's just a date string like "2026-02-09", parse that
            try:
                last_checkin = datetime.fromisoformat(str(last_checkin_str) + "T00:00:00")
            except (ValueError, TypeError):
                results.append(_clean_task(task))
                continue

        if last_checkin < cutoff:
            results.append(_clean_task(task))

    return results


def get_overdue_tasks() -> list[dict[str, Any]]:
    """
    Return all active tasks that are past their due date.

    Returns:
        List of overdue task metadata dicts.
    """
    _ensure_index()
    today = date.today()
    results = []

    for tid, task in _index["tasks"].items():
        if task.get("status") in ("done", "archived"):
            continue
        due_str = task.get("due", "")
        if not due_str:
            continue
        try:
            due_date = date.fromisoformat(str(due_str))
        except (ValueError, TypeError):
            continue
        if due_date < today:
            entry = _clean_task(task)
            entry["_days_overdue"] = (today - due_date).days
            results.append(entry)

    results.sort(key=lambda t: t.get("_days_overdue", 0), reverse=True)
    return results


def get_stats() -> dict[str, Any]:
    """
    Return summary statistics about all tasks and projects.

    Returns:
        Dictionary with counts by status, priority, overdue tasks, etc.
    """
    _ensure_index()
    today = date.today()

    stats: dict[str, Any] = {
        "total_tasks": 0,
        "by_status": {"todo": 0, "in-progress": 0, "done": 0, "archived": 0},
        "by_priority": {"low": 0, "medium": 0, "high": 0},
        "overdue": 0,
        "due_this_week": 0,
        "total_projects": len(_index["projects"]),
        "active_projects": 0,
    }

    for tid, task in _index["tasks"].items():
        stats["total_tasks"] += 1
        status = task.get("status", "todo")
        stats["by_status"][status] = stats["by_status"].get(status, 0) + 1

        priority = task.get("priority", "medium")
        if priority in stats["by_priority"]:
            stats["by_priority"][priority] += 1

        due_str = task.get("due", "")
        if due_str and status not in ("done", "archived"):
            try:
                due_date = date.fromisoformat(str(due_str))
                if due_date < today:
                    stats["overdue"] += 1
                elif due_date <= today + timedelta(days=7):
                    stats["due_this_week"] += 1
            except (ValueError, TypeError):
                pass

    for pid, proj in _index["projects"].items():
        if proj.get("status") == "active":
            stats["active_projects"] += 1

    return stats


# ── Internal helpers ───────────────────────────────────────────────

def _workspace_root() -> Optional[Path]:
    """Get workspace root from config."""
    config = load_config()
    ws = config.get("workspace_path", "")
    if not ws:
        logger.error("Workspace not configured.")
        return None
    return Path(ws)


def _ensure_index() -> None:
    """Rebuild the index if it hasn't been built yet."""
    if not _index["tasks"] and not _index["projects"]:
        rebuild_index()


def _clean_task(task: dict[str, Any]) -> dict[str, Any]:
    """Return a copy of task metadata without internal fields."""
    return {k: v for k, v in task.items() if not k.startswith("_")}


def _persist_index(root: Path) -> None:
    """Save the index to disk for faster cold starts."""
    index_path = root / ".nlplanner" / "index.json"
    ensure_directory(index_path.parent)

    # Strip body text to keep the index file small
    slim = {
        "built_at": _index["built_at"],
        "tasks": {
            tid: {k: v for k, v in t.items() if k != "_body"}
            for tid, t in _index["tasks"].items()
        },
        "projects": {
            pid: {k: v for k, v in p.items() if k != "_body"}
            for pid, p in _index["projects"].items()
        },
    }

    try:
        content = json.dumps(slim, indent=2, default=str, ensure_ascii=False)
        safe_write_file(index_path, content)
    except (TypeError, ValueError) as e:
        logger.warning("Failed to persist index: %s", e)


path: scripts/tunnel.py
content: """
Tunnel integration for remote dashboard access.

Provides a simple way to expose the local dashboard to the internet
using Cloudflare Tunnel (cloudflared), ngrok, or localtunnel.

No external Python dependencies — these tools are invoked as subprocesses.
"""

import logging
import re
import shutil
import subprocess
import threading
import time
from typing import Optional

logger = logging.getLogger("nlplanner.tunnel")

_process: Optional[subprocess.Popen] = None
_tunnel_url: str = ""
_monitor_thread: Optional[threading.Thread] = None


# ── Tunnel tool detection ──────────────────────────────────────────

def detect_tunnel_tool() -> Optional[str]:
    """
    Detect which tunnel tool is available on the system.

    Checks in order of preference: cloudflared, ngrok, localtunnel (lt).

    Returns:
        The name of the available tool, or None if none found.

    Example:
        >>> tool = detect_tunnel_tool()
        >>> print(tool)
        'cloudflared'
    """
    for tool in ["cloudflared", "ngrok", "lt"]:
        if shutil.which(tool):
            logger.info("Tunnel tool found: %s", tool)
            return tool

    logger.info("No tunnel tool found on PATH.")
    return None


def get_install_instructions() -> str:
    """
    Return installation instructions for tunnel tools.

    Returns:
        A human-readable string with install commands for each platform.
    """
    return (
        "To expose your dashboard remotely, install one of these tools:\n"
        "\n"
        "  Cloudflare Tunnel (recommended, free, no account needed for quick tunnels):\n"
        "    Windows:  winget install cloudflare.cloudflared\n"
        "    macOS:    brew install cloudflared\n"
        "    Linux:    https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n"
        "\n"
        "  ngrok (free tier available, requires account):\n"
        "    All:      https://ngrok.com/download\n"
        "\n"
        "  localtunnel (npm, no account needed):\n"
        "    All:      npm install -g localtunnel\n"
    )


# ── Start / Stop ──────────────────────────────────────────────────

def start_tunnel(port: int, tool: Optional[str] = None) -> str:
    """
    Start a tunnel to expose a local port to the internet.

    Args:
        port: The local port to tunnel (e.g., 8080).
        tool: Which tool to use. If None, auto-detects.

    Returns:
        The public tunnel URL, or empty string on failure.

    Example:
        >>> url = start_tunnel(8080)
        >>> print(url)
        'https://random-name.trycloudflare.com'
    """
    global _process, _tunnel_url

    if _process is not None:
        logger.info("Tunnel is already running at %s", _tunnel_url)
        return _tunnel_url

    if tool is None:
        tool = detect_tunnel_tool()

    if tool is None:
        logger.error("No tunnel tool available.")
        print(get_install_instructions())
        return ""

    try:
        if tool == "cloudflared":
            return _start_cloudflared(port)
        elif tool == "ngrok":
            return _start_ngrok(port)
        elif tool == "lt":
            return _start_localtunnel(port)
        else:
            logger.error("Unknown tunnel tool: %s", tool)
            return ""
    except Exception as e:
        logger.error("Failed to start tunnel with %s: %s", tool, e)
        return ""


def stop_tunnel() -> None:
    """Stop the running tunnel process."""
    global _process, _tunnel_url, _monitor_thread

    if _process is None:
        logger.info("No tunnel is running.")
        return

    try:
        _process.terminate()
        _process.wait(timeout=5)
    except subprocess.TimeoutExpired:
        _process.kill()
    except Exception as e:
        logger.warning("Error stopping tunnel: %s", e)

    _process = None
    _tunnel_url = ""
    _monitor_thread = None
    logger.info("Tunnel stopped.")


def get_tunnel_url() -> str:
    """
    Get the public URL of the running tunnel.

    Returns:
        The tunnel URL, or empty string if not running.
    """
    return _tunnel_url


def is_tunnel_running() -> bool:
    """Check if a tunnel is currently active."""
    if _process is None:
        return False
    return _process.poll() is None


# ── Tool-specific launchers ────────────────────────────────────────

def _start_cloudflared(port: int) -> str:
    """Start a Cloudflare quick tunnel."""
    global _process, _tunnel_url

    cmd = ["cloudflared", "tunnel", "--url", f"http://localhost:{port}"]
    logger.info("Starting cloudflared: %s", " ".join(cmd))

    _process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )

    # Cloudflared prints the URL to stderr/stdout — watch for it
    url = _capture_url_from_output(
        _process,
        pattern=r"(https://[a-zA-Z0-9-]+\.trycloudflare\.com)",
        timeout=15,
    )

    if url:
        _tunnel_url = url
        logger.info("Cloudflare tunnel active: %s", url)
        return url

    logger.error("Could not capture cloudflared URL within timeout.")
    stop_tunnel()
    return ""


def _start_ngrok(port: int) -> str:
    """Start an ngrok tunnel."""
    global _process, _tunnel_url

    cmd = ["ngrok", "http", str(port), "--log", "stdout"]
    logger.info("Starting ngrok: %s", " ".join(cmd))

    _process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )

    url = _capture_url_from_output(
        _process,
        pattern=r"(https://[a-zA-Z0-9-]+\.ngrok[a-zA-Z0-9.-]*\.[a-z]+)",
        timeout=10,
    )

    if url:
        _tunnel_url = url
        logger.info("ngrok tunnel active: %s", url)
        return url

    logger.error("Could not capture ngrok URL within timeout.")
    stop_tunnel()
    return ""


def _start_localtunnel(port: int) -> str:
    """Start a localtunnel (lt) tunnel."""
    global _process, _tunnel_url

    cmd = ["lt", "--port", str(port)]
    logger.info("Starting localtunnel: %s", " ".join(cmd))

    _process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )

    url = _capture_url_from_output(
        _process,
        pattern=r"(https://[a-zA-Z0-9-]+\.loca\.lt)",
        timeout=10,
    )

    if url:
        _tunnel_url = url
        logger.info("localtunnel active: %s", url)
        return url

    logger.error("Could not capture localtunnel URL within timeout.")
    stop_tunnel()
    return ""


def _capture_url_from_output(
    proc: subprocess.Popen,
    pattern: str,
    timeout: int = 15,
) -> str:
    """
    Read process output line by line and capture the first URL matching pattern.

    Runs in the current thread, blocking up to `timeout` seconds.
    After capturing, spawns a background thread to drain remaining output.
    """
    url = ""
    deadline = time.time() + timeout

    while time.time() < deadline:
        if proc.stdout is None:
            break
        # Use a short readline with the deadline in mind
        line = proc.stdout.readline()
        if not line:
            if proc.poll() is not None:
                break
            time.sleep(0.1)
            continue

        match = re.search(pattern, line)
        if match:
            url = match.group(1)
            break

    # Drain remaining output in background to prevent pipe blocking
    if proc.stdout:
        def _drain():
            try:
                for _ in proc.stdout:
                    pass
            except Exception:
                pass

        global _monitor_thread
        _monitor_thread = threading.Thread(target=_drain, daemon=True)
        _monitor_thread.start()

    return url


path: scripts/utils.py
content: """
Shared utilities for the Natural Language Planner.

Provides common functions used across all modules: slug generation,
YAML frontmatter parsing/serializing, date handling, and ID generation.
"""

import re
import uuid
import logging
from pathlib import Path
from datetime import datetime, date
from typing import Any, Optional

logger = logging.getLogger("nlplanner")


def generate_slug(text: str, max_length: int = 50) -> str:
    """
    Generate a URL/filesystem-safe slug from text.

    Args:
        text: The text to slugify.
        max_length: Maximum length of the slug.

    Returns:
        A lowercase, hyphen-separated slug string.

    Example:
        >>> generate_slug("My Cool Project!")
        'my-cool-project'
    """
    slug = text.lower().strip()
    slug = re.sub(r"[^\w\s-]", "", slug)
    slug = re.sub(r"[\s_]+", "-", slug)
    slug = re.sub(r"-+", "-", slug)
    slug = slug.strip("-")
    return slug[:max_length]


def generate_id(prefix: str = "") -> str:
    """
    Generate a unique ID with an optional prefix.

    Args:
        prefix: Optional prefix for the ID (e.g., 'task', 'proj').

    Returns:
        A unique identifier string.

    Example:
        >>> generate_id("task")
        'task-a1b2c3d4'
    """
    short_uuid = uuid.uuid4().hex[:8]
    if prefix:
        return f"{prefix}-{short_uuid}"
    return short_uuid


def generate_task_id(counter: int) -> str:
    """
    Generate a sequential task ID.

    Args:
        counter: The task number.

    Returns:
        Formatted task ID like 'task-001'.

    Example:
        >>> generate_task_id(42)
        'task-042'
    """
    return f"task-{counter:03d}"


def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
    """
    Parse YAML frontmatter from a markdown file's content.

    Expects content in the form:
        ---
        key: value
        ---
        Body text here.

    Args:
        content: The full markdown file content.

    Returns:
        A tuple of (frontmatter_dict, body_text).
        If no frontmatter is found, returns ({}, full_content).

    Example:
        >>> meta, body = parse_frontmatter("---\\ntitle: Hello\\n---\\nWorld")
        >>> meta
        {'title': 'Hello'}
        >>> body
        'World'
    """
    # Lazy import to avoid import-time dependency issues
    try:
        import yaml
    except ImportError:
        logger.error("PyYAML is required. Install with: pip install pyyaml")
        return {}, content

    pattern = r"^---\s*\n(.*?)\n---\s*\n?(.*)"
    match = re.match(pattern, content, re.DOTALL)
    if not match:
        return {}, content

    try:
        frontmatter = yaml.safe_load(match.group(1)) or {}
    except yaml.YAMLError as e:
        logger.warning("Failed to parse YAML frontmatter: %s", e)
        frontmatter = {}

    body = match.group(2).strip()
    return frontmatter, body


def serialize_frontmatter(metadata: dict[str, Any], body: str) -> str:
    """
    Serialize metadata and body into a markdown string with YAML frontmatter.

    Args:
        metadata: Dictionary of metadata fields.
        body: The markdown body content.

    Returns:
        Complete markdown string with YAML frontmatter block.

    Example:
        >>> content = serialize_frontmatter({"title": "Hello"}, "World")
        >>> print(content)
        ---
        title: Hello
        ---
        <BLANKLINE>
        World
    """
    try:
        import yaml
    except ImportError:
        logger.error("PyYAML is required. Install with: pip install pyyaml")
        return body

    # Custom representer for dates — keep them as plain strings
    def date_representer(dumper: yaml.Dumper, data: date) -> yaml.ScalarNode:
        return dumper.represent_scalar("tag:yaml.org,2002:str", data.isoformat())

    dumper = yaml.Dumper
    dumper.add_representer(date, date_representer)
    dumper.add_representer(datetime, date_representer)

    yaml_str = yaml.dump(
        metadata,
        Dumper=dumper,
        default_flow_style=False,
        sort_keys=False,
        allow_unicode=True,
    )

    return f"---\n{yaml_str.strip()}\n---\n\n{body}"


def today_str() -> str:
    """Return today's date as an ISO-formatted string (YYYY-MM-DD)."""
    return date.today().isoformat()


def now_str() -> str:
    """Return current datetime as an ISO-formatted string."""
    return datetime.now().isoformat(timespec="seconds")


def ensure_directory(path: Path) -> bool:
    """
    Ensure a directory exists, creating it and any parents if needed.

    Args:
        path: The directory path to ensure exists.

    Returns:
        True if the directory exists (or was created), False on error.
    """
    try:
        path.mkdir(parents=True, exist_ok=True)
        return True
    except OSError as e:
        logger.error("Failed to create directory %s: %s", path, e)
        return False


def safe_read_file(path: Path) -> Optional[str]:
    """
    Safely read a text file, returning None on error.

    Args:
        path: Path to the file.

    Returns:
        File contents as a string, or None if the file can't be read.
    """
    try:
        return path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError) as e:
        logger.error("Failed to read file %s: %s", path, e)
        return None


def safe_write_file(path: Path, content: str) -> bool:
    """
    Safely write content to a text file, creating parent directories as needed.

    Args:
        path: Path to the file.
        content: String content to write.

    Returns:
        True if the file was written successfully, False on error.
    """
    try:
        ensure_directory(path.parent)
        path.write_text(content, encoding="utf-8")
        return True
    except OSError as e:
        logger.error("Failed to write file %s: %s", path, e)
        return False


def safe_child_path(root: Path, *segments: str) -> Optional[Path]:
    """Construct a path from *root* + *segments* and verify it stays inside *root*.

    Returns the resolved ``Path`` if the result is inside *root*, or
    ``None`` if any segment (e.g. containing ``..``) would escape it.
    This prevents path-traversal attacks when IDs come from untrusted
    input such as URL parameters or user-supplied project/task names.

    Args:
        root: The trusted base directory (must already be resolved).
        *segments: One or more path components to join.

    Returns:
        Resolved ``Path`` inside *root*, or ``None`` on traversal.
    """
    try:
        target = root.joinpath(*segments).resolve()
        # is_relative_to is available in Python 3.9+
        if target.is_relative_to(root):
            return target
    except (ValueError, OSError):
        pass
    logger.warning("Path traversal blocked: %s / %s", root, segments)
    return None


def validate_status(status: str) -> bool:
    """Check if a status value is valid for tasks."""
    return status in ("todo", "in-progress", "done", "archived")


def validate_priority(priority: str) -> bool:
    """Check if a priority value is valid."""
    return priority in ("low", "medium", "high")


# Configure default logging
def setup_logging(level: int = logging.INFO) -> None:
    """
    Configure logging for the Natural Language Planner.

    Args:
        level: Logging level (default: INFO).
    """
    handler = logging.StreamHandler()
    handler.setFormatter(
        logging.Formatter("[%(name)s %(levelname)s] %(message)s")
    )
    root = logging.getLogger("nlplanner")
    root.setLevel(level)
    if not root.handlers:
        root.addHandler(handler)


path: templates/dashboard/index.html
content: <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>NL Planner — Dashboard</title>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <div id="app">
    <!-- ── Header ──────────────────────────────────────────── -->
    <header class="header">
      <div class="header-left">
        <h1 class="logo">NL Planner</h1>
      </div>
      <div class="header-center">
        <div class="search-box">
          <svg class="icon icon-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
          </svg>
          <input type="text" id="search-input" placeholder="Search tasks..." autocomplete="off" />
        </div>
      </div>
      <div class="header-right">
        <button class="btn btn-ghost theme-toggle" id="btn-theme" title="Toggle dark mode">
          <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
          </svg>
          <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/>
            <line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
            <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/>
            <line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
            <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
          </svg>
        </button>
        <button class="btn btn-ghost" id="btn-refresh" title="Refresh data">
          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
            <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
          </svg>
        </button>
      </div>
    </header>

    <!-- ── Stats bar ───────────────────────────────────────── -->
    <section class="stats-bar" id="stats-bar">
      <div class="stat-card">
        <span class="stat-value" id="stat-total">—</span>
        <span class="stat-label">Total Tasks</span>
      </div>
      <div class="stat-card stat-todo">
        <span class="stat-value" id="stat-todo">—</span>
        <span class="stat-label">To Do</span>
      </div>
      <div class="stat-card stat-progress">
        <span class="stat-value" id="stat-progress">—</span>
        <span class="stat-label">In Progress</span>
      </div>
      <div class="stat-card stat-done">
        <span class="stat-value" id="stat-done">—</span>
        <span class="stat-label">Done</span>
      </div>
      <div class="stat-card stat-overdue">
        <span class="stat-value" id="stat-overdue">—</span>
        <span class="stat-label">Overdue</span>
      </div>
      <div class="stat-card">
        <span class="stat-value" id="stat-projects">—</span>
        <span class="stat-label">Projects</span>
      </div>
    </section>

    <!-- ── View tabs ───────────────────────────────────────── -->
    <nav class="view-tabs">
      <button class="tab active" data-view="focus">This Week</button>
      <button class="tab" data-view="board">Board</button>
      <button class="tab" data-view="projects">Projects</button>
      <button class="tab" data-view="timeline">Timeline</button>
      <button class="tab" data-view="archive">Archive</button>
    </nav>

    <!-- ── Main content ────────────────────────────────────── -->
    <main class="main-content">
      <!-- This Week focus view (default) -->
      <div class="view view-focus active" id="view-focus">
        <section class="week-section" id="week-section">
          <div class="week-header">
            <h2>This Week</h2>
            <span class="week-date-range" id="week-date-range"></span>
          </div>
          <div class="week-grid" id="week-grid"></div>
        </section>
      </div>

      <!-- Board view (Kanban) -->
      <div class="view view-board" id="view-board">
        <div class="kanban">
          <div class="kanban-column" data-status="todo">
            <div class="column-header">
              <span class="column-dot dot-todo"></span>
              <h2>To Do</h2>
              <span class="column-count" id="col-count-todo">0</span>
            </div>
            <div class="column-body" id="col-todo"></div>
          </div>
          <div class="kanban-column" data-status="in-progress">
            <div class="column-header">
              <span class="column-dot dot-progress"></span>
              <h2>In Progress</h2>
              <span class="column-count" id="col-count-progress">0</span>
            </div>
            <div class="column-body" id="col-progress"></div>
          </div>
          <div class="kanban-column" data-status="done">
            <div class="column-header">
              <span class="column-dot dot-done"></span>
              <h2>Done</h2>
              <span class="column-count" id="col-count-done">0</span>
            </div>
            <div class="column-body" id="col-done"></div>
          </div>
        </div>
      </div>

      <!-- Projects view -->
      <div class="view view-projects" id="view-projects">
        <div class="projects-grid" id="projects-grid"></div>
      </div>

      <!-- Timeline view -->
      <div class="view view-timeline" id="view-timeline">
        <div class="timeline" id="timeline"></div>
      </div>

      <!-- Archive view -->
      <div class="view view-archive" id="view-archive">
        <div class="archive-section">
          <div class="archive-header">
            <h2>Archive</h2>
            <span class="archive-count" id="archive-count"></span>
          </div>
          <div class="archive-list" id="archive-list"></div>
        </div>
      </div>

      <!-- Search results overlay -->
      <div class="view view-search" id="view-search" style="display:none">
        <h2 class="search-title">Search Results</h2>
        <div id="search-results"></div>
      </div>
    </main>

    <!-- ── Task detail modal ───────────────────────────────── -->
    <div class="modal-overlay" id="modal-overlay">
      <div class="modal" id="task-modal">
        <button class="modal-close" id="modal-close">&times;</button>
        <div class="modal-header">
          <span class="badge" id="modal-status"></span>
          <span class="badge badge-priority" id="modal-priority"></span>
        </div>
        <h2 class="modal-title" id="modal-title"></h2>
        <div class="modal-meta">
          <span id="modal-project"></span>
          <span id="modal-due"></span>
          <span id="modal-created"></span>
        </div>
        <div class="modal-tags" id="modal-tags"></div>
        <div class="modal-body" id="modal-body"></div>
        <div class="modal-context" id="modal-context" style="display:none">
          <div class="modal-context-label">Context</div>
          <div id="modal-context-text"></div>
        </div>
        <div class="modal-deps" id="modal-deps"></div>
        <div class="modal-gallery" id="modal-gallery" style="display:none">
          <div class="modal-gallery-label">Attachments</div>
          <div class="gallery-grid" id="gallery-grid"></div>
        </div>
        <div class="modal-agent-tips" id="modal-agent-tips" style="display:none">
          <div class="agent-tips-header" id="agent-tips-header">
            <svg class="agent-tips-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
              <line x1="9" y1="21" x2="15" y2="21"/><line x1="10" y1="24" x2="14" y2="24"/>
            </svg>
            <span class="agent-tips-label">Agent Tips &amp; Ideas</span>
            <span class="agent-tips-toggle" id="agent-tips-chevron">&#9660;</span>
          </div>
          <div class="agent-tips-body" id="agent-tips-body">
            <ul class="agent-tips-list" id="agent-tips-list"></ul>
          </div>
        </div>
      </div>
    </div>

    <!-- ── Image lightbox ──────────────────────────────────── -->
    <div class="lightbox-overlay" id="lightbox-overlay">
      <img id="lightbox-img" src="" alt="Attachment preview" />
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>


path: templates/project_template.md
content: ---
id: {project_id}
title: {title}
created: {date}
status: active
tags: []
---

## Description
{description}

## Goals
- {goal}

## Notes


path: templates/task_template.md
content: ---
id: {task_id}
title: {title}
project: {project_id}
status: todo
priority: medium
created: {date}
due: ''
last_checkin: {date}
tags: []
dependencies: []
---

## Description
{description}

## Context
{context}

## Notes

## Attachments

## Agent Tips


path: tests/test_config_manager.py
content: """
Tests for scripts.config_manager — settings management.

Run with:  python -m pytest tests/test_config_manager.py -v
"""

import sys
import os
import json

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from scripts.config_manager import (
    load_config,
    save_config,
    set_config_path,
    get_setting,
    set_setting,
    get_checkin_frequency,
    set_checkin_frequency,
    get_preference,
    DEFAULT_CONFIG,
)


@pytest.fixture
def workspace(tmp_path):
    """Create a temporary workspace for config tests."""
    ws = tmp_path / "config_workspace"
    ws.mkdir()
    (ws / ".nlplanner").mkdir()
    set_config_path(str(ws))
    return ws


class TestLoadSaveConfig:
    def test_load_returns_defaults_when_no_file(self, workspace):
        config = load_config(str(workspace))
        assert config["version"] == DEFAULT_CONFIG["version"]
        assert "settings" in config

    def test_save_and_load_roundtrip(self, workspace):
        config = load_config(str(workspace))
        config["workspace_path"] = str(workspace)
        config["settings"]["dashboard_port"] = 9090
        assert save_config(config, str(workspace))

        loaded = load_config(str(workspace))
        assert loaded["workspace_path"] == str(workspace)
        assert loaded["settings"]["dashboard_port"] == 9090

    def test_load_merges_with_defaults(self, workspace):
        """New default keys should appear even if config file is old."""
        # Write a minimal config
        config_path = workspace / ".nlplanner" / "config.json"
        config_path.write_text(json.dumps({"version": "0.9.0"}), encoding="utf-8")

        loaded = load_config(str(workspace))
        # Should have the default settings merged in
        assert "settings" in loaded
        assert "checkin_frequency_hours" in loaded["settings"]

    def test_load_handles_invalid_json(self, workspace):
        config_path = workspace / ".nlplanner" / "config.json"
        config_path.write_text("not valid json {{{", encoding="utf-8")

        config = load_config(str(workspace))
        # Should return defaults
        assert config["version"] == DEFAULT_CONFIG["version"]


class TestSettings:
    def test_get_set_setting(self, workspace):
        # Save a baseline config
        config = load_config(str(workspace))
        save_config(config, str(workspace))

        assert set_setting("dashboard_port", 3000)
        assert get_setting("dashboard_port") == 3000

    def test_get_setting_missing_key(self, workspace):
        config = load_config(str(workspace))
        save_config(config, str(workspace))
        assert get_setting("nonexistent_key") is None


class TestCheckinFrequency:
    def test_default_frequency(self, workspace):
        config = load_config(str(workspace))
        save_config(config, str(workspace))
        assert get_checkin_frequency() == 24

    def test_set_frequency(self, workspace):
        config = load_config(str(workspace))
        save_config(config, str(workspace))
        assert set_checkin_frequency(48)
        assert get_checkin_frequency() == 48

    def test_minimum_frequency(self, workspace):
        config = load_config(str(workspace))
        save_config(config, str(workspace))
        assert set_checkin_frequency(0)  # Should clamp to 1
        assert get_checkin_frequency() == 1


class TestPreferences:
    def test_get_preference(self, workspace):
        config = load_config(str(workspace))
        save_config(config, str(workspace))
        assert get_preference("default_project") == "inbox"

    def test_get_missing_preference(self, workspace):
        config = load_config(str(workspace))
        save_config(config, str(workspace))
        assert get_preference("nonexistent") is None


path: tests/test_file_manager.py
content: """
Tests for scripts.file_manager — core CRUD operations.

Run with:  python -m pytest tests/test_file_manager.py -v
"""

import sys
import os
import tempfile
import shutil

import pytest

# Ensure the project root is on sys.path so imports work
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from scripts.file_manager import (
    init_workspace,
    create_project,
    get_project,
    list_projects,
    update_project,
    archive_project,
    create_task,
    get_task,
    list_tasks,
    update_task,
    archive_task,
    move_task,
    link_tasks,
    add_attachment,
)
from scripts.config_manager import set_config_path


@pytest.fixture
def workspace(tmp_path):
    """Create a temporary workspace for each test."""
    ws = tmp_path / "test_workspace"
    ws.mkdir()
    init_workspace(str(ws))
    return ws


class TestInitWorkspace:
    def test_creates_directory_structure(self, workspace):
        assert (workspace / ".nlplanner" / "config.json").exists()
        assert (workspace / "projects" / "inbox" / "tasks").is_dir()
        assert (workspace / "projects" / "inbox" / "README.md").exists()
        assert (workspace / "archive").is_dir()

    def test_idempotent(self, workspace):
        """Running init twice should not break anything."""
        assert init_workspace(str(workspace))
        assert (workspace / ".nlplanner" / "config.json").exists()


class TestProjects:
    def test_create_project(self, workspace):
        pid = create_project("My Project", description="A test project")
        assert pid == "my-project"
        assert (workspace / "projects" / "my-project" / "README.md").exists()
        assert (workspace / "projects" / "my-project" / "tasks").is_dir()
        assert (workspace / "projects" / "my-project" / "attachments").is_dir()

    def test_get_project(self, workspace):
        create_project("Read Me", description="Hello world")
        proj = get_project("read-me")
        assert proj is not None
        assert proj["meta"]["title"] == "Read Me"
        assert "Hello world" in proj["body"]

    def test_list_projects(self, workspace):
        create_project("Alpha")
        create_project("Beta")
        projects = list_projects()
        titles = [p["title"] for p in projects]
        assert "Alpha" in titles
        assert "Beta" in titles
        # Inbox should also be present
        assert "Inbox" in titles

    def test_update_project(self, workspace):
        create_project("Updatable")
        assert update_project("updatable", {"tags": ["test", "demo"]})
        proj = get_project("updatable")
        assert "test" in proj["meta"]["tags"]

    def test_archive_project(self, workspace):
        create_project("Archivable")
        assert archive_project("archivable")
        assert not (workspace / "projects" / "archivable").exists()
        assert (workspace / "archive" / "archivable" / "README.md").exists()

    def test_create_duplicate_project_returns_existing_id(self, workspace):
        pid1 = create_project("Same Name")
        pid2 = create_project("Same Name")
        assert pid1 == pid2


class TestTasks:
    def test_create_task_in_inbox(self, workspace):
        tid = create_task("My first task")
        assert tid is not None
        assert tid.startswith("task-")
        task = get_task(tid)
        assert task is not None
        assert task["meta"]["title"] == "My first task"
        assert task["meta"]["project"] == "inbox"

    def test_create_task_in_project(self, workspace):
        create_project("Work")
        tid = create_task("Do something", project_id="work", details={
            "priority": "high",
            "due": "2026-03-01",
            "tags": ["urgent"],
            "description": "Important work item",
        })
        task = get_task(tid)
        assert task["meta"]["priority"] == "high"
        assert task["meta"]["due"] == "2026-03-01"
        assert "urgent" in task["meta"]["tags"]

    def test_update_task_status(self, workspace):
        tid = create_task("Update me")
        assert update_task(tid, {"status": "in-progress"})
        task = get_task(tid)
        assert task["meta"]["status"] == "in-progress"

    def test_update_task_invalid_status_ignored(self, workspace):
        tid = create_task("Bad status")
        assert update_task(tid, {"status": "invalid-status"})
        task = get_task(tid)
        # Should remain the original status since 'invalid-status' is rejected
        assert task["meta"]["status"] == "todo"

    def test_list_tasks_no_filter(self, workspace):
        create_task("Task A")
        create_task("Task B")
        tasks = list_tasks()
        assert len(tasks) >= 2

    def test_list_tasks_with_filter(self, workspace):
        create_task("High prio", details={"priority": "high"})
        create_task("Low prio", details={"priority": "low"})
        high = list_tasks(filter_by={"priority": "high"})
        assert all(t["priority"] == "high" for t in high)

    def test_list_tasks_by_project(self, workspace):
        create_project("Proj A")
        create_task("In A", project_id="proj-a")
        create_task("In inbox")
        proj_tasks = list_tasks(project_id="proj-a")
        assert len(proj_tasks) == 1
        assert proj_tasks[0]["title"] == "In A"

    def test_archive_task(self, workspace):
        tid = create_task("Archivable task")
        assert archive_task(tid)
        # Should not appear in normal listing
        tasks = list_tasks(filter_by={"status": "todo"})
        assert not any(t["id"] == tid for t in tasks)

    def test_move_task(self, workspace):
        create_project("Destination")
        tid = create_task("Movable")
        assert move_task(tid, "destination")
        task = get_task(tid)
        assert task["meta"]["project"] == "destination"

    def test_sequential_task_ids(self, workspace):
        t1 = create_task("First")
        t2 = create_task("Second")
        t3 = create_task("Third")
        # IDs should be sequential
        assert t1 == "task-001"
        assert t2 == "task-002"
        assert t3 == "task-003"


class TestTaskDependencies:
    def test_link_tasks(self, workspace):
        t1 = create_task("Prerequisite")
        t2 = create_task("Dependent")
        assert link_tasks(t2, t1)
        task = get_task(t2)
        assert t1 in task["meta"]["dependencies"]

    def test_circular_dependency_prevented(self, workspace):
        t1 = create_task("Task A")
        t2 = create_task("Task B")
        assert link_tasks(t1, t2)  # A depends on B
        assert not link_tasks(t2, t1)  # B depends on A → circular

    def test_duplicate_link_is_idempotent(self, workspace):
        t1 = create_task("A")
        t2 = create_task("B")
        assert link_tasks(t1, t2)
        assert link_tasks(t1, t2)  # Should succeed without duplicating
        task = get_task(t1)
        assert task["meta"]["dependencies"].count(t2) == 1


class TestAttachments:
    def test_add_attachment(self, workspace, tmp_path):
        create_project("Attach Test")
        # Create a temporary file to attach
        src = tmp_path / "diagram.png"
        src.write_bytes(b"fake png data")

        rel = add_attachment("attach-test", str(src))
        assert rel is not None
        assert "diagram.png" in rel
        assert (workspace / "projects" / "attach-test" / "attachments" / "diagram.png").exists()

    def test_add_attachment_missing_source(self, workspace):
        create_project("No File")
        result = add_attachment("no-file", "/nonexistent/file.png")
        assert result is None


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)