@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.
Files changed (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. 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