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/sprint.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Current-sprint operations + review dashboards (session start/end).
|
|
2
|
+
|
|
3
|
+
Thin, v0.1-compatible surface (set_sprint / clear_sprint / list_sprint_items /
|
|
4
|
+
get_sprint_review) that delegates to the item_sprints model in core/sprints —
|
|
5
|
+
the project's single active sprint IS the "current sprint" (#216). No boolean
|
|
6
|
+
column is read or written here; in_current_sprint is derived."""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
|
|
12
|
+
from dct.db import items, item_checkpoints, changelog_entries, session_handoffs, row_to_dict
|
|
13
|
+
from dct.core.projects import resolve_project_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_sprint(engine: sa.Engine, project_slug: str, item_ids: list[int]) -> dict:
|
|
17
|
+
"""Replace the current sprint with exactly the given items (the /handoff
|
|
18
|
+
entry point).
|
|
19
|
+
|
|
20
|
+
v0.2+ (#216): the "current sprint" is the project's single active sprint and
|
|
21
|
+
membership lives in item_sprints. Delegates to set_current_sprint_items,
|
|
22
|
+
which auto-creates the active sprint if none exists and swaps membership
|
|
23
|
+
atomically. Foreign/deleted ids are ignored, so `size` may be < len(ids).
|
|
24
|
+
"""
|
|
25
|
+
from dct.core.sprints import set_current_sprint_items
|
|
26
|
+
result = set_current_sprint_items(engine, project_slug, list(item_ids))
|
|
27
|
+
return {"project": project_slug, "size": result["size"], "items": result["items"]}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clear_sprint(engine: sa.Engine, project_slug: str) -> dict:
|
|
31
|
+
"""Empty the current sprint (fresh planning). Delegates to
|
|
32
|
+
clear_current_sprint — soft-removes every member of the active sprint; the
|
|
33
|
+
sprint row stays active and empty. No-op if there is no current sprint."""
|
|
34
|
+
from dct.core.sprints import clear_current_sprint
|
|
35
|
+
return clear_current_sprint(engine, project_slug)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_sprint_items(engine: sa.Engine, project_slug: str) -> list[dict]:
|
|
39
|
+
"""Items in the project's current (active) sprint, priority-ordered.
|
|
40
|
+
|
|
41
|
+
v0.2+ (#216): membership comes from item_sprints via the active sprint.
|
|
42
|
+
Returns [] when there is no current sprint."""
|
|
43
|
+
from dct.core.sprints import get_current_sprint, list_items_in_sprint
|
|
44
|
+
sprint = get_current_sprint(engine, project_slug)
|
|
45
|
+
if not sprint:
|
|
46
|
+
return []
|
|
47
|
+
members = list_items_in_sprint(engine, sprint["id"])
|
|
48
|
+
return sorted(members, key=lambda i: (i.get("priority") or "P2", i.get("created_at") or ""))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── since parsing ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_since(since: str, engine: sa.Engine, project_slug: str) -> tuple[datetime | None, str]:
|
|
55
|
+
"""Convert a `since` specifier into an anchor datetime + human label.
|
|
56
|
+
|
|
57
|
+
Accepted forms:
|
|
58
|
+
"last_release" (default) — most recent release timestamp for project, or None if never released
|
|
59
|
+
"12h", "24h", "48h" — hours relative to now
|
|
60
|
+
"all" — no cutoff
|
|
61
|
+
ISO date like "2026-04-10" or "2026-04-10T14:00" — absolute
|
|
62
|
+
|
|
63
|
+
Returns (anchor_datetime, human_label). anchor_datetime=None means no cutoff.
|
|
64
|
+
"""
|
|
65
|
+
if since in ("", "last_release"):
|
|
66
|
+
with engine.begin() as conn:
|
|
67
|
+
project_id = resolve_project_id(conn, project_slug)
|
|
68
|
+
row = conn.execute(
|
|
69
|
+
sa.select(sa.func.max(changelog_entries.c.released_at))
|
|
70
|
+
.where(
|
|
71
|
+
changelog_entries.c.project_id == project_id,
|
|
72
|
+
changelog_entries.c.deleted_at.is_(None),
|
|
73
|
+
changelog_entries.c.released_at.isnot(None),
|
|
74
|
+
)
|
|
75
|
+
).first()
|
|
76
|
+
last_release = row[0] if row else None
|
|
77
|
+
if last_release:
|
|
78
|
+
return last_release, f"last_release ({last_release.isoformat()})"
|
|
79
|
+
return None, "no releases — showing all time"
|
|
80
|
+
|
|
81
|
+
if since == "all":
|
|
82
|
+
return None, "all time"
|
|
83
|
+
|
|
84
|
+
# Hour shortcuts
|
|
85
|
+
if since.endswith("h") and since[:-1].isdigit():
|
|
86
|
+
hours = int(since[:-1])
|
|
87
|
+
anchor = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
88
|
+
return anchor, f"last {hours}h"
|
|
89
|
+
|
|
90
|
+
# ISO date / datetime
|
|
91
|
+
try:
|
|
92
|
+
anchor = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
93
|
+
if anchor.tzinfo is None:
|
|
94
|
+
anchor = anchor.replace(tzinfo=timezone.utc)
|
|
95
|
+
return anchor, f"since {anchor.isoformat()}"
|
|
96
|
+
except ValueError:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Invalid `since`: '{since}'. Accepted: last_release, all, <N>h (e.g. 24h), ISO date"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── review ────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _to_cols_rows(rows: list[dict], cols: list[str]) -> dict:
|
|
106
|
+
"""Render list of dicts as compact {cols, rows} table."""
|
|
107
|
+
return {"cols": cols, "rows": [[r.get(c) for c in cols] for r in rows]}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_sprint_review(
|
|
111
|
+
engine: sa.Engine,
|
|
112
|
+
project_slug: str,
|
|
113
|
+
since: str = "last_release",
|
|
114
|
+
) -> dict:
|
|
115
|
+
"""Dashboard payload for session start/end.
|
|
116
|
+
|
|
117
|
+
Returns data only — interpretation (what to prioritize, what's stale, etc.)
|
|
118
|
+
lives in the /track-review skill so it can be iterated without schema change.
|
|
119
|
+
"""
|
|
120
|
+
anchor, since_label = parse_since(since, engine, project_slug)
|
|
121
|
+
|
|
122
|
+
with engine.begin() as conn:
|
|
123
|
+
project_id = resolve_project_id(conn, project_slug)
|
|
124
|
+
|
|
125
|
+
# in_sprint — items flagged
|
|
126
|
+
in_sprint_rows = _query_items_with_cp(conn, project_id, where_in_sprint=True)
|
|
127
|
+
|
|
128
|
+
# recent changes — items whose updated_at > anchor
|
|
129
|
+
if anchor:
|
|
130
|
+
changed_rows = _query_items_simple(conn, project_id, changed_since=anchor)
|
|
131
|
+
checkpoints_done = conn.execute(
|
|
132
|
+
sa.select(sa.func.count())
|
|
133
|
+
.select_from(item_checkpoints.join(items, items.c.id == item_checkpoints.c.item_id))
|
|
134
|
+
.where(
|
|
135
|
+
items.c.project_id == project_id,
|
|
136
|
+
item_checkpoints.c.status == "done",
|
|
137
|
+
item_checkpoints.c.updated_at > anchor,
|
|
138
|
+
item_checkpoints.c.deleted_at.is_(None),
|
|
139
|
+
)
|
|
140
|
+
).scalar() or 0
|
|
141
|
+
changelog_added = conn.execute(
|
|
142
|
+
sa.select(sa.func.count())
|
|
143
|
+
.where(
|
|
144
|
+
changelog_entries.c.project_id == project_id,
|
|
145
|
+
changelog_entries.c.created_at > anchor,
|
|
146
|
+
changelog_entries.c.deleted_at.is_(None),
|
|
147
|
+
)
|
|
148
|
+
).scalar() or 0
|
|
149
|
+
else:
|
|
150
|
+
changed_rows = []
|
|
151
|
+
checkpoints_done = 0
|
|
152
|
+
changelog_added = 0
|
|
153
|
+
|
|
154
|
+
# stale in_progress — items in progress > 7 days since last update
|
|
155
|
+
stale_anchor = datetime.now(timezone.utc) - timedelta(days=7)
|
|
156
|
+
stale_rows = conn.execute(
|
|
157
|
+
sa.select(items.c.id, items.c.title, items.c.updated_at)
|
|
158
|
+
.where(
|
|
159
|
+
items.c.project_id == project_id,
|
|
160
|
+
items.c.status == "in_progress",
|
|
161
|
+
items.c.updated_at < stale_anchor,
|
|
162
|
+
items.c.deleted_at.is_(None),
|
|
163
|
+
)
|
|
164
|
+
.order_by(items.c.updated_at)
|
|
165
|
+
).fetchall()
|
|
166
|
+
stale = [
|
|
167
|
+
{
|
|
168
|
+
"id": r._mapping["id"],
|
|
169
|
+
"updated_at": r._mapping["updated_at"].isoformat() if r._mapping["updated_at"] else None,
|
|
170
|
+
"title": r._mapping["title"],
|
|
171
|
+
}
|
|
172
|
+
for r in stale_rows
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
# unconsumed handoffs
|
|
176
|
+
handoff_rows = conn.execute(
|
|
177
|
+
sa.select(session_handoffs)
|
|
178
|
+
.where(
|
|
179
|
+
session_handoffs.c.project_id == project_id,
|
|
180
|
+
session_handoffs.c.consumed_at.is_(None),
|
|
181
|
+
session_handoffs.c.deleted_at.is_(None),
|
|
182
|
+
)
|
|
183
|
+
.order_by(session_handoffs.c.created_at.desc())
|
|
184
|
+
).fetchall()
|
|
185
|
+
handoffs = [row_to_dict(r) for r in handoff_rows]
|
|
186
|
+
|
|
187
|
+
# totals
|
|
188
|
+
total_open = conn.execute(
|
|
189
|
+
sa.select(sa.func.count())
|
|
190
|
+
.where(
|
|
191
|
+
items.c.project_id == project_id,
|
|
192
|
+
items.c.status == "open",
|
|
193
|
+
items.c.deleted_at.is_(None),
|
|
194
|
+
)
|
|
195
|
+
).scalar() or 0
|
|
196
|
+
|
|
197
|
+
sprint_size = len(in_sprint_rows)
|
|
198
|
+
sprint_done = sum(1 for r in in_sprint_rows if r.get("status") in ("resolved", "wont_fix"))
|
|
199
|
+
|
|
200
|
+
# cols ordered short-to-long: fixed-width fields left, free text (title) right.
|
|
201
|
+
return {
|
|
202
|
+
"project": project_slug,
|
|
203
|
+
"since": since,
|
|
204
|
+
"since_anchor": since_label,
|
|
205
|
+
"in_sprint": _to_cols_rows(
|
|
206
|
+
in_sprint_rows,
|
|
207
|
+
["id", "priority", "status", "cp", "title"],
|
|
208
|
+
),
|
|
209
|
+
"recent_activity": {
|
|
210
|
+
"items_changed": _to_cols_rows(
|
|
211
|
+
changed_rows,
|
|
212
|
+
["id", "status", "updated_at", "title"],
|
|
213
|
+
),
|
|
214
|
+
"checkpoints_completed": checkpoints_done,
|
|
215
|
+
"changelog_added": changelog_added,
|
|
216
|
+
},
|
|
217
|
+
"stale_in_progress": _to_cols_rows(
|
|
218
|
+
stale,
|
|
219
|
+
["id", "updated_at", "title"],
|
|
220
|
+
),
|
|
221
|
+
"unconsumed_handoffs": _to_cols_rows(
|
|
222
|
+
[
|
|
223
|
+
{
|
|
224
|
+
"id": h["id"],
|
|
225
|
+
"scope": h["scope"],
|
|
226
|
+
"created_by": h["created_by"],
|
|
227
|
+
"created_at": h["created_at"].isoformat() if h["created_at"] else None,
|
|
228
|
+
"prompt": h["prompt"],
|
|
229
|
+
}
|
|
230
|
+
for h in handoffs
|
|
231
|
+
],
|
|
232
|
+
["id", "scope", "created_by", "created_at", "prompt"],
|
|
233
|
+
),
|
|
234
|
+
"summary": {
|
|
235
|
+
"sprint_size": sprint_size,
|
|
236
|
+
"sprint_completion": f"{sprint_done}/{sprint_size}" if sprint_size else "0/0",
|
|
237
|
+
"total_open_in_project": total_open,
|
|
238
|
+
"handoffs_pending": len(handoffs),
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _query_items_with_cp(
|
|
244
|
+
conn: sa.Connection,
|
|
245
|
+
project_id: int,
|
|
246
|
+
where_in_sprint: bool = False,
|
|
247
|
+
) -> list[dict]:
|
|
248
|
+
"""Fetch items enriched with cp=[done,total] checkpoint progress via subquery."""
|
|
249
|
+
cp_done = sa.func.coalesce(
|
|
250
|
+
sa.func.sum(sa.case((item_checkpoints.c.status == "done", 1), else_=0)),
|
|
251
|
+
0,
|
|
252
|
+
).label("cp_done")
|
|
253
|
+
cp_total = sa.func.coalesce(
|
|
254
|
+
sa.func.sum(sa.case((item_checkpoints.c.status != "rejected", 1), else_=0)),
|
|
255
|
+
0,
|
|
256
|
+
).label("cp_total")
|
|
257
|
+
|
|
258
|
+
q = (
|
|
259
|
+
sa.select(items, cp_done, cp_total)
|
|
260
|
+
.select_from(
|
|
261
|
+
items.outerjoin(
|
|
262
|
+
item_checkpoints,
|
|
263
|
+
sa.and_(
|
|
264
|
+
item_checkpoints.c.item_id == items.c.id,
|
|
265
|
+
item_checkpoints.c.deleted_at.is_(None),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
.where(items.c.project_id == project_id, items.c.deleted_at.is_(None))
|
|
270
|
+
.group_by(items.c.id)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if where_in_sprint:
|
|
274
|
+
from dct.core.items import _current_sprint_exists
|
|
275
|
+
q = q.where(_current_sprint_exists())
|
|
276
|
+
|
|
277
|
+
q = q.order_by(items.c.priority, items.c.created_at)
|
|
278
|
+
|
|
279
|
+
out = []
|
|
280
|
+
for r in conn.execute(q):
|
|
281
|
+
m = r._mapping
|
|
282
|
+
out.append({
|
|
283
|
+
"id": m["id"],
|
|
284
|
+
"priority": m["priority"],
|
|
285
|
+
"status": m["status"],
|
|
286
|
+
"cp": [int(m["cp_done"] or 0), int(m["cp_total"] or 0)],
|
|
287
|
+
"title": m["title"],
|
|
288
|
+
})
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _query_items_simple(
|
|
293
|
+
conn: sa.Connection,
|
|
294
|
+
project_id: int,
|
|
295
|
+
changed_since: datetime,
|
|
296
|
+
) -> list[dict]:
|
|
297
|
+
rows = conn.execute(
|
|
298
|
+
sa.select(items.c.id, items.c.title, items.c.status, items.c.updated_at)
|
|
299
|
+
.where(
|
|
300
|
+
items.c.project_id == project_id,
|
|
301
|
+
items.c.updated_at > changed_since,
|
|
302
|
+
items.c.deleted_at.is_(None),
|
|
303
|
+
)
|
|
304
|
+
.order_by(items.c.updated_at.desc())
|
|
305
|
+
.limit(20)
|
|
306
|
+
).fetchall()
|
|
307
|
+
return [
|
|
308
|
+
{
|
|
309
|
+
"id": r._mapping["id"],
|
|
310
|
+
"status": r._mapping["status"],
|
|
311
|
+
"updated_at": r._mapping["updated_at"].isoformat() if r._mapping["updated_at"] else None,
|
|
312
|
+
"title": r._mapping["title"],
|
|
313
|
+
}
|
|
314
|
+
for r in rows
|
|
315
|
+
]
|