spriteforge 0.1.1__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 (218) hide show
  1. spriteforge/__init__.py +1 -0
  2. spriteforge/__main__.py +3 -0
  3. spriteforge/agents/__init__.py +0 -0
  4. spriteforge/agents/assets.py +109 -0
  5. spriteforge/agents/cancellation.py +12 -0
  6. spriteforge/agents/director.py +50 -0
  7. spriteforge/agents/models.py +63 -0
  8. spriteforge/agents/roles/art-director.md +15 -0
  9. spriteforge/agents/roles/art-reviewer.md +14 -0
  10. spriteforge/agents/roles/director.md +14 -0
  11. spriteforge/agents/roles/game-designer.md +15 -0
  12. spriteforge/agents/roles/level-tile-designer.md +16 -0
  13. spriteforge/agents/roles/librarian.md +13 -0
  14. spriteforge/agents/roles/menu-designer.md +15 -0
  15. spriteforge/agents/roles/pixel-artist.md +14 -0
  16. spriteforge/agents/roles/programmer.md +14 -0
  17. spriteforge/agents/roles/qa-playtester.md +13 -0
  18. spriteforge/agents/roles/sound-director.md +14 -0
  19. spriteforge/agents/roles.py +39 -0
  20. spriteforge/agents/router.py +233 -0
  21. spriteforge/agents/router_eval.py +145 -0
  22. spriteforge/agents/routing_state.py +133 -0
  23. spriteforge/agents/runtime.py +430 -0
  24. spriteforge/agents/workflow.py +389 -0
  25. spriteforge/app/__init__.py +0 -0
  26. spriteforge/app/agents_view.py +248 -0
  27. spriteforge/app/chat.py +114 -0
  28. spriteforge/app/extensions.py +115 -0
  29. spriteforge/app/index.html +279 -0
  30. spriteforge/app/local_only.py +49 -0
  31. spriteforge/app/server.py +358 -0
  32. spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
  33. spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
  34. spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
  35. spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
  36. spriteforge/app/static/index.html +13 -0
  37. spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
  38. spriteforge/app/studio.py +278 -0
  39. spriteforge/app/workers.py +73 -0
  40. spriteforge/cli.py +241 -0
  41. spriteforge/config.py +193 -0
  42. spriteforge/config.yaml +157 -0
  43. spriteforge/core/__init__.py +0 -0
  44. spriteforge/core/acting.py +16 -0
  45. spriteforge/core/cost.py +236 -0
  46. spriteforge/core/credentials.py +24 -0
  47. spriteforge/core/demo.py +79 -0
  48. spriteforge/core/forms.py +20 -0
  49. spriteforge/core/gates.py +175 -0
  50. spriteforge/core/security.py +42 -0
  51. spriteforge/core/state.py +222 -0
  52. spriteforge/core/team.py +15 -0
  53. spriteforge/core/usage.py +68 -0
  54. spriteforge/game/__init__.py +0 -0
  55. spriteforge/game/actors.py +377 -0
  56. spriteforge/game/agent_tools.py +178 -0
  57. spriteforge/game/art.py +2097 -0
  58. spriteforge/game/art_checks.py +190 -0
  59. spriteforge/game/art_review.py +518 -0
  60. spriteforge/game/build.py +134 -0
  61. spriteforge/game/bundle.py +1178 -0
  62. spriteforge/game/cli.py +119 -0
  63. spriteforge/game/codegen.py +433 -0
  64. spriteforge/game/codeinfo.py +154 -0
  65. spriteforge/game/dbsource.py +137 -0
  66. spriteforge/game/design.py +342 -0
  67. spriteforge/game/export.py +62 -0
  68. spriteforge/game/gamedb.py +191 -0
  69. spriteforge/game/image_cache.py +61 -0
  70. spriteforge/game/lessons.py +259 -0
  71. spriteforge/game/level_gen.py +118 -0
  72. spriteforge/game/level_preview.py +97 -0
  73. spriteforge/game/levels.py +934 -0
  74. spriteforge/game/loops.py +38 -0
  75. spriteforge/game/menus.py +712 -0
  76. spriteforge/game/mock_game.js +109 -0
  77. spriteforge/game/mock_platformer.js +58 -0
  78. spriteforge/game/motion.py +881 -0
  79. spriteforge/game/pipeline.py +1768 -0
  80. spriteforge/game/plan.py +502 -0
  81. spriteforge/game/playtest.py +318 -0
  82. spriteforge/game/qa.py +664 -0
  83. spriteforge/game/qa_checks.py +223 -0
  84. spriteforge/game/qa_runtime.py +565 -0
  85. spriteforge/game/reach.py +807 -0
  86. spriteforge/game/record.py +138 -0
  87. spriteforge/game/research.py +262 -0
  88. spriteforge/game/revision.py +225 -0
  89. spriteforge/game/sdk/API.md +104 -0
  90. spriteforge/game/sdk/FLOW.md +19 -0
  91. spriteforge/game/sdk/LEVELS.md +65 -0
  92. spriteforge/game/sdk/sf.js +418 -0
  93. spriteforge/game/sdk/sfflow.js +442 -0
  94. spriteforge/game/sdk/sflevel.js +683 -0
  95. spriteforge/game/sdk/sfsound-editor.js +225 -0
  96. spriteforge/game/sdk/sfsound.js +260 -0
  97. spriteforge/game/sdk/sounds.html +67 -0
  98. spriteforge/game/sounds.py +793 -0
  99. spriteforge/game/story.py +188 -0
  100. spriteforge/game/structure.py +98 -0
  101. spriteforge/game/team_reports.py +394 -0
  102. spriteforge/game/tilesets.py +70 -0
  103. spriteforge/game/title_art.py +186 -0
  104. spriteforge/genres/__init__.py +0 -0
  105. spriteforge/library/__init__.py +15 -0
  106. spriteforge/library/cli.py +126 -0
  107. spriteforge/library/describe.py +242 -0
  108. spriteforge/library/looks.py +154 -0
  109. spriteforge/library/models.py +79 -0
  110. spriteforge/library/store.py +422 -0
  111. spriteforge/library/tilesets/README.md +32 -0
  112. spriteforge/library/tilesets/gandalf-seasons.json +111 -0
  113. spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
  114. spriteforge/mcp/__init__.py +0 -0
  115. spriteforge/mcp/cli.py +58 -0
  116. spriteforge/mcp/client.py +57 -0
  117. spriteforge/mcp/filesystem_read.py +32 -0
  118. spriteforge/mcp/gamedb.py +56 -0
  119. spriteforge/mcp/library.py +179 -0
  120. spriteforge/mcp/memory.py +37 -0
  121. spriteforge/mcp/pixel_tools.py +145 -0
  122. spriteforge/mcp/registry.py +145 -0
  123. spriteforge/mcp/service.py +224 -0
  124. spriteforge/mcp/templates/context7.json +6 -0
  125. spriteforge/mcp/templates/fetch.json +6 -0
  126. spriteforge/mcp/templates/filesystem-read.json +6 -0
  127. spriteforge/mcp/templates/pixel-tools.json +6 -0
  128. spriteforge/memory/README.md +10 -0
  129. spriteforge/memory/__init__.py +0 -0
  130. spriteforge/memory/cli.py +131 -0
  131. spriteforge/memory/okf/__init__.py +1 -0
  132. spriteforge/memory/okf/catalog.py +331 -0
  133. spriteforge/memory/okf/codec.py +63 -0
  134. spriteforge/memory/okf/git.py +104 -0
  135. spriteforge/memory/okf/indexes.py +50 -0
  136. spriteforge/memory/okf/models.py +114 -0
  137. spriteforge/memory/okf/search.py +53 -0
  138. spriteforge/memory/okf/seed.py +81 -0
  139. spriteforge/memory/okf/seeds/genres.yaml +121 -0
  140. spriteforge/memory/okf/seeds/other.yaml +150 -0
  141. spriteforge/memory/okf/store.py +402 -0
  142. spriteforge/memory/okf/validate.py +115 -0
  143. spriteforge/pixel/__init__.py +17 -0
  144. spriteforge/pixel/atlas.py +110 -0
  145. spriteforge/pixel/grid.py +174 -0
  146. spriteforge/pixel/palette.py +156 -0
  147. spriteforge/pixel/palettes/dungeon.json +9 -0
  148. spriteforge/pixel/postprocess.py +170 -0
  149. spriteforge/pixel/preview.py +40 -0
  150. spriteforge/pixel/schemas.py +139 -0
  151. spriteforge/pixel/source.py +237 -0
  152. spriteforge/pixel/terrain.py +48 -0
  153. spriteforge/pixel/validate.py +138 -0
  154. spriteforge/prompts/anim_frame.txt +1 -0
  155. spriteforge/prompts/character.txt +1 -0
  156. spriteforge/prompts/item.txt +1 -0
  157. spriteforge/prompts/tile.txt +1 -0
  158. spriteforge/prompts/ui.txt +1 -0
  159. spriteforge/providers/__init__.py +0 -0
  160. spriteforge/providers/base.py +97 -0
  161. spriteforge/providers/http.py +38 -0
  162. spriteforge/providers/mock.py +111 -0
  163. spriteforge/providers/muse_image.py +60 -0
  164. spriteforge/providers/muse_search.py +91 -0
  165. spriteforge/providers/muse_spark.py +66 -0
  166. spriteforge/skills/__init__.py +0 -0
  167. spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
  168. spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
  169. spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
  170. spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
  171. spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
  172. spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
  173. spriteforge/skills/builtin/level-design/SKILL.md +169 -0
  174. spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
  175. spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
  176. spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
  177. spriteforge/skills/builtin/platformer/SKILL.md +153 -0
  178. spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
  179. spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
  180. spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
  181. spriteforge/skills/catalog.py +200 -0
  182. spriteforge/skills/cli.py +41 -0
  183. spriteforge/stages/__init__.py +0 -0
  184. spriteforge/stages/asset_context.py +301 -0
  185. spriteforge/stages/asset_models.py +192 -0
  186. spriteforge/stages/asset_render.py +366 -0
  187. spriteforge/stages/asset_review.py +140 -0
  188. spriteforge/stages/asset_store.py +94 -0
  189. spriteforge/stages/chat_changes.py +236 -0
  190. spriteforge/stages/concept.py +42 -0
  191. spriteforge/stages/game.py +1699 -0
  192. spriteforge/stages/game_confirm.py +112 -0
  193. spriteforge/stages/game_final.py +220 -0
  194. spriteforge/stages/game_record.py +246 -0
  195. spriteforge/stages/gameplay.py +218 -0
  196. spriteforge/stages/intake.py +372 -0
  197. spriteforge/stages/preview_assets.py +212 -0
  198. spriteforge/stages/preview_models.py +167 -0
  199. spriteforge/stages/preview_render.py +169 -0
  200. spriteforge/stages/preview_repair.py +61 -0
  201. spriteforge/stages/preview_review.py +169 -0
  202. spriteforge/stages/production.py +170 -0
  203. spriteforge/stages/references.py +105 -0
  204. spriteforge/stages/rewind.py +128 -0
  205. spriteforge/stages/workflow.py +440 -0
  206. spriteforge/templates/phaser/audit.js +45 -0
  207. spriteforge/templates/phaser/font.png +0 -0
  208. spriteforge/templates/phaser/font.xml +1 -0
  209. spriteforge/templates/phaser/preview.html +9 -0
  210. spriteforge/templates/phaser/room.js +49 -0
  211. spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
  212. spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
  213. spriteforge-0.1.1.dist-info/METADATA +364 -0
  214. spriteforge-0.1.1.dist-info/RECORD +218 -0
  215. spriteforge-0.1.1.dist-info/WHEEL +5 -0
  216. spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
  217. spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
  218. spriteforge-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,389 @@
1
+ """Offline agent-runtime acceptance workflow, resumed entirely from local artifacts and gates."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from spriteforge.config import BudgetConfig, RouterConfig, load_config
9
+ from spriteforge.core.cost import Budget
10
+ from spriteforge.core.gates import Gates, new_gate
11
+ from spriteforge.core.state import Store
12
+ from spriteforge.memory.okf.git import write_lock
13
+ from spriteforge.memory.okf.seed import seed
14
+ from spriteforge.memory.okf.store import KnowledgeStore
15
+ from spriteforge.providers.base import ChatResult
16
+ from spriteforge.providers.mock import MockImageProvider, MockLLMProvider
17
+
18
+ from .assets import AssetResult, Assets, GenerateAsset
19
+ from .director import Director
20
+ from .models import Changes, RoleOutput, StageNode, StagePlan
21
+ from .roles import Role, load_roles
22
+ from .runtime import AgentRuntime, AgentTask, Tool
23
+
24
+ WORKFLOW = "agent-runtime-mock-v1"
25
+
26
+
27
+ class Flow(BaseModel):
28
+ roles: dict[str, Role]
29
+ plan: StagePlan | None = None
30
+ outputs: dict[str, str] = Field(default_factory=dict)
31
+ completed: list[str] = Field(default_factory=list)
32
+ handled: list[str] = Field(default_factory=list)
33
+ changes: Changes | None = None
34
+ change_artifact: str | None = None
35
+ rework: StagePlan | None = None
36
+ rework_done: list[str] = Field(default_factory=list)
37
+ revisions: dict[str, int] = Field(default_factory=dict)
38
+ source_gate: str | None = None
39
+
40
+
41
+ def start_run(
42
+ store: Store,
43
+ goal: str,
44
+ budget: BudgetConfig,
45
+ roles_directory: Path | None = None,
46
+ router: RouterConfig | None = None,
47
+ ):
48
+ # Snapshot user edits for this run; subsequent runs reload the Markdown files.
49
+ roles = load_roles(roles_directory)
50
+ required = {
51
+ "Director",
52
+ "Game Designer",
53
+ "Art Director",
54
+ "Pixel Artist",
55
+ "Level/Tile Designer",
56
+ "Programmer",
57
+ "QA Playtester",
58
+ "Librarian",
59
+ }
60
+ if required - roles.keys():
61
+ raise ValueError("Missing required roles")
62
+ run = store.create_run(
63
+ {
64
+ "workflow": WORKFLOW,
65
+ "goal": goal,
66
+ "budget": budget.model_dump(),
67
+ "router": (router or load_config().router).model_dump(),
68
+ "roles": {name: role.model_dump() for name, role in roles.items()},
69
+ }
70
+ )
71
+ Gates(store)
72
+ return run
73
+
74
+
75
+ def mock_plan() -> StagePlan:
76
+ definitions = [
77
+ ("brief", None, "G1 Brief"),
78
+ ("design", "Game Designer", None),
79
+ ("art", "Art Director", None),
80
+ ("concept", None, "G2 Concept preview"),
81
+ ("assets", "Pixel Artist", None),
82
+ ("contact", None, "G3 Asset contact sheet"),
83
+ ("level", "Level/Tile Designer", None),
84
+ ("code", "Programmer", None),
85
+ ("qa", "QA Playtester", None),
86
+ ("playtest", None, "G4 Playtest"),
87
+ ("memory", "Librarian", None),
88
+ ("final", None, "G5 Final review"),
89
+ ]
90
+ return StagePlan(
91
+ stages=[
92
+ StageNode(
93
+ id=ident,
94
+ role=role,
95
+ gate=gate,
96
+ goal=gate or f"Produce mock {role} artifact",
97
+ depends_on=[definitions[i - 1][0]] if i else [],
98
+ )
99
+ for i, (ident, role, gate) in enumerate(definitions)
100
+ ]
101
+ )
102
+
103
+
104
+ def mock_changes(text: str, records: dict[str, AssetResult]) -> Changes:
105
+ """Scripted offline Director behavior; unrecognized feedback stays conservative."""
106
+ lowered = text.casefold()
107
+ named = [ident for ident in records if ident.casefold() in lowered]
108
+ if named:
109
+ target, regen = named, True
110
+ elif "style" in lowered or "palette" in lowered:
111
+ target, regen = "style", False
112
+ elif "level" in lowered or "room" in lowered:
113
+ target, regen = "level", False
114
+ else:
115
+ target, regen = "mechanic", False
116
+ return Changes(
117
+ requests=[
118
+ {
119
+ "target": target,
120
+ "instruction": text,
121
+ "severity": "minor",
122
+ "scope": "Only the listed targets",
123
+ "needs_regen": regen,
124
+ }
125
+ ]
126
+ )
127
+
128
+
129
+ def mock_replan(changes: Changes) -> StagePlan:
130
+ stages = []
131
+ for i, change in enumerate(changes.requests):
132
+ role = (
133
+ "Pixel Artist"
134
+ if change.needs_regen
135
+ else {
136
+ "style": "Art Director",
137
+ "level": "Level/Tile Designer",
138
+ "mechanic": "Game Designer",
139
+ }.get(str(change.target), "Art Director")
140
+ )
141
+ stages.append(
142
+ StageNode(
143
+ id=f"revision-{i}",
144
+ role=role,
145
+ goal=change.instruction,
146
+ depends_on=[f"revision-{i - 1}"] if i else [],
147
+ )
148
+ )
149
+ return StagePlan(stages=stages)
150
+
151
+
152
+ def workflow_step(store: Store, run_id: str) -> bool:
153
+ from spriteforge.mcp.service import ConfirmationRequired
154
+
155
+ with write_lock(store.path.parent / f".{run_id}.runtime.lock"):
156
+ try:
157
+ return asyncio.run(_step(store, run_id))
158
+ except ConfirmationRequired:
159
+ return False
160
+
161
+
162
+ async def _step(store: Store, run_id: str) -> bool:
163
+ run = store.get_run(run_id)
164
+ if run.config.get("workflow") != WORKFLOW:
165
+ raise ValueError("Unknown runtime workflow")
166
+ if run.status in {"completed", "aborted"}:
167
+ return False
168
+ gates = Gates(store)
169
+ if gates.pending(run_id):
170
+ return False
171
+ with store.connect() as db:
172
+ db.execute("CREATE TABLE IF NOT EXISTS agent_flows (run_id TEXT PRIMARY KEY, body TEXT)")
173
+ row = db.execute("SELECT body FROM agent_flows WHERE run_id=?", (run_id,)).fetchone()
174
+ flow = (
175
+ Flow.model_validate_json(row[0])
176
+ if row
177
+ else Flow(roles={n: Role.model_validate(r) for n, r in run.config["roles"].items()})
178
+ )
179
+ budget = Budget(store, run_id, BudgetConfig.model_validate(run.config["budget"]))
180
+ assets = Assets(store, run_id, MockImageProvider(budget))
181
+ memory = KnowledgeStore(store.path.parent / "memory", store, run_id)
182
+ seed(memory)
183
+
184
+ def runtime(role: Role, responses: list, tools=None) -> AgentRuntime:
185
+ if role.model != "mock":
186
+ raise ValueError(
187
+ "The offline workflow requires model: mock; inject a provider in AgentRuntime"
188
+ )
189
+ return AgentRuntime(store, run_id, memory, MockLLMProvider(budget, responses), tools)
190
+
191
+ def director(responses):
192
+ return Director(
193
+ runtime(flow.roles["Director"], responses), flow.roles["Director"], flow.roles
194
+ )
195
+
196
+ def save(kind="workflow.progress", final=False):
197
+ with store.connect() as db:
198
+ db.execute(
199
+ "INSERT OR REPLACE INTO agent_flows VALUES(?,?)", (run_id, flow.model_dump_json())
200
+ )
201
+ db.execute(
202
+ "UPDATE runs SET cursor=cursor+1,status=? WHERE id=?",
203
+ ("completed" if final else "running", run_id),
204
+ )
205
+ store._event(db, run_id, "Director", kind, {"completed": flow.completed})
206
+
207
+ if flow.plan is None:
208
+ flow.plan, ident = await director([mock_plan().model_dump()]).plan(
209
+ AgentTask(id="initial-plan", goal=run.config["goal"])
210
+ )
211
+ flow.outputs["plan"] = ident
212
+ save("director.planned")
213
+ return True
214
+
215
+ async def execute(stage: StageNode, task_id: str, inputs: list[str], rework=False):
216
+ role = flow.roles[stage.role]
217
+ tools, responses = {}, []
218
+ expected_assets = set(assets.records())
219
+ if stage.role == "Pixel Artist":
220
+ allowed = set(flow.revisions) if rework else {"hero", "tile"}
221
+ expected_assets.update(allowed)
222
+ for index, ident in enumerate(sorted(allowed)):
223
+ responses.append(
224
+ ChatResult(
225
+ tool_calls=[
226
+ {
227
+ "id": f"image-{index}",
228
+ "name": "generate_asset",
229
+ "arguments": {
230
+ "asset_id": ident,
231
+ "instruction": stage.goal + " " + ident,
232
+ },
233
+ }
234
+ ]
235
+ )
236
+ )
237
+ tools["generate_asset"] = Tool(
238
+ GenerateAsset,
239
+ AssetResult,
240
+ lambda request: assets.generate(
241
+ request, allowed=allowed, revisions=flow.revisions if rework else None
242
+ ),
243
+ )
244
+ responses.append(
245
+ RoleOutput(
246
+ summary=stage.goal + " (offline mock)", asset_ids=sorted(expected_assets)
247
+ ).model_dump()
248
+ )
249
+ result = await Director(
250
+ runtime(role, responses, tools), flow.roles["Director"], flow.roles
251
+ ).dispatch(stage, AgentTask(id=task_id, goal=stage.goal, inputs=inputs), RoleOutput)
252
+ return result.id
253
+
254
+ # Handle answered gates before dispatching another stage. All interpretation and
255
+ # re-planning results are persisted artifacts, and replay has deterministic task ids.
256
+ answered = next(
257
+ (g for g in gates.all(run_id) if g.status != "pending" and g.id not in flow.handled), None
258
+ )
259
+ if answered:
260
+ if answered.status == "aborted":
261
+ return False
262
+ feedback_artifact = next(
263
+ a.id for a in store.artifacts(run_id) if a.name == f"feedback/{answered.id}.json"
264
+ )
265
+ if answered.status == "changes_requested":
266
+ changes = mock_changes(answered.response.text, assets.records())
267
+ flow.changes, flow.change_artifact = await director([changes.model_dump()]).feedback(
268
+ AgentTask(
269
+ id=f"interpret-{answered.id}",
270
+ goal=answered.response.text,
271
+ inputs=[feedback_artifact],
272
+ ),
273
+ set(assets.records()),
274
+ )
275
+ flow.source_gate = answered.id
276
+ flow.handled.append(answered.id)
277
+ save("director.changes_proposed")
278
+ gates.open(
279
+ run_id,
280
+ new_gate(
281
+ answered.stage,
282
+ "Confirm parsed change requests",
283
+ [flow.change_artifact],
284
+ kind="confirm_changes",
285
+ parent_id=answered.id,
286
+ changes=flow.changes,
287
+ ),
288
+ )
289
+ return True
290
+ if answered.kind == "confirm_changes":
291
+ flow.rework, plan_id = await director([mock_replan(flow.changes).model_dump()]).replan(
292
+ AgentTask(
293
+ id=f"replan-{answered.id}",
294
+ goal="Re-plan only the confirmed changes",
295
+ inputs=[flow.change_artifact, feedback_artifact],
296
+ ),
297
+ flow.changes,
298
+ )
299
+ flow.outputs[f"replan-{answered.id}"] = plan_id
300
+ flow.rework_done = []
301
+ flow.revisions = {
302
+ ident: assets.records()[ident].version + 1
303
+ for change in flow.changes.requests
304
+ if change.needs_regen
305
+ for ident in change.target
306
+ }
307
+ flow.handled.append(answered.id)
308
+ save("director.replanned")
309
+ return True
310
+ flow.completed.append(answered.stage)
311
+ flow.outputs[f"gate-{answered.stage}"] = feedback_artifact
312
+ flow.handled.append(answered.id)
313
+ save()
314
+ return True
315
+
316
+ if flow.rework:
317
+ stage = Director.ready(flow.rework, set(flow.rework_done))
318
+ if stage:
319
+ if stage.gate:
320
+ raise ValueError("Revision plan must route roles only")
321
+ ident = await execute(
322
+ stage, f"rework-{flow.source_gate}-{stage.id}", [flow.change_artifact], rework=True
323
+ )
324
+ flow.outputs[f"rework-{flow.source_gate}-{stage.id}"] = ident
325
+ flow.rework_done.append(stage.id)
326
+ save()
327
+ return True
328
+ original = next(g for g in gates.all(run_id) if g.id == flow.source_gate)
329
+ flow.rework = None
330
+ flow.changes = None
331
+ save("changes.applied")
332
+ images = [r.artifact_id for r in assets.records().values()]
333
+ sheet = assets.contact_sheet()
334
+ gates.open(
335
+ run_id, new_gate(original.stage, original.title, images + ([sheet] if sheet else []))
336
+ )
337
+ return True
338
+
339
+ stage = Director.ready(flow.plan, set(flow.completed))
340
+ if stage is None:
341
+ save("run.completed", final=True)
342
+ return False
343
+ if stage.gate:
344
+ previous = [g for g in gates.all(run_id) if g.stage == stage.id]
345
+ # Crash recovery: a proposal saved immediately before opening its confirmation.
346
+ if flow.source_gate and flow.changes and not flow.rework and previous:
347
+ gates.open(
348
+ run_id,
349
+ new_gate(
350
+ stage.id,
351
+ "Confirm parsed change requests",
352
+ [flow.change_artifact],
353
+ kind="confirm_changes",
354
+ parent_id=flow.source_gate,
355
+ changes=flow.changes,
356
+ ),
357
+ )
358
+ else:
359
+ images = [r.artifact_id for r in assets.records().values()]
360
+ sheet = assets.contact_sheet()
361
+ kwargs = {}
362
+ if stage.id == "brief":
363
+ kwargs["form_schema"] = {
364
+ "type": "object",
365
+ "properties": {"brief": {"type": "string", "title": "Game brief"}},
366
+ }
367
+ gates.open(
368
+ run_id,
369
+ new_gate(
370
+ stage.id,
371
+ stage.gate,
372
+ images + ([sheet] if sheet else []) + list(flow.outputs.values())[-2:],
373
+ **kwargs,
374
+ ),
375
+ )
376
+ return True
377
+ # Include approved feedback and upstream role outputs explicitly, including
378
+ # artifacts across intervening gates and confirmed revision artifacts.
379
+ inputs = list(dict.fromkeys(flow.outputs.values()))
380
+ flow.outputs[stage.id] = await execute(stage, f"stage-{stage.id}", inputs)
381
+ flow.completed.append(stage.id)
382
+ save()
383
+ return True
384
+
385
+
386
+ def advance_until_gate(store: Store, run_id: str) -> None:
387
+ while workflow_step(store, run_id):
388
+ if store.get_run(run_id).status == "paused":
389
+ break
File without changes
@@ -0,0 +1,248 @@
1
+ """The studio's view of a run as a team of agents, derived from the append-only journal (events
2
+ and gates): who works in which order, what each produced and cost, and each agent's thread."""
3
+
4
+ import re
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from spriteforge.core.cost import ALLOWANCE_EVENTS
9
+ from spriteforge.core.gates import Gate, Gates
10
+ from spriteforge.core.state import Event, Store
11
+ from spriteforge.core.team import AGENTS
12
+ from spriteforge.stages.chat_changes import changes
13
+
14
+ # The agents whose work a gate reviews (the Director's chat shows every gate as well).
15
+ GATE_OWNERS = {
16
+ "design": ["Game Designer"],
17
+ "concept": ["Art Director"],
18
+ "assets": ["Art Director", "Pixel Artist"],
19
+ "gameplay": ["Art Director", "Pixel Artist"],
20
+ "menus": ["Menu Designer"],
21
+ "playtest": ["Programmer", "QA Playtester"],
22
+ "final": ["Librarian"],
23
+ }
24
+
25
+ # The Director narrates steps that other agents do ("build v1: the Programmer is writing the
26
+ # game"); the first matching pattern names the agent at work.
27
+ NARRATION = [
28
+ (r"Art Reviewer|vision review", "Art Reviewer"),
29
+ (r"Programmer|attempt \d+", "Programmer"),
30
+ (r"menu", "Menu Designer"),
31
+ (r"art \(|blocking issue", "Pixel Artist"),
32
+ (r"playing the game|recording|QA|tests?\b", "QA Playtester"),
33
+ ]
34
+
35
+ # Journal noise that is not an agent's own work (each memory read, the cost ledger).
36
+ QUIET = {"memory.retrieved", "cost.reserved", "cost.succeeded", "cost.failed",
37
+ # Chat and its bookkeeping: talking about the work is not doing it.
38
+ "chat.user", "chat.agent", "chat.applied", "chat.unapplied", "rewind.declined",
39
+ "run.stop_requested", "run.stop_cleared", "round.superseded", "round.rewound",
40
+ "round.replanned",
41
+ # A plan or report is about the work (an agent's plan comes before it starts).
42
+ "agent.report"}
43
+ RUN_EVENTS = {"run.created", "run.failed", "run.completed", "run.retry_requested", "game.built",
44
+ "run.paused"}
45
+
46
+
47
+ def owners(gate: Gate) -> list[str]:
48
+ """The Director, the agents whose work the gate reviews and the agents asking for it."""
49
+ return list(dict.fromkeys(["Director", *GATE_OWNERS.get(gate.stage, []), *gate.agents]))
50
+
51
+
52
+ def actor(event: Event) -> str | None:
53
+ """The agent whose work this event is, or None (the human, the ledger, memory reads)."""
54
+ if event.type == "cost.reserved":
55
+ return event.payload.get("agent")
56
+ if event.type in QUIET or event.agent not in AGENTS:
57
+ return None
58
+ if event.agent == "Director" and event.type == "game.progress":
59
+ message = str(event.payload.get("message", ""))
60
+ return next((a for p, a in NARRATION if re.search(p, message)), "Director")
61
+ return event.agent
62
+
63
+
64
+ class AgentSummary(BaseModel):
65
+ name: str
66
+ status: str # "waiting" | "working" | "needs_you" | "done"
67
+ needs_you: int # open gates this agent's chat holds
68
+ artifacts: int
69
+ calls: int
70
+ images: int
71
+ cost: float
72
+ allowance: float # what it spent since the human's last action (its cap: budget.max_agent_cost)
73
+ last: str | None # its latest progress message
74
+
75
+
76
+ def summarize(store: Store, run_id: str, busy: bool) -> list[AgentSummary]:
77
+ """`busy`: a worker is stepping this run now (so its latest actor is working)."""
78
+ stats = {
79
+ a: {"artifacts": 0, "calls": 0, "images": 0, "cost": 0.0, "allowance": 0.0, "last": None}
80
+ for a in AGENTS
81
+ }
82
+ seen, attempts, current = set(), {}, None
83
+ for event in store.events(run_id):
84
+ who = actor(event)
85
+ if event.type in ALLOWANCE_EVENTS: # a human action: every agent's allowance restarts
86
+ for s in stats.values():
87
+ s["allowance"] = 0.0
88
+ if event.type == "cost.reserved":
89
+ # Calls booked before agents were recorded go to whoever was working then.
90
+ attempts[event.payload["attempt_id"]] = who or current or "Director"
91
+ elif event.type in {"cost.succeeded", "cost.failed"}:
92
+ usage = event.payload["usage"]
93
+ agent = attempts.get(event.payload["attempt_id"])
94
+ if agent in stats and event.type == "cost.succeeded":
95
+ stats[agent]["calls"] += 1
96
+ stats[agent]["images"] += bool(usage.get("image"))
97
+ stats[agent]["cost"] += usage.get("estimated_cost") or 0.0
98
+ stats[agent]["allowance"] += usage.get("estimated_cost") or 0.0
99
+ if who:
100
+ seen.add(who)
101
+ current = who
102
+ if event.type == "artifact.created":
103
+ stats[who]["artifacts"] += 1
104
+ elif event.type == "game.progress":
105
+ stats[who]["last"] = event.payload.get("message")
106
+ gate = Gates(store).pending(run_id)
107
+ waiting_on = set(owners(gate)) if gate else set()
108
+ result = []
109
+ for name in AGENTS:
110
+ if name in waiting_on:
111
+ status = "needs_you"
112
+ elif busy and not gate and name == current:
113
+ status = "working"
114
+ elif name in seen:
115
+ status = "done"
116
+ else:
117
+ status = "waiting"
118
+ result.append(
119
+ AgentSummary(
120
+ name=name,
121
+ status=status,
122
+ needs_you=int(name in waiting_on),
123
+ **{**stats[name], "cost": round(stats[name]["cost"], 6),
124
+ "allowance": round(stats[name]["allowance"], 6)},
125
+ )
126
+ )
127
+ return result
128
+
129
+
130
+ def item(event: Event, kind: str, **fields) -> dict:
131
+ return {"id": event.id, "at": event.timestamp, "agent": event.agent, "kind": kind, **fields}
132
+
133
+
134
+ def thread(store: Store, run_id: str, agent: str, after: int = 0) -> list[dict]:
135
+ """The agent's chat as one timeline: its messages, progress, artifacts and the gates that
136
+ review its work. The Director's thread is the run's timeline: every agent's progress, every
137
+ gate and the run's milestones."""
138
+ director = agent == "Director"
139
+ gates = {g.id: g for g in Gates(store).all(run_id)}
140
+ metas = artifact_metas(store, run_id)
141
+ by_name = {m["name"]: m for m in metas.values()}
142
+
143
+ def readable(meta): # a JSON record is shown as the Markdown its agent wrote with it
144
+ twin = by_name.get(meta["name"].removesuffix(".json") + ".md")
145
+ return twin if meta["mime"] == "application/json" and twin else meta
146
+
147
+ applied = {c["id"]: c["applied"] for c in changes(store, run_id)}
148
+ offers = go_back_states(store, run_id)
149
+ items = []
150
+ for event in store.events(run_id, after):
151
+ kind, payload = event.type, event.payload
152
+ if kind == "chat.user" and payload.get("to") == agent:
153
+ fields = chat_fields(payload)
154
+ if event.id in applied: # a filed change: queued until a step takes it
155
+ fields["status"] = "applied" if applied[event.id] else "queued"
156
+ items.append(item(event, "chat", role="user", **fields))
157
+ elif kind == "chat.agent" and event.agent == agent:
158
+ fields = chat_fields(payload)
159
+ if "rewind" in payload:
160
+ fields["rewind"] = {**payload["rewind"], "state": offers.get(event.id, "open")}
161
+ items.append(item(event, "chat", role="agent", **fields))
162
+ elif kind == "agent.report" and event.agent == agent:
163
+ items.append(item(event, "report", report=payload))
164
+ elif kind == "reference.added" and payload.get("agent") in (agent, "Director"):
165
+ # The Director's references are every agent's: shown in every chat.
166
+ items.append(item(event, "reference", reference=payload))
167
+ elif kind == "round.rewound" and director:
168
+ items.append(item(event, "run", type=kind, message=payload.get("message")))
169
+ elif kind in {"gate.opened", "gate.responded"}:
170
+ gate = gates.get(payload.get("id"))
171
+ if gate is None or agent not in owners(gate):
172
+ continue
173
+ if kind == "gate.opened":
174
+ shown = [readable(metas[i]) for i in gate.artifacts_to_show if i in metas]
175
+ items.append(
176
+ item(event, "gate", gate=gate.model_dump(mode="json"), artifacts=shown)
177
+ )
178
+ else:
179
+ response = Gate.model_validate(payload).response
180
+ items.append(
181
+ item(
182
+ event,
183
+ "gate_response",
184
+ gate_id=gate.id,
185
+ title=gate.title,
186
+ status=payload.get("status"),
187
+ text=response.text if response else "",
188
+ chat=len((response.form if response else {}).get("chat_changes") or []),
189
+ )
190
+ )
191
+ elif kind == "agent.tool" and event.agent == agent:
192
+ items.append(item(event, "progress", text=tool_text(payload)))
193
+ elif kind == "game.progress" and (director or actor(event) == agent):
194
+ items.append(item(event, "progress", text=str(payload.get("message", ""))))
195
+ elif kind == "artifact.created" and event.agent == agent:
196
+ fields = {k: payload.get(k) for k in ("id", "name", "mime")}
197
+ items.append(item(event, "artifact", artifact=fields))
198
+ elif director and kind in RUN_EVENTS:
199
+ items.append(item(event, "run", type=kind, message=payload.get("message")))
200
+ return items
201
+
202
+
203
+ def artifact_metas(store: Store, run_id: str) -> dict[str, dict]:
204
+ """id -> {id, name, mime} of every artifact of the run, without loading the data."""
205
+ with store.connect() as db:
206
+ rows = db.execute("SELECT id,name,mime FROM artifacts WHERE run_id=?", (run_id,))
207
+ return {r["id"]: dict(r) for r in rows}
208
+
209
+
210
+ def go_back_states(store: Store, run_id: str) -> dict[int, str]:
211
+ """Each Go back card's state: "open", "going_back" (stop requested), "went_back" (the round
212
+ started again), "queued" (Queue for later) or "expired" (the round reached its gate or was
213
+ redone before an answer)."""
214
+ stops, declined, out, cards = {}, set(), {}, []
215
+ rewound, opened = [], []
216
+ for e in store.events(run_id):
217
+ if e.type == "chat.agent" and "rewind" in e.payload:
218
+ cards.append((e.id, e.payload["rewind"]["change"]))
219
+ elif e.type == "run.stop_requested":
220
+ stops.update(dict.fromkeys(e.payload.get("changes", []), e.id))
221
+ elif e.type == "rewind.declined":
222
+ declined.add(e.payload.get("change"))
223
+ elif e.type == "round.rewound":
224
+ rewound.append(e.id)
225
+ elif e.type == "gate.opened":
226
+ opened.append(e.id)
227
+ for card, change in cards:
228
+ if change in stops:
229
+ out[card] = "went_back" if any(r > stops[change] for r in rewound) else "going_back"
230
+ elif change in declined:
231
+ out[card] = "queued"
232
+ elif any(i > card for i in rewound + opened):
233
+ out[card] = "expired"
234
+ return out
235
+
236
+
237
+ def tool_text(payload: dict) -> str:
238
+ """An agent's tool call as a line of its chat: 'Looked up with library.library_search
239
+ (query: slime)'."""
240
+ args = ", ".join(f"{k}: {v}" for k, v in (payload.get("arguments") or {}).items()
241
+ if str(v).strip())[:120] # fmt: skip
242
+ failed = "" if payload.get("ok", True) else " (it failed)"
243
+ return f"Looked up with {payload.get('tool', 'a tool')}" + (f" ({args})" if args else "") + failed
244
+
245
+
246
+ def chat_fields(payload: dict) -> dict:
247
+ keys = ("text", "intent", "status", "error")
248
+ return {k: payload[k] for k in keys if k in payload}