Skip to content

Commit 0839e4f

Browse files
jpicklykclaude
andauthored
feat: add reopen trigger and guard role changes in manage_items (#59)
* feat: add reopen trigger and guard role changes in manage_items Close the last workflow bypass: manage_items(update) can no longer set role directly — all role changes must go through advance_item triggers. Add reopen trigger (TERMINAL → QUEUE) with cascade support: when a child reopens under a terminal parent, the parent auto-cascades to WORK. Changes: - RoleTransitionHandler: add resolveReopen + validation bypass for backward TERMINAL→QUEUE transition - CascadeDetector: add detectReopenCascades (immediate parent only) - AdvanceItemTool: wire reopen cascade, update description/triggers - ManageItemsTool: reject role field in update with clear error message - GetNextStatusTool: terminal message now mentions reopen option - Docs: api-reference.md and workflow-guide.md updated - Tests: reopen lifecycle, cascade, role guard, integration tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract cascade helper and clean up dead code from /simplify - Extract applyCascadeEvents() helper in AdvanceItemTool to deduplicate Phase 4b (start cascade) and Phase 4c (reopen cascade) blocks - Remove dead newRole variable and redundant extractItemString call in ManageItemsTool role guard — use containsKey check instead Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b3ab296 commit 0839e4f

File tree

13 files changed

+388
-47
lines changed

13 files changed

+388
-47
lines changed

current/docs/api-reference.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ is computed automatically from the parent; the maximum nesting depth is 3.
6868
fields are changed; omitted fields retain existing values. Setting `parentId` to JSON null moves
6969
the item to root.
7070

71+
**Note:** The `role` field is not accepted in update operations. Use `advance_item` with an appropriate trigger instead.
72+
7173
**Response (update).**
7274

7375
```json
@@ -643,7 +645,7 @@ gate enforcement, cascade detection, and unblock reporting. Supports batch trans
643645
| Field | Type | Required | Description |
644646
|---|---|---|---|
645647
| `itemId` | string (UUID) | Yes | Item to transition |
646-
| `trigger` | string | Yes | One of: `start`, `complete`, `block`, `hold`, `resume`, `cancel` |
648+
| `trigger` | string | Yes | One of: `start`, `complete`, `block`, `hold`, `resume`, `cancel`, `reopen` |
647649
| `summary` | string | No | Optional annotation stored on the transition record |
648650

649651
**Trigger effects:**
@@ -655,14 +657,19 @@ gate enforcement, cascade detection, and unblock reporting. Supports batch trans
655657
| `block` / `hold` | Any non-terminal → BLOCKED (saves `previousRole`) |
656658
| `resume` | BLOCKED → `previousRole` |
657659
| `cancel` | Any non-terminal → TERMINAL with `statusLabel="cancelled"` |
660+
| `reopen` | TERMINAL → QUEUE (clears statusLabel, bypasses gate enforcement, cascades parent TERMINAL → WORK) |
658661

659662
**Gate enforcement.** When the item's `tags` match a configured note schema:
660663
- `start`: required notes for the current phase must exist and be filled.
661664
- `complete`: all required notes across all phases must be filled.
662665

663666
**Start cascade.** When a child item transitions to WORK, the parent is automatically advanced from QUEUE to WORK if it is still in QUEUE (same cascade logic applies up the ancestor chain). This appears in `cascadeEvents` in the response with `trigger="cascade"`.
664667

665-
**Terminal cascade.** When a child item reaches TERMINAL, the parent may also automatically advance if all its children are terminal. Both cascade types are recorded in `cascadeEvents`.
668+
**Terminal cascade.** When a child item reaches TERMINAL, the parent may also automatically advance if all its children are terminal.
669+
670+
**Reopen cascade.** When a child item is reopened (TERMINAL → QUEUE) and its parent is TERMINAL, the parent is automatically reopened to WORK. This ensures the parent reflects that it has active children again.
671+
672+
All cascade types are recorded in `cascadeEvents`.
666673

667674
**Examples.**
668675

@@ -784,7 +791,7 @@ the item is Ready to advance, Blocked, or Terminal, without making any changes.
784791
}
785792

786793
// Terminal
787-
{ "recommendation": "Terminal", "currentRole": "terminal", "reason": "Item is already terminal and cannot progress further" }
794+
{ "recommendation": "Terminal", "currentRole": "terminal", "reason": "Item is terminal. Use 'reopen' trigger to move back to queue, or 'cancel' if already cancelled." }
788795
```
789796

790797
When the item is in BLOCKED role, the response includes a `suggestion` field instead of `blockers`. When the item is blocked by unsatisfied dependencies, the response includes `blockers` but no `suggestion`.

current/docs/workflow-guide.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Every WorkItem moves through a set of lifecycle phases called **roles**. Roles a
1515
| `queue` | Pending, not yet started. Default role at creation. |
1616
| `work` | Actively being worked on. |
1717
| `review` | Work complete, undergoing verification or review. |
18-
| `terminal` | Finished. No further transitions possible (unless cancelled).|
18+
| `terminal` | Finished. Use `reopen` to move back to queue if needed. |
1919
| `blocked` | Paused due to an unresolved dependency or explicit hold. |
2020

2121
### Standard Flow
@@ -50,6 +50,7 @@ All role transitions use `advance_item(trigger=...)`. There is no direct role as
5050
| `hold` | Any non-terminal | `blocked` | Alias for `block`. |
5151
| `resume` | `blocked` | Previous role | Restores role saved at block time. |
5252
| `cancel` | Any non-terminal | `terminal` | Sets `statusLabel = "cancelled"`. |
53+
| `reopen` | `terminal` | `queue` | Clears statusLabel, bypasses gates. Parent cascades TERMINAL → WORK. |
5354

5455
### Example: Advance a Work Item
5556

@@ -537,7 +538,9 @@ get_blocked_items(parentId="feature-uuid", includeAncestors=true)
537538

538539
**Terminal cascade (all children → TERMINAL):** When a child item reaches TERMINAL, if all siblings are also terminal, the parent is automatically advanced to TERMINAL. This cascade also continues up the ancestor chain.
539540

540-
Both cascade types appear in `cascadeEvents` in the response:
541+
**Reopen cascade (child TERMINAL → QUEUE):** When a child item is reopened under a terminal parent, the parent is automatically reopened to WORK. This only applies to the immediate parent — no recursion.
542+
543+
All cascade types appear in `cascadeEvents` in the response:
541544

542545
```json
543546
{
@@ -718,4 +721,5 @@ docker run -e AGENT_CONFIG_DIR=/project -v "$(pwd)"/.taskorchestrator:/project/.
718721
| Find blocked items | `get_blocked_items(includeAncestors=true)` |
719722
| Create a dependency chain | `manage_dependencies(operation="create", pattern="linear", itemIds=[...])` |
720723
| Cancel an item | `advance_item(transitions=[{itemId, trigger:"cancel"}])` |
724+
| Reopen a terminal item | `advance_item(transitions=[{itemId, trigger:"reopen"}])` |
721725
| Filter by phase | `query_items(operation="search", role="work")` |

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/application/service/CascadeDetector.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,40 @@ class CascadeDetector {
161161
))
162162
}
163163

164+
// -----------------------------------------------------------------------
165+
// Reopen Cascade Detection
166+
// -----------------------------------------------------------------------
167+
168+
/**
169+
* Detect whether reopening an item should cascade to reopen its parent.
170+
* If the item was just reopened (now in QUEUE) and its parent is TERMINAL,
171+
* the parent should reopen to WORK since it now has a non-terminal child.
172+
* Only checks immediate parent — no recursion.
173+
*/
174+
suspend fun detectReopenCascades(
175+
item: WorkItem,
176+
workItemRepository: WorkItemRepository
177+
): List<CascadeEvent> {
178+
// Only applies to items that just entered QUEUE via reopen
179+
if (item.role != Role.QUEUE) return emptyList()
180+
181+
val parentId = item.parentId ?: return emptyList()
182+
183+
val parent = when (val result = workItemRepository.getById(parentId)) {
184+
is Result.Success -> result.data
185+
is Result.Error -> return emptyList()
186+
}
187+
188+
// Only cascade if parent is TERMINAL
189+
if (parent.role != Role.TERMINAL) return emptyList()
190+
191+
return listOf(CascadeEvent(
192+
itemId = parent.id,
193+
currentRole = Role.TERMINAL,
194+
targetRole = Role.WORK
195+
))
196+
}
197+
164198
// -----------------------------------------------------------------------
165199
// Unblock Detection
166200
// -----------------------------------------------------------------------

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/application/service/RoleTransitionHandler.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ data class TransitionApplyResult(
6464
class RoleTransitionHandler {
6565

6666
companion object {
67-
val VALID_TRIGGERS = setOf("start", "complete", "block", "hold", "resume", "cancel")
67+
val VALID_TRIGGERS = setOf("start", "complete", "block", "hold", "resume", "cancel", "reopen")
6868
}
6969

7070
// -----------------------------------------------------------------------
@@ -89,6 +89,7 @@ class RoleTransitionHandler {
8989
"Use resolveTransition(item, trigger) instead."
9090
)
9191
"cancel" -> resolveCancel(currentRole)
92+
"reopen" -> resolveReopen(currentRole)
9293
else -> TransitionResolution(
9394
success = false,
9495
error = "Unknown trigger: '$trigger'. Valid triggers: ${VALID_TRIGGERS.joinToString()}"
@@ -111,6 +112,7 @@ class RoleTransitionHandler {
111112
"block", "hold" -> resolveBlock(item.role)
112113
"resume" -> resolveResume(item)
113114
"cancel" -> resolveCancel(item.role)
115+
"reopen" -> resolveReopen(item.role)
114116
else -> TransitionResolution(
115117
success = false,
116118
error = "Unknown trigger: '$trigger'. Valid triggers: ${VALID_TRIGGERS.joinToString()}"
@@ -183,6 +185,14 @@ class RoleTransitionHandler {
183185
else -> TransitionResolution(success = true, targetRole = Role.TERMINAL, statusLabel = "cancelled")
184186
}
185187

188+
private fun resolveReopen(currentRole: Role): TransitionResolution = when (currentRole) {
189+
Role.TERMINAL -> TransitionResolution(success = true, targetRole = Role.QUEUE, statusLabel = null)
190+
else -> TransitionResolution(
191+
success = false,
192+
error = "Cannot reopen: item is not terminal (current role: ${currentRole.name.lowercase()})"
193+
)
194+
}
195+
186196
// -----------------------------------------------------------------------
187197
// Phase 2: Validation (reads dependencies, suspend for WorkItem lookups)
188198
// -----------------------------------------------------------------------
@@ -207,6 +217,11 @@ class RoleTransitionHandler {
207217
return TransitionValidation(valid = true)
208218
}
209219

220+
// Reopen transitions bypass the terminal guard (backward transition)
221+
if (item.role == Role.TERMINAL && targetRole == Role.QUEUE) {
222+
return TransitionValidation(valid = true)
223+
}
224+
210225
// Terminal items cannot transition further
211226
if (item.role == Role.TERMINAL) {
212227
return TransitionValidation(

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/application/tools/items/ManageItemsTool.kt

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ Unified write operations for WorkItems (create, update, delete).
4444
- `tags` is always included (null if not set). `expectedNotes` is included only when the item's tags match a configured note schema — check it immediately after creation to know which notes to fill.
4545
4646
**update** - Partial update from `items` array.
47-
- Each item: `{ id (required), title?, description?, summary?, role?, statusLabel?, priority?, complexity?, parentId?, metadata?, tags? }`
47+
- Each item: `{ id (required), title?, description?, summary?, statusLabel?, priority?, complexity?, parentId?, metadata?, tags? }`
48+
- Note: role changes are not allowed in update operations. Use advance_item with triggers (start, complete, block, hold, resume, cancel, reopen) instead.
4849
- Only provided fields are changed; omitted fields retain existing values
4950
- If parentId changes, depth is recomputed from new parent
5051
- Response: `{ items: [{id, modifiedAt}], updated: N, failed: N, failures: [{id, error}] }`
@@ -392,24 +393,22 @@ Unified write operations for WorkItems (create, update, delete).
392393
val newTitle = extractItemString(itemObj, "title")
393394
val newDescription = extractItemStringAllowNull(itemObj, "description", existing.description)
394395
val newSummary = extractItemString(itemObj, "summary")
395-
val newRoleStr = extractItemString(itemObj, "role")
396+
397+
// Reject role field in updates — all role changes must go through advance_item
398+
if (itemObj.containsKey("role")) {
399+
throw ToolValidationException(
400+
"Item '$itemId': role changes are not allowed via manage_items update. " +
401+
"Use advance_item with an appropriate trigger instead (start, complete, block, hold, resume, cancel, reopen)."
402+
)
403+
}
404+
396405
val newStatusLabel = extractItemStringAllowNull(itemObj, "statusLabel", existing.statusLabel)
397406
val newPriorityStr = extractItemString(itemObj, "priority")
398407
val newComplexity = extractItemInt(itemObj, "complexity")
399408
val newRequiresVerification = itemObj["requiresVerification"]?.let { (it as? JsonPrimitive)?.booleanOrNull }
400409
val newMetadata = extractItemStringAllowNull(itemObj, "metadata", existing.metadata)
401410
val newTags = extractItemStringAllowNull(itemObj, "tags", existing.tags)
402411

403-
// Parse role if provided
404-
val newRole = if (newRoleStr != null) {
405-
Role.fromString(newRoleStr)
406-
?: throw ToolValidationException(
407-
"Item '$itemId': invalid role '$newRoleStr'. Valid: ${Role.VALID_NAMES}"
408-
)
409-
} else {
410-
null
411-
}
412-
413412
// Parse priority if provided
414413
val newPriority = if (newPriorityStr != null) {
415414
Priority.fromString(newPriorityStr)
@@ -488,7 +487,7 @@ Unified write operations for WorkItems (create, update, delete).
488487
title = newTitle ?: item.title,
489488
description = newDescription,
490489
summary = newSummary ?: item.summary,
491-
role = newRole ?: item.role,
490+
role = item.role,
492491
statusLabel = newStatusLabel,
493492
priority = newPriority ?: item.priority,
494493
complexity = newComplexity ?: item.complexity,

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/application/tools/workflow/AdvanceItemTool.kt

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.github.jpicklyk.mcptask.current.application.tools.workflow
22

33
import io.github.jpicklyk.mcptask.current.application.service.CascadeDetector
4+
import io.github.jpicklyk.mcptask.current.application.service.CascadeEvent
45
import io.github.jpicklyk.mcptask.current.application.service.RoleTransitionHandler
56
import io.github.jpicklyk.mcptask.current.application.tools.*
67
import io.github.jpicklyk.mcptask.current.domain.model.Role
@@ -17,7 +18,7 @@ import java.util.UUID
1718
* Supports batch transitions via the `transitions` array parameter. Each transition
1819
* is processed independently: failures on one do not block others.
1920
*
20-
* Valid triggers: start, complete, block, hold, resume, cancel.
21+
* Valid triggers: start, complete, block, hold, resume, cancel, reopen.
2122
*
2223
* NoteSchemaService integration:
2324
* - If the item's tags match a schema, gate enforcement applies:
@@ -35,14 +36,15 @@ Trigger-based role transitions for WorkItems with validation, cascade detection,
3536
3637
**Parameters:**
3738
- `transitions` (required array): Each element: `{ itemId (required UUID), trigger (required string), summary? (optional string) }`
38-
- Valid triggers: start, complete, block, hold, resume, cancel
39+
- Valid triggers: start, complete, block, hold, resume, cancel, reopen
3940
4041
**Trigger effects:**
4142
- start: QUEUE->WORK, WORK->REVIEW (or TERMINAL if no review phase in schema), REVIEW->TERMINAL
4243
- complete: any non-TERMINAL/BLOCKED -> TERMINAL
4344
- block/hold: any non-TERMINAL/BLOCKED -> BLOCKED (saves previousRole)
4445
- resume: BLOCKED -> previousRole
4546
- cancel: any non-TERMINAL -> TERMINAL (statusLabel = "cancelled")
47+
- reopen: TERMINAL -> QUEUE (clears statusLabel; bypasses gate enforcement)
4648
4749
**Gate enforcement (when tags match a note schema):**
4850
- start: required notes for the current phase must be filled before advancing
@@ -322,28 +324,16 @@ Trigger-based role transitions for WorkItems with validation, cascade detection,
322324
// When the first child starts, auto-advance the parent from QUEUE to WORK.
323325
if (targetRole == Role.WORK) {
324326
val startCascadeEvents = cascadeDetector.detectStartCascades(applyResult.item!!, context.workItemRepository())
325-
for (event in startCascadeEvents) {
326-
val parentResult = context.workItemRepository().getById(event.itemId)
327-
val parentItem = when (parentResult) {
328-
is Result.Success -> parentResult.data
329-
is Result.Error -> continue
330-
}
331-
332-
val cascadeApply = handler.applyTransition(
333-
parentItem, event.targetRole, "cascade",
334-
"Auto-cascaded from child start", null,
335-
context.workItemRepository(),
336-
context.roleTransitionRepository()
337-
)
327+
applyCascadeEvents(startCascadeEvents, "Auto-cascaded from child start",
328+
handler, context, cascadeJsonList)
329+
}
338330

339-
cascadeJsonList.add(buildJsonObject {
340-
put("itemId", JsonPrimitive(event.itemId.toString()))
341-
put("title", JsonPrimitive(parentItem.title))
342-
put("previousRole", JsonPrimitive(event.currentRole.name.lowercase()))
343-
put("targetRole", JsonPrimitive(event.targetRole.name.lowercase()))
344-
put("applied", JsonPrimitive(cascadeApply.success))
345-
})
346-
}
331+
// Phase 4c: Reopen cascade detection (only when reopening to QUEUE)
332+
// When a child is reopened under a terminal parent, the parent should reopen to WORK.
333+
if (trigger == "reopen" && targetRole == Role.QUEUE) {
334+
val reopenCascadeEvents = cascadeDetector.detectReopenCascades(applyResult.item!!, context.workItemRepository())
335+
applyCascadeEvents(reopenCascadeEvents, "Auto-cascaded from child reopen",
336+
handler, context, cascadeJsonList)
347337
}
348338

349339
// Phase 5: Unblock detection
@@ -440,6 +430,41 @@ Trigger-based role transitions for WorkItems with validation, cascade detection,
440430
return if (failed == 0) "Transitioned $succeeded item(s)" else "Transitioned $succeeded/$total (${failed} failed)"
441431
}
442432

433+
/**
434+
* Apply a list of cascade events: fetch each parent, apply the transition, and record
435+
* the cascade result as JSON. Shared by start cascade (Phase 4b) and reopen cascade (Phase 4c).
436+
*/
437+
private suspend fun applyCascadeEvents(
438+
events: List<CascadeEvent>,
439+
summary: String,
440+
handler: RoleTransitionHandler,
441+
context: ToolExecutionContext,
442+
cascadeJsonList: MutableList<JsonObject>
443+
) {
444+
for (event in events) {
445+
val parentResult = context.workItemRepository().getById(event.itemId)
446+
val parentItem = when (parentResult) {
447+
is Result.Success -> parentResult.data
448+
is Result.Error -> continue
449+
}
450+
451+
val cascadeApply = handler.applyTransition(
452+
parentItem, event.targetRole, "cascade",
453+
summary, null,
454+
context.workItemRepository(),
455+
context.roleTransitionRepository()
456+
)
457+
458+
cascadeJsonList.add(buildJsonObject {
459+
put("itemId", JsonPrimitive(event.itemId.toString()))
460+
put("title", JsonPrimitive(parentItem.title))
461+
put("previousRole", JsonPrimitive(event.currentRole.name.lowercase()))
462+
put("targetRole", JsonPrimitive(event.targetRole.name.lowercase()))
463+
put("applied", JsonPrimitive(cascadeApply.success))
464+
})
465+
}
466+
}
467+
443468
private fun buildErrorResult(
444469
itemId: UUID,
445470
trigger: String,

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/application/tools/workflow/GetNextStatusTool.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Read-only status progression recommendation for a WorkItem.
7878
successResponse(buildJsonObject {
7979
put("recommendation", JsonPrimitive("Terminal"))
8080
put("currentRole", JsonPrimitive("terminal"))
81-
put("reason", JsonPrimitive("Item is already terminal and cannot progress further"))
81+
put("reason", JsonPrimitive("Item is terminal. Use 'reopen' trigger to move back to queue, or 'cancel' if already cancelled."))
8282
})
8383
}
8484

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/domain/model/RoleTransition.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ data class RoleTransition(
1919
val transitionedAt: Instant = Instant.now()
2020
) {
2121
companion object {
22-
val VALID_TRIGGERS = setOf("start", "complete", "block", "hold", "resume", "cancel")
22+
val VALID_TRIGGERS = setOf("start", "complete", "block", "hold", "resume", "cancel", "reopen")
2323
}
2424
}

0 commit comments

Comments
 (0)