From duplo
AI-assisted project planning — single ticket per project, phases spec→plan→execution managed by plugin, canvas files maintained by agent.
How this skill is triggered — by the user, by Claude, or both
Slash command
/duplo:ai_plannerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Follow these steps in order.
Follow these steps in order.
HARD RULE: Every conversation related to a project — spec, plan, or execution tasks — must happen inside the AI Planner ticket. Never create separate tickets for these phases.
All planning for a project happens in a single AI Planner ticket. The plugin manages phase transitions (spec → plan → execution) by passing project_context on each message. The agent writes canvas files (canvas-documents/spec.md, canvas-documents/plan.md, canvas-documents/execution_tasks.json). The plugin saves confirmed content back to the project via Projects_patch.
Phases:
canvas-documents/spec.mdcanvas-documents/plan.mdcanvas-documents/execution_tasks.json and generates stagesPlatform context rules (always include project_id):
ticket_type: "spec_creation", project_idticket_type: "plan_creation", project_id, spec_contentticket_type: "plan_execution", project_id, spec_content, plan_contentRead .duplocloud/state.toon.
project_id or workspace_id is absent: stop and tell the user:
"No active project. Please run
/duplo:activate_projectfirst."
Capture project_id, workspace_id, project_name.
Read env values:
bash -c 'source .env 2>/dev/null; printf "TOKEN=%s\nURL=%s" "$DUPLO_TOKEN" "$DUPLO_HELPDESK_URL"'
Call duplo-helpdesk::Ticket_get_origin_context_list with:
workspaceId = workspace_idtype = "Project"id = project_idsubType = "project-planner"This returns all AI Planner tickets linked to this project. Filter for tickets where isActive == true. If multiple active tickets exist, take the one with the most recent updatedAt.
Found → tell the user:
"Resuming AI Planner ticket <ticket_name> for project <project_name>."
Set active_ticket_name. Call duplo-helpdesk::Ticket_put_status with workspaceId = workspace_id, ticketName = active_ticket_name, and body { "status": "inProgress" }. Save state. Proceed to Step 2.
Not found → fetch workspace context and create the ticket:
Call duplo-helpdesk::Workspaces_get_scopes with id = workspace_id and duplo-helpdesk::Workspaces_get_personas with id = workspace_id in parallel.
scope_ids (array of id from each scope in the response).[] for that field — do not block ticket creation.Call duplo-helpdesk::Workspaces_get_agents with id = workspace_id (always fetch — needed in both modes).
If DUPLO_AGENT_MODE=true: ask the user to pick one:
Which agent should handle this ticket?
1. <name> — <description>
2. ...
Set selected_agent_id to the chosen agent's id.
If DUPLO_AGENT_MODE=false or not set: auto-select silently — do NOT prompt the user:
id. Tell the user: "Auto-selecting agent 🟢 <name>."id. Tell the user: "Auto-selecting agent 🟢 <name>."selected_agent_id = null.From the personas response (already fetched in parallel above):
persona_ids = []."Auto-selecting persona 🟢 <name> — it's the only one available." Set
persona_ids = [<that persona's id>].
Which personas should have access to this ticket? (enter comma-separated numbers, or press Enter to skip)
1. <name>
2. <name>
...
Wait for input:
persona_ids.persona_ids = [].Call duplo-helpdesk::Ticket_create:
{
"title": "<project_name> AI Planner",
"aiAgentId": "<selected_agent_id — omit this field if null>",
"workspaceId": "<workspace_id>",
"source": "helpdesk",
"ticketContextForAgent": {
"scopeIds": ["<scope_ids>"],
"personaIds": ["<persona_ids>"]
},
"originContext": {
"type": "Project",
"id": "<project_id>",
"subType": "project-planner",
"metadata": { "projectType": "spec_creation" }
},
"requestApproval": {
"approvedCmdRegEx": ["^\s*cat\b\s+.*", "^\s*mkdir\b\s+.*", "^\s*find\b\s+.*", "^\s*ls\b\s+.*"]
}
}
Omit aiAgentId entirely if selected_agent_id is null. Omit ticketContextForAgent entirely if both scope_ids and persona_ids are empty.
Call the MCP tool and capture the returned ticket's name as active_ticket_name.
Tell the user:
"Created AI Planner ticket <active_ticket_name> for project <project_name>."
Call duplo-helpdesk::Ticket_put_status with workspaceId = workspace_id, ticketName = active_ticket_name, and body { "status": "inProgress" }. Save state.
Call duplo-helpdesk::Projects_get with id = project_id.
Extract:
spec.content → spec_contentspec.metaData.approvalState → spec_state ("Approved" / "Draft" / "Not started")plan.content → plan_contentplan.metaData.approvalState → plan_stateexecution.stages → execution_stagesexecution.version → execution_versionDetermine current phase:
| State | Phase |
|---|---|
spec_content blank | spec |
spec_content present, plan_content blank | plan |
plan_content present, execution_stages empty | execution |
execution_stages present | stages ready → skip to Step 6 |
Artifact state awareness:
spec_content is present (draft): tell the user:
"You have a spec draft (status: <spec_state>). Would you like to edit it, confirm it, or start fresh? (edit / confirm / restart)"
spec_content as context)plan_content is present (draft): tell the user:
"You have a plan draft (status: <plan_state>). Would you like to edit it, confirm it, or start fresh? (edit / confirm / restart)"
If not already shown: tell the user:
"Let's define the spec. Describe what you'd like to achieve:"
If DUPLO_AGENT_MODE=false or not set (local agent mode):
duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "<user input>", "role": "user", "message_mode": 1, "data": {} }~/.duplocloud/skills/duplo-project-management/spec-phase.md if it exists and follow its instructions to write the spec. If absent, use general judgment.duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "<the spec content just displayed>", "role": "assistant", "message_mode": 1, "data": {} }. Set spec_draft to this content. This call MUST happen in the same response turn — do NOT ask the refine/confirm question until this tool call completes.If DUPLO_AGENT_MODE=true (remote agent):
Send the user's input via duplo-helpdesk::Ticket_send_message_streaming:
{
"workspaceId": "<workspace_id>",
"ticketName": "<active_ticket_name>",
"content": "<user input>",
"message_mode": 0,
"data": {},
"platform_context": {
"duplo_base_url": "<DUPLO_HELPDESK_URL>",
"duplo_token": "<DUPLO_TOKEN>",
"project_context": {
"ticket_type": "spec_creation",
"project_id": "<project_id>"
}
}
}
Apply agent availability hard rule. From the plain JSON response:
text → display the agent's full text verbatim.present_files for an entry where path ends with spec.md → capture its content as spec_draft.Mirror confirmation prompt to ticket — call duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "Would you like to refine the spec or confirm it? (refine / confirm)", "role": "assistant", "message_mode": 1, "data": {} }. Do NOT wait for this to complete — fire and move on.
Ask:
"Would you like to refine the spec or confirm it? (refine / confirm)"
spec_draft; (remote mode) send follow-up with same project_context, display response, re-capture spec_draft. Repeat.spec_draft if captured; otherwise use the text value (remote) or the spec content you wrote (local).duplo-helpdesk::Projects_patch with body { "spec": { "content": "<spec_draft>" } }Fetch updated project: call duplo-helpdesk::Projects_get with id = project_id. Capture spec_content, plan_content, plan_state.
Tell the user:
"Now let's build the plan. Describe your approach or say 'generate from spec':"
If DUPLO_AGENT_MODE=false or not set (local agent mode):
duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "<user input>", "role": "user", "message_mode": 1, "data": {} }~/.duplocloud/skills/duplo-project-management/plan-phase.md if it exists and follow its instructions to write the plan. If absent, use general judgment.duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "<the plan content just displayed>", "role": "assistant", "message_mode": 1, "data": {} }. Set plan_draft to this content. This call MUST happen in the same response turn — do NOT ask the refine/confirm question until this tool call completes.If DUPLO_AGENT_MODE=true (remote agent):
Send the user's input via duplo-helpdesk::Ticket_send_message_streaming:
{
"workspaceId": "<workspace_id>",
"ticketName": "<active_ticket_name>",
"content": "<user input>",
"message_mode": 0,
"data": {},
"platform_context": {
"duplo_base_url": "<DUPLO_HELPDESK_URL>",
"duplo_token": "<DUPLO_TOKEN>",
"project_context": {
"ticket_type": "plan_creation",
"project_id": "<project_id>",
"spec_content": "<spec_content>"
}
}
}
Apply agent availability hard rule. From the plain JSON response:
text → display the agent's full text verbatim.present_files for an entry where path ends with plan.md → capture its content as plan_draft.Mirror confirmation prompt to ticket — call duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "Would you like to refine the plan or confirm it? (refine / confirm)", "role": "assistant", "message_mode": 1, "data": {} }. Do NOT wait for this to complete — fire and move on.
Ask:
"Would you like to refine the plan or confirm it? (refine / confirm)"
plan_draft; (remote mode) send follow-up with same project_context, display response, re-capture plan_draft. Repeat.plan_draft if captured; otherwise use the text value (remote) or the plan content you wrote (local).duplo-helpdesk::Projects_patch with body { "plan": { "content": "<plan_draft>" } }Fetch updated project: call duplo-helpdesk::Projects_get with id = project_id. Capture spec_content and plan_content.
Tell the user:
"Generating execution stages from the plan..."
If DUPLO_AGENT_MODE=false or not set (local agent mode):
duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "Based on the confirmed plan, generate execution stages and tasks for this project.", "role": "user", "message_mode": 1, "data": {} }~/.duplocloud/skills/duplo-project-management/execution-phase.md if it exists and follow its instructions to generate execution stages and tasks. If absent, use general judgment. Use spec_content and plan_content as context.duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "<the execution tasks JSON just displayed>", "role": "assistant", "message_mode": 1, "data": {} }. Set execution_draft to this content. This call MUST happen in the same response turn — do NOT ask the refine/confirm question until this tool call completes.If DUPLO_AGENT_MODE=true (remote agent):
Send via duplo-helpdesk::Ticket_send_message_streaming:
{
"workspaceId": "<workspace_id>",
"ticketName": "<active_ticket_name>",
"content": "Based on the confirmed plan, generate execution stages and tasks for this project.",
"message_mode": 0,
"data": {},
"platform_context": {
"duplo_base_url": "<DUPLO_HELPDESK_URL>",
"duplo_token": "<DUPLO_TOKEN>",
"project_context": {
"ticket_type": "plan_execution",
"project_id": "<project_id>",
"spec_content": "<spec_content>",
"plan_content": "<plan_content>"
}
}
}
Apply agent availability hard rule. From the plain JSON response:
text → display the agent's full text verbatim.present_files for an entry where path ends with execution_tasks.json → capture its content as execution_draft.If no matching entry found in present_files:
"The agent did not return execution tasks. Would you like to try again? (y/n)"
Mirror confirmation prompt to ticket — call duplo-helpdesk::Ticket_send_message with workspaceId = workspace_id, ticketName = active_ticket_name, and body: { "content": "Would you like to refine the execution tasks or confirm them? (refine / confirm)", "role": "assistant", "message_mode": 1, "data": {} }. Do NOT wait for this to complete — fire and move on.
Ask the user:
"Would you like to refine the execution tasks or confirm them? (refine / confirm)"
execution_draft; (remote mode) send follow-up with same project_context, display response, re-capture execution_draft. Repeat.duplo-helpdesk::Projects_get to get execution.version.execution_draft JSON — it has a stages array. Each stage has id, name, description, tasks[] where each task has id, title, description.duplo-helpdesk::Projects_update_plan_execution with id = project_id:
{
"version": "<execution.version>",
"stageToAddOrUpdate": [
{
"name": "<stage.id>",
"title": "<stage.name>",
"description": "<stage.description>",
"tasksToAddOrUpdate": [
{ "name": "<task.id>", "title": "<task.title>", "description": "<task.description>" }
]
}
]
}
Re-fetch if needed: call duplo-helpdesk::Projects_get with id = project_id.
Display:
Project: <project_name>
Progress: <project.progress>%
Execution Stages:
1. <stage.title> — <N> tasks
2. ...
Ask:
"Which stage would you like to work on? (number, or 'done')"
active_stage. Proceed to Step 7.Call duplo-helpdesk::Ticket_list with workspaceId = workspace_id.
Build task_ticket_map keyed by metadata.taskId using only tickets where all of the following are true:
originContext.type is ProjectoriginContext.id equals project_idoriginContext.metadata.projectType equals plan_executionoriginContext.metadata.taskId is presentFor each task in active_stage.tasks, look up an existing ticket in task_ticket_map using task.name.
Display:
Tasks in <stage.title>:
1. <task.title> — <ticket title or "No ticket">
2. ...
N+1. Add a new task
active_task. Proceed to Step 8.Ask:
"For <active_task.title>:
- Open a ticket for this task
- Edit task name / description
- Move task to a different stage
- Delete task
- Go back to stage list"
skills/activate_ticket/SKILL.md from Step 4d using task_id = active_task.name, task_title = active_task.title, project_ticket_type = "plan_execution". Ticket is linked to the task, mirroring applies.skills/stage_tasks/SKILL.md Step 5d (Edit task name / description). Pass workspace_id, project_id, active_stage, and active_task. Skip Steps 0–5c. After completion, re-fetch project and return to Step 7.skills/stage_tasks/SKILL.md Step 5c (Move task to a different stage). Pass workspace_id, project_id, active_stage, and active_task. Skip Steps 0–5b. After completion, re-fetch project and return to Step 7.skills/stage_tasks/SKILL.md Step 5e (Delete task). Pass workspace_id, project_id, active_stage, and active_task. Skip Steps 0–5d. After completion, re-fetch project and return to Step 7.Ask for title and description.
Generate a UUID:
python3 -c "import uuid; print(uuid.uuid4())"
Re-fetch project to get execution.version and all existing tasks in active_stage with their name, title, description, version.
Call duplo-helpdesk::Projects_update_plan_execution with id = project_id:
{
"version": "<execution.version>",
"stageToAddOrUpdate": [
{
"name": "<active_stage.name>",
"title": "<active_stage.title>",
"tasksToAddOrUpdate": [
{ "name": "<task.name>", "title": "<task.title>", "description": "<task.description>", "version": "<task.version>" },
{ "name": "<new-uuid>", "title": "<user title>", "description": "<user description>" }
]
}
]
}
Tell the user: "Task added." Re-fetch and go back to Step 7.
Guides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub duplocloud/duplo-claude-plugin --plugin duplo