From a90a4dae21030d58a9dad7f21faaa74bd532d9e8 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 6 Oct 2026 22:24:33 -0500 Subject: [PATCH 1/3] fix(revise): latest:N counts lanes, not files (#75); add --version, v1.1.0 latest:N took the last N transcript files, which matched the documented "N = that run's lane count" only for --each (one file per lane). A --panel round is one file holding every lane, so latest:12 refused it, and a lane retried in its own file was counted twice. latest:N now walks newest-first and takes transcripts until they cover N distinct lanes, skipping synthesis files. The too-few error names lanes and points at latest-run. Adds __version__ (1.1.0) and `roundtable --version`. Closes #75 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015eWZfxrGjWj2QpwdgFwBjR --- BACKLOG.md | 4 +-- README.md | 6 ++++- roundtable | 54 ++++++++++++++++++++++++++++++++++------ skill/SKILL.md | 3 ++- tests/test_roundtable.py | 54 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 105 insertions(+), 16 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 42dd362..295d3f1 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -66,9 +66,9 @@ The UI lives in its own private repo, - [x] `--diff` is silently dropped under `--each` — the flag isn't forwarded to children, so synthesis no-ops with exit 0 on the recommended invocation ([#74](https://github.com/CryptoJones/FlatlineRoundtable/issues/74)) — fixed on fix/open-issues-batch -- [ ] `--revise latest:N` counts files, not lanes — wrong for a `--panel` round, +- [x] `--revise latest:N` counts files, not lanes — wrong for a `--panel` round, and misleading after a clobber; docs say "lane count" - ([#75](https://github.com/CryptoJones/FlatlineRoundtable/issues/75)) + ([#75](https://github.com/CryptoJones/FlatlineRoundtable/issues/75)) — fixed on fix/revise-latest-n-counts-lanes (v1.1.0) - [x] `--revise latest:` throws a raw `ValueError` traceback instead of the clean, actionable error every other bad input gives ([#76](https://github.com/CryptoJones/FlatlineRoundtable/issues/76)) — fixed on fix/open-issues-batch diff --git a/README.md b/README.md index 78484c1..61ddcd9 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,7 @@ roundtable --lanes Skeptic,Chair "..." # a subset roundtable --list # roster + route; no network calls roundtable --json # structured output roundtable --list --json # roster as JSON, for tools; no secret refs +roundtable --version # semantic version of this roundtable roundtable --each --run-id ID "..." # tag every transcript of this run with ID roundtable --synthesize latest:12 # --diff readers over a finished run; no lane re-asked roundtable --each --revise latest-run # round 2 over the newest run, found by run_id @@ -327,7 +328,10 @@ roundtable --lanes Chair --revise ~/.local/share/.../20260901-*.json ``` `--each` writes one transcript per lane, which is why `--revise` takes -`latest:N` and comma-separated paths and merges them; it refuses to mix +`latest:N` and comma-separated paths and merges them. N counts lanes, not +files: `latest:12` is the newest transcripts that together cover 12 distinct +lanes, so it names a 12-lane `--each` run (12 files) and a 12-lane `--panel` +round (one file) alike, and a retried lane counts once. It refuses to mix transcripts whose briefs differ, because that is only ever an accident. The report ends with who held and who moved, under a banner that says the thing that matters: diff --git a/roundtable b/roundtable index 01b76cd..0ff034c 100755 --- a/roundtable +++ b/roundtable @@ -22,6 +22,7 @@ Usage: roundtable --lanes Auditor,Builder "the brief" roundtable --list roundtable --list --json roster as JSON for tools (no secret refs) + roundtable --version roundtable --json > answers.json roundtable --each --run-id ID "..." tag every transcript of this run with ID roundtable --each --revise latest-run round 2 over the newest run, found by @@ -93,6 +94,10 @@ import urllib.request import uuid from pathlib import Path +# Semantic version. Bump it in the PR that changes behaviour: patch for a fix, +# minor for a new flag or mode, major for a change that breaks a caller. +__version__ = "1.1.0" + TRANSCRIPT_DIR = Path.home() / ".local" / "share" / "flatline-roundtable" / "transcripts" RUN_ID_RE = re.compile(r"[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}") @@ -3028,8 +3033,8 @@ def transcript_time(p: Path) -> float: def latest_run_paths(flag: str) -> list[Path]: """`latest-run`: every transcript of the most recent round (#80). - `latest:N` counts files, so it is right only when N happens to equal the - run's lane count, and it reaches past the run boundary otherwise -- the + `latest:N` counts lanes (#75), so it is right only when N happens to equal + the run's lane count, and it reaches past the run boundary otherwise -- the brief and round guards in load_transcripts exist to catch exactly that. A round now has an identity (run_id, #116), so this resolves it directly: the newest lane transcript names the run, and every transcript carrying the @@ -3072,6 +3077,41 @@ def latest_run_paths(flag: str) -> list[Path]: return found +def latest_lane_paths(flag: str, spec: str, n: int) -> list[Path]: + """`latest:N`: the newest transcripts that together cover N distinct lanes (#75). + + N used to count files. That matched the docs ("N = the run's lane count") + only for `--each`, which writes one file per lane: a `--panel` round is ONE + file holding every lane, so `latest:12` refused a 12-lane panel round, and + a lane retried in its own file was counted twice. Counting lanes makes N + mean the same thing for both modes. Synthesis transcripts (#118) carry no + lanes and are skipped; unreadable files are left for an explicit path to + report. + """ + files = sorted(TRANSCRIPT_DIR.glob("*.json"), key=lambda p: p.name, reverse=True) + picked: list[Path] = [] + lanes: set[str] = set() + for p in files: + try: + d = json.loads(p.read_text()) + except (OSError, ValueError): + continue + if not isinstance(d, dict) or not isinstance(d.get("results"), list) \ + or d.get("mode") == "synthesis": + continue + got = {r["lane"] for r in d["results"] if isinstance(r, dict) and r.get("lane")} + if not got: + continue + picked.append(p) + lanes |= got + if len(lanes) >= n: + return picked + die(f"{flag} {spec}: N counts lanes, and the transcripts in {TRANSCRIPT_DIR} cover " + f"only {len(lanes)} distinct lane(s) across {len(picked)} file(s).\n" + " A --panel round is one file holding every lane; an --each round is one file " + "per lane.\n Use latest-run for the newest whole run, or name the files.") + + def load_transcripts(arg: str, flag: str = "--revise") -> tuple[list[Path], dict]: """Resolve --revise's (or --synthesize's) argument into one merged prior round. @@ -3080,7 +3120,8 @@ def load_transcripts(arg: str, flag: str = "--revise") -> tuple[list[Path], dict multi-file forms exist because `--each` — the recommended way to run a panel — writes ONE TRANSCRIPT PER LANE by design, so "the previous round" of a 12-lane run is 12 files, and `latest:12` is how you name them without - listing them. + listing them. N counts lanes, not files (#75), so `latest:12` also names a + 12-lane `--panel` round, which is one file. Briefs must match across the files: mixing transcripts of different questions is only ever an accident (a `latest:N` that reached past the run @@ -3102,11 +3143,7 @@ def load_transcripts(arg: str, flag: str = "--revise") -> tuple[list[Path], dict n_str = spec.split(":", 1)[1] if ":" in spec else "1" if not n_str.isdecimal() or int(n_str) < 1: die(f"{flag} latest:N needs a positive integer (e.g. latest:12)") - n = int(n_str) - cands = sorted(TRANSCRIPT_DIR.glob("*.json")) - if len(cands) < n: - die(f"{flag} {spec}: only {len(cands)} transcript(s) in {TRANSCRIPT_DIR}") - paths.extend(cands[-n:]) + paths.extend(latest_lane_paths(flag, spec, int(n_str))) else: paths.append(Path(spec).expanduser()) # Dedupe, then chronological order: transcript names begin with a timestamp @@ -3538,6 +3575,7 @@ def main() -> int: help="with --each, run up to N lanes at once (default 1). " "Per-lane and per-vendor caps still apply.") ap.add_argument("--panel", action="store_true") + ap.add_argument("--version", action="version", version=f"roundtable {__version__}") ap.add_argument("--list", action="store_true") ap.add_argument("--json", action="store_true") ap.add_argument("--max-spend", type=float) diff --git a/skill/SKILL.md b/skill/SKILL.md index 071aafe..f8f40a5 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -201,7 +201,8 @@ number that does not exist. Verify any concrete claim before repeating it. ## The optional second round — `--revise` -`roundtable --each --revise latest:N` (N = the prior run's lane count) reruns +`roundtable --each --revise latest:N` (N = the prior run's lane count; it +counts lanes, not files, so it works for `--panel` rounds too) reruns the panel with every lane shown the locked round-1 answers, its own marked `YOURS`, peers anonymised as PANELIST letters. Lanes open with `HOLD` or `REVISE`; the report tallies who moved. diff --git a/tests/test_roundtable.py b/tests/test_roundtable.py index 24dbe02..e95516a 100644 --- a/tests/test_roundtable.py +++ b/tests/test_roundtable.py @@ -2359,6 +2359,13 @@ def _run(self, lanes, *args, extra_cfg=None): capture_output=True, text=True, timeout=90, env=env, ) + def test_version_prints_semver_without_touching_the_store(self): + r = subprocess.run([sys.executable, str(ROOT / "roundtable"), "--version"], + capture_output=True, text=True, timeout=30) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertRegex(r.stdout.strip(), r"^roundtable \d+\.\d+\.\d+$") + self.assertEqual(r.stdout.strip(), f"roundtable {rt.__version__}") + def test_a_yaml_config_is_refused_with_the_import_yaml_pointer(self): d = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, d, True) @@ -2596,6 +2603,45 @@ def test_latest_n_merges_per_lane_transcripts(self): self.assertEqual(len(paths), 2) self.assertEqual({r["lane"] for r in merged["results"]}, {"A", "B"}) + def test_latest_n_counts_lanes_in_a_panel_transcript(self): + """#75: a --panel round is one file holding every lane. latest:3 used to + refuse it with "only 1 transcript(s)" though all three lanes were there.""" + self._transcript("20260901-000001.json", "older", [self._r("Z", "z")]) + self._transcript("20260901-000002.json", "q", + [self._r("A", "a"), self._r("B", "b"), self._r("C", "c")]) + paths, merged = rt.load_transcripts("latest:3") + self.assertEqual([p.name for p in paths], ["20260901-000002.json"]) + self.assertEqual({r["lane"] for r in merged["results"]}, {"A", "B", "C"}) + + def test_latest_n_counts_a_retried_lane_once(self): + """A lane retried in its own file is one lane, not two: latest:2 reaches + back to the second lane instead of stopping at the retry.""" + self._transcript("20260901-000001.json", "q", [self._r("B", "b")]) + self._transcript("20260901-000002.json", "q", + [{"lane": "A", "answer": None, "error": "timeout"}]) + self._transcript("20260901-000003.json", "q", [self._r("A", "a")]) + paths, merged = rt.load_transcripts("latest:2") + self.assertEqual(len(paths), 3) + self.assertEqual({r["lane"]: r["answer"] for r in merged["results"]}, + {"A": "a", "B": "b"}) + + def test_latest_n_skips_synthesis_transcripts(self): + self._transcript("20260901-000001.json", "q", [self._r("A", "a")]) + self._transcript("20260901-000002.json", "q", [self._r("B", "b")]) + self._transcript("20260901-000003-synthesis-1.json", "q", [], mode="synthesis") + paths, _ = rt.load_transcripts("latest:2") + self.assertEqual([p.name for p in paths], + ["20260901-000001.json", "20260901-000002.json"]) + + def test_latest_n_too_few_lanes_names_lanes_not_files(self): + self._transcript("20260901-000001.json", "q", + [self._r("A", "a"), self._r("B", "b")]) + with self.assertRaises(SystemExit) as e: + rt.load_transcripts("latest:3") + msg = str(e.exception.code) + self.assertIn("cover only 2 distinct lane(s) across 1 file(s)", msg) + self.assertIn("latest-run", msg) + def test_mixed_briefs_are_refused(self): """A latest:N that reaches past the run boundary must fail loudly, not hand every lane a packet half about the wrong question.""" @@ -2605,10 +2651,10 @@ def test_mixed_briefs_are_refused(self): rt.load_transcripts("latest:2") def test_an_answer_never_loses_to_a_later_failure(self): - self._transcript("20260901-000001.json", "q", [self._r("A", "kept")]) - self._transcript("20260901-000002.json", "q", - [{"lane": "A", "answer": None, "error": "timeout"}]) - _, merged = rt.load_transcripts("latest:2") + a = self._transcript("20260901-000001.json", "q", [self._r("A", "kept")]) + b = self._transcript("20260901-000002.json", "q", + [{"lane": "A", "answer": None, "error": "timeout"}]) + _, merged = rt.load_transcripts(f"{a},{b}") self.assertEqual(merged["results"][0]["answer"], "kept") def test_mixed_rounds_are_refused(self): From 25eda032079206ac7c7e56b9f93a3d7462480c55 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 6 Oct 2026 22:32:50 -0500 Subject: [PATCH 2/3] gitignore: db restore's pre-restore copies and .playwright-mcp/ session dumps Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015eWZfxrGjWj2QpwdgFwBjR --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index ee99840..c29a649 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ *.db *.db-wal *.db-shm +# db restore keeps the replaced store as .db.pre-restore-. +*.db.pre-restore-* backups/ # The retired YAML config, if an old copy is dropped here before import-yaml. FlatlineRoundtable.yaml @@ -15,4 +17,6 @@ transcripts/ *.bak.* *~ .DS_Store +# Playwright MCP session dumps (page snapshots, console logs, downloads). +.playwright-mcp/ __pycache__/ From 926b1b6b285467648e5dd2cf099c2b94300512bc Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 6 Oct 2026 22:35:31 -0500 Subject: [PATCH 3/3] install.sh --init: create the store when none exists, with verbose errors Opt-in, so a plain install never blocks on a pinentry prompt. Never touches an existing store. Before db init it fails early, naming the fix, when there is no pass and no ROUNDTABLE_DB_KEY_FILE, or when pass is not initialised. If db init itself fails it prints roundtable's own error, then the command, exit code, Python, store path, key source and the common causes. CI covers create, leave-alone and the failure message in a throwaway HOME. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015eWZfxrGjWj2QpwdgFwBjR --- .github/workflows/ci.yml | 20 ++++++++++ BACKLOG.md | 3 ++ README.md | 4 +- install.sh | 80 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d931070..dffffc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,26 @@ jobs: ALL_PROXY=http://127.0.0.1:9 \ ./roundtable --list + # --init in a throwaway HOME: creates a store when there is none, leaves an + # existing one byte-identical, and fails loudly (non-zero, with the cause) + # when it cannot create one. + - name: 'install.sh --init: create, leave alone, explain failure' + run: | + export HOME="$RUNNER_TEMP/home" XDG_DATA_HOME="$RUNNER_TEMP/init-data" + export ROUNDTABLE_DB_KEY_FILE="$RUNNER_TEMP/init.key" + ./install.sh --init + ./roundtable db doctor + before=$(sha256sum "$XDG_DATA_HOME/flatline-roundtable/roundtable.db") + ./install.sh --init | grep -q "left untouched" + test "$before" = "$(sha256sum "$XDG_DATA_HOME/flatline-roundtable/roundtable.db")" + mkdir -p "$RUNNER_TEMP/ro" && chmod 500 "$RUNNER_TEMP/ro" + if XDG_DATA_HOME="$RUNNER_TEMP/ro" ROUNDTABLE_DB_KEY_FILE="$RUNNER_TEMP/ro.key" \ + ./install.sh --init 2> init-err.txt; then + echo "--init succeeded on an unwritable store dir"; exit 1 + fi + grep -q "roundtable db init failed (exit" init-err.txt + grep -q "Store path" init-err.txt + - name: 'A YAML --config is refused with the import-yaml pointer' run: | # `! cmd` never fails a step under bash -e, so test the status explicitly. diff --git a/BACKLOG.md b/BACKLOG.md index 295d3f1..a105fb0 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -183,6 +183,9 @@ stub. `ps auxww` during `db init` shows no key (it goes to `pass insert` on stdin). - [ ] `./install.sh` then `./install.sh --uninstall` round-trips, and refuses to delete anything that is not a symlink. *(human — touches `$HOME`, no test)* +- [ ] `./install.sh --init` with no store creates one (`db doctor` passes), and + with a store present leaves it byte-identical. *(CI covers both in a + throwaway HOME)* - [ ] **Restore drill:** `restic restore latest` from telesto → `db restore` into a scratch `--config` → `db doctor` → `secrets check` → `--list`, and the scratch store's `db export` equals the live one. *(human — needs telesto and diff --git a/README.md b/README.md index 61ddcd9..8d5c057 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ $ ./roundtable db import examples/roster.example.json # or lanes add, or import- $ ./roundtable --list # shows the roster; makes no network calls ``` -Optionally `./install.sh` to expose it as a Claude Code skill. +Optionally `./install.sh` to expose it as a Claude Code skill and put +`roundtable` on PATH. `./install.sh --init` also runs `roundtable db init` when +there is no store yet; it never touches an existing one. ## Secrets diff --git a/install.sh b/install.sh index 0251bd7..0d5c22e 100755 --- a/install.sh +++ b/install.sh @@ -2,6 +2,11 @@ # Expose FlatlineRoundtable as a Claude Code skill, and put `roundtable` on PATH. # # Symlinks rather than copies, so a `git pull` updates the installed skill. +# +# ./install.sh install; print the steps if there is no store yet +# ./install.sh --init install, and run `roundtable db init` if there is +# no store (may prompt for your gpg passphrase) +# ./install.sh --uninstall remove the links; the store is left alone set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" @@ -34,8 +39,16 @@ uninstall() { exit 0 } -[[ "${1:-}" == "--uninstall" ]] && uninstall -[[ -n "${1:-}" ]] && die "Unknown argument: ${1}. Usage: ./install.sh [--uninstall]" +# --init is opt-in: `db init` may create or unlock the DB key in pass, which can +# mean a pinentry prompt, and a plain install must never block on one. +[[ $# -le 1 ]] || die "One argument at most. Usage: ./install.sh [--init | --uninstall]" +INIT=0 +case "${1:-}" in + "") ;; + --uninstall) uninstall ;; + --init) INIT=1 ;; + *) die "Unknown argument: ${1}. Usage: ./install.sh [--init | --uninstall]" ;; +esac [[ -f "${SKILL_SRC}/SKILL.md" ]] || die "Missing ${SKILL_SRC}/SKILL.md — repo is incomplete." [[ -x "${SCRIPT_DIR}/roundtable" ]] || die "Missing or non-executable ${SCRIPT_DIR}/roundtable." @@ -97,6 +110,58 @@ check_pass() { } check_pass +# --init: run `roundtable db init`, and on failure say exactly what was tried and +# what usually causes it. roundtable's own error is printed above ours as-is. +init_store() { + local key_src cmd rc + if [[ -n "${ROUNDTABLE_DB_KEY_FILE:-}" ]]; then + key_src="key file \$ROUNDTABLE_DB_KEY_FILE=${ROUNDTABLE_DB_KEY_FILE}" + else + key_src="pass entry (created, or reused if it already exists)" + # Fail before db init rather than inside it: without pass there is + # nowhere to keep the DB key, and the store would be unreadable. + if ! command -v pass >/dev/null 2>&1; then + printf 'ERROR: ./install.sh --init cannot create the store.\n' >&2 + printf ' Store path : %s\n' "${STORE}" >&2 + printf ' Problem : `pass` is not installed, and ROUNDTABLE_DB_KEY_FILE is not set,\n' >&2 + printf ' so there is nowhere to keep the DB key that encrypts lane secrets.\n' >&2 + printf ' Fix : install pass and gnupg, run `pass init `, then rerun\n' >&2 + printf ' ./install.sh --init (or set ROUNDTABLE_DB_KEY_FILE for a file key).\n' >&2 + exit 1 + fi + if ! pass ls >/dev/null 2>&1; then + printf 'ERROR: ./install.sh --init cannot create the store.\n' >&2 + printf ' Store path : %s\n' "${STORE}" >&2 + printf ' Problem : `pass` is installed but `pass ls` failed — its store is not\n' >&2 + printf ' initialised, or gpg cannot reach it.\n' >&2 + printf ' Fix : pass init , then rerun ./install.sh --init\n' >&2 + exit 1 + fi + fi + cmd=("${PYTHON}" "${SCRIPT_DIR}/roundtable" db init) + log "No store yet — creating it." + log " Store path : ${STORE}" + log " DB key : ${key_src}" + log " Running : ${cmd[*]}" + rc=0 + "${cmd[@]}" || rc=$? + if (( rc != 0 )); then + printf '\nERROR: roundtable db init failed (exit %s). Its own message is above.\n' "${rc}" >&2 + printf ' Command : %s\n' "${cmd[*]}" >&2 + printf ' Python : %s (%s)\n' "$(command -v "${PYTHON}")" "$("${PYTHON}" -V 2>&1)" >&2 + printf ' Store path : %s\n' "${STORE}" >&2 + printf ' DB key : %s\n' "${key_src}" >&2 + printf ' Common causes:\n' >&2 + printf ' - gpg-agent is locked or pinentry could not prompt (no tty): run\n' >&2 + printf ' `pass show ` once in this terminal to unlock it, then retry.\n' >&2 + printf ' - the store directory is not writable: check %s\n' "$(dirname -- "${STORE}")" >&2 + printf ' - a half-created store was left behind: inspect it with `roundtable db doctor`\n' >&2 + printf ' before deleting anything; never delete the pass entry holding the DB key.\n' >&2 + printf ' Rerun ./install.sh --init once fixed; it never touches a store that exists.\n' >&2 + exit "${rc}" + fi +} + mkdir -p "${HOME}/.claude/skills" "${BIN_DIR}" ln -sfn "${SKILL_SRC}" "${TARGET}"; log "Skill -> ${TARGET}" if [[ "${PYTHON}" == python3 ]]; then @@ -114,8 +179,15 @@ if [[ -f "${STORE}" ]]; then log "Store found at ${STORE} — left untouched." else log "" - log "No store yet. Create it, then add lanes:" - log " roundtable db init" + if (( INIT )); then + # Only reached with no store; db init also refuses an existing one. + init_store + log "" + log "Store created. It holds no lanes yet; add them:" + else + log "No store yet. Create it (or rerun with ./install.sh --init), then add lanes:" + log " roundtable db init" + fi if [[ -f "${OLD_YAML}" ]]; then log " roundtable import-yaml ${OLD_YAML} --dry-run # needs PyYAML, once" log " roundtable import-yaml ${OLD_YAML} --pull-secrets"