Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
54881f0
add session breadcrumb and Cursor transcript locator
Prog-Jacob Jun 25, 2026
ceaf499
parse Claude and Cursor transcripts in session queries
Prog-Jacob Jun 25, 2026
3c9060e
add combined distill-preload command for forked distill
Prog-Jacob Jun 25, 2026
99b0a7b
unify session hooks for Claude Code and Cursor
Prog-Jacob Jun 25, 2026
087898c
rewrite skills to tool-neutral instruction dialect
Prog-Jacob Jun 25, 2026
4bf0d4b
add Cursor plugin manifest and install script
Prog-Jacob Jun 25, 2026
cd42c55
document dual-tool install and invocation
Prog-Jacob Jun 25, 2026
e3a5fe3
add persistent learnings overlay and env-context detection
Prog-Jacob Jun 25, 2026
545855a
surface learnings as standing guidance across all skills
Prog-Jacob Jun 25, 2026
7a72b67
rewrite distill self-improve to emit learnings with a guarded observa…
Prog-Jacob Jun 25, 2026
387cc4a
capture missing-dependency friction as observations in run and suggest
Prog-Jacob Jun 25, 2026
59bb799
fire the maintenance trigger only on unprocessed input
Prog-Jacob Jun 25, 2026
dd87440
prune the log in every distill branch, not only deep maintenance
Prog-Jacob Jun 25, 2026
950ee7a
describe self-improvement as the learnings overlay it ships
Prog-Jacob Jun 25, 2026
fdd8466
bump version to 2.0.0
Prog-Jacob Jun 25, 2026
3d8693f
migrate the test suite from a shell script to bats
Prog-Jacob Jun 25, 2026
127bc1b
add makefile and ci workflow to run tests and lint
Prog-Jacob Jun 25, 2026
cf8c072
drop spec-ticket refs and changelog phrasing from library comments
Prog-Jacob Jun 25, 2026
11dfe0d
unify transcript extraction across both tools and share the project-d…
Prog-Jacob Jun 25, 2026
fbea4d2
derive the cursor workspace from the hook payload instead of the cwd
Prog-Jacob Jun 25, 2026
f047250
cover session lifecycle, watch, learnings retention, and write-habit
Prog-Jacob Jun 25, 2026
631395d
correct install docs and tighten skill wording
Prog-Jacob Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "habit",
"version": "1.10.0",
"version": "2.0.0",
"description": "Intelligent prompt inventory. Observes, captures, and evolves reusable prompts into a living collection.",
"author": {
"name": "prog-jacob"
Expand Down
11 changes: 11 additions & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "habit",
"displayName": "Habit",
"version": "2.0.0",
"description": "Intelligent prompt inventory. Observes, captures, and evolves reusable prompts into a living collection.",
"author": { "name": "prog-jacob" },
"repository": "https://github.com/Prog-Jacob/habit",
"keywords": ["habits", "prompts", "workflows", "productivity", "automation"],
"skills": "./skills/",
"hooks": "./hooks/hooks.cursor.json"
}
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
pull_request:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# shellcheck ships on the runner image; make lint is the single source of
# the lint command (see the Makefile for the -x / SC1091 rationale).
- name: ShellCheck
run: make lint

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install bats and jq
run: sudo apt-get update && sudo apt-get install -y bats jq
- name: Run tests
run: make test
13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.PHONY: check lint test

# check runs lint then tests; CI invokes the same targets.
check: lint test

# Lint from the entry points so -x follows every sourced lib and analyzes the
# assembled program (the libs are sourced fragments, not standalone scripts).
# SC1091 (unfollowable source paths) is the only remaining noise; silence it.
lint:
shellcheck -x -e SC1091 bin/habit-tools.sh hooks/*.sh install.sh

test:
bats test/
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,34 @@

## Install

Requires [Claude Code](https://code.claude.com/docs/en/overview) and [jq](https://jqlang.github.io/jq/).
Requires [jq](https://jqlang.github.io/jq/), and either [Claude Code](https://code.claude.com/docs/en/overview) or [Cursor](https://cursor.com) 2.5+.

**Claude Code:**

```
/plugin marketplace add https://github.com/Prog-Jacob/habit.git
/plugin install habit@habit
```

Then:
**Cursor (2.5+):** use the manual install below.

**Manual (any version of either tool):**

```
/plugin install habit@habit
git clone https://github.com/Prog-Jacob/habit.git
cd habit && ./install.sh
```

For Cursor, merge `hooks/hooks.cursor.json` into `~/.cursor/hooks.json`.

---

## Usage

Habit observes every session through hooks. Use Claude Code normally. After enough prompts, skills suggest running `/habit:distill` to extract reusable patterns.

The `/habit:*` forms below are Claude Code syntax. On Cursor the same skills are invoked by name or in plain language (for example, ask to run `fix-types` in the auth module, or say "distill this session").

**Create a habit from a description:**

```
Expand Down Expand Up @@ -93,7 +103,9 @@ Pick by number or name. Add context to narrow a run (e.g., `1 only in auth`).

**Hooks** fire on every session. `SessionStart` registers the session. `UserPromptSubmit` increments a prompt counter (skips prompts under 5 words) and records the transcript path. `SessionEnd` saves a breadcrumb for later distill if any prompts were captured.

**Distill** runs in a forked subagent. It preloads the current transcript, the merged habit index, pending sessions, execution log, and metadata. It classifies each prompt as reusable or one-off, deduplicates against existing habits, and writes new or merged entries. `maintain` adds a restructure pass and processes plugin observations for self-improvement. `project` scans all `.jsonl` session files for the current working directory.
**Distill** runs in a forked subagent. It preloads the current transcript, the merged habit index, pending sessions, execution log, and metadata. It classifies each prompt as reusable or one-off, deduplicates against existing habits, and writes new or merged entries. Every sweep also runs self-improvement (below). `maintain` adds a full restructure pass: merging convergent habits, normalizing tags, and archiving stale entries. `project` scans all `.jsonl` session files for the current working directory.

**Self-improvement.** When a skill hits friction (a misroute, or a missing tool or path), it records an observation. Distill turns each actionable observation into a _learning_: a short standing note aimed at the skill it concerns, stored in `settings.local.json` next to your habits. Each skill reads its learnings on the next run and applies them as extra guidance. Because the overlay lives in your data directory rather than the plugin files, it survives updates and behaves identically on Claude Code and Cursor.

**Scope.** Habits live in `~/.claude/habits/` (global) or `.claude/habits/` (project). Project habits shadow global ones with the same id.

Expand Down Expand Up @@ -122,7 +134,7 @@ Prefer narrowing types over adding `as` casts.
## Uninstall

```
/plugin uninstall habit@prog-jacob-habit
/plugin uninstall habit@habit
```

Habit data in `~/.claude/habits/` is preserved. To delete it:
Expand Down
35 changes: 34 additions & 1 deletion bin/habit-tools.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,48 @@ case "$cmd" in
source "$SCRIPT_DIR/lib/query.sh" ;;
log-observation|read-observations|clear-observations)
source "$SCRIPT_DIR/lib/observation.sh" ;;
write-learning|read-learnings|prune-learnings)
source "$SCRIPT_DIR/lib/learnings.sh" ;;
env-context)
cmd_env_context() {
local root="${1:-$SCRIPT_DIR/..}"
if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
&& git -C "$root" remote get-url origin 2>/dev/null | grep -qiE '[/:]habit(\.git)?/?$'; then
echo "source"
else
echo "installed"
fi
}
;;
read-shared)
cmd_read_shared() { cat "$SCRIPT_DIR/../skills/habit-shared/${1:?filename required}"; }
;;
distill-preload)
source "$SCRIPT_DIR/lib/frontmatter.sh"
source "$SCRIPT_DIR/lib/habit.sh"
source "$SCRIPT_DIR/lib/query.sh"
cmd_read_shared() { cat "$SCRIPT_DIR/../skills/habit-shared/${1:?filename required}"; }
cmd_distill_preload() {
local sid="${1:-}"
# Each section is fault-isolated: one failing read (e.g. corrupt state)
# must not abort the rest, or the skill's "run again" guidance loops forever.
# Errors surface in the section body (2>&1) rather than being hidden.
printf '===TRANSCRIPT===\n'; ( cmd_read_transcript "$sid" ) 2>/dev/null || true
printf '\n===INDEX===\n'; cmd_read_index merged 2>&1 || true
printf '\n===PENDING===\n'; cmd_read_pending_distill 2>&1 || true
printf '\n===LOG===\n'; cmd_read_log 2>&1 || true
printf '\n===META-GLOBAL===\n'; cmd_read_meta global 2>&1 || true
printf '\n===META-PROJECT===\n'; cmd_read_meta project 2>&1 || true
printf '\n===PROCESSING===\n'; cmd_read_shared PROCESSING.md 2>&1 || true
printf '\n===OPERATIONS===\n'; cmd_read_shared OPERATIONS.md 2>&1 || true
}
;;
self-heal|reset-meta|prune-log|clear-pending-distill|mark-sessions-distilled)
source "$SCRIPT_DIR/lib/frontmatter.sh"
source "$SCRIPT_DIR/lib/maintenance.sh" ;;
*)
echo "Usage: habit-tools.sh <command> [args]" >&2
echo "Commands: read-index, read-habit, read-meta, read-transcript, read-sessions, list-new-sessions, read-prompt-count, read-pending-distill, read-log, read-shared, session-init, session-end, prompt-tick, watch, reset-prompt-count, clear-pending-distill, mark-sessions-distilled, check-triggers, write-habit, log-exec, self-heal, reset-meta, prune-log, log-observation, read-observations, clear-observations" >&2
echo "Commands: read-index, read-habit, read-meta, read-transcript, read-sessions, list-new-sessions, read-prompt-count, read-pending-distill, read-log, read-shared, env-context, distill-preload, session-init, session-end, prompt-tick, watch, reset-prompt-count, clear-pending-distill, mark-sessions-distilled, check-triggers, write-habit, log-exec, self-heal, reset-meta, prune-log, log-observation, read-observations, clear-observations, write-learning, read-learnings, prune-learnings" >&2
exit 1
;;
esac
Expand Down
43 changes: 41 additions & 2 deletions bin/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
PROJECT_DIR=".claude/habits"
GLOBAL_DIR="$HOME/.claude/habits"
STATE_FILE="settings.local.json"
DEFAULT_STATE='{"index":[],"meta":{"update_counter":0,"last_deep_timestamp":null,"distilled_project_sessions":{}},"log":[],"sessions":{},"observations":[]}'
DEFAULT_STATE='{"index":[],"meta":{"update_counter":0,"last_deep_timestamp":null,"distilled_project_sessions":{}},"log":[],"sessions":{},"observations":[],"learnings":[]}'

LOG_RETAIN=25
LOG_TRIGGER=50
LEARN_RETAIN=40
PROMPT_THRESHOLD=20

require_jq() {
Expand Down Expand Up @@ -44,3 +44,42 @@ if stat -f "%m" /dev/null &>/dev/null 2>&1; then
else
_file_mtime() { stat -c "%Y" "$1"; }
fi

# List every Cursor agent transcript for a workspace.
# Cursor stores them at agent-transcripts/<uuid>/<uuid>.jsonl; subagent
# transcripts are excluded. Single source of the path scheme and find.
# Usage: cursor_transcript_files [workspace_path]
cursor_transcript_files() {
local workspace="${1:-$PWD}"
local dir="$HOME/.cursor/projects/$(echo "${workspace#/}" | tr '/' '-')/agent-transcripts"
find "$dir" -maxdepth 2 -name "*.jsonl" -not -path "*/subagents/*" 2>/dev/null
}

# Claude Code stores project sessions under a slugified workspace path.
# Single source of that scheme, mirroring cursor_transcript_files.
# Usage: claude_project_dir [workspace_path]
claude_project_dir() { echo "$HOME/.claude/projects/$(echo "${1:-$PWD}" | tr '/' '-')"; }

# Most recent Cursor agent transcript for a workspace, or empty.
# Usage: cursor_transcript_path [workspace_path]
cursor_transcript_path() {
local found
found=$(cursor_transcript_files "${1:-$PWD}")
[ -z "$found" ] && { echo ""; return 0; }
echo "$found" | xargs ls -t 2>/dev/null | head -1 || echo ""
}

# Session breadcrumb: lets skills resolve the tool path and session id portably,
# independent of host (Claude Code or Cursor). Lives in the existing data dir.
# Written by the session-start hook path; read by skills via `source`.
breadcrumb_path() { echo "$GLOBAL_DIR/current"; }

write_breadcrumb() {
local session_id="${1:-}"
ensure_dir "$GLOBAL_DIR"
local body
body=$(printf 'HABIT_BIN=%s\nHABIT_SID=%s\n' "$SCRIPT_DIR/habit-tools.sh" "$session_id")
printf '%s' "$body" | atomic_write_file "$(breadcrumb_path)"
}

clear_breadcrumb() { rm -f "$(breadcrumb_path)" 2>/dev/null || true; }
31 changes: 31 additions & 0 deletions bin/lib/learnings.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# learnings.sh: Persistent self-improvement overlay. Survives plugin updates
# because it lives in the data directory, not the version-pinned plugin cache.

cmd_write_learning() {
local target="${1:-}" note="${2:-}" origin="${3:-}"
[ -z "$target" ] && { echo "Error: target required" >&2; exit 1; }
[ -z "$note" ] && { echo "Error: note required" >&2; exit 1; }
ensure_dir "$GLOBAL_DIR"
update_state "$GLOBAL_DIR" jq \
--arg t "$target" --arg n "$note" --arg o "$origin" --arg ts "$(now_utc)" \
'.learnings = ((.learnings // []) + [{target:$t, note:$n, origin:$o, created:$ts}])'
echo "OK learning written"
}

cmd_read_learnings() {
local target="${1:-}"
read_state "$GLOBAL_DIR" | jq -r --arg t "$target" '
[(.learnings // [])[] | select(.target == $t or .target == "global") | .note]
| if length == 0 then "" else join("\n") end'
}

cmd_prune_learnings() {
# De-dup by (target,note) keeping the first occurrence, then keep the last
# LEARN_RETAIN. reduce preserves insertion order; group_by/unique_by would not.
update_state "$GLOBAL_DIR" jq --argjson retain "$LEARN_RETAIN" '
.learnings = ((.learnings // [])
| reduce .[] as $l ([];
if any(.[]; .target == $l.target and .note == $l.note) then . else . + [$l] end)
| .[-$retain:])'
echo "OK pruned learnings"
}
Loading
Loading