From 801861d3be9966382b8036e8fb31829395227936 Mon Sep 17 00:00:00 2001 From: Flo Hart Date: Fri, 24 Jul 2026 14:28:28 +0200 Subject: [PATCH 1/6] feat(inbox): add the captain's decision and review board generator bin/fm-inbox-view.sh writes one self-contained HTML board from live fleet state, so every open captain decision, review-ready pull request and stopped task is answerable from a single surface instead of arriving one chat interrupt at a time. It is a read-only projection in the same shape as fm-fleet-view.sh: it shells out to fm-fleet-snapshot.sh --json and `tasks-axi show --full`, and writes nothing but the board file. A test proves state/ and data/ stay byte-identical across a generation. Three selection defects the existing aggregated surface has are fixed here. Decisions are selected on the captain hold alone, because the snapshot's own captain_actionable flag also requires kind == "captain" and therefore hides every thread gated with `tasks-axi hold --kind captain`, which is where the oldest waiting decisions live. Decision text is read through tasks-axi, because the snapshot's metadata capture stops at the first comma and silently drops the options from any hold reason that lists them. A recorded pull request renders as unverified unless --verify-prs live-checks it, because locally recorded metadata cannot know a pull request was closed and a closed one must never read as ready to merge. Decision cards lead with plain English. An optional captain-private cards file supplies the plain question, what the captain is actually choosing, where it came from, firstmate's recommendation, a link to research a product or service, and a collapsed technical section. An item that is firstmate's own assumption rather than a choice the captain made is flagged as one. A decision with no card still renders and says so rather than looking complete. Answers use the Lavish input contract: native radios plus a free-text note, with exactly one queued prompt per question on submit. This change delivers the surface only; regenerating on a cadence, relaying answers back, and any escalation-routing change remain separate captain decisions. --- bin/fm-inbox-render.py | 1090 +++++++++++++++++++++++++++++++++++ bin/fm-inbox-view.sh | 193 +++++++ bin/fm-test-run.sh | 2 +- tests/fm-inbox-view.test.sh | 306 ++++++++++ 4 files changed, 1590 insertions(+), 1 deletion(-) create mode 100755 bin/fm-inbox-render.py create mode 100755 bin/fm-inbox-view.sh create mode 100755 tests/fm-inbox-view.test.sh diff --git a/bin/fm-inbox-render.py b/bin/fm-inbox-render.py new file mode 100755 index 00000000000..5c540d236bf --- /dev/null +++ b/bin/fm-inbox-render.py @@ -0,0 +1,1090 @@ +#!/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" + +# -------------------------------------------------------------------------- +# 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] + + +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": project_of(record, task), + "state": state, + "verified": bool(pr_verified and state), + } + if row["verified"] and state not in ("OPEN",): + 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 = card.get("options") or [ + "Go with firstmate's recommendation", + "Choose differently (say how in the note)", + "Not now, ask me again later", + ] + + 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": project_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; +} +.wrap{max-width:56rem; margin:0 auto; padding:2.5rem 1.25rem 5rem} +/* Long ids, URLs and hold text must wrap rather than widen the page at any + nesting level; wide blocks scroll inside their own container instead. */ +a{color:var(--accent); overflow-wrap:anywhere} +.card,.field,.field p,.flag,form.answer{min-width:0; overflow-wrap:anywhere} +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; +} +form.answer input[type=radio]{margin-top:.35rem; flex:none} +form.answer 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; +} +form.answer .send{display:flex; align-items:center; gap:.75rem; margin-top:.75rem; flex-wrap:wrap} +form.answer button{ + 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 .queued{font-size:.84rem; color:var(--ok)} + +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 = """ +function fmInboxQueue(form){ + var data=new FormData(form); + var answer=data.get('answer'); + if(!answer){return;} + var note=(data.get('note')||'').trim(); + var id=form.dataset.lavishQuestion; + var question=form.dataset.question||id; + var text='DECISION '+id+': '+answer+(note?' -- '+note:''); + var mark=form.querySelector('.queued'); + if(window.lavish&&window.lavish.queuePrompt){ + window.lavish.queuePrompt(text,{ + tag:'decision', + queueKey:id, + element:form, + text:question+' -> '+answer, + data:{hold:id,question:question,answer:answer,note:note} + }); + if(mark){mark.textContent='Queued. Press Send to Agent when you are done.';} + }else if(mark){ + mark.textContent='Open this board through Lavish to send answers.'; + } +} +""" + + +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) + ) + + opts = [] + for index, option in enumerate(view["options"]): + opts.append( + '" + % (esc(option), "" if index else "", esc(option)) + ) + parts.append( + '
' + '

Your answer

%s' + '' + '
' + '
' + % (esc(view["id"]), esc(view["question"]), "".join(opts)) + ) + parts.append("
") + return "".join(parts) + + +def render_row(project, who, what, sub=""): + return ( + '
  • %s' + '%s%s
  • ' + % ( + esc(project), + esc(who), + what, + '%s' % sub if 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(project_of(record, task)) + for record, task in model["underway"]: + projects.add(project_of(record, task)) + for record in model["queued"] + model["shipped"]: + projects.add(project_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"], + esc(row["title"]), + '%s · %s' + % (esc(row["url"]), esc(row["url"]), esc(note)), + ) + ) + if rows: + body = '' % "".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:

    ' % 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( + project_of(record, task), + project_of(record, task), + esc(record.get("title") or record.get("id")), + esc("%s, started %s" % (state, record.get("since") or "recently")), + ) + ) + blocks.append( + section( + "underway", + "Underway", + "Being worked on right now. Nothing for you to do.", + '' % "".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( + project_of(record, task), + project_of(record, task), + esc(record.get("title") or record.get("id")), + esc(sub), + ) + ) + blocks.append( + section( + "queued", + "Queued", + "Lined up behind something else, or parked on purpose.", + '' % "".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' % ( + esc(record["pr_url"]), + esc(record["pr_url"]), + ) + rows.append( + render_row( + project_of(record, task), + project_of(record, task), + esc(record.get("title") or record.get("id")), + sub, + ) + ) + else: + rows.append( + render_row( + project_of(record, task), + project_of(record, task), + esc(record.get("title") or record.get("id")), + esc(sub), + ) + ) + blocks.append( + section( + "shipped", + "Recently built and shipped", + "Landed work, most recent first.", + '' % "".join(rows) + if rows + else '

    Nothing has landed yet.

    ', + ) + ) + + # Stuck + rows = [] + for record, task, reasons in model["stuck"]: + rows.append( + render_row( + project_of(record, task), + project_of(record, task), + esc(record.get("title") or record.get("id")), + esc("; ".join(reasons)), + ) + ) + blocks.append( + section( + "stuck", + "Stuck", + "Stopped and not going to move on its own.", + '' % "".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-view.sh b/bin/fm-inbox-view.sh new file mode 100755 index 00000000000..33a420942be --- /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 28be3a25c77..0460474ec72 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -172,7 +172,7 @@ 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) printf '%s\n' snapshot-bearings ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) diff --git a/tests/fm-inbox-view.test.sh b/tests/fm-inbox-view.test.sh new file mode 100755 index 00000000000..2a6561fb2de --- /dev/null +++ b/tests/fm-inbox-view.test.sh @@ -0,0 +1,306 @@ +#!/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) +- [ ] 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-06) +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 '2 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_cards_render_and_absence_is_declared() { + local home fakebin board cards + home=$(make_home cards) + 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" + 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_filters_are_present() { + local home fakebin board + home=$(make_home filters) + 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-type-filter="decisions"' "$board" "a decisions-only filter must exist" + assert_grep 'data-type-filter="shipped"' "$board" "a shipped-only filter must exist" + assert_grep 'id="project-filter"' "$board" "a per-project filter must exist" + assert_grep '