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.
Files changed (87) hide show
  1. dct/__init__.py +6 -0
  2. dct/cli.py +1659 -0
  3. dct/config.py +150 -0
  4. dct/core/__init__.py +0 -0
  5. dct/core/analytics.py +382 -0
  6. dct/core/changelog.py +112 -0
  7. dct/core/checkpoints.py +205 -0
  8. dct/core/decisions.py +238 -0
  9. dct/core/engine.py +32 -0
  10. dct/core/handoff.py +151 -0
  11. dct/core/ids.py +25 -0
  12. dct/core/items.py +382 -0
  13. dct/core/plans/__init__.py +6 -0
  14. dct/core/plans/classify.py +94 -0
  15. dct/core/plans/crud.py +680 -0
  16. dct/core/plans/ingest.py +408 -0
  17. dct/core/plans/parser.py +312 -0
  18. dct/core/projects.py +298 -0
  19. dct/core/sprint.py +315 -0
  20. dct/core/sprints.py +601 -0
  21. dct/core/version.py +116 -0
  22. dct/db.py +558 -0
  23. dct/export.py +107 -0
  24. dct/hooks/check-changelog-on-stop.sh +49 -0
  25. dct/hooks/check-changelog.sh +143 -0
  26. dct/hooks/dct-plan-autoscan.sh +54 -0
  27. dct/hooks/dct-task-mirror.sh +62 -0
  28. dct/server.py +1812 -0
  29. dct/setup.py +258 -0
  30. dct/skills/clog/SKILL.md +73 -0
  31. dct/skills/commit/SKILL.md +153 -0
  32. dct/skills/dct-import/SKILL.md +329 -0
  33. dct/skills/dct-init/SKILL.md +289 -0
  34. dct/skills/handoff/SKILL.md +136 -0
  35. dct/skills/pickup/SKILL.md +115 -0
  36. dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
  37. dct/skills/plan-status/SKILL.md +79 -0
  38. dct/skills/release/SKILL.md +281 -0
  39. dct/skills/track/SKILL.md +121 -0
  40. dct/skills/track-list/SKILL.md +134 -0
  41. dct/skills/track-resolve/SKILL.md +53 -0
  42. dct/skills/track-update/SKILL.md +109 -0
  43. dct/web/__init__.py +1 -0
  44. dct/web/app.py +83 -0
  45. dct/web/blueprints/__init__.py +5 -0
  46. dct/web/blueprints/analytics.py +34 -0
  47. dct/web/blueprints/changelog.py +40 -0
  48. dct/web/blueprints/decisions.py +23 -0
  49. dct/web/blueprints/events.py +69 -0
  50. dct/web/blueprints/handoffs.py +26 -0
  51. dct/web/blueprints/home.py +15 -0
  52. dct/web/blueprints/items.py +128 -0
  53. dct/web/blueprints/plans.py +53 -0
  54. dct/web/blueprints/projects.py +23 -0
  55. dct/web/blueprints/sprints.py +71 -0
  56. dct/web/changes.py +77 -0
  57. dct/web/serializers.py +109 -0
  58. dct/web/static/app.css +609 -0
  59. dct/web/static/app.js +62 -0
  60. dct/web/static/vendor/SOURCES.txt +10 -0
  61. dct/web/static/vendor/htmx.min.js +1 -0
  62. dct/web/static/vendor/uPlot.iife.min.js +2 -0
  63. dct/web/static/vendor/uPlot.min.css +1 -0
  64. dct/web/templates/analytics.html +63 -0
  65. dct/web/templates/base.html +106 -0
  66. dct/web/templates/changelog/list.html +23 -0
  67. dct/web/templates/decisions/list.html +22 -0
  68. dct/web/templates/handoffs/list.html +45 -0
  69. dct/web/templates/home.html +20 -0
  70. dct/web/templates/item_detail.html +91 -0
  71. dct/web/templates/items_list.html +44 -0
  72. dct/web/templates/partials/_bars.html +15 -0
  73. dct/web/templates/partials/_checkpoint_steps.html +22 -0
  74. dct/web/templates/partials/_items_table.html +23 -0
  75. dct/web/templates/partials/_plan_section.html +24 -0
  76. dct/web/templates/partials/_project_card.html +24 -0
  77. dct/web/templates/plans/detail.html +16 -0
  78. dct/web/templates/plans/list.html +15 -0
  79. dct/web/templates/project_home.html +51 -0
  80. dct/web/templates/sprints/detail.html +37 -0
  81. dct/web/templates/sprints/list.html +35 -0
  82. dctracker-1.0.0.dist-info/METADATA +357 -0
  83. dctracker-1.0.0.dist-info/RECORD +87 -0
  84. dctracker-1.0.0.dist-info/WHEEL +5 -0
  85. dctracker-1.0.0.dist-info/entry_points.txt +2 -0
  86. dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
  87. 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()