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
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field, ValidationError
8
+
9
+ from whiletrue.basics.process import run_command, tail
10
+ from whiletrue.core import (
11
+ ActionModule,
12
+ Emit,
13
+ Ports,
14
+ PortSpec,
15
+ StepContext,
16
+ define_action,
17
+ parse_duration,
18
+ )
19
+ from whiletrue.core.module import PortValues
20
+
21
+
22
+ class ExecConfig(BaseModel):
23
+ command: str = Field(min_length=1)
24
+ args: list[str] = Field(default_factory=lambda: [])
25
+ cwd: str | None = None
26
+ env: dict[str, str] | None = None
27
+ timeout: str | int | float | None = None
28
+
29
+
30
+ class _Reply(BaseModel):
31
+ port: str | None = None
32
+ value: Any = None
33
+
34
+
35
+ async def _run(input: PortValues, ctx: StepContext[ExecConfig]) -> Emit:
36
+ cfg = ctx.config
37
+ result = await run_command(
38
+ cfg.command,
39
+ cfg.args,
40
+ cwd=str((Path(ctx.cwd) / (cfg.cwd or ".")).resolve()),
41
+ env=cfg.env,
42
+ signal=ctx.signal,
43
+ timeout_ms=None if cfg.timeout is None else parse_duration(cfg.timeout),
44
+ stdin=json.dumps(
45
+ {
46
+ "input": input.get("in"),
47
+ "config": ctx.config.model_dump(),
48
+ "item": ctx.scope.get("item"),
49
+ "step": {"id": ctx.node_id, "key": ctx.step_key, "attempt": ctx.attempt},
50
+ }
51
+ ),
52
+ )
53
+ if result.code != 0:
54
+ code = "unknown" if result.code is None else result.code
55
+ return Emit(
56
+ "fail",
57
+ {
58
+ "error": f"`{cfg.command}` exited with code {code}",
59
+ "output": tail(result.stdout + result.stderr),
60
+ },
61
+ )
62
+ try:
63
+ parsed = json.loads(result.stdout)
64
+ except ValueError:
65
+ return Emit("fail", {"error": f"`{cfg.command}` did not print valid JSON on stdout"})
66
+ try:
67
+ reply = _Reply.model_validate(parsed)
68
+ except ValidationError:
69
+ return Emit("fail", {"error": f"`{cfg.command}` reply must be {{ port?, value? }}"})
70
+ return Emit(reply.port or "ok", reply.value)
71
+
72
+
73
+ #: Escape hatch: runs any executable that speaks JSON over stdio, so a module can be written in
74
+ #: any language. Receives `{ input, config, item, step }` on stdin; replies `{ port?, value? }`.
75
+ exec_module = define_action(
76
+ ActionModule(
77
+ id="exec",
78
+ config=ExecConfig,
79
+ ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
80
+ effects="side-effect",
81
+ idempotency_key=lambda _input, ctx: f"exec:{ctx.step_key}",
82
+ run=_run,
83
+ )
84
+ )
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from whiletrue.core import (
8
+ ActionModule,
9
+ Emit,
10
+ Ports,
11
+ PortSpec,
12
+ StateStore,
13
+ StepContext,
14
+ define_action,
15
+ )
16
+ from whiletrue.core.module import PortValues
17
+
18
+
19
+ class HistoryConfig(BaseModel):
20
+ #: `item`: this item's parks, decisions, comments and steers. `project`: learnings a person
21
+ #: approved. `all`: both.
22
+ scope: Literal["item", "project", "all"] = "item"
23
+
24
+
25
+ def render_history(store: StateStore, item_id: str, scope: str) -> str:
26
+ """The human input an agent should know about, as plain text."""
27
+ sections: list[str] = []
28
+ if scope in ("item", "all"):
29
+ lines: list[str] = []
30
+ for park in store.list_parks(item_id):
31
+ if park.decision is not None:
32
+ note = f": {park.notes}" if park.notes else ""
33
+ lines.append(f"- At {park.node_path} ({park.what}) a person {park.decision}{note}")
34
+ for comment in store.list_comments(item_id):
35
+ lines.append(f"- {comment.author} commented: {comment.body}")
36
+ if comment.reply:
37
+ lines.append(f" answered: {comment.reply}")
38
+ for steer in store.list_steers(item_id):
39
+ lines.append(f"- A person steered {steer.step}: {steer.prompt}")
40
+ if lines:
41
+ sections.append("Human input on this item so far:\n" + "\n".join(lines))
42
+ if scope in ("project", "all"):
43
+ learned = store.list_learnings(status="approved")
44
+ if learned:
45
+ sections.append(
46
+ "Lessons this project has approved:\n" + "\n".join(f"- {n.note}" for n in learned)
47
+ )
48
+ return "\n\n".join(sections)
49
+
50
+
51
+ async def _context(_input: PortValues, ctx: StepContext[HistoryConfig]) -> Emit:
52
+ if ctx.store is None:
53
+ return Emit("fail", {"error": "context.history needs a state store"})
54
+ return Emit("ok", render_history(ctx.store, ctx.item_id, ctx.config.scope))
55
+
56
+
57
+ #: Turns the item's human input (and approved project lessons) into text for the next agent.
58
+ context_history_module: ActionModule[HistoryConfig] = define_action(
59
+ ActionModule(
60
+ id="context.history",
61
+ config=HistoryConfig,
62
+ ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Text")]),
63
+ effects="deterministic",
64
+ cacheable=False,
65
+ run=_context,
66
+ )
67
+ )
68
+
69
+
70
+ class DistillConfig(BaseModel):
71
+ #: Where proposals land until a person approves them.
72
+ scope: Literal["project", "run"] = "project"
73
+
74
+
75
+ async def _distill(input: PortValues, ctx: StepContext[DistillConfig]) -> Emit:
76
+ """Proposes lessons from rejections and steers. Nothing is used until a person approves it."""
77
+ store = ctx.store
78
+ if store is None:
79
+ return Emit("fail", {"error": "history.distill needs a state store"})
80
+ known = {n.note for n in store.list_learnings(scope=ctx.config.scope)}
81
+ proposed: list[int] = []
82
+
83
+ def propose(note: str, **provenance: object) -> None:
84
+ if note in known:
85
+ return
86
+ known.add(note)
87
+ record = store.add_learning(
88
+ scope=ctx.config.scope,
89
+ note=note,
90
+ status="proposed",
91
+ created_at=ctx.now(),
92
+ run_id=ctx.run_id,
93
+ provenance={"item": ctx.item_id, **provenance},
94
+ )
95
+ proposed.append(record.id)
96
+
97
+ for park in store.list_parks(ctx.item_id):
98
+ if park.decision == "rejected" and park.notes:
99
+ propose(
100
+ f"A reviewer rejected work with: {park.notes}", source="rejection", park=park.id
101
+ )
102
+ for steer in store.list_steers(ctx.item_id):
103
+ propose(f"A person steered the agent: {steer.prompt}", source="steer", steer=steer.id)
104
+ return Emit("ok", input.get("in") if input.get("in") is not None else {"proposed": proposed})
105
+
106
+
107
+ #: Proposes learnings (with provenance) from rejections and steers; a person approves them.
108
+ history_distill_module: ActionModule[DistillConfig] = define_action(
109
+ ActionModule(
110
+ id="history.distill",
111
+ config=DistillConfig,
112
+ ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
113
+ effects="deterministic",
114
+ cacheable=False,
115
+ run=_distill,
116
+ )
117
+ )
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any, cast
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from whiletrue.core import (
9
+ CommentsContext,
10
+ CompositeContext,
11
+ ConfigIssue,
12
+ Emit,
13
+ ParkRecord,
14
+ PipelineNode,
15
+ Strategy,
16
+ ValidateContext,
17
+ WakeContext,
18
+ WakeResult,
19
+ WakeSpec,
20
+ define_strategy,
21
+ evaluate_wake,
22
+ parse_duration,
23
+ parse_wake,
24
+ sync_comments,
25
+ wake_to_dict,
26
+ )
27
+ from whiletrue.core.module import PortValues
28
+
29
+
30
+ class ApproveConfig(BaseModel):
31
+ #: What is being decided; shown in `whiletrue status` and the control panel.
32
+ what: str = "approval"
33
+ #: Shown to the person deciding (templates see `input`).
34
+ show: str | None = None
35
+ #: What ends the wait besides `whiletrue approve|reject`: see `parse_wake`.
36
+ wake: Any = Field(default="cli")
37
+ #: How often a watching gate re-checks (seconds, or a duration like `5s`).
38
+ poll: str | float = "2s"
39
+
40
+
41
+ def _spec(config: ApproveConfig) -> WakeSpec:
42
+ return parse_wake(config.wake)
43
+
44
+
45
+ def _validate(config: ApproveConfig, vctx: ValidateContext) -> list[ConfigIssue]:
46
+ try:
47
+ spec = _spec(config)
48
+ except ValueError as error:
49
+ return [ConfigIssue("error", str(error))]
50
+ issues = [
51
+ ConfigIssue("error", f'Unknown wake condition "{c.name}"')
52
+ for c in spec.conditions
53
+ if vctx.registry.get_provider("wake", c.name) is None
54
+ ]
55
+ try:
56
+ parse_duration(config.poll)
57
+ except ValueError as error:
58
+ issues.append(ConfigIssue("error", f"`poll`: {error}"))
59
+ return issues
60
+
61
+
62
+ async def _sleep(seconds: float, ctx: CompositeContext[ApproveConfig]) -> None:
63
+ waiter = asyncio.ensure_future(ctx.signal.wait())
64
+ try:
65
+ await asyncio.wait({waiter}, timeout=seconds)
66
+ finally:
67
+ waiter.cancel()
68
+
69
+
70
+ def _park_for(ctx: CompositeContext[ApproveConfig], spec: WakeSpec) -> ParkRecord:
71
+ """The Nth run of this gate within a lineage is the Nth park: replays find their old park."""
72
+ assert ctx.store is not None
73
+ parks = ctx.store.list_parks(ctx.item_id, node_path=ctx.node_id, run_id=ctx.lineage)
74
+ if ctx.attempt <= len(parks):
75
+ return parks[ctx.attempt - 1]
76
+ park = ctx.store.create_park(
77
+ run_id=ctx.lineage,
78
+ item_id=ctx.item_id,
79
+ node_path=ctx.node_id,
80
+ what=ctx.config.what,
81
+ wake=wake_to_dict(spec),
82
+ reason=f"waiting for {ctx.config.what}",
83
+ created_at=ctx.now(),
84
+ )
85
+ ctx.bus.emit(
86
+ "park.created",
87
+ ctx.run_id,
88
+ {"park": park.id, "node": ctx.node_id, "what": park.what},
89
+ item_id=ctx.item_id,
90
+ )
91
+ return park
92
+
93
+
94
+ def _item(ctx: CompositeContext[ApproveConfig]) -> dict[str, Any]:
95
+ item: dict[str, Any] | None = ctx.scope.get("item")
96
+ return item or {"id": ctx.item_id}
97
+
98
+
99
+ async def _run(
100
+ children: list[PipelineNode], input: PortValues, ctx: CompositeContext[ApproveConfig]
101
+ ) -> Emit:
102
+ if ctx.store is None:
103
+ return Emit("fail", {"error": "human.approve needs a state store"})
104
+ store = ctx.store
105
+ spec = _spec(ctx.config)
106
+ park = _park_for(ctx, spec)
107
+ value = input.get("in")
108
+ item = _item(ctx)
109
+ shown = ctx.config.show # templates in config were rendered before the gate ran
110
+ poll = parse_duration(ctx.config.poll) / 1000
111
+ answered_attempts: set[int] = set()
112
+
113
+ while True:
114
+ park = next(p for p in store.list_parks(ctx.item_id) if p.id == park.id)
115
+ if park.decision is None:
116
+ woke = await evaluate_wake(
117
+ spec,
118
+ WakeContext(ctx.cwd, ctx.now, ctx.item_id, park, ctx.registry, item, value),
119
+ )
120
+ if woke is not None:
121
+ _record(ctx, park, woke)
122
+ continue
123
+ else:
124
+ return _outcome(park, value)
125
+
126
+ pending = await _unanswered(ctx, item, value)
127
+ fresh = [c for c in pending if c.id not in answered_attempts]
128
+ if fresh and children:
129
+ answered_attempts.update(c.id for c in fresh)
130
+ # New comments always get a live response, never a recorded one from an earlier cycle.
131
+ ctx.scope.set("no_replay", True)
132
+ cur = Emit("ok", {"comments": [_comment_dict(c) for c in fresh], "input": value})
133
+ for child in children:
134
+ cur = await ctx.run(child, {"in": cur.value})
135
+ if cur.port != "ok":
136
+ break
137
+ ctx.log("info", f"answered {len(fresh)} comment(s) on {ctx.item_id}", None)
138
+ continue
139
+
140
+ if not ctx.watch:
141
+ return Emit(
142
+ "needs_human",
143
+ {"park": park.id, "what": park.what, "show": shown, "waiting_for": "approval"},
144
+ )
145
+ if ctx.signal.aborted:
146
+ return Emit("fail", {"error": "cancelled"})
147
+ await _sleep(poll, ctx)
148
+
149
+
150
+ def _comment_dict(comment: Any) -> dict[str, Any]:
151
+ return {
152
+ "id": comment.id,
153
+ "author": comment.author,
154
+ "body": comment.body,
155
+ "thread": comment.thread,
156
+ "url": comment.url,
157
+ "source": comment.source,
158
+ "kind": comment.kind,
159
+ }
160
+
161
+
162
+ async def _unanswered(ctx: CompositeContext[ApproveConfig], item: dict[str, Any], value: Any):
163
+ assert ctx.store is not None
164
+ await sync_comments(
165
+ ctx.registry, ctx.store, CommentsContext(ctx.cwd, ctx.now, ctx.item_id, item, value)
166
+ )
167
+ return ctx.store.list_comments(ctx.item_id, unanswered_only=True)
168
+
169
+
170
+ def _record(ctx: CompositeContext[ApproveConfig], park: ParkRecord, woke: WakeResult) -> None:
171
+ assert ctx.store is not None
172
+ ctx.store.decide_park(
173
+ park.id, woke.decision, decided_by=woke.by, decided_at=ctx.now(), notes=woke.note
174
+ )
175
+ ctx.bus.emit(
176
+ "park.decided",
177
+ ctx.run_id,
178
+ {"park": park.id, "decision": woke.decision, "by": woke.by, "note": woke.note},
179
+ item_id=ctx.item_id,
180
+ )
181
+
182
+
183
+ def _outcome(park: ParkRecord, value: Any) -> Emit:
184
+ """Approval passes the input on. A rejection passes it on too, with the reviewer's note under
185
+ `rejection`, so whatever flows through the gate (the PR, say) survives a fix-and-review loop."""
186
+ carried: dict[str, Any] = (
187
+ {k: v for k, v in cast(dict[str, Any], value).items() if k != "rejection"}
188
+ if isinstance(value, dict)
189
+ else {"value": value}
190
+ )
191
+ if park.decision == "rejected":
192
+ carried["rejection"] = {"note": park.notes, "decided_by": park.decided_by}
193
+ return Emit("rejected", carried)
194
+ return Emit("ok", carried if isinstance(value, dict) else value)
195
+
196
+
197
+ #: Parks the item until a person approves or rejects (or a wake condition fires).
198
+ #: `do:` children run whenever new comments arrive, then the gate waits again.
199
+ approve_strategy: Strategy[ApproveConfig] = define_strategy(
200
+ Strategy(id="human.approve", config=ApproveConfig, run=_run, validate=_validate)
201
+ )
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, cast
6
+
7
+ from whiletrue.core import RunItem
8
+ from whiletrue.core.source import SourceError
9
+
10
+
11
+ class JsonSource:
12
+ """Items from a JSON file: a list of objects with an `id`, other fields, and `depends_on`."""
13
+
14
+ async def fetch(self, config: dict[str, Any], cwd: str) -> list[RunItem]:
15
+ path = config.get("path")
16
+ if not isinstance(path, str) or not path:
17
+ raise SourceError("`source.json` needs a `path`")
18
+ try:
19
+ data = json.loads((Path(cwd) / path).read_text(encoding="utf-8"))
20
+ except (OSError, ValueError) as error:
21
+ raise SourceError(f"Cannot read {path}: {error}") from error
22
+ if not isinstance(data, list):
23
+ raise SourceError(f"{path} must contain a JSON list of items")
24
+ items: list[RunItem] = []
25
+ for index, raw in enumerate(cast(list[Any], data)):
26
+ if not isinstance(raw, dict) or "id" not in raw:
27
+ raise SourceError(f"{path}: item {index} needs an `id`")
28
+ fields = {str(k): v for k, v in cast(dict[Any, Any], raw).items()}
29
+ depends = fields.pop("depends_on", [])
30
+ items.append(
31
+ RunItem(
32
+ str(fields.pop("id")), fields, [str(d) for d in cast(list[Any], depends or [])]
33
+ )
34
+ )
35
+ return items
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from whiletrue.core import RunItem
8
+ from whiletrue.core.source import SourceError
9
+
10
+ _TASK = re.compile(r"^\s*[-*]\s+\[(?P<mark>[ xX])\]\s+(?P<text>.+?)\s*$")
11
+ _META = re.compile(r"\s*\{(?P<meta>[^{}]*)\}\s*$")
12
+
13
+
14
+ def _slug(text: str) -> str:
15
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40] or "task"
16
+
17
+
18
+ class MarkdownTasksSource:
19
+ """Unchecked checklist lines of a markdown file are the items.
20
+
21
+ `- [ ] Add the login page {#login after=schema,api}`: the optional `{...}` gives the item an
22
+ id (default: a slug of the title) and the ids it waits for. Checked lines are done and skipped,
23
+ so ticking a box in the file removes the item on the next resume (the source wins).
24
+ """
25
+
26
+ async def fetch(self, config: dict[str, Any], cwd: str) -> list[RunItem]:
27
+ path = config.get("path")
28
+ if not isinstance(path, str) or not path:
29
+ raise SourceError("`source.markdown_tasks` needs a `path`")
30
+ try:
31
+ text = (Path(cwd) / path).read_text(encoding="utf-8")
32
+ except OSError as error:
33
+ raise SourceError(f"Cannot read {path}: {error}") from error
34
+ items: list[RunItem] = []
35
+ seen: set[str] = set()
36
+ for number, line in enumerate(text.splitlines(), start=1):
37
+ match = _TASK.match(line)
38
+ if match is None or match["mark"] != " ":
39
+ continue
40
+ title = match["text"]
41
+ item_id, depends = "", []
42
+ meta = _META.search(title)
43
+ if meta is not None:
44
+ title = title[: meta.start()].strip()
45
+ for token in meta["meta"].split():
46
+ if token.startswith("#"):
47
+ item_id = token[1:]
48
+ elif token.startswith("after="):
49
+ depends = [d for d in token[6:].split(",") if d]
50
+ item_id = item_id or _slug(title)
51
+ if item_id in seen:
52
+ raise SourceError(f'{path}:{number}: duplicate task id "{item_id}"')
53
+ seen.add(item_id)
54
+ items.append(RunItem(item_id, {"title": title, "line": number}, depends))
55
+ return items
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import os
6
+ import signal as signal_module
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from whiletrue.abort import AbortSignal
11
+
12
+
13
+ @dataclass
14
+ class CommandResult:
15
+ #: Exit code; None if the process never started or was killed by a signal.
16
+ code: int | None
17
+ stdout: str
18
+ stderr: str
19
+ timed_out: bool = False
20
+ aborted: bool = False
21
+
22
+
23
+ def kill_process_group(pid: int) -> None:
24
+ """SIGKILLs the whole group so grandchildren cannot keep our pipes open."""
25
+ with contextlib.suppress(ProcessLookupError, PermissionError):
26
+ os.killpg(pid, signal_module.SIGKILL)
27
+
28
+
29
+ async def run_command(
30
+ command: str,
31
+ args: list[str],
32
+ *,
33
+ cwd: str,
34
+ env: dict[str, str] | None = None,
35
+ stdin: str | None = None,
36
+ signal: AbortSignal | None = None,
37
+ timeout_ms: float | None = None,
38
+ ) -> CommandResult:
39
+ """Runs a command to completion.
40
+
41
+ Never raises for non-zero exits; spawn errors are reported as `code=None` with the OS message
42
+ on stderr.
43
+ """
44
+ try:
45
+ proc = await asyncio.create_subprocess_exec(
46
+ command,
47
+ *args,
48
+ cwd=cwd,
49
+ env={**os.environ, **(env or {})},
50
+ stdin=asyncio.subprocess.PIPE,
51
+ stdout=asyncio.subprocess.PIPE,
52
+ stderr=asyncio.subprocess.PIPE,
53
+ start_new_session=True,
54
+ )
55
+ except OSError as error:
56
+ return CommandResult(
57
+ code=None,
58
+ stdout="",
59
+ stderr=str(error),
60
+ aborted=signal.aborted if signal else False,
61
+ )
62
+
63
+ communicate = asyncio.ensure_future(proc.communicate((stdin or "").encode()))
64
+ abort_wait = asyncio.ensure_future(signal.wait()) if signal else None
65
+ waiting: set[asyncio.Future[Any]] = {communicate}
66
+ if abort_wait is not None:
67
+ waiting.add(abort_wait)
68
+
69
+ timed_out = False
70
+ aborted = False
71
+ try:
72
+ done, _ = await asyncio.wait(
73
+ waiting,
74
+ timeout=None if timeout_ms is None else timeout_ms / 1000,
75
+ return_when=asyncio.FIRST_COMPLETED,
76
+ )
77
+ if communicate not in done:
78
+ timed_out = not done
79
+ aborted = abort_wait is not None and abort_wait in done
80
+ kill_process_group(proc.pid)
81
+ stdout_b, stderr_b = await communicate
82
+ await proc.wait()
83
+ finally:
84
+ if abort_wait is not None:
85
+ abort_wait.cancel()
86
+
87
+ code = proc.returncode
88
+ return CommandResult(
89
+ code=None if code is None or code < 0 else code,
90
+ stdout=stdout_b.decode(errors="replace"),
91
+ stderr=stderr_b.decode(errors="replace"),
92
+ timed_out=timed_out,
93
+ aborted=aborted or (signal.aborted if signal else False),
94
+ )
95
+
96
+
97
+ def tail(text: str, n: int = 40) -> str:
98
+ """Last `n` lines of text, for compact failure output."""
99
+ return "\n".join(text.rstrip().split("\n")[-n:])
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from whiletrue.core import (
6
+ ActionModule,
7
+ Emit,
8
+ EmptyConfig,
9
+ Ports,
10
+ PortSpec,
11
+ Scope,
12
+ StepContext,
13
+ define_action,
14
+ )
15
+ from whiletrue.core.module import PortValues
16
+
17
+
18
+ class SessionEndConfig(BaseModel):
19
+ #: The shared session to close (`item` is the one `session: item` uses).
20
+ name: str = "item"
21
+
22
+
23
+ def _sessions(scope: Scope) -> dict[str, str]:
24
+ sessions: dict[str, str] | None = scope.get("sessions")
25
+ return sessions if sessions is not None else {}
26
+
27
+
28
+ async def _end(input: PortValues, ctx: StepContext[SessionEndConfig]) -> Emit:
29
+ _sessions(ctx.scope).pop(ctx.config.name, None)
30
+ return Emit("ok", input.get("in"))
31
+
32
+
33
+ async def _reset(input: PortValues, ctx: StepContext[EmptyConfig]) -> Emit:
34
+ _sessions(ctx.scope).clear()
35
+ return Emit("ok", input.get("in"))
36
+
37
+
38
+ _PORTS = Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")])
39
+
40
+ #: Closes a shared session: the next agent that asks for it starts a new conversation.
41
+ session_end_module: ActionModule[SessionEndConfig] = define_action(
42
+ ActionModule(
43
+ id="session.end",
44
+ config=SessionEndConfig,
45
+ ports=_PORTS,
46
+ effects="deterministic",
47
+ cacheable=False,
48
+ run=_end,
49
+ )
50
+ )
51
+
52
+ #: Drops every shared session of the item, so later agents start with a clean context.
53
+ context_reset_module: ActionModule[EmptyConfig] = define_action(
54
+ ActionModule(
55
+ id="context.reset",
56
+ config=EmptyConfig,
57
+ ports=_PORTS,
58
+ effects="deterministic",
59
+ cacheable=False,
60
+ run=_reset,
61
+ )
62
+ )
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from whiletrue.core import ActionModule, Emit, Ports, PortSpec, StepContext, define_action
8
+ from whiletrue.core.module import PortValues
9
+
10
+
11
+ class SetConfig(BaseModel):
12
+ #: Any JSON value; `{{...}}` templates in strings are rendered before the step runs.
13
+ value: Any = None
14
+
15
+
16
+ async def _run(_input: PortValues, ctx: StepContext[SetConfig]) -> Emit:
17
+ return Emit("ok", ctx.config.value)
18
+
19
+
20
+ #: Emits a constant (after templating). Useful for markers in branches and for tests.
21
+ set_module: ActionModule[SetConfig] = define_action(
22
+ ActionModule(
23
+ id="set",
24
+ config=SetConfig,
25
+ ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
26
+ effects="pure",
27
+ run=_run,
28
+ )
29
+ )