dctracker 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dct/__init__.py +6 -0
- dct/cli.py +1659 -0
- dct/config.py +150 -0
- dct/core/__init__.py +0 -0
- dct/core/analytics.py +382 -0
- dct/core/changelog.py +112 -0
- dct/core/checkpoints.py +205 -0
- dct/core/decisions.py +238 -0
- dct/core/engine.py +32 -0
- dct/core/handoff.py +151 -0
- dct/core/ids.py +25 -0
- dct/core/items.py +382 -0
- dct/core/plans/__init__.py +6 -0
- dct/core/plans/classify.py +94 -0
- dct/core/plans/crud.py +680 -0
- dct/core/plans/ingest.py +408 -0
- dct/core/plans/parser.py +312 -0
- dct/core/projects.py +298 -0
- dct/core/sprint.py +315 -0
- dct/core/sprints.py +601 -0
- dct/core/version.py +116 -0
- dct/db.py +558 -0
- dct/export.py +107 -0
- dct/hooks/check-changelog-on-stop.sh +49 -0
- dct/hooks/check-changelog.sh +143 -0
- dct/hooks/dct-plan-autoscan.sh +54 -0
- dct/hooks/dct-task-mirror.sh +62 -0
- dct/server.py +1812 -0
- dct/setup.py +258 -0
- dct/skills/clog/SKILL.md +73 -0
- dct/skills/commit/SKILL.md +153 -0
- dct/skills/dct-import/SKILL.md +329 -0
- dct/skills/dct-init/SKILL.md +289 -0
- dct/skills/handoff/SKILL.md +136 -0
- dct/skills/pickup/SKILL.md +115 -0
- dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
- dct/skills/plan-status/SKILL.md +79 -0
- dct/skills/release/SKILL.md +281 -0
- dct/skills/track/SKILL.md +121 -0
- dct/skills/track-list/SKILL.md +134 -0
- dct/skills/track-resolve/SKILL.md +53 -0
- dct/skills/track-update/SKILL.md +109 -0
- dct/web/__init__.py +1 -0
- dct/web/app.py +83 -0
- dct/web/blueprints/__init__.py +5 -0
- dct/web/blueprints/analytics.py +34 -0
- dct/web/blueprints/changelog.py +40 -0
- dct/web/blueprints/decisions.py +23 -0
- dct/web/blueprints/events.py +69 -0
- dct/web/blueprints/handoffs.py +26 -0
- dct/web/blueprints/home.py +15 -0
- dct/web/blueprints/items.py +128 -0
- dct/web/blueprints/plans.py +53 -0
- dct/web/blueprints/projects.py +23 -0
- dct/web/blueprints/sprints.py +71 -0
- dct/web/changes.py +77 -0
- dct/web/serializers.py +109 -0
- dct/web/static/app.css +609 -0
- dct/web/static/app.js +62 -0
- dct/web/static/vendor/SOURCES.txt +10 -0
- dct/web/static/vendor/htmx.min.js +1 -0
- dct/web/static/vendor/uPlot.iife.min.js +2 -0
- dct/web/static/vendor/uPlot.min.css +1 -0
- dct/web/templates/analytics.html +63 -0
- dct/web/templates/base.html +106 -0
- dct/web/templates/changelog/list.html +23 -0
- dct/web/templates/decisions/list.html +22 -0
- dct/web/templates/handoffs/list.html +45 -0
- dct/web/templates/home.html +20 -0
- dct/web/templates/item_detail.html +91 -0
- dct/web/templates/items_list.html +44 -0
- dct/web/templates/partials/_bars.html +15 -0
- dct/web/templates/partials/_checkpoint_steps.html +22 -0
- dct/web/templates/partials/_items_table.html +23 -0
- dct/web/templates/partials/_plan_section.html +24 -0
- dct/web/templates/partials/_project_card.html +24 -0
- dct/web/templates/plans/detail.html +16 -0
- dct/web/templates/plans/list.html +15 -0
- dct/web/templates/project_home.html +51 -0
- dct/web/templates/sprints/detail.html +37 -0
- dct/web/templates/sprints/list.html +35 -0
- dctracker-1.0.0.dist-info/METADATA +357 -0
- dctracker-1.0.0.dist-info/RECORD +87 -0
- dctracker-1.0.0.dist-info/WHEEL +5 -0
- dctracker-1.0.0.dist-info/entry_points.txt +2 -0
- dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
- dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/server.py
ADDED
|
@@ -0,0 +1,1812 @@
|
|
|
1
|
+
"""FastMCP server for dct — exposes tracking operations as MCP tools.
|
|
2
|
+
|
|
3
|
+
Response format conventions:
|
|
4
|
+
- list_* tools return {"cols": [...], "rows": [[...]]} — a compact table.
|
|
5
|
+
`cols` carries full field names once; `rows` contains value arrays in the
|
|
6
|
+
same order. Short/fixed-width fields come first; long free-text (title,
|
|
7
|
+
description, entry, prompt) comes last so row-wise scanning stays legible.
|
|
8
|
+
- get_* tools return the full semantic dict for a single record.
|
|
9
|
+
- format="markdown" (opt-in) returns a human-readable table (lossy).
|
|
10
|
+
- format="dict" (opt-in) returns a list of full-key dicts.
|
|
11
|
+
|
|
12
|
+
Every tool returns JSON-native (dict / list / str). No double-encoded JSON.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
|
|
19
|
+
from dct.core.engine import bootstrap_engine
|
|
20
|
+
|
|
21
|
+
mcp = FastMCP(
|
|
22
|
+
"dct",
|
|
23
|
+
instructions=(
|
|
24
|
+
"Deterministic Context Tracker — cross-project work tracking.\n\n"
|
|
25
|
+
"RESOURCES:\n"
|
|
26
|
+
"- items: features, todos, issues, ideas, improvements (status: open/in_progress/resolved/wont_fix)\n"
|
|
27
|
+
"- checkpoints: acceptance criteria tied to items (status: pending/done/rejected) — ledger\n"
|
|
28
|
+
"- notes: append-only observations on items\n"
|
|
29
|
+
"- changelog_entries: shipped work, versioned via release_changelog\n"
|
|
30
|
+
"- session_handoffs: cross-session prompts / context drafts (ledger)\n\n"
|
|
31
|
+
"RESPONSE FORMAT:\n"
|
|
32
|
+
"- list_* tools return {cols: [...], rows: [[...]]} — compact tabular form.\n"
|
|
33
|
+
" `cols` carries full field names once; rows are value arrays in the same order.\n"
|
|
34
|
+
" Short/fixed-width fields come first; long free-text (title, description, entry, prompt) last.\n"
|
|
35
|
+
"- Item cols legend: id, item_type, priority, status, in_current_sprint,\n"
|
|
36
|
+
" cp=[done,total] checkpoint progress, component, tags, created_at, title, description.\n"
|
|
37
|
+
"- Changelog cols legend: id, version, category, component, related_item_id,\n"
|
|
38
|
+
" created_at, released_at, entry.\n"
|
|
39
|
+
"- Checkpoint cols legend: id, status, sort_order, text.\n"
|
|
40
|
+
"- Handoff cols legend: id, scope, created_by, created_at, consumed_at, prompt.\n"
|
|
41
|
+
"- get_item returns the full semantic dict with embedded notes, checkpoints, progress.\n"
|
|
42
|
+
"- On list_* tools, format='markdown' gives a human-readable table (lossy);\n"
|
|
43
|
+
" format='dict' gives a list of full-key dicts (use when you need to hand the\n"
|
|
44
|
+
" data to a downstream tool that expects the classic shape).\n\n"
|
|
45
|
+
"WORKFLOW FOR COMPLEX TASKS:\n"
|
|
46
|
+
"1. list_items or get_item to find/inspect.\n"
|
|
47
|
+
"2. Item's checkpoints = your TODO list (work in sort_order).\n"
|
|
48
|
+
"3. REMEMBER the id returned by create_item, add_checkpoint, create_handoff.\n"
|
|
49
|
+
"4. For feature/improvement items: sketch 3-7 checkpoints with add_checkpoint\n"
|
|
50
|
+
" BEFORE starting code. This makes progress trackable.\n"
|
|
51
|
+
"5. complete_checkpoint(id) as each step finishes — real-time, NOT batched.\n"
|
|
52
|
+
"6. Irrelevant checkpoint → reject_checkpoint (ledger-preserving); never hard delete.\n"
|
|
53
|
+
"7. After all checkpoints done/rejected → resolve_item (no auto-transition).\n\n"
|
|
54
|
+
"SPRINT:\n"
|
|
55
|
+
"- set_sprint(project, item_ids='22,21,6') atomically replaces the current sprint.\n"
|
|
56
|
+
"- review_sprint(project, since='last_release') — start/end-of-session dashboard.\n"
|
|
57
|
+
"- resolve_item auto-clears in_current_sprint (sprint = active work only).\n"
|
|
58
|
+
"- Lifecycle: planned|active|deferred|completed|abandoned. update_sprint(sprint_id,\n"
|
|
59
|
+
" status=...) drives whitelisted transitions: park an unfinished sprint with\n"
|
|
60
|
+
" status='deferred' (keeps started_at, frees the one-active-per-project slot),\n"
|
|
61
|
+
" reactivate it with status='active'. Never mark an unfinished sprint\n"
|
|
62
|
+
" completed/abandoned — defer it.\n\n"
|
|
63
|
+
"HANDOFFS:\n"
|
|
64
|
+
"- create_handoff(prompt, created_by, scope?) — leave note for next session.\n"
|
|
65
|
+
"- Multiple unconsumed per project allowed (parallel sessions / different scopes).\n"
|
|
66
|
+
"- consume_handoff(id, consumed_by?) marks it read but preserves the row.\n\n"
|
|
67
|
+
"TOOL-CALL HYGIENE:\n"
|
|
68
|
+
"- Pass every argument as its own separate top-level parameter. Tools with\n"
|
|
69
|
+
" several prose fields (create_decision: context/decision/consequences/\n"
|
|
70
|
+
" alternatives; create_handoff: prompt) expect each in its own parameter —\n"
|
|
71
|
+
" never nest one field inside another, and never drop the parameter's\n"
|
|
72
|
+
" namespace prefix. Free-text tools reject literal tool-call markup and\n"
|
|
73
|
+
" explain the fix; a rejection means the call was mis-serialized — re-send\n"
|
|
74
|
+
" with the fields split out.\n\n"
|
|
75
|
+
"ALL tables are soft-delete only (ledger). Nothing is ever hard-deleted."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
_engine = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get_engine():
|
|
83
|
+
global _engine
|
|
84
|
+
if _engine is None:
|
|
85
|
+
_engine = bootstrap_engine(create=True)
|
|
86
|
+
return _engine
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_project(project_arg: str, required: bool = True) -> str | None:
|
|
90
|
+
"""Auto-detect project from CWD when project_arg is empty.
|
|
91
|
+
|
|
92
|
+
Each CC session spawns its own MCP subprocess inheriting the session's CWD,
|
|
93
|
+
so `os.getcwd()` identifies the active project reliably.
|
|
94
|
+
"""
|
|
95
|
+
if project_arg:
|
|
96
|
+
return project_arg
|
|
97
|
+
import os
|
|
98
|
+
from dct.core.projects import get_project_by_path
|
|
99
|
+
|
|
100
|
+
cwd = os.getcwd()
|
|
101
|
+
p = get_project_by_path(_get_engine(), cwd)
|
|
102
|
+
if p:
|
|
103
|
+
return p["slug"]
|
|
104
|
+
if required:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"No project specified and CWD ({cwd}) is not a registered project. "
|
|
107
|
+
f"Pass project=<slug> or register with: dct project add <slug> <path>"
|
|
108
|
+
)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Tool-call markup guard (#240) ─────────────────────────────────────────────
|
|
113
|
+
# The model occasionally mis-serializes a tool call — drops the `antml:` prefix
|
|
114
|
+
# or nests a <parameter> tag inside another field's value. The harness parser
|
|
115
|
+
# then recognises only the well-formed parameters and folds the siblings into
|
|
116
|
+
# ONE free-text field as literal markup, so the structured fields arrive empty.
|
|
117
|
+
# Persisting that yields silently-corrupted rows (e.g. a decision whose
|
|
118
|
+
# decision/consequences/alternatives are NULL). We catch it at the MCP boundary
|
|
119
|
+
# and reject with a self-correcting message, so the model re-issues the call
|
|
120
|
+
# cleanly and nothing bad is stored. CLI args and plan-ingest markdown never
|
|
121
|
+
# produce this markup, so the guard is intentionally MCP-only (not in core).
|
|
122
|
+
_TOOLCALL_MARKERS = (
|
|
123
|
+
"<parameter>",
|
|
124
|
+
"<parameter name=",
|
|
125
|
+
"</parameter>",
|
|
126
|
+
"<invoke name=",
|
|
127
|
+
"</invoke>",
|
|
128
|
+
"antml:",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _reject_toolcall_markup(**fields) -> dict | None:
|
|
133
|
+
"""Return an {"error": ...} dict if any string field carries literal
|
|
134
|
+
tool-call markup, else None.
|
|
135
|
+
|
|
136
|
+
The presence of these tokens means the call was mangled in transit: sibling
|
|
137
|
+
parameters collapsed into one field as raw text. Returning the error makes
|
|
138
|
+
the tool fail loud (no row written) and tells the model how to retry.
|
|
139
|
+
Non-string / empty values are skipped. Matching is case-insensitive.
|
|
140
|
+
"""
|
|
141
|
+
for name, value in fields.items():
|
|
142
|
+
if not isinstance(value, str) or not value:
|
|
143
|
+
continue
|
|
144
|
+
low = value.lower()
|
|
145
|
+
for marker in _TOOLCALL_MARKERS:
|
|
146
|
+
if marker in low:
|
|
147
|
+
return {"error": (
|
|
148
|
+
f"Malformed MCP tool call: field '{name}' contains literal "
|
|
149
|
+
f"tool-call markup '{marker}'. Sibling parameters collapsed into "
|
|
150
|
+
f"one field as raw text, so structured fields were lost — nothing "
|
|
151
|
+
f"was saved. Re-issue the call passing each field as its own "
|
|
152
|
+
f"separate top-level parameter (with the antml: prefix); never "
|
|
153
|
+
f"nest parameter tags inside another field's value."
|
|
154
|
+
)}
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── Format helpers ────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _serialize_value(v):
|
|
162
|
+
if isinstance(v, datetime):
|
|
163
|
+
return v.isoformat()
|
|
164
|
+
return v
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _to_cols_rows(rows: list[dict], cols: list[str]) -> dict:
|
|
168
|
+
"""Render list of dicts as compact {cols, rows} table."""
|
|
169
|
+
return {
|
|
170
|
+
"cols": cols,
|
|
171
|
+
"rows": [[_serialize_value(r.get(c)) for c in cols] for r in rows],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _to_markdown(rows: list[dict], cols: list[str]) -> str:
|
|
176
|
+
header = "| " + " | ".join(cols) + " |"
|
|
177
|
+
sep = "|" + "|".join("---" for _ in cols) + "|"
|
|
178
|
+
if not rows:
|
|
179
|
+
return header + "\n" + sep + "\n_(empty)_"
|
|
180
|
+
lines = [header, sep]
|
|
181
|
+
for r in rows:
|
|
182
|
+
cells = []
|
|
183
|
+
for c in cols:
|
|
184
|
+
v = r.get(c)
|
|
185
|
+
if isinstance(v, datetime):
|
|
186
|
+
v = v.isoformat()
|
|
187
|
+
if v is None:
|
|
188
|
+
v = ""
|
|
189
|
+
cells.append(str(v).replace("|", "\\|").replace("\n", " "))
|
|
190
|
+
lines.append("| " + " | ".join(cells) + " |")
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _format_list(rows: list[dict], cols: list[str], format: str):
|
|
195
|
+
if format == "markdown":
|
|
196
|
+
return _to_markdown(rows, cols)
|
|
197
|
+
if format == "dict":
|
|
198
|
+
return rows
|
|
199
|
+
return _to_cols_rows(rows, cols)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# Column sets — short/fixed-width fields first, long free-text last.
|
|
203
|
+
ITEM_COLS_DEFAULT = [
|
|
204
|
+
"id", "item_type", "priority", "status", "in_current_sprint",
|
|
205
|
+
"cp", "component", "tags", "created_at", "title", "description",
|
|
206
|
+
]
|
|
207
|
+
CHANGELOG_COLS = [
|
|
208
|
+
"id", "version", "category", "component", "related_item_id",
|
|
209
|
+
"created_at", "released_at", "entry",
|
|
210
|
+
]
|
|
211
|
+
CHECKPOINT_COLS = ["id", "status", "sort_order", "text"]
|
|
212
|
+
PROJECT_COLS = ["id", "slug", "current_version", "name", "path", "changelog_path"]
|
|
213
|
+
HANDOFF_COLS = [
|
|
214
|
+
"id", "scope", "created_by", "created_at", "consumed_at", "prompt",
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_bool_tri(val: str) -> bool | None:
|
|
219
|
+
"""Tri-state bool for MCP string params.
|
|
220
|
+
|
|
221
|
+
Empty string → None (no filter / no change).
|
|
222
|
+
Truthy tokens ('true', '1', 'yes') → True.
|
|
223
|
+
Anything else → False.
|
|
224
|
+
|
|
225
|
+
Accept multiple tokens because LLM callers vary by training regime.
|
|
226
|
+
Docstrings surfacing this param should list the canonical tokens
|
|
227
|
+
('true' / 'false') — the function is permissive but the contract is narrow.
|
|
228
|
+
"""
|
|
229
|
+
if not val:
|
|
230
|
+
return None
|
|
231
|
+
return val.lower() in ("true", "1", "yes")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── Project tools ─────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@mcp.tool()
|
|
238
|
+
def list_projects(format: str = "structured"):
|
|
239
|
+
"""List all registered projects. No filtering; no CWD auto-detection —
|
|
240
|
+
this is a discovery tool, not a scope-narrowing one. Use
|
|
241
|
+
`get_project_by_path` (via CLI) or CWD prefix matching if you need the
|
|
242
|
+
current project's slug.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
format: "structured" (default, {cols,rows}) | "markdown" | "dict".
|
|
246
|
+
|
|
247
|
+
Returns: table of all non-deleted projects with {id, slug, current_version,
|
|
248
|
+
name, path, changelog_path}, shaped per `format`. changelog_path (#225)
|
|
249
|
+
is the changelog file location relative to the project root (default
|
|
250
|
+
'changelog.md'); /release resolves {path}/{changelog_path}.
|
|
251
|
+
"""
|
|
252
|
+
from dct.core.projects import list_projects as _list
|
|
253
|
+
rows = _list(_get_engine())
|
|
254
|
+
return _format_list(rows, PROJECT_COLS, format)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Item tools ────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@mcp.tool()
|
|
261
|
+
def create_item(
|
|
262
|
+
item_type: str,
|
|
263
|
+
title: str,
|
|
264
|
+
project: str = "",
|
|
265
|
+
description: str = "",
|
|
266
|
+
priority: str = "P2",
|
|
267
|
+
component: str = "",
|
|
268
|
+
source_file: str = "",
|
|
269
|
+
source_lines: str = "",
|
|
270
|
+
tags: list[str] | None = None,
|
|
271
|
+
) -> dict:
|
|
272
|
+
"""Create a tracked item (issue / todo / feature / idea / improvement).
|
|
273
|
+
|
|
274
|
+
REMEMBER the returned `id` — you need it for add_checkpoint / update_item /
|
|
275
|
+
resolve_item / add_note.
|
|
276
|
+
|
|
277
|
+
For feature/improvement items: immediately sketch 3-7 checkpoints with
|
|
278
|
+
add_checkpoint before starting work.
|
|
279
|
+
|
|
280
|
+
TYPE SELECTION (natural-language phrases → item_type):
|
|
281
|
+
- issue: "problem", "bug", "crash", "error", "broken", "doesn't work",
|
|
282
|
+
"regression" — observable defect in existing code
|
|
283
|
+
- todo: "task", "action item", "need to", "must do" — generic work
|
|
284
|
+
that is neither a bug nor a new user-visible capability
|
|
285
|
+
- feature: "add X", "new capability", "let's build", "we should have" —
|
|
286
|
+
new user-visible capability that doesn't exist yet
|
|
287
|
+
- idea: "maybe", "what if", "consider", "brainstorm", "explore" —
|
|
288
|
+
exploratory, not yet decided to build
|
|
289
|
+
- improvement: "refactor", "polish", "cleanup", "tidy up", "make X nicer",
|
|
290
|
+
"simplify" — existing thing works but could be better
|
|
291
|
+
|
|
292
|
+
When in doubt, pick `todo` (neutral bucket). Do not ask the user about
|
|
293
|
+
type unless the phrase is genuinely ambiguous.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
item_type: One of: issue / todo / feature / idea / improvement.
|
|
297
|
+
title: Short title (≤ 80 chars).
|
|
298
|
+
project: Project slug. Empty = auto-detect from CWD.
|
|
299
|
+
description: Prose context (no checkbox lists — use add_checkpoint instead).
|
|
300
|
+
priority: P1 (blocker) / P2 (important, default) / P3 (backlog) / future.
|
|
301
|
+
component: Component or module name.
|
|
302
|
+
source_file: Relative file path.
|
|
303
|
+
source_lines: Line range (e.g. "42-58").
|
|
304
|
+
tags: List of tag strings. Pass as a JSON array, e.g. ["oss","tests"].
|
|
305
|
+
"""
|
|
306
|
+
if (err := _reject_toolcall_markup(title=title, description=description)):
|
|
307
|
+
return err
|
|
308
|
+
from dct.core.items import create_item as _create
|
|
309
|
+
|
|
310
|
+
kwargs: dict = {}
|
|
311
|
+
if description: kwargs["description"] = description
|
|
312
|
+
if component: kwargs["component"] = component
|
|
313
|
+
if source_file: kwargs["source_file"] = source_file
|
|
314
|
+
if source_lines: kwargs["source_lines"] = source_lines
|
|
315
|
+
if tags: kwargs["tags"] = tags
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
project_slug = _resolve_project(project, required=True)
|
|
319
|
+
assert project_slug is not None
|
|
320
|
+
return _create(_get_engine(), project_slug, item_type, title, priority=priority, **kwargs)
|
|
321
|
+
except Exception as e:
|
|
322
|
+
return {"error": str(e)}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@mcp.tool()
|
|
326
|
+
def list_items(
|
|
327
|
+
project: str = "",
|
|
328
|
+
status: str = "open",
|
|
329
|
+
item_type: str = "",
|
|
330
|
+
priority: str = "",
|
|
331
|
+
component: str = "",
|
|
332
|
+
in_current_sprint: str = "",
|
|
333
|
+
search: str = "",
|
|
334
|
+
limit: int = 50,
|
|
335
|
+
format: str = "structured",
|
|
336
|
+
):
|
|
337
|
+
"""List tracked items in compact {cols,rows} form.
|
|
338
|
+
|
|
339
|
+
Each row carries a `cp` field with [done, total] checkpoint progress
|
|
340
|
+
computed in a single SQL query (no N+1).
|
|
341
|
+
|
|
342
|
+
PROJECT RESOLUTION — IMPORTANT:
|
|
343
|
+
- `project="<slug>"` — filter to that project (recommended).
|
|
344
|
+
- `project=""` AND CWD is a registered project — filter to that project.
|
|
345
|
+
- `project=""` AND CWD is NOT a registered project — returns items
|
|
346
|
+
ACROSS ALL PROJECTS. This is intentional for cross-project queries
|
|
347
|
+
but can surprise callers expecting project-scoped output. When in
|
|
348
|
+
doubt pass an explicit `project` slug.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
project: Slug. Empty = CWD auto-detect; falls back to all projects
|
|
352
|
+
when CWD is unregistered (see PROJECT RESOLUTION above).
|
|
353
|
+
status: Filter by status. Default 'open'. Use '' for all statuses.
|
|
354
|
+
item_type: issue / todo / feature / idea / improvement.
|
|
355
|
+
priority: P1 / P2 / P3 / future.
|
|
356
|
+
component: Filter by component name.
|
|
357
|
+
in_current_sprint: '' (any, default), 'true' (only sprint), 'false' (non-sprint).
|
|
358
|
+
search: Substring in title + description.
|
|
359
|
+
limit: Max rows (default 50).
|
|
360
|
+
format: 'structured' (default, {cols,rows}), 'markdown', 'dict'.
|
|
361
|
+
|
|
362
|
+
Returns: {"cols": [...], "rows": [[...]]} by default, list[dict] for
|
|
363
|
+
format="dict", or markdown str for format="markdown".
|
|
364
|
+
"""
|
|
365
|
+
from dct.core.items import list_items as _list
|
|
366
|
+
|
|
367
|
+
kwargs: dict = {"limit": limit}
|
|
368
|
+
detected = _resolve_project(project, required=False)
|
|
369
|
+
if detected: kwargs["project_slug"] = detected
|
|
370
|
+
if status: kwargs["status"] = status
|
|
371
|
+
if item_type: kwargs["item_type"] = item_type
|
|
372
|
+
if priority: kwargs["priority"] = priority
|
|
373
|
+
if component: kwargs["component"] = component
|
|
374
|
+
if search: kwargs["search"] = search
|
|
375
|
+
sprint_filter = _parse_bool_tri(in_current_sprint)
|
|
376
|
+
if sprint_filter is not None:
|
|
377
|
+
kwargs["in_current_sprint"] = sprint_filter
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
rows = _list(_get_engine(), **kwargs)
|
|
381
|
+
return _format_list(rows, ITEM_COLS_DEFAULT, format)
|
|
382
|
+
except Exception as e:
|
|
383
|
+
return {"error": str(e)}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@mcp.tool()
|
|
387
|
+
def get_item(item_id: int) -> dict:
|
|
388
|
+
"""Get full item details — fields, notes, checkpoints, progress.
|
|
389
|
+
|
|
390
|
+
CALL THIS FIRST when resuming work — the checkpoints list is your TODO plan
|
|
391
|
+
(pending = to do, done = skip, rejected = abandoned).
|
|
392
|
+
|
|
393
|
+
Returns a full semantic dict (not cols/rows) — single record is legible.
|
|
394
|
+
"""
|
|
395
|
+
from dct.core.items import get_item as _get
|
|
396
|
+
|
|
397
|
+
result = _get(_get_engine(), item_id)
|
|
398
|
+
if result is None:
|
|
399
|
+
return {"error": f"Item #{item_id} not found"}
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@mcp.tool()
|
|
404
|
+
def update_item(
|
|
405
|
+
item_id: int,
|
|
406
|
+
title: str = "",
|
|
407
|
+
status: str = "",
|
|
408
|
+
priority: str = "",
|
|
409
|
+
component: str = "",
|
|
410
|
+
resolution: str = "",
|
|
411
|
+
description: str = "",
|
|
412
|
+
) -> dict:
|
|
413
|
+
"""Update one or more mutable fields on an existing item. Only non-empty
|
|
414
|
+
args are applied; an empty/omitted field is left unchanged.
|
|
415
|
+
|
|
416
|
+
Transitioning status to a terminal value (resolved/wont_fix) automatically
|
|
417
|
+
sets resolved_at AND removes the item from the current sprint (its
|
|
418
|
+
item_sprints links are soft-removed). Transitioning back to open/in_progress
|
|
419
|
+
clears resolved_at. NOTE: unlike `resolve_item`, this path does NOT run the
|
|
420
|
+
gate-checkpoint guard — prefer `resolve_item` for terminal transitions.
|
|
421
|
+
|
|
422
|
+
Sprint membership is NOT set here — `in_current_sprint` is a derived,
|
|
423
|
+
read-only field. Use set_sprint / assign_item_to_sprint /
|
|
424
|
+
remove_item_from_sprint to change membership.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
item_id: Target item.
|
|
428
|
+
title: New title (non-empty).
|
|
429
|
+
status: open / in_progress / resolved / wont_fix.
|
|
430
|
+
priority: P1 / P2 / P3 / future.
|
|
431
|
+
component: Component or module name.
|
|
432
|
+
resolution: Resolution / rationale note.
|
|
433
|
+
description: Prose context.
|
|
434
|
+
|
|
435
|
+
Clearing (nulling) a field — pass the literal '__clear__' to null out a
|
|
436
|
+
NULLABLE field: component, resolution, description. Do NOT pass the sentinel
|
|
437
|
+
for title/status/priority — title would be overwritten with an empty string
|
|
438
|
+
and status/priority would fail enum validation ({"error": ...}).
|
|
439
|
+
|
|
440
|
+
Returns: updated item dict (full get_item shape) or {"error": "..."}.
|
|
441
|
+
"""
|
|
442
|
+
if (err := _reject_toolcall_markup(title=title, description=description, resolution=resolution)):
|
|
443
|
+
return err
|
|
444
|
+
from dct.core.items import update_item as _update
|
|
445
|
+
|
|
446
|
+
kwargs: dict = {}
|
|
447
|
+
for field, value in [
|
|
448
|
+
("title", title), ("status", status), ("priority", priority),
|
|
449
|
+
("component", component), ("resolution", resolution), ("description", description),
|
|
450
|
+
]:
|
|
451
|
+
if value == "__clear__":
|
|
452
|
+
kwargs[field] = ""
|
|
453
|
+
elif value:
|
|
454
|
+
kwargs[field] = value
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
result = _update(_get_engine(), item_id, **kwargs)
|
|
458
|
+
return result or {"error": f"Item #{item_id} not found"}
|
|
459
|
+
except Exception as e:
|
|
460
|
+
return {"error": str(e)}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@mcp.tool()
|
|
464
|
+
def resolve_item(item_id: int, status: str = "resolved", resolution: str = "") -> dict:
|
|
465
|
+
"""Resolve or close an item (terminal status). Auto-clears in_current_sprint
|
|
466
|
+
and stamps resolved_at.
|
|
467
|
+
|
|
468
|
+
GATE GUARD: if the item has any pending gate checkpoint (kind not in
|
|
469
|
+
{'task','cc-task'} — e.g. 'dogfood', 'security', 'approval', 'review'), the
|
|
470
|
+
transition is REFUSED and the tool returns
|
|
471
|
+
{"error": "Cannot resolve item #N: ... gate checkpoint(s) pending ..."}.
|
|
472
|
+
Complete (done) or reject (not applicable) those gates first.
|
|
473
|
+
|
|
474
|
+
PREFER THIS OVER update_item FOR TERMINAL TRANSITIONS — explicit intent, and
|
|
475
|
+
it runs the gate guard above. update_item(status="resolved") also stamps
|
|
476
|
+
resolved_at and clears the sprint, but SKIPS the gate check.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
item_id: Item ID.
|
|
480
|
+
status: resolved (default) or wont_fix. Any other value is rejected.
|
|
481
|
+
resolution: Free-text resolution note (recommended — feeds audit trail).
|
|
482
|
+
|
|
483
|
+
Returns: full updated item dict (get_item shape), or {"error": "..."} when
|
|
484
|
+
the item is missing or a pending gate blocks the transition.
|
|
485
|
+
"""
|
|
486
|
+
from dct.core.items import resolve_item as _resolve
|
|
487
|
+
try:
|
|
488
|
+
result = _resolve(_get_engine(), item_id, status=status, resolution=resolution or None)
|
|
489
|
+
return result or {"error": f"Item #{item_id} not found"}
|
|
490
|
+
except Exception as e:
|
|
491
|
+
return {"error": str(e)}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@mcp.tool()
|
|
495
|
+
def add_note(item_id: int, note: str) -> dict:
|
|
496
|
+
"""Append a note to an item (ledger — never overwritten).
|
|
497
|
+
|
|
498
|
+
Use for: discoveries while working, decisions, blockers, "fixed in commit X".
|
|
499
|
+
Do NOT use for: TODO steps (use add_checkpoint), status changes that have
|
|
500
|
+
their own rationale field (use update_item resolution=).
|
|
501
|
+
|
|
502
|
+
/track-update treats status/priority transitions as mandatory note points —
|
|
503
|
+
the audit trail is what lets /pickup explain "why #42 moved to P1".
|
|
504
|
+
|
|
505
|
+
Returns: note dict with new `id`, or {"error": "..."} if the item does not exist.
|
|
506
|
+
"""
|
|
507
|
+
if (err := _reject_toolcall_markup(note=note)):
|
|
508
|
+
return err
|
|
509
|
+
from dct.core.items import add_note as _add_note
|
|
510
|
+
try:
|
|
511
|
+
return _add_note(_get_engine(), item_id, note)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
return {"error": str(e)}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ── Checkpoint tools ──────────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@mcp.tool()
|
|
520
|
+
def add_checkpoint(item_id: int, text: str, kind: str = "task") -> dict:
|
|
521
|
+
"""Add an acceptance criterion (checkpoint) to an item.
|
|
522
|
+
|
|
523
|
+
USE LIBERALLY — one checkpoint per concrete, actionable step.
|
|
524
|
+
For features/improvements, sketch 3-7 checkpoints BEFORE starting work.
|
|
525
|
+
|
|
526
|
+
REMEMBER the returned `id` — needed for complete_checkpoint / reject_checkpoint.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
item_id: Target item.
|
|
530
|
+
text: Concrete, actionable step description.
|
|
531
|
+
kind: Checkpoint class. Default 'task' (non-blocking). Gate kinds that
|
|
532
|
+
BLOCK resolve_item until done/rejected:
|
|
533
|
+
'dogfood' — user-facing feature needs fresh-session validation
|
|
534
|
+
'security' — security review required before release
|
|
535
|
+
'approval' — external stakeholder sign-off
|
|
536
|
+
'review' — code review / PR approval
|
|
537
|
+
'cc-task' — mirrored Claude Code TaskCreate entry (non-blocking,
|
|
538
|
+
cleaned up on /handoff).
|
|
539
|
+
|
|
540
|
+
User-facing features (new skill, CLI command, MCP tool) MUST
|
|
541
|
+
include a final `kind='dogfood'` checkpoint. See CLAUDE.md
|
|
542
|
+
"Gate checkpoints" section.
|
|
543
|
+
"""
|
|
544
|
+
from dct.core.checkpoints import add_checkpoint as _add
|
|
545
|
+
try:
|
|
546
|
+
return _add(_get_engine(), item_id, text, kind=kind)
|
|
547
|
+
except Exception as e:
|
|
548
|
+
return {"error": str(e)}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@mcp.tool()
|
|
552
|
+
def complete_checkpoint(checkpoint_id: int) -> dict:
|
|
553
|
+
"""Mark a checkpoint as done (status 'done').
|
|
554
|
+
|
|
555
|
+
CRITICAL: call IMMEDIATELY after the step completes. Do NOT batch completions
|
|
556
|
+
at the end of a task — real-time updates give the user live progress visibility.
|
|
557
|
+
Completing a pending gate checkpoint (dogfood/security/approval/review)
|
|
558
|
+
satisfies it and clears that block on resolve_item.
|
|
559
|
+
|
|
560
|
+
Returns: the updated checkpoint dict, or {"error": "Checkpoint #<id> not found"}
|
|
561
|
+
when the id is unknown or already deleted.
|
|
562
|
+
"""
|
|
563
|
+
from dct.core.checkpoints import set_status
|
|
564
|
+
try:
|
|
565
|
+
result = set_status(_get_engine(), checkpoint_id, "done")
|
|
566
|
+
return result or {"error": f"Checkpoint #{checkpoint_id} not found"}
|
|
567
|
+
except Exception as e:
|
|
568
|
+
return {"error": str(e)}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@mcp.tool()
|
|
572
|
+
def reject_checkpoint(checkpoint_id: int) -> dict:
|
|
573
|
+
"""Mark a checkpoint as rejected (status 'rejected') — ledger-preserving
|
|
574
|
+
alternative to delete.
|
|
575
|
+
|
|
576
|
+
Use when scope changes or an initial assumption turns out wrong. Rejected
|
|
577
|
+
checkpoints drop out of the `active` count, so they no longer weigh on the
|
|
578
|
+
done/active progress percent. For a gate checkpoint (kind dogfood/security/
|
|
579
|
+
approval/review) rejecting is the sanctioned "not applicable" skip that
|
|
580
|
+
unblocks resolve_item.
|
|
581
|
+
|
|
582
|
+
Returns: the updated checkpoint dict, or {"error": "Checkpoint #<id> not found"}
|
|
583
|
+
when the id is unknown or already deleted.
|
|
584
|
+
"""
|
|
585
|
+
from dct.core.checkpoints import set_status
|
|
586
|
+
try:
|
|
587
|
+
result = set_status(_get_engine(), checkpoint_id, "rejected")
|
|
588
|
+
return result or {"error": f"Checkpoint #{checkpoint_id} not found"}
|
|
589
|
+
except Exception as e:
|
|
590
|
+
return {"error": str(e)}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@mcp.tool()
|
|
594
|
+
def reopen_checkpoint(checkpoint_id: int) -> dict:
|
|
595
|
+
"""Revert a checkpoint back to pending (status 'pending'), typically from done
|
|
596
|
+
or rejected. Re-opening a previously rejected gate makes it pending again and
|
|
597
|
+
re-blocks resolve_item.
|
|
598
|
+
|
|
599
|
+
Returns: the updated checkpoint dict, or {"error": "Checkpoint #<id> not found"}
|
|
600
|
+
when the id is unknown or already deleted.
|
|
601
|
+
"""
|
|
602
|
+
from dct.core.checkpoints import set_status
|
|
603
|
+
try:
|
|
604
|
+
result = set_status(_get_engine(), checkpoint_id, "pending")
|
|
605
|
+
return result or {"error": f"Checkpoint #{checkpoint_id} not found"}
|
|
606
|
+
except Exception as e:
|
|
607
|
+
return {"error": str(e)}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@mcp.tool()
|
|
611
|
+
def list_checkpoints(
|
|
612
|
+
item_id: int,
|
|
613
|
+
include_rejected: bool = False,
|
|
614
|
+
format: str = "structured",
|
|
615
|
+
):
|
|
616
|
+
"""List checkpoints for an item in sort_order.
|
|
617
|
+
|
|
618
|
+
Defaults hide rejected checkpoints — the typical caller wants the active
|
|
619
|
+
TODO list. Set include_rejected=True for full audit view. get_item(id)
|
|
620
|
+
always returns all checkpoints regardless of status (single-item inspect).
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
item_id: parent item id.
|
|
624
|
+
include_rejected: False (default) hides rejected; True returns all.
|
|
625
|
+
format: 'structured' (default, {cols,rows}) | 'markdown' | 'dict'.
|
|
626
|
+
|
|
627
|
+
Returns: {cols, rows} by default — cols = [id, status, sort_order, text].
|
|
628
|
+
"""
|
|
629
|
+
from dct.core.checkpoints import list_checkpoints as _list
|
|
630
|
+
try:
|
|
631
|
+
rows = _list(_get_engine(), item_id, include_rejected=include_rejected)
|
|
632
|
+
return _format_list(rows, CHECKPOINT_COLS, format)
|
|
633
|
+
except Exception as e:
|
|
634
|
+
return {"error": str(e)}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
# ── Changelog tools ───────────────────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@mcp.tool()
|
|
641
|
+
def add_changelog(
|
|
642
|
+
category: str,
|
|
643
|
+
entry: str,
|
|
644
|
+
project: str = "",
|
|
645
|
+
component: str = "",
|
|
646
|
+
source_file: str = "",
|
|
647
|
+
source_lines: str = "",
|
|
648
|
+
related_item_id: int = 0,
|
|
649
|
+
) -> dict:
|
|
650
|
+
"""Add an unreleased changelog entry (version stays NULL until release_changelog stamps it).
|
|
651
|
+
|
|
652
|
+
Each field below is a SEPARATE top-level parameter.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
category: One of added / changed / fixed / removed (rejected otherwise).
|
|
656
|
+
entry: The changelog line — required, non-empty. Rendered as a "- ..." bullet on export.
|
|
657
|
+
project: Slug. Empty = auto-detect from CWD.
|
|
658
|
+
component: Optional area/module tag; appended as " (component)" to the bullet on export.
|
|
659
|
+
source_file: Optional relative path of the file the change touched.
|
|
660
|
+
source_lines: Optional line range (e.g. "42-58").
|
|
661
|
+
related_item_id: Tracked-item id to link (0 = none).
|
|
662
|
+
|
|
663
|
+
Returns: the created entry dict (full row, incl. `id`, version=null), or
|
|
664
|
+
{"error": "..."} on failure — empty/whitespace entry, invalid category,
|
|
665
|
+
literal tool-call markup in `entry`, or an unresolvable project (explicit
|
|
666
|
+
slug not found, or empty `project` with an unregistered CWD).
|
|
667
|
+
"""
|
|
668
|
+
if (err := _reject_toolcall_markup(entry=entry)):
|
|
669
|
+
return err
|
|
670
|
+
from dct.core.changelog import add_entry
|
|
671
|
+
|
|
672
|
+
kwargs: dict = {}
|
|
673
|
+
if component: kwargs["component"] = component
|
|
674
|
+
if source_file: kwargs["source_file"] = source_file
|
|
675
|
+
if source_lines: kwargs["source_lines"] = source_lines
|
|
676
|
+
if related_item_id: kwargs["related_item_id"] = related_item_id
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
project_slug = _resolve_project(project, required=True)
|
|
680
|
+
assert project_slug is not None
|
|
681
|
+
return add_entry(_get_engine(), project_slug, category, entry, **kwargs)
|
|
682
|
+
except Exception as e:
|
|
683
|
+
return {"error": str(e)}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@mcp.tool()
|
|
687
|
+
def list_changelog(
|
|
688
|
+
project: str = "",
|
|
689
|
+
unreleased_only: bool = True,
|
|
690
|
+
format: str = "structured",
|
|
691
|
+
):
|
|
692
|
+
"""List changelog entries for a project.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
project: Slug. Empty = CWD auto-detect.
|
|
696
|
+
unreleased_only: True (default) → only entries without a version; False → all.
|
|
697
|
+
format: 'structured' (default, {cols,rows}) | 'markdown' | 'dict'.
|
|
698
|
+
|
|
699
|
+
Returns: {cols, rows} by default, list[dict] for 'dict', markdown str for 'markdown'.
|
|
700
|
+
"""
|
|
701
|
+
from dct.core.changelog import list_entries
|
|
702
|
+
try:
|
|
703
|
+
project_slug = _resolve_project(project, required=True)
|
|
704
|
+
assert project_slug is not None
|
|
705
|
+
rows = list_entries(_get_engine(), project_slug, unreleased_only=unreleased_only)
|
|
706
|
+
return _format_list(rows, CHANGELOG_COLS, format)
|
|
707
|
+
except Exception as e:
|
|
708
|
+
return {"error": str(e)}
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@mcp.tool()
|
|
712
|
+
def count_unreleased_changelog(project: str = "") -> dict:
|
|
713
|
+
"""Count unreleased changelog entries (version=NULL) for a project.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
project: Slug. Empty = auto-detect from CWD.
|
|
717
|
+
|
|
718
|
+
Returns: {"count": <int>} on success, {"error": "..."} on failure.
|
|
719
|
+
Typical use: gate before /release (stop if count == 0).
|
|
720
|
+
"""
|
|
721
|
+
from dct.core.changelog import count_unreleased
|
|
722
|
+
try:
|
|
723
|
+
project_slug = _resolve_project(project, required=True)
|
|
724
|
+
assert project_slug is not None
|
|
725
|
+
return {"count": count_unreleased(_get_engine(), project_slug)}
|
|
726
|
+
except Exception as e:
|
|
727
|
+
return {"error": str(e)}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@mcp.tool()
|
|
731
|
+
def release_changelog(project: str = "", version: str = "", bump_type: str = "") -> dict:
|
|
732
|
+
"""Stamp every unreleased entry (version=NULL) with a version AND set the
|
|
733
|
+
project's current_version to that version.
|
|
734
|
+
|
|
735
|
+
Provide either an explicit version OR a bump_type — explicit version wins.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
project: Slug. Empty = auto-detect from CWD.
|
|
739
|
+
version: Explicit version like "0.2.0". Takes precedence over bump_type.
|
|
740
|
+
bump_type: patch / minor / major — bumped from the project's current_version.
|
|
741
|
+
|
|
742
|
+
Side effect: also stamps released_at on every entry it versions, and writes
|
|
743
|
+
current_version onto the project row. current_version is bumped even when
|
|
744
|
+
there are zero unreleased entries (released = 0).
|
|
745
|
+
|
|
746
|
+
Returns: {"released": <count>, "version": "<version>", "project": "<slug>"}
|
|
747
|
+
on success (released = number of changelog entries stamped; the project
|
|
748
|
+
row update is not counted). On failure returns {"error": "..."}, e.g.:
|
|
749
|
+
neither version nor bump_type given; bump_type set but the project has no
|
|
750
|
+
current_version to bump from; an invalid bump_type (not patch/minor/major);
|
|
751
|
+
or the project can't be resolved (empty project + unregistered CWD, or an
|
|
752
|
+
unknown slug).
|
|
753
|
+
"""
|
|
754
|
+
from dct.core.changelog import release_entries
|
|
755
|
+
from dct.core.projects import get_project
|
|
756
|
+
from dct.core.version import bump
|
|
757
|
+
|
|
758
|
+
try:
|
|
759
|
+
engine = _get_engine()
|
|
760
|
+
project_slug = _resolve_project(project, required=True)
|
|
761
|
+
assert project_slug is not None
|
|
762
|
+
if not version:
|
|
763
|
+
if not bump_type:
|
|
764
|
+
return {"error": "Provide version or bump_type (patch/minor/major)"}
|
|
765
|
+
proj = get_project(engine, project_slug)
|
|
766
|
+
if not proj or not proj.get("current_version"):
|
|
767
|
+
return {"error": f"No current version for '{project_slug}'. Provide explicit version."}
|
|
768
|
+
version = bump(proj["current_version"], bump_type)
|
|
769
|
+
|
|
770
|
+
count = release_entries(engine, project_slug, version)
|
|
771
|
+
return {"released": count, "version": version, "project": project_slug}
|
|
772
|
+
except Exception as e:
|
|
773
|
+
return {"error": str(e)}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
@mcp.tool()
|
|
777
|
+
def export_changelog(project: str = "", include_unreleased: bool = True, version: str = ""):
|
|
778
|
+
"""Export changelog as Keep a Changelog markdown.
|
|
779
|
+
|
|
780
|
+
For /release commits, call with include_unreleased=False so the
|
|
781
|
+
committed changelog.md contains only released versions (DB stays the
|
|
782
|
+
source of truth for unreleased).
|
|
783
|
+
|
|
784
|
+
For the append-only /release flow, pass version="X.Y.Z" to render ONLY
|
|
785
|
+
that version's section (no `# Changelog` header). The skill prepends that
|
|
786
|
+
section into the existing changelog.md instead of regenerating the whole
|
|
787
|
+
file — regeneration drops pre-adoption history for projects that adopted
|
|
788
|
+
dct mid-life (item #214). An unknown version renders to "".
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
project: Slug. Empty = CWD auto-detect.
|
|
792
|
+
include_unreleased: If True (default), include [Unreleased] section at
|
|
793
|
+
top. Ignored when `version` is set (single-version render never
|
|
794
|
+
includes Unreleased).
|
|
795
|
+
version: When non-empty, render only that version's `## [version]`
|
|
796
|
+
section, headerless, for the append-only prepend flow.
|
|
797
|
+
|
|
798
|
+
Returns: markdown str on success, {"error": "..."} dict on failure —
|
|
799
|
+
aligned with every other MCP tool in this server. Callers should
|
|
800
|
+
`isinstance(result, dict)` check before writing to file.
|
|
801
|
+
"""
|
|
802
|
+
from dct.export import export_changelog_md
|
|
803
|
+
try:
|
|
804
|
+
project_slug = _resolve_project(project, required=True)
|
|
805
|
+
assert project_slug is not None
|
|
806
|
+
return export_changelog_md(
|
|
807
|
+
_get_engine(), project_slug,
|
|
808
|
+
include_unreleased=include_unreleased, version=version or None,
|
|
809
|
+
)
|
|
810
|
+
except Exception as e:
|
|
811
|
+
return {"error": str(e)}
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# ── Sprint tools ──────────────────────────────────────────────────────────────
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@mcp.tool()
|
|
818
|
+
def set_sprint(project: str = "", item_ids: str = "") -> dict:
|
|
819
|
+
"""Atomically replace the current sprint's membership with the given ids.
|
|
820
|
+
|
|
821
|
+
The current sprint is the project's single active sprint (auto-created if
|
|
822
|
+
none exists). Membership lives in item_sprints — assigns the listed ids and
|
|
823
|
+
soft-removes the rest. Ids that aren't live items of this project (foreign,
|
|
824
|
+
unknown, or soft-deleted) are silently dropped, so the returned `size` /
|
|
825
|
+
`items` may be smaller than the ids you passed. **There is no "no-op" call —
|
|
826
|
+
`item_ids=""` WIPES the sprint** (equivalent to `clear_sprint`). If you want
|
|
827
|
+
to keep the current sprint, don't call this tool.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
project: Slug. Empty = auto-detect from CWD.
|
|
831
|
+
item_ids: Comma-separated ids (e.g. "22,21,6"). Empty string clears sprint.
|
|
832
|
+
|
|
833
|
+
Returns: {"project": "<slug>", "size": <int>, "items": [valid ids, sorted]}.
|
|
834
|
+
Error: {"error": "..."} on project resolution failure.
|
|
835
|
+
"""
|
|
836
|
+
from dct.core.sprint import set_sprint as _set
|
|
837
|
+
|
|
838
|
+
try:
|
|
839
|
+
project_slug = _resolve_project(project, required=True)
|
|
840
|
+
assert project_slug is not None
|
|
841
|
+
ids = [int(x.strip()) for x in item_ids.split(",") if x.strip()] if item_ids else []
|
|
842
|
+
return _set(_get_engine(), project_slug, ids)
|
|
843
|
+
except Exception as e:
|
|
844
|
+
return {"error": str(e)}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@mcp.tool()
|
|
848
|
+
def clear_sprint(project: str = "") -> dict:
|
|
849
|
+
"""Unflag every item from the project's current sprint (fresh planning).
|
|
850
|
+
|
|
851
|
+
Soft-removes every member of the active sprint; the sprint row itself stays
|
|
852
|
+
active and empty. No-op (cleared=0) when the project has no current sprint.
|
|
853
|
+
|
|
854
|
+
Args:
|
|
855
|
+
project: Slug. Empty = auto-detect from CWD (NOT all-projects).
|
|
856
|
+
|
|
857
|
+
Returns: {"project": "<slug>", "cleared": <int>} — count of memberships
|
|
858
|
+
soft-removed. Error: {"error": "..."} on project resolution failure.
|
|
859
|
+
"""
|
|
860
|
+
from dct.core.sprint import clear_sprint as _clear
|
|
861
|
+
try:
|
|
862
|
+
project_slug = _resolve_project(project, required=True)
|
|
863
|
+
assert project_slug is not None
|
|
864
|
+
return _clear(_get_engine(), project_slug)
|
|
865
|
+
except Exception as e:
|
|
866
|
+
return {"error": str(e)}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
@mcp.tool()
|
|
870
|
+
def review_sprint(project: str = "", since: str = "last_release") -> dict:
|
|
871
|
+
"""Session start/end dashboard — in_sprint, recent_activity, stale_in_progress, unconsumed_handoffs.
|
|
872
|
+
|
|
873
|
+
Returns structured data only. Interpretation (prioritization logic, "what to
|
|
874
|
+
do next", etc.) lives in the consuming skill — /pickup (session start) and
|
|
875
|
+
/handoff (session end) — so it can iterate without a schema change.
|
|
876
|
+
|
|
877
|
+
Args:
|
|
878
|
+
project: Slug. Empty = CWD auto-detect.
|
|
879
|
+
since: Accepted grammar —
|
|
880
|
+
- "last_release" (default) — anchor = max(released_at); falls back
|
|
881
|
+
to all-time when the project has no releases yet.
|
|
882
|
+
- "all" — no time cutoff, everything.
|
|
883
|
+
- "<N>h" where N is one-or-more digits — hours relative to now. Only
|
|
884
|
+
the hour suffix is supported (no "d"/"w"/"m"); e.g. "24h".
|
|
885
|
+
- ISO date / datetime — "2026-04-10" or "2026-04-10T14:00", optional
|
|
886
|
+
timezone (trailing "Z" accepted). Parsed via datetime.fromisoformat.
|
|
887
|
+
|
|
888
|
+
Returns: dashboard dict with keys project, since, since_anchor (human
|
|
889
|
+
label), in_sprint, recent_activity, stale_in_progress,
|
|
890
|
+
unconsumed_handoffs, summary. in_sprint / stale_in_progress /
|
|
891
|
+
unconsumed_handoffs are {cols, rows} tables; recent_activity is a dict
|
|
892
|
+
{items_changed: {cols,rows}, checkpoints_completed: int,
|
|
893
|
+
changelog_added: int}; summary is a scalar dict. Error: {"error": "..."}
|
|
894
|
+
on invalid `since`.
|
|
895
|
+
"""
|
|
896
|
+
from dct.core.sprint import get_sprint_review
|
|
897
|
+
try:
|
|
898
|
+
project_slug = _resolve_project(project, required=True)
|
|
899
|
+
assert project_slug is not None
|
|
900
|
+
return get_sprint_review(_get_engine(), project_slug, since=since)
|
|
901
|
+
except Exception as e:
|
|
902
|
+
return {"error": str(e)}
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# ── Handoff tools ─────────────────────────────────────────────────────────────
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
@mcp.tool()
|
|
909
|
+
def create_handoff(
|
|
910
|
+
prompt: str,
|
|
911
|
+
created_by: str,
|
|
912
|
+
project: str = "",
|
|
913
|
+
scope: str = "",
|
|
914
|
+
context_summary: str = "",
|
|
915
|
+
) -> dict:
|
|
916
|
+
"""Leave a handoff draft for the next session.
|
|
917
|
+
|
|
918
|
+
Multiple unconsumed handoffs per project are allowed (parallel sessions,
|
|
919
|
+
different scopes). Use `scope` to disambiguate.
|
|
920
|
+
|
|
921
|
+
Pass `prompt` as a single top-level parameter — a call carrying literal
|
|
922
|
+
tool-call markup (a stray <parameter> tag) is rejected, not stored (#240).
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
prompt: Free-form "for next session: ..." text. Must be non-empty.
|
|
926
|
+
created_by: REQUIRED, non-empty — self-identify (e.g.
|
|
927
|
+
"claude-opus-4-7 @ 2026-04-17T14:30Z").
|
|
928
|
+
project: Empty = auto-detect from CWD.
|
|
929
|
+
scope: Optional tag like "sprint-v0.2" or "refactor-auth".
|
|
930
|
+
context_summary: Optional snapshot of state at handoff time.
|
|
931
|
+
|
|
932
|
+
Returns: new handoff dict including `id`, `created_at`, `consumed_at=null`.
|
|
933
|
+
Error cases: {"error": "..."} when prompt is empty, created_by is empty,
|
|
934
|
+
or project slug does not resolve.
|
|
935
|
+
"""
|
|
936
|
+
if (err := _reject_toolcall_markup(prompt=prompt, context_summary=context_summary)):
|
|
937
|
+
return err
|
|
938
|
+
from dct.core.handoff import create_handoff as _create
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
project_slug = _resolve_project(project, required=True)
|
|
942
|
+
assert project_slug is not None
|
|
943
|
+
return _create(
|
|
944
|
+
_get_engine(), project_slug,
|
|
945
|
+
prompt=prompt, created_by=created_by,
|
|
946
|
+
scope=scope or None,
|
|
947
|
+
context_summary=context_summary or None,
|
|
948
|
+
)
|
|
949
|
+
except Exception as e:
|
|
950
|
+
return {"error": str(e)}
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
@mcp.tool()
|
|
954
|
+
def list_handoffs(
|
|
955
|
+
project: str = "",
|
|
956
|
+
unconsumed_only: bool = True,
|
|
957
|
+
format: str = "structured",
|
|
958
|
+
):
|
|
959
|
+
"""List session handoffs for a project.
|
|
960
|
+
|
|
961
|
+
PROJECT RESOLUTION — IMPORTANT:
|
|
962
|
+
- `project="<slug>"` — filter to that project (recommended).
|
|
963
|
+
- `project=""` AND CWD is a registered project — filter to that project.
|
|
964
|
+
- `project=""` AND CWD is NOT a registered project — returns handoffs
|
|
965
|
+
ACROSS ALL PROJECTS. Intentional, but can surprise — pass `project`
|
|
966
|
+
explicitly when you want scoped output.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
project: Slug. Empty = CWD auto-detect; falls back to all projects
|
|
970
|
+
when CWD is unregistered.
|
|
971
|
+
unconsumed_only: If True (default) returns only handoffs without a
|
|
972
|
+
consumed_at stamp — the "waiting for me" set. False returns all.
|
|
973
|
+
format: 'structured' (default, {cols,rows}), 'markdown', 'dict'.
|
|
974
|
+
|
|
975
|
+
Returns: {"cols": [...], "rows": [[...]]} by default, list[dict] for
|
|
976
|
+
format="dict", or markdown str for format="markdown".
|
|
977
|
+
"""
|
|
978
|
+
from dct.core.handoff import list_handoffs as _list
|
|
979
|
+
try:
|
|
980
|
+
project_slug = _resolve_project(project, required=False)
|
|
981
|
+
rows = _list(_get_engine(), project_slug, unconsumed_only=unconsumed_only)
|
|
982
|
+
return _format_list(rows, HANDOFF_COLS, format)
|
|
983
|
+
except Exception as e:
|
|
984
|
+
return {"error": str(e)}
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
@mcp.tool()
|
|
988
|
+
def consume_handoff(handoff_id: int, consumed_by: str = "") -> dict:
|
|
989
|
+
"""Mark a handoff as consumed. Preserves the row in the ledger.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
handoff_id: id returned by create_handoff / list_handoffs.
|
|
993
|
+
consumed_by: optional — self-identify the consuming session (mirrors
|
|
994
|
+
create_handoff's `created_by` format).
|
|
995
|
+
|
|
996
|
+
Returns: updated handoff dict with `consumed_at` timestamp set, or
|
|
997
|
+
{"error": "..."} if the handoff is missing / already consumed.
|
|
998
|
+
"""
|
|
999
|
+
from dct.core.handoff import consume_handoff as _consume
|
|
1000
|
+
try:
|
|
1001
|
+
result = _consume(_get_engine(), handoff_id, consumed_by=consumed_by or None)
|
|
1002
|
+
return result or {"error": f"Handoff #{handoff_id} not found or already consumed"}
|
|
1003
|
+
except Exception as e:
|
|
1004
|
+
return {"error": str(e)}
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
@mcp.tool()
|
|
1008
|
+
def delete_handoff(handoff_id: int) -> dict:
|
|
1009
|
+
"""Soft-delete a handoff (hide from lists). Preserves the row for audit.
|
|
1010
|
+
|
|
1011
|
+
Returns: {"deleted": <bool>, "id": <int>}. `deleted=False` when no matching
|
|
1012
|
+
row existed or it was already deleted. Error payload {"error": "..."}
|
|
1013
|
+
on unexpected failures.
|
|
1014
|
+
"""
|
|
1015
|
+
from dct.core.handoff import delete_handoff as _delete
|
|
1016
|
+
try:
|
|
1017
|
+
ok = _delete(_get_engine(), handoff_id)
|
|
1018
|
+
return {"deleted": ok, "id": handoff_id}
|
|
1019
|
+
except Exception as e:
|
|
1020
|
+
return {"error": str(e)}
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
@mcp.tool()
|
|
1024
|
+
def update_handoff(
|
|
1025
|
+
handoff_id: int,
|
|
1026
|
+
prompt: str = "",
|
|
1027
|
+
scope: str = "",
|
|
1028
|
+
context_summary: str = "",
|
|
1029
|
+
) -> dict:
|
|
1030
|
+
"""Amend an UNCONSUMED handoff in place — preserves id + created_at.
|
|
1031
|
+
|
|
1032
|
+
The in-place alternative to delete_handoff + create_handoff (supersede):
|
|
1033
|
+
refresh a stale draft without churning its id, so the next /pickup sees the
|
|
1034
|
+
latest text under the same handoff. Only non-empty args are applied; omit a
|
|
1035
|
+
field to leave it unchanged (you cannot blank a field through this tool).
|
|
1036
|
+
|
|
1037
|
+
Consumed or soft-deleted handoffs are immutable ledger entries — updating
|
|
1038
|
+
one returns an error (the audit trail of what a session was handed is fixed
|
|
1039
|
+
once it is read).
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
handoff_id: id returned by create_handoff / list_handoffs.
|
|
1043
|
+
prompt: New prompt text (omit/empty = unchanged; cannot be blanked).
|
|
1044
|
+
scope: New scope tag (omit/empty = unchanged).
|
|
1045
|
+
context_summary: New context snapshot (omit/empty = unchanged).
|
|
1046
|
+
|
|
1047
|
+
Returns: updated handoff dict, or {"error": "..."} if the handoff is
|
|
1048
|
+
missing / consumed / deleted, or if no field was supplied.
|
|
1049
|
+
"""
|
|
1050
|
+
if (err := _reject_toolcall_markup(prompt=prompt, context_summary=context_summary)):
|
|
1051
|
+
return err
|
|
1052
|
+
from dct.core.handoff import update_handoff as _update
|
|
1053
|
+
try:
|
|
1054
|
+
result = _update(
|
|
1055
|
+
_get_engine(), handoff_id,
|
|
1056
|
+
prompt=prompt or None,
|
|
1057
|
+
scope=scope or None,
|
|
1058
|
+
context_summary=context_summary or None,
|
|
1059
|
+
)
|
|
1060
|
+
return result or {"error": f"Handoff #{handoff_id} not found, consumed, or deleted"}
|
|
1061
|
+
except Exception as e:
|
|
1062
|
+
return {"error": str(e)}
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
# ══ v0.2.0 — plans / plan_sections / plan_checkpoints ════════════════════════
|
|
1066
|
+
|
|
1067
|
+
@mcp.tool()
|
|
1068
|
+
def create_plan(
|
|
1069
|
+
slug: str,
|
|
1070
|
+
title: str,
|
|
1071
|
+
kind: str,
|
|
1072
|
+
project: str = "",
|
|
1073
|
+
source_file: str = "",
|
|
1074
|
+
sprint_id: int = 0,
|
|
1075
|
+
) -> dict:
|
|
1076
|
+
"""Register a plan row programmatically (e.g. for a DB-only roadmap).
|
|
1077
|
+
|
|
1078
|
+
For plans whose source of truth is a markdown file, prefer /dct-import or
|
|
1079
|
+
the autoscan hook: those inject the ULID anchor back into the file and
|
|
1080
|
+
record source_hash. This tool does neither — it only generates a DB-side
|
|
1081
|
+
ULID and stores source_file verbatim (source_hash stays NULL).
|
|
1082
|
+
|
|
1083
|
+
Args:
|
|
1084
|
+
slug: Short handle, unique per project (required, non-empty).
|
|
1085
|
+
title: Plan title (required, non-empty).
|
|
1086
|
+
kind: plan | spec | roadmap | adr | retro | backlog.
|
|
1087
|
+
project: Slug. Empty = auto-detect from CWD.
|
|
1088
|
+
source_file: Optional relative path (empty = DB-only); stored verbatim.
|
|
1089
|
+
sprint_id: Optional sprint FK (0 = none).
|
|
1090
|
+
|
|
1091
|
+
Returns: created plan dict (status defaults to 'active'), or
|
|
1092
|
+
{"error": "..."} on invalid kind, empty slug/title, duplicate slug in the
|
|
1093
|
+
project, or an unresolvable project (bad slug / unregistered CWD).
|
|
1094
|
+
"""
|
|
1095
|
+
from dct.core.plans.crud import create_plan as _create
|
|
1096
|
+
try:
|
|
1097
|
+
return _create(
|
|
1098
|
+
_get_engine(),
|
|
1099
|
+
_resolve_project(project),
|
|
1100
|
+
slug=slug, title=title, kind=kind,
|
|
1101
|
+
source_file=source_file or None,
|
|
1102
|
+
sprint_id=sprint_id or None,
|
|
1103
|
+
)
|
|
1104
|
+
except Exception as e:
|
|
1105
|
+
return {"error": str(e)}
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
@mcp.tool()
|
|
1109
|
+
def list_plans(
|
|
1110
|
+
project: str = "",
|
|
1111
|
+
kind: str = "",
|
|
1112
|
+
status: str = "",
|
|
1113
|
+
format: str = "structured",
|
|
1114
|
+
) -> dict | list | str:
|
|
1115
|
+
"""List plans in a project, with optional filters.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
project: Slug. Empty = auto-detect from CWD; falls back to ALL
|
|
1119
|
+
projects when CWD is not a registered project.
|
|
1120
|
+
kind: plan | spec | roadmap | adr | retro | backlog (empty = all).
|
|
1121
|
+
status: draft | active | completed | archived (empty = all).
|
|
1122
|
+
format: 'structured' (cols/rows) | 'dict' (list of full dicts).
|
|
1123
|
+
|
|
1124
|
+
Returns: {cols, rows} by default — cols are id, ulid, kind, status,
|
|
1125
|
+
slug, title. Rows are flat plan records (no nested sections — use
|
|
1126
|
+
get_plan for sections + checkpoints).
|
|
1127
|
+
"""
|
|
1128
|
+
from dct.core.plans.crud import list_plans as _list
|
|
1129
|
+
project_slug = _resolve_project(project, required=False)
|
|
1130
|
+
try:
|
|
1131
|
+
rows = _list(
|
|
1132
|
+
_get_engine(), project_slug=project_slug,
|
|
1133
|
+
kind=kind or None, status=status or None,
|
|
1134
|
+
)
|
|
1135
|
+
except Exception as e:
|
|
1136
|
+
return {"error": str(e)}
|
|
1137
|
+
if format == "dict":
|
|
1138
|
+
return rows
|
|
1139
|
+
cols = ["id", "ulid", "kind", "status", "slug", "title"]
|
|
1140
|
+
return {"cols": cols, "rows": [[r[c] for c in cols] for r in rows]}
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
@mcp.tool()
|
|
1144
|
+
def get_plan(plan_id: int) -> dict:
|
|
1145
|
+
"""Full plan dict with nested sections + checkpoints + progress rollup.
|
|
1146
|
+
|
|
1147
|
+
Progress: {total, done, rejected, pending, active, percent}. `active =
|
|
1148
|
+
total - rejected`, `percent = 100 * done / active`.
|
|
1149
|
+
"""
|
|
1150
|
+
from dct.core.plans.crud import get_plan as _get
|
|
1151
|
+
row = _get(_get_engine(), plan_id)
|
|
1152
|
+
return row or {"error": f"Plan #{plan_id} not found"}
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
@mcp.tool()
|
|
1156
|
+
def add_plan_section(
|
|
1157
|
+
plan_id: int,
|
|
1158
|
+
heading: str,
|
|
1159
|
+
depth: int,
|
|
1160
|
+
section_type: str = "generic",
|
|
1161
|
+
parent_section_id: int = 0,
|
|
1162
|
+
slug: str = "",
|
|
1163
|
+
sort_order: int = 0,
|
|
1164
|
+
sprint_id: int = 0,
|
|
1165
|
+
) -> dict:
|
|
1166
|
+
"""Append a section to a plan.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
plan_id: Parent plan FK (required).
|
|
1170
|
+
heading: Section heading text (required).
|
|
1171
|
+
depth: Heading level, 1-6 (H1-H6) (required, no default). Out-of-range
|
|
1172
|
+
values raise and are surfaced as {"error": ...}.
|
|
1173
|
+
section_type: sprint | phase | task | block | milestone | decision |
|
|
1174
|
+
prose | changelog | adr_section | reference | generic | uncertain
|
|
1175
|
+
(default: generic). Unknown values raise and are returned as an
|
|
1176
|
+
error dict.
|
|
1177
|
+
parent_section_id: Optional parent-section FK for nesting
|
|
1178
|
+
(0 = top-level / no parent).
|
|
1179
|
+
slug: Optional stable slug (empty = none).
|
|
1180
|
+
sort_order: Ordering within the plan (default 0).
|
|
1181
|
+
sprint_id: Optional sprint FK to link this section (0 = none).
|
|
1182
|
+
|
|
1183
|
+
Returns: created section dict, or {"error": "..."} on ANY failure — both
|
|
1184
|
+
validation errors (bad section_type / out-of-range depth) and DB
|
|
1185
|
+
errors (e.g. a non-existent plan_id, sprint_id, or parent_section_id
|
|
1186
|
+
violating a foreign-key constraint) are caught and returned this way.
|
|
1187
|
+
"""
|
|
1188
|
+
from dct.core.plans.crud import add_plan_section as _add
|
|
1189
|
+
try:
|
|
1190
|
+
return _add(
|
|
1191
|
+
_get_engine(), plan_id=plan_id, heading=heading, depth=depth,
|
|
1192
|
+
section_type=section_type,
|
|
1193
|
+
parent_section_id=parent_section_id or None,
|
|
1194
|
+
slug=slug or None, sort_order=sort_order,
|
|
1195
|
+
sprint_id=sprint_id or None,
|
|
1196
|
+
)
|
|
1197
|
+
except Exception as e:
|
|
1198
|
+
return {"error": str(e)}
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
@mcp.tool()
|
|
1202
|
+
def list_plan_sections(
|
|
1203
|
+
plan_id: int,
|
|
1204
|
+
parent_section_id: int = 0,
|
|
1205
|
+
section_type: str = "",
|
|
1206
|
+
format: str = "structured",
|
|
1207
|
+
) -> dict | list:
|
|
1208
|
+
"""List sections in a plan, optionally filtered by parent or type.
|
|
1209
|
+
|
|
1210
|
+
Args:
|
|
1211
|
+
plan_id: Plan FK (required).
|
|
1212
|
+
parent_section_id: Restrict to direct children of this section
|
|
1213
|
+
(0 = no parent filter — return all sections regardless of nesting).
|
|
1214
|
+
section_type: Filter by type (empty = all). One of sprint | phase |
|
|
1215
|
+
task | block | milestone | decision | prose | changelog |
|
|
1216
|
+
adr_section | reference | generic | uncertain.
|
|
1217
|
+
format: 'structured' (cols/rows) | 'dict' (list of full dicts).
|
|
1218
|
+
|
|
1219
|
+
Returns: {cols, rows} by default — cols are id, ulid, depth, status,
|
|
1220
|
+
section_type, sort_order, heading.
|
|
1221
|
+
"""
|
|
1222
|
+
from dct.core.plans.crud import list_plan_sections as _list
|
|
1223
|
+
rows = _list(
|
|
1224
|
+
_get_engine(), plan_id=plan_id,
|
|
1225
|
+
parent_section_id=parent_section_id or None,
|
|
1226
|
+
section_type=section_type or None,
|
|
1227
|
+
)
|
|
1228
|
+
if format == "dict":
|
|
1229
|
+
return rows
|
|
1230
|
+
cols = ["id", "ulid", "depth", "status", "section_type", "sort_order", "heading"]
|
|
1231
|
+
return {"cols": cols, "rows": [[r[c] for c in cols] for r in rows]}
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
@mcp.tool()
|
|
1235
|
+
def complete_plan_checkpoint(checkpoint_id: int) -> dict:
|
|
1236
|
+
"""Mark a plan checkpoint as done. Real-time; never batch completions."""
|
|
1237
|
+
from dct.core.plans.crud import complete_plan_checkpoint as _done
|
|
1238
|
+
row = _done(_get_engine(), checkpoint_id)
|
|
1239
|
+
return row or {"error": f"Plan checkpoint #{checkpoint_id} not found"}
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
@mcp.tool()
|
|
1243
|
+
def reject_plan_checkpoint(checkpoint_id: int) -> dict:
|
|
1244
|
+
"""Mark a plan checkpoint as rejected (scope change, N/A). Preserves ledger."""
|
|
1245
|
+
from dct.core.plans.crud import reject_plan_checkpoint as _reject
|
|
1246
|
+
row = _reject(_get_engine(), checkpoint_id)
|
|
1247
|
+
return row or {"error": f"Plan checkpoint #{checkpoint_id} not found"}
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
@mcp.tool()
|
|
1251
|
+
def promote_section_to_item(
|
|
1252
|
+
section_id: int,
|
|
1253
|
+
item_type: str,
|
|
1254
|
+
priority: str = "P2",
|
|
1255
|
+
title: str = "",
|
|
1256
|
+
) -> dict:
|
|
1257
|
+
"""Convert a plan_section into a standalone items row with back-link.
|
|
1258
|
+
|
|
1259
|
+
Useful when a section ('Sprint 3') deserves its own lifecycle outside the
|
|
1260
|
+
plan. The section is preserved in the plan and back-linked via
|
|
1261
|
+
`converted_to_item_id`. Project is inherited from the section's plan.
|
|
1262
|
+
|
|
1263
|
+
Args:
|
|
1264
|
+
section_id: Source plan_section id (required).
|
|
1265
|
+
item_type: issue | todo | feature | idea | improvement (required).
|
|
1266
|
+
priority: P1 | P2 | P3 | future (default P2).
|
|
1267
|
+
title: Override item title. Empty → use the section heading.
|
|
1268
|
+
|
|
1269
|
+
Returns: the new item dict, or {"error": "..."} on failure.
|
|
1270
|
+
"""
|
|
1271
|
+
from dct.core.plans.crud import promote_section_to_item as _promote
|
|
1272
|
+
try:
|
|
1273
|
+
return _promote(
|
|
1274
|
+
_get_engine(), section_id,
|
|
1275
|
+
item_type=item_type, priority=priority,
|
|
1276
|
+
title=title or None,
|
|
1277
|
+
)
|
|
1278
|
+
except Exception as e:
|
|
1279
|
+
return {"error": str(e)}
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
@mcp.tool()
|
|
1283
|
+
def promote_checkpoint_to_item(
|
|
1284
|
+
checkpoint_id: int,
|
|
1285
|
+
item_type: str,
|
|
1286
|
+
priority: str = "P2",
|
|
1287
|
+
title: str = "",
|
|
1288
|
+
) -> dict:
|
|
1289
|
+
"""Convert a plan_checkpoint into a standalone items row with back-link.
|
|
1290
|
+
|
|
1291
|
+
The checkpoint stays in its plan and is back-linked via
|
|
1292
|
+
`converted_to_item_id`. Project is inherited from the checkpoint's plan.
|
|
1293
|
+
|
|
1294
|
+
Args:
|
|
1295
|
+
checkpoint_id: Source plan_checkpoint FK (required).
|
|
1296
|
+
item_type: issue | todo | feature | idea | improvement.
|
|
1297
|
+
priority: P1 | P2 | P3 | future (default P2).
|
|
1298
|
+
title: Override item title. Empty → use the checkpoint text
|
|
1299
|
+
(truncated to 500 chars).
|
|
1300
|
+
|
|
1301
|
+
Returns: the new item dict, or {"error": "..."} on failure.
|
|
1302
|
+
"""
|
|
1303
|
+
from dct.core.plans.crud import promote_checkpoint_to_item as _promote
|
|
1304
|
+
try:
|
|
1305
|
+
return _promote(
|
|
1306
|
+
_get_engine(), checkpoint_id,
|
|
1307
|
+
item_type=item_type, priority=priority,
|
|
1308
|
+
title=title or None,
|
|
1309
|
+
)
|
|
1310
|
+
except Exception as e:
|
|
1311
|
+
return {"error": str(e)}
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
@mcp.tool()
|
|
1315
|
+
def promote_section_to_sprint(
|
|
1316
|
+
section_id: int,
|
|
1317
|
+
code: str,
|
|
1318
|
+
name: str = "",
|
|
1319
|
+
goal: str = "",
|
|
1320
|
+
kind: str = "sprint",
|
|
1321
|
+
) -> dict:
|
|
1322
|
+
"""Materialize a `## Sprint N` plan section as a first-class sprint row.
|
|
1323
|
+
|
|
1324
|
+
`section_type='sprint'` on a plan_section is only a semantic label —
|
|
1325
|
+
this call creates the real `sprints` entry with lifecycle + code +
|
|
1326
|
+
project-scoped uniqueness, and back-links the section via
|
|
1327
|
+
`plan_sections.sprint_id`.
|
|
1328
|
+
|
|
1329
|
+
`code` is mandatory and must be unique per project (enforced by partial
|
|
1330
|
+
unique index). Heading-based auto-codes aren't safe — plan A's "Sprint 1"
|
|
1331
|
+
would collide with plan B's "Sprint 1". Caller picks a namespaced code
|
|
1332
|
+
like `v0.2-hub` or `0.5-ux-polish`.
|
|
1333
|
+
|
|
1334
|
+
Args:
|
|
1335
|
+
name: Override sprint name. Empty → use section heading.
|
|
1336
|
+
goal: Optional free-text sprint goal.
|
|
1337
|
+
kind: sprint | phase | milestone | block (default: sprint).
|
|
1338
|
+
|
|
1339
|
+
Returns: created sprint dict, or {"error": "..."} on validation failure.
|
|
1340
|
+
"""
|
|
1341
|
+
from dct.core.plans.crud import promote_section_to_sprint as _promote
|
|
1342
|
+
try:
|
|
1343
|
+
return _promote(
|
|
1344
|
+
_get_engine(), section_id,
|
|
1345
|
+
code=code, name=name or None, goal=goal or None, kind=kind,
|
|
1346
|
+
)
|
|
1347
|
+
except Exception as e:
|
|
1348
|
+
return {"error": str(e)}
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
# ══ v0.2.0 — sprints / item_sprints ══════════════════════════════════════════
|
|
1352
|
+
|
|
1353
|
+
@mcp.tool()
|
|
1354
|
+
def create_sprint(
|
|
1355
|
+
name: str,
|
|
1356
|
+
project: str = "",
|
|
1357
|
+
code: str = "",
|
|
1358
|
+
kind: str = "sprint",
|
|
1359
|
+
status: str = "planned",
|
|
1360
|
+
goal: str = "",
|
|
1361
|
+
) -> dict:
|
|
1362
|
+
"""Create a sprint (a named time-/scope-boxed unit of work) under a project.
|
|
1363
|
+
|
|
1364
|
+
Args:
|
|
1365
|
+
name: Sprint name (required, non-empty).
|
|
1366
|
+
project: Slug. Empty = auto-detect from CWD.
|
|
1367
|
+
code: Optional short handle ("0.5", "M0", "sprint-v0.1"); unique per
|
|
1368
|
+
project when set.
|
|
1369
|
+
kind: sprint | phase | milestone | block (default sprint). Subtype is
|
|
1370
|
+
informational — every kind shares one lifecycle.
|
|
1371
|
+
status: planned | active | completed | abandoned (default planned).
|
|
1372
|
+
'active' stamps started_at and is capped at ONE active sprint per
|
|
1373
|
+
project — creating a second active sprint errors. 'deferred' is NOT
|
|
1374
|
+
a legal creation status (a sprint cannot be born parked — it has no
|
|
1375
|
+
started_at yet); create it planned/active then defer via
|
|
1376
|
+
update_sprint.
|
|
1377
|
+
goal: Optional free-text objective.
|
|
1378
|
+
|
|
1379
|
+
Returns the new sprint dict (full row), or {"error": "..."} on failure
|
|
1380
|
+
(empty name, invalid kind/status, status='deferred', duplicate code, a
|
|
1381
|
+
second active sprint, or an unresolvable project — unknown slug, or empty
|
|
1382
|
+
project when the CWD is not a registered project).
|
|
1383
|
+
"""
|
|
1384
|
+
from dct.core.sprints import create_sprint as _create
|
|
1385
|
+
try:
|
|
1386
|
+
return _create(
|
|
1387
|
+
_get_engine(), _resolve_project(project),
|
|
1388
|
+
name=name, code=code or None, kind=kind,
|
|
1389
|
+
status=status, goal=goal or None,
|
|
1390
|
+
)
|
|
1391
|
+
except Exception as e:
|
|
1392
|
+
return {"error": str(e)}
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
@mcp.tool()
|
|
1396
|
+
def update_sprint(
|
|
1397
|
+
sprint_id: int,
|
|
1398
|
+
status: str = "",
|
|
1399
|
+
name: str = "",
|
|
1400
|
+
code: str = "",
|
|
1401
|
+
kind: str = "",
|
|
1402
|
+
goal: str = "",
|
|
1403
|
+
) -> dict:
|
|
1404
|
+
"""Update an existing sprint's fields, enforcing the lifecycle whitelist.
|
|
1405
|
+
|
|
1406
|
+
Empty string = field unchanged (mirrors update_item). Each argument is a
|
|
1407
|
+
SEPARATE top-level parameter.
|
|
1408
|
+
|
|
1409
|
+
Args:
|
|
1410
|
+
sprint_id: Target sprint (explicit id).
|
|
1411
|
+
status: New lifecycle status. The legal transition matrix is:
|
|
1412
|
+
planned -> active, abandoned
|
|
1413
|
+
active -> deferred, completed, abandoned
|
|
1414
|
+
deferred -> active, completed, abandoned
|
|
1415
|
+
completed -> active (reopen)
|
|
1416
|
+
abandoned -> active (reopen)
|
|
1417
|
+
Any other move errors, listing the legal targets. A status equal to
|
|
1418
|
+
the current one is an idempotent no-op (timestamps untouched).
|
|
1419
|
+
Timestamp effects: ->active stamps started_at only when it was NULL
|
|
1420
|
+
(reactivation preserves it) and is capped at ONE active sprint per
|
|
1421
|
+
project; reopen (completed/abandoned -> active) clears the stale
|
|
1422
|
+
ended_at; completed/abandoned stamp ended_at.
|
|
1423
|
+
name: New name (non-empty).
|
|
1424
|
+
code: New short handle; re-checked for per-project uniqueness.
|
|
1425
|
+
kind: sprint | phase | milestone | block.
|
|
1426
|
+
goal: New free-text objective.
|
|
1427
|
+
|
|
1428
|
+
'deferred' semantics: parks a started-but-paused sprint. It KEEPS its
|
|
1429
|
+
started_at, frees the single active slot (so another sprint can be
|
|
1430
|
+
activated), and is HIDDEN from current-sprint views (get_current_sprint /
|
|
1431
|
+
review_sprint). It stays visible via list_sprints(status='deferred').
|
|
1432
|
+
|
|
1433
|
+
Returns the full updated sprint dict, or {"error": "..."} on an unknown id,
|
|
1434
|
+
illegal transition, a second active sprint, a duplicate code, an invalid
|
|
1435
|
+
kind/status, or no fields supplied.
|
|
1436
|
+
"""
|
|
1437
|
+
from dct.core.sprints import update_sprint as _update
|
|
1438
|
+
kwargs: dict = {}
|
|
1439
|
+
if status:
|
|
1440
|
+
kwargs["status"] = status
|
|
1441
|
+
if name:
|
|
1442
|
+
kwargs["name"] = name
|
|
1443
|
+
if code:
|
|
1444
|
+
kwargs["code"] = code
|
|
1445
|
+
if kind:
|
|
1446
|
+
kwargs["kind"] = kind
|
|
1447
|
+
if goal:
|
|
1448
|
+
kwargs["goal"] = goal
|
|
1449
|
+
try:
|
|
1450
|
+
result = _update(_get_engine(), sprint_id, **kwargs)
|
|
1451
|
+
except Exception as e:
|
|
1452
|
+
return {"error": str(e)}
|
|
1453
|
+
if result is None:
|
|
1454
|
+
return {"error": f"Sprint #{sprint_id} not found"}
|
|
1455
|
+
return result
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
@mcp.tool()
|
|
1459
|
+
def list_sprints(
|
|
1460
|
+
project: str = "",
|
|
1461
|
+
status: str = "",
|
|
1462
|
+
kind: str = "",
|
|
1463
|
+
format: str = "structured",
|
|
1464
|
+
) -> dict | list:
|
|
1465
|
+
"""List a project's sprints, newest first, optionally filtered.
|
|
1466
|
+
|
|
1467
|
+
Args:
|
|
1468
|
+
project: Slug. Empty = auto-detect from CWD; lists across ALL projects
|
|
1469
|
+
when CWD is not a registered project.
|
|
1470
|
+
status: Filter by planned | active | deferred | completed | abandoned.
|
|
1471
|
+
Empty = any. Use status='deferred' to surface parked sprints (they
|
|
1472
|
+
are hidden from the current-sprint views).
|
|
1473
|
+
kind: Filter by sprint | phase | milestone | block. Empty = any.
|
|
1474
|
+
format: 'structured' (default) -> {cols, rows} with cols = [id, code,
|
|
1475
|
+
kind, status, started_at, ended_at, name]; 'dict' -> list of full
|
|
1476
|
+
sprint dicts.
|
|
1477
|
+
|
|
1478
|
+
Soft-deleted sprints are excluded.
|
|
1479
|
+
"""
|
|
1480
|
+
from dct.core.sprints import list_sprints as _list
|
|
1481
|
+
project_slug = _resolve_project(project, required=False)
|
|
1482
|
+
rows = _list(
|
|
1483
|
+
_get_engine(), project_slug=project_slug,
|
|
1484
|
+
status=status or None, kind=kind or None,
|
|
1485
|
+
)
|
|
1486
|
+
if format == "dict":
|
|
1487
|
+
return rows
|
|
1488
|
+
cols = ["id", "code", "kind", "status", "started_at", "ended_at", "name"]
|
|
1489
|
+
return {"cols": cols, "rows": [[r[c] for c in cols] for r in rows]}
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
@mcp.tool()
|
|
1493
|
+
def assign_item_to_sprint(item_id: int, sprint_id: int) -> dict:
|
|
1494
|
+
"""Link an item to a sprint (both by explicit id). Idempotent: an
|
|
1495
|
+
already-active link is returned unchanged; a soft-removed link is
|
|
1496
|
+
reactivated (clears removed_at, refreshes assigned_at).
|
|
1497
|
+
|
|
1498
|
+
Returns the item_sprints link row, or {"error": "..."} if the item or the
|
|
1499
|
+
sprint id does not exist.
|
|
1500
|
+
"""
|
|
1501
|
+
from dct.core.sprints import assign_item_to_sprint as _assign
|
|
1502
|
+
try:
|
|
1503
|
+
return _assign(_get_engine(), item_id=item_id, sprint_id=sprint_id)
|
|
1504
|
+
except Exception as e:
|
|
1505
|
+
return {"error": str(e)}
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
@mcp.tool()
|
|
1509
|
+
def remove_item_from_sprint(item_id: int, sprint_id: int) -> dict:
|
|
1510
|
+
"""Soft-remove the item<->sprint link (both by explicit id) — sets
|
|
1511
|
+
removed_at while preserving assigned_at history ("this item WAS in this
|
|
1512
|
+
sprint").
|
|
1513
|
+
|
|
1514
|
+
Returns the updated link row, or {"error": "Link not found or already
|
|
1515
|
+
removed"} when there is no active link to remove.
|
|
1516
|
+
"""
|
|
1517
|
+
from dct.core.sprints import remove_item_from_sprint as _remove
|
|
1518
|
+
row = _remove(_get_engine(), item_id=item_id, sprint_id=sprint_id)
|
|
1519
|
+
return row or {"error": "Link not found or already removed"}
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
@mcp.tool()
|
|
1523
|
+
def list_items_in_sprint(sprint_id: int, include_removed: bool = False, format: str = "structured") -> dict | list:
|
|
1524
|
+
"""List items linked to a sprint, newest assignment first.
|
|
1525
|
+
|
|
1526
|
+
Args:
|
|
1527
|
+
sprint_id: Target sprint (explicit id).
|
|
1528
|
+
include_removed: False (default) -> only active members; True also
|
|
1529
|
+
includes soft-removed links (the sprint's historical membership).
|
|
1530
|
+
format: 'structured' (default) -> {cols, rows} with cols = [id,
|
|
1531
|
+
item_type, priority, status, title]; 'dict' -> list of full item
|
|
1532
|
+
dicts.
|
|
1533
|
+
"""
|
|
1534
|
+
from dct.core.sprints import list_items_in_sprint as _list
|
|
1535
|
+
rows = _list(_get_engine(), sprint_id=sprint_id, include_removed=include_removed)
|
|
1536
|
+
if format == "dict":
|
|
1537
|
+
return rows
|
|
1538
|
+
cols = ["id", "item_type", "priority", "status", "title"]
|
|
1539
|
+
return {"cols": cols, "rows": [[r[c] for c in cols] for r in rows]}
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
@mcp.tool()
|
|
1543
|
+
def add_item_to_current_sprint(item_id: int, project: str = "") -> dict:
|
|
1544
|
+
"""Add ONE item to the project's current (active) sprint, auto-creating the
|
|
1545
|
+
sprint if none exists. Additive — keeps existing members (unlike set_sprint,
|
|
1546
|
+
which REPLACES the whole sprint). Use for "add #N to the sprint" / "flag for
|
|
1547
|
+
sprint". Returns {project, sprint_id, item_id, active}."""
|
|
1548
|
+
from dct.core.sprints import add_item_to_current_sprint as _add
|
|
1549
|
+
try:
|
|
1550
|
+
slug = _resolve_project(project, required=True)
|
|
1551
|
+
assert slug is not None
|
|
1552
|
+
return _add(_get_engine(), slug, item_id)
|
|
1553
|
+
except Exception as e:
|
|
1554
|
+
return {"error": str(e)}
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
@mcp.tool()
|
|
1558
|
+
def remove_item_from_current_sprint(item_id: int, project: str = "") -> dict:
|
|
1559
|
+
"""Remove ONE item from the project's current (active) sprint. No-op
|
|
1560
|
+
(removed=False) if there is no current sprint or the item isn't a member.
|
|
1561
|
+
Use for "remove #N from the sprint" / "unflag"."""
|
|
1562
|
+
from dct.core.sprints import remove_item_from_current_sprint as _remove
|
|
1563
|
+
try:
|
|
1564
|
+
slug = _resolve_project(project, required=True)
|
|
1565
|
+
assert slug is not None
|
|
1566
|
+
return _remove(_get_engine(), slug, item_id)
|
|
1567
|
+
except Exception as e:
|
|
1568
|
+
return {"error": str(e)}
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
# ══ v0.2.0 — decisions ═══════════════════════════════════════════════════════
|
|
1572
|
+
|
|
1573
|
+
@mcp.tool()
|
|
1574
|
+
def create_decision(
|
|
1575
|
+
kind: str,
|
|
1576
|
+
title: str,
|
|
1577
|
+
project: str = "",
|
|
1578
|
+
ref_code: str = "",
|
|
1579
|
+
context: str = "",
|
|
1580
|
+
decision: str = "",
|
|
1581
|
+
consequences: str = "",
|
|
1582
|
+
alternatives: str = "",
|
|
1583
|
+
status: str = "proposed",
|
|
1584
|
+
plan_id: int = 0,
|
|
1585
|
+
plan_section_id: int = 0,
|
|
1586
|
+
sprint_id: int = 0,
|
|
1587
|
+
) -> dict:
|
|
1588
|
+
"""Create a decision record (ADR / sprint amendment / inline decision / open question).
|
|
1589
|
+
|
|
1590
|
+
Each content field is its OWN separate top-level parameter — pass them
|
|
1591
|
+
independently, never bundle several into one value. The four prose fields:
|
|
1592
|
+
- context: the situation / forces / problem prompting the decision
|
|
1593
|
+
- decision: the choice made (the "we will …" statement)
|
|
1594
|
+
- consequences: what follows — trade-offs, risks, follow-up work
|
|
1595
|
+
- alternatives: options considered and rejected, and why
|
|
1596
|
+
A call carrying literal tool-call markup in any field is rejected, not
|
|
1597
|
+
stored (#240) — so structure the call correctly the first time.
|
|
1598
|
+
|
|
1599
|
+
Args:
|
|
1600
|
+
kind: adr | sprint_amendment | inline_plan | open_question.
|
|
1601
|
+
title: Short decision title (required).
|
|
1602
|
+
context / decision / consequences / alternatives: the prose fields above (all optional).
|
|
1603
|
+
ref_code: Short reference like "ADR-001" (optional, unique per project).
|
|
1604
|
+
status: proposed | accepted | superseded | rejected.
|
|
1605
|
+
plan_id / plan_section_id / sprint_id: optional anchors (0 = none).
|
|
1606
|
+
"""
|
|
1607
|
+
if (err := _reject_toolcall_markup(
|
|
1608
|
+
title=title, context=context, decision=decision,
|
|
1609
|
+
consequences=consequences, alternatives=alternatives,
|
|
1610
|
+
)):
|
|
1611
|
+
return err
|
|
1612
|
+
from dct.core.decisions import create_decision as _create
|
|
1613
|
+
try:
|
|
1614
|
+
return _create(
|
|
1615
|
+
_get_engine(), _resolve_project(project),
|
|
1616
|
+
kind=kind, title=title,
|
|
1617
|
+
ref_code=ref_code or None,
|
|
1618
|
+
context=context or None, decision=decision or None,
|
|
1619
|
+
consequences=consequences or None, alternatives=alternatives or None,
|
|
1620
|
+
status=status,
|
|
1621
|
+
plan_id=plan_id or None, plan_section_id=plan_section_id or None,
|
|
1622
|
+
sprint_id=sprint_id or None,
|
|
1623
|
+
)
|
|
1624
|
+
except Exception as e:
|
|
1625
|
+
return {"error": str(e)}
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
@mcp.tool()
|
|
1629
|
+
def list_decisions(
|
|
1630
|
+
project: str = "",
|
|
1631
|
+
kind: str = "",
|
|
1632
|
+
status: str = "",
|
|
1633
|
+
plan_id: int = 0,
|
|
1634
|
+
sprint_id: int = 0,
|
|
1635
|
+
format: str = "structured",
|
|
1636
|
+
) -> dict | list:
|
|
1637
|
+
"""List decisions newest-first (created_at desc), optionally filtered.
|
|
1638
|
+
|
|
1639
|
+
All filters optional — empty / 0 means "no filter":
|
|
1640
|
+
project: slug; empty = auto-detect from CWD, and if CWD is not a
|
|
1641
|
+
registered project, lists decisions across ALL projects.
|
|
1642
|
+
kind: adr | sprint_amendment | inline_plan | open_question.
|
|
1643
|
+
status: proposed | accepted | superseded | rejected.
|
|
1644
|
+
plan_id / sprint_id: keep only decisions anchored to that plan / sprint.
|
|
1645
|
+
format: 'structured' (default) → {"cols", "rows"} with
|
|
1646
|
+
cols=[id, kind, status, ref_code, created_at, title];
|
|
1647
|
+
'dict' → list of full-key decision dicts.
|
|
1648
|
+
"""
|
|
1649
|
+
from dct.core.decisions import list_decisions as _list
|
|
1650
|
+
project_slug = _resolve_project(project, required=False)
|
|
1651
|
+
rows = _list(
|
|
1652
|
+
_get_engine(), project_slug=project_slug,
|
|
1653
|
+
kind=kind or None, status=status or None,
|
|
1654
|
+
plan_id=plan_id or None, sprint_id=sprint_id or None,
|
|
1655
|
+
)
|
|
1656
|
+
if format == "dict":
|
|
1657
|
+
return rows
|
|
1658
|
+
cols = ["id", "kind", "status", "ref_code", "created_at", "title"]
|
|
1659
|
+
return {"cols": cols, "rows": [[r[c] for c in cols] for r in rows]}
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
@mcp.tool()
|
|
1663
|
+
def update_decision(
|
|
1664
|
+
decision_id: int,
|
|
1665
|
+
title: str = "",
|
|
1666
|
+
context: str = "",
|
|
1667
|
+
decision: str = "",
|
|
1668
|
+
consequences: str = "",
|
|
1669
|
+
alternatives: str = "",
|
|
1670
|
+
status: str = "",
|
|
1671
|
+
ref_code: str = "",
|
|
1672
|
+
) -> dict:
|
|
1673
|
+
"""Edit a decision's fields in place; empty string = leave that field unchanged.
|
|
1674
|
+
|
|
1675
|
+
decision_id (required): id of the decision to update. Each editable field is
|
|
1676
|
+
its own separate top-level parameter:
|
|
1677
|
+
title, context, decision, consequences, alternatives — the prose fields
|
|
1678
|
+
(same semantic roles as in create_decision).
|
|
1679
|
+
status — one of: proposed | accepted | superseded | rejected
|
|
1680
|
+
(an invalid value returns an {"error": ...} dict; nothing is written).
|
|
1681
|
+
ref_code — short reference like "ADR-001". The (project_id, ref_code) pair
|
|
1682
|
+
is unique among active decisions, so setting a ref_code already used by
|
|
1683
|
+
another decision in the project returns an {"error": ...} dict.
|
|
1684
|
+
kind and the plan/section/sprint anchors are NOT editable here — they are not
|
|
1685
|
+
parameters of this tool. At least one field must be supplied.
|
|
1686
|
+
|
|
1687
|
+
Literal tool-call markup is rejected (nothing written) only in the prose fields
|
|
1688
|
+
title / context / decision / consequences / alternatives (#240); status and
|
|
1689
|
+
ref_code are NOT markup-scanned (status is instead validated against the enum
|
|
1690
|
+
above, ref_code is stored as given).
|
|
1691
|
+
|
|
1692
|
+
Returns the updated decision dict, or an {"error": ...} dict when: decision_id
|
|
1693
|
+
is unknown, no (non-empty) fields were supplied, status is not a valid value,
|
|
1694
|
+
a ref_code collides with another active decision in the project, or a prose
|
|
1695
|
+
field contains tool-call markup.
|
|
1696
|
+
"""
|
|
1697
|
+
if (err := _reject_toolcall_markup(
|
|
1698
|
+
title=title, context=context, decision=decision,
|
|
1699
|
+
consequences=consequences, alternatives=alternatives,
|
|
1700
|
+
)):
|
|
1701
|
+
return err
|
|
1702
|
+
from dct.core.decisions import update_decision as _update
|
|
1703
|
+
kwargs = {k: v for k, v in {
|
|
1704
|
+
"title": title, "context": context, "decision": decision,
|
|
1705
|
+
"consequences": consequences, "alternatives": alternatives,
|
|
1706
|
+
"status": status, "ref_code": ref_code,
|
|
1707
|
+
}.items() if v}
|
|
1708
|
+
try:
|
|
1709
|
+
row = _update(_get_engine(), decision_id=decision_id, **kwargs)
|
|
1710
|
+
return row or {"error": f"Decision #{decision_id} not found"}
|
|
1711
|
+
except Exception as e:
|
|
1712
|
+
return {"error": str(e)}
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
@mcp.tool()
|
|
1716
|
+
def supersede_decision(old_decision_id: int, new_decision_id: int) -> dict:
|
|
1717
|
+
"""Mark decision #old_decision_id as superseded by #new_decision_id.
|
|
1718
|
+
|
|
1719
|
+
Sets status='superseded' and superseded_by=#new on the OLD row only; the
|
|
1720
|
+
NEW row is left untouched — set its status (e.g. 'accepted') separately via
|
|
1721
|
+
update_decision. Errors if either id is unknown or the two ids are equal.
|
|
1722
|
+
Returns the updated old-decision dict, or {"error": ...}.
|
|
1723
|
+
"""
|
|
1724
|
+
from dct.core.decisions import supersede_decision as _supersede
|
|
1725
|
+
try:
|
|
1726
|
+
row = _supersede(_get_engine(), old_decision_id, new_decision_id)
|
|
1727
|
+
return row or {"error": "Decision not found"}
|
|
1728
|
+
except Exception as e:
|
|
1729
|
+
return {"error": str(e)}
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
# ══ v0.2.0 — plan ingest ═════════════════════════════════════════════════════
|
|
1733
|
+
|
|
1734
|
+
@mcp.tool()
|
|
1735
|
+
def ingest_plan_file(
|
|
1736
|
+
file_path: str,
|
|
1737
|
+
project: str = "",
|
|
1738
|
+
auto_resolve_uncertain: bool = True,
|
|
1739
|
+
) -> dict:
|
|
1740
|
+
"""Ingest a markdown plan/spec/roadmap/ADR file into dct.
|
|
1741
|
+
|
|
1742
|
+
Runs the full pipeline: parse → ensure ULIDs → atomic write-back → DB
|
|
1743
|
+
upsert. On first import reads `[x]` as `status='done'` (historical
|
|
1744
|
+
truth, one-time). Subsequent re-ingests ignore MD checkbox state.
|
|
1745
|
+
|
|
1746
|
+
Args:
|
|
1747
|
+
file_path: Absolute or CWD-relative path to the file.
|
|
1748
|
+
project: Project slug. Empty = auto-detect from CWD.
|
|
1749
|
+
auto_resolve_uncertain: When True, uncertain sections go through
|
|
1750
|
+
`claude --print` classification. Use False to skip LLM calls
|
|
1751
|
+
(sections remain with section_type='uncertain').
|
|
1752
|
+
|
|
1753
|
+
Returns: IngestReport dict {plan_id, plan_ulid, is_first_import,
|
|
1754
|
+
source_hash, sections_created, sections_updated, checkpoints_created,
|
|
1755
|
+
uncertain_resolved, uncertain_remaining, ulids_injected, warnings}.
|
|
1756
|
+
"""
|
|
1757
|
+
from dct.core.plans.ingest import ingest_file as _ingest
|
|
1758
|
+
try:
|
|
1759
|
+
return _ingest(
|
|
1760
|
+
_get_engine(), file_path,
|
|
1761
|
+
project_slug=_resolve_project(project),
|
|
1762
|
+
auto_resolve_uncertain=auto_resolve_uncertain,
|
|
1763
|
+
)
|
|
1764
|
+
except Exception as e:
|
|
1765
|
+
return {"error": str(e)}
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
# ── Frugal MCP deferral exemption (#241) ──────────────────────────────────────
|
|
1769
|
+
# Claude Code defers ALL MCP tools by default (ENABLE_TOOL_SEARCH unset), so even
|
|
1770
|
+
# /pickup pays a ToolSearch round-trip before it can run. We exempt only the
|
|
1771
|
+
# daily-driver tools — the ones the pickup / track* / clog / commit / handoff
|
|
1772
|
+
# skills reach for every session — by stamping the `anthropic/alwaysLoad` _meta
|
|
1773
|
+
# hint the CC client reads (requires Claude Code v2.1.121+). Specialist tools
|
|
1774
|
+
# (plans, decisions, first-class sprints, web, release admin) stay deferred.
|
|
1775
|
+
# Add a name here ONLY if a routine skill needs it from token zero; keep the long
|
|
1776
|
+
# tail deferred to spend session context wisely. Takes effect on server restart.
|
|
1777
|
+
_ALWAYS_LOAD_TOOLS = frozenset({
|
|
1778
|
+
# project + item lifecycle (track / track-list / track-update / track-resolve)
|
|
1779
|
+
"list_projects", "list_items", "get_item",
|
|
1780
|
+
"create_item", "update_item", "resolve_item", "add_note",
|
|
1781
|
+
"add_checkpoint", "complete_checkpoint", "reject_checkpoint",
|
|
1782
|
+
# changelog / commit
|
|
1783
|
+
"add_changelog", "count_unreleased_changelog",
|
|
1784
|
+
# sprint + handoff (pickup / handoff)
|
|
1785
|
+
"review_sprint", "set_sprint",
|
|
1786
|
+
"create_handoff", "list_handoffs", "consume_handoff", "delete_handoff",
|
|
1787
|
+
})
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def _apply_always_load(server: FastMCP) -> None:
|
|
1791
|
+
"""Stamp the daily-driver tools with Claude Code's alwaysLoad hint so they
|
|
1792
|
+
load into context at session start instead of behind a ToolSearch round-trip.
|
|
1793
|
+
|
|
1794
|
+
Raises at import if a hot-set name is not a registered tool — the set must
|
|
1795
|
+
track real tool names, so a typo fails loud rather than silently leaving a
|
|
1796
|
+
tool deferred.
|
|
1797
|
+
"""
|
|
1798
|
+
registered = server._tool_manager._tools
|
|
1799
|
+
missing = _ALWAYS_LOAD_TOOLS - registered.keys()
|
|
1800
|
+
if missing:
|
|
1801
|
+
raise RuntimeError(
|
|
1802
|
+
f"_ALWAYS_LOAD_TOOLS names not registered as tools: {sorted(missing)}"
|
|
1803
|
+
)
|
|
1804
|
+
for name in _ALWAYS_LOAD_TOOLS:
|
|
1805
|
+
registered[name].meta = {"anthropic/alwaysLoad": True}
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
_apply_always_load(mcp)
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
if __name__ == "__main__":
|
|
1812
|
+
mcp.run()
|