scripts/commitment_auditor.py
7.5 KB| 1 | #!/usr/bin/env python3 |
| 2 | """commitment_auditor.py — Audit a commitment portfolio: flags, health score, one honest verdict. |
| 3 | |
| 4 | Step 8 of the weekly review (review project lists) in numbers. Reads a JSON list of commitments: |
| 5 | |
| 6 | [{"name": "Website relaunch", "days_since_touched": 3, "has_next_action": true}, ...] |
| 7 | |
| 8 | and flags each one: |
| 9 | |
| 10 | STALLED days_since_touched > 14 (a week-and-a-half of silence is drift, not rest) |
| 11 | NO-NEXT-ACTION has_next_action is false (a project with no next action is a wish) |
| 12 | SOMEDAY-CANDIDATE days_since_touched > 45 (be honest: activate it or move it to someday/maybe) |
| 13 | |
| 14 | Then computes a 0-100 commitment-health score. The formula is printed with the output so the |
| 15 | number is auditable, never mystical: |
| 16 | |
| 17 | score = 100 - 30*(stalled/total) - 40*(no_next_action/total) - 30*(someday/total) |
| 18 | |
| 19 | (SOMEDAY-CANDIDATEs also count as STALLED — the double penalty is deliberate escalation.) |
| 20 | |
| 21 | Verdict: HEALTHY (>= 80) · DRIFTING (50-79) · OVERCOMMITTED (< 50) |
| 22 | |
| 23 | Deterministic logic. No LLM calls, no network. Stdlib only. |
| 24 | |
| 25 | Usage: |
| 26 | python commitment_auditor.py --input commitments.json |
| 27 | python commitment_auditor.py --input commitments.json --json |
| 28 | python commitment_auditor.py --sample |
| 29 | |
| 30 | Exit codes: |
| 31 | 0 audit complete (any verdict) — also --sample / --help |
| 32 | 2 input file missing, unreadable, or not the expected JSON shape |
| 33 | """ |
| 34 | |
| 35 | import argparse |
| 36 | import json |
| 37 | import sys |
| 38 | from typing import Any, Dict, List |
| 39 | |
| 40 | STALLED_DAYS = 14 |
| 41 | SOMEDAY_DAYS = 45 |
| 42 | |
| 43 | FORMULA = ("score = 100 - 30*(stalled/total) - 40*(no_next_action/total) " |
| 44 | "- 30*(someday_candidates/total)") |
| 45 | |
| 46 | SAMPLE_COMMITMENTS = [ |
| 47 | {"name": "Website relaunch", "days_since_touched": 3, "has_next_action": True}, |
| 48 | {"name": "Q3 budget draft", "days_since_touched": 9, "has_next_action": True}, |
| 49 | {"name": "Hire a designer", "days_since_touched": 21, "has_next_action": False}, |
| 50 | {"name": "Learn Spanish", "days_since_touched": 41, "has_next_action": True}, |
| 51 | {"name": "Write a novel", "days_since_touched": 63, "has_next_action": False}, |
| 52 | {"name": "Renew passports", "days_since_touched": 2, "has_next_action": True}, |
| 53 | ] |
| 54 | |
| 55 | |
| 56 | def validate(data: Any) -> List[Dict[str, Any]]: |
| 57 | if not isinstance(data, list) or not data: |
| 58 | raise ValueError("input must be a non-empty JSON list of commitment objects") |
| 59 | for i, c in enumerate(data): |
| 60 | if not isinstance(c, dict): |
| 61 | raise ValueError(f"entry {i} is not an object") |
| 62 | for key in ("name", "days_since_touched", "has_next_action"): |
| 63 | if key not in c: |
| 64 | raise ValueError(f"entry {i} ({c.get('name', '?')!r}) missing key: {key!r}") |
| 65 | if not isinstance(c["days_since_touched"], (int, float)) or c["days_since_touched"] < 0: |
| 66 | raise ValueError(f"entry {i}: days_since_touched must be a non-negative number") |
| 67 | if not isinstance(c["has_next_action"], bool): |
| 68 | raise ValueError(f"entry {i}: has_next_action must be true or false") |
| 69 | return data |
| 70 | |
| 71 | |
| 72 | def audit(commitments: List[Dict[str, Any]]) -> Dict[str, Any]: |
| 73 | total = len(commitments) |
| 74 | rows = [] |
| 75 | n_stalled = n_no_action = n_someday = 0 |
| 76 | for c in commitments: |
| 77 | flags = [] |
| 78 | if c["days_since_touched"] > STALLED_DAYS: |
| 79 | flags.append("STALLED") |
| 80 | n_stalled += 1 |
| 81 | if not c["has_next_action"]: |
| 82 | flags.append("NO-NEXT-ACTION") |
| 83 | n_no_action += 1 |
| 84 | if c["days_since_touched"] > SOMEDAY_DAYS: |
| 85 | flags.append("SOMEDAY-CANDIDATE") |
| 86 | n_someday += 1 |
| 87 | rows.append({"name": c["name"], |
| 88 | "days_since_touched": c["days_since_touched"], |
| 89 | "has_next_action": c["has_next_action"], |
| 90 | "flags": flags}) |
| 91 | |
| 92 | score = 100.0 - 30.0 * (n_stalled / total) - 40.0 * (n_no_action / total) \ |
| 93 | - 30.0 * (n_someday / total) |
| 94 | score = round(max(0.0, min(100.0, score)), 1) |
| 95 | |
| 96 | if score >= 80: |
| 97 | verdict = "HEALTHY" |
| 98 | headline = ("The portfolio is honest: most commitments are moving and have a next " |
| 99 | "action. Keep the cadence.") |
| 100 | elif score >= 50: |
| 101 | verdict = "DRIFTING" |
| 102 | headline = ("Several commitments are coasting on good intentions. Give each flagged " |
| 103 | "item a next action, a waiting-for, or a someday/maybe home this review.") |
| 104 | else: |
| 105 | verdict = "OVERCOMMITTED" |
| 106 | headline = ("More commitments than attention. Kill or park the SOMEDAY-CANDIDATEs, " |
| 107 | "then rebuild next actions for what survives — fewer, moving projects " |
| 108 | "beat many, stalled ones.") |
| 109 | |
| 110 | return { |
| 111 | "total_commitments": total, |
| 112 | "flag_counts": {"STALLED": n_stalled, "NO-NEXT-ACTION": n_no_action, |
| 113 | "SOMEDAY-CANDIDATE": n_someday}, |
| 114 | "thresholds": {"stalled_days": STALLED_DAYS, "someday_days": SOMEDAY_DAYS}, |
| 115 | "commitments": rows, |
| 116 | "formula": FORMULA, |
| 117 | "health_score": score, |
| 118 | "verdict": verdict, |
| 119 | "headline": headline, |
| 120 | } |
| 121 | |
| 122 | |
| 123 | def render_human(r: Dict[str, Any]) -> str: |
| 124 | out = ["Commitment Auditor (project lists in numbers)", "=" * 64] |
| 125 | out.append(f" Commitments: {r['total_commitments']} " |
| 126 | f"Stalled > {r['thresholds']['stalled_days']}d · " |
| 127 | f"Someday-candidate > {r['thresholds']['someday_days']}d") |
| 128 | out.append("") |
| 129 | for c in r["commitments"]: |
| 130 | mark = "OK " if not c["flags"] else "!! " |
| 131 | flags = f" [{', '.join(c['flags'])}]" if c["flags"] else "" |
| 132 | out.append(f" {mark}{c['name']} — {c['days_since_touched']}d since touched, " |
| 133 | f"next action: {'yes' if c['has_next_action'] else 'NO'}{flags}") |
| 134 | fc = r["flag_counts"] |
| 135 | out.append("") |
| 136 | out.append(f" Flags: STALLED {fc['STALLED']} · NO-NEXT-ACTION {fc['NO-NEXT-ACTION']} · " |
| 137 | f"SOMEDAY-CANDIDATE {fc['SOMEDAY-CANDIDATE']}") |
| 138 | out.append(f" Formula: {r['formula']}") |
| 139 | out.append(f" COMMITMENT HEALTH: {r['health_score']}/100 VERDICT: {r['verdict']}") |
| 140 | out.append("") |
| 141 | out.append(f" {r['headline']}") |
| 142 | return "\n".join(out) |
| 143 | |
| 144 | |
| 145 | def main(argv: List[str]) -> int: |
| 146 | p = argparse.ArgumentParser( |
| 147 | description="Audit commitments: STALLED / NO-NEXT-ACTION / SOMEDAY-CANDIDATE flags + 0-100 health score.", |
| 148 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 149 | epilog=__doc__, |
| 150 | ) |
| 151 | p.add_argument("--input", help="Path to a JSON list of " |
| 152 | '{"name", "days_since_touched", "has_next_action"} objects') |
| 153 | p.add_argument("--sample", action="store_true", help="Run on embedded example data and exit 0") |
| 154 | p.add_argument("--json", action="store_true", help="Emit JSON instead of text") |
| 155 | args = p.parse_args(argv) |
| 156 | |
| 157 | try: |
| 158 | if args.sample: |
| 159 | commitments = validate(SAMPLE_COMMITMENTS) |
| 160 | elif args.input: |
| 161 | with open(args.input, "r", encoding="utf-8") as f: |
| 162 | commitments = validate(json.load(f)) |
| 163 | else: |
| 164 | p.print_help() |
| 165 | print("\nerror: provide --input <file.json> or --sample", file=sys.stderr) |
| 166 | return 2 |
| 167 | except (OSError, ValueError, json.JSONDecodeError) as e: |
| 168 | print(f"error: {e}", file=sys.stderr) |
| 169 | return 2 |
| 170 | |
| 171 | result = audit(commitments) |
| 172 | if args.json: |
| 173 | print(json.dumps(result, indent=2)) |
| 174 | else: |
| 175 | print(render_human(result)) |
| 176 | return 0 |
| 177 | |
| 178 | |
| 179 | if __name__ == "__main__": |
| 180 | sys.exit(main(sys.argv[1:])) |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

