devcouncil 0.2.0 → 0.3.0

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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -1,10 +1,12 @@
1
+ from pathlib import Path
2
+
1
3
  from devcouncil.artifacts.graph import ArtifactGraph
2
4
  from devcouncil.reporting.markdown_report import MarkdownReportGenerator
3
5
  from devcouncil.reporting.json_report import JsonReportGenerator
4
6
 
5
7
  class ReportBuilder:
6
8
  """Builds reports in various formats from the artifact graph."""
7
-
9
+
8
10
  @staticmethod
9
11
  def build_markdown(graph: ArtifactGraph, live_review: dict | None = None) -> str:
10
12
  return MarkdownReportGenerator.generate(graph, live_review=live_review)
@@ -12,3 +14,18 @@ class ReportBuilder:
12
14
  @staticmethod
13
15
  def build_json(graph: ArtifactGraph, live_review: dict | None = None) -> str:
14
16
  return JsonReportGenerator.generate(graph, live_review=live_review)
17
+
18
+ @staticmethod
19
+ def build_okf_bundle(
20
+ graph: ArtifactGraph,
21
+ output_dir: Path,
22
+ repo_map=None,
23
+ project_name: str = "DevCouncil Project",
24
+ timestamp: str = "",
25
+ ) -> list[Path]:
26
+ """Export the artifact graph as an Open Knowledge Format bundle on disk."""
27
+ from devcouncil.reporting.okf_bundle_writer import OKFBundleWriter
28
+
29
+ return OKFBundleWriter.generate(
30
+ graph, output_dir, repo_map=repo_map, project_name=project_name, timestamp=timestamp
31
+ )
@@ -10,13 +10,15 @@ scaffolded into a target repo's ``.claude/skills/`` directory.
10
10
  from __future__ import annotations
11
11
 
12
12
  import fnmatch
13
+ import functools
13
14
  import os
14
15
  import re
15
16
  from pathlib import Path
16
17
 
17
- import yaml
18
18
  from pydantic import BaseModel, Field
19
19
 
20
+ from devcouncil.knowledge.frontmatter import build_frontmatter_markdown, split_frontmatter
21
+
20
22
  LIBRARY_DIR = Path(__file__).resolve().parent / "library"
21
23
 
22
24
 
@@ -90,26 +92,18 @@ class Skill(BaseModel):
90
92
 
91
93
  def to_skill_md(self) -> str:
92
94
  """Render as a Claude-Code-style SKILL.md (name + description frontmatter + body)."""
93
- front = yaml.safe_dump(
95
+ return build_frontmatter_markdown(
94
96
  {"name": self.name, "description": self.description},
95
- sort_keys=False,
96
- default_flow_style=False,
97
- allow_unicode=True,
98
- ).strip()
99
- return f"---\n{front}\n---\n\n{self.body.strip()}\n"
97
+ self.body,
98
+ )
100
99
 
101
100
 
102
- def _split_frontmatter(text: str) -> tuple[dict, str]:
103
- if text.startswith("---"):
104
- parts = text.split("---", 2)
105
- if len(parts) == 3:
106
- meta = yaml.safe_load(parts[1]) or {}
107
- return (meta if isinstance(meta, dict) else {}), parts[2].lstrip("\n")
108
- return {}, text
101
+ # Frontmatter parsing lives in devcouncil.knowledge.frontmatter so skills and the OKF /
102
+ # design.md formats share one implementation; kept aliased here for existing callers.
103
+ _split_frontmatter = split_frontmatter
109
104
 
110
105
 
111
- def _skill_from_file(path: Path) -> Skill:
112
- meta, body = _split_frontmatter(path.read_text(encoding="utf-8"))
106
+ def _skill_from_meta(path: Path, meta: dict, body: str) -> Skill:
113
107
  triggers = meta.get("triggers") or {}
114
108
  return Skill(
115
109
  name=str(meta.get("name") or path.stem),
@@ -130,13 +124,19 @@ def _skill_from_file(path: Path) -> Skill:
130
124
  REPO_SKILL_DIRS = (".claude/skills", ".devcouncil/skills")
131
125
 
132
126
 
133
- def _is_skill_file(path: Path) -> bool:
134
- """A markdown file is a skill only if it has frontmatter with a name."""
127
+ def _try_skill_from_file(path: Path) -> Skill | None:
128
+ """Parse a markdown file into a Skill in a single read, or None if it isn't a skill.
129
+
130
+ A markdown file is a skill only if its frontmatter carries a ``name``; plain docs
131
+ (e.g. a contributor README) are ignored. Reads the file once — previously callers
132
+ read it twice (an ``_is_skill_file`` check followed by a separate parse)."""
135
133
  try:
136
- meta, _ = _split_frontmatter(path.read_text(encoding="utf-8"))
134
+ meta, body = _split_frontmatter(path.read_text(encoding="utf-8"))
137
135
  except OSError:
138
- return False
139
- return bool(meta.get("name"))
136
+ return None
137
+ if not meta.get("name"):
138
+ return None
139
+ return _skill_from_meta(path, meta, body)
140
140
 
141
141
 
142
142
  def discover_repo_skills(project_root: Path) -> list[Skill]:
@@ -154,16 +154,72 @@ def discover_repo_skills(project_root: Path) -> list[Skill]:
154
154
  candidates = sorted(base.rglob("SKILL.md")) + sorted(base.glob("*.md"))
155
155
  for path in candidates:
156
156
  resolved = path.resolve()
157
- if resolved in seen or not _is_skill_file(path):
157
+ if resolved in seen:
158
+ continue
159
+ skill = _try_skill_from_file(path)
160
+ if skill is None:
158
161
  continue
159
162
  seen.add(resolved)
160
- found.append(_skill_from_file(path))
163
+ found.append(skill)
161
164
  return found
162
165
 
163
166
 
164
- def load_skills(library_dir: Path = LIBRARY_DIR, project_root: Path | None = None) -> list[Skill]:
167
+ def load_okf_skills(project_root: Path, directory: str = ".devcouncil/knowledge") -> list[Skill]:
168
+ """Load skills from ingested OKF documents typed as engineering skills.
169
+
170
+ Reads every ``*.md`` under ``<project_root>/<directory>/okf/`` (recursively), parses
171
+ each as an :class:`~devcouncil.knowledge.okf.OKFDocument`, and keeps the ones whose
172
+ ``type`` marks them as a skill (the OKF->Skill conversion returns ``None`` for any
173
+ other node type — BigQuery tables, tasks, ...). This is how an OKF bundle ingested
174
+ from another repo contributes its skills to selection alongside the packaged library.
175
+
176
+ ``index.md`` files are skipped: a bundle index is navigation scaffolding, not a node.
177
+ The document's ``rel_path`` is set relative to the okf dir so its skill ``name`` derives
178
+ from the file stem (matching how the export side names ``skills/<name>.md``).
179
+ """
180
+ # Lazy import to avoid a registry <-> skill_bridge import cycle (see that module);
181
+ # OKFDocument is cycle-safe (knowledge.okf doesn't import skills) but kept here too
182
+ # to keep the OKF-ingest dependency local to the one function that uses it.
183
+ from devcouncil.knowledge.okf import OKFDocument
184
+ from devcouncil.knowledge.skill_bridge import okf_document_to_skill
185
+
186
+ okf_dir = project_root / directory / "okf"
187
+ if not okf_dir.exists():
188
+ return []
189
+ skills: list[Skill] = []
190
+ for path in sorted(okf_dir.rglob("*.md")):
191
+ if path.name == "index.md":
192
+ continue
193
+ rel = path.relative_to(okf_dir).as_posix()
194
+ doc = OKFDocument.from_markdown(path.read_text(encoding="utf-8"), rel_path=rel)
195
+ skill = okf_document_to_skill(doc)
196
+ if skill is not None:
197
+ skills.append(skill)
198
+ return skills
199
+
200
+
201
+ @functools.lru_cache(maxsize=32)
202
+ def load_skills(
203
+ library_dir: Path = LIBRARY_DIR,
204
+ project_root: Path | None = None,
205
+ include_okf: bool = True,
206
+ ) -> list[Skill]:
165
207
  """Load skills: the packaged library plus, when ``project_root`` is given, the
166
- repo's own skills. Repo-local skills override packaged ones with the same name.
208
+ repo's own skills and (when ``include_okf``) skills from ingested OKF documents.
209
+ Repo-local skills override packaged ones with the same name.
210
+
211
+ Result is cached per (library_dir, project_root, include_okf) for the lifetime of
212
+ the process, mirroring the repo-basename cache below: a ``dev e2e``/``repair-all``
213
+ run calls ``select_skills`` once per task, and re-reading+parsing the whole skill
214
+ tree (library glob, repo ``SKILL.md`` discovery, OKF markdown) every time is pure
215
+ waste since the on-disk skills don't change during a run. The returned list is
216
+ shared and must be treated read-only by callers (all current callers only iterate
217
+ it); ``clear_skill_caches()`` drops the cache when a refresh is needed.
218
+
219
+ OKF-derived skills are merged in last and only for names not already taken, so a
220
+ packaged library skill or a repo-local skill always wins a name conflict over an
221
+ ingested bundle node (the local definition is authoritative and carries richer
222
+ selection metadata like globs that OKF tags can't represent).
167
223
 
168
224
  Always-on skills come first, then alphabetical. Markdown files without skill
169
225
  frontmatter (e.g. a contributor README) are ignored.
@@ -171,8 +227,8 @@ def load_skills(library_dir: Path = LIBRARY_DIR, project_root: Path | None = Non
171
227
  by_name: dict[str, Skill] = {}
172
228
  if library_dir.exists():
173
229
  for path in sorted(library_dir.glob("*.md")):
174
- if _is_skill_file(path):
175
- skill = _skill_from_file(path)
230
+ skill = _try_skill_from_file(path)
231
+ if skill is not None:
176
232
  by_name[skill.name] = skill
177
233
  if project_root is not None:
178
234
  for skill in discover_repo_skills(project_root):
@@ -190,6 +246,21 @@ def load_skills(library_dir: Path = LIBRARY_DIR, project_root: Path | None = Non
190
246
  "triggers": skill.triggers if has_own_triggers else base.triggers,
191
247
  })
192
248
  by_name[skill.name] = skill # repo-local wins on name conflict
249
+ if include_okf:
250
+ # Honor a custom knowledge.directory: `dev okf ingest` and knowledge-source
251
+ # discovery both write/read under the configured dir, so the skill-ingest read
252
+ # path must too — otherwise ingested OKF skills land somewhere this never looks
253
+ # and silently never get selected. Best-effort; lazy import keeps app.config out
254
+ # of the skills package's module-load graph.
255
+ knowledge_dir = ".devcouncil/knowledge"
256
+ try:
257
+ from devcouncil.app.config import load_config
258
+ knowledge_dir = load_config(project_root).knowledge.directory
259
+ except Exception:
260
+ pass
261
+ for skill in load_okf_skills(project_root, directory=knowledge_dir):
262
+ # Only fill gaps: library + repo-local skills win on name conflict.
263
+ by_name.setdefault(skill.name, skill)
193
264
  skills = list(by_name.values())
194
265
  skills.sort(key=lambda s: (not s.always, s.name))
195
266
  return skills
@@ -211,8 +282,10 @@ _BASENAME_CACHE_MAX = 32
211
282
 
212
283
 
213
284
  def clear_skill_caches() -> None:
214
- """Drop the cached repo file scans (useful in long-running processes/tests)."""
285
+ """Drop the cached repo file scans and loaded-skill sets (useful in long-running
286
+ processes/tests). Fully resets module-level skill state so test isolation holds."""
215
287
  _basename_cache.clear()
288
+ load_skills.cache_clear()
216
289
 
217
290
 
218
291
  def _walk_repo_basenames(project_root: Path) -> set[str]:
@@ -261,11 +334,16 @@ def select_skills(
261
334
  """
262
335
  skills = load_skills(library_dir, project_root)
263
336
  repo_files = _collect_repo_basenames(project_root) if project_root else set()
264
- matched = [s for s in skills if s.matches(goal, repo_files)]
265
- # Rank by relevance so the most applicable domain skill survives the inline budget
266
- # on a polyglot repo, instead of whichever happened to load first / sort alphabetically.
267
- # always-on skills keep their leading position (highest score); ties break by name.
268
- scored = [(s, s.relevance_score(goal, repo_files)) for s in matched]
337
+ # Score each skill once and keep the ones that apply: for a Skill, matches() is exactly
338
+ # relevance_score() > 0 (always-on -> 1_000_000; otherwise a positive score requires a
339
+ # keyword/glob hit, which is what matches() tests), so a single pass replaces the old
340
+ # match-then-score double walk. (This equivalence is Skill-specific do NOT copy it to
341
+ # KnowledgeSource, whose nonzero priority floor breaks it.)
342
+ # Rank by relevance so the most applicable domain skill survives the inline budget on a
343
+ # polyglot repo; always-on skills keep their leading position; ties break by name.
344
+ scored = [
345
+ (s, score) for s in skills if (score := s.relevance_score(goal, repo_files)) > 0
346
+ ]
269
347
  scored.sort(key=lambda item: (not item[0].always, -item[1], item[0].name))
270
348
  return [skill for skill, _ in scored]
271
349
 
@@ -15,7 +15,37 @@ from devcouncil.storage.models import SchemaVersionModel
15
15
  # v4: machine-routable gap columns (file, line, suggested_command,
16
16
  # acceptance_criterion_id) so the repair contract survives a reload.
17
17
  # v5: partial unique index enforcing one ACTIVE lease per task (single-writer).
18
- SCHEMA_VERSION = 5
18
+ # v6: gap column expected_verification_method (repair loop tells an executor-remediable
19
+ # "incomplete" from a manual/llm one across a reload).
20
+ SCHEMA_VERSION = 6
21
+
22
+
23
+ # Module-level caches keyed by *resolved* paths so distinct project roots stay
24
+ # independent (important for test isolation, where every test uses a fresh
25
+ # tmp_path). `_db_instances` returns the same Database (and its single engine)
26
+ # for a given project root instead of rebuilding the engine + re-running the
27
+ # schema check on every get_db() call. `_dedup_done` records db paths whose
28
+ # active-lease dedup migration has already run this process, so the one-time
29
+ # table scan does not repeat on subsequent opens of the same database.
30
+ _db_instances: dict[Path, "Database"] = {}
31
+ _dedup_done: set[Path] = set()
32
+
33
+
34
+ def reset_db_cache() -> None:
35
+ """Drop all cached Database instances and per-path guards.
36
+
37
+ Disposes pooled engine connections so the underlying SQLite files can be
38
+ safely removed/recreated. Intended for tests (or long-lived processes) that
39
+ rebuild a project's .devcouncil database under a path already opened this
40
+ process; without this a cached engine could point at a stale/deleted file.
41
+ """
42
+ for db in _db_instances.values():
43
+ try:
44
+ db.engine.dispose()
45
+ except Exception:
46
+ pass
47
+ _db_instances.clear()
48
+ _dedup_done.clear()
19
49
 
20
50
 
21
51
  class Database:
@@ -62,8 +92,17 @@ class Database:
62
92
  # already has two ACTIVE leases. Older databases predate the constraint, so
63
93
  # collapse any duplicates first — keep the newest active lease per task, mark the
64
94
  # rest stale — making index creation succeed and restoring single-writer state.
95
+ #
96
+ # This only needs to run once per database per process: after the first pass the
97
+ # partial unique index (created immediately after, in _create_missing_indexes)
98
+ # prevents any new duplicate active leases, so re-scanning on every open is wasted
99
+ # work. Guard on the resolved path so distinct databases remain independent.
100
+ key = self.db_path.resolve()
101
+ if key in _dedup_done:
102
+ return
65
103
  inspector = inspect(self.engine)
66
104
  if "task_leases" not in inspector.get_table_names():
105
+ # Table not materialized yet; don't record as done so a later open retries.
67
106
  return
68
107
  with self.engine.begin() as conn:
69
108
  rows = conn.execute(
@@ -85,6 +124,7 @@ class Database:
85
124
  text("UPDATE task_leases SET status = 'stale', released_at = :ts WHERE id = :id"),
86
125
  {"ts": now, "id": lease_id},
87
126
  )
127
+ _dedup_done.add(key)
88
128
 
89
129
  def _create_missing_columns(self):
90
130
  # create_all never alters an existing table, so columns added to a model
@@ -139,9 +179,25 @@ class Database:
139
179
  def get_db(project_root: Path = Path(".")) -> Optional[Database]:
140
180
  dev_dir = project_root / ".devcouncil"
141
181
  if not dev_dir.exists():
182
+ # Checked before the cache so a not-yet-initialized (or removed) project
183
+ # never yields a stale cached Database.
142
184
  return None
143
-
185
+
186
+ key = project_root.resolve()
144
187
  db_path = dev_dir / "state.sqlite"
188
+ cached = _db_instances.get(key)
189
+ # Only reuse the cached instance while its underlying file still exists. If the
190
+ # .devcouncil dir was wiped and recreated under the same path within one process,
191
+ # the cached engine points at a deleted file — dispose its pool and rebuild fresh.
192
+ if cached is not None:
193
+ if db_path.exists():
194
+ return cached
195
+ try:
196
+ cached.engine.dispose()
197
+ except Exception:
198
+ pass
199
+
145
200
  db = Database(db_path)
146
201
  db.ensure_schema_version()
202
+ _db_instances[key] = db
147
203
  return db
@@ -69,6 +69,10 @@ class GapModel(SQLModel, table=True):
69
69
  line: Optional[int] = None
70
70
  suggested_command: Optional[str] = None
71
71
  acceptance_criterion_id: Optional[str] = None
72
+ # Verification method the criterion expects (unit_test/manual/llm_review/...). Persisted
73
+ # so the repair loop can tell an executor-remediable "incomplete" (an automatable check
74
+ # that did not run) from one a human must close (manual/llm_review) after a reload.
75
+ expected_verification_method: Optional[str] = None
72
76
 
73
77
  class CritiqueFindingModel(SQLModel, table=True):
74
78
  __tablename__ = "critique_findings"
@@ -142,16 +142,14 @@ class TaskLeaseRepository:
142
142
  ttl_seconds: Optional[int] = None,
143
143
  force: bool = False,
144
144
  ) -> TaskLeaseRecord:
145
- active = self.active_for_task(task_id)
145
+ active = self._active_model_for_task(task_id)
146
146
  if active is not None and not force:
147
147
  raise ValueError(f"Active lease already exists for task {task_id}")
148
148
  if active is not None and force:
149
- existing = self.session.get(TaskLeaseModel, active.id)
150
- if existing is not None:
151
- existing.status = "stale"
152
- existing.released_at = _utc_now()
153
- self.session.add(existing)
154
- self.session.commit()
149
+ active.status = "stale"
150
+ active.released_at = _utc_now()
151
+ self.session.add(active)
152
+ self.session.commit()
155
153
 
156
154
  lease_id = str(uuid.uuid4())
157
155
  token = secrets.token_urlsafe(32)
@@ -185,11 +183,8 @@ class TaskLeaseRepository:
185
183
  return _lease_from_model(model)
186
184
 
187
185
  def release(self, task_id: str, lease_token: str, status: str = "released") -> bool:
188
- active = self.active_for_task(task_id)
189
- if active is None or active.lease_token != lease_token:
190
- return False
191
- model = self.session.get(TaskLeaseModel, active.id)
192
- if model is None:
186
+ model = self._active_model_for_task(task_id)
187
+ if model is None or model.lease_token != lease_token:
193
188
  return False
194
189
  model.status = status
195
190
  model.released_at = _utc_now()
@@ -197,7 +192,11 @@ class TaskLeaseRepository:
197
192
  self.session.commit()
198
193
  return True
199
194
 
200
- def active_for_task(self, task_id: str) -> TaskLeaseRecord | None:
195
+ def _active_model_for_task(self, task_id: str) -> Optional[TaskLeaseModel]:
196
+ """Return the live ACTIVE lease *model* for a task (or None), lazily expiring a
197
+ lease that is past its TTL. Callers that need a Record wrap the result via
198
+ _lease_from_model — this avoids re-fetching the same row by primary key after a
199
+ Record-returning lookup."""
201
200
  statement = (
202
201
  select(TaskLeaseModel)
203
202
  .where(TaskLeaseModel.task_id == task_id)
@@ -216,6 +215,12 @@ class TaskLeaseRepository:
216
215
  self.session.add(model)
217
216
  self.session.commit()
218
217
  return None
218
+ return model
219
+
220
+ def active_for_task(self, task_id: str) -> TaskLeaseRecord | None:
221
+ model = self._active_model_for_task(task_id)
222
+ if model is None:
223
+ return None
219
224
  return _lease_from_model(model)
220
225
 
221
226
  def validate(self, task_id: str, lease_token: str) -> bool:
@@ -225,11 +230,8 @@ class TaskLeaseRepository:
225
230
  def renew(self, task_id: str, lease_token: str, ttl_seconds: int) -> TaskLeaseRecord | None:
226
231
  """Push the lease's expiry out by ``ttl_seconds`` from now. Returns the updated
227
232
  record, or None when the token is invalid / the lease already expired."""
228
- active = self.active_for_task(task_id)
229
- if active is None or active.lease_token != lease_token:
230
- return None
231
- model = self.session.get(TaskLeaseModel, active.id)
232
- if model is None:
233
+ model = self._active_model_for_task(task_id)
234
+ if model is None or model.lease_token != lease_token:
233
235
  return None
234
236
  model.expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat()
235
237
  self.session.add(model)
@@ -150,27 +150,43 @@ class GapRepository:
150
150
  def __init__(self, session: Session):
151
151
  self.session = session
152
152
 
153
+ @staticmethod
154
+ def _to_gap(m: GapModel) -> Gap:
155
+ return Gap.model_validate({
156
+ "id": m.id,
157
+ "severity": m.severity,
158
+ "gap_type": m.gap_type,
159
+ "requirement_id": m.requirement_id,
160
+ "task_id": m.task_id,
161
+ "description": m.description,
162
+ "evidence": json.loads(m.evidence_json),
163
+ "recommended_fix": m.recommended_fix,
164
+ "blocking": m.blocking,
165
+ "file": m.file,
166
+ "line": m.line,
167
+ "suggested_command": m.suggested_command,
168
+ "acceptance_criterion_id": m.acceptance_criterion_id,
169
+ "expected_verification_method": m.expected_verification_method,
170
+ })
171
+
153
172
  def get_all(self) -> List[Gap]:
154
173
  statement = select(GapModel)
155
174
  models = self.session.exec(statement).all()
156
- results = []
157
- for m in models:
158
- results.append(Gap.model_validate({
159
- "id": m.id,
160
- "severity": m.severity,
161
- "gap_type": m.gap_type,
162
- "requirement_id": m.requirement_id,
163
- "task_id": m.task_id,
164
- "description": m.description,
165
- "evidence": json.loads(m.evidence_json),
166
- "recommended_fix": m.recommended_fix,
167
- "blocking": m.blocking,
168
- "file": m.file,
169
- "line": m.line,
170
- "suggested_command": m.suggested_command,
171
- "acceptance_criterion_id": m.acceptance_criterion_id,
172
- }))
173
- return results
175
+ return [self._to_gap(m) for m in models]
176
+
177
+ def get_for_task(self, task_id: str) -> List[Gap]:
178
+ statement = select(GapModel).where(col(GapModel.task_id) == task_id)
179
+ models = self.session.exec(statement).all()
180
+ return [self._to_gap(m) for m in models]
181
+
182
+ def get_blocking_for_task(self, task_id: str) -> List[Gap]:
183
+ statement = (
184
+ select(GapModel)
185
+ .where(col(GapModel.task_id) == task_id)
186
+ .where(col(GapModel.blocking) == True) # noqa: E712 - SQL boolean comparison
187
+ )
188
+ models = self.session.exec(statement).all()
189
+ return [self._to_gap(m) for m in models]
174
190
 
175
191
  def save(self, gap: Gap):
176
192
  model = GapModel(
@@ -187,6 +203,7 @@ class GapRepository:
187
203
  line=gap.line,
188
204
  suggested_command=gap.suggested_command,
189
205
  acceptance_criterion_id=gap.acceptance_criterion_id,
206
+ expected_verification_method=gap.expected_verification_method,
190
207
  )
191
208
  self.session.merge(model)
192
209
  self.session.commit()