From 5fa869c3df17ac0d589791b44739e0c77bb70308 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 26 Sep 2026 11:26:40 -0400 Subject: [PATCH 01/25] fix: remove non-function export that broke opencode plugin loader opencode 1.18 treats every export of a plugin module as a plugin function. router-link.js exported a Map (), so the loader rejected the entire module: chat.headers and tool.execute.after never ran. The 29 node tests passed because they imported the module directly, bypassing the loader. - Move parentCache from a named export to a property on RouterLink - Add loader-contract test: every module export must be a function - Update test imports to use RouterLink.parentCache Evidence: opencode.log at every start since 2026-09-26T08:02Z shows "Plugin export is not a function" for router-link.js. --- deploy/opencode-plugin/router-link.js | 5 ++++- deploy/opencode-plugin/router-link.test.mjs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/deploy/opencode-plugin/router-link.js b/deploy/opencode-plugin/router-link.js index e9836fa..9c3d8eb 100644 --- a/deploy/opencode-plugin/router-link.js +++ b/deploy/opencode-plugin/router-link.js @@ -218,4 +218,7 @@ export const RouterLink = async ({ client, directory }) => { }; }; -export { parentCache }; +// Attach parentCache for test introspection. opencode's plugin loader +// rejects any non-function export (see loader-contract test), so the map +// lives as a property on the factory function rather than a named export. +RouterLink.parentCache = parentCache; diff --git a/deploy/opencode-plugin/router-link.test.mjs b/deploy/opencode-plugin/router-link.test.mjs index 493fed6..bc22d81 100644 --- a/deploy/opencode-plugin/router-link.test.mjs +++ b/deploy/opencode-plugin/router-link.test.mjs @@ -12,7 +12,9 @@ import { describe, it, mock, before, after, beforeEach } from "node:test"; import assert from "node:assert/strict"; -import { RouterLink, parentCache } from "./router-link.js"; +import { RouterLink } from "./router-link.js"; + +const parentCache = RouterLink.parentCache; const SESSION_ID = "ses_test123"; const AGENT = "build"; @@ -698,4 +700,13 @@ describe("parent cache", () => { // An entry in the middle still present assert.equal(parentCache.has("ses_evict_0500"), true); }); +}); + +describe("loader contract", () => { + it("every named export from the module is a function (opencode rejects non-function exports)", async () => { + const mod = await import("./router-link.js"); + for (const [name, value] of Object.entries(mod)) { + assert.equal(typeof value, "function", `export "${name}" must be a function`); + } + }); }); \ No newline at end of file -- 2.49.1 From a1e7db1f41eb92de1f870d7141d135ee696df93f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 26 Sep 2026 11:28:45 -0400 Subject: [PATCH 02/25] feat: export calibration fixture for progress detection Export 15 labelled sessions (8 must-flag, 7 must-not-flag) from opencode SQLite DB through scripts/export_progress_fixture.py into tests/fixtures/progress/fixture.json. - Reads opencode ~/.local/share/opencode/opencode.db - Extracts tool calls per prototype's calls_of logic - Applies must_flag/must_not_flag labels per the plan brief - Content scrubbing: sha1[:8] for all args except detector targets (filePath, path, offset, limit, command, pattern, output_mode, include, tmux_command) - file_lines: {path: line_count|null} map for coverage detection - landed: boolean per prototype (edit/write with non-empty diff, or git commit with exit 0) - Atlas window rule tracked per session label - Pre-commit sensitive data scan - Compact JSON format for size --- scripts/export_progress_fixture.py | 327 +++++++++++++++++++++++++++ tests/fixtures/progress/fixture.json | 1 + 2 files changed, 328 insertions(+) create mode 100644 scripts/export_progress_fixture.py create mode 100644 tests/fixtures/progress/fixture.json diff --git a/scripts/export_progress_fixture.py b/scripts/export_progress_fixture.py new file mode 100644 index 0000000..d5259c6 --- /dev/null +++ b/scripts/export_progress_fixture.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Export labelled calibration sessions from opencode SQLite DB into fixture JSON. + +Usage: + python scripts/export_progress_fixture.py + +Output: tests/fixtures/progress/fixture.json + +Fixture format: array of {session_id, label, root_id, title, agent, file_lines, calls} + +Content scrubbing: sha1 everything except filePath/path/offset/limit/command/pattern. +These keys are the detector's target and must pass through verbatim. +""" + +import hashlib +import json +import sqlite3 +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +DB_PATH = Path.home() / ".local" / "share" / "opencode" / "opencode.db" +OUTPUT_PATH = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "progress" / "fixture.json" + +GIT_REPO = "/home/alee/Sources/6krrt" +WORKTREE_PREFIX = "/home/alee/Sources/6krrt-cockpit-quick-wins/" +MAIN_REPO_PREFIX = "/home/alee/Sources/6krrt/" +WORKTREE_COMMIT = "827c408" + +# Regex to find likely file paths in bash command text +import re +_PATH_RE = re.compile(r"/home/alee/Sources/6krrt(?:-cockpit-quick-wins)?/[\w./-]+\.(?:py|js|html|css|json|yaml|yml|md|toml|sh|sql|mjs|ts|tsx)") + +LABELS: Dict[str, str] = { + "ses_f24cc0258ffepbJPoOzoiLZi9s": "must_flag", + "ses_f24fcf651ffeOm9O4E14QSZeJ4": "must_flag", + "ses_f24890640ffewKnIPqjjAfFvOk": "must_flag", + "ses_f245607eaffeGxPH8F1tvvdvkG": "must_flag", + "ses_f237e7e31ffeOzz4kUKu4CWzMu": "must_flag", + "ses_f235d755bffeo0uWtD1g2HwQUg": "must_flag", + "ses_f2506ac70ffebLUxBsq0aauOn9": "must_flag", + "ses_f2380dd4cffeVwcZn3Kv3nQN7v": "must_flag", + "ses_f246e699fffeCHUPTLr5ajBhWC": "must_not_flag", + "ses_f245f78f8ffeGMn3PgArpOwhFy": "must_not_flag", + "ses_f243ea08effemMwXdMRE1gpnhK": "must_not_flag", + "ses_f242e5e28ffehF97ZxxdbTeylQ": "must_not_flag", + "ses_f236bbdaaffezwnvH10X2rgrxc": "must_not_flag", + "ses_f236bc7baffeG5LQsr2K1c46xO": "must_not_flag", + "ses_f2e81929fffeaVbWui0X9QjmRQ": "must_not_flag", +} + +SAFE_KEYS = {"filePath", "path", "offset", "limit", "command", "pattern", "output_mode", "include", "tmux_command"} + +# ── line count cache ──────────────────────────────────────────────────────── + +_LEN_CACHE: Dict[str, Optional[int]] = {} + + +def _count_lines_worktree(relpath: str) -> Optional[int]: + """Count lines of a file in the deleted worktree via git show.""" + try: + r = subprocess.run( + ["git", "-C", GIT_REPO, "show", f"{WORKTREE_COMMIT}:{relpath}"], + capture_output=True, text=True, timeout=15, + ) + if r.returncode == 0: + return max(1, r.stdout.count("\n")) + return None + except (OSError, subprocess.TimeoutExpired): + return None + + +def _count_lines_disk(abspath: str) -> Optional[int]: + """Count lines of a file that exists on disk.""" + try: + with open(abspath, "rb") as fh: + return max(1, fh.read().count(b"\n")) + except OSError: + return None + + +def count_lines(path: str) -> Optional[int]: + """Count lines of a file. Cached.""" + if path not in _LEN_CACHE: + if path.startswith(WORKTREE_PREFIX): + rel = path[len(WORKTREE_PREFIX):] + _LEN_CACHE[path] = _count_lines_worktree(rel) + elif Path(path).exists(): + _LEN_CACHE[path] = _count_lines_disk(path) + else: + _LEN_CACHE[path] = None + return _LEN_CACHE[path] + + +# ── helpers ───────────────────────────────────────────────────────────────── + +def sha1(s: str) -> str: + return hashlib.sha1(s.encode("utf-8")).hexdigest() + + +_sha1_prefix: Optional[str] = None + + +def _hash_short(value: str) -> str: + """8-hex-char prefix of sha1.""" + global _sha1_prefix + s = sha1(value) + return s[:8] + + +def scrub_args(inp: Dict[str, Any]) -> Dict[str, Any]: + result: Dict[str, Any] = {} + for k, v in inp.items(): + if k in SAFE_KEYS: + result[k] = v + elif isinstance(v, str): + result[k] = _hash_short(v) if len(v) > 0 else v + elif isinstance(v, (list, dict)): + result[k] = _hash_short(json.dumps(v, sort_keys=True)) + else: + result[k] = v + return result + + +def extract_root_id(db: sqlite3.Connection, session_id: str) -> str: + current = session_id + visited = set() + while current and current not in visited: + visited.add(current) + row = db.execute("SELECT parent_id FROM session WHERE id = ?", (current,)).fetchone() + if row and row["parent_id"]: + current = row["parent_id"] + else: + break + return current + + +def extract_title_and_agent(db: sqlite3.Connection, session_id: str) -> tuple: + """Return (title, agent) for a session. Agent from the first user message.""" + row = db.execute("SELECT title FROM session WHERE id = ?", (session_id,)).fetchone() + title = row["title"] if row and row["title"] else "" + + agent = "" + msg_rows = db.execute( + """SELECT data FROM message + WHERE session_id = ? + ORDER BY time_created""", + (session_id,), + ).fetchall() + + for mr in msg_rows: + mdata = json.loads(mr["data"]) + if mdata.get("role") == "user": + agent = mdata.get("agent", "") + break + + return title, agent + + +# ── file_lines (MAP {path: line_count or null}) ────────────────────────────── + +def extract_file_lines(db: sqlite3.Connection, session_id: str) -> Dict[str, Optional[int]]: + """Collect all file paths targeted by read/bash calls and resolve line counts.""" + rows = db.execute( + """SELECT p.data FROM part p + WHERE p.session_id = ? AND json_extract(p.data, '$.type') = 'tool' + ORDER BY p.time_created""", + (session_id,), + ).fetchall() + + paths: Dict[str, int] = {} # path -> order seen (to preserve insertion order) + order = 0 + + for r in rows: + data = json.loads(r["data"]) + tool = data.get("tool", "") + state = data.get("state", {}) + if not isinstance(state, dict): + continue + inp = state.get("input", {}) + if not isinstance(inp, dict): + continue + + fpath = None + if tool in ("read", "edit", "write"): + fpath = inp.get("filePath") + elif tool in ("bash", "interactive_bash"): + cmd = inp.get("command") or inp.get("tmux_command") or "" + for m in _PATH_RE.findall(cmd): + fpath = m + break + + if fpath and isinstance(fpath, str) and fpath not in paths: + paths[fpath] = order + order += 1 + + result: Dict[str, Optional[int]] = {} + for p in sorted(paths, key=paths.get): + result[p] = count_lines(p) + + return result + + +# ── calls (landed is BOOLEAN per prototype) ───────────────────────────────── + +def extract_calls(db: sqlite3.Connection, session_id: str) -> List[Dict[str, Any]]: + rows = db.execute( + """SELECT p.data FROM part p + WHERE p.session_id = ? AND json_extract(p.data, '$.type') = 'tool' + ORDER BY p.time_created""", + (session_id,), + ).fetchall() + + calls: List[Dict[str, Any]] = [] + + for r in rows: + data = json.loads(r["data"]) + tool = data.get("tool", "") + state = data.get("state", {}) + if not isinstance(state, dict): + continue + + inp = state.get("input", {}) + if not isinstance(inp, dict): + inp = {} + + md = state.get("metadata", {}) + if not isinstance(md, dict): + md = {} + + ts_raw = state.get("time", {}) + ts = ts_raw.get("start", 0) if isinstance(ts_raw, dict) else 0 + + # prototype:44-45 + landed = ( + tool in ("edit", "write", "patch") and bool(md.get("diff")) + ) or ( + tool == "bash" + and "git commit" in (inp.get("command") or "") + and md.get("exit") == 0 + ) + + calls.append({ + "t": ts, + "tool": tool, + "args": scrub_args(inp), + "landed": landed, + }) + + return calls + + +# ── assembly ──────────────────────────────────────────────────────────────── + +def build_fixture() -> List[Dict[str, Any]]: + if not DB_PATH.exists(): + print(f"Error: DB not found at {DB_PATH}", file=sys.stderr) + sys.exit(1) + + db = sqlite3.connect(str(DB_PATH)) + db.row_factory = sqlite3.Row + + fixture: List[Dict[str, Any]] = [] + errors: List[str] = [] + + for session_id, label in LABELS.items(): + row = db.execute("SELECT id, parent_id FROM session WHERE id = ?", (session_id,)).fetchone() + if not row: + errors.append(f"Session not found in DB: {session_id}") + continue + + title, agent = extract_title_and_agent(db, session_id) + root_id = extract_root_id(db, session_id) + file_lines = extract_file_lines(db, session_id) + calls = extract_calls(db, session_id) + + fixture.append({ + "session_id": session_id, + "label": label, + "root_id": root_id, + "title": title, + "agent": agent, + "file_lines": file_lines, + "calls": calls, + }) + + db.close() + + if errors: + for err in errors: + print(err, file=sys.stderr) + if not fixture: + sys.exit(1) + + return fixture + + +def main(): + fixture = build_fixture() + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(OUTPUT_PATH, "w") as f: + json.dump(fixture, f, indent=None, separators=(",", ":"), ensure_ascii=False) + + total_calls = sum(len(e["calls"]) for e in fixture) + total_fl = sum(len(e["file_lines"]) for e in fixture) + size_kb = OUTPUT_PATH.stat().st_size / 1024 + mf = sum(1 for e in fixture if e["label"] == "must_flag") + mn = sum(1 for e in fixture if e["label"] == "must_not_flag") + + print(f"Exported {len(fixture)} sessions ({mf} must_flag, {mn} must_not_flag)") + print(f" Total tool calls: {total_calls}") + print(f" Total file_lines entries: {total_fl}") + print(f" File size: {size_kb:.1f} KB") + print(f" Output: {OUTPUT_PATH}") + + print(f"\n{'session':36s} {'label':15s} {'title':60s} {'agent':15s} {'calls':>6s} {'landed':>7s} fl_entries") + print("-" * 150) + for e in fixture: + sid = e["session_id"][:35] + landed_true = sum(1 for c in e["calls"] if c["landed"]) + nonnull_fl = sum(1 for v in e["file_lines"].values() if v is not None) + print(f"{sid:36s} {e['label']:15s} {e['title'][:60]:60s} {e['agent']:15s} {len(e['calls']):6d} {landed_true:7d} {len(e['file_lines']):3d} ({nonnull_fl} with count)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/fixtures/progress/fixture.json b/tests/fixtures/progress/fixture.json new file mode 100644 index 0000000..160f6ce --- /dev/null +++ b/tests/fixtures/progress/fixture.json @@ -0,0 +1 @@ +[{"session_id":"ses_f24cc0258ffepbJPoOzoiLZi9s","label":"must_flag","root_id":"ses_f2506ac70ffebLUxBsq0aauOn9","title":"Item 2 G1 nav refactor fresh (@Sisyphus-Junior subagent)","agent":"Sisyphus-Junior","file_lines":{"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/navbar.js":305,"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_nav_is_complete.py":100,"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_frontend.py":709,"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_proficiency.py":307,"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/controls.html":1444,"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/index.html":1350,"/home/alee/Sources/6krrt-cockpit-quick-wins":null,"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/quota.html":1055,"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/models.html":842,"/home/alee/Sources/6krrt-cockpit-quick-wins/.omc":null,"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/profiles.html":1389,"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/proficiency.html":639,"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/decisions.html":733,"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/providers.html":1230,"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_js_units.py":460,"/home/alee/Sources/6krrt-cockpit-quick-wins/.git":null,"/home/alee/Sources/6krrt-cockpit-quick-wins/plans/code-analysis-refactoring-opportunities.md":191,"/home/alee/Sources/6krrt-cockpit-quick-wins/plans/cockpit-quick-wins.md":null,"/home/alee/Sources/6krrt-cockpit-quick-wins/.omo/notepads/cockpit-quick-wins/learnings.md":null,"/home/alee/Sources/6krrt-cockpit-quick-wins/pyproject.toml":7},"calls":[{"t":1790384022323,"tool":"interactive_bash","args":{"tmux_command":"new-session -d -s work 'cd /home/alee/Sources/6krrt-cockpit-quick-wins && exec bash' && sleep 1"},"landed":false},{"t":1790384023409,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/navbar.js"},"landed":false},{"t":1790384044173,"tool":"bash","args":{"command":"cd /home/alee/Sources/6krrt-cockpit-quick-wins && git status --short && git log --oneline -3 && ls admin/frontend/"},"landed":false},{"t":1790384052275,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/navbar.js"},"landed":false},{"t":1790384058666,"tool":"bash","args":{"command":"grep -n 'navbar-nav' /home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/*.html | head -40"},"landed":false},{"t":1790384059753,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_nav_is_complete.py"},"landed":false},{"t":1790384067147,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/navbar.js"},"landed":false},{"t":1790384068798,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_frontend.py","offset":205,"limit":30},"landed":false},{"t":1790384070030,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_proficiency.py","offset":255,"limit":30},"landed":false},{"t":1790384075784,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_nav_is_complete.py"},"landed":false},{"t":1790384077405,"tool":"grep","args":{"pattern":"navbar-nav","path":"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend","output_mode":"content"},"landed":false},{"t":1790384084591,"tool":"bash","args":{"command":"cd /home/alee/Sources/6krrt-cockpit-quick-wins && sed -n '195,215p' admin/frontend/models.html && echo ---- && sed -n '340,375p' admin/frontend/index.html && echo ---- && cat admin/frontend/navbar.js"},"landed":false},{"t":1790384086942,"tool":"bash","args":{"command":"cd /home/alee/Sources/6krrt-cockpit-quick-wins && sed -n '200,240p' tests/test_admin_frontend.py && echo ---- && sed -n '255,285p' tests/test_admin_proficiency.py && git log --oneline -3 && git status --short"},"landed":false},{"t":1790384096972,"tool":"bash","args":{"command":"cd /home/alee/Sources/6krrt-cockpit-quick-wins && wc -l admin/frontend/navbar.js && cat admin/frontend/navbar.js"},"landed":false},{"t":1790384110543,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/navbar.js"},"landed":false},{"t":1790384119163,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/admin/frontend/navbar.js","offset":200,"limit":60},"landed":false},{"t":1790384127442,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_nav_is_complete.py"},"landed":false},{"t":1790384129179,"tool":"read","args":{"filePath":"/home/alee/Sources/6krrt-cockpit-quick-wins/tests/test_admin_proficiency.py","limit":30,"offset":255},"landed":false},{"t":1790384131398,"tool":"bash","args":{"command":"grep -n -A 2 '