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/items.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Item CRUD operations with soft deletes and validation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
|
|
8
|
+
from dct.db import items, item_notes, item_checkpoints, item_sprints, sprints, row_to_dict
|
|
9
|
+
from dct.core.projects import resolve_project_id
|
|
10
|
+
|
|
11
|
+
VALID_TYPES = {"issue", "todo", "feature", "idea", "improvement"}
|
|
12
|
+
VALID_PRIORITIES = {"P1", "P2", "P3", "future"}
|
|
13
|
+
VALID_STATUSES = {"open", "in_progress", "resolved", "wont_fix"}
|
|
14
|
+
TERMINAL_STATUSES = {"resolved", "wont_fix"}
|
|
15
|
+
OPEN_STATUSES = {"open", "in_progress"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _validate_enum(value: str, valid: set[str], field_name: str) -> str:
|
|
19
|
+
if value not in valid:
|
|
20
|
+
raise ValueError(f"Invalid {field_name}: '{value}'. Valid: {', '.join(sorted(valid))}")
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _serialize_tags(tags: list[str] | None) -> str:
|
|
25
|
+
"""Serialize tags for DB storage. Accepts list[str] only — callers that
|
|
26
|
+
receive CSV/string input (CLI `--tags "a,b"`) MUST split into list before
|
|
27
|
+
calling core. Historical bug: accepting `str` here meant MCP clients that
|
|
28
|
+
couldn't send array types ended up passing the JSON-stringified list,
|
|
29
|
+
which was then naively split on `,` into mangled fragments. Fix lifts
|
|
30
|
+
normalization to the boundary (MCP = list[str] schema; CLI = split in
|
|
31
|
+
`cmd_add`); core stays pure.
|
|
32
|
+
"""
|
|
33
|
+
if tags is None:
|
|
34
|
+
return "[]"
|
|
35
|
+
assert isinstance(tags, list), f"tags must be list[str], got {type(tags).__name__}"
|
|
36
|
+
return json.dumps([t for t in tags if t])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_tags(tags_str: str | None) -> list[str]:
|
|
40
|
+
if not tags_str:
|
|
41
|
+
return []
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(tags_str)
|
|
44
|
+
except (json.JSONDecodeError, TypeError):
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _current_sprint_exists():
|
|
49
|
+
"""Correlated EXISTS: the item has an active item_sprints link to its
|
|
50
|
+
project's active sprint. This IS `in_current_sprint` (#216) — derived on
|
|
51
|
+
read, never stored. Use as a labeled column in SELECT or directly in WHERE."""
|
|
52
|
+
return (
|
|
53
|
+
sa.select(sa.literal(1))
|
|
54
|
+
.select_from(item_sprints.join(sprints, sprints.c.id == item_sprints.c.sprint_id))
|
|
55
|
+
.where(
|
|
56
|
+
item_sprints.c.item_id == items.c.id,
|
|
57
|
+
item_sprints.c.removed_at.is_(None),
|
|
58
|
+
sprints.c.status == "active",
|
|
59
|
+
sprints.c.deleted_at.is_(None),
|
|
60
|
+
)
|
|
61
|
+
.exists()
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _item_to_dict(row: sa.Row) -> dict:
|
|
66
|
+
d = row_to_dict(row)
|
|
67
|
+
d["tags"] = _parse_tags(d.get("tags"))
|
|
68
|
+
if "in_current_sprint" in d: # present only when the derived column was selected
|
|
69
|
+
d["in_current_sprint"] = bool(d["in_current_sprint"])
|
|
70
|
+
return d
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _item_with_cp_to_dict(row_mapping) -> dict:
|
|
74
|
+
"""Convert an items row enriched with cp_done/cp_total aggregates into a dict
|
|
75
|
+
with a compact `cp: [done, total]` field replacing the two raw columns.
|
|
76
|
+
"""
|
|
77
|
+
d = dict(row_mapping)
|
|
78
|
+
d["tags"] = _parse_tags(d.get("tags"))
|
|
79
|
+
if "in_current_sprint" in d:
|
|
80
|
+
d["in_current_sprint"] = bool(d["in_current_sprint"])
|
|
81
|
+
done = int(d.pop("cp_done", 0) or 0)
|
|
82
|
+
total = int(d.pop("cp_total", 0) or 0)
|
|
83
|
+
d["cp"] = [done, total]
|
|
84
|
+
return d
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create_item(
|
|
88
|
+
engine: sa.Engine,
|
|
89
|
+
project_slug: str,
|
|
90
|
+
item_type: str,
|
|
91
|
+
title: str,
|
|
92
|
+
description: str | None = None,
|
|
93
|
+
priority: str = "P2",
|
|
94
|
+
status: str = "open",
|
|
95
|
+
component: str | None = None,
|
|
96
|
+
source_file: str | None = None,
|
|
97
|
+
source_lines: str | None = None,
|
|
98
|
+
tags: list[str] | None = None,
|
|
99
|
+
) -> dict:
|
|
100
|
+
if not title or not title.strip():
|
|
101
|
+
raise ValueError("Item title cannot be empty")
|
|
102
|
+
_validate_enum(item_type, VALID_TYPES, "item_type")
|
|
103
|
+
_validate_enum(priority, VALID_PRIORITIES, "priority")
|
|
104
|
+
_validate_enum(status, VALID_STATUSES, "status")
|
|
105
|
+
|
|
106
|
+
with engine.begin() as conn:
|
|
107
|
+
project_id = resolve_project_id(conn, project_slug)
|
|
108
|
+
row = conn.execute(
|
|
109
|
+
items.insert()
|
|
110
|
+
.values(
|
|
111
|
+
project_id=project_id,
|
|
112
|
+
item_type=item_type,
|
|
113
|
+
title=title,
|
|
114
|
+
description=description,
|
|
115
|
+
priority=priority,
|
|
116
|
+
status=status,
|
|
117
|
+
component=component,
|
|
118
|
+
source_file=source_file,
|
|
119
|
+
source_lines=source_lines,
|
|
120
|
+
tags=_serialize_tags(tags),
|
|
121
|
+
)
|
|
122
|
+
.returning(items)
|
|
123
|
+
).first()
|
|
124
|
+
assert row is not None
|
|
125
|
+
d = _item_to_dict(row)
|
|
126
|
+
d["in_current_sprint"] = False # a brand-new item belongs to no sprint
|
|
127
|
+
return d
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def list_items(
|
|
131
|
+
engine: sa.Engine,
|
|
132
|
+
project_slug: str | None = None,
|
|
133
|
+
status: str | None = None,
|
|
134
|
+
item_type: str | None = None,
|
|
135
|
+
priority: str | None = None,
|
|
136
|
+
component: str | None = None,
|
|
137
|
+
search: str | None = None,
|
|
138
|
+
in_current_sprint: bool | None = None,
|
|
139
|
+
limit: int = 50,
|
|
140
|
+
) -> list[dict]:
|
|
141
|
+
# Priority ordering: P1 first, future last
|
|
142
|
+
priority_order = sa.case(
|
|
143
|
+
(items.c.priority == "P1", 1),
|
|
144
|
+
(items.c.priority == "P2", 2),
|
|
145
|
+
(items.c.priority == "P3", 3),
|
|
146
|
+
(items.c.priority == "future", 4),
|
|
147
|
+
else_=5,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Checkpoint progress aggregated in a single query — avoids N+1.
|
|
151
|
+
# cp_done = count of done, cp_total = done + pending (rejected excluded from "real" total).
|
|
152
|
+
cp_done = sa.func.coalesce(
|
|
153
|
+
sa.func.sum(sa.case((item_checkpoints.c.status == "done", 1), else_=0)),
|
|
154
|
+
0,
|
|
155
|
+
).label("cp_done")
|
|
156
|
+
cp_total = sa.func.coalesce(
|
|
157
|
+
sa.func.sum(sa.case((item_checkpoints.c.status != "rejected", 1), else_=0)),
|
|
158
|
+
0,
|
|
159
|
+
).label("cp_total")
|
|
160
|
+
|
|
161
|
+
q = (
|
|
162
|
+
sa.select(items, cp_done, cp_total, _current_sprint_exists().label("in_current_sprint"))
|
|
163
|
+
.select_from(
|
|
164
|
+
items.outerjoin(
|
|
165
|
+
item_checkpoints,
|
|
166
|
+
sa.and_(
|
|
167
|
+
item_checkpoints.c.item_id == items.c.id,
|
|
168
|
+
item_checkpoints.c.deleted_at.is_(None),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
.where(items.c.deleted_at.is_(None))
|
|
173
|
+
.group_by(items.c.id)
|
|
174
|
+
.order_by(priority_order, items.c.created_at.desc())
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if status:
|
|
178
|
+
q = q.where(items.c.status == status)
|
|
179
|
+
if item_type:
|
|
180
|
+
q = q.where(items.c.item_type == item_type)
|
|
181
|
+
if priority:
|
|
182
|
+
q = q.where(items.c.priority == priority)
|
|
183
|
+
if component:
|
|
184
|
+
q = q.where(items.c.component == component)
|
|
185
|
+
if in_current_sprint is not None:
|
|
186
|
+
exists_active = _current_sprint_exists()
|
|
187
|
+
q = q.where(exists_active if in_current_sprint else ~exists_active)
|
|
188
|
+
if search:
|
|
189
|
+
pattern = f"%{search}%"
|
|
190
|
+
q = q.where(sa.or_(items.c.title.ilike(pattern), items.c.description.ilike(pattern)))
|
|
191
|
+
if limit:
|
|
192
|
+
q = q.limit(limit)
|
|
193
|
+
|
|
194
|
+
with engine.begin() as conn:
|
|
195
|
+
if project_slug:
|
|
196
|
+
pid = resolve_project_id(conn, project_slug)
|
|
197
|
+
q = q.where(items.c.project_id == pid)
|
|
198
|
+
return [_item_with_cp_to_dict(r._mapping) for r in conn.execute(q)]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_item(engine: sa.Engine, item_id: int) -> dict | None:
|
|
202
|
+
from dct.db import item_checkpoints
|
|
203
|
+
from dct.core.checkpoints import get_progress
|
|
204
|
+
|
|
205
|
+
with engine.begin() as conn:
|
|
206
|
+
row = conn.execute(
|
|
207
|
+
sa.select(items, _current_sprint_exists().label("in_current_sprint"))
|
|
208
|
+
.where(items.c.id == item_id, items.c.deleted_at.is_(None))
|
|
209
|
+
).first()
|
|
210
|
+
if not row:
|
|
211
|
+
return None
|
|
212
|
+
d = _item_to_dict(row)
|
|
213
|
+
notes = conn.execute(
|
|
214
|
+
sa.select(item_notes)
|
|
215
|
+
.where(item_notes.c.item_id == item_id, item_notes.c.deleted_at.is_(None))
|
|
216
|
+
.order_by(item_notes.c.created_at)
|
|
217
|
+
).fetchall()
|
|
218
|
+
d["notes"] = [row_to_dict(n) for n in notes]
|
|
219
|
+
|
|
220
|
+
checkpoints = conn.execute(
|
|
221
|
+
sa.select(item_checkpoints)
|
|
222
|
+
.where(item_checkpoints.c.item_id == item_id, item_checkpoints.c.deleted_at.is_(None))
|
|
223
|
+
.order_by(item_checkpoints.c.sort_order, item_checkpoints.c.created_at)
|
|
224
|
+
).fetchall()
|
|
225
|
+
d["checkpoints"] = [row_to_dict(c) for c in checkpoints]
|
|
226
|
+
|
|
227
|
+
d["progress"] = get_progress(engine, item_id)
|
|
228
|
+
return d
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def update_item(engine: sa.Engine, item_id: int, **kwargs) -> dict | None:
|
|
232
|
+
# v0.2+ guard: direct writes to `in_current_sprint` are forbidden.
|
|
233
|
+
# The boolean is now derived from item_sprints; callers must use
|
|
234
|
+
# assign_item_to_sprint / remove_item_from_sprint instead. Internal
|
|
235
|
+
# auto-clear on terminal status transitions remains (see below).
|
|
236
|
+
if "in_current_sprint" in kwargs:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"items.in_current_sprint is deprecated in v0.2.0 — use "
|
|
239
|
+
"assign_item_to_sprint(item_id, sprint_id) or "
|
|
240
|
+
"remove_item_from_sprint(item_id, sprint_id) instead. "
|
|
241
|
+
"See docs/superpowers/specs/2026-04-17-plans-roadmaps-decisions-design.md §5.2."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
allowed = {"title", "description", "priority", "status", "component", "source_file", "source_lines", "tags", "resolution", "item_type"}
|
|
245
|
+
values = {}
|
|
246
|
+
for k, v in kwargs.items():
|
|
247
|
+
if k not in allowed or v is None:
|
|
248
|
+
continue
|
|
249
|
+
if k == "priority":
|
|
250
|
+
_validate_enum(v, VALID_PRIORITIES, "priority")
|
|
251
|
+
elif k == "status":
|
|
252
|
+
_validate_enum(v, VALID_STATUSES, "status")
|
|
253
|
+
elif k == "item_type":
|
|
254
|
+
_validate_enum(v, VALID_TYPES, "item_type")
|
|
255
|
+
elif k == "tags":
|
|
256
|
+
v = _serialize_tags(v)
|
|
257
|
+
if v == "" and k in {"component", "source_file", "source_lines", "resolution", "description"}:
|
|
258
|
+
v = None
|
|
259
|
+
values[k] = v
|
|
260
|
+
|
|
261
|
+
if not values:
|
|
262
|
+
raise ValueError("No valid fields to update")
|
|
263
|
+
|
|
264
|
+
now = datetime.now(timezone.utc)
|
|
265
|
+
values["updated_at"] = now
|
|
266
|
+
|
|
267
|
+
# Invariant: terminal status ↔ resolved_at set, AND terminal status ⇒ the
|
|
268
|
+
# item leaves the current sprint. Membership lives in item_sprints now, so
|
|
269
|
+
# "leave the sprint" = soft-remove its active links (no boolean to flip).
|
|
270
|
+
leaving_sprint = False
|
|
271
|
+
if "status" in values:
|
|
272
|
+
if values["status"] in TERMINAL_STATUSES:
|
|
273
|
+
values.setdefault("resolved_at", now)
|
|
274
|
+
leaving_sprint = True
|
|
275
|
+
elif values["status"] in OPEN_STATUSES:
|
|
276
|
+
values["resolved_at"] = None
|
|
277
|
+
|
|
278
|
+
with engine.begin() as conn:
|
|
279
|
+
result = conn.execute(items.update().where(items.c.id == item_id, items.c.deleted_at.is_(None)).values(**values))
|
|
280
|
+
if result.rowcount == 0:
|
|
281
|
+
return None
|
|
282
|
+
if leaving_sprint:
|
|
283
|
+
conn.execute(
|
|
284
|
+
item_sprints.update()
|
|
285
|
+
.where(item_sprints.c.item_id == item_id, item_sprints.c.removed_at.is_(None))
|
|
286
|
+
.values(removed_at=now)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return get_item(engine, item_id)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def resolve_item(
|
|
293
|
+
engine: sa.Engine,
|
|
294
|
+
item_id: int,
|
|
295
|
+
status: str = "resolved",
|
|
296
|
+
resolution: str | None = None,
|
|
297
|
+
) -> dict | None:
|
|
298
|
+
"""Transition item to terminal status (resolved / wont_fix).
|
|
299
|
+
|
|
300
|
+
Gate check: if any pending gate checkpoint exists for this item
|
|
301
|
+
(kind not in {'task','cc-task'}, status='pending'), raises ValueError.
|
|
302
|
+
Gates must be explicitly completed (done) or rejected (not applicable).
|
|
303
|
+
|
|
304
|
+
Dogfood gate rationale: user-facing features (new skills, CLI commands,
|
|
305
|
+
MCP tools) carry a `kind='dogfood'` checkpoint as their last one — this
|
|
306
|
+
enforces that the feature was validated by the user in a fresh session,
|
|
307
|
+
not just self-reviewed by the implementer. See CLAUDE.md "Gate
|
|
308
|
+
checkpoints" section.
|
|
309
|
+
"""
|
|
310
|
+
from dct.core.checkpoints import list_pending_gates
|
|
311
|
+
|
|
312
|
+
_validate_enum(status, {"resolved", "wont_fix"}, "resolve status")
|
|
313
|
+
|
|
314
|
+
pending_gates = list_pending_gates(engine, item_id)
|
|
315
|
+
if pending_gates:
|
|
316
|
+
gate_summary = ", ".join(f"#{g['id']} [{g['kind']}]" for g in pending_gates[:3])
|
|
317
|
+
more = f" (+{len(pending_gates) - 3} more)" if len(pending_gates) > 3 else ""
|
|
318
|
+
raise ValueError(
|
|
319
|
+
f"Cannot resolve item #{item_id}: {len(pending_gates)} gate checkpoint(s) "
|
|
320
|
+
f"pending — {gate_summary}{more}. Complete them (done) or reject them "
|
|
321
|
+
f"(not applicable) before resolving. See CLAUDE.md 'Gate checkpoints'."
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
now = datetime.now(timezone.utc)
|
|
325
|
+
values: dict = {
|
|
326
|
+
"status": status,
|
|
327
|
+
"resolved_at": now,
|
|
328
|
+
"updated_at": now,
|
|
329
|
+
}
|
|
330
|
+
if resolution:
|
|
331
|
+
values["resolution"] = resolution
|
|
332
|
+
|
|
333
|
+
with engine.begin() as conn:
|
|
334
|
+
result = conn.execute(items.update().where(items.c.id == item_id, items.c.deleted_at.is_(None)).values(**values))
|
|
335
|
+
if result.rowcount == 0:
|
|
336
|
+
return None
|
|
337
|
+
# Terminal status ⇒ leave the current sprint: soft-remove active links.
|
|
338
|
+
conn.execute(
|
|
339
|
+
item_sprints.update()
|
|
340
|
+
.where(item_sprints.c.item_id == item_id, item_sprints.c.removed_at.is_(None))
|
|
341
|
+
.values(removed_at=now)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return get_item(engine, item_id)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def add_note(engine: sa.Engine, item_id: int, note: str) -> dict:
|
|
348
|
+
if not note or not note.strip():
|
|
349
|
+
raise ValueError("Note cannot be empty")
|
|
350
|
+
with engine.begin() as conn:
|
|
351
|
+
# Verify item exists
|
|
352
|
+
row = conn.execute(
|
|
353
|
+
sa.select(items.c.id).where(items.c.id == item_id, items.c.deleted_at.is_(None))
|
|
354
|
+
).first()
|
|
355
|
+
if not row:
|
|
356
|
+
raise ValueError(f"Item #{item_id} not found")
|
|
357
|
+
|
|
358
|
+
row = conn.execute(
|
|
359
|
+
item_notes.insert().values(item_id=item_id, note=note).returning(item_notes)
|
|
360
|
+
).first()
|
|
361
|
+
assert row is not None
|
|
362
|
+
return row_to_dict(row)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def list_notes(engine: sa.Engine, item_id: int) -> list[dict]:
|
|
366
|
+
with engine.begin() as conn:
|
|
367
|
+
rows = conn.execute(
|
|
368
|
+
sa.select(item_notes)
|
|
369
|
+
.where(item_notes.c.item_id == item_id, item_notes.c.deleted_at.is_(None))
|
|
370
|
+
.order_by(item_notes.c.created_at)
|
|
371
|
+
).fetchall()
|
|
372
|
+
return [row_to_dict(r) for r in rows]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def delete_item(engine: sa.Engine, item_id: int) -> bool:
|
|
376
|
+
with engine.begin() as conn:
|
|
377
|
+
result = conn.execute(
|
|
378
|
+
items.update()
|
|
379
|
+
.where(items.c.id == item_id, items.c.deleted_at.is_(None))
|
|
380
|
+
.values(deleted_at=datetime.now(timezone.utc))
|
|
381
|
+
)
|
|
382
|
+
return result.rowcount > 0
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""dct plans subsystem: parser, classifier, ingest, rescan.
|
|
2
|
+
|
|
3
|
+
This is where the v0.2.0 plans/specs/roadmap/ADR handling lives. See the
|
|
4
|
+
spec at docs/superpowers/specs/2026-04-17-plans-roadmaps-decisions-design.md
|
|
5
|
+
and the implementation checkpoints on dct item #26.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""LLM classification of uncertain plan sections — headless Claude CLI wrapper.
|
|
2
|
+
|
|
3
|
+
We do NOT take an Anthropic SDK dependency; instead we delegate to whichever
|
|
4
|
+
`claude` binary the user already has in PATH (i.e. Claude Code's CLI). This
|
|
5
|
+
reuses the user's credentials, billing, and model configuration with zero
|
|
6
|
+
extra setup. Hidden cost: CC prepends its system prompt + CLAUDE.md, adding
|
|
7
|
+
a few k input tokens per call. Haiku at current prices makes this noise
|
|
8
|
+
($0.001/classification).
|
|
9
|
+
|
|
10
|
+
If `claude` is not in PATH, the function returns None — callers treat that
|
|
11
|
+
as "leave the section uncertain; no classification possible".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
|
|
19
|
+
VALID_SECTION_TYPES: frozenset[str] = frozenset({
|
|
20
|
+
"sprint", "phase", "task", "block", "milestone",
|
|
21
|
+
"decision", "prose", "changelog", "adr_section",
|
|
22
|
+
"reference", "generic",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
DEFAULT_MODEL = "claude-haiku-4-5-20251001"
|
|
26
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_prompt(heading: str, parent_heading: str | None, preview: str) -> str:
|
|
30
|
+
"""Assemble the classification prompt sent to `claude --print`."""
|
|
31
|
+
labels = ", ".join(sorted(VALID_SECTION_TYPES))
|
|
32
|
+
parts = [
|
|
33
|
+
"Classify this plan section.",
|
|
34
|
+
f"Heading: {heading!r}.",
|
|
35
|
+
]
|
|
36
|
+
if parent_heading:
|
|
37
|
+
parts.append(f"Parent heading: {parent_heading!r}.")
|
|
38
|
+
if preview:
|
|
39
|
+
parts.append(f"First line: {preview!r}.")
|
|
40
|
+
parts.append(f"Respond with exactly one label from: {labels}.")
|
|
41
|
+
parts.append("Respond with just the label — no prose, no quotes, no markdown.")
|
|
42
|
+
return " ".join(parts)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sanitize_response(raw: str) -> str | None:
|
|
46
|
+
"""Normalize model output and return a valid section_type or None.
|
|
47
|
+
|
|
48
|
+
Handles: whitespace, trailing newlines, wrapping quotes/backticks,
|
|
49
|
+
accidental multi-word output (we take the first word). Case-insensitive.
|
|
50
|
+
"""
|
|
51
|
+
stripped = raw.strip().strip("`\"' ")
|
|
52
|
+
if not stripped:
|
|
53
|
+
return None
|
|
54
|
+
# First "word" — splits on any whitespace including newlines.
|
|
55
|
+
first = stripped.split()[0].strip("`\"',.")
|
|
56
|
+
label = first.lower()
|
|
57
|
+
return label if label in VALID_SECTION_TYPES else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def classify_uncertain(
|
|
61
|
+
heading: str,
|
|
62
|
+
parent_heading: str | None = None,
|
|
63
|
+
preview: str = "",
|
|
64
|
+
model: str = DEFAULT_MODEL,
|
|
65
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
66
|
+
) -> str | None:
|
|
67
|
+
"""Classify an uncertain plan section via `claude --print` headless mode.
|
|
68
|
+
|
|
69
|
+
Returns a label from VALID_SECTION_TYPES, or None if:
|
|
70
|
+
- `claude` binary is not in PATH,
|
|
71
|
+
- the subprocess fails / times out / exits non-zero,
|
|
72
|
+
- the model response cannot be sanitized to a valid label.
|
|
73
|
+
|
|
74
|
+
Never raises — the caller treats None as "leave uncertain".
|
|
75
|
+
"""
|
|
76
|
+
if not shutil.which("claude"):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
prompt = _build_prompt(heading, parent_heading, preview)
|
|
80
|
+
try:
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
["claude", "--print", "--model", model, prompt],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=timeout_seconds,
|
|
86
|
+
check=False,
|
|
87
|
+
)
|
|
88
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
if result.returncode != 0:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
return _sanitize_response(result.stdout)
|