devcouncil 0.1.1 → 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 (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  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 +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,6 +1,9 @@
1
+ from collections import defaultdict
1
2
  from contextlib import contextmanager
3
+ from datetime import datetime, timezone
2
4
 
3
5
  from sqlmodel import SQLModel, create_engine, Session
6
+ from sqlalchemy import inspect, text
4
7
  from sqlalchemy.exc import OperationalError
5
8
  from pathlib import Path
6
9
  from typing import Optional
@@ -8,7 +11,41 @@ from typing import Optional
8
11
  from devcouncil.storage.models import SchemaVersionModel
9
12
 
10
13
 
11
- SCHEMA_VERSION = 1
14
+ # v3: per-task indexes on the task/audit tables (task_id, lease status).
15
+ # v4: machine-routable gap columns (file, line, suggested_command,
16
+ # acceptance_criterion_id) so the repair contract survives a reload.
17
+ # v5: partial unique index enforcing one ACTIVE lease per task (single-writer).
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()
12
49
 
13
50
 
14
51
  class Database:
@@ -28,11 +65,17 @@ class Database:
28
65
  session.add(SchemaVersionModel(id="singleton", version=SCHEMA_VERSION))
29
66
  session.commit()
30
67
  return
31
- if current.version != SCHEMA_VERSION:
68
+ if current.version > SCHEMA_VERSION:
32
69
  raise RuntimeError(
33
70
  f"Unsupported DevCouncil schema version {current.version}; "
34
71
  f"expected {SCHEMA_VERSION}."
35
72
  )
73
+ if current.version < SCHEMA_VERSION:
74
+ self._create_tables()
75
+ current.version = SCHEMA_VERSION
76
+ session.add(current)
77
+ session.commit()
78
+ return
36
79
 
37
80
  def _create_tables(self):
38
81
  try:
@@ -40,6 +83,84 @@ class Database:
40
83
  except OperationalError as exc:
41
84
  if "already exists" not in str(exc):
42
85
  raise
86
+ self._create_missing_columns()
87
+ self._dedup_active_leases()
88
+ self._create_missing_indexes()
89
+
90
+ def _dedup_active_leases(self):
91
+ # The partial unique index (ux_task_leases_active) can only be created if no task
92
+ # already has two ACTIVE leases. Older databases predate the constraint, so
93
+ # collapse any duplicates first — keep the newest active lease per task, mark the
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
103
+ inspector = inspect(self.engine)
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.
106
+ return
107
+ with self.engine.begin() as conn:
108
+ rows = conn.execute(
109
+ text("SELECT id, task_id, created_at FROM task_leases WHERE status = 'active'")
110
+ ).fetchall()
111
+ by_task = defaultdict(list)
112
+ for row in rows:
113
+ by_task[row.task_id].append(((row.created_at or ""), row.id))
114
+ stale_ids = []
115
+ for items in by_task.values():
116
+ if len(items) <= 1:
117
+ continue
118
+ items.sort() # ascending by (created_at, id); keep the last = newest
119
+ stale_ids.extend(item[1] for item in items[:-1])
120
+ if stale_ids:
121
+ now = datetime.now(timezone.utc).isoformat()
122
+ for lease_id in stale_ids:
123
+ conn.execute(
124
+ text("UPDATE task_leases SET status = 'stale', released_at = :ts WHERE id = :id"),
125
+ {"ts": now, "id": lease_id},
126
+ )
127
+ _dedup_done.add(key)
128
+
129
+ def _create_missing_columns(self):
130
+ # create_all never alters an existing table, so columns added to a model
131
+ # later (e.g. the v4 gap routing columns) are missing on databases created by
132
+ # an older version — and a SELECT of the model would then fail. Add any
133
+ # missing column as a nullable ADD COLUMN (the only shape SQLite can add in
134
+ # place without a table rewrite); non-nullable additions are intentionally
135
+ # skipped because existing rows could not satisfy them.
136
+ inspector = inspect(self.engine)
137
+ existing_tables = set(inspector.get_table_names())
138
+ for table in SQLModel.metadata.sorted_tables:
139
+ if table.name not in existing_tables:
140
+ continue
141
+ existing_cols = {col["name"] for col in inspector.get_columns(table.name)}
142
+ for column in table.columns:
143
+ if column.name in existing_cols or not column.nullable:
144
+ continue
145
+ ddl_type = column.type.compile(self.engine.dialect)
146
+ stmt = text(f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {ddl_type}')
147
+ try:
148
+ with self.engine.begin() as conn:
149
+ conn.execute(stmt)
150
+ except OperationalError as exc:
151
+ if "duplicate column name" not in str(exc).lower():
152
+ raise
153
+
154
+ def _create_missing_indexes(self):
155
+ # create_all skips tables that already exist, so indexes added to the
156
+ # model definitions later never materialize on existing databases.
157
+ for table in SQLModel.metadata.sorted_tables:
158
+ for index in table.indexes:
159
+ try:
160
+ index.create(self.engine, checkfirst=True)
161
+ except OperationalError as exc:
162
+ if "already exists" not in str(exc):
163
+ raise
43
164
 
44
165
  @contextmanager
45
166
  def get_session(self):
@@ -58,9 +179,25 @@ class Database:
58
179
  def get_db(project_root: Path = Path(".")) -> Optional[Database]:
59
180
  dev_dir = project_root / ".devcouncil"
60
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.
61
184
  return None
62
-
185
+
186
+ key = project_root.resolve()
63
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
+
64
200
  db = Database(db_path)
65
201
  db.ensure_schema_version()
202
+ _db_instances[key] = db
66
203
  return db
@@ -1,4 +1,5 @@
1
1
  from sqlmodel import SQLModel, Field
2
+ from sqlalchemy import Index, text
2
3
  from typing import Optional
3
4
 
4
5
 
@@ -61,6 +62,17 @@ class GapModel(SQLModel, table=True):
61
62
  evidence_json: str = Field(default="[]")
62
63
  recommended_fix: str
63
64
  blocking: bool
65
+ # Machine-routable repair hints. Persisted (v4) so the typed next-actions
66
+ # contract survives a reload — a reconnecting/crashed agent can read outstanding
67
+ # gaps without the heuristic reconstruction the loader used to fall back to.
68
+ file: Optional[str] = None
69
+ line: Optional[int] = None
70
+ suggested_command: Optional[str] = None
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
64
76
 
65
77
  class CritiqueFindingModel(SQLModel, table=True):
66
78
  __tablename__ = "critique_findings"
@@ -81,3 +93,116 @@ class ProjectStateModel(SQLModel, table=True):
81
93
  id: str = Field(primary_key=True, default="singleton")
82
94
  current_phase: str
83
95
  history_json: str = Field(default="[]")
96
+
97
+
98
+ class TaskLeaseModel(SQLModel, table=True):
99
+ __tablename__ = "task_leases"
100
+ # Enforce the single-writer guarantee at the DB level: at most one ACTIVE lease per
101
+ # task. A partial unique index turns a concurrent double-checkout race into an
102
+ # IntegrityError (surfaced as lease_conflict) instead of two live leases.
103
+ __table_args__ = (
104
+ Index(
105
+ "ux_task_leases_active",
106
+ "task_id",
107
+ unique=True,
108
+ sqlite_where=text("status = 'active'"),
109
+ ),
110
+ )
111
+ id: str = Field(primary_key=True)
112
+ task_id: str = Field(index=True)
113
+ owner: str
114
+ agent: Optional[str] = None
115
+ client_id: Optional[str] = None
116
+ run_id: Optional[str] = None
117
+ branch: Optional[str] = None
118
+ lease_token: str
119
+ status: str = Field(default="active", index=True)
120
+ created_at: str
121
+ expires_at: Optional[str] = None
122
+ released_at: Optional[str] = None
123
+
124
+
125
+ class ShellSessionModel(SQLModel, table=True):
126
+ __tablename__ = "shell_sessions"
127
+ id: str = Field(primary_key=True)
128
+ task_id: str = Field(index=True)
129
+ lease_id: Optional[str] = None
130
+ shell: str
131
+ cwd: str
132
+ status: str
133
+ started_at: str
134
+ ended_at: Optional[str] = None
135
+
136
+
137
+ class ShellCommandEventModel(SQLModel, table=True):
138
+ __tablename__ = "shell_command_events"
139
+ id: Optional[int] = Field(default=None, primary_key=True)
140
+ task_id: str = Field(index=True)
141
+ session_id: Optional[str] = None
142
+ command: str
143
+ status: str
144
+ exit_code: Optional[int] = None
145
+ reason: str = ""
146
+ stdout_path: str = ""
147
+ stderr_path: str = ""
148
+ created_at: str
149
+
150
+
151
+ class FileChangeEventModel(SQLModel, table=True):
152
+ __tablename__ = "file_change_events"
153
+ id: Optional[int] = Field(default=None, primary_key=True)
154
+ task_id: Optional[str] = Field(default=None, index=True)
155
+ lease_id: Optional[str] = None
156
+ session_id: Optional[str] = None
157
+ path: str
158
+ operation: str
159
+ allowed: bool
160
+ reason: str = ""
161
+ created_at: str
162
+
163
+
164
+ class SemanticDiffModel(SQLModel, table=True):
165
+ __tablename__ = "semantic_diffs"
166
+ id: Optional[int] = Field(default=None, primary_key=True)
167
+ task_id: str = Field(index=True)
168
+ before_snapshot_path: str
169
+ after_snapshot_path: str
170
+ classifications_json: str = "[]"
171
+ summary: str = ""
172
+ created_at: str
173
+
174
+
175
+ class AgentHandoffModel(SQLModel, table=True):
176
+ __tablename__ = "agent_handoffs"
177
+ id: str = Field(primary_key=True)
178
+ task_id: str = Field(index=True)
179
+ from_agent: str
180
+ to_agent: str
181
+ run_id: str
182
+ manifest_path: str
183
+ status: str
184
+ created_at: str
185
+
186
+
187
+ class CorrectionManifestModel(SQLModel, table=True):
188
+ __tablename__ = "correction_manifests"
189
+ id: str = Field(primary_key=True)
190
+ task_id: str = Field(index=True)
191
+ run_id: Optional[str] = None
192
+ manifest_path: str
193
+ retry_budget: int = 3
194
+ attempt: int = 0
195
+ status: str
196
+ created_at: str
197
+
198
+
199
+ class VerificationRunModel(SQLModel, table=True):
200
+ __tablename__ = "verification_runs"
201
+ id: str = Field(primary_key=True)
202
+ task_id: str = Field(index=True)
203
+ sandbox: str
204
+ environment_json: str = "{}"
205
+ commands_json: str = "[]"
206
+ status: str
207
+ started_at: str
208
+ finished_at: Optional[str] = None