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/core/plans/crud.py ADDED
@@ -0,0 +1,680 @@
1
+ """CRUD operations for plans, plan_sections, plan_checkpoints.
2
+
3
+ Follows the same patterns as dct.core.items / dct.core.projects:
4
+ * Each function takes `engine` first.
5
+ * All returns are dicts (row_to_dict wrapped).
6
+ * Soft-delete via `deleted_at`; no hard deletes exposed.
7
+ * ULIDs are the stable cross-cut identifier (MD anchors → DB).
8
+
9
+ Imports from dct.core.plans.parser + classify are DELIBERATELY not pulled
10
+ in here — CRUD must be usable without the ingest pipeline for
11
+ programmatic roadmap creation (e.g. `dct roadmap create`).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import datetime, timezone
17
+
18
+ import sqlalchemy as sa
19
+
20
+ from dct.core.ids import new_ulid
21
+ from dct.core.projects import resolve_project_id
22
+ from dct.db import items as items_tbl
23
+ from dct.db import (
24
+ plan_checkpoints,
25
+ plan_sections,
26
+ plans,
27
+ row_to_dict,
28
+ )
29
+
30
+ VALID_PLAN_KINDS = {"plan", "spec", "roadmap", "adr", "retro", "backlog"}
31
+ VALID_PLAN_STATUSES = {"draft", "active", "completed", "archived"}
32
+
33
+ VALID_SECTION_STATUSES = {"pending", "in_progress", "done", "rejected", "na"}
34
+ VALID_SECTION_TYPES = {
35
+ "sprint", "phase", "task", "block", "milestone",
36
+ "decision", "prose", "changelog", "adr_section",
37
+ "reference", "generic", "uncertain",
38
+ }
39
+
40
+ VALID_CP_STATUSES = {"pending", "done", "rejected"}
41
+
42
+
43
+ def _validate(value: str, valid: set[str], field: str) -> str:
44
+ if value not in valid:
45
+ raise ValueError(f"Invalid {field}: '{value}'. Valid: {', '.join(sorted(valid))}")
46
+ return value
47
+
48
+
49
+ # ══ Plans ════════════════════════════════════════════════════════════════════
50
+
51
+ def create_plan(
52
+ engine: sa.Engine,
53
+ project_slug: str,
54
+ slug: str,
55
+ title: str,
56
+ kind: str,
57
+ source_file: str | None = None,
58
+ source_hash: str | None = None,
59
+ sprint_id: int | None = None,
60
+ ulid: str | None = None,
61
+ status: str = "active",
62
+ ) -> dict:
63
+ _validate(kind, VALID_PLAN_KINDS, "kind")
64
+ _validate(status, VALID_PLAN_STATUSES, "status")
65
+ if not slug or not slug.strip():
66
+ raise ValueError("Plan slug cannot be empty")
67
+ if not title or not title.strip():
68
+ raise ValueError("Plan title cannot be empty")
69
+
70
+ plan_ulid = ulid or new_ulid()
71
+ with engine.begin() as conn:
72
+ project_id = resolve_project_id(conn, project_slug)
73
+ duplicate = conn.execute(
74
+ sa.select(plans).where(
75
+ plans.c.project_id == project_id,
76
+ plans.c.slug == slug,
77
+ plans.c.deleted_at.is_(None),
78
+ )
79
+ ).first()
80
+ if duplicate:
81
+ raise ValueError(f"Plan with slug '{slug}' already exists in project")
82
+
83
+ row = conn.execute(
84
+ plans.insert()
85
+ .values(
86
+ ulid=plan_ulid,
87
+ project_id=project_id,
88
+ slug=slug.strip(),
89
+ title=title.strip(),
90
+ kind=kind,
91
+ source_file=source_file,
92
+ source_hash=source_hash,
93
+ sprint_id=sprint_id,
94
+ status=status,
95
+ )
96
+ .returning(plans)
97
+ ).first()
98
+ assert row is not None, "INSERT RETURNING must return a row"
99
+ return row_to_dict(row)
100
+
101
+
102
+ def list_plans(
103
+ engine: sa.Engine,
104
+ project_slug: str | None = None,
105
+ kind: str | None = None,
106
+ status: str | None = None,
107
+ include_deleted: bool = False,
108
+ ) -> list[dict]:
109
+ with engine.begin() as conn:
110
+ q = sa.select(plans).order_by(plans.c.created_at.desc())
111
+ if project_slug:
112
+ q = q.where(plans.c.project_id == resolve_project_id(conn, project_slug))
113
+ if kind:
114
+ q = q.where(plans.c.kind == kind)
115
+ if status:
116
+ q = q.where(plans.c.status == status)
117
+ if not include_deleted:
118
+ q = q.where(plans.c.deleted_at.is_(None))
119
+ return [row_to_dict(r) for r in conn.execute(q)]
120
+
121
+
122
+ def get_plan(engine: sa.Engine, plan_id: int) -> dict | None:
123
+ """Return plan + nested sections + checkpoints + progress rollup.
124
+
125
+ Shape: {..plan columns..,
126
+ 'sections': [{..section columns.., 'checkpoints': [...]}, ...],
127
+ 'progress': {'total': N, 'done': N, 'rejected': N, 'pending': N,
128
+ 'active': N (total minus rejected), 'percent': 0..100}}
129
+ """
130
+ with engine.begin() as conn:
131
+ plan_row = conn.execute(
132
+ sa.select(plans).where(plans.c.id == plan_id, plans.c.deleted_at.is_(None))
133
+ ).first()
134
+ if not plan_row:
135
+ return None
136
+ plan_dict = row_to_dict(plan_row)
137
+
138
+ section_rows = conn.execute(
139
+ sa.select(plan_sections)
140
+ .where(plan_sections.c.plan_id == plan_id, plan_sections.c.deleted_at.is_(None))
141
+ .order_by(plan_sections.c.sort_order, plan_sections.c.heading_line if hasattr(plan_sections.c, "heading_line") else plan_sections.c.id)
142
+ ).fetchall()
143
+
144
+ cp_rows = conn.execute(
145
+ sa.select(plan_checkpoints)
146
+ .join(plan_sections, plan_checkpoints.c.plan_section_id == plan_sections.c.id)
147
+ .where(plan_sections.c.plan_id == plan_id, plan_checkpoints.c.deleted_at.is_(None))
148
+ .order_by(plan_checkpoints.c.sort_order, plan_checkpoints.c.id)
149
+ ).fetchall()
150
+
151
+ sections_by_id: dict[int, dict] = {}
152
+ for row in section_rows:
153
+ d = row_to_dict(row)
154
+ d["checkpoints"] = []
155
+ sections_by_id[d["id"]] = d
156
+
157
+ total = done = rejected = pending = 0
158
+ for cp_row in cp_rows:
159
+ cp_d = row_to_dict(cp_row)
160
+ parent = sections_by_id.get(cp_d["plan_section_id"])
161
+ if parent is not None:
162
+ parent["checkpoints"].append(cp_d)
163
+ total += 1
164
+ if cp_d["status"] == "done":
165
+ done += 1
166
+ elif cp_d["status"] == "rejected":
167
+ rejected += 1
168
+ else:
169
+ pending += 1
170
+
171
+ active = total - rejected
172
+ percent = int(round(100 * done / active)) if active else 0
173
+ plan_dict["sections"] = list(sections_by_id.values())
174
+ plan_dict["progress"] = {
175
+ "total": total, "done": done, "rejected": rejected,
176
+ "pending": pending, "active": active, "percent": percent,
177
+ }
178
+ return plan_dict
179
+
180
+
181
+ def get_plan_by_ulid(engine: sa.Engine, ulid: str) -> dict | None:
182
+ with engine.begin() as conn:
183
+ row = conn.execute(
184
+ sa.select(plans.c.id).where(
185
+ plans.c.ulid == ulid, plans.c.deleted_at.is_(None)
186
+ )
187
+ ).first()
188
+ if not row:
189
+ return None
190
+ return get_plan(engine, row.id)
191
+
192
+
193
+ def get_plan_by_slug(engine: sa.Engine, project_slug: str, slug: str) -> dict | None:
194
+ with engine.begin() as conn:
195
+ pid = resolve_project_id(conn, project_slug)
196
+ row = conn.execute(
197
+ sa.select(plans.c.id).where(
198
+ plans.c.project_id == pid,
199
+ plans.c.slug == slug,
200
+ plans.c.deleted_at.is_(None),
201
+ )
202
+ ).first()
203
+ if not row:
204
+ return None
205
+ return get_plan(engine, row.id)
206
+
207
+
208
+ def update_plan(engine: sa.Engine, plan_id: int, **kwargs) -> dict | None:
209
+ allowed = {"title", "kind", "source_file", "source_hash", "status", "sprint_id"}
210
+ values: dict = {}
211
+ for k, v in kwargs.items():
212
+ if k not in allowed or v is None:
213
+ continue
214
+ if k == "kind":
215
+ _validate(v, VALID_PLAN_KINDS, "kind")
216
+ elif k == "status":
217
+ _validate(v, VALID_PLAN_STATUSES, "status")
218
+ values[k] = v
219
+ if not values:
220
+ raise ValueError("No valid fields to update")
221
+
222
+ values["updated_at"] = datetime.now(timezone.utc)
223
+ if values.get("status") == "archived":
224
+ values["archived_at"] = values["updated_at"]
225
+
226
+ with engine.begin() as conn:
227
+ result = conn.execute(
228
+ plans.update()
229
+ .where(plans.c.id == plan_id, plans.c.deleted_at.is_(None))
230
+ .values(**values)
231
+ )
232
+ if result.rowcount == 0:
233
+ return None
234
+ return get_plan(engine, plan_id)
235
+
236
+
237
+ def set_plan_imported(
238
+ engine: sa.Engine, plan_id: int, source_hash: str | None = None
239
+ ) -> dict | None:
240
+ """Stamp `imported_at=now()` and optionally update `source_hash`.
241
+
242
+ Called once at the end of the first ingest to close the first-import
243
+ gate (§9.3). Subsequent rescans are re-imports; they may update
244
+ source_hash but must not touch imported_at.
245
+ """
246
+ now = datetime.now(timezone.utc)
247
+ values: dict = {"imported_at": now, "updated_at": now}
248
+ if source_hash is not None:
249
+ values["source_hash"] = source_hash
250
+ with engine.begin() as conn:
251
+ result = conn.execute(
252
+ plans.update()
253
+ .where(plans.c.id == plan_id, plans.c.deleted_at.is_(None))
254
+ .values(**values)
255
+ )
256
+ if result.rowcount == 0:
257
+ return None
258
+ return get_plan(engine, plan_id)
259
+
260
+
261
+ def delete_plan(engine: sa.Engine, plan_id: int) -> bool:
262
+ now = datetime.now(timezone.utc)
263
+ with engine.begin() as conn:
264
+ result = conn.execute(
265
+ plans.update()
266
+ .where(plans.c.id == plan_id, plans.c.deleted_at.is_(None))
267
+ .values(deleted_at=now)
268
+ )
269
+ return result.rowcount > 0
270
+
271
+
272
+ # ══ Plan sections ════════════════════════════════════════════════════════════
273
+
274
+ def add_plan_section(
275
+ engine: sa.Engine,
276
+ plan_id: int,
277
+ heading: str,
278
+ depth: int,
279
+ section_type: str = "generic",
280
+ ulid: str | None = None,
281
+ parent_section_id: int | None = None,
282
+ slug: str | None = None,
283
+ sort_order: int = 0,
284
+ sprint_id: int | None = None,
285
+ needs_llm_review: bool = False,
286
+ section_type_confidence: str = "high",
287
+ source_lines: str | None = None,
288
+ status: str = "pending",
289
+ ) -> dict:
290
+ _validate(section_type, VALID_SECTION_TYPES, "section_type")
291
+ _validate(status, VALID_SECTION_STATUSES, "status")
292
+ if depth < 1 or depth > 6:
293
+ raise ValueError(f"Invalid depth: {depth}. Must be 1-6.")
294
+ if section_type_confidence not in {"high", "low", "uncertain"}:
295
+ raise ValueError(
296
+ f"Invalid section_type_confidence: '{section_type_confidence}'. "
297
+ "Valid: high, low, uncertain."
298
+ )
299
+
300
+ section_ulid = ulid or new_ulid()
301
+ with engine.begin() as conn:
302
+ row = conn.execute(
303
+ plan_sections.insert()
304
+ .values(
305
+ ulid=section_ulid,
306
+ plan_id=plan_id,
307
+ parent_section_id=parent_section_id,
308
+ heading=heading,
309
+ depth=depth,
310
+ slug=slug,
311
+ sort_order=sort_order,
312
+ section_type=section_type,
313
+ section_type_confidence=section_type_confidence,
314
+ needs_llm_review=needs_llm_review,
315
+ status=status,
316
+ source_lines=source_lines,
317
+ sprint_id=sprint_id,
318
+ )
319
+ .returning(plan_sections)
320
+ ).first()
321
+ assert row is not None, "INSERT RETURNING must return a row"
322
+ return row_to_dict(row)
323
+
324
+
325
+ def list_plan_sections(
326
+ engine: sa.Engine,
327
+ plan_id: int,
328
+ parent_section_id: int | None = None,
329
+ section_type: str | None = None,
330
+ include_deleted: bool = False,
331
+ ) -> list[dict]:
332
+ with engine.begin() as conn:
333
+ q = sa.select(plan_sections).where(plan_sections.c.plan_id == plan_id)
334
+ if parent_section_id is not None:
335
+ q = q.where(plan_sections.c.parent_section_id == parent_section_id)
336
+ if section_type:
337
+ q = q.where(plan_sections.c.section_type == section_type)
338
+ if not include_deleted:
339
+ q = q.where(plan_sections.c.deleted_at.is_(None))
340
+ q = q.order_by(plan_sections.c.sort_order, plan_sections.c.id)
341
+ return [row_to_dict(r) for r in conn.execute(q)]
342
+
343
+
344
+ def get_plan_section(engine: sa.Engine, section_id: int) -> dict | None:
345
+ with engine.begin() as conn:
346
+ row = conn.execute(
347
+ sa.select(plan_sections).where(
348
+ plan_sections.c.id == section_id,
349
+ plan_sections.c.deleted_at.is_(None),
350
+ )
351
+ ).first()
352
+ return row_to_dict(row) if row else None
353
+
354
+
355
+ def get_plan_section_by_ulid(engine: sa.Engine, ulid: str) -> dict | None:
356
+ with engine.begin() as conn:
357
+ row = conn.execute(
358
+ sa.select(plan_sections).where(
359
+ plan_sections.c.ulid == ulid,
360
+ plan_sections.c.deleted_at.is_(None),
361
+ )
362
+ ).first()
363
+ return row_to_dict(row) if row else None
364
+
365
+
366
+ def update_plan_section(engine: sa.Engine, section_id: int, **kwargs) -> dict | None:
367
+ allowed = {
368
+ "heading", "depth", "slug", "sort_order", "section_type",
369
+ "section_type_confidence", "needs_llm_review", "status",
370
+ "source_lines", "sprint_id", "parent_section_id",
371
+ }
372
+ values: dict = {}
373
+ for k, v in kwargs.items():
374
+ if k not in allowed or v is None:
375
+ continue
376
+ if k == "section_type":
377
+ _validate(v, VALID_SECTION_TYPES, "section_type")
378
+ elif k == "status":
379
+ _validate(v, VALID_SECTION_STATUSES, "status")
380
+ elif k == "section_type_confidence" and v not in {"high", "low", "uncertain"}:
381
+ raise ValueError(f"Invalid section_type_confidence: '{v}'")
382
+ elif k == "depth" and (v < 1 or v > 6):
383
+ raise ValueError(f"Invalid depth: {v}")
384
+ values[k] = v
385
+ if not values:
386
+ raise ValueError("No valid fields to update")
387
+ values["updated_at"] = datetime.now(timezone.utc)
388
+
389
+ with engine.begin() as conn:
390
+ result = conn.execute(
391
+ plan_sections.update()
392
+ .where(
393
+ plan_sections.c.id == section_id,
394
+ plan_sections.c.deleted_at.is_(None),
395
+ )
396
+ .values(**values)
397
+ )
398
+ if result.rowcount == 0:
399
+ return None
400
+ return get_plan_section(engine, section_id)
401
+
402
+
403
+ def delete_plan_section(engine: sa.Engine, section_id: int) -> bool:
404
+ now = datetime.now(timezone.utc)
405
+ with engine.begin() as conn:
406
+ result = conn.execute(
407
+ plan_sections.update()
408
+ .where(
409
+ plan_sections.c.id == section_id,
410
+ plan_sections.c.deleted_at.is_(None),
411
+ )
412
+ .values(deleted_at=now)
413
+ )
414
+ return result.rowcount > 0
415
+
416
+
417
+ # ══ Plan checkpoints ═════════════════════════════════════════════════════════
418
+
419
+ def add_plan_checkpoint(
420
+ engine: sa.Engine,
421
+ plan_section_id: int,
422
+ text: str,
423
+ status: str = "pending",
424
+ ulid: str | None = None,
425
+ sort_order: int = 0,
426
+ source_lines: str | None = None,
427
+ ) -> dict:
428
+ _validate(status, VALID_CP_STATUSES, "status")
429
+ if not text or not text.strip():
430
+ raise ValueError("Checkpoint text cannot be empty")
431
+ cp_ulid = ulid or new_ulid()
432
+ with engine.begin() as conn:
433
+ row = conn.execute(
434
+ plan_checkpoints.insert()
435
+ .values(
436
+ ulid=cp_ulid,
437
+ plan_section_id=plan_section_id,
438
+ text=text.strip(),
439
+ status=status,
440
+ sort_order=sort_order,
441
+ source_lines=source_lines,
442
+ )
443
+ .returning(plan_checkpoints)
444
+ ).first()
445
+ assert row is not None, "INSERT RETURNING must return a row"
446
+ return row_to_dict(row)
447
+
448
+
449
+ def list_plan_checkpoints(
450
+ engine: sa.Engine,
451
+ plan_section_id: int | None = None,
452
+ plan_id: int | None = None,
453
+ status: str | None = None,
454
+ include_deleted: bool = False,
455
+ ) -> list[dict]:
456
+ with engine.begin() as conn:
457
+ q = sa.select(plan_checkpoints)
458
+ if plan_section_id is not None:
459
+ q = q.where(plan_checkpoints.c.plan_section_id == plan_section_id)
460
+ if plan_id is not None:
461
+ q = q.join(
462
+ plan_sections, plan_checkpoints.c.plan_section_id == plan_sections.c.id
463
+ ).where(plan_sections.c.plan_id == plan_id)
464
+ if status:
465
+ q = q.where(plan_checkpoints.c.status == status)
466
+ if not include_deleted:
467
+ q = q.where(plan_checkpoints.c.deleted_at.is_(None))
468
+ q = q.order_by(plan_checkpoints.c.sort_order, plan_checkpoints.c.id)
469
+ return [row_to_dict(r) for r in conn.execute(q)]
470
+
471
+
472
+ def get_plan_checkpoint_by_ulid(engine: sa.Engine, ulid: str) -> dict | None:
473
+ with engine.begin() as conn:
474
+ row = conn.execute(
475
+ sa.select(plan_checkpoints).where(
476
+ plan_checkpoints.c.ulid == ulid,
477
+ plan_checkpoints.c.deleted_at.is_(None),
478
+ )
479
+ ).first()
480
+ return row_to_dict(row) if row else None
481
+
482
+
483
+ def _set_checkpoint_status(
484
+ engine: sa.Engine, checkpoint_id: int, new_status: str
485
+ ) -> dict | None:
486
+ _validate(new_status, VALID_CP_STATUSES, "status")
487
+ now = datetime.now(timezone.utc)
488
+ with engine.begin() as conn:
489
+ result = conn.execute(
490
+ plan_checkpoints.update()
491
+ .where(
492
+ plan_checkpoints.c.id == checkpoint_id,
493
+ plan_checkpoints.c.deleted_at.is_(None),
494
+ )
495
+ .values(status=new_status, updated_at=now)
496
+ )
497
+ if result.rowcount == 0:
498
+ return None
499
+ row = conn.execute(
500
+ sa.select(plan_checkpoints).where(plan_checkpoints.c.id == checkpoint_id)
501
+ ).first()
502
+ return row_to_dict(row) if row else None
503
+
504
+
505
+ def complete_plan_checkpoint(engine: sa.Engine, checkpoint_id: int) -> dict | None:
506
+ return _set_checkpoint_status(engine, checkpoint_id, "done")
507
+
508
+
509
+ def reject_plan_checkpoint(engine: sa.Engine, checkpoint_id: int) -> dict | None:
510
+ return _set_checkpoint_status(engine, checkpoint_id, "rejected")
511
+
512
+
513
+ def reopen_plan_checkpoint(engine: sa.Engine, checkpoint_id: int) -> dict | None:
514
+ """Revert a checkpoint to pending (undo done/rejected)."""
515
+ return _set_checkpoint_status(engine, checkpoint_id, "pending")
516
+
517
+
518
+ # ══ Cross-entity: promote section/checkpoint → standalone item ═══════════════
519
+
520
+ def promote_section_to_item(
521
+ engine: sa.Engine,
522
+ section_id: int,
523
+ item_type: str,
524
+ priority: str = "P2",
525
+ title: str | None = None,
526
+ ) -> dict:
527
+ """Create an `items` row from a plan_section and back-link via converted_to_item_id.
528
+
529
+ Used when a section deserves its own lifecycle (e.g. "Sprint 3" becomes
530
+ a tracked sprint-feature). The section remains in the plan as a
531
+ read-only pointer — `converted_to_item_id` marks the promotion.
532
+ """
533
+ from dct.core.items import create_item
534
+ section = get_plan_section(engine, section_id)
535
+ if section is None:
536
+ raise ValueError(f"Plan section #{section_id} not found")
537
+
538
+ # Derive project_slug via plan → project
539
+ with engine.begin() as conn:
540
+ plan_row = conn.execute(
541
+ sa.select(plans.c.project_id).where(plans.c.id == section["plan_id"])
542
+ ).first()
543
+ if plan_row is None:
544
+ raise ValueError(f"Plan #{section['plan_id']} not found for section")
545
+ from dct.db import projects as projects_tbl
546
+ project_row = conn.execute(
547
+ sa.select(projects_tbl.c.slug).where(projects_tbl.c.id == plan_row.project_id)
548
+ ).first()
549
+ assert project_row is not None
550
+ project_slug = project_row.slug
551
+
552
+ effective_title = title or section["heading"]
553
+ item = create_item(
554
+ engine, project_slug, item_type, effective_title,
555
+ priority=priority,
556
+ )
557
+ now = datetime.now(timezone.utc)
558
+ with engine.begin() as conn:
559
+ conn.execute(
560
+ plan_sections.update()
561
+ .where(plan_sections.c.id == section_id)
562
+ .values(converted_to_item_id=item["id"], updated_at=now)
563
+ )
564
+ return item
565
+
566
+
567
+ def promote_section_to_sprint(
568
+ engine: sa.Engine,
569
+ section_id: int,
570
+ code: str,
571
+ goal: str | None = None,
572
+ name: str | None = None,
573
+ kind: str = "sprint",
574
+ ) -> dict:
575
+ """Materialize a plan_section as a first-class sprint row.
576
+
577
+ Contrast with `promote_section_to_item`: that one converts a section
578
+ into a trackable `items` row. This one promotes a section that *is*
579
+ a sprint-in-prose (`## Sprint 1 — Hub Redesign`) into a queryable
580
+ `sprints` entry with lifecycle + goal + unique project-scoped `code`.
581
+
582
+ Back-link via `plan_sections.sprint_id` (FK already present in schema,
583
+ no migration needed). The section itself stays in the plan.
584
+
585
+ `code` must be unique within the project. Heading-based auto-codes are
586
+ a bad idea because plan A's "Sprint 1" collides with plan B's "Sprint 1";
587
+ caller (CLI / skill) picks the namespaced code, e.g. `v0.2-hub`.
588
+
589
+ `name` defaults to section.heading; `goal` is optional free-text.
590
+ """
591
+ from dct.core.sprints import create_sprint
592
+ section = get_plan_section(engine, section_id)
593
+ if section is None:
594
+ raise ValueError(f"Plan section #{section_id} not found")
595
+
596
+ # Derive project_slug via plan → project
597
+ with engine.begin() as conn:
598
+ plan_row = conn.execute(
599
+ sa.select(plans.c.project_id).where(plans.c.id == section["plan_id"])
600
+ ).first()
601
+ if plan_row is None:
602
+ raise ValueError(f"Plan #{section['plan_id']} not found for section")
603
+ from dct.db import projects as projects_tbl
604
+ project_row = conn.execute(
605
+ sa.select(projects_tbl.c.slug).where(projects_tbl.c.id == plan_row.project_id)
606
+ ).first()
607
+ assert project_row is not None
608
+ project_slug = project_row.slug
609
+
610
+ effective_name = name or section["heading"]
611
+ sprint = create_sprint(
612
+ engine, project_slug, name=effective_name,
613
+ code=code, kind=kind, goal=goal,
614
+ )
615
+ now = datetime.now(timezone.utc)
616
+ with engine.begin() as conn:
617
+ conn.execute(
618
+ plan_sections.update()
619
+ .where(plan_sections.c.id == section_id)
620
+ .values(sprint_id=sprint["id"], updated_at=now)
621
+ )
622
+ return sprint
623
+
624
+
625
+ def promote_checkpoint_to_item(
626
+ engine: sa.Engine,
627
+ checkpoint_id: int,
628
+ item_type: str,
629
+ priority: str = "P2",
630
+ title: str | None = None,
631
+ ) -> dict:
632
+ """Create an items row from a plan_checkpoint and back-link."""
633
+ from dct.core.items import create_item
634
+ with engine.begin() as conn:
635
+ cp_row = conn.execute(
636
+ sa.select(plan_checkpoints).where(
637
+ plan_checkpoints.c.id == checkpoint_id,
638
+ plan_checkpoints.c.deleted_at.is_(None),
639
+ )
640
+ ).first()
641
+ if cp_row is None:
642
+ raise ValueError(f"Plan checkpoint #{checkpoint_id} not found")
643
+ cp = row_to_dict(cp_row)
644
+
645
+ section_row = conn.execute(
646
+ sa.select(plan_sections.c.plan_id).where(
647
+ plan_sections.c.id == cp["plan_section_id"]
648
+ )
649
+ ).first()
650
+ assert section_row is not None
651
+ plan_row = conn.execute(
652
+ sa.select(plans.c.project_id).where(plans.c.id == section_row.plan_id)
653
+ ).first()
654
+ assert plan_row is not None
655
+ from dct.db import projects as projects_tbl
656
+ project_row = conn.execute(
657
+ sa.select(projects_tbl.c.slug).where(projects_tbl.c.id == plan_row.project_id)
658
+ ).first()
659
+ assert project_row is not None
660
+ project_slug = project_row.slug
661
+
662
+ effective_title = title or cp["text"][:500]
663
+ item = create_item(
664
+ engine, project_slug, item_type, effective_title,
665
+ priority=priority,
666
+ )
667
+ now = datetime.now(timezone.utc)
668
+ with engine.begin() as conn:
669
+ conn.execute(
670
+ plan_checkpoints.update()
671
+ .where(plan_checkpoints.c.id == checkpoint_id)
672
+ .values(converted_to_item_id=item["id"], updated_at=now)
673
+ )
674
+ return item
675
+
676
+
677
+ # Unused import placeholder — keeps the linter quiet that items_tbl is imported
678
+ # but not referenced directly. Core modules (resolve_item etc.) sometimes expect
679
+ # the table object to be importable from this module.
680
+ _ = items_tbl