taskops-cli 0.3.1__tar.gz → 0.3.3__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.1 → taskops_cli-0.3.3}/.github/workflows/ci.yml +1 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/board/cache.sqlite-shm +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/board/cache.sqlite-wal +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/board/events.jsonl +20 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/board/live.sqlite-shm +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/board/live.sqlite-wal +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ARCHITECTURE.md +2 -2
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/CHANGELOG.md +35 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/CLAUDE.md +2 -2
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/PKG-INFO +63 -8
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/README.md +61 -6
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/_version.py +1 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/commands.py +21 -10
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/serving.py +1 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/window.py +1 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/bind.py +11 -2
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/remote.py +14 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/gitdoor.py +14 -4
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/handler.py +2 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/static.py +30 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/upstream.py +1 -1
- taskops_cli-0.3.3/src/taskops/ui/app.js +60 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_git.py +32 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_topology.py +56 -2
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_ui.py +14 -2
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_window.py +58 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/fixture.json +109 -109
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/forge-links.tsx +18 -5
- taskops_cli-0.3.3/ui/smoke/sections/report-markdown.tsx +86 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/report-sandbox.tsx +28 -7
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/App.tsx +8 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/client.ts +5 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/card/Drawer.tsx +9 -2
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/card/Sections.tsx +32 -21
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/card/tokens.ts +10 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/Chapter.tsx +1 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/reports/ReportFrame.tsx +36 -1
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/main.tsx +13 -3
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/types.ts +5 -2
- taskops_cli-0.3.1/src/taskops/ui/app.js +0 -60
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.claude/settings.json +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.gitattributes +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.github/workflows/workflow.yml +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.gitignore +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.mcp.json +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/board/cache.sqlite +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/board/live.sqlite +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/board.json +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/reports/lifecycle-chapter.html +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/reports/reports-chapter.html +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/.taskops/ui.lock +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/pyproject.toml +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/_clock.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/_errors.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/_ids.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/_json.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/_locate.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/_wire.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/board.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/__main__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/admin.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/claude.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/enrol.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/grants.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/hooks.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/main.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/operate.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/paging.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/parser.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/pull.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/push.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/remote.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/rm.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/watch.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/cli/wording.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/actors.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/challenge.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/chapters.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/event.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/forge.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/graph.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/holding.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/hours.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/machine.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/mentions.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/replay.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/reports.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/review.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/scope.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/seams.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/core/types.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/catchup.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/claudefiles.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/diff.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/install.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/landing.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/patch.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/run.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/sig.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/trailer.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/gitwork/trees.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/admin.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/auth.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/feed.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/github.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/grants.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/ingest.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/login.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/mounts.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/removal.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/rpc.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/scoped.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/server.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/http/watcher.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/identity.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/__main__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/activity.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/before.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/boards.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/boardview.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/brief.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/dossier.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/fields.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/gitmoves.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/hello.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/integrate.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/render.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/schema.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/server.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/thread.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/mcp/tools.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/py.typed +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/session.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/cache.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/creds.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/live.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/log.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/pubkeys.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/reviews.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/server.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/store/stores.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/ui/index.html +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/ui/style.css +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/_args.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/_cards.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/_context.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/_facts.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/_mentions.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/_rows.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/_stories.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/_waiting.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/activity.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/assign.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/card.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/events.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/filed.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/plan.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/project.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/pulse.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/record.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/report.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/review.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/take.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/src/taskops/verbs/update.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/__init__.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/conftest.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_architecture.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_claude.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_core.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_mcp.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_report.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_store.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/test_verbs.py +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/tests/ui_harness.js +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/build.mjs +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/index.html +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/package-lock.json +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/package.json +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/main.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/run.mjs +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/actors.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/board-columns.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/chapter-landed.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/chapters-open.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/comment-box.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/diff-page.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/dossier.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/event-stream.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/git-diff.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/monitor-panes.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/overlay-escape.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/reviewing-skew.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/section.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/swarm-topology.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/thread-closing-note.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections/worktrees-index.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/smoke/sections.mjs +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/actors/Daysheet.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/actors/DevPanel.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/board/CardTile.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/board/Column.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/card/CommentBox.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/card/Patch.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/card/Thread.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/card/split.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/chrome/AvatarStack.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/chrome/Header.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/chrome/KpiRail.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/chrome/MilestonePicker.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/chrome/TabNav.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/DependencyChain.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/EditSurface.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/EventStream.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/LeaseHealth.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/LiveLeases.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/Mentions.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/Pane.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/Swarm.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/Throughput.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/monitor/panels.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/shared/Markdown.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/shared/Overlay.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/components/shared/overlayStack.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/format.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/links.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/markdown.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/pages/Actors.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/pages/Board.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/pages/Monitor.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/pages/Reports.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/pages/WorktreeDiff.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/pages/Worktrees.tsx +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/theme/theme.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/theme/tokens.css +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/useBoard.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/src/useEvents.ts +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/styles/index.css +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/ui/tsconfig.json +0 -0
- {taskops_cli-0.3.1 → taskops_cli-0.3.3}/uv.lock +0 -0
|
Binary file
|
|
Binary file
|
|
@@ -772,3 +772,23 @@
|
|
|
772
772
|
{"actor":"dev:berna","body":{"into":"ms/production-catches-up-the-host-r","sha":"e8e71eb91f89c02286b29b7b6dce424653b9e87b"},"id":"0476b2b081ed474629c63868cc9a274d","kind":"merged","task":"tk-5c64d5","ts":1786392488.108064}
|
|
773
773
|
{"actor":"dev:berna","body":{"branch":"ms/the-lifecycle-runs-backwards-too","files":["ARCHITECTURE.md","CLAUDE.md","README.md","src/taskops/cli/commands.py","src/taskops/cli/main.py","src/taskops/cli/parser.py","src/taskops/core/scope.py","src/taskops/http/admin.py","tests/test_topology.py"],"numstat":{"ARCHITECTURE.md":[168,9],"CLAUDE.md":[17,3],"README.md":[21,3],"src/taskops/_wire.py":[45,0],"src/taskops/cli/commands.py":[42,33],"src/taskops/cli/enrol.py":[82,0],"src/taskops/cli/grants.py":[59,0],"src/taskops/cli/hooks.py":[50,0],"src/taskops/cli/main.py":[13,95],"src/taskops/cli/operate.py":[55,38],"src/taskops/cli/parser.py":[149,0],"src/taskops/core/forge.py":[85,0],"src/taskops/core/scope.py":[9,0],"src/taskops/http/admin.py":[22,0],"src/taskops/http/github.py":[180,0],"src/taskops/http/handler.py":[16,1],"src/taskops/http/ingest.py":[10,5],"src/taskops/http/login.py":[12,2],"src/taskops/http/server.py":[20,0],"src/taskops/verbs/project.py":[53,7],"tests/test_topology.py":[703,1],"tests/test_verbs.py":[82,1]},"sha":"ee54aa8aa0a22e381bc1fd0a127ba0f308c475ca","subject":"merge master — two chapters, one CLI, and the section number they both claimed"},"id":"e34f0f942fed0ce549fdb76b159cb21c","kind":"commit","task":"project","ts":1786392613.0}
|
|
774
774
|
{"actor":"dev:berna","body":{"criteria_met":true,"id":"ms-392423","into":"master","op":"landed","sha":"1dee83b688967650945a93c8305e313d8ac3481f"},"id":"521b59ad4d037fa2dcc66f9e4e119f97","kind":"milestone","task":"project","ts":1786392618.650824}
|
|
775
|
+
{"actor":"dev:berna","body":{"branch":"master","files":[".taskops/board.json",".taskops/board/cache.sqlite",".taskops/board/cache.sqlite-shm",".taskops/board/cache.sqlite-wal",".taskops/board/events.jsonl",".taskops/board/live.sqlite",".taskops/board/live.sqlite-shm",".taskops/board/live.sqlite-wal",".taskops/ui.lock","CHANGELOG.md","src/taskops/_version.py"],"numstat":{".taskops/board.json":[1,0],".taskops/board/cache.sqlite":null,".taskops/board/cache.sqlite-shm":null,".taskops/board/cache.sqlite-wal":null,".taskops/board/events.jsonl":[774,0],".taskops/board/live.sqlite":null,".taskops/board/live.sqlite-shm":null,".taskops/board/live.sqlite-wal":null,".taskops/ui.lock":[0,0],"CHANGELOG.md":[31,0],"src/taskops/_version.py":[1,1]},"sha":"9b22232ff4c6ac5df0561296365e2f2576e94a0c","subject":"0.3.1 — three chapters, and the flood that made a remote board unusable"},"id":"4b8de6c0d7d4082c5b289221500e9bb2","kind":"commit","task":"project","ts":1786392799.0}
|
|
776
|
+
{"actor":"dev:berna","body":{"branch":"master","files":[".taskops/board/cache.sqlite-wal",".taskops/board/events.jsonl",".taskops/board/live.sqlite-wal","src/taskops/gitwork/bind.py","src/taskops/gitwork/remote.py","tests/test_git.py"],"numstat":{".taskops/board/cache.sqlite-wal":null,".taskops/board/events.jsonl":[1,0],".taskops/board/live.sqlite-wal":null,"src/taskops/gitwork/bind.py":[11,2],"src/taskops/gitwork/remote.py":[14,1],"tests/test_git.py":[32,0]},"sha":"3ae9dbfd62d036a8bee49a5e33ff045e06151e31","subject":"fix: a push lands on the branch it names, and never on its upstream"},"id":"797d85bd5d8d857922f491f7ff50889a","kind":"commit","task":"project","ts":1786393176.0}
|
|
777
|
+
{"actor":"dev:berna","body":{"field":"assignee","to":"agent:berna/ship1"},"id":"117335f0afc0c4057bd978286351cce0","kind":"edited","task":"tk-1dfa16","ts":1786393201.284905}
|
|
778
|
+
{"actor":"agent:berna/ship1","body":{"no_code":true,"reason":"Deployed taskops-cli 0.3.1 (the PyPI artifact, downloaded by the host itself) to taskops.bernardocastro.dev. No repo commit: the deploy touched no code here; the only file written was ~/.claude/reference/infra.md (runbook corrections, outside this repo).\n\nWHERE IT ACTUALLY RUNS — the runbook was wrong and cost ~6 calls to rediscover. 34.71.115.185 (gpu-relay) runs only nginx: proxy_pass http://10.8.0.2:8082 -> LXD device proxy -> container `bernardocastro` -> pm2 `taskops-v2` = ~/taskops-v2-app/.venv/bin/taskops serve --root ~/taskops-v2-server --host 127.0.0.1 --port 2181. Deploy host is `ssh bernardocastro-box`. infra.md now says so.\n\nROLLBACK, recorded BEFORE anything was replaced, and none of the stored wheels qualified: md5 of every taskops/*.py inside each of the 4 wheels in rollback/ vs site-packages gave mismatch=27 / 25 / 2 / 24 — even the newest (…-bffa26) differed in taskops/cli/push.py and taskops/http/ingest.py (the _birth -> _configuration change), i.e. previous deploys saved the wheel they REPLACED, never the one they installed. So the rollback was rebuilt from the live install: verified the dist-info RECORD against disk first (109 files, 0 bad -> the install was coherent, not hand-patched), then zipped site-packages/taskops + taskops-2.0.0a0.dist-info to rollback/20260810-1dfa16-preupgrade/taskops-2.0.0a0-py3-none-any.whl. Proof it IS the pre-upgrade code: re-extracted and compared — 98 .py compared, 0 mismatches, 98 .py in the wheel (no extras either way). Valid wheel filename inside a dated dir, so a rollback is one pip install with no renaming.\n\nTHE SWAP. Shipped to /tmp/tk-1dfa16/dl (never bare /tmp). Host did `pip download --no-deps --only-binary=:all: taskops-cli==0.3.1` itself (md5 592b03d485977ab1305e0f11e8b151de, 368428 bytes); simple index confirmed serving 0.3.1 first. The installed dist was named `taskops` 2.0.0a0 while PyPI ships `taskops-cli` — both own the taskops/ package, so installing over it would have left the old dist-info plus 2.0.0a0-only modules shadowing. `pip uninstall -y taskops` first (site-packages then held only pip), then `pip install --force-reinstall --no-deps`. pip list is now exactly pip + taskops-cli 0.3.1; taskops.__version__ == 0.3.1. pm2 restart taskops-v2; error log empty since.\n\nVERIFIED AGAINST THE HOST (all over https, not the deploy's exit code):\n- /healthz -> {\"ok\": true, \"seq\": 0, \"data\": {\"boards\": 6}}.\n- POST /axion/join/github: before \"nothing at /axion/join/github\" (404); after -> 409 refused \"this board is opened by invite, not by GitHub — its owner opts in by naming the repo whose membership counts: taskops board forge <owner>/<name> --need push\". Milestone criterion 1 met.\n- Second new-version route, /taskops-v2/git/README.md -> \"this host serves boards, not a repository — it was started outside a checkout\"; in 2.0.0a0 both fell through to \"nothing at\". Control: /axion/nope still \"nothing at /axion/nope\", so the 404 path is intact.\n- Six boards read identically to baseline: agenda/axion/notas/probe -> 409 \"no credential — run: taskops join …\" (same as before), taskops-v2 + scratch-tk-bffa26 -> 200 SSE `data: {\"type\":\"hello\"}`.\n- events.jsonl byte-identical across the upgrade, md5 before == after: agenda 35 b84c0a21…, axion 961 b1623f0a…, notas 48 c8bd750d…, probe 17 6c764e52…, taskops-v2 664 a6cee77d…, scratch-tk-bffa26 5 9ff99159…. 6 board dirs, unchanged. Criterion 2 met.\n\nONE SCARE, RESOLVED, NOT ASSUMED: the first /healthz after restart said {\"boards\": 0}. That is not board loss — http/mounts.py::count returns len(self._boards), a LAZY cache filled by stores(), so a just-restarted process legitimately reports 0 and the old process only said 6 because it had been up 29h. Read one feed per board and healthz returned to 6. Read the code before believing the number; documented in infra.md so the next deploy does not roll back over it. No rollback was needed.\n\nASSUMED, NOT VERIFIED: --no-deps is safe because the project declares zero runtime dependencies (pip resolved none anyway). The four 409 boards were verified as reachable reads (refusal is their pre-existing anonymous behaviour and all six Stores opened, per healthz==6), not as authenticated reads — I hold no credential for them. /taskops-v2/ui now answers 410 \"this host serves the board, not the dashboard\"; I had no pre-upgrade baseline for that path, so I cannot say whether it changed, only that it is a deliberate message and not an error.","to":"done"},"id":"ee24a141f16c3140bb79f1923d4df783","kind":"status","task":"tk-1dfa16","ts":1786393748.646178}
|
|
779
|
+
{"actor":"dev:berna","body":{"into":"ms/production-catches-up-the-host-r","sha":"e8e71eb91f89c02286b29b7b6dce424653b9e87b"},"id":"5aa9ee2076f4262b8bc0ac88bb8b9c88","kind":"merged","task":"tk-1dfa16","ts":1786393792.392233}
|
|
780
|
+
{"actor":"dev:berna","body":{"field":"assignee","to":"agent:berna/jp1"},"id":"d74b22b355d8797f6fc451e81aa105bc","kind":"edited","task":"tk-d1ff9b","ts":1786393796.008371}
|
|
781
|
+
{"actor":"dev:berna","body":{"text":"STOP — the ground moved under this card. I killed jp1 mid-work on purpose, because it was about to write an onboarding README naming a host the axion board no longer lives on.\n\nThe axion board MOVED, tonight, at Berna's request (the home box was too slow):\n\n from https://taskops.bernardocastro.dev/axion (nginx on the GCP relay → proxy_pass 10.8.0.2 → LXD container on the home box)\n to https://boards.t.bernardocastro.dev/axion (taskops 0.3.1 SERVED BY the GCP relay itself, systemd unit `taskops`, 127.0.0.1:8787 behind nginx)\n\nMeasured, which is why it moved: /healthz is 0.36s on the new host against 0.77s on the old one — the relay→box hop was 178ms each way.\n\nHow it moved, and what survives:\n- `board pull axion` brought 962 events down (\"all 962 event(s) are held here\"), then `board create` + `board push axion` on the new host: \"verified: 962 landed, seq 963\".\n- The OLD host still holds its copy — a pull destroys nothing. Nobody has removed it, and that is Berna's call, not this card's.\n- jp1's work was NOT wasted: it declared the forge before it was stopped (event 925540b6…, ts 1786393854, `{op: forge, repo: cloudacio/Axion, need: push}`), and that event travelled with the 962. The GitHub door is live on the new host — `POST /axion/join/github` answers 400 with the body shape it wants, no longer 409 \"opened by invite\".\n\nSo for whoever picks this up: every URL in the README is `boards.t.bernardocastro.dev`, the forge step is already done and must not be re-declared, and the milestone's rule still holds — every command in the README gets run at least once, and you say plainly which ones you could not run because you are the owner and not JP.\n\nOne papercut found while doing this, worth its own card and NOT this one: bare `taskops board pull` in a joined checkout defaults the board name to the DIRECTORY (`axion-v3`) instead of the board `board.json` names (`axion`), so it refused with \"no board named 'axion-v3'\". `board pull axion` works."},"id":"41b9109c6dd2c3feba08a6c0dd04eeeb","kind":"comment","task":"tk-d1ff9b","ts":1786394715.91416}
|
|
782
|
+
{"actor":"dev:berna","body":{"branch":"master","files":[".taskops/board/cache.sqlite-wal",".taskops/board/events.jsonl",".taskops/board/live.sqlite-wal","ARCHITECTURE.md","src/taskops/cli/serving.py","src/taskops/cli/window.py","src/taskops/http/handler.py","src/taskops/http/static.py","src/taskops/http/upstream.py","src/taskops/ui/app.js","tests/test_window.py","ui/src/client.ts","ui/src/main.tsx"],"numstat":{".taskops/board/cache.sqlite-wal":null,".taskops/board/events.jsonl":[6,0],".taskops/board/live.sqlite-wal":null,"ARCHITECTURE.md":[1,1],"src/taskops/cli/serving.py":[1,1],"src/taskops/cli/window.py":[1,1],"src/taskops/http/handler.py":[2,0],"src/taskops/http/static.py":[30,0],"src/taskops/http/upstream.py":[1,1],"src/taskops/ui/app.js":null,"tests/test_window.py":[58,1],"ui/src/client.ts":[5,1],"ui/src/main.tsx":[13,3]},"sha":"ab2da80683a6fb3f4358f01364f5f9b2c2aa0fe0","subject":"the window's address is its port — `taskops ui` opens `/`, not `/board/ui/`"},"id":"bdd27bc33f6137028dfab5546ebfcdfa","kind":"commit","task":"project","ts":1786395781.0}
|
|
783
|
+
{"actor":"dev:berna","body":{"branch":"master","files":[".taskops/board/cache.sqlite-wal",".taskops/board/events.jsonl",".taskops/board/live.sqlite-wal","CHANGELOG.md","src/taskops/_version.py","src/taskops/http/gitdoor.py","src/taskops/ui/app.js","tests/test_topology.py","tests/test_ui.py","ui/smoke/fixture.json","ui/smoke/sections/forge-links.tsx","ui/smoke/sections/report-markdown.tsx","ui/smoke/sections/report-sandbox.tsx","ui/src/App.tsx","ui/src/components/card/Drawer.tsx","ui/src/components/card/Sections.tsx","ui/src/components/card/tokens.ts","ui/src/components/monitor/Chapter.tsx","ui/src/components/reports/ReportFrame.tsx","ui/src/types.ts"],"numstat":{".taskops/board/cache.sqlite-wal":null,".taskops/board/events.jsonl":[1,0],".taskops/board/live.sqlite-wal":null,"CHANGELOG.md":[25,0],"src/taskops/_version.py":[1,1],"src/taskops/http/gitdoor.py":[14,4],"src/taskops/ui/app.js":null,"tests/test_topology.py":[28,2],"tests/test_ui.py":[14,2],"ui/smoke/fixture.json":[109,109],"ui/smoke/sections/forge-links.tsx":[18,5],"ui/smoke/sections/report-markdown.tsx":[86,0],"ui/smoke/sections/report-sandbox.tsx":[28,7],"ui/src/App.tsx":[8,0],"ui/src/components/card/Drawer.tsx":[9,2],"ui/src/components/card/Sections.tsx":[32,21],"ui/src/components/card/tokens.ts":[10,0],"ui/src/components/monitor/Chapter.tsx":[1,1],"ui/src/components/reports/ReportFrame.tsx":[36,1],"ui/src/types.ts":[5,2]},"sha":"e9fff1749b079e11bd8b2840457f0bca54cb3420","subject":"0.3.2 — prose reads as prose, and the card's worktree link stays inside"},"id":"a032a873f75785936dbfce569ab9b07d","kind":"commit","task":"project","ts":1786397681.0}
|
|
784
|
+
{"actor":"dev:berna","body":{"branch":"master","files":[".github/workflows/ci.yml",".taskops/board/cache.sqlite-shm",".taskops/board/cache.sqlite-wal",".taskops/board/events.jsonl",".taskops/board/live.sqlite-shm",".taskops/board/live.sqlite-wal","ARCHITECTURE.md","CLAUDE.md"],"numstat":{".github/workflows/ci.yml":[1,1],".taskops/board/cache.sqlite-shm":null,".taskops/board/cache.sqlite-wal":null,".taskops/board/events.jsonl":[1,0],".taskops/board/live.sqlite-shm":null,".taskops/board/live.sqlite-wal":null,"ARCHITECTURE.md":[1,1],"CLAUDE.md":[2,2]},"sha":"01f165b6a0cf7c578039b8fb05e3542ba4f06a01","subject":"one trunk, named by what it actually is"},"id":"570e34f74dcc2a70f1e7b5fc52bf516f","kind":"commit","task":"project","ts":1786433708.0}
|
|
785
|
+
{"actor":"dev:berna","body":{"field":"assignee","to":"agent:berna/jp2"},"id":"3a8a0205d4946d6eee567de2a405cd77","kind":"edited","task":"tk-d1ff9b","ts":1786433782.743027}
|
|
786
|
+
{"actor":"agent:berna/jp2","body":{"no_code":true,"reason":"The work landed in ~/axion-v3 (commit 48f116a, Bernardo Castro <me@bernardocastro.dev>, no trailers), not in this worktree — .taskops/trees/tk-d1ff9b has nothing to commit.\n\nWHAT CHANGED\n- NEW docs/13-taskops.md (Spanish, matches the 00-12 series tone): onboarding for ANY dev joining the repo. Board URL, the move off taskops.bernardocastro.dev with the measured reason, the two-command GitHub join, the invite as the alternative, what `join` installs, the ELEVEN MCP tools, the local dashboard, the four refusals with their verbatim text, and a §8 \"Verificado\" table.\n- README.md: the personal onboarding section is GONE. 16 lines now: what taskops is here, the board URL, the two commands, and a link to docs/13. Added the doc to the Documentación table.\n- CLAUDE.md §5 was also lying (old host, \"GitHub no longer grants board access\", \"the nine MCP tools\", a CLI list missing board pull/rm/forge). Fixed all four and pointed at docs/13.\n\nVERIFIED BY RUNNING (2026-08-11)\n- /healthz: new host 0.40s, old host 0.75s. Both answer 200.\n- POST /axion/join/github with a well-formed body -> 409 \"GitHub rejected that token (401) ... this board opens to whoever has push on cloudacio/Axion\". That IS the forge read back; I did not re-declare it.\n- /axion/ui/ -> 410.\n- uv tool install taskops-cli -> +taskops-cli==0.3.2 (in an isolated UV_TOOL_DIR; berna's own install untouched).\n- taskops remote add https://boards.t.bernardocastro.dev -> ok. taskops join axion (bare) -> \"joined ... as dev:berna. Hooks installed; the board is in MCP.\"\n- taskops invite probe-tk-d1ff9b --board axion -> minted (id 11568a62b8dbd711); taskops revoke --invite -> \"revoked\". Host is back to boards:1, no leftovers.\n- taskops board ls, taskops --help, taskops board --help -> the eleven commands and board's seven actions, both quoted in the doc from the real output.\n- taskops ui -> served 127.0.0.1:8791, GET /?token= -> 200 text/html, /healthz -> version 0.3.2. Process killed, ui.json removed.\n- ssh-keygen -t ed25519, gh auth token -> ok.\n- Tool count derived from mcp/tools.py TOOLS in BOTH 0.3.1 and 0.3.2: ELEVEN (board, card, activity, filed, plan, assign, merge, take, review, update, comment). The README's \"nueve\" was stale.\n\nCOULD NOT RUN, and why (both stated plainly in docs/13 §8)\n1. A real first `taskops join axion --github` by a non-owner. I ran it and the host refused: \"this host already has a principal named 'berna', and GitHub membership does not extend it\". That proves the door exists, not that it enrols. Only JP (push on cloudacio/Axion, no board credential) can close milestone criterion 3.\n2. `taskops board forge cloudacio/Axion --need push` — already declared by jp1; re-running is re-declaring, not verifying. Read back through the 409 above instead.\n\nCORRECTION TO THE BRIEF: the host does NOT run 0.3.2. `systemctl show taskops` on gpu-relay -> /home/berna/taskops/.venv/bin/taskops serve --root /home/berna/taskops-boards --host 127.0.0.1 --port 8787, and importlib.metadata reports 0.3.1. It does not matter for this card (0.3.1 already carries the activity/filed verbs and the forge door), but \"the relay serves 0.3.2\" is wrong. Berna's laptop is also on 0.3.1 — that is why `taskops ui` there still opens /board/ui/ while a fresh install opens /. The doc tells the reader to use the URL the command prints, so it is right on both.","to":"done"},"id":"6a1c0ebf50326cfb2fa1afc7881dc7b3","kind":"status","task":"tk-d1ff9b","ts":1786434450.829515}
|
|
787
|
+
{"actor":"dev:berna","body":{"into":"ms/production-catches-up-the-host-r","sha":"e8e71eb91f89c02286b29b7b6dce424653b9e87b"},"id":"447096a1f3da6140780b0dfd10f2fc6d","kind":"merged","task":"tk-d1ff9b","ts":1786434517.435541}
|
|
788
|
+
{"actor":"dev:berna","body":{"branch":"master","files":[".taskops/board/cache.sqlite-wal",".taskops/board/events.jsonl",".taskops/board/live.sqlite-wal","README.md","src/taskops/cli/commands.py","tests/test_topology.py"],"numstat":{".taskops/board/cache.sqlite-wal":null,".taskops/board/events.jsonl":[4,0],".taskops/board/live.sqlite-wal":null,"README.md":[61,6],"src/taskops/cli/commands.py":[20,9],"tests/test_topology.py":[28,0]},"sha":"33ad8b0433586e6abf8a8f50c17ff119f82b1c10","subject":"join reads the address the clone already carries — v1's two words, restored"},"id":"7db41a1223bd083e3d4f3dd949d9fc68","kind":"commit","task":"project","ts":1786439728.0}
|
|
789
|
+
{"actor":"dev:berna","body":{"branch":"ms/el-forge-enrola-al-equipo-github","created":1786441801.790267,"criteria":["El dueño corre `taskops board forge <owner>/<repo>` y cada colaborador con el acceso declarado queda enrolado: principal=login, llaves de github.com/<login>.keys — probado end-to-end contra un stub del API y en vivo contra un repo real.","Un dev en un clon entra con `taskops join` a secas: sin token, sin invite, sin flag — su llave ya estaba en allowed_signers.","Re-correr el forge sincroniza: enrola a los nuevos, reporta el drift de los que perdieron acceso (sin revocar solo), y jamás toca al owner.","POST /<board>/join/github, --github y el descubrimiento de token en el join NO EXISTEN más — ni código, ni tests que los nombren, ni docs que los enseñen.","grep por token persistido: cero, en el host y en los checkouts. Suite completa verde, docs de los dos repos diciendo la verdad."],"goal":"Hoy el forge abre una puerta que cada dev tiene que cruzar cargando cosas: un token de GitHub (descubierto, pero suyo), una ssh key que quizás fabricó para esto, y un POST al host en el momento del join. Berna lo dijo exacto: pedir push en el repo Y una llave aparte es redundante — quien pushea por ssh YA tiene una llave, publicada por GitHub en https://github.com/<login>.keys, sin token.\n\nEste capítulo mueve TODA la lógica de GitHub al lado del dueño, donde es trivial: `taskops board forge <owner>/<repo>` pasa de declarar un hecho a SINCRONIZAR el equipo — lista los colaboradores con el acceso declarado (una llamada con el token del dueño, que no se guarda), trae las pubkeys de cada uno del endpoint público, y las enrola en el host. Re-correrlo re-sincroniza. El dev, en su clon, escribe `taskops join` y nada más: su llave ya estaba enrolada, firma y entra. Ni token, ni invite, ni --github, ni ssh-keygen.\n\nCon eso la puerta del dev — POST /<board>/join/github, el flag --github, el descubrimiento de token en join — se BORRA, no se deja comentada. Lo que queda de GitHub: el vocabulario del forge (core/forge.py, qué repo y qué acceso) y el sync del dueño. La regla de la casa no se toca: ningún token de GitHub se guarda nunca, en ningún lado — ahora ni siquiera viaja al host.\n\nLo que este capítulo NO hace: pruning automático. Un principal enrolado por invite (ana, que no es login de GitHub) sería revocado por un sync que nunca la conoció — así que el sync AGREGA y REPORTA el drift (quiénes ya no tienen push, con el comando revoke listo para pegar), y revocar sigue siendo un acto del dueño. Si duele, es el capítulo siguiente.","id":"ms-05f325","op":"create","reviews":false,"rules":["Mutation-check cada fix: romperlo a propósito, UN sitio a la vez, ver caer el test correcto, restaurar — con PYTHONDONTWRITEBYTECODE=1.","Ningún token de GitHub se guarda ni se loguea, nunca: viaja en una llamada del CLI del dueño a api.github.com y muere con ella. Un grep por ghp_/github_token sobre lo persistido es parte de cerrar cada card.","Presupuesto de 200 líneas por módulo — si hay que partir, partir por cohesión, jamás relajar la regla. Los tests de test_architecture.py son la pared.","Borrar es borrar: nada de código comentado, flags muertos ni doors que responden 410. Los tests del código borrado se reemplazan por los del nuevo, y se justifica en el commit qué pinneaban.","Los boards legacy con bearer token siguen siendo una flota: nada de esto puede romper el join por ?token= ni por invite — los test_a_legacy_* son la prueba.","Cada refusal nombra la salida. El sync que encuentra un colaborador sin llaves en GitHub lo REPORTA con el camino (invite), no lo omite en silencio."],"status":"open","title":"El forge enrola al equipo — GitHub es del dueño, y de nadie más"},"id":"20383745733325afad71ca037c1a24da","kind":"milestone","task":"project","ts":1786441801.790267}
|
|
790
|
+
{"actor":"dev:berna","body":{"card":{"after":[],"assignee":"","created":1786441801.787267,"created_by":"dev:berna","criteria":["Mismo lote dos veces deja el mismo estado, probado.","El owner re-enrolado sigue owner; sus llaves se acumulan.","Un no-owner es rechazado con la salida en el mensaje.","allowed_signers rederivado entero tras el lote."],"files":["src/taskops/core/scope.py","src/taskops/http/login.py","src/taskops/store/pubkeys.py","src/taskops/store/server.py","tests/test_topology.py"],"id":"tk-bcf293","labels":[],"milestone":"ms-05f325","parent":null,"priority":0,"review":false,"spec":"La costura de todo el capítulo: una operación de host que recibe [{principal, keys: [líneas ssh-...]}, …] y enrola cada par, para que el CLI del dueño tenga a quién hablarle.\n\nDÓNDE MIRAR primero: `core/scope.py` (board.forge es la pared OWNER exacta a imitar), `http/login.py`::register y `store/pubkeys.py` (el enroll que ya existe — extender, no duplicar: el invite y esto tienen que terminar en el MISMO sitio), `store/server.py` (principals/roles), y el test `test_a_re_join_never_demotes_the_owner_it_only_adds_a_key` — esa propiedad tiene que sobrevivir al lote: enrolar un principal que ya existe AGREGA llaves, jamás reescribe el rol.\n\nSemántica: idempotente (mismo lote dos veces = mismo estado); un principal nuevo entra como member; el owner jamás cambia; allowed_signers se rederiva ENTERO al final, como ya hace todo enroll. La respuesta dice qué hizo: enrolados nuevos, llaves agregadas, sin cambios — números y nombres, porque el CLI los va a imprimir tal cual.\n\nQué NO hace: no revoca a nadie (el porqué está en el goal del milestone — un principal de invite sería víctima de un sync que no lo conoció), no habla con GitHub (no sabe qué es GitHub: recibe principals y llaves), no toca boards (es del HOST, como server init).\n\nTests: la pared de rol (un member que lo intenta es rechazado nombrando la salida), idempotencia, owner intocable, llaves acumulándose, y los legacy intactos.","status":"open","title":"El host enrola en lote — la operación members, del OWNER y de nadie más","updated":1786441801.787267}},"id":"759363f6cae91d1121454bb396118bc9","kind":"created","task":"tk-bcf293","ts":1786441801.787267}
|
|
791
|
+
{"actor":"dev:berna","body":{"card":{"after":["tk-bcf293"],"assignee":"","created":1786441801.788267,"created_by":"dev:berna","criteria":["forge contra el stub enrola a todos los colaboradores con push, paginación incluida.","Un colaborador sin llaves en GitHub queda nombrado con su salida, no omitido.","El drift se reporta con el comando revoke exacto; nada se revoca solo.","El token no aparece en nada persistido ni impreso — grep como parte del cierre."],"files":["src/taskops/cli/operate.py","src/taskops/cli/enrol.py","src/taskops/_wire.py","tests/test_topology.py"],"id":"tk-194ff2","labels":[],"milestone":"ms-05f325","parent":null,"priority":0,"review":false,"spec":"`taskops board forge <owner>/<repo> [--need push|admin]` pasa de declarar a DECLARAR+SINCRONIZAR, y re-correrlo re-sincroniza.\n\nEl flujo del lado del dueño, todo en el CLI: (1) el token del dueño — MOVER `enrol.github_token()` a donde corresponda ahora, no reescribirlo: gh auth token → $GITHUB_TOKEN → prompt oculto, jamás un valor de flag; (2) GET /repos/<o>/<r>/collaborators con el permiso declarado (paginado — un equipo no entra en una página; api.github.com via _wire); (3) por cada login, GET https://github.com/<login>.keys — público, sin token; (4) la operación members de la card anterior, un solo lote; (5) imprimir el reporte: enrolados, llaves agregadas, SIN LLAVES EN GITHUB (nombrados, con `taskops invite <login>` como salida), y el DRIFT — principals del host que ya no están en la lista de colaboradores, con el `taskops revoke --key SHA256:…` listo para pegar y la aclaración de que un principal de invite puede aparecer ahí legítimamente.\n\nEl token muere con la llamada (2). No se guarda, no se loguea, no viaja al host — el host recibe principals y llaves, nunca sabe de GitHub.\n\nDÓNDE MIRAR: `cli/operate.py` (forge hoy), `cli/enrol.py` (github_token — se muda con su docstring, que es el post-mortem del flag), `core/forge.py` (el vocabulario, no debería cambiar), `_wire.py`. Tests con un stub del API de GitHub sobre socket real — el patrón ya existe en test_topology para el stub forge; los tests del stub viejo que prueban MEMBRESÍA del lado del host van a morir en la card siguiente, los tuyos prueban el SYNC del lado del CLI.\n\nOjo con el presupuesto de líneas en operate.py — si el sync no entra, módulo propio (cli/sync.py o similar), partido por cohesión.","status":"open","title":"El forge sincroniza — collaborators con push → .keys → enroll, y el drift reportado","updated":1786441801.788267}},"id":"1ce93c8f500604691a443f56a3dc9f54","kind":"created","task":"tk-194ff2","ts":1786441801.788267}
|
|
792
|
+
{"actor":"dev:berna","body":{"card":{"after":["tk-194ff2"],"assignee":"","created":1786441801.789267,"created_by":"dev:berna","criteria":["grep -r 'join/github\\|--github\\|by_github' sobre src/ y tests/: cero.","El e2e nuevo: sync enrola → clon fresco → `taskops join` a secas → adentro.","Los legacy (?token=, invite) siguen verdes sin tocar sus tests.","handler.py y parser.py más chicos que antes del capítulo."],"files":["src/taskops/http/github.py","src/taskops/http/handler.py","src/taskops/cli/parser.py","src/taskops/cli/commands.py","src/taskops/cli/enrol.py","tests/test_topology.py"],"id":"tk-4c9761","labels":[],"milestone":"ms-05f325","parent":null,"priority":0,"review":false,"spec":"Con el sync vivo, la puerta del dev es peso muerto y se BORRA entera: `http/github.py` (el módulo), su ruta en `http/handler.py` (POST /<board>/join/github), el flag `--github` de `cli/parser.py`, la rama github de `commands.join`, `enrol.by_github` + NO_KEY + NO_TOKEN (github_token ya se mudó en la card anterior — verificar que nada del lado dev lo importe).\n\nEl join queda: bare (dirección del clon o remote grabado) con llave · --invite · ?token= legacy · público read-only. Un dev cuya llave enroló el sync entra con `taskops join` a secas — probarlo end-to-end: stub-sync enrola, clon fresco con board.json, join bare, firma, entra. `--as <login>` para el unix user que no coincide: ya existe, solo verificar que el camino funciona.\n\nLOS TESTS DEL CÓDIGO BORRADO: los que pineaban la puerta (el stub forge de test_topology que responde membresía, los test del door 400/409, BOTH invite+github) se borran CON justificación en el commit — pineaban un contrato que este capítulo retira a propósito. Los que pinean cosas que sobreviven (github_token nunca en un flag, el token muere con la llamada) se MUDAN al lado del sync si la card anterior no lo hizo ya. El refusal de un board sin forge (409 invite-only) también muere con la ruta.\n\nCuidado quirúrgico con handler.py y parser.py: presupuestos 191/149, borrar debería BAJARLOS. mcp/schema o tools no nombran --github (verificar con grep). El hecho op=forge y core/forge.py QUEDAN — son el vocabulario del sync ahora.","status":"open","title":"La puerta del dev se borra — join sin GitHub, de verdad","updated":1786441801.789267}},"id":"5ccccc43f25f0e7c696f926524b00574","kind":"created","task":"tk-4c9761","ts":1786441801.789267}
|
|
793
|
+
{"actor":"dev:berna","body":{"card":{"after":["tk-4c9761"],"assignee":"","created":1786441801.790267,"created_by":"dev:berna","criteria":["grep --github/join/github en docs de ambos repos: cero, salvo historia (CHANGELOG viejo).","ARCHITECTURE §19 cuenta el diseño nuevo con la fecha; §11 endurecido.","El board de axion sincronizado, con el reporte pegado en la card — o el bloqueo dicho con el comando listo.","Cada comando de los docs, corrido; los que no, nombrados."],"files":["README.md","ARCHITECTURE.md","CHANGELOG.md","CLAUDE.md"],"id":"tk-eb8453","labels":[],"milestone":"ms-05f325","parent":null,"priority":1,"review":false,"spec":"Todo lo que enseña el flujo viejo, corregido en el MISMO capítulo que lo cambió — un doc que enseña --github un día más es un onboarding roto para el próximo que clone.\n\n(1) taskops-v2: README (la escalera del join, la sección '### The whole flow' con su diagrama — el POST del dev desaparece, el sync del dueño entra; la lista de board actions; el bloque del forge), ARCHITECTURE.md §19 se reescribe: el título 'GitHub is the INTRODUCTION, never the credential' evoluciona a su forma final — GitHub es el ROSTER, preguntado por el dueño; el dev no carga nada. La entrada de §11 (STORED GitHub token) queda MÁS fuerte: ahora ni viaja. CLAUDE.md del repo si nombra el flujo. CHANGELOG: sección nueva SIN número de versión — el número lo elige Berna.\n\n(2) axion-v3: docs/13-taskops.md (el onboarding entero se simplifica: `taskops join` y reiniciar Claude — la sección --github muere), README (el bloque de dos líneas queda en una), CLAUDE.md §5 si lo nombra. Identidad de commit: Bernardo Castro <me@bernardocastro.dev>, sin trailers.\n\n(3) El board REAL de axion: correr el forge sync contra cloudacio/Axion en boards.t.bernardocastro.dev y pegar el reporte en la card — quiénes quedaron enrolados. Si el token de gh activo no alcanza para listar colaboradores de cloudacio, DECIRLO y dejar el comando exacto para que Berna lo corra — no inventar acceso.\n\n(4) Si algún .tsx cambió en el capítulo (no debería — el header del forge sigue igual), UNA rebuild del bundle acá. Verificar con git diff antes de asumir.\n\nRegla de siempre: cada comando que aparezca en un doc, corrido al menos una vez, y decir cuáles no pudiste.","status":"open","title":"Cierre — los docs dejan de enseñar la puerta que no existe, y axion se re-forja","updated":1786441801.790267}},"id":"d9c06ea6386110cf2efa70c61a9be318","kind":"created","task":"tk-eb8453","ts":1786441801.790267}
|
|
794
|
+
{"actor":"dev:berna","body":{"field":"assignee","to":"agent:berna/lote1"},"id":"7d26319e1285adef7fe667287cf0ac7a","kind":"edited","task":"tk-bcf293","ts":1786441826.92571}
|
|
Binary file
|
|
Binary file
|
|
@@ -836,7 +836,7 @@ nobody has touched yet.
|
|
|
836
836
|
|---|---|---|
|
|
837
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` |
|
|
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
|
-
| AUTOMATIC merges to
|
|
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) |
|
|
841
841
|
| Claude hooks **that decide or store** | latency, another thing to install and drift; v1's held state and gated actions | context travels in `initialize.instructions` + tool responses. The ONE exception, sanctioned 2026-08-06: `taskops hook claude`, delivery-only — it reads, injects a ✉ or ◆ line, and can be deleted with no loss but immediacy. `tests/test_claude.py` pins its safety properties, one test each — the module docstring lists them and `grep -c '^def test_' tests/test_claude.py` counts them |
|
|
842
842
|
| a mark-as-read / ack verb for mentions | a stored `read` flag is `recover` again: a write whose only job is to contradict an earlier one | `core/mentions.py::pending()` derives it from the thread; `tests/test_verbs.py::test_a_mention_clears_itself_the_moment_the_actor_touches_the_card` |
|
|
@@ -1406,7 +1406,7 @@ The routes do not change, so the committed bundle is untouched by any of this:
|
|
|
1406
1406
|
dev A (laptop) dev B (laptop)
|
|
1407
1407
|
┌────────────────────────────┐ ┌────────────────────────────┐
|
|
1408
1408
|
│ $ taskops ui │ │ $ taskops ui │
|
|
1409
|
-
│ /
|
|
1409
|
+
│ / ← the bundle │ │ / ← the bundle │
|
|
1410
1410
|
│ /board/rpc ─────┐ │ │ /board/rpc ─────┐ │
|
|
1411
1411
|
│ /board/git ─ A's clone │ │ /board/git ─ B's clone │
|
|
1412
1412
|
└──────────────────┼─────────┘ └──────────────────┼─────────┘
|
|
@@ -3,6 +3,41 @@
|
|
|
3
3
|
The source of truth for release notes — GitHub Releases are extracted from
|
|
4
4
|
here, never written twice.
|
|
5
5
|
|
|
6
|
+
## 0.3.3 — a clone joins with two words
|
|
7
|
+
|
|
8
|
+
- **`taskops join` reads the address the clone already carries.** v2 committed
|
|
9
|
+
`.taskops/board.json` exactly as v1 did — and never read it back: a bare join
|
|
10
|
+
resolved through the per-machine recorded remote, so a fresh clone died on
|
|
11
|
+
"which host?" while holding the answer in its tree. Restored: inside a clone,
|
|
12
|
+
`taskops join` (or `taskops join --github`, first time) is the whole
|
|
13
|
+
onboarding. `taskops remote add` remains for a checkout that carries no
|
|
14
|
+
address, or a join onto a different board.
|
|
15
|
+
|
|
16
|
+
## 0.3.2 — the window's address, prose that reads as prose, and a link that stays inside
|
|
17
|
+
|
|
18
|
+
- **`taskops ui` opens `http://127.0.0.1:<port>/`.** It used to hand out
|
|
19
|
+
`/board/ui/?token=…`, which leaked two implementation details into the one URL
|
|
20
|
+
a human types — `board` is only the name a window mounts its single board
|
|
21
|
+
under, and `/ui/` was a door on a HOST, which has served no page since the
|
|
22
|
+
dashboard moved to the binary. Worse, the page then rewrote the address bar to
|
|
23
|
+
a path recomputed from that mount, so a refresh landed on `/` and got a JSON
|
|
24
|
+
404. The board's own routes are unchanged and still hang off `/board`.
|
|
25
|
+
- **A markdown report is rendered as markdown.** `.md` reports — the ones written
|
|
26
|
+
to be read — were served as their own source, hashes and pipes and all: the
|
|
27
|
+
door typed them `text/plain` and the reader dropped them in a `<pre>`. The door
|
|
28
|
+
now answers `text/markdown` and the dashboard draws them with the same renderer
|
|
29
|
+
a card spec and a close note use. It needs no sandbox and gets none: that
|
|
30
|
+
renderer builds React elements and emits no HTML, so raw markup inside a report
|
|
31
|
+
is drawn as the characters it is. A self-contained HTML report still goes in
|
|
32
|
+
the `allow-scripts`-only frame, and an unrecognised type still degrades to
|
|
33
|
+
plain text.
|
|
34
|
+
- **The card's Worktree block opens the worktree, in the dashboard.** It offered
|
|
35
|
+
a forge `compare ↗` — two branches on somebody else's website, rendered for a
|
|
36
|
+
clone that may not be yours. It now opens the worktree diff view, reading your
|
|
37
|
+
own clone, with the card's thread beside it. It needs no forge slug, so a board
|
|
38
|
+
with no GitHub at all has it too.
|
|
39
|
+
- The chapter goal pane got a taller clamp — a long goal scrolls less.
|
|
40
|
+
|
|
6
41
|
## 0.3.1 — three chapters: reports, GitHub as an introduction, and the whole lifecycle
|
|
7
42
|
|
|
8
43
|
- **Reports.** A milestone's narration becomes part of the board: `taskops_activity`
|
|
@@ -37,7 +37,7 @@ releases its card by definition. A stalled card is handed over with
|
|
|
37
37
|
**2. Branches are inhabited, not switched.** `git switch` appears nowhere.
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
|
|
40
|
+
master ──────────────────────────▶ the HUMAN decides: a PR, or taskops_merge milestone=
|
|
41
41
|
└─ ms/<slug> ──┬──────┬───────▶ the ORCHESTRATOR integrates, card by card
|
|
42
42
|
tk-a11 tk-b22 ← one WORKER each, one worktree each
|
|
43
43
|
```
|
|
@@ -202,7 +202,7 @@ what it exists to report, not a fault.
|
|
|
202
202
|
|
|
203
203
|
Each has its line in ARCHITECTURE.md §11 saying what it cost and where it is
|
|
204
204
|
enforced: a reviewer ROLE, a stored review STATUS, or automatic reviewer
|
|
205
|
-
assignment · `land` or automatic merges to main · git replication between clones
|
|
205
|
+
assignment · `land` or automatic merges to the trunk · a SECOND trunk (2026-08-10: `main` and `master` both existed, `trees.base_ref` cut every chapter from `origin/main`, and a one-sided push refspec was quietly landing card merges there — three facts that only became a bug together) · git replication between clones
|
|
206
206
|
· Claude hooks **that decide or store** · a stored `doing` · a slug in a branch
|
|
207
207
|
name · a `recover` · a mark-as-read/ack verb · per-request SIGNING · hand-rolled
|
|
208
208
|
crypto or a pip crypto dependency · a `--force` on `board push` **or on `board
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: taskops-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: A shared work board for teams of coding agents, with a human who decides.
|
|
5
5
|
Project-URL: Repository, https://github.com/bernatch22/taskops
|
|
6
6
|
License-Expression: MIT
|
|
@@ -47,8 +47,9 @@ can be misread:
|
|
|
47
47
|
into it exactly once, to install it and register your key.
|
|
48
48
|
* **A board**: one directory on that host. Created from your laptop with
|
|
49
49
|
`taskops board create`.
|
|
50
|
-
* **A repo**: joined to one board.
|
|
51
|
-
|
|
50
|
+
* **A repo**: joined to one board. A clone carries the board's address
|
|
51
|
+
(`.taskops/board.json`, committed), so `taskops join` is the whole step;
|
|
52
|
+
`taskops remote add` covers a checkout that carries none.
|
|
52
53
|
* **Your agents**: they never touch any of this — they talk to the board through
|
|
53
54
|
the eleven MCP tools.
|
|
54
55
|
|
|
@@ -90,10 +91,11 @@ Joining a hosted one is bare, like every other verb — the host is recorded onc
|
|
|
90
91
|
and the key is discovered the way ssh discovers one:
|
|
91
92
|
|
|
92
93
|
```sh
|
|
93
|
-
taskops
|
|
94
|
-
taskops join
|
|
95
|
-
taskops join my-project --invite <id> # first time
|
|
96
|
-
taskops
|
|
94
|
+
taskops join # a clone: board.json carries the address, done
|
|
95
|
+
taskops join --github # same clone, first time: GitHub vouches for you
|
|
96
|
+
taskops join my-project --invite <id> # first time by invite: it enrols your key too
|
|
97
|
+
taskops remote add https://host:8787 # no carried address? record the host once…
|
|
98
|
+
taskops join my-project # …and name the board
|
|
97
99
|
taskops join my-project # no key + public board: read-only window
|
|
98
100
|
```
|
|
99
101
|
|
|
@@ -222,6 +224,59 @@ until an owner records it and cleared again with `--clear`. Only a board that
|
|
|
222
224
|
has been opted in answers `--github` at all; every other one is invite-only,
|
|
223
225
|
exactly as before.
|
|
224
226
|
|
|
227
|
+
### The whole flow, and why cloning is not enough
|
|
228
|
+
|
|
229
|
+
Cloning the repo gives you the board's ADDRESS — `.taskops/board.json` is
|
|
230
|
+
committed and travels with the code — but the host has never seen you: it is a
|
|
231
|
+
different server from GitHub, sharing no session and no cookie with it. So one
|
|
232
|
+
command — `taskops join --github`, no URL: the clone already carries it —
|
|
233
|
+
introduces you, once per checkout, and after that GitHub is not in the picture
|
|
234
|
+
at all.
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
GitHub the board HOST
|
|
238
|
+
<owner>/<repo>, private principals + allowed_signers
|
|
239
|
+
| |
|
|
240
|
+
1. git clone -> the code, and .taskops/board.json (the address).
|
|
241
|
+
remote.json is 0600 and gitignored: no credential travels.
|
|
242
|
+
| |
|
|
243
|
+
2. taskops join --github (the carried address) |
|
|
244
|
+
| |
|
|
245
|
+
|-- the CLI finds YOUR token: `gh auth token`, else $GITHUB_TOKEN,
|
|
246
|
+
| else a hidden prompt. Never a flag value — the shell writes those
|
|
247
|
+
| into ~/.zsh_history before the process starts.
|
|
248
|
+
| |
|
|
249
|
+
|-- POST /<board>/join/github ------------------>|
|
|
250
|
+
| { github_token, principal, pubkey } |
|
|
251
|
+
| |
|
|
252
|
+
| the host asks GitHub ONCE, with your token:
|
|
253
|
+
| "does this user have <need> on <owner>/<repo>?"
|
|
254
|
+
| |
|
|
255
|
+
| yes -> it writes exactly two rows: |
|
|
256
|
+
| principals: <you>, member
|
|
257
|
+
| allowed_signers: <you> ssh-ed25519 AAAA...
|
|
258
|
+
| no -> refused, without saying whether the repo exists
|
|
259
|
+
| |
|
|
260
|
+
3. the GitHub token dies with that request. It is on neither disk.
|
|
261
|
+
|
|
262
|
+
from here on GitHub never participates again:
|
|
263
|
+
|
|
264
|
+
every session: your key signs a challenge -> the host checks it
|
|
265
|
+
(~/.ssh/id_ed25519) against allowed_signers
|
|
266
|
+
-> a 12h session
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The alternative — re-checking GitHub on every request — would mean the host
|
|
270
|
+
holding somebody else's token, or being handed one on each call. That is the
|
|
271
|
+
whole category of problem this removes: there is no token to steal because
|
|
272
|
+
there is none stored.
|
|
273
|
+
|
|
274
|
+
**One consequence, stated plainly: access is granted automatically and taken
|
|
275
|
+
back by hand.** Losing push on the repo does not close the board, because the
|
|
276
|
+
credential is no longer GitHub — it is the enrolled key. Remove it with
|
|
277
|
+
`taskops revoke --key SHA256:…`, which is the same verb an invite-enrolled key
|
|
278
|
+
takes.
|
|
279
|
+
|
|
225
280
|
No URL and no `--key` after `remote add`: the host is recorded in the checkout,
|
|
226
281
|
`board create` records the name, and the key is **discovered** the way ssh
|
|
227
282
|
discovers one — `~/.ssh/id_ed25519`, `id_ecdsa`, `id_rsa`, in that order.
|
|
@@ -35,8 +35,9 @@ can be misread:
|
|
|
35
35
|
into it exactly once, to install it and register your key.
|
|
36
36
|
* **A board**: one directory on that host. Created from your laptop with
|
|
37
37
|
`taskops board create`.
|
|
38
|
-
* **A repo**: joined to one board.
|
|
39
|
-
|
|
38
|
+
* **A repo**: joined to one board. A clone carries the board's address
|
|
39
|
+
(`.taskops/board.json`, committed), so `taskops join` is the whole step;
|
|
40
|
+
`taskops remote add` covers a checkout that carries none.
|
|
40
41
|
* **Your agents**: they never touch any of this — they talk to the board through
|
|
41
42
|
the eleven MCP tools.
|
|
42
43
|
|
|
@@ -78,10 +79,11 @@ Joining a hosted one is bare, like every other verb — the host is recorded onc
|
|
|
78
79
|
and the key is discovered the way ssh discovers one:
|
|
79
80
|
|
|
80
81
|
```sh
|
|
81
|
-
taskops
|
|
82
|
-
taskops join
|
|
83
|
-
taskops join my-project --invite <id> # first time
|
|
84
|
-
taskops
|
|
82
|
+
taskops join # a clone: board.json carries the address, done
|
|
83
|
+
taskops join --github # same clone, first time: GitHub vouches for you
|
|
84
|
+
taskops join my-project --invite <id> # first time by invite: it enrols your key too
|
|
85
|
+
taskops remote add https://host:8787 # no carried address? record the host once…
|
|
86
|
+
taskops join my-project # …and name the board
|
|
85
87
|
taskops join my-project # no key + public board: read-only window
|
|
86
88
|
```
|
|
87
89
|
|
|
@@ -210,6 +212,59 @@ until an owner records it and cleared again with `--clear`. Only a board that
|
|
|
210
212
|
has been opted in answers `--github` at all; every other one is invite-only,
|
|
211
213
|
exactly as before.
|
|
212
214
|
|
|
215
|
+
### The whole flow, and why cloning is not enough
|
|
216
|
+
|
|
217
|
+
Cloning the repo gives you the board's ADDRESS — `.taskops/board.json` is
|
|
218
|
+
committed and travels with the code — but the host has never seen you: it is a
|
|
219
|
+
different server from GitHub, sharing no session and no cookie with it. So one
|
|
220
|
+
command — `taskops join --github`, no URL: the clone already carries it —
|
|
221
|
+
introduces you, once per checkout, and after that GitHub is not in the picture
|
|
222
|
+
at all.
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
GitHub the board HOST
|
|
226
|
+
<owner>/<repo>, private principals + allowed_signers
|
|
227
|
+
| |
|
|
228
|
+
1. git clone -> the code, and .taskops/board.json (the address).
|
|
229
|
+
remote.json is 0600 and gitignored: no credential travels.
|
|
230
|
+
| |
|
|
231
|
+
2. taskops join --github (the carried address) |
|
|
232
|
+
| |
|
|
233
|
+
|-- the CLI finds YOUR token: `gh auth token`, else $GITHUB_TOKEN,
|
|
234
|
+
| else a hidden prompt. Never a flag value — the shell writes those
|
|
235
|
+
| into ~/.zsh_history before the process starts.
|
|
236
|
+
| |
|
|
237
|
+
|-- POST /<board>/join/github ------------------>|
|
|
238
|
+
| { github_token, principal, pubkey } |
|
|
239
|
+
| |
|
|
240
|
+
| the host asks GitHub ONCE, with your token:
|
|
241
|
+
| "does this user have <need> on <owner>/<repo>?"
|
|
242
|
+
| |
|
|
243
|
+
| yes -> it writes exactly two rows: |
|
|
244
|
+
| principals: <you>, member
|
|
245
|
+
| allowed_signers: <you> ssh-ed25519 AAAA...
|
|
246
|
+
| no -> refused, without saying whether the repo exists
|
|
247
|
+
| |
|
|
248
|
+
3. the GitHub token dies with that request. It is on neither disk.
|
|
249
|
+
|
|
250
|
+
from here on GitHub never participates again:
|
|
251
|
+
|
|
252
|
+
every session: your key signs a challenge -> the host checks it
|
|
253
|
+
(~/.ssh/id_ed25519) against allowed_signers
|
|
254
|
+
-> a 12h session
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The alternative — re-checking GitHub on every request — would mean the host
|
|
258
|
+
holding somebody else's token, or being handed one on each call. That is the
|
|
259
|
+
whole category of problem this removes: there is no token to steal because
|
|
260
|
+
there is none stored.
|
|
261
|
+
|
|
262
|
+
**One consequence, stated plainly: access is granted automatically and taken
|
|
263
|
+
back by hand.** Losing push on the repo does not close the board, because the
|
|
264
|
+
credential is no longer GitHub — it is the enrolled key. Remove it with
|
|
265
|
+
`taskops revoke --key SHA256:…`, which is the same verb an invite-enrolled key
|
|
266
|
+
takes.
|
|
267
|
+
|
|
213
268
|
No URL and no `--key` after `remote add`: the host is recorded in the checkout,
|
|
214
269
|
`board create` records the name, and the key is **discovered** the way ssh
|
|
215
270
|
discovers one — `~/.ssh/id_ed25519`, `id_ecdsa`, `id_rsa`, in that order.
|
|
@@ -15,7 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from . import enrol, watch
|
|
16
16
|
from .. import session, identity
|
|
17
17
|
from .._json import query
|
|
18
|
-
from ..board import DIR, find_root, open_board
|
|
18
|
+
from ..board import DIR, find_root, open_board, read_config
|
|
19
19
|
from ..store import log
|
|
20
20
|
from .._errors import TaskopsError
|
|
21
21
|
from ..gitwork import remote, install, claudefiles
|
|
@@ -58,13 +58,15 @@ def join( # noqa: PLR0913 — one command, one config; each flag is a way IN
|
|
|
58
58
|
invite: str = "",
|
|
59
59
|
github: bool = False,
|
|
60
60
|
) -> int:
|
|
61
|
-
"""Connect this repo to a board. Bare like every other verb
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
taskops join
|
|
66
|
-
taskops join
|
|
67
|
-
taskops join my-project --
|
|
61
|
+
"""Connect this repo to a board. Bare like every other verb: the address is
|
|
62
|
+
CARRIED by the clone, the key is discovered, and the recorded remote is the
|
|
63
|
+
fallback for a checkout that carries nothing:
|
|
64
|
+
|
|
65
|
+
taskops join a clone: board.json travels, done
|
|
66
|
+
taskops join --github same clone, first time: GitHub vouches
|
|
67
|
+
taskops join my-project --invite <id> first time by invite: enrols the key
|
|
68
|
+
taskops remote add https://host:8787 no carried address? record the host…
|
|
69
|
+
taskops join my-project …and name the board
|
|
68
70
|
taskops join my-project no key anywhere + public board: read-only
|
|
69
71
|
|
|
70
72
|
The name defaults exactly as `board create`'s does (recorded name, else the
|
|
@@ -93,8 +95,17 @@ def join( # noqa: PLR0913 — one command, one config; each flag is a way IN
|
|
|
93
95
|
root = find_root(here)
|
|
94
96
|
bare = "://" not in target
|
|
95
97
|
if bare:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
# v1's whole ambition, restored (its join.py said it in one line: "a
|
|
99
|
+
# clone carries `.taskops/board.json`, so the second developer types
|
|
100
|
+
# two words"). The committed address is read FIRST, so a fresh clone
|
|
101
|
+
# joins with no URL and no `remote add` — the recorded remote is for
|
|
102
|
+
# the checkout that has no carried address, or a DIFFERENT board.
|
|
103
|
+
carried = str(read_config(root).get("url", ""))
|
|
104
|
+
if carried and target in ("", carried.rsplit("/", 1)[-1]):
|
|
105
|
+
target = carried
|
|
106
|
+
else:
|
|
107
|
+
host, name = remote_cli.named(target)
|
|
108
|
+
target = f"{host}/{name}"
|
|
98
109
|
base = target.partition("?")[0]
|
|
99
110
|
params = query(target)
|
|
100
111
|
invite = invite or params.get("invite", "")
|
|
@@ -110,7 +110,7 @@ def ui(here: Path) -> int:
|
|
|
110
110
|
port = httpd.server_address[1]
|
|
111
111
|
(root / DIR / "ui.json").write_text(json.dumps({"port": port, "token": token}) + "\n")
|
|
112
112
|
window.retire_when_idle(httpd.mounts, httpd)
|
|
113
|
-
_open(f"http://127.0.0.1:{port}
|
|
113
|
+
_open(f"http://127.0.0.1:{port}/?token={token}")
|
|
114
114
|
try:
|
|
115
115
|
httpd.serve_forever()
|
|
116
116
|
except KeyboardInterrupt:
|
|
@@ -104,7 +104,7 @@ def holder(folder: Path, checkout: Path) -> str:
|
|
|
104
104
|
f"ctrl-c in its terminal, or wait ~{int(IDLE_SECONDS / 60)} minutes: "
|
|
105
105
|
"a window with no open tab retires itself"
|
|
106
106
|
)
|
|
107
|
-
return f"http://127.0.0.1:{port}
|
|
107
|
+
return f"http://127.0.0.1:{port}/?token={token}"
|
|
108
108
|
raise TaskopsError(
|
|
109
109
|
f"something holds {folder / LOCK} but no window answers on port {port or '?'} — "
|
|
110
110
|
"it may be starting (run this again) or wedged: find it with "
|
|
@@ -144,7 +144,16 @@ def drain(board: Caller, repo: Path) -> int:
|
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
def push_card(repo: Path, branch: str) -> bool:
|
|
147
|
-
"""Publish the card's branch so a PR (or the orchestrator) can see it.
|
|
147
|
+
"""Publish the card's branch so a PR (or the orchestrator) can see it.
|
|
148
|
+
|
|
149
|
+
BOTH sides of the refspec, for the reason `remote.push` carries in full: a
|
|
150
|
+
one-sided `origin <branch>` is resolved through `push.default`, and under
|
|
151
|
+
`upstream` that is the tracked branch and not this one. Here the tracking is
|
|
152
|
+
also what `--set-upstream` is about to write, so the wrong destination and
|
|
153
|
+
the record of it would agree with each other.
|
|
154
|
+
"""
|
|
148
155
|
if not trailer.card_of(branch):
|
|
149
156
|
return False
|
|
150
|
-
return run.git(
|
|
157
|
+
return run.git(
|
|
158
|
+
"push", "--set-upstream", "origin", f"{branch}:refs/heads/{branch}", cwd=repo
|
|
159
|
+
).ok
|
|
@@ -142,6 +142,19 @@ def push(repo: Path, *branches: str, cwd: Path | None = None) -> None:
|
|
|
142
142
|
return
|
|
143
143
|
for name in names:
|
|
144
144
|
try:
|
|
145
|
-
|
|
145
|
+
# BOTH sides of the refspec, always. `git push origin <name>` looks
|
|
146
|
+
# explicit and is not: with no colon, git resolves the destination
|
|
147
|
+
# through `push.default`, and under `upstream` — a setting a human
|
|
148
|
+
# may have in ~/.gitconfig for their own reasons — that destination
|
|
149
|
+
# is the branch's UPSTREAM, not its name. Measured here on
|
|
150
|
+
# 2026-08-10: milestone branches cut in v1 still tracked
|
|
151
|
+
# `origin/main`, so this best-effort visibility push was writing
|
|
152
|
+
# integration branches onto MAIN, in a function that swallows every
|
|
153
|
+
# error, in a repo whose whole point is that only the human lands
|
|
154
|
+
# anything on the trunk. `<name>:refs/heads/<name>` cannot be
|
|
155
|
+
# reinterpreted by any config.
|
|
156
|
+
run.git(
|
|
157
|
+
"push", "origin", f"{name}:refs/heads/{name}", cwd=where, timeout=PUSH_TIMEOUT
|
|
158
|
+
)
|
|
146
159
|
except TaskopsError: # timeout, or no git at all — never the caller's problem
|
|
147
160
|
return
|
|
@@ -94,6 +94,7 @@ ABSENT = (
|
|
|
94
94
|
|
|
95
95
|
HTML = "text/html"
|
|
96
96
|
TEXT = "text/plain"
|
|
97
|
+
MARKDOWN = "text/markdown"
|
|
97
98
|
|
|
98
99
|
|
|
99
100
|
def answer(repo: Path | None, tail: str, query: str) -> dict[str, Any]:
|
|
@@ -135,9 +136,8 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
|
135
136
|
reading a report is the UI reading a patch, and a second vocabulary would be
|
|
136
137
|
a second renderer. `content_type` is a FIELD, never this response's header —
|
|
137
138
|
every door answers `application/json`, so no path can make THIS origin, the
|
|
138
|
-
one the token lives in, serve HTML; the reader sandboxes it.
|
|
139
|
-
|
|
140
|
-
degrade to something no renderer will execute."""
|
|
139
|
+
one the token lives in, serve HTML; the reader sandboxes it. The three types
|
|
140
|
+
`_kind` answers are argued there."""
|
|
141
141
|
path = reports.under(wanted)
|
|
142
142
|
if not path:
|
|
143
143
|
raise BadRequest(NOT_A_REPORT.format(path=wanted or "a missing ?path="))
|
|
@@ -153,7 +153,7 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
|
153
153
|
return {
|
|
154
154
|
"path": path,
|
|
155
155
|
"rev": sha,
|
|
156
|
-
"content_type":
|
|
156
|
+
"content_type": _kind(path),
|
|
157
157
|
"text": text,
|
|
158
158
|
"truncated": cut,
|
|
159
159
|
"cap": patch.CAP,
|
|
@@ -186,3 +186,13 @@ def _param(query: str, key: str) -> str:
|
|
|
186
186
|
if name == key:
|
|
187
187
|
return unquote(value.replace("+", " "))
|
|
188
188
|
return ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _kind(path: str) -> str:
|
|
192
|
+
"""The three answers, and the default keeps itself safe: an unrecognised
|
|
193
|
+
type falls to `text/plain`, which every reader draws as characters. Decided
|
|
194
|
+
HERE and never in the browser — `ReportFrame` reads what the door SAYS a
|
|
195
|
+
file is, never the extension it can see, so the two cannot drift apart."""
|
|
196
|
+
if path.endswith(".html"):
|
|
197
|
+
return HTML
|
|
198
|
+
return MARKDOWN if path.endswith(".md") else TEXT
|
|
@@ -70,6 +70,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
70
70
|
self._git(board, tail[4:])
|
|
71
71
|
elif tail.startswith("ui"):
|
|
72
72
|
self._static(tail[2:])
|
|
73
|
+
elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
|
|
74
|
+
self._send(200, *page) # a WINDOW serves its page at the ROOT
|
|
73
75
|
else:
|
|
74
76
|
self._fail(404, BadRequest(f"nothing at {self.path}"))
|
|
75
77
|
|
|
@@ -84,6 +84,36 @@ def payload(root: Path, rest: str) -> tuple[bytes, str] | None:
|
|
|
84
84
|
return (path.read_bytes(), content_type(path)) if path else None
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def at_root(root: Path | None, path: str) -> tuple[bytes, str] | None:
|
|
88
|
+
"""The bundle served at the ROOT of a window — `/`, `/app.js`, `/style.css`.
|
|
89
|
+
|
|
90
|
+
`taskops ui` used to hand out `/board/ui/?token=…`, and that path is an
|
|
91
|
+
implementation detail leaking into the one URL a human types: `board` is
|
|
92
|
+
just the name a window mounts its single board under, and `/ui/` was the
|
|
93
|
+
door on a HOST that no longer serves a page at all. A window is a window;
|
|
94
|
+
its address is its port.
|
|
95
|
+
|
|
96
|
+
Deliberately NOT `resolve`: that one falls back to the index for any
|
|
97
|
+
unknown tail, which is right for a door mounted under `/<board>/ui/` and
|
|
98
|
+
wrong at the root, where it would swallow every mistyped API path and
|
|
99
|
+
answer a mistake with a page. Only real files, plus `/` for the index — so
|
|
100
|
+
`/nope` is still the honest 404 the API gives.
|
|
101
|
+
|
|
102
|
+
`root is None` IS the host switch and the only one there is: `Mounts` sets
|
|
103
|
+
`ui` from the same `repo is not None` that /git and /healthz's identity
|
|
104
|
+
read, so a board host cannot start serving a dashboard by any route,
|
|
105
|
+
including this one — there is no second condition to keep in step.
|
|
106
|
+
"""
|
|
107
|
+
if root is None or not root.is_dir():
|
|
108
|
+
return None
|
|
109
|
+
rest = path.partition("?")[0].strip("/")
|
|
110
|
+
base = root.resolve()
|
|
111
|
+
target = (root / INDEX) if not rest else (root / rest).resolve()
|
|
112
|
+
if base != target and base not in target.parents:
|
|
113
|
+
return None # `..%2f..` and symlinks have already collapsed
|
|
114
|
+
return (target.read_bytes(), content_type(target)) if target.is_file() else None
|
|
115
|
+
|
|
116
|
+
|
|
87
117
|
def answer(root: Path | None, rest: str) -> tuple[int, bytes, str]:
|
|
88
118
|
"""The WHOLE /ui door: (status, body, content-type). `root is None` means
|
|
89
119
|
this process is a board host — see the module docstring for why that is a
|