caudate-cli 0.1.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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
planning/forge_models.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"""Forge — autonomous coding-harness data model.
|
|
2
|
+
|
|
3
|
+
Ports the LocalForge schema (Drizzle/SQLite) onto Cognos's existing
|
|
4
|
+
SQLAlchemy infrastructure. Tables live in `data/cognos.db` alongside
|
|
5
|
+
the existing `strategies` and `performance_log` tables — no new DB,
|
|
6
|
+
no new migration tool, idempotent `create_all` on import.
|
|
7
|
+
|
|
8
|
+
Tables:
|
|
9
|
+
forge_projects one row per project (= sandbox/projects/<slug>)
|
|
10
|
+
forge_features backlog cards: title, description, AC, status, priority
|
|
11
|
+
forge_feature_deps DAG edges (feature → depends-on-feature)
|
|
12
|
+
forge_sessions one row per agent run (coding | bootstrapper)
|
|
13
|
+
forge_logs streamed agent log lines (info|action|error|test|screenshot)
|
|
14
|
+
forge_chat_messages bootstrapper conversation history
|
|
15
|
+
forge_settings per-project key/value overrides (4th settings layer)
|
|
16
|
+
|
|
17
|
+
Lifecycle of a feature:
|
|
18
|
+
backlog -> in_progress -> completed (success path)
|
|
19
|
+
backlog -> in_progress -> backlog (failure: priority demoted by 1)
|
|
20
|
+
|
|
21
|
+
Session statuses mirror LocalForge:
|
|
22
|
+
in_progress -> completed | failed | terminated
|
|
23
|
+
|
|
24
|
+
This module is pure data + simple CRUD — no LLM calls, no subagent
|
|
25
|
+
spawning. The orchestrator (planning/orchestrator.py) drives state
|
|
26
|
+
transitions; the bootstrapper (planning/planner.py extension) populates
|
|
27
|
+
features; the kanban UI / CLI just renders these tables.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
from contextlib import contextmanager
|
|
35
|
+
from datetime import datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Iterator
|
|
38
|
+
|
|
39
|
+
import sqlalchemy as sa
|
|
40
|
+
from sqlalchemy import (
|
|
41
|
+
Column,
|
|
42
|
+
DateTime,
|
|
43
|
+
ForeignKey,
|
|
44
|
+
Index,
|
|
45
|
+
Integer,
|
|
46
|
+
String,
|
|
47
|
+
Text,
|
|
48
|
+
UniqueConstraint,
|
|
49
|
+
create_engine,
|
|
50
|
+
)
|
|
51
|
+
from sqlalchemy.orm import Session, declarative_base, relationship
|
|
52
|
+
|
|
53
|
+
from config import SQLITE_PATH
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Forge has its own declarative base so its tables don't get tangled
|
|
58
|
+
# with `memory/procedural.py`'s base — but they share the same SQLite
|
|
59
|
+
# file, so a single `create_all` per file is enough.
|
|
60
|
+
Base = declarative_base()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ──────────────────────────── Status enums ─────────────────────────────
|
|
64
|
+
# Stored as plain TEXT to mirror LocalForge's Drizzle schema and stay
|
|
65
|
+
# query-friendly from the sqlite3 CLI. Constants exposed for callers
|
|
66
|
+
# instead of an Enum class so the validator doubles as documentation.
|
|
67
|
+
|
|
68
|
+
PROJECT_STATUSES = ("active", "completed", "archived")
|
|
69
|
+
FEATURE_STATUSES = ("backlog", "in_progress", "completed")
|
|
70
|
+
FEATURE_CATEGORIES = ("functional", "style")
|
|
71
|
+
SESSION_TYPES = ("coding", "bootstrapper")
|
|
72
|
+
SESSION_STATUSES = ("in_progress", "completed", "failed", "terminated")
|
|
73
|
+
MESSAGE_TYPES = ("info", "action", "error", "screenshot", "test_result")
|
|
74
|
+
CHAT_ROLES = ("user", "assistant")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _now() -> datetime:
|
|
78
|
+
"""UTC clock — kept as a function so tests can monkey-patch."""
|
|
79
|
+
return datetime.utcnow()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ──────────────────────────────── Tables ──────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ForgeProject(Base):
|
|
86
|
+
"""A buildable project. Maps 1:1 to a folder under sandbox/projects/."""
|
|
87
|
+
|
|
88
|
+
__tablename__ = "forge_projects"
|
|
89
|
+
|
|
90
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
91
|
+
name = Column(String(200), nullable=False)
|
|
92
|
+
description = Column(Text, nullable=True)
|
|
93
|
+
folder_path = Column(String, nullable=False, unique=True)
|
|
94
|
+
status = Column(String(20), nullable=False, default="active")
|
|
95
|
+
created_at = Column(DateTime, nullable=False, default=_now)
|
|
96
|
+
updated_at = Column(DateTime, nullable=False, default=_now, onupdate=_now)
|
|
97
|
+
|
|
98
|
+
features = relationship(
|
|
99
|
+
"ForgeFeature",
|
|
100
|
+
back_populates="project",
|
|
101
|
+
cascade="all, delete-orphan",
|
|
102
|
+
)
|
|
103
|
+
sessions = relationship(
|
|
104
|
+
"ForgeSession",
|
|
105
|
+
back_populates="project",
|
|
106
|
+
cascade="all, delete-orphan",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
110
|
+
return f"<ForgeProject {self.id} {self.name!r}>"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ForgeFeature(Base):
|
|
114
|
+
"""A unit of work the agent will implement. Cards on the kanban."""
|
|
115
|
+
|
|
116
|
+
__tablename__ = "forge_features"
|
|
117
|
+
|
|
118
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
119
|
+
project_id = Column(
|
|
120
|
+
Integer,
|
|
121
|
+
ForeignKey("forge_projects.id", ondelete="CASCADE"),
|
|
122
|
+
nullable=False,
|
|
123
|
+
)
|
|
124
|
+
title = Column(String(200), nullable=False)
|
|
125
|
+
description = Column(Text, nullable=True)
|
|
126
|
+
acceptance_criteria = Column(Text, nullable=True)
|
|
127
|
+
status = Column(String(20), nullable=False, default="backlog")
|
|
128
|
+
priority = Column(Integer, nullable=False, default=0)
|
|
129
|
+
category = Column(String(20), nullable=False, default="functional")
|
|
130
|
+
# Optional shell command that must exit 0 to count this feature as
|
|
131
|
+
# passing post-implementation. Bootstrapper suggests one per stack
|
|
132
|
+
# (pytest / playwright / tsc / cargo test / etc).
|
|
133
|
+
verify_command = Column(Text, nullable=True)
|
|
134
|
+
# ADR 0003 — how many times the bootstrapper has been asked to
|
|
135
|
+
# revise this feature. The cap is 1: a feature parked at the
|
|
136
|
+
# 3-failure mark gets exactly one re-decompose attempt before
|
|
137
|
+
# being marked `failed` permanently.
|
|
138
|
+
revision_count = Column(Integer, nullable=False, default=0)
|
|
139
|
+
created_at = Column(DateTime, nullable=False, default=_now)
|
|
140
|
+
updated_at = Column(DateTime, nullable=False, default=_now, onupdate=_now)
|
|
141
|
+
|
|
142
|
+
project = relationship("ForgeProject", back_populates="features")
|
|
143
|
+
# Outgoing edges — features this one depends on.
|
|
144
|
+
outgoing_deps = relationship(
|
|
145
|
+
"ForgeFeatureDep",
|
|
146
|
+
foreign_keys="ForgeFeatureDep.feature_id",
|
|
147
|
+
back_populates="feature",
|
|
148
|
+
cascade="all, delete-orphan",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
__table_args__ = (
|
|
152
|
+
Index("ix_forge_features_project", "project_id"),
|
|
153
|
+
Index("ix_forge_features_status", "status"),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
157
|
+
return f"<ForgeFeature {self.id} {self.title!r} {self.status}>"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ForgeFeatureDep(Base):
|
|
161
|
+
"""DAG edge: feature_id depends on depends_on_feature_id."""
|
|
162
|
+
|
|
163
|
+
__tablename__ = "forge_feature_deps"
|
|
164
|
+
|
|
165
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
166
|
+
feature_id = Column(
|
|
167
|
+
Integer,
|
|
168
|
+
ForeignKey("forge_features.id", ondelete="CASCADE"),
|
|
169
|
+
nullable=False,
|
|
170
|
+
)
|
|
171
|
+
depends_on_feature_id = Column(
|
|
172
|
+
Integer,
|
|
173
|
+
ForeignKey("forge_features.id", ondelete="CASCADE"),
|
|
174
|
+
nullable=False,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
feature = relationship(
|
|
178
|
+
"ForgeFeature",
|
|
179
|
+
foreign_keys=[feature_id],
|
|
180
|
+
back_populates="outgoing_deps",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
__table_args__ = (
|
|
184
|
+
UniqueConstraint("feature_id", "depends_on_feature_id", name="uq_forge_dep"),
|
|
185
|
+
Index("ix_forge_deps_feature", "feature_id"),
|
|
186
|
+
Index("ix_forge_deps_dependson", "depends_on_feature_id"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ForgeSession(Base):
|
|
191
|
+
"""One agent run. Bootstrapper sessions produce features; coding
|
|
192
|
+
sessions implement them."""
|
|
193
|
+
|
|
194
|
+
__tablename__ = "forge_sessions"
|
|
195
|
+
|
|
196
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
197
|
+
project_id = Column(
|
|
198
|
+
Integer,
|
|
199
|
+
ForeignKey("forge_projects.id", ondelete="CASCADE"),
|
|
200
|
+
nullable=False,
|
|
201
|
+
)
|
|
202
|
+
feature_id = Column(
|
|
203
|
+
Integer,
|
|
204
|
+
ForeignKey("forge_features.id", ondelete="SET NULL"),
|
|
205
|
+
nullable=True,
|
|
206
|
+
)
|
|
207
|
+
session_type = Column(String(20), nullable=False) # coding | bootstrapper
|
|
208
|
+
status = Column(String(20), nullable=False, default="in_progress")
|
|
209
|
+
started_at = Column(DateTime, nullable=False, default=_now)
|
|
210
|
+
ended_at = Column(DateTime, nullable=True)
|
|
211
|
+
|
|
212
|
+
project = relationship("ForgeProject", back_populates="sessions")
|
|
213
|
+
logs = relationship(
|
|
214
|
+
"ForgeLog",
|
|
215
|
+
back_populates="session",
|
|
216
|
+
cascade="all, delete-orphan",
|
|
217
|
+
)
|
|
218
|
+
messages = relationship(
|
|
219
|
+
"ForgeChatMessage",
|
|
220
|
+
back_populates="session",
|
|
221
|
+
cascade="all, delete-orphan",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
__table_args__ = (
|
|
225
|
+
Index("ix_forge_sessions_project", "project_id"),
|
|
226
|
+
Index("ix_forge_sessions_status", "status"),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ForgeLog(Base):
|
|
231
|
+
"""Streamed agent log line. Mirrors LocalForge's `agent_logs`."""
|
|
232
|
+
|
|
233
|
+
__tablename__ = "forge_logs"
|
|
234
|
+
|
|
235
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
236
|
+
session_id = Column(
|
|
237
|
+
Integer,
|
|
238
|
+
ForeignKey("forge_sessions.id", ondelete="CASCADE"),
|
|
239
|
+
nullable=False,
|
|
240
|
+
)
|
|
241
|
+
feature_id = Column(
|
|
242
|
+
Integer,
|
|
243
|
+
ForeignKey("forge_features.id", ondelete="SET NULL"),
|
|
244
|
+
nullable=True,
|
|
245
|
+
)
|
|
246
|
+
message = Column(Text, nullable=False)
|
|
247
|
+
message_type = Column(String(20), nullable=False, default="info")
|
|
248
|
+
screenshot_path = Column(String, nullable=True)
|
|
249
|
+
created_at = Column(DateTime, nullable=False, default=_now)
|
|
250
|
+
|
|
251
|
+
session = relationship("ForgeSession", back_populates="logs")
|
|
252
|
+
|
|
253
|
+
__table_args__ = (
|
|
254
|
+
Index("ix_forge_logs_session", "session_id"),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class ForgeChatMessage(Base):
|
|
259
|
+
"""Bootstrapper conversation history."""
|
|
260
|
+
|
|
261
|
+
__tablename__ = "forge_chat_messages"
|
|
262
|
+
|
|
263
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
264
|
+
session_id = Column(
|
|
265
|
+
Integer,
|
|
266
|
+
ForeignKey("forge_sessions.id", ondelete="CASCADE"),
|
|
267
|
+
nullable=False,
|
|
268
|
+
)
|
|
269
|
+
role = Column(String(20), nullable=False)
|
|
270
|
+
content = Column(Text, nullable=False)
|
|
271
|
+
created_at = Column(DateTime, nullable=False, default=_now)
|
|
272
|
+
|
|
273
|
+
session = relationship("ForgeSession", back_populates="messages")
|
|
274
|
+
|
|
275
|
+
__table_args__ = (
|
|
276
|
+
Index("ix_forge_chat_session", "session_id"),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class ForgeSetting(Base):
|
|
281
|
+
"""Per-project key/value override. Sits above ./.cognos/settings.json
|
|
282
|
+
in the merge order, below CLI flags. Used for max_concurrent_agents,
|
|
283
|
+
coder_prompt, dev_server_port, playwright_enabled, etc."""
|
|
284
|
+
|
|
285
|
+
__tablename__ = "forge_settings"
|
|
286
|
+
|
|
287
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
288
|
+
project_id = Column(
|
|
289
|
+
Integer,
|
|
290
|
+
ForeignKey("forge_projects.id", ondelete="CASCADE"),
|
|
291
|
+
nullable=True,
|
|
292
|
+
) # NULL means "global forge default"
|
|
293
|
+
key = Column(String(100), nullable=False)
|
|
294
|
+
value = Column(Text, nullable=False)
|
|
295
|
+
|
|
296
|
+
__table_args__ = (
|
|
297
|
+
UniqueConstraint("project_id", "key", name="uq_forge_setting"),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ─────────────────────────── Engine + sessions ─────────────────────────
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
_engine: sa.Engine | None = None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def get_engine() -> sa.Engine:
|
|
308
|
+
"""Lazy-build the SQLAlchemy engine on first call. Reuses the same
|
|
309
|
+
SQLite file as the rest of Cognos."""
|
|
310
|
+
global _engine
|
|
311
|
+
if _engine is None:
|
|
312
|
+
# `check_same_thread=False` — Cognos's HTTP server runs requests
|
|
313
|
+
# on uvicorn worker threads; we need the connection to be
|
|
314
|
+
# shareable across threads. SQLite serialises writes internally.
|
|
315
|
+
_engine = create_engine(
|
|
316
|
+
f"sqlite:///{SQLITE_PATH}",
|
|
317
|
+
connect_args={"check_same_thread": False},
|
|
318
|
+
future=True,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# SQLite ignores foreign key declarations by default. Without
|
|
322
|
+
# this, deleting a project would orphan its features. Set the
|
|
323
|
+
# PRAGMA on every fresh connection so cascade rules actually
|
|
324
|
+
# fire.
|
|
325
|
+
@sa.event.listens_for(_engine, "connect")
|
|
326
|
+
def _fk_on(dbapi_conn, _conn_record): # pragma: no cover
|
|
327
|
+
cur = dbapi_conn.cursor()
|
|
328
|
+
cur.execute("PRAGMA foreign_keys=ON")
|
|
329
|
+
cur.close()
|
|
330
|
+
|
|
331
|
+
Base.metadata.create_all(_engine)
|
|
332
|
+
|
|
333
|
+
# Runtime migrations for existing DBs — SQLAlchemy create_all
|
|
334
|
+
# is no-op on existing tables, so we manually ALTER for any
|
|
335
|
+
# columns we've added after first deploy. Idempotent.
|
|
336
|
+
_migrate_forge_schema(_engine)
|
|
337
|
+
|
|
338
|
+
logger.info(
|
|
339
|
+
"forge tables ready in %s (forge_projects, forge_features, "
|
|
340
|
+
"forge_feature_deps, forge_sessions, forge_logs, "
|
|
341
|
+
"forge_chat_messages, forge_settings)",
|
|
342
|
+
SQLITE_PATH,
|
|
343
|
+
)
|
|
344
|
+
return _engine
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _migrate_forge_schema(engine: sa.Engine) -> None:
|
|
348
|
+
"""Add columns SQLAlchemy create_all won't add to existing tables.
|
|
349
|
+
|
|
350
|
+
Each migration is idempotent — checks PRAGMA table_info first
|
|
351
|
+
before issuing ALTER. Safe to run on every startup."""
|
|
352
|
+
with engine.connect() as conn:
|
|
353
|
+
rows = conn.execute(sa.text("PRAGMA table_info(forge_features)")).fetchall()
|
|
354
|
+
cols = {r[1] for r in rows} # name is column index 1
|
|
355
|
+
if "revision_count" not in cols:
|
|
356
|
+
conn.execute(sa.text(
|
|
357
|
+
"ALTER TABLE forge_features ADD COLUMN "
|
|
358
|
+
"revision_count INTEGER NOT NULL DEFAULT 0"
|
|
359
|
+
))
|
|
360
|
+
conn.commit()
|
|
361
|
+
logger.info("forge: migrated forge_features +revision_count")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@contextmanager
|
|
365
|
+
def session_scope() -> Iterator[Session]:
|
|
366
|
+
"""Yield a SQLAlchemy session, commit on exit, rollback on error.
|
|
367
|
+
Standard unit-of-work pattern — callers should not commit inside."""
|
|
368
|
+
sess = Session(get_engine(), future=True)
|
|
369
|
+
try:
|
|
370
|
+
yield sess
|
|
371
|
+
sess.commit()
|
|
372
|
+
except Exception:
|
|
373
|
+
sess.rollback()
|
|
374
|
+
raise
|
|
375
|
+
finally:
|
|
376
|
+
sess.close()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def init() -> None:
|
|
380
|
+
"""Force-create the tables. Useful as a CLI smoke test:
|
|
381
|
+
python -c "from planning.forge_models import init; init()"
|
|
382
|
+
Idempotent — calling twice is a no-op (CREATE IF NOT EXISTS semantics
|
|
383
|
+
via SQLAlchemy's `create_all`)."""
|
|
384
|
+
get_engine()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ──────────────────────── Convenience CRUD helpers ──────────────────────
|
|
388
|
+
# These are deliberately small. The orchestrator and CLI compose them.
|
|
389
|
+
# Anything more complex (joins, stats, kanban projection) lives in the
|
|
390
|
+
# orchestrator module.
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def create_project(
|
|
394
|
+
name: str,
|
|
395
|
+
description: str | None,
|
|
396
|
+
folder_path: str,
|
|
397
|
+
) -> int:
|
|
398
|
+
"""Create a project row. Returns the new id."""
|
|
399
|
+
folder_path = str(Path(folder_path).resolve())
|
|
400
|
+
with session_scope() as sess:
|
|
401
|
+
proj = ForgeProject(
|
|
402
|
+
name=name,
|
|
403
|
+
description=description,
|
|
404
|
+
folder_path=folder_path,
|
|
405
|
+
status="active",
|
|
406
|
+
)
|
|
407
|
+
sess.add(proj)
|
|
408
|
+
sess.flush()
|
|
409
|
+
return proj.id
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def list_projects() -> list[dict[str, Any]]:
|
|
413
|
+
"""Return a list of project rows as plain dicts (for JSON / CLI)."""
|
|
414
|
+
with session_scope() as sess:
|
|
415
|
+
rows = sess.query(ForgeProject).order_by(ForgeProject.id).all()
|
|
416
|
+
return [_project_row_to_dict(r) for r in rows]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def get_project(project_id: int) -> dict[str, Any] | None:
|
|
420
|
+
with session_scope() as sess:
|
|
421
|
+
row = sess.get(ForgeProject, project_id)
|
|
422
|
+
return _project_row_to_dict(row) if row else None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def create_feature(
|
|
426
|
+
project_id: int,
|
|
427
|
+
title: str,
|
|
428
|
+
description: str | None = None,
|
|
429
|
+
acceptance_criteria: str | None = None,
|
|
430
|
+
priority: int = 0,
|
|
431
|
+
category: str = "functional",
|
|
432
|
+
verify_command: str | None = None,
|
|
433
|
+
) -> int:
|
|
434
|
+
"""Insert a feature in the backlog. Returns the new id."""
|
|
435
|
+
if category not in FEATURE_CATEGORIES:
|
|
436
|
+
raise ValueError(f"invalid category {category!r}")
|
|
437
|
+
with session_scope() as sess:
|
|
438
|
+
feat = ForgeFeature(
|
|
439
|
+
project_id=project_id,
|
|
440
|
+
title=title,
|
|
441
|
+
description=description,
|
|
442
|
+
acceptance_criteria=acceptance_criteria,
|
|
443
|
+
status="backlog",
|
|
444
|
+
priority=priority,
|
|
445
|
+
category=category,
|
|
446
|
+
verify_command=verify_command,
|
|
447
|
+
)
|
|
448
|
+
sess.add(feat)
|
|
449
|
+
sess.flush()
|
|
450
|
+
return feat.id
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def add_dependency(feature_id: int, depends_on_feature_id: int) -> None:
|
|
454
|
+
"""Wire feature_id → depends_on_feature_id. No-op if the edge
|
|
455
|
+
already exists (UNIQUE constraint catches the duplicate). Raises
|
|
456
|
+
ValueError if the new edge would create a cycle."""
|
|
457
|
+
if feature_id == depends_on_feature_id:
|
|
458
|
+
raise ValueError("a feature cannot depend on itself")
|
|
459
|
+
if _would_form_cycle(feature_id, depends_on_feature_id):
|
|
460
|
+
raise ValueError(
|
|
461
|
+
f"adding {feature_id} → {depends_on_feature_id} would create a cycle"
|
|
462
|
+
)
|
|
463
|
+
with session_scope() as sess:
|
|
464
|
+
existing = (
|
|
465
|
+
sess.query(ForgeFeatureDep)
|
|
466
|
+
.filter_by(feature_id=feature_id, depends_on_feature_id=depends_on_feature_id)
|
|
467
|
+
.first()
|
|
468
|
+
)
|
|
469
|
+
if existing:
|
|
470
|
+
return
|
|
471
|
+
sess.add(
|
|
472
|
+
ForgeFeatureDep(
|
|
473
|
+
feature_id=feature_id,
|
|
474
|
+
depends_on_feature_id=depends_on_feature_id,
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def list_features(project_id: int) -> list[dict[str, Any]]:
|
|
480
|
+
"""All features for a project, ordered by (priority asc, id asc).
|
|
481
|
+
Each row carries a `depends_on` list of feature ids."""
|
|
482
|
+
with session_scope() as sess:
|
|
483
|
+
rows = (
|
|
484
|
+
sess.query(ForgeFeature)
|
|
485
|
+
.filter_by(project_id=project_id)
|
|
486
|
+
.order_by(ForgeFeature.priority.asc(), ForgeFeature.id.asc())
|
|
487
|
+
.all()
|
|
488
|
+
)
|
|
489
|
+
out = []
|
|
490
|
+
for r in rows:
|
|
491
|
+
deps = [d.depends_on_feature_id for d in r.outgoing_deps]
|
|
492
|
+
out.append(_feature_row_to_dict(r, deps))
|
|
493
|
+
return out
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def get_feature(feature_id: int) -> dict[str, Any] | None:
|
|
497
|
+
with session_scope() as sess:
|
|
498
|
+
row = sess.get(ForgeFeature, feature_id)
|
|
499
|
+
if row is None:
|
|
500
|
+
return None
|
|
501
|
+
deps = [d.depends_on_feature_id for d in row.outgoing_deps]
|
|
502
|
+
return _feature_row_to_dict(row, deps)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def update_feature(feature_id: int, **fields: Any) -> dict[str, Any] | None:
|
|
506
|
+
"""Patch arbitrary feature columns. Validates status / category.
|
|
507
|
+
|
|
508
|
+
Side effect: if this update flipped a feature to ``completed`` and
|
|
509
|
+
every other feature for the project is already done, the project
|
|
510
|
+
is auto-promoted to ``completed`` so the celebration overlay can
|
|
511
|
+
fire. Mirrors LocalForge's ``markProjectCompletedIfAllDone``."""
|
|
512
|
+
if "status" in fields and fields["status"] not in FEATURE_STATUSES:
|
|
513
|
+
raise ValueError(f"invalid status {fields['status']!r}")
|
|
514
|
+
if "category" in fields and fields["category"] not in FEATURE_CATEGORIES:
|
|
515
|
+
raise ValueError(f"invalid category {fields['category']!r}")
|
|
516
|
+
project_id_for_promote: int | None = None
|
|
517
|
+
with session_scope() as sess:
|
|
518
|
+
row = sess.get(ForgeFeature, feature_id)
|
|
519
|
+
if row is None:
|
|
520
|
+
return None
|
|
521
|
+
for k, v in fields.items():
|
|
522
|
+
if v is None and k in ("title",):
|
|
523
|
+
continue # don't blank required fields
|
|
524
|
+
setattr(row, k, v)
|
|
525
|
+
sess.flush()
|
|
526
|
+
deps = [d.depends_on_feature_id for d in row.outgoing_deps]
|
|
527
|
+
result = _feature_row_to_dict(row, deps)
|
|
528
|
+
# Capture project_id while still in session; promotion runs outside.
|
|
529
|
+
if fields.get("status") == "completed":
|
|
530
|
+
project_id_for_promote = row.project_id
|
|
531
|
+
if project_id_for_promote is not None:
|
|
532
|
+
mark_project_completed_if_all_done(project_id_for_promote)
|
|
533
|
+
return result
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def delete_feature(feature_id: int) -> bool:
|
|
537
|
+
with session_scope() as sess:
|
|
538
|
+
row = sess.get(ForgeFeature, feature_id)
|
|
539
|
+
if row is None:
|
|
540
|
+
return False
|
|
541
|
+
pid = row.project_id
|
|
542
|
+
sess.delete(row)
|
|
543
|
+
# If the deletion left the project with all-done features, promote.
|
|
544
|
+
mark_project_completed_if_all_done(pid)
|
|
545
|
+
return True
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def mark_project_completed_if_all_done(project_id: int) -> bool:
|
|
549
|
+
"""Promote project.status → 'completed' iff it has ≥ 1 feature and
|
|
550
|
+
every feature is 'completed'. Returns True if a promotion happened.
|
|
551
|
+
Idempotent — a no-op if the status is already 'completed' or there
|
|
552
|
+
are still incomplete features."""
|
|
553
|
+
with session_scope() as sess:
|
|
554
|
+
proj = sess.get(ForgeProject, project_id)
|
|
555
|
+
if proj is None or proj.status == "completed":
|
|
556
|
+
return False
|
|
557
|
+
feats = (
|
|
558
|
+
sess.query(ForgeFeature)
|
|
559
|
+
.filter_by(project_id=project_id)
|
|
560
|
+
.all()
|
|
561
|
+
)
|
|
562
|
+
if not feats:
|
|
563
|
+
return False
|
|
564
|
+
if any(f.status != "completed" for f in feats):
|
|
565
|
+
return False
|
|
566
|
+
proj.status = "completed"
|
|
567
|
+
return True
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def remove_dependency(feature_id: int, depends_on_feature_id: int) -> bool:
|
|
571
|
+
with session_scope() as sess:
|
|
572
|
+
row = (
|
|
573
|
+
sess.query(ForgeFeatureDep)
|
|
574
|
+
.filter_by(
|
|
575
|
+
feature_id=feature_id,
|
|
576
|
+
depends_on_feature_id=depends_on_feature_id,
|
|
577
|
+
)
|
|
578
|
+
.first()
|
|
579
|
+
)
|
|
580
|
+
if row is None:
|
|
581
|
+
return False
|
|
582
|
+
sess.delete(row)
|
|
583
|
+
return True
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def set_dependencies(
|
|
587
|
+
feature_id: int, depends_on: list[int],
|
|
588
|
+
) -> list[dict[str, Any]]:
|
|
589
|
+
"""Bulk-replace deps for a feature; returns the prerequisite features.
|
|
590
|
+
Rejects the entire request if any single edge would form a cycle —
|
|
591
|
+
we check edge-by-edge against the in-progress state so partial
|
|
592
|
+
application can't leave the graph cyclic on rollback."""
|
|
593
|
+
with session_scope() as sess:
|
|
594
|
+
if sess.get(ForgeFeature, feature_id) is None:
|
|
595
|
+
raise ValueError(f"feature {feature_id} not found")
|
|
596
|
+
# Drop existing deps
|
|
597
|
+
sess.query(ForgeFeatureDep).filter_by(feature_id=feature_id).delete()
|
|
598
|
+
sess.flush()
|
|
599
|
+
out: list[dict[str, Any]] = []
|
|
600
|
+
added: list[int] = []
|
|
601
|
+
for did in depends_on:
|
|
602
|
+
if did == feature_id:
|
|
603
|
+
continue
|
|
604
|
+
prereq = sess.get(ForgeFeature, did)
|
|
605
|
+
if prereq is None:
|
|
606
|
+
raise ValueError(f"prerequisite feature {did} not found")
|
|
607
|
+
if _would_form_cycle(feature_id, did):
|
|
608
|
+
raise ValueError(
|
|
609
|
+
f"adding {feature_id} → {did} would create a cycle "
|
|
610
|
+
f"(already added: {added})"
|
|
611
|
+
)
|
|
612
|
+
sess.add(
|
|
613
|
+
ForgeFeatureDep(
|
|
614
|
+
feature_id=feature_id, depends_on_feature_id=did,
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
sess.flush() # so _would_form_cycle sees this edge for subsequent loops
|
|
618
|
+
added.append(did)
|
|
619
|
+
out.append({"id": prereq.id, "title": prereq.title})
|
|
620
|
+
return out
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _would_form_cycle(
|
|
624
|
+
feature_id: int, depends_on_feature_id: int,
|
|
625
|
+
) -> bool:
|
|
626
|
+
"""True iff adding feature_id → depends_on_feature_id creates a cycle.
|
|
627
|
+
A cycle exists if depends_on_feature_id can already reach feature_id
|
|
628
|
+
via the existing dep graph."""
|
|
629
|
+
if feature_id == depends_on_feature_id:
|
|
630
|
+
return True
|
|
631
|
+
with session_scope() as sess:
|
|
632
|
+
# BFS from depends_on_feature_id following outgoing_deps; if we
|
|
633
|
+
# ever reach feature_id, the new edge would close a cycle.
|
|
634
|
+
seen = set()
|
|
635
|
+
frontier = [depends_on_feature_id]
|
|
636
|
+
while frontier:
|
|
637
|
+
cur = frontier.pop()
|
|
638
|
+
if cur in seen:
|
|
639
|
+
continue
|
|
640
|
+
seen.add(cur)
|
|
641
|
+
if cur == feature_id:
|
|
642
|
+
return True
|
|
643
|
+
edges = (
|
|
644
|
+
sess.query(ForgeFeatureDep.depends_on_feature_id)
|
|
645
|
+
.filter_by(feature_id=cur)
|
|
646
|
+
.all()
|
|
647
|
+
)
|
|
648
|
+
frontier.extend(e[0] for e in edges)
|
|
649
|
+
return False
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ────────────────────────── Row → dict marshallers ──────────────────────
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _project_row_to_dict(row: ForgeProject | None) -> dict[str, Any]:
|
|
656
|
+
if row is None:
|
|
657
|
+
return {}
|
|
658
|
+
return {
|
|
659
|
+
"id": row.id,
|
|
660
|
+
"name": row.name,
|
|
661
|
+
"description": row.description,
|
|
662
|
+
"folder_path": row.folder_path,
|
|
663
|
+
"status": row.status,
|
|
664
|
+
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
665
|
+
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _feature_row_to_dict(
|
|
670
|
+
row: ForgeFeature, deps: list[int] | None = None
|
|
671
|
+
) -> dict[str, Any]:
|
|
672
|
+
return {
|
|
673
|
+
"id": row.id,
|
|
674
|
+
"project_id": row.project_id,
|
|
675
|
+
"title": row.title,
|
|
676
|
+
"description": row.description,
|
|
677
|
+
"acceptance_criteria": row.acceptance_criteria,
|
|
678
|
+
"status": row.status,
|
|
679
|
+
"priority": row.priority,
|
|
680
|
+
"category": row.category,
|
|
681
|
+
"verify_command": row.verify_command,
|
|
682
|
+
"depends_on": deps or [],
|
|
683
|
+
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
684
|
+
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
__all__ = [
|
|
689
|
+
"Base",
|
|
690
|
+
"ForgeProject",
|
|
691
|
+
"ForgeFeature",
|
|
692
|
+
"ForgeFeatureDep",
|
|
693
|
+
"ForgeSession",
|
|
694
|
+
"ForgeLog",
|
|
695
|
+
"ForgeChatMessage",
|
|
696
|
+
"ForgeSetting",
|
|
697
|
+
"PROJECT_STATUSES",
|
|
698
|
+
"FEATURE_STATUSES",
|
|
699
|
+
"FEATURE_CATEGORIES",
|
|
700
|
+
"SESSION_TYPES",
|
|
701
|
+
"SESSION_STATUSES",
|
|
702
|
+
"MESSAGE_TYPES",
|
|
703
|
+
"CHAT_ROLES",
|
|
704
|
+
"get_engine",
|
|
705
|
+
"session_scope",
|
|
706
|
+
"init",
|
|
707
|
+
"create_project",
|
|
708
|
+
"list_projects",
|
|
709
|
+
"get_project",
|
|
710
|
+
"create_feature",
|
|
711
|
+
"add_dependency",
|
|
712
|
+
"list_features",
|
|
713
|
+
"get_feature",
|
|
714
|
+
"update_feature",
|
|
715
|
+
"delete_feature",
|
|
716
|
+
"remove_dependency",
|
|
717
|
+
"set_dependencies",
|
|
718
|
+
]
|