memstack-skill-loader 4.8.0__tar.gz → 4.10.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 (47) hide show
  1. {memstack_skill_loader-4.8.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.10.0}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/README.md +4 -4
  3. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/pyproject.toml +1 -1
  4. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/__init__.py +1 -1
  5. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/agent_runner.py +94 -1
  6. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/categories.py +1 -0
  7. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/dashboard.html +127 -0
  8. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/dashboard.py +50 -0
  9. memstack_skill_loader-4.10.0/src/memstack_skill_loader/goal.py +118 -0
  10. memstack_skill_loader-4.10.0/src/memstack_skill_loader/goal_engine.py +326 -0
  11. memstack_skill_loader-4.10.0/src/memstack_skill_loader/hooks/__init__.py +5 -0
  12. memstack_skill_loader-4.10.0/src/memstack_skill_loader/hooks/deny_git.py +21 -0
  13. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  14. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +4 -0
  15. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/MANIFEST.in +0 -0
  16. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/setup.cfg +0 -0
  17. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/__main__.py +0 -0
  18. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/compression.py +0 -0
  19. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/config.py +0 -0
  20. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/indexer.py +0 -0
  21. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/license.py +0 -0
  22. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/memory_db.py +0 -0
  23. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
  24. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
  25. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
  26. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
  27. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
  28. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
  29. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/proxy/server.py +0 -0
  30. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
  31. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/search.py +0 -0
  32. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/server.py +0 -0
  33. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/skill_config.py +0 -0
  34. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/stats.py +0 -0
  35. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  36. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader/version_check.py +0 -0
  37. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  38. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  39. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  40. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
  41. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/tests/test_body_parser_metric.py +0 -0
  42. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/tests/test_code_read_savings.py +0 -0
  43. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/tests/test_license_environment.py +0 -0
  44. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/tests/test_license_grace.py +0 -0
  45. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/tests/test_pro_compressor.py +0 -0
  46. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.0}/tests/test_pro_skills_update.py +0 -0
  47. {memstack_skill_loader-4.8.0 → memstack_skill_loader-4.10.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.8.0
3
+ Version: 4.10.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.8.0"
7
+ version = "4.10.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 = [
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.8.0"
3
+ __version__ = "4.10.0"
@@ -1026,7 +1026,8 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
1026
1026
  model: str = "", disallowed_tools: Optional[list[str]] = None,
1027
1027
  session: Optional["Session"] = None,
1028
1028
  display_name: str = "",
1029
- execution_mode: str = "subscription") -> tuple[str, int, int, int]:
1029
+ execution_mode: str = "subscription",
1030
+ extra_settings: Optional[str] = None) -> tuple[str, int, int, int]:
1030
1031
  """Run a single claude interactive streaming invocation and return the output."""
1031
1032
  claude_bin = shutil.which("claude")
1032
1033
  if not claude_bin:
@@ -1040,6 +1041,10 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
1040
1041
  if disallowed_tools:
1041
1042
  patterns = ",".join(f"mcp__{srv}__*" for srv in disallowed_tools)
1042
1043
  cmd.extend(["--disallowedTools", patterns])
1044
+ if extra_settings:
1045
+ # Inline --settings JSON (e.g. Builder git-deny PreToolUse hook). Only
1046
+ # passed by Builder call sites; Manager/Reviewer never provide this.
1047
+ cmd.extend(["--settings", extra_settings])
1043
1048
 
1044
1049
  if log_path:
1045
1050
  log_path.parent.mkdir(parents=True, exist_ok=True)
@@ -1385,10 +1390,53 @@ def _parse_subtasks(text: str) -> list[tuple[str, str]]:
1385
1390
  return []
1386
1391
 
1387
1392
 
1393
+ def _build_builder_hook_settings() -> str:
1394
+ """Inline --settings JSON that registers the Builder git-deny PreToolUse hook.
1395
+
1396
+ The hook script ships inside this package (hooks/deny_git.py). Its absolute
1397
+ path MUST use forward slashes: backslashes get stripped when the settings
1398
+ command string is executed on Windows, which mangles the path and breaks
1399
+ the hook (see smoke test)."""
1400
+ hook_script = os.path.join(os.path.dirname(__file__), "hooks", "deny_git.py").replace("\\", "/")
1401
+ return json.dumps({
1402
+ "hooks": {"PreToolUse": [{"matcher": "Bash",
1403
+ "hooks": [{"type": "command", "command": f"python {hook_script}"}]}]}
1404
+ })
1405
+
1406
+
1407
+ def _git_head(working_dir: str) -> Optional[str]:
1408
+ """Return the current HEAD commit SHA, or None if it can't be read."""
1409
+ try:
1410
+ r = subprocess.run(
1411
+ ["git", "rev-parse", "HEAD"],
1412
+ cwd=working_dir, capture_output=True, text=True, timeout=30,
1413
+ )
1414
+ return r.stdout.strip() if r.returncode == 0 else None
1415
+ except Exception:
1416
+ return None
1417
+
1418
+
1419
+ def _git_reset_soft(working_dir: str, ref: str) -> bool:
1420
+ """Best-effort `git reset --soft <ref>` to move HEAD back without touching
1421
+ the working tree. Used to undo a Builder commit so the orchestrator remains
1422
+ the single committer. Returns True on success."""
1423
+ try:
1424
+ r = subprocess.run(
1425
+ ["git", "reset", "--soft", ref],
1426
+ cwd=working_dir, capture_output=True, text=True, timeout=30,
1427
+ )
1428
+ return r.returncode == 0
1429
+ except Exception:
1430
+ return False
1431
+
1432
+
1388
1433
  def _orchestrate(session: Session) -> None:
1389
1434
  """Run the Manager → Builder → Reviewer loop."""
1390
1435
  session._start_monotonic = time.monotonic()
1391
1436
  session_log_dir = STATE_DIR / "sessions" / session.session_id
1437
+ # Builder-only git-deny hook (Layer 1). Built once, passed to Builder
1438
+ # _invoke_agent calls via extra_settings; never to Manager/Reviewer.
1439
+ builder_hook_settings = _build_builder_hook_settings()
1392
1440
 
1393
1441
  try:
1394
1442
  # Load execution modes and custom display names for this run
@@ -1512,6 +1560,9 @@ def _orchestrate(session: Session) -> None:
1512
1560
  )
1513
1561
  if session.user_name:
1514
1562
  builder_prompt = f"The user's name is {session.user_name}.\n\n" + builder_prompt
1563
+ # Layer 2 backstop: snapshot HEAD before the Builder runs so
1564
+ # we can detect any git activity that slips past the hook.
1565
+ pre_head = _git_head(session.working_dir)
1515
1566
  try:
1516
1567
  if agent_modes["builder"] == "subscription":
1517
1568
  builder_output, b_in, b_out, b_ctx = _invoke_agent(
@@ -1524,6 +1575,7 @@ def _orchestrate(session: Session) -> None:
1524
1575
  session=session,
1525
1576
  display_name=display_names["builder"],
1526
1577
  execution_mode=agent_modes["builder"],
1578
+ extra_settings=builder_hook_settings,
1527
1579
  )
1528
1580
  else:
1529
1581
  builder_output, b_in, b_out = _invoke_api_agent(
@@ -1565,6 +1617,24 @@ def _orchestrate(session: Session) -> None:
1565
1617
  if session.status == "stopped":
1566
1618
  break
1567
1619
 
1620
+ # Layer 2 backstop: if the Builder moved HEAD (commit, reset,
1621
+ # checkout, etc.) it violated the no-git policy. Soft-undo and
1622
+ # fail the subtask rather than trusting its work.
1623
+ post_head = _git_head(session.working_dir)
1624
+ if pre_head and post_head and post_head != pre_head:
1625
+ undone = _git_reset_soft(session.working_dir, pre_head)
1626
+ reason = (
1627
+ f"Builder ran git and moved HEAD (pre={pre_head[:8]} "
1628
+ f"post={post_head[:8]}) - blocked by policy"
1629
+ + ("; HEAD soft-reset to pre-state" if undone else "")
1630
+ )
1631
+ print(f"[agent-runner] POLICY VIOLATION: {reason}", file=sys.stderr)
1632
+ session.add_message("system", "all", f"Subtask {idx} of {len(subtasks)} failed: {title} - {reason}")
1633
+ subtasks_failed += 1
1634
+ subtask_results.append(f"Subtask {idx}: FAILED - {title} ({reason})")
1635
+ session._save_state()
1636
+ break
1637
+
1568
1638
  if not builder_output or len(builder_output.strip()) < 20:
1569
1639
  try:
1570
1640
  diff_result = subprocess.run(
@@ -1784,6 +1854,9 @@ def _orchestrate(session: Session) -> None:
1784
1854
  )
1785
1855
  if session.user_name:
1786
1856
  builder_prompt = f"The user's name is {session.user_name}.\n\n" + builder_prompt
1857
+ # Layer 2 backstop: snapshot HEAD before the Builder runs so we can
1858
+ # detect any git activity that slips past the hook.
1859
+ pre_head = _git_head(session.working_dir)
1787
1860
  try:
1788
1861
  if agent_modes["builder"] == "subscription":
1789
1862
  builder_output, b_in, b_out, b_ctx = _invoke_agent(
@@ -1796,6 +1869,7 @@ def _orchestrate(session: Session) -> None:
1796
1869
  session=session,
1797
1870
  display_name=display_names["builder"],
1798
1871
  execution_mode=agent_modes["builder"],
1872
+ extra_settings=builder_hook_settings,
1799
1873
  )
1800
1874
  else:
1801
1875
  builder_output, b_in, b_out = _invoke_api_agent(
@@ -1836,6 +1910,25 @@ def _orchestrate(session: Session) -> None:
1836
1910
  session._save_state()
1837
1911
  return
1838
1912
 
1913
+ # Layer 2 backstop: if the Builder moved HEAD (commit, reset,
1914
+ # checkout, etc.) it violated the no-git policy. Soft-undo and fail
1915
+ # the task rather than trusting its work.
1916
+ post_head = _git_head(session.working_dir)
1917
+ if pre_head and post_head and post_head != pre_head:
1918
+ undone = _git_reset_soft(session.working_dir, pre_head)
1919
+ reason = (
1920
+ f"Builder ran git and moved HEAD (pre={pre_head[:8]} "
1921
+ f"post={post_head[:8]}) - blocked by policy"
1922
+ + ("; HEAD soft-reset to pre-state" if undone else "")
1923
+ )
1924
+ print(f"[agent-runner] POLICY VIOLATION: {reason}", file=sys.stderr)
1925
+ session.agents["builder"]["status"] = "crashed"
1926
+ session.status = "error"
1927
+ session.result = reason
1928
+ session.add_message("system", "all", f"Task failed: {reason}")
1929
+ session._save_state()
1930
+ return
1931
+
1839
1932
  if not builder_output or len(builder_output.strip()) < 20:
1840
1933
  try:
1841
1934
  diff_result = subprocess.run(
@@ -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
@@ -1319,6 +1319,10 @@
1319
1319
  <svg viewBox="0 0 24 24"><path d="M21 6h-2v9H6v2c0 .55.45 1 1 1h11l4 4V7c0-.55-.45-1-1-1zm-4 6V3c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v14l4-4h10c.55 0 1-.45 1-1z"/></svg>
1320
1320
  Agent Monitor
1321
1321
  </a>
1322
+ <a class="nav-item" data-page="referrals">
1323
+ <svg viewBox="0 0 24 24"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z"/></svg>
1324
+ Referrals
1325
+ </a>
1322
1326
  <a class="nav-item" data-page="settings">
1323
1327
  <svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
1324
1328
  Settings
@@ -1894,6 +1898,16 @@
1894
1898
  </div>
1895
1899
 
1896
1900
  <!-- ═══════ Settings (Placeholder) ═══════ -->
1901
+ <div id="page-referrals" class="page">
1902
+ <div class="page-header">
1903
+ <h2>Referral Program</h2>
1904
+ <p class="meta">Stats update when this page loads &middot; <a href="#" id="referrals-refresh" onclick="loadReferrals();return false;">Refresh</a></p>
1905
+ </div>
1906
+ <div id="referrals-content">
1907
+ <span class="empty-state">Loading&hellip;</span>
1908
+ </div>
1909
+ </div>
1910
+
1897
1911
  <div id="page-settings" class="page">
1898
1912
  <div class="page-header">
1899
1913
  <h2>Settings</h2>
@@ -2222,6 +2236,118 @@ overlay.addEventListener('click', () => {
2222
2236
  overlay.classList.remove('open');
2223
2237
  });
2224
2238
 
2239
+ /* ════════════════════════════════════════════
2240
+ Referrals
2241
+ ════════════════════════════════════════════ */
2242
+ async function loadReferrals() {
2243
+ const el = document.getElementById('referrals-content');
2244
+ if (!el) return;
2245
+ el.innerHTML = '<span class="empty-state">Loading&hellip;</span>';
2246
+ let data;
2247
+ try {
2248
+ const res = await fetch('/api/referrals', { headers: AUTH_GET });
2249
+ data = await res.json();
2250
+ } catch (e) {
2251
+ data = { state: 'error' };
2252
+ }
2253
+ el.innerHTML = renderReferrals(data);
2254
+ }
2255
+
2256
+ function referralNotice(msg, linkText, linkHref) {
2257
+ let h = '<div class="panel" style="text-align:center;padding:2rem 1rem">';
2258
+ h += '<p style="color:#c9d1d9;margin-bottom:' + (linkText ? '1rem' : '0') + '">' + escapeHtml(msg) + '</p>';
2259
+ if (linkText && linkHref) {
2260
+ h += '<a href="' + linkHref + '" target="_blank" rel="noopener" class="copy-git-output-btn" style="text-decoration:none;display:inline-block">' + escapeHtml(linkText) + '</a>';
2261
+ }
2262
+ h += '</div>';
2263
+ return h;
2264
+ }
2265
+
2266
+ function referralBadge(status) {
2267
+ const map = {
2268
+ pending: ['#8b949e', '#161b22'],
2269
+ signed_up: ['#58a6ff', 'rgba(88,166,255,0.1)'],
2270
+ converted: ['#3fb950', 'rgba(63,185,80,0.1)'],
2271
+ paid_out: ['#bc8cff', 'rgba(188,140,255,0.1)'],
2272
+ };
2273
+ const c = map[status] || map.pending;
2274
+ const label = (status || '').replace('_', ' ');
2275
+ return '<span style="display:inline-block;padding:0.1rem 0.5rem;border-radius:10px;font-size:0.75rem;color:' + c[0] + ';background:' + c[1] + ';border:1px solid ' + c[0] + '33">' + escapeHtml(label) + '</span>';
2276
+ }
2277
+
2278
+ function renderReferrals(d) {
2279
+ const state = d && d.state;
2280
+ if (state === 'pro_required') {
2281
+ return referralNotice('Referral stats are available for MemStack Pro users.', 'Get MemStack Pro', 'https://memstack.pro');
2282
+ }
2283
+ if (state === 'not_linked') {
2284
+ return referralNotice('Link your license key to your account at memstack.pro/dashboard to see your referral stats here.', 'Link my license', 'https://memstack.pro/dashboard');
2285
+ }
2286
+ if (state !== 'ok') {
2287
+ return referralNotice("Couldn't reach memstack.pro right now. Stats will load when you're back online.", '', '');
2288
+ }
2289
+ const link = d.referral_link || '';
2290
+ const tier = d.tier || {};
2291
+ const cards = [
2292
+ ['Clicks', fmt(d.clicks)],
2293
+ ['Referrals', fmt(d.total_referrals)],
2294
+ ['Signups', fmt(d.signups)],
2295
+ ['Conversions', fmt(d.conversions)],
2296
+ ['Total Earnings', '$' + Number(d.total_earnings || 0).toFixed(2)],
2297
+ ['Tier', tier.name ? (escapeHtml(tier.name) + ' (' + tier.percent + '%)') : '&mdash;'],
2298
+ ];
2299
+ const colors = ['#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#39d353', '#ff7b72'];
2300
+ let html = '';
2301
+ html += '<div class="panel" style="margin-bottom:1rem">';
2302
+ html += '<h3>Your referral link</h3>';
2303
+ html += '<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">';
2304
+ html += '<input id="referral-link-input" readonly value="' + escapeHtml(link) + '" style="flex:1;min-width:240px;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:0.5rem 0.75rem;color:#c9d1d9;font-family:monospace;font-size:0.85rem">';
2305
+ html += '<button class="copy-git-output-btn" onclick="copyReferralLink()" title="Copy to clipboard">Copy</button>';
2306
+ html += '</div></div>';
2307
+ html += '<div class="stats-grid">';
2308
+ cards.forEach((c, i) => {
2309
+ html += '<div class="card"><div class="value" style="color:' + colors[i] + '">' + c[1] + '</div><div class="label">' + c[0] + '</div></div>';
2310
+ });
2311
+ html += '</div>';
2312
+ const recent = d.recent || [];
2313
+ html += '<div class="panel" style="margin-top:1rem"><h3>Recent Referrals</h3>';
2314
+ if (!recent.length) {
2315
+ html += '<span class="empty-state">No referrals yet. Share your link to get started!</span>';
2316
+ } else {
2317
+ html += '<table style="width:100%;border-collapse:collapse;font-size:0.85rem">';
2318
+ html += '<thead><tr style="text-align:left;color:#8b949e"><th style="padding:0.4rem 0.5rem">Email</th><th style="padding:0.4rem 0.5rem">Status</th><th style="padding:0.4rem 0.5rem">Amount</th><th style="padding:0.4rem 0.5rem">Date</th></tr></thead><tbody>';
2319
+ recent.forEach((r) => {
2320
+ const amt = r.commission_amount != null ? ('$' + Number(r.commission_amount).toFixed(2)) : '&mdash;';
2321
+ const dt = r.created_at ? new Date(r.created_at).toLocaleDateString() : '&mdash;';
2322
+ html += '<tr style="border-top:1px solid #21262d">';
2323
+ html += '<td style="padding:0.4rem 0.5rem;color:#c9d1d9">' + escapeHtml(r.referred_email_masked || '') + '</td>';
2324
+ html += '<td style="padding:0.4rem 0.5rem">' + referralBadge(r.status) + '</td>';
2325
+ html += '<td style="padding:0.4rem 0.5rem;color:#8b949e">' + amt + '</td>';
2326
+ html += '<td style="padding:0.4rem 0.5rem;color:#8b949e">' + dt + '</td>';
2327
+ html += '</tr>';
2328
+ });
2329
+ html += '</tbody></table>';
2330
+ }
2331
+ html += '</div>';
2332
+ return html;
2333
+ }
2334
+
2335
+ async function copyReferralLink() {
2336
+ const el = document.getElementById('referral-link-input');
2337
+ if (!el) return;
2338
+ try {
2339
+ await navigator.clipboard.writeText(el.value);
2340
+ const btn = document.querySelector('#page-referrals .copy-git-output-btn');
2341
+ if (btn) {
2342
+ const orig = btn.textContent;
2343
+ btn.textContent = 'Copied!';
2344
+ btn.style.color = '#3fb950';
2345
+ btn.style.borderColor = '#3fb950';
2346
+ setTimeout(() => { btn.textContent = orig; btn.style.color = ''; btn.style.borderColor = ''; }, 2000);
2347
+ }
2348
+ } catch (e) { /* ignore */ }
2349
+ }
2350
+
2225
2351
  document.querySelectorAll('.nav-item').forEach(item => {
2226
2352
  item.addEventListener('click', () => {
2227
2353
  const page = item.dataset.page;
@@ -2238,6 +2364,7 @@ document.querySelectorAll('.nav-item').forEach(item => {
2238
2364
  if (page === 'agent-monitor') loadAgentMonitor();
2239
2365
  if (page === 'memory-browser') loadMemoryBrowser();
2240
2366
  if (page === 'burn-report') loadBurnReport();
2367
+ if (page === 'referrals') loadReferrals();
2241
2368
  if (page === 'settings') loadSettings();
2242
2369
  if (page !== 'agent-monitor') stopAgentRefresh();
2243
2370
  });
@@ -9,6 +9,7 @@ import shutil
9
9
  import sys
10
10
  import threading
11
11
  import time
12
+ import urllib.error
12
13
  import urllib.request
13
14
  import webbrowser
14
15
  from datetime import datetime, timezone
@@ -543,6 +544,53 @@ def _collect_browser_entries(project: str | None = None) -> list[dict]:
543
544
  return entries
544
545
 
545
546
 
547
+ # Live referral-stats endpoint on the pro-site. Overridable for testing.
548
+ REFERRALS_URL = os.environ.get(
549
+ "MEMSTACK_REFERRALS_URL", "https://memstack.pro/api/referrals/me"
550
+ )
551
+
552
+
553
+ def _get_referral_stats() -> bytes:
554
+ """Proxy the local license key to the pro-site referral-stats endpoint.
555
+
556
+ The license key is read server-side (never exposed to browser JS) and POSTed
557
+ to REFERRALS_URL. Returns a JSON body with a top-level "state" so the page can
558
+ render each outcome distinctly:
559
+
560
+ - "pro_required": no local license key (free user) — NO network call made
561
+ - "ok": stats present (endpoint payload merged in)
562
+ - "not_linked": key isn't linked to a Pro account on the site (404)
563
+ - "error": timeout / network / unexpected — never breaks the dashboard
564
+
565
+ Fail-soft everywhere: any failure resolves to "error", 5s timeout.
566
+ """
567
+ from .license import get_license_key
568
+
569
+ key = get_license_key()
570
+ if not key:
571
+ return json.dumps({"state": "pro_required"}).encode()
572
+
573
+ try:
574
+ payload = json.dumps({"license_key": key}).encode()
575
+ req = urllib.request.Request(
576
+ REFERRALS_URL,
577
+ data=payload,
578
+ headers={"Content-Type": "application/json"},
579
+ method="POST",
580
+ )
581
+ with urllib.request.urlopen(req, timeout=5) as resp:
582
+ data = json.loads(resp.read().decode("utf-8"))
583
+ data["state"] = "ok"
584
+ return json.dumps(data).encode()
585
+ except urllib.error.HTTPError as exc:
586
+ if exc.code == 404:
587
+ return json.dumps({"state": "not_linked"}).encode()
588
+ return json.dumps({"state": "error"}).encode()
589
+ except Exception:
590
+ # Timeout, connection refused, DNS failure, bad JSON — all fail soft.
591
+ return json.dumps({"state": "error"}).encode()
592
+
593
+
546
594
  class _DashboardServer(HTTPServer):
547
595
  allow_reuse_address = False
548
596
 
@@ -868,6 +916,8 @@ class _Handler(BaseHTTPRequestHandler):
868
916
  }
869
917
  body = json.dumps(data).encode()
870
918
  self._respond(200, "application/json", body)
919
+ elif self.path == "/api/referrals":
920
+ self._respond(200, "application/json", _get_referral_stats())
871
921
  elif self.path == "/api/license-status":
872
922
  from .license import LICENSE_FILE, DISK_CACHE_FILE
873
923
  result = {"has_license": False, "key_masked": None, "valid": False, "plan": None, "checked_at": None, "cache_age_days": None}
@@ -0,0 +1,118 @@
1
+ # MemStack Goal CLI - thin `python -m memstack_skill_loader.goal` wrapper over goal_engine
2
+ """Command-line front door to the goal engine (see goal_engine.py).
3
+
4
+ This module reimplements NOTHING: every subcommand delegates to the engine's
5
+ existing functions. It exists so the /goal skill and the Architect stage can
6
+ shell out to a stable interface and, critically, gate on an EXIT CODE:
7
+
8
+ template -> print the blank five-field scaffold (exit 0)
9
+ validate -> parse + validate a drafted goal; print problems.
10
+ exit 0 when the goal is valid (warnings allowed),
11
+ exit 2 when it has errors (missing TASK/OUTCOME/
12
+ VERIFICATION). This exit code is the enforcement point.
13
+ format -> parse + re-emit the clean labeled template (exit 0)
14
+
15
+ Input for validate/format resolves in this order: --text, else --file, else
16
+ STDIN — so a caller can pipe a goal in or pass it as an argument.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import sys
23
+
24
+ from .goal_engine import (
25
+ format_goal,
26
+ goal_is_valid,
27
+ goal_template,
28
+ parse_goal,
29
+ validate_goal,
30
+ )
31
+
32
+ # Exit code returned by `validate` when the goal has hard errors. Callers
33
+ # (skill, Architect) branch on this to refuse an unverifiable goal.
34
+ EXIT_INVALID = 2
35
+
36
+
37
+ def read_input(args: argparse.Namespace) -> str:
38
+ """Resolve the goal text from --text, else --file, else STDIN.
39
+
40
+ Returns the raw text unparsed. An empty result is left to the engine's
41
+ validator to report (it will surface the missing required fields).
42
+ """
43
+ if args.text is not None:
44
+ return args.text
45
+ if args.file is not None:
46
+ with open(args.file, "r", encoding="utf-8") as fh:
47
+ return fh.read()
48
+ return sys.stdin.read()
49
+
50
+
51
+ def _add_input_args(parser: argparse.ArgumentParser) -> None:
52
+ """Attach the shared --text / --file options to a subparser."""
53
+ parser.add_argument(
54
+ "--text", default=None,
55
+ help="Goal text passed directly on the command line.",
56
+ )
57
+ parser.add_argument(
58
+ "--file", default=None,
59
+ help="Path to a file containing the drafted goal.",
60
+ )
61
+
62
+
63
+ def cmd_template(_args: argparse.Namespace) -> int:
64
+ """Print the blank five-field template."""
65
+ print(goal_template())
66
+ return 0
67
+
68
+
69
+ def cmd_validate(args: argparse.Namespace) -> int:
70
+ """Validate a drafted goal; print problems, return 0 (valid) or EXIT_INVALID.
71
+
72
+ Problems are printed one per line in the engine's order (errors first, then
73
+ ``WARNING:`` lines). Warnings do not affect the exit code.
74
+ """
75
+ goal = parse_goal(read_input(args))
76
+ for problem in validate_goal(goal):
77
+ print(problem)
78
+ return 0 if goal_is_valid(goal) else EXIT_INVALID
79
+
80
+
81
+ def cmd_format(args: argparse.Namespace) -> int:
82
+ """Parse a goal and re-emit it via the engine's clean template."""
83
+ goal = parse_goal(read_input(args))
84
+ print(format_goal(goal))
85
+ return 0
86
+
87
+
88
+ def main() -> None:
89
+ """CLI entry point. Dispatches to a subcommand and exits with its code."""
90
+ parser = argparse.ArgumentParser(
91
+ prog="python -m memstack_skill_loader.goal",
92
+ description="Structure and validate a task as a goal with a required, "
93
+ "checkable VERIFICATION criterion.",
94
+ )
95
+ sub = parser.add_subparsers(dest="command", required=True)
96
+
97
+ p_template = sub.add_parser("template", help="Print the blank five-field template.")
98
+ p_template.set_defaults(func=cmd_template)
99
+
100
+ p_validate = sub.add_parser(
101
+ "validate",
102
+ help="Validate a drafted goal (stdin/--file/--text). Exit 2 on errors.",
103
+ )
104
+ _add_input_args(p_validate)
105
+ p_validate.set_defaults(func=cmd_validate)
106
+
107
+ p_format = sub.add_parser(
108
+ "format", help="Re-emit a drafted goal (stdin/--file/--text) as the clean template.",
109
+ )
110
+ _add_input_args(p_format)
111
+ p_format.set_defaults(func=cmd_format)
112
+
113
+ args = parser.parse_args()
114
+ sys.exit(args.func(args))
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,326 @@
1
+ # MemStack Goal Engine - structures a task into five fields with a required, checkable VERIFICATION criterion
2
+ """Goal-structuring engine shared by the /goal skill and the Architect stage.
3
+
4
+ A "goal" is a task decomposed into five fields:
5
+
6
+ TASK what to do
7
+ WHY why it matters / who it's for (optional)
8
+ OUTCOME the exact finished result / definition of done
9
+ CONSTRAINTS what must or must not be done (optional, list)
10
+ VERIFICATION how to prove the OUTCOME is complete (REQUIRED)
11
+
12
+ VERIFICATION is the load-bearing field and the whole reason this module exists.
13
+ The dominant failure mode this engine is built to prevent is a goal that reads
14
+ as "done" without a concrete, checkable way to prove it: an agent (or a person)
15
+ declares success against a vague target and ships something unverified. A goal
16
+ without a specific verification criterion is treated as invalid here — not a
17
+ style nit, but a hard rule enforced by ``validate_goal``. A verification that
18
+ exists but only says "it works" / "tests pass" is flagged as a warning so the
19
+ author is nudged toward a concrete, observable check.
20
+
21
+ Pure standard library. No project imports, no external dependencies.
22
+
23
+ Validation contract (see ``validate_goal``): the returned list mixes two
24
+ severities distinguished by prefix —
25
+ * ERRORS — plain messages (e.g. "TASK is required"). A goal with any error
26
+ is invalid and must not proceed.
27
+ * WARNINGS — messages prefixed with the literal ``"WARNING: "``. Advisory
28
+ only; a goal with warnings but no errors is still valid.
29
+ Use ``goal_has_errors`` / ``goal_is_valid`` to branch without string-matching.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from dataclasses import dataclass, field
36
+
37
+ # The five recognized field labels, in canonical display order. Parsing is
38
+ # case-insensitive and whitespace-tolerant; this ordering drives format output.
39
+ _LABELS = ("TASK", "WHY", "OUTCOME", "CONSTRAINTS", "VERIFICATION")
40
+
41
+ # Matches a label line like "OUTCOME: ...", tolerating leading whitespace and
42
+ # any casing. Group 1 = the (upper-cased) label, group 2 = the inline value.
43
+ _LABEL_RE = re.compile(
44
+ r"^\s*(" + "|".join(_LABELS) + r")\s*:\s*(.*)$",
45
+ re.IGNORECASE,
46
+ )
47
+
48
+ # Marker prepended to advisory messages so callers can separate them from
49
+ # hard errors without re-deriving the rules.
50
+ _WARNING_PREFIX = "WARNING: "
51
+
52
+ # A verification shorter than this (after normalization) is assumed too terse
53
+ # to name a concrete, checkable criterion.
54
+ _VAGUE_MIN_LEN = 15
55
+
56
+ # Vacuous verification phrasings that may exceed the length floor yet still
57
+ # assert nothing observable. Compared against the normalized (lower-cased,
58
+ # punctuation-stripped, whitespace-collapsed) verification text.
59
+ _VAGUE_PHRASES = frozenset({
60
+ "it works",
61
+ "works",
62
+ "it is done",
63
+ "its done",
64
+ "done",
65
+ "finished",
66
+ "complete",
67
+ "completed",
68
+ "tests pass",
69
+ "all tests pass",
70
+ "the tests pass",
71
+ "passes",
72
+ "it passes",
73
+ "ok",
74
+ "looks good",
75
+ "lgtm",
76
+ "works as expected",
77
+ "it works as expected",
78
+ })
79
+
80
+
81
+ @dataclass
82
+ class Goal:
83
+ """A task structured into five fields.
84
+
85
+ ``task``, ``outcome`` and ``verification`` carry the meaning; ``why`` and
86
+ ``constraints`` are optional context. Only ``task`` is a positional
87
+ requirement of the dataclass — the required-ness of ``outcome`` and
88
+ ``verification`` is a *validation* rule (see ``validate_goal``), not a
89
+ construction rule, so partial goals can be built and then reported on.
90
+ """
91
+
92
+ task: str
93
+ why: str = ""
94
+ outcome: str = ""
95
+ constraints: list[str] = field(default_factory=list)
96
+ verification: str = ""
97
+
98
+
99
+ def _normalize(text: str) -> str:
100
+ """Lower-case, strip surrounding punctuation, and collapse inner whitespace.
101
+
102
+ Used to compare a verification string against ``_VAGUE_PHRASES`` so that
103
+ "It works." and " it works " both reduce to "it works".
104
+ """
105
+ lowered = text.strip().lower()
106
+ lowered = re.sub(r"\s+", " ", lowered)
107
+ return lowered.strip(" \t\r\n.!?,;:-*")
108
+
109
+
110
+ def _split_constraints(raw_lines: list[str]) -> list[str]:
111
+ """Turn accumulated CONSTRAINTS text into a clean list.
112
+
113
+ Splits on both newlines and commas, strips common bullet markers
114
+ ("-", "*", "•") and surrounding whitespace, and drops empties.
115
+ """
116
+ items: list[str] = []
117
+ for line in raw_lines:
118
+ for part in re.split(r"[,\n]", line):
119
+ cleaned = part.strip().lstrip("-*• \t").strip()
120
+ if cleaned:
121
+ items.append(cleaned)
122
+ return items
123
+
124
+
125
+ def parse_goal(text: str) -> Goal:
126
+ """Parse labeled ``LABEL: value`` text into a :class:`Goal`.
127
+
128
+ Recognizes the five labels (TASK, WHY, OUTCOME, CONSTRAINTS, VERIFICATION)
129
+ case-insensitively and with tolerant whitespace. A field's value may span
130
+ multiple lines: everything after a label line, up to the next label line,
131
+ belongs to that field. Scalar fields are whitespace-joined; CONSTRAINTS is
132
+ split into a list (see :func:`_split_constraints`).
133
+
134
+ Missing fields become empty (``""`` or ``[]``). Parsing never raises for
135
+ missing required fields — that is reported by :func:`validate_goal`.
136
+ """
137
+ # Accumulate raw text lines per field as we walk the input.
138
+ buckets: dict[str, list[str]] = {label: [] for label in _LABELS}
139
+ current: str | None = None
140
+
141
+ for line in text.splitlines():
142
+ match = _LABEL_RE.match(line)
143
+ if match:
144
+ current = match.group(1).upper()
145
+ inline = match.group(2).strip()
146
+ if inline:
147
+ buckets[current].append(inline)
148
+ elif current is not None:
149
+ # Continuation line for the field currently being read.
150
+ stripped = line.strip()
151
+ if stripped:
152
+ buckets[current].append(stripped)
153
+
154
+ def scalar(label: str) -> str:
155
+ return " ".join(buckets[label]).strip()
156
+
157
+ return Goal(
158
+ task=scalar("TASK"),
159
+ why=scalar("WHY"),
160
+ outcome=scalar("OUTCOME"),
161
+ constraints=_split_constraints(buckets["CONSTRAINTS"]),
162
+ verification=scalar("VERIFICATION"),
163
+ )
164
+
165
+
166
+ def _verification_is_vague(verification: str) -> bool:
167
+ """True when a non-empty verification names no concrete, checkable criterion.
168
+
169
+ Two triggers: it is shorter than ``_VAGUE_MIN_LEN`` after normalization, or
170
+ it matches one of the known vacuous phrasings in ``_VAGUE_PHRASES``.
171
+ """
172
+ normalized = _normalize(verification)
173
+ if not normalized:
174
+ return False # empty is an ERROR case, handled by validate_goal
175
+ if len(normalized) < _VAGUE_MIN_LEN:
176
+ return True
177
+ return normalized in _VAGUE_PHRASES
178
+
179
+
180
+ def validate_goal(goal: Goal) -> list[str]:
181
+ """Return a list of problems with *goal*; an empty list means fully valid.
182
+
183
+ Errors (plain messages — presence of any makes the goal invalid):
184
+ * "TASK is required"
185
+ * "OUTCOME is required (define what done looks like)"
186
+ * "VERIFICATION is required (state how you'll prove it's complete)"
187
+
188
+ Warnings (prefixed with ``"WARNING: "`` — advisory, do not invalidate):
189
+ * a present-but-vacuous VERIFICATION.
190
+
191
+ Errors are listed before warnings. Use :func:`goal_has_errors` to test
192
+ validity without inspecting the prefix yourself.
193
+ """
194
+ errors: list[str] = []
195
+ warnings: list[str] = []
196
+
197
+ if not goal.task.strip():
198
+ errors.append("TASK is required")
199
+ if not goal.outcome.strip():
200
+ errors.append("OUTCOME is required (define what done looks like)")
201
+ if not goal.verification.strip():
202
+ errors.append(
203
+ "VERIFICATION is required (state how you'll prove it's complete)"
204
+ )
205
+ elif _verification_is_vague(goal.verification):
206
+ warnings.append(
207
+ _WARNING_PREFIX
208
+ + "VERIFICATION is vague - state a concrete, checkable criterion"
209
+ )
210
+
211
+ return errors + warnings
212
+
213
+
214
+ def goal_has_errors(problems: list[str]) -> bool:
215
+ """True if *problems* (from :func:`validate_goal`) contains any hard error.
216
+
217
+ A hard error is any message NOT prefixed with ``"WARNING: "``.
218
+ """
219
+ return any(not p.startswith(_WARNING_PREFIX) for p in problems)
220
+
221
+
222
+ def goal_is_valid(goal: Goal) -> bool:
223
+ """Convenience: True when *goal* has no hard errors (warnings are allowed)."""
224
+ return not goal_has_errors(validate_goal(goal))
225
+
226
+
227
+ def format_goal(goal: Goal) -> str:
228
+ """Render *goal* back into the clean labeled template for display/output.
229
+
230
+ Optional empty fields (WHY, CONSTRAINTS) are still emitted as labels so the
231
+ output shape is stable and copy-editable. Constraints render as a bulleted
232
+ list; a single constraint renders inline.
233
+ """
234
+ lines = [f"TASK: {goal.task}".rstrip()]
235
+ lines.append(f"WHY: {goal.why}".rstrip())
236
+ lines.append(f"OUTCOME: {goal.outcome}".rstrip())
237
+
238
+ if not goal.constraints:
239
+ lines.append("CONSTRAINTS:")
240
+ elif len(goal.constraints) == 1:
241
+ lines.append(f"CONSTRAINTS: {goal.constraints[0]}")
242
+ else:
243
+ lines.append("CONSTRAINTS:")
244
+ lines.extend(f" - {item}" for item in goal.constraints)
245
+
246
+ lines.append(f"VERIFICATION: {goal.verification}".rstrip())
247
+ return "\n".join(lines)
248
+
249
+
250
+ def goal_template() -> str:
251
+ """Return the empty five-field template with a one-line hint per label.
252
+
253
+ Handed to a user (or the Architect) starting from scratch, so every field —
254
+ especially VERIFICATION — comes with a prompt for what belongs there.
255
+ """
256
+ return (
257
+ "TASK: (what to do)\n"
258
+ "WHY: (why it matters / who it's for - optional)\n"
259
+ "OUTCOME: (the exact finished result - what 'done' looks like)\n"
260
+ "CONSTRAINTS: (what must or must not be done - optional, one per line)\n"
261
+ "VERIFICATION: (how you'll prove the outcome is complete - "
262
+ "a concrete, checkable criterion)"
263
+ )
264
+
265
+
266
+ if __name__ == "__main__":
267
+ # Small self-test exercising the four cases: valid, missing-verification
268
+ # (error), vague-verification (warning), and template output.
269
+ print("=" * 64)
270
+ print("CASE 1 — complete valid goal")
271
+ print("=" * 64)
272
+ valid = parse_goal(
273
+ """
274
+ TASK: Add a --json flag to the report command
275
+ WHY: CI needs machine-readable output for the dashboard
276
+ OUTCOME: `report --json` prints a valid JSON object to stdout and exits 0
277
+ CONSTRAINTS: no new dependencies
278
+ must not change the default human output
279
+ VERIFICATION: run `report --json | python -m json.tool` and confirm it
280
+ parses and contains the "total" key
281
+ """
282
+ )
283
+ print(format_goal(valid))
284
+ problems = validate_goal(valid)
285
+ print("\nproblems:", problems)
286
+ print("has_errors:", goal_has_errors(problems), "| is_valid:", goal_is_valid(valid))
287
+ print("constraints parsed:", valid.constraints)
288
+ assert problems == [], "expected no problems for a complete goal"
289
+ assert goal_is_valid(valid)
290
+ assert valid.constraints == ["no new dependencies", "must not change the default human output"]
291
+
292
+ print("\n" + "=" * 64)
293
+ print("CASE 2 — missing VERIFICATION (error)")
294
+ print("=" * 64)
295
+ missing = parse_goal(
296
+ "TASK: Refactor the parser\n"
297
+ "OUTCOME: parser split into tokenizer and builder modules"
298
+ )
299
+ problems = validate_goal(missing)
300
+ print("problems:", problems)
301
+ print("has_errors:", goal_has_errors(problems), "| is_valid:", goal_is_valid(missing))
302
+ assert goal_has_errors(problems), "missing verification must be an error"
303
+ assert any("VERIFICATION is required" in p for p in problems)
304
+ assert not goal_is_valid(missing)
305
+
306
+ print("\n" + "=" * 64)
307
+ print("CASE 3 — vague VERIFICATION (warning, still valid)")
308
+ print("=" * 64)
309
+ vague = parse_goal(
310
+ "TASK: Fix the login bug\n"
311
+ "OUTCOME: users can log in again\n"
312
+ "VERIFICATION: it works"
313
+ )
314
+ problems = validate_goal(vague)
315
+ print("problems:", problems)
316
+ print("has_errors:", goal_has_errors(problems), "| is_valid:", goal_is_valid(vague))
317
+ assert not goal_has_errors(problems), "vague verification is a warning, not an error"
318
+ assert any(p.startswith("WARNING: ") for p in problems)
319
+ assert goal_is_valid(vague), "a goal with only warnings is still valid"
320
+
321
+ print("\n" + "=" * 64)
322
+ print("CASE 4 — empty template")
323
+ print("=" * 64)
324
+ print(goal_template())
325
+
326
+ print("\nAll self-test assertions passed.")
@@ -0,0 +1,5 @@
1
+ """Hook scripts injected into agent subprocesses (e.g. Builder git-guard).
2
+
3
+ Kept as a subpackage so the scripts ship inside the installed wheel and can be
4
+ resolved at runtime via os.path.dirname(__file__).
5
+ """
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python
2
+ """PreToolUse hook: denies any Bash git invocation. Injected only into the
3
+ Builder agent subprocess via --settings, so the Builder cannot run git
4
+ (commit, push, reset, checkout, etc.). Fail-closed: on any error, deny."""
5
+ import sys, json, re
6
+
7
+ try:
8
+ data = json.load(sys.stdin)
9
+ cmd = data.get("tool_input", {}).get("command", "")
10
+ except Exception:
11
+ # Fail closed: if we can't parse, deny to be safe.
12
+ print("BLOCKED: Builder git-guard could not parse tool input; denying.", file=sys.stderr)
13
+ sys.exit(2)
14
+
15
+ # Match git as a command token in any form: git, /usr/bin/git, sh -c "git",
16
+ # env VAR=x git, chained "&& git", "; git", "| git", GIT_DIR=... git, etc.
17
+ if re.search(r"(^|[\s;&|/])git(\s|$)", cmd) or re.search(r"\bgit\b", cmd):
18
+ print("BLOCKED: Builder may not run git. Git operations are performed by the orchestrator, not the Builder.", file=sys.stderr)
19
+ sys.exit(2)
20
+
21
+ sys.exit(0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.8.0
3
+ Version: 4.10.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
@@ -9,6 +9,8 @@ src/memstack_skill_loader/compression.py
9
9
  src/memstack_skill_loader/config.py
10
10
  src/memstack_skill_loader/dashboard.html
11
11
  src/memstack_skill_loader/dashboard.py
12
+ src/memstack_skill_loader/goal.py
13
+ src/memstack_skill_loader/goal_engine.py
12
14
  src/memstack_skill_loader/indexer.py
13
15
  src/memstack_skill_loader/license.py
14
16
  src/memstack_skill_loader/memory_db.py
@@ -24,6 +26,8 @@ src/memstack_skill_loader.egg-info/dependency_links.txt
24
26
  src/memstack_skill_loader.egg-info/entry_points.txt
25
27
  src/memstack_skill_loader.egg-info/requires.txt
26
28
  src/memstack_skill_loader.egg-info/top_level.txt
29
+ src/memstack_skill_loader/hooks/__init__.py
30
+ src/memstack_skill_loader/hooks/deny_git.py
27
31
  src/memstack_skill_loader/proxy/__init__.py
28
32
  src/memstack_skill_loader/proxy/_diag.py
29
33
  src/memstack_skill_loader/proxy/body_parser.py