Skip to content

Commit 8af0fd2

Browse files
jpicklykclaude
andauthored
feat: add guidancePointer and noteProgress to manage_notes upsert response (#61)
* feat: add guidancePointer and noteProgress to manage_notes upsert response After a successful upsert, the response now includes an `itemContext` map keyed by itemId, containing `guidancePointer` (guidance for the next unfilled required note) and `noteProgress` (filled/remaining/total counts for the current phase). This eliminates the need to call get_context after each manage_notes upsert to check remaining work. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add edge case tests for manage_notes itemContext Cover three gaps identified during review: - Terminal items return null guidancePointer/noteProgress even with schema - Mixed batch (success + failure) only includes successful items in itemContext - Pre-filled notes are counted correctly in noteProgress Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bbc0825 commit 8af0fd2

File tree

3 files changed

+430
-9
lines changed

3 files changed

+430
-9
lines changed

current/docs/api-reference.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,10 +405,22 @@ The `(itemId, key)` pair is unique — upserting with an existing pair updates t
405405
{ "id": "uuid", "itemId": "uuid", "key": "requirements", "role": "queue" }
406406
],
407407
"upserted": 1,
408-
"failed": 0
408+
"failed": 0,
409+
"itemContext": {
410+
"<itemId>": {
411+
"guidancePointer": "Guidance text for the next unfilled required note, or null",
412+
"noteProgress": { "filled": 1, "remaining": 0, "total": 1 }
413+
}
414+
}
409415
}
410416
```
411417

418+
The `itemContext` map is keyed by each `itemId` that had at least one successful upsert. For each item:
419+
- `guidancePointer` — the `guidance` text from the first unfilled required note in the item's current phase, or `null` if all required notes are filled (or no schema matches).
420+
- `noteProgress``{ filled, remaining, total }` counts of required notes for the current phase, or `null` if the item has no matching schema or is in terminal state.
421+
422+
This eliminates the need to call `get_context` after each `manage_notes` upsert to check remaining work.
423+
412424
---
413425

414426
### query_notes

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/application/tools/notes/ManageNotesTool.kt

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.github.jpicklyk.mcptask.current.application.tools.notes
22

33
import io.github.jpicklyk.mcptask.current.application.tools.*
44
import io.github.jpicklyk.mcptask.current.domain.model.Note
5+
import io.github.jpicklyk.mcptask.current.domain.model.Role
56
import io.github.jpicklyk.mcptask.current.domain.repository.Result
67
import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations
78
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
@@ -29,7 +30,7 @@ Unified write operations for Notes (upsert, delete).
2930
- Each note: `{ itemId (required), key (required), role (required: "queue"|"work"|"review"), body? }`
3031
- (itemId, key) is unique — existing notes with same pair are updated
3132
- Validates that itemId references an existing WorkItem
32-
- Response: `{ notes: [{id, itemId, key, role}], upserted: N, failed: N, failures: [{index, error}] }`
33+
- Response: `{ notes: [{id, itemId, key, role}], upserted: N, failed: N, failures: [{index, error}], itemContext: { "<itemId>": { guidancePointer: "...|null", noteProgress: { filled: N, remaining: N, total: N }|null } } }`
3334
3435
**delete** - Delete notes.
3536
- By `ids` array: delete each note by UUID
@@ -132,6 +133,8 @@ Unified write operations for Notes (upsert, delete).
132133

133134
val upsertedNotes = mutableListOf<JsonObject>()
134135
val failures = mutableListOf<JsonObject>()
136+
// Cache validated items to avoid redundant DB lookups in itemContext computation
137+
val validatedItems = mutableMapOf<UUID, io.github.jpicklyk.mcptask.current.domain.model.WorkItem>()
135138

136139
for ((index, element) in notesArray.withIndex()) {
137140
try {
@@ -152,12 +155,14 @@ Unified write operations for Notes (upsert, delete).
152155
throw ToolValidationException("Note at index $index: 'itemId' is not a valid UUID: $itemIdStr")
153156
}
154157

155-
// Validate that the WorkItem exists
156-
when (itemRepo.getById(itemId)) {
157-
is Result.Success -> { /* item exists */ }
158-
is Result.Error -> throw ToolValidationException(
159-
"Note at index $index: WorkItem '$itemIdStr' not found"
160-
)
158+
// Validate that the WorkItem exists (cache for itemContext reuse)
159+
if (itemId !in validatedItems) {
160+
when (val r = itemRepo.getById(itemId)) {
161+
is Result.Success -> validatedItems[itemId] = r.data
162+
is Result.Error -> throw ToolValidationException(
163+
"Note at index $index: WorkItem '$itemIdStr' not found"
164+
)
165+
}
161166
}
162167

163168
// Check for existing note with same (itemId, key) to preserve its ID
@@ -203,13 +208,78 @@ Unified write operations for Notes (upsert, delete).
203208
}
204209
}
205210

211+
// Compute itemContext for each unique itemId that had at least one successful upsert
212+
val successItemIds = upsertedNotes.mapNotNull { note ->
213+
note["itemId"]?.let { (it as? JsonPrimitive)?.content }
214+
}.toSet()
215+
216+
val itemContextMap = buildJsonObject {
217+
for (itemIdStr in successItemIds) {
218+
val itemId = UUID.fromString(itemIdStr)
219+
// Reuse cached item from validation — avoids redundant DB lookup
220+
val item = validatedItems[itemId] ?: continue
221+
222+
// Terminal items cannot advance — no guidance needed
223+
if (item.role == Role.TERMINAL) {
224+
put(itemIdStr, buildJsonObject {
225+
put("guidancePointer", JsonNull)
226+
put("noteProgress", JsonNull)
227+
})
228+
continue
229+
}
230+
231+
val schema = context.noteSchemaService().getSchemaForTags(item.tagList())
232+
if (schema == null) {
233+
put(itemIdStr, buildJsonObject {
234+
put("guidancePointer", JsonNull)
235+
put("noteProgress", JsonNull)
236+
})
237+
continue
238+
}
239+
240+
// Get all notes for this item (including ones just upserted)
241+
val allNotes = when (val nr = noteRepo.findByItemId(itemId)) {
242+
is Result.Success -> nr.data
243+
is Result.Error -> emptyList()
244+
}
245+
val notesByKey = allNotes.associateBy { it.key }
246+
val currentRoleStr = item.role.name.lowercase()
247+
248+
// Required notes for current phase
249+
val currentPhaseRequired = schema.filter { it.role == currentRoleStr && it.required }
250+
val missingForPhase = currentPhaseRequired.filter {
251+
val note = notesByKey[it.key]
252+
note == null || note.body.isBlank()
253+
}
254+
255+
// guidancePointer = guidance of first missing required note (use directly, no re-lookup)
256+
val guidancePointer = missingForPhase.firstOrNull()?.guidance
257+
258+
// noteProgress counts
259+
val filled = currentPhaseRequired.size - missingForPhase.size
260+
val remaining = missingForPhase.size
261+
val total = currentPhaseRequired.size
262+
263+
put(itemIdStr, buildJsonObject {
264+
if (guidancePointer != null) put("guidancePointer", JsonPrimitive(guidancePointer))
265+
else put("guidancePointer", JsonNull)
266+
put("noteProgress", buildJsonObject {
267+
put("filled", JsonPrimitive(filled))
268+
put("remaining", JsonPrimitive(remaining))
269+
put("total", JsonPrimitive(total))
270+
})
271+
})
272+
}
273+
}
274+
206275
val data = buildJsonObject {
207276
put("notes", JsonArray(upsertedNotes))
208277
put("upserted", JsonPrimitive(upsertedNotes.size))
209278
put("failed", JsonPrimitive(failures.size))
210279
if (failures.isNotEmpty()) {
211280
put("failures", JsonArray(failures))
212281
}
282+
put("itemContext", itemContextMap)
213283
}
214284

215285
return successResponse(data)

0 commit comments

Comments
 (0)