taskops-cli 0.4.2__tar.gz → 0.5.1__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 (286) hide show
  1. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ARCHITECTURE.md +175 -10
  2. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/CHANGELOG.md +77 -0
  3. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/CLAUDE.md +11 -0
  4. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/PKG-INFO +27 -5
  5. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/README.md +26 -4
  6. taskops_cli-0.5.1/smoke.sh +164 -0
  7. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/_version.py +1 -1
  8. taskops_cli-0.5.1/src/taskops/gitwork/mirror.py +132 -0
  9. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/auth.py +19 -1
  10. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/gitdoor.py +29 -48
  11. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/handler.py +37 -15
  12. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/mounts.py +11 -12
  13. taskops_cli-0.5.1/src/taskops/http/repos.py +98 -0
  14. taskops_cli-0.5.1/src/taskops/http/routes.py +32 -0
  15. taskops_cli-0.5.1/src/taskops/http/stale.py +62 -0
  16. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/static.py +49 -15
  17. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/ui/app.js +7 -7
  18. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/ui/index.html +4 -2
  19. taskops_cli-0.5.1/tests/test_mirror.py +164 -0
  20. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_topology.py +367 -6
  21. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/index.html +4 -2
  22. taskops_cli-0.5.1/ui/smoke/sections/board-root-mount.tsx +75 -0
  23. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/git-diff.tsx +6 -2
  24. taskops_cli-0.5.1/ui/smoke/sections/hosted-window.tsx +96 -0
  25. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/client.ts +24 -3
  26. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/card/Patch.tsx +1 -1
  27. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/links.tsx +5 -3
  28. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/main.tsx +6 -8
  29. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/pages/Reports.tsx +6 -5
  30. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.claude/settings.json +0 -0
  31. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.gitattributes +0 -0
  32. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.github/workflows/ci.yml +0 -0
  33. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.github/workflows/workflow.yml +0 -0
  34. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.gitignore +0 -0
  35. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.mcp.json +0 -0
  36. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite +0 -0
  37. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite-shm +0 -0
  38. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite-wal +0 -0
  39. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/board/events.jsonl +0 -0
  40. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/board/live.sqlite +0 -0
  41. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/board/live.sqlite-shm +0 -0
  42. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/board/live.sqlite-wal +0 -0
  43. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/board.json +0 -0
  44. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/reports/lifecycle-chapter.html +0 -0
  45. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/reports/reports-chapter.html +0 -0
  46. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/.taskops/ui.lock +0 -0
  47. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/pyproject.toml +0 -0
  48. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/__init__.py +0 -0
  49. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/_clock.py +0 -0
  50. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/_errors.py +0 -0
  51. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/_ids.py +0 -0
  52. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/_json.py +0 -0
  53. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/_locate.py +0 -0
  54. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/_wire.py +0 -0
  55. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/board.py +0 -0
  56. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/__init__.py +0 -0
  57. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/__main__.py +0 -0
  58. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/admin.py +0 -0
  59. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/claude.py +0 -0
  60. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/commands.py +0 -0
  61. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/enrol.py +0 -0
  62. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/github.py +0 -0
  63. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/grants.py +0 -0
  64. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/hooks.py +0 -0
  65. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/main.py +0 -0
  66. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/operate.py +0 -0
  67. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/paging.py +0 -0
  68. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/parser.py +0 -0
  69. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/pull.py +0 -0
  70. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/push.py +0 -0
  71. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/remote.py +0 -0
  72. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/rm.py +0 -0
  73. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/serving.py +0 -0
  74. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/team.py +0 -0
  75. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/watch.py +0 -0
  76. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/window.py +0 -0
  77. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/cli/wording.py +0 -0
  78. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/__init__.py +0 -0
  79. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/actors.py +0 -0
  80. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/challenge.py +0 -0
  81. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/chapters.py +0 -0
  82. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/event.py +0 -0
  83. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/forge.py +0 -0
  84. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/graph.py +0 -0
  85. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/holding.py +0 -0
  86. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/hours.py +0 -0
  87. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/kinds.py +0 -0
  88. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/machine.py +0 -0
  89. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/mentions.py +0 -0
  90. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/replay.py +0 -0
  91. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/reports.py +0 -0
  92. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/review.py +0 -0
  93. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/scope.py +0 -0
  94. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/seams.py +0 -0
  95. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/core/types.py +0 -0
  96. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/__init__.py +0 -0
  97. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/bind.py +0 -0
  98. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/catchup.py +0 -0
  99. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/claudefiles.py +0 -0
  100. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/diff.py +0 -0
  101. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/install.py +0 -0
  102. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/landing.py +0 -0
  103. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/patch.py +0 -0
  104. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/remote.py +0 -0
  105. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/run.py +0 -0
  106. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/sig.py +0 -0
  107. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/trailer.py +0 -0
  108. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/gitwork/trees.py +0 -0
  109. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/__init__.py +0 -0
  110. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/admin.py +0 -0
  111. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/feed.py +0 -0
  112. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/grants.py +0 -0
  113. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/ingest.py +0 -0
  114. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/login.py +0 -0
  115. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/members.py +0 -0
  116. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/removal.py +0 -0
  117. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/rpc.py +0 -0
  118. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/scoped.py +0 -0
  119. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/server.py +0 -0
  120. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/upstream.py +0 -0
  121. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/http/watcher.py +0 -0
  122. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/identity.py +0 -0
  123. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/__init__.py +0 -0
  124. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/__main__.py +0 -0
  125. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/activity.py +0 -0
  126. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/before.py +0 -0
  127. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/boards.py +0 -0
  128. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/boardview.py +0 -0
  129. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/brief.py +0 -0
  130. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/chapter.py +0 -0
  131. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/dossier.py +0 -0
  132. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/fields.py +0 -0
  133. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/gitmoves.py +0 -0
  134. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/hello.py +0 -0
  135. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/integrate.py +0 -0
  136. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/orders.py +0 -0
  137. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/render.py +0 -0
  138. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/schema.py +0 -0
  139. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/server.py +0 -0
  140. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/thread.py +0 -0
  141. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/mcp/tools.py +0 -0
  142. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/py.typed +0 -0
  143. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/session.py +0 -0
  144. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/__init__.py +0 -0
  145. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/cache.py +0 -0
  146. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/creds.py +0 -0
  147. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/handover.py +0 -0
  148. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/live.py +0 -0
  149. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/log.py +0 -0
  150. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/pubkeys.py +0 -0
  151. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/reviews.py +0 -0
  152. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/server.py +0 -0
  153. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/store/stores.py +0 -0
  154. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/ui/style.css +0 -0
  155. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/__init__.py +0 -0
  156. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_args.py +0 -0
  157. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_cards.py +0 -0
  158. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_chapter.py +0 -0
  159. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_context.py +0 -0
  160. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_facts.py +0 -0
  161. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_mentions.py +0 -0
  162. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_rows.py +0 -0
  163. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_stories.py +0 -0
  164. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_waiting.py +0 -0
  165. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/_windows.py +0 -0
  166. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/activity.py +0 -0
  167. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/assign.py +0 -0
  168. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/card.py +0 -0
  169. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/events.py +0 -0
  170. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/filed.py +0 -0
  171. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/plan.py +0 -0
  172. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/project.py +0 -0
  173. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/pulse.py +0 -0
  174. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/record.py +0 -0
  175. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/report.py +0 -0
  176. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/review.py +0 -0
  177. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/take.py +0 -0
  178. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/src/taskops/verbs/update.py +0 -0
  179. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/__init__.py +0 -0
  180. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/conftest.py +0 -0
  181. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_architecture.py +0 -0
  182. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_claude.py +0 -0
  183. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_core.py +0 -0
  184. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_git.py +0 -0
  185. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_mcp.py +0 -0
  186. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_report.py +0 -0
  187. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_store.py +0 -0
  188. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_ui.py +0 -0
  189. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_verbs.py +0 -0
  190. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/test_window.py +0 -0
  191. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/tests/ui_harness.js +0 -0
  192. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/build.mjs +0 -0
  193. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/package-lock.json +0 -0
  194. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/package.json +0 -0
  195. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/fixture.json +0 -0
  196. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/main.tsx +0 -0
  197. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/run.mjs +0 -0
  198. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/actors-window-filter.tsx +0 -0
  199. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/actors.tsx +0 -0
  200. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/all-chapters-scope.tsx +0 -0
  201. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/board-columns.tsx +0 -0
  202. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/board-flow-view.tsx +0 -0
  203. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/board-motion-and-avatars.tsx +0 -0
  204. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-completion-derivation.tsx +0 -0
  205. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-landed.tsx +0 -0
  206. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-story-grid.tsx +0 -0
  207. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/chapters-open.tsx +0 -0
  208. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/comment-box.tsx +0 -0
  209. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/comment-toasts-model.tsx +0 -0
  210. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/comment-toasts-stack.tsx +0 -0
  211. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/diff-page.tsx +0 -0
  212. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/dossier.tsx +0 -0
  213. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/event-stream.tsx +0 -0
  214. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/feed-reconnect.tsx +0 -0
  215. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/forge-links.tsx +0 -0
  216. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/forge-opens-the-board.tsx +0 -0
  217. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/monitor-panes.tsx +0 -0
  218. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/overlay-escape.tsx +0 -0
  219. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/report-markdown.tsx +0 -0
  220. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/report-sandbox.tsx +0 -0
  221. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/reviewing-skew.tsx +0 -0
  222. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/section.ts +0 -0
  223. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/swarm-topology.tsx +0 -0
  224. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/thread-closing-note.tsx +0 -0
  225. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections/worktrees-index.tsx +0 -0
  226. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/smoke/sections.mjs +0 -0
  227. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/App.tsx +0 -0
  228. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/completed.ts +0 -0
  229. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/actors/Daysheet.tsx +0 -0
  230. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/actors/DevPanel.tsx +0 -0
  231. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/board/CardTile.tsx +0 -0
  232. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/board/Column.tsx +0 -0
  233. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/board/ViewToggle.tsx +0 -0
  234. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/board/flip.ts +0 -0
  235. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/board/useFlip.ts +0 -0
  236. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/card/CommentBox.tsx +0 -0
  237. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/card/Drawer.tsx +0 -0
  238. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/card/Sections.tsx +0 -0
  239. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/card/Thread.tsx +0 -0
  240. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/card/split.ts +0 -0
  241. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/card/tokens.ts +0 -0
  242. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/chrome/AvatarStack.tsx +0 -0
  243. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/chrome/Header.tsx +0 -0
  244. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/chrome/KpiRail.tsx +0 -0
  245. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/chrome/MilestonePicker.tsx +0 -0
  246. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/chrome/TabNav.tsx +0 -0
  247. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/flow/FlowView.tsx +0 -0
  248. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/flow/layout.ts +0 -0
  249. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/Chapter.tsx +0 -0
  250. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/DependencyChain.tsx +0 -0
  251. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/EditSurface.tsx +0 -0
  252. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/EventStream.tsx +0 -0
  253. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/LeaseHealth.tsx +0 -0
  254. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/LiveLeases.tsx +0 -0
  255. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/Mentions.tsx +0 -0
  256. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/Pane.tsx +0 -0
  257. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/Swarm.tsx +0 -0
  258. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/Throughput.tsx +0 -0
  259. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/monitor/panels.ts +0 -0
  260. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/reports/ReportFrame.tsx +0 -0
  261. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/shared/Avatar.tsx +0 -0
  262. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/shared/Markdown.tsx +0 -0
  263. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/shared/Overlay.tsx +0 -0
  264. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/shared/Segmented.tsx +0 -0
  265. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/shared/overlayStack.ts +0 -0
  266. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/story/ChapterStory.tsx +0 -0
  267. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/story/stats.ts +0 -0
  268. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/toasts/ToastStack.tsx +0 -0
  269. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/toasts/model.ts +0 -0
  270. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/components/toasts/useToasts.ts +0 -0
  271. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/format.ts +0 -0
  272. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/hoursWindow.ts +0 -0
  273. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/markdown.ts +0 -0
  274. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/pages/Actors.tsx +0 -0
  275. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/pages/Board.tsx +0 -0
  276. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/pages/Monitor.tsx +0 -0
  277. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/pages/WorktreeDiff.tsx +0 -0
  278. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/pages/Worktrees.tsx +0 -0
  279. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/theme/theme.ts +0 -0
  280. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/theme/tokens.css +0 -0
  281. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/types.ts +0 -0
  282. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/useBoard.ts +0 -0
  283. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/src/useEvents.ts +0 -0
  284. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/styles/index.css +0 -0
  285. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/ui/tsconfig.json +0 -0
  286. {taskops_cli-0.4.2 → taskops_cli-0.5.1}/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,21 @@ 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. And the page's ADDRESS moved
1512
+ one notch after that: on a serve-mode host the last column is the board's own
1513
+ `/<board>/`, with `/<board>/ui/` kept as an alias — "The board's own address IS
1514
+ the page" at the end of this section.
1509
1515
 
1510
1516
  A viewer meets that gap whenever this clone cannot answer — a ref not fetched
1511
1517
  yet, a shallow checkout, a commit from before this repo had the branch — and
1512
1518
  there the UI's declared cascade takes over: numstat from the event → patch from
1513
1519
  /git → the forge link if a slug exists → one honest sentence. No dead anchor, no
1514
1520
  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
- more — see below.)
1521
+ with no clone at all, pointed at the hosted page. That page was withdrawn — see
1522
+ below — and comes back only standing on a real history: the forge mirror, in
1523
+ "The hosted window".)
1517
1524
 
1518
1525
  That cascade is ONE function — `ui/src/links.tsx::cascade`, beside the slug and
1519
1526
  the link templates it already owned — and `ui/src/components/card/Patch.tsx`
@@ -1595,9 +1602,11 @@ doubled separator and the directory itself, and never repairs one: a traversal
1595
1602
  normalised into something acceptable is the bug. Anything outside
1596
1603
  `.taskops/reports/` is a 400 that names the directory, and `tests/test_topology.py`
1597
1604
  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.
1605
+ contents appear nowhere in the answer). `taskops serve` mounts no `/git`
1606
+ from a checkout it does not have: the switch is `Mounts.repo`, decided once at
1607
+ construction, and a new question underneath it opens no door. The hosted-window
1608
+ amendment below changes what `repo` may point AT — a bare mirror of the
1609
+ declared forge — never the switch itself.
1601
1610
 
1602
1611
  ### The window: `taskops ui` serves locally even for a REMOTE board (2026-08-08)
1603
1612
 
@@ -1636,7 +1645,7 @@ The routes do not change, so the committed bundle is untouched by any of this:
1636
1645
  decision). The server used to serve the bundle too, from a `--ui` flag with a
1637
1646
  packaged default — so the box above answered `/ui/` with a real dashboard, and
1638
1647
  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*
1648
+ from deliberately had no clone. It was a degraded window presented as *the*
1640
1649
  window; the redirect above merely pointed people at it. **The binary serves the
1641
1650
  window; the server serves the truth.**
1642
1651
 
@@ -1644,7 +1653,9 @@ So: `Mounts.ui` is not a parameter, it is `repo`'s shadow — one construction-t
1644
1653
  switch mounts `/git` AND the bundle, and only a process standing in a checkout
1645
1654
  has either. `/ui/` on a board host answers `static.py::NO_UI` as plain text with
1646
1655
  **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
1656
+ withdrawn on purpose and is never coming back to a host with no clone — and a
1657
+ host holding the board's forge MIRROR is no longer that host ("The hosted
1658
+ window", below). Plain
1648
1659
  text and not the JSON envelope because the only reader who reaches that door is
1649
1660
  a human with a browser. The `--ui` flag is REMOVED, not deprecated: there is no
1650
1661
  command line that puts the decision back. The bundle still ships inside the
@@ -1709,6 +1720,66 @@ move a branch under a worktree somebody is sitting in.
1709
1720
 
1710
1721
  A repo joined to nothing behaves exactly as it did.
1711
1722
 
1723
+ ### The hosted window: the host may hold a MIRROR of the declared forge (decided 2026-08-30)
1724
+
1725
+ The first amendment narrowed "no server reads a repo" to "a host that sits in a
1726
+ repo may read it". This one narrows the other clause — *the server deliberately
1727
+ has no clone* — and, as before, the change is recorded here rather than quietly
1728
+ made:
1729
+
1730
+ > **A board host MAY hold a bare, READ-ONLY mirror of the board's DECLARED
1731
+ > forge — and only then does it answer `/git` and `/ui/`.** A board with no
1732
+ > declared forge is exactly what it was: 404 on `/git`, 410 on `/ui/`, each
1733
+ > refusal naming `taskops board forge <owner>/<repo>` as the door.
1734
+
1735
+ Why this is not the degraded window "API ONLY" withdrew. That window's flaw was
1736
+ never that a server drew a dashboard — it was that the machine drawing it had
1737
+ NO git at all, so every diff fell through the whole cascade to a forge link or
1738
+ a sentence, a blank presented as *the* window. The mirror removes the flaw, not
1739
+ the caution: it is **the same history the forge already holds** — `git clone
1740
+ --mirror` of the repo the owner declared with `board forge`, refreshed by ONE
1741
+ bounded on-demand fetch when a requested ref is missing, then answered stale
1742
+ rather than blocking — so a diff served from it is the diff GitHub would show,
1743
+ read by the same `gitwork/diff.py` doors, behind the same visibility rules as
1744
+ `/rpc`. And the dev-side story does not move an inch: `taskops ui` still serves
1745
+ the window from the checkout it stands in, reading the dev's OWN clone,
1746
+ including refs the forge has never seen. The hosted window is for the reader
1747
+ who has no clone — the case the local window, by construction, cannot serve.
1748
+
1749
+ **Where it lives**: `<root>/<board>/mirror.git`, a bare repo beside the board's
1750
+ `events.jsonl` — later cards follow this wording. It is DERIVED and disposable
1751
+ in exactly the sense `cache.sqlite` is: delete it and it re-clones from the
1752
+ declared forge; nothing in it is truth the forge does not hold. Its ONLY source
1753
+ is that forge. It takes no client pushes — the host still exposes no receive
1754
+ door of any kind — and it is not a second trunk: nothing lands there, nothing
1755
+ is read back into the log from it, and `taskops_merge` still happens in the
1756
+ dev's shared checkout.
1757
+
1758
+ **The credential story** is §19's, unchanged in kind. A public repo needs no
1759
+ credential at all. A private repo is fetched with an **ssh deploy key on the
1760
+ host's filesystem** — read-only, minted and installed by the OWNER, revocable
1761
+ on the forge like any deploy key — which is the ONE new credential this chapter
1762
+ adds, and it is the owner's business exactly as `board forge` itself is. What
1763
+ remains banned is what §11 and §19 already ban: a stored GitHub token (the
1764
+ mirror's remote is an address, never a token), a write credential in any form,
1765
+ GitHub as a second identity system, and any dev credential travelling to the
1766
+ host — nobody's `gh auth token` is involved, because the owner already declared
1767
+ the repo and the mirror pulls from the outside like any anonymous (or
1768
+ deploy-keyed) clone.
1769
+
1770
+ **Still banned, spelled out**: client pushes to the host and git replication
1771
+ between clones (the mirror replicates FROM the forge, one direction, and no
1772
+ clone ever pushes to it); a write credential on the host; a second trunk; a
1773
+ `--ui` flag or any per-request sniffing (whether the mirror exists is decided
1774
+ at mount time, the same `Mounts.repo` switch, now pointable at `mirror.git`);
1775
+ and anonymous writes — a public board's hosted window is a READ, and it leaves
1776
+ no `presence` row.
1777
+
1778
+ So `static.py::NO_UI` and `gitdoor.py::NO_REPO` change meaning, not words of
1779
+ intent: each stops saying "this host never has a repo" and starts saying "this
1780
+ BOARD declared no forge, so this host holds no mirror to read from" — a state
1781
+ the owner ends with one `taskops board forge`. The refusal still names the door.
1782
+
1712
1783
  ### A concept named by two cards is a seam — the fan-out rule, third notch
1713
1784
 
1714
1785
  `gitwork/remote.py` was born TWICE in one wave: two workers, each over the
@@ -1755,6 +1826,98 @@ starts from the server's real shape, and `null` (fewer than two ready cards,
1755
1826
  nothing to decide) is deliberately a different state from absent (a board older
1756
1827
  than the wave).
1757
1828
 
1829
+ ### A bare `Bearer` is NO credential, not an unknown one (2026-08-31)
1830
+
1831
+ The hosted window shipped on 2026-08-30 and was unusable the next morning for
1832
+ the exact reader it was built for: a PUBLIC board, opened from its own hosted
1833
+ page, answered *"unknown credential — run: taskops join"*. Nothing about
1834
+ visibility was wrong. The page simply had no token, so the browser sent
1835
+ `Authorization: Bearer ` with an empty value; `auth.token_in`'s `removeprefix`
1836
+ did not match the header without its space-and-value, and it returned the
1837
+ literal string `Bearer` as if that were somebody's token. `creds.check` then did
1838
+ its job perfectly on a token nobody ever minted.
1839
+
1840
+ So the fault was never in the visibility rule and never in the client. It was in
1841
+ the one place that decides what a header MEANS:
1842
+
1843
+ > **An `Authorization` header that carries no value is the ABSENCE of a
1844
+ > credential, identical to sending no header at all. A header that carries a
1845
+ > value is a claim, and a claim that does not check out is refused with the
1846
+ > sentence it has always used.**
1847
+
1848
+ Both halves are load-bearing, and the second is why this is not the tempting
1849
+ one-line generalisation. "Treat anything unparseable as anonymous" would make a
1850
+ typo'd token read a private board as far as its visibility allows and, worse,
1851
+ would turn an expired credential into a silent downgrade instead of the loud
1852
+ `unknown credential` it has always been. Empty is not wrong; wrong is wrong —
1853
+ and the two refusals stay different sentences: a caller who presented NOTHING to
1854
+ a private board is told how to join, a caller who presented something unknown is
1855
+ told it is unknown.
1856
+
1857
+ It lives in ONE extractor (`http/auth.py::token_in`) because every door — `rpc`,
1858
+ `feed`, `git`, the static page — asks that function and nothing else. A second
1859
+ door with its own opinion about a header is how the fleet ends up with two
1860
+ credential rules, which is the failure mode §19 exists to prevent. `taskops
1861
+ ui`'s window never saw this: it mints a token before it opens a tab, so the only
1862
+ client that sends an empty bearer is the hosted page, and the hosted page is
1863
+ younger than the extractor.
1864
+
1865
+ The lesson generalises past auth: **anonymous worked only because no client
1866
+ existed**. Every test that pinned "no header reads a public board" sent no
1867
+ header, because the only readers were `curl` and the MCP client. A browser
1868
+ cannot express "no header" through a fetch wrapper that always sets one — so a
1869
+ contract that is only ever exercised by hand-written clients is not pinned,
1870
+ it is merely unvisited. `tests/test_topology.py` now sends the bare header
1871
+ alongside the absent one, on a public board AND a private one, which is the pair
1872
+ that says which of the two rules above is being tested.
1873
+
1874
+ ### The board's own address IS the page (2026-08-31)
1875
+
1876
+ The same report carried a second complaint, and it is a design one: the page was
1877
+ at `/<board>/ui/`. A board host serves exactly one thing to a human, and it had
1878
+ been given a sub-path — so the URL a person pastes says `ui` twice over (once in
1879
+ the product's name for the thing, once in the path) and the shortest address on
1880
+ the host, `/<board>/`, answered 404. Whatever a host serves to people belongs at
1881
+ its own address; a prefix is for the doors people never type.
1882
+
1883
+ > **A board's page is served at `/<board>/` (and `/<board>`, bare). The MACHINE
1884
+ > doors move under `/<board>/api/…` — `rpc`, `git/…`, `feed`, `invite/redeem`.**
1885
+
1886
+ Two facts make that router unambiguous rather than a guess per request. The
1887
+ prefix is stripped ONCE, in `http/routes.py::split`, shared by GET and POST, so
1888
+ `/<board>/api/rpc` IS `/<board>/rpc` — same handler, same credential, same
1889
+ words, and no door can drift from the other spelling. And the page's own
1890
+ relative links (`./app.js`, `./style.css` off `index.html`) now arrive as board
1891
+ tails, which the router separates from doors by a CLOSED SET: `static.py::asset`
1892
+ asks the packaged bundle what files it actually ships, flat, with a suffix in
1893
+ `TYPES`. None is named `rpc`, `git`, `feed`, `ui` or `api`, so an asset can
1894
+ never shadow a door and a door never shadows an asset — and a bundle that grows
1895
+ a font ships without a router edit. The host's own one-segment doors (`/login`,
1896
+ `/rpc`, `/healthz`) are untouched, because the strip lives in the TAIL and never
1897
+ sees them; a board literally named `api` still answers at `/api/…`.
1898
+
1899
+ **The 0.5.0 spellings are kept, not deprecated** — `/<board>/ui/`,
1900
+ `/<board>/rpc`, `/<board>/git/…`, `/<board>/feed` all answer, unprefixed and
1901
+ without a redirect. Not out of politeness: links to them were pasted the day
1902
+ they existed, `.mcp.json` files and agents are configured against them, four
1903
+ legacy-bearer production boards speak them, and `taskops ui`'s upstream forward
1904
+ builds them. A redirect would have been the tidier story and would have broken
1905
+ every POST that follows one badly. So the rule this chapter adds about old
1906
+ addresses is the one it also obeys: **an address that ever worked keeps
1907
+ answering, and the new one is where the page POINTS people.** `ui/src/client.ts`
1908
+ derives its base from the page's own location (`baseOf`) precisely so the page
1909
+ never hardcodes which of the two it was loaded from.
1910
+
1911
+ **Both faults are pinned live, not only in the suite.** `smoke.sh` at the repo
1912
+ root is the re-runnable list — every assertion in it is one of these two reports
1913
+ or something 0.5.0 already promised, read-only, and it exits on the first
1914
+ failure:
1915
+
1916
+ ```sh
1917
+ sh smoke.sh # taskops.bernardocastro.dev, taskops-v2
1918
+ sh smoke.sh <host> <board> <private-board>
1919
+ ```
1920
+
1758
1921
  ---
1759
1922
 
1760
1923
  ## 17. Deployed — taskops.bernardocastro.dev is v2 (2026-08-08)
@@ -1803,7 +1966,9 @@ request (`http/mounts.py::stores`), so a just-restarted process says `1`, not
1803
1966
 
1804
1967
  **That md5-of-the-served-bundle check is history, not the procedure any more.**
1805
1968
  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
1969
+ answers `410` and one sentence (until the hosted-window chapter deploys a board
1970
+ whose forge is declared — §16, "The hosted window"), so there is nothing at the
1971
+ domain to md5. The
1807
1972
  wheel is still checked for its markers *before* it ships — it is the same wheel
1808
1973
  teammates install to get `taskops ui` — and the domain is verified by DATA,
1809
1974
  through `/rpc`.
@@ -3,6 +3,83 @@
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.1 — the hosted window, actually usable
7
+
8
+ 0.5.0 shipped a window nobody could use. Both faults were reported the next
9
+ morning against the live host, and both were contract-level: the URL people
10
+ paste, and the credential rule every door shares.
11
+
12
+ - **A public board was unreadable from its own hosted page.** The client sends
13
+ `Authorization: "Bearer " + token()`; with no token that header arrives as
14
+ `Bearer`, and `auth.token_in`'s `removeprefix("Bearer ")` did not match — so
15
+ the literal string `"Bearer"` came back AS the token and the reader was told
16
+ "unknown credential — run: taskops join". Anonymous reads worked only when NO
17
+ Authorization header was sent at all, which no browser client does: the
18
+ anonymous path was only ever green because nothing but curl had walked it.
19
+ Scheme and value are now parsed as separate fields, the scheme
20
+ case-insensitive per RFC 7235, and a bearer scheme with no value is the
21
+ ABSENCE of a credential. Deliberately narrow: a header carrying any actual
22
+ value — wrong, expired, or under a scheme this server never spoke — is a
23
+ credential that WAS presented and still refuses exactly as before.
24
+ "Unparseable" is not "anonymous".
25
+ - **The board's own address IS the page.** `https://<host>/<board>/` (and
26
+ `/<board>`) serves the dashboard, with `app.js` and `style.css` beside it; the
27
+ machine doors also answer under `/<board>/api/{rpc,git,feed}`, stripped once
28
+ in `http/routes.py::split` so no door can drift from another. A sub-path was
29
+ wrong for the primary thing a board host serves.
30
+ - **Nothing that worked in 0.5.0 stopped working.** `/<board>/ui/`,
31
+ `/<board>/rpc`, `/<board>/git/…` and `/<board>/feed` all still answer — agents,
32
+ the MCP client, `taskops ui`'s forward and the legacy production boards speak
33
+ them, and a link somebody already pasted is a contract, not a legacy.
34
+ - **`smoke.sh`** — the live, read-only smoke for a board host: the page at the
35
+ board root, a bare `Bearer` reading a public board, a private board still
36
+ refusing, a card branch's diff rendered from the forge mirror. Every line is
37
+ there because it failed once in production, and each says which report it
38
+ came from.
39
+
40
+ ## 0.5.0 — the hosted window
41
+
42
+ - **A board can be READ without a clone.** Until now the dashboard was only ever
43
+ served by `taskops ui`, a binary standing in a checkout: a host running
44
+ `taskops serve` answered `/git` with a 404 and `/ui/` with a 410, because a
45
+ dashboard draws DIFFS and the host deliberately had no repository to read them
46
+ from. Serving a page that could not draw a single patch was a degraded window
47
+ presented as *the* window, so it was withdrawn. That reasoning had one
48
+ unexamined premise — that the host can never have git — and this release
49
+ removes it, argued in ARCHITECTURE §16 ("The hosted window") before a line of
50
+ code moved.
51
+ - **The host may hold a bare, READ-ONLY mirror of the board's DECLARED forge**,
52
+ at `<root>/<board>/mirror.git`, and only then does it answer `/git` and
53
+ `/ui/`. The mirror is derived and disposable in exactly the sense
54
+ `cache.sqlite` is: delete it and it re-clones; nothing in it is truth the
55
+ forge does not already hold. Its ONLY source is the repo the owner declared
56
+ with `taskops board forge` — it takes no client pushes, it is not a second
57
+ trunk, and nothing is ever read back into the log from it.
58
+ - **A board that declared no forge is exactly what it was**: 404 on `/git`,
59
+ 410 on `/ui/`, and each refusal now names the door — `taskops board forge
60
+ <owner>/<repo>` — instead of stranding the reader in a sentence about a host
61
+ they cannot change.
62
+ - **No new credential type.** A public repo is mirrored anonymously; a private
63
+ one is fetched with an ssh deploy key on the host's filesystem, installed by
64
+ the OWNER, revocable on the forge — the one new credential this chapter adds,
65
+ and it is the owner's business exactly as `board forge` itself is. No stored
66
+ GitHub token, no dev credential travelling, no second identity system.
67
+ - **The page opens on the FACT, never on a clone.** `/ui/` is granted by the
68
+ declared forge alone: the bundle's bytes come from the installed package, so
69
+ putting a network clone in front of `index.html` would take a static page down
70
+ whenever the forge is unreachable. The mirror resolves lazily the moment a
71
+ diff is actually read — which is where a failure belongs, in the diff pane, in
72
+ the door's own words.
73
+ - **A missing ref buys exactly ONE bounded fetch**, then answers stale rather
74
+ than blocking a page on a forge that is down — and only on a mirror. A
75
+ window's own clone still fetches nothing on the reader's behalf: a background
76
+ fetch inside a read-only door would move a branch under a worktree somebody is
77
+ sitting in.
78
+ - **Reads stay reads.** `/ui/` sits behind the credential `/rpc` asks for —
79
+ a `public` board serves the page to an anonymous reader, a private one refuses
80
+ with the same join sentence — and an anonymous page load writes nothing at
81
+ all, not even the invisible write: no presence row.
82
+
6
83
  ## 0.4.2 — the filter that filtered nothing, and the window that stopped signing in
7
84
 
8
85
  - **"All chapters" is an ARGUMENT now, and it really shows every chapter.** The
@@ -228,6 +228,17 @@ what it exists to report, not a fault.
228
228
  - One card rebuilds the bundle at the end of a wave. N cards rebuilding it is
229
229
  N-1 conflicts by construction.
230
230
 
231
+ **The hosted page is at the board's OWN address** — `https://<host>/<board>/`
232
+ (and `/<board>`), its assets beside it, the machine doors under
233
+ `/<board>/api/{rpc,git,feed}`. 0.5.0's spellings (`/<board>/ui/`,
234
+ `/<board>/rpc`, `/<board>/git/…`, `/<board>/feed`) still answer and are a
235
+ contract, not a legacy — ARCHITECTURE §16, "The board's own address IS the
236
+ page". The page derives its base from its own location
237
+ (`ui/src/client.ts::baseOf`) and hardcodes neither. `sh smoke.sh` re-derives all
238
+ of it against the live host, plus the credential rule a browser exposed:
239
+ **an `Authorization` header with no value is the ABSENCE of a credential, a
240
+ header with a wrong value still refuses loudly** (`http/auth.py::token_in`).
241
+
231
242
  ## Never re-introduce
232
243
 
233
244
  Each has its line in ARCHITECTURE.md §11 saying what it cost and where it is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskops-cli
3
- Version: 0.4.2
3
+ Version: 0.5.1
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 — the page 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,31 @@ 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>`.
391
+
392
+ **The hosted page is at the board's OWN address.** `https://<host>/<board>/`
393
+ IS the dashboard — not a sub-path under it — and the machine doors sit under a
394
+ prefix that can never collide with a page asset:
395
+
396
+ ```
397
+ https://<host>/<board>/ the page (also /<board>, no slash)
398
+ https://<host>/<board>/app.js its assets style.css, index.html
399
+ https://<host>/<board>/api/rpc the verbs also /<board>/rpc
400
+ https://<host>/<board>/api/git/… diffs from the mirror also /<board>/git/…
401
+ https://<host>/<board>/api/feed the live feed also /<board>/feed
402
+ https://<host>/healthz the host itself
403
+ ```
404
+
405
+ The right-hand spellings are 0.5.0's and they keep answering, unprefixed and
406
+ un-redirected — including `/<board>/ui/`, which was the page's address for one
407
+ day. Links were pasted, agents and the MCP client are configured against them,
408
+ and `taskops ui`'s upstream forward speaks them, so they are a contract now
409
+ rather than a legacy. Re-derivable at any time with `sh smoke.sh <host>
410
+ <board>`.
389
411
 
390
412
  **Reading one, from the other side.** Everything above is the author's half; a
391
413
  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 — the page 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,31 @@ 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>`.
379
+
380
+ **The hosted page is at the board's OWN address.** `https://<host>/<board>/`
381
+ IS the dashboard — not a sub-path under it — and the machine doors sit under a
382
+ prefix that can never collide with a page asset:
383
+
384
+ ```
385
+ https://<host>/<board>/ the page (also /<board>, no slash)
386
+ https://<host>/<board>/app.js its assets style.css, index.html
387
+ https://<host>/<board>/api/rpc the verbs also /<board>/rpc
388
+ https://<host>/<board>/api/git/… diffs from the mirror also /<board>/git/…
389
+ https://<host>/<board>/api/feed the live feed also /<board>/feed
390
+ https://<host>/healthz the host itself
391
+ ```
392
+
393
+ The right-hand spellings are 0.5.0's and they keep answering, unprefixed and
394
+ un-redirected — including `/<board>/ui/`, which was the page's address for one
395
+ day. Links were pasted, agents and the MCP client are configured against them,
396
+ and `taskops ui`'s upstream forward speaks them, so they are a contract now
397
+ rather than a legacy. Re-derivable at any time with `sh smoke.sh <host>
398
+ <board>`.
377
399
 
378
400
  **Reading one, from the other side.** Everything above is the author's half; a
379
401
  reader needs no ceremony at all. Three doors onto the same bytes:
@@ -0,0 +1,164 @@
1
+ #!/bin/sh
2
+ # The live smoke for a taskops board HOST. Read-only, no credential, no state:
3
+ # every request below is a GET or a `board` read, so it is safe to run against
4
+ # production at any time, including from a laptop that never joined the board.
5
+ #
6
+ # sh smoke.sh # the author's host, defaults below
7
+ # sh smoke.sh <host> <public-board> <private-board> <card-branch>
8
+ #
9
+ # Every assertion is here because it FAILED once, in production, against a real
10
+ # reader — the comment above each one says which report it comes from. Nothing
11
+ # is asserted because it seemed prudent: a smoke list that grows by taste stops
12
+ # being read, and a green line that never could have gone red is a lie about
13
+ # coverage. It exits non-zero on the FIRST failure, so the first red line is the
14
+ # whole diagnosis.
15
+ #
16
+ # `pass` prints what was actually observed, never just OK — a smoke you cannot
17
+ # read the evidence out of is a smoke you end up re-running by hand anyway.
18
+
19
+ set -eu
20
+
21
+ HOST=${1:-taskops.bernardocastro.dev}
22
+ BOARD=${2:-taskops-v2}
23
+ PRIVATE=${3:-axion}
24
+ BRANCH=${4:-tk-32d2ba}
25
+
26
+ BASE="https://$HOST"
27
+ C="curl -sS -m 15"
28
+
29
+ fail() { printf '\nFAIL %s\n %s\n' "$1" "$2" >&2; exit 1; }
30
+ pass() { printf 'ok %s\n %s\n' "$1" "$2"; }
31
+ head_ln() { printf '\n-- %s\n' "$1"; }
32
+
33
+ # ── (e) the host itself ───────────────────────────────────────────────────────
34
+ # First, because every failure below is ambiguous until this one is green: a
35
+ # 502 from the proxy and a broken board answer the same shape of nothing.
36
+ head_ln "healthz — the host is up"
37
+ body=$($C "$BASE/healthz") || fail "GET /healthz" "no answer from $BASE"
38
+ case $body in
39
+ *'"ok": true'*|*'"ok":true'*) pass "GET /healthz" "$body" ;;
40
+ *) fail "GET /healthz" "$body" ;;
41
+ esac
42
+
43
+ # ── (a) the board's own address IS the page ───────────────────────────────────
44
+ # Reported 2026-08-31: the page was at /<board>/ui/ and /<board>/ was a 404 —
45
+ # the shortest address on the host, the one a person types, answered nothing.
46
+ # Asserted on the BARE path too: /<board> without the trailing slash is what
47
+ # gets pasted into Slack, and a router that only handles one of the two looks
48
+ # fine in a browser (it follows the redirect) and breaks every copied link.
49
+ head_ln "the page at the board's own address"
50
+ for path in "/$BOARD/" "/$BOARD"; do
51
+ page=$($C "$BASE$path") || fail "GET $path" "no answer"
52
+ case $page in
53
+ *'app.js'*) pass "GET $path" "html referencing app.js ($(printf %s "$page" | wc -c | tr -d ' ') bytes)" ;;
54
+ *) fail "GET $path" "not the page: $(printf %s "$page" | head -c 200)" ;;
55
+ esac
56
+ done
57
+ # And its assets resolve from that address: the page's own links are relative
58
+ # (./app.js off index.html), so they arrive as board TAILS and the router has to
59
+ # tell them from the machine doors. If this 404s, the page loads unstyled and
60
+ # blank with nothing in the server log to say why.
61
+ for asset in app.js style.css; do
62
+ code=$($C -o /dev/null -w '%{http_code} %{content_type}' "$BASE/$BOARD/$asset") \
63
+ || fail "GET /$BOARD/$asset" "no answer"
64
+ case $code in
65
+ 200*) pass "GET /$BOARD/$asset" "$code" ;;
66
+ *) fail "GET /$BOARD/$asset" "$code" ;;
67
+ esac
68
+ done
69
+
70
+ # ── (b) a bare `Bearer` reads a PUBLIC board ──────────────────────────────────
71
+ # THE fault of this chapter, shipped in 0.5.0 and reported 2026-08-31: the page
72
+ # sends `Authorization: "Bearer " + token()`, so with no token the header
73
+ # arrives as `Bearer`, and the old extractor returned the literal string
74
+ # "Bearer" as somebody's token — "unknown credential — run: taskops join" to a
75
+ # reader who presented nothing. Anonymous reads worked only with NO header at
76
+ # all, which no browser client can express. Both shapes are asserted: the
77
+ # no-header stranger (what the suite always tested) and the bare bearer (what
78
+ # every browser actually sends).
79
+ head_ln "a bare Bearer is no credential — public board reads"
80
+ rpc() { # rpc <path> [<authorization value>]
81
+ if [ $# -gt 1 ]; then
82
+ $C -o /dev/null -w '%{http_code}' -X POST "$BASE$1" \
83
+ -H 'Content-Type: application/json' -H "Authorization: $2" \
84
+ -d '{"verb": "board", "args": {}}'
85
+ else
86
+ $C -o /dev/null -w '%{http_code}' -X POST "$BASE$1" \
87
+ -H 'Content-Type: application/json' -d '{"verb": "board", "args": {}}'
88
+ fi
89
+ }
90
+ code=$(rpc "/$BOARD/api/rpc" "Bearer") || fail "POST /$BOARD/api/rpc (bare Bearer)" "no answer"
91
+ [ "$code" = 200 ] || fail "POST /$BOARD/api/rpc (bare Bearer)" "HTTP $code — the 0.5.0 fault is back"
92
+ pass "POST /$BOARD/api/rpc Authorization: Bearer" "HTTP 200"
93
+ code=$(rpc "/$BOARD/api/rpc") || fail "POST /$BOARD/api/rpc (no header)" "no answer"
94
+ [ "$code" = 200 ] || fail "POST /$BOARD/api/rpc (no header)" "HTTP $code"
95
+ pass "POST /$BOARD/api/rpc no Authorization" "HTTP 200"
96
+ # The other half of that fix, and the reason it is not a one-line
97
+ # generalisation: a credential that WAS presented and does not check out still
98
+ # refuses loudly. "Unparseable is anonymous" would turn an expired token into a
99
+ # silent read-only downgrade instead of a sentence naming the way back in.
100
+ body=$($C -X POST "$BASE/$BOARD/api/rpc" -H 'Content-Type: application/json' \
101
+ -H 'Authorization: Bearer not-a-token' -d '{"verb": "board", "args": {}}') \
102
+ || fail "POST with a wrong token" "no answer"
103
+ case $body in
104
+ *'unknown credential'*) pass "POST /$BOARD/api/rpc Bearer not-a-token" "refused: unknown credential" ;;
105
+ *) fail "POST with a wrong token" "$body" ;;
106
+ esac
107
+
108
+ # ── (c) a PRIVATE board still refuses ─────────────────────────────────────────
109
+ # The wall moved for public boards; it must not have moved here. And the
110
+ # SENTENCE matters as much as the status: somebody who presented nothing is told
111
+ # how to join, which is a different answer from "unknown credential".
112
+ head_ln "a private board still refuses the same stranger"
113
+ body=$($C -X POST "$BASE/$PRIVATE/api/rpc" -H 'Content-Type: application/json' \
114
+ -H 'Authorization: Bearer' -d '{"verb": "board", "args": {}}') \
115
+ || fail "POST /$PRIVATE/api/rpc" "no answer"
116
+ # Grepped for `no credential` and NOT for `taskops join`: both refusals name
117
+ # that command, so the looser grep passes on the 0.5.0 fault itself — where a
118
+ # private board answered `unknown credential` to a stranger who presented
119
+ # nothing. The whole point is WHICH sentence comes back.
120
+ case $body in
121
+ *'no credential'*) pass "POST /$PRIVATE/api/rpc Authorization: Bearer" "refused: no credential — run: taskops join" ;;
122
+ *) fail "POST /$PRIVATE/api/rpc" "expected the no-credential refusal, got: $body" ;;
123
+ esac
124
+
125
+ # ── (d) a card branch renders a diff from the forge mirror ────────────────────
126
+ # The hosted window's whole point: a reader with NO clone. Before the mirror,
127
+ # every diff fell through the UI's cascade to a forge link — a blank presented
128
+ # as the window. A tk-* branch on purpose: `master` is fetched by any clone, and
129
+ # the failure this catches is a mirror whose on-demand fetch never ran, which
130
+ # only shows on a ref the mirror did not have at clone time.
131
+ head_ln "a card branch diffs from the mirror"
132
+ body=$($C "$BASE/$BOARD/api/git/commit/$BRANCH") || fail "GET git/commit/$BRANCH" "no answer"
133
+ case $body in
134
+ *'"patch"'*) pass "GET /$BOARD/api/git/commit/$BRANCH" "a diff ($(printf %s "$body" | wc -c | tr -d ' ') bytes)" ;;
135
+ *) fail "GET git/commit/$BRANCH" "no diff: $(printf %s "$body" | head -c 300)" ;;
136
+ esac
137
+
138
+ # ── (f) every 0.5.0 address still answers ─────────────────────────────────────
139
+ # Links were pasted the day they existed, .mcp.json files are configured against
140
+ # them, four legacy-bearer production boards speak them, and `taskops ui`'s
141
+ # upstream forward builds them. An old address that 404s in silence is this
142
+ # chapter's own rule broken.
143
+ head_ln "the 0.5.0 addresses, unprefixed"
144
+ code=$($C -o /dev/null -w '%{http_code}' "$BASE/$BOARD/ui/") || fail "GET /$BOARD/ui/" "no answer"
145
+ [ "$code" = 200 ] || fail "GET /$BOARD/ui/" "HTTP $code"
146
+ pass "GET /$BOARD/ui/" "HTTP 200"
147
+ code=$(rpc "/$BOARD/rpc" "Bearer") || fail "POST /$BOARD/rpc" "no answer"
148
+ [ "$code" = 200 ] || fail "POST /$BOARD/rpc" "HTTP $code"
149
+ pass "POST /$BOARD/rpc" "HTTP 200"
150
+ code=$($C -o /dev/null -w '%{http_code}' "$BASE/$BOARD/git/commit/$BRANCH") \
151
+ || fail "GET /$BOARD/git/commit/$BRANCH" "no answer"
152
+ [ "$code" = 200 ] || fail "GET /$BOARD/git/commit/$BRANCH" "HTTP $code"
153
+ pass "GET /$BOARD/git/commit/$BRANCH" "HTTP 200"
154
+ # /feed is a stream that never ends by design, so this reads the HEADERS and
155
+ # hangs up. curl's own timeout is therefore EXPECTED here and not a failure —
156
+ # what is asserted is the content type, which is what tells an SSE client it may
157
+ # start reading.
158
+ headers=$(curl -sS -m 3 -D - -o /dev/null "$BASE/$BOARD/feed" 2>/dev/null || true)
159
+ case $headers in
160
+ *event-stream*) pass "GET /$BOARD/feed" "text/event-stream" ;;
161
+ *) fail "GET /$BOARD/feed" "no event-stream header: $(printf %s "$headers" | tr '\r\n' ' ')" ;;
162
+ esac
163
+
164
+ printf '\nall green — %s, board %s\n' "$HOST" "$BOARD"
@@ -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.1" # single source of truth; pyproject reads it dynamically