openreflex 0.5.2__tar.gz → 0.5.3__tar.gz
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.
- {openreflex-0.5.2/src/openreflex.egg-info → openreflex-0.5.3}/PKG-INFO +1 -1
- {openreflex-0.5.2 → openreflex-0.5.3}/pyproject.toml +1 -1
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/__init__.py +1 -1
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/decision.py +29 -1
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/engine.py +20 -2
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/entrypoint.py +9 -1
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/mcp_server.py +16 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/reflex_index.py +40 -9
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/tui.py +2 -0
- {openreflex-0.5.2 → openreflex-0.5.3/src/openreflex.egg-info}/PKG-INFO +1 -1
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_engine.py +28 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_mcp_metrics_benchmark.py +5 -4
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_project_map.py +0 -1
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_reflex_index.py +29 -1
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_tui_memory.py +22 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/LICENSE +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/README.md +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/setup.cfg +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/__main__.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/benchmark.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/claude_ui.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/cli.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/control.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/detect.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/execution_state.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/hooks.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/install.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/integrations/opencode.ts +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/learning.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/mcp_contracts.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/metrics.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/models.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/policies/default.toml +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/policy.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/privacy.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/project.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/project_map.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/project_memory.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/routing.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/store.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/updater.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/usage.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/SOURCES.txt +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/dependency_links.txt +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/entry_points.txt +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/requires.txt +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/top_level.txt +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_completion_recap_semantics.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_execution_state.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_hooks.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_install_cli.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_outcome_closure.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_policy_recap.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_primer_background.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_project_memory.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_project_scope.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_quality_hardening.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_release_consistency.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_reliability_lifecycle.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_routing_control.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_stress.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_token_receiver.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_tokens_cli.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_update_cli.py +0 -0
- {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_work_modes_tokens.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "openreflex"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.3"
|
|
8
8
|
description = "Muscle memory for AI coding agents: learns from every task, advises the next one, and flags failure loops live"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import asdict, dataclass, field
|
|
6
6
|
from typing import Iterable
|
|
7
7
|
|
|
8
|
-
from .models import CandidatePath, Experience
|
|
8
|
+
from .models import CandidatePath, Execution, Experience, Task
|
|
9
9
|
from .policy import Policy
|
|
10
10
|
|
|
11
11
|
|
|
@@ -233,3 +233,31 @@ def render_trace(snapshots: list[DecisionSnapshot]) -> str:
|
|
|
233
233
|
detail += f" · {snapshot.event}"
|
|
234
234
|
lines.append(f"{minute:02d}:{second:02d} {label:<9} {detail}")
|
|
235
235
|
return "\n".join(lines)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def render_paths(execution: Execution, task: Task, paths: list[CandidatePath]) -> str:
|
|
239
|
+
best = min(paths, key=lambda p: (p.dominated_by is not None, not p.within_limits, -p.score))
|
|
240
|
+
chosen = next((p for p in paths if p.id == execution.chosen_path_id), None)
|
|
241
|
+
recommended = next((p for p in paths if p.id == execution.recommended_path_id), None)
|
|
242
|
+
lines = ["OPENREFLEX / PATHS", "",
|
|
243
|
+
f"Task {task.task_class} - {task.description}",
|
|
244
|
+
f"Recommended {recommended.strategy if recommended else 'n/a'}"]
|
|
245
|
+
if chosen is not None:
|
|
246
|
+
lines.append(f"Followed {chosen.strategy} "
|
|
247
|
+
f"({'inferred from tool calls' if execution.chosen_inferred else 'explicit'})")
|
|
248
|
+
else:
|
|
249
|
+
lines.append("Followed not yet determined" +
|
|
250
|
+
(" (execution still running)" if execution.ended_at is None
|
|
251
|
+
else " (no strategy could be inferred from tool-call evidence)"))
|
|
252
|
+
lines += ["", "CANDIDATES"]
|
|
253
|
+
for path in paths:
|
|
254
|
+
tags = ", ".join(tag for tag in (("recommended" if path is recommended else None),
|
|
255
|
+
("followed" if path is chosen else None)) if tag)
|
|
256
|
+
note = (f"dominated by {path.dominated_by}" if path.dominated_by else
|
|
257
|
+
"over your limit" if not path.within_limits else
|
|
258
|
+
(f"{path.score:+.2f} vs {best.score:+.2f}" if path is not best else ""))
|
|
259
|
+
suffix = " ".join(part for part in (f"[{tags}]" if tags else "", f"({note})" if note else "") if part)
|
|
260
|
+
lines.append(f" {path.strategy:<14} score {path.score:+.2f} success {path.success_probability:.0%} "
|
|
261
|
+
f"~{path.tool_calls:.0f} calls ~{path.time_seconds / 60:.0f} min risk {path.risk:.2f} "
|
|
262
|
+
f"{suffix}".rstrip())
|
|
263
|
+
return "\n".join(lines)
|
|
@@ -7,7 +7,7 @@ from collections import Counter
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from . import control, detect, learning
|
|
10
|
-
from .decision import DecisionSnapshot, make_snapshot, render_recap, render_trace, render_why
|
|
10
|
+
from .decision import DecisionSnapshot, make_snapshot, render_paths, render_recap, render_trace, render_why
|
|
11
11
|
from .models import CandidatePath, Context, Execution, Experience, Lesson, Outcome, Status, Task, ToolCall, uid
|
|
12
12
|
from .policy import load_policy
|
|
13
13
|
from .privacy import PROGRESS, categorize, error_signature, file_paths, fingerprint, redact
|
|
@@ -293,6 +293,24 @@ class Engine:
|
|
|
293
293
|
def trace(self, execution_id: str | None = None) -> str:
|
|
294
294
|
return render_trace(self.decision_snapshots(execution_id))
|
|
295
295
|
|
|
296
|
+
def candidate_paths(self, execution_id: str | None = None) -> tuple[Execution, Task, list[CandidatePath]] | None:
|
|
297
|
+
execution = self.store.get(execution_id) if execution_id else self.current_execution()
|
|
298
|
+
if execution is None:
|
|
299
|
+
return None
|
|
300
|
+
task = self._task(execution)
|
|
301
|
+
paths = self.store.list("CandidatePath", "task_id", task.id)
|
|
302
|
+
paths.sort(key=lambda p: (p.dominated_by is not None, not p.within_limits, -p.score, p.strategy))
|
|
303
|
+
return execution, task, paths
|
|
304
|
+
|
|
305
|
+
def paths(self, execution_id: str | None = None) -> str:
|
|
306
|
+
found = self.candidate_paths(execution_id)
|
|
307
|
+
if found is None:
|
|
308
|
+
return "OPENREFLEX / PATHS\n\nNo execution recorded yet."
|
|
309
|
+
execution, task, paths = found
|
|
310
|
+
if not paths:
|
|
311
|
+
return "OPENREFLEX / PATHS\n\nNo candidate paths recorded for the current task."
|
|
312
|
+
return render_paths(execution, task, paths)
|
|
313
|
+
|
|
296
314
|
def retrieve(self, description: str, task_class: str | None = None,
|
|
297
315
|
k: int | None = None) -> list[tuple[Experience, float]]:
|
|
298
316
|
query = embed(description)
|
|
@@ -684,7 +702,7 @@ def render_context(task: Task, paths: list[CandidatePath], retrieved: list[tuple
|
|
|
684
702
|
if shown:
|
|
685
703
|
lines.append("Lessons:")
|
|
686
704
|
lines += [f"- {lesson.text}" + (f" (seen {support}x)" if support > 1 else "") for lesson, _, support in shown]
|
|
687
|
-
lines.append("Advisory
|
|
705
|
+
lines.append("Advisory; choose_path records deviations.")
|
|
688
706
|
text = "\n".join(lines)
|
|
689
707
|
max_chars = cfg.integer("context.max_chars")
|
|
690
708
|
return text if len(text) <= max_chars else text[: max_chars - 1] + "..."
|
|
@@ -164,12 +164,18 @@ def _prompt_state(project: Path, output: str, project_context: str | None) -> No
|
|
|
164
164
|
match = re.search(r"Suggested path:\s*([a-z0-9_-]+)", text, re.I)
|
|
165
165
|
if match:
|
|
166
166
|
route = match.group(1)
|
|
167
|
+
alternatives = None
|
|
168
|
+
match = re.search(r"Alternatives:\s*(.+)", text)
|
|
169
|
+
if match:
|
|
170
|
+
alternatives = match.group(1).strip()
|
|
167
171
|
match = re.search(r"\[OpenReflex\]\s+(BUILD|INVESTIGATE|THINK)", text, re.I)
|
|
168
172
|
if match:
|
|
169
173
|
mode = match.group(1).lower()
|
|
170
174
|
task = {"active": True, "mode": mode}
|
|
171
175
|
if route:
|
|
172
176
|
task["route"] = route
|
|
177
|
+
if alternatives:
|
|
178
|
+
task["alternatives"] = alternatives
|
|
173
179
|
# A new prompt is a new task surface. Do not carry the previous task's call count/activity into it.
|
|
174
180
|
phase = "recall" if experiences or project_context else ("investigate" if mode == "investigate" else
|
|
175
181
|
"think" if mode == "think" else "watch")
|
|
@@ -245,9 +251,11 @@ def _stop_state(project: Path, agent: str, session: str, output: str, payload: d
|
|
|
245
251
|
outcome = "execution captured"
|
|
246
252
|
data = _output_dict(output)
|
|
247
253
|
notice = str(data.get("systemMessage") or "") if isinstance(data, dict) else ""
|
|
248
|
-
match = re.search(r"(?:COMPLETE|FAILED|UNVERIFIED|FINISHED)\s*\n\s*([^\s·]+)", notice)
|
|
254
|
+
match = re.search(r"(?:COMPLETE|FAILED|UNVERIFIED|FINISHED)\s*\n\s*([^\s·]+)(?:\s*·\s*([a-z0-9_-]+))?", notice)
|
|
249
255
|
if match:
|
|
250
256
|
outcome = match.group(1)
|
|
257
|
+
if match.group(2):
|
|
258
|
+
task["followed"] = match.group(2)
|
|
251
259
|
reinforce_latest_execution(project, agent, session)
|
|
252
260
|
task["active"] = False
|
|
253
261
|
update_state(project, "remember", outcome=outcome, execution={}, task=task, activity={})
|
|
@@ -179,6 +179,22 @@ def build_server(project: Path) -> FastMCP:
|
|
|
179
179
|
project is approved."""
|
|
180
180
|
return run(lambda e: e.trace())
|
|
181
181
|
|
|
182
|
+
@tool("Get candidate paths", READ)
|
|
183
|
+
def get_candidate_paths() -> str:
|
|
184
|
+
"""Show every candidate path OpenReflex considered for the most recent task side by side.
|
|
185
|
+
Returns: plain text with each strategy's score, success probability, cost/risk numbers, and whether it
|
|
186
|
+
was dominated; which path was recommended; and which one was actually followed - explicit (via
|
|
187
|
+
choose_path), inferred from tool-call evidence, or not yet determined while the execution is still
|
|
188
|
+
running.
|
|
189
|
+
Use when: the user asks what other approaches were considered, or whether the agent followed the
|
|
190
|
+
suggested path.
|
|
191
|
+
Not for: a single recommendation's reasoning (use explain_decision) or the decision timeline (use
|
|
192
|
+
get_execution_trace).
|
|
193
|
+
Side effects: none; read-only.
|
|
194
|
+
Errors: 'No execution recorded yet' before any task was planned; a 'not enabled' message until the
|
|
195
|
+
project is approved."""
|
|
196
|
+
return run(lambda e: e.paths())
|
|
197
|
+
|
|
182
198
|
@tool("Get Reflex Score", READ)
|
|
183
199
|
def get_reflex_score() -> ReflexScoreResult:
|
|
184
200
|
"""Return the latest decision's Reflex Score and machine-readable components.
|
|
@@ -13,6 +13,7 @@ import math
|
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
15
|
import time
|
|
16
|
+
from collections import Counter
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
18
19
|
from .engine import Engine
|
|
@@ -43,6 +44,9 @@ def _terms(text: str) -> set[str]:
|
|
|
43
44
|
stop = {
|
|
44
45
|
"this", "that", "with", "from", "into", "when", "where", "what", "fix", "add", "make", "please",
|
|
45
46
|
"project", "issue", "bug", "code", "change", "update", "implement", "create",
|
|
47
|
+
"how", "why", "who", "which", "any", "all", "some", "without", "within",
|
|
48
|
+
"the", "and", "for", "are", "was", "were", "has", "have", "had", "not", "but",
|
|
49
|
+
"used", "use", "using", "uses", "need", "needs", "file", "files",
|
|
46
50
|
}
|
|
47
51
|
# Split camelCase before lowercasing, then split path/identifier punctuation. This
|
|
48
52
|
# makes `charts/payments/values.yaml`, `validate_expired_token` and
|
|
@@ -57,9 +61,24 @@ def _terms(text: str) -> set[str]:
|
|
|
57
61
|
terms.add(token)
|
|
58
62
|
if len(token) > 4 and token.endswith("s"):
|
|
59
63
|
terms.add(token[:-1])
|
|
64
|
+
stem = _stem(token)
|
|
65
|
+
if stem:
|
|
66
|
+
terms.add(stem)
|
|
60
67
|
return terms
|
|
61
68
|
|
|
62
69
|
|
|
70
|
+
def _stem(token: str) -> str | None:
|
|
71
|
+
"""Strip a gerund "-ing" suffix so e.g. "wrapping" also matches a symbol named `wrap_text`.
|
|
72
|
+
Added as an extra term alongside the original (never replacing it), same as the plural-`s` handling
|
|
73
|
+
above, so a wrong guess only adds a low-value extra term rather than losing a real match."""
|
|
74
|
+
if len(token) > 6 and token.endswith("ing"):
|
|
75
|
+
stem = token[:-3]
|
|
76
|
+
if len(stem) >= 4 and stem[-1] == stem[-2] and stem[-1] not in "aeiou":
|
|
77
|
+
stem = stem[:-1]
|
|
78
|
+
return stem
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
63
82
|
def _evidence_state(item: dict) -> str:
|
|
64
83
|
verified = int(item.get("verified_successes", 0))
|
|
65
84
|
observations = int(item.get("observations", 0))
|
|
@@ -200,11 +219,23 @@ def account_project_context(project: Path, agent: str, session: str, context: st
|
|
|
200
219
|
engine.close()
|
|
201
220
|
|
|
202
221
|
|
|
203
|
-
def
|
|
222
|
+
def _term_idf(indexed: list[dict]) -> dict[str, float]:
|
|
223
|
+
"""Smoothed IDF so a match on a rare term (`shell_completion`) outweighs a match on a
|
|
224
|
+
term shared by dozens of files (`test`, `type`) - otherwise a test file with many
|
|
225
|
+
generically-named symbols can out-score the one source file that actually matters."""
|
|
226
|
+
doc_count = len(indexed) or 1
|
|
227
|
+
frequency: Counter[str] = Counter()
|
|
228
|
+
for item in indexed:
|
|
229
|
+
terms = _terms(str(item.get("path", ""))) | _terms(" ".join(str(s) for s in item.get("symbols", [])))
|
|
230
|
+
frequency.update(terms)
|
|
231
|
+
return {term: math.log((doc_count + 1) / (count + 1)) + 1.0 for term, count in frequency.items()}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _file_score(item: dict, query: set[str], idf: dict[str, float]) -> float:
|
|
204
235
|
path_terms = _terms(str(item.get("path", "")))
|
|
205
236
|
symbol_terms = _terms(" ".join(str(symbol) for symbol in item.get("symbols", [])))
|
|
206
|
-
|
|
207
|
-
|
|
237
|
+
path_weight = sum(idf.get(term, 1.0) for term in query & path_terms)
|
|
238
|
+
symbol_weight = sum(idf.get(term, 1.0) for term in query & symbol_terms)
|
|
208
239
|
empirical = (
|
|
209
240
|
4.0 * int(item.get("verified_successes", 0))
|
|
210
241
|
+ 1.5 * int(item.get("successful_outcomes", 0))
|
|
@@ -213,8 +244,8 @@ def _file_score(item: dict, query: set[str]) -> float:
|
|
|
213
244
|
)
|
|
214
245
|
hotspot = min(3.0, math.log1p(max(0, int(item.get("hotspot", 0)))) * 0.9)
|
|
215
246
|
return (
|
|
216
|
-
|
|
217
|
-
+
|
|
247
|
+
path_weight * 10.0
|
|
248
|
+
+ symbol_weight * 7.0
|
|
218
249
|
+ ROLE_BONUS.get(str(item.get("role")), 0.0)
|
|
219
250
|
+ hotspot
|
|
220
251
|
+ min(empirical, 12.0)
|
|
@@ -250,7 +281,8 @@ def context_for_task(project: Path, description: str, *, max_files: int = 6, max
|
|
|
250
281
|
query = _terms(description)
|
|
251
282
|
indexed = [item for item in snapshot.get("indexed_files", []) if item.get("path")]
|
|
252
283
|
by_path = {str(item["path"]): item for item in indexed}
|
|
253
|
-
|
|
284
|
+
idf = _term_idf(indexed)
|
|
285
|
+
scored = [(_file_score(item, query, idf), item) for item in indexed]
|
|
254
286
|
scored = [(score, item) for score, item in scored if score > 0]
|
|
255
287
|
|
|
256
288
|
base_limit = max(1, max_files - 1)
|
|
@@ -280,8 +312,8 @@ def context_for_task(project: Path, description: str, *, max_files: int = 6, max
|
|
|
280
312
|
if selected:
|
|
281
313
|
lines.append("Likely project locations: " + ", ".join(str(item["path"]) for item in selected) + ".")
|
|
282
314
|
symbol_parts = []
|
|
283
|
-
for item in selected[:
|
|
284
|
-
symbols = [str(symbol) for symbol in item.get("symbols", [])[:
|
|
315
|
+
for item in selected[:2]:
|
|
316
|
+
symbols = [str(symbol) for symbol in item.get("symbols", [])[:2]]
|
|
285
317
|
if symbols:
|
|
286
318
|
symbol_parts.append(f"{item['path']} -> {', '.join(symbols)}")
|
|
287
319
|
if symbol_parts:
|
|
@@ -299,6 +331,5 @@ def context_for_task(project: Path, description: str, *, max_files: int = 6, max
|
|
|
299
331
|
lines.append(
|
|
300
332
|
f"Execution support: selected locations include {supported} explicit verified-success observation(s)."
|
|
301
333
|
)
|
|
302
|
-
lines.append("Evidence: project-map and Git signals are structural priors; execution outcome evidence is counted separately.")
|
|
303
334
|
text = "\n".join(lines)
|
|
304
335
|
return text if len(text) <= 1800 else text[:1797] + "..."
|
|
@@ -222,6 +222,8 @@ def dashboard(project: Path, *, colour: bool | None = None) -> str:
|
|
|
222
222
|
f" phase {phase.upper()}",
|
|
223
223
|
f" mode {str(task.get('mode') or 'build').upper()}",
|
|
224
224
|
f" suggested path {task.get('route') or '-'}",
|
|
225
|
+
f" alternatives {task.get('alternatives') or '-'}",
|
|
226
|
+
f" followed {task.get('followed') or '-'}",
|
|
225
227
|
f" recall {recall.get('experiences', 0)} related",
|
|
226
228
|
f" activity {activity or '-'}",
|
|
227
229
|
f" calls {execution.get('calls', 0)}",
|
|
@@ -149,6 +149,34 @@ def test_choose_path_accepts_custom_strategy(engine, clock):
|
|
|
149
149
|
assert path.uncertainty == 1.0
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
def test_candidate_paths_reports_explicit_choice(engine, clock):
|
|
153
|
+
engine.prompt("claude-code", "s1", "Migrate the settings loader from yaml to toml")
|
|
154
|
+
chosen = engine.choose_path("spike-then-rewrite", ["Prototype toml loader", "Swap callers"])
|
|
155
|
+
|
|
156
|
+
execution, task, paths = engine.candidate_paths()
|
|
157
|
+
assert any(p.id == chosen.id for p in paths)
|
|
158
|
+
text = engine.paths()
|
|
159
|
+
assert "OPENREFLEX / PATHS" in text
|
|
160
|
+
assert "Followed spike-then-rewrite (explicit)" in text
|
|
161
|
+
assert "[followed]" in text
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_candidate_paths_reports_inferred_choice_after_finalize(engine, clock):
|
|
165
|
+
outcome, _ = run_task(engine, clock, "s1", "Fix the login bug where expired tokens are accepted", FIX_SCRIPT)
|
|
166
|
+
execution = engine.store.get(outcome.execution_id)
|
|
167
|
+
assert execution.chosen_inferred
|
|
168
|
+
|
|
169
|
+
text = engine.paths()
|
|
170
|
+
assert "inferred from tool calls" in text
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_candidate_paths_reports_not_yet_determined_while_running(engine, clock):
|
|
174
|
+
engine.prompt("claude-code", "s1", "Fix the login bug where expired tokens are accepted")
|
|
175
|
+
|
|
176
|
+
text = engine.paths()
|
|
177
|
+
assert "Followed not yet determined (execution still running)" in text
|
|
178
|
+
|
|
179
|
+
|
|
152
180
|
def test_compound_commands_are_categorized_by_the_program_they_run():
|
|
153
181
|
from openreflex.privacy import categorize
|
|
154
182
|
assert categorize("Bash", {"command": "Get-Location; rg --files -g '*test*' -g pytest.ini"}) == "search"
|
|
@@ -23,8 +23,8 @@ def test_mcp_tools_require_approval_then_work(project, clock):
|
|
|
23
23
|
server = build_server(project)
|
|
24
24
|
names = {tool.name for tool in asyncio.run(server.list_tools())}
|
|
25
25
|
assert {"get_execution_context", "choose_path", "check_progress", "record_outcome", "search_experience",
|
|
26
|
-
"explain_decision", "get_execution_trace", "
|
|
27
|
-
"approve_project", "forget_experience"} == names
|
|
26
|
+
"explain_decision", "get_execution_trace", "get_candidate_paths", "get_reflex_score", "explain_node",
|
|
27
|
+
"get_project_insights", "approve_project", "forget_experience"} == names
|
|
28
28
|
assert "not enabled" in tool_text(server, "get_execution_context", {"task": "Fix the login redirect loop"})
|
|
29
29
|
assert "Not approved" in tool_text(server, "approve_project", {})
|
|
30
30
|
assert "enabled" in tool_text(server, "approve_project", {"confirm": True})
|
|
@@ -33,6 +33,7 @@ def test_mcp_tools_require_approval_then_work(project, clock):
|
|
|
33
33
|
assert "Suggested path" in context and "Candidate paths" in context and "Pareto-efficient" in context
|
|
34
34
|
assert "OPENREFLEX / WHY" in tool_text(server, "explain_decision", {})
|
|
35
35
|
assert "OPENREFLEX / TRACE" in tool_text(server, "get_execution_trace", {})
|
|
36
|
+
assert "OPENREFLEX / PATHS" in tool_text(server, "get_candidate_paths", {})
|
|
36
37
|
score = json.loads(tool_text(server, "get_reflex_score", {}))
|
|
37
38
|
assert score["available"] is True and 0 <= score["reflex_score"] <= 100
|
|
38
39
|
assert "Recorded chosen path: incremental" in tool_text(server, "choose_path", {"strategy": "incremental"})
|
|
@@ -92,5 +93,5 @@ def test_every_mcp_tool_is_fully_described(project):
|
|
|
92
93
|
for name, schema in tool.inputSchema.get("properties", {}).items():
|
|
93
94
|
assert schema.get("description"), f"{tool.name}.{name} has no description"
|
|
94
95
|
read_only = {t.name for t in tools if t.annotations.readOnlyHint}
|
|
95
|
-
assert read_only == {"check_progress", "explain_decision", "get_execution_trace", "
|
|
96
|
-
"search_experience", "explain_node", "get_project_insights"}
|
|
96
|
+
assert read_only == {"check_progress", "explain_decision", "get_execution_trace", "get_candidate_paths",
|
|
97
|
+
"get_reflex_score", "search_experience", "explain_node", "get_project_insights"}
|
|
@@ -35,7 +35,6 @@ def test_project_map_indexes_source_symbols_and_dependencies_without_persisting_
|
|
|
35
35
|
assert "src/auth/token_validator.py" in context
|
|
36
36
|
assert "validate_expired_token" in context
|
|
37
37
|
assert "pydantic" in context
|
|
38
|
-
assert "structural priors" in context
|
|
39
38
|
|
|
40
39
|
|
|
41
40
|
def test_project_map_indexes_nested_manifests_with_project_relative_provenance(project):
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from openreflex.engine import Engine
|
|
2
|
+
from openreflex.project_map import enrich_snapshot
|
|
2
3
|
from openreflex.project_memory import build_snapshot, load_snapshot
|
|
3
4
|
from openreflex.reflex_index import account_project_context, context_for_task, reinforce_latest_execution
|
|
4
5
|
|
|
@@ -17,6 +18,34 @@ def _auth_memory(project):
|
|
|
17
18
|
return next(item for item in load_snapshot(project)["indexed_files"] if item["path"] == "src/auth.py")
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def test_expanded_stopwords_rank_real_content_match_above_boilerplate_only_overlap(project):
|
|
22
|
+
(project / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8")
|
|
23
|
+
(project / "src").mkdir()
|
|
24
|
+
(project / "src" / "shell_completion.py").write_text("def shell_complete():\n pass\n", encoding="utf-8")
|
|
25
|
+
(project / "src" / "file_reader.py").write_text("def read_file():\n pass\n", encoding="utf-8")
|
|
26
|
+
build_snapshot(project)
|
|
27
|
+
enrich_snapshot(project)
|
|
28
|
+
|
|
29
|
+
context = context_for_task(project, "Without editing any files, investigate how shell completion works")
|
|
30
|
+
|
|
31
|
+
line = next(entry for entry in context.splitlines() if entry.startswith("Likely project locations:"))
|
|
32
|
+
# file_reader.py only overlaps the query via now-stopped words ("file", "how", "any", "without");
|
|
33
|
+
# it must not outrank shell_completion.py, which matches on real content ("shell", "completion").
|
|
34
|
+
assert line.index("shell_completion.py") < line.index("file_reader.py")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_terms_stem_gerund_forms_to_match_base_word(project):
|
|
38
|
+
(project / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8")
|
|
39
|
+
(project / "src").mkdir()
|
|
40
|
+
(project / "src" / "wrap_text.py").write_text("def format():\n pass\n", encoding="utf-8")
|
|
41
|
+
build_snapshot(project)
|
|
42
|
+
enrich_snapshot(project)
|
|
43
|
+
|
|
44
|
+
context = context_for_task(project, "Investigate the wrapping behaviour")
|
|
45
|
+
|
|
46
|
+
assert "src/wrap_text.py" in context
|
|
47
|
+
|
|
48
|
+
|
|
20
49
|
def test_verified_execution_reinforces_project_file_without_rewriting_structural_provenance(project, clock):
|
|
21
50
|
_project_with_auth(project)
|
|
22
51
|
|
|
@@ -38,7 +67,6 @@ def test_verified_execution_reinforces_project_file_without_rewriting_structural
|
|
|
38
67
|
context = context_for_task(project, "Fix expired token handling in auth")
|
|
39
68
|
assert "src/auth.py" in context
|
|
40
69
|
assert "explicit verified-success observation" in context
|
|
41
|
-
assert "structural prior" in context
|
|
42
70
|
|
|
43
71
|
|
|
44
72
|
def test_repeated_verified_success_promotes_file_to_reinforced(project, clock):
|
|
@@ -81,6 +81,26 @@ def test_new_prompt_resets_previous_task_activity_and_calls(project):
|
|
|
81
81
|
assert state["task"] == {"active": True, "mode": "build"}
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def test_prompt_state_captures_suggested_path_and_alternatives(project):
|
|
85
|
+
output = json.dumps({
|
|
86
|
+
"hookSpecificOutput": {
|
|
87
|
+
"hookEventName": "UserPromptSubmit",
|
|
88
|
+
"additionalContext": "[OpenReflex] BUILD · debug | 1 similar past task(s) here, 1 succeeded.\n"
|
|
89
|
+
"Suggested path: test-first - Reproduce -> Fix -> Verify "
|
|
90
|
+
"(success~85%, ~18 tool calls, default prior).\n"
|
|
91
|
+
"Alternatives: incremental (+0.29 vs +0.30), inspect-first (dominated by test-first)\n",
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
_prompt_state(project, output, None)
|
|
96
|
+
state = read_state(project)
|
|
97
|
+
|
|
98
|
+
assert state["task"]["route"] == "test-first"
|
|
99
|
+
assert state["task"]["alternatives"] == "incremental (+0.29 vs +0.30), inspect-first (dominated by test-first)"
|
|
100
|
+
assert "test-first" in dashboard(project, colour=False)
|
|
101
|
+
assert "incremental (+0.29 vs +0.30)" in dashboard(project, colour=False)
|
|
102
|
+
|
|
103
|
+
|
|
84
104
|
def test_verification_continuation_stays_in_verify_instead_of_remember(project):
|
|
85
105
|
output = json.dumps({
|
|
86
106
|
"hookSpecificOutput": {
|
|
@@ -105,6 +125,8 @@ def test_final_stop_marks_task_inactive(project):
|
|
|
105
125
|
assert state["phase"] == "remember"
|
|
106
126
|
assert state["task"]["active"] is False
|
|
107
127
|
assert state["outcome"] == "success"
|
|
128
|
+
assert state["task"]["followed"] == "inspect-first"
|
|
129
|
+
assert "inspect-first" in dashboard(project, colour=False)
|
|
108
130
|
|
|
109
131
|
|
|
110
132
|
def test_hook_runtime_records_plugin_manifest_version_without_cache_path(project, tmp_path):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|