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/checkpoints.py
ADDED
|
@@ -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))
|