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/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
+ ]