whiletrue 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 (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
whiletrue/__init__.py ADDED
File without changes
whiletrue/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from whiletrue.cli.app import entrypoint
2
+
3
+ entrypoint()
whiletrue/abort.py ADDED
@@ -0,0 +1,79 @@
1
+ """Cooperative cancellation, modelled on the web's AbortSignal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+
8
+
9
+ class AbortSignal:
10
+ """A one-way flag that can be awaited. Once aborted it stays aborted."""
11
+
12
+ def __init__(self) -> None:
13
+ self._aborted = False
14
+ self._reason: str | None = None
15
+ self._event = asyncio.Event()
16
+ self._callbacks: list[Callable[[], None]] = []
17
+ self._timer: asyncio.TimerHandle | None = None
18
+ self._detach: list[Callable[[], None]] = []
19
+
20
+ @property
21
+ def aborted(self) -> bool:
22
+ return self._aborted
23
+
24
+ @property
25
+ def reason(self) -> str | None:
26
+ return self._reason
27
+
28
+ def abort(self, reason: str = "aborted") -> None:
29
+ if self._aborted:
30
+ return
31
+ self._aborted = True
32
+ self._reason = reason
33
+ self._event.set()
34
+ callbacks, self._callbacks = self._callbacks, []
35
+ for callback in callbacks:
36
+ callback()
37
+
38
+ async def wait(self) -> None:
39
+ """Returns once the signal is aborted."""
40
+ await self._event.wait()
41
+
42
+ def on_abort(self, callback: Callable[[], None]) -> Callable[[], None]:
43
+ """Calls `callback` on abort (immediately if already aborted). Returns an unsubscribe."""
44
+ if self._aborted:
45
+ callback()
46
+ return lambda: None
47
+ self._callbacks.append(callback)
48
+
49
+ def remove() -> None:
50
+ if callback in self._callbacks:
51
+ self._callbacks.remove(callback)
52
+
53
+ return remove
54
+
55
+ def dispose(self) -> None:
56
+ """Releases timers and subscriptions held by a derived signal (timeout / any)."""
57
+ if self._timer is not None:
58
+ self._timer.cancel()
59
+ self._timer = None
60
+ for detach in self._detach:
61
+ detach()
62
+ self._detach.clear()
63
+
64
+ @staticmethod
65
+ def any(*signals: AbortSignal) -> AbortSignal:
66
+ """A signal that aborts as soon as any of `signals` does."""
67
+ derived = AbortSignal()
68
+ for source in signals:
69
+ derived._detach.append(source.on_abort(derived.abort))
70
+ return derived
71
+
72
+ @staticmethod
73
+ def timeout(ms: float) -> AbortSignal:
74
+ """A signal that aborts after `ms` milliseconds."""
75
+ derived = AbortSignal()
76
+ derived._timer = asyncio.get_running_loop().call_later(
77
+ ms / 1000, lambda: derived.abort("timeout")
78
+ )
79
+ return derived
@@ -0,0 +1,99 @@
1
+ """Built-in modules: shell, gate.*, exec, set, agent, sessions, human.approve, comments."""
2
+
3
+ from whiletrue.basics.agent import AgentConfig, agent_module
4
+ from whiletrue.basics.comments import (
5
+ CommentReplies,
6
+ CommentReply,
7
+ comments_reply_module,
8
+ gate_comments_answered_module,
9
+ )
10
+ from whiletrue.basics.diff_size import DiffSizeConfig, diff_size_module
11
+ from whiletrue.basics.exec_ import ExecConfig, exec_module
12
+ from whiletrue.basics.history import (
13
+ DistillConfig,
14
+ HistoryConfig,
15
+ context_history_module,
16
+ history_distill_module,
17
+ render_history,
18
+ )
19
+ from whiletrue.basics.human import ApproveConfig, approve_strategy
20
+ from whiletrue.basics.json_source import JsonSource
21
+ from whiletrue.basics.markdown_source import MarkdownTasksSource
22
+ from whiletrue.basics.process import CommandResult, run_command, tail
23
+ from whiletrue.basics.sessions import (
24
+ SessionEndConfig,
25
+ context_reset_module,
26
+ session_end_module,
27
+ )
28
+ from whiletrue.basics.set_ import SetConfig, set_module
29
+ from whiletrue.basics.shell import (
30
+ GateTestsConfig,
31
+ ShellConfig,
32
+ gate_shell_module,
33
+ gate_tests_module,
34
+ shell_module,
35
+ )
36
+ from whiletrue.basics.wake import FileExistsWake, TimerWake
37
+ from whiletrue.core import PluginApi, SimplePlugin
38
+
39
+
40
+ def _register(api: PluginApi) -> None:
41
+ api.register_action(shell_module)
42
+ api.register_action(gate_shell_module)
43
+ api.register_action(gate_tests_module)
44
+ api.register_action(diff_size_module)
45
+ api.register_action(exec_module)
46
+ api.register_action(set_module)
47
+ api.register_action(agent_module)
48
+ api.register_action(session_end_module)
49
+ api.register_action(context_reset_module)
50
+ api.register_action(comments_reply_module)
51
+ api.register_action(context_history_module)
52
+ api.register_action(history_distill_module)
53
+ api.register_action(gate_comments_answered_module)
54
+ api.register_strategy(approve_strategy)
55
+ api.register_provider("source", "json", JsonSource())
56
+ api.register_provider("source", "markdown_tasks", MarkdownTasksSource())
57
+ api.register_provider("wake", "file_exists", FileExistsWake())
58
+ api.register_provider("wake", "timer", TimerWake())
59
+ api.register_cap("CommentReplies", CommentReplies)
60
+
61
+
62
+ #: Registers the built-in modules through the same public plugin API third parties use.
63
+ basics_plugin = SimplePlugin(name="whiletrue.basics", register=_register)
64
+
65
+ __all__ = [
66
+ "AgentConfig",
67
+ "ApproveConfig",
68
+ "CommandResult",
69
+ "CommentReplies",
70
+ "CommentReply",
71
+ "DiffSizeConfig",
72
+ "DistillConfig",
73
+ "ExecConfig",
74
+ "GateTestsConfig",
75
+ "HistoryConfig",
76
+ "JsonSource",
77
+ "MarkdownTasksSource",
78
+ "SessionEndConfig",
79
+ "SetConfig",
80
+ "ShellConfig",
81
+ "agent_module",
82
+ "approve_strategy",
83
+ "basics_plugin",
84
+ "comments_reply_module",
85
+ "context_history_module",
86
+ "context_reset_module",
87
+ "diff_size_module",
88
+ "exec_module",
89
+ "gate_comments_answered_module",
90
+ "gate_shell_module",
91
+ "gate_tests_module",
92
+ "history_distill_module",
93
+ "render_history",
94
+ "run_command",
95
+ "session_end_module",
96
+ "set_module",
97
+ "shell_module",
98
+ "tail",
99
+ ]
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from whiletrue.abort import AbortSignal
10
+ from whiletrue.core import (
11
+ ActionModule,
12
+ AgentDefinition,
13
+ AgentLimits,
14
+ AgentRequest,
15
+ ConfigError,
16
+ ConfigIssue,
17
+ Emit,
18
+ Ports,
19
+ PortSpec,
20
+ Registry,
21
+ RunContext,
22
+ StepContext,
23
+ ValidateContext,
24
+ define_action,
25
+ load_agent_definition,
26
+ render_template,
27
+ )
28
+ from whiletrue.core.module import PortValues
29
+
30
+
31
+ class AgentConfig(BaseModel):
32
+ uses: str = Field(min_length=1)
33
+ runner: str | None = None
34
+ vars: dict[str, Any] = Field(default_factory=lambda: {})
35
+
36
+
37
+ #: Caps that carry no useful JSON Schema constraint for structured output.
38
+ _UNSTRUCTURED = {"Text", "Any"}
39
+
40
+
41
+ def _runner_issues(
42
+ definition: AgentDefinition, runner_id: str, registry: Registry
43
+ ) -> list[ConfigIssue]:
44
+ runner = registry.get_runner(runner_id)
45
+ if runner is None:
46
+ return [
47
+ ConfigIssue(
48
+ "error",
49
+ f'Unknown runner "{runner_id}" (e.g. claude-code-headless, opencode)',
50
+ )
51
+ ]
52
+ issues: list[ConfigIssue] = []
53
+ out_cap = next(iter(definition.outputs.values()), None)
54
+ if out_cap and out_cap not in _UNSTRUCTURED and not runner.capabilities.structured_output:
55
+ issues.append(
56
+ ConfigIssue(
57
+ "error",
58
+ f'Agent "{definition.name}" declares structured output ({out_cap}) '
59
+ f'but runner "{runner_id}" cannot produce it',
60
+ )
61
+ )
62
+ if (
63
+ definition.limits is not None
64
+ and definition.limits.budget_usd is not None
65
+ and not runner.capabilities.budget
66
+ ):
67
+ issues.append(
68
+ ConfigIssue(
69
+ "error",
70
+ f'Agent "{definition.name}" sets a budget but runner "{runner_id}" '
71
+ "cannot enforce one",
72
+ )
73
+ )
74
+ if definition.fork and not runner.capabilities.fork:
75
+ issues.append(
76
+ ConfigIssue(
77
+ "error",
78
+ f'Agent "{definition.name}" asks to fork a session but runner "{runner_id}" '
79
+ "cannot fork",
80
+ )
81
+ )
82
+ if definition.bare and runner.capabilities.interactive:
83
+ issues.append(
84
+ ConfigIssue(
85
+ "warning",
86
+ f'Agent "{definition.name}" sets `bare`, which turns off hooks: runner '
87
+ f'"{runner_id}" then cannot nudge, enforce limits or detect steering',
88
+ )
89
+ )
90
+ if definition.session != "fresh" and not runner.capabilities.resume:
91
+ issues.append(
92
+ ConfigIssue(
93
+ "error",
94
+ f'Agent "{definition.name}" asks for session "{definition.session}" '
95
+ f'but runner "{runner_id}" cannot resume sessions',
96
+ )
97
+ )
98
+ return issues
99
+
100
+
101
+ def _validate(config: AgentConfig, vctx: ValidateContext) -> list[ConfigIssue]:
102
+ try:
103
+ definition = load_agent_definition(Path(vctx.cwd) / config.uses)
104
+ except ConfigError as error:
105
+ return [ConfigIssue("error", str(error))]
106
+ except OSError as error:
107
+ return [ConfigIssue("error", f"Cannot read agent file: {error}")]
108
+ issues: list[ConfigIssue] = []
109
+ if definition.limits is None:
110
+ issues.append(
111
+ ConfigIssue(
112
+ "error",
113
+ f'Agent "{definition.name}" needs `limits` (budget_usd, turns, wall): '
114
+ "agent steps are bounded by default",
115
+ )
116
+ )
117
+ return [*issues, *_runner_issues(definition, config.runner or definition.runner, vctx.registry)]
118
+
119
+
120
+ async def _run(input: PortValues, ctx: StepContext[AgentConfig]) -> Emit:
121
+ definition = load_agent_definition(Path(ctx.cwd) / ctx.config.uses)
122
+ runner_id = ctx.config.runner or definition.runner
123
+ runner = ctx.registry.get_runner(runner_id)
124
+ if runner is None:
125
+ return Emit("fail", {"error": f'Unknown runner "{runner_id}"'})
126
+
127
+ prompt = render_template(
128
+ definition.prompt,
129
+ {**ctx.scope.snapshot(), "input": input.get("in"), **ctx.config.vars},
130
+ )
131
+
132
+ out_cap = next(iter(definition.outputs.values()), None)
133
+ structured = out_cap is not None and out_cap not in _UNSTRUCTURED
134
+ wall: AbortSignal | None = None
135
+ signal = ctx.signal
136
+ if definition.limits is not None and definition.limits.wall_ms is not None:
137
+ wall = AbortSignal.timeout(definition.limits.wall_ms)
138
+ signal = AbortSignal.any(ctx.signal, wall)
139
+
140
+ sessions: dict[str, str] | None = ctx.scope.get("sessions")
141
+ shared = definition.session if definition.session != "fresh" and sessions is not None else None
142
+ existing = sessions.get(shared) if sessions is not None and shared else None
143
+ new_session = str(uuid.uuid4())
144
+
145
+ def path(p: str) -> str:
146
+ return str(Path(ctx.cwd) / p)
147
+
148
+ request = AgentRequest(
149
+ prompt=prompt,
150
+ cwd=ctx.cwd,
151
+ model=definition.model,
152
+ allowed_tools=definition.tools,
153
+ permission_mode=definition.permission_mode,
154
+ output_schema=ctx.registry.caps.to_json_schema(out_cap) if structured and out_cap else None,
155
+ limits=AgentLimits(
156
+ budget_usd=definition.limits.budget_usd,
157
+ turns=definition.limits.turns,
158
+ wall_ms=definition.limits.wall_ms,
159
+ )
160
+ if definition.limits is not None
161
+ else None,
162
+ session_id=None if existing else new_session,
163
+ resume=existing,
164
+ fork=bool(existing) and definition.fork,
165
+ add_dirs=[path(d) for d in definition.add_dirs] if definition.add_dirs else None,
166
+ append_system_prompt_file=path(definition.append_system_prompt_file)
167
+ if definition.append_system_prompt_file
168
+ else None,
169
+ mcp_config=[path(c) for c in definition.mcp_config] if definition.mcp_config else None,
170
+ bare=definition.bare,
171
+ on_done=definition.on_done,
172
+ step_key=ctx.step_key,
173
+ label=f"{ctx.item_id} {definition.name}",
174
+ item=ctx.scope.get("item"),
175
+ output_cap=out_cap,
176
+ validate_output=(lambda v, cap=out_cap: ctx.registry.caps.validate(cap, v))
177
+ if structured and out_cap
178
+ else None,
179
+ )
180
+
181
+ def on_event(event: dict[str, Any]) -> None:
182
+ if event.get("kind") == "steer" and ctx.store is not None:
183
+ ctx.store.add_steer(
184
+ run_id=ctx.run_id,
185
+ item_id=ctx.item_id,
186
+ step=ctx.node_id,
187
+ prompt=str(event.get("prompt", "")),
188
+ created_at=ctx.now(),
189
+ )
190
+ ctx.bus.emit(
191
+ "agent.event",
192
+ ctx.run_id,
193
+ {"node": ctx.node_id, **event},
194
+ item_id=ctx.item_id,
195
+ )
196
+
197
+ on_event({"kind": "runner", "runner": runner_id})
198
+ try:
199
+ if ctx.agent_slots is not None:
200
+ async with ctx.agent_slots:
201
+ result = await runner.run(request, RunContext(signal=signal, on_event=on_event))
202
+ else:
203
+ result = await runner.run(request, RunContext(signal=signal, on_event=on_event))
204
+ finally:
205
+ if wall is not None:
206
+ wall.dispose()
207
+ signal.dispose()
208
+ if shared and sessions is not None and not (existing and definition.fork):
209
+ # Remember the conversation (even a failed one) so a retry or a later step continues it.
210
+ sessions[shared] = result.session_id or existing or new_session
211
+ if result.cost_usd:
212
+ ctx.record_cost(result.cost_usd)
213
+ if result.steered_cost_usd is not None:
214
+ on_event(
215
+ {
216
+ "kind": "cost",
217
+ "agent_usd": (result.cost_usd or 0.0) - result.steered_cost_usd,
218
+ "steered_usd": result.steered_cost_usd,
219
+ }
220
+ )
221
+ if result.park:
222
+ return Emit(
223
+ "needs_human",
224
+ {"reason": result.park, "detail": result.error, "text": result.text},
225
+ )
226
+ if not result.ok:
227
+ return Emit("fail", {"error": result.error or "agent failed", "text": result.text})
228
+ value = result.output if result.output is not None else result.text
229
+ if out_cap:
230
+ check = ctx.registry.caps.validate(out_cap, value)
231
+ if not check.ok:
232
+ return Emit(
233
+ "fail",
234
+ {"error": f"Agent output does not match {out_cap}: {'; '.join(check.errors)}"},
235
+ )
236
+ return Emit("ok", value)
237
+
238
+
239
+ #: Runs a prompt-defined agent (markdown with frontmatter) on a runner.
240
+ agent_module: ActionModule[AgentConfig] = define_action(
241
+ ActionModule(
242
+ id="agent",
243
+ config=AgentConfig,
244
+ ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
245
+ effects="agentic",
246
+ validate=_validate,
247
+ run=_run,
248
+ )
249
+ )
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from whiletrue.core import (
6
+ ActionModule,
7
+ CommentsContext,
8
+ Emit,
9
+ EmptyConfig,
10
+ Ports,
11
+ PortSpec,
12
+ StepContext,
13
+ define_action,
14
+ with_prefix,
15
+ )
16
+ from whiletrue.core.module import PortValues
17
+
18
+ COMMENT_KINDS = ("question", "change_request", "nit", "approval", "out_of_scope")
19
+
20
+
21
+ class CommentReply(BaseModel):
22
+ comment_id: int
23
+ #: How the comment was classified.
24
+ kind: str = "question"
25
+ #: What was said back: the answer, "fixed in abc123", or "declined because ...".
26
+ reply: str = Field(min_length=1)
27
+
28
+
29
+ class CommentReplies(BaseModel):
30
+ """What a responder agent returns: one reply per comment it was given."""
31
+
32
+ replies: list[CommentReply]
33
+
34
+
35
+ async def _reply(input: PortValues, ctx: StepContext[EmptyConfig]) -> Emit:
36
+ store = ctx.store
37
+ if store is None:
38
+ return Emit("fail", {"error": "comments.reply needs a state store"})
39
+ payload = CommentReplies.model_validate(input.get("in") or {})
40
+ known = {c.id: c for c in store.list_comments(ctx.item_id)}
41
+ item = ctx.scope.get("item") or {"id": ctx.item_id}
42
+ posted: list[int] = []
43
+ for reply in payload.replies:
44
+ comment = known.get(reply.comment_id)
45
+ if comment is None:
46
+ return Emit("fail", {"error": f"No comment {reply.comment_id} on {ctx.item_id}"})
47
+ if comment.answered:
48
+ continue
49
+ key = f"reply:{ctx.item_id}:{comment.source}:{comment.external_id}"
50
+ effect = store.effect_get(key)
51
+ body = with_prefix(reply.reply)
52
+ if effect is None:
53
+ provider = ctx.registry.get_provider("comments", comment.source)
54
+ store.effect_put(
55
+ key, run_id=ctx.run_id, status="started", ref=None, created_at=ctx.now()
56
+ )
57
+ if provider is not None:
58
+ ref = await provider.reply(
59
+ comment, body, CommentsContext(ctx.cwd, ctx.now, ctx.item_id, item)
60
+ )
61
+ else:
62
+ ref = None # a local comment: the answer is only recorded
63
+ store.effect_put(key, run_id=ctx.run_id, status="done", ref=ref, created_at=ctx.now())
64
+ # A "started" effect with no "done" means a crash mid-post: do not risk a duplicate.
65
+ store.answer_comment(comment.id, body)
66
+ ctx.bus.emit(
67
+ "comment.answered",
68
+ ctx.run_id,
69
+ {"comment": comment.id, "source": comment.source, "kind": reply.kind},
70
+ item_id=ctx.item_id,
71
+ )
72
+ posted.append(comment.id)
73
+ return Emit("ok", {"answered": posted})
74
+
75
+
76
+ #: Posts the agent's replies in each comment's own thread (prefixed `[whiletrue]`, once only).
77
+ comments_reply_module: ActionModule[EmptyConfig] = define_action(
78
+ ActionModule(
79
+ id="comments.reply",
80
+ config=EmptyConfig,
81
+ ports=Ports(inputs=[PortSpec("in", "CommentReplies")], outputs=[PortSpec("out", "Any")]),
82
+ effects="side-effect",
83
+ cacheable=False,
84
+ idempotency_key=lambda _i, ctx: f"comments.reply:{ctx.item_id}:{ctx.node_id}",
85
+ run=_reply,
86
+ )
87
+ )
88
+
89
+
90
+ async def _answered(_input: PortValues, ctx: StepContext[EmptyConfig]) -> Emit:
91
+ if ctx.store is None:
92
+ return Emit("fail", {"error": "gate.comments_answered needs a state store"})
93
+ open_comments = ctx.store.list_comments(ctx.item_id, unanswered_only=True)
94
+ if open_comments:
95
+ ids = ", ".join(f"#{c.id} ({c.author})" for c in open_comments)
96
+ return Emit(
97
+ "fail",
98
+ {"error": f"{len(open_comments)} comment(s) still have no reply: {ids}"},
99
+ )
100
+ return Emit("ok", _input.get("in"))
101
+
102
+
103
+ #: Fails while any comment on the item (human or reviewer) has no reply.
104
+ gate_comments_answered_module: ActionModule[EmptyConfig] = define_action(
105
+ ActionModule(
106
+ id="gate.comments_answered",
107
+ config=EmptyConfig,
108
+ ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
109
+ effects="deterministic",
110
+ cacheable=False,
111
+ run=_answered,
112
+ )
113
+ )
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, PositiveInt
4
+
5
+ from whiletrue.basics.process import run_command, tail
6
+ from whiletrue.core import (
7
+ ActionModule,
8
+ ConfigIssue,
9
+ Emit,
10
+ Ports,
11
+ PortSpec,
12
+ StepContext,
13
+ define_action,
14
+ )
15
+ from whiletrue.core.module import PortValues
16
+
17
+ #: git's well-known empty tree, so `diff` works in a repository without commits.
18
+ _EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
19
+
20
+
21
+ class DiffSizeConfig(BaseModel):
22
+ max_lines: PositiveInt | None = None
23
+ max_files: PositiveInt | None = None
24
+ #: Revision to diff the working tree against. Default: HEAD.
25
+ base: str = "HEAD"
26
+
27
+
28
+ def _validate(config: DiffSizeConfig, _vctx: object) -> list[ConfigIssue]:
29
+ if config.max_lines is None and config.max_files is None:
30
+ return [ConfigIssue("error", "`gate.diff_size` needs `max_lines` and/or `max_files`")]
31
+ return []
32
+
33
+
34
+ async def _git(ctx: StepContext[DiffSizeConfig], *args: str):
35
+ return await run_command("git", list(args), cwd=ctx.cwd, signal=ctx.signal)
36
+
37
+
38
+ async def _run(_input: PortValues, ctx: StepContext[DiffSizeConfig]) -> Emit:
39
+ cfg = ctx.config
40
+ base = cfg.base
41
+ if base == "HEAD" and (await _git(ctx, "rev-parse", "--verify", "HEAD")).code != 0:
42
+ base = _EMPTY_TREE
43
+ diff = await _git(ctx, "diff", "--numstat", base)
44
+ if diff.code != 0:
45
+ return Emit("fail", {"error": "git diff failed", "output": tail(diff.stderr)})
46
+ untracked = await _git(ctx, "ls-files", "--others", "--exclude-standard")
47
+ if untracked.code != 0:
48
+ return Emit("fail", {"error": "git ls-files failed", "output": tail(untracked.stderr)})
49
+ lines = 0
50
+ files = 0
51
+ for row in diff.stdout.splitlines():
52
+ added, deleted, _name = [*row.split("\t", 2), "", "", ""][:3]
53
+ files += 1
54
+ # Binary files show "-" for both counts.
55
+ lines += (int(added) if added.isdigit() else 0) + (int(deleted) if deleted.isdigit() else 0)
56
+ files += len([n for n in untracked.stdout.splitlines() if n])
57
+ stats = {"lines": lines, "files": files}
58
+ problems: list[str] = []
59
+ if cfg.max_lines is not None and lines > cfg.max_lines:
60
+ problems.append(f"{lines} changed lines exceed the limit of {cfg.max_lines}")
61
+ if cfg.max_files is not None and files > cfg.max_files:
62
+ problems.append(f"{files} changed files exceed the limit of {cfg.max_files}")
63
+ if problems:
64
+ return Emit("fail", {"error": "; ".join(problems), **stats})
65
+ return Emit("ok", stats)
66
+
67
+
68
+ #: A deterministic gate: fails when the working tree differs from `base` by more than the limits
69
+ #: (changed lines and files, untracked files counted as files).
70
+ diff_size_module: ActionModule[DiffSizeConfig] = define_action(
71
+ ActionModule(
72
+ id="gate.diff_size",
73
+ config=DiffSizeConfig,
74
+ ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
75
+ effects="deterministic",
76
+ cacheable=False,
77
+ validate=_validate, # pyright: ignore[reportArgumentType]
78
+ run=_run,
79
+ )
80
+ )