SLOPSHOPPER

week-calendar

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

newguardprompt
v0.3.0no licenseupdated 2026-10-05AskTinNguyen/ather-mods/week-calendar
A shopper browsing a rack in a slop shop
README

week-calendar

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.

Install on an agent PC

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.

Settings

Set them in Claude Code's plugin config menu (each is optional):

SettingDefaultWhat it does
Machine namethe computer nameThe PC's name in team reports
Operatoryour global git user.nameWho runs the PC
Available hours per week168Denominator of productive time
Reports repohttps://github.com/AskTinNguyen/agent-reports.gitWhere the weekly report goes. owner/name means a GitHub repo; publishing refuses a repo whose name does not mention reports
GitHub loginthe account gh is signed in asTells your merged PRs from teammates' PRs you committed to
Ignored foldersTemp foldersSessions in these folders are left out
Extra git emailsnoneCommit emails that count as this PC's, e.g. the AI agent account

Use it

  • "what did I do this week" or "write my weekly report": the week-calendar agent rebuilds the calendar, Claude writes a three-line report, then asks the weekly survey (rating, most valuable and most wasted session, why no-commit sessions stopped) and publishes the report.
  • "exclude the git polling session from the week calendar" (or "include it"): changes what counts, permanently.
  • The calendar is ~/.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.

How it measures

  • Commits are credited to the session (or subagent) whose 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.
  • Productive: a session that pushed successfully, or whose commits are on a remote branch. Main takes no direct commits, so a push is where work becomes productive.
  • PRs merged: PRs merged that week (GitHub search) whose commits include one this PC made, in that week or the four before. Those whose author is your GitHub login are yours (metrics.prsAuthored); the rest are teammates' PRs you committed to. A session that fed several PRs shares its agent hours and cost between them.
  • Busy time: every gap between consecutive log records of a session or its subagents, unless the later record starts a turn; one step counts for at most 90 minutes. Waiting on a person: from the record before a typed prompt to that prompt, up to 8 hours.
  • Outliers: a session of 4+ hours where 80%+ of turns started by themselves (schedules, loops, plugin or SDK drivers) is excluded from totals.
  • Routine runs: a commitless session of 3 minutes or less whose title recurs 5+ times that week (butlers, schedulers), or in which nobody typed, is automated: left out of totals and no-commit counts, counted as automated hours, and drawn as ticks on each day's edge.
  • The calendar leads with agent-busy hours. With one project it colors sessions by workstream (sessions sharing a PR or a title); pieces of a session under 30 minutes apart are one bar; clicking a bar opens its details; on a phone it shows one day at a time.
  • Weeks in reports are ISO weeks (Monday start) in the PC's local time.

Files

PathWhat it is
~/.calendar/latest.htmlThis week's calendar
~/.calendar/reports/<isoWeek>.jsonWeekly snapshot, kept after Claude Code deletes old logs
~/.calendar/agent-reports/Clone of the reports repo
~/.calendar/survey/<isoWeek>.jsonSurvey answers
~/.calendar/logs/Weekly job logs

Develop

node --test "scripts/test/*.test.mjs"
claude plugin test .
claude plugin validate .

Changes

  • 0.3.0 Calendar redesign: full width, routine runs as ticks, one bar per stretch of a session, workstream colors, details in a side drawer, one-day view on phones, agent-busy headline. Commits a pull or merge brought in are no longer credited by timing unless they carry this PC's git identity; merged PRs split into yours (new GitHub login setting) and teammates' PRs you committed to; a session's hours are shared across the PRs it fed. Reports repo accepts owner/name and refuses a repo that is not a reports repo.
  • 0.2.0 Joins the 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.
  • 0.1.0 The weekly calendar: one block per session, commits and changed files, no-commit sessions, hours per project with parallel sessions counted once.
Source 1 files
hooks/register.ts 204 lines
1import 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