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.
Files changed (65) hide show
  1. {openreflex-0.5.2/src/openreflex.egg-info → openreflex-0.5.3}/PKG-INFO +1 -1
  2. {openreflex-0.5.2 → openreflex-0.5.3}/pyproject.toml +1 -1
  3. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/__init__.py +1 -1
  4. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/decision.py +29 -1
  5. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/engine.py +20 -2
  6. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/entrypoint.py +9 -1
  7. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/mcp_server.py +16 -0
  8. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/reflex_index.py +40 -9
  9. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/tui.py +2 -0
  10. {openreflex-0.5.2 → openreflex-0.5.3/src/openreflex.egg-info}/PKG-INFO +1 -1
  11. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_engine.py +28 -0
  12. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_mcp_metrics_benchmark.py +5 -4
  13. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_project_map.py +0 -1
  14. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_reflex_index.py +29 -1
  15. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_tui_memory.py +22 -0
  16. {openreflex-0.5.2 → openreflex-0.5.3}/LICENSE +0 -0
  17. {openreflex-0.5.2 → openreflex-0.5.3}/README.md +0 -0
  18. {openreflex-0.5.2 → openreflex-0.5.3}/setup.cfg +0 -0
  19. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/__main__.py +0 -0
  20. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/benchmark.py +0 -0
  21. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/claude_ui.py +0 -0
  22. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/cli.py +0 -0
  23. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/control.py +0 -0
  24. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/detect.py +0 -0
  25. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/execution_state.py +0 -0
  26. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/hooks.py +0 -0
  27. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/install.py +0 -0
  28. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/integrations/opencode.ts +0 -0
  29. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/learning.py +0 -0
  30. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/mcp_contracts.py +0 -0
  31. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/metrics.py +0 -0
  32. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/models.py +0 -0
  33. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/policies/default.toml +0 -0
  34. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/policy.py +0 -0
  35. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/privacy.py +0 -0
  36. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/project.py +0 -0
  37. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/project_map.py +0 -0
  38. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/project_memory.py +0 -0
  39. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/routing.py +0 -0
  40. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/store.py +0 -0
  41. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/updater.py +0 -0
  42. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex/usage.py +0 -0
  43. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/SOURCES.txt +0 -0
  44. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/dependency_links.txt +0 -0
  45. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/entry_points.txt +0 -0
  46. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/requires.txt +0 -0
  47. {openreflex-0.5.2 → openreflex-0.5.3}/src/openreflex.egg-info/top_level.txt +0 -0
  48. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_completion_recap_semantics.py +0 -0
  49. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_execution_state.py +0 -0
  50. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_hooks.py +0 -0
  51. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_install_cli.py +0 -0
  52. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_outcome_closure.py +0 -0
  53. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_policy_recap.py +0 -0
  54. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_primer_background.py +0 -0
  55. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_project_memory.py +0 -0
  56. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_project_scope.py +0 -0
  57. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_quality_hardening.py +0 -0
  58. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_release_consistency.py +0 -0
  59. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_reliability_lifecycle.py +0 -0
  60. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_routing_control.py +0 -0
  61. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_stress.py +0 -0
  62. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_token_receiver.py +0 -0
  63. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_tokens_cli.py +0 -0
  64. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_update_cli.py +0 -0
  65. {openreflex-0.5.2 → openreflex-0.5.3}/tests/test_work_modes_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreflex
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Muscle memory for AI coding agents: learns from every task, advises the next one, and flags failure loops live
5
5
  Author: vishnu-77
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "openreflex"
7
- version = "0.5.2"
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"
@@ -1,3 +1,3 @@
1
1
  """Ambient, local execution intelligence."""
2
2
 
3
- __version__ = "0.5.2"
3
+ __version__ = "0.5.3"
@@ -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 only. If you follow a different approach, the choose_path tool records it.")
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 _file_score(item: dict, query: set[str]) -> float:
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
- path_overlap = len(query & path_terms)
207
- symbol_overlap = len(query & symbol_terms)
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
- path_overlap * 10.0
217
- + symbol_overlap * 7.0
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
- scored = [(_file_score(item, query), item) for item in indexed]
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[:4]:
284
- symbols = [str(symbol) for symbol in item.get("symbols", [])[:3]]
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)}",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openreflex
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Muscle memory for AI coding agents: learns from every task, advises the next one, and flags failure loops live
5
5
  Author: vishnu-77
6
6
  License-Expression: MIT
@@ -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", "get_reflex_score", "explain_node", "get_project_insights",
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", "get_reflex_score",
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