mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 10:33:27 +00:00
Rename Phases to Plans, clean up deprecated aliases (#71)
* Rename "Phases" to "Plans" and clean up deprecated phase aliases
Renames the default "Phases" collection to "Plans" across the full stack:
- DB migration renames existing collections in-place (name, slug, prefix PLAN, icon 🗺️)
- Removes all deprecated Phase* backward-compat aliases from models and store
- Removes --phase CLI flag (use --parent instead)
- Updates convention triggers: on-phase-start/complete → on-plan-start/complete
- Updates dashboard API: active_phases → active_plans, /phases-progress → /plans-progress
- Updates all frontend components, types, and documentation
Closes IDEA-124
* Fix CSRF cookie not being cleared on logout
The SessionAuth middleware was re-issuing a CSRF cookie before the
logout handler could clear it, resulting in two Set-Cookie headers.
Skip CSRF re-issue for /api/v1/auth/ paths since auth endpoints
manage their own CSRF cookies (login sets, logout clears).
* Fix migration issues found in Codex review
- P1: Move doc_type UPDATE from migration 024 into 025, which recreates
the table with the new CHECK constraint first (SQLite enforces CHECK
on UPDATE, so the old constraint would reject 'plan')
- P1: Add PostgreSQL migration 005 for the collection rename (phases →
plans) — previously only existed on the SQLite path
- P2: Recreate FTS triggers, indexes, and rebuild FTS after the table
swap in migration 025 (DROP TABLE drops associated objects in SQLite)
* Fix parent filter field name and sync .agents skill copy
Codex review round 2 findings:
- P1: Parent filter compared against `parent_id` (wrong) instead of
`parent_link_id` — plan filtering in collection view was broken
- P1: .agents/skills/pad/SKILL.md still had old --phase flags and
"Phases" references — synced from the updated .claude copy
- P2: Accept legacy 'phase' filter key for backward compat with
existing saved views that serialized the old key name
* Fix PG migration JSONB casting and add slug collision guards
Codex PR review bot findings:
- P1: PostgreSQL REPLACE/LIKE don't work on JSONB columns — cast
schema::text and fields::text before string ops, then back to ::jsonb
- P1: If a workspace already has a custom 'plans' collection, the
rename hits UNIQUE(workspace_id, slug) — added NOT EXISTS guard
to both SQLite and PostgreSQL migrations
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
---
|
||||
name: pad
|
||||
description: "Talk to your project. Natural-language project management — create items, check status, plan work, brainstorm ideas, and more."
|
||||
argument-hint: <anything you want to say to your project>
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
---
|
||||
|
||||
# Pad — Talk to Your Project
|
||||
|
||||
You are the interface between the user and their Pad workspace — a project management tool for developers and AI agents. Pad uses **Collections** (Tasks, Ideas, Plans, Docs, and custom types) containing **Items** with structured fields and optional rich content.
|
||||
|
||||
Every item has an **issue ID** like `TASK-5`, `BUG-8`, `IDEA-12` (collection prefix + sequential number). **Always use issue IDs to reference items** — never use slugs. Issue IDs are short, stable, and human-readable.
|
||||
|
||||
The `pad` CLI must be on PATH. It auto-starts a local server and auto-detects the workspace from `.pad.toml` in the directory tree. If `pad` is not found, tell the user: "Pad CLI not found. Install it or add it to your PATH."
|
||||
|
||||
## How This Works
|
||||
|
||||
There is **one command**: `/pad <anything>`. You interpret the user's intent and use the CLI to take action. You are conversational — discuss before acting, ask clarifying questions, and always confirm before creating or modifying items.
|
||||
|
||||
## Context Loading
|
||||
|
||||
On every `/pad` invocation, start by loading workspace context:
|
||||
|
||||
```bash
|
||||
pad project dashboard --format json # Project overview: collections, plans, attention, suggestions
|
||||
pad collection list --format json # Available collections with schemas
|
||||
pad item list conventions --field status=active --field trigger=always --format json # Always-on project conventions
|
||||
```
|
||||
|
||||
This tells you: what collections exist, what items are in them, what's active, what needs attention, and what project conventions to always follow.
|
||||
|
||||
If the conventions list includes items, treat them as project rules you must follow. They are short instructions like "run make install after code changes" or "use conventional commit format."
|
||||
|
||||
## Parse $ARGUMENTS
|
||||
|
||||
### No arguments
|
||||
Show project status conversationally. Run `pad project dashboard --format json`, and present the dashboard in a friendly, readable way — highlight what's active, what needs attention, and suggest what to work on next.
|
||||
|
||||
### Natural Language Routing
|
||||
|
||||
Interpret the user's intent and route to the appropriate action. Here are common patterns:
|
||||
|
||||
**Creating items:**
|
||||
- "I have an idea for X" → Create an Idea item
|
||||
- "new task: fix the OAuth bug" → Create a Task item
|
||||
- "let's start a new plan for the API redesign" → Create a Plan item
|
||||
- "document the auth architecture" → Create a Doc item
|
||||
|
||||
**Querying:**
|
||||
- "what's on my plate?" / "what should I work on?" → `pad project next --format json`
|
||||
- "how far along are we?" / "show me status" → `pad project dashboard --format json`
|
||||
- "what server am I connected to?" / "show my Pad connection info" → `pad server info --format json`
|
||||
- "show me all tasks" / "list bugs" → `pad item list <collection> --format json`
|
||||
- "find anything about OAuth" → `pad item search "OAuth" --format json`
|
||||
|
||||
**Updating:**
|
||||
- "I finished the OAuth fix" / "mark TASK-5 as done" → `pad item update TASK-5 --status done`
|
||||
- "I'm starting on TASK-3" → `pad item update TASK-3 --status in-progress`
|
||||
- "deprioritize IDEA-7" → `pad item update IDEA-7 --priority low`
|
||||
|
||||
**Planning:**
|
||||
- "let's create a plan" → Multi-step planning workflow (see below)
|
||||
- "break plan 2 into tasks" → Decompose a plan into task items
|
||||
- "what's blocking us?" → Analyze open items and dependencies
|
||||
|
||||
**Ideation:**
|
||||
- "let's brainstorm about X" → Multi-step ideation workflow (see below)
|
||||
- "what if we added X?" → Discuss, then offer to capture as an Idea
|
||||
|
||||
**Dependencies:**
|
||||
- "what's blocking TASK-5?" / "show deps for TASK-5" → `pad item deps TASK-5 --format json`
|
||||
- "TASK-5 blocks TASK-8" → `pad item block TASK-5 TASK-8`
|
||||
- "TASK-5 depends on TASK-3" → `pad item blocked-by TASK-5 TASK-3`
|
||||
- "remove the dependency" → `pad item unblock TASK-5 TASK-8`
|
||||
|
||||
**Reports:**
|
||||
- "prep for standup" / "what did we do?" → `pad project standup --format json`
|
||||
- "generate changelog" / "what shipped?" → `pad project changelog --format json`
|
||||
- "changelog for this plan" → `pad project changelog --parent PLAN-2 --format json`
|
||||
- "changelog since Monday" → `pad project changelog --since 2026-03-24 --format json`
|
||||
|
||||
**Retrospective:**
|
||||
- "plan 2 is done, let's retro" → Review completed work, save retrospective
|
||||
|
||||
**Onboarding:**
|
||||
- "scan this codebase" / "set up my workspace" → Codebase analysis + onboarding workflow (see below)
|
||||
- "what conventions should this project follow?" → Analyze tooling, suggest conventions from the library
|
||||
|
||||
## Before Performing Work
|
||||
|
||||
When you are about to take action (implement code, complete a task, create a PR, etc.), load the relevant conventions and playbooks FIRST:
|
||||
|
||||
```bash
|
||||
# Before implementing code:
|
||||
pad item list conventions --field trigger=on-implement --field status=active --format json
|
||||
pad item list playbooks --field trigger=on-implement --field status=active --format json
|
||||
|
||||
# Before completing a task:
|
||||
pad item list conventions --field trigger=on-task-complete --field status=active --format json
|
||||
|
||||
# Before creating a PR:
|
||||
pad item list conventions --field trigger=on-pr-create --field status=active --format json
|
||||
|
||||
# Before committing:
|
||||
pad item list conventions --field trigger=on-commit --field status=active --format json
|
||||
|
||||
# Before planning:
|
||||
pad item list conventions --field trigger=on-plan --field status=active --format json
|
||||
```
|
||||
|
||||
Follow ALL returned conventions. If a playbook exists for the action, follow its steps in order. Conventions are project-specific rules the team has established — they override your defaults.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
**IMPORTANT:** All commands that take an item reference accept issue IDs (e.g. `TASK-5`, `BUG-8`). Always prefer issue IDs over slugs. When you create an item, the CLI prints its issue ID — use that for subsequent commands.
|
||||
|
||||
### Item CRUD
|
||||
```bash
|
||||
# Create items (collection accepts singular or plural: task/tasks, idea/ideas, etc.)
|
||||
# The CLI prints the new item's issue ID (e.g. "Created TASK-5: ...") — use it for subsequent commands
|
||||
pad item create <collection> "title" [--status X] [--priority X] [--assignee X] [--category X] [--content "..."] [--stdin]
|
||||
pad item create task "Fix OAuth redirect" --priority high
|
||||
pad item create idea "Real-time collaboration" --category infrastructure
|
||||
pad item create plan "API Redesign" --status active
|
||||
pad item create doc "Auth Architecture" --category architecture --stdin <<< "# Auth Architecture\n\n..."
|
||||
|
||||
# Custom fields via --field flag (works for any collection's fields)
|
||||
pad item create convention "Run tests" --field trigger=on-task-complete --field scope=all --field priority=must
|
||||
pad item create roadmap "Feature X" --field quarter=2026-Q3
|
||||
|
||||
# List items (defaults to non-done items)
|
||||
pad item list [collection] [--status X] [--priority X] [--all] [--field key=value] [--format json]
|
||||
pad item list tasks # open + in_progress tasks
|
||||
pad item list tasks --status done # completed tasks
|
||||
pad item list conventions --field trigger=always --field status=active # filtered by custom fields
|
||||
pad item list --all # everything across all collections
|
||||
|
||||
# Show item detail — use the issue ID (e.g. TASK-5, BUG-8)
|
||||
pad item show TASK-5 [--format json|markdown]
|
||||
|
||||
# Update items — use the issue ID
|
||||
pad item update TASK-5 --status done
|
||||
pad item update IDEA-3 --priority high --assignee dave
|
||||
pad item update DOC-1 --stdin < updated-doc.md
|
||||
|
||||
# Delete (archive) — use the issue ID
|
||||
pad item delete TASK-5
|
||||
|
||||
# Search
|
||||
pad item search "query" [--format json]
|
||||
```
|
||||
|
||||
### Intelligence
|
||||
```bash
|
||||
pad project dashboard [--format json] # Project dashboard
|
||||
pad project next [--format json] # Recommended next task
|
||||
pad project standup [--days N] [--format json] # Daily standup report (completed/in-progress/blockers)
|
||||
pad project changelog [--days N] [--since DATE] [--parent PLAN-2] [--format json|markdown] # Release notes
|
||||
```
|
||||
|
||||
### Server
|
||||
```bash
|
||||
pad server info [--format json] # Show local client, connection, and local server status
|
||||
pad server open # Open the Pad web UI in your browser
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
```bash
|
||||
pad item block TASK-5 TASK-8 # "TASK-5 blocks TASK-8"
|
||||
pad item blocked-by TASK-5 TASK-3 # "TASK-5 is blocked by TASK-3"
|
||||
pad item deps TASK-5 # Show all dependencies for an item
|
||||
pad item unblock TASK-5 TASK-8 # Remove a dependency
|
||||
```
|
||||
|
||||
### Collections
|
||||
```bash
|
||||
pad collection list [--format json] # List collections with counts
|
||||
pad collection create "Name" --fields "key:type[:options]; ..." [--icon "X"]
|
||||
```
|
||||
|
||||
### Webhooks
|
||||
```bash
|
||||
# Webhooks are managed via the REST API:
|
||||
# POST /api/v1/workspaces/{ws}/webhooks — create webhook
|
||||
# GET /api/v1/workspaces/{ws}/webhooks — list webhooks
|
||||
# DELETE /api/v1/workspaces/{ws}/webhooks/{id} — delete
|
||||
# Events: item.created, item.updated, item.deleted, item.moved, comment.created
|
||||
```
|
||||
|
||||
### Output Formats
|
||||
All commands support `--format json` (for parsing) or `--format table` (default, human-readable).
|
||||
|
||||
## Multi-Step Workflows
|
||||
|
||||
### Ideation: "Let's brainstorm about X"
|
||||
|
||||
1. **Load context:** Run `pad project dashboard --format json` and `pad item list --format json --limit 20`
|
||||
2. **Search for related items:** `pad item search "X" --format json`
|
||||
3. **Discuss systematically:** Ask clarifying questions, explore trade-offs, reference existing items with [[Title]] links
|
||||
4. **Offer to save:** At natural checkpoints, offer to create items:
|
||||
- "Want me to save this as an Idea?" → `pad item create idea "X" --content "..." --stdin`
|
||||
- "Should I create a Doc for this architecture decision?" → `pad item create doc "X" --category decision --stdin`
|
||||
5. **Never save without asking.** Always show what you'll create and get confirmation.
|
||||
|
||||
### Planning: "Let's create a plan"
|
||||
|
||||
1. **Load context:** `pad project dashboard --format json`, `pad item list plans --all --format json`
|
||||
2. **Understand current state:** What plans exist? What's active? What's completed?
|
||||
3. **Propose outline:** Present plan title + 1-line summary. Ask for feedback.
|
||||
4. **Create the plan:** `pad item create plan "Plan N: Title" --status draft --stdin <<< "<plan content>"`
|
||||
5. **Decompose into tasks:** For each task in the plan, create a Task item:
|
||||
```bash
|
||||
pad item create task "Task description" --parent PLAN-3 --priority medium
|
||||
```
|
||||
6. **Each task should be PR-sized** — small enough for one branch, large enough to be meaningful.
|
||||
7. **Ask before creating each item.** Don't bulk-create without approval.
|
||||
|
||||
### Decomposition: "Break plan X into tasks"
|
||||
|
||||
1. **Load the plan:** `pad item show PLAN-2 --format markdown`
|
||||
2. **Analyze the content** for actionable work items
|
||||
3. **Propose task list** with titles and priorities
|
||||
4. **Create approved tasks:** One `pad item create task` per approved item
|
||||
5. **Link tasks to plan** using `--parent PLAN-2` flag
|
||||
|
||||
### Status Check: "How are we doing?"
|
||||
|
||||
1. Run `pad project dashboard --format json`
|
||||
2. Present conversationally:
|
||||
- Collection summaries (Tasks: 5 open, 2 in progress, 12 done)
|
||||
- Active plan progress with bars
|
||||
- Attention items (stalled, overdue)
|
||||
- Suggested next actions
|
||||
3. Offer follow-up: "Want me to dig into any of these?"
|
||||
|
||||
### Daily Standup: "Prep for standup"
|
||||
|
||||
1. Run `pad item list tasks --status done --format json` (recently completed)
|
||||
2. Run `pad item list tasks --status in-progress --format json` (current work)
|
||||
3. Run `pad project dashboard --format json` for blockers/attention items
|
||||
4. Present as: Yesterday / Today / Blockers format
|
||||
|
||||
### Onboarding: "Scan this codebase" / "Set up my workspace"
|
||||
|
||||
1. **Check workspace state:** `pad project dashboard --format json` — if the workspace already has items, ask if they want to add more or start fresh sections.
|
||||
2. **Analyze the codebase:** Read key project files to understand the project:
|
||||
- `README.md` or `README` — project overview, setup instructions
|
||||
- `CLAUDE.md` — existing AI/agent instructions
|
||||
- Build config: `Makefile`, `package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `pom.xml`
|
||||
- CI config: `.github/workflows/`, `.gitlab-ci.yml`, `.circleci/`
|
||||
- Directory structure: `ls` the top-level directories to understand the layout
|
||||
3. **Detect project type and tooling:**
|
||||
- Language: Go, Node/TypeScript, Rust, Python, Java, etc.
|
||||
- Build system: make, npm, cargo, pip, maven, etc.
|
||||
- Test runner: what command runs the tests?
|
||||
- Linter/formatter: what tools enforce code style?
|
||||
4. **Suggest conventions:** Based on the detected tooling, suggest conventions from the library. Customize the content with the actual commands found in the project (e.g., "Run `make test`" not just "Run the test suite"). Present as a checklist and ask which to activate.
|
||||
5. **Draft an architecture doc:** Summarize the project structure, tech stack, key directories, and how the pieces fit together. Offer to save as a Doc item.
|
||||
6. **Propose an initial plan:** Based on recent git activity (`git log --oneline -20`) and any open TODOs, suggest a plan name and a few starter tasks. Ask before creating.
|
||||
7. **Always confirm before creating each item.** Show what will be created, get approval, then create.
|
||||
|
||||
### Retrospective: "Plan X is done, let's retro"
|
||||
|
||||
1. Load the plan: `pad item show PLAN-2 --format markdown`
|
||||
2. Load tasks: `pad item list tasks --all --format json` (filter to plan)
|
||||
3. Generate retro: What shipped, what was deferred, lessons learned
|
||||
4. Offer to save: `pad item create doc "Plan N Retrospective" --category retro --stdin`
|
||||
5. Offer to update plan status: `pad item update PLAN-2 --status completed`
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Use issue IDs, not slugs.** Every item has an ID like `TASK-5` or `BUG-8`. Use these in all commands: `pad item show TASK-5`, `pad item update BUG-8 --status done`. The CLI prints issue IDs in all output — look for them.
|
||||
2. **Discuss before acting.** Always show what you plan to create/modify and get confirmation.
|
||||
3. **Use the CLI.** Every action goes through `pad` commands — don't try to modify the database directly.
|
||||
4. **Be conversational.** You're not a command executor. You're a project partner.
|
||||
5. **Reference existing items.** Use `[[Item Title]]` links in content to connect items.
|
||||
6. **Keep it practical.** Tasks should be PR-sized. Ideas should be actionable. Docs should be concise.
|
||||
7. **Attribution matters.** Items you create will have `created_by: agent` and `source: cli` automatically.
|
||||
8. **Follow project conventions.** Always load and follow active conventions before performing work. They are project-specific rules that override your defaults.
|
||||
9. **Learn and teach.** When the user corrects your behavior or teaches you a project-specific rule, offer to save it as a convention: "Should I save this as a project convention so future agents follow it too?" Use `pad item create convention "Title" --field trigger=<inferred> --field scope=<inferred> --field priority=should --stdin` with an appropriate trigger inferred from the context.
|
||||
|
||||
## Anything Else
|
||||
|
||||
If the user's intent doesn't match any pattern above, respond helpfully. You can always:
|
||||
- Run `pad item list` or `pad item search` to find relevant items
|
||||
- Run `pad item show TASK-5` to load any item's detail (use the issue ID from list output)
|
||||
- Suggest the appropriate workflow based on what they're trying to do
|
||||
@@ -66,7 +66,7 @@ REST API at `/api/v1/`. Key endpoints:
|
||||
- `GET/POST /workspaces/{ws}/collections` — collection CRUD
|
||||
- `GET/POST /workspaces/{ws}/collections/{coll}/items` — item CRUD
|
||||
- `GET/PATCH/DELETE /workspaces/{ws}/items/{slug}` — item by slug
|
||||
- `GET /workspaces/{ws}/dashboard` — computed project overview (active items, phases, attention, blockers)
|
||||
- `GET /workspaces/{ws}/dashboard` — computed project overview (active items, plans, attention, blockers)
|
||||
- `GET /workspaces/{ws}/activity` — workspace activity feed (enriched with item titles + change details)
|
||||
- `GET/POST/DELETE /workspaces/{ws}/webhooks` — webhook management
|
||||
- `GET /workspaces/{ws}/items/{slug}/children` — child items linked to a parent
|
||||
@@ -177,9 +177,9 @@ Collection names accept singular forms: `task`→`tasks`, `idea`→`ideas`, `doc
|
||||
|
||||
- **Collections** have JSON schemas defining typed fields (select, text, date, number, etc.)
|
||||
- **Items** have structured `fields` JSON + optional rich `content` (markdown)
|
||||
- **Parent/child links:** Any item can be a parent of child items (`--parent REF`). Children get progress tracking, burndown charts, and nested rendering. Phases are the most common parent, but Ideas, Docs, or Tasks can also have children.
|
||||
- **Parent/child links:** Any item can be a parent of child items (`--parent REF`). Children get progress tracking, burndown charts, and nested rendering. Plans are the most common parent, but Ideas, Docs, or Tasks can also have children.
|
||||
- **Wiki-links** `[[Title]]` resolve across all items, rendered as clickable links
|
||||
- **Default collections:** Tasks, Ideas, Phases, Docs
|
||||
- **Default collections:** Tasks, Ideas, Plans, Docs
|
||||
- **Templates:** startup (default), scrum, product — set via `pad workspace init --template`
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- TODO: Replace with actual screenshots -->
|
||||
<!--
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard.png" width="800" alt="Pad dashboard showing collection overview, active phases, and activity feed" />
|
||||
<img src="docs/screenshots/dashboard.png" width="800" alt="Pad dashboard showing collection overview, active plans, and activity feed" />
|
||||
</p>
|
||||
-->
|
||||
|
||||
@@ -68,7 +68,7 @@ pad server info # How this client is connected to Pad
|
||||
- **Rich text editor** — Tiptap-based with markdown, formatting toolbar, and auto-save
|
||||
- **Wiki-links** — type `[[Title]]` to link between items
|
||||
- **Real-time updates** — agent creates a task in the terminal, it appears in the browser instantly (via SSE)
|
||||
- **Dashboard** — collection overview, active work, phase tracking, activity feed
|
||||
- **Dashboard** — collection overview, active work, plan tracking, activity feed
|
||||
|
||||
<!-- TODO: Screenshot of board view with drag-and-drop -->
|
||||
|
||||
@@ -121,7 +121,7 @@ Pad organizes work into **collections** — typed containers with structured fie
|
||||
|---|---|
|
||||
| **Tasks** | Work items with status, priority, assignee, effort, due date |
|
||||
| **Ideas** | Feature ideas with impact and category |
|
||||
| **Phases** | Project milestones with progress tracking |
|
||||
| **Plans** | Project milestones with progress tracking |
|
||||
| **Docs** | Documentation, decisions, reference material |
|
||||
| **Conventions** | Project rules that guide agent behavior |
|
||||
| **Playbooks** | Multi-step workflows for agents to follow |
|
||||
@@ -268,7 +268,7 @@ pad project changelog [--days N] Release notes from completed items
|
||||
pad project watch Real-time activity stream
|
||||
pad project reconcile Reconcile item and PR state
|
||||
|
||||
pad item create <coll> "title" Create item (task, idea, phase, doc, ...)
|
||||
pad item create <coll> "title" Create item (task, idea, plan, doc, ...)
|
||||
pad item list [collection] List items (filters: --status, --priority, --all)
|
||||
pad item show <ref> Show item detail
|
||||
pad item update <ref> Update item fields
|
||||
|
||||
+20
-42
@@ -1143,7 +1143,7 @@ func printOnboardingHints() {
|
||||
bold.Println("Get started:")
|
||||
fmt.Printf(" %s %s\n", cyan.Sprint("/pad"), "scan this codebase and set up my workspace")
|
||||
fmt.Printf(" %s %s\n", cyan.Sprint("/pad"), "what conventions should this project follow?")
|
||||
fmt.Printf(" %s %s\n", cyan.Sprint("/pad"), "create a phase for what I'm working on")
|
||||
fmt.Printf(" %s %s\n", cyan.Sprint("/pad"), "create a plan for what I'm working on")
|
||||
fmt.Println()
|
||||
fmt.Printf("Or open the web UI at %s\n", bold.Sprint("http://localhost:7777"))
|
||||
fmt.Println(dim.Sprint("Run 'pad project dashboard' to see your project dashboard"))
|
||||
@@ -1720,7 +1720,6 @@ func createCmd() *cobra.Command {
|
||||
status string
|
||||
assignee string
|
||||
roleFlag string
|
||||
phase string
|
||||
category string
|
||||
parentSlug string
|
||||
tags string
|
||||
@@ -1736,7 +1735,7 @@ func createCmd() *cobra.Command {
|
||||
Examples:
|
||||
pad item create task "Fix OAuth redirect" --priority high
|
||||
pad item create idea "Real-time collaboration" --category infrastructure
|
||||
pad item create phase "API Redesign" --status active
|
||||
pad item create plan "API Redesign" --status active
|
||||
pad item create doc "Payment Architecture" --category architecture --stdin
|
||||
|
||||
Run with --help-collections to see available collections and their status values.`,
|
||||
@@ -1757,11 +1756,7 @@ Run with --help-collections to see available collections and their status values
|
||||
if priority != "" {
|
||||
fields["priority"] = priority
|
||||
}
|
||||
// --parent takes precedence; --phase is a backward-compat alias
|
||||
parentRef := parentSlug
|
||||
if parentRef == "" {
|
||||
parentRef = phase
|
||||
}
|
||||
if parentRef != "" {
|
||||
parentItem, err := client.GetItem(ws, parentRef)
|
||||
if err != nil {
|
||||
@@ -1868,8 +1863,6 @@ Run with --help-collections to see available collections and their status values
|
||||
cmd.Flags().StringVar(&assignee, "assign", "", "assign to user (name or email)")
|
||||
cmd.Flags().StringVar(&roleFlag, "role", "", "assign agent role (slug)")
|
||||
cmd.Flags().StringVar(&parentSlug, "parent", "", "parent item (ref, slug, or ID)")
|
||||
cmd.Flags().StringVar(&phase, "phase", "", "parent item (deprecated alias for --parent)")
|
||||
cmd.Flags().Lookup("phase").Hidden = true
|
||||
cmd.Flags().StringVar(&category, "category", "", "category field value")
|
||||
cmd.Flags().StringVar(&tags, "tags", "", "JSON array of tags")
|
||||
cmd.Flags().StringArrayVarP(&fieldFlags, "field", "f", nil, "set arbitrary field (repeatable): --field key=value")
|
||||
@@ -2259,7 +2252,6 @@ func updateCmd() *cobra.Command {
|
||||
priority string
|
||||
assignee string
|
||||
roleFlag string
|
||||
phase string
|
||||
parentFlag string
|
||||
category string
|
||||
tags string
|
||||
@@ -2277,7 +2269,7 @@ Items can be referenced by issue ID (e.g. TASK-5) or slug.
|
||||
Examples:
|
||||
pad item update TASK-5 --status done
|
||||
pad item update TASK-5 --status done --comment "Fixed the login bug"
|
||||
pad item update PHASE-2 --status active --priority high
|
||||
pad item update PLAN-2 --status active --priority high
|
||||
pad item update DOC-3 --stdin < updated-doc.md`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -2317,11 +2309,7 @@ Examples:
|
||||
}
|
||||
|
||||
// Merge field changes with existing fields
|
||||
// --parent takes precedence; --phase is a backward-compat alias
|
||||
parentRef := parentFlag
|
||||
if parentRef == "" {
|
||||
parentRef = phase
|
||||
}
|
||||
|
||||
hasFieldChanges := status != "" || priority != "" || assignee != "" || parentRef != "" || category != "" || len(fieldFlags) > 0
|
||||
if hasFieldChanges {
|
||||
@@ -2424,8 +2412,6 @@ Examples:
|
||||
cmd.Flags().StringVar(&assignee, "assign", "", "assign to user (name or email)")
|
||||
cmd.Flags().StringVar(&roleFlag, "role", "", "assign agent role (slug)")
|
||||
cmd.Flags().StringVar(&parentFlag, "parent", "", "update parent item (ref, slug, or ID)")
|
||||
cmd.Flags().StringVar(&phase, "phase", "", "update parent item (deprecated alias for --parent)")
|
||||
cmd.Flags().Lookup("phase").Hidden = true
|
||||
cmd.Flags().StringVar(&category, "category", "", "update category field")
|
||||
cmd.Flags().StringVar(&tags, "tags", "", "update tags (JSON array)")
|
||||
cmd.Flags().StringArrayVarP(&fieldFlags, "field", "f", nil, "set arbitrary field (repeatable): --field key=value")
|
||||
@@ -2947,13 +2933,13 @@ func statusCmd() *cobra.Command {
|
||||
Status string `json:"status"`
|
||||
ItemRef string `json:"item_ref"`
|
||||
} `json:"active_items"`
|
||||
ActivePhases []struct {
|
||||
ActivePlans []struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Progress int `json:"progress"`
|
||||
TaskCount int `json:"task_count"`
|
||||
DoneCount int `json:"done_count"`
|
||||
} `json:"active_phases"`
|
||||
} `json:"active_plans"`
|
||||
ByRole []struct {
|
||||
RoleName string `json:"role_name"`
|
||||
RoleSlug string `json:"role_slug"`
|
||||
@@ -3021,17 +3007,17 @@ func statusCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// Active phases
|
||||
if len(dash.ActivePhases) > 0 {
|
||||
// Active plans
|
||||
if len(dash.ActivePlans) > 0 {
|
||||
fmt.Println()
|
||||
bold.Println("🏗️ Active Phases")
|
||||
for _, p := range dash.ActivePhases {
|
||||
bar := colorProgressBar(p.Progress, 20, green)
|
||||
bold.Println("🏗️ Active Plans")
|
||||
for _, plan := range dash.ActivePlans {
|
||||
bar := colorProgressBar(plan.Progress, 20, green)
|
||||
fmt.Printf(" %s %s %s %s\n",
|
||||
bold.Sprint(p.Title),
|
||||
bold.Sprint(plan.Title),
|
||||
bar,
|
||||
color.New(color.FgGreen).Sprintf("%d%%", p.Progress),
|
||||
dim.Sprintf("(%d/%d tasks)", p.DoneCount, p.TaskCount),
|
||||
color.New(color.FgGreen).Sprintf("%d%%", plan.Progress),
|
||||
dim.Sprintf("(%d/%d tasks)", plan.DoneCount, plan.TaskCount),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3135,7 +3121,7 @@ func nextCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
if len(dash.SuggestedNext) == 0 {
|
||||
fmt.Println("No suggestions — all tasks may be complete or no active phases found.")
|
||||
fmt.Println("No suggestions — all tasks may be complete or no active plans found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3390,7 +3376,6 @@ func standupCmd() *cobra.Command {
|
||||
func changelogCmd() *cobra.Command {
|
||||
var days int
|
||||
var since string
|
||||
var phase string
|
||||
var parentRef string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -3432,20 +3417,15 @@ func changelogCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by parent if specified (--parent or --phase)
|
||||
// Filter by parent if specified
|
||||
filterParent := parentRef
|
||||
if filterParent == "" {
|
||||
filterParent = phase
|
||||
}
|
||||
if filterParent != "" {
|
||||
var filtered []models.Item
|
||||
for _, item := range allItems {
|
||||
// Check parent link (populated by API enrichment)
|
||||
if strings.EqualFold(item.ParentLinkID, filterParent) ||
|
||||
strings.EqualFold(item.ParentRef, filterParent) ||
|
||||
strings.EqualFold(item.ParentTitle, filterParent) ||
|
||||
strings.EqualFold(item.PhaseID, filterParent) ||
|
||||
strings.EqualFold(item.PhaseRef, filterParent) {
|
||||
strings.EqualFold(item.ParentTitle, filterParent) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
@@ -3609,8 +3589,6 @@ func changelogCmd() *cobra.Command {
|
||||
cmd.Flags().IntVar(&days, "days", 7, "show items completed in last N days")
|
||||
cmd.Flags().StringVar(&since, "since", "", "only show items completed after this date (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&parentRef, "parent", "", "only show items under a specific parent (ref, slug, or title)")
|
||||
cmd.Flags().StringVar(&phase, "phase", "", "only show items under a specific parent (deprecated alias for --parent)")
|
||||
cmd.Flags().Lookup("phase").Hidden = true
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -3626,8 +3604,8 @@ func collectionDefaultIcon(slug string) string {
|
||||
return "💡"
|
||||
case "docs":
|
||||
return "📄"
|
||||
case "phases":
|
||||
return "📋"
|
||||
case "plans":
|
||||
return "🗺️"
|
||||
default:
|
||||
return "•"
|
||||
}
|
||||
@@ -3828,7 +3806,7 @@ func completeCollectionNames(cmd *cobra.Command, args []string, toComplete strin
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Static list of common collection names (singular + plural)
|
||||
names := []string{"task", "tasks", "idea", "ideas", "phase", "phases", "doc", "docs", "bug", "bugs"}
|
||||
names := []string{"task", "tasks", "idea", "ideas", "plan", "plans", "doc", "docs", "bug", "bugs"}
|
||||
// Try to fetch dynamic collections from API
|
||||
cfg, err := config.Load()
|
||||
if err == nil && cfg.IsConfigured() {
|
||||
@@ -3905,7 +3883,7 @@ func normalizeCollectionSlug(input string) string {
|
||||
aliases := map[string]string{
|
||||
"task": "tasks", "t": "tasks",
|
||||
"idea": "ideas", "i": "ideas",
|
||||
"phase": "phases", "p": "phases",
|
||||
"plan": "plans", "p": "plans", "phase": "plans", "phases": "plans",
|
||||
"doc": "docs", "d": "docs",
|
||||
"bug": "bugs",
|
||||
"convention": "conventions",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ func readyCmd() *cobra.Command {
|
||||
|
||||
This is the broader query-oriented counterpart to 'pad project next'. It reuses the
|
||||
dashboard's suggested-next logic and returns the current actionable backlog
|
||||
for active phases.`,
|
||||
for active plans.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
|
||||
@@ -180,7 +180,7 @@ func FormatForTool(tool AgentTool, embeddedContent []byte) []byte {
|
||||
body := StripFrontmatter(embeddedContent)
|
||||
fm := `---
|
||||
name: pad
|
||||
description: "Talk to your project. Natural-language project management — create items, check status, plan phases, brainstorm ideas, and more."
|
||||
description: "Talk to your project. Natural-language project management — create items, check status, create plans, brainstorm ideas, and more."
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
@@ -251,13 +251,9 @@ func PrintItemMeta(item *models.Item) {
|
||||
fmt.Printf("%s %s\n", label.Sprint("Collection:"), collLabel)
|
||||
}
|
||||
// Parent link
|
||||
if item.ParentRef != "" || item.PhaseRef != "" {
|
||||
if item.ParentRef != "" {
|
||||
ref := item.ParentRef
|
||||
title := item.ParentTitle
|
||||
if ref == "" {
|
||||
ref = item.PhaseRef
|
||||
title = item.PhaseTitle
|
||||
}
|
||||
parentStr := ref
|
||||
if title != "" {
|
||||
parentStr = ref + " " + title
|
||||
|
||||
@@ -6,7 +6,7 @@ type LibraryConvention struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"` // git, quality, pm, docs, build
|
||||
Trigger string `json:"trigger"` // always, on-task-start, on-task-complete, on-implement, on-commit, on-pr-create, on-phase-complete, on-plan
|
||||
Trigger string `json:"trigger"` // always, on-task-start, on-task-complete, on-implement, on-commit, on-pr-create, on-plan-complete, on-plan
|
||||
Surfaces []string `json:"surfaces"` // all, backend, frontend, mobile, docs, devops
|
||||
Enforcement string `json:"enforcement"` // must, should, nice-to-have
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
@@ -132,10 +132,10 @@ func ConventionLibrary() []LibraryCategory {
|
||||
Enforcement: "should",
|
||||
},
|
||||
{
|
||||
Title: "Retrospective on phase completion",
|
||||
Content: "When all tasks in a phase are done, suggest running a retrospective before marking the phase complete. Capture: what shipped, what was deferred, and lessons learned.",
|
||||
Title: "Retrospective on plan completion",
|
||||
Content: "When all tasks in a plan are done, suggest running a retrospective before marking the plan complete. Capture: what shipped, what was deferred, and lessons learned.",
|
||||
Category: "pm",
|
||||
Trigger: "on-phase-complete",
|
||||
Trigger: "on-plan-complete",
|
||||
Surfaces: []string{"all"},
|
||||
Enforcement: "nice-to-have",
|
||||
},
|
||||
|
||||
@@ -112,10 +112,10 @@ func Defaults() []DefaultCollection {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Phases",
|
||||
Slug: "phases",
|
||||
Icon: "🏗️",
|
||||
Description: "Plan and track project phases and milestones",
|
||||
Name: "Plans",
|
||||
Slug: "plans",
|
||||
Icon: "🗺️",
|
||||
Description: "Plan and track project plans and milestones",
|
||||
SortOrder: 2,
|
||||
Schema: models.CollectionSchema{
|
||||
Fields: []models.FieldDef{
|
||||
@@ -152,10 +152,10 @@ func Defaults() []DefaultCollection {
|
||||
DefaultView: "list",
|
||||
ListSortBy: "sort_order",
|
||||
QuickActions: []models.QuickAction{
|
||||
{Label: "Plan this phase", Prompt: "/pad plan {ref} \"{title}\" — outline goals, deliverables, and timeline", Scope: "item", Icon: "📐"},
|
||||
{Label: "Plan this", Prompt: "/pad plan {ref} \"{title}\" — outline goals, deliverables, and timeline", Scope: "item", Icon: "📐"},
|
||||
{Label: "Break into tasks", Prompt: "/pad break {ref} \"{title}\" into PR-sized tasks", Scope: "item", Icon: "📝"},
|
||||
{Label: "Run a retro", Prompt: "/pad run a retrospective on {ref} \"{title}\"", Scope: "item", Icon: "🔄"},
|
||||
{Label: "Compare progress", Prompt: "/pad compare progress across all phases", Scope: "collection", Icon: "📊"},
|
||||
{Label: "Compare progress", Prompt: "/pad compare progress across all plans", Scope: "collection", Icon: "📊"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -217,7 +217,7 @@ func Defaults() []DefaultCollection {
|
||||
Key: "trigger",
|
||||
Label: "When",
|
||||
Type: "select",
|
||||
Options: []string{"always", "on-task-start", "on-task-complete", "on-implement", "on-commit", "on-pr-create", "on-phase-start", "on-phase-complete", "on-plan"},
|
||||
Options: []string{"always", "on-task-start", "on-task-complete", "on-implement", "on-commit", "on-pr-create", "on-plan-start", "on-plan-complete", "on-plan"},
|
||||
},
|
||||
{
|
||||
Key: "scope",
|
||||
|
||||
@@ -61,18 +61,18 @@ func PlaybookLibrary() []PlaybookCategory {
|
||||
Description: "Planning and triage workflows",
|
||||
Playbooks: []LibraryPlaybook{
|
||||
{
|
||||
Title: "Phase Planning",
|
||||
Title: "Plan Creation",
|
||||
Category: "planning",
|
||||
Trigger: "on-plan",
|
||||
Scope: "all",
|
||||
Content: `1. Review current state — check the roadmap, active phases, and recent progress
|
||||
2. Define the goal — what does success look like for this phase?
|
||||
Content: `1. Review current state — check the roadmap, active plans, and recent progress
|
||||
2. Define the goal — what does success look like for this plan?
|
||||
3. Identify the work — list everything that needs to happen
|
||||
4. Break into tasks — each task should be independently completable (one branch, one PR)
|
||||
5. Estimate effort — flag tasks that seem too large and split them
|
||||
6. Order by dependency — what must happen before what?
|
||||
7. Set targets — define when the phase should start and end
|
||||
8. Create the items — build the phase and its tasks in the project tracker
|
||||
7. Set targets — define when the plan should start and end
|
||||
8. Create the items — build the plan and its tasks in the project tracker
|
||||
|
||||
` + "\U0001F4A1 Ask your AI agent to customize this playbook for your specific project tools and workflow.",
|
||||
},
|
||||
@@ -86,7 +86,7 @@ func PlaybookLibrary() []PlaybookCategory {
|
||||
3. Determine severity — critical (broken for everyone), high (significant impact), medium (inconvenient), low (cosmetic)
|
||||
4. Check for duplicates — search existing tasks for similar reports
|
||||
5. Capture the details — create a task with: steps to reproduce, expected vs actual behavior, severity
|
||||
6. Link related items — connect to relevant phases, architecture docs, or prior work
|
||||
6. Link related items — connect to relevant plans, architecture docs, or prior work
|
||||
7. Prioritize — decide if it needs immediate attention or can be scheduled
|
||||
|
||||
` + "\U0001F4A1 Ask your AI agent to customize this playbook for your specific project tools and workflow.",
|
||||
@@ -102,12 +102,12 @@ func PlaybookLibrary() []PlaybookCategory {
|
||||
Category: "quality",
|
||||
Trigger: "manual",
|
||||
Scope: "all",
|
||||
Content: `1. Gather the data — load the completed phase and all its tasks
|
||||
Content: `1. Gather the data — load the completed plan and all its tasks
|
||||
2. What shipped — list everything that was completed
|
||||
3. What was deferred — list anything that was planned but postponed
|
||||
4. What went well — identify practices, tools, or decisions that helped
|
||||
5. What could improve — identify friction, surprises, or mistakes
|
||||
6. Action items — concrete changes for the next phase
|
||||
6. Action items — concrete changes for the next plan
|
||||
7. Save and share — document the retrospective for future reference
|
||||
|
||||
` + "\U0001F4A1 Ask your AI agent to customize this playbook for your specific project tools and workflow.",
|
||||
@@ -119,7 +119,7 @@ func PlaybookLibrary() []PlaybookCategory {
|
||||
Scope: "all",
|
||||
Content: `1. Read the architecture — understand the tech stack, structure, and key patterns
|
||||
2. Read the roadmap — understand where the project is and where it's going
|
||||
3. Review active work — check current phases, in-progress tasks, and recent activity
|
||||
3. Review active work — check current plans, in-progress tasks, and recent activity
|
||||
4. Set up the environment — get the project building and running locally
|
||||
5. Read the conventions — understand the team's rules and expectations
|
||||
6. Pick a starter task — choose something small to build familiarity
|
||||
|
||||
@@ -77,7 +77,7 @@ func conventionsCollection(sortOrder int) DefaultCollection {
|
||||
Key: "trigger",
|
||||
Label: "When",
|
||||
Type: "select",
|
||||
Options: []string{"always", "on-task-start", "on-task-complete", "on-implement", "on-commit", "on-pr-create", "on-phase-start", "on-phase-complete", "on-plan"},
|
||||
Options: []string{"always", "on-task-start", "on-task-complete", "on-implement", "on-commit", "on-pr-create", "on-plan-start", "on-plan-complete", "on-plan"},
|
||||
},
|
||||
{
|
||||
Key: "scope",
|
||||
@@ -153,7 +153,7 @@ func playbooksCollection(sortOrder int) DefaultCollection {
|
||||
var templates = []WorkspaceTemplate{
|
||||
{
|
||||
Name: "startup",
|
||||
Description: "Tasks, Ideas, Phases, Docs, Conventions, Playbooks",
|
||||
Description: "Tasks, Ideas, Plans, Docs, Conventions, Playbooks",
|
||||
Collections: Defaults(),
|
||||
},
|
||||
{
|
||||
@@ -423,9 +423,9 @@ var templates = []WorkspaceTemplate{
|
||||
|
||||
func demoSeedItems() []SeedItem {
|
||||
return []SeedItem{
|
||||
// Phase
|
||||
// Plan
|
||||
{
|
||||
CollectionSlug: "phases",
|
||||
CollectionSlug: "plans",
|
||||
Title: "MVP Launch",
|
||||
Content: `# MVP Launch
|
||||
|
||||
@@ -529,7 +529,7 @@ Pad is a single Go binary with an embedded SvelteKit web UI and SQLite storage.
|
||||
|
||||
## Related
|
||||
|
||||
See [[MVP Launch]] for the current phase and [[Write API documentation]] for the API docs effort.
|
||||
See [[MVP Launch]] for the current plan and [[Write API documentation]] for the API docs effort.
|
||||
`,
|
||||
Fields: `{"status":"published","category":"architecture"}`,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// Valid document types
|
||||
var ValidDocTypes = []string{
|
||||
"roadmap", "phase-plan", "architecture", "ideation",
|
||||
"roadmap", "plan", "architecture", "ideation",
|
||||
"feature-spec", "notes", "prompt-library", "reference",
|
||||
}
|
||||
|
||||
|
||||
@@ -57,11 +57,6 @@ type Item struct {
|
||||
ParentRef string `json:"parent_ref,omitempty"`
|
||||
ParentTitle string `json:"parent_title,omitempty"`
|
||||
|
||||
// Deprecated aliases — kept for API backward compatibility
|
||||
PhaseID string `json:"phase_id,omitempty"`
|
||||
PhaseRef string `json:"phase_ref,omitempty"`
|
||||
PhaseTitle string `json:"phase_title,omitempty"`
|
||||
|
||||
// HasChildren is true if this item has child items linked to it.
|
||||
// Populated by enrichment, not stored in the DB.
|
||||
HasChildren bool `json:"has_children,omitempty"`
|
||||
@@ -520,7 +515,6 @@ type ItemListParams struct {
|
||||
AssignedUserID string // filter by assigned user
|
||||
AgentRoleID string // filter by agent role (ID or slug)
|
||||
ParentLinkID string // filter by parent link (item ID of the parent)
|
||||
PhaseID string // deprecated alias for ParentLinkID
|
||||
IncludeArchived bool
|
||||
Limit int
|
||||
Offset int
|
||||
|
||||
@@ -13,7 +13,6 @@ const (
|
||||
ItemLinkTypeSupersedes = "supersedes"
|
||||
ItemLinkTypeImplements = "implements"
|
||||
ItemLinkTypeParent = "parent"
|
||||
ItemLinkTypePhase = "parent" // Deprecated alias — use ItemLinkTypeParent
|
||||
)
|
||||
|
||||
var itemLinkTypeAliases = map[string]string{
|
||||
@@ -27,7 +26,6 @@ var itemLinkTypeAliases = map[string]string{
|
||||
"supersedes": ItemLinkTypeSupersedes,
|
||||
"implements": ItemLinkTypeImplements,
|
||||
"parent": ItemLinkTypeParent,
|
||||
"phase": ItemLinkTypeParent, // backward compat
|
||||
}
|
||||
|
||||
// NormalizeItemLinkType canonicalizes supported link types and returns an error
|
||||
|
||||
@@ -12,12 +12,12 @@ type ProgressSnapshot struct {
|
||||
OpenTasks int `json:"open_tasks"`
|
||||
InProgress int `json:"in_progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
PhaseData string `json:"phase_data"` // JSON array of per-phase snapshots
|
||||
PlanData string `json:"phase_data"` // JSON array of per-plan snapshots (legacy DB column name: phase_data)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PhaseSnapshot is a single entry in the PhaseData JSON array.
|
||||
type PhaseSnapshot struct {
|
||||
// PlanSnapshot is a single entry in the PlanData JSON array.
|
||||
type PlanSnapshot struct {
|
||||
Title string `json:"title"`
|
||||
Done int `json:"done"`
|
||||
Total int `json:"total"`
|
||||
|
||||
@@ -66,19 +66,19 @@ How do we know when we're done?
|
||||
`,
|
||||
},
|
||||
{
|
||||
Type: "phase-plan",
|
||||
Name: "Phase Plan",
|
||||
Description: "Scoped implementation plan for a specific phase or milestone",
|
||||
Type: "plan",
|
||||
Name: "Plan",
|
||||
Description: "Scoped implementation plan for a specific milestone",
|
||||
Icon: "\U0001F3D7\uFE0F",
|
||||
Content: `# {Title}
|
||||
|
||||
## Overview
|
||||
|
||||
What does this phase accomplish? What's the scope?
|
||||
What does this plan accomplish? What's the scope?
|
||||
|
||||
## Prerequisites
|
||||
|
||||
What needs to be done before this phase can start?
|
||||
What needs to be done before this plan can start?
|
||||
|
||||
- [ ] Prerequisite 1
|
||||
- [ ] Prerequisite 2
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
type DashboardResponse struct {
|
||||
Summary DashboardSummary `json:"summary"`
|
||||
ActiveItems []DashboardActiveItem `json:"active_items"`
|
||||
ActivePhases []DashboardPhase `json:"active_phases"`
|
||||
ActivePlans []DashboardPlan `json:"active_plans"`
|
||||
ByRole []store.RoleBreakdown `json:"by_role,omitempty"`
|
||||
Attention []DashboardAttention `json:"attention"`
|
||||
RecentActivity []DashboardActivity `json:"recent_activity"`
|
||||
@@ -52,7 +52,7 @@ type DashboardSummary struct {
|
||||
ByCollection map[string]map[string]int `json:"by_collection"`
|
||||
}
|
||||
|
||||
type DashboardPhase struct {
|
||||
type DashboardPlan struct {
|
||||
Slug string `json:"slug"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Title string `json:"title"`
|
||||
@@ -147,7 +147,7 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ByCollection: make(map[string]map[string]int),
|
||||
},
|
||||
ActiveItems: []DashboardActiveItem{},
|
||||
ActivePhases: []DashboardPhase{},
|
||||
ActivePlans: []DashboardPlan{},
|
||||
Attention: []DashboardAttention{},
|
||||
RecentActivity: []DashboardActivity{},
|
||||
SuggestedNext: []DashboardSuggestion{},
|
||||
@@ -180,8 +180,8 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if !isActiveStatus(status) {
|
||||
continue
|
||||
}
|
||||
// Skip phases (they have their own section)
|
||||
if item.CollectionSlug == "phases" {
|
||||
// Skip plans (they have their own section)
|
||||
if item.CollectionSlug == "plans" {
|
||||
continue
|
||||
}
|
||||
ai := DashboardActiveItem{
|
||||
@@ -212,28 +212,28 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
resp.ActiveItems = resp.ActiveItems[:10]
|
||||
}
|
||||
|
||||
// Active phases: items in "phases" collection where status=active
|
||||
phases, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: "phases",
|
||||
// Active plans: items in "plans" collection where status=active
|
||||
plans, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: "plans",
|
||||
Fields: map[string]string{"status": "active"},
|
||||
})
|
||||
if err == nil {
|
||||
for _, phase := range phases {
|
||||
dp := DashboardPhase{
|
||||
Slug: phase.Slug,
|
||||
Ref: phase.Ref,
|
||||
Title: phase.Title,
|
||||
for _, plan := range plans {
|
||||
dp := DashboardPlan{
|
||||
Slug: plan.Slug,
|
||||
Ref: plan.Ref,
|
||||
Title: plan.Title,
|
||||
}
|
||||
|
||||
// Compute progress from child items linked via parent link
|
||||
total, done, err := s.store.GetItemProgress(phase.ID)
|
||||
total, done, err := s.store.GetItemProgress(plan.ID)
|
||||
if err == nil && total > 0 {
|
||||
dp.TaskCount = total
|
||||
dp.DoneCount = done
|
||||
dp.Progress = (done * 100) / total
|
||||
} else {
|
||||
// Fallback: use explicit progress field on the phase
|
||||
progress := extractFieldValue(phase.Fields, "progress")
|
||||
// Fallback: use explicit progress field on the plan
|
||||
progress := extractFieldValue(plan.Fields, "progress")
|
||||
if progress != "" {
|
||||
var pval float64
|
||||
if err := json.Unmarshal([]byte(progress), &pval); err == nil {
|
||||
@@ -242,7 +242,7 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
resp.ActivePhases = append(resp.ActivePhases, dp)
|
||||
resp.ActivePlans = append(resp.ActivePlans, dp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,23 +301,23 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// (c) Phase completion: phases where ALL child items are done
|
||||
for _, dp := range resp.ActivePhases {
|
||||
// (c) Plan completion: plans where ALL child items are done
|
||||
for _, dp := range resp.ActivePlans {
|
||||
if dp.TaskCount > 0 && dp.DoneCount == dp.TaskCount {
|
||||
resp.Attention = append(resp.Attention, DashboardAttention{
|
||||
Type: "phase_completion",
|
||||
Type: "plan_completion",
|
||||
ItemSlug: dp.Slug,
|
||||
ItemTitle: dp.Title,
|
||||
Collection: "phases",
|
||||
Collection: "plans",
|
||||
Reason: "All " + strconv.Itoa(dp.TaskCount) + " tasks are done. Mark as completed?",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// (d) Orphaned tasks: tasks with no parent link set
|
||||
// Only flag these if the workspace has active phases with children linked to them.
|
||||
// Only flag these if the workspace has active plans with children linked to them.
|
||||
hasParentWithChildren := false
|
||||
for _, dp := range resp.ActivePhases {
|
||||
for _, dp := range resp.ActivePlans {
|
||||
if dp.TaskCount > 0 {
|
||||
hasParentWithChildren = true
|
||||
break
|
||||
@@ -345,7 +345,7 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ItemRef: task.Ref,
|
||||
ItemTitle: task.Title,
|
||||
Collection: "tasks",
|
||||
Reason: "Task has no phase assigned",
|
||||
Reason: "Task has no plan assigned",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -419,15 +419,15 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// --- Suggested Next ---
|
||||
// Find open child items in active phases, sorted by priority, top 3
|
||||
// Find open child items in active plans, sorted by priority, top 3
|
||||
type suggestion struct {
|
||||
item models.Item
|
||||
phase string
|
||||
plan string
|
||||
priority int
|
||||
}
|
||||
var candidates []suggestion
|
||||
|
||||
for _, dp := range resp.ActivePhases {
|
||||
for _, dp := range resp.ActivePlans {
|
||||
parentItem, err := s.store.ResolveItem(workspaceID, dp.Slug)
|
||||
if err != nil || parentItem == nil {
|
||||
continue
|
||||
@@ -442,7 +442,7 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
pri := extractFieldValue(task.Fields, "priority")
|
||||
candidates = append(candidates, suggestion{
|
||||
item: task,
|
||||
phase: dp.Title,
|
||||
plan: dp.Title,
|
||||
priority: priorityRank(pri),
|
||||
})
|
||||
}
|
||||
@@ -461,7 +461,7 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for _, c := range candidates[:limit] {
|
||||
pri := extractFieldValue(c.item.Fields, "priority")
|
||||
reason := "Open task in active phase \"" + c.phase + "\""
|
||||
reason := "Open task in active plan \"" + c.plan + "\""
|
||||
if pri != "" {
|
||||
reason += " (" + pri + " priority)"
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ func TestDashboardEmpty(t *testing.T) {
|
||||
if len(resp.ActiveItems) != 0 {
|
||||
t.Errorf("expected 0 active_items, got %d", len(resp.ActiveItems))
|
||||
}
|
||||
if len(resp.ActivePhases) != 0 {
|
||||
t.Errorf("expected 0 active_phases, got %d", len(resp.ActivePhases))
|
||||
if len(resp.ActivePlans) != 0 {
|
||||
t.Errorf("expected 0 active_plans, got %d", len(resp.ActivePlans))
|
||||
}
|
||||
if len(resp.Attention) != 0 {
|
||||
t.Errorf("expected 0 attention items, got %d", len(resp.Attention))
|
||||
@@ -93,7 +93,7 @@ func TestDashboardEmpty(t *testing.T) {
|
||||
if err := json.Unmarshal(raw.Body.Bytes(), &rawMap); err != nil {
|
||||
t.Fatalf("failed to parse raw JSON: %v", err)
|
||||
}
|
||||
for _, key := range []string{"active_items", "active_phases", "attention", "recent_activity", "suggested_next"} {
|
||||
for _, key := range []string{"active_items", "active_plans", "attention", "recent_activity", "suggested_next"} {
|
||||
val := string(rawMap[key])
|
||||
if val == "null" {
|
||||
t.Errorf("expected %s to be [], got null", key)
|
||||
@@ -285,14 +285,14 @@ func TestDashboardActiveItemsSorting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardActiveItemsExcludesPhases(t *testing.T) {
|
||||
func TestDashboardActiveItemsExcludesPlans(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create an active phase — it should NOT appear in active_items
|
||||
// (phases have their own section)
|
||||
createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
"title": "Phase 1",
|
||||
// Create an active plan — it should NOT appear in active_items
|
||||
// (plans have their own section)
|
||||
createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Plan 1",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
|
||||
@@ -305,55 +305,55 @@ func TestDashboardActiveItemsExcludesPhases(t *testing.T) {
|
||||
resp := getDashboard(t, srv, slug)
|
||||
|
||||
if len(resp.ActiveItems) != 1 {
|
||||
t.Fatalf("expected 1 active_item (no phases), got %d", len(resp.ActiveItems))
|
||||
t.Fatalf("expected 1 active_item (no plans), got %d", len(resp.ActiveItems))
|
||||
}
|
||||
if resp.ActiveItems[0].Title != "Active Task" {
|
||||
t.Errorf("expected active item to be 'Active Task', got %q", resp.ActiveItems[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardActivePhases(t *testing.T) {
|
||||
func TestDashboardActivePlans(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create a phase with status=active
|
||||
phase := createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
// Create a plan with status=active
|
||||
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Sprint 1",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
|
||||
// Create tasks linked to the phase
|
||||
// Create tasks linked to the plan
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Task A",
|
||||
"fields": `{"status":"open","priority":"high","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"high","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Task B",
|
||||
"fields": `{"status":"done","priority":"medium","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"done","priority":"medium","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Task C",
|
||||
"fields": `{"status":"done","priority":"low","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"done","priority":"low","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
|
||||
// Also a planned phase that should NOT appear in active_phases
|
||||
createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
// Also a planned plan that should NOT appear in active_plans
|
||||
createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Sprint 2",
|
||||
"fields": `{"status":"planned"}`,
|
||||
})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
|
||||
if len(resp.ActivePhases) != 1 {
|
||||
t.Fatalf("expected 1 active_phase, got %d", len(resp.ActivePhases))
|
||||
if len(resp.ActivePlans) != 1 {
|
||||
t.Fatalf("expected 1 active_plan, got %d", len(resp.ActivePlans))
|
||||
}
|
||||
|
||||
ap := resp.ActivePhases[0]
|
||||
ap := resp.ActivePlans[0]
|
||||
if ap.Title != "Sprint 1" {
|
||||
t.Errorf("expected phase title 'Sprint 1', got %q", ap.Title)
|
||||
t.Errorf("expected plan title 'Sprint 1', got %q", ap.Title)
|
||||
}
|
||||
if ap.Slug == "" {
|
||||
t.Error("expected phase slug to be set")
|
||||
t.Error("expected plan slug to be set")
|
||||
}
|
||||
if ap.TaskCount != 3 {
|
||||
t.Errorf("expected task_count=3, got %d", ap.TaskCount)
|
||||
@@ -412,9 +412,9 @@ func TestDashboardAttentionOverdueEndDate(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create a phase with a past end_date (not done)
|
||||
createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
"title": "Overdue Phase",
|
||||
// Create a plan with a past end_date (not done)
|
||||
createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Overdue Plan",
|
||||
"fields": `{"status":"active","end_date":"2020-06-15"}`,
|
||||
})
|
||||
|
||||
@@ -425,8 +425,8 @@ func TestDashboardAttentionOverdueEndDate(t *testing.T) {
|
||||
t.Fatalf("expected 1 overdue attention item for end_date, got %d: %+v", len(overdueItems), overdueItems)
|
||||
}
|
||||
|
||||
if overdueItems[0].ItemTitle != "Overdue Phase" {
|
||||
t.Errorf("expected 'Overdue Phase', got %q", overdueItems[0].ItemTitle)
|
||||
if overdueItems[0].ItemTitle != "Overdue Plan" {
|
||||
t.Errorf("expected 'Overdue Plan', got %q", overdueItems[0].ItemTitle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,34 +520,34 @@ func TestDashboardSuggestedNext(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create an active phase
|
||||
phase := createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
"title": "Active Phase",
|
||||
// Create an active plan
|
||||
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Active Plan",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
|
||||
// Create open tasks in the phase with different priorities
|
||||
// Create open tasks in the plan with different priorities
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Low Priority Task",
|
||||
"fields": `{"status":"open","priority":"low","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"low","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Critical Priority Task",
|
||||
"fields": `{"status":"open","priority":"critical","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"critical","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "High Priority Task",
|
||||
"fields": `{"status":"open","priority":"high","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"high","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Medium Priority Task",
|
||||
"fields": `{"status":"open","priority":"medium","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"medium","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
|
||||
// This one is in-progress — not "open", so NOT suggested
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Already In Progress",
|
||||
"fields": `{"status":"in-progress","priority":"critical","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"in-progress","priority":"critical","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
@@ -578,11 +578,11 @@ func TestDashboardSuggestedNext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardSuggestedNextNoPhases(t *testing.T) {
|
||||
func TestDashboardSuggestedNextNoPlans(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create tasks but no active phases
|
||||
// Create tasks but no active plans
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Orphan Task",
|
||||
"fields": `{"status":"open","priority":"high"}`,
|
||||
@@ -591,29 +591,29 @@ func TestDashboardSuggestedNextNoPhases(t *testing.T) {
|
||||
resp := getDashboard(t, srv, slug)
|
||||
|
||||
if len(resp.SuggestedNext) != 0 {
|
||||
t.Errorf("expected 0 suggested_next without active phases, got %d", len(resp.SuggestedNext))
|
||||
t.Errorf("expected 0 suggested_next without active plans, got %d", len(resp.SuggestedNext))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardSuggestedNextFromPlannedPhase(t *testing.T) {
|
||||
func TestDashboardSuggestedNextFromPlannedPlan(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Planned phase — should NOT contribute to suggested_next
|
||||
phase := createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
"title": "Planned Phase",
|
||||
// Planned plan — should NOT contribute to suggested_next
|
||||
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Planned Plan",
|
||||
"fields": `{"status":"planned"}`,
|
||||
})
|
||||
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Task in Planned Phase",
|
||||
"fields": `{"status":"open","priority":"high","phase":"` + phase.ID + `"}`,
|
||||
"title": "Task in Planned Plan",
|
||||
"fields": `{"status":"open","priority":"high","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
|
||||
if len(resp.SuggestedNext) != 0 {
|
||||
t.Errorf("expected 0 suggested_next from planned phase, got %d", len(resp.SuggestedNext))
|
||||
t.Errorf("expected 0 suggested_next from planned plan, got %d", len(resp.SuggestedNext))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,12 +657,12 @@ func TestDashboardIsDoneStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardPhaseCompletion(t *testing.T) {
|
||||
func TestDashboardPlanCompletion(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create active phase
|
||||
phase := createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
// Create active plan
|
||||
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Completed Sprint",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
@@ -670,52 +670,52 @@ func TestDashboardPhaseCompletion(t *testing.T) {
|
||||
// All tasks done
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Done Task 1",
|
||||
"fields": `{"status":"done","priority":"high","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"done","priority":"high","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Done Task 2",
|
||||
"fields": `{"status":"done","priority":"medium","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"done","priority":"medium","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
|
||||
phaseCompletions := filterAttention(resp.Attention, "phase_completion")
|
||||
if len(phaseCompletions) != 1 {
|
||||
t.Fatalf("expected 1 phase_completion attention, got %d: %+v", len(phaseCompletions), phaseCompletions)
|
||||
planCompletions := filterAttention(resp.Attention, "plan_completion")
|
||||
if len(planCompletions) != 1 {
|
||||
t.Fatalf("expected 1 plan_completion attention, got %d: %+v", len(planCompletions), planCompletions)
|
||||
}
|
||||
|
||||
if phaseCompletions[0].ItemTitle != "Completed Sprint" {
|
||||
t.Errorf("expected 'Completed Sprint', got %q", phaseCompletions[0].ItemTitle)
|
||||
if planCompletions[0].ItemTitle != "Completed Sprint" {
|
||||
t.Errorf("expected 'Completed Sprint', got %q", planCompletions[0].ItemTitle)
|
||||
}
|
||||
if phaseCompletions[0].Collection != "phases" {
|
||||
t.Errorf("expected collection 'phases', got %q", phaseCompletions[0].Collection)
|
||||
if planCompletions[0].Collection != "plans" {
|
||||
t.Errorf("expected collection 'plans', got %q", planCompletions[0].Collection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardPhaseCompletionNotTriggeredWithOpenTasks(t *testing.T) {
|
||||
func TestDashboardPlanCompletionNotTriggeredWithOpenTasks(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Active phase with one done and one open task
|
||||
phase := createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
// Active plan with one done and one open task
|
||||
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "In Progress Sprint",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Done Task",
|
||||
"fields": `{"status":"done","priority":"high","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"done","priority":"high","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Open Task",
|
||||
"fields": `{"status":"open","priority":"high","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"high","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
|
||||
phaseCompletions := filterAttention(resp.Attention, "phase_completion")
|
||||
if len(phaseCompletions) != 0 {
|
||||
t.Errorf("expected 0 phase_completion when tasks are still open, got %d", len(phaseCompletions))
|
||||
planCompletions := filterAttention(resp.Attention, "plan_completion")
|
||||
if len(planCompletions) != 0 {
|
||||
t.Errorf("expected 0 plan_completion when tasks are still open, got %d", len(planCompletions))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,67 +778,67 @@ func TestDashboardNonexistentWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardMultipleActivePhases(t *testing.T) {
|
||||
func TestDashboardMultipleActivePlans(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create two active phases
|
||||
phase1 := createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
"title": "Phase Alpha",
|
||||
// Create two active plans
|
||||
plan1 := createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Plan Alpha",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
phase2 := createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
"title": "Phase Beta",
|
||||
plan2 := createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Plan Beta",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
|
||||
// Tasks for phase 1: 1 done out of 2
|
||||
// Tasks for plan 1: 1 done out of 2
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Alpha Task 1",
|
||||
"fields": `{"status":"done","priority":"high","phase":"` + phase1.ID + `"}`,
|
||||
"fields": `{"status":"done","priority":"high","parent":"` + plan1.ID + `"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Alpha Task 2",
|
||||
"fields": `{"status":"open","priority":"medium","phase":"` + phase1.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"medium","parent":"` + plan1.ID + `"}`,
|
||||
})
|
||||
|
||||
// Tasks for phase 2: 0 done out of 1
|
||||
// Tasks for plan 2: 0 done out of 1
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Beta Task 1",
|
||||
"fields": `{"status":"open","priority":"high","phase":"` + phase2.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"high","parent":"` + plan2.ID + `"}`,
|
||||
})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
|
||||
if len(resp.ActivePhases) != 2 {
|
||||
t.Fatalf("expected 2 active phases, got %d", len(resp.ActivePhases))
|
||||
if len(resp.ActivePlans) != 2 {
|
||||
t.Fatalf("expected 2 active plans, got %d", len(resp.ActivePlans))
|
||||
}
|
||||
|
||||
phaseMap := map[string]DashboardPhase{}
|
||||
for _, p := range resp.ActivePhases {
|
||||
phaseMap[p.Title] = p
|
||||
planMap := map[string]DashboardPlan{}
|
||||
for _, p := range resp.ActivePlans {
|
||||
planMap[p.Title] = p
|
||||
}
|
||||
|
||||
alpha, ok := phaseMap["Phase Alpha"]
|
||||
alpha, ok := planMap["Plan Alpha"]
|
||||
if !ok {
|
||||
t.Fatal("expected 'Phase Alpha' in active phases")
|
||||
t.Fatal("expected 'Plan Alpha' in active plans")
|
||||
}
|
||||
if alpha.TaskCount != 2 || alpha.DoneCount != 1 {
|
||||
t.Errorf("Phase Alpha: expected task_count=2, done_count=1, got task_count=%d, done_count=%d", alpha.TaskCount, alpha.DoneCount)
|
||||
t.Errorf("Plan Alpha: expected task_count=2, done_count=1, got task_count=%d, done_count=%d", alpha.TaskCount, alpha.DoneCount)
|
||||
}
|
||||
if alpha.Progress != 50 {
|
||||
t.Errorf("Phase Alpha: expected progress=50, got %d", alpha.Progress)
|
||||
t.Errorf("Plan Alpha: expected progress=50, got %d", alpha.Progress)
|
||||
}
|
||||
|
||||
beta, ok := phaseMap["Phase Beta"]
|
||||
beta, ok := planMap["Plan Beta"]
|
||||
if !ok {
|
||||
t.Fatal("expected 'Phase Beta' in active phases")
|
||||
t.Fatal("expected 'Plan Beta' in active plans")
|
||||
}
|
||||
if beta.TaskCount != 1 || beta.DoneCount != 0 {
|
||||
t.Errorf("Phase Beta: expected task_count=1, done_count=0, got task_count=%d, done_count=%d", beta.TaskCount, beta.DoneCount)
|
||||
t.Errorf("Plan Beta: expected task_count=1, done_count=0, got task_count=%d, done_count=%d", beta.TaskCount, beta.DoneCount)
|
||||
}
|
||||
if beta.Progress != 0 {
|
||||
t.Errorf("Phase Beta: expected progress=0, got %d", beta.Progress)
|
||||
t.Errorf("Plan Beta: expected progress=0, got %d", beta.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,23 +865,23 @@ func TestDashboardOrphanedTasks(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create an active phase WITH at least one linked task (activates orphan detection)
|
||||
phase := createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
// Create an active plan WITH at least one linked task (activates orphan detection)
|
||||
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Sprint 1",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Linked Task",
|
||||
"fields": `{"status":"open","priority":"high","phase":"` + phase.ID + `"}`,
|
||||
"fields": `{"status":"open","priority":"high","parent":"` + plan.ID + `"}`,
|
||||
})
|
||||
|
||||
// Create a task WITHOUT a phase — should be flagged as orphaned
|
||||
// Create a task WITHOUT a plan — should be flagged as orphaned
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Orphan Task",
|
||||
"fields": `{"status":"open","priority":"medium"}`,
|
||||
})
|
||||
|
||||
// Create a done task without a phase — should NOT be flagged (done tasks are excluded)
|
||||
// Create a done task without a plan — should NOT be flagged (done tasks are excluded)
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Done Orphan",
|
||||
"fields": `{"status":"done","priority":"low"}`,
|
||||
@@ -898,17 +898,17 @@ func TestDashboardOrphanedTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardOrphanedTasksNotFlaggedWithoutPhaseLinks(t *testing.T) {
|
||||
func TestDashboardOrphanedTasksNotFlaggedWithoutPlanLinks(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Create an active phase but with NO linked tasks
|
||||
createItem(t, srv, slug, "phases", map[string]interface{}{
|
||||
"title": "Empty Phase",
|
||||
// Create an active plan but with NO linked tasks
|
||||
createItem(t, srv, slug, "plans", map[string]interface{}{
|
||||
"title": "Empty Plan",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
|
||||
// Task without phase — should NOT be flagged because no phase has tasks
|
||||
// Task without plan — should NOT be flagged because no plan has tasks
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Unlinked Task",
|
||||
"fields": `{"status":"open","priority":"medium"}`,
|
||||
@@ -918,7 +918,7 @@ func TestDashboardOrphanedTasksNotFlaggedWithoutPhaseLinks(t *testing.T) {
|
||||
|
||||
orphans := filterAttention(resp.Attention, "orphaned_task")
|
||||
if len(orphans) != 0 {
|
||||
t.Errorf("expected 0 orphaned tasks when no phase has tasks linked, got %d: %+v", len(orphans), orphans)
|
||||
t.Errorf("expected 0 orphaned tasks when no plan has tasks linked, got %d: %+v", len(orphans), orphans)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,10 +134,10 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Extract parent from fields — it's managed via item_links, not stored in fields JSON.
|
||||
// Accepts both "parent" and "phase" (backward compat) as the field key.
|
||||
// Accepts both "parent" and "plan" as the field key.
|
||||
// Skip this if the schema actually defines a field with that key.
|
||||
var parentValue string
|
||||
for _, key := range []string{"parent", "phase"} {
|
||||
for _, key := range []string{"parent", "plan"} {
|
||||
if schemaHasField(schema, key) {
|
||||
continue
|
||||
}
|
||||
@@ -277,11 +277,11 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Extract parent from fields — it's managed via item_links, not stored in fields JSON.
|
||||
// Accepts both "parent" and "phase" (backward compat) as the field key.
|
||||
// Accepts both "parent" and "plan" as the field key.
|
||||
// Skip this if the schema actually defines a field with that key.
|
||||
var parentValue string
|
||||
var parentProvided bool
|
||||
for _, key := range []string{"parent", "phase"} {
|
||||
for _, key := range []string{"parent", "plan"} {
|
||||
if schemaHasField(schema, key) {
|
||||
continue
|
||||
}
|
||||
@@ -622,15 +622,15 @@ func (s *Server) publishItemEventWithName(eventType, workspaceID, itemID, title,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePhasesProgress returns child item completion progress for all non-deleted phases.
|
||||
// handlePlansProgress returns child item completion progress for all non-deleted plans.
|
||||
// This is a backward-compat endpoint; the general form is per-item via /items/{slug}/children.
|
||||
func (s *Server) handlePhasesProgress(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handlePlansProgress(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID, ok := s.getWorkspaceID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
progress, err := s.store.GetAllItemProgress(workspaceID, "phases")
|
||||
progress, err := s.store.GetAllItemProgress(workspaceID, "plans")
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
@@ -707,7 +707,7 @@ func (s *Server) handleGetItemProgress(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// resolveParentFilter extracts a "parent" (or legacy "phase") key from the field
|
||||
// resolveParentFilter extracts a "parent" (or "plan") key from the field
|
||||
// filters and converts it to a ParentLinkID filter (which uses item_links instead of json_extract).
|
||||
// An optional schema can be passed; if the schema defines a field with the key,
|
||||
// that key is left as a normal field filter instead of being treated as a parent link.
|
||||
@@ -716,14 +716,14 @@ func (s *Server) resolveParentFilter(workspaceID string, params *models.ItemList
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept both "parent" and "phase" (backward compat)
|
||||
// Accept both "parent" and "plan" as parent filter keys
|
||||
// but skip if the schema defines a real field with that key
|
||||
var schema *models.CollectionSchema
|
||||
if len(schemas) > 0 {
|
||||
schema = &schemas[0]
|
||||
}
|
||||
var val string
|
||||
for _, key := range []string{"parent", "phase"} {
|
||||
for _, key := range []string{"parent", "plan"} {
|
||||
if schema != nil && schemaHasField(*schema, key) {
|
||||
continue
|
||||
}
|
||||
@@ -752,7 +752,7 @@ func (s *Server) resolveParentFilter(workspaceID string, params *models.ItemList
|
||||
|
||||
// resolveRelationFields resolves slugs, PREFIX-NUMBER refs, and other identifiers
|
||||
// in relation fields to their canonical UUIDs. This allows clients to send
|
||||
// human-readable identifiers (e.g. --field phase=workspace-onboarding) and have
|
||||
// human-readable identifiers (e.g. --field plan=workspace-onboarding) and have
|
||||
// them stored as UUIDs that the dashboard and queries expect.
|
||||
func (s *Server) resolveRelationFields(workspaceID string, fields map[string]any, schema models.CollectionSchema) error {
|
||||
for _, def := range schema.Fields {
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestCollectionCRUD(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// List collections — should have 4 defaults (tasks, ideas, phases, docs)
|
||||
// List collections — should have 4 defaults (tasks, ideas, plans, docs)
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("list collections: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
@@ -234,23 +234,23 @@ func TestListCollectionItemsResolvesRelationFieldFilterRefs(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
phaseResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/phases/items", map[string]interface{}{
|
||||
planResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/plans/items", map[string]interface{}{
|
||||
"title": "Agent Workflow Intelligence",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
if phaseResp.Code != http.StatusCreated {
|
||||
t.Fatalf("create phase: expected 201, got %d: %s", phaseResp.Code, phaseResp.Body.String())
|
||||
if planResp.Code != http.StatusCreated {
|
||||
t.Fatalf("create plan: expected 201, got %d: %s", planResp.Code, planResp.Body.String())
|
||||
}
|
||||
|
||||
var phase models.Item
|
||||
parseJSON(t, phaseResp, &phase)
|
||||
if phase.Ref == "" {
|
||||
t.Fatal("expected phase ref to be populated")
|
||||
var plan models.Item
|
||||
parseJSON(t, planResp, &plan)
|
||||
if plan.Ref == "" {
|
||||
t.Fatal("expected plan ref to be populated")
|
||||
}
|
||||
|
||||
taskResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
|
||||
"title": "Add relation filter resolution",
|
||||
"fields": `{"status":"open","phase":"` + phase.Ref + `"}`,
|
||||
"fields": `{"status":"open","parent":"` + plan.Ref + `"}`,
|
||||
})
|
||||
if taskResp.Code != http.StatusCreated {
|
||||
t.Fatalf("create task: expected 201, got %d: %s", taskResp.Code, taskResp.Body.String())
|
||||
@@ -264,15 +264,15 @@ func TestListCollectionItemsResolvesRelationFieldFilterRefs(t *testing.T) {
|
||||
t.Fatalf("create unrelated task: expected 201, got %d: %s", otherTaskResp.Code, otherTaskResp.Body.String())
|
||||
}
|
||||
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/items?phase="+phase.Ref, nil)
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/items?parent="+plan.Ref, nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("list tasks by phase ref: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
t.Fatalf("list tasks by plan ref: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var items []models.Item
|
||||
parseJSON(t, rr, &items)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 task for phase ref filter, got %d", len(items))
|
||||
t.Fatalf("expected 1 task for plan ref filter, got %d", len(items))
|
||||
}
|
||||
if items[0].Title != "Add relation filter resolution" {
|
||||
t.Fatalf("unexpected task returned: %q", items[0].Title)
|
||||
@@ -283,20 +283,20 @@ func TestListItemsResolvesRelationFieldFilterRefsAcrossCollections(t *testing.T)
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
phaseResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/phases/items", map[string]interface{}{
|
||||
planResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/plans/items", map[string]interface{}{
|
||||
"title": "Open Source Launch",
|
||||
"fields": `{"status":"active"}`,
|
||||
})
|
||||
if phaseResp.Code != http.StatusCreated {
|
||||
t.Fatalf("create phase: expected 201, got %d: %s", phaseResp.Code, phaseResp.Body.String())
|
||||
if planResp.Code != http.StatusCreated {
|
||||
t.Fatalf("create plan: expected 201, got %d: %s", planResp.Code, planResp.Body.String())
|
||||
}
|
||||
|
||||
var phase models.Item
|
||||
parseJSON(t, phaseResp, &phase)
|
||||
var plan models.Item
|
||||
parseJSON(t, planResp, &plan)
|
||||
|
||||
taskResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
|
||||
"title": "Document release filters",
|
||||
"fields": `{"status":"open","phase":"` + phase.Ref + `"}`,
|
||||
"fields": `{"status":"open","parent":"` + plan.Ref + `"}`,
|
||||
})
|
||||
if taskResp.Code != http.StatusCreated {
|
||||
t.Fatalf("create task: expected 201, got %d: %s", taskResp.Code, taskResp.Body.String())
|
||||
@@ -310,15 +310,15 @@ func TestListItemsResolvesRelationFieldFilterRefsAcrossCollections(t *testing.T)
|
||||
t.Fatalf("create doc: expected 201, got %d: %s", docResp.Code, docResp.Body.String())
|
||||
}
|
||||
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items?phase="+phase.Ref, nil)
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items?parent="+plan.Ref, nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("list items by phase ref: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
t.Fatalf("list items by plan ref: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var items []models.Item
|
||||
parseJSON(t, rr, &items)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item for cross-collection phase ref filter, got %d", len(items))
|
||||
t.Fatalf("expected 1 item for cross-collection plan ref filter, got %d", len(items))
|
||||
}
|
||||
if items[0].CollectionSlug != "tasks" {
|
||||
t.Fatalf("expected task item, got collection %q", items[0].CollectionSlug)
|
||||
@@ -911,8 +911,8 @@ func TestDashboard(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify structure has correct field types (even if empty)
|
||||
if resp.ActivePhases == nil {
|
||||
t.Error("expected active_phases to be non-nil")
|
||||
if resp.ActivePlans == nil {
|
||||
t.Error("expected active_plans to be non-nil")
|
||||
}
|
||||
if resp.Attention == nil {
|
||||
t.Error("expected attention to be non-nil")
|
||||
|
||||
@@ -46,21 +46,13 @@ func (s *Server) enrichItemsWithParent(workspaceID string, items []models.Item)
|
||||
continue
|
||||
}
|
||||
items[i].ParentLinkID = pid
|
||||
items[i].PhaseID = pid // backward compat
|
||||
if info, ok := parents[pid]; ok {
|
||||
items[i].ParentTitle = info.title
|
||||
items[i].ParentRef = info.ref
|
||||
items[i].PhaseTitle = info.title // backward compat
|
||||
items[i].PhaseRef = info.ref // backward compat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enrichItemsWithPhase is a deprecated alias for enrichItemsWithParent.
|
||||
func (s *Server) enrichItemsWithPhase(workspaceID string, items []models.Item) {
|
||||
s.enrichItemsWithParent(workspaceID, items)
|
||||
}
|
||||
|
||||
func (s *Server) enrichItemForResponse(item *models.Item) error {
|
||||
if item == nil {
|
||||
return nil
|
||||
@@ -80,10 +72,6 @@ func (s *Server) enrichItemForResponse(item *models.Item) error {
|
||||
item.ParentLinkID = parentLink.TargetID
|
||||
item.ParentRef = parentLink.TargetRef
|
||||
item.ParentTitle = parentLink.TargetTitle
|
||||
// Backward compat
|
||||
item.PhaseID = parentLink.TargetID
|
||||
item.PhaseRef = parentLink.TargetRef
|
||||
item.PhaseTitle = parentLink.TargetTitle
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -119,8 +119,11 @@ func (s *Server) SessionAuth(next http.Handler) http.Handler {
|
||||
|
||||
// Re-issue CSRF cookie if the session is valid but the cookie is missing.
|
||||
// This can happen when cookies expire at different times or are selectively cleared.
|
||||
if _, csrfErr := r.Cookie(csrfCookie); csrfErr != nil {
|
||||
setCSRFCookie(w, 7*24*60*60, s.secureCookies)
|
||||
// Skip for auth endpoints — they manage their own CSRF cookies (login sets, logout clears).
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/auth/") {
|
||||
if _, csrfErr := r.Cookie(csrfCookie); csrfErr != nil {
|
||||
setCSRFCookie(w, 7*24*60*60, s.secureCookies)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxCurrentUser, user)
|
||||
|
||||
@@ -276,8 +276,8 @@ func (s *Server) setupRouter() {
|
||||
})
|
||||
})
|
||||
|
||||
// Phases progress
|
||||
r.Get("/phases-progress", s.handlePhasesProgress)
|
||||
// Plans progress
|
||||
r.Get("/plans-progress", s.handlePlansProgress)
|
||||
|
||||
// Items (cross-collection, v2)
|
||||
r.Get("/items", s.handleListItems)
|
||||
|
||||
@@ -329,7 +329,7 @@ func (s *Store) rebuildFTSForWorkspace(wsID string) {
|
||||
}
|
||||
|
||||
// remapFieldIDs replaces old UUIDs in a JSON fields string with their new IDs.
|
||||
// This handles relation fields (e.g. phase: "uuid") without needing to parse the schema.
|
||||
// This handles relation fields (e.g. parent: "uuid") without needing to parse the schema.
|
||||
func remapFieldIDs(fieldsJSON string, itemMap, collMap map[string]string) string {
|
||||
result := fieldsJSON
|
||||
for oldID, newID := range itemMap {
|
||||
|
||||
+5
-50
@@ -415,13 +415,9 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m
|
||||
}
|
||||
|
||||
// Parent link filter via item_links
|
||||
parentFilter := params.ParentLinkID
|
||||
if parentFilter == "" {
|
||||
parentFilter = params.PhaseID // deprecated alias
|
||||
}
|
||||
if parentFilter != "" {
|
||||
if params.ParentLinkID != "" {
|
||||
query += " AND EXISTS (SELECT 1 FROM item_links il WHERE il.source_id = i.id AND il.link_type = 'parent' AND il.target_id = ?)"
|
||||
args = append(args, parentFilter)
|
||||
args = append(args, params.ParentLinkID)
|
||||
}
|
||||
|
||||
// Field filters — supports comma-separated values as OR
|
||||
@@ -1018,11 +1014,6 @@ func (s *Store) SetParentLink(workspaceID, itemID, parentID, createdBy string) (
|
||||
return nil, fmt.Errorf("parent link created but not found")
|
||||
}
|
||||
|
||||
// SetPhaseLink is a deprecated alias for SetParentLink.
|
||||
func (s *Store) SetPhaseLink(workspaceID, itemID, parentID, createdBy string) (*models.ItemLink, error) {
|
||||
return s.SetParentLink(workspaceID, itemID, parentID, createdBy)
|
||||
}
|
||||
|
||||
// checkParentCycle walks the ancestor chain from parentID and returns an error
|
||||
// if itemID is found (which would create a cycle).
|
||||
func (s *Store) checkParentCycle(itemID, parentID string) error {
|
||||
@@ -1057,11 +1048,6 @@ func (s *Store) ClearParentLink(itemID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearPhaseLink is a deprecated alias for ClearParentLink.
|
||||
func (s *Store) ClearPhaseLink(itemID string) error {
|
||||
return s.ClearParentLink(itemID)
|
||||
}
|
||||
|
||||
// GetParentForItem returns the parent link for an item, or nil if it has no parent.
|
||||
func (s *Store) GetParentForItem(itemID string) (*models.ItemLink, error) {
|
||||
sStatusExpr := s.dialect.JSONExtractText("s.fields", "status")
|
||||
@@ -1120,11 +1106,6 @@ func (s *Store) GetParentForItem(itemID string) (*models.ItemLink, error) {
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
// GetPhaseForItem is a deprecated alias for GetParentForItem.
|
||||
func (s *Store) GetPhaseForItem(itemID string) (*models.ItemLink, error) {
|
||||
return s.GetParentForItem(itemID)
|
||||
}
|
||||
|
||||
// GetParentMap returns a map of item ID -> parent item ID for all parent links
|
||||
// in a workspace. Used for efficient batch lookups (e.g., dashboard, list enrichment).
|
||||
func (s *Store) GetParentMap(workspaceID string) (map[string]string, error) {
|
||||
@@ -1148,17 +1129,11 @@ func (s *Store) GetParentMap(workspaceID string) (map[string]string, error) {
|
||||
return m, rows.Err()
|
||||
}
|
||||
|
||||
// GetTaskPhaseMap is a deprecated alias for GetParentMap.
|
||||
func (s *Store) GetTaskPhaseMap(workspaceID string) (map[string]string, error) {
|
||||
return s.GetParentMap(workspaceID)
|
||||
}
|
||||
|
||||
// --- Child Item Progress ---
|
||||
|
||||
// GetItemProgress counts total and done child items linked to a parent via item_links.
|
||||
// "Done" means any terminal status as defined by the child items' collection schemas.
|
||||
// Unlike the old GetPhaseProgress, this is not filtered to a specific collection —
|
||||
// children from any collection count toward progress.
|
||||
// Children from any collection count toward progress.
|
||||
func (s *Store) GetItemProgress(parentItemID string) (total int, done int, err error) {
|
||||
termPlaceholders, termArgs := s.getChildTerminalPlaceholders(parentItemID)
|
||||
args := append(termArgs, parentItemID)
|
||||
@@ -1176,11 +1151,6 @@ func (s *Store) GetItemProgress(parentItemID string) (total int, done int, err e
|
||||
return total, done, nil
|
||||
}
|
||||
|
||||
// GetPhaseProgress is a deprecated alias for GetItemProgress.
|
||||
func (s *Store) GetPhaseProgress(parentItemID string) (total int, done int, err error) {
|
||||
return s.GetItemProgress(parentItemID)
|
||||
}
|
||||
|
||||
// getChildTerminalPlaceholders returns SQL placeholders and args for the terminal
|
||||
// statuses of the collections that a parent item's children belong to.
|
||||
// It queries the actual child items' collection schemas rather than hardcoding 'tasks'.
|
||||
@@ -1288,12 +1258,8 @@ type ItemProgress struct {
|
||||
Done int `json:"done"`
|
||||
}
|
||||
|
||||
// PhaseProgress is a deprecated alias for ItemProgress.
|
||||
type PhaseProgress = ItemProgress
|
||||
|
||||
// GetAllItemProgress returns child item completion counts for every non-deleted
|
||||
// item in the given collection within a workspace. This generalizes
|
||||
// GetAllPhasesProgress — any collection can be queried, not just phases.
|
||||
// item in the given collection within a workspace.
|
||||
func (s *Store) GetAllItemProgress(workspaceID, collectionSlug string) ([]ItemProgress, error) {
|
||||
termPlaceholders, termArgs := s.getCollectionChildTerminalPlaceholders(workspaceID, collectionSlug)
|
||||
args := append(termArgs, workspaceID, collectionSlug)
|
||||
@@ -1331,14 +1297,8 @@ func (s *Store) GetAllItemProgress(workspaceID, collectionSlug string) ([]ItemPr
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetAllPhasesProgress is a deprecated alias that calls GetAllItemProgress for the "phases" collection.
|
||||
func (s *Store) GetAllPhasesProgress(workspaceID string) ([]ItemProgress, error) {
|
||||
return s.GetAllItemProgress(workspaceID, "phases")
|
||||
}
|
||||
|
||||
// GetChildItems returns all non-deleted child items linked to the given parent
|
||||
// via item_links. Unlike the old GetTasksForPhase, this returns children from
|
||||
// any collection, not just tasks.
|
||||
// via item_links. Returns children from any collection.
|
||||
func (s *Store) GetChildItems(parentItemID string) ([]models.Item, error) {
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags,
|
||||
@@ -1409,11 +1369,6 @@ func (s *Store) PopulateHasChildren(items []models.Item) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetTasksForPhase is a deprecated alias for GetChildItems.
|
||||
func (s *Store) GetTasksForPhase(parentItemID string) ([]models.Item, error) {
|
||||
return s.GetChildItems(parentItemID)
|
||||
}
|
||||
|
||||
// MoveItem moves an item to a different collection within the same workspace.
|
||||
// It updates the collection_id, assigns a new item_number in the target collection,
|
||||
// and updates the fields JSON.
|
||||
|
||||
@@ -194,7 +194,7 @@ func TestSeedDefaultCollections(t *testing.T) {
|
||||
t.Errorf("expected collection %q to be default", c.Slug)
|
||||
}
|
||||
}
|
||||
for _, expected := range []string{"tasks", "ideas", "phases", "docs", "conventions", "playbooks"} {
|
||||
for _, expected := range []string{"tasks", "ideas", "plans", "docs", "conventions", "playbooks"} {
|
||||
if !slugs[expected] {
|
||||
t.Errorf("expected default collection %q", expected)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Rename "Phases" collection to "Plans"
|
||||
-- Guard against slug collision: only rename if 'plans' doesn't already exist in the same workspace
|
||||
UPDATE collections
|
||||
SET name = 'Plans',
|
||||
slug = 'plans',
|
||||
icon = '🗺️',
|
||||
prefix = 'PLAN',
|
||||
description = 'Plan and track project plans and milestones'
|
||||
WHERE slug = 'phases'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM collections c2
|
||||
WHERE c2.workspace_id = collections.workspace_id AND c2.slug = 'plans'
|
||||
);
|
||||
|
||||
-- Update convention trigger options: on-phase-start → on-plan-start, on-phase-complete → on-plan-complete
|
||||
-- Update the conventions collection schema to use new trigger names
|
||||
UPDATE collections
|
||||
SET schema = REPLACE(REPLACE(schema, 'on-phase-start', 'on-plan-start'), 'on-phase-complete', 'on-plan-complete')
|
||||
WHERE slug = 'conventions';
|
||||
|
||||
-- Update the playbooks collection schema similarly
|
||||
UPDATE collections
|
||||
SET schema = REPLACE(REPLACE(schema, 'on-phase-start', 'on-plan-start'), 'on-phase-complete', 'on-plan-complete')
|
||||
WHERE slug = 'playbooks';
|
||||
|
||||
-- Update any convention items that have trigger=on-phase-start or on-phase-complete in their fields JSON
|
||||
UPDATE items
|
||||
SET fields = REPLACE(REPLACE(fields, '"on-phase-start"', '"on-plan-start"'), '"on-phase-complete"', '"on-plan-complete"')
|
||||
WHERE collection_id IN (SELECT id FROM collections WHERE slug = 'conventions' OR slug = 'playbooks')
|
||||
AND (fields LIKE '%on-phase-start%' OR fields LIKE '%on-phase-complete%');
|
||||
|
||||
-- Note: document type 'phase-plan' → 'plan' is handled in 025_doc_type_plan.sql
|
||||
-- which recreates the table with the new CHECK constraint first.
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Rename doc_type 'phase-plan' to 'plan' in the CHECK constraint.
|
||||
-- SQLite does not support ALTER CHECK, so we recreate the table.
|
||||
|
||||
CREATE TABLE documents_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
doc_type TEXT NOT NULL DEFAULT 'notes'
|
||||
CHECK (doc_type IN ('roadmap','plan','architecture','ideation',
|
||||
'feature-spec','notes','prompt-library','reference')),
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft','active','completed','archived')),
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (created_by IN ('user','agent')),
|
||||
last_modified_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (last_modified_by IN ('user','agent')),
|
||||
source TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (source IN ('cli','web','skill')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
|
||||
UNIQUE(workspace_id, slug),
|
||||
UNIQUE(workspace_id, title)
|
||||
);
|
||||
|
||||
-- Copy data, renaming 'phase-plan' to 'plan' during the insert
|
||||
INSERT INTO documents_new
|
||||
SELECT id, workspace_id, title, slug, content,
|
||||
CASE WHEN doc_type = 'phase-plan' THEN 'plan' ELSE doc_type END,
|
||||
status, tags, pinned, sort_order, created_by, last_modified_by,
|
||||
source, created_at, updated_at, deleted_at
|
||||
FROM documents;
|
||||
|
||||
DROP TABLE documents;
|
||||
|
||||
ALTER TABLE documents_new RENAME TO documents;
|
||||
|
||||
-- Recreate indexes (originally from 001_initial.sql)
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_workspace ON documents(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(workspace_id, doc_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(workspace_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_updated ON documents(workspace_id, updated_at);
|
||||
|
||||
-- Recreate FTS triggers (originally from 001_initial.sql)
|
||||
CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(rowid, title, content, tags)
|
||||
VALUES (new.rowid, new.title, new.content, new.tags);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
|
||||
INSERT INTO documents_fts(documents_fts, rowid, title, content, tags)
|
||||
VALUES ('delete', old.rowid, old.title, old.content, old.tags);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN
|
||||
INSERT INTO documents_fts(documents_fts, rowid, title, content, tags)
|
||||
VALUES ('delete', old.rowid, old.title, old.content, old.tags);
|
||||
INSERT INTO documents_fts(rowid, title, content, tags)
|
||||
VALUES (new.rowid, new.title, new.content, new.tags);
|
||||
END;
|
||||
|
||||
-- Rebuild FTS index to match potentially renamed doc_type values
|
||||
INSERT INTO documents_fts(documents_fts) VALUES ('rebuild');
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Rename doc_type 'phase-plan' to 'plan' in the CHECK constraint.
|
||||
-- PostgreSQL supports dropping and re-adding constraints.
|
||||
|
||||
-- Update existing rows first
|
||||
UPDATE documents SET doc_type = 'plan' WHERE doc_type = 'phase-plan';
|
||||
|
||||
-- Drop the old constraint and add the new one
|
||||
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_doc_type_check;
|
||||
ALTER TABLE documents ADD CONSTRAINT documents_doc_type_check
|
||||
CHECK (doc_type IN ('roadmap','plan','architecture','ideation',
|
||||
'feature-spec','notes','prompt-library','reference'));
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Rename "Phases" collection to "Plans"
|
||||
-- Guard against slug collision: only rename if 'plans' doesn't already exist in the same workspace
|
||||
UPDATE collections c
|
||||
SET name = 'Plans',
|
||||
slug = 'plans',
|
||||
icon = '🗺️',
|
||||
prefix = 'PLAN',
|
||||
description = 'Plan and track project plans and milestones'
|
||||
WHERE c.slug = 'phases'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM collections c2
|
||||
WHERE c2.workspace_id = c.workspace_id AND c2.slug = 'plans'
|
||||
);
|
||||
|
||||
-- Update convention trigger options: on-phase-start → on-plan-start, on-phase-complete → on-plan-complete
|
||||
-- Cast JSONB to text for REPLACE, then back to JSONB
|
||||
UPDATE collections
|
||||
SET schema = REPLACE(REPLACE(schema::text, 'on-phase-start', 'on-plan-start'), 'on-phase-complete', 'on-plan-complete')::jsonb
|
||||
WHERE slug = 'conventions';
|
||||
|
||||
UPDATE collections
|
||||
SET schema = REPLACE(REPLACE(schema::text, 'on-phase-start', 'on-plan-start'), 'on-phase-complete', 'on-plan-complete')::jsonb
|
||||
WHERE slug = 'playbooks';
|
||||
|
||||
-- Update any convention/playbook items with old trigger names in fields JSON
|
||||
-- Cast JSONB to text for REPLACE/LIKE, then back to JSONB
|
||||
UPDATE items
|
||||
SET fields = REPLACE(REPLACE(fields::text, '"on-phase-start"', '"on-plan-start"'), '"on-phase-complete"', '"on-plan-complete"')::jsonb
|
||||
WHERE collection_id IN (SELECT id FROM collections WHERE slug = 'conventions' OR slug = 'playbooks')
|
||||
AND (fields::text LIKE '%on-phase-start%' OR fields::text LIKE '%on-phase-complete%');
|
||||
@@ -12,7 +12,7 @@ func (s *Store) CreateSnapshot(snap models.ProgressSnapshot) error {
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO progress_snapshots (id, workspace_id, total_tasks, done_tasks, open_tasks, in_progress, percentage, phase_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
newID(), snap.WorkspaceID, snap.TotalTasks, snap.DoneTasks, snap.OpenTasks, snap.InProgress, snap.Percentage, snap.PhaseData, now(),
|
||||
newID(), snap.WorkspaceID, snap.TotalTasks, snap.DoneTasks, snap.OpenTasks, snap.InProgress, snap.Percentage, snap.PlanData, now(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert snapshot: %w", err)
|
||||
@@ -54,7 +54,7 @@ func (s *Store) ListSnapshots(workspaceID string, params models.SnapshotListPara
|
||||
for rows.Next() {
|
||||
var snap models.ProgressSnapshot
|
||||
var createdAt string
|
||||
if err := rows.Scan(&snap.ID, &snap.WorkspaceID, &snap.TotalTasks, &snap.DoneTasks, &snap.OpenTasks, &snap.InProgress, &snap.Percentage, &snap.PhaseData, &createdAt); err != nil {
|
||||
if err := rows.Scan(&snap.ID, &snap.WorkspaceID, &snap.TotalTasks, &snap.DoneTasks, &snap.OpenTasks, &snap.InProgress, &snap.Percentage, &snap.PlanData, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("scan snapshot: %w", err)
|
||||
}
|
||||
snap.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
@@ -74,7 +74,7 @@ func (s *Store) LatestSnapshot(workspaceID string) (*models.ProgressSnapshot, er
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`),
|
||||
workspaceID,
|
||||
).Scan(&snap.ID, &snap.WorkspaceID, &snap.TotalTasks, &snap.DoneTasks, &snap.OpenTasks, &snap.InProgress, &snap.Percentage, &snap.PhaseData, &createdAt)
|
||||
).Scan(&snap.ID, &snap.WorkspaceID, &snap.TotalTasks, &snap.DoneTasks, &snap.OpenTasks, &snap.InProgress, &snap.Percentage, &snap.PlanData, &createdAt)
|
||||
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
|
||||
@@ -140,6 +140,8 @@ func (s *Store) migrate() error {
|
||||
"021_phase_to_links.sql",
|
||||
"022_audit_trail.sql",
|
||||
"023_parent_link_type.sql",
|
||||
"024_phases_to_plans.sql",
|
||||
"025_doc_type_plan.sql",
|
||||
}
|
||||
|
||||
for _, name := range migrations {
|
||||
@@ -187,6 +189,8 @@ func (s *Store) migratePostgres() error {
|
||||
"001_initial.sql",
|
||||
"002_audit_trail.sql",
|
||||
"003_parent_link_type.sql",
|
||||
"004_doc_type_plan.sql",
|
||||
"005_phases_to_plans.sql",
|
||||
}
|
||||
|
||||
for _, name := range migrations {
|
||||
|
||||
@@ -291,7 +291,7 @@ func TestDocumentListFilters(t *testing.T) {
|
||||
|
||||
// Create docs of different types and statuses
|
||||
s.CreateDocument(ws.ID, models.DocumentCreate{Title: "Roadmap", DocType: "roadmap", Status: "active"})
|
||||
s.CreateDocument(ws.ID, models.DocumentCreate{Title: "Plan", DocType: "phase-plan", Status: "active"})
|
||||
s.CreateDocument(ws.ID, models.DocumentCreate{Title: "Plan", DocType: "plan", Status: "active"})
|
||||
s.CreateDocument(ws.ID, models.DocumentCreate{Title: "Notes", DocType: "notes", Status: "draft"})
|
||||
|
||||
// Filter by type
|
||||
|
||||
+27
-27
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: pad
|
||||
description: "Talk to your project. Natural-language project management — create items, check status, plan phases, brainstorm ideas, and more."
|
||||
description: "Talk to your project. Natural-language project management — create items, check status, plan work, brainstorm ideas, and more."
|
||||
argument-hint: <anything you want to say to your project>
|
||||
allowed-tools:
|
||||
- Bash
|
||||
@@ -9,7 +9,7 @@ allowed-tools:
|
||||
|
||||
# Pad — Talk to Your Project
|
||||
|
||||
You are the interface between the user and their Pad workspace — a project management tool for developers and AI agents. Pad uses **Collections** (Tasks, Ideas, Phases, Docs, and custom types) containing **Items** with structured fields and optional rich content.
|
||||
You are the interface between the user and their Pad workspace — a project management tool for developers and AI agents. Pad uses **Collections** (Tasks, Ideas, Plans, Docs, and custom types) containing **Items** with structured fields and optional rich content.
|
||||
|
||||
Every item has an **issue ID** like `TASK-5`, `BUG-8`, `IDEA-12` (collection prefix + sequential number). **Always use issue IDs to reference items** — never use slugs. Issue IDs are short, stable, and human-readable.
|
||||
|
||||
@@ -24,7 +24,7 @@ There is **one command**: `/pad <anything>`. You interpret the user's intent and
|
||||
On every `/pad` invocation, start by loading workspace context:
|
||||
|
||||
```bash
|
||||
pad project dashboard --format json # Project overview: collections, phases, attention, suggestions
|
||||
pad project dashboard --format json # Project overview: collections, plans, attention, suggestions
|
||||
pad collection list --format json # Available collections with schemas
|
||||
pad item list conventions --field status=active --field trigger=always --format json # Always-on project conventions
|
||||
pad role list --format json # Agent roles configured in workspace
|
||||
@@ -113,7 +113,7 @@ Interpret the user's intent and route to the appropriate action. Here are common
|
||||
**Creating items:**
|
||||
- "I have an idea for X" → Create an Idea item
|
||||
- "new task: fix the OAuth bug" → Create a Task item
|
||||
- "let's start a new phase for the API redesign" → Create a Phase item
|
||||
- "let's start a new plan for the API redesign" → Create a Plan item
|
||||
- "document the auth architecture" → Create a Doc item
|
||||
|
||||
**Querying:**
|
||||
@@ -131,8 +131,8 @@ Interpret the user's intent and route to the appropriate action. Here are common
|
||||
**Best practice:** Always use `--comment` when changing status to explain *why*. This creates an audit trail linking each status change to a reason.
|
||||
|
||||
**Planning:**
|
||||
- "let's plan the next phase" → Multi-step planning workflow (see below)
|
||||
- "break phase 2 into tasks" → Decompose a phase into task items
|
||||
- "let's create a plan" → Multi-step planning workflow (see below)
|
||||
- "break plan 2 into tasks" → Decompose a plan into task items
|
||||
- "what's blocking us?" → Analyze open items and dependencies
|
||||
|
||||
**Ideation:**
|
||||
@@ -148,11 +148,11 @@ Interpret the user's intent and route to the appropriate action. Here are common
|
||||
**Reports:**
|
||||
- "prep for standup" / "what did we do?" → `pad project standup --format json`
|
||||
- "generate changelog" / "what shipped?" → `pad project changelog --format json`
|
||||
- "changelog for this phase" → `pad project changelog --parent PHASE-2 --format json`
|
||||
- "changelog for this plan" → `pad project changelog --parent PLAN-2 --format json`
|
||||
- "changelog since Monday" → `pad project changelog --since 2026-03-24 --format json`
|
||||
|
||||
**Retrospective:**
|
||||
- "phase 2 is done, let's retro" → Review completed work, save retrospective
|
||||
- "plan 2 is done, let's retro" → Review completed work, save retrospective
|
||||
|
||||
**Onboarding:**
|
||||
- "scan this codebase" / "set up my workspace" → Codebase analysis + onboarding workflow (see below)
|
||||
@@ -204,9 +204,9 @@ pad role delete <slug> # Delete a r
|
||||
# Create items (collection accepts singular or plural: task/tasks, idea/ideas, etc.)
|
||||
# The CLI prints the new item's issue ID (e.g. "Created TASK-5: ...") — use it for subsequent commands
|
||||
pad item create <collection> "title" [--status X] [--priority X] [--parent REF] [--role X] [--assign X] [--category X] [--content "..."] [--stdin]
|
||||
pad item create task "Fix OAuth redirect" --priority high --parent PHASE-3 --role implementer --assign Dave
|
||||
pad item create task "Fix OAuth redirect" --priority high --parent PLAN-3 --role implementer --assign Dave
|
||||
pad item create idea "Real-time collaboration" --category infrastructure
|
||||
pad item create phase "API Redesign" --status active
|
||||
pad item create plan "API Redesign" --status active
|
||||
pad item create doc "Auth Architecture" --category architecture --stdin <<< "# Auth Architecture\n\n..."
|
||||
|
||||
# Custom fields via --field flag (works for any collection's fields)
|
||||
@@ -248,7 +248,7 @@ pad item search "query" [--format json]
|
||||
pad project dashboard [--format json] # Project dashboard
|
||||
pad project next [--format json] # Recommended next task
|
||||
pad project standup [--days N] [--format json] # Daily standup report (completed/in-progress/blockers)
|
||||
pad project changelog [--days N] [--since DATE] [--parent PHASE-2] [--format json|markdown] # Release notes
|
||||
pad project changelog [--days N] [--since DATE] [--parent PLAN-2] [--format json|markdown] # Release notes
|
||||
```
|
||||
|
||||
### Server
|
||||
@@ -295,27 +295,27 @@ All commands support `--format json` (for parsing) or `--format table` (default,
|
||||
- "Should I create a Doc for this architecture decision?" → `pad item create doc "X" --category decision --stdin`
|
||||
5. **Never save without asking.** Always show what you'll create and get confirmation.
|
||||
|
||||
### Planning: "Let's plan the next phase"
|
||||
### Planning: "Let's create a plan"
|
||||
|
||||
1. **Load context:** `pad project dashboard --format json`, `pad item list phases --all --format json`
|
||||
2. **Understand current state:** What phases exist? What's active? What's completed?
|
||||
3. **Propose outline:** Present phase title + 1-line summary. Ask for feedback.
|
||||
4. **Create the phase:** `pad item create phase "Phase N: Title" --status draft --stdin <<< "<plan content>"`
|
||||
1. **Load context:** `pad project dashboard --format json`, `pad item list plans --all --format json`
|
||||
2. **Understand current state:** What plans exist? What's active? What's completed?
|
||||
3. **Propose outline:** Present plan title + 1-line summary. Ask for feedback.
|
||||
4. **Create the plan:** `pad item create plan "Plan N: Title" --status draft --stdin <<< "<plan content>"`
|
||||
5. **Decompose into tasks:** For each task in the plan, create a Task item:
|
||||
```bash
|
||||
pad item create task "Task description" --parent PHASE-3 --priority medium
|
||||
pad item create task "Task description" --parent PLAN-3 --priority medium
|
||||
```
|
||||
6. **If roles exist, suggest role assignments** for each task: "This looks like Implementer work — assign to Implementer?"
|
||||
7. **Each task should be PR-sized** — small enough for one branch, large enough to be meaningful.
|
||||
8. **Ask before creating each item.** Don't bulk-create without approval.
|
||||
|
||||
### Decomposition: "Break phase X into tasks"
|
||||
### Decomposition: "Break plan X into tasks"
|
||||
|
||||
1. **Load the phase:** `pad item show PHASE-2 --format markdown`
|
||||
1. **Load the plan:** `pad item show PLAN-2 --format markdown`
|
||||
2. **Analyze the content** for actionable work items
|
||||
3. **Propose task list** with titles, priorities, and suggested role assignments
|
||||
4. **Create approved tasks:** One `pad item create task` per approved item
|
||||
5. **Link tasks to phase** using `--parent PHASE-2` flag
|
||||
5. **Link tasks to plan** using `--parent PLAN-2` flag
|
||||
|
||||
### Status Check: "How are we doing?"
|
||||
|
||||
@@ -324,7 +324,7 @@ All commands support `--format json` (for parsing) or `--format table` (default,
|
||||
3. Present conversationally:
|
||||
- If role active: role queue first ("Your Implementer queue: 3 items")
|
||||
- Collection summaries (Tasks: 5 open, 2 in progress, 12 done)
|
||||
- Active phase progress with bars
|
||||
- Active plan progress with bars
|
||||
- Attention items (stalled, overdue)
|
||||
- Suggested next actions
|
||||
4. Offer follow-up: "Want me to dig into any of these?"
|
||||
@@ -352,17 +352,17 @@ All commands support `--format json` (for parsing) or `--format table` (default,
|
||||
- Linter/formatter: what tools enforce code style?
|
||||
4. **Suggest conventions:** Based on the detected tooling, suggest conventions from the library. Customize the content with the actual commands found in the project (e.g., "Run `make test`" not just "Run the test suite"). Present as a checklist and ask which to activate.
|
||||
5. **Draft an architecture doc:** Summarize the project structure, tech stack, key directories, and how the pieces fit together. Offer to save as a Doc item.
|
||||
6. **Propose an initial phase:** Based on recent git activity (`git log --oneline -20`) and any open TODOs, suggest a phase name and a few starter tasks. Ask before creating.
|
||||
6. **Propose an initial plan:** Based on recent git activity (`git log --oneline -20`) and any open TODOs, suggest a plan name and a few starter tasks. Ask before creating.
|
||||
7. **Suggest agent roles:** If no roles exist yet, suggest creating roles based on the project type. For a typical dev project: Planner, Implementer, Reviewer. Don't auto-create — ask first.
|
||||
8. **Always confirm before creating each item.** Show what will be created, get approval, then create.
|
||||
|
||||
### Retrospective: "Phase X is done, let's retro"
|
||||
### Retrospective: "Plan X is done, let's retro"
|
||||
|
||||
1. Load the phase: `pad item show PHASE-2 --format markdown`
|
||||
2. Load tasks: `pad item list tasks --all --format json` (filter to phase)
|
||||
1. Load the plan: `pad item show PLAN-2 --format markdown`
|
||||
2. Load tasks: `pad item list tasks --all --format json` (filter to plan)
|
||||
3. Generate retro: What shipped, what was deferred, lessons learned
|
||||
4. Offer to save: `pad item create doc "Phase N Retrospective" --category retro --stdin`
|
||||
5. Offer to update phase status: `pad item update PHASE-2 --status completed`
|
||||
4. Offer to save: `pad item create doc "Plan N Retrospective" --category retro --stdin`
|
||||
5. Offer to update plan status: `pad item update PLAN-2 --status completed`
|
||||
|
||||
## Key Principles
|
||||
|
||||
|
||||
@@ -278,8 +278,8 @@ export const api = {
|
||||
request<Item[]>(`/workspaces/${ws}/items/${slug}/children`),
|
||||
|
||||
/** @deprecated Use progress() per-item instead */
|
||||
phasesProgress: (ws: string) =>
|
||||
request<{item_id: string; total: number; done: number}[]>(`/workspaces/${ws}/phases-progress`)
|
||||
plansProgress: (ws: string) =>
|
||||
request<{item_id: string; total: number; done: number}[]>(`/workspaces/${ws}/plans-progress`)
|
||||
},
|
||||
|
||||
// ── Versions ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
hint: '/pad what conventions should this project follow?'
|
||||
},
|
||||
{
|
||||
title: 'Create your first phase',
|
||||
href: `/${wsSlug}/new?collection=phases`,
|
||||
done: collectionHasItems('phases'),
|
||||
hint: '/pad create a phase for what I\'m working on'
|
||||
title: 'Create your first plan',
|
||||
href: `/${wsSlug}/new?collection=plans`,
|
||||
done: collectionHasItems('plans'),
|
||||
hint: '/pad create a plan for what I\'m working on'
|
||||
},
|
||||
{
|
||||
title: 'Add a few tasks',
|
||||
|
||||
@@ -30,16 +30,16 @@
|
||||
onFilterChange(next);
|
||||
}
|
||||
|
||||
let hasPhaseFilter = $derived(Object.keys(relationLabels).length > 0);
|
||||
let activePhase = $derived(activeFilters.phase ?? '');
|
||||
let hasParentFilter = $derived(Object.keys(relationLabels).length > 0);
|
||||
let activeParent = $derived(activeFilters.parent ?? '');
|
||||
|
||||
function setPhaseFilter(e: Event) {
|
||||
function setParentFilter(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
const next = { ...activeFilters };
|
||||
if (value === '') {
|
||||
delete next.phase;
|
||||
delete next.parent;
|
||||
} else {
|
||||
next.phase = value;
|
||||
next.parent = value;
|
||||
}
|
||||
onFilterChange(next);
|
||||
}
|
||||
@@ -72,9 +72,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasPhaseFilter}
|
||||
<select class="phase-filter" value={activePhase} onchange={setPhaseFilter}>
|
||||
<option value="">All phases</option>
|
||||
{#if hasParentFilter}
|
||||
<select class="parent-filter" value={activeParent} onchange={setParentFilter}>
|
||||
<option value="">All plans</option>
|
||||
{#each Object.entries(relationLabels) as [id, label] (id)}
|
||||
<option value={id}>{label}</option>
|
||||
{/each}
|
||||
@@ -132,7 +132,7 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.phase-filter {
|
||||
.parent-filter {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
@@ -142,7 +142,7 @@
|
||||
cursor: pointer;
|
||||
max-width: 180px;
|
||||
}
|
||||
.phase-filter:focus {
|
||||
.parent-filter:focus {
|
||||
border-color: var(--accent-blue);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -109,9 +109,9 @@
|
||||
{formatLabel(fields.priority)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.phase_title}
|
||||
{#if item.parent_title}
|
||||
<span class="meta-sep">·</span>
|
||||
<span class="meta-phase">{item.phase_ref ? `${item.phase_ref}: ${item.phase_title}` : item.phase_title}</span>
|
||||
<span class="meta-parent">{item.parent_ref ? `${item.parent_ref}: ${item.parent_title}` : item.parent_title}</span>
|
||||
{/if}
|
||||
{#if item.agent_role_name}
|
||||
<span class="meta-sep">·</span>
|
||||
@@ -258,7 +258,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-phase {
|
||||
.meta-parent {
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
color: var(--accent-purple, var(--text-secondary));
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const messages: Record<string, string> = {
|
||||
tasks: 'No tasks yet. Create your first task to start tracking work.',
|
||||
ideas: 'No ideas captured yet. Jot down your first idea.',
|
||||
phases: 'No phases defined. Create a phase to plan your milestones.',
|
||||
plans: 'No plans defined. Create a plan to organize your milestones.',
|
||||
docs: 'No documents yet. Start writing to build your knowledge base.',
|
||||
bugs: "No bugs reported. That's either great news or you haven't looked yet.",
|
||||
conventions: 'No conventions set. Define rules for how agents should work.'
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
fields: Object.entries(fields)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(', '),
|
||||
phase: item ? String(fields['phase'] ?? '') : ''
|
||||
plan: item ? String(fields['plan'] ?? '') : '',
|
||||
phase: item ? String(fields['phase'] ?? fields['plan'] ?? '') : ''
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
|
||||
@@ -321,12 +321,6 @@ export interface Item {
|
||||
parent_link_id?: string;
|
||||
parent_ref?: string;
|
||||
parent_title?: string;
|
||||
/** @deprecated Use parent_id */
|
||||
phase_id?: string;
|
||||
/** @deprecated Use parent_ref */
|
||||
phase_ref?: string;
|
||||
/** @deprecated Use parent_title */
|
||||
phase_title?: string;
|
||||
has_children?: boolean;
|
||||
derived_closure?: ItemDerivedClosure;
|
||||
code_context?: ItemCodeContext;
|
||||
@@ -520,7 +514,7 @@ export interface DashboardResponse {
|
||||
by_collection: Record<string, Record<string, number>>;
|
||||
};
|
||||
active_items: DashboardActiveItem[];
|
||||
active_phases: {
|
||||
active_plans: {
|
||||
slug: string;
|
||||
title: string;
|
||||
progress: number;
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
function attentionIcon(type: string): string {
|
||||
if (type === 'overdue') return '\u23f0';
|
||||
if (type === 'stalled') return '\u26a0';
|
||||
if (type === 'phase_complete' || type === 'phase_completion') return '\ud83c\udf89';
|
||||
if (type === 'plan_complete' || type === 'plan_completion' || type === 'phase_complete' || type === 'phase_completion') return '\ud83c\udf89';
|
||||
if (type === 'orphaned_task' || type === 'orphaned') return '\ud83d\udd17';
|
||||
return '?';
|
||||
}
|
||||
@@ -253,20 +253,20 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 4. Active Phases -->
|
||||
{#if dashboard.active_phases.length > 0}
|
||||
<!-- 4. Active Plans -->
|
||||
{#if dashboard.active_plans.length > 0}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-label">Active Phases</span>
|
||||
<span class="section-label">Active Plans</span>
|
||||
</div>
|
||||
<div class="phase-list">
|
||||
{#each dashboard.active_phases as phase (phase.slug)}
|
||||
<a href="/{wsSlug}/phases/{phase.slug}" class="phase-row">
|
||||
<span class="phase-title">{phase.title}</span>
|
||||
<div class="plan-list">
|
||||
{#each dashboard.active_plans as plan (plan.slug)}
|
||||
<a href="/{wsSlug}/plans/{plan.slug}" class="plan-row">
|
||||
<span class="plan-title">{plan.title}</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {phase.progress}%"></div>
|
||||
<div class="progress-fill" style="width: {plan.progress}%"></div>
|
||||
</div>
|
||||
<span class="phase-meta">{phase.done_count}/{phase.task_count} · {phase.progress}%</span>
|
||||
<span class="plan-meta">{plan.done_count}/{plan.task_count} · {plan.progress}%</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -599,13 +599,13 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Active Phases ──────────────────────────────────────────────────── */
|
||||
.phase-list {
|
||||
/* ── Active Plans ──────────────────────────────────────────────────── */
|
||||
.plan-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.phase-row {
|
||||
.plan-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
@@ -617,11 +617,11 @@
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.phase-row:hover {
|
||||
.plan-row:hover {
|
||||
border-color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
.phase-title {
|
||||
.plan-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-primary);
|
||||
@@ -641,7 +641,7 @@
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.phase-meta {
|
||||
.plan-meta {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -141,8 +141,8 @@
|
||||
items = freshItems;
|
||||
|
||||
// Update progress data without resetting view state
|
||||
if (collSlug === 'phases') {
|
||||
const progress = await api.items.phasesProgress(wsSlug).catch(() => []);
|
||||
if (collSlug === 'plans') {
|
||||
const progress = await api.items.plansProgress(wsSlug).catch(() => []);
|
||||
const map: Record<string, { total: number; done: number }> = {};
|
||||
for (const p of progress) {
|
||||
map[p.item_id] = { total: p.total, done: p.done };
|
||||
@@ -184,10 +184,10 @@
|
||||
savedViews = viewsData;
|
||||
activeViewId = null;
|
||||
|
||||
// Fetch phase progress if viewing phases collection
|
||||
if (coll === 'phases') {
|
||||
// Fetch plan progress if viewing plans collection
|
||||
if (coll === 'plans') {
|
||||
try {
|
||||
const progress = await api.items.phasesProgress(ws);
|
||||
const progress = await api.items.plansProgress(ws);
|
||||
const map: Record<string, { total: number; done: number }> = {};
|
||||
for (const p of progress) {
|
||||
map[p.item_id] = { total: p.total, done: p.done };
|
||||
@@ -211,12 +211,12 @@
|
||||
progressLabel = 'done';
|
||||
}
|
||||
|
||||
// Fetch phase names for relation display on task cards
|
||||
// Fetch plan names for relation display on task cards
|
||||
if (coll === 'tasks') {
|
||||
try {
|
||||
const phases = await api.items.listByCollection(ws, 'phases');
|
||||
const plans = await api.items.listByCollection(ws, 'plans');
|
||||
const labels: Record<string, string> = {};
|
||||
for (const p of phases) {
|
||||
for (const p of plans) {
|
||||
labels[p.id] = p.title;
|
||||
}
|
||||
relationLabels = labels;
|
||||
@@ -260,9 +260,10 @@
|
||||
// Apply field filters
|
||||
for (const [key, value] of Object.entries(activeFilters)) {
|
||||
result = result.filter((item) => {
|
||||
// Phase filter uses the phase link, not fields JSON
|
||||
if (key === 'phase') {
|
||||
return item.phase_id === value;
|
||||
// Parent filter uses the parent link, not fields JSON
|
||||
// Also accept legacy 'phase' key for backward compat with saved views
|
||||
if (key === 'parent' || key === 'phase') {
|
||||
return item.parent_link_id === value;
|
||||
}
|
||||
const fields = parseFields(item);
|
||||
return fields[key] === value;
|
||||
@@ -305,7 +306,7 @@
|
||||
const emptyHintMap: Record<string, string> = {
|
||||
tasks: '/pad break down my current work into tasks',
|
||||
ideas: "/pad I have an idea for...",
|
||||
phases: '/pad create a phase for what I\'m working on',
|
||||
plans: '/pad create a plan for what I\'m working on',
|
||||
docs: '/pad document the architecture of this project',
|
||||
conventions: '/pad what conventions should this project follow?',
|
||||
playbooks: '/pad set up playbooks for our workflow',
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
item = itemData;
|
||||
collection = collData;
|
||||
|
||||
// Fetch child item progress for any item (generalized from phases-only)
|
||||
// Fetch child item progress for any item (generalized parent/child)
|
||||
try {
|
||||
const progress = await api.items.progress(wsSlug, itemData.slug);
|
||||
if (progress.total > 0) {
|
||||
@@ -464,7 +464,7 @@
|
||||
try {
|
||||
await api.links.delete(wsSlug, linkId);
|
||||
itemLinks = itemLinks.filter(l => l.id !== linkId);
|
||||
// Refresh item to update phase info
|
||||
// Refresh item to update parent info
|
||||
const refreshed = await api.items.get(wsSlug, itemSlug);
|
||||
item = { ...refreshed, content: item.content };
|
||||
toastStore.show('Relationship removed', 'success');
|
||||
@@ -512,7 +512,7 @@
|
||||
showAddLink = false;
|
||||
addLinkSearch = '';
|
||||
addLinkResults = [];
|
||||
// Refresh item to update phase info
|
||||
// Refresh item to update parent info
|
||||
const refreshed = await api.items.get(wsSlug, itemSlug);
|
||||
item = { ...refreshed, content: item.content };
|
||||
toastStore.show('Relationship added', 'success');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
const TRIGGERS = ['always','on-task-start','on-task-complete','on-implement','on-commit','on-pr-create','on-phase-start','on-phase-complete','on-plan'] as const;
|
||||
const TRIGGERS = ['always','on-task-start','on-task-complete','on-implement','on-commit','on-pr-create','on-plan-start','on-plan-complete','on-plan'] as const;
|
||||
type Trigger = typeof TRIGGERS[number];
|
||||
|
||||
const TRIGGER_META: Record<Trigger, { icon: string; label: string }> = {
|
||||
@@ -16,8 +16,8 @@
|
||||
'on-implement': { icon: '\u{1F528}', label: 'On Implement' },
|
||||
'on-commit': { icon: '\u{1F4BE}', label: 'On Commit' },
|
||||
'on-pr-create': { icon: '\u{1F500}', label: 'On PR Create' },
|
||||
'on-phase-start': { icon: '\u{1F3C1}', label: 'On Phase Start' },
|
||||
'on-phase-complete': { icon: '\u{1F389}', label: 'On Phase Complete' },
|
||||
'on-plan-start': { icon: '\u{1F3C1}', label: 'On Plan Start' },
|
||||
'on-plan-complete': { icon: '\u{1F389}', label: 'On Plan Complete' },
|
||||
'on-plan': { icon: '\u{1F4CB}', label: 'On Plan' },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user