Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ It provides twenty-one tools:
- **`declare_work_item`** — attach the current session (and its continuation chain) to a work item. Pass exactly one of `issue_key` (a tracker key such as `"AI-1234"`, an issue number in the session's repository such as `"#123"`, a qualified `"owner/repo#123"`, or a GitHub issue URL), `pr_number`, `work_item_id`, or `new_title` (creates a brand-new work item).
- **`get_session_work_items`** — list the work items the current session is attached to.
- **`get_next_work`** — what the user should work on next: work others are waiting on, the user's own unfinished work (work items, interrupted sessions, loose ends) and new backlog. Rows take turns by the signal behind them, one row per signal per pass, in an order that depends on whether others work alongside the user: if they do, teammates blocked on the user, review requests and interrupted sessions lead; if not, loose ends and unfinished work items lead. Each row carries a because-clause, one line of evidence, and the target_key to pass to `dismiss_next_work`, inside a `<next-work-data>` block whose text is sanitised and marked as data, followed by a freshness line (when the feed was read, how current its tracker state is, and any source that was not current). `repo_hash` defaults to the repository the server runs in; `limit` defaults to 5, max 20. On a server with next-work off it answers "Next-work is not enabled on this server." The server's instructions tell the agent to call it first whenever the user asks what to work on next, or before it proposes new work.
- **`declare_loose_end`** — record one concrete piece of work this session leaves unfinished (`text`), so it appears in the user's next-work loose-ends ledger. Idempotent per session, owner and normalized text; the server refuses none-class text (`"none"`, `"n/a"`, …).
- **`declare_loose_end`** — record one concrete piece of work this session leaves unfinished (`text`), so it appears in the user's next-work loose-ends ledger. Idempotent per session, owner and normalized text; the server refuses none-class text (`"none"`, `"n/a"`, …). An optional `subject` (`PROJ-123`, `#123`, `owner/repo#123`, or a GitHub issue URL) names the issue the end is about; when the server has fetched it and it is already closed, the result tells the agent to check the remote before treating the end as unfinished.
- **`declare_work_breakdown`** — declare that a work item is broken into parts (`parent_id` + `part_ids`). Idempotent; a part has at most one parent, and every item must be visible to the caller — a part may live in a different repository than its parent.
- **`retract_work_breakdown`** — detach the named parts from the parent.
- **`declare_work_relation`** — declare a dependency between two items (`from_id`, `to_id`, `relation_kind` `"blocks"` or `"blocked_by"`). Both ends must be visible to the caller and may live in different repositories; no self-relation.
Expand Down
6 changes: 5 additions & 1 deletion kcap/skills/work-items/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ end twice is a no-op (`created: false`). It refuses text shorter than 12 or long
500 characters and "none"-style phrases — do not declare that there is nothing left.
Loose ends are the user's; they are never converted into work items by this tool.

Name the issue it is about with an optional `subject` (`PROJ-123`, `#123`, `owner/repo#123`,
or a GitHub issue URL). When the server fetches it and finds it already closed, the result
says so and tells you to check the remote before treating the end as unfinished.

When you finish a listed or declared loose end, close it with `close_loose_end`, naming it by
the `loose_end_id` that `get_next_work`'s evidence or `list_loose_ends` shows. A later sighting
of the same work reopens it on its own; `reopen_loose_end` undoes a mistaken close.
Expand Down Expand Up @@ -128,7 +132,7 @@ loose ends with `declare_loose_end` (one call per item, never "none"), then call
| `declare_work_item` | exactly one of `issue_key` \| `pr_number` \| `work_item_id` \| `new_title` | Attach the session to a work item (or create one). `session_id` defaults to the current session. |
| `get_session_work_items` | — | List what the current session is attached to. |
| `get_next_work` | — | What the user should work on next, ranked, with because-clauses and evidence. `repo_hash` defaults to the current repository; `limit` defaults to 5 (max 20). |
| `declare_loose_end` | `text` | Record one unfinished item in the user's next-work ledger. `session_id` defaults to the current session. |
| `declare_loose_end` | `text` | Record one unfinished item in the user's next-work ledger. `subject` optionally names the issue it is about. `session_id` defaults to the current session. |
| `list_loose_ends` | — | List loose ends with their `loose_end_id`: `status` open (default) or closed, `repo_hash` defaults to the current repository, `limit` 20 (max 50), `cursor` from `next_cursor`. |
| `close_loose_end` | `loose_end_id` | Mark a finished loose end done. `session_id` defaults to the current session. |
| `reopen_loose_end` | `loose_end_id` | Undo a close. |
Expand Down
6 changes: 4 additions & 2 deletions src/Capacitor.Cli.Core/Resources/help-mcp.txt
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,10 @@ mcp workitems:
defaults to this checkout; limit default 5,
max 20).
declare_loose_end Record one concrete piece of unfinished work
(text) in the user's next-work ledger; the
server refuses none-class text.
(text) in the user's next-work ledger, with an
optional subject issue key; the server refuses
none-class text and flags a subject already
closed in the tracker.
declare_work_breakdown Declare that a work item is broken into parts
(parent_id + part_ids); idempotent, one parent
per part, every item visible to the caller.
Expand Down
35 changes: 33 additions & 2 deletions src/Capacitor.Cli/Commands/McpWorkItemsServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ internal async Task<string> HandleToolCallAsync(
return BuildToolResult(id, $"Error: HTTP {(int)httpResponse.StatusCode} — {body}", isError: true);
}

if (toolName == "declare_loose_end") return BuildToolResult(id, FormatDeclareLooseEndResult(body));

return BuildToolResult(id, body);
} catch (OperationCanceledException) when (IsWorkItemEvalTool(toolName)) {
return BuildToolResult(id, WorkItemEvalToolResults.DeadlineMessage, isError: true);
Expand Down Expand Up @@ -805,8 +807,36 @@ internal static JsonObject BuildDetachBody(JsonObject? args) =>
new() { ["session_id"] = McpSessionId.Resolve(args) };

// Text bounds and the none-class rule stay the server's, so its 400 names the real reason.
internal static JsonObject BuildDeclareLooseEndBody(JsonObject? args) =>
new() { ["session_id"] = McpSessionId.Resolve(args), ["text"] = McpToolArguments.RequireString(args, "text") };
internal static JsonObject BuildDeclareLooseEndBody(JsonObject? args) {
var body = new JsonObject { ["session_id"] = McpSessionId.Resolve(args), ["text"] = McpToolArguments.RequireString(args, "text") };

if (McpToolArguments.OptionalString(args, "subject") is { } subject) body["subject"] = subject;

return body;
}

/// <summary>The declare response is always valid JSON. When the server reports the subject
/// already settled in the tracker, a <c>guidance</c> string is added alongside
/// <c>declaration_id</c> so the agent checks the remote instead of treating the item as live; any
/// other <c>subject_state</c>, or a body that does not parse as a JSON object, passes through
/// unchanged.</summary>
internal static string FormatDeclareLooseEndResult(string body) {
JsonNode? node;
try {
node = JsonNode.Parse(body);
} catch (JsonException) {
return body;
}

if (node is not JsonObject obj) return body;
if (obj["subject_state"] is not JsonValue stateValue || !stateValue.TryGetValue<string>(out var state) || state != "settled")
return body;

obj["guidance"] = "The subject issue is already closed in the tracker. Check the remote (fetch origin) "
+ "before treating this as unfinished; if it is done, close the loose end with close_loose_end.";

return obj.ToJsonString();
}

/// <summary>The session is optional context for the server, so a close outside any harness session
/// still goes through.</summary>
Expand Down Expand Up @@ -958,6 +988,7 @@ internal static McpTool[] BuildToolsList() => [
+ "plain text; do not declare 'none'. Requires a session: the current kcap-hooked one by default.",
new("object", new() {
["text"] = new("string", "The unfinished work, as one plain-text sentence; the server accepts 12-500 characters after normalizing whitespace and case."),
["subject"] = new("string", "The issue this loose end is about (PROJ-123, #123, owner/repo#123 or a GitHub issue URL). Name it when the end is a specific issue."),
["session_id"] = new("string", "Session id to declare against. Defaults to the session this server runs in when omitted.")
}, ["text"]), McpToolAnnotations.Upsert),

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Capacitor.Cli.Commands;
using Capacitor.Cli.Core;
Expand Down Expand Up @@ -165,6 +166,40 @@ public async Task Loose_end_body_carries_session_id_and_text() {
await Assert.That(body.ToJsonString()).IsEqualTo("""{"session_id":"s1","text":"Add the retry test"}""");
}

[Test]
public async Task Loose_end_body_carries_an_optional_subject() {
var body = McpWorkItemsServer.BuildDeclareLooseEndBody(
Args("""{"session_id":"s1","text":"Fix the fixtures","subject":"#1250"}"""));

await Assert.That(body["subject"]!.GetValue<string>()).IsEqualTo("#1250");
}

[Test]
public async Task A_settled_subject_adds_a_guidance_field_and_stays_valid_json() {
var text = McpWorkItemsServer.FormatDeclareLooseEndResult(
"""{"declaration_id":"d","created":true,"subject":"github:o/r#1","subject_state":"settled"}""");

using var doc = JsonDocument.Parse(text);
await Assert.That(doc.RootElement.GetProperty("declaration_id").GetString()).IsEqualTo("d");
await Assert.That(doc.RootElement.GetProperty("guidance").GetString()).Contains("already closed");
}

[Test]
public async Task An_open_or_absent_subject_state_leaves_the_result_unchanged() {
const string open = """{"declaration_id":"d","created":true,"subject":"github:o/r#1","subject_state":"open"}""";
const string none = """{"declaration_id":"d","created":true}""";

await Assert.That(McpWorkItemsServer.FormatDeclareLooseEndResult(open)).IsEqualTo(open);
await Assert.That(McpWorkItemsServer.FormatDeclareLooseEndResult(none)).IsEqualTo(none);
}

[Test]
public async Task A_non_object_body_passes_through_unchanged() {
const string arrayBody = """[1,2,3]""";

await Assert.That(McpWorkItemsServer.FormatDeclareLooseEndResult(arrayBody)).IsEqualTo(arrayBody);
}

[Test]
public async Task Loose_end_body_requires_text() {
await Assert.That(() => McpWorkItemsServer.BuildDeclareLooseEndBody(Args("""{"session_id":"s1"}""")))
Expand Down Expand Up @@ -266,12 +301,18 @@ await Assert.That(() => McpWorkItemsServer.BuildDeclareLooseEndBody(Args("""{"se
.Throws<ArgumentException>().WithMessageContaining("session_id");
}

[Test]
public async Task Loose_end_body_rejects_a_non_string_subject_as_a_field_error() {
await Assert.That(() => McpWorkItemsServer.BuildDeclareLooseEndBody(Args("""{"session_id":"s1","text":"Add the retry test","subject":123}""")))
.Throws<ArgumentException>().WithMessageContaining("subject");
}

[Test]
public async Task Declare_loose_end_requires_text_and_defaults_the_session() {
var tool = McpWorkItemsServer.BuildToolsList().Single(t => t.Name == "declare_loose_end");

await Assert.That(tool.InputSchema.Required).IsEquivalentTo(new[] { "text" });
await Assert.That(tool.InputSchema.Properties.Keys).IsEquivalentTo(new[] { "text", "session_id" });
await Assert.That(tool.InputSchema.Properties.Keys).IsEquivalentTo(new[] { "text", "subject", "session_id" });
}

[Test]
Expand Down
Loading