@cullumco/cadence 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "displayName": "Cadence",
4
- "version": "0.1.2",
4
+ "version": "0.1.4",
5
5
  "description": "Ambient context for Claude Code: embodied signals, cadence dials, and finish-line guardrails. macOS-only alpha.",
6
6
  "author": {
7
7
  "name": "Cullum&Co",
package/README.md CHANGED
@@ -12,7 +12,7 @@ deaf to the room.
12
12
  **macOS-only (alpha).** Most signals read the Mac around you; other platforms
13
13
  degrade to self-report + dials + time/git.
14
14
 
15
- A [Cullum&Co](https://cullum.co) project.
15
+ A [Cullum&Co](https://cullum.co) project · [cullumco.github.io/cadence](https://cullumco.github.io/cadence/)
16
16
 
17
17
  ## What it does
18
18
 
@@ -21,8 +21,8 @@ Before Claude sees your prompt, Cadence injects a `<user_state>` block:
21
21
  ```
22
22
  <user_state>
23
23
  signals:
24
- music: "Loose" — Daniel Caesar (Spotify)
25
- vibe: sexy, chilled
24
+ music: "You Fail Me" — Converge (Spotify)
25
+ vibe: aggressive, energetic
26
26
  self_report: "two beers, shipping"
27
27
  cadence: # inferred from signals, advisory
28
28
  { pace=fast tone=warm posture=decisive proactivity=act-freely }
@@ -78,8 +78,10 @@ Same words. The room around them changed, and the agent finally saw it.
78
78
  Spotify login, no API key, no Premium.
79
79
  - **self-report** — what you tell it: `cadence state "two beers, shipping"`.
80
80
 
81
- Time/day and self-report move the dials; the rest render as context the agent
82
- reads (flavor). Git's nudges are built but dormant see `BACKLOG.md`.
81
+ Time/day, self-report, and git move the dials (git reads *what you're
82
+ doing*: 3+ commits/hr fast pace, mid-conflict verify-first); the rest
83
+ render as context the agent reads (flavor). Self-report always outranks
84
+ inference — "I'm shipping" beats a mid-conflict read.
83
85
  2. **Dials** — four independent knobs, each `low | medium | high`, inferred from
84
86
  the signals (or pinned by you):
85
87
  - **pace** — deliberate ↔ fast
@@ -228,10 +230,12 @@ See [`BACKLOG.md`](BACKLOG.md). Highlights:
228
230
  one long considered one.
229
231
  - **focused app** — what's frontmost next to the terminal (docs? a profiler?
230
232
  Slack?).
231
- - **scheduled Focus** — manual Focus detection ships now; scheduled/geofenced
232
- Focus needs schedule math against `ModeConfigurations.json`.
233
- - **After-the-fact injection** — refine the cadence mid-task (`PostToolUse`),
234
- building on the conservative finish-line `Stop` guard that now ships.
233
+ - **deeper Focus** — manual + scheduled Focus detection ship now; geofenced/
234
+ iPhone-synced Focus leaves no local trace and stays undetectable.
235
+ - **After-the-fact injection** — the first cut ships: a `PostToolUse` hook
236
+ watches git-ish commands and speaks exactly once when the repo enters or
237
+ leaves a merge/rebase conflict ("this is debug now" / "conflict resolved").
238
+ Next material events: failing-test transitions, reset/force-push thrash.
235
239
  - **Opt-in flavor providers** — horoscope, moon phase, for those who want them.
236
240
 
237
241
  ## Caveats
package/dist/cadence.js CHANGED
@@ -71,6 +71,16 @@ export function deriveCadence(state) {
71
71
  if (music?.vibe && /\b(calm|chilled|ethereal|romantic|warm)\b/.test(music.vibe)) {
72
72
  c.tone = "low";
73
73
  }
74
+ // ── git → pace / proactivity (what you're DOING, not what you said) ───────
75
+ // Enabled 2026-06-05 after the flavor proved trustworthy in real use.
76
+ // Applied below self-report on purpose: "I'm shipping" beats a mid-conflict
77
+ // read — the user's explicit word stays the higher authority.
78
+ if (git) {
79
+ if (git.commitsLastHour >= 3)
80
+ c.pace = "high"; // flow state
81
+ if (git.conflicted)
82
+ c.proactivity = "low"; // verify, don't barrel
83
+ }
74
84
  // ── self-report → posture / proactivity / tone (you know your state) ──────
75
85
  if (report) {
76
86
  const t = report.text.toLowerCase();
@@ -92,13 +102,8 @@ export function deriveCadence(state) {
92
102
  if (/\b(focused|formal|work|serious|crunch)\b/.test(t))
93
103
  c.tone = "high";
94
104
  }
95
- // ── git: FLAVOR ONLY for now (renders as context, no dial nudges yet) ─────
96
- // Candidate nudges, deliberately dormant until we've watched real output:
97
- // conflicted → proactivity low (verify, don't barrel)
98
- // commitsLastHour >= 3 → pace high (flow state)
105
+ // Still-dormant candidate nudges (see BACKLOG):
99
106
  // ambient focus on → proactivity high (heads-down = fewer check-ins)
100
- // See BACKLOG: turn these on once the flavor proves trustworthy.
101
- void git;
102
107
  // ── activity → pace (returning from a break = slow back down) ─────────────
103
108
  if (activity?.minSinceLastPrompt != null && activity.minSinceLastPrompt > 30) {
104
109
  c.pace = "low";
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { getGitSignal } from "./providers/git.js";
7
+ /* ─────────────────────────────────────────────────────────────────────────
8
+ * PostToolUse adapter — V2 "after-the-fact" refinement, conservative cut.
9
+ *
10
+ * The UserPromptSubmit lens is predictive: it reads AMBIENT state before any
11
+ * work happens. This hook watches the work itself and speaks only when the
12
+ * observed work MATERIALLY changes the cadence read. V2's single material
13
+ * event: the repo entering or leaving a merge/rebase conflict — the
14
+ * strongest debug tell we have, far stronger than music ever was.
15
+ *
16
+ * Discipline (the BACKLOG's hard constraint):
17
+ * - fires only after Bash tool calls whose command mentions git
18
+ * - injects at most once per conflict-state TRANSITION, never per tool
19
+ * - silent in every other case, silent on every error
20
+ * ───────────────────────────────────────────────────────────────────────── */
21
+ const TOTAL_BUDGET_MS = 1500;
22
+ const STATE_FILE = join(homedir(), ".cadence", "workstate.json");
23
+ const MAX_SESSIONS = 20;
24
+ // Gate 1: is this tool call even capable of changing git conflict state?
25
+ // Only Bash commands that mention git — Edit/Read/etc. can't start a merge,
26
+ // and checking the repo after every tool call would betray the silence rule.
27
+ export function shouldCheck(input) {
28
+ if (input.tool_name !== "Bash")
29
+ return false;
30
+ const cmd = input.tool_input?.command;
31
+ return typeof cmd === "string" && /\bgit\b/.test(cmd);
32
+ }
33
+ // Gate 2: did the observed state actually TRANSITION? Speak only on the edge.
34
+ // undefined → true first observation reveals a conflict → speak
35
+ // false → true work just entered a conflict → speak
36
+ // true → false conflict resolved → speak (release)
37
+ // anything else no change → silent
38
+ export function refineContext(prev, conflicted) {
39
+ if (prev === conflicted)
40
+ return null;
41
+ if (conflicted) {
42
+ return ("<user_state_update>observed work: the repo just entered a merge/rebase " +
43
+ "conflict. Read the cadence as debug now — verify the repo state, lead " +
44
+ "with hypotheses, and don't barrel toward shipping until it's resolved. " +
45
+ "If the user's words clearly mean otherwise, follow their words.</user_state_update>");
46
+ }
47
+ if (prev === true) {
48
+ return ("<user_state_update>observed work: the conflict is resolved — drop the " +
49
+ "debug framing and return to the user's prior cadence.</user_state_update>");
50
+ }
51
+ return null; // first observation of a clean repo: record silently
52
+ }
53
+ async function loadState() {
54
+ try {
55
+ const raw = JSON.parse(await readFile(STATE_FILE, "utf-8"));
56
+ return raw && typeof raw === "object" ? raw : {};
57
+ }
58
+ catch {
59
+ return {};
60
+ }
61
+ }
62
+ async function saveState(state) {
63
+ try {
64
+ // prune to the newest MAX_SESSIONS so the file can't grow unbounded
65
+ const entries = Object.entries(state)
66
+ .sort((a, b) => b[1].at - a[1].at)
67
+ .slice(0, MAX_SESSIONS);
68
+ await mkdir(join(homedir(), ".cadence"), { recursive: true });
69
+ await writeFile(STATE_FILE, JSON.stringify(Object.fromEntries(entries)), "utf-8");
70
+ }
71
+ catch {
72
+ // best-effort; a failed save just means we might speak twice
73
+ }
74
+ }
75
+ async function readStdin() {
76
+ if (process.stdin.isTTY)
77
+ return {};
78
+ let raw = "";
79
+ for await (const chunk of process.stdin)
80
+ raw += chunk;
81
+ try {
82
+ return JSON.parse(raw);
83
+ }
84
+ catch {
85
+ return {};
86
+ }
87
+ }
88
+ async function main() {
89
+ const input = await readStdin();
90
+ if (!shouldCheck(input))
91
+ return;
92
+ const git = await Promise.race([
93
+ getGitSignal(input.cwd ?? process.cwd()).catch(() => null),
94
+ new Promise((resolve) => setTimeout(() => resolve(null), TOTAL_BUDGET_MS)),
95
+ ]);
96
+ if (!git)
97
+ return; // not a repo / git unavailable → nothing to observe
98
+ const key = input.session_id ?? "default";
99
+ const state = await loadState();
100
+ const prev = state[key]?.conflicted;
101
+ const message = refineContext(prev, git.conflicted);
102
+ state[key] = { conflicted: git.conflicted, at: Date.now() };
103
+ await saveState(state);
104
+ if (message) {
105
+ process.stdout.write(JSON.stringify({
106
+ hookSpecificOutput: {
107
+ hookEventName: "PostToolUse",
108
+ additionalContext: message,
109
+ },
110
+ }));
111
+ }
112
+ }
113
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
114
+ main().catch((err) => {
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ process.stderr.write(`cadence posttool: ${msg}\n`);
117
+ process.exit(0); // never block the tool loop
118
+ });
119
+ }
@@ -22,7 +22,9 @@ function sh(cmd, ms = 500) {
22
22
  * "Put the vibes back into engineering": this is the atmosphere layer.
23
23
  * ───────────────────────────────────────────────────────────────────────── */
24
24
  const CONFIG_FILE = join(homedir(), ".cadence", "config.json");
25
- const DND_ASSERTIONS = join(homedir(), "Library", "DoNotDisturb", "DB", "Assertions.json");
25
+ const DND_DIR = join(homedir(), "Library", "DoNotDisturb", "DB");
26
+ const DND_ASSERTIONS = join(DND_DIR, "Assertions.json");
27
+ const DND_MODE_CONFIGS = join(DND_DIR, "ModeConfigurations.json");
26
28
  const WEATHER_TIMEOUT_MS = 900;
27
29
  const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
28
30
  function partOfDay(hour) {
@@ -63,27 +65,78 @@ function getVitals() {
63
65
  const load1 = loadavg()[0] ?? 0;
64
66
  return { uptimeHours, loadHigh: load1 / cores > 0.8 };
65
67
  }
68
+ // Is any SCHEDULED Focus window active right now? Pure function over the
69
+ // parsed ModeConfigurations.json (exported for fixture tests). The shape is
70
+ // Apple-private but stable Monterey→Tahoe: each mode's triggers carry
71
+ // enabledSetting (2 = on) and a start/end time-of-day window that may wrap
72
+ // midnight (22:00 → 07:00). Anything unexpected reads as "not active".
73
+ export function scheduleActive(json, now) {
74
+ try {
75
+ const configs = json?.data?.[0]?.modeConfigurations;
76
+ if (!configs || typeof configs !== "object")
77
+ return false;
78
+ const minutes = now.getHours() * 60 + now.getMinutes();
79
+ for (const mode of Object.values(configs)) {
80
+ const triggers = mode?.triggers?.triggers;
81
+ if (!Array.isArray(triggers))
82
+ continue;
83
+ for (const t of triggers) {
84
+ const trig = t;
85
+ if (trig?.enabledSetting !== 2)
86
+ continue; // 2 = schedule enabled
87
+ const { timePeriodStartTimeHour: sh, timePeriodEndTimeHour: eh } = trig;
88
+ if (typeof sh !== "number" || typeof eh !== "number")
89
+ continue;
90
+ const start = sh * 60 + (trig.timePeriodStartTimeMinute ?? 0);
91
+ const end = eh * 60 + (trig.timePeriodEndTimeMinute ?? 0);
92
+ const active = start < end
93
+ ? minutes >= start && minutes < end
94
+ : minutes >= start || minutes < end; // window wraps midnight
95
+ if (active)
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
66
105
  // Focus / DND — tri-state, read straight from the private donotdisturbd DB
67
106
  // (~1ms, no subprocess). Exported for the darwin smoke test.
68
- // true → a Focus mode is asserted (manually toggled on this device)
69
- // false → file read OK, no assertion records → focus is off
107
+ // true → a Focus mode is asserted (manual toggle) OR a scheduled
108
+ // Focus window is active right now
109
+ // false → assertions read OK, none set, no schedule active → off
70
110
  // undefined → unreadable: terminal lacks Full Disk Access (TCC denies the
71
111
  // read silently — hook subprocesses never get a prompt), file
72
112
  // moved, or shape changed → "unavailable", never "off"
73
- // Known gap: SCHEDULED/geofenced Focus writes no assertion record (detecting
74
- // it needs ModeConfigurations.json schedule math backlogged).
75
- export async function getFocus() {
113
+ // Remaining gap: geofenced/iPhone-synced Focus writes neither an assertion
114
+ // nor a local schedule — undetectable from this Mac.
115
+ export async function getFocus(now = new Date()) {
76
116
  if (process.platform !== "darwin")
77
117
  return undefined;
118
+ let manual;
78
119
  try {
79
120
  const raw = await readFile(DND_ASSERTIONS, "utf-8");
80
121
  const json = JSON.parse(raw);
81
122
  const records = json.data?.[0]?.storeAssertionRecords;
82
- return Array.isArray(records) && records.length > 0;
123
+ manual = Array.isArray(records) && records.length > 0;
83
124
  }
84
125
  catch {
85
- return undefined;
126
+ manual = undefined;
127
+ }
128
+ if (manual)
129
+ return true;
130
+ // Manual focus is off (or unknowable) — a scheduled window may still be on.
131
+ try {
132
+ const raw = await readFile(DND_MODE_CONFIGS, "utf-8");
133
+ if (scheduleActive(JSON.parse(raw), now))
134
+ return true;
135
+ }
136
+ catch {
137
+ // both files unreadable → truly unknown
86
138
  }
139
+ return manual;
87
140
  }
88
141
  // ── mac context: best-effort shell-outs, all flavor (no dial nudges) ─────────
89
142
  async function getMacContext() {
package/dist/vibe.js CHANGED
@@ -37,11 +37,55 @@ const GENRE_AFFECT = {
37
37
  pop: { energy: 0.72, valence: 0.78, acoustic: 0.15, moods: ["happy", "uplifting"] },
38
38
  funk: { energy: 0.75, valence: 0.8, acoustic: 0.1, moods: ["happy", "energetic"] },
39
39
  disco: { energy: 0.8, valence: 0.85, acoustic: 0.1, moods: ["happy", "uplifting"] },
40
+ "post-hardcore": { energy: 0.85, valence: 0.45, acoustic: 0.05, moods: ["aggressive", "energetic"] },
41
+ "post-punk": { energy: 0.7, valence: 0.4, acoustic: 0.1, moods: ["dark", "energetic"] },
42
+ screamo: { energy: 0.9, valence: 0.3, acoustic: 0.03, moods: ["aggressive", "dark"] },
43
+ grindcore: { energy: 0.98, valence: 0.3, acoustic: 0.02, moods: ["aggressive", "scary"] },
44
+ grunge: { energy: 0.75, valence: 0.4, acoustic: 0.15, moods: ["aggressive", "dark"] },
45
+ emo: { energy: 0.7, valence: 0.35, acoustic: 0.2, moods: ["sad", "energetic"] },
46
+ stoner: { energy: 0.7, valence: 0.5, acoustic: 0.15, moods: ["dark", "energetic"] },
47
+ sludge: { energy: 0.65, valence: 0.25, acoustic: 0.1, moods: ["dark", "aggressive"] },
48
+ industrial: { energy: 0.8, valence: 0.35, acoustic: 0.02, moods: ["dark", "aggressive"] },
49
+ noise: { energy: 0.7, valence: 0.25, acoustic: 0.05, moods: ["scary", "aggressive"] },
50
+ trance: { energy: 0.85, valence: 0.65, acoustic: 0.02, moods: ["energetic", "uplifting"] },
51
+ dubstep: { energy: 0.85, valence: 0.5, acoustic: 0.02, moods: ["energetic", "dark"] },
52
+ edm: { energy: 0.85, valence: 0.7, acoustic: 0.02, moods: ["energetic", "uplifting"] },
53
+ trap: { energy: 0.75, valence: 0.5, acoustic: 0.05, moods: ["energetic", "dark"] },
54
+ drill: { energy: 0.75, valence: 0.4, acoustic: 0.05, moods: ["dark", "aggressive"] },
55
+ grime: { energy: 0.8, valence: 0.5, acoustic: 0.05, moods: ["aggressive", "energetic"] },
56
+ "k-pop": { energy: 0.8, valence: 0.8, acoustic: 0.08, moods: ["happy", "energetic"] },
57
+ "j-pop": { energy: 0.78, valence: 0.8, acoustic: 0.1, moods: ["happy", "uplifting"] },
58
+ "new wave": { energy: 0.7, valence: 0.65, acoustic: 0.08, moods: ["energetic", "happy"] },
59
+ synthpop: { energy: 0.7, valence: 0.7, acoustic: 0.05, moods: ["happy", "energetic"] },
60
+ "synth-pop": { energy: 0.7, valence: 0.7, acoustic: 0.05, moods: ["happy", "energetic"] },
61
+ reggaeton: { energy: 0.8, valence: 0.75, acoustic: 0.05, moods: ["sexy", "energetic"] },
62
+ latin: { energy: 0.75, valence: 0.75, acoustic: 0.3, moods: ["happy", "energetic"] },
63
+ salsa: { energy: 0.8, valence: 0.8, acoustic: 0.4, moods: ["happy", "energetic"] },
64
+ cumbia: { energy: 0.65, valence: 0.75, acoustic: 0.4, moods: ["happy"] },
65
+ afrobeat: { energy: 0.75, valence: 0.75, acoustic: 0.3, moods: ["happy", "energetic"] },
66
+ ska: { energy: 0.8, valence: 0.75, acoustic: 0.15, moods: ["happy", "energetic"] },
67
+ surf: { energy: 0.7, valence: 0.7, acoustic: 0.2, moods: ["happy", "energetic"] },
68
+ "math rock": { energy: 0.75, valence: 0.55, acoustic: 0.2, moods: ["energetic"] },
40
69
  // ── mid / groovy → ship-or-think depending on energy ─────────────────
41
70
  "r&b": { energy: 0.55, valence: 0.6, acoustic: 0.2, moods: ["sexy", "chilled"] },
42
71
  soul: { energy: 0.55, valence: 0.65, acoustic: 0.25, moods: ["romantic", "uplifting"] },
43
72
  reggae: { energy: 0.6, valence: 0.75, acoustic: 0.2, moods: ["chilled", "happy"] },
44
73
  indie: { energy: 0.6, valence: 0.55, acoustic: 0.3, moods: ["chilled"] },
74
+ alternative: { energy: 0.65, valence: 0.5, acoustic: 0.2, moods: ["energetic"] },
75
+ progressive: { energy: 0.65, valence: 0.5, acoustic: 0.15, moods: ["epic"] },
76
+ psychedelic: { energy: 0.6, valence: 0.55, acoustic: 0.3, moods: ["ethereal"] },
77
+ krautrock: { energy: 0.6, valence: 0.5, acoustic: 0.2, moods: ["ethereal", "energetic"] },
78
+ synthwave: { energy: 0.65, valence: 0.55, acoustic: 0.02, moods: ["energetic", "ethereal"] },
79
+ "city pop": { energy: 0.6, valence: 0.7, acoustic: 0.3, moods: ["happy", "chilled"] },
80
+ garage: { energy: 0.75, valence: 0.6, acoustic: 0.1, moods: ["energetic"] },
81
+ country: { energy: 0.55, valence: 0.6, acoustic: 0.7, moods: ["happy", "romantic"] },
82
+ bluegrass: { energy: 0.65, valence: 0.7, acoustic: 0.9, moods: ["happy", "uplifting"] },
83
+ americana: { energy: 0.45, valence: 0.5, acoustic: 0.8, moods: ["calm", "romantic"] },
84
+ gospel: { energy: 0.6, valence: 0.8, acoustic: 0.6, moods: ["uplifting", "epic"] },
85
+ swing: { energy: 0.65, valence: 0.75, acoustic: 0.6, moods: ["happy"] },
86
+ "big band": { energy: 0.65, valence: 0.7, acoustic: 0.6, moods: ["happy", "epic"] },
87
+ samba: { energy: 0.7, valence: 0.8, acoustic: 0.5, moods: ["happy"] },
88
+ flamenco: { energy: 0.6, valence: 0.5, acoustic: 0.85, moods: ["romantic", "epic"] },
45
89
  // ── low energy, organic → think ──────────────────────────────────────
46
90
  ambient: { energy: 0.2, valence: 0.5, acoustic: 0.6, moods: ["ethereal", "calm"] },
47
91
  "lo-fi": { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
@@ -59,23 +103,41 @@ const GENRE_AFFECT = {
59
103
  "singer-songwriter": { energy: 0.4, valence: 0.5, acoustic: 0.7, moods: ["calm", "sad"] },
60
104
  "post-rock": { energy: 0.5, valence: 0.4, acoustic: 0.4, moods: ["epic", "ethereal"] },
61
105
  shoegaze: { energy: 0.55, valence: 0.4, acoustic: 0.3, moods: ["ethereal", "dark"] },
106
+ "dream pop": { energy: 0.45, valence: 0.5, acoustic: 0.3, moods: ["ethereal", "chilled"] },
107
+ "bedroom pop": { energy: 0.45, valence: 0.55, acoustic: 0.3, moods: ["chilled"] },
108
+ vaporwave: { energy: 0.35, valence: 0.45, acoustic: 0.1, moods: ["ethereal", "chilled"] },
109
+ idm: { energy: 0.55, valence: 0.45, acoustic: 0.05, moods: ["ethereal"] },
110
+ "bossa nova": { energy: 0.35, valence: 0.6, acoustic: 0.7, moods: ["chilled", "romantic"] },
111
+ "new age": { energy: 0.2, valence: 0.6, acoustic: 0.7, moods: ["calm", "ethereal"] },
112
+ soundtrack: { energy: 0.45, valence: 0.45, acoustic: 0.6, moods: ["epic"] },
113
+ opera: { energy: 0.5, valence: 0.45, acoustic: 0.9, moods: ["epic"] },
114
+ choral: { energy: 0.3, valence: 0.5, acoustic: 0.95, moods: ["ethereal", "epic"] },
62
115
  // ── low energy, low valence → debug-leaning ──────────────────────────
63
116
  blues: { energy: 0.45, valence: 0.35, acoustic: 0.5, moods: ["sad", "dark"] },
117
+ goth: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
118
+ gothic: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
119
+ darkwave: { energy: 0.6, valence: 0.35, acoustic: 0.1, moods: ["dark", "ethereal"] },
120
+ drone: { energy: 0.15, valence: 0.3, acoustic: 0.4, moods: ["dark", "ethereal"] },
64
121
  slowcore: { energy: 0.25, valence: 0.3, acoustic: 0.5, moods: ["sad", "dark"] },
65
122
  sad: { energy: 0.3, valence: 0.2, acoustic: 0.5, moods: ["sad"] },
66
123
  melancholy: { energy: 0.3, valence: 0.25, acoustic: 0.5, moods: ["sad", "dark"] },
67
124
  melancholic: { energy: 0.3, valence: 0.25, acoustic: 0.5, moods: ["sad", "dark"] },
68
125
  doom: { energy: 0.5, valence: 0.2, acoustic: 0.2, moods: ["dark", "scary"] },
69
126
  };
127
+ // Keys sorted longest-first so the most SPECIFIC row wins per tag: a
128
+ // "post-rock" tag must hit the post-rock row, never fall through to "rock".
129
+ // (Insertion-order matching silently mis-filed every sub-genre whose parent
130
+ // appeared earlier in the table.)
131
+ const GENRE_KEYS = Object.keys(GENRE_AFFECT).sort((a, b) => b.length - a.length);
70
132
  /** Match a list of raw tags against the affect table and aggregate. */
71
133
  export function tagsToVibe(tags) {
72
134
  const hits = [];
73
135
  for (const raw of tags) {
74
136
  const t = raw.toLowerCase();
75
- for (const key in GENRE_AFFECT) {
137
+ for (const key of GENRE_KEYS) {
76
138
  if (t.includes(key)) {
77
139
  hits.push(GENRE_AFFECT[key]);
78
- break; // one row per tag — don't double-count "trip hop" as "hop"
140
+ break; // one row per tag — the longest (most specific) match wins
79
141
  }
80
142
  }
81
143
  }
package/hooks/hooks.json CHANGED
@@ -22,6 +22,17 @@
22
22
  ]
23
23
  }
24
24
  ],
25
+ "PostToolUse": [
26
+ {
27
+ "matcher": "Bash",
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/posttool.js\""
32
+ }
33
+ ]
34
+ }
35
+ ],
25
36
  "Stop": [
26
37
  {
27
38
  "matcher": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cullumco/cadence",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Agents that read the room. Ambient context, cadence dials, and finish-line guardrails for Claude Code. macOS-only alpha.",
5
5
  "type": "module",
6
6
  "bin": {