taskops-cli 0.3.3__tar.gz → 0.4.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.3.3 → taskops_cli-0.4.1}/ARCHITECTURE.md +571 -127
  2. taskops_cli-0.4.1/CHANGELOG.md +332 -0
  3. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/CLAUDE.md +53 -21
  4. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/PKG-INFO +105 -45
  5. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/README.md +104 -44
  6. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_version.py +1 -1
  7. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_wire.py +25 -22
  8. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/commands.py +12 -29
  9. taskops_cli-0.4.1/src/taskops/cli/enrol.py +57 -0
  10. taskops_cli-0.4.1/src/taskops/cli/github.py +196 -0
  11. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/main.py +3 -3
  12. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/operate.py +22 -18
  13. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/parser.py +0 -9
  14. taskops_cli-0.4.1/src/taskops/cli/team.py +96 -0
  15. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/wording.py +2 -2
  16. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/chapters.py +5 -0
  17. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/graph.py +4 -0
  18. taskops_cli-0.4.1/src/taskops/core/hours.py +193 -0
  19. taskops_cli-0.4.1/src/taskops/core/kinds.py +45 -0
  20. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/scope.py +9 -0
  21. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/types.py +18 -37
  22. taskops_cli-0.4.1/src/taskops/gitwork/catchup.py +114 -0
  23. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/admin.py +6 -3
  24. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/handler.py +1 -16
  25. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/login.py +12 -8
  26. taskops_cli-0.4.1/src/taskops/http/members.py +178 -0
  27. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/before.py +10 -0
  28. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/boardview.py +10 -1
  29. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/brief.py +8 -0
  30. taskops_cli-0.3.3/src/taskops/mcp/gitmoves.py → taskops_cli-0.4.1/src/taskops/mcp/chapter.py +46 -83
  31. taskops_cli-0.4.1/src/taskops/mcp/gitmoves.py +90 -0
  32. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/integrate.py +17 -6
  33. taskops_cli-0.4.1/src/taskops/mcp/orders.py +79 -0
  34. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/schema.py +16 -53
  35. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/server.py +2 -2
  36. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/tools.py +7 -3
  37. taskops_cli-0.4.1/src/taskops/store/handover.py +61 -0
  38. taskops_cli-0.4.1/src/taskops/ui/app.js +61 -0
  39. taskops_cli-0.4.1/src/taskops/ui/style.css +1 -0
  40. taskops_cli-0.4.1/src/taskops/verbs/_chapter.py +64 -0
  41. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_facts.py +8 -0
  42. taskops_cli-0.4.1/src/taskops/verbs/_rows.py +78 -0
  43. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_waiting.py +1 -1
  44. taskops_cli-0.4.1/src/taskops/verbs/_windows.py +95 -0
  45. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/assign.py +13 -10
  46. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/plan.py +3 -0
  47. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/pulse.py +18 -0
  48. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/record.py +23 -6
  49. taskops_cli-0.4.1/src/taskops/verbs/report.py +166 -0
  50. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/update.py +9 -39
  51. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_claude.py +5 -1
  52. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_core.py +93 -9
  53. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_git.py +133 -0
  54. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_mcp.py +179 -4
  55. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_topology.py +509 -436
  56. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_ui.py +32 -2
  57. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_verbs.py +324 -6
  58. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/fixture.json +10 -0
  59. taskops_cli-0.4.1/ui/smoke/sections/actors-window-filter.tsx +266 -0
  60. taskops_cli-0.4.1/ui/smoke/sections/board-flow-view.tsx +174 -0
  61. taskops_cli-0.4.1/ui/smoke/sections/board-motion-and-avatars.tsx +205 -0
  62. taskops_cli-0.4.1/ui/smoke/sections/chapter-completion-derivation.tsx +120 -0
  63. taskops_cli-0.4.1/ui/smoke/sections/chapter-story-grid.tsx +251 -0
  64. taskops_cli-0.4.1/ui/smoke/sections/comment-toasts-model.tsx +198 -0
  65. taskops_cli-0.4.1/ui/smoke/sections/comment-toasts-stack.tsx +201 -0
  66. taskops_cli-0.4.1/ui/smoke/sections/feed-reconnect.tsx +229 -0
  67. taskops_cli-0.4.1/ui/smoke/sections/forge-opens-the-board.tsx +80 -0
  68. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections.mjs +20 -6
  69. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/App.tsx +75 -3
  70. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/client.ts +49 -6
  71. taskops_cli-0.4.1/ui/src/completed.ts +48 -0
  72. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/actors/DevPanel.tsx +10 -2
  73. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/board/CardTile.tsx +53 -22
  74. taskops_cli-0.4.1/ui/src/components/board/ViewToggle.tsx +42 -0
  75. taskops_cli-0.4.1/ui/src/components/board/flip.ts +105 -0
  76. taskops_cli-0.4.1/ui/src/components/board/useFlip.ts +96 -0
  77. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/AvatarStack.tsx +12 -44
  78. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/Header.tsx +33 -4
  79. taskops_cli-0.4.1/ui/src/components/flow/FlowView.tsx +270 -0
  80. taskops_cli-0.4.1/ui/src/components/flow/layout.ts +166 -0
  81. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/EventStream.tsx +16 -16
  82. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/panels.ts +10 -0
  83. taskops_cli-0.4.1/ui/src/components/shared/Avatar.tsx +132 -0
  84. taskops_cli-0.4.1/ui/src/components/shared/Segmented.tsx +78 -0
  85. taskops_cli-0.4.1/ui/src/components/story/ChapterStory.tsx +423 -0
  86. taskops_cli-0.4.1/ui/src/components/story/stats.ts +108 -0
  87. taskops_cli-0.4.1/ui/src/components/toasts/ToastStack.tsx +231 -0
  88. taskops_cli-0.4.1/ui/src/components/toasts/model.ts +189 -0
  89. taskops_cli-0.4.1/ui/src/components/toasts/useToasts.ts +147 -0
  90. taskops_cli-0.4.1/ui/src/hoursWindow.ts +111 -0
  91. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Actors.tsx +56 -9
  92. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Board.tsx +104 -1
  93. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Monitor.tsx +16 -13
  94. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/theme/tokens.css +17 -0
  95. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/types.ts +112 -1
  96. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/useBoard.ts +53 -9
  97. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/useEvents.ts +36 -1
  98. taskops_cli-0.3.3/CHANGELOG.md +0 -106
  99. taskops_cli-0.3.3/src/taskops/cli/enrol.py +0 -127
  100. taskops_cli-0.3.3/src/taskops/core/hours.py +0 -123
  101. taskops_cli-0.3.3/src/taskops/gitwork/catchup.py +0 -59
  102. taskops_cli-0.3.3/src/taskops/http/github.py +0 -180
  103. taskops_cli-0.3.3/src/taskops/ui/app.js +0 -60
  104. taskops_cli-0.3.3/src/taskops/ui/style.css +0 -1
  105. taskops_cli-0.3.3/src/taskops/verbs/_rows.py +0 -46
  106. taskops_cli-0.3.3/src/taskops/verbs/report.py +0 -120
  107. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.claude/settings.json +0 -0
  108. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.gitattributes +0 -0
  109. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.github/workflows/ci.yml +0 -0
  110. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.github/workflows/workflow.yml +0 -0
  111. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.gitignore +0 -0
  112. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.mcp.json +0 -0
  113. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/cache.sqlite +0 -0
  114. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/cache.sqlite-shm +0 -0
  115. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/cache.sqlite-wal +0 -0
  116. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/events.jsonl +0 -0
  117. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/live.sqlite +0 -0
  118. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/live.sqlite-shm +0 -0
  119. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/live.sqlite-wal +0 -0
  120. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board.json +0 -0
  121. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/reports/lifecycle-chapter.html +0 -0
  122. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/reports/reports-chapter.html +0 -0
  123. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/ui.lock +0 -0
  124. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/pyproject.toml +0 -0
  125. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/__init__.py +0 -0
  126. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_clock.py +0 -0
  127. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_errors.py +0 -0
  128. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_ids.py +0 -0
  129. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_json.py +0 -0
  130. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_locate.py +0 -0
  131. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/board.py +0 -0
  132. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/__init__.py +0 -0
  133. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/__main__.py +0 -0
  134. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/admin.py +0 -0
  135. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/claude.py +0 -0
  136. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/grants.py +0 -0
  137. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/hooks.py +0 -0
  138. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/paging.py +0 -0
  139. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/pull.py +0 -0
  140. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/push.py +0 -0
  141. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/remote.py +0 -0
  142. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/rm.py +0 -0
  143. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/serving.py +0 -0
  144. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/watch.py +0 -0
  145. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/window.py +0 -0
  146. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/__init__.py +0 -0
  147. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/actors.py +0 -0
  148. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/challenge.py +0 -0
  149. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/event.py +0 -0
  150. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/forge.py +0 -0
  151. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/holding.py +0 -0
  152. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/machine.py +0 -0
  153. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/mentions.py +0 -0
  154. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/replay.py +0 -0
  155. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/reports.py +0 -0
  156. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/review.py +0 -0
  157. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/seams.py +0 -0
  158. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/__init__.py +0 -0
  159. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/bind.py +0 -0
  160. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/claudefiles.py +0 -0
  161. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/diff.py +0 -0
  162. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/install.py +0 -0
  163. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/landing.py +0 -0
  164. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/patch.py +0 -0
  165. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/remote.py +0 -0
  166. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/run.py +0 -0
  167. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/sig.py +0 -0
  168. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/trailer.py +0 -0
  169. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/trees.py +0 -0
  170. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/__init__.py +0 -0
  171. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/auth.py +0 -0
  172. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/feed.py +0 -0
  173. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/gitdoor.py +0 -0
  174. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/grants.py +0 -0
  175. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/ingest.py +0 -0
  176. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/mounts.py +0 -0
  177. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/removal.py +0 -0
  178. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/rpc.py +0 -0
  179. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/scoped.py +0 -0
  180. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/server.py +0 -0
  181. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/static.py +0 -0
  182. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/upstream.py +0 -0
  183. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/watcher.py +0 -0
  184. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/identity.py +0 -0
  185. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/__init__.py +0 -0
  186. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/__main__.py +0 -0
  187. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/activity.py +0 -0
  188. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/boards.py +0 -0
  189. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/dossier.py +0 -0
  190. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/fields.py +0 -0
  191. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/hello.py +0 -0
  192. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/render.py +0 -0
  193. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/thread.py +0 -0
  194. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/py.typed +0 -0
  195. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/session.py +0 -0
  196. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/__init__.py +0 -0
  197. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/cache.py +0 -0
  198. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/creds.py +0 -0
  199. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/live.py +0 -0
  200. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/log.py +0 -0
  201. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/pubkeys.py +0 -0
  202. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/reviews.py +0 -0
  203. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/server.py +0 -0
  204. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/stores.py +0 -0
  205. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/ui/index.html +0 -0
  206. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/__init__.py +0 -0
  207. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_args.py +0 -0
  208. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_cards.py +0 -0
  209. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_context.py +0 -0
  210. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_mentions.py +0 -0
  211. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_stories.py +0 -0
  212. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/activity.py +0 -0
  213. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/card.py +0 -0
  214. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/events.py +0 -0
  215. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/filed.py +0 -0
  216. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/project.py +0 -0
  217. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/review.py +0 -0
  218. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/take.py +0 -0
  219. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/__init__.py +0 -0
  220. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/conftest.py +0 -0
  221. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_architecture.py +0 -0
  222. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_report.py +0 -0
  223. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_store.py +0 -0
  224. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_window.py +0 -0
  225. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/ui_harness.js +0 -0
  226. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/build.mjs +0 -0
  227. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/index.html +0 -0
  228. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/package-lock.json +0 -0
  229. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/package.json +0 -0
  230. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/main.tsx +0 -0
  231. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/run.mjs +0 -0
  232. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/actors.tsx +0 -0
  233. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/board-columns.tsx +0 -0
  234. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/chapter-landed.tsx +0 -0
  235. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/chapters-open.tsx +0 -0
  236. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/comment-box.tsx +0 -0
  237. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/diff-page.tsx +0 -0
  238. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/dossier.tsx +0 -0
  239. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/event-stream.tsx +0 -0
  240. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/forge-links.tsx +0 -0
  241. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/git-diff.tsx +0 -0
  242. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/monitor-panes.tsx +0 -0
  243. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/overlay-escape.tsx +0 -0
  244. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/report-markdown.tsx +0 -0
  245. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/report-sandbox.tsx +0 -0
  246. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/reviewing-skew.tsx +0 -0
  247. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/section.ts +0 -0
  248. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/swarm-topology.tsx +0 -0
  249. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/thread-closing-note.tsx +0 -0
  250. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/worktrees-index.tsx +0 -0
  251. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/actors/Daysheet.tsx +0 -0
  252. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/board/Column.tsx +0 -0
  253. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/CommentBox.tsx +0 -0
  254. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/Drawer.tsx +0 -0
  255. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/Patch.tsx +0 -0
  256. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/Sections.tsx +0 -0
  257. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/Thread.tsx +0 -0
  258. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/split.ts +0 -0
  259. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/tokens.ts +0 -0
  260. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/KpiRail.tsx +0 -0
  261. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/MilestonePicker.tsx +0 -0
  262. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/TabNav.tsx +0 -0
  263. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Chapter.tsx +0 -0
  264. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/DependencyChain.tsx +0 -0
  265. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/EditSurface.tsx +0 -0
  266. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/LeaseHealth.tsx +0 -0
  267. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/LiveLeases.tsx +0 -0
  268. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Mentions.tsx +0 -0
  269. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Pane.tsx +0 -0
  270. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Swarm.tsx +0 -0
  271. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Throughput.tsx +0 -0
  272. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/reports/ReportFrame.tsx +0 -0
  273. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/shared/Markdown.tsx +0 -0
  274. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/shared/Overlay.tsx +0 -0
  275. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/shared/overlayStack.ts +0 -0
  276. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/format.ts +0 -0
  277. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/links.tsx +0 -0
  278. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/main.tsx +0 -0
  279. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/markdown.ts +0 -0
  280. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Reports.tsx +0 -0
  281. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/WorktreeDiff.tsx +0 -0
  282. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Worktrees.tsx +0 -0
  283. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/theme/theme.ts +0 -0
  284. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/styles/index.css +0 -0
  285. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/tsconfig.json +0 -0
  286. {taskops_cli-0.3.3 → taskops_cli-0.4.1}/uv.lock +0 -0
@@ -214,7 +214,8 @@ flowchart TB
214
214
  wire["_wire — one POST, one envelope: the decoder both clients share"]
215
215
  end
216
216
  subgraph L1["1 · core (PURE — no I/O)"]
217
- types["types — Card/Milestone/Event/Lease, KINDS registry"]
217
+ types["types — Card/Milestone/Event/Lease (re-exports KINDS and the actor grammar)"]
218
+ kindsm["kinds — the event-kind registry: replayed? required body keys"]
218
219
  actors["actors — presence, folded from events"]
219
220
  event["event — construct + hash"]
220
221
  replay["replay — fold(events) → State"]
@@ -228,7 +229,8 @@ flowchart TB
228
229
  reportsm["reports — is this a report PATH, and the chapter's list as a fold"]
229
230
  holdingm["holding — 'is this history held here?', by event id: board pull + board rm"]
230
231
  forgem["forge — the forge vocabulary: which host can be asked, what access counts, owner/name"]
231
- hours["hoursworking-time math"]
232
+ seamsm["seamswhich ready cards name the same CONCEPT, from the specs alone"]
233
+ hours["hours — working-time math, and the calendar windows to report it over"]
232
234
  end
233
235
  subgraph L2["2 · store (the ONLY SQL)"]
234
236
  log["log.jsonl — append + fsync"]
@@ -246,14 +248,14 @@ flowchart TB
246
248
  mentionsv["_mentions — the ✉ read"]; waitingv["_waiting — the ◆ read"]; eventsv["events — the log, paged"]
247
249
  reviewv["review — claim a submitted card, or record the verdict"]
248
250
  projectv["project — board-level facts: op=remote, op=visibility, op=forge"]
249
- helpersv["_args _cards _context _facts _rows — the helpers; the _ says 'not a verb'"]
251
+ helpersv["_args _cards _chapter _context _facts _rows _stories _windows — the helpers; the _ says 'not a verb'; _windows owns what a window= spelling MEANS"]
250
252
  registry["__init__ — Verb(fn, kind, roles, refusal)"]
251
253
  end
252
254
  subgraph L4["4 · board.py + gitwork (the ONLY git, client-side)"]
253
255
  board["board.py — LocalBoard | RemoteBoard, routing decided ONCE"]
254
256
  run["gitwork/run"]; trees["gitwork/trees — worktrees: the GEOMETRY"]
255
257
  landing["gitwork/landing — the MERGES: card→chapter, chapter→trunk"]
256
- catchup["gitwork/catchup — one worktree, up to date with a branch"]
258
+ catchup["gitwork/catchup — one worktree, up to date with a branch; the chapter's declared union_files fold, everything else still aborts"]
257
259
  trailer["gitwork/trailer"]; bind["gitwork/bind"]
258
260
  install["gitwork/install — what GIT needs: hooks, gitignore, the address"]
259
261
  claudef["gitwork/claudefiles — what CLAUDE reads: .mcp.json, settings.json"]
@@ -265,11 +267,11 @@ flowchart TB
265
267
  identitym["identity.py — WHO signs in, with WHICH key: discover, establish"]
266
268
  end
267
269
  subgraph L5["5 · transports"]
268
- mcpsrv["mcp/server, hello, tools, gitmoves, integrate, dossier, before, render, brief, schema, thread, boards, fields"]
269
- httpsrv["http/server (lifecycle), handler (one method per door), mounts, watcher, rpc, admin, scoped, grants, ingest, removal, auth, login, github (membership enrols a key — it mints nothing), feed, static, gitdoor, upstream"]
270
+ mcpsrv["mcp/server, hello, tools, gitmoves (assign + merge dispatch), integrate (a CARD lands), chapter (a MILESTONE lands: gate, catch-up, record), dossier, before, render, brief, activity, boardview, schema, orders, thread, boards, fields"]
271
+ httpsrv["http/server (lifecycle), handler (one method per door), mounts, watcher, rpc, admin, scoped, grants, ingest, removal, auth, login, members (the owner's enrol BATCH), feed, static, gitdoor, upstream"]
270
272
  end
271
273
  subgraph L6["6 · cli"]
272
- cli["commands: init · join --key/--invite/--github · enrol: the two introductions, and WHERE a GitHub token may come from · hooks: what the two git hooks call · watch: join with nothing · serving: serve · ui · remote: remote add — which host, which board · operate: board · board visibility · board forge · grants: invite · revoke · push: board push · pull: board pull — the same five steps backwards, over paging: the whole log through the `events` verb · rm: board rm — the guardrail · admin: server init + break-glass"]
274
+ cli["commands: init · join --key/--invite · enrol: the invite introduction · github + team: the OWNER's forge sync, and WHERE a GitHub token may come from · hooks: what the two git hooks call · watch: join with nothing · serving: serve · ui · remote: remote add — which host, which board · operate: board · board visibility · board forge · grants: invite · revoke · push: board push · pull: board pull — the same five steps backwards, over paging: the whole log through the `events` verb · rm: board rm — the guardrail · admin: server init + break-glass"]
273
275
  end
274
276
 
275
277
  L1 --> L0
@@ -290,8 +292,8 @@ instead of an arbitrary one.
290
292
  **A leading `_` marks plumbing for the layer above, and it is a three-zone
291
293
  convention**: the package root (`_errors _ids _clock _json _locate _version
292
294
  _wire` are level 0; `board.py`, `session.py` and `identity.py` are that layer's
293
- doors), and `verbs/` (`_args _cards _context _facts _mentions _rows _waiting`
294
- are helpers — the un-prefixed files are the registry's entries, one per verb).
295
+ doors), and `verbs/` (`_args _cards _chapter _context _facts _mentions _rows
296
+ _stories _waiting _windows` are helpers — the un-prefixed files are the registry's entries, one per verb).
295
297
  Nowhere else carries it, because every module under `core/ store/ gitwork/
296
298
  http/ mcp/ cli/` is internal to its layer and there is nothing to distinguish;
297
299
  `import taskops` exposes five errors and a version, so module names are a
@@ -590,21 +592,19 @@ ever looked at it again or said so. It is now a STOP naming both ways out —
590
592
  delete) and join anyway. It counts EVENTS, not the directory, so the ordinary
591
593
  `init` → `join` sequence is untouched.
592
594
 
593
- **`taskops join <board> --github` is the whole introduction in one command**
594
- (`cli/commands.py::join`, `cli/enrol.py::by_github` the client half of the
595
- door in §10). It is the `--invite` path with a different proof: the pubkey
596
- travels with something that vouches for it, the host enrols it, and the key
597
- signs in ON THE SPOT through the same `session.sign_in`, so what lands in
598
- `remote.json` is a SESSION with an expiry and a `login` block and never a
599
- standing credential. **The GitHub token is never a flag value.** `--github` is
600
- a `store_true` and must stay one: a secret passed as an argument is written to
601
- the shell's history file before the process starts and is visible in `ps` to
602
- every user on the box while it runs. It comes from `gh auth token` (the CLI is
603
- already installed and already authenticated on the machine of anybody who has
604
- push on the repo, so the common case asks nothing), else `$GITHUB_TOKEN` (CI, and
605
- a shell with no `gh`), else a HIDDEN `getpass` prompt three sources, one
606
- order, in `enrol.py::github_token`. A bare `taskops join` is untouched by all of
607
- this: an enrolled key still signs in with no flags and never speaks to GitHub.
595
+ **A dev's join carries nothing about GitHub, and did for one day** (deleted
596
+ 2026-08-11, §19.1). `taskops join --github` posted the joiner's own GitHub token
597
+ for the host to verify against the declared repo; it was the `--invite` path with
598
+ a different proof, and it was the right shape for the wrong side. The owner's
599
+ `board forge` already knows the team, so the fact is established once, on their
600
+ laptop, and a dev whose key that sync published joins BARE: `taskops join`, the
601
+ address carried by the clone, the key discovered the way ssh discovers one, the
602
+ challenge signed on the spot. Nothing of theirs travels which is stronger than
603
+ never storing what did. **A GitHub token is never a flag value** survives the
604
+ flag it was written for and now lives beside the only caller left
605
+ (`cli/github.py::token`, §19.1): `gh auth token`, else `$GITHUB_TOKEN`, else a
606
+ HIDDEN `getpass` prompt, because a secret passed as an argument is in the shell's
607
+ history file before the process starts and in `ps` while it runs.
608
608
 
609
609
  **The on-box commands survive as break-glass**: `taskops invite/revoke --root
610
610
  <dir>` still work against the files, on the machine that holds them, for the day
@@ -698,7 +698,7 @@ flowchart LR
698
698
  dispatch_t -->|"assign + cut worktree"| verbA["verb: assign"] --> gitA["gitwork/trees.ensure_card"]
699
699
  take_t -->|"acquire lease"| verbT["verb: take"] --> liveT["live.sqlite INSERT OR IGNORE"]
700
700
  update_t -->|"close / release / drop / rewrite"| verbU["verb: update"]
701
- comment_t -->|"say something on ANY open card (+mentions)"| verbU
701
+ comment_t -->|"say something on ANY card, closed included (+mentions)"| verbU
702
702
  review_t -->|"task= claims · verdict= judges"| verbR["verb: review"] --> liveR["live.sqlite — the REVIEW lease"]
703
703
  merge_t -->|"--no-ff in integration worktree"| gitM["gitwork/landing.merge_card"] --> verbM["verb: merged"]
704
704
  activity_t -->|"a chapter's story, capped, no diffs"| verbAc["verb: activity — a READ"]
@@ -770,7 +770,7 @@ sequenceDiagram
770
770
  Dev->>Board: taskops_board
771
771
  Board-->>Dev: group MERGE: [tk-a1]
772
772
  Dev->>Board: taskops_merge task=tk-a1 (or tasks=[tk-a1, tk-b2] / done=true — the same path per card, in order, stopping at the first failure)
773
- Board->>Git: trees.behind() — if tk-a1 lacks the ms/* head, catchup.catch_up() merges ms/* IN tk-a1's own worktree (dirty or missing: refused, untouched; conflict: aborted clean, git's files + the count)
773
+ Board->>Git: trees.behind() — if tk-a1 lacks the ms/* head, catchup.catch_up() merges ms/* IN tk-a1's own worktree, with the chapter's union_files (from the dossier already loaded) applied as merge=union through an EPHEMERAL core.attributesFile outside the repo (dirty or missing: refused, untouched; conflict in anything undeclared: aborted clean, git's files + the count)
774
774
  Board->>Git: merge_card() --no-ff into ms/<milestone>, in the integration worktree
775
775
  Git-->>Board: sha (or refused, conflict files named, ms/* untouched)
776
776
  Board-->>Dev: merged
@@ -824,7 +824,7 @@ nobody has touched yet.
824
824
  |---|---|---|
825
825
  | **MCP over stdio** | Claude / any MCP host, per-repo | newline-delimited JSON-RPC 2.0. `initialize` returns `instructions`: the whole role protocol AND the board as of that moment (`mcp/hello.py` — the same verb and renderer an agent would call, so it is a delivered answer, not a second version of one). That is what a v1 system prompt was, minus the second place for truth to live, and it needs no hook and no settings file to be trusted. **One server per SESSION, shared by every sub-agent**, which is why identity rides on the call (`actor=`, §5) and not in the process's environment. `tools/call` on a refusal answers `isError: true` with the refusal *as the text content* — never a protocol-level error, because an agent that cannot read the way out will invent one; an unexpected exception answers the same way rather than ending the loop, since that loop is the session's only door to the board. |
826
826
  | **HTTP RPC** | a remote board's clients (`RemoteBoard`, the browser) | `POST /<board>/rpc` with `{"verb", "args", "actor"}`, `Authorization: Bearer <token>`. Answer is always an envelope: `{"ok": true, "seq": N, "data": {...}}` or `{"ok": false, "error": {"code", "message"}}` — v1 let three verbs answer with a bare array and the decoder silently turned it into `{}`. |
827
- | **GitHub introduction** | somebody joining a board that DECLARED a forge | `POST /<board>/join/github` with `{"github_token", "principal", "pubkey"}`. The host asks GitHub once `GET /repos/{owner}/{name}`, the caller's own token in the header, `permissions[need]` read out of the answer and on yes enrols the pubkey exactly as `invite/redeem` does. It mints NOTHING: the answer is `{principal, actor, role, fingerprint, repo, need}` and the caller's next call is the ordinary `/login` challenge with the key it just enrolled, so from the second call on this door is invisible. **The token is a header on one outgoing request and nothing else** — not the log, not `server.sqlite`, not `remote.json`, not the answer, not a refusal. A board that declared no forge refuses before anything leaves the host and keeps its invite-only flow; GitHub not answering is `Unreachable`, never a grant. **The opt-in is `taskops board forge <owner>/<repo> [--need push|admin]`**, owner-only and server-scope beside `board.visibility`, and `--clear` takes it back to invite-only the refusal's own sentence is TYPED as the argv of a test, so the message and the command cannot drift (`http/github.py::FORGE_VERB`, `cli/operate.py::_forge`, and the two blocks in `tests/test_topology.py`). |
827
+ | **GitHub, from the OWNER's laptop** | `taskops board forge <owner>/<repo>`, and nothing else in the system | two GETs, neither of them at a taskops door: `GET /repos/{owner}/{name}/collaborators?permission=<need>` (paginated, the owner's own token in the `Authorization` header the only authenticated call in the chapter) and `GET https://github.com/<login>.keys` (PUBLIC, `text/plain`, no credential spent). What reaches the host afterwards is `members.enroll` over the ordinary `/rpc`, carrying principals and ssh key lines: **no GitHub token ever crosses to a taskops host, and none is stored on either side** it is a header on the owner's outgoing requests and dies with the command. A board that declared no forge is not asked about at all, so it stays invite-only exactly as before. There was a door here for one day a dev POSTing their own token to `/<board>/join/github` — and §19.1 is why it is gone (`cli/github.py`, `cli/team.py`, `http/members.py`, and the tail block in `tests/test_topology.py`). |
828
828
  | **WebSocket feed** | the browser UI only | `GET /<board>/feed`, upgraded via the RFC 6455 handshake (`HTTP/1.1` required for the 101 response to be accepted — the one bug class invisible to a library-based test, caught only with a raw-socket test). A message is a *signal* (`{"type": "changed"}`), never a payload — the client refetches, so a dropped or duplicated frame can never show something the board never said. SSE is the automatic fallback for a proxy that eats the Upgrade. Agents never use this: their live channel is the "pulse" line appended to every tool result. The UI carries exactly ONE write — a comment box with a mention picker on the card panel, through the same `/rpc` door and token. |
829
829
  | **Delivery hook** | Claude Code sessions in a joined repo | `taskops hook claude`, wired by `init`/`join` into `.claude/settings.json` on `PostToolUse` + `UserPromptSubmit`. Read-only and one-way: resolves the reader (env, else worktree-path → card → holder) and injects context — `✉ …` when a mention is pending, for ANYBODY (throttled per reader per 30s); and `◆ …`, one line per group with the count and the call that clears it, when MERGE / REVIEW / STALLED is non-empty and the reader is a `dev:` (throttled per 180s, its own key). Silence otherwise, exit 0 always, any failure silent. Both reads (`mentions`, `waiting`) renew no lease. The one sanctioned Claude hook: it may deliver, never decide, store, or write. |
830
830
 
@@ -834,7 +834,7 @@ nobody has touched yet.
834
834
 
835
835
  | removed / never built | why | where it's enforced |
836
836
  |---|---|---|
837
- | a `recover` verb | doing is derived from the live lease; nothing is ever wrong to recover | no entry in `verbs/__init__.py::REGISTRY`; `tests/test_verbs.py::test_a_dead_workers_card_comes_back_by_itself` |
837
+ | a `recover` verb | doing is derived from the live lease; nothing is ever wrong to recover. Handing a card on is not that verb and never grew into one — it stayed inside `assign`, needs a named replacement, and is argued in §12 | no entry in `verbs/__init__.py::REGISTRY`; `tests/test_verbs.py::test_a_dead_workers_card_comes_back_by_itself` |
838
838
  | a reviewer ROLE, a stored review STATUS, automatic reviewer assignment | v1's review system: `peer` deadlocks, 14 closing rules over 6 modules, reviewers eating the budget of the work | review EXISTS since 2026-08-07 but narrowed: optional per card, derived from history-only events + a second lease, judged by an ordinary agent that may never judge its own work. `CARD_STATUSES` stays three; there is no reviewer role and nothing auto-assigns |
839
839
  | AUTOMATIC merges to the trunk | v1's `land` merged as a side effect of closing a card and ran checkout under working agents | a CARD cannot be merged to the trunk — `taskops_merge task=` takes no target. A finished MILESTONE lands via `taskops_merge milestone=` (2026-08-07): explicit, refused while any card is open or unintegrated, refused off-trunk, recorded as a `milestone landed` event. What stays impossible is the trunk moving as a side effect of anything |
840
840
  | git replication between clones | split-brain, two machines "owning" the same card | `RemoteBoard` never falls back to a local store on write failure (`Unreachable` instead) |
@@ -846,7 +846,9 @@ nobody has touched yet.
846
846
  | a `--force` on `taskops board push`, and a SYNC channel behind `board.ingest` | a non-empty target means two histories, and giving them an order they never had fabricates a timeline the board never observed. The precondition ("no history but its own beginning") is true exactly once in a board's life, which is what keeps the door a promotion and not replication between clones — the thing banned two rows up | `http/ingest.py` refuses and says so; `tests/test_topology.py::test_a_target_that_is_not_empty_is_refused_and_no_force_is_offered`, and the argument is the module's own docstring |
847
847
  | a `--force` on `taskops board rm` (and it is not an alias for `--discard-history`) | removing a board deletes the only copy of a history nobody can regenerate, and `--force` is a word every tool spends on something recoverable — it names no consequence, so it cannot warn. The flag that gets past the guardrail names the thing it destroys, and the guardrail itself is on the HOST, comparing the ids the caller says it holds against the ids the board really has: a wall the client enforces is a convention | `http/removal.py` refuses unless `core/holding.py` says the history is held elsewhere, and only a literal `true` opens `discard_history`; `tests/test_topology.py::test_the_flag_is_named_for_what_it_destroys_and_a_force_is_not_a_synonym` and `…::test_board_rm_refuses_a_history_this_checkout_does_not_hold_and_names_both_ways_out` |
848
848
  | a SILENT `taskops join` over a local board | the local history stayed on disk byte for byte and nothing ever looked at it again or said so — a command about connecting is what made it invisible | `cli/commands.py::_keep_or_archive` refuses naming `taskops board push` and `--discard-local` (which archives, never deletes); `tests/test_topology.py::test_join_refuses_to_orphan_a_local_board_and_names_both_ways_out` |
849
- | GitHub as a CREDENTIAL — v1's GitHub login: a stored token, a call to GitHub on every sign-in | three costs, and §18 is the whole argument: a token that travels (worth stealing for reasons unrelated to the board), a network dependency at every login (GitHub down = nobody signs in), and a SECOND identity system beside the keys, with its own enrolment, expiry and revocation to keep in step. The convenience is kept and the costs are not: GitHub is asked ONCE, and what persists is an ssh key | `http/github.py` mints nothing no token, no session, no `Set-Cookie`: the answer is `{principal, actor, role, fingerprint, repo, need}` and the caller's next call is the ordinary `/login`. The token is a header on one outgoing request and is written to no file (`tests/test_topology.py::test_the_github_token_is_written_to_no_file_and_no_event`, which greps the board log AND the host store, with a positive control), and it cannot be passed as a flag value at all (`…::test_a_token_cannot_be_passed_as_a_flag_value_at_all`). There is no `join.github` row in `core/scope.py` and no second credential type anywhere |
849
+ | GitHub as a CREDENTIAL — v1's GitHub login: a stored token, a call to GitHub on every sign-in | three costs, and §19 is the whole argument: a token that travels (worth stealing for reasons unrelated to the board), a network dependency at every login (GitHub down = nobody signs in), and a SECOND identity system beside the keys, with its own enrolment, expiry and revocation to keep in step. The convenience is kept and the costs are not: GitHub is asked ONCE, by the owner, and what persists is an ssh key | the only module that speaks to GitHub is `cli/github.py`, on the owner's machine; the host takes principals and key lines (`http/members.py`) and no token reaches it. `tests/test_topology.py::test_the_owners_token_is_spent_on_ONE_endpoint_and_written_nowhere` greps the host tree AND the checkout with a positive control, and `…::test_no_flag_on_join_takes_a_token_and_none_ever_will` holds the parser to "never a flag value". There is one `members.enroll` row in `core/scope.py` — a ROLE rule — and no second credential type anywhere |
850
+ | a GitHub door on the DEV's side — `POST /<board>/join/github`, a `--github` flag, a token discovered at join time (2026-08-11) | it made every dev's own credential travel to a host that has no business seeing one, to prove a fact the OWNER already holds; and it asked for an ssh key from people who, having push over ssh, had published one already. Deleted the day after it shipped — §19.1 | there is no route, no flag and no client function: `grep -r 'join/github\|by_github' src tests` is empty, and `test_no_flag_on_join_takes_a_token_and_none_ever_will` asserts the ABSENCE from `join --help`. The replacement is one command on the owner's side and `taskops join`, bare, on the dev's (`…::test_the_dev_whose_key_the_sync_published_joins_with_two_words`) |
851
+ | a COMMITTED `.gitattributes` (or a written `$GIT_DIR/info/attributes`) as the union-merge mechanism | a milestone's `union_files` is one chapter's convenience, and both of those are repo-wide and outlive the merge. Measured in this repo: `git rev-parse --git-path info/attributes` inside a LINKED worktree answers the COMMON dir, so writing there hands one card's declaration to every sibling merging at the same moment, and a crash between write and delete leaves it enabled for the whole repository, invisibly. A committed file is worse still: it makes the chapter's convenience permanent and reviewable as if it were a repo decision | `gitwork/catchup.py::_attributes` writes a temp file OUTSIDE the repository and passes it as `git -c core.attributesFile=…` for that one process, deleted in a `finally` on both paths. The precedence is deliberate: an in-tree `.gitattributes` BEATS `core.attributesFile`, so the dashboard bundle's `-merge` cannot be overridden by a declaration. `tests/test_git.py::test_no_attributes_file_survives_a_merge_that_worked` and `…_that_aborted` assert nothing is left in the temp dir, in the tree, or at `info/attributes` |
850
852
  | a slug in a branch name that isn't the milestone's | a renamed milestone orphaned its branch (ghost branches) | `Milestone.branch` computed once at creation, stored, never re-derived |
851
853
  | a stored `doing` | a dead worker's card claimed to be worked on forever | `CARD_STATUSES = ("open", "done", "dropped")` — `"doing"` raises `BadRequest` if ever passed to `status=` |
852
854
  | a worker-SLOT roster (held / free / lapsed as a pool) | taskops allocates no worker: `workers=[…]` is a label chosen at the call, sub-agents are ephemeral, and an actor is a name bound to the RUN of a card — a roster with capacity would be a fiction the board could never make true | the Actors view draws DEVS with their agents as lines inside them instead (`ui/src/pages/Actors.tsx`, `components/monitor/panels.ts` — both carry the post-mortem); an agent with no card is HISTORY, never "free", and `ui/smoke/sections/actors.tsx` asserts the words `free`, `slot` and `capacity` appear nowhere in that markup ("actors: NO WORKER SLOTS, under that name or any other") |
@@ -865,6 +867,68 @@ not have. A dead function that describes a banned design is worse than no
865
867
  function — the next reader takes it as permission. An abandoned lease expires;
866
868
  nothing takes a card away from its holder.
867
869
 
870
+ **Amended 2026-08-11, and the amendment is narrow.** One thing does take a card
871
+ from its holder, and it is not a recover: `taskops_assign` handing that card to
872
+ a NAMED replacement (`store/handover.py::displace`, its only caller
873
+ `verbs/assign.py`). What made `force_release()` a banned shape was that it freed
874
+ a lease into nobody's hands, on nobody's authority, to fix something the design
875
+ says is never wrong. This frees it into a named worker's, on the orchestrator's,
876
+ in the same call and the same event that names them — and the reason is that the
877
+ clock was answering a question it cannot answer. The lease's only heartbeat is
878
+ `Live.renew`, called by every verb, so *MCP traffic* stands in for *alive*, and
879
+ that proxy is wrong in both directions at once: a worker that DIED holds its card
880
+ for up to `LEASE_TTL` while the orchestrator that watched it die is refused the
881
+ hand-over, and a worker that is WORKING — twenty quiet minutes of reading,
882
+ editing and running tests, not one MCP call — stops renewing and is reported
883
+ `stalled` while alive. No value of `LEASE_TTL` fixes both; raising it worsens the
884
+ first and lowering it worsens the second. They are one bug pulling in opposite
885
+ directions. So the clock stopped being the authority: `stalled` is now a
886
+ *report* ("quiet for N minutes", `quiet_for`), never a mechanism, and what
887
+ changes hands is decided by somebody, on the record. Nothing is taken by the
888
+ passage of time, and a displaced worker still does not lose what it built —
889
+ `core/machine.py::_not_somebody_elses` asks about the ASSIGNEE, never the clock.
890
+ Pinned by `tests/test_verbs.py::test_the_orchestrator_hands_over_a_card_whose_lease_is_still_live`
891
+ and `…::test_handing_a_card_to_the_worker_that_holds_it_leaves_its_lease_alone`.
892
+
893
+ **So a lapsed lease never blocks the owner's own close** (2026-08-14, asked by an
894
+ operator who watched a resumed worker's `done` land after its lease had expired).
895
+ It is accepted, deliberately: `core/machine.py::_not_somebody_elses` refuses only
896
+ a LIVE holder who is somebody else, so with no new holder the worker that did the
897
+ work still closes it — the same argument as above, read from the other end. A
898
+ close that demanded a live lease would punish exactly the twenty quiet minutes of
899
+ editing that the clock cannot see, and it would do it at the one moment the work
900
+ is finished. Pinned by
901
+ `tests/test_core.py::test_a_lapsed_lease_does_not_cost_you_your_own_card`.
902
+
903
+ **And a done card can never read `stalled`.** The same operator reported seeing
904
+ both at once. That is not a state the derivation can produce:
905
+ `core/graph.py::derived` answers the STORED status first, above every live fact,
906
+ so the instant the done event is in the log the card reads `done` — owner or no
907
+ owner, holder or no holder, however long the lease has been gone. `stalled`
908
+ belongs to the open branch alone. A stalled row beside a landed done is a read
909
+ that happened before the write (or a different copy of the board), never a
910
+ disagreement between the two. Pinned by
911
+ `tests/test_core.py::test_a_closed_card_derives_done_and_can_never_read_stalled`.
912
+
913
+ **And a report has to be readable.** `quiet_for` alone was not: a session limit
914
+ took five workers at once and every STALLED row said the same "quiet for 1h", so
915
+ resume-vs-reassign became an investigation per card. A stalled row now also
916
+ carries `last_event = {kind, ago}` — the last thing on the card's thread — and
917
+ `commits`, how many commit events are bound to it (`verbs/_rows.py::forensics`,
918
+ attached by `pulse.py::run` to the stalled group ALONE, exactly as `waiting_on`
919
+ is attached to blocked; every other row is byte-identical to what it was, and no
920
+ other row pays for the thread read). Dispatched-and-never-heard-from
921
+ (`edited`), claimed-then-silent (`claimed`), thinking-out-loud-then-gone
922
+ (`comment`) and work-on-the-branch (`commit`, with a count) are four different
923
+ moves, and they are now four different lines. Still derived per read, still
924
+ nothing stored, and still no cause of death: the lease's only heartbeat is MCP
925
+ traffic, so the board can say WHAT was last said and never WHY it stopped.
926
+ Rendered on the MCP board by `mcp/boardview.py::_group`; pinned by
927
+ `tests/test_verbs.py::test_a_stalled_row_says_what_its_holder_last_did_and_how_long_ago`,
928
+ `…::test_a_stalled_row_counts_the_commits_bound_to_the_card`,
929
+ `…::test_only_a_stalled_row_carries_the_forensics_keys` and
930
+ `tests/test_mcp.py::test_a_stalled_line_says_what_the_holder_last_did_and_what_is_on_the_branch`.
931
+
868
932
  The remaining "recover" mentions (`core/types.py`, `verbs/assign.py`,
869
933
  `mcp/tools.py`, `gitwork/run.py`, `core/mentions.py`, `store/live.py`) are
870
934
  intentional: each one explains why the
@@ -886,10 +950,39 @@ Milestone, next to `rules`. Both are implemented (tk-097cae, 2026-08-07): the
886
950
  ordering rule is one sentence in `mcp/server.py::INSTRUCTIONS`, delivered at
887
951
  the handshake inside `hello.py`'s budget; `Milestone.criteria` travels into
888
952
  every take like `rules` and is SHOWN at `taskops_merge milestone=`, which
889
- refuses until the human answers `criteria_met=true` recorded in the `landed`
890
- event, never judged by the machine. §10 of that post-mortem was the map from each
953
+ refuses until the human answers `criteria_met=true`, or `criteria_met=false`
954
+ with a MANDATORY `note=` (2026-08-14, tk-d65ad3: some criteria are structurally
955
+ post-landing — "seven days of live rows" for code that only deploys FROM the
956
+ trunk — and a gate that took only `true` deadlocked the chapter or invited a
957
+ lie). Both answers, and the note, are recorded in the `landed` event and never
958
+ judged by the machine; `false` in silence is the one refused outcome, in the
959
+ gate (`mcp/chapter.py::land`, before any git runs) and again in the write
960
+ (`verbs/record.py::merged`). §10 of that post-mortem was the map from each
891
961
  adoption to its test.
892
962
 
963
+ **And the seam files a chapter declares are a chapter's field, not a repo's**
964
+ (2026-08-14, tk-6882a1). Three conflicts in one real wave were the same shape —
965
+ sibling cards each APPENDING to a registry, a changelog, a barrel file — and git
966
+ already folds that shape with its built-in `union` driver. So a milestone
967
+ DECLARES the paths: `taskops_plan union_files=[…]` at creation, or
968
+ `taskops_update milestone=… union_files=[…]` afterwards, replaced WHOLE like
969
+ `rules` and `criteria` (`verbs/_chapter.py::LISTS`, `core/chapters.py`) — the
970
+ un-declare is `union_files=[]`, because an append-only log has no un-declare
971
+ event. It is SCOPED twice over. In space: only the declared paths get
972
+ `merge=union`; everything undeclared conflicts, aborts and refuses byte for
973
+ byte as before, and an in-tree `.gitattributes` still WINS over the ephemeral
974
+ file, so the dashboard bundle's `-merge` cannot be overridden by a declaration.
975
+ In time and in place: it is applied on the CARD's catch-up alone
976
+ (`mcp/integrate.py` reads it off the dossier it already loaded and passes it to
977
+ `gitwork/catchup.py::catch_up`); the chapter→trunk catch-up
978
+ (`mcp/chapter.py::_catch_up_to_trunk`) passes nothing, because a chapter meeting
979
+ a moved trunk is the human's merge, not a sibling's append. Why the mechanism is
980
+ an EPHEMERAL `core.attributesFile` outside the repo rather than a committed
981
+ `.gitattributes` or `info/attributes` is argued in §11's table — in one line,
982
+ both of those are repo-wide and outlive the merge, and inside a linked worktree
983
+ `info/attributes` resolves to the COMMON dir, which would hand one card's
984
+ declaration to every sibling merging at that moment.
985
+
893
986
  ---
894
987
 
895
988
  ## 13. Verified state at time of writing
@@ -985,6 +1078,23 @@ dashboard on a machine with no node. React is bundled, never fetched from a
985
1078
  CDN, so it also serves offline. It is a *client of the same HTTP contracts*
986
1079
  described in §10 and imports nothing from the Python tree.
987
1080
 
1081
+ Its live channel (`ui/src/client.ts::subscribe`) is **one reconnect loop with no
1082
+ terminal state but `stop()`**. It had two dead ends and neither survives: a
1083
+ WebSocket that had opened and dropped retried exactly ONCE after 500ms and then
1084
+ fell back to SSE permanently, and the SSE error handler only reported
1085
+ `onLive(false)` — so an `EventSource` that died fatally (`readyState` CLOSED, a
1086
+ non-200 while the server restarts) was retried by nothing at all, and the header
1087
+ sat on "offline" with a stale board until somebody pressed reload. Now WS retries
1088
+ with capped exponential backoff (500ms doubling to 8s: a laptop that slept
1089
+ through the night must not wait minutes after the lid opens), a fatally-closed
1090
+ SSE hands itself back to the same loop, and every failure path ends in `retry()`
1091
+ or in a transport that will. Regaining the feed pokes exactly ONE refetch — the
1092
+ transports' own `hello` frame, counted by `frame()`, never a second signal on
1093
+ `open` — so staleness heals with the connection and one recovery is one fetch.
1094
+ `subscribe` takes an injectable `Env` (`WebSocket`, `EventSource`, `setTimeout`,
1095
+ `clearTimeout`), which is what lets `ui/smoke/sections/feed-reconnect.tsx` drive
1096
+ the whole state machine headlessly, both removed dead ends included.
1097
+
988
1098
  What it shows, at this commit — the section is written to be re-read against
989
1099
  `ui/src/App.tsx` and `ui/src/components/chrome/TabNav.tsx`, which are the two
990
1100
  files that answer it:
@@ -1046,6 +1156,52 @@ files that answer it:
1046
1156
  columns (`ui/src/pages/Board.tsx`): Ready · In flight (`doing` + `stalled`,
1047
1157
  which carries a danger marker) · Review (`review` + `reviewing` + `changes`,
1048
1158
  three chips in one column) · Blocked · To merge · Done.
1159
+ When the focused chapter is COMPLETE — landed, or every group but Done empty
1160
+ with something done — the columns give way to the **chapter story**
1161
+ (`ui/src/components/story/ChapterStory.tsx`): a single landing-timeline, one
1162
+ tile per card in the order they landed, each with its closing line, diff-stat
1163
+ and worked time, under a header of aggregate stats. Completion is DERIVED per
1164
+ read from the board payload (`ui/src/components/story/stats.ts`) — no new
1165
+ verb, no stored status — the data is the existing `activity` verb through
1166
+ `useBoard`, and the view is read-only like the columns it replaces: a tile
1167
+ opens the same card drawer.
1168
+ An open chapter is read **two ways**, chosen by a `columns | flow` segmented
1169
+ control (`components/board/ViewToggle.tsx`) whose state lives on the page and
1170
+ in `localStorage`. Not a route and not a sixth tab: the flow IS the board drawn
1171
+ differently, and a tab would file it beside Monitor and Actors as another page
1172
+ with another payload. **Flow** draws the dependency graph left to right, agents
1173
+ on the nodes; all of its geometry is decided without a DOM in
1174
+ `components/flow/layout.ts`, and `FlowView.tsx` measures and paints and decides
1175
+ nothing. **Every edge it can draw ends on a BLOCKED node**, which is the
1176
+ payload read honestly rather than a shortcut: a `BoardRow` (`verbs/pulse.py::_row`)
1177
+ carries no `after` — the card's whole dependency list is sent only on the CARD
1178
+ read — and the one dependency fact on the board payload is `waiting_on`, which
1179
+ `pulse.py::run` attaches to the blocked group alone from `core/graph.py::blockers`,
1180
+ already filtered to the dependencies that have NOT closed. That is "closing a
1181
+ blocker frees its dependents by definition" reaching the drawing. The bands
1182
+ therefore cannot come from the edges — a done card with no surviving edge would
1183
+ sit beside a running one — so each state has a FLOOR (done 0 · in flight 1 ·
1184
+ waiting 2) that a longest-path over open blockers may raise and never lower.
1185
+ A blocker the payload does not carry as a row yields no edge: a line to a node
1186
+ that is not on screen is a line to nowhere.
1187
+ In BOTH views a tile that changes column **moves** rather than teleporting
1188
+ (`components/board/flip.ts` + `useFlip.ts`): First/Last/Invert/Play, one
1189
+ `requestAnimationFrame`, CSS transitions and no animation library. The old
1190
+ rects are cached at the END of every commit rather than measured on the way in,
1191
+ because the payload arrives through `useBoard` several components up and a
1192
+ refetch is not the only thing that re-lays the page out. Exits are NOT
1193
+ choreographed — a tile the board no longer says exists would be a second source
1194
+ of truth about what is on the board, for a quarter second of decoration — and
1195
+ `prefers-reduced-motion` skips the play, never the layout, which is exactly the
1196
+ behaviour that came before. The holder rides along: `shared/Avatar.tsx` is now
1197
+ the ONE disc, serving both the header presence row and the card tile, which
1198
+ disagreed before (four hash tones in the header, accent-vs-grey by role on the
1199
+ tile — one agent, two colours, which reads as two agents). It has two
1200
+ independent axes: the HUE is derived from the actor string over 24 steps
1201
+ because this board runs eight agents in a wave and four tones is a guaranteed
1202
+ collision, and `live` — the lease actually held — is the emphasis. Neither is a
1203
+ literal colour: saturation, lightness and the wash's alpha are `--disc-*`
1204
+ tokens declared once per theme.
1049
1205
  * **Actors** — the fourth view (`ui/src/pages/Actors.tsx`), and the one that
1050
1206
  answers "who has been on this board, what did they carry, and for how long".
1051
1207
  **It is a page about DEVS, and an agent is a LINE inside one.** It shipped
@@ -1065,6 +1221,14 @@ files that answer it:
1065
1221
  reads no verb and no new payload key: `team`, `doing`, `reviewing`, `stalled`
1066
1222
  and `board.hours` are four slices the board already sends, and
1067
1223
  `closed`/`commits` are counts over the SAME window, said once in the subtitle.
1224
+ **Which window is a CHOICE, and it opens on the calendar month** — 7 days ·
1225
+ This month · Last month · Total, a `Segmented` filter in the header
1226
+ (`ui/src/hoursWindow.ts`), carried into the one board call as `window=` and
1227
+ into the dev overlay unchanged. A sliding default is what read as "hours being
1228
+ discounted when a chapter closes"; §21 has the whole diagnosis. The subtitle
1229
+ says what came BACK, not what was asked: `windowSaid()` prints
1230
+ `report.window.label` — the server's own "August 2026" — and falls back to the
1231
+ day-bucket sentence for a board that sends no `window` at all.
1068
1232
  A figure the payload cannot say draws an em dash and never `0`, and a dev's
1069
1233
  totals are dev + agents — refused WHOLE when one member cannot say its own,
1070
1234
  because a sum over a subset presented as a total is the dishonesty this
@@ -1182,8 +1346,12 @@ files that answer it:
1182
1346
  constant and not a prop, so no caller can ask for a laxer frame. Everything
1183
1347
  else (forms, popups, top navigation, modals, downloads) stays withheld, and
1184
1348
  the frame carries `referrerpolicy="no-referrer"` and an empty `allow`. A
1185
- `text/plain` report is NOT framed: `srcdoc` parses as HTML, so it goes into a
1186
- `<pre>` as a React text node — escaped, whitespace intact, and no
1349
+ `.md` report is answered `content_type: text/markdown` and drawn by the
1350
+ shared markdown renderer (`ui/src/markdown.ts`
1351
+ `components/shared/Markdown.tsx`) with no frame and no sandbox — the renderer
1352
+ builds React elements and emits no HTML. Only unrecognised types fall to the
1353
+ `<pre>`: a `text/plain` report is NOT framed — `srcdoc` parses as HTML, so it
1354
+ goes into a `<pre>` as a React text node — escaped, whitespace intact, and no
1187
1355
  `dangerouslySetInnerHTML` exists anywhere in this dashboard. It is pinned
1188
1356
  headlessly in `ui/smoke/sections/report-sandbox.tsx` against a report the
1189
1357
  fixture commits WITH a hostile `<script>` in it
@@ -1195,13 +1363,43 @@ files that answer it:
1195
1363
  carries the UI's ONE write, the comment box with its mention picker
1196
1364
  . The header's milestone picker scopes every tab at once.
1197
1365
 
1366
+ * **the comment toasts**, a stack bottom-right over every tab
1367
+ (`ui/src/components/toasts/`), because the UI's one write was invisible to
1368
+ everyone but its author: a comment landed and no other session's screen said
1369
+ so. Everything is DERIVED client-side from the feed the Event pane already
1370
+ reads — nothing is stored, no verb changed and there is no Python diff behind
1371
+ it. `model.ts` is the whole of the decision-making and it is a plain module
1372
+ with no React, no timers and no clock of its own: which comments are new, what
1373
+ the preview trims to, how deep the stack goes and when a toast leaves are all
1374
+ functions taking their clock and cursor as ARGUMENTS, which is what lets
1375
+ `ui/smoke/sections/comment-toasts-model.tsx` and `…-stack.tsx` pin them under
1376
+ `react-dom/server` with no jsdom and no stopwatch. "New" is a HEAD DELTA plus
1377
+ an id set, not a per-event seq — `verbs/events.py` drops the rowid on the way
1378
+ out, so the client derives the arrival count from `head - lastHead` against a
1379
+ newest-first page one, and the id set catches the one case that arithmetic
1380
+ over-counts (a burst larger than `EVENT_PAGE`). A toast is shown once, by
1381
+ event id, ever, and the first load is SILENT: a page of history toasted on
1382
+ mount would be fifty notifications about yesterday. "Already toasted" is a set
1383
+ in memory and never a read-receipt — there is no mark-as-read verb on this
1384
+ board and there must not be one (§11). A toast carries the author's avatar,
1385
+ the card's title and the trimmed text; clicking it expands in place to the
1386
+ whole message, and a SEPARATE affordance opens the card dossier through the
1387
+ same `openCard`. The commented card's tile answers with a short-lived pulse
1388
+ that returns to rest — the BORDER untouched, `components/board/CardTile.tsx`
1389
+ argues why. No literal colour anywhere in it (`theme/tokens.css` only), and
1390
+ `prefers-reduced-motion` suppresses every animation through the query helper
1391
+ `components/board/flip.ts` already exported.
1392
+
1198
1393
  Removed on purpose and not to be rebuilt: an "Attention" screen, which is in no
1199
1394
  Nova section, and an "Hours" tab, which in Nova is the Throughput panel *inside*
1200
1395
  Monitor.
1201
1396
 
1202
1397
  **The board points at the code** (`ui/src/links.tsx`): given a slug, a sha is an
1203
- anchor to `…/commit/<sha>`, a card offers its PR-style `…/compare/<base>...<head>`
1204
- and a chapter compares against the forge's own default branch the trunk's name
1398
+ anchor to `…/commit/<sha>`, and a chapter compares against the forge's own
1399
+ default branch. The card dossier's Worktree block opens the dashboard's own
1400
+ worktree diff view (reading the local clone, no forge slug needed); forge
1401
+ `…/compare/<base>...<head>` links remain on the Worktrees index rows, the
1402
+ WorktreeDiff header and the chapter compare (the `compareUrl` consumers) — the trunk's name
1205
1403
  is not on the board, and a `main` in one of these URLs would be the UI guessing.
1206
1404
  The host is a VALUE (`BY_HOST`), not a second code path: GitLab differs by
1207
1405
  `/-/commit/` and nothing else. And it shows the diff ITSELF when the host it is
@@ -1250,7 +1448,7 @@ Three mechanisms, all client-side, all in the layers that already had git:
1250
1448
  | # | mechanism | where |
1251
1449
  |---|---|---|
1252
1450
  | 1 | a commit event carries `numstat` — `+/-` per file, `null` for a file git could not count (a binary, never a `0`) | `gitwork/bind.py` → `verbs/record.py`; drawn by `ui/src/links.tsx` on the dossier's commit list and on the Event stream |
1253
- | 2 | branches reach `origin` by **best-effort pushes** at the three lifecycle moments that already exist — done, integrate, land | `gitwork/remote.py::push`, called from `gitwork/trees.py` / `mcp/gitmoves.py`. `trees.py`'s `merge_trunk` is the precedent and its comment is the contract: *best effort; local still landed*. Never a gate, never in a commit hook, never a board fact |
1451
+ | 2 | branches reach `origin` by **best-effort pushes** at the three lifecycle moments that already exist — done, integrate, land | `gitwork/remote.py::push`, called from `mcp/gitmoves.py` (done) and `gitwork/landing.py` (integrate, land). `landing.py`'s comment is the contract: *best effort; local still landed*. Never a gate, never in a commit hook, never a board fact |
1254
1452
  | 3 | the repo's forge slug is recorded ONCE, by the side that HAS the repo (`init`/`join`), as `{host, slug, url}`, and rides on the board payload | `gitwork/remote.py::remember` → `verbs/project.py`; consumed by `ui/src/links.tsx` |
1255
1453
 
1256
1454
  **The switch for all of it is `git remote get-url origin`, never a
@@ -1372,7 +1570,9 @@ clone, exactly as a patch does: `diff.resolve` for the rev, then `git show
1372
1570
  <sha>:<path>` through `gitwork/patch.py::show`, capped on the same `CAP` and
1373
1571
  flagged the same way (`capped()` is one function so the two answers cannot
1374
1572
  disagree about what `truncated` means). It answers the same JSON envelope, so
1375
- `content_type` (`text/html` only for a literal `.html`) is a FIELD and never
1573
+ `content_type` one of three values decided by extension: `text/html` for a
1574
+ literal `.html`, `text/markdown` for `.md`, `text/plain` for everything else
1575
+ (`gitdoor.py::_kind`) — is a FIELD and never
1376
1576
  this response's header — the token lives in this origin, so no file can make it
1377
1577
  serve HTML, and deciding what to do with `text/html` is the READER's job. It is
1378
1578
  done in one place, `ui/src/components/reports/ReportFrame.tsx`: an `<iframe
@@ -1715,13 +1915,14 @@ One thing the same incident exposed and this card did NOT fix: `Mounts` caches
1715
1915
  until the process restarts. It is noted in `_configuration`'s docstring so the
1716
1916
  next reader of that module knows it, and nowhere else.
1717
1917
 
1718
- **Not yet running this chapter.** §18's door is in this tree and NOT on the
1719
- domain — proved by asking it, not assumed: `POST /axion/join/github` answers
1720
- *"nothing at /axion/join/github"*, and a `project op=forge` call on the axion
1721
- board is refused with *"op='forge' is not a project fact this board knows:
1722
- ('remote', 'visibility')"*. So the axion board has no forge declared and cannot
1723
- have one until the sixth run of *Upgrading a host*. Everything §18 describes is
1724
- true of the code and of the suite; nothing in it is true of production yet.
1918
+ **Not yet running this chapter.** §19 is in this tree and NOT on the domain —
1919
+ proved by asking it, not assumed: a `project op=forge` call on the axion board is
1920
+ refused with *"op='forge' is not a project fact this board knows: ('remote',
1921
+ 'visibility')"*. So the axion board has no forge declared and cannot have one
1922
+ until the sixth run of *Upgrading a host*. (The join door that same probe asked
1923
+ about it answered *"nothing at …"* then, from a host running older code — no
1924
+ longer exists anywhere: §19.1.) Everything §19 describes is true of the code and
1925
+ of the suite; nothing in it is true of production yet.
1725
1926
  ---
1726
1927
 
1727
1928
  ## 18. Reports — the loop, end to end (2026-08-10)
@@ -1811,103 +2012,102 @@ agreed with the goal and paid for it with the mechanism: it had **GitHub login**
1811
2012
  — you handed it a token, it kept the token, and every sign-in was another call
1812
2013
  to GitHub with it. v2 deleted that on purpose, and this chapter brings back the
1813
2014
  convenience without any of the three things it cost. The whole design is one
1814
- sentence: **GitHub proves WHO you are once, the server enrols your ssh key from
1815
- that proof, and from the second call onwards the chapter is invisible.**
2015
+ sentence: **GitHub says once who works on the repo, the host enrols their ssh
2016
+ keys from that, and from then on the chapter is invisible.**
2017
+
2018
+ The chapter shipped in two halves a day apart, and the second deleted the first.
2019
+ For one day the DEV carried the introduction (`taskops join --github`: their own
2020
+ token posted to `POST /<board>/join/github`, the host asking GitHub whether it
2021
+ had `need` on the declared repo). It worked and it was on the wrong side —
2022
+ §19.1 is the argument. What runs now:
1816
2023
 
1817
2024
  ```
1818
- taskops join axion --github
2025
+ taskops board forge <owner>/<repo> ← the OWNER, on their laptop
1819
2026
  │ gh auth token / $GITHUB_TOKEN / a hidden prompt (never a flag)
2027
+ │ 1. record the fact, then re-READ it out of the answer ← --clear asks nothing
2028
+ │ 2. GET /repos/owner/name/collaborators?permission=need · paginated
2029
+ │ the ONLY authenticated call, the token in a header and nowhere else
2030
+ │ 3. GET github.com/<login>.keys — PUBLIC, no token, no rate budget
1820
2031
 
1821
- POST /axion/join/github {github_token, principal, pubkey}
1822
- 1. read the board's OWN declaration no forge? refuse; nothing leaves the host
1823
- │ 2. GET /repos/owner/name · ONE call · token in the header, nowhere else
1824
- │ 3. permissions[need] is true → login.register(pubkey) ← what an invite does
1825
- ▼ answers {principal, actor, role, fingerprint, repo, need} — NO token, NO session
1826
- POST /login → the ordinary SSHSIG challenge, with the key just enrolled
2032
+ POST /rpc members.enroll {members: [{principal, keys}, …]} ← ONE batch
2033
+ login.register per person what an invite does, exactly
1827
2034
 
1828
- every call after this one is what it would have been with an invite
2035
+ the dev, in a fresh clone: taskops join → the ordinary SSHSIG challenge
1829
2036
  ```
1830
2037
 
2038
+ **The dev types two words and nothing about GitHub ever reaches them.** Their
2039
+ key was published on GitHub before any of this — somebody with push has already
2040
+ got one, which is the whole observation the chapter turns on — so asking them
2041
+ for a second one, or for a token, was redundant twice over.
2042
+
1831
2043
  ### The three costs, and where each one goes
1832
2044
 
1833
2045
  **1. A token that travels.** A GitHub token is a bearer for everything that
1834
2046
  account can reach; v1 stored one per user, so the board's database became worth
1835
- stealing for reasons that had nothing to do with the board. Here the token is a
1836
- LOCAL and a header: read out of one request body, put in the `Authorization` of
1837
- one outgoing call, and gone with the frame. It is not in `events.jsonl`, not in
1838
- `server.sqlite`, not in `allowed_signers`, not in `remote.json`, not in the
1839
- answer, and not spelled into any refusal — `_why()` names the repo and the
1840
- access level and never the credential. On the client side it is never a flag
1841
- VALUE either: `--github` is a `store_true`, because a secret passed as an
1842
- argument is in `~/.zsh_history` before the process starts and in `ps` for every
1843
- user on the box while it runs.
2047
+ stealing for reasons that had nothing to do with the board. Here exactly one
2048
+ token is read, on the machine of the person it belongs to, and it goes into the
2049
+ `Authorization` header of the collaborator pages and nowhere else: not to the
2050
+ taskops host at all (`members.enroll` takes principals and key lines and does not
2051
+ know what GitHub is), not in `events.jsonl`, not in `server.sqlite`, not in
2052
+ `allowed_signers`, not in `remote.json`, not spelled into a refusal
2053
+ `cli/github.py::_why` names the repo and the access level and never the
2054
+ credential. It is never a flag VALUE either: a secret passed as an argument is in
2055
+ `~/.zsh_history` before the process starts and in `ps` for every user on the box
2056
+ while it runs, so the sources are `gh auth token`, `$GITHUB_TOKEN`, a hidden
2057
+ `getpass` prompt, in that order (`cli/github.py::token`).
1844
2058
 
1845
2059
  That is asserted rather than reviewed, in `tests/test_topology.py`:
1846
- `test_the_github_token_is_written_to_no_file_and_no_event` greps every byte the
1847
- host owns the board log and the server store, not the two files somebody
1848
- remembered with a positive control proving the grep reads what the flow
1849
- actually wrote; `test_join_github_is_one_command_and_what_lands_is_a_session_never_the_token`
1850
- byte-scans the joined checkout afterwards; and
1851
- `test_a_token_cannot_be_passed_as_a_flag_value_at_all` holds the parser to it in
1852
- both argparse syntaxes.
2060
+ `test_the_owners_token_is_spent_on_ONE_endpoint_and_written_nowhere` greps the
2061
+ host's whole tree AND the owner's checkout, with a positive control proving the
2062
+ scan reads what the flow actually wrote, and checks that `<login>.keys` carried
2063
+ no credential; `test_the_dev_whose_key_the_sync_published_joins_with_two_words`
2064
+ byte-scans the joined clone afterwards; and
2065
+ `test_no_flag_on_join_takes_a_token_and_none_ever_will` holds the parser to it.
1853
2066
 
1854
2067
  **2. A network dependency at every login.** v1 could sign nobody in while GitHub
1855
2068
  was down or rate-limiting, because GitHub was the login. Here GitHub is asked
1856
- exactly ONCE in a principal's life, and by the caller's own token — so the rate
1857
- budget spent is theirs (5000/h authenticated against 60/h anonymous) and not a
1858
- shared one this host could exhaust for everybody. `GET /repos/{owner}/{name}`
1859
- answers `permissions` *for the authenticated user*, which is why one request
1860
- settles the question. After that the credential is an ssh key and the door is
1861
- `/login`; a board whose members are all enrolled never speaks to GitHub again,
1862
- and `test_a_bare_join_still_works_unchanged_for_a_key_the_host_already_knows`
1863
- pins that by counting ZERO further calls at the stub forge. When GitHub *is*
1864
- unreachable the answer is `Unreachable`, loudly a host that cannot ask does
1865
- not guess (`test_github_unreachable_refuses_LOUDLY_and_never_falls_back_to_granting`).
2069
+ when the OWNER runs one command, by the owner's own token — so the rate budget
2070
+ spent is theirs (5000/h authenticated against 60/h anonymous) and never a shared
2071
+ one this host could exhaust for everybody. After that the credential is an ssh
2072
+ key and the door is `/login`; nobody's sign-in touches GitHub, ever, and
2073
+ `test_the_dev_whose_key_the_sync_published_joins_with_two_words` pins it by
2074
+ counting ZERO calls at the stub while the dev joins. When GitHub *is* unreachable
2075
+ the sync refuses loudly and enrols nobody — a host whose team could not be listed
2076
+ does not guess at one and the fix is to run the same command again, because the
2077
+ declaration is already recorded when that happens.
1866
2078
 
1867
2079
  **3. A second identity system.** This is the one that actually killed v1's
1868
2080
  version: two kinds of "who", each with its own enrolment, expiry and revocation,
1869
2081
  and every feature afterwards written twice. There is no second kind here because
1870
- the door creates nothing of its own. It calls `login.register` — the *same*
1871
- function `invite/redeem` calls, renamed from `_register` and shared rather than
1872
- copied so what persists is a pubkey and a line in `allowed_signers`, byte for
1873
- byte what an invite leaves behind. It mints no session and returns no token, so
1874
- there is nothing to expire, nothing extra to revoke (`taskops revoke --key
1875
- SHA256:…` retires a GitHub-enrolled key exactly as it retires an invited one),
1876
- and `permit` has one table.
1877
-
1878
- **And no new row in `core/scope.py`, deliberately.** A server-scope operation is
1879
- a rule about what a ROLE may do, and the caller at this door holds no role yet
1880
- the same as at `invite/redeem`, whose authorisation is the invite it burns and
1881
- not a principal it does not have. GitHub's yes IS that proof, and the effect is
1882
- the one `key.add` already describes; a `join.github` operation would be a second
1883
- credential type standing where a role belongs. What the caller *becomes* is
1884
- `member`, through the ordinary table, on the ordinary `/login`. Nor is it the
1885
- banned anonymous write: a named account with a named access level on a named
1886
- repo caused it, which is strictly more than an invite proves.
1887
-
1888
- One refusal is not about GitHub at all. `login.register` ADDS a key to a
1889
- principal that already exists — right for an invite, since an owner re-joining
1890
- must not be demoted — so through this door it would let anybody with push on the
1891
- repo hang their own key off the name `berna`. A principal this host already
1892
- knows is therefore refused here: membership of a repo is not permission to BE
1893
- somebody (`test_membership_of_the_repo_is_not_permission_to_BE_somebody`).
2082
+ nothing in this chapter creates one. `members.enroll` calls `login.register` —
2083
+ the *same* function `invite/redeem` calls so what persists is a pubkey and a
2084
+ line in `allowed_signers`, byte for byte what an invite leaves behind. No session
2085
+ is minted and no token returned, so there is nothing to expire, nothing extra to
2086
+ revoke (`taskops revoke --key SHA256:…` retires a GitHub-enrolled key exactly as
2087
+ it retires an invited one), and `permit` has one table.
2088
+
2089
+ **The one row in `core/scope.py` is `members.enroll`, and it is a role rule.**
2090
+ The caller is a PRINCIPAL with a session an ssh key minted — the owner — and
2091
+ what it decides is who exists on this host, which is `key.add`'s wall and not a
2092
+ softer one. There is deliberately no `join.github` operation and no credential
2093
+ type beside the key: a role table answers "what may this role do", and "GitHub
2094
+ said yes" is not a role.
1894
2095
 
1895
2096
  ### Why the board must OPT IN
1896
2097
 
1897
- `forge()` is `None` for every board ever created, and the door reads it FIRST
1898
- before the body is parsed and before anything leaves the host. A board that
1899
- never declared a forge has no such door: it refuses with the sentence that says
1900
- it is opened by invite, its stub forge records nothing, and its invite flow is
1901
- byte-identical to the one it had before this chapter existed
1902
- (`test_a_board_that_declared_no_forge_keeps_its_invite_only_door`). That is not
1903
- politeness towards old boards — it is the only way "GitHub can open a board" is
1904
- not also "anybody who can name a repo can open *your* board". The declaration is
1905
- an owner's act, `{host, repo, need}`, refused loudly on every part of it
1906
- (`core/forge.py::declare`): an unknown host, a slug that is not exactly
1907
- `owner/name`, a `need` outside GitHub's own `push`/`admin`. `pull` is absent on
1908
- purpose read access to a public repo is not a membership. And it is
1909
- REVERSIBLE: `op=forge` with `repo=""` clears the fact and the board is
1910
- invite-only again, because opting in is reversible or it is a trap.
2098
+ `forge()` is `None` for every board ever created, and the sync reads it back out
2099
+ of the answer before it asks GitHub anything so a board that never declared a
2100
+ forge, and a board whose owner cleared it, talk to GitHub not at all
2101
+ (`test_the_forge_is_cleared_back_to_invite_only_by_the_same_verb` counts the
2102
+ stub's calls across both). Such a board is invite-only, byte-identically to what
2103
+ it was before this chapter existed. That is not politeness towards old boards —
2104
+ it is the only way "GitHub can open a board" is not also "anybody who can name a
2105
+ repo can open *your* board". The declaration is an owner's act, `{host, repo,
2106
+ need}`, refused loudly on every part of it (`core/forge.py::declare`): an unknown
2107
+ host, a slug that is not exactly `owner/name`, a `need` outside GitHub's own
2108
+ `push`/`admin`. `pull` is absent on purpose — read access to a public repo is not
2109
+ a membership. And it is REVERSIBLE: `op=forge` with `repo=""` clears the fact,
2110
+ because opting in is reversible or it is a trap.
1911
2111
 
1912
2112
  Reading the same fact back is asymmetric and that asymmetry is the design:
1913
2113
  `declare` refuses loudly because a human is typing and a typo that defaults to
@@ -1918,17 +2118,171 @@ than as a promise the host cannot keep.
1918
2118
 
1919
2119
  ### How a board opts in
1920
2120
 
1921
- `op=forge` is an owner's act and it now has a door a human reaches: `taskops board
2121
+ `op=forge` is an owner's act and it has one door a human reaches: `taskops board
1922
2122
  forge <owner>/<name> [--need push|admin]`, with `--clear` to make the board
1923
2123
  invite-only again. It is a server-scope OWNER operation beside `board.visibility`
1924
- in `http/admin.py::REGISTRY` — same table, same role gate, no second door.
1925
-
1926
- `http/github.py::NO_FORGE` names that command, and the naming is a TEST rather
1927
- than a string comparison: `test_the_refusal_names_a_command_the_cli_really_answers`
1928
- splits the refusal's own sentence and feeds it back as argv, then reads the fact
1929
- back. That refusal advertised a command that did not exist oncesending the one
1930
- reader already blocked to an argparse error and executing it is what makes that
1931
- the last time.
2124
+ in `http/admin.py::REGISTRY` — same table, same role gate, no second door — and
2125
+ since 2026-08-11 that same command SYNCS the repo's team into the host (§19.1).
2126
+
2127
+ ### And how anybody else finds out
2128
+
2129
+ Declaring it wrote an event on the host and nothing elsecorrectly, since a
2130
+ forge is a fact about the BOARD and a committed file would be a second place the
2131
+ truth lives. But for one chapter the READ side had the same hole the write side
2132
+ never did: the fact was in the log, `forge()` read it, the door acted on it, and
2133
+ it was in no payload. An agent with full board access could not tell that this
2134
+ board is one whose repo team the owner can sync in, and the dashboard could not
2135
+ draw what it could not read. **Discovery was by bumping into a closed door.**
2136
+
2137
+ It now rides on the `board` payload beside `visibility`, which is the same move
2138
+ for the same reason (`verbs/pulse.py`, `verbs/project.py`): derived per read
2139
+ from `project.forge(stores)`, one log, one fold, one reader — the payload simply
2140
+ stopped hiding it. That is also what makes the line above true, that neither
2141
+ `board.visibility` nor `board.forge` needs a `get` half.
2142
+
2143
+ **A board that declared no forge sends NO KEY** — not `null`, not `{}`. The
2144
+ splat in `pulse.py` is what enforces it, and it is not a nicety: `None` would be
2145
+ a third state (absent / null / a fact) for every consumer to learn, when
2146
+ `core/forge.py` spent a whole module collapsing absent, cleared and
2147
+ unintelligible into ONE answer. Proved rather than assumed — a forge-less
2148
+ board's whole payload is byte-identical across this change, and
2149
+ `tests/test_verbs.py::test_a_cleared_forge_takes_the_key_out_of_the_payload_again`
2150
+ pins that clearing a forge returns the payload to exactly the key set it had.
2151
+
2152
+ The dashboard draws it as one line under the board's own identity in the header
2153
+ — `github.com/cloudacio/Axion · push` — text and not an anchor, because
2154
+ `ui/src/links.tsx` owns every forge URL the dashboard emits and keys them off
2155
+ `BoardPayload.repo`, a DIFFERENT fact that may name a different repo. The
2156
+ command a reader would need is the line's `title`
2157
+ (`ui/src/components/chrome/Header.tsx`, `ui/smoke/sections/forge-opens-the-board.tsx`).
2158
+
2159
+ ### 19.1 The introduction moved to the OWNER (2026-08-11)
2160
+
2161
+ The half of the chapter that deleted the other half. Everything below shipped a
2162
+ day after the door above, and on the second day the door was removed: `POST
2163
+ /<board>/join/github`, the `--github` flag, the client's `by_github`, the token
2164
+ discovery inside `join`, and the six tests that pinned them. Deleted, not
2165
+ deprecated — there is no route answering 410 and no dead flag.
2166
+
2167
+ **Why the dev's door was the wrong side.** It asked each dev to prove a fact the
2168
+ owner already holds, and charged them a credential to do it: their GitHub token
2169
+ left their machine, on every first join, to be verified by a host that has no
2170
+ business seeing one. Berna put it in one sentence — requiring push on the repo
2171
+ AND a separate ssh key is redundant, because whoever pushes over ssh has already
2172
+ published the key they push with. So the same question is asked once, by the
2173
+ person whose token it is, and the dev's side becomes `taskops join`.
2174
+
2175
+ **What the deleted tests pinned, and where it went.** The token in no file: now
2176
+ `test_the_owners_token_is_spent_on_ONE_endpoint_and_written_nowhere`, over a
2177
+ wider surface (host tree *and* checkout). The three refusals by name: now
2178
+ `test_github_refusing_the_owners_token_names_the_repo_and_enrols_nobody`. An
2179
+ unreachable GitHub never degrading into a yes: the sync enrols nobody and says
2180
+ to run it again. A board with no forge never talking to GitHub: counted at the
2181
+ stub in the `--clear` test. Membership not being permission to BE somebody: it
2182
+ cannot arise — nothing enrols on a stranger's say-so any more. And the whole
2183
+ flow, from both ends, in
2184
+ `test_the_dev_whose_key_the_sync_published_joins_with_two_words`.
2185
+
2186
+ ### The host enrols in BATCH — `members.enroll` (2026-08-11)
2187
+
2188
+ The seam the owner's side of this chapter stands on, and the one row it takes in
2189
+ `core/scope.py`: this is called by a PRINCIPAL with a role — the owner, over
2190
+ `/rpc`, with a session an ssh key minted — and what it decides is *who exists on
2191
+ this host*. That is `key.add`'s wall and not a softer one because the argument is
2192
+ a list (`http/members.py`, `admin.py::REGISTRY`).
2193
+
2194
+ ```
2195
+ {"verb": "members.enroll", "args": {"members": [{"principal": "ana",
2196
+ "keys": ["ssh-ed25519 AAAA…"]}]}}
2197
+ -> {"enrolled": […], "added": [{principal, fingerprint}…], "unchanged": […],
2198
+ "skipped": [{principal, fingerprint, why}…], "others": […], "signers": 3}
2199
+ ```
2200
+
2201
+ **It does not know what GitHub is.** It receives principals and key lines; the
2202
+ forge, the collaborator list and the owner's token live entirely in the CLI that
2203
+ calls it, so there is no token to store here because none ever arrives. And the
2204
+ enrolment is `login.register` — the same function the invite door calls, which
2205
+ is what keeps "an existing principal only ever GAINS a key" one rule rather than
2206
+ two: a batch that names the owner leaves the owner an owner
2207
+ (`test_the_owner_re_enrolled_stays_owner_and_the_keys_accumulate`, beside the
2208
+ invite door's own `test_a_re_join_never_demotes_the_owner_it_only_adds_a_key`).
2209
+
2210
+ **Idempotent by SKIPPING, not by rewriting.** A key already live for its
2211
+ principal is not written again, so a re-run touches no row and `allowed_signers`
2212
+ comes out byte for byte (`test_one_batch_enrols_a_team_and_re_running_it_leaves_the_same_state`).
2213
+ Two keys are refused and REPORTED rather than obeyed: a fingerprint the owner
2214
+ revoked — otherwise the next sync undoes every revocation — and one another
2215
+ principal already holds, which the fingerprint being the primary key would MOVE,
2216
+ silently stopping somebody outside the batch from signing. The whole batch is
2217
+ validated (names and key grammar) before a single row is written, so a typo in
2218
+ the tenth entry does not leave nine enrolled.
2219
+
2220
+ **And it revokes nobody.** A principal enrolled by invite has no reason to be in
2221
+ a forge's collaborator list, so a sync that never heard of them would revoke them
2222
+ for existing. The answer instead carries `others` — every principal the batch did
2223
+ not name, with their live fingerprints, which is what `taskops revoke --key`
2224
+ takes — and the decision stays a human's
2225
+ (`test_the_answer_names_who_the_batch_did_NOT_name_and_revokes_nobody`).
2226
+
2227
+ ### The command itself — `taskops board forge` declares AND syncs
2228
+
2229
+ Declaring and syncing are ONE command because they are one intention. The owner
2230
+ who names the repo means "these people work here"; a second verb to make that
2231
+ true is a second thing to forget, and the whole point of the chapter is that the
2232
+ dev on the other end types `taskops join` and nothing else. So `taskops board
2233
+ forge <owner>/<repo>` records the fact and then, on the fact **re-read out of
2234
+ the answer** (never on its own argv, which is why `--clear` asks GitHub nothing):
2235
+
2236
+ ```
2237
+ cli/operate.py::_forge declares, signs in ONCE, hands the session down
2238
+ └─ cli/team.py::sync the flow and the report — knows no transport
2239
+ ├─ cli/github.py::token() gh auth token → $GITHUB_TOKEN → hidden prompt
2240
+ ├─ cli/github.py::collaborators() GET /repos/…/collaborators?permission=push
2241
+ │ …paginated. AUTHENTICATED, and the only call that is
2242
+ ├─ cli/github.py::keys_of() GET github.com/<login>.keys — PUBLIC, no token
2243
+ └─ members.enroll ONE batch to the host (§19 above)
2244
+ ```
2245
+
2246
+ **The token's whole life is `collaborators`.** It is read on the owner's laptop,
2247
+ put in one `Authorization` header per page, and dropped with the frame — not
2248
+ returned, not printed, not written, and *not sent to the taskops host at all*.
2249
+ That is the difference from the door this replaced, where a stranger's token
2250
+ arrived in a request body: here it never leaves the machine that owns it, and
2251
+ `test_the_owners_token_is_spent_on_ONE_endpoint_and_written_nowhere` greps the
2252
+ host's whole tree AND the checkout's for it, with a positive control so the scan
2253
+ is known to be reading real bytes.
2254
+
2255
+ **Pagination is not a detail.** A first page is 30 by default and 100 at most,
2256
+ and a team that outgrows one page is exactly the team this command exists for:
2257
+ stopping at page one enrols some of them and reports the rest as DRIFT, which
2258
+ reads to the owner as a revocation list. `test_the_forge_enrols_every_collaborator_with_push_across_every_page`
2259
+ puts five people over three pages against a real socket.
2260
+
2261
+ **Nobody is dropped in silence, and that is the whole report.** Three outcomes
2262
+ travel back and each names its own way out:
2263
+
2264
+ | what happened | printed as |
2265
+ |---|---|
2266
+ | enrolled / already there | `enrolled`, `unchanged`, `keys N added` — from the host's own answer |
2267
+ | a collaborator with no ssh key on GitHub | named, with `taskops invite <login>` |
2268
+ | a principal here who is no longer a collaborator | named, with the exact `taskops revoke --key SHA256:…` |
2269
+
2270
+ The third one is REPORTED and never acted on, for §19's reason: a principal
2271
+ enrolled by an invite is not a GitHub login, so a pruning sync would retire them
2272
+ for having been introduced the other way. The owner is excluded from that list
2273
+ outright — a `revoke` line beside the account running the command is a way to
2274
+ lock yourself out of your own host. A login GitHub capitalises is lower-cased on
2275
+ the CLI side, because a principal name is `[a-z0-9._-]` and `Ana` would be
2276
+ refused mid-batch; a `Bot` collaborator is dropped by `type`, having neither a
2277
+ legal principal name nor a key to publish.
2278
+
2279
+ `_wire.text()` exists for this, and is now the only foreign GET in the package:
2280
+ it briefly had a sibling decoding into an OBJECT, and neither of the two answers
2281
+ here is one — `.keys` is `text/plain` and the collaborator page is a JSON *array*,
2282
+ which `as_object` flattens to `{}`. A page of people silently reading as nobody
2283
+ is the bug that shape prevents, so the decoding belongs to whoever knows what it
2284
+ asked for and only the status and `Unreachable` are shared.
2285
+
1932
2286
  ## 20. A board's whole life — create → push → (live) → pull → rm (2026-08-10)
1933
2287
 
1934
2288
  Until this chapter a board's life ran one way. `board create` made one, `board
@@ -2113,3 +2467,93 @@ reads the committed report back at its sha.
2113
2467
  `taskops` already has, and no chapter can dogfood one it introduced. Registering
2114
2468
  this report belongs to the session after `ms-b9bf00` puts master on the host and
2115
2469
  the tool on the laptop — the milestone that exists for exactly that.
2470
+
2471
+ ## 21. Hours you can trust — the edge rule, and calendar anchors (2026-08-14)
2472
+
2473
+ Berna reported hours being "discounted" when chapters close. Diagnosed against
2474
+ the live log, not guessed: **no event is ever lost.** `events.jsonl` only grows
2475
+ and the land machinery refuses to touch a dirty board file. The complaint was
2476
+ true, and it was windowing arithmetic plus a missing anchor.
2477
+
2478
+ ### The artifact, exactly
2479
+
2480
+ Every figure the dashboard drew was over one sliding window, and
2481
+ `core/hours.py::sessions` only counted an interval when BOTH of its stamps were
2482
+ inside it — `report.summary` handed it `cache.window(start, end)`, so an
2483
+ interval that straddled the leading edge arrived with its opener already
2484
+ filtered out and was counted by NOBODY. A chapter close produces a burst of
2485
+ events. Days later the window's leading edge crosses that burst and **whole
2486
+ intervals vanish at once**, so the total falls by more than the elapsed time.
2487
+ That is precisely what reads as "hours deducted when a chapter closes".
2488
+
2489
+ ### Half one — an interval belongs to the window its CLOSING stamp is in
2490
+
2491
+ `sessions(stamps, since=)` keeps an interval when the stamp that CLOSES it is at
2492
+ or after the edge, whatever its opener. The caller's one duty is the FETCH:
2493
+ `report._fetch` reads from `start - hours.GAP`, because nothing older than the
2494
+ longest countable interval can pair into the window anyway. The keeping is
2495
+ `sessions()`'s decision, once.
2496
+
2497
+ **The rule lives in `core/hours.py` and not in the feeding** because `sessions()`
2498
+ is the one definition of what an interval is, and the timesheet blocks and the
2499
+ total beside them must stay one pass — a caller that filtered its own events
2500
+ would be a second definition, and the blocks would disagree with the total the
2501
+ moment the two drifted. The counts beside the hours (`closed`, `commits`,
2502
+ `cards`) stay strictly inside the window: they count EVENTS, not intervals, so
2503
+ the pre-roll is not theirs to see. `tests/test_core.py` replays one log through
2504
+ two adjacent window positions and pins that the sum changes only by real aging.
2505
+
2506
+ ### Half two — the vocabulary, one place
2507
+
2508
+ `verbs/_windows.py::parse` is the ONE decision of what a `window=` spelling
2509
+ means, and every form resolves through `core/hours.py` so the DST rule holds for
2510
+ all of them (both edges out of the same zoneinfo walk, never an opening stamp
2511
+ plus a count of seconds):
2512
+
2513
+ ```
2514
+ 7d the last N calendar days, 1..90 — the sliding figure
2515
+ month this calendar month in the caller's tz, first midnight → today
2516
+ 2026-07 that calendar month, closed on BOTH edges — a figure that never moves again
2517
+ total the whole log, the figure that only grows
2518
+ ```
2519
+
2520
+ An unrecognised spelling is **REFUSED, and the refusal names all four.** The
2521
+ earlier `days()` fell back to 7 for anything it did not understand, which is how
2522
+ `7dd` or `august` becomes a plausible number nobody questions.
2523
+
2524
+ Two edges are argued in the code and easy to get wrong. An OPEN-ended span
2525
+ (`month`, `total`) closes on the next local midnight, never on `now`: every edge
2526
+ here is half-open, and the event that closes the current interval is usually the
2527
+ one stamped `now` — ending there silently dropped the last block of work from
2528
+ the very window a person opens to see it. And `total` carries NO day buckets
2529
+ (`days_total: 0`): the whole log grows by a bucket a day forever, most of them
2530
+ empty, so a truncated tail would be mistaken for the span.
2531
+
2532
+ The resolved `Span` rides on the answer (`window` in the payload: the spelling
2533
+ asked, the kind, a printable label, both edges), so a screen titles itself
2534
+ "August 2026" instead of inferring a month from two epoch floats.
2535
+
2536
+ ### Half three — the page anchors on a figure that only grows
2537
+
2538
+ The Actors page opens on the CURRENT MONTH (`ui/src/hoursWindow.ts`,
2539
+ `DEFAULT_HOURS_CHOICE`) with a visible filter — 7 days · This month · Last month
2540
+ · Total — and the per-actor overlay carries the same span. A sliding window as
2541
+ the default is what read as hours being discounted.
2542
+
2543
+ `hoursWindow.ts` maps four labelled options onto four server spellings and
2544
+ repeats none of the arithmetic; everything the screen prints about the span
2545
+ comes back on `ReportPayload.window`, because a client re-deriving "August 2026"
2546
+ from two epoch floats is a second calendar implementation in a second language
2547
+ and a second zone. ONE spelling is computed in the browser and has to be: `last`
2548
+ is "the month before the one the READER is in", emitted as a bare `YYYY-MM` —
2549
+ the form the server closes on both edges — and `lastMonth(now)` takes `now` as a
2550
+ parameter so the January case (`2026-00`) is testable.
2551
+
2552
+ The window is an ARGUMENT to the one `board` call, exactly as `milestone` is
2553
+ (`ui/src/useBoard.ts`) — still ONE fetcher, one coalesced refetch, one snapshot
2554
+ every pane reads. It is not the per-tab fetcher §15 refuses. The cost is stated:
2555
+ Throughput draws exactly `THROUGHPUT_DAYS` bars into a viewBox cut into that
2556
+ many slots and Actors anchors on a calendar month, so one number cannot be both,
2557
+ and moving between those tabs costs one request. A board one version behind
2558
+ sends no `window` key at all, and `windowSaid()` falls back to the day-bucket
2559
+ sentence — a degradation, never a blank.