Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*.db
*.db-wal
*.db-shm
# db restore keeps the replaced store as <name>.db.pre-restore-<stamp>.
*.db.pre-restore-*
backups/
# The retired YAML config, if an old copy is dropped here before import-yaml.
FlatlineRoundtable.yaml
Expand All @@ -15,4 +17,6 @@ transcripts/
*.bak.*
*~
.DS_Store
# Playwright MCP session dumps (page snapshots, console logs, downloads).
.playwright-mcp/
__pycache__/
7 changes: 5 additions & 2 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<non-int>` 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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
80 changes: 76 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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 <gpg-key-id>`, 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 <gpg-key-id>, 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 <any entry>` 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
Expand All @@ -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"
Expand Down
54 changes: 46 additions & 8 deletions roundtable
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading