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/version.py ADDED
@@ -0,0 +1,116 @@
1
+ """Version bump + autodetect logic — supports X.Y and X.Y.Z formats."""
2
+
3
+ import json
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+
8
+ def parse_version(version: str) -> tuple[int, ...]:
9
+ parts = version.strip().split(".")
10
+ return tuple(int(p) for p in parts)
11
+
12
+
13
+ def format_version(parts: tuple[int, ...]) -> str:
14
+ return ".".join(str(p) for p in parts)
15
+
16
+
17
+ def bump(current: str, bump_type: str) -> str:
18
+ """Bump version string by type (patch/minor/major).
19
+
20
+ Supports both X.Y (two-part) and X.Y.Z (three-part) formats.
21
+ Two-part versions get a patch level on patch bump: 0.18 → 0.18.1
22
+ """
23
+ if bump_type not in ("patch", "minor", "major"):
24
+ raise ValueError(f"Invalid bump type: '{bump_type}'. Valid: patch, minor, major")
25
+
26
+ parts = list(parse_version(current))
27
+
28
+ if bump_type == "major":
29
+ return format_version((parts[0] + 1, 0, 0) if len(parts) >= 3 else (parts[0] + 1, 0))
30
+ elif bump_type == "minor":
31
+ return format_version((parts[0], parts[1] + 1, 0) if len(parts) >= 3 else (parts[0], parts[1] + 1))
32
+ else: # patch
33
+ if len(parts) >= 3:
34
+ return format_version((parts[0], parts[1], parts[2] + 1))
35
+ else:
36
+ return format_version((parts[0], parts[1], 1))
37
+
38
+
39
+ def detect_version(path: str | Path) -> tuple[str | None, str | None]:
40
+ """Best-effort version detection from common project files.
41
+
42
+ Tries in order and returns the first hit:
43
+ 1. <path>/VERSION — raw single line, stripped
44
+ 2. <path>/pyproject.toml — [project].version (PEP 621)
45
+ 3. <path>/package.json — .version (npm/yarn)
46
+ 4. <path>/Cargo.toml — [package].version (rust)
47
+ 5. <path>/src-tauri/tauri.conf.json — .version (Tauri v2) or
48
+ .package.version (Tauri v1)
49
+
50
+ Returns (version, source_file) or (None, None) if no pattern matches.
51
+ Never raises — malformed files are silently skipped so `project add`
52
+ proceeds even when one of these files is corrupt.
53
+ """
54
+ base = Path(path)
55
+ if not base.is_dir():
56
+ return None, None
57
+
58
+ # 1. VERSION (raw text)
59
+ vf = base / "VERSION"
60
+ if vf.is_file():
61
+ try:
62
+ v = vf.read_text(encoding="utf-8").strip()
63
+ if v:
64
+ return v, "VERSION"
65
+ except (OSError, UnicodeDecodeError):
66
+ pass
67
+
68
+ # 2. pyproject.toml ([project].version)
69
+ pp = base / "pyproject.toml"
70
+ if pp.is_file():
71
+ try:
72
+ with open(pp, "rb") as f:
73
+ data = tomllib.load(f)
74
+ v = (data.get("project") or {}).get("version")
75
+ if isinstance(v, str) and v:
76
+ return v, "pyproject.toml"
77
+ except (OSError, tomllib.TOMLDecodeError):
78
+ pass
79
+
80
+ # 3. package.json (.version)
81
+ pj = base / "package.json"
82
+ if pj.is_file():
83
+ try:
84
+ with open(pj, encoding="utf-8") as f:
85
+ data = json.load(f)
86
+ v = data.get("version")
87
+ if isinstance(v, str) and v:
88
+ return v, "package.json"
89
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
90
+ pass
91
+
92
+ # 4. Cargo.toml ([package].version)
93
+ ct = base / "Cargo.toml"
94
+ if ct.is_file():
95
+ try:
96
+ with open(ct, "rb") as f:
97
+ data = tomllib.load(f)
98
+ v = (data.get("package") or {}).get("version")
99
+ if isinstance(v, str) and v:
100
+ return v, "Cargo.toml"
101
+ except (OSError, tomllib.TOMLDecodeError):
102
+ pass
103
+
104
+ # 5. Tauri — v2 puts version at top level, v1 under .package.version
105
+ tc = base / "src-tauri" / "tauri.conf.json"
106
+ if tc.is_file():
107
+ try:
108
+ with open(tc, encoding="utf-8") as f:
109
+ data = json.load(f)
110
+ v = data.get("version") or (data.get("package") or {}).get("version")
111
+ if isinstance(v, str) and v:
112
+ return v, "src-tauri/tauri.conf.json"
113
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
114
+ pass
115
+
116
+ return None, None
dct/db.py ADDED
@@ -0,0 +1,558 @@
1
+ """SQLAlchemy Core database layer for dct.
2
+
3
+ Defines all tables, engine creation, and connection helpers.
4
+ Uses Core (not ORM) for portability between PostgreSQL and SQLite.
5
+ """
6
+
7
+ import sqlalchemy as sa
8
+ from sqlalchemy import event
9
+
10
+ metadata = sa.MetaData()
11
+
12
+ projects = sa.Table(
13
+ "projects",
14
+ metadata,
15
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
16
+ sa.Column("slug", sa.String(50), nullable=False),
17
+ sa.Column("name", sa.String(200), nullable=False),
18
+ sa.Column("path", sa.String(500)),
19
+ sa.Column("description", sa.Text),
20
+ sa.Column("current_version", sa.String(50)),
21
+ # #222: per-project opt-in for the dct changelog hook (default TRUE; the
22
+ # migration backfills existing projects from their actual dct changelog
23
+ # history). Opt out with `dct project changelog <slug> off`.
24
+ sa.Column("manages_changelog", sa.Boolean, nullable=False, server_default=sa.true()),
25
+ # #225: changelog file location relative to the project root (default repo
26
+ # root). A project keeping its changelog in a subdir points it there and
27
+ # /release resolves {path}/{changelog_path}. Constrained to the project root
28
+ # by set_changelog_path (leading '/' stripped, '..' escape rejected).
29
+ sa.Column("changelog_path", sa.String(500), nullable=False, server_default="changelog.md"),
30
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
31
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
32
+ sa.Column("deleted_at", sa.DateTime),
33
+ )
34
+
35
+ items = sa.Table(
36
+ "items",
37
+ metadata,
38
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
39
+ sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
40
+ sa.Column("item_type", sa.String(20), nullable=False),
41
+ sa.Column("title", sa.String(500), nullable=False),
42
+ sa.Column("description", sa.Text),
43
+ sa.Column("priority", sa.String(10), nullable=False, server_default="P2"),
44
+ sa.Column("status", sa.String(20), nullable=False, server_default="open"),
45
+ sa.Column("component", sa.String(100)),
46
+ sa.Column("source_file", sa.String(500)),
47
+ sa.Column("source_lines", sa.String(100)),
48
+ sa.Column("tags", sa.Text, server_default="[]"),
49
+ sa.Column("resolution", sa.Text),
50
+ # NB: sprint membership is NOT a column. `in_current_sprint` is derived at
51
+ # read time from item_sprints + the project's active sprint (single source
52
+ # of truth, #216). The legacy boolean column is dropped by the migration in
53
+ # _apply_schema_updates.
54
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
55
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
56
+ sa.Column("resolved_at", sa.DateTime),
57
+ sa.Column("deleted_at", sa.DateTime),
58
+ )
59
+
60
+ item_notes = sa.Table(
61
+ "item_notes",
62
+ metadata,
63
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
64
+ sa.Column("item_id", sa.Integer, sa.ForeignKey("items.id"), nullable=False),
65
+ sa.Column("note", sa.Text, nullable=False),
66
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
67
+ sa.Column("deleted_at", sa.DateTime),
68
+ )
69
+
70
+ item_checkpoints = sa.Table(
71
+ "item_checkpoints",
72
+ metadata,
73
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
74
+ sa.Column("item_id", sa.Integer, sa.ForeignKey("items.id"), nullable=False),
75
+ sa.Column("text", sa.Text, nullable=False),
76
+ sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
77
+ sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
78
+ # `kind` classifies the checkpoint as a 'task' (default, closeable by
79
+ # anyone) or a 'gate' (kind != 'task'). Pending gates block resolve_item.
80
+ # Open string column (not ENUM) so OSS users can define custom gate
81
+ # kinds; core validates against a whitelist in checkpoints.py but
82
+ # unknown kinds are treated as gates (conservative default).
83
+ sa.Column("kind", sa.String(32), nullable=False, server_default="task"),
84
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
85
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
86
+ sa.Column("deleted_at", sa.DateTime),
87
+ )
88
+
89
+ changelog_entries = sa.Table(
90
+ "changelog_entries",
91
+ metadata,
92
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
93
+ sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
94
+ sa.Column("version", sa.String(50)),
95
+ sa.Column("category", sa.String(20), nullable=False),
96
+ sa.Column("entry", sa.Text, nullable=False),
97
+ sa.Column("component", sa.String(100)),
98
+ sa.Column("source_file", sa.String(500)),
99
+ sa.Column("source_lines", sa.String(100)),
100
+ sa.Column("related_item_id", sa.Integer, sa.ForeignKey("items.id")),
101
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
102
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
103
+ sa.Column("released_at", sa.DateTime),
104
+ sa.Column("deleted_at", sa.DateTime),
105
+ )
106
+
107
+ session_handoffs = sa.Table(
108
+ "session_handoffs",
109
+ metadata,
110
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
111
+ sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
112
+ sa.Column("prompt", sa.Text, nullable=False),
113
+ sa.Column("context_summary", sa.Text),
114
+ sa.Column("scope", sa.String(100)),
115
+ sa.Column("created_by", sa.String(200), nullable=False),
116
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
117
+ sa.Column("consumed_at", sa.DateTime),
118
+ sa.Column("consumed_by", sa.String(200)),
119
+ sa.Column("deleted_at", sa.DateTime),
120
+ )
121
+
122
+ # ── v0.2.0 schema: plans / sprints / decisions ────────────────────────────────
123
+
124
+ sprints = sa.Table(
125
+ "sprints",
126
+ metadata,
127
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
128
+ sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
129
+ sa.Column("name", sa.String(200), nullable=False),
130
+ sa.Column("code", sa.String(50)), # short code: "0.5", "M0", "sprint-v0.1-ship"
131
+ sa.Column("kind", sa.String(20), nullable=False, server_default="sprint"), # sprint|phase|milestone|block
132
+ sa.Column("status", sa.String(20), nullable=False, server_default="planned"), # planned|active|deferred|completed|abandoned
133
+ sa.Column("goal", sa.Text),
134
+ sa.Column("started_at", sa.DateTime),
135
+ sa.Column("ended_at", sa.DateTime),
136
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
137
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
138
+ sa.Column("deleted_at", sa.DateTime),
139
+ )
140
+
141
+ plans = sa.Table(
142
+ "plans",
143
+ metadata,
144
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
145
+ sa.Column("ulid", sa.String(26), nullable=False, unique=True),
146
+ sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
147
+ sa.Column("slug", sa.String(200), nullable=False),
148
+ sa.Column("title", sa.String(500), nullable=False),
149
+ sa.Column("kind", sa.String(20), nullable=False), # plan|spec|roadmap|adr|retro|backlog
150
+ sa.Column("source_file", sa.String(1000)), # NULL for DB-only roadmap
151
+ sa.Column("source_hash", sa.String(64)), # sha256 of file at last scan
152
+ sa.Column("status", sa.String(20), nullable=False, server_default="active"), # draft|active|completed|archived
153
+ sa.Column("sprint_id", sa.Integer, sa.ForeignKey("sprints.id")),
154
+ sa.Column("imported_at", sa.DateTime), # NULL = never imported (first-import gate)
155
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
156
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
157
+ sa.Column("archived_at", sa.DateTime),
158
+ sa.Column("deleted_at", sa.DateTime),
159
+ )
160
+
161
+ plan_sections = sa.Table(
162
+ "plan_sections",
163
+ metadata,
164
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
165
+ sa.Column("ulid", sa.String(26), nullable=False, unique=True),
166
+ sa.Column("plan_id", sa.Integer, sa.ForeignKey("plans.id"), nullable=False),
167
+ sa.Column("parent_section_id", sa.Integer, sa.ForeignKey("plan_sections.id")),
168
+ sa.Column("heading", sa.Text, nullable=False),
169
+ sa.Column("depth", sa.Integer, nullable=False), # 1=H1, 2=H2, ..., 6=H6
170
+ sa.Column("slug", sa.String(200)),
171
+ sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
172
+ sa.Column("section_type", sa.String(30), nullable=False, server_default="generic"),
173
+ sa.Column("section_type_confidence", sa.String(10), server_default="high"), # high|low|uncertain
174
+ sa.Column("needs_llm_review", sa.Boolean, nullable=False, server_default=sa.false()),
175
+ sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
176
+ sa.Column("source_lines", sa.String(50)),
177
+ sa.Column("sprint_id", sa.Integer, sa.ForeignKey("sprints.id")),
178
+ sa.Column("converted_to_item_id", sa.Integer, sa.ForeignKey("items.id")),
179
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
180
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
181
+ sa.Column("deleted_at", sa.DateTime),
182
+ )
183
+
184
+ plan_checkpoints = sa.Table(
185
+ "plan_checkpoints",
186
+ metadata,
187
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
188
+ sa.Column("ulid", sa.String(26), nullable=False, unique=True),
189
+ sa.Column("plan_section_id", sa.Integer, sa.ForeignKey("plan_sections.id"), nullable=False),
190
+ sa.Column("text", sa.Text, nullable=False),
191
+ sa.Column("status", sa.String(10), nullable=False, server_default="pending"), # pending|done|rejected
192
+ sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
193
+ sa.Column("converted_to_item_id", sa.Integer, sa.ForeignKey("items.id")),
194
+ sa.Column("source_lines", sa.String(50)),
195
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
196
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
197
+ sa.Column("deleted_at", sa.DateTime),
198
+ )
199
+
200
+ item_sprints = sa.Table(
201
+ "item_sprints",
202
+ metadata,
203
+ sa.Column("item_id", sa.Integer, sa.ForeignKey("items.id"), primary_key=True),
204
+ sa.Column("sprint_id", sa.Integer, sa.ForeignKey("sprints.id"), primary_key=True),
205
+ sa.Column("assigned_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
206
+ sa.Column("removed_at", sa.DateTime),
207
+ )
208
+
209
+ decisions = sa.Table(
210
+ "decisions",
211
+ metadata,
212
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
213
+ sa.Column("ulid", sa.String(26), nullable=False, unique=True),
214
+ sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id"), nullable=False),
215
+ sa.Column("kind", sa.String(30), nullable=False), # adr|sprint_amendment|inline_plan|open_question
216
+ sa.Column("ref_code", sa.String(50)),
217
+ sa.Column("title", sa.String(500), nullable=False),
218
+ sa.Column("context", sa.Text),
219
+ sa.Column("decision", sa.Text),
220
+ sa.Column("consequences", sa.Text),
221
+ sa.Column("alternatives", sa.Text),
222
+ sa.Column("status", sa.String(20), nullable=False, server_default="proposed"), # proposed|accepted|superseded|rejected
223
+ sa.Column("superseded_by", sa.Integer, sa.ForeignKey("decisions.id")),
224
+ sa.Column("source_file", sa.String(1000)),
225
+ sa.Column("source_lines", sa.String(50)),
226
+ sa.Column("plan_id", sa.Integer, sa.ForeignKey("plans.id")),
227
+ sa.Column("plan_section_id", sa.Integer, sa.ForeignKey("plan_sections.id")),
228
+ sa.Column("sprint_id", sa.Integer, sa.ForeignKey("sprints.id")),
229
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
230
+ sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
231
+ sa.Column("deleted_at", sa.DateTime),
232
+ )
233
+
234
+
235
+ sa.Index(
236
+ "ix_projects_slug_active",
237
+ projects.c.slug,
238
+ unique=True,
239
+ sqlite_where=projects.c.deleted_at.is_(None),
240
+ postgresql_where=projects.c.deleted_at.is_(None),
241
+ )
242
+
243
+ sa.Index("ix_items_project_status_deleted", items.c.project_id, items.c.status, items.c.deleted_at)
244
+ # (ix_items_sprint removed with the in_current_sprint column — membership now
245
+ # lives in item_sprints; the current-sprint EXISTS lookup is served by the
246
+ # item_sprints PK on item_id + ix_sprints_project_status.)
247
+ sa.Index("ix_changelog_project_version_deleted", changelog_entries.c.project_id, changelog_entries.c.version, changelog_entries.c.deleted_at)
248
+ sa.Index("ix_changelog_related_item", changelog_entries.c.related_item_id)
249
+ sa.Index("ix_item_notes_item_deleted", item_notes.c.item_id, item_notes.c.deleted_at)
250
+ sa.Index("ix_checkpoints_item_status_deleted", item_checkpoints.c.item_id, item_checkpoints.c.status, item_checkpoints.c.deleted_at)
251
+ # Partial index: fast lookup of pending gates per item (hot path in resolve_item).
252
+ # Condition must match the predicate in resolve_item exactly for planner to use it.
253
+ sa.Index(
254
+ "ix_checkpoints_pending_gate",
255
+ item_checkpoints.c.item_id,
256
+ sqlite_where=sa.and_(
257
+ item_checkpoints.c.kind != "task",
258
+ item_checkpoints.c.status == "pending",
259
+ item_checkpoints.c.deleted_at.is_(None),
260
+ ),
261
+ postgresql_where=sa.and_(
262
+ item_checkpoints.c.kind != "task",
263
+ item_checkpoints.c.status == "pending",
264
+ item_checkpoints.c.deleted_at.is_(None),
265
+ ),
266
+ )
267
+ sa.Index(
268
+ "ix_handoffs_unconsumed",
269
+ session_handoffs.c.project_id,
270
+ session_handoffs.c.created_at.desc(),
271
+ sqlite_where=sa.and_(session_handoffs.c.consumed_at.is_(None), session_handoffs.c.deleted_at.is_(None)),
272
+ postgresql_where=sa.and_(session_handoffs.c.consumed_at.is_(None), session_handoffs.c.deleted_at.is_(None)),
273
+ )
274
+
275
+ # v0.2.0 indexes
276
+ sa.Index(
277
+ "ix_plans_project_slug_active",
278
+ plans.c.project_id, plans.c.slug,
279
+ unique=True,
280
+ sqlite_where=plans.c.deleted_at.is_(None),
281
+ postgresql_where=plans.c.deleted_at.is_(None),
282
+ )
283
+ sa.Index("ix_plan_sections_plan", plan_sections.c.plan_id, plan_sections.c.sort_order)
284
+ sa.Index("ix_plan_sections_parent", plan_sections.c.parent_section_id)
285
+ sa.Index(
286
+ "ix_plan_sections_uncertain",
287
+ plan_sections.c.plan_id,
288
+ sqlite_where=sa.and_(plan_sections.c.needs_llm_review.is_(True), plan_sections.c.deleted_at.is_(None)),
289
+ postgresql_where=sa.and_(plan_sections.c.needs_llm_review.is_(True), plan_sections.c.deleted_at.is_(None)),
290
+ )
291
+ sa.Index("ix_plan_checkpoints_section_status", plan_checkpoints.c.plan_section_id, plan_checkpoints.c.status, plan_checkpoints.c.deleted_at)
292
+ sa.Index(
293
+ "ix_sprints_project_code_active",
294
+ sprints.c.project_id, sprints.c.code,
295
+ unique=True,
296
+ sqlite_where=sa.and_(sprints.c.code.isnot(None), sprints.c.deleted_at.is_(None)),
297
+ postgresql_where=sa.and_(sprints.c.code.isnot(None), sprints.c.deleted_at.is_(None)),
298
+ )
299
+ sa.Index("ix_sprints_project_status", sprints.c.project_id, sprints.c.status, sprints.c.deleted_at)
300
+ sa.Index(
301
+ "ix_item_sprints_sprint_active",
302
+ item_sprints.c.sprint_id,
303
+ sqlite_where=item_sprints.c.removed_at.is_(None),
304
+ postgresql_where=item_sprints.c.removed_at.is_(None),
305
+ )
306
+ sa.Index(
307
+ "ix_decisions_project_refcode_active",
308
+ decisions.c.project_id, decisions.c.ref_code,
309
+ unique=True,
310
+ sqlite_where=sa.and_(decisions.c.ref_code.isnot(None), decisions.c.deleted_at.is_(None)),
311
+ postgresql_where=sa.and_(decisions.c.ref_code.isnot(None), decisions.c.deleted_at.is_(None)),
312
+ )
313
+ sa.Index("ix_decisions_project_kind_status", decisions.c.project_id, decisions.c.kind, decisions.c.status, decisions.c.deleted_at)
314
+
315
+
316
+ def _set_sqlite_wal(dbapi_connection, _connection_record):
317
+ cursor = dbapi_connection.cursor()
318
+ cursor.execute("PRAGMA foreign_keys=ON")
319
+ try:
320
+ cursor.execute("PRAGMA journal_mode=WAL")
321
+ except Exception:
322
+ pass
323
+ cursor.close()
324
+
325
+
326
+ def get_engine(database_url: str) -> sa.Engine:
327
+ kwargs = {}
328
+ if database_url.startswith("sqlite"):
329
+ kwargs["connect_args"] = {"check_same_thread": False}
330
+
331
+ engine = sa.create_engine(database_url, **kwargs)
332
+
333
+ if database_url.startswith("sqlite"):
334
+ event.listen(engine, "connect", _set_sqlite_wal)
335
+
336
+ return engine
337
+
338
+
339
+ def create_tables(engine: sa.Engine) -> None:
340
+ metadata.create_all(engine)
341
+ _apply_schema_updates(engine)
342
+
343
+
344
+ def _apply_schema_updates(engine: sa.Engine) -> None:
345
+ """Idempotent schema updates for existing DBs (lightweight migration runner).
346
+
347
+ Each schema change (column add, index add, ...) is checked and applied
348
+ INDEPENDENTLY. We do NOT treat a logical group (e.g. "sprint migration")
349
+ as atomic — a partial prior run could leave the column in place but the
350
+ index missing, and we must heal each piece on the next call. This mirrors
351
+ the invariant-per-field rule learned from the update_item resolved_at bug
352
+ (core/items.py TERMINAL_STATUSES).
353
+
354
+ Dialect note: PostgreSQL is strict about BOOLEAN literals — `0` (INTEGER)
355
+ does NOT coerce to FALSE, the ALTER fails with DatatypeMismatch; same for
356
+ `1` vs TRUE in index predicates. SQLite is permissive. We emit FALSE/TRUE
357
+ for PostgreSQL and 0/1 for SQLite. Keep this in sync with the
358
+ server_default on the SQLAlchemy column definition (which uses sa.false()
359
+ to portably emit the correct literal at CREATE TABLE time).
360
+ """
361
+ inspector = sa.inspect(engine)
362
+ if "items" not in inspector.get_table_names():
363
+ return
364
+
365
+ col_names = {c["name"] for c in inspector.get_columns("items")}
366
+
367
+ # #222: opt-in changelog enforcement. Add projects.manages_changelog and,
368
+ # on first add, backfill — a project KEEPS enforcement only if it already
369
+ # has dct changelog history; one with none opts out (so projects registered
370
+ # for item-tracking but keeping their own changelog aren't wrongly blocked).
371
+ # New rows default TRUE via the column server_default; this block is a
372
+ # one-shot (guarded by column absence) so re-runs never override a later
373
+ # explicit `dct project changelog` toggle.
374
+ proj_cols = {c["name"] for c in inspector.get_columns("projects")}
375
+ if "manages_changelog" not in proj_cols:
376
+ is_pg = engine.dialect.name == "postgresql"
377
+ true_lit, false_lit = ("TRUE", "FALSE") if is_pg else ("1", "0")
378
+ with engine.begin() as conn:
379
+ conn.execute(sa.text(
380
+ f"ALTER TABLE projects ADD COLUMN manages_changelog "
381
+ f"BOOLEAN NOT NULL DEFAULT {true_lit}"
382
+ ))
383
+ conn.execute(sa.text(
384
+ f"UPDATE projects SET manages_changelog = {false_lit} "
385
+ "WHERE deleted_at IS NULL AND id NOT IN "
386
+ "(SELECT DISTINCT project_id FROM changelog_entries "
387
+ "WHERE deleted_at IS NULL AND project_id IS NOT NULL)"
388
+ ))
389
+
390
+ # #225: per-project changelog location. The ADD COLUMN DEFAULT backfills
391
+ # existing rows to 'changelog.md' (repo root) — no conditional UPDATE needed.
392
+ # Guarded by column absence so re-runs never clobber an explicit value set
393
+ # later via `dct project changelog-path`. VARCHAR default is dialect-portable
394
+ # (unlike the BOOLEAN literal dance above).
395
+ if "changelog_path" not in proj_cols:
396
+ with engine.begin() as conn:
397
+ conn.execute(sa.text(
398
+ "ALTER TABLE projects ADD COLUMN changelog_path "
399
+ "VARCHAR(500) NOT NULL DEFAULT 'changelog.md'"
400
+ ))
401
+
402
+ # v0.4.0: add item_checkpoints.kind column + partial index for gate lookup.
403
+ # See item #33 (gate checkpoints). Pre-v0.4 checkpoints default to kind='task'
404
+ # via server_default — existing resolve_item behaviour preserved for rows
405
+ # without explicit gate assignment.
406
+ if "item_checkpoints" in inspector.get_table_names():
407
+ cp_cols = {c["name"] for c in inspector.get_columns("item_checkpoints")}
408
+ cp_idx = {ix["name"] for ix in inspector.get_indexes("item_checkpoints")}
409
+ with engine.begin() as conn:
410
+ if "kind" not in cp_cols:
411
+ conn.execute(sa.text(
412
+ "ALTER TABLE item_checkpoints ADD COLUMN kind "
413
+ "VARCHAR(32) NOT NULL DEFAULT 'task'"
414
+ ))
415
+ if "ix_checkpoints_pending_gate" not in cp_idx:
416
+ conn.execute(sa.text(
417
+ "CREATE INDEX IF NOT EXISTS ix_checkpoints_pending_gate "
418
+ "ON item_checkpoints(item_id) "
419
+ "WHERE kind != 'task' AND status = 'pending' AND deleted_at IS NULL"
420
+ ))
421
+
422
+ # v0.2.0 + #216: migrate the legacy `in_current_sprint` boolean into the
423
+ # item_sprints model, THEN drop the column. Sprint membership now has ONE
424
+ # source of truth (item_sprints + the active sprint) and is derived on read.
425
+ # The seed reads the column via raw SQL (it is intentionally absent from the
426
+ # table metadata), so this whole block self-disables once the column is gone.
427
+ # Order matters: seed BEFORE dropping, and drop the dependent index first
428
+ # (SQLite refuses to drop a column an index still references).
429
+ tables_after = set(inspector.get_table_names())
430
+
431
+ # Legacy sprints are HISTORY, not the current sprint — keep them 'completed'.
432
+ # INDEPENDENT, idempotent invariant (deliberately NOT nested under the column
433
+ # check): on a DB where the column was dropped before this demote existed,
434
+ # the legacy sprint would otherwise stay 'active' and wrongly resolve as the
435
+ # current sprint. Heal per-piece (the rule from this runner's docstring).
436
+ if "sprints" in tables_after:
437
+ with engine.begin() as conn:
438
+ conn.execute(sa.text(
439
+ "UPDATE sprints SET status = 'completed', "
440
+ "ended_at = COALESCE(ended_at, CURRENT_TIMESTAMP), "
441
+ "updated_at = CURRENT_TIMESTAMP "
442
+ "WHERE code = 'legacy' AND status = 'active' AND deleted_at IS NULL"
443
+ ))
444
+
445
+ # v0.2.0 + #216: migrate the legacy `in_current_sprint` boolean into the
446
+ # item_sprints model, THEN drop the column. Sprint membership now has ONE
447
+ # source of truth (item_sprints + the active sprint) and is derived on read.
448
+ # The seed reads the column via raw SQL (intentionally absent from the table
449
+ # metadata), so this block self-disables once the column is gone. Order
450
+ # matters: seed BEFORE dropping, drop the dependent index first (SQLite).
451
+ if "in_current_sprint" in col_names and {"sprints", "item_sprints"}.issubset(tables_after):
452
+ _migrate_legacy_sprint_flag(engine)
453
+ with engine.begin() as conn:
454
+ conn.execute(sa.text("DROP INDEX IF EXISTS ix_items_sprint"))
455
+ conn.execute(sa.text("ALTER TABLE items DROP COLUMN in_current_sprint"))
456
+
457
+
458
+ def _migrate_legacy_sprint_flag(engine: sa.Engine) -> None:
459
+ """Seed a 'legacy' sprint per project with flagged items and link them.
460
+
461
+ Idempotent — for each project with any item where `in_current_sprint=TRUE`,
462
+ we ensure:
463
+ 1. A sprints row with code='legacy' exists (created if missing).
464
+ 2. Every flagged item has an item_sprints link ROW to that sprint,
465
+ created once if absent. A link later soft-removed via the v0.2 API
466
+ (removed_at set) is left untouched — this backfill is a one-shot seed,
467
+ NOT an authority that overrides v0.2 sprint-membership decisions.
468
+ Repeat invocations make no further writes once the invariant holds — the
469
+ existence probe spans the FULL (item_id, sprint_id) primary key regardless
470
+ of removed_at, so a re-run never collides with a soft-removed row.
471
+
472
+ One-shot seed (#216): reads the legacy `in_current_sprint` column via raw
473
+ SQL because the column is absent from the table metadata and is DROPPED
474
+ right after this seed runs (see _apply_schema_updates). No-op once the
475
+ column is gone, so it is always safe to call. The full-PK existence probe
476
+ means a re-run never collides with a soft-removed link (the #215 lesson).
477
+ """
478
+ from datetime import datetime, timezone
479
+
480
+ # The column only exists in pre-#216 DBs. Self-disable cleanly otherwise.
481
+ if "in_current_sprint" not in {c["name"] for c in sa.inspect(engine).get_columns("items")}:
482
+ return
483
+
484
+ with engine.begin() as conn:
485
+ # Read the legacy column via literal_column (it is absent from the table
486
+ # metadata) while keeping updated_at a TYPED column, so the value stays a
487
+ # datetime and can be reused as item_sprints.assigned_at.
488
+ flagged_rows = conn.execute(
489
+ sa.select(items.c.id, items.c.project_id, items.c.updated_at).where(
490
+ sa.literal_column("in_current_sprint") == sa.true(),
491
+ items.c.deleted_at.is_(None),
492
+ )
493
+ ).fetchall()
494
+ if not flagged_rows:
495
+ return
496
+
497
+ by_project: dict[int, list[tuple[int, "datetime"]]] = {}
498
+ for row in flagged_rows:
499
+ by_project.setdefault(row.project_id, []).append((row.id, row.updated_at))
500
+
501
+ for project_id, flagged in by_project.items():
502
+ existing = conn.execute(
503
+ sa.select(sprints.c.id).where(
504
+ sprints.c.project_id == project_id,
505
+ sprints.c.code == "legacy",
506
+ sprints.c.deleted_at.is_(None),
507
+ )
508
+ ).first()
509
+ if existing:
510
+ sprint_id = existing.id
511
+ else:
512
+ row = conn.execute(
513
+ sprints.insert()
514
+ .values(
515
+ project_id=project_id,
516
+ name="Pre-0.1 legacy sprint",
517
+ code="legacy",
518
+ kind="sprint",
519
+ # Legacy sprint is HISTORY, not the current sprint — it is
520
+ # 'completed' so it never resolves as the active/current
521
+ # sprint after #216. Items keep their membership for the
522
+ # record; the user sets a fresh current sprint via /handoff.
523
+ status="completed",
524
+ started_at=datetime.now(timezone.utc),
525
+ ended_at=datetime.now(timezone.utc),
526
+ )
527
+ .returning(sprints.c.id)
528
+ ).first()
529
+ assert row is not None, "INSERT RETURNING must return a row"
530
+ sprint_id = row.id
531
+
532
+ # Probe by the FULL primary key (item_id, sprint_id) — deliberately
533
+ # NOT filtered by removed_at. item_sprints_pkey spans both columns
534
+ # irrespective of removed_at, so a soft-removed link still occupies
535
+ # the slot. Filtering removed_at IS NULL here (the original bug)
536
+ # would miss a flagged item whose link was later soft-removed via
537
+ # remove_item_from_sprint, then re-INSERT it -> UniqueViolation.
538
+ already_linked = {
539
+ r.item_id for r in conn.execute(
540
+ sa.select(item_sprints.c.item_id).where(
541
+ item_sprints.c.sprint_id == sprint_id,
542
+ )
543
+ )
544
+ }
545
+ for item_id, updated_at in flagged:
546
+ if item_id in already_linked:
547
+ continue
548
+ conn.execute(
549
+ item_sprints.insert().values(
550
+ item_id=item_id,
551
+ sprint_id=sprint_id,
552
+ assigned_at=updated_at,
553
+ )
554
+ )
555
+
556
+
557
+ def row_to_dict(row: sa.Row) -> dict:
558
+ return dict(row._mapping)