Weekly calendar and agent-effectiveness report of Claude Code sessions: PRs merged and productive agent time per machine, published to a team repo

A Claude Code plugin that turns each agent PC's session logs into a weekly calendar and an effectiveness report: PRs merged and productive agent time. Every Monday each PC pushes last week's report to AskTinNguyen/agent-reports, where a GitHub Action builds the team roll-up.
Needs Node 20+, git, and the GitHub CLI logged in (gh auth login) with read access to the repos the agents work in and write access to agent-reports.
Install it only on PCs whose sessions should be reported: every week it pushes the full session data (prompts, titles, commits) to the team's reports repo. It is a separate plugin from ather-automata for that reason.
claude plugin marketplace add AskTinNguyen/ather-mods
claude plugin install week-calendar@ather --scope user
Start a Claude Code session once: the plugin writes its settings to ~/.calendar/config.json and copies its scripts to ~/.calendar/bin/. Check that a weekly run works without pushing, then schedule it:
node ~/.calendar/bin/weekly.mjs --dry-run
node ~/.calendar/bin/schedule-weekly.mjs
That creates a Task Scheduler task, "week-calendar weekly report", Mondays 06:00 for the current user; --remove deletes it.
Set them in Claude Code's plugin config menu (each is optional):
| Setting | Default | What it does |
|---|---|---|
| Machine name | the computer name | The PC's name in team reports |
| Operator | your global git user.name | Who runs the PC |
| Available hours per week | 168 | Denominator of productive time |
| Reports repo | https://github.com/AskTinNguyen/agent-reports.git | Where the weekly report goes. owner/name means a GitHub repo; publishing refuses a repo whose name does not mention reports |
| GitHub login | the account gh is signed in as | Tells your merged PRs from teammates' PRs you committed to |
| Ignored folders | Temp folders | Sessions in these folders are left out |
| Extra git emails | none | Commit emails that count as this PC's, e.g. the AI agent account |
~/.calendar/latest.html. The first time, the agent asks for a style: dark or light, an accent color, color by project or task type, and the first day of the week.git commit, rebase, merge, pull, cherry-pick, revert or am call made them: the hash git printed, or the commits created while that call ran by this PC's git identities (the repo's user.email and Extra git emails). A pull or merge brings in teammates' commits dated inside the call; those are not yours. This holds when parallel sessions share a repo. A commit no call claims is credited by time only when exactly one session was active in that repo.metrics.prsAuthored); the rest are teammates' PRs you committed to. A session that fed several PRs shares its agent hours and cost between them.| Path | What it is |
|---|---|
~/.calendar/latest.html | This week's calendar |
~/.calendar/reports/<isoWeek>.json | Weekly snapshot, kept after Claude Code deletes old logs |
~/.calendar/agent-reports/ | Clone of the reports repo |
~/.calendar/survey/<isoWeek>.json | Survey answers |
~/.calendar/logs/ | Weekly job logs |
node --test "scripts/test/*.test.mjs"
claude plugin test .
claude plugin validate .
owner/name and refuses a repo that is not a reports repo.ather marketplace. Commits credited to the session (or subagent) whose git call made them; push and merged-PR status; busy, productive, waiting and idle machine hours; automated sessions left out of totals; weekly survey; secret-scanned weekly report pushed to agent-reports; Monday scheduled job.hooks/register.ts 204 lines1import type { Register } from 'claude-code'
2
3const AGENT = 'week-calendar'
4const AGENT_TYPE = `week-calendar:${AGENT}`
5
6// "what did I do this week" / "write my weekly report" (and close variants)
7export const REPORT_ASK = /\bwhat\s+did\s+i\s+(do|get\s+done|work\s+on)\s+this\s+week\b|\bwrite\s+(me\s+)?(up\s+)?my\s+weekly\s+report\b/i
8
9// The scripts copied to ~/.calendar/bin, so the weekly scheduled task has a path that survives plugin updates.
10const SCRIPT_FILES = [
11 'build-calendar.mjs', 'publish-report.mjs', 'weekly.mjs', 'schedule-weekly.mjs',
12 'lib/util.mjs', 'lib/config.mjs', 'lib/logs.mjs', 'lib/git.mjs', 'lib/github.mjs',
13 'lib/analyze.mjs', 'lib/render.mjs', 'lib/report.mjs', 'lib/secrets.mjs',
14]
15
16const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
17
18// What the week-calendar agent may run: the calendar scripts and git reads, one plain command at a time.
19export function bashVerdict(command: string): string | null {
20 const cmd = command.trim()
21 if (/[;&|><`]|\$\(/.test(cmd)) return 'week-calendar runs one plain command at a time: no pipes, redirects or chaining.'
22 const isScript = /^node\s+("[^"]*(build-calendar|publish-report)\.mjs"|\S*(build-calendar|publish-report)\.mjs)(\s|$)/.test(cmd)
23 const isGitRead = /^git(\s+-C\s+("[^"]*"|\S+))?\s+(log|show|rev-parse|status|diff|config\s+(--get\s+)?user\.(email|name))(\s|$)/.test(cmd)
24 return isScript || isGitRead ? null : 'week-calendar may only run git read commands, build-calendar.mjs and publish-report.mjs.'
25}
26
27// Where the week-calendar agent may write: ~/.calendar and its own agent memory.
28export function writeVerdict(filePath: string, home: string): string | null {
29 const target = norm(filePath)
30 const h = norm(home)
31 const inCalendar = target.startsWith(`${h}/.calendar/`)
32 const inMemory = target.startsWith(`${h}/.claude/agent-memory/`) && /week-calendar/.test(target)
33 return !target.includes('/../') && (inCalendar || inMemory)
34 ? null
35 : `week-calendar may only write under ~/.calendar/ or its own agent memory, not ${filePath}`
36}
37
38function agentPrompt(scripts: string, home: string) {
39 const build = `node "${scripts}/build-calendar.mjs"`
40 const publish = `node "${scripts}/publish-report.mjs"`
41 return `You are week-calendar. Your only job is the weekly calendar and weekly effectiveness report of the user's Claude Code sessions. You never do any other task.
42
43## Tools and limits
44- Bash: only to run the two scripts below and \`git\` read commands. One plain command per call: no redirects, pipes or chaining.
45- You write files ONLY under ${home}/.calendar/ and in your own agent memory directory. Never anywhere else.
46- The only data that leaves this PC is the weekly report the publish script pushes to the team's private agent-reports repo.
47
48## Scripts
49${build} --theme <dark|light> --accent <#rrggbb> --color-by <project|task> --week-start <monday|sunday> --export
50- --list-untitled prints the sessions that have no title (sessionId + first typed message), without building.
51- --week-offset -1 builds last week instead of this one.
52- It reads ~/.claude/projects/**/*.jsonl (subagent logs too), converts times to local time, names projects by their git remote,
53 makes one block per session (a new block after any gap over 30 minutes), credits each commit to the session whose git call made it,
54 checks pushes and PRs merged (read-only gh api), and computes agent-busy, productive, waiting-on-a-person and idle hours.
55 It writes ${home}/.calendar/latest.html and latest.json, and with --export the weekly snapshot ${home}/.calendar/reports/<isoWeek>.json.
56 It prints a JSON summary that includes isoWeek.
57- Outliers: a session that ran 4h+ where 80%+ of its turns started by themselves (scheduled tasks, loops, plugin or SDK drivers)
58 is excluded from totals and reports automatically; it stays on the calendar, greyed out. So are routine runs: commitless sessions of
59 3 minutes or less whose title recurs 5+ times in the week (butlers, schedulers) or in which nobody typed; the calendar draws them as
60 ticks on each day's edge. Add --no-auto-exclude only when asked.
61${publish} [--week-offset -1]
62- Pushes ${home}/.calendar/reports/<isoWeek>.json (secrets redacted) to the agent-reports repo. Run it only when the request asks
63 to publish, or after saving a survey.
64
65## Preferences (memory)
66Your preferences live in your agent memory directory as \`preferences.md\` with exactly these four lines:
67theme: dark|light
68accent: #rrggbb
69color-by: project|task
70week-start: monday|sunday
71
72Every run, first read preferences.md from your memory directory.
73- If it is missing or incomplete AND the request does not contain the answers, do nothing else and reply with exactly:
74 NEEDS_PREFERENCES
75 1. Style: dark or light?
76 2. One accent color (name or hex)?
77 3. Color blocks by project or by task type?
78 4. Week starts on Monday or Sunday?
79- If the request contains the answers, convert the color to a hex value, write preferences.md (and add a one-line pointer to it in MEMORY.md
80 in your memory directory if that file exists or your memory instructions ask for one), then continue.
81- Always follow the saved preferences; only change them when the request explicitly gives new ones.
82
83## Excluding or including sessions
84When the request asks to exclude (or include, count, put back) a session, find it in ${home}/.calendar/latest.json "sessions"
85by title, first message, project or time. If more than one could match, list them and do nothing. Then edit
86${home}/.calendar/excluded.json, { "exclude": [sessionIds], "include": [sessionIds] }: to exclude, add the id to exclude and drop it
87from include; to include, the reverse (include also overrides the automatic outlier rule). Create the file if missing. Then rebuild.
88
89## Saving the weekly survey
90When the request gives survey answers, write ${home}/.calendar/survey/<isoWeek>.json (isoWeek from the request, else from the build output):
91{ "rating": 1-5, "mostValuable": { "sessionId": "...", "title": "..." } or null, "mostWasted": { ... } or null,
92 "noCommitReasons": { "<sessionId>": "blocked" | "exploratory" | "abandoned" | "parked" }, "note": "..." or null,
93 "answeredAt": "<ISO time now>" }
94Keep fields already in the file that the request does not change. Then rebuild with --export and run the publish script.
95
96## Each run
971. Read preferences (above).
982. Run the build with --list-untitled and the preference flags. For each session listed, sum up its first typed message in 3 to 5 words
99 (plain words, no quotes, no trailing period). Merge them into ${home}/.calendar/titles.json, a JSON object { "<sessionId>": "<summary>" },
100 keeping the entries already there. Skip this step if the list is empty.
1013. Run the build with the preference flags and --export.
1024. Reply briefly: the HTML path, the isoWeek, PRs merged, productive time (percent), agent-busy / waiting / idle hours,
103 hours per project (top 5), the no-commit sessions (title, project, end time) newest first, and any excluded sessions with their reason.
104 The full data is in ${home}/.calendar/latest.json.`
105}
106
107export const register: Register = (on, options) => {
108 // agentId -> whether that loop is a week-calendar agent
109 const ours = new Map<string, boolean>()
110 let home = ''
111
112 on('session.start', async ($, e, next) => {
113 home = ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
114 const root = $.plugin.root.replace(/\\/g, '/')
115 const bin = `${home}/.calendar/bin`
116
117 // Settings for the scripts, from the plugin's userConfig; empty fields keep the scripts' defaults.
118 const str = (k: string) => String(options[k] ?? '').trim()
119 const config: Record<string, unknown> = {}
120 if (str('machineName')) config.machineName = str('machineName')
121 if (str('operator')) config.operator = str('operator')
122 if (Number(options.availableHoursPerWeek) > 0) config.availableHoursPerWeek = Number(options.availableHoursPerWeek)
123 if (str('reportsRepoUrl')) config.reportsRepoUrl = str('reportsRepoUrl')
124 if (str('githubLogin')) config.githubLogin = str('githubLogin')
125 if (str('ignoreFolders')) config.ignoreFolders = str('ignoreFolders').split(',').map(s => s.trim()).filter(Boolean)
126 if (str('gitEmails')) config.gitEmails = str('gitEmails').split(',').map(s => s.trim()).filter(Boolean)
127 try { await $.fs.write(`${home}/.calendar/config.json`, JSON.stringify(config, null, 2) + '\n') } catch {}
128
129 // A stable copy of the scripts for the scheduled task.
130 try {
131 const manifest = JSON.parse(String(await $.fs.read(`${root}/.claude-plugin/plugin.json`)))
132 for (const f of SCRIPT_FILES) {
133 const src = String(await $.fs.read(`${root}/scripts/${f}`))
134 const dest = `${bin}/${f}`
135 let cur = ''
136 try { cur = String(await $.fs.read(dest)) } catch {}
137 if (cur !== src) await $.fs.write(dest, src)
138 }
139 await $.fs.write(`${bin}/version.json`, JSON.stringify({ version: manifest.version }) + '\n')
140 } catch {}
141
142 await $.agent.register({
143 name: AGENT,
144 description:
145 "Builds the weekly calendar and effectiveness report of the user's Claude Code sessions (local HTML in ~/.calendar/; PRs merged, " +
146 'productive agent time). Use it to (re)generate the calendar, before any weekly report, to exclude or include sessions ' +
147 '(outliers such as automated polling sessions), to save weekly survey answers, and to publish the weekly report to the team repo. ' +
148 'If it answers NEEDS_PREFERENCES, ask the user those four questions with AskUserQuestion, then run it again with the answers in the prompt.',
149 prompt: agentPrompt(`${root}/scripts`, home),
150 tools: ['Bash', 'Read', 'Write', 'Edit', 'Glob'],
151 memory: 'user',
152 })
153 return next(e)
154 })
155
156 // Keep the agent inside its lane.
157 on('tool.call', async ($, e, next) => {
158 if (!e.agentId) return next(e)
159 if (!ours.has(e.agentId)) {
160 const agents = await $.agent.list()
161 const found = agents.find(a => a.id === e.agentId)
162 if (!found) return next(e)
163 ours.set(e.agentId, found.type === AGENT_TYPE || found.type === AGENT)
164 }
165 if (!ours.get(e.agentId)) return next(e)
166
167 const input = e as unknown as Record<string, unknown>
168 if (e.tool === 'Write' || e.tool === 'Edit' || e.tool === 'MultiEdit' || e.tool === 'NotebookEdit') {
169 const deny = writeVerdict(String(input.file_path ?? input.notebook_path ?? ''), home)
170 if (deny) return { deny }
171 }
172 if (e.tool === 'Bash') {
173 const deny = bashVerdict(String(input.command ?? ''))
174 if (deny) return { deny }
175 }
176 if (e.tool === 'PowerShell') return { deny: 'week-calendar runs its scripts through Bash only.' }
177 return next(e)
178 })
179
180 // The weekly-report rule.
181 on('prompt.submit', async ($, e, next) => {
182 if (!REPORT_ASK.test(e.text)) return next(e)
183 const rule = [
184 'Weekly report rule (week-calendar plugin):',
185 `1. First regenerate the calendar: call the Agent tool with subagent_type "${AGENT_TYPE}" and prompt "Regenerate this week's calendar."`,
186 ' If it answers NEEDS_PREFERENCES, ask the user its four questions with AskUserQuestion (style dark/light, one accent color,',
187 ' color by project or task type, week starts Monday or Sunday), then call it again with the answers in the prompt.',
188 `2. Read ${home}/.calendar/latest.json (sessions, totals, metrics, machineHours, prsMerged, noCommitSessions, survey).`,
189 '3. Reply with exactly 3 lines, then the calendar path on a 4th line:',
190 ' Line 1: what the user mainly got done this week (from merged PR titles, session titles and commit subjects), with the number of PRs they authored',
191 ' (metrics.prsAuthored; prsMerged entries with yours: true) and how many teammate PRs they contributed commits to.',
192 ' Line 2: which project took the most time, with its hours (parallel sessions counted once, excluded sessions left out), and the productive time percent.',
193 ' Line 3: which no-commit sessions to pick up first next week (most recent and longest first, by title).',
194 '4. Unless latest.json survey.answered is true, run the weekly survey with AskUserQuestion, one call with three questions:',
195 ' rating ("5 Excellent", "4 Good", "3 Okay", "1-2 Poor"); most valuable session (the three sessions with the most busyHours, by title);',
196 ' most wasted session (up to three of the longest no-commit or excluded sessions that are not automated, plus "None"). If there are no-commit sessions, a second call',
197 ' asks the reason for up to four of the longest: blocked, exploratory, abandoned or parked.',
198 ` Then call the Agent tool with subagent_type "${AGENT_TYPE}": "Save the weekly survey for <survey.isoWeek>: <the answers as JSON with sessionIds>. Then rebuild and publish."`,
199 ' If the user dismisses the survey, skip it without asking again in this conversation.',
200 ].join('\n')
201 return next({ ...e, context: [...(e.context ?? []), rule] })
202 })
203}
204