@giamme/forge 0.10.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 (53) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/SKILL.md +4 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +11 -0
  5. package/docs/commands.md +8 -3
  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 +344 -16
  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_dataset/__init__.py +7 -2
  29. package/scripts/forge_dataset/cli.py +44 -1
  30. package/scripts/forge_dataset/export.py +36 -3
  31. package/scripts/forge_dataset/forecast.py +355 -0
  32. package/scripts/forge_dataset/harvest.py +23 -2
  33. package/scripts/forge_dataset/importer.py +20 -8
  34. package/scripts/forge_dataset/reviewers.py +145 -0
  35. package/scripts/forge_dataset/routing.py +1 -0
  36. package/scripts/forge_jev/__init__.py +7 -3
  37. package/scripts/forge_registry.py +15 -10
  38. package/ui/README.md +4 -3
  39. package/ui/docs/api.md +75 -15
  40. package/ui/forge_ui/control.py +12 -0
  41. package/ui/forge_ui/reader.py +184 -11
  42. package/ui/forge_ui/static/app.js +24 -10
  43. package/ui/forge_ui/static/cloud3d.js +4 -1
  44. package/ui/forge_ui/static/cloudmodel.js +1 -1
  45. package/ui/forge_ui/static/cloudui.js +13 -4
  46. package/ui/forge_ui/static/findingsview.js +29 -3
  47. package/ui/forge_ui/static/graph.js +6 -5
  48. package/ui/forge_ui/static/leaderboard.js +18 -0
  49. package/ui/forge_ui/static/modelstats.js +19 -1
  50. package/ui/forge_ui/static/panel.js +52 -10
  51. package/ui/forge_ui/static/runmodel.js +128 -13
  52. package/ui/forge_ui/static/scene3d.js +32 -15
  53. package/ui/forge_ui/static/style.css +7 -3
@@ -6,6 +6,13 @@ A run can end with findings nobody will act on: CONFIRMED findings `--qa-thresho
6
6
  `--include-plausible`), and whatever a whole-run review left open. This drafts one issue per
7
7
  finding (or per task/run with `--group`) and creates them only with `--apply`.
8
8
 
9
+ A whole-run review whose integration branch has moved is marked outdated: its findings stay
10
+ listed (a later merge may have fixed only some of them) and the issue names the tasks that
11
+ merged after it. CONFIRMED findings under `## Accepted by a human despite the reviewer's`
12
+ heading are origin `accepted` — a person accepted that verdict — not `tolerated`. A grouped
13
+ issue repeats every distinct acceptance in the group, and the outdated-review sentence of
14
+ every review in it, not only the first finding's.
15
+
9
16
  <run> is a plan dir, a task dir, a review dir (`<plan>/final-review`), known_issues.md or qa.last.
10
17
  Tasks that did not pass are skipped: their findings still block them.
11
18
 
@@ -14,7 +21,7 @@ gh-proman computes, and both tools skip a finding either of them already filed,
14
21
  When the gh-proman skill is configured for the repository, prefer `gh-proman findings`: it also
15
22
  adds the issues to the team's project board with a priority. This command is the fallback.
16
23
 
17
- --json lists every finding in `findings` (fp, severity, origin, plan, task, text, `filed`, ...). --offline never
24
+ --json lists every finding in `findings` (fp, severity, origin, plan, task, text, `filed`, `review`, ...). --offline never
18
25
  runs `gh` (no filed state, publication of commits judged from local refs only). --only FP[,FP] keeps those
19
26
  fingerprints. --all-runs reads <run> as a repository (or a plan dir naming one) and collects every run in its
20
27
  registry, the newest plan winning for a finding present in several. A caller filing an explicit selection
@@ -48,6 +55,7 @@ import errno
48
55
  import fcntl
49
56
  import hashlib
50
57
  import json
58
+ import math
51
59
  import os
52
60
  import re
53
61
  import stat
@@ -76,11 +84,16 @@ TEXT = {
76
84
  "plausible": "To verify: ",
77
85
  "tolerated": "Forge review finding left open: it was below the blocking threshold {threshold}, so the "
78
86
  "work was merged without fixing it.",
87
+ "accepted": "A person accepted the work over the reviewer's {status} verdict, so it was merged without fixing it.",
88
+ "accepted_reason": "Reason: {reason}",
79
89
  "review": "Forge whole-run review finding left open: check whether it was already fixed before working on it.",
90
+ "outdated": "The review is outdated: {n} {tasks} merged into the integration branch after it{ids}, "
91
+ "so check whether they fixed it first.",
80
92
  "plausible_ctx": "PLAUSIBLE forge review finding (low confidence): verify it before fixing. If it turns out "
81
93
  "to be wrong, close the issue saying why.",
82
94
  "group": "Forge review findings left open in run `{plan}` ({n}).",
83
95
  "group_title": "Non-blocking forge findings: {scope}",
96
+ "group_title_accepted": "Accepted forge findings: {scope}",
84
97
  "origin": "Origin: forge `{plan}`, task `{task}`, severity {severity}, reviewed by `{reviewer}`{commit}.",
85
98
  "headings": ["Context", "Finding", "Tasks", "Closing criteria", "References"],
86
99
  "tasks": ["Reproduce the described case", "Fix it", "Add a regression test that fails before the fix"],
@@ -95,12 +108,18 @@ TEXT = {
95
108
  "plausible": "Da verificare: ",
96
109
  "tolerated": "Segnalazione della review di forge rimasta aperta: era sotto la soglia bloccante {threshold}, "
97
110
  "quindi il lavoro è stato integrato senza correggerla.",
111
+ "accepted": "Una persona ha accettato il lavoro nonostante il verdetto {status} del revisore, quindi è "
112
+ "stato integrato senza correggerlo.",
113
+ "accepted_reason": "Motivo: {reason}",
98
114
  "review": "Segnalazione della review complessiva di forge rimasta aperta: verificare se è già stata "
99
115
  "corretta prima di lavorarci.",
116
+ "outdated": "La review è obsoleta: {n} {tasks} nel branch di integrazione dopo di essa{ids}, quindi "
117
+ "verifica prima se l'hanno già corretta.",
100
118
  "plausible_ctx": "Segnalazione PLAUSIBILE della review di forge (confidenza bassa): va verificata prima di "
101
119
  "correggere. Se risulta infondata, chiudi l'issue spiegando perché.",
102
120
  "group": "Segnalazioni della review di forge rimaste aperte nel run `{plan}` ({n}).",
103
121
  "group_title": "Segnalazioni forge non bloccanti: {scope}",
122
+ "group_title_accepted": "Segnalazioni forge accettate: {scope}",
104
123
  "origin": "Origine: forge `{plan}`, task `{task}`, gravità {severity}, review di `{reviewer}`{commit}.",
105
124
  "headings": ["Contesto", "Segnalazione", "Attività", "Criteri di chiusura", "Riferimenti"],
106
125
  "tasks": ["Riprodurre il caso descritto", "Correggere",
@@ -166,6 +185,222 @@ def reviewer_spec(task_dir: Path):
166
185
  return ":".join(x for x in (kv.get("alias"), kv.get("effort"), kv.get("harness")) if x) or "?"
167
186
 
168
187
 
188
+ # `final-review-<epoch>-<pid>` or `integrate-<epoch>-<pid>`. `at` is that whole-second epoch.
189
+ _REVIEW_KIND = re.compile(r"(final-review|integrate)-([0-9]+)-([0-9]+)\Z")
190
+ # What `git rev-parse` writes for a commit: sha1 (40) or sha256 (64), lowercase hex.
191
+ _COMMIT = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
192
+ # accept appends the reviewer's reply under this heading; findings before it were threshold-tolerated.
193
+ _ACCEPTED_HEADING = re.compile(
194
+ r"^## Accepted by a human despite the reviewer's(?:\s+(\S+))?\s+verdict\s*$", re.M)
195
+
196
+
197
+ def _epoch(text):
198
+ if len(text) > 15:
199
+ return None
200
+ try:
201
+ at = float(text)
202
+ except (ValueError, OverflowError):
203
+ return None
204
+ return at if math.isfinite(at) else None
205
+
206
+
207
+ def review_clock(review_dir: Path):
208
+ """(source, at) of a whole-run review directory.
209
+
210
+ `final-review-<epoch>-<pid>` is source `review`. `integrate-<epoch>-<pid>/final-review` is
211
+ source `integrate`, and `at` is the epoch in the integrate directory's name. A directory
212
+ literally named `final-review` with no such parent is a review whose clock is unknown.
213
+ """
214
+ review_dir = Path(review_dir).resolve()
215
+ if review_dir.name == "final-review":
216
+ match = _REVIEW_KIND.fullmatch(review_dir.parent.name)
217
+ if match and match.group(1) == "integrate":
218
+ return "integrate", _epoch(match.group(2))
219
+ return "review", None
220
+ match = _REVIEW_KIND.fullmatch(review_dir.name)
221
+ if match and match.group(1) == "final-review":
222
+ return "review", _epoch(match.group(2))
223
+ return "review", None
224
+
225
+
226
+ def review_relname(plan_dir, review_dir: Path):
227
+ """The review directory's path relative to the plan, posix, after resolving links."""
228
+ review_dir = Path(review_dir).resolve()
229
+ if plan_dir:
230
+ try:
231
+ return review_dir.relative_to(Path(plan_dir).resolve()).as_posix()
232
+ except ValueError:
233
+ pass
234
+ return review_dir.name
235
+
236
+
237
+ def _meta_fields(review_dir: Path):
238
+ fields = {}
239
+ for line in read(review_dir / "review.meta").splitlines():
240
+ key, sep, value = line.partition("=")
241
+ if sep and key.strip():
242
+ fields[key.strip()] = value.strip()
243
+ return fields
244
+
245
+
246
+ def _known_integration(meta):
247
+ """The `integration=` commit, or None when it is missing or not a commit object name.
248
+
249
+ A malformed value is not the commit the review judged, so freshness uses the next rule.
250
+ """
251
+ value = meta.get("integration") or None
252
+ if not value:
253
+ return None
254
+ return value if _COMMIT.fullmatch(value) else None
255
+
256
+
257
+ def _task_id_ok(name: str) -> bool:
258
+ """True for a task id the scheduler and the UI reader both accept.
259
+
260
+ Empty, `.`, `..`, a slash, a NUL or U+FFFD are not ids. An id may start with `_`.
261
+ """
262
+ return bool(name) and name not in (".", "..") and "/" not in name and "\0" not in name and "\ufffd" not in name
263
+
264
+
265
+ def _listed_task_ids(plan_dir: Path):
266
+ """Task ids from tasks.tsv, in file order.
267
+
268
+ Only those rows are tasks: `tasks/` also holds forge's own directories (for example
269
+ `tasks/_integration`), which are not tasks unless a row lists them. A row needs seven
270
+ columns, the same minimum the UI reader requires. The first of a duplicate id wins.
271
+ """
272
+ found, seen = [], set()
273
+ for line in read(Path(plan_dir) / "tasks.tsv").splitlines():
274
+ if not line.strip() or line.startswith("#"):
275
+ continue
276
+ parts = line.split("\t")
277
+ if len(parts) < 7:
278
+ continue
279
+ ident = parts[0]
280
+ if not _task_id_ok(ident) or ident in seen:
281
+ continue
282
+ seen.add(ident)
283
+ found.append(ident)
284
+ return found
285
+
286
+
287
+ def _task_merges(plan_dir: Path):
288
+ """(id, mtime) of every listed task's `merged` marker, oldest merge first, then id.
289
+
290
+ The ids are `_listed_task_ids`: a listed `_fix` counts, and an unlisted `tasks/_integration`
291
+ does not. This is the set whose merge times the UI uses for `merged_since`.
292
+ """
293
+ root = Path(plan_dir) / "tasks"
294
+ pairs = []
295
+ for ident in _listed_task_ids(plan_dir):
296
+ marker = root / ident / "merged"
297
+ try:
298
+ if marker.is_file():
299
+ pairs.append((ident, marker.stat().st_mtime))
300
+ except OSError:
301
+ continue
302
+ pairs.sort(key=lambda item: (item[1], item[0]))
303
+ return pairs
304
+
305
+
306
+ def _first_line(path: Path):
307
+ text = read(path)
308
+ return text.splitlines()[0].strip() if text else ""
309
+
310
+
311
+ def review_freshness(accepted, integration, tip, source, at, merges, verdict):
312
+ """(outdated, merged_since) for one final review. Same rule as forge-ui's FinalReviewSummary.
313
+
314
+ Null when the review has no verdict or the plan has no `accepted.integration`. Otherwise the
315
+ first rule that applies: a known `integration` commit, else `tip` when `source` is `review`,
316
+ else (an older integrate review) any task merged after `at`. A merge counts when its marker
317
+ mtime is `>= at + 1`. `merged_since` is those ids, oldest first, and is empty unless the
318
+ review is outdated.
319
+ """
320
+ later = []
321
+ if at is not None:
322
+ later = [item for item in merges if item[1] is not None and item[1] >= at + 1]
323
+ later.sort(key=lambda item: (item[1], item[0]))
324
+ ids = [task_id for task_id, _merged_at in later]
325
+ if not verdict or not accepted:
326
+ return None, []
327
+ if integration:
328
+ moved = integration != accepted
329
+ elif source == "review":
330
+ moved = tip != accepted
331
+ else:
332
+ moved = bool(ids)
333
+ return (True, ids) if moved else (False, [])
334
+
335
+
336
+ def review_outdated(plan_dir, review_dir):
337
+ """(outdated, merged_since) for the whole-run review directory `review_dir` in `plan_dir`."""
338
+ if not plan_dir or not review_dir:
339
+ return None, []
340
+ review_dir = Path(review_dir)
341
+ meta = _meta_fields(review_dir)
342
+ source, at = review_clock(review_dir)
343
+ verdict = (_first_line(review_dir / "verdict").split() or [""])[0]
344
+ return review_freshness(_first_line(Path(plan_dir) / "accepted.integration"), _known_integration(meta),
345
+ meta.get("tip") or None, source, at, _task_merges(plan_dir), verdict)
346
+
347
+
348
+ def review_stamp(plan_dir, review_dir):
349
+ """The `review` object a whole-run finding carries, or None when there is no review directory."""
350
+ if not review_dir:
351
+ return None
352
+ outdated, merged_since = review_outdated(plan_dir, review_dir)
353
+ return {"name": review_relname(plan_dir, review_dir), "outdated": outdated, "merged_since": merged_since}
354
+
355
+
356
+ def acceptance_of(task_dir):
357
+ """(status, reason) recorded in `accepted.by`.
358
+
359
+ Key=value (`status`, `reason`, …) is the current record. Free text, which older runs wrote,
360
+ is a reason and no status. `reason` is cut to REASON_MAX characters.
361
+ """
362
+ if not task_dir:
363
+ return "", ""
364
+ raw = read(Path(task_dir) / "accepted.by")
365
+ if not raw:
366
+ return "", ""
367
+ fields = {}
368
+ for line in raw.splitlines():
369
+ if not line.strip():
370
+ continue
371
+ if "=" not in line:
372
+ return "", raw[:REASON_MAX]
373
+ key, value = line.split("=", 1)
374
+ key = key.strip()
375
+ if not key or re.search(r"\s", key):
376
+ return "", raw[:REASON_MAX]
377
+ fields[key] = value.strip()
378
+ if not fields:
379
+ return "", raw[:REASON_MAX]
380
+ return fields.get("status", ""), fields.get("reason", "")[:REASON_MAX]
381
+
382
+
383
+ def known_findings(text, task_dir, meta):
384
+ """CONFIRMED findings of a task's known_issues.md, split at the acceptance heading.
385
+
386
+ Findings before `## Accepted by a human despite the reviewer's <status> verdict` stay
387
+ `tolerated`. Findings after it are `accepted`. The heading's status is used only when
388
+ `accepted.by` does not record one.
389
+ """
390
+ text = text or ""
391
+ match = _ACCEPTED_HEADING.search(text)
392
+ before, after = (text[:match.start()], text[match.end():]) if match else (text, "")
393
+ found = [{**f, **meta, "origin": "tolerated", "review": None}
394
+ for f in parse_findings(before) if f["confidence"] == "CONFIRMED"]
395
+ if match:
396
+ file_status, reason = acceptance_of(task_dir)
397
+ status = file_status or match.group(1) or "UNKNOWN"
398
+ found += [{**f, **meta, "origin": "accepted", "review": None,
399
+ "accepted_status": status, "accepted_reason": reason}
400
+ for f in parse_findings(after) if f["confidence"] == "CONFIRMED"]
401
+ return found
402
+
403
+
169
404
  def task_findings(task_dir: Path, plan_dir, include_plausible, reviewers, review=None):
170
405
  """`review` True: task_dir is a whole-run review dir (it may hold a known_issues.md when a threshold applied)."""
171
406
  status = (read(task_dir / "status").split() or [""])[0]
@@ -184,7 +419,10 @@ def task_findings(task_dir: Path, plan_dir, include_plausible, reviewers, review
184
419
  review = not status and task not in results and not (task_dir / "known_issues.md").exists()
185
420
  if review:
186
421
  # A review with no task behind it (whole-run review): everything it found is still open.
187
- found = [{**f, **meta, "task": None if whole_run else task, "origin": "review" if f["confidence"] == "CONFIRMED" else "plausible", "threshold": "-"}
422
+ stamp = review_stamp(plan_dir, task_dir) if whole_run else None
423
+ found = [{**f, **meta, "task": None if whole_run else task,
424
+ "origin": "review" if f["confidence"] == "CONFIRMED" else "plausible",
425
+ "threshold": "-", "review": stamp}
188
426
  for f in parse_findings(read(task_dir / "qa.last"))
189
427
  if f["confidence"] == "CONFIRMED" or include_plausible]
190
428
  return found, None
@@ -192,11 +430,10 @@ def task_findings(task_dir: Path, plan_dir, include_plausible, reviewers, review
192
430
  return [], f"{task}: status {state or 'unknown'}, its findings still block it"
193
431
  threshold = read(task_dir / "qa_threshold") or (read(plan_dir / "qa_threshold") if plan_dir else "") or "?"
194
432
  meta["threshold"] = threshold
195
- found = [{**f, **meta, "origin": "tolerated"} for f in parse_findings(read(task_dir / "known_issues.md"))
196
- if f["confidence"] == "CONFIRMED"]
433
+ found = known_findings(read(task_dir / "known_issues.md"), task_dir, meta)
197
434
  if include_plausible:
198
- found += [{**f, **meta, "origin": "plausible"} for f in parse_findings(read(task_dir / "qa.last"))
199
- if f["confidence"] == "PLAUSIBLE"]
435
+ found += [{**f, **meta, "origin": "plausible", "review": None}
436
+ for f in parse_findings(read(task_dir / "qa.last")) if f["confidence"] == "PLAUSIBLE"]
200
437
  return found, None
201
438
 
202
439
 
@@ -314,10 +551,16 @@ def collect(path: Path, include_plausible, all_runs=False):
314
551
  "reviewer": reviewer_spec(parent)}
315
552
  meta["plan"], meta["plan_dir"] = (plan_root.name, str(plan_root))
316
553
  if is_review:
317
- found = [{**f, **meta, "origin": "review" if f["confidence"] == "CONFIRMED" else "plausible"}
554
+ stamp = review_stamp(plan_root, parent)
555
+ found = [{**f, **meta, "origin": "review" if f["confidence"] == "CONFIRMED" else "plausible", "review": stamp}
318
556
  for f in parse_findings(read(path)) if f["confidence"] == "CONFIRMED" or include_plausible]
557
+ elif path.name == "known_issues.md":
558
+ found = known_findings(read(path), parent, meta)
559
+ if include_plausible:
560
+ found += [{**f, **meta, "origin": "plausible", "review": None}
561
+ for f in parse_findings(read(path)) if f["confidence"] == "PLAUSIBLE"]
319
562
  else:
320
- found = [{**f, **meta, "origin": "tolerated" if f["confidence"] == "CONFIRMED" else "plausible"}
563
+ found = [{**f, **meta, "origin": "tolerated" if f["confidence"] == "CONFIRMED" else "plausible", "review": None}
321
564
  for f in parse_findings(read(path)) if f["confidence"] == "CONFIRMED" or include_plausible]
322
565
  # plan_root is the plan dir, or for a file outside any plan (a solo run's) the dir holding it
323
566
  return {"plan": plan_root.name, "repo_path": recorded_repo(plan_root)}, found, []
@@ -395,24 +638,100 @@ def published(slug, repo_path, commit, offline=False):
395
638
  return run(["gh", "api", f"repos/{slug}/commits/{commit}", "--jq", ".sha"], check=False).returncode == 0
396
639
 
397
640
 
641
+ def merged_since_text(ids, lang):
642
+ """Ids cut after 5, as `(a, b, c, d, e, +N more)`, or "" when there are none."""
643
+ shown = [str(item) for item in ids[:5]]
644
+ extra = len(ids) - len(shown)
645
+ names = ", ".join(shown)
646
+ if extra:
647
+ more = f"+{extra} altri" if lang == "it" else f"+{extra} more"
648
+ names = f"{names}, {more}" if names else more
649
+ return f" ({names})" if names else ""
650
+
651
+
652
+ def outdated_line(lang, review):
653
+ """The sentence an outdated whole-run review adds after the context line, or ""."""
654
+ if not review or review.get("outdated") is not True:
655
+ return ""
656
+ ids = list(review.get("merged_since") or [])
657
+ n = len(ids)
658
+ if lang == "it":
659
+ tasks = "task è stato integrato" if n == 1 else "task sono stati integrati"
660
+ else:
661
+ tasks = "task" if n == 1 else "tasks"
662
+ return TEXT[lang]["outdated"].format(n=n, tasks=tasks, ids=merged_since_text(ids, lang))
663
+
664
+
665
+ def accepted_line(lang, finding):
666
+ """The sentence for a finding a person accepted, including the recorded reason, or ""."""
667
+ if not finding or finding.get("origin") != "accepted":
668
+ return ""
669
+ t = TEXT[lang]
670
+ line = t["accepted"].format(status=finding.get("accepted_status") or "UNKNOWN")
671
+ reason = finding.get("accepted_reason") or ""
672
+ if reason:
673
+ line += " " + t["accepted_reason"].format(reason=escape(reason))
674
+ return line
675
+
676
+
677
+ def acceptance_lines(lang, items):
678
+ """One sentence per distinct acceptance, in first-seen order.
679
+
680
+ `--group run` puts every accepted finding of the plan in one issue. Tasks record their
681
+ own verdict and reason (`accepted.by`); each distinct pair is its own sentence, so a
682
+ later task's acceptance is not replaced by the first finding's.
683
+ """
684
+ lines, seen = [], set()
685
+ for finding in items:
686
+ line = accepted_line(lang, finding)
687
+ if line and line not in seen:
688
+ seen.add(line)
689
+ lines.append(line)
690
+ return lines
691
+
692
+
693
+ def outdated_lines(lang, items):
694
+ """The outdated-review sentence for every distinct review in the group, in first-seen order.
695
+
696
+ Severity order can put a task's PLAUSIBLE finding ahead of the outdated review's, and
697
+ that first finding has no review. The warning still follows the context line, once per
698
+ distinct sentence.
699
+ """
700
+ lines, seen = [], set()
701
+ for finding in items:
702
+ line = outdated_line(lang, finding.get("review") if finding else None)
703
+ if line and line not in seen:
704
+ seen.add(line)
705
+ lines.append(line)
706
+ return lines
707
+
708
+
398
709
  def body_for(slug, repo_path, items, lang, offline=False):
399
710
  t = TEXT[lang]
400
711
  h = t["headings"]
401
712
  first = items[0]
402
713
  plan = first["plan"]
403
714
  plausible = all(f["origin"] == "plausible" for f in items)
715
+ accepted = acceptance_lines(lang, items)
404
716
  if len(items) > 1:
405
717
  context = t["group"].format(plan=plan, n=len(items))
406
718
  elif plausible:
407
719
  context = t["plausible_ctx"]
408
720
  elif first["origin"] == "review":
409
721
  context = t["review"]
722
+ elif accepted:
723
+ context = accepted[0]
410
724
  else:
411
725
  context = t["tolerated"].format(threshold=first["threshold"])
412
726
  commit = f", commit `{first['commit'][:12]}`" if first.get("commit") else ""
413
- lines = [f"## {h[0]}", context, "",
414
- t["origin"].format(plan=plan, task=first["task"] or "final-review", severity=first["severity"],
415
- reviewer=first["reviewer"], commit=commit), "", f"## {h[1]}"]
727
+ lines = [f"## {h[0]}", context]
728
+ # A grouped issue's context line is the group intro, so every distinct acceptance follows it.
729
+ if len(items) > 1:
730
+ lines.extend(accepted)
731
+ lines.extend(outdated_lines(lang, items))
732
+ lines += ["",
733
+ t["origin"].format(plan=plan, task=first["task"] or "final-review", severity=first["severity"],
734
+ reviewer=first["reviewer"], commit=commit), "", f"## {h[1]}"]
416
735
  for f in items:
417
736
  lines += [f"> {escape(f['raw'])}", ""]
418
737
  lines.append(f"## {h[2]}")
@@ -670,6 +989,7 @@ def finding_entry(f, filed=None, dismissed=None):
670
989
  "plan": f["plan"], "plan_dir": f["plan_dir"], "task": f["task"], "file": f["file"],
671
990
  "summary": (f["text"].splitlines() or [""])[0][:160], "text": f["text"],
672
991
  "reviewer": f["reviewer"], "commit": f.get("commit") or "",
992
+ "review": f.get("review"),
673
993
  "filed": {"url": filed["url"], "state": filed["state"]} if filed else None,
674
994
  "dismissed": dismissed}
675
995
 
@@ -725,8 +1045,12 @@ def list_and_file(args, meta, found, notes, repo_path, slug, warnings):
725
1045
  title = title_for(items[0], args.lang)
726
1046
  else:
727
1047
  scope = (items[0]["task"] or items[0]["plan"]) if args.group == "task" else items[0]["plan"]
728
- prefix = t["plausible"] if items[0]["origin"] == "plausible" else ""
729
- title = f"[{items[0]['severity']}] {prefix}{t['group_title'].format(scope=scope)}"
1048
+ origin = items[0]["origin"]
1049
+ if origin == "accepted":
1050
+ title = f"[{items[0]['severity']}] {t['group_title_accepted'].format(scope=scope)}"
1051
+ else:
1052
+ prefix = t["plausible"] if origin == "plausible" else ""
1053
+ title = f"[{items[0]['severity']}] {prefix}{t['group_title'].format(scope=scope)}"
730
1054
  drafts.append({"title": title, "severity": items[0]["severity"], "origin": items[0]["origin"],
731
1055
  "findings": [f["raw"] for f in items], "fingerprints": [f["fp"] for f in items],
732
1056
  "same_title": None if offline else open_titles.get(norm_title(title)),
@@ -735,7 +1059,8 @@ def list_and_file(args, meta, found, notes, repo_path, slug, warnings):
735
1059
  out = {"repo": slug, "plan": meta["plan"], "notes": notes, "warnings": warnings,
736
1060
  "summary": {"findings": len(seen), "already_filed": len(already),
737
1061
  "dismissed": sum(1 for e in entries if e["dismissed"]), "drafts": len(drafts),
738
- **{o: sum(1 for f in todo if f["origin"] == o) for o in ("tolerated", "review", "plausible")}},
1062
+ **{o: sum(1 for f in todo if f["origin"] == o)
1063
+ for o in ("tolerated", "accepted", "review", "plausible")}},
739
1064
  "findings": entries, "already_filed": already, "drafts": drafts}
740
1065
  if args.apply:
741
1066
  out["created"] = create(slug, drafts, args)
@@ -779,7 +1104,8 @@ def print_human(out, applied):
779
1104
  s = out["summary"]
780
1105
  dismissed = f", {s['dismissed']} dismissed" if s["dismissed"] else ""
781
1106
  print(f"{out['repo']} · run {out['plan']}: {s['findings']} finding(s), {s['already_filed']} already filed{dismissed}, "
782
- f"{s['drafts']} draft(s) ({s['tolerated']} tolerated, {s['review']} review, {s['plausible']} plausible)")
1107
+ f"{s['drafts']} draft(s) ({s['tolerated']} tolerated, {s['accepted']} accepted, "
1108
+ f"{s['review']} review, {s['plausible']} plausible)")
783
1109
  for warning in out["warnings"]:
784
1110
  print(f" warning {warning}")
785
1111
  for note in out["notes"]:
@@ -795,9 +1121,11 @@ def print_human(out, applied):
795
1121
  else:
796
1122
  print(f" skipped {c['title']}: {c['skipped']}{' ' + c['issue'] if c.get('issue') else ''}")
797
1123
  return
1124
+ outdated = {e["fp"] for e in out["findings"] if (e.get("review") or {}).get("outdated") is True}
798
1125
  for d in out["drafts"]:
799
1126
  flag = f" (open issue with the same title: {d['same_title']})" if d["same_title"] else ""
800
- print(f" draft {d['title']}{flag}")
1127
+ mark = " (outdated review)" if any(fp in outdated for fp in d["fingerprints"]) else ""
1128
+ print(f" draft {d['title']}{mark}{flag}")
801
1129
  if out["drafts"]:
802
1130
  print("Nothing created: add --apply to file these.")
803
1131
 
@@ -130,7 +130,7 @@ plans_make_integration() { # plans_make_integration <repo> <branch> <commit> <wt
130
130
 
131
131
  # Per-plan option files carried to a derived plan. The cost-bearing choices a user made
132
132
  # for this plan apply to the plan made from it; `planner` does not (nothing plans again).
133
- PLANS_OPTION_FILES="no_ripwire no_memory yolo_dwarf yolo_qa qa_threshold timeout verify_retries verify_cmd setup_cmd final_qa shadow_qa shadow_rate explore escalate_after fractal-routing.json fractal-selection.json jev-selection.json"
133
+ PLANS_OPTION_FILES="no_ripwire no_memory yolo_dwarf yolo_qa reviewer_session qa_threshold timeout verify_retries verify_cmd setup_cmd final_qa shadow_qa shadow_rate explore escalate_after fractal-routing.json fractal-selection.json jev-selection.json"
134
134
 
135
135
  # --- accept ---------------------------------------------------------------------
136
136
  # A reviewer's FAIL is the QA gate; overriding it is the user's call and never forge's or
@@ -505,7 +505,7 @@ Retry or accept them (or split them off into their own plan) first." 3
505
505
  [ "$ok" = 1 ] || { plans_undo; trap - INT TERM; die "could not write the combined plan" 3; }
506
506
 
507
507
  # Options: a plan-wide choice carries over when every source made it identically; the
508
- # two permission flags only when every source granted them.
508
+ # permission flags and the reviewer-session experiment only when every source chose them.
509
509
  local goals="" g
510
510
  for p in "${SRC[@]}"; do
511
511
  if [ -s "$p/goal.txt" ]; then g="$(tr '\n' ' ' < "$p/goal.txt" | sed 's/ *$//')"; else g="$(basename "$p")"; fi
@@ -520,6 +520,10 @@ Retry or accept them (or split them off into their own plan) first." 3
520
520
  all=1; for p in "${SRC[@]}"; do [ -f "$p/$f" ] || all=0; done
521
521
  [ "$all" = 1 ] && : > "$NEW/$f"
522
522
  done
523
+ # Same gate as the permission flags. The file holds `on`, which is what `plan` writes
524
+ # and what the dataset records; readers only test that the file exists.
525
+ all=1; for p in "${SRC[@]}"; do [ -f "$p/reviewer_session" ] || all=0; done
526
+ [ "$all" = 1 ] && printf '%s\n' on > "$NEW/reviewer_session"
523
527
  for f in qa_threshold timeout verify_retries; do
524
528
  same=1
525
529
  for p in "${SRC[@]}"; do