diff --git a/AGENTS.md b/AGENTS.md index 4991118..009d606 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning + inbox-cards.md optional captain-private plain-English copy for the inbox board; see docs/inbox-board.md learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store projects.md thin fleet navigation registry; firstmate-private, parsed by fm-project-mode.sh (section 6) secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) @@ -101,6 +102,7 @@ state/ volatile runtime signals; gitignored x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) + inbox-answers/ generated captain-inbox Lavish feedback drops; inspect after the registered inbox check wakes firstmate (docs/inbox-board.md) x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers diff --git a/README.md b/README.md index a7f69e3..6d402df 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/architecture.md](docs/architecture.md) - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional X mode, the files you set, and harness support. - [docs/calm.md](docs/calm.md) - current Pi `/calm` behavior and supported presentation limits. +- [docs/inbox-board.md](docs/inbox-board.md) - the captain decision-and-review board, its answer relay, and current verification pointers. - [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for an away-mode escalation delivery that gets stuck. - [docs/tmux-backend.md](docs/tmux-backend.md) - current setup and limits for the tmux reference backend. - [docs/herdr-backend.md](docs/herdr-backend.md) - current setup, safety boundaries, and limits for the experimental Herdr backend. diff --git a/bin/fm-inbox-arm.sh b/bin/fm-inbox-arm.sh new file mode 100755 index 0000000..8138e88 --- /dev/null +++ b/bin/fm-inbox-arm.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# fm-inbox-arm.sh - write and register the captain-inbox answer relay. +# +# Writes a bounded watcher check at state/inbox.check.sh that polls the served +# board and wakes firstmate whenever the captain submits an answer, then binds +# its exact bytes with bin/fm-check-register.sh so the watcher will execute it. +# This is the fix for answers that looked silently eaten: once armed, the normal +# supervision cycle relays them without anyone remembering to poll. +# +# The generated check fails fast and stays silent when the board is not being +# served, so an armed-but-idle relay never floods. bin/fm-inbox-serve.sh calls +# this; run it directly only to (re)arm against an existing board. +# +# This writes firstmate's own private runtime state. Run it from the operating +# firstmate home. +# +# Usage: +# fm-inbox-arm.sh [--port ] +# fm-inbox-arm.sh --help +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +PORT=${LAVISH_AXI_PORT:-4387} + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +die() { + printf 'fm-inbox-arm: %s\n' "$1" >&2 + exit "${2:-1}" +} + +port_valid() { + local port=$1 + case "$port" in + ''|*[!0-9]*) return 1 ;; + esac + [ "$port" -ge 1 ] 2>/dev/null && [ "$port" -le 65535 ] 2>/dev/null +} + +BOARD= +while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --port) + [ "$#" -ge 2 ] || die "--port requires a port" 2 + PORT=$2; shift 2 ;; + --) shift; break ;; + -*) usage >&2; exit 2 ;; + *) + [ -z "$BOARD" ] || die "expected exactly one board path" 2 + BOARD=$1; shift ;; + esac +done +if [ "$#" -gt 0 ]; then + [ -z "$BOARD" ] || die "expected exactly one board path" 2 + [ "$#" -eq 1 ] || die "expected exactly one board path" 2 + BOARD=$1 +fi +[ -n "$BOARD" ] || { usage >&2; exit 2; } +port_valid "$PORT" || die "invalid port: $PORT" 2 +case "$BOARD" in + /*) ;; + *) BOARD="$PWD/$BOARD" ;; +esac + +[ -d "$STATE" ] && [ ! -L "$STATE" ] || die "state directory is unavailable: $STATE" +[ -x "$SCRIPT_DIR/fm-check-register.sh" ] || die "fm-check-register.sh is unavailable" +[ -r "$SCRIPT_DIR/fm-pr-lib.sh" ] || die "fm-pr-lib.sh is unavailable" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" + +ANSWERS="$STATE/inbox-answers" +CHECK="$STATE/inbox.check.sh" +STATE_DEVICE=$(fm_pr_file_device "$STATE") || die "state directory is unavailable: $STATE" +fm_pr_regular_destination_on_device_or_absent "$CHECK" "$STATE_DEVICE" \ + || die "relay path is unsafe: $CHECK" + +printf -v BOARD_Q '%q' "$BOARD" +printf -v ANSWERS_Q '%q' "$ANSWERS" +printf -v PORT_Q '%q' "$PORT" +printf -v SCRIPT_DIR_Q '%q' "$SCRIPT_DIR" + +umask 077 +TMP_CHECK=$(mktemp "$STATE/.inbox.check.XXXXXX") || die "cannot stage the answer relay" +trap '[ -z "${TMP_CHECK:-}" ] || rm -f -- "$TMP_CHECK"' EXIT HUP INT TERM +cat > "$TMP_CHECK" </dev/null 2>&1 || exit 0 +command -v curl >/dev/null 2>&1 || exit 0 +curl -s -o /dev/null --max-time 2 "http://127.0.0.1:\$LAVISH_AXI_PORT/" \ + >/dev/null 2>&1 || exit 0 +res=\$(lavish-axi poll "\$BOARD" --timeout-ms 5000 2>/dev/null) || exit 0 +case "\$res" in *"status: feedback"*) ;; *) exit 0 ;; esac +FM_X_LIB=\$SCRIPT_DIR/fm-x-lib.sh +[ -r "\$FM_X_LIB" ] || { printf 'inbox board error: could not record captain answer in state/inbox-answers\n'; exit 0; } +. "\$FM_X_LIB" || { printf 'inbox board error: could not record captain answer in state/inbox-answers\n'; exit 0; } +stamp=\$(date -u +%Y%m%dT%H%M%SZ) +if ! (set -o pipefail; printf '%s\n' "\$res" | fmx_private_artifact_publish_stdin "\$ANSWERS" "\$stamp.txt" 600); then + printf 'inbox board error: could not record captain answer in state/inbox-answers\n' + exit 0 +fi +printf 'inbox board: a captain answer is waiting in state/inbox-answers\n' +SHIM +chmod 0700 "$TMP_CHECK" || die "cannot set relay permissions" +fm_pr_private_file_valid "$TMP_CHECK" 700 "$STATE_DEVICE" \ + || die "staged relay is unsafe" +fm_pr_regular_destination_on_device_or_absent "$CHECK" "$STATE_DEVICE" \ + || die "relay path is unsafe: $CHECK" +mv -f -- "$TMP_CHECK" "$CHECK" || die "cannot publish the answer relay" +TMP_CHECK= +fm_pr_private_file_valid "$CHECK" 700 "$STATE_DEVICE" \ + || { rm -f -- "$CHECK"; die "published relay is unsafe"; } + +"$SCRIPT_DIR/fm-check-register.sh" inbox >/dev/null \ + || { rm -f -- "$CHECK"; die "could not register the answer relay"; } + +printf 'armed: state/inbox.check.sh relays answers for %s\n' "$BOARD" diff --git a/bin/fm-inbox-render.py b/bin/fm-inbox-render.py new file mode 100755 index 0000000..b6efc3c --- /dev/null +++ b/bin/fm-inbox-render.py @@ -0,0 +1,1220 @@ +#!/usr/bin/env python3 +"""Render the captain's decision-and-review board from a fleet snapshot. + +Driven by bin/fm-inbox-view.sh, which owns argument handling, the snapshot and +tasks-axi reads, and the atomic write. This module only turns those inputs into +one self-contained HTML page. It writes nothing but the file named by --out. + +Three helper modes let the caller collect the extra inputs it needs before the +final render: + --decision-ids print the ids of every open captain decision, one per line + --pr-urls print every recorded pull request URL, one per line +""" + +from __future__ import annotations + +import argparse +import html +import json +import os +import re +import sys +from datetime import datetime, timezone + +SCHEMA = "fm-inbox-board.v1" +PR_STALE_STATES = {"CLOSED", "MERGED"} + +# -------------------------------------------------------------------------- +# snapshot selection +# -------------------------------------------------------------------------- + + +def structured_records(snap): + return [r for r in snap.get("backlog", {}).get("records", []) if r.get("structured")] + + +def unresolved(record): + return record.get("unresolved_blocker_ids") or [] + + +def open_captain_decisions(snap): + """Every captain-held queued item with nothing blocking it. + + Keyed on hold_kind alone. The snapshot's own captain_actionable flag also + requires kind == "captain", which only bin/fm-decision-hold.sh sets, so it + silently hides every thread gated with `tasks-axi hold --kind captain`. + Those are the captain's oldest waiting decisions. + """ + out = [] + for r in structured_records(snap): + if r.get("state") != "queued": + continue + if r.get("hold_kind") != "captain": + continue + if not r.get("hold_reason"): + continue + if unresolved(r): + continue + out.append(r) + return out + + +def recorded_prs(snap): + """Recorded pull request URLs for work that has not landed yet. + + Task metadata first, then the backlog. Work already recorded as done is + skipped: its pull request belongs to the shipped list, not to a review + queue. + """ + done = { + r.get("id") + for r in structured_records(snap) + if r.get("state") == "done" + } + seen = {} + for task in snap.get("tasks", []): + url = (task.get("pr") or {}).get("url") + if url and url not in seen and task.get("id") not in done: + seen[url] = {"url": url, "id": task.get("id"), "source": "task record"} + for r in structured_records(snap): + url = r.get("pr_url") + if url and url not in seen and r.get("state") != "done": + seen[url] = {"url": url, "id": r.get("id"), "source": "backlog"} + return list(seen.values()) + + +# -------------------------------------------------------------------------- +# tasks-axi full text +# -------------------------------------------------------------------------- + +_AXI_FIELD = re.compile(r"^ ([a-z_]+): (.*)$") + + +def parse_axi_show(text): + """Parse one `tasks-axi show --full` block into a flat dict.""" + fields = {} + for line in text.splitlines(): + m = _AXI_FIELD.match(line) + if not m: + continue + key, raw = m.group(1), m.group(2).strip() + if len(raw) >= 2 and raw[0] == '"' and raw[-1] == '"': + raw = raw[1:-1].replace('\\"', '"') + raw = raw.replace("\\n", "\n") + fields[key] = raw + return fields + + +def load_full_text(directory): + out = {} + if not directory or not os.path.isdir(directory): + return out + for name in sorted(os.listdir(directory)): + if not name.endswith(".txt"): + continue + path = os.path.join(directory, name) + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + out[name[: -len(".txt")]] = parse_axi_show(fh.read()) + except OSError: + continue + return out + + +# -------------------------------------------------------------------------- +# plain-English decision cards +# -------------------------------------------------------------------------- + +CARD_FIELDS = ("question", "plain", "why", "take", "link", "options", "expand", "flag") + + +def load_cards(path): + """Parse the optional plain-English cards file. See fm-inbox-view.sh --help.""" + cards = {} + if not path or not os.path.isfile(path): + return cards + current = None + field = None + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.rstrip("\n") + if line.startswith("## "): + current = line[3:].strip() + cards[current] = {} + field = None + continue + if current is None: + continue + if line.startswith("### "): + name = line[4:].strip().lower() + field = name if name in CARD_FIELDS else None + if field: + cards[current].setdefault(field, []) + continue + if field: + cards[current][field].append(line) + normalized = {} + for key, fields in cards.items(): + entry = {} + for name, lines in fields.items(): + if name == "options": + entry[name] = [ + ln.lstrip("-* ").strip() + for ln in lines + if ln.strip().startswith(("-", "*")) + ] + else: + entry[name] = "\n".join(lines).strip() + normalized[key] = entry + return normalized + + +# -------------------------------------------------------------------------- +# text helpers +# -------------------------------------------------------------------------- + +_URL = re.compile(r"(https?://[^\s<>\"')\]]+)") + + +def esc(value): + return html.escape(value if value is not None else "", quote=True) + + +def linkify(escaped): + return _URL.sub(r'\1', escaped) + + +def rich(text): + """Escaped paragraphs with bare URLs turned into links.""" + if not text: + return "" + blocks = [b.strip() for b in re.split(r"\n\s*\n", text) if b.strip()] + return "".join( + "

%s

" % linkify(esc(b).replace("\n", "
")) for b in blocks + ) + + +NO_PROJECT = "no project" + +# The board is captain-facing, so internal run-state and completion vocabulary is +# translated once, here, rather than leaked into the page. +LIVE_LABELS = { + "working": "in progress", + "running": "in progress", + "fixing": "in progress", + "ci": "waiting on checks", + "done": "finished, being wrapped up", + "paused": "waiting on something external", + "blocked": "stopped", + "failed": "stopped", +} + +COMPLETION_LABELS = { + "merged": "merged", + "reported": "findings filed", + "done": "done", +} + + +def project_of(record, task=None): + repo = record.get("repo") if record else None + if not repo and task: + path = task.get("project") or "" + repo = os.path.basename(path.rstrip("/")) if path else None + if not repo: + return NO_PROJECT + return repo.rstrip("/").split("/")[-1] + + +# Readable area labels for the many personal-idea and firstmate-infra items that +# carry no project yet. The captain will organize projects later; until then a +# generic "no project" badge is useless, so a small known map plus the item's +# own leading id token gives every row a readable, filterable name. +_AREA_ALIASES = { + "fm": "firstmate", + "harden": "firstmate", + "add": "firstmate", + "workflowy": "personal", + "network": "personal", + "planning": "planning", + "touchbase": "touchbase", + "content": "content", +} + + +def origin_of(record): + """The originating investigation id from a decision's body, when present.""" + for line in (record.get("body_lines") or []): + if line.lower().startswith("origin:"): + return line.split(":", 1)[1].strip() + return None + + +def area_of(record, task=None): + """Project when known, otherwise a readable area derived from origin or id.""" + project = project_of(record, task) + if project != NO_PROJECT: + return project + base = origin_of(record) or record.get("id") or "" + token = base.split("-", 1)[0].strip().lower() + if not token: + return NO_PROJECT + return _AREA_ALIASES.get(token, token) + + +def slugify(value): + return re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-") or "item" + + +# -------------------------------------------------------------------------- +# section assembly +# -------------------------------------------------------------------------- + + +def live_state(task): + if not task: + return None + return (task.get("current_state") or {}).get("state") + + +def build_model(snap, full_text, cards, pr_state, pr_verified): + tasks = {t.get("id"): t for t in snap.get("tasks", []) if t.get("id")} + records = structured_records(snap) + + decisions = open_captain_decisions(snap) + decided_ids = {r.get("id") for r in decisions} + + stuck = [] + for r in records: + rid = r.get("id") + if rid in decided_ids or r.get("state") == "done": + continue + task = tasks.get(rid) + reasons = [] + if live_state(task) in ("failed", "blocked"): + reasons.append("the worker stopped and needs help") + if (task or {}).get("hints", {}).get("blocked_event"): + reasons.append("the worker reported a blocker") + if (task or {}).get("hints", {}).get("pending_decision"): + reasons.append("the worker is waiting on an answer") + if r.get("blocked_reason"): + reasons.append(r["blocked_reason"]) + if reasons: + stuck.append((r, task, reasons)) + stuck_ids = {r.get("id") for r, _, _ in stuck} + + prs = [] + stale_prs = [] + for entry in recorded_prs(snap): + rid = entry["id"] + record = next((r for r in records if r.get("id") == rid), None) or {} + task = tasks.get(rid) + state = (pr_state.get(entry["url"]) or "").upper() + row = { + "id": rid, + "url": entry["url"], + "source": entry["source"], + "title": record.get("title") or (task or {}).get("id") or rid, + "project": area_of(record, task), + "state": state, + "verified": bool(pr_verified and state), + } + if row["verified"] and state in PR_STALE_STATES: + stale_prs.append(row) + else: + prs.append(row) + pr_ids = {row["id"] for row in prs} + + underway = [ + (r, tasks.get(r.get("id"))) + for r in records + if r.get("state") == "in_flight" and r.get("id") not in stuck_ids + ] + underway_ids = {r.get("id") for r, _ in underway} + + queued = [ + r + for r in records + if r.get("state") == "queued" + and r.get("id") not in decided_ids + and r.get("id") not in stuck_ids + and r.get("id") not in underway_ids + ] + + shipped = [r for r in records if r.get("state") == "done"] + + return { + "decisions": [ + decision_view(r, tasks.get(r.get("id")), full_text, cards) for r in decisions + ], + "prs": prs, + "stale_prs": stale_prs, + "pr_ids": pr_ids, + "stuck": stuck, + "underway": underway, + "queued": queued, + "shipped": shipped, + "tasks": tasks, + } + + +def decision_view(record, task, full_text, cards): + rid = record.get("id") + axi = full_text.get(rid, {}) + card = cards.get(rid, {}) + + hold = axi.get("hold_reason") or record.get("hold_reason") or "" + body = axi.get("body") or "\n".join(record.get("body_lines") or []) + title = axi.get("title") or record.get("title") or rid + + origin = None + for line in body.splitlines(): + if line.lower().startswith("origin:"): + origin = line.split(":", 1)[1].strip() + report = None + if origin: + report = "data/%s/report.md" % origin + + # Options are quick-picks, never a gate: the free-text answer stands alone. + options = card.get("options") or [] + + return { + "id": rid, + "question": card.get("question") or title, + "plain": card.get("plain"), + "why": card.get("why"), + "take": card.get("take"), + "link": card.get("link"), + "flag": card.get("flag"), + "expand": card.get("expand"), + "options": options, + "hold": hold, + "body": body, + "origin": origin, + "report": report, + "since": record.get("since"), + "project": area_of(record, task), + "annotated": bool(card), + } + + +# -------------------------------------------------------------------------- +# HTML +# -------------------------------------------------------------------------- + +CSS = """ +*,*::before,*::after{box-sizing:border-box} +:root{ + color-scheme:light dark; + --bg:#fbfaf8; --panel:#ffffff; --ink:#1d1f21; --muted:#5f6672; --faint:#8b929c; + --line:#e4e2dd; --line-soft:#efeee9; --accent:#8a5a2b; --accent-soft:#f5efe6; + --flagbg:#fdf6e6; --flagline:#e3d3ac; --ok:#2f6b48; --warn:#8a5a2b; + --radius:10px; +} +@media (prefers-color-scheme:dark){ + :root{ + --bg:#16181b; --panel:#1d2024; --ink:#e8e6e2; --muted:#a3a9b2; --faint:#7d848d; + --line:#2e3238; --line-soft:#25282d; --accent:#d9a86a; --accent-soft:#2a2620; + --flagbg:#2a2620; --flagline:#4a4030; --ok:#7fb894; --warn:#d9a86a; + } +} +:root[data-theme="dark"]{ + --bg:#16181b; --panel:#1d2024; --ink:#e8e6e2; --muted:#a3a9b2; --faint:#7d848d; + --line:#2e3238; --line-soft:#25282d; --accent:#d9a86a; --accent-soft:#2a2620; + --flagbg:#2a2620; --flagline:#4a4030; --ok:#7fb894; --warn:#d9a86a; +} +:root[data-theme="light"]{ + --bg:#fbfaf8; --panel:#ffffff; --ink:#1d1f21; --muted:#5f6672; --faint:#8b929c; + --line:#e4e2dd; --line-soft:#efeee9; --accent:#8a5a2b; --accent-soft:#f5efe6; + --flagbg:#fdf6e6; --flagline:#e3d3ac; --ok:#2f6b48; --warn:#8a5a2b; +} +body{ + margin:0; background:var(--bg); color:var(--ink); + font:16px/1.6 ui-sans-serif,-apple-system,"Segoe UI",Inter,Helvetica,Arial,sans-serif; + -webkit-font-smoothing:antialiased; +} +html,body{max-width:100%; overflow-x:hidden} +.wrap{max-width:56rem; margin:0 auto; padding:2.5rem 1.25rem 5rem} +/* A long title, id, or URL must wrap rather than widen the page at any nesting + level. Lavish's layout audit flags horizontal overflow as an error, so every + text container wraps unbreakable strings and every flex/grid child gets a + min-width:0 track so it can actually shrink. Genuinely wide blocks scroll + inside their own container instead of stretching the page. */ +*{min-width:0} +a{color:var(--accent); overflow-wrap:anywhere} +.card,.field,.field p,.flag,form.answer,form.discuss, +.card h3,.tag,li.row,li.row .what,li.row .what .t,li.row .what .sub{ + overflow-wrap:anywhere; word-break:break-word; +} +h1{font-size:1.55rem; line-height:1.25; margin:0 0 .4rem; letter-spacing:-.01em} +.lede{margin:0 0 .35rem; color:var(--muted); max-width:44rem} +.meta{margin:0; color:var(--faint); font-size:.82rem} +header.board{border-bottom:1px solid var(--line); padding-bottom:1.5rem; margin-bottom:1.5rem} + +.controls{ + display:flex; flex-wrap:wrap; gap:.75rem 1.25rem; align-items:flex-end; + margin-bottom:2.25rem; +} +.control{display:flex; flex-direction:column; gap:.35rem; min-width:0} +.control > span{font-size:.72rem; letter-spacing:.08em; text-transform:uppercase; color:var(--faint)} +.segmented{display:flex; border:1px solid var(--line); border-radius:var(--radius); overflow:hidden} +.segmented button{ + appearance:none; border:0; background:var(--panel); color:var(--muted); + font:inherit; font-size:.88rem; padding:.42rem .85rem; cursor:pointer; + border-right:1px solid var(--line); +} +.segmented button:last-child{border-right:0} +.segmented button[aria-pressed="true"]{background:var(--accent-soft); color:var(--ink); font-weight:600} +select{ + font:inherit; font-size:.88rem; padding:.42rem .6rem; border-radius:var(--radius); + border:1px solid var(--line); background:var(--panel); color:var(--ink); max-width:100%; +} +.tally{margin-left:auto; color:var(--faint); font-size:.82rem; align-self:flex-end} + +section.block{margin:0 0 2.75rem} +section.block > h2{ + font-size:.78rem; letter-spacing:.11em; text-transform:uppercase; color:var(--faint); + margin:0 0 .25rem; font-weight:600; +} +section.block > .blurb{margin:0 0 1rem; color:var(--muted); font-size:.88rem} +.empty{color:var(--faint); font-size:.9rem; font-style:italic; margin:0} + +.card{ + background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); + padding:1.4rem 1.5rem; margin-bottom:1.1rem; +} +.card > h3{font-size:1.12rem; line-height:1.35; margin:0 0 .6rem; letter-spacing:-.005em} +.tags{display:flex; flex-wrap:wrap; gap:.4rem; margin:0 0 1rem} +.tag{ + font-size:.72rem; letter-spacing:.03em; color:var(--muted); background:var(--line-soft); + border-radius:99px; padding:.16rem .55rem; white-space:nowrap; +} +.tag.warn{background:var(--flagbg); color:var(--warn); border:1px solid var(--flagline)} + +.field{margin:0 0 .95rem} +.field > .label{ + font-size:.74rem; letter-spacing:.06em; text-transform:uppercase; color:var(--faint); + margin:0 0 .18rem; font-weight:600; +} +.field p{margin:0 0 .5rem} +.field p:last-child{margin-bottom:0} +.field.take p{color:var(--ink)} + +.flag{ + background:var(--flagbg); border:1px solid var(--flagline); border-radius:var(--radius); + padding:.8rem 1rem; margin:0 0 1rem; +} +.flag .label{color:var(--warn)} + +details{border-top:1px solid var(--line-soft); margin-top:1.1rem; padding-top:.85rem} +details summary{ + cursor:pointer; font-size:.85rem; color:var(--muted); list-style:none; + display:inline-flex; align-items:center; gap:.4rem; +} +details summary::-webkit-details-marker{display:none} +details summary::before{content:"+"; color:var(--faint); font-weight:600} +details[open] summary::before{content:"\\2212"} +details .inner{padding-top:.85rem; font-size:.92rem; color:var(--muted); overflow-x:auto} +details .inner code, details .inner .src{ + font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.84rem; +} + +form.answer{border-top:1px solid var(--line-soft); margin-top:1.1rem; padding-top:1rem} +form.answer .label{ + font-size:.74rem; letter-spacing:.06em; text-transform:uppercase; color:var(--faint); + margin:0 0 .5rem; font-weight:600; +} +form.answer label.opt{ + display:flex; gap:.6rem; align-items:flex-start; padding:.38rem 0; cursor:pointer; + font-size:.95rem; overflow-wrap:anywhere; +} +form.answer input[type=radio]{margin-top:.35rem; flex:none} +form.answer .clear{ + appearance:none; background:none; border:0; padding:.2rem 0; margin-top:.15rem; + font:inherit; font-size:.8rem; color:var(--muted); cursor:pointer; text-decoration:underline; +} +form.answer textarea,form.discuss textarea{ + width:100%; margin-top:.7rem; min-height:3.6rem; font:inherit; font-size:.92rem; + padding:.55rem .7rem; border-radius:var(--radius); border:1px solid var(--line); + background:var(--bg); color:var(--ink); resize:vertical; max-width:100%; +} +form.answer .send,form.discuss .send{ + display:flex; align-items:center; gap:.75rem; margin-top:.75rem; flex-wrap:wrap; +} +form.answer button[type=submit],form.discuss button[type=submit]{ + appearance:none; font:inherit; font-size:.9rem; font-weight:600; cursor:pointer; + padding:.45rem 1rem; border-radius:var(--radius); border:1px solid var(--accent); + background:var(--accent); color:var(--panel); +} +form.answer .status,form.discuss .status{font-size:.84rem; color:var(--muted); overflow-wrap:anywhere} +form.answer .status.done,form.discuss .status.done{color:var(--ok); font-weight:600} +form.discuss{margin-top:.4rem} +form.discuss summary{ + cursor:pointer; font-size:.85rem; color:var(--muted); list-style:none; + display:inline-flex; align-items:center; gap:.4rem; +} +form.discuss summary::-webkit-details-marker{display:none} +form.discuss summary::before{content:"?"; color:var(--faint); font-weight:700} + +ul.rows{list-style:none; margin:0; padding:0} +li.row{ + display:flex; gap:.75rem; align-items:baseline; padding:.6rem 0; + border-bottom:1px solid var(--line-soft); min-width:0; +} +li.row:last-child{border-bottom:0} +li.row .who{flex:none; width:8.5rem; color:var(--faint); font-size:.78rem; overflow-wrap:anywhere} +li.row .what{flex:1 1 auto; min-width:0} +li.row .what .t{display:block; overflow-wrap:anywhere} +li.row .what .sub{display:block; color:var(--faint); font-size:.82rem; overflow-wrap:anywhere} +li.row .what .sub a{overflow-wrap:anywhere} +@media (max-width:34rem){ + .wrap{padding:1.75rem 1rem 4rem} + .card{padding:1.15rem 1.1rem} + li.row{flex-direction:column; gap:.15rem} + li.row .who{width:auto} + .tally{margin-left:0; width:100%} +} +""" + +JS = """ +(function(){ + var root=document.documentElement; + var typeButtons=[].slice.call(document.querySelectorAll('[data-type-filter]')); + var projectSelect=document.getElementById('project-filter'); + var tally=document.getElementById('tally'); + var state={type:'all',project:'all'}; + + function visibleFor(section){ + if(state.type==='all') return true; + if(state.type==='decisions') return section.dataset.block==='decide'; + if(state.type==='shipped') return section.dataset.block==='shipped'; + return true; + } + + function apply(){ + var shown=0; + [].forEach.call(document.querySelectorAll('section.block'),function(section){ + var allowed=visibleFor(section); + var live=0; + [].forEach.call(section.querySelectorAll('[data-project]'),function(item){ + var ok=allowed&&(state.project==='all'||item.dataset.project===state.project); + item.hidden=!ok; + if(ok){live++;} + }); + var none=section.querySelector('.empty-filtered'); + if(none){none.hidden=!(allowed&&live===0);} + section.hidden=!allowed; + shown+=live; + if(section.dataset.block==='decide'){ + var c=section.querySelector('.count'); + if(c){c.textContent=live===1?'1 decision':(live+' decisions');} + } + }); + if(tally){tally.textContent=shown+(shown===1?' item shown':' items shown');} + } + + typeButtons.forEach(function(btn){ + btn.addEventListener('click',function(){ + state.type=btn.dataset.typeFilter; + typeButtons.forEach(function(b){ + b.setAttribute('aria-pressed',String(b===btn)); + }); + apply(); + }); + }); + if(projectSelect){ + projectSelect.addEventListener('change',function(){ + state.project=projectSelect.value; + apply(); + }); + } + var toggle=document.getElementById('theme-toggle'); + if(toggle){ + toggle.addEventListener('click',function(){ + var dark=root.getAttribute('data-theme')==='dark'; + root.setAttribute('data-theme',dark?'light':'dark'); + }); + } + apply(); +})(); +""" + +QUEUE_JS = """ +// Clear the quick-pick radios so a selection is never a trap. +function fmInboxClear(button){ + var form=button.closest('form'); + if(!form){return;} + form.querySelectorAll('input[name=answer]').forEach(function(r){r.checked=false;}); +} +// One shared send path. It queues exactly one prompt and sends it immediately +// so an answer never sits waiting on a separate Send button the captain has to +// find - the failure mode where the board looked like it silently ate answers. +function fmInboxSend(form, text, data, confirm){ + var mark=form.querySelector('.status'); + if(window.lavish&&window.lavish.queuePrompt){ + window.lavish.queuePrompt(text,{ + tag:data.tag, queueKey:data.queueKey, element:form, text:data.summary, data:data + }); + if(window.lavish.sendQueuedPrompts){window.lavish.sendQueuedPrompts();} + if(mark){mark.textContent=confirm; mark.classList.add('done');} + }else if(mark){ + mark.textContent='Open this board through Lavish to send.'; + } +} +// The free-text answer stands alone: a picked option is a convenience, never +// required. Submit works with just the note, just an option, or both. +function fmInboxAnswer(form){ + var data=new FormData(form); + var choice=(data.get('answer')||'').trim(); + var note=(data.get('note')||'').trim(); + var mark=form.querySelector('.status'); + if(!choice&&!note){ + if(mark){mark.textContent='Type your answer, or pick one of the options.'; mark.classList.remove('done');} + return; + } + var id=form.dataset.lavishQuestion; + var question=form.dataset.question||id; + var body=note?(choice?(choice+' -- '+note):note):choice; + fmInboxSend(form,'DECISION '+id+': '+body,{ + tag:'decision', queueKey:id, summary:question+' -> '+body, + hold:id, question:question, answer:choice, note:note + },'Sent to firstmate.'); +} +// The discuss path asks a question back instead of deciding. firstmate replies; +// it does not treat this as the answer. +function fmInboxDiscuss(form){ + var data=new FormData(form); + var text=(data.get('question')||'').trim(); + var mark=form.querySelector('.status'); + if(!text){ + if(mark){mark.textContent='Type your question first.'; mark.classList.remove('done');} + return; + } + var id=form.dataset.lavishQuestion; + var hold=id.replace(/-discuss$/,''); + var question=form.dataset.question||hold; + fmInboxSend(form,'DISCUSS '+hold+': '+text,{ + tag:'discuss', queueKey:id, summary:'Question on '+question, + hold:hold, question:question, message:text + },'Question sent to firstmate.'); +} +""" + + +def tag(text, cls="tag"): + return '%s' % (cls, esc(text)) + + +def field(label, body, extra=""): + if not body: + return "" + return '

%s

%s
' % ( + extra, + esc(label), + body, + ) + + +def render_decision(view): + parts = [] + parts.append( + '
' + % (esc(view["project"]), esc(slugify(view["id"]))) + ) + parts.append("

%s

" % esc(view["question"])) + + tags = ['
', tag(view["project"])] + if view["since"]: + tags.append(tag("waiting since %s" % view["since"])) + if view["flag"]: + tags.append(tag("firstmate's assumption, not your call", "tag warn")) + if not view["annotated"]: + tags.append(tag("no plain-English summary written yet", "tag warn")) + tags.append("
") + parts.append("".join(tags)) + + if view["plain"]: + parts.append(field("In plain terms", rich(view["plain"]))) + else: + parts.append( + field( + "In plain terms", + rich(view["hold"] or "No description was recorded for this decision."), + ) + ) + if view["why"]: + parts.append(field("Why this is even a question", rich(view["why"]))) + elif view["origin"]: + parts.append( + field( + "Why this is even a question", + rich("It came out of the %s investigation. Full write-up: %s" + % (view["origin"], view["report"] or "not recorded")), + ) + ) + if view["take"]: + parts.append(field("My take", rich(view["take"]), extra="take")) + if view["link"]: + parts.append( + field( + "Look into it", + '

%s

' + % (esc(view["link"]), esc(view["link"])), + ) + ) + if view["flag"]: + parts.append( + '

Check this assumption

%s
' + % rich(view["flag"]) + ) + + inner = [] + if view["expand"]: + inner.append(rich(view["expand"])) + if view["hold"] and view["hold"] != view.get("plain"): + inner.append( + '

Recorded decision note

%s
' + % rich(view["hold"]) + ) + trail = [] + if view["origin"]: + trail.append("came from the %s investigation" % view["origin"]) + if view["report"]: + trail.append("full write-up in %s" % view["report"]) + trail.append("tracked as %s" % view["id"]) + inner.append("

%s.

" % esc("; ".join(trail).capitalize())) + parts.append( + "
The technical detail" + '
%s
' % "".join(inner) + ) + + # Quick-pick options are optional. Each carries a clear-selection reset so a + # picked radio is never a trap the captain cannot back out of, and the + # free-text box below submits on its own. + opts = "" + if view["options"]: + rows = "".join( + '" % (esc(option), esc(option)) + for option in view["options"] + ) + opts = ( + '
%s' + '' + "
" % rows + ) + parts.append( + '
' + '

Your answer

%s' + '' + '
' + '
' + % ( + esc(view["id"]), + esc(view["question"]), + opts, + esc( + "Write your own answer here. You do not have to pick an option above." + if view["options"] + else "Write your answer here." + ), + ) + ) + parts.append( + '
' + "
Not ready to answer? Ask a question instead" + '' + '
' + '
' + % ( + esc(view["id"]), + esc(view["question"]), + esc( + "What is unclear? firstmate will reply here, it will not treat " + "this as your decision." + ), + ) + ) + parts.append("
") + return "".join(parts) + + +def render_row(project, who, what, sub="", sub_html=False): + rendered_sub = sub if sub_html else esc(sub) + return ( + '
  • %s' + '%s%s
  • ' + % ( + esc(project), + esc(who), + esc(what), + '%s' % rendered_sub if rendered_sub else "", + ) + ) + + +def section(block, title, blurb, body, count_label=""): + head = '

    %s%s

    ' % ( + esc(title), + ' %s' % esc(count_label) if count_label else "", + ) + return ( + '
    %s

    %s

    %s' + '' + "
    " % (esc(block), head, esc(blurb), body) + ) + + +def render(model, snap, home, pr_verified): + generated = snap.get("generated") or datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + projects = set() + for view in model["decisions"]: + projects.add(view["project"]) + for row in model["prs"] + model["stale_prs"]: + projects.add(row["project"]) + for record, task, _ in model["stuck"]: + projects.add(area_of(record, task)) + for record, task in model["underway"]: + projects.add(area_of(record, task)) + for record in model["queued"] + model["shipped"]: + projects.add(area_of(record, model["tasks"].get(record.get("id")))) + + options = "".join( + '' % (esc(p), esc(p)) + for p in sorted(projects, key=lambda s: (s == NO_PROJECT, s)) + ) + + titles = {r.get("id"): r.get("title") for r in structured_records(snap)} + + blocks = [] + + # Decide + if model["decisions"]: + body = "".join(render_decision(v) for v in model["decisions"]) + else: + body = '

    Nothing is waiting on you.

    ' + blocks.append( + section( + "decide", + "Decide", + "Open questions that are yours to answer. Nothing is blocking any of them.", + body, + count_label="%d decisions" % len(model["decisions"]), + ) + ) + + # Review and merge + rows = [] + for row in model["prs"]: + if pr_verified and row["state"] == "OPEN": + note = "checked just now: open" + elif pr_verified: + note = "could not be checked" + else: + note = "not checked, it may already be closed or merged" + rows.append( + render_row( + row["project"], + row["project"], + row["title"], + '%s · %s' + % (esc(row["url"]), esc(row["url"]), esc(note)), + sub_html=True, + ) + ) + if rows: + body = '
      %s
    ' % "".join(rows) + else: + body = '

    Nothing is waiting for your review.

    ' + if model["stale_prs"]: + stale = "".join( + '
  • %s' + '%s' + '%s · %s' + "
  • " + % ( + esc(row["project"]), + esc(row["project"]), + esc(row["title"]), + esc(row["url"]), + esc(row["url"]), + esc("already %s, no longer merge-ready" % row["state"].lower()), + ) + for row in model["stale_prs"] + ) + body += ( + '

    Recorded links that are no ' + 'longer open:

      %s
    ' % stale + ) + blocks.append( + section( + "review", + "Review and merge", + "Finished work waiting for your yes." + + ( + "" + if pr_verified + else " These links were read from local records, so a closed or already" + " merged pull request can still appear here." + ), + body, + ) + ) + + # Underway + rows = [] + for record, task in model["underway"]: + state = LIVE_LABELS.get(live_state(task) or "", "in progress") + rows.append( + render_row( + area_of(record, task), + area_of(record, task), + record.get("title") or record.get("id"), + "%s, started %s" % (state, record.get("since") or "recently"), + ) + ) + blocks.append( + section( + "underway", + "Underway", + "Being worked on right now. Nothing for you to do.", + '
      %s
    ' % "".join(rows) + if rows + else '

    Nothing is running.

    ', + ) + ) + + # Queued + rows = [] + for record in model["queued"]: + task = model["tasks"].get(record.get("id")) + blockers = unresolved(record) + if blockers: + sub = "waiting for %s" % ", ".join( + titles.get(b) or b for b in blockers + ) + elif record.get("hold_reason"): + sub = "on hold: %s" % record["hold_reason"] + else: + sub = "queued since %s" % (record.get("since") or "recently") + rows.append( + render_row( + area_of(record, task), + area_of(record, task), + record.get("title") or record.get("id"), + sub, + ) + ) + blocks.append( + section( + "queued", + "Queued", + "Lined up behind something else, or parked on purpose.", + '
      %s
    ' % "".join(rows) + if rows + else '

    Nothing is queued.

    ', + ) + ) + + # Shipped + rows = [] + for record in model["shipped"]: + task = model["tasks"].get(record.get("id")) + verb = (record.get("completion") or {}).get("verb") or "done" + date = (record.get("completion") or {}).get("date") or record.get("since") or "" + sub = "%s %s" % (COMPLETION_LABELS.get(verb, verb), date) + if record.get("pr_url"): + sub = '%s · %s' % ( + esc(sub), + esc(record["pr_url"]), + esc(record["pr_url"]), + ) + rows.append( + render_row( + area_of(record, task), + area_of(record, task), + record.get("title") or record.get("id"), + sub, + sub_html=True, + ) + ) + else: + rows.append( + render_row( + area_of(record, task), + area_of(record, task), + record.get("title") or record.get("id"), + sub, + ) + ) + blocks.append( + section( + "shipped", + "Recently built and shipped", + "Landed work, most recent first.", + '
      %s
    ' % "".join(rows) + if rows + else '

    Nothing has landed yet.

    ', + ) + ) + + # Stuck + rows = [] + for record, task, reasons in model["stuck"]: + rows.append( + render_row( + area_of(record, task), + area_of(record, task), + record.get("title") or record.get("id"), + "; ".join(reasons), + ) + ) + blocks.append( + section( + "stuck", + "Stuck", + "Stopped and not going to move on its own.", + '
      %s
    ' % "".join(rows) + if rows + else '

    Nothing is stuck.

    ', + ) + ) + + return """ + + + + + +Decisions and reviews firstmate needs from you + + + +
    +
    +

    Decisions and reviews firstmate needs from you

    +

    This is the one place firstmate puts work that is waiting on you: + decisions to make, finished work to approve, and anything that has stopped. + Answer a decision here and firstmate picks it up. Everything below it is + context, not a task.

    +

    Built from live fleet state at {generated} · {home}

    +
    + +
    +
    + Show +
    + + + +
    +
    +
    + Project + +
    +
    + Theme +
    +
    +

    +
    + +{blocks} +
    + + + + +""".format( + schema=SCHEMA, + css=CSS, + generated=esc(generated), + home=esc(home), + options=options, + blocks="\n".join(blocks), + queue_js=QUEUE_JS, + js=JS, + ) + + +# -------------------------------------------------------------------------- +# entry point +# -------------------------------------------------------------------------- + + +def load_pr_state(path): + state = {} + if not path or not os.path.isfile(path): + return state + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + if "\t" not in line: + continue + url, value = line.rstrip("\n").split("\t", 1) + state[url] = value + return state + + +def main(argv): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--snapshot", required=True) + parser.add_argument("--full-text-dir", default="") + parser.add_argument("--cards", default="") + parser.add_argument("--pr-state", default="") + parser.add_argument("--pr-verified", default="0") + parser.add_argument("--home", default="") + parser.add_argument("--out", default="") + parser.add_argument("--decision-ids", action="store_true") + parser.add_argument("--pr-urls", action="store_true") + args = parser.parse_args(argv) + + with open(args.snapshot, "r", encoding="utf-8", errors="replace") as fh: + snap = json.load(fh) + + if args.decision_ids: + for record in open_captain_decisions(snap): + print(record.get("id", "")) + return 0 + if args.pr_urls: + for entry in recorded_prs(snap): + print(entry["url"]) + return 0 + + if not args.out: + parser.error("--out is required") + + pr_verified = args.pr_verified == "1" + model = build_model( + snap, + load_full_text(args.full_text_dir), + load_cards(args.cards), + load_pr_state(args.pr_state), + pr_verified, + ) + page = render(model, snap, args.home or snap.get("fm_home", ""), pr_verified) + with open(args.out, "w", encoding="utf-8") as fh: + fh.write(page) + counts = ( + len(model["decisions"]), + len(model["prs"]), + len(model["underway"]), + len(model["queued"]), + len(model["shipped"]), + len(model["stuck"]), + ) + print( + "decisions=%d review=%d underway=%d queued=%d shipped=%d stuck=%d" % counts, + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bin/fm-inbox-serve.sh b/bin/fm-inbox-serve.sh new file mode 100755 index 0000000..da3f4bc --- /dev/null +++ b/bin/fm-inbox-serve.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# fm-inbox-serve.sh - generate, serve, and arm the captain's decision board. +# +# One operator command makes the board reliable end to end: +# 1. regenerates the board through bin/fm-inbox-view.sh (read-only), +# 2. writes and registers a bounded watcher relay at state/inbox.check.sh so +# the captain's answers reach firstmate without anyone remembering to poll, +# 3. serves the board on the advertised Tailscale address, +# 4. prints and verifies the exact link to hand the captain. +# +# The relay is the fix for the captain's core complaint: with nothing polling +# the board, submitted answers looked silently eaten. Once armed, firstmate's +# supervision cycle polls the served board on its normal check cadence, appends +# any answer to state/inbox-answers/, and wakes firstmate to act on it. When the +# board is not being served the poll fails fast and the relay stays a silent +# no-op, so it never floods. +# +# Unlike bin/fm-inbox-view.sh, which is a pure read-only generator, this command +# writes firstmate's own private runtime state (the relay and its registration). +# Run it from the operating firstmate home. +# +# Usage: +# fm-inbox-serve.sh [] +# fm-inbox-serve.sh [options] [] +# fm-inbox-serve.sh --help +# +# Board path defaults to $HOME/lavish-boards/inbox/board.html. +# +# Options: +# --no-generate serve the board as it stands; do not regenerate it. +# --no-arm serve without writing or registering the relay (the +# answers will not reach firstmate on their own). +# --link-host Tailscale address to advertise in the link. Default is +# the first 100.* address reported by `ifconfig`. +# --cards passed through to fm-inbox-view.sh. +# --verify-prs passed through to fm-inbox-view.sh. +# Any other fm-inbox-view.sh option can follow `--` and is passed through. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +die() { + printf 'fm-inbox-serve: %s\n' "$1" >&2 + exit "${2:-1}" +} + +BOARD= +GENERATE=1 +ARM=1 +LINK_HOST= +PORT=${LAVISH_AXI_PORT:-4387} +VIEW_ARGS=() + +while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --no-generate) GENERATE=0; shift ;; + --no-arm) ARM=0; shift ;; + --link-host) + [ "$#" -ge 2 ] || die "--link-host requires an address" 2 + LINK_HOST=$2; shift 2 ;; + --cards) + [ "$#" -ge 2 ] || die "--cards requires a path" 2 + VIEW_ARGS+=(--cards "$2"); shift 2 ;; + --verify-prs) VIEW_ARGS+=(--verify-prs); shift ;; + --) shift; VIEW_ARGS+=("$@"); break ;; + -*) usage >&2; exit 2 ;; + *) + [ -z "$BOARD" ] || die "only one board path is accepted" 2 + BOARD=$1; shift ;; + esac +done + +[ -n "$BOARD" ] || BOARD="${HOME:-/tmp}/lavish-boards/inbox/board.html" +case "$BOARD" in + /*) ;; + *) BOARD="$PWD/$BOARD" ;; +esac +command -v lavish-axi >/dev/null 2>&1 || die "lavish-axi not found" + +if [ "$GENERATE" -eq 1 ]; then + "$SCRIPT_DIR/fm-inbox-view.sh" "${VIEW_ARGS[@]}" "$BOARD" || die "board generation failed" +fi +[ -f "$BOARD" ] || die "board not found: $BOARD (generate it first, or drop --no-generate)" + +# Arm the relay so answers reach firstmate without manual polling. Owned by +# fm-inbox-arm.sh; this is firstmate's own private state. +if [ "$ARM" -eq 1 ]; then + "$SCRIPT_DIR/fm-inbox-arm.sh" --port "$PORT" "$BOARD" \ + || die "could not arm the answer relay" +fi + +# Detect the advertised Tailscale address unless one was supplied. The active +# interface address is enough for Lavish links and does not depend on the +# tailscale CLI's login-state reporting. +if [ -z "$LINK_HOST" ]; then + LINK_HOST=$(ifconfig 2>/dev/null | awk '/inet 100\./ {print $2; exit}') +fi +[ -n "$LINK_HOST" ] || die "could not determine a Tailscale address; pass --link-host " + +# Serve on all interfaces with the advertised link so the captain can reach it +# from another device. Do not open a local browser on the serving machine. +served=$(LAVISH_AXI_HOST=0.0.0.0 LAVISH_AXI_LINK_HOST="$LINK_HOST" LAVISH_AXI_NO_OPEN=1 \ + lavish-axi "$BOARD" 2>&1) || die "lavish-axi could not serve the board: +$served" + +url=$(printf '%s\n' "$served" | awk -F'"' '/url:/ {print $2; exit}') +[ -n "$url" ] || url=$(printf '%s\n' "$served" | grep -oE 'https?://[^ ]+/session/[A-Za-z0-9]+' | head -1) +[ -n "$url" ] || die "served the board but could not read its link: +$served" + +session=${url##*/session/} +verify="http://$LINK_HOST:$PORT/session/$session" +code=$(curl -s -o /dev/null -w '%{http_code}' "$verify" 2>/dev/null || printf '000') + +printf 'board: %s\n' "$BOARD" +printf 'link: %s\n' "$url" +if [ "$code" = 200 ]; then + printf 'reachable: yes (HTTP 200 over Tailscale)\n' +else + printf 'reachable: could not confirm (HTTP %s from %s)\n' "$code" "$verify" >&2 + exit 1 +fi diff --git a/bin/fm-inbox-view.sh b/bin/fm-inbox-view.sh new file mode 100755 index 0000000..33a4209 --- /dev/null +++ b/bin/fm-inbox-view.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# fm-inbox-view.sh - the captain's decision-and-review board, written as one HTML page. +# +# This command is a READ-ONLY projection, in the same shape as fm-fleet-view.sh. +# It shells out to fm-fleet-snapshot.sh --json for fleet state and to +# `tasks-axi show --full` for untruncated captain-hold text, then writes one +# self-contained HTML file through bin/fm-inbox-render.py. +# It never writes under state/ or data/, never acquires the session lock, never +# drains wakes, and never mutates the backlog. tests/fm-inbox-view.test.sh proves +# the no-mutation guarantee against a fixture home. +# +# The board is served with lavish-axi so the captain can answer decisions in the +# browser. Serving, answer relay, and escalation policy are NOT this script's +# job; it only produces the surface. +# +# Usage: +# fm-inbox-view.sh [] +# fm-inbox-view.sh [options] [] +# fm-inbox-view.sh --help +# +# Output path defaults to $HOME/lavish-boards/inbox/board.html, the stable +# bookmarkable topic path. Any other path is written as given; parent +# directories are created. The file is written atomically. +# +# Options: +# --cards plain-English decision cards to merge in (see below). +# Default: $FM_HOME/data/inbox-cards.md when it exists. +# --verify-prs live-check every recorded pull request with +# `gh pr view --json state`. Without it, every +# recorded PR is rendered as UNVERIFIED, because locally +# recorded metadata cannot know a PR was closed. +# --snapshot render a previously captured fm-fleet-snapshot.sh --json +# document instead of running a fresh one (tests, replay). +# --no-full-text skip the per-decision `tasks-axi show --full` reads and +# accept the snapshot's truncated hold text. +# +# Decision cards file (--cards), optional and captain-private: +# Snapshot metadata alone describes a decision in firstmate's own vocabulary, +# which is not answerable by the captain. This file carries the plain-English +# half. It is keyed by decision id and every field is optional; a decision with +# no entry still renders, marked as having no plain-English summary yet. +# +# ## +# ### question +# The decision, as a plain question. +# ### plain +# One or two sentences: what the captain is actually choosing. +# ### why +# Where this came from and what triggered it. +# ### take +# Firstmate's recommendation, in plain terms. +# ### link +# https://example.invalid/product-page +# ### options +# - First answer +# - Second answer +# ### expand +# Technical detail, shown only when the captain expands the card. +# ### flag +# Firstmate assumed this because ...; accept it or drop it. +# +# Use `flag` when the item is an assumption an investigation made rather than +# a choice the captain ever made. It renders as an explicit callout. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +RENDERER="$SCRIPT_DIR/fm-inbox-render.py" + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +die() { + printf 'fm-inbox-view: %s\n' "$1" >&2 + exit "${2:-1}" +} + +OUT= +CARDS= +SNAPSHOT_IN= +VERIFY_PRS=0 +FULL_TEXT=1 + +while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --cards) + [ "$#" -ge 2 ] || die "--cards requires a path" 2 + CARDS=$2; shift 2 ;; + --snapshot) + [ "$#" -ge 2 ] || die "--snapshot requires a path" 2 + SNAPSHOT_IN=$2; shift 2 ;; + --verify-prs) VERIFY_PRS=1; shift ;; + --no-full-text) FULL_TEXT=0; shift ;; + --) shift; break ;; + -*) usage >&2; exit 2 ;; + *) + [ -z "$OUT" ] || die "only one output path is accepted" 2 + OUT=$1; shift ;; + esac +done +if [ "$#" -gt 0 ]; then + [ -z "$OUT" ] || die "only one output path is accepted" 2 + OUT=$1 +fi + +[ -n "$OUT" ] || OUT="${HOME:-/tmp}/lavish-boards/inbox/board.html" +[ -f "$RENDERER" ] || die "renderer is missing: $RENDERER" +command -v python3 >/dev/null 2>&1 || die "python3 not found" + +if [ -z "$CARDS" ] && [ -f "$DATA/inbox-cards.md" ]; then + CARDS="$DATA/inbox-cards.md" +fi +if [ -n "$CARDS" ] && [ ! -f "$CARDS" ]; then + die "decision cards file not found: $CARDS" +fi + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-inbox-view.XXXXXX") || die "cannot create a work directory" +trap '[ -z "${TMP:-}" ] || rm -rf -- "$TMP"' EXIT HUP INT TERM + +SNAPSHOT="$TMP/snapshot.json" +if [ -n "$SNAPSHOT_IN" ]; then + [ -f "$SNAPSHOT_IN" ] || die "snapshot file not found: $SNAPSHOT_IN" + cat -- "$SNAPSHOT_IN" > "$SNAPSHOT" || die "cannot read snapshot: $SNAPSHOT_IN" +else + "$SCRIPT_DIR/fm-fleet-snapshot.sh" --json > "$SNAPSHOT" || die "fleet snapshot failed" +fi + +# Untruncated captain-hold text. fm-fleet-snapshot's metadata capture stops at +# the first comma, so a hold reason carrying options is unreadable from the +# snapshot alone; tasks-axi is the authority for the full text. +FULLDIR="$TMP/full" +mkdir -p "$FULLDIR" +if [ "$FULL_TEXT" -eq 1 ] && command -v tasks-axi >/dev/null 2>&1; then + while IFS= read -r id; do + [ -n "$id" ] || continue + case "$id" in + *[!A-Za-z0-9._-]*|.*) continue ;; + esac + (cd "$FM_HOME" && tasks-axi show "$id" --full) > "$FULLDIR/$id.txt" 2>/dev/null \ + || rm -f -- "$FULLDIR/$id.txt" + done < <(python3 "$RENDERER" --decision-ids --snapshot "$SNAPSHOT") +fi + +# Recorded PR metadata cannot know a PR was closed. Verification is opt-in, and +# an unverified PR is rendered as unverified rather than as ready to merge. +PRSTATE="$TMP/pr-state.tsv" +: > "$PRSTATE" +if [ "$VERIFY_PRS" -eq 1 ]; then + if command -v gh >/dev/null 2>&1; then + while IFS= read -r url; do + [ -n "$url" ] || continue + state=$(gh pr view "$url" --json state -q .state 2>/dev/null) || state= + [ -n "$state" ] || state=unknown + printf '%s\t%s\n' "$url" "$state" >> "$PRSTATE" + done < <(python3 "$RENDERER" --pr-urls --snapshot "$SNAPSHOT") + else + printf 'fm-inbox-view: gh not found, pull requests stay unverified\n' >&2 + VERIFY_PRS=0 + fi +fi + +OUT_DIR=$(dirname -- "$OUT") +mkdir -p -- "$OUT_DIR" || die "cannot create output directory: $OUT_DIR" +# Render beside the destination so the publish is one same-filesystem rename: +# an open board never sees a half-written page, and a failed render leaves the +# previous board untouched. +TMP_OUT=$(mktemp "$OUT_DIR/.fm-inbox-board.XXXXXX") || die "cannot stage the board" +trap '[ -z "${TMP:-}" ] || rm -rf -- "$TMP"; [ -z "${TMP_OUT:-}" ] || rm -f -- "$TMP_OUT"' \ + EXIT HUP INT TERM + +python3 "$RENDERER" \ + --snapshot "$SNAPSHOT" \ + --full-text-dir "$FULLDIR" \ + --cards "$CARDS" \ + --pr-state "$PRSTATE" \ + --pr-verified "$VERIFY_PRS" \ + --home "$FM_HOME" \ + --out "$TMP_OUT" || die "render failed" + +# BSD chmod has no `--`, so the staged name is generated by mktemp and never +# starts with a dash. +chmod 0644 "$TMP_OUT" || die "cannot set board permissions" +mv -f -- "$TMP_OUT" "$OUT" || die "cannot write board: $OUT" +TMP_OUT= +printf 'wrote %s\n' "$OUT" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 255c1cd..16d719d 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -173,7 +173,8 @@ family_for_basename() { fm-afk-inject-e2e.test.sh|fm-afk-return.test.sh) printf '%s\n' afk ;; - fm-bearings-snapshot.test.sh|fm-fleet-snapshot-view.test.sh) + fm-bearings-snapshot.test.sh|fm-fleet-snapshot-view.test.sh|\ + fm-inbox-view.test.sh|fm-inbox-arm.test.sh) printf '%s\n' snapshot-bearings ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) @@ -677,6 +678,12 @@ families_for_changed_path() { bin/fm-bearings-snapshot.sh|bin/fm-fleet-snapshot.sh|bin/fm-fleet-view.sh) printf '%s\n' snapshot-bearings ;; + bin/fm-inbox-view.sh|bin/fm-inbox-render.py) + printf '%s\n' "__script__:fm-inbox-view.test.sh" + ;; + bin/fm-inbox-arm.sh|bin/fm-inbox-serve.sh) + printf '%s\n' "__script__:fm-inbox-arm.test.sh" + ;; bin/fm-install-herdr.sh|bin/fm-install-treehouse.sh|bin/fm-herdr-ci-cleanup.sh) printf '%s\n' pure-contract-unit # Pin or cleanup changes also select the real-Herdr family so the required diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 54b2190..e399e48 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -77,6 +77,10 @@ "source": "docs/wedge-alarm.md", "target": "docs/verification/supervision.md" }, + { + "source": "docs/inbox-board.md", + "target": "docs/verification/supervision.md" + }, { "source": "docs/tmux-backend.md", "target": "docs/verification/runtime-backends.md" @@ -255,6 +259,10 @@ "path": "docs/herdr-backend.md", "audience": "operator-current" }, + { + "path": "docs/inbox-board.md", + "audience": "operator-current" + }, { "path": "docs/orca-backend.md", "audience": "operator-current" diff --git a/docs/inbox-board.md b/docs/inbox-board.md new file mode 100644 index 0000000..cea5aa5 --- /dev/null +++ b/docs/inbox-board.md @@ -0,0 +1,57 @@ +# Captain decision-and-review board (reference) + +The inbox board is one Lavish HTML surface for work that may need the captain - decisions to make, pull requests to approve, and stopped work - with running, queued, and recently shipped context below it, so it arrives in one place he opens when he wants instead of as scattered chat interruptions. +This is the operator reference; each script's own `--help` owns exact flags and mechanics. + +## Operator commands + +`bin/fm-inbox-view.sh` generates the board as a pure read-only projection over `bin/fm-fleet-snapshot.sh --json` plus `tasks-axi show --full`, writing nothing but the HTML file. +`tests/fm-inbox-view.test.sh` proves it leaves `state/` and `data/` byte-identical. + +`bin/fm-inbox-arm.sh` writes and registers the answer relay described below, and it writes firstmate's own private `state/`, so it is run from the operating home. + +`bin/fm-inbox-serve.sh` is the one operator command: it regenerates the board, arms the relay, serves the board through Lavish on an advertised host, and prints and verifies the link. +Serve the board with this, not a bare `lavish-axi` call, so answers actually reach firstmate. + +## Decision cards + +Every unblocked queued item with `hold_kind == "captain"` and a hold reason renders as a decision card, even when the backlog item's own `kind` is still `ship`. +That deliberate selection keeps `tasks-axi hold --kind captain` threads visible when the snapshot's narrower `captain_actionable` flag would hide them. +Each card leads with plain English: the question, what the captain is actually choosing, why it is a question, firstmate's recommendation, an optional research link, and a collapsed technical section. +The plain-English text is not derivable from backlog metadata, so it comes from an optional captain-private cards file (`data/inbox-cards.md` by default; see `fm-inbox-view.sh --help` for the format). +A decision with no card still renders and is marked as not yet written, rather than presenting a raw hold note as if it were plain English. +An item that is firstmate's own assumption rather than a choice the captain made is flagged as such on the card. + +## Review and context rows + +Recorded pull requests appear in Review and merge unless the work is already Done. +Without live verification they are marked as unverified, because local metadata cannot prove a pull request is still open. +When live verification is requested, open pull requests stay reviewable, closed or merged links move to the stale-record list, and unknown verification results stay reviewable with an unchecked note. +Underway, queued, shipped, and stuck rows give context below the action sections; only decision cards submit answers. + +## Answering + +The captain answers in the browser through Lavish's input contract: + +- A free-text answer is first-class, and any predefined options are optional quick-picks with a clear-selection reset, so the captain can always submit his own answer with no option selected. +- Each card also has a discuss path that sends a question back (`DISCUSS : ...`) instead of an answer (`DECISION : ...`), so a decision can be clarified rather than forced. +- Submitting queues exactly one prompt and sends it immediately with a visible confirmation, so an answer never sits waiting on a separate control. + +## The answer relay (why answers reliably arrive) + +Submitted answers are committed to the Lavish server and never lost, but something has to poll for them. +`fm-inbox-arm.sh` writes `state/inbox.check.sh` - a bounded relay that only calls `lavish-axi poll` after the board port is already listening - and binds its exact bytes with `bin/fm-check-register.sh`, so firstmate's supervision cycle runs it on the normal check cadence, appends any answer to `state/inbox-answers/`, and wakes firstmate to act on it. +This is the same registered-check pattern as the Workflowy `@go` channel. +The relay fails closed: when the board is not being served, the check exits before polling, prints nothing, and wakes no one. +It only runs while a supervision cycle is armed, so answer latency is one check sweep (`FM_CHECK_INTERVAL`, default 300s). + +## Serving + +Boards are served through Lavish on an advertised host so the captain can reach them from another device. +By default, `fm-inbox-serve.sh` advertises the first `100.*` address reported by `ifconfig`, because the reachable Tailscale address is interface state and does not require the `tailscale` CLI to report a logged-in state. +Pass the link host explicitly when that autodetection is wrong. + +## Verification evidence + +The active evidence is recorded in [supervision verification](verification/supervision.md#captain-inbox-answer-relay). +The focused regression entry points are `tests/fm-inbox-view.test.sh` and `tests/fm-inbox-arm.test.sh`. diff --git a/docs/scripts.md b/docs/scripts.md index 6a10d13..23f7d34 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -15,6 +15,10 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | +| `fm-inbox-view.sh` | Generate the read-only captain decision-and-review board HTML | +| `fm-inbox-render.py` | Render the inbox-board HTML from snapshot, card, full-text, and PR-state inputs | +| `fm-inbox-arm.sh` | Write and register the captain-inbox answer relay | +| `fm-inbox-serve.sh` | Generate, arm, serve, print, and verify the captain inbox board link | | `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | | `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 326d21d..d5f3e54 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -2,10 +2,67 @@ Audience: maintainer verification. -This record supports current session-start, turn-end, watcher-continuity, and wedge-alarm guarantees. +This record supports current session-start, turn-end, watcher-continuity, captain-inbox relay, and wedge-alarm guarantees. Operator behavior and active limits remain in the linked current guides. Task-specific chronology, temporary paths, run identifiers, and delivery transcripts remain in private reports or PR evidence. +## Captain inbox answer relay + +Recorded 2026-07-25 against the operating home and isolated fixture homes. +Focused deterministic suites were rerun on 2026-07-28. +The operator behavior is owned by [inbox-board.md](../inbox-board.md), while exact flags remain in the script headers. + +Observed guarantees: + +- Generation is read-only: a content-hash manifest of `state/` and `data/` was byte-identical before and after `fm-inbox-view.sh`. +- `fm-inbox-serve.sh --no-generate --link-host 127.0.0.1` against a fixture home armed the relay, served the board, and confirmed HTTP 200 for the generated session link. +- The registered relay is accepted by `fm_custom_check_registered`, and a one-byte edit to the check file makes it rejected. +- Run against a fixture board with no active Lavish session, the relay printed nothing, exited 0, and created no `state/inbox-answers/` directory. +- Unsafe linked answer-drop paths are refused with a diagnostic wake and no write-through. +- Shipped completion metadata is HTML-escaped while pull request links remain clickable. + +Current deterministic entry points: + +```sh +tests/fm-inbox-view.test.sh +tests/fm-inbox-arm.test.sh +``` + +Focused verification was rerun on 2026-07-28: + +```text +$ bash tests/fm-inbox-view.test.sh +ok - --help prints the header and exits 0 +ok - Decide selects on the captain hold alone and excludes blocked holds +ok - full decision text is read through tasks-axi and can be opted out of +ok - recorded pull requests render as unverified and landed ones stay out +ok - unknown PR verification stays in Review and merge +ok - shipped completion metadata escapes while PR links render +ok - decision cards render in full and missing cards are declared +ok - answers queue exactly once per question, on submit +ok - free text answers stand alone and options are optional quick-picks +ok - every decision offers a discuss/clarify path +ok - a submitted answer is sent immediately with visible confirmation +ok - no-project items get readable names instead of a generic badge +ok - long titles wrap instead of overflowing the card +ok - type and project filters are rendered for every section +ok - board generation leaves state/ and data/ byte-identical +ok - a missing explicit cards file refuses instead of rendering a silent gap + +$ bash tests/fm-inbox-arm.test.sh +ok - --help prints the header and exits 0 +ok - a missing board path refuses +ok - arming writes a mode-0700 relay and registers it for the watcher +ok - editing the relay after registration invalidates it +ok - the relay stays silent and starts no server while the board is unserved +ok - the relay is bound to its board and a durable answer drop +ok - arming refuses linked relay paths without writing through them +ok - the relay shell-escapes paths and exports its target port +ok - the relay emits one fixed wake line for feedback +ok - the relay refuses linked answer drops with a diagnostic wake +ok - serve passes its resolved port into the armed relay +``` + ## Native session-start delivery The cross-harness transport pass ran on 2026-07-17 with Codex 0.144.4, Grok 0.2.103, OpenCode 1.17.18, Pi 0.80.10, and the tracked Claude hook wiring. diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 323cd4f..922b227 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -12,7 +12,10 @@ # binaries and fixtures as the REFACTORED versions in this checkout, then # diffs the two command logs byte-for-byte - the report's P1 checklist # item "run current main scripts and refactored scripts against the same -# fake tools and compare command logs". +# fake tools and compare command logs". The teardown old-vs-new case also +# overlays a content-historical permissive tmux kill fixture: after the +# exact-selector change lands on the default branch, merge-base with main +# collapses to HEAD and can no longer supply that baseline. # 3. Asserts the `--backend`/`FM_BACKEND` selection refuses unknown backends # and the blocked `codex-app` backend loudly. # @@ -80,6 +83,9 @@ SH } # The commit this branch started from - the P1 "current main" baseline. +# Suitable for byte-identical old-vs-new checks while a branch still diverges +# from main. After a squash lands, merge-base(HEAD, main) collapses to HEAD, so +# callers that need a true pre-change fixture must not rely on this alone. resolve_base_ref() { local ref base for ref in main refs/heads/main origin/main refs/remotes/origin/main origin/HEAD refs/remotes/origin/HEAD; do @@ -95,6 +101,30 @@ resolve_base_ref() { BASE_REF=$(resolve_base_ref) \ || fail "fm-backend baseline requires local main or origin/main; fetch the default branch before running this test" +# Newest first-parent revision whose bin/backends/tmux.sh still uses the +# pre-exact permissive kill-window target. Content-addressed from history so the +# fixture stays historical on default-branch CI and on branches cut after the +# exact-selector change, where merge-base with main is self-referential. +resolve_permissive_tmux_kill_ref() { + local commit body + while IFS= read -r commit; do + [ -n "$commit" ] || continue + body=$(git -C "$ROOT" show "$commit:bin/backends/tmux.sh" 2>/dev/null) || continue + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "=$session:=$window"'*) continue ;; + esac + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) + printf '%s\n' "$commit" + return 0 + ;; + esac + done < <(git -C "$ROOT" log --first-parent --format='%H' HEAD -- bin/backends/tmux.sh) + return 1 +} + # --- shared: a pre-refactor bin/ shim -------------------------------------- # # build_old_bin echoes a directory whose bin/ subdir holds the PRE-REFACTOR @@ -929,10 +959,52 @@ run_teardown_case() { "$script" "$id" } +test_permissive_tmux_kill_ref_stays_historical() { + local ref body_hist body_head head + head=$(git -C "$ROOT" rev-parse HEAD) + ref=$(resolve_permissive_tmux_kill_ref) \ + || fail "unable to locate a historical bin/backends/tmux.sh with permissive kill-window selectors" + body_hist=$(git -C "$ROOT" show "$ref:bin/backends/tmux.sh") \ + || fail "could not read historical tmux adapter at $ref" + body_head=$(cat "$ROOT/bin/backends/tmux.sh") + + # shellcheck disable=SC2016 + case "$body_hist" in + *'tmux kill-window -t "=$session:=$window"'*) + fail "resolve_permissive_tmux_kill_ref returned exact selectors at $ref" + ;; + esac + # shellcheck disable=SC2016 + case "$body_hist" in + *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) ;; + *) fail "historical tmux adapter at $ref lacks a permissive kill-window target" ;; + esac + # shellcheck disable=SC2016 + case "$body_head" in + *'tmux kill-window -t "=$session:=$window"'*) ;; + *) fail "current tmux adapter lost exact kill-window selectors" ;; + esac + [ "$ref" != "$head" ] \ + || fail "permissive tmux baseline collapsed to HEAD; fixture is no longer historical" + + pass "historical permissive tmux kill baseline stays distinct from current exact selectors" +} + test_teardown_conformance_old_vs_new() { - local old_bin fb proj wt id + local old_bin fb proj wt id old_tmux_ref saved_base_ref local state_old state_new config_old config_new data log_old log_new out_old out_new rc_old rc_new + # Force the post-squash topology inside this case: merge-base with main may + # equal HEAD on default-branch CI, and that must not make the legacy kill + # fixture self-referential. build_old_bin still uses BASE_REF for entrypoints; + # only the tmux kill adapter is pinned to the content-historical permissive ref. + saved_base_ref=$BASE_REF + BASE_REF=$(git -C "$ROOT" rev-parse HEAD) + old_tmux_ref=$(resolve_permissive_tmux_kill_ref) \ + || { BASE_REF=$saved_base_ref; fail "unable to locate a historical bin/backends/tmux.sh with permissive kill-window selectors"; } old_bin=$(build_old_bin teardown-old) + git -C "$ROOT" show "$old_tmux_ref:bin/backends/tmux.sh" > "$old_bin/bin/backends/tmux.sh" \ + || { BASE_REF=$saved_base_ref; fail "could not materialize historical tmux adapter from $old_tmux_ref"; } + BASE_REF=$saved_base_ref proj="$TMP_ROOT/teardown-project"; wt="$TMP_ROOT/teardown-wt" id="teardownconform1" fm_git_worktree "$proj" "$wt" "fm/$id" @@ -1106,6 +1178,7 @@ test_backend_of_selector_matches_explicit_target_meta test_send_conformance_old_vs_new test_peek_conformance_old_vs_new test_spawn_symlinked_project_prefix_avoids_false_refusal +test_permissive_tmux_kill_ref_stays_historical test_teardown_conformance_old_vs_new test_spawn_refuses_unknown_backend_flag test_spawn_refuses_codex_app_backend_flag diff --git a/tests/fm-inbox-arm.test.sh b/tests/fm-inbox-arm.test.sh new file mode 100755 index 0000000..e572a45 --- /dev/null +++ b/tests/fm-inbox-arm.test.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# Behavior tests for the captain-inbox answer relay (bin/fm-inbox-arm.sh) and +# the bounded check it registers. Proves the relay is a valid, registered, +# fail-closed watcher check that stays silent until the board is actually served. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ARM="$ROOT/bin/fm-inbox-arm.sh" +SERVE="$ROOT/bin/fm-inbox-serve.sh" +TMP_ROOT=$(fm_test_tmproot fm-inbox-arm) + +# check_registered : true when the watcher would execute the +# check, using the same trust primitives the watcher itself uses. +check_registered() { + bash -c ' + ROOT=$1; state=$2; id=$3 + # shellcheck disable=SC1090 + . "$ROOT/bin/fm-pr-lib.sh" + . "$ROOT/bin/fm-check-lib.sh" + fm_custom_check_registered "$state" "$id" + ' _ "$ROOT" "$1" "$2" +} + +make_home() { # + local home=$TMP_ROOT/$1 + mkdir -p "$home/state" + printf 'board' > "$home/board.html" + printf '%s\n' "$home" +} + +file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f '%Lp' "$1" + else + stat -c '%a' "$1" + fi +} + +test_help_exits_zero() { + local out + out=$("$ARM" --help) || fail "--help must exit 0" + assert_contains "$out" "answer relay" "--help must describe the relay" + pass "--help prints the header and exits 0" +} + +test_no_arg_refuses() { + local code=0 + "$ARM" >/dev/null 2>&1 || code=$? + expect_code 2 "$code" "arming with no board path must refuse" + pass "a missing board path refuses" +} + +test_writes_and_registers_a_valid_check() { + local home out + home=$(make_home valid) + out=$(FM_HOME="$home" "$ARM" "$home/board.html") || fail "arming must succeed" + assert_contains "$out" "armed:" "arming must confirm" + assert_present "$home/state/inbox.check.sh" "the relay file must be written" + assert_present "$home/state/inbox.check-trust" "the relay must be registered" + [ "$(file_mode "$home/state/inbox.check.sh")" = 700 ] \ + || fail "the relay must be mode 0700" + check_registered "$home/state" inbox || fail "the watcher must accept the relay" + pass "arming writes a mode-0700 relay and registers it for the watcher" +} + +test_registration_rejects_tampering() { + local home + home=$(make_home tamper) + FM_HOME="$home" "$ARM" "$home/board.html" >/dev/null || fail "arming must succeed" + check_registered "$home/state" inbox || fail "the fresh relay must be accepted" + printf '\n# tampered\n' >> "$home/state/inbox.check.sh" + if check_registered "$home/state" inbox; then + fail "a byte change to the relay must invalidate its registration" + fi + pass "editing the relay after registration invalidates it" +} + +test_relay_is_silent_when_board_unserved() { + local home fakebin out + home=$(make_home silent) + fakebin=$(fm_fakebin "$home") + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +exit 7 +SH + cat > "$fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +printf 'poll invoked\n' > "$POLL_MARKER" +exit 1 +SH + chmod +x "$fakebin/curl" "$fakebin/lavish-axi" + FM_HOME="$home" "$ARM" "$home/board.html" >/dev/null || fail "arming must succeed" + out=$(POLL_MARKER="$home/poll-invoked" PATH="$fakebin:$PATH" \ + bash "$home/state/inbox.check.sh") + [ -z "$out" ] || fail "an unserved relay must print nothing, got: $out" + assert_absent "$home/poll-invoked" \ + "an unserved relay must not invoke lavish-axi poll" + assert_absent "$home/state/inbox-answers" \ + "an unserved relay must not create an answers directory" + pass "the relay stays silent and starts no server while the board is unserved" +} + +test_relay_targets_the_board_path() { + local home + home=$(make_home path) + FM_HOME="$home" "$ARM" "$home/board.html" >/dev/null || fail "arming must succeed" + assert_grep "$home/board.html" "$home/state/inbox.check.sh" \ + "the relay must poll the exact board it was armed for" + assert_grep "state/inbox-answers" "$home/state/inbox.check.sh" \ + "the relay must record answers durably for firstmate to pick up" + pass "the relay is bound to its board and a durable answer drop" +} + +test_relay_rejects_linked_check_path_without_writing_through() { + local home peer out + home=$(make_home linked) + peer="$home/external.txt" + printf 'external sentinel\n' > "$peer" + ln -s "$peer" "$home/state/inbox.check.sh" + if out=$(FM_HOME="$home" "$ARM" "$home/board.html" 2>&1); then + fail "arming must refuse a linked relay path: $out" + fi + [ "$(cat "$peer")" = "external sentinel" ] \ + || fail "a linked relay path must not be written through" + [ -L "$home/state/inbox.check.sh" ] || fail "the rejected symlink must remain" + assert_absent "$home/state/inbox.check-trust" \ + "a rejected relay path must not be registered" + + rm -f -- "$home/state/inbox.check.sh" + printf 'hardlink sentinel\n' > "$peer" + ln "$peer" "$home/state/inbox.check.sh" + if out=$(FM_HOME="$home" "$ARM" "$home/board.html" 2>&1); then + fail "arming must refuse a hardlinked relay path: $out" + fi + [ "$(cat "$peer")" = "hardlink sentinel" ] \ + || fail "a hardlinked relay path must not be written through" + assert_absent "$home/state/inbox.check-trust" \ + "a rejected hardlinked relay path must not be registered" + pass "arming refuses linked relay paths without writing through them" +} + +test_relay_escapes_paths_and_exports_port() { + local home fakebin capture actual expected + home=$(make_home "quote'path") + fakebin=$(fm_fakebin "$home") + capture="$home/poll-capture.txt" + cat > "$fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n%s\n' "${LAVISH_AXI_PORT:-}" "${2:-}" > "$CAPTURE" +exit 1 +SH + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/lavish-axi" "$fakebin/curl" + FM_HOME="$home" "$ARM" --port 5173 "$home/board.html" >/dev/null \ + || fail "arming a quoted path must succeed" + bash -n "$home/state/inbox.check.sh" || fail "the relay must be valid bash" + CAPTURE="$capture" PATH="$fakebin:$PATH" bash "$home/state/inbox.check.sh" + actual=$(cat "$capture") + expected=$(printf '5173\n%s' "$home/board.html") + [ "$actual" = "$expected" ] || fail "the relay must preserve its port and board path" + pass "the relay shell-escapes paths and exports its target port" +} + +test_relay_wakes_once_for_feedback() { + local home fakebin out answers count answer_file + home=$(make_home feedback) + fakebin=$(fm_fakebin "$home") + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +printf 'status: feedback\n' +printf ' decision,"DECISION task: ship it"\n' +exit 0 +SH + chmod +x "$fakebin/curl" "$fakebin/lavish-axi" + FM_HOME="$home" "$ARM" "$home/board.html" >/dev/null || fail "arming must succeed" + out=$(PATH="$fakebin:$PATH" bash "$home/state/inbox.check.sh") + [ "$out" = "inbox board: a captain answer is waiting in state/inbox-answers" ] \ + || fail "feedback must emit exactly one fixed wake line, got: $out" + answers=$home/state/inbox-answers + assert_present "$answers" "feedback must create the answer drop" + count=$(find "$answers" -type f | wc -l | tr -d ' ') + [ "$count" = 1 ] || fail "feedback must write exactly one answer file, got $count" + [ "$(file_mode "$answers")" = 700 ] || fail "the answer drop must be mode 0700" + answer_file=$(find "$answers" -type f -name '*.txt' -print | head -n1) + [ -n "$answer_file" ] || fail "feedback must write a named answer file" + [ "$(file_mode "$answer_file")" = 600 ] || fail "the answer file must be mode 0600" + assert_grep "DECISION task: ship it" "$answer_file" \ + "the answer file must preserve the captain feedback" + pass "the relay emits one fixed wake line for feedback" +} + +test_relay_rejects_linked_answer_drop_with_diagnostic() { + local home fakebin out peer_count + home=$(make_home linked-answers) + fakebin=$(fm_fakebin "$home") + mkdir -p "$home/external-answers" + ln -s "$home/external-answers" "$home/state/inbox-answers" + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +printf 'status: feedback\n' +printf ' decision,"DECISION task: keep private"\n' +exit 0 +SH + chmod +x "$fakebin/curl" "$fakebin/lavish-axi" + FM_HOME="$home" "$ARM" "$home/board.html" >/dev/null || fail "arming must succeed" + out=$(PATH="$fakebin:$PATH" bash "$home/state/inbox.check.sh") + [ "$out" = "inbox board error: could not record captain answer in state/inbox-answers" ] \ + || fail "unsafe answer drop must emit one diagnostic wake, got: $out" + [ -L "$home/state/inbox-answers" ] || fail "the unsafe answer drop symlink must remain untouched" + peer_count=$(find "$home/external-answers" -type f | wc -l | tr -d ' ') + [ "$peer_count" = 0 ] || fail "the relay must not write through a linked answer drop" + pass "the relay refuses linked answer drops with a diagnostic wake" +} + +test_serve_passes_resolved_port_to_relay() { + local home fakebin out + home=$(make_home serveport) + fakebin=$(fm_fakebin "$home") + cat > "$fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +set -u +printf 'url: "http://%s:%s/session/abc123"\n' "${LAVISH_AXI_LINK_HOST:-127.0.0.1}" "${LAVISH_AXI_PORT:-4387}" +SH + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +printf '200' +SH + chmod +x "$fakebin/lavish-axi" "$fakebin/curl" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" LAVISH_AXI_PORT=5199 \ + "$SERVE" --no-generate --link-host 100.64.0.1 "$home/board.html" 2>&1) \ + || fail "serving must succeed: $out" + assert_contains "$out" "reachable: yes" "the served board must be verified" + assert_grep "export LAVISH_AXI_PORT=5199" "$home/state/inbox.check.sh" \ + "serving must arm the relay against the served port" + pass "serve passes its resolved port into the armed relay" +} + +test_help_exits_zero +test_no_arg_refuses +test_writes_and_registers_a_valid_check +test_registration_rejects_tampering +test_relay_is_silent_when_board_unserved +test_relay_targets_the_board_path +test_relay_rejects_linked_check_path_without_writing_through +test_relay_escapes_paths_and_exports_port +test_relay_wakes_once_for_feedback +test_relay_rejects_linked_answer_drop_with_diagnostic +test_serve_passes_resolved_port_to_relay diff --git a/tests/fm-inbox-view.test.sh b/tests/fm-inbox-view.test.sh new file mode 100755 index 0000000..2eec7b5 --- /dev/null +++ b/tests/fm-inbox-view.test.sh @@ -0,0 +1,448 @@ +#!/usr/bin/env bash +# Behavior tests for the read-only captain decision-and-review board. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +VIEW="$ROOT/bin/fm-inbox-view.sh" +TMP_ROOT=$(fm_test_tmproot fm-inbox-view) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } +command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found"; exit 0; } + +make_fakebin() { # + local fb + fb=$(fm_fakebin "$1") + cat > "$fb/no-mistakes" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + # tasks-axi stub: only `show --full` is used, and it returns text that is + # deliberately longer and comma-bearing so a truncating reader is detectable. + cat > "$fb/tasks-axi" <<'SH' +#!/usr/bin/env bash +set -u +[ "${1:-}" = "show" ] || exit 1 +printf 'task:\n' +printf ' id: %s\n' "$2" +printf ' title: "Full title for %s"\n' "$2" +case "$2" in + hidden-decision) + printf ' hold_reason: "First clause, second clause, and the FULLTEXTMARKER tail."\n' + ;; + *) + printf ' hold_reason: "Recorded note for %s, with a comma."\n' "$2" + ;; +esac +printf ' hold_kind: captain\n' +printf ' body: "Origin: some-scout\\nDecision key: k\\nState: awaiting captain decision."\n' +exit 0 +SH + chmod +x "$fb/no-mistakes" "$fb/tmux" "$fb/tasks-axi" + printf '%s\n' "$fb" +} + +make_home() { # + local home=$TMP_ROOT/$1 + mkdir -p "$home/state" "$home/data" "$home/projects" "$home/config" + printf '%s\n' "$home" +} + +write_fixture() { # + local home=$1 + mkdir -p "$home/projects/alpha-worktree" + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] running-task - Running Task (repo: alpha) (kind: ship) (since 2026-07-07) + +## Queued +- [ ] hidden-decision - Hidden Decision (repo: alpha) (kind: ship) (since 2026-07-01) (hold: short snapshot text) (hold-kind: captain) +- [ ] plain-decision - Plain Decision (repo: beta) (kind: captain) (since 2026-07-02) (hold: another note) (hold-kind: captain) +- [ ] workflowy-partner-idea - Workflowy Partner Idea (kind: captain) (since 2026-07-02) (hold: a no-project decision) (hold-kind: captain) +- [ ] blocked-decision - Blocked Decision blocked-by: running-task (repo: alpha) (kind: captain) (since 2026-07-03) (hold: waiting note) (hold-kind: captain) +- [ ] pr-task - PR Task (repo: alpha) (kind: ship) (since 2026-07-05) + +## Done +- [x] shipped-task - Shipped Task https://github.com/kunchenguid/firstmate/pull/7 (repo: alpha) (kind: ship) (merged 2026-07-06unsafe) +EOF + fm_write_meta "$home/state/pr-task.meta" \ + "window=firstmate:fm-pr-task" \ + "worktree=$home/projects/alpha-worktree" \ + "project=alpha" \ + "harness=codex" \ + "kind=ship" \ + "mode=ship" \ + "yolo=off" \ + "pr=https://github.com/kunchenguid/firstmate/pull/920" + fm_write_meta "$home/state/shipped-task.meta" \ + "window=firstmate:fm-shipped-task" \ + "worktree=$home/projects/alpha-worktree" \ + "project=alpha" \ + "harness=codex" \ + "kind=ship" \ + "mode=ship" \ + "yolo=off" \ + "pr=https://github.com/kunchenguid/firstmate/pull/7" +} + +write_cards() { # + cat > "$1" <<'EOF' +## hidden-decision +### question +Should we do the plain-English thing? +### plain +This is what you are actually choosing. +### why +It came from an investigation that hit this fork in the road. +### take +Do it, it is cheap. +### link +https://example.invalid/product +### options +- Yes, do it +- No, drop it +### expand +The deep technical detail nobody needs up front. +### flag +Firstmate assumed this because nobody ever said otherwise; accept it or drop it. +EOF +} + +# section_of : print one rendered section so an assertion can +# target it instead of the whole page. +section_of() { + python3 - "$1" "$2" <<'PY' +import re, sys + +src = open(sys.argv[1], encoding="utf-8").read() +match = re.search( + r'
    ' % re.escape(sys.argv[2]), + src, + re.S, +) +sys.stdout.write(match.group(0) if match else "") +PY +} + +# manifest : content hash of every file under state/ and data/, so a test +# can prove the board generator mutated nothing. +manifest() { + local home=$1 f + ( cd "$home" && find state data -type f 2>/dev/null | LC_ALL=C sort ) | while IFS= read -r f; do + [ -n "$f" ] || continue + printf '%s %s\n' "$(shasum -a 256 "$home/$f" | cut -d' ' -f1)" "$f" + done +} + +test_help_exits_zero() { + local out + out=$("$VIEW" --help) || fail "--help must exit 0" + assert_contains "$out" "READ-ONLY projection" "--help must print the script header" + assert_contains "$out" "--verify-prs" "--help must document its options" + pass "--help prints the header and exits 0" +} + +test_selects_captain_holds_regardless_of_kind() { + local home fakebin out board + home=$(make_home selection) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + board=$home/board.html + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" "$board" 2>&1) \ + || fail "generator must succeed: $out" + + # The snapshot's own captain_actionable also requires kind == "captain", which + # hides every thread gated with `tasks-axi hold --kind captain`. hidden-decision + # is exactly that shape and must still reach the board. + assert_grep 'id="decision-hidden-decision"' "$board" \ + "a captain hold on a ship-kind item must appear in Decide" + assert_grep 'id="decision-plain-decision"' "$board" \ + "a captain-kind captain hold must appear in Decide" + assert_no_grep 'id="decision-blocked-decision"' "$board" \ + "a captain hold with an unresolved blocker must not appear in Decide" + assert_grep '3 decisions' "$board" "the Decide count must match the selection" + pass "Decide selects on the captain hold alone and excludes blocked holds" +} + +test_uses_untruncated_hold_text() { + local home fakebin board + home=$(make_home fulltext) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + assert_grep 'FULLTEXTMARKER' "$board" \ + "decision text must come from tasks-axi show --full, not the truncated snapshot" + + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --no-full-text "$board" >/dev/null 2>&1 \ + || fail "--no-full-text must succeed" + assert_no_grep 'FULLTEXTMARKER' "$board" \ + "--no-full-text must fall back to the snapshot text" + pass "full decision text is read through tasks-axi and can be opted out of" +} + +test_recorded_pr_is_marked_unverified() { + local home fakebin board review + home=$(make_home prstate) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + review=$(section_of "$board" review) + assert_contains "$review" "pull/920" "an unlanded recorded pull request belongs in review" + assert_contains "$review" "it may already be closed or merged" \ + "an unverified pull request must say so rather than read as ready to merge" + assert_not_contains "$review" "pull/7\"" \ + "a pull request on landed work must not appear in the review section" + assert_contains "$(section_of "$board" shipped)" "pull/7" \ + "a landed pull request stays linked from the shipped section" + pass "recorded pull requests render as unverified and landed ones stay out" +} + +test_unknown_pr_verification_stays_reviewable() { + local home fakebin board review + home=$(make_home prunknown) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$fakebin/gh" + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --verify-prs "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + review=$(section_of "$board" review) + assert_contains "$review" "pull/920" \ + "a pull request with an unknown verification result must stay reviewable" + assert_contains "$review" "could not be checked" \ + "an unknown verification result must be marked as unchecked" + assert_not_contains "$review" "already unknown" \ + "an unknown verification result must not be classified as stale" + pass "unknown PR verification stays in Review and merge" +} + +test_shipped_completion_metadata_is_escaped() { + local home fakebin board shipped + home=$(make_home shippedescape) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + shipped=$(section_of "$board" shipped) + assert_contains "$shipped" "2026-07-06<b>unsafe</b>" \ + "completion metadata must be escaped in shipped rows" + assert_not_contains "$shipped" "2026-07-06unsafe" \ + "completion metadata must not render as markup" + assert_contains "$shipped" '/dev/null 2>&1 \ + || fail "generator must succeed" + assert_grep 'Should we do the plain-English thing?' "$board" "the plain question must lead the card" + assert_grep 'This is what you are actually choosing.' "$board" "In plain terms must render" + assert_grep 'It came from an investigation' "$board" "the why must render" + assert_grep 'Do it, it is cheap.' "$board" "the recommendation must render" + assert_grep 'https://example.invalid/product' "$board" "the research link must render" + assert_grep 'The deep technical detail' "$board" "the expandable detail must render" + assert_grep "firstmate's assumption, not your call" "$board" \ + "an assumption must be flagged as such" + assert_grep 'no plain-English summary written yet' "$board" \ + "a decision with no card must declare that rather than look complete" + pass "decision cards render in full and missing cards are declared" +} + +test_answer_control_queues_once_per_question() { + local home fakebin board queue_calls + home=$(make_home answers) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + assert_grep 'data-lavish-question="hidden-decision"' "$board" \ + "each decision needs its own question wrapper" + assert_grep 'onsubmit=' "$board" "answers are queued on submit, not on radio change" + assert_no_grep 'onchange=' "$board" "a radio change must not queue a prompt" + queue_calls=$(grep -c -F 'window.lavish.queuePrompt(text' "$board") + [ "$queue_calls" -eq 1 ] \ + || fail "one shared queue call is expected, not one per card, got $queue_calls" + pass "answers queue exactly once per question, on submit" +} + +test_free_text_answer_is_first_class() { + local home fakebin cards board + home=$(make_home freeform) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + cards=$home/cards.md + write_cards "$cards" + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" --cards "$cards" "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + # A free-text answer must submit on its own: the guard blocks only when BOTH + # the note and any picked option are empty, never on a missing option alone. + assert_grep 'if(!choice&&!note){' "$board" \ + "the answer must submit with just free text, not require an option" + assert_no_grep 'var answer=data.get(' "$board" \ + "the old option-required submit path must be gone" + assert_grep 'You do not have to pick an option above.' "$board" \ + "the free-text box must say an option is not required" + # A card with options still offers them as quick-picks with a clear-selection + # reset, so a picked radio is never a trap. + assert_grep 'name="answer" value="Yes, do it"' "$board" \ + "predefined options remain available as quick-picks" + assert_grep 'fmInboxClear(this)' "$board" "a clear-selection reset must exist" + pass "free text answers stand alone and options are optional quick-picks" +} + +test_discuss_path_present() { + local home fakebin board + home=$(make_home discuss) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + assert_grep 'data-lavish-question="hidden-decision-discuss"' "$board" \ + "each decision needs a discuss path distinct from its answer" + assert_grep 'fmInboxDiscuss' "$board" "the discuss control must be wired" + assert_grep "'DISCUSS '+hold" "$board" \ + "a question must be relayed as a discussion, not an answer" + assert_grep 'Ask a question instead' "$board" "the discuss affordance must be visible" + pass "every decision offers a discuss/clarify path" +} + +test_answer_sends_immediately() { + local home fakebin board + home=$(make_home immediate) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + # The submit both queues and sends, so an answer never waits on a separate + # Send button the captain has to find - the silently-eaten-answers failure. + assert_grep 'window.lavish.sendQueuedPrompts()' "$board" \ + "a submitted answer must be sent, not left queued" + assert_grep 'Sent to firstmate.' "$board" "the card must confirm delivery" + pass "a submitted answer is sent immediately with visible confirmation" +} + +test_no_project_items_get_readable_names() { + local home fakebin board + home=$(make_home areas) + write_fixture "$home" + fakebin=$(make_fakebin "$home") + board=$home/board.html + PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW" "$board" >/dev/null 2>&1 \ + || fail "generator must succeed" + # workflowy-partner-idea has no repo; it must read as a named area, never as + # the useless generic "no project" badge. + assert_grep 'id="decision-workflowy-partner-idea"' "$board" \ + "the no-project decision must render" + assert_grep '