taskops-cli 0.4.2__tar.gz → 0.5.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 (283) hide show
  1. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ARCHITECTURE.md +80 -10
  2. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/CHANGELOG.md +43 -0
  3. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/PKG-INFO +7 -5
  4. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/README.md +6 -4
  5. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_version.py +1 -1
  6. taskops_cli-0.5.0/src/taskops/gitwork/mirror.py +132 -0
  7. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/gitdoor.py +29 -48
  8. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/handler.py +28 -7
  9. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/mounts.py +11 -12
  10. taskops_cli-0.5.0/src/taskops/http/repos.py +98 -0
  11. taskops_cli-0.5.0/src/taskops/http/stale.py +62 -0
  12. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/static.py +30 -15
  13. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/ui/app.js +3 -3
  14. taskops_cli-0.5.0/tests/test_mirror.py +164 -0
  15. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_topology.py +187 -6
  16. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/git-diff.tsx +6 -2
  17. taskops_cli-0.5.0/ui/smoke/sections/hosted-window.tsx +96 -0
  18. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/Patch.tsx +1 -1
  19. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/links.tsx +5 -3
  20. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Reports.tsx +6 -5
  21. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.claude/settings.json +0 -0
  22. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.gitattributes +0 -0
  23. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.github/workflows/ci.yml +0 -0
  24. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.github/workflows/workflow.yml +0 -0
  25. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.gitignore +0 -0
  26. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.mcp.json +0 -0
  27. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/cache.sqlite +0 -0
  28. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/cache.sqlite-shm +0 -0
  29. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/cache.sqlite-wal +0 -0
  30. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/events.jsonl +0 -0
  31. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/live.sqlite +0 -0
  32. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/live.sqlite-shm +0 -0
  33. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/live.sqlite-wal +0 -0
  34. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board.json +0 -0
  35. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/reports/lifecycle-chapter.html +0 -0
  36. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/reports/reports-chapter.html +0 -0
  37. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/ui.lock +0 -0
  38. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/CLAUDE.md +0 -0
  39. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/pyproject.toml +0 -0
  40. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/__init__.py +0 -0
  41. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_clock.py +0 -0
  42. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_errors.py +0 -0
  43. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_ids.py +0 -0
  44. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_json.py +0 -0
  45. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_locate.py +0 -0
  46. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_wire.py +0 -0
  47. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/board.py +0 -0
  48. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/__init__.py +0 -0
  49. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/__main__.py +0 -0
  50. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/admin.py +0 -0
  51. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/claude.py +0 -0
  52. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/commands.py +0 -0
  53. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/enrol.py +0 -0
  54. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/github.py +0 -0
  55. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/grants.py +0 -0
  56. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/hooks.py +0 -0
  57. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/main.py +0 -0
  58. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/operate.py +0 -0
  59. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/paging.py +0 -0
  60. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/parser.py +0 -0
  61. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/pull.py +0 -0
  62. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/push.py +0 -0
  63. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/remote.py +0 -0
  64. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/rm.py +0 -0
  65. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/serving.py +0 -0
  66. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/team.py +0 -0
  67. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/watch.py +0 -0
  68. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/window.py +0 -0
  69. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/wording.py +0 -0
  70. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/__init__.py +0 -0
  71. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/actors.py +0 -0
  72. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/challenge.py +0 -0
  73. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/chapters.py +0 -0
  74. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/event.py +0 -0
  75. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/forge.py +0 -0
  76. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/graph.py +0 -0
  77. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/holding.py +0 -0
  78. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/hours.py +0 -0
  79. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/kinds.py +0 -0
  80. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/machine.py +0 -0
  81. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/mentions.py +0 -0
  82. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/replay.py +0 -0
  83. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/reports.py +0 -0
  84. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/review.py +0 -0
  85. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/scope.py +0 -0
  86. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/seams.py +0 -0
  87. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/types.py +0 -0
  88. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/__init__.py +0 -0
  89. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/bind.py +0 -0
  90. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/catchup.py +0 -0
  91. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/claudefiles.py +0 -0
  92. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/diff.py +0 -0
  93. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/install.py +0 -0
  94. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/landing.py +0 -0
  95. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/patch.py +0 -0
  96. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/remote.py +0 -0
  97. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/run.py +0 -0
  98. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/sig.py +0 -0
  99. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/trailer.py +0 -0
  100. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/trees.py +0 -0
  101. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/__init__.py +0 -0
  102. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/admin.py +0 -0
  103. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/auth.py +0 -0
  104. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/feed.py +0 -0
  105. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/grants.py +0 -0
  106. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/ingest.py +0 -0
  107. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/login.py +0 -0
  108. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/members.py +0 -0
  109. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/removal.py +0 -0
  110. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/rpc.py +0 -0
  111. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/scoped.py +0 -0
  112. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/server.py +0 -0
  113. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/upstream.py +0 -0
  114. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/watcher.py +0 -0
  115. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/identity.py +0 -0
  116. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/__init__.py +0 -0
  117. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/__main__.py +0 -0
  118. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/activity.py +0 -0
  119. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/before.py +0 -0
  120. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/boards.py +0 -0
  121. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/boardview.py +0 -0
  122. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/brief.py +0 -0
  123. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/chapter.py +0 -0
  124. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/dossier.py +0 -0
  125. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/fields.py +0 -0
  126. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/gitmoves.py +0 -0
  127. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/hello.py +0 -0
  128. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/integrate.py +0 -0
  129. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/orders.py +0 -0
  130. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/render.py +0 -0
  131. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/schema.py +0 -0
  132. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/server.py +0 -0
  133. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/thread.py +0 -0
  134. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/tools.py +0 -0
  135. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/py.typed +0 -0
  136. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/session.py +0 -0
  137. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/__init__.py +0 -0
  138. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/cache.py +0 -0
  139. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/creds.py +0 -0
  140. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/handover.py +0 -0
  141. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/live.py +0 -0
  142. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/log.py +0 -0
  143. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/pubkeys.py +0 -0
  144. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/reviews.py +0 -0
  145. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/server.py +0 -0
  146. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/stores.py +0 -0
  147. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/ui/index.html +0 -0
  148. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/ui/style.css +0 -0
  149. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/__init__.py +0 -0
  150. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_args.py +0 -0
  151. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_cards.py +0 -0
  152. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_chapter.py +0 -0
  153. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_context.py +0 -0
  154. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_facts.py +0 -0
  155. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_mentions.py +0 -0
  156. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_rows.py +0 -0
  157. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_stories.py +0 -0
  158. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_waiting.py +0 -0
  159. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_windows.py +0 -0
  160. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/activity.py +0 -0
  161. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/assign.py +0 -0
  162. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/card.py +0 -0
  163. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/events.py +0 -0
  164. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/filed.py +0 -0
  165. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/plan.py +0 -0
  166. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/project.py +0 -0
  167. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/pulse.py +0 -0
  168. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/record.py +0 -0
  169. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/report.py +0 -0
  170. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/review.py +0 -0
  171. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/take.py +0 -0
  172. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/update.py +0 -0
  173. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/__init__.py +0 -0
  174. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/conftest.py +0 -0
  175. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_architecture.py +0 -0
  176. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_claude.py +0 -0
  177. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_core.py +0 -0
  178. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_git.py +0 -0
  179. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_mcp.py +0 -0
  180. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_report.py +0 -0
  181. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_store.py +0 -0
  182. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_ui.py +0 -0
  183. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_verbs.py +0 -0
  184. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_window.py +0 -0
  185. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/ui_harness.js +0 -0
  186. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/build.mjs +0 -0
  187. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/index.html +0 -0
  188. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/package-lock.json +0 -0
  189. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/package.json +0 -0
  190. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/fixture.json +0 -0
  191. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/main.tsx +0 -0
  192. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/run.mjs +0 -0
  193. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/actors-window-filter.tsx +0 -0
  194. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/actors.tsx +0 -0
  195. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/all-chapters-scope.tsx +0 -0
  196. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/board-columns.tsx +0 -0
  197. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/board-flow-view.tsx +0 -0
  198. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/board-motion-and-avatars.tsx +0 -0
  199. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/chapter-completion-derivation.tsx +0 -0
  200. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/chapter-landed.tsx +0 -0
  201. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/chapter-story-grid.tsx +0 -0
  202. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/chapters-open.tsx +0 -0
  203. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/comment-box.tsx +0 -0
  204. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/comment-toasts-model.tsx +0 -0
  205. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/comment-toasts-stack.tsx +0 -0
  206. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/diff-page.tsx +0 -0
  207. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/dossier.tsx +0 -0
  208. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/event-stream.tsx +0 -0
  209. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/feed-reconnect.tsx +0 -0
  210. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/forge-links.tsx +0 -0
  211. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/forge-opens-the-board.tsx +0 -0
  212. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/monitor-panes.tsx +0 -0
  213. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/overlay-escape.tsx +0 -0
  214. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/report-markdown.tsx +0 -0
  215. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/report-sandbox.tsx +0 -0
  216. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/reviewing-skew.tsx +0 -0
  217. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/section.ts +0 -0
  218. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/swarm-topology.tsx +0 -0
  219. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/thread-closing-note.tsx +0 -0
  220. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/worktrees-index.tsx +0 -0
  221. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections.mjs +0 -0
  222. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/App.tsx +0 -0
  223. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/client.ts +0 -0
  224. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/completed.ts +0 -0
  225. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/actors/Daysheet.tsx +0 -0
  226. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/actors/DevPanel.tsx +0 -0
  227. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/CardTile.tsx +0 -0
  228. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/Column.tsx +0 -0
  229. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/ViewToggle.tsx +0 -0
  230. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/flip.ts +0 -0
  231. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/useFlip.ts +0 -0
  232. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/CommentBox.tsx +0 -0
  233. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/Drawer.tsx +0 -0
  234. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/Sections.tsx +0 -0
  235. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/Thread.tsx +0 -0
  236. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/split.ts +0 -0
  237. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/tokens.ts +0 -0
  238. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/AvatarStack.tsx +0 -0
  239. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/Header.tsx +0 -0
  240. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/KpiRail.tsx +0 -0
  241. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/MilestonePicker.tsx +0 -0
  242. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/TabNav.tsx +0 -0
  243. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/flow/FlowView.tsx +0 -0
  244. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/flow/layout.ts +0 -0
  245. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Chapter.tsx +0 -0
  246. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/DependencyChain.tsx +0 -0
  247. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/EditSurface.tsx +0 -0
  248. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/EventStream.tsx +0 -0
  249. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/LeaseHealth.tsx +0 -0
  250. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/LiveLeases.tsx +0 -0
  251. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Mentions.tsx +0 -0
  252. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Pane.tsx +0 -0
  253. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Swarm.tsx +0 -0
  254. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Throughput.tsx +0 -0
  255. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/panels.ts +0 -0
  256. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/reports/ReportFrame.tsx +0 -0
  257. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/Avatar.tsx +0 -0
  258. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/Markdown.tsx +0 -0
  259. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/Overlay.tsx +0 -0
  260. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/Segmented.tsx +0 -0
  261. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/overlayStack.ts +0 -0
  262. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/story/ChapterStory.tsx +0 -0
  263. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/story/stats.ts +0 -0
  264. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/toasts/ToastStack.tsx +0 -0
  265. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/toasts/model.ts +0 -0
  266. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/toasts/useToasts.ts +0 -0
  267. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/format.ts +0 -0
  268. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/hoursWindow.ts +0 -0
  269. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/main.tsx +0 -0
  270. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/markdown.ts +0 -0
  271. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Actors.tsx +0 -0
  272. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Board.tsx +0 -0
  273. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Monitor.tsx +0 -0
  274. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/WorktreeDiff.tsx +0 -0
  275. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Worktrees.tsx +0 -0
  276. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/theme/theme.ts +0 -0
  277. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/theme/tokens.css +0 -0
  278. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/types.ts +0 -0
  279. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/useBoard.ts +0 -0
  280. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/useEvents.ts +0 -0
  281. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/styles/index.css +0 -0
  282. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/tsconfig.json +0 -0
  283. {taskops_cli-0.4.2 → taskops_cli-0.5.0}/uv.lock +0 -0
@@ -1012,7 +1012,8 @@ Both remaining items are done. The migration ran against all four v1 boards
1012
1012
  directory of board logs, so it is `pip install` into a venv and one pm2 entry
1013
1013
  (README, "Deployed"). **The box runs this tree** since 2026-08-09 (tk-df8e64,
1014
1014
  §17), so `https://taskops.bernardocastro.dev/<board>/ui/` answers `410` and one
1015
- sentence, exactly as the trunk does (§16, §17). Re-derivable:
1015
+ sentence, exactly as the trunk does (§16, §17) — until a board with a declared
1016
+ forge gets its hosted window (§16, "The hosted window"). Re-derivable:
1016
1017
  `curl -s https://taskops.bernardocastro.dev/healthz` and
1017
1018
  `curl -w '%{http_code}' https://taskops.bernardocastro.dev/axion/ui/`
1018
1019
  — measured `{"boards": 4}` (after all four boards had been addressed through
@@ -1505,15 +1506,18 @@ construction and never sniffed per request** (`Mounts(repo=…)`,
1505
1506
  | `taskops serve` | a directory of boards, no checkout | **404 with the reason spelled out**, and nothing faked | **410 and one sentence** |
1506
1507
 
1507
1508
  The `/ui/` column arrived later, and it is the same `repo` deciding both — see
1508
- "API ONLY is now literal" below.
1509
+ "API ONLY is now literal" below. Both cells of the `serve` row are narrowed a
1510
+ second time by "The hosted window" below: a board that DECLARED a forge may be
1511
+ answered from a bare read-only mirror of it, and only then.
1509
1512
 
1510
1513
  A viewer meets that gap whenever this clone cannot answer — a ref not fetched
1511
1514
  yet, a shallow checkout, a commit from before this repo had the branch — and
1512
1515
  there the UI's declared cascade takes over: numstat from the event → patch from
1513
1516
  /git → the forge link if a slug exists → one honest sentence. No dead anchor, no
1514
1517
  empty pane pretending. (The gap used to have a second, worse shape: a browser
1515
- with no clone at all, pointed at the hosted page. There is no hosted page any
1516
- moresee below.)
1518
+ with no clone at all, pointed at the hosted page. That page was withdrawn see
1519
+ belowand comes back only standing on a real history: the forge mirror, in
1520
+ "The hosted window".)
1517
1521
 
1518
1522
  That cascade is ONE function — `ui/src/links.tsx::cascade`, beside the slug and
1519
1523
  the link templates it already owned — and `ui/src/components/card/Patch.tsx`
@@ -1595,9 +1599,11 @@ doubled separator and the directory itself, and never repairs one: a traversal
1595
1599
  normalised into something acceptable is the bug. Anything outside
1596
1600
  `.taskops/reports/` is a 400 that names the directory, and `tests/test_topology.py`
1597
1601
  walks every shape (a real secret one directory up included, asserting its
1598
- contents appear nowhere in the answer). `taskops serve` still mounts no `/git`
1599
- at all: the switch is `Mounts.repo`, decided once at construction, and a new
1600
- question underneath it opens no door.
1602
+ contents appear nowhere in the answer). `taskops serve` mounts no `/git`
1603
+ from a checkout it does not have: the switch is `Mounts.repo`, decided once at
1604
+ construction, and a new question underneath it opens no door. The hosted-window
1605
+ amendment below changes what `repo` may point AT — a bare mirror of the
1606
+ declared forge — never the switch itself.
1601
1607
 
1602
1608
  ### The window: `taskops ui` serves locally even for a REMOTE board (2026-08-08)
1603
1609
 
@@ -1636,7 +1642,7 @@ The routes do not change, so the committed bundle is untouched by any of this:
1636
1642
  decision). The server used to serve the bundle too, from a `--ui` flag with a
1637
1643
  packaged default — so the box above answered `/ui/` with a real dashboard, and
1638
1644
  that dashboard could not draw a single patch, because the machine it was served
1639
- from deliberately has no clone. It was a degraded window presented as *the*
1645
+ from deliberately had no clone. It was a degraded window presented as *the*
1640
1646
  window; the redirect above merely pointed people at it. **The binary serves the
1641
1647
  window; the server serves the truth.**
1642
1648
 
@@ -1644,7 +1650,9 @@ So: `Mounts.ui` is not a parameter, it is `repo`'s shadow — one construction-t
1644
1650
  switch mounts `/git` AND the bundle, and only a process standing in a checkout
1645
1651
  has either. `/ui/` on a board host answers `static.py::NO_UI` as plain text with
1646
1652
  **410 Gone**, not 404: 404 says "no idea, maybe later", and this page was
1647
- withdrawn on purpose and is never coming back to a host with no clone. Plain
1653
+ withdrawn on purpose and is never coming back to a host with no clone — and a
1654
+ host holding the board's forge MIRROR is no longer that host ("The hosted
1655
+ window", below). Plain
1648
1656
  text and not the JSON envelope because the only reader who reaches that door is
1649
1657
  a human with a browser. The `--ui` flag is REMOVED, not deprecated: there is no
1650
1658
  command line that puts the decision back. The bundle still ships inside the
@@ -1709,6 +1717,66 @@ move a branch under a worktree somebody is sitting in.
1709
1717
 
1710
1718
  A repo joined to nothing behaves exactly as it did.
1711
1719
 
1720
+ ### The hosted window: the host may hold a MIRROR of the declared forge (decided 2026-08-30)
1721
+
1722
+ The first amendment narrowed "no server reads a repo" to "a host that sits in a
1723
+ repo may read it". This one narrows the other clause — *the server deliberately
1724
+ has no clone* — and, as before, the change is recorded here rather than quietly
1725
+ made:
1726
+
1727
+ > **A board host MAY hold a bare, READ-ONLY mirror of the board's DECLARED
1728
+ > forge — and only then does it answer `/git` and `/ui/`.** A board with no
1729
+ > declared forge is exactly what it was: 404 on `/git`, 410 on `/ui/`, each
1730
+ > refusal naming `taskops board forge <owner>/<repo>` as the door.
1731
+
1732
+ Why this is not the degraded window "API ONLY" withdrew. That window's flaw was
1733
+ never that a server drew a dashboard — it was that the machine drawing it had
1734
+ NO git at all, so every diff fell through the whole cascade to a forge link or
1735
+ a sentence, a blank presented as *the* window. The mirror removes the flaw, not
1736
+ the caution: it is **the same history the forge already holds** — `git clone
1737
+ --mirror` of the repo the owner declared with `board forge`, refreshed by ONE
1738
+ bounded on-demand fetch when a requested ref is missing, then answered stale
1739
+ rather than blocking — so a diff served from it is the diff GitHub would show,
1740
+ read by the same `gitwork/diff.py` doors, behind the same visibility rules as
1741
+ `/rpc`. And the dev-side story does not move an inch: `taskops ui` still serves
1742
+ the window from the checkout it stands in, reading the dev's OWN clone,
1743
+ including refs the forge has never seen. The hosted window is for the reader
1744
+ who has no clone — the case the local window, by construction, cannot serve.
1745
+
1746
+ **Where it lives**: `<root>/<board>/mirror.git`, a bare repo beside the board's
1747
+ `events.jsonl` — later cards follow this wording. It is DERIVED and disposable
1748
+ in exactly the sense `cache.sqlite` is: delete it and it re-clones from the
1749
+ declared forge; nothing in it is truth the forge does not hold. Its ONLY source
1750
+ is that forge. It takes no client pushes — the host still exposes no receive
1751
+ door of any kind — and it is not a second trunk: nothing lands there, nothing
1752
+ is read back into the log from it, and `taskops_merge` still happens in the
1753
+ dev's shared checkout.
1754
+
1755
+ **The credential story** is §19's, unchanged in kind. A public repo needs no
1756
+ credential at all. A private repo is fetched with an **ssh deploy key on the
1757
+ host's filesystem** — read-only, minted and installed by the OWNER, revocable
1758
+ on the forge like any deploy key — which is the ONE new credential this chapter
1759
+ adds, and it is the owner's business exactly as `board forge` itself is. What
1760
+ remains banned is what §11 and §19 already ban: a stored GitHub token (the
1761
+ mirror's remote is an address, never a token), a write credential in any form,
1762
+ GitHub as a second identity system, and any dev credential travelling to the
1763
+ host — nobody's `gh auth token` is involved, because the owner already declared
1764
+ the repo and the mirror pulls from the outside like any anonymous (or
1765
+ deploy-keyed) clone.
1766
+
1767
+ **Still banned, spelled out**: client pushes to the host and git replication
1768
+ between clones (the mirror replicates FROM the forge, one direction, and no
1769
+ clone ever pushes to it); a write credential on the host; a second trunk; a
1770
+ `--ui` flag or any per-request sniffing (whether the mirror exists is decided
1771
+ at mount time, the same `Mounts.repo` switch, now pointable at `mirror.git`);
1772
+ and anonymous writes — a public board's hosted window is a READ, and it leaves
1773
+ no `presence` row.
1774
+
1775
+ So `static.py::NO_UI` and `gitdoor.py::NO_REPO` change meaning, not words of
1776
+ intent: each stops saying "this host never has a repo" and starts saying "this
1777
+ BOARD declared no forge, so this host holds no mirror to read from" — a state
1778
+ the owner ends with one `taskops board forge`. The refusal still names the door.
1779
+
1712
1780
  ### A concept named by two cards is a seam — the fan-out rule, third notch
1713
1781
 
1714
1782
  `gitwork/remote.py` was born TWICE in one wave: two workers, each over the
@@ -1803,7 +1871,9 @@ request (`http/mounts.py::stores`), so a just-restarted process says `1`, not
1803
1871
 
1804
1872
  **That md5-of-the-served-bundle check is history, not the procedure any more.**
1805
1873
  Since §16's "API ONLY" amendment the host serves no bundle: `/<board>/ui/`
1806
- answers `410` and one sentence, so there is nothing at the domain to md5. The
1874
+ answers `410` and one sentence (until the hosted-window chapter deploys a board
1875
+ whose forge is declared — §16, "The hosted window"), so there is nothing at the
1876
+ domain to md5. The
1807
1877
  wheel is still checked for its markers *before* it ships — it is the same wheel
1808
1878
  teammates install to get `taskops ui` — and the domain is verified by DATA,
1809
1879
  through `/rpc`.
@@ -3,6 +3,49 @@
3
3
  The source of truth for release notes — GitHub Releases are extracted from
4
4
  here, never written twice.
5
5
 
6
+ ## 0.5.0 — the hosted window
7
+
8
+ - **A board can be READ without a clone.** Until now the dashboard was only ever
9
+ served by `taskops ui`, a binary standing in a checkout: a host running
10
+ `taskops serve` answered `/git` with a 404 and `/ui/` with a 410, because a
11
+ dashboard draws DIFFS and the host deliberately had no repository to read them
12
+ from. Serving a page that could not draw a single patch was a degraded window
13
+ presented as *the* window, so it was withdrawn. That reasoning had one
14
+ unexamined premise — that the host can never have git — and this release
15
+ removes it, argued in ARCHITECTURE §16 ("The hosted window") before a line of
16
+ code moved.
17
+ - **The host may hold a bare, READ-ONLY mirror of the board's DECLARED forge**,
18
+ at `<root>/<board>/mirror.git`, and only then does it answer `/git` and
19
+ `/ui/`. The mirror is derived and disposable in exactly the sense
20
+ `cache.sqlite` is: delete it and it re-clones; nothing in it is truth the
21
+ forge does not already hold. Its ONLY source is the repo the owner declared
22
+ with `taskops board forge` — it takes no client pushes, it is not a second
23
+ trunk, and nothing is ever read back into the log from it.
24
+ - **A board that declared no forge is exactly what it was**: 404 on `/git`,
25
+ 410 on `/ui/`, and each refusal now names the door — `taskops board forge
26
+ <owner>/<repo>` — instead of stranding the reader in a sentence about a host
27
+ they cannot change.
28
+ - **No new credential type.** A public repo is mirrored anonymously; a private
29
+ one is fetched with an ssh deploy key on the host's filesystem, installed by
30
+ the OWNER, revocable on the forge — the one new credential this chapter adds,
31
+ and it is the owner's business exactly as `board forge` itself is. No stored
32
+ GitHub token, no dev credential travelling, no second identity system.
33
+ - **The page opens on the FACT, never on a clone.** `/ui/` is granted by the
34
+ declared forge alone: the bundle's bytes come from the installed package, so
35
+ putting a network clone in front of `index.html` would take a static page down
36
+ whenever the forge is unreachable. The mirror resolves lazily the moment a
37
+ diff is actually read — which is where a failure belongs, in the diff pane, in
38
+ the door's own words.
39
+ - **A missing ref buys exactly ONE bounded fetch**, then answers stale rather
40
+ than blocking a page on a forge that is down — and only on a mirror. A
41
+ window's own clone still fetches nothing on the reader's behalf: a background
42
+ fetch inside a read-only door would move a branch under a worktree somebody is
43
+ sitting in.
44
+ - **Reads stay reads.** `/ui/` sits behind the credential `/rpc` asks for —
45
+ a `public` board serves the page to an anonymous reader, a private one refuses
46
+ with the same join sentence — and an anonymous page load writes nothing at
47
+ all, not even the invisible write: no presence row.
48
+
6
49
  ## 0.4.2 — the filter that filtered nothing, and the window that stopped signing in
7
50
 
8
51
  - **"All chapters" is an ARGUMENT now, and it really shows every chapter.** The
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskops-cli
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: A shared work board for teams of coding agents, with a human who decides.
5
5
  Project-URL: Repository, https://github.com/bernatch22/taskops
6
6
  License-Expression: MIT
@@ -70,7 +70,7 @@ importable leaves commits un-stamped, silently.
70
70
  taskops init a local board in this repo
71
71
  taskops join [<name>] [--invite <id>] join a board, install the hooks
72
72
  taskops remote add <url> the host this checkout operates, like git's origin
73
- taskops serve host boards — an events API, no dashboard
73
+ taskops serve host boards — /ui and /git open per declared forge
74
74
  taskops server init bootstrap THIS host: its owner and their ssh key
75
75
  taskops board create|ls|push|pull|rm the boards on a host
76
76
  taskops board visibility|forge who may read one · which repo's team is enrolled
@@ -383,9 +383,11 @@ to bytes that are not in history yet is a pointer to nothing.
383
383
  ```
384
384
 
385
385
  Then `taskops ui` lists it under the chapter's **Reports** tab and renders it
386
- full width, read out of **your own clone** at that sha — the dashboard never
387
- asks the server for the bytes, and there is nothing to serve: a host running
388
- `taskops serve` answers `/git` with a 404, whole.
386
+ full width, read out of **your own clone** at that sha — the local dashboard
387
+ never asks the server for the bytes. A host running `taskops serve` answers
388
+ `/git` too, from a bare read-only mirror of the board's DECLARED forge — the
389
+ hosted window, for the reader with no clone. A board that never declared one
390
+ refuses with a sentence naming `taskops board forge <owner>/<repo>`.
389
391
 
390
392
  **Reading one, from the other side.** Everything above is the author's half; a
391
393
  reader needs no ceremony at all. Three doors onto the same bytes:
@@ -58,7 +58,7 @@ importable leaves commits un-stamped, silently.
58
58
  taskops init a local board in this repo
59
59
  taskops join [<name>] [--invite <id>] join a board, install the hooks
60
60
  taskops remote add <url> the host this checkout operates, like git's origin
61
- taskops serve host boards — an events API, no dashboard
61
+ taskops serve host boards — /ui and /git open per declared forge
62
62
  taskops server init bootstrap THIS host: its owner and their ssh key
63
63
  taskops board create|ls|push|pull|rm the boards on a host
64
64
  taskops board visibility|forge who may read one · which repo's team is enrolled
@@ -371,9 +371,11 @@ to bytes that are not in history yet is a pointer to nothing.
371
371
  ```
372
372
 
373
373
  Then `taskops ui` lists it under the chapter's **Reports** tab and renders it
374
- full width, read out of **your own clone** at that sha — the dashboard never
375
- asks the server for the bytes, and there is nothing to serve: a host running
376
- `taskops serve` answers `/git` with a 404, whole.
374
+ full width, read out of **your own clone** at that sha — the local dashboard
375
+ never asks the server for the bytes. A host running `taskops serve` answers
376
+ `/git` too, from a bare read-only mirror of the board's DECLARED forge — the
377
+ hosted window, for the reader with no clone. A board that never declared one
378
+ refuses with a sentence naming `taskops board forge <owner>/<repo>`.
377
379
 
378
380
  **Reading one, from the other side.** Everything above is the author's half; a
379
381
  reader needs no ceremony at all. Three doors onto the same bytes:
@@ -1,4 +1,4 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  __title__ = "taskops"
4
- __version__ = "0.4.2" # single source of truth; pyproject reads it dynamically
4
+ __version__ = "0.5.0" # single source of truth; pyproject reads it dynamically
@@ -0,0 +1,132 @@
1
+ """The board's bare mirror — the forge's history, held so a host can answer.
2
+
3
+ ARCHITECTURE §16 ("The hosted window") decided this on 2026-08-30: a board
4
+ host MAY hold `<root>/<board>/mirror.git`, a bare `git clone --mirror` of the
5
+ forge the owner declared with `board forge` — and only then does it answer
6
+ `/git` and `/ui/`. The mirror is DERIVED and disposable in exactly the sense
7
+ `cache.sqlite` is: delete it and `ensure` re-clones; nothing in it is truth
8
+ the forge does not hold. Its ONLY source is that forge — it takes no client
9
+ pushes, it is not a second trunk, and nothing here ever writes to it beyond
10
+ what `clone` and `fetch` are.
11
+
12
+ **The address is derived, never a credential.** The default remote is
13
+ `https://<host>/<repo>.git`, spelled straight out of the declared fact
14
+ (`core/forge.py` owns that shape) — an anonymous clone, which is all a public
15
+ repo needs. A private repo is the owner's business: they point the mirror at
16
+ an ssh remote backed by a deploy key on the host's filesystem (`url=` at
17
+ clone time, or `git remote set-url` in a mirror that already exists), and
18
+ this module neither knows nor stores anything about it. What §11 bans stays
19
+ banned: no stored token, no write credential, no dev credential travelling.
20
+
21
+ **Failures return None/False, never raise into a request.** These functions
22
+ sit behind HTTP doors serving readers who cannot fix the host's network; a
23
+ mirror that cannot answer is a stale page, not a 500. The same reasoning
24
+ gave `remote.py::push` its 10-second budget, and `fetch` inherits the number:
25
+ a refresh is never a gate, so it may never cost more than a moment. The one
26
+ bounded on-demand fetch lives in `refresh_if_missing` — a requested ref that
27
+ is absent buys exactly one fetch, then the answer is whatever is true, stale
28
+ included. Answering stale beats blocking a page on a forge that is down.
29
+
30
+ All subprocess goes through `run.py`, the one module allowed to import it —
31
+ and `run.git` RAISES on a timeout (right for workers, wrong for a request),
32
+ so the catch here is part of the contract, not belt-and-braces.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from pathlib import Path
38
+
39
+ from . import run
40
+ from ..core import forge as forge_facts
41
+ from .._errors import TaskopsError
42
+
43
+ __all__ = ["ensure", "fetch", "refresh_if_missing"]
44
+
45
+ MIRROR = "mirror.git"
46
+ """§16's wording, verbatim: `<root>/<board>/mirror.git`, beside events.jsonl."""
47
+
48
+ FETCH_TIMEOUT = 10.0
49
+ """`remote.py::PUSH_TIMEOUT`'s argument, pointing the other way: a refresh is
50
+ never a gate. Worst case a reader waits ten seconds ONCE and gets the truth
51
+ about this disk — which is what `gitdoor` answers anyway."""
52
+
53
+ CLONE_TIMEOUT = 120.0
54
+ """A first clone moves the whole history, once, at mount time — not per
55
+ request — so it gets `run.TIMEOUT`'s patience rather than `fetch`'s."""
56
+
57
+
58
+ def _url(forge: object) -> str:
59
+ """The declared fact → the anonymous https remote, or "".
60
+
61
+ `forge_facts.understood` already collapses absent, cleared and
62
+ unintelligible to None, so an https URL is only ever built from a fact a
63
+ door would also act on. No token, no user@ — the address IS the whole
64
+ credential story for a public repo.
65
+ """
66
+ fact = forge_facts.understood(forge)
67
+ if fact is None:
68
+ return ""
69
+ return f"https://{fact['host']}/{fact['repo']}.git"
70
+
71
+
72
+ def ensure(board_dir: Path, forge: object, *, url: str = "") -> Path | None:
73
+ """The mirror's path — cloning it into existence if this is the first ask.
74
+
75
+ An EXISTING `mirror.git` is returned untouched, whatever remote it holds:
76
+ that is how an owner's ssh remote (a deploy key's address) survives every
77
+ later call. `url=` overrides the derived address at clone time — the same
78
+ owner's move, and how tests clone from a local fixture instead of the
79
+ network. No forge and no url means no mirror, which is the state every
80
+ board is born in; a clone that fails leaves nothing behind, so the next
81
+ ask is a clean retry rather than a corpse mistaken for a mirror.
82
+ """
83
+ dest = board_dir / MIRROR
84
+ if dest.is_dir():
85
+ return dest
86
+ remote = url or _url(forge)
87
+ if not remote:
88
+ return None
89
+ try:
90
+ result = run.git("clone", "--mirror", remote, str(dest), timeout=CLONE_TIMEOUT)
91
+ except TaskopsError:
92
+ result = None # timeout, or no git at all — a request never hears it
93
+ if result is None or not result.ok:
94
+ return None
95
+ return dest
96
+
97
+
98
+ def fetch(mirror: Path) -> bool:
99
+ """One bounded fetch from the mirror's own remote. True means it ran clean.
100
+
101
+ A `--mirror` clone's fetch refspec already maps every ref one-to-one, so
102
+ plain `git fetch` IS the whole refresh — no refspec spelled here that
103
+ could drift from what clone configured. False covers everything a reader
104
+ cannot fix: a dead remote, a timeout, a directory that stopped being a
105
+ repo. The caller answers with what it has.
106
+ """
107
+ try:
108
+ return run.git("fetch", cwd=mirror, timeout=FETCH_TIMEOUT).ok
109
+ except TaskopsError: # timeout, or no git at all — never the reader's problem
110
+ return False
111
+
112
+
113
+ def _has(mirror: Path, ref: str) -> bool:
114
+ """`^{commit}` is load-bearing: bare `rev-parse --verify <40-hex>` answers
115
+ ok for ANY well-formed sha, present or not — a ref arriving as a full sha
116
+ (which is what a diff door holds) would always read as here."""
117
+ return run.git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", cwd=mirror).ok
118
+
119
+
120
+ def refresh_if_missing(mirror: Path, ref: str) -> bool:
121
+ """Is `ref` here — after at most ONE fetch if it was not?
122
+
123
+ §16's exact promise: a missing ref triggers one bounded on-demand fetch
124
+ before answering stale. A ref already present costs nothing — no network
125
+ touched, so a page over known history never waits on a forge. A ref still
126
+ absent after the fetch is answered False, truthfully: "not here" is the
127
+ truth about this disk, and the door already knows how to say so.
128
+ """
129
+ if _has(mirror, ref):
130
+ return True
131
+ fetch(mirror)
132
+ return _has(mirror, ref)
@@ -11,12 +11,14 @@ there is no second credential system, and `server.py` checks the credential
11
11
  before this module is reached.
12
12
 
13
13
  **Whether the door exists at all is decided ONCE, at construction**, never
14
- re-derived per request: `Mounts` carries `repo: Path | None`. `taskops ui` sits
14
+ re-derived per request (`http/repos.py` resolves it per board). `taskops ui` sits
15
15
  inside a repo (its root is `<repo>/.taskops`) and passes it; `taskops serve`
16
- sits in a boards directory and passes nothing, so every /git request there is a
17
- 404 whose message SAYS which case it is — the UI reads those words and falls
18
- through its cascade (numstat /git the forge link an honest sentence)
19
- rather than showing a dead pane. ARCHITECTURE.md §16.
16
+ sits in a boards directory and passes nothing FOR A BOARD WITH NO DECLARED
17
+ FORGE, so /git there is a 404 whose message says which case it is — the UI falls
18
+ through its cascade rather than a dead pane. A declared forge may instead mount
19
+ a bare read-only mirror (`<root>/<board>/mirror.git` §16, "The hosted
20
+ window"): `NO_REPO` means "no checkout here and no mirror for this board",
21
+ never "this host can never read git".
20
22
 
21
23
  This module only routes and refuses. Everything about git is `gitwork/diff.py`,
22
24
  which is where the ref validation lives.
@@ -39,6 +41,7 @@ from typing import Any
39
41
  from pathlib import Path
40
42
  from urllib.parse import unquote
41
43
 
44
+ from . import stale
42
45
  from ..core import reports
43
46
  from .._errors import NotFound, BadRequest
44
47
  from ..gitwork import diff, patch
@@ -46,26 +49,12 @@ from ..gitwork import diff, patch
46
49
  NO_REPO = (
47
50
  "this host serves boards, not a repository — it was started outside a "
48
51
  "checkout (taskops serve), so there is no clone here to read a diff from. "
49
- "A host that sits in a repo (taskops ui) answers /git."
52
+ "A host that sits in a repo (taskops ui) answers /git; a board with a "
53
+ "declared forge answers from its mirror when the host can hold one."
50
54
  )
51
55
 
52
56
  SEPARATOR = "..."
53
57
 
54
- STALE = (
55
- "{refs} not in your clone yet — `{fetch}` brings {them}. The board is shared and "
56
- "the code is not: a card's branch reaches origin when it closes, and this "
57
- "window reads only the checkout it stands in. Nothing is fetched for you."
58
- )
59
- """The MISSING-REF case, in its own words. It is not an error and must not read
60
- like one: on a shared board most refs belong to somebody else's card, and until
61
- you fetch, "not here yet" is simply the truth about your disk. Naming the exact
62
- command is this codebase's habit — every refusal names the call that works —
63
- and it is also the reason nothing fetches on the reader's behalf: a background
64
- `git fetch` inside a read-only door would move a branch under a worktree
65
- somebody is sitting in."""
66
-
67
- SHA = "0123456789abcdef"
68
-
69
58
  NOT_A_REPORT = (
70
59
  "{path} is not a report. This door serves committed files under "
71
60
  f"{reports.DIR} and nothing else — it is not a file server, and a read "
@@ -97,24 +86,34 @@ TEXT = "text/plain"
97
86
  MARKDOWN = "text/markdown"
98
87
 
99
88
 
100
- def answer(repo: Path | None, tail: str, query: str) -> dict[str, Any]:
101
- """The whole door. `tail` is the path after `<board>/git/`."""
89
+ def answer(
90
+ repo: Path | None, tail: str, query: str, *, mirrored: bool = False
91
+ ) -> dict[str, Any]:
92
+ """The whole door. `tail` is the path after `<board>/git/`. `mirrored`
93
+ says the repo is the board's mirror (`http/repos.py`), which is the one
94
+ case a missing ref buys a bounded fetch (`http/stale.py`)."""
102
95
  if repo is None:
103
96
  raise NotFound(NO_REPO)
104
97
  kind, _, rest = tail.partition("/")
105
98
  path = _param(query, "path") or None
106
99
  if kind == "file" and rest:
107
- return _file(repo, unquote(rest), path or "")
100
+ return _file(repo, unquote(rest), path or "", mirrored)
108
101
  if kind == "commit" and rest:
109
102
  ref = unquote(rest)
110
103
  found = diff.commit_range(repo, ref)
104
+ if found is None and stale.refreshed(repo, mirrored, [ref]):
105
+ found = diff.commit_range(repo, ref)
111
106
  if found is None:
112
- raise NotFound(_stale(ref))
107
+ raise NotFound(stale.sentence(ref))
113
108
  elif kind == "compare" and SEPARATOR in rest:
114
109
  left, _, right = unquote(rest).partition(SEPARATOR)
115
110
  found = diff.compare_range(repo, left, right)
116
111
  if found is None:
117
- raise NotFound(_stale(*(r for r in (left, right) if not diff.resolve(repo, r))))
112
+ missing = [r for r in (left, right) if not diff.resolve(repo, r)]
113
+ if stale.refreshed(repo, mirrored, missing):
114
+ found = diff.compare_range(repo, left, right)
115
+ if found is None:
116
+ raise NotFound(stale.sentence(*missing))
118
117
  else:
119
118
  raise BadRequest(
120
119
  "git/commit/<ref>, git/compare/<a>...<b>, or git/file/<rev>?path=<file>"
@@ -122,7 +121,7 @@ def answer(repo: Path | None, tail: str, query: str) -> dict[str, Any]:
122
121
  return patch.between(repo, found[0], found[1], path)
123
122
 
124
123
 
125
- def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
124
+ def _file(repo: Path, rev: str, wanted: str, mirrored: bool) -> dict[str, Any]:
126
125
  """One committed file at a rev — read-only, shape-guarded, capped.
127
126
 
128
127
  Three walls, in this order: the path is a REPORT path (`under()`, which
@@ -144,8 +143,10 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
144
143
  if not diff.usable(path):
145
144
  raise BadRequest(ODD_SHAPE.format(path=path, dir=reports.DIR))
146
145
  sha = diff.resolve(repo, rev)
146
+ if sha is None and stale.refreshed(repo, mirrored, [rev]):
147
+ sha = diff.resolve(repo, rev)
147
148
  if sha is None:
148
- raise NotFound(_stale(rev))
149
+ raise NotFound(stale.sentence(rev))
149
150
  got = patch.show(repo, sha, path)
150
151
  if got is None:
151
152
  raise NotFound(ABSENT.format(path=path, sha=sha[:12]))
@@ -160,26 +161,6 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
160
161
  }
161
162
 
162
163
 
163
- def _stale(*refs: str) -> str:
164
- """Which refs are missing, and the one command that brings them.
165
-
166
- A sha is asked for WITHOUT a refspec — `git fetch origin <40 hex>` is
167
- refused by most servers unless they allow it — while a branch is named, so
168
- the reader can paste the line and get exactly what the pane wanted."""
169
- names = [ref for ref in refs if ref] or ["that ref"]
170
- branches = [ref for ref in names if not _looks_like_a_sha(ref)]
171
- many = len(names) > 1
172
- return STALE.format(
173
- refs=f"{' and '.join(names)} {'are' if many else 'is'}",
174
- fetch=" ".join(["git fetch origin", *branches]),
175
- them="them" if many else "it",
176
- )
177
-
178
-
179
- def _looks_like_a_sha(ref: str) -> bool:
180
- return len(ref) >= 7 and all(char in SHA for char in ref.lower())
181
-
182
-
183
164
  def _param(query: str, key: str) -> str:
184
165
  for part in query.split("&"):
185
166
  name, _, value = part.partition("=")
@@ -67,7 +67,7 @@ class Handler(BaseHTTPRequestHandler):
67
67
  elif tail.startswith("git/"):
68
68
  self._git(board, tail[4:])
69
69
  elif tail.startswith("ui"):
70
- self._static(tail[2:])
70
+ self._static(board, tail[2:])
71
71
  elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
72
72
  self._send(200, *page) # a WINDOW serves its page at the ROOT
73
73
  else:
@@ -121,14 +121,35 @@ class Handler(BaseHTTPRequestHandler):
121
121
  self._answer(lambda _: self._diff(board, rest))
122
122
 
123
123
  def _diff(self, board: str, rest: str) -> dict[str, Any]:
124
- """Mounted from the LOCAL clone whether the board is local or remote —
125
- that is the whole point of serving the window here (§16)."""
124
+ """A window answers from its own LOCAL clone; a serve-mode host from
125
+ the board's forge mirror `repos.py` decides which, per board (§16)."""
126
126
  self.mounts.check(board)
127
127
  self._credential(board, "read")
128
- return gitdoor.answer(self.mounts.repo, rest, self.path.partition("?")[2])
129
-
130
- def _static(self, rest: str) -> None: # `static.py` owns what it answers, and why
131
- self._send(*static.answer(self.mounts.ui, rest))
128
+ repo, mirrored = self.mounts.repos.for_board(board)
129
+ return gitdoor.answer(repo, rest, self.path.partition("?")[2], mirrored=mirrored)
130
+
131
+ def _static(self, board: str, rest: str) -> None:
132
+ """The /ui door. A WINDOW serves its bundle to whoever reaches the port,
133
+ unchanged. A serve-mode HOST serves the SAME packaged bundle for a board
134
+ whose owner declared a forge (`repos.backed` — the fact, never a clone),
135
+ behind the credential /rpc asks for: public board, anonymous READ;
136
+ private board, the join refusal. The 410 comes BEFORE the credential on
137
+ purpose — it says nothing about the board a login would guard, and the
138
+ no-forge sentence predates keys on this door. A GET here runs no verb,
139
+ so an anonymous page load writes nothing — no presence row (§11)."""
140
+ if self.mounts.ui is not None:
141
+ self._send(*static.answer(self.mounts.ui, rest))
142
+ return
143
+ try:
144
+ self.mounts.check(board)
145
+ if not self.mounts.repos.backed(board):
146
+ self._send(*static.answer(None, rest))
147
+ return
148
+ self._credential(board, "read")
149
+ except TaskopsError as err:
150
+ self._fail(rpc.status_for(rpc.failure(err)), err)
151
+ return
152
+ self._send(*static.answer(static.PACKAGED, rest))
132
153
 
133
154
  # ── plumbing ────────────────────────────────────────────────────────────
134
155
 
@@ -11,9 +11,10 @@ import re
11
11
  from pathlib import Path
12
12
  from threading import Lock
13
13
 
14
- from . import feed, watcher
14
+ from . import feed, static, watcher
15
15
  from .. import verbs, _clock
16
16
  from .login import Host
17
+ from .repos import Repos
17
18
  from ..verbs import project
18
19
  from .._errors import NotFound, BadRequest
19
20
  from .upstream import Upstream, seq_of
@@ -22,8 +23,6 @@ from ..store.stores import Stores
22
23
 
23
24
  NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
24
25
 
25
- _PACKAGED_UI = Path(__file__).resolve().parent.parent / "ui"
26
-
27
26
  class Mounts:
28
27
  """The boards this process serves, opened once and kept.
29
28
 
@@ -45,16 +44,16 @@ class Mounts:
45
44
  ) -> None:
46
45
  self.root = root
47
46
  self.upstream = upstream
48
- # The ONE place that decides whether this process can read a repo, and
49
- # it is decided by the CALLER at construction, never sniffed per
50
- # request (`gitdoor.py` carries the rest).
47
+ # Whether this process can read a repo is decided by the CALLER at
48
+ # construction a window's own checkout or, on a serve-mode host,
49
+ # per BOARD from its declared forge's mirror (`repos.py` carries it).
51
50
  self.repo = repo
52
- # ONE switch, not two: the same `repo` that mounts /git mounts the bundle. A
53
- # dashboard needs the viewer's CLONE to draw a diff, so a process with no clone
54
- # has no business serving one see `static.py` for the whole post-mortem. The
55
- # bundle still ships inside the wheel; what went away is the server-side mount
56
- # and the `--ui` flag that configured it.
57
- self.ui = _PACKAGED_UI if repo is not None else None
51
+ self.repos = Repos(root, repo, self.stores)
52
+ # ONE switch, not two: the same `repo` that mounts /git mounts the bundle at
53
+ # the WINDOW's root. On a serve-mode host `ui` stays None and the /ui door asks
54
+ # `repos.backed()` per board instead the forge fact, the same one that opens
55
+ # /git see `static.py` and `repos.py` for the whole argument.
56
+ self.ui = static.PACKAGED if repo is not None else None
58
57
  self.credentials = Credentials(root / "live.sqlite")
59
58
  # The HOST's own identity, and it opens NOTHING until a login asks
60
59
  # (`login.py::Host` says why lazily is a rule here, not a taste).