scripts/agenda_builder.py
8.9 KB| 1 | #!/usr/bin/env python3 |
| 2 | """agenda_builder.py — Build a timeboxed, decision-first agenda where every topic has an outcome. |
| 3 | |
| 4 | The `meetings` skill's second discipline. Feed it topics as |
| 5 | --topic "title:desired_outcome:minutes:owner" |
| 6 | plus the total meeting length (--length). It enforces the agenda canon deterministically: |
| 7 | |
| 8 | 1. NO DESIRED OUTCOME, NO AGENDA SLOT — any topic with an empty outcome is refused, by name. |
| 9 | 2. TIMEBOXES ARE A BUDGET — topic timeboxes + a mandatory 5-minute closing "actions recap" |
| 10 | buffer must fit --length, or the agenda is refused with the exact overflow. |
| 11 | 3. DECISIONS FIRST — topics whose outcome starts with decide/choose/approve sort before |
| 12 | discuss/inform topics, so the decision gets fresh brains, not the last five minutes. |
| 13 | 4. PRE-READ — the agenda opens with a pre-read line (circulate ahead; the meeting assumes it |
| 14 | was read) and closes with the actions-recap slot (every action gets an owner + a date). |
| 15 | |
| 16 | NO LLM CALLS. Pure parsing + ordering + arithmetic. Stdlib only. Nothing is sent or scheduled. |
| 17 | |
| 18 | Usage: |
| 19 | python agenda_builder.py --length 45 \\ |
| 20 | --topic "Q3 pricing:Decide usage-based vs seat-based:15:maria" \\ |
| 21 | --topic "Launch risks:Discuss open launch blockers:15:sam" |
| 22 | python agenda_builder.py --sample |
| 23 | python agenda_builder.py --sample --json |
| 24 | """ |
| 25 | |
| 26 | import argparse |
| 27 | import json |
| 28 | import sys |
| 29 | from typing import Any, Dict, List |
| 30 | |
| 31 | CLOSING_BUFFER_MIN = 5 # mandatory closing "actions recap" slot |
| 32 | DECISION_PREFIXES = ("decide", "choose", "approve") |
| 33 | |
| 34 | EPILOG = """\ |
| 35 | exit codes: |
| 36 | 0 agenda built (timeboxes + 5-min closing buffer fit --length; all outcomes present) |
| 37 | 1 usage error — malformed --topic spec or bad/missing flags (never a verdict) |
| 38 | 2 refused — one or more topics has an empty desired outcome (named in output) |
| 39 | 3 refused — timeboxes + 5-min closing buffer exceed --length (overflow named in output) |
| 40 | |
| 41 | --help and --sample exit 0. |
| 42 | """ |
| 43 | |
| 44 | |
| 45 | def parse_topic(spec: str) -> Dict[str, Any]: |
| 46 | # Split the two structured fields (minutes, owner) from the right, then the |
| 47 | # title at the first colon — so a desired outcome may itself contain colons. |
| 48 | parts = spec.rsplit(":", 2) |
| 49 | if len(parts) != 3 or ":" not in parts[0]: |
| 50 | raise ValueError( |
| 51 | f'malformed --topic "{spec}" — expected 4 colon-separated fields: ' |
| 52 | '"title:desired_outcome:minutes:owner" (colons are allowed inside ' |
| 53 | "desired_outcome; the title must not contain one)" |
| 54 | ) |
| 55 | head, minutes_raw, owner = parts |
| 56 | title, outcome = head.split(":", 1) |
| 57 | title, outcome, minutes_raw, owner = (p.strip() for p in (title, outcome, minutes_raw, owner)) |
| 58 | try: |
| 59 | minutes = int(minutes_raw) |
| 60 | except ValueError: |
| 61 | raise ValueError(f'malformed --topic "{spec}" — minutes must be an integer, got "{minutes_raw}"') |
| 62 | if minutes < 1: |
| 63 | raise ValueError(f'malformed --topic "{spec}" — minutes must be >= 1') |
| 64 | if not title: |
| 65 | raise ValueError(f'malformed --topic "{spec}" — title is empty') |
| 66 | is_decision = outcome.lower().startswith(DECISION_PREFIXES) |
| 67 | return {"title": title, "outcome": outcome, "minutes": minutes, "owner": owner, |
| 68 | "kind": "decision" if is_decision else "discuss/inform"} |
| 69 | |
| 70 | |
| 71 | def build(topics: List[Dict[str, Any]], length: int) -> Dict[str, Any]: |
| 72 | empty = [t["title"] for t in topics if not t["outcome"]] |
| 73 | if empty: |
| 74 | return { |
| 75 | "status": "REFUSED-NO-OUTCOME", |
| 76 | "exit_code": 2, |
| 77 | "offenders": empty, |
| 78 | "message": ("No desired outcome, no agenda slot. Topics refused: " |
| 79 | + ", ".join(f'"{t}"' for t in empty) |
| 80 | + ". Go get the outcome, then rebuild."), |
| 81 | } |
| 82 | |
| 83 | topic_minutes = sum(t["minutes"] for t in topics) |
| 84 | budget = topic_minutes + CLOSING_BUFFER_MIN |
| 85 | if budget > length: |
| 86 | overflow = budget - length |
| 87 | return { |
| 88 | "status": "REFUSED-OVERFLOW", |
| 89 | "exit_code": 3, |
| 90 | "topic_minutes": topic_minutes, |
| 91 | "closing_buffer": CLOSING_BUFFER_MIN, |
| 92 | "length": length, |
| 93 | "overflow_minutes": overflow, |
| 94 | "message": (f"Timeboxes ({topic_minutes} min) + closing actions-recap buffer " |
| 95 | f"({CLOSING_BUFFER_MIN} min) = {budget} min, but the meeting is " |
| 96 | f"{length} min — {overflow} min over. Cut a topic, shrink a timebox, " |
| 97 | "or move an inform topic to the pre-read."), |
| 98 | } |
| 99 | |
| 100 | # Decisions first (stable within each group — caller's order is preserved otherwise). |
| 101 | ordered = sorted(topics, key=lambda t: 0 if t["kind"] == "decision" else 1) |
| 102 | |
| 103 | cursor = 0 |
| 104 | slots: List[Dict[str, Any]] = [] |
| 105 | for t in ordered: |
| 106 | slots.append({**t, "start_min": cursor, "end_min": cursor + t["minutes"]}) |
| 107 | cursor += t["minutes"] |
| 108 | recap_start = length - CLOSING_BUFFER_MIN |
| 109 | slack = recap_start - cursor |
| 110 | |
| 111 | return { |
| 112 | "status": "OK", |
| 113 | "exit_code": 0, |
| 114 | "length": length, |
| 115 | "topic_minutes": topic_minutes, |
| 116 | "closing_buffer": CLOSING_BUFFER_MIN, |
| 117 | "slack_minutes": slack, |
| 118 | "slots": slots, |
| 119 | "recap": {"title": "Actions recap", "start_min": recap_start, "end_min": length, |
| 120 | "outcome": "Read every action aloud: owner + due date, or it is not an action"}, |
| 121 | } |
| 122 | |
| 123 | |
| 124 | def render_markdown(r: Dict[str, Any]) -> str: |
| 125 | out: List[str] = [] |
| 126 | out.append(f"# Agenda — {r['length']} minutes, {len(r['slots'])} topics") |
| 127 | out.append("") |
| 128 | out.append("**Pre-read:** circulate the relevant doc(s) at least a day ahead. " |
| 129 | "The meeting starts assuming the pre-read was read — no recap slot for skippers.") |
| 130 | out.append("") |
| 131 | out.append("| # | Timebox | Topic | Desired outcome | Owner |") |
| 132 | out.append("|---|---------|-------|-----------------|-------|") |
| 133 | for idx, s in enumerate(r["slots"], 1): |
| 134 | out.append(f"| {idx} | {s['start_min']:02d}–{s['end_min']:02d} min ({s['minutes']} min) " |
| 135 | f"| {s['title']} | {s['outcome']} | {s['owner']} |") |
| 136 | rec = r["recap"] |
| 137 | out.append(f"| ✔ | {rec['start_min']:02d}–{rec['end_min']:02d} min ({CLOSING_BUFFER_MIN} min) " |
| 138 | f"| **{rec['title']}** | {rec['outcome']} | meeting owner |") |
| 139 | out.append("") |
| 140 | if r["slack_minutes"] > 0: |
| 141 | out.append(f"_{r['slack_minutes']} min of slack before the recap — if topics finish early, " |
| 142 | "end early. Nobody has ever complained about a meeting ending early._") |
| 143 | else: |
| 144 | out.append("_Zero slack — hold the timeboxes or the recap gets eaten. " |
| 145 | "The recap is the most valuable slot; protect it._") |
| 146 | return "\n".join(out) |
| 147 | |
| 148 | |
| 149 | SAMPLE_ARGS = { |
| 150 | "length": 45, |
| 151 | "topics": [ |
| 152 | "Metrics review:Inform team of the activation trend:5:alex", |
| 153 | "Q3 pricing:Decide usage-based vs seat-based:15:maria", |
| 154 | "Launch risks:Discuss open launch blockers:15:sam", |
| 155 | ], |
| 156 | } |
| 157 | |
| 158 | |
| 159 | def main(argv: List[str]) -> int: |
| 160 | p = argparse.ArgumentParser( |
| 161 | description="Build a timeboxed, decision-first agenda (refuses empty outcomes and overflow).", |
| 162 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 163 | epilog=EPILOG, |
| 164 | ) |
| 165 | p.add_argument("--topic", action="append", default=[], metavar="SPEC", |
| 166 | help='Repeatable. "title:desired_outcome:minutes:owner" ' |
| 167 | '(outcome starting decide/choose/approve sorts first)') |
| 168 | p.add_argument("--length", type=int, help="Total meeting length in minutes") |
| 169 | p.add_argument("--sample", action="store_true", help="Run the embedded 3-topic sample") |
| 170 | p.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of markdown") |
| 171 | args = p.parse_args(argv) |
| 172 | |
| 173 | if args.sample: |
| 174 | length, specs = SAMPLE_ARGS["length"], SAMPLE_ARGS["topics"] |
| 175 | elif args.topic and args.length is not None: |
| 176 | length, specs = args.length, args.topic |
| 177 | else: |
| 178 | p.print_help() |
| 179 | print("\nerror: provide --length and at least one --topic, or --sample", file=sys.stderr) |
| 180 | return 1 |
| 181 | |
| 182 | if length < CLOSING_BUFFER_MIN + 1: |
| 183 | print(f"error: --length must be at least {CLOSING_BUFFER_MIN + 1} minutes " |
| 184 | f"({CLOSING_BUFFER_MIN}-min closing buffer is mandatory)", file=sys.stderr) |
| 185 | return 1 |
| 186 | |
| 187 | try: |
| 188 | topics = [parse_topic(s) for s in specs] |
| 189 | except ValueError as e: |
| 190 | print(f"error: {e}", file=sys.stderr) |
| 191 | return 1 |
| 192 | |
| 193 | result = build(topics, length) |
| 194 | if args.json: |
| 195 | print(json.dumps(result, indent=2)) |
| 196 | elif result["status"] == "OK": |
| 197 | print(render_markdown(result)) |
| 198 | else: |
| 199 | print(f"REFUSED ({result['status']}): {result['message']}", file=sys.stderr) |
| 200 | return result["exit_code"] |
| 201 | |
| 202 | |
| 203 | if __name__ == "__main__": |
| 204 | sys.exit(main(sys.argv[1:])) |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

