memstack-skill-loader 4.9.0__tar.gz → 4.11.0__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 (51) hide show
  1. {memstack_skill_loader-4.9.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.11.0}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/README.md +4 -4
  3. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/pyproject.toml +2 -2
  4. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/__init__.py +1 -1
  5. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/agent_runner.py +308 -4
  6. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/categories.py +1 -0
  7. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/dashboard.html +37 -18
  8. memstack_skill_loader-4.11.0/src/memstack_skill_loader/goal.py +118 -0
  9. memstack_skill_loader-4.11.0/src/memstack_skill_loader/goal_engine.py +326 -0
  10. memstack_skill_loader-4.11.0/src/memstack_skill_loader/hooks/__init__.py +5 -0
  11. memstack_skill_loader-4.11.0/src/memstack_skill_loader/hooks/deny_git.py +21 -0
  12. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/memory_db.py +161 -16
  13. memstack_skill_loader-4.11.0/src/memstack_skill_loader/skill_descriptions.json +522 -0
  14. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  15. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +8 -0
  16. memstack_skill_loader-4.11.0/tests/test_advisor_strategy.py +195 -0
  17. memstack_skill_loader-4.11.0/tests/test_failed_approaches.py +83 -0
  18. memstack_skill_loader-4.11.0/tests/test_search_memory.py +161 -0
  19. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/MANIFEST.in +0 -0
  20. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/setup.cfg +0 -0
  21. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/__main__.py +0 -0
  22. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/compression.py +0 -0
  23. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/config.py +0 -0
  24. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/dashboard.py +0 -0
  25. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/indexer.py +0 -0
  26. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/license.py +0 -0
  27. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
  28. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
  29. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
  30. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
  31. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
  32. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
  33. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/server.py +0 -0
  34. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
  35. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/search.py +0 -0
  36. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/server.py +0 -0
  37. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/skill_config.py +0 -0
  38. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/stats.py +0 -0
  39. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  40. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader/version_check.py +0 -0
  41. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  42. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  43. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  44. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
  45. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/tests/test_body_parser_metric.py +0 -0
  46. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/tests/test_code_read_savings.py +0 -0
  47. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/tests/test_license_environment.py +0 -0
  48. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/tests/test_license_grace.py +0 -0
  49. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/tests/test_pro_compressor.py +0 -0
  50. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/tests/test_pro_skills_update.py +0 -0
  51. {memstack_skill_loader-4.9.0 → memstack_skill_loader-4.11.0}/tests/test_skill_drift.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.9.0
3
+ Version: 4.11.0
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0
@@ -1,6 +1,6 @@
1
1
  # MemStack™ Skill Loader
2
2
 
3
- **129 skills for Claude Code:** 85 free + 44 Pro exclusive. Vector-indexed so CC loads only the skill it needs, saving your context window.
3
+ **130 skills for Claude Code:** 86 free + 44 Pro exclusive. Vector-indexed so CC loads only the skill it needs, saving your context window.
4
4
 
5
5
  ## Quick Start (5 minutes)
6
6
 
@@ -11,7 +11,7 @@ MemStack installs in two parts: the **skills** (via the Claude Code plugin marke
11
11
  /plugin marketplace add cwinvestments/memstack
12
12
  /plugin install memstack@cwinvestments-memstack
13
13
  ```
14
- Run both commands. This unlocks the 85 free skills right away.
14
+ Run both commands. This unlocks the 86 free skills right away.
15
15
 
16
16
  > **SSH error?** ("Host key verification failed" on a fresh server that's never used GitHub over SSH.)
17
17
  > **Default fix · (in terminal)** — rewrite GitHub to HTTPS, then retry Step 1:
@@ -53,7 +53,7 @@ If this doesn't match Step 2's interpreter, the server won't launch (you'll see
53
53
  ```
54
54
  activate_license(key="your-key", email="you@example.com")
55
55
  ```
56
- Use `key="free"` for the free tier, or your Pro key from [memstack.pro](https://memstack.pro) to unlock all 44 Pro skills (85 free + 44 Pro = 129 total). Then type `list skills` to verify.
56
+ Use `key="free"` for the free tier, or your Pro key from [memstack.pro](https://memstack.pro) to unlock all 44 Pro skills (86 free + 44 Pro = 130 total). Then type `list skills` to verify.
57
57
 
58
58
  > To override the skills path, set `MEMSTACK_SKILLS_DIR=/path/to/your/memstack/skills`.
59
59
 
@@ -86,7 +86,7 @@ sudo apt install python3-pip
86
86
 
87
87
  ## How It Works
88
88
 
89
- MCP server that vector-indexes all 129 MemStack™ skills so Claude Code can call `find_skill("deploy to Railway")` and load **only** the relevant skill on demand, instead of all skills consuming context window.
89
+ MCP server that vector-indexes all 130 MemStack™ skills so Claude Code can call `find_skill("deploy to Railway")` and load **only** the relevant skill on demand, instead of all skills consuming context window.
90
90
 
91
91
  - **No Anthropic API key required:** skill search and indexing run locally — the only outbound network call is license validation, which contacts the MemStack license server on session start ([Data and privacy](#data-and-privacy))
92
92
  - **Pro skills auto-detected:** set your license key and they appear automatically
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.9.0"
7
+ version = "4.11.0"
8
8
  description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -23,4 +23,4 @@ memstack-index = "memstack_skill_loader.indexer:main"
23
23
  where = ["src"]
24
24
 
25
25
  [tool.setuptools.package-data]
26
- memstack_skill_loader = ["dashboard.html"]
26
+ memstack_skill_loader = ["dashboard.html", "skill_descriptions.json"]
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.9.0"
3
+ __version__ = "4.11.0"
@@ -156,6 +156,35 @@ SYSTEM_PROMPTS = {
156
156
  "or update a .env.example file with the variable name and a placeholder value.\n"
157
157
  "- Do NOT run git add, git commit, or any git commands. Only make file changes."
158
158
  ),
159
+ "advisor": (
160
+ "You are an Advisor agent. You are called in ONLY when a Builder agent has failed "
161
+ "three consecutive review cycles on the SAME subtask. The approach being used is not "
162
+ "working and will not start working by trying harder — it needs to change.\n\n"
163
+ "You receive:\n"
164
+ "- The original subtask specification.\n"
165
+ "- The full history of Builder attempts and the Reviewer's REVISE feedback for each.\n\n"
166
+ "Your job:\n"
167
+ "1. DIAGNOSE the root cause. Identify the single recurring reason the attempts keep "
168
+ "failing review — look for the pattern across all attempts, not just the last one.\n"
169
+ "2. PRESCRIBE a DIFFERENT fundamental approach. It MUST be materially different from "
170
+ "what has already been tried — a different file, a different mechanism, a different "
171
+ "sequence, or a different technique. Do NOT restate, rephrase, or lightly tweak the "
172
+ "approach that already failed.\n\n"
173
+ "Hard rules:\n"
174
+ "- Do NOT use tools. Reason only from the text provided.\n"
175
+ "- Do NOT repeat the failed approach. If your recommendation resembles what was already "
176
+ "tried, it is wrong — find another angle.\n"
177
+ "- Be concrete and file-specific. The Builder will act on this directly.\n"
178
+ "- Be concise. This output is injected into the Builder's next prompt.\n\n"
179
+ "Output EXACTLY this structure:\n"
180
+ "ROOT CAUSE: <one or two sentences naming why every attempt failed>\n"
181
+ "DIFFERENT APPROACH: <the new approach, explicitly different from what failed>\n"
182
+ "CONCRETE STEPS:\n"
183
+ "- <step 1>\n"
184
+ "- <step 2>\n"
185
+ "- <step 3 (add more only if needed)>\n"
186
+ "AVOID: <the specific thing the Builder must stop doing>"
187
+ ),
159
188
  "reviewer": (
160
189
  "You are a Reviewer agent. You evaluate the Builder's changes against the original task "
161
190
  "specification using a structured 4-perspective review.\n\n"
@@ -1016,6 +1045,119 @@ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
1016
1045
  return output, total_input_tokens, output_tokens
1017
1046
 
1018
1047
 
1048
+ # ---------------------------------------------------------------------------
1049
+ # Advisor consult (direct API — no file tools; fires after 3 REVISE strikes)
1050
+ # ---------------------------------------------------------------------------
1051
+
1052
+ def _consult_advisor(session: "Session", spec: str, attempts_history: str,
1053
+ log_path: Optional[Path] = None, model: str = "",
1054
+ display_name: str = "Advisor") -> str:
1055
+ """Consult the Advisor after 3 consecutive Reviewer-REVISE strikes on one subtask.
1056
+
1057
+ Runs via the direct Anthropic API (_invoke_api_agent) — no file tools. Returns a
1058
+ formatted intervention block to fold into the next Builder prompt, or "" on any
1059
+ failure so the caller degrades gracefully to normal revision. Advisor token usage
1060
+ is written to advisor.log only; it is intentionally NOT tracked in session.agents.
1061
+ """
1062
+ advisor_input = (
1063
+ f"Subtask specification:\n{spec}\n\n"
1064
+ "The Builder has now failed three consecutive review cycles on this subtask. "
1065
+ "Here is the full history of attempts and the Reviewer's REVISE feedback:\n\n"
1066
+ f"{attempts_history}\n\n"
1067
+ "Diagnose the recurring root cause and prescribe a DIFFERENT fundamental approach."
1068
+ )
1069
+ try:
1070
+ advisor_output, _a_in, _a_out = _invoke_api_agent(
1071
+ "advisor", advisor_input,
1072
+ system_prompt=SYSTEM_PROMPTS["advisor"],
1073
+ log_path=log_path,
1074
+ timeout=session.timeout,
1075
+ model=model,
1076
+ session_id=session.session_id,
1077
+ working_dir=session.working_dir,
1078
+ display_name=display_name,
1079
+ )
1080
+ except Exception as exc:
1081
+ session.add_message("system", "all", f"Advisor consult failed ({exc}); continuing with normal revision")
1082
+ return ""
1083
+ if not (advisor_output or "").strip():
1084
+ return ""
1085
+ # Decision 3: post under the already-registered "system" role, not a new author.
1086
+ session.add_message("system", "builder", advisor_output)
1087
+ return (
1088
+ "=== ADVISOR INTERVENTION (after 3 failed attempts) ===\n"
1089
+ "The previous approach has failed repeatedly. Do NOT repeat it. "
1090
+ "Follow this alternative approach instead:\n"
1091
+ f"{advisor_output}\n"
1092
+ "=== END ADVISOR INTERVENTION ==="
1093
+ )
1094
+
1095
+
1096
+ # ---------------------------------------------------------------------------
1097
+ # Failed-approaches memory (best-effort; never interrupts a run)
1098
+ # ---------------------------------------------------------------------------
1099
+
1100
+ _FA_STORE_SLICE = 1200 # max chars persisted per approach / feedback
1101
+ _FA_SHOW_SLICE = 500 # max chars shown per field in the read-back block
1102
+ _FA_TOPK = 3
1103
+
1104
+
1105
+ def _record_failed_approach(session: "Session", task_text: str,
1106
+ title: Optional[str], spec: Optional[str],
1107
+ builder_output: str, reviewer_output: str,
1108
+ iteration: int) -> None:
1109
+ """Persist one Reviewer-REVISE failure. Best-effort; never raises."""
1110
+ try:
1111
+ from .memory_db import record_failed_approach
1112
+ record_failed_approach(
1113
+ project_dir=session.working_dir,
1114
+ task_text=task_text or "",
1115
+ approach_summary=(builder_output or "")[:_FA_STORE_SLICE],
1116
+ reviewer_feedback=(reviewer_output or "")[:_FA_STORE_SLICE],
1117
+ iteration=iteration,
1118
+ subtask_title=title,
1119
+ subtask_spec=spec,
1120
+ session_id=session.session_id,
1121
+ )
1122
+ except Exception as exc:
1123
+ print(f"[agent-runner] record_failed_approach failed: {exc}", file=sys.stderr)
1124
+
1125
+
1126
+ def _failed_approaches_block(session: "Session", task_text: str,
1127
+ title: Optional[str] = None,
1128
+ spec: Optional[str] = None) -> str:
1129
+ """Return a 'previously failed approaches' block, or '' if none / on error."""
1130
+ try:
1131
+ from .memory_db import query_failed_approaches
1132
+ key = " ".join(p for p in (task_text, title, spec) if p)
1133
+ rows = query_failed_approaches(
1134
+ key, project_dir=session.working_dir, top_k=_FA_TOPK
1135
+ )
1136
+ if not rows:
1137
+ return ""
1138
+ lines = [
1139
+ "=== PREVIOUSLY FAILED APPROACHES ON SIMILAR TASKS ===",
1140
+ "These were tried on similar work and REJECTED by the reviewer. "
1141
+ "Do NOT repeat them; learn from why they failed.",
1142
+ "",
1143
+ ]
1144
+ for i, r in enumerate(rows, 1):
1145
+ t = r.get("subtask_title") or "(whole task)"
1146
+ lines.append(f"[{i}] (attempt {r.get('iteration')}) {t}")
1147
+ lines.append(
1148
+ f" Approach tried: {(r.get('approach_summary') or '')[:_FA_SHOW_SLICE]}"
1149
+ )
1150
+ lines.append(
1151
+ f" Why it failed: {(r.get('reviewer_feedback') or '')[:_FA_SHOW_SLICE]}"
1152
+ )
1153
+ lines.append("")
1154
+ lines.append("=== END PREVIOUSLY FAILED APPROACHES ===")
1155
+ return "\n".join(lines)
1156
+ except Exception as exc:
1157
+ print(f"[agent-runner] failed_approaches lookup failed: {exc}", file=sys.stderr)
1158
+ return ""
1159
+
1160
+
1019
1161
  # ---------------------------------------------------------------------------
1020
1162
  # CC subprocess invocation (Builder — needs file tools)
1021
1163
  # ---------------------------------------------------------------------------
@@ -1026,7 +1168,8 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
1026
1168
  model: str = "", disallowed_tools: Optional[list[str]] = None,
1027
1169
  session: Optional["Session"] = None,
1028
1170
  display_name: str = "",
1029
- execution_mode: str = "subscription") -> tuple[str, int, int, int]:
1171
+ execution_mode: str = "subscription",
1172
+ extra_settings: Optional[str] = None) -> tuple[str, int, int, int]:
1030
1173
  """Run a single claude interactive streaming invocation and return the output."""
1031
1174
  claude_bin = shutil.which("claude")
1032
1175
  if not claude_bin:
@@ -1040,6 +1183,10 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
1040
1183
  if disallowed_tools:
1041
1184
  patterns = ",".join(f"mcp__{srv}__*" for srv in disallowed_tools)
1042
1185
  cmd.extend(["--disallowedTools", patterns])
1186
+ if extra_settings:
1187
+ # Inline --settings JSON (e.g. Builder git-deny PreToolUse hook). Only
1188
+ # passed by Builder call sites; Manager/Reviewer never provide this.
1189
+ cmd.extend(["--settings", extra_settings])
1043
1190
 
1044
1191
  if log_path:
1045
1192
  log_path.parent.mkdir(parents=True, exist_ok=True)
@@ -1385,10 +1532,53 @@ def _parse_subtasks(text: str) -> list[tuple[str, str]]:
1385
1532
  return []
1386
1533
 
1387
1534
 
1535
+ def _build_builder_hook_settings() -> str:
1536
+ """Inline --settings JSON that registers the Builder git-deny PreToolUse hook.
1537
+
1538
+ The hook script ships inside this package (hooks/deny_git.py). Its absolute
1539
+ path MUST use forward slashes: backslashes get stripped when the settings
1540
+ command string is executed on Windows, which mangles the path and breaks
1541
+ the hook (see smoke test)."""
1542
+ hook_script = os.path.join(os.path.dirname(__file__), "hooks", "deny_git.py").replace("\\", "/")
1543
+ return json.dumps({
1544
+ "hooks": {"PreToolUse": [{"matcher": "Bash",
1545
+ "hooks": [{"type": "command", "command": f"python {hook_script}"}]}]}
1546
+ })
1547
+
1548
+
1549
+ def _git_head(working_dir: str) -> Optional[str]:
1550
+ """Return the current HEAD commit SHA, or None if it can't be read."""
1551
+ try:
1552
+ r = subprocess.run(
1553
+ ["git", "rev-parse", "HEAD"],
1554
+ cwd=working_dir, capture_output=True, text=True, timeout=30,
1555
+ )
1556
+ return r.stdout.strip() if r.returncode == 0 else None
1557
+ except Exception:
1558
+ return None
1559
+
1560
+
1561
+ def _git_reset_soft(working_dir: str, ref: str) -> bool:
1562
+ """Best-effort `git reset --soft <ref>` to move HEAD back without touching
1563
+ the working tree. Used to undo a Builder commit so the orchestrator remains
1564
+ the single committer. Returns True on success."""
1565
+ try:
1566
+ r = subprocess.run(
1567
+ ["git", "reset", "--soft", ref],
1568
+ cwd=working_dir, capture_output=True, text=True, timeout=30,
1569
+ )
1570
+ return r.returncode == 0
1571
+ except Exception:
1572
+ return False
1573
+
1574
+
1388
1575
  def _orchestrate(session: Session) -> None:
1389
1576
  """Run the Manager → Builder → Reviewer loop."""
1390
1577
  session._start_monotonic = time.monotonic()
1391
1578
  session_log_dir = STATE_DIR / "sessions" / session.session_id
1579
+ # Builder-only git-deny hook (Layer 1). Built once, passed to Builder
1580
+ # _invoke_agent calls via extra_settings; never to Manager/Reviewer.
1581
+ builder_hook_settings = _build_builder_hook_settings()
1392
1582
 
1393
1583
  try:
1394
1584
  # Load execution modes and custom display names for this run
@@ -1484,6 +1674,9 @@ def _orchestrate(session: Session) -> None:
1484
1674
  session.add_message("system", "all", f"Starting subtask {idx} of {len(subtasks)}: {title}")
1485
1675
  builder_input = spec
1486
1676
  subtask_passed = False
1677
+ fail_streak = 0 # consecutive Reviewer-REVISE strikes on THIS subtask
1678
+ advisor_used = False # cap: at most one Advisor consult per subtask
1679
+ attempts_history: list[str] = []
1487
1680
 
1488
1681
  for iteration in range(1, MAX_ITERATIONS + 1):
1489
1682
  if session.status == "stopped":
@@ -1503,15 +1696,24 @@ def _orchestrate(session: Session) -> None:
1503
1696
  session.agents["builder"]["last_output"] = ""
1504
1697
  session._save_state()
1505
1698
 
1699
+ fa_block = ""
1700
+ if iteration == 1:
1701
+ fa_block = _failed_approaches_block(
1702
+ session, session.task, title, spec
1703
+ )
1506
1704
  builder_prompt = (
1507
1705
  SYSTEM_PROMPTS["builder"].format(working_dir=session.working_dir)
1508
1706
  + "\n\n" + context
1707
+ + (("\n\n" + fa_block) if fa_block else "")
1509
1708
  + f"\n\nOriginal task:\n{session.task}"
1510
1709
  + (f"\n\nAdditional context from user:\n{session.context}" if session.context else "")
1511
1710
  + f"\n\nBuild specification:\n{builder_input}"
1512
1711
  )
1513
1712
  if session.user_name:
1514
1713
  builder_prompt = f"The user's name is {session.user_name}.\n\n" + builder_prompt
1714
+ # Layer 2 backstop: snapshot HEAD before the Builder runs so
1715
+ # we can detect any git activity that slips past the hook.
1716
+ pre_head = _git_head(session.working_dir)
1515
1717
  try:
1516
1718
  if agent_modes["builder"] == "subscription":
1517
1719
  builder_output, b_in, b_out, b_ctx = _invoke_agent(
@@ -1524,6 +1726,7 @@ def _orchestrate(session: Session) -> None:
1524
1726
  session=session,
1525
1727
  display_name=display_names["builder"],
1526
1728
  execution_mode=agent_modes["builder"],
1729
+ extra_settings=builder_hook_settings,
1527
1730
  )
1528
1731
  else:
1529
1732
  builder_output, b_in, b_out = _invoke_api_agent(
@@ -1565,6 +1768,24 @@ def _orchestrate(session: Session) -> None:
1565
1768
  if session.status == "stopped":
1566
1769
  break
1567
1770
 
1771
+ # Layer 2 backstop: if the Builder moved HEAD (commit, reset,
1772
+ # checkout, etc.) it violated the no-git policy. Soft-undo and
1773
+ # fail the subtask rather than trusting its work.
1774
+ post_head = _git_head(session.working_dir)
1775
+ if pre_head and post_head and post_head != pre_head:
1776
+ undone = _git_reset_soft(session.working_dir, pre_head)
1777
+ reason = (
1778
+ f"Builder ran git and moved HEAD (pre={pre_head[:8]} "
1779
+ f"post={post_head[:8]}) - blocked by policy"
1780
+ + ("; HEAD soft-reset to pre-state" if undone else "")
1781
+ )
1782
+ print(f"[agent-runner] POLICY VIOLATION: {reason}", file=sys.stderr)
1783
+ session.add_message("system", "all", f"Subtask {idx} of {len(subtasks)} failed: {title} - {reason}")
1784
+ subtasks_failed += 1
1785
+ subtask_results.append(f"Subtask {idx}: FAILED - {title} ({reason})")
1786
+ session._save_state()
1787
+ break
1788
+
1568
1789
  if not builder_output or len(builder_output.strip()) < 20:
1569
1790
  try:
1570
1791
  diff_result = subprocess.run(
@@ -1671,10 +1892,37 @@ def _orchestrate(session: Session) -> None:
1671
1892
  session.add_message("system", "all", f"Subtask {idx} of {len(subtasks)} approved: {title}")
1672
1893
  break
1673
1894
 
1895
+ # Reviewer requested changes (REVISE) — a retryable strike. Hard-fails
1896
+ # never reach here; they break out of the iteration loop above.
1897
+ fail_streak += 1
1898
+ attempts_history.append(
1899
+ f"--- Attempt {iteration} ---\n"
1900
+ f"Builder did:\n{(builder_output or '')[:1500]}\n"
1901
+ f"Reviewer REVISE feedback:\n{(reviewer_output or '')[:1500]}"
1902
+ )
1903
+ _record_failed_approach(
1904
+ session, session.task, title, spec,
1905
+ builder_output, reviewer_output, iteration,
1906
+ )
1907
+ advisor_block = ""
1908
+ if fail_streak >= 3 and not advisor_used:
1909
+ advisor_used = True
1910
+ session.add_message(
1911
+ "system", "all",
1912
+ f"Subtask {idx} of {len(subtasks)}: Builder stuck after "
1913
+ f"{fail_streak} attempts — consulting Advisor for a new approach",
1914
+ )
1915
+ advisor_block = _consult_advisor(
1916
+ session, spec, "\n\n".join(attempts_history),
1917
+ log_path=session_log_dir / "advisor.log",
1918
+ model=session.models.get("advisor", ""),
1919
+ display_name=display_names.get("advisor", "Advisor"),
1920
+ )
1674
1921
  builder_input = (
1675
1922
  f"The reviewer requested changes:\n{reviewer_output}\n\n"
1676
1923
  f"Original subtask specification:\n{spec}\n\n"
1677
- "Please address the reviewer's feedback."
1924
+ + (advisor_block + "\n\n" if advisor_block else "")
1925
+ + "Please address the reviewer's feedback."
1678
1926
  )
1679
1927
  else:
1680
1928
  if not subtask_passed:
@@ -1756,6 +2004,9 @@ def _orchestrate(session: Session) -> None:
1756
2004
 
1757
2005
  # Step 2-3: Builder/Reviewer loop
1758
2006
  builder_input = manager_output
2007
+ fail_streak = 0 # consecutive Reviewer-REVISE strikes on this task
2008
+ advisor_used = False # cap: at most one Advisor consult per task
2009
+ attempts_history: list[str] = []
1759
2010
 
1760
2011
  for iteration in range(1, MAX_ITERATIONS + 1):
1761
2012
  if session.status == "stopped":
@@ -1775,15 +2026,22 @@ def _orchestrate(session: Session) -> None:
1775
2026
  session.agents["builder"]["last_output"] = ""
1776
2027
  session._save_state()
1777
2028
 
2029
+ fa_block = ""
2030
+ if iteration == 1:
2031
+ fa_block = _failed_approaches_block(session, session.task)
1778
2032
  builder_prompt = (
1779
2033
  SYSTEM_PROMPTS["builder"].format(working_dir=session.working_dir)
1780
2034
  + "\n\n" + context
2035
+ + (("\n\n" + fa_block) if fa_block else "")
1781
2036
  + f"\n\nOriginal task:\n{session.task}"
1782
2037
  + (f"\n\nAdditional context from user:\n{session.context}" if session.context else "")
1783
2038
  + f"\n\nBuild specification:\n{builder_input}"
1784
2039
  )
1785
2040
  if session.user_name:
1786
2041
  builder_prompt = f"The user's name is {session.user_name}.\n\n" + builder_prompt
2042
+ # Layer 2 backstop: snapshot HEAD before the Builder runs so we can
2043
+ # detect any git activity that slips past the hook.
2044
+ pre_head = _git_head(session.working_dir)
1787
2045
  try:
1788
2046
  if agent_modes["builder"] == "subscription":
1789
2047
  builder_output, b_in, b_out, b_ctx = _invoke_agent(
@@ -1796,6 +2054,7 @@ def _orchestrate(session: Session) -> None:
1796
2054
  session=session,
1797
2055
  display_name=display_names["builder"],
1798
2056
  execution_mode=agent_modes["builder"],
2057
+ extra_settings=builder_hook_settings,
1799
2058
  )
1800
2059
  else:
1801
2060
  builder_output, b_in, b_out = _invoke_api_agent(
@@ -1836,6 +2095,25 @@ def _orchestrate(session: Session) -> None:
1836
2095
  session._save_state()
1837
2096
  return
1838
2097
 
2098
+ # Layer 2 backstop: if the Builder moved HEAD (commit, reset,
2099
+ # checkout, etc.) it violated the no-git policy. Soft-undo and fail
2100
+ # the task rather than trusting its work.
2101
+ post_head = _git_head(session.working_dir)
2102
+ if pre_head and post_head and post_head != pre_head:
2103
+ undone = _git_reset_soft(session.working_dir, pre_head)
2104
+ reason = (
2105
+ f"Builder ran git and moved HEAD (pre={pre_head[:8]} "
2106
+ f"post={post_head[:8]}) - blocked by policy"
2107
+ + ("; HEAD soft-reset to pre-state" if undone else "")
2108
+ )
2109
+ print(f"[agent-runner] POLICY VIOLATION: {reason}", file=sys.stderr)
2110
+ session.agents["builder"]["status"] = "crashed"
2111
+ session.status = "error"
2112
+ session.result = reason
2113
+ session.add_message("system", "all", f"Task failed: {reason}")
2114
+ session._save_state()
2115
+ return
2116
+
1839
2117
  if not builder_output or len(builder_output.strip()) < 20:
1840
2118
  try:
1841
2119
  diff_result = subprocess.run(
@@ -1995,11 +2273,37 @@ def _orchestrate(session: Session) -> None:
1995
2273
  session._save_state()
1996
2274
  return
1997
2275
 
1998
- # Changes requested — feed back to builder
2276
+ # Changes requested — feed back to builder. Retryable REVISE strike;
2277
+ # hard-fails return out above and never reach here.
2278
+ fail_streak += 1
2279
+ attempts_history.append(
2280
+ f"--- Attempt {iteration} ---\n"
2281
+ f"Builder did:\n{(builder_output or '')[:1500]}\n"
2282
+ f"Reviewer REVISE feedback:\n{(reviewer_output or '')[:1500]}"
2283
+ )
2284
+ _record_failed_approach(
2285
+ session, session.task, None, None,
2286
+ builder_output, reviewer_output, iteration,
2287
+ )
2288
+ advisor_block = ""
2289
+ if fail_streak >= 3 and not advisor_used:
2290
+ advisor_used = True
2291
+ session.add_message(
2292
+ "system", "all",
2293
+ f"Builder stuck after {fail_streak} attempts — "
2294
+ "consulting Advisor for a new approach",
2295
+ )
2296
+ advisor_block = _consult_advisor(
2297
+ session, manager_output, "\n\n".join(attempts_history),
2298
+ log_path=session_log_dir / "advisor.log",
2299
+ model=session.models.get("advisor", ""),
2300
+ display_name=display_names.get("advisor", "Advisor"),
2301
+ )
1999
2302
  builder_input = (
2000
2303
  f"The reviewer requested changes:\n{reviewer_output}\n\n"
2001
2304
  f"Original manager specification:\n{manager_output}\n\n"
2002
- "Please address the reviewer's feedback."
2305
+ + (advisor_block + "\n\n" if advisor_block else "")
2306
+ + "Please address the reviewer's feedback."
2003
2307
  )
2004
2308
 
2005
2309
  session.status = "completed"
@@ -31,6 +31,7 @@ CATEGORY_MAP = {
31
31
  "performance-audit": "Development", "refactor-planner": "Development", "test-writer": "Development",
32
32
  "changelog-generator": "Development", "mentor": "Development", "webapp-testing": "Development",
33
33
  # Core
34
+ "goal": "Core",
34
35
  "compress": "Core", "diary": "Core", "echo": "Core", "grimoire": "Core",
35
36
  "sight": "Core", "token-optimization": "Core",
36
37
  # Marketing
@@ -3599,7 +3599,7 @@ async function resetAgentUI() {
3599
3599
  // Restore working directory from localStorage (user likely wants same project)
3600
3600
  const cachedWd = localStorage.getItem('memstack-last-workdir');
3601
3601
  const wdInput = document.getElementById('agent-workdir-input');
3602
- if (cachedWd) { wdInput.value = cachedWd; fetchMcpServers(cachedWd); fetchBranches(cachedWd); fetchAgentWorkflow(cachedWd); }
3602
+ if (cachedWd) { wdInput.value = cachedWd; refreshWorkdir(cachedWd); }
3603
3603
  }
3604
3604
 
3605
3605
  async function startAgentTask() {
@@ -3636,12 +3636,16 @@ async function startAgentTask() {
3636
3636
  await fetchBranches(workDir);
3637
3637
  }
3638
3638
  }
3639
+ // Repopulate the branch dropdown from the ACTUAL working dir on every run
3640
+ // start (not only the git-init path above), so the options, pre-selected
3641
+ // branch, and dataset.currentBranch reflect this repo — never a stale
3642
+ // dropdown left over from a previously-loaded directory.
3643
+ if (workDir) await fetchBranches(workDir);
3639
3644
  const branchSel = document.getElementById('agent-branch-select');
3640
3645
  const branchRow = document.getElementById('agent-branch-row');
3641
3646
  const selectedBranch = (branchRow && branchRow.style.display !== 'none' && branchSel && branchSel.value) ? branchSel.value : '';
3642
3647
  if (selectedBranch && workDir) {
3643
- const curOpt = branchSel.querySelector('option[selected]');
3644
- const currentBranch = curOpt ? curOpt.value : '';
3648
+ const currentBranch = branchSel.dataset.currentBranch || '';
3645
3649
  if (selectedBranch !== currentBranch) {
3646
3650
  btn.textContent = 'Checking out branch...';
3647
3651
  const coRes = await fetch('/api/git-checkout', {method:'POST', headers: AUTH_HEADERS, body: JSON.stringify({workdir: workDir, branch: selectedBranch})});
@@ -3720,9 +3724,17 @@ async function loadAgentMonitor() {
3720
3724
  wdInput._mcpBound = true;
3721
3725
  wdInput.addEventListener('blur', () => {
3722
3726
  const wd = wdInput.value.trim();
3723
- fetchMcpServers(wd);
3724
- fetchBranches(wd);
3725
- fetchAgentWorkflow(wd);
3727
+ refreshWorkdir(wd);
3728
+ });
3729
+ // Also refresh when the user types/pastes a new path (debounced), so the
3730
+ // branch dropdown updates without waiting for the field to lose focus.
3731
+ let _wdInputTimer = null;
3732
+ wdInput.addEventListener('input', () => {
3733
+ clearTimeout(_wdInputTimer);
3734
+ _wdInputTimer = setTimeout(() => {
3735
+ const wd = wdInput.value.trim();
3736
+ refreshWorkdir(wd);
3737
+ }, 400);
3726
3738
  });
3727
3739
  }
3728
3740
  fetchAgentStatus();
@@ -3734,11 +3746,6 @@ async function loadAgentMonitor() {
3734
3746
  }
3735
3747
 
3736
3748
  async function loadLastWorkdir() {
3737
- function _initWorkdir(path) {
3738
- fetchMcpServers(path);
3739
- fetchBranches(path);
3740
- fetchAgentWorkflow(path);
3741
- }
3742
3749
  try {
3743
3750
  const input = document.getElementById('agent-workdir-input');
3744
3751
  if (input.value.trim()) return;
@@ -3747,16 +3754,16 @@ async function loadLastWorkdir() {
3747
3754
  if (data.path) {
3748
3755
  input.value = data.path;
3749
3756
  localStorage.setItem('memstack-last-workdir', data.path);
3750
- _initWorkdir(data.path);
3757
+ refreshWorkdir(data.path);
3751
3758
  } else {
3752
3759
  const cached = localStorage.getItem('memstack-last-workdir');
3753
- if (cached) { input.value = cached; _initWorkdir(cached); }
3760
+ if (cached) { input.value = cached; refreshWorkdir(cached); }
3754
3761
  }
3755
3762
  } catch(e) {
3756
3763
  const cached = localStorage.getItem('memstack-last-workdir');
3757
3764
  if (cached) {
3758
3765
  const input = document.getElementById('agent-workdir-input');
3759
- if (!input.value.trim()) { input.value = cached; _initWorkdir(cached); }
3766
+ if (!input.value.trim()) { input.value = cached; refreshWorkdir(cached); }
3760
3767
  }
3761
3768
  }
3762
3769
  }
@@ -3772,6 +3779,12 @@ function updateMcpToolsSummaryLabel() {
3772
3779
  el.textContent = name + ' MCP Tools';
3773
3780
  }
3774
3781
 
3782
+ function refreshWorkdir(path) {
3783
+ fetchMcpServers(path);
3784
+ fetchBranches(path);
3785
+ fetchAgentWorkflow(path);
3786
+ }
3787
+
3775
3788
  async function fetchMcpServers(workdir) {
3776
3789
  const listEl = document.getElementById('mcp-tools-list');
3777
3790
  const emptyEl = document.getElementById('mcp-tools-empty');
@@ -3953,9 +3966,9 @@ async function fetchBranches(workdir) {
3953
3966
  if (!workdir) { row.style.display = 'none'; sel.innerHTML = ''; return; }
3954
3967
  try {
3955
3968
  const res = await fetch('/api/git-branches?workdir=' + encodeURIComponent(workdir), {headers: AUTH_GET});
3956
- if (!res.ok) { row.style.display = 'none'; return; }
3969
+ if (!res.ok) { sel.innerHTML = ''; row.style.display = 'none'; return; }
3957
3970
  const d = await res.json();
3958
- if (!d.branches || !d.branches.length) { row.style.display = 'none'; return; }
3971
+ if (!d.branches || !d.branches.length) { sel.innerHTML = ''; row.style.display = 'none'; return; }
3959
3972
  sel.innerHTML = '';
3960
3973
  for (const b of d.branches) {
3961
3974
  const opt = document.createElement('option');
@@ -3963,8 +3976,14 @@ async function fetchBranches(workdir) {
3963
3976
  if (b === d.current) opt.selected = true;
3964
3977
  sel.appendChild(opt);
3965
3978
  }
3979
+ // Pre-select the repo's actual current branch and record it so the run
3980
+ // path can read the selection reliably (branchSel.value) and compare it
3981
+ // against the true current branch (dataset), not a [selected] attribute
3982
+ // that opt.selected = true never sets.
3983
+ if (d.current) sel.value = d.current;
3984
+ sel.dataset.currentBranch = d.current || '';
3966
3985
  row.style.display = 'block';
3967
- } catch(e) { row.style.display = 'none'; }
3986
+ } catch(e) { sel.innerHTML = ''; row.style.display = 'none'; }
3968
3987
  }
3969
3988
 
3970
3989
  async function fetchAgentWorkflow(workdir) {
@@ -4317,7 +4336,7 @@ function closeDirBrowser() {
4317
4336
  function selectDirBrowser() {
4318
4337
  document.getElementById('agent-workdir-input').value = dirBrowserCurrentPath;
4319
4338
  closeDirBrowser();
4320
- fetchMcpServers(dirBrowserCurrentPath);
4339
+ refreshWorkdir(dirBrowserCurrentPath);
4321
4340
  }
4322
4341
 
4323
4342
  function dirBrowserBack() {