跳到主要内容

会议议程与行动清单

收藏0

判断是否需要开会,整理带时长和目标的议程,把会议笔记转换为负责人、截止日期明确的行动项。

让 AI 安装

github最新版本 0.1.1更新于 2026-10-21下载 2 次2 个版本

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
4The `meetings` skill's second discipline. Feed it topics as
5 --topic "title:desired_outcome:minutes:owner"
6plus 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
16NO LLM CALLS. Pure parsing + ordering + arithmetic. Stdlib only. Nothing is sent or scheduled.
17
18Usage:
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
26import argparse
27import json
28import sys
29from typing import Any, Dict, List
30
31CLOSING_BUFFER_MIN = 5 # mandatory closing "actions recap" slot
32DECISION_PREFIXES = ("decide", "choose", "approve")
33
34EPILOG = """\
35exit 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
45def 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
71def 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
124def 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
149SAMPLE_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
159def 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
203if __name__ == "__main__":
204 sys.exit(main(sys.argv[1:]))

文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。