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/.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__/ diff --git a/BACKLOG.md b/BACKLOG.md index 42dd362..a105fb0 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 @@ -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 78484c1..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 @@ -247,6 +249,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 +330,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/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" 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):