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
api/forge_routes.py ADDED
@@ -0,0 +1,630 @@
1
+ """Forge HTTP routes — REST API for the autonomous coding harness.
2
+
3
+ Mirrors LocalForge's /api/projects, /api/features, /api/agent-sessions
4
+ surface, ported to Cognos's FastAPI server. All state lives in
5
+ data/cognos.db (planning/forge_models.py); execution is driven by
6
+ planning/orchestrator.py.
7
+
8
+ Endpoints:
9
+ GET /forge/projects list projects (with feature counts)
10
+ POST /forge/projects create project
11
+ GET /forge/projects/{id} kanban state (project + features + running)
12
+ DELETE /forge/projects/{id} delete project
13
+ POST /forge/projects/{id}/features create feature
14
+ POST /forge/projects/{id}/bootstrap NL → backlog (calls bootstrapper)
15
+ POST /forge/projects/{id}/start start orchestrator (next ready feature)
16
+ POST /forge/projects/{id}/stop stop all sessions for project
17
+ GET /forge/features/{id} one feature
18
+ PATCH /forge/features/{id} update feature fields
19
+ DELETE /forge/features/{id} delete feature
20
+ POST /forge/features/{id}/dependencies bulk-replace deps (LocalForge semantics)
21
+ POST /forge/sessions/{id}/stop stop one session
22
+ GET /forge/sessions/{id}/events SSE stream of one session's events
23
+ GET /forge/events SSE stream of all events
24
+ GET /forge/sessions/{id}/messages chat messages for a bootstrapper session
25
+
26
+ Same SSE format LocalForge uses (`data: <json>\n\n`); the kanban UI
27
+ in ui/web/app.js consumes both endpoints.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import json
34
+ import logging
35
+ from datetime import datetime
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from fastapi import APIRouter, HTTPException
40
+ from fastapi.responses import StreamingResponse
41
+ from pydantic import BaseModel, Field
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ # ───────────────────────── Pydantic schemas ───────────────────────────
47
+
48
+
49
+ class CreateProjectIn(BaseModel):
50
+ name: str
51
+ description: str | None = None
52
+ folder_path: str | None = None # default: sandbox/projects/<slug>
53
+
54
+
55
+ class CreateFeatureIn(BaseModel):
56
+ title: str
57
+ description: str | None = None
58
+ acceptance_criteria: str | None = None
59
+ priority: int = 0
60
+ category: str = "functional"
61
+ verify_command: str | None = None
62
+
63
+
64
+ class UpdateFeatureIn(BaseModel):
65
+ title: str | None = None
66
+ description: str | None = None
67
+ acceptance_criteria: str | None = None
68
+ status: str | None = None
69
+ priority: int | None = None
70
+ category: str | None = None
71
+ verify_command: str | None = None
72
+
73
+
74
+ class BootstrapIn(BaseModel):
75
+ goal: str
76
+ stack_hints: list[str] = Field(default_factory=list)
77
+ max_features: int = 15
78
+ dry_run: bool = False
79
+
80
+
81
+ class DependenciesIn(BaseModel):
82
+ depends_on: list[int] = Field(default_factory=list)
83
+
84
+
85
+ class ProjectSettingsIn(BaseModel):
86
+ """Per-project setting overrides. Empty string / None clears."""
87
+ provider: str | None = None
88
+ ollama_url: str | None = None
89
+ lm_studio_url: str | None = None
90
+ model: str | None = None
91
+ coder_prompt: str | None = None
92
+ dev_server_command: str | None = None
93
+ dev_server_port: int | None = None
94
+ max_concurrent_agents: int | None = None
95
+ playwright_enabled: bool | None = None
96
+ playwright_headed: bool | None = None
97
+
98
+
99
+ class LoadExampleIn(BaseModel):
100
+ example: str
101
+ name: str | None = None
102
+
103
+
104
+ # ──────────────────────────── Router ─────────────────────────────────
105
+
106
+
107
+ def build_router() -> APIRouter:
108
+ router = APIRouter(prefix="/forge", tags=["forge"])
109
+
110
+ # Lazy imports — keep server import time tight if forge isn't used.
111
+ def _lazy():
112
+ from planning import forge_models as fm
113
+ from planning import orchestrator as orch
114
+ return fm, orch
115
+
116
+ # ── Projects ───────────────────────────────────────────────────────
117
+
118
+ @router.get("/projects")
119
+ async def list_projects() -> list[dict[str, Any]]:
120
+ fm, _ = _lazy()
121
+ fm.init()
122
+ projects = fm.list_projects()
123
+ # decorate with feature counts for the sidebar
124
+ for p in projects:
125
+ with fm.session_scope() as sess:
126
+ feats = (
127
+ sess.query(fm.ForgeFeature)
128
+ .filter_by(project_id=p["id"])
129
+ .all()
130
+ )
131
+ counts = {"backlog": 0, "in_progress": 0, "completed": 0}
132
+ for f in feats:
133
+ counts[f.status] = counts.get(f.status, 0) + 1
134
+ p["feature_counts"] = counts
135
+ return projects
136
+
137
+ @router.post("/projects")
138
+ async def create_project(body: CreateProjectIn) -> dict[str, Any]:
139
+ fm, _ = _lazy()
140
+ fm.init()
141
+ import re
142
+ folder = body.folder_path or str(
143
+ Path("sandbox/projects") /
144
+ re.sub(r"[^a-zA-Z0-9_-]+", "-", body.name.lower()).strip("-")
145
+ )
146
+ Path(folder).mkdir(parents=True, exist_ok=True)
147
+ try:
148
+ pid = fm.create_project(body.name, body.description, folder)
149
+ except Exception as e:
150
+ raise HTTPException(409, str(e))
151
+ return fm.get_project(pid) or {}
152
+
153
+ @router.get("/projects/{project_id}")
154
+ async def get_kanban(project_id: int) -> dict[str, Any]:
155
+ _, orch = _lazy()
156
+ try:
157
+ return orch.get_kanban_state(project_id)
158
+ except Exception as e:
159
+ status = getattr(e, "status", 500)
160
+ raise HTTPException(status, str(e))
161
+
162
+ @router.delete("/projects/{project_id}")
163
+ async def delete_project(project_id: int) -> dict[str, str]:
164
+ fm, orch = _lazy()
165
+ # Stop any running sessions first
166
+ orch.stop_all_for_project(project_id)
167
+ with fm.session_scope() as sess:
168
+ row = sess.get(fm.ForgeProject, project_id)
169
+ if row is None:
170
+ raise HTTPException(404, "project not found")
171
+ sess.delete(row)
172
+ return {"status": "deleted"}
173
+
174
+ @router.post("/projects/{project_id}/features")
175
+ async def add_feature(
176
+ project_id: int, body: CreateFeatureIn,
177
+ ) -> dict[str, Any]:
178
+ fm, _ = _lazy()
179
+ try:
180
+ fid = fm.create_feature(
181
+ project_id=project_id,
182
+ title=body.title,
183
+ description=body.description,
184
+ acceptance_criteria=body.acceptance_criteria,
185
+ priority=body.priority,
186
+ category=body.category,
187
+ verify_command=body.verify_command,
188
+ )
189
+ except ValueError as e:
190
+ raise HTTPException(400, str(e))
191
+ return {"id": fid}
192
+
193
+ @router.post("/projects/{project_id}/bootstrap")
194
+ async def bootstrap(project_id: int, body: BootstrapIn) -> dict[str, Any]:
195
+ """Decompose a goal into features. Writes to DB unless dry_run."""
196
+ fm, _ = _lazy()
197
+ from planning.planner import Planner
198
+ from llm.provider import LLMProvider
199
+ from core.settings import Settings
200
+ from config import SYSTEM_1_MODEL, SYSTEM_2_MODEL, LLM_MODEL
201
+
202
+ proj = fm.get_project(project_id)
203
+ if not proj:
204
+ raise HTTPException(404, "project not found")
205
+
206
+ # Local-first: prefer the user's System-1 Ollama model for the
207
+ # bootstrap (a structured-output task GLM-class models handle
208
+ # well). Falls back to System-2 only if no System-1 is set.
209
+ settings = Settings.load()
210
+ chosen = (
211
+ settings.get("system1") or SYSTEM_1_MODEL
212
+ or settings.get("system2") or SYSTEM_2_MODEL
213
+ or LLM_MODEL
214
+ )
215
+ logger.info(f"forge bootstrap model: {chosen}")
216
+ llm = LLMProvider(model=chosen)
217
+ planner = Planner(llm)
218
+ # Anthropic models authenticate via the user's Claude Code OAuth
219
+ # token (~/.claude/.credentials.json) instead of ANTHROPIC_API_KEY.
220
+ # subscription_auth_scope() flips a ContextVar that LLMProvider
221
+ # reads on every call.
222
+ from core.anthropic_auth import subscription_auth_scope
223
+ try:
224
+ with subscription_auth_scope():
225
+ features = await planner.decompose_to_features(
226
+ goal=body.goal,
227
+ stack_hints=list(body.stack_hints),
228
+ max_features=body.max_features,
229
+ )
230
+ except Exception as e:
231
+ raise HTTPException(500, f"bootstrapper failed: {e}")
232
+
233
+ out = [f.model_dump() for f in features]
234
+ if body.dry_run:
235
+ return {"features": out, "written": False}
236
+
237
+ idx_to_id: dict[int, int] = {}
238
+ for idx, f in enumerate(features):
239
+ cat = f.category if f.category in ("functional", "style") \
240
+ else "functional"
241
+ fid = fm.create_feature(
242
+ project_id=project_id,
243
+ title=f.title,
244
+ description=f.description,
245
+ acceptance_criteria=f.acceptance_criteria,
246
+ priority=f.priority,
247
+ category=cat,
248
+ verify_command=f.verify_command,
249
+ )
250
+ idx_to_id[idx] = fid
251
+ for idx, f in enumerate(features):
252
+ for dep_idx in f.depends_on:
253
+ if dep_idx in idx_to_id and dep_idx != idx:
254
+ try:
255
+ fm.add_dependency(idx_to_id[idx], idx_to_id[dep_idx])
256
+ except Exception as e:
257
+ logger.warning(f"dep wire failed: {e}")
258
+ return {"features": out, "written": True, "ids": list(idx_to_id.values())}
259
+
260
+ # ── Features ───────────────────────────────────────────────────────
261
+
262
+ @router.get("/features/{feature_id}")
263
+ async def get_feature(feature_id: int) -> dict[str, Any]:
264
+ fm, _ = _lazy()
265
+ with fm.session_scope() as sess:
266
+ row = sess.get(fm.ForgeFeature, feature_id)
267
+ if row is None:
268
+ raise HTTPException(404, "feature not found")
269
+ deps = (
270
+ sess.query(fm.ForgeFeatureDep.depends_on_feature_id)
271
+ .filter_by(feature_id=feature_id)
272
+ .all()
273
+ )
274
+ return {
275
+ "id": row.id, "project_id": row.project_id,
276
+ "title": row.title, "description": row.description,
277
+ "acceptance_criteria": row.acceptance_criteria,
278
+ "status": row.status, "priority": row.priority,
279
+ "category": row.category,
280
+ "verify_command": row.verify_command,
281
+ "depends_on": [d[0] for d in deps],
282
+ }
283
+
284
+ @router.patch("/features/{feature_id}")
285
+ async def update_feature(
286
+ feature_id: int, body: UpdateFeatureIn,
287
+ ) -> dict[str, Any]:
288
+ """Patch a feature. Routes through forge_models.update_feature
289
+ so the project-completion side effect (auto-promote
290
+ project.status when every feature lands at 'completed') fires."""
291
+ fm, _ = _lazy()
292
+ updates = body.model_dump(exclude_unset=True)
293
+ try:
294
+ row = fm.update_feature(feature_id, **updates)
295
+ except ValueError as e:
296
+ raise HTTPException(400, str(e))
297
+ if row is None:
298
+ raise HTTPException(404, "feature not found")
299
+ return await get_feature(feature_id)
300
+
301
+ @router.delete("/features/{feature_id}")
302
+ async def delete_feature(feature_id: int) -> dict[str, str]:
303
+ fm, _ = _lazy()
304
+ with fm.session_scope() as sess:
305
+ row = sess.get(fm.ForgeFeature, feature_id)
306
+ if row is None:
307
+ raise HTTPException(404, "feature not found")
308
+ sess.delete(row)
309
+ return {"status": "deleted"}
310
+
311
+ @router.post("/features/{feature_id}/dependencies")
312
+ async def set_deps(
313
+ feature_id: int, body: DependenciesIn,
314
+ ) -> dict[str, Any]:
315
+ """Bulk-replace deps for a feature (matches LocalForge semantics).
316
+ Routes through ``forge_models.set_dependencies`` so cycle
317
+ detection is enforced — a request that would create a cycle is
318
+ rejected wholesale (400)."""
319
+ fm, _ = _lazy()
320
+ if fm.get_feature(feature_id) is None:
321
+ raise HTTPException(404, "feature not found")
322
+ try:
323
+ fm.set_dependencies(feature_id, list(body.depends_on))
324
+ except ValueError as e:
325
+ raise HTTPException(400, str(e))
326
+ return {"status": "ok", "depends_on": body.depends_on}
327
+
328
+ # ── Orchestrator control ───────────────────────────────────────────
329
+
330
+ @router.post("/projects/{project_id}/start")
331
+ async def start_project(project_id: int) -> dict[str, Any]:
332
+ _, orch = _lazy()
333
+ try:
334
+ return await orch.start_orchestrator(project_id)
335
+ except Exception as e:
336
+ status = getattr(e, "status", 500)
337
+ raise HTTPException(status, str(e))
338
+
339
+ @router.post("/projects/{project_id}/stop")
340
+ async def stop_project(project_id: int) -> dict[str, int]:
341
+ _, orch = _lazy()
342
+ n = orch.stop_all_for_project(project_id)
343
+ return {"stopped": n}
344
+
345
+ @router.post("/sessions/{session_id}/stop")
346
+ async def stop_session_endpoint(session_id: int) -> dict[str, bool]:
347
+ _, orch = _lazy()
348
+ return {"stopped": orch.stop_session(session_id)}
349
+
350
+ # ── Per-project settings (forge_settings table) ────────────────────
351
+
352
+ _INT_KEYS = {"dev_server_port", "max_concurrent_agents"}
353
+ _BOOL_KEYS = {"playwright_enabled", "playwright_headed"}
354
+
355
+ def _coerce(key: str, raw: str) -> Any:
356
+ if key in _INT_KEYS:
357
+ try:
358
+ return int(raw)
359
+ except ValueError:
360
+ return raw
361
+ if key in _BOOL_KEYS:
362
+ return raw.lower() in ("true", "1", "yes")
363
+ return raw
364
+
365
+ def _read_overrides(project_id: int) -> dict[str, Any]:
366
+ fm, _ = _lazy()
367
+ with fm.session_scope() as sess:
368
+ rows = (
369
+ sess.query(fm.ForgeSetting)
370
+ .filter_by(project_id=project_id)
371
+ .all()
372
+ )
373
+ return {r.key: _coerce(r.key, r.value) for r in rows}
374
+
375
+ def _global_defaults() -> dict[str, Any]:
376
+ from core.settings import Settings
377
+ from config import SYSTEM_1_MODEL, SYSTEM_2_MODEL, LLM_MODEL
378
+ s = Settings.load()
379
+ return {
380
+ "provider": "ollama",
381
+ "ollama_url": "http://127.0.0.1:11434",
382
+ "lm_studio_url": "http://127.0.0.1:1234",
383
+ "model": (s.get("system1") or SYSTEM_1_MODEL or LLM_MODEL),
384
+ "coder_prompt": "",
385
+ "dev_server_command": "",
386
+ "dev_server_port": 3000,
387
+ "max_concurrent_agents": 3,
388
+ "playwright_enabled": False,
389
+ "playwright_headed": False,
390
+ }
391
+
392
+ @router.get("/projects/{project_id}/settings")
393
+ async def get_project_settings(project_id: int) -> dict[str, Any]:
394
+ fm, _ = _lazy()
395
+ if fm.get_project(project_id) is None:
396
+ raise HTTPException(404, "project not found")
397
+ overrides = _read_overrides(project_id)
398
+ defaults = _global_defaults()
399
+ effective = {**defaults, **{k: v for k, v in overrides.items() if v not in (None, "")}}
400
+ return {
401
+ "overrides": overrides,
402
+ "effective": effective,
403
+ "defaults": defaults,
404
+ }
405
+
406
+ @router.put("/projects/{project_id}/settings")
407
+ async def put_project_settings(
408
+ project_id: int, body: ProjectSettingsIn,
409
+ ) -> dict[str, Any]:
410
+ fm, _ = _lazy()
411
+ if fm.get_project(project_id) is None:
412
+ raise HTTPException(404, "project not found")
413
+ updates = body.model_dump(exclude_unset=True)
414
+ with fm.session_scope() as sess:
415
+ for k, v in updates.items():
416
+ # Empty string / None clears the override; non-empty sets it.
417
+ row = (
418
+ sess.query(fm.ForgeSetting)
419
+ .filter_by(project_id=project_id, key=k)
420
+ .first()
421
+ )
422
+ if v in (None, ""):
423
+ if row is not None:
424
+ sess.delete(row)
425
+ else:
426
+ if row is None:
427
+ sess.add(fm.ForgeSetting(
428
+ project_id=project_id, key=k,
429
+ value=str(v),
430
+ ))
431
+ else:
432
+ row.value = str(v)
433
+ return await get_project_settings(project_id)
434
+
435
+ # ── Completion stats (celebration screen) ──────────────────────────
436
+
437
+ @router.get("/projects/{project_id}/completion")
438
+ async def get_completion(project_id: int) -> dict[str, Any]:
439
+ fm, orch = _lazy()
440
+ proj = fm.get_project(project_id)
441
+ if proj is None:
442
+ raise HTTPException(404, "project not found")
443
+ with fm.session_scope() as sess:
444
+ feats = (
445
+ sess.query(fm.ForgeFeature)
446
+ .filter_by(project_id=project_id)
447
+ .all()
448
+ )
449
+ total = len(feats)
450
+ done = sum(1 for f in feats if f.status == "completed")
451
+ sessions = (
452
+ sess.query(fm.ForgeSession)
453
+ .filter_by(project_id=project_id, session_type="coding")
454
+ .all()
455
+ )
456
+ tests_passed = 0
457
+ total_runtime_s = 0.0
458
+ for s in sessions:
459
+ if s.started_at and s.ended_at:
460
+ total_runtime_s += (s.ended_at - s.started_at).total_seconds()
461
+ # Tests passed: count log rows with message_type='test_result' and "verify" in message
462
+ test_logs = (
463
+ sess.query(fm.ForgeLog)
464
+ .filter(
465
+ fm.ForgeLog.session_id.in_([s.id for s in sessions]),
466
+ fm.ForgeLog.message_type == "test_result",
467
+ )
468
+ .all()
469
+ )
470
+ tests_passed = len(test_logs)
471
+ project_started = (
472
+ sess.query(fm.ForgeProject.created_at)
473
+ .filter_by(id=project_id)
474
+ .scalar()
475
+ )
476
+ project_completed_at = max(
477
+ (f.updated_at for f in feats
478
+ if f.status == "completed" and f.updated_at),
479
+ default=None,
480
+ )
481
+ wallclock_s = (
482
+ (project_completed_at - project_started).total_seconds()
483
+ if project_completed_at and project_started else None
484
+ )
485
+ return {
486
+ "completion": {
487
+ "project_id": project_id,
488
+ "name": proj["name"],
489
+ "status": proj["status"],
490
+ "features_total": total,
491
+ "features_completed": done,
492
+ "tests_passed": tests_passed,
493
+ "agent_runtime_s": total_runtime_s,
494
+ "wallclock_s": wallclock_s,
495
+ "completed_at": (
496
+ project_completed_at.isoformat()
497
+ if project_completed_at else None
498
+ ),
499
+ }
500
+ }
501
+
502
+ # ── Load example project (seed) ────────────────────────────────────
503
+
504
+ @router.post("/projects/load-example")
505
+ async def load_example(body: LoadExampleIn) -> dict[str, Any]:
506
+ from pathlib import Path as _Path
507
+ import json as _json
508
+ slug = (body.example or "").strip()
509
+ if not slug:
510
+ raise HTTPException(400, "field 'example' is required")
511
+ example_path = (_Path(__file__).resolve().parents[1]
512
+ / "data" / "examples" / f"{slug}.json")
513
+ if not example_path.exists():
514
+ raise HTTPException(404, f"example {slug!r} not found")
515
+ try:
516
+ data = _json.loads(example_path.read_text())
517
+ except Exception as e:
518
+ raise HTTPException(500, f"failed to load example: {e}")
519
+
520
+ fm, _ = _lazy()
521
+ fm.init()
522
+ import re as _re
523
+ proj_name = (body.name or data.get("name") or "Example").strip()
524
+ folder = str(
525
+ _Path("sandbox/projects")
526
+ / _re.sub(r"[^a-zA-Z0-9_-]+", "-", proj_name.lower()).strip("-")
527
+ )
528
+ _Path(folder).mkdir(parents=True, exist_ok=True)
529
+ pid = fm.create_project(
530
+ proj_name, data.get("description"), folder,
531
+ )
532
+ feats = data.get("features") or []
533
+ # Pass 1: create rows, remember per-index id
534
+ idx_to_id: list[int] = []
535
+ for f in feats:
536
+ cat = f.get("category") or "functional"
537
+ if cat not in ("functional", "style"):
538
+ cat = "functional"
539
+ fid = fm.create_feature(
540
+ project_id=pid,
541
+ title=f.get("title") or "(untitled)",
542
+ description=f.get("description"),
543
+ acceptance_criteria=f.get("acceptanceCriteria"),
544
+ category=cat,
545
+ priority=int(f.get("priority", 0)),
546
+ )
547
+ idx_to_id.append(fid)
548
+ # Pass 2: wire deps
549
+ deps_wired = 0
550
+ for i, f in enumerate(feats):
551
+ d_idx = f.get("dependsOnIndices") or []
552
+ d_ids = [
553
+ idx_to_id[j] for j in d_idx
554
+ if 0 <= int(j) < len(idx_to_id)
555
+ ]
556
+ if d_ids:
557
+ try:
558
+ fm.set_dependencies(idx_to_id[i], d_ids)
559
+ deps_wired += 1
560
+ except Exception as e:
561
+ logger.warning(f"dep wire failed for feature {i}: {e}")
562
+ return {
563
+ "project": fm.get_project(pid),
564
+ "features_created": len(idx_to_id),
565
+ "dependencies_wired": deps_wired,
566
+ }
567
+
568
+ # ── Dev-server panel ───────────────────────────────────────────────
569
+
570
+ @router.get("/projects/{project_id}/dev-server")
571
+ async def get_dev_server(project_id: int) -> dict[str, Any]:
572
+ from planning import dev_server as _ds
573
+ return _ds.status(project_id)
574
+
575
+ @router.post("/projects/{project_id}/dev-server")
576
+ async def start_dev_server(project_id: int) -> dict[str, Any]:
577
+ fm, _ = _lazy()
578
+ proj = fm.get_project(project_id)
579
+ if proj is None:
580
+ raise HTTPException(404, "project not found")
581
+ # Pull command + port from per-project settings, with fallbacks.
582
+ overrides = _read_overrides(project_id)
583
+ defaults = _global_defaults()
584
+ cmd = overrides.get("dev_server_command") or defaults.get("dev_server_command") or ""
585
+ port_raw = overrides.get("dev_server_port") or defaults.get("dev_server_port") or 3000
586
+ try:
587
+ port = int(port_raw)
588
+ except (TypeError, ValueError):
589
+ port = 3000
590
+ from planning import dev_server as _ds
591
+ result = _ds.start(
592
+ project_id, proj["folder_path"],
593
+ command=cmd or None, port=port,
594
+ )
595
+ if not result.get("running"):
596
+ raise HTTPException(400, result.get("error", "failed to start"))
597
+ return result
598
+
599
+ @router.delete("/projects/{project_id}/dev-server")
600
+ async def stop_dev_server(project_id: int) -> dict[str, Any]:
601
+ from planning import dev_server as _ds
602
+ return {"stopped": _ds.stop(project_id)}
603
+
604
+ # ── SSE streams ────────────────────────────────────────────────────
605
+
606
+ async def _sse_iter(gen):
607
+ """Convert an async-iter of dicts to text/event-stream chunks."""
608
+ try:
609
+ async for ev in gen:
610
+ yield f"data: {json.dumps(ev)}\n\n"
611
+ except asyncio.CancelledError:
612
+ return
613
+
614
+ @router.get("/sessions/{session_id}/events")
615
+ async def session_events(session_id: int) -> StreamingResponse:
616
+ _, orch = _lazy()
617
+ return StreamingResponse(
618
+ _sse_iter(orch.subscribe_session(session_id)),
619
+ media_type="text/event-stream",
620
+ )
621
+
622
+ @router.get("/events")
623
+ async def all_events() -> StreamingResponse:
624
+ _, orch = _lazy()
625
+ return StreamingResponse(
626
+ _sse_iter(orch.subscribe_global()),
627
+ media_type="text/event-stream",
628
+ )
629
+
630
+ return router