@giamme/forge 0.9.0 → 0.11.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 (67) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/SKILL.md +22 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +28 -2
  5. package/docs/commands.md +55 -0
  6. package/docs/dataset.md +22 -0
  7. package/docs/decomposed-runs.md +54 -2
  8. package/docs/forge-ui.md +48 -7
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +87 -2
  11. package/forge +31 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +103 -4
  14. package/references/decompose.md +41 -5
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +31 -6
  17. package/scripts/forge-config.py +350 -0
  18. package/scripts/forge-control.py +649 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +1134 -0
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +358 -41
  24. package/scripts/forge-parallel-options.sh +3 -1
  25. package/scripts/forge-parallel.sh +99 -13
  26. package/scripts/forge-prompt.py +61 -4
  27. package/scripts/forge-route.py +245 -28
  28. package/scripts/forge-runs-rm.py +840 -0
  29. package/scripts/forge-runs.py +19 -1
  30. package/scripts/forge-runtime.py +47 -1
  31. package/scripts/forge-schedule.py +227 -11
  32. package/scripts/forge-ui.py +85 -19
  33. package/scripts/forge_dataset/__init__.py +84 -8
  34. package/scripts/forge_dataset/capture.py +5 -2
  35. package/scripts/forge_dataset/cli.py +44 -1
  36. package/scripts/forge_dataset/export.py +36 -3
  37. package/scripts/forge_dataset/forecast.py +355 -0
  38. package/scripts/forge_dataset/harvest.py +45 -2
  39. package/scripts/forge_dataset/importer.py +431 -17
  40. package/scripts/forge_dataset/reviewers.py +145 -0
  41. package/scripts/forge_dataset/routing.py +1 -0
  42. package/scripts/forge_jev/__init__.py +7 -3
  43. package/scripts/forge_registry.py +183 -26
  44. package/ui/README.md +41 -7
  45. package/ui/docs/api.md +259 -21
  46. package/ui/forge_ui/__main__.py +10 -2
  47. package/ui/forge_ui/control.py +587 -0
  48. package/ui/forge_ui/export.py +17 -3
  49. package/ui/forge_ui/reader.py +210 -12
  50. package/ui/forge_ui/server.py +125 -4
  51. package/ui/forge_ui/static/app.js +104 -18
  52. package/ui/forge_ui/static/cloud3d.js +4 -1
  53. package/ui/forge_ui/static/cloudmodel.js +1 -1
  54. package/ui/forge_ui/static/cloudui.js +13 -4
  55. package/ui/forge_ui/static/controls.js +619 -0
  56. package/ui/forge_ui/static/findingsview.js +438 -0
  57. package/ui/forge_ui/static/graph.js +6 -5
  58. package/ui/forge_ui/static/index.html +7 -1
  59. package/ui/forge_ui/static/leaderboard.js +18 -0
  60. package/ui/forge_ui/static/modelstats.js +19 -1
  61. package/ui/forge_ui/static/panel.js +76 -14
  62. package/ui/forge_ui/static/rundelete.js +388 -0
  63. package/ui/forge_ui/static/runmodel.js +128 -13
  64. package/ui/forge_ui/static/scene3d.js +32 -15
  65. package/ui/forge_ui/static/settings.js +399 -0
  66. package/ui/forge_ui/static/style.css +184 -5
  67. package/ui/forge_ui/static/uikit.js +161 -0
@@ -0,0 +1,355 @@
1
+ """`forge dataset forecast <plan>`: what a plan is likely to cost, from forge's own history.
2
+
3
+ For each task of a plan, the tasks in the dataset that ran at the same tier on the same
4
+ model (the dwarf spec resolved through the registry; the effort too when the spec names one)
5
+ say what to expect: the median and p90 of tokens (input + output, dwarf and QA, over every
6
+ attempt) and of wall time (the same dispatches' durations), and the first-attempt PASS rate
7
+ with a 95% Wilson interval. Below MIN_HISTORY matching tasks a row says "not enough history"
8
+ instead of numbers a handful of tasks would make up, and a task whose rate is under
9
+ FLAG_BELOW is flagged as likely to need a retry.
10
+
11
+ Advisory only: nothing that routes a task, builds a prompt or gates a run reads it. The
12
+ rates are observational (who ran what was not randomized unless `--explore` drew it), so a
13
+ low rate can be the tasks a model was given rather than the model.
14
+
15
+ `forge-parallel.sh run` prints a short version before dispatching (`--no-forecast` or
16
+ FORGE_FORECAST=off skip it). There it runs under a time budget (FORGE_FORECAST_BUDGET_S,
17
+ default 3s), is silent when there is no history, never changes the run's exit code, and
18
+ records what it predicted: `<plan>/forecast.json` and a `forecast` dataset event keyed by
19
+ the run, so a later report can check the forecast against what happened.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import math
26
+ import os
27
+ import statistics
28
+ import sys
29
+ import threading
30
+ import time
31
+ from collections import defaultdict
32
+ from pathlib import Path
33
+
34
+ from . import SKILL, connect, dataset_dir, record
35
+ from .export import Runs, Store
36
+ from .routing import tasks as routing_tasks, wilson
37
+
38
+ MIN_HISTORY = 5
39
+ FLAG_BELOW = 0.5
40
+ BUDGET_S = 3.0
41
+ OFF = {'0', 'off', 'false', 'no'}
42
+
43
+
44
+ # --- the registry, as forge-route.py reads it (that module's name is not importable) ---
45
+
46
+ def registry_models() -> dict:
47
+ """{alias: {harness: model id}}; the first row of an alias is its default."""
48
+ out = {}
49
+ path = Path(os.environ.get('FORGE_REGISTRY') or str(SKILL / 'registry.tsv'))
50
+ try:
51
+ lines = path.read_text().splitlines()
52
+ except OSError:
53
+ return out
54
+ for line in lines:
55
+ if not line.strip() or line.startswith('#'):
56
+ continue
57
+ cells = line.split('\t')
58
+ if len(cells) >= 3:
59
+ out.setdefault(cells[0], {}).setdefault(cells[1], cells[2])
60
+ return out
61
+
62
+
63
+ def resolve(spec, registry: dict) -> tuple:
64
+ """(model id, effort or None) a dwarf spec such as `sol`, `sol:high` or `sol:high:codex` runs."""
65
+ parts = str(spec or '').split(':')
66
+ alias = parts[0]
67
+ effort = parts[1] if len(parts) > 1 and parts[1] else None
68
+ harness = parts[2] if len(parts) > 2 else None
69
+ rows = registry.get(alias)
70
+ if not rows:
71
+ return alias or None, effort
72
+ if harness and harness in rows:
73
+ return rows[harness], effort
74
+ return next(iter(rows.values())), effort
75
+
76
+
77
+ # --- reading the plan ---
78
+
79
+ def _tsv(path: Path) -> list:
80
+ try:
81
+ lines = path.read_text().splitlines()
82
+ except OSError:
83
+ return []
84
+ cols = ('id', 'deps', 'difficulty', 'files', 'dwarf', 'qa', 'title')
85
+ return [dict(zip(cols, line.split('\t'))) for line in lines if line.strip() and not line.startswith('#')]
86
+
87
+
88
+ def plan_tasks(plan: Path) -> tuple:
89
+ """([task dicts to forecast], [ids already merged]) from tasks.tsv (declared tier when kept)."""
90
+ declared = {r['id']: r for r in _tsv(plan / 'tasks.declared.tsv')}
91
+ out, merged = [], []
92
+ for row in _tsv(plan / 'tasks.tsv'):
93
+ tid = row.get('id')
94
+ if not tid:
95
+ continue
96
+ if (plan / 'tasks' / tid / 'merged').exists():
97
+ merged.append(tid)
98
+ continue
99
+ tier = (declared.get(tid) or {}).get('difficulty') or row.get('difficulty') or None
100
+ out.append(dict(task=tid, tier=tier, spec=row.get('dwarf') or None))
101
+ return out, merged
102
+
103
+
104
+ def waves(plan: Path) -> dict:
105
+ out = {}
106
+ try:
107
+ lines = (plan / 'waves.tsv').read_text().splitlines()
108
+ except OSError:
109
+ return out
110
+ for line in lines:
111
+ cells = line.split('\t')
112
+ if len(cells) >= 2 and cells[1].strip():
113
+ out[cells[1].strip()] = cells[0].strip()
114
+ return out
115
+
116
+
117
+ def run_uid(plan: Path):
118
+ try:
119
+ value = (plan / 'dataset.uid').read_text().strip()
120
+ except OSError:
121
+ return None
122
+ return value or None
123
+
124
+
125
+ # --- history ---
126
+
127
+ def _cost(runs: Runs, run, task) -> tuple:
128
+ """(tokens, seconds) a task's dwarf and QA dispatches took over every attempt; None when unrecorded."""
129
+ tokens = seconds = None
130
+ for role in ('dwarf', 'qa'):
131
+ for e in runs.dispatches.get((run, task, role), []):
132
+ t = e['d'].get('tokens') or {}
133
+ used = [t.get(k) for k in ('input', 'output') if isinstance(t.get(k), (int, float))]
134
+ if used:
135
+ tokens = (tokens or 0) + sum(used)
136
+ d = e['d'].get('duration_s')
137
+ if isinstance(d, (int, float)):
138
+ seconds = (seconds or 0) + d
139
+ return tokens, seconds
140
+
141
+
142
+ def history(runs: Runs, registry: dict, *, repo=None, exclude_run=None) -> list:
143
+ """Every task that ran, with the (tier, model, effort) it ran at and what it cost."""
144
+ out = []
145
+ for r in routing_tasks(runs, repo=repo):
146
+ if not r['attempts'] or r['run'] == exclude_run:
147
+ continue
148
+ model, effort = r.get('model_id'), r.get('effort')
149
+ if not model:
150
+ model, effort = resolve(r['ran'], registry)
151
+ if not model:
152
+ continue
153
+ tokens, seconds = _cost(runs, r['run'], r['task'])
154
+ out.append(dict(tier=r['tier'], model=model, effort=effort, first_pass=r['first_pass'],
155
+ tokens=tokens, seconds=seconds))
156
+ return out
157
+
158
+
159
+ def nearest_rank(values: list, q: float):
160
+ ordered = sorted(values)
161
+ return ordered[max(0, math.ceil(q * len(ordered)) - 1)]
162
+
163
+
164
+ def _spread(values: list) -> dict:
165
+ if len(values) < MIN_HISTORY:
166
+ return dict(n=len(values), median=None, p90=None)
167
+ return dict(n=len(values), median=statistics.median(values), p90=nearest_rank(values, 0.9))
168
+
169
+
170
+ def forecast_task(task: dict, past: list, registry: dict) -> dict:
171
+ model, effort = resolve(task['spec'], registry) if task['spec'] else (None, None)
172
+ same = [h for h in past if model and h['tier'] == task['tier'] and h['model'] == model
173
+ and (effort is None or h['effort'] == effort)]
174
+ outcomes = [h['first_pass'] for h in same if h['first_pass'] is not None]
175
+ k, n = sum(1 for x in outcomes if x), len(outcomes)
176
+ enough = n >= MIN_HISTORY
177
+ low, high = wilson(k, n) if enough else (None, None)
178
+ rate = k / n if enough else None
179
+ return dict(task=task['task'], tier=task['tier'], spec=task['spec'], model=model, effort=effort,
180
+ history=len(same), enough=enough,
181
+ first_pass=dict(k=k, n=n, rate=rate, ci=[low, high] if enough else None),
182
+ tokens=_spread([h['tokens'] for h in same if h['tokens'] is not None]),
183
+ time_s=_spread([h['seconds'] for h in same if h['seconds'] is not None]),
184
+ flag=bool(enough and rate < FLAG_BELOW),
185
+ note=None if enough else 'not enough history (%d/%d)' % (n, MIN_HISTORY))
186
+
187
+
188
+ def totals(rows: list, wave_of: dict) -> dict:
189
+ tok = [r for r in rows if r['tokens']['median'] is not None]
190
+ sec = [r for r in rows if r['time_s']['median'] is not None]
191
+ rated = [r for r in rows if r['enough']]
192
+ by_wave = defaultdict(float)
193
+ for r in sec:
194
+ w = wave_of.get(r['task'], r['task'])
195
+ by_wave[w] = max(by_wave[w], r['time_s']['median'])
196
+ return dict(
197
+ tasks=len(rows), forecast=len(rated), flagged=sum(1 for r in rows if r['flag']),
198
+ tokens=dict(tasks=len(tok), median=sum(r['tokens']['median'] for r in tok) if tok else None,
199
+ p90=sum(r['tokens']['p90'] for r in tok) if tok else None),
200
+ time_s=dict(tasks=len(sec), median=sum(r['time_s']['median'] for r in sec) if sec else None,
201
+ p90=sum(r['time_s']['p90'] for r in sec) if sec else None,
202
+ by_waves=sum(by_wave.values()) if (sec and wave_of) else None),
203
+ first_pass_expected=sum(r['first_pass']['rate'] for r in rated) if rated else None)
204
+
205
+
206
+ def build(plan: Path, *, repo=None) -> dict | None:
207
+ """The forecast for a plan, or None when there is no history at all (no store, no tasks)."""
208
+ # Reading must not create a store: a fresh machine gets nothing, not an empty database.
209
+ if not (dataset_dir() / 'dataset.sqlite').exists():
210
+ return None
211
+ db = connect()
212
+ try:
213
+ runs = Runs(Store(db))
214
+ finally:
215
+ db.close()
216
+ registry = registry_models()
217
+ uid = run_uid(plan)
218
+ past = history(runs, registry, repo=repo, exclude_run=uid)
219
+ if not past:
220
+ return None
221
+ todo, merged = plan_tasks(plan)
222
+ rows = [forecast_task(t, past, registry) for t in todo]
223
+ return dict(plan=str(plan), run=uid, at=time.time(), min_history=MIN_HISTORY, flag_below=FLAG_BELOW,
224
+ history_tasks=len(past), tasks=rows, merged=merged, total=totals(rows, waves(plan)))
225
+
226
+
227
+ # --- output ---
228
+
229
+ def _tokens(v):
230
+ if v is None:
231
+ return '?'
232
+ return '%.1fM' % (v / 1e6) if v >= 1e6 else ('%.0fk' % (v / 1e3) if v >= 1e3 else '%d' % v)
233
+
234
+
235
+ def _time(v):
236
+ if v is None:
237
+ return '?'
238
+ v = int(round(v))
239
+ return '%dh%02dm' % (v // 3600, v % 3600 // 60) if v >= 3600 else ('%dm%02ds' % (v // 60, v % 60) if v >= 60 else '%ds' % v)
240
+
241
+
242
+ def _pct(v):
243
+ return '%.0f%%' % (v * 100)
244
+
245
+
246
+ def lines(fc: dict) -> list:
247
+ out = ['forecast from %d past task(s) at the same tier and model (advisory; routing never reads it)'
248
+ % fc['history_tasks']]
249
+ for r in fc['tasks']:
250
+ where = '%s %s' % (r['tier'] or '?', r['spec'] or '?')
251
+ if not r['enough']:
252
+ out.append(' %-14s %-22s %s' % (r['task'], where, r['note']))
253
+ continue
254
+ fp = r['first_pass']
255
+ t, s = r['tokens'], r['time_s']
256
+ out.append(' %-14s %-22s first try %s [%s-%s] (%d/%d) tokens %s (p90 %s) time %s (p90 %s)%s' % (
257
+ r['task'], where, _pct(fp['rate']), _pct(fp['ci'][0]), _pct(fp['ci'][1]), fp['k'], fp['n'],
258
+ _tokens(t['median']), _tokens(t['p90']), _time(s['median']), _time(s['p90']),
259
+ ' <- likely to need a retry' if r['flag'] else ''))
260
+ if fc['merged']:
261
+ out.append(' %d task(s) already merged, not forecast' % len(fc['merged']))
262
+ tot = fc['total']
263
+ if tot['forecast'] or tot['tokens']['tasks']:
264
+ tk, tm = tot['tokens'], tot['time_s']
265
+ summary = ' total for %d/%d task(s) with history: tokens %s (p90 %s), time %s (p90 %s) summed' % (
266
+ max(tk['tasks'], tm['tasks']), tot['tasks'], _tokens(tk['median']), _tokens(tk['p90']),
267
+ _time(tm['median']), _time(tm['p90']))
268
+ if tm['by_waves'] is not None:
269
+ summary += ', about %s by waves (ignores --max-parallel)' % _time(tm['by_waves'])
270
+ out.append(summary)
271
+ if tot['first_pass_expected'] is not None:
272
+ out.append(' expected first-try passes: %.1f of %d forecast task(s)%s' % (
273
+ tot['first_pass_expected'], tot['forecast'],
274
+ ', %d flagged under %s' % (tot['flagged'], _pct(fc['flag_below'])) if tot['flagged'] else ''))
275
+ return out
276
+
277
+
278
+ def save(plan: Path, fc: dict) -> None:
279
+ """Keep what was predicted, so it can be checked against what happened. Fail-open."""
280
+ body = {k: v for k, v in fc.items() if k != 'at'}
281
+ digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
282
+ try:
283
+ tmp = plan / ('.forecast.json.%d' % os.getpid())
284
+ tmp.write_text(json.dumps(fc, indent=2, sort_keys=True) + '\n')
285
+ tmp.replace(plan / 'forecast.json')
286
+ except OSError:
287
+ pass
288
+ if not fc.get('run'): # record() itself does nothing under FORGE_DATASET=off
289
+ return
290
+ from .harvest import first_line, locate
291
+ run_id = locate(plan).get('run_id') or plan.name
292
+ repo = first_line(plan / 'repo') or None
293
+ record([dict(rid='forecast:%s:%s' % (fc['run'], digest), kind='forecast', ts=fc['at'], src='live',
294
+ run=fc['run'], run_id=run_id, mode='parallel', task=None, data=fc)], repo=repo, where='forecast')
295
+
296
+
297
+ def advise(plan: Path, shown: list) -> None:
298
+ """The run's advisory block: print it, record it. Never raises."""
299
+ try:
300
+ fc = build(plan)
301
+ except Exception:
302
+ return
303
+ if not fc or not fc['tasks']:
304
+ return
305
+ for line in lines(fc):
306
+ print(line)
307
+ sys.stdout.flush()
308
+ shown.append(True)
309
+ try:
310
+ save(plan, fc)
311
+ except Exception:
312
+ pass
313
+
314
+
315
+ def budget() -> float:
316
+ try:
317
+ value = float(os.environ.get('FORGE_FORECAST_BUDGET_S') or BUDGET_S)
318
+ except ValueError:
319
+ return BUDGET_S
320
+ return value if value > 0 else BUDGET_S
321
+
322
+
323
+ def main(args) -> int:
324
+ plan = Path(args.plan).resolve()
325
+ if args.run:
326
+ # Called by forge-parallel.sh run: silent on anything unexpected, exit 0, and never longer than the budget.
327
+ if os.environ.get('FORGE_FORECAST', 'on').strip().lower() in OFF or not (plan / 'tasks.tsv').is_file():
328
+ return 0
329
+ shown = []
330
+ worker = threading.Thread(target=advise, args=(plan, shown), daemon=True)
331
+ worker.start()
332
+ worker.join(budget())
333
+ if worker.is_alive():
334
+ # Past the budget the run goes on without it. Once the block is out, only the
335
+ # recording is cut short, which is not worth a line.
336
+ if not shown:
337
+ print('forecast skipped: reading the history took over %gs' % budget())
338
+ sys.stdout.flush()
339
+ os._exit(0)
340
+ return 0
341
+ if not (plan / 'tasks.tsv').is_file():
342
+ print('forge dataset forecast: no tasks.tsv in %s' % plan, file=sys.stderr)
343
+ return 2
344
+ fc = build(plan, repo=os.path.realpath(args.repo) if args.repo else None)
345
+ if args.json:
346
+ print(json.dumps(fc, indent=2, sort_keys=True))
347
+ return 0
348
+ if not fc:
349
+ print('no task history in the dataset yet, so nothing to forecast from')
350
+ return 0
351
+ for line in lines(fc):
352
+ print(line)
353
+ print(' tokens are input + output of the dwarf and QA dispatches over every attempt; time is their summed duration')
354
+ print(' rates are observational: a low one can be the tasks a model was given rather than the model')
355
+ return 0
@@ -30,7 +30,7 @@ PIPELINE_ROLES = ('task', 'solo', 'verification', 'parallel')
30
30
  TASKS_COLS = ('id', 'deps', 'difficulty', 'files', 'dwarf', 'qa', 'title')
31
31
  STRUCTURED = ('codex', 'claude', 'openclaude', 'grok', 'opencode') # as forge-runtime.py's: .last is the message, not the log
32
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')
33
+ PLAN_RID_LATER = ('decompose_level', 'shadow_qa', 'shadow_rate', 'explore', 'reviewer_session')
34
34
 
35
35
  # Files a task or solo execution may write. begin() snapshots their signatures so
36
36
  # finish() can tell this execution's outputs from what an earlier one left behind.
@@ -345,14 +345,56 @@ def run_uid(run_dir: Path, create: bool) -> str:
345
345
  return legacy_uid(Path(run_dir))
346
346
 
347
347
 
348
+ # --- interventions: a human's (or the policy's) change of course ------------------------
349
+ # Escalations and control changes are point events, recorded at the moment they happen, but
350
+ # the run dir also keeps them (tasks/<id>/escalations.tsv, control.log). Their rid hangs on
351
+ # what they say rather than on when they were captured, so live capture and an import of the
352
+ # run dir agree on it and a missed live event is filled in later without a duplicate.
353
+
354
+ INTERVENTIONS = {
355
+ 'escalation': ('after_attempt', 'from', 'to', 'source'),
356
+ 'control': ('at', 'actor', 'action', 'key', 'value', 'previous'),
357
+ }
358
+
359
+
360
+ def intervention_data(name: str, values: dict) -> dict:
361
+ """The fields that identify an intervention, as the strings `--set k=v` delivers."""
362
+ return {field: '' if values.get(field) is None else str(values[field]) for field in INTERVENTIONS[name]}
363
+
364
+
365
+ def intervention_rid(name: str, uid: str | None, task: str | None, values: dict) -> str:
366
+ canonical = json.dumps([uid, task, intervention_data(name, values)], sort_keys=True)
367
+ return 'event:%s:%s' % (name, sha256(canonical.encode())[:24])
368
+
369
+
348
370
  # --- dispatch: one model call ----------------------------------------------------------
349
371
 
372
+ def session_record(attempt: Path, role: str, metrics: dict) -> tuple[str | None, str | None]:
373
+ """The harness session, and whether this dispatch was asked to continue one.
374
+
375
+ `<role>.session` is the id live capture published; `metrics.json` fills it in when an
376
+ archive has the id and not the file. `<role>.continue` exists only when continuing was
377
+ requested. `mode=continued` is the success. Any other mode, or a file with no mode, is
378
+ `failed`: the request is what makes the judgment not independent, and a failed continue
379
+ can still have printed a verdict.
380
+ """
381
+ recorded = first_line(attempt / (role + '.session'))
382
+ if not recorded:
383
+ value = metrics.get('session_id')
384
+ recorded = value.strip() if isinstance(value, str) and value.strip() else None
385
+ path = attempt / (role + '.continue')
386
+ if not path.exists():
387
+ return recorded, None
388
+ return recorded, 'continued' if kv(path).get('mode') == 'continued' else 'failed'
389
+
390
+
350
391
  def dispatch_event(attempt: Path, root: Path, role: str, harness: str, rc, prov: dict, src: str = 'live') -> dict:
351
392
  metrics = read_json(attempt / 'metrics.json') or {}
352
393
  resolved = kv(attempt / (role + '.resolved'))
353
394
  infra = kv(attempt / (role + '.infra'))
354
395
  harness = harness or metrics.get('harness') or resolved.get('harness')
355
396
  message = text(attempt / (role + '.last'))
397
+ session_id, session_continue = session_record(attempt, role, metrics)
356
398
  final_source = 'structured' if harness in STRUCTURED else 'log_copy'
357
399
  delivery = resolved.get('delivery') or 'custom'
358
400
  orphans = text(attempt / (role + '.orphans'))
@@ -377,6 +419,7 @@ def dispatch_event(attempt: Path, root: Path, role: str, harness: str, rc, prov:
377
419
  prompt_bytes=metrics.get('prompt_bytes'), final_source=final_source,
378
420
  verdict=verdict, findings=found, learnings=learnings(message),
379
421
  findings_parse=review_completeness(verdict, found, parsed) if role == 'qa' else None,
422
+ session_id=session_id, session_continue=session_continue,
380
423
  root=str(root), scope=prov.get('scope'), trigger=prov.get('trigger'),
381
424
  **({'shadow': shadow} if shadow else {}),
382
425
  )
@@ -650,7 +693,7 @@ def plan_event(plan: Path, prov: dict, src: str = 'live', inventory: bool = True
650
693
  fractal_selection=read_json(plan / 'fractal-selection.json'),
651
694
  jev_coupling=read_json(plan / 'jev-coupling.json'),
652
695
  options={name: first_line(plan / name) for name in ('qa_threshold', 'verify_retries', 'timeout', 'final_qa',
653
- 'shadow_qa', 'shadow_rate', 'explore')
696
+ 'shadow_qa', 'shadow_rate', 'explore', 'reviewer_session')
654
697
  if (plan / name).exists()},
655
698
  flags=sorted(name for name in ('no_memory', 'no_ripwire', 'yolo_dwarf', 'yolo_qa') if (plan / name).exists()),
656
699
  blobs=blobs(goal=put_file(plan / 'goal.txt'), request=put_file(plan / 'request.md', 'prompt'),