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
@@ -0,0 +1,71 @@
1
+ """Sprint views — list (both models, labeled) + detail (members + steps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from flask import Blueprint, abort, g, render_template
6
+
7
+ from dct.web.app import current_engine
8
+ from dct.core import sprints as sprints_core
9
+ from dct.core import checkpoints as checkpoints_core
10
+ from dct.core import projects as projects_core
11
+
12
+ bp = Blueprint("sprints", __name__)
13
+
14
+
15
+ @bp.route("/p/<slug>/sprints")
16
+ def list(slug): # noqa: A001 — Flask endpoint name is intentional (sprints.list)
17
+ engine = current_engine()
18
+ first_class = sprints_core.list_sprints(engine, project_slug=slug)
19
+ # Current sprint = the project's single active sprint; its members come from
20
+ # item_sprints (one source of truth, #216). No more legacy-boolean section.
21
+ current = sprints_core.get_current_sprint(engine, slug)
22
+ current_items = (
23
+ sprints_core.list_items_in_sprint(engine, current["id"]) if current else []
24
+ )
25
+ return render_template(
26
+ "sprints/list.html",
27
+ slug=slug,
28
+ sprints=first_class,
29
+ current=current,
30
+ current_items=current_items,
31
+ )
32
+
33
+
34
+ @bp.route("/sprints/<int:sprint_id>")
35
+ def detail(sprint_id):
36
+ engine = current_engine()
37
+ sprint = sprints_core.get_sprint(engine, sprint_id)
38
+ if sprint is None:
39
+ abort(404)
40
+ # get_sprint returns project_id (int) but NO slug; resolve it so member
41
+ # links can build url_for('items.detail', slug=..., id=...). projects core
42
+ # has no get-by-id, so map id->slug from list_projects.
43
+ slug = next(
44
+ (p["slug"] for p in projects_core.list_projects(engine)
45
+ if p["id"] == sprint["project_id"]),
46
+ None,
47
+ )
48
+ # Scope the sidebar + breadcrumb to the owning project (no slug in the URL).
49
+ g.current_slug = slug
50
+ g.current_section = "sprints"
51
+ members = sprints_core.list_items_in_sprint(engine, sprint_id)
52
+ rows = []
53
+ done = total = 0
54
+ for it in members:
55
+ cps = checkpoints_core.list_checkpoints(
56
+ engine, it["id"], include_rejected=True
57
+ )
58
+ rows.append({"item": it, "checkpoints": cps})
59
+ for cp in cps:
60
+ if cp["status"] != "rejected":
61
+ total += 1
62
+ if cp["status"] == "done":
63
+ done += 1
64
+ return render_template(
65
+ "sprints/detail.html",
66
+ sprint=sprint,
67
+ slug=slug,
68
+ rows=rows,
69
+ cp_done=done,
70
+ cp_total=total,
71
+ )
dct/web/changes.py ADDED
@@ -0,0 +1,77 @@
1
+ """Cheap composite change-token for SSE live refresh.
2
+
3
+ The token is two halves joined by ':':
4
+ 1. max(updated_at) across every table that carries an updated_at column
5
+ — advances on any UPDATE (core stamps updated_at with microsecond precision).
6
+ 2. per-table live row counts (deleted_at IS NULL; item_sprints uses removed_at
7
+ IS NULL) — advances on any INSERT or SOFT-DELETE, including child rows
8
+ (notes, checkpoints, links) that never touch a parent's updated_at.
9
+
10
+ Stable when the DB is unchanged. Read-only: SELECTs only, never DDL.
11
+ """
12
+
13
+ import sqlalchemy as sa
14
+
15
+ from dct.db import (
16
+ projects,
17
+ items,
18
+ item_notes,
19
+ item_checkpoints,
20
+ changelog_entries,
21
+ session_handoffs,
22
+ sprints,
23
+ plans,
24
+ plan_sections,
25
+ plan_checkpoints,
26
+ item_sprints,
27
+ decisions,
28
+ )
29
+
30
+ # Tables carrying an updated_at column — feed the freshness half of the token.
31
+ _UPDATED_AT_TABLES = (
32
+ projects,
33
+ items,
34
+ item_checkpoints,
35
+ changelog_entries,
36
+ sprints,
37
+ plans,
38
+ plan_sections,
39
+ plan_checkpoints,
40
+ decisions,
41
+ )
42
+
43
+ # (table, live_predicate) — count of non-deleted rows feeds the count half.
44
+ # item_notes / session_handoffs / item_sprints have no updated_at, so their
45
+ # mutations are visible only through this row-count half.
46
+ _COUNT_TABLES = (
47
+ (projects, projects.c.deleted_at.is_(None)),
48
+ (items, items.c.deleted_at.is_(None)),
49
+ (item_notes, item_notes.c.deleted_at.is_(None)),
50
+ (item_checkpoints, item_checkpoints.c.deleted_at.is_(None)),
51
+ (changelog_entries, changelog_entries.c.deleted_at.is_(None)),
52
+ (session_handoffs, session_handoffs.c.deleted_at.is_(None)),
53
+ (sprints, sprints.c.deleted_at.is_(None)),
54
+ (plans, plans.c.deleted_at.is_(None)),
55
+ (plan_sections, plan_sections.c.deleted_at.is_(None)),
56
+ (plan_checkpoints, plan_checkpoints.c.deleted_at.is_(None)),
57
+ (item_sprints, item_sprints.c.removed_at.is_(None)),
58
+ (decisions, decisions.c.deleted_at.is_(None)),
59
+ )
60
+
61
+
62
+ def change_token(engine: sa.Engine) -> str:
63
+ """Return a cheap composite token that changes on any tracked mutation."""
64
+ parts: list[str] = []
65
+ with engine.connect() as conn:
66
+ newest = None
67
+ for table in _UPDATED_AT_TABLES:
68
+ value = conn.execute(sa.select(sa.func.max(table.c.updated_at))).scalar()
69
+ if value is not None and (newest is None or value > newest):
70
+ newest = value
71
+ parts.append(str(newest) if newest is not None else "-")
72
+ for table, predicate in _COUNT_TABLES:
73
+ count = conn.execute(
74
+ sa.select(sa.func.count()).select_from(table).where(predicate)
75
+ ).scalar()
76
+ parts.append(str(count or 0))
77
+ return ":".join(parts)
dct/web/serializers.py ADDED
@@ -0,0 +1,109 @@
1
+ """Serialization helpers for the dct web viewer.
2
+
3
+ JSON-native conversions shared by every blueprint:
4
+ - datetimes -> ISO 8601 strings,
5
+ - the JSON-in-TEXT `tags` column -> a real list,
6
+ - markdown prose -> sanitized HTML (raw HTML escaped, never executed).
7
+ """
8
+
9
+ import json
10
+ import re
11
+ from datetime import datetime
12
+
13
+ import markdown as _markdown
14
+ import markupsafe
15
+
16
+
17
+ def to_iso(dt) -> str | None:
18
+ """datetime -> ISO 8601 string; None passes through; other types stringified."""
19
+ if dt is None:
20
+ return None
21
+ if isinstance(dt, datetime):
22
+ return dt.isoformat()
23
+ return str(dt)
24
+
25
+
26
+ def parse_tags(raw) -> list[str]:
27
+ """Tolerant parse of the `tags` field.
28
+
29
+ Accepts JSON-encoded TEXT (``'["a","b"]'``), ``None``, an already-decoded
30
+ list, or a bare string. Always returns ``list[str]`` and never raises.
31
+ """
32
+ if raw is None:
33
+ return []
34
+ if isinstance(raw, list):
35
+ return [str(t) for t in raw]
36
+ if isinstance(raw, str):
37
+ s = raw.strip()
38
+ if not s:
39
+ return []
40
+ try:
41
+ parsed = json.loads(s)
42
+ except (ValueError, TypeError):
43
+ return [s]
44
+ if isinstance(parsed, list):
45
+ return [str(t) for t in parsed]
46
+ return [str(parsed)]
47
+ return []
48
+
49
+
50
+ # Markdown link syntax can emit dangerous-scheme URIs (e.g. `[x](javascript:alert(1))`)
51
+ # even though raw HTML is escaped upstream. Rewrite such href/src values to "#" so a
52
+ # malicious description/note can't smuggle a javascript:/data: link into the page.
53
+ _UNSAFE_URI = re.compile(
54
+ r"""(href|src)\s*=\s*(["'])\s*(?:javascript|vbscript|data|file)\s*:[^"']*\2""",
55
+ re.IGNORECASE,
56
+ )
57
+
58
+
59
+ def _neutralize_unsafe_links(html: str) -> str:
60
+ return _UNSAFE_URI.sub(lambda m: f"{m.group(1)}={m.group(2)}#{m.group(2)}", html)
61
+
62
+
63
+ # --- readability: paragraph-split long unstructured prose ------------------
64
+ # Many dct descriptions/notes are stored as ONE continuous block (zero newlines)
65
+ # and render as an unreadable wall. When a block has no paragraph breaks and no
66
+ # markdown block syntax, we split it on sentence boundaries and regroup so the
67
+ # renderer emits real <p> breaks. Conservative: structured/short text is left alone.
68
+ _ABBREV = ("e.g.", "i.e.", "etc.", "vs.", "cf.", "al.", "inc.", "approx.", "no.", "fig.")
69
+ _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z(`])")
70
+ _BLOCK_MD = re.compile(r"(?m)^\s*(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)")
71
+
72
+
73
+ def _paragraphize(text: str, group: int = 2) -> str:
74
+ if "\n\n" in text or _BLOCK_MD.search(text) or len(text) <= 300:
75
+ return text
76
+ parts = _SENTENCE_SPLIT.split(text.strip())
77
+ sentences: list[str] = []
78
+ for part in parts:
79
+ if sentences and sentences[-1].rstrip().lower().endswith(_ABBREV):
80
+ sentences[-1] = f"{sentences[-1]} {part}"
81
+ else:
82
+ sentences.append(part)
83
+ if len(sentences) < 3:
84
+ return text
85
+ return "\n\n".join(
86
+ " ".join(sentences[i:i + group]) for i in range(0, len(sentences), group)
87
+ )
88
+
89
+
90
+ def render_markdown(text: str | None) -> markupsafe.Markup:
91
+ """Render markdown to HTML with raw HTML escaped (defense-in-depth).
92
+
93
+ We escape the source *before* handing it to python-markdown, so any raw
94
+ ``<script>`` / ``<img onerror=...>`` becomes inert text while ordinary
95
+ markdown syntax (emphasis, lists, code fences, tables) still renders.
96
+ Long unstructured blocks are paragraph-split first for readability.
97
+ Returns a ``Markup`` so Jinja2 treats it as safe to emit verbatim.
98
+ """
99
+ if not text:
100
+ return markupsafe.Markup("")
101
+ text = _paragraphize(text)
102
+ escaped = str(markupsafe.escape(text))
103
+ html = _markdown.markdown(
104
+ escaped,
105
+ extensions=["fenced_code", "tables"],
106
+ output_format="html",
107
+ )
108
+ html = _neutralize_unsafe_links(html)
109
+ return markupsafe.Markup(html)