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.
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ARCHITECTURE.md +571 -127
- taskops_cli-0.4.1/CHANGELOG.md +332 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/CLAUDE.md +53 -21
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/PKG-INFO +105 -45
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/README.md +104 -44
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_version.py +1 -1
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_wire.py +25 -22
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/commands.py +12 -29
- taskops_cli-0.4.1/src/taskops/cli/enrol.py +57 -0
- taskops_cli-0.4.1/src/taskops/cli/github.py +196 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/main.py +3 -3
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/operate.py +22 -18
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/parser.py +0 -9
- taskops_cli-0.4.1/src/taskops/cli/team.py +96 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/wording.py +2 -2
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/chapters.py +5 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/graph.py +4 -0
- taskops_cli-0.4.1/src/taskops/core/hours.py +193 -0
- taskops_cli-0.4.1/src/taskops/core/kinds.py +45 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/scope.py +9 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/types.py +18 -37
- taskops_cli-0.4.1/src/taskops/gitwork/catchup.py +114 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/admin.py +6 -3
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/handler.py +1 -16
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/login.py +12 -8
- taskops_cli-0.4.1/src/taskops/http/members.py +178 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/before.py +10 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/boardview.py +10 -1
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/brief.py +8 -0
- taskops_cli-0.3.3/src/taskops/mcp/gitmoves.py → taskops_cli-0.4.1/src/taskops/mcp/chapter.py +46 -83
- taskops_cli-0.4.1/src/taskops/mcp/gitmoves.py +90 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/integrate.py +17 -6
- taskops_cli-0.4.1/src/taskops/mcp/orders.py +79 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/schema.py +16 -53
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/server.py +2 -2
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/tools.py +7 -3
- taskops_cli-0.4.1/src/taskops/store/handover.py +61 -0
- taskops_cli-0.4.1/src/taskops/ui/app.js +61 -0
- taskops_cli-0.4.1/src/taskops/ui/style.css +1 -0
- taskops_cli-0.4.1/src/taskops/verbs/_chapter.py +64 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_facts.py +8 -0
- taskops_cli-0.4.1/src/taskops/verbs/_rows.py +78 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_waiting.py +1 -1
- taskops_cli-0.4.1/src/taskops/verbs/_windows.py +95 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/assign.py +13 -10
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/plan.py +3 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/pulse.py +18 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/record.py +23 -6
- taskops_cli-0.4.1/src/taskops/verbs/report.py +166 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/update.py +9 -39
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_claude.py +5 -1
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_core.py +93 -9
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_git.py +133 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_mcp.py +179 -4
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_topology.py +509 -436
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_ui.py +32 -2
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_verbs.py +324 -6
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/fixture.json +10 -0
- taskops_cli-0.4.1/ui/smoke/sections/actors-window-filter.tsx +266 -0
- taskops_cli-0.4.1/ui/smoke/sections/board-flow-view.tsx +174 -0
- taskops_cli-0.4.1/ui/smoke/sections/board-motion-and-avatars.tsx +205 -0
- taskops_cli-0.4.1/ui/smoke/sections/chapter-completion-derivation.tsx +120 -0
- taskops_cli-0.4.1/ui/smoke/sections/chapter-story-grid.tsx +251 -0
- taskops_cli-0.4.1/ui/smoke/sections/comment-toasts-model.tsx +198 -0
- taskops_cli-0.4.1/ui/smoke/sections/comment-toasts-stack.tsx +201 -0
- taskops_cli-0.4.1/ui/smoke/sections/feed-reconnect.tsx +229 -0
- taskops_cli-0.4.1/ui/smoke/sections/forge-opens-the-board.tsx +80 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections.mjs +20 -6
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/App.tsx +75 -3
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/client.ts +49 -6
- taskops_cli-0.4.1/ui/src/completed.ts +48 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/actors/DevPanel.tsx +10 -2
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/board/CardTile.tsx +53 -22
- taskops_cli-0.4.1/ui/src/components/board/ViewToggle.tsx +42 -0
- taskops_cli-0.4.1/ui/src/components/board/flip.ts +105 -0
- taskops_cli-0.4.1/ui/src/components/board/useFlip.ts +96 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/AvatarStack.tsx +12 -44
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/Header.tsx +33 -4
- taskops_cli-0.4.1/ui/src/components/flow/FlowView.tsx +270 -0
- taskops_cli-0.4.1/ui/src/components/flow/layout.ts +166 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/EventStream.tsx +16 -16
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/panels.ts +10 -0
- taskops_cli-0.4.1/ui/src/components/shared/Avatar.tsx +132 -0
- taskops_cli-0.4.1/ui/src/components/shared/Segmented.tsx +78 -0
- taskops_cli-0.4.1/ui/src/components/story/ChapterStory.tsx +423 -0
- taskops_cli-0.4.1/ui/src/components/story/stats.ts +108 -0
- taskops_cli-0.4.1/ui/src/components/toasts/ToastStack.tsx +231 -0
- taskops_cli-0.4.1/ui/src/components/toasts/model.ts +189 -0
- taskops_cli-0.4.1/ui/src/components/toasts/useToasts.ts +147 -0
- taskops_cli-0.4.1/ui/src/hoursWindow.ts +111 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Actors.tsx +56 -9
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Board.tsx +104 -1
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Monitor.tsx +16 -13
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/theme/tokens.css +17 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/types.ts +112 -1
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/useBoard.ts +53 -9
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/useEvents.ts +36 -1
- taskops_cli-0.3.3/CHANGELOG.md +0 -106
- taskops_cli-0.3.3/src/taskops/cli/enrol.py +0 -127
- taskops_cli-0.3.3/src/taskops/core/hours.py +0 -123
- taskops_cli-0.3.3/src/taskops/gitwork/catchup.py +0 -59
- taskops_cli-0.3.3/src/taskops/http/github.py +0 -180
- taskops_cli-0.3.3/src/taskops/ui/app.js +0 -60
- taskops_cli-0.3.3/src/taskops/ui/style.css +0 -1
- taskops_cli-0.3.3/src/taskops/verbs/_rows.py +0 -46
- taskops_cli-0.3.3/src/taskops/verbs/report.py +0 -120
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.claude/settings.json +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.gitattributes +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.github/workflows/ci.yml +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.github/workflows/workflow.yml +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.gitignore +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.mcp.json +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/cache.sqlite +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/cache.sqlite-shm +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/cache.sqlite-wal +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/events.jsonl +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/live.sqlite +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/live.sqlite-shm +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board/live.sqlite-wal +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/board.json +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/reports/lifecycle-chapter.html +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/reports/reports-chapter.html +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/.taskops/ui.lock +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/pyproject.toml +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_clock.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_errors.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_ids.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_json.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/_locate.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/board.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/__main__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/admin.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/claude.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/grants.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/hooks.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/paging.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/pull.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/push.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/remote.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/rm.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/serving.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/watch.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/cli/window.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/actors.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/challenge.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/event.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/forge.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/holding.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/machine.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/mentions.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/replay.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/reports.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/review.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/core/seams.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/bind.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/claudefiles.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/diff.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/install.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/landing.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/patch.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/remote.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/run.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/sig.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/trailer.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/gitwork/trees.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/auth.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/feed.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/gitdoor.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/grants.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/ingest.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/mounts.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/removal.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/rpc.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/scoped.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/server.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/static.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/upstream.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/http/watcher.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/identity.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/__main__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/activity.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/boards.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/dossier.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/fields.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/hello.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/render.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/mcp/thread.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/py.typed +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/session.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/cache.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/creds.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/live.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/log.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/pubkeys.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/reviews.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/server.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/store/stores.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/ui/index.html +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_args.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_cards.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_context.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_mentions.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/_stories.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/activity.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/card.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/events.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/filed.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/project.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/review.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/src/taskops/verbs/take.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/__init__.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/conftest.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_architecture.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_report.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_store.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/test_window.py +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/tests/ui_harness.js +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/build.mjs +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/index.html +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/package-lock.json +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/package.json +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/main.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/run.mjs +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/actors.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/board-columns.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/chapter-landed.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/chapters-open.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/comment-box.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/diff-page.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/dossier.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/event-stream.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/forge-links.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/git-diff.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/monitor-panes.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/overlay-escape.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/report-markdown.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/report-sandbox.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/reviewing-skew.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/section.ts +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/swarm-topology.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/thread-closing-note.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/smoke/sections/worktrees-index.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/actors/Daysheet.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/board/Column.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/CommentBox.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/Drawer.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/Patch.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/Sections.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/Thread.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/split.ts +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/card/tokens.ts +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/KpiRail.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/MilestonePicker.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/chrome/TabNav.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Chapter.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/DependencyChain.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/EditSurface.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/LeaseHealth.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/LiveLeases.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Mentions.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Pane.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Swarm.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/monitor/Throughput.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/reports/ReportFrame.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/shared/Markdown.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/shared/Overlay.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/components/shared/overlayStack.ts +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/format.ts +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/links.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/main.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/markdown.ts +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Reports.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/WorktreeDiff.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/pages/Worktrees.tsx +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/src/theme/theme.ts +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/styles/index.css +0 -0
- {taskops_cli-0.3.3 → taskops_cli-0.4.1}/ui/tsconfig.json +0 -0
- {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
|
|
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
|
-
|
|
232
|
+
seamsm["seams — which 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,
|
|
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
|
|
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
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
|
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
|
|
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 §
|
|
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
|
|
890
|
-
|
|
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
|
-
`
|
|
1186
|
-
|
|
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
|
|
1204
|
-
|
|
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 `
|
|
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`
|
|
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.** §
|
|
1719
|
-
|
|
1720
|
-
*"
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|
|
1815
|
-
that
|
|
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
|
|
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 /
|
|
1822
|
-
│
|
|
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
|
-
|
|
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
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
`
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
user on the box
|
|
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
|
-
`
|
|
1847
|
-
host
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
byte-scans the joined
|
|
1851
|
-
`
|
|
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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
and
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
-
|
|
1871
|
-
function `invite/redeem` calls
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
`
|
|
1908
|
-
|
|
1909
|
-
|
|
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
|
|
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
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
the
|
|
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 else — correctly, 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.
|