@try-works/dsh-recursive-mode 0.1.5 → 0.1.6

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 (85) hide show
  1. package/lib/bootstrap.d.ts +6 -3
  2. package/lib/client/board.d.ts +4 -1
  3. package/lib/client/open-state.d.ts +20 -0
  4. package/lib/client/slots.d.ts +7 -1
  5. package/lib/client.js +135 -47
  6. package/lib/index.js +3391 -906
  7. package/lib/runtime.d.ts +12 -5
  8. package/lib/ts-lint.d.ts +205 -0
  9. package/package.json +1 -1
  10. package/src/bootstrap.ts +338 -331
  11. package/src/client/board.tsx +0 -0
  12. package/src/client/open-state.ts +46 -0
  13. package/src/client/slots.ts +41 -20
  14. package/src/commands.ts +11 -0
  15. package/src/index.ts +238 -228
  16. package/src/runtime.ts +19 -23
  17. package/src/ts-lint.ts +2110 -0
  18. package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  19. package/references/scripts/lint-recursive-run.ps1 +0 -25
  20. package/references/scripts/lint-recursive-run.py +0 -2870
  21. package/references/scripts/recursive-closeout.ps1 +0 -38
  22. package/references/scripts/recursive-closeout.py +0 -541
  23. package/references/scripts/recursive-init.ps1 +0 -515
  24. package/references/scripts/recursive-init.py +0 -356
  25. package/references/scripts/recursive-lock.ps1 +0 -26
  26. package/references/scripts/recursive-lock.py +0 -302
  27. package/references/scripts/recursive-review-bundle.ps1 +0 -58
  28. package/references/scripts/recursive-review-bundle.py +0 -503
  29. package/references/scripts/recursive-router-cli-configure.ps1 +0 -2
  30. package/references/scripts/recursive-router-cli-configure.py +0 -9
  31. package/references/scripts/recursive-router-cli-init.ps1 +0 -2
  32. package/references/scripts/recursive-router-cli-init.py +0 -9
  33. package/references/scripts/recursive-router-cli-invoke.ps1 +0 -2
  34. package/references/scripts/recursive-router-cli-invoke.py +0 -9
  35. package/references/scripts/recursive-router-cli-probe.ps1 +0 -2
  36. package/references/scripts/recursive-router-cli-probe.py +0 -9
  37. package/references/scripts/recursive-router-cli-resolve.ps1 +0 -2
  38. package/references/scripts/recursive-router-cli-resolve.py +0 -9
  39. package/references/scripts/recursive-router-cli-validate.ps1 +0 -2
  40. package/references/scripts/recursive-router-cli-validate.py +0 -9
  41. package/references/scripts/recursive-router-configure.ps1 +0 -27
  42. package/references/scripts/recursive-router-configure.py +0 -74
  43. package/references/scripts/recursive-router-init.ps1 +0 -17
  44. package/references/scripts/recursive-router-init.py +0 -29
  45. package/references/scripts/recursive-router-invoke.ps1 +0 -47
  46. package/references/scripts/recursive-router-invoke.py +0 -103
  47. package/references/scripts/recursive-router-probe.ps1 +0 -25
  48. package/references/scripts/recursive-router-probe.py +0 -44
  49. package/references/scripts/recursive-router-resolve.ps1 +0 -26
  50. package/references/scripts/recursive-router-resolve.py +0 -46
  51. package/references/scripts/recursive-router-validate.ps1 +0 -17
  52. package/references/scripts/recursive-router-validate.py +0 -27
  53. package/references/scripts/recursive-status.ps1 +0 -23
  54. package/references/scripts/recursive-status.py +0 -2124
  55. package/references/scripts/recursive-subagent-action.ps1 +0 -98
  56. package/references/scripts/recursive-subagent-action.py +0 -197
  57. package/references/scripts/recursive-training-extract.ps1 +0 -23
  58. package/references/scripts/recursive-training-extract.py +0 -99
  59. package/references/scripts/recursive-training-grpo.ps1 +0 -52
  60. package/references/scripts/recursive-training-grpo.py +0 -1341
  61. package/references/scripts/recursive-training-loader.ps1 +0 -78
  62. package/references/scripts/recursive-training-loader.py +0 -558
  63. package/references/scripts/recursive-training-mcp.ps1 +0 -29
  64. package/references/scripts/recursive-training-mcp.py +0 -261
  65. package/references/scripts/recursive-training-phase8-trigger.ps1 +0 -55
  66. package/references/scripts/recursive-training-phase8-trigger.py +0 -137
  67. package/references/scripts/recursive-training-sync.ps1 +0 -29
  68. package/references/scripts/recursive-training-sync.py +0 -183
  69. package/references/scripts/recursive_phase_rules.py +0 -367
  70. package/references/scripts/recursive_router_cli_lib.py +0 -2
  71. package/references/scripts/recursive_router_lib.py +0 -2282
  72. package/references/scripts/verify-locks.ps1 +0 -25
  73. package/references/scripts/verify-locks.py +0 -353
  74. package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
  75. package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  76. package/scripts/install-recursive-mode.ps1 +0 -956
  77. package/scripts/install-recursive-mode.py +0 -750
  78. package/scripts/lint-recursive-run.py +0 -2870
  79. package/scripts/recursive-closeout.py +0 -541
  80. package/scripts/recursive-init.py +0 -356
  81. package/scripts/recursive-lock.py +0 -302
  82. package/scripts/recursive-status.py +0 -2124
  83. package/scripts/recursive_phase_rules.py +0 -367
  84. package/scripts/recursive_router_lib.py +0 -2282
  85. package/scripts/verify-locks.py +0 -353
@@ -1,1341 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Repository-local Training-Free GRPO + ReasoningBank
4
- for recursive-mode runs.
5
-
6
- Synthesizes two research advances:
7
- - ReasoningBank: structured memory items (title/description/content/schema)
8
- - GRPO: variance-filtered group comparison (winners vs losers)
9
-
10
- Supports dual extraction modes:
11
- - Contrastive: winners vs losers (classic GRPO)
12
- - Winner-only: consistent patterns across successful runs
13
-
14
- Key design: groups by SUBSYSTEM only, not by task_type. A single run can
15
- produce learnings about many kinds of work (requirements scoping, planning,
16
- implementation, testing, QA, commit workflow, cleanup). The extractor script extracts
17
- ALL learnings from a subsystem's runs and tags each item with its own task_type.
18
- Items are then distributed to training memory files by their self-declared task_type.
19
-
20
- Reads .recursive/run/<id>/, discovers ALL markdown files in each run folder,
21
- groups by subsystem, filters for extraction signal, extracts structured memory
22
- through the companion extractor script, and writes to .recursive/memory/.
23
-
24
- Usage:
25
- python recursive-training-grpo.py --repo-root .
26
- python recursive-training-grpo.py --repo-root . \
27
- --incremental --run-id phase15b-commit-remediation
28
- python recursive-training-grpo.py --repo-root . \
29
- --winner-only-threshold 3
30
- """
31
-
32
- from __future__ import annotations
33
-
34
- import argparse
35
- import asyncio
36
- import hashlib
37
- import json
38
- import os
39
- import re
40
- import subprocess
41
- import sys
42
- import tempfile
43
- from collections import defaultdict
44
- from dataclasses import dataclass, field
45
- from datetime import datetime, timezone
46
- from pathlib import Path
47
- from typing import Dict, List, Optional, Any, Tuple
48
-
49
- # ---------------------------------------------------------------------------
50
- # Configuration
51
- # ---------------------------------------------------------------------------
52
-
53
- MIN_GROUP_SIZE = 2
54
- MIN_WINNER_ONLY_GROUP_SIZE = 2
55
-
56
- # ---------------------------------------------------------------------------
57
- # Run parser — discovers ALL .md files in a run folder
58
- # ---------------------------------------------------------------------------
59
-
60
- @dataclass
61
- class RecursiveRollout:
62
- run_id: str
63
- repo_root: str
64
- task_type: str
65
- subsystem: str
66
- requirements: str = ""
67
- as_is: str = ""
68
- to_be_plan: str = ""
69
- implementation: str = ""
70
- test_summary: str = ""
71
- qa_summary: str = ""
72
- decisions_update: str = ""
73
- state_update: str = ""
74
- memory_impact: str = ""
75
- tests_passed: int = 0
76
- tests_total: int = 0
77
- audit_passed: bool = False
78
- coverage_passed: bool = False
79
- approval_passed: bool = False
80
- qa_passed: bool = False
81
- changed_files: List[str] = field(default_factory=list)
82
- documents: Dict[str, str] = field(default_factory=dict)
83
-
84
- @property
85
- def reward(self) -> float:
86
- if not self.approval_passed or not self.coverage_passed:
87
- return 0.0
88
- if self.tests_total == 0:
89
- return 0.5
90
- return self.tests_passed / self.tests_total
91
-
92
- @property
93
- def is_complete_winner(self) -> bool:
94
- return (
95
- self.audit_passed and self.coverage_passed and self.approval_passed
96
- and self.qa_passed
97
- and (self.tests_passed == self.tests_total if self.tests_total > 0 else True)
98
- )
99
-
100
- @property
101
- def all_content(self) -> str:
102
- """Concatenate all discovered documents for signal extraction."""
103
- parts = []
104
- for fname in sorted(self.documents.keys()):
105
- content = self.documents[fname]
106
- if content:
107
- parts.append(f"--- {fname} ---\n{content}")
108
- return "\n\n".join(parts)
109
-
110
-
111
- class RunParser:
112
- # Explicit pass/fail gates
113
- AUDIT_VERDICT_PATTERN = re.compile(r'##?\s*Audit\s*Verdict.*?\n+\s*[*-]?\s*\*\*?(PASS|FAIL)\*\*?', re.I | re.S)
114
- COVERAGE_PATTERN = re.compile(r'Coverage:\s*(PASS|FAIL)', re.I)
115
- APPROVAL_PATTERN = re.compile(r'Approval:\s*(PASS|FAIL)', re.I)
116
- QA_VERDICT_PATTERN = re.compile(r'QA\s*Verdict\s*.*?\n+\s*[*-]?\s*\*\*?(PASS|FAIL)\*\*?', re.I | re.S)
117
-
118
- # Test counts — multiple patterns for flexibility
119
- TEST_SLASH_PATTERN = re.compile(r'(\d+)\s*/\s*(\d+)\s*(?:tests?\s*)?pass', re.I)
120
- TEST_TABLE_PATTERN = re.compile(r'(?:PASS\s*[-—]\s*)?(\d+)\s+(?:files?[,;]?\s+)?(\d+)\s+tests?', re.I)
121
- TEST_PASSED_PAREN_PATTERN = re.compile(r'(\d+)\s+passed\s+\((\d+)\s+tests?\)', re.I)
122
- TEST_SINGLE_PATTERN = re.compile(r'(\d+)\s+(?:tests?\s+)?pass(?:ed|ing)?', re.I)
123
- TEST_LABEL_PATTERN = re.compile(r'(?:tests?|result)[:\s]+(\d+)\s+(?:tests?\s+)?pass', re.I)
124
-
125
- # File references
126
- FILE_TABLE_PATTERN = re.compile(r'\|\s*`([^`]+)`\s*\|', re.MULTILINE)
127
- CODE_POINTER_PATTERN = re.compile(r'-\s*`([^`]+)`', re.MULTILINE)
128
- FILE_INLINE_PATTERN = re.compile(
129
- r'`([^`]+\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts|py|go|rs|json|yaml|yml|md|css|html))`',
130
- re.MULTILINE,
131
- )
132
- PHASE4_OVERALL_PASS = re.compile(r'Overall\s+Phase\s+4[^\n]*\bPASS\b', re.I)
133
- PHASE4_PASS_ROW = re.compile(
134
- r'\|\s*([^|]+?)\s*\|\s*(\d+)\s*/\s*(\d+)\s+PASS\b',
135
- re.I,
136
- )
137
- IGNORED_PATH_PREFIXES = (
138
- ".recursive/",
139
- "evidence/",
140
- ".git/",
141
- ".worktrees/",
142
- "node_modules/",
143
- )
144
-
145
- def __init__(self, run_dir: Path):
146
- self.run_dir = run_dir
147
- self.run_id = run_dir.name
148
-
149
- def read_artifact(self, filename: str) -> str:
150
- path = self.run_dir / filename
151
- return path.read_text(encoding="utf-8") if path.exists() else ""
152
-
153
- def discover_documents(self) -> Dict[str, str]:
154
- """Read ALL .md files in the run directory."""
155
- docs = {}
156
- if not self.run_dir.exists():
157
- return docs
158
- for md_path in sorted(self.run_dir.glob("*.md")):
159
- docs[md_path.name] = md_path.read_text(encoding="utf-8")
160
- return docs
161
-
162
- def _extract_motivation(self, text: str, max_chars: int = 600) -> str:
163
- """Extract the motivation paragraph after stripping frontmatter."""
164
- if text.startswith("---"):
165
- end = text.find("---", 3)
166
- if end != -1:
167
- text = text[end+3:]
168
- lines = text.strip().split("\n")
169
- skip_patterns = [
170
- r'^Run:\s*', r'^Phase:\s*', r'^Status:\s*', r'^LockedAt:\s*',
171
- r'^LockHash:\s*', r'^Workflow\s+version:\s*', r'^Inputs:\s*$',
172
- r'^Outputs:\s*$', r'^Scope\s+note:\s*',
173
- ]
174
- content_lines = []
175
- in_frontmatter = True
176
- for line in lines:
177
- stripped = line.strip()
178
- if not stripped:
179
- continue
180
- is_frontmatter = any(re.match(p, stripped) for p in skip_patterns)
181
- if in_frontmatter and is_frontmatter:
182
- continue
183
- in_frontmatter = False
184
- if stripped.startswith("|") or stripped.startswith("#") or stripped.startswith("-"):
185
- continue
186
- content_lines.append(stripped)
187
- if len(" ".join(content_lines)) >= max_chars:
188
- break
189
- return " ".join(content_lines)[:max_chars]
190
-
191
- def infer_task_type(self, documents: Dict[str, str], run_id: str) -> str:
192
- """Infer task type from the actual content of run documents.
193
-
194
- Content-first approach: reads the motivation/summary paragraphs from
195
- requirements, plan, and implementation to determine the nature of work.
196
- Scores action types (what was done) and domains (where), then composes
197
- them into a canonical task_type string. Run_id is fallback only.
198
- """
199
- requirements = documents.get("00-requirements.md", "")
200
- plan = documents.get("02-to-be-plan.md", "")
201
- impl = documents.get("03-implementation-summary.md", "")
202
-
203
- req_motivation = self._extract_motivation(requirements, 600)
204
- plan_motivation = self._extract_motivation(plan, 400)
205
- impl_motivation = self._extract_motivation(impl, 400)
206
- impl_content = impl.lower() if impl else ""
207
- req_plan_lower = (requirements + "\n" + plan).lower()
208
-
209
- # --- Action detection ---
210
- action_scores: Dict[str, int] = defaultdict(int)
211
-
212
- def score_action(text: str, weight: int):
213
- t = text.lower()
214
- # Audit
215
- if re.search(r'\bthis run\b.*\baudit\b|\bperforms?\b.*\baudit\b|\brepo-wide\s+audit\b', t):
216
- action_scores["audit"] += weight + 3
217
- elif re.search(r'\baudit\b.*\bfinding|\brepository.*\baudit\b', t):
218
- action_scores["audit"] += weight + 1
219
- # Commit / cleanup / validate
220
- if re.search(r'\bcommits?\b.*\b(remediation|changes|work)\b|\bgit\s+commit\b', t):
221
- action_scores["commit"] += weight + 2
222
- if re.search(r'\bcleans?\s+up\b|\bcleanup\b|\bremove\s+temporary\b|\bremov\w+\s+temp\b', t):
223
- action_scores["cleanup"] += weight + 2
224
- if re.search(r'\bvalidat\w+\b.*\b(tests?|build)\b|\bverify\b.*\bbuild\b', t):
225
- action_scores["validate"] += weight + 2
226
- # Implementation
227
- if re.search(r'\badds?\b|\bimplement\w*\b|\bbuilds?\b|\bcompletes?\b', t):
228
- action_scores["implement"] += weight + 1
229
- # Fix / remediate
230
- if re.search(r'\bthis run\b.*\b(fix|remediat)\w*\b|\bremediation run\b', t):
231
- action_scores["fix"] += weight + 2
232
- elif re.search(r'\bfix\b.*\bschema|\bfix\b.*\bgap|\brepair\b', t):
233
- action_scores["fix"] += weight
234
- # Integration
235
- if re.search(r'\bintegrat\w+\b.*\binto\b|\bconnect\w*\b.*\bto\b', t):
236
- action_scores["integrate"] += weight + 1
237
- # Refactor
238
- if re.search(r'\brefactor\b|\brestructur\w+\b', t):
239
- action_scores["refactor"] += weight + 1
240
-
241
- score_action(req_motivation, 5)
242
- score_action(plan_motivation, 2)
243
- score_action(impl_motivation, 1)
244
-
245
- # Tie-breaker: audit vs fix
246
- if action_scores.get("audit", 0) > 0 and action_scores.get("fix", 0) > 0:
247
- if impl_content:
248
- if re.search(r'\bfix\w*\b|\brepair\w*\b|\bremediat\w*\b', impl_content):
249
- action_scores["fix"] += 3
250
- if re.search(r'\baudit\w*\b|\bfindings\b', impl_content):
251
- action_scores["audit"] += 3
252
-
253
- # --- Domain detection ---
254
- domain_scores: Dict[str, int] = defaultdict(int)
255
-
256
- # Primary: implementation file paths
257
- if any(k in impl_content for k in ("apps/web/src", "packages/ui/src", ".tsx", ".css", "tailwind", "shadcn")):
258
- domain_scores["frontend"] += 3
259
- if any(k in impl_content for k in ("apps/api-worker", "packages/domain", "packages/schemas", "hono", "durable object")):
260
- domain_scores["backend"] += 3
261
- if any(k in impl_content for k in ("protocol/schemas", "schema-tools", "validate-schemas", "$id")):
262
- domain_scores["schema"] += 4
263
- if any(k in impl_content for k in ("wrangler", "wrangler.jsonc", "deploy", "docker", "ci/cd")):
264
- domain_scores["infrastructure"] += 3
265
- if any(k in impl_content for k in ("state.md", "decisions.md", "memory", "governance", "control-plane")):
266
- domain_scores["governance"] += 3
267
-
268
- # Fallback: explicit domain keywords in requirements + plan
269
- if not impl_content or sum(domain_scores.values()) < 2:
270
- if any(k in req_plan_lower for k in ("frontend", "react", "component", "ui", "page", "route", "dashboard")):
271
- domain_scores["frontend"] += 3
272
- if any(k in req_plan_lower for k in ("api", "route", "worker", "server", "backend")):
273
- domain_scores["backend"] += 3
274
- if any(k in req_plan_lower for k in ("schema", "protocol", "json schema", "$id", "canonical")):
275
- domain_scores["schema"] += 3
276
- if any(k in req_plan_lower for k in ("wrangler", "deploy", "docker", "ci/cd")):
277
- domain_scores["infrastructure"] += 3
278
- if any(k in req_plan_lower for k in ("state.md", "decisions.md", "memory", "governance")):
279
- domain_scores["governance"] += 3
280
-
281
- best_action = max(action_scores, key=action_scores.get) if action_scores else None
282
- best_domain = max(domain_scores, key=domain_scores.get) if domain_scores else None
283
- action_score = action_scores.get(best_action, 0) if best_action else 0
284
- domain_score = domain_scores.get(best_domain, 0) if best_domain else 0
285
-
286
- parts: List[str] = []
287
- if best_action == "audit":
288
- if best_domain and domain_score >= 4:
289
- parts = [best_domain, "audit"]
290
- elif "repository" in req_motivation.lower() or "repo-wide" in req_motivation.lower():
291
- parts = ["repository", "audit"]
292
- else:
293
- parts = ["audit"]
294
- elif best_action in ("commit", "cleanup", "validate"):
295
- actions = []
296
- if action_scores.get("commit", 0) > 0: actions.append("commit")
297
- if action_scores.get("cleanup", 0) > 0: actions.append("cleanup")
298
- if action_scores.get("validate", 0) > 0: actions.append("validate")
299
- if actions: parts = ["-".join(actions)]
300
- else: parts = [best_action]
301
- elif best_action == "fix" and best_domain and domain_score >= 3:
302
- parts = [best_domain, "remediation"]
303
- elif best_action == "implement" and best_domain and domain_score >= 3:
304
- parts = [best_domain, "feature-implementation"]
305
- elif best_action == "integrate":
306
- if best_domain and domain_score >= 3: parts = [best_domain, "integration"]
307
- else: parts = ["integration"]
308
- elif best_domain and domain_score >= 3:
309
- parts = [best_domain, best_action or "work"]
310
- elif best_action and action_score >= 2:
311
- parts = [best_action]
312
-
313
- task_type = "-".join(parts) if parts else ""
314
- if not task_type or action_score < 2:
315
- cleaned = re.sub(r'[^a-zA-Z0-9_-]', '-', run_id.lower()).strip('-')
316
- cleaned = re.sub(r'^(phase\d+[a-z]?-?|run-?\d+-?)+', '', cleaned)
317
- cleaned = re.sub(r'-+', '-', cleaned).strip('-')
318
- task_type = cleaned[:60] if cleaned else "general"
319
-
320
- return task_type
321
-
322
- @classmethod
323
- def normalize_repo_path(cls, raw: str) -> str:
324
- path = raw.strip().strip("`").replace("\\", "/")
325
- while path.startswith("./"):
326
- path = path[2:]
327
- if path.startswith("/"):
328
- path = path[1:]
329
- # Drop absolute Windows drive prefixes if they sneak in
330
- if re.match(r"^[A-Za-z]:/", path):
331
- parts = path.split("/", 2)
332
- path = parts[2] if len(parts) == 3 else path
333
- return path
334
-
335
- @classmethod
336
- def is_noise_path(cls, path: str) -> bool:
337
- normalized = cls.normalize_repo_path(path)
338
- if not normalized:
339
- return True
340
- if normalized.startswith(".recursive/") or normalized == ".recursive":
341
- return True
342
- if any(normalized.startswith(prefix) for prefix in cls.IGNORED_PATH_PREFIXES):
343
- return True
344
- if "/.recursive/" in f"/{normalized}":
345
- return True
346
- if re.search(r"(^|/)\.recursive/run/", normalized):
347
- return True
348
- return False
349
-
350
- def infer_subsystem(self, implementation: str, as_is: str, documents: Dict[str, str]) -> str:
351
- preferred_docs = [
352
- documents.get("03-implementation-summary.md", ""),
353
- documents.get("00-worktree.md", ""),
354
- documents.get("04-test-summary.md", ""),
355
- implementation,
356
- as_is,
357
- ]
358
- files: List[str] = []
359
- for text in preferred_docs:
360
- if text:
361
- files.extend(self.extract_changed_files(text))
362
- if not files:
363
- files = self.extract_changed_files("\n".join(documents.values()))
364
-
365
- prefixes: Dict[str, int] = {}
366
- for raw in files:
367
- normalized = self.normalize_repo_path(raw)
368
- if self.is_noise_path(normalized):
369
- continue
370
- parts = [p for p in normalized.split("/") if p]
371
- if len(parts) < 1:
372
- continue
373
- if parts[0] in ("packages", "apps", "libs") and len(parts) >= 2:
374
- key = parts[1]
375
- else:
376
- key = parts[0]
377
- if not key or key in {".recursive", "evidence", "run"}:
378
- continue
379
- prefixes[key] = prefixes.get(key, 0) + 1
380
- return max(prefixes, key=prefixes.get) if prefixes else "general"
381
-
382
- def extract_changed_files(self, text: str) -> List[str]:
383
- files = set()
384
- for pat in (self.FILE_TABLE_PATTERN, self.CODE_POINTER_PATTERN, self.FILE_INLINE_PATTERN):
385
- for m in pat.findall(text):
386
- candidate = self.normalize_repo_path(m)
387
- if ("." in candidate or "/" in candidate) and not self.is_noise_path(candidate):
388
- files.add(candidate)
389
- return sorted(files)
390
-
391
- def extract_test_counts_from_text(self, text: str, *, allow_loose: bool = False) -> tuple[int, int]:
392
- """Extract (passed, total). Prefer explicit N/M PASS rows; loose patterns optional."""
393
- total_passed, total_tests, seen = 0, 0, set()
394
-
395
- for _label, p, t in self.PHASE4_PASS_ROW.findall(text):
396
- key = (int(p), int(t))
397
- if key not in seen and key[0] <= key[1] and key[1] > 0:
398
- # Skip rows that look like RED/historical in the label column context is handled by PASS-only
399
- seen.add(key)
400
- total_passed += key[0]
401
- total_tests += key[1]
402
-
403
- if total_tests > 0:
404
- return total_passed, total_tests
405
-
406
- for p, t in self.TEST_SLASH_PATTERN.findall(text):
407
- key = (int(p), int(t))
408
- if key not in seen and key[0] <= key[1] and key[1] > 0 and key[0] == key[1]:
409
- seen.add(key)
410
- total_passed += key[0]
411
- total_tests += key[1]
412
-
413
- if total_tests > 0:
414
- return total_passed, total_tests
415
-
416
- if not allow_loose:
417
- return 0, 0
418
-
419
- for p, t in self.TEST_PASSED_PAREN_PATTERN.findall(text):
420
- key = (int(p), int(t))
421
- if key not in seen and key[0] <= key[1] and key[1] > 0:
422
- seen.add(key)
423
- total_passed += key[0]
424
- total_tests += key[1]
425
-
426
- return total_passed, total_tests
427
-
428
- def extract_test_counts(self, documents: Dict[str, str] | str) -> tuple[int, int]:
429
- """Prefer Phase 4 primary suite PASS rows; do not soup RED history from all markdown."""
430
- if isinstance(documents, str):
431
- return self.extract_test_counts_from_text(documents, allow_loose=False)
432
-
433
- phase4 = documents.get("04-test-summary.md", "")
434
- if phase4:
435
- # Drop historical RED sections before counting
436
- cleaned_lines = []
437
- for line in phase4.splitlines():
438
- lower = line.lower()
439
- if "red" in lower and ("historical" in lower or "fail as expected" in lower):
440
- continue
441
- if re.search(r'\bFAIL as expected\b', line, re.I):
442
- continue
443
- cleaned_lines.append(line)
444
- cleaned = "\n".join(cleaned_lines)
445
- passed, total = self.extract_test_counts_from_text(cleaned, allow_loose=False)
446
- if total > 0:
447
- return passed, total
448
- if self.PHASE4_OVERALL_PASS.search(phase4):
449
- # Gates + overall PASS with no reliable counts → treat as complete (0/0)
450
- return 0, 0
451
-
452
- # Fallback: green-only slash totals from implementation/test docs (no loose "N pass")
453
- for key in ("04-test-summary.md", "03-implementation-summary.md"):
454
- text = documents.get(key, "")
455
- if not text:
456
- continue
457
- passed, total = self.extract_test_counts_from_text(text, allow_loose=False)
458
- if total > 0:
459
- return passed, total
460
- return 0, 0
461
-
462
- def extract_reward_signals(self, documents: Dict[str, str]) -> dict:
463
- all_text = "\n\n".join(documents.values())
464
-
465
- audit_matches = self.AUDIT_VERDICT_PATTERN.findall(all_text)
466
- coverage_matches = self.COVERAGE_PATTERN.findall(all_text)
467
- approval_matches = self.APPROVAL_PATTERN.findall(all_text)
468
- qa_matches = self.QA_VERDICT_PATTERN.findall(all_text)
469
-
470
- total_passed, total_tests = self.extract_test_counts(documents)
471
-
472
- has_audit_fail = any("FAIL" in m.upper() for m in audit_matches)
473
- coverage_pass = any("PASS" in m.upper() for m in coverage_matches) and not any("FAIL" in m.upper() for m in coverage_matches)
474
- approval_pass = any("PASS" in m.upper() for m in approval_matches) and not any("FAIL" in m.upper() for m in approval_matches)
475
-
476
- # Flexible QA verdict detection
477
- qa_doc_text = documents.get("05-manual-qa.md", "")
478
- qa_doc_approval = self.APPROVAL_PATTERN.findall(qa_doc_text)
479
-
480
- if qa_matches:
481
- qa_pass = all("FAIL" not in m.upper() for m in qa_matches)
482
- elif qa_doc_approval:
483
- # QA doc has explicit Approval gate - use that
484
- qa_pass = all("PASS" in m.upper() for m in qa_doc_approval) and not any("FAIL" in m.upper() for m in qa_doc_approval)
485
- elif qa_doc_text and "FAIL" not in qa_doc_text.upper():
486
- # Has QA doc but no explicit verdict - assume pass if no FAIL found
487
- qa_pass = True
488
- else:
489
- qa_pass = False
490
-
491
- return {
492
- "has_audit_fail": has_audit_fail,
493
- "coverage_pass": coverage_pass,
494
- "approval_pass": approval_pass,
495
- "tests_passed": total_passed,
496
- "tests_total": total_tests,
497
- "qa_pass": qa_pass,
498
- }
499
-
500
- def has_implementation_evidence(self, documents: Dict[str, str]) -> bool:
501
- """Check if any document contains implementation evidence."""
502
- if documents.get("03-implementation-summary.md"):
503
- return True
504
- if any(f.startswith("AUDIT-") and content for f, content in documents.items()):
505
- return True
506
- impl_keywords = ["implemented", "refactor", "changed files", "commit", "git diff", "diff --stat", "requirements completed"]
507
- for fname, content in documents.items():
508
- lower = content.lower()
509
- if any(k in lower for k in impl_keywords):
510
- return True
511
- return False
512
-
513
- def has_test_evidence(self, documents: Dict[str, str]) -> bool:
514
- """Check if any document contains test evidence."""
515
- if documents.get("04-test-summary.md"):
516
- return True
517
- test_keywords = ["tests pass", "test suite", "vitest", "npx vitest", "test result", "npm test", "passed", "full suite"]
518
- for fname, content in documents.items():
519
- lower = content.lower()
520
- if any(k in lower for k in test_keywords):
521
- return True
522
- return False
523
-
524
- def parse(self) -> Optional[RecursiveRollout]:
525
- # Discover ALL .md files in the run directory
526
- documents = self.discover_documents()
527
- if not documents:
528
- return None
529
-
530
- # The primary requirements artifact must exist for a valid run
531
- req = documents.get("00-requirements.md", "")
532
- if not req:
533
- return None
534
-
535
- # Named fields from primary artifacts (if present)
536
- as_is = documents.get("01-as-is.md", "")
537
- impl = documents.get("03-implementation-summary.md", "")
538
- test = documents.get("04-test-summary.md", "")
539
- qa = documents.get("05-manual-qa.md", "")
540
- decisions = documents.get("06-decisions-update.md", "")
541
- state = documents.get("07-state-update.md", "")
542
- memory = documents.get("08-memory-impact.md", "")
543
- to_be = documents.get("02-to-be-plan.md", "")
544
-
545
- # Reward signals from ALL documents
546
- signals = self.extract_reward_signals(documents)
547
-
548
- # Critical missing = no implementation evidence AND no test evidence anywhere
549
- critical_missing = not (self.has_implementation_evidence(documents) or self.has_test_evidence(documents))
550
-
551
- return RecursiveRollout(
552
- run_id=self.run_id,
553
- repo_root=str(self.run_dir.parent.parent.parent),
554
- task_type=self.infer_task_type(documents, self.run_id),
555
- subsystem=self.infer_subsystem(impl, as_is, documents),
556
- requirements=req, as_is=as_is, to_be_plan=to_be,
557
- implementation=impl, test_summary=test, qa_summary=qa,
558
- decisions_update=decisions, state_update=state, memory_impact=memory,
559
- tests_passed=signals["tests_passed"], tests_total=signals["tests_total"],
560
- audit_passed=not signals["has_audit_fail"],
561
- coverage_passed=signals["coverage_pass"] and not critical_missing,
562
- approval_passed=signals["approval_pass"] and not critical_missing,
563
- qa_passed=signals["qa_pass"] and not critical_missing,
564
- changed_files=self.extract_changed_files(impl) or self.extract_changed_files(as_is) or self.extract_changed_files(req),
565
- documents=documents,
566
- )
567
-
568
-
569
- def phase8_is_locked(run_dir: Path) -> bool:
570
- phase8 = run_dir / "08-memory-impact.md"
571
- if not phase8.exists():
572
- return False
573
- text = phase8.read_text(encoding="utf-8")
574
- return bool(re.search(r"(?m)^Status:\s*`?LOCKED`?\s*$", text)) or "LockedAt:" in text
575
-
576
-
577
- def parse_all_runs(runs_dir: Path, *, require_phase8_locked: bool = True) -> List[RecursiveRollout]:
578
- rollouts = []
579
- if not runs_dir.exists():
580
- return rollouts
581
- for run_dir in sorted(runs_dir.iterdir()):
582
- if not (run_dir.is_dir() and any(run_dir.glob("*.md"))):
583
- continue
584
- if require_phase8_locked and not phase8_is_locked(run_dir):
585
- continue
586
- r = RunParser(run_dir).parse()
587
- if r:
588
- rollouts.append(r)
589
- return rollouts
590
-
591
-
592
- def filter_rollouts_for_training(
593
- rollouts: List[RecursiveRollout],
594
- incremental_run_id: Optional[str] = None,
595
- ) -> List[RecursiveRollout]:
596
- """Incremental mode keeps the target run plus peers in the same subsystem."""
597
- if not incremental_run_id:
598
- return list(rollouts)
599
- target = next((r for r in rollouts if r.run_id == incremental_run_id), None)
600
- if target is None:
601
- return []
602
- return [r for r in rollouts if r.subsystem == target.subsystem]
603
-
604
- # ---------------------------------------------------------------------------
605
- # Grouping with hierarchical prefix fallback
606
- # ---------------------------------------------------------------------------
607
-
608
- def group_by_subsystem(rollouts: List[RecursiveRollout]) -> Dict[str, List[RecursiveRollout]]:
609
- """Group rollouts by subsystem only.
610
-
611
- A single run can produce learnings about multiple task types (requirements
612
- scoping, planning, implementation, testing, QA). Grouping by subsystem
613
- allows the extractor script to extract ALL learnings from a run's documents and tag
614
- each item with its own task_type. Items are distributed to training memory
615
- files by their self-declared task_type after extraction.
616
- """
617
- groups: Dict[str, List[RecursiveRollout]] = defaultdict(list)
618
- for r in rollouts:
619
- groups[r.subsystem].append(r)
620
- return dict(groups)
621
-
622
-
623
- # ---------------------------------------------------------------------------
624
- # Group classification
625
- # ---------------------------------------------------------------------------
626
-
627
- def classify_group(group: List[RecursiveRollout], winner_only_threshold: int) -> Tuple[str, List[RecursiveRollout], List[RecursiveRollout]]:
628
- """Classify a group for extraction mode.
629
-
630
- Returns:
631
- (mode, winners, losers) where mode is:
632
- - "contrastive": has both winners and losers (classic GRPO)
633
- - "winner-only": only winners, but enough for pattern extraction
634
- - "insufficient": not enough signal for either mode
635
- """
636
- winners = [r for r in group if r.is_complete_winner]
637
- losers = [r for r in group if not r.is_complete_winner]
638
-
639
- if winners and losers:
640
- return "contrastive", winners, losers
641
- if len(winners) >= winner_only_threshold:
642
- return "winner-only", winners, []
643
- return "insufficient", [], []
644
-
645
-
646
- # ---------------------------------------------------------------------------
647
- # Companion extraction script.
648
- # The training orchestrator constructs prompts and delegates prompt evaluation
649
- # to the extractor script. It does not choose transports or credentials.
650
- # ---------------------------------------------------------------------------
651
-
652
- class ExtractionUnavailableError(RuntimeError):
653
- """Raised when the companion extractor script is unavailable."""
654
-
655
-
656
- def _extractor_script_path() -> Path:
657
- return Path(__file__).with_name("recursive-training-extract.py")
658
-
659
-
660
- def invoke_extractor_script(repo_root: Path, prompt: str) -> str:
661
- extractor_script = _extractor_script_path()
662
- if not extractor_script.exists():
663
- raise ExtractionUnavailableError(
664
- "Training extractor script is unavailable for recursive-training."
665
- )
666
-
667
- with tempfile.TemporaryDirectory(prefix="recursive-training-extract-") as temp_dir:
668
- prompt_path = Path(temp_dir) / "prompt.txt"
669
- prompt_path.write_text(prompt, encoding="utf-8")
670
- result = subprocess.run(
671
- [
672
- sys.executable,
673
- str(extractor_script),
674
- "--repo-root",
675
- str(repo_root),
676
- "--prompt-file",
677
- str(prompt_path),
678
- ],
679
- check=False,
680
- capture_output=True,
681
- text=True,
682
- )
683
-
684
- stdout = result.stdout.strip()
685
- stderr = result.stderr.strip()
686
- if result.returncode == 0:
687
- return stdout
688
- if result.returncode == 2:
689
- raise ExtractionUnavailableError(
690
- stderr or stdout or "Training extractor script is not available."
691
- )
692
- print(f" Training extraction failed: {stderr or stdout or 'no diagnostics emitted'}")
693
- return ""
694
-
695
-
696
- # ---------------------------------------------------------------------------
697
- # Extraction prompts
698
- # ---------------------------------------------------------------------------
699
-
700
- CONTRASTIVE_PROMPT = """You are analyzing multiple attempts to work in a specific software repository subsystem. Each attempt is a recursive-mode run with full phase artifacts.
701
-
702
- This repository uses recursive-mode: a structured workflow where each run produces phase artifacts (requirements, AS-IS analysis, TO-BE plan, implementation, tests, QA) and may also include supplementary audit reports, addenda, or evidence files.
703
-
704
- Subsystem: {subsystem}
705
- Repository: {repo_name}
706
-
707
- Below are {num_rollouts} historical runs. Some SUCCEEDED fully. Others FAILED at some phase.
708
-
709
- === WINNER RUNS ===
710
- {winner_summaries}
711
-
712
- === LOSER RUNS ===
713
- {loser_summaries}
714
-
715
- Your task: Extract REPOSITORY-SPECIFIC reasoning strategies that explain why winners succeeded where losers failed. These must be actionable lessons unique to THIS codebase.
716
-
717
- A single run can contain learnings about MANY different kinds of work — requirements scoping, planning, implementation, testing, QA, cleanup, commit workflow, etc. Extract ALL distinct learnings you can find.
718
-
719
- For each memory item, provide:
720
- - **title**: ≤ 6 words, concise identifier (e.g., "Branch-based commit workflow")
721
- - **description**: ≤ 20 words, one-line summary
722
- - **content**: max 100 words, distilled actionable steps with rationale
723
- - **task_type**: the kind of work this learning applies to (e.g., "commit-workflow", "test-validation", "frontend-implementation", "requirements-scoping", "planning", "cleanup", "qa-verification", "api-design"). Be specific and descriptive.
724
- - **applies_to**: array of file paths, commands, or conceptual tags this applies to
725
-
726
- Focus on:
727
- 1. What successful runs did differently (concrete steps, commands, file paths)
728
- 2. What repo-specific conventions or quirks mattered
729
- 3. Common failure modes and how to avoid them
730
- 4. Build/test commands that are specific to this repo's setup
731
- 5. Patterns in how requirements are scoped, plans are structured, or tests are organized
732
-
733
- Return ONLY a JSON array:
734
- ```json
735
- [
736
- {{
737
- "title": "Branch-based commit workflow",
738
- "description": "Always create feature branches before committing remediation work",
739
- "content": "1. Create branch from stage HEAD: git checkout -b fix/<issue>. 2. Make changes and verify. 3. Push branch and open PR — never commit directly to stage.",
740
- "task_type": "commit-workflow",
741
- "applies_to": [".worktrees/", "git workflow", "stage branch"]
742
- }}
743
- ]
744
- ```
745
-
746
- If no meaningful repo-specific strategies can be extracted, return [].
747
- """
748
-
749
-
750
- WINNER_ONLY_PROMPT = """You are analyzing multiple successful attempts to work in a specific software repository subsystem. Each attempt is a recursive-mode run with full phase artifacts.
751
-
752
- This repository uses recursive-mode: a structured workflow where each run produces phase artifacts (requirements, AS-IS analysis, TO-BE plan, implementation, tests, QA) and may also include supplementary audit reports, addenda, or evidence files.
753
-
754
- Subsystem: {subsystem}
755
- Repository: {repo_name}
756
-
757
- Below are {num_rollouts} historical runs. All SUCCEEDED.
758
-
759
- === SUCCESSFUL RUNS ===
760
- {winner_summaries}
761
-
762
- Your task: Extract REPOSITORY-SPECIFIC patterns, conventions, and practices that appear consistently across these successful runs. These must be actionable knowledge unique to THIS codebase that future agents should apply when working on similar tasks.
763
-
764
- A single run can contain learnings about MANY different kinds of work — requirements scoping, planning, implementation, testing, QA, cleanup, commit workflow, etc. Extract ALL distinct learnings you can find.
765
-
766
- For each memory item, provide:
767
- - **title**: ≤ 6 words, concise identifier
768
- - **description**: ≤ 20 words, one-line summary
769
- - **content**: max 100 words, distilled actionable steps with rationale
770
- - **task_type**: the kind of work this learning applies to (e.g., "commit-workflow", "test-validation", "frontend-implementation", "requirements-scoping", "planning", "cleanup", "qa-verification", "api-design"). Be specific and descriptive.
771
- - **applies_to**: array of file paths, commands, or conceptual tags this applies to
772
-
773
- Focus on:
774
- 1. Build/test commands that are specific to this repo's setup
775
- 2. File organization patterns that appear repeatedly across runs
776
- 3. Architectural conventions (e.g., how languages/modules interact, how vendored code is managed)
777
- 4. Common verification approaches or validation commands used
778
- 5. Repo quirks that an external agent would need to know
779
- 6. Consistent patterns in how requirements are scoped or how plans are structured
780
-
781
- Return ONLY a JSON array:
782
- ```json
783
- [
784
- {{
785
- "title": "Use corepack pnpm for nested commands",
786
- "description": "Root scripts must use corepack pnpm instead of bare pnpm for nested workspace calls",
787
- "content": "1. Root package.json scripts must invoke nested workspace commands through `corepack pnpm ...` instead of bare `pnpm`. 2. This ensures PATH-independent resolution on Windows. 3. Apply to schemas:validate, test, smoke, and build scripts.",
788
- "task_type": "infrastructure-scripting",
789
- "applies_to": ["package.json", "pnpm scripts", "root commands"]
790
- }}
791
- ]
792
- ```
793
-
794
- If no meaningful repo-specific patterns can be extracted, return [].
795
- """
796
-
797
-
798
- # ---------------------------------------------------------------------------
799
- # Rollout summarization
800
- # ---------------------------------------------------------------------------
801
-
802
- def _summarize_rollout(r: RecursiveRollout, label: str) -> str:
803
- """Create a rich summary of a rollout from all its documents."""
804
- files_str = ", ".join(r.changed_files[:8]) if r.changed_files else "N/A"
805
- test_line = f"{r.tests_passed}/{r.tests_total} pass" if r.tests_total > 0 else "No tests"
806
-
807
- # Include supplementary document names to hint at richness
808
- extra_docs = [f for f in r.documents if f not in (
809
- "00-requirements.md", "01-as-is.md", "02-to-be-plan.md",
810
- "03-implementation-summary.md", "04-test-summary.md",
811
- "05-manual-qa.md", "06-decisions-update.md",
812
- "07-state-update.md", "08-memory-impact.md", "00-worktree.md"
813
- )]
814
- extra_hint = f" | Extra docs: {', '.join(extra_docs)}" if extra_docs else ""
815
-
816
- plan_snippet = r.to_be_plan[:500].replace("\n", "\n ") if r.to_be_plan else "N/A"
817
- impl_snippet = r.implementation[:400].replace("\n", "\n ") if r.implementation else "N/A"
818
-
819
- # Include task_type and document keys so the extractor sees what kind of work this run covered
820
- doc_keys = ", ".join(sorted(r.documents.keys())) if r.documents else "N/A"
821
-
822
- return (
823
- f"Run: {r.run_id} [{label}] (inferred: {r.task_type}){extra_hint}\n"
824
- f" Documents: {doc_keys}\n"
825
- f" Files: {files_str}\n"
826
- f" Tests: {test_line} | Audit: {'PASS' if r.audit_passed else 'FAIL'} | "
827
- f"Coverage: {'PASS' if r.coverage_passed else 'FAIL'} | Approval: {'PASS' if r.approval_passed else 'FAIL'}\n"
828
- f" Plan:\n {plan_snippet}\n"
829
- f" Implementation:\n {impl_snippet}\n"
830
- )
831
-
832
-
833
- async def extract_reasoningbank_items(
834
- mode: str, subsystem: str,
835
- winners: List[RecursiveRollout], losers: List[RecursiveRollout],
836
- existing_items: List[dict]
837
- ) -> List[dict]:
838
- """Extract structured ReasoningBank memory items from a subsystem group.
839
-
840
- The extractor is free to tag each item with its own task_type. A single run can
841
- contribute items with different task_types (e.g., commit-workflow,
842
- test-validation, requirements-scoping).
843
- """
844
- repo_name = winners[0].repo_root.split("/")[-1] if "/" in winners[0].repo_root else winners[0].repo_root
845
-
846
- winner_text = "\n---\n".join(_summarize_rollout(r, "WIN") for r in winners)
847
-
848
- if mode == "contrastive":
849
- loser_text = "\n---\n".join(_summarize_rollout(r, "FAIL") for r in losers)
850
- prompt_template = CONTRASTIVE_PROMPT
851
- else:
852
- loser_text = ""
853
- prompt_template = WINNER_ONLY_PROMPT
854
-
855
- # Deduplication hint
856
- existing_titles = [item.get("title", "") for item in existing_items]
857
- dedup_hint = f"\nExisting memory titles (avoid duplicates): {existing_titles}\n" if existing_titles else ""
858
-
859
- prompt = prompt_template.format(
860
- subsystem=subsystem,
861
- repo_name=repo_name,
862
- num_rollouts=len(winners) + len(losers),
863
- winner_summaries=winner_text,
864
- loser_summaries=loser_text,
865
- ) + dedup_hint
866
-
867
- repo_path = Path(winners[0].repo_root).resolve()
868
- content = invoke_extractor_script(repo_path, prompt)
869
- if not content:
870
- return []
871
-
872
- return _parse_reasoningbank_json(content)
873
-
874
-
875
- def _parse_reasoningbank_json(text: str) -> List[dict]:
876
- if not text:
877
- return []
878
- for pattern in (r'```json\s*([\s\S]*?)\s*```', r'```\s*([\s\S]*?)\s*```'):
879
- m = re.search(pattern, text)
880
- if m:
881
- text = m.group(1)
882
- break
883
- try:
884
- data = json.loads(text)
885
- if isinstance(data, list):
886
- items = []
887
- for item in data:
888
- if isinstance(item, dict) and "title" in item and "content" in item:
889
- # Normalize task_type: lowercase, hyphenated, no fixed enum
890
- raw_tt = item.get("task_type", "")
891
- if raw_tt:
892
- tt = re.sub(r'[^a-zA-Z0-9_-]', '-', raw_tt.lower()).strip('-')
893
- tt = re.sub(r'-+', '-', tt).strip('-')
894
- item["task_type"] = tt[:60]
895
- else:
896
- item["task_type"] = "general"
897
- items.append(item)
898
- return items
899
- except json.JSONDecodeError:
900
- pass
901
- return []
902
-
903
-
904
- # ---------------------------------------------------------------------------
905
- # ReasoningBank: memory plane writer
906
- # ---------------------------------------------------------------------------
907
-
908
- class ReasoningBankMemory:
909
- """Manages .recursive/memory/ as a ReasoningBank-style structured store."""
910
-
911
- MEMORY_DIR = ".recursive/memory"
912
- DOMAINS_DIR = "domains"
913
- TRAINING_DIR = "training"
914
-
915
- def __init__(self, repo_root: str):
916
- self.root = Path(repo_root)
917
- self.domains_dir = self.root / self.MEMORY_DIR / self.DOMAINS_DIR
918
- self.training_dir = self.root / self.MEMORY_DIR / self.TRAINING_DIR
919
- self.memory_index = self.root / self.MEMORY_DIR / "MEMORY.md"
920
- self.domains_dir.mkdir(parents=True, exist_ok=True)
921
- self.training_dir.mkdir(parents=True, exist_ok=True)
922
- self._rb_counter = self._load_max_rb_id()
923
-
924
- @staticmethod
925
- def _domain_filename(subsystem: str) -> str:
926
- cleaned = re.sub(r'[^a-zA-Z0-9_-]+', '-', subsystem.strip().lower())
927
- cleaned = cleaned.strip('-_') or "general"
928
- return f"{cleaned}.md"
929
-
930
- def _load_max_rb_id(self) -> int:
931
- max_id = -1
932
- for d in (self.domains_dir, self.training_dir):
933
- if not d.exists():
934
- continue
935
- for f in d.glob("*.md"):
936
- content = f.read_text(encoding="utf-8")
937
- for m in re.finditer(r'rb_id:\s*"RB-(\d+)"', content):
938
- max_id = max(max_id, int(m.group(1)))
939
- return max_id
940
-
941
- def _next_rb_id(self) -> str:
942
- self._rb_counter += 1
943
- return f"RB-{self._rb_counter}"
944
-
945
- def existing_titles(self) -> set[str]:
946
- titles: set[str] = set()
947
- for d in (self.domains_dir, self.training_dir):
948
- if not d.exists():
949
- continue
950
- for f in d.glob("*.md"):
951
- content = f.read_text(encoding="utf-8")
952
- for m in re.finditer(r'### RB-\d+:\s*(.+)', content):
953
- titles.add(m.group(1).strip().lower())
954
- return titles
955
-
956
- def assign_rb_ids(self, items: List[dict]) -> List[dict]:
957
- """Assign a stable rb_id once per item before dual writes."""
958
- prepared = []
959
- for item in items:
960
- cloned = dict(item)
961
- if not cloned.get("rb_id"):
962
- cloned["rb_id"] = self._next_rb_id()
963
- prepared.append(cloned)
964
- return prepared
965
-
966
- def filter_new_items(self, items: List[dict]) -> List[dict]:
967
- existing = self.existing_titles()
968
- fresh = []
969
- for item in items:
970
- title = str(item.get("title", "")).strip().lower()
971
- if not title or title in existing:
972
- continue
973
- existing.add(title)
974
- fresh.append(item)
975
- return fresh
976
-
977
- def _compute_success_rate(self, source_runs: List[str], winner_runs: List[str]) -> float:
978
- if not source_runs:
979
- return 0.0
980
- return len(winner_runs) / len(source_runs)
981
-
982
- def _product_owns_paths(self, changed_paths: List[str]) -> List[str]:
983
- owns = []
984
- for raw in changed_paths:
985
- path = RunParser.normalize_repo_path(raw)
986
- if RunParser.is_noise_path(path):
987
- continue
988
- if " | " in path or "Status:" in path or "Changed Files:" in path:
989
- continue
990
- if len(path) > 180:
991
- continue
992
- if not (("/" in path) or re.search(r"\.[A-Za-z0-9]{1,8}$", path)):
993
- continue
994
- owns.append(path)
995
- if len(owns) >= 10:
996
- break
997
- return owns
998
-
999
- def write_domain_memory(
1000
- self, subsystem: str, items: List[dict], source_runs: List[str],
1001
- winner_runs: List[str], changed_paths: List[str]
1002
- ) -> List[dict]:
1003
- items = self.filter_new_items(items)
1004
- if not items:
1005
- return []
1006
- items = self.assign_rb_ids(items)
1007
- domain_file = self.domains_dir / self._domain_filename(subsystem)
1008
- existing = domain_file.read_text(encoding="utf-8") if domain_file.exists() else ""
1009
- now = datetime.now(timezone.utc).isoformat()
1010
- success_rate = self._compute_success_rate(source_runs, winner_runs)
1011
-
1012
- new_section = f"\n## ReasoningBank Items ({now})\n\n"
1013
- for item in items:
1014
- rb_id = item.get("rb_id") or self._next_rb_id()
1015
- item["rb_id"] = rb_id
1016
- applies_to = item.get("applies_to", [])
1017
- new_section += (
1018
- f"### {rb_id}: {item['title']}\n\n"
1019
- f"**Description:** {item['description']}\n\n"
1020
- f"**Content:** {item['content']}\n\n"
1021
- f"```yaml\n"
1022
- f"rb_id: \"{rb_id}\"\n"
1023
- f"title: \"{item['title']}\"\n"
1024
- f"description: \"{item['description']}\"\n"
1025
- f"task_type: \"{item.get('task_type', '')}\"\n"
1026
- f"subsystem: \"{subsystem}\"\n"
1027
- f"source_runs: {json.dumps(source_runs)}\n"
1028
- f"applies_to: {json.dumps(applies_to)}\n"
1029
- f"success_rate: {success_rate:.2f}\n"
1030
- f"status: active\n"
1031
- f"created_at: \"{now}\"\n"
1032
- f"```\n\n"
1033
- )
1034
-
1035
- if not existing:
1036
- owns_list = self._product_owns_paths(changed_paths)
1037
- owns = ", ".join(owns_list) if owns_list else "TBD"
1038
- header = (
1039
- f"---\nType: domain\nStatus: CURRENT\nScope: {subsystem}\n"
1040
- f"Owns-Paths: {owns}\nWatch-Paths:\n"
1041
- f"Source-Runs: {', '.join(source_runs)}\n"
1042
- f"Validated-At-Commit:\nLast-Validated: {now}\n"
1043
- f"Tags: reasoningbank, training-free-grpo\n---\n\n"
1044
- f"# {subsystem}\n\n"
1045
- f"Domain memory for `{subsystem}`.\n\n"
1046
- )
1047
- content = header + new_section
1048
- else:
1049
- split_marker = "## Router and Parent Refresh"
1050
- if split_marker in existing:
1051
- idx = existing.index(split_marker)
1052
- content = existing[:idx] + new_section + "\n" + existing[idx:]
1053
- else:
1054
- content = existing + "\n" + new_section
1055
- domain_file.write_text(content, encoding="utf-8")
1056
- return items
1057
-
1058
- def write_training_memory(
1059
- self,
1060
- task_type: str,
1061
- subsystem: str,
1062
- items: List[dict],
1063
- source_runs: List[str],
1064
- winner_runs: List[str],
1065
- ):
1066
- # Training fan-out reuses rb_ids already assigned; do not re-filter titles
1067
- # when items were already filtered for domain write. Still skip empty.
1068
- if not items:
1069
- return
1070
- prepared = []
1071
- for item in items:
1072
- cloned = dict(item)
1073
- if not cloned.get("rb_id"):
1074
- cloned["rb_id"] = self._next_rb_id()
1075
- prepared.append(cloned)
1076
- items = prepared
1077
- task_file = self.training_dir / f"{task_type}.md"
1078
- existing = task_file.read_text(encoding="utf-8") if task_file.exists() else ""
1079
- now = datetime.now(timezone.utc).isoformat()
1080
- success_rate = self._compute_success_rate(source_runs, winner_runs)
1081
- watch_paths = sorted({value for item in items for value in item.get("applies_to", []) if value})
1082
-
1083
- new_section = f"\n## Extracted Reasoning Items ({now})\n\n"
1084
- for item in items:
1085
- rb_id = item.get("rb_id") or self._next_rb_id()
1086
- applies_to = item.get("applies_to", [])
1087
- new_section += (
1088
- f"### {rb_id}: {item['title']}\n\n"
1089
- f"**Description:** {item['description']}\n\n"
1090
- f"**Content:** {item['content']}\n\n"
1091
- f"```yaml\n"
1092
- f"rb_id: \"{rb_id}\"\n"
1093
- f"title: \"{item['title']}\"\n"
1094
- f"description: \"{item['description']}\"\n"
1095
- f"task_type: \"{task_type}\"\n"
1096
- f"subsystem: \"{subsystem}\"\n"
1097
- f"source_runs: {json.dumps(source_runs)}\n"
1098
- f"applies_to: {json.dumps(applies_to)}\n"
1099
- f"success_rate: {success_rate:.2f}\n"
1100
- f"status: active\n"
1101
- f"created_at: \"{now}\"\n"
1102
- f"```\n\n"
1103
- )
1104
-
1105
- if not existing:
1106
- watch_lines = "\n".join(f"- {path}" for path in watch_paths) if watch_paths else "- TBD"
1107
- header = (
1108
- f"---\nType: training\nStatus: CURRENT\n"
1109
- f"Scope: {task_type}\n"
1110
- f"Owns-Paths:\n"
1111
- f"Watch-Paths:\n{watch_lines}\n"
1112
- f"Source-Runs: {', '.join(source_runs)}\n"
1113
- f"Validated-At-Commit:\n"
1114
- f"Last-Validated: {now}\n"
1115
- f"Tags: training, reasoningbank, training-free-grpo\n---\n\n"
1116
- f"# Training Memory: {task_type}\n\n"
1117
- f"Reasoning items extracted from recursive-mode runs for `{task_type}` tasks.\n\n"
1118
- )
1119
- content = header + new_section
1120
- else:
1121
- content = existing + "\n" + new_section
1122
- task_file.write_text(content, encoding="utf-8")
1123
-
1124
- def refresh_memory_index(self) -> None:
1125
- """Refresh MEMORY.md registry bullets for domains/ and training/ shards."""
1126
- if not self.memory_index.exists():
1127
- return
1128
- original = self.memory_index.read_text(encoding="utf-8")
1129
- bullets = []
1130
- for folder, label in ((self.domains_dir, "domains"), (self.training_dir, "training")):
1131
- if not folder.exists():
1132
- continue
1133
- for path in sorted(folder.glob("*.md")):
1134
- rel = f"{label}/{path.name}"
1135
- text = path.read_text(encoding="utf-8")
1136
- if "Status: CURRENT" not in text and "Status: SUSPECT" not in text:
1137
- continue
1138
- title_match = re.search(r'^#\s+(.+)$', text, re.M)
1139
- title = title_match.group(1).strip() if title_match else path.stem
1140
- source = ""
1141
- source_match = re.search(r'(?m)^Source-Runs:\s*(.+)$', text)
1142
- if source_match:
1143
- source = f" (Source-Runs: {source_match.group(1).strip()})"
1144
- bullets.append(f"- `{rel}` — {title}{source}")
1145
-
1146
- if not bullets:
1147
- return
1148
-
1149
- block = "## Training Extraction Registry\n\n" + "\n".join(bullets) + "\n"
1150
- marker_start = "<!-- RECURSIVE-TRAINING-REGISTRY:START -->"
1151
- marker_end = "<!-- RECURSIVE-TRAINING-REGISTRY:END -->"
1152
- wrapped = f"{marker_start}\n{block}{marker_end}"
1153
- if marker_start in original and marker_end in original:
1154
- pattern = re.compile(
1155
- re.escape(marker_start) + r".*?" + re.escape(marker_end),
1156
- re.DOTALL,
1157
- )
1158
- updated = pattern.sub(wrapped, original)
1159
- elif "## Registry" in original:
1160
- updated = original.replace("## Registry", f"## Registry\n\n{wrapped}\n", 1)
1161
- else:
1162
- updated = original.rstrip() + "\n\n" + wrapped + "\n"
1163
- self.memory_index.write_text(updated, encoding="utf-8")
1164
-
1165
- def load_all_items_flat(self) -> Dict[str, str]:
1166
- """Load all items as flat dict for tool-file generation."""
1167
- items = {}
1168
- for d in (self.domains_dir, self.training_dir):
1169
- if not d.exists():
1170
- continue
1171
- for f in d.glob("*.md"):
1172
- content = f.read_text(encoding="utf-8")
1173
- if "Status: CURRENT" not in content:
1174
- continue
1175
- for block in re.finditer(
1176
- r'### (RB-\d+):\s*(.+?)\n\n'
1177
- r'\*\*Description:\*\*\s*(.+?)\n\n'
1178
- r'\*\*Content:\*\*\s*(.+?)(?=\n\n```|\n### |\Z)',
1179
- content, re.DOTALL
1180
- ):
1181
- rb_id = block.group(1)
1182
- title = block.group(2).strip()
1183
- desc = block.group(3).strip()
1184
- content_text = block.group(4).strip()
1185
- items[rb_id] = f"**{title}**: {desc} {content_text}"
1186
- return items
1187
-
1188
-
1189
- # ---------------------------------------------------------------------------
1190
- # Main orchestrator
1191
- # ---------------------------------------------------------------------------
1192
-
1193
- async def train_repo(
1194
- repo_root: str,
1195
- incremental_run_id: Optional[str] = None,
1196
- winner_only_threshold: int = MIN_WINNER_ONLY_GROUP_SIZE,
1197
- ) -> int:
1198
- repo_path = Path(repo_root).resolve()
1199
- runs_dir = repo_path / ".recursive" / "run"
1200
- if not runs_dir.exists():
1201
- print(f"ERROR: No .recursive/run/ at {runs_dir}")
1202
- return 1
1203
-
1204
- print(f"Scanning {runs_dir} ...")
1205
- rollouts = parse_all_runs(runs_dir, require_phase8_locked=True)
1206
- if incremental_run_id:
1207
- before = len(rollouts)
1208
- rollouts = filter_rollouts_for_training(rollouts, incremental_run_id)
1209
- print(
1210
- f"Incremental filter for {incremental_run_id}: "
1211
- f"kept {len(rollouts)}/{before} rollouts in the target subsystem"
1212
- )
1213
- if not rollouts:
1214
- print(f"ERROR: Run '{incremental_run_id}' not found among Phase-8-locked training inputs.")
1215
- return 1
1216
- print(f"Loaded {len(rollouts)} rollouts from {len({r.run_id for r in rollouts})} runs")
1217
- if len(rollouts) < 2:
1218
- print("WARNING: < 2 rollouts. Results will be weak.")
1219
-
1220
- groups = group_by_subsystem(rollouts)
1221
- print(f"Formed {len(groups)} subsystem groups")
1222
- for sub, g in groups.items():
1223
- print(f" - {sub} ({len(g)} runs)")
1224
-
1225
- rb_memory = ReasoningBankMemory(repo_path)
1226
-
1227
- total_items = 0
1228
- processed = 0
1229
- skipped_insufficient = 0
1230
- extractor_failed = False
1231
-
1232
- for subsystem, group in groups.items():
1233
- print(f"\nSubsystem: {subsystem} ({len(group)} rollouts)")
1234
-
1235
- mode, winners, losers = classify_group(group, winner_only_threshold)
1236
-
1237
- if mode == "insufficient":
1238
- w = sum(1 for r in group if r.is_complete_winner)
1239
- l = len(group) - w
1240
- print(f" Skipping — insufficient signal ({w} wins, {l} losses, threshold={winner_only_threshold})")
1241
- skipped_insufficient += 1
1242
- continue
1243
-
1244
- print(f" Mode: {mode} ({len(winners)} wins, {len(losers)} losses)")
1245
-
1246
- # Load existing items for deduplication hints in the prompt
1247
- existing_items = []
1248
- for d in (rb_memory.domains_dir, rb_memory.training_dir):
1249
- for f in d.glob("*.md"):
1250
- content = f.read_text(encoding="utf-8")
1251
- for block in re.finditer(
1252
- r'### (RB-\d+):\s*(.+?)\n\n\*\*Description:\*\*\s*(.+?)\n\n\*\*Content:\*\*\s*(.+?)(?=\n\n```|\n### |\Z)',
1253
- content, re.DOTALL
1254
- ):
1255
- existing_items.append({
1256
- "rb_id": block.group(1),
1257
- "title": block.group(2).strip(),
1258
- "description": block.group(3).strip(),
1259
- "content": block.group(4).strip(),
1260
- })
1261
-
1262
- print(f" Extracting ReasoningBank items ...")
1263
- try:
1264
- items = await extract_reasoningbank_items(
1265
- mode, subsystem, winners, losers, existing_items
1266
- )
1267
- except ExtractionUnavailableError as exc:
1268
- print(f"\nTraining failed: {exc}")
1269
- extractor_failed = True
1270
- break
1271
-
1272
- items = rb_memory.filter_new_items(items)
1273
- if items:
1274
- items_by_task_type: Dict[str, List[dict]] = defaultdict(list)
1275
-
1276
- source_runs = list({r.run_id for r in group})
1277
- winner_runs = list({r.run_id for r in group if r.is_complete_winner})
1278
- changed_paths = list({f for r in group for f in r.changed_files})
1279
-
1280
- written = rb_memory.write_domain_memory(
1281
- subsystem, items, source_runs, winner_runs, changed_paths
1282
- )
1283
- if not written:
1284
- print(f" No new items after dedup")
1285
- continue
1286
-
1287
- for item in written:
1288
- tt = item.get("task_type", "general")
1289
- items_by_task_type[tt].append(item)
1290
-
1291
- for tt, task_items in items_by_task_type.items():
1292
- rb_memory.write_training_memory(tt, subsystem, task_items, source_runs, winner_runs)
1293
-
1294
- print(f" Added {len(written)} items across {len(items_by_task_type)} task types:")
1295
- for tt, task_items in sorted(items_by_task_type.items()):
1296
- print(f" [{tt}] ({len(task_items)} items)")
1297
- for item in task_items:
1298
- print(f" - [{item.get('rb_id', 'NEW')}] {item['title']}: {item['description'][:60]}...")
1299
- total_items += len(written)
1300
- processed += 1
1301
- else:
1302
- print(f" No items extracted")
1303
-
1304
- if extractor_failed:
1305
- print(f"\nTraining aborted: extractor unavailable ({processed} groups processed, {total_items} items extracted)")
1306
- return 2
1307
-
1308
- if total_items > 0:
1309
- rb_memory.refresh_memory_index()
1310
-
1311
- print(f"\nTraining complete: {processed} groups processed, {total_items} items extracted")
1312
- if total_items == 0:
1313
- if skipped_insufficient == len(groups) or processed == 0:
1314
- print("No training items written (insufficient groups or empty extraction).")
1315
- return 3
1316
- return 0
1317
-
1318
-
1319
- def main():
1320
- parser = argparse.ArgumentParser(description="Repository-local Training-Free GRPO + ReasoningBank")
1321
- parser.add_argument("--repo-root", type=str, required=True)
1322
- parser.add_argument("--incremental", action="store_true",
1323
- help="Limit extraction to the subsystem of --run-id, keeping peer runs in that subsystem")
1324
- parser.add_argument("--run-id", type=str,
1325
- help="Target run id (required with --incremental)")
1326
- parser.add_argument("--winner-only-threshold", type=int, default=MIN_WINNER_ONLY_GROUP_SIZE,
1327
- help="Minimum winners for winner-only extraction when no losers exist (default: 2)")
1328
- args = parser.parse_args()
1329
-
1330
- if args.incremental and not args.run_id:
1331
- print("ERROR: --incremental requires --run-id")
1332
- sys.exit(1)
1333
- if args.run_id and not args.incremental:
1334
- print("NOTE: --run-id without --incremental is ignored; passing all Phase-8-locked runs.")
1335
-
1336
- incremental_id = args.run_id if args.incremental else None
1337
- raise SystemExit(asyncio.run(train_repo(args.repo_root, incremental_id, args.winner_only_threshold)))
1338
-
1339
-
1340
- if __name__ == "__main__":
1341
- main()