spritegen-cli 0.2.0__tar.gz → 0.4.0__tar.gz

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 (161) hide show
  1. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.gitignore +9 -0
  2. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/PKG-INFO +1 -1
  3. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0003-append-only-jsonl-ledger.md +2 -1
  4. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0006-centralise-configuration-and-never-cache-it.md +2 -1
  5. spritegen_cli-0.4.0/docs/adr/0015-record-the-call-before-the-files-it-writes.md +53 -0
  6. spritegen_cli-0.4.0/docs/adr/0016-a-workspace-is-marked-by-the-file-that-configures-it.md +63 -0
  7. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/codewiki/spending-money.md +5 -5
  8. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/codewiki/the-stage-registry.md +10 -5
  9. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/codewiki/the-workspace.md +9 -9
  10. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/glossary.md +3 -0
  11. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/notes.md +4 -0
  12. spritegen_cli-0.4.0/plans/code-health.md +119 -0
  13. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/pyproject.toml +1 -1
  14. spritegen_cli-0.4.0/specs/workspace-config/design.md +98 -0
  15. spritegen_cli-0.4.0/specs/workspace-config/requirements.md +50 -0
  16. spritegen_cli-0.4.0/specs/workspace-config/tasks.md +25 -0
  17. spritegen_cli-0.4.0/src/spritegen/__init__.py +27 -0
  18. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/atlas.py +29 -11
  19. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/cli.py +31 -6
  20. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/clip.py +84 -32
  21. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/drive.py +14 -5
  22. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/fal.py +45 -8
  23. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/imaging.py +24 -3
  24. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/ledger.py +43 -4
  25. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/matting.py +16 -2
  26. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/migrate.py +1 -1
  27. spritegen_cli-0.4.0/src/spritegen/report.py +284 -0
  28. spritegen_cli-0.4.0/src/spritegen/settings.py +420 -0
  29. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/sheet.py +7 -4
  30. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/skill/__init__.py +128 -19
  31. spritegen_cli-0.4.0/src/spritegen/stages/__init__.py +80 -0
  32. spritegen_cli-0.4.0/src/spritegen/stages/_common.py +105 -0
  33. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/stages/anchor.py +7 -19
  34. spritegen_cli-0.2.0/src/spritegen/stages/__init__.py → spritegen_cli-0.4.0/src/spritegen/stages/catalog.py +47 -155
  35. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/stages/matte.py +17 -4
  36. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/stages/motion.py +30 -19
  37. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/stages/pose.py +10 -27
  38. spritegen_cli-0.4.0/src/spritegen/stages/registry.py +59 -0
  39. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/stages/video.py +16 -61
  40. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/upscale.py +43 -16
  41. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/workspace.py +119 -277
  42. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/conftest.py +41 -0
  43. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_anchor.py +4 -12
  44. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_board_stage.py +3 -10
  45. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_cli.py +54 -0
  46. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_clip.py +54 -0
  47. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_fal.py +120 -0
  48. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_imaging.py +24 -0
  49. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_ledger.py +59 -0
  50. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_matte.py +29 -8
  51. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_matting.py +36 -3
  52. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_migrate.py +5 -3
  53. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_motion.py +26 -11
  54. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_pose.py +6 -8
  55. spritegen_cli-0.4.0/tests/test_settings.py +462 -0
  56. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_sheet.py +24 -0
  57. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_show.py +3 -3
  58. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_skill.py +157 -0
  59. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_stages.py +30 -0
  60. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_upscale.py +116 -3
  61. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_versions.py +0 -7
  62. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_video.py +3 -11
  63. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_workspace.py +164 -24
  64. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/uv.lock +217 -195
  65. spritegen_cli-0.2.0/src/spritegen/__init__.py +0 -3
  66. spritegen_cli-0.2.0/src/spritegen/settings.py +0 -127
  67. spritegen_cli-0.2.0/tests/test_settings.py +0 -165
  68. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/agents/code-review.md +0 -0
  69. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/agents/security-review.md +0 -0
  70. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/commands/scc-adr.md +0 -0
  71. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/commands/scc-codewiki.md +0 -0
  72. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/commands/scc-glossary.md +0 -0
  73. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/commands/scc-init.md +0 -0
  74. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/commands/scc-plan-run.md +0 -0
  75. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/commands/scc-prd.md +0 -0
  76. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/commands/scc-stack.md +0 -0
  77. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/commands/scc-wiki.md +0 -0
  78. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/artifacts.md +0 -0
  79. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/autonomy.md +0 -0
  80. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/caveman.md +0 -0
  81. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/code-search.md +0 -0
  82. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/delivery.md +0 -0
  83. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/knowledge-base.md +0 -0
  84. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/methodology.md +0 -0
  85. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/notes.md +0 -0
  86. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/prior-art.md +0 -0
  87. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/project.md +0 -0
  88. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/routing.md +0 -0
  89. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/specs.md +0 -0
  90. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/tasks.md +0 -0
  91. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/rules/verification.md +0 -0
  92. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/scc-manifest.json +0 -0
  93. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/skills/adr/SKILL.md +0 -0
  94. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/skills/codewiki/SKILL.md +0 -0
  95. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/skills/glossary/SKILL.md +0 -0
  96. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/skills/init/SKILL.md +0 -0
  97. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/skills/plan-run/SKILL.md +0 -0
  98. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/skills/prd/SKILL.md +0 -0
  99. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/skills/stack/SKILL.md +0 -0
  100. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.claude/skills/wiki/SKILL.md +0 -0
  101. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.env.template +0 -0
  102. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.gitattributes +0 -0
  103. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  104. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.github/workflows/release.yml +0 -0
  105. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/.python-version +0 -0
  106. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/CLAUDE.md +0 -0
  107. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/README.md +0 -0
  108. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0001-asset-directory-and-no-path-arguments.md +0 -0
  109. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0002-transfer-movement-instead-of-generating-frames.md +0 -0
  110. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0004-allow-list-every-downloaded-host.md +0 -0
  111. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0005-a-directory-per-artifact-kind.md +0 -0
  112. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0007-heavy-dependencies-are-optional-extras.md +0 -0
  113. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0008-local-backends-are-the-default.md +0 -0
  114. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0009-walk-the-redirect-chain-here.md +0 -0
  115. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0010-ci-on-three-operating-systems.md +0 -0
  116. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0011-keep-pixelfixer-out-of-the-distribution.md +0 -0
  117. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0012-publish-with-one-secret.md +0 -0
  118. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0013-require-python-3-13.md +0 -0
  119. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/adr/0014-every-run-is-a-version-and-the-state-names-the-chosen-one.md +0 -0
  120. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/stack.md +0 -0
  121. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/changelog.md +0 -0
  122. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/index.md +0 -0
  123. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/pages/configuration.md +0 -0
  124. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/pages/grid-and-palette-recovery.md +0 -0
  125. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/pages/local-instead-of-paid.md +0 -0
  126. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/pages/motion-transfer.md +0 -0
  127. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/pages/paid-calls-and-the-ledger.md +0 -0
  128. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/pages/the-asset-directory.md +0 -0
  129. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/pages/the-generated-skill.md +0 -0
  130. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/docs/wiki/pages/the-pipeline.md +0 -0
  131. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/plans/motion-optimisation.md +0 -0
  132. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/specs/artifact-versions/design.md +0 -0
  133. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/specs/artifact-versions/requirements.md +0 -0
  134. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/specs/artifact-versions/tasks.md +0 -0
  135. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/endpoints.py +0 -0
  136. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/prompts.py +0 -0
  137. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/rrdb.py +0 -0
  138. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/skill/files/SKILL.md +0 -0
  139. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/src/spritegen/stages/board.py +0 -0
  140. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/helpers.py +0 -0
  141. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/anchor_crop.pixelart.json +0 -0
  142. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/anchor_crop.pixelart.png +0 -0
  143. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/synthetic_chroma.cut.json +0 -0
  144. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/synthetic_chroma.cut.png +0 -0
  145. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/synthetic_matted.json +0 -0
  146. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/synthetic_row.png +0 -0
  147. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/synthetic_video_board.json +0 -0
  148. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/synthetic_video_board.png +0 -0
  149. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/walk_south_row.gif +0 -0
  150. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/expected/walk_south_row.png +0 -0
  151. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/input/anchor_crop.png +0 -0
  152. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/input/synthetic_board.png +0 -0
  153. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/input/synthetic_chroma.png +0 -0
  154. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/input/synthetic_matted.png +0 -0
  155. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/input/walk_south_board.png +0 -0
  156. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/parity/manifest.json +0 -0
  157. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_atlas.py +0 -0
  158. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_drive.py +0 -0
  159. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_parity.py +0 -0
  160. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/test_prompts.py +0 -0
  161. {spritegen_cli-0.2.0 → spritegen_cli-0.4.0}/tests/tests_fal_doubles.py +0 -0
@@ -217,3 +217,12 @@ __marimo__/
217
217
  # Streamlit
218
218
  .streamlit/secrets.toml
219
219
  assets/
220
+
221
+ # CodeGraph index — never committed
222
+ .codegraph/
223
+
224
+ # ai-jail sandbox configuration, local to a checkout
225
+ .ai-jail
226
+
227
+ # the spritegen workspace's own settings, and its key
228
+ .spritegen.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spritegen-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Sprite generation CLI — a few AI-generated images into a game-ready character sheet
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: av>=12.0
@@ -1,5 +1,6 @@
1
1
  ---
2
- status: accepted
2
+ status: superseded
3
+ superseded-by: 0015-record-the-call-before-the-files-it-writes
3
4
  ---
4
5
 
5
6
  # The record of a paid call is append-only JSONL
@@ -1,5 +1,6 @@
1
1
  ---
2
- status: accepted
2
+ status: superseded
3
+ superseded-by: 0016-a-workspace-is-marked-by-the-file-that-configures-it
3
4
  ---
4
5
 
5
6
  # One settings class, read fresh, with a ceiling on the upward search
@@ -0,0 +1,53 @@
1
+ ---
2
+ status: accepted
3
+ ---
4
+
5
+ # The ledger records a call before the files it writes
6
+
7
+ ## Context
8
+
9
+ `adr:0003-append-only-jsonl-ledger` puts one line per paid call in `ledger.jsonl`,
10
+ carrying the endpoint, the payload, the URLs returned and the sha256 of every file
11
+ written. A line can only carry those digests once the files exist, so the write had to
12
+ wait for them.
13
+
14
+ What sits between the endpoint answering and the files landing is a download, a decode
15
+ and a pack. Any of it can fail — a dropped transfer, a clip `av` will not open, a full
16
+ disk — and the money is already spent when it does. The ledger is the only record that
17
+ a call happened, and in exactly that case it recorded nothing at all: the failure it
18
+ exists to survive was the one it did not.
19
+
20
+ Appending is what makes the file safe to write from a run that dies, and it is also
21
+ what stops the line being completed later. A record cannot be both written early and
22
+ filled in afterwards.
23
+
24
+ ## Decision
25
+
26
+ A paid call is **two lines joined by a `call` id**, not one.
27
+
28
+ The first is written the moment the endpoint answers, before anything is downloaded:
29
+ `kind: "call"`, with the endpoint, the redacted payload and the URLs. The second is
30
+ written when the files land: `kind: "files"`, with the sha256 of each. A stage whose
31
+ whole output is what the call returned, with nothing to download, still writes one
32
+ line.
33
+
34
+ `summarise` counts a call where `kind` is not `"files"`, so the two lines fold to one
35
+ call and one set of files.
36
+
37
+ ## Consequences
38
+
39
+ A run that dies mid-download leaves a `call` line with no `files` line. That is the
40
+ point: the asset's ledger says the money went, and the absent second line says nothing
41
+ landed. `cost` reports the call.
42
+
43
+ A line written before this decision has no `kind`, and is read as a whole call on its
44
+ own — old ledgers keep totalling to what they always did, and the format stays
45
+ append-only, so nothing on disk is rewritten.
46
+
47
+ The cost is that a reader can no longer assume one line is one call, and that the two
48
+ lines of a call are adjacent only in practice — a concurrent run against one asset
49
+ would interleave them, and the `call` id is what makes that legible rather than the
50
+ line order.
51
+
52
+ This supersedes `adr:0003-append-only-jsonl-ledger` on the shape of a line only. Its
53
+ other decisions — append-only, upload URLs redacted, one file per asset — stand.
@@ -0,0 +1,63 @@
1
+ ---
2
+ status: accepted
3
+ ---
4
+
5
+ # A workspace is marked by the file that configures it
6
+
7
+ ## Context
8
+
9
+ `adr:0006-centralise-configuration-and-never-cache-it` reads settings from the
10
+ environment, the nearest `.env`, and the declared default, in that order. Where the
11
+ assets are was a separate question, answered by walking up from the working directory
12
+ looking for a directory named `spritegen/assets`.
13
+
14
+ That walk had nothing to recognise. A directory of the right name is not a claim to be
15
+ one, so the search could not tell a workspace from a coincidence — and its ceiling
16
+ admitted the home directory as a candidate. Run in `~/spritegen1/test1`, `init`
17
+ reported a workspace at `~/spritegen/assets` and adopted it: one workspace became every
18
+ project's, and a second project on the same machine was not possible without giving it
19
+ a repository first.
20
+
21
+ The two questions turned out to be one. A project that has its own assets usually wants
22
+ its own cell size, its own matte backend, and — where more than one account is in play —
23
+ its own key.
24
+
25
+ ## Decision
26
+
27
+ **A directory holding `.spritegen.json` is a workspace root.** Its assets are
28
+ `<root>/spritegen/assets`, and the nearest root at or above the working directory is
29
+ the one a command uses.
30
+
31
+ The same file carries the settings, as a JSON object whose names are the `Settings`
32
+ fields without the environment's prefix. It sits **between the environment and the
33
+ `.env`**: an exported variable still wins, which is what makes a one-off override work,
34
+ and a machine with one key in `~/.env` keeps working. A name in it that is not a setting
35
+ is refused rather than ignored.
36
+
37
+ **The home directory is a candidate only when it is where the command was typed.** A
38
+ marker there serves a command run in it and is never climbed into from below.
39
+
40
+ `init` creates the workspace where it was run, always. It writes the marker with no
41
+ credential in it, adds it to a `.gitignore` that exists, and warns when the marker is
42
+ already tracked by git.
43
+
44
+ ## Consequences
45
+
46
+ Several workspaces on one machine work, and they work without a repository: the marker
47
+ is the boundary that `.git` used to have to stand in for.
48
+
49
+ A credential can live in a file inside a project, which is why `init` writes it into
50
+ `.gitignore` and warns when git already has it. `allowed_hosts` is settable there too
51
+ (`adr:0004-allow-list-every-downloaded-host`) — the same reach a `.env` already had,
52
+ bounded by the same ceiling.
53
+
54
+ Two files now answer the same question, and that is the cost. `.env` is kept because
55
+ removing it would break every machine that has a key today; whether it eventually goes
56
+ is deliberately not decided here.
57
+
58
+ A workspace opened before this exists has no marker. It keeps working through the
59
+ unmarked fallback, which now holds the same line about the home directory — but it will
60
+ not be found from a sibling project, and `init` in its root is what gives it one.
61
+
62
+ This supersedes `adr:0006-centralise-configuration-and-never-cache-it` **on the list of
63
+ sources only**. One class, nothing cached, and the ceiling all stand.
@@ -6,7 +6,7 @@ goes wrong rather than because of a way it works.
6
6
 
7
7
  ## A URL an endpoint returned is still a URL somebody could have chosen
8
8
 
9
- [src/spritegen/fal.py:24-86]()
9
+ [src/spritegen/fal.py:24-88]()
10
10
 
11
11
  The allowed hosts are a fixed tuple plus whatever `SPRITEGEN_ALLOWED_HOSTS` adds. The
12
12
  scheme must be https, and an authority carrying userinfo is refused outright — `https://
@@ -16,7 +16,7 @@ check.
16
16
 
17
17
  ## The redirect chain is walked here, not by the client
18
18
 
19
- [src/spritegen/fal.py:88-156]()
19
+ [src/spritegen/fal.py:153-218]()
20
20
 
21
21
  This is the reason `httpx` is a dependency at all. A client following redirects itself
22
22
  would apply the host check to the URL that was handed in and to nothing after it, so one
@@ -25,7 +25,7 @@ hop out of the allowed set is enough to fetch from anywhere. Every hop is checke
25
25
 
26
26
  ## One door, and two refusals before anything is spent
27
27
 
28
- [src/spritegen/fal.py:166-211]()
28
+ [src/spritegen/fal.py:220-241]()
29
29
 
30
30
  `require_key` and the `dry_run` branch live in `call` rather than in each stage, so no
31
31
  stage can forget one. `_subscribe` is the only function that touches `fal_client`, and it
@@ -34,7 +34,7 @@ without an HTTP client and what lets a test stand in for the endpoint without on
34
34
 
35
35
  ## The record redacts what would otherwise be a key
36
36
 
37
- [src/spritegen/ledger.py:23-59]()
37
+ [src/spritegen/ledger.py:30-65]()
38
38
 
39
39
  `fal_client.upload_file` returns standing access to the uploaded file for anyone holding
40
40
  the link. Writing those into the ledger verbatim would turn a per-asset record into a way
@@ -44,7 +44,7 @@ named explicitly.
44
44
 
45
45
  ## One line, appended, never rewritten
46
46
 
47
- [src/spritegen/ledger.py:75-125]()
47
+ [src/spritegen/ledger.py:81-141]()
48
48
 
49
49
  JSONL and `append` rather than a document re-serialised whole, because a run that dies
50
50
  halfway has still spent the money. A format that rewrites the file to add a line can lose
@@ -5,9 +5,14 @@ builds its sub-commands from it, `workspace` checks order against it, and `skill
5
5
  generates the published tables out of it. Reading the file tells you what it holds;
6
6
  this page is why it holds it there rather than in the stages.
7
7
 
8
+ It is three files. `registry.py` is the two dataclasses a stage is described with,
9
+ `catalog.py` is the table itself — four fifths of the whole by line count and none of
10
+ its behaviour — and `__init__.py` is the lookup, which is what a reader of the registry
11
+ came for.
12
+
8
13
  ## An option is declared by the registry, not by the stage that takes it
9
14
 
10
- [src/spritegen/stages/__init__.py:21-35]()
15
+ [src/spritegen/stages/registry.py:12-26]()
11
16
 
12
17
  The obvious place for `--size` is inside `anchor`. It is here instead because `--help`
13
18
  has to be complete, and building help from the stages would mean importing five modules
@@ -16,7 +21,7 @@ value off the parsed namespace and knows nothing about how it was parsed.
16
21
 
17
22
  ## `requires` is any-one-of, because the pipeline forks
18
23
 
19
- [src/spritegen/stages/__init__.py:38-43]()
24
+ [src/spritegen/stages/registry.py:29-54]()
20
25
 
21
26
  `motion` and `video` produce the same artifact by different routes, and `matte` takes
22
27
  whichever ran. A field meaning *all of these* would have no way to express that, and the
@@ -24,7 +29,7 @@ fork is the shape of the tool rather than an exception in it.
24
29
 
25
30
  ## Kind and variant, once the stage stopped being the artifact
26
31
 
27
- [src/spritegen/stages/__init__.py:44-67]()
32
+ [src/spritegen/stages/registry.py:29-59]()
28
33
 
29
34
  `produces` was the stage's name for as long as a stage made one thing. `anchor` makes an
30
35
  anchor, box art or an icon; `board` closes one set of frames into as many art directions
@@ -38,7 +43,7 @@ Both name an option this stage already declares, and neither is turned into a pa
38
43
 
39
44
  ## Order is derived, and there is no list of it
40
45
 
41
- [src/spritegen/stages/__init__.py:440-473]()
46
+ [src/spritegen/stages/__init__.py:43-63]()
42
47
 
43
48
  `order` walks the table rather than reading a sequence somebody maintained beside it. A
44
49
  second record of the pipeline's shape would be the one that goes stale the first time a
@@ -46,7 +51,7 @@ stage is added.
46
51
 
47
52
  ## The module is resolved only when the command has been chosen
48
53
 
49
- [src/spritegen/stages/__init__.py:476-490]()
54
+ [src/spritegen/stages/__init__.py:66-80]()
50
55
 
51
56
  This is the reason the registry can describe the whole pipeline without importing Pillow,
52
57
  numpy or `fal_client`. It also decides the blast radius of a broken stage: an import
@@ -6,7 +6,7 @@ refusing things.
6
6
 
7
7
  ## Every upward search stops, and the stop is the security control
8
8
 
9
- [src/spritegen/workspace.py:72-103]()
9
+ [src/spritegen/workspace.py:70-101]()
10
10
 
11
11
  A command is typed from wherever is convenient, so searching upward for the project is
12
12
  right. Searching upward without a ceiling reaches the filesystem root, and everything
@@ -16,7 +16,7 @@ directory nobody chose to configure this tool.
16
16
 
17
17
  ## Where the assets are, and why the order is the design
18
18
 
19
- [src/spritegen/workspace.py:105-140]()
19
+ [src/spritegen/workspace.py:103-138]()
20
20
 
21
21
  The explicit variable wins first, which is what lets a test and a second collection of
22
22
  characters exist at all. The nearest existing `spritegen/assets/` comes next because that
@@ -25,9 +25,9 @@ else's, so the images belong to that project rather than to the tool.
25
25
 
26
26
  ## A name off a command line becomes a directory
27
27
 
28
- [src/spritegen/workspace.py:51-70]()
28
+ [src/spritegen/workspace.py:49-68]()
29
29
 
30
- [src/spritegen/workspace.py:142-170]()
30
+ [src/spritegen/workspace.py:140-168]()
31
31
 
32
32
  The forbidden set and the reserved names are Windows', and they are checked on every
33
33
  platform. An asset name arrives from an argument or from a state file written earlier,
@@ -36,7 +36,7 @@ on Windows — which is why CI runs the suite on three operating systems rather
36
36
 
37
37
  ## Kind, variant and version become a path in exactly one place
38
38
 
39
- [src/spritegen/workspace.py:440-469]()
39
+ [src/spritegen/workspace.py:476-505]()
40
40
 
41
41
  Every stage writes through this. That is what let layout 1 become layout 2 without each
42
42
  stage learning about the move, layout 2 become layout 3 the same way, and it is what
@@ -44,9 +44,9 @@ stage learning about the move, layout 2 become layout 3 the same way, and it is
44
44
 
45
45
  ## Which version a run writes, and which one it reads
46
46
 
47
- [src/spritegen/workspace.py:491-505]()
47
+ [src/spritegen/workspace.py:527-541]()
48
48
 
49
- [src/spritegen/workspace.py:650-692]()
49
+ [src/spritegen/workspace.py:667-684]()
50
50
 
51
51
  Two records go into picking a number and neither is trusted over the other: what the
52
52
  state file recorded, and what is on disk. They disagree when a run died between writing
@@ -64,7 +64,7 @@ has none, so a re-run cannot re-aim the paid stages that follow it — that is
64
64
 
65
65
  ## A recorded path is not trusted
66
66
 
67
- [src/spritegen/workspace.py:511-534]()
67
+ [src/spritegen/workspace.py:547-570]()
68
68
 
69
69
  The state file holds relative paths, and it is a file on disk that something else may
70
70
  have written. `inside` is the check that a recorded path still resolves within the asset
@@ -73,7 +73,7 @@ another asset's output because the state said so.
73
73
 
74
74
  ## What "ready" means when `requires` forks
75
75
 
76
- [src/spritegen/workspace.py:378-418]()
76
+ [src/spritegen/workspace.py:414-454]()
77
77
 
78
78
  `satisfied` is where the registry's any-one-of semantics turn into a decision, and
79
79
  `next_stages` is what `status` prints. Both read the registry; neither keeps a copy of
@@ -9,6 +9,9 @@ directory `row/` to `sheet/as-is/` — retired a spelling whose word is still in
9
9
  use elsewhere, and listing it would report every correct use as a finding. Add an
10
10
  `Avoid:` the first time a genuinely dead, distinctive name turns up.
11
11
 
12
+ - **workspace** — a directory holding `.spritegen.json`, and the assets under `spritegen/assets` inside it. One machine carries several, and a command uses the nearest one at or above where it was typed.
13
+ - **workspace root** — the directory the marker is in, which is what `status` names and what `init` creates in. Not the assets directory, which sits inside it.
14
+ - **marker** — `.spritegen.json`: the file that says a directory is a workspace, and holds that workspace's settings. Avoid: config file, settings file
12
15
  - **asset** — everything belonging to one character, under `assets/<name>/`. The unit `new`, `status`, `show` and `cost` all talk about, and the reason no stage takes an input or output path.
13
16
  - **state file** — `state.json` inside an asset, the only record of progress. It holds the stages that have completed, never what should happen next.
14
17
  - **stage** — one step of the pipeline, declared in `stages.STAGES` and implemented by the module of the same name. Most spend money; `board` does not.
@@ -41,3 +41,7 @@ over this file answers with the example above as well as with the notes. -->
41
41
  - n-0001 2026-08-28 #gotcha @src/spritegen/workspace.py — Context.version is resolved on the first ask for out and kept, so a stage that opens its output twice in one run writes both halves to the same version
42
42
  - n-0002 2026-08-28 #gotcha @tests/conftest.py — settings.DEFAULT_CACHE is built from Path.home() at import, so conftest moving home afterwards does not move the cache; the suite points SPRITEGEN_CACHE at a temporary directory instead
43
43
  - n-0003 2026-08-28 #gotcha @pyproject.toml — the assets root spritegen/ in the repo root shadows the package name, so coverage source=[spritegen] resolves to the empty assets directory and collects nothing — source_pkgs is the unambiguous form
44
+ - n-0004 2026-08-28 #gotcha @src/spritegen/sheet.py — the GIF preview writes one global palette plus one shared local palette whatever the frames were quantised with — Pillow unifies on save, so quantising per frame and quantising the strip produce the same file
45
+ - n-0005 2026-08-28 #gotcha @src/spritegen/upscale.py — the weight cache sidecar must stay a content hash — size and mtime are forgeable with the same write access a swap needs, so a stat-based fast path silently disables the check
46
+ - n-0006 2026-08-28 #gotcha @docs/codewiki/the-workspace.md — a codewiki citation that still resolves after a refactor is not still correct — the range shifts onto a neighbouring function and validate cannot tell, so check what each range opens on
47
+ - n-0007 2026-08-29 #gotcha @src/spritegen/__init__.py — the CLI version came from a hand-written __version__ in spritegen/__init__.py, a second copy of what pyproject.toml declares — it read 0.1.0 through three releases; it is importlib.metadata now and a test pins the two together
@@ -0,0 +1,119 @@
1
+ ---
2
+ autonomy: auto
3
+ ci: wait
4
+ status: approved
5
+ pr: per-group
6
+ merge: auto
7
+ checksum: a381e0be7b8276b7d56f6be879477d27acc6bab7600bae8957b6bee7d8d0b577
8
+ ---
9
+
10
+ # Code health
11
+
12
+ Clear what a full read of `src/` and `tests/` turned up: ten defects, seven places
13
+ that repeat work already done, four modules that carry more than one job, and a test
14
+ suite that pays for video encoding no assertion reads.
15
+
16
+ ## Why
17
+
18
+ None of this was found by a failure — the suite is green and has been. It is what a
19
+ deliberate read found sitting under green tests, which is why it is worth landing as
20
+ one piece of work rather than waiting for each item to announce itself. Three of the
21
+ defects lose something a user cannot get back: a paid call whose ledger line is never
22
+ written, a re-fetch that deletes the good file it was going to replace, and a weight
23
+ file that stops being checked the moment its digest sidecar goes missing. The rest is
24
+ waste that compounds — a 64 MB model re-read per image, a colour count that allocates
25
+ one tuple per pixel of an upscaled frame, twenty-five tests re-encoding the same clip
26
+ at the slowest x264 preset. Done when the defects are fixed with tests that would have
27
+ caught them, the repeated work happens once, the four oversized modules are split
28
+ along the seams the survey mapped, and the suite is faster with no coverage lost.
29
+
30
+ ## Paths
31
+
32
+ - `src/spritegen/` — `workspace.py`, `fal.py`, `upscale.py`, `imaging.py`, `clip.py`,
33
+ `drive.py`, `matting.py`, `sheet.py`, `cli.py`, `atlas.py`, `ledger.py`
34
+ - `src/spritegen/stages/` — `__init__.py`, `motion.py`, `pose.py`, `video.py`,
35
+ `anchor.py`, `matte.py`, `board.py`
36
+ - `src/spritegen/skill/__init__.py`
37
+ - `tests/` — `conftest.py`, `helpers.py`, `test_motion.py`, `test_video.py`
38
+
39
+ ## References
40
+
41
+ - `adr:0003-append-only-jsonl-ledger` — one line per paid call, carrying the sha256 of
42
+ every file written. Task 1.1 cannot hold to both halves at once and supersedes it.
43
+ - `adr:0006-centralise-configuration-and-never-cache-it` — nothing is cached and
44
+ `load()` builds a new object every call. Task 3.5 resolves once per command and
45
+ passes it down, which is the shape that ADR permits; a module-level cache is not.
46
+ - `adr:0004-allow-list-every-downloaded-host` — the host check task 1.9 walks results
47
+ for.
48
+ - `adr:0007-heavy-dependencies-are-optional-extras` — why `available()` exists at all,
49
+ which task 4.7 decides the fate of.
50
+
51
+ ## Out of scope
52
+
53
+ - The unbounded recursion in `fal.seed_in` and `fal.urls_in`: a hostile response nested
54
+ a thousand levels deep raises `RecursionError` after billing. Hosts are allow-listed
55
+ and the impact is one denied run; it is filed on PR #17 and belongs with a review of
56
+ what else trusts endpoint JSON shape.
57
+ - Introducing `ruff format`. No formatter is configured here and adding one rewrites
58
+ the tree in a commit nobody can read.
59
+ - Raising the coverage floor. The real number is 91.36% against a floor of 86, but it
60
+ has been there for one day.
61
+
62
+ ## Tasks
63
+
64
+ - [x] 1.1 (TDD) Record the paid call in the ledger before the files it writes, and supersede `adr:0003` with the two-line shape
65
+ - [x] 1.2 (TDD) Download to a partial file and rename on success, so a failed re-fetch cannot delete the good file
66
+ _Depends 1.1_
67
+ - [x] 1.3 (TDD) Write the weight digest when the sidecar is missing, and stop the `finally` arm deleting a completed download
68
+ - [x] 1.4 (Unit) Default `--device` to `None` so the setting is reachable and a dry run names the device the real run will use
69
+ - [x] 1.5 (Unit) Make `matte --mask` on the local backend either write the mask or say it cannot
70
+ - [ ] 1.6 (Unit) Quantise the GIF preview once over the strip instead of a palette per
71
+ frame
72
+ _Status removed_
73
+ _Reason measured against the parity fixture: Pillow already writes one global palette plus one shared local palette whatever the frames were quantised with, so the per-frame ADAPTIVE call produces the same two palettes the strip does — the change grew the file by 800 bytes, altered no palette, and broke a documented parity baseline_
74
+ - [x] 1.7 (Unit) Map transport, codec and OS errors to exit codes, and narrow the catch that turns an internal bug into a user message
75
+ - [x] 1.8 (Unit) Name the file when `state.json` does not parse
76
+ - [x] 1.9 (Unit) Keep walking under a `url` key whose value is not a string
77
+ - [x] 1.10 (Unit) Generate the skill's free-command table from the parser instead of maintaining a second copy
78
+ - [x] 2.1 (Unit) Build the upscale network once per command and pass it to each file
79
+ - [x] 2.2 (Unit) Count colours before the upscale and without a tuple per pixel
80
+ - [x] 2.3 (TDD) Choose the wanted frame indices before decoding, and keep only those
81
+ - [ ] 2.4 (Unit) Re-hash a cached weight only when its size or mtime changed
82
+ _Status removed_
83
+ _Reason the win it chased was already delivered by 2.1 — with the network built once per command the weight is hashed once per command either way — and the mechanism defeated the integrity check beside it: size and mtime are forgeable with the same write access the attack needs, so a matching stamp let a swapped weight through unhashed and silent_
84
+ - [x] 2.5 (Unit) Resolve settings once per command and pass them down, adding no cache
85
+ _Depends 1.4_
86
+ - [x] 2.6 (Unit) Measure alpha in memory, read the sheet once per build, and skip the crops a run without a GIF throws away
87
+ - [x] 2.7 (Unit) Compare found against recorded with sets, not lists
88
+ - [x] 3.1 (Unit) Move the stage catalogue out of `stages/__init__.py` into its own module
89
+ - [ ] 3.2 (Unit) Declare the chroma, prompt, reference and frame-extraction options once
90
+ and splice them into each stage
91
+ _Depends 3.1_
92
+ _Status removed_
93
+ _Reason only the chroma and despill cluster was duplicated verbatim, and that is done. The prompt, reference and frame-extraction options share flags, kinds and defaults but not their help text: motion explains that the driving cycle caps --frames and video explains that an image-to-video clip opens on a still input image, which is the part of an option worth having. Replaced by 3.8_
94
+ - [x] 3.3 (Unit) Give `Context` the anchor lookup the three stages copy, and drop the cross-stage import
95
+ - [x] 3.4 (Unit) Extract the reference-file check, the missing-URL guard and the chroma finish that each exist three or four times
96
+ _Depends 3.3_
97
+ - [x] 3.5 (Unit) Extract the layout refusal and the artifact-key composition to one place each
98
+ - [x] 3.6 (Unit) Have the two grid packers share their arithmetic
99
+ - [x] 3.7 (Unit) Split `workspace.py` along the seams the survey mapped, presentation first
100
+ _Depends 3.3, 3.5_
101
+ - [x] 4.1 (Unit) Delete what nothing calls, and decide whether the availability helpers get a caller or go
102
+ - [x] 5.1 (Unit) Stop the motion tests encoding at 720 and the slowest preset
103
+ - [ ] 5.2 (Unit) Share the cache across the session so the same driving clip encodes
104
+ once
105
+ _Depends 5.1_
106
+ _Status removed_
107
+ _Reason measured after 5.1: an encode is 20ms at the cell size the tests now use, so sharing the cache across the session saves about 1s of a 20.8s suite, bought with mutable state shared across 799 tests against the one fixture whose purpose is isolating them_
108
+ - [x] 5.3 (Unit) Move the fixtures copied across eleven files into `conftest.py`
109
+ _Depends 5.1_
110
+ - [x] 3.8 (Unit) Declare the chroma and despill options once and splice them into the
111
+ three stages that cut
112
+ _Reason 3.2 asked for four option groups and only one of them was duplication; this is what was actually built_
113
+
114
+ ## Done when
115
+
116
+ - `uv run pytest -q --cov` passes with coverage no lower than 91.36%.
117
+ - `uv run ruff check src tests` and `scc validate` both exit 0.
118
+ - Every task above is ticked or struck out with a reason.
119
+ - The suite's wall-clock time is lower than the 34 s it takes now, measured the same way.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spritegen-cli"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Sprite generation CLI — a few AI-generated images into a game-ready character sheet"
5
5
  requires-python = ">=3.13"
6
6
 
@@ -0,0 +1,98 @@
1
+ # Workspace configuration — design
2
+
3
+ ## What changes, and where
4
+
5
+ Today a workspace is found by looking for a directory that happens to be named
6
+ `spritegen/assets`, walking up from the working directory. That has no marker, so the
7
+ walk cannot tell a workspace from a coincidence — and it reaches the home directory,
8
+ where one workspace becomes every project's. Run from `~/spritegen1/test1`, `init`
9
+ reported a workspace at `~/spritegen/assets`, which belongs to something else.
10
+
11
+ The change is a marker. `.spritegen.json` at the root of a workspace says *this is
12
+ one*, the way `package.json` does for npm and `pyproject.toml` for this repository. The
13
+ walk stops at the nearest one, so a second project one directory over is a second
14
+ workspace, and nothing has to be configured for that to be true.
15
+
16
+ The same file carries the settings, which is the other half: a credential and a cell
17
+ size belong to the project they are for, not to the shell that happened to run the
18
+ command.
19
+
20
+ - `settings.py` — a source between the environment and `.env`, and the search that
21
+ finds it.
22
+ - `workspace.py` — `assets_root` resolves through the marker rather than by looking for
23
+ a directory of the right name.
24
+ - `skill/__init__.py` — `cmd_init` creates in the working directory and writes the file.
25
+
26
+ ## The order settings are read in
27
+
28
+ Highest wins:
29
+
30
+ 1. **The environment.** `SPRITEGEN_CELL=200 spritegen board …` still overrides for one
31
+ run, which is `adr:0006`'s reason and does not change.
32
+ 2. **`.spritegen.json`** at the workspace root — the new one, and where a project's own
33
+ answer belongs.
34
+ 3. **The nearest `.env`** — kept, because a machine with one `FAL_KEY` for everything is
35
+ a real setup and breaking it buys nothing.
36
+ 4. **The declared default.**
37
+
38
+ A name in the file that is not a setting is refused rather than ignored: a typo that
39
+ does nothing is worse than one that says so, and this is the file a `fal_key` is
40
+ misspelled in.
41
+
42
+ ## The file
43
+
44
+ ```json
45
+ {
46
+ "fal_key": "…",
47
+ "cell": 166,
48
+ "matte_backend": "local"
49
+ }
50
+ ```
51
+
52
+ Every field is a `Settings` field under its own name, without the `SPRITEGEN_` prefix
53
+ the environment uses — the prefix exists to keep a shared namespace apart, and a file
54
+ of this tool's own has no such namespace to share.
55
+
56
+ ## The credential, and git
57
+
58
+ `init` writes the file with no credential in it, and adds it to `.gitignore` where one
59
+ exists. It cannot add it where there is no `.gitignore`: writing one is a decision
60
+ about a repository this tool does not own.
61
+
62
+ That leaves the case the guard is for — a `.spritegen.json` somebody committed before
63
+ putting a key in it. `git ls-files --error-unmatch` answers whether it is tracked in one
64
+ call and needs no repository when there is none, so a warning is cheap. It warns and
65
+ proceeds: refusing would make the tool unusable in exactly the situation somebody is
66
+ trying to fix.
67
+
68
+ ## What a file may point at
69
+
70
+ A path out of this file is **relative to the workspace and resolved under it** — R4.4,
71
+ R4.5. The environment keeps the unrestricted form, and the difference is who decided:
72
+ exporting a variable takes a shell, while this file arrives by being cloned, extracted,
73
+ or synced onto a mounted drive, and is then read for no reason but the command having
74
+ been run in that directory.
75
+
76
+ The reachable case is worse than a redirected output directory. `Path.is_dir()` on
77
+ `\host\share` opens an SMB connection and authenticates, so `status` — free, and
78
+ touching no network — would hand a credential to whoever wrote the file. So rooted
79
+ counts, not only absolute: on Windows `/etc/x` carries no drive and is not
80
+ `is_absolute()`, and it leaves the workspace all the same.
81
+
82
+ **`allowed_hosts` becomes settable from a file inside the project**
83
+ (`adr:0004-allow-list-every-downloaded-host`). That is the same reach a `.env` already
84
+ had, and the ceiling in R1.5 is what bounds both — worth stating rather than
85
+ discovering.
86
+
87
+ ## What this supersedes
88
+
89
+ `adr:0006-centralise-configuration-and-never-cache-it` names the environment, the
90
+ nearest `.env` and the default as the three sources, in that order. It gains a fourth
91
+ between the first two, and its other decisions — one class, nothing cached, the ceiling
92
+ — stand. A new record supersedes it on the source list alone.
93
+
94
+ ## Not decided here
95
+
96
+ Whether `.env` is eventually dropped. Two files for one job is one too many, but
97
+ removing one while adding the other would break every machine that has a key today,
98
+ and nothing forces the choice now.
@@ -0,0 +1,50 @@
1
+ ---
2
+ autonomy: auto
3
+ ci: wait
4
+ branch: feat/workspace-config
5
+ delivery: in-review
6
+ pr: 24
7
+ ---
8
+
9
+ # Workspace configuration — requirements
10
+
11
+ A workspace is marked by a file it holds, so one machine can carry several and a
12
+ command run inside one never reaches into another.
13
+
14
+ ## R1 — where the workspace is
15
+
16
+ - **R1.1** The system shall treat a directory holding `.spritegen.json` as a workspace root.
17
+ - **R1.2** When a command needs a workspace, the system shall use the nearest workspace root at or above the working directory.
18
+ - **R1.3** If no workspace root is at or above the working directory, then the system shall refuse and name `spritegen init`.
19
+ - **R1.4** While a workspace root is in use, the system shall keep the assets under `<root>/spritegen/assets`.
20
+ - **R1.5** The system shall stop the upward search at the first repository boundary or at the home directory.
21
+
22
+ ## R2 — opening one
23
+
24
+ - **R2.1** When `init` runs, the system shall create the workspace in the working directory.
25
+ - **R2.2** When `init` creates a workspace, the system shall write `.spritegen.json` in that directory.
26
+ - **R2.3** Where a `.gitignore` is at the workspace root, the system shall add `.spritegen.json` to it.
27
+ - **R2.4** If a workspace root is already at or above the working directory, then the system shall name it and create the new one anyway.
28
+ - **R2.5** If `.spritegen.json` is already in the working directory, then the system shall leave its contents alone.
29
+
30
+ ## R3 — what configuration is read
31
+
32
+ - **R3.1** The system shall read its settings from `.spritegen.json` at the workspace root.
33
+ - **R3.2** Where a setting is in the environment, the environment shall win over the file.
34
+ - **R3.3** Where a setting is in neither, the system shall use the `.env` the search finds, and then the default.
35
+ - **R3.4** If `.spritegen.json` does not parse, then the system shall refuse naming the file and the reason.
36
+ - **R3.5** If `.spritegen.json` holds a name that is not a setting, then the system shall refuse naming it.
37
+ - **R3.6** The system shall build its settings fresh on every read and cache nothing.
38
+
39
+ ## R4 — the key in it
40
+
41
+ - **R4.1** Where `.spritegen.json` holds `fal_key`, the system shall use it as the credential.
42
+ - **R4.2** When `init` writes `.spritegen.json`, the system shall write no credential into it.
43
+ - **R4.3** If `.spritegen.json` is tracked by git, then the system shall warn that a credential in it is committed.
44
+ - **R4.4** (ADDED) Where `.spritegen.json` sets a path, the system shall resolve it under the workspace root.
45
+ - **R4.5** (ADDED) If `.spritegen.json` sets a path that is absolute, rooted or outside the workspace, then the system shall refuse it.
46
+
47
+ ## R5 — saying which one
48
+
49
+ - **R5.1** When `status` runs, the system shall name the workspace root it is using.
50
+ - **R5.2** When `init` finishes, the system shall name the directory it created.
@@ -0,0 +1,25 @@
1
+ # Workspace configuration — tasks
2
+
3
+ ## Tasks
4
+
5
+ - [x] 1.1 (TDD) Find the workspace root by the marker file, bounded by the ceiling — R1.1, R1.2, R1.5
6
+ - [x] 1.2 (Unit) Resolve the assets under the root the marker names, and refuse when there is none — R1.3, R1.4
7
+ _Depends 1.1_
8
+ - [x] 2.1 (TDD) Read `.spritegen.json` between the environment and `.env`, refusing a name that is not a setting — R3.1, R3.2, R3.3, R3.4, R3.5, R3.6
9
+ _Depends 1.1_
10
+ - [x] 2.2 (Unit) Take the credential from the file — R4.1
11
+ _Depends 2.1_
12
+ - [x] 3.1 (Unit) Create the workspace where `init` was run, and write the marker with no credential in it — R2.1, R2.2, R2.4, R2.5, R4.2, R5.2
13
+ _Depends 1.2_
14
+ - [x] 3.2 (Unit) Add the marker to a `.gitignore` that is there — R2.3
15
+ _Depends 3.1_
16
+ - [x] 3.3 (Unit) Warn when the marker is tracked by git — R4.3
17
+ _Depends 3.1_
18
+ - [x] 4.1 (Unit) Name the workspace root in `status` — R5.1
19
+ _Depends 1.2_
20
+ - [x] 4.2 (Unit) Record the decision as an ADR superseding `adr:0006` on its source list, and the term in the glossary — R3.1
21
+ _Depends 2.1_
22
+ - [x] 3.4 (Unit) Stop the skill search climbing into the home directory's .claude — R1.5
23
+ _Reason the same defect in the other search: the docstring says it is bounded and it is not, so init in a fresh project reported the global skill as already there and set that project up with none_
24
+ - [x] 2.3 (Unit) Bound a path the marker sets to the workspace it is in — R4.4, R4.5
25
+ _Reason security review: a hostile marker could point assets at a UNC path, and stat on a share authenticates, so status alone leaked a credential_