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.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -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
+ ]