induscode 0.1.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 (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,256 @@
1
+ """Task capability — delegate a self-contained piece of work to a sub-agent.
2
+
3
+ App-novel wiring. The ``task`` tool lets the primary agent hand off a bounded
4
+ objective (e.g. "find every call site of ``foo`` and summarize them") to a
5
+ fresh sub-agent that runs its own tool loop and reports back a single result,
6
+ keeping the parent's context window clean.
7
+
8
+ The actual sub-agent runner is NOT owned by this card — it is a framework /
9
+ swarm concern injected through :attr:`DeckContext.framework` under the
10
+ :data:`DELEGATE_HANDLE_KEY` key. When that handle is present the capability
11
+ delegates to it; when it is absent (tests, headless tooling, a host that has
12
+ not wired the swarm) the capability degrades gracefully to a clearly-typed
13
+ stub that reports delegation is unavailable rather than throwing. Either way
14
+ the card builds a valid :data:`Capability` and the deck assembles and runs.
15
+
16
+ Port note: the TypeBox schema becomes a dict-literal JSON Schema, and a
17
+ defensive runtime guard replaces the compile-time requirement on
18
+ ``objective``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Awaitable, Mapping, Sequence
24
+ from dataclasses import dataclass
25
+ from typing import Literal, Protocol
26
+
27
+ from indusagi.agent import AgentToolResult
28
+ from indusagi.ai import TextContent
29
+
30
+ from ..contract import Capability, CapabilityCard, DeckContext, Schema, capability_id
31
+
32
+ __all__ = [
33
+ "DELEGATE_HANDLE_KEY",
34
+ "DelegateRequest",
35
+ "DelegateResult",
36
+ "DelegateRunner",
37
+ "TaskDetails",
38
+ "build_task_capability",
39
+ "task_card",
40
+ ]
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Delegate handle (injected via DeckContext.framework)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ #: Key under which a host wires a live delegate runner into the deck context.
48
+ DELEGATE_HANDLE_KEY = "delegate"
49
+
50
+
51
+ @dataclass(frozen=True, slots=True, kw_only=True)
52
+ class DelegateRequest:
53
+ """A single delegated objective handed to the sub-agent runner."""
54
+
55
+ # The self-contained objective the sub-agent should accomplish.
56
+ objective: str
57
+ # Optional named agent profile to run as (e.g. "explorer", "reviewer").
58
+ agent: str | None = None
59
+ # Optional extra context the parent wants the sub-agent to start with.
60
+ context: str | None = None
61
+
62
+
63
+ @dataclass(frozen=True, slots=True, kw_only=True)
64
+ class DelegateResult:
65
+ """The single result a sub-agent reports back to the parent."""
66
+
67
+ # Whether the sub-agent considers the objective met.
68
+ ok: bool
69
+ # The sub-agent's final report, surfaced to the parent agent verbatim.
70
+ report: str
71
+
72
+
73
+ class DelegateRunner(Protocol):
74
+ """The contract a host's sub-agent runner must satisfy to be wired in.
75
+
76
+ Intentionally minimal: the deck only needs a way to run one objective
77
+ under cancellation and get one report back. How the runner spawns the
78
+ sub-agent (in-process loop, worktree, separate process) is entirely the
79
+ host's concern. A runner *may* also expose a ``list_agents()`` returning
80
+ the named profiles it offers; the tool description surfaces them when
81
+ present (read via ``getattr``, the Python stand-in for the TS optional
82
+ method).
83
+ """
84
+
85
+ def run(
86
+ self, request: DelegateRequest, signal: object | None = None
87
+ ) -> Awaitable[DelegateResult]:
88
+ """Run one delegated objective and resolve with the sub-agent's
89
+ report."""
90
+ ...
91
+
92
+
93
+ def _read_delegate_runner(ctx: DeckContext) -> DelegateRunner | None:
94
+ handle = (ctx.framework or {}).get(DELEGATE_HANDLE_KEY)
95
+ if handle is not None and callable(getattr(handle, "run", None)):
96
+ return handle # type: ignore[return-value] — structural check above
97
+ return None
98
+
99
+
100
+ def _list_agents(runner: DelegateRunner | None) -> Sequence[str]:
101
+ lister = getattr(runner, "list_agents", None)
102
+ if callable(lister):
103
+ try:
104
+ return tuple(lister())
105
+ except Exception:
106
+ return ()
107
+ return ()
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Parameters (dict-literal JSON Schema)
112
+ # ---------------------------------------------------------------------------
113
+
114
+ _TASK_PARAMS: Schema = {
115
+ "type": "object",
116
+ "properties": {
117
+ "objective": {
118
+ "type": "string",
119
+ "description": (
120
+ "A complete, self-contained statement of what the sub-agent should "
121
+ "accomplish. Include everything it needs; it does not share your "
122
+ "conversation history."
123
+ ),
124
+ },
125
+ "agent": {
126
+ "type": "string",
127
+ "description": (
128
+ "Optional named sub-agent profile to run as. Omit to use the "
129
+ "default profile."
130
+ ),
131
+ },
132
+ "context": {
133
+ "type": "string",
134
+ "description": "Optional extra background the sub-agent should start with.",
135
+ },
136
+ },
137
+ "required": ["objective"],
138
+ "additionalProperties": False,
139
+ }
140
+
141
+
142
+ @dataclass(frozen=True, slots=True, kw_only=True)
143
+ class TaskDetails:
144
+ """Structured detail returned alongside the model-facing content."""
145
+
146
+ # True when a delegate runner handled the objective.
147
+ delegated: bool
148
+ # True when the sub-agent (or stub) considers the objective met.
149
+ ok: bool
150
+ # The agent profile that ran, if one was named.
151
+ agent: str | None = None
152
+
153
+
154
+ def _base_description(agents: Sequence[str]) -> str:
155
+ roster = f" Available sub-agent profiles: {', '.join(agents)}." if agents else ""
156
+ return (
157
+ "Delegate a focused, self-contained piece of work to a sub-agent that runs its own "
158
+ "tool loop and returns a single report. Use this to keep your own context clean when a "
159
+ "task is well-scoped — searching a large codebase, drafting a file, or investigating a "
160
+ "question end-to-end. Give a complete `objective`; the sub-agent does not see your "
161
+ "conversation." + roster
162
+ )
163
+
164
+
165
+ _STUB_NOTE = (
166
+ "Sub-agent delegation is not wired in this environment, so the objective was not run. "
167
+ "Wire a DelegateRunner into the deck context to enable it, or perform the work inline "
168
+ "with the other tools."
169
+ )
170
+
171
+
172
+ def _field(params: object, key: str) -> object:
173
+ if isinstance(params, Mapping):
174
+ return params.get(key)
175
+ return getattr(params, key, None)
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Capability builder
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ class _TaskCapability:
184
+ """The live task/delegate capability — structurally an ``AgentTool``."""
185
+
186
+ name = "task"
187
+ label = "Delegate task"
188
+ parameters: Schema = _TASK_PARAMS
189
+
190
+ def __init__(self, runner: DelegateRunner | None) -> None:
191
+ self._runner = runner
192
+ self.description = _base_description(_list_agents(runner))
193
+
194
+ async def execute(
195
+ self,
196
+ tool_call_id: str,
197
+ params: object,
198
+ signal: object = None,
199
+ on_update: object = None,
200
+ ) -> AgentToolResult:
201
+ del tool_call_id, on_update
202
+ agent_raw = _field(params, "agent")
203
+ agent = agent_raw if isinstance(agent_raw, str) else None
204
+ if self._runner is None:
205
+ return AgentToolResult(
206
+ content=(TextContent(text=_STUB_NOTE),),
207
+ details=TaskDetails(delegated=False, ok=False, agent=agent),
208
+ isError=True,
209
+ )
210
+ objective = _field(params, "objective")
211
+ if not isinstance(objective, str) or objective == "":
212
+ return AgentToolResult(
213
+ content=(
214
+ TextContent(text="`objective` is required to delegate a task."),
215
+ ),
216
+ details=TaskDetails(delegated=False, ok=False, agent=agent),
217
+ isError=True,
218
+ )
219
+ context_raw = _field(params, "context")
220
+ context = context_raw if isinstance(context_raw, str) else None
221
+ result = await self._runner.run(
222
+ DelegateRequest(objective=objective, agent=agent, context=context),
223
+ signal,
224
+ )
225
+ return AgentToolResult(
226
+ content=(TextContent(text=result.report),),
227
+ details=TaskDetails(delegated=True, ok=result.ok, agent=agent),
228
+ isError=not result.ok,
229
+ )
230
+
231
+
232
+ def build_task_capability(ctx: DeckContext) -> _TaskCapability:
233
+ """Build the task/delegate capability.
234
+
235
+ If a :class:`DelegateRunner` is present on the context it is bound and the
236
+ tool truly delegates; otherwise the tool builds anyway and returns a
237
+ typed, non-throwing stub result so the deck stays assemblable in every
238
+ environment.
239
+
240
+ :param ctx: the deck context; an optional delegate runner is read from
241
+ ``ctx.framework[DELEGATE_HANDLE_KEY]``
242
+ """
243
+ return _TaskCapability(_read_delegate_runner(ctx))
244
+
245
+
246
+ def _build(ctx: DeckContext) -> Capability:
247
+ return build_task_capability(ctx)
248
+
249
+
250
+ #: Catalog row for the task/delegate capability.
251
+ task_card = CapabilityCard(
252
+ id=capability_id("task"),
253
+ title="Delegate task",
254
+ summary="Hand a self-contained objective to a sub-agent and receive one report back.",
255
+ build=_build,
256
+ )
@@ -0,0 +1,312 @@
1
+ """Todo capability — an in-memory checklist the agent sets/reads during a run.
2
+
3
+ App-novel and framework-agnostic: the store is a plain in-process tuple of
4
+ items, owned by the card and closed over by the built capability. No file
5
+ persistence, no session-branch reconstruction, no framework store — just a
6
+ mutable list the agent rewrites wholesale (the model is expected to send the
7
+ complete desired list on every ``set``, the same convention coding agents use
8
+ so the plan stays a single coherent snapshot rather than a diff stream).
9
+
10
+ Two operations are folded into one tool keyed by an ``action`` discriminant:
11
+
12
+ - ``read`` — return the current checklist as model-facing prose.
13
+ - ``set`` — replace the checklist with the supplied items (the authoritative
14
+ new plan), then echo it back.
15
+
16
+ The card produces a :data:`Capability` (the framework ``AgentTool`` shape) so
17
+ the conductor consumes it verbatim as one of ``options.tools``.
18
+
19
+ Port note — TypeBox → dict-literal JSON Schema + runtime guards
20
+ ---------------------------------------------------------------
21
+ The TS card's TypeBox schema becomes a plain JSON-schema mapping (the
22
+ framework's ``parameters: Mapping`` convention), and the compile-time
23
+ ``Static`` typing it bought is replaced by defensive runtime guards: a
24
+ malformed ``action`` or ``items`` yields an ``isError`` result instead of an
25
+ exception, because nothing upstream is guaranteed to have validated the
26
+ model's arguments.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Mapping, Sequence
32
+ from dataclasses import dataclass
33
+ from typing import Literal, TypeAlias
34
+
35
+ from indusagi.agent import AgentToolResult
36
+ from indusagi.ai import TextContent
37
+
38
+ from ..contract import Capability, CapabilityCard, DeckContext, Schema, capability_id
39
+
40
+ __all__ = [
41
+ "TodoDetails",
42
+ "TodoItem",
43
+ "TodoLedger",
44
+ "TodoState",
45
+ "TodoWeight",
46
+ "build_todo_capability",
47
+ "todo_card",
48
+ ]
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Store (in-memory, card-owned)
53
+ # ---------------------------------------------------------------------------
54
+
55
+ #: Lifecycle state of one checklist item.
56
+ TodoState: TypeAlias = Literal["pending", "active", "done", "dropped"]
57
+
58
+ #: Relative importance of one checklist item.
59
+ TodoWeight: TypeAlias = Literal["low", "normal", "high"]
60
+
61
+ _STATES: tuple[TodoState, ...] = ("pending", "active", "done", "dropped")
62
+ _WEIGHTS: tuple[TodoWeight, ...] = ("low", "normal", "high")
63
+
64
+
65
+ @dataclass(frozen=True, slots=True, kw_only=True)
66
+ class TodoItem:
67
+ """One row of the in-memory checklist."""
68
+
69
+ # The work to be done, phrased as a short imperative.
70
+ task: str
71
+ # Where the item stands right now.
72
+ state: TodoState
73
+ # How much it matters relative to the rest of the list.
74
+ weight: TodoWeight
75
+
76
+
77
+ class TodoLedger:
78
+ """A minimal in-process checklist store.
79
+
80
+ Deliberately tiny and synchronous: the whole list lives in one field,
81
+ :meth:`set` swaps it atomically, and :meth:`read` hands back an immutable
82
+ tuple so a caller cannot mutate the store's backing sequence. One store is
83
+ created per built capability, so two sessions never share a checklist.
84
+ """
85
+
86
+ def __init__(self) -> None:
87
+ self._items: tuple[TodoItem, ...] = ()
88
+
89
+ def set(self, next_items: Sequence[TodoItem]) -> tuple[TodoItem, ...]:
90
+ """Replace the entire checklist with ``next_items``; returns the
91
+ stored snapshot."""
92
+ self._items = tuple(next_items)
93
+ return self._items
94
+
95
+ def read(self) -> tuple[TodoItem, ...]:
96
+ """Return the current checklist as an immutable snapshot."""
97
+ return self._items
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Parameters (dict-literal JSON Schema)
102
+ # ---------------------------------------------------------------------------
103
+
104
+ _TODO_ITEM_SCHEMA: Schema = {
105
+ "type": "object",
106
+ "properties": {
107
+ "task": {
108
+ "type": "string",
109
+ "description": "Short imperative description of the work item.",
110
+ },
111
+ "state": {
112
+ "type": "string",
113
+ "enum": list(_STATES),
114
+ "description": "Lifecycle state of the item.",
115
+ },
116
+ "weight": {
117
+ "type": "string",
118
+ "enum": list(_WEIGHTS),
119
+ "description": "Relative importance of the item.",
120
+ },
121
+ },
122
+ "required": ["task", "state", "weight"],
123
+ "additionalProperties": False,
124
+ }
125
+
126
+ _TODO_PARAMS: Schema = {
127
+ "type": "object",
128
+ "properties": {
129
+ "action": {
130
+ "type": "string",
131
+ "enum": ["read", "set"],
132
+ "description": (
133
+ "`read` returns the current checklist; `set` replaces it with "
134
+ "the items you provide."
135
+ ),
136
+ },
137
+ "items": {
138
+ "type": "array",
139
+ "items": _TODO_ITEM_SCHEMA,
140
+ "description": (
141
+ "The complete desired checklist. Required for `set` (send the "
142
+ "whole list, not a delta); ignored for `read`."
143
+ ),
144
+ },
145
+ },
146
+ "required": ["action"],
147
+ "additionalProperties": False,
148
+ }
149
+
150
+
151
+ @dataclass(frozen=True, slots=True, kw_only=True)
152
+ class TodoDetails:
153
+ """Structured detail returned alongside the model-facing content."""
154
+
155
+ # The action that was performed.
156
+ action: Literal["read", "set"]
157
+ # The checklist as it stands after the call.
158
+ items: tuple[TodoItem, ...]
159
+
160
+
161
+ _TODO_DESCRIPTION = (
162
+ 'Maintain a working checklist for the current task. Call with `action:"set"` and a '
163
+ "complete `items` list to record or revise your plan — always send the full intended "
164
+ 'list, since the previous one is discarded. Call with `action:"read"` to recall the '
165
+ "current plan. Mark items `active` while in progress and `done` when finished so the "
166
+ "list reflects real status; use `dropped` for work you decided to skip."
167
+ )
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Rendering
172
+ # ---------------------------------------------------------------------------
173
+
174
+ _STATE_GLYPH: Mapping[str, str] = {
175
+ "pending": "[ ]",
176
+ "active": "[~]",
177
+ "done": "[x]",
178
+ "dropped": "[-]",
179
+ }
180
+
181
+
182
+ def _render_checklist(items: Sequence[TodoItem]) -> str:
183
+ if not items:
184
+ return "The checklist is empty."
185
+ lines = []
186
+ for item in items:
187
+ weight = "" if item.weight == "normal" else f" ({item.weight})"
188
+ lines.append(f"{_STATE_GLYPH[item.state]} {item.task}{weight}")
189
+ return "\n".join(lines)
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Runtime guards (the Static<typeof TodoParams> stand-in)
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def _field(params: object, key: str) -> object:
198
+ """Read one argument off the model's params, whether they arrived as a
199
+ mapping (the wire form) or an attribute-bearing object (direct callers)."""
200
+ if isinstance(params, Mapping):
201
+ return params.get(key)
202
+ return getattr(params, key, None)
203
+
204
+
205
+ def _parse_items(raw: object) -> tuple[TodoItem, ...]:
206
+ """Validate and coerce the ``items`` argument; raises :class:`ValueError`
207
+ on any shape the schema would have rejected."""
208
+ if raw is None:
209
+ return ()
210
+ if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
211
+ raise ValueError("`items` must be an array of checklist items.")
212
+ items: list[TodoItem] = []
213
+ for entry in raw:
214
+ if isinstance(entry, TodoItem):
215
+ items.append(entry)
216
+ continue
217
+ if not isinstance(entry, Mapping):
218
+ raise ValueError("each checklist item must be an object.")
219
+ task = entry.get("task")
220
+ if not isinstance(task, str) or task == "":
221
+ raise ValueError("each checklist item requires a `task` string.")
222
+ state = entry.get("state")
223
+ if state not in _STATES:
224
+ raise ValueError(
225
+ "each checklist item requires a `state` of "
226
+ "pending | active | done | dropped."
227
+ )
228
+ weight = entry.get("weight")
229
+ if weight not in _WEIGHTS:
230
+ raise ValueError(
231
+ "each checklist item requires a `weight` of low | normal | high."
232
+ )
233
+ items.append(TodoItem(task=task, state=state, weight=weight))
234
+ return tuple(items)
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Capability builder
239
+ # ---------------------------------------------------------------------------
240
+
241
+
242
+ class _TodoCapability:
243
+ """The live todo capability — structurally an ``AgentTool``."""
244
+
245
+ name = "todo"
246
+ label = "Checklist"
247
+ description = _TODO_DESCRIPTION
248
+ parameters: Schema = _TODO_PARAMS
249
+
250
+ def __init__(self) -> None:
251
+ self._ledger = TodoLedger()
252
+
253
+ async def execute(
254
+ self,
255
+ tool_call_id: str,
256
+ params: object,
257
+ signal: object = None,
258
+ on_update: object = None,
259
+ ) -> AgentToolResult:
260
+ del tool_call_id, signal, on_update
261
+ action = _field(params, "action")
262
+ if action not in ("read", "set"):
263
+ return AgentToolResult(
264
+ content=(TextContent(text='`action` must be "read" or "set".'),),
265
+ details=None,
266
+ isError=True,
267
+ )
268
+ if action == "set":
269
+ try:
270
+ items = self._ledger.set(_parse_items(_field(params, "items")))
271
+ except ValueError as bad:
272
+ return AgentToolResult(
273
+ content=(TextContent(text=str(bad)),),
274
+ details=None,
275
+ isError=True,
276
+ )
277
+ heading = "Checklist updated:"
278
+ else:
279
+ items = self._ledger.read()
280
+ heading = "Current checklist:"
281
+ text = f"{heading}\n{_render_checklist(items)}"
282
+ return AgentToolResult(
283
+ content=(TextContent(text=text),),
284
+ details=TodoDetails(action=action, items=items),
285
+ )
286
+
287
+
288
+ def build_todo_capability(_ctx: DeckContext) -> _TodoCapability:
289
+ """Build the todo capability, binding it to a freshly created in-memory
290
+ ledger.
291
+
292
+ The ledger is owned by the returned capability, so the checklist persists
293
+ for the life of this capability instance (i.e. the session) without any
294
+ external store.
295
+
296
+ :param _ctx: the deck context (unused — the todo card needs no
297
+ cwd/backends)
298
+ """
299
+ return _TodoCapability()
300
+
301
+
302
+ def _build(ctx: DeckContext) -> Capability:
303
+ return build_todo_capability(ctx)
304
+
305
+
306
+ #: Catalog row for the todo capability — registered in ``APP_NOVEL_CARDS``.
307
+ todo_card = CapabilityCard(
308
+ id=capability_id("todo"),
309
+ title="Checklist",
310
+ summary="Keep an in-memory checklist of the current task's steps; set or read it.",
311
+ build=_build,
312
+ )