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/core/caps.py ADDED
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
8
+
9
+
10
+ class CapError(Exception):
11
+ """Caps are named schemas attached to ports (like GStreamer caps)."""
12
+
13
+
14
+ @dataclass
15
+ class CapValidation:
16
+ ok: bool
17
+ value: Any = None
18
+ errors: list[str] = field(default_factory=lambda: [])
19
+
20
+
21
+ _CAP_NAME = re.compile(r"^[A-Z][A-Za-z0-9]*$")
22
+
23
+
24
+ def format_validation_error(error: ValidationError) -> list[str]:
25
+ """`path: message` per issue, in the order pydantic reports them."""
26
+ out: list[str] = []
27
+ for issue in error.errors():
28
+ path = ".".join(str(p) for p in issue["loc"])
29
+ out.append(f"{path}: {issue['msg']}" if path else issue["msg"])
30
+ return out
31
+
32
+
33
+ class CapRegistry:
34
+ def __init__(self) -> None:
35
+ self._caps: dict[str, TypeAdapter[Any]] = {}
36
+
37
+ def register(self, name: str, schema: Any) -> None:
38
+ """`schema` is anything pydantic can validate: a model class, `str`, `Any`, ..."""
39
+ if not _CAP_NAME.match(name):
40
+ raise CapError(f'Invalid cap name "{name}" (use PascalCase)')
41
+ if name in self._caps:
42
+ raise CapError(f'Cap "{name}" is already registered')
43
+ self._caps[name] = schema if isinstance(schema, TypeAdapter) else TypeAdapter(schema) # pyright: ignore[reportUnknownVariableType]
44
+
45
+ def has(self, name: str) -> bool:
46
+ return name in self._caps
47
+
48
+ def names(self) -> list[str]:
49
+ return list(self._caps)
50
+
51
+ def _get(self, name: str) -> TypeAdapter[Any]:
52
+ adapter = self._caps.get(name)
53
+ if adapter is None:
54
+ raise CapError(f'Unknown cap "{name}"')
55
+ return adapter
56
+
57
+ def validate(self, name: str, value: Any) -> CapValidation:
58
+ adapter = self._get(name)
59
+ try:
60
+ parsed = adapter.validate_python(value)
61
+ except ValidationError as error:
62
+ return CapValidation(ok=False, errors=format_validation_error(error))
63
+ return CapValidation(ok=True, value=adapter.dump_python(parsed, mode="json", by_alias=True))
64
+
65
+ def to_json_schema(self, name: str) -> dict[str, Any]:
66
+ return self._get(name).json_schema()
67
+
68
+ def compatible(self, from_cap: str, to_cap: str) -> bool:
69
+ """Can a value produced under `from_cap` be consumed under `to_cap`?
70
+
71
+ Nominal: identical caps, or either side is `Any`. Unknown caps are never compatible.
72
+ """
73
+ if not self.has(from_cap) or not self.has(to_cap):
74
+ return False
75
+ return "Any" in (from_cap, to_cap) or from_cap == to_cap
76
+
77
+
78
+ class _Strict(BaseModel):
79
+ model_config = ConfigDict(strict=True, populate_by_name=True)
80
+
81
+
82
+ class Issue(_Strict):
83
+ id: str | int
84
+ number: int | None = None
85
+ title: str
86
+ body: str
87
+ labels: list[str] | None = None
88
+ url: str | None = None
89
+ spec_refs: list[str] | None = None
90
+ depends_on: list[str | int] | None = None
91
+
92
+
93
+ class DiffFile(_Strict):
94
+ path: str
95
+ additions: int
96
+ deletions: int
97
+
98
+
99
+ class Diff(_Strict):
100
+ files: list[DiffFile]
101
+ patch: str | None = None
102
+
103
+
104
+ class PullRequest(_Strict):
105
+ number: int
106
+ url: str
107
+ branch: str
108
+ base: str | None = None
109
+ title: str | None = None
110
+ repo: str | None = None
111
+
112
+
113
+ class Verdict(_Strict):
114
+ pass_: bool = Field(alias="pass")
115
+ reason: str | None = None
116
+
117
+
118
+ class Finding(_Strict):
119
+ file: str | None = None
120
+ line: int | None = None
121
+ kind: str | None = None
122
+ severity: Literal["low", "medium", "high", "critical"]
123
+ message: str
124
+
125
+
126
+ class ReviewFindings(_Strict):
127
+ findings: list[Finding]
128
+
129
+
130
+ def create_builtin_caps() -> CapRegistry:
131
+ caps = CapRegistry()
132
+ caps.register("Any", Any)
133
+ caps.register("Text", str)
134
+ caps.register("Issue", Issue)
135
+ caps.register("Diff", Diff)
136
+ caps.register("PullRequest", PullRequest)
137
+ caps.register("Verdict", Verdict)
138
+ caps.register("ReviewFindings", ReviewFindings)
139
+ return caps
@@ -0,0 +1,80 @@
1
+ """Comments are human (or reviewer) input attached to an item; every one must get an answer.
2
+
3
+ Providers fetch comments from wherever they live (GitHub PR threads, a local file) and post
4
+ replies. The store remembers them across runs, so a comment is answered once.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any, Literal, Protocol
11
+
12
+ from whiletrue.core.history import CommentRecord
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from whiletrue.core.registry import Registry
18
+ from whiletrue.core.store import StateStore
19
+
20
+ #: Prefix on every reply the pipeline posts, so people always know who wrote what.
21
+ REPLY_PREFIX = "[whiletrue]"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class IncomingComment:
26
+ external_id: str
27
+ author: str
28
+ body: str
29
+ created_at: int
30
+ thread: str | None = None
31
+ url: str | None = None
32
+ kind: Literal["human", "reviewer"] = "human"
33
+
34
+
35
+ @dataclass
36
+ class CommentsContext:
37
+ cwd: str
38
+ now: Callable[[], int]
39
+ item_id: str
40
+ item: dict[str, Any]
41
+ #: What the step before the gate produced (for GitHub: the opened PR).
42
+ input: Any = None
43
+
44
+
45
+ class CommentsProvider(Protocol):
46
+ """Where comments come from and where replies go. Providers never resolve threads."""
47
+
48
+ async def fetch(self, ctx: CommentsContext) -> list[IncomingComment]: ...
49
+
50
+ async def reply(self, comment: CommentRecord, body: str, ctx: CommentsContext) -> str | None:
51
+ """Posts a reply in the comment's thread; returns the new comment's id if there is one."""
52
+ ...
53
+
54
+
55
+ def with_prefix(body: str) -> str:
56
+ return body if body.startswith(REPLY_PREFIX) else f"{REPLY_PREFIX} {body}"
57
+
58
+
59
+ async def sync_comments(
60
+ registry: Registry, store: StateStore, ctx: CommentsContext
61
+ ) -> list[CommentRecord]:
62
+ """Pulls new comments from every provider into the store; returns the ones just added."""
63
+ added: list[CommentRecord] = []
64
+ for name, provider in registry.providers("comments"):
65
+ incoming = await provider.fetch(ctx)
66
+ for comment in incoming:
67
+ record = store.add_comment(
68
+ item_id=ctx.item_id,
69
+ source=name,
70
+ external_id=comment.external_id,
71
+ author=comment.author,
72
+ body=comment.body,
73
+ created_at=comment.created_at,
74
+ thread=comment.thread,
75
+ url=comment.url,
76
+ kind=comment.kind,
77
+ )
78
+ if record is not None:
79
+ added.append(record)
80
+ return added
@@ -0,0 +1,140 @@
1
+ """Composite expansion: `composite: { uses: <file>, params: {...} }` becomes a `sequence` node
2
+ holding the composite's steps, with `{{params.x}}` filled in. Done at load time, so validation,
3
+ planning and hashing see the real tree."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
13
+
14
+ from whiletrue.core.config import (
15
+ CompositeDefinition,
16
+ ConfigError,
17
+ PipelineNode,
18
+ SourceLoc,
19
+ load_composite,
20
+ )
21
+ from whiletrue.core.template import render_template
22
+
23
+ MAX_DEPTH = 10
24
+ RECIPE_PREFIX = "recipe:"
25
+ RECIPES_DIR = Path(__file__).resolve().parent.parent / "recipes"
26
+
27
+ _PARAM = re.compile(r"\{\{\s*params\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}")
28
+
29
+
30
+ class _CompositeStep(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ uses: str = Field(min_length=1)
34
+ params: dict[str, Any] = Field(default_factory=lambda: {})
35
+
36
+
37
+ def _resolve_path(uses: str, base_dir: Path, loc: SourceLoc) -> Path:
38
+ if uses.startswith(RECIPE_PREFIX):
39
+ path = RECIPES_DIR / f"{uses[len(RECIPE_PREFIX) :]}.yaml"
40
+ if not path.is_file():
41
+ raise ConfigError(loc, f'Unknown recipe "{uses[len(RECIPE_PREFIX) :]}"')
42
+ return path
43
+ path = (base_dir / uses).resolve()
44
+ if not path.is_file():
45
+ raise ConfigError(loc, f"Composite file not found: {uses}")
46
+ return path
47
+
48
+
49
+ def _bind_params(
50
+ definition: CompositeDefinition, given: dict[str, Any], loc: SourceLoc
51
+ ) -> dict[str, Any]:
52
+ unknown = sorted(set(given) - set(definition.params))
53
+ if unknown:
54
+ known = ", ".join(sorted(definition.params)) or "none"
55
+ raise ConfigError(
56
+ loc, f'Unknown param "{unknown[0]}" for composite "{definition.name}" (known: {known})'
57
+ )
58
+ bound: dict[str, Any] = {}
59
+ for name, spec in definition.params.items():
60
+ if name in given:
61
+ bound[name] = given[name]
62
+ elif spec.required:
63
+ raise ConfigError(loc, f'Composite "{definition.name}" needs param "{name}"')
64
+ else:
65
+ bound[name] = spec.default
66
+ return bound
67
+
68
+
69
+ def _substitute(value: Any, params: dict[str, Any], loc: SourceLoc) -> Any:
70
+ """Fills `{{params.x}}`; a string that is exactly one token keeps the param's own type."""
71
+ if isinstance(value, str):
72
+ whole = _PARAM.fullmatch(value.strip())
73
+ if whole:
74
+ return _param(whole.group(1), params, loc)
75
+ return _PARAM.sub(
76
+ lambda m: render_template("{{v}}", {"v": _param(m.group(1), params, loc)}), value
77
+ )
78
+ if isinstance(value, list):
79
+ return [_substitute(v, params, loc) for v in value] # pyright: ignore[reportUnknownVariableType]
80
+ if isinstance(value, dict):
81
+ return {k: _substitute(v, params, loc) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType]
82
+ return value
83
+
84
+
85
+ def _param(name: str, params: dict[str, Any], loc: SourceLoc) -> Any:
86
+ if name not in params:
87
+ raise ConfigError(loc, f'Unknown param "{name}"')
88
+ return copy.deepcopy(params[name])
89
+
90
+
91
+ def _prepare(node: PipelineNode, prefix: str, params: dict[str, Any]) -> None:
92
+ node.config = _substitute(node.config, params, node.loc)
93
+ node.id = f"{prefix}.{node.id}"
94
+ node.wires = {port: f"{prefix}.{target}" for port, target in node.wires.items()}
95
+ if node.use == "switch": # `switch` names sibling steps in its config; keep them in sync
96
+ cases = node.config.get("cases")
97
+ if isinstance(cases, dict):
98
+ node.config["cases"] = {k: f"{prefix}.{v}" for k, v in cases.items()} # pyright: ignore[reportUnknownVariableType]
99
+ if isinstance(node.config.get("default"), str):
100
+ node.config["default"] = f"{prefix}.{node.config['default']}"
101
+ for child in node.children:
102
+ _prepare(child, prefix, params)
103
+
104
+
105
+ def expand_composites(
106
+ node: PipelineNode, base_dir: Path, sources: list[str], stack: tuple[Path, ...] = ()
107
+ ) -> PipelineNode:
108
+ """Returns `node` with every `composite` step replaced by its expansion (recursively)."""
109
+ if node.use != "composite":
110
+ node.children = [expand_composites(c, base_dir, sources, stack) for c in node.children]
111
+ return node
112
+ try:
113
+ step = _CompositeStep.model_validate(node.config)
114
+ except ValidationError as error:
115
+ raise ConfigError(
116
+ node.loc, f"Invalid composite step: {error.errors()[0]['msg']}"
117
+ ) from error
118
+ path = _resolve_path(step.uses, base_dir, node.loc)
119
+ if path in stack:
120
+ raise ConfigError(
121
+ node.loc, f"Composite cycle: {' -> '.join(p.name for p in (*stack, path))}"
122
+ )
123
+ if len(stack) >= MAX_DEPTH:
124
+ raise ConfigError(node.loc, f"Composites nested deeper than {MAX_DEPTH} levels")
125
+ definition = load_composite(path)
126
+ params = _bind_params(definition, step.params, node.loc)
127
+ inner = definition.root
128
+ _prepare(inner, node.id, params)
129
+ if str(path) not in sources and not str(path).startswith(str(RECIPES_DIR)):
130
+ sources.append(str(path))
131
+ expanded = expand_composites(inner, path.parent, sources, (*stack, path))
132
+ return PipelineNode(
133
+ id=node.id,
134
+ use="sequence",
135
+ config={},
136
+ children=[expanded],
137
+ wires=node.wires,
138
+ loc=node.loc,
139
+ ports=definition.ports or None,
140
+ )