SLOPSHOPPER

tracker-sync

When a session ends, appends a one-line log entry to a markdown tracker file (e.g. in an Obsidian vault).

newmodelprocess
v0.1.0MITupdated 2026-10-09AliakbarNateghi/cc-tracker-sync
A shopper browsing a rack in a slop shop
README

tracker-sync

A Claude Code mod. When a session ends, it appends one line to a markdown tracker file (for example a note in an Obsidian vault):

- 2026-10-10 14:32 · my-project · Fixed the login redirect bug
  • Project: the name of the git root folder of the session's working directory.
  • Summary: a one-line summary of your prompts, written by Haiku after each turn. If that is not ready, the first prompt is used instead.
  • The line goes under a ## Session log heading. The heading is created if it is missing.
  • Sessions with no prompts are skipped.

Install

/plugin install tracker-sync --marketplace AliakbarNateghi/cc-tracker-sync

Answer y to add the marketplace, then pick a scope.

Settings

  • trackerPath: absolute path to the tracker file. Empty (the default) turns the mod off.
Source 1 files
hooks/register.ts 75 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const HEADING = '## Session log'
4
5/** Adds `line` at the end of the `## Session log` section, creating the section if missing. */
6export function addLine(doc: string, line: string): string {
7  const lines = doc.split('\n')
8  const at = lines.findIndex(l => l.trim() === HEADING)
9  if (at < 0) return `${doc.trimEnd() ? doc.trimEnd() + '\n\n' : ''}${HEADING}\n\n${line}\n`
10  let end = at + 1
11  while (end < lines.length && !/^#{1,2} /.test(lines[end] ?? '')) end++
12  while (end > at + 1 && (lines[end - 1] ?? '').trim() === '') end--
13  lines.splice(end, 0, line)
14  return lines.join('\n')
15}
16
17/** The person's own prompts: user rows with text, no tool results, no injected tags. */
18async function prompts($: EngineInterface): Promise<string[]> {
19  const rows = await $.session.messages()
20  return rows
21    .filter(m => m.role === 'user' && !m.toolResults?.length)
22    .map(m => m.text.trim())
23    .filter(t => t && !t.startsWith('<'))
24}
25
26const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim().slice(0, 140)
27
28export const register: Register = (on, options) => {
29  const path = String(options.trackerPath ?? '').trim()
30  if (!path) return
31
32  // session.end allows ~1.5 s in all, too little for a model call, so the
33  // summary is refreshed after each main-thread turn and read at the end.
34  let summary: string | undefined
35  let seen: string[] = []
36
37  on('turn.complete', ($, e, next) => {
38    if (!e.agentId)
39      void (async () => {
40        const ps = (seen = await prompts($))
41        if (!ps.length) return
42        const r = await $.model.complete({
43          model: 'haiku',
44          effort: 'low',
45          maxTokens: 60,
46          timeoutMs: 30_000,
47          prompt:
48            'Summarize what this coding session was about in one short line (max 12 words, no quotes, no trailing period). The user prompts:\n\n' +
49            ps.map(p => `- ${p.slice(0, 500)}`).join('\n'),
50        })
51        if (r.isAnswered && r.text.trim()) summary = oneLine(r.text)
52      })().catch(() => {})
53    return next(e)
54  })
55
56  on('session.end', async ($, e, next) => {
57    // ponytail: some modes (claude -p) have no transcript at session.end; use the last turn's copy.
58    const ps = await prompts($).catch(() => seen)
59    if (ps.length) {
60      const cwd = await $.session.cwd()
61      const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 500 })
62      const root = (git.exitCode === 0 && git.stdout.trim()) || cwd
63      const project = root.replace(/\/+$/, '').split('/').pop() || root
64      const date = (await $.process.run(['date', '+%Y-%m-%d %H:%M'], { timeoutMs: 500 })).stdout.trim()
65      const line = `- ${date} · ${project} · ${summary ?? oneLine(ps[0] ?? '')}`
66      // Only a missing file starts empty; any other read error throws, so the file is never clobbered.
67      const doc = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
68      await $.fs.write(path, addLine(doc, line))
69    }
70    summary = undefined
71    seen = []
72    return next(e)
73  })
74}
75