@try-works/dsh-recursive-mode 0.1.2 → 0.1.4
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.
- package/cordis.patch.yml +15 -10
- package/lib/bootstrap.d.ts +9 -2
- package/lib/client/apply-guard.d.ts +14 -0
- package/lib/client/board.d.ts +5 -4
- package/lib/client/contract.d.ts +57 -24
- package/lib/client/host-api.d.ts +26 -0
- package/lib/client/index.d.ts +16 -9
- package/lib/client/inspector.d.ts +3 -3
- package/lib/client/slots.d.ts +4 -6
- package/lib/client/strip.d.ts +4 -3
- package/lib/client/use-live.d.ts +9 -0
- package/lib/client.js +252 -174
- package/lib/index.d.ts +11 -11
- package/lib/index.js +972 -4504
- package/lib/init-templates.d.ts +41 -0
- package/lib/live-route.d.ts +69 -0
- package/lib/phase-rules.d.ts +34 -0
- package/lib/runtime.d.ts +24 -17
- package/lib/snapshot.d.ts +11 -0
- package/package.json +4 -3
- package/preset/recursive/agent.cordis.yml +7 -4
- package/preset/recursive/preset.yml +1 -1
- package/references/agents-block.md +95 -0
- package/references/artifact-template.md +2574 -0
- package/references/bodies/claude.md +7 -0
- package/references/bodies/codex-agents.md +95 -0
- package/references/bodies/copilot.md +7 -0
- package/references/bodies/cursorrules.md +7 -0
- package/references/bodies/decisions.md +5 -0
- package/references/bodies/delegated-verification.md +49 -0
- package/references/bodies/memory-router.md +45 -0
- package/references/bodies/phase8-skill-memory.md +48 -0
- package/references/bodies/plans-bridge.md +43 -0
- package/references/bodies/recursive-agents-router.md +64 -0
- package/references/bodies/skill-discovery.md +51 -0
- package/references/bodies/skill-memory-router.md +42 -0
- package/references/bodies/state.md +5 -0
- package/references/bootstrap/RECURSIVE.md +2362 -0
- package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
- package/references/scripts/lint-recursive-run.ps1 +25 -0
- package/references/scripts/lint-recursive-run.py +2870 -0
- package/references/scripts/recursive-closeout.ps1 +38 -0
- package/references/scripts/recursive-closeout.py +541 -0
- package/references/scripts/recursive-init.ps1 +515 -0
- package/references/scripts/recursive-init.py +356 -0
- package/references/scripts/recursive-lock.ps1 +26 -0
- package/references/scripts/recursive-lock.py +302 -0
- package/references/scripts/recursive-review-bundle.ps1 +58 -0
- package/references/scripts/recursive-review-bundle.py +503 -0
- package/references/scripts/recursive-router-cli-configure.ps1 +2 -0
- package/references/scripts/recursive-router-cli-configure.py +9 -0
- package/references/scripts/recursive-router-cli-init.ps1 +2 -0
- package/references/scripts/recursive-router-cli-init.py +9 -0
- package/references/scripts/recursive-router-cli-invoke.ps1 +2 -0
- package/references/scripts/recursive-router-cli-invoke.py +9 -0
- package/references/scripts/recursive-router-cli-probe.ps1 +2 -0
- package/references/scripts/recursive-router-cli-probe.py +9 -0
- package/references/scripts/recursive-router-cli-resolve.ps1 +2 -0
- package/references/scripts/recursive-router-cli-resolve.py +9 -0
- package/references/scripts/recursive-router-cli-validate.ps1 +2 -0
- package/references/scripts/recursive-router-cli-validate.py +9 -0
- package/references/scripts/recursive-router-configure.ps1 +27 -0
- package/references/scripts/recursive-router-configure.py +74 -0
- package/references/scripts/recursive-router-init.ps1 +17 -0
- package/references/scripts/recursive-router-init.py +29 -0
- package/references/scripts/recursive-router-invoke.ps1 +47 -0
- package/references/scripts/recursive-router-invoke.py +103 -0
- package/references/scripts/recursive-router-probe.ps1 +25 -0
- package/references/scripts/recursive-router-probe.py +44 -0
- package/references/scripts/recursive-router-resolve.ps1 +26 -0
- package/references/scripts/recursive-router-resolve.py +46 -0
- package/references/scripts/recursive-router-validate.ps1 +17 -0
- package/references/scripts/recursive-router-validate.py +27 -0
- package/references/scripts/recursive-status.ps1 +23 -0
- package/references/scripts/recursive-status.py +2124 -0
- package/references/scripts/recursive-subagent-action.ps1 +98 -0
- package/references/scripts/recursive-subagent-action.py +197 -0
- package/references/scripts/recursive-training-extract.ps1 +23 -0
- package/references/scripts/recursive-training-extract.py +99 -0
- package/references/scripts/recursive-training-grpo.ps1 +52 -0
- package/references/scripts/recursive-training-grpo.py +1341 -0
- package/references/scripts/recursive-training-loader.ps1 +78 -0
- package/references/scripts/recursive-training-loader.py +558 -0
- package/references/scripts/recursive-training-mcp.ps1 +29 -0
- package/references/scripts/recursive-training-mcp.py +261 -0
- package/references/scripts/recursive-training-phase8-trigger.ps1 +55 -0
- package/references/scripts/recursive-training-phase8-trigger.py +137 -0
- package/references/scripts/recursive-training-sync.ps1 +29 -0
- package/references/scripts/recursive-training-sync.py +183 -0
- package/references/scripts/recursive_phase_rules.py +367 -0
- package/references/scripts/recursive_router_cli_lib.py +2 -0
- package/references/scripts/recursive_router_lib.py +2282 -0
- package/references/scripts/verify-locks.ps1 +25 -0
- package/references/scripts/verify-locks.py +353 -0
- package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
- package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
- package/scripts/install-preset.cmd +7 -0
- package/scripts/install-preset.js +101 -0
- package/scripts/lint-recursive-run.py +7 -5
- package/scripts/test-recursive-mode-smoke.ts +29 -28
- package/src/bootstrap.ts +253 -39
- package/src/client/apply-guard.ts +23 -0
- package/src/client/board.tsx +0 -0
- package/src/client/contract.ts +72 -24
- package/src/client/host-api.ts +90 -0
- package/src/client/index.ts +22 -11
- package/src/client/inspector.tsx +7 -9
- package/src/client/slots.ts +70 -26
- package/src/client/strip.tsx +9 -9
- package/src/client/use-live.ts +52 -0
- package/src/index.ts +89 -146
- package/src/init-templates.ts +276 -0
- package/src/live-route.ts +163 -0
- package/src/phase-rules.ts +262 -0
- package/src/policy.ts +28 -4
- package/src/runtime.ts +129 -77
- package/src/snapshot.ts +104 -0
- package/lib/client/node.d.ts +0 -71
- package/lib/events.d.ts +0 -173
- package/lib/projection.d.ts +0 -29
- package/src/client/node.ts +0 -156
- package/src/events.ts +0 -173
- package/src/projection.ts +0 -237
|
@@ -0,0 +1,1341 @@
|
|
|
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()
|