taskops-cli 0.5.0__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.5.0 → taskops_cli-0.5.1}/ARCHITECTURE.md +96 -1
  2. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/CHANGELOG.md +34 -0
  3. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/CLAUDE.md +11 -0
  4. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/PKG-INFO +22 -2
  5. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/README.md +21 -1
  6. taskops_cli-0.5.1/smoke.sh +164 -0
  7. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_version.py +1 -1
  8. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/auth.py +19 -1
  9. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/handler.py +12 -11
  10. taskops_cli-0.5.1/src/taskops/http/routes.py +32 -0
  11. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/static.py +19 -0
  12. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/ui/app.js +5 -5
  13. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/ui/index.html +4 -2
  14. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_topology.py +180 -0
  15. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/index.html +4 -2
  16. taskops_cli-0.5.1/ui/smoke/sections/board-root-mount.tsx +75 -0
  17. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/client.ts +24 -3
  18. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/main.tsx +6 -8
  19. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.claude/settings.json +0 -0
  20. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.gitattributes +0 -0
  21. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.github/workflows/ci.yml +0 -0
  22. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.github/workflows/workflow.yml +0 -0
  23. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.gitignore +0 -0
  24. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.mcp.json +0 -0
  25. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite +0 -0
  26. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite-shm +0 -0
  27. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite-wal +0 -0
  28. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/events.jsonl +0 -0
  29. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/live.sqlite +0 -0
  30. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/live.sqlite-shm +0 -0
  31. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/live.sqlite-wal +0 -0
  32. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board.json +0 -0
  33. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/reports/lifecycle-chapter.html +0 -0
  34. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/reports/reports-chapter.html +0 -0
  35. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/ui.lock +0 -0
  36. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/pyproject.toml +0 -0
  37. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/__init__.py +0 -0
  38. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_clock.py +0 -0
  39. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_errors.py +0 -0
  40. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_ids.py +0 -0
  41. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_json.py +0 -0
  42. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_locate.py +0 -0
  43. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_wire.py +0 -0
  44. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/board.py +0 -0
  45. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/__init__.py +0 -0
  46. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/__main__.py +0 -0
  47. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/admin.py +0 -0
  48. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/claude.py +0 -0
  49. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/commands.py +0 -0
  50. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/enrol.py +0 -0
  51. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/github.py +0 -0
  52. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/grants.py +0 -0
  53. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/hooks.py +0 -0
  54. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/main.py +0 -0
  55. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/operate.py +0 -0
  56. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/paging.py +0 -0
  57. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/parser.py +0 -0
  58. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/pull.py +0 -0
  59. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/push.py +0 -0
  60. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/remote.py +0 -0
  61. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/rm.py +0 -0
  62. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/serving.py +0 -0
  63. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/team.py +0 -0
  64. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/watch.py +0 -0
  65. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/window.py +0 -0
  66. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/wording.py +0 -0
  67. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/__init__.py +0 -0
  68. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/actors.py +0 -0
  69. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/challenge.py +0 -0
  70. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/chapters.py +0 -0
  71. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/event.py +0 -0
  72. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/forge.py +0 -0
  73. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/graph.py +0 -0
  74. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/holding.py +0 -0
  75. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/hours.py +0 -0
  76. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/kinds.py +0 -0
  77. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/machine.py +0 -0
  78. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/mentions.py +0 -0
  79. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/replay.py +0 -0
  80. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/reports.py +0 -0
  81. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/review.py +0 -0
  82. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/scope.py +0 -0
  83. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/seams.py +0 -0
  84. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/types.py +0 -0
  85. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/__init__.py +0 -0
  86. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/bind.py +0 -0
  87. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/catchup.py +0 -0
  88. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/claudefiles.py +0 -0
  89. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/diff.py +0 -0
  90. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/install.py +0 -0
  91. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/landing.py +0 -0
  92. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/mirror.py +0 -0
  93. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/patch.py +0 -0
  94. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/remote.py +0 -0
  95. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/run.py +0 -0
  96. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/sig.py +0 -0
  97. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/trailer.py +0 -0
  98. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/trees.py +0 -0
  99. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/__init__.py +0 -0
  100. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/admin.py +0 -0
  101. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/feed.py +0 -0
  102. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/gitdoor.py +0 -0
  103. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/grants.py +0 -0
  104. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/ingest.py +0 -0
  105. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/login.py +0 -0
  106. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/members.py +0 -0
  107. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/mounts.py +0 -0
  108. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/removal.py +0 -0
  109. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/repos.py +0 -0
  110. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/rpc.py +0 -0
  111. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/scoped.py +0 -0
  112. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/server.py +0 -0
  113. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/stale.py +0 -0
  114. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/upstream.py +0 -0
  115. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/watcher.py +0 -0
  116. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/identity.py +0 -0
  117. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/__init__.py +0 -0
  118. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/__main__.py +0 -0
  119. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/activity.py +0 -0
  120. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/before.py +0 -0
  121. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/boards.py +0 -0
  122. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/boardview.py +0 -0
  123. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/brief.py +0 -0
  124. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/chapter.py +0 -0
  125. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/dossier.py +0 -0
  126. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/fields.py +0 -0
  127. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/gitmoves.py +0 -0
  128. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/hello.py +0 -0
  129. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/integrate.py +0 -0
  130. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/orders.py +0 -0
  131. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/render.py +0 -0
  132. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/schema.py +0 -0
  133. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/server.py +0 -0
  134. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/thread.py +0 -0
  135. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/tools.py +0 -0
  136. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/py.typed +0 -0
  137. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/session.py +0 -0
  138. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/__init__.py +0 -0
  139. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/cache.py +0 -0
  140. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/creds.py +0 -0
  141. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/handover.py +0 -0
  142. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/live.py +0 -0
  143. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/log.py +0 -0
  144. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/pubkeys.py +0 -0
  145. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/reviews.py +0 -0
  146. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/server.py +0 -0
  147. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/stores.py +0 -0
  148. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/ui/style.css +0 -0
  149. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/__init__.py +0 -0
  150. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_args.py +0 -0
  151. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_cards.py +0 -0
  152. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_chapter.py +0 -0
  153. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_context.py +0 -0
  154. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_facts.py +0 -0
  155. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_mentions.py +0 -0
  156. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_rows.py +0 -0
  157. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_stories.py +0 -0
  158. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_waiting.py +0 -0
  159. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_windows.py +0 -0
  160. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/activity.py +0 -0
  161. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/assign.py +0 -0
  162. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/card.py +0 -0
  163. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/events.py +0 -0
  164. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/filed.py +0 -0
  165. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/plan.py +0 -0
  166. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/project.py +0 -0
  167. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/pulse.py +0 -0
  168. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/record.py +0 -0
  169. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/report.py +0 -0
  170. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/review.py +0 -0
  171. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/take.py +0 -0
  172. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/update.py +0 -0
  173. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/__init__.py +0 -0
  174. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/conftest.py +0 -0
  175. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_architecture.py +0 -0
  176. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_claude.py +0 -0
  177. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_core.py +0 -0
  178. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_git.py +0 -0
  179. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_mcp.py +0 -0
  180. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_mirror.py +0 -0
  181. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_report.py +0 -0
  182. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_store.py +0 -0
  183. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_ui.py +0 -0
  184. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_verbs.py +0 -0
  185. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_window.py +0 -0
  186. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/ui_harness.js +0 -0
  187. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/build.mjs +0 -0
  188. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/package-lock.json +0 -0
  189. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/package.json +0 -0
  190. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/fixture.json +0 -0
  191. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/main.tsx +0 -0
  192. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/run.mjs +0 -0
  193. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/actors-window-filter.tsx +0 -0
  194. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/actors.tsx +0 -0
  195. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/all-chapters-scope.tsx +0 -0
  196. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/board-columns.tsx +0 -0
  197. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/board-flow-view.tsx +0 -0
  198. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/board-motion-and-avatars.tsx +0 -0
  199. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-completion-derivation.tsx +0 -0
  200. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-landed.tsx +0 -0
  201. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-story-grid.tsx +0 -0
  202. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/chapters-open.tsx +0 -0
  203. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/comment-box.tsx +0 -0
  204. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/comment-toasts-model.tsx +0 -0
  205. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/comment-toasts-stack.tsx +0 -0
  206. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/diff-page.tsx +0 -0
  207. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/dossier.tsx +0 -0
  208. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/event-stream.tsx +0 -0
  209. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/feed-reconnect.tsx +0 -0
  210. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/forge-links.tsx +0 -0
  211. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/forge-opens-the-board.tsx +0 -0
  212. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/git-diff.tsx +0 -0
  213. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/hosted-window.tsx +0 -0
  214. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/monitor-panes.tsx +0 -0
  215. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/overlay-escape.tsx +0 -0
  216. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/report-markdown.tsx +0 -0
  217. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/report-sandbox.tsx +0 -0
  218. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/reviewing-skew.tsx +0 -0
  219. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/section.ts +0 -0
  220. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/swarm-topology.tsx +0 -0
  221. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/thread-closing-note.tsx +0 -0
  222. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/worktrees-index.tsx +0 -0
  223. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections.mjs +0 -0
  224. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/App.tsx +0 -0
  225. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/completed.ts +0 -0
  226. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/actors/Daysheet.tsx +0 -0
  227. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/actors/DevPanel.tsx +0 -0
  228. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/CardTile.tsx +0 -0
  229. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/Column.tsx +0 -0
  230. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/ViewToggle.tsx +0 -0
  231. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/flip.ts +0 -0
  232. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/useFlip.ts +0 -0
  233. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/CommentBox.tsx +0 -0
  234. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/Drawer.tsx +0 -0
  235. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/Patch.tsx +0 -0
  236. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/Sections.tsx +0 -0
  237. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/Thread.tsx +0 -0
  238. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/split.ts +0 -0
  239. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/tokens.ts +0 -0
  240. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/AvatarStack.tsx +0 -0
  241. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/Header.tsx +0 -0
  242. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/KpiRail.tsx +0 -0
  243. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/MilestonePicker.tsx +0 -0
  244. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/TabNav.tsx +0 -0
  245. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/flow/FlowView.tsx +0 -0
  246. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/flow/layout.ts +0 -0
  247. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Chapter.tsx +0 -0
  248. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/DependencyChain.tsx +0 -0
  249. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/EditSurface.tsx +0 -0
  250. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/EventStream.tsx +0 -0
  251. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/LeaseHealth.tsx +0 -0
  252. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/LiveLeases.tsx +0 -0
  253. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Mentions.tsx +0 -0
  254. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Pane.tsx +0 -0
  255. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Swarm.tsx +0 -0
  256. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Throughput.tsx +0 -0
  257. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/panels.ts +0 -0
  258. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/reports/ReportFrame.tsx +0 -0
  259. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/Avatar.tsx +0 -0
  260. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/Markdown.tsx +0 -0
  261. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/Overlay.tsx +0 -0
  262. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/Segmented.tsx +0 -0
  263. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/overlayStack.ts +0 -0
  264. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/story/ChapterStory.tsx +0 -0
  265. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/story/stats.ts +0 -0
  266. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/toasts/ToastStack.tsx +0 -0
  267. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/toasts/model.ts +0 -0
  268. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/toasts/useToasts.ts +0 -0
  269. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/format.ts +0 -0
  270. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/hoursWindow.ts +0 -0
  271. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/links.tsx +0 -0
  272. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/markdown.ts +0 -0
  273. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Actors.tsx +0 -0
  274. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Board.tsx +0 -0
  275. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Monitor.tsx +0 -0
  276. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Reports.tsx +0 -0
  277. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/WorktreeDiff.tsx +0 -0
  278. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Worktrees.tsx +0 -0
  279. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/theme/theme.ts +0 -0
  280. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/theme/tokens.css +0 -0
  281. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/types.ts +0 -0
  282. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/useBoard.ts +0 -0
  283. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/useEvents.ts +0 -0
  284. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/styles/index.css +0 -0
  285. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/tsconfig.json +0 -0
  286. {taskops_cli-0.5.0 → taskops_cli-0.5.1}/uv.lock +0 -0
@@ -1508,7 +1508,10 @@ construction and never sniffed per request** (`Mounts(repo=…)`,
1508
1508
  The `/ui/` column arrived later, and it is the same `repo` deciding both — see
1509
1509
  "API ONLY is now literal" below. Both cells of the `serve` row are narrowed a
1510
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.
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.
1512
1515
 
1513
1516
  A viewer meets that gap whenever this clone cannot answer — a ref not fetched
1514
1517
  yet, a shallow checkout, a commit from before this repo had the branch — and
@@ -1823,6 +1826,98 @@ starts from the server's real shape, and `null` (fewer than two ready cards,
1823
1826
  nothing to decide) is deliberately a different state from absent (a board older
1824
1827
  than the wave).
1825
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
+
1826
1921
  ---
1827
1922
 
1828
1923
  ## 17. Deployed — taskops.bernardocastro.dev is v2 (2026-08-08)
@@ -3,6 +3,40 @@
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
+
6
40
  ## 0.5.0 — the hosted window
7
41
 
8
42
  - **A board can be READ without a clone.** Until now the dashboard was only ever
@@ -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.5.0
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 — /ui and /git open per declared forge
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
@@ -389,6 +389,26 @@ never asks the server for the bytes. A host running `taskops serve` answers
389
389
  hosted window, for the reader with no clone. A board that never declared one
390
390
  refuses with a sentence naming `taskops board forge <owner>/<repo>`.
391
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>`.
411
+
392
412
  **Reading one, from the other side.** Everything above is the author's half; a
393
413
  reader needs no ceremony at all. Three doors onto the same bytes:
394
414
 
@@ -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 — /ui and /git open per declared forge
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
@@ -377,6 +377,26 @@ never asks the server for the bytes. A host running `taskops serve` answers
377
377
  hosted window, for the reader with no clone. A board that never declared one
378
378
  refuses with a sentence naming `taskops board forge <owner>/<repo>`.
379
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>`.
399
+
380
400
  **Reading one, from the other side.** Everything above is the author's half; a
381
401
  reader needs no ceremony at all. Three doors onto the same bytes:
382
402
 
@@ -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.5.0" # single source of truth; pyproject reads it dynamically
4
+ __version__ = "0.5.1" # single source of truth; pyproject reads it dynamically
@@ -52,8 +52,26 @@ def token_in(header: str, path: str) -> str:
52
52
  agent's client sends a header, a browser follows a link (`?token=`), and a
53
53
  newcomer redeems an invite (`?invite=`). One extractor, so a door added
54
54
  later — /feed, /git — cannot accidentally invent a second way in.
55
+
56
+ POST-MORTEM (shipped broken in 0.5.0, reported 2026-08-31): this used to be
57
+ `header.removeprefix("Bearer ").strip()`, and that one line made every
58
+ PUBLIC board unreadable from its own hosted page. The page's client sends
59
+ `Authorization: "Bearer " + token()` — with no token, the header reaches
60
+ the server as `Bearer` (the trailing space stripped in transit), the prefix
61
+ `"Bearer "` does not match, and the LITERAL STRING "Bearer" came back as
62
+ the token. `creds.check` then answered "unknown credential — run: taskops
63
+ join" to a reader who presented nothing at all. Anonymous reads worked only
64
+ when NO Authorization header was sent — which no browser client does. So:
65
+ scheme and value are parsed as separate whitespace-delimited fields, the
66
+ scheme case-insensitive per RFC 7235, and a bearer scheme with NO value is
67
+ NO credential presented — the query-string fallback and, behind it, the
68
+ anonymous gate still run. This is deliberately narrow: a header that
69
+ carries any actual value — wrong, expired, or under a scheme this server
70
+ never spoke — is a credential that WAS presented, and it refuses exactly as
71
+ it always has. "Unparseable" is not "anonymous".
55
72
  """
56
- token = header.removeprefix("Bearer ").strip()
73
+ scheme, _, value = header.strip().partition(" ")
74
+ token = value.strip() if scheme.lower() == "bearer" else header.strip()
57
75
  if token:
58
76
  return token
59
77
  for part in path.partition("?")[2].split("&"):
@@ -1,7 +1,7 @@
1
1
  """The request handler — one method per door, split out of `server.py` so the
2
2
  router (the class) and the server's lifecycle (`BoardServer`, `serve`) each
3
- have room. What each route MEANS is `server.py`'s docstring; this file is only
4
- how one request travels through it.
3
+ have room. What each route MEANS is `server.py`'s docstring; this file is
4
+ only how one request travels through it (`routes.py` reads the path).
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -13,6 +13,8 @@ from http.server import BaseHTTPRequestHandler
13
13
  from . import rpc, feed, admin, login, static, gitdoor
14
14
  from .. import _clock
15
15
  from .auth import Credential, token_in, anonymous
16
+ from .mounts import NAME
17
+ from .routes import split
16
18
  from .._errors import BadRequest, TaskopsError
17
19
  from .._version import __version__
18
20
 
@@ -67,7 +69,10 @@ class Handler(BaseHTTPRequestHandler):
67
69
  elif tail.startswith("git/"):
68
70
  self._git(board, tail[4:])
69
71
  elif tail.startswith("ui"):
70
- self._static(board, tail[2:])
72
+ self._static(board, tail[2:]) # 0.5.0's address, kept — links were pasted
73
+ elif self.mounts.ui is None and NAME.match(board) and (not tail or static.asset(tail)):
74
+ # the page at the board's OWN address; assets are a CLOSED set
75
+ self._static(board, tail)
71
76
  elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
72
77
  self._send(200, *page) # a WINDOW serves its page at the ROOT
73
78
  else:
@@ -129,8 +134,10 @@ class Handler(BaseHTTPRequestHandler):
129
134
  return gitdoor.answer(repo, rest, self.path.partition("?")[2], mirrored=mirrored)
130
135
 
131
136
  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
137
+ """The page — at the board's own root since tk-32d2ba, and still at
138
+ /ui/, the 0.5.0 address (kept: links were pasted). A WINDOW serves
139
+ its bundle to whoever reaches the port, unchanged. A serve-mode HOST
140
+ serves the SAME packaged bundle for a board
134
141
  whose owner declared a forge (`repos.backed` — the fact, never a clone),
135
142
  behind the credential /rpc asks for: public board, anonymous READ;
136
143
  private board, the join refusal. The 410 comes BEFORE the credential on
@@ -191,9 +198,3 @@ class Handler(BaseHTTPRequestHandler):
191
198
 
192
199
  def _fail(self, status: int, err: TaskopsError) -> None:
193
200
  self._json(status, rpc.failure(err))
194
-
195
-
196
- def split(path: str) -> tuple[str, str]:
197
- clean = path.partition("?")[0].strip("/")
198
- board, _, tail = clean.partition("/")
199
- return board, tail
@@ -0,0 +1,32 @@
1
+ """Where a request path splits into `(board, tail)` — ONE function, shared by
2
+ GET and POST so the two methods cannot disagree about what a path names. Split
3
+ out of `handler.py` at the seam that owns no HTTP at all (this module sees
4
+ strings, never sockets), because the handler sits on the ≤200-line budget
5
+ `tests/test_architecture.py` enforces.
6
+
7
+ `api/` is stripped HERE, once. Since the board's own address became the page
8
+ (tk-32d2ba), the machine doors — rpc, git, feed, invite/redeem — also answer
9
+ under `/<board>/api/…`, which is the spelling the page uses from now on.
10
+ Stripping in the split rather than per door means every door gets both
11
+ spellings and none can drift: `/<board>/api/rpc` IS `/<board>/rpc` — same
12
+ handler, same credential, same words. The 0.5.0 spellings stay, unprefixed,
13
+ because agents, the MCP client, `taskops ui`'s upstream forward and four
14
+ legacy production boards speak them today.
15
+
16
+ The prefix cannot shadow a page asset: the packaged bundle's filenames are a
17
+ CLOSED SET and none of them is named `api` (`static.asset` argues the same
18
+ fact from the router's other side). And the ROOT is untouched — `/login`,
19
+ `/rpc` and `/healthz` are one segment, so the strip, which lives in the TAIL,
20
+ never sees them; a board named `api` still answers at `/api/…` because the
21
+ strip runs after the board segment is taken.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+
27
+ def split(path: str) -> tuple[str, str]:
28
+ clean = path.partition("?")[0].strip("/")
29
+ board, _, tail = clean.partition("/")
30
+ if tail.startswith("api/"):
31
+ tail = tail[4:]
32
+ return board, tail
@@ -86,6 +86,25 @@ def resolve(root: Path, rest: str) -> Path | None:
86
86
  return fallback if fallback.is_file() else None
87
87
 
88
88
 
89
+ def asset(rest: str) -> bool:
90
+ """Is this tail a file the packaged bundle actually ships?
91
+
92
+ Since tk-32d2ba the page lives at the board's OWN address, so its relative
93
+ links — `./style.css`, `./app.js` off `index.html` — arrive as
94
+ `/<board>/style.css` and the router must tell the page's tails from a
95
+ board's machine doors. It can, deterministically: the bundle's filenames
96
+ are a CLOSED SET — whatever `ui/build.mjs` wrote into the wheel, flat,
97
+ every suffix in `TYPES` — and none of them is `rpc`, `git`, `feed`, `ui`
98
+ or `api`, so an asset can never shadow a door and a door never shadows an
99
+ asset. Checked against the real files rather than a hardcoded list, so a
100
+ bundle that grows a font ships without a router edit; a nested or
101
+ dot-leading tail is refused before disk is asked, which also keeps `..`
102
+ out without a resolve."""
103
+ if not rest or "/" in rest or rest.startswith(".") or Path(rest).suffix not in TYPES:
104
+ return False
105
+ return (PACKAGED / rest).is_file()
106
+
107
+
89
108
  def content_type(path: Path) -> str:
90
109
  return TYPES.get(path.suffix, "application/octet-stream")
91
110