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,205 @@
1
+ """Checkpoint CRUD — acceptance criteria tied to items with ledger semantics."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ import sqlalchemy as sa
6
+
7
+ from dct.db import item_checkpoints, items, row_to_dict
8
+
9
+ VALID_STATUSES = {"pending", "done", "rejected"}
10
+
11
+ # Known checkpoint kinds. 'task' is the default and does NOT block resolve_item.
12
+ # Any non-'task' value is treated as a gate — resolve_item refuses until done
13
+ # or rejected. The set is intentionally open (see `kind` column docstring in
14
+ # db.py) — OSS users may define custom gate kinds; the validator accepts any
15
+ # non-empty string, the whitelist is advisory (warning for typos in future).
16
+ KNOWN_KINDS = {"task", "dogfood", "security", "approval", "review", "cc-task"}
17
+
18
+
19
+ def _validate_status(status: str) -> str:
20
+ if status not in VALID_STATUSES:
21
+ raise ValueError(f"Invalid status: '{status}'. Valid: {', '.join(sorted(VALID_STATUSES))}")
22
+ return status
23
+
24
+
25
+ def _validate_kind(kind: str) -> str:
26
+ if not kind or not kind.strip():
27
+ raise ValueError("Checkpoint kind cannot be empty")
28
+ return kind
29
+
30
+
31
+ def add_checkpoint(
32
+ engine: sa.Engine,
33
+ item_id: int,
34
+ text: str,
35
+ sort_order: int | None = None,
36
+ kind: str = "task",
37
+ ) -> dict:
38
+ """Create a checkpoint on an item.
39
+
40
+ Args:
41
+ kind: 'task' (default, non-blocking) or a gate kind:
42
+ - 'dogfood' — user-facing feature awaiting fresh-session validation
43
+ - 'security' — security review required
44
+ - 'approval' — stakeholder sign-off
45
+ - 'review' — code review
46
+ - 'cc-task' — mirrored from Claude Code TaskCreate (non-blocking)
47
+ Any non-'task' kind blocks `resolve_item` until the checkpoint is
48
+ completed or rejected (reject = 'not applicable').
49
+ """
50
+ if not text or not text.strip():
51
+ raise ValueError("Checkpoint text cannot be empty")
52
+ _validate_kind(kind)
53
+
54
+ with engine.begin() as conn:
55
+ exists = conn.execute(
56
+ sa.select(items.c.id).where(items.c.id == item_id, items.c.deleted_at.is_(None))
57
+ ).first()
58
+ if not exists:
59
+ raise ValueError(f"Item #{item_id} not found")
60
+
61
+ if sort_order is None:
62
+ max_row = conn.execute(
63
+ sa.select(sa.func.max(item_checkpoints.c.sort_order))
64
+ .where(item_checkpoints.c.item_id == item_id, item_checkpoints.c.deleted_at.is_(None))
65
+ ).first()
66
+ max_order = (max_row[0] if max_row else None) or 0
67
+ sort_order = max_order + 10
68
+
69
+ row = conn.execute(
70
+ item_checkpoints.insert()
71
+ .values(item_id=item_id, text=text, sort_order=sort_order, kind=kind)
72
+ .returning(item_checkpoints)
73
+ ).first()
74
+ assert row is not None
75
+ return row_to_dict(row)
76
+
77
+
78
+ def list_pending_gates(engine: sa.Engine, item_id: int) -> list[dict]:
79
+ """Return pending gate checkpoints (kind != 'task', status = 'pending') for item.
80
+ Used by resolve_item to block resolution until gates close. cc-task is
81
+ treated as non-gating even though kind != 'task' — it's a mirror class, not
82
+ a gate. Use the explicit filter in resolve_item rather than hardcoding here
83
+ so future gate kinds are captured by default.
84
+ """
85
+ with engine.begin() as conn:
86
+ rows = conn.execute(
87
+ sa.select(item_checkpoints)
88
+ .where(
89
+ item_checkpoints.c.item_id == item_id,
90
+ item_checkpoints.c.kind.notin_(["task", "cc-task"]),
91
+ item_checkpoints.c.status == "pending",
92
+ item_checkpoints.c.deleted_at.is_(None),
93
+ )
94
+ .order_by(item_checkpoints.c.sort_order)
95
+ ).fetchall()
96
+ return [row_to_dict(r) for r in rows]
97
+
98
+
99
+ def list_checkpoints(
100
+ engine: sa.Engine,
101
+ item_id: int,
102
+ include_deleted: bool = False,
103
+ include_rejected: bool = False,
104
+ ) -> list[dict]:
105
+ """List checkpoints for an item.
106
+
107
+ Defaults hide rejected and deleted — the typical caller wants the active
108
+ TODO list (pending + done). Pass include_rejected=True for full audit
109
+ view (e.g. "what did we plan and abandon?"). Note: get_item() does NOT
110
+ use this filter — a single-item view always returns all checkpoints,
111
+ because the caller explicitly asked about that item.
112
+ """
113
+ with engine.begin() as conn:
114
+ q = (
115
+ sa.select(item_checkpoints)
116
+ .where(item_checkpoints.c.item_id == item_id)
117
+ .order_by(item_checkpoints.c.sort_order, item_checkpoints.c.created_at)
118
+ )
119
+ if not include_deleted:
120
+ q = q.where(item_checkpoints.c.deleted_at.is_(None))
121
+ if not include_rejected:
122
+ q = q.where(item_checkpoints.c.status != "rejected")
123
+ return [row_to_dict(r) for r in conn.execute(q)]
124
+
125
+
126
+ def get_checkpoint(engine: sa.Engine, checkpoint_id: int) -> dict | None:
127
+ with engine.begin() as conn:
128
+ row = conn.execute(
129
+ sa.select(item_checkpoints).where(
130
+ item_checkpoints.c.id == checkpoint_id,
131
+ item_checkpoints.c.deleted_at.is_(None),
132
+ )
133
+ ).first()
134
+ return row_to_dict(row) if row else None
135
+
136
+
137
+ def set_status(engine: sa.Engine, checkpoint_id: int, status: str) -> dict | None:
138
+ _validate_status(status)
139
+ now = datetime.now(timezone.utc)
140
+ with engine.begin() as conn:
141
+ result = conn.execute(
142
+ item_checkpoints.update()
143
+ .where(item_checkpoints.c.id == checkpoint_id, item_checkpoints.c.deleted_at.is_(None))
144
+ .values(status=status, updated_at=now)
145
+ )
146
+ if result.rowcount == 0:
147
+ return None
148
+ return get_checkpoint(engine, checkpoint_id)
149
+
150
+
151
+ def update_text(engine: sa.Engine, checkpoint_id: int, text: str) -> dict | None:
152
+ if not text or not text.strip():
153
+ raise ValueError("Checkpoint text cannot be empty")
154
+ now = datetime.now(timezone.utc)
155
+ with engine.begin() as conn:
156
+ result = conn.execute(
157
+ item_checkpoints.update()
158
+ .where(item_checkpoints.c.id == checkpoint_id, item_checkpoints.c.deleted_at.is_(None))
159
+ .values(text=text, updated_at=now)
160
+ )
161
+ if result.rowcount == 0:
162
+ return None
163
+ return get_checkpoint(engine, checkpoint_id)
164
+
165
+
166
+ def delete_checkpoint(engine: sa.Engine, checkpoint_id: int) -> bool:
167
+ with engine.begin() as conn:
168
+ result = conn.execute(
169
+ item_checkpoints.update()
170
+ .where(item_checkpoints.c.id == checkpoint_id, item_checkpoints.c.deleted_at.is_(None))
171
+ .values(deleted_at=datetime.now(timezone.utc))
172
+ )
173
+ return result.rowcount > 0
174
+
175
+
176
+ def get_progress(engine: sa.Engine, item_id: int) -> dict:
177
+ """Compute checkpoint progress for an item.
178
+
179
+ Returns: {total, done, rejected, pending, active, percent}
180
+ - active = total - rejected (things that actually need to be done)
181
+ - percent = done / active * 100 (0 if active == 0)
182
+ """
183
+ with engine.begin() as conn:
184
+ rows = conn.execute(
185
+ sa.select(item_checkpoints.c.status, sa.func.count())
186
+ .where(item_checkpoints.c.item_id == item_id, item_checkpoints.c.deleted_at.is_(None))
187
+ .group_by(item_checkpoints.c.status)
188
+ ).fetchall()
189
+
190
+ counts = {"pending": 0, "done": 0, "rejected": 0}
191
+ for status, count in rows:
192
+ counts[status] = count
193
+
194
+ total = counts["pending"] + counts["done"] + counts["rejected"]
195
+ active = counts["pending"] + counts["done"]
196
+ percent = round(counts["done"] / active * 100) if active else 0
197
+
198
+ return {
199
+ "total": total,
200
+ "done": counts["done"],
201
+ "rejected": counts["rejected"],
202
+ "pending": counts["pending"],
203
+ "active": active,
204
+ "percent": percent,
205
+ }
dct/core/decisions.py ADDED
@@ -0,0 +1,238 @@
1
+ """Decision CRUD — ADRs, sprint amendments, inline plan decisions, open questions.
2
+
3
+ A decision is a first-class record of an architectural / scope choice with
4
+ its own lifecycle (proposed → accepted → superseded/rejected). It can be
5
+ standalone (classic ADR file in docs/adr/) or anchored to a plan, a plan
6
+ section, or a sprint — or several at once (multi-anchor via optional FKs).
7
+
8
+ Spec §4.1 + §5.1 + §9.5.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime, timezone
14
+
15
+ import sqlalchemy as sa
16
+
17
+ from dct.core.ids import new_ulid
18
+ from dct.core.projects import resolve_project_id
19
+ from dct.db import decisions, row_to_dict
20
+
21
+ VALID_KINDS = {"adr", "sprint_amendment", "inline_plan", "open_question"}
22
+ VALID_STATUSES = {"proposed", "accepted", "superseded", "rejected"}
23
+
24
+
25
+ def _validate(value: str, valid: set[str], field: str) -> str:
26
+ if value not in valid:
27
+ raise ValueError(f"Invalid {field}: '{value}'. Valid: {', '.join(sorted(valid))}")
28
+ return value
29
+
30
+
31
+ def create_decision(
32
+ engine: sa.Engine,
33
+ project_slug: str,
34
+ kind: str,
35
+ title: str,
36
+ context: str | None = None,
37
+ decision: str | None = None,
38
+ consequences: str | None = None,
39
+ alternatives: str | None = None,
40
+ status: str = "proposed",
41
+ ref_code: str | None = None,
42
+ plan_id: int | None = None,
43
+ plan_section_id: int | None = None,
44
+ sprint_id: int | None = None,
45
+ source_file: str | None = None,
46
+ source_lines: str | None = None,
47
+ ulid: str | None = None,
48
+ ) -> dict:
49
+ _validate(kind, VALID_KINDS, "kind")
50
+ _validate(status, VALID_STATUSES, "status")
51
+ if not title or not title.strip():
52
+ raise ValueError("Decision title cannot be empty")
53
+
54
+ decision_ulid = ulid or new_ulid()
55
+ with engine.begin() as conn:
56
+ project_id = resolve_project_id(conn, project_slug)
57
+ if ref_code:
58
+ dup = conn.execute(
59
+ sa.select(decisions).where(
60
+ decisions.c.project_id == project_id,
61
+ decisions.c.ref_code == ref_code,
62
+ decisions.c.deleted_at.is_(None),
63
+ )
64
+ ).first()
65
+ if dup:
66
+ raise ValueError(f"Decision with ref_code '{ref_code}' already exists in project")
67
+
68
+ row = conn.execute(
69
+ decisions.insert()
70
+ .values(
71
+ ulid=decision_ulid,
72
+ project_id=project_id,
73
+ kind=kind,
74
+ ref_code=ref_code,
75
+ title=title.strip(),
76
+ context=context,
77
+ decision=decision,
78
+ consequences=consequences,
79
+ alternatives=alternatives,
80
+ status=status,
81
+ source_file=source_file,
82
+ source_lines=source_lines,
83
+ plan_id=plan_id,
84
+ plan_section_id=plan_section_id,
85
+ sprint_id=sprint_id,
86
+ )
87
+ .returning(decisions)
88
+ ).first()
89
+ assert row is not None, "INSERT RETURNING must return a row"
90
+ return row_to_dict(row)
91
+
92
+
93
+ def list_decisions(
94
+ engine: sa.Engine,
95
+ project_slug: str | None = None,
96
+ kind: str | None = None,
97
+ status: str | None = None,
98
+ plan_id: int | None = None,
99
+ sprint_id: int | None = None,
100
+ include_deleted: bool = False,
101
+ ) -> list[dict]:
102
+ with engine.begin() as conn:
103
+ q = sa.select(decisions).order_by(decisions.c.created_at.desc())
104
+ if project_slug:
105
+ q = q.where(decisions.c.project_id == resolve_project_id(conn, project_slug))
106
+ if kind:
107
+ q = q.where(decisions.c.kind == kind)
108
+ if status:
109
+ q = q.where(decisions.c.status == status)
110
+ if plan_id is not None:
111
+ q = q.where(decisions.c.plan_id == plan_id)
112
+ if sprint_id is not None:
113
+ q = q.where(decisions.c.sprint_id == sprint_id)
114
+ if not include_deleted:
115
+ q = q.where(decisions.c.deleted_at.is_(None))
116
+ return [row_to_dict(r) for r in conn.execute(q)]
117
+
118
+
119
+ def get_decision(engine: sa.Engine, decision_id: int) -> dict | None:
120
+ with engine.begin() as conn:
121
+ row = conn.execute(
122
+ sa.select(decisions).where(
123
+ decisions.c.id == decision_id, decisions.c.deleted_at.is_(None)
124
+ )
125
+ ).first()
126
+ return row_to_dict(row) if row else None
127
+
128
+
129
+ def get_decision_by_ulid(engine: sa.Engine, ulid: str) -> dict | None:
130
+ with engine.begin() as conn:
131
+ row = conn.execute(
132
+ sa.select(decisions).where(
133
+ decisions.c.ulid == ulid, decisions.c.deleted_at.is_(None)
134
+ )
135
+ ).first()
136
+ return row_to_dict(row) if row else None
137
+
138
+
139
+ def get_decision_by_ref_code(
140
+ engine: sa.Engine, project_slug: str, ref_code: str
141
+ ) -> dict | None:
142
+ with engine.begin() as conn:
143
+ pid = resolve_project_id(conn, project_slug)
144
+ row = conn.execute(
145
+ sa.select(decisions).where(
146
+ decisions.c.project_id == pid,
147
+ decisions.c.ref_code == ref_code,
148
+ decisions.c.deleted_at.is_(None),
149
+ )
150
+ ).first()
151
+ return row_to_dict(row) if row else None
152
+
153
+
154
+ def update_decision(engine: sa.Engine, decision_id: int, **kwargs) -> dict | None:
155
+ allowed = {
156
+ "title", "context", "decision", "consequences", "alternatives",
157
+ "status", "kind", "ref_code",
158
+ "plan_id", "plan_section_id", "sprint_id",
159
+ "source_file", "source_lines",
160
+ }
161
+ values: dict = {}
162
+ for k, v in kwargs.items():
163
+ if k not in allowed or v is None:
164
+ continue
165
+ if k == "kind":
166
+ _validate(v, VALID_KINDS, "kind")
167
+ elif k == "status":
168
+ _validate(v, VALID_STATUSES, "status")
169
+ values[k] = v
170
+ if not values:
171
+ raise ValueError("No valid fields to update")
172
+ values["updated_at"] = datetime.now(timezone.utc)
173
+
174
+ with engine.begin() as conn:
175
+ result = conn.execute(
176
+ decisions.update()
177
+ .where(
178
+ decisions.c.id == decision_id, decisions.c.deleted_at.is_(None)
179
+ )
180
+ .values(**values)
181
+ )
182
+ if result.rowcount == 0:
183
+ return None
184
+ return get_decision(engine, decision_id)
185
+
186
+
187
+ def supersede_decision(
188
+ engine: sa.Engine, old_decision_id: int, new_decision_id: int
189
+ ) -> dict | None:
190
+ """Mark an older decision as superseded by a newer one.
191
+
192
+ Sets `status='superseded'` and `superseded_by=<new_id>` on the old row.
193
+ Does NOT touch the new row — caller is responsible for its state
194
+ (typically `status='accepted'` when the supersession happens).
195
+ """
196
+ if old_decision_id == new_decision_id:
197
+ raise ValueError("A decision cannot supersede itself")
198
+ now = datetime.now(timezone.utc)
199
+ with engine.begin() as conn:
200
+ # Verify both rows exist
201
+ old = conn.execute(
202
+ sa.select(decisions.c.id).where(
203
+ decisions.c.id == old_decision_id,
204
+ decisions.c.deleted_at.is_(None),
205
+ )
206
+ ).first()
207
+ if not old:
208
+ raise ValueError(f"Decision #{old_decision_id} not found")
209
+ new = conn.execute(
210
+ sa.select(decisions.c.id).where(
211
+ decisions.c.id == new_decision_id,
212
+ decisions.c.deleted_at.is_(None),
213
+ )
214
+ ).first()
215
+ if not new:
216
+ raise ValueError(f"Decision #{new_decision_id} not found")
217
+
218
+ result = conn.execute(
219
+ decisions.update()
220
+ .where(decisions.c.id == old_decision_id)
221
+ .values(status="superseded", superseded_by=new_decision_id, updated_at=now)
222
+ )
223
+ if result.rowcount == 0:
224
+ return None
225
+ return get_decision(engine, old_decision_id)
226
+
227
+
228
+ def delete_decision(engine: sa.Engine, decision_id: int) -> bool:
229
+ now = datetime.now(timezone.utc)
230
+ with engine.begin() as conn:
231
+ result = conn.execute(
232
+ decisions.update()
233
+ .where(
234
+ decisions.c.id == decision_id, decisions.c.deleted_at.is_(None)
235
+ )
236
+ .values(deleted_at=now)
237
+ )
238
+ return result.rowcount > 0
dct/core/engine.py ADDED
@@ -0,0 +1,32 @@
1
+ """Engine bootstrap — the single config→engine→(optional)DDL helper.
2
+
3
+ DRY extraction of the duplicated `load_config() -> get_engine() -> create_tables()`
4
+ block that previously lived in both dct/server.py and dct/cli.py.
5
+
6
+ `create=False` returns a usable Engine WITHOUT running any DDL. The v1 web
7
+ viewer is strictly read-only and relies on this to guarantee it never mutates
8
+ the schema (no create_tables, only SELECTs).
9
+ """
10
+
11
+ import sqlalchemy as sa
12
+
13
+ from dct.config import load_config
14
+ from dct.db import get_engine, create_tables
15
+
16
+
17
+ def bootstrap_engine(create: bool = True) -> sa.Engine:
18
+ """Build a SQLAlchemy Engine from dct config.
19
+
20
+ Args:
21
+ create: when True (default) run idempotent DDL via create_tables so the
22
+ schema exists. When False, skip ALL DDL — caller guarantees the DB
23
+ already exists (read-only consumers such as the web viewer).
24
+
25
+ Returns:
26
+ A configured sqlalchemy.Engine.
27
+ """
28
+ cfg = load_config()
29
+ engine = get_engine(cfg.database_url)
30
+ if create:
31
+ create_tables(engine) # idempotent
32
+ return engine
dct/core/handoff.py ADDED
@@ -0,0 +1,151 @@
1
+ """Session handoff ledger — prompts left by one session for the next.
2
+
3
+ Ledger semantics: never hard-deleted, multiple unconsumed rows per project
4
+ allowed (parallel sessions, different scopes). Consumer marks consumed_at
5
+ (history preserved) or deleted_at (soft discard).
6
+ """
7
+
8
+ from datetime import datetime, timezone
9
+
10
+ import sqlalchemy as sa
11
+
12
+ from dct.db import session_handoffs, row_to_dict
13
+ from dct.core.projects import resolve_project_id
14
+
15
+
16
+ def create_handoff(
17
+ engine: sa.Engine,
18
+ project_slug: str,
19
+ prompt: str,
20
+ created_by: str,
21
+ scope: str | None = None,
22
+ context_summary: str | None = None,
23
+ ) -> dict:
24
+ if not prompt or not prompt.strip():
25
+ raise ValueError("Handoff prompt cannot be empty")
26
+ if not created_by or not created_by.strip():
27
+ raise ValueError("created_by cannot be empty — identify the session (model + timestamp)")
28
+
29
+ with engine.begin() as conn:
30
+ project_id = resolve_project_id(conn, project_slug)
31
+ row = conn.execute(
32
+ session_handoffs.insert()
33
+ .values(
34
+ project_id=project_id,
35
+ prompt=prompt,
36
+ context_summary=context_summary,
37
+ scope=scope,
38
+ created_by=created_by,
39
+ )
40
+ .returning(session_handoffs)
41
+ ).first()
42
+ assert row is not None
43
+ return row_to_dict(row)
44
+
45
+
46
+ def list_handoffs(
47
+ engine: sa.Engine,
48
+ project_slug: str | None = None,
49
+ unconsumed_only: bool = True,
50
+ ) -> list[dict]:
51
+ with engine.begin() as conn:
52
+ q = sa.select(session_handoffs).where(session_handoffs.c.deleted_at.is_(None))
53
+ if project_slug:
54
+ project_id = resolve_project_id(conn, project_slug)
55
+ q = q.where(session_handoffs.c.project_id == project_id)
56
+ if unconsumed_only:
57
+ q = q.where(session_handoffs.c.consumed_at.is_(None))
58
+ q = q.order_by(session_handoffs.c.created_at.desc())
59
+ return [row_to_dict(r) for r in conn.execute(q)]
60
+
61
+
62
+ def get_handoff(engine: sa.Engine, handoff_id: int) -> dict | None:
63
+ with engine.begin() as conn:
64
+ row = conn.execute(
65
+ sa.select(session_handoffs).where(
66
+ session_handoffs.c.id == handoff_id,
67
+ session_handoffs.c.deleted_at.is_(None),
68
+ )
69
+ ).first()
70
+ return row_to_dict(row) if row else None
71
+
72
+
73
+ def consume_handoff(
74
+ engine: sa.Engine,
75
+ handoff_id: int,
76
+ consumed_by: str | None = None,
77
+ ) -> dict | None:
78
+ now = datetime.now(timezone.utc)
79
+ with engine.begin() as conn:
80
+ result = conn.execute(
81
+ session_handoffs.update()
82
+ .where(
83
+ session_handoffs.c.id == handoff_id,
84
+ session_handoffs.c.deleted_at.is_(None),
85
+ session_handoffs.c.consumed_at.is_(None),
86
+ )
87
+ .values(consumed_at=now, consumed_by=consumed_by)
88
+ )
89
+ if result.rowcount == 0:
90
+ return None
91
+ return get_handoff(engine, handoff_id)
92
+
93
+
94
+ def delete_handoff(engine: sa.Engine, handoff_id: int) -> bool:
95
+ """Soft delete — preserves row for audit but hides from list_handoffs."""
96
+ with engine.begin() as conn:
97
+ result = conn.execute(
98
+ session_handoffs.update()
99
+ .where(
100
+ session_handoffs.c.id == handoff_id,
101
+ session_handoffs.c.deleted_at.is_(None),
102
+ )
103
+ .values(deleted_at=datetime.now(timezone.utc))
104
+ )
105
+ return result.rowcount > 0
106
+
107
+
108
+ def update_handoff(
109
+ engine: sa.Engine,
110
+ handoff_id: int,
111
+ prompt: str | None = None,
112
+ scope: str | None = None,
113
+ context_summary: str | None = None,
114
+ ) -> dict | None:
115
+ """Amend an UNCONSUMED handoff in place — preserves id + created_at.
116
+
117
+ Only fields passed (not None) are changed; None means "leave unchanged".
118
+ Consumed or soft-deleted handoffs are immutable ledger entries: returns
119
+ None (no row matches the filter). Also returns None when the id does not
120
+ exist. This is the in-place alternative to the supersede pattern
121
+ (delete_handoff + create_handoff), so the next session sees the latest
122
+ draft without the id churning.
123
+
124
+ Raises ValueError if prompt is given but blank, or if no field is given.
125
+ """
126
+ if prompt is not None and not prompt.strip():
127
+ raise ValueError("Handoff prompt cannot be empty")
128
+
129
+ values: dict = {}
130
+ if prompt is not None:
131
+ values["prompt"] = prompt
132
+ if scope is not None:
133
+ values["scope"] = scope
134
+ if context_summary is not None:
135
+ values["context_summary"] = context_summary
136
+ if not values:
137
+ raise ValueError("Nothing to update — pass prompt, scope, or context_summary")
138
+
139
+ with engine.begin() as conn:
140
+ result = conn.execute(
141
+ session_handoffs.update()
142
+ .where(
143
+ session_handoffs.c.id == handoff_id,
144
+ session_handoffs.c.deleted_at.is_(None),
145
+ session_handoffs.c.consumed_at.is_(None),
146
+ )
147
+ .values(**values)
148
+ )
149
+ if result.rowcount == 0:
150
+ return None
151
+ return get_handoff(engine, handoff_id)
dct/core/ids.py ADDED
@@ -0,0 +1,25 @@
1
+ """Identifier helpers — ULID generation for plans/sections/checkpoints/decisions.
2
+
3
+ Why ULID (not UUID4): time-sortable at millisecond granularity (newer rows
4
+ sort later naturally in DB), shorter stringified form (26 chars vs 36),
5
+ Crockford base32 so no I/L/O/U ambiguity. Strict ordering within one
6
+ millisecond is NOT guaranteed — intra-plan ordering uses explicit sort_order
7
+ columns instead.
8
+ """
9
+
10
+ import re
11
+
12
+ from ulid import ULID
13
+
14
+ # Crockford base32 alphabet without I, L, O, U; 26 chars total.
15
+ ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
16
+
17
+
18
+ def new_ulid() -> str:
19
+ """Generate a fresh ULID as a 26-char uppercase Crockford-base32 string."""
20
+ return str(ULID())
21
+
22
+
23
+ def is_valid_ulid(value: str) -> bool:
24
+ """True iff `value` has the exact shape of a ULID — 26 Crockford-base32 chars."""
25
+ return bool(ULID_RE.fullmatch(value))