Skip to content

Commit bceeae0

Browse files
refactoring
Co-authored-by: Koru Agent <agent@coru.dev>
1 parent 8ad4729 commit bceeae0

11 files changed

Lines changed: 740 additions & 5 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Byte-compiled / optimized / DLL files
22
.idea/cli2wup.iml
3+
.intent/
34
.idea/dsl2wup.iml
45
.idea/mcp2wup.iml
56
.idea/nlp2wup.iml

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Continuous todo2code Intent-vs-Reality monitoring.** The opt-in
12+
`intent_monitoring` section runs deterministic or LLM-backed audits on
13+
startup, periodically and after debounced file changes. Findings enter the
14+
normal WUP health/event/Planfile stream as `<project>:intent`, and both the
15+
todo2code CLI and its Python SDK bridge are supported.
1116
- **Pluggable endpoint-discovery adapters (`wup/discovery.py`).** `deps.json` is
1217
now built by per-ecosystem adapters — FastAPI, Flask, Django, NestJS, Express,
1318
Fastify, Hono, Go (gin/echo/net-http) and OpenAPI/Swagger — selected by repo
@@ -59,6 +64,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5964
always writing `app/src/routes`. This fixes `No valid paths to watch` on
6065
projects whose code lives under a different top-level folder (e.g. a monorepo
6166
module using `services/`).
67+
- `wup watch .` now detects source directories inside immediate project
68+
subfolders (for example `api/src` and `worker/services`) through
69+
`wup/config.py::detect_watch_paths`. Existing older auto-generated configs
70+
whose `app/src/routes` paths do not exist fall back through
71+
`wup/core.py::WupWatcher.build_watched_paths` instead of exiting with
72+
`No valid paths to watch`; regression coverage lives in
73+
`tests/test_multi_project.py`.
6274

6375
- **Simultaneous multi-project watching.** `wup watch` now accepts several
6476
project directories and watches them at once in a single process
@@ -1554,4 +1566,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15541566
- Update .idea/.gitignore
15551567
- Update drug/__init__.py
15561568
- Update drug/core.py
1557-

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ wup testql-endpoints /path/to/scenarios --output testql-deps.json
113113
Pass several project roots to watch them all at once in a single process. Each
114114
project keeps its own `wup.yaml`, dependency map, file observer and test queue.
115115

116+
When the current directory is a workspace containing projects in immediate
117+
subfolders, plain `wup watch .` auto-detects their common source directories,
118+
for example `api/src`, `worker/services` and `frontend/app`. This also repairs
119+
the runtime behaviour of older auto-generated configs that still point only to
120+
missing root-level `app`, `src` and `routes` directories. The config file is not
121+
rewritten by this fallback.
122+
116123
```bash
117124
# Watch several projects at the same time
118125
wup watch ./service-a ./service-b ./service-c
@@ -132,6 +139,50 @@ path is resolved per project, so each project uses its own `deps.json`.
132139
> The live `--dashboard` and single `--config` options apply to single-project
133140
> runs only; when watching multiple projects each uses its own `wup.yaml`.
134141
142+
### Continuous Intent Monitoring with todo2code
143+
144+
WUP can run todo2code on startup, periodically and after debounced source-file
145+
changes. Diagnostics are projected into the regular service-health stream as
146+
`<project>:intent`: blocking findings produce `down`, review-required findings
147+
produce `degraded`, and a clean audit produces `up`.
148+
149+
The integration is opt-in and deterministic by default, so merely installing
150+
WUP never starts paid LLM requests:
151+
152+
```yaml
153+
intent_monitoring:
154+
enabled: true
155+
runner: cli
156+
command:
157+
- node
158+
- /path/to/todo2code/dist/src/cli.js
159+
interval_s: 300
160+
debounce_s: 10
161+
run_on_start: true
162+
run_on_change: true
163+
mode: deterministic
164+
task_file: TASK.md
165+
todo_file: TODO.md
166+
changelog_file: CHANGELOG.md
167+
docs:
168+
- README.md
169+
- docs/**/*.md
170+
output_dir: .wup/intent
171+
fail_severities:
172+
- blocking
173+
- review_required
174+
```
175+
176+
Set `runner: python` to use the dependency-free `todo2code-sdk` bridge. Install
177+
it from todo2code's `sdk/python` directory and set `cli_path` to the built
178+
`dist/src/cli.js`. The Python package still delegates semantic processing to
179+
the canonical Node/TypeScript runtime.
180+
181+
`mode: prefer-llm` or `mode: require-llm` enables LLM enrichment for NL and
182+
TODO/CHANGELOG. Documentation and final-summary LLM calls remain separately
183+
controlled by `docs_llm` and `summary_llm`. Use these modes deliberately: an
184+
audit may run after every debounce interval and therefore incur provider cost.
185+
135186
### Initialize Configuration
136187

137188
```bash
@@ -250,6 +301,8 @@ The generated `wup.yaml` includes:
250301
- **Metadata header**: Version, generation date, documentation links
251302
- **Dependencies info**: WUP version and optional wupbro dashboard
252303
- **Quick start guide**: Common commands to get started
304+
- **Detected watch paths**: Existing common source directories at the project
305+
root and one level below it
253306

254307
Example `wup.yaml`:
255308

app.doql.events.pb

22.7 KB
Binary file not shown.

tests/test_intent_monitor.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Tests for continuous todo2code Intent-vs-Reality monitoring."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
import types
8+
from pathlib import Path
9+
from types import SimpleNamespace
10+
11+
from wup.config import load_config, save_config
12+
from wup.intent_monitor import IntentAuditResult, Todo2CodeIntentMonitor
13+
from wup.models.config import IntentMonitoringConfig, ProjectConfig, WupConfig
14+
from wup.testql_watcher import TestQLWatcher
15+
16+
17+
def _diagnostics(tmp_path: Path, items: list[dict]) -> Path:
18+
path = tmp_path / "diagnostics.json"
19+
path.write_text(json.dumps({"diagnostics": items}), encoding="utf-8")
20+
return path
21+
22+
23+
def test_config_round_trip_preserves_intent_monitoring(tmp_path: Path) -> None:
24+
config = WupConfig(
25+
project=ProjectConfig(name="demo"),
26+
intent_monitoring=IntentMonitoringConfig(
27+
enabled=True,
28+
runner="python",
29+
cli_path="/opt/todo2code/dist/src/cli.js",
30+
interval_s=120,
31+
mode="require-llm",
32+
docs_llm=True,
33+
fail_codes=["PLANNED_NOT_IMPLEMENTED"],
34+
),
35+
)
36+
save_config(config, tmp_path / "wup.yaml")
37+
38+
loaded = load_config(tmp_path)
39+
40+
assert loaded.intent_monitoring.enabled is True
41+
assert loaded.intent_monitoring.runner == "python"
42+
assert loaded.intent_monitoring.interval_s == 120
43+
assert loaded.intent_monitoring.mode == "require-llm"
44+
assert loaded.intent_monitoring.docs_llm is True
45+
assert loaded.intent_monitoring.fail_codes == ["PLANNED_NOT_IMPLEMENTED"]
46+
47+
48+
def test_cli_monitor_maps_blocking_diagnostics_to_down(tmp_path: Path, monkeypatch) -> None:
49+
path = _diagnostics(
50+
tmp_path,
51+
[{"severity": "blocking", "code": "PLANNED_NOT_IMPLEMENTED"}],
52+
)
53+
calls = []
54+
55+
def fake_run(command, **kwargs):
56+
calls.append((command, kwargs))
57+
payload = {"diagnosticsPath": str(path), "runDirectory": str(tmp_path / "run")}
58+
return SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="")
59+
60+
monkeypatch.setattr("wup.intent_monitor.subprocess.run", fake_run)
61+
results = []
62+
config = IntentMonitoringConfig(
63+
enabled=True,
64+
command=["node", "/opt/todo2code/cli.js"],
65+
mode="deterministic",
66+
)
67+
monitor = Todo2CodeIntentMonitor(tmp_path, config, results.append)
68+
69+
result = monitor.run_once()
70+
71+
assert result.status == "down"
72+
assert len(result.diagnostics) == 1
73+
assert results == [result]
74+
command, kwargs = calls[0]
75+
assert command[:3] == ["node", "/opt/todo2code/cli.js", "pipeline"]
76+
assert "--no-docs-llm" in command
77+
assert "--no-summary-llm" in command
78+
assert kwargs.get("shell", False) is False
79+
80+
81+
def test_cli_monitor_resolves_single_javascript_launcher(tmp_path: Path, monkeypatch) -> None:
82+
path = _diagnostics(tmp_path, [])
83+
cli_path = tmp_path / "cli.js"
84+
cli_path.write_text("", encoding="utf-8")
85+
observed = {}
86+
87+
monkeypatch.setattr("wup.intent_monitor.shutil.which", lambda command: str(cli_path))
88+
89+
def fake_run(command, **kwargs):
90+
observed["command"] = command
91+
return SimpleNamespace(
92+
returncode=0,
93+
stdout=json.dumps({"diagnosticsPath": str(path)}),
94+
stderr="",
95+
)
96+
97+
monkeypatch.setattr("wup.intent_monitor.subprocess.run", fake_run)
98+
monitor = Todo2CodeIntentMonitor(
99+
tmp_path,
100+
IntentMonitoringConfig(enabled=True, command=["t2c"]),
101+
lambda result: None,
102+
)
103+
104+
result = monitor.run_once()
105+
106+
assert result.status == "up"
107+
assert observed["command"][:2] == ["node", str(cli_path.resolve())]
108+
109+
110+
def test_monitor_filters_codes_and_maps_review_required_to_degraded(tmp_path: Path) -> None:
111+
path = _diagnostics(
112+
tmp_path,
113+
[
114+
{"severity": "review_required", "code": "CHANGELOG_WITHOUT_IMPLEMENTATION"},
115+
{"severity": "blocking", "code": "UNRELATED"},
116+
],
117+
)
118+
config = IntentMonitoringConfig(
119+
fail_codes=["CHANGELOG_WITHOUT_IMPLEMENTATION"]
120+
)
121+
monitor = Todo2CodeIntentMonitor(tmp_path, config, lambda result: None)
122+
123+
result = monitor._result_from_payload({"diagnosticsPath": str(path)})
124+
125+
assert result.status == "degraded"
126+
assert [item["code"] for item in result.diagnostics] == [
127+
"CHANGELOG_WITHOUT_IMPLEMENTATION"
128+
]
129+
130+
131+
def test_monitor_reports_runner_failure_as_down(tmp_path: Path, monkeypatch) -> None:
132+
monkeypatch.setattr(
133+
"wup.intent_monitor.subprocess.run",
134+
lambda *args, **kwargs: SimpleNamespace(
135+
returncode=2, stdout="", stderr="structured output invalid"
136+
),
137+
)
138+
results = []
139+
monitor = Todo2CodeIntentMonitor(
140+
tmp_path, IntentMonitoringConfig(enabled=True), results.append
141+
)
142+
143+
result = monitor.run_once()
144+
145+
assert result.status == "down"
146+
assert "structured output invalid" in result.message
147+
assert results == [result]
148+
149+
150+
def test_python_runner_uses_todo2code_sdk_bridge(tmp_path: Path, monkeypatch) -> None:
151+
path = _diagnostics(tmp_path, [])
152+
observed = {}
153+
154+
class FakeRuntime:
155+
def __init__(self, root, **kwargs):
156+
observed["root"] = root
157+
observed["init"] = kwargs
158+
159+
def invoke(self, arguments):
160+
observed["arguments"] = arguments
161+
payload = {"diagnosticsPath": str(path), "runDirectory": "python-run"}
162+
return SimpleNamespace(stdout=json.dumps(payload))
163+
164+
module = types.ModuleType("todo2code")
165+
module.TypeScriptRuntime = FakeRuntime
166+
monkeypatch.setitem(sys.modules, "todo2code", module)
167+
config = IntentMonitoringConfig(
168+
enabled=True,
169+
runner="python",
170+
cli_path="/opt/todo2code/dist/src/cli.js",
171+
mode="prefer-llm",
172+
docs_llm=True,
173+
)
174+
monitor = Todo2CodeIntentMonitor(tmp_path, config, lambda result: None)
175+
176+
result = monitor.run_once()
177+
178+
assert result.status == "up"
179+
assert observed["root"] == tmp_path.resolve()
180+
assert observed["init"]["cli_path"] == config.cli_path
181+
assert observed["arguments"][:2] == ["pipeline", str(tmp_path.resolve())]
182+
assert observed["arguments"][observed["arguments"].index("--markdown-mode") + 1] == "prefer-llm"
183+
assert "--no-docs-llm" not in observed["arguments"]
184+
assert "--no-communication" in observed["arguments"]
185+
186+
187+
def test_testql_watcher_projects_intent_result_as_project_health() -> None:
188+
watcher = object.__new__(TestQLWatcher)
189+
watcher.config = SimpleNamespace(project=SimpleNamespace(name="workspace"))
190+
transitions = []
191+
watcher._record_health_transition = lambda **values: transitions.append(values)
192+
result = IntentAuditResult(
193+
status="down",
194+
message="todo2code found 3 intent issue(s), 1 blocking",
195+
diagnostics_path="/tmp/diagnostics.json",
196+
)
197+
198+
watcher._record_intent_audit(result)
199+
200+
assert transitions == [
201+
{
202+
"service": "workspace:intent",
203+
"status": "down",
204+
"stage": "intent",
205+
"message": result.message,
206+
"track_file": result.diagnostics_path,
207+
}
208+
]

tests/test_multi_project.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,29 @@ def test_detect_watch_paths_backend_frontend(tmp_path: Path) -> None:
5858
assert "app/**" not in paths
5959

6060

61+
def test_detect_watch_paths_finds_source_dirs_in_project_subfolders(
62+
tmp_path: Path,
63+
) -> None:
64+
(tmp_path / "api" / "src").mkdir(parents=True)
65+
(tmp_path / "worker" / "services").mkdir(parents=True)
66+
67+
assert detect_watch_paths(tmp_path) == ["api/src/**", "worker/services/**"]
68+
69+
70+
def test_detect_watch_paths_combines_root_and_project_subfolders(tmp_path: Path) -> None:
71+
(tmp_path / "src").mkdir()
72+
(tmp_path / "worker" / "src").mkdir(parents=True)
73+
74+
assert detect_watch_paths(tmp_path) == ["src/**", "worker/src/**"]
75+
76+
77+
def test_detect_watch_paths_skips_nested_vendor_and_hidden_dirs(tmp_path: Path) -> None:
78+
(tmp_path / "node_modules" / "package" / "src").mkdir(parents=True)
79+
(tmp_path / ".cache" / "src").mkdir(parents=True)
80+
81+
assert detect_watch_paths(tmp_path) == ["app/**", "src/**", "routes/**"]
82+
83+
6184
def test_default_config_watches_only_real_dirs(tmp_path: Path) -> None:
6285
(tmp_path / "services").mkdir()
6386
cfg = get_default_config(tmp_path)
@@ -122,6 +145,25 @@ def test_prepare_observer_none_when_no_valid_paths(tmp_path: Path) -> None:
122145
assert watcher.prepare_observer() is None
123146

124147

148+
def test_watcher_falls_back_to_source_dirs_in_project_subfolders(
149+
tmp_path: Path,
150+
) -> None:
151+
nested_src = tmp_path / "api" / "src"
152+
nested_services = tmp_path / "worker" / "services"
153+
nested_src.mkdir(parents=True)
154+
nested_services.mkdir(parents=True)
155+
watcher = _watcher(tmp_path, paths=["app/**", "src/**", "routes/**"])
156+
157+
assert watcher.build_watched_paths() == [str(nested_src), str(nested_services)]
158+
159+
160+
def test_watcher_does_not_replace_invalid_custom_paths(tmp_path: Path) -> None:
161+
(tmp_path / "src").mkdir()
162+
watcher = _watcher(tmp_path, paths=["misspelled-source/**"])
163+
164+
assert watcher.build_watched_paths() == []
165+
166+
125167
def test_multi_watcher_returns_false_when_all_invalid(tmp_path: Path) -> None:
126168
w1 = _watcher(tmp_path, paths=["missing-a/**"])
127169
w2 = _watcher(tmp_path, paths=["missing-b/**"])

0 commit comments

Comments
 (0)