diff --git a/examples/sample-output.md b/examples/sample-output.md new file mode 100644 index 0000000..f4321a1 --- /dev/null +++ b/examples/sample-output.md @@ -0,0 +1,128 @@ +# Sample outputs + +Reference outputs the skill should produce given a representative day. Use these as regression anchors when iterating on SKILL.md wording. + +--- + +## Example 1 — Full four-bucket day + +**Ask:** `debrief today` + +**Output:** + +``` +Fri, Apr 18 2026 + +Decided + - Went with go-yaml over mapstructure — reflection cost wasn't worth the flexibility + - Dropped the Slack webhook path for v1 — too much scope + +Shipped + - Handle PR-squash chore commits (#11) + - Redesigned the cost table with per-model breakdown (#12) + - Added --project filter to standup and cost commands + +Investigated + - goreleaser's default matrix skips arm64 Linux — had to add it explicitly + +Watch + - The classifier regex is too aggressive on test: prefixes and might drop real work +``` + +Note: **Decided** and **Investigated** bullets come from Claude session notes. **Shipped** bullets are a mix of commits and session notes about completed work. **Watch** captures a risk the user flagged during a session but didn't file as an issue. + +--- + +## Example 2 — Quiet day (chore-only) + +**Ask:** `debrief today` + +**Commits:** only `chore: bump deps`, `chore: format`, `test: flaky retry`. + +**Session notes:** none survived the filter (all were planning language). + +**Output:** + +``` +Quiet day — just chores and lints. Nothing shipped worth writing up. +``` + +This exact line signals "the tool worked, your day was just quiet" — distinct from "the tool found nothing" (which means a broken scan). + +--- + +## Example 3 — Nothing at all + +**Ask:** `debrief yesterday` on a day the user didn't work. + +**Output:** + +``` +No activity to report. +``` + +--- + +## Example 4 — Slack format + +**Ask:** `debrief today as slack` + +**Output:** + +```` +`Fri, Apr 18 2026` + +- Went with go-yaml over mapstructure — reflection cost wasn't worth the flexibility +- Handle PR-squash chore commits (#11) +- goreleaser's default matrix skips arm64 Linux — had to add it explicitly +- The classifier regex is too aggressive on test: prefixes and might drop real work +```` + +- Date is wrapped in backticks (Slack renders it as inline code). +- No section headers — flat list. +- Bucket order is preserved in bullet order. + +--- + +## Example 5 — Week range + +**Ask:** `debrief this week` + +**Output:** + +``` +Week of Apr 13 – Apr 19, 2026 + +Decided + - Went with go-yaml over mapstructure + - Dropped cost subcommand — claudecost owns pricing + +Shipped + - Handle PR-squash chore commits (#11) + - Humanizer visibility banner (#9) + - CWD-fallback git discovery (#10) + - Cost table redesign with per-model breakdown (#12) + +Investigated + - goreleaser arm64 Linux matrix gap + - GIT_* env vars override -C flag in subprocesses + +Watch + - Classifier aggressive on test: prefixes +``` + +For multi-day ranges, bullets are deduplicated and merged across days. Date header is a range, not a single day. + +--- + +## What "good output" means — tuning rubric + +When dogfooding, grade each day's output against these questions: + +1. **Could I paste this into Slack verbatim?** If no — the wording is off. +2. **Does every bullet describe real work I did?** If no — the note filter let noise through. +3. **Is anything I actually did missing?** If yes — either the filter is too strict, or the commit was covered-by-notes incorrectly. +4. **Did Decided/Investigated/Watch bullets land in the right buckets?** If a "decided" showed up in Shipped — the keyword list missed it. +5. **On a chore-only day, does it say "Quiet day"?** If it says "No activity" — the noise-vs-emptiness distinction broke. + +Fix the SKILL.md wording — don't add code. diff --git a/internal/collector/git_test.go b/internal/collector/git_test.go index 17a4f85..bb43e89 100644 --- a/internal/collector/git_test.go +++ b/internal/collector/git_test.go @@ -31,10 +31,44 @@ func sanitizedEnv() []string { return out } +// unsetGitHookVars unsets process-level GIT_* variables that git sets when +// running inside a hook (e.g. pre-commit). sanitizedEnv only scrubs env for +// subprocesses we launch directly — the production collector code under test +// spawns its own git processes that inherit os.Environ(), so those still see +// the leaked GIT_DIR/GIT_WORK_TREE and target the outer worktree instead of +// the temp repo. Unset at the process level and restore via t.Cleanup. +func unsetGitHookVars(t *testing.T) { + t.Helper() + hookVars := []string{ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_PREFIX", + "GIT_EXEC_PATH", + "GIT_REFLOG_ACTION", + } + for _, k := range hookVars { + old, present := os.LookupEnv(k) + if !present { + continue + } + if err := os.Unsetenv(k); err != nil { + t.Fatalf("unset %s: %v", k, err) + } + t.Cleanup(func() { + _ = os.Setenv(k, old) + }) + } +} + func TestGitCollector_CollectFromTempRepo(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } + // See unsetGitHookVars — required when the test runs inside a pre-commit + // hook so Collect() targets the temp repo, not the outer worktree. + unsetGitHookVars(t) // Create a temp repo with commits. dir := t.TempDir() @@ -58,7 +92,13 @@ func TestGitCollector_CollectFromTempRepo(t *testing.T) { } run("init") - run("checkout", "-b", "main") + // Disable any global hooksPath — the temp repo inherits ~/.gitconfig + // settings; a developer-machine commit-msg hook (e.g. min-length) would + // otherwise fire inside the test. + run("config", "core.hooksPath", "/dev/null") + // -B (create-or-reset) tolerates init.defaultBranch=main, which makes + // git init pre-create "main" and then reject -b main. + run("checkout", "-B", "main") // Belt-and-suspenders: also set identity via repo-local config so commits // resolve to test@example.com even if GIT_AUTHOR_* env doesn't propagate. run("config", "user.email", "test@example.com") @@ -159,6 +199,8 @@ func TestGitCollector_MultiDayCommitsSplitByDay(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } + // See TestGitCollector_CollectFromTempRepo for why this is needed. + unsetGitHookVars(t) dir := t.TempDir() repo := filepath.Join(dir, "multi-day-project") @@ -186,7 +228,9 @@ func TestGitCollector_MultiDayCommitsSplitByDay(t *testing.T) { ) run(baseEnv, "init") - run(baseEnv, "checkout", "-b", "main") + // See TestGitCollector_CollectFromTempRepo for the rationale on these. + run(baseEnv, "config", "core.hooksPath", "/dev/null") + run(baseEnv, "checkout", "-B", "main") run(baseEnv, "config", "user.email", "test@example.com") run(baseEnv, "config", "user.name", "Test User") diff --git a/skills/debrief/SKILL.md b/skills/debrief/SKILL.md new file mode 100644 index 0000000..44471c6 --- /dev/null +++ b/skills/debrief/SKILL.md @@ -0,0 +1,322 @@ +--- +name: debrief +description: Generate a daily standup — what you decided, shipped, and investigated — from local git history and Claude Code session logs. 100% local, no network calls. Use when the user asks for a standup, daily summary, end-of-day writeup, "what did I do today", or similar. +allowed-tools: Bash(git:*), Bash(ls:*), Bash(date:*), Bash(env:*), Bash(pbcopy:*), Bash(xclip:*), Read, Grep, Glob +--- + +# debrief + +Synthesize a standup from local git commits + Claude Code session logs, grouped by intent into four buckets: **Decided, Shipped, Investigated, Watch**. + +Most daily-summary tools just list commits. debrief also reads the user's Claude Code session history (`~/.claude/projects/*.jsonl`) to surface decisions, discoveries, and risks that never made it into a commit message. + +Runs entirely against local files. No API calls. No GitHub API. + +--- + +## When to invoke + +Trigger on any of: + +- "debrief" / "/debrief" +- "what did I (do|ship|work on|decide) (today|yesterday|this week|this month)" +- "give me a standup" / "standup summary" / "daily summary" / "end-of-day writeup" +- "what's worth writing up from today" + +Do **not** invoke for: changelog generation (use a changelog skill), PR descriptions, retrospectives across many weeks. + +--- + +## Inputs + +All optional. Free-form — parse intelligently: + +| Input | Examples | Default | +|---|---|---| +| Time range | `today`, `yesterday`, `this week`, `this month`, `2026-04-18`, `last 3 days` | `today` | +| Project filter | `--project debrief`, `only debrief`, `for cloudprobe/*` | all repos | +| Format | `slack`, `flat`, `bullets` | sectioned text | +| Copy | `copy it`, `put it on my clipboard` | off | + +If inputs are ambiguous, ask one short clarifying question before running. Never fabricate a range. + +--- + +## Procedure + +Do these steps in order. Do not skip steps even if you think the next one is obvious — the rules below are tuned and skipping loses quality. + +### 1. Resolve the time range + +Convert the user's input into a concrete `[start, end]` pair in the local timezone. + +- `today` → today 00:00 → now +- `yesterday` → yesterday 00:00 → yesterday 23:59:59 +- `this week` → most recent Monday 00:00 → now +- `this month` → day 1 of current month → now +- `last N days` → now - N days → now +- explicit `YYYY-MM-DD` → that day 00:00 → that day 23:59:59 + +**Timezone handling.** The window above is local-time. Git's `--since` / `--until` already honor the local zone, so §3 needs no conversion. §4 (Claude session notes) compares against JSONL `timestamp` values that are ISO-8601 UTC (`…Z`) — convert *both* sides to Unix epoch seconds before comparing. Do not string-compare ISO forms, and do not assume the JSONL record is in local time. + +Scan these locations, depth 2: +- `~/work` +- `~/projects` +- `~/code` +- `$PWD` (fallback if above yield nothing, or if user is inside a repo) + +A directory is a repo if it contains `.git/` or `.git` file. If the CWD itself is a repo, include it explicitly — directory walks that only look at children miss the CWD. + +If all configured paths are missing, fall back to `$PWD` and note what was scanned so the user knows. + +### 3. Collect commits per repo + +For each repo, run: + +```bash +git -C log --all \ + --since="" --until="" \ + --author="$(git -C config user.email)" \ + --format="%H|%at|%s" +``` + +**Important: strip `GIT_*` env vars before invoking git.** If `GIT_DIR`, `GIT_WORK_TREE`, or `GIT_INDEX_FILE` are set in the environment, git ignores `-C` and queries the ambient repo instead. This is a real bug seen in production. Use `env -i PATH="$PATH" HOME="$HOME" git -C ...` or unset the vars explicitly. + +Parse each line as `||`. Keep the subject line (first line of commit message) for classification. + +### 4. Collect Claude session notes + +Walk `~/.claude/projects/**/*.jsonl`. Skip `memory/` and `tool-results/` subdirectories — not user work product. + +Each JSONL file is one Claude Code session. Lines are records like: + +```json +{"type":"assistant","sessionId":"...","timestamp":"...","cwd":"/path","gitBranch":"main","message":{"id":"msg_...","content":[{"type":"text","text":"..."},{"type":"tool_use",...}]}} +``` + +For each assistant message within `[start, end]` (per the epoch-seconds comparison rule in §1): +- Dedup globally by `message.id` across all files (same message can appear in parent + subagent files). +- For each `content` block of type `"text"`, extract the text and pass it through the **note filter** (§5). + +Attribute each surviving note to a project via `rec.cwd`: +- Run `git -C remote get-url origin` and derive `org/repo` from the URL. +- Fall back to `basename(cwd)`. +- Cache this lookup per cwd — it's called often. + +### 5. Note filter rules + +A text block becomes a note only if **all** of these hold. Apply in order; reject early. + +**Length and shape:** +- `len(trimmed) > 0` and `len(trimmed) <= 600` +- Does not contain triple-backtick fenced code (```` ``` ````) or a tab character — both signal code, not a note. + +**First sentence must be ≥ 15 chars**, where "first sentence" means: split on `". "` / `"! "` / `"? "` but ignore breaks after known abbreviations (`e.g.`, `i.e.`, `vs.`, `etc.`, `Mr.`, `Dr.`, `St.`, month abbreviations `Jan.` through `Dec.`). Use only the first paragraph (break at `\n\n`). Strip markdown: `**bold**` → `bold`, `*italic*` → `italic`, `` `code` `` → `code`, leading `- ` or `• `. + +**Reject planning / hedging language.** Drop if the lowercased first sentence starts with any of: +``` +"let me ", "i'll ", "i will ", "i'm going to ", "i need to ", +"let's ", "now i'll ", "next ", "first ", "to " +``` + +**Require action-completion prefix.** Keep only if the lowercased first sentence starts with one of: +``` +"i've ", "i have ", "done", "fixed", "added", "updated", "removed", +"built", "implemented", "created", "refactored", "changed", "moved", +"cleaned", "dropped", "replaced", "simplified", "wired", "switched", +"deleted", "renamed", "extracted", "merged", "resolved", +"pushed", "committed", "tagged", "released", "deployed", "shipped", +"wrote", "rewrote", "generated", "configured", "installed", "upgraded", +"migrated", "patched", "reverted", "exposed", "enabled", "disabled", +"all tests", "tests pass", "build passes" +``` + +**Reject list intros and truncations:** +- Ends with `:` → list intro, drop. +- Contains `:` followed later by ` - ` → list intro, drop. +- Ends with ` N.` where N is digits (e.g. `captured: 1.`) → numbered-list fragment, drop. +- Ends with `(e.g.`, `(i.e.`, `(`, or `e.g.` → truncated, drop. + +**Reject conversational responses:** +- Contains `http://` or `https://` → not a standup bullet. +- Contains a standalone `you` / `your` / `you're` / `you'll` / `yourself` word → addresses the reader, drop. +- Starts with `"go ahead"`, `"feel free"`, `"keep the"`, `"keep in"`, `"paste "`, `"try the"`, `"run the"`, `"check the"`, `"note that"`, `"just "`, `"sure,"`, `"of course"` → conversational, drop. + +**Clean the surviving note:** +- Strip leading `"Done. "`, `"Done — "`, `"Done: "`, `"Done, "`, `"Done! "` — recapitalize the remainder. +- Strip leading `"I've "` or `"I have "` — recapitalize the remainder. +- If what's left is bare `"Done."` / `"Done"` / `"Done!"` — drop. + +**Final quality gates (post-clean):** +- `len(note) >= 40`. +- Does not match a bare `[0-9a-f]{7,}` hex hash (naked commit SHA — noise). +- Does not start with `"pushed"`, `"committed"`, `"fixed."` — these add no info commits don't. +- Not literally `"All the content."`. + +### 6. Classify + +**Commits → bucket:** + +| Commit shape | Bucket | +|---|---| +| Starts with `"Merge pull request"` or `"Merge branch"` | **Shipped** | +| No `:` in subject (no conventional prefix) | **Shipped** | +| Prefix `feat` / `fix` / `perf` / `refactor` / `build` / `ci` | **Shipped** | +| Prefix `docs` AND body-after-prefix > 20 chars | **Shipped** | +| Prefix `docs` AND body ≤ 20 chars | **Drop (noise)** | +| Prefix `chore` / `test` AND has `(#N)` suffix AND body-after-prefix > 20 chars | **Shipped** (merged via review) | +| Prefix `chore` / `test` without both conditions | **Drop (noise)** | +| Any other prefix | **Shipped** | + +"Prefix" matching: take the text before the first `:`, lowercase it, strip anything in parentheses (so `feat(cli):` matches `feat`). + +"Body-after-prefix": text after the first `:`, trimmed, with the first char capitalized. + +**Notes → bucket:** match the lowercased note against these keyword sets in order. First match wins. + +| If lowercased note contains | Bucket | +|---|---| +| `"decided"`, `"went with"`, `"chose"`, `"switched to"`, `"picked"` | **Decided** | +| `"found"`, `"discovered"`, `"ruled out"`, `"investigated"`, `"turns out"` | **Investigated** | +| `"risk"`, `"concern"`, `"watch out"` | **Watch** | +| Anything else | **Shipped** | + +### 7. Deduplicate commits covered by notes + +For each commit heading for the Shipped bucket, check if a surviving note covers it. Coverage rule: +- Extract "significant words" from the commit and each note: lowercase, strip surrounding punctuation, keep words > 4 chars that are not in `{with, from, that, this, into, have, been, when, also, then}`. +- If at least half the commit's significant words appear in any single note, drop the commit (the note already describes it). + +This is the reason notes are richer than commit logs — don't skip this step. + +### 8. Render + +Emit bullets in this exact bucket order: **Decided → Shipped → Investigated → Watch**. + +Skip empty buckets. If all four are empty and at least one commit was filtered as noise, emit: +``` +Quiet day — just chores and lints. Nothing shipped worth writing up. +``` +If all four are empty and zero commits existed, emit: +``` +No activity to report. +``` + +**Default (sectioned text) format:** + +``` + + +Decided + - + - + +Shipped + - + - + +Investigated + - + +Watch + - +``` + +- Two-space indent before `-` for bullets. +- Blank line between sections; **no** blank line between section header and its first bullet. +- Date header uses `Mon, Jan 2 2006` format for single-day output. For ranges, use something like `Week of Apr 13 – Apr 19, 2026`. + +**Slack format (`slack` / `flat` request):** flat bullets, no section headers, wrap the date in backticks: + +``` +`Fri, Apr 18 2026` + +- +- +- +- +``` + +Buckets still dictate the *order* of bullets in the flat list. No section headers. + +**Copy request:** after rendering, do not pass the output through the shell as an argument — commit subjects and notes commonly contain `"`, `` ` ``, `$`, and `\`, which would be reinterpreted. Write the rendered payload to a tempfile and pipe from there: + +```bash +tmp=$(mktemp) +# write the rendered standup to "$tmp" via the Write tool (no shell interpolation) +pbcopy < "$tmp" # macOS +xclip -selection clipboard < "$tmp" # Linux +rm -f "$tmp" +``` + +Confirm with a one-line status on a stderr-style second message: `"Copied to clipboard."` + +### 9. Display + +Print the rendered standup. Do not wrap it in code fences unless the user asked for Slack format — the output is meant to be copy-pasted. + +If the scan fell back to `$PWD` (configured paths had no repos), append a single italic line at the end: `*Scanned $PWD only — no repos found under ~/work, ~/projects, ~/code.*` + +--- + +## Style and tone + +- **No AI filler.** Do not use "utilize," "leverage," "delve," "pivotal," "navigate the landscape," "the intersection of," "comprehensive," "robust." Write like a tired engineer at 5pm. +- **Contractions.** "Didn't," "don't," "wasn't" — not "did not." +- **No rule-of-three.** Prefer two examples or one. Never three parallel clauses for emphasis. +- **Preserve identifiers verbatim.** File names, function names, PR numbers (`#42`), commit short-SHAs — do not rewrite or translate. +- **One-line bullets.** Each bullet is a single sentence. Fragments are fine. +- **Past tense, active voice.** "Redesigned the cost table." Not "The cost table was redesigned." + +--- + +## Worked example + +**Input:** `debrief today` on 2026-04-18. + +**Collected:** +- Commit `feat(classifier): handle PR-squash chore commits (#11)` in `cloudprobe/debrief`. +- Commit `chore: bump goreleaser to 2.4.1` in `cloudprobe/debrief`. +- Session note `"Decided to go with go-yaml over mapstructure — reflection cost wasn't worth the flexibility."` +- Session note `"Found that goreleaser's default matrix skips arm64 Linux, had to add it explicitly."` +- Session note `"Risk: the classifier regex is too aggressive on test: prefixes and might drop real work."` + +**Classification:** +- `feat` commit → Shipped. +- `chore` commit without `(#N)` → noise, drop. +- "Decided to go with..." → Decided. +- "Found that..." → Investigated. +- "Risk:..." → Watch. + +**Output:** + +``` +Fri, Apr 18 2026 + +Decided + - Went with go-yaml over mapstructure — reflection cost wasn't worth the flexibility + +Shipped + - Handle PR-squash chore commits (#11) + +Investigated + - goreleaser's default matrix skips arm64 Linux — had to add it explicitly + +Watch + - The classifier regex is too aggressive on test: prefixes and might drop real work +``` + +--- + +## Non-goals + +- **No network calls.** This skill reads local files. Do not call the GitHub API, Linear, Jira, or any web service even if it would make the output "better." +- **No LLM-generated content beyond what's derived from the inputs.** If a bucket is empty, leave it empty — don't invent bullets. +- **No multi-person aggregation.** This is a personal standup. Team rollups are out of scope. +- **No scheduled/cron use.** Skills run inside a Claude session. For unattended standups, the user can ask daily inside a running session. + +--- + +## Acknowledgements + +Rules ported from the Go CLI at [cloudprobe/debrief](https://github.com/cloudprobe/debrief) (now archived). The 4-bucket classifier, session-note filter, and PR-squash handling are tuned to months of real standup output — preserve them literally.