codex-coach 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/pyproject.toml ADDED
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codex-coach"
7
+ version = "0.1.0"
8
+ description = "Local-first Codex usage coach and plugin"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Codex Coach Contributors" }]
13
+ keywords = ["codex", "ai", "developer-tools", "analytics", "prompting"]
14
+ dependencies = []
15
+
16
+ [project.scripts]
17
+ codex-coach = "codex_coach.cli:main"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/codex_coach"]
21
+
22
+ [tool.hatch.build.targets.sdist]
23
+ include = [
24
+ "/assets",
25
+ "/src",
26
+ "/skills",
27
+ "/.codex-plugin",
28
+ "/README.md",
29
+ "/install.sh",
30
+ "/install.ps1",
31
+ ]
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: codex-coach
3
+ description: Use when the user asks to analyze their Codex usage, habits, prompt quality, model/effort choices, tool usage, project switching, context recovery, or wants suggestions for Codex custom instructions or AGENTS.md improvements.
4
+ ---
5
+
6
+ # Codex Coach
7
+
8
+ Codex Coach is a local-first coaching workflow. Use the bundled `codex-coach` command for deterministic parsing; do not manually scan large logs unless the command is unavailable.
9
+
10
+ ## Quick Workflow
11
+
12
+ 1. Run a health check:
13
+
14
+ ```sh
15
+ codex-coach doctor
16
+ ```
17
+
18
+ 2. Generate or refresh the report:
19
+
20
+ ```sh
21
+ codex-coach report --since 7d
22
+ ```
23
+
24
+ 3. Use beginner or expert mode based on the user:
25
+
26
+ ```sh
27
+ codex-coach report --since 7d --mode beginner
28
+ codex-coach report --since 7d --mode expert
29
+ ```
30
+
31
+ 4. Read `~/.codex-coach/reports/latest.md` and summarize the highest-impact findings.
32
+
33
+ 5. For config improvements, generate review files:
34
+
35
+ ```sh
36
+ codex-coach suggest-config --since 7d
37
+ ```
38
+
39
+ 6. For a prompt the user is drafting, lint it directly:
40
+
41
+ ```sh
42
+ codex-coach lint-prompt "fix the login bug"
43
+ ```
44
+
45
+ 7. Explain that suggestions are reviewable and are not applied automatically.
46
+
47
+ ## User Modes
48
+
49
+ - Beginner: summarize the top 3 findings in plain language, then give one habit to try this week.
50
+ - Expert: include model/effort mix, tool mix, verification ratio, compactions, project capsules, and skill opportunities.
51
+ - Project-specific: if the user names a project path, filter your discussion to that project from the report/facts if present.
52
+
53
+ ## What To Surface
54
+
55
+ - Project capsules: explain the likely workflow, top friction, and suggested local instruction.
56
+ - Prompt rewrites: show the redacted preview and the safer template, not the raw original prompt.
57
+ - Confidence: keep low-confidence suggestions tentative; high-confidence suggestions can be presented as the next best habit.
58
+ - Skill opportunities: if the report flags a repeated workflow, suggest a small user skill with trigger, context to gather, verification commands, and resume rules.
59
+
60
+ ## Guardrails
61
+
62
+ - Keep reports local. Do not upload logs.
63
+ - Do not paste full prompt bodies or source code into chat.
64
+ - Treat `~/.codex-coach/facts/latest.json` as the machine-readable source of truth.
65
+ - Do not edit global custom instructions or `AGENTS.md` automatically; show the generated suggestion file and ask for explicit approval before applying any change.
66
+
67
+ ## Optional References
68
+
69
+ - Read `references/prompt-rubric.md` when explaining prompt quality scores.
70
+ - Read `references/config-suggestions.md` when discussing custom instruction or AGENTS.md changes.
71
+ - Read `references/privacy.md` when the user asks what data is read or stored.
@@ -0,0 +1,8 @@
1
+ interface:
2
+ display_name: "Codex Coach"
3
+ short_description: "Analyze local Codex habits and usage."
4
+ brand_color: "#22C55E"
5
+ default_prompt: "Use $codex-coach to analyze my Codex usage this week."
6
+
7
+ policy:
8
+ allow_implicit_invocation: true
@@ -0,0 +1,35 @@
1
+ # Config Suggestion Policy
2
+
3
+ Codex Coach can suggest changes to global custom instructions, user-level skills, or project `AGENTS.md` files. Suggestions must be reviewable and reversible.
4
+
5
+ Recommended suggestion format:
6
+
7
+ - Evidence: one metric or repeated pattern from the report.
8
+ - Confidence: low, medium, or high.
9
+ - Change: the exact instruction text to add.
10
+ - Benefit: what should improve.
11
+ - Rollback: how to remove it if it does not help.
12
+
13
+ Common suggestions:
14
+
15
+ ```md
16
+ When a task is simple, local, or status-oriented, default to medium reasoning. Reserve high or xhigh for ambiguous debugging, architecture, security, broad refactors, or unclear failures.
17
+ ```
18
+
19
+ ```md
20
+ Before saying a fix is complete, run the smallest meaningful verification command or inspect the live/runtime state when applicable.
21
+ ```
22
+
23
+ ```md
24
+ For long tasks, keep a short durable checklist or artifact ledger so work can resume safely after compaction or interruption.
25
+ ```
26
+
27
+ ```md
28
+ For multi-project work, prefer short project capsules over one large global instruction.
29
+ ```
30
+
31
+ For repeated workflows, prefer a user skill over a long global instruction:
32
+
33
+ ```md
34
+ When this workflow appears, gather [project context], follow [steps], verify with [commands], and resume from [ledger/checklist] after interruption.
35
+ ```
@@ -0,0 +1,25 @@
1
+ # Privacy Model
2
+
3
+ Codex Coach is local-first.
4
+
5
+ Default inputs:
6
+
7
+ - `~/.codex/sessions/**/*.jsonl`
8
+ - `~/.codex/archived_sessions/*.jsonl`
9
+ - Optional Codex config and project metadata only when a user asks for deeper recommendations.
10
+
11
+ Default outputs:
12
+
13
+ - `~/.codex-coach/facts/latest.json`
14
+ - `~/.codex-coach/reports/latest.md`
15
+ - `~/.codex-coach/reports/weekly-YYYY-MM-DD.md`
16
+ - `~/.codex-coach/suggestions/*.patch.md`
17
+
18
+ Default redaction:
19
+
20
+ - Full prompts are not written to Markdown reports.
21
+ - Source code is not included.
22
+ - File paths, URLs, emails, secret-like tokens, and long opaque strings are replaced in previews.
23
+ - Prompt rewrites are generated as generic templates and do not include raw private details.
24
+ - Project capsules use redacted project labels instead of full local paths.
25
+ - Config suggestions are written as review files and are not applied automatically.
@@ -0,0 +1,21 @@
1
+ # Prompt Quality Rubric
2
+
3
+ Codex Coach scores prompts from 0 to 10 using local text only.
4
+
5
+ - Excellent: clear action plus enough context, or a brief reply that is unambiguous because the previous Codex turn asked for a choice.
6
+ - Good: actionable but missing one optional detail.
7
+ - Needs work: too short to identify the target, missing the failing symptom, or using vague words like "fix", "run", "delete", or "make better" without context.
8
+
9
+ Do not treat brevity as bad by itself. Brief prompts like "yes", "proceed", "run tests", or "commit" can be high quality when Codex has enough local context.
10
+
11
+ When recommending improvements, prefer templates:
12
+
13
+ ```text
14
+ [action] in [project/file] because [symptom]. Success means [observable result].
15
+ ```
16
+
17
+ ```text
18
+ Debug [error text]. I expected [expected behavior], but got [actual behavior].
19
+ ```
20
+
21
+ `codex-coach lint-prompt "..."` applies the same rubric to a single draft prompt. If the score is low, present the suggested rewrite as a template, not as a claim that Codex Coach knows the missing private details.
@@ -0,0 +1,3 @@
1
+ """Local-first Codex usage coach."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from .install import install_from_source, uninstall
10
+ from .parser import iter_log_paths, scan_logs
11
+ from .paths import default_paths
12
+ from .prompts import score_prompt
13
+ from .reports import render_markdown_report, write_json_facts, write_markdown_report, write_suggestion_files
14
+ from .timeutil import parse_since
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ parser = argparse.ArgumentParser(prog="codex-coach", description="Local-first Codex usage coach")
19
+ parser.add_argument("--home", help="Override HOME for testing or portable installs")
20
+ parser.add_argument("--codex-home", help="Override Codex home directory")
21
+ parser.add_argument("--coach-home", help="Override Codex Coach output directory")
22
+ sub = parser.add_subparsers(dest="command", required=True)
23
+
24
+ sub.add_parser("doctor", help="Check local Codex Coach setup")
25
+
26
+ scan = sub.add_parser("scan", help="Scan Codex logs and write JSON facts")
27
+ scan.add_argument("--since", default="7d", help="Window such as 7d, 2w, 24h, or ISO timestamp")
28
+
29
+ report = sub.add_parser("report", help="Write a Markdown coaching report")
30
+ report.add_argument("--since", default="7d")
31
+ report.add_argument("--mode", choices=("beginner", "expert"), default="beginner")
32
+ report.add_argument("--expert", action="store_true", help="Include raw expert metrics")
33
+
34
+ suggest = sub.add_parser("suggest-config", help="Write reviewable config suggestion files")
35
+ suggest.add_argument("--since", default="7d")
36
+
37
+ lint = sub.add_parser("lint-prompt", help="Score one prompt and suggest a safer rewrite")
38
+ lint.add_argument("prompt", nargs="*", help="Prompt text. Reads stdin when omitted.")
39
+ lint.add_argument("--json", action="store_true", help="Write machine-readable JSON")
40
+
41
+ install_cmd = sub.add_parser("install", help="Install command, plugin, skill, and default config")
42
+ install_cmd.add_argument("--source-root", default=_repo_root(), help="Source checkout root")
43
+ install_cmd.add_argument("--schedule", choices=("daily", "weekly", "none"), default="weekly")
44
+
45
+ sub.add_parser("uninstall", help="Remove installed command, plugin, skill, and scheduler files")
46
+
47
+ args = parser.parse_args(argv)
48
+ paths = default_paths(home=args.home, codex_home=args.codex_home, coach_home=args.coach_home)
49
+
50
+ try:
51
+ if args.command == "doctor":
52
+ return _doctor(paths)
53
+ if args.command == "scan":
54
+ facts = _scan(paths, args.since)
55
+ print(f"Wrote facts to {paths.facts_dir / 'latest.json'}")
56
+ return 0
57
+ if args.command == "report":
58
+ facts = _scan(paths, args.since)
59
+ generated_at = datetime.now(UTC)
60
+ report_text = render_markdown_report(facts, generated_at=generated_at, expert=args.expert, mode=args.mode)
61
+ latest, weekly = write_markdown_report(report_text, paths.reports_dir, generated_at=generated_at)
62
+ print(f"Wrote report to {latest}")
63
+ print(f"Wrote weekly copy to {weekly}")
64
+ return 0
65
+ if args.command == "suggest-config":
66
+ facts = _scan(paths, args.since)
67
+ written = write_suggestion_files(facts, paths.suggestions_dir)
68
+ for path in written:
69
+ print(f"Wrote suggestion {path}")
70
+ return 0
71
+ if args.command == "lint-prompt":
72
+ prompt_text = " ".join(args.prompt).strip() if args.prompt else sys.stdin.read().strip()
73
+ return _lint_prompt(prompt_text, as_json=args.json)
74
+ if args.command == "install":
75
+ result = install_from_source(Path(args.source_root), paths, schedule=args.schedule)
76
+ for key, value in result.items():
77
+ print(f"{key}: {value}")
78
+ return 0
79
+ if args.command == "uninstall":
80
+ removed = uninstall(paths)
81
+ if removed:
82
+ for path in removed:
83
+ print(f"Removed {path}")
84
+ else:
85
+ print("Nothing to remove")
86
+ return 0
87
+ except Exception as exc: # noqa: BLE001 - CLI should provide a direct error.
88
+ print(f"codex-coach: {exc}", file=sys.stderr)
89
+ return 1
90
+ return 1
91
+
92
+
93
+ def _scan(paths, since: str) -> dict:
94
+ since_dt = parse_since(since)
95
+ paths.ensure_output_dirs()
96
+ facts = scan_logs(paths.codex_home, since_dt=since_dt, since_label=since)
97
+ facts["generated_at"] = datetime.now(UTC).isoformat(timespec="seconds")
98
+ write_json_facts(facts, paths.facts_dir / "latest.json")
99
+ return facts
100
+
101
+
102
+ def _doctor(paths) -> int:
103
+ log_paths = iter_log_paths(paths.codex_home)
104
+ print(f"home: {paths.home}")
105
+ print(f"codex_home: {paths.codex_home} {'OK' if paths.codex_home.exists() else 'MISSING'}")
106
+ print(f"coach_home: {paths.coach_home}")
107
+ print(f"log_files: {len(log_paths)}")
108
+ print(f"reports_dir: {paths.reports_dir}")
109
+ command = paths.home / ".local" / "bin" / "codex-coach"
110
+ print(f"command: {command} {'OK' if command.exists() else 'not installed'}")
111
+ plugin = paths.home / "plugins" / "codex-coach" / ".codex-plugin" / "plugin.json"
112
+ print(f"plugin: {plugin} {'OK' if plugin.exists() else 'not installed'}")
113
+ skill = paths.home / ".agents" / "skills" / "codex-coach" / "SKILL.md"
114
+ print(f"skill: {skill} {'OK' if skill.exists() else 'not installed'}")
115
+ return 0 if paths.codex_home.exists() else 1
116
+
117
+
118
+ def _lint_prompt(prompt_text: str, *, as_json: bool) -> int:
119
+ score = score_prompt(prompt_text)
120
+ result = {
121
+ "score": score.score,
122
+ "category": score.category,
123
+ "reason": score.reason,
124
+ "missing": list(score.missing),
125
+ "preview": score.preview,
126
+ "rewrite": score.rewrite,
127
+ }
128
+ if as_json:
129
+ print(json.dumps(result, indent=2, sort_keys=True))
130
+ return 0 if score.category != "needs_work" else 2
131
+
132
+ print(f"Score: {score.score}/10 ({score.category})")
133
+ print(f"Reason: {score.reason}")
134
+ if score.missing:
135
+ print(f"Missing: {', '.join(score.missing)}")
136
+ print(f"Preview: {score.preview}")
137
+ print(f"Try: {score.rewrite}")
138
+ return 0 if score.category != "needs_work" else 2
139
+
140
+
141
+ def _repo_root() -> str:
142
+ return str(Path(__file__).resolve().parents[2])
143
+
144
+
145
+ if __name__ == "__main__":
146
+ raise SystemExit(main())
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from .paths import CoachPaths
9
+
10
+ PLUGIN_NAME = "codex-coach"
11
+
12
+
13
+ def install_from_source(source_root: Path, paths: CoachPaths, *, schedule: str = "weekly") -> dict[str, str]:
14
+ source_root = source_root.resolve()
15
+ paths.ensure_output_dirs()
16
+ paths.coach_home.mkdir(parents=True, exist_ok=True)
17
+ _write_default_config(paths, schedule=schedule)
18
+ _copy_app(source_root, paths.app_dir)
19
+ _install_command(paths)
20
+ plugin_path = _install_plugin(source_root, paths.home)
21
+ skill_paths = _install_user_skills(source_root, paths.home)
22
+ marketplace = _update_marketplace(paths.home, plugin_path)
23
+ scheduler = _write_scheduler(paths, schedule=schedule)
24
+ return {
25
+ "coach_home": str(paths.coach_home),
26
+ "command": str(paths.home / ".local" / "bin" / "codex-coach"),
27
+ "plugin": str(plugin_path),
28
+ "skills": ", ".join(str(path) for path in skill_paths),
29
+ "marketplace": str(marketplace),
30
+ "scheduler": str(scheduler) if scheduler else "not configured",
31
+ }
32
+
33
+
34
+ def uninstall(paths: CoachPaths) -> list[str]:
35
+ removed: list[str] = []
36
+ command = paths.home / ".local" / "bin" / "codex-coach"
37
+ if command.exists():
38
+ command.unlink()
39
+ removed.append(str(command))
40
+ for target in (
41
+ paths.home / "plugins" / PLUGIN_NAME,
42
+ paths.home / ".agents" / "skills" / PLUGIN_NAME,
43
+ paths.home / ".codex" / "skills" / PLUGIN_NAME,
44
+ ):
45
+ if target.exists():
46
+ shutil.rmtree(target)
47
+ removed.append(str(target))
48
+ _remove_marketplace_entry(paths.home)
49
+ for target in _scheduler_paths(paths):
50
+ if target.exists():
51
+ target.unlink()
52
+ removed.append(str(target))
53
+ return removed
54
+
55
+
56
+ def _copy_app(source_root: Path, app_dir: Path) -> None:
57
+ if app_dir.exists():
58
+ shutil.rmtree(app_dir)
59
+ ignore = shutil.ignore_patterns(".git", ".venv", "__pycache__", ".pytest_cache", "dist", "build", "*.egg-info")
60
+ shutil.copytree(source_root, app_dir, ignore=ignore)
61
+
62
+
63
+ def _install_command(paths: CoachPaths) -> None:
64
+ bin_dir = paths.home / ".local" / "bin"
65
+ bin_dir.mkdir(parents=True, exist_ok=True)
66
+ command = bin_dir / "codex-coach"
67
+ python = os.environ.get("PYTHON", "python3")
68
+ command.write_text(
69
+ "#!/usr/bin/env sh\n"
70
+ "set -eu\n"
71
+ f'PYTHONPATH="{paths.app_dir / "src"}${{PYTHONPATH:+:$PYTHONPATH}}" exec {python} -m codex_coach.cli "$@"\n',
72
+ encoding="utf-8",
73
+ )
74
+ command.chmod(0o755)
75
+
76
+
77
+ def _install_plugin(source_root: Path, home: Path) -> Path:
78
+ plugin_root = home / "plugins" / PLUGIN_NAME
79
+ if plugin_root.exists():
80
+ shutil.rmtree(plugin_root)
81
+ plugin_root.mkdir(parents=True, exist_ok=True)
82
+ for name in (".codex-plugin", "skills", "README.md", "LICENSE"):
83
+ source = source_root / name
84
+ target = plugin_root / name
85
+ if source.is_dir():
86
+ shutil.copytree(source, target)
87
+ elif source.exists():
88
+ shutil.copy2(source, target)
89
+ return plugin_root
90
+
91
+
92
+ def _install_user_skills(source_root: Path, home: Path) -> list[Path]:
93
+ skill_source = source_root / "skills" / PLUGIN_NAME
94
+ targets = [
95
+ home / ".agents" / "skills" / PLUGIN_NAME,
96
+ home / ".codex" / "skills" / PLUGIN_NAME,
97
+ ]
98
+ installed: list[Path] = []
99
+ for target in targets:
100
+ if target.exists():
101
+ shutil.rmtree(target)
102
+ target.parent.mkdir(parents=True, exist_ok=True)
103
+ shutil.copytree(skill_source, target)
104
+ installed.append(target)
105
+ return installed
106
+
107
+
108
+ def _update_marketplace(home: Path, plugin_path: Path) -> Path:
109
+ marketplace = home / ".agents" / "plugins" / "marketplace.json"
110
+ marketplace.parent.mkdir(parents=True, exist_ok=True)
111
+ if marketplace.exists():
112
+ try:
113
+ data = json.loads(marketplace.read_text(encoding="utf-8"))
114
+ except json.JSONDecodeError:
115
+ data = _new_marketplace()
116
+ else:
117
+ data = _new_marketplace()
118
+ plugins = [item for item in data.get("plugins", []) if item.get("name") != PLUGIN_NAME]
119
+ plugins.append(
120
+ {
121
+ "name": PLUGIN_NAME,
122
+ "source": {"source": "local", "path": "./plugins/codex-coach"},
123
+ "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
124
+ "category": "Productivity",
125
+ }
126
+ )
127
+ data["plugins"] = plugins
128
+ marketplace.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
129
+ return marketplace
130
+
131
+
132
+ def _remove_marketplace_entry(home: Path) -> None:
133
+ marketplace = home / ".agents" / "plugins" / "marketplace.json"
134
+ if not marketplace.exists():
135
+ return
136
+ try:
137
+ data = json.loads(marketplace.read_text(encoding="utf-8"))
138
+ except json.JSONDecodeError:
139
+ return
140
+ data["plugins"] = [item for item in data.get("plugins", []) if item.get("name") != PLUGIN_NAME]
141
+ marketplace.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
142
+
143
+
144
+ def _new_marketplace() -> dict:
145
+ return {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
146
+
147
+
148
+ def _write_default_config(paths: CoachPaths, *, schedule: str) -> None:
149
+ if paths.config_file.exists():
150
+ return
151
+ paths.config_file.write_text(
152
+ "\n".join(
153
+ [
154
+ "# Codex Coach config",
155
+ "redact_prompts = true",
156
+ "include_source_code = false",
157
+ f'schedule = "{schedule}"',
158
+ 'default_since = "7d"',
159
+ "",
160
+ ]
161
+ ),
162
+ encoding="utf-8",
163
+ )
164
+
165
+
166
+ def _write_scheduler(paths: CoachPaths, *, schedule: str) -> Path | None:
167
+ if schedule == "none":
168
+ return None
169
+ if schedule not in {"daily", "weekly"}:
170
+ raise ValueError(f"unsupported schedule: {schedule}")
171
+ systemd_dir = paths.home / ".config" / "systemd" / "user"
172
+ if os.name == "posix" and Path("/run/systemd").exists():
173
+ systemd_dir.mkdir(parents=True, exist_ok=True)
174
+ service = systemd_dir / "codex-coach.service"
175
+ timer = systemd_dir / "codex-coach.timer"
176
+ service.write_text(
177
+ "[Unit]\nDescription=Codex Coach report\n\n"
178
+ "[Service]\nType=oneshot\n"
179
+ f"ExecStart={paths.home / '.local' / 'bin' / 'codex-coach'} report --since 7d\n",
180
+ encoding="utf-8",
181
+ )
182
+ calendar = "09:00" if schedule == "daily" else "Sun 09:00"
183
+ timer.write_text(
184
+ f"[Unit]\nDescription=Run Codex Coach {schedule}\n\n"
185
+ f"[Timer]\nOnCalendar={calendar}\nPersistent=true\n\n"
186
+ "[Install]\nWantedBy=timers.target\n",
187
+ encoding="utf-8",
188
+ )
189
+ return timer
190
+
191
+ launch_agents = paths.home / "Library" / "LaunchAgents"
192
+ if os.name == "posix" and (paths.home / "Library").exists():
193
+ launch_agents.mkdir(parents=True, exist_ok=True)
194
+ plist = launch_agents / f"com.codex-coach.{schedule}.plist"
195
+ interval = (
196
+ "<dict><key>Hour</key><integer>9</integer></dict>"
197
+ if schedule == "daily"
198
+ else "<dict><key>Weekday</key><integer>0</integer><key>Hour</key><integer>9</integer></dict>"
199
+ )
200
+ plist.write_text(
201
+ f"""<?xml version="1.0" encoding="UTF-8"?>
202
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
203
+ <plist version="1.0">
204
+ <dict>
205
+ <key>Label</key><string>com.codex-coach.{schedule}</string>
206
+ <key>ProgramArguments</key>
207
+ <array>
208
+ <string>{paths.home / '.local' / 'bin' / 'codex-coach'}</string>
209
+ <string>report</string>
210
+ <string>--since</string>
211
+ <string>7d</string>
212
+ </array>
213
+ <key>StartCalendarInterval</key>
214
+ {interval}
215
+ </dict>
216
+ </plist>
217
+ """,
218
+ encoding="utf-8",
219
+ )
220
+ return plist
221
+ return None
222
+
223
+
224
+ def _scheduler_paths(paths: CoachPaths) -> list[Path]:
225
+ return [
226
+ paths.home / ".config" / "systemd" / "user" / "codex-coach.service",
227
+ paths.home / ".config" / "systemd" / "user" / "codex-coach.timer",
228
+ paths.home / "Library" / "LaunchAgents" / "com.codex-coach.weekly.plist",
229
+ paths.home / "Library" / "LaunchAgents" / "com.codex-coach.daily.plist",
230
+ ]