@giamme/forge 0.10.0 → 0.11.1

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 (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/SKILL.md +4 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +13 -1
  5. package/docs/commands.md +11 -5
  6. package/docs/dataset.md +13 -0
  7. package/docs/decomposed-runs.md +13 -3
  8. package/docs/forge-ui.md +8 -3
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +73 -2
  11. package/forge +16 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +86 -4
  14. package/references/decompose.md +24 -6
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +4 -1
  17. package/scripts/forge-config.py +5 -2
  18. package/scripts/forge-control.py +4 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +345 -17
  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 +132 -14
  24. package/scripts/forge-parallel.sh +96 -11
  25. package/scripts/forge-prompt.py +61 -4
  26. package/scripts/forge-route.py +72 -12
  27. package/scripts/forge-runtime.py +47 -1
  28. package/scripts/forge-ui.py +8 -4
  29. package/scripts/forge_dataset/__init__.py +7 -2
  30. package/scripts/forge_dataset/cli.py +44 -1
  31. package/scripts/forge_dataset/export.py +36 -3
  32. package/scripts/forge_dataset/forecast.py +355 -0
  33. package/scripts/forge_dataset/harvest.py +23 -2
  34. package/scripts/forge_dataset/importer.py +20 -8
  35. package/scripts/forge_dataset/reviewers.py +145 -0
  36. package/scripts/forge_dataset/routing.py +1 -0
  37. package/scripts/forge_jev/__init__.py +7 -3
  38. package/scripts/forge_registry.py +15 -10
  39. package/ui/README.md +5 -4
  40. package/ui/docs/api.md +88 -23
  41. package/ui/forge_ui/control.py +26 -5
  42. package/ui/forge_ui/reader.py +184 -11
  43. package/ui/forge_ui/static/app.js +24 -10
  44. package/ui/forge_ui/static/cloud3d.js +4 -1
  45. package/ui/forge_ui/static/cloudmodel.js +1 -1
  46. package/ui/forge_ui/static/cloudui.js +13 -4
  47. package/ui/forge_ui/static/findingsview.js +65 -14
  48. package/ui/forge_ui/static/graph.js +6 -5
  49. package/ui/forge_ui/static/leaderboard.js +18 -0
  50. package/ui/forge_ui/static/modelstats.js +19 -1
  51. package/ui/forge_ui/static/panel.js +52 -10
  52. package/ui/forge_ui/static/runmodel.js +128 -13
  53. package/ui/forge_ui/static/scene3d.js +32 -15
  54. package/ui/forge_ui/static/settings.js +48 -12
  55. package/ui/forge_ui/static/style.css +7 -3
@@ -5,20 +5,72 @@ from __future__ import annotations
5
5
  import argparse
6
6
  from pathlib import Path
7
7
 
8
+ DIFF_INLINE_CAP = 100 * 1024
9
+ REPLY_CAP = 8 * 1024
10
+
11
+ # What a reviewer is asked to do with the previous round's report. The labelled-line rule
12
+ # matters: the severity gate, `forge issues` and forge-ui count every `- [Px][...]` line as open.
13
+ QA_ROUND_NOTE = """\
14
+ Check each finding above first: is it fixed, still present, or validly rejected by the
15
+ implementer (see their reply below)? Report a finding that is still present again, in the
16
+ usual labelled format with its severity. Mention a fixed or rejected finding only in a plain
17
+ sentence, never as a `- [Px][CONFIRMED]` or `- [Px][PLAUSIBLE]` line: every labelled line is
18
+ counted as an open finding. This round's threshold, in the contract below, governs; it may
19
+ differ from the last round's. Then review the full task diff below: the changes since that
20
+ round are where new problems are most likely."""
21
+
22
+
23
+ def read(path: str | None, errors: str = 'strict') -> str:
24
+ if path and Path(path).is_file():
25
+ with Path(path).open(newline="", errors=errors) as source:
26
+ return source.read()
27
+ return ''
28
+
29
+
30
+ def carried(text: str) -> str:
31
+ # The dataset and forge-ui take the LAST ```diff fence of a QA prompt as the reviewed
32
+ # diff, so nothing carried from an earlier round may open one.
33
+ return text.replace('```diff', '```text')
34
+
35
+
36
+ def qa_round(findings: str, diff: str | None, span: str | None, stat: str | None, reply: str | None) -> str:
37
+ parts = ['## Findings from the previous review round\n' + carried(findings).rstrip('\n') + '\n\n' + QA_ROUND_NOTE]
38
+ # A git diff carries raw source bytes, so a file that is not UTF-8 must not abort the prompt.
39
+ patch = read(diff, 'replace')
40
+ if patch:
41
+ label = ' (' + span.strip() + ')' if span and span.strip() else ''
42
+ if len(patch.encode()) <= DIFF_INLINE_CAP:
43
+ body = '```patch\n' + patch.rstrip('\n') + '\n```'
44
+ else:
45
+ body = ('The diff since that review is too large to inline; its stat:\n\n```text\n'
46
+ + read(stat, 'replace').rstrip('\n') + '\n```')
47
+ parts.append('### Changes since that review' + label + '\n' + body)
48
+ said = read(reply, 'replace')
49
+ if said.strip():
50
+ raw = said.encode()
51
+ if len(raw) > REPLY_CAP:
52
+ said = raw[:REPLY_CAP].decode(errors='ignore') + '\n[... the reply continues; cut here ...]\n'
53
+ parts.append("### The implementer's reply (its own account; verify every claim against the code)\n"
54
+ + carried(said).rstrip('\n'))
55
+ return '\n\n'.join(parts) + '\n'
56
+
8
57
 
9
58
  def context(
10
59
  requirements: str, approach: str | None = None, capsule: str | None = None,
11
60
  retry: str | None = None, goal: str | None = None, memories: tuple[str, ...] = (),
61
+ role: str = 'dwarf', retry_diff: str | None = None, retry_range: str | None = None,
62
+ retry_stat: str | None = None, reply: str | None = None,
12
63
  ) -> str:
13
64
  chunks = []
14
65
  for title, path in [('Ownership and baseline', capsule), ('Overall goal', goal),
15
66
  ('Complete implementation requirements', requirements),
16
67
  ('Intended approach', approach),
17
68
  ('Your previous attempt was reviewed and rejected', retry)]:
18
- if path and Path(path).is_file():
19
- with Path(path).open(newline="") as source:
20
- data = source.read()
21
- if data:
69
+ data = read(path) if path and Path(path).is_file() else ''
70
+ if data:
71
+ if role == 'qa' and path == retry:
72
+ chunks.append(qa_round(data, retry_diff, retry_range, retry_stat, reply).rstrip('\n'))
73
+ else:
22
74
  chunks.append('## ' + title + '\n' + data)
23
75
  # Memory injection emits headings and prose around bullet facts. Union only
24
76
  # exact fact lines, preserving distinct facts even when they share a key.
@@ -40,6 +92,11 @@ if __name__ == '__main__':
40
92
  for key in ('requirements', 'approach', 'capsule', 'retry', 'goal'):
41
93
  parser.add_argument('--' + key, required=key == 'requirements')
42
94
  parser.add_argument('--memory', action='append', default=[])
95
+ parser.add_argument('--role', choices=('dwarf', 'qa'), default='dwarf')
96
+ parser.add_argument('--retry-diff')
97
+ parser.add_argument('--retry-range')
98
+ parser.add_argument('--retry-stat')
99
+ parser.add_argument('--reply')
43
100
  args = vars(parser.parse_args())
44
101
  args['memories'] = args.pop('memory')
45
102
  print(context(**args), end='')
@@ -16,7 +16,7 @@ bias (inverse-propensity weighting needs the probability each choice had).
16
16
  rewritten) and marks a changed pick in tasks/<id>/route.explored for the table. A row
17
17
  with no neighbour is not explored and records nothing.
18
18
 
19
- forge-route.py escalation --plan P --task ID [--check]
19
+ forge-route.py escalation --plan P --task ID [--check] [--qa-only-next yes|no]
20
20
  The retry policy: after <plan>/escalate_after (default 3; 0 = off) failed attempts on
21
21
  one spec, the next attempt goes to the next tier up of the user's pools instead of
22
22
  repeating the same dispatch. Prints that spec when one is due. When none exists, writes
@@ -25,11 +25,21 @@ bias (inverse-propensity weighting needs the probability each choice had).
25
25
  the streak no longer holds. A pending tasks/<id>/escalation.pin (a human's one-shot
26
26
  choice, applied by prepare_retry) is what `--check` reports as the next model instead
27
27
  (escalation.next = its spec), whatever escalate_after says, unless the next retry is a
28
- QA-only resume or the plan is Fractal (pins never apply there). Nothing has applied the
29
- pin yet, so the policy's own stop and split markers stay as the policy decided them
30
- beneath it: they are what holds again if the pin is withdrawn.
31
- The marker writes take <plan>/control.lock and re-read escalate_after inside it: a
32
- policy decision made before a human switched the policy off writes nothing.
28
+ QA-only resume or the plan is Fractal (pins never apply there). `--qa-only-next` is
29
+ that fact as the caller measured it; without the option, a resume marker or an
30
+ unreviewed commit is taken as QA-only. Nothing has applied the pin yet, so the policy's
31
+ own stop and split markers stay as the policy decided them beneath it: they are what
32
+ holds again if the pin is withdrawn. The marker writes take <plan>/control.lock and
33
+ re-read escalate_after inside it: a policy decision made before a human switched the
34
+ policy off writes nothing.
35
+
36
+ forge-route.py record --plan P --task ID (--probe | --line TEXT)
37
+ Internal. Probe or append tasks/<id>/escalations.tsv through forge-control.py, which
38
+ walks from the plan one directory at a time and refuses a symlink at any component.
39
+ `--probe` exits 0 when every component below the plan is a real directory and the file
40
+ is absent or a regular file. `--line` appends one record under those same conditions.
41
+ A missing parent exits 1 and writes nothing: the walk has no directory descriptor then,
42
+ and opening the leaf would create it in the process's current directory.
33
43
 
34
44
  The propensity of the chosen spec is (1-E)*[chosen == SPEC] + E/2. Forge never picks a
35
45
  model the user did not name: both helpers read only the pools `plan` stored in
@@ -389,11 +399,10 @@ def _pin(tdir: Path):
389
399
 
390
400
 
391
401
  def _qa_next(tdir: Path) -> bool:
392
- """True when the task's next retry only re-runs QA, so it cannot apply a pin.
402
+ """Fallback when `--qa-only-next` is absent: a resume marker, or an unreviewed commit.
393
403
 
394
- That is a `resume` marker (a QA-stage infrastructure stop), or a verdict-less review of
395
- committed work: prepare_retry turns that into the marker, but it has not run yet when
396
- `--check` does, right after the attempt.
404
+ This does not look at the worktree. Callers that measured it (qa_only_next) pass
405
+ `--qa-only-next` instead, so a marker the worktree no longer matches is a full retry.
397
406
  """
398
407
  if (tdir / 'resume').exists():
399
408
  return True
@@ -455,8 +464,10 @@ def escalation(args) -> int:
455
464
  idle = after <= 0 or fractal or (tdir / 'resume').exists()
456
465
  # A pin is a human's choice and outranks the policy, so `--check` reports it as the next
457
466
  # model whatever escalate_after says; prepare_retry applies it and claims the file. The
458
- # policy is still decided underneath it and its markers kept current.
459
- pin = _pin(tdir) if args.check and not fractal and not _qa_next(tdir) else None
467
+ # policy is still decided underneath it and its markers kept current. The policy's own
468
+ # idle test above is separate and stays on the raw resume marker.
469
+ qa_only = _qa_next(tdir) if args.qa_only_next is None else args.qa_only_next == 'yes'
470
+ pin = _pin(tdir) if args.check and not fractal and not qa_only else None
460
471
  clear, writes, shown, rc = (MARKERS if after <= 0 else ()), (), '', 0
461
472
  if not idle:
462
473
  clear, writes, shown, rc = _decide(plan, tdir, args, after)
@@ -474,6 +485,45 @@ def escalation(args) -> int:
474
485
  return rc
475
486
 
476
487
 
488
+ def _load_control():
489
+ """forge-control.py, by path: probe and append_line refuse a symlink at any component."""
490
+ import importlib.util
491
+ spec = importlib.util.spec_from_file_location(
492
+ 'forge_control', Path(__file__).resolve().with_name('forge-control.py'))
493
+ module = importlib.util.module_from_spec(spec)
494
+ sys.modules['forge_control'] = module
495
+ spec.loader.exec_module(module)
496
+ return module
497
+
498
+
499
+ def record_escalation(args) -> int:
500
+ """Probe or append tasks/<id>/escalations.tsv. 0 when that is safe; 1 when it is not.
501
+
502
+ A missing parent is a refusal on both paths. probe() reports that as the file being
503
+ absent, so the check has to look at the walk itself. append_line refuses the same case
504
+ before it opens the leaf: with no directory descriptor that open would use the process
505
+ cwd, including when the task directory disappears between this check and the append.
506
+ """
507
+ if not args.task or args.task in ('.', '..') or '/' in args.task or '\0' in args.task:
508
+ return 1
509
+ try:
510
+ control = _load_control()
511
+ plan = os.path.realpath(args.plan)
512
+ rel = 'tasks/%s/escalations.tsv' % args.task
513
+ parent, _leaf = control._open_parent(plan, rel)
514
+ if parent is None:
515
+ return 1
516
+ os.close(parent)
517
+ if args.probe:
518
+ control.probe(plan, rel)
519
+ else:
520
+ line = args.line if args.line.endswith('\n') else args.line + '\n'
521
+ control.append_line(plan, rel, line)
522
+ except Exception:
523
+ return 1
524
+ return 0
525
+
526
+
477
527
  def main(argv=None) -> int:
478
528
  ap = argparse.ArgumentParser(prog='forge-route.py')
479
529
  sub = ap.add_subparsers(dest='command', required=True)
@@ -484,6 +534,14 @@ def main(argv=None) -> int:
484
534
  p.add_argument('--plan', required=True)
485
535
  p.add_argument('--task', required=True)
486
536
  p.add_argument('--check', action='store_true')
537
+ p.add_argument('--qa-only-next', choices=('yes', 'no'), default=None,
538
+ help='whether the next entry only re-runs QA, as the caller measured it')
539
+ p = sub.add_parser('record', help=argparse.SUPPRESS)
540
+ p.add_argument('--plan', required=True)
541
+ p.add_argument('--task', required=True)
542
+ mode = p.add_mutually_exclusive_group(required=True)
543
+ mode.add_argument('--probe', action='store_true')
544
+ mode.add_argument('--line')
487
545
  args = ap.parse_args(argv)
488
546
  if args.command == 'explore':
489
547
  try:
@@ -491,6 +549,8 @@ def main(argv=None) -> int:
491
549
  except Exception:
492
550
  print(args.default)
493
551
  return 0
552
+ if args.command == 'record':
553
+ return record_escalation(args)
494
554
  try:
495
555
  return escalation(args)
496
556
  except Exception:
@@ -175,6 +175,43 @@ def extract(log: str, harness: str) -> tuple[str | None, dict[str, int | None]]:
175
175
  return final, usage
176
176
 
177
177
 
178
+ def session_id(log: str, harness: str) -> str | None:
179
+ """The harness session this dispatch ran in, or None.
180
+
181
+ claude and openclaude name it session_id on the stream-json init line and
182
+ again on the result line. The init line wins. The result line is used when
183
+ there is no init line, or that line has no session_id. A session_id that is
184
+ present but not a string is not a session, and the result line does not
185
+ replace it. codex names it thread_id on the first thread.started line.
186
+ Any other harness, a missing id, or an id that is not a plain string is None.
187
+ """
188
+ if harness not in ('claude', 'openclaude', 'codex'):
189
+ return None
190
+ saw_init = False
191
+ result_id = None
192
+ for line in log.splitlines():
193
+ try:
194
+ event = json.loads(line)
195
+ except ValueError:
196
+ continue
197
+ if not isinstance(event, dict):
198
+ continue
199
+ if harness == 'codex':
200
+ if event.get('type') == 'thread.started':
201
+ value = event.get('thread_id')
202
+ return value if type(value) is str else None
203
+ continue
204
+ kind = event.get('type')
205
+ if not saw_init and kind == 'system' and event.get('subtype') == 'init':
206
+ saw_init = True
207
+ if 'session_id' in event:
208
+ value = event.get('session_id')
209
+ return value if type(value) is str else None
210
+ elif kind == 'result' and 'session_id' in event:
211
+ result_id = event.get('session_id')
212
+ return result_id if type(result_id) is str else None
213
+
214
+
178
215
  def finish(attempt: str, root: str, role: str, harness: str, rc: str) -> None:
179
216
  try:
180
217
  _finish(Path(attempt), Path(root), role, harness, rc)
@@ -189,6 +226,7 @@ def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None
189
226
  structured = harness in STRUCTURED
190
227
  text = log.read_text(errors='replace') if structured and log.exists() else ''
191
228
  final, usage = extract(text, harness)
229
+ found = session_id(text, harness)
192
230
  if not last.exists() or not last.stat().st_size:
193
231
  if final is not None:
194
232
  last.write_text(final + '\n')
@@ -201,6 +239,7 @@ def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None
201
239
  data.update(
202
240
  harness=harness,
203
241
  prompt_bytes=prompt.stat().st_size if prompt_ready else None,
242
+ session_id=found,
204
243
  **usage,
205
244
  )
206
245
  infra = root / (role + '.infra')
@@ -209,7 +248,14 @@ def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None
209
248
  for line in infra.read_text(errors='replace').splitlines():
210
249
  if line.startswith('class='):
211
250
  data['infra_class'] = line[len('class='):].strip() or None
212
- for suffix in ('prompt', 'last', 'log', 'resolved', 'cmd', 'timedout', 'infra', 'orphans'):
251
+ # Drop a stale id first. A dispatch that published none must not leave the
252
+ # previous one for a later round to continue.
253
+ session_path = root / (role + '.session')
254
+ session_path.unlink(missing_ok=True)
255
+ if found is not None:
256
+ session_path.write_text(found + '\n')
257
+ for suffix in ('prompt', 'last', 'log', 'resolved', 'cmd', 'timedout', 'infra', 'orphans',
258
+ 'session', 'continue'):
213
259
  if suffix == 'prompt' and not prompt_ready:
214
260
  continue
215
261
  source = root / (role + '.' + suffix)
@@ -355,12 +355,16 @@ def cmd_open(args):
355
355
  # the record and the live answer must both name this code and the mode asked for
356
356
  if (server.get('build') == build and answer.get('build') == build and server.get('registry') == registry
357
357
  and bool(server.get('read_only')) == read_only and is_read_only(answer) == read_only):
358
- # Reused, it would run this shell's writes with another registry and dataset, and a
359
- # restart could cut off a write in flight: say so instead, and how to restart it.
358
+ # Reused, its CLIs would run with another registry and dataset. A server that takes
359
+ # writes may be in the middle of one, which a restart could cut off: say so instead,
360
+ # and how to restart it. A read-only one has none to cut off: replace it.
360
361
  change = config_change(server.get('cli_env'), cli_env)
361
- if change:
362
+ if change and read_only:
363
+ terminate(server['pid'])
364
+ server = None
365
+ elif change:
362
366
  _say(f'error: the running forge-ui (pid {server["pid"]}) has another forge configuration '
363
- f'for its writes: {change}. Run `forge ui stop`, then open again.')
367
+ f'for its CLIs: {change}. Run `forge ui stop`, then open again.')
364
368
  return 5
365
369
  else:
366
370
  terminate(server['pid'])
@@ -94,8 +94,13 @@ def data_home() -> Path:
94
94
  explicit = os.environ.get('FORGE_DATA_HOME')
95
95
  if explicit:
96
96
  return Path(explicit).expanduser()
97
- base = os.environ.get('XDG_DATA_HOME') or str(Path.home() / '.local' / 'share')
98
- return Path(base).expanduser() / 'forge'
97
+ # An empty or relative XDG_DATA_HOME counts as unset, as the XDG spec says; taken
98
+ # literally it named a directory below the current one, usually the user's repo.
99
+ value = os.environ.get('XDG_DATA_HOME') or ''
100
+ base = Path(value).expanduser() if value else None
101
+ if base is None or not base.is_absolute():
102
+ base = Path.home() / '.local' / 'share'
103
+ return base / 'forge'
99
104
 
100
105
 
101
106
  def dataset_dir() -> Path:
@@ -1,6 +1,6 @@
1
1
  """`forge dataset ...`: inspect the run dataset, import old runs, and the shell's capture hooks.
2
2
 
3
- User-facing: status, dump, import, export, outcomes. Internal (called by forge's shell scripts, fail-open,
3
+ User-facing: status, dump, import, export, outcomes, routing, reviewers, forecast. Internal (called by forge's shell scripts, fail-open,
4
4
  silent): event, invocation, plan.
5
5
  """
6
6
  from __future__ import annotations
@@ -28,6 +28,24 @@ def _human(n: int) -> str:
28
28
  return str(n)
29
29
 
30
30
 
31
+ def _continued_sessions(db) -> dict:
32
+ """How many dispatches asked to continue a reviewer session.
33
+
34
+ Counted from the dispatch events themselves. A shadow review is not required, and a
35
+ continue that failed still counts: the file was written because continuing was requested.
36
+ """
37
+ continued = failed = 0
38
+ for (raw,) in db.execute(
39
+ "SELECT data FROM events WHERE kind = 'dispatch' AND data LIKE ?",
40
+ ('%"session_continue": "%',)):
41
+ value = json.loads(raw).get('session_continue')
42
+ if value == 'continued':
43
+ continued += 1
44
+ elif value == 'failed':
45
+ failed += 1
46
+ return dict(continued_reviews=continued, failed_continues=failed)
47
+
48
+
31
49
  def status(args) -> int:
32
50
  from . import checks
33
51
  directory = dataset_dir()
@@ -43,6 +61,7 @@ def status(args) -> int:
43
61
  last = db.execute('SELECT MAX(ts) FROM events').fetchone()[0]
44
62
  summary['last_event'] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last)) if last else None
45
63
  summary['checks'] = checks.run(db, verbose=args.verbose)
64
+ summary.update(_continued_sessions(db))
46
65
  if summary['events'].get('dispatch'):
47
66
  from . import export
48
67
  if db.execute("SELECT 1 FROM events WHERE kind = 'dispatch' AND data LIKE '%qa_shadow%' LIMIT 1").fetchone():
@@ -77,6 +96,9 @@ def status(args) -> int:
77
96
  % (shadow['shadow_reviews'], shadow['pairs'], shadow['agree'], rate))
78
97
  for pair, n in shadow['by_pair'].items():
79
98
  print(' %s: %d of %d agree' % (pair, n['agree'], n['pairs']))
99
+ if summary.get('continued_reviews') or summary.get('failed_continues'):
100
+ print(' continued reviews: %d' % summary['continued_reviews'])
101
+ print(' failed continues: %d' % summary['failed_continues'])
80
102
  if summary['legacy_archives']:
81
103
  print(' legacy: %d archived run dir(s) kept in %s (`forge dataset import %s` re-reads them;'
82
104
  ' events already stored are skipped)' % (summary['legacy_archives'], legacy, legacy))
@@ -163,6 +185,16 @@ def run_routing(args) -> int:
163
185
  return routing.main(args)
164
186
 
165
187
 
188
+ def run_reviewers(args) -> int:
189
+ from . import reviewers
190
+ return reviewers.main(args)
191
+
192
+
193
+ def run_forecast(args) -> int:
194
+ from . import forecast
195
+ return forecast.main(args)
196
+
197
+
166
198
  def parser() -> argparse.ArgumentParser:
167
199
  top = argparse.ArgumentParser(prog='forge dataset', description='The run dataset: every run\'s inputs and outcomes.')
168
200
  sub = top.add_subparsers(dest='command', required=True)
@@ -204,6 +236,17 @@ def parser() -> argparse.ArgumentParser:
204
236
  p.add_argument('--since', type=float, metavar='DAYS', help='only tasks first attempted in the last DAYS days')
205
237
  p.add_argument('--json', action='store_true')
206
238
  p.set_defaults(func=run_routing)
239
+ p = sub.add_parser('reviewers', help='how often work each reviewer passed was later reverted or rewritten (advisory)')
240
+ p.add_argument('--horizon-days', type=int, default=14, help='the outcomes recorded at this horizon (default 14)')
241
+ p.add_argument('--repo', help='only this repo')
242
+ p.add_argument('--json', action='store_true')
243
+ p.set_defaults(func=run_reviewers)
244
+ p = sub.add_parser('forecast', help="a plan's likely tokens, time and first-try pass rate, from past tasks (advisory)")
245
+ p.add_argument('plan', help='the plan dir')
246
+ p.add_argument('--repo', help='only history from this repo')
247
+ p.add_argument('--json', action='store_true')
248
+ p.add_argument('--run', action='store_true', help=argparse.SUPPRESS) # forge-parallel.sh run: budgeted, silent, records
249
+ p.set_defaults(func=run_forecast)
207
250
  p = sub.add_parser('event', help=argparse.SUPPRESS)
208
251
  p.add_argument('name')
209
252
  p.add_argument('--dir')
@@ -213,6 +213,10 @@ class Runs:
213
213
  for e in store.of('dispatch'):
214
214
  if e['task']:
215
215
  self.dispatches[(e['run'], e['task'], e['d'].get('role'))].append(e)
216
+ # Store order is import order. A reader that takes [-1] means the dispatch
217
+ # the attempt ended on, so each list is chronological; a tie keeps store order.
218
+ for rows in self.dispatches.values():
219
+ rows.sort(key=lambda e: (e['attempt'] or 0, e['d'].get('wall_started') or e['ts']))
216
220
  self.accepted = {(e['run'], e['d'].get('task')) for e in store.of('invocation')
217
221
  if e['d'].get('command') == 'accept' and e['d'].get('exit') == 0}
218
222
  self.interrupted = {(e['run'], e['task']) for e in store.of('event')
@@ -558,8 +562,23 @@ def _float(value):
558
562
  return None
559
563
 
560
564
 
565
+ def session_continued(event) -> bool:
566
+ """Whether this dispatch was asked to continue a reviewer session.
567
+
568
+ `continued` and `failed` both record the request. A missing field, on a dispatch
569
+ captured before the experiment, is a fresh session.
570
+ """
571
+ return (event.get('d') or {}).get('session_continue') is not None
572
+
573
+
561
574
  def _qa_rows(runs: Runs):
562
- """Every QA dispatch with a verdict, with the diff it was shown and its task text."""
575
+ """Every independent QA dispatch with a verdict, with the diff it was shown and its task text.
576
+
577
+ A dispatch that continued a reviewer session is not an independent judgment, including
578
+ one whose continue failed after it had already printed a verdict. A fresh fallback in
579
+ the same round has no `session_continue` and stays. There is no switch to put the
580
+ continued ones back, so a slice never mixes the two.
581
+ """
563
582
  store = runs.store
564
583
  effort = {}
565
584
  for (run, task), rows in runs.attempts.items():
@@ -572,6 +591,8 @@ def _qa_rows(runs: Runs):
572
591
  if role != 'qa':
573
592
  continue
574
593
  for e in rows:
594
+ if session_continued(e):
595
+ continue
575
596
  d = e['d']
576
597
  if d.get('verdict') not in ('PASS', 'FAIL'):
577
598
  continue
@@ -604,6 +625,12 @@ def shadow_pairs(runs: Runs) -> dict:
604
625
  Usable means the shadow ran to its end on an untouched snapshot (`shadow_review` says
605
626
  done), both reviewers gave a verdict, and the shadow reviewed the commit that attempt
606
627
  committed. Anything else would count noise in the shadow as disagreement.
628
+
629
+ The pair uses the attempt's last verdict-bearing primary. The dispatch list is
630
+ chronological, so that entry is the one the attempt ended on. A QA-only re-run keeps
631
+ the attempt number, so the candidates stay unfiltered until that last one is chosen:
632
+ an earlier fresh verdict must not stand in for a later continued session. The pair is
633
+ then dropped when that chosen primary continued a session.
607
634
  """
608
635
  ended = {}
609
636
  for e in runs.store.of('event'):
@@ -620,8 +647,14 @@ def shadow_pairs(runs: Runs) -> dict:
620
647
  primary = [d for d in runs.dispatches.get((run, task, 'qa'), [])
621
648
  if d['attempt'] == n and d['d'].get('verdict') in ('PASS', 'FAIL')]
622
649
  reviewed = (shadow['d'].get('shadow') or {}).get('reviewed')
623
- if primary and reviewed and reviewed == runs.reviewed.get((run, task, n)):
624
- out[(run, task, n)] = (primary[-1], shadow)
650
+ if not (primary and reviewed and reviewed == runs.reviewed.get((run, task, n))):
651
+ continue
652
+ # Chosen after the list is built, and that list is chronological: filtering
653
+ # continued reviews out first would let an earlier fresh verdict stand in
654
+ # for the one the attempt ended on.
655
+ if session_continued(primary[-1]):
656
+ continue
657
+ out[(run, task, n)] = (primary[-1], shadow)
625
658
  return out
626
659
 
627
660