scripts/weekly_review_gate.py
9.5 KB| 1 | #!/usr/bin/env python3 |
| 2 | """weekly_review_gate.py — Gate a GTD weekly review: completion %, named gaps, one honest verdict. |
| 3 | |
| 4 | Encodes David Allen's weekly-review checklist as three phases and ten numbered steps: |
| 5 | |
| 6 | GET CLEAR 1. Collect loose inputs (papers, receipts, notes, downloads) |
| 7 | 2. Process inboxes to zero (clarify, don't do) |
| 8 | 3. Empty your head (mind sweep — capture everything still riding in RAM) |
| 9 | GET CURRENT 4. Review next-action lists (mark done, prune dead, surface stuck) |
| 10 | 5. Review previous calendar (missed/spawned commitments become actions) |
| 11 | 6. Review upcoming calendar (prepare, don't react) |
| 12 | 7. Review waiting-for list (chase, re-date, or drop each item) |
| 13 | 8. Review project lists (every active project has exactly one next action) |
| 14 | GET CREATIVE 9. Review someday/maybe (activate, keep, or kill) |
| 15 | 10. Capture new ideas (add to the system while your head is clear) |
| 16 | |
| 17 | The gate's one hard rule: ALL FIVE GET CURRENT steps (4-8) are mandatory. A GET CURRENT step that |
| 18 | is neither done nor explicitly skipped with a reason ALWAYS forces INCOMPLETE — a review that |
| 19 | skims the core is a guilt ritual, not a review. |
| 20 | |
| 21 | Deterministic logic. No LLM calls. Stdlib only. |
| 22 | |
| 23 | Usage: |
| 24 | python weekly_review_gate.py --list |
| 25 | python weekly_review_gate.py --done "1,2,3,4,5,6,7,8,10" --skip "9:no someday list yet" |
| 26 | python weekly_review_gate.py --done "1,4,6" --json |
| 27 | python weekly_review_gate.py --sample |
| 28 | |
| 29 | Exit codes: |
| 30 | 0 review COMPLETE (every step done or skipped with a reason) — also --list / --sample / --help |
| 31 | 2 review INCOMPLETE (at least one step neither done nor skipped) |
| 32 | 3 bad input (unknown step number, malformed --done/--skip, step both done and skipped) |
| 33 | """ |
| 34 | |
| 35 | import argparse |
| 36 | import json |
| 37 | import sys |
| 38 | from typing import Any, Dict, List |
| 39 | |
| 40 | STEPS = [ |
| 41 | (1, "GET CLEAR", "Collect loose inputs (papers, receipts, notes, downloads)"), |
| 42 | (2, "GET CLEAR", "Process inboxes to zero (clarify, don't do)"), |
| 43 | (3, "GET CLEAR", "Empty your head (mind sweep — capture everything still in RAM)"), |
| 44 | (4, "GET CURRENT", "Review next-action lists (mark done, prune dead, surface stuck)"), |
| 45 | (5, "GET CURRENT", "Review previous calendar (missed/spawned commitments become actions)"), |
| 46 | (6, "GET CURRENT", "Review upcoming calendar (prepare, don't react)"), |
| 47 | (7, "GET CURRENT", "Review waiting-for list (chase, re-date, or drop each item)"), |
| 48 | (8, "GET CURRENT", "Review project lists (every active project has one next action)"), |
| 49 | (9, "GET CREATIVE", "Review someday/maybe (activate, keep, or kill)"), |
| 50 | (10, "GET CREATIVE", "Capture new ideas (add them while your head is clear)"), |
| 51 | ] |
| 52 | |
| 53 | MANDATORY_PHASE = "GET CURRENT" |
| 54 | PHASE_ORDER = ["GET CLEAR", "GET CURRENT", "GET CREATIVE"] |
| 55 | |
| 56 | |
| 57 | def parse_done(raw: str) -> List[int]: |
| 58 | if not raw or not raw.strip(): |
| 59 | return [] |
| 60 | nums = [] |
| 61 | for part in raw.split(","): |
| 62 | part = part.strip() |
| 63 | if not part: |
| 64 | continue |
| 65 | if not part.isdigit(): |
| 66 | raise ValueError(f"--done entries must be step numbers, got: {part!r}") |
| 67 | nums.append(int(part)) |
| 68 | return sorted(set(nums)) |
| 69 | |
| 70 | |
| 71 | def parse_skips(raw_list: List[str]) -> Dict[int, str]: |
| 72 | skips: Dict[int, str] = {} |
| 73 | for raw in raw_list or []: |
| 74 | if ":" not in raw: |
| 75 | raise ValueError(f"--skip must be 'N:reason', got: {raw!r}") |
| 76 | num_s, reason = raw.split(":", 1) |
| 77 | num_s, reason = num_s.strip(), reason.strip() |
| 78 | if not num_s.isdigit(): |
| 79 | raise ValueError(f"--skip step must be a number, got: {num_s!r}") |
| 80 | if not reason: |
| 81 | raise ValueError(f"--skip requires a non-empty reason: {raw!r}") |
| 82 | skips[int(num_s)] = reason |
| 83 | return skips |
| 84 | |
| 85 | |
| 86 | def evaluate(done: List[int], skips: Dict[int, str]) -> Dict[str, Any]: |
| 87 | valid = {n for n, _, _ in STEPS} |
| 88 | for n in done: |
| 89 | if n not in valid: |
| 90 | raise ValueError(f"unknown step number in --done: {n} (valid: 1-10)") |
| 91 | for n in skips: |
| 92 | if n not in valid: |
| 93 | raise ValueError(f"unknown step number in --skip: {n} (valid: 1-10)") |
| 94 | overlap = set(done) & set(skips) |
| 95 | if overlap: |
| 96 | raise ValueError(f"step(s) both done and skipped: {sorted(overlap)}") |
| 97 | |
| 98 | rows = [] |
| 99 | missing: List[Dict[str, Any]] = [] |
| 100 | for n, phase, label in STEPS: |
| 101 | if n in done: |
| 102 | status = "DONE" |
| 103 | elif n in skips: |
| 104 | status = "SKIPPED" |
| 105 | else: |
| 106 | status = "MISSING" |
| 107 | missing.append({"step": n, "phase": phase, "label": label}) |
| 108 | rows.append({"step": n, "phase": phase, "label": label, "status": status, |
| 109 | "skip_reason": skips.get(n)}) |
| 110 | |
| 111 | considered = len(STEPS) - len(skips) |
| 112 | completion_pct = round(100.0 * len(done) / considered, 1) if considered else 100.0 |
| 113 | |
| 114 | missing_mandatory = [m for m in missing if m["phase"] == MANDATORY_PHASE] |
| 115 | skipped_mandatory = [n for n in skips if 4 <= n <= 8] |
| 116 | |
| 117 | verdict = "COMPLETE" if not missing else "INCOMPLETE" |
| 118 | forced = bool(missing_mandatory) |
| 119 | |
| 120 | warnings = [] |
| 121 | if skipped_mandatory: |
| 122 | warnings.append( |
| 123 | f"GET CURRENT step(s) {sorted(skipped_mandatory)} skipped with a reason — " |
| 124 | "allowed, but the core of the review was not fully walked. Do them first next week." |
| 125 | ) |
| 126 | if forced: |
| 127 | warnings.append( |
| 128 | "Unskipped GET CURRENT step(s) missing — this ALWAYS forces INCOMPLETE, " |
| 129 | "regardless of completion %." |
| 130 | ) |
| 131 | |
| 132 | return { |
| 133 | "steps": rows, |
| 134 | "done_count": len(done), |
| 135 | "skipped_count": len(skips), |
| 136 | "missing_count": len(missing), |
| 137 | "completion_pct": completion_pct, |
| 138 | "missing_steps": missing, |
| 139 | "missing_mandatory": missing_mandatory, |
| 140 | "mandatory_gate_forced_incomplete": forced, |
| 141 | "warnings": warnings, |
| 142 | "verdict": verdict, |
| 143 | "exit_code": 0 if verdict == "COMPLETE" else 2, |
| 144 | } |
| 145 | |
| 146 | |
| 147 | def render_list() -> str: |
| 148 | out = ["GTD Weekly Review — the ten steps", "=" * 64] |
| 149 | for phase in PHASE_ORDER: |
| 150 | tag = " (all mandatory)" if phase == MANDATORY_PHASE else "" |
| 151 | out.append(f"\n{phase}{tag}") |
| 152 | for n, p, label in STEPS: |
| 153 | if p == phase: |
| 154 | out.append(f" {n:>2}. {label}") |
| 155 | out.append("\nMark progress with --done \"1,2,3\" and --skip \"N:reason\".") |
| 156 | return "\n".join(out) |
| 157 | |
| 158 | |
| 159 | def render_human(r: Dict[str, Any]) -> str: |
| 160 | out = ["Weekly Review Gate (GET CLEAR -> GET CURRENT -> GET CREATIVE)", "=" * 64] |
| 161 | current_phase = None |
| 162 | for row in r["steps"]: |
| 163 | if row["phase"] != current_phase: |
| 164 | current_phase = row["phase"] |
| 165 | tag = " (mandatory)" if current_phase == MANDATORY_PHASE else "" |
| 166 | out.append(f"\n {current_phase}{tag}") |
| 167 | mark = {"DONE": "[x]", "SKIPPED": "[s]", "MISSING": "[ ]"}[row["status"]] |
| 168 | line = f" {mark} {row['step']:>2}. {row['label']}" |
| 169 | if row["status"] == "SKIPPED": |
| 170 | line += f" (skipped: {row['skip_reason']})" |
| 171 | out.append(line) |
| 172 | out.append("") |
| 173 | out.append(f" Completion: {r['completion_pct']}% " |
| 174 | f"(done {r['done_count']} · skipped {r['skipped_count']} · missing {r['missing_count']})") |
| 175 | if r["missing_steps"]: |
| 176 | out.append("\n Missing steps (do these to finish):") |
| 177 | for m in r["missing_steps"]: |
| 178 | star = " <- MANDATORY" if m["phase"] == MANDATORY_PHASE else "" |
| 179 | out.append(f" - {m['step']}. [{m['phase']}] {m['label']}{star}") |
| 180 | for w in r["warnings"]: |
| 181 | out.append(f"\n WARNING: {w}") |
| 182 | out.append("") |
| 183 | out.append(f" VERDICT: {r['verdict']}" + |
| 184 | (" (mandatory GET CURRENT gate forced this)" if r["mandatory_gate_forced_incomplete"] else "")) |
| 185 | return "\n".join(out) |
| 186 | |
| 187 | |
| 188 | def main(argv: List[str]) -> int: |
| 189 | p = argparse.ArgumentParser( |
| 190 | description="Gate a GTD weekly review: completion %, named gaps, COMPLETE/INCOMPLETE verdict.", |
| 191 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 192 | epilog=__doc__, |
| 193 | ) |
| 194 | p.add_argument("--done", default="", help='Comma-separated step numbers completed, e.g. "1,3,5"') |
| 195 | p.add_argument("--skip", action="append", default=[], |
| 196 | help='Skip a step with a reason, e.g. --skip "9:no someday list yet" (repeatable)') |
| 197 | p.add_argument("--list", action="store_true", help="Show the numbered ten-step checklist and exit") |
| 198 | p.add_argument("--sample", action="store_true", |
| 199 | help="Run an embedded example review (always exits 0; the sample shows an INCOMPLETE verdict)") |
| 200 | p.add_argument("--json", action="store_true", help="Emit JSON instead of text") |
| 201 | args = p.parse_args(argv) |
| 202 | |
| 203 | if args.list: |
| 204 | print(render_list()) |
| 205 | return 0 |
| 206 | |
| 207 | try: |
| 208 | if args.sample: |
| 209 | done = parse_done("1,2,3,4,6,7,8") |
| 210 | skips = parse_skips(["9:no someday list yet"]) |
| 211 | else: |
| 212 | done = parse_done(args.done) |
| 213 | skips = parse_skips(args.skip) |
| 214 | result = evaluate(done, skips) |
| 215 | except ValueError as e: |
| 216 | print(f"error: {e}", file=sys.stderr) |
| 217 | return 3 |
| 218 | |
| 219 | if args.json: |
| 220 | print(json.dumps(result, indent=2)) |
| 221 | else: |
| 222 | print(render_human(result)) |
| 223 | |
| 224 | if args.sample: |
| 225 | return 0 # sample is illustrative; documented to always exit 0 |
| 226 | return result["exit_code"] |
| 227 | |
| 228 | |
| 229 | if __name__ == "__main__": |
| 230 | sys.exit(main(sys.argv[1:])) |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

