bigpowers 2.91.0 → 2.92.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 (59) hide show
  1. package/.agents/skills/guard-git/SKILL.md +7 -2
  2. package/.cline/skills/guard-git/SKILL.md +7 -2
  3. package/.codebuddy/skills/guard-git/SKILL.md +7 -2
  4. package/.codex/skills/guard-git/SKILL.md +7 -2
  5. package/.copilot/skills/guard-git/SKILL.md +7 -2
  6. package/.cursor/rules/guard-git.mdc +7 -2
  7. package/.gemini/extensions/bigpowers/commands/prompts/guard-git.md +7 -2
  8. package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
  9. package/.gemini/extensions/bigpowers/skills/guard-git/SKILL.md +7 -2
  10. package/.hermes/skills/guard-git/SKILL.md +7 -2
  11. package/.kilocode/rules/guard-git.md +7 -2
  12. package/.mimocode/skills/guard-git/SKILL.md +7 -2
  13. package/.opencode/skills/guard-git/SKILL.md +7 -2
  14. package/.pi/package.json +1 -1
  15. package/.pi/prompts/guard-git.md +7 -2
  16. package/.pi/skills/guard-git/SKILL.md +7 -2
  17. package/.qwen/skills/guard-git/SKILL.md +7 -2
  18. package/.trae/skills/guard-git/SKILL.md +7 -2
  19. package/.windsurf/rules/guard-git.md +7 -2
  20. package/.zcode/skills/guard-git/SKILL.md +7 -2
  21. package/CHANGELOG.md +15 -0
  22. package/extensions/omp-hooks.ts +197 -58
  23. package/hooks/pre/bash.sh +54 -7
  24. package/hooks/pre-tool-use.sh +49 -11
  25. package/package.json +1 -1
  26. package/scripts/ab-sandbox.sh +3 -3
  27. package/scripts/adapters/agy.sh +1 -1
  28. package/scripts/adapters/cursor.sh +1 -1
  29. package/scripts/adapters/gemini.sh +1 -1
  30. package/scripts/adapters/mimo.sh +1 -1
  31. package/scripts/adapters/pi.sh +1 -1
  32. package/scripts/adapters/zcode.sh +1 -1
  33. package/scripts/generate-context-bundle.sh +2 -1
  34. package/scripts/lib/__pycache__/atomic_write.cpython-310.pyc +0 -0
  35. package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
  36. package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
  37. package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
  38. package/scripts/lib/ab-eval.py +19 -5
  39. package/scripts/lib/ab_lean.py +116 -0
  40. package/scripts/lib/ab_metrics.py +8 -0
  41. package/scripts/lib/ab_progress.py +3 -1
  42. package/scripts/lib/ab_report.py +15 -4
  43. package/scripts/lib/ab_vsm.py +122 -0
  44. package/scripts/lib/ab_waste.py +173 -0
  45. package/scripts/lib/git-argv.py +168 -0
  46. package/scripts/lib/golden-suite-gates.sh +2 -0
  47. package/scripts/omp-guard-probe.ts +46 -0
  48. package/scripts/test-adapter-render.sh +1 -1
  49. package/scripts/test-guard-git-normalize.sh +189 -0
  50. package/scripts/test-test-lock-hook.sh +7 -7
  51. package/scripts/verify-adversarial.sh +8 -1
  52. package/skills/guard-git/SKILL.md +7 -2
  53. package/skills-lock.json +1 -1
  54. package/tests/test_ab_eval.py +0 -44
  55. package/tests/test_ab_lean.py +81 -0
  56. package/tests/test_ab_score.py +12 -2
  57. package/tests/test_ab_tasks.py +75 -0
  58. package/tests/test_ab_vsm.py +109 -0
  59. package/tests/test_ab_waste.py +106 -0
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s10
3
+ """Lean views of an A/B run: each arm's mean value stream (stage time and share) and waste metrics,
4
+ deltas against vanilla with seeded bootstrap CIs, and text VSM bars plus a waste table.
5
+
6
+ Reads the `vsm` (e58s08) and `waste` (e58s09) blocks of run records; runs without them are skipped.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from ab_report import bootstrap_delta_ci, mean # type: ignore[import]
11
+
12
+ STAGES = ["plan", "design", "build", "test", "deploy", "maintain"]
13
+ LETTER = {"plan": "P", "design": "D", "build": "B", "test": "T", "deploy": "R", "maintain": "M"}
14
+ WASTE_KEYS = ["test_runs", "redundant_test_runs", "rework_loops", "reedits", "rereads",
15
+ "repeated_commands", "review_load", "diff_ratio", "pce"]
16
+ # (key, label, per-run extractor) for the deltas against vanilla.
17
+ DELTAS = [
18
+ ("lead_time_s", "lead time (s)", lambda r: r["vsm"]["lead_time_s"]),
19
+ ("time_to_first_build_s", "first build (s)", lambda r: r["vsm"]["time_to_first_build_s"]),
20
+ ("switches", "stage switches", lambda r: r["vsm"]["switches"]),
21
+ ("build_share", "build share", lambda r: share(r, "build")),
22
+ ("test_share", "test share", lambda r: share(r, "test")),
23
+ ("test_runs", "test runs", lambda r: r["waste"].get("test_runs")),
24
+ ("redundant_test_runs", "redundant test runs", lambda r: r["waste"].get("redundant_test_runs")),
25
+ ("rework_loops", "rework loops", lambda r: r["waste"].get("rework_loops")),
26
+ ("reedits", "re-edits", lambda r: r["waste"].get("reedits")),
27
+ ("review_load", "review load", lambda r: r["waste"].get("review_load")),
28
+ ("diff_ratio", "diff ratio", lambda r: r["waste"].get("diff_ratio")),
29
+ ("ftt", "first-time-through", lambda r: None if r["waste"].get("ftt") is None else float(r["waste"]["ftt"])),
30
+ ("pce", "PCE", lambda r: r["waste"].get("pce")),
31
+ ]
32
+
33
+
34
+ def share(run: dict, stage: str) -> float | None:
35
+ lead = run["vsm"]["lead_time_s"]
36
+ return run["vsm"]["stages"].get(stage, {}).get("s", 0) / lead if lead else None
37
+
38
+
39
+ def lean_runs(records: list[dict], arm: str) -> list[dict]:
40
+ return [r for r in records if r["arm"] == arm and r.get("vsm") and r.get("waste")]
41
+
42
+
43
+ def arm_lean(runs: list[dict], all_runs: list[dict]) -> dict:
44
+ passes = sum(r["pass"] for r in all_runs)
45
+ cost = sum(r.get("cost_usd") or 0 for r in all_runs)
46
+ return {
47
+ "runs": len(runs),
48
+ "lead_time_s": mean([r["vsm"]["lead_time_s"] for r in runs]),
49
+ "time_to_first_build_s": mean([r["vsm"]["time_to_first_build_s"] for r in runs]),
50
+ "switches": mean([r["vsm"]["switches"] for r in runs]),
51
+ "stage_s": {s: mean([r["vsm"]["stages"].get(s, {}).get("s", 0) for r in runs]) for s in STAGES},
52
+ "stage_share": {s: mean([share(r, s) for r in runs]) for s in STAGES},
53
+ "waste": {k: mean([r["waste"].get(k) for r in runs]) for k in WASTE_KEYS},
54
+ "ftt_rate": mean([None if r["waste"].get("ftt") is None else float(r["waste"]["ftt"]) for r in runs]),
55
+ "leftover_rate": mean([float(bool(r["waste"].get("leftovers"))) for r in runs]),
56
+ "cost_per_pass": round(cost / passes, 6) if passes else None,
57
+ }
58
+
59
+
60
+ def lean_summary(records: list[dict], arms: list[str]) -> dict | None:
61
+ by_arm = {a: lean_runs(records, a) for a in arms}
62
+ if not any(by_arm.values()):
63
+ return None
64
+ summary = {a: arm_lean(runs, [r for r in records if r["arm"] == a]) for a, runs in by_arm.items() if runs}
65
+ deltas = {}
66
+ if "vanilla" in summary:
67
+ for arm in summary:
68
+ if arm == "vanilla":
69
+ continue
70
+ deltas[arm] = {}
71
+ for key, _, get in DELTAS:
72
+ tr = [v for v in map(get, by_arm[arm]) if v is not None]
73
+ va = [v for v in map(get, by_arm["vanilla"]) if v is not None]
74
+ if tr and va:
75
+ deltas[arm][key] = {"delta": round(sum(tr) / len(tr) - sum(va) / len(va), 4),
76
+ "ci95": bootstrap_delta_ci(tr, va)}
77
+ return {"arms": summary, "deltas": deltas}
78
+
79
+
80
+ def bar(arm: dict, scale: float) -> str:
81
+ """Stage letters, one per `scale` seconds of mean stage time: the bar's length is the lead time."""
82
+ return "".join(LETTER[s] * round((arm["stage_s"][s] or 0) / scale) for s in STAGES)
83
+
84
+
85
+ def fmt(value, places=2) -> str:
86
+ return "-" if value is None else f"{value:.{places}f}"
87
+
88
+
89
+ def lean_lines(summary: dict | None, width: int = 50) -> list[str]:
90
+ if not summary:
91
+ return []
92
+ arms = summary["arms"]
93
+ scale = max((a["lead_time_s"] or 0) for a in arms.values()) / width or 1
94
+ out = ["", "value stream (bar length = mean lead time; P plan, D design, B build, T test, R release, M maintain):"]
95
+ for name, a in arms.items():
96
+ out.append(f" {name:<10} |{bar(a, scale):<{width}}| {fmt(a['lead_time_s'], 0):>5} s "
97
+ f"first build {fmt(a['time_to_first_build_s'], 0)} s PCE {fmt(a['waste']['pce'])} "
98
+ f"FTT {fmt(a['ftt_rate'])} $/pass {fmt(a['cost_per_pass'], 4)}")
99
+ out += ["", f" {'waste (mean per run)':<22}" + "".join(f"{n:>11}" for n in arms) + " delta vs vanilla [95% CI]"]
100
+ for key, label, _ in DELTAS:
101
+ if key in ("lead_time_s", "time_to_first_build_s"):
102
+ continue
103
+ values = "".join(f"{fmt(row_value(a, key)):>11}" for a in arms.values())
104
+ ds = " ".join(f"{n} {d[key]['delta']:+.2f} {d[key]['ci95']}" for n, d in summary["deltas"].items() if key in d)
105
+ out.append(f" {label:<22}{values} {ds}")
106
+ return out
107
+
108
+
109
+ def row_value(arm: dict, key: str):
110
+ if key == "switches":
111
+ return arm["switches"]
112
+ if key in ("build_share", "test_share"):
113
+ return arm["stage_share"][key.split("_")[0]]
114
+ if key == "ftt":
115
+ return arm["ftt_rate"]
116
+ return arm["waste"].get(key)
@@ -121,3 +121,11 @@ def tap_score(stdout: str) -> float | None:
121
121
  passed = sum(int(n) for n in re.findall(r"^# pass (\d+)", stdout, re.M))
122
122
  failed = sum(int(n) for n in re.findall(r"^# (?:fail|cancelled) (\d+)", stdout, re.M))
123
123
  return round(passed / (passed + failed), 4) if passed + failed else None
124
+
125
+
126
+ def tap_failures(stdout: str) -> int:
127
+ return sum(int(n) for n in re.findall(r"^# (?:fail|cancelled) (\d+)", stdout, re.M))
128
+
129
+
130
+ def tap_failed_names(stdout: str) -> list[str]:
131
+ return [m.strip() for m in re.findall(r"^not ok \d+ - (.+)$", stdout, re.M)]
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- # story: e58s05
2
+ # story: e58s05 e58s10
3
3
  """Live view of an A/B eval while it runs: reads every ab-live.jsonl under the given folders
4
4
  (one per shard) and prints runs, pass rate, score and cost per task and arm, each arm's delta
5
5
  against vanilla with its bootstrap CI, and how those deltas evolved as runs accumulated.
@@ -16,6 +16,7 @@ from datetime import datetime
16
16
  from pathlib import Path
17
17
 
18
18
  sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ from ab_lean import lean_lines # type: ignore[import] # noqa: E402
19
20
  from ab_report import aggregate, mean # type: ignore[import] # noqa: E402
20
21
 
21
22
  CHECKPOINTS = (5, 10, 20, 30, 50, 75, 100)
@@ -79,6 +80,7 @@ def render(records: list[dict]) -> str:
79
80
  out += ["", "deltas (all runs so far, 95% CI):"]
80
81
  out += [delta_line(n, d) for n, d in result["deltas_vs_vanilla"].items()]
81
82
  out += ["", "evolution (pass and score delta by run index):"] + evolution(records, tasks, arms)
83
+ out += lean_lines(result.get("lean"))
82
84
  return "\n".join(out)
83
85
 
84
86
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- # story: e58s01 e58s03 e58s04
2
+ # story: e58s01 e58s03 e58s04 e58s10
3
3
  """A/B eval aggregation and reports — pure functions over run records (no agent, no subprocess
4
4
  except reading the bigpowers git SHA). Split from ab-eval.py to keep both under the 300-line cap."""
5
5
  from __future__ import annotations
@@ -47,6 +47,7 @@ def arm_summary(runs: list[dict]) -> dict:
47
47
  "runs": len(runs),
48
48
  "pass_rate": mean([float(r["pass"]) for r in runs]),
49
49
  "mean_score": mean([r.get("score") for r in runs]),
50
+ "mean_bugs_opened": mean([r.get("bugs_opened", 0) for r in runs]),
50
51
  "tamper_rate": mean([float(r["tampered"]) for r in runs]),
51
52
  "agent_error_rate": mean([float(r["agent_error"]) for r in runs]),
52
53
  "mean_cost_usd": mean([r["cost_usd"] for r in runs]),
@@ -66,8 +67,10 @@ def aggregate(records: list[dict], arms: list[str], tasks: list[str]) -> dict:
66
67
  per_task_score = {t: {a: mean([r.get("score") for r in by_arm[a] if r["task"] == t]) for a in arms}
67
68
  for t in tasks}
68
69
  deltas = {a: arm_delta(by_arm, summary, a) for a in arms if a != "vanilla"} if "vanilla" in arms else {}
70
+ from ab_lean import lean_summary # type: ignore[import] # lazy: ab_lean imports this module
69
71
  return {"arms_summary": summary, "per_task": per_task, "per_task_score": per_task_score,
70
- "delta": deltas.get("bigpowers"), "deltas_vs_vanilla": deltas}
72
+ "delta": deltas.get("bigpowers"), "deltas_vs_vanilla": deltas,
73
+ "lean": lean_summary(records, arms)}
71
74
 
72
75
 
73
76
  def arm_delta(by_arm: dict, summary: dict, arm: str) -> dict:
@@ -86,6 +89,8 @@ def arm_delta(by_arm: dict, summary: dict, arm: str) -> dict:
86
89
  if scores[arm] and scores["vanilla"]:
87
90
  delta["score"] = round(tr["mean_score"] - va["mean_score"], 4)
88
91
  delta["score_ci95"] = bootstrap_delta_ci(scores[arm], scores["vanilla"])
92
+ if tr.get("mean_bugs_opened") is not None and va.get("mean_bugs_opened") is not None:
93
+ delta["bugs_opened"] = round(tr["mean_bugs_opened"] - va["mean_bugs_opened"], 4)
89
94
  return delta
90
95
 
91
96
 
@@ -111,13 +116,19 @@ def write_reports(out: Path, meta: dict, result: dict, records: list[dict]) -> P
111
116
 
112
117
 
113
118
  def print_summary(result: dict) -> None:
114
- print(f"{'arm':<10} {'pass':>6} {'score':>6} {'tamper':>7} {'cost$':>8} {'turns':>6} {'secs':>7}")
119
+ print(f"{'arm':<10} {'pass':>6} {'score':>6} {'bugs':>6} {'tamper':>7} {'cost$':>8} {'turns':>6} {'secs':>7}")
115
120
  for arm, s in result["arms_summary"].items():
116
121
  fmt = lambda v, p=2: "-" if v is None else f"{v:.{p}f}"
117
- print(f"{arm:<10} {fmt(s['pass_rate']):>6} {fmt(s['mean_score']):>6} {fmt(s['tamper_rate']):>7} "
122
+ b_str = "-" if s.get("mean_bugs_opened") is None else f"{s['mean_bugs_opened']:.1f}"
123
+ print(f"{arm:<10} {fmt(s['pass_rate']):>6} {fmt(s['mean_score']):>6} {b_str:>6} {fmt(s['tamper_rate']):>7} "
118
124
  f"{fmt(s['mean_cost_usd'], 3):>8} {fmt(s['mean_turns'], 1):>6} {fmt(s['mean_duration_s'], 0):>7}")
119
125
  for arm, d in result.get("deltas_vs_vanilla", {}).items():
120
126
  line = f"delta {arm:<10} - vanilla: pass {d['pass_rate']:+.2f} CI {d['ci95']}"
121
127
  if "score" in d:
122
128
  line += f" score {d['score']:+.2f} CI {d['score_ci95']}"
129
+ if "bugs_opened" in d:
130
+ line += f" bugs {d['bugs_opened']:+.1f}"
131
+ print(line)
132
+ from ab_lean import lean_lines # type: ignore[import]
133
+ for line in lean_lines(result.get("lean")):
123
134
  print(line)
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s08
3
+ """Value stream map of one A/B run: credit each model turn's time, tokens and cost to the lean stage
4
+ of the action it takes (plan, design, build, test, deploy, maintain; wrap-up when it takes none).
5
+
6
+ Tools are only 1-3% of a run's time, so time is credited per turn, not per tool call. pi stamps an
7
+ assistant message when it starts, so a turn lasts until the next assistant message starts: the model
8
+ time plus the tools it ran.
9
+ Rules: specs/benchmarks/ab/vsm.yaml.
10
+
11
+ CLI: python3 scripts/lib/ab_vsm.py <trace.jsonl.gz>... (one VSM line per saved transcript)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ import json
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+
23
+ REPO_ROOT = Path(__file__).resolve().parents[2]
24
+ RULES_PATH = REPO_ROOT / "specs" / "benchmarks" / "ab" / "vsm.yaml"
25
+ WRAP_UP = "wrap-up"
26
+
27
+
28
+ def load_rules(path: Path = RULES_PATH) -> dict:
29
+ rules = yaml.safe_load(path.read_text())
30
+ rules["compiled"] = [(r["stage"], re.compile(r["tools"]), re.compile(r["target"])) for r in rules["rules"]]
31
+ return rules
32
+
33
+
34
+ def classify_call(tool: str, args: dict | None, rules: dict) -> str:
35
+ args = args or {}
36
+ target = str(args.get("command") or args.get("path") or args.get("file_path") or "")
37
+ for stage, tools, pattern in rules["compiled"]:
38
+ if tools.search(tool or "") and pattern.search(target):
39
+ return stage
40
+ return "design"
41
+
42
+
43
+ def turn_stage(message: dict, rules: dict) -> str:
44
+ calls = [c for c in message.get("content") or [] if isinstance(c, dict) and c.get("type") == "toolCall"]
45
+ stages = {classify_call(c.get("name", ""), c.get("arguments"), rules) for c in calls}
46
+ return next((s for s in rules["turn_priority"] if s in stages), WRAP_UP)
47
+
48
+
49
+ def pi_messages(stdout: str) -> list[dict]:
50
+ messages = []
51
+ for line in stdout.splitlines():
52
+ try:
53
+ event = json.loads(line)
54
+ except json.JSONDecodeError:
55
+ continue
56
+ if isinstance(event, dict) and event.get("type") == "message_end":
57
+ message = event.get("message") or {}
58
+ if isinstance(message.get("timestamp"), (int, float)):
59
+ messages.append(message)
60
+ return messages
61
+
62
+
63
+ def vsm_from_pi(stdout: str, rules: dict) -> dict | None:
64
+ messages = pi_messages(stdout)
65
+ if not any(m.get("role") == "assistant" for m in messages):
66
+ return None
67
+ start = messages[0]["timestamp"]
68
+ stages: dict[str, dict] = {}
69
+ sequence, first_build = [], None
70
+ turns = [m for m in messages if m.get("role") == "assistant"]
71
+ for i, message in enumerate(turns):
72
+ stage = turn_stage(message, rules)
73
+ end = turns[i + 1]["timestamp"] if i + 1 < len(turns) else messages[-1]["timestamp"]
74
+ usage = message.get("usage") or {}
75
+ slot = stages.setdefault(stage, {"s": 0.0, "cost_usd": 0.0, "output_tokens": 0, "turns": 0})
76
+ slot["s"] = round(slot["s"] + (end - message["timestamp"]) / 1000, 3)
77
+ slot["cost_usd"] = round(slot["cost_usd"] + ((usage.get("cost") or {}).get("total") or 0), 6)
78
+ slot["output_tokens"] += usage.get("output", 0)
79
+ slot["turns"] += 1
80
+ sequence.append(stage)
81
+ if stage == "build" and first_build is None:
82
+ first_build = round((message["timestamp"] - start) / 1000, 3)
83
+ return {
84
+ "lead_time_s": round((messages[-1]["timestamp"] - start) / 1000, 3),
85
+ "time_to_first_build_s": first_build,
86
+ "switches": sum(1 for a, b in zip(sequence, sequence[1:]) if a != b),
87
+ "sequence": sequence,
88
+ "stages": stages,
89
+ }
90
+
91
+
92
+ def vsm_from(parser: str, stdout: str, rules: dict | None = None) -> dict | None:
93
+ """vsm block for a run record; None when the adapter's transcript format is not supported."""
94
+ if parser != "pi-json":
95
+ return None
96
+ return vsm_from_pi(stdout, rules or load_rules())
97
+
98
+
99
+ def describe(name: str, block: dict | None) -> str:
100
+ if not block:
101
+ return f"{name:<32} no VSM (unsupported or empty transcript)"
102
+ lead = block["lead_time_s"] or 1
103
+ parts = sorted(block["stages"].items(), key=lambda kv: -kv[1]["s"])
104
+ shares = " ".join(f"{stage} {v['s']:.0f}s {100 * v['s'] / lead:.0f}%" for stage, v in parts if v["turns"])
105
+ return f"{name:<32} lead {block['lead_time_s']:6.1f}s first build {block['time_to_first_build_s']}s " \
106
+ f"switches {block['switches']:>2} | {shares}"
107
+
108
+
109
+ def main(argv: list[str]) -> int:
110
+ if not argv:
111
+ print("usage: python3 scripts/lib/ab_vsm.py <trace.jsonl.gz>...")
112
+ return 2
113
+ rules = load_rules()
114
+ for name in argv:
115
+ path = Path(name)
116
+ text = gzip.decompress(path.read_bytes()).decode() if path.suffix == ".gz" else path.read_text()
117
+ print(describe(path.name, vsm_from_pi(text, rules)))
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s09 e58s10
3
+ """Lean waste metrics for one A/B run, from its final patch and its pi transcript.
4
+
5
+ Patch (any adapter): changed lines per file class, the reference solution's size, extra files,
6
+ leftover scratch files and the review load a person would face. Transcript (pi): relearning
7
+ (rereads, repeated commands), defects and rework (failing test runs after the first build edit,
8
+ re-edits), first-time-through and process cycle efficiency. See LEAN-VSM_LATEST.md and
9
+ specs/benchmarks/ab/vsm.yaml (file classes reuse the stage rules; `leftovers` regex).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import gzip
15
+ import json
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ from collections import Counter
21
+ from pathlib import Path
22
+
23
+ from ab_vsm import classify_call, load_rules, vsm_from_pi # type: ignore[import]
24
+
25
+ CLASS_OF_STAGE = {"build": "product", "test": "test", "maintain": "doc", "plan": "plan"}
26
+ FAILED = re.compile(r'"exit_code":\s*[1-9]|(?:^|\\n|\n)\s*(?:ℹ|#)\s*fail\s+[1-9]|(?:^|\\n|\n)not ok ')
27
+
28
+
29
+ def git(cwd: Path, *args: str) -> str:
30
+ res = subprocess.run(["git", "-c", "user.name=ab", "-c", "user.email=ab@local", *args],
31
+ cwd=cwd, capture_output=True, text=True)
32
+ return res.stdout
33
+
34
+
35
+ def file_class(path: str, rules: dict) -> str:
36
+ cls = CLASS_OF_STAGE.get(classify_call("edit", {"path": path}, rules), "product")
37
+ return "doc" if cls == "product" and path.endswith(".md") else cls
38
+
39
+
40
+ def numstat(cwd: Path, base: str) -> dict[str, int]:
41
+ changed = {}
42
+ for line in git(cwd, "diff", "--cached", "--numstat", base).splitlines():
43
+ added, deleted, path = line.split("\t", 2)
44
+ changed[path] = sum(int(n) for n in (added, deleted) if n.isdigit())
45
+ return changed
46
+
47
+
48
+ @functools.lru_cache(maxsize=None)
49
+ def solution_files(task_dir: str) -> dict[str, int] | None:
50
+ """Changed lines per file of the reference solution, applied over the fixture."""
51
+ task = Path(task_dir)
52
+ if not (task / "solution").is_dir():
53
+ return None
54
+ work = Path(tempfile.mkdtemp(prefix="ab-solution-"))
55
+ try:
56
+ shutil.copytree(task / "fixture", work, dirs_exist_ok=True)
57
+ git(work, "init", "-q")
58
+ git(work, "add", "-A")
59
+ git(work, "commit", "-q", "--no-verify", "-m", "fixture")
60
+ shutil.copytree(task / "solution", work, dirs_exist_ok=True)
61
+ git(work, "add", "-A")
62
+ return numstat(work, "HEAD")
63
+ finally:
64
+ shutil.rmtree(work, ignore_errors=True)
65
+
66
+
67
+ def patch_waste(workdir: Path, base: str, task_dir: Path, patch_path: Path | None, rules: dict) -> dict:
68
+ git(workdir, "add", "-A")
69
+ patch = git(workdir, "diff", "--cached", base)
70
+ if patch_path is not None:
71
+ patch_path.parent.mkdir(parents=True, exist_ok=True)
72
+ patch_path.write_bytes(gzip.compress(patch.encode()))
73
+ changed = numstat(workdir, base)
74
+ lines = Counter()
75
+ for path, n in changed.items():
76
+ lines[file_class(path, rules)] += n
77
+ added = [line[2:] for line in git(workdir, "diff", "--cached", "--name-status", base).splitlines()
78
+ if line.startswith("A\t")]
79
+ solution = solution_files(str(task_dir))
80
+ solution_lines = sum(solution.values()) if solution else None
81
+ product = [p for p in changed if file_class(p, rules) == "product"]
82
+ doc_words = sum(len(line[1:].split()) for line in md_added_lines(patch))
83
+ return {
84
+ "product_lines": lines["product"], "test_lines": lines["test"],
85
+ "doc_lines": lines["doc"], "plan_lines": lines["plan"],
86
+ "solution_lines": solution_lines,
87
+ "diff_ratio": round(lines["product"] / solution_lines, 3) if solution_lines else None,
88
+ "extra_files": sorted(p for p in product if solution is not None and p not in solution),
89
+ "leftovers": sorted(p for p in added if re.search(rules["leftovers"], p)),
90
+ "review_load": sum(changed.values()) + len(changed) + doc_words,
91
+ }
92
+
93
+
94
+ def md_added_lines(patch: str) -> list[str]:
95
+ out, in_md = [], False
96
+ for line in patch.splitlines():
97
+ if line.startswith("+++ "):
98
+ in_md = line.endswith(".md")
99
+ elif in_md and line.startswith("+"):
100
+ out.append(line)
101
+ return out
102
+
103
+
104
+ def tool_calls(stdout: str) -> list[dict]:
105
+ """Ordered tool calls with their stage and whether a test run failed."""
106
+ calls, by_id = [], {}
107
+ for line in stdout.splitlines():
108
+ try:
109
+ event = json.loads(line)
110
+ except json.JSONDecodeError:
111
+ continue
112
+ if not isinstance(event, dict):
113
+ continue
114
+ if event.get("type") == "tool_execution_start":
115
+ call = {"tool": event.get("toolName", ""), "args": event.get("args") or {}, "failed": False}
116
+ by_id[event.get("toolCallId")] = call
117
+ calls.append(call)
118
+ elif event.get("type") == "tool_execution_end" and event.get("toolCallId") in by_id:
119
+ by_id[event["toolCallId"]]["failed"] = bool(FAILED.search(json.dumps(event.get("result"), ensure_ascii=False)))
120
+ return calls
121
+
122
+
123
+ def transcript_waste(stdout: str, rules: dict | None = None) -> dict | None:
124
+ rules = rules or load_rules()
125
+ vsm = vsm_from_pi(stdout, rules)
126
+ calls = tool_calls(stdout)
127
+ if vsm is None and not calls:
128
+ return None
129
+ for call in calls:
130
+ call["stage"] = classify_call(call["tool"], call["args"], rules)
131
+ reads = Counter(c["args"].get("path") for c in calls if c["tool"] == "read")
132
+ commands = Counter(c["args"].get("command") for c in calls if c["tool"] == "bash" and c["stage"] != "test")
133
+ edits = Counter(c["args"].get("path") for c in calls if c["tool"] in ("edit", "write"))
134
+ first_build = next((i for i, c in enumerate(calls) if c["stage"] == "build"), None)
135
+ runs = [c for c in calls if c["tool"] == "bash" and c["stage"] == "test"]
136
+ after = [c for c in calls[first_build:] if c in runs] if first_build is not None else []
137
+ rework = sum(c["failed"] for c in after)
138
+ redundant = redundant_runs(calls)
139
+ stages = (vsm or {}).get("stages", {})
140
+ lead = (vsm or {}).get("lead_time_s") or 0
141
+ value_add = sum(stages.get(s, {}).get("s", 0) for s in ("build", "test"))
142
+ return {
143
+ "rereads": sum(n - 1 for n in reads.values()),
144
+ "repeated_commands": sum(n - 1 for n in commands.values()),
145
+ "test_runs": len(runs), "redundant_test_runs": redundant, "rework_loops": rework,
146
+ "reedits": sum(n - 1 for n in edits.values()),
147
+ "ftt": bool(after) and rework == 0,
148
+ "pce": round(value_add / lead, 3) if lead else None,
149
+ }
150
+
151
+
152
+ def redundant_runs(calls: list[dict]) -> int:
153
+ """Passing test runs with no file edited since the previous passing run (overprocessing, e58s10)."""
154
+ redundant, green, dirty = 0, False, False
155
+ for call in calls:
156
+ if call["tool"] in ("edit", "write"):
157
+ dirty = True
158
+ elif call["tool"] == "bash" and call["stage"] == "test" and not call["failed"]:
159
+ redundant += green and not dirty
160
+ green, dirty = True, False
161
+ return redundant
162
+
163
+
164
+ EMPTY_TRANSCRIPT = dict.fromkeys(["rereads", "repeated_commands", "test_runs", "redundant_test_runs",
165
+ "rework_loops", "reedits", "ftt", "pce"])
166
+
167
+
168
+ def waste_for(workdir: Path, base: str, stdout: str, parser: str, task_dir: Path,
169
+ patch_path: Path | None = None) -> dict:
170
+ """The run record's waste block; transcript metrics are None for non-pi adapters."""
171
+ rules = load_rules()
172
+ transcript = transcript_waste(stdout, rules) if parser == "pi-json" else None
173
+ return {**patch_waste(workdir, base, task_dir, patch_path, rules), **(transcript or EMPTY_TRANSCRIPT)}
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """Normalize git argv before applying the guard-git deny rules (#184).
3
+
4
+ The old guard matched literal substrings, so `git -C repo reset --hard`,
5
+ `git reset -q --hard` and `git checkout -- .` all slipped through: the
6
+ destructive tokens are still there, only their position or a global flag
7
+ changed. Matching on the raw command string cannot see that.
8
+
9
+ This module tokenizes the command (quote-aware), finds every `git` invocation,
10
+ strips the global options that precede the subcommand (`-C <path>`,
11
+ `--no-pager`, `-c k=v`, `--git-dir=…`, …), and then decides on the
12
+ (subcommand, arguments) pair. `extensions/omp-hooks.ts` implements the same
13
+ rules in TypeScript; `scripts/test-guard-git-normalize.sh` fails if the two
14
+ disagree on any fixture row.
15
+
16
+ Rule table (keep in sync with the TS port):
17
+
18
+ reset block when any argument is --hard
19
+ clean block when any short flag carries `f` (-f, -fd, -fdx) or --force
20
+ branch block on -D, or on --delete together with --force/-f
21
+ checkout block on -f/--force, or on a `.` pathspec (with or without --)
22
+ restore block on --worktree, or on a `.` pathspec
23
+ push block on -f/--force/+refspec, unless --force-with-lease is given
24
+
25
+ CLI: git-argv.py --command "<shell command>" → prints `block: <reason>` or `allow`
26
+ Exit: 0 allow, 10 block, 2 usage error.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import shlex
31
+ import sys
32
+
33
+ # Global options that consume the next token as their value.
34
+ VALUE_FLAGS = {
35
+ "-C",
36
+ "-c",
37
+ "--exec-path",
38
+ "--git-dir",
39
+ "--work-tree",
40
+ "--namespace",
41
+ "--super-prefix",
42
+ "--config-env",
43
+ }
44
+
45
+ # Global options that stand alone.
46
+ BARE_FLAGS = {
47
+ "-p",
48
+ "--paginate",
49
+ "--no-pager",
50
+ "--bare",
51
+ "--no-replace-objects",
52
+ "--literal-pathspecs",
53
+ "--glob-pathspecs",
54
+ "--icase-pathspecs",
55
+ "--no-optional-locks",
56
+ "--version",
57
+ "--help",
58
+ }
59
+
60
+ # Command separators: the guard judges each git invocation on its own.
61
+ SEPARATORS = {";", "&&", "||", "|", "&", "(", ")", "\n"}
62
+
63
+
64
+ def tokenize(command: str) -> list[str]:
65
+ """Quote-aware split. Bad quoting degrades to whitespace splitting, never to eval."""
66
+ try:
67
+ return shlex.split(command, comments=False, posix=True)
68
+ except ValueError:
69
+ return command.split()
70
+
71
+
72
+ def git_invocations(tokens: list[str]) -> list[tuple[str, list[str], int]]:
73
+ """Yield (subcommand, args, index) for every git call in the token stream."""
74
+ found: list[tuple[str, list[str], int]] = []
75
+ i = 0
76
+ while i < len(tokens):
77
+ token = tokens[i]
78
+ if token.rsplit("/", 1)[-1] != "git":
79
+ i += 1
80
+ continue
81
+ start = i
82
+ j = i + 1
83
+ # Skip global options between `git` and the subcommand.
84
+ while j < len(tokens):
85
+ opt = tokens[j]
86
+ if opt in SEPARATORS:
87
+ break
88
+ name = opt.split("=", 1)[0]
89
+ if name in VALUE_FLAGS:
90
+ j += 2 if "=" not in opt else 1
91
+ continue
92
+ if name in BARE_FLAGS or (opt.startswith("-") and not opt.startswith("--") and len(opt) > 1):
93
+ j += 1
94
+ continue
95
+ break
96
+ if j >= len(tokens) or tokens[j] in SEPARATORS:
97
+ i = j + 1
98
+ continue
99
+ sub = tokens[j]
100
+ args: list[str] = []
101
+ k = j + 1
102
+ while k < len(tokens) and tokens[k] not in SEPARATORS:
103
+ args.append(tokens[k])
104
+ k += 1
105
+ found.append((sub, args, start))
106
+ i = k
107
+ return found
108
+
109
+
110
+ def _short_flag_has(args: list[str], letter: str) -> bool:
111
+ for arg in args:
112
+ if arg.startswith("-") and not arg.startswith("--") and letter in arg[1:]:
113
+ return True
114
+ return False
115
+
116
+
117
+ def classify_git(sub: str, args: list[str]) -> str | None:
118
+ """Return a block reason, or None to allow."""
119
+ if sub == "reset" and "--hard" in args:
120
+ return "git reset --hard discards uncommitted work"
121
+ if sub == "clean" and (_short_flag_has(args, "f") or "--force" in args):
122
+ return "git clean -f deletes untracked files"
123
+ if sub == "branch":
124
+ forced = "-D" in args or ("--delete" in args and ("--force" in args or _short_flag_has(args, "f")))
125
+ if forced:
126
+ return "git branch -D deletes an unmerged branch"
127
+ if sub == "checkout":
128
+ if "-f" in args or "--force" in args:
129
+ return "git checkout -f discards local changes"
130
+ if "." in args:
131
+ return "git checkout . discards local changes"
132
+ if sub == "restore":
133
+ if "--worktree" in args or "." in args:
134
+ return "git restore discards local changes"
135
+ if sub == "push":
136
+ leased = any(a == "--force-with-lease" or a.startswith("--force-with-lease=") for a in args)
137
+ forced = (
138
+ "-f" in args
139
+ or "--force" in args
140
+ or any(a.startswith("+") and not a.startswith("++") for a in args)
141
+ )
142
+ if forced and not leased:
143
+ return "force push rewrites remote history"
144
+ return None
145
+
146
+
147
+ def classify(command: str) -> str | None:
148
+ for sub, args, _idx in git_invocations(tokenize(command)):
149
+ reason = classify_git(sub, args)
150
+ if reason:
151
+ return reason
152
+ return None
153
+
154
+
155
+ def main(argv: list[str]) -> int:
156
+ if len(argv) == 3 and argv[1] == "--command":
157
+ reason = classify(argv[2])
158
+ if reason:
159
+ print(f"block: {reason}")
160
+ return 10
161
+ print("allow")
162
+ return 0
163
+ print(__doc__, file=sys.stderr)
164
+ return 2
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main(sys.argv))