@giamme/forge 0.0.0-stage → 0.9.0
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/CHANGELOG.md +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
"""Readers that turn forge's run artifacts into dataset events.
|
|
2
|
+
|
|
3
|
+
Live capture (capture.py) and the importer share these, so a run captured live and the
|
|
4
|
+
same run imported later produce the same rids and the same shapes, and INSERT OR IGNORE
|
|
5
|
+
keeps whichever arrived first.
|
|
6
|
+
|
|
7
|
+
Every reader takes what it needs from IMMUTABLE places whenever one exists: the
|
|
8
|
+
per-execution `attempts/<role>-<uuid>/` dir that forge-runtime.py fills at the end of
|
|
9
|
+
every dispatch. Task-level files (changes.diff, qa.last, status) are mutable and outlive
|
|
10
|
+
the execution that wrote them -- a dwarf that fails returns before changes.diff is
|
|
11
|
+
refreshed, so a naive read would credit the previous attempt's diff to the failed one.
|
|
12
|
+
begin() therefore snapshots each task file's (inode, mtime, size), and finish() only
|
|
13
|
+
attributes a file to this execution when that signature changed.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import importlib.util
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import SCRIPTS, put_blob, put_file, sha256
|
|
27
|
+
|
|
28
|
+
DISPATCH_ROLES = ('dwarf', 'qa', 'planner')
|
|
29
|
+
PIPELINE_ROLES = ('task', 'solo', 'verification', 'parallel')
|
|
30
|
+
TASKS_COLS = ('id', 'deps', 'difficulty', 'files', 'dwarf', 'qa', 'title')
|
|
31
|
+
STRUCTURED = ('codex', 'claude', 'openclaude', 'grok', 'opencode') # as forge-runtime.py's: .last is the message, not the log
|
|
32
|
+
# Plan files that change what a plan decides, added to its rid only when present (see plan_event).
|
|
33
|
+
PLAN_RID_LATER = ('decompose_level', 'shadow_qa', 'shadow_rate', 'explore')
|
|
34
|
+
|
|
35
|
+
# Files a task or solo execution may write. begin() snapshots their signatures so
|
|
36
|
+
# finish() can tell this execution's outputs from what an earlier one left behind.
|
|
37
|
+
WATCHED = (
|
|
38
|
+
'status', 'attempt', 'resume', 'noretry', 'changes.diff', 'changes.prev.diff', 'committed.diff',
|
|
39
|
+
'drift.txt', 'guard.txt', 'qa.last', 'dwarf.last', 'qa.gate', 'known_issues.md',
|
|
40
|
+
'retry_findings.md', 'reviewed.commit', 'base_ref', 'infra.txt', 'dwarf.memory', 'qa.memory',
|
|
41
|
+
'baseline.capsule', 'capsule.md', 'subset.jev.txt', 'qa.effort.jev', 'qa.jev.json', 'jev.json',
|
|
42
|
+
'verdict', 'existing.diff', 'start.tree', 'reviewed.tree', 'context.md',
|
|
43
|
+
'verification.status', 'verification.exit', 'verification.command', 'verification.log',
|
|
44
|
+
'verification.retry.log', 'verification.flaky', 'verification.coverage.txt',
|
|
45
|
+
'verification.jev.json', 'verification.runs.tsv', 'verification.commit', 'verification.fingerprint',
|
|
46
|
+
'results.tsv',
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_VERDICT = re.compile(r'^FORGE_VERDICT: (PASS|FAIL)([ \t]+(—|–|-)( .*)?)?[ \t]*$')
|
|
50
|
+
_LEARNING = re.compile(r'^\s*FORGE_LEARNING:\s*(.+)$')
|
|
51
|
+
|
|
52
|
+
_contract = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def contract():
|
|
56
|
+
"""forge-contract.py, loaded by path: its file name is not importable."""
|
|
57
|
+
global _contract
|
|
58
|
+
if _contract is None:
|
|
59
|
+
spec = importlib.util.spec_from_file_location('forge_contract', SCRIPTS / 'forge-contract.py')
|
|
60
|
+
module = importlib.util.module_from_spec(spec)
|
|
61
|
+
spec.loader.exec_module(module)
|
|
62
|
+
_contract = module
|
|
63
|
+
return _contract
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- small readers -------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def text(path: Path, limit: int | None = None) -> str | None:
|
|
69
|
+
try:
|
|
70
|
+
data = Path(path).read_bytes()
|
|
71
|
+
except OSError:
|
|
72
|
+
return None
|
|
73
|
+
if limit is not None:
|
|
74
|
+
data = data[:limit]
|
|
75
|
+
return data.decode('utf-8', 'replace')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def first_line(path: Path) -> str | None:
|
|
79
|
+
value = text(path, 4096)
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
value = value.strip().splitlines()
|
|
83
|
+
return value[0].strip() if value else ''
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def kv(path: Path) -> dict:
|
|
87
|
+
"""key=value lines (`.resolved`, `.infra`, `infra.txt`); a repeated key keeps the last."""
|
|
88
|
+
result = {}
|
|
89
|
+
for line in (text(path) or '').splitlines():
|
|
90
|
+
if '=' in line:
|
|
91
|
+
key, _, value = line.partition('=')
|
|
92
|
+
result[key.strip()] = value.strip()
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_json(path: Path) -> dict | None:
|
|
97
|
+
try:
|
|
98
|
+
value = json.loads(Path(path).read_text())
|
|
99
|
+
except (OSError, ValueError):
|
|
100
|
+
return None
|
|
101
|
+
return value if isinstance(value, dict) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_json(path: Path, value: dict) -> None:
|
|
105
|
+
temporary = path.with_name('.%s.%d' % (path.name, os.getpid()))
|
|
106
|
+
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n')
|
|
107
|
+
temporary.replace(path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def as_int(value) -> int | None:
|
|
111
|
+
try:
|
|
112
|
+
return int(str(value).strip())
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def signature(path: Path) -> list | None:
|
|
118
|
+
try:
|
|
119
|
+
st = path.stat()
|
|
120
|
+
except OSError:
|
|
121
|
+
return None
|
|
122
|
+
return [st.st_ino, st.st_mtime_ns, st.st_size]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def snapshot(root: Path) -> dict:
|
|
126
|
+
return {name: signature(root / name) for name in WATCHED if (root / name).exists()}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def produced(root: Path, before: dict | None, name: str) -> bool:
|
|
130
|
+
"""Whether this execution wrote `name`. Unknown history (no snapshot) means no."""
|
|
131
|
+
now = signature(root / name)
|
|
132
|
+
if now is None or before is None:
|
|
133
|
+
return False
|
|
134
|
+
return before.get(name) != now
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def verdict_of(message: str | None) -> str:
|
|
138
|
+
"""forge_verdict (forge-artifact.sh), in Python: only a standalone last line counts."""
|
|
139
|
+
lines = [line for line in (message or '').splitlines() if line.strip()]
|
|
140
|
+
if not lines:
|
|
141
|
+
return 'UNKNOWN'
|
|
142
|
+
match = _VERDICT.match(lines[-1].rstrip('\r'))
|
|
143
|
+
return match.group(1) if match else 'UNKNOWN'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def learnings(message: str | None) -> list:
|
|
147
|
+
out = []
|
|
148
|
+
for line in (message or '').splitlines():
|
|
149
|
+
match = _LEARNING.match(line)
|
|
150
|
+
if match:
|
|
151
|
+
out.append(match.group(1).strip()[:2000])
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def findings(message: str | None) -> list:
|
|
156
|
+
return parse_review(message)[0]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_review(message: str | None) -> tuple[list, bool]:
|
|
160
|
+
"""(findings, parsed): parsed is False when the contract's parser raised."""
|
|
161
|
+
if not message:
|
|
162
|
+
return [], True
|
|
163
|
+
try:
|
|
164
|
+
parsed = contract().parse_findings(message)
|
|
165
|
+
except Exception:
|
|
166
|
+
return [], False
|
|
167
|
+
return [dict(status=f.status, severity=f.severity, located=f.located, line=f.line,
|
|
168
|
+
head=f.text.splitlines()[0][:300] if f.text else '') for f in parsed], True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def review_completeness(verdict: str | None, found: list, parsed: bool = True) -> str | None:
|
|
172
|
+
"""How fully a review's findings can be read, so a graded label is never a guess.
|
|
173
|
+
|
|
174
|
+
clean a PASS with no finding blocks: worst severity is none
|
|
175
|
+
graded every CONFIRMED finding carries P0-P3 and none is UNSTATED
|
|
176
|
+
ungraded a CONFIRMED finding without a severity, an UNSTATED one, or a FAIL whose
|
|
177
|
+
reasons are not CONFIRMED findings
|
|
178
|
+
unparseable no verdict line, or the parser failed: an empty list here means nothing
|
|
179
|
+
"""
|
|
180
|
+
if verdict not in ('PASS', 'FAIL') or not parsed:
|
|
181
|
+
return 'unparseable'
|
|
182
|
+
if not found:
|
|
183
|
+
return 'clean' if verdict == 'PASS' else 'ungraded'
|
|
184
|
+
confirmed = [f for f in found if f.get('status') == 'CONFIRMED']
|
|
185
|
+
if any(f.get('status') == 'UNSTATED' for f in found) or any(f.get('severity') is None for f in confirmed):
|
|
186
|
+
return 'ungraded'
|
|
187
|
+
if verdict == 'FAIL' and not confirmed:
|
|
188
|
+
return 'ungraded'
|
|
189
|
+
return 'graded'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def jsonl(path: Path) -> list:
|
|
193
|
+
"""The JSON objects of a line-per-record file; a torn or foreign line is skipped, not fatal."""
|
|
194
|
+
out = []
|
|
195
|
+
for line in (text(path) or '').splitlines():
|
|
196
|
+
try:
|
|
197
|
+
value = json.loads(line)
|
|
198
|
+
except ValueError:
|
|
199
|
+
continue
|
|
200
|
+
if isinstance(value, dict):
|
|
201
|
+
out.append(value)
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def tasks_rows(path: Path) -> dict:
|
|
206
|
+
rows = {}
|
|
207
|
+
for line in (text(path) or '').splitlines():
|
|
208
|
+
if not line.strip() or line.startswith('#'):
|
|
209
|
+
continue
|
|
210
|
+
cells = line.split('\t')
|
|
211
|
+
row = {name: (cells[i] if i < len(cells) else '') for i, name in enumerate(TASKS_COLS)}
|
|
212
|
+
if row['id']:
|
|
213
|
+
rows[row['id']] = row
|
|
214
|
+
return rows
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def split_list(value: str | None) -> list:
|
|
218
|
+
if not value or value == '-':
|
|
219
|
+
return []
|
|
220
|
+
return [item.strip() for item in value.split(',') if item.strip()]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def diff_summary(diff: str | None) -> dict:
|
|
224
|
+
"""Changed files, per-file +/- counts, and a key that survives rebasing.
|
|
225
|
+
|
|
226
|
+
The patch key hashes each file's added and removed lines (sorted, stripped), not the
|
|
227
|
+
hunks' positions, so the same change landing on a moved base still matches.
|
|
228
|
+
"""
|
|
229
|
+
files, current, binary = {}, None, set()
|
|
230
|
+
for line in (diff or '').splitlines():
|
|
231
|
+
if line.startswith('diff --git '):
|
|
232
|
+
match = re.match(r'diff --git a/(.*) b/(.*)$', line)
|
|
233
|
+
current = match.group(2) if match else line[11:]
|
|
234
|
+
files.setdefault(current, [0, 0, []])
|
|
235
|
+
elif current is None:
|
|
236
|
+
continue
|
|
237
|
+
elif line.startswith('Binary files') or line.startswith('GIT binary patch'):
|
|
238
|
+
binary.add(current)
|
|
239
|
+
elif line.startswith('+') and not line.startswith('+++'):
|
|
240
|
+
files[current][0] += 1
|
|
241
|
+
files[current][2].append('+' + line[1:].strip())
|
|
242
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
243
|
+
files[current][1] += 1
|
|
244
|
+
files[current][2].append('-' + line[1:].strip())
|
|
245
|
+
key = hashlib.sha256()
|
|
246
|
+
for name in sorted(files):
|
|
247
|
+
key.update(name.encode() + b'\0')
|
|
248
|
+
for changed in sorted(files[name][2]):
|
|
249
|
+
key.update(changed.encode('utf-8', 'replace') + b'\n')
|
|
250
|
+
return dict(changed_files=sorted(files),
|
|
251
|
+
numstat={name: [v[0], v[1]] for name, v in sorted(files.items())},
|
|
252
|
+
binary=sorted(binary), patch_key=key.hexdigest()[:24] if files else None)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def blobs(**refs) -> dict:
|
|
256
|
+
return {name: ref for name, ref in refs.items() if ref}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --- where an execution belongs ------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def locate(root: str | Path, role: str | None = None) -> dict:
|
|
262
|
+
"""Which run, mode and task a dispatch or pipeline root belongs to, from the path alone.
|
|
263
|
+
|
|
264
|
+
Env vars cannot carry this reliably (the orchestrator dispatches the planner by hand,
|
|
265
|
+
Fractal steps run under tmux, and the importer has no env at all), so live capture
|
|
266
|
+
and import both derive it from where forge put the files.
|
|
267
|
+
"""
|
|
268
|
+
p = Path(root).resolve()
|
|
269
|
+
parts = p.parts
|
|
270
|
+
ctx = dict(mode='adhoc', scope='run', run_dir=p, plan=None, task=None, repo=None,
|
|
271
|
+
run_id=p.name, link=None)
|
|
272
|
+
if 'steps' in parts and 'nodes' in parts:
|
|
273
|
+
steps = len(parts) - 1 - parts[::-1].index('steps')
|
|
274
|
+
nodes = len(parts) - 1 - parts[::-1].index('nodes')
|
|
275
|
+
task_dir = Path(*parts[:nodes])
|
|
276
|
+
run_dir = task_dir.parent.parent if task_dir.parent.name == 'tasks' else task_dir
|
|
277
|
+
request = read_json(task_dir / 'request.json') or {}
|
|
278
|
+
ctx.update(mode='fractal', scope='step', run_dir=run_dir, run_id=run_dir.name,
|
|
279
|
+
task='%s/%s' % (task_dir.name, parts[nodes + 1]) if nodes + 1 < steps else task_dir.name,
|
|
280
|
+
repo=request.get('repo') or request.get('source'), link=request.get('output'))
|
|
281
|
+
return ctx
|
|
282
|
+
plan = None
|
|
283
|
+
if p.parent.parent.name == 'shadow' and (p.parent.parent.parent / 'tasks.tsv').exists():
|
|
284
|
+
# <plan>/shadow/<task>/<attempt>-<k>: a shadow review (forge-shadow.py) of that task.
|
|
285
|
+
plan, ctx['task'], ctx['scope'], ctx['shadow'] = p.parent.parent.parent, p.parent.name, 'task', True
|
|
286
|
+
elif p.parent.name == 'tasks' and (p.parent.parent / 'tasks.tsv').exists():
|
|
287
|
+
plan, ctx['task'], ctx['scope'] = p.parent.parent, p.name, 'task'
|
|
288
|
+
elif p.name.startswith('final-review-') and (p.parent / 'tasks.tsv').exists():
|
|
289
|
+
plan, ctx['scope'] = p.parent, 'final_review'
|
|
290
|
+
elif p.name == 'final-review' and p.parent.name.startswith('integrate-'):
|
|
291
|
+
plan, ctx['scope'] = p.parent.parent, 'final_review'
|
|
292
|
+
elif p.name.startswith('integrate-') and (p.parent / 'tasks.tsv').exists():
|
|
293
|
+
plan, ctx['scope'] = p.parent, 'integrate'
|
|
294
|
+
elif (p / 'tasks.tsv').exists() or (p / 'run_id').exists():
|
|
295
|
+
plan, ctx['scope'] = p, 'plan'
|
|
296
|
+
if plan is not None:
|
|
297
|
+
ctx.update(mode='parallel', run_dir=plan, plan=plan,
|
|
298
|
+
repo=first_line(plan / 'repo'), run_id=first_line(plan / 'run_id') or plan.name)
|
|
299
|
+
return ctx
|
|
300
|
+
if (p / 'prompt.md').exists() and role != 'planner':
|
|
301
|
+
ctx.update(mode='solo', repo=first_line(p / 'repo'))
|
|
302
|
+
elif role == 'planner':
|
|
303
|
+
ctx['mode'] = 'planning'
|
|
304
|
+
if not ctx['repo']:
|
|
305
|
+
for name in ('dwarf.resolved', 'planner.resolved'):
|
|
306
|
+
repo = kv(p / name).get('repo')
|
|
307
|
+
if repo:
|
|
308
|
+
ctx['repo'] = repo
|
|
309
|
+
break
|
|
310
|
+
return ctx
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def legacy_uid(run_dir: Path) -> str:
|
|
314
|
+
return legacy_uid_for(run_dir, run_dir)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def legacy_uid_for(run_dir: Path, original: Path) -> str:
|
|
318
|
+
"""A stable id for a run captured before dataset.uid existed.
|
|
319
|
+
|
|
320
|
+
`original` is where the run lived when it ran: an archive extracted elsewhere must
|
|
321
|
+
get the same id as the dir it was made from. The earliest start is part of the key
|
|
322
|
+
because /tmp plan paths get reused by later, unrelated runs.
|
|
323
|
+
"""
|
|
324
|
+
starts = []
|
|
325
|
+
for metrics in run_dir.glob('**/attempts/*/metrics.json'):
|
|
326
|
+
if 'review-' in str(metrics.parent.parent.name):
|
|
327
|
+
continue
|
|
328
|
+
value = (read_json(metrics) or {}).get('wall_started')
|
|
329
|
+
if isinstance(value, (int, float)):
|
|
330
|
+
starts.append(value)
|
|
331
|
+
earliest = '%.0f' % min(starts) if starts else ''
|
|
332
|
+
return 'legacy-' + sha256(('%s\0%s\0%s' % (original, first_line(run_dir / 'run_id') or run_dir.name,
|
|
333
|
+
earliest)).encode())[:24]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def run_uid(run_dir: Path, create: bool) -> str:
|
|
337
|
+
path = Path(run_dir) / 'dataset.uid'
|
|
338
|
+
value = first_line(path)
|
|
339
|
+
if value:
|
|
340
|
+
return value
|
|
341
|
+
if create:
|
|
342
|
+
from . import create_once
|
|
343
|
+
import uuid
|
|
344
|
+
return create_once(path, uuid.uuid4().hex)
|
|
345
|
+
return legacy_uid(Path(run_dir))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# --- dispatch: one model call ----------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
def dispatch_event(attempt: Path, root: Path, role: str, harness: str, rc, prov: dict, src: str = 'live') -> dict:
|
|
351
|
+
metrics = read_json(attempt / 'metrics.json') or {}
|
|
352
|
+
resolved = kv(attempt / (role + '.resolved'))
|
|
353
|
+
infra = kv(attempt / (role + '.infra'))
|
|
354
|
+
harness = harness or metrics.get('harness') or resolved.get('harness')
|
|
355
|
+
message = text(attempt / (role + '.last'))
|
|
356
|
+
final_source = 'structured' if harness in STRUCTURED else 'log_copy'
|
|
357
|
+
delivery = resolved.get('delivery') or 'custom'
|
|
358
|
+
orphans = text(attempt / (role + '.orphans'))
|
|
359
|
+
effort = resolved.get('effort')
|
|
360
|
+
verdict = verdict_of(message) if message is not None else None
|
|
361
|
+
found, parsed = parse_review(message) if role == 'qa' else ([], True)
|
|
362
|
+
shadow = prov.get('shadow')
|
|
363
|
+
data = dict(
|
|
364
|
+
# A shadow review keeps the qa files and contract but is never a task's review, so it
|
|
365
|
+
# gets its own role: nothing that reads qa dispatches mistakes it for the one that decided.
|
|
366
|
+
role='qa_shadow' if shadow and role == 'qa' else role, alias=resolved.get('alias'), harness=harness, model=resolved.get('model'),
|
|
367
|
+
effort=None if not effort or effort.startswith('<') else effort,
|
|
368
|
+
yolo=resolved.get('yolo') == 'ON', passthrough=resolved.get('passthrough') == 'yes',
|
|
369
|
+
clamped=resolved.get('clamped'), timeout=as_int(resolved.get('timeout')),
|
|
370
|
+
timeout_source=resolved.get('timeout_source'), delivery=delivery,
|
|
371
|
+
duration_s=as_int(resolved.get('duration_s')), exit=as_int(rc), exit_code=metrics.get('exit_code'),
|
|
372
|
+
elapsed_s=metrics.get('elapsed_s'), wall_started=metrics.get('wall_started'),
|
|
373
|
+
wall_ended=prov.get('wall_ended'), timed_out=(attempt / (role + '.timedout')).exists(),
|
|
374
|
+
infra=infra or None, orphans=len((orphans or '').splitlines()) or None,
|
|
375
|
+
tokens=dict(input=metrics.get('input_tokens'), cached_input=metrics.get('cached_input_tokens'),
|
|
376
|
+
output=metrics.get('output_tokens')),
|
|
377
|
+
prompt_bytes=metrics.get('prompt_bytes'), final_source=final_source,
|
|
378
|
+
verdict=verdict, findings=found, learnings=learnings(message),
|
|
379
|
+
findings_parse=review_completeness(verdict, found, parsed) if role == 'qa' else None,
|
|
380
|
+
root=str(root), scope=prov.get('scope'), trigger=prov.get('trigger'),
|
|
381
|
+
**({'shadow': shadow} if shadow else {}),
|
|
382
|
+
)
|
|
383
|
+
prompt = None if delivery == 'native_review' else put_file(attempt / (role + '.prompt'), 'prompt')
|
|
384
|
+
last = put_file(attempt / (role + '.last'), 'message' if final_source == 'structured' else 'log')
|
|
385
|
+
return dict(rid=attempt.name, kind='dispatch', ts=prov.get('wall_ended') or time.time(), src=src,
|
|
386
|
+
run=prov.get('run'), run_id=prov.get('run_id'), mode=prov.get('mode'),
|
|
387
|
+
task=prov.get('task'), attempt=prov.get('attempt_now'), parent=prov.get('parent'),
|
|
388
|
+
inv=prov.get('inv'), data=dict(data, blobs=blobs(prompt=prompt, message=last)))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# --- pipeline: a task attempt, a solo run, a verification, a whole run ----------------
|
|
392
|
+
|
|
393
|
+
def attempt_number(prov: dict, end: int | None, status: str | None, infra: dict) -> tuple[int | None, dict]:
|
|
394
|
+
"""Which attempt this execution was, and how it was spent.
|
|
395
|
+
|
|
396
|
+
The counter is bumped after begin(), refunded on a dwarf-stage infra stop, and left
|
|
397
|
+
alone by a QA-only resume, so the number alone is ambiguous.
|
|
398
|
+
"""
|
|
399
|
+
start = prov.get('attempt_start')
|
|
400
|
+
flags = dict(resumed=False, refunded=False)
|
|
401
|
+
if start is None or end is None:
|
|
402
|
+
return end, flags
|
|
403
|
+
if end > start:
|
|
404
|
+
return end, flags
|
|
405
|
+
if status == 'INFRA' and infra.get('stage') == 'dwarf':
|
|
406
|
+
flags['refunded'] = True
|
|
407
|
+
return start + 1, flags
|
|
408
|
+
flags['resumed'] = bool(prov.get('resume'))
|
|
409
|
+
return start, flags
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def children(root: Path, parent: str) -> list:
|
|
413
|
+
found = []
|
|
414
|
+
for path in sorted(Path(root).glob('attempts/*/dataset.json')):
|
|
415
|
+
if (read_json(path) or {}).get('parent') == parent:
|
|
416
|
+
found.append(path.parent.name)
|
|
417
|
+
return found
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def task_event(attempt: Path, tdir: Path, rc, prov: dict, src: str = 'live') -> dict:
|
|
421
|
+
before = prov.get('files')
|
|
422
|
+
made = lambda name: produced(tdir, before, name)
|
|
423
|
+
status = first_line(tdir / 'status')
|
|
424
|
+
infra = kv(tdir / 'infra.txt') if made('infra.txt') else {}
|
|
425
|
+
end = as_int(first_line(tdir / 'attempt'))
|
|
426
|
+
number, flags = attempt_number(prov, end, status, infra)
|
|
427
|
+
plan = tdir.parent.parent
|
|
428
|
+
row = tasks_rows(plan / 'tasks.tsv').get(tdir.name, {})
|
|
429
|
+
diff = text(tdir / 'changes.diff') if made('changes.diff') else None
|
|
430
|
+
gate = first_line(tdir / 'qa.gate') if made('qa.gate') else None
|
|
431
|
+
data = dict(
|
|
432
|
+
status=status, exit=as_int(rc), attempt_start=prov.get('attempt_start'), attempt_end=end,
|
|
433
|
+
trigger=prov.get('trigger'), **flags,
|
|
434
|
+
dwarf=row.get('dwarf'), qa=row.get('qa'), difficulty=row.get('difficulty'),
|
|
435
|
+
title=row.get('title'), deps=split_list(row.get('deps')), declared_files=split_list(row.get('files')),
|
|
436
|
+
base_ref=first_line(tdir / 'base_ref'),
|
|
437
|
+
reviewed_commit=first_line(tdir / 'reviewed.commit') if made('reviewed.commit') else None,
|
|
438
|
+
drift=(text(tdir / 'drift.txt') or '').split() if made('drift.txt') else [],
|
|
439
|
+
guard=(text(tdir / 'guard.txt') or '').splitlines() if made('guard.txt') else [],
|
|
440
|
+
noretry=made('noretry'), gate=gate, infra=infra or None,
|
|
441
|
+
identical_retry=bool(diff) and made('changes.prev.diff') and diff == text(tdir / 'changes.prev.diff'),
|
|
442
|
+
produced=sorted(n for n in WATCHED if made(n)),
|
|
443
|
+
carried=sorted(n for n in WATCHED if (tdir / n).exists() and not made(n)),
|
|
444
|
+
children=children(tdir, attempt.name),
|
|
445
|
+
**(diff_summary(diff) if diff is not None else dict(changed_files=[], numstat={}, binary=[], patch_key=None)),
|
|
446
|
+
)
|
|
447
|
+
refs = blobs(
|
|
448
|
+
diff=put_blob(diff, 'diff') if diff is not None else None,
|
|
449
|
+
retry_findings=put_file(tdir / 'retry_findings.md', 'message'),
|
|
450
|
+
forge_note=put_file(tdir / 'qa.last', 'message') if made('qa.last') and not any(
|
|
451
|
+
c.startswith('qa-') for c in data['children']) else None,
|
|
452
|
+
known_issues=put_file(tdir / 'known_issues.md') if made('known_issues.md') else None,
|
|
453
|
+
dwarf_memory=put_file(tdir / 'dwarf.memory') if made('dwarf.memory') else None,
|
|
454
|
+
qa_memory=put_file(tdir / 'qa.memory') if made('qa.memory') else None,
|
|
455
|
+
capsule=put_file(tdir / 'baseline.capsule') if made('baseline.capsule') else None,
|
|
456
|
+
subset=put_file(tdir / 'subset.jev.txt') if made('subset.jev.txt') else None,
|
|
457
|
+
qa_effort_jev=put_file(tdir / 'qa.effort.jev') if made('qa.effort.jev') else None,
|
|
458
|
+
review_triage_jev=put_file(tdir / 'qa.jev.json') if made('qa.jev.json') else None,
|
|
459
|
+
routing_jev=put_file(tdir / 'jev.json'),
|
|
460
|
+
prompt=put_file(tdir / 'prompt.md', 'prompt'), approach=put_file(tdir / 'approach.md', 'prompt'),
|
|
461
|
+
)
|
|
462
|
+
data['blobs'] = refs
|
|
463
|
+
return dict(rid=attempt.name, kind='attempt', ts=prov.get('wall_ended') or time.time(), src=src,
|
|
464
|
+
run=prov.get('run'), run_id=prov.get('run_id'), mode=prov.get('mode'), task=tdir.name,
|
|
465
|
+
attempt=number, parent=prov.get('parent'), inv=prov.get('inv'), data=data)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def solo_event(attempt: Path, run: Path, rc, prov: dict, src: str = 'live') -> dict:
|
|
469
|
+
before = prov.get('files')
|
|
470
|
+
made = lambda name: produced(run, before, name)
|
|
471
|
+
diff = text(run / 'changes.diff') if made('changes.diff') else None
|
|
472
|
+
starts = sorted(((read_json(m) or {}).get('wall_started') or 0, m.parent.name)
|
|
473
|
+
for m in run.glob('attempts/solo-*/metrics.json'))
|
|
474
|
+
number = next((i + 1 for i, (_, name) in enumerate(starts) if name == attempt.name), None)
|
|
475
|
+
data = dict(
|
|
476
|
+
verdict=first_line(run / 'verdict') if made('verdict') else None, exit=as_int(rc),
|
|
477
|
+
goal=first_line(run / 'goal.txt'), trigger=prov.get('trigger'),
|
|
478
|
+
guard=(text(run / 'guard.txt') or '').splitlines() if made('guard.txt') else [],
|
|
479
|
+
start_tree=first_line(run / 'start.tree') if made('start.tree') else None,
|
|
480
|
+
reviewed_tree=first_line(run / 'reviewed.tree') if made('reviewed.tree') else None,
|
|
481
|
+
produced=sorted(n for n in WATCHED if made(n)), children=children(run, attempt.name),
|
|
482
|
+
**(diff_summary(diff) if diff is not None else dict(changed_files=[], numstat={}, binary=[], patch_key=None)),
|
|
483
|
+
)
|
|
484
|
+
data['blobs'] = blobs(
|
|
485
|
+
diff=put_blob(diff, 'diff') if diff is not None else None,
|
|
486
|
+
existing_diff=put_file(run / 'existing.diff', 'diff') if made('existing.diff') else None,
|
|
487
|
+
prompt=put_file(run / 'prompt.md', 'prompt'), goal=put_file(run / 'goal.txt'),
|
|
488
|
+
request=put_file(run / 'request.md', 'prompt'),
|
|
489
|
+
dwarf_memory=put_file(run / 'dwarf.memory') if made('dwarf.memory') else None,
|
|
490
|
+
qa_memory=put_file(run / 'qa.memory') if made('qa.memory') else None,
|
|
491
|
+
)
|
|
492
|
+
return dict(rid=attempt.name, kind='attempt', ts=prov.get('wall_ended') or time.time(), src=src,
|
|
493
|
+
run=prov.get('run'), run_id=prov.get('run_id'), mode='solo', task=None, attempt=number,
|
|
494
|
+
parent=prov.get('parent'), inv=prov.get('inv'), data=data)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def verification_events(attempt: Path, out: Path, rc, prov: dict, src: str = 'live') -> list:
|
|
498
|
+
before = prov.get('files')
|
|
499
|
+
made = lambda name: produced(out, before, name) if before is not None else (out / name).exists()
|
|
500
|
+
plan = out.parent if out.name.startswith('integrate-') else out
|
|
501
|
+
repo = first_line(plan / 'repo')
|
|
502
|
+
command = first_line(out / 'verification.command') if made('verification.command') else None
|
|
503
|
+
jev = read_json(out / 'verification.jev.json') if made('verification.jev.json') else None
|
|
504
|
+
if not command:
|
|
505
|
+
source = None
|
|
506
|
+
elif (plan / 'verify_cmd').exists():
|
|
507
|
+
source = 'flag'
|
|
508
|
+
elif jev and jev.get('discovered'):
|
|
509
|
+
source = 'jev'
|
|
510
|
+
elif repo and (Path(repo) / '.forge' / 'verify').exists():
|
|
511
|
+
source = 'repo'
|
|
512
|
+
else:
|
|
513
|
+
source = 'unknown'
|
|
514
|
+
runs = []
|
|
515
|
+
if made('verification.runs.tsv'):
|
|
516
|
+
for line in (text(out / 'verification.runs.tsv') or '').splitlines():
|
|
517
|
+
cells = line.split('\t')
|
|
518
|
+
if len(cells) >= 5 and cells[0].isdigit():
|
|
519
|
+
runs.append(dict(n=int(cells[0]), kind=cells[1], exit=as_int(cells[2]),
|
|
520
|
+
tree_changed=cells[3] == '1', duration_s=as_int(cells[4]),
|
|
521
|
+
log=cells[5] if len(cells) > 5 else None))
|
|
522
|
+
elif command and made('verification.log'):
|
|
523
|
+
runs.append(dict(n=1, kind='initial', exit=as_int(first_line(out / 'verification.exit')),
|
|
524
|
+
tree_changed=None, duration_s=None, log='verification.log'))
|
|
525
|
+
if made('verification.retry.log'):
|
|
526
|
+
runs.append(dict(n=2, kind='rerun', exit=None, tree_changed=None, duration_s=None,
|
|
527
|
+
log='verification.retry.log'))
|
|
528
|
+
status = first_line(out / 'verification.status') if made('verification.status') else None
|
|
529
|
+
data = dict(
|
|
530
|
+
scope='integrate' if out != plan else 'run', status=status, exit=as_int(rc), command=command,
|
|
531
|
+
commit=first_line(out / 'verification.commit') if made('verification.commit') else None,
|
|
532
|
+
command_source=source, retries_allowed=as_int(first_line(plan / 'verify_retries')),
|
|
533
|
+
flaky=kv(out / 'verification.flaky') if made('verification.flaky') else None,
|
|
534
|
+
jev=jev, runs=[{k: v for k, v in r.items() if k != 'log'} for r in runs],
|
|
535
|
+
blobs=blobs(coverage=put_file(out / 'verification.coverage.txt') if made('verification.coverage.txt') else None),
|
|
536
|
+
)
|
|
537
|
+
base = dict(src=src, run=prov.get('run'), run_id=prov.get('run_id'), mode=prov.get('mode'),
|
|
538
|
+
parent=prov.get('parent'), inv=prov.get('inv'))
|
|
539
|
+
events = [dict(base, rid=attempt.name, kind='verification', ts=prov.get('wall_ended') or time.time(), data=data)]
|
|
540
|
+
for r in runs:
|
|
541
|
+
log = put_file(out / r['log'], 'log') if r.get('log') else None
|
|
542
|
+
events.append(dict(base, rid='%s:run:%d' % (attempt.name, r['n']), kind='verify_run',
|
|
543
|
+
ts=prov.get('wall_ended') or time.time(), parent=attempt.name,
|
|
544
|
+
data=dict({k: v for k, v in r.items() if k != 'log'}, command=command,
|
|
545
|
+
blobs=blobs(log=log))))
|
|
546
|
+
return events
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def run_end_event(attempt: Path, plan: Path, rc, prov: dict, src: str = 'live') -> dict:
|
|
550
|
+
results = {}
|
|
551
|
+
for line in (text(plan / 'results.tsv') or '').splitlines():
|
|
552
|
+
cells = line.split('\t')
|
|
553
|
+
if len(cells) >= 2:
|
|
554
|
+
results[cells[0]] = cells[1]
|
|
555
|
+
repo = first_line(plan / 'repo')
|
|
556
|
+
data = dict(exit=as_int(rc), results=results,
|
|
557
|
+
accepted_integration=first_line(plan / 'accepted.integration'),
|
|
558
|
+
blobs=blobs(memory=put_file(Path(repo) / '.forge' / 'memory.md') if repo else None))
|
|
559
|
+
return dict(rid=attempt.name, kind='run_end', ts=prov.get('wall_ended') or time.time(), src=src,
|
|
560
|
+
run=prov.get('run'), run_id=prov.get('run_id'), mode='parallel', parent=prov.get('parent'),
|
|
561
|
+
inv=prov.get('inv'), data=data)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# --- the plan: every decision made before the first dispatch ----------------------------
|
|
565
|
+
|
|
566
|
+
def _git(repo: str, *args: str, limit: int = 1 << 20) -> str | None:
|
|
567
|
+
"""Read-only object-store queries; never touches the working tree or index."""
|
|
568
|
+
try:
|
|
569
|
+
result = subprocess.run(['git', '-C', repo, *args], stdout=subprocess.PIPE,
|
|
570
|
+
stderr=subprocess.DEVNULL, timeout=5, env=dict(os.environ, GIT_OPTIONAL_LOCKS='0'))
|
|
571
|
+
except (OSError, subprocess.SubprocessError):
|
|
572
|
+
return None
|
|
573
|
+
if result.returncode:
|
|
574
|
+
return None
|
|
575
|
+
return result.stdout[:limit].decode('utf-8', 'replace')
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def decision_snapshot(repo: str, rows: dict, head: str | None) -> dict | None:
|
|
579
|
+
"""What the declared files looked like when the plan was made.
|
|
580
|
+
|
|
581
|
+
Routing, drift and coupling questions are about code as it was at decision time,
|
|
582
|
+
and by the time anyone trains on this the repo has moved on.
|
|
583
|
+
"""
|
|
584
|
+
if not repo or not head:
|
|
585
|
+
return None
|
|
586
|
+
excerpts, total = {}, 0
|
|
587
|
+
for row in rows.values():
|
|
588
|
+
for name in split_list(row.get('files')):
|
|
589
|
+
if name in excerpts or len(excerpts) >= 40 or total >= 256 << 10:
|
|
590
|
+
continue
|
|
591
|
+
if name.endswith('/'):
|
|
592
|
+
continue
|
|
593
|
+
body = _git(repo, 'show', '%s:%s' % (head, name), limit=4096)
|
|
594
|
+
if body is not None:
|
|
595
|
+
excerpts[name] = body
|
|
596
|
+
total += len(body)
|
|
597
|
+
return excerpts
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def plan_event(plan: Path, prov: dict, src: str = 'live', inventory: bool = True, head: str | None = None) -> dict:
|
|
601
|
+
rows = tasks_rows(plan / 'tasks.tsv')
|
|
602
|
+
declared_path = plan / 'tasks.declared.tsv'
|
|
603
|
+
declared = tasks_rows(declared_path) if declared_path.exists() else None
|
|
604
|
+
repo = first_line(plan / 'repo')
|
|
605
|
+
# Live, the plan is made against HEAD. An import is not: HEAD has moved on, so the caller
|
|
606
|
+
# passes the commit the tasks branched from.
|
|
607
|
+
if head is None and src == 'live' and repo:
|
|
608
|
+
head = (_git(repo, 'rev-parse', 'HEAD') or '').strip() or None
|
|
609
|
+
per_task = {}
|
|
610
|
+
for tid, row in rows.items():
|
|
611
|
+
tdir = plan / 'tasks' / tid
|
|
612
|
+
routing = read_json(tdir / 'jev.json')
|
|
613
|
+
declared_tier = (declared or {}).get(tid, {}).get('difficulty') or (routing or {}).get('declared')
|
|
614
|
+
# Only an imported plan from before 2026-10-17 can match: Jev no longer rewrites a tier.
|
|
615
|
+
if declared_tier and declared_tier != row.get('difficulty') and routing and routing.get('may_act'):
|
|
616
|
+
source = 'jev-act'
|
|
617
|
+
elif (plan / 'planner').exists() and list(plan.glob('attempts/planner-*')):
|
|
618
|
+
source = 'planner'
|
|
619
|
+
else:
|
|
620
|
+
source = 'orchestrator'
|
|
621
|
+
per_task[tid] = dict(row, declared_difficulty=declared_tier, difficulty_source=source,
|
|
622
|
+
routing=routing, prompt=put_file(tdir / 'prompt.md', 'prompt'),
|
|
623
|
+
approach=put_file(tdir / 'approach.md', 'prompt'))
|
|
624
|
+
decisions = jsonl(tdir / 'route.jsonl')
|
|
625
|
+
if decisions:
|
|
626
|
+
per_task[tid]['route'] = decisions # exploratory routing (forge-route.py), oldest first
|
|
627
|
+
waves = {}
|
|
628
|
+
for line in (text(plan / 'waves.tsv') or '').splitlines():
|
|
629
|
+
cells = line.split('\t')
|
|
630
|
+
if len(cells) >= 2:
|
|
631
|
+
waves[cells[1]] = as_int(cells[0])
|
|
632
|
+
# Inputs that joined the plan after its rid scheme was fixed are hashed only when present,
|
|
633
|
+
# so every plan already in a store keeps its rid and re-importing an archive adds nothing.
|
|
634
|
+
# Options older than that (timeout, qa_threshold, ...) stay out for the same reason.
|
|
635
|
+
later = {name: first_line(plan / name) for name in PLAN_RID_LATER if (plan / name).exists()}
|
|
636
|
+
routes = {tid: [d.get('decision') for d in r['route']] for tid, r in per_task.items() if r.get('route')}
|
|
637
|
+
if routes:
|
|
638
|
+
later['route'] = routes
|
|
639
|
+
inputs = json.dumps([text(plan / 'goal.txt'), text(plan / 'tasks.tsv'), text(declared_path),
|
|
640
|
+
{t: [r['prompt'], r['approach']] for t, r in per_task.items()}] + ([later] if later else []),
|
|
641
|
+
sort_keys=True, default=str)
|
|
642
|
+
snapshot_excerpts = decision_snapshot(repo, rows, head) if repo else None
|
|
643
|
+
listing = _git(repo, 'ls-tree', '-r', '--name-only', head) if inventory and repo and head else None
|
|
644
|
+
data = dict(
|
|
645
|
+
goal=first_line(plan / 'goal.txt'), decompose_level=first_line(plan / 'decompose_level'),
|
|
646
|
+
planner=first_line(plan / 'planner'), author='planner' if list(plan.glob('attempts/planner-*')) else 'orchestrator',
|
|
647
|
+
planner_dispatches=sorted(p.name for p in plan.glob('attempts/planner-*')),
|
|
648
|
+
head=head, tasks=per_task, waves=waves, deferrals=(text(plan / 'deferrals.txt') or '').splitlines(),
|
|
649
|
+
pools=read_json(plan / 'fractal-routing.json'), jev_selection=read_json(plan / 'jev-selection.json'),
|
|
650
|
+
fractal_selection=read_json(plan / 'fractal-selection.json'),
|
|
651
|
+
jev_coupling=read_json(plan / 'jev-coupling.json'),
|
|
652
|
+
options={name: first_line(plan / name) for name in ('qa_threshold', 'verify_retries', 'timeout', 'final_qa',
|
|
653
|
+
'shadow_qa', 'shadow_rate', 'explore')
|
|
654
|
+
if (plan / name).exists()},
|
|
655
|
+
flags=sorted(name for name in ('no_memory', 'no_ripwire', 'yolo_dwarf', 'yolo_qa') if (plan / name).exists()),
|
|
656
|
+
blobs=blobs(goal=put_file(plan / 'goal.txt'), request=put_file(plan / 'request.md', 'prompt'),
|
|
657
|
+
tasks=put_file(plan / 'tasks.tsv'), tasks_declared=put_file(declared_path),
|
|
658
|
+
jev_routing=put_file(plan / 'jev-routing.tsv'),
|
|
659
|
+
snapshot=put_blob(json.dumps(snapshot_excerpts, sort_keys=True), 'request') if snapshot_excerpts else None,
|
|
660
|
+
inventory=put_blob(listing, 'request') if listing else None,
|
|
661
|
+
memory=put_file(Path(repo) / '.forge' / 'memory.md') if repo else None),
|
|
662
|
+
)
|
|
663
|
+
return dict(rid='plan:%s:%s' % (prov.get('run'), sha256(inputs.encode())[:16]), kind='plan',
|
|
664
|
+
ts=time.time(), src=src, run=prov.get('run'), run_id=prov.get('run_id'), mode='parallel',
|
|
665
|
+
inv=prov.get('inv'), data=data)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
# --- Fractal ------------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
def fractal_context(task_dir: Path, create: bool) -> dict:
|
|
671
|
+
request = read_json(task_dir / 'request.json') or {}
|
|
672
|
+
run_dir = task_dir.parent.parent if task_dir.parent.name == 'tasks' else task_dir
|
|
673
|
+
return dict(run=run_uid(run_dir, create=create), run_id=run_dir.name, repo=request.get('repo'),
|
|
674
|
+
link=request.get('output'))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def fractal_step_event(task_dir: Path, node: dict, step_dir: Path, ctx: dict, *, phase: str | None = None,
|
|
678
|
+
iteration=None, rc=None, cost=None, resolved=None, src: str = 'live') -> dict:
|
|
679
|
+
request = read_json(step_dir / 'request.json') or {}
|
|
680
|
+
role = request.get('role')
|
|
681
|
+
phase = phase or ('planning' if role == 'planner' else 'implementation')
|
|
682
|
+
decomposition = node.get('decomposition') or {}
|
|
683
|
+
dispatches = sorted(p.name for p in step_dir.glob('attempts/*-*'))
|
|
684
|
+
if rc is None and dispatches:
|
|
685
|
+
rc = (read_json(step_dir / 'attempts' / dispatches[-1] / 'metrics.json') or {}).get('exit_code')
|
|
686
|
+
data = dict(node=node.get('id'), parent_node=node.get('parent'), phase=phase, iteration=iteration,
|
|
687
|
+
exit=as_int(rc), cost=cost, resolved_model=resolved, model=node.get('model'),
|
|
688
|
+
difficulty=node.get('difficulty'), status=node.get('status'), goal=node.get('goal'),
|
|
689
|
+
decision=decomposition.get('decision'), decomposition_attempts=decomposition.get('attempts'),
|
|
690
|
+
link=ctx['link'], dispatches=dispatches)
|
|
691
|
+
return dict(rid='fractal_step:' + step_dir.name, kind='fractal_step', ts=time.time(), src=src, run=ctx['run'],
|
|
692
|
+
run_id=ctx['run_id'], mode='fractal', task='%s/%s' % (task_dir.name, node.get('id')),
|
|
693
|
+
attempt=as_int(iteration), data=data)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def fractal_task_events(task_dir: Path, ctx: dict, src: str = 'live') -> list:
|
|
697
|
+
"""Every node's state and the task outcome, keyed by content: once per distinct state."""
|
|
698
|
+
events = []
|
|
699
|
+
for path in sorted(task_dir.glob('nodes/*/node.json')):
|
|
700
|
+
raw = text(path) or ''
|
|
701
|
+
node = read_json(path) or {}
|
|
702
|
+
events.append(dict(rid='fractal_node:%s/%s:%s' % (ctx['run'], node.get('id'), sha256(raw.encode())[:16]),
|
|
703
|
+
kind='fractal_node', ts=time.time(), src=src, run=ctx['run'], run_id=ctx['run_id'],
|
|
704
|
+
mode='fractal', task='%s/%s' % (task_dir.name, node.get('id')),
|
|
705
|
+
attempt=as_int(node.get('iteration')),
|
|
706
|
+
data=dict(node, blobs=blobs(candidate=put_file(path.parent / 'candidate.diff', 'diff')))))
|
|
707
|
+
outcome = read_json(task_dir / 'outcome.json')
|
|
708
|
+
if outcome is not None:
|
|
709
|
+
raw = json.dumps(outcome, sort_keys=True)
|
|
710
|
+
events.append(dict(rid='fractal_outcome:%s/%s:%s' % (ctx['run'], task_dir.name, sha256(raw.encode())[:16]),
|
|
711
|
+
kind='event', ts=time.time(), src=src, run=ctx['run'], run_id=ctx['run_id'],
|
|
712
|
+
mode='fractal', task=task_dir.name,
|
|
713
|
+
data=dict(outcome, name='fractal_outcome', link=ctx['link'],
|
|
714
|
+
blobs=blobs(diff=put_file(task_dir / 'changes.diff', 'diff'),
|
|
715
|
+
qa=put_file(task_dir / 'qa.last', 'message')))))
|
|
716
|
+
return events
|