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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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
|
-
|
|
95
|
+
return build_frontmatter_markdown(
|
|
94
96
|
{"name": self.name, "description": self.description},
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
134
|
-
"""
|
|
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,
|
|
134
|
+
meta, body = _split_frontmatter(path.read_text(encoding="utf-8"))
|
|
137
135
|
except OSError:
|
|
138
|
-
return
|
|
139
|
-
|
|
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
|
|
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(
|
|
163
|
+
found.append(skill)
|
|
161
164
|
return found
|
|
162
165
|
|
|
163
166
|
|
|
164
|
-
def
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
189
|
-
if
|
|
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
|
|
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
|
-
|
|
229
|
-
if
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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()
|