@seanyao/roll 3.609.2 → 3.610.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.
@@ -1,243 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- backfill-pi-usage — one-time, idempotent recovery of pi/deepseek token+cost
4
- into an existing loop events file.
5
-
6
- Why this exists
7
- ---------------
8
- Before US-LOOP-026 the loop ran pi via ``pi -p`` (text mode), which prints no
9
- usage. loop-fmt's old passthrough still appended a ``stage=="usage"`` event on
10
- every retry attempt, each with ``model=="pi"`` and null tokens — so a single
11
- cycle accumulated up to ~180 empty usage events. The dashboard SUMS token
12
- fields across same-label usage events; with all-null tokens the SUM was 0
13
- (harmless), but it means every affected cycle shows ``—/—``.
14
-
15
- pi persists every session to ``~/.pi/agent/sessions/<enc-cwd>/<ts>_<uuid>.jsonl``
16
- with real per-message usage. This script recovers that, and rewrites the events
17
- file so each affected cycle is left with **exactly one** authoritative usage
18
- event (real tokens, cost frozen in native CNY) — collapsing the N null events to
19
- avoid the dashboard ×N inflation.
20
-
21
- Safety / idempotency
22
- --------------------
23
- - Backs up the events file to ``<file>.bak-<UTC>`` first; aborts if backup fails.
24
- - Only touches labels whose usage events are all pi-vendor (``model`` in
25
- {"pi", "deepseek-v4-pro"}) AND carry null tokens AND match a pi session.
26
- claude cycles, already-real cycles, and unmatched-null cycles are passed
27
- through untouched.
28
- - Re-runnable: once a label has a real-token usage event it is no longer a
29
- candidate, so a second run is a no-op.
30
- - FIX-065 tripwire: refuses to rewrite a production ``~/.shared/roll`` events
31
- file from a test context (BATS / temp cwd) unless HOME itself is sandboxed.
32
-
33
- Usage
34
- -----
35
- python3 lib/backfill-pi-usage.py --slug roll-ecf079
36
- python3 lib/backfill-pi-usage.py --events /path/to/events.ndjson --dry-run
37
- """
38
-
39
- import argparse
40
- import importlib.util
41
- import json
42
- import os
43
- import shutil
44
- import sys
45
- from datetime import datetime, timezone
46
-
47
- _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
48
-
49
- PI_VENDOR_MODELS = ("pi", "deepseek-v4-pro")
50
-
51
-
52
- def _load_pi_emit():
53
- spec = importlib.util.spec_from_file_location(
54
- "pi_emit", os.path.join(_THIS_DIR, "agent_usage", "pi_emit.py")
55
- )
56
- m = importlib.util.module_from_spec(spec)
57
- spec.loader.exec_module(m)
58
- return m
59
-
60
-
61
- def _default_events_path(slug, shared=None):
62
- base = shared or os.environ.get("LOOP_SHARED_ROOT") \
63
- or os.path.expanduser("~/.shared/roll")
64
- return os.path.join(base, "loop", "events-%s.ndjson" % slug)
65
-
66
-
67
- def _is_test_context():
68
- return bool(os.environ.get("BATS_TEST_FILENAME")) or _cwd_is_temp()
69
-
70
-
71
- def _cwd_is_temp():
72
- p = os.environ.get("PWD") or os.getcwd()
73
- return any(seg in p for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
74
-
75
-
76
- def _home_is_sandbox():
77
- home = os.environ.get("HOME") or ""
78
- return any(seg in home for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
79
-
80
-
81
- def _tripwire(evfile):
82
- """FIX-065: refuse a prod write from a test context."""
83
- home = os.environ.get("HOME") or ""
84
- if not home or _home_is_sandbox():
85
- return
86
- prod = os.path.join(home, ".shared", "roll") + os.sep
87
- if os.path.abspath(evfile).startswith(os.path.abspath(prod)) and _is_test_context():
88
- raise SystemExit(
89
- "[FIX-065] refusing to rewrite prod events file from test context: %s" % evfile
90
- )
91
-
92
-
93
- def _scan(lines):
94
- """Parse lines → (events_or_None list, per-label usage summary).
95
-
96
- Returns (parsed, labels) where parsed is a list of (raw_line, obj_or_None)
97
- preserving order, and labels maps label → {"pi": bool, "real": bool}.
98
- """
99
- parsed = []
100
- labels = {}
101
- for raw in lines:
102
- obj = None
103
- try:
104
- obj = json.loads(raw)
105
- except (ValueError, TypeError):
106
- obj = None
107
- parsed.append((raw, obj))
108
- if not obj or obj.get("stage") != "usage":
109
- continue
110
- lab = obj.get("label")
111
- d = obj.get("detail") or {}
112
- rec = labels.setdefault(lab, {"pi": False, "real": False})
113
- if d.get("model") in PI_VENDOR_MODELS:
114
- rec["pi"] = True
115
- if d.get("input_tokens"):
116
- rec["real"] = True
117
- return parsed, labels
118
-
119
-
120
- def backfill(evfile, slug=None, shared=None, base_dir=None, dry_run=False):
121
- """Rewrite evfile so each recoverable pi cycle nets one real usage event.
122
-
123
- Returns a stats dict.
124
- """
125
- _tripwire(evfile)
126
- pi_emit = _load_pi_emit()
127
-
128
- with open(evfile) as f:
129
- lines = f.readlines()
130
-
131
- parsed, labels = _scan(lines)
132
-
133
- # Candidate = pi-vendor, all-null, and a session match yields real usage.
134
- candidates = [l for l, r in labels.items() if r["pi"] and not r["real"]]
135
- replacement = {} # label -> detail payload
136
- matched, unmatched = [], []
137
- for lab in candidates:
138
- cwd = os.path.join(
139
- (shared or os.environ.get("LOOP_SHARED_ROOT")
140
- or os.path.expanduser("~/.shared/roll")),
141
- "worktrees", "%s-cycle-%s" % (slug, lab),
142
- )
143
- ev = pi_emit.build_event(cwd=cwd, cycle_id=lab, slug=slug, base_dir=base_dir)
144
- if ev is None:
145
- unmatched.append(lab)
146
- continue
147
- replacement[lab] = ev["detail"]
148
- matched.append(lab)
149
-
150
- if dry_run or not matched:
151
- # Nothing recoverable to rewrite → no backup, no write (keeps re-runs
152
- # a true no-op instead of spawning empty .bak files).
153
- return {
154
- "candidates": len(candidates),
155
- "matched": len(matched),
156
- "unmatched": len(unmatched),
157
- "matched_labels": sorted(matched),
158
- "unmatched_labels": sorted(unmatched),
159
- "written": False,
160
- }
161
-
162
- # Backup before any write; abort the whole run if backup fails.
163
- stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
164
- bak = "%s.bak-%s" % (evfile, stamp)
165
- shutil.copy2(evfile, bak)
166
-
167
- # Stream rewrite: for an affected label, the FIRST usage line becomes the
168
- # real event (original ts preserved so it stays in its day bucket), every
169
- # subsequent same-label usage line is dropped → exactly one per label.
170
- emitted = set()
171
- out = []
172
- for raw, obj in parsed:
173
- if not obj or obj.get("stage") != "usage":
174
- out.append(raw)
175
- continue
176
- lab = obj.get("label")
177
- if lab not in replacement:
178
- out.append(raw) # claude / already-real / unmatched-null: untouched
179
- continue
180
- if lab in emitted:
181
- continue # collapse the remaining null duplicates away
182
- new_ev = {
183
- "ts": obj.get("ts"),
184
- "stage": "usage",
185
- "label": lab,
186
- "detail": replacement[lab],
187
- "outcome": "ok",
188
- }
189
- out.append(json.dumps(new_ev) + "\n")
190
- emitted.add(lab)
191
-
192
- tmp = evfile + ".tmp-%s" % stamp
193
- with open(tmp, "w") as f:
194
- f.writelines(out)
195
- os.replace(tmp, evfile)
196
-
197
- return {
198
- "candidates": len(candidates),
199
- "matched": len(matched),
200
- "unmatched": len(unmatched),
201
- "matched_labels": sorted(matched),
202
- "unmatched_labels": sorted(unmatched),
203
- "backup": bak,
204
- "written": True,
205
- }
206
-
207
-
208
- def main(argv=None):
209
- ap = argparse.ArgumentParser(description="backfill pi/deepseek usage into events file")
210
- ap.add_argument("--slug", help="project slug (resolves default events path + session cwd)")
211
- ap.add_argument("--events", help="explicit events file path (overrides --slug default)")
212
- ap.add_argument("--shared", help="shared root (default ~/.shared/roll)")
213
- ap.add_argument("--base-dir", help="pi sessions root override (tests)")
214
- ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
215
- args = ap.parse_args(argv)
216
-
217
- evfile = args.events or _default_events_path(args.slug, args.shared)
218
- if not os.path.isfile(evfile):
219
- print("[backfill] no events file: %s" % evfile, file=sys.stderr)
220
- return 1
221
- if not args.slug:
222
- # slug is needed to reconstruct session cwd; derive from filename.
223
- base = os.path.basename(evfile)
224
- if base.startswith("events-") and base.endswith(".ndjson"):
225
- args.slug = base[len("events-"):-len(".ndjson")]
226
-
227
- stats = backfill(
228
- evfile, slug=args.slug, shared=args.shared,
229
- base_dir=args.base_dir, dry_run=args.dry_run,
230
- )
231
- mode = "DRY-RUN" if args.dry_run else "WROTE"
232
- print("[backfill] %s %s" % (mode, evfile))
233
- print(" candidates=%d matched=%d unmatched=%d"
234
- % (stats["candidates"], stats["matched"], stats["unmatched"]))
235
- if stats.get("backup"):
236
- print(" backup=%s" % stats["backup"])
237
- if stats["unmatched_labels"]:
238
- print(" unmatched (left as null): %s" % ", ".join(stats["unmatched_labels"]))
239
- return 0
240
-
241
-
242
- if __name__ == "__main__":
243
- sys.exit(main())
@@ -1,149 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Changelog audit module (US-CONSIST-002).
3
-
4
- Checks that ✅ Done backlog stories are reflected in CHANGELOG.md.
5
- Provides both coverage checking and gap reporting.
6
-
7
- Usage as library:
8
- from lib.changelog_audit import check_changelog_coverage
9
- result = check_changelog_coverage(done_stories, changelog_path)
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- from pathlib import Path
15
- from typing import Any
16
-
17
-
18
- def _read_changelog_text(changelog_path: Path) -> str:
19
- """Read changelog text, returning empty string if file is missing."""
20
- if not changelog_path.exists():
21
- return ""
22
- return changelog_path.read_text(encoding="utf-8")
23
-
24
-
25
- def check_changelog_coverage(
26
- done_stories: dict[str, list[str]],
27
- changelog_path: Path,
28
- ) -> dict[str, Any]:
29
- """Check that Done stories appear in CHANGELOG.md.
30
-
31
- Args:
32
- done_stories: {feature_name: [story_id, ...]} from backlog
33
- changelog_path: Path to CHANGELOG.md
34
-
35
- Returns:
36
- {"status": "pass"|"fail", "gaps": [descriptions...]}
37
- """
38
- if not changelog_path.exists():
39
- return {"status": "pass", "gaps": []}
40
-
41
- changelog_text = _read_changelog_text(changelog_path)
42
- gaps: list[str] = []
43
-
44
- for feature_name, story_ids in done_stories.items():
45
- for story_id in story_ids:
46
- if story_id not in changelog_text:
47
- gaps.append(
48
- f"Story '{story_id}' (feature '{feature_name}') is Done "
49
- "but not referenced in CHANGELOG.md"
50
- )
51
-
52
- return {
53
- "status": "pass" if not gaps else "fail",
54
- "gaps": gaps,
55
- }
56
-
57
-
58
- def check_features_md_coverage(
59
- done_features: dict[str, list[str]],
60
- features_md_path: Path,
61
- ) -> dict[str, Any]:
62
- """Check that features with Done stories are listed in features.md.
63
-
64
- Args:
65
- done_features: {feature_name: [story_id, ...]} from backlog
66
- features_md_path: Path to .roll/features.md
67
-
68
- Returns:
69
- {"status": "pass"|"fail", "gaps": [descriptions...]}
70
- """
71
- import re
72
-
73
- if not features_md_path.exists():
74
- return {"status": "pass", "gaps": []}
75
-
76
- features_text = features_md_path.read_text(encoding="utf-8")
77
- gaps: list[str] = []
78
-
79
- for feature_name in done_features:
80
- escaped = re.escape(feature_name)
81
- if not re.search(r"(^|[\s/])" + escaped + r"([\s/).]|$)", features_text):
82
- gaps.append(
83
- f"Feature '{feature_name}' has Done stories but is missing "
84
- "from features.md catalog"
85
- )
86
-
87
- return {
88
- "status": "pass" if not gaps else "fail",
89
- "gaps": gaps,
90
- }
91
-
92
-
93
- def check_guide_doc_coverage(
94
- done_features: dict[str, list[str]],
95
- guide_en_dir: Path,
96
- ) -> dict[str, Any]:
97
- """Check that features with Done stories have guide documentation.
98
-
99
- Heuristic: checks whether a .md file in guide/en/ references the feature
100
- name or its story IDs.
101
-
102
- Args:
103
- done_features: {feature_name: [story_id, ...]} from backlog
104
- guide_en_dir: Path to guide/en/
105
-
106
- Returns:
107
- {"status": "pass"|"fail", "gaps": [descriptions...]}
108
- """
109
- if not guide_en_dir.exists() or not guide_en_dir.is_dir():
110
- return {"status": "pass", "gaps": []}
111
-
112
- # Collect all guide text
113
- all_guide_text = ""
114
- for md_file in sorted(guide_en_dir.glob("*.md")):
115
- try:
116
- all_guide_text += md_file.read_text(encoding="utf-8")
117
- except (OSError, UnicodeDecodeError):
118
- continue
119
-
120
- # Also read practices/ subdirectory
121
- practices_dir = guide_en_dir / "practices"
122
- if practices_dir.exists() and practices_dir.is_dir():
123
- for md_file in sorted(practices_dir.glob("*.md")):
124
- try:
125
- all_guide_text += md_file.read_text(encoding="utf-8")
126
- except (OSError, UnicodeDecodeError):
127
- continue
128
-
129
- gaps: list[str] = []
130
- for feature_name, story_ids in done_features.items():
131
- found = False
132
- # Check if feature name appears in guide text
133
- if feature_name.lower() in all_guide_text.lower():
134
- found = True
135
- # Check if any story ID appears
136
- for sid in story_ids:
137
- if sid in all_guide_text:
138
- found = True
139
- break
140
- if not found:
141
- gaps.append(
142
- f"Feature '{feature_name}' has Done stories but no guide "
143
- "documentation found in guide/en/"
144
- )
145
-
146
- return {
147
- "status": "pass" if not gaps else "fail",
148
- "gaps": gaps,
149
- }