diff --git a/bin/file-bugs.sh b/bin/file-bugs.sh index 1acee6c5..d9b46e6d 100755 --- a/bin/file-bugs.sh +++ b/bin/file-bugs.sh @@ -4,6 +4,7 @@ # bin/file-bugs.sh [--project DIR] # dry run: redacted title + body, duplicates # bin/file-bugs.sh --file PBUG-02 [--file ...] # file that bug, after a y/N per bug # bin/file-bugs.sh --all # every open bug, still a y/N per bug +# bin/file-bugs.sh --file PBUG-02 --to mxcli # file it to the other repo as well (row keeps its target) # bin/file-bugs.sh --links # prefilled github new-issue URL per open bug # bin/file-bugs.sh --json # redacted bugs + URLs as JSON (for the report) # bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A \ diff --git a/bin/lib/file-bugs.py b/bin/lib/file-bugs.py index 38294392..dac4d2aa 100644 --- a/bin/lib/file-bugs.py +++ b/bin/lib/file-bugs.py @@ -639,6 +639,9 @@ def main(): ap.add_argument("--links", action="store_true") ap.add_argument("--json", action="store_true") ap.add_argument("--name", action="append", default=[], help="extra project name to redact") + ap.add_argument("--to", choices=sorted(TARGETS), help="file to this repo instead of the row's target: a finding that is " + "really both is two submissions (skills/upstream-feedback.md). The row keeps its target and status; " + "the issue URL is recorded under also.") ap.add_argument("--add", action="store_true") for k in ("target", "title", "repro", "expected", "actual", "workaround", "mxcli", "mendix", "toolkit-version"): ap.add_argument("--" + k) @@ -654,7 +657,9 @@ def main(): sys.exit("file-bugs: no %s" % path) want = set(a.file) sid = lambda d: str(d.get("id")) - todo = [(i, d) for i, d in rows if d.get("status", "open") == "open" and (not want or sid(d) in want)] + also = lambda d: bool(a.to) and a.to != d.get("target") # a second submission, to the other repo + todo = [(i, d) for i, d in rows if (d.get("status", "open") == "open" or (also(d) and d.get("status") == "filed")) + and not (also(d) and (d.get("also") or {}).get(a.to)) and (not want or sid(d) in want)] for w in want: if not any(sid(d) == w for _, d in rows): sys.exit("file-bugs: %s is not in %s" % (w, path)) @@ -684,6 +689,7 @@ def main(): for _, d in todo: target = d.get("target") repo = TARGETS.get(target) if isinstance(target, str) else None + if repo and a.to: repo = TARGETS[a.to] if not repo: sys.stderr.write("file-bugs: %s has target %s, not one of %s; skipped\n" % (sid(d), json.dumps(target)[:40], "/".join(TARGETS))) @@ -710,6 +716,9 @@ def main(): dp = dupes(repo, title) if dp is None: print("Duplicates: search unavailable (gh missing or not authenticated)") + if posting: + print("Nothing breaks: the bug stays open in docs/bugs.jsonl with its workaround. To file it later, install gh and run " + "`gh auth login`, or print a pre-filled link for someone with a GitHub account: file-bugs.sh --links " + sid(d)) elif not dp: print("Duplicates: none found") else: @@ -731,7 +740,8 @@ def main(): if p.returncode != 0 or not m: print("gh issue create failed: " + (p.stderr or p.stdout).strip()[:300]) continue - write_back(path, sid(d), {"status": "filed", "issue_url": m.group(0)}) + if also(d): write_back(path, sid(d), {"also": dict(d.get("also") or {}, **{a.to: m.group(0)})}) + else: write_back(path, sid(d), {"status": "filed", "issue_url": m.group(0)}) print("filed: " + m.group(0)) if a.json: print(json.dumps(out, indent=2, ensure_ascii=False)) diff --git a/project-bin/progress-report.py b/project-bin/progress-report.py index 9c2440d4..b255c168 100755 --- a/project-bin/progress-report.py +++ b/project-bin/progress-report.py @@ -24,6 +24,8 @@ STAGE_NAME = {"P": "Kickoff", "0": "Triage", "1": "Analysis", "2": "Requirements", "3": "Architecture and design", "4": "Build plan", "5": "Build and improve", "6": "Verify and report"} +TABS = [("tab-overview", "Overview"), ("tab-build", "Build"), ("tab-changes", "Changes"), ("tab-performance", "Performance"), ("tab-pages", "Pages"), ("tab-notes", "Notes, tips and bugs")] +BUG_REPO = {"mxcli": ("mxcli", "https://github.com/mendixlabs/mxcli"), "toolkit": ("the toolkit", "https://github.com/MendixMau/mxcli-project-toolkit")} REPORT_FILE = "progress-report.html" # the one place the page name lives here (build-plan-status.sh asks for it with --print-name) IDLE_GAP_S = 600 # a gap longer than this between two events counts as waiting for the user CACHE_TTL_S = 300 # prompt cache time to live assumed for the cache rewrite observation @@ -183,6 +185,14 @@ def load_project(proj): t = decision_time(" ".join(c[2:]) or c[1]) or decision_time(c[1]) sm = re.search(r"Stage\s*(\w)\b", c[1]); sg = c[0] if c[0] in STAGES else (sm.group(1) if sm else None) if sg in STAGES and (sg not in info["signoff"] or (t and t < info["signoff"][sg][0])): info["signoff"][sg] = (t, c[1][:80], bool(re.search(r"20\d\d-\d\d-\d\d[ T]\d\d:\d\d", " ".join(c[2:]) + " " + c[1]))) + info["changes"] = [] # PROJECT.md ## Change log rows (skills/change-intake.md), shown on the Changes tab + sec = re.search(r"^## Change log\s*\n(.*?)(?=^## |\Z)", md, re.M | re.S) + if sec: + for line in sec.group(1).splitlines(): + c = [x.strip().replace("\\|", "|") for x in re.split(r"(?= 8 and re.match(r"CR-\d+$", c[0]): + st = re.match(r"[A-Za-z]+", c[7]); info["changes"].append({"cr": c[0], "date": c[1], "ask": c[3], "cls": c[4], "size": c[5], + "status": st.group(0).lower() if st else "unknown", "status_txt": c[7], "closed": c[8] if len(c) > 8 else ""}) m = re.search(r"Entry mode:\s*\**\s*(\w[\w-]*)", md) em = (m.group(1).lower() if m else "migration") info["entry_mode"] = "requirements" if em.startswith("requirements") else "existing-app-change" if em.startswith("existing") else em @@ -192,6 +202,96 @@ def load_project(proj): info["md_mtime"] = mt return info +CR_STATE = {"open": ("waiting", "open"), "refined": ("waiting", "refined"), "agreed": ("waiting", "agreed"), "applied": ("built", "applied"), + "proven": ("proven", "proven"), "rejected": ("closed", "rejected")} + +def render_changes(w, chg, bp): + """Changes tab: the PROJECT.md Change log as a backlog, with the plan rows that carry each CR tag.""" + w("

Changes: requests logged after the plan

From the PROJECT.md Change log (skills/change-intake.md). " + "Open, refined and agreed rows are waiting; applied rows are built; proven rows have evidence. Plan rows are the build-plan.md rows whose text names the CR.
") + if not chg: + w("
No change requests logged. A change made with no row is invisible here, so log every ask first.
"); return + tagged = collections.defaultdict(lambda: [0, 0]) + for p in (bp or {}).get("phases", []): + for x in p.get("steps", []): + for cr in set(re.findall(r"CR-\d+", x.get("step", ""))): + tagged[cr][1] += 1; tagged[cr][0] += x.get("cls") == "built" + groups = collections.Counter(CR_STATE.get(c["status"], ("waiting", ""))[0] for c in chg) + col = {"waiting": "var(--warn)", "built": "var(--accent)", "proven": "var(--ok)", "closed": "var(--idle)"} + w("
%d change request(s): %d waiting, %d built, %d proven%s
" % (len(chg), groups["waiting"], groups["built"], groups["proven"], (", %d rejected" % groups["closed"]) if groups["closed"] else "")) + w("
" + "".join("" % (k, groups[k], pct(groups[k], len(chg)), col[k]) for k in col if groups[k]) + "
") + w("
" + "".join("%s %d" % (col[k], k, groups[k]) for k in col if groups[k]) + "
") + w("
") + for c in chg: + g, _ = CR_STATE.get(c["status"], ("waiting", c["status"])); t = tagged.get(c["cr"]) + ask = c["ask"].strip('"'); pm = re.search(r"\(part:\s*([^)]*)\)\s*$", ask) # one sentence split over several CRs: lead with the part + if pm: ask = "Part: %s. %s" % (pm.group(1).strip(), ask[:pm.start()].strip().strip('"')) + w("" % ( + esc(c["cr"]), esc(c["date"]), esc(ask), esc(ask[:140] + ("..." if len(ask) > 140 else "")), esc(c["cls"]), esc(c["size"]), g, esc(c["status_txt"]), esc(c["status"]), + ("%d of %d" % (t[0], t[1])) if t else "none tagged", esc(c["closed"][:60]) or "none yet")) + w("
CRAskClassSizeStatusPlan rows builtClosed by
%s
%s
%s%s%s%s%s%s
") + +def load_bugs(proj, tk): + """docs/bugs.jsonl (bin/file-bugs.sh) for the Bugs tab. The rows are shown as logged; the file buttons come only from + file-bugs.py --json, which redacts each bug and refuses without a deny list, so this page never builds a public URL + itself and never posts anything. The --json run makes no network call.""" + path = os.path.join(proj, "docs", "bugs.jsonl"); out = {"rows": [], "links": {}, "note": "", "path": path, "proj": proj, "cmd": ""} + for line in read(path).splitlines(): + try: d = json.loads(line) + except ValueError: continue + if isinstance(d, dict) and d.get("id"): out["rows"].append(d) + if not any(r.get("status", "open") == "open" for r in out["rows"]): return out + dirs = [tk, os.environ.get("MXTK_TOOLKIT_DIR", "")] # file-bugs.py ships in the toolkit only, never in a project's bin/ + for f in (os.path.join(proj, ".claude", "toolkit.env"), os.path.join(os.path.expanduser("~"), ".mxcli-toolkit.env")): + m = re.search(r"^\s*(?:export\s+)?(?:MXTK_TOOLKIT_DIR|TOOLKIT_DIR|MXCLI_TOOLKIT)\s*=\s*[\"']?([^\"'\n]+)", read(f), re.M) + if m: dirs.append(os.path.expanduser(m.group(1).strip())) + fb = next((x for x in (os.path.join(d, "bin", "lib", "file-bugs.py") for d in dirs if d) if os.path.isfile(x)), "") + if not fb: + out["note"] = "File buttons need the toolkit's bin/lib/file-bugs.py. Point to the toolkit clone with MXTK_TOOLKIT_DIR= in ~/.mxcli-toolkit.env or pass --toolkit."; return out + out["cmd"] = os.path.join(os.path.dirname(os.path.dirname(fb)), "file-bugs.sh") + try: + import subprocess + p = subprocess.run([sys.executable, "-I", fb, "--project", proj, "--json"], capture_output=True, text=True, timeout=30) + if p.returncode == 0: + for it in json.loads(p.stdout or "[]"): out["links"][it.get("id")] = it + else: out["note"] = (p.stderr or p.stdout).strip().replace("file-bugs: ", "")[:400] + except Exception as e: + out["note"] = "file-bugs.py --json did not run (%s)." % type(e).__name__ + return out + +def render_bugs(w, bugs): + """Bugs tab: the project's tool bugs (mxcli or toolkit). An open bug gets buttons that copy its file-bugs.sh + command (one per repo); the person runs it, which files through gh after a y/N.""" + rows = bugs["rows"] + w("

Encountered bugs or limitations

Problems in mxcli or the toolkit hit in this project, from docs/bugs.jsonl. Add one with " + "bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A. A button copies the filing command. Run it in your " + "terminal (or in Claude Code after !): it removes project names, paths and secrets, shows the text and likely duplicates, asks y/N, " + "then files with gh issue create. Nothing is posted from this page. No GitHub CLI or account (for example on " + "Team Server only)? Nothing breaks: the bug stays here, open, with its workaround, and --links prints a pre-filled link to " + "hand to someone who has an account.
") + if not rows: + w("
No bugs logged in this project. When a session hits or works around a bug in mxcli or the toolkit, log it first, then file it from here.
"); return + n = collections.Counter(r.get("status", "open") for r in rows) + w("
%d bug(s): %d open, %d filed, %d fixed
" % (len(rows), n["open"], n["filed"], n["fixed"])) + if bugs["note"]: w("

No file buttons: %s

" % esc(bugs["note"])) + w("
") + for r in rows: + tgt = r.get("target", ""); label = BUG_REPO.get(tgt, (tgt or "unknown", ""))[0]; st = r.get("status", "open"); it = bugs["links"].get(r.get("id"), {}) + if st == "filed" and r.get("issue_url"): act = " ".join("%s" % (esc(u), esc(re.sub(r"^https://github\.com/", "", u))) for u in [r["issue_url"]] + list((r.get("also") or {}).values())) + elif st == "fixed": act = "fixed" + elif it.get("refused"): act = "held back: redaction found something that looks private; fix the row" % esc(it.get("note", "")) + elif it.get("title") and bugs.get("cmd"): # filing runs gh in the person's terminal: redact, duplicate search, y/N, gh issue create + home = os.path.expanduser("~"); q = lambda x: "~" + x[len(home):] if x.startswith(home + os.sep) else x + base = "%s --project %s --file %s" % (q(bugs["cmd"]), q(bugs["proj"]), r.get("id")) + # a finding that is really both is two submissions, one per repo (skills/upstream-feedback.md section 1) + cmds = [(base, "File to " + label, "")] + [(base + " --to " + k, "also to " + BUG_REPO[k][0], " alt") for k in BUG_REPO if k != tgt and tgt in BUG_REPO] + act = "".join(" " % (c_, esc(cmd), esc(lab_)) for cmd, lab_, c_ in cmds) + act += "
%s
" % esc(base) + else: act = "no button (see note above)" + w("" % ( + esc(r.get("id", "")), esc(r.get("date", "")), esc(label), esc(r.get("title", "")), {"open": "waiting", "filed": "built", "fixed": "proven"}.get(st, "closed"), esc(st), act)) + w("
BugForTitleStatusAction
%s
%s
%s%s%s%s
") + def decision_time(txt): """Epoch of the first date (and time when given) in a decision row cell; local time; None without a date.""" m = re.search(r"(20\d\d)-(\d\d)-(\d\d)(?:[ T](\d\d):(\d\d))?", txt or "") @@ -1543,7 +1643,18 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, health = run_health(cs) return fired[:ADV["max_items"]], quiet + fired[ADV["max_items"]:], health, cs, hidden[0] -JS = "" +# tabs: without JavaScript every section shows, one page as before; the chosen tab survives the 30 s reload +TABS_JS = ("(function(){var b=document.body,tabs=[].slice.call(document.querySelectorAll('section.tab')),links=[].slice.call(document.querySelectorAll('.tabbar a[data-tab]'));if(!tabs.length)return;\n" + "var K='pr-tab:'+location.pathname;function get(){try{return sessionStorage.getItem(K)}catch(e){return null}}\n" + "function show(id){tabs.forEach(function(t){t.classList.toggle('on',t.id===id)});links.forEach(function(l){l.classList.toggle('on',l.getAttribute('data-tab')===id)});try{sessionStorage.setItem(K,id)}catch(e){}}\n" + "window.__prShow=show;b.classList.add('tabbed');var s=get();show(s&&document.getElementById(s)?s:tabs[0].id);\n" + "links.forEach(function(l){l.addEventListener('click',function(e){e.preventDefault();b.classList.remove('all');show(l.getAttribute('data-tab'));window.scrollTo(0,0)})});\n" + "document.addEventListener('click',function(e){var t=e.target.closest&&e.target.closest('button[data-cmd]');if(!t)return;var c=t.getAttribute('data-cmd'),l=t.textContent;\n" + " function fb(){try{var x=document.createElement('textarea');x.value=c;document.body.appendChild(x);x.select();var ok=document.execCommand('copy');x.remove();return ok}catch(_){return false}}\n" + " function done(ok){t.textContent=ok?'Copied: paste in your terminal':'Copy failed: use the command below';setTimeout(function(){t.textContent=l},2000)}\n" + " if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(c).then(function(){done(true)},function(){done(fb())});else done(fb())});\n" + "var a=document.getElementById('showall');if(a)a.addEventListener('click',function(e){e.preventDefault();b.classList.toggle('all');a.textContent=b.classList.contains('all')?'Tabs':'Show all'});})();\n") +JS = "" # ----------------------------------------------------------------- render CSS = """ :root{--bg:#f6f7f9;--card:#fff;--ink:#1c2430;--mut:#667085;--line:#e3e7ee;--accent:#2f6fed;--ok:#1f9d63;--warn:#d98a0b;--bad:#c93c3c;--idle:#d9dde5;--main:#2f6fed;--sub:#8b5cf6;--soft:#eef2fb;--shadow:0 1px 2px rgba(20,30,50,.06)} @@ -1589,6 +1700,9 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, .snav a{min-width:34px;text-align:center;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid var(--line);font-weight:650;font-size:13px;color:var(--ink)} .snav a:hover{text-decoration:none;border-color:var(--accent)} .hd h3 a{color:inherit}.updated{display:inline-block;margin-top:6px;padding:3px 10px;border-radius:8px;background:var(--soft);font-size:13px;font-weight:600} +.tabbar{display:none;margin-left:auto;gap:4px;flex-wrap:wrap}body.tabbed .tabbar{display:flex}body.tabbed .snav>a{display:none}.tabbar a{padding:3px 10px;border-radius:999px;text-decoration:none;color:var(--ink);border:1px solid var(--line)}.tabbar a.on{background:var(--accent);border-color:var(--accent);color:#fff}#showall{color:var(--mut);border-style:dashed} +body.tabbed:not(.all) section.tab:not(.on){display:none}body.all section.tab{border-top:1px solid var(--line);margin-top:24px}section.tab .side{position:static;max-height:none;margin-top:24px} +button.filebtn{font:inherit;font-size:13px;cursor:pointer;border:1px solid var(--accent)}.filebtn{display:inline-block;padding:3px 10px;border-radius:6px;background:var(--accent);color:#fff;text-decoration:none;white-space:nowrap;margin:2px 0}.filebtn.alt{background:transparent;color:var(--accent);border:1px solid var(--accent)}.crs td{vertical-align:top}.crst{display:inline-block;padding:1px 8px;border-radius:999px;font-size:12px;border:1px solid currentColor}.cr-waiting{color:var(--warn)}.cr-built{color:var(--accent)}.cr-proven{color:var(--ok)}.cr-closed{color:var(--mut)} .layout{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px;align-items:start}.layout.solo{display:block}.content{min-width:0}.side{position:sticky;top:52px;max-height:calc(100vh - 64px);overflow:auto;padding-right:2px}.side h2{margin-top:0}.health{margin-top:8px}.health .hi{font-size:13px;margin-top:3px}.grp{margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--card)}.grp>summary{cursor:pointer;padding:9px 12px;font-size:14px}.grp .speed,.grp>.split,.grp>div,.grp .tip{padding:0 12px 10px}.tip{border-top:1px solid var(--line);padding-top:10px}.tip h3{font-size:13px;margin:0 0 4px}.tip p{margin:0 0 4px;font-size:12.5px}.p-info{background:#8793a6;color:#fff}@media(max-width:1000px){.layout{grid-template-columns:minmax(0,1fr)}.side{position:static;max-height:none}}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px}.p-green{background:#2e9e5b;color:#fff}.p-amber{background:#e0a100;color:#fff}.p-red{background:#d1383d;color:#fff}.pill{padding:1px 9px;margin-left:6px;border-radius:99px;font-size:12px}.speed{margin-top:8px;font-size:13px}.split{display:flex;gap:2px;margin:6px 0}.seg{background:var(--accent);color:#fff;font-size:11px;padding:3px 6px;border-radius:4px;white-space:nowrap;overflow:hidden}.seg:nth-child(2){opacity:.75}.seg:nth-child(3){background:var(--idle);color:var(--ink)}.seg:nth-child(4){opacity:.55}.muted{color:var(--mut);font-size:12px}\n.advl{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:10px;margin-top:8px} .advi{border-left:4px solid var(--warn)}.advi.hi{border-left-color:var(--bad)}.advi .rk{font-size:12px;color:var(--mut)}.advi h3{margin:2px 0 6px}.advi p{margin:4px 0;font-size:13px}.advi .why{color:var(--mut);font-size:12px} details.ph>summary{list-style:none;cursor:pointer;color:var(--ink)}details.ph>summary::-webkit-details-marker{display:none} @@ -1698,9 +1812,10 @@ def money(c): return "n/a" if unp else "$%.0f" % c w("") w("") w("Progress report: %s" % (esc(info["name"]), CSS)) - # One group of section links (issue #479). The stage list is one link away; the current stage is in the - # header line below, and the #stage-N card anchors stay for links from other pages. - w("
" % ("" if L else " solo")) + # One group of section links (issue #479): the bar without JavaScript. With JavaScript the tab bar replaces them + # (body.tabbed hides the plain links). The current stage is in the header line below, and the #stage-N card + # anchors stay for links from other pages; a link into a hidden tab switches to that tab first. + w("
") last_ev = datetime.datetime.fromtimestamp(an["t1"]).strftime("%Y-%m-%d %H:%M") if L else "no session log" w("

Progress report: %s

" % esc(info["name"])) w("Last updated %s %s session log last event %s%s
" % (gen, datetime.datetime.now().astimezone().strftime("%Z"), last_ev, @@ -1724,7 +1839,9 @@ def money(c): return "n/a" if unp else "$%.0f" % c if how_parts: w("
How this was measured%s
" % "".join(how_parts)) if not L: w("

%s Time, token and cost figures are not shown; everything else is (details in How this was measured).

" % esc(args.log_note)) - if args.ledgers and args.ledgers["phases"]: render_ledgers(w, args.ledgers) + PERF = [] + if not L and args.ledgers and args.ledgers["phases"]: render_ledgers(PERF.append, args.ledgers) + w("
") w("

%s

%d of 8 gates passed%s
" % (info["current"] or "P", esc(info["current_line"] or "Current stage unknown"), npass, (", %d waived" % sum(1 for s_ in STAGES if states[s_] == "waived")) if "waived" in states.values() else "")) w("
" % overall) w("
Overall progress %.0f%%: mean of the stage bars below (passed or waived stage = 100, current stage = promised artifacts present, Stage 5 = plan rows built).
" % overall) @@ -1737,13 +1854,18 @@ def money(c): return "n/a" if unp else "$%.0f" % c cwin, cnote = context_window(main["msgs"], main["compacts"], main.get("flag_1m", False)) pw = lambda a_: ctx_pct_txt(a_, cwin) nart = sum(len(arts[s_]["files"]) for s_ in STAGES); ndec = len(info["decisions"]) - kp = [("Gates passed", "%d of 8" % npass, "stages P to 6, from PROJECT.md"), ("Plan rows built", ("%d of %d" % (bp["built"], bp["total"])) if bp and bp["total"] else "none yet", "from architecture/build-plan.md" + signoff_suffix(bp)), - ("Artifacts present", "%d" % nart, "files found for the eight stages"), ("Decisions", "%d" % ndec, "rows in the PROJECT.md register")] if not L else [("Wall time", fmt_dur(wall), "first to last event in the session"), ("Waiting for you", fmt_dur(idle_s), "gaps over %d min" % (IDLE_GAP_S // 60)), + chg = info.get("changes", []); waiting = [c for c in chg if c["status"] in ("open", "refined", "agreed")] + kp0 = [("Gates passed", "%d of 8" % npass, "stages P to 6, from PROJECT.md"), ("Plan rows built", ("%d of %d" % (bp["built"], bp["total"])) if bp and bp["total"] else "none yet", "from architecture/build-plan.md" + signoff_suffix(bp)), + ("Changes waiting", ("%d of %d" % (len(waiting), len(chg))) if chg else "none logged", "open, refined or agreed in the Change log"), + ("Artifacts present", "%d" % nart, "files found for the eight stages"), ("Decisions", "%d" % ndec, "rows in the PROJECT.md register")] + kp = [] if not L else [("Wall time", fmt_dur(wall), "first to last event in the session"), ("Waiting for you", fmt_dur(idle_s), "gaps over %d min" % (IDLE_GAP_S // 60)), ("New tokens", fmt_tok(head(tot)), "input + output + cache write"), ("Cache reads", fmt_tok(tot["cr"]), "billed at a fraction"), ("Est. cost", money(cost_all), cost_note), ("Sub-agents", "%d" % nsub, "%d of them in %d workflow run(s)" % (nwf, len({a["wf_id"] for a in agents if a["wf"]}))), ("Context avg / peak", "%s / %s" % (fmt_tok(ctx_avg), fmt_tok(peak)), "per main request, " + (("window %s" % fmt_tok(cwin) + (" (%s)" % cnote if cnote else "")) if cwin else "window unknown")), ("Compactions", "%d" % len(main["compacts"]), "automatic context summaries"), ("Your prompts", "%d" % main["prompts"], "typed messages, text not read into this page")] - w("
" + "".join("
%s
%s
%s
" % (esc(v), esc(l), esc(n)) for l, v, n in kp) + "
") + kpis = lambda k_: "
" + "".join("
%s
%s
%s
" % (esc(v), esc(l), esc(n)) for l, v, n in k_) + "
" + w(kpis(kp0)) + if kp: PERF.append("

Session totals

" + kpis(kp)) # tips and tricks: a narrow side column, one collapsed box per theme, calm severity, actionable tips only aside = "" if L: @@ -1831,6 +1953,8 @@ def chip(f, cls=""): "".join("%s" % esc(m) for m in miss))) w("
") w("
") + w("") + w_main = w; w = PERF.append if L: # where time went w("

Where time went: wall clock by stage, activity and tokens

Top band: stage (approximate). Middle: who was working. Bottom: new tokens per 10 minutes. Hours are local time.
") @@ -1919,7 +2043,9 @@ def chip(f, cls=""): for t, p in adv: w("

%s

%s

" % (esc(t), p if "&" in p else esc(p))) top5 = sorted(agents, key=lambda a: -a["tok"]["cost"])[:5] w("

Five most expensive sub-agents

" + "".join("" % (esc(a["desc"]), esc(a["model"].replace("claude-", "")), money(a["tok"]["cost"])) for a in top5) + "
%s%s%s
") + w = w_main # build progress (Stage 5): the plan's own phases, stacked built / partial / not built + w("
") w("

Build progress: Stage 5 plan, scripts and proof

") if bp: kinds = bp["kinds"]; tr = "" @@ -1935,8 +2061,7 @@ def chip(f, cls=""): if facts and facts["score"]: w("
Latest model score %.1f (%s).
" % (facts["score"]["score"], esc(facts["score"]["script"][:40]))) if bp.get("note"): w("
%s.
" % esc(bp["note"])) w("
Kinds are the plan's own Kind column; row state is the plan's State column read by prefix (built or done = built, partial or in progress = partial, anything else = not built); HARNESS rows are not build rows and are not counted. A HARNESS row running verify-module.sh <Module> is that module's sign-off and shows on its phase card as one status, read from the module's verify summary and LOOK report: verified only when the summary is clean and newer than the scripts it depends on and the LOOK is N of N. The gate's own record of the look is the look obligation, in the obligations table of gate-check.sh <project> 5 (index.html). Plan read %s from %s.
%s
" % (esc(bp.get("generated") or "unknown"), "architecture/build-plan.md" if bp.get("source") == "md" else "architecture/build-plan.json", signoff_rollup(bp))) - w("
") - for p in bp["phases"]: + def card(p): now = p is cur w("
Phase %s: %s%s%s
%s
module files: %s
%s
Click to show or hide the rows
" % (" now" if now else "", esc(p["id"]), " open" if now else "", esc(p["id"]), esc(p["name"]), " building now" if now else "", phase_bar(p, runs_by.get(str(p["id"]))), esc(" ".join("%s %d" % kv for kv in p["kinds"].items())), @@ -1944,15 +2069,40 @@ def chip(f, cls=""): if str(p["id"]) in phase_detail: w(phase_detail[str(p["id"])]) else: w("
" + "".join("" % (x["cls"], esc(x["n"]), esc(x["kind"]) + (" (not counted)" if x["cls"] == "harness" else ""), esc(x["step"]), esc(x["state"][:90])) for x in p["steps"]) + "
#KindRowState
%s%s%s%s
" if p["steps"] else "") w("
") - w("
") + # focus: the phase being built plus the three unfinished ones with the latest built date; the rest fold + last = lambda p: max([m for x in p["steps"] for m in re.findall(r"20\d\d-\d\d-\d\d", x.get("state", ""))] or [""]) + done = [p for p in bp["phases"] if p["n"] and p["built"] == p["n"]] + left = [p for p in bp["phases"] if p not in done] + cl = any(p is cur for p in left) + focus = ([cur] if cl else []) + sorted([p for p in left if p is not cur], key=last, reverse=True)[:4 - cl] + rest = [p for p in left if not any(p is f for f in focus)] + def fold(fid, title, sub, ps): + w("
%s (%d)
%s
" % (fid, title, len(ps), sub)) + for p in ps: card(p) + w("
") + if focus: + w("

Building now and next

The phase being built, then the unfinished phases with the most recent built rows.
") + for p in focus: card(p) + w("
") + if rest: fold("fold-rest", "Other unfinished phases", "Not started yet, or a few rows left: %s" % esc(", ".join("Phase %s %d of %d" % (p["id"], p["built"], p["n"]) for p in rest)), rest) + if done: fold("fold-done", "Completed phases", "Every row built: %s" % esc(", ".join("Phase %s" % p["id"] for p in done)), done) else: w("
No plan to summarise yet: %s
" % esc(args.plan_msg or "architecture/build-plan.md was not read.")) if args.fragment: # plan tally, views A (script folders) and B (module proof); the per-phase rows live in the cards above w("
") w(args.fragment) w("
") + w("") + render_changes(w, chg, bp) + w("
") + if not L: w("

Performance

%s Time, token and cost figures need a Claude Code session log of this project (see How this was measured at the top).

" % esc(args.log_note)) + H.extend(PERF) + w("
") w(render_pages(proj, args.outdir, [SELF_OUT])) + w("
") + w(aside) # tips first, then the bugs met here; the method notes below are reference, folded + render_bugs(w, args.bugs) # footer - w("

How these numbers are derived

    ") + w("
    How these numbers are derived
    Sources, cost, stage windows, attribution, time, gate state, known gaps and the exact inputs of this page.
      ") if L: sg = an["sig"] w("
    • Source. The Claude Code session logs of this project on this machine (%d session file(s), %d sub-agent log(s)). Usage is counted once per message id, taking the maximum of each counter, because the log writes one record per streamed block (same method as the toolkit's token-burn.sh). Synthetic records are skipped. New tokens = input + output + cache write; cache reads are shown apart. Only numbers, timestamps, tool names and sub-agent short descriptions are used; no prompt or message text is copied into this page.
    • " % (args.n_sessions, len(agents))) @@ -1972,7 +2122,7 @@ def chip(f, cls=""): direct = "progress-report.py <project> " + (" ".join("--session-log %s" % esc(x) for x in args.session_log) if args.session_log else "--logs-dir <dir>") w("
    • Inputs. Project folder: %s. Session log(s) (%s): %s. Rates file: %s. To regenerate with the same inputs: %s, or %s.
    • " % (esc(proj), how, logs_txt, rates_txt, regen, direct)) w("
    ") - w("
    Rebuild: project-bin/build-plan-status.sh --html. Setting in PROJECT.md: Progress report: off, ask or auto (now %s). Generated %s.
    %s
" % (esc(info["setting"]), gen, aside)) + w("
Rebuild: project-bin/build-plan-status.sh --html. Setting in PROJECT.md: Progress report: off, ask or auto (now %s). Generated %s.
" % (esc(info["setting"]), gen)) w(JS) if args.reload: w("" % ("(function(){\nvar VK='pr-view:'+location.pathname,PK='pr-paused',IDLE=20000,EVERY=30000,last=%d,active=%d,lastInput=0,de=document.documentElement;\nfunction get(k){try{return sessionStorage.getItem(k)}catch(e){return null}}\nfunction put(k,v){try{if(v===null)sessionStorage.removeItem(k);else sessionStorage.setItem(k,v)}catch(e){}}\ntry{history.scrollRestoration='manual'}catch(e){}\nfunction restore(){try{var st=JSON.parse(get(VK)||'null');if(!st)return;\n var d=st.d||{};for(var id in d){var e=document.getElementById(id);if(e&&e.tagName==='DETAILS')e.open=!!d[id]}\n var sb=de.style.scrollBehavior;de.style.scrollBehavior='auto';window.scrollTo(0,st.y||0);de.style.scrollBehavior=sb;\n }catch(e){}}\nrestore();window.addEventListener('load',function(){restore();put(VK,null)});\nfunction save(){var d={};var all=document.querySelectorAll('details[id]');for(var i=0;i(.*?)", read(a.phase_detail) if a.phase_detail else "", re.S)}; a.plan_msg = a.plan_msg + a.bugs = load_bugs(proj, tk) if a.session_log and not a.no_log: sessions, base = explicit_sessions(a.session_log), "" else: sessions, base = ([], "") if a.no_log else find_sessions(proj, a.logs_dir, a.session_cache) diff --git a/tests/wave2/test-file-bugs.sh b/tests/wave2/test-file-bugs.sh index e182036e..71b56272 100755 --- a/tests/wave2/test-file-bugs.sh +++ b/tests/wave2/test-file-bugs.sh @@ -56,8 +56,9 @@ echo "$*" >> "$GHLOG" case "$1 $2" in "issue list") echo '[{"number":77,"title":"Similar widget crash","state":"OPEN","url":"https://github.com/mendixlabs/mxcli/issues/77"}]' ;; "issue create") - for a in "$@"; do case "$a" in --body-file=*) cp "${a#--body-file=}" "$GHLOG.body" ;; esac; done - echo "https://github.com/mendixlabs/mxcli/issues/4242" ;; + r=mendixlabs/mxcli + for a in "$@"; do case "$a" in --body-file=*) cp "${a#--body-file=}" "$GHLOG.body" ;; --repo=*) r="${a#--repo=}" ;; esac; done + echo "https://github.com/$r/issues/4242" ;; "--version "*) echo "gh version stub" ;; esac exit 0 @@ -341,6 +342,27 @@ G="$(USER=maurits.visser bodyof 'by maurits.visser, maurits and visser; Kundennu chk "first.last redacts whole and each part" '! echo "$G" | grep -qi "maurits\|visser"' chk "attribute followed by digits or letters is redacted" '! echo "$G" | grep -qi "kundennummer"' +echo "N. --to: the same bug filed to the other repo too" +: > "$GHLOG"; cp "$P/docs/bugs.jsonl" "$W/before" +chk "--to toolkit offers a filed mxcli bug for the toolkit repo" 'run --to toolkit --json /dev/null 2>&1 +chk "--to toolkit creates the issue in the toolkit repo" '[ "$(grep -c "issue create" "$GHLOG")" = 1 ] && grep -q "issue create --repo=MendixMau/mxcli-project-toolkit " "$GHLOG"' +chk "the row keeps its target, status and first url; the second url goes in also" 'grep "\"PBUG-01\"" "$P/docs/bugs.jsonl" | grep "\"target\": *\"mxcli\"" | grep "\"status\": \"filed\"" | grep -q "\"issue_url\": \"https://github.com/mendixlabs/mxcli/issues/4242\", \"also\": {\"toolkit\": \"https://github.com/MendixMau/mxcli-project-toolkit/issues/4242\"}"' +chk "other rows byte-identical after --to" 'diff <(grep -v PBUG-01 "$W/before") <(grep -v PBUG-01 "$P/docs/bugs.jsonl") >/dev/null' +chk "a bug already filed to that repo is not offered again" '! run --to toolkit /dev/null; done; done +cp "$P/docs/bugs.jsonl" "$W/before"; NC="$(grep -c "" "$GHLOG")" +OUT="$(PATH="$NOGH" ANS=y "$PY" "$W/ptyrun.py" tty "$SUBJ" --project "$P" --file PBUG-03 2>&1)"; RC=$? +chk "the stripped PATH really has no gh" '! PATH="$NOGH" command -v gh >/dev/null' +chk "without gh, --file exits 0" '[ "$RC" = 0 ]' +chk "without gh nothing is posted and the stub was never reached" '[ "$(grep -c "" "$GHLOG")" = "$NC" ] && echo "$OUT" | grep -q "Not posting"' +chk "without gh the log is unchanged" 'cmp -s "$P/docs/bugs.jsonl" "$W/before"' +chk "without gh it names the way out (gh auth login, or --links)" 'echo "$OUT" | grep -q "Nothing breaks" && echo "$OUT" | grep -q "file-bugs.sh --links PBUG-03"' + echo "Z. no real issue was ever created" chk "every gh call went through the stub (log exists, no network tool used)" '[ -s "$GHLOG" ]' diff --git a/tests/wave2/test-progress-harness-verify.sh b/tests/wave2/test-progress-harness-verify.sh index a6f0210d..4c2133ae 100644 --- a/tests/wave2/test-progress-harness-verify.sh +++ b/tests/wave2/test-progress-harness-verify.sh @@ -184,7 +184,8 @@ for layout in single two; do && ok "N4 $L: build-plan.json lists all 10 HARNESS rows with their Step, Phase 1 state still in progress; build-plan.md untouched" \ || bad "N4 $L: agent-facing plan" "json HARNESS rows, phase 1 state: ${nj:-none}" # N5: the badge reads build rows only; a HARNESS-only phase is a sign-off phase - bd="$(printf '%s' "$H" | grep -o 'Phase state: [^<]*' | sed 's/.*">//' | tr '\n' ',')" + # read by phase id: the Build tab orders cards by focus (building now first, completed phases folded last) + bd="$("$PY" -c 'import re,sys;h=sys.argv[1];c=dict((m.group(1),m.start()) for m in re.finditer(r"id=.phase-(\w+).",h));print("".join(re.search(r"Phase state: ([^<]*)",h[c[k]:]).group(1)+"," for k in sorted(c)))' "$H")" { [ "$bd" = "built,in progress,sign-off only," ] && printf '%s' "$H" | grep -q 'Sign-off phase: no build rows'; } \ && ok "N5 $L: Phase state badges built / in progress / sign-off only; Phase 3 card 'Sign-off phase: no build rows'" \ || bad "N5 $L: badges" "$bd $(printf '%s' "$H" | grep -o 'Sign-off phase[^<]*')" diff --git a/tests/wave2/test-progress-report-polish.sh b/tests/wave2/test-progress-report-polish.sh index 4773e87c..7521a54e 100644 --- a/tests/wave2/test-progress-report-polish.sh +++ b/tests/wave2/test-progress-report-polish.sh @@ -20,6 +20,14 @@ # sticky and phone-safe; every #stage-N card anchor stays; the header states the current stage; exactly one # id=stamp, every other volatile line marked data-stamp, every id on the page unique; the autoreload's # live controls sit on the id=stamp line; no "replaces the former build-plan.html" in the header. +# P8 tabs: six sections (Overview, Build, Changes, Performance, Pages, Notes) and a tab bar beside the section +# links; without JavaScript every section shows and the section links stay. The Changes tab reads the PROJECT.md +# Change log, counts waiting rows, leads a split sentence with its "(part: ...)" and counts plan rows that name the CR. +# P9 bugs in the Notes tab: docs/bugs.jsonl rows; an open bug gets "File to ..." buttons that copy its file-bugs.sh +# command (gh in the terminal, y/N), one per repo, and no browser new-issue link; a filed bug links its issue(s); +# without a deny list there is no button and the tab says why. +# P10 Build tab: the phase being built (open) and the three unfinished phases with the latest built date as +# cards; other unfinished and completed phases fold into two groups. # P3 no session log: one calm line (not the orange box), the regenerate hint inside "How this was measured"; # with --no-log the page never claims it looked in folders or logs. # @@ -83,7 +91,7 @@ has "$R" "

A\\. Script folders" "view A stays" has "$R" "

B\\. Modules" "view B stays" has "$R" "href='#plan-tables'" "the jump link still points at views A and B" has "$R" "addEventListener(.\\?'toggle" "accordion toggle handler present in the script" -[ "$(grep -o "addEventListener(.\\?'toggle" "$R" | wc -l)" = 1 ] && ok "accordion handler is not duplicated" || bad "accordion handler is not duplicated" +[ "$(grep -o "addEventListener(.\\?'toggle" "$R" | wc -l | tr -d ' ')" = 1 ] && ok "accordion handler is not duplicated" || bad "accordion handler is not duplicated" echo "P6b an older progress-report.py without --phase-detail still renders the page, with the short row tables" OLD="$WORK/oldbin"; mkdir -p "$OLD" @@ -117,7 +125,8 @@ const h = fs.readFileSync(process.argv[2], "utf8"); const main = h.match(/