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,408 @@
1
+ """Ingest pipeline — glue from markdown file to DB state.
2
+
3
+ Flow per spec §9.1.3:
4
+ 1. Read + parse (parser.parse_plan).
5
+ 2. Ensure every parsed element has a ULID; fill missing parents.
6
+ 3. If any ULID was generated → write file atomically.
7
+ 4. Compute sha256 → source_hash.
8
+ 5. first-import (no plans row, or imported_at=NULL) vs re-sync.
9
+ 6. Upsert plans + sections + checkpoints (parent FK resolution via ulid→id map).
10
+ 7. On first import only: respect MD checkbox `[x]` as `status='done'`.
11
+ 8. Optional: resolve uncertain sections via `claude --print`.
12
+ 9. Stamp imported_at on first import; always update source_hash.
13
+
14
+ Deliberately keeps no global state — all intermediate maps are local.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import os
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from dct.config import load_config
26
+ from dct.core.ids import new_ulid
27
+ from dct.core.plans import classify as classify_mod
28
+ from dct.core.plans.crud import (
29
+ add_plan_checkpoint,
30
+ add_plan_section,
31
+ create_plan,
32
+ get_plan,
33
+ get_plan_by_ulid,
34
+ get_plan_checkpoint_by_ulid,
35
+ get_plan_section_by_ulid,
36
+ set_plan_imported,
37
+ update_plan,
38
+ update_plan_section,
39
+ )
40
+ from dct.core.plans.parser import ParseResult, parse_plan, slugify
41
+
42
+
43
+ @dataclass
44
+ class IngestReport:
45
+ plan_id: int
46
+ plan_ulid: str
47
+ is_first_import: bool
48
+ source_hash: str
49
+ sections_created: int = 0
50
+ sections_updated: int = 0
51
+ checkpoints_created: int = 0
52
+ uncertain_resolved: int = 0
53
+ uncertain_remaining: int = 0
54
+ ulids_injected: bool = False
55
+ warnings: list[str] = field(default_factory=list)
56
+
57
+ def as_dict(self) -> dict:
58
+ return {
59
+ "plan_id": self.plan_id,
60
+ "plan_ulid": self.plan_ulid,
61
+ "is_first_import": self.is_first_import,
62
+ "source_hash": self.source_hash,
63
+ "sections_created": self.sections_created,
64
+ "sections_updated": self.sections_updated,
65
+ "checkpoints_created": self.checkpoints_created,
66
+ "uncertain_resolved": self.uncertain_resolved,
67
+ "uncertain_remaining": self.uncertain_remaining,
68
+ "ulids_injected": self.ulids_injected,
69
+ "warnings": self.warnings,
70
+ }
71
+
72
+
73
+ # ── ULID + parent fill ────────────────────────────────────────────────────────
74
+
75
+ def ensure_ulids_and_parents(result: ParseResult) -> bool:
76
+ """Generate missing ULIDs on result in-place; fill missing parents via nesting.
77
+
78
+ Returns True iff any ULID was newly generated (caller must write-back).
79
+ """
80
+ changed = False
81
+ if result.plan_ulid is None:
82
+ result.plan_ulid = new_ulid()
83
+ changed = True
84
+
85
+ # Section ULIDs first, so parent lookups resolve.
86
+ for s in result.sections:
87
+ if s.ulid is None:
88
+ s.ulid = new_ulid()
89
+ changed = True
90
+
91
+ # Parent lookups via depth-stack walk in file order.
92
+ stack: list[tuple[int, str]] = [] # (depth, ulid)
93
+ for s in result.sections:
94
+ while stack and stack[-1][0] >= s.depth:
95
+ stack.pop()
96
+ if s.parent_ulid is None and stack:
97
+ s.parent_ulid = stack[-1][1]
98
+ changed = True
99
+ stack.append((s.depth, s.ulid)) # type: ignore[arg-type]
100
+
101
+ # Checkpoint ULIDs + parent section ULID from the nearest preceding heading.
102
+ sections_in_order = sorted(result.sections, key=lambda s: s.heading_line)
103
+ for cp in result.checkpoints:
104
+ if cp.ulid is None:
105
+ cp.ulid = new_ulid()
106
+ changed = True
107
+ if cp.parent_section_ulid is None:
108
+ preceding = [s for s in sections_in_order if 0 < s.heading_line < cp.checkbox_line]
109
+ if preceding:
110
+ cp.parent_section_ulid = preceding[-1].ulid
111
+ changed = True
112
+
113
+ return changed
114
+
115
+
116
+ # ── Write-back ────────────────────────────────────────────────────────────────
117
+
118
+ CHECKBOX_PATTERN = re.compile(r"^(?P<prefix>\s*-\s+\[[ xX]\]\s+)(?P<rest>.*)$")
119
+
120
+
121
+ def write_back_anchors(content: str, result: ParseResult) -> str:
122
+ """Return `content` augmented with missing ULID HTML anchors.
123
+
124
+ Inserts at these points:
125
+ - Plan anchor at top of file if `result.plan_anchor_line is None`.
126
+ - Section anchor on line AFTER heading_line when `anchor_line is None`.
127
+ - Checkpoint anchor inline (between `[ ]` marker and text) on each checkbox
128
+ line missing an existing cp anchor.
129
+
130
+ Idempotent: lines that already carry the anchor are left untouched.
131
+ Preserves trailing newline if the source had one.
132
+ """
133
+ ends_with_newline = content.endswith("\n")
134
+ lines = content.splitlines()
135
+
136
+ # --- Checkpoint anchors: replace lines in-place (no line count change). ---
137
+ for cp in result.checkpoints:
138
+ if cp.ulid is None or cp.checkbox_line <= 0 or cp.checkbox_line > len(lines):
139
+ continue
140
+ idx = cp.checkbox_line - 1
141
+ original = lines[idx]
142
+ if "<!-- dct:cp:" in original:
143
+ continue # already has an anchor
144
+ m = CHECKBOX_PATTERN.match(original)
145
+ if not m:
146
+ continue
147
+ parent_part = f" parent={cp.parent_section_ulid}" if cp.parent_section_ulid else ""
148
+ lines[idx] = (
149
+ f"{m.group('prefix')}<!-- dct:cp:{cp.ulid}{parent_part} --> "
150
+ f"{m.group('rest').lstrip()}"
151
+ )
152
+
153
+ # --- Section anchors: INSERT new line after heading_line (descending, so
154
+ # earlier insertions don't shift later line numbers). Collect first.
155
+ insertions: list[tuple[int, str]] = [] # (insert_at_0based, line)
156
+ for s in result.sections:
157
+ if s.ulid is None or s.anchor_line is not None:
158
+ continue
159
+ parent_part = f" parent={s.parent_ulid}" if s.parent_ulid else ""
160
+ line = f"<!-- dct:section:{s.ulid}{parent_part} -->"
161
+ # heading_line is 1-based → insert AFTER it → 0-based index = heading_line
162
+ insertions.append((s.heading_line, line))
163
+
164
+ # --- Plan anchor at top of file if missing. ---
165
+ if result.plan_anchor_line is None and result.plan_ulid:
166
+ insertions.append((0, f"<!-- dct:plan:{result.plan_ulid} -->"))
167
+
168
+ # Apply insertions in DESCENDING order of target position.
169
+ insertions.sort(key=lambda x: x[0], reverse=True)
170
+ for at_idx, new_line in insertions:
171
+ lines.insert(at_idx, new_line)
172
+
173
+ output = "\n".join(lines)
174
+ if ends_with_newline and not output.endswith("\n"):
175
+ output += "\n"
176
+ return output
177
+
178
+
179
+ def write_file_atomic(path: Path, content: str) -> None:
180
+ """Atomic write: temp file in same dir, then os.replace."""
181
+ tmp = path.with_suffix(path.suffix + ".dct-tmp")
182
+ tmp.write_text(content, encoding="utf-8")
183
+ os.replace(tmp, path)
184
+
185
+
186
+ # ── Kind inference from path ──────────────────────────────────────────────────
187
+
188
+ def infer_kind_from_path(path: Path) -> str:
189
+ """Map a filesystem path to a plan `kind`.
190
+
191
+ Order matters — specific dirs first, then filename-based heuristics, then
192
+ a conservative default of 'plan'.
193
+ """
194
+ parts = [p.lower() for p in path.parts]
195
+ name = path.name.lower()
196
+
197
+ if "adr" in parts or name.startswith("adr-"):
198
+ return "adr"
199
+ if "specs" in parts or name.endswith("-design.md") or "design-spec" in name:
200
+ return "spec"
201
+ if "plans" in parts:
202
+ return "plan"
203
+ if "retros" in parts or name.startswith("retro-") or "retrospective" in name:
204
+ return "retro"
205
+ if "roadmap" in name:
206
+ return "roadmap"
207
+ if name in {"todos.md", "future.md"} or "backlog" in name:
208
+ return "backlog"
209
+ return "plan"
210
+
211
+
212
+ # ── Main ingest function ─────────────────────────────────────────────────────
213
+
214
+ def ingest_file(
215
+ engine,
216
+ file_path: str | Path,
217
+ project_slug: str,
218
+ auto_resolve_uncertain: bool | None = None,
219
+ ) -> dict:
220
+ """Ingest a markdown plan file into dct.
221
+
222
+ `auto_resolve_uncertain`: when True, any section classified as 'uncertain'
223
+ is sent through `classify_uncertain()` (`claude --print`). None = read
224
+ the default from `~/.claude/dct.toml [plans].auto_resolve_uncertain`
225
+ (default true).
226
+ """
227
+ path = Path(file_path).resolve()
228
+ if not path.is_file():
229
+ raise FileNotFoundError(f"Plan file not found: {path}")
230
+
231
+ if auto_resolve_uncertain is None:
232
+ auto_resolve_uncertain = load_config().plans.auto_resolve_uncertain
233
+
234
+ content = path.read_text(encoding="utf-8")
235
+ result = parse_plan(content)
236
+
237
+ ulids_injected = ensure_ulids_and_parents(result)
238
+ if ulids_injected:
239
+ content = write_back_anchors(content, result)
240
+ write_file_atomic(path, content)
241
+ # `plan_anchor_missing` was a legitimate complaint about the file
242
+ # state at parse time, but we just fixed it by injecting the anchor.
243
+ # Keep the report honest — drop that warning so callers don't see a
244
+ # false "missing" after we've visibly written the anchor to disk.
245
+ result.warnings = [w for w in result.warnings if w != "plan_anchor_missing"]
246
+
247
+ source_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
248
+
249
+ # Look up existing plan by ULID (stable across rename / move).
250
+ assert result.plan_ulid is not None # ensure_ulids guarantees this
251
+ existing = get_plan_by_ulid(engine, result.plan_ulid)
252
+ is_first_import = existing is None or existing.get("imported_at") is None
253
+
254
+ # Derive plan title + slug on first creation.
255
+ if existing is None:
256
+ title = _infer_title(result, path)
257
+ slug = _pick_unique_slug(engine, project_slug, slugify(path.stem))
258
+ kind = infer_kind_from_path(path)
259
+ plan = create_plan(
260
+ engine, project_slug, slug=slug, title=title, kind=kind,
261
+ source_file=str(path), source_hash=source_hash, ulid=result.plan_ulid,
262
+ )
263
+ else:
264
+ update_plan(engine, existing["id"], source_hash=source_hash)
265
+ plan = get_plan(engine, existing["id"]) or existing
266
+
267
+ report = IngestReport(
268
+ plan_id=plan["id"], plan_ulid=result.plan_ulid,
269
+ is_first_import=is_first_import, source_hash=source_hash,
270
+ ulids_injected=ulids_injected, warnings=list(result.warnings),
271
+ )
272
+
273
+ # ── Sections ────────────────────────────────────────────────────────────
274
+ section_ulid_to_id: dict[str, int] = {}
275
+ for s in result.sections:
276
+ assert s.ulid is not None
277
+
278
+ # Resolve uncertain if requested.
279
+ section_type = s.section_type
280
+ confidence = s.section_type_confidence
281
+ needs_review = section_type == "uncertain"
282
+ if needs_review and auto_resolve_uncertain:
283
+ resolved = classify_mod.classify_uncertain(
284
+ s.heading,
285
+ parent_heading=_find_parent_heading(s, result.sections),
286
+ preview="",
287
+ )
288
+ if resolved:
289
+ section_type = resolved
290
+ confidence = "high"
291
+ needs_review = False
292
+ report.uncertain_resolved += 1
293
+ else:
294
+ report.uncertain_remaining += 1
295
+ elif needs_review:
296
+ report.uncertain_remaining += 1
297
+
298
+ parent_id = (
299
+ section_ulid_to_id.get(s.parent_ulid) if s.parent_ulid else None
300
+ )
301
+ existing_section = get_plan_section_by_ulid(engine, s.ulid)
302
+ if existing_section is None:
303
+ new_section = add_plan_section(
304
+ engine, plan["id"], s.heading, s.depth,
305
+ section_type=section_type,
306
+ section_type_confidence=confidence,
307
+ needs_llm_review=needs_review,
308
+ ulid=s.ulid,
309
+ parent_section_id=parent_id,
310
+ sort_order=s.sort_order,
311
+ )
312
+ section_ulid_to_id[s.ulid] = new_section["id"]
313
+ report.sections_created += 1
314
+ else:
315
+ updates: dict = {}
316
+ if existing_section["heading"] != s.heading:
317
+ updates["heading"] = s.heading
318
+ if existing_section["section_type"] != section_type:
319
+ # Sticky resolved: a row that was previously classified (e.g.
320
+ # by LLM to 'reference', or by a user via /plan-resolve-uncertain)
321
+ # must NOT be downgraded back to 'uncertain' on a later rescan
322
+ # where auto_resolve_uncertain is off or the LLM happens to
323
+ # fail. The parser always re-infers from heading text alone,
324
+ # so its 'uncertain' verdict is weaker than any resolved DB
325
+ # state. Only overwrite when the new classification is itself
326
+ # non-uncertain (i.e. parser found a better match, or the LLM
327
+ # resolved on this run).
328
+ if section_type == "uncertain" and existing_section["section_type"] != "uncertain":
329
+ pass # keep the resolved DB state
330
+ else:
331
+ updates["section_type"] = section_type
332
+ updates["section_type_confidence"] = confidence
333
+ updates["needs_llm_review"] = needs_review
334
+ if existing_section["sort_order"] != s.sort_order:
335
+ updates["sort_order"] = s.sort_order
336
+ if updates:
337
+ update_plan_section(engine, existing_section["id"], **updates)
338
+ report.sections_updated += 1
339
+ section_ulid_to_id[s.ulid] = existing_section["id"]
340
+
341
+ # ── Checkpoints ─────────────────────────────────────────────────────────
342
+ for cp in result.checkpoints:
343
+ assert cp.ulid is not None
344
+ if cp.parent_section_ulid is None:
345
+ continue # orphan checkpoint — warning already in result.warnings
346
+ section_id = section_ulid_to_id.get(cp.parent_section_ulid)
347
+ if section_id is None:
348
+ continue # parent not tracked (shouldn't happen after upsert)
349
+ existing_cp = get_plan_checkpoint_by_ulid(engine, cp.ulid)
350
+ if existing_cp is None:
351
+ initial_status = "pending"
352
+ if is_first_import and cp.initial_state == "done":
353
+ initial_status = "done"
354
+ add_plan_checkpoint(
355
+ engine, section_id, cp.text,
356
+ status=initial_status, ulid=cp.ulid,
357
+ sort_order=cp.sort_order,
358
+ )
359
+ report.checkpoints_created += 1
360
+ # Re-sync path: state is DB-only, MD brackets ignored. We DO NOT
361
+ # update cp.text on re-sync; treating text edit as a new checkpoint
362
+ # would lose state. Future work: add a `dct plan cp rename` command.
363
+
364
+ # ── Finalize ────────────────────────────────────────────────────────────
365
+ if is_first_import:
366
+ set_plan_imported(engine, plan["id"], source_hash=source_hash)
367
+
368
+ return report.as_dict()
369
+
370
+
371
+ # ── Small helpers ────────────────────────────────────────────────────────────
372
+
373
+ def _infer_title(result: ParseResult, path: Path) -> str:
374
+ """Prefer the first H1 heading; fall back to the file stem."""
375
+ for s in result.sections:
376
+ if s.depth == 1:
377
+ return s.heading
378
+ return path.stem.replace("-", " ").replace("_", " ").title()
379
+
380
+
381
+ def _pick_unique_slug(engine, project_slug: str, base_slug: str) -> str:
382
+ """Find a slug not already taken in this project.
383
+
384
+ Appends `-2`, `-3`, … if needed. Caller is responsible for the race with
385
+ concurrent ingest (unlikely — importer is typically single-invocation).
386
+ """
387
+ from dct.core.plans.crud import get_plan_by_slug
388
+ if not get_plan_by_slug(engine, project_slug, base_slug):
389
+ return base_slug
390
+ i = 2
391
+ while True:
392
+ candidate = f"{base_slug}-{i}"
393
+ if not get_plan_by_slug(engine, project_slug, candidate):
394
+ return candidate
395
+ i += 1
396
+
397
+
398
+ def _find_parent_heading(
399
+ section, sections: list
400
+ ) -> str | None:
401
+ """Given a ParsedSection, return the heading text of its structural parent."""
402
+ parent_ulid = section.parent_ulid
403
+ if not parent_ulid:
404
+ return None
405
+ for s in sections:
406
+ if s.ulid == parent_ulid:
407
+ return s.heading
408
+ return None