dctracker 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dct/__init__.py +6 -0
- dct/cli.py +1659 -0
- dct/config.py +150 -0
- dct/core/__init__.py +0 -0
- dct/core/analytics.py +382 -0
- dct/core/changelog.py +112 -0
- dct/core/checkpoints.py +205 -0
- dct/core/decisions.py +238 -0
- dct/core/engine.py +32 -0
- dct/core/handoff.py +151 -0
- dct/core/ids.py +25 -0
- dct/core/items.py +382 -0
- dct/core/plans/__init__.py +6 -0
- dct/core/plans/classify.py +94 -0
- dct/core/plans/crud.py +680 -0
- dct/core/plans/ingest.py +408 -0
- dct/core/plans/parser.py +312 -0
- dct/core/projects.py +298 -0
- dct/core/sprint.py +315 -0
- dct/core/sprints.py +601 -0
- dct/core/version.py +116 -0
- dct/db.py +558 -0
- dct/export.py +107 -0
- dct/hooks/check-changelog-on-stop.sh +49 -0
- dct/hooks/check-changelog.sh +143 -0
- dct/hooks/dct-plan-autoscan.sh +54 -0
- dct/hooks/dct-task-mirror.sh +62 -0
- dct/server.py +1812 -0
- dct/setup.py +258 -0
- dct/skills/clog/SKILL.md +73 -0
- dct/skills/commit/SKILL.md +153 -0
- dct/skills/dct-import/SKILL.md +329 -0
- dct/skills/dct-init/SKILL.md +289 -0
- dct/skills/handoff/SKILL.md +136 -0
- dct/skills/pickup/SKILL.md +115 -0
- dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
- dct/skills/plan-status/SKILL.md +79 -0
- dct/skills/release/SKILL.md +281 -0
- dct/skills/track/SKILL.md +121 -0
- dct/skills/track-list/SKILL.md +134 -0
- dct/skills/track-resolve/SKILL.md +53 -0
- dct/skills/track-update/SKILL.md +109 -0
- dct/web/__init__.py +1 -0
- dct/web/app.py +83 -0
- dct/web/blueprints/__init__.py +5 -0
- dct/web/blueprints/analytics.py +34 -0
- dct/web/blueprints/changelog.py +40 -0
- dct/web/blueprints/decisions.py +23 -0
- dct/web/blueprints/events.py +69 -0
- dct/web/blueprints/handoffs.py +26 -0
- dct/web/blueprints/home.py +15 -0
- dct/web/blueprints/items.py +128 -0
- dct/web/blueprints/plans.py +53 -0
- dct/web/blueprints/projects.py +23 -0
- dct/web/blueprints/sprints.py +71 -0
- dct/web/changes.py +77 -0
- dct/web/serializers.py +109 -0
- dct/web/static/app.css +609 -0
- dct/web/static/app.js +62 -0
- dct/web/static/vendor/SOURCES.txt +10 -0
- dct/web/static/vendor/htmx.min.js +1 -0
- dct/web/static/vendor/uPlot.iife.min.js +2 -0
- dct/web/static/vendor/uPlot.min.css +1 -0
- dct/web/templates/analytics.html +63 -0
- dct/web/templates/base.html +106 -0
- dct/web/templates/changelog/list.html +23 -0
- dct/web/templates/decisions/list.html +22 -0
- dct/web/templates/handoffs/list.html +45 -0
- dct/web/templates/home.html +20 -0
- dct/web/templates/item_detail.html +91 -0
- dct/web/templates/items_list.html +44 -0
- dct/web/templates/partials/_bars.html +15 -0
- dct/web/templates/partials/_checkpoint_steps.html +22 -0
- dct/web/templates/partials/_items_table.html +23 -0
- dct/web/templates/partials/_plan_section.html +24 -0
- dct/web/templates/partials/_project_card.html +24 -0
- dct/web/templates/plans/detail.html +16 -0
- dct/web/templates/plans/list.html +15 -0
- dct/web/templates/project_home.html +51 -0
- dct/web/templates/sprints/detail.html +37 -0
- dct/web/templates/sprints/list.html +35 -0
- dctracker-1.0.0.dist-info/METADATA +357 -0
- dctracker-1.0.0.dist-info/RECORD +87 -0
- dctracker-1.0.0.dist-info/WHEEL +5 -0
- dctracker-1.0.0.dist-info/entry_points.txt +2 -0
- dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
- dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/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
|