taskops-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. taskops/__init__.py +39 -0
  2. taskops/_clock.py +32 -0
  3. taskops/_errors.py +160 -0
  4. taskops/_ids.py +63 -0
  5. taskops/_types.py +106 -0
  6. taskops/_version.py +7 -0
  7. taskops/assets/GUIDE.md +220 -0
  8. taskops/contracts/__init__.py +96 -0
  9. taskops/contracts/_fields.py +113 -0
  10. taskops/contracts/actor.py +30 -0
  11. taskops/contracts/board.py +142 -0
  12. taskops/contracts/commit.py +36 -0
  13. taskops/contracts/day.py +150 -0
  14. taskops/contracts/dep.py +23 -0
  15. taskops/contracts/event.py +58 -0
  16. taskops/contracts/gitstate.py +45 -0
  17. taskops/contracts/index.py +37 -0
  18. taskops/contracts/lease.py +43 -0
  19. taskops/contracts/log.py +59 -0
  20. taskops/contracts/remote.py +48 -0
  21. taskops/contracts/results.py +84 -0
  22. taskops/contracts/task.py +94 -0
  23. taskops/contracts/tools.py +110 -0
  24. taskops/contracts/wire.py +60 -0
  25. taskops/engine/__init__.py +34 -0
  26. taskops/engine/_blocks.py +48 -0
  27. taskops/engine/_briefs.py +92 -0
  28. taskops/engine/_chunks.py +66 -0
  29. taskops/engine/_closed.py +65 -0
  30. taskops/engine/_entries.py +126 -0
  31. taskops/engine/_events.py +55 -0
  32. taskops/engine/_opened.py +51 -0
  33. taskops/engine/_process.py +80 -0
  34. taskops/engine/_prompts.py +98 -0
  35. taskops/engine/_stream.py +129 -0
  36. taskops/engine/activity.py +94 -0
  37. taskops/engine/bus.py +42 -0
  38. taskops/engine/commitline.py +110 -0
  39. taskops/engine/day.py +142 -0
  40. taskops/engine/diffstat.py +63 -0
  41. taskops/engine/gitio.py +108 -0
  42. taskops/engine/gitstate.py +96 -0
  43. taskops/engine/history.py +76 -0
  44. taskops/engine/identity.py +84 -0
  45. taskops/engine/log.py +68 -0
  46. taskops/engine/machine.py +160 -0
  47. taskops/engine/narrate.py +91 -0
  48. taskops/engine/project.py +57 -0
  49. taskops/engine/replay.py +142 -0
  50. taskops/engine/reports.py +67 -0
  51. taskops/engine/scheduler.py +135 -0
  52. taskops/engine/transcript.py +127 -0
  53. taskops/engine/wire.py +92 -0
  54. taskops/engine/worker.py +115 -0
  55. taskops/py.typed +0 -0
  56. taskops/render/__init__.py +40 -0
  57. taskops/render/_closed_days.py +64 -0
  58. taskops/render/_dossier.py +68 -0
  59. taskops/render/_opened.py +64 -0
  60. taskops/render/_sections.py +51 -0
  61. taskops/render/_tasklist.py +72 -0
  62. taskops/render/_text.py +74 -0
  63. taskops/render/_verbatim.py +65 -0
  64. taskops/render/ansi.py +92 -0
  65. taskops/render/board.py +55 -0
  66. taskops/render/day.py +102 -0
  67. taskops/render/dispatch.py +104 -0
  68. taskops/render/inbox.py +27 -0
  69. taskops/render/log.py +47 -0
  70. taskops/render/recover.py +64 -0
  71. taskops/render/report.py +51 -0
  72. taskops/render/reports.py +57 -0
  73. taskops/render/results.py +96 -0
  74. taskops/render/session.py +75 -0
  75. taskops/render/task.py +88 -0
  76. taskops/render/tasklist.py +65 -0
  77. taskops/storage/__init__.py +36 -0
  78. taskops/storage/_ddl.py +78 -0
  79. taskops/storage/_delivered.py +51 -0
  80. taskops/storage/_deps.py +69 -0
  81. taskops/storage/_events.py +127 -0
  82. taskops/storage/_leases.py +108 -0
  83. taskops/storage/_rows.py +90 -0
  84. taskops/storage/_tasks.py +124 -0
  85. taskops/storage/locate.py +76 -0
  86. taskops/storage/schema.py +66 -0
  87. taskops/storage/store.py +120 -0
  88. taskops/storage/sync.py +139 -0
  89. taskops/transports/__init__.py +6 -0
  90. taskops/transports/cli/__init__.py +0 -0
  91. taskops/transports/cli/commands/__init__.py +3 -0
  92. taskops/transports/cli/commands/_digest.py +64 -0
  93. taskops/transports/cli/commands/_serve_init.py +65 -0
  94. taskops/transports/cli/commands/_shared.py +50 -0
  95. taskops/transports/cli/commands/_tasks_args.py +112 -0
  96. taskops/transports/cli/commands/_window.py +45 -0
  97. taskops/transports/cli/commands/ask.py +27 -0
  98. taskops/transports/cli/commands/dispatch.py +43 -0
  99. taskops/transports/cli/commands/init.py +48 -0
  100. taskops/transports/cli/commands/log.py +22 -0
  101. taskops/transports/cli/commands/plan.py +43 -0
  102. taskops/transports/cli/commands/pushpull.py +53 -0
  103. taskops/transports/cli/commands/recover.py +30 -0
  104. taskops/transports/cli/commands/remote.py +56 -0
  105. taskops/transports/cli/commands/report.py +82 -0
  106. taskops/transports/cli/commands/run_.py +60 -0
  107. taskops/transports/cli/commands/serve.py +74 -0
  108. taskops/transports/cli/commands/sync.py +22 -0
  109. taskops/transports/cli/commands/tasks.py +79 -0
  110. taskops/transports/cli/commands/ui.py +72 -0
  111. taskops/transports/cli/commands/update.py +25 -0
  112. taskops/transports/cli/main.py +85 -0
  113. taskops/transports/hooks/__init__.py +15 -0
  114. taskops/transports/hooks/__main__.py +63 -0
  115. taskops/transports/hooks/_args.py +28 -0
  116. taskops/transports/hooks/claude.py +78 -0
  117. taskops/transports/hooks/commit.py +61 -0
  118. taskops/transports/hooks/events.py +107 -0
  119. taskops/transports/hooks/record.py +55 -0
  120. taskops/transports/http/__init__.py +21 -0
  121. taskops/transports/http/_handler.py +132 -0
  122. taskops/transports/http/_wire.py +79 -0
  123. taskops/transports/http/_wsframes.py +116 -0
  124. taskops/transports/http/agentapi.py +69 -0
  125. taskops/transports/http/api.py +122 -0
  126. taskops/transports/http/exchange.py +80 -0
  127. taskops/transports/http/live.py +160 -0
  128. taskops/transports/http/policy.py +110 -0
  129. taskops/transports/http/projects.py +116 -0
  130. taskops/transports/http/reports.py +75 -0
  131. taskops/transports/http/router.py +90 -0
  132. taskops/transports/http/server.py +50 -0
  133. taskops/transports/http/static.py +86 -0
  134. taskops/transports/http/ui/app.js +59 -0
  135. taskops/transports/http/ui/index.html +23 -0
  136. taskops/transports/http/ui/style.css +1 -0
  137. taskops/transports/http/websocket.py +60 -0
  138. taskops/transports/mcp/__init__.py +19 -0
  139. taskops/transports/mcp/__main__.py +7 -0
  140. taskops/transports/mcp/_descriptions.py +102 -0
  141. taskops/transports/mcp/_reads.py +82 -0
  142. taskops/transports/mcp/_writes.py +72 -0
  143. taskops/transports/mcp/answers.py +44 -0
  144. taskops/transports/mcp/arguments.py +104 -0
  145. taskops/transports/mcp/dispatch.py +47 -0
  146. taskops/transports/mcp/protocol.py +83 -0
  147. taskops/transports/mcp/schema.py +50 -0
  148. taskops/transports/mcp/server.py +49 -0
  149. taskops/transports/mcp/tools.py +66 -0
  150. taskops/usecases/__init__.py +56 -0
  151. taskops/usecases/_entry.py +84 -0
  152. taskops/usecases/_facts.py +49 -0
  153. taskops/usecases/_freeing.py +93 -0
  154. taskops/usecases/_gitignore.py +94 -0
  155. taskops/usecases/_mirroring.py +57 -0
  156. taskops/usecases/_narrating.py +77 -0
  157. taskops/usecases/_project.py +67 -0
  158. taskops/usecases/_range.py +102 -0
  159. taskops/usecases/_reasons.py +54 -0
  160. taskops/usecases/_remotefile.py +62 -0
  161. taskops/usecases/_reportsync.py +108 -0
  162. taskops/usecases/_routing.py +91 -0
  163. taskops/usecases/_wireclient.py +141 -0
  164. taskops/usecases/ask.py +41 -0
  165. taskops/usecases/claim.py +104 -0
  166. taskops/usecases/dispatch.py +160 -0
  167. taskops/usecases/dossier.py +155 -0
  168. taskops/usecases/edit.py +71 -0
  169. taskops/usecases/exchange.py +86 -0
  170. taskops/usecases/feed.py +138 -0
  171. taskops/usecases/guard.py +122 -0
  172. taskops/usecases/hooks.py +127 -0
  173. taskops/usecases/index.py +62 -0
  174. taskops/usecases/ingest.py +60 -0
  175. taskops/usecases/log.py +134 -0
  176. taskops/usecases/narration.py +91 -0
  177. taskops/usecases/plan.py +128 -0
  178. taskops/usecases/pushpull.py +119 -0
  179. taskops/usecases/recover.py +84 -0
  180. taskops/usecases/remote.py +78 -0
  181. taskops/usecases/report.py +87 -0
  182. taskops/usecases/reportfile.py +106 -0
  183. taskops/usecases/session.py +134 -0
  184. taskops/usecases/setup.py +92 -0
  185. taskops/usecases/sync.py +78 -0
  186. taskops/usecases/update.py +123 -0
  187. taskops/usecases/view.py +112 -0
  188. taskops_cli-0.2.0.dist-info/METADATA +291 -0
  189. taskops_cli-0.2.0.dist-info/RECORD +193 -0
  190. taskops_cli-0.2.0.dist-info/WHEEL +5 -0
  191. taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
  192. taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  193. taskops_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <!-- The mount point, and the ONE line the server rewrites.
8
+ Every other href/src below is relative, and `api.ts` reads `document.baseURI`, so
9
+ re-pointing this tag moves the whole app under `/<project>/` when `taskops serve`
10
+ mounts it. `taskops ui` leaves it exactly as it is. -->
11
+ <base href="/" />
12
+ <title>taskops ui</title>
13
+ <link rel="stylesheet" href="style.css" />
14
+ <link
15
+ rel="icon"
16
+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230f1115'/%3E%3Cpath d='M8 16.5l5 5 11-11' stroke='%23b8ff3a' stroke-width='3.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
17
+ />
18
+ </head>
19
+ <body>
20
+ <div id="root"></div>
21
+ <script type="module" src="app.js"></script>
22
+ </body>
23
+ </html>
@@ -0,0 +1 @@
1
+ :root{--bg: #0f1115;--bg-raised: #171a21;--bg-card: #1b1f28;--bg-hover: #212633;--line: #262c39;--line-soft: #1e2430;--ink: #e6e8ee;--ink-dim: #8b93a7;--ink-faint: #5b6479;--lime: #b8ff3a;--amber: #ffb84d;--red: #ff6b6b;--blue: #6bb6ff;--violet: #b48cff;--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;--sans: ui-sans-serif, -apple-system, "Inter", "Segoe UI", system-ui, sans-serif;--r: 8px;--r-lg: 12px;--shadow: 0 8px 28px rgb(0 0 0 / 45%);--pad: 14px}@media (prefers-color-scheme: light){:root{--bg: #f7f8fa;--bg-raised: #ffffff;--bg-card: #ffffff;--bg-hover: #f0f2f6;--line: #dde1e8;--line-soft: #e008;--line-soft: #e8ebf0;--ink: #12151c;--ink-dim: #5b6479;--ink-faint: #8b93a7;--lime: #4f9c00;--amber: #b06f00;--red: #c92a2a;--blue: #1268c3;--violet: #6d3fd4;--shadow: 0 6px 20px rgb(15 20 35 / 12%)}}*,*:before,*:after{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 var(--sans);-webkit-font-smoothing:antialiased}h1,h2,h3{margin:0;font-weight:600;line-height:1.3}h1{font-size:20px;letter-spacing:-.01em}h2{font-size:13px}h3{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-dim)}p{margin:0 0 8px}ul,ol{margin:0;padding:0;list-style:none}code,pre{font-family:var(--mono);font-size:12px}pre{margin:0;white-space:pre-wrap;word-break:break-word}button{font:inherit;color:inherit;background:var(--bg-raised);border:1px solid var(--line);border-radius:6px;padding:5px 10px;cursor:pointer;transition:background .12s ease,border-color .12s ease}button:hover:not(:disabled){background:var(--bg-hover);border-color:var(--ink-faint)}button:disabled{opacity:.45;cursor:not-allowed}button.primary{background:var(--lime);color:#0d1207;border-color:transparent;font-weight:600}button.primary:hover:not(:disabled){filter:brightness(1.08);background:var(--lime)}button.linkish{background:none;border:none;padding:0;color:var(--lime);font-family:var(--mono);font-size:12px;text-decoration:none}button.linkish:hover{background:none;text-decoration:underline}input,textarea{font:inherit;width:100%;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:7px 10px}input:focus,textarea:focus{outline:2px solid color-mix(in oklab,var(--lime) 55%,transparent);outline-offset:-1px;border-color:transparent}textarea{resize:vertical;font-family:var(--mono);font-size:12px}.dim{color:var(--ink-dim)}.tally{font-family:var(--mono);font-size:11px;color:var(--ink-dim);background:var(--bg);border:1px solid var(--line);border-radius:20px;padding:0 6px}.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:16px;padding:10px 16px;background:color-mix(in oklab,var(--bg) 88%,transparent);backdrop-filter:blur(12px);border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:10px;min-width:0}.brand>div{display:flex;flex-direction:column;line-height:1.25;min-width:0}.brand strong{font-size:13px;letter-spacing:-.01em}.logo{display:grid;place-items:center;width:26px;height:26px;border-radius:7px;background:var(--lime);color:#0d1207;font-weight:700;font-size:15px}.repo{font-family:var(--mono);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.top-right{display:flex;align-items:center;gap:12px;margin-left:auto}.stats{display:flex;gap:10px}.stat{font-size:11px;color:var(--ink-dim)}.stat b{font-family:var(--mono);font-size:13px;color:var(--ink)}.stat-good b{color:var(--lime)}.badge{font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--amber);border:1px solid color-mix(in oklab,var(--amber) 40%,transparent);border-radius:20px;padding:2px 8px}.live{display:flex;align-items:center;gap:6px;font-size:11px;font-family:var(--mono)}.live.on{color:var(--lime)}.live.off{color:var(--ink-faint)}.live .dot{width:7px;height:7px;border-radius:50%;background:currentColor}.live.on .dot{animation:tick .9s ease-out}@keyframes tick{0%{transform:scale(1);box-shadow:0 0 color-mix(in oklab,var(--lime) 70%,transparent)}40%{transform:scale(1.7)}to{transform:scale(1);box-shadow:0 0 0 9px transparent}}@media (prefers-reduced-motion: reduce){.live.on .dot{animation:none}*{transition:none!important}}.search{position:relative;flex:1 1 260px;max-width:420px}.hits{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:30;background:var(--bg-raised);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--shadow);overflow:hidden}.hits button{display:block;width:100%;text-align:left;background:none;border:none;border-radius:0;padding:8px 10px;font-size:13px}.hits button:hover{background:var(--bg-hover)}.hits code{color:var(--lime);margin-right:6px}.squeeze{padding:4px 9px;font-size:13px;line-height:1;color:var(--ink-dim)}.squeeze.on{color:var(--lime);border-color:color-mix(in oklab,var(--lime) 40%,transparent);background:color-mix(in oklab,var(--lime) 10%,transparent)}main{padding:16px}.banner{display:flex;align-items:center;gap:10px;margin:12px 16px 0;padding:9px 12px;font-size:13px;color:var(--red);background:color-mix(in oklab,var(--red) 10%,transparent);border:1px solid color-mix(in oklab,var(--red) 35%,transparent);border-radius:var(--r)}.loading{padding:40px 0;text-align:center}.board{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px;scroll-snap-type:x proximity}.column{flex:0 0 272px;scroll-snap-align:start;background:var(--bg-raised);border:1px solid var(--line-soft);border-radius:var(--r-lg);padding:10px}.column-head{display:flex;align-items:center;gap:7px;padding:2px 4px 10px;flex-wrap:wrap}.column-head h2{flex:1}.column-head .tally{margin-left:auto}.mark{font-family:var(--mono);color:var(--ink-faint)}.group-by{flex:0 0 100%;margin-top:2px;font-family:var(--mono);font-size:10.5px;padding:2px 7px;color:var(--ink-dim)}.group>summary{display:flex;align-items:center;gap:7px;padding:5px 4px;cursor:pointer;list-style:none;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-dim);border-bottom:1px solid var(--line-soft)}.group>summary::-webkit-details-marker{display:none}.group>summary:before{content:"\25b8";color:var(--ink-faint);font-size:9px}.group[open]>summary:before{content:"\25be"}.group>summary:hover{color:var(--ink)}.group-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group[open]{display:flex;flex-direction:column;gap:8px}.group[open]>summary{margin-bottom:2px}.column-ready{box-shadow:inset 0 2px 0 var(--lime)}.column-claimed,.column-in_progress{box-shadow:inset 0 2px 0 var(--amber)}.column-blocked{box-shadow:inset 0 2px 0 var(--red)}.column-review{box-shadow:inset 0 2px 0 var(--blue)}.column-done{box-shadow:inset 0 2px color-mix(in oklab,var(--lime) 45%,var(--line))}.column-ready .mark{color:var(--lime)}.column-blocked .mark{color:var(--red)}.column-claimed .mark,.column-in_progress .mark{color:var(--amber)}.cards{display:flex;flex-direction:column;gap:8px;min-height:44px}.column-empty{margin:0;padding:12px 4px;font-size:12px;text-align:center;border:1px dashed var(--line);border-radius:var(--r)}.card{background:var(--bg-card);border:1px solid var(--line);border-radius:var(--r);padding:var(--pad);cursor:pointer;transition:border-color .12s ease,transform .12s ease,box-shadow .12s ease}.card:hover{border-color:var(--ink-faint);transform:translateY(-1px);box-shadow:var(--shadow)}.card:focus-visible{outline:2px solid var(--lime);outline-offset:1px}.card.held{border-left:2px solid var(--amber)}.card-top{display:flex;align-items:center;gap:7px;margin-bottom:7px}.card .id{color:var(--ink-dim)}.card .title{margin:0 0 8px;font-size:13.5px;line-height:1.4}.card-lease{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-bottom:7px}.card-foot{font-size:11px}.branch{color:var(--ink-dim);background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:1px 5px;font-size:11px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pri{font-family:var(--mono);font-size:10.5px;font-weight:700;letter-spacing:.02em}.pri-urgent{color:var(--red)}.pri-high{color:var(--amber)}.pri-normal{color:var(--ink-faint)}.actor{font-family:var(--mono);font-size:11.5px;white-space:nowrap}.actor-agent{color:var(--violet)}.actor-dev{color:var(--blue)}.actor-unknown{color:var(--ink-dim)}.counts{display:flex;gap:6px;margin-left:auto;font-family:var(--mono);font-size:11px}.count.blocked{color:var(--red)}.count.blocking{color:var(--amber)}.count.commits{color:var(--lime)}.labels{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:7px}.label{font-size:10.5px;color:var(--ink-dim);background:var(--bg);border:1px solid var(--line);border-radius:20px;padding:1px 7px}.empty{padding:56px 20px;text-align:center;max-width:44rem;margin:0 auto}.empty h2{font-size:17px;margin-bottom:8px}.empty pre{text-align:left;margin-top:14px;padding:12px;background:var(--bg-raised);border:1px solid var(--line);border-radius:var(--r)}.views{display:flex;gap:4px}.views button{font-size:12px;padding:4px 11px;background:none;border-color:transparent;color:var(--ink-dim)}.views button.on{color:var(--ink);background:var(--bg-raised);border-color:var(--line)}.activity{display:flex;flex-direction:column;gap:12px}.activity-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.activity-head h2{font-size:15px}.activity-head .filter{flex:1 1 220px;max-width:340px}.meta-count{font-size:11px;font-family:var(--mono)}.windows{display:flex;gap:3px}.windows button,.kind,.chip{font-family:var(--mono);font-size:11px;padding:3px 8px;color:var(--ink-dim)}.windows button.on,.kind.on{color:var(--lime);border-color:color-mix(in oklab,var(--lime) 40%,transparent);background:color-mix(in oklab,var(--lime) 10%,transparent)}.chip{margin-left:7px;color:var(--lime);border-color:color-mix(in oklab,var(--lime) 35%,transparent)}.activity-body{display:grid;grid-template-columns:260px minmax(0,1fr);gap:16px;align-items:start}@media (max-width: 860px){.activity-body{grid-template-columns:minmax(0,1fr)}}.people{position:sticky;top:72px;display:flex;flex-direction:column;gap:8px}.people ul{display:flex;flex-direction:column;gap:6px}.person button{width:100%;text-align:left;padding:8px 10px;background:var(--bg-raised);border-color:var(--line-soft)}.person.on button{border-color:color-mix(in oklab,var(--lime) 45%,transparent);background:color-mix(in oklab,var(--lime) 8%,transparent)}.person-top{display:flex;align-items:center;gap:7px;margin-bottom:4px;font-size:11px}.person-top .dim{margin-left:auto}.person-counts{display:flex;align-items:center;gap:8px;font-size:11px;font-family:var(--mono);color:var(--ink-dim)}.timeline{display:flex;flex-direction:column;gap:10px}.timeline h3{display:flex;align-items:center}.kinds{display:flex;flex-wrap:wrap;gap:4px}.day{display:flex;flex-direction:column;gap:6px}.day h4{position:sticky;top:64px;z-index:5;display:flex;align-items:center;gap:7px;margin:6px 0 2px;padding:4px 0;font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--ink-dim);background:var(--bg);border-bottom:1px solid var(--line-soft)}.events{display:flex;flex-direction:column}.event{display:flex;align-items:baseline;gap:9px;padding:5px 2px}.event+.event{border-top:1px solid var(--line-soft)}.event .when{font-family:var(--mono);font-size:11px;flex:0 0 44px}.kind-dot{width:6px;height:6px;border-radius:50%;flex:0 0 auto;background:var(--ink-faint)}.kind-commit,.kind-done{background:var(--lime)}.kind-status,.kind-created,.kind-branch{background:var(--blue)}.kind-claimed,.kind-activity,.kind-handoff,.kind-released{background:var(--amber)}.kind-comment,.kind-message{background:var(--violet)}.kind-blocked{background:var(--red)}.event-body{flex:1;min-width:0}.event-top{display:flex;align-items:baseline;gap:8px;font-size:11.5px;min-width:0}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.event-said{margin:1px 0 0;font-size:12.5px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.drawer{position:fixed;inset:0;z-index:40;background:#0000008c;backdrop-filter:blur(2px);display:flex;justify-content:flex-end}.panel{width:min(620px,100%);height:100%;overflow-y:auto;background:var(--bg);border-left:1px solid var(--line);padding:16px 20px 40px;display:flex;flex-direction:column;gap:16px;animation:slide .18s ease-out}@keyframes slide{0%{transform:translate(24px);opacity:.6}}.panel-head{display:flex;align-items:center;gap:10px}.panel-head>div{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.panel-head .close{margin-left:auto;padding:3px 9px}.id.big{font-size:13px;color:var(--lime)}.panel h1{margin:0}.meta{font-size:11.5px;display:flex;align-items:center;gap:5px;flex-wrap:wrap}.status{font-family:var(--mono);font-size:11px;border-radius:20px;padding:2px 9px;border:1px solid currentColor}.status-ready{color:var(--lime)}.status-claimed,.status-in_progress{color:var(--amber)}.status-blocked{color:var(--red)}.status-review{color:var(--blue)}.status-done{color:var(--lime)}.status-backlog,.status-cancelled{color:var(--ink-dim)}.panel section{display:flex;flex-direction:column;gap:8px}.cmd{padding:10px 12px;color:var(--lime);background:var(--bg-raised);border:1px solid var(--line);border-radius:var(--r);user-select:all}.spec{padding:12px;line-height:1.6;background:var(--bg-raised);border:1px solid var(--line);border-radius:var(--r)}.files{display:flex;flex-wrap:wrap;gap:6px}.files code{background:var(--bg-raised);border:1px solid var(--line);border-radius:5px;padding:2px 7px;color:var(--ink-dim)}.warn{padding:12px;background:color-mix(in oklab,var(--red) 8%,transparent);border:1px solid color-mix(in oklab,var(--red) 32%,transparent);border-radius:var(--r)}.warn h3{color:var(--red)}.warn ul,.graph{display:flex;flex-direction:column;gap:5px;font-size:13px}.graph li{display:flex;align-items:baseline;gap:7px}.thread{display:flex;flex-direction:column;gap:12px}.thread li{padding-left:10px;border-left:2px solid var(--line)}.msg-head{display:flex;align-items:center;gap:8px;font-size:11px;margin-bottom:3px}.msg{margin:0;font-size:13px;white-space:pre-wrap}.tag{font-size:9.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--violet);border:1px solid color-mix(in oklab,var(--violet) 40%,transparent);border-radius:20px;padding:0 6px}.compose{padding-top:8px;border-top:1px solid var(--line)}.actions{display:flex;flex-wrap:wrap;gap:6px}.actions button{font-size:12px}.failed{color:var(--red);font-size:12.5px;margin:0}.commits{display:flex;flex-direction:column;gap:8px}.commit{padding:8px 10px;background:var(--bg-raised);border:1px solid var(--line)}.commit-head{display:flex;align-items:baseline;gap:8px;margin-bottom:5px}.commit .sha{color:var(--lime);flex:0 0 auto}.commit .subject{font-size:13px}.reports{display:grid;grid-template-columns:240px minmax(0,1fr);gap:16px;align-items:start}@media (max-width: 860px){.reports{grid-template-columns:minmax(0,1fr)}}.report-list{position:sticky;top:72px;display:flex;flex-direction:column;gap:8px}.report-list ul{display:flex;flex-direction:column;gap:6px}.report-row button{width:100%;text-align:left;padding:8px 10px;background:var(--bg-raised);border-color:var(--line-soft)}.report-row.on button{border-color:color-mix(in oklab,var(--lime) 45%,transparent);background:color-mix(in oklab,var(--lime) 8%,transparent)}.report-row-top{display:flex;align-items:center;gap:7px;margin-bottom:4px}.report-row-top code{font-size:12px}.report-row-top .narr{margin-left:auto;color:var(--lime);font-size:11px}.report-row-meta{display:flex;align-items:center;gap:7px;font-size:11px;font-family:var(--mono)}.stale{color:var(--amber);border:1px solid color-mix(in oklab,var(--amber) 35%,transparent);border-radius:20px;padding:0 6px}.report-body{display:flex;flex-direction:column;gap:12px;min-width:0}.report-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.report-head h2{font-size:15px;font-family:var(--mono)}.report-path{font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.report-head button{margin-left:auto}.narrating{display:flex;align-items:center;gap:8px;font-size:12.5px}.spinner{width:11px;height:11px;flex:0 0 auto;border-radius:50%;border:2px solid var(--line);border-top-color:var(--lime);animation:spin .72s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.narration{padding:14px 16px;border-radius:var(--r-lg);background:color-mix(in oklab,var(--lime) 6%,var(--bg-raised));border:1px solid color-mix(in oklab,var(--lime) 30%,transparent)}.narration h3{color:var(--lime);margin-bottom:6px}.narration.pending{background:var(--bg-raised);border-color:var(--line)}.narration.pending h3{color:var(--ink-dim)}.narration.streaming{border-color:color-mix(in oklab,var(--lime) 55%,transparent)}.narration.streaming h3{animation:breathe 1.6s ease-in-out infinite}@keyframes breathe{50%{opacity:.45}}@media (prefers-reduced-motion: reduce){.narration.streaming h3{animation:none}}.report-row-meta .running{color:var(--lime)}.dossier{padding:14px 16px;border-radius:var(--r);background:var(--bg-raised);border:1px solid var(--line)}.md{display:flex;flex-direction:column;gap:9px;min-width:0}.md-h{margin-top:6px;font-size:14px;font-weight:600;color:var(--ink);text-transform:none;letter-spacing:0}.md h2.md-h{font-size:15px}.md-p{margin:0;white-space:pre-wrap;font-size:13px;line-height:1.6}.md-p code,.md-list code,.md-table code{color:var(--lime);font-size:11.5px}.md-list{display:flex;flex-direction:column;gap:4px;padding-left:18px;font-size:13px}.md-list li{list-style:disc}ol.md-list li{list-style:decimal}.md-code{padding:10px 12px;overflow-x:auto;background:var(--bg);border:1px solid var(--line);border-radius:var(--r)}.md-rule{border:0;border-top:1px solid var(--line-soft)}.md-table-wrap{overflow-x:auto}.md-table{border-collapse:collapse;font-size:12.5px}.md-table th,.md-table td{padding:5px 10px;border:1px solid var(--line-soft);text-align:left}.md-table th{color:var(--ink-dim);font-weight:600;background:var(--bg)}
@@ -0,0 +1,60 @@
1
+ """The websocket handshake: turning an HTTP request into a socket.
2
+
3
+ Only the upgrade lives here. Everything that flows afterwards is `_wsframes`, and the split is the
4
+ same one the rest of this package makes everywhere — HTTP is one concern, the binary protocol on the
5
+ other side of it is another.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import os
13
+
14
+ from ._wsframes import (
15
+ OPCODE_CLOSE,
16
+ OPCODE_PING,
17
+ OPCODE_PONG,
18
+ OPCODE_TEXT,
19
+ close_frame,
20
+ ping_frame,
21
+ read_frame,
22
+ text_frame,
23
+ )
24
+
25
+ __all__ = ["accept_key", "handshake_headers", "is_upgrade", "new_key", "text_frame",
26
+ "close_frame", "ping_frame", "read_frame", "OPCODE_TEXT", "OPCODE_CLOSE",
27
+ "OPCODE_PING", "OPCODE_PONG"]
28
+
29
+ _GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
30
+ """The magic string from RFC 6455 §4.2.2. It exists so a server cannot accidentally complete a
31
+ handshake it did not understand — the client checks that we hashed its key with exactly this."""
32
+
33
+
34
+ def is_upgrade(headers: dict[str, str]) -> bool:
35
+ """Is this request asking to become a websocket.
36
+
37
+ Checked on the lower-cased header map the server already built. `Connection` may legitimately be
38
+ a LIST (`keep-alive, Upgrade`), which is why this is a substring test and not equality — the
39
+ equality version works in every browser and fails behind some proxies.
40
+ """
41
+ return ("websocket" in headers.get("upgrade", "").lower()
42
+ and "upgrade" in headers.get("connection", "").lower()
43
+ and bool(headers.get("sec-websocket-key")))
44
+
45
+
46
+ def accept_key(client_key: str) -> str:
47
+ """The `Sec-WebSocket-Accept` value: sha1 of the key plus the GUID, base64."""
48
+ digest = hashlib.sha1((client_key.strip() + _GUID).encode("utf-8")).digest()
49
+ return base64.b64encode(digest).decode("ascii")
50
+
51
+
52
+ def handshake_headers(client_key: str) -> dict[str, str]:
53
+ """The headers that complete the upgrade. Status 101 is the server's job."""
54
+ return {"Upgrade": "websocket", "Connection": "Upgrade",
55
+ "Sec-WebSocket-Accept": accept_key(client_key)}
56
+
57
+
58
+ def new_key() -> str:
59
+ """A client-side `Sec-WebSocket-Key`. Only the tests need this — a browser makes its own."""
60
+ return base64.b64encode(os.urandom(16)).decode("ascii")
@@ -0,0 +1,19 @@
1
+ """The MCP transport: five tools over JSON-RPC on stdio.
2
+
3
+ Layered on purpose — `tools` is the surface, `schema` generates it from `contracts/`,
4
+ `arguments` reads what a model wrote, `dispatch` maps a name to a use case and a renderer,
5
+ `protocol` is JSON-RPC as a pure function, and `server` is the wire.
6
+
7
+ Nothing here imports the engine at module scope: a host lists the tools before it asks
8
+ anything, and that handshake must stay free of sqlite and git. `dispatch` is imported inside
9
+ `protocol`'s tools/call branch, where the cost is warranted.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .protocol import INSTRUCTIONS, PROTOCOL, respond
15
+ from .server import main, serve
16
+ from .tools import TOOLS, Tool, listing
17
+
18
+ __all__ = ["TOOLS", "Tool", "listing", "PROTOCOL", "INSTRUCTIONS", "respond",
19
+ "serve", "main"]
@@ -0,0 +1,7 @@
1
+ """`python -m taskops.transports.mcp` — the server a host launches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .server import main
6
+
7
+ raise SystemExit(main())
@@ -0,0 +1,102 @@
1
+ """The tool descriptions. Prose, not logic — which is why they are not in `tools.py`.
2
+
3
+ Same split as `storage/_ddl`: that module is the SQL, this one is the text, and in both
4
+ cases the point is that reviewing a change to the assembly is not also re-reading sixty
5
+ lines of unchanged content.
6
+
7
+ These strings are the single highest-leverage text in the package. They are what an agent
8
+ reads before it decides which tool to call and what to put in it, and every sentence here
9
+ earns its place by preventing a mistake seen in a real session — an agent coding before
10
+ claiming, closing a task with nothing to show, or discovering a shared file at merge time.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __all__ = ["NEXT", "UPDATE", "ASK", "PLAN", "REPORT", "DISPATCH", "RECOVER"]
16
+
17
+ NEXT = (
18
+ "CLAIM the next piece of work — the call to make when you are ready to code, and the "
19
+ "first call in any session where you do not already hold a task. Returns the task's "
20
+ "full spec, the exact branch to create, the conversation so far, the commits already "
21
+ "on it, and a WARNING listing any other agent editing the same files. The claim is "
22
+ "atomic and leased: no two agents can hold one task, and if your process dies the "
23
+ "lease expires and the work returns to the queue instead of sitting there looking "
24
+ "claimed forever. Every taskops call you make afterwards renews it, so a long task "
25
+ "never expires under you. When nothing is available it says WHY — everything blocked, "
26
+ "everything taken, or the project is finished — which is usually something you can "
27
+ "act on."
28
+ )
29
+
30
+ UPDATE = (
31
+ "Record progress, hand work back, close a task, or MESSAGE another developer's agent — "
32
+ "one call, because they are one thought. `comment` is what the next agent and the "
33
+ "human reviewing at 9am will read, so write the decision and the surprise rather than "
34
+ "a restatement of the title. `mentions` reaches other actors' inboxes: they see it "
35
+ "within one tool call of their own, and it appears live on the board — this is how you "
36
+ "raise a shared file before you both edit it. `status=released` returns the task to "
37
+ "the queue with your progress attached, which is the honest move when you are out of "
38
+ "context or out of depth. `status=done` requires a commit bound to this task (pass "
39
+ "no_code with a comment if the task genuinely produced none) — that guard is the "
40
+ "reason the board can be trusted."
41
+ )
42
+
43
+ ASK = (
44
+ "READ a task in full: the spec, the whole conversation, the commits, what blocks it, "
45
+ "what it is blocking, its subtasks, and which OTHER tasks touch the same files. Use it "
46
+ "before starting on a task somebody handed you by id, and before editing a file you "
47
+ "suspect is contested — the neighbours list is the one thing that prevents a merge "
48
+ "conflict rather than reporting it afterwards. With `query` instead of `task` it "
49
+ "searches titles and specs, for when you know what the work is called but not its id."
50
+ )
51
+
52
+ PLAN = (
53
+ "Turn a decomposition into a persistent task graph in ONE call — tasks, their "
54
+ "parent/child tree, and the dependencies between them. YOU do the decomposition; this "
55
+ "makes it durable, ordered, and visible to every other agent and developer on the "
56
+ "project. `after` accepts the 0-based index of an earlier task in the same batch, so a "
57
+ "whole plan lands atomically instead of needing a second pass that a context limit can "
58
+ "cut short. Write each `spec` for a FRESH agent with none of your context: what done "
59
+ "looks like, what must not change, which files to start from. Name `files` and the "
60
+ "scheduler will never hand two agents the same one."
61
+ )
62
+
63
+ REPORT = (
64
+ "The generated view: `board` (every column, who holds what), `standup` (what changed "
65
+ "in a window, per actor, and what needs a human), or `day` (ONE calendar day in full — "
66
+ "every card closed with who closed it, how long it was held, each of its commits with "
67
+ "the size of its diff, what is still in flight or blocked, the whole conversation, and "
68
+ "a roll-up per actor), or `range` (that same dossier over MANY days, its closed cards "
69
+ "grouped by day, newest first). Nobody writes these by hand, so they cannot be out of "
70
+ "date — use `standup` when a human asks how it is going, `day` with date='yesterday' "
71
+ "when they ask what got done, and `range` when they ask about the project rather than "
72
+ "about a day: with `last`='7d' for the week, and with NO window fields at all for "
73
+ "everything since the log began, which is the answer to 'evaluate all of this'."
74
+ )
75
+
76
+ DISPATCH = (
77
+ "PREPARE workers for cards, then spawn them YOURSELF as sub-agents of this session. It assigns "
78
+ "each card to a named worker, creates a git worktree per card already checked out on that card's "
79
+ "branch, and returns a ready-to-use BRIEF per card. It starts nothing — you take those briefs and "
80
+ "spawn one sub-agent each, ALL IN ONE MESSAGE so they run in parallel, on the session you are "
81
+ "already in. That is the whole flow: plan, dispatch, spawn. Each brief already tells its worker "
82
+ "which worktree to work in and never to run `git switch`, because sub-agents share this repository "
83
+ "and switching would move every other worker's branch at once. `count` says how many (default 3, "
84
+ "ceiling 12); `dry_run` shows which cards would be picked and changes nothing. Assignment happens "
85
+ "first, so no other agent is offered a card you prepared — and IF YOU DO NOT SPAWN THEM, run "
86
+ "recovery, because otherwise those cards sit assigned to workers that never existed and are "
87
+ "invisible to everybody else. This tool never starts a process itself."
88
+ )
89
+
90
+ RECOVER = (
91
+ "UNSTICK the board: hand back every card whose worker died, and say what survived. Call it when "
92
+ "the board shows work in flight that is not moving — cards sitting `claimed` by agents that were "
93
+ "killed, or assigned to workers somebody dispatched and never spawned. Both cases are covered, "
94
+ "and the second has no lease at all, so nothing else finds it: those cards are invisible to every "
95
+ "other agent and claimable by nobody until this runs. It does not wait for a lease to expire, "
96
+ "because if you are looking at the board you have already noticed. It clears the ASSIGNMENT as "
97
+ "well as the claim, so the card returns to the open pool. And it writes on each card what "
98
+ "survived: commits are safe in git, and any UNCOMMITTED work is named with its full path — a "
99
+ "killed agent writes before it commits, so that path is often real work. Read it before letting "
100
+ "anybody start the task over. Workers still reporting are left alone and named in the reply; pass "
101
+ "`force` to release those too."
102
+ )
@@ -0,0 +1,82 @@
1
+ """Handlers that only QUERY: read a task, search, or render a projection.
2
+
3
+ Split from `_handlers` because there are seven tools and one file of them stopped being readable. The
4
+ seam is what a handler DOES: these answer questions and change nothing, so they are safe to call twice and their only
5
+ real decisions are about SHAPE — `ask` serves two forms behind one tool, and `report` four.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ...render import (
13
+ render_board,
14
+ render_day,
15
+ render_search,
16
+ render_standup,
17
+ render_view,
18
+ )
19
+ from ...usecases import (
20
+ Selector,
21
+ ask,
22
+ board,
23
+ day,
24
+ period,
25
+ search,
26
+ standup,
27
+ )
28
+ from . import arguments as arg
29
+
30
+ __all__ = ["ask_", "report_"]
31
+
32
+
33
+ def ask_(args: dict[str, Any]) -> str:
34
+ """`task` or `query`, and saying so when neither came.
35
+
36
+ Two shapes behind one tool: an agent either knows which task it means or it does not,
37
+ and making that two tools would have it choose wrong half the time.
38
+ """
39
+ if wanted := arg.optional(args, "task"):
40
+ return render_view(ask(arg.repo(args), wanted,
41
+ actor=arg.optional(args, "actor")))
42
+ if query := arg.optional(args, "query"):
43
+ return render_search(search(arg.repo(args), query), query)
44
+ raise arg.Missing("pass `task` to read one, or `query` to search")
45
+
46
+
47
+ def report_(args: dict[str, Any]) -> str:
48
+ """Four kinds, and an unknown one is REFUSED rather than quietly given a board.
49
+
50
+ `fleet` and `burndown` used to be answerable here. Falling through to the board would
51
+ hand an agent that asked for one of them a report about something else and no way to
52
+ tell — so the removal is stated, with what to ask for instead.
53
+ """
54
+ kind = arg.optional(args, "kind") or "board"
55
+ where = arg.repo(args)
56
+ if kind == "standup":
57
+ return render_standup(standup(where, since=arg.optional(args, "since") or "24h",
58
+ actor=arg.optional(args, "actor")))
59
+ if kind == "day":
60
+ return render_day(day(where, arg.optional(args, "date") or "today"))
61
+ if kind == "range":
62
+ return render_day(period(where, _span(args)))
63
+ if kind != "board":
64
+ raise arg.Missing(f"`{kind}` is not a report — ask for `board`, `standup`, `day` "
65
+ f"or `range`")
66
+ return render_board(board(where))
67
+
68
+
69
+ def _span(args: dict[str, Any]) -> Selector:
70
+ """`range` with nothing to narrow it is the WHOLE project.
71
+
72
+ The generous default is the point: an agent asked to evaluate what was done here should
73
+ get everything by asking for a range, not an empty report because it did not know which
74
+ of three window fields this tool wanted.
75
+ """
76
+ last, first = arg.optional(args, "last"), arg.optional(args, "from_date")
77
+ to = arg.optional(args, "to")
78
+ if not (last or first):
79
+ return Selector(whole=True, to=to)
80
+ return Selector(date=first, to=to, last=last)
81
+
82
+
@@ -0,0 +1,72 @@
1
+ """Handlers that CHANGE the board: plan, claim, update, dispatch, recover.
2
+
3
+ Split from `_handlers` because there are seven tools and one file of them stopped being readable. The
4
+ seam is what a handler DOES: every one of these writes, so each is the entry point of a use case that enforces a rule —
5
+ a guard, a lease, an assignment — and none of them may make that decision here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ...render import (
13
+ render_dispatch,
14
+ render_next,
15
+ render_plan,
16
+ render_recover,
17
+ render_update,
18
+ )
19
+ from ...usecases import (
20
+ dispatch,
21
+ next_task,
22
+ plan,
23
+ recover,
24
+ update,
25
+ )
26
+ from . import arguments as arg
27
+
28
+ __all__ = ["plan_", "next_", "update_", "dispatch_", "recover_"]
29
+
30
+
31
+ def plan_(args: dict[str, Any]) -> str:
32
+ return render_plan(plan(arg.repo(args), arg.entries(args),
33
+ actor=arg.optional(args, "actor")))
34
+
35
+
36
+ def next_(args: dict[str, Any]) -> str:
37
+ return render_next(next_task(arg.repo(args), actor=arg.optional(args, "actor"),
38
+ session=arg.optional(args, "session"),
39
+ labels=arg.csv(args, "labels"),
40
+ task=arg.optional(args, "task")))
41
+
42
+
43
+ def update_(args: dict[str, Any]) -> str:
44
+ return render_update(update(arg.repo(args), arg.text(args, "task"),
45
+ actor=arg.optional(args, "actor"),
46
+ status=arg.optional(args, "status"),
47
+ comment=arg.optional(args, "comment"),
48
+ mentions=arg.csv(args, "mentions"),
49
+ blocked_on=arg.optional(args, "blocked_on"),
50
+ no_code=arg.flag(args, "no_code")))
51
+
52
+
53
+ def dispatch_(args: dict[str, Any]) -> str:
54
+ return render_dispatch(dispatch(arg.repo(args), tasks=arg.csv(args, "tasks"),
55
+ count=arg.count(args), actor=arg.optional(args, "actor"),
56
+ prefix=arg.optional(args, "prefix"),
57
+ model=arg.optional(args, "model"),
58
+ # `spawn` is NOT read, on purpose: this surface never starts a
59
+ # process. See `DispatchParams` for why.
60
+ dry_run=arg.flag(args, "dry_run")))
61
+
62
+
63
+ def recover_(args: dict[str, Any]) -> str:
64
+ """`grace` of 0 means "use the default", which is what an omitted integer reads as."""
65
+ from ..._clock import HEARTBEAT_GRACE
66
+
67
+ asked = arg.count(args, "grace")
68
+ return render_recover(recover(arg.repo(args), actor=arg.optional(args, "actor"),
69
+ force=arg.flag(args, "force"),
70
+ grace=float(asked) or HEARTBEAT_GRACE))
71
+
72
+
@@ -0,0 +1,44 @@
1
+ """What a tool call returns — one record, and the one way a failure is shaped.
2
+
3
+ The reader is a model, so this matters more than usual: `isError` tells it not to trust the
4
+ text as an answer, and the machine code inside the text tells it what to do about it. A
5
+ failure each call site formats its own way is a failure an agent cannot parse.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from ..._errors import TaskopsError
13
+
14
+ __all__ = ["Answer", "answer", "failure", "from_engine"]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Answer:
19
+ """Text for the agent, and whether it is an answer or an apology."""
20
+
21
+ text: str
22
+ failed: bool = False
23
+
24
+
25
+ def answer(text: str) -> Answer:
26
+ return Answer(text=text)
27
+
28
+
29
+ def failure(message: str, code: str = "error") -> Answer:
30
+ """The machine-readable half travels IN the text.
31
+
32
+ MCP has no error field beyond the `isError` flag, and a code the agent can match on is
33
+ what turns "it failed" into "claim the task first".
34
+ """
35
+ return Answer(text=f"error ({code}): {message}", failed=True)
36
+
37
+
38
+ def from_engine(err: TaskopsError) -> Answer:
39
+ """A typed failure, translated ONCE.
40
+
41
+ The taxonomy carries its own stable code, so a new error type reaches this surface
42
+ correctly the day it is added rather than the day somebody remembers to map it.
43
+ """
44
+ return failure(str(err), err.code)
@@ -0,0 +1,104 @@
1
+ """Reading the arguments a model wrote.
2
+
3
+ Its own module because this is where values from outside become the arguments a use case is
4
+ called with, and every rule about that belongs where it can be read at once. The caller is
5
+ a language model, so the rules are stricter than for a typed client: a missing key, a
6
+ number where a string belongs, and a comma-separated list where an array was declared are
7
+ all ordinary Tuesday traffic, and none of them may reach the engine as a crash.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+ from ..._errors import BadRequest
17
+
18
+ __all__ = ["Missing", "repo", "text", "optional", "flag", "csv", "count", "entries"]
19
+
20
+
21
+ class Missing(BadRequest):
22
+ """An argument the caller left out. A subclass so one `except` covers both this and
23
+ an argument that was present and meaningless."""
24
+
25
+ code = "bad_request"
26
+
27
+
28
+ def repo(arguments: dict[str, Any]) -> Path:
29
+ return Path(text(arguments, "repo_path"))
30
+
31
+
32
+ def text(arguments: dict[str, Any], name: str) -> str:
33
+ value = arguments.get(name)
34
+ if not isinstance(value, str) or not value.strip():
35
+ raise Missing(f"`{name}` is required, as a non-empty string")
36
+ return value.strip()
37
+
38
+
39
+ def optional(arguments: dict[str, Any], name: str) -> str:
40
+ """A string the caller may leave out entirely. "" means "no opinion"."""
41
+ value = arguments.get(name)
42
+ return value.strip() if isinstance(value, str) else ""
43
+
44
+
45
+ def flag(arguments: dict[str, Any], name: str, *, default: bool = False) -> bool:
46
+ """Present-and-false is the only way to turn a defaulted flag off; a missing key
47
+ means the caller had no opinion, which is not the same thing."""
48
+ value = arguments.get(name)
49
+ return value if isinstance(value, bool) else default
50
+
51
+
52
+ def csv(arguments: dict[str, Any], name: str) -> tuple[str, ...]:
53
+ """A comma-separated string, or a real list, into a tuple.
54
+
55
+ Both accepted because the schema says string and models send lists anyway — and a
56
+ `mentions` field that silently dropped a list would mean a message nobody received,
57
+ which is the worst possible way for this to fail.
58
+ """
59
+ value: object = arguments.get(name)
60
+ if isinstance(value, list):
61
+ items = cast("list[object]", value)
62
+ return tuple(str(item).strip() for item in items if str(item).strip())
63
+ if isinstance(value, str):
64
+ return tuple(part.strip() for part in value.split(",") if part.strip())
65
+ return ()
66
+
67
+
68
+ def count(arguments: dict[str, Any], name: str = "count") -> int:
69
+ """A non-negative integer, or 0 for "no opinion".
70
+
71
+ `bool` is excluded explicitly: `True == 1` in Python, so `count: true` would silently mean one
72
+ worker. A string of digits is accepted because a model that has been told a field is a number
73
+ still sends "3" often enough to matter.
74
+ """
75
+ value = arguments.get(name)
76
+ if isinstance(value, bool):
77
+ return 0
78
+ if isinstance(value, int):
79
+ return max(0, value)
80
+ if isinstance(value, str) and value.strip().isdigit():
81
+ return int(value.strip())
82
+ return 0
83
+
84
+
85
+ def entries(arguments: dict[str, Any]) -> list[dict[str, Any]]:
86
+ """The `plan` batch, however the caller packaged it.
87
+
88
+ FIELD HABIT: a model sends `{tasks: "<json string>"}` — learned from tools whose
89
+ arguments are stringly typed — and it would fail as a missing list. A single task sent
90
+ unwrapped is accepted for the same reason: the caller said what it wanted, and
91
+ rejecting it over a bracket teaches it nothing about the real requirement.
92
+ """
93
+ found: object = arguments.get("tasks")
94
+ if isinstance(found, str) and found.strip():
95
+ try:
96
+ found = json.loads(found)
97
+ except json.JSONDecodeError as bad:
98
+ raise Missing(f"`tasks` is a string that is not JSON: {bad}") from bad
99
+ if isinstance(found, dict):
100
+ found = [cast("object", found)]
101
+ if isinstance(found, list) and found:
102
+ items = cast("list[object]", found)
103
+ return [cast("dict[str, Any]", item) for item in items if isinstance(item, dict)]
104
+ raise Missing("`tasks` is required — a non-empty list of {title, spec, after?}")
@@ -0,0 +1,47 @@
1
+ """One tool call -> the text the agent reads. A TABLE, never a chain of ifs.
2
+
3
+ The handlers live in `_reads` and `_writes`, split by what they DO rather than by tool count: one
4
+ group answers questions and changes nothing, the other enters a use case that enforces a rule. What is
5
+ left here is the routing and the ONE place a typed engine failure becomes a sentence an agent can act
6
+ on — which is the whole reason the CLI, MCP and HTTP surfaces cannot drift apart.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable
12
+
13
+ from ..._errors import TaskopsError
14
+ from . import _reads as read
15
+ from . import _writes as write
16
+ from .answers import Answer, answer, failure, from_engine
17
+
18
+ __all__ = ["call_tool", "HANDLERS"]
19
+
20
+ Handler = Callable[[dict[str, Any]], str]
21
+
22
+ HANDLERS: dict[str, Handler] = {
23
+ "taskops_next": write.next_,
24
+ "taskops_update": write.update_,
25
+ "taskops_ask": read.ask_,
26
+ "taskops_plan": write.plan_,
27
+ "taskops_dispatch": write.dispatch_,
28
+ "taskops_recover": write.recover_,
29
+ "taskops_report": read.report_,
30
+ }
31
+
32
+
33
+ def call_tool(name: str, args: dict[str, Any]) -> Answer:
34
+ """Never raises for a failure anyone should expect.
35
+
36
+ An unknown tool, an argument the model left out, an uninitialised project and a lost race for a
37
+ lease are all ordinary traffic here, and each one is worth a sentence the agent can act on rather
38
+ than a traceback the host swallows.
39
+ """
40
+ handler = HANDLERS.get(name)
41
+ if handler is None:
42
+ return failure(f"no tool named {name} — taskops serves "
43
+ f"{', '.join(sorted(HANDLERS))}", "unknown_tool")
44
+ try:
45
+ return answer(handler(args))
46
+ except TaskopsError as err:
47
+ return from_engine(err)